@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
@@ -0,0 +1,62 @@
1
+ import type { PopularityLookup } from "./coverage.js";
2
+ import type { RankSource } from "./verify.js";
3
+ import { type MetricSource } from "./metrics.js";
4
+ import type { SpecFile, StoreId } from "./schema.js";
5
+ export declare const APPEEKY_API_URL = "https://api.appeeky.com/v1";
6
+ export interface KeywordMetric {
7
+ popularity: number;
8
+ difficulty: number;
9
+ fetchedAt: string;
10
+ }
11
+ type Fetcher = typeof fetch;
12
+ /**
13
+ * Small client for the Appeeky API (market data for ASO). Needs an API key
14
+ * from appeeky.com; every command that uses it also works without it.
15
+ */
16
+ export declare class AppeekyApi {
17
+ private readonly apiKey;
18
+ private readonly base;
19
+ constructor(apiKey: string, options?: {
20
+ baseUrl?: string;
21
+ fetcher?: Fetcher;
22
+ });
23
+ private readonly fetcher;
24
+ get<T>(path: string, query?: Record<string, string>): Promise<T>;
25
+ post<T>(path: string, body: unknown): Promise<T>;
26
+ /** Any other method, e.g. DELETE. */
27
+ request<T>(method: string, path: string, body?: unknown): Promise<T>;
28
+ /** Keyword opportunities for an app: popular searches it does not rank for yet. */
29
+ opportunities(appId: string, country: string, store: StoreId): Promise<Array<{
30
+ keyword: string;
31
+ popularity: number;
32
+ difficulty: number | null;
33
+ source?: string;
34
+ }>>;
35
+ /** Popularity (0–100) and difficulty for one keyword in several storefronts. */
36
+ keywordMetrics(keyword: string, countries: string[], store: StoreId): Promise<Record<string, KeywordMetric>>;
37
+ }
38
+ /**
39
+ * Fetch popularity for every target keyword, reusing a 7-day cache in
40
+ * `.appeeky/cache/metrics.json` so repeated runs stay inside the API's
41
+ * per-minute limit. Returns a lookup for the coverage report.
42
+ */
43
+ export declare function loadPopularity(root: string, api: AppeekyApi | undefined, targets: Record<string, string[]>, stores: StoreId[], onProgress?: (done: number, total: number) => void): Promise<{
44
+ lookup: PopularityLookup;
45
+ fetched: number;
46
+ failed: string[];
47
+ }>;
48
+ /** Live ranks from the Appeeky API (App Store and Google Play), 10 keywords per request. */
49
+ export declare function appeekyRankSource(api: AppeekyApi): RankSource;
50
+ /**
51
+ * Outcome metrics (store analytics, Search Ads, RevenueCat) for an app in the
52
+ * user's Appeeky list. Apps that are not in the list, or have nothing
53
+ * connected, give null.
54
+ */
55
+ export declare function appeekyMetricSource(api: AppeekyApi): MetricSource;
56
+ /** Spend and installs of the Meta campaigns listed under `ads.meta`, through the Meta connection in Appeeky. */
57
+ export declare function metaMetricSource(api: AppeekyApi, config: NonNullable<SpecFile["ads"]["meta"]>): MetricSource;
58
+ /** Spend and conversions of the TikTok campaigns listed under `ads.tiktok`, through the TikTok connection in Appeeky. */
59
+ export declare function tiktokMetricSource(api: AppeekyApi, config: NonNullable<SpecFile["ads"]["tiktok"]>): MetricSource;
60
+ /** Every outcome source a spec can use: the app's connected data, plus the Meta and TikTok campaigns it lists. */
61
+ export declare function metricSourceFor(api: AppeekyApi, spec: SpecFile): MetricSource;
62
+ export {};
@@ -0,0 +1,240 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { combineSources, summarizeMeta, summarizeMetrics, summarizeTikTok, } from "./metrics.js";
5
+ import { normalize } from "./text.js";
6
+ export const APPEEKY_API_URL = "https://api.appeeky.com/v1";
7
+ /**
8
+ * Small client for the Appeeky API (market data for ASO). Needs an API key
9
+ * from appeeky.com; every command that uses it also works without it.
10
+ */
11
+ export class AppeekyApi {
12
+ apiKey;
13
+ base;
14
+ constructor(apiKey, options = {}) {
15
+ this.apiKey = apiKey;
16
+ this.base = (options.baseUrl ?? APPEEKY_API_URL).replace(/\/$/, "");
17
+ this.fetcher = options.fetcher ?? fetch;
18
+ }
19
+ fetcher;
20
+ async get(path, query = {}) {
21
+ const url = new URL(this.base + path);
22
+ for (const [k, v] of Object.entries(query))
23
+ url.searchParams.set(k, v);
24
+ const res = await this.fetcher(url, { headers: { "X-API-Key": this.apiKey, "User-Agent": "appeeky-spec" } });
25
+ const body = (await res.json().catch(() => null));
26
+ if (!res.ok) {
27
+ const retry = res.headers.get("retry-after");
28
+ throw new Error(`Appeeky API ${path}: ${body?.error?.message ?? `HTTP ${res.status}`}${retry ? ` (retry after ${retry}s)` : ""}`);
29
+ }
30
+ return body?.data;
31
+ }
32
+ async post(path, body) {
33
+ const res = await this.fetcher(this.base + path, {
34
+ method: "POST",
35
+ headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json", "User-Agent": "appeeky-spec" },
36
+ body: JSON.stringify(body),
37
+ });
38
+ const json = (await res.json().catch(() => null));
39
+ if (!res.ok)
40
+ throw new Error(`Appeeky API ${path}: ${json?.error?.message ?? `HTTP ${res.status}`}`);
41
+ return json?.data;
42
+ }
43
+ /** Any other method, e.g. DELETE. */
44
+ async request(method, path, body) {
45
+ const res = await this.fetcher(this.base + path, {
46
+ method,
47
+ headers: { "X-API-Key": this.apiKey, "User-Agent": "appeeky-spec", ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
48
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
49
+ });
50
+ const json = (await res.json().catch(() => null));
51
+ if (!res.ok)
52
+ throw new Error(`Appeeky API ${path}: ${json?.error?.message ?? `HTTP ${res.status}`}`);
53
+ return json?.data;
54
+ }
55
+ /** Keyword opportunities for an app: popular searches it does not rank for yet. */
56
+ async opportunities(appId, country, store) {
57
+ const data = await this.get(`/aso/opportunities/${encodeURIComponent(appId)}`, { country, platform: store === "app-store" ? "apple" : "google" });
58
+ return (data?.opportunities ?? [])
59
+ .filter((o) => !o.isBrandTerm)
60
+ .map((o) => ({ keyword: o.keyword, popularity: o.volumeScore, difficulty: o.difficulty, source: o.source }));
61
+ }
62
+ /** Popularity (0–100) and difficulty for one keyword in several storefronts. */
63
+ async keywordMetrics(keyword, countries, store) {
64
+ const data = await this.get("/keywords/metrics", {
65
+ keyword,
66
+ countries: countries.join(","),
67
+ platform: store === "app-store" ? "apple" : "google",
68
+ });
69
+ const now = new Date().toISOString();
70
+ const out = {};
71
+ for (const [cc, m] of Object.entries(data?.results ?? {})) {
72
+ out[cc] = { popularity: m.volumeScore ?? 0, difficulty: m.difficulty ?? 0, fetchedAt: now };
73
+ }
74
+ return out;
75
+ }
76
+ }
77
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
78
+ function cacheKey(store, country, keyword) {
79
+ return `${store}:${country}:${normalize(keyword).trim()}`;
80
+ }
81
+ /**
82
+ * Fetch popularity for every target keyword, reusing a 7-day cache in
83
+ * `.appeeky/cache/metrics.json` so repeated runs stay inside the API's
84
+ * per-minute limit. Returns a lookup for the coverage report.
85
+ */
86
+ export async function loadPopularity(root, api, targets, stores, onProgress) {
87
+ const file = join(root, ".appeeky", "cache", "metrics.json");
88
+ let cache = {};
89
+ if (existsSync(file)) {
90
+ try {
91
+ cache = JSON.parse(await readFile(file, "utf8"));
92
+ }
93
+ catch {
94
+ cache = {};
95
+ }
96
+ }
97
+ const fresh = (m) => m && Date.now() - Date.parse(m.fetchedAt) < CACHE_TTL_MS;
98
+ // One request per keyword covers every storefront that targets it.
99
+ const byKeyword = new Map();
100
+ for (const [country, keywords] of Object.entries(targets)) {
101
+ for (const k of keywords) {
102
+ const key = normalize(k).trim();
103
+ if (!byKeyword.has(key))
104
+ byKeyword.set(key, new Set());
105
+ byKeyword.get(key).add(country);
106
+ }
107
+ }
108
+ let fetched = 0;
109
+ const failed = [];
110
+ if (api) {
111
+ const jobs = [];
112
+ for (const store of stores) {
113
+ for (const [keyword, countries] of byKeyword) {
114
+ const stale = [...countries].filter((cc) => !fresh(cache[cacheKey(store, cc, keyword)]));
115
+ if (stale.length > 0)
116
+ jobs.push({ store, keyword, countries: stale });
117
+ }
118
+ }
119
+ for (const [i, job] of jobs.entries()) {
120
+ // The API allows 10 metrics requests a minute per key; pace after the first burst.
121
+ if (i >= 10)
122
+ await new Promise((r) => setTimeout(r, 6_100));
123
+ try {
124
+ const metrics = await api.keywordMetrics(job.keyword, job.countries, job.store);
125
+ for (const [cc, m] of Object.entries(metrics))
126
+ cache[cacheKey(job.store, cc, job.keyword)] = m;
127
+ fetched++;
128
+ }
129
+ catch (err) {
130
+ failed.push(`${job.keyword}: ${err.message}`);
131
+ if (/retry after|burst|429/i.test(err.message))
132
+ break;
133
+ }
134
+ onProgress?.(i + 1, jobs.length);
135
+ }
136
+ await mkdir(dirname(file), { recursive: true });
137
+ await writeFile(file, JSON.stringify(cache, null, 2));
138
+ }
139
+ const lookup = (keyword, country, store) => {
140
+ const m = cache[cacheKey(store, country, keyword)];
141
+ return m ? { popularity: m.popularity, difficulty: m.difficulty } : undefined;
142
+ };
143
+ return { lookup, fetched, failed };
144
+ }
145
+ /** Live ranks from the Appeeky API (App Store and Google Play), 10 keywords per request. */
146
+ export function appeekyRankSource(api) {
147
+ return {
148
+ label: "Appeeky API",
149
+ async ranks(store, appId, country, keywords) {
150
+ const out = {};
151
+ for (let i = 0; i < keywords.length; i += 10) {
152
+ const batch = keywords.slice(i, i + 10);
153
+ const data = await api.post(`/apps/${encodeURIComponent(appId)}/keywords/verify`, { keywords: batch, country, platform: store === "app-store" ? "apple" : "google" });
154
+ for (const r of data?.results ?? [])
155
+ out[normalize(r.keyword).trim()] = r.rank ?? null;
156
+ }
157
+ return out;
158
+ },
159
+ };
160
+ }
161
+ /**
162
+ * Outcome metrics (store analytics, Search Ads, RevenueCat) for an app in the
163
+ * user's Appeeky list. Apps that are not in the list, or have nothing
164
+ * connected, give null.
165
+ */
166
+ export function appeekyMetricSource(api) {
167
+ return {
168
+ label: "Appeeky (store analytics, Search Ads, RevenueCat)",
169
+ async window(_store, appId, from, to) {
170
+ // The API takes an inclusive last day; windows here end exclusively.
171
+ const last = new Date(Date.parse(to) - 86_400_000).toISOString().slice(0, 10);
172
+ try {
173
+ const data = await api.get(`/user/apps/${encodeURIComponent(appId)}/metrics`, { from, to: last });
174
+ if (!data)
175
+ return null;
176
+ const window = summarizeMetrics(data, from, to);
177
+ return Object.keys(window.values).length > 0 ? window : null;
178
+ }
179
+ catch (e) {
180
+ if (/not found/i.test(e.message))
181
+ return null;
182
+ throw e;
183
+ }
184
+ },
185
+ };
186
+ }
187
+ /** The window's inclusive last day; windows here end exclusively. */
188
+ const lastDay = (to) => new Date(Date.parse(to) - 86_400_000).toISOString().slice(0, 10);
189
+ const daysIn = (from, to) => Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
190
+ /** Spend and installs of the Meta campaigns listed under `ads.meta`, through the Meta connection in Appeeky. */
191
+ export function metaMetricSource(api, config) {
192
+ return {
193
+ label: "Meta Ads",
194
+ async window(_store, _appId, from, to) {
195
+ const rows = [];
196
+ for (const campaign of config.campaigns) {
197
+ const data = await api.get("/connect/meta-ads/insights", {
198
+ targetType: "campaign",
199
+ targetId: campaign,
200
+ since: from,
201
+ until: lastDay(to),
202
+ fields: "spend,actions",
203
+ ...(config.adAccount ? { adAccountId: config.adAccount } : {}),
204
+ });
205
+ rows.push(...(data?.data ?? []));
206
+ }
207
+ const values = summarizeMeta(rows, daysIn(from, to));
208
+ return Object.keys(values).length > 0 ? { from, to, days: daysIn(from, to), values } : null;
209
+ },
210
+ };
211
+ }
212
+ /** Spend and conversions of the TikTok campaigns listed under `ads.tiktok`, through the TikTok connection in Appeeky. */
213
+ export function tiktokMetricSource(api, config) {
214
+ return {
215
+ label: "TikTok Ads",
216
+ async window(_store, _appId, from, to) {
217
+ const data = await api.get("/connect/tiktok-ads/report", {
218
+ dataLevel: "AUCTION_CAMPAIGN",
219
+ dimensions: "campaign_id",
220
+ metrics: "spend,conversion",
221
+ startDate: from,
222
+ endDate: lastDay(to),
223
+ pageSize: "1000",
224
+ ...(config.advertiser ? { advertiserId: config.advertiser } : {}),
225
+ });
226
+ const values = summarizeTikTok(data?.list ?? [], config.campaigns, daysIn(from, to));
227
+ return Object.keys(values).length > 0 ? { from, to, days: daysIn(from, to), values } : null;
228
+ },
229
+ };
230
+ }
231
+ /** Every outcome source a spec can use: the app's connected data, plus the Meta and TikTok campaigns it lists. */
232
+ export function metricSourceFor(api, spec) {
233
+ const sources = [appeekyMetricSource(api)];
234
+ if (spec.ads.meta)
235
+ sources.push(metaMetricSource(api, spec.ads.meta));
236
+ if (spec.ads.tiktok)
237
+ sources.push(tiktokMetricSource(api, spec.ads.tiktok));
238
+ return sources.length === 1 ? sources[0] : combineSources(sources);
239
+ }
240
+ //# sourceMappingURL=appeeky-api.js.map
@@ -0,0 +1,28 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { SpecFile, StoreId } from "./schema.js";
3
+ /**
4
+ * `attribution init`: create (or find) the app in Appeeky Analytics for each store in the
5
+ * spec, and say what to put in the app and in RevenueCat. Self-hosted, it talks to your
6
+ * server with its admin token; hosted, it turns attribution on for the app in Appeeky.
7
+ */
8
+ export interface AttributionApp {
9
+ store: StoreId;
10
+ /** The id the spec reads: the server's app id, or the store id when hosted. */
11
+ id: string;
12
+ publishableKey: string;
13
+ /** The SDK's ingest URL. */
14
+ endpoint: string;
15
+ created: boolean;
16
+ }
17
+ export declare function specStores(spec: SpecFile): Array<{
18
+ store: StoreId;
19
+ storeId: string;
20
+ }>;
21
+ /** Self-hosted: find each store's app on the server by its store id, or create it. */
22
+ export declare function initSelfHosted(spec: SpecFile, url: string, token: string, fetcher?: typeof fetch): Promise<AttributionApp[]>;
23
+ /** Hosted: turn attribution on for each store's app in Appeeky (already on is fine). */
24
+ export declare function initHosted(spec: SpecFile, api: AppeekyApi): Promise<AttributionApp[]>;
25
+ /** The `attribution:` block for these apps: one id, or one per store when they differ. */
26
+ export declare function attributionBlock(via: "self" | "hosted", apps: AttributionApp[], url?: string): NonNullable<SpecFile["attribution"]>;
27
+ /** What to do next, in the app and in RevenueCat. */
28
+ export declare function nextSteps(via: "self" | "hosted", apps: AttributionApp[], url?: string): string[];
@@ -0,0 +1,65 @@
1
+ const PLATFORM = { "app-store": "apple", "google-play": "google" };
2
+ export function specStores(spec) {
3
+ return [
4
+ ...(spec.stores.appStore?.appId ? [{ store: "app-store", storeId: spec.stores.appStore.appId }] : []),
5
+ ...(spec.stores.googlePlay?.packageName ? [{ store: "google-play", storeId: spec.stores.googlePlay.packageName }] : []),
6
+ ];
7
+ }
8
+ /** Self-hosted: find each store's app on the server by its store id, or create it. */
9
+ export async function initSelfHosted(spec, url, token, fetcher = fetch) {
10
+ const base = url.replace(/\/$/, "");
11
+ const call = async (path, init = {}) => {
12
+ const res = await fetcher(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
13
+ const body = (await res.json().catch(() => null));
14
+ if (!res.ok)
15
+ throw new Error(`${path}: ${body?.error?.message ?? `HTTP ${res.status}`}`);
16
+ return body;
17
+ };
18
+ const { apps } = await call("/v1/apps");
19
+ const out = [];
20
+ for (const { store, storeId } of specStores(spec)) {
21
+ const found = apps.find((a) => a.platform === PLATFORM[store] && a.storeAppId === storeId);
22
+ const app = found ??
23
+ (await call("/v1/apps", {
24
+ method: "POST",
25
+ body: JSON.stringify({ name: spec.app.name, platform: PLATFORM[store], storeAppId: storeId }),
26
+ }));
27
+ out.push({ store, id: app.id, publishableKey: app.publishableKey, endpoint: `${base}/v1/ingest`, created: !found });
28
+ }
29
+ return out;
30
+ }
31
+ /** Hosted: turn attribution on for each store's app in Appeeky (already on is fine). */
32
+ export async function initHosted(spec, api) {
33
+ const out = [];
34
+ for (const { store, storeId } of specStores(spec)) {
35
+ const res = await api.post(`/analytics/apps/${encodeURIComponent(storeId)}/enable`, {});
36
+ const body = res && "data" in res ? res.data : res;
37
+ out.push({ store, id: storeId, publishableKey: body.publishableKey, endpoint: body.endpoint, created: true });
38
+ }
39
+ return out;
40
+ }
41
+ /** The `attribution:` block for these apps: one id, or one per store when they differ. */
42
+ export function attributionBlock(via, apps, url) {
43
+ const ids = [...new Set(apps.map((a) => a.id))];
44
+ const app = ids.length === 1 ? ids[0] : Object.fromEntries(apps.map((a) => [a.store, a.id]));
45
+ return { via, ...(via === "self" && url ? { url: url.replace(/\/$/, "") } : {}), app };
46
+ }
47
+ /** What to do next, in the app and in RevenueCat. */
48
+ export function nextSteps(via, apps, url) {
49
+ const lines = ["In the app (React Native and Expo; Swift, Kotlin and Flutter work the same way):", ""];
50
+ const endpoint = via === "self" && apps[0] ? `, endpoint: "${apps[0].endpoint}"` : "";
51
+ const ios = apps.find((a) => a.store === "app-store");
52
+ const android = apps.find((a) => a.store === "google-play");
53
+ // One codebase, a key per platform.
54
+ const key = ios && android ? `Platform.OS === "ios" ? "${ios.publishableKey}" : "${android.publishableKey}"` : `"${(ios ?? android).publishableKey}"`;
55
+ lines.push(` Appeeky.init({ key: ${key}${endpoint} });`);
56
+ lines.push(" Appeeky.identify(await Purchases.getAppUserID());", "");
57
+ if (via === "self") {
58
+ lines.push("In RevenueCat, Integrations → Webhooks:", ` URL: ${url.replace(/\/$/, "")}/v1/webhooks/revenuecat`, " Authorization header: your REVENUECAT_WEBHOOK_SECRET", " Environment: production");
59
+ }
60
+ else {
61
+ lines.push("Connect RevenueCat in Appeeky if it is not connected: purchases reach attribution from there.");
62
+ }
63
+ return lines;
64
+ }
65
+ //# sourceMappingURL=attribution-setup.js.map
@@ -0,0 +1,121 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { MetricSource } from "./metrics.js";
3
+ import type { SpecFile, StoreId } from "./schema.js";
4
+ /**
5
+ * Install attribution from Appeeky Analytics: which source each install came from
6
+ * (organic store search, Apple Search Ads down to the keyword, a smart link, the
7
+ * Google Play referrer) and the trials and revenue it led to.
8
+ *
9
+ * Two ways to reach it, like the stores:
10
+ * via: self your own server on Cloudflare (apps/worker), with ATTRIBUTION_TOKEN (its ADMIN_TOKEN)
11
+ * via: hosted Appeeky's hosted service, through the Appeeky API with APPEEKY_API_KEY
12
+ */
13
+ export type AttributionConfig = NonNullable<SpecFile["attribution"]>;
14
+ type Fetcher = typeof fetch;
15
+ export interface AttributionClient {
16
+ readonly label: string;
17
+ get<T>(view: string, query?: Record<string, string>): Promise<T>;
18
+ post<T>(view: string, body: unknown): Promise<T>;
19
+ }
20
+ export interface SourceRow {
21
+ source: string;
22
+ campaign: string | null;
23
+ installs: number;
24
+ trials: number;
25
+ payers: number;
26
+ revenueUsd: number;
27
+ }
28
+ export interface Summary {
29
+ days: number;
30
+ since: string;
31
+ installs: number;
32
+ revenueUsd: number;
33
+ bySource: SourceRow[];
34
+ unattributedRevenueUsd: number;
35
+ }
36
+ export interface BreakdownRow {
37
+ key: string | null;
38
+ count: number;
39
+ revenueUsd: number | null;
40
+ payers: number;
41
+ campaign?: string | null;
42
+ country?: string | null;
43
+ }
44
+ export interface LinkStats {
45
+ link: {
46
+ id: string;
47
+ slug: string;
48
+ name: string;
49
+ campaign: string;
50
+ channel: string | null;
51
+ };
52
+ clicks: {
53
+ total: number;
54
+ ios: number;
55
+ android: number;
56
+ other: number;
57
+ };
58
+ installs: number;
59
+ revenueUsd: number;
60
+ }
61
+ export declare function selfHostedClient(config: AttributionConfig, token: string, fetcher?: Fetcher, app?: string): AttributionClient;
62
+ export declare function hostedClient(config: AttributionConfig, api: AppeekyApi, app?: string): AttributionClient;
63
+ /** The Appeeky Analytics app for each store: one id for every store in the spec, or one per store. */
64
+ export declare function attributionApps(spec: SpecFile): Partial<Record<StoreId, string>>;
65
+ export type AttributionClients = Partial<Record<StoreId, AttributionClient>>;
66
+ /** A client per store the spec's `attribution:` covers, or why there are none. */
67
+ export declare function attributionClients(spec: SpecFile, api: AppeekyApi | undefined, env?: NodeJS.ProcessEnv): AttributionClients | {
68
+ missing: string;
69
+ };
70
+ /**
71
+ * The client for one store, or the first one. Without a store, Google Play comes first:
72
+ * its install referrer is what lets a smart link count installs.
73
+ */
74
+ export declare function attributionClient(spec: SpecFile, api: AppeekyApi | undefined, env?: NodeJS.ProcessEnv, store?: StoreId): AttributionClient | {
75
+ missing: string;
76
+ };
77
+ /** Which store the client `attributionClient` picks without one belongs to. */
78
+ export declare function linkStore(spec: SpecFile): StoreId | undefined;
79
+ export declare const isClient: (c: AttributionClient | {
80
+ missing: string;
81
+ }) => c is AttributionClient;
82
+ export declare const hasClients: (c: AttributionClients | {
83
+ missing: string;
84
+ }) => c is AttributionClients;
85
+ /** Installs from organic store search (App Store or Google Play), the ones a listing change moves. */
86
+ export declare function organicRow(summary: Summary): {
87
+ installs: number;
88
+ payers: number;
89
+ revenueUsd: number;
90
+ };
91
+ /**
92
+ * Organic installs and what they paid, around a push. The server answers for the
93
+ * installs first seen in the last N days, so a window that ends before today is the
94
+ * difference of two answers. Revenue counts what those installs paid up to now, so
95
+ * an older window has had longer to pay: a rise in organic revenue after a push is
96
+ * a conservative reading.
97
+ */
98
+ export declare function attributionMetricSource(clients: AttributionClient | AttributionClients, now?: Date): MetricSource;
99
+ /** A smart link for an experiment: one URL that sends each visitor to the right store, counted to the install. */
100
+ export declare function createExperimentLink(client: AttributionClient, experimentId: string, stores: {
101
+ appStoreId?: string;
102
+ packageName?: string;
103
+ }, channel?: string): Promise<{
104
+ slug: string;
105
+ id: string;
106
+ }>;
107
+ /** Clicks, installs and revenue of one smart link over the last 30 days. */
108
+ export declare function linkResult(client: AttributionClient, slug: string): Promise<LinkStats | null>;
109
+ /**
110
+ * On iOS the App Store passes nothing from a link click to the installed app, and the
111
+ * service does not fingerprint devices, so a new iOS install is never tied to a click.
112
+ * The SDK reads the link only on the app's first launch, so on iOS a link install is
113
+ * counted only when that first launch comes through the link (a deep link opening an
114
+ * app that was installed but never opened). Link installs are, in practice, Google
115
+ * Play installs (the install referrer). For an App Store only app, measure clicks, or
116
+ * organic installs around the post.
117
+ */
118
+ export declare const IOS_LINK_NOTE: string;
119
+ /** An installs or revenue metric on a link that can only send people to the App Store. */
120
+ export declare function iosOnlyLink(spec: SpecFile, metric: string): boolean;
121
+ export {};