// App Store keyword rank check against Apple's public iTunes Search API, as pure parts. The command (cli/aso.ts rank) // sends the queries one by one, slower than Apple's limit, and stores a snapshot in .storebound/aso/. // Source: Apple Search API (Performance Partners), checked October 2026: // https://itunes.apple.com/search?term=&country=<2-letter>&media=software&entity=software&limit=1..200 (default 50) // "approximately 20 calls per minute (subject to change)". https://performance-partners.apple.com/search-api // What this is and is not: the order of the Search API results is close to, but not the same as, App Store search // (no personalisation, no search ads, no "in-app events" rows), so read ranks as a trend, not a promise. The API returns // title (trackName), developer (sellerName), bundleId, rating and description, but no subtitle: competitor subtitles // are not available through a public API, so they are not shown (storebound scrapes nothing). // Result fields used (as the API returns them; Apple's table lists only the general ones): trackId, trackName, // bundleId, sellerName, averageUserRating, userRatingCount, primaryGenreName; `aso competitors` also releaseDate, // currentVersionReleaseDate, genres (localized to the storefront, e.g. "Produktivität" for country=de) and genreIds. export const SEARCH_API = 'https://itunes.apple.com/search'; /** Apple: about 20 calls per minute. One call every 3.5 s stays below it with room for retries. */ export const MIN_DELAY_MS = 3_500; export const MAX_RESULTS = 200; export interface SearchApp { trackId: number; trackName: string; bundleId?: string; sellerName?: string; averageUserRating?: number; userRatingCount?: number; primaryGenreName?: string; releaseDate?: string; currentVersionReleaseDate?: string; genres?: string[]; genreIds?: string[]; primaryGenreId?: number; } export function searchUrl(term: string, country: string, limit = MAX_RESULTS): string { if (!/^[a-z]{2}$/i.test(country)) throw new Error(`storefront "${country}": a two-letter country code (us, de, tr)`); return `${SEARCH_API}?${new URLSearchParams({ term, country: country.toLowerCase(), media: 'software', entity: 'software', limit: String(Math.min(MAX_RESULTS, Math.max(1, limit))) })}`; } /** Keywords of one App Store locale's keyword field ("a,b,c"), trimmed and de-duplicated, plus extra terms. Pure. */ export function keywordList(field: string | undefined, extra: string[] = []): string[] { const out: string[] = []; for (const k of [...(field ?? '').split(','), ...extra].map(s => s.trim()).filter(Boolean)) if (!out.some(x => x.toLowerCase() === k.toLowerCase())) out.push(k); return out; } /** "us=en-US,de=de-DE" or "us,de" -> storefront + listing locale (default locale for the bare ones). Pure. */ export function parseStorefronts(spec: string[], defaultLocale: string): { storefront: string; locale: string }[] { const list = spec.length ? spec : ['us']; return list.map(s => { const [sf, loc] = s.split('='); return { storefront: sf!.trim().toLowerCase(), locale: (loc ?? defaultLocale).trim() }; }); } /** 1-based rank of the app in the results (by Apple ID, else bundle id), or null when not in the first N. Pure. */ export function rankOf(results: SearchApp[], app: { appId?: string; bundleId?: string }): number | null { const i = results.findIndex(r => (app.appId && String(r.trackId) === app.appId) || (app.bundleId && r.bundleId === app.bundleId)); return i >= 0 ? i + 1 : null; } export interface Competitor { rank: number; title: string; developer: string; rating: number | null; ratings: number; genre: string } /** The first n results that are not the app itself. Pure. */ export function competitors(results: SearchApp[], app: { appId?: string; bundleId?: string }, n = 3): Competitor[] { return results.map((r, i) => ({ r, i })).filter(({ r }) => !((app.appId && String(r.trackId) === app.appId) || (app.bundleId && r.bundleId === app.bundleId))).slice(0, n) .map(({ r, i }) => ({ rank: i + 1, title: r.trackName, developer: r.sellerName ?? '', rating: r.averageUserRating ?? null, ratings: r.userRatingCount ?? 0, genre: r.primaryGenreName ?? '' })); } export interface RankRow { storefront: string; locale: string; keyword: string; rank: number | null; results: number; competitors: Competitor[] } /** Rank change between two snapshots: positive = moved up. Pure. */ export function rankDelta(now: RankRow[], before: RankRow[] | undefined): Map { const prev = new Map((before ?? []).map(r => [`${r.storefront}|${r.keyword.toLowerCase()}`, r.rank] as const)); const out = new Map(); for (const r of now) { const p = prev.get(`${r.storefront}|${r.keyword.toLowerCase()}`); out.set(`${r.storefront}|${r.keyword.toLowerCase()}`, p === undefined || p === null || r.rank === null ? null : p - r.rank); } return out; }