Skip to content

Commit 042fa8a

Browse files
authored
Merge pull request #33 from pullboxapp/feature/annas-search-redirect
fix: make Anna's Archive search fallback provider-safe
2 parents a2d2921 + 305bcd5 commit 042fa8a

10 files changed

Lines changed: 510 additions & 26 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ quota, source availability, and malformed responses remain distinct failures.
181181
Opening search-result details must not call resolve because a fast-link request
182182
may consume source quota.
183183

184+
Search attempts the selected official Anna's Archive page first. When that page
185+
is blocked by a browser challenge, is temporarily unavailable, or returns no
186+
candidates, the provider performs a bounded fallback against the LibGen comics
187+
catalog. Only candidates with a matching lowercase LibGen ID and MD5 content
188+
fingerprint are considered for Anna's Archive discovery, with canonical files
189+
listed before mobile derivatives. Catalog presence does not guarantee that Anna
190+
offers a member fast-download route, so availability is verified only when the
191+
user grabs the result. Catalog-derived candidates intentionally do not expose a
192+
cross-provider fingerprint to Pullbox: if Anna cannot resolve the record,
193+
Pullbox reports that failure instead of silently downloading it from LibGen.
194+
Resolution still uses the official member fast-download JSON API, and the member
195+
secret is never sent to LibGen. This fallback covers only Anna's Archive records
196+
sourced from LibGen and does not claim parity with Anna's Archive's complete
197+
catalog. The Anna's Archive image includes the catalog-discovery dependency and
198+
does not require a separate LibGen provider container.
199+
184200
Successful resolves may report provider-generic remaining/limit/window quota
185201
telemetry. The response intentionally excludes account identity and download
186202
history. Pullbox stores only the latest capacity observation, applies its

docker/Dockerfile.annas-archive

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
1717
WORKDIR /build
1818

1919
COPY packages/provider_contract packages/provider_contract
20+
COPY providers/libgen providers/libgen
2021
COPY providers/annas_archive providers/annas_archive
2122

2223
RUN python -m pip wheel --wheel-dir /wheels ./packages/provider_contract && \
24+
python -m pip wheel --find-links /wheels --wheel-dir /wheels ./providers/libgen && \
2325
python -m pip wheel --find-links /wheels --wheel-dir /wheels ./providers/annas_archive
2426

2527
FROM security-patched AS runtime

packages/provider_contract/src/pullbox_provider_contract/source_http.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import asyncio
66
from collections.abc import Awaitable, Callable, Mapping, Sequence
77
from typing import TYPE_CHECKING
8-
from urllib.parse import urlsplit
8+
from urllib.parse import urljoin, urlsplit
99

1010
import httpx
1111

@@ -24,6 +24,7 @@
2424
_MAX_SOURCE_BYTES = 8 * 1024 * 1024
2525
_MAX_REDIRECT_URL_LENGTH = 4_000
2626
_REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
27+
_MAX_SOURCE_REDIRECTS = 1
2728

2829
BrowserResolver = Callable[..., Awaitable[ProviderResolverOutcome | None]]
2930

