import type { AppDetails, AppQuery, ChartEntry, ChartQuery, ScraperLib } from "./api.ts"; /** Subset of `app-store-scraper` we use. Allows injecting a mock in tests. */ export interface AppleScraperLib { list(opts: { collection: string; country: string; num: number }): Promise; app(opts: { id?: string; appId?: string; country: string }): Promise; collection: Record; } const APPLE_COLLECTION_MAP: Record = { "top-grossing": "TOP_GROSSING_IOS", "top-free": "TOP_FREE_IOS", "top-paid": "TOP_PAID_IOS", }; function normalizeAppleEntry(raw: unknown): ChartEntry { const o = raw as Record; // Bundle ID (e.g., "com.google.ios.youtube") is what we store as `appId`. // Older lib versions exposed it under `id`; newer ones under `appId`. const id = o.appId ?? o.id ?? o.trackId; // Numeric track id (e.g., "284882215"). Required by apps.apple.com URLs // (`/id`). Apple's lib surfaces it under `trackId` on per-app // results; chart list() entries surface the same numeric value under `id` // when the lib hasn't been upgraded to expose `appId` separately. const trackIdRaw = o.trackId ?? o.id; const trackId = typeof trackIdRaw === "string" ? trackIdRaw : typeof trackIdRaw === "number" ? String(trackIdRaw) : undefined; return { appId: String(id ?? ""), trackId, title: typeof o.title === "string" ? o.title : (o.trackName as string | undefined), developer: typeof o.developer === "string" ? o.developer : (o.artistName as string | undefined), primaryGenre: typeof o.primaryGenre === "string" ? o.primaryGenre : (o.genre as string | undefined), genre: typeof o.genre === "string" ? o.genre : undefined, price: typeof o.price === "number" ? o.price : undefined, currency: typeof o.currency === "string" ? o.currency : undefined, free: typeof o.free === "boolean" ? o.free : undefined, score: typeof o.score === "number" ? o.score : undefined, reviews: typeof o.reviews === "number" ? o.reviews : undefined, ratings: typeof o.ratings === "number" ? o.ratings : undefined, icon: typeof o.icon === "string" ? o.icon : undefined, description: typeof o.description === "string" ? o.description : undefined, screenshots: Array.isArray(o.screenshots) ? (o.screenshots as string[]) : undefined, released: typeof o.released === "string" ? o.released : undefined, updated: typeof o.updated === "string" ? o.updated : undefined, }; } export function createAppleScraperLib(lib: AppleScraperLib): ScraperLib { return { async fetchChart(query: ChartQuery): Promise { if (query.store !== "apple") { throw new Error(`apple client received non-apple query: ${query.store}`); } const collectionKey = APPLE_COLLECTION_MAP[query.collection]; if (!collectionKey) { throw new Error(`unsupported apple collection: ${query.collection}`); } // `app-store-scraper` validates `list({collection})` against its own // enum *values* (e.g. "topgrossingapplications"), not the key strings // ("TOP_GROSSING_IOS"). Look up the value on the lib's own collection // map so we tolerate any value-string drift between releases. const collection = lib.collection[collectionKey]; if (!collection) { throw new Error(`apple-store-scraper has no collection enum value for "${collectionKey}"`); } const raw = await lib.list({ collection, country: query.market.toLowerCase(), num: query.limit, }); return raw.map(normalizeAppleEntry); }, async fetchApp(query: AppQuery): Promise { if (query.store !== "apple") { throw new Error(`apple client received non-apple query: ${query.store}`); } // `app-store-scraper`'s `app()` takes EITHER `id` (numeric trackId // like "544007664") OR `appId` (bundle ID like "com.google.ios.youtube"). // Passing a bundle ID as `id` fails — the lib looks it up as a // numeric trackId and gets nothing. M7's first demo-refresh run // caught this (100% Apple enrichment failure across 10 apps); the // smoke gate didn't catch it because smoke topN=1 + Google enriching // = 1 Google candidate ranked, masking the Apple failure. We use // a heuristic: numeric strings → `id`, anything else → `appId`. const isNumericId = /^\d+$/.test(query.appId); const lookup = isNumericId ? { id: query.appId, country: query.market.toLowerCase() } : { appId: query.appId, country: query.market.toLowerCase() }; const raw = await lib.app(lookup); const entry = normalizeAppleEntry(raw); const o = raw as Record; return { ...entry, inAppPurchases: typeof o.inAppPurchases === "boolean" ? o.inAppPurchases : undefined, }; }, }; } /** Convenience: load the real `app-store-scraper` and return a wired-up client. */ export async function loadDefaultAppleClient(): Promise { const mod = await import("app-store-scraper"); const lib = (mod as unknown as { default?: AppleScraperLib }).default ?? (mod as unknown as AppleScraperLib); return createAppleScraperLib(lib); }