Skip to content

Commit 45470cc

Browse files
committed
Add OA PDF resolver: Unpaywall + arXiv title fallback for paywalled-source papers
Why --- Most IEEE / ACM / Springer / Elsevier results come back from their plugins with pdf_url=None because the publisher sites are paywalled even when the paper itself is open access. The OA copy usually exists somewhere else - the author's institutional repo, an arXiv preprint, ResearchGate, etc. - and Unpaywall indexes ~50M of them keyed by DOI. Without this resolver the pipeline's per-paper PPT emit gate cuts those papers because pdf_url is missing, even though a downloadable PDF was one HTTP roundtrip away. How --- New module autopapertoppt/core/oa_resolver.py runs after dedup + rank + top-tier filter. For every paper still missing pdf_url: 1. Unpaywall by DOI via https://api.unpaywall.org/v2/{doi}. Requires AUTOPAPERTOPPT_CONTACT_EMAIL for politeness; skipped silently with a one-shot WARNING when unset. Returns best_oa_location.url_for_pdf when found. 2. arXiv title search for papers without DOI (or where Unpaywall missed). Uses arXiv's field-restricted ti:"<title>" syntax, accepts only exact normalised-title matches so loosely similar titles do not get adopted by accident. Both lookups are best-effort and never raise; concurrency capped at 5 by a semaphore; HTTPS-only enforced by the existing transport wrapper. Surfaces -------- - run_search now takes resolve_oa: bool = True. Default ON. - CLI: --no-oa-resolve flag to skip the resolver per run. - All existing fake_run_search mocks across tests/test_cli.py, tests/test_mcp_tools.py, tests/gui/test_search_page.py updated to accept **_kwargs so the new kwarg does not break them. Note: OpenAlex and Semantic Scholar parsers ALREADY surface their OA URL fields (best_oa_location.pdf_url, openAccessPdf.url) - this PR doesn't touch them. The resolver only kicks in for papers whose source plugin returns no pdf_url, which is almost exclusively IEEE / ACM (via Crossref) / DBLP / paywalled OpenAlex hits. Tests ----- +11 tests in tests/test_oa_resolver.py covering: early-exit when all papers have pdf_url, Unpaywall happy path, arXiv fallback when Unpaywall misses, both miss, email-unset skip, one-shot warning flag, fuzzy title matching, arxiv-sourced paper skip, exact-match only, https-only enforcement. Docs ---- configuration.md: AUTOPAPERTOPPT_CONTACT_EMAIL row mentions the new Unpaywall use. architecture.md: pipeline diagram updated + new "OA PDF resolution" subsection. cli.md: --no-oa-resolve row. 462 tests pass, ruff + bandit clean.
1 parent 3453c02 commit 45470cc

10 files changed

Lines changed: 499 additions & 16 deletions

File tree

autopapertoppt/cli.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,19 @@ def build_parser() -> argparse.ArgumentParser:
255255
),
256256
)
257257
parser.set_defaults(top_tier_only=True)
258+
parser.add_argument(
259+
"--no-oa-resolve",
260+
dest="resolve_oa",
261+
action="store_false",
262+
help=(
263+
"Skip the open-access PDF resolver step that runs after dedup. "
264+
"By default the pipeline looks up every paper without pdf_url "
265+
"in Unpaywall (needs AUTOPAPERTOPPT_CONTACT_EMAIL) and falls "
266+
"back to an arXiv title search — typical lift of 40-70 percent "
267+
"for IEEE / ACM / Springer / Elsevier paywalled papers."
268+
),
269+
)
270+
parser.set_defaults(resolve_oa=True)
258271
parser.add_argument(
259272
"--paywall-threshold",
260273
type=float,
@@ -540,7 +553,7 @@ async def _collect(args: argparse.Namespace):
540553
top_tier_only=args.top_tier_only,
541554
)
542555
_LOG.info("Running search: %s across %s", keywords, ", ".join(sources))
543-
return await run_search(query)
556+
return await run_search(query, resolve_oa=args.resolve_oa)
544557

545558

