@appeeky/spec 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +483 -2
- package/dist/ads.d.ts +39 -0
- package/dist/ads.js +67 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1387 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +270 -0
- package/dist/metrics.js +250 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +71 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +519 -0
- package/dist/schema.js +218 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +226 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +98 -0
- package/dist/transport.js +170 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility.d.ts +149 -0
- package/dist/visibility.js +239 -0
- package/package.json +76 -4
- package/skills/appeeky-spec/SKILL.md +146 -0
- package/skills/appeeky-spec-experiments/SKILL.md +83 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +79 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { SPEC_DIR } from "./files.js";
|
|
6
|
+
import { indexedLocales } from "./storefronts.js";
|
|
7
|
+
import { normalize } from "./text.js";
|
|
8
|
+
import { rankAppIds } from "./verify.js";
|
|
9
|
+
/**
|
|
10
|
+
* AI visibility in the repo: whether ChatGPT, Claude, Gemini and Perplexity name
|
|
11
|
+
* the app when people ask for an app like it, who they name instead, and which
|
|
12
|
+
* pages they cite. The intents and prompts live in appeeky.spec.yaml; Appeeky AI
|
|
13
|
+
* Visibility asks the assistants on a schedule, and `visibility pull` writes what
|
|
14
|
+
* they answered to appeeky/visibility/<country>.yaml.
|
|
15
|
+
*/
|
|
16
|
+
export const MODELS = ["chatgpt", "claude", "gemini", "perplexity"];
|
|
17
|
+
export const visibilityDir = (root) => join(root, SPEC_DIR, "visibility");
|
|
18
|
+
export const visibilityChangesFile = (root) => join(visibilityDir(root), "changes.md");
|
|
19
|
+
/** Which app and storefronts to watch. */
|
|
20
|
+
export function visibilityTarget(project) {
|
|
21
|
+
const cfg = project.spec.aiVisibility;
|
|
22
|
+
const ids = rankAppIds(project);
|
|
23
|
+
const store = cfg?.store ?? (ids["app-store"] ? "app-store" : ids["google-play"] ? "google-play" : undefined);
|
|
24
|
+
if (!store || !ids[store])
|
|
25
|
+
return null;
|
|
26
|
+
const countries = cfg?.countries ?? [Object.keys(project.spec.targets)[0] ?? "us"];
|
|
27
|
+
return { store, appId: ids[store], countries };
|
|
28
|
+
}
|
|
29
|
+
/** ISO 639-1 language for a storefront's prompts, from the listing locale it indexes first. */
|
|
30
|
+
export function languageFor(project, country) {
|
|
31
|
+
return (indexedLocales(project, country)[0] ?? project.spec.app.primaryLocale).slice(0, 2).toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
const path = (appId, rest = "") => `/ai-visibility/${encodeURIComponent(appId)}${rest}`;
|
|
34
|
+
export async function remoteIntents(api, appId, country) {
|
|
35
|
+
const rows = await api.get(path(appId, "/intents"), { country });
|
|
36
|
+
return (rows ?? []).filter((r) => r.status !== "archived");
|
|
37
|
+
}
|
|
38
|
+
export async function remotePrompts(api, appId, country, intentId) {
|
|
39
|
+
const detail = await api.get(path(appId, `/intents/${encodeURIComponent(intentId)}`), { country });
|
|
40
|
+
return (detail?.prompts ?? []).filter((p) => p.status !== "archived");
|
|
41
|
+
}
|
|
42
|
+
const pct = (n) => (n === null || n === undefined ? undefined : Math.round(n));
|
|
43
|
+
export async function fetchVisibility(api, target, country, now = new Date()) {
|
|
44
|
+
const opp = await api.get(path(target.appId, "/opportunities"), { country, days: "30" });
|
|
45
|
+
const fixes = await api.get(path(target.appId, "/fixes"), { country }).catch(() => null);
|
|
46
|
+
const labels = new Map((opp?.intents ?? []).map((i) => [i.intentId, i.label]));
|
|
47
|
+
return {
|
|
48
|
+
pulledAt: now.toISOString().slice(0, 10),
|
|
49
|
+
country,
|
|
50
|
+
store: target.store,
|
|
51
|
+
appId: target.appId,
|
|
52
|
+
summary: opp?.summary ?? { winning: 0, weak: 0, missing: 0, noData: 0 },
|
|
53
|
+
intents: (opp?.intents ?? []).map((i) => {
|
|
54
|
+
const byModel = Object.fromEntries(Object.entries(i.byModel).map(([m, s]) => [m, pct(s?.visibilityPct)]));
|
|
55
|
+
return strip({
|
|
56
|
+
label: i.label,
|
|
57
|
+
status: i.status,
|
|
58
|
+
you: pct(i.ownerVisibilityPct),
|
|
59
|
+
byModel: Object.keys(byModel).length ? byModel : undefined,
|
|
60
|
+
namedInstead: i.winners.length ? i.winners.slice(0, 5).map((w) => `${w.name} (${Math.round(w.sharePct)}%)`) : undefined,
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
sources: (opp?.sources.sources ?? []).slice(0, 15).map((s) => strip({ domain: s.domain, count: s.count, yours: s.citesOwner ? true : undefined, url: s.url })),
|
|
64
|
+
fixes: (fixes?.fixes ?? [])
|
|
65
|
+
.filter((f) => f.status === "open")
|
|
66
|
+
.map((f) => strip({ intent: labels.get(f.intentId) ?? f.intentId, title: f.title, why: f.rationale, page: f.targetUrl ?? undefined })),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const strip = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
70
|
+
export const visibilityPath = (root, country) => join(visibilityDir(root), `${country}.yaml`);
|
|
71
|
+
export async function readVisibility(root, country) {
|
|
72
|
+
const file = visibilityPath(root, country);
|
|
73
|
+
return existsSync(file) ? YAML.parse(await readFile(file, "utf8")) : null;
|
|
74
|
+
}
|
|
75
|
+
/** What moved since the last pull: an intent the assistants started or stopped naming the app for. */
|
|
76
|
+
export function visibilityChanges(prev, next) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const before = new Map(prev.intents.map((i) => [normalize(i.label), i]));
|
|
79
|
+
const rank = { missing: 0, weak: 1, winning: 2, no_data: -1 };
|
|
80
|
+
for (const i of next.intents) {
|
|
81
|
+
const was = before.get(normalize(i.label));
|
|
82
|
+
if (!was || was.status === "no_data" || i.status === "no_data" || was.status === i.status)
|
|
83
|
+
continue;
|
|
84
|
+
const verb = rank[i.status] > rank[was.status] ? "up" : "down";
|
|
85
|
+
out.push(`"${i.label}": ${was.status} → ${i.status} (${was.you ?? 0}% → ${i.you ?? 0}%), ${verb}`);
|
|
86
|
+
}
|
|
87
|
+
const cited = (f) => f.sources.some((s) => s.yours);
|
|
88
|
+
if (!cited(prev) && cited(next))
|
|
89
|
+
out.push("the assistants now cite your own site");
|
|
90
|
+
if (cited(prev) && !cited(next))
|
|
91
|
+
out.push("the assistants stopped citing your own site");
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
async function recordChanges(root, date, country, changes) {
|
|
95
|
+
if (changes.length === 0)
|
|
96
|
+
return;
|
|
97
|
+
const file = visibilityChangesFile(root);
|
|
98
|
+
const old = existsSync(file) ? await readFile(file, "utf8") : "# AI visibility changes\n\nWritten by `visibility pull`: intents the assistants started or stopped naming the app for.\n";
|
|
99
|
+
const [head, ...rest] = old.split(/\n(?=- )/);
|
|
100
|
+
const lines = changes.map((c) => `- ${date} · ${country.toUpperCase()} · ${c}`);
|
|
101
|
+
await writeFile(file, [head.trimEnd(), "", ...lines, ...rest.map((r) => r.trimEnd())].join("\n") + "\n");
|
|
102
|
+
}
|
|
103
|
+
/** Pull every watched storefront and write its file. */
|
|
104
|
+
export async function pullVisibility(api, project, countries, now = new Date()) {
|
|
105
|
+
const target = visibilityTarget(project);
|
|
106
|
+
if (!target)
|
|
107
|
+
throw new Error("Set stores.appStore.appId (numeric) or stores.googlePlay.packageName first.");
|
|
108
|
+
await mkdir(visibilityDir(project.root), { recursive: true });
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const country of countries ?? target.countries) {
|
|
111
|
+
try {
|
|
112
|
+
const data = await fetchVisibility(api, target, country, now);
|
|
113
|
+
const file = visibilityPath(project.root, country);
|
|
114
|
+
const prev = await readVisibility(project.root, country);
|
|
115
|
+
const changes = prev ? visibilityChanges(prev, data) : [];
|
|
116
|
+
await recordChanges(project.root, data.pulledAt, country, changes);
|
|
117
|
+
await writeFile(file, YAML.stringify(data, { lineWidth: 0 }));
|
|
118
|
+
out.push({ country, file, data, changes });
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
out.push({ country, changes: [], error: e.message });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
// ── Intents as code ─────────────────────────────────────────────────────────
|
|
127
|
+
/** The intents and prompts Appeeky tracks for a storefront, in the spec's shape. */
|
|
128
|
+
export async function readRemoteIntents(api, appId, country) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const i of await remoteIntents(api, appId, country)) {
|
|
131
|
+
const prompts = await remotePrompts(api, appId, country, i.intentId);
|
|
132
|
+
out.push(strip({ label: i.label, description: i.description ?? undefined, prompts: prompts.map((p) => p.text) }));
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** Ask Appeeky to write intents and prompts from the store listing (runs in the background). */
|
|
137
|
+
export async function bootstrapIntents(api, appId, country, language) {
|
|
138
|
+
const r = await api.post(`${path(appId, "/bootstrap")}?country=${encodeURIComponent(country)}&language=${encodeURIComponent(language)}`, {});
|
|
139
|
+
return { queued: Boolean(r?.taskRunId), already: Boolean(r?.alreadyBootstrapped) };
|
|
140
|
+
}
|
|
141
|
+
const key = (s) => normalize(s).replace(/\s+/g, " ").trim();
|
|
142
|
+
/** Compare the spec's intents for a storefront with what Appeeky tracks. */
|
|
143
|
+
export async function planSync(api, appId, country, wanted) {
|
|
144
|
+
const remote = await remoteIntents(api, appId, country);
|
|
145
|
+
const byLabel = new Map(remote.map((r) => [key(r.label), r]));
|
|
146
|
+
const plan = { country, addIntents: [], addPrompts: [], extraIntents: [], extraPrompts: [] };
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
for (const intent of wanted) {
|
|
149
|
+
const r = byLabel.get(key(intent.label));
|
|
150
|
+
if (!r) {
|
|
151
|
+
plan.addIntents.push(intent);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
seen.add(r.intentId);
|
|
155
|
+
const prompts = await remotePrompts(api, appId, country, r.intentId);
|
|
156
|
+
const have = new Map(prompts.map((p) => [key(p.text), p]));
|
|
157
|
+
const want = new Set(intent.prompts.map(key));
|
|
158
|
+
for (const text of intent.prompts)
|
|
159
|
+
if (!have.has(key(text)))
|
|
160
|
+
plan.addPrompts.push({ intentId: r.intentId, label: intent.label, text });
|
|
161
|
+
for (const p of prompts)
|
|
162
|
+
if (!want.has(key(p.text)))
|
|
163
|
+
plan.extraPrompts.push({ promptId: p.promptId, label: intent.label, text: p.text });
|
|
164
|
+
}
|
|
165
|
+
for (const r of remote)
|
|
166
|
+
if (!seen.has(r.intentId))
|
|
167
|
+
plan.extraIntents.push({ intentId: r.intentId, label: r.label });
|
|
168
|
+
return plan;
|
|
169
|
+
}
|
|
170
|
+
/** Apply a plan: add what the spec has; archive what it does not only when `prune` is set. */
|
|
171
|
+
export async function applySync(api, appId, plan, language, prune) {
|
|
172
|
+
const q = `?country=${encodeURIComponent(plan.country)}&language=${encodeURIComponent(language)}`;
|
|
173
|
+
let intents = 0;
|
|
174
|
+
let prompts = 0;
|
|
175
|
+
let archived = 0;
|
|
176
|
+
for (const intent of plan.addIntents) {
|
|
177
|
+
const created = await api.post(`${path(appId, "/intents")}${q}`, { label: intent.label, description: intent.description ?? null });
|
|
178
|
+
intents++;
|
|
179
|
+
for (const text of intent.prompts) {
|
|
180
|
+
await api.post(`${path(appId, `/intents/${encodeURIComponent(created.id)}/prompts`)}${q}`, { text });
|
|
181
|
+
prompts++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const p of plan.addPrompts) {
|
|
185
|
+
await api.post(`${path(appId, `/intents/${encodeURIComponent(p.intentId)}/prompts`)}${q}`, { text: p.text });
|
|
186
|
+
prompts++;
|
|
187
|
+
}
|
|
188
|
+
if (prune) {
|
|
189
|
+
for (const p of plan.extraPrompts) {
|
|
190
|
+
await api.request("DELETE", path(appId, `/prompts/${encodeURIComponent(p.promptId)}`));
|
|
191
|
+
archived++;
|
|
192
|
+
}
|
|
193
|
+
for (const i of plan.extraIntents) {
|
|
194
|
+
await api.request("DELETE", `${path(appId, `/intents/${encodeURIComponent(i.intentId)}`)}`);
|
|
195
|
+
archived++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { intents, prompts, archived };
|
|
199
|
+
}
|
|
200
|
+
/** Ask the assistants now instead of waiting for the schedule. Charged in AI Visibility credits. */
|
|
201
|
+
export async function scanVisibility(api, appId, country) {
|
|
202
|
+
return api.post(`${path(appId, "/scan")}?country=${encodeURIComponent(country)}`, {});
|
|
203
|
+
}
|
|
204
|
+
// ── Measuring ───────────────────────────────────────────────────────────────
|
|
205
|
+
/**
|
|
206
|
+
* `ai_visibility` around a push: the share of prompts whose answer names the
|
|
207
|
+
* app, averaged over the scanned days in the window and the assistants (0–1).
|
|
208
|
+
* Reads Appeeky's daily series once per assistant per run.
|
|
209
|
+
*/
|
|
210
|
+
export function visibilityMetricSource(api, project) {
|
|
211
|
+
const target = visibilityTarget(project);
|
|
212
|
+
if (!target)
|
|
213
|
+
return null;
|
|
214
|
+
const country = target.countries[0];
|
|
215
|
+
let series;
|
|
216
|
+
return {
|
|
217
|
+
label: "Appeeky AI Visibility",
|
|
218
|
+
async window(store, _appId, from, to) {
|
|
219
|
+
if (store !== target.store)
|
|
220
|
+
return null;
|
|
221
|
+
series ??= Promise.all(MODELS.map((model) => api.get(path(target.appId, "/trend"), { country, model, windowDays: "90" }).then((s) => s ?? []).catch(() => [])));
|
|
222
|
+
const byDay = new Map();
|
|
223
|
+
for (const points of await series) {
|
|
224
|
+
for (const p of points) {
|
|
225
|
+
if (p.date < from || p.date >= to)
|
|
226
|
+
continue;
|
|
227
|
+
byDay.set(p.date, [...(byDay.get(p.date) ?? []), p.visibilityPct]);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (byDay.size === 0)
|
|
231
|
+
return null;
|
|
232
|
+
const daily = [...byDay.values()].map((xs) => xs.reduce((a, b) => a + b, 0) / xs.length);
|
|
233
|
+
const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
|
|
234
|
+
const values = { ai_visibility: daily.reduce((a, b) => a + b, 0) / daily.length / 100 };
|
|
235
|
+
return { from, to, days, values };
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=visibility.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appeeky/spec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your app's store listing as a spec: lint it, check keyword coverage, diff it against the live store and push it to App Store Connect and Google Play.",
|
|
5
|
+
"license": "FSL-1.1-MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"appeeky-spec": "dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./core": {
|
|
18
|
+
"types": "./dist/core.d.ts",
|
|
19
|
+
"import": "./dist/core.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"!dist/**/*.map",
|
|
26
|
+
"skills",
|
|
27
|
+
".github/assets/cover.gif",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org/"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/appeeky/appeeky-spec.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://spec.appeeky.com",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/appeeky/appeeky-spec/issues"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"aso",
|
|
45
|
+
"app-store-optimization",
|
|
46
|
+
"app-store",
|
|
47
|
+
"app-store-connect",
|
|
48
|
+
"google-play",
|
|
49
|
+
"metadata",
|
|
50
|
+
"keywords",
|
|
51
|
+
"spec",
|
|
52
|
+
"cli",
|
|
53
|
+
"appeeky"
|
|
54
|
+
],
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsc -p tsconfig.build.json",
|
|
60
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:watch": "vitest",
|
|
64
|
+
"prepublishOnly": "npm run build",
|
|
65
|
+
"pack:check": "npm run build && npm pack --dry-run",
|
|
66
|
+
"release:dry": "node scripts/release.mjs --dry-run"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"commander": "^13.1.0",
|
|
70
|
+
"yaml": "^2.6.1",
|
|
71
|
+
"zod": "^3.24.3"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/node": "^22.15.3",
|
|
75
|
+
"typescript": "^5.8.3",
|
|
76
|
+
"vitest": "^3.1.2"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: appeeky-spec
|
|
3
|
+
description: Improve and ship an app's App Store / Google Play listing with Appeeky Spec (appeeky.spec.yaml + appeeky/store). Use when asked to do ASO on a repo, change an app's name, subtitle, keywords, promotional text or descriptions, check keyword coverage, set up listing-as-code or its GitHub Action, handle App Store screenshots, or push listing changes to App Store Connect or Google Play, or run the weekly growth routine. To choose target keywords see appeeky-spec-targets; for a new language or country see appeeky-spec-localize; to judge past releases see appeeky-spec-review; for AI assistants and the category chart see appeeky-spec-visibility; for screenshots, UGC, ads, pricing, attribution and data see appeeky-spec-experiments.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 1.3.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Appeeky Spec
|
|
9
|
+
|
|
10
|
+
The app's store listings live in files, and every push is recorded and measured.
|
|
11
|
+
|
|
12
|
+
| Path | What it holds |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `appeeky.spec.yaml` | app, stores, `targets` (searches the app should be found for, per storefront), optional `storefronts` and `personas` |
|
|
15
|
+
| `appeeky/store/app-store/<locale>.yaml` | name, subtitle, keywords, promotionalText, description, whatsNew, supportUrl, marketingUrl |
|
|
16
|
+
| `appeeky/store/google-play/<language>.yaml` | title, shortDescription, fullDescription, video (Play codes like `tr-TR`) |
|
|
17
|
+
| `appeeky/releases/<id>.yaml` | every push: before/after values, note, rank baseline, day-7/14 checks |
|
|
18
|
+
| `appeeky/experiments/<id>.yaml` | screenshot, UGC, ads and pricing experiments |
|
|
19
|
+
| `appeeky/loops.md` | generated by `status`, `verify`, `push` and `experiment`: every change being measured, its metric, next check and result so far |
|
|
20
|
+
| `appeeky/playbook.md` | what worked and what did not for this app |
|
|
21
|
+
| `appeeky/reviews/` | customer reviews per month (JSONL), `summary.md`, and `replies/` drafted for review |
|
|
22
|
+
| `appeeky/competitors/<name>.yaml` | each competitor's listing, best ranks, ranks on your targets, recent low ratings |
|
|
23
|
+
| `appeeky/visibility/<country>.yaml` | whether ChatGPT, Claude, Gemini and Perplexity name the app per intent, who they name instead, the pages they cite, suggested fixes; `changes.md` beside it |
|
|
24
|
+
| `appeeky/category.csv` | rank in the category chart (top 100), one row a day |
|
|
25
|
+
| `.appeeky/data/*.csv` (or `appeeky/data/`) | analytics, Search Ads, RevenueCat, Meta and TikTok per day; `README.md` says what each file holds |
|
|
26
|
+
|
|
27
|
+
Run commands as `npx @appeeky/spec <command> --json` (or `appeeky-spec` when installed). Add `-C <dir>` when the spec is not in the current folder.
|
|
28
|
+
|
|
29
|
+
## No spec yet
|
|
30
|
+
|
|
31
|
+
In a terminal the user runs, `init` with no options is a guided setup. It finds the app, pulls both stores, suggests targets, offers the workflows and the skills, and shows the first coverage. As an agent, use the flags instead:
|
|
32
|
+
|
|
33
|
+
1. Find the App Store app id and Google Play package name (Xcode project, `app.json`, `build.gradle`), or ask.
|
|
34
|
+
2. Store access: `appstore` / `gps` CLIs with the user's keys (local), or `APPEEKY_API_KEY` with a store connected in Appeeky (hosted). Do not ask for or handle key files yourself.
|
|
35
|
+
- Local App Store: `appstore accounts add`, or `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY`.
|
|
36
|
+
- Local Google Play: `gps accounts add`, or `GPS_CREDENTIALS`.
|
|
37
|
+
- `--via auto` (default) uses a local CLI when installed, hosted when `APPEEKY_API_KEY` is set. `APPEEKY_API_KEY` also adds keyword popularity and ideas.
|
|
38
|
+
3. `init --name "<App>" --app-store <id> --google-play <package> --pull`, then fill `targets` (see appeeky-spec-targets).
|
|
39
|
+
4. `doctor --json` lists everything still missing (store access, targets, the key, attribution, workflows, skills, `.gitignore`), each with its fix. Exit code 1 means something must be fixed.
|
|
40
|
+
|
|
41
|
+
### Automation
|
|
42
|
+
|
|
43
|
+
`init --ci` (also on an existing spec) writes three GitHub workflows:
|
|
44
|
+
|
|
45
|
+
| Workflow | When | What |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `listing-check.yml` | every pull request | comments what the listing changes do (`report`), fails on check errors |
|
|
48
|
+
| `listing-push.yml` | merge to main touching `appeeky/store/**` or the spec | `push --yes`, then commits the release file |
|
|
49
|
+
| `listing-daily.yml` | daily 06:00 UTC | `category pull`, `visibility pull`, `verify --playbook`, `reviews pull`, `competitors pull`, then commits |
|
|
50
|
+
|
|
51
|
+
The merge is the approval gate: nothing reaches the store unless a person merged it. The workflows need `APPEEKY_API_KEY` (or store keys) as repository secrets.
|
|
52
|
+
|
|
53
|
+
Without GitHub Actions the same commands run anywhere:
|
|
54
|
+
|
|
55
|
+
- **Other CI** (GitLab, Bitbucket, CircleCI): the same three triggers and commands.
|
|
56
|
+
- **cron or launchd** on the user's machine: `examples/weekly/` has a script.
|
|
57
|
+
- **The user's own server** (or Railway, Fly, Render): `docker/docker-compose.yml` with a `.env` from `docker/.env.example`, then `docker compose up -d`. It runs the routines on a schedule and commits back. Its push routine only pushes listing changes that reached `main` after it started, so the merge stays the gate. Suggest `PUSH_DRY_RUN=1` for the first days.
|
|
58
|
+
- **A scheduled agent:** it runs the daily commands and the weekly routine below.
|
|
59
|
+
- **By hand:** `status` flags checks that are late.
|
|
60
|
+
|
|
61
|
+
Without CI, a person runs `push`. Never take over that gate with `--yes`.
|
|
62
|
+
|
|
63
|
+
## Workflow
|
|
64
|
+
|
|
65
|
+
1. **Understand.** Run `status` (or read `appeeky/loops.md`; `status --json` lists the `free` stores): do not start a change on a store that already has a release being measured, or the two results mix. Read `appeeky/playbook.md` and recent releases (`log --json`). Do not repeat a change that already lost unless something changed. With `APPEEKY_API_KEY`, refresh and read what users and competitors say: `reviews pull` then `appeeky/reviews/summary.md`, `competitors pull` then `appeeky/competitors/`, `visibility pull` then `appeeky/visibility/`, and `data pull` for the numbers (`.appeeky/data/README.md`). `category pull` records today's category rank (the App Store needs no key). Words users use in reviews and searches competitors win are good candidates for targets.
|
|
66
|
+
2. **Find gaps.** `coverage --json`, `suggest --json` and `check --json`. Missing or `partial` high-popularity targets are the opportunities. `crossLocalization` lists extra locales a storefront reads with spare keyword space. If listings are missing, run `pull` first.
|
|
67
|
+
3. **Propose.** Edit the listing files. Keep every word that currently covers a target unless the user agrees to drop it. For each change say which targets it should gain and which it could lose. One clear hypothesis per release.
|
|
68
|
+
4. **Verify.** `check --json` (no errors allowed) and `diff --json`. Changes marked `blocked` cannot go live yet; tell the user why (usually: the App Store needs a new version, `push --create-version <x.y.z>`).
|
|
69
|
+
5. **Ship only with consent.** Pushing changes the live store; on Google Play it is public within minutes. Run `push --note "<hypothesis>"` only when the user asked; use `--yes` only when they said so explicitly. Commit the release file it writes.
|
|
70
|
+
6. **Learn.** After day 7, `verify --playbook --json` (see appeeky-spec-review).
|
|
71
|
+
|
|
72
|
+
Never run `pull` over uncommitted listing edits: it overwrites local files.
|
|
73
|
+
|
|
74
|
+
### Weekly routine
|
|
75
|
+
|
|
76
|
+
For a scheduled or "what should we do this week" run, propose exactly one listing change. Make it for a `free` store (`status --json`), never one with a release still being measured.
|
|
77
|
+
|
|
78
|
+
1. Read the sources:
|
|
79
|
+
- `status`, `appeeky/loops.md`, `appeeky/playbook.md`
|
|
80
|
+
- `appeeky/competitors/changes.md`, `appeeky/reviews/summary.md`, `appeeky/visibility/changes.md`
|
|
81
|
+
- `.appeeky/data/README.md`
|
|
82
|
+
2. Pick the change most likely to win:
|
|
83
|
+
- an uncovered target,
|
|
84
|
+
- words users write in reviews,
|
|
85
|
+
- a search a competitor just climbed,
|
|
86
|
+
- an intent the assistants answer with competitors.
|
|
87
|
+
|
|
88
|
+
Do not repeat a change the playbook says lost.
|
|
89
|
+
3. Edit, then `check` and `diff`. Write the pull request: what changes, why (with the numbers), which metric should move and when it is checked.
|
|
90
|
+
4. Do not `push`. A person merges; the push runs on merge.
|
|
91
|
+
|
|
92
|
+
To run it every week without anyone starting it:
|
|
93
|
+
|
|
94
|
+
| Where | How |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| GitHub Actions | `examples/weekly/appeeky-spec-weekly.yml` |
|
|
97
|
+
| The user's machine | `examples/weekly/weekly.sh` with cron or launchd |
|
|
98
|
+
| The user's own server | `WEEKLY_AT=0 7 * * 1` in the docker `.env`, with the `latest-agent` image |
|
|
99
|
+
|
|
100
|
+
All of them copy `examples/weekly/propose.md` to `.github/appeeky-propose.md`.
|
|
101
|
+
|
|
102
|
+
The agent needs one of two things:
|
|
103
|
+
- **`CLAUDE_CODE_OAUTH_TOKEN`:** the user runs `claude setup-token` once on their computer. Runs then count against their Claude Pro or Max plan, with no API key.
|
|
104
|
+
- **`ANTHROPIC_API_KEY`:** billed per use.
|
|
105
|
+
|
|
106
|
+
Set it as a repository secret or in the `.env`. Never ask the user to paste a token into chat or commit it.
|
|
107
|
+
|
|
108
|
+
### Before and after a change
|
|
109
|
+
|
|
110
|
+
- `competitors discover --add <n>` finds apps like this one and adds them to `competitors:`.
|
|
111
|
+
- `report --base main` prints the Markdown a pull request needs: listing changes, check results and coverage.
|
|
112
|
+
- `log --markdown` summarizes past releases.
|
|
113
|
+
|
|
114
|
+
## App Store rules
|
|
115
|
+
|
|
116
|
+
- Name 30, subtitle 30, keyword field 100, promotional text 170 characters. Counted in characters, not bytes (a Cyrillic or CJK letter is one).
|
|
117
|
+
- Name, subtitle and keyword field words are indexed once and combine into phrases across the three. Never repeat a word across them. Separate keywords with commas and no spaces; a field without commas is read as one phrase.
|
|
118
|
+
- Weight: name > subtitle > keyword field. The description is not indexed for search.
|
|
119
|
+
- Skip filler words ("app", "the", "and", "for"), the app's category name and plurals of words already present (English).
|
|
120
|
+
- Only promotional text changes without a new version.
|
|
121
|
+
- A storefront can index more than one listing locale (the US App Store also reads Spanish (Mexico)). Use the extra locale's keyword field for more keywords for that storefront; `coverage` counts it.
|
|
122
|
+
- Do not use competitor brand names or trademarks.
|
|
123
|
+
|
|
124
|
+
## Google Play rules
|
|
125
|
+
|
|
126
|
+
- Title 30, short description 80, full description 4000 characters. All three are indexed; use the main keywords naturally in the full description, without stuffing.
|
|
127
|
+
- No ranking or promotional words ("best", "#1", "top", "free", "sale"), emoji or all caps in the title.
|
|
128
|
+
- Changes go live without an app update. A new language needs all three text fields.
|
|
129
|
+
|
|
130
|
+
## Screenshots
|
|
131
|
+
|
|
132
|
+
`screenshots pull` downloads the live App Store screenshots of every locale into `appeeky/screenshots/app-store/`. `screenshots check` (also part of `check`) flags wrong sizes and counts per display type. Screenshots are not indexed for search; they change conversion, so treat a new set as an experiment.
|
|
133
|
+
|
|
134
|
+
## Other experiments
|
|
135
|
+
|
|
136
|
+
Screenshots, UGC, ads, pricing, attribution, smart links and the app's numbers: see appeeky-spec-experiments.
|
|
137
|
+
|
|
138
|
+
Docs: https://docs.appeeky.com/docs/spec
|
|
139
|
+
|
|
140
|
+
## AI visibility and category rank
|
|
141
|
+
|
|
142
|
+
Whether ChatGPT, Claude, Gemini and Perplexity name the app (`aiVisibility:` in the spec, `visibility init|sync|pull`), and the daily category chart rank (`category pull`): see appeeky-spec-visibility. Never run `visibility sync --prune` or `visibility scan` unless the user asked.
|
|
143
|
+
|
|
144
|
+
## Reviews
|
|
145
|
+
|
|
146
|
+
`reviews list --rating 1-2 --unanswered --json` shows what needs an answer. Draft each reply with `reviews reply <id> --text "..."`: specific to what the user said, no promises the app does not keep, under 350 characters for Google Play. The drafts land in `appeeky/reviews/replies/`; open a pull request with them and send them with `reviews push` only after it is approved. Recurring complaints in reviews are listing input too: a feature people look for belongs in the description or the screenshots.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: appeeky-spec-experiments
|
|
3
|
+
description: Run and measure growth experiments outside the store listing in an Appeeky Spec repo, such as screenshots, UGC and creator videos, ads and pricing, with install attribution, smart links and the app's numbers. Use when asked to test screenshots, a TikTok or creator campaign, an ad creative or a price, to set up attribution, to see where installs and revenue come from, or to pull analytics, ads and RevenueCat data for analysis.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Experiments, attribution and data with Appeeky Spec
|
|
9
|
+
|
|
10
|
+
A listing push is already an experiment: `verify` measures it. Everything else you try to grow the app, like a new screenshot set, a creator video, an ad or a price, is tracked the same way in `appeeky/experiments/<id>.yaml`. Each one has one hypothesis, one metric, a baseline, a date to check and a result.
|
|
11
|
+
|
|
12
|
+
Run commands as `npx @appeeky/spec <command> --json`.
|
|
13
|
+
|
|
14
|
+
## 1. Know what can be measured
|
|
15
|
+
|
|
16
|
+
`experiment add --metric` takes one metric. Prefer one the spec can measure on its own. It then takes the baseline from the last days and `verify` closes the experiment when it is due:
|
|
17
|
+
|
|
18
|
+
| Needs | Metrics |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `APPEEKY_API_KEY` with the store connected in Appeeky | `conversion`, `page_views`, `downloads`, `proceeds`, `trials` |
|
|
21
|
+
| RevenueCat connected in Appeeky (app-scoped project) | `revenue`, `rc_trials`, `trial_conversion` |
|
|
22
|
+
| Apple Search Ads connected | `ad_installs`, `cpa` |
|
|
23
|
+
| Meta / TikTok campaigns under `ads:` | `meta_spend`, `meta_installs`, `meta_cpi`, `tiktok_spend`, `tiktok_conversions`, `tiktok_cpa` |
|
|
24
|
+
| `attribution:` in the spec | `organic_installs`, `organic_revenue`, `organic_paid`, and with `--link`: `link_clicks`, `link_installs`, `link_revenue` |
|
|
25
|
+
| reviews pulled | `rating`, `low_ratings` |
|
|
26
|
+
| AI Visibility / `category pull` | `ai_visibility`, `category_rank` (see appeeky-spec-visibility) |
|
|
27
|
+
|
|
28
|
+
Without any of these, the user gives the value at the end (`experiment close --value`). Say so up front.
|
|
29
|
+
|
|
30
|
+
## 2. Attribution: where installs come from
|
|
31
|
+
|
|
32
|
+
With Appeeky Analytics in the app, the spec knows each install's source: organic store search, an Apple Search Ads keyword, a smart link, the Google Play referrer. It also knows what that install paid. Set it up once:
|
|
33
|
+
|
|
34
|
+
- `attribution init` uses Appeeky's hosted service (`APPEEKY_API_KEY`). `attribution init --self <url>` uses the user's own server on Cloudflare (`ATTRIBUTION_TOKEN`).
|
|
35
|
+
- It writes `attribution:` to the spec and prints the SDK key and the RevenueCat webhook. Adding the SDK to the app and the webhook to RevenueCat is a code and settings change: propose it, do not do it silently.
|
|
36
|
+
- An app on both stores has one id per store under `attribution.app`.
|
|
37
|
+
|
|
38
|
+
Then listing releases are judged on organic installs and revenue first, not only ranks.
|
|
39
|
+
|
|
40
|
+
## 3. Start an experiment
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx @appeeky/spec experiment add --type ugc --persona new-parents \
|
|
44
|
+
--hypothesis "A 'my baby slept in 5 minutes' hook gets more installs per view than the product demo" \
|
|
45
|
+
--metric link_installs --days 14 --link tiktok
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `--type`: `screenshots`, `ugc`, `ads`, `pricing`, `other`.
|
|
49
|
+
- `--persona`: an id from `personas` in the spec. Write the hypothesis for that person.
|
|
50
|
+
- `--link [channel]` creates a smart link for the experiment (needs `attribution:`), so its installs and revenue are counted exactly. Channels: `tiktok`, `instagram`, `creator`, `tiktok_ads`, `meta_ads`, and others. Give each creator or ad its own experiment and link.
|
|
51
|
+
- **App Store caveat:** link installs can only be counted on Google Play. For an App Store-only app, measure `link_clicks`.
|
|
52
|
+
- `--asset` records what ran: file paths, video links, ad ids.
|
|
53
|
+
- `--baseline` and `--target` when the user knows them. Otherwise the baseline is measured.
|
|
54
|
+
|
|
55
|
+
Rules:
|
|
56
|
+
|
|
57
|
+
- One change per experiment. A new video with a new price measures neither.
|
|
58
|
+
- Do not start a screenshot or pricing experiment on a store that has a listing release still being measured. Check `status` first; `free` stores are safe. Both would move conversion.
|
|
59
|
+
- Screenshots are not indexed for search. They change conversion, so measure `conversion` (or `organic_installs` with attribution).
|
|
60
|
+
|
|
61
|
+
## 4. Close it
|
|
62
|
+
|
|
63
|
+
`verify` closes due experiments on measured metrics (`closedExperiments` in its JSON) and lists the ones that still need a value (`dueExperiments`). For those:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx @appeeky/spec experiment close <id> --value 3.1 --note "Hook B: 3.1 installs per 1k views vs 1.8" --playbook
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--playbook` adds the result to `appeeky/playbook.md`. A lost experiment is a result too: write it so it is not tried again.
|
|
70
|
+
|
|
71
|
+
## 5. Ads
|
|
72
|
+
|
|
73
|
+
- `ads --json` gives spend, installs and cost per install for the last `--days` (default 30). It covers Apple Search Ads and the Meta and TikTok campaigns listed under `ads:` in the spec.
|
|
74
|
+
- `--save` keeps a copy in `appeeky/ads/<date>.yaml`.
|
|
75
|
+
- Campaigns are listed by id. Ask the user which campaigns promote this app; never guess from names.
|
|
76
|
+
|
|
77
|
+
## 6. Data for analysis
|
|
78
|
+
|
|
79
|
+
`data pull --json` writes CSV files the agent can read: store analytics, traffic sources, sales, Apple Search Ads, RevenueCat, Meta and TikTok per day. It also writes attribution by source, keyword and smart link. `--days` defaults to 90, and `README.md` in the folder says what each file holds.
|
|
80
|
+
|
|
81
|
+
- They are private business numbers. They go to `.appeeky/data/` (gitignored) unless the spec says `data: { commit: true }`. Never move them into the repo or paste them into a public pull request without asking.
|
|
82
|
+
- Pulling again merges by date, so files grow; there is no need to delete them first.
|
|
83
|
+
- Use them to find the next experiment: a country with many page views and low conversion, a channel with rising cost per install, a keyword that brings installs that never pay.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: appeeky-spec-localize
|
|
3
|
+
description: Add or improve a store listing for a new language or country in an Appeeky Spec repo (appeeky.spec.yaml + appeeky/store). Use when asked to localize App Store or Google Play metadata, launch in a new country, add a locale, translate keywords, or use spare keyword space in secondary locales such as Spanish (Mexico) for the US.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Localize a listing with Appeeky Spec
|
|
9
|
+
|
|
10
|
+
Localizing is keyword research per market, not translation. People in each country search with their own words, and a literal translation of an English keyword is often a term nobody types.
|
|
11
|
+
|
|
12
|
+
Run commands as `npx @appeeky/spec <command> --json`.
|
|
13
|
+
|
|
14
|
+
## 1. Pick the storefront and the locale
|
|
15
|
+
|
|
16
|
+
- Storefronts are countries (`us`, `de`, `tr`). Listing locales are languages (`en-US`, `de-DE`, `tr`). One storefront can read several locales.
|
|
17
|
+
- Run `coverage --json`: `crossLocalization` shows extra locales a storefront already reads and how many keyword characters they leave unused. Filling those is the cheapest win (for the US App Store: the `es-MX` keyword field).
|
|
18
|
+
- For a new market, ask the user which countries matter (downloads, revenue, plans) before writing many locales.
|
|
19
|
+
|
|
20
|
+
## 2. Targets first
|
|
21
|
+
|
|
22
|
+
Add the market's targets to `appeeky.spec.yaml` before writing copy:
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
targets:
|
|
26
|
+
de: [einschlafgeräusche, weißes rauschen, regengeräusche]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Find the terms locally: what competitors in that country use in their titles and subtitles, store search suggestions, and keyword popularity from the Appeeky MCP server or `suggest --country <cc> --json` when `APPEEKY_API_KEY` is set. Mark terms you are unsure about and ask a native speaker when one is available.
|
|
30
|
+
|
|
31
|
+
## 3. Write the listing file
|
|
32
|
+
|
|
33
|
+
Create `appeeky/store/app-store/<locale>.yaml` or `appeeky/store/google-play/<language>.yaml` (Google Play uses codes like `de-DE`, `tr-TR`).
|
|
34
|
+
|
|
35
|
+
- Only the fields you write are managed. Start with name, subtitle and keywords (App Store) or title and short description (Google Play); leave descriptions out until they are written properly.
|
|
36
|
+
- Keep the brand in the name the same in every locale.
|
|
37
|
+
- App Store keyword field: 100 characters, commas without spaces, no words already in the name or subtitle, no plurals of present words. Characters, not bytes.
|
|
38
|
+
- Google Play: a new language needs title, short description and full description together.
|
|
39
|
+
- For a secondary locale used only for another storefront's keywords (es-MX for the US), fill the keyword field with that storefront's target words that the primary locale does not cover. Name and subtitle should still read correctly in the locale's language.
|
|
40
|
+
|
|
41
|
+
## 4. Check and ship
|
|
42
|
+
|
|
43
|
+
1. `check --json` (no errors) and `coverage --country <cc> --json`: the new targets should be covered or partial.
|
|
44
|
+
2. `diff --json`: a new locale shows as `newLocale`. On the App Store a new locale needs a version in Prepare for Submission.
|
|
45
|
+
3. Push only when the user asks, with a note naming the market and the hypothesis: `push --note "Launch de-DE: einschlafgeräusche, weißes rauschen"`.
|
|
46
|
+
|
|
47
|
+
## Mistakes to avoid
|
|
48
|
+
|
|
49
|
+
- Machine-translating the English keyword field.
|
|
50
|
+
- Copying the English subtitle into every locale.
|
|
51
|
+
- Adding ten locales at once with no targets: nothing can be measured.
|
|
52
|
+
- Using another app's brand name as a keyword.
|