storebound/cli/lib/aso-rank.ts
CRT 7e26941ffc aso: competitors, suggest and audit from official data; ASO card on the Growth tab
- aso competitors: top apps per keyword from the iTunes Search API (name, developer,
  rating count, release / update dates, localized genre), coverage gaps (words of 2+
  developers), new entrants vs the last snapshot (.storebound/aso/competitors/)
- aso suggest: candidates from own text, competitor names, localized category names and
  Apple Ads keyword suggestions (POST /v1/suggestions/keywords/query, relative 0-100
  popularity, only with credentials); 100-byte keyword field per locale as a diff,
  --write marks it a draft that check --aso and aso audit report
- aso audit: check --aso, free keyword room, rank trend arrows, gaps, Play short
  description / density, App Store conversion, PPO / Play experiment ideas, next 3 actions
- MCP: read-only aso_competitors, aso_suggest, aso_audit; desktop ASO card (visx rank chart)
- docs: README, aso-basics, store-pitfalls (Search API order, no Play search API), CHANGELOG;
  demo-app listing files; tests on recorded Search API and mocked Ads answers
2026-10-12 04:09:34 +08:00

67 lines
4.8 KiB
TypeScript

// 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=<text>&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<string, number | null> {
const prev = new Map((before ?? []).map(r => [`${r.storefront}|${r.keyword.toLowerCase()}`, r.rank] as const));
const out = new Map<string, number | null>();
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;
}