// App Store keyword work from official / public data only (no scraping, no third-party ASO services). // storebound aso rank [--storefronts us,de=de-DE] [--keywords "a,b"] [--only-keywords] [--competitors 3] [--limit 200] // [--delay 3500] [--dry] [--json] // storebound aso competitors [--term ]... [--storefront us,tr] [--only-terms] [--top 10] [--delay 3500] [--no-save] [--dry] [--json] // storebound aso suggest [--locale en-US,tr] [--fresh] [--offline] [--write] [--json] // storebound aso audit [--storefront US] [--json] // rank: keywords = the keyword field of each storefront's locale in store/appstore.md (us -> the primary locale unless // "us=en-GB"), plus --keywords (--only-keywords: just those). One request per keyword and storefront, at most one // every 3.5 s (Apple allows about 20 a minute); a 403 / 429 stops the run with what was read. Rank = position of the // app (apple.appId, else the bundle id) in the first --limit results, "-" when not there; plus the first competitors // (title, developer, rating). Writes .storebound/aso/.json (and compares with the previous one) unless --dry. // competitors: the same queries and rate limit; per term the top N other apps (name, developer, rating count, first // release, last update, genre) and the words in their names that our name / subtitle / keywords do not cover. Saves // .storebound/aso/competitors/.json unless --dry / --no-save; names the apps new in a term's top N since the // previous snapshot. Competitor subtitles are not in any public API and are not shown. // suggest: keyword candidates per App Store locale from our own name / subtitle / description, the latest competitor // snapshot, the localized category names (from the Search API's genres) and, when Apple Ads credentials exist (and // not --offline), Apple Ads keyword suggestions with their relative popularity (0-100). Prints the ranked candidates // and a 100-byte keyword field per locale as a diff against store/appstore.md; --write puts it there with a draft // line above it (review, then delete the line). Never invents a popularity. // audit: one offline report (cli/lib/aso-audit.ts) with experiment ideas and the next 3 actions. Never writes. // Pure parts: cli/lib/aso-rank.ts, cli/lib/aso-intel.ts, cli/lib/aso-audit.ts. import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { adsClient } from './ads/client'; import type { Snapshot } from './lib/analytics'; import { mergeSeries } from './lib/analytics'; import { auditAso } from './lib/aso'; import { auditText, buildAudit, type RankSnap } from './lib/aso-audit'; import { ADS_KEYWORD_SUGGESTIONS, type AdsSuggestion, candidates, categoryNames, competitorRow, type CompetitorRow, type CompetitorSnapshot, coverageGaps, draftLocales, draftMark, fitKeywordField, keywordSuggestionBody, newEntrants, parseKeywordSuggestions, setKeywords, storefrontOf, tokens, } from './lib/aso-intel'; import { competitors, keywordList, MIN_DELAY_MS, parseStorefronts, rankDelta, rankOf, type RankRow, type SearchApp, searchUrl } from './lib/aso-rank'; import { appleName } from './lib/config'; import { parseAppStoreListing, parsePlayListing } from './lib/copy'; import type { Ctx } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { appleSections } from './lib/project-files'; import { textTable } from './lib/table'; const TAG = 'aso'; const SUBS = ['rank', 'competitors', 'suggest', 'audit']; const SNAP_RE = /^\d{4}-\d{2}-\d{2}\.json$/; const today = () => new Date().toISOString().slice(0, 10); function listingOf(ctx: Ctx): { file: string; md: string; sections: Map> } { const { config, resolve } = ctx.loaded; const file = resolve(config.apple?.listing ?? 'store/appstore.md'); const md = existsSync(file) ? readFileSync(file, 'utf8') : ''; return { file, md, sections: appleSections(md) }; } const primaryLocale = (ctx: Ctx): string => ctx.loaded.config.apple?.primaryLocale ?? ctx.loaded.config.languages.find(l => l.apple)?.apple ?? 'en-US'; function snapshots(dir: string): { file: string; data: T }[] { if (!existsSync(dir)) return []; return readdirSync(dir).filter(f => SNAP_RE.test(f)).sort().map(f => { try { return { file: f, data: JSON.parse(readFileSync(join(dir, f), 'utf8')) as T }; } catch { return null; } }).filter((x): x is { file: string; data: T } => x !== null); } /** One Search API call per item, one every `delay` ms; stops on 403 / 429 with what was read. */ async function searchAll