546559
def _resolve_enrich_mode(args: argparse.Namespace) -> str:

autopapertoppt/core/oa_resolver.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Post-dedup PDF resolver — fill missing pdf_url from open-access aggregators.
2+
3+
Why this exists
4+
---------------
5+
Most IEEE / ACM / Springer / Elsevier papers come back from their
6+
respective source plugins with ``pdf_url=None`` because the publisher
7+
sites are paywalled even when the paper itself is open access. The OA
8+
copy almost always exists somewhere else — the author's institutional
9+
repository, an arXiv preprint, ResearchGate, etc. — and Unpaywall
10+
indexes ~50M of them keyed by DOI.
11+
12+
This module runs after dedup and tries two strategies in order for
13+
every paper that still lacks a pdf_url:
14+
15+
1. **Unpaywall** (https://unpaywall.org/products/api). Free, no API
16+
key required, but needs an email in the query string per their
17+
politeness contract. Pulled from ``AUTOPAPERTOPPT_CONTACT_EMAIL``
18+
(same env var Crossref / OpenAlex use); skipped silently when
19+
unset, with a one-time WARNING log so the user knows what they're
20+
missing.
21+
22+
2. **arXiv title search**. For papers without a DOI (or where
23+
Unpaywall returned no hit), search arXiv with the paper's title
24+
and accept the first result whose normalised title matches
25+
exactly. Covers the many CS papers that have an arXiv preprint
26+
but reach the pipeline via OpenAlex / Crossref / DBLP without
27+
the arXiv ID populated.
28+
29+
Both lookups are best-effort: any failure logs at DEBUG and the
30+
paper passes through unchanged. The resolver never raises.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import asyncio
36+
import dataclasses
37+
import os
38+
from typing import Any
39+
40+
import httpx
41+
42+
from autopapertoppt.core.exceptions import FetchError
43+
from autopapertoppt.core.models import Paper, PaperCollection, Query
44+
from autopapertoppt.fetchers.http import get_client
45+
from autopapertoppt.utils.logging import get_logger
46+
47+
_LOG = get_logger(__name__)
48+
49+
_UNPAYWALL_ENDPOINT = "https://api.unpaywall.org/v2"
50+
_UNPAYWALL_SOURCE = "unpaywall"
51+
_LOOKUP_TIMEOUT_SECONDS = 10.0
52+
_CONCURRENCY = 5
53+
54+
# One-shot warning so we don't spam logs for every paper in a large run.
55+
_email_warning_emitted = False
56+
57+
58+
async def resolve_oa_pdfs(collection: PaperCollection) -> PaperCollection:
59+
"""Try to fill ``pdf_url`` for every paper currently missing one.
60+
61+
Returns a new ``PaperCollection`` with the same query and same
62+
paper count. Papers that already have ``pdf_url`` pass through
63+
unchanged.
64+
"""
65+
missing = sum(1 for p in collection.papers if not p.pdf_url)
66+
if missing == 0:
67+
return collection
68+
69+
_LOG.info(
70+
"OA resolver: looking up %d / %d papers without pdf_url",
71+
missing,
72+
len(collection.papers),
73+
)
74+
75+
semaphore = asyncio.Semaphore(_CONCURRENCY)
76+
resolved = await asyncio.gather(
77+
*(_resolve_one(paper, semaphore) for paper in collection.papers)
78+
)
79+
found = sum(
80+
1
81+
for old, new in zip(collection.papers, resolved, strict=True)
82+
if not old.pdf_url and new.pdf_url
83+
)
84+
if found:
85+
_LOG.info(
86+
"OA resolver: filled %d / %d missing pdf_url (Unpaywall + arXiv)",
87+
found,
88+
missing,
89+
)
90+
return PaperCollection(query=collection.query, papers=tuple(resolved))
91+
92+
93+
async def _resolve_one(paper: Paper, semaphore: asyncio.Semaphore) -> Paper:
94+
if paper.pdf_url:
95+
return paper
96+
async with semaphore:
97+
# 1. Unpaywall by DOI — fastest path, highest precision.
98+
if paper.doi:
99+
pdf = await _query_unpaywall(paper.doi)
100+
if pdf:
101+
_LOG.debug("Unpaywall hit for %s: %s", paper.bibtex_key(), pdf)
102+
return dataclasses.replace(paper, pdf_url=pdf)
103+
# 2. arXiv title search — covers DOI-less papers + DOIs missed
104+
# by Unpaywall.
105+
pdf = await _query_arxiv_title(paper)
106+
if pdf:
107+
_LOG.debug("arXiv title hit for %s: %s", paper.bibtex_key(), pdf)
108+
return dataclasses.replace(paper, pdf_url=pdf)
109+
return paper
110+
111+
112+
async def _query_unpaywall(doi: str) -> str | None:
113+
"""Look up a DOI in Unpaywall; return the best OA PDF URL or None."""
114+
email = os.environ.get("AUTOPAPERTOPPT_CONTACT_EMAIL", "").strip()
115+
if not email:
116+
_warn_once_about_email()
117+
return None
118+
client = await get_client(_UNPAYWALL_SOURCE)
119+
try:
120+
response = await asyncio.wait_for(
121+
client.get(
122+
f"{_UNPAYWALL_ENDPOINT}/{doi}",
123+
params={"email": email},
124+
),
125+
timeout=_LOOKUP_TIMEOUT_SECONDS,
126+
)
127+
except (TimeoutError, httpx.HTTPError, FetchError) as err:
128+
_LOG.debug("Unpaywall lookup failed for %s: %s", doi, err)
129+
return None
130+
if response.status_code == 404:
131+
return None # not indexed
132+
if response.status_code != 200:
133+
_LOG.debug(
134+
"Unpaywall returned %s for %s: %s",
135+
response.status_code, doi, response.text[:128],
136+
)
137+
return None
138+
try:
139+
data: dict[str, Any] = response.json()
140+
except ValueError:
141+
return None
142+
best_oa = data.get("best_oa_location") or {}
143+
candidate = (best_oa.get("url_for_pdf") or "").strip()
144+
if candidate.startswith("https://"):
145+
return candidate
146+
return None
147+
148+
149+
async def _query_arxiv_title(paper: Paper) -> str | None:
150+
"""Search arXiv by title; return the matching paper's PDF URL or None.
151+
152+
Match is exact on the normalised title (alphanumeric + lowercase)
153+
so a "transformer" paper doesn't accidentally claim someone else's
154+
"transformer architecture for X" preprint.
155+
"""
156+
if not paper.title:
157+
return None
158+
# Skip the round-trip if the paper is already from arXiv — its
159+
# plugin would have populated pdf_url at parse time if a PDF
160+
# existed.
161+
if paper.source == "arxiv":
162+
return None
163+
try:
164+
from autopapertoppt.fetchers.base import load_fetcher
165+
except ImportError:
166+
return None
167+
try:
168+
fetcher = load_fetcher("arxiv")
169+
except Exception: # noqa: BLE001 — load failures must not break the resolver
170+
return None
171+
172+
# arXiv's API supports field-restricted queries; ti:"<title>" looks
173+
# only at the title field. Pull the top 3 in case the first is a
174+
# later version of a different paper with similar words.
175+
query = Query(
176+
keywords=f'ti:"{paper.title}"',
177+
sources=("arxiv",),
178+
max_results=3,
179+
)
180+
try:
181+
results = await fetcher.search(query)
182+
except Exception as err: # noqa: BLE001 — best-effort
183+
_LOG.debug("arXiv title search failed for %r: %s", paper.title, err)
184+
return None
185+
186+
target = _normalise_title(paper.title)
187+
for candidate in results:
188+
if (
189+
_normalise_title(candidate.title) == target
190+
and candidate.pdf_url
191+
and candidate.pdf_url.startswith("https://")
192+
):
193+
return candidate.pdf_url
194+
return None
195+
196+
197+
def _normalise_title(text: str) -> str:
198+
"""Lowercase + drop non-alphanumeric for fuzzy title comparison."""
199+
return "".join(c.lower() for c in text if c.isalnum())
200+
201+
202+
def _warn_once_about_email() -> None:
203+
"""Log a single WARNING line when CONTACT_EMAIL is unset.
204+
205+
Module-global flag rather than logging-stdlib filter because we
206+
want the warning per-process, not per-logger-handler, and the
207+
fewer moving parts the better.
208+
"""
209+
global _email_warning_emitted # noqa: PLW0603 — intentional one-shot flag
210+
if _email_warning_emitted:
211+
return
212+
_email_warning_emitted = True
213+
_LOG.warning(
214+
"OA resolver: AUTOPAPERTOPPT_CONTACT_EMAIL is not set; "
215+
"Unpaywall lookups (the biggest PDF coverage win for IEEE / "
216+
"ACM / Springer / Elsevier papers) will be skipped. Set the "
217+
"env var to your email to enable them."
218+
)

autopapertoppt/core/pipeline.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424
from autopapertoppt.core.identifiers import PaperIdentifier
2525
from autopapertoppt.core.models import Paper, PaperCollection, Query
26+
from autopapertoppt.core.oa_resolver import resolve_oa_pdfs
2627
from autopapertoppt.core.ranking import rank
2728
from autopapertoppt.core.top_venues import is_top_tier
2829
from autopapertoppt.fetchers.base import load_fetcher
@@ -31,11 +32,19 @@
3132
_LOG = get_logger(__name__)
3233

3334

34-
async def run_search(query: Query) -> PaperCollection:
35+
async def run_search(
36+
query: Query, *, resolve_oa: bool = True
37+
) -> PaperCollection:
3538
"""Run `query` across its sources concurrently and produce a collection.
3639
3740
Source plugins that fail to load (e.g. an opt-in plugin whose env var
3841
is unset) are skipped with a warning so the rest of the mix still runs.
42+
43+
``resolve_oa`` (default True) runs the OA PDF resolver after dedup +
44+
rank + top-tier filter so papers whose source returned no ``pdf_url``
45+
(typical for IEEE / ACM / Springer / Elsevier) get a chance to pick
46+
up an open-access mirror from Unpaywall or an arXiv preprint.
47+
Pass ``False`` from tests or CLI flags that want raw source output.
3948
"""
4049
fetchers = [
4150
loaded
@@ -58,7 +67,12 @@ async def run_search(query: Query) -> PaperCollection:
5867
_LOG.info(
5968
"top-tier filter kept %d / %d papers", len(ordered), before
6069
)
61-
return PaperCollection(query=query, papers=tuple(ordered[: query.max_results]))
70+
collection = PaperCollection(
71+
query=query, papers=tuple(ordered[: query.max_results])
72+
)
73+
if resolve_oa:
74+
collection = await resolve_oa_pdfs(collection)
75+
return collection
6276

6377

6478
def _load_fetcher_safe(name: str):

docs/architecture.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ came back.
126126
└──────────┘
127127
128128
129+
(optional) top-tier filter
130+
131+
132+
┌────────────────┐
133+
│ oa_resolver │ Unpaywall + arXiv title fallback —
134+
└────────────────┘ fills pdf_url for paywalled-source papers
135+
136+
129137
(optional) enrich PDF → PaperSummary
130138
131139
@@ -137,6 +145,27 @@ came back.
137145
└───────────────┘
138146
```
139147

148+
### OA PDF resolution
149+
150+
`autopapertoppt.core.oa_resolver` runs after dedup + rank + top-tier
151+
filter. For every paper still missing `pdf_url`:
152+
153+
1. **Unpaywall** (https://api.unpaywall.org/v2/{doi}) — free, no API
154+
key needed; requires `AUTOPAPERTOPPT_CONTACT_EMAIL` for politeness.
155+
Covers ~50M papers. Returns the best OA PDF mirror from author
156+
institutional repos, arXiv, ResearchGate, etc.
157+
2. **arXiv title search** — for papers without a DOI or where
158+
Unpaywall returned no hit, search arXiv by the paper's title.
159+
Exact-match on the normalised title (alphanumeric + lowercase) so
160+
loosely-similar titles don't get picked up by accident.
161+
162+
Both lookups are best-effort and never raise; a paper that resists
163+
both passes through with `pdf_url=None` and the downstream paywall
164+
gate / per-paper renderer falls back to the lightweight tier.
165+
166+
Disabled per-run via the CLI's `--no-oa-resolve` flag or
167+
`run_search(query, resolve_oa=False)` from Python.
168+
140169
### Dedup
141170

142171
`autopapertoppt.core.dedup` is a three-pass merge:

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ autopapertoppt (--query KEYWORDS | --paper IDENTIFIER)
4747
| `--lightweight` | off | Force the abstract-only deck even when `ANTHROPIC_API_KEY` is set. Useful for unattended runs where you do not want to spend tokens. |
4848
| `--llm-model` | `claude-opus-4-7` | Override the default model used when `--enrich` is on. Also reads `AUTOPAPERTOPPT_LLM_MODEL`. |
4949
| `--all-venues` | off | Disable the top-tier whitelist. By default the search keeps only flagship CS conferences / journals + Nature / Science / PNAS / CACM / LNCS. arXiv passes through unconditionally. |
50+
| `--no-oa-resolve` | off | Skip the open-access PDF resolver step that runs after dedup. By default the pipeline looks up every paper without `pdf_url` in Unpaywall (needs `AUTOPAPERTOPPT_CONTACT_EMAIL`) and falls back to an arXiv title search — typical lift of 40-70% for IEEE / ACM / Springer / Elsevier paywalled papers. Use this flag if you want raw source output without OA enrichment, or to skip the extra HTTP round-trips on a tight latency budget. |
5051
| `--paywall-threshold` | `0.30` | Fraction of paywalled results above which the search-mode pipeline asks the user before generating per-paper PPTs. |
5152
| `--yes` | off | Auto-accept the paywall prompt. |
5253
| `--max-slides` | `25` | Per-paper slide cap. Pass `0` for unlimited. |

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ each value into `os.environ` before any fetcher initialises.
3131
| `AUTOPAPERTOPPT_SPRINGER_API_KEY` | unset | Free key from <https://dev.springernature.com/>. **Required** for the Springer plugin — it raises `ConfigError` at construction without a key, which the pipeline silently skips. |
3232
| `AUTOPAPERTOPPT_CROSSREF_PLUS_TOKEN` | unset | Crossref Plus subscriber token. Attached to requests as `Crossref-Plus-API-Token: Bearer <token>`. Raises rate limits and improves cache freshness on the `acm` and `crossref` plugins. |
3333
| `AUTOPAPERTOPPT_ENABLE_SCHOLAR_SCRAPING` | unset | Must be `=1` to enable the Google Scholar plugin. Scholar's terms of use forbid scraping — off by default. |
34-
| `AUTOPAPERTOPPT_CONTACT_EMAIL` | unset | Sent to Crossref / OpenAlex as the `mailto=` parameter (entry into their polite pool) and to NCBI as `tool` / `email` headers. Set this for any non-trivial workload. |
34+
| `AUTOPAPERTOPPT_CONTACT_EMAIL` | unset | Sent to Crossref / OpenAlex as the `mailto=` parameter (entry into their polite pool), to NCBI as `tool` / `email` headers, **and to Unpaywall as `email=`** for the post-dedup OA PDF resolver. Highly recommended — without it the resolver skips Unpaywall lookups entirely, which is the single biggest PDF coverage win for IEEE / ACM / Springer / Elsevier paywalled papers (typical lift 40-70%). |
3535

3636
### PDF download
3737

tests/gui/test_search_page.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def test_search_button_runs_and_populates_table(qtbot, monkeypatch):
3535
page = SearchPage(ui_language="en")
3636
qtbot.addWidget(page)
3737

38-
async def fake_run_search(_query):
38+
async def fake_run_search(_query, **_kwargs):
3939
return _canned_collection()
4040

4141
async def fake_shutdown():

0 commit comments

Comments
 (0)