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,669 @@
|
|
|
1
|
+
"""The one way this package talks to the network.
|
|
2
|
+
|
|
3
|
+
Everything goes through HttpClient so that four things are true by construction
|
|
4
|
+
rather than by remembering: every request passes a per-host token bucket, every
|
|
5
|
+
response carries the metadata provenance needs, transient failures back off, and
|
|
6
|
+
no URL containing a secret is ever logged or written to disk.
|
|
7
|
+
|
|
8
|
+
Politeness parameters (mailto, tool/email, api keys) are injected here from the
|
|
9
|
+
environment rather than being spelled out at each call site, so a new source
|
|
10
|
+
cannot accidentally opt out of the polite pool.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Dict, Optional
|
|
20
|
+
from urllib.parse import urlparse, urlsplit, urlunsplit, parse_qsl, urlencode
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
from requests.exceptions import (
|
|
24
|
+
ChunkedEncodingError,
|
|
25
|
+
ConnectionError as _ReqConnErr,
|
|
26
|
+
SSLError,
|
|
27
|
+
Timeout,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from .._env import EMAIL as _DEFAULT_EMAIL
|
|
31
|
+
from .._http import USER_AGENT
|
|
32
|
+
from .ratelimit import HostRateLimiter
|
|
33
|
+
|
|
34
|
+
#: Query parameters whose values must never appear in a log line or a sidecar.
|
|
35
|
+
_SECRET_PARAMS = {"apikey", "api_key", "key", "token", "access_token", "auth"}
|
|
36
|
+
_SECRET_HEADERS = {"authorization", "x-api-key", "apikey"}
|
|
37
|
+
|
|
38
|
+
#: Headers that carry a credential, i.e. the ones worth REMOVING and retrying
|
|
39
|
+
#: when a host rejects them. Same membership as `_SECRET_HEADERS` today but a
|
|
40
|
+
#: separate name on purpose: that set answers "must this be redacted before it
|
|
41
|
+
#: is logged", this one answers "is this what the host just refused". A future
|
|
42
|
+
#: header could easily need one and not the other.
|
|
43
|
+
_CREDENTIAL_HEADERS = {"authorization", "x-api-key", "apikey"}
|
|
44
|
+
|
|
45
|
+
#: Which environment variable supplies the credential for a host, so a rejection
|
|
46
|
+
#: names the variable the user has to fix rather than "an API key somewhere".
|
|
47
|
+
_CREDENTIAL_ENV = {
|
|
48
|
+
"api.semanticscholar.org": "SEMANTIC_SCHOLAR_API_KEY",
|
|
49
|
+
"api.openalex.org": "OPENALEXAPIKEY",
|
|
50
|
+
"api.core.ac.uk": "COREAPIKEY",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _credential_env_for(host: str) -> Optional[str]:
|
|
55
|
+
return _CREDENTIAL_ENV.get(host)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: Hosts already reported as rejecting their credential, so the warning is
|
|
59
|
+
#: emitted once per process rather than once per request.
|
|
60
|
+
_WARNED_CREDENTIALS: set = set()
|
|
61
|
+
|
|
62
|
+
_SECRET_IN_PATH_RE = re.compile(
|
|
63
|
+
r"(?i)\b(apikey|api_key|access_token|token)[=/]([^&/?#\s]+)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def redact(url: Optional[str]) -> Optional[str]:
|
|
68
|
+
"""Replace secret query-parameter values with REDACTED.
|
|
69
|
+
|
|
70
|
+
Applied to every URL before it is logged or persisted. Elsevier takes its
|
|
71
|
+
key as a query parameter, so an un-redacted provenance sidecar would be a
|
|
72
|
+
credential file that looks like an audit record.
|
|
73
|
+
"""
|
|
74
|
+
if not url:
|
|
75
|
+
return url
|
|
76
|
+
try:
|
|
77
|
+
parts = urlsplit(url)
|
|
78
|
+
if parts.query:
|
|
79
|
+
cleaned = [
|
|
80
|
+
(k, "REDACTED" if k.lower() in _SECRET_PARAMS else v)
|
|
81
|
+
for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
|
82
|
+
]
|
|
83
|
+
url = urlunsplit(
|
|
84
|
+
(parts.scheme, parts.netloc, parts.path, urlencode(cleaned), parts.fragment)
|
|
85
|
+
)
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
return _SECRET_IN_PATH_RE.sub(r"\1=REDACTED", url)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Response:
|
|
93
|
+
"""A completed request, with everything provenance needs to explain it."""
|
|
94
|
+
|
|
95
|
+
url: str # final URL after redirects, redacted
|
|
96
|
+
request_url: str # as requested, redacted
|
|
97
|
+
status: int
|
|
98
|
+
content: bytes
|
|
99
|
+
content_type: str
|
|
100
|
+
headers: Dict[str, str]
|
|
101
|
+
elapsed: float
|
|
102
|
+
from_cache: bool = False
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def ok(self) -> bool:
|
|
106
|
+
return 200 <= self.status < 300
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def text(self) -> str:
|
|
110
|
+
return self.content.decode("utf-8", errors="replace")
|
|
111
|
+
|
|
112
|
+
def json(self):
|
|
113
|
+
"""Parse as JSON. Raises ValueError on anything that is not.
|
|
114
|
+
|
|
115
|
+
Deliberately not silently returning {}: a source that asked for JSON and
|
|
116
|
+
got an HTML error page served as 200 needs to know, because that is
|
|
117
|
+
exactly the failure this package exists to catch.
|
|
118
|
+
"""
|
|
119
|
+
import json as _json
|
|
120
|
+
|
|
121
|
+
return _json.loads(self.text)
|
|
122
|
+
|
|
123
|
+
def json_or(self, default):
|
|
124
|
+
try:
|
|
125
|
+
return self.json()
|
|
126
|
+
except ValueError:
|
|
127
|
+
return default
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Download:
|
|
132
|
+
"""The outcome of a size-capped streaming GET written straight to disk.
|
|
133
|
+
|
|
134
|
+
`path` is set only on a complete, under-cap write, so `ok` is the single
|
|
135
|
+
question a caller has to ask. Everything else exists so the supplementary
|
|
136
|
+
manifest can say *why* a file is not there -- "refused, 4.2 GB" and "nothing
|
|
137
|
+
was offered" are different facts and must not collapse into one.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
url: str # final URL after redirects, redacted
|
|
141
|
+
request_url: str # as requested, redacted
|
|
142
|
+
status: int
|
|
143
|
+
content_type: str = ""
|
|
144
|
+
bytes_written: int = 0
|
|
145
|
+
sha256: str = "" # of the bytes actually written
|
|
146
|
+
path: Optional[str] = None
|
|
147
|
+
outcome: str = "ok" # ok | too-large | http-error | empty | unreachable
|
|
148
|
+
declared_length: Optional[int] = None # Content-Length as served, when sent
|
|
149
|
+
elapsed: float = 0.0
|
|
150
|
+
detail: str = ""
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def ok(self) -> bool:
|
|
154
|
+
return self.path is not None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class HttpClient:
|
|
158
|
+
"""Rate-limited, retrying, redacting HTTP for the retrieval path."""
|
|
159
|
+
|
|
160
|
+
#: Transport-level failures worth another attempt. HTTP 4xx/5xx are not
|
|
161
|
+
#: retried here -- a 403 from a publisher TDM link is an answer, not a blip.
|
|
162
|
+
_TRANSIENT = (SSLError, _ReqConnErr, Timeout, ChunkedEncodingError)
|
|
163
|
+
|
|
164
|
+
def __init__(self, limiter: HostRateLimiter, email: Optional[str] = None,
|
|
165
|
+
verbose: bool = False, timeout: int = 30):
|
|
166
|
+
self.limiter = limiter
|
|
167
|
+
self.email = email or _DEFAULT_EMAIL
|
|
168
|
+
self.verbose = verbose
|
|
169
|
+
self.timeout = timeout
|
|
170
|
+
self.session = requests.Session()
|
|
171
|
+
self.session.headers.update({"User-Agent": USER_AGENT})
|
|
172
|
+
self.call_count = 0
|
|
173
|
+
|
|
174
|
+
# -- politeness ---------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def polite_params(self, host: str, params: Optional[dict] = None) -> dict:
|
|
177
|
+
"""Add each API's required or rate-limit-lifting identification."""
|
|
178
|
+
params = dict(params or {})
|
|
179
|
+
if not self.email:
|
|
180
|
+
return params
|
|
181
|
+
if host == "api.crossref.org":
|
|
182
|
+
params.setdefault("mailto", self.email)
|
|
183
|
+
elif host.endswith("ncbi.nlm.nih.gov"):
|
|
184
|
+
params.setdefault("tool", "fetchpdf")
|
|
185
|
+
params.setdefault("email", self.email)
|
|
186
|
+
api_key = os.getenv("ENTREZ_EUTILS_API_KEY")
|
|
187
|
+
if api_key:
|
|
188
|
+
params.setdefault("api_key", api_key)
|
|
189
|
+
elif host == "api.unpaywall.org":
|
|
190
|
+
params.setdefault("email", self.email)
|
|
191
|
+
elif host == "www.ebi.ac.uk":
|
|
192
|
+
params.setdefault("email", self.email)
|
|
193
|
+
return params
|
|
194
|
+
|
|
195
|
+
def polite_headers(self, host: str, headers: Optional[dict] = None) -> dict:
|
|
196
|
+
headers = dict(headers or {})
|
|
197
|
+
if host == "api.semanticscholar.org":
|
|
198
|
+
key = os.getenv("SEMANTIC_SCHOLAR_API_KEY")
|
|
199
|
+
if key:
|
|
200
|
+
headers.setdefault("x-api-key", key)
|
|
201
|
+
elif host == "api.openalex.org":
|
|
202
|
+
key = os.getenv("OPENALEXAPIKEY")
|
|
203
|
+
if key:
|
|
204
|
+
headers.setdefault("Authorization", f"Bearer {key}")
|
|
205
|
+
elif host == "api.core.ac.uk":
|
|
206
|
+
key = os.getenv("COREAPIKEY")
|
|
207
|
+
if key:
|
|
208
|
+
headers.setdefault("Authorization", f"Bearer {key}")
|
|
209
|
+
return headers
|
|
210
|
+
|
|
211
|
+
# -- the request --------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def get(self, url, params=None, headers=None, timeout=None, retries=3,
|
|
214
|
+
allow_redirects=True, polite=True, stream_limit=None) -> Response:
|
|
215
|
+
"""GET with rate limiting, backoff and redaction.
|
|
216
|
+
|
|
217
|
+
Never raises for an HTTP status: sources decide what a status means.
|
|
218
|
+
Transport failures after `retries` attempts surface as a synthetic
|
|
219
|
+
status 0 response, so a dead host demotes the source instead of killing
|
|
220
|
+
the record.
|
|
221
|
+
"""
|
|
222
|
+
host = urlparse(url).netloc
|
|
223
|
+
if polite:
|
|
224
|
+
params = self.polite_params(host, params)
|
|
225
|
+
headers = self.polite_headers(host, headers)
|
|
226
|
+
|
|
227
|
+
backoff = 0.5
|
|
228
|
+
last_error = ""
|
|
229
|
+
for attempt in range(max(1, retries)):
|
|
230
|
+
self.limiter.acquire(host)
|
|
231
|
+
started = time.monotonic()
|
|
232
|
+
try:
|
|
233
|
+
r = self.session.get(
|
|
234
|
+
url,
|
|
235
|
+
params=params,
|
|
236
|
+
headers=headers,
|
|
237
|
+
timeout=timeout or self.timeout,
|
|
238
|
+
allow_redirects=allow_redirects,
|
|
239
|
+
)
|
|
240
|
+
self.call_count += 1
|
|
241
|
+
elapsed = time.monotonic() - started
|
|
242
|
+
|
|
243
|
+
# A key we sent and the host rejected is WORSE than no key:
|
|
244
|
+
# unkeyed, these APIs answer at a lower rate limit; keyed with a
|
|
245
|
+
# dead credential they answer 401/403 and the source drops out
|
|
246
|
+
# of the chain entirely. Measured 2026-08-17: an expired
|
|
247
|
+
# SEMANTIC_SCHOLAR_API_KEY turned every Semantic Scholar call --
|
|
248
|
+
# step 5 of the default resolution chain -- into a 403, while
|
|
249
|
+
# the same request with no key at all returned 200. Nothing
|
|
250
|
+
# said so, because a dead source just looks like a source that
|
|
251
|
+
# had nothing. Retry once without the credential and say which
|
|
252
|
+
# key is bad, so the failure is a warning rather than a silent
|
|
253
|
+
# capability loss.
|
|
254
|
+
if r.status_code in (401, 403) and polite:
|
|
255
|
+
stripped = {k: v for k, v in (headers or {}).items()
|
|
256
|
+
if k.lower() not in _CREDENTIAL_HEADERS}
|
|
257
|
+
if stripped != (headers or {}):
|
|
258
|
+
bad = _credential_env_for(host) or "its API key"
|
|
259
|
+
# Once per host per process. The keyed hosts sit in the
|
|
260
|
+
# per-record resolution chain, so a per-call warning
|
|
261
|
+
# prints once per paper and buries the run log under
|
|
262
|
+
# thousands of copies of one fact.
|
|
263
|
+
if host not in _WARNED_CREDENTIALS:
|
|
264
|
+
_WARNED_CREDENTIALS.add(host)
|
|
265
|
+
print(f" ⚠ {host} rejected {bad} ({r.status_code}); "
|
|
266
|
+
f"retrying unkeyed at the lower rate limit for "
|
|
267
|
+
f"the rest of this run. Refresh or unset it to "
|
|
268
|
+
f"silence this.")
|
|
269
|
+
headers = stripped
|
|
270
|
+
polite = False # do not re-add it on the next pass
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# 429/503 are worth waiting out; they mean "later", not "no".
|
|
274
|
+
if r.status_code in (429, 503) and attempt < retries - 1:
|
|
275
|
+
wait = _retry_after(r) or backoff * (2 ** attempt)
|
|
276
|
+
self.limiter.penalize(host, wait)
|
|
277
|
+
if self.verbose:
|
|
278
|
+
print(f" {host} {r.status_code}; backing off {wait:.1f}s")
|
|
279
|
+
time.sleep(wait)
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
content = r.content
|
|
283
|
+
if stream_limit and len(content) > stream_limit:
|
|
284
|
+
content = content[:stream_limit]
|
|
285
|
+
return Response(
|
|
286
|
+
url=redact(r.url),
|
|
287
|
+
request_url=redact(url),
|
|
288
|
+
status=r.status_code,
|
|
289
|
+
content=content,
|
|
290
|
+
content_type=r.headers.get("content-type", "").split(";")[0].strip().lower(),
|
|
291
|
+
headers={
|
|
292
|
+
k: ("REDACTED" if k.lower() in _SECRET_HEADERS else v)
|
|
293
|
+
for k, v in r.headers.items()
|
|
294
|
+
},
|
|
295
|
+
elapsed=elapsed,
|
|
296
|
+
)
|
|
297
|
+
except self._TRANSIENT as e:
|
|
298
|
+
last_error = str(e)[:200]
|
|
299
|
+
if attempt < retries - 1:
|
|
300
|
+
time.sleep(backoff * (2 ** attempt))
|
|
301
|
+
continue
|
|
302
|
+
except Exception as e: # malformed URL, bad encoding, ...
|
|
303
|
+
last_error = str(e)[:200]
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
if self.verbose:
|
|
307
|
+
print(f" {host} unreachable: {last_error}")
|
|
308
|
+
return Response(
|
|
309
|
+
url=redact(url),
|
|
310
|
+
request_url=redact(url),
|
|
311
|
+
status=0,
|
|
312
|
+
content=b"",
|
|
313
|
+
content_type="",
|
|
314
|
+
headers={},
|
|
315
|
+
elapsed=0.0,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# -- the JSON request ---------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def post_json(self, url, payload, headers=None, timeout=None, retries=2,
|
|
321
|
+
polite=False) -> Response:
|
|
322
|
+
"""POST a JSON body, with the same rate limiting, backoff and redaction.
|
|
323
|
+
|
|
324
|
+
Added for the retrieval agent's OpenRouter backend, and routed through
|
|
325
|
+
here rather than through `requests` directly for one reason: `redact`
|
|
326
|
+
and `_SECRET_HEADERS`. A bearer token that reaches a log line reaches
|
|
327
|
+
the manifest and the sidecar too, and those are files a corpus gets
|
|
328
|
+
shared as.
|
|
329
|
+
|
|
330
|
+
Fewer retries than `get` by default. A model call is expensive and
|
|
331
|
+
slow, so a repeat is not the cheap insurance it is for a metadata
|
|
332
|
+
lookup, and an inference endpoint that 429s wants a real wait rather
|
|
333
|
+
than three quick attempts.
|
|
334
|
+
|
|
335
|
+
Never raises for an HTTP status, exactly as `get` does not -- the
|
|
336
|
+
caller decides what a status means, and a dead endpoint must degrade
|
|
337
|
+
the agent rather than kill the record.
|
|
338
|
+
"""
|
|
339
|
+
host = urlparse(url).netloc
|
|
340
|
+
if polite:
|
|
341
|
+
headers = self.polite_headers(host, headers)
|
|
342
|
+
|
|
343
|
+
backoff = 1.0
|
|
344
|
+
last_error = ""
|
|
345
|
+
for attempt in range(max(1, retries)):
|
|
346
|
+
self.limiter.acquire(host)
|
|
347
|
+
started = time.monotonic()
|
|
348
|
+
try:
|
|
349
|
+
r = self.session.post(
|
|
350
|
+
url,
|
|
351
|
+
json=payload,
|
|
352
|
+
headers=headers,
|
|
353
|
+
timeout=timeout or self.timeout,
|
|
354
|
+
)
|
|
355
|
+
self.call_count += 1
|
|
356
|
+
elapsed = time.monotonic() - started
|
|
357
|
+
|
|
358
|
+
if r.status_code in (429, 503) and attempt < retries - 1:
|
|
359
|
+
wait = _retry_after(r) or backoff * (2 ** attempt)
|
|
360
|
+
self.limiter.penalize(host, wait)
|
|
361
|
+
if self.verbose:
|
|
362
|
+
print(f" {host} {r.status_code}; backing off {wait:.1f}s")
|
|
363
|
+
time.sleep(wait)
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
return Response(
|
|
367
|
+
url=redact(r.url),
|
|
368
|
+
request_url=redact(url),
|
|
369
|
+
status=r.status_code,
|
|
370
|
+
content=r.content,
|
|
371
|
+
content_type=r.headers.get("content-type", "").split(";")[0].strip().lower(),
|
|
372
|
+
headers={
|
|
373
|
+
k: ("REDACTED" if k.lower() in _SECRET_HEADERS else v)
|
|
374
|
+
for k, v in r.headers.items()
|
|
375
|
+
},
|
|
376
|
+
elapsed=elapsed,
|
|
377
|
+
)
|
|
378
|
+
except self._TRANSIENT as e:
|
|
379
|
+
last_error = str(e)[:200]
|
|
380
|
+
if attempt < retries - 1:
|
|
381
|
+
time.sleep(backoff * (2 ** attempt))
|
|
382
|
+
continue
|
|
383
|
+
except Exception as e:
|
|
384
|
+
last_error = str(e)[:200]
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
if self.verbose:
|
|
388
|
+
print(f" {host} unreachable: {last_error}")
|
|
389
|
+
return Response(url=redact(url), request_url=redact(url), status=0,
|
|
390
|
+
content=b"", content_type="", headers={}, elapsed=0.0)
|
|
391
|
+
|
|
392
|
+
# -- the size-capped transfer -------------------------------------------
|
|
393
|
+
|
|
394
|
+
def download(self, url, dest, max_bytes, params=None, headers=None, timeout=None,
|
|
395
|
+
retries=2, allow_redirects=True, polite=True,
|
|
396
|
+
chunk_size=65536) -> Download:
|
|
397
|
+
"""Stream a URL to `dest`, aborting cleanly above `max_bytes`.
|
|
398
|
+
|
|
399
|
+
Unlike get(), nothing is buffered in memory and nothing is ever
|
|
400
|
+
truncated. get()'s stream_limit keeps the first N bytes of an oversized
|
|
401
|
+
body, which is right for sniffing a landing page and catastrophic for a
|
|
402
|
+
file: a 300 MB prefix of a 400 MB .xlsx is a zip with a corrupt central
|
|
403
|
+
directory that some readers open far enough to yield wrong numbers.
|
|
404
|
+
Here an over-cap body is abandoned and its partial removed, so `dest` is
|
|
405
|
+
complete or absent -- never partial.
|
|
406
|
+
|
|
407
|
+
Never raises for an HTTP status, and never raises for a transport
|
|
408
|
+
failure: like get(), a dead host comes back as a synthetic status 0 so
|
|
409
|
+
one bad URL demotes a file rather than killing the record.
|
|
410
|
+
"""
|
|
411
|
+
if str(url).startswith("file://"):
|
|
412
|
+
# A provider that had to fetch the bytes itself hands them back as a
|
|
413
|
+
# local path. supplement_atypon does this because its URLs only work
|
|
414
|
+
# from inside the browser session that clicked them, so there is
|
|
415
|
+
# nothing here to re-request -- but the file still has to go through
|
|
416
|
+
# the same cap, hashing, sniffing and manifesting as everything else.
|
|
417
|
+
return _adopt_local_file_impl(url, dest, max_bytes)
|
|
418
|
+
|
|
419
|
+
host = urlparse(url).netloc
|
|
420
|
+
if polite:
|
|
421
|
+
params = self.polite_params(host, params)
|
|
422
|
+
headers = self.polite_headers(host, headers)
|
|
423
|
+
|
|
424
|
+
directory = os.path.dirname(os.path.abspath(dest)) or "."
|
|
425
|
+
backoff = 0.5
|
|
426
|
+
last_error = ""
|
|
427
|
+
for attempt in range(max(1, retries)):
|
|
428
|
+
self.limiter.acquire(host)
|
|
429
|
+
started = time.monotonic()
|
|
430
|
+
temp_path = None
|
|
431
|
+
try:
|
|
432
|
+
r = self.session.get(
|
|
433
|
+
url,
|
|
434
|
+
params=params,
|
|
435
|
+
headers=headers,
|
|
436
|
+
timeout=timeout or self.timeout,
|
|
437
|
+
allow_redirects=allow_redirects,
|
|
438
|
+
stream=True,
|
|
439
|
+
)
|
|
440
|
+
self.call_count += 1
|
|
441
|
+
with r:
|
|
442
|
+
declared = _content_length(r)
|
|
443
|
+
|
|
444
|
+
if r.status_code in (429, 503) and attempt < retries - 1:
|
|
445
|
+
wait = _retry_after(r) or backoff * (2 ** attempt)
|
|
446
|
+
self.limiter.penalize(host, wait)
|
|
447
|
+
if self.verbose:
|
|
448
|
+
print(f" {host} {r.status_code}; backing off {wait:.1f}s")
|
|
449
|
+
time.sleep(wait)
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
outcome = _Outcome(r, declared, url)
|
|
453
|
+
if not (200 <= r.status_code < 300):
|
|
454
|
+
return outcome.failure("http-error", f"HTTP {r.status_code}")
|
|
455
|
+
|
|
456
|
+
# The cheap refusal: a declared length over the cap costs no
|
|
457
|
+
# bandwidth at all. Deliberately read off the streaming
|
|
458
|
+
# response rather than from a separate HEAD -- plenty of
|
|
459
|
+
# repository file hosts 405 HEAD, and Europe PMC answers it
|
|
460
|
+
# with content-length: 0 while GET returns real bytes.
|
|
461
|
+
if declared is not None and declared > max_bytes:
|
|
462
|
+
return outcome.failure(
|
|
463
|
+
"too-large", f"declared {declared} > cap {max_bytes}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
os.makedirs(directory, exist_ok=True)
|
|
467
|
+
handle, temp_path = tempfile.mkstemp(
|
|
468
|
+
prefix=".fetchpdf-dl-", dir=directory
|
|
469
|
+
)
|
|
470
|
+
hasher = hashlib.sha256()
|
|
471
|
+
written = 0
|
|
472
|
+
over_cap = False
|
|
473
|
+
with os.fdopen(handle, "wb") as f:
|
|
474
|
+
for chunk in r.iter_content(chunk_size):
|
|
475
|
+
if not chunk:
|
|
476
|
+
continue
|
|
477
|
+
written += len(chunk)
|
|
478
|
+
# Servers lie, and chunked responses declare nothing.
|
|
479
|
+
# This is the case the Content-Length check above
|
|
480
|
+
# cannot catch, and the one that makes truncating
|
|
481
|
+
# tempting.
|
|
482
|
+
if written > max_bytes:
|
|
483
|
+
over_cap = True
|
|
484
|
+
break
|
|
485
|
+
hasher.update(chunk)
|
|
486
|
+
f.write(chunk)
|
|
487
|
+
|
|
488
|
+
if over_cap:
|
|
489
|
+
_unlink(temp_path)
|
|
490
|
+
return outcome.failure(
|
|
491
|
+
"too-large", f"exceeded cap {max_bytes} mid-stream"
|
|
492
|
+
)
|
|
493
|
+
if written == 0:
|
|
494
|
+
_unlink(temp_path)
|
|
495
|
+
return outcome.failure("empty", "no bytes served")
|
|
496
|
+
|
|
497
|
+
apply_default_mode(temp_path)
|
|
498
|
+
os.replace(temp_path, dest)
|
|
499
|
+
return Download(
|
|
500
|
+
url=redact(r.url),
|
|
501
|
+
request_url=redact(url),
|
|
502
|
+
status=r.status_code,
|
|
503
|
+
content_type=_content_type(r),
|
|
504
|
+
bytes_written=written,
|
|
505
|
+
sha256=hasher.hexdigest(),
|
|
506
|
+
path=dest,
|
|
507
|
+
outcome="ok",
|
|
508
|
+
declared_length=declared,
|
|
509
|
+
elapsed=time.monotonic() - started,
|
|
510
|
+
)
|
|
511
|
+
except self._TRANSIENT as e:
|
|
512
|
+
# Retry from scratch, never resume: a resumed partial with a
|
|
513
|
+
# mismatched offset is silent corruption of exactly the kind the
|
|
514
|
+
# no-truncation rule above exists to prevent.
|
|
515
|
+
_unlink(temp_path)
|
|
516
|
+
last_error = str(e)[:200]
|
|
517
|
+
if attempt < retries - 1:
|
|
518
|
+
time.sleep(backoff * (2 ** attempt))
|
|
519
|
+
continue
|
|
520
|
+
except BaseException as e:
|
|
521
|
+
_unlink(temp_path)
|
|
522
|
+
if isinstance(e, Exception):
|
|
523
|
+
last_error = str(e)[:200]
|
|
524
|
+
break
|
|
525
|
+
raise
|
|
526
|
+
|
|
527
|
+
if self.verbose:
|
|
528
|
+
print(f" {host} download failed: {last_error}")
|
|
529
|
+
return Download(
|
|
530
|
+
url=redact(url),
|
|
531
|
+
request_url=redact(url),
|
|
532
|
+
status=0,
|
|
533
|
+
outcome="unreachable",
|
|
534
|
+
detail=last_error,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _adopt_local_file_impl(url, dest, max_bytes) -> Download:
|
|
539
|
+
"""Bring a provider-fetched local file into the pipeline under the same rules.
|
|
540
|
+
|
|
541
|
+
Same cap, same hash, same "complete or absent" guarantee as a network
|
|
542
|
+
download -- the only difference is where the bytes came from. Copied rather
|
|
543
|
+
than moved so the provider's scratch dir stays consistent and a retry is
|
|
544
|
+
still possible.
|
|
545
|
+
"""
|
|
546
|
+
source = url[len("file://"):]
|
|
547
|
+
try:
|
|
548
|
+
size = os.path.getsize(source)
|
|
549
|
+
except OSError as error:
|
|
550
|
+
return Download(url=redact(url), request_url=redact(url), status=0,
|
|
551
|
+
outcome="unreachable", detail=str(error)[:120])
|
|
552
|
+
if size == 0:
|
|
553
|
+
return Download(url=redact(url), request_url=redact(url), status=200,
|
|
554
|
+
outcome="empty", declared_length=0)
|
|
555
|
+
if size > max_bytes:
|
|
556
|
+
return Download(url=redact(url), request_url=redact(url), status=200,
|
|
557
|
+
outcome="too-large", declared_length=size,
|
|
558
|
+
detail=f"{size} bytes over the {max_bytes} cap")
|
|
559
|
+
|
|
560
|
+
hasher = hashlib.sha256()
|
|
561
|
+
written = 0
|
|
562
|
+
try:
|
|
563
|
+
with open(source, "rb") as src, open(dest, "wb") as out:
|
|
564
|
+
while True:
|
|
565
|
+
chunk = src.read(65536)
|
|
566
|
+
if not chunk:
|
|
567
|
+
break
|
|
568
|
+
hasher.update(chunk)
|
|
569
|
+
out.write(chunk)
|
|
570
|
+
written += len(chunk)
|
|
571
|
+
except OSError as error:
|
|
572
|
+
try:
|
|
573
|
+
os.unlink(dest)
|
|
574
|
+
except OSError:
|
|
575
|
+
pass
|
|
576
|
+
return Download(url=redact(url), request_url=redact(url), status=0,
|
|
577
|
+
outcome="unreachable", detail=str(error)[:120])
|
|
578
|
+
|
|
579
|
+
return Download(url=redact(url), request_url=redact(url), status=200,
|
|
580
|
+
content_type="", bytes_written=written,
|
|
581
|
+
sha256=hasher.hexdigest(), path=dest, outcome="ok",
|
|
582
|
+
declared_length=size)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class _Outcome:
|
|
586
|
+
"""Builds a failed Download that still carries the response metadata."""
|
|
587
|
+
|
|
588
|
+
def __init__(self, response, declared, request_url):
|
|
589
|
+
self.url = redact(response.url)
|
|
590
|
+
self.request_url = redact(request_url)
|
|
591
|
+
self.status = response.status_code
|
|
592
|
+
self.content_type = _content_type(response)
|
|
593
|
+
self.declared = declared
|
|
594
|
+
|
|
595
|
+
def failure(self, outcome: str, detail: str) -> Download:
|
|
596
|
+
return Download(
|
|
597
|
+
url=self.url,
|
|
598
|
+
request_url=self.request_url,
|
|
599
|
+
status=self.status,
|
|
600
|
+
content_type=self.content_type,
|
|
601
|
+
outcome=outcome,
|
|
602
|
+
declared_length=self.declared,
|
|
603
|
+
detail=detail,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _content_type(response) -> str:
|
|
608
|
+
return response.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _content_length(response) -> Optional[int]:
|
|
612
|
+
raw = response.headers.get("Content-Length")
|
|
613
|
+
if raw is None:
|
|
614
|
+
return None
|
|
615
|
+
try:
|
|
616
|
+
value = int(raw)
|
|
617
|
+
except (TypeError, ValueError):
|
|
618
|
+
return None
|
|
619
|
+
return value if value >= 0 else None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _default_file_mode() -> int:
|
|
623
|
+
"""The mode an ordinary open(..., "wb") would produce here.
|
|
624
|
+
|
|
625
|
+
Read once, at import, because reading a umask means temporarily setting it:
|
|
626
|
+
doing that later would race every other thread in batch_fetch_pdfs's pool,
|
|
627
|
+
any of which could create a file during the window when the umask is 0.
|
|
628
|
+
"""
|
|
629
|
+
try:
|
|
630
|
+
current = os.umask(0)
|
|
631
|
+
os.umask(current)
|
|
632
|
+
return 0o666 & ~current
|
|
633
|
+
except OSError:
|
|
634
|
+
return 0o644
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
DELIVERED_FILE_MODE = _default_file_mode()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def apply_default_mode(path: str) -> None:
|
|
641
|
+
"""Give a delivered file the permissions an ordinary write would have had.
|
|
642
|
+
|
|
643
|
+
tempfile.mkstemp creates 0600, which is right for a staging file and wrong for
|
|
644
|
+
a delivered one: supplementary files would end up less readable than the PDF
|
|
645
|
+
sitting beside them, which anyone sharing an output directory hits at once.
|
|
646
|
+
"""
|
|
647
|
+
try:
|
|
648
|
+
os.chmod(path, DELIVERED_FILE_MODE)
|
|
649
|
+
except OSError:
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _unlink(path: Optional[str]) -> None:
|
|
654
|
+
if not path:
|
|
655
|
+
return
|
|
656
|
+
try:
|
|
657
|
+
os.unlink(path)
|
|
658
|
+
except OSError:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _retry_after(response) -> Optional[float]:
|
|
663
|
+
raw = response.headers.get("Retry-After")
|
|
664
|
+
if not raw:
|
|
665
|
+
return None
|
|
666
|
+
try:
|
|
667
|
+
return min(float(raw), 60.0)
|
|
668
|
+
except (TypeError, ValueError):
|
|
669
|
+
return None
|