Files
stack/dev/scripts/fetch_pdfs.py
kert 16f3b43974
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m12s
CI / skinny-install (api) (push) Successful in 30s
CI / skinny-install (bcda) (push) Successful in 36s
CI / skinny-install (bib) (push) Successful in 35s
CI / skinny-install (bls) (push) Successful in 27s
CI / skinny-install (ccw) (push) Successful in 32s
CI / skinny-install (cli) (push) Successful in 41s
CI / skinny-install (cms) (push) Successful in 37s
CI / skinny-install (conf) (push) Successful in 38s
CI / skinny-install (opps) (push) Successful in 33s
CI / skinny-install (perf) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 38s
CI / skinny-install (rex) (push) Successful in 34s
Deploy / build-scan-report (push) Failing after 46s
Infra CI / notebooks (push) Failing after 25s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Failing after 16s
CI / lint-test (push) Failing after 11m2s
Infra CI / mc (push) Successful in 21s
Infra CI / api (push) Successful in 29s
Package Supply Chain / pkg-supply-chain (push) Failing after 41s
feat: full session — mail servers, comment pipeline, PRISMA fetch, email ingest
Mail: Maddy on DO (corwins.media+Resend, fhirworx.io+Postmark),
touchless/stateless/idempotent. Gitea SMTP via env_file. CMS inbox
at cmsupdates@mail.fhirworx.io with IMAP→bib poller.

Bib: regulations.gov v4 client, Federal Register discovery, 164K
comment backfill (running), IMAP email ingest, Zotero sync routing.

