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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. 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())