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,193 @@
|
|
|
1
|
+
"""Crossref text-and-data-mining links, and the Elsevier endpoint most of them
|
|
2
|
+
point at.
|
|
3
|
+
|
|
4
|
+
Three things the obvious implementation gets wrong:
|
|
5
|
+
|
|
6
|
+
1. The content-type for XML is "text/xml", not "application/xml". Verified on
|
|
7
|
+
10.1016/S0140-6736(20)30183-5, 10.1016/j.jclinepi.2021.02.003 and
|
|
8
|
+
10.1016/j.ijnurstu.2019.103466 -- all "text/xml".
|
|
9
|
+
|
|
10
|
+
2. intended-application matters. "similarity-checking" links feed iThenticate
|
|
11
|
+
and are usually landing pages: 10.1371/journal.pone.0000308 exposes exactly
|
|
12
|
+
one link, content-type "unspecified", pointing at dx.plos.org. Only
|
|
13
|
+
"text-mining" links are candidates.
|
|
14
|
+
|
|
15
|
+
3. There is no Crossref token to obtain. Crossref retired the click-through
|
|
16
|
+
token service in December 2020; entitlement is entirely publisher-side now.
|
|
17
|
+
So 401/403 is the expected outcome on a subscription title, not an error
|
|
18
|
+
condition, and it must never abort a run.
|
|
19
|
+
|
|
20
|
+
For Elsevier the Crossref link already carries the PII inline, which is why
|
|
21
|
+
Scopus is not needed to resolve one. The API key is appended here because the
|
|
22
|
+
link Crossref publishes does not include it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from ..._env import ELSEVIER_TDM_API_KEY
|
|
29
|
+
from ..._http import elsevier_first_page_only
|
|
30
|
+
from ..artifact import Artifact
|
|
31
|
+
from ..tiers import Tier
|
|
32
|
+
|
|
33
|
+
_XML_CONTENT_TYPES = {"text/xml", "application/xml"}
|
|
34
|
+
_TEXT_MINING = "text-mining"
|
|
35
|
+
_ELSEVIER_HOSTS = ("api.elsevier.com",)
|
|
36
|
+
|
|
37
|
+
ELSEVIER_ARTICLE = "https://api.elsevier.com/content/article/PII:{pii}"
|
|
38
|
+
|
|
39
|
+
#: Elsevier answers a non-entitled request with 200 and a coredata-only payload.
|
|
40
|
+
#: Cheap pre-filter; the T1 validator is what actually decides.
|
|
41
|
+
_ENTITLEMENT_SIGNATURES = (
|
|
42
|
+
b"<service-error",
|
|
43
|
+
b"AUTHENTICATION_ERROR",
|
|
44
|
+
b"APIKEY_INVALID",
|
|
45
|
+
b"RESOURCE_NOT_FOUND",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_elsevier_tdm(ids, ctx) -> Optional[Artifact]:
|
|
50
|
+
"""Elsevier full-text XML by PII."""
|
|
51
|
+
if not ids.elsevier_pii:
|
|
52
|
+
return None
|
|
53
|
+
if not ELSEVIER_TDM_API_KEY:
|
|
54
|
+
ctx.log(" Elsevier TDM: no ELSEVIER_TDM_API_KEY")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
url = ELSEVIER_ARTICLE.format(pii=ids.elsevier_pii)
|
|
58
|
+
response = ctx.http.get(
|
|
59
|
+
url,
|
|
60
|
+
params={"httpAccept": "text/xml", "apiKey": ELSEVIER_TDM_API_KEY},
|
|
61
|
+
timeout=60,
|
|
62
|
+
)
|
|
63
|
+
if response.status in (401, 403):
|
|
64
|
+
ctx.log(f" Elsevier TDM: not entitled (HTTP {response.status})")
|
|
65
|
+
return None
|
|
66
|
+
if not response.ok or not response.content:
|
|
67
|
+
ctx.log(f" Elsevier TDM: HTTP {response.status}")
|
|
68
|
+
return None
|
|
69
|
+
if any(sig in response.content[:4096] for sig in _ENTITLEMENT_SIGNATURES):
|
|
70
|
+
ctx.log(" Elsevier TDM: 200 with an error payload (not entitled)")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
return Artifact(
|
|
74
|
+
content=response.content,
|
|
75
|
+
tier=Tier.T1_XML,
|
|
76
|
+
source="elsevier_tdm",
|
|
77
|
+
url=response.url,
|
|
78
|
+
http_status=response.status,
|
|
79
|
+
served_content_type=response.content_type,
|
|
80
|
+
identifier_used=ids.elsevier_pii,
|
|
81
|
+
license=ids.license,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fetch_elsevier_pdf(ids, ctx) -> Optional[Artifact]:
|
|
86
|
+
"""Elsevier full-text PDF by PII -- the same endpoint, a different Accept.
|
|
87
|
+
|
|
88
|
+
Entitlement at Elsevier is negotiated per representation, not per article:
|
|
89
|
+
the XML view of a record can come back as coredata-only metadata (no
|
|
90
|
+
<body>, or a 400-character corrigendum notice) while `Accept:
|
|
91
|
+
application/pdf` on the *same* PII returns the complete typeset article.
|
|
92
|
+
Verified across four records that the XML route rejected -- all four
|
|
93
|
+
returned real PDFs of 237-446 KB.
|
|
94
|
+
|
|
95
|
+
So this is not a retry of fetch_elsevier_tdm, it is a different question
|
|
96
|
+
asked of the same URL, and it belongs at T5 because what comes back is a
|
|
97
|
+
PDF. Registered after legacy_pdf_chain in ladder.json: the chain's own
|
|
98
|
+
routes are free, and this spends Elsevier quota.
|
|
99
|
+
"""
|
|
100
|
+
if not ids.elsevier_pii:
|
|
101
|
+
return None
|
|
102
|
+
if not ELSEVIER_TDM_API_KEY:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
url = ELSEVIER_ARTICLE.format(pii=ids.elsevier_pii)
|
|
106
|
+
response = ctx.http.get(
|
|
107
|
+
url,
|
|
108
|
+
headers={"X-ELS-APIKey": ELSEVIER_TDM_API_KEY,
|
|
109
|
+
"Accept": "application/pdf"},
|
|
110
|
+
timeout=90,
|
|
111
|
+
)
|
|
112
|
+
if response.status in (401, 403):
|
|
113
|
+
ctx.log(f" Elsevier PDF: not entitled (HTTP {response.status})")
|
|
114
|
+
return None
|
|
115
|
+
if not response.ok or not response.content:
|
|
116
|
+
ctx.log(f" Elsevier PDF: HTTP {response.status}")
|
|
117
|
+
return None
|
|
118
|
+
# A non-entitled PDF request answers 200 with an XML error payload, so the
|
|
119
|
+
# magic bytes -- not the status -- are what decide. The engine's T5
|
|
120
|
+
# validator checks this too; failing here keeps a junk artifact from
|
|
121
|
+
# occupying the PDF goal in the first place.
|
|
122
|
+
if not response.content.startswith(b"%PDF"):
|
|
123
|
+
ctx.log(" Elsevier PDF: 200 but not a PDF (not entitled)")
|
|
124
|
+
return None
|
|
125
|
+
# Partial entitlement is a REAL one-page PDF, not an error payload: the
|
|
126
|
+
# magic bytes and the T5 validator both pass on it. The X-ELS-Status
|
|
127
|
+
# warning header is the only signal, so it must be read here where the
|
|
128
|
+
# response headers still exist -- an Artifact does not carry them.
|
|
129
|
+
if elsevier_first_page_only(response.headers):
|
|
130
|
+
ctx.log(" Elsevier PDF: first-page preview only (not entitled to full PDF)")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
return Artifact(
|
|
134
|
+
content=response.content,
|
|
135
|
+
tier=Tier.T5_PDF,
|
|
136
|
+
source="elsevier_pdf",
|
|
137
|
+
url=response.url,
|
|
138
|
+
http_status=response.status,
|
|
139
|
+
served_content_type=response.content_type,
|
|
140
|
+
identifier_used=ids.elsevier_pii,
|
|
141
|
+
license=ids.license,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fetch_crossref_tdm(ids, ctx) -> Optional[Artifact]:
|
|
146
|
+
"""Any other publisher's TDM XML, as advertised in Crossref's link[]."""
|
|
147
|
+
if not ids.doi:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Lazily resolve Crossref: a record that short-circuited on a PMCID never
|
|
151
|
+
# got here, and never paid for this call.
|
|
152
|
+
ctx.resolver.crossref(ids)
|
|
153
|
+
candidates = [
|
|
154
|
+
link for link in (ids.crossref_links or [])
|
|
155
|
+
if (link.get("content-type") or "").lower() in _XML_CONTENT_TYPES
|
|
156
|
+
and (link.get("intended-application") or "").lower() == _TEXT_MINING
|
|
157
|
+
]
|
|
158
|
+
if not candidates:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# Version of record before accepted manuscript, when the publisher says.
|
|
162
|
+
candidates.sort(key=lambda link: 0 if (link.get("content-version") == "vor") else 1)
|
|
163
|
+
|
|
164
|
+
for link in candidates:
|
|
165
|
+
url = link.get("URL")
|
|
166
|
+
if not url:
|
|
167
|
+
continue
|
|
168
|
+
if any(host in url for host in _ELSEVIER_HOSTS):
|
|
169
|
+
# Elsevier has its own entry point that knows about the API key.
|
|
170
|
+
m = re.search(r"PII:([A-Z0-9]+)", url, re.IGNORECASE)
|
|
171
|
+
if m and not ids.elsevier_pii:
|
|
172
|
+
ids.learn("crossref.link", url=url, elsevier_pii=m.group(1))
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
response = ctx.http.get(url, timeout=45)
|
|
176
|
+
if response.status in (401, 403):
|
|
177
|
+
ctx.log(f" Crossref TDM: publisher requires entitlement (HTTP {response.status})")
|
|
178
|
+
continue
|
|
179
|
+
if not response.ok or not response.content:
|
|
180
|
+
ctx.log(f" Crossref TDM: HTTP {response.status}")
|
|
181
|
+
continue
|
|
182
|
+
return Artifact(
|
|
183
|
+
content=response.content,
|
|
184
|
+
tier=Tier.T1_XML,
|
|
185
|
+
source="crossref_tdm",
|
|
186
|
+
url=response.url,
|
|
187
|
+
http_status=response.status,
|
|
188
|
+
served_content_type=response.content_type,
|
|
189
|
+
identifier_used=ids.doi,
|
|
190
|
+
license=ids.license,
|
|
191
|
+
extra={"content_version": link.get("content-version")},
|
|
192
|
+
)
|
|
193
|
+
return None
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Europe PMC: the highest-value T1 route, and the one trap in it.
|
|
2
|
+
|
|
3
|
+
Measured on a 1023-DOI clinical corpus: 48.7% of records resolve to a PMCID, and
|
|
4
|
+
82.5% of those yield a JATS document that passes the T1 gate. That is ~40% of a
|
|
5
|
+
batch served by one free, unauthenticated endpoint.
|
|
6
|
+
|
|
7
|
+
The trap is preprints. Europe PMC indexes them as PPR records with availability
|
|
8
|
+
flags identical to an OA journal article:
|
|
9
|
+
|
|
10
|
+
10.1101/2020.01.30.927871 -> id=PPR110986 source=PPR inEPMC=Y
|
|
11
|
+
isOpenAccess=Y hasPDF=Y
|
|
12
|
+
fullTextIdList={'fullTextId': ['PPR110986']}
|
|
13
|
+
|
|
14
|
+
...and /PPR110986/fullTextXML returns 404. So do /supplementaryFiles and
|
|
15
|
+
/textMinedTerms. Reproduced on PPR118691. The flags do not predict full text for
|
|
16
|
+
preprints, so PPR records are handed to the bioRxiv/medRxiv source instead --
|
|
17
|
+
and the PPR id is stashed on the IdentifierSet so that source knows to run.
|
|
18
|
+
|
|
19
|
+
The flags are still worth fetching: they gate the fullTextXML call for journal
|
|
20
|
+
articles and carry the license. But the fetch also tolerates a 404 on a record
|
|
21
|
+
the flags called available, because ~12% of PMCID records are indexed in the ID
|
|
22
|
+
Converter without being in the Europe PMC OA subset.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from ..artifact import Artifact
|
|
28
|
+
from ..tiers import Tier
|
|
29
|
+
|
|
30
|
+
REST = "https://www.ebi.ac.uk/europepmc/webservices/rest"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def search_flags(ids, ctx) -> dict:
|
|
34
|
+
"""Fetch and memoize the Europe PMC record for this DOI/PMCID.
|
|
35
|
+
|
|
36
|
+
Cheap, and it answers three questions at once: is the full text here, is it
|
|
37
|
+
open access, and is this a preprint pretending otherwise.
|
|
38
|
+
"""
|
|
39
|
+
if ids.epmc_flags.get("_searched"):
|
|
40
|
+
return ids.epmc_flags
|
|
41
|
+
|
|
42
|
+
query = None
|
|
43
|
+
if ids.doi:
|
|
44
|
+
query = 'DOI:"{}"'.format(ids.doi)
|
|
45
|
+
elif ids.pmcid:
|
|
46
|
+
query = "PMCID:{}".format(ids.pmcid)
|
|
47
|
+
if not query:
|
|
48
|
+
return ids.epmc_flags
|
|
49
|
+
|
|
50
|
+
response = ctx.http.get(
|
|
51
|
+
REST + "/search",
|
|
52
|
+
params={"query": query, "format": "json", "resultType": "core"},
|
|
53
|
+
timeout=25,
|
|
54
|
+
)
|
|
55
|
+
ids.epmc_flags["_searched"] = True
|
|
56
|
+
if not response.ok:
|
|
57
|
+
ids.chain.record("europepmc.search", url=REST + "/search", http_status=response.status)
|
|
58
|
+
return ids.epmc_flags
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
results = (response.json() or {}).get("resultList", {}).get("result", [])
|
|
62
|
+
except ValueError:
|
|
63
|
+
results = []
|
|
64
|
+
if not results:
|
|
65
|
+
ids.chain.record(
|
|
66
|
+
"europepmc.search", url=REST + "/search", http_status=response.status,
|
|
67
|
+
note="no Europe PMC record",
|
|
68
|
+
)
|
|
69
|
+
return ids.epmc_flags
|
|
70
|
+
|
|
71
|
+
record = results[0]
|
|
72
|
+
for key in ("inEPMC", "inPMC", "isOpenAccess", "hasPDF", "hasSuppl",
|
|
73
|
+
"hasTextMinedTerms", "source", "id"):
|
|
74
|
+
if record.get(key) is not None:
|
|
75
|
+
ids.epmc_flags[key] = record[key]
|
|
76
|
+
ids.epmc_flags["fullTextIdList"] = record.get("fullTextIdList") or {}
|
|
77
|
+
|
|
78
|
+
values = {}
|
|
79
|
+
if record.get("pmcid"):
|
|
80
|
+
values["pmcid"] = record["pmcid"]
|
|
81
|
+
if record.get("pmid"):
|
|
82
|
+
values["pmid"] = str(record["pmid"])
|
|
83
|
+
if record.get("license"):
|
|
84
|
+
values["license"] = record["license"]
|
|
85
|
+
# A PPR id is the signal that this is a preprint; the preprint source uses it.
|
|
86
|
+
if str(record.get("source") or "").upper() == "PPR" and record.get("id"):
|
|
87
|
+
values["ppr_id"] = record["id"]
|
|
88
|
+
ids.learn(
|
|
89
|
+
"europepmc.search",
|
|
90
|
+
url=REST + "/search",
|
|
91
|
+
http_status=response.status,
|
|
92
|
+
note="availability flags",
|
|
93
|
+
**values
|
|
94
|
+
)
|
|
95
|
+
return ids.epmc_flags
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch_fulltext_xml(ids, ctx) -> Optional[Artifact]:
|
|
99
|
+
"""JATS full text for a PMC record."""
|
|
100
|
+
flags = search_flags(ids, ctx)
|
|
101
|
+
|
|
102
|
+
if str(flags.get("source") or "").upper() == "PPR":
|
|
103
|
+
ctx.log(" Europe PMC: preprint (PPR) record -- flags do not imply full text here")
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if not ids.pmcid:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# Gate on the flags rather than blind-fetching, but do not treat them as
|
|
110
|
+
# authoritative: fetch anyway when they are simply absent.
|
|
111
|
+
if flags and flags.get("inEPMC") == "N" and flags.get("inPMC") == "N":
|
|
112
|
+
ctx.log(f" Europe PMC: {ids.pmcid} flagged as not in EPMC/PMC")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
url = "{}/{}/fullTextXML".format(REST, ids.pmcid)
|
|
116
|
+
response = ctx.http.get(url, timeout=45)
|
|
117
|
+
if not response.ok or not response.content:
|
|
118
|
+
# Expected for ~12% of PMCID records: indexed, but not in the OA subset.
|
|
119
|
+
ctx.log(f" Europe PMC: {ids.pmcid} fullTextXML HTTP {response.status}")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
ctx.pmc_xml_already_taken = True
|
|
123
|
+
return Artifact(
|
|
124
|
+
content=response.content,
|
|
125
|
+
tier=Tier.T1_XML,
|
|
126
|
+
source="europepmc_xml",
|
|
127
|
+
url=response.url,
|
|
128
|
+
http_status=response.status,
|
|
129
|
+
served_content_type=response.content_type,
|
|
130
|
+
identifier_used=ids.pmcid,
|
|
131
|
+
license=flags.get("license") or ids.license,
|
|
132
|
+
extra={"epmc_flags": {k: v for k, v in flags.items() if not k.startswith("_")}},
|
|
133
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""T7: the landing page. Last rung.
|
|
2
|
+
|
|
3
|
+
Kept as a real source rather than an implicit "nothing found" so that a record
|
|
4
|
+
which produced only a landing page is distinguishable from one that was never
|
|
5
|
+
attempted. That difference matters when re-running with --upgrade-existing:
|
|
6
|
+
the first is a known dead end, the second is work still to do.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ..artifact import Artifact
|
|
12
|
+
from ..tiers import Tier
|
|
13
|
+
|
|
14
|
+
DOI_RESOLVER = "https://doi.org/{doi}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_landing_page(ids, ctx) -> Optional[Artifact]:
|
|
18
|
+
if not ids.doi:
|
|
19
|
+
return None
|
|
20
|
+
response = ctx.http.get(DOI_RESOLVER.format(doi=ids.doi), timeout=30, polite=False)
|
|
21
|
+
if not response.ok or not response.content:
|
|
22
|
+
return None
|
|
23
|
+
return Artifact(
|
|
24
|
+
content=response.content,
|
|
25
|
+
tier=Tier.T7_LANDING,
|
|
26
|
+
source="landing_page",
|
|
27
|
+
url=response.url,
|
|
28
|
+
http_status=response.status,
|
|
29
|
+
served_content_type=response.content_type,
|
|
30
|
+
identifier_used=ids.doi,
|
|
31
|
+
license=ids.license,
|
|
32
|
+
extension=".landing.html",
|
|
33
|
+
tables_unavailable=True,
|
|
34
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""T5: the existing PDF chain, called as a single source.
|
|
2
|
+
|
|
3
|
+
Deliberately not reimplemented. The chain in fetchpdf is ~1000 lines
|
|
4
|
+
of accumulated publisher-specific knowledge -- Wiley rendered PDFs, the Crossref
|
|
5
|
+
chooser page, PsychArchives bitstreams, the eScholarship LinkOut route, session
|
|
6
|
+
cookies, SSL fallbacks and the Playwright paths. Forking it to slot into a tier would duplicate every one of
|
|
7
|
+
those behaviours and let the two copies drift.
|
|
8
|
+
|
|
9
|
+
So T5 is one call back into it with the tiered flags off. Its retries, backoff,
|
|
10
|
+
rate limiting and source tracking are preserved by construction rather than by
|
|
11
|
+
being carefully re-derived here.
|
|
12
|
+
|
|
13
|
+
It writes to a temporary path in the destination directory rather than to the
|
|
14
|
+
caller's save_path, because the engine has not decided to accept anything yet --
|
|
15
|
+
a T5 artifact that fails classification must leave no file behind.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from ..artifact import Artifact
|
|
23
|
+
from ..tiers import Tier
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_via_legacy_chain(ids, ctx) -> Optional[Artifact]:
|
|
27
|
+
if not ids.doi:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
# Imported at call time: fetchpdf imports the engine, so a
|
|
31
|
+
# module-scope import here would be a cycle.
|
|
32
|
+
#
|
|
33
|
+
# The CHAIN, not the `fetch_pdf` wrapper. The wrapper falls back to a
|
|
34
|
+
# structured-full-text walk when it cannot produce a PDF -- correct for a
|
|
35
|
+
# caller who asked for a paper, wrong here, where the tiered engine has
|
|
36
|
+
# ALREADY walked and failed T1 and T2 before descending to this rung. Going
|
|
37
|
+
# through the wrapper re-ran that walk verbatim for every hard record, with
|
|
38
|
+
# `resolver=None`, so it also discarded the primed batch resolver and built
|
|
39
|
+
# a fresh limiter, client and cache per record -- then announced it had
|
|
40
|
+
# saved structured text to a temp path the caller never sees.
|
|
41
|
+
from ...fetchpdf import _fetch_pdf_chain as fetch_pdf
|
|
42
|
+
from ...fetchpdf import download_url_for
|
|
43
|
+
|
|
44
|
+
directory = os.path.dirname(os.path.abspath(ctx.save_path)) or "."
|
|
45
|
+
os.makedirs(directory, exist_ok=True)
|
|
46
|
+
handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-t5-", suffix=".pdf", dir=directory)
|
|
47
|
+
os.close(handle)
|
|
48
|
+
# The chain's own skip-if-exists check would see the empty temp file and
|
|
49
|
+
# return immediately, so it has to be gone before the call.
|
|
50
|
+
_unlink(temp_path)
|
|
51
|
+
|
|
52
|
+
source_out = [None]
|
|
53
|
+
written = None
|
|
54
|
+
try:
|
|
55
|
+
written = fetch_pdf(
|
|
56
|
+
ids.doi,
|
|
57
|
+
temp_path,
|
|
58
|
+
email=ctx.email,
|
|
59
|
+
verbose=ctx.verbose,
|
|
60
|
+
delay=ctx.delay,
|
|
61
|
+
use_playwright=ctx.use_playwright,
|
|
62
|
+
_source_out=source_out,
|
|
63
|
+
)
|
|
64
|
+
if not written or not os.path.exists(written):
|
|
65
|
+
return None
|
|
66
|
+
with open(written, "rb") as f:
|
|
67
|
+
content = f.read()
|
|
68
|
+
if not content:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# The chain's own Elsevier XML fallback writes .xml, not .pdf. That is a
|
|
72
|
+
# T1 artifact arriving through the T5 door; the engine's classifier will
|
|
73
|
+
# re-tier it, so it is simply passed along as found.
|
|
74
|
+
return Artifact(
|
|
75
|
+
content=content,
|
|
76
|
+
tier=Tier.T5_PDF,
|
|
77
|
+
source="legacy_pdf_chain",
|
|
78
|
+
# The URL the bytes actually came from, recorded by try_download.
|
|
79
|
+
# This was "" until now, which is why a wrong artifact on disk could
|
|
80
|
+
# not say where it came from.
|
|
81
|
+
url=download_url_for(written) or download_url_for(temp_path),
|
|
82
|
+
http_status=200,
|
|
83
|
+
served_content_type="application/pdf",
|
|
84
|
+
identifier_used=ids.doi,
|
|
85
|
+
license=ids.license,
|
|
86
|
+
extra={"legacy_source": source_out[0]},
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
for path in {temp_path, written}:
|
|
90
|
+
if path:
|
|
91
|
+
_unlink(path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _unlink(path: str) -> None:
|
|
95
|
+
try:
|
|
96
|
+
if os.path.exists(path):
|
|
97
|
+
os.unlink(path)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""NCBI E-utilities efetch against db=pmc. Fallback only.
|
|
2
|
+
|
|
3
|
+
Same underlying PMC JATS as Europe PMC serves, so it runs only when the Europe
|
|
4
|
+
PMC route did not produce an artifact. Storing both copies would not be
|
|
5
|
+
corroboration -- it would manufacture false agreement between two views of one
|
|
6
|
+
source, which is worse than having one.
|
|
7
|
+
|
|
8
|
+
This endpoint is also the cleanest example of why HTTP 200 cannot be trusted.
|
|
9
|
+
For a non-OA record it returns a complete, well-formed document -- journal
|
|
10
|
+
metadata, title, authors, the lot -- with no <body> element, and the reason
|
|
11
|
+
stated in an XML comment that every parser silently discards:
|
|
12
|
+
|
|
13
|
+
<!--The publisher of this article does not allow downloading of the full
|
|
14
|
+
text in XML form.-->
|
|
15
|
+
|
|
16
|
+
Verified on PMC3390974 and PMC2148499. The T1 validator catches it against the
|
|
17
|
+
raw bytes; the check lives there rather than here so every XML source gets it.
|
|
18
|
+
|
|
19
|
+
Scope note on the August 2026 NCBI change: it removes legacy *bulk dataset
|
|
20
|
+
files* from the FTP and Cloud services. The OA Web Service and E-utilities are
|
|
21
|
+
not in scope, and this package touches no FTP or S3 path, so nothing here needs
|
|
22
|
+
to migrate.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from ..artifact import Artifact
|
|
28
|
+
from ..tiers import Tier
|
|
29
|
+
|
|
30
|
+
EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fetch_efetch_xml(ids, ctx) -> Optional[Artifact]:
|
|
34
|
+
if not ids.pmcid:
|
|
35
|
+
return None
|
|
36
|
+
if ctx.pmc_xml_already_taken:
|
|
37
|
+
ctx.log(" NCBI efetch: skipped, Europe PMC already served this PMC record")
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
numeric_id = str(ids.pmcid).upper().replace("PMC", "")
|
|
41
|
+
response = ctx.http.get(
|
|
42
|
+
EFETCH,
|
|
43
|
+
params={"db": "pmc", "id": numeric_id, "retmode": "xml"},
|
|
44
|
+
timeout=45,
|
|
45
|
+
)
|
|
46
|
+
if not response.ok or not response.content:
|
|
47
|
+
ctx.log(f" NCBI efetch: HTTP {response.status} for {ids.pmcid}")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return Artifact(
|
|
51
|
+
content=response.content,
|
|
52
|
+
tier=Tier.T1_XML,
|
|
53
|
+
source="pmc_efetch",
|
|
54
|
+
url=response.url,
|
|
55
|
+
http_status=response.status,
|
|
56
|
+
served_content_type=response.content_type,
|
|
57
|
+
identifier_used=ids.pmcid,
|
|
58
|
+
license=ids.license,
|
|
59
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""bioRxiv / medRxiv JATS.
|
|
2
|
+
|
|
3
|
+
Their details API exposes a `jatsxml` field pointing at the full JATS document:
|
|
4
|
+
|
|
5
|
+
api.biorxiv.org/details/biorxiv/10.1101/2020.01.30.927871
|
|
6
|
+
-> jatsxml: https://www.biorxiv.org/content/early/2020/01/31/....source.xml
|
|
7
|
+
|
|
8
|
+
The trap is the server segment. A medRxiv DOI queried against /details/biorxiv/
|
|
9
|
+
returns HTTP 200 with {"collection": [], "messages": [{"status": "no posts
|
|
10
|
+
found"}]} -- a miss that looks exactly like a successful empty answer. Querying
|
|
11
|
+
the wrong server for every medRxiv record would silently report "no full text"
|
|
12
|
+
for the entire preprint half of a corpus, so both servers are tried and the
|
|
13
|
+
first non-empty collection wins.
|
|
14
|
+
|
|
15
|
+
This is also where Europe PMC PPR records land: EPMC indexes preprints with
|
|
16
|
+
inEPMC=Y and a populated fullTextIdList but 404s on fullTextXML, so the flags
|
|
17
|
+
cannot be used and the publisher's own API is the real route.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from ..artifact import Artifact
|
|
23
|
+
from ..tiers import Tier
|
|
24
|
+
|
|
25
|
+
DETAILS = "https://api.biorxiv.org/details/{server}/{doi}"
|
|
26
|
+
_SERVERS = ("biorxiv", "medrxiv")
|
|
27
|
+
_PREPRINT_PREFIX = "10.1101/"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch_preprint_jats(ids, ctx) -> Optional[Artifact]:
|
|
31
|
+
doi = (ids.doi or "").lower()
|
|
32
|
+
is_preprint = doi.startswith(_PREPRINT_PREFIX) or bool(ids.ppr_id)
|
|
33
|
+
if not is_preprint:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
record = None
|
|
37
|
+
for server in _order_servers(ids.preprint_server):
|
|
38
|
+
record = _latest_version(ctx, server, ids.doi)
|
|
39
|
+
if record:
|
|
40
|
+
ids.learn(
|
|
41
|
+
"biorxiv.details",
|
|
42
|
+
url=DETAILS.format(server=server, doi=ids.doi),
|
|
43
|
+
note="preprint server identified",
|
|
44
|
+
preprint_server=server,
|
|
45
|
+
license=record.get("license"),
|
|
46
|
+
)
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not record:
|
|
50
|
+
ctx.log(" preprint: no record on either bioRxiv or medRxiv")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
jats_url = record.get("jatsxml")
|
|
54
|
+
if not jats_url:
|
|
55
|
+
ctx.log(" preprint: record has no jatsxml field")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
response = ctx.http.get(jats_url, timeout=45)
|
|
59
|
+
if not response.ok or not response.content:
|
|
60
|
+
ctx.log(f" preprint: jatsxml HTTP {response.status}")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return Artifact(
|
|
64
|
+
content=response.content,
|
|
65
|
+
tier=Tier.T1_XML,
|
|
66
|
+
source="preprint_jats",
|
|
67
|
+
url=response.url,
|
|
68
|
+
http_status=response.status,
|
|
69
|
+
served_content_type=response.content_type,
|
|
70
|
+
identifier_used=ids.doi,
|
|
71
|
+
license=record.get("license"),
|
|
72
|
+
extra={"version": record.get("version"), "server": record.get("server")},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _order_servers(known: Optional[str]):
|
|
77
|
+
"""Try the known server first; 'unknown' means the DOI prefix told us nothing."""
|
|
78
|
+
if known in _SERVERS:
|
|
79
|
+
return (known,) + tuple(s for s in _SERVERS if s != known)
|
|
80
|
+
return _SERVERS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _latest_version(ctx, server: str, doi: str) -> Optional[dict]:
|
|
84
|
+
"""The highest-version record, or None for the empty-collection miss."""
|
|
85
|
+
response = ctx.http.get(DETAILS.format(server=server, doi=doi), timeout=25)
|
|
86
|
+
if not response.ok:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
collection = (response.json() or {}).get("collection") or []
|
|
90
|
+
except ValueError:
|
|
91
|
+
return None
|
|
92
|
+
if not collection:
|
|
93
|
+
return None
|
|
94
|
+
return max(collection, key=lambda r: _version_of(r))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _version_of(record: dict) -> int:
|
|
98
|
+
try:
|
|
99
|
+
return int(record.get("version") or 0)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return 0
|