261 lines
19 KiB
TypeScript
261 lines
19 KiB
TypeScript
// 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 <kw>]... [--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/<date>.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/<date>.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';
|
|
import * as rt from './lib/rt';
|
|
|
|
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<string, Record<string, string | undefined>> } {
|
|
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<T>(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<P extends { storefront: string; keyword: string }>(plan: P[], limit: number, delay: number, each: (p: P, results: SearchApp[], i: number) => void): Promise<number> {
|
|
let read = 0;
|
|
for (const [i, p] of plan.entries()) {
|
|
if (i) await rt.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<void> {
|
|
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<unknown>(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<void> {
|
|
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 <keyword>`);
|
|
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<CompetitorSnapshot>(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<void> {
|
|
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<CompetitorSnapshot>(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<typeof adsClient> | 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<typeof candidates>; proposal: ReturnType<typeof fitKeywordField> }[] = [];
|
|
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.chars}/100 characters)`); continue; }
|
|
console.log(`--- ${r.locale} keywords (store/appstore.md)\n- ${p.before || '(empty)'}\n+ ${p.after}\n ${p.chars}/100 characters · 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<RankSnap>(resolve('.storebound/aso')).map(s => ({ date: s.data.date, rows: s.data.rows ?? [] }));
|
|
const comp = snapshots<CompetitorSnapshot>(resolve('.storebound/aso/competitors')).at(-1)?.data ?? null;
|
|
const analytics = mergeSeries(snapshots<Snapshot>(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<void> {
|
|
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);
|
|
}
|