@@ -62,19 +63,37 @@ async def fetch_source_html(
6263
response: httpx.Response | None = None
6364
try:
6465
try:
65-
response = await client.send(
66-
client.build_request("GET", safe_url, headers={"Accept": "text/html"}),
67-
stream=True,
68-
follow_redirects=False,
69-
)
70-
body = await _read_bounded(response)
66+
for redirect_count in range(_MAX_SOURCE_REDIRECTS + 1):
67+
response = await client.send(
68+
client.build_request("GET", safe_url, headers={"Accept": "text/html"}),
69+
stream=True,
70+
follow_redirects=False,
71+
)
72+
if response.status_code not in _REDIRECT_STATUS_CODES:
73+
body = await _read_bounded(response)
74+
break
75+
if redirect_count >= _MAX_SOURCE_REDIRECTS:
76+
raise RuntimeError("Source redirect limit was exceeded.")
77+
redirected_url = _same_origin_source_redirect(
78+
safe_url,
79+
response.headers.get("location"),
80+
)
81+
await response.aclose()
82+
response = None
83+
safe_url = await _validate_source_url(
84+
redirected_url,
85+
declared_domains,
86+
resolver=target_resolver,
87+
)
88+
else: # pragma: no cover - bounded loop always returns or raises
89+
raise RuntimeError("Source redirect limit was exceeded.")
7190
except asyncio.CancelledError:
7291
raise
7392
except (httpx.HTTPError, TimeoutError) as exc:
7493
raise RuntimeError("Source request is temporarily unavailable.") from exc
7594

76-
if response.status_code in {301, 302, 303, 307, 308}:
77-
raise RuntimeError("Source redirect was rejected.")
95+
if response is None: # pragma: no cover - defensive invariant
96+
raise RuntimeError("Source request did not return a response.")
7897
ordinary = OrdinaryHttpResponse(
7998
status_code=response.status_code,
8099
headers=_safe_headers(response.headers),
@@ -109,6 +128,32 @@ async def fetch_source_html(
109128
await client.aclose()
110129

111130

131+
def _same_origin_source_redirect(source_url: str, raw_location: str | None) -> str:
132+
if not raw_location or len(raw_location) > _MAX_REDIRECT_URL_LENGTH:
133+
raise RuntimeError("Source redirect destination is invalid.")
134+
try:
135+
source = urlsplit(source_url)
136+
destination_url = urljoin(source_url, raw_location.strip())
137+
destination = urlsplit(destination_url)
138+
source_port = source.port or 443
139+
destination_port = destination.port or 443
140+
except ValueError as exc:
141+
raise RuntimeError("Source redirect destination is invalid.") from exc
142+
if (
143+
source.scheme != "https"
144+
or destination.scheme != "https"
145+
or not source.hostname
146+
or not destination.hostname
147+
or source.hostname.casefold().rstrip(".") != destination.hostname.casefold().rstrip(".")
148+
or source_port != destination_port
149+
or destination.username
150+
or destination.password
151+
or destination.fragment
152+
):
153+
raise RuntimeError("Source redirect destination was rejected.")
154+
return destination_url
155+
156+
112157
async def resolve_source_redirect(
113158
raw_url: str,
114159
*,

providers/annas_archive/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
1010
license = "GPL-3.0-or-later"
1111
dependencies = [
1212
"pullbox-direct-provider-contract==1.0.0",
13+
"pullbox-provider-libgen==0.1.0",
1314
"uvicorn>=0.34,<1",
1415
]
1516

providers/annas_archive/src/pullbox_provider_annas_archive/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from pullbox_provider_contract.source_http import BrowserChallengeRequiredError
3131

3232
from pullbox_provider_annas_archive.service import (
33+
CATALOG_FALLBACK_DOMAINS,
3334
DEFAULT_OFFICIAL_URL,
3435
SUPPORTED_OFFICIAL_DOMAINS,
3536
SUPPORTED_OFFICIAL_URLS,
@@ -76,7 +77,7 @@ async def manifest() -> ManifestResponse:
7677
homepage_url="https://github.com/pullboxapp/pullbox-direct-providers",
7778
documentation_url="https://github.com/pullboxapp/pullbox-direct-providers",
7879
support_url="https://github.com/pullboxapp/pullbox-direct-providers/issues",
79-
source_domains=list(SUPPORTED_OFFICIAL_DOMAINS),
80+
source_domains=[*SUPPORTED_OFFICIAL_DOMAINS, *CATALOG_FALLBACK_DOMAINS],
8081
artifact_host_patterns=["generic_https"],
8182
capabilities=ProviderCapabilities(
8283
search=True,

providers/annas_archive/src/pullbox_provider_annas_archive/service.py

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
from collections.abc import Awaitable, Callable, Mapping, Sequence
88
from dataclasses import dataclass
9-
from typing import TYPE_CHECKING
9+
from typing import TYPE_CHECKING, Protocol
1010
from urllib.parse import urlencode, urlsplit
1111

1212
import httpx
@@ -22,6 +22,7 @@
2222
)
2323
from pullbox_provider_contract.search_terms import collection_title_fragment, is_collection_intent
2424
from pullbox_provider_contract.source_http import fetch_source_html
25+
from pullbox_provider_libgen.service import KNOWN_SOURCE_DOMAINS, LibGenProviderService
2526

2627
from pullbox_provider_annas_archive.parser import parse_search_html
2728

@@ -34,6 +35,7 @@
3435
"annas-archive.gd",
3536
)
3637
SUPPORTED_OFFICIAL_URLS = tuple(f"https://{domain}" for domain in SUPPORTED_OFFICIAL_DOMAINS)
38+
CATALOG_FALLBACK_DOMAINS = KNOWN_SOURCE_DOMAINS
3739
DEFAULT_OFFICIAL_URL = "https://annas-archive.gd"
3840
_MD5 = re.compile(r"\A[a-f0-9]{32}\Z")
3941
_MAX_JSON_BYTES = 256 * 1024
@@ -44,6 +46,17 @@
4446
FastDownloadFetcher = Callable[..., Awaitable[tuple[int, dict[str, object]]]]
4547

4648

49+
class CatalogFallback(Protocol):
50+
async def search(
51+
self,
52+
intent: SearchIntent,
53+
*,
54+
provider_config: Mapping[str, object],
55+
limit: int,
56+
resolver_profile: ResolverProfile | None = None,
57+
) -> list[Candidate]: ...
58+
59+
4760
@dataclass(frozen=True, slots=True)
4861
class AnnasArchiveResolveResult:
4962
"""Resolved artifacts plus safe source-account capacity telemetry."""
@@ -60,9 +73,11 @@ def __init__(
6073
*,
6174
page_fetcher: PageFetcher = fetch_source_html,
6275
fast_download_fetcher: FastDownloadFetcher | None = None,
76+
catalog_fallback: CatalogFallback | None = None,
6377
) -> None:
6478
self._page_fetcher = page_fetcher
6579
self._fast_download_fetcher = fast_download_fetcher or _fetch_fast_download
80+
self._catalog_fallback = catalog_fallback or LibGenProviderService()
6681

6782
async def search(
6883
self,
@@ -75,15 +90,43 @@ async def search(
7590
domain = validate_official_domain(str(provider_config.get("domain", DEFAULT_OFFICIAL_URL)))
7691
query = _build_query(intent)
7792
params = urlencode([("q", query), ("ext", "cbz"), ("ext", "cbr"), ("ext", "pdf")])
78-
html = await self._page_fetcher(
79-
f"{domain}/search?{params}",
80-
declared_domains=((urlsplit(domain).hostname or ""),),
81-
resolver_profile=resolver_profile,
93+
primary_error: RuntimeError | None = None
94+
try:
95+
html = await self._page_fetcher(
96+
f"{domain}/search?{params}",
97+
declared_domains=((urlsplit(domain).hostname or ""),),
98+
resolver_profile=resolver_profile,
99+
)
100+
candidates = parse_search_html(
101+
html,
102+
source_domain=urlsplit(domain).hostname or "annas-archive.gd",
103+
)[:limit]
104+
except asyncio.CancelledError:
105+
raise
106+
except RuntimeError as exc:
107+
primary_error = exc
108+
else:
109+
if candidates:
110+
return candidates
111+
112+
try:
113+
catalog_candidates = await self._catalog_fallback.search(
114+
intent,
115+
provider_config={},
116+
limit=limit,
117+
resolver_profile=resolver_profile,
118+
)
119+
except asyncio.CancelledError:
120+
raise
121+
except (RuntimeError, ValueError):
122+
if primary_error is not None:
123+
raise primary_error from None
124+
return []
125+
return _anna_candidates_from_catalog(
126+
catalog_candidates,
127+
domain=domain,
128+
limit=limit,
82129
)
83-
return parse_search_html(
84-
html,
85-
source_domain=urlsplit(domain).hostname or "annas-archive.gd",
86-
)[:limit]
87130

88131
async def resolve(
89132
self,
@@ -293,6 +336,52 @@ def _build_query(intent: SearchIntent) -> str:
293336
return " ".join(parts)[:700]
294337

295338

339+
def _anna_candidates_from_catalog(
340+
catalog_candidates: Sequence[Candidate],
341+
*,
342+
domain: str,
343+
limit: int,
344+
) -> list[Candidate]:
345+
candidates: list[Candidate] = []
346+
seen: set[str] = set()
347+
for candidate in sorted(catalog_candidates, key=_catalog_candidate_rank):
348+
rank = _catalog_candidate_rank(candidate)
349+
fingerprint = candidate.content_fingerprint or ""
350+
md5 = fingerprint.removeprefix("md5:") if fingerprint.startswith("md5:") else ""
351+
if (
352+
not _MD5.fullmatch(md5)
353+
or candidate.provider_candidate_id != f"libgen:{md5}"
354+
or md5 in seen
355+
):
356+
continue
357+
seen.add(md5)
358+
candidates.append(
359+
candidate.model_copy(
360+
update={
361+
"provider_candidate_id": f"anna:{md5}",
362+
"source_reference": f"{domain}/md5/{md5}",
363+
# Catalog presence does not prove that Anna exposes a
364+
# member fast-download route for this exact LibGen file.
365+
"content_fingerprint": None,
366+
"provider_confidence": max(candidate.provider_confidence - (0.05 * rank), 0),
367+
"provenance": {
368+
"layout": "libgen-catalog-fallback-v1",
369+
"source_kind": "metadata",
370+
"catalog_source": "libgen",
371+
},
372+
}
373+
)
374+
)
375+
if len(candidates) == limit:
376+
break
377+
return candidates
378+
379+
380+
def _catalog_candidate_rank(candidate: Candidate) -> int:
381+
"""Prefer canonical files over mobile derivatives while keeping source order."""
382+
return 1 if "digital-mobile" in candidate.display_title.casefold() else 0
383+
384+
296385
def _safe_download_url(raw_url: str) -> bool:
297386
return _safe_download_origin(raw_url) is not None
298387

@@ -367,7 +456,10 @@ def _is_quota_error(payload: Mapping[str, object]) -> bool:
367456

368457
def _is_candidate_unavailable(payload: Mapping[str, object]) -> bool:
369458
error = payload.get("error")
370-
return isinstance(error, str) and "invalid domain_index or path_index" in error.casefold()
459+
return isinstance(error, str) and any(
460+
marker in error.casefold()
461+
for marker in ("invalid domain_index or path_index", "record not found")
462+
)
371463

372464

373465
def _quota_status(payload: Mapping[str, object]) -> QuotaStatus | None:

tests/unit/test_annas_archive_app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ async def test_anna_manifest_marks_member_key_secret_and_official_urls_editable(
106106
"annas-archive.gl",
107107
"annas-archive.pk",
108108
"annas-archive.gd",
109+
"libgen.gl",
110+
"libgen.li",
111+
"libgen.vg",
112+
"libgen.la",
113+
"libgen.bz",
109114
]
110115
assert payload["artifact_host_patterns"] == ["generic_https"]
111116
schema = payload["configuration_schema"]

0 commit comments

Comments
 (0)