Spaces:
Running
Running
| """Platform API credential registry — providers, env fallbacks, resolution order.""" | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from dataclasses import dataclass | |
| from typing import Literal | |
| import httpx | |
| import structlog | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.config import get_settings | |
| from app.core.crypto.credential_crypto import decrypt_secret, mask_secret | |
| from app.repositories.platform_api_credential_repo import PlatformApiCredentialRepository | |
| from app.services.platform_pricing import ( | |
| PriceContext, | |
| PlatformPrice, | |
| extract_price_from_html, | |
| format_price, | |
| ) | |
| logger = structlog.get_logger(__name__) | |
| _CACHE_PREFIX = "platform_api_cred:v1:" | |
| _CACHE_TTL = 300 | |
| ProviderId = Literal["google_books", "rainforest", "scrapingbee"] | |
| PROVIDER_SCOPE_PREFIX = "@provider:" | |
| PROVIDER_DEFINITIONS: dict[str, dict] = { | |
| "scrapingbee": { | |
| "display_name": "ScrapingBee", | |
| "description": "Shared proxy for B&N, Apple, Kobo, ThriftBooks, AbeBooks, Bookshop", | |
| }, | |
| "rainforest": { | |
| "display_name": "Rainforest API", | |
| "description": "Amazon product data API", | |
| }, | |
| "google_books": { | |
| "display_name": "Google Books API", | |
| "description": "Google Books volumes and pricing", | |
| }, | |
| } | |
| PLATFORM_API_META: dict[str, dict] = { | |
| "amazon": { | |
| "display_name": "Amazon", | |
| "providers": ["rainforest", "scrapingbee"], | |
| "default_provider": "rainforest", | |
| "env_key": "RAINFOREST_API_KEY", | |
| }, | |
| "google_books": { | |
| "display_name": "Google Books", | |
| "providers": ["google_books"], | |
| "default_provider": "google_books", | |
| "env_key": "GOOGLE_BOOKS_API_KEY", | |
| }, | |
| "barnes_noble": { | |
| "display_name": "Barnes & Noble", | |
| "providers": ["scrapingbee"], | |
| "default_provider": "scrapingbee", | |
| "env_key": None, | |
| }, | |
| "apple_books": { | |
| "display_name": "Apple Books", | |
| "providers": ["scrapingbee"], | |
| "default_provider": "scrapingbee", | |
| "env_key": None, | |
| }, | |
| "kobo": { | |
| "display_name": "Kobo", | |
| "providers": ["scrapingbee"], | |
| "default_provider": "scrapingbee", | |
| "env_key": None, | |
| }, | |
| "thriftbooks": { | |
| "display_name": "ThriftBooks", | |
| "providers": ["scrapingbee"], | |
| "default_provider": "scrapingbee", | |
| "env_key": None, | |
| }, | |
| "abebooks": { | |
| "display_name": "AbeBooks", | |
| "providers": ["scrapingbee"], | |
| "default_provider": "scrapingbee", | |
| "env_key": None, | |
| }, | |
| "bookshop": { | |
| "display_name": "Bookshop.org", | |
| "providers": ["scrapingbee"], | |
| "default_provider": "scrapingbee", | |
| "env_key": None, | |
| }, | |
| "goodreads": { | |
| "display_name": "Goodreads", | |
| "providers": [], | |
| "default_provider": None, | |
| "env_key": None, | |
| }, | |
| "open_library": { | |
| "display_name": "Open Library", | |
| "providers": [], | |
| "default_provider": None, | |
| "env_key": None, | |
| }, | |
| } | |
| class EffectiveCredential: | |
| """Resolved credential ready for outbound API calls.""" | |
| platform_id: str | |
| provider: str | |
| api_key: str | |
| api_secret: str | None | |
| source: Literal["superadmin", "environment"] | |
| class PlatformApiCredentialView: | |
| """Masked credential for SuperAdmin API responses.""" | |
| platform_id: str | |
| display_name: str | |
| provider: str | None | |
| api_key_hint: str | None | |
| has_secret: bool | |
| is_enabled: bool | |
| source: str | |
| available_providers: list[str] | |
| def provider_scope_id(provider: str) -> str: | |
| """Canonical DB key for a shared provider credential.""" | |
| return f"{PROVIDER_SCOPE_PREFIX}{provider}" | |
| def is_provider_scope(platform_id: str) -> bool: | |
| """True when platform_id is a shared provider scope row.""" | |
| return platform_id.startswith(PROVIDER_SCOPE_PREFIX) | |
| def provider_id_from_scope(scope_id: str) -> str: | |
| """Extract provider id from @provider:scrapingbee.""" | |
| return scope_id.removeprefix(PROVIDER_SCOPE_PREFIX) | |
| def platforms_for_provider(provider: str) -> list[str]: | |
| """Retail platform IDs that can use this provider.""" | |
| return [ | |
| pid for pid, meta in PLATFORM_API_META.items() | |
| if provider in meta.get("providers", []) | |
| ] | |
| def _env_credential_for_provider(provider: str) -> EffectiveCredential | None: | |
| """First matching env fallback for any platform using this provider.""" | |
| for pid in platforms_for_provider(provider): | |
| meta = PLATFORM_API_META.get(pid, {}) | |
| if meta.get("default_provider") != provider: | |
| continue | |
| cred = _env_credential(pid) | |
| if cred: | |
| return cred | |
| return None | |
| def _env_credential(platform_id: str) -> EffectiveCredential | None: | |
| """Build credential from environment when configured.""" | |
| meta = PLATFORM_API_META.get(platform_id) | |
| if not meta or not meta.get("env_key"): | |
| return None | |
| env_name = meta["env_key"] | |
| cfg = get_settings() | |
| raw = (getattr(cfg, env_name, None) or "").strip() | |
| if not raw: | |
| return None | |
| provider = meta["default_provider"] | |
| if not provider: | |
| return None | |
| return EffectiveCredential( | |
| platform_id=platform_id, | |
| provider=provider, | |
| api_key=raw, | |
| api_secret=None, | |
| source="environment", | |
| ) | |
| def _row_to_effective(row) -> EffectiveCredential: | |
| """Decrypt DB row into runtime credential.""" | |
| secret = None | |
| if row.api_secret_encrypted: | |
| secret = decrypt_secret(row.api_secret_encrypted) | |
| return EffectiveCredential( | |
| platform_id=row.platform_id, | |
| provider=row.provider, | |
| api_key=decrypt_secret(row.api_key_encrypted), | |
| api_secret=secret, | |
| source="superadmin", | |
| ) | |
| async def _cache_get(redis, platform_id: str) -> EffectiveCredential | None: | |
| if redis is None: | |
| return None | |
| try: | |
| raw = await redis.get(_CACHE_PREFIX + platform_id) | |
| if not raw: | |
| return None | |
| data = json.loads(raw) | |
| if data.get("empty"): | |
| return None | |
| return EffectiveCredential(**data) | |
| except Exception: | |
| return None | |
| async def _cache_set(redis, platform_id: str, cred: EffectiveCredential | None) -> None: | |
| if redis is None: | |
| return | |
| try: | |
| key = _CACHE_PREFIX + platform_id | |
| if cred is None: | |
| await redis.set(key, json.dumps({"empty": True}), ex=_CACHE_TTL) | |
| else: | |
| await redis.set( | |
| key, | |
| json.dumps({ | |
| "platform_id": cred.platform_id, | |
| "provider": cred.provider, | |
| "api_key": cred.api_key, | |
| "api_secret": cred.api_secret, | |
| "source": cred.source, | |
| }), | |
| ex=_CACHE_TTL, | |
| ) | |
| except Exception: | |
| pass | |
| async def invalidate_credential_cache(redis, platform_id: str | None = None) -> None: | |
| """Clear cached credentials after SuperAdmin writes (R-179).""" | |
| if redis is None: | |
| return | |
| try: | |
| if platform_id: | |
| await redis.delete(_CACHE_PREFIX + platform_id) | |
| if is_provider_scope(platform_id): | |
| for pid in platforms_for_provider(provider_id_from_scope(platform_id)): | |
| await redis.delete(_CACHE_PREFIX + pid) | |
| else: | |
| keys = await redis.keys(_CACHE_PREFIX + "*") | |
| if keys: | |
| await redis.delete(*keys) | |
| except Exception: | |
| pass | |
| async def resolve_platform_credential( | |
| platform_id: str, | |
| *, | |
| db: AsyncSession | None = None, | |
| redis=None, | |
| ) -> EffectiveCredential | None: | |
| """Resolve credential: platform override → provider scope → environment → None.""" | |
| cached = await _cache_get(redis, platform_id) | |
| if cached is not None or (redis and await redis.get(_CACHE_PREFIX + platform_id)): | |
| return cached | |
| cred: EffectiveCredential | None = None | |
| if db is not None: | |
| repo = PlatformApiCredentialRepository(db) | |
| row = await repo.get_enabled(platform_id) | |
| if row and not is_provider_scope(row.platform_id): | |
| cred = _row_to_effective(row) | |
| if cred is None: | |
| meta = PLATFORM_API_META.get(platform_id) | |
| default_provider = meta.get("default_provider") if meta else None | |
| if default_provider: | |
| scope_row = await repo.get_enabled(provider_scope_id(default_provider)) | |
| if scope_row: | |
| cred = _row_to_effective(scope_row) | |
| if cred is None: | |
| cred = _env_credential(platform_id) | |
| await _cache_set(redis, platform_id, cred) | |
| return cred | |
| async def load_all_credentials( | |
| db: AsyncSession | None = None, | |
| redis=None, | |
| ) -> dict[str, EffectiveCredential]: | |
| """Load credentials for all scored platforms.""" | |
| from app.services.platform_presence import SCORED_PLATFORMS | |
| out: dict[str, EffectiveCredential] = {} | |
| for cfg in SCORED_PLATFORMS: | |
| pid = cfg["platform_id"] | |
| cred = await resolve_platform_credential(pid, db=db, redis=redis) | |
| if cred: | |
| out[pid] = cred | |
| return out | |
| def _extract_asin(url: str) -> str | None: | |
| m = re.search(r"/(?:dp|gp/product|gp/aw/d)/([A-Z0-9]{10})", url, re.I) | |
| return m.group(1).upper() if m else None | |
| async def _fetch_rainforest_price( | |
| client: httpx.AsyncClient, | |
| cred: EffectiveCredential, | |
| listing_url: str, | |
| ) -> PlatformPrice | None: | |
| asin = _extract_asin(listing_url) | |
| if not asin: | |
| return None | |
| try: | |
| r = await client.get( | |
| "https://api.rainforestapi.com/request", | |
| params={ | |
| "api_key": cred.api_key, | |
| "type": "product", | |
| "amazon_domain": "amazon.com", | |
| "asin": asin, | |
| }, | |
| timeout=15.0, | |
| ) | |
| if r.status_code != 200: | |
| return None | |
| data = r.json() | |
| winner = data.get("product", {}).get("buybox_winner") or data.get("buybox_winner") or {} | |
| price = winner.get("price") or {} | |
| amount = price.get("value") or price.get("raw") | |
| if amount is None: | |
| return None | |
| amount_f = float(str(amount).replace(",", "").replace("$", "")) | |
| currency = str(price.get("currency") or "USD").upper() | |
| return PlatformPrice( | |
| formatted=format_price(amount_f, currency), | |
| amount=amount_f, | |
| currency=currency, | |
| source="api:rainforest", | |
| ) | |
| except Exception as exc: | |
| logger.debug("Rainforest price failed", error=str(exc)[:120]) | |
| return None | |
| async def _fetch_scrapingbee_html( | |
| client: httpx.AsyncClient, | |
| cred: EffectiveCredential, | |
| listing_url: str, | |
| ) -> str | None: | |
| try: | |
| r = await client.get( | |
| "https://app.scrapingbee.com/api/v1", | |
| params={ | |
| "api_key": cred.api_key, | |
| "url": listing_url, | |
| "premium_proxy": "true", | |
| "country_code": "us", | |
| }, | |
| timeout=30.0, | |
| ) | |
| if r.status_code == 200 and len(r.text) > 500: | |
| return r.text | |
| except Exception as exc: | |
| logger.debug("ScrapingBee fetch failed", error=str(exc)[:120]) | |
| return None | |
| async def _fetch_google_books_with_key( | |
| client: httpx.AsyncClient, | |
| cred: EffectiveCredential, | |
| listing_url: str, | |
| ctx: PriceContext | None, | |
| ) -> PlatformPrice | None: | |
| from app.services.platform_pricing import fetch_google_books_price | |
| volume_m = re.search(r"[?&]id=([^&]+)", listing_url) | |
| isbn = (ctx.isbn13 if ctx else None) or "" | |
| try: | |
| if volume_m: | |
| r = await client.get( | |
| f"https://www.googleapis.com/books/v1/volumes/{volume_m.group(1)}", | |
| params={"key": cred.api_key, "country": "US"}, | |
| timeout=10.0, | |
| ) | |
| if r.status_code == 200: | |
| from app.services.platform_pricing import _sale_info_price | |
| hit = _sale_info_price(r.json().get("saleInfo") or {}) | |
| if hit: | |
| hit.source = "api:google_books" | |
| return hit | |
| if isbn: | |
| r = await client.get( | |
| "https://www.googleapis.com/books/v1/volumes", | |
| params={ | |
| "key": cred.api_key, | |
| "q": f"isbn:{isbn}", | |
| "maxResults": "5", | |
| "country": "US", | |
| }, | |
| timeout=10.0, | |
| ) | |
| if r.status_code == 200: | |
| from app.services.platform_pricing import _sale_info_price | |
| for item in r.json().get("items", []): | |
| hit = _sale_info_price(item.get("saleInfo") or {}) | |
| if hit: | |
| hit.source = "api:google_books" | |
| return hit | |
| except Exception as exc: | |
| logger.debug("Google Books keyed fetch failed", error=str(exc)[:120]) | |
| return await fetch_google_books_price(client, listing_url, ctx) | |
| async def fetch_price_via_credential( | |
| client: httpx.AsyncClient, | |
| cred: EffectiveCredential, | |
| platform_id: str, | |
| listing_url: str, | |
| ctx: PriceContext | None = None, | |
| ) -> PlatformPrice | None: | |
| """Fetch price using SuperAdmin/env credential (priority tier 1).""" | |
| if cred.provider == "rainforest": | |
| if platform_id != "amazon": | |
| return None | |
| return await _fetch_rainforest_price(client, cred, listing_url) | |
| if cred.provider == "google_books": | |
| if platform_id != "google_books": | |
| return None | |
| return await _fetch_google_books_with_key(client, cred, listing_url, ctx) | |
| if cred.provider == "scrapingbee": | |
| html = await _fetch_scrapingbee_html(client, cred, listing_url) | |
| if html: | |
| hit = extract_price_from_html(platform_id, html) | |
| if hit: | |
| hit.source = "api:scrapingbee" | |
| return hit | |
| return None | |
| def build_credential_views(rows: list) -> list[PlatformApiCredentialView]: | |
| """Build SuperAdmin list view for all platforms.""" | |
| by_id = {r.platform_id: r for r in rows} | |
| views: list[PlatformApiCredentialView] = [] | |
| for pid, meta in PLATFORM_API_META.items(): | |
| row = by_id.get(pid) | |
| env_cred = _env_credential(pid) | |
| if row: | |
| key_hint = mask_secret(decrypt_secret(row.api_key_encrypted)) | |
| views.append(PlatformApiCredentialView( | |
| platform_id=pid, | |
| display_name=meta["display_name"], | |
| provider=row.provider, | |
| api_key_hint=key_hint, | |
| has_secret=bool(row.api_secret_encrypted), | |
| is_enabled=row.is_enabled, | |
| source="superadmin", | |
| available_providers=list(meta["providers"]), | |
| )) | |
| elif env_cred: | |
| views.append(PlatformApiCredentialView( | |
| platform_id=pid, | |
| display_name=meta["display_name"], | |
| provider=env_cred.provider, | |
| api_key_hint=mask_secret(env_cred.api_key), | |
| has_secret=False, | |
| is_enabled=True, | |
| source="environment", | |
| available_providers=list(meta["providers"]), | |
| )) | |
| else: | |
| views.append(PlatformApiCredentialView( | |
| platform_id=pid, | |
| display_name=meta["display_name"], | |
| provider=meta.get("default_provider"), | |
| api_key_hint=None, | |
| has_secret=False, | |
| is_enabled=False, | |
| source="none", | |
| available_providers=list(meta["providers"]), | |
| )) | |
| return views | |
| def build_provider_views(rows: list) -> dict: | |
| """Build SuperAdmin provider-scoped credential views (one key per provider).""" | |
| by_id = {r.platform_id: r for r in rows} | |
| providers_out: list[dict] = [] | |
| for provider_id, info in PROVIDER_DEFINITIONS.items(): | |
| scope_id = provider_scope_id(provider_id) | |
| row = by_id.get(scope_id) | |
| env_cred = _env_credential_for_provider(provider_id) | |
| platform_labels = [ | |
| { | |
| "platform_id": pid, | |
| "display_name": PLATFORM_API_META[pid]["display_name"], | |
| } | |
| for pid in platforms_for_provider(provider_id) | |
| ] | |
| if row and row.is_enabled: | |
| source = "superadmin" | |
| key_hint = mask_secret(decrypt_secret(row.api_key_encrypted)) | |
| is_enabled = True | |
| elif env_cred: | |
| source = "environment" | |
| key_hint = mask_secret(env_cred.api_key) | |
| is_enabled = True | |
| else: | |
| source = "none" | |
| key_hint = None | |
| is_enabled = False | |
| providers_out.append({ | |
| "provider_id": provider_id, | |
| "scope_id": scope_id, | |
| "display_name": info["display_name"], | |
| "description": info["description"], | |
| "platforms": platform_labels, | |
| "api_key_hint": key_hint, | |
| "is_enabled": is_enabled, | |
| "source": source, | |
| }) | |
| without_api = [ | |
| { | |
| "platform_id": pid, | |
| "display_name": meta["display_name"], | |
| } | |
| for pid, meta in PLATFORM_API_META.items() | |
| if not meta.get("providers") | |
| ] | |
| return {"providers": providers_out, "platforms_without_api": without_api} | |