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,149 @@
|
|
|
1
|
+
"""The identifier set built by phase 1, and the audit trail of how it was built.
|
|
2
|
+
|
|
3
|
+
Resolution is separated from retrieval because a PMCID discovered via the ID
|
|
4
|
+
Converter is what unlocks the entire T1 tier -- and on a real clinical corpus it
|
|
5
|
+
does so for roughly 40% of records without touching a single quota-limited API.
|
|
6
|
+
Interleaving resolution with retrieval per source throws that away: you cannot
|
|
7
|
+
short-circuit on an identifier you have not looked up yet.
|
|
8
|
+
|
|
9
|
+
IdentifierSet is mutable rather than frozen. Resolution is incremental by nature
|
|
10
|
+
(stage 1 yields a PMCID, stage 3 may yield an arXiv id that feeds a second
|
|
11
|
+
lookup), and threading dataclasses.replace() through five resolvers obscures
|
|
12
|
+
what is actually happening. Mutation goes through learn(), which records it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ResolutionStep:
|
|
22
|
+
"""One resolution attempt, recorded whether or not it yielded anything."""
|
|
23
|
+
|
|
24
|
+
step: str # "crossref", "idconv", "openalex", ...
|
|
25
|
+
url: Optional[str] # redacted before it is ever persisted
|
|
26
|
+
http_status: Optional[int]
|
|
27
|
+
yielded: Dict[str, str] # {"pmcid": "PMC123"} -- empty dict on a miss
|
|
28
|
+
note: str = ""
|
|
29
|
+
timestamp: float = field(default_factory=time.time)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"step": self.step,
|
|
34
|
+
"url": self.url,
|
|
35
|
+
"http_status": self.http_status,
|
|
36
|
+
"yielded": dict(self.yielded),
|
|
37
|
+
"note": self.note,
|
|
38
|
+
"timestamp": self.timestamp,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResolutionChain:
|
|
43
|
+
"""Ordered record of every resolution attempt for one record.
|
|
44
|
+
|
|
45
|
+
Built as resolution happens rather than reconstructed afterwards: the
|
|
46
|
+
provenance requirement is to explain why a given number came from a given
|
|
47
|
+
file, and "which lookup produced the identifier we retrieved by" is half of
|
|
48
|
+
that answer.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self.steps: List[ResolutionStep] = []
|
|
53
|
+
|
|
54
|
+
def record(self, step, url=None, http_status=None, yielded=None, note=""):
|
|
55
|
+
self.steps.append(
|
|
56
|
+
ResolutionStep(
|
|
57
|
+
step=step,
|
|
58
|
+
url=url,
|
|
59
|
+
http_status=http_status,
|
|
60
|
+
yielded=dict(yielded or {}),
|
|
61
|
+
note=note,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def to_list(self) -> List[dict]:
|
|
66
|
+
return [s.to_dict() for s in self.steps]
|
|
67
|
+
|
|
68
|
+
def __len__(self):
|
|
69
|
+
return len(self.steps)
|
|
70
|
+
|
|
71
|
+
def __repr__(self):
|
|
72
|
+
return f"<ResolutionChain {len(self.steps)} steps>"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
#: Fields a source may declare in ladder.json under "requires".
|
|
76
|
+
IDENTIFIER_FIELDS = (
|
|
77
|
+
"doi",
|
|
78
|
+
"pmid",
|
|
79
|
+
"pmcid",
|
|
80
|
+
"openalex_id",
|
|
81
|
+
"arxiv_id",
|
|
82
|
+
"ppr_id",
|
|
83
|
+
"elsevier_pii",
|
|
84
|
+
"repository_id",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class IdentifierSet:
|
|
90
|
+
"""Everything phase 1 managed to learn about one record."""
|
|
91
|
+
|
|
92
|
+
doi: Optional[str] = None
|
|
93
|
+
pmid: Optional[str] = None
|
|
94
|
+
pmcid: Optional[str] = None
|
|
95
|
+
openalex_id: Optional[str] = None
|
|
96
|
+
arxiv_id: Optional[str] = None
|
|
97
|
+
ppr_id: Optional[str] = None # Europe PMC preprint id (PPR…)
|
|
98
|
+
elsevier_pii: Optional[str] = None
|
|
99
|
+
repository_id: Optional[str] = None # figshare/zenodo/OSF record id
|
|
100
|
+
|
|
101
|
+
# Context that is not an identifier but gates later stages.
|
|
102
|
+
publisher_prefix: Optional[str] = None # DOI registrant prefix, e.g. "10.1016"
|
|
103
|
+
preprint_server: Optional[str] = None # "biorxiv" | "medrxiv"
|
|
104
|
+
license: Optional[str] = None
|
|
105
|
+
#: Europe PMC availability flags (inEPMC, isOpenAccess, hasPDF, ...), exactly
|
|
106
|
+
#: what the name says. It used to double as a general scratch bag holding
|
|
107
|
+
#: memoized Crossref and Unpaywall payloads, which made the name a lie about
|
|
108
|
+
#: the contents; those live in `memo` now.
|
|
109
|
+
epmc_flags: Dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
#: Memoized API payloads, so two consumers of the same record share one fetch.
|
|
112
|
+
#: Keyed by source name ("crossref", "unpaywall").
|
|
113
|
+
memo: Dict[str, Any] = field(default_factory=dict)
|
|
114
|
+
crossref_links: List[dict] = field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
chain: ResolutionChain = field(default_factory=ResolutionChain)
|
|
117
|
+
|
|
118
|
+
def learn(self, step: str, url=None, http_status=None, note="", **values) -> Dict[str, str]:
|
|
119
|
+
"""Set any identifier fields not already known, and record the attempt.
|
|
120
|
+
|
|
121
|
+
Never overwrites a value that is already set: the first resolver to find
|
|
122
|
+
an identifier wins, so a later, less trustworthy source cannot quietly
|
|
123
|
+
redirect retrieval to a different paper.
|
|
124
|
+
"""
|
|
125
|
+
gained = {}
|
|
126
|
+
for key, value in values.items():
|
|
127
|
+
if value in (None, "", []):
|
|
128
|
+
continue
|
|
129
|
+
if not hasattr(self, key):
|
|
130
|
+
raise AttributeError(f"IdentifierSet has no field {key!r}")
|
|
131
|
+
if getattr(self, key):
|
|
132
|
+
continue
|
|
133
|
+
setattr(self, key, value)
|
|
134
|
+
if isinstance(value, (str, int)):
|
|
135
|
+
gained[key] = str(value)
|
|
136
|
+
self.chain.record(step, url=url, http_status=http_status, yielded=gained, note=note)
|
|
137
|
+
return gained
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def best_id(self) -> Optional[str]:
|
|
141
|
+
"""The identifier a human would use to name this record."""
|
|
142
|
+
return self.doi or self.pmcid or self.pmid or self.arxiv_id
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> dict:
|
|
145
|
+
return {
|
|
146
|
+
f: getattr(self, f)
|
|
147
|
+
for f in IDENTIFIER_FIELDS
|
|
148
|
+
if getattr(self, f)
|
|
149
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"_comment": [
|
|
4
|
+
"Tier ladder and per-source capability map. Reorder here rather than in code.",
|
|
5
|
+
"Override wholesale with FETCHPDF_LADDER=/path/to/your.json.",
|
|
6
|
+
"Tier names must match fetchpdf.retrieval.tiers.Tier members.",
|
|
7
|
+
"'requires' lists IdentifierSet fields that must be non-empty for the source",
|
|
8
|
+
"to be attempted at all -- this is what keeps quota-limited calls off records",
|
|
9
|
+
"that could never have used them.",
|
|
10
|
+
"No 'host' key on sources: rate limiting buckets on the actual URL host inside HttpClient, so a per-source host was inert config that looked like a knob. Adjust throttling in rate_limits below, keyed by real hostname."
|
|
11
|
+
],
|
|
12
|
+
"ladders": {
|
|
13
|
+
"extraction": [
|
|
14
|
+
"T1_XML",
|
|
15
|
+
"T2_HTML",
|
|
16
|
+
"T3_SOURCE",
|
|
17
|
+
"T4_SUPPLEMENT",
|
|
18
|
+
"T5_PDF",
|
|
19
|
+
"T7_LANDING"
|
|
20
|
+
],
|
|
21
|
+
"screening": [
|
|
22
|
+
"T1_XML",
|
|
23
|
+
"T2_HTML",
|
|
24
|
+
"T3_SOURCE",
|
|
25
|
+
"T6_PLAINTEXT",
|
|
26
|
+
"T5_PDF",
|
|
27
|
+
"T7_LANDING"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"_ladders_comment": [
|
|
31
|
+
"T6 plain text is 4th for screening and ABSENT for extraction. Flattened text",
|
|
32
|
+
"keeps every number but anchors none of them to a row or column, so an",
|
|
33
|
+
"extraction model produces confident, plausible, wrong arm-to-outcome",
|
|
34
|
+
"assignments. That is worse than a missing table, so extraction refuses it",
|
|
35
|
+
"outright rather than ranking it last and degrading into it."
|
|
36
|
+
],
|
|
37
|
+
"tier_sources": {
|
|
38
|
+
"T1_XML": [
|
|
39
|
+
"europepmc_xml",
|
|
40
|
+
"preprint_jats",
|
|
41
|
+
"pmc_efetch",
|
|
42
|
+
"elsevier_tdm",
|
|
43
|
+
"crossref_tdm"
|
|
44
|
+
],
|
|
45
|
+
"T2_HTML": [
|
|
46
|
+
"unpaywall_html",
|
|
47
|
+
"publisher_html",
|
|
48
|
+
"arxiv_html"
|
|
49
|
+
],
|
|
50
|
+
"T3_SOURCE": [
|
|
51
|
+
"arxiv_eprint"
|
|
52
|
+
],
|
|
53
|
+
"T4_SUPPLEMENT": [
|
|
54
|
+
"europepmc_supplements",
|
|
55
|
+
"figshare_files",
|
|
56
|
+
"zenodo_files",
|
|
57
|
+
"osf_files"
|
|
58
|
+
],
|
|
59
|
+
"T5_PDF": [
|
|
60
|
+
"legacy_pdf_chain",
|
|
61
|
+
"elsevier_pdf"
|
|
62
|
+
],
|
|
63
|
+
"T6_PLAINTEXT": [
|
|
64
|
+
"core_fulltext"
|
|
65
|
+
],
|
|
66
|
+
"T7_LANDING": [
|
|
67
|
+
"landing_page"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"sources": {
|
|
71
|
+
"europepmc_xml": {
|
|
72
|
+
"callable": "epmc:fetch_fulltext_xml",
|
|
73
|
+
"requires": [
|
|
74
|
+
"pmcid"
|
|
75
|
+
],
|
|
76
|
+
"notes": "Highest-value T1 route. Gated on search flags, but tolerates 404 anyway: ~12% of records with a PMCID are not in the EPMC OA subset."
|
|
77
|
+
},
|
|
78
|
+
"preprint_jats": {
|
|
79
|
+
"callable": "preprint:fetch_preprint_jats",
|
|
80
|
+
"requires": [
|
|
81
|
+
"doi"
|
|
82
|
+
],
|
|
83
|
+
"notes": "bioRxiv/medRxiv JATS. Also the route for EPMC PPR records, whose availability flags say full text exists when it does not."
|
|
84
|
+
},
|
|
85
|
+
"pmc_efetch": {
|
|
86
|
+
"callable": "pmc:fetch_efetch_xml",
|
|
87
|
+
"requires": [
|
|
88
|
+
"pmcid"
|
|
89
|
+
],
|
|
90
|
+
"notes": "FALLBACK ONLY -- same underlying PMC JATS as Europe PMC. Never stored alongside it; two copies of one source is not corroboration."
|
|
91
|
+
},
|
|
92
|
+
"elsevier_tdm": {
|
|
93
|
+
"callable": "crossref_tdm:fetch_elsevier_tdm",
|
|
94
|
+
"requires": [
|
|
95
|
+
"elsevier_pii"
|
|
96
|
+
],
|
|
97
|
+
"notes": "Entitlement- and IP-bound. Non-entitled requests return 200 with an error payload, so the validator, not the status code, decides."
|
|
98
|
+
},
|
|
99
|
+
"crossref_tdm": {
|
|
100
|
+
"callable": "crossref_tdm:fetch_crossref_tdm",
|
|
101
|
+
"requires": [
|
|
102
|
+
"doi"
|
|
103
|
+
],
|
|
104
|
+
"notes": "link[] with content-type text/xml AND intended-application text-mining. Crossref retired click-through tokens in Dec 2020, so 401/403 is the expected case, not an error."
|
|
105
|
+
},
|
|
106
|
+
"unpaywall_html": {
|
|
107
|
+
"callable": "publisher_html:fetch_unpaywall_fulltext",
|
|
108
|
+
"requires": [
|
|
109
|
+
"doi"
|
|
110
|
+
],
|
|
111
|
+
"notes": "oa_locations[].url is sometimes XML or HTML full text rather than a PDF. publishedVersion preferred over acceptedVersion."
|
|
112
|
+
},
|
|
113
|
+
"publisher_html": {
|
|
114
|
+
"callable": "publisher_html:fetch_publisher_html",
|
|
115
|
+
"requires": [
|
|
116
|
+
"doi"
|
|
117
|
+
],
|
|
118
|
+
"notes": "DOI resolution to publisher full text. At most OA publishers this HTML is generated from the same JATS as the XML endpoint."
|
|
119
|
+
},
|
|
120
|
+
"arxiv_html": {
|
|
121
|
+
"callable": "arxiv:fetch_latexml_html",
|
|
122
|
+
"requires": [
|
|
123
|
+
"arxiv_id"
|
|
124
|
+
],
|
|
125
|
+
"notes": "Official arxiv.org/html covers ~Dec 2023 onward; ar5iv covers the back catalogue to 2007 (verified: 0704.0001 -> 200, 35 tables). Enabled because between them they cover effectively the whole corpus. Machine conversion, so the caveat is recorded in provenance."
|
|
126
|
+
},
|
|
127
|
+
"arxiv_eprint": {
|
|
128
|
+
"callable": "arxiv:fetch_eprint_source",
|
|
129
|
+
"requires": [
|
|
130
|
+
"arxiv_id"
|
|
131
|
+
],
|
|
132
|
+
"notes": "LaTeX source tarball. arXiv tightened rate limits in Feb 2026 -- this host gets its own slow bucket."
|
|
133
|
+
},
|
|
134
|
+
"europepmc_supplements": {
|
|
135
|
+
"callable": "supplements:fetch_epmc_supplements",
|
|
136
|
+
"requires": [
|
|
137
|
+
"pmcid"
|
|
138
|
+
],
|
|
139
|
+
"notes": "Keyed on the PMCID already resolved in phase 1, so cheaper than enumerating repositories."
|
|
140
|
+
},
|
|
141
|
+
"figshare_files": {
|
|
142
|
+
"callable": "supplements:fetch_figshare_files",
|
|
143
|
+
"requires": [
|
|
144
|
+
"doi"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"zenodo_files": {
|
|
148
|
+
"callable": "supplements:fetch_zenodo_files",
|
|
149
|
+
"requires": [
|
|
150
|
+
"doi"
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
"osf_files": {
|
|
154
|
+
"callable": "supplements:fetch_osf_files",
|
|
155
|
+
"requires": [
|
|
156
|
+
"doi"
|
|
157
|
+
]
|
|
158
|
+
},
|
|
159
|
+
"legacy_pdf_chain": {
|
|
160
|
+
"callable": "legacy_pdf:fetch_via_legacy_chain",
|
|
161
|
+
"requires": [
|
|
162
|
+
"doi"
|
|
163
|
+
],
|
|
164
|
+
"notes": "One call into the existing chain in fetch_pdf_from_doi. Preserves its retries, backoff and Playwright paths by construction rather than by duplication."
|
|
165
|
+
},
|
|
166
|
+
"elsevier_pdf": {
|
|
167
|
+
"callable": "crossref_tdm:fetch_elsevier_pdf",
|
|
168
|
+
"requires": [
|
|
169
|
+
"elsevier_pii"
|
|
170
|
+
],
|
|
171
|
+
"notes": "Same endpoint as elsevier_tdm, asked with Accept: application/pdf. Entitlement is per-representation at Elsevier, so a record whose XML view is coredata-only or a 400-char corrigendum often still serves the full typeset PDF. Ordered after legacy_pdf_chain because the chain's routes cost no Elsevier quota."
|
|
172
|
+
},
|
|
173
|
+
"core_fulltext": {
|
|
174
|
+
"callable": "core_text:fetch_core_fulltext",
|
|
175
|
+
"requires": [
|
|
176
|
+
"doi"
|
|
177
|
+
],
|
|
178
|
+
"notes": "Always stamped tables_unavailable=true. Refuses to return anything when target_task is extraction."
|
|
179
|
+
},
|
|
180
|
+
"landing_page": {
|
|
181
|
+
"callable": "landing:fetch_landing_page",
|
|
182
|
+
"requires": [
|
|
183
|
+
"doi"
|
|
184
|
+
],
|
|
185
|
+
"notes": "Last resort. Recorded so a record that produced nothing usable is distinguishable from one never attempted."
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"rate_limits": {
|
|
189
|
+
"_comment": "Per-host, never global: configured limits differ by more than an order of magnitude across these APIs. rps_keyed applies when key_env is set in the environment.",
|
|
190
|
+
"eutils.ncbi.nlm.nih.gov": {
|
|
191
|
+
"rps": 3,
|
|
192
|
+
"rps_keyed": 10,
|
|
193
|
+
"key_env": "ENTREZ_EUTILS_API_KEY",
|
|
194
|
+
"burst": 3
|
|
195
|
+
},
|
|
196
|
+
"pmc.ncbi.nlm.nih.gov": {
|
|
197
|
+
"rps": 3,
|
|
198
|
+
"rps_keyed": 10,
|
|
199
|
+
"key_env": "ENTREZ_EUTILS_API_KEY",
|
|
200
|
+
"burst": 3
|
|
201
|
+
},
|
|
202
|
+
"www.ncbi.nlm.nih.gov": {
|
|
203
|
+
"rps": 3,
|
|
204
|
+
"rps_keyed": 10,
|
|
205
|
+
"key_env": "ENTREZ_EUTILS_API_KEY",
|
|
206
|
+
"burst": 3
|
|
207
|
+
},
|
|
208
|
+
"www.ebi.ac.uk": {
|
|
209
|
+
"rps": 5,
|
|
210
|
+
"burst": 5
|
|
211
|
+
},
|
|
212
|
+
"api.crossref.org": {
|
|
213
|
+
"rps": 10,
|
|
214
|
+
"burst": 10
|
|
215
|
+
},
|
|
216
|
+
"api.semanticscholar.org": {
|
|
217
|
+
"rps": 1,
|
|
218
|
+
"rps_keyed": 10,
|
|
219
|
+
"key_env": "SEMANTIC_SCHOLAR_API_KEY",
|
|
220
|
+
"burst": 1
|
|
221
|
+
},
|
|
222
|
+
"api.unpaywall.org": {
|
|
223
|
+
"rps": 5,
|
|
224
|
+
"burst": 5
|
|
225
|
+
},
|
|
226
|
+
"api.openalex.org": {
|
|
227
|
+
"rps": 5,
|
|
228
|
+
"burst": 5
|
|
229
|
+
},
|
|
230
|
+
"api.elsevier.com": {
|
|
231
|
+
"rps": 2,
|
|
232
|
+
"burst": 2
|
|
233
|
+
},
|
|
234
|
+
"api.datacite.org": {
|
|
235
|
+
"rps": 3,
|
|
236
|
+
"burst": 3
|
|
237
|
+
},
|
|
238
|
+
"api.scholexplorer.openaire.eu": {
|
|
239
|
+
"rps": 3,
|
|
240
|
+
"burst": 3
|
|
241
|
+
},
|
|
242
|
+
"api.core.ac.uk": {
|
|
243
|
+
"rps": 2,
|
|
244
|
+
"burst": 2
|
|
245
|
+
},
|
|
246
|
+
"api.biorxiv.org": {
|
|
247
|
+
"rps": 2,
|
|
248
|
+
"burst": 2
|
|
249
|
+
},
|
|
250
|
+
"export.arxiv.org": {
|
|
251
|
+
"rps": 0.34,
|
|
252
|
+
"burst": 1
|
|
253
|
+
},
|
|
254
|
+
"arxiv.org": {
|
|
255
|
+
"rps": 0.34,
|
|
256
|
+
"burst": 1
|
|
257
|
+
},
|
|
258
|
+
"ar5iv.labs.arxiv.org": {
|
|
259
|
+
"rps": 0.5,
|
|
260
|
+
"burst": 1
|
|
261
|
+
},
|
|
262
|
+
"api.figshare.com": {
|
|
263
|
+
"rps": 2,
|
|
264
|
+
"burst": 2
|
|
265
|
+
},
|
|
266
|
+
"zenodo.org": {
|
|
267
|
+
"rps": 2,
|
|
268
|
+
"burst": 2
|
|
269
|
+
},
|
|
270
|
+
"api.osf.io": {
|
|
271
|
+
"rps": 2,
|
|
272
|
+
"burst": 2
|
|
273
|
+
},
|
|
274
|
+
"doi.org": {
|
|
275
|
+
"rps": 5,
|
|
276
|
+
"burst": 5
|
|
277
|
+
},
|
|
278
|
+
"_supplementary_comment": [
|
|
279
|
+
"--pull-supplementary fetches files rather than metadata, so it talks to a",
|
|
280
|
+
"second set of hosts: the CDNs and file endpoints the listing APIs point at.",
|
|
281
|
+
"Without entries here they all fall to _default, which is safe but treats a",
|
|
282
|
+
"publisher CDN and a scraped article page as equally robust. S3 is fast and",
|
|
283
|
+
"does not care; article pages and supp.apa.org are scraped one at a time."
|
|
284
|
+
],
|
|
285
|
+
"pmc-oa-opendata.s3.amazonaws.com": {
|
|
286
|
+
"rps": 10,
|
|
287
|
+
"burst": 10
|
|
288
|
+
},
|
|
289
|
+
"ndownloader.figshare.com": {
|
|
290
|
+
"rps": 2,
|
|
291
|
+
"burst": 2
|
|
292
|
+
},
|
|
293
|
+
"files.osf.io": {
|
|
294
|
+
"rps": 2,
|
|
295
|
+
"burst": 2
|
|
296
|
+
},
|
|
297
|
+
"osf.io": {
|
|
298
|
+
"rps": 2,
|
|
299
|
+
"burst": 2
|
|
300
|
+
},
|
|
301
|
+
"datadryad.org": {
|
|
302
|
+
"rps": 2,
|
|
303
|
+
"burst": 2
|
|
304
|
+
},
|
|
305
|
+
"dataverse.harvard.edu": {
|
|
306
|
+
"rps": 2,
|
|
307
|
+
"burst": 2
|
|
308
|
+
},
|
|
309
|
+
"journals.plos.org": {
|
|
310
|
+
"rps": 2,
|
|
311
|
+
"burst": 2
|
|
312
|
+
},
|
|
313
|
+
"media.springernature.com": {
|
|
314
|
+
"rps": 2,
|
|
315
|
+
"burst": 2
|
|
316
|
+
},
|
|
317
|
+
"static-content.springer.com": {
|
|
318
|
+
"rps": 2,
|
|
319
|
+
"burst": 2
|
|
320
|
+
},
|
|
321
|
+
"ars.els-cdn.com": {
|
|
322
|
+
"rps": 2,
|
|
323
|
+
"burst": 2
|
|
324
|
+
},
|
|
325
|
+
"www.nature.com": {
|
|
326
|
+
"rps": 1,
|
|
327
|
+
"burst": 1
|
|
328
|
+
},
|
|
329
|
+
"link.springer.com": {
|
|
330
|
+
"rps": 1,
|
|
331
|
+
"burst": 1
|
|
332
|
+
},
|
|
333
|
+
"www.biorxiv.org": {
|
|
334
|
+
"rps": 1,
|
|
335
|
+
"burst": 1
|
|
336
|
+
},
|
|
337
|
+
"www.medrxiv.org": {
|
|
338
|
+
"rps": 1,
|
|
339
|
+
"burst": 1
|
|
340
|
+
},
|
|
341
|
+
"supp.apa.org": {
|
|
342
|
+
"rps": 1,
|
|
343
|
+
"burst": 1
|
|
344
|
+
},
|
|
345
|
+
"academic.oup.com": {
|
|
346
|
+
"rps": 1,
|
|
347
|
+
"burst": 1
|
|
348
|
+
},
|
|
349
|
+
"_default": {
|
|
350
|
+
"rps": 3,
|
|
351
|
+
"burst": 3
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
"thresholds": {
|
|
355
|
+
"_comment": "Character counts are of extracted body text, not raw bytes.",
|
|
356
|
+
"t1_min_chars": 5000,
|
|
357
|
+
"t2_min_chars": 5000,
|
|
358
|
+
"abstract_stub_chars": 2500,
|
|
359
|
+
"min_populated_td": 1,
|
|
360
|
+
"idconv_chunk_size": 200
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The record of what datasets and code the citation graph links to this paper.
|
|
2
|
+
|
|
3
|
+
The supplement providers download files; this sidecar records *links* -- every
|
|
4
|
+
publication->dataset and publication->software relation the link services
|
|
5
|
+
returned, whether or not it resolved to files. The two facts are different and
|
|
6
|
+
both matter downstream: a Dryad DOI with no enumerator, a trial registration,
|
|
7
|
+
and an "xgboost software on GitHub" tool citation all belong here even though
|
|
8
|
+
none of them lands on disk.
|
|
9
|
+
|
|
10
|
+
Empty results are recorded explicitly. On the corpora this was measured against
|
|
11
|
+
(2026-08), most papers have zero links -- ~30% of a recent biomedical batch had
|
|
12
|
+
any ScholeXplorer link, and an older clinical-trial corpus had none at all --
|
|
13
|
+
so "queried, nothing found" is the common case, and it must stay
|
|
14
|
+
distinguishable from "never queried".
|
|
15
|
+
|
|
16
|
+
The classification vocabulary, coarsest that survives contact with real links:
|
|
17
|
+
|
|
18
|
+
* owned -- deposited as the paper's own material (IsSupplementTo
|
|
19
|
+
and friends), or reached through a data DOI.
|
|
20
|
+
* related -- a dataset/software record linked to the paper without a
|
|
21
|
+
supplement-grade relation. Most Scholix "cites" links.
|
|
22
|
+
* tool_citation -- a software record that is somebody's tool, not this
|
|
23
|
+
paper's code. OpenAIRE mints these for every R package a
|
|
24
|
+
paper cites; they dominate the software links.
|
|
25
|
+
* registry -- a registration, not an artifact: ClinicalTrials.gov and
|
|
26
|
+
the like. Never downloadable, always worth recording.
|
|
27
|
+
|
|
28
|
+
Providers append to ctx.scratch as they run; pull_for_record writes the
|
|
29
|
+
sidecar once, after the manifest. No scratch entry, no sidecar -- a run with a
|
|
30
|
+
restricted provider list must not write a file claiming services were asked
|
|
31
|
+
when they were not.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
_SCHEMA_VERSION = 1
|
|
41
|
+
|
|
42
|
+
#: The sidecar lands beside the manifest, keyed on the same stem.
|
|
43
|
+
SIDECAR_SUFFIX = "_linked_artifacts.json"
|
|
44
|
+
|
|
45
|
+
#: Where providers accumulate their queries and links between enumeration and
|
|
46
|
+
#: the write. One key, holding {"queried": [...], "links": [...]}.
|
|
47
|
+
SCRATCH_KEY = "linked_artifacts"
|
|
48
|
+
|
|
49
|
+
CLASS_OWNED = "owned"
|
|
50
|
+
CLASS_RELATED = "related"
|
|
51
|
+
CLASS_TOOL_CITATION = "tool_citation"
|
|
52
|
+
CLASS_REGISTRY = "registry"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bucket(ctx) -> dict:
|
|
56
|
+
return ctx.scratch.setdefault(SCRATCH_KEY, {"queried": [], "links": []})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def record_query(ctx, service: str, url: str, status: str, detail: str = "") -> None:
|
|
60
|
+
"""One service asked one question. `url` must already be redacted."""
|
|
61
|
+
_bucket(ctx)["queried"].append({
|
|
62
|
+
"service": service,
|
|
63
|
+
"url": url,
|
|
64
|
+
"status": status,
|
|
65
|
+
"detail": detail,
|
|
66
|
+
"retrieved_at": _iso(time.time()),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def record_link(ctx, *, service: str, target_pid: Optional[str], target_type: str,
|
|
71
|
+
relation: str, classified: str, title: str = "",
|
|
72
|
+
publisher: str = "", provider_name: str = "",
|
|
73
|
+
harvest_date: str = "", routed: bool = False) -> None:
|
|
74
|
+
"""One link the service asserted. `routed` means a repository enumerator
|
|
75
|
+
was handed the target to resolve into files."""
|
|
76
|
+
_bucket(ctx)["links"].append({
|
|
77
|
+
"service": service,
|
|
78
|
+
"target_pid": target_pid,
|
|
79
|
+
"target_type": target_type,
|
|
80
|
+
"relation": relation,
|
|
81
|
+
"classified": classified,
|
|
82
|
+
"title": (title or "")[:300],
|
|
83
|
+
"publisher": (publisher or "")[:100],
|
|
84
|
+
"provider_name": provider_name,
|
|
85
|
+
"harvest_date": harvest_date,
|
|
86
|
+
"routed_to_download": routed,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sidecar_path_for(stem: str) -> str:
|
|
91
|
+
return stem + SIDECAR_SUFFIX
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_linked_sidecar(stem: str, ids, ctx, verbose: bool = False) -> Optional[str]:
|
|
95
|
+
"""Write the sidecar atomically. Never raises into the supplementary pass.
|
|
96
|
+
|
|
97
|
+
Returns the path written, or None -- both when writing failed and when
|
|
98
|
+
there was nothing to write because no link service ran.
|
|
99
|
+
"""
|
|
100
|
+
bucket = ctx.scratch.get(SCRATCH_KEY)
|
|
101
|
+
if not bucket:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
links = bucket["links"]
|
|
105
|
+
record = {
|
|
106
|
+
"schema_version": _SCHEMA_VERSION,
|
|
107
|
+
"identifier": str(getattr(ids, "doi", "") or getattr(ids, "best_id", "") or ""),
|
|
108
|
+
"identifiers_resolved": ids.to_dict() if hasattr(ids, "to_dict") else {},
|
|
109
|
+
"stem": os.path.basename(stem),
|
|
110
|
+
"completed_at": _iso(time.time()),
|
|
111
|
+
"queried": bucket["queried"],
|
|
112
|
+
"links": links,
|
|
113
|
+
"counts": {
|
|
114
|
+
"links": len(links),
|
|
115
|
+
"routed_to_download": sum(1 for l in links if l.get("routed_to_download")),
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
path = sidecar_path_for(stem)
|
|
120
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
121
|
+
try:
|
|
122
|
+
os.makedirs(directory, exist_ok=True)
|
|
123
|
+
handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-la-", dir=directory)
|
|
124
|
+
try:
|
|
125
|
+
with os.fdopen(handle, "w", encoding="utf-8") as f:
|
|
126
|
+
json.dump(record, f, indent=2, ensure_ascii=False)
|
|
127
|
+
os.replace(temp_path, path)
|
|
128
|
+
except BaseException:
|
|
129
|
+
try:
|
|
130
|
+
os.unlink(temp_path)
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
raise
|
|
134
|
+
except Exception as e:
|
|
135
|
+
if verbose:
|
|
136
|
+
print(f" ⚠️ could not write {os.path.basename(path)}: {e}")
|
|
137
|
+
return None
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _iso(timestamp: float) -> str:
|
|
142
|
+
from datetime import datetime, timezone
|
|
143
|
+
|
|
144
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|