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,666 @@
1
+ """The tiered retrieval loop: format tiers outer, sources inner.
2
+
3
+ This is the semantic core. The existing chain iterates sources and takes the
4
+ first artifact each one yields, which means the best-ranked source's PDF beats
5
+ the worst-ranked source's JATS. Here a tier is exhausted across every applicable
6
+ source before the walk descends, so structure wins over source ranking -- which
7
+ is the whole point, because a table that survives as markup is worth more than
8
+ one that has to be reconstructed no matter who served it.
9
+
10
+ Three rules hold throughout:
11
+
12
+ * A source's declared tier is a hint. The tier that counts is the one the
13
+ classifier reads off the bytes.
14
+ * Validation failure demotes and continues. It never accepts silently and
15
+ never hard-fails the record.
16
+ * Nothing is written until an artifact has been classified and validated, so a
17
+ rejected candidate leaves nothing on disk.
18
+ """
19
+
20
+ import os
21
+ import re
22
+ import tempfile
23
+ from typing import List, Optional, Tuple
24
+
25
+ from . import classify as classifier
26
+ from .artifact import Artifact, ValidationResult
27
+ from .cache import ResolutionCache
28
+ from .context import RetrievalContext
29
+ from . import goals
30
+ from .http import HttpClient
31
+ from .provenance import ProvenanceRecord, write_sidecar
32
+ from .ratelimit import HostRateLimiter, shared_host_limiter
33
+ from .resolve import BatchResolver
34
+ from .tiers import ARTIFACT_EXTENSIONS, Tier, TIER_EXTENSIONS, load_ladder
35
+ from .validate import validate_t1, validate_t2
36
+
37
+
38
+ class RetrievalResult:
39
+ """What the engine hands back to fetch_pdf.
40
+
41
+ `path` and `artifact` stay singular and keep meaning "the best one": batch
42
+ counting, the format tally and the missing-PDF report all read them, and
43
+ changing what they mean would quietly alter every report. Multi-artifact walks
44
+ expose the rest through `paths` / `artifacts` / `goals`.
45
+ """
46
+
47
+ def __init__(self, path=None, artifact=None, provenance=None, reason="",
48
+ goals=None):
49
+ self.path = path
50
+ self.artifact = artifact
51
+ self.provenance = provenance
52
+ self.reason = reason
53
+ self.goals = goals
54
+
55
+ def __bool__(self):
56
+ return self.path is not None
57
+
58
+ @property
59
+ def paths(self) -> List[str]:
60
+ """Every artifact written or already present, best tier first."""
61
+ if self.goals is None:
62
+ return [self.path] if self.path else []
63
+ return self.goals.filled_paths()
64
+
65
+ @property
66
+ def artifacts(self) -> List[Artifact]:
67
+ if self.goals is None:
68
+ return [self.artifact] if self.artifact is not None else []
69
+ return [g.artifact for g in self.goals if g.artifact is not None]
70
+
71
+ def path_for(self, goal_name: str) -> Optional[str]:
72
+ """The artifact filling a named goal, e.g. 'structured' or 'pdf'."""
73
+ if self.goals is None:
74
+ return None
75
+ goal = self.goals.by_name(goal_name)
76
+ return goal.path if goal is not None else None
77
+
78
+ @property
79
+ def structured_path(self) -> Optional[str]:
80
+ return self.path_for("structured")
81
+
82
+ @property
83
+ def pdf_path(self) -> Optional[str]:
84
+ return self.path_for("pdf")
85
+
86
+ @property
87
+ def summary(self) -> str:
88
+ """'xml+pdf', 'pdf (structured: none)' -- for the per-record log line."""
89
+ return self.goals.summary() if self.goals is not None else ""
90
+
91
+
92
+
93
+ def retrieve_tiered(
94
+ raw_identifier,
95
+ doi=None,
96
+ pmid=None,
97
+ save_path=None,
98
+ target_task="extraction",
99
+ xml_only=False,
100
+ xml_html_only=False,
101
+ get_xml_or_html=False,
102
+ upgrade_existing=False,
103
+ want_provenance=False,
104
+ email=None,
105
+ verbose=False,
106
+ delay=0.1,
107
+ use_playwright=False,
108
+ resolver=None,
109
+ ladder=None,
110
+ ) -> RetrievalResult:
111
+ """Walk the ladder for one record. Returns the written path, or a reason."""
112
+ ladder = ladder or load_ladder()
113
+ output_dir = os.path.dirname(os.path.abspath(save_path)) or "."
114
+
115
+ owns_resolver = resolver is None
116
+ if owns_resolver:
117
+ # Single-record mode: a resolver of its own, so the CLI path works
118
+ # without a batch priming it. Batch mode passes a primed one in.
119
+ limiter = shared_host_limiter(ladder.rate_limits)
120
+ http = HttpClient(limiter, email=email, verbose=verbose)
121
+ resolver = BatchResolver(
122
+ http, ResolutionCache.for_output_dir(output_dir, verbose), ladder, verbose
123
+ )
124
+ http = resolver.http
125
+
126
+ ctx = RetrievalContext(
127
+ http=http,
128
+ resolver=resolver,
129
+ ladder=ladder,
130
+ save_path=save_path,
131
+ target_task=target_task,
132
+ verbose=verbose,
133
+ use_playwright=use_playwright,
134
+ email=email,
135
+ delay=delay,
136
+ )
137
+
138
+ provenance = ProvenanceRecord(
139
+ identifier=str(doi or raw_identifier),
140
+ target_task=target_task,
141
+ flags={
142
+ "prioritize_xml": True,
143
+ "xml_only": xml_only,
144
+ "upgrade_existing": upgrade_existing,
145
+ },
146
+ ) if want_provenance else None
147
+ ctx.provenance = provenance
148
+
149
+ # -- phase 1: resolve ---------------------------------------------------
150
+ ids = resolver.resolve(raw_identifier, doi=doi, pmid=pmid)
151
+ ctx.scratch["ids"] = ids
152
+ if provenance is not None:
153
+ provenance.identifiers = ids.to_dict()
154
+
155
+ # -- already on disk? ---------------------------------------------------
156
+ stem = _stem_for(save_path)
157
+ existing_path, existing_tier = _existing_artifact(stem)
158
+
159
+ # Restricting the walk is subtraction from the configured ladder, not a
160
+ # separate ladder: the source ordering within each rung, and which rungs
161
+ # exist at all, stay whatever the config says.
162
+ tiers = ladder.for_task(target_task)
163
+ restriction = None
164
+ keep = ()
165
+ if xml_only:
166
+ keep, restriction = (Tier.T1_XML,), "--xml-only"
167
+ elif xml_html_only:
168
+ keep, restriction = (Tier.T1_XML, Tier.T2_HTML), "--xml-html-only"
169
+ if restriction:
170
+ tiers = [t for t in tiers if t in keep]
171
+
172
+ # What this walk is collecting. The default is a single "best" goal that any
173
+ # tier fills, which reduces exactly to stop-at-first-hit -- one code path, not
174
+ # a fork.
175
+ goalset = goals.build(tiers, get_xml_or_html=get_xml_or_html)
176
+
177
+ # Pre-filling from disk is how a goal gets skipped, which is exactly wrong
178
+ # under --upgrade-existing: that flag means "look for something better than
179
+ # what is here". Pre-filling would leave no unfilled goals, wanted_tiers()
180
+ # would come back empty, and the walk would skip every tier and upgrade
181
+ # nothing. The rank check inside the loop is what stops the walk descending
182
+ # below what is already on disk.
183
+ if not upgrade_existing:
184
+ _fill_goals_from_disk(goalset, stem, ctx)
185
+
186
+ if goalset.complete and not upgrade_existing:
187
+ found = ", ".join(os.path.basename(p) for p in goalset.filled_paths())
188
+ ctx.log(f"✅ Skipping {ids.best_id} - already exists: {found}")
189
+ return RetrievalResult(
190
+ path=goalset.filled_paths()[0], reason="already exists", goals=goalset,
191
+ )
192
+
193
+ # -- phase 2: retrieve --------------------------------------------------
194
+ stage3_done = False
195
+ for tier in tiers:
196
+ if goalset.complete:
197
+ break
198
+ if tier not in goalset.wanted_tiers():
199
+ # No unfilled goal accepts this tier. Under --get-xml-or-html that
200
+ # skips T3/T4/T7 outright: a LaTeX tarball or a landing page fills
201
+ # neither the structured nor the PDF slot, so trying them would only
202
+ # cost time.
203
+ continue
204
+ if (
205
+ upgrade_existing
206
+ and existing_tier is not None
207
+ and _rank_of(tier, tiers) >= _rank_of(existing_tier, tiers)
208
+ ):
209
+ # Everything from here down is no better than what is already on
210
+ # disk, so there is nothing left worth fetching.
211
+ ctx.log(
212
+ f" stopping at {tier.name}: not better than existing "
213
+ f"{existing_tier.name} artifact"
214
+ )
215
+ break
216
+
217
+ for spec in ladder.sources_for(tier):
218
+ if not spec.applicable(ids):
219
+ if not stage3_done:
220
+ # Conditional resolution, run at most once and only when a
221
+ # source actually wants an identifier we do not have. A
222
+ # record that short-circuits at T1 never reaches this.
223
+ ctx.log(" running conditional resolution (stage 3)")
224
+ resolver.resolve_conditional(ids)
225
+ stage3_done = True
226
+ if provenance is not None:
227
+ provenance.identifiers = ids.to_dict()
228
+ if not spec.applicable(ids):
229
+ continue
230
+
231
+ artifact = _run_source(spec, ids, ctx)
232
+ if artifact is None:
233
+ continue
234
+
235
+ # Guarded for the same reason _run_source is. A validator is just
236
+ # code, and a crash in one used to escape the record, escape
237
+ # download_one, and take down the whole batch at future.result() --
238
+ # thousands of records lost to one malformed page. "Demote and
239
+ # continue" has to survive our own bugs, not just the network's.
240
+ try:
241
+ accepted, validation = _judge(artifact, tier, ctx, tiers, goalset, ids=ids)
242
+ except Exception as e:
243
+ accepted = False
244
+ validation = ValidationResult.failure(
245
+ f"validation raised: {type(e).__name__}: {str(e)[:120]}"
246
+ )
247
+ ctx.log(f" ✗ {spec.name}: {validation.reason}")
248
+ if provenance is not None:
249
+ provenance.attempt(
250
+ source=spec.name,
251
+ tier_attempted=tier.name,
252
+ url=artifact.url,
253
+ http_status=artifact.http_status,
254
+ served_content_type=artifact.served_content_type,
255
+ accepted=accepted,
256
+ outcome="accepted" if accepted else validation.reason,
257
+ checks_passed=list(validation.checks_passed),
258
+ )
259
+ if not accepted:
260
+ ctx.log(f" ✗ {spec.name}: {validation.reason}")
261
+ continue
262
+
263
+ goal = goalset.goal_for(artifact.tier)
264
+ path = _accept(artifact, stem, ctx, provenance)
265
+ goal.fill(path, artifact)
266
+ ctx.log(
267
+ f" ✓ {spec.name} -> {artifact.tier.name} "
268
+ f"{os.path.basename(path)} [{goal.name}]"
269
+ )
270
+ if existing_path and existing_path != path:
271
+ # The superseded file is left in place: deleting a PDF someone
272
+ # may already have annotated or indexed is not this tool's call.
273
+ # The sidecar records which artifact is authoritative.
274
+ ctx.log(f" superseded {os.path.basename(existing_path)} (left on disk)")
275
+ if provenance is not None:
276
+ provenance.note(
277
+ f"supersedes {os.path.basename(existing_path)} "
278
+ f"({existing_tier.name}); superseded file left on disk"
279
+ )
280
+ # This goal is filled, so no other source at this tier can help it.
281
+ # Move to the next tier, where a different goal may still be open.
282
+ break
283
+
284
+ if goalset.complete:
285
+ break
286
+
287
+ if goalset.any_filled:
288
+ wrote_something = any(g.filled and not g.preexisting for g in goalset)
289
+ if wrote_something:
290
+ _write_sidecar_once(provenance, goalset, stem, ctx)
291
+ _flush(resolver, owns_resolver)
292
+ best = goalset.filled_paths()[0]
293
+ best_artifact = next((g.artifact for g in goalset if g.artifact is not None), None)
294
+ unfilled = [g.name for g in goalset.unfilled()]
295
+
296
+ if not wrote_something:
297
+ # Every filled goal was already on disk and nothing better turned up.
298
+ # Under --upgrade-existing that is the expected outcome for most
299
+ # records, and calling it a failure would fill failed_dois.csv with
300
+ # records that are perfectly fine.
301
+ reason = "kept existing {}".format(
302
+ existing_tier.name if existing_tier else "artifact"
303
+ )
304
+ ctx.log(" no better tier than the existing artifact; keeping it")
305
+ elif unfilled:
306
+ reason = "unfilled: " + ", ".join(unfilled)
307
+ else:
308
+ reason = ""
309
+
310
+ return RetrievalResult(
311
+ path=best,
312
+ artifact=best_artifact,
313
+ provenance=provenance,
314
+ goals=goalset,
315
+ reason=reason,
316
+ )
317
+
318
+ _write_provenance_only(provenance, stem, ctx)
319
+ _flush(resolver, owns_resolver)
320
+
321
+ if restriction:
322
+ reason = "{}: no validated {} artifact".format(
323
+ restriction,
324
+ "/".join(t.name for t in tiers),
325
+ )
326
+ ctx.log(f" {reason}")
327
+ if not existing_path:
328
+ return RetrievalResult(reason=reason, provenance=provenance)
329
+
330
+ if existing_path:
331
+ # Under --upgrade-existing, "nothing better was available" is the
332
+ # expected outcome for most records. Reporting it as a failure would
333
+ # fill failed_dois.csv with records that are perfectly fine.
334
+ ctx.log(f" no better tier than the existing {existing_tier.name}; keeping it")
335
+ return RetrievalResult(path=existing_path,
336
+ reason=f"kept existing {existing_tier.name}")
337
+
338
+ return RetrievalResult(reason="no tier produced a validated artifact",
339
+ provenance=provenance)
340
+
341
+
342
+ # -- the three rules --------------------------------------------------------
343
+
344
+
345
+ def _run_source(spec, ids, ctx) -> Optional[Artifact]:
346
+ """Call a source. A source that raises demotes; it never kills the record."""
347
+ try:
348
+ return spec.resolve()(ids, ctx)
349
+ except Exception as e:
350
+ ctx.log(f" ✗ {spec.name} raised: {str(e)[:150]}")
351
+ if ctx.provenance is not None:
352
+ ctx.provenance.attempt(
353
+ source=spec.name,
354
+ tier_attempted="unknown",
355
+ accepted=False,
356
+ outcome=f"source raised: {str(e)[:150]}",
357
+ )
358
+ return None
359
+
360
+
361
+ def _judge(artifact: Artifact, expected: Tier, ctx, order: List[Tier],
362
+ goalset=None, ids=None) -> Tuple[bool, ValidationResult]:
363
+ """Classify by content, then validate against the tier the content is.
364
+
365
+ "Better" means earlier in the active ladder, not a lower Tier number. The
366
+ two differ: screening ranks plain text above PDF, extraction excludes it
367
+ entirely. Comparing Tier ints instead would hardcode the extraction ordering
368
+ into the accept rule and silently discard a perfectly good screening
369
+ artifact.
370
+ """
371
+ actual = classifier.classify(artifact.content, declared=artifact.tier)
372
+ if actual != artifact.tier:
373
+ artifact.declared_tier = artifact.tier
374
+ artifact.tier = actual
375
+ ctx.log(
376
+ f" reclassified {artifact.source}: declared "
377
+ f"{artifact.declared_tier.name}, content is {actual.name} "
378
+ f"({classifier.describe(artifact.content)})"
379
+ )
380
+
381
+ # Not in this ladder at all: for extraction that is how plain text gets
382
+ # refused outright rather than ranked last and degraded into.
383
+ if artifact.tier not in order:
384
+ return False, ValidationResult.failure(
385
+ f"{artifact.tier.name} is not in the {ctx.target_task} ladder"
386
+ )
387
+
388
+ # A source that promised T1 and delivered T5 must not be accepted at this
389
+ # rung -- otherwise the ladder's ordering means nothing. It is dropped here
390
+ # and the rung that does match picks it up on its own pass.
391
+ #
392
+ # Better-than-expected is the opposite case and must be kept. The legacy
393
+ # chain at T5 has its own Elsevier XML fallback, so it sometimes hands back
394
+ # a T1 document; every rung above has already been tried and failed, so
395
+ # finding structured full text late is a windfall, not a violation. Dropping
396
+ # it is how one record with usable full text ended up saved as a landing page.
397
+ if order.index(artifact.tier) > order.index(expected):
398
+ return False, ValidationResult.failure(
399
+ f"content is {artifact.tier.name}, not {expected.name}"
400
+ )
401
+
402
+ # Nothing left to put it in. Under --get-xml-or-html this is what stops a
403
+ # second XML artifact overwriting the first one, and it is recorded as a
404
+ # demotion reason rather than a silent drop.
405
+ if goalset is not None and goalset.goal_for(artifact.tier) is None:
406
+ return False, ValidationResult.failure(
407
+ f"no unfilled goal accepts {artifact.tier.name}"
408
+ )
409
+
410
+ validation = _validate_for_tier(artifact, ctx, ids)
411
+ artifact.validation = validation
412
+ return validation.ok, validation
413
+
414
+
415
+ def _validate_for_tier(artifact: Artifact, ctx, ids=None) -> ValidationResult:
416
+ tier = artifact.tier
417
+ if tier == Tier.T1_XML:
418
+ return validate_t1(
419
+ artifact.content,
420
+ min_chars=ctx.threshold("t1_min_chars", 5000),
421
+ abstract_stub_chars=ctx.threshold("abstract_stub_chars", 2500),
422
+ )
423
+ if tier == Tier.T2_HTML:
424
+ return validate_t2(
425
+ artifact.content,
426
+ min_chars=ctx.threshold("t2_min_chars", 5000),
427
+ abstract_stub_chars=ctx.threshold("abstract_stub_chars", 2500),
428
+ min_populated_td=ctx.threshold("min_populated_td", 1),
429
+ url=artifact.url,
430
+ doi=(ids.doi if ids is not None else None),
431
+ )
432
+ if tier == Tier.T3_SOURCE:
433
+ if len(artifact.content) < 512:
434
+ return ValidationResult.failure("source archive implausibly small")
435
+ return ValidationResult(ok=True, checks_passed=["archive-magic", "min-size"])
436
+ if tier == Tier.T4_SUPPLEMENT:
437
+ if len(artifact.content) < 64:
438
+ return ValidationResult.failure("supplement implausibly small")
439
+ return ValidationResult(ok=True, checks_passed=["structured-file", "min-size"])
440
+ if tier == Tier.T5_PDF:
441
+ if not artifact.content.startswith(b"%PDF"):
442
+ return ValidationResult.failure("not a PDF despite T5 classification")
443
+ if len(artifact.content) < 1024:
444
+ return ValidationResult.failure("PDF implausibly small")
445
+ # "Is it a PDF" was the whole gate here, and it is a file-type check
446
+ # wearing an identity check's clothes. T2 has refused a page that
447
+ # declares a different article since _citation_doi_mismatch; a PDF got
448
+ # no such question, so a document from the paper's own reference list
449
+ # passed as the paper. See retrieval/pdf_identity.
450
+ from .pdf_identity import (
451
+ article_arxiv_id,
452
+ article_pages,
453
+ article_title,
454
+ verify_pdf_identity,
455
+ )
456
+
457
+ verdict = verify_pdf_identity(
458
+ artifact.content,
459
+ ids.doi if ids is not None else None,
460
+ article_title(ids) if ids is not None else "",
461
+ pages=article_pages(ids) if ids is not None else "",
462
+ # The only signal an arXiv record has: its DOI is at DataCite, so
463
+ # Crossref supplies neither title nor page range, and the PDF prints
464
+ # the id rather than the DOI.
465
+ arxiv_id=article_arxiv_id(ids) if ids is not None else "",
466
+ # Left by _accept when a structured copy of THIS record was taken
467
+ # earlier in the same walk. A publisher's first-page preview carries
468
+ # the article's own DOI and title, so no identity signal can see it;
469
+ # its length against the full text can.
470
+ reference_chars=ctx.scratch.get("structured_text_chars"),
471
+ )
472
+ if not verdict.ok:
473
+ return ValidationResult.failure(verdict.reason)
474
+ return ValidationResult(
475
+ ok=True, checks_passed=["pdf-magic", "min-size"] + verdict.signals
476
+ )
477
+ if tier == Tier.T6_PLAINTEXT:
478
+ text = artifact.content.decode("utf-8", errors="replace")
479
+ minimum = ctx.threshold("abstract_stub_chars", 2500)
480
+ if len(text.strip()) < minimum:
481
+ return ValidationResult.failure(
482
+ f"plain text below stub threshold ({len(text.strip())} < {minimum})",
483
+ n_chars=len(text.strip()),
484
+ )
485
+ return ValidationResult(
486
+ ok=True, checks_passed=["min-chars"], n_chars=len(text.strip())
487
+ )
488
+ # T7: a landing page is accepted as what it is, and marked as holding no tables.
489
+ if not artifact.content:
490
+ return ValidationResult.failure("empty landing page")
491
+ return ValidationResult(ok=True, checks_passed=["non-empty"])
492
+
493
+
494
+ def _accept(artifact: Artifact, stem: str, ctx, provenance) -> str:
495
+ """Normalize, chunk, write the artifact, then the sidecar."""
496
+ _note_structured_length(artifact, ctx)
497
+ chunks, canonical_tokens = _normalize(artifact, ctx)
498
+ path = stem + artifact.suffix
499
+ _write_atomic(path, artifact.content)
500
+
501
+ if provenance is not None:
502
+ provenance.resolution_chain = _chain_of(ctx)
503
+ provenance.accept(artifact, path, chunks=chunks, canonical_tokens=canonical_tokens)
504
+ return path
505
+
506
+
507
+ def _note_structured_length(artifact: Artifact, ctx) -> None:
508
+ """Record how much prose a structured artifact carries, for later tiers.
509
+
510
+ T1 and T2 are walked before T5, so by the time a PDF is judged this is the
511
+ length of the same article in a format that cannot be truncated by a
512
+ paywall. See pdf_identity._truncation_reason.
513
+ """
514
+ if artifact.tier not in (Tier.T1_XML, Tier.T2_HTML):
515
+ return
516
+ try:
517
+ text = artifact.content.decode("utf-8", errors="replace")
518
+ except Exception:
519
+ return
520
+ text = re.sub(r"(?is)<(script|style)[^>]*>.*?</\1>", " ", text)
521
+ plain = " ".join(re.sub(r"<[^>]+>", " ", text).split())
522
+ if plain:
523
+ ctx.scratch.setdefault("structured_text_chars", len(plain))
524
+
525
+
526
+ def _normalize(artifact: Artifact, ctx) -> Tuple[list, int]:
527
+ """Canonical tables for the structured tiers. Deterministic, no model."""
528
+ from .chunk import chunks_from_html, chunks_from_jats
529
+ from .normalize import strip_boilerplate, token_count
530
+
531
+ chunks: List = []
532
+ try:
533
+ if artifact.tier == Tier.T1_XML:
534
+ chunks = chunks_from_jats(artifact.content, artifact.source)
535
+ elif artifact.tier == Tier.T2_HTML:
536
+ chunks = chunks_from_html(artifact.content, artifact.source)
537
+ _, ratio, failures = strip_boilerplate(
538
+ artifact.content.decode("utf-8", errors="replace")
539
+ )
540
+ artifact.extra["boilerplate_token_ratio"] = round(ratio, 3)
541
+ artifact.normalization_failures.extend(failures)
542
+ except Exception as e:
543
+ artifact.normalization_failures.append(f"normalization failed: {str(e)[:120]}")
544
+ return [], 0
545
+
546
+ for chunk in chunks:
547
+ artifact.normalization_failures.extend(chunk.normalization_failures)
548
+ canonical_tokens = sum(token_count(c.as_block()) for c in chunks)
549
+ return chunks, canonical_tokens
550
+
551
+
552
+ def _chain_of(ctx) -> list:
553
+ ids = ctx.scratch.get("ids")
554
+ return ids.chain.to_list() if ids is not None else []
555
+
556
+
557
+ # -- disk -------------------------------------------------------------------
558
+
559
+
560
+ def _stem_for(save_path: str) -> str:
561
+ """The path with any artifact suffix removed.
562
+
563
+ Handles the multi-part suffixes (.source.tar.gz, .fulltext.html) that
564
+ os.path.splitext would only half-strip.
565
+ """
566
+ for suffix in sorted(ARTIFACT_EXTENSIONS, key=len, reverse=True):
567
+ if save_path.endswith(suffix):
568
+ return save_path[: -len(suffix)]
569
+ return os.path.splitext(save_path)[0]
570
+
571
+
572
+ def _existing_artifact(stem: str) -> Tuple[Optional[str], Optional[Tier]]:
573
+ """The best artifact already on disk for this record, if any.
574
+
575
+ Iterating TIER_EXTENSIONS rather than Tier because not every tier writes a
576
+ file, so `for tier in sorted(Tier)` would KeyError.
577
+ """
578
+ for tier in sorted(TIER_EXTENSIONS):
579
+ path = stem + TIER_EXTENSIONS[tier]
580
+ if os.path.exists(path) and os.path.getsize(path) > 0:
581
+ return path, tier
582
+ return None, None
583
+
584
+
585
+ def _rank_of(tier: Tier, tiers: List[Tier]) -> int:
586
+ """Position on the ACTIVE ladder -- lower is better.
587
+
588
+ Not the Tier int: the screening ladder ranks T6 above T5, so comparing ints
589
+ would let a T5 PDF overwrite a better T6 screening artifact under
590
+ --upgrade-existing. This is the same argument _judge's docstring already
591
+ makes, applied to the one place that was still comparing ints.
592
+
593
+ A preprint is ranked where the T8 rung sits even when that rung is not being
594
+ walked, so --upgrade-existing without --alt-version cannot "upgrade" a
595
+ preprint into a landing page.
596
+ """
597
+ if tier in tiers:
598
+ return tiers.index(tier)
599
+ return len(tiers) # not on this ladder at all: anything beats it
600
+
601
+
602
+ def _write_atomic(path: str, content: bytes) -> None:
603
+ directory = os.path.dirname(os.path.abspath(path)) or "."
604
+ os.makedirs(directory, exist_ok=True)
605
+ handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-w-", dir=directory)
606
+ try:
607
+ with os.fdopen(handle, "wb") as f:
608
+ f.write(content)
609
+ os.replace(temp_path, path)
610
+ except BaseException:
611
+ try:
612
+ os.unlink(temp_path)
613
+ except OSError:
614
+ pass
615
+ raise
616
+
617
+
618
+ def _flush(resolver, owns_resolver: bool) -> None:
619
+ """Persist the resolution cache when this call owns it.
620
+
621
+ Batch mode flushes once at the end instead: flushing per record would
622
+ rewrite a file that grows with every row, which is quadratic over a batch.
623
+ """
624
+ if owns_resolver:
625
+ resolver.cache.flush()
626
+
627
+
628
+ def _fill_goals_from_disk(goalset, stem: str, ctx) -> None:
629
+ """Pre-fill any goal whose artifact is already on disk.
630
+
631
+ Per goal, not per record: that is what turns --get-xml-or-html into a backfill
632
+ over an existing PDF inventory. A directory full of PDFs has its `pdf` goal
633
+ satisfied and only the structured half fetched, and no PDF is touched.
634
+ """
635
+ for goal in goalset:
636
+ for tier in goal.tiers:
637
+ suffix = TIER_EXTENSIONS.get(tier)
638
+ if not suffix:
639
+ continue
640
+ path = stem + suffix
641
+ if os.path.exists(path) and os.path.getsize(path) > 0:
642
+ goal.fill(path, artifact=None, preexisting=True)
643
+ ctx.log(f" {goal.name}: already on disk ({os.path.basename(path)})")
644
+ break
645
+
646
+
647
+ def _write_sidecar_once(provenance, goalset, stem: str, ctx) -> None:
648
+ """One sidecar per record, keyed on the best artifact's stem.
649
+
650
+ Written after the walk rather than inside _accept: a two-artifact record has one
651
+ audit trail describing both, not two files each telling half the story.
652
+ """
653
+ if provenance is None:
654
+ return
655
+ provenance.resolution_chain = _chain_of(ctx)
656
+ paths = goalset.filled_paths()
657
+ if paths:
658
+ write_sidecar(provenance, paths[0], verbose=ctx.verbose)
659
+
660
+
661
+ def _write_provenance_only(provenance, stem: str, ctx) -> None:
662
+ """Record a failed record too: 'tried and found nothing' is an audit fact."""
663
+ if provenance is None:
664
+ return
665
+ provenance.resolution_chain = _chain_of(ctx)
666
+ write_sidecar(provenance, stem + ".pdf", verbose=ctx.verbose)