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,351 @@
1
+ """PMC supplementary routes: what the article says it has, and where to get it.
2
+
3
+ Three things live here and they divide along a line worth being explicit about.
4
+
5
+ E1, the JATS manifest, is a **classifier**, not a fetcher. The markup names every
6
+ supplementary file:
7
+
8
+ <supplementary-material content-type="local-data" id="pone.0000308.s001">
9
+ <media xlink:href="pone.0000308.s001.doc"/>
10
+
11
+ but xlink:href is a bare filename with no resolvable base. The obvious base URL
12
+ does not work: pmc.ncbi.nlm.nih.gov/articles/PMC1817752/bin/{name} returns HTTP
13
+ 200, text/html, 21 KB -- a Google reCAPTCHA challenge page (verified 2026-07-29);
14
+ www.ncbi.nlm.nih.gov 301s to the same, and europepmc.org/articles/PMC../bin/..
15
+ redirects to a render endpoint that hangs. So E1 contributes no bytes. What it
16
+ contributes is the distinction between the article's supplements and its figures,
17
+ which is the one thing the filename heuristics cannot know for certain, and it
18
+ leaves that in ctx.scratch for the routes that do carry bytes.
19
+
20
+ E3, PMC's AWS Open Data mirror, is the fetcher. It is also the replacement for a
21
+ route that is about to disappear: oa.fcgi still returns
22
+ ftp://ftp.ncbi.nlm.nih.gov/pub/pmc/oa_package/../PMC1817752.tar.gz, that path now
23
+ 404s over both https and ftp, and the readme dated 2026-04-10 says all legacy FTP
24
+ files are removed in August 2026. An endpoint that returns 200 and a dead link is
25
+ exactly the failure this package exists to catch, so it is not built on. The S3
26
+ mirror is better than the tarball ever was anyway: it declares every object's
27
+ size and md5 before transfer, which is what lets the size cap refuse an oversized
28
+ file for nothing.
29
+ """
30
+
31
+ import os
32
+ import re
33
+ from typing import Dict, List, Optional, Set, Tuple
34
+ from xml.etree import ElementTree
35
+
36
+ from .supplement_index import (
37
+ ROLE_SUPPLEMENT,
38
+ SupplementFile,
39
+ classify_role,
40
+ jats_sets,
41
+ )
42
+
43
+ EPMC_FULLTEXT = "https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/fullTextXML"
44
+
45
+ S3_BUCKET = "https://pmc-oa-opendata.s3.amazonaws.com"
46
+ S3_VERSIONS = S3_BUCKET + "/?list-type=2&prefix=PMC{number}.&delimiter=/"
47
+ S3_LISTING = S3_BUCKET + "/?list-type=2&prefix=PMC{number}.{version}/"
48
+ S3_METADATA = S3_BUCKET + "/metadata/PMC{number}.{version}.json"
49
+
50
+ _XLINK = "{http://www.w3.org/1999/xlink}href"
51
+ _S3_NS = "{http://s3.amazonaws.com/doc/2006-03-01/}"
52
+
53
+
54
+ # -- E1: the JATS manifest --------------------------------------------------
55
+
56
+
57
+ def enumerate_jats_manifest(ids, ctx) -> List[SupplementFile]:
58
+ """Seed ctx.scratch with the article's own supplement/figure sets.
59
+
60
+ Returns no files by design -- see the module docstring. Runs first so every
61
+ later provider can classify against ground truth instead of a filename guess.
62
+ """
63
+ if not ids.pmcid:
64
+ return []
65
+
66
+ content = _jats_for(ids, ctx)
67
+ if not content:
68
+ return []
69
+ try:
70
+ root = ElementTree.fromstring(content)
71
+ except ElementTree.ParseError:
72
+ return []
73
+
74
+ # A zero count is only trustworthy from a document that actually has a body.
75
+ # An abstract stub or an EPMC error document parses cleanly and has no
76
+ # <supplementary-material> either, and the two are otherwise identical.
77
+ if root.find(".//{*}body") is None and root.find(".//body") is None:
78
+ ctx.log(" JATS manifest: no <body>, so a zero supplement count proves nothing")
79
+ return []
80
+
81
+ supplements, labels, raw = _supplementary_hrefs(root)
82
+ figures = _figure_hrefs(root)
83
+ ctx.scratch["jats_supplements"] = supplements
84
+ ctx.scratch["jats_figures"] = figures - supplements
85
+ ctx.scratch["jats_labels"] = labels
86
+ # basename -> raw href, for two consumers that need more than the set: the
87
+ # completeness gate (declared vs obtained) and the jats_declared recovery
88
+ # provider (an absolute href is directly fetchable; a bare filename is not).
89
+ ctx.scratch["jats_declared"] = raw
90
+ ctx.log(f" JATS manifest: {len(supplements)} supplementary, {len(figures)} figure href(s)")
91
+ return []
92
+
93
+
94
+ def enumerate_jats_declared(ids, ctx) -> List[SupplementFile]:
95
+ """Declared hrefs that are themselves URLs. Recovery of last resort.
96
+
97
+ Runs last so it only matters when every real provider came up short. Most
98
+ declared hrefs are bare filenames with no resolvable base (see the module
99
+ docstring on the /bin/ captcha), but some publishers declare an absolute
100
+ URL -- LWW's links.lww.com permalinks, verified live to serve the file
101
+ directly -- and those are fetchable exactly as written. Anything already
102
+ obtained dedupes away by sha256 downstream, so listing here is free.
103
+ """
104
+ declared = ctx.scratch.get("jats_declared") or {}
105
+ files: List[SupplementFile] = []
106
+ for basename, href in sorted(declared.items()):
107
+ if not re.match(r"^https?://", str(href), re.IGNORECASE):
108
+ continue
109
+ files.append(SupplementFile(
110
+ name=basename,
111
+ url=str(href),
112
+ provider="jats_declared",
113
+ role=ROLE_SUPPLEMENT,
114
+ origin_doi=ids.doi,
115
+ listing_index=len(files),
116
+ ))
117
+ return files
118
+
119
+
120
+ def _jats_for(ids, ctx) -> Optional[bytes]:
121
+ """The article XML, preferring the copy a T1 pass already wrote."""
122
+ from .supplementary import stem_for
123
+
124
+ on_disk = stem_for(ctx.save_path) + ".xml"
125
+ try:
126
+ with open(on_disk, "rb") as f:
127
+ return f.read()
128
+ except OSError:
129
+ pass
130
+
131
+ response = ctx.http.get(EPMC_FULLTEXT.format(pmcid=ids.pmcid), timeout=60)
132
+ if response.ok and response.content:
133
+ return response.content
134
+ return None
135
+
136
+
137
+ def declared_from_xml(content: bytes) -> Optional[Dict[str, str]]:
138
+ """basename -> raw href of every declared supplement, or None if unverifiable.
139
+
140
+ None, not {}, for an unparseable document or one with no <body>: the same
141
+ rule enumerate_jats_manifest applies, because a zero count from an abstract
142
+ stub proves nothing and must not read as "verified complete".
143
+ """
144
+ try:
145
+ root = ElementTree.fromstring(content)
146
+ except ElementTree.ParseError:
147
+ return None
148
+ if root.find(".//{*}body") is None and root.find(".//body") is None:
149
+ return None
150
+ _, _, raw = _supplementary_hrefs(root)
151
+ return raw
152
+
153
+
154
+ def _supplementary_hrefs(root) -> Tuple[Set[str], Dict[str, str], Dict[str, str]]:
155
+ """Every href under a <supplementary-material>: basenames, labels, raw hrefs."""
156
+ hrefs: Set[str] = set()
157
+ labels: Dict[str, str] = {}
158
+ raw: Dict[str, str] = {}
159
+ for block in root.iter():
160
+ if not _tag_is(block, "supplementary-material"):
161
+ continue
162
+ label = _label_of(block)
163
+ for element in block.iter():
164
+ # <ext-link> included: LWW declares its supplement as a prose link
165
+ # (<supplementary-material><p>... <ext-link xlink:href=
166
+ # "http://links.lww.com/PR9/A213">) with no <media> at all, and a
167
+ # declaration the parser cannot see is a completeness gate that
168
+ # reads "verified complete" over a missing file.
169
+ if not (_tag_is(element, "media") or _tag_is(element, "graphic")
170
+ or _tag_is(element, "ext-link")):
171
+ continue
172
+ href = element.get(_XLINK) or element.get("href")
173
+ if not href:
174
+ continue
175
+ key = _basename(href)
176
+ if not key:
177
+ continue
178
+ hrefs.add(key)
179
+ raw.setdefault(key, str(href).strip())
180
+ if label:
181
+ labels[key] = label
182
+ return hrefs, labels, raw
183
+
184
+
185
+ def _figure_hrefs(root) -> Set[str]:
186
+ """Every href under a <fig> or <table-wrap>: images of the paper, not data."""
187
+ hrefs: Set[str] = set()
188
+ for block in root.iter():
189
+ if not (_tag_is(block, "fig") or _tag_is(block, "table-wrap")):
190
+ continue
191
+ for element in block.iter():
192
+ if not (_tag_is(element, "graphic") or _tag_is(element, "media")):
193
+ continue
194
+ href = element.get(_XLINK) or element.get("href")
195
+ if href:
196
+ hrefs.add(_basename(href))
197
+ return hrefs
198
+
199
+
200
+ def _label_of(block) -> str:
201
+ for child in block:
202
+ if _tag_is(child, "label") and (child.text or "").strip():
203
+ return (child.text or "").strip()
204
+ for child in block.iter():
205
+ if _tag_is(child, "title") and (child.text or "").strip():
206
+ return (child.text or "").strip()
207
+ return ""
208
+
209
+
210
+ def _tag_is(element, name: str) -> bool:
211
+ tag = element.tag
212
+ if not isinstance(tag, str):
213
+ return False
214
+ return tag.rsplit("}", 1)[-1] == name
215
+
216
+
217
+ def _basename(href: str) -> str:
218
+ return str(href).split("?")[0].replace("\\", "/").rsplit("/", 1)[-1].strip().lower()
219
+
220
+
221
+ # -- E3: PMC AWS Open Data --------------------------------------------------
222
+
223
+
224
+ def enumerate_pmc_s3(ids, ctx) -> List[SupplementFile]:
225
+ """Media objects from the PMC Open Access S3 mirror.
226
+
227
+ Sizes come from the bucket listing and md5s from the metadata document, both
228
+ before a single file byte moves, which is what makes the cap free here.
229
+ """
230
+ number = _pmcid_number(ids.pmcid)
231
+ if not number:
232
+ return []
233
+
234
+ version = _latest_version(ctx, number)
235
+ if version is None:
236
+ return []
237
+
238
+ metadata = ctx.http.get(
239
+ S3_METADATA.format(number=number, version=version), timeout=30, polite=False
240
+ )
241
+ if not metadata.ok:
242
+ return []
243
+ try:
244
+ payload = metadata.json() or {}
245
+ except ValueError:
246
+ return []
247
+
248
+ media = payload.get("media_urls") or []
249
+ if not media:
250
+ # Not an error: author manuscripts without a CC licence have their media
251
+ # withheld while the article record itself exists.
252
+ ctx.log(" PMC S3: record exists but lists no media")
253
+ return []
254
+
255
+ # The article's own text renditions are not supplements.
256
+ own = {
257
+ _basename(payload.get(key) or "")
258
+ for key in ("pdf_url", "xml_url", "text_url")
259
+ if payload.get(key)
260
+ }
261
+ sizes = _listing_sizes(ctx, number, version)
262
+ supplements, figures = jats_sets(ctx)
263
+
264
+ files = []
265
+ for index, s3_url in enumerate(media):
266
+ if not isinstance(s3_url, str):
267
+ continue
268
+ key, checksum = _key_and_md5(s3_url)
269
+ if not key:
270
+ continue
271
+ name = _basename(key)
272
+ if name in own:
273
+ continue
274
+ files.append(SupplementFile(
275
+ name=os.path.basename(key),
276
+ url=f"{S3_BUCKET}/{key}",
277
+ provider="pmc_s3",
278
+ listing_index=index,
279
+ size_bytes=sizes.get(key),
280
+ checksum=f"md5:{checksum}" if checksum else None,
281
+ role=classify_role(name, jats_supplements=supplements, jats_figures=figures),
282
+ label=(ctx.scratch.get("jats_labels") or {}).get(name, ""),
283
+ origin_doi=ids.doi,
284
+ extra={"pmcid": ids.pmcid, "s3_key": key},
285
+ ))
286
+ return files
287
+
288
+
289
+ def _latest_version(ctx, number: str) -> Optional[int]:
290
+ """The highest PMC{n}.{v} prefix in the bucket.
291
+
292
+ Versions matter: .1 is sometimes the author manuscript and .2 the published
293
+ version, and they carry different media.
294
+ """
295
+ response = ctx.http.get(S3_VERSIONS.format(number=number), timeout=30, polite=False)
296
+ if not response.ok:
297
+ return None
298
+ versions = [
299
+ int(m.group(1))
300
+ for m in re.finditer(rf"<Prefix>PMC{number}\.(\d+)/</Prefix>", response.text)
301
+ ]
302
+ return max(versions) if versions else None
303
+
304
+
305
+ def _listing_sizes(ctx, number: str, version: int) -> Dict[str, int]:
306
+ """key -> byte size, from one anonymous ListBucket call."""
307
+ response = ctx.http.get(
308
+ S3_LISTING.format(number=number, version=version), timeout=30, polite=False
309
+ )
310
+ if not response.ok:
311
+ return {}
312
+ try:
313
+ root = ElementTree.fromstring(response.content)
314
+ except ElementTree.ParseError:
315
+ return {}
316
+ sizes = {}
317
+ for contents in root.iter():
318
+ if not _tag_is(contents, "Contents"):
319
+ continue
320
+ key = size = None
321
+ for child in contents:
322
+ if _tag_is(child, "Key"):
323
+ key = (child.text or "").strip()
324
+ elif _tag_is(child, "Size"):
325
+ try:
326
+ size = int((child.text or "").strip())
327
+ except ValueError:
328
+ size = None
329
+ if key and size is not None:
330
+ sizes[key] = size
331
+ return sizes
332
+
333
+
334
+ def _key_and_md5(s3_url: str) -> Tuple[Optional[str], Optional[str]]:
335
+ """s3://pmc-oa-opendata/PMC1.1/x.xls?md5=abc -> ("PMC1.1/x.xls", "abc")."""
336
+ without_scheme = re.sub(r"^s3://[^/]+/", "", s3_url.strip())
337
+ if not without_scheme or without_scheme == s3_url.strip() and "://" in s3_url:
338
+ return None, None
339
+ key, _, query = without_scheme.partition("?")
340
+ checksum = None
341
+ for part in query.split("&"):
342
+ if part.startswith("md5="):
343
+ checksum = part[4:].strip() or None
344
+ return (key or None), checksum
345
+
346
+
347
+ def _pmcid_number(pmcid: Optional[str]) -> Optional[str]:
348
+ if not pmcid:
349
+ return None
350
+ m = re.search(r"(\d+)", str(pmcid))
351
+ return m.group(1) if m else None