@appeeky/spec 0.1.0 → 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.
Files changed (46) hide show
  1. package/README.md +158 -5
  2. package/agents/appeeky-ads.md +50 -0
  3. package/agents/appeeky-aso.md +58 -0
  4. package/agents/appeeky-releases.md +47 -0
  5. package/agents/appeeky-reviews.md +53 -0
  6. package/agents/appeeky-social.md +54 -0
  7. package/agents/appeeky-ugc.md +56 -0
  8. package/agents/cofounder.md +36 -0
  9. package/dist/ads.d.ts +5 -2
  10. package/dist/ads.js +8 -4
  11. package/dist/agents.d.ts +47 -0
  12. package/dist/agents.js +88 -0
  13. package/dist/cli.js +582 -9
  14. package/dist/dataforseo.d.ts +139 -0
  15. package/dist/dataforseo.js +354 -0
  16. package/dist/experiments.d.ts +4 -4
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.js +4 -0
  19. package/dist/report.d.ts +5 -0
  20. package/dist/report.js +42 -0
  21. package/dist/reviews.d.ts +2 -2
  22. package/dist/schema.d.ts +95 -2
  23. package/dist/schema.js +35 -0
  24. package/dist/search-ads.d.ts +137 -0
  25. package/dist/search-ads.js +354 -0
  26. package/dist/seo.d.ts +87 -0
  27. package/dist/seo.js +391 -0
  28. package/dist/setup.js +39 -6
  29. package/dist/social.d.ts +129 -0
  30. package/dist/social.js +189 -0
  31. package/dist/transport.d.ts +1 -0
  32. package/dist/transport.js +1 -1
  33. package/dist/ugc-core.js +1 -0
  34. package/dist/ugc-local.d.ts +116 -0
  35. package/dist/ugc-local.js +174 -0
  36. package/dist/ugc.d.ts +149 -0
  37. package/dist/ugc.js +111 -0
  38. package/dist/visibility-core.js +96 -0
  39. package/dist/visibility-local.d.ts +159 -0
  40. package/dist/visibility-local.js +246 -0
  41. package/dist/visibility.d.ts +3 -0
  42. package/dist/visibility.js +1 -1
  43. package/package.json +7 -3
  44. package/skills/appeeky-spec/SKILL.md +14 -18
  45. package/skills/appeeky-spec-experiments/SKILL.md +12 -2
  46. package/skills/appeeky-spec-visibility/SKILL.md +33 -6