(plan: P[], limit: number, delay: number, each: (p: P, results: SearchApp[], i: number) => void): Promise { let read = 0; for (const [i, p] of plan.entries()) { if (i) await Bun.sleep(delay); const r = await fetch(searchUrl(p.keyword, p.storefront, limit), { headers: { Accept: 'application/json' } }); if (r.status === 403 || r.status === 429) { warn(TAG, `Search API answered ${r.status} (rate limit): stopped after ${read} of ${plan.length}; try later or with a larger --delay`); break; } if (!r.ok) { warn(TAG, `${p.storefront} "${p.keyword}": HTTP ${r.status}`); continue; } each(p, ((await r.json()) as { results?: SearchApp[] }).results ?? [], i); read++; } return read; } async function rank(ctx: Ctx): Promise { const { config, resolve } = ctx.loaded; const a = ctx.args; if (!config.apple) throw new Error('aso rank checks App Store search: the config has no apple section'); const { file: listingFile, sections: listing } = listingOf(ctx); const extra = a.list('keywords'); const fronts = parseStorefronts(a.list('storefronts'), primaryLocale(ctx)); const n = a.num('competitors', 3); const limit = a.num('limit', 200); const delay = Math.max(MIN_DELAY_MS, a.num('delay', MIN_DELAY_MS)); const app = { appId: config.apple.appId, bundleId: config.apple.bundleId }; const plan = fronts.flatMap(f => keywordList(a.has('only-keywords') ? '' : listing.get(f.locale)?.keywords, extra).map(k => ({ ...f, keyword: k }))); if (!plan.length) throw new Error(`no keywords: ${listingFile} has no keywords for ${fronts.map(f => f.locale).join(', ')}; pass --keywords`); log(TAG, `${plan.length} quer${plan.length === 1 ? 'y' : 'ies'} (${fronts.map(f => f.storefront).join(', ')}), one every ${delay / 1000}s: about ${Math.ceil(plan.length * delay / 60_000)} min`); if (a.has('dry')) { if (a.has('json')) printJson({ dryRun: true, queries: plan.map(p => ({ storefront: p.storefront, keyword: p.keyword, url: searchUrl(p.keyword, p.storefront, limit) })) }); else for (const p of plan) log(TAG, `would GET ${searchUrl(p.keyword, p.storefront, limit)}`); return; } const rows: RankRow[] = []; await searchAll(plan, limit, delay, (p, results, i) => { const row: RankRow = { storefront: p.storefront, locale: p.locale, keyword: p.keyword, rank: rankOf(results, app), results: results.length, competitors: competitors(results, app, n) }; rows.push(row); log(TAG, `${i + 1}/${plan.length} ${p.storefront} "${p.keyword}": ${row.rank ?? `not in the first ${results.length}`}`); }); const dir = resolve('.storebound/aso'); const prevFile = snapshots(dir).at(-1)?.file; const prev = prevFile ? (JSON.parse(readFileSync(join(dir, prevFile), 'utf8')) as { rows?: RankRow[] }).rows : undefined; const delta = rankDelta(rows, prev); const date = today(); if (a.has('json')) printJson({ date, previous: prevFile ?? null, rows: rows.map(r => ({ ...r, change: delta.get(`${r.storefront}|${r.keyword.toLowerCase()}`) ?? null })) }); else console.log(textTable(['store', 'keyword', 'rank', 'change', 'top competitors'], rows.map(r => { const d = delta.get(`${r.storefront}|${r.keyword.toLowerCase()}`); return [r.storefront, r.keyword, r.rank ?? '-', d === null || d === undefined ? '' : d > 0 ? `+${d}` : String(d), r.competitors.map(c => `${c.rank}. ${c.title.slice(0, 28)} (${c.developer.slice(0, 18)})`).join('; ')]; }))); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `${date}.json`), JSON.stringify({ date, app: { appId: app.appId ?? null, bundleId: app.bundleId }, rows }, null, 2) + '\n'); done(TAG, `${rows.length} rank(s) saved to ${join(dir, `${date}.json`)}${prevFile ? ` (change vs ${prevFile})` : ''}; Search API order is close to App Store search, read it as a trend`); } async function competitorsCmd(ctx: Ctx): Promise { const { config, resolve } = ctx.loaded; const a = ctx.args; if (!config.apple) throw new Error('aso competitors reads App Store search: the config has no apple section'); const { file, sections } = listingOf(ctx); const terms = a.many('term').flatMap(t => t.split(',')).map(t => t.trim()).filter(Boolean); const fronts = parseStorefronts([...a.list('storefront'), ...a.list('storefronts')], primaryLocale(ctx)); const top = Math.max(1, Math.min(50, a.num('top', 10))); const delay = Math.max(MIN_DELAY_MS, a.num('delay', MIN_DELAY_MS)); const app = { appId: config.apple.appId, bundleId: config.apple.bundleId }; const plan = fronts.flatMap(f => keywordList(a.has('only-terms') ? '' : sections.get(f.locale)?.keywords, terms).map(k => ({ ...f, keyword: k }))); if (!plan.length) throw new Error(`no terms: ${file} has no keywords for ${fronts.map(f => f.locale).join(', ')}; pass --term `); const limit = top + 1; const save = !a.has('dry') && !a.has('no-save'); if (!a.has('json')) log(TAG, `${plan.length} quer${plan.length === 1 ? 'y' : 'ies'} (${fronts.map(f => f.storefront).join(', ')}), top ${top} each, one every ${delay / 1000}s: about ${Math.ceil(plan.length * delay / 60_000)} min`); if (a.has('dry')) { if (a.has('json')) printJson({ dryRun: true, top, queries: plan.map(p => ({ storefront: p.storefront, locale: p.locale, term: p.keyword, url: searchUrl(p.keyword, p.storefront, limit) })) }); else for (const p of plan) log(TAG, `would GET ${searchUrl(p.keyword, p.storefront, limit)}`); return; } const rows: CompetitorRow[] = []; await searchAll(plan, limit, delay, (p, results, i) => { rows.push(competitorRow(results, app, { storefront: p.storefront, locale: p.locale, term: p.keyword }, top)); if (!a.has('json')) log(TAG, `${i + 1}/${plan.length} ${p.storefront} "${p.keyword}": ${results.length} result(s)`); }); const dir = resolve('.storebound/aso/competitors'); const prev = snapshots(dir).filter(s => s.file !== `${today()}.json`).at(-1); const fresh = newEntrants(rows, prev?.data.rows); const gaps = fronts.map(f => { const s = sections.get(f.locale) ?? {}; const covered = [...tokens(s.name ?? appleName(config), f.locale), ...tokens(s.subtitle ?? '', f.locale), ...(s.keywords ?? '').split(',').flatMap(t => tokens(t, f.locale))]; return { storefront: f.storefront, locale: f.locale, gaps: coverageGaps(rows.filter(r => r.storefront === f.storefront && r.locale === f.locale), covered, f.locale) }; }); const snap: CompetitorSnapshot = { date: today(), app: { appId: app.appId ?? null, bundleId: app.bundleId }, top, rows }; if (a.has('json')) printJson({ ...snap, previous: prev?.file ?? null, newEntrants: Object.fromEntries(fresh), gaps, saved: save, notes: ['Search API order is close to, not equal to, App Store search', 'competitor subtitles are not in any public API'] }); else { for (const r of rows) { console.log(`\n${r.storefront} "${r.term}"${r.ourRank ? ` (you: #${r.ourRank})` : ''}`); console.log(textTable(['#', 'app', 'developer', 'ratings', 'released', 'updated', 'genre'], r.apps.map(x => [x.rank, x.name.slice(0, 40), x.developer.slice(0, 24), x.ratings, x.released ?? '-', x.updated ?? '-', x.genres[0]?.name || x.genre]))); const n = fresh.get(`${r.storefront}|${r.term.toLowerCase()}`); if (n?.length) log(TAG, `new in the top ${top} since ${prev!.data.date}: ${n.join(', ')}`); } for (const g of gaps) console.log(`\n${g.storefront} ${g.locale}: words in competitors' names you do not cover: ${g.gaps.length ? g.gaps.map(x => `${x.word} (${x.apps} apps, ${x.developers} developers)`).join(', ') : 'none'}`); log(TAG, 'subtitles are not in any public API, so they are not shown; words one developer uses are left out (likely a brand); never put other apps\' names into your keywords'); } if (save) { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `${snap.date}.json`), JSON.stringify(snap, null, 2) + '\n'); if (!a.has('json')) done(TAG, `${rows.length} term(s) saved to ${join(dir, `${snap.date}.json`)}`); } } async function suggest(ctx: Ctx): Promise { const { config, resolve, root } = ctx.loaded; const a = ctx.args; const json = a.has('json'); if (!config.apple) throw new Error('aso suggest proposes App Store keyword fields: the config has no apple section'); const { file, md } = listingOf(ctx); if (!md) throw new Error(`${file} not found`); const listing = parseAppStoreListing(md); const locales = a.list('locale').length ? a.list('locale') : Object.keys(listing); for (const l of locales) if (!listing[l]) throw new Error(`--locale ${l}: no "## ${l}" section in ${file}`); const comp = snapshots(resolve('.storebound/aso/competitors')).at(-1)?.data ?? null; const cats = Object.values(config.apple.info?.categories ?? {}).filter((x): x is string => typeof x === 'string'); // Apple Ads keyword suggestions: only with the owner's credentials and an Apple ID; never estimated without them let ads: ReturnType | null = null; let adsNote = ''; if (a.has('offline')) adsNote = '--offline: Apple Ads not asked; popularity unknown'; else if (!config.apple.appId) adsNote = 'no apple.appId in the config: Apple Ads suggestions need the app\'s Apple ID; popularity unknown'; else { try { ads = adsClient(root); if (!ads.accountId) { ads = null; adsNote = 'APPLE_ADS_ACCOUNT_ID is not set: popularity unknown'; } } catch (e) { adsNote = `no Apple Ads credentials (${(e as Error).message.split(';')[0]}): popularity unknown`; } } const results: { locale: string; storefront: string; ads: string; competitors: string | null; candidates: ReturnType; proposal: ReturnType }[] = []; for (const locale of locales) { const c = listing[locale]!; const sf = storefrontOf(locale); const rows = (comp?.rows ?? []).filter(r => r.locale === locale); const name = c.name ?? appleName(config); let sugg: AdsSuggestion[] = []; let adsState = adsNote; if (ads) { const seeds = [...new Set([...tokens(name, locale), ...tokens(c.subtitle ?? '', locale), ...(c.keywords ?? '').split(',').map(t => t.trim()).filter(Boolean)])].slice(0, 20); try { sugg = parseKeywordSuggestions(await ads.post(ADS_KEYWORD_SUGGESTIONS, keywordSuggestionBody(config.apple.appId!, seeds, [sf]))); adsState = `Apple Ads keyword suggestions (${sf.toUpperCase()}): ${sugg.length}; popularity is Apple's relative 0-100 score, not a search volume`; } catch (e) { adsState = `Apple Ads keyword suggestions failed (${(e as Error).message.slice(0, 160)}): popularity unknown`; } } const cands = candidates({ locale, name, subtitle: c.subtitle ?? '', keywords: c.keywords ?? '', description: c.description ?? '', competitors: rows, categories: categoryNames(rows, cats), ads: sugg }); const proposal = fitKeywordField({ locale, current: c.keywords ?? '', name, subtitle: c.subtitle ?? '', ranked: cands, fresh: a.has('fresh') }); results.push({ locale, storefront: sf, ads: adsState, competitors: rows.length ? `${comp!.date}: ${rows.length} term(s)` : null, candidates: cands, proposal }); } const write = a.has('write') && !a.has('dry'); const changed = results.filter(r => r.proposal.after !== r.proposal.before); if (json) printJson({ file, written: write && changed.length > 0, results: results.map(r => ({ ...r, candidates: r.candidates.slice(0, 60) })) }); else { for (const r of results) { console.log(`\n== ${r.locale} (storefront ${r.storefront}) · competitors: ${r.competitors ?? 'no snapshot (storebound aso competitors)'} · ${r.ads}`); const shown = r.candidates.filter(c => c.status === 'candidate').slice(0, 25); console.log(textTable(['candidate', 'from', 'relevance', 'in names', 'popularity'], shown.map(c => [c.term, c.sources.join('+'), `${c.relevance}/2`, `${c.inNames}/${c.ofApps}`, c.popularity === null ? 'unknown' : `${c.popularity}${c.popularityFrom === 'phrase' ? ' (phrase)' : ''}`]))); const skipped = r.candidates.filter(c => c.status !== 'candidate' && c.kind === 'word').slice(0, 12); if (skipped.length) log(TAG, `left out: ${skipped.map(c => `${c.term} (${c.why})`).join(', ')}`); const p = r.proposal; if (p.after === p.before) { log(TAG, `${r.locale}: keyword field unchanged (${p.bytes}/100 bytes)`); continue; } console.log(`--- ${r.locale} keywords (store/appstore.md)\n- ${p.before || '(empty)'}\n+ ${p.after}\n ${p.bytes}/100 bytes · kept ${p.kept.length} · added ${p.added.join(', ') || 'none'}${p.dropped.length ? ` · dropped ${p.dropped.map(d => `${d.term} (${d.why})`).join(', ')}` : ''}`); } log(TAG, 'relevance: in your own text (+1) and in competitors\' names (+1); "in names": top apps that already have the word in their name; popularity only from Apple Ads, never estimated'); } if (!changed.length) return; if (!write) { if (!json) log(TAG, `dry run: nothing written; --write puts the ${changed.length} proposal(s) into ${file} as drafts for review`); return; } let next = md; for (const r of changed) next = setKeywords(next, r.locale, r.proposal.after, draftMark(today())); writeFileSync(file, next); if (!json) done(TAG, `wrote ${changed.length} draft keyword field(s) to ${file}: review them, delete the draft lines, then storebound check --aso`); } function audit(ctx: Ctx): void { const { config, resolve } = ctx.loaded; const a = ctx.args; const storefront = (a.get('storefront') ?? 'US').toUpperCase(); const { file, md } = listingOf(ctx); const apple = config.apple && md ? parseAppStoreListing(md) : undefined; const playFile = resolve(config.play?.listing ?? 'store/listing.md'); const play = config.play && existsSync(playFile) ? parsePlayListing(readFileSync(playFile, 'utf8'), config.app.name) : undefined; const findings = auditAso({ apple, play, appName: appleName(config), storefront }); const ranks = snapshots(resolve('.storebound/aso')).map(s => ({ date: s.data.date, rows: s.data.rows ?? [] })); const comp = snapshots(resolve('.storebound/aso/competitors')).at(-1)?.data ?? null; const analytics = mergeSeries(snapshots(resolve('.storebound/analytics')).map(s => s.data)).apple; const r = buildAudit({ today: today(), storefront, findings, drafts: md ? draftLocales(md) : [], apple, appName: appleName(config), play, ranks, competitors: comp, analytics, variants: Object.keys(config.shots?.variants ?? {}) }); if (a.has('json')) return printJson({ ...r, files: { appstore: apple ? file : null, play: play ? playFile : null } }); console.log(auditText(r)); } export async function run(ctx: Ctx): Promise { const sub = ctx.args._[0]; if (!sub || !SUBS.includes(sub)) throw new Error('usage: storebound aso rank | competitors | suggest | audit (see the aso-basics skill)'); if (sub === 'rank') return rank(ctx); if (sub === 'competitors') return competitorsCmd(ctx); if (sub === 'suggest') return suggest(ctx); return audit(ctx); }