@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.
Files changed (111) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +636 -2
  4. package/agents/appeeky-ads.md +50 -0
  5. package/agents/appeeky-aso.md +58 -0
  6. package/agents/appeeky-releases.md +47 -0
  7. package/agents/appeeky-reviews.md +53 -0
  8. package/agents/appeeky-social.md +54 -0
  9. package/agents/appeeky-ugc.md +56 -0
  10. package/agents/cofounder.md +36 -0
  11. package/dist/ads.d.ts +42 -0
  12. package/dist/ads.js +71 -0
  13. package/dist/agents.d.ts +47 -0
  14. package/dist/agents.js +88 -0
  15. package/dist/appeeky-api.d.ts +62 -0
  16. package/dist/appeeky-api.js +240 -0
  17. package/dist/attribution-setup.d.ts +28 -0
  18. package/dist/attribution-setup.js +65 -0
  19. package/dist/attribution.d.ts +121 -0
  20. package/dist/attribution.js +170 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +4 -0
  23. package/dist/category.d.ts +71 -0
  24. package/dist/category.js +198 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +1960 -0
  27. package/dist/competitors.d.ts +74 -0
  28. package/dist/competitors.js +143 -0
  29. package/dist/control.d.ts +21 -0
  30. package/dist/control.js +53 -0
  31. package/dist/core.d.ts +15 -0
  32. package/dist/core.js +15 -0
  33. package/dist/coverage.d.ts +46 -0
  34. package/dist/coverage.js +115 -0
  35. package/dist/data.d.ts +48 -0
  36. package/dist/data.js +298 -0
  37. package/dist/dataforseo.d.ts +139 -0
  38. package/dist/dataforseo.js +354 -0
  39. package/dist/diff.d.ts +11 -0
  40. package/dist/diff.js +33 -0
  41. package/dist/experiments.d.ts +115 -0
  42. package/dist/experiments.js +85 -0
  43. package/dist/files.d.ts +27 -0
  44. package/dist/files.js +129 -0
  45. package/dist/history.d.ts +43 -0
  46. package/dist/history.js +55 -0
  47. package/dist/index.d.ts +24 -0
  48. package/dist/index.js +24 -0
  49. package/dist/limits.d.ts +15 -0
  50. package/dist/limits.js +30 -0
  51. package/dist/lint.d.ts +16 -0
  52. package/dist/lint.js +159 -0
  53. package/dist/loops.d.ts +37 -0
  54. package/dist/loops.js +197 -0
  55. package/dist/measure.d.ts +40 -0
  56. package/dist/measure.js +159 -0
  57. package/dist/metrics.d.ts +290 -0
  58. package/dist/metrics.js +254 -0
  59. package/dist/output.d.ts +21 -0
  60. package/dist/output.js +79 -0
  61. package/dist/project.d.ts +19 -0
  62. package/dist/project.js +35 -0
  63. package/dist/report.d.ts +10 -0
  64. package/dist/report.js +113 -0
  65. package/dist/reviews.d.ts +130 -0
  66. package/dist/reviews.js +287 -0
  67. package/dist/schema.d.ts +612 -0
  68. package/dist/schema.js +253 -0
  69. package/dist/screenshots.d.ts +23 -0
  70. package/dist/screenshots.js +116 -0
  71. package/dist/search-ads.d.ts +137 -0
  72. package/dist/search-ads.js +354 -0
  73. package/dist/seo.d.ts +87 -0
  74. package/dist/seo.js +391 -0
  75. package/dist/setup.d.ts +45 -0
  76. package/dist/setup.js +259 -0
  77. package/dist/social.d.ts +129 -0
  78. package/dist/social.js +189 -0
  79. package/dist/storefronts.d.ts +15 -0
  80. package/dist/storefronts.js +89 -0
  81. package/dist/stores/app-store.d.ts +33 -0
  82. package/dist/stores/app-store.js +159 -0
  83. package/dist/stores/google-play.d.ts +13 -0
  84. package/dist/stores/google-play.js +61 -0
  85. package/dist/stores/types.d.ts +34 -0
  86. package/dist/stores/types.js +2 -0
  87. package/dist/suggest.d.ts +73 -0
  88. package/dist/suggest.js +135 -0
  89. package/dist/text.d.ts +18 -0
  90. package/dist/text.js +55 -0
  91. package/dist/transport.d.ts +99 -0
  92. package/dist/transport.js +170 -0
  93. package/dist/ugc-core.js +1 -0
  94. package/dist/ugc-local.d.ts +116 -0
  95. package/dist/ugc-local.js +174 -0
  96. package/dist/ugc.d.ts +149 -0
  97. package/dist/ugc.js +111 -0
  98. package/dist/verify.d.ts +70 -0
  99. package/dist/verify.js +134 -0
  100. package/dist/visibility-core.js +96 -0
  101. package/dist/visibility-local.d.ts +159 -0
  102. package/dist/visibility-local.js +246 -0
  103. package/dist/visibility.d.ts +152 -0
  104. package/dist/visibility.js +239 -0
  105. package/package.json +80 -4
  106. package/skills/appeeky-spec/SKILL.md +142 -0
  107. package/skills/appeeky-spec-experiments/SKILL.md +93 -0
  108. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  109. package/skills/appeeky-spec-review/SKILL.md +75 -0
  110. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  111. package/skills/appeeky-spec-visibility/SKILL.md +106 -0
