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,842 @@
1
+ """Reaching supplements through the citation graph rather than the publisher.
2
+
3
+ Both routes here run *backwards*, and that is the whole trick.
4
+
5
+ DataCite: forward traversal from an article DOI almost always fails, because the
6
+ article is registered with Crossref and has no DataCite record to traverse from.
7
+ The reverse query -- "which DataCite records name this DOI as related?" -- is one
8
+ request and works. Verified on 10.1186/s40168-025-02261-0: three hits, a Dryad
9
+ dataset and two Zenodo software records.
10
+
11
+ That verification also settled how to filter them. The relations those three were
12
+ deposited under were IsCitedBy and IsSourceOf -- *not* IsSupplementTo. A
13
+ relationType whitelist, which is the obvious design and the one the legacy chain
14
+ uses at _collect_datacite_candidate_urls, would have discarded all three. So the
15
+ gate is types.resourceTypeGeneral: a Dataset that names your article is your data
16
+ whatever relation the depositor picked, and a JournalArticle that names your
17
+ article is somebody else's paper citing you.
18
+
19
+ Crossref: article-level `relation` is empty at most publishers (is-supplemented-by
20
+ covers 99,154 works out of 185 million, about 0.05%). Component DOIs are a
21
+ different story -- 9.3 million works -- and the reverse filter finds them:
22
+ relation.object:{doi} returns the article's own components, of which the s001-style
23
+ ones are its supplementary files.
24
+
25
+ ScholeXplorer and Europe PMC datalinks run *forward* -- both index links FROM the
26
+ article, so sourcePid={doi} works where the DataCite forward traversal did not.
27
+ They earn their place beside the DataCite reverse query because their link pools
28
+ barely overlap it: ScholeXplorer folds in Crossref, DataCite AND OpenAIRE's
29
+ text-mining of ~14M PDFs, and Europe PMC adds accession-number mining (trial
30
+ registrations, GEO, PDB...) that no DOI-registry query can see. Measured on the
31
+ forensic corpora (2026-08): ~30% of a recent biomedical batch had ScholeXplorer
32
+ links -- mostly tool citations, with the occasional Dryad/figshare deposit that
33
+ is exactly the point -- and Europe PMC found links on most of that batch. Both
34
+ found nothing on an older clinical-trial corpus, which is a fact worth a sidecar
35
+ entry rather than a silence (see linked_artifacts.py).
36
+ """
37
+
38
+ import os
39
+ import re
40
+ from typing import List, Optional
41
+
42
+ from .linked_artifacts import (
43
+ CLASS_OWNED,
44
+ CLASS_REGISTRY,
45
+ CLASS_RELATED,
46
+ CLASS_TOOL_CITATION,
47
+ record_link,
48
+ record_query,
49
+ )
50
+ from .supplement_index import (
51
+ ROLE_SUPPLEMENT,
52
+ SupplementFile,
53
+ classify_role,
54
+ jats_sets,
55
+ )
56
+
57
+ DATACITE_REVERSE = "https://api.datacite.org/dois"
58
+ CROSSREF_COMPONENTS = "https://api.crossref.org/works"
59
+ SCHOLEX_LINKS = "https://api.scholexplorer.openaire.eu/v3/Links"
60
+ EPMC_DATALINKS = "https://www.ebi.ac.uk/europepmc/webservices/rest/MED/{pmid}/datalinks"
61
+
62
+ #: Resource types that are somebody's deposited data rather than somebody's paper.
63
+ KEEP_RESOURCE_TYPES = {
64
+ "dataset", "software", "collection", "audiovisual", "image", "model",
65
+ "text", "other", "workflow", "physicalobject",
66
+ }
67
+
68
+ #: Relations that mean "a different work referring to this one". A citing paper is
69
+ #: not a supplement, however generously the type field is filled in.
70
+ DROP_RELATIONS = {"cites", "references", "isreferencedby", "iscitedby", "isreviewof"}
71
+
72
+ #: Resource types that are the article, a version of it, or another paper.
73
+ DROP_RESOURCE_TYPES = {"preprint", "journalarticle", "article", "book", "bookchapter",
74
+ "conferencepaper", "peerreview", "publication"}
75
+
76
+ #: Component DOI suffixes that are a figure or a table, not a supplementary file.
77
+ _FIGURE_COMPONENT_RE = re.compile(r"\.(g|t)\d+$", re.IGNORECASE)
78
+
79
+ _MAX_RELATED = 25
80
+ _MAX_COMPONENTS = 100
81
+
82
+
83
+ # -- E4: DataCite reverse related-identifier query --------------------------
84
+
85
+
86
+ def enumerate_datacite_related(ids, ctx) -> List[SupplementFile]:
87
+ """Data DOIs that name this article, resolved into files where we can.
88
+
89
+ A hit here is a whole deposit, not a file, so the useful output is a set of
90
+ repository DOIs handed to the repository enumerators. Everything inside a data
91
+ DOI reached this way is supplementary by construction -- nobody deposits the
92
+ paper as its own dataset -- so the article-vs-supplement heuristics are not
93
+ consulted for these.
94
+ """
95
+ if not ids.doi:
96
+ return []
97
+
98
+ response = ctx.http.get(
99
+ DATACITE_REVERSE,
100
+ params={
101
+ "query": f'relatedIdentifiers.relatedIdentifier:"{ids.doi}"',
102
+ "page[size]": _MAX_RELATED,
103
+ },
104
+ timeout=30,
105
+ polite=False,
106
+ )
107
+ if not response.ok:
108
+ return []
109
+ try:
110
+ records = (response.json() or {}).get("data") or []
111
+ except ValueError:
112
+ return []
113
+
114
+ files: List[SupplementFile] = []
115
+ for record in records:
116
+ related_doi = _accept_related(record, ids.doi, ctx)
117
+ if not related_doi:
118
+ continue
119
+ files.extend(_files_in_repository(related_doi, ids, ctx))
120
+ if len(files) >= _MAX_RELATED:
121
+ break
122
+ return files
123
+
124
+
125
+ def _accept_related(record, article_doi, ctx) -> Optional[str]:
126
+ """The related DOI, if this record is data about our article."""
127
+ if not isinstance(record, dict):
128
+ return None
129
+ attributes = record.get("attributes") or {}
130
+ related_doi = (attributes.get("doi") or "").strip().lower()
131
+ if not related_doi or related_doi == (article_doi or "").strip().lower():
132
+ return None
133
+
134
+ types = attributes.get("types") or {}
135
+ general = str(types.get("resourceTypeGeneral") or "").strip().lower()
136
+ specific = re.sub(r"[^a-z]", "", str(types.get("resourceType") or "").lower())
137
+ if general in DROP_RESOURCE_TYPES or specific in DROP_RESOURCE_TYPES:
138
+ return None
139
+ if general and general not in KEEP_RESOURCE_TYPES:
140
+ return None
141
+
142
+ # The relation is not the gate (see the module docstring), but a relation that
143
+ # positively means "another work citing this one" still rules a record out.
144
+ for relation in attributes.get("relatedIdentifiers") or []:
145
+ if not isinstance(relation, dict):
146
+ continue
147
+ identifier = str(relation.get("relatedIdentifier") or "").strip().lower()
148
+ if article_doi and article_doi.lower() in identifier:
149
+ kind = str(relation.get("relationType") or "").strip().lower()
150
+ if kind in DROP_RELATIONS and general not in ("dataset", "software"):
151
+ return None
152
+ ctx.log(f" DataCite: {related_doi} ({general or 'untyped'})")
153
+ return related_doi
154
+
155
+
156
+ def _files_in_repository(related_doi: str, ids, ctx,
157
+ via: str = "datacite_related") -> List[SupplementFile]:
158
+ """Route a data DOI to the enumerator for the repository that holds it.
159
+
160
+ Three providers fan out through here (DataCite reverse, ScholeXplorer,
161
+ EPMC datalinks) and their link pools overlap, so a per-record seen-set
162
+ stops the same deposit being listed -- and its files downloaded -- once per
163
+ provider that mentions it. First mention wins the provider tag.
164
+ """
165
+ from .identifiers import IdentifierSet
166
+ from .supplement_index import (
167
+ enumerate_dataverse,
168
+ enumerate_dryad,
169
+ enumerate_figshare,
170
+ enumerate_osf,
171
+ enumerate_zenodo,
172
+ )
173
+
174
+ routed = ctx.scratch.setdefault("_routed_deposit_dois", set())
175
+ key = related_doi.strip().lower()
176
+ if key in routed:
177
+ return []
178
+ routed.add(key)
179
+
180
+ proxy = IdentifierSet(doi=related_doi)
181
+ if related_doi.startswith("10.5281") or "zenodo" in related_doi:
182
+ enumerator, provider = enumerate_zenodo, "zenodo_files"
183
+ elif related_doi.startswith("10.6084") or "figshare" in related_doi:
184
+ enumerator, provider = enumerate_figshare, "figshare_files"
185
+ elif "osf.io" in related_doi or related_doi.startswith("10.17605"):
186
+ enumerator, provider = enumerate_osf, "osf_files"
187
+ elif related_doi.startswith("10.5061") or "dryad" in related_doi:
188
+ enumerator, provider = enumerate_dryad, "dryad_files"
189
+ elif related_doi.startswith("10.7910/dvn"):
190
+ enumerator, provider = enumerate_dataverse, "dataverse_files"
191
+ else:
192
+ # The long tail (institutional repos, subject databases) has no
193
+ # enumerator; the link still lands in the linked-artifacts sidecar.
194
+ ctx.log(f" {via}: no enumerator for {related_doi}")
195
+ return []
196
+
197
+ files = enumerator(proxy, ctx) or []
198
+ for entry in files:
199
+ entry.provider = f"{via}:{provider}"
200
+ entry.origin_doi = related_doi
201
+ # Reached through a data DOI, so it is data. Nothing here is the paper.
202
+ entry.role = ROLE_SUPPLEMENT
203
+ return files
204
+
205
+
206
+ # -- E9: Crossref component DOIs --------------------------------------------
207
+
208
+
209
+ def enumerate_crossref_components(ids, ctx) -> List[SupplementFile]:
210
+ """The article's own component DOIs, minus its figures and tables.
211
+
212
+ Largely redundant with the PMC routes for anything in PubMed Central, and it
213
+ earns its place on the publishers that register components but never reach
214
+ PMC.
215
+ """
216
+ if not ids.doi:
217
+ return []
218
+
219
+ response = ctx.http.get(
220
+ CROSSREF_COMPONENTS,
221
+ params={"filter": f"relation.object:{ids.doi}", "rows": _MAX_COMPONENTS},
222
+ timeout=30,
223
+ )
224
+ if not response.ok:
225
+ return []
226
+ try:
227
+ items = ((response.json() or {}).get("message") or {}).get("items") or []
228
+ except ValueError:
229
+ return []
230
+
231
+ supplements, figures = jats_sets(ctx)
232
+ files = []
233
+ for index, item in enumerate(items):
234
+ if not isinstance(item, dict) or item.get("type") != "component":
235
+ continue
236
+ component_doi = str(item.get("DOI") or "")
237
+ if _FIGURE_COMPONENT_RE.search(component_doi):
238
+ continue
239
+ url = (((item.get("resource") or {}).get("primary") or {}).get("URL"))
240
+ if not url:
241
+ continue
242
+ name = component_doi.rsplit("/", 1)[-1]
243
+ role = classify_role(name, jats_supplements=supplements, jats_figures=figures)
244
+ files.append(SupplementFile(
245
+ name=name,
246
+ url=url,
247
+ provider="crossref_components",
248
+ listing_index=index,
249
+ role=ROLE_SUPPLEMENT if role == "unknown" else role,
250
+ label=" ".join(item.get("title") or [])[:200],
251
+ origin_doi=ids.doi,
252
+ extra={"component_doi": component_doi},
253
+ ))
254
+ return files
255
+
256
+
257
+ # -- E16: OpenAIRE ScholeXplorer (Scholix links) ------------------------------
258
+
259
+ #: OpenAIRE mints a software record for every package a paper cites, titled
260
+ #: "<name> software on GitHub". Those are somebody's tool, not this paper's
261
+ #: code -- on the biomedical corpus they were the *majority* of software links
262
+ #: (xgboost, ggthemes, table1...) -- so they are recorded but never routed.
263
+ _TOOL_CITATION_RE = re.compile(r"(?i)\bsoftware on git(hub|lab)$")
264
+
265
+ #: Relation subtypes that positively mean "deposited as this paper's material".
266
+ _OWNED_RELATIONS = {"issupplementto", "issupplementedby", "haspart", "ispartof"}
267
+
268
+ #: DOI prefixes that are papers wearing a dataset costume. EPMC's text-mined
269
+ #: "Data Citations" happily include arXiv DOIs.
270
+ #: DOIs that name a PAPER, never a data deposit. Routing one downloads someone
271
+ #: else's article and files it under this record -- which is exactly what
272
+ #: happened: 10.31234/osf.io/8r9p7 (a PsyArXiv preprint) was downloaded as a
273
+ #: "dataset" for 10.1073/pnas.2316670121.
274
+ #:
275
+ #: The OSF preprint servers are the trap here, because their DOIs embed
276
+ #: "osf.io" and so look like OSF deposits to every downstream check.
277
+ _PAPER_DOI_PREFIXES = (
278
+ "10.48550", # arXiv
279
+ "10.31234", # PsyArXiv
280
+ "10.31235", # SocArXiv
281
+ "10.31219", # OSF Preprints
282
+ "10.31730", # AfricArXiv
283
+ "10.26434", # ChemRxiv
284
+ "10.1101", # bioRxiv / medRxiv
285
+ "10.20944", # Preprints.org
286
+ "10.21203", # Research Square
287
+ "10.2139", # SSRN
288
+ )
289
+
290
+
291
+ #: Dataverse's convention for a paper's own deposit. Harvard Dataverse fills
292
+ #: in NO relatedIdentifiers at all, so for it the title is the declaration.
293
+ _REPLICATION_TITLE_RE = re.compile(r"(?i)^replication\s+(data|code|materials?)\s+for:?\s*(.+)")
294
+
295
+
296
+ def _deposit_names_article(repo_doi: str, article_doi: str, ctx) -> bool:
297
+ """Whether the deposit's own metadata declares it belongs to this article.
298
+
299
+ The ownership gate for text-mined and citation-graph links. A "cites" or
300
+ "References" link cannot distinguish the paper's own deposit from a
301
+ dataset the paper merely cites -- verified both ways on real links: a
302
+ Dryad deposit Scholix tied to one article was IsCitedBy a *different*
303
+ article (a cited work, not the paper's data), while two Dataverse
304
+ deposits that genuinely were their papers' data carried no
305
+ relatedIdentifiers at all.
306
+
307
+ Two affirmative routes, both from the deposit's own DataCite record:
308
+ 1. a relatedIdentifier naming the article DOI (any non-citation
309
+ relation -- see the module docstring for why relationType is not
310
+ the filter), or
311
+ 2. a "Replication Data for: <title>" deposit title whose remainder
312
+ matches the article's Crossref title (the Dataverse convention).
313
+ Everything unconfirmed stays a sidecar link for the operator. Failing
314
+ closed here is the difference between a corpus of each paper's own data
315
+ and a corpus polluted with its bibliography.
316
+ """
317
+ if not repo_doi or not article_doi:
318
+ return False
319
+ cache = ctx.scratch.setdefault("_deposit_ownership", {})
320
+ key = repo_doi.strip().lower()
321
+ if key in cache:
322
+ return cache[key]
323
+
324
+ import urllib.parse
325
+ response = ctx.http.get(
326
+ "https://api.datacite.org/dois/" + urllib.parse.quote(key, safe=""),
327
+ timeout=30, polite=False,
328
+ )
329
+ verdict = False
330
+ if response.ok:
331
+ try:
332
+ attributes = ((response.json() or {}).get("data") or {}).get("attributes") or {}
333
+ except ValueError:
334
+ attributes = {}
335
+ needle = article_doi.strip().lower()
336
+ for relation in attributes.get("relatedIdentifiers") or []:
337
+ if not isinstance(relation, dict):
338
+ continue
339
+ kind = str(relation.get("relationType") or "").strip().lower()
340
+ identifier = str(relation.get("relatedIdentifier") or "").strip().lower()
341
+ if needle and needle in identifier and kind not in DROP_RELATIONS:
342
+ verdict = True
343
+ break
344
+ if not verdict:
345
+ verdict = _replication_title_matches(attributes, article_doi, ctx)
346
+ cache[key] = verdict
347
+ return verdict
348
+
349
+
350
+ #: How close a "Replication Data for: <title>" remainder must sit to the
351
+ #: article's own title. Exact containment is too strict by exactly one letter:
352
+ #: a Harvard Dataverse deposit titled "...ESCAS randomised pilot study" against
353
+ #: a journal title reading "...ESCAS Randomized Pilot Study" agrees for 108
354
+ #: characters and then diverges on British vs American spelling, which sent a
355
+ #: trial's own replication data to the sidecar unrouted.
356
+ #:
357
+ #: The margin is not delicate. That pair scores 0.99; an unrelated replication
358
+ #: title scores 0.12. Anything in between is not a near-miss worth arguing over.
359
+ _TITLE_MATCH_RATIO = 0.93
360
+
361
+
362
+ def _replication_title_matches(attributes: dict, article_doi: str, ctx) -> bool:
363
+ """Route 2: 'Replication Data for: <title>' against the article's title."""
364
+ import difflib
365
+
366
+ for entry in attributes.get("titles") or []:
367
+ title = str((entry or {}).get("title") or "")
368
+ matched = _REPLICATION_TITLE_RE.match(title.strip())
369
+ if not matched:
370
+ continue
371
+ claimed = _squash(matched.group(2))
372
+ actual = _squash(_article_title(article_doi, ctx))
373
+ if not claimed or not actual:
374
+ continue
375
+ if claimed in actual or actual in claimed:
376
+ return True
377
+ # Containment first (cheap, and the common case); near-identity only
378
+ # for the spelling-variant tail it would otherwise drop.
379
+ if difflib.SequenceMatcher(None, claimed, actual).ratio() >= _TITLE_MATCH_RATIO:
380
+ return True
381
+ return False
382
+
383
+
384
+ def _article_title(article_doi: str, ctx) -> str:
385
+ cache = ctx.scratch.setdefault("_article_titles", {})
386
+ key = article_doi.strip().lower()
387
+ if key in cache:
388
+ return cache[key]
389
+ title = ""
390
+ response = ctx.http.get(
391
+ "https://api.crossref.org/works/" + key, timeout=30,
392
+ )
393
+ if response.ok:
394
+ try:
395
+ titles = ((response.json() or {}).get("message") or {}).get("title") or []
396
+ title = str(titles[0]) if titles else ""
397
+ except ValueError:
398
+ pass
399
+ cache[key] = title
400
+ return title
401
+
402
+
403
+ def _squash(text: str) -> str:
404
+ """Lowercased alphanumerics only, so punctuation and casing differences
405
+ between a deposit title and a journal title cannot break the match."""
406
+ return re.sub(r"[^a-z0-9]", "", (text or "").lower())
407
+
408
+
409
+ def enumerate_scholix_related(ids, ctx) -> List[SupplementFile]:
410
+ """Datasets and software ScholeXplorer links to this article, as files
411
+ where a repository enumerator exists, and as sidecar links always.
412
+
413
+ Free, unauthenticated, first page only: a paper with more than 100
414
+ dataset links is not a paper whose links we can usefully triage.
415
+ """
416
+ if not ids.doi:
417
+ return []
418
+
419
+ files: List[SupplementFile] = []
420
+ for target_type in ("dataset", "software"):
421
+ response = ctx.http.get(
422
+ SCHOLEX_LINKS,
423
+ params={"sourcePid": ids.doi, "targetType": target_type},
424
+ timeout=30,
425
+ polite=False,
426
+ )
427
+ if not response.ok:
428
+ record_query(ctx, "scholexplorer", response.request_url,
429
+ "error", f"http {response.status}")
430
+ continue
431
+ try:
432
+ payload = response.json() or {}
433
+ except ValueError:
434
+ record_query(ctx, "scholexplorer", response.request_url,
435
+ "error", "not JSON")
436
+ continue
437
+
438
+ links = payload.get("result") or []
439
+ record_query(ctx, "scholexplorer", response.request_url, "ok",
440
+ f"{payload.get('totalLinks', len(links))} {target_type} link(s)")
441
+
442
+ for link in links:
443
+ if not isinstance(link, dict):
444
+ continue
445
+ target = link.get("target") or {}
446
+ target_doi = _scholix_doi(target)
447
+ title = str(target.get("Title") or "")
448
+ relation = _scholix_relation(link)
449
+ classified = _classify_scholix(target_doi, title, relation)
450
+
451
+ # Only the paper's own material is downloaded. "cites" cannot
452
+ # distinguish the paper's deposit from a dataset the paper merely
453
+ # cites, so a citation-grade link earns a download only when the
454
+ # deposit's DataCite record names this article.
455
+ if classified == CLASS_RELATED and _has_repo_enumerator(target_doi) \
456
+ and _deposit_names_article(target_doi, ids.doi, ctx):
457
+ classified = CLASS_OWNED
458
+
459
+ routed_files: List[SupplementFile] = []
460
+ if _should_route(classified, ctx, target_doi, ids) and target_doi \
461
+ and not target_doi.startswith(_PAPER_DOI_PREFIXES):
462
+ routed_files = _files_in_repository(
463
+ target_doi, ids, ctx, via="scholix_related")
464
+ files.extend(routed_files)
465
+
466
+ record_link(
467
+ ctx, service="scholexplorer", target_pid=target_doi,
468
+ target_type=target_type, relation=relation,
469
+ classified=classified, title=title,
470
+ publisher=_scholix_publisher(target),
471
+ provider_name=_scholix_provider(link),
472
+ harvest_date=str(link.get("HarvestDate") or ""),
473
+ routed=bool(routed_files),
474
+ )
475
+ return files
476
+
477
+
478
+ def _scholix_doi(target: dict) -> Optional[str]:
479
+ for identifier in target.get("Identifier") or []:
480
+ if isinstance(identifier, dict) and \
481
+ str(identifier.get("IDScheme") or "").lower() == "doi":
482
+ doi = str(identifier.get("ID") or "").strip().lower()
483
+ if doi:
484
+ return doi
485
+ return None
486
+
487
+
488
+ def _scholix_relation(link: dict) -> str:
489
+ relationship = link.get("RelationshipType") or {}
490
+ return str(relationship.get("SubType") or relationship.get("Name") or "").strip()
491
+
492
+
493
+ def _scholix_publisher(target: dict) -> str:
494
+ publishers = target.get("Publisher")
495
+ if isinstance(publishers, list) and publishers:
496
+ return str((publishers[0] or {}).get("name") or "")
497
+ if isinstance(publishers, dict):
498
+ return str(publishers.get("name") or "")
499
+ return ""
500
+
501
+
502
+ def _scholix_provider(link: dict) -> str:
503
+ providers = link.get("LinkProvider")
504
+ if isinstance(providers, list) and providers:
505
+ return str((providers[0] or {}).get("name") or "")
506
+ return ""
507
+
508
+
509
+ def _classify_scholix(target_doi: Optional[str], title: str, relation: str) -> str:
510
+ if _TOOL_CITATION_RE.search(title or ""):
511
+ return CLASS_TOOL_CITATION
512
+ if target_doi and target_doi.startswith(_PAPER_DOI_PREFIXES):
513
+ return CLASS_RELATED
514
+ if relation.strip().lower() in _OWNED_RELATIONS:
515
+ return CLASS_OWNED
516
+ return CLASS_RELATED
517
+
518
+
519
+ def enumerate_fulltext_scan(ids, ctx) -> List[SupplementFile]:
520
+ """E18: deposits the paper names in its own prose.
521
+
522
+ Only runs under --download-data-artifacts, and only reads files already on
523
+ disk, so it costs no network requests for discovery. Link indexes missed
524
+ every one of the 14 OSF deposits found this way in a real corpus.
525
+
526
+ Reads the record's PDF when there is no markup beside it. That was a
527
+ silent coverage hole rather than a limitation: measured 2026-08-22, 606 of
528
+ 1,491 corpus records are PDF-only, and every one of them was reported as
529
+ "scanned, nothing found" without ever being opened.
530
+
531
+ Precision, not recall, is the risk here -- half the repository URLs in a
532
+ paper belong to somebody else -- so fulltext_scan applies its rules first
533
+ and only accepted candidates are routed. Refused and uncertain ones are
534
+ recorded in the sidecar unrouted, where they can be audited.
535
+ """
536
+ if not getattr(ctx, "download_data_artifacts", False):
537
+ return []
538
+
539
+ from .fulltext_scan import ACCEPT, scan_record
540
+ from .linked_artifacts import record_link, record_query
541
+
542
+ stem = os.path.splitext(ctx.save_path)[0] if ctx.save_path else ""
543
+ if not stem:
544
+ return []
545
+ try:
546
+ candidates = scan_record(stem, log=ctx.log)
547
+ except Exception as error: # never break a run over a scan
548
+ ctx.log(f" fulltext_scan: {error}")
549
+ return []
550
+
551
+ record_query(ctx, service="fulltext_scan", url="(local full text)",
552
+ status="ok", detail=f"{len(candidates)} repository mention(s)")
553
+ if not candidates:
554
+ return []
555
+
556
+ if getattr(ctx, "llm_adjudicate_artifacts", False):
557
+ from .llm_adjudicate import adjudicate
558
+ adjudicate(candidates,
559
+ model=getattr(ctx, "llm_model", None) or "haiku",
560
+ log=ctx.log, enabled=True)
561
+
562
+ files: List[SupplementFile] = []
563
+ for candidate in candidates:
564
+ routed: List[SupplementFile] = []
565
+ if candidate.verdict == ACCEPT and candidate.repo != "github":
566
+ # GitHub has no DOI, so it cannot go through _files_in_repository;
567
+ # it is handled by its own enumerator below.
568
+ routed = _files_in_repository(
569
+ candidate.ident if "/" in candidate.ident else candidate.url,
570
+ ids, ctx, via="fulltext_scan")
571
+ files.extend(routed)
572
+ elif candidate.verdict == ACCEPT and candidate.repo == "github":
573
+ routed = _github_tarball(candidate.ident, ctx)
574
+ files.extend(routed)
575
+
576
+ record_link(
577
+ ctx, service="fulltext_scan", target_pid=candidate.url,
578
+ target_type="software" if candidate.repo == "github" else "dataset",
579
+ relation="NamedInFullText",
580
+ classified=CLASS_OWNED if candidate.verdict == ACCEPT else CLASS_RELATED,
581
+ title=candidate.reason[:300], publisher=candidate.repo,
582
+ provider_name=candidate.adjudicated_by or "rules",
583
+ routed=bool(routed),
584
+ )
585
+ return files
586
+
587
+
588
+ #: A branch tarball is what a replication check needs; git history is not.
589
+ _GITHUB_TARBALL = "https://codeload.github.com/{repo}/tar.gz/refs/heads/{branch}"
590
+ _GITHUB_API = "https://api.github.com/repos/{repo}"
591
+
592
+
593
+ def _github_tarball(repo: str, ctx) -> List[SupplementFile]:
594
+ """One archive for a GitHub repository, at its default branch."""
595
+ branch = "main"
596
+ try:
597
+ response = ctx.http.get(_GITHUB_API.format(repo=repo), timeout=20,
598
+ polite=False)
599
+ if response.ok:
600
+ branch = ((response.json() or {}).get("default_branch")) or "main"
601
+ except Exception:
602
+ pass # main is the right guess when the API is unavailable
603
+ return [SupplementFile(
604
+ name=f"{repo.replace('/', '-')}-{branch}.tar.gz",
605
+ url=_GITHUB_TARBALL.format(repo=repo, branch=branch),
606
+ provider="fulltext_scan:github",
607
+ # NOT is_archive. That flag means "expand rather than keep", and the
608
+ # expander is `_expand_zip` -- zipfile, on a gzip stream. Every GitHub
609
+ # repository this provider has ever found was refused
610
+ # `not-an-archive` because of it: zero `fulltext_scan:github` files
611
+ # exist across 1,934 corpus manifests, and the tarball URL itself is
612
+ # fine (verified: HTTP 200, 6.5 MB of valid gzip).
613
+ #
614
+ # Keeping it whole is also the right policy on its own terms. A
615
+ # repository is a replication package, and this module already refuses
616
+ # to explode those by default -- unpacking one produced 199 files from
617
+ # a vendored Stata library in a single record.
618
+ role=ROLE_SUPPLEMENT,
619
+ extra={"github_repo": repo, "branch": branch},
620
+ )]
621
+
622
+
623
+ def _should_route(classified: str, ctx, target_doi=None, ids=None) -> bool:
624
+ """Whether a link of this class is downloaded, not merely recorded.
625
+
626
+ Default: only the paper's own material. That is the conservative reading
627
+ and the right one when the caller asked for supplementary files.
628
+
629
+ Under --download-data-artifacts the gate widens to `related` as well,
630
+ because on real corpora that is where the research data actually is: in a
631
+ 44-sidecar sample every `owned` link was a BioStudies mirror of
632
+ supplementary files already fetched, while the Zenodo record (8 files,
633
+ 14 MB CSV) and the Dataverse replication packages were all `related`.
634
+
635
+ `registry` never routes -- ClinicalTrials.gov and friends have nothing to
636
+ download. `tool_citation` never routes either: those are libraries the
637
+ authors used, and the Scholix ones carry no target_pid at all, so there is
638
+ nothing to fetch even if we wanted it. The paper's OWN code is found by
639
+ reading the paper (fulltext_scan), not by trusting a citation index.
640
+ """
641
+ if classified == CLASS_OWNED:
642
+ return True
643
+ if not getattr(ctx, "download_data_artifacts", False):
644
+ return False
645
+ if classified != CLASS_RELATED:
646
+ return False
647
+
648
+ # `related` means an index asserted a connection, not that the deposit is
649
+ # this paper's. Downloading on that assertion alone fetched "Tying Odysseus
650
+ # to the Mast" (Ashraf 2006) and filed it under a 2025 savings-reminder
651
+ # megastudy. So the deposit has to name this article in its own DataCite
652
+ # record before we take it -- the same oracle already used to upgrade
653
+ # `related` to `owned`, simply consulted for the routing decision too.
654
+ if getattr(ctx, "download_related_unverified", False):
655
+ return True
656
+ if not target_doi or not getattr(ids, "doi", None):
657
+ return False
658
+ # Requiring the deposit to NAME this article is too strict for real
659
+ # metadata: Zenodo's MegaOath dataset declares IsSupplementTo an OSF
660
+ # project rather than the paper, and Harvard Dataverse fills in no
661
+ # relatedIdentifiers at all. Both are genuinely the article's data, and
662
+ # both would be refused.
663
+ #
664
+ # What actually separates the false positive is the opposite signal: the
665
+ # Odysseus deposit declares IsSupplementTo 10.1162/qjec.2006.121.2.635 --
666
+ # it says out loud that it belongs to a DIFFERENT paper. So accept unless
667
+ # the deposit claims another article, rather than demanding it claim ours.
668
+ if _deposit_names_article(target_doi, ids.doi, ctx):
669
+ return True
670
+ return not _deposit_claims_another_article(target_doi, ids.doi, ctx)
671
+
672
+
673
+ #: Relations by which a deposit declares which article it belongs to. If one of
674
+ #: these points at a DIFFERENT paper, the deposit is that paper's, not ours.
675
+ _OWNERSHIP_RELATIONS = {"issupplementto", "ispartof", "isdescribedby",
676
+ "iscitedby", "isreferencedby"}
677
+
678
+
679
+ def _deposit_claims_another_article(target_doi: str, article_doi: str, ctx) -> bool:
680
+ """True when the deposit says it belongs to some other paper.
681
+
682
+ The precise signal that separates a wrongly-routed deposit from a correctly
683
+ routed one on real data. "Tying Odysseus to the Mast" declares
684
+ IsSupplementTo 10.1162/qjec.2006.121.2.635; it was being filed under a 2025
685
+ savings-reminder megastudy. Deposits with no relatedIdentifiers at all
686
+ (Harvard Dataverse fills in none) are NOT caught by this, which is correct
687
+ -- absence of a claim is not a claim to the contrary.
688
+
689
+ Fails OPEN on any lookup error: a DataCite outage should not silently start
690
+ discarding real data.
691
+ """
692
+ cache = ctx.scratch.setdefault("_deposit_other_article", {})
693
+ key = (target_doi or "").strip().lower()
694
+ if key in cache:
695
+ return cache[key]
696
+
697
+ verdict = False
698
+ try:
699
+ response = ctx.http.get(f"https://api.datacite.org/dois/{key}",
700
+ timeout=25, polite=False)
701
+ if response.ok:
702
+ attributes = ((response.json() or {}).get("data") or {}).get("attributes") or {}
703
+ mine = (article_doi or "").strip().lower()
704
+ for relation in attributes.get("relatedIdentifiers") or []:
705
+ if not isinstance(relation, dict):
706
+ continue
707
+ kind = str(relation.get("relationType") or "").strip().lower()
708
+ if kind not in _OWNERSHIP_RELATIONS:
709
+ continue
710
+ other = str(relation.get("relatedIdentifier") or "").strip().lower()
711
+ # Only a DOI naming a different *article* counts. A deposit
712
+ # pointing at an OSF project or its own earlier version is not
713
+ # claiming another paper.
714
+ if not other.startswith("10.") or mine in other:
715
+ continue
716
+ if other.startswith(_DEPOSIT_DOI_PREFIXES):
717
+ continue
718
+ verdict = True
719
+ ctx.log(f" {key}: declares {kind} {other} -- not this article")
720
+ break
721
+ except Exception:
722
+ verdict = False
723
+ cache[key] = verdict
724
+ return verdict
725
+
726
+
727
+ #: Prefixes that are DEPOSITS, not articles. A deposit relating to one of these
728
+ #: is relating to data or a project page, which says nothing about which paper
729
+ #: owns it.
730
+ _DEPOSIT_DOI_PREFIXES = ("10.5281", "10.6084", "10.17605", "10.5061", "10.7910",
731
+ "10.3886", "10.25384", "10.24433")
732
+
733
+
734
+ def _has_repo_enumerator(target_doi: Optional[str]) -> bool:
735
+ """Whether a DOI would route somewhere in _files_in_repository.
736
+
737
+ Gates the ownership lookup: a DOI that cannot be downloaded anyway is not
738
+ worth a DataCite request to classify more finely.
739
+ """
740
+ if not target_doi:
741
+ return False
742
+ doi = target_doi.strip().lower()
743
+ return (doi.startswith(("10.5281", "10.6084", "10.17605", "10.5061", "10.7910/dvn"))
744
+ or "zenodo" in doi or "figshare" in doi or "osf.io" in doi or "dryad" in doi)
745
+
746
+
747
+ # -- E17: Europe PMC datalinks ------------------------------------------------
748
+
749
+
750
+ def enumerate_epmc_datalinks(ids, ctx) -> List[SupplementFile]:
751
+ """Europe PMC's data links for this article: text-mined accessions,
752
+ data citations, and BioStudies deposits.
753
+
754
+ Registry accessions (ClinicalTrials.gov and friends) are sidecar-only by
755
+ nature. BioStudies links are sidecar-only by choice: the study page mirrors
756
+ the EPMC supplementary bundle that europepmc_supplements already fetches,
757
+ so downloading it again would double every file. Data-citation DOIs route
758
+ to the repository enumerators like every other deposit.
759
+ """
760
+ if not ids.pmid:
761
+ return []
762
+
763
+ response = ctx.http.get(
764
+ EPMC_DATALINKS.format(pmid=ids.pmid),
765
+ params={"format": "json"},
766
+ timeout=30,
767
+ )
768
+ if not response.ok:
769
+ record_query(ctx, "epmc_datalinks", response.request_url,
770
+ "error", f"http {response.status}")
771
+ return []
772
+ try:
773
+ payload = response.json() or {}
774
+ except ValueError:
775
+ record_query(ctx, "epmc_datalinks", response.request_url,
776
+ "error", "not JSON")
777
+ return []
778
+
779
+ categories = ((payload.get("dataLinkList") or {}).get("Category")) or []
780
+ record_query(ctx, "epmc_datalinks", response.request_url, "ok",
781
+ f"{payload.get('hitCount', 0)} link(s), "
782
+ f"{len(categories)} categorie(s)")
783
+
784
+ files: List[SupplementFile] = []
785
+ for category in categories:
786
+ if not isinstance(category, dict):
787
+ continue
788
+ category_name = str(category.get("Name") or "")
789
+ # Altmetric is attention, not data.
790
+ if category_name.lower().startswith("altmetric"):
791
+ continue
792
+ for section in category.get("Section") or []:
793
+ for link in ((section or {}).get("Linklist") or {}).get("Link") or []:
794
+ if not isinstance(link, dict):
795
+ continue
796
+ files.extend(_take_epmc_link(link, category_name, ids, ctx))
797
+ return files
798
+
799
+
800
+ def _take_epmc_link(link: dict, category_name: str, ids, ctx) -> List[SupplementFile]:
801
+ target = link.get("Target") or {}
802
+ identifier = target.get("Identifier") or {}
803
+ scheme = str(identifier.get("IDScheme") or "").strip()
804
+ pid = str(identifier.get("ID") or "").strip()
805
+ title = str(target.get("Title") or "")
806
+ relation = str(((link.get("RelationshipType") or {}).get("Name")) or "")
807
+
808
+ is_doi = scheme.lower() == "doi"
809
+ target_doi = pid.lower() if is_doi else None
810
+ if scheme.lower() == "clinicaltrials.gov" or "clinical trials" in category_name.lower():
811
+ classified = CLASS_REGISTRY
812
+ elif "biostudies" in category_name.lower():
813
+ # The article's own deposit, but see the enumerator docstring.
814
+ classified = CLASS_OWNED
815
+ else:
816
+ # EPMC "Data Citations" are text-mined from the whole paper -- the
817
+ # data-availability statement AND the reference list, with nothing to
818
+ # tell them apart. On a real corpus the majority were cited journal
819
+ # articles wearing a dataset costume, so every one is a citation until
820
+ # the deposit's own DataCite record names this article.
821
+ classified = CLASS_RELATED
822
+ if _has_repo_enumerator(target_doi) and \
823
+ _deposit_names_article(target_doi, ids.doi, ctx):
824
+ classified = CLASS_OWNED
825
+
826
+ routed_files: List[SupplementFile] = []
827
+ if target_doi and _should_route(classified, ctx, target_doi, ids) and \
828
+ not target_doi.startswith(_PAPER_DOI_PREFIXES) and \
829
+ "biostudies" not in category_name.lower():
830
+ routed_files = _files_in_repository(target_doi, ids, ctx, via="epmc_datalinks")
831
+
832
+ record_link(
833
+ ctx, service="epmc_datalinks",
834
+ target_pid=pid or None,
835
+ target_type=str(((target.get("Type") or {}).get("Name")) or category_name),
836
+ relation=relation, classified=classified, title=title,
837
+ publisher=str(((target.get("Publisher") or {}).get("Name")) or ""),
838
+ provider_name=str(((link.get("LinkProvider") or {}).get("Name")) or ""),
839
+ harvest_date=str(link.get("PublicationDate") or ""),
840
+ routed=bool(routed_files),
841
+ )
842
+ return routed_files