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,481 @@
1
+ """Phase 1: build the fullest identifier set we can, before retrieving anything.
2
+
3
+ Ordering principle: run free and batchable calls before quota-limited ones, and
4
+ gate every expensive call on a cheap signal that predicts it will succeed.
5
+
6
+ One deliberate deviation from the specified stage order, because measuring it
7
+ changed the answer. The spec puts a per-record Crossref call first in stage 1.
8
+ On a real clinical corpus (1023 DOIs) the batched ID Converter alone resolves a
9
+ PMCID for 48.7% of records, and 82.5% of those go on to yield a validated JATS
10
+ artifact -- so roughly 40% of the batch never needs Crossref at all. Crossref's
11
+ gating role (which publisher owns this DOI) is derivable from the DOI prefix
12
+ string with no network call whatsoever. So:
13
+
14
+ prime() -- batched ID Converter only. 6 HTTP calls for 1023 DOIs.
15
+ resolve() -- returns as soon as a PMCID is in hand; retrieval short-circuits.
16
+ crossref() and resolve_conditional() -- lazy, called only once the T1 routes
17
+ that need no further resolution have failed.
18
+
19
+ This preserves the principle exactly while skipping ~40% of the Crossref calls
20
+ the literal step order would make.
21
+ """
22
+
23
+ import re
24
+ from typing import Iterable, List, Optional, Sequence
25
+
26
+ from .identifiers import IdentifierSet
27
+
28
+ #: The ID Converter moved. www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/ now 301s
29
+ #: here, so the legacy URL costs an extra round-trip on every single call.
30
+ IDCONV_URL = "https://pmc.ncbi.nlm.nih.gov/tools/idconv/api/v1/articles/"
31
+
32
+ #: Hard ceiling, verified empirically: 200 ids returns status "ok" with 200
33
+ #: records; 201 returns status "error" with zero records, no partial result and
34
+ #: no warning. Overshooting does not truncate -- it loses the whole chunk.
35
+ IDCONV_MAX_IDS = 200
36
+
37
+ #: DOI registrant prefixes that resolve through DataCite rather than Crossref.
38
+ #: Stage 3 only calls DataCite for these; on a Crossref DOI it is a guaranteed
39
+ #: miss, and a miss still costs a round-trip.
40
+ DATACITE_REPOSITORY_PREFIXES = {
41
+ "10.5281": "zenodo",
42
+ "10.6084": "figshare",
43
+ "10.17605": "osf",
44
+ "10.31234": "osf", # PsyArXiv
45
+ "10.31235": "osf", # SocArXiv
46
+ "10.23668": "psycharchives",
47
+ "10.25384": "figshare", # SAGE-hosted figshare
48
+ "10.48550": "arxiv",
49
+ }
50
+
51
+ _ELSEVIER_PREFIX = "10.1016"
52
+ _PREPRINT_PREFIX = "10.1101" # bioRxiv and medRxiv share it; the API disambiguates
53
+
54
+ _ARXIV_FROM_DOI_RE = re.compile(r"10\.48550/arxiv\.(.+)$", re.IGNORECASE)
55
+ _ARXIV_ID_RE = re.compile(r"(\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)", re.IGNORECASE)
56
+ _PII_RE = re.compile(r"PII:([A-Z0-9]+)", re.IGNORECASE)
57
+
58
+ #: An arXiv id sitting in a URL we already have.
59
+ #:
60
+ #: This exists because the arXiv routes -- T2 LaTeXML HTML and T3 LaTeX source --
61
+ #: were effectively dead. They require `arxiv_id`, and the only two places that ever
62
+ #: produced one were a 10.48550/arXiv.* DOI and Semantic Scholar's externalIds. An
63
+ #: arXiv paper published under a publisher DOI therefore never got an id, so the two
64
+ #: highest-fidelity routes available for physics/CS/stats silently never ran.
65
+ #:
66
+ #: The arXiv API cannot fix it: the legacy query API has no `doi:` field prefix
67
+ #: (verified -- such a search returns zero entries). But the responses we already
68
+ #: fetch do carry the id. Unpaywall, for instance:
69
+ #: 10.1103/PhysRevLett.116.061102 -> oa_locations[] has arxiv.org/pdf/1602.03837
70
+ #: so harvesting costs no additional request.
71
+ #:
72
+ #: Both id schemes: modern (2101.00001) and pre-2007 (hep-th/9901001).
73
+ _ARXIV_URL_RE = re.compile(
74
+ r"arxiv\.org/(?:abs|pdf|html)/"
75
+ r"(\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?/\d{7})"
76
+ r"(?:v\d+)?",
77
+ re.IGNORECASE,
78
+ )
79
+
80
+
81
+ def arxiv_id_from_doi(doi: Optional[str]) -> Optional[str]:
82
+ """The arXiv id inside a 10.48550/arXiv.* DOI. Free -- no network call.
83
+
84
+ Public because `pdf_identity` needs it for a caller that holds a DOI string
85
+ and no IdentifierSet: an arXiv PDF is stamped with its arXiv id and never
86
+ with its DataCite DOI, so this is the only route from what that caller has
87
+ to something actually printed on the page.
88
+ """
89
+ if not doi:
90
+ return None
91
+ matched = _ARXIV_FROM_DOI_RE.search(str(doi).strip())
92
+ return matched.group(1) if matched else None
93
+
94
+
95
+ def arxiv_id_in(*values) -> Optional[str]:
96
+ """The first arXiv id found in any of these strings, version suffix stripped.
97
+
98
+ Accepts anything stringable -- URLs, whole JSON blobs, None -- so callers can
99
+ hand it whatever they happen to have without pre-filtering.
100
+ """
101
+ for value in values:
102
+ if not value:
103
+ continue
104
+ match = _ARXIV_URL_RE.search(str(value))
105
+ if match:
106
+ return match.group(1)
107
+ return None
108
+
109
+
110
+ def doi_prefix(doi: Optional[str]) -> Optional[str]:
111
+ """The registrant prefix, e.g. '10.1016'. Free -- no network call."""
112
+ if not doi or not doi.startswith("10."):
113
+ return None
114
+ return doi.split("/", 1)[0]
115
+
116
+
117
+ class BatchResolver:
118
+ """Owns the resolution cache and the batched stage-1 lookups.
119
+
120
+ One instance per batch. In single-DOI mode it degenerates to chunks of one,
121
+ which is correct but wastes the 200x leverage -- hence batch_fetch_pdfs
122
+ primes it with the whole DOI list up front.
123
+ """
124
+
125
+ def __init__(self, http, cache, ladder=None, verbose=False):
126
+ self.http = http
127
+ self.cache = cache
128
+ self.ladder = ladder
129
+ self.verbose = verbose
130
+ self.chunk_size = (
131
+ ladder.threshold("idconv_chunk_size", IDCONV_MAX_IDS) if ladder else IDCONV_MAX_IDS
132
+ )
133
+ if self.chunk_size > IDCONV_MAX_IDS:
134
+ # Configuring this above the ceiling would silently drop whole chunks.
135
+ self.chunk_size = IDCONV_MAX_IDS
136
+ self._primed = set()
137
+
138
+ # -- stage 1: batched, free, unauthenticated ----------------------------
139
+
140
+ def prime(self, identifiers: Sequence[str]) -> int:
141
+ """Resolve DOI/PMID -> PMCID for a whole batch in chunks of 200.
142
+
143
+ Returns the number of HTTP calls made. Records already in the cache are
144
+ skipped, so a resumed run costs nothing here.
145
+ """
146
+ dois, pmids = [], []
147
+ for raw in identifiers:
148
+ value = str(raw or "").strip()
149
+ if not value or self.cache.get(value):
150
+ continue
151
+ if value.lower().startswith("10."):
152
+ dois.append(value)
153
+ elif value.isdigit():
154
+ pmids.append(value)
155
+
156
+ calls = 0
157
+ for idtype, values in (("doi", dois), ("pmid", pmids)):
158
+ for chunk in _chunks(_dedupe(values), self.chunk_size):
159
+ calls += 1
160
+ self._idconv_chunk(chunk, idtype)
161
+ if self.verbose and calls:
162
+ print(f" resolution: {calls} ID Converter call(s) for {len(dois) + len(pmids)} identifier(s)")
163
+ self.cache.flush()
164
+ return calls
165
+
166
+ def _idconv_chunk(self, chunk: List[str], idtype: str) -> None:
167
+ response = self.http.get(
168
+ IDCONV_URL,
169
+ params={"ids": ",".join(chunk), "format": "json", "idtype": idtype},
170
+ )
171
+ if not response.ok:
172
+ if self.verbose:
173
+ print(f" ID Converter chunk failed: HTTP {response.status}")
174
+ return
175
+ for record in _safe_records(response):
176
+ requested = record.get("requested-id") or record.get(idtype)
177
+ if not requested:
178
+ continue
179
+ values = {}
180
+ if record.get("pmcid"):
181
+ values["pmcid"] = record["pmcid"]
182
+ if record.get("pmid"):
183
+ values["pmid"] = str(record["pmid"])
184
+ if record.get("doi"):
185
+ values["doi"] = record["doi"]
186
+ # Cache misses too: "not in PMC" is a fact worth not re-asking for.
187
+ values["_idconv"] = record.get("errmsg") or "ok"
188
+ self.cache.put(requested, values)
189
+
190
+ def resolve(self, raw_identifier: str, doi: Optional[str] = None,
191
+ pmid: Optional[str] = None) -> IdentifierSet:
192
+ """Stage 0 + stage 1 for one record. Cheap: usually a dict lookup."""
193
+ ids = IdentifierSet(doi=doi, pmid=pmid)
194
+ ids.publisher_prefix = doi_prefix(ids.doi)
195
+
196
+ cached = self.cache.get(raw_identifier) or (self.cache.get(doi) if doi else None)
197
+ if cached:
198
+ ids.learn(
199
+ "cache",
200
+ note="stage 0 hit",
201
+ doi=cached.get("doi") or ids.doi,
202
+ pmid=cached.get("pmid"),
203
+ pmcid=cached.get("pmcid"),
204
+ )
205
+ else:
206
+ self._idconv_chunk(
207
+ [raw_identifier], "doi" if str(raw_identifier).startswith("10.") else "pmid"
208
+ )
209
+ fresh = self.cache.get(raw_identifier) or {}
210
+ ids.learn(
211
+ "idconv",
212
+ url=IDCONV_URL,
213
+ note="single-record lookup (not primed)",
214
+ doi=fresh.get("doi") or ids.doi,
215
+ pmid=fresh.get("pmid"),
216
+ pmcid=fresh.get("pmcid"),
217
+ )
218
+
219
+ if not ids.publisher_prefix:
220
+ ids.publisher_prefix = doi_prefix(ids.doi)
221
+ self._learn_free_facts(ids)
222
+ return ids
223
+
224
+ def _learn_free_facts(self, ids: IdentifierSet) -> None:
225
+ """Everything derivable from the DOI string itself. No network."""
226
+ doi = (ids.doi or "").lower()
227
+ if not doi:
228
+ return
229
+ from_doi = arxiv_id_from_doi(doi)
230
+ if from_doi:
231
+ ids.learn("doi-shape", note="arXiv DOI", arxiv_id=from_doi)
232
+ if doi.startswith(_PREPRINT_PREFIX + "/"):
233
+ ids.preprint_server = "unknown" # biorxiv vs medrxiv; the API decides
234
+
235
+ # -- lazy: only once the free T1 routes have failed ---------------------
236
+
237
+ def crossref(self, ids: IdentifierSet) -> dict:
238
+ """Fetch and memoize Crossref metadata for this record.
239
+
240
+ Cheapest informative single call, but deferred: a record that
241
+ short-circuits on a PMCID never needs it.
242
+ """
243
+ if "crossref" in ids.memo:
244
+ return ids.memo["crossref"]
245
+ if not ids.doi:
246
+ return {}
247
+ url = "https://api.crossref.org/works/{}".format(ids.doi)
248
+ response = self.http.get(url, timeout=15)
249
+ message = {}
250
+ if response.ok:
251
+ try:
252
+ message = (response.json() or {}).get("message") or {}
253
+ except ValueError:
254
+ message = {}
255
+ links = message.get("link") or []
256
+ pii = None
257
+ for link in links:
258
+ m = _PII_RE.search(link.get("URL") or "")
259
+ if m:
260
+ pii = m.group(1)
261
+ break
262
+ ids.crossref_links = links
263
+ licenses = message.get("license") or []
264
+ ids.learn(
265
+ "crossref",
266
+ url=url,
267
+ http_status=response.status,
268
+ note="publisher metadata, TDM links",
269
+ elsevier_pii=pii,
270
+ license=(licenses[0].get("URL") if licenses else None),
271
+ # Crossref's link[] and relation[] sometimes point at the arXiv
272
+ # preprint of the same work.
273
+ arxiv_id=arxiv_id_in(response.text),
274
+ )
275
+ ids.memo["crossref"] = message
276
+ return message
277
+
278
+ def unpaywall(self, ids: IdentifierSet) -> dict:
279
+ """Fetch and memoize the Unpaywall record.
280
+
281
+ Memoized on the IdentifierSet because two callers want it: this stage, to
282
+ harvest an arXiv id out of oa_locations[], and the T2 source, to walk those
283
+ same locations looking for HTML full text. Without the memo the record would
284
+ be fetched twice for no reason.
285
+ """
286
+ cached = ids.memo.get("unpaywall")
287
+ if cached is not None:
288
+ return cached
289
+ if not ids.doi:
290
+ return {}
291
+ url = "https://api.unpaywall.org/v2/{}".format(ids.doi)
292
+ response = self.http.get(url, timeout=25)
293
+ payload = response.json_or({}) if response.ok else {}
294
+ ids.memo["unpaywall"] = payload
295
+ best = payload.get("best_oa_location") or {}
296
+ ids.learn(
297
+ "unpaywall",
298
+ url=url,
299
+ http_status=response.status,
300
+ note="oa_locations scanned for an arXiv id",
301
+ license=best.get("license"),
302
+ # The whole payload, not just best_oa_location: the arXiv copy is
303
+ # usually a repository location and rarely the "best" one.
304
+ arxiv_id=arxiv_id_in(response.text),
305
+ )
306
+ return payload
307
+
308
+ def resolve_conditional(self, ids: IdentifierSet) -> IdentifierSet:
309
+ """Stage 3. Every call gated on a cheap signal; none unconditional."""
310
+ prefix = ids.publisher_prefix or doi_prefix(ids.doi)
311
+
312
+ # DataCite: only for prefixes that are actually registered there.
313
+ if prefix in DATACITE_REPOSITORY_PREFIXES and not ids.repository_id:
314
+ self._datacite(ids)
315
+
316
+ # OpenAlex, then Semantic Scholar: only while the PMCID is still missing,
317
+ # since a PMID from either feeds a second ID Converter pass.
318
+ if not ids.pmcid:
319
+ if not ids.pmid:
320
+ self._openalex(ids)
321
+ if not ids.pmid:
322
+ self._semantic_scholar(ids)
323
+ if ids.pmid:
324
+ self._second_idconv_pass(ids)
325
+
326
+ # Scopus exists here for exactly one purpose: yielding an Elsevier PII.
327
+ # Crossref's link[] already carries it inline for every Elsevier DOI
328
+ # tested, so this only runs in the rare case where link[] was absent.
329
+ if prefix == _ELSEVIER_PREFIX and not ids.elsevier_pii:
330
+ self.crossref(ids)
331
+ if not ids.elsevier_pii:
332
+ self._scopus(ids)
333
+
334
+ # An arXiv id unlocks two of the highest-fidelity routes there are: LaTeXML
335
+ # HTML, whose equations carry their original LaTeX in MathML alttext, and
336
+ # the LaTeX source itself. Crossref may already have supplied one above;
337
+ # Unpaywall is the reliable source and its record is memoized, so the T2
338
+ # source reuses this response rather than fetching it again.
339
+ if not ids.arxiv_id:
340
+ self.crossref(ids)
341
+ if not ids.arxiv_id:
342
+ self.unpaywall(ids)
343
+
344
+ return ids
345
+
346
+ def _second_idconv_pass(self, ids: IdentifierSet) -> None:
347
+ """A PMID found at stage 3 is a second chance at the PMCID."""
348
+ self._idconv_chunk([ids.pmid], "pmid")
349
+ fresh = self.cache.get(ids.pmid) or {}
350
+ ids.learn(
351
+ "idconv#2",
352
+ url=IDCONV_URL,
353
+ note="retry with PMID recovered at stage 3",
354
+ pmcid=fresh.get("pmcid"),
355
+ )
356
+
357
+ def _openalex(self, ids: IdentifierSet) -> None:
358
+ url = "https://api.openalex.org/works/https://doi.org/{}".format(ids.doi)
359
+ response = self.http.get(url, timeout=15)
360
+ if not response.ok:
361
+ ids.chain.record("openalex", url=url, http_status=response.status)
362
+ return
363
+ try:
364
+ data = response.json() or {}
365
+ except ValueError:
366
+ ids.chain.record("openalex", url=url, http_status=response.status, note="unparseable")
367
+ return
368
+ external = data.get("ids") or {}
369
+ pmid = _tail(external.get("pmid"))
370
+ pmcid = _tail(external.get("pmcid"))
371
+ # OpenAlex lists every location it knows, and for an arXiv-hosted paper one
372
+ # of them is an arxiv.org URL. Scanning the whole payload rather than
373
+ # walking locations[] keeps this robust to their schema moving around.
374
+ ids.learn(
375
+ "openalex",
376
+ url=url,
377
+ http_status=response.status,
378
+ openalex_id=_tail(external.get("openalex")),
379
+ pmid=pmid,
380
+ pmcid=("PMC" + pmcid.lstrip("PMC")) if pmcid else None,
381
+ arxiv_id=arxiv_id_in(response.text),
382
+ )
383
+
384
+ def _semantic_scholar(self, ids: IdentifierSet) -> None:
385
+ url = "https://api.semanticscholar.org/graph/v1/paper/DOI:{}".format(ids.doi)
386
+ response = self.http.get(url, params={"fields": "externalIds"}, timeout=15)
387
+ if not response.ok:
388
+ ids.chain.record("semantic_scholar", url=url, http_status=response.status)
389
+ return
390
+ try:
391
+ external = (response.json() or {}).get("externalIds") or {}
392
+ except ValueError:
393
+ external = {}
394
+ pmcid = external.get("PubMedCentral")
395
+ ids.learn(
396
+ "semantic_scholar",
397
+ url=url,
398
+ http_status=response.status,
399
+ pmid=str(external["PubMed"]) if external.get("PubMed") else None,
400
+ pmcid=("PMC" + str(pmcid).lstrip("PMC")) if pmcid else None,
401
+ arxiv_id=external.get("ArXiv"),
402
+ )
403
+
404
+ def _datacite(self, ids: IdentifierSet) -> None:
405
+ url = "https://api.datacite.org/dois/{}".format(ids.doi)
406
+ response = self.http.get(url, timeout=15)
407
+ if not response.ok:
408
+ ids.chain.record("datacite", url=url, http_status=response.status)
409
+ return
410
+ try:
411
+ attributes = ((response.json() or {}).get("data") or {}).get("attributes") or {}
412
+ except ValueError:
413
+ attributes = {}
414
+ repository_id = None
415
+ for candidate in (attributes.get("url") or "", ids.doi or ""):
416
+ m = re.search(r"(?:zenodo\.|figshare\.|records?/)(\d+)", candidate, re.IGNORECASE)
417
+ if m:
418
+ repository_id = m.group(1)
419
+ break
420
+ ids.learn(
421
+ "datacite",
422
+ url=url,
423
+ http_status=response.status,
424
+ note="bridge to repository record id",
425
+ repository_id=repository_id,
426
+ license=attributes.get("rightsList", [{}])[0].get("rightsUri")
427
+ if attributes.get("rightsList") else None,
428
+ )
429
+
430
+ def _scopus(self, ids: IdentifierSet) -> None:
431
+ import os
432
+
433
+ api_key = os.getenv("SCOPUS_API_KEY")
434
+ if not api_key:
435
+ ids.chain.record("scopus", note="skipped: SCOPUS_API_KEY unset")
436
+ return
437
+ url = "https://api.elsevier.com/content/abstract/doi/{}".format(ids.doi)
438
+ response = self.http.get(
439
+ url,
440
+ params={"apiKey": api_key, "httpAccept": "application/json"},
441
+ timeout=20,
442
+ )
443
+ pii = None
444
+ if response.ok:
445
+ m = re.search(r'"pii"\s*:\s*"([A-Z0-9]+)"', response.text, re.IGNORECASE)
446
+ if m:
447
+ pii = m.group(1)
448
+ ids.learn(
449
+ "scopus",
450
+ url=url,
451
+ http_status=response.status,
452
+ note="fallback only: Crossref link[] had no PII",
453
+ elsevier_pii=pii,
454
+ )
455
+
456
+
457
+ # -- helpers ----------------------------------------------------------------
458
+
459
+
460
+ def _chunks(values: Sequence[str], size: int) -> Iterable[List[str]]:
461
+ for i in range(0, len(values), size):
462
+ yield list(values[i:i + size])
463
+
464
+
465
+ def _dedupe(values: Iterable[str]) -> List[str]:
466
+ return list(dict.fromkeys(values))
467
+
468
+
469
+ def _tail(url: Optional[str]) -> Optional[str]:
470
+ if not url:
471
+ return None
472
+ return str(url).rstrip("/").rsplit("/", 1)[-1]
473
+
474
+
475
+ def _safe_records(response) -> List[dict]:
476
+ try:
477
+ payload = response.json()
478
+ except Exception:
479
+ return []
480
+ records = payload.get("records")
481
+ return records if isinstance(records, list) else []
@@ -0,0 +1,15 @@
1
+ """Per-tier retrieval sources.
2
+
3
+ Every source is a plain function with the same shape:
4
+
5
+ def fetch_x(ids: IdentifierSet, ctx: RetrievalContext) -> Optional[Artifact]
6
+
7
+ Return None to decline (not applicable, nothing found, request failed). Never
8
+ raise into the engine, and never write to disk -- the engine decides what is
9
+ accepted, after the classifier and the validator have had their say. A source
10
+ that wrote its own output would be able to smuggle an unvalidated artifact past
11
+ both.
12
+
13
+ Which tiers a source serves, and which identifiers it needs, are declared in
14
+ ladder.json rather than here.
15
+ """
@@ -0,0 +1,102 @@
1
+ """arXiv e-print source (T3) and LaTeXML HTML (T2).
2
+
3
+ The query API this codebase already uses returns metadata only. The LaTeX source
4
+ is at a separate endpoint entirely -- export.arxiv.org/e-print/{id} -- and it
5
+ was simply missing, which means arXiv PDFs were being taken while the source
6
+ that produced them sat one request away. Verified: both arxiv.org/e-print and
7
+ export.arxiv.org/e-print return 200 application/gzip.
8
+
9
+ HTML is available for effectively the whole corpus, via two endpoints:
10
+
11
+ * arxiv.org/html/{id} -- official, but only from ~Dec 2023 onward.
12
+ Verified: 2401.00001 -> 200, 2101.00001 -> 404.
13
+ * ar5iv.labs.arxiv.org/html/{id} -- LaTeXML conversion of the back catalogue.
14
+ Verified back to the first paper of the modern ID scheme: 0704.0001 -> 200,
15
+ 2.5 MB, 35 <table> elements. 2101.00001 -> 200, 129 KB, 2 tables.
16
+
17
+ Official first (it is the newer, better-maintained converter), ar5iv second.
18
+ There is no JATS route: arXiv does not produce JATS, so "arXiv as XML" is not a
19
+ thing -- T2 HTML and T3 LaTeX source are the structured options.
20
+
21
+ Both renderings are machine conversions of LaTeX, so a conversion artifact in a
22
+ table is possible in a way it is not for publisher JATS. That caveat is recorded
23
+ in provenance rather than assumed away.
24
+
25
+ arXiv tightened rate limits in February 2026 (429s reported even at 3-4 second
26
+ spacing), which is why both arXiv hosts get their own slow bucket.
27
+ """
28
+
29
+ import re
30
+ from typing import Optional
31
+
32
+ from ..artifact import Artifact
33
+ from ..tiers import Tier
34
+
35
+ EPRINT = "https://export.arxiv.org/e-print/{arxiv_id}"
36
+
37
+ #: Official renderer first, then the back-catalogue converter. Order matters:
38
+ #: where both exist the official one is the better-maintained conversion.
39
+ LATEXML_HTML_URLS = (
40
+ ("arxiv_html", "https://arxiv.org/html/{arxiv_id}"),
41
+ ("ar5iv", "https://ar5iv.labs.arxiv.org/html/{arxiv_id}"),
42
+ )
43
+
44
+
45
+ def fetch_eprint_source(ids, ctx) -> Optional[Artifact]:
46
+ """The LaTeX source tarball."""
47
+ if not ids.arxiv_id:
48
+ return None
49
+
50
+ url = EPRINT.format(arxiv_id=ids.arxiv_id)
51
+ response = ctx.http.get(url, timeout=90, polite=False)
52
+ if not response.ok or not response.content:
53
+ ctx.log(f" arXiv e-print: HTTP {response.status} for {ids.arxiv_id}")
54
+ return None
55
+
56
+ # Some submissions are a single PDF with no source. Those are a T5 in T3's
57
+ # clothing; the classifier will catch it, but saying so here is clearer.
58
+ if response.content[:4] == b"%PDF":
59
+ ctx.log(" arXiv e-print: submission is PDF-only, no LaTeX source")
60
+ return None
61
+
62
+ return Artifact(
63
+ content=response.content,
64
+ tier=Tier.T3_SOURCE,
65
+ source="arxiv_eprint",
66
+ url=response.url,
67
+ http_status=response.status,
68
+ served_content_type=response.content_type,
69
+ identifier_used=ids.arxiv_id,
70
+ extension=".source.tar.gz",
71
+ )
72
+
73
+
74
+ def fetch_latexml_html(ids, ctx) -> Optional[Artifact]:
75
+ """arXiv HTML: the official renderer, falling back to ar5iv for older papers."""
76
+ if not ids.arxiv_id:
77
+ return None
78
+
79
+ # Strip any version suffix: ar5iv 404s on some versioned ids that resolve
80
+ # perfectly well unversioned.
81
+ base_id = re.sub(r"v\d+$", "", str(ids.arxiv_id))
82
+
83
+ for source_name, template in LATEXML_HTML_URLS:
84
+ url = template.format(arxiv_id=base_id)
85
+ response = ctx.http.get(url, timeout=60, polite=False)
86
+ if not response.ok or not response.content:
87
+ ctx.log(f" {source_name}: HTTP {response.status} for {base_id}")
88
+ continue
89
+ return Artifact(
90
+ content=response.content,
91
+ tier=Tier.T2_HTML,
92
+ source=source_name,
93
+ url=response.url,
94
+ http_status=response.status,
95
+ served_content_type=response.content_type,
96
+ identifier_used=base_id,
97
+ normalization_failures=[
98
+ f"{source_name}: LaTeX->HTML machine conversion; "
99
+ f"tables may carry conversion artifacts"
100
+ ],
101
+ )
102
+ return None
@@ -0,0 +1,64 @@
1
+ """T6: CORE plain full text. Screening only.
2
+
3
+ Flattening destroys row/column association silently. Every number is still
4
+ present, in reading order, anchored to nothing -- so an extraction model does
5
+ not fail, it produces confident, plausible, wrong arm-to-outcome assignments.
6
+ That is worse than a missing table, because a missing table is visible.
7
+
8
+ So this source refuses outright when the target task is extraction, rather than
9
+ ranking last and being degraded into. The artifact it does return for screening
10
+ is stamped tables_unavailable=True so nothing downstream can mistake it for a
11
+ source of tabular values.
12
+ """
13
+
14
+ from typing import Optional
15
+
16
+ from ..artifact import Artifact
17
+ from ..tiers import Tier
18
+
19
+ CORE_SEARCH = "https://api.core.ac.uk/v3/search/works"
20
+
21
+
22
+ def fetch_core_fulltext(ids, ctx) -> Optional[Artifact]:
23
+ if not ids.doi:
24
+ return None
25
+
26
+ if ctx.is_extraction():
27
+ ctx.log(" CORE full text: refused (plain text cannot support extraction)")
28
+ return None
29
+
30
+ import os
31
+
32
+ if not os.getenv("COREAPIKEY"):
33
+ return None
34
+
35
+ response = ctx.http.get(
36
+ CORE_SEARCH,
37
+ params={"q": 'doi:"{}"'.format(ids.doi), "limit": 1},
38
+ timeout=30,
39
+ )
40
+ if not response.ok:
41
+ return None
42
+ try:
43
+ results = (response.json() or {}).get("results") or []
44
+ except ValueError:
45
+ return None
46
+ if not results:
47
+ return None
48
+
49
+ full_text = results[0].get("fullText")
50
+ if not full_text or not full_text.strip():
51
+ return None
52
+
53
+ return Artifact(
54
+ content=full_text.encode("utf-8"),
55
+ tier=Tier.T6_PLAINTEXT,
56
+ source="core_fulltext",
57
+ url=response.url,
58
+ http_status=response.status,
59
+ served_content_type=response.content_type,
60
+ identifier_used=ids.doi,
61
+ license=results[0].get("license") or ids.license,
62
+ extension=".txt",
63
+ tables_unavailable=True,
64
+ )