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,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)
|