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,820 @@
1
+ """What supplementary files exist for a record, before anything is fetched.
2
+
3
+ Pure enumeration: every function here asks a provider what it has and returns a
4
+ list of SupplementFile. Nothing is downloaded, nothing is written, nothing is
5
+ filtered on desirability. Two callers consume it and they want different subsets,
6
+ so the filtering lives with them rather than here:
7
+
8
+ * T4_SUPPLEMENT (sources/supplements.py) wants only machine-readable data,
9
+ because there a supplement stands *in place of* the paper's full text and a
10
+ supplementary PDF is no better than the PDF it was standing in for.
11
+ * --pull-supplementary (supplementary.py) wants everything, because there a
12
+ supplement stands *alongside* the paper and the caller asked for the lot.
13
+
14
+ Keeping enumeration in one place is what stops those two from drifting: the
15
+ repository listings were already being fetched twice with two different
16
+ predicates applied to them, once in the legacy chain and once at T4.
17
+
18
+ Archives are enumerated as archives, not expanded here. A zip's central
19
+ directory is at the end of the file, so its members are unknowable until the
20
+ whole thing has landed -- which is a decision about byte budgets, and belongs
21
+ with the caller that owns the cap.
22
+ """
23
+
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ FIGSHARE_FILES = "https://api.figshare.com/v2/articles/{article_id}/files"
29
+ FIGSHARE_ARTICLE = "https://api.figshare.com/v2/articles/{article_id}"
30
+ ZENODO_RECORD = "https://zenodo.org/api/records/{record_id}"
31
+ OSF_GUID = "https://api.osf.io/v2/guids/{osf_id}/"
32
+ EPMC_SUPPL = "https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/supplementaryFiles"
33
+ DRYAD_BASE = "https://datadryad.org"
34
+ DRYAD_DATASET = DRYAD_BASE + "/api/v2/datasets/{encoded_doi}"
35
+ DATAVERSE_DATASET = ("https://dataverse.harvard.edu/api/datasets/:persistentId/"
36
+ "?persistentId=doi:{doi}")
37
+ DATAVERSE_FILE = "https://dataverse.harvard.edu/api/access/datafile/{file_id}"
38
+
39
+ #: Dryad file listings paginate; a deposit with more pages than this is a
40
+ #: deposit whose tail we drop rather than walk forever.
41
+ _DRYAD_MAX_PAGES = 10
42
+
43
+ #: Roles. "figure" and "article" are the two things --pull-supplementary drops;
44
+ #: everything else is kept, including "unknown", because the caller asked for as
45
+ #: much as possible and a spurious file costs disk while a dropped one loses data.
46
+ ROLE_SUPPLEMENT = "supplement"
47
+ ROLE_FIGURE = "figure"
48
+ ROLE_ARTICLE = "article"
49
+ ROLE_UNKNOWN = "unknown"
50
+
51
+ #: How deep to follow OSF folders, and how many nodes to visit in total. OSF
52
+ #: projects can nest arbitrarily and a runaway walk would spend a record's whole
53
+ #: request budget on one deposit.
54
+ _OSF_MAX_DEPTH = 4
55
+ _OSF_MAX_NODES = 200
56
+
57
+
58
+ @dataclass
59
+ class SupplementFile:
60
+ """One file a provider says exists, before anything is fetched."""
61
+
62
+ name: str # remote filename as listed
63
+ url: str # direct download URL
64
+ provider: str # "figshare_files", "pmc_s3", ...
65
+ listing_index: int = 0 # position in the provider's listing, as returned
66
+ mimetype: Optional[str] = None # as declared by the listing, if any
67
+ size_bytes: Optional[int] = None # declared; lets the cap refuse before transferring
68
+ checksum: Optional[str] = None # "md5:..." as the source declares it
69
+ role: str = ROLE_UNKNOWN
70
+ label: str = "" # JATS <label>/<caption>, when known
71
+ origin_doi: Optional[str] = None # the DOI this file was enumerated under
72
+ is_archive: bool = False # expand rather than keep (EPMC zip, arXiv tarball)
73
+ polite: bool = False # pass through to HttpClient.download
74
+ extra: Dict[str, Any] = field(default_factory=dict)
75
+
76
+ @property
77
+ def basename(self) -> str:
78
+ """The listed name with any path or query string removed.
79
+
80
+ Never used to build an output path -- supplementary.py numbers files
81
+ instead -- but it is what dedupe and extension-guessing key on.
82
+ """
83
+ name = (self.name or "").split("?")[0].replace("\\", "/")
84
+ return name.rsplit("/", 1)[-1].strip()
85
+
86
+
87
+ # -- role heuristics --------------------------------------------------------
88
+ #
89
+ # Only meaningful when the thing being enumerated is the article's own DOI. When
90
+ # a data DOI was reached through DataCite, everything in it is supplementary by
91
+ # construction and these are not consulted.
92
+
93
+ #: Filenames that are the paper itself, as each publisher's deposit names it.
94
+ _ARTICLE_NAME_RE = re.compile(
95
+ r"(?i)^("
96
+ r"(article|paper|manuscript|main[_\- ]?text|full[_\- ]?text|preprint|ms)\.pdf"
97
+ r"|1-s2\.0-S\d+-main\.pdf"
98
+ r"|PMC\d+(\.\d+)?\.pdf"
99
+ r"|s\d{5}-\d{3}-\d{5}-\w\.pdf"
100
+ r")$"
101
+ )
102
+
103
+ #: Filenames that are positively supplementary, across the publishers we reach.
104
+ _SUPPLEMENT_NAME_RE = re.compile(
105
+ r"(?i)("
106
+ r"_s\d+|_si_\d+|MOESM\d+|-sup-\d+|^mmc\d+|\.s\d{3}\b"
107
+ r"|suppl|supporting|appendix|additional[_\- ]file"
108
+ r"|(data|table|fig(ure)?|movie|video|dataset)[_\- ]?s\d+"
109
+ r")"
110
+ )
111
+
112
+ #: Repository item types that mean "this record *is* the paper".
113
+ _ARTICLE_ITEM_TYPES = {"journal contribution", "preprint", "publication", "journalarticle"}
114
+
115
+
116
+ def classify_role(name: Optional[str], item_type: Optional[str] = None,
117
+ jats_supplements=None, jats_figures=None) -> str:
118
+ """Best guess at whether a listed file is a supplement, a figure or the paper.
119
+
120
+ Ordered cheapest-first and deliberately biased toward ROLE_SUPPLEMENT: the
121
+ caller asked for as much as possible, so ambiguity resolves toward keeping.
122
+ JATS ground truth wins when we have it, because it is the article's own
123
+ statement about its own files rather than a guess at a filename.
124
+ """
125
+ base = (name or "").split("?")[0].rsplit("/", 1)[-1].strip()
126
+ stem = base.lower()
127
+
128
+ if jats_supplements and _matches_href(stem, jats_supplements):
129
+ return ROLE_SUPPLEMENT
130
+ if jats_figures and _matches_href(stem, jats_figures):
131
+ return ROLE_FIGURE
132
+
133
+ if _ARTICLE_NAME_RE.search(base):
134
+ return ROLE_ARTICLE
135
+ if _SUPPLEMENT_NAME_RE.search(base):
136
+ return ROLE_SUPPLEMENT
137
+ if item_type and str(item_type).strip().lower() in _ARTICLE_ITEM_TYPES:
138
+ # The item is the paper, but a "publication" record still carries its
139
+ # supplements alongside the PDF. Only the PDF itself is the article.
140
+ return ROLE_ARTICLE if stem.endswith(".pdf") else ROLE_UNKNOWN
141
+ return ROLE_UNKNOWN
142
+
143
+
144
+ def jats_sets(ctx) -> Tuple[set, set]:
145
+ """The supplement/figure href sets E1 left in ctx.scratch, if it ran.
146
+
147
+ Empty when there was no JATS to read, in which case classify_role falls back
148
+ to its filename heuristics.
149
+ """
150
+ return (ctx.scratch.get("jats_supplements") or set(),
151
+ ctx.scratch.get("jats_figures") or set())
152
+
153
+
154
+ def _matches_href(basename: str, hrefs) -> bool:
155
+ """True if a filename matches a JATS xlink:href, extension-insensitively.
156
+
157
+ JATS routinely omits the extension -- href "pone.0000308.s001" against a
158
+ blob named "pone.0000308.s001.doc" -- so a bare equality test would miss
159
+ almost every match.
160
+ """
161
+ if not basename:
162
+ return False
163
+ stem = basename.rsplit(".", 1)[0]
164
+ for href in hrefs:
165
+ other = str(href).split("?")[0].rsplit("/", 1)[-1].strip().lower()
166
+ if not other:
167
+ continue
168
+ if basename == other or stem == other.rsplit(".", 1)[0]:
169
+ return True
170
+ return False
171
+
172
+
173
+ # -- Europe PMC -------------------------------------------------------------
174
+
175
+
176
+ def enumerate_epmc_archive(ids, ctx, include_inline_images: bool = False
177
+ ) -> List[SupplementFile]:
178
+ """The EPMC supplementary bundle, as one archive to expand.
179
+
180
+ Not fetched here: the caller downloads it under its own byte budget and
181
+ expands the members, because a zip's member sizes are only knowable once the
182
+ whole file has landed.
183
+
184
+ includeInlineImage=no is the right default and the difference is not
185
+ cosmetic. Verified on PMC1817752: the default returns 14 members / 132 KB,
186
+ the article's entire media blob set with every figure and table image in it;
187
+ with the flag it returns 4 members / 34 KB, exactly s001-s004. It also turns
188
+ a vague answer into a clean negative -- PMC3339580 gives 404 with the flag
189
+ and 371 KB of figures without it, and that article genuinely has no
190
+ supplementary material (zero <supplementary-material> in its JATS, and its
191
+ S3 media_urls are nine Fig*.jpg).
192
+
193
+ The listing is speculative: the URL is constructed for any PMCID without
194
+ checking that a bundle exists, so the endpoint's "nothing here" answers (a
195
+ bare 404, or HTTP 200 wrapping an <errorBean> stub for non-OA articles) are
196
+ negatives, not losses. extra["speculative"] tells the download side to file
197
+ them under "no-bundle" instead of a lost reason -- on a 99-record corpus the
198
+ old classification turned 40 clean negatives into phantom "partial" statuses.
199
+ """
200
+ if not ids.pmcid:
201
+ return []
202
+ url = EPMC_SUPPL.format(pmcid=ids.pmcid)
203
+ if not include_inline_images:
204
+ url += "?includeInlineImage=no"
205
+ return [
206
+ SupplementFile(
207
+ name=f"{ids.pmcid}_SupplementaryFiles.zip",
208
+ url=url,
209
+ provider="europepmc_supplements",
210
+ mimetype="application/zip",
211
+ role=ROLE_SUPPLEMENT,
212
+ origin_doi=ids.doi,
213
+ is_archive=True,
214
+ extra={"pmcid": ids.pmcid, "speculative": True},
215
+ )
216
+ ]
217
+
218
+
219
+ # -- Figshare ---------------------------------------------------------------
220
+
221
+
222
+ def enumerate_figshare(ids, ctx, with_item_type: bool = True) -> List[SupplementFile]:
223
+ """Figshare's file listing.
224
+
225
+ with_item_type costs one extra request and only affects `role`, so T4 -- which
226
+ filters on _is_structured and never reads a role -- turns it off rather than
227
+ paying for it on every figshare record.
228
+ """
229
+ article_id = match_id(ids.doi, r"figshare\.(\d+)")
230
+ if not article_id:
231
+ return []
232
+ response = ctx.http.get(
233
+ FIGSHARE_FILES.format(article_id=article_id), timeout=30, polite=False
234
+ )
235
+ if not response.ok:
236
+ return []
237
+ try:
238
+ listing = response.json() or []
239
+ except ValueError:
240
+ return []
241
+
242
+ item_type = _figshare_item_type(ctx, article_id) if with_item_type else None
243
+ files = []
244
+ for index, entry in enumerate(listing):
245
+ if not isinstance(entry, dict):
246
+ continue
247
+ url = entry.get("download_url")
248
+ if not url:
249
+ continue
250
+ # is_link_only entries are not files: download_url serves the landing
251
+ # page of whatever external resource was linked, as HTML, with a 200.
252
+ if entry.get("is_link_only"):
253
+ continue
254
+ name = entry.get("name") or ""
255
+ checksum = entry.get("supplied_md5") or entry.get("computed_md5")
256
+ files.append(SupplementFile(
257
+ name=name,
258
+ url=url,
259
+ provider="figshare_files",
260
+ listing_index=index,
261
+ mimetype=entry.get("mimetype"),
262
+ size_bytes=_as_int(entry.get("size")),
263
+ checksum=f"md5:{checksum}" if checksum else None,
264
+ role=classify_role(name, item_type),
265
+ origin_doi=ids.doi,
266
+ extra={"figshare_article": article_id},
267
+ ))
268
+ return files
269
+
270
+
271
+ def _figshare_item_type(ctx, article_id) -> Optional[str]:
272
+ """defined_type_name, which says whether the item *is* the paper.
273
+
274
+ One extra request per figshare record; worth it because "journal
275
+ contribution" vs "dataset" settles the article-vs-supplement question that
276
+ filename heuristics only guess at.
277
+ """
278
+ response = ctx.http.get(
279
+ FIGSHARE_ARTICLE.format(article_id=article_id), timeout=30, polite=False
280
+ )
281
+ if not response.ok:
282
+ return None
283
+ try:
284
+ return ((response.json() or {}).get("defined_type_name")) or None
285
+ except ValueError:
286
+ return None
287
+
288
+
289
+ # -- Zenodo -----------------------------------------------------------------
290
+
291
+
292
+ def enumerate_zenodo(ids, ctx) -> List[SupplementFile]:
293
+ record_id = match_id(ids.doi, r"zenodo\.(\d+)")
294
+ if not record_id:
295
+ return []
296
+ response = ctx.http.get(
297
+ ZENODO_RECORD.format(record_id=record_id), timeout=30, polite=False
298
+ )
299
+ if not response.ok:
300
+ return []
301
+ try:
302
+ payload = response.json() or {}
303
+ except ValueError:
304
+ return []
305
+
306
+ item_type = (((payload.get("metadata") or {}).get("resource_type") or {}).get("type"))
307
+ files = []
308
+ for index, entry in enumerate(payload.get("files") or []):
309
+ if not isinstance(entry, dict):
310
+ continue
311
+ # Restricted records list their files with no links at all.
312
+ url = ((entry.get("links") or {}).get("self"))
313
+ if not url:
314
+ continue
315
+ name = entry.get("key") or ""
316
+ files.append(SupplementFile(
317
+ name=name,
318
+ url=url,
319
+ provider="zenodo_files",
320
+ listing_index=index,
321
+ mimetype=entry.get("mimetype") or entry.get("type"),
322
+ size_bytes=_as_int(entry.get("size")),
323
+ checksum=_normalize_checksum(entry.get("checksum")),
324
+ role=classify_role(name, item_type),
325
+ origin_doi=ids.doi,
326
+ extra={"zenodo_record": record_id},
327
+ ))
328
+ return files
329
+
330
+
331
+ # -- OSF --------------------------------------------------------------------
332
+
333
+
334
+ def enumerate_osf(ids, ctx) -> List[SupplementFile]:
335
+ osf_id = match_id(ids.doi, r"osf\.io/([a-z0-9]+)") or match_id(ids.doi, r"/([a-z0-9]{5})$")
336
+ if not osf_id:
337
+ return []
338
+
339
+ guid = ctx.http.get(OSF_GUID.format(osf_id=osf_id), timeout=30, polite=False)
340
+ if not guid.ok:
341
+ return []
342
+ try:
343
+ data = (guid.json() or {}).get("data") or {}
344
+ except ValueError:
345
+ return []
346
+
347
+ files_url = _related_href(data, "files")
348
+ if not files_url:
349
+ return []
350
+
351
+ files: List[SupplementFile] = []
352
+ budget = [_OSF_MAX_NODES]
353
+ for provider_url in _osf_provider_urls(ctx, files_url):
354
+ _osf_walk(ctx, provider_url, ids, files, budget, depth=0, seen=set())
355
+ if budget[0] <= 0:
356
+ ctx.log(f" OSF: stopped after {_OSF_MAX_NODES} nodes")
357
+ break
358
+ for index, entry in enumerate(files):
359
+ entry.listing_index = index
360
+ return files
361
+
362
+
363
+ def _osf_provider_urls(ctx, files_url) -> List[str]:
364
+ response = ctx.http.get(files_url, timeout=30, polite=False)
365
+ if not response.ok:
366
+ return []
367
+ try:
368
+ entries = (response.json() or {}).get("data") or []
369
+ except ValueError:
370
+ return []
371
+ urls = []
372
+ for entry in entries:
373
+ href = _related_href(entry, "files")
374
+ if href:
375
+ urls.append(href)
376
+ return urls
377
+
378
+
379
+ def _osf_walk(ctx, url, ids, out: List[SupplementFile], budget, depth: int, seen: set) -> None:
380
+ """Collect files under an OSF storage node, following folders.
381
+
382
+ Folders arrive as entries with kind "folder" and links.download null. The
383
+ T4 enumerator dropped them, and everything inside them with them -- fine when
384
+ you only want a spreadsheet if one happens to be at the top level, wrong when
385
+ the ask is "as many as possible" and the deposit is organised into
386
+ directories.
387
+ """
388
+ if depth > _OSF_MAX_DEPTH or budget[0] <= 0 or url in seen:
389
+ return
390
+ seen.add(url)
391
+ budget[0] -= 1
392
+
393
+ response = ctx.http.get(url, timeout=30, polite=False)
394
+ if not response.ok:
395
+ return
396
+ try:
397
+ entries = (response.json() or {}).get("data") or []
398
+ except ValueError:
399
+ return
400
+
401
+ for entry in entries:
402
+ if budget[0] <= 0:
403
+ return
404
+ if not isinstance(entry, dict):
405
+ continue
406
+ attributes = entry.get("attributes") or {}
407
+ name = attributes.get("name") or ""
408
+ if (attributes.get("kind") or "").lower() == "folder":
409
+ child = _related_href(entry, "files")
410
+ if child:
411
+ _osf_walk(ctx, child, ids, out, budget, depth + 1, seen)
412
+ continue
413
+ download = ((entry.get("links") or {}).get("download"))
414
+ if not download:
415
+ continue
416
+ out.append(SupplementFile(
417
+ name=name,
418
+ url=download,
419
+ provider="osf_files",
420
+ mimetype=attributes.get("contentType") or attributes.get("content_type"),
421
+ size_bytes=_as_int(attributes.get("size")),
422
+ role=classify_role(name),
423
+ origin_doi=ids.doi,
424
+ extra={
425
+ "osf_path": attributes.get("materialized_path") or attributes.get("path") or "",
426
+ },
427
+ ))
428
+
429
+
430
+ # -- Dryad ------------------------------------------------------------------
431
+
432
+
433
+ def enumerate_dryad(ids, ctx) -> List[SupplementFile]:
434
+ """Dryad's file listing for the latest version of a deposit.
435
+
436
+ Two hops (dataset -> version -> files), both free and unauthenticated, and
437
+ case-insensitive on the DOI (verified live 2026-08-09). Every file carries
438
+ a declared size and a sha-256, so the size cap can refuse before
439
+ transferring. Reached almost exclusively through the link services --
440
+ a paper's own DOI is never a Dryad DOI -- which is why this exists:
441
+ both real dataset links the sweeps found were Dryad deposits the router
442
+ could see and not fetch.
443
+ """
444
+ import urllib.parse
445
+
446
+ dryad_doi = match_id(ids.doi or "", r"(10\.5061/dryad\.[a-z0-9./]+)")
447
+ if not dryad_doi:
448
+ return []
449
+ encoded = urllib.parse.quote(f"doi:{dryad_doi}", safe="")
450
+
451
+ dataset = ctx.http.get(DRYAD_DATASET.format(encoded_doi=encoded),
452
+ timeout=30, polite=False)
453
+ if not dataset.ok:
454
+ return []
455
+ try:
456
+ version_href = (((dataset.json() or {}).get("_links") or {})
457
+ .get("stash:version") or {}).get("href")
458
+ except ValueError:
459
+ return []
460
+ if not version_href:
461
+ return []
462
+
463
+ files: List[SupplementFile] = []
464
+ url = DRYAD_BASE + version_href + "/files"
465
+ for _ in range(_DRYAD_MAX_PAGES):
466
+ response = ctx.http.get(url, timeout=30, polite=False)
467
+ if not response.ok:
468
+ break
469
+ try:
470
+ payload = response.json() or {}
471
+ except ValueError:
472
+ break
473
+ for entry in ((payload.get("_embedded") or {}).get("stash:files")) or []:
474
+ if not isinstance(entry, dict):
475
+ continue
476
+ if str(entry.get("status") or "").lower() == "deleted":
477
+ continue
478
+ download = ((entry.get("_links") or {}).get("stash:download") or {}).get("href")
479
+ if not download:
480
+ continue
481
+ name = entry.get("path") or ""
482
+ digest = entry.get("digest")
483
+ digest_type = str(entry.get("digestType") or "").replace("-", "")
484
+ files.append(SupplementFile(
485
+ name=name,
486
+ url=DRYAD_BASE + download,
487
+ provider="dryad_files",
488
+ listing_index=len(files),
489
+ mimetype=entry.get("mimeType"),
490
+ size_bytes=_as_int(entry.get("size")),
491
+ checksum=f"{digest_type}:{digest}" if digest and digest_type else None,
492
+ role=classify_role(name, "dataset"),
493
+ origin_doi=ids.doi,
494
+ extra={"dryad_doi": dryad_doi},
495
+ ))
496
+ next_href = ((payload.get("_links") or {}).get("next") or {}).get("href")
497
+ if not next_href:
498
+ break
499
+ url = DRYAD_BASE + next_href
500
+ return files
501
+
502
+
503
+ # -- Dataverse --------------------------------------------------------------
504
+
505
+
506
+ def enumerate_dataverse(ids, ctx) -> List[SupplementFile]:
507
+ """Harvard Dataverse (the 10.7910 prefix), latest version's files.
508
+
509
+ One request; files declare size and md5. Restricted files are listed
510
+ without being downloadable, so they are skipped the same way Zenodo's
511
+ restricted records are.
512
+ """
513
+ dvn_doi = match_id(ids.doi or "", r"(10\.7910/dvn/[a-z0-9]+)")
514
+ if not dvn_doi:
515
+ return []
516
+
517
+ api_url = DATAVERSE_DATASET.format(doi=dvn_doi)
518
+ response = ctx.http.get(api_url, timeout=30, polite=False)
519
+ data = None
520
+ if response.ok:
521
+ try:
522
+ data = (response.json() or {}).get("data") or {}
523
+ except ValueError:
524
+ data = None
525
+
526
+ if data is None:
527
+ # An empty HTTP 202 is Harvard Dataverse's AWS WAF, not an empty
528
+ # deposit -- and `return []` here is indistinguishable from "this
529
+ # dataset has no files", which is how a trial's own replication data
530
+ # went unfetched while the manifest recorded none_found.
531
+ from .repository_waf import fetch_json_through_browser, looks_like_challenge
532
+
533
+ body = getattr(response, "content", b"") or b""
534
+ status = getattr(response, "status", 0)
535
+ if not looks_like_challenge(body, status):
536
+ return []
537
+ ctx.log(f" dataverse: HTTP {status} looks like a bot-challenge; "
538
+ f"retrying through a headed browser")
539
+ payload = fetch_json_through_browser(api_url, dvn_doi, ctx)
540
+ if not payload:
541
+ return []
542
+ data = (payload or {}).get("data") or {}
543
+
544
+ files: List[SupplementFile] = []
545
+ for entry in ((data.get("latestVersion") or {}).get("files")) or []:
546
+ if not isinstance(entry, dict) or entry.get("restricted"):
547
+ continue
548
+ datafile = entry.get("dataFile") or {}
549
+ file_id = datafile.get("id")
550
+ name = datafile.get("filename") or ""
551
+ if not file_id or not name:
552
+ continue
553
+ checksum = datafile.get("md5") or \
554
+ ((datafile.get("checksum") or {}).get("value")
555
+ if isinstance(datafile.get("checksum"), dict) else None)
556
+ files.append(SupplementFile(
557
+ name=name,
558
+ url=DATAVERSE_FILE.format(file_id=file_id),
559
+ provider="dataverse_files",
560
+ listing_index=len(files),
561
+ mimetype=datafile.get("contentType"),
562
+ size_bytes=_as_int(datafile.get("filesize")),
563
+ checksum=_normalize_checksum(checksum),
564
+ role=classify_role(name, "dataset"),
565
+ origin_doi=ids.doi,
566
+ extra={"dataverse_doi": dvn_doi},
567
+ ))
568
+ return files
569
+
570
+
571
+ def _related_href(entry: dict, relationship: str) -> Optional[str]:
572
+ href = (
573
+ ((entry.get("relationships") or {}).get(relationship) or {})
574
+ .get("links", {})
575
+ .get("related", {})
576
+ )
577
+ if isinstance(href, dict):
578
+ return href.get("href")
579
+ return None
580
+
581
+
582
+ # -- the registry -----------------------------------------------------------
583
+
584
+ #: Provider order, and therefore the order files are numbered on disk. A tuple
585
+ #: rather than a dict or a set because that order is load-bearing: it is the
586
+ #: primary sort key in supplementary.py, so appending a provider here must not be
587
+ #: able to renumber the files an earlier one already produced.
588
+ #:
589
+ #: The ordering rationale, in three parts:
590
+ #:
591
+ #: * jats_manifest is first because it carries no files at all. It leaves the
592
+ #: article's own supplement/figure sets in ctx.scratch, and every provider
593
+ #: after it classifies against that ground truth instead of guessing from a
594
+ #: filename.
595
+ #: * Routes that declare a size and a checksum *before* transferring come next,
596
+ #: so the size cap can refuse an oversized file for no bandwidth.
597
+ #: * Archival copies (Europe PMC, PMC S3) beat publisher copies, because their
598
+ #: filenames are stable and their bytes are the deposited originals.
599
+ #:
600
+ #: datacite_related is late because it fans out into the repository enumerators,
601
+ #: so anything it finds that was already reachable directly has been taken.
602
+ def _atypon_if_withheld(ids, ctx) -> List[SupplementFile]:
603
+ """E19: the publisher's own SI, but only when Europe PMC refused ours.
604
+
605
+ Costs a headed browser, so it is not something to run 46 times to find the
606
+ one record that needs it. The EPMC provider leaves a breadcrumb in
607
+ ctx.scratch when it hits the "not open access" refusal, and this reads it.
608
+
609
+ Enumeration all happens before any download, so the refusal has not been
610
+ seen yet at this point -- asking EPMC's search index directly is what makes
611
+ the gate work here. `hasSuppl=Y` with `isOpenAccess=N` is exactly the state
612
+ that produces the "not open access one" errorBean later, and it costs one
613
+ cheap JSON call against a host we are already rate-limited against.
614
+ """
615
+ from .supplement_atypon import atypon_host_for, fetch_atypon_supplements
616
+
617
+ doi = getattr(ids, "doi", None)
618
+ if not atypon_host_for(doi):
619
+ return []
620
+ if not _epmc_withholds_supplements(ids, ctx):
621
+ return []
622
+ return fetch_atypon_supplements(ids, ctx)
623
+
624
+
625
+ def _epmc_withholds_supplements(ids, ctx, default_without_pmcid: bool = True) -> bool:
626
+ """True when EPMC says supplements exist but the article is not open access.
627
+
628
+ `default_without_pmcid` exists because the two callers want opposite things
629
+ from a missing PMCID, and conflating them produced a real bug:
630
+
631
+ * The E19 browser gate wants True -- an Atypon DOI that never reached
632
+ Europe PMC should still get the publisher attempt.
633
+ * The "SI WITHHELD" warning wants False. Claiming files exist and are
634
+ being withheld, with no evidence that they exist at all, is exactly the
635
+ false alarm this parameter prevents.
636
+ """
637
+ pmcid = getattr(ids, "pmcid", None)
638
+ if not pmcid:
639
+ return default_without_pmcid
640
+ cached = ctx.scratch.get("_epmc_withholds")
641
+ if cached is not None:
642
+ return cached
643
+ verdict = False
644
+ try:
645
+ response = ctx.http.get(
646
+ "https://www.ebi.ac.uk/europepmc/webservices/rest/search",
647
+ params={"query": f"PMCID:{pmcid}", "resultType": "core",
648
+ "format": "json"},
649
+ timeout=25,
650
+ )
651
+ if response.ok:
652
+ results = ((response.json() or {}).get("resultList") or {}).get("result") or []
653
+ if results:
654
+ record = results[0]
655
+ verdict = (str(record.get("hasSuppl") or "").upper() == "Y"
656
+ and str(record.get("isOpenAccess") or "").upper() != "Y")
657
+ except Exception:
658
+ verdict = False # never let a metadata lookup break the record
659
+ ctx.scratch["_epmc_withholds"] = verdict
660
+ return verdict
661
+
662
+
663
+ def _providers() -> Tuple[Tuple[str, Any], ...]:
664
+ """Built lazily so the provider modules can import from this one."""
665
+ from .supplement_graph import (
666
+ enumerate_crossref_components,
667
+ enumerate_datacite_related,
668
+ enumerate_epmc_datalinks,
669
+ enumerate_fulltext_scan,
670
+ enumerate_scholix_related,
671
+ )
672
+ from .supplement_pmc import (
673
+ enumerate_jats_declared,
674
+ enumerate_jats_manifest,
675
+ enumerate_pmc_s3,
676
+ )
677
+ from .supplement_atypon import fetch_atypon_supplements
678
+ from .supplement_publishers import (
679
+ enumerate_apa_supplemental,
680
+ enumerate_elsevier_objects,
681
+ enumerate_plos,
682
+ enumerate_preprint_supplements,
683
+ enumerate_springer_esm,
684
+ )
685
+
686
+ return (
687
+ ("jats_manifest", enumerate_jats_manifest), # E1 classifier only
688
+ ("europepmc_supplements", enumerate_epmc_archive), # E2
689
+ ("pmc_s3", enumerate_pmc_s3), # E3
690
+ ("elsevier_objects", enumerate_elsevier_objects), # E10
691
+ ("springer_esm", enumerate_springer_esm), # E11
692
+ ("plos", enumerate_plos), # E12
693
+ ("preprint_supplements", enumerate_preprint_supplements), # E13
694
+ ("apa_supplemental", enumerate_apa_supplemental), # E14
695
+ ("figshare_files", enumerate_figshare), # E5
696
+ ("zenodo_files", enumerate_zenodo), # E6
697
+ ("osf_files", enumerate_osf), # E7
698
+ ("datacite_related", enumerate_datacite_related), # E4
699
+ ("crossref_components", enumerate_crossref_components), # E9
700
+ ("jats_declared", enumerate_jats_declared), # E15 recovery
701
+ ("scholix_related", enumerate_scholix_related), # E16
702
+ ("epmc_datalinks", enumerate_epmc_datalinks), # E17
703
+ ("fulltext_scan", enumerate_fulltext_scan), # E18
704
+ # E19 LAST on purpose: one headed browser per record. Only reached for
705
+ # records Europe PMC has already said it is withholding, which on a
706
+ # 46-record corpus was 1, not 46.
707
+ ("atypon_suppl", _atypon_if_withheld), # E19
708
+ # E20 truly last: the only provider that costs money, and the only one
709
+ # that benefits from knowing what all the others already found.
710
+ ("llm_agent", _llm_agent), # E20
711
+ )
712
+
713
+
714
+ def _llm_agent(ids, ctx):
715
+ """Imported at call time: a run without the flag never loads a backend."""
716
+ from .llm_agent_retrieval import enumerate_llm_agent
717
+ return enumerate_llm_agent(ids, ctx)
718
+
719
+
720
+ #: The provider names, in order, without importing the provider modules. Kept in
721
+ #: step with _providers() by test_provider_names_match_the_registry.
722
+ PROVIDER_NAMES: Tuple[str, ...] = (
723
+ "jats_manifest",
724
+ "europepmc_supplements",
725
+ "pmc_s3",
726
+ "elsevier_objects",
727
+ "springer_esm",
728
+ "plos",
729
+ "preprint_supplements",
730
+ "apa_supplemental",
731
+ "figshare_files",
732
+ "zenodo_files",
733
+ "osf_files",
734
+ "datacite_related",
735
+ "crossref_components",
736
+ "jats_declared",
737
+ "scholix_related",
738
+ "epmc_datalinks",
739
+ "fulltext_scan",
740
+ "atypon_suppl",
741
+ "llm_agent",
742
+ )
743
+
744
+
745
+ def provider_rank(name: str) -> int:
746
+ """Where a provider sits in the numbering order.
747
+
748
+ Tolerant of the "datacite_related:zenodo_files" form, so a file reached by
749
+ fanning out through DataCite ranks with DataCite rather than falling to the
750
+ end -- the fan-out is where it came from.
751
+ """
752
+ root = str(name or "").split(":", 1)[0]
753
+ try:
754
+ return PROVIDER_NAMES.index(root)
755
+ except ValueError:
756
+ return len(PROVIDER_NAMES)
757
+
758
+
759
+ def enumerate_all(ids, ctx, providers=None) -> Tuple[List[SupplementFile], List[dict]]:
760
+ """Ask every applicable provider what it has.
761
+
762
+ Each provider is guarded individually, the same way engine._run_source
763
+ guards a source: one repository serving malformed JSON must cost us that
764
+ repository, not the record. The reports are what lets the manifest say
765
+ "figshare was not applicable" and "zenodo errored" rather than collapsing
766
+ both into an empty list.
767
+ """
768
+ providers = providers if providers is not None else _providers()
769
+ found: List[SupplementFile] = []
770
+ reports: List[dict] = []
771
+ # A live reference, so a provider that runs LAST can see what the earlier
772
+ # ones already listed. Only the retrieval agent uses it, and only to avoid
773
+ # asking a model to go and fetch a file we are already holding.
774
+ ctx.scratch["enumerated_so_far"] = found
775
+ for name, enumerator in providers:
776
+ try:
777
+ files = enumerator(ids, ctx) or []
778
+ except Exception as e:
779
+ ctx.log(f" ✗ {name} raised: {str(e)[:150]}")
780
+ reports.append({
781
+ "name": name,
782
+ "status": "error",
783
+ "reason": f"{type(e).__name__}: {str(e)[:150]}",
784
+ })
785
+ continue
786
+ for entry in files:
787
+ entry.provider = entry.provider or name
788
+ found.extend(files)
789
+ reports.append({
790
+ "name": name,
791
+ "status": "ok" if files else "nothing_listed",
792
+ "listed": len(files),
793
+ })
794
+ return found, reports
795
+
796
+
797
+ # -- shared helpers ---------------------------------------------------------
798
+
799
+
800
+ def match_id(value: Optional[str], pattern: str) -> Optional[str]:
801
+ if not value:
802
+ return None
803
+ m = re.search(pattern, value, re.IGNORECASE)
804
+ return m.group(1) if m else None
805
+
806
+
807
+ def _as_int(value) -> Optional[int]:
808
+ try:
809
+ parsed = int(value)
810
+ except (TypeError, ValueError):
811
+ return None
812
+ return parsed if parsed >= 0 else None
813
+
814
+
815
+ def _normalize_checksum(value) -> Optional[str]:
816
+ """Zenodo already sends "md5:...", others send a bare hex digest."""
817
+ if not value:
818
+ return None
819
+ text = str(value).strip()
820
+ return text if ":" in text else f"md5:{text}"