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,368 @@
|
|
|
1
|
+
"""Tier gates. HTTP 200 is not evidence of full text.
|
|
2
|
+
|
|
3
|
+
Two failures this exists to catch, both of which look like success:
|
|
4
|
+
|
|
5
|
+
* NCBI efetch db=pmc returns 200 and a well-formed document for non-OA
|
|
6
|
+
records: <pmc-articleset><article> with complete front matter, journal
|
|
7
|
+
metadata, title, authors -- and no <body> at all. The explanation is an XML
|
|
8
|
+
*comment* immediately after the <article> tag:
|
|
9
|
+
|
|
10
|
+
<!--The publisher of this article does not allow downloading of the
|
|
11
|
+
full text in XML form.-->
|
|
12
|
+
|
|
13
|
+
Verified on PMC3390974 and PMC2148499. The comment matters: ElementTree
|
|
14
|
+
discards comments, so a parsed-tree search for that sentence finds nothing
|
|
15
|
+
and the record fails with a puzzling "no <body>" instead of the real reason.
|
|
16
|
+
That is why the denial check runs against the raw bytes.
|
|
17
|
+
|
|
18
|
+
* Elsevier's TDM API returns 200 with a coredata-only payload when the caller
|
|
19
|
+
is not entitled, and publisher landing pages routinely serve paywall
|
|
20
|
+
interstitials and Cloudflare challenges as 200.
|
|
21
|
+
|
|
22
|
+
On failure the caller logs the reason and descends the ladder. Never accept
|
|
23
|
+
silently, never hard-fail the record.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import xml.etree.ElementTree as ET
|
|
28
|
+
from typing import List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from .artifact import ValidationResult
|
|
31
|
+
from ._util import localname as _localname
|
|
32
|
+
|
|
33
|
+
#: Documents that parse but carry no full text. Matched against raw bytes, not
|
|
34
|
+
#: the parsed tree: PMC puts this in an XML comment (see module docstring).
|
|
35
|
+
_DENIAL_SIGNATURES = (
|
|
36
|
+
b"does not allow downloading of the full text",
|
|
37
|
+
b"the publisher of this article does not allow",
|
|
38
|
+
b"full text is not available for this article",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _denial_in(content: bytes) -> bool:
|
|
43
|
+
lowered = content[:8192].lower()
|
|
44
|
+
return any(signature in lowered for signature in _DENIAL_SIGNATURES)
|
|
45
|
+
|
|
46
|
+
#: HTML that is really an access wall or a bot challenge, served as 200.
|
|
47
|
+
#:
|
|
48
|
+
#: The second block was added after a real corpus run: publisher/journal
|
|
49
|
+
#: navigation pages, cookie-consent gates, and a JS-disabled fallback shell all
|
|
50
|
+
#: cleared the original signatures plus the table/char-count floors below (a
|
|
51
|
+
#: nav or metrics table trivially has >=1 populated <td>, and a page full of
|
|
52
|
+
#: menu/policy chrome trivially clears 5000 chars). Each phrase here was pulled
|
|
53
|
+
#: verbatim from a page that fooled the original list: a Journal-of-Neurosurgery
|
|
54
|
+
#: TOC/cookie page, a Dove Medical Press no-JS shell, and a Russian publisher's
|
|
55
|
+
#: profession-verification gate (matched on its own text, not a translation --
|
|
56
|
+
#: translating it would not match the byte content being checked).
|
|
57
|
+
_PAYWALL_SIGNATURES = (
|
|
58
|
+
"just a moment", # Cloudflare interstitial
|
|
59
|
+
"enable javascript and cookies",
|
|
60
|
+
"access denied",
|
|
61
|
+
"purchase pdf",
|
|
62
|
+
"get access to the full version",
|
|
63
|
+
"sign in to continue reading",
|
|
64
|
+
"subscribe to view the full text",
|
|
65
|
+
"your institution does not have access",
|
|
66
|
+
"checking your browser before accessing",
|
|
67
|
+
"this site uses cookies",
|
|
68
|
+
"dismiss this warning",
|
|
69
|
+
"javascript is currently disabled",
|
|
70
|
+
"закрывая это сообщение", # "by closing this message [you confirm...]"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: Hostname/path fragments that mean "repository or aggregator record page",
|
|
74
|
+
#: not "publisher full text" -- checked against the FINAL, post-redirect URL.
|
|
75
|
+
#: An institutional-repository (DSpace/CRIS/IRIS) catalog page routinely shows
|
|
76
|
+
#: the real title and even the abstract, which is exactly why a text-content
|
|
77
|
+
#: check alone does not catch it; the URL is where the identity of the page
|
|
78
|
+
#: actually lives. `iris.` is deliberately a prefix/domain-label fragment
|
|
79
|
+
#: rather than a full hostname, since IRIS is a shared platform (`iris.<inst>.
|
|
80
|
+
#: it`) used by many Italian universities under different domains.
|
|
81
|
+
_REPOSITORY_URL_MARKERS = (
|
|
82
|
+
"iris.",
|
|
83
|
+
"/handle/",
|
|
84
|
+
"dspace",
|
|
85
|
+
"cris.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _looks_like_repository_url(url: Optional[str]) -> Optional[str]:
|
|
90
|
+
"""The matched marker if `url` looks like a repository/catalog page, else None."""
|
|
91
|
+
if not url:
|
|
92
|
+
return None
|
|
93
|
+
lowered = url.lower()
|
|
94
|
+
for marker in _REPOSITORY_URL_MARKERS:
|
|
95
|
+
if marker in lowered:
|
|
96
|
+
return marker
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _meta_content(tree, name: str) -> str:
|
|
101
|
+
"""The `content` attribute of a `<meta name="..."/>` or `<meta property="...">`, or ""."""
|
|
102
|
+
for el in tree.iter("meta"):
|
|
103
|
+
if (el.get("name") or el.get("property") or "").lower() == name.lower():
|
|
104
|
+
return (el.get("content") or "").strip()
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _normalise_doi(doi: str) -> str:
|
|
109
|
+
doi = doi.strip().lower()
|
|
110
|
+
for prefix in ("doi:", "https://doi.org/", "http://doi.org/", "doi.org/"):
|
|
111
|
+
if doi.startswith(prefix):
|
|
112
|
+
doi = doi[len(prefix):]
|
|
113
|
+
return doi
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _citation_doi_mismatch(tree, requested_doi: Optional[str]) -> Optional[str]:
|
|
117
|
+
"""The page's own declared DOI if it contradicts `requested_doi`, else None.
|
|
118
|
+
|
|
119
|
+
Present-and-different is the only signal used here: absence proves nothing
|
|
120
|
+
(most publisher HTML omits these tags), so a missing meta tag must never be
|
|
121
|
+
treated as a mismatch.
|
|
122
|
+
"""
|
|
123
|
+
if not requested_doi:
|
|
124
|
+
return None
|
|
125
|
+
declared = _meta_content(tree, "citation_doi") or _meta_content(tree, "dc.identifier")
|
|
126
|
+
if not declared:
|
|
127
|
+
return None
|
|
128
|
+
if _normalise_doi(declared) == _normalise_doi(requested_doi):
|
|
129
|
+
return None
|
|
130
|
+
return declared
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_HTML_SNIFF_RE = re.compile(rb"^\s*(?:<!doctype\s+html|<html\b)", re.IGNORECASE)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _iterfind(root, *names) -> List[ET.Element]:
|
|
137
|
+
wanted = {n.lower() for n in names}
|
|
138
|
+
return [el for el in root.iter() if _localname(el.tag) in wanted]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _text_of(elements) -> str:
|
|
142
|
+
return " ".join("".join(el.itertext()) for el in elements).strip()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_xml(content: bytes) -> Tuple[Optional[ET.Element], str]:
|
|
146
|
+
"""Parse, returning (root, error). Never raises."""
|
|
147
|
+
if not content:
|
|
148
|
+
return None, "empty response"
|
|
149
|
+
if _HTML_SNIFF_RE.match(content):
|
|
150
|
+
# An HTML error page served where XML was requested. Worth its own
|
|
151
|
+
# message: "not well-formed" would send someone hunting for an encoding
|
|
152
|
+
# bug that is not there.
|
|
153
|
+
return None, "HTML served where XML was expected"
|
|
154
|
+
try:
|
|
155
|
+
return ET.fromstring(content), ""
|
|
156
|
+
except ET.ParseError as e:
|
|
157
|
+
return None, f"not well-formed XML: {str(e)[:120]}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def validate_t1(content: bytes, min_chars: int = 5000,
|
|
161
|
+
abstract_stub_chars: int = 2500) -> ValidationResult:
|
|
162
|
+
"""Gate for JATS/TEI structured full text."""
|
|
163
|
+
# Against the raw bytes, before parsing: PMC states the denial in an XML
|
|
164
|
+
# comment, which every parser drops. Checking the tree would report "no
|
|
165
|
+
# <body>" and send someone looking for a parse bug that is not there.
|
|
166
|
+
if _denial_in(content):
|
|
167
|
+
return ValidationResult.failure(
|
|
168
|
+
"publisher denial stub (200 with no full text)",
|
|
169
|
+
checks_passed=[],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
root, error = parse_xml(content)
|
|
173
|
+
if root is None:
|
|
174
|
+
return ValidationResult.failure(error)
|
|
175
|
+
checks = ["parses", "not-a-denial-stub"]
|
|
176
|
+
|
|
177
|
+
bodies = _iterfind(root, "body")
|
|
178
|
+
if not bodies:
|
|
179
|
+
# TEI puts the running text in <text>, not <body>.
|
|
180
|
+
bodies = _iterfind(root, "text")
|
|
181
|
+
if not bodies:
|
|
182
|
+
return ValidationResult.failure("no <body> or TEI <text> element", checks_passed=checks)
|
|
183
|
+
|
|
184
|
+
body_text = _text_of(bodies)
|
|
185
|
+
n_chars = len(body_text)
|
|
186
|
+
if not body_text:
|
|
187
|
+
return ValidationResult.failure("<body> present but empty", checks_passed=checks)
|
|
188
|
+
checks.append("body-populated")
|
|
189
|
+
|
|
190
|
+
# <table-wrap> wraps a <table>; counting both would double every JATS table.
|
|
191
|
+
n_tables = len(_iterfind(root, "table-wrap")) or len(_iterfind(root, "table"))
|
|
192
|
+
n_footnotes = len(_iterfind(root, "table-wrap-foot", "fn", "note"))
|
|
193
|
+
n_cells = len([c for c in _iterfind(root, "td") if "".join(c.itertext()).strip()])
|
|
194
|
+
|
|
195
|
+
if n_chars < abstract_stub_chars:
|
|
196
|
+
return ValidationResult.failure(
|
|
197
|
+
f"abstract-length stub ({n_chars} chars < {abstract_stub_chars})",
|
|
198
|
+
checks_passed=checks,
|
|
199
|
+
n_chars=n_chars,
|
|
200
|
+
n_tables=n_tables,
|
|
201
|
+
n_footnotes=n_footnotes,
|
|
202
|
+
)
|
|
203
|
+
if n_chars < min_chars:
|
|
204
|
+
return ValidationResult.failure(
|
|
205
|
+
f"below full-text threshold ({n_chars} chars < {min_chars})",
|
|
206
|
+
checks_passed=checks,
|
|
207
|
+
n_chars=n_chars,
|
|
208
|
+
n_tables=n_tables,
|
|
209
|
+
n_footnotes=n_footnotes,
|
|
210
|
+
)
|
|
211
|
+
checks.append(f"min-chars>={min_chars}")
|
|
212
|
+
|
|
213
|
+
return ValidationResult(
|
|
214
|
+
ok=True,
|
|
215
|
+
reason="",
|
|
216
|
+
checks_passed=checks,
|
|
217
|
+
n_chars=n_chars,
|
|
218
|
+
n_tables=n_tables,
|
|
219
|
+
n_footnotes=n_footnotes,
|
|
220
|
+
n_populated_cells=n_cells,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def validate_t2(content: bytes, min_chars: int = 5000,
|
|
225
|
+
abstract_stub_chars: int = 2500,
|
|
226
|
+
min_populated_td: int = 1,
|
|
227
|
+
url: Optional[str] = None,
|
|
228
|
+
doi: Optional[str] = None) -> ValidationResult:
|
|
229
|
+
"""Gate for publisher HTML full text.
|
|
230
|
+
|
|
231
|
+
The load-bearing check is populated <td> count, not table count. A static
|
|
232
|
+
fetch of a JS-rendered article returns the table *container* with 200 and no
|
|
233
|
+
cells; tables rendered as <img> likewise have zero <td>. Both must fail here
|
|
234
|
+
rather than being handed to a model as if they held data.
|
|
235
|
+
|
|
236
|
+
`url` and `doi` are optional because most callers of this module in tests
|
|
237
|
+
and one-off scripts do not have them handy -- every check that uses them
|
|
238
|
+
degrades to "not checked" rather than failing closed when they are absent.
|
|
239
|
+
"""
|
|
240
|
+
if not content:
|
|
241
|
+
return ValidationResult.failure("empty response")
|
|
242
|
+
|
|
243
|
+
text = content.decode("utf-8", errors="replace")
|
|
244
|
+
lowered = text.lower()
|
|
245
|
+
for signature in _PAYWALL_SIGNATURES:
|
|
246
|
+
if signature in lowered:
|
|
247
|
+
return ValidationResult.failure(f"paywall/bot-challenge signature: {signature!r}")
|
|
248
|
+
checks = ["no-paywall-signature"]
|
|
249
|
+
|
|
250
|
+
repo_marker = _looks_like_repository_url(url)
|
|
251
|
+
if repo_marker:
|
|
252
|
+
return ValidationResult.failure(
|
|
253
|
+
f"URL looks like a repository/catalog page (matched {repo_marker!r}), "
|
|
254
|
+
"not publisher full text",
|
|
255
|
+
checks_passed=checks,
|
|
256
|
+
)
|
|
257
|
+
checks.append("not-a-repository-url")
|
|
258
|
+
|
|
259
|
+
tree = _parse_html(text)
|
|
260
|
+
if tree is None:
|
|
261
|
+
return ValidationResult.failure(
|
|
262
|
+
"lxml not installed; install fetchpdf[html] for T2 HTML support",
|
|
263
|
+
checks_passed=checks,
|
|
264
|
+
)
|
|
265
|
+
checks.append("parses")
|
|
266
|
+
|
|
267
|
+
declared_doi = _citation_doi_mismatch(tree, doi)
|
|
268
|
+
if declared_doi:
|
|
269
|
+
return ValidationResult.failure(
|
|
270
|
+
f"page declares a different article (citation_doi/dc.identifier="
|
|
271
|
+
f"{declared_doi!r}, requested {doi!r})",
|
|
272
|
+
checks_passed=checks,
|
|
273
|
+
)
|
|
274
|
+
checks.append("citation-doi-consistent")
|
|
275
|
+
|
|
276
|
+
body_text = " ".join(tree.itertext()) if hasattr(tree, "itertext") else text
|
|
277
|
+
body_text = re.sub(r"\s+", " ", body_text).strip()
|
|
278
|
+
n_chars = len(body_text)
|
|
279
|
+
|
|
280
|
+
tables = tree.findall(".//table")
|
|
281
|
+
n_tables = len(tables)
|
|
282
|
+
populated = 0
|
|
283
|
+
for table in tables:
|
|
284
|
+
for cell in table.iter():
|
|
285
|
+
if _localname(cell.tag) in ("td", "th") and "".join(cell.itertext()).strip():
|
|
286
|
+
populated += 1
|
|
287
|
+
|
|
288
|
+
if n_tables == 0:
|
|
289
|
+
return ValidationResult.failure(
|
|
290
|
+
"no <table> elements", checks_passed=checks, n_chars=n_chars
|
|
291
|
+
)
|
|
292
|
+
checks.append("has-table")
|
|
293
|
+
|
|
294
|
+
if populated < min_populated_td:
|
|
295
|
+
return ValidationResult.failure(
|
|
296
|
+
"table container with no populated cells "
|
|
297
|
+
"(JS-rendered, separate table viewer, or tables as images)",
|
|
298
|
+
checks_passed=checks,
|
|
299
|
+
n_chars=n_chars,
|
|
300
|
+
n_tables=n_tables,
|
|
301
|
+
)
|
|
302
|
+
checks.append(f"populated-cells>={min_populated_td}")
|
|
303
|
+
|
|
304
|
+
if n_chars < abstract_stub_chars:
|
|
305
|
+
return ValidationResult.failure(
|
|
306
|
+
f"abstract-length stub ({n_chars} chars)",
|
|
307
|
+
checks_passed=checks,
|
|
308
|
+
n_chars=n_chars,
|
|
309
|
+
n_tables=n_tables,
|
|
310
|
+
n_populated_cells=populated,
|
|
311
|
+
)
|
|
312
|
+
if n_chars < min_chars:
|
|
313
|
+
return ValidationResult.failure(
|
|
314
|
+
f"below full-text threshold ({n_chars} chars < {min_chars})",
|
|
315
|
+
checks_passed=checks,
|
|
316
|
+
n_chars=n_chars,
|
|
317
|
+
n_tables=n_tables,
|
|
318
|
+
n_populated_cells=populated,
|
|
319
|
+
)
|
|
320
|
+
checks.append(f"min-chars>={min_chars}")
|
|
321
|
+
|
|
322
|
+
return ValidationResult(
|
|
323
|
+
ok=True,
|
|
324
|
+
checks_passed=checks,
|
|
325
|
+
n_chars=n_chars,
|
|
326
|
+
n_tables=n_tables,
|
|
327
|
+
n_populated_cells=populated,
|
|
328
|
+
n_footnotes=len([e for e in tree.iter() if _localname(e.tag) == "tfoot"]),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def has_empty_table_containers(content: bytes) -> bool:
|
|
333
|
+
"""True when HTML has <table> elements but no populated cells.
|
|
334
|
+
|
|
335
|
+
The one condition under which a headless-browser retry is worth its cost:
|
|
336
|
+
the page really does have a table, it just has not been filled in yet.
|
|
337
|
+
"""
|
|
338
|
+
tree = _parse_html(content.decode("utf-8", errors="replace") if content else "")
|
|
339
|
+
if tree is None:
|
|
340
|
+
return False
|
|
341
|
+
tables = tree.findall(".//table")
|
|
342
|
+
if not tables:
|
|
343
|
+
return False
|
|
344
|
+
for table in tables:
|
|
345
|
+
for cell in table.iter():
|
|
346
|
+
if _localname(cell.tag) in ("td", "th") and "".join(cell.itertext()).strip():
|
|
347
|
+
return False
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _parse_html(text: str):
|
|
352
|
+
"""Parse HTML with lxml if available, else return None.
|
|
353
|
+
|
|
354
|
+
lxml is an optional extra (`pip install 'fetchpdf[html]'`). Real publisher
|
|
355
|
+
HTML is malformed often enough that stdlib html.parser produces a tree that
|
|
356
|
+
silently loses table rows, which is worse than not parsing at all -- so the
|
|
357
|
+
T2 source demotes rather than guessing.
|
|
358
|
+
"""
|
|
359
|
+
if not text:
|
|
360
|
+
return None
|
|
361
|
+
try:
|
|
362
|
+
import lxml.html
|
|
363
|
+
except ImportError:
|
|
364
|
+
return None
|
|
365
|
+
try:
|
|
366
|
+
return lxml.html.fromstring(text)
|
|
367
|
+
except Exception:
|
|
368
|
+
return None
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""fetchpdf-verify: does the corpus on disk still match its manifests?
|
|
2
|
+
|
|
3
|
+
A corpus can be complete by every count fetchpdf keeps -- every record fetched,
|
|
4
|
+
every manifest written, every declared file obtained -- and still be hollow. A
|
|
5
|
+
real one held 30 git-lfs pointer stubs where its supplementary spreadsheets
|
|
6
|
+
should have been, each 130 bytes standing in for tens of KB. Every pointer's
|
|
7
|
+
oid matched the sha256 the manifest recorded, which is the proof the bytes were
|
|
8
|
+
downloaded and hashed correctly and something afterward replaced them.
|
|
9
|
+
|
|
10
|
+
Nothing noticed, because the manifest's integrity metadata was only ever
|
|
11
|
+
written, never read back. This command reads it back. It makes no network
|
|
12
|
+
requests: it answers "is what I have what I fetched", not "is there more to
|
|
13
|
+
fetch".
|
|
14
|
+
|
|
15
|
+
The verdicts come from the same _integrity_of predicate the repair pass uses,
|
|
16
|
+
so an audit that says a file is damaged and a re-run that restores it can never
|
|
17
|
+
disagree about which files those are.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import glob
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from typing import List, Tuple
|
|
27
|
+
|
|
28
|
+
from .supplementary import (
|
|
29
|
+
ABSENT,
|
|
30
|
+
DATA_ARTIFACT_INFIX,
|
|
31
|
+
HASH_MISMATCH,
|
|
32
|
+
INTACT,
|
|
33
|
+
LFS_POINTER,
|
|
34
|
+
MANIFEST_SUFFIX,
|
|
35
|
+
SIZE_MISMATCH,
|
|
36
|
+
UNVERIFIABLE,
|
|
37
|
+
_integrity_of,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Ordered worst-first, because that is the order an operator needs to act in.
|
|
41
|
+
_REPORT_ORDER = (LFS_POINTER, HASH_MISMATCH, SIZE_MISMATCH, ABSENT, UNVERIFIABLE)
|
|
42
|
+
|
|
43
|
+
#: What to actually DO about each verdict. A stub and a truncated download both
|
|
44
|
+
#: read as "corrupt", but re-running fetchpdf fixes only one of them -- the
|
|
45
|
+
#: other returns on the next sync unless the storage is fixed.
|
|
46
|
+
_ADVICE = {
|
|
47
|
+
LFS_POINTER: ("git-lfs pointer, content never smudged -- fix the checkout "
|
|
48
|
+
"(git lfs pull) or the sync; a re-fetch works but the next "
|
|
49
|
+
"sync will stub it again"),
|
|
50
|
+
HASH_MISMATCH: "content differs from what was fetched -- re-run with --refresh-supplementary",
|
|
51
|
+
SIZE_MISMATCH: "truncated or overwritten -- re-run with --refresh-supplementary",
|
|
52
|
+
ABSENT: "file listed in the manifest is gone -- re-run with --refresh-supplementary",
|
|
53
|
+
UNVERIFIABLE: ("manifest predates integrity metadata; nothing to check "
|
|
54
|
+
"against (not a fault)"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def audit_manifest(manifest_path: str, verify_hashes: bool = True
|
|
59
|
+
) -> List[Tuple[str, str]]:
|
|
60
|
+
"""Verdicts for one manifest, as (relative path, verdict) pairs."""
|
|
61
|
+
try:
|
|
62
|
+
with open(manifest_path, "r", encoding="utf-8") as handle:
|
|
63
|
+
manifest = json.load(handle)
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
return [(manifest_path, "manifest_unreadable")]
|
|
66
|
+
|
|
67
|
+
directory = os.path.dirname(os.path.abspath(manifest_path)) or "."
|
|
68
|
+
# Supplementary files are flat siblings of the manifest; linked-deposit
|
|
69
|
+
# files keep their ORIGINAL names under <stem>_data_artifacts/ and record a
|
|
70
|
+
# filename relative to THAT. Resolving both against the manifest's own
|
|
71
|
+
# directory reports every deposit file as absent when all of them are there.
|
|
72
|
+
stem = os.path.basename(manifest_path[:-len(MANIFEST_SUFFIX)]) \
|
|
73
|
+
if manifest_path.endswith(MANIFEST_SUFFIX) else ""
|
|
74
|
+
artifact_root = os.path.join(directory, stem + DATA_ARTIFACT_INFIX)
|
|
75
|
+
|
|
76
|
+
results = []
|
|
77
|
+
for record in manifest.get("files") or []:
|
|
78
|
+
# No file by design -- its members are already unpacked beside it.
|
|
79
|
+
if record.get("removed_after_extraction"):
|
|
80
|
+
continue
|
|
81
|
+
filename = record.get("filename") or ""
|
|
82
|
+
if not filename:
|
|
83
|
+
continue
|
|
84
|
+
path = os.path.join(directory, filename)
|
|
85
|
+
if not os.path.isfile(path):
|
|
86
|
+
candidate = os.path.join(artifact_root, filename)
|
|
87
|
+
if os.path.isfile(candidate):
|
|
88
|
+
path = candidate
|
|
89
|
+
results.append((path, _integrity_of(path, record, verify_hashes)))
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def audit_tree(root: str, verify_hashes: bool = True) -> List[Tuple[str, str]]:
|
|
94
|
+
"""Every manifest under root, recursively."""
|
|
95
|
+
pattern = os.path.join(root, "**", "*" + MANIFEST_SUFFIX)
|
|
96
|
+
results = []
|
|
97
|
+
for manifest_path in sorted(glob.glob(pattern, recursive=True)):
|
|
98
|
+
results.extend(audit_manifest(manifest_path, verify_hashes))
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv=None) -> int:
|
|
103
|
+
parser = argparse.ArgumentParser(
|
|
104
|
+
prog="fetchpdf-verify",
|
|
105
|
+
description="Check supplementary files on disk against their manifests. "
|
|
106
|
+
"Makes no network requests.")
|
|
107
|
+
parser.add_argument("directory", help="Corpus root (searched recursively)")
|
|
108
|
+
parser.add_argument("--no-verify-hashes", action="store_true",
|
|
109
|
+
help="Compare sizes only. Faster on a large corpus, and "
|
|
110
|
+
"still catches every stub and truncation; misses "
|
|
111
|
+
"same-length corruption.")
|
|
112
|
+
parser.add_argument("--quiet", "-q", action="store_true",
|
|
113
|
+
help="Totals only, no per-file lines.")
|
|
114
|
+
args = parser.parse_args(argv)
|
|
115
|
+
|
|
116
|
+
if not os.path.isdir(args.directory):
|
|
117
|
+
print(f"not a directory: {args.directory}", file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
|
|
120
|
+
results = audit_tree(args.directory, not args.no_verify_hashes)
|
|
121
|
+
if not results:
|
|
122
|
+
print(f"No supplementary manifests found under {args.directory}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
tally = Counter(verdict for _, verdict in results)
|
|
126
|
+
damaged = [(p, v) for p, v in results if v not in (INTACT, UNVERIFIABLE)]
|
|
127
|
+
|
|
128
|
+
if damaged and not args.quiet:
|
|
129
|
+
for verdict in _REPORT_ORDER:
|
|
130
|
+
hits = [p for p, v in damaged if v == verdict]
|
|
131
|
+
if not hits:
|
|
132
|
+
continue
|
|
133
|
+
print(f"\n{verdict.upper()} ({len(hits)})")
|
|
134
|
+
print(f" → {_ADVICE.get(verdict, '')}")
|
|
135
|
+
for path in hits:
|
|
136
|
+
print(f" {os.path.relpath(path, args.directory)}")
|
|
137
|
+
|
|
138
|
+
print(f"\n{len(results)} file(s) checked under {args.directory}")
|
|
139
|
+
for verdict in (INTACT,) + _REPORT_ORDER:
|
|
140
|
+
if tally.get(verdict):
|
|
141
|
+
print(f" {verdict:<15} {tally[verdict]}")
|
|
142
|
+
|
|
143
|
+
# Exit non-zero on real damage so this can gate an analysis run in CI or a
|
|
144
|
+
# shell chain. UNVERIFIABLE is not damage: it is an older manifest.
|
|
145
|
+
return 1 if damaged else 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main())
|