pyPaperFlow 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyPaperFlow/__init__.py +1 -0
- pyPaperFlow/cli.py +662 -0
- pyPaperFlow/integrations/mineru_parser.py +1315 -0
- pyPaperFlow/integrations/pdf_fetch.py +2080 -0
- pyPaperFlow/preprint/arxiv_fetcher.py +549 -0
- pyPaperFlow/preprint/biorxiv_fetcher.py +404 -0
- pyPaperFlow/preprint/source_models.py +29 -0
- pyPaperFlow/preprint/source_utils.py +131 -0
- pyPaperFlow/pubmed/__init__.py +0 -0
- pyPaperFlow/pubmed/pubmed_fetcher.py +1910 -0
- pyPaperFlow/pubmed/pubmed_merger.py +958 -0
- pyPaperFlow/utils.py +70 -0
- pypaperflow-0.2.0.dist-info/METADATA +2025 -0
- pypaperflow-0.2.0.dist-info/RECORD +17 -0
- pypaperflow-0.2.0.dist-info/WHEEL +4 -0
- pypaperflow-0.2.0.dist-info/entry_points.txt +2 -0
- pypaperflow-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,2080 @@
|
|
|
1
|
+
# ⚠️ This script is adapted and modified from https://github.com/Agents365-ai/paper-fetch/blob/main/scripts/fetch.py
|
|
2
|
+
|
|
3
|
+
#!/usr/bin/env python3
|
|
4
|
+
"""Fetch legal open-access PDFs by DOI.
|
|
5
|
+
|
|
6
|
+
Resolution order: Unpaywall -> Semantic Scholar openAccessPdf ->
|
|
7
|
+
arXiv -> PMC OA -> bioRxiv/medRxiv.
|
|
8
|
+
|
|
9
|
+
Exit codes:
|
|
10
|
+
0 success (all DOIs resolved and downloaded / dry-run previewed)
|
|
11
|
+
1 unresolved — one or more DOIs had no OA copy; no transport failure
|
|
12
|
+
2 reserved for auth errors (currently unused; Unpaywall gracefully degrades)
|
|
13
|
+
3 validation error (bad arguments, missing input)
|
|
14
|
+
4 transport error — network / download / IO failure (retryable class)
|
|
15
|
+
|
|
16
|
+
If UNPAYWALL_EMAIL is not set, the Unpaywall source is skipped
|
|
17
|
+
and the remaining 4 sources are still tried.
|
|
18
|
+
|
|
19
|
+
Machine contract:
|
|
20
|
+
stdout — one JSON object per invocation (or NDJSON with --stream)
|
|
21
|
+
stderr — NDJSON progress events when --format json; prose when --format text
|
|
22
|
+
|
|
23
|
+
Contract-changing version of this file. The schema_version below is what the
|
|
24
|
+
`schema` subcommand reports and what appears in every response's `meta` slot;
|
|
25
|
+
agents that cache schema should compare against it to detect drift.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import html.parser
|
|
31
|
+
import ipaddress
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import shlex
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
import urllib.parse
|
|
39
|
+
import urllib.request
|
|
40
|
+
import uuid
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Versioning
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
CLI_VERSION = "0.13.1"
|
|
48
|
+
SCHEMA_VERSION = "1.9.0"
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Config
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
EMAIL = os.environ.get("UNPAYWALL_EMAIL", "").strip()
|
|
55
|
+
# UA for API calls (Unpaywall requires contact email in the UA per their ToS).
|
|
56
|
+
UA = f"paper-fetch/{CLI_VERSION} (mailto:{EMAIL or 'anonymous'})"
|
|
57
|
+
# UA for PDF downloads — some publishers (e.g., iiarjournals.org) return
|
|
58
|
+
# HTTP 403 for non-browser User-Agents even on OA PDFs. Uses a generic
|
|
59
|
+
# modern browser identifier; the per-request Accept header still declares
|
|
60
|
+
# we want a PDF, and the host allowlist still restricts where we fetch.
|
|
61
|
+
DOWNLOAD_UA = (
|
|
62
|
+
f"Mozilla/5.0 (compatible; paper-fetch/{CLI_VERSION}; "
|
|
63
|
+
f"+https://github.com/obra/paper-fetch) "
|
|
64
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
65
|
+
)
|
|
66
|
+
DEFAULT_TIMEOUT = 30
|
|
67
|
+
MAX_PDF_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
68
|
+
|
|
69
|
+
# Canonical DOI shape — kept here so build_schema() and runtime validation
|
|
70
|
+
# share one source of truth. Schema-side this is exposed as the regex below
|
|
71
|
+
# without the surrounding anchors.
|
|
72
|
+
DOI_PATTERN = r"^10\..+/.+$"
|
|
73
|
+
_DOI_RE = re.compile(DOI_PATTERN)
|
|
74
|
+
|
|
75
|
+
EXIT_SUCCESS = 0
|
|
76
|
+
EXIT_UNRESOLVED = 1
|
|
77
|
+
EXIT_AUTH = 2 # reserved
|
|
78
|
+
EXIT_VALIDATION = 3
|
|
79
|
+
EXIT_TRANSPORT = 4
|
|
80
|
+
|
|
81
|
+
# Per-error retry backoff hints surfaced to agents. Only set on retryable=True
|
|
82
|
+
# codes. Values are recommendations, not guarantees: an orchestrator that
|
|
83
|
+
# ignores them and retries sooner will at worst re-hit the same failure.
|
|
84
|
+
RETRY_AFTER_HOURS = {
|
|
85
|
+
"not_found": 168, # OA availability changes on embargo / preprint timescale
|
|
86
|
+
"download_network_error": 1, # transient network / upstream hiccup
|
|
87
|
+
"download_size_exceeded": 24, # publisher posted a >50 MB PDF; revisit in a day
|
|
88
|
+
"download_io_error": 1, # local disk full / permission blip
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Institutional mode
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
# Rate limit (institutional mode only — public OA sources are unmetered by
|
|
96
|
+
# their operators and do not need client-side pacing).
|
|
97
|
+
INSTITUTIONAL_RATE_PER_SEC = 1.0
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Sci-Hub fallback
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
# Default mirror list (snapshot from https://www.sci-hub.pub/ on 2026-04-26).
|
|
104
|
+
# Operator can override with PAPER_FETCH_SCIHUB_MIRRORS=sci-hub.ru,sci-hub.st,...
|
|
105
|
+
# When all configured mirrors miss, we re-scan SCIHUB_DISCOVERY_URL once per
|
|
106
|
+
# process for a fresh list.
|
|
107
|
+
SCIHUB_DEFAULT_MIRRORS = (
|
|
108
|
+
"sci-hub.ru",
|
|
109
|
+
"sci-hub.st",
|
|
110
|
+
"sci-hub.su",
|
|
111
|
+
"sci-hub.box",
|
|
112
|
+
"sci-hub.red",
|
|
113
|
+
"sci-hub.al",
|
|
114
|
+
"sci-hub.mk",
|
|
115
|
+
"sci-hub.ee",
|
|
116
|
+
)
|
|
117
|
+
SCIHUB_DISCOVERY_URL = "https://www.sci-hub.pub/"
|
|
118
|
+
|
|
119
|
+
# Mobile Safari UA for Sci-Hub HTML page fetches. Mobile clients tend to
|
|
120
|
+
# get a simpler page layout less likely to trigger CAPTCHA. Technique
|
|
121
|
+
# borrowed from ethanwillis/zotero-scihub.
|
|
122
|
+
SCIHUB_UA = (
|
|
123
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) "
|
|
124
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) "
|
|
125
|
+
"Version/17.4 Mobile/15E148 Safari/604.1"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Polite per-host pacing for Sci-Hub mirror requests. Public OA APIs are
|
|
129
|
+
# unmetered; Sci-Hub mirrors throttle and CAPTCHA aggressively, so we pace
|
|
130
|
+
# Sci-Hub fetches independently of institutional mode.
|
|
131
|
+
SCIHUB_RATE_PER_SEC = 1.0
|
|
132
|
+
_last_scihub_request_monotonic: float = 0.0
|
|
133
|
+
|
|
134
|
+
# Hostnames blocked in every mode. Covers two threat classes:
|
|
135
|
+
# - loopback aliases that resolve to 127.0.0.1 / ::1 but pass the IP literal
|
|
136
|
+
# check (the ip literal check only fires when the URL host IS an IP)
|
|
137
|
+
# - cloud metadata endpoints that can leak IAM credentials if an SSRF
|
|
138
|
+
# target pivoted into fetching from them
|
|
139
|
+
# This does not defend against DNS rebinding — a hostname pointing at a
|
|
140
|
+
# public IP at validation time but a private IP at connection time slips
|
|
141
|
+
# through. Mitigating that requires pin-after-resolve and is out of scope
|
|
142
|
+
# for v0.8.0.
|
|
143
|
+
_BLOCKED_HOSTS = {
|
|
144
|
+
# Loopback aliases
|
|
145
|
+
"localhost",
|
|
146
|
+
"localhost.localdomain",
|
|
147
|
+
"ip6-localhost",
|
|
148
|
+
"ip6-loopback",
|
|
149
|
+
# Cloud metadata
|
|
150
|
+
"metadata.google.internal",
|
|
151
|
+
"metadata.aws.internal",
|
|
152
|
+
"metadata", # some cloud SDKs resolve bare 'metadata'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_institutional() -> bool:
|
|
157
|
+
"""True iff the operator has opted the process into institutional mode."""
|
|
158
|
+
return bool(os.environ.get("PAPER_FETCH_INSTITUTIONAL"))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _auth_mode() -> str:
|
|
162
|
+
return "institutional" if _is_institutional() else "public"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_safe_url(url: str) -> tuple[bool, str]:
|
|
166
|
+
"""Universal URL safety check — applied in every mode.
|
|
167
|
+
|
|
168
|
+
Returns (ok, reason). Blocks SSRF vectors regardless of whether the
|
|
169
|
+
hostname would pass the allowlist check:
|
|
170
|
+
- non-http(s) schemes (file://, ftp://, gopher://, etc.)
|
|
171
|
+
- non-80/443 ports
|
|
172
|
+
- IP literals in private / loopback / link-local / reserved space
|
|
173
|
+
- known cloud metadata hostnames
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
parsed = urllib.parse.urlparse(url)
|
|
177
|
+
except Exception:
|
|
178
|
+
return False, "malformed_url"
|
|
179
|
+
if parsed.scheme not in ("http", "https"):
|
|
180
|
+
return False, "scheme_not_allowed"
|
|
181
|
+
if parsed.port is not None and parsed.port not in (80, 443):
|
|
182
|
+
return False, "port_not_allowed"
|
|
183
|
+
host = (parsed.hostname or "").lower()
|
|
184
|
+
if not host:
|
|
185
|
+
return False, "empty_host"
|
|
186
|
+
try:
|
|
187
|
+
ip = ipaddress.ip_address(host)
|
|
188
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
|
189
|
+
return False, "private_ip"
|
|
190
|
+
except ValueError:
|
|
191
|
+
pass # hostname is a name, not a literal — fine
|
|
192
|
+
if host in _BLOCKED_HOSTS:
|
|
193
|
+
return False, "blocked_host"
|
|
194
|
+
return True, ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Simple per-process token bucket. Single-threaded, so no locking needed.
|
|
198
|
+
_last_request_monotonic: float = 0.0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _rate_limit_gate() -> None:
|
|
202
|
+
"""Enforce INSTITUTIONAL_RATE_PER_SEC pacing. No-op in public mode.
|
|
203
|
+
|
|
204
|
+
Runs before every outbound HTTP request in institutional mode so
|
|
205
|
+
that a single process cannot inadvertently hammer a publisher's
|
|
206
|
+
servers beyond the configured rate.
|
|
207
|
+
"""
|
|
208
|
+
global _last_request_monotonic
|
|
209
|
+
if not _is_institutional():
|
|
210
|
+
return
|
|
211
|
+
min_interval = 1.0 / INSTITUTIONAL_RATE_PER_SEC
|
|
212
|
+
now = time.monotonic()
|
|
213
|
+
wait = _last_request_monotonic + min_interval - now
|
|
214
|
+
if wait > 0:
|
|
215
|
+
time.sleep(wait)
|
|
216
|
+
now = time.monotonic()
|
|
217
|
+
_last_request_monotonic = now
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Output helpers
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
# Global output state (set by main()).
|
|
225
|
+
_format = "json"
|
|
226
|
+
_pretty = False
|
|
227
|
+
_stream = False
|
|
228
|
+
_request_id = ""
|
|
229
|
+
_started_monotonic = 0.0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _now_ms() -> int:
|
|
233
|
+
return int((time.monotonic() - _started_monotonic) * 1000)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _log_text(msg: str) -> None:
|
|
237
|
+
"""Human-readable diagnostic → stderr only (used in text mode)."""
|
|
238
|
+
print(msg, file=sys.stderr)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _progress(event: str, **fields) -> None:
|
|
242
|
+
"""Progress event on stderr.
|
|
243
|
+
|
|
244
|
+
JSON mode emits NDJSON so orchestrators can parse stderr for liveness.
|
|
245
|
+
Text mode emits prose for humans.
|
|
246
|
+
"""
|
|
247
|
+
if _format == "json":
|
|
248
|
+
payload = {"event": event, "request_id": _request_id, "elapsed_ms": _now_ms(), **fields}
|
|
249
|
+
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr, flush=True)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Text mode — render a short human line.
|
|
253
|
+
if event == "session":
|
|
254
|
+
# Agent-only diagnostic; silent in human mode.
|
|
255
|
+
return
|
|
256
|
+
if event == "start":
|
|
257
|
+
_log_text(f"==> {fields.get('doi', '?')}")
|
|
258
|
+
elif event == "source_skip":
|
|
259
|
+
_log_text(f" [{fields.get('source', '?')}] skipped ({fields.get('reason', '?')})")
|
|
260
|
+
elif event == "source_try":
|
|
261
|
+
_log_text(f" [{fields.get('source', '?')}] trying…")
|
|
262
|
+
elif event == "source_hit":
|
|
263
|
+
_log_text(f" [{fields.get('source', '?')}] {fields.get('pdf_url', '?')}")
|
|
264
|
+
elif event == "source_miss":
|
|
265
|
+
_log_text(f" [{fields.get('source', '?')}] no PDF")
|
|
266
|
+
elif event == "download_error":
|
|
267
|
+
reason = fields.get("reason", "?")
|
|
268
|
+
status = fields.get("http_status")
|
|
269
|
+
detail = fields.get("error")
|
|
270
|
+
if status:
|
|
271
|
+
_log_text(f" download failed: {reason} (HTTP {status})")
|
|
272
|
+
elif detail:
|
|
273
|
+
_log_text(f" download failed: {reason} ({detail})")
|
|
274
|
+
else:
|
|
275
|
+
_log_text(f" download failed: {reason}")
|
|
276
|
+
elif event == "download_ok":
|
|
277
|
+
_log_text(f" saved → {fields.get('file', '?')}")
|
|
278
|
+
elif event == "download_skip":
|
|
279
|
+
_log_text(f" [skip-existing] {fields.get('file', '?')}")
|
|
280
|
+
elif event == "dry_run":
|
|
281
|
+
_log_text(f" [dry-run] [{fields.get('source', '?')}] {fields.get('pdf_url', '?')} → {fields.get('file', '?')}")
|
|
282
|
+
elif event == "not_found":
|
|
283
|
+
_log_text(f" no OA PDF found for {fields.get('doi', '?')}")
|
|
284
|
+
else:
|
|
285
|
+
# fall back
|
|
286
|
+
_log_text(f" [{event}] {fields}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _dump_json(obj: dict) -> str:
|
|
290
|
+
if _pretty:
|
|
291
|
+
return json.dumps(obj, ensure_ascii=False, indent=2)
|
|
292
|
+
return json.dumps(obj, ensure_ascii=False)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _emit(obj: dict) -> None:
|
|
296
|
+
"""Final result → stdout as JSON or human-readable text."""
|
|
297
|
+
if _format == "json":
|
|
298
|
+
print(_dump_json(obj))
|
|
299
|
+
else:
|
|
300
|
+
_emit_text(obj)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _emit_ndjson(obj: dict) -> None:
|
|
304
|
+
"""Per-item streaming line on stdout (--stream mode)."""
|
|
305
|
+
print(_dump_json(obj), flush=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _emit_text(obj: dict) -> None:
|
|
309
|
+
"""Render a result envelope as human-readable text on stdout."""
|
|
310
|
+
ok = obj.get("ok")
|
|
311
|
+
if ok is False:
|
|
312
|
+
err = obj.get("error", {})
|
|
313
|
+
print(f"error: [{err.get('code', '?')}] {err.get('message', '?')}")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
data = obj.get("data", {})
|
|
317
|
+
results = data.get("results", [data] if "doi" in data else [])
|
|
318
|
+
for r in results:
|
|
319
|
+
if r.get("skipped"):
|
|
320
|
+
status = "skipped"
|
|
321
|
+
elif r.get("dry_run"):
|
|
322
|
+
status = "dry-run"
|
|
323
|
+
elif r.get("success"):
|
|
324
|
+
status = "saved"
|
|
325
|
+
else:
|
|
326
|
+
status = "failed"
|
|
327
|
+
src = r.get("source") or "?"
|
|
328
|
+
doi = r.get("doi", "?")
|
|
329
|
+
target = r.get("file") or r.get("pdf_url") or "?"
|
|
330
|
+
print(f"[{src}] {doi} → {target} ({status})")
|
|
331
|
+
summary = data.get("summary")
|
|
332
|
+
if summary:
|
|
333
|
+
print(f"\n{summary['succeeded']}/{summary['total']} succeeded ({summary.get('failed', 0)} failed)")
|
|
334
|
+
nxt = data.get("next") or []
|
|
335
|
+
if nxt:
|
|
336
|
+
print("\nnext:")
|
|
337
|
+
for hint in nxt:
|
|
338
|
+
print(f" {hint}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _meta(extra: dict | None = None) -> dict:
|
|
342
|
+
m = {
|
|
343
|
+
"request_id": _request_id,
|
|
344
|
+
"latency_ms": _now_ms(),
|
|
345
|
+
"schema_version": SCHEMA_VERSION,
|
|
346
|
+
"cli_version": CLI_VERSION,
|
|
347
|
+
"auth_mode": _auth_mode(),
|
|
348
|
+
}
|
|
349
|
+
if extra:
|
|
350
|
+
m.update(extra)
|
|
351
|
+
return m
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _envelope_ok(data: dict, *, ok=True, meta_extra: dict | None = None) -> dict:
|
|
355
|
+
return {"ok": ok, "data": data, "meta": _meta(meta_extra)}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _envelope_err(code: str, message: str, *, retryable: bool = False, **ctx) -> dict:
|
|
359
|
+
e = {"code": code, "message": message, "retryable": retryable}
|
|
360
|
+
e.update(ctx)
|
|
361
|
+
return {"ok": False, "error": e, "meta": _meta()}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# HTTP helpers
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _get(url: str, *, accept: str = "application/json", timeout: int, user_agent: str | None = None) -> bytes:
|
|
370
|
+
_rate_limit_gate()
|
|
371
|
+
req = urllib.request.Request(url, headers={"User-Agent": user_agent or UA, "Accept": accept})
|
|
372
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
373
|
+
return r.read()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _get_json(url: str, *, timeout: int):
|
|
377
|
+
return json.loads(_get(url, timeout=timeout).decode("utf-8"))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _scihub_rate_gate() -> None:
|
|
381
|
+
"""1 req/s pacing for Sci-Hub fetches, applied in every auth mode."""
|
|
382
|
+
global _last_scihub_request_monotonic
|
|
383
|
+
min_interval = 1.0 / SCIHUB_RATE_PER_SEC
|
|
384
|
+
now = time.monotonic()
|
|
385
|
+
wait = _last_scihub_request_monotonic + min_interval - now
|
|
386
|
+
if wait > 0:
|
|
387
|
+
time.sleep(wait)
|
|
388
|
+
now = time.monotonic()
|
|
389
|
+
_last_scihub_request_monotonic = now
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _is_allowed_host(url: str) -> bool:
|
|
393
|
+
"""Gatekeeper for any outbound PDF fetch.
|
|
394
|
+
|
|
395
|
+
Only SSRF defense applies — private IPs, non-http(s) schemes, non-80/443
|
|
396
|
+
ports, and cloud metadata hostnames are rejected. Everything else is
|
|
397
|
+
allowed: the skill trusts URLs returned by the OA APIs it already called
|
|
398
|
+
(Unpaywall, Semantic Scholar, bioRxiv, PMC), and the %PDF magic-byte +
|
|
399
|
+
50 MB size checks in `_download` catch tampered responses.
|
|
400
|
+
"""
|
|
401
|
+
ok, _reason = _is_safe_url(url)
|
|
402
|
+
return ok
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _download(url: str, dest: Path, *, timeout: int) -> str | None:
|
|
406
|
+
"""Download a PDF. Returns None on success, or an error slug on failure."""
|
|
407
|
+
if not _is_allowed_host(url):
|
|
408
|
+
_progress("download_error", reason="host_not_allowed", url=url)
|
|
409
|
+
return "host_not_allowed"
|
|
410
|
+
_rate_limit_gate()
|
|
411
|
+
req = urllib.request.Request(
|
|
412
|
+
url,
|
|
413
|
+
headers={
|
|
414
|
+
"User-Agent": DOWNLOAD_UA,
|
|
415
|
+
"Accept": "application/pdf,*/*;q=0.8",
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
try:
|
|
419
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
420
|
+
data = r.read(MAX_PDF_SIZE + 1)
|
|
421
|
+
except Exception as e:
|
|
422
|
+
# Surface HTTP status when present (urllib.error.HTTPError carries .code).
|
|
423
|
+
# Lets agents distinguish a 403 publisher block (try a VPN / different
|
|
424
|
+
# source) from a generic timeout (just retry).
|
|
425
|
+
http_status = getattr(e, "code", None)
|
|
426
|
+
fields: dict = {"reason": "network_error", "error": str(e)}
|
|
427
|
+
if isinstance(http_status, int):
|
|
428
|
+
fields["http_status"] = http_status
|
|
429
|
+
_progress("download_error", **fields)
|
|
430
|
+
return "network_error"
|
|
431
|
+
if len(data) > MAX_PDF_SIZE:
|
|
432
|
+
_progress("download_error", reason="size_exceeded", bytes=len(data), limit=MAX_PDF_SIZE)
|
|
433
|
+
return "size_exceeded"
|
|
434
|
+
if not data[:5].startswith(b"%PDF"):
|
|
435
|
+
_progress("download_error", reason="not_a_pdf")
|
|
436
|
+
return "not_a_pdf"
|
|
437
|
+
try:
|
|
438
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
dest.write_bytes(data)
|
|
440
|
+
except OSError as e:
|
|
441
|
+
_progress("download_error", reason="io_error", error=str(e))
|
|
442
|
+
return "io_error"
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# Filename helpers
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _slug(s: str, n: int = 40) -> str:
|
|
452
|
+
s = re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_")
|
|
453
|
+
return s[:n]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
_JOURNAL_STOPWORDS = {"the", "of", "and", "for", "in", "on", "a", "an", "to", "&"}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _journal_abbrev(name: str | None, max_len: int = 20) -> str:
|
|
460
|
+
"""ISO-style initials for 3+ words (PNAS, JACS, NEJM); CamelCase otherwise."""
|
|
461
|
+
if not name:
|
|
462
|
+
return ""
|
|
463
|
+
words = [w for w in re.split(r"[^A-Za-z0-9]+", name) if w and w.lower() not in _JOURNAL_STOPWORDS]
|
|
464
|
+
if not words:
|
|
465
|
+
return ""
|
|
466
|
+
if len(words) >= 3:
|
|
467
|
+
return "".join(w[0].upper() for w in words)[:max_len]
|
|
468
|
+
return "".join(w[:1].upper() + w[1:] for w in words)[:max_len]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _filename(meta: dict) -> str:
|
|
472
|
+
author = _slug((meta.get("author") or "unknown").split()[-1], 20)
|
|
473
|
+
year = str(meta.get("year") or "nd")
|
|
474
|
+
journal = _journal_abbrev(meta.get("journal"))
|
|
475
|
+
title = _slug(meta.get("title") or "paper", 40)
|
|
476
|
+
parts = [author, year]
|
|
477
|
+
if journal:
|
|
478
|
+
parts.append(journal)
|
|
479
|
+
parts.append(title)
|
|
480
|
+
return "_".join(parts) + ".pdf"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
# Source resolvers
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def try_unpaywall(doi: str, *, timeout: int) -> tuple[str | None, dict]:
|
|
489
|
+
url = f"https://api.unpaywall.org/v2/{urllib.parse.quote(doi)}?email={EMAIL}"
|
|
490
|
+
try:
|
|
491
|
+
d = _get_json(url, timeout=timeout)
|
|
492
|
+
except Exception as e:
|
|
493
|
+
_progress("source_miss", source="unpaywall", reason=str(e))
|
|
494
|
+
return None, {}
|
|
495
|
+
meta = {
|
|
496
|
+
"title": d.get("title"),
|
|
497
|
+
"year": d.get("year"),
|
|
498
|
+
"author": (d.get("z_authors") or [{}])[0].get("family") if d.get("z_authors") else None,
|
|
499
|
+
"journal": d.get("journal_name"),
|
|
500
|
+
}
|
|
501
|
+
loc = d.get("best_oa_location") or {}
|
|
502
|
+
return loc.get("url_for_pdf"), meta
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def try_semantic_scholar(doi: str, *, timeout: int) -> tuple[str | None, dict, dict]:
|
|
506
|
+
url = (
|
|
507
|
+
f"https://api.semanticscholar.org/graph/v1/paper/DOI:{urllib.parse.quote(doi)}"
|
|
508
|
+
"?fields=title,year,authors,openAccessPdf,externalIds,venue"
|
|
509
|
+
)
|
|
510
|
+
try:
|
|
511
|
+
d = _get_json(url, timeout=timeout)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
_progress("source_miss", source="semantic_scholar", reason=str(e))
|
|
514
|
+
return None, {}, {}
|
|
515
|
+
meta = {
|
|
516
|
+
"title": d.get("title"),
|
|
517
|
+
"year": d.get("year"),
|
|
518
|
+
"author": (d.get("authors") or [{}])[0].get("name"),
|
|
519
|
+
"journal": d.get("venue") or None,
|
|
520
|
+
}
|
|
521
|
+
pdf = (d.get("openAccessPdf") or {}).get("url")
|
|
522
|
+
return pdf, meta, d.get("externalIds") or {}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def try_arxiv(arxiv_id: str) -> str:
|
|
526
|
+
return f"https://arxiv.org/pdf/{arxiv_id}.pdf"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def try_pmc(pmcid: str) -> str:
|
|
530
|
+
pmcid = pmcid if pmcid.startswith("PMC") else f"PMC{pmcid}"
|
|
531
|
+
return f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/pdf/"
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def try_europe_pmc(pmcid: str) -> str:
|
|
535
|
+
"""Europe PMC's render endpoint — mirror of PMC without PoW challenge.
|
|
536
|
+
|
|
537
|
+
For articles flagged as hasPDF=Y in Europe PMC's catalog, this returns
|
|
538
|
+
the paper's PDF directly. Useful as a fallback when NCBI PMC returns
|
|
539
|
+
its cloudpmc-viewer JavaScript proof-of-work page.
|
|
540
|
+
"""
|
|
541
|
+
pmcid = pmcid if pmcid.startswith("PMC") else f"PMC{pmcid}"
|
|
542
|
+
return f"https://europepmc.org/articles/{pmcid}?pdf=render"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
_PMCID_URL_RE = re.compile(r"/pmc/articles/(PMC\d+)", re.IGNORECASE)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _pmcid_from_url(url: str | None) -> str | None:
|
|
549
|
+
"""Extract a PMCID from a URL like https://www.ncbi.nlm.nih.gov/pmc/articles/PMC123/...
|
|
550
|
+
|
|
551
|
+
S2's openAccessPdf.url often points to a PMC article without also
|
|
552
|
+
populating externalIds.PubMedCentral; parsing the URL recovers the id
|
|
553
|
+
so we can still build Europe PMC / PMC fallback candidates.
|
|
554
|
+
"""
|
|
555
|
+
if not url:
|
|
556
|
+
return None
|
|
557
|
+
m = _PMCID_URL_RE.search(url)
|
|
558
|
+
return m.group(1).upper() if m else None
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def try_biorxiv(doi: str, *, timeout: int) -> str | None:
|
|
562
|
+
if not doi.startswith("10.1101/"):
|
|
563
|
+
return None
|
|
564
|
+
for server in ("biorxiv", "medrxiv"):
|
|
565
|
+
try:
|
|
566
|
+
d = _get_json(f"https://api.biorxiv.org/details/{server}/{doi}", timeout=timeout)
|
|
567
|
+
coll = d.get("collection") or []
|
|
568
|
+
if coll:
|
|
569
|
+
latest = coll[-1]
|
|
570
|
+
return f"https://www.{server}.org/content/10.1101/{latest['doi'].split('/')[-1]}v{latest.get('version', 1)}.full.pdf"
|
|
571
|
+
except Exception:
|
|
572
|
+
continue
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ---------------------------------------------------------------------------
|
|
577
|
+
# Title → DOI resolvers (Crossref + Semantic Scholar fallback)
|
|
578
|
+
# ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
# Minimum title length we'll send to a resolver. Anything shorter is almost
|
|
581
|
+
# certainly a typo or one-word query that will return noise.
|
|
582
|
+
_MIN_TITLE_LEN = 6
|
|
583
|
+
|
|
584
|
+
# Heuristic confidence thresholds for Crossref's relevance score. The score
|
|
585
|
+
# is unitless and scales with title length, so these are calibrated to be
|
|
586
|
+
# permissive — anything obviously sloppy still produces a low_confidence
|
|
587
|
+
# flag rather than silently picking the wrong paper.
|
|
588
|
+
TITLE_SCORE_MIN = 40.0 # absolute floor; below this the top is suspect
|
|
589
|
+
TITLE_GAP_MIN = 3.0 # gap from top to runner-up; below this the top is ambiguous
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def try_crossref_title(title: str, *, timeout: int) -> tuple[str | None, dict, list[dict]]:
|
|
593
|
+
"""Resolve a paper title to a DOI via Crossref.
|
|
594
|
+
|
|
595
|
+
Crossref's relevance score is unitless and scales with title length, so we
|
|
596
|
+
don't gate on an absolute threshold — we hand the top match plus the
|
|
597
|
+
top 3 candidates back to the caller so an agent can sanity-check.
|
|
598
|
+
|
|
599
|
+
Returns ``(top_doi, top_meta, candidates)``:
|
|
600
|
+
- ``top_doi``: best-match DOI, or ``None`` if Crossref returned no items
|
|
601
|
+
- ``top_meta``: ``{title, year, author, journal, score}`` for the top hit
|
|
602
|
+
- ``candidates``: list of up to 3 candidate dicts in score order
|
|
603
|
+
"""
|
|
604
|
+
q = title.strip()
|
|
605
|
+
if len(q) < _MIN_TITLE_LEN:
|
|
606
|
+
return None, {}, []
|
|
607
|
+
# query.title outranks query.bibliographic for this use case: the input is
|
|
608
|
+
# explicitly a paper title, and bibliographic mode also weights authors/year
|
|
609
|
+
# equally — empirically that demoted the canonical AlphaFold paper below
|
|
610
|
+
# secondary "Faculty Opinions recommendation of ..." entries that share
|
|
611
|
+
# all the user's title tokens.
|
|
612
|
+
params = {
|
|
613
|
+
"query.title": q,
|
|
614
|
+
"rows": "3",
|
|
615
|
+
"select": "DOI,title,score,author,issued,container-title",
|
|
616
|
+
}
|
|
617
|
+
# Crossref's polite pool gives priority to requests that identify the
|
|
618
|
+
# caller via mailto. We already pass UA but also include mailto when the
|
|
619
|
+
# operator set UNPAYWALL_EMAIL, since the same address is theirs.
|
|
620
|
+
if EMAIL:
|
|
621
|
+
params["mailto"] = EMAIL
|
|
622
|
+
url = "https://api.crossref.org/works?" + urllib.parse.urlencode(params)
|
|
623
|
+
try:
|
|
624
|
+
data = _get_json(url, timeout=timeout)
|
|
625
|
+
except Exception as e:
|
|
626
|
+
_progress("title_resolve_failed", reason=str(e))
|
|
627
|
+
return None, {}, []
|
|
628
|
+
|
|
629
|
+
items = ((data.get("message") or {}).get("items")) or []
|
|
630
|
+
if not items:
|
|
631
|
+
return None, {}, []
|
|
632
|
+
|
|
633
|
+
candidates: list[dict] = []
|
|
634
|
+
for it in items[:3]:
|
|
635
|
+
title_list = it.get("title") or []
|
|
636
|
+
author_list = it.get("author") or []
|
|
637
|
+
first_author = ""
|
|
638
|
+
if author_list:
|
|
639
|
+
a0 = author_list[0]
|
|
640
|
+
first_author = a0.get("family") or a0.get("name") or ""
|
|
641
|
+
issued = ((it.get("issued") or {}).get("date-parts") or [[None]])[0]
|
|
642
|
+
year = issued[0] if issued and issued[0] else None
|
|
643
|
+
cont = it.get("container-title") or []
|
|
644
|
+
candidates.append({
|
|
645
|
+
"doi": it.get("DOI"),
|
|
646
|
+
"title": title_list[0] if title_list else None,
|
|
647
|
+
"year": year,
|
|
648
|
+
"author": first_author or None,
|
|
649
|
+
"journal": cont[0] if cont else None,
|
|
650
|
+
"score": it.get("score"),
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
top = candidates[0]
|
|
654
|
+
top_meta = {k: v for k, v in top.items() if k != "doi"}
|
|
655
|
+
return top.get("doi"), top_meta, candidates
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def try_semantic_scholar_match(title: str, *, timeout: int) -> tuple[str | None, dict]:
|
|
659
|
+
"""Resolve a title to a DOI via Semantic Scholar's ``/paper/search/match``.
|
|
660
|
+
|
|
661
|
+
S2's match endpoint returns at most one paper — its closest title in the
|
|
662
|
+
corpus. Better than the relevance endpoint for our use case because we
|
|
663
|
+
want exactness, not breadth. Critically, S2's corpus includes arXiv-only
|
|
664
|
+
papers that never get a Crossref DOI; for those we synthesize the
|
|
665
|
+
canonical arXiv DOI ``10.48550/arXiv.{id}`` so the downstream fetch
|
|
666
|
+
chain treats the result uniformly.
|
|
667
|
+
|
|
668
|
+
Returns ``(doi, meta)``. ``meta`` carries ``title``, ``year``, ``author``,
|
|
669
|
+
``journal``, ``paper_id``, and ``external_ids`` for caller transparency.
|
|
670
|
+
"""
|
|
671
|
+
q = title.strip()
|
|
672
|
+
if len(q) < _MIN_TITLE_LEN:
|
|
673
|
+
return None, {}
|
|
674
|
+
params = {
|
|
675
|
+
"query": q,
|
|
676
|
+
"fields": "title,authors,year,venue,externalIds",
|
|
677
|
+
}
|
|
678
|
+
url = "https://api.semanticscholar.org/graph/v1/paper/search/match?" + urllib.parse.urlencode(params)
|
|
679
|
+
try:
|
|
680
|
+
d = _get_json(url, timeout=timeout)
|
|
681
|
+
except Exception as e:
|
|
682
|
+
# 404 (no match) is the expected miss path here — the helper logs
|
|
683
|
+
# the same way for any failure since the caller treats them as miss.
|
|
684
|
+
_progress("title_resolver_miss", resolver="semantic_scholar", reason=str(e))
|
|
685
|
+
return None, {}
|
|
686
|
+
|
|
687
|
+
items = d.get("data") or []
|
|
688
|
+
if not items:
|
|
689
|
+
return None, {}
|
|
690
|
+
top = items[0]
|
|
691
|
+
ext = top.get("externalIds") or {}
|
|
692
|
+
doi = ext.get("DOI")
|
|
693
|
+
if not doi and ext.get("ArXiv"):
|
|
694
|
+
# arXiv assigns DataCite DOIs as 10.48550/arXiv.<id> (since 2022;
|
|
695
|
+
# for older preprints the DOI may not be registered, but the fetch
|
|
696
|
+
# chain's arXiv source resolver doesn't need a registered DOI —
|
|
697
|
+
# it builds the PDF URL from the arXiv id itself once S2 / Unpaywall
|
|
698
|
+
# surface it via externalIds during the download phase).
|
|
699
|
+
doi = f"10.48550/arXiv.{ext['ArXiv']}"
|
|
700
|
+
if not doi:
|
|
701
|
+
return None, {}
|
|
702
|
+
authors = top.get("authors") or []
|
|
703
|
+
return doi, {
|
|
704
|
+
"doi": doi,
|
|
705
|
+
"title": top.get("title"),
|
|
706
|
+
"year": top.get("year"),
|
|
707
|
+
"author": authors[0].get("name") if authors else None,
|
|
708
|
+
"journal": top.get("venue") or None,
|
|
709
|
+
"paper_id": top.get("paperId"),
|
|
710
|
+
"external_ids": ext,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# Publisher-direct fallback (institutional mode only)
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
# When the five OA sources all miss and the operator has opted into
|
|
718
|
+
# institutional mode, construct a publisher-side PDF URL by DOI prefix.
|
|
719
|
+
# The caller's IP / subscription cookies / EZproxy determine whether the
|
|
720
|
+
# publisher actually serves the PDF; unauthorized responses (401/403 or an
|
|
721
|
+
# HTML login page) fail the %PDF magic-byte check and the envelope surfaces
|
|
722
|
+
# download_not_a_pdf. SSRF + 50 MB + 1 req/s rate limit still apply.
|
|
723
|
+
|
|
724
|
+
_PUBLISHER_DIRECT_TEMPLATES: dict[str, tuple[str, str]] = {
|
|
725
|
+
# DOI prefix -> (publisher label, URL template).
|
|
726
|
+
# {doi} = full DOI; {suffix} = part after the prefix.
|
|
727
|
+
"10.1038/": ("nature", "https://www.nature.com/articles/{suffix}.pdf"),
|
|
728
|
+
"10.1126/": ("science", "https://www.science.org/doi/pdf/{doi}"),
|
|
729
|
+
"10.1002/": ("wiley", "https://onlinelibrary.wiley.com/doi/pdf/{doi}"),
|
|
730
|
+
"10.1007/": ("springer", "https://link.springer.com/content/pdf/{doi}.pdf"),
|
|
731
|
+
"10.1021/": ("acs", "https://pubs.acs.org/doi/pdf/{doi}"),
|
|
732
|
+
"10.1073/": ("pnas", "https://www.pnas.org/doi/pdf/{doi}"),
|
|
733
|
+
"10.1056/": ("nejm", "https://www.nejm.org/doi/pdf/{doi}"),
|
|
734
|
+
"10.1177/": ("sage", "https://journals.sagepub.com/doi/pdf/{doi}"),
|
|
735
|
+
"10.1080/": ("tandf", "https://www.tandfonline.com/doi/pdf/{doi}"),
|
|
736
|
+
# 10.1016/ (Elsevier / Cell Press) needs PII lookup — handled separately below.
|
|
737
|
+
# 10.3390/ (MDPI) needs slug lookup — handled separately below; the
|
|
738
|
+
# canonical www.mdpi.com PDF URL is gated by Akamai and 403s many
|
|
739
|
+
# data-center / non-Western IPs even on OA papers, so we route via the
|
|
740
|
+
# pub.mdpi-res.com CDN instead (see _mdpi_pdf_candidates).
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# MDPI uses a short journal abbreviation in its DOI suffix (e.g. "app" for
|
|
745
|
+
# Applied Sciences) but a longer slug in the CDN URL (e.g. "applsci"). For
|
|
746
|
+
# many journals these are identical — ijms, molecules, sensors, cells,
|
|
747
|
+
# nutrients, cancers, foods, plants, etc. — and the fallback below covers
|
|
748
|
+
# them. Only journals whose slug differs from the short need to live here.
|
|
749
|
+
# Source: MDPI's own pub.mdpi-res.com URL convention, verified against
|
|
750
|
+
# representative DOIs from each listed journal.
|
|
751
|
+
_MDPI_SHORT_TO_SLUG: dict[str, str] = {
|
|
752
|
+
"app": "applsci",
|
|
753
|
+
"su": "sustainability",
|
|
754
|
+
"ma": "materials",
|
|
755
|
+
"en": "energies",
|
|
756
|
+
"ani": "animals",
|
|
757
|
+
"polym": "polymers",
|
|
758
|
+
"antiox": "antioxidants",
|
|
759
|
+
"math": "mathematics",
|
|
760
|
+
"sym": "symmetry",
|
|
761
|
+
"nano": "nanomaterials",
|
|
762
|
+
"met": "metals",
|
|
763
|
+
"catal": "catalysts",
|
|
764
|
+
"cryst": "crystals",
|
|
765
|
+
"atmos": "atmosphere",
|
|
766
|
+
"info": "information",
|
|
767
|
+
"md": "marinedrugs",
|
|
768
|
+
"fi": "futureinternet",
|
|
769
|
+
"f": "forests",
|
|
770
|
+
"w": "water",
|
|
771
|
+
"v": "viruses",
|
|
772
|
+
"d": "diversity",
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
# DOI suffix shape for MDPI: <alpha-short><yy><iss><art>. Year and issue
|
|
776
|
+
# are 2 digits each; article fills the rest (1+ digits, padded to 5 in URL).
|
|
777
|
+
_MDPI_DOI_SUFFIX_RE = re.compile(r"^([a-z]+)(\d{2})(\d{2})(\d+)$")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _mdpi_pdf_candidates(doi: str) -> list[str]:
|
|
781
|
+
"""CDN URL candidates for an MDPI DOI (10.3390/...).
|
|
782
|
+
|
|
783
|
+
Returns 1-2 candidate URLs on pub.mdpi-res.com. Empty list if the DOI
|
|
784
|
+
suffix doesn't match the expected MDPI shape (rare; older DOIs).
|
|
785
|
+
|
|
786
|
+
Two URLs are returned when the short prefix has a known mapping AND
|
|
787
|
+
differs from the short itself, so the download loop can fall back to
|
|
788
|
+
the short-as-slug guess if the mapping is wrong or stale.
|
|
789
|
+
"""
|
|
790
|
+
if not doi.startswith("10.3390/"):
|
|
791
|
+
return []
|
|
792
|
+
suffix = doi[len("10.3390/"):]
|
|
793
|
+
m = _MDPI_DOI_SUFFIX_RE.match(suffix)
|
|
794
|
+
if not m:
|
|
795
|
+
return []
|
|
796
|
+
short, vol, _iss, art = m.groups()
|
|
797
|
+
art5 = art.zfill(5)
|
|
798
|
+
slugs: list[str] = []
|
|
799
|
+
mapped = _MDPI_SHORT_TO_SLUG.get(short)
|
|
800
|
+
if mapped:
|
|
801
|
+
slugs.append(mapped)
|
|
802
|
+
if short not in slugs:
|
|
803
|
+
slugs.append(short)
|
|
804
|
+
return [
|
|
805
|
+
f"https://pub.mdpi-res.com/{s}/{s}-{vol}-{art5}/article_deploy/{s}-{vol}-{art5}.pdf"
|
|
806
|
+
for s in slugs
|
|
807
|
+
]
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _try_publisher_direct(doi: str, *, timeout: int) -> list[tuple[str, str]]:
|
|
811
|
+
"""Construct publisher-side direct PDF URL candidates by DOI prefix.
|
|
812
|
+
|
|
813
|
+
Returns a list of (url, publisher_label) tuples in priority order, or
|
|
814
|
+
an empty list if no template matches. Multiple candidates are returned
|
|
815
|
+
when the publisher has more than one viable host (e.g. MDPI with both
|
|
816
|
+
a mapped slug and a fallback slug). The actual HTTP fetch will reveal
|
|
817
|
+
authorization failures via 401/403 or HTML responses.
|
|
818
|
+
"""
|
|
819
|
+
if doi.startswith("10.1016/"):
|
|
820
|
+
# Elsevier: resolve DOI -> PII via Crossref, then build sciencedirect URL.
|
|
821
|
+
try:
|
|
822
|
+
data = _get_json(f"https://api.crossref.org/works/{doi}", timeout=timeout)
|
|
823
|
+
except Exception:
|
|
824
|
+
return []
|
|
825
|
+
ids = (data.get("message") or {}).get("alternative-id") or []
|
|
826
|
+
pii = next(
|
|
827
|
+
(i for i in ids if isinstance(i, str) and i.startswith("S") and len(i) >= 16),
|
|
828
|
+
None,
|
|
829
|
+
)
|
|
830
|
+
if not pii:
|
|
831
|
+
return []
|
|
832
|
+
return [(f"https://www.sciencedirect.com/science/article/pii/{pii}/pdfft", "elsevier")]
|
|
833
|
+
|
|
834
|
+
if doi.startswith("10.3390/"):
|
|
835
|
+
return [(url, "mdpi") for url in _mdpi_pdf_candidates(doi)]
|
|
836
|
+
|
|
837
|
+
for prefix, (label, tmpl) in _PUBLISHER_DIRECT_TEMPLATES.items():
|
|
838
|
+
if doi.startswith(prefix):
|
|
839
|
+
suffix = doi[len(prefix):]
|
|
840
|
+
return [(tmpl.format(doi=doi, suffix=suffix), label)]
|
|
841
|
+
|
|
842
|
+
return []
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ---------------------------------------------------------------------------
|
|
846
|
+
# Sci-Hub resolver
|
|
847
|
+
# ---------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
_SCIHUB_DISCOVERY_RE = re.compile(
|
|
850
|
+
r'href=["\']https?://(?:www\.)?(sci-hub\.[a-z0-9.-]+)/?["\']',
|
|
851
|
+
re.IGNORECASE,
|
|
852
|
+
)
|
|
853
|
+
# Phrases that signal the paper is genuinely not in Sci-Hub's corpus
|
|
854
|
+
# (vs. CAPTCHA / mirror outage). Lets us short-circuit instead of cycling
|
|
855
|
+
# through every mirror.
|
|
856
|
+
_SCIHUB_NOT_IN_CORPUS_PATTERNS = (
|
|
857
|
+
re.compile(r"please\s+try\s+to\s+search\s+again\s+using\s+doi", re.IGNORECASE),
|
|
858
|
+
re.compile(r"статья\s+не\s+найдена\s+в\s+базе", re.IGNORECASE),
|
|
859
|
+
re.compile(r"article\s+not\s+found\s+in\s+(?:the\s+)?database", re.IGNORECASE),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
# Lazily populated; reset only on process restart.
|
|
863
|
+
_scihub_discovered_cache: list[str] | None = None
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _is_scihub_enabled() -> bool:
|
|
867
|
+
"""True unless operator opted out via PAPER_FETCH_NO_SCIHUB=1."""
|
|
868
|
+
return not os.environ.get("PAPER_FETCH_NO_SCIHUB")
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _scihub_mirrors() -> list[str]:
|
|
872
|
+
"""Mirror list for Sci-Hub, in priority order.
|
|
873
|
+
|
|
874
|
+
PAPER_FETCH_SCIHUB_MIRRORS (comma-sep) overrides the built-in defaults.
|
|
875
|
+
Discovery (re-scanning SCIHUB_DISCOVERY_URL) is invoked separately by
|
|
876
|
+
`try_scihub` after the configured list is exhausted.
|
|
877
|
+
"""
|
|
878
|
+
override = os.environ.get("PAPER_FETCH_SCIHUB_MIRRORS", "").strip()
|
|
879
|
+
if override:
|
|
880
|
+
return _parse_mirror_overrides(override)
|
|
881
|
+
return list(SCIHUB_DEFAULT_MIRRORS)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _parse_mirror_overrides(raw: str) -> list[str]:
|
|
885
|
+
"""Parse comma-separated mirror overrides into bare hostnames.
|
|
886
|
+
|
|
887
|
+
Accepts forms like ``sci-hub.ru``, ``https://sci-hub.ru``, or
|
|
888
|
+
``sci-hub.ru/path/`` and returns just the hostname. Empty / unsafe
|
|
889
|
+
entries (non-http(s) schemes, IP literals in private space, blocked
|
|
890
|
+
hosts) are dropped — without this, a typo in the env var could route
|
|
891
|
+
traffic at an attacker-controlled host.
|
|
892
|
+
"""
|
|
893
|
+
out: list[str] = []
|
|
894
|
+
seen: set[str] = set()
|
|
895
|
+
for raw_entry in raw.split(","):
|
|
896
|
+
entry = raw_entry.strip().rstrip("/")
|
|
897
|
+
if not entry:
|
|
898
|
+
continue
|
|
899
|
+
# Add a scheme so urlparse splits hostname correctly for bare
|
|
900
|
+
# ``sci-hub.ru`` inputs (urlparse treats them as path-only).
|
|
901
|
+
candidate = entry if "://" in entry else "https://" + entry
|
|
902
|
+
try:
|
|
903
|
+
parsed = urllib.parse.urlparse(candidate)
|
|
904
|
+
except ValueError:
|
|
905
|
+
continue
|
|
906
|
+
if parsed.scheme not in ("http", "https"):
|
|
907
|
+
continue
|
|
908
|
+
host = (parsed.hostname or "").lower()
|
|
909
|
+
if not host or host in seen:
|
|
910
|
+
continue
|
|
911
|
+
# Reuse the universal SSRF guard so an override of e.g.
|
|
912
|
+
# ``localhost`` or a private IP literal is dropped.
|
|
913
|
+
ok, _ = _is_safe_url(f"https://{host}/")
|
|
914
|
+
if not ok:
|
|
915
|
+
continue
|
|
916
|
+
seen.add(host)
|
|
917
|
+
out.append(host)
|
|
918
|
+
return out
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _scihub_is_not_in_corpus(html: str) -> bool:
|
|
922
|
+
"""True if the HTML matches a known 'paper not in database' message.
|
|
923
|
+
|
|
924
|
+
Lets the resolver skip the remaining mirrors when continuing is pointless
|
|
925
|
+
(every mirror serves the same shared corpus). Distinct from CAPTCHA, which
|
|
926
|
+
looks like an empty or challenge page — for that we still rotate mirrors.
|
|
927
|
+
"""
|
|
928
|
+
return any(p.search(html) for p in _SCIHUB_NOT_IN_CORPUS_PATTERNS)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
class _ScihubEmbedFinder(html.parser.HTMLParser):
|
|
932
|
+
"""Collect <iframe>/<embed> tags from Sci-Hub paper pages.
|
|
933
|
+
|
|
934
|
+
Order-independent attribute capture — unlike the prior regex, an
|
|
935
|
+
``<iframe src="..." id="pdf">`` is treated identically to
|
|
936
|
+
``<iframe id="pdf" src="...">``. Records all candidates so the caller
|
|
937
|
+
can prefer ``id="pdf"`` and fall back to any ``.pdf`` src.
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
def __init__(self) -> None:
|
|
941
|
+
super().__init__(convert_charrefs=True)
|
|
942
|
+
# list of (id_attr_lower, src_attr) tuples, in document order.
|
|
943
|
+
self.candidates: list[tuple[str, str]] = []
|
|
944
|
+
|
|
945
|
+
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|
946
|
+
self._maybe_record(tag, attrs)
|
|
947
|
+
|
|
948
|
+
def handle_startendtag(self, tag: str, attrs: list) -> None:
|
|
949
|
+
# Self-closing variant (<embed ... />) — still want to capture.
|
|
950
|
+
self._maybe_record(tag, attrs)
|
|
951
|
+
|
|
952
|
+
def _maybe_record(self, tag: str, attrs: list) -> None:
|
|
953
|
+
if tag.lower() not in ("iframe", "embed"):
|
|
954
|
+
return
|
|
955
|
+
attr_map = {(k or "").lower(): (v or "") for k, v in attrs}
|
|
956
|
+
src = attr_map.get("src", "").strip()
|
|
957
|
+
if not src:
|
|
958
|
+
return
|
|
959
|
+
self.candidates.append((attr_map.get("id", "").lower(), src))
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _scihub_normalize_pdf_url(url: str, mirror_host: str | None) -> str | None:
|
|
963
|
+
"""Normalize a candidate src into an absolute https URL.
|
|
964
|
+
|
|
965
|
+
Returns None if the URL is path-relative without a mirror context to
|
|
966
|
+
anchor it against — the caller will fall back to another mirror.
|
|
967
|
+
"""
|
|
968
|
+
if url.startswith("//"):
|
|
969
|
+
return "https:" + url
|
|
970
|
+
if url.startswith("/"):
|
|
971
|
+
if not mirror_host:
|
|
972
|
+
return None
|
|
973
|
+
return f"https://{mirror_host}{url}"
|
|
974
|
+
if url.startswith("http://"):
|
|
975
|
+
return "https://" + url[len("http://"):]
|
|
976
|
+
return url
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _scihub_extract_iframe(html_text: str, mirror_host: str | None = None) -> str | None:
|
|
980
|
+
"""Extract the embedded PDF URL from a Sci-Hub paper page.
|
|
981
|
+
|
|
982
|
+
Sci-Hub returns an HTML page with an <iframe src="...pdf"> (or sometimes
|
|
983
|
+
an <embed src="...pdf">) pointing at the actual PDF on a CDN. Returns
|
|
984
|
+
the absolute https:// URL, or None if no embed found (CAPTCHA, missing
|
|
985
|
+
paper, or layout change). When `mirror_host` is provided, path-relative
|
|
986
|
+
URLs (e.g. `/downloads/abc.pdf`) are resolved against it.
|
|
987
|
+
"""
|
|
988
|
+
finder = _ScihubEmbedFinder()
|
|
989
|
+
try:
|
|
990
|
+
finder.feed(html_text)
|
|
991
|
+
except Exception:
|
|
992
|
+
# Malformed markup — bail to None so the caller rotates mirrors.
|
|
993
|
+
return None
|
|
994
|
+
|
|
995
|
+
# Prefer tags carrying id="pdf" regardless of attribute order in the source.
|
|
996
|
+
# Within each tier, prefer entries whose src contains ".pdf".
|
|
997
|
+
pdf_id = [(i, s) for i, s in finder.candidates if i == "pdf"]
|
|
998
|
+
other = [(i, s) for i, s in finder.candidates if i != "pdf"]
|
|
999
|
+
|
|
1000
|
+
for tier in (pdf_id, other):
|
|
1001
|
+
# First pass within tier — strict ".pdf" hint.
|
|
1002
|
+
for _, src in tier:
|
|
1003
|
+
if ".pdf" not in src.lower():
|
|
1004
|
+
continue
|
|
1005
|
+
normalized = _scihub_normalize_pdf_url(src.strip(), mirror_host)
|
|
1006
|
+
if normalized:
|
|
1007
|
+
return normalized
|
|
1008
|
+
# Second pass within the id="pdf" tier — Sci-Hub sometimes serves
|
|
1009
|
+
# an obfuscated CDN URL without the ``.pdf`` extension. Trust the
|
|
1010
|
+
# explicit id anchor over filename hints.
|
|
1011
|
+
if tier is pdf_id:
|
|
1012
|
+
for _, src in tier:
|
|
1013
|
+
normalized = _scihub_normalize_pdf_url(src.strip(), mirror_host)
|
|
1014
|
+
if normalized:
|
|
1015
|
+
return normalized
|
|
1016
|
+
return None
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _scihub_discover_mirrors(*, timeout: int) -> list[str]:
|
|
1020
|
+
"""Scrape SCIHUB_DISCOVERY_URL for current mirror list. Cached per process."""
|
|
1021
|
+
global _scihub_discovered_cache
|
|
1022
|
+
if _scihub_discovered_cache is not None:
|
|
1023
|
+
return _scihub_discovered_cache
|
|
1024
|
+
try:
|
|
1025
|
+
html = _get(SCIHUB_DISCOVERY_URL, accept="text/html", timeout=timeout).decode("utf-8", "replace")
|
|
1026
|
+
except Exception as e:
|
|
1027
|
+
_progress("scihub_discover_failed", reason=str(e))
|
|
1028
|
+
_scihub_discovered_cache = []
|
|
1029
|
+
return []
|
|
1030
|
+
found: list[str] = []
|
|
1031
|
+
seen: set[str] = set()
|
|
1032
|
+
for m in _SCIHUB_DISCOVERY_RE.finditer(html):
|
|
1033
|
+
host = m.group(1).lower()
|
|
1034
|
+
if host in seen:
|
|
1035
|
+
continue
|
|
1036
|
+
seen.add(host)
|
|
1037
|
+
found.append(host)
|
|
1038
|
+
_scihub_discovered_cache = found
|
|
1039
|
+
if found:
|
|
1040
|
+
_progress("scihub_discover_ok", mirrors=found)
|
|
1041
|
+
return found
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def try_scihub(doi: str, *, timeout: int) -> tuple[str, str] | None:
|
|
1045
|
+
"""Resolve a DOI to a PDF URL via Sci-Hub mirrors.
|
|
1046
|
+
|
|
1047
|
+
Tries the configured mirror list in order; on exhaustion, performs a
|
|
1048
|
+
one-shot discovery scan of SCIHUB_DISCOVERY_URL and tries any new
|
|
1049
|
+
mirrors. Returns `(pdf_url, mirror_host)` on hit so the caller can
|
|
1050
|
+
surface which mirror succeeded; returns None if every mirror missed.
|
|
1051
|
+
|
|
1052
|
+
Short-circuits when a mirror explicitly reports the paper is not in
|
|
1053
|
+
Sci-Hub's database — every mirror shares one corpus, so cycling further
|
|
1054
|
+
just wastes round trips.
|
|
1055
|
+
"""
|
|
1056
|
+
tried: set[str] = set()
|
|
1057
|
+
mirrors = _scihub_mirrors()
|
|
1058
|
+
|
|
1059
|
+
def _try_one(host: str) -> tuple[str | None, str]:
|
|
1060
|
+
"""Returns (pdf_url, status). status is 'pdf' | 'no_pdf' | 'not_in_corpus' | 'error'."""
|
|
1061
|
+
url = f"https://{host}/{doi}"
|
|
1062
|
+
if not _is_allowed_host(url):
|
|
1063
|
+
return None, "error"
|
|
1064
|
+
_scihub_rate_gate()
|
|
1065
|
+
try:
|
|
1066
|
+
html = _get(
|
|
1067
|
+
url,
|
|
1068
|
+
accept="text/html,application/xhtml+xml",
|
|
1069
|
+
timeout=timeout,
|
|
1070
|
+
user_agent=SCIHUB_UA,
|
|
1071
|
+
).decode("utf-8", "replace")
|
|
1072
|
+
except Exception:
|
|
1073
|
+
return None, "error"
|
|
1074
|
+
pdf = _scihub_extract_iframe(html, mirror_host=host)
|
|
1075
|
+
if pdf:
|
|
1076
|
+
return pdf, "pdf"
|
|
1077
|
+
if _scihub_is_not_in_corpus(html):
|
|
1078
|
+
return None, "not_in_corpus"
|
|
1079
|
+
return None, "no_pdf"
|
|
1080
|
+
|
|
1081
|
+
def _walk(hosts: list[str]) -> tuple[tuple[str, str] | None, bool]:
|
|
1082
|
+
"""Returns ((pdf_url, mirror) | None, gave_up). gave_up=True on confirmed not-in-corpus."""
|
|
1083
|
+
for host in hosts:
|
|
1084
|
+
if host in tried:
|
|
1085
|
+
continue
|
|
1086
|
+
tried.add(host)
|
|
1087
|
+
pdf, status = _try_one(host)
|
|
1088
|
+
if pdf:
|
|
1089
|
+
return (pdf, host), False
|
|
1090
|
+
if status == "not_in_corpus":
|
|
1091
|
+
_progress("source_miss", source="scihub", reason="not_in_corpus", mirror=host)
|
|
1092
|
+
return None, True
|
|
1093
|
+
return None, False
|
|
1094
|
+
|
|
1095
|
+
hit, gave_up = _walk(mirrors)
|
|
1096
|
+
if hit or gave_up:
|
|
1097
|
+
return hit
|
|
1098
|
+
|
|
1099
|
+
fresh = _scihub_discover_mirrors(timeout=timeout)
|
|
1100
|
+
hit, _ = _walk(fresh)
|
|
1101
|
+
return hit
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
# ---------------------------------------------------------------------------
|
|
1105
|
+
# Core fetch logic
|
|
1106
|
+
# ---------------------------------------------------------------------------
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _download_failure(
|
|
1110
|
+
doi: str,
|
|
1111
|
+
meta: dict,
|
|
1112
|
+
sources_tried: list[str],
|
|
1113
|
+
errors: list[dict],
|
|
1114
|
+
*,
|
|
1115
|
+
candidates: list[tuple[str, str]] | None = None,
|
|
1116
|
+
) -> dict:
|
|
1117
|
+
"""Build a per-item download failure result. `errors` must be non-empty."""
|
|
1118
|
+
last = errors[-1]
|
|
1119
|
+
retryable = last["reason"] in ("network_error", "size_exceeded", "io_error")
|
|
1120
|
+
code = f"download_{last['reason']}"
|
|
1121
|
+
err_obj = {
|
|
1122
|
+
"code": code,
|
|
1123
|
+
"message": (
|
|
1124
|
+
f"All {len(errors)} candidate(s) failed; last error from {last['source']}: {last['reason']}"
|
|
1125
|
+
if len(errors) > 1
|
|
1126
|
+
else f"Download failed from {last['source']}: {last['reason']}"
|
|
1127
|
+
),
|
|
1128
|
+
"retryable": retryable,
|
|
1129
|
+
}
|
|
1130
|
+
if retryable and code in RETRY_AFTER_HOURS:
|
|
1131
|
+
err_obj["retry_after_hours"] = RETRY_AFTER_HOURS[code]
|
|
1132
|
+
out = {
|
|
1133
|
+
"doi": doi,
|
|
1134
|
+
"success": False,
|
|
1135
|
+
"source": last["source"],
|
|
1136
|
+
"pdf_url": last["url"],
|
|
1137
|
+
"file": None,
|
|
1138
|
+
"meta": meta or {},
|
|
1139
|
+
"sources_tried": sources_tried,
|
|
1140
|
+
"download_attempts": errors,
|
|
1141
|
+
"error": err_obj,
|
|
1142
|
+
}
|
|
1143
|
+
if candidates:
|
|
1144
|
+
out["candidates"] = [{"source": s, "url": u} for s, u in candidates]
|
|
1145
|
+
return out
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def fetch(
|
|
1149
|
+
doi: str,
|
|
1150
|
+
out_dir: Path,
|
|
1151
|
+
*,
|
|
1152
|
+
dry_run: bool,
|
|
1153
|
+
overwrite: bool,
|
|
1154
|
+
timeout: int,
|
|
1155
|
+
) -> dict:
|
|
1156
|
+
"""Resolve and optionally download a single DOI.
|
|
1157
|
+
|
|
1158
|
+
Returns a structured per-item result (not an envelope). Guaranteed keys:
|
|
1159
|
+
doi, success, source, pdf_url, file, meta, sources_tried, error?
|
|
1160
|
+
"""
|
|
1161
|
+
doi = doi.strip()
|
|
1162
|
+
# str.removeprefix is Python 3.9+; README advertises 3.8+.
|
|
1163
|
+
for _prefix in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/", "http://dx.doi.org/", "doi.org/", "dx.doi.org/", "doi:"):
|
|
1164
|
+
if doi.startswith(_prefix):
|
|
1165
|
+
doi = doi[len(_prefix):]
|
|
1166
|
+
break
|
|
1167
|
+
# Reject anything that doesn't match the documented DOI pattern before
|
|
1168
|
+
# we start hitting external APIs. Saves a round-trip on typos and keeps
|
|
1169
|
+
# the runtime contract aligned with the schema's `params.doi.pattern`.
|
|
1170
|
+
if not _DOI_RE.match(doi):
|
|
1171
|
+
return {
|
|
1172
|
+
"doi": doi,
|
|
1173
|
+
"success": False,
|
|
1174
|
+
"source": None,
|
|
1175
|
+
"pdf_url": None,
|
|
1176
|
+
"file": None,
|
|
1177
|
+
"meta": {},
|
|
1178
|
+
"sources_tried": [],
|
|
1179
|
+
"error": {
|
|
1180
|
+
"code": "validation_error",
|
|
1181
|
+
"message": f"Not a valid DOI: {doi!r} (expected pattern {DOI_PATTERN})",
|
|
1182
|
+
"retryable": False,
|
|
1183
|
+
},
|
|
1184
|
+
}
|
|
1185
|
+
_progress("start", doi=doi)
|
|
1186
|
+
|
|
1187
|
+
sources_tried: list[str] = []
|
|
1188
|
+
meta: dict = {}
|
|
1189
|
+
download_errors: list[dict] = []
|
|
1190
|
+
|
|
1191
|
+
# Fatal download errors that abort the fallback loop. Only host-independent
|
|
1192
|
+
# local failures qualify (e.g., disk write failed). ``size_exceeded`` is per-URL,
|
|
1193
|
+
# so a bloated copy from one source should not prevent trying a smaller copy from another.
|
|
1194
|
+
FATAL_DL_ERRORS = ("io_error",)
|
|
1195
|
+
|
|
1196
|
+
def _merge_meta(extra: dict) -> list[str]:
|
|
1197
|
+
added: list[str] = []
|
|
1198
|
+
for k, v in (extra or {}).items():
|
|
1199
|
+
if v and not meta.get(k):
|
|
1200
|
+
meta[k] = v
|
|
1201
|
+
added.append(k)
|
|
1202
|
+
return added
|
|
1203
|
+
|
|
1204
|
+
# --- Semantic Scholar is queried lazily (cached). Provides metadata,
|
|
1205
|
+
# its own PDF URL, and externalIds (PMCID, arXiv id) used to construct
|
|
1206
|
+
# additional candidates. Only called when needed so that a successful
|
|
1207
|
+
# Unpaywall hit with complete metadata short-circuits the flow. ---
|
|
1208
|
+
_s2_cache: dict | None = None
|
|
1209
|
+
|
|
1210
|
+
def _get_s2() -> tuple[str | None, dict, dict]:
|
|
1211
|
+
nonlocal _s2_cache
|
|
1212
|
+
if _s2_cache is not None:
|
|
1213
|
+
return _s2_cache["pdf"], _s2_cache["meta"], _s2_cache["ext"]
|
|
1214
|
+
if "semantic_scholar" not in sources_tried:
|
|
1215
|
+
sources_tried.append("semantic_scholar")
|
|
1216
|
+
_progress("source_try", doi=doi, source="semantic_scholar")
|
|
1217
|
+
pdf, s2_meta, ext = try_semantic_scholar(doi, timeout=timeout)
|
|
1218
|
+
_s2_cache = {"pdf": pdf, "meta": s2_meta, "ext": ext}
|
|
1219
|
+
return pdf, s2_meta, ext
|
|
1220
|
+
|
|
1221
|
+
# --- Unpaywall first (often the quickest OA link) ---
|
|
1222
|
+
up_url: str | None = None
|
|
1223
|
+
if EMAIL:
|
|
1224
|
+
_progress("source_try", doi=doi, source="unpaywall")
|
|
1225
|
+
sources_tried.append("unpaywall")
|
|
1226
|
+
up_url, up_meta = try_unpaywall(doi, timeout=timeout)
|
|
1227
|
+
_merge_meta(up_meta)
|
|
1228
|
+
if up_url:
|
|
1229
|
+
_progress("source_hit", doi=doi, source="unpaywall", pdf_url=up_url)
|
|
1230
|
+
# Enrich metadata from S2 if Unpaywall didn't give us author/title
|
|
1231
|
+
# (prevents unknown_<year>_paper.pdf filenames).
|
|
1232
|
+
if not meta.get("author") or not meta.get("title"):
|
|
1233
|
+
_, s2_meta, _ = _get_s2()
|
|
1234
|
+
added = _merge_meta(s2_meta)
|
|
1235
|
+
if added:
|
|
1236
|
+
_progress("source_enrich", doi=doi, source="semantic_scholar", fields=added)
|
|
1237
|
+
elif not s2_meta:
|
|
1238
|
+
_progress("source_enrich_failed", doi=doi, source="semantic_scholar", reason="s2_unavailable")
|
|
1239
|
+
else:
|
|
1240
|
+
_progress("source_miss", doi=doi, source="unpaywall")
|
|
1241
|
+
else:
|
|
1242
|
+
_progress("source_skip", doi=doi, source="unpaywall", reason="UNPAYWALL_EMAIL not set")
|
|
1243
|
+
|
|
1244
|
+
# --- Compute destination filename from merged meta ---
|
|
1245
|
+
fname = _filename(meta or {"title": doi})
|
|
1246
|
+
dest = out_dir / fname
|
|
1247
|
+
|
|
1248
|
+
# Per-source diagnostics (mirror that succeeded, publisher label, etc.)
|
|
1249
|
+
# surfaced in the result envelope under `source_detail`. Keyed by source label.
|
|
1250
|
+
source_details: dict[str, dict] = {}
|
|
1251
|
+
|
|
1252
|
+
def _success(src: str, url: str, extra: dict | None = None) -> dict:
|
|
1253
|
+
out = {
|
|
1254
|
+
"doi": doi,
|
|
1255
|
+
"success": True,
|
|
1256
|
+
"source": src,
|
|
1257
|
+
"pdf_url": url,
|
|
1258
|
+
"file": str(dest),
|
|
1259
|
+
"meta": meta or {},
|
|
1260
|
+
"sources_tried": sources_tried,
|
|
1261
|
+
}
|
|
1262
|
+
if src in source_details:
|
|
1263
|
+
out["source_detail"] = source_details[src]
|
|
1264
|
+
if extra:
|
|
1265
|
+
out.update(extra)
|
|
1266
|
+
return out
|
|
1267
|
+
|
|
1268
|
+
# --- Try Unpaywall's PDF first (if we have one) ---
|
|
1269
|
+
if up_url:
|
|
1270
|
+
if dry_run:
|
|
1271
|
+
_progress("dry_run", doi=doi, source="unpaywall", pdf_url=up_url, file=str(dest))
|
|
1272
|
+
return _success("unpaywall", up_url, {"dry_run": True})
|
|
1273
|
+
if dest.exists() and not overwrite:
|
|
1274
|
+
_progress("download_skip", doi=doi, file=str(dest))
|
|
1275
|
+
return _success("unpaywall", up_url, {"skipped": True, "skip_reason": "file_exists"})
|
|
1276
|
+
dl_err = _download(up_url, dest, timeout=timeout)
|
|
1277
|
+
if dl_err is None:
|
|
1278
|
+
_progress("download_ok", doi=doi, file=str(dest), source="unpaywall")
|
|
1279
|
+
return _success("unpaywall", up_url)
|
|
1280
|
+
download_errors.append({"source": "unpaywall", "url": up_url, "reason": dl_err})
|
|
1281
|
+
if dl_err in FATAL_DL_ERRORS:
|
|
1282
|
+
return _download_failure(doi, meta, sources_tried, download_errors)
|
|
1283
|
+
# Non-fatal download failure — fall through to additional sources as fallback.
|
|
1284
|
+
|
|
1285
|
+
# --- Force S2 lookup (for fallback PDF URL + externalIds) ---
|
|
1286
|
+
s2_pdf, s2_meta, ext = _get_s2()
|
|
1287
|
+
_merge_meta(s2_meta)
|
|
1288
|
+
|
|
1289
|
+
# If the Unpaywall path ran the file-exists check and skipped, we already returned above.
|
|
1290
|
+
# For the remaining sources, check destination once more in case enrichment changed the name.
|
|
1291
|
+
fname = _filename(meta or {"title": doi})
|
|
1292
|
+
dest = out_dir / fname
|
|
1293
|
+
|
|
1294
|
+
# --- Build fallback candidate list (deduped by URL) ---
|
|
1295
|
+
# Any URL already attempted via Unpaywall is skipped — no point retrying
|
|
1296
|
+
# the exact same URL from a different source label.
|
|
1297
|
+
attempted_urls: set[str] = {e["url"] for e in download_errors}
|
|
1298
|
+
candidates: list[tuple[str, str]] = []
|
|
1299
|
+
|
|
1300
|
+
def _add(src: str, url: str) -> None:
|
|
1301
|
+
if url in attempted_urls:
|
|
1302
|
+
return
|
|
1303
|
+
if any(u == url for _, u in candidates):
|
|
1304
|
+
return
|
|
1305
|
+
attempted_urls.add(url)
|
|
1306
|
+
candidates.append((src, url))
|
|
1307
|
+
|
|
1308
|
+
if s2_pdf:
|
|
1309
|
+
_progress("source_hit", doi=doi, source="semantic_scholar", pdf_url=s2_pdf)
|
|
1310
|
+
_add("semantic_scholar", s2_pdf)
|
|
1311
|
+
elif not up_url:
|
|
1312
|
+
_progress("source_miss", doi=doi, source="semantic_scholar")
|
|
1313
|
+
|
|
1314
|
+
if ext.get("ArXiv"):
|
|
1315
|
+
sources_tried.append("arxiv")
|
|
1316
|
+
arxiv_url = try_arxiv(ext["ArXiv"])
|
|
1317
|
+
_progress("source_hit", doi=doi, source="arxiv", pdf_url=arxiv_url)
|
|
1318
|
+
_add("arxiv", arxiv_url)
|
|
1319
|
+
|
|
1320
|
+
# Recover PMCID from any PMC-style URL we've seen (S2 openAccessPdf often
|
|
1321
|
+
# points to a PMC landing page without populating externalIds.PubMedCentral).
|
|
1322
|
+
if not ext.get("PubMedCentral"):
|
|
1323
|
+
for url_src in (up_url, s2_pdf):
|
|
1324
|
+
pmcid_from_url = _pmcid_from_url(url_src)
|
|
1325
|
+
if pmcid_from_url:
|
|
1326
|
+
ext["PubMedCentral"] = pmcid_from_url
|
|
1327
|
+
break
|
|
1328
|
+
|
|
1329
|
+
if ext.get("PubMedCentral"):
|
|
1330
|
+
# Europe PMC tried first — bypasses NCBI PMC's cloudpmc-viewer JS challenge.
|
|
1331
|
+
sources_tried.append("europe_pmc")
|
|
1332
|
+
epmc_url = try_europe_pmc(ext["PubMedCentral"])
|
|
1333
|
+
_progress("source_hit", doi=doi, source="europe_pmc", pdf_url=epmc_url)
|
|
1334
|
+
_add("europe_pmc", epmc_url)
|
|
1335
|
+
sources_tried.append("pmc")
|
|
1336
|
+
pmc_url = try_pmc(ext["PubMedCentral"])
|
|
1337
|
+
_progress("source_hit", doi=doi, source="pmc", pdf_url=pmc_url)
|
|
1338
|
+
_add("pmc", pmc_url)
|
|
1339
|
+
|
|
1340
|
+
if doi.startswith("10.1101/"):
|
|
1341
|
+
_progress("source_try", doi=doi, source="biorxiv")
|
|
1342
|
+
sources_tried.append("biorxiv")
|
|
1343
|
+
bx_url = try_biorxiv(doi, timeout=timeout)
|
|
1344
|
+
if bx_url:
|
|
1345
|
+
_progress("source_hit", doi=doi, source="biorxiv", pdf_url=bx_url)
|
|
1346
|
+
_add("biorxiv", bx_url)
|
|
1347
|
+
else:
|
|
1348
|
+
_progress("source_miss", doi=doi, source="biorxiv")
|
|
1349
|
+
|
|
1350
|
+
# --- Publisher-direct fallback (institutional mode only) ---
|
|
1351
|
+
# Runs only when the operator has opted into institutional mode. The
|
|
1352
|
+
# caller's IP / cookies / EZproxy are what actually authorize the fetch.
|
|
1353
|
+
if _is_institutional():
|
|
1354
|
+
_progress("source_try", doi=doi, source="publisher_direct")
|
|
1355
|
+
pub_candidates = _try_publisher_direct(doi, timeout=timeout)
|
|
1356
|
+
if pub_candidates:
|
|
1357
|
+
sources_tried.append("publisher_direct")
|
|
1358
|
+
for pub_url, pub_label in pub_candidates:
|
|
1359
|
+
_progress("source_hit", doi=doi, source="publisher_direct", pdf_url=pub_url, publisher=pub_label)
|
|
1360
|
+
_add("publisher_direct", pub_url)
|
|
1361
|
+
else:
|
|
1362
|
+
_progress("source_miss", doi=doi, source="publisher_direct", reason="no_template_for_doi_prefix")
|
|
1363
|
+
|
|
1364
|
+
# --- Sci-Hub fallback (last resort) ---
|
|
1365
|
+
# Mirror list comes from PAPER_FETCH_SCIHUB_MIRRORS or the built-in defaults;
|
|
1366
|
+
# exhaustion triggers a one-shot scan of SCIHUB_DISCOVERY_URL for fresh mirrors.
|
|
1367
|
+
# Disabled with PAPER_FETCH_NO_SCIHUB=1.
|
|
1368
|
+
def _try_scihub_resolve() -> str | None:
|
|
1369
|
+
if not _is_scihub_enabled():
|
|
1370
|
+
return None
|
|
1371
|
+
if "scihub" in sources_tried:
|
|
1372
|
+
return None
|
|
1373
|
+
_progress("source_try", doi=doi, source="scihub")
|
|
1374
|
+
sources_tried.append("scihub")
|
|
1375
|
+
sh_hit = try_scihub(doi, timeout=timeout)
|
|
1376
|
+
if not sh_hit:
|
|
1377
|
+
_progress("source_miss", doi=doi, source="scihub")
|
|
1378
|
+
return None
|
|
1379
|
+
sh_url, sh_mirror = sh_hit
|
|
1380
|
+
source_details["scihub"] = {"mirror": sh_mirror}
|
|
1381
|
+
_progress("source_hit", doi=doi, source="scihub", pdf_url=sh_url, mirror=sh_mirror)
|
|
1382
|
+
return sh_url
|
|
1383
|
+
|
|
1384
|
+
# First Sci-Hub pass: runs when no OA candidates resolved at all (regardless
|
|
1385
|
+
# of whether Unpaywall produced a non-fatal download error). The download
|
|
1386
|
+
# loop below treats Sci-Hub like any other candidate.
|
|
1387
|
+
if not candidates:
|
|
1388
|
+
sh_url = _try_scihub_resolve()
|
|
1389
|
+
if sh_url:
|
|
1390
|
+
_add("scihub", sh_url)
|
|
1391
|
+
|
|
1392
|
+
# --- Exhausted all sources with no candidates and no prior attempts → not_found ---
|
|
1393
|
+
if not candidates and not download_errors:
|
|
1394
|
+
_progress("not_found", doi=doi)
|
|
1395
|
+
err = {
|
|
1396
|
+
"code": "not_found",
|
|
1397
|
+
"message": "No open-access PDF found",
|
|
1398
|
+
"retryable": True,
|
|
1399
|
+
"retry_after_hours": RETRY_AFTER_HOURS["not_found"],
|
|
1400
|
+
"reason": "OA availability changes over time; retry after embargo lifts or preprint appears",
|
|
1401
|
+
}
|
|
1402
|
+
# In public mode, suggest institutional access as a next avenue.
|
|
1403
|
+
# Silent in institutional mode — if they're already opted in and the
|
|
1404
|
+
# paper still wasn't found, the subscription doesn't cover it.
|
|
1405
|
+
if not _is_institutional():
|
|
1406
|
+
err["suggest_institutional"] = True
|
|
1407
|
+
err["hint"] = (
|
|
1408
|
+
"If your institution has a subscription to this paper, "
|
|
1409
|
+
"set PAPER_FETCH_INSTITUTIONAL=1 and run from on-campus or VPN."
|
|
1410
|
+
)
|
|
1411
|
+
return {
|
|
1412
|
+
"doi": doi,
|
|
1413
|
+
"success": False,
|
|
1414
|
+
"source": None,
|
|
1415
|
+
"pdf_url": None,
|
|
1416
|
+
"file": None,
|
|
1417
|
+
"meta": meta or {},
|
|
1418
|
+
"sources_tried": sources_tried,
|
|
1419
|
+
"error": err,
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
# --- Dry-run preview of first fallback candidate (only reached when Unpaywall didn't hit) ---
|
|
1423
|
+
if dry_run and candidates:
|
|
1424
|
+
src0, url0 = candidates[0]
|
|
1425
|
+
_progress("dry_run", doi=doi, source=src0, pdf_url=url0, file=str(dest))
|
|
1426
|
+
return _success(src0, url0, {"dry_run": True, "candidates": [{"source": s, "url": u} for s, u in candidates]})
|
|
1427
|
+
|
|
1428
|
+
# --- File-exists skip on first candidate (non-Unpaywall path) ---
|
|
1429
|
+
if candidates and dest.exists() and not overwrite:
|
|
1430
|
+
src0, url0 = candidates[0]
|
|
1431
|
+
_progress("download_skip", doi=doi, file=str(dest))
|
|
1432
|
+
return _success(src0, url0, {"skipped": True, "skip_reason": "file_exists"})
|
|
1433
|
+
|
|
1434
|
+
# --- Fallback download loop ---
|
|
1435
|
+
fatal_seen = False
|
|
1436
|
+
for cand_src, cand_url in candidates:
|
|
1437
|
+
dl_err = _download(cand_url, dest, timeout=timeout)
|
|
1438
|
+
if dl_err is None:
|
|
1439
|
+
_progress("download_ok", doi=doi, file=str(dest), source=cand_src)
|
|
1440
|
+
return _success(cand_src, cand_url, {"candidates": [{"source": s, "url": u} for s, u in candidates]})
|
|
1441
|
+
download_errors.append({"source": cand_src, "url": cand_url, "reason": dl_err})
|
|
1442
|
+
if dl_err in FATAL_DL_ERRORS:
|
|
1443
|
+
fatal_seen = True
|
|
1444
|
+
break
|
|
1445
|
+
|
|
1446
|
+
# Second Sci-Hub pass: every OA candidate produced a URL but none of them
|
|
1447
|
+
# could actually be downloaded (e.g. CAPTCHA, broken link, blocked host).
|
|
1448
|
+
# Try Sci-Hub now if it hasn't already been attempted, and if no fatal
|
|
1449
|
+
# local error (io_error) terminated the loop.
|
|
1450
|
+
if not fatal_seen and "scihub" not in sources_tried:
|
|
1451
|
+
sh_url = _try_scihub_resolve()
|
|
1452
|
+
if sh_url and sh_url not in attempted_urls:
|
|
1453
|
+
attempted_urls.add(sh_url)
|
|
1454
|
+
candidates.append(("scihub", sh_url))
|
|
1455
|
+
dl_err = _download(sh_url, dest, timeout=timeout)
|
|
1456
|
+
if dl_err is None:
|
|
1457
|
+
_progress("download_ok", doi=doi, file=str(dest), source="scihub")
|
|
1458
|
+
return _success("scihub", sh_url, {"candidates": [{"source": s, "url": u} for s, u in candidates]})
|
|
1459
|
+
download_errors.append({"source": "scihub", "url": sh_url, "reason": dl_err})
|
|
1460
|
+
|
|
1461
|
+
return _download_failure(doi, meta, sources_tried, download_errors, candidates=candidates)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
# ---------------------------------------------------------------------------
|
|
1465
|
+
# Idempotency sidecar
|
|
1466
|
+
# ---------------------------------------------------------------------------
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _idem_path(out_dir: Path, key: str) -> Path:
|
|
1470
|
+
safe = _slug(key, 80) or "default"
|
|
1471
|
+
return out_dir / ".paper-fetch-idem" / f"{safe}.json"
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _idem_load(out_dir: Path, key: str) -> dict | None:
|
|
1475
|
+
p = _idem_path(out_dir, key)
|
|
1476
|
+
if not p.exists():
|
|
1477
|
+
return None
|
|
1478
|
+
try:
|
|
1479
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
1480
|
+
except Exception:
|
|
1481
|
+
return None
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _idem_store(out_dir: Path, key: str, envelope: dict) -> None:
|
|
1485
|
+
p = _idem_path(out_dir, key)
|
|
1486
|
+
try:
|
|
1487
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1488
|
+
p.write_text(json.dumps(envelope, ensure_ascii=False), encoding="utf-8")
|
|
1489
|
+
except OSError:
|
|
1490
|
+
pass # best-effort only
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
# ---------------------------------------------------------------------------
|
|
1494
|
+
# Schema subcommand
|
|
1495
|
+
# ---------------------------------------------------------------------------
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def build_schema() -> dict:
|
|
1499
|
+
return {
|
|
1500
|
+
"command": "paper-fetch",
|
|
1501
|
+
"cli_version": CLI_VERSION,
|
|
1502
|
+
"schema_version": SCHEMA_VERSION,
|
|
1503
|
+
"description": "Fetch PDFs by DOI via Unpaywall, Semantic Scholar, arXiv, Europe PMC, PMC, and bioRxiv/medRxiv. In institutional mode (PAPER_FETCH_INSTITUTIONAL=1), also attempts a publisher-direct fetch (publisher_direct source) using the caller's own subscription IP / cookies / EZproxy. As a last resort, falls back to Sci-Hub mirrors (scihub source); disable with PAPER_FETCH_NO_SCIHUB=1. On download failure (host_not_allowed, not_a_pdf, network_error), automatically falls back to the next candidate source.",
|
|
1504
|
+
"subcommands": {
|
|
1505
|
+
"schema": "Print this schema as JSON and exit (no network).",
|
|
1506
|
+
},
|
|
1507
|
+
"params": {
|
|
1508
|
+
"doi": {
|
|
1509
|
+
"type": "string",
|
|
1510
|
+
"required": False,
|
|
1511
|
+
"description": "DOI to fetch (positional). Use '-' to read DOIs line-by-line from stdin.",
|
|
1512
|
+
"pattern": DOI_PATTERN,
|
|
1513
|
+
"example": "10.1038/s41586-020-2649-2",
|
|
1514
|
+
},
|
|
1515
|
+
"title": {
|
|
1516
|
+
"type": "string",
|
|
1517
|
+
"required": False,
|
|
1518
|
+
"description": "Paper title; resolved to a DOI via Crossref before download. Mutually exclusive with positional DOI / --batch. The resolved DOI, top match, and up to 3 candidates are surfaced under meta.title_resolution.",
|
|
1519
|
+
"example": "Highly accurate protein structure prediction with AlphaFold",
|
|
1520
|
+
},
|
|
1521
|
+
"batch": {
|
|
1522
|
+
"type": "path",
|
|
1523
|
+
"required": False,
|
|
1524
|
+
"description": "File with one DOI per line for bulk download. Use '-' to read from stdin.",
|
|
1525
|
+
},
|
|
1526
|
+
"out": {
|
|
1527
|
+
"type": "path",
|
|
1528
|
+
"required": False,
|
|
1529
|
+
"default": "pdfs",
|
|
1530
|
+
"description": "Output directory.",
|
|
1531
|
+
},
|
|
1532
|
+
"dry_run": {
|
|
1533
|
+
"type": "boolean",
|
|
1534
|
+
"required": False,
|
|
1535
|
+
"default": False,
|
|
1536
|
+
"description": "Resolve sources without downloading; preview the PDF URL and destination path.",
|
|
1537
|
+
},
|
|
1538
|
+
"format": {
|
|
1539
|
+
"type": "enum",
|
|
1540
|
+
"values": ["json", "text"],
|
|
1541
|
+
"required": False,
|
|
1542
|
+
"default": "auto (json when stdout not a TTY, text otherwise)",
|
|
1543
|
+
"description": "Output format. json for agents, text for humans.",
|
|
1544
|
+
},
|
|
1545
|
+
"pretty": {
|
|
1546
|
+
"type": "boolean",
|
|
1547
|
+
"required": False,
|
|
1548
|
+
"default": False,
|
|
1549
|
+
"description": "Pretty-print JSON output with 2-space indentation.",
|
|
1550
|
+
},
|
|
1551
|
+
"stream": {
|
|
1552
|
+
"type": "boolean",
|
|
1553
|
+
"required": False,
|
|
1554
|
+
"default": False,
|
|
1555
|
+
"description": "Emit one NDJSON result per line on stdout as each DOI resolves, then a final summary line.",
|
|
1556
|
+
},
|
|
1557
|
+
"overwrite": {
|
|
1558
|
+
"type": "boolean",
|
|
1559
|
+
"required": False,
|
|
1560
|
+
"default": False,
|
|
1561
|
+
"description": "Re-download PDFs even when the destination file already exists.",
|
|
1562
|
+
},
|
|
1563
|
+
"idempotency_key": {
|
|
1564
|
+
"type": "string",
|
|
1565
|
+
"required": False,
|
|
1566
|
+
"description": "Stable key for safe retries. Re-running with the same key returns the original envelope from a sidecar in <out>/.paper-fetch-idem/.",
|
|
1567
|
+
},
|
|
1568
|
+
"timeout": {
|
|
1569
|
+
"type": "integer",
|
|
1570
|
+
"required": False,
|
|
1571
|
+
"default": DEFAULT_TIMEOUT,
|
|
1572
|
+
"description": "HTTP timeout in seconds per request.",
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
"exit_codes": {
|
|
1576
|
+
"0": "success (all DOIs resolved / previewed)",
|
|
1577
|
+
"1": "unresolved (some DOIs had no OA copy; no transport failure)",
|
|
1578
|
+
"2": "reserved for auth errors (currently unused)",
|
|
1579
|
+
"3": "validation error (bad arguments, missing input)",
|
|
1580
|
+
"4": "transport error (network / download / IO failure; retryable class)",
|
|
1581
|
+
},
|
|
1582
|
+
"error_codes": {
|
|
1583
|
+
"validation_error": {"retryable": False, "message": "Bad arguments or empty input"},
|
|
1584
|
+
"not_found": {"retryable": True, "retry_after_hours": RETRY_AFTER_HOURS["not_found"], "message": "No OA PDF found anywhere; OA availability changes over time"},
|
|
1585
|
+
"title_resolve_failed": {"retryable": False, "message": "Crossref returned no items for the given title; provide a DOI directly or refine the title"},
|
|
1586
|
+
"download_network_error": {"retryable": True, "retry_after_hours": RETRY_AFTER_HOURS["download_network_error"], "message": "Network failure during download"},
|
|
1587
|
+
"download_not_a_pdf": {"retryable": False, "message": "Response was not a PDF (HTML landing page)"},
|
|
1588
|
+
"download_host_not_allowed": {"retryable": False, "message": "PDF URL failed SSRF safety check (private IP, non-http(s) scheme, non-80/443 port, or blocked metadata host)"},
|
|
1589
|
+
"download_size_exceeded": {"retryable": True, "retry_after_hours": RETRY_AFTER_HOURS["download_size_exceeded"], "message": f"Response exceeded {MAX_PDF_SIZE // (1024*1024)} MB limit"},
|
|
1590
|
+
"download_io_error": {"retryable": True, "retry_after_hours": RETRY_AFTER_HOURS["download_io_error"], "message": "Local filesystem write failed"},
|
|
1591
|
+
"internal_error": {"retryable": False, "message": "Unexpected error"},
|
|
1592
|
+
},
|
|
1593
|
+
"envelope": {
|
|
1594
|
+
"success": {"ok": True, "data": {"results": [], "summary": {}, "next": []}, "meta": {}},
|
|
1595
|
+
"partial": {"ok": "partial", "data": {"results": [], "summary": {}, "next": []}, "meta": {}},
|
|
1596
|
+
"failure": {"ok": False, "error": {"code": "", "message": "", "retryable": False}, "meta": {}},
|
|
1597
|
+
},
|
|
1598
|
+
"result_fields": {
|
|
1599
|
+
"source_detail": "Optional per-source diagnostics (e.g. {'mirror': 'sci-hub.ru'} when source='scihub'). Present only when the resolving source has additional context worth surfacing for orchestrator routing.",
|
|
1600
|
+
},
|
|
1601
|
+
"deprecations": [],
|
|
1602
|
+
"meta_fields": {
|
|
1603
|
+
"request_id": "Unique per-invocation id; correlates stderr progress events with the stdout envelope.",
|
|
1604
|
+
"latency_ms": "Wall-clock time from process start to this emit.",
|
|
1605
|
+
"schema_version": "Version of this schema contract; bumped on any additive or breaking change.",
|
|
1606
|
+
"cli_version": "Version of the paper-fetch binary that produced the envelope.",
|
|
1607
|
+
"auth_mode": "Either 'public' (OA sources, no client rate limit) or 'institutional' (user opted in via PAPER_FETCH_INSTITUTIONAL=1; 1 req/s rate limit to protect the operator's IP from publisher-side throttling).",
|
|
1608
|
+
"sources_tried": "Union of sources consulted across all DOIs in this run.",
|
|
1609
|
+
"title_resolution": "Present only when --title was used. Includes: query, resolver (the resolver whose match was used: 'crossref' or 'semantic_scholar'), resolvers_tried (ordered list of every resolver consulted), resolved_doi, resolved_title, match_score (Crossref relevance score; absent for S2 matches), candidates (top-3 from the winning resolver), low_confidence (true if the chosen DOI failed the score/gap heuristics), low_confidence_reason ('score_below_threshold' / 'ambiguous_runner_up' / 'no_match'), fallback_reason (why Crossref's match was rejected when S2 was used), and crossref_candidates (top-3 Crossref hits when the S2 fallback won, for cross-resolver inspection). Agents should sanity-check the top match — especially when low_confidence is true.",
|
|
1610
|
+
},
|
|
1611
|
+
"env": {
|
|
1612
|
+
"UNPAYWALL_EMAIL": "Optional. Contact email for Unpaywall API. If unset, Unpaywall is skipped.",
|
|
1613
|
+
"PAPER_FETCH_INSTITUTIONAL": "Optional. Set to any value to opt into institutional mode: activates a 1 req/s rate limiter and enables the publisher-direct fallback. Intended for callers whose IP / cookies / EZproxy already grant subscription access. SSRF defense applies in every mode.",
|
|
1614
|
+
"PAPER_FETCH_NO_SCIHUB": "Optional. Set to any value to disable the Sci-Hub fallback (enabled by default).",
|
|
1615
|
+
"PAPER_FETCH_SCIHUB_MIRRORS": "Optional. Comma-separated list of Sci-Hub mirror hostnames to try, in priority order, overriding the built-in defaults (e.g. 'sci-hub.ru,sci-hub.st,sci-hub.su').",
|
|
1616
|
+
},
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
# ---------------------------------------------------------------------------
|
|
1621
|
+
# CLI
|
|
1622
|
+
# ---------------------------------------------------------------------------
|
|
1623
|
+
|
|
1624
|
+
EPILOG = """\
|
|
1625
|
+
exit codes:
|
|
1626
|
+
0 all DOIs resolved successfully
|
|
1627
|
+
1 unresolved (some DOIs had no OA copy; no transport failure)
|
|
1628
|
+
3 validation error (bad arguments)
|
|
1629
|
+
4 transport error (network / download / IO failure; retryable class)
|
|
1630
|
+
|
|
1631
|
+
subcommands:
|
|
1632
|
+
schema print the machine-readable CLI schema and exit (no network)
|
|
1633
|
+
|
|
1634
|
+
stdin:
|
|
1635
|
+
paper-fetch - read a single DOI from stdin
|
|
1636
|
+
paper-fetch --batch - read DOIs line-by-line from stdin
|
|
1637
|
+
|
|
1638
|
+
output:
|
|
1639
|
+
stdout emits one JSON object per invocation (NDJSON with --stream).
|
|
1640
|
+
stderr emits NDJSON progress events when --format json, prose when --format text.
|
|
1641
|
+
stdout format auto-detects TTY: json when piped/captured, text in a terminal.
|
|
1642
|
+
|
|
1643
|
+
examples:
|
|
1644
|
+
%(prog)s 10.1038/s41586-020-2649-2
|
|
1645
|
+
%(prog)s 10.1038/s41586-020-2649-2 --dry-run
|
|
1646
|
+
%(prog)s --batch dois.txt --out ./papers --format text
|
|
1647
|
+
echo 10.1038/s41586-020-2649-2 | %(prog)s --batch -
|
|
1648
|
+
%(prog)s schema
|
|
1649
|
+
"""
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _load_dois_from_args(args) -> list[str] | dict:
|
|
1653
|
+
"""Parse DOI input from args. Returns list of DOIs or an error envelope dict.
|
|
1654
|
+
|
|
1655
|
+
Title resolution (``--title``) is handled separately by ``_resolve_title``
|
|
1656
|
+
in main(); this loader sees only the resolved DOI by then.
|
|
1657
|
+
"""
|
|
1658
|
+
inputs = [bool(args.batch), bool(args.doi), bool(getattr(args, "title", None))]
|
|
1659
|
+
if sum(inputs) > 1:
|
|
1660
|
+
return _envelope_err(
|
|
1661
|
+
"validation_error",
|
|
1662
|
+
"Pass exactly one of: positional DOI, --batch FILE, or --title TITLE.",
|
|
1663
|
+
)
|
|
1664
|
+
if args.batch:
|
|
1665
|
+
if args.batch == "-":
|
|
1666
|
+
text = sys.stdin.read()
|
|
1667
|
+
dois = [l.strip() for l in text.splitlines() if l.strip()]
|
|
1668
|
+
else:
|
|
1669
|
+
batch_path = Path(args.batch)
|
|
1670
|
+
if not batch_path.exists():
|
|
1671
|
+
return _envelope_err(
|
|
1672
|
+
"validation_error",
|
|
1673
|
+
f"Batch file not found: {args.batch}",
|
|
1674
|
+
field="batch",
|
|
1675
|
+
)
|
|
1676
|
+
dois = [l.strip() for l in batch_path.read_text().splitlines() if l.strip()]
|
|
1677
|
+
elif args.doi == "-":
|
|
1678
|
+
text = sys.stdin.read()
|
|
1679
|
+
dois = [l.strip() for l in text.splitlines() if l.strip()]
|
|
1680
|
+
elif args.doi:
|
|
1681
|
+
dois = [args.doi]
|
|
1682
|
+
else:
|
|
1683
|
+
return _envelope_err("validation_error", "Provide a DOI, --title, or --batch file")
|
|
1684
|
+
|
|
1685
|
+
if not dois:
|
|
1686
|
+
return _envelope_err("validation_error", "No DOIs found in input")
|
|
1687
|
+
return dois
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _classify_low_confidence(score: float | None, gap: float | None) -> str | None:
|
|
1691
|
+
"""Identify why a Crossref top match should be treated as low-confidence.
|
|
1692
|
+
|
|
1693
|
+
Returns a single short reason string, or None if both heuristics pass.
|
|
1694
|
+
Order matters: ``score_below_threshold`` is the more diagnostic signal,
|
|
1695
|
+
so report that first when both fire.
|
|
1696
|
+
"""
|
|
1697
|
+
if score is not None and score < TITLE_SCORE_MIN:
|
|
1698
|
+
return "score_below_threshold"
|
|
1699
|
+
if gap is not None and gap < TITLE_GAP_MIN:
|
|
1700
|
+
return "ambiguous_runner_up"
|
|
1701
|
+
return None
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _resolve_title(title: str, *, timeout: int) -> tuple[str | None, dict]:
|
|
1705
|
+
"""Resolve a title to a DOI via Crossref → Semantic Scholar fallback chain.
|
|
1706
|
+
|
|
1707
|
+
Always populates a ``resolution_meta`` dict (at least ``query`` and
|
|
1708
|
+
``resolvers_tried``) so callers can surface it in the envelope's meta slot.
|
|
1709
|
+
"""
|
|
1710
|
+
_progress("title_resolve_try", query=title)
|
|
1711
|
+
resolvers_tried: list[str] = []
|
|
1712
|
+
|
|
1713
|
+
# Pass 1 — Crossref. Confident hit short-circuits the chain.
|
|
1714
|
+
resolvers_tried.append("crossref")
|
|
1715
|
+
cr_doi, cr_top, cr_candidates = try_crossref_title(title, timeout=timeout)
|
|
1716
|
+
cr_score = cr_top.get("score") if cr_top else None
|
|
1717
|
+
cr_gap: float | None = None
|
|
1718
|
+
if len(cr_candidates) >= 2:
|
|
1719
|
+
s0 = cr_candidates[0].get("score")
|
|
1720
|
+
s1 = cr_candidates[1].get("score")
|
|
1721
|
+
if isinstance(s0, (int, float)) and isinstance(s1, (int, float)):
|
|
1722
|
+
cr_gap = float(s0) - float(s1)
|
|
1723
|
+
cr_low_reason = _classify_low_confidence(cr_score, cr_gap) if cr_doi else "no_match"
|
|
1724
|
+
|
|
1725
|
+
if cr_doi and cr_low_reason is None:
|
|
1726
|
+
_progress(
|
|
1727
|
+
"title_resolve_hit",
|
|
1728
|
+
query=title,
|
|
1729
|
+
resolver="crossref",
|
|
1730
|
+
doi=cr_doi,
|
|
1731
|
+
title=cr_top.get("title"),
|
|
1732
|
+
score=cr_score,
|
|
1733
|
+
)
|
|
1734
|
+
return cr_doi, {
|
|
1735
|
+
"query": title,
|
|
1736
|
+
"resolver": "crossref",
|
|
1737
|
+
"resolvers_tried": resolvers_tried,
|
|
1738
|
+
"resolved_doi": cr_doi,
|
|
1739
|
+
"resolved_title": cr_top.get("title"),
|
|
1740
|
+
"match_score": cr_score,
|
|
1741
|
+
"candidates": cr_candidates,
|
|
1742
|
+
"low_confidence": False,
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
# Pass 2 — Semantic Scholar match endpoint. Covers arXiv-only papers
|
|
1746
|
+
# (no Crossref DOI) and rescues low-confidence Crossref matches.
|
|
1747
|
+
_progress(
|
|
1748
|
+
"title_resolver_try",
|
|
1749
|
+
query=title,
|
|
1750
|
+
resolver="semantic_scholar",
|
|
1751
|
+
reason="crossref_" + cr_low_reason if cr_low_reason else "crossref_no_match",
|
|
1752
|
+
)
|
|
1753
|
+
resolvers_tried.append("semantic_scholar")
|
|
1754
|
+
s2_doi, s2_meta = try_semantic_scholar_match(title, timeout=timeout)
|
|
1755
|
+
if s2_doi:
|
|
1756
|
+
_progress(
|
|
1757
|
+
"title_resolve_hit",
|
|
1758
|
+
query=title,
|
|
1759
|
+
resolver="semantic_scholar",
|
|
1760
|
+
doi=s2_doi,
|
|
1761
|
+
title=s2_meta.get("title"),
|
|
1762
|
+
)
|
|
1763
|
+
out: dict = {
|
|
1764
|
+
"query": title,
|
|
1765
|
+
"resolver": "semantic_scholar",
|
|
1766
|
+
"resolvers_tried": resolvers_tried,
|
|
1767
|
+
"resolved_doi": s2_doi,
|
|
1768
|
+
"resolved_title": s2_meta.get("title"),
|
|
1769
|
+
"candidates": [s2_meta],
|
|
1770
|
+
"low_confidence": False,
|
|
1771
|
+
"fallback_reason": cr_low_reason,
|
|
1772
|
+
}
|
|
1773
|
+
# Preserve the Crossref candidate list so an agent can compare what
|
|
1774
|
+
# each resolver thought was the top hit (helps when the two disagree).
|
|
1775
|
+
if cr_candidates:
|
|
1776
|
+
out["crossref_candidates"] = cr_candidates
|
|
1777
|
+
return s2_doi, out
|
|
1778
|
+
|
|
1779
|
+
# Pass 3 — every resolver missed. If Crossref had *any* candidate, return
|
|
1780
|
+
# it with a low_confidence flag so the agent can either (a) proceed with
|
|
1781
|
+
# caution or (b) bail out via the dry-run preview.
|
|
1782
|
+
if cr_doi:
|
|
1783
|
+
_progress(
|
|
1784
|
+
"title_resolve_hit",
|
|
1785
|
+
query=title,
|
|
1786
|
+
resolver="crossref",
|
|
1787
|
+
doi=cr_doi,
|
|
1788
|
+
title=cr_top.get("title"),
|
|
1789
|
+
score=cr_score,
|
|
1790
|
+
low_confidence=True,
|
|
1791
|
+
reason=cr_low_reason,
|
|
1792
|
+
)
|
|
1793
|
+
return cr_doi, {
|
|
1794
|
+
"query": title,
|
|
1795
|
+
"resolver": "crossref",
|
|
1796
|
+
"resolvers_tried": resolvers_tried,
|
|
1797
|
+
"resolved_doi": cr_doi,
|
|
1798
|
+
"resolved_title": cr_top.get("title"),
|
|
1799
|
+
"match_score": cr_score,
|
|
1800
|
+
"candidates": cr_candidates,
|
|
1801
|
+
"low_confidence": True,
|
|
1802
|
+
"low_confidence_reason": cr_low_reason,
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
_progress("title_resolve_miss", query=title, resolvers_tried=resolvers_tried)
|
|
1806
|
+
return None, {
|
|
1807
|
+
"query": title,
|
|
1808
|
+
"resolvers_tried": resolvers_tried,
|
|
1809
|
+
"candidates": [],
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _default_format() -> str:
|
|
1814
|
+
try:
|
|
1815
|
+
return "json" if not sys.stdout.isatty() else "text"
|
|
1816
|
+
except Exception:
|
|
1817
|
+
return "json"
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def _decide_exit(results: list[dict]) -> int:
|
|
1821
|
+
"""Pick the most descriptive exit code from per-item outcomes."""
|
|
1822
|
+
any_validation = False
|
|
1823
|
+
any_transport = False
|
|
1824
|
+
any_unresolved = False
|
|
1825
|
+
any_failure = False
|
|
1826
|
+
for r in results:
|
|
1827
|
+
if r.get("success"):
|
|
1828
|
+
continue
|
|
1829
|
+
any_failure = True
|
|
1830
|
+
err = r.get("error") or {}
|
|
1831
|
+
code = err.get("code", "")
|
|
1832
|
+
if code == "validation_error":
|
|
1833
|
+
any_validation = True
|
|
1834
|
+
elif code == "not_found":
|
|
1835
|
+
any_unresolved = True
|
|
1836
|
+
elif code.startswith("download_"):
|
|
1837
|
+
any_transport = True
|
|
1838
|
+
else:
|
|
1839
|
+
any_unresolved = True
|
|
1840
|
+
if not any_failure:
|
|
1841
|
+
return EXIT_SUCCESS
|
|
1842
|
+
# Validation errors win over transport/unresolved: a malformed DOI is a
|
|
1843
|
+
# caller bug, not a transient network issue.
|
|
1844
|
+
if any_validation and not (any_transport or any_unresolved):
|
|
1845
|
+
return EXIT_VALIDATION
|
|
1846
|
+
if any_transport:
|
|
1847
|
+
return EXIT_TRANSPORT
|
|
1848
|
+
return EXIT_UNRESOLVED
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def _next_hints(results: list[dict], args) -> list[str]:
|
|
1852
|
+
"""Suggest follow-up commands for the failed subset.
|
|
1853
|
+
|
|
1854
|
+
Hints are intended for an agent or human to copy-paste and run, so all
|
|
1855
|
+
user-controlled values (DOIs, --out path) are shell-quoted to prevent
|
|
1856
|
+
a maliciously crafted DOI from injecting commands.
|
|
1857
|
+
"""
|
|
1858
|
+
failed = [r["doi"] for r in results if not r.get("success")]
|
|
1859
|
+
if not failed:
|
|
1860
|
+
return []
|
|
1861
|
+
out = shlex.quote(args.out)
|
|
1862
|
+
if len(failed) == 1:
|
|
1863
|
+
cmd = f"paper-fetch {shlex.quote(failed[0])} --out {out}"
|
|
1864
|
+
if args.dry_run:
|
|
1865
|
+
cmd += " --dry-run"
|
|
1866
|
+
return [cmd]
|
|
1867
|
+
# Multiple failures — feed them via stdin so each DOI is delimited by a
|
|
1868
|
+
# real newline rather than interpolated into the shell command.
|
|
1869
|
+
payload = shlex.quote("\n".join(failed) + "\n")
|
|
1870
|
+
cmd = f"printf %s {payload} | paper-fetch --batch - --out {out}"
|
|
1871
|
+
if args.dry_run:
|
|
1872
|
+
cmd += " --dry-run"
|
|
1873
|
+
return [cmd]
|
|
1874
|
+
|
|
1875
|
+
# ⚠️ modify here !
|
|
1876
|
+
def run(argv: list[str] | None = None):
|
|
1877
|
+
"""Core entry point. argv is ['paper-fetch', '--doi', ...]; None means sys.argv."""
|
|
1878
|
+
global _format, _pretty, _stream, _request_id, _started_monotonic
|
|
1879
|
+
|
|
1880
|
+
_started_monotonic = time.monotonic()
|
|
1881
|
+
if argv is None:
|
|
1882
|
+
argv = sys.argv
|
|
1883
|
+
_request_id = f"req_{uuid.uuid4().hex[:12]}"
|
|
1884
|
+
|
|
1885
|
+
# Schema subcommand — handle before the main parser so we don't require a DOI.
|
|
1886
|
+
if len(argv) >= 2 and argv[1] == "schema":
|
|
1887
|
+
# Honor --pretty / --format if they follow.
|
|
1888
|
+
rest = argv[2:]
|
|
1889
|
+
_pretty = "--pretty" in rest
|
|
1890
|
+
if "--format" in rest:
|
|
1891
|
+
i = rest.index("--format")
|
|
1892
|
+
if i + 1 < len(rest) and rest[i + 1] in ("json", "text"):
|
|
1893
|
+
_format = rest[i + 1]
|
|
1894
|
+
else:
|
|
1895
|
+
_format = _default_format()
|
|
1896
|
+
else:
|
|
1897
|
+
_format = _default_format()
|
|
1898
|
+
schema = build_schema()
|
|
1899
|
+
_emit(_envelope_ok(schema))
|
|
1900
|
+
sys.exit(EXIT_SUCCESS)
|
|
1901
|
+
|
|
1902
|
+
ap = argparse.ArgumentParser(
|
|
1903
|
+
prog="paper-fetch",
|
|
1904
|
+
description="Fetch legal open-access PDFs by DOI via Unpaywall, Semantic Scholar, arXiv, PMC, and bioRxiv/medRxiv.",
|
|
1905
|
+
epilog=EPILOG,
|
|
1906
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1907
|
+
)
|
|
1908
|
+
ap.add_argument("doi", nargs="?", help="DOI to fetch (e.g. 10.1038/s41586-020-2649-2). Use '-' to read from stdin.")
|
|
1909
|
+
ap.add_argument("--title", metavar="TITLE", help="paper title; resolved to a DOI via Crossref before download. Mutually exclusive with positional DOI / --batch.")
|
|
1910
|
+
ap.add_argument("--batch", metavar="FILE", help="file with one DOI per line for bulk download. Use '-' to read from stdin.")
|
|
1911
|
+
ap.add_argument("--out", default="pdfs", metavar="DIR", help="output directory (default: pdfs)")
|
|
1912
|
+
ap.add_argument("--dry-run", action="store_true", help="resolve sources without downloading; preview the PDF URL and filename")
|
|
1913
|
+
ap.add_argument(
|
|
1914
|
+
"--format",
|
|
1915
|
+
choices=["json", "text"],
|
|
1916
|
+
default=None,
|
|
1917
|
+
dest="fmt",
|
|
1918
|
+
help="output format. json for agents, text for humans. Default: json when stdout is not a TTY, text otherwise.",
|
|
1919
|
+
)
|
|
1920
|
+
ap.add_argument("--pretty", action="store_true", help="pretty-print JSON output (2-space indent)")
|
|
1921
|
+
ap.add_argument("--stream", action="store_true", help="emit one NDJSON result per line on stdout as each DOI resolves (batch mode)")
|
|
1922
|
+
ap.add_argument("--overwrite", action="store_true", help="re-download even if the destination file already exists")
|
|
1923
|
+
ap.add_argument("--idempotency-key", metavar="KEY", default=None, help="safe-retry key; re-running with the same key replays the original envelope from <out>/.paper-fetch-idem/")
|
|
1924
|
+
ap.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, metavar="SECONDS", help=f"HTTP timeout in seconds per request (default: {DEFAULT_TIMEOUT})")
|
|
1925
|
+
ap.add_argument("--version", action="version", version=f"paper-fetch {CLI_VERSION} (schema {SCHEMA_VERSION})")
|
|
1926
|
+
args = ap.parse_args(argv[1:])
|
|
1927
|
+
|
|
1928
|
+
_format = args.fmt or _default_format()
|
|
1929
|
+
_pretty = args.pretty
|
|
1930
|
+
_stream = args.stream
|
|
1931
|
+
|
|
1932
|
+
# One-time session header — lets agents detect schema drift on the very
|
|
1933
|
+
# first stderr line, before any per-DOI work or network I/O.
|
|
1934
|
+
_progress("session", cli_version=CLI_VERSION, schema_version=SCHEMA_VERSION)
|
|
1935
|
+
|
|
1936
|
+
if not EMAIL:
|
|
1937
|
+
_progress("source_skip", source="unpaywall", reason="UNPAYWALL_EMAIL not set (top-level notice)")
|
|
1938
|
+
|
|
1939
|
+
out_dir = Path(args.out)
|
|
1940
|
+
|
|
1941
|
+
# Title resolution — runs before DOI loading so the rest of the pipeline
|
|
1942
|
+
# treats the resolved DOI as if it had been passed directly.
|
|
1943
|
+
title_resolution: dict | None = None
|
|
1944
|
+
if args.title:
|
|
1945
|
+
# Reject simultaneous title + DOI / --batch up front rather than later.
|
|
1946
|
+
if args.doi or args.batch:
|
|
1947
|
+
_emit(_envelope_err(
|
|
1948
|
+
"validation_error",
|
|
1949
|
+
"--title cannot be combined with a positional DOI or --batch.",
|
|
1950
|
+
))
|
|
1951
|
+
sys.exit(EXIT_VALIDATION)
|
|
1952
|
+
resolved_doi, title_resolution = _resolve_title(args.title, timeout=args.timeout)
|
|
1953
|
+
if not resolved_doi:
|
|
1954
|
+
_emit(_envelope_err(
|
|
1955
|
+
"title_resolve_failed",
|
|
1956
|
+
f"Crossref returned no items for title: {args.title!r}",
|
|
1957
|
+
retryable=False,
|
|
1958
|
+
title_resolution=title_resolution,
|
|
1959
|
+
))
|
|
1960
|
+
sys.exit(EXIT_UNRESOLVED)
|
|
1961
|
+
# Inject the resolved DOI as the positional argument so downstream
|
|
1962
|
+
# logic (DOI validation, fetch loop, idempotency replay) is identical.
|
|
1963
|
+
# Clear args.title so the mutual-exclusion guard in _load_dois_from_args
|
|
1964
|
+
# doesn't trip on the (now consumed) title.
|
|
1965
|
+
args.doi = resolved_doi
|
|
1966
|
+
args.title = None
|
|
1967
|
+
|
|
1968
|
+
loaded = _load_dois_from_args(args)
|
|
1969
|
+
if isinstance(loaded, dict):
|
|
1970
|
+
_emit(loaded)
|
|
1971
|
+
sys.exit(EXIT_VALIDATION)
|
|
1972
|
+
dois: list[str] = loaded
|
|
1973
|
+
|
|
1974
|
+
# Idempotency replay — before any network I/O.
|
|
1975
|
+
if args.idempotency_key:
|
|
1976
|
+
cached = _idem_load(out_dir, args.idempotency_key)
|
|
1977
|
+
if cached is not None:
|
|
1978
|
+
# Re-stamp meta so the replayed envelope still reports current latency / request id.
|
|
1979
|
+
cached_meta = cached.get("meta", {}) or {}
|
|
1980
|
+
cached_meta.update({
|
|
1981
|
+
"request_id": _request_id,
|
|
1982
|
+
"latency_ms": _now_ms(),
|
|
1983
|
+
"replayed_from_idempotency_key": args.idempotency_key,
|
|
1984
|
+
})
|
|
1985
|
+
cached["meta"] = cached_meta
|
|
1986
|
+
_emit(cached)
|
|
1987
|
+
# Exit code mirrors the cached envelope's outcome.
|
|
1988
|
+
if cached.get("ok") is True:
|
|
1989
|
+
sys.exit(EXIT_SUCCESS)
|
|
1990
|
+
if cached.get("ok") == "partial":
|
|
1991
|
+
sys.exit(_decide_exit(cached.get("data", {}).get("results", [])))
|
|
1992
|
+
sys.exit(EXIT_VALIDATION if cached.get("error", {}).get("code") == "validation_error" else EXIT_UNRESOLVED)
|
|
1993
|
+
|
|
1994
|
+
results: list[dict] = []
|
|
1995
|
+
for d in dois:
|
|
1996
|
+
r = fetch(
|
|
1997
|
+
d,
|
|
1998
|
+
out_dir,
|
|
1999
|
+
dry_run=args.dry_run,
|
|
2000
|
+
overwrite=args.overwrite,
|
|
2001
|
+
timeout=args.timeout,
|
|
2002
|
+
)
|
|
2003
|
+
results.append(r)
|
|
2004
|
+
if _stream and _format == "json":
|
|
2005
|
+
_emit_ndjson({"ok": bool(r.get("success")), "data": r, "meta": _meta()})
|
|
2006
|
+
|
|
2007
|
+
succeeded = sum(1 for r in results if r.get("success"))
|
|
2008
|
+
total = len(results)
|
|
2009
|
+
failed = total - succeeded
|
|
2010
|
+
|
|
2011
|
+
if succeeded == total:
|
|
2012
|
+
ok_flag: bool | str = True
|
|
2013
|
+
elif succeeded == 0:
|
|
2014
|
+
ok_flag = False
|
|
2015
|
+
else:
|
|
2016
|
+
ok_flag = "partial"
|
|
2017
|
+
|
|
2018
|
+
data = {
|
|
2019
|
+
"results": results,
|
|
2020
|
+
"summary": {
|
|
2021
|
+
"total": total,
|
|
2022
|
+
"succeeded": succeeded,
|
|
2023
|
+
"failed": failed,
|
|
2024
|
+
},
|
|
2025
|
+
"next": _next_hints(results, args),
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
sources_tried_union = sorted({s for r in results for s in r.get("sources_tried", [])})
|
|
2029
|
+
meta_extra = {"sources_tried": sources_tried_union}
|
|
2030
|
+
if not EMAIL:
|
|
2031
|
+
meta_extra["unpaywall_skipped"] = True
|
|
2032
|
+
if title_resolution is not None:
|
|
2033
|
+
meta_extra["title_resolution"] = title_resolution
|
|
2034
|
+
|
|
2035
|
+
if ok_flag is False:
|
|
2036
|
+
# Total failure of a single-DOI call — downgrade to an error envelope
|
|
2037
|
+
# when the single result has an error with a code, so agents see
|
|
2038
|
+
# {ok:false, error:{...}} for the simple case.
|
|
2039
|
+
if total == 1 and results[0].get("error"):
|
|
2040
|
+
err = results[0]["error"]
|
|
2041
|
+
envelope = _envelope_err(
|
|
2042
|
+
err.get("code", "internal_error"),
|
|
2043
|
+
err.get("message", "failed"),
|
|
2044
|
+
retryable=err.get("retryable", False),
|
|
2045
|
+
**{k: v for k, v in err.items() if k not in ("code", "message", "retryable")},
|
|
2046
|
+
doi=results[0]["doi"],
|
|
2047
|
+
sources_tried=results[0].get("sources_tried", []),
|
|
2048
|
+
)
|
|
2049
|
+
envelope["meta"].update(meta_extra)
|
|
2050
|
+
else:
|
|
2051
|
+
envelope = _envelope_ok(data, ok=False, meta_extra=meta_extra)
|
|
2052
|
+
else:
|
|
2053
|
+
envelope = _envelope_ok(data, ok=ok_flag, meta_extra=meta_extra)
|
|
2054
|
+
|
|
2055
|
+
# Stream mode already emitted per-item lines; final envelope still goes out as a summary.
|
|
2056
|
+
if _stream and _format == "json":
|
|
2057
|
+
print(_dump_json({"summary": data["summary"], "meta": envelope["meta"], "next": data["next"], "ok": ok_flag}), flush=True)
|
|
2058
|
+
else:
|
|
2059
|
+
_emit(envelope)
|
|
2060
|
+
|
|
2061
|
+
# Store idempotency sidecar on completion (even for partial — replay returns same shape).
|
|
2062
|
+
if args.idempotency_key:
|
|
2063
|
+
_idem_store(out_dir, args.idempotency_key, envelope)
|
|
2064
|
+
|
|
2065
|
+
sys.exit(_decide_exit(results))
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
def main():
|
|
2069
|
+
"""CLI entry point (when run as __main__). Thin wrapper around run()."""
|
|
2070
|
+
try:
|
|
2071
|
+
run()
|
|
2072
|
+
except KeyboardInterrupt:
|
|
2073
|
+
sys.exit(130)
|
|
2074
|
+
except Exception as e:
|
|
2075
|
+
_emit(_envelope_err("internal_error", str(e)))
|
|
2076
|
+
sys.exit(EXIT_TRANSPORT)
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
if __name__ == "__main__":
|
|
2080
|
+
main()
|