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,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