66import re
77from collections .abc import Awaitable , Callable , Mapping , Sequence
88from dataclasses import dataclass
9- from typing import TYPE_CHECKING
9+ from typing import TYPE_CHECKING , Protocol
1010from urllib .parse import urlencode , urlsplit
1111
1212import httpx
2222)
2323from pullbox_provider_contract .search_terms import collection_title_fragment , is_collection_intent
2424from pullbox_provider_contract .source_http import fetch_source_html
25+ from pullbox_provider_libgen .service import KNOWN_SOURCE_DOMAINS , LibGenProviderService
2526
2627from pullbox_provider_annas_archive .parser import parse_search_html
2728
3435 "annas-archive.gd" ,
3536)
3637SUPPORTED_OFFICIAL_URLS = tuple (f"https://{ domain } " for domain in SUPPORTED_OFFICIAL_DOMAINS )
38+ CATALOG_FALLBACK_DOMAINS = KNOWN_SOURCE_DOMAINS
3739DEFAULT_OFFICIAL_URL = "https://annas-archive.gd"
3840_MD5 = re .compile (r"\A[a-f0-9]{32}\Z" )
3941_MAX_JSON_BYTES = 256 * 1024
4446FastDownloadFetcher = 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 )
4861class 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+
296385def _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
368457def _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
373465def _quota_status (payload : Mapping [str , object ]) -> QuotaStatus | None :
0 commit comments