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