@appeeky/spec 0.0.0-stage → 0.2.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 +636 -2
- package/agents/appeeky-ads.md +50 -0
- package/agents/appeeky-aso.md +58 -0
- package/agents/appeeky-releases.md +47 -0
- package/agents/appeeky-reviews.md +53 -0
- package/agents/appeeky-social.md +54 -0
- package/agents/appeeky-ugc.md +56 -0
- package/agents/cofounder.md +36 -0
- package/dist/ads.d.ts +42 -0
- package/dist/ads.js +71 -0
- package/dist/agents.d.ts +47 -0
- package/dist/agents.js +88 -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 +1960 -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/dataforseo.d.ts +139 -0
- package/dist/dataforseo.js +354 -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 +290 -0
- package/dist/metrics.js +254 -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 +10 -0
- package/dist/report.js +113 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +612 -0
- package/dist/schema.js +253 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/search-ads.d.ts +137 -0
- package/dist/search-ads.js +354 -0
- package/dist/seo.d.ts +87 -0
- package/dist/seo.js +391 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +259 -0
- package/dist/social.d.ts +129 -0
- package/dist/social.js +189 -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 +99 -0
- package/dist/transport.js +170 -0
- package/dist/ugc-core.js +1 -0
- package/dist/ugc-local.d.ts +116 -0
- package/dist/ugc-local.js +174 -0
- package/dist/ugc.d.ts +149 -0
- package/dist/ugc.js +111 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility-core.js +96 -0
- package/dist/visibility-local.d.ts +159 -0
- package/dist/visibility-local.js +246 -0
- package/dist/visibility.d.ts +152 -0
- package/dist/visibility.js +239 -0
- package/package.json +80 -4
- package/skills/appeeky-spec/SKILL.md +142 -0
- package/skills/appeeky-spec-experiments/SKILL.md +93 -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 +106 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { seoDir } from "./seo.js";
|
|
5
|
+
import { rankAppIds } from "./verify.js";
|
|
6
|
+
export function readDataForSeo(env) {
|
|
7
|
+
const login = env.DATAFORSEO_LOGIN?.trim();
|
|
8
|
+
const password = env.DATAFORSEO_PASSWORD?.trim();
|
|
9
|
+
if (!login && !password)
|
|
10
|
+
return null;
|
|
11
|
+
if (!login || !password)
|
|
12
|
+
throw new Error("DataForSEO needs both DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD (from app.dataforseo.com → API access).");
|
|
13
|
+
return { login, password };
|
|
14
|
+
}
|
|
15
|
+
/** Rough prices for the quote, in USD; the real cost of every call is added up from the responses. */
|
|
16
|
+
export const DFS_PRICE = { serp: 0.02, keywordOverview: 0.0101, related: 0.0135, domain: 0.0505 };
|
|
17
|
+
const ISO_NUMERIC = {
|
|
18
|
+
ar: 32, at: 40, au: 36, be: 56, bg: 100, br: 76, ca: 124, ch: 756, cl: 152, cn: 156, co: 170, cz: 203, de: 276, dk: 208, ee: 233,
|
|
19
|
+
eg: 818, es: 724, fi: 246, fr: 250, gb: 826, gr: 300, hk: 344, hr: 191, hu: 348, id: 360, ie: 372, il: 376, in: 356, it: 380,
|
|
20
|
+
jp: 392, kr: 410, lt: 440, lv: 428, ma: 504, mx: 484, my: 458, ng: 566, nl: 528, no: 578, nz: 554, pe: 604, ph: 608, pk: 586,
|
|
21
|
+
pl: 616, pt: 620, ro: 642, rs: 688, ru: 643, sa: 682, se: 752, sg: 702, sk: 703, th: 764, tr: 792, tw: 158, ua: 804, us: 840,
|
|
22
|
+
vn: 704, za: 710,
|
|
23
|
+
};
|
|
24
|
+
const LANGUAGE = {
|
|
25
|
+
ar: "es", at: "de", br: "pt", ch: "de", cl: "es", cn: "zh-CN", co: "es", cz: "cs", de: "de", dk: "da", eg: "ar", es: "es", fi: "fi",
|
|
26
|
+
fr: "fr", gr: "el", hk: "zh-TW", hr: "hr", hu: "hu", id: "id", il: "he", it: "it", jp: "ja", kr: "ko", lt: "lt", lv: "lv", ma: "ar",
|
|
27
|
+
mx: "es", nl: "nl", no: "no", pe: "es", pl: "pl", pt: "pt", ro: "ro", rs: "sr", ru: "ru", sa: "ar", se: "sv", sk: "sk", th: "th",
|
|
28
|
+
tr: "tr", tw: "zh-TW", ua: "uk", vn: "vi",
|
|
29
|
+
};
|
|
30
|
+
/** Country-level location codes are 2000 + the ISO 3166-1 numeric code. */
|
|
31
|
+
export const locationCode = (country) => 2000 + (ISO_NUMERIC[country.toLowerCase()] ?? 840);
|
|
32
|
+
export const languageCode = (country) => LANGUAGE[country.toLowerCase()] ?? "en";
|
|
33
|
+
/** POST one task to a DataForSEO live endpoint. HTTP 200 can still carry a failed task, so both are checked. */
|
|
34
|
+
export async function dfsPost(auth, path, task, fetcher = fetch) {
|
|
35
|
+
const res = await fetcher(`https://api.dataforseo.com${path}`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { authorization: `Basic ${Buffer.from(`${auth.login}:${auth.password}`).toString("base64")}`, "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify([task]),
|
|
39
|
+
signal: AbortSignal.timeout(90_000),
|
|
40
|
+
});
|
|
41
|
+
if (res.status === 401)
|
|
42
|
+
throw new Error("DataForSEO: wrong login or password (DATAFORSEO_LOGIN, DATAFORSEO_PASSWORD).");
|
|
43
|
+
const json = (await res.json().catch(() => null));
|
|
44
|
+
if (!res.ok || !json)
|
|
45
|
+
throw new Error(`DataForSEO: HTTP ${res.status}`);
|
|
46
|
+
const t = json.tasks?.[0];
|
|
47
|
+
if (!t)
|
|
48
|
+
throw new Error(`DataForSEO: ${json.status_code} ${json.status_message}`);
|
|
49
|
+
if (t.status_code !== 20000 && t.status_code !== 20100) {
|
|
50
|
+
if (t.status_code === 40200 || /payment|balance|funds/i.test(t.status_message))
|
|
51
|
+
throw new Error(`DataForSEO: ${t.status_message.replace(/\.$/, "")}. Add funds at app.dataforseo.com.`);
|
|
52
|
+
throw new Error(`DataForSEO: ${t.status_code} ${t.status_message}`);
|
|
53
|
+
}
|
|
54
|
+
return { results: t.result ?? [], cost: json.cost ?? 0 };
|
|
55
|
+
}
|
|
56
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
57
|
+
const r4 = (n) => Math.round(n * 10_000) / 10_000;
|
|
58
|
+
const day = (d) => d.toISOString().slice(0, 10);
|
|
59
|
+
const cell = (v) => (v === null || v === undefined ? "" : typeof v === "string" && /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : String(v));
|
|
60
|
+
const pipe = (s) => s.replace(/\|/g, "\\|");
|
|
61
|
+
export function bareDomain(hostOrUrl) {
|
|
62
|
+
let host = hostOrUrl.trim().toLowerCase();
|
|
63
|
+
try {
|
|
64
|
+
if (/^[a-z]+:\/\//.test(host))
|
|
65
|
+
host = new URL(host).hostname;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// keep as is
|
|
69
|
+
}
|
|
70
|
+
return host.replace(/^www\./, "").replace(/\/.*$/, "");
|
|
71
|
+
}
|
|
72
|
+
const sameSite = (domain, own) => domain === own || domain.endsWith(`.${own}`);
|
|
73
|
+
/** The keywords to watch per storefront: web.keywords, else the store targets. */
|
|
74
|
+
export function webKeywords(project) {
|
|
75
|
+
const own = project.spec.web?.keywords;
|
|
76
|
+
const out = {};
|
|
77
|
+
const countries = project.spec.web?.countries ?? Object.keys(own ?? {}).concat(Object.keys(project.spec.targets)).filter((c, i, a) => a.indexOf(c) === i).slice(0, 1);
|
|
78
|
+
for (const cc of countries) {
|
|
79
|
+
const list = own?.[cc] ?? project.spec.targets[cc] ?? [];
|
|
80
|
+
if (list.length)
|
|
81
|
+
out[cc] = [...new Set(list.map((k) => k.trim().toLowerCase()).filter(Boolean))];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export function siteDomain(project) {
|
|
86
|
+
const url = project.spec.web?.url;
|
|
87
|
+
if (!url)
|
|
88
|
+
throw new Error("Set web.url in appeeky.spec.yaml first.");
|
|
89
|
+
return bareDomain(url);
|
|
90
|
+
}
|
|
91
|
+
export function readSerp(items, own, storePatterns) {
|
|
92
|
+
const organic = items.filter((i) => i.type === "organic" && i.url);
|
|
93
|
+
const pos = (i) => i.rank_group ?? i.rank_absolute ?? 999;
|
|
94
|
+
const mine = organic.filter((i) => sameSite(bareDomain(i.domain ?? i.url), own)).sort((a, b) => pos(a) - pos(b))[0];
|
|
95
|
+
const store = organic.filter((i) => storePatterns.some((p) => p.test(i.url))).sort((a, b) => pos(a) - pos(b))[0];
|
|
96
|
+
const block = items.find((i) => i.type === "ai_overview");
|
|
97
|
+
const cited = new Set();
|
|
98
|
+
for (const ref of [...(block?.references ?? []), ...(block?.items ?? []).flatMap((x) => x.references ?? [])]) {
|
|
99
|
+
const d = ref.domain ?? ref.url;
|
|
100
|
+
if (d)
|
|
101
|
+
cited.add(bareDomain(d));
|
|
102
|
+
}
|
|
103
|
+
const top = [...new Set(organic.sort((a, b) => pos(a) - pos(b)).map((i) => bareDomain(i.domain ?? i.url)))].slice(0, 3);
|
|
104
|
+
return {
|
|
105
|
+
position: mine ? pos(mine) : null,
|
|
106
|
+
...(mine?.url ? { url: mine.url } : {}),
|
|
107
|
+
storePosition: store ? pos(store) : null,
|
|
108
|
+
aiOverview: Boolean(block),
|
|
109
|
+
aiCitesYou: [...cited].some((d) => sameSite(d, own)),
|
|
110
|
+
aiCited: [...cited].slice(0, 8),
|
|
111
|
+
top,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export const ranksFile = (root) => join(seoDir(root), "ranks.csv");
|
|
115
|
+
const RANKS_HEADER = "date,country,keyword,position,url,store_position,ai_overview,ai_cites_you\n";
|
|
116
|
+
async function readRanks(root) {
|
|
117
|
+
if (!existsSync(ranksFile(root)))
|
|
118
|
+
return [];
|
|
119
|
+
return (await readFile(ranksFile(root), "utf8"))
|
|
120
|
+
.split("\n")
|
|
121
|
+
.slice(1)
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.map((l) => {
|
|
124
|
+
const parts = l.match(/("([^"]|"")*"|[^,]*)(,|$)/g).map((p) => p.replace(/,$/, "").replace(/^"|"$/g, "").replace(/""/g, '"'));
|
|
125
|
+
return { date: parts[0], country: parts[1], keyword: parts[2], position: parts[3] ? Number(parts[3]) : null, aiOverview: parts[6] === "yes", aiCitesYou: parts[7] === "yes" };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export async function daysSinceRanks(root, now = new Date()) {
|
|
129
|
+
const last = (await readRanks(root)).map((r) => r.date).sort().at(-1);
|
|
130
|
+
return last ? Math.floor((now.getTime() - Date.parse(last)) / 86_400_000) : null;
|
|
131
|
+
}
|
|
132
|
+
/** One Google results page per keyword and storefront, read like a person on a phone sees it. */
|
|
133
|
+
export async function pullRanks(project, auth, options = {}) {
|
|
134
|
+
const own = siteDomain(project);
|
|
135
|
+
const ids = rankAppIds(project);
|
|
136
|
+
const storePatterns = [
|
|
137
|
+
...(ids["app-store"] ? [new RegExp(`apps\\.apple\\.com/.*id${ids["app-store"]}`)] : []),
|
|
138
|
+
...(ids["google-play"] ? [new RegExp(`play\\.google\\.com/store/apps/details\\?id=${ids["google-play"].replace(/\./g, "\\.")}`)] : []),
|
|
139
|
+
];
|
|
140
|
+
const device = project.spec.web?.device ?? "mobile";
|
|
141
|
+
const now = options.now ?? new Date();
|
|
142
|
+
const date = day(now);
|
|
143
|
+
const ranks = [];
|
|
144
|
+
const errors = [];
|
|
145
|
+
let cost = 0;
|
|
146
|
+
for (const [country, keywords] of Object.entries(webKeywords(project))) {
|
|
147
|
+
for (const keyword of keywords) {
|
|
148
|
+
try {
|
|
149
|
+
const { results, cost: c } = await dfsPost(auth, "/v3/serp/google/organic/live/advanced", { keyword, location_code: locationCode(country), language_code: languageCode(country), device, os: device === "mobile" ? "ios" : "macos", depth: 100, load_async_ai_overview: true, group_organic_results: true }, options.fetcher);
|
|
150
|
+
cost += c;
|
|
151
|
+
ranks.push({ country, keyword, ...readSerp(results[0]?.items ?? [], own, storePatterns) });
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
errors.push(`${country.toUpperCase()} "${keyword}": ${e.message}`);
|
|
155
|
+
if (/wrong login|Add funds/.test(e.message))
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
options.log?.(`${ranks.length + errors.length} keywords`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const dir = seoDir(project.root);
|
|
162
|
+
await mkdir(dir, { recursive: true });
|
|
163
|
+
const prev = await readRanks(project.root);
|
|
164
|
+
const lastDate = prev.map((r) => r.date).filter((d) => d < date).sort().at(-1);
|
|
165
|
+
const before = new Map(prev.filter((r) => r.date === lastDate).map((r) => [`${r.country}\u0000${r.keyword}`, r]));
|
|
166
|
+
const changes = [];
|
|
167
|
+
for (const r of ranks) {
|
|
168
|
+
const was = before.get(`${r.country}\u0000${r.keyword}`);
|
|
169
|
+
if (!was)
|
|
170
|
+
continue;
|
|
171
|
+
const label = `${r.country.toUpperCase()} "${r.keyword}"`;
|
|
172
|
+
const top10 = (p) => p !== null && p <= 10;
|
|
173
|
+
if (top10(r.position) && !top10(was.position))
|
|
174
|
+
changes.push(`${label}: into the top 10 (${was.position ?? "not ranked"} → ${r.position})`);
|
|
175
|
+
if (!top10(r.position) && top10(was.position))
|
|
176
|
+
changes.push(`${label}: out of the top 10 (${was.position} → ${r.position ?? "not ranked"})`);
|
|
177
|
+
if (r.aiCitesYou && !was.aiCitesYou)
|
|
178
|
+
changes.push(`${label}: Google's AI Overview now cites the site`);
|
|
179
|
+
if (!r.aiCitesYou && was.aiCitesYou)
|
|
180
|
+
changes.push(`${label}: Google's AI Overview stopped citing the site`);
|
|
181
|
+
}
|
|
182
|
+
if (!existsSync(ranksFile(project.root)))
|
|
183
|
+
await writeFile(ranksFile(project.root), RANKS_HEADER);
|
|
184
|
+
// One snapshot a day: a second run the same day replaces the first.
|
|
185
|
+
if (prev.some((r) => r.date === date)) {
|
|
186
|
+
const kept = (await readFile(ranksFile(project.root), "utf8")).split("\n").filter((l, i) => i === 0 || (l && !l.startsWith(`${date},`)));
|
|
187
|
+
await writeFile(ranksFile(project.root), kept.join("\n") + "\n");
|
|
188
|
+
}
|
|
189
|
+
const yes = (b) => (b ? "yes" : "no");
|
|
190
|
+
if (ranks.length)
|
|
191
|
+
await appendFile(ranksFile(project.root), ranks.map((r) => [date, r.country, cell(r.keyword), r.position ?? "", cell(r.url ?? ""), r.storePosition ?? "", yes(r.aiOverview), yes(r.aiCitesYou)].join(",")).join("\n") + "\n");
|
|
192
|
+
const ranked = ranks.filter((r) => r.position !== null);
|
|
193
|
+
const md = [
|
|
194
|
+
"# Google ranks",
|
|
195
|
+
"",
|
|
196
|
+
`Written by \`seo ranks\` from DataForSEO, ${date}, ${device}. ${own}: ${ranked.length} of ${ranks.length} keywords in the top 100, ${ranks.filter((r) => r.position !== null && r.position <= 10).length} in the top 10. Google's AI Overview shows for ${ranks.filter((r) => r.aiOverview).length} and cites the site for ${ranks.filter((r) => r.aiCitesYou).length}.`,
|
|
197
|
+
"",
|
|
198
|
+
"| keyword | site | store page | AI Overview | top 3 |",
|
|
199
|
+
"| --- | --- | --- | --- | --- |",
|
|
200
|
+
...ranks.map((r) => `| ${r.country.toUpperCase()} ${pipe(r.keyword)} | ${r.position ?? "—"} | ${r.storePosition ?? "—"} | ${r.aiOverview ? (r.aiCitesYou ? "cites you" : `cites ${r.aiCited.slice(0, 3).join(", ") || "no one"}`) : "—"} | ${r.top.join(", ")} |`),
|
|
201
|
+
"",
|
|
202
|
+
"`site` is the best position of a page on the site, `store page` is where the app's App Store or Google Play page ranks on Google.",
|
|
203
|
+
"",
|
|
204
|
+
].join("\n");
|
|
205
|
+
await writeFile(join(dir, "ranks.md"), md);
|
|
206
|
+
if (changes.length) {
|
|
207
|
+
const file = join(dir, "changes.md");
|
|
208
|
+
const old = existsSync(file) ? await readFile(file, "utf8") : "# Google changes\n\nWritten by `seo ranks`: keywords that entered or left the top 10, and AI Overview citations.\n";
|
|
209
|
+
const [head, ...rest] = old.split(/\n(?=- )/);
|
|
210
|
+
await writeFile(file, [head.trimEnd(), "", ...changes.map((c) => `- ${date} · ${c}`), ...rest.map((r) => r.trimEnd())].join("\n") + "\n");
|
|
211
|
+
}
|
|
212
|
+
return { ranks, costUsd: r4(cost), changes, errors };
|
|
213
|
+
}
|
|
214
|
+
/** `web_rank` (average position, 101 for not ranked) and `ai_overview_citations` (share of keywords) per scan day. */
|
|
215
|
+
export function webRankMetricSource(root) {
|
|
216
|
+
if (!existsSync(ranksFile(root)))
|
|
217
|
+
return null;
|
|
218
|
+
let rows;
|
|
219
|
+
return {
|
|
220
|
+
label: "Google ranks in the repo",
|
|
221
|
+
async window(_store, _appId, from, to) {
|
|
222
|
+
rows ??= readRanks(root);
|
|
223
|
+
const inside = (await rows).filter((r) => r.date >= from && r.date < to);
|
|
224
|
+
if (inside.length === 0)
|
|
225
|
+
return null;
|
|
226
|
+
const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
|
|
227
|
+
const values = {
|
|
228
|
+
web_rank: inside.reduce((n, r) => n + (r.position ?? 101), 0) / inside.length,
|
|
229
|
+
ai_overview_citations: inside.filter((r) => r.aiCitesYou).length / inside.length,
|
|
230
|
+
};
|
|
231
|
+
return { from, to, days, values };
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function flat(item) {
|
|
236
|
+
const n = item.keyword_data;
|
|
237
|
+
return {
|
|
238
|
+
keyword: (n?.keyword ?? item.keyword ?? "").toLowerCase(),
|
|
239
|
+
volume: n?.keyword_info?.search_volume ?? item.keyword_info?.search_volume ?? null,
|
|
240
|
+
cpc: n?.keyword_info?.cpc ?? item.keyword_info?.cpc ?? null,
|
|
241
|
+
difficulty: n?.keyword_properties?.keyword_difficulty ?? item.keyword_properties?.keyword_difficulty ?? null,
|
|
242
|
+
intent: n?.search_intent_info?.main_intent ?? item.search_intent_info?.main_intent ?? null,
|
|
243
|
+
position: item.ranked_serp_element?.serp_item?.rank_group ?? null,
|
|
244
|
+
url: item.ranked_serp_element?.serp_item?.url ?? null,
|
|
245
|
+
traffic: item.ranked_serp_element?.serp_item?.etv ?? null,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Volume, difficulty, CPC and intent for the watched keywords, and related ideas from the first few. */
|
|
249
|
+
export async function pullKeywords(project, auth, options = {}) {
|
|
250
|
+
const rows = [];
|
|
251
|
+
let cost = 0;
|
|
252
|
+
for (const [country, keywords] of Object.entries(webKeywords(project))) {
|
|
253
|
+
const base = { location_code: locationCode(country), language_code: languageCode(country) };
|
|
254
|
+
const overview = await dfsPost(auth, "/v3/dataforseo_labs/google/keyword_overview/live", { ...base, keywords: keywords.slice(0, 700) }, options.fetcher);
|
|
255
|
+
cost += overview.cost;
|
|
256
|
+
const seen = new Set();
|
|
257
|
+
const got = new Map((overview.results[0]?.items ?? []).map((i) => [flat(i).keyword, flat(i)]));
|
|
258
|
+
for (const k of keywords) {
|
|
259
|
+
const f = got.get(k);
|
|
260
|
+
seen.add(k);
|
|
261
|
+
rows.push({ country, keyword: k, volume: f?.volume ?? null, difficulty: f?.difficulty ?? null, cpc: f?.cpc ?? null, intent: f?.intent ?? null, tracked: true });
|
|
262
|
+
}
|
|
263
|
+
for (const seed of keywords.slice(0, options.seeds ?? 3)) {
|
|
264
|
+
const related = await dfsPost(auth, "/v3/dataforseo_labs/google/related_keywords/live", { ...base, keyword: seed, depth: 1, limit: 50, order_by: ["keyword_data.keyword_info.search_volume,desc"] }, options.fetcher);
|
|
265
|
+
cost += related.cost;
|
|
266
|
+
for (const i of related.results[0]?.items ?? []) {
|
|
267
|
+
const f = flat(i);
|
|
268
|
+
if (!f.keyword || seen.has(f.keyword))
|
|
269
|
+
continue;
|
|
270
|
+
seen.add(f.keyword);
|
|
271
|
+
rows.push({ country, keyword: f.keyword, volume: f.volume, difficulty: f.difficulty, cpc: f.cpc, intent: f.intent, tracked: false });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const dir = seoDir(project.root);
|
|
276
|
+
await mkdir(dir, { recursive: true });
|
|
277
|
+
const sorted = [...rows].sort((a, b) => Number(b.tracked) - Number(a.tracked) || (b.volume ?? -1) - (a.volume ?? -1));
|
|
278
|
+
await writeFile(join(dir, "keywords.csv"), `country,keyword,volume,difficulty,cpc,intent,tracked\n${sorted.map((r) => [r.country, cell(r.keyword), r.volume ?? "", r.difficulty ?? "", r.cpc ?? "", r.intent ?? "", r.tracked ? "yes" : "no"].join(",")).join("\n")}\n`);
|
|
279
|
+
return { rows: sorted, costUsd: r4(cost) };
|
|
280
|
+
}
|
|
281
|
+
const PLATFORMS = /(^|\.)(google\.|apple\.com$|youtube\.com$|wikipedia\.org$|facebook\.com$|reddit\.com$|amazon\.|linkedin\.com$|twitter\.com$|x\.com$|instagram\.com$|tiktok\.com$|medium\.com$|github\.com$)/;
|
|
282
|
+
/** What the site ranks for in the first storefront, and the sites that rank for the same searches. */
|
|
283
|
+
export async function pullDomain(project, auth, options = {}) {
|
|
284
|
+
const domain = siteDomain(project);
|
|
285
|
+
const country = Object.keys(webKeywords(project))[0] ?? Object.keys(project.spec.targets)[0] ?? "us";
|
|
286
|
+
const target = { target: domain, location_code: locationCode(country), language_code: languageCode(country) };
|
|
287
|
+
let cost = 0;
|
|
288
|
+
const overview = await dfsPost(auth, "/v3/dataforseo_labs/google/domain_rank_overview/live", target, options.fetcher);
|
|
289
|
+
cost += overview.cost;
|
|
290
|
+
const o = overview.results[0]?.items?.[0]?.metrics?.organic ?? {};
|
|
291
|
+
const ranked = await dfsPost(auth, "/v3/dataforseo_labs/google/ranked_keywords/live", { ...target, limit: 100, order_by: ["ranked_serp_element.serp_item.etv,desc"] }, options.fetcher);
|
|
292
|
+
cost += ranked.cost;
|
|
293
|
+
const competitors = await dfsPost(auth, "/v3/dataforseo_labs/google/competitors_domain/live", { ...target, limit: 30 }, options.fetcher);
|
|
294
|
+
cost += competitors.cost;
|
|
295
|
+
const report = {
|
|
296
|
+
domain,
|
|
297
|
+
country,
|
|
298
|
+
keywords: o.count ?? 0,
|
|
299
|
+
traffic: Math.round(o.etv ?? 0),
|
|
300
|
+
top3: (o.pos_1 ?? 0) + (o.pos_2_3 ?? 0),
|
|
301
|
+
top10: (o.pos_1 ?? 0) + (o.pos_2_3 ?? 0) + (o.pos_4_10 ?? 0),
|
|
302
|
+
ranked: (ranked.results[0]?.items ?? []).map((i) => {
|
|
303
|
+
const f = flat(i);
|
|
304
|
+
return { keyword: f.keyword, position: f.position, volume: f.volume, url: f.url, traffic: f.traffic === null ? null : Math.round(f.traffic) };
|
|
305
|
+
}),
|
|
306
|
+
competitors: (competitors.results[0]?.items ?? [])
|
|
307
|
+
.filter((c) => c.domain && bareDomain(c.domain) !== domain && !PLATFORMS.test(bareDomain(c.domain)))
|
|
308
|
+
.slice(0, 10)
|
|
309
|
+
.map((c) => ({ domain: bareDomain(c.domain), common: c.intersections ?? 0, avgPosition: c.avg_position === undefined ? null : r2(c.avg_position) })),
|
|
310
|
+
};
|
|
311
|
+
const dir = seoDir(project.root);
|
|
312
|
+
await mkdir(dir, { recursive: true });
|
|
313
|
+
await writeFile(join(dir, "ranked-keywords.csv"), `keyword,position,volume,traffic,url\n${report.ranked.map((r) => [cell(r.keyword), r.position ?? "", r.volume ?? "", r.traffic ?? "", cell(r.url ?? "")].join(",")).join("\n")}${report.ranked.length ? "\n" : ""}`);
|
|
314
|
+
const targets = new Set(Object.values(project.spec.targets).flat().map((t) => t.toLowerCase()));
|
|
315
|
+
const md = [
|
|
316
|
+
"# The site in Google",
|
|
317
|
+
"",
|
|
318
|
+
`Written by \`seo domain\` from DataForSEO for ${domain} in ${country.toUpperCase()}.`,
|
|
319
|
+
"",
|
|
320
|
+
`- Ranks for ${report.keywords} keywords: ${report.top3} in the top 3, ${report.top10} in the top 10`,
|
|
321
|
+
`- Estimated organic visits a month: ${report.traffic}`,
|
|
322
|
+
"",
|
|
323
|
+
"## Top keywords",
|
|
324
|
+
"",
|
|
325
|
+
"| keyword | position | volume | store target |",
|
|
326
|
+
"| --- | --- | --- | --- |",
|
|
327
|
+
...(report.ranked.length ? report.ranked.slice(0, 20).map((r) => `| ${pipe(r.keyword)} | ${r.position ?? "—"} | ${r.volume ?? "—"} | ${targets.has(r.keyword) ? "yes" : ""} |`) : ["| none yet | | | |"]),
|
|
328
|
+
"",
|
|
329
|
+
"## Sites that rank for the same searches",
|
|
330
|
+
"",
|
|
331
|
+
"| domain | shared keywords | their average position |",
|
|
332
|
+
"| --- | --- | --- |",
|
|
333
|
+
...(report.competitors.length ? report.competitors.map((c) => `| ${c.domain} | ${c.common} | ${c.avgPosition ?? "—"} |`) : ["| none found | | |"]),
|
|
334
|
+
"",
|
|
335
|
+
].join("\n");
|
|
336
|
+
await writeFile(join(dir, "domain.md"), md);
|
|
337
|
+
return { report, costUsd: r4(cost) };
|
|
338
|
+
}
|
|
339
|
+
/** USD before anything is asked. */
|
|
340
|
+
export function quoteSeo(project, what, seeds = 3) {
|
|
341
|
+
const sets = Object.values(webKeywords(project));
|
|
342
|
+
if (what === "ranks")
|
|
343
|
+
return r2(sets.reduce((n, k) => n + k.length, 0) * DFS_PRICE.serp);
|
|
344
|
+
if (what === "keywords")
|
|
345
|
+
return r2(sets.reduce((n, k) => n + DFS_PRICE.keywordOverview + Math.min(seeds, k.length) * DFS_PRICE.related, 0));
|
|
346
|
+
return r2(DFS_PRICE.domain);
|
|
347
|
+
}
|
|
348
|
+
/** For tests and callers that only want the latest snapshot. */
|
|
349
|
+
export async function latestRanks(root) {
|
|
350
|
+
const rows = await readRanks(root);
|
|
351
|
+
const last = rows.map((r) => r.date).sort().at(-1);
|
|
352
|
+
return rows.filter((r) => r.date === last);
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=dataforseo.js.map
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Listing, type StoreId, type StoreListings } from "./schema.js";
|
|
2
|
+
import type { FieldChange } from "./stores/types.js";
|
|
3
|
+
/** Stores trim trailing whitespace and normalize line endings, so compare the same way. */
|
|
4
|
+
export declare function normalizeValue(value: string | undefined | null): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Field-level differences for the fields the spec manages. A locale or field
|
|
7
|
+
* that is only in the store is left alone: the spec owns what it lists.
|
|
8
|
+
*/
|
|
9
|
+
export declare function diffListings<L extends Listing>(store: StoreId, local: StoreListings<L>, remote: StoreListings<L>): FieldChange[];
|
|
10
|
+
/** The inverse of a set of applied changes: what to push to go back. A field that was empty goes back to "" (cleared). */
|
|
11
|
+
export declare function invertChanges(changes: FieldChange[]): FieldChange[];
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fieldsFor } from "./schema.js";
|
|
2
|
+
/** Stores trim trailing whitespace and normalize line endings, so compare the same way. */
|
|
3
|
+
export function normalizeValue(value) {
|
|
4
|
+
if (value === undefined || value === null)
|
|
5
|
+
return undefined;
|
|
6
|
+
return value.replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "").trimEnd();
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Field-level differences for the fields the spec manages. A locale or field
|
|
10
|
+
* that is only in the store is left alone: the spec owns what it lists.
|
|
11
|
+
*/
|
|
12
|
+
export function diffListings(store, local, remote) {
|
|
13
|
+
const changes = [];
|
|
14
|
+
for (const [locale, listing] of Object.entries(local)) {
|
|
15
|
+
const live = remote[locale];
|
|
16
|
+
for (const field of fieldsFor(store)) {
|
|
17
|
+
const after = normalizeValue(listing[field]);
|
|
18
|
+
if (after === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
const before = normalizeValue(live?.[field]);
|
|
21
|
+
// "" in the spec clears the field; empty and missing are the same.
|
|
22
|
+
if ((before ?? "") === after)
|
|
23
|
+
continue;
|
|
24
|
+
changes.push({ store, locale, field, before, after, newLocale: live === undefined });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return changes;
|
|
28
|
+
}
|
|
29
|
+
/** The inverse of a set of applied changes: what to push to go back. A field that was empty goes back to "" (cleared). */
|
|
30
|
+
export function invertChanges(changes) {
|
|
31
|
+
return changes.map((c) => ({ ...c, before: c.after, after: c.before ?? "", newLocale: false, blocked: undefined }));
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Anything you try to grow the app that is not a listing push: screenshots,
|
|
4
|
+
* a UGC hook, an ad creative, a price. Same shape as a release: a hypothesis,
|
|
5
|
+
* one metric, a baseline, a date to check, and a result.
|
|
6
|
+
* Listing pushes are experiments already (their release file holds the result).
|
|
7
|
+
*/
|
|
8
|
+
export declare const experimentSchema: z.ZodObject<{
|
|
9
|
+
id: z.ZodString;
|
|
10
|
+
type: z.ZodEnum<["screenshots", "ugc", "ads", "pricing", "other"]>;
|
|
11
|
+
hypothesis: z.ZodString;
|
|
12
|
+
/** Persona id from `personas:` in appeeky.spec.yaml. */
|
|
13
|
+
persona: z.ZodOptional<z.ZodString>;
|
|
14
|
+
store: z.ZodOptional<z.ZodEnum<["app-store", "google-play"]>>;
|
|
15
|
+
country: z.ZodOptional<z.ZodString>;
|
|
16
|
+
metric: z.ZodString;
|
|
17
|
+
baseline: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
/** Value that counts as a win, e.g. 2.5 for "install_per_1k_views > 2.5". */
|
|
19
|
+
target: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
startedAt: z.ZodString;
|
|
21
|
+
checkAfterDays: z.ZodDefault<z.ZodNumber>;
|
|
22
|
+
status: z.ZodDefault<z.ZodEnum<["running", "won", "lost", "inconclusive"]>>;
|
|
23
|
+
result: z.ZodOptional<z.ZodObject<{
|
|
24
|
+
value: z.ZodOptional<z.ZodNumber>;
|
|
25
|
+
note: z.ZodOptional<z.ZodString>;
|
|
26
|
+
closedAt: z.ZodString;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
closedAt: string;
|
|
29
|
+
value?: number | undefined;
|
|
30
|
+
note?: string | undefined;
|
|
31
|
+
}, {
|
|
32
|
+
closedAt: string;
|
|
33
|
+
value?: number | undefined;
|
|
34
|
+
note?: string | undefined;
|
|
35
|
+
}>>;
|
|
36
|
+
/** The smart link that carries the experiment's traffic, so its installs and revenue are counted exactly. */
|
|
37
|
+
link: z.ZodOptional<z.ZodObject<{
|
|
38
|
+
slug: z.ZodString;
|
|
39
|
+
url: z.ZodOptional<z.ZodString>;
|
|
40
|
+
store: z.ZodOptional<z.ZodEnum<["app-store", "google-play"]>>;
|
|
41
|
+
}, "strict", z.ZodTypeAny, {
|
|
42
|
+
slug: string;
|
|
43
|
+
url?: string | undefined;
|
|
44
|
+
store?: "app-store" | "google-play" | undefined;
|
|
45
|
+
}, {
|
|
46
|
+
slug: string;
|
|
47
|
+
url?: string | undefined;
|
|
48
|
+
store?: "app-store" | "google-play" | undefined;
|
|
49
|
+
}>>;
|
|
50
|
+
/** Links, file paths, ad ids: whatever ran. */
|
|
51
|
+
assets: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
52
|
+
}, "strict", z.ZodTypeAny, {
|
|
53
|
+
type: "ads" | "ugc" | "screenshots" | "pricing" | "other";
|
|
54
|
+
status: "won" | "lost" | "running" | "inconclusive";
|
|
55
|
+
id: string;
|
|
56
|
+
hypothesis: string;
|
|
57
|
+
metric: string;
|
|
58
|
+
startedAt: string;
|
|
59
|
+
checkAfterDays: number;
|
|
60
|
+
assets: string[];
|
|
61
|
+
store?: "app-store" | "google-play" | undefined;
|
|
62
|
+
link?: {
|
|
63
|
+
slug: string;
|
|
64
|
+
url?: string | undefined;
|
|
65
|
+
store?: "app-store" | "google-play" | undefined;
|
|
66
|
+
} | undefined;
|
|
67
|
+
country?: string | undefined;
|
|
68
|
+
result?: {
|
|
69
|
+
closedAt: string;
|
|
70
|
+
value?: number | undefined;
|
|
71
|
+
note?: string | undefined;
|
|
72
|
+
} | undefined;
|
|
73
|
+
persona?: string | undefined;
|
|
74
|
+
target?: number | undefined;
|
|
75
|
+
baseline?: number | undefined;
|
|
76
|
+
}, {
|
|
77
|
+
type: "ads" | "ugc" | "screenshots" | "pricing" | "other";
|
|
78
|
+
id: string;
|
|
79
|
+
hypothesis: string;
|
|
80
|
+
metric: string;
|
|
81
|
+
startedAt: string;
|
|
82
|
+
status?: "won" | "lost" | "running" | "inconclusive" | undefined;
|
|
83
|
+
store?: "app-store" | "google-play" | undefined;
|
|
84
|
+
link?: {
|
|
85
|
+
slug: string;
|
|
86
|
+
url?: string | undefined;
|
|
87
|
+
store?: "app-store" | "google-play" | undefined;
|
|
88
|
+
} | undefined;
|
|
89
|
+
country?: string | undefined;
|
|
90
|
+
result?: {
|
|
91
|
+
closedAt: string;
|
|
92
|
+
value?: number | undefined;
|
|
93
|
+
note?: string | undefined;
|
|
94
|
+
} | undefined;
|
|
95
|
+
persona?: string | undefined;
|
|
96
|
+
target?: number | undefined;
|
|
97
|
+
baseline?: number | undefined;
|
|
98
|
+
checkAfterDays?: number | undefined;
|
|
99
|
+
assets?: string[] | undefined;
|
|
100
|
+
}>;
|
|
101
|
+
export type Experiment = z.infer<typeof experimentSchema>;
|
|
102
|
+
export type ExperimentInput = z.input<typeof experimentSchema>;
|
|
103
|
+
export declare function experimentsDir(root: string): string;
|
|
104
|
+
export declare function readExperiments(root: string): Promise<Experiment[]>;
|
|
105
|
+
export declare function writeExperiment(root: string, input: ExperimentInput): Promise<Experiment>;
|
|
106
|
+
export declare function experimentId(date: Date, hypothesis: string): string;
|
|
107
|
+
/** Experiments whose check date has passed and that have no result yet. */
|
|
108
|
+
export declare function dueExperiments(experiments: Experiment[], now?: Date): Experiment[];
|
|
109
|
+
/**
|
|
110
|
+
* Close with a measured value. With a `target`, reaching it wins. Without
|
|
111
|
+
* one, a known metric (conversion, revenue, cpa, ...) is judged against the
|
|
112
|
+
* baseline: a 5% move the right way wins, the wrong way loses. Anything else
|
|
113
|
+
* is inconclusive.
|
|
114
|
+
*/
|
|
115
|
+
export declare function closeExperiment(e: Experiment, value: number | undefined, note: string | undefined, now?: Date): Experiment;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SPEC_DIR, SpecError } from "./files.js";
|
|
7
|
+
import { isMetricName, outcomeOf } from "./metrics.js";
|
|
8
|
+
/**
|
|
9
|
+
* Anything you try to grow the app that is not a listing push: screenshots,
|
|
10
|
+
* a UGC hook, an ad creative, a price. Same shape as a release: a hypothesis,
|
|
11
|
+
* one metric, a baseline, a date to check, and a result.
|
|
12
|
+
* Listing pushes are experiments already (their release file holds the result).
|
|
13
|
+
*/
|
|
14
|
+
export const experimentSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
17
|
+
type: z.enum(["screenshots", "ugc", "ads", "pricing", "other"]),
|
|
18
|
+
hypothesis: z.string().min(1),
|
|
19
|
+
/** Persona id from `personas:` in appeeky.spec.yaml. */
|
|
20
|
+
persona: z.string().optional(),
|
|
21
|
+
store: z.enum(["app-store", "google-play"]).optional(),
|
|
22
|
+
country: z.string().optional(),
|
|
23
|
+
metric: z.string().min(1),
|
|
24
|
+
baseline: z.number().optional(),
|
|
25
|
+
/** Value that counts as a win, e.g. 2.5 for "install_per_1k_views > 2.5". */
|
|
26
|
+
target: z.number().optional(),
|
|
27
|
+
startedAt: z.string(),
|
|
28
|
+
checkAfterDays: z.number().int().positive().default(14),
|
|
29
|
+
status: z.enum(["running", "won", "lost", "inconclusive"]).default("running"),
|
|
30
|
+
result: z.object({ value: z.number().optional(), note: z.string().optional(), closedAt: z.string() }).optional(),
|
|
31
|
+
/** The smart link that carries the experiment's traffic, so its installs and revenue are counted exactly. */
|
|
32
|
+
link: z.object({ slug: z.string(), url: z.string().optional(), store: z.enum(["app-store", "google-play"]).optional() }).strict().optional(),
|
|
33
|
+
/** Links, file paths, ad ids: whatever ran. */
|
|
34
|
+
assets: z.array(z.string()).default([]),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
37
|
+
export function experimentsDir(root) {
|
|
38
|
+
return join(root, SPEC_DIR, "experiments");
|
|
39
|
+
}
|
|
40
|
+
export async function readExperiments(root) {
|
|
41
|
+
const dir = experimentsDir(root);
|
|
42
|
+
if (!existsSync(dir))
|
|
43
|
+
return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort().reverse()) {
|
|
46
|
+
const parsed = experimentSchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
|
|
47
|
+
if (!parsed.success)
|
|
48
|
+
throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
|
|
49
|
+
out.push(parsed.data);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
export async function writeExperiment(root, input) {
|
|
54
|
+
const experiment = experimentSchema.parse(input);
|
|
55
|
+
await mkdir(experimentsDir(root), { recursive: true });
|
|
56
|
+
await writeFile(join(experimentsDir(root), `${experiment.id}.yaml`), YAML.stringify(experiment, { lineWidth: 0 }));
|
|
57
|
+
return experiment;
|
|
58
|
+
}
|
|
59
|
+
export function experimentId(date, hypothesis) {
|
|
60
|
+
const slug = hypothesis.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "experiment";
|
|
61
|
+
return `${date.toISOString().slice(0, 10)}-${slug}`;
|
|
62
|
+
}
|
|
63
|
+
/** Experiments whose check date has passed and that have no result yet. */
|
|
64
|
+
export function dueExperiments(experiments, now = new Date()) {
|
|
65
|
+
return experiments.filter((e) => e.status === "running" && Date.parse(e.startedAt) + e.checkAfterDays * 86_400_000 <= now.getTime());
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Close with a measured value. With a `target`, reaching it wins. Without
|
|
69
|
+
* one, a known metric (conversion, revenue, cpa, ...) is judged against the
|
|
70
|
+
* baseline: a 5% move the right way wins, the wrong way loses. Anything else
|
|
71
|
+
* is inconclusive.
|
|
72
|
+
*/
|
|
73
|
+
export function closeExperiment(e, value, note, now = new Date()) {
|
|
74
|
+
let status = "inconclusive";
|
|
75
|
+
if (value !== undefined && e.target !== undefined) {
|
|
76
|
+
const lowerIsBetter = isMetricName(e.metric) && outcomeOf(e.metric, -1) === "better";
|
|
77
|
+
status = (lowerIsBetter ? value <= e.target : value >= e.target) ? "won" : "lost";
|
|
78
|
+
}
|
|
79
|
+
else if (value !== undefined && e.baseline !== undefined && e.baseline > 0 && isMetricName(e.metric)) {
|
|
80
|
+
const outcome = outcomeOf(e.metric, value / e.baseline - 1);
|
|
81
|
+
status = outcome === "better" ? "won" : outcome === "worse" ? "lost" : "inconclusive";
|
|
82
|
+
}
|
|
83
|
+
return { ...e, status, result: { value, note, closedAt: now.toISOString() } };
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=experiments.js.map
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Listing, type Project, type SpecFile, type SpecFileInput, type StoreId } from "./schema.js";
|
|
2
|
+
export declare const SPEC_FILE = "appeeky.spec.yaml";
|
|
3
|
+
export declare const SPEC_DIR = "appeeky";
|
|
4
|
+
export declare class SpecError extends Error {
|
|
5
|
+
readonly file?: string | undefined;
|
|
6
|
+
constructor(message: string, file?: string | undefined);
|
|
7
|
+
}
|
|
8
|
+
export declare function listingsDir(root: string, store: StoreId): string;
|
|
9
|
+
export declare function listingPath(root: string, store: StoreId, locale: string): string;
|
|
10
|
+
/** Walk up from `start` to the folder that holds `appeeky.spec.yaml`. */
|
|
11
|
+
export declare function findRoot(start?: string): string | undefined;
|
|
12
|
+
export declare function parseSpec(raw: unknown, file?: string): SpecFile;
|
|
13
|
+
export declare function parseListing(store: StoreId, raw: unknown, file: string): Listing;
|
|
14
|
+
/** Load the spec and every listing file under `appeeky/store/`. */
|
|
15
|
+
export declare function loadProject(start?: string): Promise<Project>;
|
|
16
|
+
/** Keep the store's field order and use block scalars for multi-line text so diffs stay readable. */
|
|
17
|
+
export declare function stringifyListing(store: StoreId, listing: Listing): string;
|
|
18
|
+
export declare function writeListing(root: string, store: StoreId, locale: string, listing: Listing): Promise<string>;
|
|
19
|
+
export declare function stringifySpec(spec: SpecFileInput): string;
|
|
20
|
+
export declare function writeSpec(root: string, spec: SpecFileInput): Promise<string>;
|
|
21
|
+
/** Where each store's listing files were last pulled from, e.g. "version 3.0.1 (prepare for submission)". */
|
|
22
|
+
export type PullState = Partial<Record<StoreId, {
|
|
23
|
+
target: string;
|
|
24
|
+
at: string;
|
|
25
|
+
}>>;
|
|
26
|
+
export declare function readPullState(root: string): Promise<PullState>;
|
|
27
|
+
export declare function recordPull(root: string, store: StoreId, target: string): Promise<void>;
|