@@ -0,0 +1,139 @@
1
+ import type { MetricSource } from "./metrics.js";
2
+ import type { Project } from "./schema.js";
3
+ /**
4
+ * Google search for the app's website on your own DataForSEO account (DATAFORSEO_LOGIN,
5
+ * DATAFORSEO_PASSWORD), no Appeeky account:
6
+ *
7
+ * seo ranks where the site, and the app's store page, rank on Google for each keyword,
8
+ * and whether Google's AI Overview cites the site (appeeky/seo/ranks.csv, ranks.md)
9
+ * seo keywords search volume, difficulty, CPC and intent for the keywords, plus related ideas
10
+ * seo domain what the site ranks for, its estimated traffic, and the domains it competes with
11
+ *
12
+ * The keywords are web.keywords.<country>, or the store targets of that storefront.
13
+ * DataForSEO bills each call; every command can quote first with --dry-run.
14
+ */
15
+ export interface DfsAuth {
16
+ login: string;
17
+ password: string;
18
+ }
19
+ export declare function readDataForSeo(env: Record<string, string | undefined>): DfsAuth | null;
20
+ /** Rough prices for the quote, in USD; the real cost of every call is added up from the responses. */
21
+ export declare const DFS_PRICE: {
22
+ readonly serp: 0.02;
23
+ readonly keywordOverview: 0.0101;
24
+ readonly related: 0.0135;
25
+ readonly domain: 0.0505;
26
+ };
27
+ /** Country-level location codes are 2000 + the ISO 3166-1 numeric code. */
28
+ export declare const locationCode: (country: string) => number;
29
+ export declare const languageCode: (country: string) => string;
30
+ /** POST one task to a DataForSEO live endpoint. HTTP 200 can still carry a failed task, so both are checked. */
31
+ export declare function dfsPost<T>(auth: DfsAuth, path: string, task: Record<string, unknown>, fetcher?: typeof fetch): Promise<{
32
+ results: T[];
33
+ cost: number;
34
+ }>;
35
+ export declare function bareDomain(hostOrUrl: string): string;
36
+ /** The keywords to watch per storefront: web.keywords, else the store targets. */
37
+ export declare function webKeywords(project: Project): Record<string, string[]>;
38
+ export declare function siteDomain(project: Project): string;
39
+ interface SerpItem {
40
+ type: string;
41
+ rank_group?: number;
42
+ rank_absolute?: number;
43
+ domain?: string;
44
+ url?: string;
45
+ title?: string;
46
+ items?: SerpItem[];
47
+ references?: Array<{
48
+ url?: string;
49
+ domain?: string;
50
+ }>;
51
+ }
52
+ export interface KeywordRank {
53
+ country: string;
54
+ keyword: string;
55
+ /** The site's best organic position, null when not in the top 100. */
56
+ position: number | null;
57
+ url?: string;
58
+ /** Where the app's own App Store or Google Play page ranks on Google. */
59
+ storePosition: number | null;
60
+ aiOverview: boolean;
61
+ aiCitesYou: boolean;
62
+ /** Domains the AI Overview cites. */
63
+ aiCited: string[];
64
+ top: string[];
65
+ }
66
+ export declare function readSerp(items: SerpItem[], own: string, storePatterns: RegExp[]): Omit<KeywordRank, "country" | "keyword">;
67
+ export declare const ranksFile: (root: string) => string;
68
+ interface RankRow {
69
+ date: string;
70
+ country: string;
71
+ keyword: string;
72
+ position: number | null;
73
+ aiOverview: boolean;
74
+ aiCitesYou: boolean;
75
+ }
76
+ export declare function daysSinceRanks(root: string, now?: Date): Promise<number | null>;
77
+ export interface RanksResult {
78
+ ranks: KeywordRank[];
79
+ costUsd: number;
80
+ changes: string[];
81
+ errors: string[];
82
+ }
83
+ /** One Google results page per keyword and storefront, read like a person on a phone sees it. */
84
+ export declare function pullRanks(project: Project, auth: DfsAuth, options?: {
85
+ fetcher?: typeof fetch;
86
+ now?: Date;
87
+ log?: (l: string) => void;
88
+ }): Promise<RanksResult>;
89
+ /** `web_rank` (average position, 101 for not ranked) and `ai_overview_citations` (share of keywords) per scan day. */
90
+ export declare function webRankMetricSource(root: string): MetricSource | null;
91
+ export interface KeywordRow {
92
+ country: string;
93
+ keyword: string;
94
+ volume: number | null;
95
+ difficulty: number | null;
96
+ cpc: number | null;
97
+ intent: string | null;
98
+ tracked: boolean;
99
+ }
100
+ /** Volume, difficulty, CPC and intent for the watched keywords, and related ideas from the first few. */
101
+ export declare function pullKeywords(project: Project, auth: DfsAuth, options?: {
102
+ fetcher?: typeof fetch;
103
+ seeds?: number;
104
+ }): Promise<{
105
+ rows: KeywordRow[];
106
+ costUsd: number;
107
+ }>;
108
+ export interface DomainReport {
109
+ domain: string;
110
+ country: string;
111
+ keywords: number;
112
+ traffic: number;
113
+ top3: number;
114
+ top10: number;
115
+ ranked: Array<{
116
+ keyword: string;
117
+ position: number | null;
118
+ volume: number | null;
119
+ url: string | null;
120
+ traffic: number | null;
121
+ }>;
122
+ competitors: Array<{
123
+ domain: string;
124
+ common: number;
125
+ avgPosition: number | null;
126
+ }>;
127
+ }
128
+ /** What the site ranks for in the first storefront, and the sites that rank for the same searches. */
129
+ export declare function pullDomain(project: Project, auth: DfsAuth, options?: {
130
+ fetcher?: typeof fetch;
131
+ }): Promise<{
132
+ report: DomainReport;
133
+ costUsd: number;
134
+ }>;
135
+ /** USD before anything is asked. */
136
+ export declare function quoteSeo(project: Project, what: "ranks" | "keywords" | "domain", seeds?: number): number;
137
+ /** For tests and callers that only want the latest snapshot. */
138
+ export declare function latestRanks(root: string): Promise<RankRow[]>;
139
+ export {};
@@ -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
@@ -50,7 +50,7 @@ export declare const experimentSchema: z.ZodObject<{
50
50
  /** Links, file paths, ad ids: whatever ran. */
51
51
  assets: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
52
52
  }, "strict", z.ZodTypeAny, {
53
- type: "ads" | "screenshots" | "ugc" | "pricing" | "other";
53
+ type: "ads" | "ugc" | "screenshots" | "pricing" | "other";
54
54
  status: "won" | "lost" | "running" | "inconclusive";
55
55
  id: string;
56
56
  hypothesis: string;
@@ -71,10 +71,10 @@ export declare const experimentSchema: z.ZodObject<{
71
71
  note?: string | undefined;
72
72
  } | undefined;
73
73
  persona?: string | undefined;
74
- baseline?: number | undefined;
75
74
  target?: number | undefined;
75
+ baseline?: number | undefined;
76
76
  }, {
77
- type: "ads" | "screenshots" | "ugc" | "pricing" | "other";
77
+ type: "ads" | "ugc" | "screenshots" | "pricing" | "other";
78
78
  id: string;
79
79
  hypothesis: string;
80
80
  metric: string;
@@ -93,8 +93,8 @@ export declare const experimentSchema: z.ZodObject<{
93
93
  note?: string | undefined;
94
94
  } | undefined;
95
95
  persona?: string | undefined;
96
- baseline?: number | undefined;
97
96
  target?: number | undefined;
97
+ baseline?: number | undefined;
98
98
  checkAfterDays?: number | undefined;
99
99
  assets?: string[] | undefined;
100
100
  }>;
