98 lines
6.7 KiB
TypeScript
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;
|
|
}
|