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.
@@ -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()