fetchpdf 0.1.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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1770 @@
1
+ """--pull-supplementary: every supplementary file, alongside the paper.
2
+
3
+ This is not a tier. The tier walk in engine.py is winner-take-all -- it returns
4
+ the moment one artifact is accepted, because its question is "what is the best
5
+ single representation of this paper's full text". This pass answers a different
6
+ question, "what else did the authors deposit", and the answer is a set, not a
7
+ winner. So it runs after retrieval rather than inside it, and it never touches
8
+ the record's verdict: a paper with no supplementary material is not a failure,
9
+ and a repository serving malformed JSON must not turn a successful download into
10
+ a failed one.
11
+
12
+ Files land as flat siblings of the main artifact:
13
+
14
+ 10.1371--journal.pone.0000308.pdf
15
+ 10.1371--journal.pone.0000308_supplementary_info_1.doc
16
+ 10.1371--journal.pone.0000308_supplementary_info_2.xls
17
+ 10.1371--journal.pone.0000308_supplementary_info.json
18
+
19
+ The numbering makes the manifest mandatory rather than nice to have: it is the
20
+ only record of what _2 originally was, what it was called, where it came from,
21
+ and what was refused for being too large. "Nothing was offered" and "one 4 GB
22
+ HDF5 was refused" are different facts about a record and must not collapse into
23
+ the same empty directory listing.
24
+
25
+ Numbering is deterministic, because it has no other anchor. Same remote content
26
+ gives the same files in the same order across re-runs and across worker counts --
27
+ see _sort_key.
28
+ """
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import tempfile
35
+ import threading
36
+ import time
37
+ import zipfile
38
+ from dataclasses import dataclass, field
39
+ from typing import Dict, List, Optional, Tuple
40
+
41
+ from .http import apply_default_mode, redact
42
+ from .linked_artifacts import write_linked_sidecar
43
+ from .supplement_index import (
44
+ ROLE_ARTICLE,
45
+ ROLE_FIGURE,
46
+ SupplementFile,
47
+ _epmc_withholds_supplements,
48
+ _matches_href,
49
+ enumerate_all,
50
+ provider_rank,
51
+ )
52
+ from .tiers import ARTIFACT_EXTENSIONS
53
+
54
+ _SCHEMA_VERSION = 1
55
+
56
+ #: The infix that makes a supplementary sibling recognisable. Anything
57
+ #: downstream globbing *.pdf over an output directory will pick these up too, so
58
+ #: it needs to be distinctive enough to filter on.
59
+ INFIX = "_supplementary_info"
60
+ MANIFEST_SUFFIX = INFIX + ".json"
61
+
62
+ #: Linked deposits (Zenodo, OSF, Dryad, ...) land under
63
+ #: <stem>_data_artifacts/<deposit>/ with their ORIGINAL filenames, rather than
64
+ #: in the flat _supplementary_info_N_ namespace the paper's own SI uses. They
65
+ #: are a different kind of thing -- often hundreds of MB of third-party data --
66
+ #: and mixing them in buried a paper's four real files under 199 members of a
67
+ #: vendored Stata package.
68
+ DATA_ARTIFACT_INFIX = "_data_artifacts"
69
+
70
+ #: Every supplementary file, downloaded or unpacked, is named
71
+ #:
72
+ #: {stem}_supplementary_info_{index}_{descriptor}{ext}
73
+ #:
74
+ #: One rule, because two would be worse than either: a reader seeing a name with
75
+ #: no descriptor could not tell whether the provider gave no usable name or
76
+ #: whether a different code path produced the file.
77
+ #:
78
+ #: The index stays the anchor -- deterministic, unique, and the thing the
79
+ #: manifest keys on. The descriptor is added because a name is the only thing an
80
+ #: agent sees when deciding what to open, and "TablesS1-S5" earns its place.
81
+ #: Measured across this corpus, though, only ~20% of provider names carry a
82
+ #: content word; the rest are codes like "jamanetwopen-e2337679-s001" or
83
+ #: Springer's generic "MOESM1_ESM". So the descriptor is a bonus, never the
84
+ #: identifier, and it is omitted entirely when it would say nothing.
85
+ #:
86
+ #: A kept archive's members are unpacked beside it and take indices of their own.
87
+ #: The archive itself stays: it is the only proof of what the members came from.
88
+ DESCRIPTOR_MAX_CHARS = 60
89
+
90
+ #: Per-file cap. The CLI default; --max-supplementary-mb overrides it.
91
+ DEFAULT_MAX_FILE_BYTES = 300 * 1024 * 1024
92
+
93
+ #: An archive's members are unknowable until it has landed, so the per-file cap
94
+ #: cannot gate the transfer. These bound the damage instead.
95
+ BUNDLE_CAP_MULTIPLE = 4
96
+ DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
97
+ DEFAULT_MAX_FILES = 200
98
+
99
+ #: zipfile will happily inflate a bomb. Refuse an archive whose members claim to
100
+ #: expand to more than this.
101
+ _RATIO_GUARD_MULTIPLE = 20
102
+
103
+ #: Bodies that are a challenge page rather than the file that was asked for.
104
+ #: Every one of these is served with HTTP 200 (or, at ACS, a 404 with 57 KB of
105
+ #: HTML), which is why the status code cannot be the check.
106
+ _CHALLENGE_MARKERS = (
107
+ b"recaptcha/challengepage",
108
+ b"cf-browser-verification",
109
+ b"Just a moment...",
110
+ b"Attention Required! | Cloudflare",
111
+ b"_Incapsula_Resource",
112
+ b"px-captcha",
113
+ )
114
+
115
+ _TEXTUAL_EXTENSIONS = (".html", ".htm", ".txt", ".csv", ".tsv", ".xml", ".json", ".md")
116
+
117
+ #: Formats whose bytes cannot legitimately begin with `<`. Used to catch an API
118
+ #: that answers a file request with an XML wrapper describing the file -- see
119
+ #: `_looks_like_a_document`.
120
+ _BINARY_DOC_EXTENSIONS = (
121
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
122
+ ".zip", ".gz", ".tar", ".rar", ".7z",
123
+ ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".gif", ".bmp", ".eps",
124
+ ".mov", ".mp4", ".avi", ".wmv", ".sav", ".dta", ".mat",
125
+ )
126
+
127
+ _MAGIC = (
128
+ (b"%PDF", ".pdf"),
129
+ (b"PK\x03\x04", ".zip"),
130
+ (b"\x1f\x8b", ".gz"),
131
+ (b"\x89PNG", ".png"),
132
+ (b"\xff\xd8\xff", ".jpg"),
133
+ (b"GIF8", ".gif"),
134
+ (b"BM", ".bmp"),
135
+ (b"Rar!", ".rar"),
136
+ (b"7z\xbc\xaf", ".7z"),
137
+ (b"\xd0\xcf\x11\xe0", ".doc"),
138
+ (b"BZh", ".bz2"),
139
+ )
140
+
141
+ _CONTENT_TYPE_EXTENSIONS = {
142
+ "application/pdf": ".pdf",
143
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
144
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
145
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
146
+ "application/vnd.ms-excel": ".xls",
147
+ "application/msword": ".doc",
148
+ "application/vnd.oasis.opendocument.spreadsheet": ".ods",
149
+ "text/csv": ".csv",
150
+ "text/tab-separated-values": ".tsv",
151
+ "text/plain": ".txt",
152
+ "application/zip": ".zip",
153
+ "application/gzip": ".gz",
154
+ "application/x-gzip": ".gz",
155
+ "application/x-tar": ".tar",
156
+ "image/png": ".png",
157
+ "image/jpeg": ".jpg",
158
+ "image/tiff": ".tif",
159
+ "video/mp4": ".mp4",
160
+ "application/json": ".json",
161
+ "application/xml": ".xml",
162
+ "text/xml": ".xml",
163
+ }
164
+
165
+ #: Skip reasons that mean a file the record has was not obtained, as opposed to
166
+ #: one that was obtained through a different provider. Only these make a run
167
+ #: "partial"; a duplicate is a complete result, not a shortfall. "no-bundle" is
168
+ #: deliberately absent: a speculative listing (EPMC constructs its bundle URL
169
+ #: for every PMCID without checking) answered by 404 or an <errorBean> stub is
170
+ #: a clean negative, not a loss -- misfiling it here once turned 40 negatives
171
+ #: into phantom "partial" statuses across a 99-record corpus.
172
+ _LOST_REASONS = frozenset({
173
+ "too-large", "http-error", "unreachable", "empty", "not-a-document",
174
+ "not-an-archive", "decompression-ratio", "unreadable-member", "unwritable",
175
+ # The files exist and were withheld -- that is a shortfall, unlike
176
+ # "no-bundle", which means there was nothing to fetch in the first place.
177
+ "epmc_not_open_access",
178
+ })
179
+
180
+ #: EPMC's phrasing when it holds supplements it is not permitted to serve.
181
+ #: Matched on the message, not the status code: the refusal arrives as HTTP 200
182
+ #: wrapping a 165-byte <errorBean>, so the status says "fine" while the body
183
+ #: says "no".
184
+ _NOT_OPEN_ACCESS_RE = re.compile(r"not\s+open\s+access", re.I)
185
+
186
+ #: Records whose supplements Europe PMC withheld this run, for the end-of-run
187
+ #: summary. A 500-record batch should give a number and a list, not 500 lines
188
+ #: the user has to scroll back through.
189
+ _NOT_OPEN_ACCESS_RECORDS: List[str] = []
190
+ _NOA_LOCK = threading.Lock()
191
+
192
+
193
+ def _print_yellow_warning(message: str) -> None:
194
+ """Yellow, like the CORE and news-item notices in fetchpdf.py.
195
+
196
+ Defined here rather than imported: fetchpdf.py imports this module, so
197
+ reaching back into it would be a cycle.
198
+ """
199
+ print(f"\033[93m{message}\033[0m")
200
+
201
+
202
+ def not_open_access_records() -> List[str]:
203
+ """DOIs whose supplements EPMC held but refused, for the run summary."""
204
+ with _NOA_LOCK:
205
+ return list(_NOT_OPEN_ACCESS_RECORDS)
206
+
207
+
208
+ def reset_not_open_access_records() -> None:
209
+ """Exists for tests; a batch is one process."""
210
+ with _NOA_LOCK:
211
+ _NOT_OPEN_ACCESS_RECORDS.clear()
212
+
213
+ #: Refusals worth a slow second look. HTTP layer already retries 429/503 once
214
+ #: with Retry-After; this is the record-level list for the escalating rounds in
215
+ #: pull_for_record. 403/404/410 are answers, not blips, and stay terminal.
216
+ _TRANSIENT_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
217
+
218
+ #: Waits between record-level retry rounds, chosen to slow down dramatically:
219
+ #: a transient failure gets a second chance quickly, then the run backs far off
220
+ #: rather than hammering a struggling host.
221
+ _RETRY_WAITS = (20, 90, 300)
222
+
223
+ #: Suffixes trusted as extensions when they appear on a listed filename. Anything
224
+ #: outside this set loses to the served Content-Type and to the magic bytes -- see
225
+ #: _extension_for for why that ordering matters.
226
+ _KNOWN_EXTENSIONS = frozenset(
227
+ e.lstrip(".") for e in _CONTENT_TYPE_EXTENSIONS.values()
228
+ ) | frozenset("""
229
+ pdf doc docx xls xlsx xlsm ods csv tsv txt rtf odt ppt pptx odp
230
+ zip gz tgz bz2 xz 7z rar tar
231
+ png jpg jpeg gif tif tiff bmp svg eps ps webp heic
232
+ mp4 mov avi mkv webm wmv mp3 wav flac m4a
233
+ xml json yaml yml html htm md tex bib
234
+ h5 hdf5 nc mat sav dta rds rda rdata sas7bdat npy npz parquet feather
235
+ fasta fastq fa vcf bam sam bed gff gtf sra cel idat nii mgf mzml raw
236
+ py r m do sh ipynb nb sav pkl db sqlite
237
+ """.split())
238
+
239
+ #: One lock per output stem, so two workers handed the same DOI twice in one CSV
240
+ #: cannot interleave each other's numbering. os.replace already prevents torn
241
+ #: files; this prevents run A's _1 sitting next to run B's _2.
242
+ _STEM_LOCKS: Dict[str, threading.Lock] = {}
243
+ _STEM_LOCKS_GUARD = threading.Lock()
244
+
245
+
246
+ @dataclass
247
+ class SupplementarySummary:
248
+ """What the pass did. Never an input to whether the record succeeded."""
249
+
250
+ #: incomplete > partial > error > ok > none_found. "incomplete" means a file
251
+ #: the article's own JATS declares was not obtained -- named data loss, and
252
+ #: the one status callers must surface loudly. "partial" means an undeclared
253
+ #: listed file was lost; "skipped" is the repair path's no-op.
254
+ status: str = "none_found" # ok | none_found | partial | incomplete | error | skipped
255
+ written: int = 0
256
+ skipped: int = 0
257
+ bytes_written: int = 0
258
+ manifest_path: Optional[str] = None
259
+ paths: List[str] = field(default_factory=list)
260
+ detail: str = ""
261
+ #: Declared-but-not-obtained original filenames, when status == "incomplete".
262
+ missing_declared: List[str] = field(default_factory=list)
263
+
264
+ def __bool__(self):
265
+ return self.written > 0
266
+
267
+
268
+ def pull_for_record(raw_identifier, doi=None, pmid=None, save_path=None,
269
+ resolver=None, ladder=None, http=None,
270
+ max_file_bytes=DEFAULT_MAX_FILE_BYTES,
271
+ max_total_bytes=DEFAULT_MAX_TOTAL_BYTES,
272
+ max_files=DEFAULT_MAX_FILES,
273
+ refresh=False, providers=None, email=None,
274
+ verbose=False, delay=0.1, use_playwright=False,
275
+ download_data_artifacts=False,
276
+ max_data_artifact_bytes=None,
277
+ llm_adjudicate_artifacts=False,
278
+ llm_agent_retrieval=False,
279
+ llm_backend=None,
280
+ max_llm_records=0,
281
+ llm_model=None,
282
+ unpack_data_artifacts=False,
283
+ download_related_unverified=False,
284
+ verify_hashes=True) -> SupplementarySummary:
285
+ """Fetch every supplementary file for one record into flat siblings of save_path.
286
+
287
+ Never raises for a network or provider failure; the outcome is the returned
288
+ summary and the manifest beside the artifact. Callers must not let the result
289
+ influence whether the record succeeded -- see the module docstring.
290
+ """
291
+ if not save_path:
292
+ return SupplementarySummary(status="error", detail="no save_path")
293
+
294
+ stem = stem_for(save_path)
295
+ manifest_path = stem + MANIFEST_SUFFIX
296
+
297
+ with _stem_lock(stem):
298
+ # The manifest, not the files, is the skip signal. On a real corpus most
299
+ # records have no supplementary material at all, and that is a fact worth
300
+ # recording: without it every re-run would re-enumerate every empty record
301
+ # at 3-6 requests each, which on run 2 onward is the entire cost of the
302
+ # flag.
303
+ if os.path.exists(manifest_path) and not refresh:
304
+ return _repair(manifest_path, stem, resolver, http, ladder, email,
305
+ verbose, max_file_bytes, verify_hashes)
306
+
307
+ ladder, http, resolver, owns_resolver = _wire(
308
+ resolver, http, ladder, save_path, email, verbose)
309
+ ctx = _context(http, resolver, ladder, save_path, verbose, email, delay,
310
+ use_playwright,
311
+ download_data_artifacts=download_data_artifacts,
312
+ max_data_artifact_bytes=max_data_artifact_bytes,
313
+ llm_adjudicate_artifacts=llm_adjudicate_artifacts,
314
+ llm_agent_retrieval=llm_agent_retrieval,
315
+ llm_backend=llm_backend,
316
+ max_llm_records=max_llm_records,
317
+ llm_model=llm_model,
318
+ download_related_unverified=download_related_unverified)
319
+
320
+ ids = _resolve(resolver, raw_identifier, doi, pmid)
321
+ listed, reports = enumerate_all(ids, ctx, providers)
322
+
323
+ run = _Run(stem=stem, ctx=ctx, http=http, ids=ids,
324
+ max_file_bytes=max_file_bytes, max_total_bytes=max_total_bytes,
325
+ max_files=max_files,
326
+ max_artifact_bytes=max_data_artifact_bytes,
327
+ unpack_data_artifacts=unpack_data_artifacts)
328
+ run.absorb_main_artifact()
329
+ for entry in sorted(_wanted(listed), key=_sort_key):
330
+ if not run.has_budget():
331
+ break
332
+ # Linked deposits answer to their own ceiling, so one large
333
+ # replication package cannot consume the allowance the paper's own
334
+ # supplementary files need.
335
+ if run._is_data_artifact(entry) and not run.artifact_has_budget(entry):
336
+ continue
337
+ run.take(entry)
338
+ _retry_transients(run, stem)
339
+
340
+ # Providers that had to download into a temp dir (atypon_suppl, and
341
+ # the retrieval agent's downloading backend) are done with it once
342
+ # every file has been committed.
343
+ for module in ("supplement_atypon", "llm_agent_retrieval"):
344
+ try:
345
+ cleanup = __import__(f"fetchpdf.retrieval.{module}",
346
+ fromlist=["cleanup_scratch"])
347
+ cleanup.cleanup_scratch(ctx)
348
+ except Exception:
349
+ pass
350
+ summary = run.finish(manifest_path, ids, reports, max_file_bytes)
351
+ # The link services' answers, including the empty ones. Written after
352
+ # the manifest so a crash between the two loses the links, never the
353
+ # account of the files. No-op when no link provider ran.
354
+ write_linked_sidecar(stem, ids, ctx, verbose=verbose)
355
+ if owns_resolver:
356
+ # Batch mode flushes once at the end instead; doing it per record
357
+ # there would rewrite a file that grows with every row.
358
+ try:
359
+ resolver.cache.flush()
360
+ except Exception:
361
+ pass
362
+ return summary
363
+
364
+
365
+ # -- selection --------------------------------------------------------------
366
+
367
+
368
+ def _wanted(listed: List[SupplementFile]) -> List[SupplementFile]:
369
+ """Everything except the paper itself and its figures.
370
+
371
+ The one filter. --pull-supplementary was asked for as much as possible, so
372
+ ROLE_UNKNOWN is kept: a spurious file costs disk, a dropped one loses data.
373
+ """
374
+ return [f for f in listed if f.role not in (ROLE_ARTICLE, ROLE_FIGURE) and f.url]
375
+
376
+
377
+ def _sort_key(entry: SupplementFile) -> tuple:
378
+ """Provider order, then natural filename order, then URL.
379
+
380
+ Determinism is not a nicety here. The output names carry no information --
381
+ _2.xlsx means nothing without the manifest -- so if the order shifted between
382
+ runs, the same file would land under different names and a re-run would look
383
+ like a different set of supplements. Provider rank comes first so appending a
384
+ provider cannot renumber the files an earlier one already produced; API
385
+ listing order is deliberately not the key, because neither Zenodo nor figshare
386
+ guarantees it.
387
+ """
388
+ return (provider_rank(entry.provider), _natural(entry.basename), entry.url or "")
389
+
390
+
391
+ def _natural(name: str) -> tuple:
392
+ """Digit runs compared as ints, so Table_S2 sorts before Table_S10."""
393
+ return tuple(
394
+ (1, int(part), "") if part.isdigit() else (0, 0, part.lower())
395
+ for part in re.split(r"(\d+)", name or "")
396
+ if part != ""
397
+ )
398
+
399
+
400
+ # -- the run ----------------------------------------------------------------
401
+
402
+
403
+ class _Run:
404
+ """One record's pass: download, dedupe, name, and account for everything."""
405
+
406
+ def __init__(self, stem, ctx, http, ids, max_file_bytes, max_total_bytes,
407
+ max_files, max_artifact_bytes=None,
408
+ unpack_data_artifacts=False):
409
+ self.stem = stem
410
+ self.ctx = ctx
411
+ self.http = http
412
+ self.ids = ids
413
+ self.max_file_bytes = max_file_bytes
414
+ self.max_total_bytes = max_total_bytes
415
+ self.max_files = max_files
416
+ self.max_artifact_bytes = max_artifact_bytes
417
+ self.unpack_data_artifacts = unpack_data_artifacts
418
+ self.directory = os.path.dirname(os.path.abspath(stem)) or "."
419
+
420
+ self.kept: List[dict] = []
421
+ self.skipped: List[dict] = []
422
+ #: (entry, its skip record) for refusals worth a slow second look --
423
+ #: consumed by _retry_transients, which pops the stale record and
424
+ #: re-takes the entry.
425
+ self.transient: List[Tuple[SupplementFile, dict]] = []
426
+ self.bytes_written = 0
427
+ #: Bytes spent on LINKED DATA ARTIFACTS, counted separately from the
428
+ #: paper's own supplementary files. A replication package can be
429
+ #: hundreds of MB, and sharing one budget would let a single dataset
430
+ #: starve the SI the record actually needs (or the reverse).
431
+ self.artifact_bytes_written = 0
432
+ #: Computed sha256 -> index. Authoritative, and the only cross-origin key.
433
+ self.by_hash: Dict[str, int] = {}
434
+ #: Provider-declared digest -> index. Kept apart from by_hash because a
435
+ #: declared md5 and a computed sha256 are not comparable; conflating them
436
+ #: would make every declared-checksum lookup a guaranteed miss.
437
+ self.by_declared: Dict[str, int] = {}
438
+ self.by_name_size: Dict[Tuple[str, Optional[int]], int] = {}
439
+ self.by_name_within_origin: Dict[Tuple[Optional[str], str], int] = {}
440
+ #: Size -> path of an artifact already on disk for this record, with its
441
+ #: hash filled in only if some candidate turns out to match that size.
442
+ self.main_by_size: Dict[int, str] = {}
443
+ self.main_hash_by_size: Dict[int, Optional[str]] = {}
444
+
445
+ # -- budgets ----------------------------------------------------------
446
+
447
+ def _warn_not_open_access(self, stub: str) -> None:
448
+ """Say out loud that supplements exist and cannot be fetched.
449
+
450
+ Loud, and per-record rather than once per run: unlike a dead API key,
451
+ each occurrence names a DIFFERENT paper whose SI the user may want by
452
+ hand, so collapsing them would hide the list. This silence is what let
453
+ the gap go unnoticed until a colleague reported it.
454
+ """
455
+ identifier = (getattr(self.ids, "doi", None)
456
+ or getattr(self.ids, "pmcid", None) or "record")
457
+ pmcid = getattr(self.ids, "pmcid", None) or "the PMC record"
458
+ _print_yellow_warning(
459
+ f"⚠️ {identifier}: Europe PMC reports supplementary material exists "
460
+ f"but will not serve it -- {pmcid} is not open access. Publishers "
461
+ f"block automated download of SI files, so these cannot be "
462
+ f"retrieved programmatically; fetch them from the article page by "
463
+ f"hand if needed."
464
+ )
465
+ with _NOA_LOCK:
466
+ _NOT_OPEN_ACCESS_RECORDS.append(str(identifier))
467
+
468
+ def _artifact_dir(self, entry) -> Optional[str]:
469
+ """The directory a linked deposit's files go in, created on demand.
470
+
471
+ `<stem>_data_artifacts/<repo>_<id>/`, so a 14 MB third-party CSV is
472
+ visibly not the paper's own supplementary material and each deposit
473
+ stays grouped. Returns None for the paper's own files, which keep the
474
+ flat numbered scheme they have always had -- changing that would
475
+ renumber every existing corpus.
476
+ """
477
+ if not self._is_data_artifact(entry):
478
+ return None
479
+ provider = (getattr(entry, "provider", "") or "").replace(":", "_")
480
+ origin = (getattr(entry, "origin_doi", "") or "").strip().lower()
481
+ slug = re.sub(r"[^a-z0-9._-]+", "_", origin).strip("._-")[:60] or provider
482
+ path = os.path.join(self.directory,
483
+ os.path.basename(self.stem) + DATA_ARTIFACT_INFIX,
484
+ slug)
485
+ try:
486
+ os.makedirs(path, exist_ok=True)
487
+ except OSError:
488
+ return None # fall back to flat rather than lose the file
489
+ return path
490
+
491
+ @staticmethod
492
+ def _is_data_artifact(entry) -> bool:
493
+ """True for a file routed from a linked deposit rather than the paper.
494
+
495
+ Keyed on the provider tag: _files_in_repository stamps routed files as
496
+ "<via>:<provider>", so the prefix names the discovery route.
497
+ """
498
+ # The retrieval agent's DATASET files are tagged
499
+ # "fulltext_scan:llm_agent" precisely so they match the
500
+ # "fulltext_scan" prefix below and land in _data_artifacts/ on the
501
+ # data budget. Its SUPPLEMENT files are tagged "llm_agent", which
502
+ # deliberately does not match: those are the paper's own SI and belong
503
+ # in the flat numbered namespace beside the PDF.
504
+ provider = getattr(entry, "provider", "") or ""
505
+ return provider.startswith(("scholix_related:", "epmc_datalinks:",
506
+ "datacite_related:", "fulltext_scan"))
507
+
508
+ def artifact_has_budget(self, entry) -> bool:
509
+ """The separate ceiling for linked data artifacts."""
510
+ if self.max_artifact_bytes is None:
511
+ return True
512
+ if self.artifact_bytes_written >= self.max_artifact_bytes:
513
+ self._note_budget(
514
+ f"per-record data-artifact limit {self.max_artifact_bytes} bytes reached")
515
+ return False
516
+ return True
517
+
518
+ def has_budget(self) -> bool:
519
+ if len(self.kept) >= self.max_files:
520
+ self._note_budget(f"per-record file limit {self.max_files} reached")
521
+ return False
522
+ if self.bytes_written >= self.max_total_bytes:
523
+ self._note_budget(f"per-record byte limit {self.max_total_bytes} reached")
524
+ return False
525
+ return True
526
+
527
+ def _note_budget(self, reason: str) -> None:
528
+ if not any(s.get("reason") == "record-budget" for s in self.skipped):
529
+ self.skipped.append({"reason": "record-budget", "detail": reason})
530
+ self.ctx.log(f" supplements: {reason}")
531
+
532
+ # -- the main artifact ------------------------------------------------
533
+
534
+ def absorb_main_artifact(self) -> None:
535
+ """Note the sizes of what is already on disk for this record.
536
+
537
+ Repositories routinely list the paper alongside its supplements, and the
538
+ role heuristics only guess at that from a filename. Byte identity settles
539
+ it. Sizes are collected now and hashed lazily, on an exact size match
540
+ only, so a record with a 30 MB PDF is not sha256'd for nothing.
541
+ """
542
+ for extension in ARTIFACT_EXTENSIONS:
543
+ path = self.stem + extension
544
+ try:
545
+ if os.path.isfile(path):
546
+ self.main_by_size.setdefault(os.path.getsize(path), path)
547
+ except OSError:
548
+ continue
549
+
550
+ def _is_the_main_artifact(self, size: int, digest: str) -> Optional[str]:
551
+ path = self.main_by_size.get(size)
552
+ if not path:
553
+ return None
554
+ if size not in self.main_hash_by_size:
555
+ self.main_hash_by_size[size] = _hash_file(path)
556
+ return path if self.main_hash_by_size[size] == digest else None
557
+
558
+ # -- taking one entry -------------------------------------------------
559
+
560
+ def take(self, entry: SupplementFile) -> None:
561
+ if entry.is_archive:
562
+ self._take_archive(entry)
563
+ else:
564
+ self._take_file(entry)
565
+
566
+ def _take_file(self, entry: SupplementFile) -> None:
567
+ # The free refusal: a listing that declares an oversized file costs us
568
+ # nothing to decline.
569
+ if entry.size_bytes is not None and entry.size_bytes > self.max_file_bytes:
570
+ self._refuse(entry, "too-large", declared_bytes=entry.size_bytes)
571
+ return
572
+ if self._already_seen(entry):
573
+ return
574
+
575
+ staged = self._stage()
576
+ if staged is None:
577
+ return
578
+ result = self.http.download(
579
+ entry.url, staged, self.max_file_bytes, polite=entry.polite
580
+ )
581
+ if not result.ok:
582
+ _unlink(staged)
583
+ self._refuse(entry, result.outcome,
584
+ declared_bytes=result.declared_length,
585
+ detail=result.detail, url=result.request_url,
586
+ status=result.status)
587
+ return
588
+
589
+ self._commit(entry, staged, result.sha256, result.bytes_written,
590
+ result.content_type, url=result.url)
591
+
592
+ def _take_archive(self, entry: SupplementFile) -> None:
593
+ """Fetch a bundle under its own cap, then expand it member by member.
594
+
595
+ A zip's central directory sits at the end of the file, so nothing about
596
+ its members is knowable until the whole archive has landed. The per-file
597
+ cap therefore cannot gate this transfer; a larger bundle cap bounds it,
598
+ and the per-file cap is applied to each member on the way out.
599
+ """
600
+ bundle_cap = min(self.max_file_bytes * BUNDLE_CAP_MULTIPLE, self.max_total_bytes)
601
+ staged = self._stage()
602
+ if staged is None:
603
+ return
604
+ result = self.http.download(entry.url, staged, bundle_cap, polite=entry.polite)
605
+ if not result.ok:
606
+ _unlink(staged)
607
+ # A speculative listing was never evidence the bundle exists, so a
608
+ # 404 (or 410) is the endpoint saying "nothing here" -- verified
609
+ # reproducible at concurrency 1 -- not a download that failed.
610
+ if entry.extra.get("speculative") and result.status in (404, 410):
611
+ self._refuse(entry, "no-bundle", url=result.request_url,
612
+ status=result.status)
613
+ else:
614
+ self._refuse(entry, result.outcome,
615
+ declared_bytes=result.declared_length,
616
+ detail=result.detail, url=result.request_url,
617
+ status=result.status)
618
+ return
619
+
620
+ try:
621
+ self._expand_zip(entry, staged, result)
622
+ finally:
623
+ _unlink(staged)
624
+
625
+ def _expand_zip(self, entry: SupplementFile, staged: str, result) -> None:
626
+ # EPMC's other way of saying "no bundle": HTTP 200 wrapping a tiny
627
+ # <errorBean> XML stub ("Article with id PMC... is not open access
628
+ # one", 165 bytes live). Catching it before ZipFile keeps the honest
629
+ # not-an-archive reason for payloads that were supposed to be zips.
630
+ stub = _error_bean(staged)
631
+ if stub is not None:
632
+ # Two very different facts arrive through the same stub, and only
633
+ # one is worth a human's attention: "this article has no
634
+ # supplements" versus "it HAS supplements and Europe PMC is not
635
+ # allowed to give them to you".
636
+ #
637
+ # The stub CANNOT tell them apart. Europe PMC returns the identical
638
+ # body either way -- verified: PMC11215513 (hasSuppl=N) and
639
+ # PMC3458378 (hasSuppl=Y) both answer 200 with "...is not open
640
+ # access one". The message means "this article is closed", not "we
641
+ # are withholding files". An earlier version of this branch keyed on
642
+ # that text and warned about five records when only one of them had
643
+ # any supplements to withhold.
644
+ #
645
+ # hasSuppl is the authority, so ask the search index.
646
+ if _NOT_OPEN_ACCESS_RE.search(stub or "") \
647
+ and _epmc_withholds_supplements(
648
+ self.ids, self.ctx, default_without_pmcid=False):
649
+ self._refuse(entry, "epmc_not_open_access", detail=stub,
650
+ url=result.url)
651
+ # Breadcrumb for E19: the publisher fallback costs a headed
652
+ # browser, so it only runs for the records EPMC has actually
653
+ # said it is withholding.
654
+ self.ctx.scratch["epmc_withheld_supplements"] = True
655
+ self._warn_not_open_access(stub)
656
+ else:
657
+ # Includes the closed-access-but-no-supplements case, which is
658
+ # a clean negative: nothing exists, nothing was lost, say
659
+ # nothing.
660
+ self._refuse(entry, "no-bundle", detail=stub, url=result.url)
661
+ return
662
+ try:
663
+ archive = zipfile.ZipFile(staged)
664
+ except (zipfile.BadZipFile, OSError) as e:
665
+ self._refuse(entry, "not-an-archive", detail=str(e)[:120],
666
+ url=result.url)
667
+ return
668
+
669
+ with archive:
670
+ members = [m for m in archive.infolist() if not m.is_dir()]
671
+ declared = sum(m.file_size for m in members)
672
+ allowance = max(result.bytes_written * _RATIO_GUARD_MULTIPLE, self.max_file_bytes)
673
+ if declared > allowance:
674
+ self._refuse(entry, "decompression-ratio",
675
+ declared_bytes=declared, url=result.url,
676
+ detail=f"{declared} bytes from a {result.bytes_written}-byte archive")
677
+ return
678
+
679
+ # Sorted by name, not left in the archive's own order. A zip's
680
+ # central directory preserves whatever order the producer wrote, and
681
+ # Europe PMC's is not the obvious one -- its s001-s004 bundle stores
682
+ # s002 first -- so honouring it would number the members in a way that
683
+ # matches neither _sort_key nor anybody's expectation.
684
+ ordered = sorted(
685
+ members,
686
+ key=lambda m: _natural(os.path.basename(m.filename.replace("\\", "/"))),
687
+ )
688
+ for index, member in enumerate(ordered):
689
+ if not self.has_budget():
690
+ return
691
+ # Member names are untrusted input. They are never joined to a
692
+ # path here -- output names are generated -- but take the
693
+ # basename anyway so a traversing name cannot survive into the
694
+ # manifest either.
695
+ name = os.path.basename(member.filename.replace("\\", "/"))
696
+ member_entry = SupplementFile(
697
+ name=name or "supplement",
698
+ url=entry.url,
699
+ provider=entry.provider,
700
+ listing_index=index,
701
+ size_bytes=member.file_size,
702
+ role=entry.role,
703
+ origin_doi=entry.origin_doi,
704
+ extra=dict(entry.extra),
705
+ )
706
+ if member.file_size > self.max_file_bytes:
707
+ self._refuse(member_entry, "too-large",
708
+ declared_bytes=member.file_size,
709
+ container=entry.basename)
710
+ continue
711
+ if self._already_seen(member_entry, container=entry.basename):
712
+ continue
713
+ try:
714
+ payload = archive.read(member)
715
+ except Exception as e:
716
+ self._refuse(member_entry, "unreadable-member",
717
+ detail=str(e)[:120], container=entry.basename)
718
+ continue
719
+
720
+ staged_member = self._stage()
721
+ if staged_member is None:
722
+ return
723
+ with open(staged_member, "wb") as f:
724
+ f.write(payload)
725
+ apply_default_mode(staged_member)
726
+ self._commit(member_entry, staged_member,
727
+ hashlib.sha256(payload).hexdigest(), len(payload),
728
+ "", url=result.url, container=entry.basename)
729
+
730
+ # -- dedupe -----------------------------------------------------------
731
+
732
+ def _already_seen(self, entry: SupplementFile, container=None) -> bool:
733
+ """Cheap dedupe, before spending a transfer on it.
734
+
735
+ A duplicate deliberately does not consume an index: a gap would make _3
736
+ mean different things on different runs.
737
+ """
738
+ if entry.checksum:
739
+ index = self.by_declared.get(_digest_of(entry.checksum))
740
+ if index:
741
+ self._refuse(entry, "duplicate-of", duplicate_of_index=index,
742
+ container=container)
743
+ return True
744
+ key = (entry.basename.lower(), entry.size_bytes)
745
+ if entry.basename and entry.size_bytes is not None and key in self.by_name_size:
746
+ self._refuse(entry, "duplicate-of",
747
+ duplicate_of_index=self.by_name_size[key], container=container)
748
+ return True
749
+ # Name alone is only evidence within one deposit. Across origins,
750
+ # "Supplementary_Table_1.xlsx" in a Dryad deposit and in a figshare
751
+ # deposit are routinely different files, so those go to the hash check
752
+ # after download instead.
753
+ origin_key = (entry.origin_doi, entry.basename.lower())
754
+ if entry.basename and origin_key in self.by_name_within_origin:
755
+ self._refuse(entry, "duplicate-of",
756
+ duplicate_of_index=self.by_name_within_origin[origin_key],
757
+ container=container)
758
+ return True
759
+ return False
760
+
761
+ # -- commit -----------------------------------------------------------
762
+
763
+ def _commit(self, entry, staged, digest, size, content_type, url=None,
764
+ container=None) -> None:
765
+ head = _head_of(staged)
766
+ plausible, why = _looks_like_a_document(entry.basename, head, content_type, size)
767
+ if not plausible:
768
+ _unlink(staged)
769
+ self._refuse(entry, "not-a-document", detail=why, url=url,
770
+ container=container)
771
+ return
772
+
773
+ duplicate = self.by_hash.get(digest)
774
+ if duplicate:
775
+ _unlink(staged)
776
+ self._refuse(entry, "duplicate-of", duplicate_of_index=duplicate,
777
+ url=url, container=container, sha256=digest)
778
+ return
779
+
780
+ main = self._is_the_main_artifact(size, digest)
781
+ if main:
782
+ _unlink(staged)
783
+ self._refuse(entry, "duplicate-of-main-artifact", url=url,
784
+ container=container, sha256=digest,
785
+ main_artifact=os.path.basename(main))
786
+ return
787
+
788
+ index = len(self.kept) + 1
789
+ extension = _extension_for(entry.basename, content_type, head)
790
+ artifact_dir = self._artifact_dir(entry)
791
+ if artifact_dir:
792
+ # A linked deposit keeps its own filenames inside its own directory.
793
+ # The flat _supplementary_info_N_ scheme exists because the paper's
794
+ # own SI has no other namespace; a deposit does, and forcing it into
795
+ # the flat one buried four real files under 199 members of a
796
+ # vendored Stata package in a single record.
797
+ filename = _safe_member_name(entry.basename, extension, index)
798
+ final = os.path.join(artifact_dir, filename)
799
+ filename = os.path.join(os.path.basename(artifact_dir), filename)
800
+ else:
801
+ filename = si_filename(os.path.basename(self.stem), index,
802
+ entry.basename, extension)
803
+ final = os.path.join(self.directory, filename)
804
+ try:
805
+ os.replace(staged, final)
806
+ except OSError as e:
807
+ _unlink(staged)
808
+ self._refuse(entry, "unwritable", detail=str(e)[:120], url=url)
809
+ return
810
+
811
+ self.by_hash[digest] = index
812
+ if entry.checksum:
813
+ self.by_declared.setdefault(_digest_of(entry.checksum), index)
814
+ if entry.basename:
815
+ self.by_name_size[(entry.basename.lower(), size)] = index
816
+ self.by_name_size.setdefault((entry.basename.lower(), entry.size_bytes), index)
817
+ self.by_name_within_origin[(entry.origin_doi, entry.basename.lower())] = index
818
+ self.bytes_written += size
819
+ if self._is_data_artifact(entry):
820
+ self.artifact_bytes_written += size
821
+ self.kept.append({
822
+ "index": index,
823
+ "filename": filename,
824
+ "original_name": entry.name,
825
+ "label": entry.label,
826
+ "provider": entry.provider,
827
+ "listing_index": entry.listing_index,
828
+ "container": container,
829
+ "url": _provenance_url(entry, url),
830
+ "content_type": content_type,
831
+ "role": entry.role,
832
+ "bytes": size,
833
+ "sha256": digest,
834
+ "retrieved_at": _iso(time.time()),
835
+ })
836
+ self.ctx.log(f" 📎 {filename} ← {entry.provider} ({size} bytes)")
837
+
838
+ # A supplementary file that is itself a zip gets unpacked alongside.
839
+ # Distinct from _take_archive, which expands the TRANSPORT bundle (the
840
+ # Europe PMC container) and never keeps it. This one is a file the authors
841
+ # deposited, so the archive stays and its members join it.
842
+ # A data artifact's archive is KEPT WHOLE by default. A replication
843
+ # package is a coherent thing with its own internal layout, and
844
+ # unpacking one produced 199 files from a vendored Stata library in a
845
+ # single record. --unpack-data-artifacts opts back in.
846
+ if _is_zip(final) and not (self._artifact_dir(entry)
847
+ and not self.unpack_data_artifacts):
848
+ self._unpack_stored_zip(final, filename, entry, self.kept[-1])
849
+
850
+ def _unpack_stored_zip(self, archive_path: str, archive_filename: str, entry,
851
+ archive_record: dict) -> None:
852
+ """Unpack a kept supplementary archive beside itself.
853
+
854
+ Reuses the same defences as the bundle expander: the decompression-ratio
855
+ guard, the per-file cap, the running budget, and basenaming every member
856
+ so a traversing name ("../../etc/passwd") cannot escape the directory or
857
+ reach the manifest.
858
+ """
859
+ try:
860
+ archive = zipfile.ZipFile(archive_path)
861
+ except (zipfile.BadZipFile, OSError) as e:
862
+ self._refuse(entry, "not-an-archive", detail=str(e)[:120],
863
+ container=archive_filename)
864
+ return
865
+
866
+ taken = 0
867
+ with archive:
868
+ members = [m for m in archive.infolist() if not m.is_dir()]
869
+ if not members:
870
+ return
871
+ declared = sum(m.file_size for m in members)
872
+ on_disk = os.path.getsize(archive_path)
873
+ allowance = max(on_disk * _RATIO_GUARD_MULTIPLE, self.max_file_bytes)
874
+ if declared > allowance:
875
+ self._refuse(entry, "decompression-ratio", declared_bytes=declared,
876
+ container=archive_filename,
877
+ detail=f"{declared} bytes from a {on_disk}-byte archive")
878
+ return
879
+
880
+ for member in sorted(members, key=lambda m: _natural(
881
+ os.path.basename(m.filename.replace("\\", "/")))):
882
+ if not self.has_budget():
883
+ break
884
+ name = os.path.basename(member.filename.replace("\\", "/"))
885
+ if not name or name.startswith("."):
886
+ continue # __MACOSX/, .DS_Store and friends
887
+ if member.file_size > self.max_file_bytes:
888
+ self._refuse(entry, "too-large", declared_bytes=member.file_size,
889
+ container=archive_filename, detail=name)
890
+ continue
891
+ try:
892
+ payload = archive.read(member)
893
+ except Exception as e:
894
+ self._refuse(entry, "unreadable-member", detail=f"{name}: {str(e)[:90]}",
895
+ container=archive_filename)
896
+ continue
897
+
898
+ index = len(self.kept) + 1
899
+ extension = os.path.splitext(name)[1].lower() or ".bin"
900
+ out_name = si_filename(os.path.basename(self.stem), index,
901
+ name, extension)
902
+ out_path = os.path.join(self.directory, out_name)
903
+ try:
904
+ _write_bytes_atomic(out_path, payload)
905
+ except OSError as e:
906
+ self._refuse(entry, "unwritable", detail=str(e)[:120],
907
+ container=archive_filename)
908
+ continue
909
+
910
+ self.bytes_written += len(payload)
911
+ taken += 1
912
+ # Recorded in the manifest like any other file. An extracted
913
+ # member that existed only on disk would be untraceable: nothing
914
+ # else says which archive it came from or what it was called.
915
+ self.kept.append({
916
+ "index": index,
917
+ "filename": out_name,
918
+ "original_name": name,
919
+ "provider": entry.provider,
920
+ "extracted_from": archive_filename,
921
+ "url": redact(entry.url),
922
+ "role": entry.role,
923
+ "bytes": len(payload),
924
+ "sha256": hashlib.sha256(payload).hexdigest(),
925
+ "retrieved_at": _iso(time.time()),
926
+ # Nested archives are left packed rather than expanded
927
+ # recursively: one level is the useful case, and unbounded
928
+ # recursion is how a zip quine becomes a disk-filling loop.
929
+ "nested_archive": bool(name.lower().endswith(".zip")),
930
+ })
931
+ if taken:
932
+ self.ctx.log(f" 📦 {archive_filename} → {taken} extracted file(s)")
933
+
934
+ # Only after a successful extraction, and only outside the `with` so the
935
+ # handle is closed first (Windows will not unlink an open file).
936
+ if taken:
937
+ _unlink(archive_path)
938
+ self.bytes_written -= archive_record.get("bytes", 0)
939
+ archive_record["extracted_members"] = taken
940
+ # The record stays, marked. Dropping it would lose the only account
941
+ # of where the members came from, and _repair would see a manifest
942
+ # entry with no file and try to download the archive all over again.
943
+ archive_record["removed_after_extraction"] = True
944
+ archive_record["filename"] = None
945
+
946
+ def _refuse(self, entry, reason, **fields) -> None:
947
+ record = {
948
+ "original_name": entry.name,
949
+ "provider": entry.provider,
950
+ "url": redact(fields.pop("url", None) or entry.url),
951
+ "reason": reason,
952
+ }
953
+ record.update({k: v for k, v in fields.items() if v is not None})
954
+ self.skipped.append(record)
955
+ if _is_transient(reason, record.get("status")):
956
+ self.transient.append((entry, record))
957
+ self.ctx.log(f" ✗ {entry.basename or entry.provider}: {reason}")
958
+
959
+ def _stage(self) -> Optional[str]:
960
+ """A staging path in the output dir.
961
+
962
+ Downloads land here first because the keep-or-drop decision needs the
963
+ sha256, and the sha256 needs the bytes. Two same-directory renames per
964
+ file is free next to the transfer, and it keeps the property that a
965
+ numbered sibling never exists in a partial state.
966
+ """
967
+ try:
968
+ os.makedirs(self.directory, exist_ok=True)
969
+ handle, path = tempfile.mkstemp(prefix=".fetchpdf-si-", dir=self.directory)
970
+ os.close(handle)
971
+ return path
972
+ except OSError as e:
973
+ self.ctx.log(f" supplements: cannot stage in {self.directory}: {e}")
974
+ return None
975
+
976
+ # -- output -----------------------------------------------------------
977
+
978
+ def finish(self, manifest_path, ids, reports, max_file_bytes) -> SupplementarySummary:
979
+ # The completeness gate first: it outranks every other status. The
980
+ # question that matters is not "did any request fail" -- providers list
981
+ # speculatively and endpoints say "nothing here" in strange ways -- but
982
+ # "did we get what the paper itself declares it has".
983
+ declared = _declared_check(self.ctx, self.kept)
984
+
985
+ # "partial" means something the record has was not obtained. A duplicate
986
+ # is not that: the same file reachable three ways and stored once is a
987
+ # complete result, and the overwhelming majority of skips are duplicates.
988
+ lost = [s for s in self.skipped if s.get("reason") in _LOST_REASONS]
989
+ if declared.get("missing"):
990
+ # A file the article's own JATS names was not obtained by any
991
+ # provider. Worse than "partial": this is known, named data loss.
992
+ status = "incomplete"
993
+ elif lost:
994
+ # "partial" whether or not anything was kept. A record whose one
995
+ # supplement was refused for size is not an error -- the refusal was
996
+ # deliberate and is recorded with its byte count -- but it is not a
997
+ # clean result either.
998
+ status = "partial"
999
+ elif self.kept:
1000
+ status = "ok"
1001
+ elif any(r.get("status") == "error" for r in reports):
1002
+ status = "error"
1003
+ else:
1004
+ status = "none_found"
1005
+
1006
+ manifest = {
1007
+ "schema_version": _SCHEMA_VERSION,
1008
+ "identifier": str(ids.doi or ids.best_id or ""),
1009
+ "identifiers_resolved": ids.to_dict() if hasattr(ids, "to_dict") else {},
1010
+ "stem": os.path.basename(self.stem),
1011
+ "completed_at": _iso(time.time()),
1012
+ "status": status,
1013
+ "limits": {
1014
+ "max_file_bytes": max_file_bytes,
1015
+ "max_total_bytes": self.max_total_bytes,
1016
+ "max_files": self.max_files,
1017
+ },
1018
+ "providers": reports,
1019
+ "declared": declared,
1020
+ "files": self.kept,
1021
+ "skipped": self.skipped,
1022
+ "counts": {
1023
+ "written": len(self.kept),
1024
+ "skipped": len(self.skipped),
1025
+ "bytes_written": self.bytes_written,
1026
+ },
1027
+ }
1028
+ written_path = write_manifest(manifest_path, manifest, self.ctx.verbose)
1029
+ return SupplementarySummary(
1030
+ status=status,
1031
+ written=len(self.kept),
1032
+ skipped=len(self.skipped),
1033
+ bytes_written=self.bytes_written,
1034
+ manifest_path=written_path,
1035
+ # filename is None for an archive removed after its members were
1036
+ # extracted (see _unpack_stored_zip): the record is kept as the only
1037
+ # account of where those members came from, but there is no file to
1038
+ # point at. Joining it raised TypeError and failed the whole
1039
+ # supplementary pass for the record.
1040
+ paths=[os.path.join(self.directory, f["filename"])
1041
+ for f in self.kept if f.get("filename")],
1042
+ missing_declared=list(declared.get("missing") or []),
1043
+ )
1044
+
1045
+
1046
+ # -- the slow retry ----------------------------------------------------------
1047
+
1048
+
1049
+ def _is_transient(reason: str, status) -> bool:
1050
+ """Whether a refusal deserves the record-level slow rounds.
1051
+
1052
+ "unreachable" and "empty" are connection weather. An http-error earns a
1053
+ retry only for statuses that mean "later" -- 403/404/410 are answers, and
1054
+ retrying an answer is how a run spins forever against a correct "no".
1055
+ """
1056
+ if reason in ("unreachable", "empty"):
1057
+ return True
1058
+ return reason == "http-error" and status in _TRANSIENT_STATUSES
1059
+
1060
+
1061
+ def _retry_transients(run: "_Run", stem: str, waits=None) -> None:
1062
+ """Re-take transiently failed entries, slowing down dramatically per round.
1063
+
1064
+ The waits print unconditionally: a five-minute silence in a batch run reads
1065
+ as a hang, and the slowdown is deliberate behaviour worth narrating. Each
1066
+ round pops the stale skip record before re-taking, so a file that succeeds
1067
+ on round two leaves no trace of its round-one failure beyond the log.
1068
+ """
1069
+ if waits is None:
1070
+ waits = _RETRY_WAITS # read at call time, so tests can shrink it
1071
+ for round_no, wait in enumerate(waits, start=1):
1072
+ pending, run.transient = run.transient, []
1073
+ if not pending or not run.has_budget():
1074
+ return
1075
+ names = ", ".join((e.basename or e.provider) for e, _ in pending[:3])
1076
+ print(f" ⏳ SI retry round {round_no}/{len(waits)} for "
1077
+ f"{os.path.basename(stem)}: {len(pending)} file(s) ({names}), "
1078
+ f"waiting {wait}s")
1079
+ time.sleep(wait)
1080
+ for entry, record in pending:
1081
+ if not run.has_budget():
1082
+ run.transient.append((entry, record))
1083
+ continue
1084
+ try:
1085
+ run.skipped.remove(record)
1086
+ except ValueError:
1087
+ pass
1088
+ run.take(entry)
1089
+
1090
+
1091
+ # -- the completeness gate ---------------------------------------------------
1092
+
1093
+
1094
+ def _declared_block(declared: Dict[str, str], kept: List[dict]) -> dict:
1095
+ """The manifest's declared-vs-obtained accounting, computed purely.
1096
+
1097
+ A declared basename counts as obtained when any kept file's original name
1098
+ matches it extension-insensitively (_matches_href): publishers list .jpg
1099
+ and serve .gif often enough that exact matching would cry wolf.
1100
+ """
1101
+ names = [f.get("original_name") or f.get("filename") or "" for f in kept]
1102
+ missing = sorted(base for base in declared
1103
+ if not _matches_href(base, names))
1104
+ return {
1105
+ "source": "jats",
1106
+ "total": len(declared),
1107
+ "obtained": len(declared) - len(missing),
1108
+ "missing": missing,
1109
+ }
1110
+
1111
+
1112
+ def _declared_check(ctx, kept: List[dict]) -> dict:
1113
+ """What the article declares vs what was obtained, or an honest shrug.
1114
+
1115
+ "unavailable" when the JATS manifest never ran (no PMCID, no parseable XML,
1116
+ no <body>): a record that cannot be verified must not read as verified.
1117
+ """
1118
+ declared = ctx.scratch.get("jats_declared")
1119
+ if declared is None:
1120
+ supplements = ctx.scratch.get("jats_supplements")
1121
+ if supplements is None:
1122
+ return {"source": "unavailable"}
1123
+ declared = {base: "" for base in supplements}
1124
+ return _declared_block(declared, kept)
1125
+
1126
+
1127
+ def _heal_manifest(manifest: dict, manifest_path: str, stem: str,
1128
+ verbose: bool) -> Tuple[str, List[str]]:
1129
+ """Bring an old manifest's accounting up to current classification.
1130
+
1131
+ Two upgrades, both idempotent and download-free:
1132
+
1133
+ * EPMC bundle refusals recorded before "no-bundle" existed -- an http-error
1134
+ 404/410, or a not-an-archive that was really the <errorBean> stub -- are
1135
+ reclassified, with the original reason kept under "was".
1136
+ * The declared-vs-obtained gate is recomputed from the record's own XML on
1137
+ disk, and the status re-derived under the same ladder finish() uses.
1138
+
1139
+ Returns the status the repair-path summary should carry: the healed status
1140
+ when it demands attention ("incomplete"), else "skipped" -- the repair
1141
+ pass's contract that a no-op must never look like new work.
1142
+ """
1143
+ changed = False
1144
+ for skip in manifest.get("skipped") or []:
1145
+ if skip.get("provider") != "europepmc_supplements":
1146
+ continue
1147
+ reason = skip.get("reason")
1148
+ if reason == "http-error" and skip.get("status") in (404, 410):
1149
+ skip["reason"], skip["was"] = "no-bundle", reason
1150
+ changed = True
1151
+ elif reason == "not-an-archive":
1152
+ # This endpoint's only known non-zip payload is the errorBean stub;
1153
+ # if it was something else, the gate below still catches any real
1154
+ # shortfall via the declared list.
1155
+ skip["reason"], skip["was"] = "no-bundle", reason
1156
+ changed = True
1157
+
1158
+ declared = _declared_from_disk(stem)
1159
+ if declared is not None:
1160
+ block = _declared_block(declared, manifest.get("files") or [])
1161
+ if manifest.get("declared") != block:
1162
+ manifest["declared"] = block
1163
+ changed = True
1164
+
1165
+ missing_declared = list((manifest.get("declared") or {}).get("missing") or [])
1166
+ lost = [s for s in (manifest.get("skipped") or [])
1167
+ if s.get("reason") in _LOST_REASONS]
1168
+ if missing_declared:
1169
+ status = "incomplete"
1170
+ elif lost:
1171
+ status = "partial"
1172
+ elif manifest.get("files"):
1173
+ status = "ok"
1174
+ elif any(r.get("status") == "error" for r in manifest.get("providers") or []):
1175
+ status = "error"
1176
+ else:
1177
+ status = "none_found"
1178
+
1179
+ if manifest.get("status") != status:
1180
+ manifest["status"] = status
1181
+ changed = True
1182
+ if changed:
1183
+ write_manifest(manifest_path, manifest, verbose)
1184
+ if verbose:
1185
+ print(f" 📋 manifest healed: status {status}")
1186
+ return ("incomplete" if status == "incomplete" else "skipped",
1187
+ missing_declared)
1188
+
1189
+
1190
+ def _declared_from_disk(stem: str) -> Optional[Dict[str, str]]:
1191
+ """The declared-supplement map from {stem}.xml, or None if unverifiable."""
1192
+ from .supplement_pmc import declared_from_xml
1193
+
1194
+ try:
1195
+ with open(stem + ".xml", "rb") as f:
1196
+ content = f.read()
1197
+ except OSError:
1198
+ return None
1199
+ return declared_from_xml(content)
1200
+
1201
+
1202
+ # -- the repair pass --------------------------------------------------------
1203
+
1204
+
1205
+ def _repair(manifest_path, stem, resolver, http, ladder, email, verbose,
1206
+ max_file_bytes, verify_hashes: bool = True) -> SupplementarySummary:
1207
+ """Honour an existing manifest, re-fetching what is missing OR corrupt.
1208
+
1209
+ A file deleted from disk comes back at its recorded index, so removing _2
1210
+ never renumbers _3. When nothing is missing this costs N stat calls and zero
1211
+ HTTP, which is the whole point of the manifest being the skip signal.
1212
+
1213
+ "Missing" means unusable, not absent. The manifest records a size and a
1214
+ sha256 for every file and this pass is the only place they are ever read
1215
+ back; without that, a truncated or git-lfs-stubbed supplement counts as
1216
+ present forever and the corpus reads as complete while holding 130-byte
1217
+ placeholders where its data should be.
1218
+ """
1219
+ manifest = read_manifest(manifest_path)
1220
+ if manifest is None:
1221
+ return SupplementarySummary(status="error", manifest_path=manifest_path,
1222
+ detail="manifest unreadable")
1223
+
1224
+ # Heal before anything else: manifests written before the no-bundle
1225
+ # classification and the completeness gate carry phantom "partial"
1226
+ # statuses, and the repair pass runs on every re-run -- the natural place
1227
+ # to bring old accounting up to current truth without re-downloading.
1228
+ status, missing_declared = _heal_manifest(manifest, manifest_path, stem,
1229
+ verbose)
1230
+
1231
+ directory = os.path.dirname(os.path.abspath(stem)) or "."
1232
+ files = manifest.get("files") or []
1233
+ # A removed-after-extraction archive has no file BY DESIGN; refetching it
1234
+ # would restore the very zip whose members are already unpacked beside it.
1235
+ files = [f for f in files if not f.get("removed_after_extraction")]
1236
+
1237
+ # Existence is not integrity -- see _integrity_of. A file that is present
1238
+ # but corrupt is re-fetched at its recorded index, exactly like an absent
1239
+ # one, so the no-renumber guarantee holds for both.
1240
+ verdicts = {}
1241
+ missing = []
1242
+ for f in files:
1243
+ verdict = _integrity_of(os.path.join(directory, f.get("filename") or ""),
1244
+ f, verify_hashes)
1245
+ verdicts[id(f)] = verdict
1246
+ if verdict != INTACT and verdict != UNVERIFIABLE:
1247
+ missing.append(f)
1248
+ present = len(files) - len(missing)
1249
+
1250
+ damaged = [(f, verdicts[id(f)]) for f in missing if verdicts[id(f)] != ABSENT]
1251
+ if damaged and verbose:
1252
+ for f, verdict in damaged:
1253
+ name = f.get("filename") or "?"
1254
+ if verdict == LFS_POINTER:
1255
+ # Naming the cause matters: the fix is a checkout/sync repair,
1256
+ # not a re-download, and the next sync will stub it again.
1257
+ print(f" ⚠️ {name}: git-lfs pointer, content never smudged "
1258
+ f"(re-fetching, but the storage needs fixing)")
1259
+ else:
1260
+ print(f" ⚠️ {name}: {verdict.replace('_', ' ')} vs manifest "
1261
+ f"(re-fetching)")
1262
+
1263
+ if not missing:
1264
+ return SupplementarySummary(
1265
+ status=status, written=present, manifest_path=manifest_path,
1266
+ paths=[os.path.join(directory, f["filename"]) for f in files
1267
+ if f.get("filename")],
1268
+ detail="manifest present; nothing missing",
1269
+ missing_declared=missing_declared,
1270
+ )
1271
+
1272
+ if http is None:
1273
+ return SupplementarySummary(
1274
+ status=status, written=present, manifest_path=manifest_path,
1275
+ detail=f"{len(missing)} file(s) missing, no client to refetch with",
1276
+ missing_declared=missing_declared,
1277
+ )
1278
+
1279
+ repaired = 0
1280
+ archives: Dict[str, List[dict]] = {}
1281
+ for record in missing:
1282
+ url = record.get("url") or ""
1283
+ filename = record.get("filename") or ""
1284
+ # A redacted URL cannot be re-fetched, and guessing at the secret would
1285
+ # be worse than saying so.
1286
+ if not url or not filename or "REDACTED" in url:
1287
+ continue
1288
+ # A file that arrived by archive expansion has the *archive's* URL, so
1289
+ # fetching it straight into the member's name would write the whole zip
1290
+ # where a single member belongs -- the right bytes for nothing.
1291
+ if record.get("container"):
1292
+ archives.setdefault(url, []).append(record)
1293
+ continue
1294
+ dest = os.path.join(directory, filename)
1295
+ if http.download(url, dest, max_file_bytes, polite=False).ok:
1296
+ repaired += 1
1297
+ if verbose:
1298
+ print(f" 📎 restored {filename}")
1299
+
1300
+ for url, records in archives.items():
1301
+ repaired += _repair_from_archive(http, url, records, directory,
1302
+ max_file_bytes, verbose)
1303
+
1304
+ # Say which were corrupt rather than absent: "restored 30 of 30" hides a
1305
+ # storage fault that will recur on the next sync.
1306
+ detail = f"restored {repaired} of {len(missing)} missing file(s)"
1307
+ if damaged:
1308
+ kinds = sorted({verdict for _, verdict in damaged})
1309
+ detail += f" ({len(damaged)} corrupt on disk: {', '.join(kinds)})"
1310
+
1311
+ return SupplementarySummary(
1312
+ status=status, written=present + repaired, manifest_path=manifest_path,
1313
+ detail=detail,
1314
+ missing_declared=missing_declared,
1315
+ )
1316
+
1317
+
1318
+ #: A git-lfs pointer stands where the content should be. This is NOT a download
1319
+ #: failure -- the bytes were fetched and hashed correctly, then a checkout or a
1320
+ #: file-sync replaced them with this 130-byte stub. Measured on a real corpus:
1321
+ #: 30 of 38 supplementary payloads, every pointer oid matching the sha256 the
1322
+ #: manifest recorded, which is the proof the real bytes were once on disk.
1323
+ _LFS_POINTER_MAGIC = b"version https://git-lfs.github.com/spec/v1"
1324
+
1325
+ #: What _integrity_of found. "absent" and "lfs_pointer" are different facts and
1326
+ #: call for different operator actions -- re-fetch versus fix your checkout --
1327
+ #: so they never collapse into one "missing".
1328
+ INTACT = "intact"
1329
+ ABSENT = "absent"
1330
+ LFS_POINTER = "lfs_pointer"
1331
+ SIZE_MISMATCH = "size_mismatch"
1332
+ HASH_MISMATCH = "hash_mismatch"
1333
+ UNVERIFIABLE = "unverifiable"
1334
+
1335
+
1336
+ def _integrity_of(path: str, record: dict, verify_hashes: bool = True) -> str:
1337
+ """Classify one manifest-listed file on disk.
1338
+
1339
+ Existence is not integrity. A truncated, zeroed, half-synced or LFS-stubbed
1340
+ file passes os.path.isfile and fails every use it was downloaded for -- and
1341
+ a hollow supplement reads downstream as "this paper published no data",
1342
+ which is worse than a loud absence.
1343
+
1344
+ Ordered by cost: stat before read, read before hash, so an intact corpus
1345
+ stays at N stats plus a 4 KB head read and the manifest keeps its
1346
+ zero-HTTP skip-signal property.
1347
+
1348
+ A record with no recorded size or digest is UNVERIFIABLE, never corrupt:
1349
+ manifests written before this check exist, and re-downloading a healthy
1350
+ file on absent metadata would be a regression, not a repair.
1351
+ """
1352
+ if not os.path.isfile(path):
1353
+ return ABSENT
1354
+
1355
+ declared_bytes = record.get("bytes")
1356
+ try:
1357
+ actual_bytes = os.path.getsize(path)
1358
+ except OSError:
1359
+ return ABSENT
1360
+
1361
+ # Cheapest decisive check first, and on its own enough to catch every
1362
+ # stub: a 130-byte pointer where 9362 bytes were recorded.
1363
+ if _head_of(path, len(_LFS_POINTER_MAGIC)).startswith(_LFS_POINTER_MAGIC):
1364
+ return LFS_POINTER
1365
+
1366
+ if isinstance(declared_bytes, int) and declared_bytes >= 0:
1367
+ if actual_bytes != declared_bytes:
1368
+ return SIZE_MISMATCH
1369
+ elif not record.get("sha256"):
1370
+ return UNVERIFIABLE
1371
+
1372
+ declared_sha = str(record.get("sha256") or "").strip().lower()
1373
+ if verify_hashes and declared_sha:
1374
+ actual_sha = _hash_file(path)
1375
+ if actual_sha is None:
1376
+ return ABSENT
1377
+ if actual_sha.lower() != declared_sha:
1378
+ return HASH_MISMATCH
1379
+
1380
+ if not isinstance(declared_bytes, int) and not declared_sha:
1381
+ return UNVERIFIABLE
1382
+ return INTACT
1383
+
1384
+
1385
+ def _repair_from_archive(http, url, records, directory, max_file_bytes, verbose) -> int:
1386
+ """Re-extract missing members from their archive, at their recorded names."""
1387
+ staged = None
1388
+ restored = 0
1389
+ try:
1390
+ handle, staged = tempfile.mkstemp(prefix=".fetchpdf-si-", dir=directory)
1391
+ os.close(handle)
1392
+ bundle_cap = max_file_bytes * BUNDLE_CAP_MULTIPLE
1393
+ if not http.download(url, staged, bundle_cap, polite=False).ok:
1394
+ return 0
1395
+ with zipfile.ZipFile(staged) as archive:
1396
+ by_name = {
1397
+ os.path.basename(m.filename.replace("\\", "/")): m
1398
+ for m in archive.infolist()
1399
+ if not m.is_dir()
1400
+ }
1401
+ for record in records:
1402
+ member = by_name.get(record.get("original_name") or "")
1403
+ if member is None or member.file_size > max_file_bytes:
1404
+ continue
1405
+ dest = os.path.join(directory, record["filename"])
1406
+ payload = archive.read(member)
1407
+ _write_bytes_atomic(dest, payload)
1408
+ restored += 1
1409
+ if verbose:
1410
+ print(f" 📎 restored {record['filename']} from {url.rsplit('/', 1)[-1]}")
1411
+ except (zipfile.BadZipFile, OSError, KeyError):
1412
+ return restored
1413
+ finally:
1414
+ _unlink(staged)
1415
+ return restored
1416
+
1417
+
1418
+ def si_filename(stem_basename: str, index: int, original_name: str, extension: str) -> str:
1419
+ """The one supplementary filename rule. See DESCRIPTOR_MAX_CHARS above."""
1420
+ descriptor = _descriptor(original_name, extension)
1421
+ return f"{stem_basename}{INFIX}_{index}{descriptor}{extension}"
1422
+
1423
+
1424
+ def _safe_member_name(original: str, extension: str, index: int) -> str:
1425
+ """A deposit file's own name, made safe for every filesystem.
1426
+
1427
+ Unlike the flat scheme this keeps the author's filename -- inside a
1428
+ per-deposit directory it is unambiguous, and "MainStudy_UK_raw.csv" is far
1429
+ more use to a reader than "_supplementary_info_7". Same whitelist as
1430
+ _descriptor, so Windows-forbidden characters and path traversal are both
1431
+ handled; the index only breaks ties.
1432
+ """
1433
+ base = os.path.basename(str(original or "").replace("\\", "/"))
1434
+ stem, dot_ext = os.path.splitext(base)
1435
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-")[:DESCRIPTOR_MAX_CHARS]
1436
+ cleaned = re.sub(r"_{2,}", "_", cleaned).strip("._-")
1437
+ suffix = dot_ext if dot_ext and len(dot_ext) <= 8 else extension
1438
+ return f"{cleaned or f'file_{index}'}{suffix}"
1439
+
1440
+
1441
+ def _descriptor(original_name: str, extension: str) -> str:
1442
+ """`_TablesS1-S5` from `TablesS1-S5.docx`, or "" when it would add nothing."""
1443
+ if not original_name:
1444
+ return ""
1445
+ base = os.path.basename(str(original_name).replace("\\", "/"))
1446
+ if extension and base.lower().endswith(extension.lower()):
1447
+ base = base[: -len(extension)]
1448
+ else:
1449
+ base = os.path.splitext(base)[0]
1450
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._-")
1451
+ cleaned = re.sub(r"_{2,}", "_", cleaned)
1452
+ if not cleaned:
1453
+ return ""
1454
+ # Long enough to stay recognisable, short enough that stem + infix + index +
1455
+ # descriptor + extension cannot approach the 255-byte filename limit.
1456
+ return "_" + cleaned[:DESCRIPTOR_MAX_CHARS].strip("._-")
1457
+
1458
+
1459
+ def _error_bean(path: str) -> Optional[str]:
1460
+ """The errMsg of an EPMC <errorBean> stub, or None for anything else.
1461
+
1462
+ Size-gated hard: a real bundle is never this small, and nothing legitimate
1463
+ starts with an XML prolog and contains <errorBean>.
1464
+ """
1465
+ try:
1466
+ if os.path.getsize(path) > 1024:
1467
+ return None
1468
+ with open(path, "rb") as f:
1469
+ head = f.read(1024)
1470
+ except OSError:
1471
+ return None
1472
+ if not head.lstrip().startswith(b"<?xml") or b"<errorBean>" not in head:
1473
+ return None
1474
+ match = re.search(rb"<errMsg>(.*?)</errMsg>", head, re.DOTALL)
1475
+ if match:
1476
+ return match.group(1).decode("utf-8", "replace")[:160]
1477
+ return "errorBean with no errMsg"
1478
+
1479
+
1480
+ def _provenance_url(entry, transferred_url=None) -> Optional[str]:
1481
+ """Where this file actually came from, for the manifest.
1482
+
1483
+ Normally that is the URL we transferred. The exception is a file some
1484
+ provider had already fetched to a scratch directory and handed over as
1485
+ `file://...`: HttpClient.download adopts those under the same rules as a
1486
+ network transfer, which is the right thing for the BYTES and the wrong
1487
+ thing for the RECORD -- a temp path stops meaning anything the moment the
1488
+ directory is removed, and the manifest is what a reader consults to ask
1489
+ where a file came from.
1490
+
1491
+ So a provider that knows the real origin puts it in `extra["source_url"]`
1492
+ and it wins. Losing that would be this toolkit's own signature defect: a
1493
+ local path presented as a provenance.
1494
+ """
1495
+ extra = getattr(entry, "extra", None) or {}
1496
+ source = extra.get("source_url")
1497
+ if source:
1498
+ return redact(str(source))
1499
+ candidate = transferred_url or entry.url
1500
+ if str(candidate or "").startswith("file://"):
1501
+ return None # honest: we do not know, rather than a temp path
1502
+ return redact(candidate)
1503
+
1504
+
1505
+ def _is_zip(path: str) -> bool:
1506
+ """By magic bytes, not extension: a .zip served as .dat is still a zip, and a
1507
+ .docx is a zip we must NOT unpack -- hence the extension check as well."""
1508
+ if not path.lower().endswith(".zip"):
1509
+ return False
1510
+ try:
1511
+ with open(path, "rb") as f:
1512
+ return f.read(4) == b"PK\x03\x04"
1513
+ except OSError:
1514
+ return False
1515
+
1516
+
1517
+ def _write_bytes_atomic(path: str, payload: bytes) -> None:
1518
+ directory = os.path.dirname(os.path.abspath(path)) or "."
1519
+ handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-si-", dir=directory)
1520
+ try:
1521
+ with os.fdopen(handle, "wb") as f:
1522
+ f.write(payload)
1523
+ apply_default_mode(temp_path)
1524
+ os.replace(temp_path, path)
1525
+ except BaseException:
1526
+ _unlink(temp_path)
1527
+ raise
1528
+
1529
+
1530
+ # -- naming -----------------------------------------------------------------
1531
+
1532
+
1533
+ def stem_for(save_path: str) -> str:
1534
+ """The record's stem, sharing engine._stem_for's multi-part suffix handling."""
1535
+ from .engine import _stem_for
1536
+
1537
+ return _stem_for(save_path)
1538
+
1539
+
1540
+ def _extension_for(name: str, content_type: str, head: bytes) -> str:
1541
+ """The output extension, derived in a fixed order so it is reproducible.
1542
+
1543
+ Falls through to no extension at all rather than inventing .bin: the manifest
1544
+ carries the original name and the content type, and a fabricated extension is
1545
+ a lie that a downstream tool will act on.
1546
+
1547
+ The name is only believed when what follows its last dot is *recognisably* an
1548
+ extension. Plenty of supplementary identifiers end in a dotted segment that is
1549
+ not one -- a Crossref component DOI is named "pone.0000308.s001", and taking
1550
+ "s001" would produce a file called _1.s001 that nothing can open, while the
1551
+ served Content-Type said application/msword all along.
1552
+ """
1553
+ candidate = ""
1554
+ if name and "." in name:
1555
+ candidate = name.rsplit(".", 1)[-1].lower()
1556
+ if not (1 <= len(candidate) <= 8 and candidate.isalnum()):
1557
+ candidate = ""
1558
+ if candidate in _KNOWN_EXTENSIONS:
1559
+ return "." + candidate
1560
+
1561
+ mapped = _CONTENT_TYPE_EXTENSIONS.get((content_type or "").strip().lower())
1562
+ if mapped:
1563
+ return mapped
1564
+ for magic, extension in _MAGIC:
1565
+ if head.startswith(magic):
1566
+ return extension
1567
+ # An unrecognised suffix is still better than nothing when the bytes and the
1568
+ # header offered no opinion either -- it is at least what the source called it.
1569
+ return "." + candidate if candidate else ""
1570
+
1571
+
1572
+ def _looks_like_a_document(name, head: bytes, content_type: str, size: int) -> Tuple[bool, str]:
1573
+ """Reject the bodies that are a 200 without being the file that was asked for.
1574
+
1575
+ Every marker here is served with a success status. This is the single guard
1576
+ that stops a Cloudflare interstitial being written as Supplementary Table 1.
1577
+ """
1578
+ if size < 64:
1579
+ return False, f"implausibly small ({size} bytes)"
1580
+ window = head[:2048]
1581
+ for marker in _CHALLENGE_MARKERS:
1582
+ if marker in window:
1583
+ return False, "bot challenge page"
1584
+ lowered = (name or "").lower()
1585
+ if not lowered.endswith(_TEXTUAL_EXTENSIONS):
1586
+ stripped = window.lstrip()[:9].lower()
1587
+ if (content_type or "").startswith("text/html") or stripped in (b"<!doctype", b"<html"):
1588
+ return False, f"served HTML for {name or 'an unnamed file'}"
1589
+
1590
+ # A binary document that begins with markup is not that document, whatever
1591
+ # the extension claims. The HTML check above is too narrow to catch this:
1592
+ # Elsevier's attachment endpoint answers 200 with an
1593
+ # `<attachment-metadata-response>` wrapper -- ~726 bytes of XML whose only
1594
+ # payload is the URL of the file you actually asked for -- and it was
1595
+ # written straight to disk as `mmc1.pdf`. Measured on one corpus: 51 such
1596
+ # stubs across 33 papers, and 8 of them sat under a manifest reporting
1597
+ # `status: ok`.
1598
+ #
1599
+ # That is the worst shape a supplement failure can take. `fetchpdf-verify`
1600
+ # still passes them, because the sha256 recorded at download time matches
1601
+ # the sha256 on disk -- it answers "is what I have what I fetched", not "is
1602
+ # what I fetched a document". Downstream, a 726-byte XML stub named .pdf
1603
+ # reads as "this paper published this table", which is a claim about the
1604
+ # authors manufactured out of an API quirk.
1605
+ if lowered.endswith(_BINARY_DOC_EXTENSIONS) and window.lstrip()[:1] == b"<":
1606
+ root = window.lstrip()[:60].decode("ascii", "replace").split(">")[0]
1607
+ return False, f"served markup ({root}>) for {name or 'an unnamed file'}"
1608
+ return True, ""
1609
+
1610
+
1611
+ # -- manifest io ------------------------------------------------------------
1612
+
1613
+
1614
+ def write_manifest(path: str, manifest: dict, verbose: bool = False) -> Optional[str]:
1615
+ """Atomically, and never raising into the caller's record."""
1616
+ directory = os.path.dirname(os.path.abspath(path)) or "."
1617
+ try:
1618
+ os.makedirs(directory, exist_ok=True)
1619
+ handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-sim-", dir=directory)
1620
+ try:
1621
+ with os.fdopen(handle, "w", encoding="utf-8") as f:
1622
+ json.dump(manifest, f, indent=2, ensure_ascii=False)
1623
+ os.replace(temp_path, path)
1624
+ except BaseException:
1625
+ _unlink(temp_path)
1626
+ raise
1627
+ return path
1628
+ except Exception as e:
1629
+ if verbose:
1630
+ print(f" ⚠️ could not write {os.path.basename(path)}: {e}")
1631
+ return None
1632
+
1633
+
1634
+ def read_manifest(path: str) -> Optional[dict]:
1635
+ try:
1636
+ with open(path, "r", encoding="utf-8") as f:
1637
+ manifest = json.load(f)
1638
+ return manifest if isinstance(manifest, dict) else None
1639
+ except (OSError, ValueError):
1640
+ return None
1641
+
1642
+
1643
+ # -- wiring -----------------------------------------------------------------
1644
+
1645
+
1646
+ def _wire(resolver, http, ladder, save_path, email, verbose):
1647
+ """Borrow the batch's resolver and client, or build them.
1648
+
1649
+ Borrowing matters more than it looks: HostRateLimiter is lock-guarded but not
1650
+ a singleton, so a client built per record gives every worker its own token
1651
+ buckets and multiplies the configured per-host rate by the worker count -- on
1652
+ the most request-heavy path in the tool.
1653
+
1654
+ Building a resolver when there is none is not optional either. Half the
1655
+ providers here are keyed on a PMCID or an Elsevier PII rather than on the DOI,
1656
+ and without a resolver those identifiers are simply absent -- so the PMC
1657
+ routes, which are the highest-yield ones for biomedical records, would
1658
+ silently never run in single-record mode.
1659
+ """
1660
+ from .tiers import load_ladder
1661
+
1662
+ ladder = ladder or (getattr(resolver, "ladder", None)) or load_ladder()
1663
+ if http is None and resolver is not None:
1664
+ http = getattr(resolver, "http", None)
1665
+ if http is None:
1666
+ from .http import HttpClient
1667
+ from .ratelimit import HostRateLimiter, shared_host_limiter
1668
+
1669
+ http = HttpClient(shared_host_limiter(ladder.rate_limits), email=email, verbose=verbose)
1670
+
1671
+ owns_resolver = resolver is None
1672
+ if owns_resolver:
1673
+ from .cache import ResolutionCache
1674
+ from .resolve import BatchResolver
1675
+
1676
+ output_dir = os.path.dirname(os.path.abspath(save_path)) or "."
1677
+ resolver = BatchResolver(
1678
+ http, ResolutionCache.for_output_dir(output_dir, verbose), ladder, verbose
1679
+ )
1680
+ return ladder, http, resolver, owns_resolver
1681
+
1682
+
1683
+ def _context(http, resolver, ladder, save_path, verbose, email, delay,
1684
+ use_playwright, download_data_artifacts=False,
1685
+ max_data_artifact_bytes=None, llm_adjudicate_artifacts=False,
1686
+ llm_agent_retrieval=False, llm_backend=None,
1687
+ max_llm_records=0,
1688
+ llm_model=None, download_related_unverified=False):
1689
+ from .context import RetrievalContext
1690
+
1691
+ ctx = RetrievalContext(
1692
+ http=http, resolver=resolver, ladder=ladder, save_path=save_path,
1693
+ verbose=verbose, email=email, delay=delay,
1694
+ use_playwright=use_playwright,
1695
+ download_data_artifacts=download_data_artifacts,
1696
+ max_data_artifact_bytes=max_data_artifact_bytes,
1697
+ download_related_unverified=download_related_unverified,
1698
+ )
1699
+ # Adjudication settings ride on scratch rather than becoming typed fields:
1700
+ # they configure one optional provider, not the retrieval itself, and
1701
+ # RetrievalContext is shared with every source in the ladder.
1702
+ ctx.llm_adjudicate_artifacts = llm_adjudicate_artifacts
1703
+ ctx.llm_agent_retrieval = llm_agent_retrieval
1704
+ ctx.llm_backend = llm_backend
1705
+ ctx.max_llm_records = max_llm_records
1706
+ ctx.llm_model = llm_model
1707
+ return ctx
1708
+
1709
+
1710
+ def _resolve(resolver, raw_identifier, doi, pmid):
1711
+ from .identifiers import IdentifierSet
1712
+
1713
+ if resolver is None:
1714
+ return IdentifierSet(doi=doi, pmid=pmid)
1715
+ try:
1716
+ return resolver.resolve(raw_identifier, doi=doi, pmid=pmid)
1717
+ except Exception:
1718
+ return IdentifierSet(doi=doi, pmid=pmid)
1719
+
1720
+
1721
+ def _stem_lock(stem: str) -> threading.Lock:
1722
+ key = os.path.abspath(stem)
1723
+ with _STEM_LOCKS_GUARD:
1724
+ lock = _STEM_LOCKS.get(key)
1725
+ if lock is None:
1726
+ lock = threading.Lock()
1727
+ _STEM_LOCKS[key] = lock
1728
+ return lock
1729
+
1730
+
1731
+ # -- small helpers ----------------------------------------------------------
1732
+
1733
+
1734
+ def _head_of(path: str, size: int = 4096) -> bytes:
1735
+ try:
1736
+ with open(path, "rb") as f:
1737
+ return f.read(size)
1738
+ except OSError:
1739
+ return b""
1740
+
1741
+
1742
+ def _hash_file(path: str) -> Optional[str]:
1743
+ hasher = hashlib.sha256()
1744
+ try:
1745
+ with open(path, "rb") as f:
1746
+ for chunk in iter(lambda: f.read(1 << 20), b""):
1747
+ hasher.update(chunk)
1748
+ except OSError:
1749
+ return None
1750
+ return hasher.hexdigest()
1751
+
1752
+
1753
+ def _digest_of(checksum: str) -> str:
1754
+ """"md5:abc" -> "abc". Declared digests are compared to each other only."""
1755
+ return str(checksum).split(":", 1)[-1].strip().lower()
1756
+
1757
+
1758
+ def _iso(timestamp: float) -> str:
1759
+ from datetime import datetime, timezone
1760
+
1761
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1762
+
1763
+
1764
+ def _unlink(path: Optional[str]) -> None:
1765
+ if not path:
1766
+ return
1767
+ try:
1768
+ os.unlink(path)
1769
+ except OSError:
1770
+ pass