PRISMA: altcha PoW solver, CrossRef DOI resolution, 83/129 PDFs.
Zotero: schema parity, ops module, CLI, fail-fast guard.
CI: docs.Dockerfile COPY glob fix (tracks #341).
Infra: Gitea+marimo fhirworx themes, IOM/OIG modules.
2026-04-16 09:04:38 -04:00

519 lines
18 KiB
Python

"""Headless PDF retrieval for skin substitute evidence base.
Downloads PDFs via Unpaywall (legal, ~50%), PMC (free), and SciHub
(fallback) without requiring Zotero's GUI. Attaches downloaded PDFs
to Zotero items in the database.
Usage:
uv run python dev/scripts/fetch_pdfs.py
uv run python dev/scripts/fetch_pdfs.py --limit 100
uv run python dev/scripts/fetch_pdfs.py --source unpaywall
uv run python dev/scripts/fetch_pdfs.py --source pmc
uv run python dev/scripts/fetch_pdfs.py --source scihub
uv run python dev/scripts/fetch_pdfs.py --source s2
# With SciHub proxy (for blocked networks):
SCIHUB_PROXY=socks5://localhost:1080 uv run python dev/scripts/fetch_pdfs.py --source scihub
"""
from __future__ import annotations
import argparse
import os
import re
import sqlite3
import time
from pathlib import Path
import httpx
from conf import path as conf_path
from zot.db import FIELD_MAP, TYPE_MAP, generate_key, now_iso
ZOTERO_DB = str(conf_path("db.zotero"))
STORAGE_DIR = Path(conf_path("storage.zotero"))
UNPAYWALL_EMAIL = "dev@fhirworx.io"
USER_AGENT = "stack-pdf-fetcher/1.0 (mailto:{})".format(UNPAYWALL_EMAIL)
# ---------------------------------------------------------------------------
# Source: Unpaywall (legal, no auth, ~50% coverage)
# ---------------------------------------------------------------------------
def fetch_unpaywall(doi: str) -> str | None:
"""Get PDF URL from Unpaywall for a DOI. Returns URL or None."""
url = f"https://api.unpaywall.org/v2/{doi}"
try:
resp = httpx.get(
url,
params={"email": UNPAYWALL_EMAIL},
headers={"User-Agent": USER_AGENT},
timeout=15,
)
if resp.status_code != 200:
return None
data = resp.json()
best = data.get("best_oa_location") or {}
return best.get("url_for_pdf") or best.get("url")
except Exception:
return None
# ---------------------------------------------------------------------------
# Source: PubMed Central (free for OA articles)
# ---------------------------------------------------------------------------
def doi_to_pmcid(dois: list[str]) -> dict[str, str]:
"""Convert DOIs to PMCIDs via NCBI ID Converter. Returns {doi: pmcid}."""
result = {}
# API accepts up to 200 IDs per request
for i in range(0, len(dois), 200):
batch = dois[i : i + 200]
try:
resp = httpx.get(
"https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/",
params={
"ids": ",".join(batch),
"format": "json",
"tool": "stack-pdf-fetcher",
"email": UNPAYWALL_EMAIL,
},
timeout=30,
)
if resp.status_code == 200:
for rec in resp.json().get("records", []):
doi = rec.get("doi", "")
pmcid = rec.get("pmcid", "")
if doi and pmcid:
result[doi.lower()] = pmcid
except Exception:
pass
time.sleep(0.34)
return result
def fetch_pmc(pmcid: str) -> str | None:
"""Get PDF URL for a PMC article. Returns URL or None."""
return f"https://pmc.ncbi.nlm.nih.gov/articles/{pmcid}/pdf/"
# ---------------------------------------------------------------------------
# Source: Semantic Scholar (supplementary, batch endpoint)
# ---------------------------------------------------------------------------
def fetch_s2_batch(dois: list[str]) -> dict[str, str]:
"""Batch lookup OA PDF URLs from Semantic Scholar. Returns {doi: pdf_url}."""
result = {}
for i in range(0, len(dois), 500):
batch = [f"DOI:{d}" for d in dois[i : i + 500]]
try:
resp = httpx.post(
"https://api.semanticscholar.org/graph/v1/paper/batch",
json={"ids": batch, "fields": "openAccessPdf,externalIds"},
timeout=30,
)
if resp.status_code == 200:
for paper in resp.json():
if paper and paper.get("openAccessPdf"):
pdf_url = paper["openAccessPdf"].get("url")
ext_ids = paper.get("externalIds", {})
doi = ext_ids.get("DOI", "")
if pdf_url and doi:
result[doi.lower()] = pdf_url
except Exception:
pass
time.sleep(1)
return result
# ---------------------------------------------------------------------------
# Source: SciHub (fallback)
# ---------------------------------------------------------------------------
SCIHUB_MIRRORS = [
"https://sci-hub.se",
"https://sci-hub.st",
"https://sci-hub.ru",
"https://sci-hub.ren",
"https://sci-hub.ee",
"https://sci-hub.wf",
]
# Proxy for SciHub (set SCIHUB_PROXY env var, e.g. socks5://localhost:1080)
SCIHUB_PROXY = os.environ.get("SCIHUB_PROXY", "")
# Cloudflare Worker proxy (preferred — bypasses ISP blocking via CF edge)
CF_WORKER_URL = os.environ.get(
"PDF_PROXY_URL", "https://pdf-proxy.lite7889.workers.dev"
)
CF_WORKER_SECRET = os.environ.get("PDF_PROXY_SECRET", "")
def fetch_via_worker(doi: str, dest: Path) -> bool:
"""Fetch PDF via Cloudflare Worker proxy. Returns True on success.
The Worker fetches from SciHub on Cloudflare's edge network,
bypassing ISP-level DNS/IP blocking.
"""
if not CF_WORKER_SECRET:
return False
try:
resp = httpx.get(
f"{CF_WORKER_URL}/pdf",
params={"doi": doi},
headers={
"X-Proxy-Key": CF_WORKER_SECRET,
"User-Agent": USER_AGENT,
},
timeout=45,
)
if resp.status_code != 200:
return False
if b"%PDF" not in resp.content[:1024]:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(resp.content)
return True
except Exception:
return False
def fetch_scihub(doi: str) -> str | None:
"""Get PDF URL from SciHub directly. Returns direct PDF URL or None.
Set SCIHUB_PROXY env var for networks where SciHub is blocked.
Prefer fetch_via_worker() which uses Cloudflare edge.
"""
transport = httpx.HTTPTransport(proxy=SCIHUB_PROXY) if SCIHUB_PROXY else None
client_kwargs = {"transport": transport} if transport else {}
for mirror in SCIHUB_MIRRORS:
try:
with httpx.Client(
timeout=15, follow_redirects=True, **client_kwargs
) as client:
resp = client.get(
f"{mirror}/{doi}",
headers={"User-Agent": "Mozilla/5.0"},
)
if resp.status_code != 200:
continue
# Parse HTML for #pdf iframe/embed src
m = re.search(r'id="pdf"[^>]*src="([^"]+)"', resp.text)
if not m:
m = re.search(r'<iframe[^>]*src="([^"]*\.pdf[^"]*)"', resp.text)
if not m:
m = re.search(r'<embed[^>]*src="([^"]*\.pdf[^"]*)"', resp.text)
if m:
pdf_url = m.group(1)
if pdf_url.startswith("//"):
pdf_url = "https:" + pdf_url
elif pdf_url.startswith("/"):
pdf_url = mirror + pdf_url
return pdf_url
except Exception:
continue
return None
# ---------------------------------------------------------------------------
# Download PDF
# ---------------------------------------------------------------------------
def download_pdf(url: str, dest: Path) -> bool:
"""Download a PDF file. Returns True on success."""
try:
resp = httpx.get(
url,
headers={"User-Agent": USER_AGENT},
timeout=30,
follow_redirects=True,
)
if resp.status_code != 200:
return False
content_type = resp.headers.get("content-type", "")
# Verify it's actually a PDF
if b"%PDF" not in resp.content[:1024] and "pdf" not in content_type:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(resp.content)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Zotero attachment
# ---------------------------------------------------------------------------
def attach_pdf_to_item(
con: sqlite3.Connection, item_id: int, pdf_path: Path, title: str = ""
) -> bool:
"""Create a Zotero attachment record for a downloaded PDF."""
key = generate_key()
while con.execute("SELECT 1 FROM items WHERE key = ?", (key,)).fetchone():
key = generate_key()
now = now_iso()
# Create attachment item (itemTypeID=14 = attachment)
cur = con.execute(
"""INSERT INTO items
(itemTypeID, dateAdded, dateModified, clientDateModified,
libraryID, key, version, synced)
VALUES (?, ?, ?, ?, 1, ?, 0, 0)""",
(TYPE_MAP["attachment"], now, now, now, key),
)
att_item_id = cur.lastrowid
# Create attachment record
# linkMode: 0 = imported file, 1 = imported URL, 2 = linked file
con.execute(
"""INSERT INTO itemAttachments
(itemID, parentItemID, linkMode, contentType, path, storageModTime)
VALUES (?, ?, 0, 'application/pdf', ?, ?)""",
(
att_item_id,
item_id,
f"storage:{key}/{pdf_path.name}",
int(time.time() * 1000),
),
)
# Move PDF to Zotero storage
storage_dir = STORAGE_DIR / key
storage_dir.mkdir(parents=True, exist_ok=True)
dest = storage_dir / pdf_path.name
if pdf_path != dest:
import shutil
shutil.copy2(pdf_path, dest)
con.commit()
return True
# ---------------------------------------------------------------------------
# Main pipeline
# ---------------------------------------------------------------------------
def get_items_needing_pdfs(con: sqlite3.Connection) -> list[dict]:
"""Get skin-subs items with DOI but no PDF attachment."""
rows = con.execute(
"""
SELECT DISTINCT i.itemID,
(SELECT idv.value FROM itemData id
JOIN itemDataValues idv ON id.valueID = idv.valueID
WHERE id.itemID = i.itemID AND id.fieldID = ?) AS doi
FROM items i
JOIN itemTags it ON i.itemID = it.itemID
JOIN tags t ON it.tagID = t.tagID
WHERE t.name = 'module:skin-subs'
AND i.itemTypeID = ?
AND i.itemID NOT IN (
SELECT DISTINCT parentItemID FROM itemAttachments
WHERE parentItemID IS NOT NULL
AND contentType = 'application/pdf'
)
""",
(FIELD_MAP["DOI"], TYPE_MAP["journalArticle"]),
).fetchall()
return [{"item_id": r[0], "doi": r[1]} for r in rows if r[1]]
def main() -> None:
parser = argparse.ArgumentParser(description="Headless PDF retrieval")
parser.add_argument(
"--limit", type=int, default=0, help="Max items to process (0=all)"
)
parser.add_argument(
"--source",
choices=["all", "unpaywall", "pmc", "s2", "worker", "scihub"],
default="all",
help="Which source to use",
)
parser.add_argument(
"--proxy", help="SOCKS/HTTP proxy for SciHub (e.g. socks5://localhost:1080)"
)
args = parser.parse_args()
global SCIHUB_PROXY
if args.proxy:
SCIHUB_PROXY = args.proxy
print("=" * 60)
print("Headless PDF Retrieval")
if SCIHUB_PROXY:
print(f" SciHub proxy: {SCIHUB_PROXY}")
print("=" * 60)
con = sqlite3.connect(ZOTERO_DB, timeout=10)
con.row_factory = sqlite3.Row
items = get_items_needing_pdfs(con)
if args.limit:
items = items[: args.limit]
print(f"\nItems needing PDFs: {len(items)}")
tmp_dir = Path("/tmp/pdf_downloads")
tmp_dir.mkdir(exist_ok=True)
stats = {"unpaywall": 0, "pmc": 0, "scihub": 0, "failed": 0, "total": 0}
# Phase 1: Unpaywall (fast, legal, no CAPTCHA)
if args.source in ("all", "unpaywall"):
print("\n--- Phase 1: Unpaywall ---")
for i, item in enumerate(items):
if not item["doi"]:
continue
stats["total"] += 1
pdf_url = fetch_unpaywall(item["doi"])
if pdf_url:
dest = tmp_dir / f"{item['item_id']}.pdf"
if download_pdf(pdf_url, dest):
attach_pdf_to_item(con, item["item_id"], dest, item["doi"])
stats["unpaywall"] += 1
item["_done"] = True
if (i + 1) % 50 == 0:
print(f" {i + 1}/{len(items)} unpaywall={stats['unpaywall']}")
time.sleep(0.1) # Unpaywall is generous but be polite
print(f" Unpaywall: {stats['unpaywall']} PDFs")
# Phase 2: PMC (batch convert DOIs to PMCIDs, then download)
remaining = [it for it in items if not it.get("_done")]
if args.source in ("all", "pmc") and remaining:
print(f"\n--- Phase 2: PubMed Central ({len(remaining)} remaining) ---")
dois = [it["doi"] for it in remaining if it["doi"]]
pmcid_map = doi_to_pmcid(dois)
print(f" DOIs with PMCIDs: {len(pmcid_map)}")
for item in remaining:
doi = (item["doi"] or "").lower()
if doi in pmcid_map:
pdf_url = fetch_pmc(pmcid_map[doi])
dest = tmp_dir / f"{item['item_id']}.pdf"
if download_pdf(pdf_url, dest):
attach_pdf_to_item(con, item["item_id"], dest, item["doi"])
stats["pmc"] += 1
item["_done"] = True
time.sleep(0.34)
print(f" PMC: {stats['pmc']} PDFs")
# Phase 3: Semantic Scholar (batch, catches some repos Unpaywall misses)
remaining = [it for it in items if not it.get("_done")]
if args.source in ("all", "s2") and remaining:
print(f"\n--- Phase 3: Semantic Scholar ({len(remaining)} remaining) ---")
dois = [it["doi"] for it in remaining if it["doi"]]
s2_map = fetch_s2_batch(dois)
print(f" S2 OA PDFs found: {len(s2_map)}")
for item in remaining:
doi = (item["doi"] or "").lower()
if doi in s2_map:
dest = tmp_dir / f"{item['item_id']}.pdf"
if download_pdf(s2_map[doi], dest):
attach_pdf_to_item(con, item["item_id"], dest, item["doi"])
stats["s2"] = stats.get("s2", 0) + 1
item["_done"] = True
time.sleep(0.2)
print(f" Semantic Scholar: {stats.get('s2', 0)} PDFs")
# Phase 4: Cloudflare Worker → SciHub (bypasses ISP blocking)
remaining = [it for it in items if not it.get("_done")]
if args.source in ("all", "worker", "scihub") and remaining and CF_WORKER_SECRET:
print(f"\n--- Phase 4: CF Worker → SciHub ({len(remaining)} remaining) ---")
consecutive_failures = 0
for i, item in enumerate(remaining):
if consecutive_failures >= 20:
print(f" Stopping: {consecutive_failures} consecutive failures")
break
dest = tmp_dir / f"{item['item_id']}.pdf"
if fetch_via_worker(item["doi"], dest):
attach_pdf_to_item(con, item["item_id"], dest, item["doi"])
stats["worker"] = stats.get("worker", 0) + 1
item["_done"] = True
consecutive_failures = 0
else:
consecutive_failures += 1
if (i + 1) % 25 == 0:
print(
f" {i + 1}/{len(remaining)} worker={stats.get('worker', 0)} "
f"fails={consecutive_failures}"
)
time.sleep(1.5) # Be polite to Worker + SciHub
print(f" CF Worker: {stats.get('worker', 0)} PDFs")
# Phase 5: SciHub direct (fallback if Worker unavailable)
remaining = [it for it in items if not it.get("_done")]
if args.source in ("scihub",) and remaining and not CF_WORKER_SECRET:
print(f"\n--- Phase 5: SciHub direct ({len(remaining)} remaining) ---")
if not SCIHUB_PROXY:
print(" WARNING: No proxy set. SciHub may be blocked from this network.")
print(" Set SCIHUB_PROXY or use --proxy socks5://host:port")
consecutive_failures = 0
for i, item in enumerate(remaining):
if consecutive_failures >= 10:
print(
f" Stopping: {consecutive_failures} consecutive failures (CAPTCHA?)"
)
break
pdf_url = fetch_scihub(item["doi"])
if pdf_url:
dest = tmp_dir / f"{item['item_id']}.pdf"
if download_pdf(pdf_url, dest):
attach_pdf_to_item(con, item["item_id"], dest, item["doi"])
stats["scihub"] += 1
item["_done"] = True
consecutive_failures = 0
else:
consecutive_failures += 1
else:
consecutive_failures += 1
if (i + 1) % 25 == 0:
print(
f" {i + 1}/{len(remaining)} scihub={stats['scihub']} "
f"fails={consecutive_failures}"
)
time.sleep(1) # Be polite to SciHub
print(f" SciHub: {stats['scihub']} PDFs")
stats["failed"] = len([it for it in items if not it.get("_done")])
total_found = (
stats["unpaywall"]
+ stats["pmc"]
+ stats.get("s2", 0)
+ stats.get("worker", 0)
+ stats.get("scihub", 0)
)
print(f"\n{'=' * 60}")
print(f"Results: {total_found} PDFs downloaded")
print(f" Unpaywall: {stats['unpaywall']}")
print(f" PMC: {stats['pmc']}")
print(f" S2: {stats.get('s2', 0)}")
print(f" CF Worker: {stats.get('worker', 0)}")
print(f" SciHub: {stats.get('scihub', 0)}")
print(f" Failed: {stats['failed']}")
print(f" Coverage: {total_found * 100 // max(len(items), 1)}%")
con.close()
if __name__ == "__main__":
main()