222 lines
17 KiB
TypeScript
222 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 { consoleLink } from './console-links';
|
|
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: `${consoleLink('play.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');
|