storebound/cli/lib/aso-audit.ts

221 lines
17 KiB
TypeScript

// `aso audit`: one offline report over everything storebound already keeps, as pure parts. Inputs: the `check --aso`
// findings and the cross-locale free-keyword-room hint (cli/lib/aso.ts), the rank snapshots of `aso rank`
// (.storebound/aso/<date>.json, trend arrows), the latest competitor snapshot of `aso competitors`
// (.storebound/aso/competitors/<date>.json, coverage gaps), the Play listing (keyword density, short description use),
// the App Store analytics snapshots (.storebound/analytics, impressions / downloads / conversion) and the screenshot
// variants of the config. Output: the report, experiment ideas (suggestions only: nothing is started) and the next 3
// concrete actions. No network, no writes.
import type { AppleDay } from './analytics';
import type { AsoFinding } from './aso';
import { words } from './aso';
import { type CompetitorSnapshot, coverageGaps, type Gap, isStop, tokens } from './aso-intel';
import type { RankRow } from './aso-rank';
import type { AppleCopy, PlayCopy } from './copy';
import { len } from './copy';
import { textTable } from './table';
export interface RankSnap { date: string; rows: RankRow[] }
export type Arrow = '↑' | '↓' | '→' | 'new' | 'lost' | '·';
export interface TrendRow { storefront: string; keyword: string; rank: number | null; previous: number | null; change: number | null; arrow: Arrow; best: number | null; snapshots: number }
const k = (r: { storefront: string; keyword: string }) => `${r.storefront}|${r.keyword.toLowerCase()}`;
/** Latest rank per storefront + keyword against the snapshot before it (positive change = moved up). Pure. */
export function rankTrend(snaps: RankSnap[]): { rows: TrendRow[]; dates: string[]; series: { storefront: string; keyword: string; ranks: (number | null)[] }[] } {
const sorted = [...snaps].sort((a, b) => a.date.localeCompare(b.date));
const dates = sorted.map(s => s.date);
const hist = new Map<string, { storefront: string; keyword: string; ranks: (number | null | undefined)[] }>();
sorted.forEach((s, i) => {
for (const r of s.rows) {
const h = hist.get(k(r)) ?? { storefront: r.storefront, keyword: r.keyword, ranks: Array(sorted.length).fill(undefined) };
h.ranks[i] = r.rank;
hist.set(k(r), h);
}
});
const rows: TrendRow[] = [];
for (const h of hist.values()) {
const seen = h.ranks.map((v, i) => ({ v, i })).filter(x => x.v !== undefined) as { v: number | null; i: number }[];
const last = seen.at(-1)!, prev = seen.at(-2);
const rank = last.v, previous = prev ? prev.v : null;
const arrow: Arrow = !prev ? (rank === null ? '·' : 'new') : rank === null ? (previous === null ? '·' : 'lost') : previous === null ? 'new'
: rank < previous ? '↑' : rank > previous ? '↓' : '→';
const known = seen.map(x => x.v).filter((v): v is number => v !== null);
rows.push({ storefront: h.storefront, keyword: h.keyword, rank, previous, change: rank !== null && previous !== null ? previous - rank : null, arrow,
best: known.length ? Math.min(...known) : null, snapshots: seen.length });
}
rows.sort((a, b) => a.storefront.localeCompare(b.storefront) || (a.rank ?? 999) - (b.rank ?? 999) || a.keyword.localeCompare(b.keyword));
const series = [...hist.values()].map(h => ({ storefront: h.storefront, keyword: h.keyword, ranks: h.ranks.map(v => v ?? null) }));
return { rows, dates, series };
}
export interface PlayUse { lang: string; title: string; shortChars: number; shortRoom: number; titleWordsInShort: boolean; shortWordsInOpening: boolean; top: { word: string; count: number; density: number }[]; stuffed: string[] }
/** Play: how the short description is used and which words the full description leans on (density). Pure. */
export function playUse(c: PlayCopy): PlayUse {
const L = c.lang;
const full = words(c.full, L);
const counts = new Map<string, number>();
for (const w of full) if (w.length > 3 && !isStop(w, L)) counts.set(w, (counts.get(w) ?? 0) + 1);
const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5)
.map(([word, count]) => ({ word, count, density: full.length ? count / full.length : 0 }));
const titleWords = tokens(c.title, L).filter(w => w.length > 2);
const shortWords = new Set(tokens(c.short, L));
const opening = new Set(words(c.full.split('\n').slice(0, 3).join(' '), L));
return {
lang: L, title: c.title, shortChars: len(c.short), shortRoom: 80 - len(c.short),
titleWordsInShort: titleWords.some(w => shortWords.has(w)),
shortWordsInOpening: [...shortWords].some(w => w.length > 3 && opening.has(w)),
top, stuffed: top.filter(t => full.length >= 80 && t.count >= 6 && t.density > 0.03).map(t => t.word),
};
}
export interface Experiment { store: 'apple' | 'play'; what: string; why: string; how: string }
export interface Action { what: string; why: string; command?: string }
export interface AuditInput {
today: string;
storefront: string;
findings: AsoFinding[];
drafts: string[];
apple?: Record<string, AppleCopy>;
appName: string;
play?: PlayCopy[];
ranks: RankSnap[];
competitors: CompetitorSnapshot | null;
analytics: AppleDay[];
variants: string[];
}
export interface AsoAudit {
generated: string; storefront: string;
keywordField: { errors: number; warnings: number; hints: number; findings: AsoFinding[] };
crossLocale: string | null;
drafts: string[];
ranks: { snapshots: number; latest: string | null; rows: TrendRow[]; dates: string[]; series: { storefront: string; keyword: string; ranks: (number | null)[] }[] };
competitors: { date: string | null; gaps: { storefront: string; locale: string; gaps: Gap[] }[] };
play: PlayUse[];
/** check --aso findings of the Play listing (stuffing, claims, caps, emoji, short description room). */
playFindings: AsoFinding[];
analytics: { days: number; start: string; end: string; impressions: number; pageViews: number; downloads: number; conversion: number | null } | null;
experiments: Experiment[];
next: Action[];
}
const daysBetween = (a: string, b: string) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000);
/** The whole audit. Pure. */
export function buildAudit(i: AuditInput): AsoAudit {
const kwFindings = i.findings.filter(f => f.where.startsWith('apple ') && !/storefront$/.test(f.where));
const playFindings = i.findings.filter(f => f.where.startsWith('play '));
const cross = i.findings.find(f => /storefront$/.test(f.where))?.msg ?? null;
const trend = rankTrend(i.ranks);
const gaps: AsoAudit['competitors']['gaps'] = [];
if (i.competitors) {
const groups = new Map<string, CompetitorSnapshot['rows']>();
for (const r of i.competitors.rows) groups.set(`${r.storefront}|${r.locale}`, [...(groups.get(`${r.storefront}|${r.locale}`) ?? []), r]);
for (const [g, rows] of groups) {
const [storefront, locale] = g.split('|') as [string, string];
const c = i.apple?.[locale];
const covered = [...words(c?.name ?? i.appName, locale), ...words(c?.subtitle ?? '', locale), ...(c?.keywords ?? '').split(',').flatMap(t => words(t, locale))];
gaps.push({ storefront, locale, gaps: coverageGaps(rows, covered, locale, 8) });
}
}
const play = (i.play ?? []).map(playUse);
const last = i.analytics.slice(-28);
const sum = (f: (d: AppleDay) => number) => last.reduce((n, d) => n + f(d), 0);
const analytics = last.length ? { days: last.length, start: last[0]!.date, end: last.at(-1)!.date, impressions: sum(d => d.uniqueImpressions || d.impressions),
pageViews: sum(d => d.uniquePageViews || d.pageViews), downloads: sum(d => d.firstTimeDownloads || d.downloads), conversion: null as number | null } : null;
if (analytics) analytics.conversion = analytics.impressions ? analytics.downloads / analytics.impressions : null;
const a: AsoAudit = {
generated: i.today, storefront: i.storefront,
keywordField: { errors: kwFindings.filter(f => f.level === 'error').length, warnings: kwFindings.filter(f => f.level === 'warn').length, hints: kwFindings.filter(f => f.level === 'info').length, findings: kwFindings },
crossLocale: cross, drafts: i.drafts,
ranks: { snapshots: i.ranks.length, latest: trend.dates.at(-1) ?? null, rows: trend.rows, dates: trend.dates, series: trend.series },
competitors: { date: i.competitors?.date ?? null, gaps },
play, playFindings, analytics, experiments: [], next: [],
};
a.experiments = experimentIdeas(a, i);
a.next = nextActions(a, i);
return a;
}
/** Product Page Optimization (Apple) and store listing experiment (Play) ideas from the audit. Suggestions only. Pure. */
export function experimentIdeas(a: AsoAudit, i: AuditInput): Experiment[] {
const out: Experiment[] = [];
if (i.apple) {
const v = i.variants[0];
out.push({ store: 'apple', what: 'first screenshot caption: lead with the search phrase people use for the app\'s main job',
why: a.analytics?.conversion !== null && a.analytics ? `App Store conversion (first-time downloads / unique impressions, last ${a.analytics.days} days) is ${(a.analytics.conversion! * 100).toFixed(1)}%: the first screenshot is what search results show` : 'the first screenshot is what search results show; PPO measures it against the original',
how: v ? `variant "${v}" exists: storebound shots render --variant ${v}, then storebound apple ppo create --variant ${v} --dry and apple ppo upload --variant ${v} --dry (starting the test is the owner's step: apple ppo start)`
: 'add a shots.variants entry with the new caption, storebound shots render --variant b, then storebound apple ppo create --variant b --dry (starting the test is the owner\'s step: apple ppo start)' });
out.push({ store: 'apple', what: 'subtitle variant',
why: 'PPO tests only icons, screenshots and app previews: the subtitle cannot be A/B tested on the App Store',
how: 'change it with the next version and compare before / after with storebound analytics compare (one change at a time)' });
}
for (const p of a.play.slice(0, 1)) {
out.push({ store: 'play', what: `short description variant (${p.lang})`,
why: p.shortRoom > 30 ? `the short description uses ${p.shortChars}/80 characters` : !p.titleWordsInShort ? 'the short description shares no word with the title' : 'the short description is the first text on the listing',
how: 'Play Console > Grow users > Store listing experiments (no API: storebound only prepares the text; the store-screenshots skill has the steps)' });
if (i.variants.length) out.push({ store: 'play', what: `screenshot variant "${i.variants[0]}"`, why: 'graphics experiments need no new version',
how: `storebound shots render --variant ${i.variants[0]} --size play, then upload it as the experiment's variant in Play Console` });
}
return out;
}
/** The next 3 concrete actions, most important first. Pure. */
export function nextActions(a: AsoAudit, i: AuditInput): Action[] {
const out: Action[] = [];
const f = a.keywordField.findings;
const err = f.find(x => x.level === 'error');
if (err) out.push({ what: `fix ${err.where}: ${err.msg}`, why: 'App Store Connect refuses the keyword field as it is', command: 'storebound check --aso' });
if (a.drafts.length) out.push({ what: `review the draft keywords of ${a.drafts.join(', ')} in store/appstore.md and delete the draft line`, why: 'they were proposed by aso suggest, not reviewed yet' });
const room = f.find(x => /characters used; fill/.test(x.msg));
if (room) out.push({ what: `fill the keyword field of ${room.where.replace('apple ', '')} (${room.msg.replace(/^keywords: /, '')})`, why: 'unused characters are unused search reach', command: `storebound aso suggest --locale ${room.where.replace('apple ', '')}` });
const rep = f.find(x => /repeat words from the name/.test(x.msg));
if (rep) out.push({ what: `${rep.where}: replace the words the name / subtitle already index (${rep.msg.split(': ').at(-1)})`, why: 'they take characters without adding reach', command: `storebound aso suggest --locale ${rep.where.replace('apple ', '')}` });
const gap = a.competitors.gaps.find(g => g.gaps.length);
if (gap) out.push({ what: `consider "${gap.gaps.slice(0, 3).map(g => g.word).join('", "')}" for ${gap.locale} (in the names of ${gap.gaps[0]!.developers} developers' top apps, not in your name / subtitle / keywords)`, why: 'words the top apps lead with; only if they describe your app', command: `storebound aso suggest --locale ${gap.locale}` });
const lost = a.ranks.rows.filter(r => r.arrow === '↓' || r.arrow === 'lost');
if (lost.length) out.push({ what: `look at the keywords that dropped: ${lost.slice(0, 3).map(r => `${r.storefront} "${r.keyword}" ${r.previous ?? '-'} -> ${r.rank ?? '-'}`).join(', ')}`, why: 'a drop after a listing change is the signal to revert or adjust it' });
if (!a.ranks.latest) out.push({ what: 'take a first rank snapshot', why: 'trends need a baseline before the next listing change', command: 'storebound aso rank --dry, then storebound aso rank' });
else if (daysBetween(a.ranks.latest, i.today) > 14) out.push({ what: `take a new rank snapshot (the last is from ${a.ranks.latest})`, why: 'trend arrows compare the last two snapshots', command: 'storebound aso rank' });
if (!a.competitors.date) out.push({ what: 'read the competitors for your keywords', why: 'coverage gaps and suggest need the top apps per term', command: 'storebound aso competitors --dry, then storebound aso competitors' });
for (const p of a.play) {
if (p.stuffed.length) out.push({ what: `Play ${p.lang}: use "${p.stuffed.join('", "')}" less often in the full description`, why: 'reads as keyword stuffing (Play metadata policy)' });
else if (p.shortRoom > 30) out.push({ what: `Play ${p.lang}: put the core phrase into the short description (${p.shortChars}/80 used)`, why: 'Play indexes the short description' });
}
if (!a.analytics) out.push({ what: 'start App Store analytics snapshots', why: 'conversion before / after each listing change is the real measure', command: 'storebound analytics setup --dry, then storebound analytics snapshot' });
return out.slice(0, 3);
}
const signed = (n: number | null) => (n === null ? '' : n > 0 ? `+${n}` : String(n));
/** The report as text. Pure. */
export function auditText(a: AsoAudit): string {
const o: string[] = [];
const kf = a.keywordField;
o.push(`ASO audit ${a.generated} (offline: listing files, .storebound/aso snapshots, .storebound/analytics)`, '');
o.push(`1. Keyword field (check --aso): ${kf.errors} errors, ${kf.warnings} warnings, ${kf.hints} hints`);
for (const f of kf.findings.filter(x => x.level !== 'info').slice(0, 8)) o.push(` ${f.level === 'error' ? 'ERROR' : 'warn '} [${f.where}] ${f.msg}`);
if (a.drafts.length) o.push(` draft keywords (from aso suggest, not reviewed): ${a.drafts.join(', ')}`);
if (a.crossLocale) o.push(` ${a.storefront} storefront: ${a.crossLocale}`);
o.push('', `2. Ranks (aso rank, iTunes Search API order; a trend, not App Store search itself): ${a.ranks.snapshots} snapshot(s)${a.ranks.latest ? `, latest ${a.ranks.latest}` : ''}`);
if (a.ranks.rows.length) o.push(indent(textTable(['store', 'keyword', 'rank', '', 'change', 'best'], a.ranks.rows.map(r => [r.storefront, r.keyword, r.rank ?? '-', r.arrow, signed(r.change), r.best ?? '-']))));
else o.push(' none yet: storebound aso rank');
o.push('', `3. Competitor coverage (aso competitors${a.competitors.date ? `, ${a.competitors.date}` : ''}): words in the top apps' names you do not cover`);
if (!a.competitors.date) o.push(' no snapshot yet: storebound aso competitors');
for (const g of a.competitors.gaps) o.push(` ${g.storefront} ${g.locale}: ${g.gaps.length ? g.gaps.map(x => `${x.word} (${x.apps} apps, ${x.developers} devs)`).join(', ') : 'none'}`);
o.push('', '4. Google Play listing');
if (!a.play.length) o.push(' no Play listing');
for (const f of a.playFindings.filter(x => x.level !== 'info')) o.push(` warn [${f.where}] ${f.msg}`);
for (const p of a.play) o.push(` ${p.lang}: short ${p.shortChars}/80${p.titleWordsInShort ? '' : ', no title word in it'}${p.shortWordsInOpening ? '' : ', opening lines share no word with it'}; top words ${p.top.map(t => `${t.word} ${t.count}x (${(t.density * 100).toFixed(1)}%)`).join(', ') || '-'}${p.stuffed.length ? `; STUFFING: ${p.stuffed.join(', ')}` : ''}`);
o.push('', '5. App Store analytics (analytics snapshot)');
o.push(a.analytics ? ` ${a.analytics.start} .. ${a.analytics.end} (${a.analytics.days} days): ${a.analytics.impressions} unique impressions, ${a.analytics.pageViews} product page views, ${a.analytics.downloads} first-time downloads, conversion ${a.analytics.conversion === null ? 'n/a' : `${(a.analytics.conversion * 100).toFixed(2)}%`}` : ' no snapshots: storebound analytics snapshot');
o.push('', '6. Experiment ideas (suggestions only; nothing is started)');
for (const e of a.experiments) o.push(` [${e.store === 'apple' ? 'App Store PPO' : 'Play experiment'}] ${e.what}`, ` why: ${e.why}`, ` how: ${e.how}`);
o.push('', 'Next 3 actions');
a.next.forEach((x, n) => o.push(` ${n + 1}. ${x.what}`, ` why: ${x.why}${x.command ? `; run: ${x.command}` : ''}`));
return o.join('\n');
}
const indent = (s: string) => s.split('\n').map(l => ` ${l}`).join('\n');