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.
- fetchpdf/__init__.py +49 -0
- fetchpdf/__main__.py +6 -0
- fetchpdf/_env.py +63 -0
- fetchpdf/_http.py +40 -0
- fetchpdf/fetch_abstract_from_doi.py +549 -0
- fetchpdf/fetch_metadata_from_doi.py +225 -0
- fetchpdf/fetchpdf.py +7339 -0
- fetchpdf/retrieval/__init__.py +36 -0
- fetchpdf/retrieval/_util.py +150 -0
- fetchpdf/retrieval/artifact.py +87 -0
- fetchpdf/retrieval/backends/__init__.py +109 -0
- fetchpdf/retrieval/backends/claude_cli.py +111 -0
- fetchpdf/retrieval/backends/openrouter.py +244 -0
- fetchpdf/retrieval/cache.py +123 -0
- fetchpdf/retrieval/chunk.py +195 -0
- fetchpdf/retrieval/classify.py +110 -0
- fetchpdf/retrieval/context.py +69 -0
- fetchpdf/retrieval/corresponding.py +349 -0
- fetchpdf/retrieval/engine.py +666 -0
- fetchpdf/retrieval/extract_images.py +593 -0
- fetchpdf/retrieval/fulltext_scan.py +472 -0
- fetchpdf/retrieval/goals.py +145 -0
- fetchpdf/retrieval/http.py +669 -0
- fetchpdf/retrieval/identifiers.py +149 -0
- fetchpdf/retrieval/ladder.json +362 -0
- fetchpdf/retrieval/linked_artifacts.py +144 -0
- fetchpdf/retrieval/llm_adjudicate.py +278 -0
- fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
- fetchpdf/retrieval/normalize.py +301 -0
- fetchpdf/retrieval/pdf_identity.py +533 -0
- fetchpdf/retrieval/pdf_text.py +242 -0
- fetchpdf/retrieval/provenance.py +185 -0
- fetchpdf/retrieval/ratelimit.py +113 -0
- fetchpdf/retrieval/repository_waf.py +152 -0
- fetchpdf/retrieval/request_drafts.py +304 -0
- fetchpdf/retrieval/resolve.py +481 -0
- fetchpdf/retrieval/sources/__init__.py +15 -0
- fetchpdf/retrieval/sources/arxiv.py +102 -0
- fetchpdf/retrieval/sources/core_text.py +64 -0
- fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
- fetchpdf/retrieval/sources/epmc.py +133 -0
- fetchpdf/retrieval/sources/landing.py +34 -0
- fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
- fetchpdf/retrieval/sources/pmc.py +59 -0
- fetchpdf/retrieval/sources/preprint.py +101 -0
- fetchpdf/retrieval/sources/publisher_html.py +154 -0
- fetchpdf/retrieval/sources/supplements.py +183 -0
- fetchpdf/retrieval/supplement_atypon.py +269 -0
- fetchpdf/retrieval/supplement_dspace.py +187 -0
- fetchpdf/retrieval/supplement_graph.py +842 -0
- fetchpdf/retrieval/supplement_index.py +820 -0
- fetchpdf/retrieval/supplement_pmc.py +351 -0
- fetchpdf/retrieval/supplement_publishers.py +374 -0
- fetchpdf/retrieval/supplementary.py +1770 -0
- fetchpdf/retrieval/tiers.py +213 -0
- fetchpdf/retrieval/to_markdown.py +661 -0
- fetchpdf/retrieval/validate.py +368 -0
- fetchpdf/retrieval/verify.py +149 -0
- fetchpdf-0.1.0.dist-info/METADATA +1392 -0
- fetchpdf-0.1.0.dist-info/RECORD +64 -0
- fetchpdf-0.1.0.dist-info/WHEEL +5 -0
- fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
- fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|