by drpwchen
Publisher-aware full-text PDF fetcher — DOI in, PDF out via OA → publisher TDM APIs → your own institutional proxy. Download end of a paper pipeline; pair with paper-radar (discovery) and paper-review-and-digest (reading).
# Add to your Claude Code skills
git clone https://github.com/drpwchen/paper-fetchGuides for using data processing skills like paper-fetch.
Last scanned: 7/20/2026
{
"issues": [],
"status": "PASSED",
"scannedAt": "2026-07-20T06:45:17.428Z",
"npmAuditRan": true,
"pipAuditRan": false,
"promptInjectionRan": true
}paper-fetch is an open-source data processing skill for AI coding assistants such as Claude Code, Codex CLI, and ChatGPT, built by drpwchen. Publisher-aware full-text PDF fetcher — DOI in, PDF out via OA → publisher TDM APIs → your own institutional proxy. Download end of a paper pipeline; pair with paper-radar (discovery) and paper-review-and-digest (reading). It has 51 GitHub stars.
Yes. paper-fetch passed SkillsLLM's automated security scan — a dependency vulnerability audit plus prompt-injection heuristics — with no high-severity issues. You can read the full report in the Security Report section on this page.
Clone the repository with "git clone https://github.com/drpwchen/paper-fetch" and add it to your Claude Code skills directory (see the Installation section above).
paper-fetch is primarily written in Python. It is open-source under drpwchen on GitHub, so you can review or fork the full source.
Yes. SkillsLLM lists many other Data Processing skills you can browse and compare side by side. Open the Data Processing category from the badge at the top of this page, or use the Related Skills and comparison links further down to weigh paper-fetch against similar tools.
No comments yet. Be the first to share your thoughts!
Top skills in this category by stars
Give it a DOI; it walks a route ladder to get the full-text PDF the cheapest, most legitimate way first — open access, then official publisher text-mining (TDM) APIs, then your own institutional library proxy, and finally it just prints your library's resolver link so you can finish by hand. Every route is one you're already allowed to use; there is no Sci-Hub fallback here.
It's the download end of a small paper pipeline — the piece that was missing longest, because it's the one that fights publishers:
| Stage | Repo | What it does |
|---|---|---|
| discovery | paper-radar | journal/PubMed feeds → interest-scored → private triage |
| download | paper-fetch (you are here) | DOI → full-text PDF, via the route ladder |
| reading | paper-review-and-digest | /paper-review appraisal, /paper-digest content digest |
# 30 seconds to first PDF (the OA route needs zero keys — just your email in config.yaml)
git clone https://github.com/drpwchen/paper-fetch && cd paper-fetch
pip install -r requirements.txt && cp config.example.yaml config.yaml # set unpaywall_email
python paper_fetch.py 10.1186/s12984-023-01168-x out.pdf
This project ships no institution's access. You supply your own library's endpoints (in
config.yaml) and your own account (in a local secret store). It automates your own authenticated session — it is not a paywall bypass and it does not share credentials. Most readers will point an AI agent at this repo to understand and adapt the method; the code — and AGENTS.md — are written for exactly that.
The whole design is: try the cheapest, most-permitted route first, fall through on failure.
DOI
├─ 1. Open Access ─────── Unpaywall + Semantic Scholar openAccessPdf → every oa_location,
│ PMC→Europe PMC render, landing-page citation_pdf_url (repositories)
├─ 2. Publisher TDM API ─ Elsevier / Wiley / Springer official text-mining endpoints
│ (you register for your own key; entirely sanctioned)
├─ 3. Institutional proxy your library's off-campus remote-auth + EZproxy/NetScaler proxy
│ (your own login; for subscriptions you already have)
└─ 4. Resolver link ────── print your library's SFX/OpenURL link → finish manually
Layer 1 works out of the box for anyone. Layers 2–3 are where the work is.
Two rules worth stealing:
%PDF magic bytes, never trust Content-Type. Paywalls and Cloudflare love
to return 200 text/html that looks like a PDF response but isn't.The point of this repo. Each publisher exposes a different shape; these are the ones mapped and verified against live articles.
| Shape | How it works | DOI prefixes |
|---|---|---|
All four shapes live in one dispatch table — ROUTES in library_session.py, keyed by DOI |
||
prefix with a kind of tpl / meta / lww — and as of v1.0 every one of them ships |
||
| working code: |
| Shape | How it works | DOI prefixes |
|---|---|---|
template (kind: tpl) |
build the PDF URL from a host + path template | 10.1002/10.1111 Wiley · 10.1007/10.1186 Springer/BMC · 10.1056 NEJM · 10.1177 Sage · 10.1080 T&F · 10.2214 AJR · 10.1148 Radiology/RSNA · 10.1142 World Scientific |
citation-meta (kind: meta) |
resolver → article HTML's <meta name="citation_pdf_url"> → fetch with Referer. Headless. |
10.1001 JAMA · 10.1093 Oxford · 10.1542 Pediatrics · 10.1183 ERJ · 10.3171 J Neurosurg · 10.1038 Nature |
citation-meta + headful nav (kind: meta, nav: true) |
same, but the resolver runs as a real headful navigation to clear a Cloudflare challenge | 10.1136 BMJ · 10.3174 AJNR · 10.2967 J Nucl Med |
signed-URL (kind: lww) |
multi-step walk to a signed PDF URL: resolver → scrape article number → viewer HTML → signed pdfUrl with the right Referer chain, plus an Ovid-OCE fallback for ahead-of-print articles and concurrent-licence-seat (E3) back-off |
10.1097/10.1161/10.1213/10.2215 LWW/Ovid |
10.1016 Elsevier goes through the TDM API in paper_fetch.py, never the proxy.
Adding a new publisher? Reach for the citation_pdf_url route first. Many sites with no
DOI→PDF template still advertise the exact PDF URL in a <meta name="citation_pdf_url"> tag on
the article page (it's what Google Scholar indexes). Resolve the DOI through the proxy, read the
meta, fetch it with the article as Referer. No reverse engineering needed.
And if a route comes back cf_block, try headful navigation before declaring it dead. BMJ was
documented here as a Cloudflare dead end that no stealth browser could clear. That was simply
wrong: the WAF only blocks headless requests, and a real headful navigation passes on the first
try — that's the whole _HEADFUL_META_PREFIXES variant. (Highwire sites like AJNR/JNM also loop
on the generic doi-org resolver → give them an explicit host so the route uses that site's own
/lookup/doi/.)
Plenty of tools turn a DOI into a PDF. They differ on two axes: where the PDFs come from and what happens when a route fails.
| Capability | OA-only clients (unpywall, …) | Sci-Hub-based (PyPaperBot, scidownl, …) | Multi-source fetchers | paper-fetch |
|---|---|---|---|---|
Open-access ladder (Unpaywall + S2 + PMC/Europe PMC + citation_pdf_url) |
partial (usually one index) | — | ✅ | ✅ two independent OA indexes + PMCID direct-conversion |
| Official publisher TDM APIs (your own keys) | — | — | rare | ✅ Elsevier · Wiley · Springer |
| Institutional proxy layer (your own login, persistent session) | — | — | IP/cookie passthrough at best | ✅ full remote-auth walk, session survives reboots |
Per-publisher route shapes (URL template / citation_pdf_url / headful CF nav / multi-step signed-URL) |
— | — | generic stealth browser | ✅ 20+ verified, named, documented |
| Entitlement ground truth (your library's A–Z holdings → local SQLite, checked before blaming a route) | — | — | — | ✅ holdings.py, as far as we know unique |
Route health from access logs (stats, per-route success history) |
— | — | — | ✅ |
%PDF magic-byte validation |
rare | rare | some do | ✅ |
Agent-native output (--json envelope, typed exit codes) |
— | — | some do | ✅ |
The two rows nothing else has — entitlement ground truth and log-driven route health — exist because they answer the question every other tool leaves you guessing on: "is this route broken, or do I just not have access to this article?"
python -m patchright install chromium # only needed for the institutional proxy layer
(The clone / pip install / cp config.example.yaml config.yaml steps are in the quickstart
above.) Then store publisher and library credentials in a local DPAPI secret store (Windows) —
never in config.yaml:
powershell -File ~/.secrets/secret.ps1 set ELSEVIER_TDM_KEY
powershell -File ~/.secrets/secret.ps1 set WILEY_TDM_TOKEN
powershell -File ~/.secrets/secret.ps1 set LIB_USER # your own library account
powershell -File ~/.secrets/secret.ps1 set LIB_PASS
(Any secret store works — the code shells out to ~/.secrets/secret.ps1 get <NAME>; swap in
your own if you're not on Windows DPAPI.)
How to register for the publisher TDM APIs (Elsevier / Wiley / Springer / Unpaywall) → docs/publisher-tdm-apis.md.
python paper_fetch.py 10.1371/journal.pone.0000000 out.pdf # OA / TDM — works out of the box
python paper_fetch.py --json 10.1016/xxx out.pdf # agent mode: JSON envelope on stdout
python holdings.py 10.1097/xxxxx # do I even have access to this?
python library_session.py check # proxy layer (after config.yaml `auth:` is set)
python library_session.py fetch 10.1002/xxxxx out.pdf
python library_session.py stats # rate / block analysis
python library_session.py routes # per-route scorecard + holdings gaps
examples/example-note.md shows the intended Zotero + Obsidian workflow around it.
--json)paper_fetch.py --json prints exactly one JSON envelope on stdout (all diagnostics go
to stderr), so an orchestrator can json.loads the last line without scraping logs:
{"schema": 1, "doi": "…", "ok": true, "route": "elsevier", "tried": ["elsevier"],
"bytes": 117204, "sha256": "…", "path": "out.pdf", "elapsed_s": 1.4}
On failure ok is false, tried lists every route attempted, and resolver_url carries
your library's SFX link for a manual finish. sha256 lets batch callers dedupe.
| Code | Meaning | What the caller should do |
|---|---|---|
0 |
PDF obtained | validate %PDF, carry on |
1 |
usage error | fix the command |
2 |
no route / auth failed | genuinely unavailable — stop |
4 |
profile busy (another fetch holds the lock) — library_session.py only |
retry serially — not a missing paper |
5 |
watchdog abort (PAPERFETCH_TIMEOUT_S, default 240 s) — library_session.py only |
retry once — not a missing p |