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,1770 @@
|
|
|
1
|
+
"""--pull-supplementary: every supplementary file, alongside the paper.
|
|
2
|
+
|
|
3
|
+
This is not a tier. The tier walk in engine.py is winner-take-all -- it returns
|
|
4
|
+
the moment one artifact is accepted, because its question is "what is the best
|
|
5
|
+
single representation of this paper's full text". This pass answers a different
|
|
6
|
+
question, "what else did the authors deposit", and the answer is a set, not a
|
|
7
|
+
winner. So it runs after retrieval rather than inside it, and it never touches
|
|
8
|
+
the record's verdict: a paper with no supplementary material is not a failure,
|
|
9
|
+
and a repository serving malformed JSON must not turn a successful download into
|
|
10
|
+
a failed one.
|
|
11
|
+
|
|
12
|
+
Files land as flat siblings of the main artifact:
|
|
13
|
+
|
|
14
|
+
10.1371--journal.pone.0000308.pdf
|
|
15
|
+
10.1371--journal.pone.0000308_supplementary_info_1.doc
|
|
16
|
+
10.1371--journal.pone.0000308_supplementary_info_2.xls
|
|
17
|
+
10.1371--journal.pone.0000308_supplementary_info.json
|
|
18
|
+
|
|
19
|
+
The numbering makes the manifest mandatory rather than nice to have: it is the
|
|
20
|
+
only record of what _2 originally was, what it was called, where it came from,
|
|
21
|
+
and what was refused for being too large. "Nothing was offered" and "one 4 GB
|
|
22
|
+
HDF5 was refused" are different facts about a record and must not collapse into
|
|
23
|
+
the same empty directory listing.
|
|
24
|
+
|
|
25
|
+
Numbering is deterministic, because it has no other anchor. Same remote content
|
|
26
|
+
gives the same files in the same order across re-runs and across worker counts --
|
|
27
|
+
see _sort_key.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import tempfile
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
import zipfile
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Dict, List, Optional, Tuple
|
|
40
|
+
|
|
41
|
+
from .http import apply_default_mode, redact
|
|
42
|
+
from .linked_artifacts import write_linked_sidecar
|
|
43
|
+
from .supplement_index import (
|
|
44
|
+
ROLE_ARTICLE,
|
|
45
|
+
ROLE_FIGURE,
|
|
46
|
+
SupplementFile,
|
|
47
|
+
_epmc_withholds_supplements,
|
|
48
|
+
_matches_href,
|
|
49
|
+
enumerate_all,
|
|
50
|
+
provider_rank,
|
|
51
|
+
)
|
|
52
|
+
from .tiers import ARTIFACT_EXTENSIONS
|
|
53
|
+
|
|
54
|
+
_SCHEMA_VERSION = 1
|
|
55
|
+
|
|
56
|
+
#: The infix that makes a supplementary sibling recognisable. Anything
|
|
57
|
+
#: downstream globbing *.pdf over an output directory will pick these up too, so
|
|
58
|
+
#: it needs to be distinctive enough to filter on.
|
|
59
|
+
INFIX = "_supplementary_info"
|
|
60
|
+
MANIFEST_SUFFIX = INFIX + ".json"
|
|
61
|
+
|
|
62
|
+
#: Linked deposits (Zenodo, OSF, Dryad, ...) land under
|
|
63
|
+
#: <stem>_data_artifacts/<deposit>/ with their ORIGINAL filenames, rather than
|
|
64
|
+
#: in the flat _supplementary_info_N_ namespace the paper's own SI uses. They
|
|
65
|
+
#: are a different kind of thing -- often hundreds of MB of third-party data --
|
|
66
|
+
#: and mixing them in buried a paper's four real files under 199 members of a
|
|
67
|
+
#: vendored Stata package.
|
|
68
|
+
DATA_ARTIFACT_INFIX = "_data_artifacts"
|
|
69
|
+
|
|
70
|
+
#: Every supplementary file, downloaded or unpacked, is named
|
|
71
|
+
#:
|
|
72
|
+
#: {stem}_supplementary_info_{index}_{descriptor}{ext}
|
|
73
|
+
#:
|
|
74
|
+
#: One rule, because two would be worse than either: a reader seeing a name with
|
|
75
|
+
#: no descriptor could not tell whether the provider gave no usable name or
|
|
76
|
+
#: whether a different code path produced the file.
|
|
77
|
+
#:
|
|
78
|
+
#: The index stays the anchor -- deterministic, unique, and the thing the
|
|
79
|
+
#: manifest keys on. The descriptor is added because a name is the only thing an
|
|
80
|
+
#: agent sees when deciding what to open, and "TablesS1-S5" earns its place.
|
|
81
|
+
#: Measured across this corpus, though, only ~20% of provider names carry a
|
|
82
|
+
#: content word; the rest are codes like "jamanetwopen-e2337679-s001" or
|
|
83
|
+
#: Springer's generic "MOESM1_ESM". So the descriptor is a bonus, never the
|
|
84
|
+
#: identifier, and it is omitted entirely when it would say nothing.
|
|
85
|
+
#:
|
|
86
|
+
#: A kept archive's members are unpacked beside it and take indices of their own.
|
|
87
|
+
#: The archive itself stays: it is the only proof of what the members came from.
|
|
88
|
+
DESCRIPTOR_MAX_CHARS = 60
|
|
89
|
+
|
|
90
|
+
#: Per-file cap. The CLI default; --max-supplementary-mb overrides it.
|
|
91
|
+
DEFAULT_MAX_FILE_BYTES = 300 * 1024 * 1024
|
|
92
|
+
|
|
93
|
+
#: An archive's members are unknowable until it has landed, so the per-file cap
|
|
94
|
+
#: cannot gate the transfer. These bound the damage instead.
|
|
95
|
+
BUNDLE_CAP_MULTIPLE = 4
|
|
96
|
+
DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
|
97
|
+
DEFAULT_MAX_FILES = 200
|
|
98
|
+
|
|
99
|
+
#: zipfile will happily inflate a bomb. Refuse an archive whose members claim to
|
|
100
|
+
#: expand to more than this.
|
|
101
|
+
_RATIO_GUARD_MULTIPLE = 20
|
|
102
|
+
|
|
103
|
+
#: Bodies that are a challenge page rather than the file that was asked for.
|
|
104
|
+
#: Every one of these is served with HTTP 200 (or, at ACS, a 404 with 57 KB of
|
|
105
|
+
#: HTML), which is why the status code cannot be the check.
|
|
106
|
+
_CHALLENGE_MARKERS = (
|
|
107
|
+
b"recaptcha/challengepage",
|
|
108
|
+
b"cf-browser-verification",
|
|
109
|
+
b"Just a moment...",
|
|
110
|
+
b"Attention Required! | Cloudflare",
|
|
111
|
+
b"_Incapsula_Resource",
|
|
112
|
+
b"px-captcha",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
_TEXTUAL_EXTENSIONS = (".html", ".htm", ".txt", ".csv", ".tsv", ".xml", ".json", ".md")
|
|
116
|
+
|
|
117
|
+
#: Formats whose bytes cannot legitimately begin with `<`. Used to catch an API
|
|
118
|
+
#: that answers a file request with an XML wrapper describing the file -- see
|
|
119
|
+
#: `_looks_like_a_document`.
|
|
120
|
+
_BINARY_DOC_EXTENSIONS = (
|
|
121
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
122
|
+
".zip", ".gz", ".tar", ".rar", ".7z",
|
|
123
|
+
".png", ".jpg", ".jpeg", ".tif", ".tiff", ".gif", ".bmp", ".eps",
|
|
124
|
+
".mov", ".mp4", ".avi", ".wmv", ".sav", ".dta", ".mat",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
_MAGIC = (
|
|
128
|
+
(b"%PDF", ".pdf"),
|
|
129
|
+
(b"PK\x03\x04", ".zip"),
|
|
130
|
+
(b"\x1f\x8b", ".gz"),
|
|
131
|
+
(b"\x89PNG", ".png"),
|
|
132
|
+
(b"\xff\xd8\xff", ".jpg"),
|
|
133
|
+
(b"GIF8", ".gif"),
|
|
134
|
+
(b"BM", ".bmp"),
|
|
135
|
+
(b"Rar!", ".rar"),
|
|
136
|
+
(b"7z\xbc\xaf", ".7z"),
|
|
137
|
+
(b"\xd0\xcf\x11\xe0", ".doc"),
|
|
138
|
+
(b"BZh", ".bz2"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_CONTENT_TYPE_EXTENSIONS = {
|
|
142
|
+
"application/pdf": ".pdf",
|
|
143
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
144
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
145
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
146
|
+
"application/vnd.ms-excel": ".xls",
|
|
147
|
+
"application/msword": ".doc",
|
|
148
|
+
"application/vnd.oasis.opendocument.spreadsheet": ".ods",
|
|
149
|
+
"text/csv": ".csv",
|
|
150
|
+
"text/tab-separated-values": ".tsv",
|
|
151
|
+
"text/plain": ".txt",
|
|
152
|
+
"application/zip": ".zip",
|
|
153
|
+
"application/gzip": ".gz",
|
|
154
|
+
"application/x-gzip": ".gz",
|
|
155
|
+
"application/x-tar": ".tar",
|
|
156
|
+
"image/png": ".png",
|
|
157
|
+
"image/jpeg": ".jpg",
|
|
158
|
+
"image/tiff": ".tif",
|
|
159
|
+
"video/mp4": ".mp4",
|
|
160
|
+
"application/json": ".json",
|
|
161
|
+
"application/xml": ".xml",
|
|
162
|
+
"text/xml": ".xml",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#: Skip reasons that mean a file the record has was not obtained, as opposed to
|
|
166
|
+
#: one that was obtained through a different provider. Only these make a run
|
|
167
|
+
#: "partial"; a duplicate is a complete result, not a shortfall. "no-bundle" is
|
|
168
|
+
#: deliberately absent: a speculative listing (EPMC constructs its bundle URL
|
|
169
|
+
#: for every PMCID without checking) answered by 404 or an <errorBean> stub is
|
|
170
|
+
#: a clean negative, not a loss -- misfiling it here once turned 40 negatives
|
|
171
|
+
#: into phantom "partial" statuses across a 99-record corpus.
|
|
172
|
+
_LOST_REASONS = frozenset({
|
|
173
|
+
"too-large", "http-error", "unreachable", "empty", "not-a-document",
|
|
174
|
+
"not-an-archive", "decompression-ratio", "unreadable-member", "unwritable",
|
|
175
|
+
# The files exist and were withheld -- that is a shortfall, unlike
|
|
176
|
+
# "no-bundle", which means there was nothing to fetch in the first place.
|
|
177
|
+
"epmc_not_open_access",
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
#: EPMC's phrasing when it holds supplements it is not permitted to serve.
|
|
181
|
+
#: Matched on the message, not the status code: the refusal arrives as HTTP 200
|
|
182
|
+
#: wrapping a 165-byte <errorBean>, so the status says "fine" while the body
|
|
183
|
+
#: says "no".
|
|
184
|
+
_NOT_OPEN_ACCESS_RE = re.compile(r"not\s+open\s+access", re.I)
|
|
185
|
+
|
|
186
|
+
#: Records whose supplements Europe PMC withheld this run, for the end-of-run
|
|
187
|
+
#: summary. A 500-record batch should give a number and a list, not 500 lines
|
|
188
|
+
#: the user has to scroll back through.
|
|
189
|
+
_NOT_OPEN_ACCESS_RECORDS: List[str] = []
|
|
190
|
+
_NOA_LOCK = threading.Lock()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _print_yellow_warning(message: str) -> None:
|
|
194
|
+
"""Yellow, like the CORE and news-item notices in fetchpdf.py.
|
|
195
|
+
|
|
196
|
+
Defined here rather than imported: fetchpdf.py imports this module, so
|
|
197
|
+
reaching back into it would be a cycle.
|
|
198
|
+
"""
|
|
199
|
+
print(f"\033[93m{message}\033[0m")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def not_open_access_records() -> List[str]:
|
|
203
|
+
"""DOIs whose supplements EPMC held but refused, for the run summary."""
|
|
204
|
+
with _NOA_LOCK:
|
|
205
|
+
return list(_NOT_OPEN_ACCESS_RECORDS)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def reset_not_open_access_records() -> None:
|
|
209
|
+
"""Exists for tests; a batch is one process."""
|
|
210
|
+
with _NOA_LOCK:
|
|
211
|
+
_NOT_OPEN_ACCESS_RECORDS.clear()
|
|
212
|
+
|
|
213
|
+
#: Refusals worth a slow second look. HTTP layer already retries 429/503 once
|
|
214
|
+
#: with Retry-After; this is the record-level list for the escalating rounds in
|
|
215
|
+
#: pull_for_record. 403/404/410 are answers, not blips, and stay terminal.
|
|
216
|
+
_TRANSIENT_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
217
|
+
|
|
218
|
+
#: Waits between record-level retry rounds, chosen to slow down dramatically:
|
|
219
|
+
#: a transient failure gets a second chance quickly, then the run backs far off
|
|
220
|
+
#: rather than hammering a struggling host.
|
|
221
|
+
_RETRY_WAITS = (20, 90, 300)
|
|
222
|
+
|
|
223
|
+
#: Suffixes trusted as extensions when they appear on a listed filename. Anything
|
|
224
|
+
#: outside this set loses to the served Content-Type and to the magic bytes -- see
|
|
225
|
+
#: _extension_for for why that ordering matters.
|
|
226
|
+
_KNOWN_EXTENSIONS = frozenset(
|
|
227
|
+
e.lstrip(".") for e in _CONTENT_TYPE_EXTENSIONS.values()
|
|
228
|
+
) | frozenset("""
|
|
229
|
+
pdf doc docx xls xlsx xlsm ods csv tsv txt rtf odt ppt pptx odp
|
|
230
|
+
zip gz tgz bz2 xz 7z rar tar
|
|
231
|
+
png jpg jpeg gif tif tiff bmp svg eps ps webp heic
|
|
232
|
+
mp4 mov avi mkv webm wmv mp3 wav flac m4a
|
|
233
|
+
xml json yaml yml html htm md tex bib
|
|
234
|
+
h5 hdf5 nc mat sav dta rds rda rdata sas7bdat npy npz parquet feather
|
|
235
|
+
fasta fastq fa vcf bam sam bed gff gtf sra cel idat nii mgf mzml raw
|
|
236
|
+
py r m do sh ipynb nb sav pkl db sqlite
|
|
237
|
+
""".split())
|
|
238
|
+
|
|
239
|
+
#: One lock per output stem, so two workers handed the same DOI twice in one CSV
|
|
240
|
+
#: cannot interleave each other's numbering. os.replace already prevents torn
|
|
241
|
+
#: files; this prevents run A's _1 sitting next to run B's _2.
|
|
242
|
+
_STEM_LOCKS: Dict[str, threading.Lock] = {}
|
|
243
|
+
_STEM_LOCKS_GUARD = threading.Lock()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class SupplementarySummary:
|
|
248
|
+
"""What the pass did. Never an input to whether the record succeeded."""
|
|
249
|
+
|
|
250
|
+
#: incomplete > partial > error > ok > none_found. "incomplete" means a file
|
|
251
|
+
#: the article's own JATS declares was not obtained -- named data loss, and
|
|
252
|
+
#: the one status callers must surface loudly. "partial" means an undeclared
|
|
253
|
+
#: listed file was lost; "skipped" is the repair path's no-op.
|
|
254
|
+
status: str = "none_found" # ok | none_found | partial | incomplete | error | skipped
|
|
255
|
+
written: int = 0
|
|
256
|
+
skipped: int = 0
|
|
257
|
+
bytes_written: int = 0
|
|
258
|
+
manifest_path: Optional[str] = None
|
|
259
|
+
paths: List[str] = field(default_factory=list)
|
|
260
|
+
detail: str = ""
|
|
261
|
+
#: Declared-but-not-obtained original filenames, when status == "incomplete".
|
|
262
|
+
missing_declared: List[str] = field(default_factory=list)
|
|
263
|
+
|
|
264
|
+
def __bool__(self):
|
|
265
|
+
return self.written > 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def pull_for_record(raw_identifier, doi=None, pmid=None, save_path=None,
|
|
269
|
+
resolver=None, ladder=None, http=None,
|
|
270
|
+
max_file_bytes=DEFAULT_MAX_FILE_BYTES,
|
|
271
|
+
max_total_bytes=DEFAULT_MAX_TOTAL_BYTES,
|
|
272
|
+
max_files=DEFAULT_MAX_FILES,
|
|
273
|
+
refresh=False, providers=None, email=None,
|
|
274
|
+
verbose=False, delay=0.1, use_playwright=False,
|
|
275
|
+
download_data_artifacts=False,
|
|
276
|
+
max_data_artifact_bytes=None,
|
|
277
|
+
llm_adjudicate_artifacts=False,
|
|
278
|
+
llm_agent_retrieval=False,
|
|
279
|
+
llm_backend=None,
|
|
280
|
+
max_llm_records=0,
|
|
281
|
+
llm_model=None,
|
|
282
|
+
unpack_data_artifacts=False,
|
|
283
|
+
download_related_unverified=False,
|
|
284
|
+
verify_hashes=True) -> SupplementarySummary:
|
|
285
|
+
"""Fetch every supplementary file for one record into flat siblings of save_path.
|
|
286
|
+
|
|
287
|
+
Never raises for a network or provider failure; the outcome is the returned
|
|
288
|
+
summary and the manifest beside the artifact. Callers must not let the result
|
|
289
|
+
influence whether the record succeeded -- see the module docstring.
|
|
290
|
+
"""
|
|
291
|
+
if not save_path:
|
|
292
|
+
return SupplementarySummary(status="error", detail="no save_path")
|
|
293
|
+
|
|
294
|
+
stem = stem_for(save_path)
|
|
295
|
+
manifest_path = stem + MANIFEST_SUFFIX
|
|
296
|
+
|
|
297
|
+
with _stem_lock(stem):
|
|
298
|
+
# The manifest, not the files, is the skip signal. On a real corpus most
|
|
299
|
+
# records have no supplementary material at all, and that is a fact worth
|
|
300
|
+
# recording: without it every re-run would re-enumerate every empty record
|
|
301
|
+
# at 3-6 requests each, which on run 2 onward is the entire cost of the
|
|
302
|
+
# flag.
|
|
303
|
+
if os.path.exists(manifest_path) and not refresh:
|
|
304
|
+
return _repair(manifest_path, stem, resolver, http, ladder, email,
|
|
305
|
+
verbose, max_file_bytes, verify_hashes)
|
|
306
|
+
|
|
307
|
+
ladder, http, resolver, owns_resolver = _wire(
|
|
308
|
+
resolver, http, ladder, save_path, email, verbose)
|
|
309
|
+
ctx = _context(http, resolver, ladder, save_path, verbose, email, delay,
|
|
310
|
+
use_playwright,
|
|
311
|
+
download_data_artifacts=download_data_artifacts,
|
|
312
|
+
max_data_artifact_bytes=max_data_artifact_bytes,
|
|
313
|
+
llm_adjudicate_artifacts=llm_adjudicate_artifacts,
|
|
314
|
+
llm_agent_retrieval=llm_agent_retrieval,
|
|
315
|
+
llm_backend=llm_backend,
|
|
316
|
+
max_llm_records=max_llm_records,
|
|
317
|
+
llm_model=llm_model,
|
|
318
|
+
download_related_unverified=download_related_unverified)
|
|
319
|
+
|
|
320
|
+
ids = _resolve(resolver, raw_identifier, doi, pmid)
|
|
321
|
+
listed, reports = enumerate_all(ids, ctx, providers)
|
|
322
|
+
|
|
323
|
+
run = _Run(stem=stem, ctx=ctx, http=http, ids=ids,
|
|
324
|
+
max_file_bytes=max_file_bytes, max_total_bytes=max_total_bytes,
|
|
325
|
+
max_files=max_files,
|
|
326
|
+
max_artifact_bytes=max_data_artifact_bytes,
|
|
327
|
+
unpack_data_artifacts=unpack_data_artifacts)
|
|
328
|
+
run.absorb_main_artifact()
|
|
329
|
+
for entry in sorted(_wanted(listed), key=_sort_key):
|
|
330
|
+
if not run.has_budget():
|
|
331
|
+
break
|
|
332
|
+
# Linked deposits answer to their own ceiling, so one large
|
|
333
|
+
# replication package cannot consume the allowance the paper's own
|
|
334
|
+
# supplementary files need.
|
|
335
|
+
if run._is_data_artifact(entry) and not run.artifact_has_budget(entry):
|
|
336
|
+
continue
|
|
337
|
+
run.take(entry)
|
|
338
|
+
_retry_transients(run, stem)
|
|
339
|
+
|
|
340
|
+
# Providers that had to download into a temp dir (atypon_suppl, and
|
|
341
|
+
# the retrieval agent's downloading backend) are done with it once
|
|
342
|
+
# every file has been committed.
|
|
343
|
+
for module in ("supplement_atypon", "llm_agent_retrieval"):
|
|
344
|
+
try:
|
|
345
|
+
cleanup = __import__(f"fetchpdf.retrieval.{module}",
|
|
346
|
+
fromlist=["cleanup_scratch"])
|
|
347
|
+
cleanup.cleanup_scratch(ctx)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
summary = run.finish(manifest_path, ids, reports, max_file_bytes)
|
|
351
|
+
# The link services' answers, including the empty ones. Written after
|
|
352
|
+
# the manifest so a crash between the two loses the links, never the
|
|
353
|
+
# account of the files. No-op when no link provider ran.
|
|
354
|
+
write_linked_sidecar(stem, ids, ctx, verbose=verbose)
|
|
355
|
+
if owns_resolver:
|
|
356
|
+
# Batch mode flushes once at the end instead; doing it per record
|
|
357
|
+
# there would rewrite a file that grows with every row.
|
|
358
|
+
try:
|
|
359
|
+
resolver.cache.flush()
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
return summary
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# -- selection --------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _wanted(listed: List[SupplementFile]) -> List[SupplementFile]:
|
|
369
|
+
"""Everything except the paper itself and its figures.
|
|
370
|
+
|
|
371
|
+
The one filter. --pull-supplementary was asked for as much as possible, so
|
|
372
|
+
ROLE_UNKNOWN is kept: a spurious file costs disk, a dropped one loses data.
|
|
373
|
+
"""
|
|
374
|
+
return [f for f in listed if f.role not in (ROLE_ARTICLE, ROLE_FIGURE) and f.url]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _sort_key(entry: SupplementFile) -> tuple:
|
|
378
|
+
"""Provider order, then natural filename order, then URL.
|
|
379
|
+
|
|
380
|
+
Determinism is not a nicety here. The output names carry no information --
|
|
381
|
+
_2.xlsx means nothing without the manifest -- so if the order shifted between
|
|
382
|
+
runs, the same file would land under different names and a re-run would look
|
|
383
|
+
like a different set of supplements. Provider rank comes first so appending a
|
|
384
|
+
provider cannot renumber the files an earlier one already produced; API
|
|
385
|
+
listing order is deliberately not the key, because neither Zenodo nor figshare
|
|
386
|
+
guarantees it.
|
|
387
|
+
"""
|
|
388
|
+
return (provider_rank(entry.provider), _natural(entry.basename), entry.url or "")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _natural(name: str) -> tuple:
|
|
392
|
+
"""Digit runs compared as ints, so Table_S2 sorts before Table_S10."""
|
|
393
|
+
return tuple(
|
|
394
|
+
(1, int(part), "") if part.isdigit() else (0, 0, part.lower())
|
|
395
|
+
for part in re.split(r"(\d+)", name or "")
|
|
396
|
+
if part != ""
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# -- the run ----------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class _Run:
|
|
404
|
+
"""One record's pass: download, dedupe, name, and account for everything."""
|
|
405
|
+
|
|
406
|
+
def __init__(self, stem, ctx, http, ids, max_file_bytes, max_total_bytes,
|
|
407
|
+
max_files, max_artifact_bytes=None,
|
|
408
|
+
unpack_data_artifacts=False):
|
|
409
|
+
self.stem = stem
|
|
410
|
+
self.ctx = ctx
|
|
411
|
+
self.http = http
|
|
412
|
+
self.ids = ids
|
|
413
|
+
self.max_file_bytes = max_file_bytes
|
|
414
|
+
self.max_total_bytes = max_total_bytes
|
|
415
|
+
self.max_files = max_files
|
|
416
|
+
self.max_artifact_bytes = max_artifact_bytes
|
|
417
|
+
self.unpack_data_artifacts = unpack_data_artifacts
|
|
418
|
+
self.directory = os.path.dirname(os.path.abspath(stem)) or "."
|
|
419
|
+
|
|
420
|
+
self.kept: List[dict] = []
|
|
421
|
+
self.skipped: List[dict] = []
|
|
422
|
+
#: (entry, its skip record) for refusals worth a slow second look --
|
|
423
|
+
#: consumed by _retry_transients, which pops the stale record and
|
|
424
|
+
#: re-takes the entry.
|
|
425
|
+
self.transient: List[Tuple[SupplementFile, dict]] = []
|
|
426
|
+
self.bytes_written = 0
|
|
427
|
+
#: Bytes spent on LINKED DATA ARTIFACTS, counted separately from the
|
|
428
|
+
#: paper's own supplementary files. A replication package can be
|
|
429
|
+
#: hundreds of MB, and sharing one budget would let a single dataset
|
|
430
|
+
#: starve the SI the record actually needs (or the reverse).
|
|
431
|
+
self.artifact_bytes_written = 0
|
|
432
|
+
#: Computed sha256 -> index. Authoritative, and the only cross-origin key.
|
|
433
|
+
self.by_hash: Dict[str, int] = {}
|
|
434
|
+
#: Provider-declared digest -> index. Kept apart from by_hash because a
|
|
435
|
+
#: declared md5 and a computed sha256 are not comparable; conflating them
|
|
436
|
+
#: would make every declared-checksum lookup a guaranteed miss.
|
|
437
|
+
self.by_declared: Dict[str, int] = {}
|
|
438
|
+
self.by_name_size: Dict[Tuple[str, Optional[int]], int] = {}
|
|
439
|
+
self.by_name_within_origin: Dict[Tuple[Optional[str], str], int] = {}
|
|
440
|
+
#: Size -> path of an artifact already on disk for this record, with its
|
|
441
|
+
#: hash filled in only if some candidate turns out to match that size.
|
|
442
|
+
self.main_by_size: Dict[int, str] = {}
|
|
443
|
+
self.main_hash_by_size: Dict[int, Optional[str]] = {}
|
|
444
|
+
|
|
445
|
+
# -- budgets ----------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def _warn_not_open_access(self, stub: str) -> None:
|
|
448
|
+
"""Say out loud that supplements exist and cannot be fetched.
|
|
449
|
+
|
|
450
|
+
Loud, and per-record rather than once per run: unlike a dead API key,
|
|
451
|
+
each occurrence names a DIFFERENT paper whose SI the user may want by
|
|
452
|
+
hand, so collapsing them would hide the list. This silence is what let
|
|
453
|
+
the gap go unnoticed until a colleague reported it.
|
|
454
|
+
"""
|
|
455
|
+
identifier = (getattr(self.ids, "doi", None)
|
|
456
|
+
or getattr(self.ids, "pmcid", None) or "record")
|
|
457
|
+
pmcid = getattr(self.ids, "pmcid", None) or "the PMC record"
|
|
458
|
+
_print_yellow_warning(
|
|
459
|
+
f"⚠️ {identifier}: Europe PMC reports supplementary material exists "
|
|
460
|
+
f"but will not serve it -- {pmcid} is not open access. Publishers "
|
|
461
|
+
f"block automated download of SI files, so these cannot be "
|
|
462
|
+
f"retrieved programmatically; fetch them from the article page by "
|
|
463
|
+
f"hand if needed."
|
|
464
|
+
)
|
|
465
|
+
with _NOA_LOCK:
|
|
466
|
+
_NOT_OPEN_ACCESS_RECORDS.append(str(identifier))
|
|
467
|
+
|
|
468
|
+
def _artifact_dir(self, entry) -> Optional[str]:
|
|
469
|
+
"""The directory a linked deposit's files go in, created on demand.
|
|
470
|
+
|
|
471
|
+
`<stem>_data_artifacts/<repo>_<id>/`, so a 14 MB third-party CSV is
|
|
472
|
+
visibly not the paper's own supplementary material and each deposit
|
|
473
|
+
stays grouped. Returns None for the paper's own files, which keep the
|
|
474
|
+
flat numbered scheme they have always had -- changing that would
|
|
475
|
+
renumber every existing corpus.
|
|
476
|
+
"""
|
|
477
|
+
if not self._is_data_artifact(entry):
|
|
478
|
+
return None
|
|
479
|
+
provider = (getattr(entry, "provider", "") or "").replace(":", "_")
|
|
480
|
+
origin = (getattr(entry, "origin_doi", "") or "").strip().lower()
|
|
481
|
+
slug = re.sub(r"[^a-z0-9._-]+", "_", origin).strip("._-")[:60] or provider
|
|
482
|
+
path = os.path.join(self.directory,
|
|
483
|
+
os.path.basename(self.stem) + DATA_ARTIFACT_INFIX,
|
|
484
|
+
slug)
|
|
485
|
+
try:
|
|
486
|
+
os.makedirs(path, exist_ok=True)
|
|
487
|
+
except OSError:
|
|
488
|
+
return None # fall back to flat rather than lose the file
|
|
489
|
+
return path
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def _is_data_artifact(entry) -> bool:
|
|
493
|
+
"""True for a file routed from a linked deposit rather than the paper.
|
|
494
|
+
|
|
495
|
+
Keyed on the provider tag: _files_in_repository stamps routed files as
|
|
496
|
+
"<via>:<provider>", so the prefix names the discovery route.
|
|
497
|
+
"""
|
|
498
|
+
# The retrieval agent's DATASET files are tagged
|
|
499
|
+
# "fulltext_scan:llm_agent" precisely so they match the
|
|
500
|
+
# "fulltext_scan" prefix below and land in _data_artifacts/ on the
|
|
501
|
+
# data budget. Its SUPPLEMENT files are tagged "llm_agent", which
|
|
502
|
+
# deliberately does not match: those are the paper's own SI and belong
|
|
503
|
+
# in the flat numbered namespace beside the PDF.
|
|
504
|
+
provider = getattr(entry, "provider", "") or ""
|
|
505
|
+
return provider.startswith(("scholix_related:", "epmc_datalinks:",
|
|
506
|
+
"datacite_related:", "fulltext_scan"))
|
|
507
|
+
|
|
508
|
+
def artifact_has_budget(self, entry) -> bool:
|
|
509
|
+
"""The separate ceiling for linked data artifacts."""
|
|
510
|
+
if self.max_artifact_bytes is None:
|
|
511
|
+
return True
|
|
512
|
+
if self.artifact_bytes_written >= self.max_artifact_bytes:
|
|
513
|
+
self._note_budget(
|
|
514
|
+
f"per-record data-artifact limit {self.max_artifact_bytes} bytes reached")
|
|
515
|
+
return False
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
def has_budget(self) -> bool:
|
|
519
|
+
if len(self.kept) >= self.max_files:
|
|
520
|
+
self._note_budget(f"per-record file limit {self.max_files} reached")
|
|
521
|
+
return False
|
|
522
|
+
if self.bytes_written >= self.max_total_bytes:
|
|
523
|
+
self._note_budget(f"per-record byte limit {self.max_total_bytes} reached")
|
|
524
|
+
return False
|
|
525
|
+
return True
|
|
526
|
+
|
|
527
|
+
def _note_budget(self, reason: str) -> None:
|
|
528
|
+
if not any(s.get("reason") == "record-budget" for s in self.skipped):
|
|
529
|
+
self.skipped.append({"reason": "record-budget", "detail": reason})
|
|
530
|
+
self.ctx.log(f" supplements: {reason}")
|
|
531
|
+
|
|
532
|
+
# -- the main artifact ------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def absorb_main_artifact(self) -> None:
|
|
535
|
+
"""Note the sizes of what is already on disk for this record.
|
|
536
|
+
|
|
537
|
+
Repositories routinely list the paper alongside its supplements, and the
|
|
538
|
+
role heuristics only guess at that from a filename. Byte identity settles
|
|
539
|
+
it. Sizes are collected now and hashed lazily, on an exact size match
|
|
540
|
+
only, so a record with a 30 MB PDF is not sha256'd for nothing.
|
|
541
|
+
"""
|
|
542
|
+
for extension in ARTIFACT_EXTENSIONS:
|
|
543
|
+
path = self.stem + extension
|
|
544
|
+
try:
|
|
545
|
+
if os.path.isfile(path):
|
|
546
|
+
self.main_by_size.setdefault(os.path.getsize(path), path)
|
|
547
|
+
except OSError:
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
def _is_the_main_artifact(self, size: int, digest: str) -> Optional[str]:
|
|
551
|
+
path = self.main_by_size.get(size)
|
|
552
|
+
if not path:
|
|
553
|
+
return None
|
|
554
|
+
if size not in self.main_hash_by_size:
|
|
555
|
+
self.main_hash_by_size[size] = _hash_file(path)
|
|
556
|
+
return path if self.main_hash_by_size[size] == digest else None
|
|
557
|
+
|
|
558
|
+
# -- taking one entry -------------------------------------------------
|
|
559
|
+
|
|
560
|
+
def take(self, entry: SupplementFile) -> None:
|
|
561
|
+
if entry.is_archive:
|
|
562
|
+
self._take_archive(entry)
|
|
563
|
+
else:
|
|
564
|
+
self._take_file(entry)
|
|
565
|
+
|
|
566
|
+
def _take_file(self, entry: SupplementFile) -> None:
|
|
567
|
+
# The free refusal: a listing that declares an oversized file costs us
|
|
568
|
+
# nothing to decline.
|
|
569
|
+
if entry.size_bytes is not None and entry.size_bytes > self.max_file_bytes:
|
|
570
|
+
self._refuse(entry, "too-large", declared_bytes=entry.size_bytes)
|
|
571
|
+
return
|
|
572
|
+
if self._already_seen(entry):
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
staged = self._stage()
|
|
576
|
+
if staged is None:
|
|
577
|
+
return
|
|
578
|
+
result = self.http.download(
|
|
579
|
+
entry.url, staged, self.max_file_bytes, polite=entry.polite
|
|
580
|
+
)
|
|
581
|
+
if not result.ok:
|
|
582
|
+
_unlink(staged)
|
|
583
|
+
self._refuse(entry, result.outcome,
|
|
584
|
+
declared_bytes=result.declared_length,
|
|
585
|
+
detail=result.detail, url=result.request_url,
|
|
586
|
+
status=result.status)
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
self._commit(entry, staged, result.sha256, result.bytes_written,
|
|
590
|
+
result.content_type, url=result.url)
|
|
591
|
+
|
|
592
|
+
def _take_archive(self, entry: SupplementFile) -> None:
|
|
593
|
+
"""Fetch a bundle under its own cap, then expand it member by member.
|
|
594
|
+
|
|
595
|
+
A zip's central directory sits at the end of the file, so nothing about
|
|
596
|
+
its members is knowable until the whole archive has landed. The per-file
|
|
597
|
+
cap therefore cannot gate this transfer; a larger bundle cap bounds it,
|
|
598
|
+
and the per-file cap is applied to each member on the way out.
|
|
599
|
+
"""
|
|
600
|
+
bundle_cap = min(self.max_file_bytes * BUNDLE_CAP_MULTIPLE, self.max_total_bytes)
|
|
601
|
+
staged = self._stage()
|
|
602
|
+
if staged is None:
|
|
603
|
+
return
|
|
604
|
+
result = self.http.download(entry.url, staged, bundle_cap, polite=entry.polite)
|
|
605
|
+
if not result.ok:
|
|
606
|
+
_unlink(staged)
|
|
607
|
+
# A speculative listing was never evidence the bundle exists, so a
|
|
608
|
+
# 404 (or 410) is the endpoint saying "nothing here" -- verified
|
|
609
|
+
# reproducible at concurrency 1 -- not a download that failed.
|
|
610
|
+
if entry.extra.get("speculative") and result.status in (404, 410):
|
|
611
|
+
self._refuse(entry, "no-bundle", url=result.request_url,
|
|
612
|
+
status=result.status)
|
|
613
|
+
else:
|
|
614
|
+
self._refuse(entry, result.outcome,
|
|
615
|
+
declared_bytes=result.declared_length,
|
|
616
|
+
detail=result.detail, url=result.request_url,
|
|
617
|
+
status=result.status)
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
self._expand_zip(entry, staged, result)
|
|
622
|
+
finally:
|
|
623
|
+
_unlink(staged)
|
|
624
|
+
|
|
625
|
+
def _expand_zip(self, entry: SupplementFile, staged: str, result) -> None:
|
|
626
|
+
# EPMC's other way of saying "no bundle": HTTP 200 wrapping a tiny
|
|
627
|
+
# <errorBean> XML stub ("Article with id PMC... is not open access
|
|
628
|
+
# one", 165 bytes live). Catching it before ZipFile keeps the honest
|
|
629
|
+
# not-an-archive reason for payloads that were supposed to be zips.
|
|
630
|
+
stub = _error_bean(staged)
|
|
631
|
+
if stub is not None:
|
|
632
|
+
# Two very different facts arrive through the same stub, and only
|
|
633
|
+
# one is worth a human's attention: "this article has no
|
|
634
|
+
# supplements" versus "it HAS supplements and Europe PMC is not
|
|
635
|
+
# allowed to give them to you".
|
|
636
|
+
#
|
|
637
|
+
# The stub CANNOT tell them apart. Europe PMC returns the identical
|
|
638
|
+
# body either way -- verified: PMC11215513 (hasSuppl=N) and
|
|
639
|
+
# PMC3458378 (hasSuppl=Y) both answer 200 with "...is not open
|
|
640
|
+
# access one". The message means "this article is closed", not "we
|
|
641
|
+
# are withholding files". An earlier version of this branch keyed on
|
|
642
|
+
# that text and warned about five records when only one of them had
|
|
643
|
+
# any supplements to withhold.
|
|
644
|
+
#
|
|
645
|
+
# hasSuppl is the authority, so ask the search index.
|
|
646
|
+
if _NOT_OPEN_ACCESS_RE.search(stub or "") \
|
|
647
|
+
and _epmc_withholds_supplements(
|
|
648
|
+
self.ids, self.ctx, default_without_pmcid=False):
|
|
649
|
+
self._refuse(entry, "epmc_not_open_access", detail=stub,
|
|
650
|
+
url=result.url)
|
|
651
|
+
# Breadcrumb for E19: the publisher fallback costs a headed
|
|
652
|
+
# browser, so it only runs for the records EPMC has actually
|
|
653
|
+
# said it is withholding.
|
|
654
|
+
self.ctx.scratch["epmc_withheld_supplements"] = True
|
|
655
|
+
self._warn_not_open_access(stub)
|
|
656
|
+
else:
|
|
657
|
+
# Includes the closed-access-but-no-supplements case, which is
|
|
658
|
+
# a clean negative: nothing exists, nothing was lost, say
|
|
659
|
+
# nothing.
|
|
660
|
+
self._refuse(entry, "no-bundle", detail=stub, url=result.url)
|
|
661
|
+
return
|
|
662
|
+
try:
|
|
663
|
+
archive = zipfile.ZipFile(staged)
|
|
664
|
+
except (zipfile.BadZipFile, OSError) as e:
|
|
665
|
+
self._refuse(entry, "not-an-archive", detail=str(e)[:120],
|
|
666
|
+
url=result.url)
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
with archive:
|
|
670
|
+
members = [m for m in archive.infolist() if not m.is_dir()]
|
|
671
|
+
declared = sum(m.file_size for m in members)
|
|
672
|
+
allowance = max(result.bytes_written * _RATIO_GUARD_MULTIPLE, self.max_file_bytes)
|
|
673
|
+
if declared > allowance:
|
|
674
|
+
self._refuse(entry, "decompression-ratio",
|
|
675
|
+
declared_bytes=declared, url=result.url,
|
|
676
|
+
detail=f"{declared} bytes from a {result.bytes_written}-byte archive")
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
# Sorted by name, not left in the archive's own order. A zip's
|
|
680
|
+
# central directory preserves whatever order the producer wrote, and
|
|
681
|
+
# Europe PMC's is not the obvious one -- its s001-s004 bundle stores
|
|
682
|
+
# s002 first -- so honouring it would number the members in a way that
|
|
683
|
+
# matches neither _sort_key nor anybody's expectation.
|
|
684
|
+
ordered = sorted(
|
|
685
|
+
members,
|
|
686
|
+
key=lambda m: _natural(os.path.basename(m.filename.replace("\\", "/"))),
|
|
687
|
+
)
|
|
688
|
+
for index, member in enumerate(ordered):
|
|
689
|
+
if not self.has_budget():
|
|
690
|
+
return
|
|
691
|
+
# Member names are untrusted input. They are never joined to a
|
|
692
|
+
# path here -- output names are generated -- but take the
|
|
693
|
+
# basename anyway so a traversing name cannot survive into the
|
|
694
|
+
# manifest either.
|
|
695
|
+
name = os.path.basename(member.filename.replace("\\", "/"))
|
|
696
|
+
member_entry = SupplementFile(
|
|
697
|
+
name=name or "supplement",
|
|
698
|
+
url=entry.url,
|
|
699
|
+
provider=entry.provider,
|
|
700
|
+
listing_index=index,
|
|
701
|
+
size_bytes=member.file_size,
|
|
702
|
+
role=entry.role,
|
|
703
|
+
origin_doi=entry.origin_doi,
|
|
704
|
+
extra=dict(entry.extra),
|
|
705
|
+
)
|
|
706
|
+
if member.file_size > self.max_file_bytes:
|
|
707
|
+
self._refuse(member_entry, "too-large",
|
|
708
|
+
declared_bytes=member.file_size,
|
|
709
|
+
container=entry.basename)
|
|
710
|
+
continue
|
|
711
|
+
if self._already_seen(member_entry, container=entry.basename):
|
|
712
|
+
continue
|
|
713
|
+
try:
|
|
714
|
+
payload = archive.read(member)
|
|
715
|
+
except Exception as e:
|
|
716
|
+
self._refuse(member_entry, "unreadable-member",
|
|
717
|
+
detail=str(e)[:120], container=entry.basename)
|
|
718
|
+
continue
|
|
719
|
+
|
|
720
|
+
staged_member = self._stage()
|
|
721
|
+
if staged_member is None:
|
|
722
|
+
return
|
|
723
|
+
with open(staged_member, "wb") as f:
|
|
724
|
+
f.write(payload)
|
|
725
|
+
apply_default_mode(staged_member)
|
|
726
|
+
self._commit(member_entry, staged_member,
|
|
727
|
+
hashlib.sha256(payload).hexdigest(), len(payload),
|
|
728
|
+
"", url=result.url, container=entry.basename)
|
|
729
|
+
|
|
730
|
+
# -- dedupe -----------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
def _already_seen(self, entry: SupplementFile, container=None) -> bool:
|
|
733
|
+
"""Cheap dedupe, before spending a transfer on it.
|
|
734
|
+
|
|
735
|
+
A duplicate deliberately does not consume an index: a gap would make _3
|
|
736
|
+
mean different things on different runs.
|
|
737
|
+
"""
|
|
738
|
+
if entry.checksum:
|
|
739
|
+
index = self.by_declared.get(_digest_of(entry.checksum))
|
|
740
|
+
if index:
|
|
741
|
+
self._refuse(entry, "duplicate-of", duplicate_of_index=index,
|
|
742
|
+
container=container)
|
|
743
|
+
return True
|
|
744
|
+
key = (entry.basename.lower(), entry.size_bytes)
|
|
745
|
+
if entry.basename and entry.size_bytes is not None and key in self.by_name_size:
|
|
746
|
+
self._refuse(entry, "duplicate-of",
|
|
747
|
+
duplicate_of_index=self.by_name_size[key], container=container)
|
|
748
|
+
return True
|
|
749
|
+
# Name alone is only evidence within one deposit. Across origins,
|
|
750
|
+
# "Supplementary_Table_1.xlsx" in a Dryad deposit and in a figshare
|
|
751
|
+
# deposit are routinely different files, so those go to the hash check
|
|
752
|
+
# after download instead.
|
|
753
|
+
origin_key = (entry.origin_doi, entry.basename.lower())
|
|
754
|
+
if entry.basename and origin_key in self.by_name_within_origin:
|
|
755
|
+
self._refuse(entry, "duplicate-of",
|
|
756
|
+
duplicate_of_index=self.by_name_within_origin[origin_key],
|
|
757
|
+
container=container)
|
|
758
|
+
return True
|
|
759
|
+
return False
|
|
760
|
+
|
|
761
|
+
# -- commit -----------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
def _commit(self, entry, staged, digest, size, content_type, url=None,
|
|
764
|
+
container=None) -> None:
|
|
765
|
+
head = _head_of(staged)
|
|
766
|
+
plausible, why = _looks_like_a_document(entry.basename, head, content_type, size)
|
|
767
|
+
if not plausible:
|
|
768
|
+
_unlink(staged)
|
|
769
|
+
self._refuse(entry, "not-a-document", detail=why, url=url,
|
|
770
|
+
container=container)
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
duplicate = self.by_hash.get(digest)
|
|
774
|
+
if duplicate:
|
|
775
|
+
_unlink(staged)
|
|
776
|
+
self._refuse(entry, "duplicate-of", duplicate_of_index=duplicate,
|
|
777
|
+
url=url, container=container, sha256=digest)
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
main = self._is_the_main_artifact(size, digest)
|
|
781
|
+
if main:
|
|
782
|
+
_unlink(staged)
|
|
783
|
+
self._refuse(entry, "duplicate-of-main-artifact", url=url,
|
|
784
|
+
container=container, sha256=digest,
|
|
785
|
+
main_artifact=os.path.basename(main))
|
|
786
|
+
return
|
|
787
|
+
|
|
788
|
+
index = len(self.kept) + 1
|
|
789
|
+
extension = _extension_for(entry.basename, content_type, head)
|
|
790
|
+
artifact_dir = self._artifact_dir(entry)
|
|
791
|
+
if artifact_dir:
|
|
792
|
+
# A linked deposit keeps its own filenames inside its own directory.
|
|
793
|
+
# The flat _supplementary_info_N_ scheme exists because the paper's
|
|
794
|
+
# own SI has no other namespace; a deposit does, and forcing it into
|
|
795
|
+
# the flat one buried four real files under 199 members of a
|
|
796
|
+
# vendored Stata package in a single record.
|
|
797
|
+
filename = _safe_member_name(entry.basename, extension, index)
|
|
798
|
+
final = os.path.join(artifact_dir, filename)
|
|
799
|
+
filename = os.path.join(os.path.basename(artifact_dir), filename)
|
|
800
|
+
else:
|
|
801
|
+
filename = si_filename(os.path.basename(self.stem), index,
|
|
802
|
+
entry.basename, extension)
|
|
803
|
+
final = os.path.join(self.directory, filename)
|
|
804
|
+
try:
|
|
805
|
+
os.replace(staged, final)
|
|
806
|
+
except OSError as e:
|
|
807
|
+
_unlink(staged)
|
|
808
|
+
self._refuse(entry, "unwritable", detail=str(e)[:120], url=url)
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
self.by_hash[digest] = index
|
|
812
|
+
if entry.checksum:
|
|
813
|
+
self.by_declared.setdefault(_digest_of(entry.checksum), index)
|
|
814
|
+
if entry.basename:
|
|
815
|
+
self.by_name_size[(entry.basename.lower(), size)] = index
|
|
816
|
+
self.by_name_size.setdefault((entry.basename.lower(), entry.size_bytes), index)
|
|
817
|
+
self.by_name_within_origin[(entry.origin_doi, entry.basename.lower())] = index
|
|
818
|
+
self.bytes_written += size
|
|
819
|
+
if self._is_data_artifact(entry):
|
|
820
|
+
self.artifact_bytes_written += size
|
|
821
|
+
self.kept.append({
|
|
822
|
+
"index": index,
|
|
823
|
+
"filename": filename,
|
|
824
|
+
"original_name": entry.name,
|
|
825
|
+
"label": entry.label,
|
|
826
|
+
"provider": entry.provider,
|
|
827
|
+
"listing_index": entry.listing_index,
|
|
828
|
+
"container": container,
|
|
829
|
+
"url": _provenance_url(entry, url),
|
|
830
|
+
"content_type": content_type,
|
|
831
|
+
"role": entry.role,
|
|
832
|
+
"bytes": size,
|
|
833
|
+
"sha256": digest,
|
|
834
|
+
"retrieved_at": _iso(time.time()),
|
|
835
|
+
})
|
|
836
|
+
self.ctx.log(f" 📎 {filename} ← {entry.provider} ({size} bytes)")
|
|
837
|
+
|
|
838
|
+
# A supplementary file that is itself a zip gets unpacked alongside.
|
|
839
|
+
# Distinct from _take_archive, which expands the TRANSPORT bundle (the
|
|
840
|
+
# Europe PMC container) and never keeps it. This one is a file the authors
|
|
841
|
+
# deposited, so the archive stays and its members join it.
|
|
842
|
+
# A data artifact's archive is KEPT WHOLE by default. A replication
|
|
843
|
+
# package is a coherent thing with its own internal layout, and
|
|
844
|
+
# unpacking one produced 199 files from a vendored Stata library in a
|
|
845
|
+
# single record. --unpack-data-artifacts opts back in.
|
|
846
|
+
if _is_zip(final) and not (self._artifact_dir(entry)
|
|
847
|
+
and not self.unpack_data_artifacts):
|
|
848
|
+
self._unpack_stored_zip(final, filename, entry, self.kept[-1])
|
|
849
|
+
|
|
850
|
+
def _unpack_stored_zip(self, archive_path: str, archive_filename: str, entry,
|
|
851
|
+
archive_record: dict) -> None:
|
|
852
|
+
"""Unpack a kept supplementary archive beside itself.
|
|
853
|
+
|
|
854
|
+
Reuses the same defences as the bundle expander: the decompression-ratio
|
|
855
|
+
guard, the per-file cap, the running budget, and basenaming every member
|
|
856
|
+
so a traversing name ("../../etc/passwd") cannot escape the directory or
|
|
857
|
+
reach the manifest.
|
|
858
|
+
"""
|
|
859
|
+
try:
|
|
860
|
+
archive = zipfile.ZipFile(archive_path)
|
|
861
|
+
except (zipfile.BadZipFile, OSError) as e:
|
|
862
|
+
self._refuse(entry, "not-an-archive", detail=str(e)[:120],
|
|
863
|
+
container=archive_filename)
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
taken = 0
|
|
867
|
+
with archive:
|
|
868
|
+
members = [m for m in archive.infolist() if not m.is_dir()]
|
|
869
|
+
if not members:
|
|
870
|
+
return
|
|
871
|
+
declared = sum(m.file_size for m in members)
|
|
872
|
+
on_disk = os.path.getsize(archive_path)
|
|
873
|
+
allowance = max(on_disk * _RATIO_GUARD_MULTIPLE, self.max_file_bytes)
|
|
874
|
+
if declared > allowance:
|
|
875
|
+
self._refuse(entry, "decompression-ratio", declared_bytes=declared,
|
|
876
|
+
container=archive_filename,
|
|
877
|
+
detail=f"{declared} bytes from a {on_disk}-byte archive")
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
for member in sorted(members, key=lambda m: _natural(
|
|
881
|
+
os.path.basename(m.filename.replace("\\", "/")))):
|
|
882
|
+
if not self.has_budget():
|
|
883
|
+
break
|
|
884
|
+
name = os.path.basename(member.filename.replace("\\", "/"))
|
|
885
|
+
if not name or name.startswith("."):
|
|
886
|
+
continue # __MACOSX/, .DS_Store and friends
|
|
887
|
+
if member.file_size > self.max_file_bytes:
|
|
888
|
+
self._refuse(entry, "too-large", declared_bytes=member.file_size,
|
|
889
|
+
container=archive_filename, detail=name)
|
|
890
|
+
continue
|
|
891
|
+
try:
|
|
892
|
+
payload = archive.read(member)
|
|
893
|
+
except Exception as e:
|
|
894
|
+
self._refuse(entry, "unreadable-member", detail=f"{name}: {str(e)[:90]}",
|
|
895
|
+
container=archive_filename)
|
|
896
|
+
continue
|
|
897
|
+
|
|
898
|
+
index = len(self.kept) + 1
|
|
899
|
+
extension = os.path.splitext(name)[1].lower() or ".bin"
|
|
900
|
+
out_name = si_filename(os.path.basename(self.stem), index,
|
|
901
|
+
name, extension)
|
|
902
|
+
out_path = os.path.join(self.directory, out_name)
|
|
903
|
+
try:
|
|
904
|
+
_write_bytes_atomic(out_path, payload)
|
|
905
|
+
except OSError as e:
|
|
906
|
+
self._refuse(entry, "unwritable", detail=str(e)[:120],
|
|
907
|
+
container=archive_filename)
|
|
908
|
+
continue
|
|
909
|
+
|
|
910
|
+
self.bytes_written += len(payload)
|
|
911
|
+
taken += 1
|
|
912
|
+
# Recorded in the manifest like any other file. An extracted
|
|
913
|
+
# member that existed only on disk would be untraceable: nothing
|
|
914
|
+
# else says which archive it came from or what it was called.
|
|
915
|
+
self.kept.append({
|
|
916
|
+
"index": index,
|
|
917
|
+
"filename": out_name,
|
|
918
|
+
"original_name": name,
|
|
919
|
+
"provider": entry.provider,
|
|
920
|
+
"extracted_from": archive_filename,
|
|
921
|
+
"url": redact(entry.url),
|
|
922
|
+
"role": entry.role,
|
|
923
|
+
"bytes": len(payload),
|
|
924
|
+
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
925
|
+
"retrieved_at": _iso(time.time()),
|
|
926
|
+
# Nested archives are left packed rather than expanded
|
|
927
|
+
# recursively: one level is the useful case, and unbounded
|
|
928
|
+
# recursion is how a zip quine becomes a disk-filling loop.
|
|
929
|
+
"nested_archive": bool(name.lower().endswith(".zip")),
|
|
930
|
+
})
|
|
931
|
+
if taken:
|
|
932
|
+
self.ctx.log(f" 📦 {archive_filename} → {taken} extracted file(s)")
|
|
933
|
+
|
|
934
|
+
# Only after a successful extraction, and only outside the `with` so the
|
|
935
|
+
# handle is closed first (Windows will not unlink an open file).
|
|
936
|
+
if taken:
|
|
937
|
+
_unlink(archive_path)
|
|
938
|
+
self.bytes_written -= archive_record.get("bytes", 0)
|
|
939
|
+
archive_record["extracted_members"] = taken
|
|
940
|
+
# The record stays, marked. Dropping it would lose the only account
|
|
941
|
+
# of where the members came from, and _repair would see a manifest
|
|
942
|
+
# entry with no file and try to download the archive all over again.
|
|
943
|
+
archive_record["removed_after_extraction"] = True
|
|
944
|
+
archive_record["filename"] = None
|
|
945
|
+
|
|
946
|
+
def _refuse(self, entry, reason, **fields) -> None:
|
|
947
|
+
record = {
|
|
948
|
+
"original_name": entry.name,
|
|
949
|
+
"provider": entry.provider,
|
|
950
|
+
"url": redact(fields.pop("url", None) or entry.url),
|
|
951
|
+
"reason": reason,
|
|
952
|
+
}
|
|
953
|
+
record.update({k: v for k, v in fields.items() if v is not None})
|
|
954
|
+
self.skipped.append(record)
|
|
955
|
+
if _is_transient(reason, record.get("status")):
|
|
956
|
+
self.transient.append((entry, record))
|
|
957
|
+
self.ctx.log(f" ✗ {entry.basename or entry.provider}: {reason}")
|
|
958
|
+
|
|
959
|
+
def _stage(self) -> Optional[str]:
|
|
960
|
+
"""A staging path in the output dir.
|
|
961
|
+
|
|
962
|
+
Downloads land here first because the keep-or-drop decision needs the
|
|
963
|
+
sha256, and the sha256 needs the bytes. Two same-directory renames per
|
|
964
|
+
file is free next to the transfer, and it keeps the property that a
|
|
965
|
+
numbered sibling never exists in a partial state.
|
|
966
|
+
"""
|
|
967
|
+
try:
|
|
968
|
+
os.makedirs(self.directory, exist_ok=True)
|
|
969
|
+
handle, path = tempfile.mkstemp(prefix=".fetchpdf-si-", dir=self.directory)
|
|
970
|
+
os.close(handle)
|
|
971
|
+
return path
|
|
972
|
+
except OSError as e:
|
|
973
|
+
self.ctx.log(f" supplements: cannot stage in {self.directory}: {e}")
|
|
974
|
+
return None
|
|
975
|
+
|
|
976
|
+
# -- output -----------------------------------------------------------
|
|
977
|
+
|
|
978
|
+
def finish(self, manifest_path, ids, reports, max_file_bytes) -> SupplementarySummary:
|
|
979
|
+
# The completeness gate first: it outranks every other status. The
|
|
980
|
+
# question that matters is not "did any request fail" -- providers list
|
|
981
|
+
# speculatively and endpoints say "nothing here" in strange ways -- but
|
|
982
|
+
# "did we get what the paper itself declares it has".
|
|
983
|
+
declared = _declared_check(self.ctx, self.kept)
|
|
984
|
+
|
|
985
|
+
# "partial" means something the record has was not obtained. A duplicate
|
|
986
|
+
# is not that: the same file reachable three ways and stored once is a
|
|
987
|
+
# complete result, and the overwhelming majority of skips are duplicates.
|
|
988
|
+
lost = [s for s in self.skipped if s.get("reason") in _LOST_REASONS]
|
|
989
|
+
if declared.get("missing"):
|
|
990
|
+
# A file the article's own JATS names was not obtained by any
|
|
991
|
+
# provider. Worse than "partial": this is known, named data loss.
|
|
992
|
+
status = "incomplete"
|
|
993
|
+
elif lost:
|
|
994
|
+
# "partial" whether or not anything was kept. A record whose one
|
|
995
|
+
# supplement was refused for size is not an error -- the refusal was
|
|
996
|
+
# deliberate and is recorded with its byte count -- but it is not a
|
|
997
|
+
# clean result either.
|
|
998
|
+
status = "partial"
|
|
999
|
+
elif self.kept:
|
|
1000
|
+
status = "ok"
|
|
1001
|
+
elif any(r.get("status") == "error" for r in reports):
|
|
1002
|
+
status = "error"
|
|
1003
|
+
else:
|
|
1004
|
+
status = "none_found"
|
|
1005
|
+
|
|
1006
|
+
manifest = {
|
|
1007
|
+
"schema_version": _SCHEMA_VERSION,
|
|
1008
|
+
"identifier": str(ids.doi or ids.best_id or ""),
|
|
1009
|
+
"identifiers_resolved": ids.to_dict() if hasattr(ids, "to_dict") else {},
|
|
1010
|
+
"stem": os.path.basename(self.stem),
|
|
1011
|
+
"completed_at": _iso(time.time()),
|
|
1012
|
+
"status": status,
|
|
1013
|
+
"limits": {
|
|
1014
|
+
"max_file_bytes": max_file_bytes,
|
|
1015
|
+
"max_total_bytes": self.max_total_bytes,
|
|
1016
|
+
"max_files": self.max_files,
|
|
1017
|
+
},
|
|
1018
|
+
"providers": reports,
|
|
1019
|
+
"declared": declared,
|
|
1020
|
+
"files": self.kept,
|
|
1021
|
+
"skipped": self.skipped,
|
|
1022
|
+
"counts": {
|
|
1023
|
+
"written": len(self.kept),
|
|
1024
|
+
"skipped": len(self.skipped),
|
|
1025
|
+
"bytes_written": self.bytes_written,
|
|
1026
|
+
},
|
|
1027
|
+
}
|
|
1028
|
+
written_path = write_manifest(manifest_path, manifest, self.ctx.verbose)
|
|
1029
|
+
return SupplementarySummary(
|
|
1030
|
+
status=status,
|
|
1031
|
+
written=len(self.kept),
|
|
1032
|
+
skipped=len(self.skipped),
|
|
1033
|
+
bytes_written=self.bytes_written,
|
|
1034
|
+
manifest_path=written_path,
|
|
1035
|
+
# filename is None for an archive removed after its members were
|
|
1036
|
+
# extracted (see _unpack_stored_zip): the record is kept as the only
|
|
1037
|
+
# account of where those members came from, but there is no file to
|
|
1038
|
+
# point at. Joining it raised TypeError and failed the whole
|
|
1039
|
+
# supplementary pass for the record.
|
|
1040
|
+
paths=[os.path.join(self.directory, f["filename"])
|
|
1041
|
+
for f in self.kept if f.get("filename")],
|
|
1042
|
+
missing_declared=list(declared.get("missing") or []),
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# -- the slow retry ----------------------------------------------------------
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _is_transient(reason: str, status) -> bool:
|
|
1050
|
+
"""Whether a refusal deserves the record-level slow rounds.
|
|
1051
|
+
|
|
1052
|
+
"unreachable" and "empty" are connection weather. An http-error earns a
|
|
1053
|
+
retry only for statuses that mean "later" -- 403/404/410 are answers, and
|
|
1054
|
+
retrying an answer is how a run spins forever against a correct "no".
|
|
1055
|
+
"""
|
|
1056
|
+
if reason in ("unreachable", "empty"):
|
|
1057
|
+
return True
|
|
1058
|
+
return reason == "http-error" and status in _TRANSIENT_STATUSES
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _retry_transients(run: "_Run", stem: str, waits=None) -> None:
|
|
1062
|
+
"""Re-take transiently failed entries, slowing down dramatically per round.
|
|
1063
|
+
|
|
1064
|
+
The waits print unconditionally: a five-minute silence in a batch run reads
|
|
1065
|
+
as a hang, and the slowdown is deliberate behaviour worth narrating. Each
|
|
1066
|
+
round pops the stale skip record before re-taking, so a file that succeeds
|
|
1067
|
+
on round two leaves no trace of its round-one failure beyond the log.
|
|
1068
|
+
"""
|
|
1069
|
+
if waits is None:
|
|
1070
|
+
waits = _RETRY_WAITS # read at call time, so tests can shrink it
|
|
1071
|
+
for round_no, wait in enumerate(waits, start=1):
|
|
1072
|
+
pending, run.transient = run.transient, []
|
|
1073
|
+
if not pending or not run.has_budget():
|
|
1074
|
+
return
|
|
1075
|
+
names = ", ".join((e.basename or e.provider) for e, _ in pending[:3])
|
|
1076
|
+
print(f" ⏳ SI retry round {round_no}/{len(waits)} for "
|
|
1077
|
+
f"{os.path.basename(stem)}: {len(pending)} file(s) ({names}), "
|
|
1078
|
+
f"waiting {wait}s")
|
|
1079
|
+
time.sleep(wait)
|
|
1080
|
+
for entry, record in pending:
|
|
1081
|
+
if not run.has_budget():
|
|
1082
|
+
run.transient.append((entry, record))
|
|
1083
|
+
continue
|
|
1084
|
+
try:
|
|
1085
|
+
run.skipped.remove(record)
|
|
1086
|
+
except ValueError:
|
|
1087
|
+
pass
|
|
1088
|
+
run.take(entry)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
# -- the completeness gate ---------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _declared_block(declared: Dict[str, str], kept: List[dict]) -> dict:
|
|
1095
|
+
"""The manifest's declared-vs-obtained accounting, computed purely.
|
|
1096
|
+
|
|
1097
|
+
A declared basename counts as obtained when any kept file's original name
|
|
1098
|
+
matches it extension-insensitively (_matches_href): publishers list .jpg
|
|
1099
|
+
and serve .gif often enough that exact matching would cry wolf.
|
|
1100
|
+
"""
|
|
1101
|
+
names = [f.get("original_name") or f.get("filename") or "" for f in kept]
|
|
1102
|
+
missing = sorted(base for base in declared
|
|
1103
|
+
if not _matches_href(base, names))
|
|
1104
|
+
return {
|
|
1105
|
+
"source": "jats",
|
|
1106
|
+
"total": len(declared),
|
|
1107
|
+
"obtained": len(declared) - len(missing),
|
|
1108
|
+
"missing": missing,
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _declared_check(ctx, kept: List[dict]) -> dict:
|
|
1113
|
+
"""What the article declares vs what was obtained, or an honest shrug.
|
|
1114
|
+
|
|
1115
|
+
"unavailable" when the JATS manifest never ran (no PMCID, no parseable XML,
|
|
1116
|
+
no <body>): a record that cannot be verified must not read as verified.
|
|
1117
|
+
"""
|
|
1118
|
+
declared = ctx.scratch.get("jats_declared")
|
|
1119
|
+
if declared is None:
|
|
1120
|
+
supplements = ctx.scratch.get("jats_supplements")
|
|
1121
|
+
if supplements is None:
|
|
1122
|
+
return {"source": "unavailable"}
|
|
1123
|
+
declared = {base: "" for base in supplements}
|
|
1124
|
+
return _declared_block(declared, kept)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _heal_manifest(manifest: dict, manifest_path: str, stem: str,
|
|
1128
|
+
verbose: bool) -> Tuple[str, List[str]]:
|
|
1129
|
+
"""Bring an old manifest's accounting up to current classification.
|
|
1130
|
+
|
|
1131
|
+
Two upgrades, both idempotent and download-free:
|
|
1132
|
+
|
|
1133
|
+
* EPMC bundle refusals recorded before "no-bundle" existed -- an http-error
|
|
1134
|
+
404/410, or a not-an-archive that was really the <errorBean> stub -- are
|
|
1135
|
+
reclassified, with the original reason kept under "was".
|
|
1136
|
+
* The declared-vs-obtained gate is recomputed from the record's own XML on
|
|
1137
|
+
disk, and the status re-derived under the same ladder finish() uses.
|
|
1138
|
+
|
|
1139
|
+
Returns the status the repair-path summary should carry: the healed status
|
|
1140
|
+
when it demands attention ("incomplete"), else "skipped" -- the repair
|
|
1141
|
+
pass's contract that a no-op must never look like new work.
|
|
1142
|
+
"""
|
|
1143
|
+
changed = False
|
|
1144
|
+
for skip in manifest.get("skipped") or []:
|
|
1145
|
+
if skip.get("provider") != "europepmc_supplements":
|
|
1146
|
+
continue
|
|
1147
|
+
reason = skip.get("reason")
|
|
1148
|
+
if reason == "http-error" and skip.get("status") in (404, 410):
|
|
1149
|
+
skip["reason"], skip["was"] = "no-bundle", reason
|
|
1150
|
+
changed = True
|
|
1151
|
+
elif reason == "not-an-archive":
|
|
1152
|
+
# This endpoint's only known non-zip payload is the errorBean stub;
|
|
1153
|
+
# if it was something else, the gate below still catches any real
|
|
1154
|
+
# shortfall via the declared list.
|
|
1155
|
+
skip["reason"], skip["was"] = "no-bundle", reason
|
|
1156
|
+
changed = True
|
|
1157
|
+
|
|
1158
|
+
declared = _declared_from_disk(stem)
|
|
1159
|
+
if declared is not None:
|
|
1160
|
+
block = _declared_block(declared, manifest.get("files") or [])
|
|
1161
|
+
if manifest.get("declared") != block:
|
|
1162
|
+
manifest["declared"] = block
|
|
1163
|
+
changed = True
|
|
1164
|
+
|
|
1165
|
+
missing_declared = list((manifest.get("declared") or {}).get("missing") or [])
|
|
1166
|
+
lost = [s for s in (manifest.get("skipped") or [])
|
|
1167
|
+
if s.get("reason") in _LOST_REASONS]
|
|
1168
|
+
if missing_declared:
|
|
1169
|
+
status = "incomplete"
|
|
1170
|
+
elif lost:
|
|
1171
|
+
status = "partial"
|
|
1172
|
+
elif manifest.get("files"):
|
|
1173
|
+
status = "ok"
|
|
1174
|
+
elif any(r.get("status") == "error" for r in manifest.get("providers") or []):
|
|
1175
|
+
status = "error"
|
|
1176
|
+
else:
|
|
1177
|
+
status = "none_found"
|
|
1178
|
+
|
|
1179
|
+
if manifest.get("status") != status:
|
|
1180
|
+
manifest["status"] = status
|
|
1181
|
+
changed = True
|
|
1182
|
+
if changed:
|
|
1183
|
+
write_manifest(manifest_path, manifest, verbose)
|
|
1184
|
+
if verbose:
|
|
1185
|
+
print(f" 📋 manifest healed: status {status}")
|
|
1186
|
+
return ("incomplete" if status == "incomplete" else "skipped",
|
|
1187
|
+
missing_declared)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _declared_from_disk(stem: str) -> Optional[Dict[str, str]]:
|
|
1191
|
+
"""The declared-supplement map from {stem}.xml, or None if unverifiable."""
|
|
1192
|
+
from .supplement_pmc import declared_from_xml
|
|
1193
|
+
|
|
1194
|
+
try:
|
|
1195
|
+
with open(stem + ".xml", "rb") as f:
|
|
1196
|
+
content = f.read()
|
|
1197
|
+
except OSError:
|
|
1198
|
+
return None
|
|
1199
|
+
return declared_from_xml(content)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
# -- the repair pass --------------------------------------------------------
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _repair(manifest_path, stem, resolver, http, ladder, email, verbose,
|
|
1206
|
+
max_file_bytes, verify_hashes: bool = True) -> SupplementarySummary:
|
|
1207
|
+
"""Honour an existing manifest, re-fetching what is missing OR corrupt.
|
|
1208
|
+
|
|
1209
|
+
A file deleted from disk comes back at its recorded index, so removing _2
|
|
1210
|
+
never renumbers _3. When nothing is missing this costs N stat calls and zero
|
|
1211
|
+
HTTP, which is the whole point of the manifest being the skip signal.
|
|
1212
|
+
|
|
1213
|
+
"Missing" means unusable, not absent. The manifest records a size and a
|
|
1214
|
+
sha256 for every file and this pass is the only place they are ever read
|
|
1215
|
+
back; without that, a truncated or git-lfs-stubbed supplement counts as
|
|
1216
|
+
present forever and the corpus reads as complete while holding 130-byte
|
|
1217
|
+
placeholders where its data should be.
|
|
1218
|
+
"""
|
|
1219
|
+
manifest = read_manifest(manifest_path)
|
|
1220
|
+
if manifest is None:
|
|
1221
|
+
return SupplementarySummary(status="error", manifest_path=manifest_path,
|
|
1222
|
+
detail="manifest unreadable")
|
|
1223
|
+
|
|
1224
|
+
# Heal before anything else: manifests written before the no-bundle
|
|
1225
|
+
# classification and the completeness gate carry phantom "partial"
|
|
1226
|
+
# statuses, and the repair pass runs on every re-run -- the natural place
|
|
1227
|
+
# to bring old accounting up to current truth without re-downloading.
|
|
1228
|
+
status, missing_declared = _heal_manifest(manifest, manifest_path, stem,
|
|
1229
|
+
verbose)
|
|
1230
|
+
|
|
1231
|
+
directory = os.path.dirname(os.path.abspath(stem)) or "."
|
|
1232
|
+
files = manifest.get("files") or []
|
|
1233
|
+
# A removed-after-extraction archive has no file BY DESIGN; refetching it
|
|
1234
|
+
# would restore the very zip whose members are already unpacked beside it.
|
|
1235
|
+
files = [f for f in files if not f.get("removed_after_extraction")]
|
|
1236
|
+
|
|
1237
|
+
# Existence is not integrity -- see _integrity_of. A file that is present
|
|
1238
|
+
# but corrupt is re-fetched at its recorded index, exactly like an absent
|
|
1239
|
+
# one, so the no-renumber guarantee holds for both.
|
|
1240
|
+
verdicts = {}
|
|
1241
|
+
missing = []
|
|
1242
|
+
for f in files:
|
|
1243
|
+
verdict = _integrity_of(os.path.join(directory, f.get("filename") or ""),
|
|
1244
|
+
f, verify_hashes)
|
|
1245
|
+
verdicts[id(f)] = verdict
|
|
1246
|
+
if verdict != INTACT and verdict != UNVERIFIABLE:
|
|
1247
|
+
missing.append(f)
|
|
1248
|
+
present = len(files) - len(missing)
|
|
1249
|
+
|
|
1250
|
+
damaged = [(f, verdicts[id(f)]) for f in missing if verdicts[id(f)] != ABSENT]
|
|
1251
|
+
if damaged and verbose:
|
|
1252
|
+
for f, verdict in damaged:
|
|
1253
|
+
name = f.get("filename") or "?"
|
|
1254
|
+
if verdict == LFS_POINTER:
|
|
1255
|
+
# Naming the cause matters: the fix is a checkout/sync repair,
|
|
1256
|
+
# not a re-download, and the next sync will stub it again.
|
|
1257
|
+
print(f" ⚠️ {name}: git-lfs pointer, content never smudged "
|
|
1258
|
+
f"(re-fetching, but the storage needs fixing)")
|
|
1259
|
+
else:
|
|
1260
|
+
print(f" ⚠️ {name}: {verdict.replace('_', ' ')} vs manifest "
|
|
1261
|
+
f"(re-fetching)")
|
|
1262
|
+
|
|
1263
|
+
if not missing:
|
|
1264
|
+
return SupplementarySummary(
|
|
1265
|
+
status=status, written=present, manifest_path=manifest_path,
|
|
1266
|
+
paths=[os.path.join(directory, f["filename"]) for f in files
|
|
1267
|
+
if f.get("filename")],
|
|
1268
|
+
detail="manifest present; nothing missing",
|
|
1269
|
+
missing_declared=missing_declared,
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
if http is None:
|
|
1273
|
+
return SupplementarySummary(
|
|
1274
|
+
status=status, written=present, manifest_path=manifest_path,
|
|
1275
|
+
detail=f"{len(missing)} file(s) missing, no client to refetch with",
|
|
1276
|
+
missing_declared=missing_declared,
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
repaired = 0
|
|
1280
|
+
archives: Dict[str, List[dict]] = {}
|
|
1281
|
+
for record in missing:
|
|
1282
|
+
url = record.get("url") or ""
|
|
1283
|
+
filename = record.get("filename") or ""
|
|
1284
|
+
# A redacted URL cannot be re-fetched, and guessing at the secret would
|
|
1285
|
+
# be worse than saying so.
|
|
1286
|
+
if not url or not filename or "REDACTED" in url:
|
|
1287
|
+
continue
|
|
1288
|
+
# A file that arrived by archive expansion has the *archive's* URL, so
|
|
1289
|
+
# fetching it straight into the member's name would write the whole zip
|
|
1290
|
+
# where a single member belongs -- the right bytes for nothing.
|
|
1291
|
+
if record.get("container"):
|
|
1292
|
+
archives.setdefault(url, []).append(record)
|
|
1293
|
+
continue
|
|
1294
|
+
dest = os.path.join(directory, filename)
|
|
1295
|
+
if http.download(url, dest, max_file_bytes, polite=False).ok:
|
|
1296
|
+
repaired += 1
|
|
1297
|
+
if verbose:
|
|
1298
|
+
print(f" 📎 restored {filename}")
|
|
1299
|
+
|
|
1300
|
+
for url, records in archives.items():
|
|
1301
|
+
repaired += _repair_from_archive(http, url, records, directory,
|
|
1302
|
+
max_file_bytes, verbose)
|
|
1303
|
+
|
|
1304
|
+
# Say which were corrupt rather than absent: "restored 30 of 30" hides a
|
|
1305
|
+
# storage fault that will recur on the next sync.
|
|
1306
|
+
detail = f"restored {repaired} of {len(missing)} missing file(s)"
|
|
1307
|
+
if damaged:
|
|
1308
|
+
kinds = sorted({verdict for _, verdict in damaged})
|
|
1309
|
+
detail += f" ({len(damaged)} corrupt on disk: {', '.join(kinds)})"
|
|
1310
|
+
|
|
1311
|
+
return SupplementarySummary(
|
|
1312
|
+
status=status, written=present + repaired, manifest_path=manifest_path,
|
|
1313
|
+
detail=detail,
|
|
1314
|
+
missing_declared=missing_declared,
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
#: A git-lfs pointer stands where the content should be. This is NOT a download
|
|
1319
|
+
#: failure -- the bytes were fetched and hashed correctly, then a checkout or a
|
|
1320
|
+
#: file-sync replaced them with this 130-byte stub. Measured on a real corpus:
|
|
1321
|
+
#: 30 of 38 supplementary payloads, every pointer oid matching the sha256 the
|
|
1322
|
+
#: manifest recorded, which is the proof the real bytes were once on disk.
|
|
1323
|
+
_LFS_POINTER_MAGIC = b"version https://git-lfs.github.com/spec/v1"
|
|
1324
|
+
|
|
1325
|
+
#: What _integrity_of found. "absent" and "lfs_pointer" are different facts and
|
|
1326
|
+
#: call for different operator actions -- re-fetch versus fix your checkout --
|
|
1327
|
+
#: so they never collapse into one "missing".
|
|
1328
|
+
INTACT = "intact"
|
|
1329
|
+
ABSENT = "absent"
|
|
1330
|
+
LFS_POINTER = "lfs_pointer"
|
|
1331
|
+
SIZE_MISMATCH = "size_mismatch"
|
|
1332
|
+
HASH_MISMATCH = "hash_mismatch"
|
|
1333
|
+
UNVERIFIABLE = "unverifiable"
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _integrity_of(path: str, record: dict, verify_hashes: bool = True) -> str:
|
|
1337
|
+
"""Classify one manifest-listed file on disk.
|
|
1338
|
+
|
|
1339
|
+
Existence is not integrity. A truncated, zeroed, half-synced or LFS-stubbed
|
|
1340
|
+
file passes os.path.isfile and fails every use it was downloaded for -- and
|
|
1341
|
+
a hollow supplement reads downstream as "this paper published no data",
|
|
1342
|
+
which is worse than a loud absence.
|
|
1343
|
+
|
|
1344
|
+
Ordered by cost: stat before read, read before hash, so an intact corpus
|
|
1345
|
+
stays at N stats plus a 4 KB head read and the manifest keeps its
|
|
1346
|
+
zero-HTTP skip-signal property.
|
|
1347
|
+
|
|
1348
|
+
A record with no recorded size or digest is UNVERIFIABLE, never corrupt:
|
|
1349
|
+
manifests written before this check exist, and re-downloading a healthy
|
|
1350
|
+
file on absent metadata would be a regression, not a repair.
|
|
1351
|
+
"""
|
|
1352
|
+
if not os.path.isfile(path):
|
|
1353
|
+
return ABSENT
|
|
1354
|
+
|
|
1355
|
+
declared_bytes = record.get("bytes")
|
|
1356
|
+
try:
|
|
1357
|
+
actual_bytes = os.path.getsize(path)
|
|
1358
|
+
except OSError:
|
|
1359
|
+
return ABSENT
|
|
1360
|
+
|
|
1361
|
+
# Cheapest decisive check first, and on its own enough to catch every
|
|
1362
|
+
# stub: a 130-byte pointer where 9362 bytes were recorded.
|
|
1363
|
+
if _head_of(path, len(_LFS_POINTER_MAGIC)).startswith(_LFS_POINTER_MAGIC):
|
|
1364
|
+
return LFS_POINTER
|
|
1365
|
+
|
|
1366
|
+
if isinstance(declared_bytes, int) and declared_bytes >= 0:
|
|
1367
|
+
if actual_bytes != declared_bytes:
|
|
1368
|
+
return SIZE_MISMATCH
|
|
1369
|
+
elif not record.get("sha256"):
|
|
1370
|
+
return UNVERIFIABLE
|
|
1371
|
+
|
|
1372
|
+
declared_sha = str(record.get("sha256") or "").strip().lower()
|
|
1373
|
+
if verify_hashes and declared_sha:
|
|
1374
|
+
actual_sha = _hash_file(path)
|
|
1375
|
+
if actual_sha is None:
|
|
1376
|
+
return ABSENT
|
|
1377
|
+
if actual_sha.lower() != declared_sha:
|
|
1378
|
+
return HASH_MISMATCH
|
|
1379
|
+
|
|
1380
|
+
if not isinstance(declared_bytes, int) and not declared_sha:
|
|
1381
|
+
return UNVERIFIABLE
|
|
1382
|
+
return INTACT
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _repair_from_archive(http, url, records, directory, max_file_bytes, verbose) -> int:
|
|
1386
|
+
"""Re-extract missing members from their archive, at their recorded names."""
|
|
1387
|
+
staged = None
|
|
1388
|
+
restored = 0
|
|
1389
|
+
try:
|
|
1390
|
+
handle, staged = tempfile.mkstemp(prefix=".fetchpdf-si-", dir=directory)
|
|
1391
|
+
os.close(handle)
|
|
1392
|
+
bundle_cap = max_file_bytes * BUNDLE_CAP_MULTIPLE
|
|
1393
|
+
if not http.download(url, staged, bundle_cap, polite=False).ok:
|
|
1394
|
+
return 0
|
|
1395
|
+
with zipfile.ZipFile(staged) as archive:
|
|
1396
|
+
by_name = {
|
|
1397
|
+
os.path.basename(m.filename.replace("\\", "/")): m
|
|
1398
|
+
for m in archive.infolist()
|
|
1399
|
+
if not m.is_dir()
|
|
1400
|
+
}
|
|
1401
|
+
for record in records:
|
|
1402
|
+
member = by_name.get(record.get("original_name") or "")
|
|
1403
|
+
if member is None or member.file_size > max_file_bytes:
|
|
1404
|
+
continue
|
|
1405
|
+
dest = os.path.join(directory, record["filename"])
|
|
1406
|
+
payload = archive.read(member)
|
|
1407
|
+
_write_bytes_atomic(dest, payload)
|
|
1408
|
+
restored += 1
|
|
1409
|
+
if verbose:
|
|
1410
|
+
print(f" 📎 restored {record['filename']} from {url.rsplit('/', 1)[-1]}")
|
|
1411
|
+
except (zipfile.BadZipFile, OSError, KeyError):
|
|
1412
|
+
return restored
|
|
1413
|
+
finally:
|
|
1414
|
+
_unlink(staged)
|
|
1415
|
+
return restored
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def si_filename(stem_basename: str, index: int, original_name: str, extension: str) -> str:
|
|
1419
|
+
"""The one supplementary filename rule. See DESCRIPTOR_MAX_CHARS above."""
|
|
1420
|
+
descriptor = _descriptor(original_name, extension)
|
|
1421
|
+
return f"{stem_basename}{INFIX}_{index}{descriptor}{extension}"
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _safe_member_name(original: str, extension: str, index: int) -> str:
|
|
1425
|
+
"""A deposit file's own name, made safe for every filesystem.
|
|
1426
|
+
|
|
1427
|
+
Unlike the flat scheme this keeps the author's filename -- inside a
|
|
1428
|
+
per-deposit directory it is unambiguous, and "MainStudy_UK_raw.csv" is far
|
|
1429
|
+
more use to a reader than "_supplementary_info_7". Same whitelist as
|
|
1430
|
+
_descriptor, so Windows-forbidden characters and path traversal are both
|
|
1431
|
+
handled; the index only breaks ties.
|
|
1432
|
+
"""
|
|
1433
|
+
base = os.path.basename(str(original or "").replace("\\", "/"))
|
|
1434
|
+
stem, dot_ext = os.path.splitext(base)
|
|
1435
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-")[:DESCRIPTOR_MAX_CHARS]
|
|
1436
|
+
cleaned = re.sub(r"_{2,}", "_", cleaned).strip("._-")
|
|
1437
|
+
suffix = dot_ext if dot_ext and len(dot_ext) <= 8 else extension
|
|
1438
|
+
return f"{cleaned or f'file_{index}'}{suffix}"
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _descriptor(original_name: str, extension: str) -> str:
|
|
1442
|
+
"""`_TablesS1-S5` from `TablesS1-S5.docx`, or "" when it would add nothing."""
|
|
1443
|
+
if not original_name:
|
|
1444
|
+
return ""
|
|
1445
|
+
base = os.path.basename(str(original_name).replace("\\", "/"))
|
|
1446
|
+
if extension and base.lower().endswith(extension.lower()):
|
|
1447
|
+
base = base[: -len(extension)]
|
|
1448
|
+
else:
|
|
1449
|
+
base = os.path.splitext(base)[0]
|
|
1450
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._-")
|
|
1451
|
+
cleaned = re.sub(r"_{2,}", "_", cleaned)
|
|
1452
|
+
if not cleaned:
|
|
1453
|
+
return ""
|
|
1454
|
+
# Long enough to stay recognisable, short enough that stem + infix + index +
|
|
1455
|
+
# descriptor + extension cannot approach the 255-byte filename limit.
|
|
1456
|
+
return "_" + cleaned[:DESCRIPTOR_MAX_CHARS].strip("._-")
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _error_bean(path: str) -> Optional[str]:
|
|
1460
|
+
"""The errMsg of an EPMC <errorBean> stub, or None for anything else.
|
|
1461
|
+
|
|
1462
|
+
Size-gated hard: a real bundle is never this small, and nothing legitimate
|
|
1463
|
+
starts with an XML prolog and contains <errorBean>.
|
|
1464
|
+
"""
|
|
1465
|
+
try:
|
|
1466
|
+
if os.path.getsize(path) > 1024:
|
|
1467
|
+
return None
|
|
1468
|
+
with open(path, "rb") as f:
|
|
1469
|
+
head = f.read(1024)
|
|
1470
|
+
except OSError:
|
|
1471
|
+
return None
|
|
1472
|
+
if not head.lstrip().startswith(b"<?xml") or b"<errorBean>" not in head:
|
|
1473
|
+
return None
|
|
1474
|
+
match = re.search(rb"<errMsg>(.*?)</errMsg>", head, re.DOTALL)
|
|
1475
|
+
if match:
|
|
1476
|
+
return match.group(1).decode("utf-8", "replace")[:160]
|
|
1477
|
+
return "errorBean with no errMsg"
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _provenance_url(entry, transferred_url=None) -> Optional[str]:
|
|
1481
|
+
"""Where this file actually came from, for the manifest.
|
|
1482
|
+
|
|
1483
|
+
Normally that is the URL we transferred. The exception is a file some
|
|
1484
|
+
provider had already fetched to a scratch directory and handed over as
|
|
1485
|
+
`file://...`: HttpClient.download adopts those under the same rules as a
|
|
1486
|
+
network transfer, which is the right thing for the BYTES and the wrong
|
|
1487
|
+
thing for the RECORD -- a temp path stops meaning anything the moment the
|
|
1488
|
+
directory is removed, and the manifest is what a reader consults to ask
|
|
1489
|
+
where a file came from.
|
|
1490
|
+
|
|
1491
|
+
So a provider that knows the real origin puts it in `extra["source_url"]`
|
|
1492
|
+
and it wins. Losing that would be this toolkit's own signature defect: a
|
|
1493
|
+
local path presented as a provenance.
|
|
1494
|
+
"""
|
|
1495
|
+
extra = getattr(entry, "extra", None) or {}
|
|
1496
|
+
source = extra.get("source_url")
|
|
1497
|
+
if source:
|
|
1498
|
+
return redact(str(source))
|
|
1499
|
+
candidate = transferred_url or entry.url
|
|
1500
|
+
if str(candidate or "").startswith("file://"):
|
|
1501
|
+
return None # honest: we do not know, rather than a temp path
|
|
1502
|
+
return redact(candidate)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _is_zip(path: str) -> bool:
|
|
1506
|
+
"""By magic bytes, not extension: a .zip served as .dat is still a zip, and a
|
|
1507
|
+
.docx is a zip we must NOT unpack -- hence the extension check as well."""
|
|
1508
|
+
if not path.lower().endswith(".zip"):
|
|
1509
|
+
return False
|
|
1510
|
+
try:
|
|
1511
|
+
with open(path, "rb") as f:
|
|
1512
|
+
return f.read(4) == b"PK\x03\x04"
|
|
1513
|
+
except OSError:
|
|
1514
|
+
return False
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _write_bytes_atomic(path: str, payload: bytes) -> None:
|
|
1518
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
1519
|
+
handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-si-", dir=directory)
|
|
1520
|
+
try:
|
|
1521
|
+
with os.fdopen(handle, "wb") as f:
|
|
1522
|
+
f.write(payload)
|
|
1523
|
+
apply_default_mode(temp_path)
|
|
1524
|
+
os.replace(temp_path, path)
|
|
1525
|
+
except BaseException:
|
|
1526
|
+
_unlink(temp_path)
|
|
1527
|
+
raise
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
# -- naming -----------------------------------------------------------------
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def stem_for(save_path: str) -> str:
|
|
1534
|
+
"""The record's stem, sharing engine._stem_for's multi-part suffix handling."""
|
|
1535
|
+
from .engine import _stem_for
|
|
1536
|
+
|
|
1537
|
+
return _stem_for(save_path)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _extension_for(name: str, content_type: str, head: bytes) -> str:
|
|
1541
|
+
"""The output extension, derived in a fixed order so it is reproducible.
|
|
1542
|
+
|
|
1543
|
+
Falls through to no extension at all rather than inventing .bin: the manifest
|
|
1544
|
+
carries the original name and the content type, and a fabricated extension is
|
|
1545
|
+
a lie that a downstream tool will act on.
|
|
1546
|
+
|
|
1547
|
+
The name is only believed when what follows its last dot is *recognisably* an
|
|
1548
|
+
extension. Plenty of supplementary identifiers end in a dotted segment that is
|
|
1549
|
+
not one -- a Crossref component DOI is named "pone.0000308.s001", and taking
|
|
1550
|
+
"s001" would produce a file called _1.s001 that nothing can open, while the
|
|
1551
|
+
served Content-Type said application/msword all along.
|
|
1552
|
+
"""
|
|
1553
|
+
candidate = ""
|
|
1554
|
+
if name and "." in name:
|
|
1555
|
+
candidate = name.rsplit(".", 1)[-1].lower()
|
|
1556
|
+
if not (1 <= len(candidate) <= 8 and candidate.isalnum()):
|
|
1557
|
+
candidate = ""
|
|
1558
|
+
if candidate in _KNOWN_EXTENSIONS:
|
|
1559
|
+
return "." + candidate
|
|
1560
|
+
|
|
1561
|
+
mapped = _CONTENT_TYPE_EXTENSIONS.get((content_type or "").strip().lower())
|
|
1562
|
+
if mapped:
|
|
1563
|
+
return mapped
|
|
1564
|
+
for magic, extension in _MAGIC:
|
|
1565
|
+
if head.startswith(magic):
|
|
1566
|
+
return extension
|
|
1567
|
+
# An unrecognised suffix is still better than nothing when the bytes and the
|
|
1568
|
+
# header offered no opinion either -- it is at least what the source called it.
|
|
1569
|
+
return "." + candidate if candidate else ""
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _looks_like_a_document(name, head: bytes, content_type: str, size: int) -> Tuple[bool, str]:
|
|
1573
|
+
"""Reject the bodies that are a 200 without being the file that was asked for.
|
|
1574
|
+
|
|
1575
|
+
Every marker here is served with a success status. This is the single guard
|
|
1576
|
+
that stops a Cloudflare interstitial being written as Supplementary Table 1.
|
|
1577
|
+
"""
|
|
1578
|
+
if size < 64:
|
|
1579
|
+
return False, f"implausibly small ({size} bytes)"
|
|
1580
|
+
window = head[:2048]
|
|
1581
|
+
for marker in _CHALLENGE_MARKERS:
|
|
1582
|
+
if marker in window:
|
|
1583
|
+
return False, "bot challenge page"
|
|
1584
|
+
lowered = (name or "").lower()
|
|
1585
|
+
if not lowered.endswith(_TEXTUAL_EXTENSIONS):
|
|
1586
|
+
stripped = window.lstrip()[:9].lower()
|
|
1587
|
+
if (content_type or "").startswith("text/html") or stripped in (b"<!doctype", b"<html"):
|
|
1588
|
+
return False, f"served HTML for {name or 'an unnamed file'}"
|
|
1589
|
+
|
|
1590
|
+
# A binary document that begins with markup is not that document, whatever
|
|
1591
|
+
# the extension claims. The HTML check above is too narrow to catch this:
|
|
1592
|
+
# Elsevier's attachment endpoint answers 200 with an
|
|
1593
|
+
# `<attachment-metadata-response>` wrapper -- ~726 bytes of XML whose only
|
|
1594
|
+
# payload is the URL of the file you actually asked for -- and it was
|
|
1595
|
+
# written straight to disk as `mmc1.pdf`. Measured on one corpus: 51 such
|
|
1596
|
+
# stubs across 33 papers, and 8 of them sat under a manifest reporting
|
|
1597
|
+
# `status: ok`.
|
|
1598
|
+
#
|
|
1599
|
+
# That is the worst shape a supplement failure can take. `fetchpdf-verify`
|
|
1600
|
+
# still passes them, because the sha256 recorded at download time matches
|
|
1601
|
+
# the sha256 on disk -- it answers "is what I have what I fetched", not "is
|
|
1602
|
+
# what I fetched a document". Downstream, a 726-byte XML stub named .pdf
|
|
1603
|
+
# reads as "this paper published this table", which is a claim about the
|
|
1604
|
+
# authors manufactured out of an API quirk.
|
|
1605
|
+
if lowered.endswith(_BINARY_DOC_EXTENSIONS) and window.lstrip()[:1] == b"<":
|
|
1606
|
+
root = window.lstrip()[:60].decode("ascii", "replace").split(">")[0]
|
|
1607
|
+
return False, f"served markup ({root}>) for {name or 'an unnamed file'}"
|
|
1608
|
+
return True, ""
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
# -- manifest io ------------------------------------------------------------
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def write_manifest(path: str, manifest: dict, verbose: bool = False) -> Optional[str]:
|
|
1615
|
+
"""Atomically, and never raising into the caller's record."""
|
|
1616
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
1617
|
+
try:
|
|
1618
|
+
os.makedirs(directory, exist_ok=True)
|
|
1619
|
+
handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-sim-", dir=directory)
|
|
1620
|
+
try:
|
|
1621
|
+
with os.fdopen(handle, "w", encoding="utf-8") as f:
|
|
1622
|
+
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
|
1623
|
+
os.replace(temp_path, path)
|
|
1624
|
+
except BaseException:
|
|
1625
|
+
_unlink(temp_path)
|
|
1626
|
+
raise
|
|
1627
|
+
return path
|
|
1628
|
+
except Exception as e:
|
|
1629
|
+
if verbose:
|
|
1630
|
+
print(f" ⚠️ could not write {os.path.basename(path)}: {e}")
|
|
1631
|
+
return None
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def read_manifest(path: str) -> Optional[dict]:
|
|
1635
|
+
try:
|
|
1636
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
1637
|
+
manifest = json.load(f)
|
|
1638
|
+
return manifest if isinstance(manifest, dict) else None
|
|
1639
|
+
except (OSError, ValueError):
|
|
1640
|
+
return None
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
# -- wiring -----------------------------------------------------------------
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _wire(resolver, http, ladder, save_path, email, verbose):
|
|
1647
|
+
"""Borrow the batch's resolver and client, or build them.
|
|
1648
|
+
|
|
1649
|
+
Borrowing matters more than it looks: HostRateLimiter is lock-guarded but not
|
|
1650
|
+
a singleton, so a client built per record gives every worker its own token
|
|
1651
|
+
buckets and multiplies the configured per-host rate by the worker count -- on
|
|
1652
|
+
the most request-heavy path in the tool.
|
|
1653
|
+
|
|
1654
|
+
Building a resolver when there is none is not optional either. Half the
|
|
1655
|
+
providers here are keyed on a PMCID or an Elsevier PII rather than on the DOI,
|
|
1656
|
+
and without a resolver those identifiers are simply absent -- so the PMC
|
|
1657
|
+
routes, which are the highest-yield ones for biomedical records, would
|
|
1658
|
+
silently never run in single-record mode.
|
|
1659
|
+
"""
|
|
1660
|
+
from .tiers import load_ladder
|
|
1661
|
+
|
|
1662
|
+
ladder = ladder or (getattr(resolver, "ladder", None)) or load_ladder()
|
|
1663
|
+
if http is None and resolver is not None:
|
|
1664
|
+
http = getattr(resolver, "http", None)
|
|
1665
|
+
if http is None:
|
|
1666
|
+
from .http import HttpClient
|
|
1667
|
+
from .ratelimit import HostRateLimiter, shared_host_limiter
|
|
1668
|
+
|
|
1669
|
+
http = HttpClient(shared_host_limiter(ladder.rate_limits), email=email, verbose=verbose)
|
|
1670
|
+
|
|
1671
|
+
owns_resolver = resolver is None
|
|
1672
|
+
if owns_resolver:
|
|
1673
|
+
from .cache import ResolutionCache
|
|
1674
|
+
from .resolve import BatchResolver
|
|
1675
|
+
|
|
1676
|
+
output_dir = os.path.dirname(os.path.abspath(save_path)) or "."
|
|
1677
|
+
resolver = BatchResolver(
|
|
1678
|
+
http, ResolutionCache.for_output_dir(output_dir, verbose), ladder, verbose
|
|
1679
|
+
)
|
|
1680
|
+
return ladder, http, resolver, owns_resolver
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _context(http, resolver, ladder, save_path, verbose, email, delay,
|
|
1684
|
+
use_playwright, download_data_artifacts=False,
|
|
1685
|
+
max_data_artifact_bytes=None, llm_adjudicate_artifacts=False,
|
|
1686
|
+
llm_agent_retrieval=False, llm_backend=None,
|
|
1687
|
+
max_llm_records=0,
|
|
1688
|
+
llm_model=None, download_related_unverified=False):
|
|
1689
|
+
from .context import RetrievalContext
|
|
1690
|
+
|
|
1691
|
+
ctx = RetrievalContext(
|
|
1692
|
+
http=http, resolver=resolver, ladder=ladder, save_path=save_path,
|
|
1693
|
+
verbose=verbose, email=email, delay=delay,
|
|
1694
|
+
use_playwright=use_playwright,
|
|
1695
|
+
download_data_artifacts=download_data_artifacts,
|
|
1696
|
+
max_data_artifact_bytes=max_data_artifact_bytes,
|
|
1697
|
+
download_related_unverified=download_related_unverified,
|
|
1698
|
+
)
|
|
1699
|
+
# Adjudication settings ride on scratch rather than becoming typed fields:
|
|
1700
|
+
# they configure one optional provider, not the retrieval itself, and
|
|
1701
|
+
# RetrievalContext is shared with every source in the ladder.
|
|
1702
|
+
ctx.llm_adjudicate_artifacts = llm_adjudicate_artifacts
|
|
1703
|
+
ctx.llm_agent_retrieval = llm_agent_retrieval
|
|
1704
|
+
ctx.llm_backend = llm_backend
|
|
1705
|
+
ctx.max_llm_records = max_llm_records
|
|
1706
|
+
ctx.llm_model = llm_model
|
|
1707
|
+
return ctx
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def _resolve(resolver, raw_identifier, doi, pmid):
|
|
1711
|
+
from .identifiers import IdentifierSet
|
|
1712
|
+
|
|
1713
|
+
if resolver is None:
|
|
1714
|
+
return IdentifierSet(doi=doi, pmid=pmid)
|
|
1715
|
+
try:
|
|
1716
|
+
return resolver.resolve(raw_identifier, doi=doi, pmid=pmid)
|
|
1717
|
+
except Exception:
|
|
1718
|
+
return IdentifierSet(doi=doi, pmid=pmid)
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _stem_lock(stem: str) -> threading.Lock:
|
|
1722
|
+
key = os.path.abspath(stem)
|
|
1723
|
+
with _STEM_LOCKS_GUARD:
|
|
1724
|
+
lock = _STEM_LOCKS.get(key)
|
|
1725
|
+
if lock is None:
|
|
1726
|
+
lock = threading.Lock()
|
|
1727
|
+
_STEM_LOCKS[key] = lock
|
|
1728
|
+
return lock
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
# -- small helpers ----------------------------------------------------------
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def _head_of(path: str, size: int = 4096) -> bytes:
|
|
1735
|
+
try:
|
|
1736
|
+
with open(path, "rb") as f:
|
|
1737
|
+
return f.read(size)
|
|
1738
|
+
except OSError:
|
|
1739
|
+
return b""
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def _hash_file(path: str) -> Optional[str]:
|
|
1743
|
+
hasher = hashlib.sha256()
|
|
1744
|
+
try:
|
|
1745
|
+
with open(path, "rb") as f:
|
|
1746
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
1747
|
+
hasher.update(chunk)
|
|
1748
|
+
except OSError:
|
|
1749
|
+
return None
|
|
1750
|
+
return hasher.hexdigest()
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def _digest_of(checksum: str) -> str:
|
|
1754
|
+
""""md5:abc" -> "abc". Declared digests are compared to each other only."""
|
|
1755
|
+
return str(checksum).split(":", 1)[-1].strip().lower()
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _iso(timestamp: float) -> str:
|
|
1759
|
+
from datetime import datetime, timezone
|
|
1760
|
+
|
|
1761
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _unlink(path: Optional[str]) -> None:
|
|
1765
|
+
if not path:
|
|
1766
|
+
return
|
|
1767
|
+
try:
|
|
1768
|
+
os.unlink(path)
|
|
1769
|
+
except OSError:
|
|
1770
|
+
pass
|