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,472 @@
|
|
|
1
|
+
"""Find the paper's own data deposits by reading the paper.
|
|
2
|
+
|
|
3
|
+
Link services (ScholeXplorer, Europe PMC datalinks) answer "what does an index
|
|
4
|
+
say is connected to this DOI". That misses a great deal: across 13 full-text
|
|
5
|
+
records in a real corpus they surfaced zero of the 14 OSF deposits the articles
|
|
6
|
+
themselves name in their own prose. The article is the better source, and it is
|
|
7
|
+
already on disk -- so this provider costs no network requests at all.
|
|
8
|
+
|
|
9
|
+
The catch is precision. A bare URL regex over full text is only about HALF
|
|
10
|
+
right, because the same page also contains:
|
|
11
|
+
|
|
12
|
+
* preregistrations "preregistration: https://osf.io/2efnw"
|
|
13
|
+
* OTHER papers' deposits a reference-list entry citing someone's archive
|
|
14
|
+
* preprint DOIs 10.31234/osf.io/8r9p7 is PsyArXiv, not a deposit
|
|
15
|
+
|
|
16
|
+
Downloading those is the "dirty directories" failure: gigabytes of somebody
|
|
17
|
+
else's material filed under this paper. So three rules run over the prose around
|
|
18
|
+
each URL, and a candidate is accepted only when the sentence actually claims a
|
|
19
|
+
deposit. On the 14 hand-labelled OSF IDs this scores 7/7 accepted and 7/7
|
|
20
|
+
refused -- though those rules were written after reading those examples, so
|
|
21
|
+
treat the number as a regression baseline rather than a validated accuracy.
|
|
22
|
+
|
|
23
|
+
What this deliberately does NOT do is guess. A candidate the rules cannot settle
|
|
24
|
+
is returned as UNCERTAIN rather than silently dropped, so the caller can pay a
|
|
25
|
+
model to adjudicate it (see llm_adjudicate) or record it unresolved. Losing a
|
|
26
|
+
deposit silently is the one failure mode worth engineering against: an extra
|
|
27
|
+
download is visible and cheap, a missing one is invisible.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
#: Verdicts. UNCERTAIN is a real answer, not a failure -- it is the queue that
|
|
36
|
+
#: optional LLM adjudication drains.
|
|
37
|
+
ACCEPT = "accept"
|
|
38
|
+
REFUSE = "refuse"
|
|
39
|
+
UNCERTAIN = "uncertain"
|
|
40
|
+
|
|
41
|
+
#: How much prose around a URL is read. 300 chars each way covers the sentence
|
|
42
|
+
#: and its neighbour, which is where "deposited"/"preregistration" live, without
|
|
43
|
+
#: dragging in the next paragraph's unrelated claims.
|
|
44
|
+
CONTEXT_CHARS = 300
|
|
45
|
+
|
|
46
|
+
#: Repositories worth resolving. Keyed by the name _files_in_repository expects.
|
|
47
|
+
#: GitHub is matched but handled separately -- see _GITHUB below.
|
|
48
|
+
_REPO_PATTERNS = (
|
|
49
|
+
("osf", re.compile(r"osf\.io/([a-z0-9]{5})\b", re.I)),
|
|
50
|
+
("zenodo", re.compile(r"zenodo\.org/record[s]?/(\d+)", re.I)),
|
|
51
|
+
("zenodo", re.compile(r"\b(10\.5281/zenodo\.\d+)", re.I)),
|
|
52
|
+
("dryad", re.compile(r"\b(10\.5061/dryad\.[a-z0-9./]+)", re.I)),
|
|
53
|
+
("dataverse", re.compile(r"\b(10\.7910/dvn/[a-z0-9]+)", re.I)),
|
|
54
|
+
("figshare", re.compile(r"figshare\.com/[^\s\"'<>)]*?(\d{6,})", re.I)),
|
|
55
|
+
("github", re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", re.I)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: Rule 1 -- the sentence has to claim a deposit. Without one of these the URL
|
|
59
|
+
#: is just a URL: mentioned in passing, in a footnote, or somebody else's.
|
|
60
|
+
_DEPOSIT_LANGUAGE = re.compile(
|
|
61
|
+
r"(deposited|data\s+availability|code\s+availability|are\s+available|"
|
|
62
|
+
r"is\s+available|publicly\s+available|available\s+(at|on|from)|"
|
|
63
|
+
r"analysis\s+(code|scripts)|materials\s+and\s+data|data\s+and\s+(code|materials)|"
|
|
64
|
+
r"online\s+archive|can\s+be\s+(found|accessed)|have\s+been\s+shared|"
|
|
65
|
+
r"osf\s+project)",
|
|
66
|
+
re.I,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
#: Rule 2 -- a preregistration is not data. Papers routinely link both, and the
|
|
70
|
+
#: prereg is the one that is NOT the analysis dataset.
|
|
71
|
+
#:
|
|
72
|
+
#: This must match the URL's PURPOSE, not merely the word appearing nearby. A
|
|
73
|
+
#: deposit's contents are often listed as "preregistrations, the analysis code,
|
|
74
|
+
#: anonymized data ... available as an OSF project at <url>" -- one link
|
|
75
|
+
#: holding everything, including a prereg. Matching a bare "prereg" there
|
|
76
|
+
#: refused a real deposit. So require the word to be attached to the link:
|
|
77
|
+
#: "preregistration: <url>", "preregistered at <url>", "(preregistration <url>)".
|
|
78
|
+
_PREREG_LANGUAGE = re.compile(
|
|
79
|
+
r"(prereg\w*\s*(:|at|is|was|available|link|page|here)?\s*[\(\[]?\s*$|"
|
|
80
|
+
r"pre-regist\w*\s*(:|at)?\s*[\(\[]?\s*$|"
|
|
81
|
+
r"analysis\s+plan\s*[\(\[:]?\s*$|"
|
|
82
|
+
r"registered\s+report\s*[\(\[:]?\s*$)",
|
|
83
|
+
re.I,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
#: A deposit that merely CONTAINS a preregistration among other things is still
|
|
87
|
+
#: a deposit. When both signals fire, this one wins.
|
|
88
|
+
_MULTI_ARTIFACT_DEPOSIT = re.compile(
|
|
89
|
+
r"(all\s+materials|materials\s+includ\w+|includ\w+\s+.{0,80}"
|
|
90
|
+
r"(analysis\s+code|anonymized\s+data|data\s+and\s+code)|"
|
|
91
|
+
r"(analysis\s+code|anonymi[sz]ed\s+data)\s*,)",
|
|
92
|
+
re.I,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
#: Rule 3 -- inside a reference/citation the deposit belongs to the work being
|
|
96
|
+
#: CITED, not to this paper. JATS marks these structurally, which is why this
|
|
97
|
+
#: is reliable rather than a guess.
|
|
98
|
+
_CITATION_CONTEXT = re.compile(
|
|
99
|
+
r"(<ref\b|</ref>|<mixed-citation|<element-citation|citation-string|"
|
|
100
|
+
r"ref-list|<nlm-citation|\[Preprint\])",
|
|
101
|
+
re.I,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
#: Preprint-server DOIs that embed "osf.io" but name a PREPRINT, not a deposit.
|
|
105
|
+
#: Without this, 10.31234/osf.io/8r9p7 is scraped as OSF id "8r9p7" and
|
|
106
|
+
#: downloaded. 10.31219 is OSF Preprints' own prefix and was missing: a live
|
|
107
|
+
#: corpus reference list carries "OSF https://doi.org/10.31219/osf.io/k268q",
|
|
108
|
+
#: which this scan would otherwise have fetched as somebody else's deposit.
|
|
109
|
+
_PREPRINT_DOI = re.compile(r"10\.(?:31234|31219)/", re.I)
|
|
110
|
+
|
|
111
|
+
#: Rule 3 boost -- a URL inside a data-availability section is as strong a
|
|
112
|
+
#: signal as prose gets. Not required (only 4 of 13 records have such a
|
|
113
|
+
#: section), but it upgrades an otherwise uncertain candidate.
|
|
114
|
+
_DAS_SECTION = re.compile(
|
|
115
|
+
r"(data[\s-]*availability|code[\s-]*availability|"
|
|
116
|
+
r"data,\s*materials,?\s*and\s*software\s*availability|<ack\b)",
|
|
117
|
+
re.I,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
#: Well-known tool orgs. A backstop under both the rules and any LLM verdict:
|
|
121
|
+
#: these are libraries the authors USED, never the paper's own deposit.
|
|
122
|
+
_TOOL_ORGS = frozenset({
|
|
123
|
+
"stan-dev", "tidyverse", "numpy", "scipy", "pandas-dev", "rstudio",
|
|
124
|
+
"matplotlib", "scikit-learn", "statsmodels", "pytorch", "tensorflow",
|
|
125
|
+
"jupyter", "ipython", "r-lib", "rstan", "pymc-devs", "huggingface",
|
|
126
|
+
"apache", "microsoft", "google", "facebook", "openai",
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Candidate:
|
|
132
|
+
"""One repository reference found in a paper, and what we decided about it."""
|
|
133
|
+
|
|
134
|
+
repo: str # "osf", "zenodo", "github", ...
|
|
135
|
+
ident: str # the id/DOI as matched
|
|
136
|
+
url: str # normalized, resolvable
|
|
137
|
+
verdict: str = UNCERTAIN
|
|
138
|
+
reason: str = ""
|
|
139
|
+
context: str = "" # prose that drove the verdict; audit trail
|
|
140
|
+
in_das: bool = False
|
|
141
|
+
adjudicated_by: Optional[str] = None # set when an LLM overrode the rules
|
|
142
|
+
extra: Dict[str, str] = field(default_factory=dict)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def key(self) -> str:
|
|
146
|
+
return f"{self.repo}:{self.ident.lower()}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_TAG = re.compile(r"<[^>]*>")
|
|
150
|
+
#: The window is a slice, so its final tag is usually CUT OFF mid-attribute
|
|
151
|
+
#: ('<ext-link xlink:href="') and has no closing bracket for _TAG to match.
|
|
152
|
+
#: That unterminated remnant is exactly what sits between the prereg word and
|
|
153
|
+
#: the URL, so it has to go too or the anchored test can never fire.
|
|
154
|
+
_OPEN_TAG_TAIL = re.compile(r"<[^<>]*$")
|
|
155
|
+
_URL_TAIL = re.compile(r"https?:(//)?\S*$", re.I)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _strip_tags(fragment: str) -> str:
|
|
159
|
+
"""Prose only: closed tags, the trailing half-tag, and any dangling URL.
|
|
160
|
+
|
|
161
|
+
All three matter for the anchored prereg test, which asks whether the word
|
|
162
|
+
is attached to THIS link rather than merely present in the paragraph.
|
|
163
|
+
"""
|
|
164
|
+
text = _TAG.sub(" ", fragment)
|
|
165
|
+
text = _OPEN_TAG_TAIL.sub(" ", text)
|
|
166
|
+
text = " ".join(text.split())
|
|
167
|
+
return _URL_TAIL.sub("", text).rstrip()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalize(repo: str, ident: str) -> str:
|
|
171
|
+
"""A URL the downstream enumerators can actually resolve."""
|
|
172
|
+
if repo == "osf":
|
|
173
|
+
return f"https://osf.io/{ident}/"
|
|
174
|
+
if repo == "zenodo":
|
|
175
|
+
if ident.lower().startswith("10.5281"):
|
|
176
|
+
return f"https://doi.org/{ident}"
|
|
177
|
+
# As the DOI, not the web URL: the downstream enumerator matches
|
|
178
|
+
# r"zenodo\.(\d+)", which a /records/<id> path does not satisfy.
|
|
179
|
+
return f"https://doi.org/10.5281/zenodo.{ident}"
|
|
180
|
+
if repo == "dryad":
|
|
181
|
+
return f"https://doi.org/{ident}"
|
|
182
|
+
if repo == "dataverse":
|
|
183
|
+
return f"https://doi.org/{ident}"
|
|
184
|
+
if repo == "figshare":
|
|
185
|
+
# Same reason as Zenodo: the enumerator matches r"figshare\.(\d+)",
|
|
186
|
+
# which a /articles/<id> path does not satisfy.
|
|
187
|
+
return f"https://doi.org/10.6084/m9.figshare.{ident}"
|
|
188
|
+
if repo == "github":
|
|
189
|
+
return f"https://github.com/{ident}"
|
|
190
|
+
return ident
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _judge(repo: str, ident: str, context: str, in_das: bool,
|
|
194
|
+
in_citation: bool = False, before: str = ""):
|
|
195
|
+
"""Apply the three rules to one candidate's surrounding prose.
|
|
196
|
+
|
|
197
|
+
`in_citation` and `before` are both computed from what PRECEDES the URL,
|
|
198
|
+
not from the whole window -- see _inside_citation for why that distinction
|
|
199
|
+
is load-bearing. `before` is the ~120 chars immediately before the link.
|
|
200
|
+
"""
|
|
201
|
+
# Rule 3a: a PsyArXiv DOI is a preprint that merely contains "osf.io".
|
|
202
|
+
if _PREPRINT_DOI.search(context):
|
|
203
|
+
return REFUSE, "PsyArXiv preprint DOI, not a data deposit"
|
|
204
|
+
|
|
205
|
+
# Rule 3b: inside a reference, the deposit belongs to the cited work.
|
|
206
|
+
if in_citation:
|
|
207
|
+
return REFUSE, "appears inside a reference/citation entry"
|
|
208
|
+
|
|
209
|
+
# Rule 2: a preregistration is not the analysis data -- but only when the
|
|
210
|
+
# word is attached to THIS link (`before` is the text immediately preceding
|
|
211
|
+
# the URL), and not when the sentence describes a multi-artifact deposit
|
|
212
|
+
# that merely includes a prereg alongside the data.
|
|
213
|
+
if _PREREG_LANGUAGE.search(before) and not _MULTI_ARTIFACT_DEPOSIT.search(context):
|
|
214
|
+
return REFUSE, "preregistration, not a data deposit"
|
|
215
|
+
|
|
216
|
+
if repo == "github":
|
|
217
|
+
owner = ident.split("/", 1)[0].lower()
|
|
218
|
+
if owner in _TOOL_ORGS:
|
|
219
|
+
return REFUSE, f"{owner} is a known tool/library org"
|
|
220
|
+
# GitHub is where the rules are weakest: "we used X" and "our code is
|
|
221
|
+
# at X" look alike outside a DAS. Only a data-availability section is
|
|
222
|
+
# strong enough on its own; everything else goes to adjudication.
|
|
223
|
+
if in_das and _DEPOSIT_LANGUAGE.search(context):
|
|
224
|
+
return ACCEPT, "code repository named in a data/code availability section"
|
|
225
|
+
return UNCERTAIN, "GitHub link outside a data-availability section"
|
|
226
|
+
|
|
227
|
+
# Rule 1: for deposit repositories, deposit language is the signal.
|
|
228
|
+
if _DEPOSIT_LANGUAGE.search(context):
|
|
229
|
+
where = "data-availability section" if in_das else "deposit language nearby"
|
|
230
|
+
return ACCEPT, f"deposit repository with {where}"
|
|
231
|
+
|
|
232
|
+
return UNCERTAIN, "repository URL with no deposit language nearby"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
#: Markers that OPEN and CLOSE a reference entry. Direction matters, so these
|
|
236
|
+
#: are separate from the loose _CITATION_CONTEXT scan.
|
|
237
|
+
_CITATION_OPEN = re.compile(r"<(ref|mixed-citation|element-citation|nlm-citation)\b", re.I)
|
|
238
|
+
_CITATION_CLOSE = re.compile(r"</(ref|mixed-citation|element-citation|nlm-citation)>", re.I)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _inside_citation(text: str, position: int) -> bool:
|
|
242
|
+
"""True when the URL at `position` sits inside a reference entry.
|
|
243
|
+
|
|
244
|
+
Looking for "<ref" anywhere in a symmetric window is wrong, and wrongly
|
|
245
|
+
refused a real deposit: PNAS puts the data-availability sentence
|
|
246
|
+
immediately before <ref-list>, so a citation tag 200 characters AFTER the
|
|
247
|
+
URL disqualified prose that plainly said "publicly available online as an
|
|
248
|
+
OSF project at ...".
|
|
249
|
+
|
|
250
|
+
What actually matters is whether the nearest citation delimiter BEFORE the
|
|
251
|
+
URL is an opening tag. If it is, we are inside an entry; if it is a closing
|
|
252
|
+
tag (or there is none), we are in body prose.
|
|
253
|
+
"""
|
|
254
|
+
window = text[max(0, position - 4000):position]
|
|
255
|
+
last_open = None
|
|
256
|
+
for match in _CITATION_OPEN.finditer(window):
|
|
257
|
+
last_open = match.start()
|
|
258
|
+
last_close = None
|
|
259
|
+
for match in _CITATION_CLOSE.finditer(window):
|
|
260
|
+
last_close = match.start()
|
|
261
|
+
if last_open is None:
|
|
262
|
+
return False
|
|
263
|
+
if last_close is None:
|
|
264
|
+
return True
|
|
265
|
+
return last_open > last_close
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
#: A bibliography heading standing alone on its line. Markdown hashes and a
|
|
269
|
+
#: leading section number are allowed because that is how a PDF-to-text pass
|
|
270
|
+
#: and a converted body render one. Deliberately NOT matched inside markup: in
|
|
271
|
+
#: JATS the same word arrives as <title>References</title>, which is
|
|
272
|
+
#: _inside_citation's job and is decided structurally rather than by a heading.
|
|
273
|
+
_REFERENCE_HEADING = re.compile(
|
|
274
|
+
r"^[ \t]{0,3}(?:#{1,6}[ \t]*)?(?:\d{0,2}[.)][ \t]*)?"
|
|
275
|
+
r"(references|bibliography|works\s+cited|literature\s+cited|"
|
|
276
|
+
r"reference\s+list)[ \t]*:?[ \t]*$",
|
|
277
|
+
re.I | re.M,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _after_reference_heading(text: str, position: int) -> bool:
|
|
282
|
+
"""True when the URL at `position` sits in the bibliography of plain text.
|
|
283
|
+
|
|
284
|
+
The plain-text sibling of `_inside_citation`, and it exists because that
|
|
285
|
+
function is built entirely on JATS tags: on a PDF or a markdown body it
|
|
286
|
+
returns False at every position, so every reference-list URL becomes a live
|
|
287
|
+
candidate. That is the "dirty directories" failure -- somebody else's
|
|
288
|
+
deposit downloaded and filed under this paper.
|
|
289
|
+
|
|
290
|
+
POSITION-BASED, NOT TRUNCATING, for the reason
|
|
291
|
+
`test_deposit_before_a_reference_list_is_accepted` records: availability
|
|
292
|
+
sentences sit immediately before the bibliography, so cutting the document
|
|
293
|
+
at the heading discards the very prose this scan exists to read.
|
|
294
|
+
|
|
295
|
+
And a data-availability heading BETWEEN the bibliography and the URL
|
|
296
|
+
re-opens the document. Journals that put availability statements in back
|
|
297
|
+
matter after the references are common enough that a blanket "everything
|
|
298
|
+
after References is a citation" rule would refuse the strongest signal
|
|
299
|
+
there is.
|
|
300
|
+
"""
|
|
301
|
+
last_end = None
|
|
302
|
+
for match in _REFERENCE_HEADING.finditer(text, 0, position):
|
|
303
|
+
last_end = match.end()
|
|
304
|
+
if last_end is None:
|
|
305
|
+
return False
|
|
306
|
+
return not _DAS_SECTION.search(text[last_end:position])
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _section_is_das(text: str, position: int) -> bool:
|
|
310
|
+
"""True when the URL sits under a data/code-availability heading.
|
|
311
|
+
|
|
312
|
+
Looks back a bounded distance for the nearest heading-ish marker rather
|
|
313
|
+
than parsing JATS: the scan has to work on HTML too, where the structure
|
|
314
|
+
differs, and a bounded window is the same answer for far less machinery.
|
|
315
|
+
"""
|
|
316
|
+
window = text[max(0, position - 1200):position]
|
|
317
|
+
return bool(_DAS_SECTION.search(window))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def scan_text(text: str) -> List[Candidate]:
|
|
321
|
+
"""Every repository reference in one document, judged.
|
|
322
|
+
|
|
323
|
+
Deduplicated by (repo, id): a deposit named five times is one candidate,
|
|
324
|
+
and it keeps the STRONGEST verdict any of its mentions earned -- a URL
|
|
325
|
+
given properly in the data-availability statement is not disqualified by
|
|
326
|
+
also appearing in the reference list.
|
|
327
|
+
"""
|
|
328
|
+
if not text:
|
|
329
|
+
return []
|
|
330
|
+
|
|
331
|
+
best: Dict[str, Candidate] = {}
|
|
332
|
+
rank = {ACCEPT: 2, UNCERTAIN: 1, REFUSE: 0}
|
|
333
|
+
|
|
334
|
+
for repo, pattern in _REPO_PATTERNS:
|
|
335
|
+
for match in pattern.finditer(text):
|
|
336
|
+
ident = match.group(1)
|
|
337
|
+
if not ident:
|
|
338
|
+
continue
|
|
339
|
+
if repo == "github":
|
|
340
|
+
# Strip a trailing ".git" and any path fragment beyond owner/repo.
|
|
341
|
+
ident = ident.rsplit(".git", 1)[0]
|
|
342
|
+
start = match.start()
|
|
343
|
+
context = text[max(0, start - CONTEXT_CHARS):start + CONTEXT_CHARS]
|
|
344
|
+
context = " ".join(context.split())
|
|
345
|
+
in_das = _section_is_das(text, start)
|
|
346
|
+
# Tags are stripped before the "is the prereg word attached to
|
|
347
|
+
# THIS link" test: in JATS the URL lives inside
|
|
348
|
+
# <ext-link xlink:href="..."> so 60+ characters of markup sit
|
|
349
|
+
# between "preregistration:" and the link, and an anchored match
|
|
350
|
+
# against the raw text never fires.
|
|
351
|
+
before = _strip_tags(text[max(0, start - 200):start])
|
|
352
|
+
verdict, reason = _judge(
|
|
353
|
+
repo, ident, context, in_das,
|
|
354
|
+
in_citation=(_inside_citation(text, start)
|
|
355
|
+
or _after_reference_heading(text, start)),
|
|
356
|
+
before=before)
|
|
357
|
+
|
|
358
|
+
candidate = Candidate(
|
|
359
|
+
repo=repo, ident=ident, url=_normalize(repo, ident),
|
|
360
|
+
verdict=verdict, reason=reason, context=context[:600],
|
|
361
|
+
in_das=in_das,
|
|
362
|
+
)
|
|
363
|
+
previous = best.get(candidate.key)
|
|
364
|
+
if previous is None or rank[verdict] > rank[previous.verdict]:
|
|
365
|
+
best[candidate.key] = candidate
|
|
366
|
+
|
|
367
|
+
return sorted(best.values(), key=lambda c: (c.repo, c.ident))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
#: Markup artifacts whose text is worth scanning. These come first because
|
|
371
|
+
#: they carry the structural citation signal rule 3 depends on -- in markup we
|
|
372
|
+
#: know we are inside a reference entry, on plain text we can only infer it.
|
|
373
|
+
#: Suffixes rather than extensions: the concat below is literal, so
|
|
374
|
+
#: `.fulltext.html` and a converted `_from_pdf_body.md` are reached the same
|
|
375
|
+
#: way a bare `.xml` is.
|
|
376
|
+
_SCANNABLE = (".xml", ".nxml", ".html", ".htm", ".fulltext.html",
|
|
377
|
+
"_from_pdf_body.md", "_from_xml.md", "_from_html.md")
|
|
378
|
+
|
|
379
|
+
#: Read only if no markup was found. A PDF's text is the same prose with the
|
|
380
|
+
#: structure thrown away, so scanning it alongside the XML would add nothing
|
|
381
|
+
#: but a second chance to misjudge the reference list.
|
|
382
|
+
_SCANNABLE_PDF = (".pdf",)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _read_markup(stem: str):
|
|
386
|
+
"""`(path, text)` for every markup artifact on disk for this record."""
|
|
387
|
+
for suffix in _SCANNABLE:
|
|
388
|
+
path = stem + suffix
|
|
389
|
+
if not os.path.exists(path):
|
|
390
|
+
continue
|
|
391
|
+
try:
|
|
392
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
393
|
+
yield path, handle.read()
|
|
394
|
+
except OSError:
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _read_pdf(stem: str, log=None):
|
|
399
|
+
"""`(path, text)` for the record's PDF, when there is text to be had.
|
|
400
|
+
|
|
401
|
+
Silence here would be the project's own worst failure mode -- a machine
|
|
402
|
+
with no PDF engine would report "scanned, nothing found" for every
|
|
403
|
+
PDF-only record. So the two ways this comes back empty are logged with
|
|
404
|
+
different sentences: no engine on this machine, versus no text layer in
|
|
405
|
+
this document.
|
|
406
|
+
"""
|
|
407
|
+
from .pdf_text import engine_status, pdf_text
|
|
408
|
+
|
|
409
|
+
for suffix in _SCANNABLE_PDF:
|
|
410
|
+
path = stem + suffix
|
|
411
|
+
if not os.path.exists(path):
|
|
412
|
+
continue
|
|
413
|
+
engine, detail = engine_status()
|
|
414
|
+
if engine is None:
|
|
415
|
+
if log:
|
|
416
|
+
log(f" fulltext_scan: {detail}")
|
|
417
|
+
return
|
|
418
|
+
text = pdf_text(path)
|
|
419
|
+
if not text:
|
|
420
|
+
if log:
|
|
421
|
+
log(f" fulltext_scan: {os.path.basename(path)} has no text "
|
|
422
|
+
f"layer ({engine} read nothing)")
|
|
423
|
+
return
|
|
424
|
+
yield path, text
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def record_text(stem: str, log=None):
|
|
428
|
+
"""`(path, text)` for the one source `scan_record` would read, or None.
|
|
429
|
+
|
|
430
|
+
Public because the retrieval agent needs exactly the same text and exactly
|
|
431
|
+
the same markup-beats-PDF choice. Two readers disagreeing about what "the
|
|
432
|
+
paper" is would be a subtle way for the agent to judge prose the scan never
|
|
433
|
+
saw.
|
|
434
|
+
"""
|
|
435
|
+
for path, text in _read_markup(stem):
|
|
436
|
+
return path, text
|
|
437
|
+
for path, text in _read_pdf(stem, log=log):
|
|
438
|
+
return path, text
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def scan_record(stem: str, log=None) -> List[Candidate]:
|
|
443
|
+
"""Scan whatever full text is already on disk for one record.
|
|
444
|
+
|
|
445
|
+
`stem` is the output path stem (no extension), matching how the rest of the
|
|
446
|
+
supplementary subsystem addresses a record.
|
|
447
|
+
|
|
448
|
+
Markup wins when both exist. The PDF is the fallback, not a supplement to
|
|
449
|
+
the XML, because it is the same prose with the citation structure stripped:
|
|
450
|
+
scanning both would only give the weaker reading a second vote.
|
|
451
|
+
"""
|
|
452
|
+
candidates: Dict[str, Candidate] = {}
|
|
453
|
+
rank = {ACCEPT: 2, UNCERTAIN: 1, REFUSE: 0}
|
|
454
|
+
|
|
455
|
+
sources = list(_read_markup(stem))
|
|
456
|
+
if not sources:
|
|
457
|
+
sources = list(_read_pdf(stem, log=log))
|
|
458
|
+
|
|
459
|
+
for _path, text in sources:
|
|
460
|
+
for candidate in scan_text(text):
|
|
461
|
+
previous = candidates.get(candidate.key)
|
|
462
|
+
if previous is None or rank[candidate.verdict] > rank[previous.verdict]:
|
|
463
|
+
candidates[candidate.key] = candidate
|
|
464
|
+
return sorted(candidates.values(), key=lambda c: (c.repo, c.ident))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def accepted(candidates: List[Candidate]) -> List[Candidate]:
|
|
468
|
+
return [c for c in candidates if c.verdict == ACCEPT]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def uncertain(candidates: List[Candidate]) -> List[Candidate]:
|
|
472
|
+
return [c for c in candidates if c.verdict == UNCERTAIN]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""What a single walk of the ladder is trying to collect.
|
|
2
|
+
|
|
3
|
+
The tier walk was winner-take-all: best format, write it, stop. That is right when
|
|
4
|
+
a consumer reads one artifact, and wrong for the ones that read two.
|
|
5
|
+
agent_for_forensic_metascience takes (body_md, main_pdf, si_pdfs) -- it needs the
|
|
6
|
+
PDF for supplements, figures and the rendered page -- while the reason to want XML
|
|
7
|
+
alongside it is that the glyph-corruption class its tests quarantine papers over
|
|
8
|
+
(broken ToUnicode CMaps mapping '-' to '2' and '=' to '5') is a PDF-only disease
|
|
9
|
+
that markup does not have.
|
|
10
|
+
|
|
11
|
+
So a walk now fills a set of GOALS rather than returning the first hit. The default
|
|
12
|
+
is a one-goal set, which reduces to exactly the old behaviour -- there is one code
|
|
13
|
+
path, not a flag-selected fork.
|
|
14
|
+
|
|
15
|
+
A goal names the tiers that can fill it, in preference order. Tiers, not sources:
|
|
16
|
+
which sources serve a tier stays in ladder.json, so reordering sources there needs
|
|
17
|
+
no change here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import List, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from .tiers import Tier
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Goal:
|
|
28
|
+
"""One slot to fill, and what filled it."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
tiers: Sequence[Tier]
|
|
32
|
+
path: Optional[str] = None
|
|
33
|
+
artifact: object = None # retrieval.artifact.Artifact once filled
|
|
34
|
+
preexisting: bool = False # found on disk rather than fetched now
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def filled(self) -> bool:
|
|
38
|
+
return self.path is not None
|
|
39
|
+
|
|
40
|
+
def accepts(self, tier: Tier) -> bool:
|
|
41
|
+
return tier in self.tiers
|
|
42
|
+
|
|
43
|
+
def fill(self, path: str, artifact=None, preexisting: bool = False) -> None:
|
|
44
|
+
self.path = path
|
|
45
|
+
self.artifact = artifact
|
|
46
|
+
self.preexisting = preexisting
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class GoalSet:
|
|
51
|
+
"""The goals for one walk, plus the questions the engine asks of them."""
|
|
52
|
+
|
|
53
|
+
goals: List[Goal] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def __iter__(self):
|
|
56
|
+
return iter(self.goals)
|
|
57
|
+
|
|
58
|
+
def __len__(self):
|
|
59
|
+
return len(self.goals)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def complete(self) -> bool:
|
|
63
|
+
return all(g.filled for g in self.goals)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def any_filled(self) -> bool:
|
|
67
|
+
return any(g.filled for g in self.goals)
|
|
68
|
+
|
|
69
|
+
def unfilled(self) -> List[Goal]:
|
|
70
|
+
return [g for g in self.goals if not g.filled]
|
|
71
|
+
|
|
72
|
+
def goal_for(self, tier: Tier) -> Optional[Goal]:
|
|
73
|
+
"""The first UNFILLED goal this tier can fill.
|
|
74
|
+
|
|
75
|
+
Unfilled matters: without it a second XML artifact would overwrite the
|
|
76
|
+
first, and the walk would never move on to the PDF.
|
|
77
|
+
"""
|
|
78
|
+
for goal in self.goals:
|
|
79
|
+
if not goal.filled and goal.accepts(tier):
|
|
80
|
+
return goal
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def by_name(self, name: str) -> Optional[Goal]:
|
|
84
|
+
return next((g for g in self.goals if g.name == name), None)
|
|
85
|
+
|
|
86
|
+
def wanted_tiers(self) -> List[Tier]:
|
|
87
|
+
"""Every tier any unfilled goal would still accept."""
|
|
88
|
+
wanted = []
|
|
89
|
+
for goal in self.unfilled():
|
|
90
|
+
for tier in goal.tiers:
|
|
91
|
+
if tier not in wanted:
|
|
92
|
+
wanted.append(tier)
|
|
93
|
+
return wanted
|
|
94
|
+
|
|
95
|
+
def filled_paths(self) -> List[str]:
|
|
96
|
+
return [g.path for g in self.goals if g.filled]
|
|
97
|
+
|
|
98
|
+
def summary(self) -> str:
|
|
99
|
+
"""'xml+pdf', or 'pdf (structured: none)' -- for the per-record log line."""
|
|
100
|
+
got = [g.name for g in self.goals if g.filled]
|
|
101
|
+
missing = [g.name for g in self.goals if not g.filled]
|
|
102
|
+
if not missing:
|
|
103
|
+
return "+".join(got)
|
|
104
|
+
return "{} ({}: none)".format("+".join(got) or "nothing", ",".join(missing))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: The structured half of the collect: best markup available, XML before HTML.
|
|
108
|
+
#: T3 LaTeX is deliberately NOT here -- a source tarball is not something a
|
|
109
|
+
#: table-extraction pass can read without a TeX toolchain, so it stays a
|
|
110
|
+
#: winner-take-all tier rather than a slot to fill alongside the PDF.
|
|
111
|
+
_STRUCTURED_TIERS = (Tier.T1_XML, Tier.T2_HTML)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def single_best(tiers: Sequence[Tier]) -> GoalSet:
|
|
115
|
+
"""The default: one goal that any tier in the ladder can fill.
|
|
116
|
+
|
|
117
|
+
Equivalent to the original stop-at-first-hit walk, expressed as a goal set so
|
|
118
|
+
the engine has one loop instead of two.
|
|
119
|
+
"""
|
|
120
|
+
return GoalSet([Goal(name="best", tiers=tuple(tiers))])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def structured_and_pdf(tiers: Sequence[Tier]) -> GoalSet:
|
|
124
|
+
"""--get-xml-or-html: a structured copy AND the PDF.
|
|
125
|
+
|
|
126
|
+
Ordered structured-first so that when both are still open, a validated T1
|
|
127
|
+
artifact is filed as the structured goal rather than being consumed by a
|
|
128
|
+
broader one.
|
|
129
|
+
"""
|
|
130
|
+
structured = [t for t in _STRUCTURED_TIERS if t in tiers]
|
|
131
|
+
pdf = [t for t in (Tier.T5_PDF,) if t in tiers]
|
|
132
|
+
goals = []
|
|
133
|
+
if structured:
|
|
134
|
+
goals.append(Goal(name="structured", tiers=tuple(structured)))
|
|
135
|
+
if pdf:
|
|
136
|
+
goals.append(Goal(name="pdf", tiers=tuple(pdf)))
|
|
137
|
+
# A ladder restricted to neither (nothing but T6/T7, say) would give an empty
|
|
138
|
+
# goal set and a walk that accepts nothing. Fall back to the default rather
|
|
139
|
+
# than silently failing every record.
|
|
140
|
+
return GoalSet(goals) if goals else single_best(tiers)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build(tiers: Sequence[Tier], get_xml_or_html: bool = False) -> GoalSet:
|
|
144
|
+
"""The goal set for this walk."""
|
|
145
|
+
return structured_and_pdf(tiers) if get_xml_or_html else single_best(tiers)
|