"""Platform Presence Scanner — discover where a book is listed across retailers. Scores distribution coverage out of 10, saves admin-only listing URLs, and suggests missing platforms with realistic benefit copy. """ from __future__ import annotations import asyncio import hashlib import json import re import urllib.parse from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from typing import Literal import httpx import structlog from app.services.book_url_scraper import ( PLATFORM_ICONS, _browser_headers, _normalize_isbn, ) from app.services.catalog_hub import ( build_catalog_snapshot, discover_platform_hit, normalize_source_platform, ) from app.services.isbn_catalog import ( classify_retailer_url, extract_amazon_asin, is_confirmed_isbn13, sanitize_platform_url, ) logger = structlog.get_logger(__name__) _SCAN_TIMEOUT = 10.0 _MAX_CONCURRENT = 6 _SCAN_CACHE_PREFIX = "platform_scan:v10:" _SCAN_CACHE_TTL = 21_600 # 6h Status = Literal["verified", "likely", "not_found", "checking", "error", "skipped"] VERIFIED_THRESHOLD = 0.85 LIKELY_THRESHOLD = 0.70 PLATFORM_BENEFITS: dict[str, str] = { "amazon": "Largest US ebook and print discovery channel", "barnes_noble": "US brick-and-mortar and Nook readers", "goodreads": "Social discovery and reader reviews", "google_books": "Google Books catalog and preview reach", "apple_books": "Reach readers on iPhone, iPad, and Mac", "kobo": "Strong global indie and Rakuten audience", "thriftbooks": "Secondary-market demand signal", "abebooks": "Used, rare, and long-tail collector buyers", "bookshop": "Supports indie bookstores with affiliate margin", "open_library": "Free open catalog and lending discovery", } SCORED_PLATFORMS: list[dict] = [ {"platform_id": "amazon", "display_name": "Amazon", "weight": 2.0}, {"platform_id": "barnes_noble", "display_name": "Barnes & Noble", "weight": 1.0}, {"platform_id": "goodreads", "display_name": "Goodreads", "weight": 1.0}, {"platform_id": "google_books", "display_name": "Google Books", "weight": 1.2}, {"platform_id": "apple_books", "display_name": "Apple Books", "weight": 1.2}, {"platform_id": "kobo", "display_name": "Kobo", "weight": 1.0}, {"platform_id": "thriftbooks", "display_name": "ThriftBooks", "weight": 0.8}, {"platform_id": "abebooks", "display_name": "AbeBooks", "weight": 0.6}, {"platform_id": "bookshop", "display_name": "Bookshop.org", "weight": 0.8}, {"platform_id": "open_library", "display_name": "Open Library", "weight": 0.8}, ] @dataclass class PlatformListing: """Single platform scan result.""" platform_id: str display_name: str status: Status confidence: float listing_url: str | None benefit: str weight: float icon: str = "" price: str | None = None price_currency: str | None = None @dataclass class PlatformPresenceResult: """Full scan output for a book.""" score: float listed_count: int scanned_at: str source_isbn: str | None platforms: list[PlatformListing] = field(default_factory=list) recommendations: list[dict] = field(default_factory=list) distribution_note: str | None = None def parse_goodreads_book_ref(url: str | None) -> tuple[str, str] | None: """Parse Goodreads book show URL into (book_id, book_path).""" if not url: return None m = re.search( r"goodreads\.com/book/show/(\d+)(?:[/-]([a-z0-9-]+))?", url, re.IGNORECASE, ) if not m: return None book_id = m.group(1) slug = (m.group(2) or "").strip("-") path = f"/book/show/{book_id}-{slug}" if slug else f"/book/show/{book_id}" return book_id, path def infer_distribution_note( platforms: list[PlatformListing], source_isbn: str | None = None, ) -> str | None: """Explain Kindle-only / narrow distribution so authors aren't misled.""" from app.services.isbn_catalog import is_confirmed_isbn13 listed = {p.platform_id for p in platforms if p.status == "verified"} if not listed: return None if is_confirmed_isbn13(source_isbn): return None if listed <= {"amazon", "goodreads"} and "amazon" in listed: return ( "This title appears to be Amazon Kindle / KDP only. " "Goodreads buy buttons are affiliate search links — not verified listings on " "Barnes & Noble, Kobo, or Apple Books." ) return None def scan_cache_key(url: str) -> str: """Redis key for caching scan results by source URL.""" digest = hashlib.sha256(url.strip().encode()).hexdigest()[:20] return f"{_SCAN_CACHE_PREFIX}{digest}" def search_title(raw: str) -> str: """Strip series/edition suffixes retailers omit from listing titles. Goodreads and similar sources often append series info like ``Her Deadly Homecoming (Carolina McKay, #1)`` — store pages use ``Her Deadly Homecoming`` only. """ title = re.sub(r"\s+", " ", (raw or "").strip()) if not title: return "" prev = None while prev != title: prev = title title = re.sub(r"\s*\([^)]+\)\s*$", "", title).strip() title = re.sub(r"\s*\[[^\]]+\]\s*$", "", title).strip() title = re.sub( r"\s*[:\-–—]\s*(Book|Bk\.?|Volume|Vol\.?)\s+\d+\s*$", "", title, flags=re.IGNORECASE, ).strip() title = re.sub(r"\s+#\d+\s*$", "", title).strip() title = _strip_marketing_subtitle(title) return title or (raw or "").strip() def _strip_marketing_subtitle(title: str) -> str: """Drop Amazon-style ': The gripping…' taglines that break catalog search.""" if not title: return "" if ":" in title: head, tail = title.split(":", 1) tail = tail.strip() if ( len(tail) >= 28 or re.match(r"^(the|a|an)\s", tail, re.IGNORECASE) ): return head.strip() for sep in (" – ", " — ", " - "): if sep in title: head, tail = title.split(sep, 1) if len(tail.strip()) >= 28: return head.strip() return title def _title_tokens(text: str) -> list[str]: """Normalize title into meaningful tokens for fuzzy matching.""" text = search_title(text) if not text: return [] cleaned = re.sub(r"[^\w\s]", " ", text.lower()) stop = {"the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for"} return [t for t in cleaned.split() if len(t) > 2 and t not in stop] def _author_tokens(author: str) -> list[str]: """Extract surname-weighted author tokens.""" if not author: return [] parts = re.split(r"[,;&]", author.lower()) tokens: list[str] = [] for part in parts: words = [w for w in re.sub(r"[^\w\s]", " ", part).split() if len(w) > 2] if words: tokens.append(words[-1]) tokens.extend(words[:-1]) return list(dict.fromkeys(tokens)) def title_match_score( title: str, found_title: str, author: str = "", found_author: str = "", ) -> float: """Compute 0.0–1.0 overlap between expected and discovered metadata.""" title = search_title(title) found_title = search_title(found_title) title_tokens = _title_tokens(title) if not title_tokens: return 0.0 found_lower = found_title.lower() matched = sum(1 for t in title_tokens if t in found_lower) score = matched / len(title_tokens) if author: auth_tokens = _author_tokens(author) if auth_tokens: found_auth = found_author.lower() auth_hit = sum(1 for t in auth_tokens if t in found_auth) score = score * 0.65 + (auth_hit / len(auth_tokens)) * 0.35 return min(score, 1.0) def confidence_to_status(confidence: float, isbn_match: bool = False) -> Status: """Binary presence: verified (listed) or not_found.""" if isbn_match or confidence >= VERIFIED_THRESHOLD: return "verified" return "not_found" def compute_score(platforms: list[PlatformListing]) -> float: """Count verified platforms out of 10 (same as listed_count).""" return float(sum(1 for p in platforms if p.status == "verified")) def build_recommendations(platforms: list[PlatformListing]) -> list[dict]: """Missing platforms sorted by weight with benefit copy.""" missing = [ p for p in platforms if p.status in ("not_found", "error", "skipped") ] missing.sort(key=lambda p: p.weight, reverse=True) return [ { "platform_id": p.platform_id, "display_name": p.display_name, "weight": p.weight, "benefit": p.benefit, "icon": p.icon, } for p in missing ] def _search_query(title: str, author: str, isbn: str | None) -> str: """Build a retailer search query from metadata.""" if isbn: return isbn core = search_title(title) or title.strip() parts = [core] if author.strip(): parts.append(author.strip()) return " ".join(parts) @dataclass class CatalogContext: """Cross-platform hints from Goodreads autocomplete + buy links.""" resolved_author: str = "" goodreads_id: str | None = None goodreads_url: str | None = None retailer_links: dict[str, str] = field(default_factory=dict) # Goodreads BookLink name → platform_id _BOOKLINK_PLATFORM_KEYS: list[tuple[str, tuple[str, ...]]] = [ ("amazon", ("amazon",)), ("barnes_noble", ("barnes", "noble")), ("kobo", ("kobo",)), ("google_books", ("google books", "google play")), ("google_play", ("google play",)), ("apple_books", ("apple books", "ibooks")), ("bookshop", ("bookshop",)), ("thriftbooks", ("thriftbooks",)), ("abebooks", ("abebooks",)), ("walmart", ("walmart",)), ("goodreads", ("goodreads",)), ] def _unescape_js_string(value: str) -> str: """Decode Goodreads embedded JSON string escapes.""" return ( value.replace("\\u0026", "&") .replace("\\u0027", "'") .replace("\\/", "/") .replace('\\"', '"') ) def parse_goodreads_book_links(html: str) -> dict[str, str]: """Extract retailer buy/search URLs from a Goodreads book page.""" if not html: return {} links: dict[str, str] = {} for match in re.finditer( r'BookLink","name":"((?:[^"\\]|\\.)*)","url":"((?:[^"\\]|\\.)*)"', html, ): name = _unescape_js_string(match.group(1)).lower() url = _unescape_js_string(match.group(2)) if not url.startswith("http"): continue for platform_id, keys in _BOOKLINK_PLATFORM_KEYS: if platform_id in links: continue if any(key in name for key in keys): links[platform_id] = url break return links async def _fetch_goodreads_book_page( client: httpx.AsyncClient, book_id: str, book_path: str = "", ) -> str: """Fetch Goodreads book HTML with slug/referer fallbacks for datacenter IPs.""" if not book_id: return "" candidates: list[str] = [] if book_path: candidates.append(f"https://www.goodreads.com{book_path}") candidates.append(f"https://www.goodreads.com/book/show/{book_id}") candidates.append(f"https://m.goodreads.com/book/show/{book_id}") retryable = {403, 429, 503, 202, 520, 521, 522, 523, 524} referer = "https://www.goodreads.com/" for url in candidates: for attempt in range(3): try: if attempt > 0: await asyncio.sleep(1.5 * attempt) headers = _browser_headers(referer) headers["Referer"] = referer resp = await client.get(url, headers=headers, timeout=_SCAN_TIMEOUT) body = resp.text or "" if body and "BookLink" in body: return body if resp.status_code == 200 and body: return body if resp.status_code in retryable: logger.debug( "Goodreads page blocked, retrying", url=url[:60], status=resp.status_code, attempt=attempt + 1, ) continue break except (httpx.TimeoutException, httpx.ConnectError) as exc: logger.debug("Goodreads page fetch error", url=url[:60], error=str(exc)) if attempt < 2: await asyncio.sleep(1.5 * (attempt + 1)) logger.warning("Goodreads book page fetch failed", book_id=book_id) return "" def merge_retailer_links( base: dict[str, str], extra: dict[str, str], ) -> dict[str, str]: """Merge retailer URLs; first source wins.""" merged = dict(base) for pid, url in extra.items(): if pid not in merged and url: merged[pid] = url return merged async def _goodreads_autocomplete_by_isbn( client: httpx.AsyncClient, isbn13: str, ) -> dict | None: """Resolve Goodreads book via ISBN when title autocomplete fails.""" if not is_confirmed_isbn13(isbn13): return None try: resp = await client.get( "https://www.goodreads.com/book/auto_complete", params={"format": "json", "q": isbn13}, headers={ "Accept": "application/json", "User-Agent": _browser_headers()["User-Agent"], }, timeout=_SCAN_TIMEOUT, ) if resp.status_code != 200: return None for item in resp.json()[:5]: book_id = str(item.get("bookId") or "") book_path = item.get("bookUrl") or "" if book_id and book_path: return { "book_id": book_id, "book_path": book_path, "author_name": (item.get("author") or {}).get("name") or "", "title_bare": item.get("bookTitleBare") or item.get("title") or "", } except Exception as exc: logger.debug("Goodreads ISBN autocomplete failed", error=str(exc)) return None async def _goodreads_autocomplete_best( client: httpx.AsyncClient, title: str, author: str, ) -> dict | None: """Match title via Goodreads public autocomplete JSON API.""" core = search_title(title) or title if not core: return None query = f"{core} {author}".strip() if author.strip() else core try: resp = await client.get( "https://www.goodreads.com/book/auto_complete", params={"format": "json", "q": query}, headers={ "Accept": "application/json", "User-Agent": _browser_headers()["User-Agent"], }, timeout=_SCAN_TIMEOUT, ) if resp.status_code != 200: return None for item in resp.json()[:10]: bare = item.get("bookTitleBare") or item.get("title") or "" auth = (item.get("author") or {}).get("name") or "" if title_match_score(core, bare, author, auth) >= LIKELY_THRESHOLD: book_path = item.get("bookUrl") or "" return { "book_id": str(item.get("bookId") or ""), "book_path": book_path, "author_name": auth, "title_bare": bare, } except Exception as exc: logger.debug("Goodreads autocomplete failed", error=str(exc)) return None async def _build_catalog_context( client: httpx.AsyncClient, title: str, author: str, isbn: str | None = None, ) -> CatalogContext: """Resolve author and retailer links via Goodreads (hub for indie books).""" from app.services.isbn_catalog import ( bookshop_url_from_isbn, resolve_isbn_open_library, ) ctx = CatalogContext(resolved_author=author.strip()) hit = await _goodreads_autocomplete_best(client, title, author) if not hit: return ctx if not ctx.resolved_author and hit.get("author_name"): ctx.resolved_author = hit["author_name"] book_id = hit.get("book_id") or "" book_path = hit.get("book_path") or "" if book_id: ctx.goodreads_id = book_id ctx.goodreads_url = f"https://www.goodreads.com{book_path}" if book_path else None page_html = await _fetch_goodreads_book_page(client, book_id, book_path) if page_html: gr_links = { pid: url for pid, raw in parse_goodreads_book_links(page_html).items() if (url := sanitize_platform_url(raw, pid)) } ctx.retailer_links = merge_retailer_links(ctx.retailer_links, gr_links) if ctx.goodreads_url: ctx.retailer_links = merge_retailer_links( ctx.retailer_links, {"goodreads": ctx.goodreads_url}, ) scan_author = ctx.resolved_author or author resolved_isbn = _normalize_isbn(isbn) if isbn else None if not resolved_isbn: resolved_isbn = await resolve_isbn_open_library(client, title, scan_author) if resolved_isbn and "bookshop" not in ctx.retailer_links: bs_url = bookshop_url_from_isbn(resolved_isbn, title) if bs_url: ctx.retailer_links["bookshop"] = bs_url return ctx def _core_phrase(title: str) -> str: """Lowercase core title phrase for substring checks in HTML.""" return re.sub(r"\s+", " ", (search_title(title) or title).lower()).strip() def _build_search_url(platform_id: str, title: str, author: str, isbn: str | None) -> str: """Construct canonical search URL per retailer.""" q = urllib.parse.quote(_search_query(title, author, isbn)) urls = { "amazon": f"https://www.amazon.com/s?k={q}", "barnes_noble": f"https://www.barnesandnoble.com/s/{q}", "kobo": f"https://www.kobo.com/us/en/search?query={q}", "walmart": f"https://www.walmart.com/search?q={q}", "bookshop": f"https://bookshop.org/search?keywords={q}", "thriftbooks": f"https://www.thriftbooks.com/browse/?b.search={q}", "abebooks": f"https://www.abebooks.com/servlet/SearchResults?kn={q}", "goodreads": f"https://www.goodreads.com/search?q={q}", "google_play": f"https://play.google.com/store/search?q={q}&c=books", } return urls.get(platform_id, "") def _listing_from_config(cfg: dict, status: Status = "checking") -> PlatformListing: """Seed a PlatformListing from platform config.""" pid = cfg["platform_id"] return PlatformListing( platform_id=pid, display_name=cfg["display_name"], status=status, confidence=0.0, listing_url=None, benefit=PLATFORM_BENEFITS.get(pid, ""), weight=cfg["weight"], icon=PLATFORM_ICONS.get(pid, "🔗"), ) def platform_presence_to_dict(result: PlatformPresenceResult) -> dict: """Serialize scan result for API/Redis.""" return asdict(result) def platform_presence_from_dict(data: dict) -> PlatformPresenceResult: """Deserialize scan result from Redis.""" platforms = [PlatformListing(**p) for p in data.get("platforms", [])] return PlatformPresenceResult( score=data.get("score", 0.0), listed_count=data.get("listed_count", 0), scanned_at=data.get("scanned_at", ""), source_isbn=data.get("source_isbn"), platforms=platforms, recommendations=data.get("recommendations", []), distribution_note=data.get("distribution_note"), ) def presence_dict_from_db_listings( listings: list, source_isbn: str | None = None, ) -> dict: """Rebuild platform_presence dict from persisted BookPlatformListing rows.""" cfg_by_id = {p["platform_id"]: p for p in SCORED_PLATFORMS} platforms: list[PlatformListing] = [] for row in listings: pid = row.platform_id cfg = cfg_by_id.get(pid, {}) platforms.append(PlatformListing( platform_id=pid, display_name=cfg.get("display_name", pid.replace("_", " ").title()), status=row.status, confidence=row.confidence, listing_url=row.listing_url, benefit=PLATFORM_BENEFITS.get(pid, ""), weight=cfg.get("weight", 0.5), icon=PLATFORM_ICONS.get(pid, "🔗"), price=row.list_price, price_currency=row.price_currency, )) listed_count = sum(1 for p in platforms if p.status == "verified") scanned_at = "" if listings and listings[0].last_scanned_at: scanned_at = listings[0].last_scanned_at.isoformat() result = PlatformPresenceResult( score=compute_score(platforms), listed_count=listed_count, scanned_at=scanned_at, source_isbn=source_isbn, platforms=platforms, recommendations=build_recommendations(platforms), ) return platform_presence_to_dict(result) def count_missing_listings(listings: list) -> int: """Count platforms with not_found/skipped/error status.""" return sum( 1 for row in listings if row.status in ("not_found", "skipped", "error") ) async def save_scan_to_redis(redis, url: str, result: PlatformPresenceResult) -> None: """Cache scan result for import confirm.""" if redis is None: return try: await redis.setex( scan_cache_key(url), _SCAN_CACHE_TTL, json.dumps(platform_presence_to_dict(result)), ) except Exception as exc: logger.debug("Platform scan cache write failed", error=str(exc)) async def load_scan_from_redis(redis, url: str) -> PlatformPresenceResult | None: """Load cached scan result if available.""" if redis is None: return None try: raw = await redis.get(scan_cache_key(url)) if raw: return platform_presence_from_dict(json.loads(raw)) except Exception as exc: logger.debug("Platform scan cache read failed", error=str(exc)) return None async def _load_goodreads_hub( client: httpx.AsyncClient, title: str, author: str, source_url: str | None = None, isbn: str | None = None, ) -> tuple[str, str | None, dict[str, str]]: """Goodreads book page + buy links (prefer explicit source URL when provided).""" resolved_author = author.strip() gr_url: str | None = None links: dict[str, str] = {} ref = parse_goodreads_book_ref(source_url) if ref: book_id, book_path = ref gr_url = f"https://www.goodreads.com{book_path}" links["goodreads"] = gr_url page_html = await _fetch_goodreads_book_page(client, book_id, book_path) if page_html: for pid, raw in parse_goodreads_book_links(page_html).items(): if pid == "google_play": if "books.google.com" not in raw and "/store/books/details" not in raw: continue pid = "google_books" clean = sanitize_platform_url(raw, pid) if clean: links[pid] = clean return resolved_author, gr_url, links hit = await _goodreads_autocomplete_best(client, title, author) if not hit and isbn: hit = await _goodreads_autocomplete_by_isbn(client, isbn) if not hit: return resolved_author, None, links if hit.get("author_name"): resolved_author = hit["author_name"] book_id = hit.get("book_id") or "" book_path = hit.get("book_path") or "" if book_path: gr_url = f"https://www.goodreads.com{book_path}" links["goodreads"] = gr_url if book_id: page_html = await _fetch_goodreads_book_page(client, book_id, book_path) if page_html: for pid, raw in parse_goodreads_book_links(page_html).items(): if pid == "google_play": if "books.google.com" not in raw and "/store/books/details" not in raw: continue pid = "google_books" clean = sanitize_platform_url(raw, pid) if clean: links[pid] = clean return resolved_author, gr_url, links def _extract_asin(isbn: str | None) -> str | None: """Pull Kindle ASIN from identifier field when present.""" if not isbn: return None raw = isbn.strip().upper() if raw.startswith("B0") and len(raw) == 10: return raw return extract_amazon_asin(isbn) def _finalize_listing_url(listing: PlatformListing) -> None: """Normalize URL — search pages are not listings; clear them.""" pid = listing.platform_id if not listing.listing_url: listing.status = "not_found" listing.confidence = 0.0 return clean = sanitize_platform_url(listing.listing_url, pid) if not clean or classify_retailer_url(clean, pid) != "product": listing.listing_url = None listing.status = "not_found" listing.confidence = 0.0 return listing.listing_url = clean if listing.confidence >= VERIFIED_THRESHOLD: listing.status = "verified" else: listing.status = "not_found" listing.listing_url = None listing.confidence = 0.0 async def _scan_one_platform( cfg: dict, snap, source_platform: str | None, source_url: str | None, buy_url: str | None, ) -> PlatformListing: """Run API-first discovery for a single platform.""" listing = _listing_from_config(cfg, status="checking") pid = cfg["platform_id"] hit = discover_platform_hit( pid, snap, source_platform=source_platform, source_url=source_url, buy_url=buy_url, ) if hit.listing_url and ( hit.isbn_match or hit.confidence >= VERIFIED_THRESHOLD ): listing.confidence = round(hit.confidence, 2) listing.status = confidence_to_status(hit.confidence, hit.isbn_match) listing.listing_url = hit.listing_url _finalize_listing_url(listing) else: listing.status = "not_found" listing.confidence = 0.0 listing.listing_url = None return listing async def _enrich_listing_prices( client: httpx.AsyncClient, platforms: list[PlatformListing], *, isbn13: str | None = None, title: str | None = None, author: str | None = None, db=None, redis=None, ) -> None: """Fetch list prices for verified listings (best-effort, parallel).""" from app.services.platform_api_registry import load_all_credentials from app.services.platform_pricing import PriceContext, fetch_listing_price ctx = PriceContext(isbn13=isbn13, title=title, author=author) credentials = await load_all_credentials(db=db, redis=redis) targets = [ p for p in platforms if p.status == "verified" and p.listing_url ] if not targets: return async def _one(listing: PlatformListing) -> None: try: hit = await fetch_listing_price( client, listing.platform_id, listing.listing_url or "", ctx, credential=credentials.get(listing.platform_id), ) if hit: listing.price = hit.formatted listing.price_currency = hit.currency except Exception as exc: logger.debug( "Platform price enrichment failed", platform=listing.platform_id, error=str(exc)[:120], ) await asyncio.gather(*[_one(p) for p in targets], return_exceptions=True) gr = next( (p for p in platforms if p.platform_id == "goodreads" and p.status == "verified"), None, ) if gr and not gr.price: retail = [ p for p in platforms if p.platform_id not in ("goodreads", "open_library") and p.status == "verified" and p.price and p.price != "Free" ] if retail: amazon = next((p for p in retail if p.platform_id == "amazon"), None) ref = amazon or retail[0] gr.price = f"from {ref.price}" gr.price_currency = ref.price_currency async def scan_platform_presence( *, title: str, author: str = "", isbn: str | None = None, source_platform: str | None = None, source_url: str | None = None, buy_url: str | None = None, client: httpx.AsyncClient | None = None, db=None, redis=None, ) -> PlatformPresenceResult: """Scan top-10 platforms and return scored presence result.""" raw_isbn = _normalize_isbn(isbn) if isbn else None norm_isbn = raw_isbn if is_confirmed_isbn13(raw_isbn) else None scanned_at = datetime.now(timezone.utc).isoformat() core_title = search_title(title) or title.strip() if not core_title: empty = [_listing_from_config(c, status="skipped") for c in SCORED_PLATFORMS] return PlatformPresenceResult( score=0.0, listed_count=0, scanned_at=scanned_at, source_isbn=norm_isbn, platforms=empty, recommendations=build_recommendations(empty), ) sem = asyncio.Semaphore(_MAX_CONCURRENT) async def _run_scan(client: httpx.AsyncClient) -> tuple[list[PlatformListing], str | None]: scan_isbn = norm_isbn async def _gr_hub() -> tuple[str, str | None, dict[str, str]]: return await _load_goodreads_hub( client, core_title, author, source_url=source_url, isbn=scan_isbn, ) async def _base_snapshot() -> "CatalogSnapshot": from app.services.catalog_hub import build_catalog_snapshot resolved_asin = _extract_asin(scan_isbn or isbn) return await build_catalog_snapshot( client, title=core_title, author=author, isbn=scan_isbn, asin=resolved_asin, goodreads_links={}, goodreads_url=None, goodreads_confidence=0.0, resolved_author=author, skip_enrich=True, ) (resolved_author, gr_url, gr_links), snap = await asyncio.gather( _gr_hub(), _base_snapshot(), ) if scan_isbn and is_confirmed_isbn13(scan_isbn): snap.isbn13 = scan_isbn elif snap.isbn13 and is_confirmed_isbn13(snap.isbn13): scan_isbn = snap.isbn13 if gr_url: snap.goodreads_url = gr_url snap.goodreads_confidence = 0.95 if resolved_author: snap.author = resolved_author snap.retailer_links = merge_retailer_links(gr_links, snap.retailer_links) from app.services.isbn_catalog import enrich_retailer_links_from_isbn snap.retailer_links = await enrich_retailer_links_from_isbn( client, snap.retailer_links, isbn13=snap.isbn13 or scan_isbn, title=core_title, author=snap.author or author, ) if not snap.asin: resolved_asin = _extract_asin(scan_isbn or isbn) if not resolved_asin and gr_links.get("amazon"): resolved_asin = extract_amazon_asin(gr_links["amazon"]) if resolved_asin: snap.asin = resolved_asin src = normalize_source_platform(source_platform) async def _guarded(cfg: dict) -> PlatformListing: async with sem: return await _scan_one_platform( cfg, snap, src, source_url, buy_url, ) return list(await asyncio.gather(*[_guarded(c) for c in SCORED_PLATFORMS])), scan_isbn if client is not None: platforms, resolved_scan_isbn = await _run_scan(client) await _enrich_listing_prices( client, platforms, isbn13=resolved_scan_isbn or norm_isbn, title=core_title, author=author, db=db, redis=redis, ) else: async with httpx.AsyncClient(follow_redirects=True) as shared: platforms, resolved_scan_isbn = await _run_scan(shared) await _enrich_listing_prices( shared, platforms, isbn13=resolved_scan_isbn or norm_isbn, title=core_title, author=author, db=db, redis=redis, ) listed_count = sum(1 for p in platforms if p.status == "verified") score = compute_score(platforms) recommendations = build_recommendations(platforms) return PlatformPresenceResult( score=score, listed_count=listed_count, scanned_at=scanned_at, source_isbn=resolved_scan_isbn or norm_isbn, platforms=platforms, recommendations=recommendations, distribution_note=infer_distribution_note( platforms, resolved_scan_isbn or norm_isbn, ), )