feat(zot): fetch.deliver — libgen ads→get with throttle retry, Anna's fast download under a daily reserve (refs Z1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
175
src/zot/fetch/deliver.py
Normal file
175
src/zot/fetch/deliver.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Candidate → file on disk.
|
||||
|
||||
libgen: ``ads.php?md5=`` (browser UA + Referer; a 200 with an error div
|
||||
and no ``get.php`` link is a throttle → one retry after 3 s) then the
|
||||
keyed ``get.php``. Anna's: the member fast-download JSON API by md5,
|
||||
guarded by a daily reserve. Unpaywall URLs download directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from zot.fetch.resolve import LIBGEN, Candidate, Pacer
|
||||
|
||||
ANNAS = "https://annas-archive.gl"
|
||||
MIN_BYTES = 10_240
|
||||
MAGIC = {"pdf": b"%PDF", "epub": b"PK"}
|
||||
_GET_LINK = re.compile(r"get\.php\?md5=([0-9a-f]{32})&(?:amp;)?key=([A-Za-z0-9]+)")
|
||||
_ADS_RETRY_SECONDS = 3.0
|
||||
|
||||
|
||||
class AnnasAuthError(RuntimeError):
|
||||
"""The Anna's key is wrong or the account is not a member — stop the run."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Budget:
|
||||
annas_key: str = ""
|
||||
reserve: int = 5
|
||||
annas_left: int | None = None
|
||||
annas_disabled: bool = False
|
||||
quota_hit: bool = False
|
||||
|
||||
def can_use_annas(self) -> bool:
|
||||
if not self.annas_key or self.annas_disabled:
|
||||
return False
|
||||
return self.annas_left is None or self.annas_left > self.reserve
|
||||
|
||||
|
||||
def download(
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
dest: Path,
|
||||
*,
|
||||
ext: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Path | None:
|
||||
"""Stream *url* to *dest*; None unless it is a real file of type *ext*."""
|
||||
try:
|
||||
with client.stream(
|
||||
"GET", url, headers=headers, timeout=120, follow_redirects=True
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
if "text/html" in r.headers.get("content-type", "").lower():
|
||||
return None
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with dest.open("wb") as fh:
|
||||
for chunk in r.iter_bytes(1 << 16):
|
||||
fh.write(chunk)
|
||||
except (httpx.HTTPError, OSError):
|
||||
dest.unlink(missing_ok=True)
|
||||
return None
|
||||
size = dest.stat().st_size
|
||||
with dest.open("rb") as fh:
|
||||
head = fh.read(4)
|
||||
if size < MIN_BYTES or not head.startswith(MAGIC[ext]):
|
||||
dest.unlink(missing_ok=True)
|
||||
return None
|
||||
return dest
|
||||
|
||||
|
||||
def _ads_key(client: httpx.Client, md5: str, pacer: Pacer) -> str | None:
|
||||
pacer.wait()
|
||||
try:
|
||||
r = client.get(
|
||||
f"{LIBGEN}/ads.php",
|
||||
params={"md5": md5},
|
||||
headers={"Referer": f"{LIBGEN}/"},
|
||||
timeout=30,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
m = _GET_LINK.search(r.text)
|
||||
return m.group(2) if m and m.group(1) == md5 else None
|
||||
|
||||
|
||||
def fetch_libgen(
|
||||
client: httpx.Client,
|
||||
cand: Candidate,
|
||||
dest: Path,
|
||||
*,
|
||||
pacer: Pacer,
|
||||
sleep: Callable[[float], None],
|
||||
) -> Path | None:
|
||||
md5 = cand.md5 or ""
|
||||
key = _ads_key(client, md5, pacer)
|
||||
if key is None:
|
||||
sleep(_ADS_RETRY_SECONDS)
|
||||
key = _ads_key(client, md5, pacer)
|
||||
if key is None:
|
||||
return None
|
||||
return download(
|
||||
client,
|
||||
f"{LIBGEN}/get.php?md5={md5}&key={key}",
|
||||
dest,
|
||||
ext=cand.ext,
|
||||
headers={"Referer": f"{LIBGEN}/ads.php?md5={md5}"},
|
||||
)
|
||||
|
||||
|
||||
def fetch_annas(
|
||||
client: httpx.Client, cand: Candidate, dest: Path, *, budget: Budget
|
||||
) -> Path | None:
|
||||
try:
|
||||
r = client.get(
|
||||
f"{ANNAS}/dyn/api/fast_download.json",
|
||||
params={"md5": cand.md5 or "", "key": budget.annas_key},
|
||||
timeout=30,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
if r.status_code in (401, 403):
|
||||
raise AnnasAuthError(
|
||||
f"Anna's Archive refused the key ({r.status_code}): {r.text[:200]}"
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
budget.annas_disabled = True
|
||||
return None
|
||||
try:
|
||||
data = r.json()
|
||||
except ValueError:
|
||||
return None
|
||||
info = data.get("account_fast_download_info") or {}
|
||||
if isinstance(info.get("downloads_left"), int):
|
||||
budget.annas_left = info["downloads_left"]
|
||||
url = data.get("download_url")
|
||||
if not url:
|
||||
return None
|
||||
return download(client, url, dest, ext=cand.ext)
|
||||
|
||||
|
||||
def fetch(
|
||||
cand: Candidate,
|
||||
dest: Path,
|
||||
*,
|
||||
direct: httpx.Client,
|
||||
proxied: httpx.Client,
|
||||
budget: Budget,
|
||||
pacer: Pacer,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> str | None:
|
||||
"""Deliver *cand* to *dest*; returns the source that worked, else None.
|
||||
|
||||
Sets ``budget.quota_hit`` when Anna's would have been tried but the
|
||||
reserve (or an earlier API failure) blocked it.
|
||||
"""
|
||||
if cand.source == "unpaywall":
|
||||
got = download(direct, cand.url or "", dest, ext=cand.ext)
|
||||
return "unpaywall" if got else None
|
||||
if fetch_libgen(proxied, cand, dest, pacer=pacer, sleep=sleep):
|
||||
return "libgen"
|
||||
if budget.can_use_annas():
|
||||
return "annas" if fetch_annas(proxied, cand, dest, budget=budget) else None
|
||||
if budget.annas_key:
|
||||
budget.quota_hit = True
|
||||
return None
|
||||
1
tests/zot/fetch/fixtures/annas_fast_download.json
Normal file
1
tests/zot/fetch/fixtures/annas_fast_download.json
Normal file
@@ -0,0 +1 @@
|
||||
{"download_url": "https://fast.example.org/dl/a78add1bd62047ead5945134488f470f.pdf", "account_fast_download_info": {"downloads_left": 42, "downloads_per_day": 50, "recently_downloaded_md5s": []}}
|
||||
1
tests/zot/fetch/fixtures/libgen_ads.html
Normal file
1
tests/zot/fetch/fixtures/libgen_ads.html
Normal file
@@ -0,0 +1 @@
|
||||
<html><body><table><tr><td><a href="get.php?md5=a78add1bd62047ead5945134488f470f&key=AJPW7IO3P1LGRGWA">GET</a></td></tr></table></body></html>
|
||||
341
tests/zot/fetch/test_deliver.py
Normal file
341
tests/zot/fetch/test_deliver.py
Normal file
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from zot.fetch.deliver import (
|
||||
MIN_BYTES,
|
||||
AnnasAuthError,
|
||||
Budget,
|
||||
download,
|
||||
fetch,
|
||||
fetch_annas,
|
||||
fetch_libgen,
|
||||
)
|
||||
from zot.fetch.resolve import LIBGEN, Candidate, Pacer
|
||||
|
||||
FX = Path(__file__).parent / "fixtures"
|
||||
PDF = b"%PDF-1.4\n" + b"0" * MIN_BYTES
|
||||
EPUB = b"PK\x03\x04" + b"0" * MIN_BYTES
|
||||
MD5 = "a78add1bd62047ead5945134488f470f"
|
||||
ADS = (FX / "libgen_ads.html").read_text()
|
||||
ANNAS_OK = (FX / "annas_fast_download.json").read_text()
|
||||
PDF_HDR = {"content-type": "application/pdf"}
|
||||
|
||||
|
||||
def _pacer() -> Pacer:
|
||||
return Pacer(3.0, clock=lambda: 0.0, sleep=lambda s: None)
|
||||
|
||||
|
||||
def _client(handler) -> httpx.Client:
|
||||
return httpx.Client(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _lg(md5=MD5) -> Candidate:
|
||||
return Candidate("libgen", md5, None, "pdf", None, "T")
|
||||
|
||||
|
||||
def _up() -> Candidate:
|
||||
return Candidate("unpaywall", None, "https://example.org/x.pdf", "pdf", None, "T")
|
||||
|
||||
|
||||
def _nosleep(s: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# ── download ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status, ctype, body, ext, ok",
|
||||
[
|
||||
(200, "application/pdf", PDF, "pdf", True),
|
||||
(200, "application/octet-stream", PDF, "pdf", True),
|
||||
(200, "application/epub+zip", EPUB, "epub", True),
|
||||
(200, "text/html; charset=utf-8", PDF, "pdf", False),
|
||||
(200, "application/pdf", b"%PDF tiny", "pdf", False),
|
||||
(200, "application/pdf", b"<html>" + b"0" * MIN_BYTES, "pdf", False),
|
||||
(503, "application/pdf", PDF, "pdf", False),
|
||||
],
|
||||
)
|
||||
def test_download_validates_status_type_size_and_magic(
|
||||
tmp_path, status, ctype, body, ext, ok
|
||||
):
|
||||
c = _client(
|
||||
lambda r: httpx.Response(status, content=body, headers={"content-type": ctype})
|
||||
)
|
||||
dest = tmp_path / f"f.{ext}"
|
||||
got = download(c, "https://x/y", dest, ext=ext)
|
||||
assert (got == dest) is ok
|
||||
assert dest.exists() is ok
|
||||
|
||||
|
||||
def test_download_transport_error_is_none(tmp_path):
|
||||
def boom(r):
|
||||
raise httpx.ReadTimeout("t")
|
||||
|
||||
assert download(_client(boom), "https://x", tmp_path / "f.pdf", ext="pdf") is None
|
||||
|
||||
|
||||
def test_download_passes_headers(tmp_path):
|
||||
seen = {}
|
||||
|
||||
def h(r):
|
||||
seen.update(r.headers)
|
||||
return httpx.Response(200, content=PDF, headers=PDF_HDR)
|
||||
|
||||
download(
|
||||
_client(h),
|
||||
"https://x",
|
||||
tmp_path / "f.pdf",
|
||||
ext="pdf",
|
||||
headers={"Referer": "https://r/"},
|
||||
)
|
||||
assert seen["referer"] == "https://r/"
|
||||
|
||||
|
||||
# ── libgen ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _libgen_handler(ads_pages: list[httpx.Response], get_status=200):
|
||||
calls: list[str] = []
|
||||
|
||||
def h(r: httpx.Request) -> httpx.Response:
|
||||
calls.append(r.url.path)
|
||||
if r.url.path == "/ads.php":
|
||||
assert r.url.params["md5"] == MD5
|
||||
assert r.headers["referer"] == f"{LIBGEN}/"
|
||||
return ads_pages.pop(0)
|
||||
if r.url.path == "/get.php":
|
||||
assert r.url.params["key"] == "AJPW7IO3P1LGRGWA"
|
||||
return httpx.Response(get_status, content=PDF, headers=PDF_HDR)
|
||||
raise AssertionError(r.url)
|
||||
|
||||
return h, calls
|
||||
|
||||
|
||||
def test_fetch_libgen_ads_then_get(tmp_path):
|
||||
h, calls = _libgen_handler([httpx.Response(200, text=ADS)])
|
||||
got = fetch_libgen(
|
||||
_client(h), _lg(), tmp_path / "f.pdf", pacer=_pacer(), sleep=_nosleep
|
||||
)
|
||||
assert got == tmp_path / "f.pdf" and calls == ["/ads.php", "/get.php"]
|
||||
|
||||
|
||||
def test_libgen_ads_error_div_retries_once_then_gives_up(tmp_path):
|
||||
err = httpx.Response(
|
||||
200,
|
||||
text='<div class="alert alert-danger">Could not connect to the database 3306</div>',
|
||||
)
|
||||
h, calls = _libgen_handler([err, err])
|
||||
slept: list[float] = []
|
||||
got = fetch_libgen(
|
||||
_client(h), _lg(), tmp_path / "f.pdf", pacer=_pacer(), sleep=slept.append
|
||||
)
|
||||
assert got is None and calls == ["/ads.php", "/ads.php"] and slept == [3.0]
|
||||
|
||||
|
||||
def test_libgen_ads_error_then_success_on_retry(tmp_path):
|
||||
h, calls = _libgen_handler([httpx.Response(500), httpx.Response(200, text=ADS)])
|
||||
got = fetch_libgen(
|
||||
_client(h), _lg(), tmp_path / "f.pdf", pacer=_pacer(), sleep=_nosleep
|
||||
)
|
||||
assert got is not None and calls == ["/ads.php", "/ads.php", "/get.php"]
|
||||
|
||||
|
||||
def test_libgen_ads_link_for_other_md5_is_ignored(tmp_path):
|
||||
other = httpx.Response(200, text=ADS.replace(MD5, "f" * 32))
|
||||
h, calls = _libgen_handler([other, other])
|
||||
got = fetch_libgen(
|
||||
_client(h), _lg(), tmp_path / "f.pdf", pacer=_pacer(), sleep=_nosleep
|
||||
)
|
||||
assert got is None and calls == ["/ads.php", "/ads.php"]
|
||||
|
||||
|
||||
def test_libgen_get_503_is_none(tmp_path):
|
||||
h, _ = _libgen_handler([httpx.Response(200, text=ADS)], get_status=503)
|
||||
got = fetch_libgen(
|
||||
_client(h), _lg(), tmp_path / "f.pdf", pacer=_pacer(), sleep=_nosleep
|
||||
)
|
||||
assert got is None
|
||||
|
||||
|
||||
def test_libgen_transport_error_is_none(tmp_path):
|
||||
def boom(r):
|
||||
raise httpx.ConnectError("x")
|
||||
|
||||
got = fetch_libgen(
|
||||
_client(boom), _lg(), tmp_path / "f.pdf", pacer=_pacer(), sleep=_nosleep
|
||||
)
|
||||
assert got is None
|
||||
|
||||
|
||||
# ── Anna's ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _annas_handler(api: httpx.Response, file_status=200):
|
||||
def h(r: httpx.Request) -> httpx.Response:
|
||||
if r.url.host == "annas-archive.gl":
|
||||
assert r.url.path == "/dyn/api/fast_download.json"
|
||||
assert r.url.params["md5"] == MD5 and r.url.params["key"] == "SECRET"
|
||||
return api
|
||||
return httpx.Response(file_status, content=PDF, headers=PDF_HDR)
|
||||
|
||||
return h
|
||||
|
||||
|
||||
def test_fetch_annas_downloads_and_updates_budget(tmp_path):
|
||||
b = Budget(annas_key="SECRET")
|
||||
c = _client(_annas_handler(httpx.Response(200, text=ANNAS_OK)))
|
||||
got = fetch_annas(c, _lg(), tmp_path / "f.pdf", budget=b)
|
||||
assert got == tmp_path / "f.pdf" and b.annas_left == 42
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
def test_fetch_annas_auth_errors_stop_the_run(tmp_path, status):
|
||||
b = Budget(annas_key="SECRET")
|
||||
c = _client(_annas_handler(httpx.Response(status, json={"error": "Not a member"})))
|
||||
with pytest.raises(AnnasAuthError):
|
||||
fetch_annas(c, _lg(), tmp_path / "f.pdf", budget=b)
|
||||
|
||||
|
||||
def test_fetch_annas_other_error_disables_annas(tmp_path):
|
||||
b = Budget(annas_key="SECRET")
|
||||
c = _client(_annas_handler(httpx.Response(500)))
|
||||
assert fetch_annas(c, _lg(), tmp_path / "f.pdf", budget=b) is None
|
||||
assert b.annas_disabled is True and b.can_use_annas() is False
|
||||
|
||||
|
||||
def test_fetch_annas_null_download_url_is_none(tmp_path):
|
||||
b = Budget(annas_key="SECRET")
|
||||
api = httpx.Response(200, json={"download_url": None, "error": "Invalid md5"})
|
||||
assert (
|
||||
fetch_annas(_client(_annas_handler(api)), _lg(), tmp_path / "f.pdf", budget=b)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_annas_bad_json_or_transport_is_none(tmp_path):
|
||||
b = Budget(annas_key="SECRET")
|
||||
c = _client(_annas_handler(httpx.Response(200, text="nope")))
|
||||
assert fetch_annas(c, _lg(), tmp_path / "f.pdf", budget=b) is None
|
||||
|
||||
def boom(r):
|
||||
raise httpx.ConnectError("x")
|
||||
|
||||
assert fetch_annas(_client(boom), _lg(), tmp_path / "f.pdf", budget=b) is None
|
||||
|
||||
|
||||
def test_fetch_annas_file_failure_still_reports_budget(tmp_path):
|
||||
b = Budget(annas_key="SECRET")
|
||||
c = _client(_annas_handler(httpx.Response(200, text=ANNAS_OK), file_status=503))
|
||||
got = fetch_annas(c, _lg(), tmp_path / "f.pdf", budget=b)
|
||||
assert got is None and b.annas_left == 42 # the API call itself consumed one
|
||||
|
||||
|
||||
def test_budget_reserve_blocks_annas():
|
||||
assert Budget(annas_key="").can_use_annas() is False
|
||||
assert Budget(annas_key="k").can_use_annas() is True # unknown budget → allowed
|
||||
assert Budget(annas_key="k", annas_left=6, reserve=5).can_use_annas() is True
|
||||
assert Budget(annas_key="k", annas_left=5, reserve=5).can_use_annas() is False
|
||||
|
||||
|
||||
# ── fetch (the cascade) ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fetch_unpaywall_uses_direct_client(tmp_path):
|
||||
direct = _client(lambda r: httpx.Response(200, content=PDF, headers=PDF_HDR))
|
||||
|
||||
def never(r):
|
||||
raise AssertionError("proxied client used for Unpaywall")
|
||||
|
||||
src = fetch(
|
||||
_up(),
|
||||
tmp_path / "f.pdf",
|
||||
direct=direct,
|
||||
proxied=_client(never),
|
||||
budget=Budget(),
|
||||
pacer=_pacer(),
|
||||
)
|
||||
assert src == "unpaywall"
|
||||
|
||||
|
||||
def test_fetch_unpaywall_failure_is_none(tmp_path):
|
||||
direct = _client(lambda r: httpx.Response(404))
|
||||
src = fetch(
|
||||
_up(),
|
||||
tmp_path / "f.pdf",
|
||||
direct=direct,
|
||||
proxied=direct,
|
||||
budget=Budget(),
|
||||
pacer=_pacer(),
|
||||
)
|
||||
assert src is None
|
||||
|
||||
|
||||
def test_fetch_libgen_then_annas_on_failure(tmp_path):
|
||||
def h(r: httpx.Request) -> httpx.Response:
|
||||
if r.url.path == "/ads.php":
|
||||
return httpx.Response(500)
|
||||
if r.url.host == "annas-archive.gl":
|
||||
return httpx.Response(200, text=ANNAS_OK)
|
||||
return httpx.Response(200, content=PDF, headers=PDF_HDR)
|
||||
|
||||
b = Budget(annas_key="SECRET")
|
||||
src = fetch(
|
||||
_lg(),
|
||||
tmp_path / "f.pdf",
|
||||
direct=_client(h),
|
||||
proxied=_client(h),
|
||||
budget=b,
|
||||
pacer=_pacer(),
|
||||
sleep=_nosleep,
|
||||
)
|
||||
assert src == "annas" and b.annas_left == 42
|
||||
|
||||
|
||||
def test_fetch_libgen_success_skips_annas(tmp_path):
|
||||
h, calls = _libgen_handler([httpx.Response(200, text=ADS)])
|
||||
b = Budget(annas_key="SECRET")
|
||||
src = fetch(
|
||||
_lg(),
|
||||
tmp_path / "f.pdf",
|
||||
direct=_client(h),
|
||||
proxied=_client(h),
|
||||
budget=b,
|
||||
pacer=_pacer(),
|
||||
sleep=_nosleep,
|
||||
)
|
||||
assert src == "libgen" and calls == ["/ads.php", "/get.php"]
|
||||
|
||||
|
||||
def test_fetch_marks_quota_when_annas_blocked_by_reserve(tmp_path):
|
||||
proxied = _client(lambda r: httpx.Response(500))
|
||||
b = Budget(annas_key="SECRET", annas_left=2, reserve=5)
|
||||
src = fetch(
|
||||
_lg(),
|
||||
tmp_path / "f.pdf",
|
||||
direct=proxied,
|
||||
proxied=proxied,
|
||||
budget=b,
|
||||
pacer=_pacer(),
|
||||
sleep=_nosleep,
|
||||
)
|
||||
assert src is None and b.quota_hit is True
|
||||
|
||||
|
||||
def test_fetch_all_fail_without_annas_is_not_quota(tmp_path):
|
||||
proxied = _client(lambda r: httpx.Response(500))
|
||||
b = Budget()
|
||||
src = fetch(
|
||||
_lg(),
|
||||
tmp_path / "f.pdf",
|
||||
direct=proxied,
|
||||
proxied=proxied,
|
||||
budget=b,
|
||||
pacer=_pacer(),
|
||||
sleep=_nosleep,
|
||||
)
|
||||
assert src is None and b.quota_hit is False
|
||||
Reference in New Issue
Block a user