@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/schema.js ADDED
@@ -0,0 +1,253 @@
1
+ import { z } from "zod";
2
+ /** Stores a spec can describe. The ids double as folder names under `appeeky/store/`. */
3
+ export const STORES = ["app-store", "google-play"];
4
+ /** How a store is reached: the local store CLI with your own key, or Appeeky's hosted gateway. */
5
+ export const transportMode = z.enum(["auto", "local", "hosted"]);
6
+ const countryCode = z
7
+ .string()
8
+ .regex(/^[a-z]{2}$/i, "Use a two-letter storefront code such as us, gb or tr")
9
+ .transform((s) => s.toLowerCase());
10
+ const appStoreConfig = z
11
+ .object({
12
+ /** Numeric App Store id (or a bundle id; the local CLI resolves it). */
13
+ appId: z.string().min(1),
14
+ platform: z.enum(["IOS", "MAC_OS", "TV_OS", "VISION_OS"]).default("IOS"),
15
+ via: transportMode.default("auto"),
16
+ /** Named account of the local `appstore` CLI. */
17
+ account: z.string().optional(),
18
+ /**
19
+ * Apple Search Ads on your own API key (ASA_CLIENT_ID, ASA_TEAM_ID, ASA_KEY_ID, ASA_PRIVATE_KEY):
20
+ * `search-ads pull` reads this app's campaigns. Without it, Search Ads comes from Appeeky.
21
+ */
22
+ searchAds: z
23
+ .object({
24
+ /** The organization, when the key reaches more than one. */
25
+ orgId: z.string().regex(/^\d+$/, "Use the numeric org id from Apple Search Ads → Account Settings").optional(),
26
+ /** Only these campaign ids; default: every campaign for this app. */
27
+ campaigns: z.array(z.string().regex(/^\d+$/)).optional(),
28
+ })
29
+ .strict()
30
+ .optional(),
31
+ })
32
+ .strict();
33
+ const googlePlayConfig = z
34
+ .object({
35
+ packageName: z.string().regex(/^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$/, "Use the package name, e.g. com.example.app"),
36
+ via: transportMode.default("auto"),
37
+ /** Named account of the local `gps` CLI (`gps accounts add`). */
38
+ account: z.string().optional(),
39
+ /** Old name for `account`. */
40
+ profile: z.string().optional(),
41
+ })
42
+ .strict();
43
+ /** Meta ad campaigns that promote this app. Insights come through the Meta connection in Appeeky. */
44
+ const metaAdsConfig = z
45
+ .object({
46
+ /** Ad account (act_…); defaults to the one picked in Appeeky. */
47
+ adAccount: z.string().optional(),
48
+ campaigns: z.array(z.string().min(1)).min(1),
49
+ })
50
+ .strict();
51
+ /** TikTok ad campaigns that promote this app. Reports come through the TikTok connection in Appeeky. */
52
+ const tiktokAdsConfig = z
53
+ .object({
54
+ /** Advertiser id; defaults to the one picked in Appeeky. */
55
+ advertiser: z.string().optional(),
56
+ campaigns: z.array(z.string().min(1)).min(1),
57
+ })
58
+ .strict();
59
+ /** One thing people ask AI assistants for, and the prompts that ask it. */
60
+ const aiIntent = z
61
+ .object({
62
+ label: z.string().min(8, "An intent label needs at least 8 characters"),
63
+ description: z.string().optional(),
64
+ prompts: z.array(z.string().min(15, "A prompt needs at least 15 characters")).default([]),
65
+ })
66
+ .strict();
67
+ /** Store charts a category rank is read from. */
68
+ export const CHARTS = ["top-free", "top-paid", "top-grossing"];
69
+ /** `appeeky.spec.yaml`: who the app is, where it ships, and what it should be found for. */
70
+ export const specFileSchema = z
71
+ .object({
72
+ version: z.literal(1),
73
+ app: z
74
+ .object({
75
+ name: z.string().min(1),
76
+ /** Locale the store falls back to when a storefront has no localization of its own. */
77
+ primaryLocale: z.string().default("en-US"),
78
+ })
79
+ .strict(),
80
+ stores: z
81
+ .object({
82
+ appStore: appStoreConfig.optional(),
83
+ googlePlay: googlePlayConfig.optional(),
84
+ })
85
+ .strict()
86
+ .default({}),
87
+ /** Target keywords per storefront: the searches this app should be found for. */
88
+ targets: z.record(countryCode, z.array(z.string().min(1)).default([])).default({}),
89
+ /** Override which listing locales a storefront indexes (App Store). */
90
+ storefronts: z.record(countryCode, z.array(z.string().min(2)).min(1)).default({}),
91
+ /**
92
+ * Paid channels outside the store. Apple Search Ads needs nothing here: it is
93
+ * matched to the app in Appeeky. Meta and TikTok campaigns are listed by id.
94
+ */
95
+ ads: z
96
+ .object({
97
+ meta: metaAdsConfig.optional(),
98
+ tiktok: tiktokAdsConfig.optional(),
99
+ })
100
+ .strict()
101
+ .default({}),
102
+ /** Apps you compete with. `competitors pull` writes each one to appeeky/competitors/. */
103
+ competitors: z
104
+ .array(z
105
+ .object({
106
+ name: z.string().optional(),
107
+ appStore: z.string().regex(/^\d+$/, "Use the numeric App Store id").optional(),
108
+ googlePlay: z.string().optional(),
109
+ })
110
+ .strict()
111
+ .refine((c) => c.appStore || c.googlePlay, "Give an appStore id or a googlePlay package name"))
112
+ .default([]),
113
+ /**
114
+ * Install attribution from Appeeky Analytics: organic search, Search Ads keywords,
115
+ * smart links, with the revenue each led to. `via: self` reads your own server on
116
+ * Cloudflare (`url`, ATTRIBUTION_TOKEN); `via: hosted` reads Appeeky's (APPEEKY_API_KEY).
117
+ */
118
+ attribution: z
119
+ .object({
120
+ via: z.enum(["self", "hosted"]).default("hosted"),
121
+ url: z.string().url().optional(),
122
+ /**
123
+ * The app's id in Appeeky Analytics. An app there is one platform, so an app on both
124
+ * stores gives one per store: `{ app-store: …, google-play: … }`.
125
+ */
126
+ app: z.union([
127
+ z.string().min(1),
128
+ z
129
+ .object({ "app-store": z.string().min(1).optional(), "google-play": z.string().min(1).optional() })
130
+ .strict()
131
+ .refine((m) => m["app-store"] || m["google-play"], "give the app id for app-store, google-play or both"),
132
+ ]),
133
+ })
134
+ .strict()
135
+ .refine((a) => a.via !== "self" || a.url, "via: self needs the url of your Appeeky Analytics server")
136
+ .optional(),
137
+ /**
138
+ * Where `data pull` writes store analytics, ads and revenue. They are your private
139
+ * business numbers, so by default they stay in .appeeky/data/ (gitignored).
140
+ * `commit: true` writes them to appeeky/data/ so they are versioned with the spec.
141
+ */
142
+ data: z.object({ commit: z.boolean().default(false) }).strict().default({}),
143
+ /**
144
+ * Whether ChatGPT, Claude, Gemini and Perplexity name the app when people ask
145
+ * for one like it. Intents are what people want ("fall asleep to rain sounds"),
146
+ * prompts are how they ask; both are tracked per storefront in Appeeky AI Visibility.
147
+ */
148
+ aiVisibility: z
149
+ .object({
150
+ /** Which app to watch when it is on both stores; defaults to the App Store app. */
151
+ store: z.enum(STORES).optional(),
152
+ /** Storefronts to watch; defaults to the first storefront under targets. */
153
+ countries: z.array(countryCode).min(1).optional(),
154
+ /** What people ask AI assistants, per storefront. `visibility sync` sends them to Appeeky. */
155
+ intents: z.record(countryCode, z.array(aiIntent)).default({}),
156
+ /** Assistants `visibility scan --local` asks, among those with a key set; defaults to all of them. */
157
+ models: z.array(z.enum(["chatgpt", "claude", "gemini", "perplexity"])).min(1).optional(),
158
+ /** Other names the assistants may use for the app, e.g. the brand without the subtitle. */
159
+ aliases: z.array(z.string().min(2)).optional(),
160
+ })
161
+ .strict()
162
+ .optional(),
163
+ /**
164
+ * The app's website. `seo check` reads it for search and AI readiness; `seo pull` records
165
+ * its Google Search Console clicks with your own service account.
166
+ */
167
+ web: z
168
+ .object({
169
+ url: z.string().url(),
170
+ /** Search Console property: "sc-domain:example.com" or "https://example.com/"; defaults to the url's domain. */
171
+ searchConsole: z.string().min(3).optional(),
172
+ /** Google searches to watch with `seo ranks` and `seo keywords`, per storefront; default: the store targets. */
173
+ keywords: z.record(countryCode, z.array(z.string().min(1))).optional(),
174
+ /** Storefronts to watch; default: the first one under keywords or targets. */
175
+ countries: z.array(countryCode).min(1).optional(),
176
+ /** The results page to read; default mobile, where people look for apps. */
177
+ device: z.enum(["mobile", "desktop"]).optional(),
178
+ })
179
+ .strict()
180
+ .optional(),
181
+ /** Rank in the app's category chart (top 100), recorded daily by `category pull`. */
182
+ category: z
183
+ .object({
184
+ /** App Store genre id, e.g. "6013" for Health & Fitness; read from the app when left out. */
185
+ appStore: z.string().regex(/^\d+$/, "Use the numeric App Store genre id, e.g. 6013").optional(),
186
+ /** Google Play category, e.g. HEALTH_AND_FITNESS. */
187
+ googlePlay: z.string().regex(/^[A-Z_]+$/, "Use the Google Play category id, e.g. HEALTH_AND_FITNESS").optional(),
188
+ /** Defaults to top-paid for a paid App Store app, top-free otherwise. */
189
+ chart: z.enum(CHARTS).optional(),
190
+ /** Storefronts to record; defaults to the storefronts under targets. */
191
+ countries: z.array(countryCode).min(1).optional(),
192
+ })
193
+ .strict()
194
+ .optional(),
195
+ /** Who the app is for. Experiments, screenshots and UGC refer to these by id. */
196
+ personas: z
197
+ .array(z
198
+ .object({
199
+ id: z.string().regex(/^[a-z0-9-]+$/),
200
+ name: z.string().min(1),
201
+ description: z.string().optional(),
202
+ /** How this persona searches: seeds for targets and keyword ideas. */
203
+ searches: z.array(z.string()).default([]),
204
+ })
205
+ .strict())
206
+ .default([]),
207
+ })
208
+ .strict();
209
+ /**
210
+ * One App Store locale: `appeeky/store/app-store/<locale>.yaml`.
211
+ * A field left out is not managed by the spec: it is never diffed or pushed.
212
+ */
213
+ export const appStoreListingSchema = z
214
+ .object({
215
+ name: z.string().optional(),
216
+ subtitle: z.string().optional(),
217
+ keywords: z.string().optional(),
218
+ promotionalText: z.string().optional(),
219
+ description: z.string().optional(),
220
+ whatsNew: z.string().optional(),
221
+ marketingUrl: z.string().url().optional(),
222
+ supportUrl: z.string().url().optional(),
223
+ })
224
+ .strict();
225
+ /** One Google Play language: `appeeky/store/google-play/<language>.yaml`. */
226
+ export const googlePlayListingSchema = z
227
+ .object({
228
+ title: z.string().optional(),
229
+ shortDescription: z.string().optional(),
230
+ fullDescription: z.string().optional(),
231
+ video: z.string().url().optional(),
232
+ })
233
+ .strict();
234
+ export const APP_STORE_FIELDS = [
235
+ "name",
236
+ "subtitle",
237
+ "keywords",
238
+ "promotionalText",
239
+ "description",
240
+ "whatsNew",
241
+ "marketingUrl",
242
+ "supportUrl",
243
+ ];
244
+ export const GOOGLE_PLAY_FIELDS = [
245
+ "title",
246
+ "shortDescription",
247
+ "fullDescription",
248
+ "video",
249
+ ];
250
+ export function fieldsFor(store) {
251
+ return store === "app-store" ? APP_STORE_FIELDS : GOOGLE_PLAY_FIELDS;
252
+ }
253
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,23 @@
1
+ import type { Issue } from "./lint.js";
2
+ import type { RemoteState } from "./stores/types.js";
3
+ import type { OpsTransport } from "./transport.js";
4
+ /**
5
+ * Screenshots live next to the listings:
6
+ * `appeeky/screenshots/app-store/<locale>/<DISPLAY_TYPE>/01.png`.
7
+ * `screenshots pull` downloads the live set; `check` validates sizes and counts.
8
+ */
9
+ export declare function screenshotsDir(root: string): string;
10
+ /** Portrait sizes App Store Connect accepts per display type (landscape is the same, swapped). */
11
+ export declare const SCREENSHOT_SIZES: Record<string, Array<[number, number]>>;
12
+ /** Width and height from a PNG or JPEG header, without decoding the image. */
13
+ export declare function imageSize(file: string): Promise<{
14
+ width: number;
15
+ height: number;
16
+ } | null>;
17
+ /** Sizes and counts of every screenshot set in the spec. */
18
+ export declare function checkScreenshots(root: string): Promise<Issue[]>;
19
+ /** Download the live App Store screenshots of every locale into the spec. */
20
+ export declare function pullScreenshots(root: string, transport: OpsTransport, state: RemoteState, options?: {
21
+ locales?: string[];
22
+ fetcher?: typeof fetch;
23
+ }): Promise<number>;
@@ -0,0 +1,116 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, open, readdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { SPEC_DIR } from "./files.js";
5
+ /**
6
+ * Screenshots live next to the listings:
7
+ * `appeeky/screenshots/app-store/<locale>/<DISPLAY_TYPE>/01.png`.
8
+ * `screenshots pull` downloads the live set; `check` validates sizes and counts.
9
+ */
10
+ export function screenshotsDir(root) {
11
+ return join(root, SPEC_DIR, "screenshots", "app-store");
12
+ }
13
+ /** Portrait sizes App Store Connect accepts per display type (landscape is the same, swapped). */
14
+ export const SCREENSHOT_SIZES = {
15
+ APP_IPHONE_67: [[1290, 2796], [1320, 2868], [1260, 2736]],
16
+ APP_IPHONE_65: [[1242, 2688], [1284, 2778]],
17
+ APP_IPHONE_61: [[1179, 2556], [1206, 2622], [1170, 2532]],
18
+ APP_IPHONE_58: [[1125, 2436], [1080, 2340]],
19
+ APP_IPHONE_55: [[1242, 2208]],
20
+ APP_IPAD_PRO_3GEN_129: [[2048, 2732], [2064, 2752]],
21
+ APP_IPAD_PRO_129: [[2048, 2732]],
22
+ APP_IPAD_PRO_3GEN_11: [[1668, 2388], [1640, 2360]],
23
+ };
24
+ const MAX_PER_SET = 10;
25
+ /** Width and height from a PNG or JPEG header, without decoding the image. */
26
+ export async function imageSize(file) {
27
+ const fh = await open(file, "r");
28
+ try {
29
+ const head = Buffer.alloc(64 * 1024);
30
+ const { bytesRead } = await fh.read(head, 0, head.length, 0);
31
+ const b = head.subarray(0, bytesRead);
32
+ if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47)
33
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
34
+ if (b[0] === 0xff && b[1] === 0xd8) {
35
+ let i = 2;
36
+ while (i + 9 < b.length) {
37
+ if (b[i] !== 0xff)
38
+ return null;
39
+ const marker = b[i + 1];
40
+ const len = b.readUInt16BE(i + 2);
41
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
42
+ return { height: b.readUInt16BE(i + 5), width: b.readUInt16BE(i + 7) };
43
+ }
44
+ i += 2 + len;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ finally {
50
+ await fh.close();
51
+ }
52
+ }
53
+ /** Sizes and counts of every screenshot set in the spec. */
54
+ export async function checkScreenshots(root) {
55
+ const base = screenshotsDir(root);
56
+ if (!existsSync(base))
57
+ return [];
58
+ const issues = [];
59
+ for (const locale of await readdir(base)) {
60
+ const localeDir = join(base, locale);
61
+ if (!(await readdir(localeDir).catch(() => null)))
62
+ continue;
63
+ for (const type of await readdir(localeDir)) {
64
+ const files = (await readdir(join(localeDir, type)).catch(() => [])).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort();
65
+ const at = (severity, message) => issues.push({ severity, rule: "screenshots", store: "app-store", locale, field: type, message });
66
+ if (files.length > MAX_PER_SET)
67
+ at("error", `${type} has ${files.length} screenshots; the limit is ${MAX_PER_SET}.`);
68
+ const sizes = SCREENSHOT_SIZES[type];
69
+ if (!sizes)
70
+ continue;
71
+ for (const f of files) {
72
+ const size = await imageSize(join(localeDir, type, f));
73
+ if (!size)
74
+ continue;
75
+ const ok = sizes.some(([w, h]) => (size.width === w && size.height === h) || (size.width === h && size.height === w));
76
+ if (!ok)
77
+ at("error", `${f} is ${size.width}×${size.height}; ${type} takes ${sizes.map(([w, h]) => `${w}×${h}`).join(", ")}.`);
78
+ }
79
+ }
80
+ }
81
+ return issues;
82
+ }
83
+ const list = (doc) => {
84
+ const d = doc?.data;
85
+ return Array.isArray(d) ? d : [];
86
+ };
87
+ /** Download the live App Store screenshots of every locale into the spec. */
88
+ export async function pullScreenshots(root, transport, state, options = {}) {
89
+ const ctx = state.context;
90
+ const doFetch = options.fetcher ?? fetch;
91
+ let count = 0;
92
+ for (const [locale, localizationId] of Object.entries(ctx.versionLocIds)) {
93
+ if (options.locales && !options.locales.includes(locale))
94
+ continue;
95
+ const sets = list(await transport.call("appstore_list_screenshot_sets", { localizationId, limit: 50 }));
96
+ for (const set of sets) {
97
+ const type = set.attributes?.screenshotDisplayType ?? "UNKNOWN";
98
+ const shots = list(await transport.call("appstore_list_screenshots", { setId: set.id, limit: 50 }));
99
+ const dir = join(screenshotsDir(root), locale, type);
100
+ await mkdir(dir, { recursive: true });
101
+ for (const [i, shot] of shots.entries()) {
102
+ const asset = shot.attributes?.imageAsset;
103
+ if (!asset?.templateUrl || !asset.width || !asset.height)
104
+ continue;
105
+ const url = asset.templateUrl.replace("{w}", String(asset.width)).replace("{h}", String(asset.height)).replace("{f}", "png");
106
+ const res = await doFetch(url);
107
+ if (!res.ok)
108
+ continue;
109
+ await writeFile(join(dir, `${String(i + 1).padStart(2, "0")}.png`), Buffer.from(await res.arrayBuffer()));
110
+ count++;
111
+ }
112
+ }
113
+ }
114
+ return count;
115
+ }
116
+ //# sourceMappingURL=screenshots.js.map
@@ -0,0 +1,137 @@
1
+ import { type Runner } from "./transport.js";
2
+ import type { MetricSource } from "./metrics.js";
3
+ import type { Project } from "./schema.js";
4
+ export declare const searchAdsDir: (root: string) => string;
5
+ export interface SearchAdsCredentials {
6
+ clientId: string;
7
+ teamId: string;
8
+ keyId: string;
9
+ privateKey: string;
10
+ orgId?: string;
11
+ }
12
+ /** ASA_* (or the APPLE_SEARCH_ADS_* spelling); null when no key is set. */
13
+ export declare function readSearchAdsCredentials(env: Record<string, string | undefined>, orgId?: string): Promise<SearchAdsCredentials | null>;
14
+ /** The client secret Apple wants: a JWT signed with the API key (ES256). */
15
+ export declare function clientSecret(creds: SearchAdsCredentials, now?: Date): string;
16
+ /** One Search Ads API call: returns Apple's JSON body, or throws with Apple's message. */
17
+ export type SearchAdsRequest = (method: string, path: string, body?: unknown) => Promise<{
18
+ data?: unknown;
19
+ }>;
20
+ /** Calls Apple directly with a key from the environment (ASA_*). */
21
+ export declare function directRequest(creds: SearchAdsCredentials, fetcher?: typeof fetch): {
22
+ request: SearchAdsRequest;
23
+ org: () => Promise<string>;
24
+ };
25
+ /**
26
+ * Calls Apple through the App Store Connect CLI (`appstore search-ads call`), with the Search Ads key
27
+ * saved on an account next to its App Store Connect key, so the private key never leaves the CLI:
28
+ * appstore accounts search-ads <account> --client-id … --team-id … --key-id … --private-key-path …
29
+ */
30
+ export declare function cliRequest(options?: {
31
+ account?: string;
32
+ orgId?: string;
33
+ runner?: Runner;
34
+ useNpx?: boolean;
35
+ }): {
36
+ request: SearchAdsRequest;
37
+ org: () => Promise<string>;
38
+ };
39
+ /**
40
+ * Where the Search Ads key comes from: ASA_* in the environment (called directly), else the
41
+ * App Store Connect CLI account in stores.appStore.account (or its default account).
42
+ */
43
+ export declare function searchAdsSource(project: Project, env?: Record<string, string | undefined>, runner?: Runner): Promise<{
44
+ label: string;
45
+ source: SearchAdsCredentials;
46
+ } | {
47
+ label: string;
48
+ source: {
49
+ request: SearchAdsRequest;
50
+ org: () => Promise<string>;
51
+ };
52
+ }>;
53
+ export declare class SearchAdsClient {
54
+ private readonly request;
55
+ private readonly resolveOrg;
56
+ /** A key from the environment (calls Apple directly), or another way to call it, such as the appstore CLI. */
57
+ constructor(source: SearchAdsCredentials | {
58
+ request: SearchAdsRequest;
59
+ org: () => Promise<string>;
60
+ }, fetcher?: typeof fetch);
61
+ private call;
62
+ /** The organization the calls go to. */
63
+ org(): Promise<string>;
64
+ campaigns(): Promise<Campaign[]>;
65
+ /** Per campaign and day. */
66
+ daily(from: string, to: string): Promise<CampaignReportRow[]>;
67
+ /** Totals per keyword (or per search term) for one campaign. */
68
+ totals(campaignId: number | string, kind: "keywords" | "searchterms", from: string, to: string): Promise<TotalsRow[]>;
69
+ }
70
+ export interface Campaign {
71
+ id: number;
72
+ name: string;
73
+ adamId: number;
74
+ status?: string;
75
+ displayStatus?: string;
76
+ countriesOrRegions?: string[];
77
+ /** Where the ads show: only APPSTORE_SEARCH_RESULTS campaigns have keywords and search terms. */
78
+ supplySources?: string[];
79
+ adChannelType?: string;
80
+ }
81
+ interface Metrics {
82
+ impressions?: number;
83
+ taps?: number;
84
+ totalInstalls?: number;
85
+ installs?: number;
86
+ tapInstalls?: number;
87
+ localSpend?: {
88
+ amount: string;
89
+ currency: string;
90
+ };
91
+ }
92
+ export interface CampaignReportRow {
93
+ metadata: {
94
+ campaignId: number;
95
+ campaignName?: string;
96
+ adamId?: number;
97
+ };
98
+ granularity?: Array<Metrics & {
99
+ date: string;
100
+ }>;
101
+ }
102
+ export interface TotalsRow {
103
+ metadata: {
104
+ keyword?: string;
105
+ searchTermText?: string | null;
106
+ matchType?: string;
107
+ countryOrRegion?: string;
108
+ countriesOrRegions?: string[];
109
+ keywordStatus?: string;
110
+ };
111
+ total?: Metrics;
112
+ }
113
+ export interface SearchAdsPullResult {
114
+ orgId: string;
115
+ campaigns: Array<{
116
+ id: number;
117
+ name: string;
118
+ status?: string;
119
+ }>;
120
+ currency?: string;
121
+ last30: {
122
+ spend: number;
123
+ installs: number;
124
+ taps: number;
125
+ impressions: number;
126
+ };
127
+ keywords: number;
128
+ searchTerms: number;
129
+ /** Reports that could not be read; the rest were written. */
130
+ errors: string[];
131
+ files: string[];
132
+ }
133
+ /** The App Store app's campaigns only: ads for other apps in the same organization stay out. */
134
+ export declare function pullSearchAds(project: Project, client: SearchAdsClient, now?: Date, days?: number): Promise<SearchAdsPullResult>;
135
+ /** `ad_installs` and `cpa` per day from appeeky/search-ads/daily.csv, for the App Store app. */
136
+ export declare function searchAdsMetricSource(root: string): MetricSource | null;
137
+ export {};