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,820 @@
|
|
|
1
|
+
"""What supplementary files exist for a record, before anything is fetched.
|
|
2
|
+
|
|
3
|
+
Pure enumeration: every function here asks a provider what it has and returns a
|
|
4
|
+
list of SupplementFile. Nothing is downloaded, nothing is written, nothing is
|
|
5
|
+
filtered on desirability. Two callers consume it and they want different subsets,
|
|
6
|
+
so the filtering lives with them rather than here:
|
|
7
|
+
|
|
8
|
+
* T4_SUPPLEMENT (sources/supplements.py) wants only machine-readable data,
|
|
9
|
+
because there a supplement stands *in place of* the paper's full text and a
|
|
10
|
+
supplementary PDF is no better than the PDF it was standing in for.
|
|
11
|
+
* --pull-supplementary (supplementary.py) wants everything, because there a
|
|
12
|
+
supplement stands *alongside* the paper and the caller asked for the lot.
|
|
13
|
+
|
|
14
|
+
Keeping enumeration in one place is what stops those two from drifting: the
|
|
15
|
+
repository listings were already being fetched twice with two different
|
|
16
|
+
predicates applied to them, once in the legacy chain and once at T4.
|
|
17
|
+
|
|
18
|
+
Archives are enumerated as archives, not expanded here. A zip's central
|
|
19
|
+
directory is at the end of the file, so its members are unknowable until the
|
|
20
|
+
whole thing has landed -- which is a decision about byte budgets, and belongs
|
|
21
|
+
with the caller that owns the cap.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
FIGSHARE_FILES = "https://api.figshare.com/v2/articles/{article_id}/files"
|
|
29
|
+
FIGSHARE_ARTICLE = "https://api.figshare.com/v2/articles/{article_id}"
|
|
30
|
+
ZENODO_RECORD = "https://zenodo.org/api/records/{record_id}"
|
|
31
|
+
OSF_GUID = "https://api.osf.io/v2/guids/{osf_id}/"
|
|
32
|
+
EPMC_SUPPL = "https://www.ebi.ac.uk/europepmc/webservices/rest/{pmcid}/supplementaryFiles"
|
|
33
|
+
DRYAD_BASE = "https://datadryad.org"
|
|
34
|
+
DRYAD_DATASET = DRYAD_BASE + "/api/v2/datasets/{encoded_doi}"
|
|
35
|
+
DATAVERSE_DATASET = ("https://dataverse.harvard.edu/api/datasets/:persistentId/"
|
|
36
|
+
"?persistentId=doi:{doi}")
|
|
37
|
+
DATAVERSE_FILE = "https://dataverse.harvard.edu/api/access/datafile/{file_id}"
|
|
38
|
+
|
|
39
|
+
#: Dryad file listings paginate; a deposit with more pages than this is a
|
|
40
|
+
#: deposit whose tail we drop rather than walk forever.
|
|
41
|
+
_DRYAD_MAX_PAGES = 10
|
|
42
|
+
|
|
43
|
+
#: Roles. "figure" and "article" are the two things --pull-supplementary drops;
|
|
44
|
+
#: everything else is kept, including "unknown", because the caller asked for as
|
|
45
|
+
#: much as possible and a spurious file costs disk while a dropped one loses data.
|
|
46
|
+
ROLE_SUPPLEMENT = "supplement"
|
|
47
|
+
ROLE_FIGURE = "figure"
|
|
48
|
+
ROLE_ARTICLE = "article"
|
|
49
|
+
ROLE_UNKNOWN = "unknown"
|
|
50
|
+
|
|
51
|
+
#: How deep to follow OSF folders, and how many nodes to visit in total. OSF
|
|
52
|
+
#: projects can nest arbitrarily and a runaway walk would spend a record's whole
|
|
53
|
+
#: request budget on one deposit.
|
|
54
|
+
_OSF_MAX_DEPTH = 4
|
|
55
|
+
_OSF_MAX_NODES = 200
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SupplementFile:
|
|
60
|
+
"""One file a provider says exists, before anything is fetched."""
|
|
61
|
+
|
|
62
|
+
name: str # remote filename as listed
|
|
63
|
+
url: str # direct download URL
|
|
64
|
+
provider: str # "figshare_files", "pmc_s3", ...
|
|
65
|
+
listing_index: int = 0 # position in the provider's listing, as returned
|
|
66
|
+
mimetype: Optional[str] = None # as declared by the listing, if any
|
|
67
|
+
size_bytes: Optional[int] = None # declared; lets the cap refuse before transferring
|
|
68
|
+
checksum: Optional[str] = None # "md5:..." as the source declares it
|
|
69
|
+
role: str = ROLE_UNKNOWN
|
|
70
|
+
label: str = "" # JATS <label>/<caption>, when known
|
|
71
|
+
origin_doi: Optional[str] = None # the DOI this file was enumerated under
|
|
72
|
+
is_archive: bool = False # expand rather than keep (EPMC zip, arXiv tarball)
|
|
73
|
+
polite: bool = False # pass through to HttpClient.download
|
|
74
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def basename(self) -> str:
|
|
78
|
+
"""The listed name with any path or query string removed.
|
|
79
|
+
|
|
80
|
+
Never used to build an output path -- supplementary.py numbers files
|
|
81
|
+
instead -- but it is what dedupe and extension-guessing key on.
|
|
82
|
+
"""
|
|
83
|
+
name = (self.name or "").split("?")[0].replace("\\", "/")
|
|
84
|
+
return name.rsplit("/", 1)[-1].strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -- role heuristics --------------------------------------------------------
|
|
88
|
+
#
|
|
89
|
+
# Only meaningful when the thing being enumerated is the article's own DOI. When
|
|
90
|
+
# a data DOI was reached through DataCite, everything in it is supplementary by
|
|
91
|
+
# construction and these are not consulted.
|
|
92
|
+
|
|
93
|
+
#: Filenames that are the paper itself, as each publisher's deposit names it.
|
|
94
|
+
_ARTICLE_NAME_RE = re.compile(
|
|
95
|
+
r"(?i)^("
|
|
96
|
+
r"(article|paper|manuscript|main[_\- ]?text|full[_\- ]?text|preprint|ms)\.pdf"
|
|
97
|
+
r"|1-s2\.0-S\d+-main\.pdf"
|
|
98
|
+
r"|PMC\d+(\.\d+)?\.pdf"
|
|
99
|
+
r"|s\d{5}-\d{3}-\d{5}-\w\.pdf"
|
|
100
|
+
r")$"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
#: Filenames that are positively supplementary, across the publishers we reach.
|
|
104
|
+
_SUPPLEMENT_NAME_RE = re.compile(
|
|
105
|
+
r"(?i)("
|
|
106
|
+
r"_s\d+|_si_\d+|MOESM\d+|-sup-\d+|^mmc\d+|\.s\d{3}\b"
|
|
107
|
+
r"|suppl|supporting|appendix|additional[_\- ]file"
|
|
108
|
+
r"|(data|table|fig(ure)?|movie|video|dataset)[_\- ]?s\d+"
|
|
109
|
+
r")"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
#: Repository item types that mean "this record *is* the paper".
|
|
113
|
+
_ARTICLE_ITEM_TYPES = {"journal contribution", "preprint", "publication", "journalarticle"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def classify_role(name: Optional[str], item_type: Optional[str] = None,
|
|
117
|
+
jats_supplements=None, jats_figures=None) -> str:
|
|
118
|
+
"""Best guess at whether a listed file is a supplement, a figure or the paper.
|
|
119
|
+
|
|
120
|
+
Ordered cheapest-first and deliberately biased toward ROLE_SUPPLEMENT: the
|
|
121
|
+
caller asked for as much as possible, so ambiguity resolves toward keeping.
|
|
122
|
+
JATS ground truth wins when we have it, because it is the article's own
|
|
123
|
+
statement about its own files rather than a guess at a filename.
|
|
124
|
+
"""
|
|
125
|
+
base = (name or "").split("?")[0].rsplit("/", 1)[-1].strip()
|
|
126
|
+
stem = base.lower()
|
|
127
|
+
|
|
128
|
+
if jats_supplements and _matches_href(stem, jats_supplements):
|
|
129
|
+
return ROLE_SUPPLEMENT
|
|
130
|
+
if jats_figures and _matches_href(stem, jats_figures):
|
|
131
|
+
return ROLE_FIGURE
|
|
132
|
+
|
|
133
|
+
if _ARTICLE_NAME_RE.search(base):
|
|
134
|
+
return ROLE_ARTICLE
|
|
135
|
+
if _SUPPLEMENT_NAME_RE.search(base):
|
|
136
|
+
return ROLE_SUPPLEMENT
|
|
137
|
+
if item_type and str(item_type).strip().lower() in _ARTICLE_ITEM_TYPES:
|
|
138
|
+
# The item is the paper, but a "publication" record still carries its
|
|
139
|
+
# supplements alongside the PDF. Only the PDF itself is the article.
|
|
140
|
+
return ROLE_ARTICLE if stem.endswith(".pdf") else ROLE_UNKNOWN
|
|
141
|
+
return ROLE_UNKNOWN
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def jats_sets(ctx) -> Tuple[set, set]:
|
|
145
|
+
"""The supplement/figure href sets E1 left in ctx.scratch, if it ran.
|
|
146
|
+
|
|
147
|
+
Empty when there was no JATS to read, in which case classify_role falls back
|
|
148
|
+
to its filename heuristics.
|
|
149
|
+
"""
|
|
150
|
+
return (ctx.scratch.get("jats_supplements") or set(),
|
|
151
|
+
ctx.scratch.get("jats_figures") or set())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _matches_href(basename: str, hrefs) -> bool:
|
|
155
|
+
"""True if a filename matches a JATS xlink:href, extension-insensitively.
|
|
156
|
+
|
|
157
|
+
JATS routinely omits the extension -- href "pone.0000308.s001" against a
|
|
158
|
+
blob named "pone.0000308.s001.doc" -- so a bare equality test would miss
|
|
159
|
+
almost every match.
|
|
160
|
+
"""
|
|
161
|
+
if not basename:
|
|
162
|
+
return False
|
|
163
|
+
stem = basename.rsplit(".", 1)[0]
|
|
164
|
+
for href in hrefs:
|
|
165
|
+
other = str(href).split("?")[0].rsplit("/", 1)[-1].strip().lower()
|
|
166
|
+
if not other:
|
|
167
|
+
continue
|
|
168
|
+
if basename == other or stem == other.rsplit(".", 1)[0]:
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- Europe PMC -------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def enumerate_epmc_archive(ids, ctx, include_inline_images: bool = False
|
|
177
|
+
) -> List[SupplementFile]:
|
|
178
|
+
"""The EPMC supplementary bundle, as one archive to expand.
|
|
179
|
+
|
|
180
|
+
Not fetched here: the caller downloads it under its own byte budget and
|
|
181
|
+
expands the members, because a zip's member sizes are only knowable once the
|
|
182
|
+
whole file has landed.
|
|
183
|
+
|
|
184
|
+
includeInlineImage=no is the right default and the difference is not
|
|
185
|
+
cosmetic. Verified on PMC1817752: the default returns 14 members / 132 KB,
|
|
186
|
+
the article's entire media blob set with every figure and table image in it;
|
|
187
|
+
with the flag it returns 4 members / 34 KB, exactly s001-s004. It also turns
|
|
188
|
+
a vague answer into a clean negative -- PMC3339580 gives 404 with the flag
|
|
189
|
+
and 371 KB of figures without it, and that article genuinely has no
|
|
190
|
+
supplementary material (zero <supplementary-material> in its JATS, and its
|
|
191
|
+
S3 media_urls are nine Fig*.jpg).
|
|
192
|
+
|
|
193
|
+
The listing is speculative: the URL is constructed for any PMCID without
|
|
194
|
+
checking that a bundle exists, so the endpoint's "nothing here" answers (a
|
|
195
|
+
bare 404, or HTTP 200 wrapping an <errorBean> stub for non-OA articles) are
|
|
196
|
+
negatives, not losses. extra["speculative"] tells the download side to file
|
|
197
|
+
them under "no-bundle" instead of a lost reason -- on a 99-record corpus the
|
|
198
|
+
old classification turned 40 clean negatives into phantom "partial" statuses.
|
|
199
|
+
"""
|
|
200
|
+
if not ids.pmcid:
|
|
201
|
+
return []
|
|
202
|
+
url = EPMC_SUPPL.format(pmcid=ids.pmcid)
|
|
203
|
+
if not include_inline_images:
|
|
204
|
+
url += "?includeInlineImage=no"
|
|
205
|
+
return [
|
|
206
|
+
SupplementFile(
|
|
207
|
+
name=f"{ids.pmcid}_SupplementaryFiles.zip",
|
|
208
|
+
url=url,
|
|
209
|
+
provider="europepmc_supplements",
|
|
210
|
+
mimetype="application/zip",
|
|
211
|
+
role=ROLE_SUPPLEMENT,
|
|
212
|
+
origin_doi=ids.doi,
|
|
213
|
+
is_archive=True,
|
|
214
|
+
extra={"pmcid": ids.pmcid, "speculative": True},
|
|
215
|
+
)
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# -- Figshare ---------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def enumerate_figshare(ids, ctx, with_item_type: bool = True) -> List[SupplementFile]:
|
|
223
|
+
"""Figshare's file listing.
|
|
224
|
+
|
|
225
|
+
with_item_type costs one extra request and only affects `role`, so T4 -- which
|
|
226
|
+
filters on _is_structured and never reads a role -- turns it off rather than
|
|
227
|
+
paying for it on every figshare record.
|
|
228
|
+
"""
|
|
229
|
+
article_id = match_id(ids.doi, r"figshare\.(\d+)")
|
|
230
|
+
if not article_id:
|
|
231
|
+
return []
|
|
232
|
+
response = ctx.http.get(
|
|
233
|
+
FIGSHARE_FILES.format(article_id=article_id), timeout=30, polite=False
|
|
234
|
+
)
|
|
235
|
+
if not response.ok:
|
|
236
|
+
return []
|
|
237
|
+
try:
|
|
238
|
+
listing = response.json() or []
|
|
239
|
+
except ValueError:
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
item_type = _figshare_item_type(ctx, article_id) if with_item_type else None
|
|
243
|
+
files = []
|
|
244
|
+
for index, entry in enumerate(listing):
|
|
245
|
+
if not isinstance(entry, dict):
|
|
246
|
+
continue
|
|
247
|
+
url = entry.get("download_url")
|
|
248
|
+
if not url:
|
|
249
|
+
continue
|
|
250
|
+
# is_link_only entries are not files: download_url serves the landing
|
|
251
|
+
# page of whatever external resource was linked, as HTML, with a 200.
|
|
252
|
+
if entry.get("is_link_only"):
|
|
253
|
+
continue
|
|
254
|
+
name = entry.get("name") or ""
|
|
255
|
+
checksum = entry.get("supplied_md5") or entry.get("computed_md5")
|
|
256
|
+
files.append(SupplementFile(
|
|
257
|
+
name=name,
|
|
258
|
+
url=url,
|
|
259
|
+
provider="figshare_files",
|
|
260
|
+
listing_index=index,
|
|
261
|
+
mimetype=entry.get("mimetype"),
|
|
262
|
+
size_bytes=_as_int(entry.get("size")),
|
|
263
|
+
checksum=f"md5:{checksum}" if checksum else None,
|
|
264
|
+
role=classify_role(name, item_type),
|
|
265
|
+
origin_doi=ids.doi,
|
|
266
|
+
extra={"figshare_article": article_id},
|
|
267
|
+
))
|
|
268
|
+
return files
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _figshare_item_type(ctx, article_id) -> Optional[str]:
|
|
272
|
+
"""defined_type_name, which says whether the item *is* the paper.
|
|
273
|
+
|
|
274
|
+
One extra request per figshare record; worth it because "journal
|
|
275
|
+
contribution" vs "dataset" settles the article-vs-supplement question that
|
|
276
|
+
filename heuristics only guess at.
|
|
277
|
+
"""
|
|
278
|
+
response = ctx.http.get(
|
|
279
|
+
FIGSHARE_ARTICLE.format(article_id=article_id), timeout=30, polite=False
|
|
280
|
+
)
|
|
281
|
+
if not response.ok:
|
|
282
|
+
return None
|
|
283
|
+
try:
|
|
284
|
+
return ((response.json() or {}).get("defined_type_name")) or None
|
|
285
|
+
except ValueError:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# -- Zenodo -----------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def enumerate_zenodo(ids, ctx) -> List[SupplementFile]:
|
|
293
|
+
record_id = match_id(ids.doi, r"zenodo\.(\d+)")
|
|
294
|
+
if not record_id:
|
|
295
|
+
return []
|
|
296
|
+
response = ctx.http.get(
|
|
297
|
+
ZENODO_RECORD.format(record_id=record_id), timeout=30, polite=False
|
|
298
|
+
)
|
|
299
|
+
if not response.ok:
|
|
300
|
+
return []
|
|
301
|
+
try:
|
|
302
|
+
payload = response.json() or {}
|
|
303
|
+
except ValueError:
|
|
304
|
+
return []
|
|
305
|
+
|
|
306
|
+
item_type = (((payload.get("metadata") or {}).get("resource_type") or {}).get("type"))
|
|
307
|
+
files = []
|
|
308
|
+
for index, entry in enumerate(payload.get("files") or []):
|
|
309
|
+
if not isinstance(entry, dict):
|
|
310
|
+
continue
|
|
311
|
+
# Restricted records list their files with no links at all.
|
|
312
|
+
url = ((entry.get("links") or {}).get("self"))
|
|
313
|
+
if not url:
|
|
314
|
+
continue
|
|
315
|
+
name = entry.get("key") or ""
|
|
316
|
+
files.append(SupplementFile(
|
|
317
|
+
name=name,
|
|
318
|
+
url=url,
|
|
319
|
+
provider="zenodo_files",
|
|
320
|
+
listing_index=index,
|
|
321
|
+
mimetype=entry.get("mimetype") or entry.get("type"),
|
|
322
|
+
size_bytes=_as_int(entry.get("size")),
|
|
323
|
+
checksum=_normalize_checksum(entry.get("checksum")),
|
|
324
|
+
role=classify_role(name, item_type),
|
|
325
|
+
origin_doi=ids.doi,
|
|
326
|
+
extra={"zenodo_record": record_id},
|
|
327
|
+
))
|
|
328
|
+
return files
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# -- OSF --------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def enumerate_osf(ids, ctx) -> List[SupplementFile]:
|
|
335
|
+
osf_id = match_id(ids.doi, r"osf\.io/([a-z0-9]+)") or match_id(ids.doi, r"/([a-z0-9]{5})$")
|
|
336
|
+
if not osf_id:
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
guid = ctx.http.get(OSF_GUID.format(osf_id=osf_id), timeout=30, polite=False)
|
|
340
|
+
if not guid.ok:
|
|
341
|
+
return []
|
|
342
|
+
try:
|
|
343
|
+
data = (guid.json() or {}).get("data") or {}
|
|
344
|
+
except ValueError:
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
files_url = _related_href(data, "files")
|
|
348
|
+
if not files_url:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
files: List[SupplementFile] = []
|
|
352
|
+
budget = [_OSF_MAX_NODES]
|
|
353
|
+
for provider_url in _osf_provider_urls(ctx, files_url):
|
|
354
|
+
_osf_walk(ctx, provider_url, ids, files, budget, depth=0, seen=set())
|
|
355
|
+
if budget[0] <= 0:
|
|
356
|
+
ctx.log(f" OSF: stopped after {_OSF_MAX_NODES} nodes")
|
|
357
|
+
break
|
|
358
|
+
for index, entry in enumerate(files):
|
|
359
|
+
entry.listing_index = index
|
|
360
|
+
return files
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _osf_provider_urls(ctx, files_url) -> List[str]:
|
|
364
|
+
response = ctx.http.get(files_url, timeout=30, polite=False)
|
|
365
|
+
if not response.ok:
|
|
366
|
+
return []
|
|
367
|
+
try:
|
|
368
|
+
entries = (response.json() or {}).get("data") or []
|
|
369
|
+
except ValueError:
|
|
370
|
+
return []
|
|
371
|
+
urls = []
|
|
372
|
+
for entry in entries:
|
|
373
|
+
href = _related_href(entry, "files")
|
|
374
|
+
if href:
|
|
375
|
+
urls.append(href)
|
|
376
|
+
return urls
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _osf_walk(ctx, url, ids, out: List[SupplementFile], budget, depth: int, seen: set) -> None:
|
|
380
|
+
"""Collect files under an OSF storage node, following folders.
|
|
381
|
+
|
|
382
|
+
Folders arrive as entries with kind "folder" and links.download null. The
|
|
383
|
+
T4 enumerator dropped them, and everything inside them with them -- fine when
|
|
384
|
+
you only want a spreadsheet if one happens to be at the top level, wrong when
|
|
385
|
+
the ask is "as many as possible" and the deposit is organised into
|
|
386
|
+
directories.
|
|
387
|
+
"""
|
|
388
|
+
if depth > _OSF_MAX_DEPTH or budget[0] <= 0 or url in seen:
|
|
389
|
+
return
|
|
390
|
+
seen.add(url)
|
|
391
|
+
budget[0] -= 1
|
|
392
|
+
|
|
393
|
+
response = ctx.http.get(url, timeout=30, polite=False)
|
|
394
|
+
if not response.ok:
|
|
395
|
+
return
|
|
396
|
+
try:
|
|
397
|
+
entries = (response.json() or {}).get("data") or []
|
|
398
|
+
except ValueError:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
for entry in entries:
|
|
402
|
+
if budget[0] <= 0:
|
|
403
|
+
return
|
|
404
|
+
if not isinstance(entry, dict):
|
|
405
|
+
continue
|
|
406
|
+
attributes = entry.get("attributes") or {}
|
|
407
|
+
name = attributes.get("name") or ""
|
|
408
|
+
if (attributes.get("kind") or "").lower() == "folder":
|
|
409
|
+
child = _related_href(entry, "files")
|
|
410
|
+
if child:
|
|
411
|
+
_osf_walk(ctx, child, ids, out, budget, depth + 1, seen)
|
|
412
|
+
continue
|
|
413
|
+
download = ((entry.get("links") or {}).get("download"))
|
|
414
|
+
if not download:
|
|
415
|
+
continue
|
|
416
|
+
out.append(SupplementFile(
|
|
417
|
+
name=name,
|
|
418
|
+
url=download,
|
|
419
|
+
provider="osf_files",
|
|
420
|
+
mimetype=attributes.get("contentType") or attributes.get("content_type"),
|
|
421
|
+
size_bytes=_as_int(attributes.get("size")),
|
|
422
|
+
role=classify_role(name),
|
|
423
|
+
origin_doi=ids.doi,
|
|
424
|
+
extra={
|
|
425
|
+
"osf_path": attributes.get("materialized_path") or attributes.get("path") or "",
|
|
426
|
+
},
|
|
427
|
+
))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# -- Dryad ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def enumerate_dryad(ids, ctx) -> List[SupplementFile]:
|
|
434
|
+
"""Dryad's file listing for the latest version of a deposit.
|
|
435
|
+
|
|
436
|
+
Two hops (dataset -> version -> files), both free and unauthenticated, and
|
|
437
|
+
case-insensitive on the DOI (verified live 2026-08-09). Every file carries
|
|
438
|
+
a declared size and a sha-256, so the size cap can refuse before
|
|
439
|
+
transferring. Reached almost exclusively through the link services --
|
|
440
|
+
a paper's own DOI is never a Dryad DOI -- which is why this exists:
|
|
441
|
+
both real dataset links the sweeps found were Dryad deposits the router
|
|
442
|
+
could see and not fetch.
|
|
443
|
+
"""
|
|
444
|
+
import urllib.parse
|
|
445
|
+
|
|
446
|
+
dryad_doi = match_id(ids.doi or "", r"(10\.5061/dryad\.[a-z0-9./]+)")
|
|
447
|
+
if not dryad_doi:
|
|
448
|
+
return []
|
|
449
|
+
encoded = urllib.parse.quote(f"doi:{dryad_doi}", safe="")
|
|
450
|
+
|
|
451
|
+
dataset = ctx.http.get(DRYAD_DATASET.format(encoded_doi=encoded),
|
|
452
|
+
timeout=30, polite=False)
|
|
453
|
+
if not dataset.ok:
|
|
454
|
+
return []
|
|
455
|
+
try:
|
|
456
|
+
version_href = (((dataset.json() or {}).get("_links") or {})
|
|
457
|
+
.get("stash:version") or {}).get("href")
|
|
458
|
+
except ValueError:
|
|
459
|
+
return []
|
|
460
|
+
if not version_href:
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
files: List[SupplementFile] = []
|
|
464
|
+
url = DRYAD_BASE + version_href + "/files"
|
|
465
|
+
for _ in range(_DRYAD_MAX_PAGES):
|
|
466
|
+
response = ctx.http.get(url, timeout=30, polite=False)
|
|
467
|
+
if not response.ok:
|
|
468
|
+
break
|
|
469
|
+
try:
|
|
470
|
+
payload = response.json() or {}
|
|
471
|
+
except ValueError:
|
|
472
|
+
break
|
|
473
|
+
for entry in ((payload.get("_embedded") or {}).get("stash:files")) or []:
|
|
474
|
+
if not isinstance(entry, dict):
|
|
475
|
+
continue
|
|
476
|
+
if str(entry.get("status") or "").lower() == "deleted":
|
|
477
|
+
continue
|
|
478
|
+
download = ((entry.get("_links") or {}).get("stash:download") or {}).get("href")
|
|
479
|
+
if not download:
|
|
480
|
+
continue
|
|
481
|
+
name = entry.get("path") or ""
|
|
482
|
+
digest = entry.get("digest")
|
|
483
|
+
digest_type = str(entry.get("digestType") or "").replace("-", "")
|
|
484
|
+
files.append(SupplementFile(
|
|
485
|
+
name=name,
|
|
486
|
+
url=DRYAD_BASE + download,
|
|
487
|
+
provider="dryad_files",
|
|
488
|
+
listing_index=len(files),
|
|
489
|
+
mimetype=entry.get("mimeType"),
|
|
490
|
+
size_bytes=_as_int(entry.get("size")),
|
|
491
|
+
checksum=f"{digest_type}:{digest}" if digest and digest_type else None,
|
|
492
|
+
role=classify_role(name, "dataset"),
|
|
493
|
+
origin_doi=ids.doi,
|
|
494
|
+
extra={"dryad_doi": dryad_doi},
|
|
495
|
+
))
|
|
496
|
+
next_href = ((payload.get("_links") or {}).get("next") or {}).get("href")
|
|
497
|
+
if not next_href:
|
|
498
|
+
break
|
|
499
|
+
url = DRYAD_BASE + next_href
|
|
500
|
+
return files
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# -- Dataverse --------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def enumerate_dataverse(ids, ctx) -> List[SupplementFile]:
|
|
507
|
+
"""Harvard Dataverse (the 10.7910 prefix), latest version's files.
|
|
508
|
+
|
|
509
|
+
One request; files declare size and md5. Restricted files are listed
|
|
510
|
+
without being downloadable, so they are skipped the same way Zenodo's
|
|
511
|
+
restricted records are.
|
|
512
|
+
"""
|
|
513
|
+
dvn_doi = match_id(ids.doi or "", r"(10\.7910/dvn/[a-z0-9]+)")
|
|
514
|
+
if not dvn_doi:
|
|
515
|
+
return []
|
|
516
|
+
|
|
517
|
+
api_url = DATAVERSE_DATASET.format(doi=dvn_doi)
|
|
518
|
+
response = ctx.http.get(api_url, timeout=30, polite=False)
|
|
519
|
+
data = None
|
|
520
|
+
if response.ok:
|
|
521
|
+
try:
|
|
522
|
+
data = (response.json() or {}).get("data") or {}
|
|
523
|
+
except ValueError:
|
|
524
|
+
data = None
|
|
525
|
+
|
|
526
|
+
if data is None:
|
|
527
|
+
# An empty HTTP 202 is Harvard Dataverse's AWS WAF, not an empty
|
|
528
|
+
# deposit -- and `return []` here is indistinguishable from "this
|
|
529
|
+
# dataset has no files", which is how a trial's own replication data
|
|
530
|
+
# went unfetched while the manifest recorded none_found.
|
|
531
|
+
from .repository_waf import fetch_json_through_browser, looks_like_challenge
|
|
532
|
+
|
|
533
|
+
body = getattr(response, "content", b"") or b""
|
|
534
|
+
status = getattr(response, "status", 0)
|
|
535
|
+
if not looks_like_challenge(body, status):
|
|
536
|
+
return []
|
|
537
|
+
ctx.log(f" dataverse: HTTP {status} looks like a bot-challenge; "
|
|
538
|
+
f"retrying through a headed browser")
|
|
539
|
+
payload = fetch_json_through_browser(api_url, dvn_doi, ctx)
|
|
540
|
+
if not payload:
|
|
541
|
+
return []
|
|
542
|
+
data = (payload or {}).get("data") or {}
|
|
543
|
+
|
|
544
|
+
files: List[SupplementFile] = []
|
|
545
|
+
for entry in ((data.get("latestVersion") or {}).get("files")) or []:
|
|
546
|
+
if not isinstance(entry, dict) or entry.get("restricted"):
|
|
547
|
+
continue
|
|
548
|
+
datafile = entry.get("dataFile") or {}
|
|
549
|
+
file_id = datafile.get("id")
|
|
550
|
+
name = datafile.get("filename") or ""
|
|
551
|
+
if not file_id or not name:
|
|
552
|
+
continue
|
|
553
|
+
checksum = datafile.get("md5") or \
|
|
554
|
+
((datafile.get("checksum") or {}).get("value")
|
|
555
|
+
if isinstance(datafile.get("checksum"), dict) else None)
|
|
556
|
+
files.append(SupplementFile(
|
|
557
|
+
name=name,
|
|
558
|
+
url=DATAVERSE_FILE.format(file_id=file_id),
|
|
559
|
+
provider="dataverse_files",
|
|
560
|
+
listing_index=len(files),
|
|
561
|
+
mimetype=datafile.get("contentType"),
|
|
562
|
+
size_bytes=_as_int(datafile.get("filesize")),
|
|
563
|
+
checksum=_normalize_checksum(checksum),
|
|
564
|
+
role=classify_role(name, "dataset"),
|
|
565
|
+
origin_doi=ids.doi,
|
|
566
|
+
extra={"dataverse_doi": dvn_doi},
|
|
567
|
+
))
|
|
568
|
+
return files
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _related_href(entry: dict, relationship: str) -> Optional[str]:
|
|
572
|
+
href = (
|
|
573
|
+
((entry.get("relationships") or {}).get(relationship) or {})
|
|
574
|
+
.get("links", {})
|
|
575
|
+
.get("related", {})
|
|
576
|
+
)
|
|
577
|
+
if isinstance(href, dict):
|
|
578
|
+
return href.get("href")
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# -- the registry -----------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
#: Provider order, and therefore the order files are numbered on disk. A tuple
|
|
585
|
+
#: rather than a dict or a set because that order is load-bearing: it is the
|
|
586
|
+
#: primary sort key in supplementary.py, so appending a provider here must not be
|
|
587
|
+
#: able to renumber the files an earlier one already produced.
|
|
588
|
+
#:
|
|
589
|
+
#: The ordering rationale, in three parts:
|
|
590
|
+
#:
|
|
591
|
+
#: * jats_manifest is first because it carries no files at all. It leaves the
|
|
592
|
+
#: article's own supplement/figure sets in ctx.scratch, and every provider
|
|
593
|
+
#: after it classifies against that ground truth instead of guessing from a
|
|
594
|
+
#: filename.
|
|
595
|
+
#: * Routes that declare a size and a checksum *before* transferring come next,
|
|
596
|
+
#: so the size cap can refuse an oversized file for no bandwidth.
|
|
597
|
+
#: * Archival copies (Europe PMC, PMC S3) beat publisher copies, because their
|
|
598
|
+
#: filenames are stable and their bytes are the deposited originals.
|
|
599
|
+
#:
|
|
600
|
+
#: datacite_related is late because it fans out into the repository enumerators,
|
|
601
|
+
#: so anything it finds that was already reachable directly has been taken.
|
|
602
|
+
def _atypon_if_withheld(ids, ctx) -> List[SupplementFile]:
|
|
603
|
+
"""E19: the publisher's own SI, but only when Europe PMC refused ours.
|
|
604
|
+
|
|
605
|
+
Costs a headed browser, so it is not something to run 46 times to find the
|
|
606
|
+
one record that needs it. The EPMC provider leaves a breadcrumb in
|
|
607
|
+
ctx.scratch when it hits the "not open access" refusal, and this reads it.
|
|
608
|
+
|
|
609
|
+
Enumeration all happens before any download, so the refusal has not been
|
|
610
|
+
seen yet at this point -- asking EPMC's search index directly is what makes
|
|
611
|
+
the gate work here. `hasSuppl=Y` with `isOpenAccess=N` is exactly the state
|
|
612
|
+
that produces the "not open access one" errorBean later, and it costs one
|
|
613
|
+
cheap JSON call against a host we are already rate-limited against.
|
|
614
|
+
"""
|
|
615
|
+
from .supplement_atypon import atypon_host_for, fetch_atypon_supplements
|
|
616
|
+
|
|
617
|
+
doi = getattr(ids, "doi", None)
|
|
618
|
+
if not atypon_host_for(doi):
|
|
619
|
+
return []
|
|
620
|
+
if not _epmc_withholds_supplements(ids, ctx):
|
|
621
|
+
return []
|
|
622
|
+
return fetch_atypon_supplements(ids, ctx)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _epmc_withholds_supplements(ids, ctx, default_without_pmcid: bool = True) -> bool:
|
|
626
|
+
"""True when EPMC says supplements exist but the article is not open access.
|
|
627
|
+
|
|
628
|
+
`default_without_pmcid` exists because the two callers want opposite things
|
|
629
|
+
from a missing PMCID, and conflating them produced a real bug:
|
|
630
|
+
|
|
631
|
+
* The E19 browser gate wants True -- an Atypon DOI that never reached
|
|
632
|
+
Europe PMC should still get the publisher attempt.
|
|
633
|
+
* The "SI WITHHELD" warning wants False. Claiming files exist and are
|
|
634
|
+
being withheld, with no evidence that they exist at all, is exactly the
|
|
635
|
+
false alarm this parameter prevents.
|
|
636
|
+
"""
|
|
637
|
+
pmcid = getattr(ids, "pmcid", None)
|
|
638
|
+
if not pmcid:
|
|
639
|
+
return default_without_pmcid
|
|
640
|
+
cached = ctx.scratch.get("_epmc_withholds")
|
|
641
|
+
if cached is not None:
|
|
642
|
+
return cached
|
|
643
|
+
verdict = False
|
|
644
|
+
try:
|
|
645
|
+
response = ctx.http.get(
|
|
646
|
+
"https://www.ebi.ac.uk/europepmc/webservices/rest/search",
|
|
647
|
+
params={"query": f"PMCID:{pmcid}", "resultType": "core",
|
|
648
|
+
"format": "json"},
|
|
649
|
+
timeout=25,
|
|
650
|
+
)
|
|
651
|
+
if response.ok:
|
|
652
|
+
results = ((response.json() or {}).get("resultList") or {}).get("result") or []
|
|
653
|
+
if results:
|
|
654
|
+
record = results[0]
|
|
655
|
+
verdict = (str(record.get("hasSuppl") or "").upper() == "Y"
|
|
656
|
+
and str(record.get("isOpenAccess") or "").upper() != "Y")
|
|
657
|
+
except Exception:
|
|
658
|
+
verdict = False # never let a metadata lookup break the record
|
|
659
|
+
ctx.scratch["_epmc_withholds"] = verdict
|
|
660
|
+
return verdict
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _providers() -> Tuple[Tuple[str, Any], ...]:
|
|
664
|
+
"""Built lazily so the provider modules can import from this one."""
|
|
665
|
+
from .supplement_graph import (
|
|
666
|
+
enumerate_crossref_components,
|
|
667
|
+
enumerate_datacite_related,
|
|
668
|
+
enumerate_epmc_datalinks,
|
|
669
|
+
enumerate_fulltext_scan,
|
|
670
|
+
enumerate_scholix_related,
|
|
671
|
+
)
|
|
672
|
+
from .supplement_pmc import (
|
|
673
|
+
enumerate_jats_declared,
|
|
674
|
+
enumerate_jats_manifest,
|
|
675
|
+
enumerate_pmc_s3,
|
|
676
|
+
)
|
|
677
|
+
from .supplement_atypon import fetch_atypon_supplements
|
|
678
|
+
from .supplement_publishers import (
|
|
679
|
+
enumerate_apa_supplemental,
|
|
680
|
+
enumerate_elsevier_objects,
|
|
681
|
+
enumerate_plos,
|
|
682
|
+
enumerate_preprint_supplements,
|
|
683
|
+
enumerate_springer_esm,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
("jats_manifest", enumerate_jats_manifest), # E1 classifier only
|
|
688
|
+
("europepmc_supplements", enumerate_epmc_archive), # E2
|
|
689
|
+
("pmc_s3", enumerate_pmc_s3), # E3
|
|
690
|
+
("elsevier_objects", enumerate_elsevier_objects), # E10
|
|
691
|
+
("springer_esm", enumerate_springer_esm), # E11
|
|
692
|
+
("plos", enumerate_plos), # E12
|
|
693
|
+
("preprint_supplements", enumerate_preprint_supplements), # E13
|
|
694
|
+
("apa_supplemental", enumerate_apa_supplemental), # E14
|
|
695
|
+
("figshare_files", enumerate_figshare), # E5
|
|
696
|
+
("zenodo_files", enumerate_zenodo), # E6
|
|
697
|
+
("osf_files", enumerate_osf), # E7
|
|
698
|
+
("datacite_related", enumerate_datacite_related), # E4
|
|
699
|
+
("crossref_components", enumerate_crossref_components), # E9
|
|
700
|
+
("jats_declared", enumerate_jats_declared), # E15 recovery
|
|
701
|
+
("scholix_related", enumerate_scholix_related), # E16
|
|
702
|
+
("epmc_datalinks", enumerate_epmc_datalinks), # E17
|
|
703
|
+
("fulltext_scan", enumerate_fulltext_scan), # E18
|
|
704
|
+
# E19 LAST on purpose: one headed browser per record. Only reached for
|
|
705
|
+
# records Europe PMC has already said it is withholding, which on a
|
|
706
|
+
# 46-record corpus was 1, not 46.
|
|
707
|
+
("atypon_suppl", _atypon_if_withheld), # E19
|
|
708
|
+
# E20 truly last: the only provider that costs money, and the only one
|
|
709
|
+
# that benefits from knowing what all the others already found.
|
|
710
|
+
("llm_agent", _llm_agent), # E20
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _llm_agent(ids, ctx):
|
|
715
|
+
"""Imported at call time: a run without the flag never loads a backend."""
|
|
716
|
+
from .llm_agent_retrieval import enumerate_llm_agent
|
|
717
|
+
return enumerate_llm_agent(ids, ctx)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
#: The provider names, in order, without importing the provider modules. Kept in
|
|
721
|
+
#: step with _providers() by test_provider_names_match_the_registry.
|
|
722
|
+
PROVIDER_NAMES: Tuple[str, ...] = (
|
|
723
|
+
"jats_manifest",
|
|
724
|
+
"europepmc_supplements",
|
|
725
|
+
"pmc_s3",
|
|
726
|
+
"elsevier_objects",
|
|
727
|
+
"springer_esm",
|
|
728
|
+
"plos",
|
|
729
|
+
"preprint_supplements",
|
|
730
|
+
"apa_supplemental",
|
|
731
|
+
"figshare_files",
|
|
732
|
+
"zenodo_files",
|
|
733
|
+
"osf_files",
|
|
734
|
+
"datacite_related",
|
|
735
|
+
"crossref_components",
|
|
736
|
+
"jats_declared",
|
|
737
|
+
"scholix_related",
|
|
738
|
+
"epmc_datalinks",
|
|
739
|
+
"fulltext_scan",
|
|
740
|
+
"atypon_suppl",
|
|
741
|
+
"llm_agent",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def provider_rank(name: str) -> int:
|
|
746
|
+
"""Where a provider sits in the numbering order.
|
|
747
|
+
|
|
748
|
+
Tolerant of the "datacite_related:zenodo_files" form, so a file reached by
|
|
749
|
+
fanning out through DataCite ranks with DataCite rather than falling to the
|
|
750
|
+
end -- the fan-out is where it came from.
|
|
751
|
+
"""
|
|
752
|
+
root = str(name or "").split(":", 1)[0]
|
|
753
|
+
try:
|
|
754
|
+
return PROVIDER_NAMES.index(root)
|
|
755
|
+
except ValueError:
|
|
756
|
+
return len(PROVIDER_NAMES)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def enumerate_all(ids, ctx, providers=None) -> Tuple[List[SupplementFile], List[dict]]:
|
|
760
|
+
"""Ask every applicable provider what it has.
|
|
761
|
+
|
|
762
|
+
Each provider is guarded individually, the same way engine._run_source
|
|
763
|
+
guards a source: one repository serving malformed JSON must cost us that
|
|
764
|
+
repository, not the record. The reports are what lets the manifest say
|
|
765
|
+
"figshare was not applicable" and "zenodo errored" rather than collapsing
|
|
766
|
+
both into an empty list.
|
|
767
|
+
"""
|
|
768
|
+
providers = providers if providers is not None else _providers()
|
|
769
|
+
found: List[SupplementFile] = []
|
|
770
|
+
reports: List[dict] = []
|
|
771
|
+
# A live reference, so a provider that runs LAST can see what the earlier
|
|
772
|
+
# ones already listed. Only the retrieval agent uses it, and only to avoid
|
|
773
|
+
# asking a model to go and fetch a file we are already holding.
|
|
774
|
+
ctx.scratch["enumerated_so_far"] = found
|
|
775
|
+
for name, enumerator in providers:
|
|
776
|
+
try:
|
|
777
|
+
files = enumerator(ids, ctx) or []
|
|
778
|
+
except Exception as e:
|
|
779
|
+
ctx.log(f" ✗ {name} raised: {str(e)[:150]}")
|
|
780
|
+
reports.append({
|
|
781
|
+
"name": name,
|
|
782
|
+
"status": "error",
|
|
783
|
+
"reason": f"{type(e).__name__}: {str(e)[:150]}",
|
|
784
|
+
})
|
|
785
|
+
continue
|
|
786
|
+
for entry in files:
|
|
787
|
+
entry.provider = entry.provider or name
|
|
788
|
+
found.extend(files)
|
|
789
|
+
reports.append({
|
|
790
|
+
"name": name,
|
|
791
|
+
"status": "ok" if files else "nothing_listed",
|
|
792
|
+
"listed": len(files),
|
|
793
|
+
})
|
|
794
|
+
return found, reports
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# -- shared helpers ---------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def match_id(value: Optional[str], pattern: str) -> Optional[str]:
|
|
801
|
+
if not value:
|
|
802
|
+
return None
|
|
803
|
+
m = re.search(pattern, value, re.IGNORECASE)
|
|
804
|
+
return m.group(1) if m else None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _as_int(value) -> Optional[int]:
|
|
808
|
+
try:
|
|
809
|
+
parsed = int(value)
|
|
810
|
+
except (TypeError, ValueError):
|
|
811
|
+
return None
|
|
812
|
+
return parsed if parsed >= 0 else None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _normalize_checksum(value) -> Optional[str]:
|
|
816
|
+
"""Zenodo already sends "md5:...", others send a bare hex digest."""
|
|
817
|
+
if not value:
|
|
818
|
+
return None
|
|
819
|
+
text = str(value).strip()
|
|
820
|
+
return text if ":" in text else f"md5:{text}"
|