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