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,242 @@
|
|
|
1
|
+
"""Text out of a PDF, for the code that has to read the paper itself.
|
|
2
|
+
|
|
3
|
+
Two consumers, one seam: `fulltext_scan` looks for deposit URLs in the prose,
|
|
4
|
+
and the retrieval agent needs the same prose as its brief. Both were blind to
|
|
5
|
+
PDF-only records -- measured 2026-08-22 across four corpora, **606 papers have
|
|
6
|
+
a PDF and no XML or HTML at all** (cerebrolysin 212, masliah 369, nina_mazar
|
|
7
|
+
25). Those were never scanned, and a record that was never scanned was being
|
|
8
|
+
reported as one where nothing was found.
|
|
9
|
+
|
|
10
|
+
OPTIONAL BY CONTRACT, AND STILL IS. Neither engine raises here, and a missing
|
|
11
|
+
one degrades to "no text" with a named reason -- never an exception, never a
|
|
12
|
+
failed record. `engine_status()` says which of the three states holds so a
|
|
13
|
+
caller can tell "this machine has no PDF reader" from "this PDF has no text
|
|
14
|
+
layer", which are different facts about different things.
|
|
15
|
+
|
|
16
|
+
What changed is the POLICY LAYER above it, not this contract. A third consumer,
|
|
17
|
+
`pdf_identity`, uses this module to prove a retrieved PDF is the paper that was
|
|
18
|
+
asked for; with no engine it cannot prove anything, and under reject-always
|
|
19
|
+
that would silently discard every PDF in a run. So `pypdf` moved into the base
|
|
20
|
+
dependencies and the CLI refuses to start without an engine -- a RUN-level
|
|
21
|
+
error answered once at startup, not a per-record one answered 5,000 times. The
|
|
22
|
+
module keeps degrading; the caller owns the policy. Do not make this raise.
|
|
23
|
+
|
|
24
|
+
PyMuPDF first, pypdf second. PyMuPDF is faster and keeps reading order better;
|
|
25
|
+
pypdf is the fallback already used by `corresponding.from_pdf`, so a machine
|
|
26
|
+
set up for that keeps working here.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
from typing import Optional, Tuple
|
|
31
|
+
|
|
32
|
+
#: Engines, in preference order. Names are what `engine_status` reports.
|
|
33
|
+
ENGINE_PYMUPDF = "pymupdf"
|
|
34
|
+
ENGINE_PYPDF = "pypdf"
|
|
35
|
+
|
|
36
|
+
#: Reported when neither engine imports. The message names the install, because
|
|
37
|
+
#: the alternative is a silent zero-yield scan that reads like a clean corpus.
|
|
38
|
+
MISSING_DEP_MSG = ("no PDF text engine, so no retrieved PDF can be checked "
|
|
39
|
+
"against the record it was fetched for; install pypdf, or "
|
|
40
|
+
"fetchpdf[text] for the faster PyMuPDF reader")
|
|
41
|
+
|
|
42
|
+
#: A page cap for callers that only need the front or back matter. None reads
|
|
43
|
+
#: the whole document, which is what the deposit scan wants -- a data
|
|
44
|
+
#: availability statement lives in the back matter, not on page one.
|
|
45
|
+
DEFAULT_MAX_PAGES = None
|
|
46
|
+
|
|
47
|
+
#: Below this many characters we treat the read as "no text layer" rather than
|
|
48
|
+
#: as text. A scanned page yields a handful of stray glyphs, not prose, and
|
|
49
|
+
#: calling that a successful read invites the caller to conclude the paper
|
|
50
|
+
#: named no deposit when nothing was ever legible.
|
|
51
|
+
MIN_USEFUL_CHARS = 500
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _pymupdf():
|
|
55
|
+
"""PyMuPDF if installed, else None. Optional extra: fetchpdf[text]."""
|
|
56
|
+
try:
|
|
57
|
+
import pymupdf
|
|
58
|
+
return pymupdf
|
|
59
|
+
except ImportError:
|
|
60
|
+
try:
|
|
61
|
+
import fitz
|
|
62
|
+
return fitz
|
|
63
|
+
except ImportError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pypdf():
|
|
68
|
+
"""pypdf if installed, else None."""
|
|
69
|
+
try:
|
|
70
|
+
from pypdf import PdfReader
|
|
71
|
+
return PdfReader
|
|
72
|
+
except ImportError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def engine_status() -> Tuple[Optional[str], str]:
|
|
77
|
+
"""`(engine_name, detail)`. `engine_name` is None when none is installed."""
|
|
78
|
+
if _pymupdf() is not None:
|
|
79
|
+
return ENGINE_PYMUPDF, "PyMuPDF"
|
|
80
|
+
if _pypdf() is not None:
|
|
81
|
+
return ENGINE_PYPDF, "pypdf"
|
|
82
|
+
return None, MISSING_DEP_MSG
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def pdf_text(path: str, max_pages: Optional[int] = DEFAULT_MAX_PAGES,
|
|
86
|
+
min_chars: int = MIN_USEFUL_CHARS) -> Optional[str]:
|
|
87
|
+
"""The document's text, or None when it could not be read as text.
|
|
88
|
+
|
|
89
|
+
None covers every failure the same way on purpose -- no engine, an
|
|
90
|
+
unreadable file, an encrypted document, a page tree of images. The caller
|
|
91
|
+
that needs to tell those apart asks `engine_status()` first; the caller
|
|
92
|
+
that just wants prose gets "there is none" without a traceback.
|
|
93
|
+
|
|
94
|
+
`min_chars` exists because MIN_USEFUL_CHARS is calibrated for the whole-
|
|
95
|
+
document deposit scan, where a few hundred stray glyphs mean "scanned, no
|
|
96
|
+
text layer". A caller reading only the first two pages of a short paper can
|
|
97
|
+
fall under that floor with perfectly good text, and reporting that as "no
|
|
98
|
+
text layer" would, under reject-always, delete a correct PDF. Identity
|
|
99
|
+
verification passes a lower floor; the default is unchanged, so
|
|
100
|
+
`fulltext_scan` is untouched.
|
|
101
|
+
"""
|
|
102
|
+
if not path or not os.path.exists(path):
|
|
103
|
+
return None
|
|
104
|
+
return _extract(path, max_pages, min_chars)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pdf_text_from_bytes(content: bytes,
|
|
108
|
+
max_pages: Optional[int] = DEFAULT_MAX_PAGES,
|
|
109
|
+
min_chars: int = MIN_USEFUL_CHARS) -> Optional[str]:
|
|
110
|
+
"""`pdf_text` for a document that is still in memory.
|
|
111
|
+
|
|
112
|
+
The tiered engine judges an artifact before anything has been written to
|
|
113
|
+
disk -- deliberately, so a T5 artifact that fails validation leaves no file
|
|
114
|
+
behind -- so it has bytes where the scan has a path.
|
|
115
|
+
"""
|
|
116
|
+
if not content:
|
|
117
|
+
return None
|
|
118
|
+
return _extract(content, max_pages, min_chars)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def pdf_page_count(source) -> Optional[int]:
|
|
122
|
+
"""How many pages the document has, or None if it cannot be opened.
|
|
123
|
+
|
|
124
|
+
Structural, not textual: this works on an image-only scan, which is the
|
|
125
|
+
whole reason it exists. See `pdf_identity`, where a page count is the only
|
|
126
|
+
identity evidence a scanned paper can offer.
|
|
127
|
+
"""
|
|
128
|
+
module = _pymupdf()
|
|
129
|
+
if module is not None:
|
|
130
|
+
document = None
|
|
131
|
+
try:
|
|
132
|
+
document = _open_pymupdf(module, source)
|
|
133
|
+
return int(document.page_count)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
finally:
|
|
137
|
+
if document is not None:
|
|
138
|
+
try:
|
|
139
|
+
document.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
reader_cls = _pypdf()
|
|
144
|
+
if reader_cls is not None:
|
|
145
|
+
try:
|
|
146
|
+
return len(reader_cls(_as_pypdf_source(source)).pages)
|
|
147
|
+
except Exception:
|
|
148
|
+
return None
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def pdf_metadata(source) -> dict:
|
|
153
|
+
"""The document's Info/XMP dictionary, or {} for anything unreadable.
|
|
154
|
+
|
|
155
|
+
Accepts a path or bytes. Only the embedded `Title` is used today, as the
|
|
156
|
+
last identity signal when a PDF's text layer is too thin to carry one.
|
|
157
|
+
"""
|
|
158
|
+
module = _pymupdf()
|
|
159
|
+
if module is not None:
|
|
160
|
+
document = None
|
|
161
|
+
try:
|
|
162
|
+
document = _open_pymupdf(module, source)
|
|
163
|
+
return dict(document.metadata or {})
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
finally:
|
|
167
|
+
if document is not None:
|
|
168
|
+
try:
|
|
169
|
+
document.close()
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
reader_cls = _pypdf()
|
|
174
|
+
if reader_cls is not None:
|
|
175
|
+
try:
|
|
176
|
+
reader = reader_cls(_as_pypdf_source(source))
|
|
177
|
+
return {str(k).lstrip("/").lower(): str(v)
|
|
178
|
+
for k, v in (reader.metadata or {}).items()}
|
|
179
|
+
except Exception:
|
|
180
|
+
return {}
|
|
181
|
+
return {}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _open_pymupdf(module, source):
|
|
185
|
+
if isinstance(source, (bytes, bytearray)):
|
|
186
|
+
return module.open(stream=bytes(source), filetype="pdf")
|
|
187
|
+
return module.open(source)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _as_pypdf_source(source):
|
|
191
|
+
if isinstance(source, (bytes, bytearray)):
|
|
192
|
+
import io
|
|
193
|
+
return io.BytesIO(bytes(source))
|
|
194
|
+
return source
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _extract(source, max_pages: Optional[int], min_chars: int) -> Optional[str]:
|
|
198
|
+
"""PyMuPDF first, pypdf second, over either a path or bytes."""
|
|
199
|
+
module = _pymupdf()
|
|
200
|
+
if module is not None:
|
|
201
|
+
text = _read_pymupdf(module, source, max_pages, min_chars)
|
|
202
|
+
if text is not None:
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
reader = _pypdf()
|
|
206
|
+
if reader is not None:
|
|
207
|
+
return _read_pypdf(reader, source, max_pages, min_chars)
|
|
208
|
+
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _read_pymupdf(module, source, max_pages: Optional[int],
|
|
213
|
+
min_chars: int = MIN_USEFUL_CHARS) -> Optional[str]:
|
|
214
|
+
document = None
|
|
215
|
+
try:
|
|
216
|
+
document = _open_pymupdf(module, source)
|
|
217
|
+
pages = list(document)
|
|
218
|
+
if max_pages is not None:
|
|
219
|
+
pages = pages[:max_pages]
|
|
220
|
+
text = "\n".join(page.get_text() or "" for page in pages)
|
|
221
|
+
except Exception:
|
|
222
|
+
# A corrupt or encrypted PDF is not this module's problem to raise on;
|
|
223
|
+
# the pypdf fallback gets its turn, and then the caller gets None.
|
|
224
|
+
return None
|
|
225
|
+
finally:
|
|
226
|
+
if document is not None:
|
|
227
|
+
try:
|
|
228
|
+
document.close()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
return text if len(text.strip()) >= min_chars else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _read_pypdf(reader_cls, source, max_pages: Optional[int],
|
|
235
|
+
min_chars: int = MIN_USEFUL_CHARS) -> Optional[str]:
|
|
236
|
+
try:
|
|
237
|
+
reader = reader_cls(_as_pypdf_source(source))
|
|
238
|
+
pages = reader.pages if max_pages is None else reader.pages[:max_pages]
|
|
239
|
+
text = "\n".join((page.extract_text() or "") for page in pages)
|
|
240
|
+
except Exception:
|
|
241
|
+
return None
|
|
242
|
+
return text if len(text.strip()) >= min_chars else None
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The per-record audit sidecar.
|
|
2
|
+
|
|
3
|
+
The requirement this satisfies is not "nice logging". It is: given a number in
|
|
4
|
+
an extraction output, reconstruct why it came from that file -- which source
|
|
5
|
+
served it, which identifier got us there, which validation checks it passed, and
|
|
6
|
+
what we tried and rejected first.
|
|
7
|
+
|
|
8
|
+
That last part is why `attempts` records demotions as well as the accepted
|
|
9
|
+
artifact. "We used the PDF" is not an audit trail; "the Europe PMC XML was a
|
|
10
|
+
publisher denial stub and the Crossref TDM link 403'd, so we used the PDF" is.
|
|
11
|
+
|
|
12
|
+
Written only when --provenance is passed. Without it no sidecar appears, so an
|
|
13
|
+
existing output directory gains no new files.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from .http import redact
|
|
24
|
+
from ._util import iso as _iso
|
|
25
|
+
|
|
26
|
+
SIDECAR_SUFFIX = ".provenance.json"
|
|
27
|
+
_SCHEMA_VERSION = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Attempt:
|
|
32
|
+
"""One source tried, accepted or demoted."""
|
|
33
|
+
|
|
34
|
+
source: str
|
|
35
|
+
tier_attempted: str
|
|
36
|
+
url: Optional[str] = None
|
|
37
|
+
http_status: Optional[int] = None
|
|
38
|
+
served_content_type: str = ""
|
|
39
|
+
accepted: bool = False
|
|
40
|
+
outcome: str = "" # why it was demoted, or "accepted"
|
|
41
|
+
checks_passed: List[str] = field(default_factory=list)
|
|
42
|
+
timestamp: float = field(default_factory=time.time)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"source": self.source,
|
|
47
|
+
"tier_attempted": self.tier_attempted,
|
|
48
|
+
"url": redact(self.url),
|
|
49
|
+
"http_status": self.http_status,
|
|
50
|
+
"served_content_type": self.served_content_type,
|
|
51
|
+
"accepted": self.accepted,
|
|
52
|
+
"outcome": self.outcome,
|
|
53
|
+
"checks_passed": list(self.checks_passed),
|
|
54
|
+
"timestamp": _iso(self.timestamp),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ProvenanceRecord:
|
|
59
|
+
"""Accumulates everything about one record's retrieval."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, identifier: str, target_task: str, flags: Optional[dict] = None):
|
|
62
|
+
self.identifier = identifier
|
|
63
|
+
self.target_task = target_task
|
|
64
|
+
self.flags = dict(flags or {})
|
|
65
|
+
self.attempts: List[Attempt] = []
|
|
66
|
+
self.started_at = time.time()
|
|
67
|
+
self.artifact: Optional[dict] = None # the best one, for single-artifact readers
|
|
68
|
+
self.artifacts: List[dict] = []
|
|
69
|
+
self.resolution_chain: List[dict] = []
|
|
70
|
+
self.identifiers: Dict[str, str] = {}
|
|
71
|
+
self.tables: List[dict] = []
|
|
72
|
+
self.notes: List[str] = []
|
|
73
|
+
|
|
74
|
+
def attempt(self, **kwargs) -> Attempt:
|
|
75
|
+
record = Attempt(**kwargs)
|
|
76
|
+
self.attempts.append(record)
|
|
77
|
+
return record
|
|
78
|
+
|
|
79
|
+
def note(self, message: str) -> None:
|
|
80
|
+
self.notes.append(message)
|
|
81
|
+
|
|
82
|
+
def accept(self, artifact, path: str, chunks=None, canonical_tokens: int = 0) -> None:
|
|
83
|
+
"""Record an accepted artifact.
|
|
84
|
+
|
|
85
|
+
Appends. A --get-xml-or-html walk accepts two (a structured copy and the
|
|
86
|
+
PDF), and an audit record for such a record has to describe both -- which
|
|
87
|
+
one a number came from is the question this file exists to answer.
|
|
88
|
+
`self.artifact` stays pointing at the first, best one for readers that
|
|
89
|
+
expect a single entry.
|
|
90
|
+
"""
|
|
91
|
+
validation = artifact.validation
|
|
92
|
+
entry = {
|
|
93
|
+
"path": os.path.basename(path),
|
|
94
|
+
"source": artifact.source,
|
|
95
|
+
"tier": artifact.tier.name,
|
|
96
|
+
"tier_value": int(artifact.tier),
|
|
97
|
+
"tier_declared_by_source": (
|
|
98
|
+
artifact.declared_tier.name if artifact.declared_tier else artifact.tier.name
|
|
99
|
+
),
|
|
100
|
+
"tier_assigned_by": "content inspection",
|
|
101
|
+
"why_this_tier": {
|
|
102
|
+
"checks_passed": list(validation.checks_passed) if validation else [],
|
|
103
|
+
"reason": validation.reason if validation else "",
|
|
104
|
+
},
|
|
105
|
+
"served_content_type": artifact.served_content_type,
|
|
106
|
+
"identifier_used": artifact.identifier_used,
|
|
107
|
+
"request_url": redact(artifact.url),
|
|
108
|
+
"http_status": artifact.http_status,
|
|
109
|
+
"retrieved_at": _iso(time.time()),
|
|
110
|
+
"content_hash": artifact.content_hash,
|
|
111
|
+
"content_bytes": len(artifact.content),
|
|
112
|
+
"license": artifact.license,
|
|
113
|
+
"table_count": validation.n_tables if validation else 0,
|
|
114
|
+
"footnote_count": validation.n_footnotes if validation else 0,
|
|
115
|
+
"populated_cell_count": validation.n_populated_cells if validation else 0,
|
|
116
|
+
"canonical_token_count": canonical_tokens,
|
|
117
|
+
"tables_unavailable": artifact.tables_unavailable,
|
|
118
|
+
"normalization_failures": list(artifact.normalization_failures),
|
|
119
|
+
}
|
|
120
|
+
self.artifacts.append(entry)
|
|
121
|
+
if self.artifact is None:
|
|
122
|
+
self.artifact = entry
|
|
123
|
+
if chunks:
|
|
124
|
+
self.tables.extend(c.to_dict() for c in chunks)
|
|
125
|
+
# Zero tables in a trial paper is a finding, not a normal outcome -- but
|
|
126
|
+
# only where we actually counted. A PDF's table count is 0 because
|
|
127
|
+
# nothing here parses PDFs, which is not the same claim at all.
|
|
128
|
+
countable = artifact.tier.name in ("T1_XML", "T2_HTML")
|
|
129
|
+
if countable and entry["table_count"] == 0 and not artifact.tables_unavailable:
|
|
130
|
+
self.note("zero tables in accepted artifact -- verify this paper has none")
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict:
|
|
133
|
+
return {
|
|
134
|
+
"schema_version": _SCHEMA_VERSION,
|
|
135
|
+
"identifier": self.identifier,
|
|
136
|
+
"target_task": self.target_task,
|
|
137
|
+
"flags": self.flags,
|
|
138
|
+
"started_at": _iso(self.started_at),
|
|
139
|
+
"completed_at": _iso(time.time()),
|
|
140
|
+
"identifiers_resolved": dict(self.identifiers),
|
|
141
|
+
"resolution_chain": [
|
|
142
|
+
{**step, "url": redact(step.get("url"))} for step in self.resolution_chain
|
|
143
|
+
],
|
|
144
|
+
"attempts": [a.to_dict() for a in self.attempts],
|
|
145
|
+
"artifact": self.artifact,
|
|
146
|
+
"artifacts": list(self.artifacts),
|
|
147
|
+
"tables": self.tables,
|
|
148
|
+
"notes": list(self.notes),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def sidecar_path_for(artifact_path: str) -> str:
|
|
153
|
+
"""Sidecar sits beside the artifact, keyed on the same stem."""
|
|
154
|
+
stem = artifact_path
|
|
155
|
+
for suffix in (".source.tar.gz", ".fulltext.html", ".landing.html", ".suppl.zip"):
|
|
156
|
+
if stem.endswith(suffix):
|
|
157
|
+
return stem[: -len(suffix)] + SIDECAR_SUFFIX
|
|
158
|
+
return os.path.splitext(stem)[0] + SIDECAR_SUFFIX
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write_sidecar(record: ProvenanceRecord, artifact_path: str, verbose: bool = False) -> Optional[str]:
|
|
162
|
+
"""Write the sidecar atomically. Never raises into the retrieval path."""
|
|
163
|
+
path = sidecar_path_for(artifact_path)
|
|
164
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
165
|
+
try:
|
|
166
|
+
os.makedirs(directory, exist_ok=True)
|
|
167
|
+
fd, tmp = tempfile.mkstemp(prefix=".fetchpdf-prov-", dir=directory)
|
|
168
|
+
try:
|
|
169
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
170
|
+
json.dump(record.to_dict(), f, indent=2, ensure_ascii=False)
|
|
171
|
+
os.replace(tmp, path)
|
|
172
|
+
except BaseException:
|
|
173
|
+
try:
|
|
174
|
+
os.unlink(tmp)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
raise
|
|
178
|
+
except OSError as e:
|
|
179
|
+
if verbose:
|
|
180
|
+
print(f" could not write provenance sidecar: {e}")
|
|
181
|
+
return None
|
|
182
|
+
return path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Per-host token buckets.
|
|
2
|
+
|
|
3
|
+
Per-host and not global, because the configured limits differ by more than an
|
|
4
|
+
order of magnitude across the APIs this package touches: NCBI E-utilities is
|
|
5
|
+
3/s unkeyed and 10/s keyed, Semantic Scholar is 1/s unkeyed, Crossref's polite
|
|
6
|
+
pool is 10/s, and arXiv now wants roughly one request every three seconds. A
|
|
7
|
+
single global limiter set safe enough for arXiv would make a PMC-heavy batch
|
|
8
|
+
take all day; set fast enough for Crossref it would get us 429'd off arXiv.
|
|
9
|
+
|
|
10
|
+
Buckets are process-wide and lock-guarded so the ThreadPoolExecutor in
|
|
11
|
+
batch_fetch_pdfs shares one budget per host rather than one per worker.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Dict, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TokenBucket:
|
|
21
|
+
"""Classic token bucket: `rate` tokens/second, capped at `burst`."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, rate: float, burst: float):
|
|
24
|
+
self.rate = max(float(rate), 0.001)
|
|
25
|
+
self.burst = max(float(burst), 1.0)
|
|
26
|
+
self._tokens = self.burst
|
|
27
|
+
self._last = time.monotonic()
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
def acquire(self, tokens: float = 1.0) -> float:
|
|
31
|
+
"""Block until `tokens` are available. Returns seconds actually slept."""
|
|
32
|
+
slept = 0.0
|
|
33
|
+
while True:
|
|
34
|
+
with self._lock:
|
|
35
|
+
now = time.monotonic()
|
|
36
|
+
self._tokens = min(
|
|
37
|
+
self.burst, self._tokens + (now - self._last) * self.rate
|
|
38
|
+
)
|
|
39
|
+
self._last = now
|
|
40
|
+
if self._tokens >= tokens:
|
|
41
|
+
self._tokens -= tokens
|
|
42
|
+
return slept
|
|
43
|
+
deficit = tokens - self._tokens
|
|
44
|
+
wait = deficit / self.rate
|
|
45
|
+
time.sleep(wait)
|
|
46
|
+
slept += wait
|
|
47
|
+
|
|
48
|
+
def penalize(self, seconds: float) -> None:
|
|
49
|
+
"""Drain the bucket after a 429, so the next caller waits it out."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._tokens = 0.0
|
|
52
|
+
self._last = time.monotonic() + max(0.0, seconds)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: The process-wide limiter. Politeness is a property of the PROCESS, not of a
|
|
56
|
+
#: batch: an API's allowance does not multiply because we started a second
|
|
57
|
+
#: walk. Three call sites used to build their own -- the tiered engine, the
|
|
58
|
+
#: supplementary pass and the legacy chain's Crossref helper -- so a run that
|
|
59
|
+
#: touched two of them had two budgets for the same host and could serve
|
|
60
|
+
#: Crossref 20 requests a second against a published allowance of 10.
|
|
61
|
+
#:
|
|
62
|
+
#: First config wins, which is correct here because every caller passes the
|
|
63
|
+
#: same `ladder.rate_limits`. Tests that want an isolated limiter construct
|
|
64
|
+
#: HostRateLimiter directly.
|
|
65
|
+
_SHARED_LIMITER = None
|
|
66
|
+
_SHARED_LOCK = threading.Lock()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shared_host_limiter(config: Optional[dict] = None) -> "HostRateLimiter":
|
|
70
|
+
"""The one limiter every production caller should use."""
|
|
71
|
+
global _SHARED_LIMITER
|
|
72
|
+
with _SHARED_LOCK:
|
|
73
|
+
if _SHARED_LIMITER is None:
|
|
74
|
+
_SHARED_LIMITER = HostRateLimiter(config)
|
|
75
|
+
return _SHARED_LIMITER
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HostRateLimiter:
|
|
79
|
+
"""Resolves a host to its bucket, honoring the keyed/unkeyed distinction."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, config: Optional[dict] = None):
|
|
82
|
+
self._config = dict(config or {})
|
|
83
|
+
self._default = self._config.get("_default", {"rps": 3, "burst": 3})
|
|
84
|
+
self._buckets: Dict[str, TokenBucket] = {}
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
def _spec_for(self, host: str) -> dict:
|
|
88
|
+
spec = self._config.get(host)
|
|
89
|
+
if not isinstance(spec, dict):
|
|
90
|
+
return self._default
|
|
91
|
+
return spec
|
|
92
|
+
|
|
93
|
+
def bucket(self, host: Optional[str]) -> TokenBucket:
|
|
94
|
+
key = host or "_default"
|
|
95
|
+
with self._lock:
|
|
96
|
+
bucket = self._buckets.get(key)
|
|
97
|
+
if bucket is not None:
|
|
98
|
+
return bucket
|
|
99
|
+
spec = self._spec_for(key)
|
|
100
|
+
rate = spec.get("rps", 3)
|
|
101
|
+
key_env = spec.get("key_env")
|
|
102
|
+
# The keyed allowance only applies if we actually hold the key.
|
|
103
|
+
if key_env and os.getenv(key_env) and spec.get("rps_keyed"):
|
|
104
|
+
rate = spec["rps_keyed"]
|
|
105
|
+
bucket = TokenBucket(rate=rate, burst=spec.get("burst", rate))
|
|
106
|
+
self._buckets[key] = bucket
|
|
107
|
+
return bucket
|
|
108
|
+
|
|
109
|
+
def acquire(self, host: Optional[str]) -> float:
|
|
110
|
+
return self.bucket(host).acquire()
|
|
111
|
+
|
|
112
|
+
def penalize(self, host: Optional[str], seconds: float) -> None:
|
|
113
|
+
self.bucket(host).penalize(seconds)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Repository APIs behind a JavaScript bot-challenge, via a real browser tab.
|
|
2
|
+
|
|
3
|
+
Harvard Dataverse sits behind AWS WAF. When the challenge is armed, *every*
|
|
4
|
+
endpoint -- the dataset API, `/api/access`, `/api/info/version`, even the site
|
|
5
|
+
root -- answers **HTTP 202 with an empty body** to a plain HTTP client. Measured
|
|
6
|
+
2026-08-12 against 10.7910/DVN/Z8WAKW, same machine and network:
|
|
7
|
+
|
|
8
|
+
curl, no UA 202, 0 bytes
|
|
9
|
+
curl, browser UA 202, 2071 bytes <- the challenge page
|
|
10
|
+
demo.dataverse.org (control) 200 <- not us, not the network
|
|
11
|
+
Playwright HEADED, land then ask API 200, 1 file <- works
|
|
12
|
+
|
|
13
|
+
202-with-no-body is the part that misleads. It is not 5xx, not 403, not a
|
|
14
|
+
timeout; `response.ok` is false and the enumerator's `return []` reads as "this
|
|
15
|
+
deposit has no files". A trial's own individual-level replication data was
|
|
16
|
+
skipped that way while the paper was reviewed against its summary statistics
|
|
17
|
+
alone, and the manifest recorded `none_found` -- a claim about the authors
|
|
18
|
+
written out of somebody else's bot protection.
|
|
19
|
+
|
|
20
|
+
The challenge page is recognisable: AWS WAF ships `window.gokuProps` and
|
|
21
|
+
`awsWafCookieDomainList` and needs JavaScript to solve. A real browser solves it
|
|
22
|
+
in a few seconds and receives a cookie; the API then answers normally *in that
|
|
23
|
+
browser context*, which is why the request is issued through `page.request`
|
|
24
|
+
rather than copied back out to the HTTP client.
|
|
25
|
+
|
|
26
|
+
Two things are load-bearing, both learned in supplement_atypon.py:
|
|
27
|
+
|
|
28
|
+
* **Headed, not headless.** On a headless server that means xvfb. The helpers
|
|
29
|
+
are imported from supplement_atypon rather than copied -- one xvfb
|
|
30
|
+
implementation, not two that drift.
|
|
31
|
+
* **Land on the page first.** The challenge is issued for the site, not for
|
|
32
|
+
the endpoint. Navigating straight to the API URL hands the challenge to a
|
|
33
|
+
JSON client that cannot run it.
|
|
34
|
+
|
|
35
|
+
Expensive -- one browser per record -- so it is the last resort, reached only
|
|
36
|
+
after the plain HTTP enumerator has already failed against a host known to use
|
|
37
|
+
this protection.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import time
|
|
42
|
+
from typing import Optional
|
|
43
|
+
|
|
44
|
+
#: Hosts observed to serve a JS bot-challenge in front of their API. Keyed by
|
|
45
|
+
#: host so a repository that later drops the challenge can simply be removed.
|
|
46
|
+
#: The landing template gives the browser a page to solve the challenge on
|
|
47
|
+
#: before the API is asked.
|
|
48
|
+
WAF_HOSTS = {
|
|
49
|
+
"dataverse.harvard.edu":
|
|
50
|
+
"https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:{doi}",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Markers of an AWS WAF interstitial. Present in the 202 body; absent once the
|
|
54
|
+
#: challenge is solved, which is how the wait knows it is done.
|
|
55
|
+
_CHALLENGE_MARKERS = ("gokuProps", "awsWafCookieDomainList")
|
|
56
|
+
|
|
57
|
+
#: A challenge that has not cleared in this long is not going to.
|
|
58
|
+
_CHALLENGE_TIMEOUT_S = 25
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def host_uses_waf(url: str) -> Optional[str]:
|
|
62
|
+
"""The landing-page template for `url`'s host, or None."""
|
|
63
|
+
for host, landing in WAF_HOSTS.items():
|
|
64
|
+
if host in (url or ""):
|
|
65
|
+
return landing
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def looks_like_challenge(body: bytes, status: int) -> bool:
|
|
70
|
+
"""Whether this response is a bot-challenge rather than an answer.
|
|
71
|
+
|
|
72
|
+
An empty 202 counts: that is what the WAF returns to a client it will not
|
|
73
|
+
even hand the challenge page to, and treating it as a valid empty result is
|
|
74
|
+
the whole failure this module exists to prevent.
|
|
75
|
+
"""
|
|
76
|
+
if status == 202:
|
|
77
|
+
return True
|
|
78
|
+
if not body:
|
|
79
|
+
return False
|
|
80
|
+
try:
|
|
81
|
+
head = body[:4096].decode("utf-8", "ignore")
|
|
82
|
+
except Exception:
|
|
83
|
+
return False
|
|
84
|
+
return any(marker in head for marker in _CHALLENGE_MARKERS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _page_content(page) -> str:
|
|
88
|
+
"""page.content() while the WAF is mid-redirect raises; retry briefly."""
|
|
89
|
+
for _ in range(10):
|
|
90
|
+
try:
|
|
91
|
+
return page.content()
|
|
92
|
+
except Exception:
|
|
93
|
+
time.sleep(1)
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_json_through_browser(api_url: str, doi: str, ctx) -> Optional[dict]:
|
|
98
|
+
"""Solve the host's challenge in a real browser, then GET `api_url` in it.
|
|
99
|
+
|
|
100
|
+
Returns the decoded JSON, or None if a browser cannot run here, the
|
|
101
|
+
challenge does not clear, or the API still refuses. Never raises: the
|
|
102
|
+
caller's contract is that a supplementary failure cannot fail the record.
|
|
103
|
+
"""
|
|
104
|
+
landing_template = host_uses_waf(api_url)
|
|
105
|
+
if not landing_template:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
from .supplement_atypon import (_start_xvfb, _stop_xvfb,
|
|
109
|
+
headed_browser_available)
|
|
110
|
+
|
|
111
|
+
if not headed_browser_available():
|
|
112
|
+
ctx.log(" repository WAF: no display and no xvfb; cannot solve challenge")
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
from playwright.sync_api import sync_playwright
|
|
116
|
+
except ImportError:
|
|
117
|
+
ctx.log(" repository WAF: playwright not installed; cannot solve challenge")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
virtual_display = None
|
|
121
|
+
try:
|
|
122
|
+
virtual_display = _start_xvfb(ctx)
|
|
123
|
+
with sync_playwright() as driver:
|
|
124
|
+
browser = driver.chromium.launch(headless=False) # load-bearing
|
|
125
|
+
try:
|
|
126
|
+
page = browser.new_context(
|
|
127
|
+
viewport={"width": 1400, "height": 900}).new_page()
|
|
128
|
+
page.goto(landing_template.format(doi=doi),
|
|
129
|
+
wait_until="domcontentloaded", timeout=60000)
|
|
130
|
+
|
|
131
|
+
deadline = time.time() + _CHALLENGE_TIMEOUT_S
|
|
132
|
+
while time.time() < deadline:
|
|
133
|
+
time.sleep(1)
|
|
134
|
+
content = _page_content(page)
|
|
135
|
+
if content and not any(m in content for m in _CHALLENGE_MARKERS):
|
|
136
|
+
break
|
|
137
|
+
else:
|
|
138
|
+
ctx.log(" repository WAF: challenge did not clear")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
response = page.request.get(api_url, timeout=60000)
|
|
142
|
+
if response.status != 200:
|
|
143
|
+
ctx.log(f" repository WAF: API still {response.status} after challenge")
|
|
144
|
+
return None
|
|
145
|
+
return json.loads(response.text())
|
|
146
|
+
finally:
|
|
147
|
+
browser.close()
|
|
148
|
+
except Exception as exc: # noqa: BLE001 - never fail the record
|
|
149
|
+
ctx.log(f" repository WAF: browser fallback failed ({type(exc).__name__})")
|
|
150
|
+
return None
|
|
151
|
+
finally:
|
|
152
|
+
_stop_xvfb(virtual_display)
|