// 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 = { 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(); 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(); 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; 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; }