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,842 @@
|
|
|
1
|
+
"""Reaching supplements through the citation graph rather than the publisher.
|
|
2
|
+
|
|
3
|
+
Both routes here run *backwards*, and that is the whole trick.
|
|
4
|
+
|
|
5
|
+
DataCite: forward traversal from an article DOI almost always fails, because the
|
|
6
|
+
article is registered with Crossref and has no DataCite record to traverse from.
|
|
7
|
+
The reverse query -- "which DataCite records name this DOI as related?" -- is one
|
|
8
|
+
request and works. Verified on 10.1186/s40168-025-02261-0: three hits, a Dryad
|
|
9
|
+
dataset and two Zenodo software records.
|
|
10
|
+
|
|
11
|
+
That verification also settled how to filter them. The relations those three were
|
|
12
|
+
deposited under were IsCitedBy and IsSourceOf -- *not* IsSupplementTo. A
|
|
13
|
+
relationType whitelist, which is the obvious design and the one the legacy chain
|
|
14
|
+
uses at _collect_datacite_candidate_urls, would have discarded all three. So the
|
|
15
|
+
gate is types.resourceTypeGeneral: a Dataset that names your article is your data
|
|
16
|
+
whatever relation the depositor picked, and a JournalArticle that names your
|
|
17
|
+
article is somebody else's paper citing you.
|
|
18
|
+
|
|
19
|
+
Crossref: article-level `relation` is empty at most publishers (is-supplemented-by
|
|
20
|
+
covers 99,154 works out of 185 million, about 0.05%). Component DOIs are a
|
|
21
|
+
different story -- 9.3 million works -- and the reverse filter finds them:
|
|
22
|
+
relation.object:{doi} returns the article's own components, of which the s001-style
|
|
23
|
+
ones are its supplementary files.
|
|
24
|
+
|
|
25
|
+
ScholeXplorer and Europe PMC datalinks run *forward* -- both index links FROM the
|
|
26
|
+
article, so sourcePid={doi} works where the DataCite forward traversal did not.
|
|
27
|
+
They earn their place beside the DataCite reverse query because their link pools
|
|
28
|
+
barely overlap it: ScholeXplorer folds in Crossref, DataCite AND OpenAIRE's
|
|
29
|
+
text-mining of ~14M PDFs, and Europe PMC adds accession-number mining (trial
|
|
30
|
+
registrations, GEO, PDB...) that no DOI-registry query can see. Measured on the
|
|
31
|
+
forensic corpora (2026-08): ~30% of a recent biomedical batch had ScholeXplorer
|
|
32
|
+
links -- mostly tool citations, with the occasional Dryad/figshare deposit that
|
|
33
|
+
is exactly the point -- and Europe PMC found links on most of that batch. Both
|
|
34
|
+
found nothing on an older clinical-trial corpus, which is a fact worth a sidecar
|
|
35
|
+
entry rather than a silence (see linked_artifacts.py).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
from typing import List, Optional
|
|
41
|
+
|
|
42
|
+
from .linked_artifacts import (
|
|
43
|
+
CLASS_OWNED,
|
|
44
|
+
CLASS_REGISTRY,
|
|
45
|
+
CLASS_RELATED,
|
|
46
|
+
CLASS_TOOL_CITATION,
|
|
47
|
+
record_link,
|
|
48
|
+
record_query,
|
|
49
|
+
)
|
|
50
|
+
from .supplement_index import (
|
|
51
|
+
ROLE_SUPPLEMENT,
|
|
52
|
+
SupplementFile,
|
|
53
|
+
classify_role,
|
|
54
|
+
jats_sets,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
DATACITE_REVERSE = "https://api.datacite.org/dois"
|
|
58
|
+
CROSSREF_COMPONENTS = "https://api.crossref.org/works"
|
|
59
|
+
SCHOLEX_LINKS = "https://api.scholexplorer.openaire.eu/v3/Links"
|
|
60
|
+
EPMC_DATALINKS = "https://www.ebi.ac.uk/europepmc/webservices/rest/MED/{pmid}/datalinks"
|
|
61
|
+
|
|
62
|
+
#: Resource types that are somebody's deposited data rather than somebody's paper.
|
|
63
|
+
KEEP_RESOURCE_TYPES = {
|
|
64
|
+
"dataset", "software", "collection", "audiovisual", "image", "model",
|
|
65
|
+
"text", "other", "workflow", "physicalobject",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#: Relations that mean "a different work referring to this one". A citing paper is
|
|
69
|
+
#: not a supplement, however generously the type field is filled in.
|
|
70
|
+
DROP_RELATIONS = {"cites", "references", "isreferencedby", "iscitedby", "isreviewof"}
|
|
71
|
+
|
|
72
|
+
#: Resource types that are the article, a version of it, or another paper.
|
|
73
|
+
DROP_RESOURCE_TYPES = {"preprint", "journalarticle", "article", "book", "bookchapter",
|
|
74
|
+
"conferencepaper", "peerreview", "publication"}
|
|
75
|
+
|
|
76
|
+
#: Component DOI suffixes that are a figure or a table, not a supplementary file.
|
|
77
|
+
_FIGURE_COMPONENT_RE = re.compile(r"\.(g|t)\d+$", re.IGNORECASE)
|
|
78
|
+
|
|
79
|
+
_MAX_RELATED = 25
|
|
80
|
+
_MAX_COMPONENTS = 100
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- E4: DataCite reverse related-identifier query --------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def enumerate_datacite_related(ids, ctx) -> List[SupplementFile]:
|
|
87
|
+
"""Data DOIs that name this article, resolved into files where we can.
|
|
88
|
+
|
|
89
|
+
A hit here is a whole deposit, not a file, so the useful output is a set of
|
|
90
|
+
repository DOIs handed to the repository enumerators. Everything inside a data
|
|
91
|
+
DOI reached this way is supplementary by construction -- nobody deposits the
|
|
92
|
+
paper as its own dataset -- so the article-vs-supplement heuristics are not
|
|
93
|
+
consulted for these.
|
|
94
|
+
"""
|
|
95
|
+
if not ids.doi:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
response = ctx.http.get(
|
|
99
|
+
DATACITE_REVERSE,
|
|
100
|
+
params={
|
|
101
|
+
"query": f'relatedIdentifiers.relatedIdentifier:"{ids.doi}"',
|
|
102
|
+
"page[size]": _MAX_RELATED,
|
|
103
|
+
},
|
|
104
|
+
timeout=30,
|
|
105
|
+
polite=False,
|
|
106
|
+
)
|
|
107
|
+
if not response.ok:
|
|
108
|
+
return []
|
|
109
|
+
try:
|
|
110
|
+
records = (response.json() or {}).get("data") or []
|
|
111
|
+
except ValueError:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
files: List[SupplementFile] = []
|
|
115
|
+
for record in records:
|
|
116
|
+
related_doi = _accept_related(record, ids.doi, ctx)
|
|
117
|
+
if not related_doi:
|
|
118
|
+
continue
|
|
119
|
+
files.extend(_files_in_repository(related_doi, ids, ctx))
|
|
120
|
+
if len(files) >= _MAX_RELATED:
|
|
121
|
+
break
|
|
122
|
+
return files
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _accept_related(record, article_doi, ctx) -> Optional[str]:
|
|
126
|
+
"""The related DOI, if this record is data about our article."""
|
|
127
|
+
if not isinstance(record, dict):
|
|
128
|
+
return None
|
|
129
|
+
attributes = record.get("attributes") or {}
|
|
130
|
+
related_doi = (attributes.get("doi") or "").strip().lower()
|
|
131
|
+
if not related_doi or related_doi == (article_doi or "").strip().lower():
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
types = attributes.get("types") or {}
|
|
135
|
+
general = str(types.get("resourceTypeGeneral") or "").strip().lower()
|
|
136
|
+
specific = re.sub(r"[^a-z]", "", str(types.get("resourceType") or "").lower())
|
|
137
|
+
if general in DROP_RESOURCE_TYPES or specific in DROP_RESOURCE_TYPES:
|
|
138
|
+
return None
|
|
139
|
+
if general and general not in KEEP_RESOURCE_TYPES:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
# The relation is not the gate (see the module docstring), but a relation that
|
|
143
|
+
# positively means "another work citing this one" still rules a record out.
|
|
144
|
+
for relation in attributes.get("relatedIdentifiers") or []:
|
|
145
|
+
if not isinstance(relation, dict):
|
|
146
|
+
continue
|
|
147
|
+
identifier = str(relation.get("relatedIdentifier") or "").strip().lower()
|
|
148
|
+
if article_doi and article_doi.lower() in identifier:
|
|
149
|
+
kind = str(relation.get("relationType") or "").strip().lower()
|
|
150
|
+
if kind in DROP_RELATIONS and general not in ("dataset", "software"):
|
|
151
|
+
return None
|
|
152
|
+
ctx.log(f" DataCite: {related_doi} ({general or 'untyped'})")
|
|
153
|
+
return related_doi
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _files_in_repository(related_doi: str, ids, ctx,
|
|
157
|
+
via: str = "datacite_related") -> List[SupplementFile]:
|
|
158
|
+
"""Route a data DOI to the enumerator for the repository that holds it.
|
|
159
|
+
|
|
160
|
+
Three providers fan out through here (DataCite reverse, ScholeXplorer,
|
|
161
|
+
EPMC datalinks) and their link pools overlap, so a per-record seen-set
|
|
162
|
+
stops the same deposit being listed -- and its files downloaded -- once per
|
|
163
|
+
provider that mentions it. First mention wins the provider tag.
|
|
164
|
+
"""
|
|
165
|
+
from .identifiers import IdentifierSet
|
|
166
|
+
from .supplement_index import (
|
|
167
|
+
enumerate_dataverse,
|
|
168
|
+
enumerate_dryad,
|
|
169
|
+
enumerate_figshare,
|
|
170
|
+
enumerate_osf,
|
|
171
|
+
enumerate_zenodo,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
routed = ctx.scratch.setdefault("_routed_deposit_dois", set())
|
|
175
|
+
key = related_doi.strip().lower()
|
|
176
|
+
if key in routed:
|
|
177
|
+
return []
|
|
178
|
+
routed.add(key)
|
|
179
|
+
|
|
180
|
+
proxy = IdentifierSet(doi=related_doi)
|
|
181
|
+
if related_doi.startswith("10.5281") or "zenodo" in related_doi:
|
|
182
|
+
enumerator, provider = enumerate_zenodo, "zenodo_files"
|
|
183
|
+
elif related_doi.startswith("10.6084") or "figshare" in related_doi:
|
|
184
|
+
enumerator, provider = enumerate_figshare, "figshare_files"
|
|
185
|
+
elif "osf.io" in related_doi or related_doi.startswith("10.17605"):
|
|
186
|
+
enumerator, provider = enumerate_osf, "osf_files"
|
|
187
|
+
elif related_doi.startswith("10.5061") or "dryad" in related_doi:
|
|
188
|
+
enumerator, provider = enumerate_dryad, "dryad_files"
|
|
189
|
+
elif related_doi.startswith("10.7910/dvn"):
|
|
190
|
+
enumerator, provider = enumerate_dataverse, "dataverse_files"
|
|
191
|
+
else:
|
|
192
|
+
# The long tail (institutional repos, subject databases) has no
|
|
193
|
+
# enumerator; the link still lands in the linked-artifacts sidecar.
|
|
194
|
+
ctx.log(f" {via}: no enumerator for {related_doi}")
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
files = enumerator(proxy, ctx) or []
|
|
198
|
+
for entry in files:
|
|
199
|
+
entry.provider = f"{via}:{provider}"
|
|
200
|
+
entry.origin_doi = related_doi
|
|
201
|
+
# Reached through a data DOI, so it is data. Nothing here is the paper.
|
|
202
|
+
entry.role = ROLE_SUPPLEMENT
|
|
203
|
+
return files
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# -- E9: Crossref component DOIs --------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def enumerate_crossref_components(ids, ctx) -> List[SupplementFile]:
|
|
210
|
+
"""The article's own component DOIs, minus its figures and tables.
|
|
211
|
+
|
|
212
|
+
Largely redundant with the PMC routes for anything in PubMed Central, and it
|
|
213
|
+
earns its place on the publishers that register components but never reach
|
|
214
|
+
PMC.
|
|
215
|
+
"""
|
|
216
|
+
if not ids.doi:
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
response = ctx.http.get(
|
|
220
|
+
CROSSREF_COMPONENTS,
|
|
221
|
+
params={"filter": f"relation.object:{ids.doi}", "rows": _MAX_COMPONENTS},
|
|
222
|
+
timeout=30,
|
|
223
|
+
)
|
|
224
|
+
if not response.ok:
|
|
225
|
+
return []
|
|
226
|
+
try:
|
|
227
|
+
items = ((response.json() or {}).get("message") or {}).get("items") or []
|
|
228
|
+
except ValueError:
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
supplements, figures = jats_sets(ctx)
|
|
232
|
+
files = []
|
|
233
|
+
for index, item in enumerate(items):
|
|
234
|
+
if not isinstance(item, dict) or item.get("type") != "component":
|
|
235
|
+
continue
|
|
236
|
+
component_doi = str(item.get("DOI") or "")
|
|
237
|
+
if _FIGURE_COMPONENT_RE.search(component_doi):
|
|
238
|
+
continue
|
|
239
|
+
url = (((item.get("resource") or {}).get("primary") or {}).get("URL"))
|
|
240
|
+
if not url:
|
|
241
|
+
continue
|
|
242
|
+
name = component_doi.rsplit("/", 1)[-1]
|
|
243
|
+
role = classify_role(name, jats_supplements=supplements, jats_figures=figures)
|
|
244
|
+
files.append(SupplementFile(
|
|
245
|
+
name=name,
|
|
246
|
+
url=url,
|
|
247
|
+
provider="crossref_components",
|
|
248
|
+
listing_index=index,
|
|
249
|
+
role=ROLE_SUPPLEMENT if role == "unknown" else role,
|
|
250
|
+
label=" ".join(item.get("title") or [])[:200],
|
|
251
|
+
origin_doi=ids.doi,
|
|
252
|
+
extra={"component_doi": component_doi},
|
|
253
|
+
))
|
|
254
|
+
return files
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# -- E16: OpenAIRE ScholeXplorer (Scholix links) ------------------------------
|
|
258
|
+
|
|
259
|
+
#: OpenAIRE mints a software record for every package a paper cites, titled
|
|
260
|
+
#: "<name> software on GitHub". Those are somebody's tool, not this paper's
|
|
261
|
+
#: code -- on the biomedical corpus they were the *majority* of software links
|
|
262
|
+
#: (xgboost, ggthemes, table1...) -- so they are recorded but never routed.
|
|
263
|
+
_TOOL_CITATION_RE = re.compile(r"(?i)\bsoftware on git(hub|lab)$")
|
|
264
|
+
|
|
265
|
+
#: Relation subtypes that positively mean "deposited as this paper's material".
|
|
266
|
+
_OWNED_RELATIONS = {"issupplementto", "issupplementedby", "haspart", "ispartof"}
|
|
267
|
+
|
|
268
|
+
#: DOI prefixes that are papers wearing a dataset costume. EPMC's text-mined
|
|
269
|
+
#: "Data Citations" happily include arXiv DOIs.
|
|
270
|
+
#: DOIs that name a PAPER, never a data deposit. Routing one downloads someone
|
|
271
|
+
#: else's article and files it under this record -- which is exactly what
|
|
272
|
+
#: happened: 10.31234/osf.io/8r9p7 (a PsyArXiv preprint) was downloaded as a
|
|
273
|
+
#: "dataset" for 10.1073/pnas.2316670121.
|
|
274
|
+
#:
|
|
275
|
+
#: The OSF preprint servers are the trap here, because their DOIs embed
|
|
276
|
+
#: "osf.io" and so look like OSF deposits to every downstream check.
|
|
277
|
+
_PAPER_DOI_PREFIXES = (
|
|
278
|
+
"10.48550", # arXiv
|
|
279
|
+
"10.31234", # PsyArXiv
|
|
280
|
+
"10.31235", # SocArXiv
|
|
281
|
+
"10.31219", # OSF Preprints
|
|
282
|
+
"10.31730", # AfricArXiv
|
|
283
|
+
"10.26434", # ChemRxiv
|
|
284
|
+
"10.1101", # bioRxiv / medRxiv
|
|
285
|
+
"10.20944", # Preprints.org
|
|
286
|
+
"10.21203", # Research Square
|
|
287
|
+
"10.2139", # SSRN
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
#: Dataverse's convention for a paper's own deposit. Harvard Dataverse fills
|
|
292
|
+
#: in NO relatedIdentifiers at all, so for it the title is the declaration.
|
|
293
|
+
_REPLICATION_TITLE_RE = re.compile(r"(?i)^replication\s+(data|code|materials?)\s+for:?\s*(.+)")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _deposit_names_article(repo_doi: str, article_doi: str, ctx) -> bool:
|
|
297
|
+
"""Whether the deposit's own metadata declares it belongs to this article.
|
|
298
|
+
|
|
299
|
+
The ownership gate for text-mined and citation-graph links. A "cites" or
|
|
300
|
+
"References" link cannot distinguish the paper's own deposit from a
|
|
301
|
+
dataset the paper merely cites -- verified both ways on real links: a
|
|
302
|
+
Dryad deposit Scholix tied to one article was IsCitedBy a *different*
|
|
303
|
+
article (a cited work, not the paper's data), while two Dataverse
|
|
304
|
+
deposits that genuinely were their papers' data carried no
|
|
305
|
+
relatedIdentifiers at all.
|
|
306
|
+
|
|
307
|
+
Two affirmative routes, both from the deposit's own DataCite record:
|
|
308
|
+
1. a relatedIdentifier naming the article DOI (any non-citation
|
|
309
|
+
relation -- see the module docstring for why relationType is not
|
|
310
|
+
the filter), or
|
|
311
|
+
2. a "Replication Data for: <title>" deposit title whose remainder
|
|
312
|
+
matches the article's Crossref title (the Dataverse convention).
|
|
313
|
+
Everything unconfirmed stays a sidecar link for the operator. Failing
|
|
314
|
+
closed here is the difference between a corpus of each paper's own data
|
|
315
|
+
and a corpus polluted with its bibliography.
|
|
316
|
+
"""
|
|
317
|
+
if not repo_doi or not article_doi:
|
|
318
|
+
return False
|
|
319
|
+
cache = ctx.scratch.setdefault("_deposit_ownership", {})
|
|
320
|
+
key = repo_doi.strip().lower()
|
|
321
|
+
if key in cache:
|
|
322
|
+
return cache[key]
|
|
323
|
+
|
|
324
|
+
import urllib.parse
|
|
325
|
+
response = ctx.http.get(
|
|
326
|
+
"https://api.datacite.org/dois/" + urllib.parse.quote(key, safe=""),
|
|
327
|
+
timeout=30, polite=False,
|
|
328
|
+
)
|
|
329
|
+
verdict = False
|
|
330
|
+
if response.ok:
|
|
331
|
+
try:
|
|
332
|
+
attributes = ((response.json() or {}).get("data") or {}).get("attributes") or {}
|
|
333
|
+
except ValueError:
|
|
334
|
+
attributes = {}
|
|
335
|
+
needle = article_doi.strip().lower()
|
|
336
|
+
for relation in attributes.get("relatedIdentifiers") or []:
|
|
337
|
+
if not isinstance(relation, dict):
|
|
338
|
+
continue
|
|
339
|
+
kind = str(relation.get("relationType") or "").strip().lower()
|
|
340
|
+
identifier = str(relation.get("relatedIdentifier") or "").strip().lower()
|
|
341
|
+
if needle and needle in identifier and kind not in DROP_RELATIONS:
|
|
342
|
+
verdict = True
|
|
343
|
+
break
|
|
344
|
+
if not verdict:
|
|
345
|
+
verdict = _replication_title_matches(attributes, article_doi, ctx)
|
|
346
|
+
cache[key] = verdict
|
|
347
|
+
return verdict
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
#: How close a "Replication Data for: <title>" remainder must sit to the
|
|
351
|
+
#: article's own title. Exact containment is too strict by exactly one letter:
|
|
352
|
+
#: a Harvard Dataverse deposit titled "...ESCAS randomised pilot study" against
|
|
353
|
+
#: a journal title reading "...ESCAS Randomized Pilot Study" agrees for 108
|
|
354
|
+
#: characters and then diverges on British vs American spelling, which sent a
|
|
355
|
+
#: trial's own replication data to the sidecar unrouted.
|
|
356
|
+
#:
|
|
357
|
+
#: The margin is not delicate. That pair scores 0.99; an unrelated replication
|
|
358
|
+
#: title scores 0.12. Anything in between is not a near-miss worth arguing over.
|
|
359
|
+
_TITLE_MATCH_RATIO = 0.93
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _replication_title_matches(attributes: dict, article_doi: str, ctx) -> bool:
|
|
363
|
+
"""Route 2: 'Replication Data for: <title>' against the article's title."""
|
|
364
|
+
import difflib
|
|
365
|
+
|
|
366
|
+
for entry in attributes.get("titles") or []:
|
|
367
|
+
title = str((entry or {}).get("title") or "")
|
|
368
|
+
matched = _REPLICATION_TITLE_RE.match(title.strip())
|
|
369
|
+
if not matched:
|
|
370
|
+
continue
|
|
371
|
+
claimed = _squash(matched.group(2))
|
|
372
|
+
actual = _squash(_article_title(article_doi, ctx))
|
|
373
|
+
if not claimed or not actual:
|
|
374
|
+
continue
|
|
375
|
+
if claimed in actual or actual in claimed:
|
|
376
|
+
return True
|
|
377
|
+
# Containment first (cheap, and the common case); near-identity only
|
|
378
|
+
# for the spelling-variant tail it would otherwise drop.
|
|
379
|
+
if difflib.SequenceMatcher(None, claimed, actual).ratio() >= _TITLE_MATCH_RATIO:
|
|
380
|
+
return True
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _article_title(article_doi: str, ctx) -> str:
|
|
385
|
+
cache = ctx.scratch.setdefault("_article_titles", {})
|
|
386
|
+
key = article_doi.strip().lower()
|
|
387
|
+
if key in cache:
|
|
388
|
+
return cache[key]
|
|
389
|
+
title = ""
|
|
390
|
+
response = ctx.http.get(
|
|
391
|
+
"https://api.crossref.org/works/" + key, timeout=30,
|
|
392
|
+
)
|
|
393
|
+
if response.ok:
|
|
394
|
+
try:
|
|
395
|
+
titles = ((response.json() or {}).get("message") or {}).get("title") or []
|
|
396
|
+
title = str(titles[0]) if titles else ""
|
|
397
|
+
except ValueError:
|
|
398
|
+
pass
|
|
399
|
+
cache[key] = title
|
|
400
|
+
return title
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _squash(text: str) -> str:
|
|
404
|
+
"""Lowercased alphanumerics only, so punctuation and casing differences
|
|
405
|
+
between a deposit title and a journal title cannot break the match."""
|
|
406
|
+
return re.sub(r"[^a-z0-9]", "", (text or "").lower())
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def enumerate_scholix_related(ids, ctx) -> List[SupplementFile]:
|
|
410
|
+
"""Datasets and software ScholeXplorer links to this article, as files
|
|
411
|
+
where a repository enumerator exists, and as sidecar links always.
|
|
412
|
+
|
|
413
|
+
Free, unauthenticated, first page only: a paper with more than 100
|
|
414
|
+
dataset links is not a paper whose links we can usefully triage.
|
|
415
|
+
"""
|
|
416
|
+
if not ids.doi:
|
|
417
|
+
return []
|
|
418
|
+
|
|
419
|
+
files: List[SupplementFile] = []
|
|
420
|
+
for target_type in ("dataset", "software"):
|
|
421
|
+
response = ctx.http.get(
|
|
422
|
+
SCHOLEX_LINKS,
|
|
423
|
+
params={"sourcePid": ids.doi, "targetType": target_type},
|
|
424
|
+
timeout=30,
|
|
425
|
+
polite=False,
|
|
426
|
+
)
|
|
427
|
+
if not response.ok:
|
|
428
|
+
record_query(ctx, "scholexplorer", response.request_url,
|
|
429
|
+
"error", f"http {response.status}")
|
|
430
|
+
continue
|
|
431
|
+
try:
|
|
432
|
+
payload = response.json() or {}
|
|
433
|
+
except ValueError:
|
|
434
|
+
record_query(ctx, "scholexplorer", response.request_url,
|
|
435
|
+
"error", "not JSON")
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
links = payload.get("result") or []
|
|
439
|
+
record_query(ctx, "scholexplorer", response.request_url, "ok",
|
|
440
|
+
f"{payload.get('totalLinks', len(links))} {target_type} link(s)")
|
|
441
|
+
|
|
442
|
+
for link in links:
|
|
443
|
+
if not isinstance(link, dict):
|
|
444
|
+
continue
|
|
445
|
+
target = link.get("target") or {}
|
|
446
|
+
target_doi = _scholix_doi(target)
|
|
447
|
+
title = str(target.get("Title") or "")
|
|
448
|
+
relation = _scholix_relation(link)
|
|
449
|
+
classified = _classify_scholix(target_doi, title, relation)
|
|
450
|
+
|
|
451
|
+
# Only the paper's own material is downloaded. "cites" cannot
|
|
452
|
+
# distinguish the paper's deposit from a dataset the paper merely
|
|
453
|
+
# cites, so a citation-grade link earns a download only when the
|
|
454
|
+
# deposit's DataCite record names this article.
|
|
455
|
+
if classified == CLASS_RELATED and _has_repo_enumerator(target_doi) \
|
|
456
|
+
and _deposit_names_article(target_doi, ids.doi, ctx):
|
|
457
|
+
classified = CLASS_OWNED
|
|
458
|
+
|
|
459
|
+
routed_files: List[SupplementFile] = []
|
|
460
|
+
if _should_route(classified, ctx, target_doi, ids) and target_doi \
|
|
461
|
+
and not target_doi.startswith(_PAPER_DOI_PREFIXES):
|
|
462
|
+
routed_files = _files_in_repository(
|
|
463
|
+
target_doi, ids, ctx, via="scholix_related")
|
|
464
|
+
files.extend(routed_files)
|
|
465
|
+
|
|
466
|
+
record_link(
|
|
467
|
+
ctx, service="scholexplorer", target_pid=target_doi,
|
|
468
|
+
target_type=target_type, relation=relation,
|
|
469
|
+
classified=classified, title=title,
|
|
470
|
+
publisher=_scholix_publisher(target),
|
|
471
|
+
provider_name=_scholix_provider(link),
|
|
472
|
+
harvest_date=str(link.get("HarvestDate") or ""),
|
|
473
|
+
routed=bool(routed_files),
|
|
474
|
+
)
|
|
475
|
+
return files
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _scholix_doi(target: dict) -> Optional[str]:
|
|
479
|
+
for identifier in target.get("Identifier") or []:
|
|
480
|
+
if isinstance(identifier, dict) and \
|
|
481
|
+
str(identifier.get("IDScheme") or "").lower() == "doi":
|
|
482
|
+
doi = str(identifier.get("ID") or "").strip().lower()
|
|
483
|
+
if doi:
|
|
484
|
+
return doi
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _scholix_relation(link: dict) -> str:
|
|
489
|
+
relationship = link.get("RelationshipType") or {}
|
|
490
|
+
return str(relationship.get("SubType") or relationship.get("Name") or "").strip()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _scholix_publisher(target: dict) -> str:
|
|
494
|
+
publishers = target.get("Publisher")
|
|
495
|
+
if isinstance(publishers, list) and publishers:
|
|
496
|
+
return str((publishers[0] or {}).get("name") or "")
|
|
497
|
+
if isinstance(publishers, dict):
|
|
498
|
+
return str(publishers.get("name") or "")
|
|
499
|
+
return ""
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _scholix_provider(link: dict) -> str:
|
|
503
|
+
providers = link.get("LinkProvider")
|
|
504
|
+
if isinstance(providers, list) and providers:
|
|
505
|
+
return str((providers[0] or {}).get("name") or "")
|
|
506
|
+
return ""
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _classify_scholix(target_doi: Optional[str], title: str, relation: str) -> str:
|
|
510
|
+
if _TOOL_CITATION_RE.search(title or ""):
|
|
511
|
+
return CLASS_TOOL_CITATION
|
|
512
|
+
if target_doi and target_doi.startswith(_PAPER_DOI_PREFIXES):
|
|
513
|
+
return CLASS_RELATED
|
|
514
|
+
if relation.strip().lower() in _OWNED_RELATIONS:
|
|
515
|
+
return CLASS_OWNED
|
|
516
|
+
return CLASS_RELATED
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def enumerate_fulltext_scan(ids, ctx) -> List[SupplementFile]:
|
|
520
|
+
"""E18: deposits the paper names in its own prose.
|
|
521
|
+
|
|
522
|
+
Only runs under --download-data-artifacts, and only reads files already on
|
|
523
|
+
disk, so it costs no network requests for discovery. Link indexes missed
|
|
524
|
+
every one of the 14 OSF deposits found this way in a real corpus.
|
|
525
|
+
|
|
526
|
+
Reads the record's PDF when there is no markup beside it. That was a
|
|
527
|
+
silent coverage hole rather than a limitation: measured 2026-08-22, 606 of
|
|
528
|
+
1,491 corpus records are PDF-only, and every one of them was reported as
|
|
529
|
+
"scanned, nothing found" without ever being opened.
|
|
530
|
+
|
|
531
|
+
Precision, not recall, is the risk here -- half the repository URLs in a
|
|
532
|
+
paper belong to somebody else -- so fulltext_scan applies its rules first
|
|
533
|
+
and only accepted candidates are routed. Refused and uncertain ones are
|
|
534
|
+
recorded in the sidecar unrouted, where they can be audited.
|
|
535
|
+
"""
|
|
536
|
+
if not getattr(ctx, "download_data_artifacts", False):
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
from .fulltext_scan import ACCEPT, scan_record
|
|
540
|
+
from .linked_artifacts import record_link, record_query
|
|
541
|
+
|
|
542
|
+
stem = os.path.splitext(ctx.save_path)[0] if ctx.save_path else ""
|
|
543
|
+
if not stem:
|
|
544
|
+
return []
|
|
545
|
+
try:
|
|
546
|
+
candidates = scan_record(stem, log=ctx.log)
|
|
547
|
+
except Exception as error: # never break a run over a scan
|
|
548
|
+
ctx.log(f" fulltext_scan: {error}")
|
|
549
|
+
return []
|
|
550
|
+
|
|
551
|
+
record_query(ctx, service="fulltext_scan", url="(local full text)",
|
|
552
|
+
status="ok", detail=f"{len(candidates)} repository mention(s)")
|
|
553
|
+
if not candidates:
|
|
554
|
+
return []
|
|
555
|
+
|
|
556
|
+
if getattr(ctx, "llm_adjudicate_artifacts", False):
|
|
557
|
+
from .llm_adjudicate import adjudicate
|
|
558
|
+
adjudicate(candidates,
|
|
559
|
+
model=getattr(ctx, "llm_model", None) or "haiku",
|
|
560
|
+
log=ctx.log, enabled=True)
|
|
561
|
+
|
|
562
|
+
files: List[SupplementFile] = []
|
|
563
|
+
for candidate in candidates:
|
|
564
|
+
routed: List[SupplementFile] = []
|
|
565
|
+
if candidate.verdict == ACCEPT and candidate.repo != "github":
|
|
566
|
+
# GitHub has no DOI, so it cannot go through _files_in_repository;
|
|
567
|
+
# it is handled by its own enumerator below.
|
|
568
|
+
routed = _files_in_repository(
|
|
569
|
+
candidate.ident if "/" in candidate.ident else candidate.url,
|
|
570
|
+
ids, ctx, via="fulltext_scan")
|
|
571
|
+
files.extend(routed)
|
|
572
|
+
elif candidate.verdict == ACCEPT and candidate.repo == "github":
|
|
573
|
+
routed = _github_tarball(candidate.ident, ctx)
|
|
574
|
+
files.extend(routed)
|
|
575
|
+
|
|
576
|
+
record_link(
|
|
577
|
+
ctx, service="fulltext_scan", target_pid=candidate.url,
|
|
578
|
+
target_type="software" if candidate.repo == "github" else "dataset",
|
|
579
|
+
relation="NamedInFullText",
|
|
580
|
+
classified=CLASS_OWNED if candidate.verdict == ACCEPT else CLASS_RELATED,
|
|
581
|
+
title=candidate.reason[:300], publisher=candidate.repo,
|
|
582
|
+
provider_name=candidate.adjudicated_by or "rules",
|
|
583
|
+
routed=bool(routed),
|
|
584
|
+
)
|
|
585
|
+
return files
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
#: A branch tarball is what a replication check needs; git history is not.
|
|
589
|
+
_GITHUB_TARBALL = "https://codeload.github.com/{repo}/tar.gz/refs/heads/{branch}"
|
|
590
|
+
_GITHUB_API = "https://api.github.com/repos/{repo}"
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _github_tarball(repo: str, ctx) -> List[SupplementFile]:
|
|
594
|
+
"""One archive for a GitHub repository, at its default branch."""
|
|
595
|
+
branch = "main"
|
|
596
|
+
try:
|
|
597
|
+
response = ctx.http.get(_GITHUB_API.format(repo=repo), timeout=20,
|
|
598
|
+
polite=False)
|
|
599
|
+
if response.ok:
|
|
600
|
+
branch = ((response.json() or {}).get("default_branch")) or "main"
|
|
601
|
+
except Exception:
|
|
602
|
+
pass # main is the right guess when the API is unavailable
|
|
603
|
+
return [SupplementFile(
|
|
604
|
+
name=f"{repo.replace('/', '-')}-{branch}.tar.gz",
|
|
605
|
+
url=_GITHUB_TARBALL.format(repo=repo, branch=branch),
|
|
606
|
+
provider="fulltext_scan:github",
|
|
607
|
+
# NOT is_archive. That flag means "expand rather than keep", and the
|
|
608
|
+
# expander is `_expand_zip` -- zipfile, on a gzip stream. Every GitHub
|
|
609
|
+
# repository this provider has ever found was refused
|
|
610
|
+
# `not-an-archive` because of it: zero `fulltext_scan:github` files
|
|
611
|
+
# exist across 1,934 corpus manifests, and the tarball URL itself is
|
|
612
|
+
# fine (verified: HTTP 200, 6.5 MB of valid gzip).
|
|
613
|
+
#
|
|
614
|
+
# Keeping it whole is also the right policy on its own terms. A
|
|
615
|
+
# repository is a replication package, and this module already refuses
|
|
616
|
+
# to explode those by default -- unpacking one produced 199 files from
|
|
617
|
+
# a vendored Stata library in a single record.
|
|
618
|
+
role=ROLE_SUPPLEMENT,
|
|
619
|
+
extra={"github_repo": repo, "branch": branch},
|
|
620
|
+
)]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _should_route(classified: str, ctx, target_doi=None, ids=None) -> bool:
|
|
624
|
+
"""Whether a link of this class is downloaded, not merely recorded.
|
|
625
|
+
|
|
626
|
+
Default: only the paper's own material. That is the conservative reading
|
|
627
|
+
and the right one when the caller asked for supplementary files.
|
|
628
|
+
|
|
629
|
+
Under --download-data-artifacts the gate widens to `related` as well,
|
|
630
|
+
because on real corpora that is where the research data actually is: in a
|
|
631
|
+
44-sidecar sample every `owned` link was a BioStudies mirror of
|
|
632
|
+
supplementary files already fetched, while the Zenodo record (8 files,
|
|
633
|
+
14 MB CSV) and the Dataverse replication packages were all `related`.
|
|
634
|
+
|
|
635
|
+
`registry` never routes -- ClinicalTrials.gov and friends have nothing to
|
|
636
|
+
download. `tool_citation` never routes either: those are libraries the
|
|
637
|
+
authors used, and the Scholix ones carry no target_pid at all, so there is
|
|
638
|
+
nothing to fetch even if we wanted it. The paper's OWN code is found by
|
|
639
|
+
reading the paper (fulltext_scan), not by trusting a citation index.
|
|
640
|
+
"""
|
|
641
|
+
if classified == CLASS_OWNED:
|
|
642
|
+
return True
|
|
643
|
+
if not getattr(ctx, "download_data_artifacts", False):
|
|
644
|
+
return False
|
|
645
|
+
if classified != CLASS_RELATED:
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
# `related` means an index asserted a connection, not that the deposit is
|
|
649
|
+
# this paper's. Downloading on that assertion alone fetched "Tying Odysseus
|
|
650
|
+
# to the Mast" (Ashraf 2006) and filed it under a 2025 savings-reminder
|
|
651
|
+
# megastudy. So the deposit has to name this article in its own DataCite
|
|
652
|
+
# record before we take it -- the same oracle already used to upgrade
|
|
653
|
+
# `related` to `owned`, simply consulted for the routing decision too.
|
|
654
|
+
if getattr(ctx, "download_related_unverified", False):
|
|
655
|
+
return True
|
|
656
|
+
if not target_doi or not getattr(ids, "doi", None):
|
|
657
|
+
return False
|
|
658
|
+
# Requiring the deposit to NAME this article is too strict for real
|
|
659
|
+
# metadata: Zenodo's MegaOath dataset declares IsSupplementTo an OSF
|
|
660
|
+
# project rather than the paper, and Harvard Dataverse fills in no
|
|
661
|
+
# relatedIdentifiers at all. Both are genuinely the article's data, and
|
|
662
|
+
# both would be refused.
|
|
663
|
+
#
|
|
664
|
+
# What actually separates the false positive is the opposite signal: the
|
|
665
|
+
# Odysseus deposit declares IsSupplementTo 10.1162/qjec.2006.121.2.635 --
|
|
666
|
+
# it says out loud that it belongs to a DIFFERENT paper. So accept unless
|
|
667
|
+
# the deposit claims another article, rather than demanding it claim ours.
|
|
668
|
+
if _deposit_names_article(target_doi, ids.doi, ctx):
|
|
669
|
+
return True
|
|
670
|
+
return not _deposit_claims_another_article(target_doi, ids.doi, ctx)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
#: Relations by which a deposit declares which article it belongs to. If one of
|
|
674
|
+
#: these points at a DIFFERENT paper, the deposit is that paper's, not ours.
|
|
675
|
+
_OWNERSHIP_RELATIONS = {"issupplementto", "ispartof", "isdescribedby",
|
|
676
|
+
"iscitedby", "isreferencedby"}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _deposit_claims_another_article(target_doi: str, article_doi: str, ctx) -> bool:
|
|
680
|
+
"""True when the deposit says it belongs to some other paper.
|
|
681
|
+
|
|
682
|
+
The precise signal that separates a wrongly-routed deposit from a correctly
|
|
683
|
+
routed one on real data. "Tying Odysseus to the Mast" declares
|
|
684
|
+
IsSupplementTo 10.1162/qjec.2006.121.2.635; it was being filed under a 2025
|
|
685
|
+
savings-reminder megastudy. Deposits with no relatedIdentifiers at all
|
|
686
|
+
(Harvard Dataverse fills in none) are NOT caught by this, which is correct
|
|
687
|
+
-- absence of a claim is not a claim to the contrary.
|
|
688
|
+
|
|
689
|
+
Fails OPEN on any lookup error: a DataCite outage should not silently start
|
|
690
|
+
discarding real data.
|
|
691
|
+
"""
|
|
692
|
+
cache = ctx.scratch.setdefault("_deposit_other_article", {})
|
|
693
|
+
key = (target_doi or "").strip().lower()
|
|
694
|
+
if key in cache:
|
|
695
|
+
return cache[key]
|
|
696
|
+
|
|
697
|
+
verdict = False
|
|
698
|
+
try:
|
|
699
|
+
response = ctx.http.get(f"https://api.datacite.org/dois/{key}",
|
|
700
|
+
timeout=25, polite=False)
|
|
701
|
+
if response.ok:
|
|
702
|
+
attributes = ((response.json() or {}).get("data") or {}).get("attributes") or {}
|
|
703
|
+
mine = (article_doi or "").strip().lower()
|
|
704
|
+
for relation in attributes.get("relatedIdentifiers") or []:
|
|
705
|
+
if not isinstance(relation, dict):
|
|
706
|
+
continue
|
|
707
|
+
kind = str(relation.get("relationType") or "").strip().lower()
|
|
708
|
+
if kind not in _OWNERSHIP_RELATIONS:
|
|
709
|
+
continue
|
|
710
|
+
other = str(relation.get("relatedIdentifier") or "").strip().lower()
|
|
711
|
+
# Only a DOI naming a different *article* counts. A deposit
|
|
712
|
+
# pointing at an OSF project or its own earlier version is not
|
|
713
|
+
# claiming another paper.
|
|
714
|
+
if not other.startswith("10.") or mine in other:
|
|
715
|
+
continue
|
|
716
|
+
if other.startswith(_DEPOSIT_DOI_PREFIXES):
|
|
717
|
+
continue
|
|
718
|
+
verdict = True
|
|
719
|
+
ctx.log(f" {key}: declares {kind} {other} -- not this article")
|
|
720
|
+
break
|
|
721
|
+
except Exception:
|
|
722
|
+
verdict = False
|
|
723
|
+
cache[key] = verdict
|
|
724
|
+
return verdict
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
#: Prefixes that are DEPOSITS, not articles. A deposit relating to one of these
|
|
728
|
+
#: is relating to data or a project page, which says nothing about which paper
|
|
729
|
+
#: owns it.
|
|
730
|
+
_DEPOSIT_DOI_PREFIXES = ("10.5281", "10.6084", "10.17605", "10.5061", "10.7910",
|
|
731
|
+
"10.3886", "10.25384", "10.24433")
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _has_repo_enumerator(target_doi: Optional[str]) -> bool:
|
|
735
|
+
"""Whether a DOI would route somewhere in _files_in_repository.
|
|
736
|
+
|
|
737
|
+
Gates the ownership lookup: a DOI that cannot be downloaded anyway is not
|
|
738
|
+
worth a DataCite request to classify more finely.
|
|
739
|
+
"""
|
|
740
|
+
if not target_doi:
|
|
741
|
+
return False
|
|
742
|
+
doi = target_doi.strip().lower()
|
|
743
|
+
return (doi.startswith(("10.5281", "10.6084", "10.17605", "10.5061", "10.7910/dvn"))
|
|
744
|
+
or "zenodo" in doi or "figshare" in doi or "osf.io" in doi or "dryad" in doi)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# -- E17: Europe PMC datalinks ------------------------------------------------
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def enumerate_epmc_datalinks(ids, ctx) -> List[SupplementFile]:
|
|
751
|
+
"""Europe PMC's data links for this article: text-mined accessions,
|
|
752
|
+
data citations, and BioStudies deposits.
|
|
753
|
+
|
|
754
|
+
Registry accessions (ClinicalTrials.gov and friends) are sidecar-only by
|
|
755
|
+
nature. BioStudies links are sidecar-only by choice: the study page mirrors
|
|
756
|
+
the EPMC supplementary bundle that europepmc_supplements already fetches,
|
|
757
|
+
so downloading it again would double every file. Data-citation DOIs route
|
|
758
|
+
to the repository enumerators like every other deposit.
|
|
759
|
+
"""
|
|
760
|
+
if not ids.pmid:
|
|
761
|
+
return []
|
|
762
|
+
|
|
763
|
+
response = ctx.http.get(
|
|
764
|
+
EPMC_DATALINKS.format(pmid=ids.pmid),
|
|
765
|
+
params={"format": "json"},
|
|
766
|
+
timeout=30,
|
|
767
|
+
)
|
|
768
|
+
if not response.ok:
|
|
769
|
+
record_query(ctx, "epmc_datalinks", response.request_url,
|
|
770
|
+
"error", f"http {response.status}")
|
|
771
|
+
return []
|
|
772
|
+
try:
|
|
773
|
+
payload = response.json() or {}
|
|
774
|
+
except ValueError:
|
|
775
|
+
record_query(ctx, "epmc_datalinks", response.request_url,
|
|
776
|
+
"error", "not JSON")
|
|
777
|
+
return []
|
|
778
|
+
|
|
779
|
+
categories = ((payload.get("dataLinkList") or {}).get("Category")) or []
|
|
780
|
+
record_query(ctx, "epmc_datalinks", response.request_url, "ok",
|
|
781
|
+
f"{payload.get('hitCount', 0)} link(s), "
|
|
782
|
+
f"{len(categories)} categorie(s)")
|
|
783
|
+
|
|
784
|
+
files: List[SupplementFile] = []
|
|
785
|
+
for category in categories:
|
|
786
|
+
if not isinstance(category, dict):
|
|
787
|
+
continue
|
|
788
|
+
category_name = str(category.get("Name") or "")
|
|
789
|
+
# Altmetric is attention, not data.
|
|
790
|
+
if category_name.lower().startswith("altmetric"):
|
|
791
|
+
continue
|
|
792
|
+
for section in category.get("Section") or []:
|
|
793
|
+
for link in ((section or {}).get("Linklist") or {}).get("Link") or []:
|
|
794
|
+
if not isinstance(link, dict):
|
|
795
|
+
continue
|
|
796
|
+
files.extend(_take_epmc_link(link, category_name, ids, ctx))
|
|
797
|
+
return files
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _take_epmc_link(link: dict, category_name: str, ids, ctx) -> List[SupplementFile]:
|
|
801
|
+
target = link.get("Target") or {}
|
|
802
|
+
identifier = target.get("Identifier") or {}
|
|
803
|
+
scheme = str(identifier.get("IDScheme") or "").strip()
|
|
804
|
+
pid = str(identifier.get("ID") or "").strip()
|
|
805
|
+
title = str(target.get("Title") or "")
|
|
806
|
+
relation = str(((link.get("RelationshipType") or {}).get("Name")) or "")
|
|
807
|
+
|
|
808
|
+
is_doi = scheme.lower() == "doi"
|
|
809
|
+
target_doi = pid.lower() if is_doi else None
|
|
810
|
+
if scheme.lower() == "clinicaltrials.gov" or "clinical trials" in category_name.lower():
|
|
811
|
+
classified = CLASS_REGISTRY
|
|
812
|
+
elif "biostudies" in category_name.lower():
|
|
813
|
+
# The article's own deposit, but see the enumerator docstring.
|
|
814
|
+
classified = CLASS_OWNED
|
|
815
|
+
else:
|
|
816
|
+
# EPMC "Data Citations" are text-mined from the whole paper -- the
|
|
817
|
+
# data-availability statement AND the reference list, with nothing to
|
|
818
|
+
# tell them apart. On a real corpus the majority were cited journal
|
|
819
|
+
# articles wearing a dataset costume, so every one is a citation until
|
|
820
|
+
# the deposit's own DataCite record names this article.
|
|
821
|
+
classified = CLASS_RELATED
|
|
822
|
+
if _has_repo_enumerator(target_doi) and \
|
|
823
|
+
_deposit_names_article(target_doi, ids.doi, ctx):
|
|
824
|
+
classified = CLASS_OWNED
|
|
825
|
+
|
|
826
|
+
routed_files: List[SupplementFile] = []
|
|
827
|
+
if target_doi and _should_route(classified, ctx, target_doi, ids) and \
|
|
828
|
+
not target_doi.startswith(_PAPER_DOI_PREFIXES) and \
|
|
829
|
+
"biostudies" not in category_name.lower():
|
|
830
|
+
routed_files = _files_in_repository(target_doi, ids, ctx, via="epmc_datalinks")
|
|
831
|
+
|
|
832
|
+
record_link(
|
|
833
|
+
ctx, service="epmc_datalinks",
|
|
834
|
+
target_pid=pid or None,
|
|
835
|
+
target_type=str(((target.get("Type") or {}).get("Name")) or category_name),
|
|
836
|
+
relation=relation, classified=classified, title=title,
|
|
837
|
+
publisher=str(((target.get("Publisher") or {}).get("Name")) or ""),
|
|
838
|
+
provider_name=str(((link.get("LinkProvider") or {}).get("Name")) or ""),
|
|
839
|
+
harvest_date=str(link.get("PublicationDate") or ""),
|
|
840
|
+
routed=bool(routed_files),
|
|
841
|
+
)
|
|
842
|
+
return routed_files
|