package/dist/data.js ADDED
@@ -0,0 +1,298 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { SPEC_DIR } from "./files.js";
5
+ import { rankAppIds } from "./verify.js";
6
+ /**
7
+ * Your app's numbers as plain CSV files an agent can read: the same sources Appeeky
8
+ * Desktop keeps locally (App Store Connect and Google Play analytics, Apple Search Ads,
9
+ * RevenueCat, Meta and TikTok), pulled through the connections in Appeeky.
10
+ *
11
+ * They are private business data, so they go to .appeeky/data/ (gitignored) unless the
12
+ * spec says `data: { commit: true }`, which writes them to appeeky/data/ instead.
13
+ * Pulling again merges by date, so a scheduled pull keeps one growing file per source.
14
+ */
15
+ export function dataDir(project) {
16
+ return project.spec.data.commit ? join(project.root, SPEC_DIR, "data") : join(project.root, ".appeeky", "data");
17
+ }
18
+ function cell(v) {
19
+ if (v === null || v === undefined)
20
+ return "";
21
+ const s = typeof v === "number" ? String(Math.round(v * 10_000) / 10_000) : v;
22
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
23
+ }
24
+ function parseCsv(text) {
25
+ const [head, ...lines] = text.trim().split("\n");
26
+ if (!head)
27
+ return [];
28
+ const cols = head.split(",");
29
+ return lines.map((line) => {
30
+ const vals = [];
31
+ let cur = "";
32
+ let quoted = false;
33
+ for (let i = 0; i < line.length; i++) {
34
+ const ch = line[i];
35
+ if (quoted) {
36
+ if (ch === '"' && line[i + 1] === '"') {
37
+ cur += '"';
38
+ i++;
39
+ }
40
+ else if (ch === '"')
41
+ quoted = false;
42
+ else
43
+ cur += ch;
44
+ }
45
+ else if (ch === '"')
46
+ quoted = true;
47
+ else if (ch === ",") {
48
+ vals.push(cur);
49
+ cur = "";
50
+ }
51
+ else
52
+ cur += ch;
53
+ }
54
+ vals.push(cur);
55
+ return Object.fromEntries(cols.map((c, i) => [c, vals[i] ?? ""]));
56
+ });
57
+ }
58
+ /** Merge rows into a CSV file by key: new rows replace old ones with the same key. */
59
+ export async function mergeCsv(dir, file) {
60
+ const path = join(dir, `${file.name}.csv`);
61
+ const keyOf = (r) => file.key.map((k) => String(r[k] ?? "")).join("|");
62
+ const merged = new Map();
63
+ if (existsSync(path))
64
+ for (const r of parseCsv(await readFile(path, "utf8")))
65
+ merged.set(keyOf(r), r);
66
+ for (const r of file.rows)
67
+ merged.set(keyOf(r), r);
68
+ const rows = [...merged.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
69
+ await mkdir(dir, { recursive: true });
70
+ await writeFile(path, [file.columns.join(","), ...rows.map((r) => file.columns.map((c) => cell(r[c])).join(","))].join("\n") + "\n");
71
+ const dates = rows.map((r) => String(r.date ?? "")).filter(Boolean).sort();
72
+ return { path, rows: rows.length, from: dates[0], to: dates[dates.length - 1] };
73
+ }
74
+ const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
75
+ /** [from, to] inclusive, split into chunks the API accepts. */
76
+ export function dateChunks(from, to, maxDays = 180) {
77
+ const out = [];
78
+ let start = Date.parse(from);
79
+ const end = Date.parse(to);
80
+ while (start <= end) {
81
+ const stop = Math.min(end, start + (maxDays - 1) * 86_400_000);
82
+ out.push([iso(start), iso(stop)]);
83
+ start = stop + 86_400_000;
84
+ }
85
+ return out;
86
+ }
87
+ /** Every source this spec can read, over the `days` up to yesterday. */
88
+ export async function pullData(api, project, days, now = new Date(), attributionIn) {
89
+ const to = iso(now.getTime() - 86_400_000);
90
+ const from = iso(Date.parse(to) - (Math.max(1, days) - 1) * 86_400_000);
91
+ const ids = rankAppIds(project);
92
+ const files = [];
93
+ const errors = [];
94
+ const attempt = async (source, run) => {
95
+ try {
96
+ await run();
97
+ }
98
+ catch (e) {
99
+ errors.push({ source, message: e.message });
100
+ }
101
+ };
102
+ for (const store of (api ? Object.keys(ids) : [])) {
103
+ if (!api)
104
+ break;
105
+ const appId = ids[store];
106
+ const label = store === "app-store" ? "App Store Connect" : "Google Play";
107
+ await attempt(`${label} analytics`, async () => {
108
+ const storeRows = [];
109
+ const adRows = [];
110
+ const rcRows = [];
111
+ let sources = null;
112
+ let rcScope;
113
+ for (const [a, b] of dateChunks(from, to)) {
114
+ const m = await api.get(`/user/apps/${encodeURIComponent(appId)}/metrics`, { from: a, to: b });
115
+ for (const d of m?.store?.days ?? [])
116
+ storeRows.push({ ...d, date: String(d.date) });
117
+ for (const d of m?.ads?.days ?? [])
118
+ adRows.push({ ...d, currency: m?.ads?.currency });
119
+ for (const d of m?.revenuecat?.days ?? [])
120
+ rcRows.push({ ...d, date: String(d.date) });
121
+ if (m?.sources)
122
+ sources = m.sources;
123
+ rcScope = m?.revenuecat?.scope ?? rcScope;
124
+ }
125
+ if (storeRows.length)
126
+ files.push({
127
+ name: `${store}-analytics`,
128
+ about: `${label}: impressions, product page views, downloads, conversion, proceeds, trials and subscriptions per day`,
129
+ key: ["date"],
130
+ columns: ["date", "impressions", "pageViews", "downloads", "conversionRate", "proceeds", "trials", "subscriptions"],
131
+ rows: storeRows,
132
+ });
133
+ if (sources?.length)
134
+ files.push({
135
+ name: `${store}-traffic-sources`,
136
+ about: `${label}: where product page views came from, ${from} to ${to} (replaced on every pull)`,
137
+ key: ["source"],
138
+ columns: ["source", "impressions", "pageViews", "share"],
139
+ rows: sources,
140
+ });
141
+ if (adRows.length && store === "app-store")
142
+ files.push({
143
+ name: "apple-search-ads",
144
+ about: "Apple Search Ads: spend, impressions, taps and installs per day",
145
+ key: ["date"],
146
+ columns: ["date", "spend", "impressions", "taps", "installs", "currency"],
147
+ rows: adRows,
148
+ });
149
+ if (rcRows.length && !files.some((f) => f.name === "revenuecat"))
150
+ files.push({
151
+ name: "revenuecat",
152
+ about: `RevenueCat: revenue, MRR, trials, new customers, trial conversion, churn and actives per day${rcScope === "project" ? " (the RevenueCat project holds other apps too, so these include them)" : ""}`,
153
+ key: ["date"],
154
+ columns: ["date", "revenue", "mrr", "trials", "newCustomers", "trialConversion", "churn", "actives"],
155
+ rows: rcRows,
156
+ });
157
+ });
158
+ }
159
+ if (api && ids["app-store"]) {
160
+ await attempt("App Store Connect sales", async () => {
161
+ const m = await api.get(`/connect/metrics/apps/${encodeURIComponent(ids["app-store"])}`, { from, to });
162
+ if (m?.daily?.length)
163
+ files.push({
164
+ name: "app-store-sales",
165
+ about: "App Store Connect sales reports: units, revenue, subscriptions, trials and in-app purchases per day",
166
+ key: ["date"],
167
+ columns: ["date", "downloads", "revenue", "subscriptions", "trials", "iap_count"],
168
+ rows: m.daily.map((d) => ({ ...d, date: String(d.metric_date) })),
169
+ });
170
+ if (m?.countries?.length)
171
+ files.push({
172
+ name: "app-store-countries",
173
+ about: `App Store Connect: downloads and revenue by country, ${from} to ${to} (replaced on every pull)`,
174
+ key: ["country"],
175
+ columns: ["country", "downloads", "revenue"],
176
+ rows: m.countries,
177
+ });
178
+ });
179
+ }
180
+ // One set of files per Appeeky Analytics app; with an app per store, the store is in the name.
181
+ const attributionByStore = !attributionIn
182
+ ? []
183
+ : "get" in attributionIn
184
+ ? [["", attributionIn]]
185
+ : [...new Map(Object.entries(attributionIn).map(([store, client]) => [client, store])).entries()].map(([client, store]) => [store, client]);
186
+ for (const [store, attribution] of attributionByStore) {
187
+ const suffix = attributionByStore.length > 1 ? `-${store}` : "";
188
+ await attempt(attribution.label, async () => {
189
+ const summary = await attribution.get("summary", { days: String(Math.min(180, Math.max(1, days))) });
190
+ if (summary?.bySource?.length)
191
+ files.push({
192
+ name: `attribution-sources${suffix}`,
193
+ about: `Installs, trials, payers and revenue by source and campaign, for installs in the last ${summary.days} days (replaced on every pull)`,
194
+ key: ["source", "campaign"],
195
+ columns: ["source", "campaign", "installs", "trials", "payers", "revenueUsd"],
196
+ rows: summary.bySource.map((r) => ({ ...r })),
197
+ });
198
+ for (const dim of ["keyword", "campaign", "country"]) {
199
+ const rows = await attribution.get("breakdown", { dim, range: "30d" });
200
+ const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);
201
+ // Apple Search Ads reports keyword ids, not the words; the words are in your Search Ads account.
202
+ const column = dim === "keyword" ? "keywordId" : dim;
203
+ if (list.length)
204
+ files.push({
205
+ name: `attribution-${dim === "country" ? "countries" : `${dim}s`}${suffix}`,
206
+ about: `Installs, payers and revenue by ${dim === "keyword" ? "Apple Search Ads keyword id" : dim === "campaign" ? "campaign ((none) is organic and other installs without one)" : dim}, last 30 days (replaced on every pull)`,
207
+ key: [column],
208
+ columns: [column, "installs", "payers", "revenueUsd"],
209
+ rows: list.map((r) => ({ [column]: r.key ?? "(none)", installs: r.count, payers: r.payers, revenueUsd: r.revenueUsd })),
210
+ });
211
+ }
212
+ const links = await attribution.get("links", { range: "30d" });
213
+ if (links?.links?.length)
214
+ files.push({
215
+ name: `smart-links${suffix}`,
216
+ about: "Smart links: clicks, installs and revenue per link, last 30 days (replaced on every pull)",
217
+ key: ["slug"],
218
+ columns: ["slug", "name", "campaign", "channel", "clicks", "installs", "revenueUsd"],
219
+ rows: links.links.map((l) => ({ slug: l.link.slug, name: l.link.name, campaign: l.link.campaign, channel: l.link.channel, clicks: l.clicks.total, installs: l.installs, revenueUsd: l.revenueUsd })),
220
+ });
221
+ });
222
+ }
223
+ const meta = project.spec.ads.meta;
224
+ if (api && meta) {
225
+ await attempt("Meta Ads", async () => {
226
+ const rows = [];
227
+ for (const campaign of meta.campaigns) {
228
+ const data = await api.get("/connect/meta-ads/insights", {
229
+ targetType: "campaign",
230
+ targetId: campaign,
231
+ since: from,
232
+ until: to,
233
+ timeIncrement: "1",
234
+ fields: "spend,impressions,clicks,actions,date_start",
235
+ ...(meta.adAccount ? { adAccountId: meta.adAccount } : {}),
236
+ });
237
+ for (const r of data?.data ?? []) {
238
+ const installs = (r.actions ?? []).find((a) => ["mobile_app_install", "app_install", "omni_app_install"].includes(a.action_type));
239
+ rows.push({ date: r.date_start, campaign, spend: Number(r.spend ?? 0), impressions: Number(r.impressions ?? 0), clicks: Number(r.clicks ?? 0), installs: Number(installs?.value ?? 0) });
240
+ }
241
+ }
242
+ if (rows.length)
243
+ files.push({ name: "meta-ads", about: "Meta Ads: spend, impressions, clicks and installs per campaign per day", key: ["date", "campaign"], columns: ["date", "campaign", "spend", "impressions", "clicks", "installs"], rows });
244
+ });
245
+ }
246
+ const tiktok = project.spec.ads.tiktok;
247
+ if (api && tiktok) {
248
+ await attempt("TikTok Ads", async () => {
249
+ const data = await api.get("/connect/tiktok-ads/report", {
250
+ dataLevel: "AUCTION_CAMPAIGN",
251
+ dimensions: "campaign_id,stat_time_day",
252
+ metrics: "spend,impressions,clicks,conversion",
253
+ startDate: from,
254
+ endDate: to,
255
+ pageSize: "1000",
256
+ ...(tiktok.advertiser ? { advertiserId: tiktok.advertiser } : {}),
257
+ });
258
+ const rows = (data?.list ?? [])
259
+ .filter((r) => r.dimensions?.campaign_id && tiktok.campaigns.includes(String(r.dimensions.campaign_id)))
260
+ .map((r) => ({
261
+ date: String(r.dimensions?.stat_time_day ?? "").slice(0, 10),
262
+ campaign: String(r.dimensions?.campaign_id),
263
+ spend: Number(r.metrics?.spend ?? 0),
264
+ impressions: Number(r.metrics?.impressions ?? 0),
265
+ clicks: Number(r.metrics?.clicks ?? 0),
266
+ conversions: Number(r.metrics?.conversion ?? 0),
267
+ }));
268
+ if (rows.length)
269
+ files.push({ name: "tiktok-ads", about: "TikTok Ads: spend, impressions, clicks and conversions per campaign per day", key: ["date", "campaign"], columns: ["date", "campaign", "spend", "impressions", "clicks", "conversions"], rows });
270
+ });
271
+ }
272
+ const dir = dataDir(project);
273
+ const written = [];
274
+ for (const f of files) {
275
+ // Range snapshots are replaced, daily series are merged.
276
+ if (!f.key.includes("date") && existsSync(join(dir, `${f.name}.csv`)))
277
+ await writeFile(join(dir, `${f.name}.csv`), "");
278
+ written.push({ name: f.name, about: f.about, ...(await mergeCsv(dir, f)) });
279
+ }
280
+ if (written.length)
281
+ await writeIndex(dir, written, now);
282
+ return { files: written, errors };
283
+ }
284
+ /** README.md next to the files: what each one holds, so an agent knows where to look. */
285
+ async function writeIndex(dir, files, now) {
286
+ const lines = [
287
+ "# App data",
288
+ "",
289
+ `Pulled ${now.toISOString().slice(0, 10)} by \`appeeky-spec data pull\`. Daily files are merged by date on every pull. Do not edit by hand.`,
290
+ "",
291
+ "| file | rows | dates | what it holds |",
292
+ "| --- | --- | --- | --- |",
293
+ ...files.map((f) => `| ${f.name}.csv | ${f.rows} | ${f.from && f.to ? `${f.from} → ${f.to}` : "—"} | ${f.about} |`),
294
+ "",
295
+ ];
296
+ await writeFile(join(dir, "README.md"), lines.join("\n"));
297
+ }
298
+ //# sourceMappingURL=data.js.map
@@ -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 {};