storebound/cli/lib/aso.ts

98 lines
6.7 KiB
TypeScript

// Offline keyword audit over the two listing sources (store/appstore.md, store/listing.md). Pure.
// Rules are the boring, well-documented ones: use the App Store keyword field fully, do not waste it on words the
// name / subtitle already index, no duplicates, no stuffing in Play descriptions. Nothing here guesses search volume.
import type { AppleCopy, PlayCopy } from './copy';
import { len } from './copy';
export type AsoLevel = 'error' | 'warn' | 'info';
export interface AsoFinding { level: AsoLevel; where: string; msg: string }
/** Storefront -> App Store localizations that storefront supports (Apple's "App Store localizations" table, App Store
* Connect Help). Apple says keywords of a localization are searchable in every storefront that supports its language.
* That several localizations' keyword fields STACK in one storefront's search is community ASO knowledge, not an Apple
* statement: use it as a hint and verify with your own data. Add rows for other storefronts from Apple's table. */
export const CROSS_LOCALES: Record<string, string[]> = {
US: ['en-US', 'ar-SA', 'zh-Hans', 'zh-Hant', 'fr-FR', 'ko', 'pt-BR', 'ru', 'es-MX', 'vi'],
};
/** Words that add nothing in the keyword field (Apple: the category name and "app" are matched anyway). */
export const FILLER = new Set(['app', 'apps', 'game', 'games', 'free', 'the', 'and', 'for', 'with', 'a', 'an', 'of', 'iphone', 'ipad', 'ios']);
export const words = (s: string, locale: string): string[] =>
s.toLocaleLowerCase(locale).split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 1);
/** One locale of the App Store listing: keyword field rules. */
export function auditAppleKeywords(locale: string, c: AppleCopy, appName: string): AsoFinding[] {
const out: AsoFinding[] = [];
const at = `apple ${locale}`;
const kw = c.keywords ?? '';
if (!kw) return [{ level: 'error', where: at, msg: 'keywords: empty' }];
// App Store Connect limits the keyword field to 100 characters, not UTF-8 bytes: live versions (READY_FOR_SALE,
// read 2026-10-13) hold Thai 95 characters / 265 bytes, Russian 91 / 169, Turkish 100 / 105, Polish 99 / 105
// (store-pitfalls 43). Both the limit and the fill hint count characters.
const chars = len(kw);
if (chars > 100) out.push({ level: 'error', where: at, msg: `keywords: ${chars} characters > 100` });
if (/,\s|\s,/.test(kw)) out.push({ level: 'error', where: at, msg: 'keywords: spaces around commas waste characters' });
if (chars < 90) out.push({ level: 'warn', where: at, msg: `keywords: ${chars}/100 characters used; fill at least 90 (${100 - chars} characters free)` });
const terms = kw.split(',').map(t => t.trim().toLocaleLowerCase(locale)).filter(Boolean);
const seen = new Set<string>();
for (const t of terms) {
if (seen.has(t)) out.push({ level: 'warn', where: at, msg: `keywords: "${t}" listed twice` });
seen.add(t);
}
const indexed = new Set([...words(c.name ?? appName, locale), ...words(c.subtitle ?? '', locale)]);
const kwWords = terms.flatMap(t => words(t, locale));
const repeated = [...new Set(kwWords.filter(w => indexed.has(w)))];
if (repeated.length) out.push({ level: 'warn', where: at, msg: `keywords repeat words from the name / subtitle (already indexed): ${repeated.join(', ')}` });
const filler = [...new Set(kwWords.filter(w => FILLER.has(w)))];
if (filler.length) out.push({ level: 'info', where: at, msg: `keywords: filler words that rarely help: ${filler.join(', ')}` });
const multi = terms.filter(t => /\s/.test(t));
if (multi.length) out.push({ level: 'info', where: at, msg: `keywords: multi-word phrases (${multi.join(' | ')}); single words combine on their own and save characters` });
return out;
}
/** Which extra locales a storefront is commonly said to index, and which of them the listing fills. */
export function crossLocaleHint(storefront: string, locales: string[]): AsoFinding | null {
const extra = CROSS_LOCALES[storefront];
if (!extra) return null;
const have = extra.filter(l => locales.includes(l));
const free = extra.filter(l => !locales.includes(l));
return { level: 'info', where: `apple ${storefront} storefront`, msg:
`supports ${extra.join(', ')} (Apple table); stacked keyword indexing across them is unverified community knowledge. You fill ${have.join(', ') || 'none'}; ` +
`${free.length ? `free keyword room in ${free.join(', ')}: a locale there can carry extra keywords, but its text must still be a real translation` : 'all covered'}` };
}
/** Play: keyword placement without stuffing. Play indexes title, short and full description. */
export function auditPlayListing(c: PlayCopy): AsoFinding[] {
const out: AsoFinding[] = [];
const at = `play ${c.lang}`;
const loc = c.lang;
if (len(c.short) < 50) out.push({ level: 'info', where: at, msg: `short description ${len(c.short)}/80: room for the core keyword phrase` });
const full = words(c.full, loc);
if (full.length >= 80) {
const counts = new Map<string, number>();
for (const w of full) if (w.length > 3 && !FILLER.has(w)) counts.set(w, (counts.get(w) ?? 0) + 1);
for (const [w, n] of counts) {
const density = n / full.length;
if (n >= 6 && density > 0.03) out.push({ level: 'warn', where: at, msg: `full description: "${w}" ${n}x (${(density * 100).toFixed(1)}%): reads as keyword stuffing (Play metadata policy)` });
}
}
const firstLines = c.full.split('\n').slice(0, 3).join(' ');
const shortWords = new Set(words(c.short, loc).filter(w => w.length > 3));
if (shortWords.size && !words(firstLines, loc).some(w => shortWords.has(w)))
out.push({ level: 'info', where: at, msg: 'full description: the first lines share no word with the short description; lead with the core phrase' });
if (/\b(best|#1|no\.? ?1|top)\b/i.test(`${c.title} ${c.short}`)) out.push({ level: 'warn', where: at, msg: 'title / short: "best", "#1", "top" style claims are not allowed in Play metadata' });
if (/[A-Z]{6,}/.test(c.title) && c.title === c.title.toUpperCase()) out.push({ level: 'warn', where: at, msg: 'title: all caps (not allowed unless it is the brand)' });
if (/\p{Extended_Pictographic}/u.test(`${c.title} ${c.short}`)) out.push({ level: 'warn', where: at, msg: 'title / short: emoji are not allowed there' });
return out;
}
export interface AsoInput { apple?: Record<string, AppleCopy>; play?: PlayCopy[]; appName: string; storefront?: string }
export function auditAso(i: AsoInput): AsoFinding[] {
const out: AsoFinding[] = [];
for (const [locale, c] of Object.entries(i.apple ?? {})) out.push(...auditAppleKeywords(locale, c, i.appName));
if (i.apple) { const h = crossLocaleHint(i.storefront ?? 'US', Object.keys(i.apple)); if (h) out.push(h); }
for (const c of i.play ?? []) out.push(...auditPlayListing(c));
return out;
}