358 lines
23 KiB
TypeScript
358 lines
23 KiB
TypeScript
// ASO keyword research from official and public data only, as pure parts. The commands are in cli/aso.ts
|
|
// (`aso competitors`, `aso suggest`); the audit report is cli/lib/aso-audit.ts.
|
|
//
|
|
// Data sources and what each number is (and is not):
|
|
// - iTunes Search API (public, no account; https://performance-partners.apple.com/search-api): the top N apps for a
|
|
// term in one storefront, with name (trackName), developer, rating count, first release date, last update, genre
|
|
// and the genre names localized to that storefront ("genres": "Produktivität" for country=de). Its order is close
|
|
// to, not equal to, App Store search. It has no subtitle field, so competitor subtitles are never shown.
|
|
// - Apple Ads Platform API, POST /v1/suggestions/keywords/query (only with the owner's Apple Ads credentials):
|
|
// keyword suggestions for the app (promotedObjectId = Apple ID, promotedObjectType APPSTORE_APP), seeded with
|
|
// `terms` and scoped with `countriesOrRegions`; each KeywordSuggestion is { text, popularity } where popularity is
|
|
// "a relative popularity score (not an absolute volume)" on a 0-100 scale. That is the only popularity signal
|
|
// storebound shows; without credentials popularity stays "unknown" (never estimated).
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/query-keyword-suggestions
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/keywordsuggestion
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/recommendationqueryrequest (pageSize 1..1000)
|
|
// (The Recommendations endpoints are budget / target CPA only; Insights has search term popularity 1-5 by genre,
|
|
// not used here.)
|
|
// - The app's own listing (store/appstore.md): name, subtitle, keyword field and description.
|
|
// Nothing here scrapes a store web page or calls a third-party ASO service.
|
|
import { FILLER, words } from './aso';
|
|
import type { SearchApp } from './aso-rank';
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Words
|
|
|
|
/** Function words that never make a keyword (a few languages; extend as needed). FILLER (app, game, free ...) too. */
|
|
const STOP: Record<string, string[]> = {
|
|
en: ['to', 'in', 'on', 'at', 'by', 'or', 'is', 'it', 'its', 'you', 'your', 'our', 'we', 'my', 'me', 'all', 'one', 'new', 'more', 'from', 'that', 'this', 'are', 'be', 'can', 'get', 'up', 'out', 'as', 'so', 'no', 'not', 'any', 'every', 'each', 'just', 'into', 'than', 'then', 'also', 'will', 'has', 'have', 'only', 'when', 'what', 'who', 'how', 'way', 'like', 'use', 'make', 'keep', 'see'],
|
|
de: ['der', 'die', 'das', 'und', 'mit', 'für', 'von', 'zu', 'ein', 'eine', 'ist', 'im', 'in', 'auf', 'den', 'dem', 'des', 'sie', 'du', 'dein', 'deine', 'alle', 'oder', 'auch'],
|
|
fr: ['le', 'la', 'les', 'et', 'de', 'des', 'du', 'un', 'une', 'pour', 'avec', 'en', 'au', 'aux', 'vos', 'votre', 'est', 'dans', 'sur', 'ou', 'tout', 'tous'],
|
|
es: ['el', 'la', 'los', 'las', 'y', 'de', 'del', 'un', 'una', 'para', 'con', 'en', 'tu', 'tus', 'es', 'por', 'o', 'que', 'todo', 'todos'],
|
|
pt: ['o', 'a', 'os', 'as', 'e', 'de', 'do', 'da', 'um', 'uma', 'para', 'com', 'em', 'seu', 'sua', 'no', 'na', 'ou', 'que', 'todo', 'todos'],
|
|
it: ['il', 'lo', 'la', 'le', 'gli', 'e', 'di', 'del', 'un', 'una', 'per', 'con', 'in', 'tuo', 'tua', 'che', 'tutti'],
|
|
tr: ['ve', 'ile', 'için', 'göre', 'kadar', 'sonra', 'önce', 'olan', 'olarak', 'bir', 'bu', 'şu', 'da', 'de', 'mi', 'en', 'çok', 'her', 'tüm', 'gibi', 'daha', 'sen', 'senin'],
|
|
};
|
|
/** Words of listing boilerplate (account, settings, privacy ...): they describe the store page, not the app's job. */
|
|
const BOILER = new Set(['account', 'accounts', 'settings', 'sign', 'signin', 'login', 'delete', 'privacy', 'policy', 'terms', 'support', 'contact', 'feature', 'features',
|
|
'update', 'updates', 'version', 'subscription', 'purchase', 'purchases', 'time', 'any', 'back', 'between', 'off', 'next', 'keeps', 'keep', 'item', 'items',
|
|
'change', 'changes', 'show', 'shows', 'work', 'works', 'people', 'phone', 'phones', 'here', 'there', 'about', 'want', 'need', 'help', 'day', 'days', 'even', 'much', 'many']);
|
|
const lang = (locale: string): string => locale.split('-')[0]!.toLowerCase();
|
|
export const isStop = (w: string, locale: string): boolean => FILLER.has(w) || BOILER.has(w) || (STOP[lang(locale)] ?? []).includes(w) || STOP.en!.includes(w) || /^\d+$/.test(w);
|
|
/** "Ayarlar'dan" -> "Ayarlar", "it's" -> "it": the suffix after an apostrophe is not a word of its own. */
|
|
const unApostrophe = (s: string): string => s.replace(/['’]\p{L}+/gu, '');
|
|
|
|
/** Content words of a text (lowercased for the locale, function words, boilerplate and numbers left out). Pure. */
|
|
export const tokens = (s: string, locale: string): string[] => words(unApostrophe(s), locale).filter(w => !isStop(w, locale));
|
|
|
|
/** Two-word phrases of content words that stand next to each other in the text. Pure. */
|
|
export function bigrams(s: string, locale: string): string[] {
|
|
const out: string[] = [];
|
|
for (const seg of unApostrophe(s).split(/[.!?\n:;,()"“”«»•·|/–—-]+/)) {
|
|
const ws = words(seg, locale);
|
|
for (let i = 0; i + 1 < ws.length; i++) if (!isStop(ws[i]!, locale) && !isStop(ws[i + 1]!, locale)) out.push(`${ws[i]} ${ws[i + 1]}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Plural-insensitive key: languages whose plural is mostly "-s" fold "lists" and "list" together. Pure. */
|
|
export function stem(w: string, locale: string): string {
|
|
const l = lang(locale);
|
|
if (l === 'en' && w.length > 4 && w.endsWith('ies')) return w.slice(0, -3) + 'y'; // groceries -> grocery
|
|
return ['en', 'fr', 'es', 'pt'].includes(l) && w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w;
|
|
}
|
|
|
|
/** Languages that add suffixes to a word ("listeler", "listelerini"): one word starts with the other. */
|
|
const SUFFIXING = new Set(['tr', 'fi', 'hu', 'et', 'az', 'kk', 'uz']);
|
|
/** The same keyword for search: plural-insensitive, and in suffixing languages a word that only adds a short suffix
|
|
* (up to 5 letters) to a word of at least 4. A heuristic: it errs on the side of not repeating a word. Pure. */
|
|
export function sameWord(a: string, b: string, locale: string): boolean {
|
|
const x = stem(a, locale), y = stem(b, locale);
|
|
if (x === y) return true;
|
|
if (!SUFFIXING.has(lang(locale))) return false;
|
|
const [s, l] = x.length <= y.length ? [x, y] : [y, x];
|
|
return s.length >= 4 && l.startsWith(s) && l.length - s.length <= 5;
|
|
}
|
|
const coveredBy = (w: string, set: Iterable<string>, locale: string): boolean => { for (const s of set) if (sameWord(w, s, locale)) return true; return false; };
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Competitors (iTunes Search API)
|
|
|
|
export { GENRE_IDS } from './store-catalogs';
|
|
import { GENRE_IDS } from './store-catalogs';
|
|
|
|
export interface CompetitorApp {
|
|
rank: number; id: string; name: string; developer: string; ratings: number; rating: number | null;
|
|
/** First release (releaseDate) and the current version's release (currentVersionReleaseDate), YYYY-MM-DD. */
|
|
released: string | null; updated: string | null;
|
|
genre: string;
|
|
/** Genre ids with their names localized to the storefront. */
|
|
genres: { id: string; name: string }[];
|
|
}
|
|
export interface CompetitorRow { storefront: string; locale: string; term: string; results: number; ourRank: number | null; apps: CompetitorApp[] }
|
|
export interface CompetitorSnapshot { date: string; app: { appId: string | null; bundleId: string }; top: number; rows: CompetitorRow[] }
|
|
|
|
const isUs = (r: SearchApp, app: { appId?: string; bundleId?: string }) => !!((app.appId && String(r.trackId) === app.appId) || (app.bundleId && r.bundleId === app.bundleId));
|
|
|
|
/** One term's Search API results -> the top n other apps (and our own position among the results read). Pure. */
|
|
export function competitorRow(results: SearchApp[], app: { appId?: string; bundleId?: string }, q: { storefront: string; locale: string; term: string }, n: number): CompetitorRow {
|
|
const i = results.findIndex(r => isUs(r, app));
|
|
const apps = results.map((r, k) => ({ r, k })).filter(({ r }) => !isUs(r, app)).slice(0, n).map(({ r, k }): CompetitorApp => ({
|
|
rank: k + 1, id: String(r.trackId), name: r.trackName, developer: r.sellerName ?? '', ratings: r.userRatingCount ?? 0, rating: r.averageUserRating ?? null,
|
|
released: r.releaseDate?.slice(0, 10) ?? null, updated: r.currentVersionReleaseDate?.slice(0, 10) ?? null, genre: r.primaryGenreName ?? '',
|
|
genres: (r.genreIds ?? []).map((id, j) => ({ id: String(id), name: r.genres?.[j] ?? '' })).filter(g => g.name),
|
|
}));
|
|
return { ...q, results: results.length, ourRank: i >= 0 ? i + 1 : null, apps };
|
|
}
|
|
|
|
/** Distinct apps of the rows (one storefront / locale), first seen first. Pure. */
|
|
export function distinctApps(rows: CompetitorRow[]): CompetitorApp[] {
|
|
const seen = new Map<string, CompetitorApp>();
|
|
for (const r of rows) for (const a of r.apps) if (!seen.has(a.id)) seen.set(a.id, a);
|
|
return [...seen.values()];
|
|
}
|
|
|
|
export interface Gap { word: string; apps: number; developers: number; terms: string[]; examples: string[] }
|
|
|
|
/**
|
|
* Words in competitors' names that our name, subtitle and keyword field do not cover. A word only counts when apps of at
|
|
* least two different developers use it: a word one developer uses is most likely a brand (and other apps' names and
|
|
* trademarks must never go into a keyword field). Pure.
|
|
*/
|
|
export function coverageGaps(rows: CompetitorRow[], covered: Iterable<string>, locale: string, max = 15): Gap[] {
|
|
const have = [...covered];
|
|
const by = new Map<string, { apps: Set<string>; devs: Set<string>; terms: Set<string>; examples: string[] }>();
|
|
for (const r of rows) for (const a of r.apps) for (const w of new Set(tokens(a.name, locale))) {
|
|
if (w.length < 3 || coveredBy(w, have, locale)) continue;
|
|
const k = stem(w, locale);
|
|
const g = by.get(k) ?? { apps: new Set(), devs: new Set(), terms: new Set(), examples: [] };
|
|
if (!g.apps.has(a.id) && g.examples.length < 3) g.examples.push(a.name);
|
|
g.apps.add(a.id); g.devs.add(a.developer.toLowerCase()); g.terms.add(r.term);
|
|
by.set(k, g);
|
|
}
|
|
return [...by.entries()].filter(([, g]) => g.devs.size >= 2)
|
|
.map(([word, g]) => ({ word, apps: g.apps.size, developers: g.devs.size, terms: [...g.terms], examples: g.examples }))
|
|
.sort((a, b) => b.developers - a.developers || b.apps - a.apps || a.word.localeCompare(b.word)).slice(0, max);
|
|
}
|
|
|
|
/** Apps in the top N now that were not there in the previous snapshot, per storefront + term. Pure. */
|
|
export function newEntrants(now: CompetitorRow[], before: CompetitorRow[] | undefined): Map<string, string[]> {
|
|
const out = new Map<string, string[]>();
|
|
if (!before) return out;
|
|
const prev = new Map(before.map(r => [`${r.storefront}|${r.term.toLowerCase()}`, new Set(r.apps.map(a => a.id))] as const));
|
|
for (const r of now) {
|
|
const p = prev.get(`${r.storefront}|${r.term.toLowerCase()}`);
|
|
if (p) out.set(`${r.storefront}|${r.term.toLowerCase()}`, r.apps.filter(a => !p.has(a.id)).map(a => a.name));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The category names of the app in one storefront's language, read from the Search API results' localized genres. Pure. */
|
|
export function categoryNames(rows: CompetitorRow[], categoryIds: string[]): string[] {
|
|
const ids = new Set(categoryIds.map(c => GENRE_IDS[c]).filter(Boolean));
|
|
const names = new Set<string>();
|
|
for (const a of distinctApps(rows)) for (const g of a.genres) if (ids.has(g.id)) names.add(g.name);
|
|
return [...names];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Apple Ads keyword suggestions
|
|
|
|
export const ADS_KEYWORD_SUGGESTIONS = '/suggestions/keywords/query';
|
|
|
|
/** RecommendationQueryRequest for keyword suggestions: the two required filters, seed terms, countries. Pure. */
|
|
export function keywordSuggestionBody(adamId: string, terms: string[], countries: string[], pageSize = 200): Record<string, unknown> {
|
|
const filters: Record<string, unknown>[] = [
|
|
{ field: 'promotedObjectId', operator: 'EQUALS', value: [adamId] },
|
|
{ field: 'promotedObjectType', operator: 'EQUALS', value: ['APPSTORE_APP'] },
|
|
];
|
|
if (terms.length) filters.push({ field: 'terms', operator: 'IN', value: terms });
|
|
if (countries.length) filters.push({ field: 'countriesOrRegions', operator: 'IN', value: countries.map(c => c.toUpperCase()) });
|
|
return { filters, sorting: [{ field: 'popularity', order: 'DESC' }], pagination: { offset: 0, pageSize: Math.min(1000, Math.max(1, pageSize)) } };
|
|
}
|
|
|
|
export interface AdsSuggestion { text: string; popularity: number }
|
|
/** `result` of the keyword suggestions answer -> { text, popularity } (0-100), invalid rows left out. Pure. */
|
|
export function parseKeywordSuggestions(res: unknown): AdsSuggestion[] {
|
|
const rows = (res as { result?: unknown })?.result;
|
|
if (!Array.isArray(rows)) return [];
|
|
return rows.filter((r): r is { text: string; popularity: number } => typeof r?.text === 'string' && typeof r?.popularity === 'number')
|
|
.map(r => ({ text: r.text.trim(), popularity: Math.max(0, Math.min(100, Math.round(r.popularity))) })).filter(r => r.text);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Candidates and scoring
|
|
|
|
export type CandSource = 'name' | 'subtitle' | 'keywords' | 'description' | 'competitors' | 'category' | 'apple-ads';
|
|
export type CandStatus = 'candidate' | 'indexed' | 'category' | 'filler';
|
|
export interface Candidate {
|
|
term: string;
|
|
kind: 'word' | 'phrase';
|
|
sources: CandSource[];
|
|
/** Times in our own name / subtitle / description. */
|
|
ours: number;
|
|
/** relevance 0-2: +1 in our own text (name, subtitle, description, keywords), +1 in competitor names (the top apps). */
|
|
relevance: number;
|
|
/** Competition: how many of the distinct top apps already have it in their name, of how many read. */
|
|
inNames: number; ofApps: number;
|
|
/** Apple Ads relative popularity 0-100, null = unknown (no credentials, or Apple returned nothing for it). */
|
|
popularity: number | null;
|
|
/** exact: Apple Ads suggested this exact word; phrase: the highest score of a suggested phrase that contains it. */
|
|
popularityFrom: 'exact' | 'phrase' | null;
|
|
status: CandStatus;
|
|
why?: string;
|
|
}
|
|
|
|
export interface CandidateInput {
|
|
locale: string;
|
|
name: string; subtitle: string; keywords: string; description: string;
|
|
/** The competitor rows of this locale's storefront (latest `aso competitors` snapshot), may be empty. */
|
|
competitors: CompetitorRow[];
|
|
/** Category names in this language (categoryNames); matched by Apple anyway, so never put into the field. */
|
|
categories: string[];
|
|
ads: AdsSuggestion[];
|
|
}
|
|
|
|
/** Every keyword candidate of one locale with its signals. Sorted: candidates first, by relevance, popularity (known
|
|
* first), lower competition, then how often our own text uses it. Pure. */
|
|
export function candidates(i: CandidateInput): Candidate[] {
|
|
const L = i.locale;
|
|
const m = new Map<string, Candidate>();
|
|
const key = (t: string) => t.split(' ').map(w => stem(w, L)).join(' ');
|
|
const add = (term: string, kind: Candidate['kind'], src: CandSource, n = 1) => {
|
|
const k = key(term);
|
|
const c = m.get(k) ?? { term, kind, sources: [], ours: 0, relevance: 0, inNames: 0, ofApps: 0, popularity: null, popularityFrom: null, status: 'candidate' as CandStatus };
|
|
if (!c.sources.includes(src)) c.sources.push(src);
|
|
if (src === 'name' || src === 'subtitle' || src === 'description') c.ours += n;
|
|
m.set(k, c);
|
|
};
|
|
for (const [src, text] of [['name', i.name], ['subtitle', i.subtitle], ['description', i.description]] as const) for (const w of tokens(text, L)) if (w.length > 2) add(w, 'word', src);
|
|
for (const w of i.keywords.split(',').flatMap(t => tokens(t, L))) if (w.length > 1) add(w, 'word', 'keywords');
|
|
// phrases: only the ones our own text uses at least twice, or that stand in the name / subtitle
|
|
const phraseCount = new Map<string, number>();
|
|
for (const b of [...bigrams(i.name, L), ...bigrams(i.subtitle, L), ...bigrams(i.subtitle, L), ...bigrams(i.description, L)]) phraseCount.set(b, (phraseCount.get(b) ?? 0) + 1);
|
|
for (const [b, n] of phraseCount) if (n >= 2) add(b, 'phrase', 'description', n);
|
|
const apps = distinctApps(i.competitors);
|
|
const nameWords = apps.map(a => new Set(tokens(a.name, L).map(w => stem(w, L))));
|
|
for (const g of coverageGaps(i.competitors, [], L, 200)) add(g.word, 'word', 'competitors');
|
|
const catWords = [...new Set(i.categories.flatMap(c => words(c, L)))];
|
|
const cats = new Set(catWords.map(w => stem(w, L)));
|
|
for (const c of catWords) add(c, 'word', 'category');
|
|
for (const s of i.ads) {
|
|
const ws = tokens(s.text, L);
|
|
if (ws.length === 1) add(ws[0]!, 'word', 'apple-ads');
|
|
}
|
|
const indexed = [...tokens(i.name, L), ...tokens(i.subtitle, L)];
|
|
for (const c of m.values()) {
|
|
const ws = c.term.split(' ').map(w => stem(w, L));
|
|
const inOurs = c.ours > 0 || c.sources.includes('keywords');
|
|
c.inNames = nameWords.filter(n => ws.every(w => n.has(w))).length;
|
|
c.ofApps = apps.length;
|
|
c.relevance = (inOurs ? 1 : 0) + (c.inNames > 0 ? 1 : 0);
|
|
const exact = i.ads.find(s => key(s.text.toLocaleLowerCase(L)) === key(c.term));
|
|
const phrases = i.ads.filter(s => ws.every(w => tokens(s.text, L).map(x => stem(x, L)).includes(w)));
|
|
if (exact) { c.popularity = exact.popularity; c.popularityFrom = 'exact'; }
|
|
else if (phrases.length) { c.popularity = Math.max(...phrases.map(s => s.popularity)); c.popularityFrom = 'phrase'; }
|
|
if (ws.some(w => cats.has(w)) && ws.length === 1) { c.status = 'category'; c.why = 'category name: matched by Apple anyway'; }
|
|
else if (ws.every(w => coveredBy(w, indexed, L))) { c.status = 'indexed'; c.why = 'already in the name / subtitle (indexed)'; }
|
|
else if (ws.some(w => FILLER.has(w))) { c.status = 'filler'; c.why = 'filler word'; }
|
|
}
|
|
const order = (c: Candidate) => (c.status === 'candidate' ? 0 : 1);
|
|
// a word only the description uses once is noise, unless competitors or Apple Ads bring it up too
|
|
const weak = (c: Candidate) => c.sources.length === 1 && c.sources[0] === 'description' && c.ours < 2;
|
|
return [...m.values()].filter(c => !weak(c)).sort((a, b) => order(a) - order(b) || b.relevance - a.relevance || (b.popularity ?? -1) - (a.popularity ?? -1)
|
|
|| a.inNames - b.inNames || b.ours - a.ours || a.term.localeCompare(b.term));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// The 100-byte keyword field
|
|
|
|
export interface FieldProposal { locale: string; before: string; after: string; chars: number; kept: string[]; added: string[]; dropped: { term: string; why: string }[] }
|
|
|
|
const chars = (s: string) => [...s].length;
|
|
|
|
/**
|
|
* A keyword field for one locale that fits 100 characters (App Store Connect counts characters, not bytes) with the existing rules: commas without spaces, no
|
|
* duplicates (plural-insensitive), no words of the name / subtitle, no filler or category words, single words. Current
|
|
* terms that are still valid are kept first (change a few terms per release, not all), unless `fresh`; then the best
|
|
* candidates fill the room. Pure.
|
|
*/
|
|
export function fitKeywordField(o: { locale: string; current: string; name: string; subtitle: string; ranked: Candidate[]; fresh?: boolean; limit?: number }): FieldProposal {
|
|
const L = o.locale, limit = o.limit ?? 100;
|
|
const indexed = [...tokens(o.name, L), ...tokens(o.subtitle, L)];
|
|
const blocked = new Map(o.ranked.filter(c => c.status !== 'candidate' && c.kind === 'word').map(c => [stem(c.term, L), c.why ?? c.status] as const));
|
|
const out: string[] = [], kept: string[] = [], added: string[] = [], dropped: { term: string; why: string }[] = [];
|
|
const seen: string[] = [];
|
|
const fits = (t: string) => chars([...out, t].join(',')) <= limit;
|
|
const tryAdd = (t: string, into: string[]): string | null => {
|
|
const ws = words(t, L);
|
|
if (!ws.length) return 'empty';
|
|
if (ws.every(w => coveredBy(w, indexed, L))) return 'already in the name / subtitle (indexed)';
|
|
if (ws.some(w => FILLER.has(w))) return 'filler word';
|
|
if (ws.length === 1 ? coveredBy(ws[0]!, seen, L) : seen.includes(ws.join(' '))) return 'duplicate';
|
|
const b = ws.length === 1 ? blocked.get(stem(ws[0]!, L)) : undefined;
|
|
if (b) return b;
|
|
if (!fits(t)) return 'no room in 100 characters';
|
|
seen.push(ws.length === 1 ? ws[0]! : ws.join(' ')); out.push(t); into.push(t);
|
|
return null;
|
|
};
|
|
if (!o.fresh) for (const t of o.current.split(',').map(s => s.trim().toLocaleLowerCase(L)).filter(Boolean)) {
|
|
// keep a current phrase as the words it is made of: single words combine on their own and save characters
|
|
const parts = /\s/.test(t) ? tokens(t, L) : [t];
|
|
for (const p of parts) { const why = tryAdd(p, kept); if (why && why !== 'duplicate') dropped.push({ term: p, why }); }
|
|
}
|
|
for (const c of o.ranked) {
|
|
if (c.status !== 'candidate' || c.kind !== 'word') continue;
|
|
tryAdd(c.term, added);
|
|
}
|
|
const after = out.join(',');
|
|
return { locale: L, before: o.current, after, chars: chars(after), kept, added, dropped };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Writing the proposal into store/appstore.md (only with --write; marked as a draft for review)
|
|
|
|
export const DRAFT_RE = /^<!--\s*storebound-aso:\s*draft\b[^>]*-->\s*$/m;
|
|
export const draftMark = (date: string): string => `<!-- storebound-aso: draft keywords from \`aso suggest\` ${date}; review them, then delete this line -->`;
|
|
|
|
/** Locales of store/appstore.md whose keywords carry the draft line. Pure. */
|
|
export function draftLocales(md: string): string[] {
|
|
const out: string[] = [];
|
|
for (const sec of md.split(/^(?=## )/m)) {
|
|
if (!sec.startsWith('## ')) continue;
|
|
const kw = sec.split(/^(?=### )/m).find(p => /^### keywords\s*$/m.test(p.split('\n')[0]!));
|
|
if (kw && DRAFT_RE.test(kw)) out.push(sec.slice(3).split(/[\s·]/)[0]!.trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** store/appstore.md with the keywords block of `locale` set to `value` and the draft line above it. Pure. */
|
|
export function setKeywords(md: string, locale: string, value: string, mark: string): string {
|
|
const secs = md.split(/^(?=## )/m);
|
|
const i = secs.findIndex(s => s.startsWith('## ') && s.slice(3).split(/[\s·]/)[0]!.trim() === locale);
|
|
if (i < 0) throw new Error(`no "## ${locale}" section in the App Store listing`);
|
|
const parts = secs[i]!.split(/^(?=### )/m);
|
|
const block = `### keywords\n${mark}\n\`\`\`\n${value}\n\`\`\`\n`;
|
|
const k = parts.findIndex(p => /^### keywords\s*$/.test(p.split('\n')[0]!));
|
|
if (k >= 0) {
|
|
const old = parts[k]!;
|
|
const fence = /^```[^\n]*\n[\s\S]*?\n```[^\n]*\n?/m.exec(old);
|
|
const tail = fence ? old.slice(fence.index + fence[0].length) : old.split('\n').slice(1).join('\n');
|
|
parts[k] = block + tail.replace(DRAFT_RE, '').replace(/^\n+/, '\n');
|
|
if (!/\n$/.test(parts[k]!)) parts[k] += '\n';
|
|
} else {
|
|
const last = parts.length - 1;
|
|
parts[last] = parts[last]!.replace(/\n*$/, '\n\n') + block + (i < secs.length - 1 ? '\n' : '');
|
|
}
|
|
secs[i] = parts.join('');
|
|
return secs.join('');
|
|
}
|
|
|
|
/** "us" for en-US, "tr" for tr, "jp" for ja ... : the storefront a locale is most likely meant for. Pure. */
|
|
export function storefrontOf(locale: string): string {
|
|
const [l, r] = locale.split('-');
|
|
if (r && /^[A-Za-z]{2}$/.test(r)) return r.toLowerCase();
|
|
const map: Record<string, string> = { ja: 'jp', ko: 'kr', zh: locale === 'zh-Hant' ? 'tw' : 'cn', el: 'gr', he: 'il', hi: 'in', uk: 'ua', vi: 'vn', cs: 'cz', da: 'dk', sv: 'se', nb: 'no', ms: 'my', ca: 'es', en: 'us', ar: 'sa' };
|
|
return map[l!.toLowerCase()] ?? l!.toLowerCase();
|
|
}
|