package/dist/metrics.d.ts CHANGED
@@ -131,6 +131,26 @@ export declare const METRICS: {
131
131
  readonly kind: "rate";
132
132
  readonly better: "up";
133
133
  };
134
+ readonly search_clicks: {
135
+ readonly label: "Google search clicks/day";
136
+ readonly kind: "count";
137
+ readonly better: "up";
138
+ };
139
+ readonly search_impressions: {
140
+ readonly label: "Google search impressions/day";
141
+ readonly kind: "count";
142
+ readonly better: "up";
143
+ };
144
+ readonly web_rank: {
145
+ readonly label: "average Google position of the site";
146
+ readonly kind: "count";
147
+ readonly better: "down";
148
+ };
149
+ readonly ai_overview_citations: {
150
+ readonly label: "Google AI Overviews that cite the site";
151
+ readonly kind: "rate";
152
+ readonly better: "up";
153
+ };
134
154
  readonly category_rank: {
135
155
  readonly label: "category rank";
136
156
  readonly kind: "count";
package/dist/metrics.js CHANGED
@@ -30,6 +30,10 @@ export const METRICS = {
30
30
  tiktok_conversions: { label: "TikTok conversions/day", kind: "count", better: "up" },
31
31
  tiktok_cpa: { label: "TikTok cost per conversion", kind: "money", better: "down" },
32
32
  ai_visibility: { label: "AI answers that name the app", kind: "rate", better: "up" },
33
+ search_clicks: { label: "Google search clicks/day", kind: "count", better: "up" },
34
+ search_impressions: { label: "Google search impressions/day", kind: "count", better: "up" },
35
+ web_rank: { label: "average Google position of the site", kind: "count", better: "down" },
36
+ ai_overview_citations: { label: "Google AI Overviews that cite the site", kind: "rate", better: "up" },
33
37
  category_rank: { label: "category rank", kind: "count", better: "down" },
34
38
  };
35
39
  export function isMetricName(name) {
package/dist/report.d.ts CHANGED
@@ -3,3 +3,8 @@ import { type Project } from "./schema.js";
3
3
  export declare function loadProjectAtRef(root: string, ref: string): Promise<Project | null>;
4
4
  /** Markdown for a pull request: listing changes, check results and coverage before → after. */
5
5
  export declare function specReport(base: Project | null, head: Project): string;
6
+ /**
7
+ * The posts and clips a merge sends out: social drafts and UGC briefs still marked draft, so a
8
+ * reviewer sees them next to the listing change. Empty when there are none.
9
+ */
10
+ export declare function outboxReport(root: string, now?: Date): Promise<string>;
package/dist/report.js CHANGED
@@ -8,6 +8,8 @@ import { diffListings } from "./diff.js";
8
8
  import { loadProject, SPEC_DIR, SPEC_FILE } from "./files.js";
9
9
  import { lintProject } from "./lint.js";
10
10
  import { STORES } from "./schema.js";
11
+ import { draftIssues, readAccounts, readDrafts } from "./social.js";
12
+ import { readBriefs } from "./ugc.js";
11
13
  const run = promisify(execFile);
12
14
  /** Load the spec as it was at a git ref (e.g. `origin/main`), or null when it did not exist there. */
13
15
  export async function loadProjectAtRef(root, ref) {
@@ -68,4 +70,44 @@ export function specReport(base, head) {
68
70
  }
69
71
  return out.join("\n");
70
72
  }
73
+ const cell = (s, n = 90) => s.replace(/\n+/g, " ").replace(/\|/g, "\\|").slice(0, n);
74
+ /**
75
+ * The posts and clips a merge sends out: social drafts and UGC briefs still marked draft, so a
76
+ * reviewer sees them next to the listing change. Empty when there are none.
77
+ */
78
+ export async function outboxReport(root, now = new Date()) {
79
+ const out = [];
80
+ const rel = (f) => relative(root, f);
81
+ try {
82
+ const drafts = (await readDrafts(root)).filter((d) => d.status === "draft");
83
+ if (drafts.length > 0) {
84
+ const accounts = await readAccounts(root);
85
+ out.push("", `**${drafts.length} post${drafts.length === 1 ? " goes" : "s go"} out on merge**`, "", "| Draft | Accounts | When | Text |", "| --- | --- | --- | --- |");
86
+ for (const d of drafts)
87
+ out.push(`| ${rel(d.file)} | ${d.accounts.join(", ")} | ${d.when ?? "on merge"} | ${cell(d.text)} |`);
88
+ if (accounts.length === 0)
89
+ out.push("", "- ⚠️ No accounts in appeeky/social/accounts.yaml: run `social pull` so the drafts can be checked.");
90
+ else
91
+ for (const d of drafts)
92
+ for (const issue of draftIssues(d, accounts, now))
93
+ out.push(`- ⚠️ ${rel(d.file)}: ${issue}`);
94
+ }
95
+ }
96
+ catch (err) {
97
+ out.push("", `- ❌ Social drafts: ${err.message}`);
98
+ }
99
+ try {
100
+ const briefs = (await readBriefs(root)).filter((b) => b.status === "draft");
101
+ if (briefs.length > 0) {
102
+ out.push("", `**${briefs.length} clip${briefs.length === 1 ? "" : "s"} made on merge**`, "", "| Brief | Persona | Length | Script |", "| --- | --- | --- | --- |");
103
+ for (const b of briefs)
104
+ out.push(`| ${rel(b.file)} | ${b.persona} | ${b.durationSec ?? 8}s ${b.aspectRatio ?? "9:16"} | ${cell(b.script)} |`);
105
+ out.push("", "Run `ugc generate --dry-run` (or `--local --dry-run`) for the cost.");
106
+ }
107
+ }
108
+ catch (err) {
109
+ out.push("", `- ❌ UGC briefs: ${err.message}`);
110
+ }
111
+ return out.join("\n");
112
+ }
71
113
  //# sourceMappingURL=report.js.map
package/dist/reviews.d.ts CHANGED
@@ -102,15 +102,15 @@ export declare const replySchema: z.ZodObject<{
102
102
  store: "app-store" | "google-play";
103
103
  id: string;
104
104
  text: string;
105
- review?: string | undefined;
106
105
  sentAt?: string | undefined;
106
+ review?: string | undefined;
107
107
  }, {
108
108
  store: "app-store" | "google-play";
109
109
  id: string;
110
110
  text: string;
111
111
  status?: "draft" | "sent" | undefined;
112
- review?: string | undefined;
113
112
  sentAt?: string | undefined;
113
+ review?: string | undefined;
114
114
  }>;
115
115
  export type Reply = z.infer<typeof replySchema>;
116
116
  export declare const repliesDir: (root: string) => string;