@appeeky/spec 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +636 -2
- package/agents/appeeky-ads.md +50 -0
- package/agents/appeeky-aso.md +58 -0
- package/agents/appeeky-releases.md +47 -0
- package/agents/appeeky-reviews.md +53 -0
- package/agents/appeeky-social.md +54 -0
- package/agents/appeeky-ugc.md +56 -0
- package/agents/cofounder.md +36 -0
- package/dist/ads.d.ts +42 -0
- package/dist/ads.js +71 -0
- package/dist/agents.d.ts +47 -0
- package/dist/agents.js +88 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1960 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/dataforseo.d.ts +139 -0
- package/dist/dataforseo.js +354 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +290 -0
- package/dist/metrics.js +254 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +10 -0
- package/dist/report.js +113 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +612 -0
- package/dist/schema.js +253 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/search-ads.d.ts +137 -0
- package/dist/search-ads.js +354 -0
- package/dist/seo.d.ts +87 -0
- package/dist/seo.js +391 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +259 -0
- package/dist/social.d.ts +129 -0
- package/dist/social.js +189 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +99 -0
- package/dist/transport.js +170 -0
- package/dist/ugc-core.js +1 -0
- package/dist/ugc-local.d.ts +116 -0
- package/dist/ugc-local.js +174 -0
- package/dist/ugc.d.ts +149 -0
- package/dist/ugc.js +111 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility-core.js +96 -0
- package/dist/visibility-local.d.ts +159 -0
- package/dist/visibility-local.js +246 -0
- package/dist/visibility.d.ts +152 -0
- package/dist/visibility.js +239 -0
- package/package.json +80 -4
- package/skills/appeeky-spec/SKILL.md +142 -0
- package/skills/appeeky-spec-experiments/SKILL.md +93 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +106 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SPEC_DIR } from "./files.js";
|
|
6
|
+
import { defaultRunner, onPath, STORE_CLIS } from "./transport.js";
|
|
7
|
+
/**
|
|
8
|
+
* Apple Search Ads on your own API key, no Appeeky account: the app's campaigns, spend and installs
|
|
9
|
+
* per day, and the keywords and search terms that brought installs, in appeeky/search-ads/.
|
|
10
|
+
*
|
|
11
|
+
* Make the key in Apple Search Ads → Account Settings → API (an API user with a read role), then set
|
|
12
|
+
* ASA_CLIENT_ID, ASA_TEAM_ID, ASA_KEY_ID and ASA_PRIVATE_KEY (the PEM, or a path to it), next to the
|
|
13
|
+
* App Store Connect key. The org comes from stores.appStore.searchAds.orgId (or ASA_ORG_ID) when the key
|
|
14
|
+
* reaches more than one organization.
|
|
15
|
+
*/
|
|
16
|
+
const TOKEN_URL = "https://appleid.apple.com/auth/oauth2/token";
|
|
17
|
+
const API = "https://api.searchads.apple.com/api/v5";
|
|
18
|
+
export const searchAdsDir = (root) => join(root, SPEC_DIR, "search-ads");
|
|
19
|
+
const env1 = (env, ...names) => names.map((n) => env[n]?.trim()).find(Boolean);
|
|
20
|
+
/** ASA_* (or the APPLE_SEARCH_ADS_* spelling); null when no key is set. */
|
|
21
|
+
export async function readSearchAdsCredentials(env, orgId) {
|
|
22
|
+
const clientId = env1(env, "ASA_CLIENT_ID", "APPLE_SEARCH_ADS_CLIENT_ID");
|
|
23
|
+
const keyId = env1(env, "ASA_KEY_ID", "APPLE_SEARCH_ADS_KEY_ID");
|
|
24
|
+
const rawKey = env1(env, "ASA_PRIVATE_KEY", "APPLE_SEARCH_ADS_PRIVATE_KEY");
|
|
25
|
+
if (!clientId && !keyId && !rawKey)
|
|
26
|
+
return null;
|
|
27
|
+
const missing = [!clientId && "ASA_CLIENT_ID", !keyId && "ASA_KEY_ID", !rawKey && "ASA_PRIVATE_KEY"].filter(Boolean);
|
|
28
|
+
if (missing.length)
|
|
29
|
+
throw new Error(`Apple Search Ads needs ${missing.join(", ")} too.`);
|
|
30
|
+
const privateKey = rawKey.includes("BEGIN") ? rawKey.replace(/\\n/g, "\n") : existsSync(rawKey) ? await readFile(rawKey, "utf8") : rawKey;
|
|
31
|
+
return {
|
|
32
|
+
clientId: clientId,
|
|
33
|
+
teamId: env1(env, "ASA_TEAM_ID", "APPLE_SEARCH_ADS_TEAM_ID") ?? clientId,
|
|
34
|
+
keyId: keyId,
|
|
35
|
+
privateKey,
|
|
36
|
+
orgId: orgId ?? env1(env, "ASA_ORG_ID", "APPLE_SEARCH_ADS_ORG_ID"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const b64url = (b) => Buffer.from(b).toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
40
|
+
/** The client secret Apple wants: a JWT signed with the API key (ES256). */
|
|
41
|
+
export function clientSecret(creds, now = new Date()) {
|
|
42
|
+
let key;
|
|
43
|
+
try {
|
|
44
|
+
key = createPrivateKey(creds.privateKey);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error("ASA_PRIVATE_KEY is not a private key: use the EC private key PEM you made for the API user, not the public key you uploaded to Apple.");
|
|
48
|
+
}
|
|
49
|
+
const iat = Math.floor(now.getTime() / 1000);
|
|
50
|
+
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: creds.keyId, typ: "JWT" }))}.${b64url(JSON.stringify({ sub: creds.clientId, aud: "https://appleid.apple.com", iss: creds.teamId, iat, exp: iat + 3000 }))}`;
|
|
51
|
+
return `${input}.${b64url(sign("sha256", Buffer.from(input), { key, dsaEncoding: "ieee-p1363" }))}`;
|
|
52
|
+
}
|
|
53
|
+
function appleError(status, json, path) {
|
|
54
|
+
const e = json.error?.errors?.[0];
|
|
55
|
+
return new Error(`Apple Search Ads: ${e ? [e.message, e.field && `(${e.field})`].filter(Boolean).join(" ") : `HTTP ${status}`} on ${path.split("?")[0]}`);
|
|
56
|
+
}
|
|
57
|
+
/** Calls Apple directly with a key from the environment (ASA_*). */
|
|
58
|
+
export function directRequest(creds, fetcher = fetch) {
|
|
59
|
+
let token;
|
|
60
|
+
let orgId = creds.orgId;
|
|
61
|
+
const accessToken = async () => {
|
|
62
|
+
if (token)
|
|
63
|
+
return token;
|
|
64
|
+
const res = await fetcher(TOKEN_URL, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
67
|
+
body: new URLSearchParams({ grant_type: "client_credentials", client_id: creds.clientId, client_secret: clientSecret(creds), scope: "searchadsorg" }).toString(),
|
|
68
|
+
});
|
|
69
|
+
const body = (await res.json().catch(() => ({})));
|
|
70
|
+
if (!res.ok || !body.access_token)
|
|
71
|
+
throw new Error(`Apple Search Ads sign-in failed: ${body.error ?? res.status}. Check ASA_CLIENT_ID, ASA_TEAM_ID, ASA_KEY_ID and the private key.`);
|
|
72
|
+
token = body.access_token;
|
|
73
|
+
return token;
|
|
74
|
+
};
|
|
75
|
+
const raw = async (method, path, body, withOrg) => {
|
|
76
|
+
const headers = { authorization: `Bearer ${await accessToken()}`, accept: "application/json" };
|
|
77
|
+
if (withOrg)
|
|
78
|
+
headers["x-ap-context"] = `orgId=${await org()}`;
|
|
79
|
+
if (body)
|
|
80
|
+
headers["content-type"] = "application/json";
|
|
81
|
+
const res = await fetcher(`${API}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(60_000) });
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
let json = {};
|
|
84
|
+
try {
|
|
85
|
+
json = text ? JSON.parse(text) : {};
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// not JSON
|
|
89
|
+
}
|
|
90
|
+
if (!res.ok)
|
|
91
|
+
throw appleError(res.status, json, path);
|
|
92
|
+
return json;
|
|
93
|
+
};
|
|
94
|
+
const org = async () => {
|
|
95
|
+
if (orgId)
|
|
96
|
+
return orgId;
|
|
97
|
+
const acls = ((await raw("GET", "/acls", undefined, false)).data ?? []);
|
|
98
|
+
const ids = [...new Set(acls.map((a) => String(a.orgId)))];
|
|
99
|
+
if (ids.length !== 1)
|
|
100
|
+
throw new Error(ids.length ? `The key reaches ${ids.length} organizations (${acls.map((a) => `${a.orgId} ${a.orgName ?? ""}`.trim()).join(", ")}). Set stores.appStore.searchAds.orgId.` : "The key reaches no Apple Search Ads organization.");
|
|
101
|
+
orgId = ids[0];
|
|
102
|
+
return orgId;
|
|
103
|
+
};
|
|
104
|
+
return { request: (method, path, body) => raw(method, path, body, path !== "/acls"), org };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Calls Apple through the App Store Connect CLI (`appstore search-ads call`), with the Search Ads key
|
|
108
|
+
* saved on an account next to its App Store Connect key, so the private key never leaves the CLI:
|
|
109
|
+
* appstore accounts search-ads <account> --client-id … --team-id … --key-id … --private-key-path …
|
|
110
|
+
*/
|
|
111
|
+
export function cliRequest(options = {}) {
|
|
112
|
+
const cli = STORE_CLIS["app-store"];
|
|
113
|
+
const run = async (args) => {
|
|
114
|
+
const argv = ["--json", ...(options.account ? ["--account", options.account] : []), "search-ads", ...args];
|
|
115
|
+
const useNpx = options.useNpx ?? !onPath(cli.bin);
|
|
116
|
+
const [command, full] = useNpx ? ["npx", ["-y", "-p", cli.npmPackage, cli.bin, ...argv]] : [cli.bin, argv];
|
|
117
|
+
let r;
|
|
118
|
+
try {
|
|
119
|
+
r = await (options.runner ?? defaultRunner)(command, full);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
throw new Error(`Could not start ${command}: ${e.message}`);
|
|
123
|
+
}
|
|
124
|
+
if (r.code !== 0) {
|
|
125
|
+
let message = r.stderr.trim().split("\n").at(-1) ?? "";
|
|
126
|
+
try {
|
|
127
|
+
message = JSON.parse(r.stdout).error?.message ?? message;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// stderr has the message
|
|
131
|
+
}
|
|
132
|
+
if (/unknown command|search-ads/i.test(message) && !/Search Ads/.test(message))
|
|
133
|
+
message += " Update the App Store Connect CLI: npm install -g @appeeky/app-store-connect-cli@latest";
|
|
134
|
+
throw new Error(message || `appstore search-ads exited with ${r.code}`);
|
|
135
|
+
}
|
|
136
|
+
return JSON.parse(r.stdout);
|
|
137
|
+
};
|
|
138
|
+
let orgId = options.orgId;
|
|
139
|
+
return {
|
|
140
|
+
request: (method, path, body) => run(["call", method, `/api/v5${path}`, ...(body ? [JSON.stringify(body)] : []), ...(options.orgId ? ["--org-id", options.orgId] : [])]),
|
|
141
|
+
org: async () => {
|
|
142
|
+
if (orgId)
|
|
143
|
+
return orgId;
|
|
144
|
+
const orgs = (await run(["orgs"]));
|
|
145
|
+
const list = (Array.isArray(orgs) ? orgs : (orgs.data ?? []));
|
|
146
|
+
orgId = list.length === 1 ? String(list[0].orgId) : "the account's org";
|
|
147
|
+
return orgId;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Where the Search Ads key comes from: ASA_* in the environment (called directly), else the
|
|
153
|
+
* App Store Connect CLI account in stores.appStore.account (or its default account).
|
|
154
|
+
*/
|
|
155
|
+
export async function searchAdsSource(project, env = process.env, runner) {
|
|
156
|
+
const cfg = project.spec.stores.appStore;
|
|
157
|
+
const creds = await readSearchAdsCredentials(env, cfg?.searchAds?.orgId);
|
|
158
|
+
if (creds)
|
|
159
|
+
return { label: "ASA_* key", source: creds };
|
|
160
|
+
return { label: `appstore CLI${cfg?.account ? ` account ${cfg.account}` : ""}`, source: cliRequest({ account: cfg?.account, orgId: cfg?.searchAds?.orgId, runner }) };
|
|
161
|
+
}
|
|
162
|
+
export class SearchAdsClient {
|
|
163
|
+
request;
|
|
164
|
+
resolveOrg;
|
|
165
|
+
/** A key from the environment (calls Apple directly), or another way to call it, such as the appstore CLI. */
|
|
166
|
+
constructor(source, fetcher = fetch) {
|
|
167
|
+
const via = "request" in source ? source : directRequest(source, fetcher);
|
|
168
|
+
this.request = via.request;
|
|
169
|
+
this.resolveOrg = via.org;
|
|
170
|
+
}
|
|
171
|
+
async call(path, init = {}) {
|
|
172
|
+
return (await this.request(init.method ?? "GET", path, init.body)).data;
|
|
173
|
+
}
|
|
174
|
+
/** The organization the calls go to. */
|
|
175
|
+
async org() {
|
|
176
|
+
return this.resolveOrg();
|
|
177
|
+
}
|
|
178
|
+
async campaigns() {
|
|
179
|
+
return (await this.call("/campaigns?limit=1000")) ?? [];
|
|
180
|
+
}
|
|
181
|
+
/** Per campaign and day. */
|
|
182
|
+
async daily(from, to) {
|
|
183
|
+
const data = await this.call("/reports/campaigns", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
body: { startTime: from, endTime: to, granularity: "DAILY", timeZone: "UTC", returnRecordsWithNoMetrics: false, returnRowTotals: false, returnGrandTotals: false, selector: { orderBy: [{ field: "campaignId", sortOrder: "ASCENDING" }], pagination: { offset: 0, limit: 1000 } } },
|
|
186
|
+
});
|
|
187
|
+
return data?.reportingDataResponse?.row ?? [];
|
|
188
|
+
}
|
|
189
|
+
/** Totals per keyword (or per search term) for one campaign. */
|
|
190
|
+
async totals(campaignId, kind, from, to) {
|
|
191
|
+
const data = await this.call(`/reports/campaigns/${campaignId}/${kind}`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
// Search term reports only take the org's time zone, and are split by storefront.
|
|
194
|
+
body: {
|
|
195
|
+
startTime: from,
|
|
196
|
+
endTime: to,
|
|
197
|
+
timeZone: kind === "searchterms" ? "ORTZ" : "UTC",
|
|
198
|
+
...(kind === "searchterms" ? { groupBy: ["countryOrRegion"] } : {}),
|
|
199
|
+
returnRecordsWithNoMetrics: false,
|
|
200
|
+
returnRowTotals: true,
|
|
201
|
+
returnGrandTotals: false,
|
|
202
|
+
selector: { orderBy: [{ field: "impressions", sortOrder: "DESCENDING" }], pagination: { offset: 0, limit: 1000 } },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
return data?.reportingDataResponse?.row ?? [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const installsOf = (m) => m?.totalInstalls ?? m?.installs ?? m?.tapInstalls ?? 0;
|
|
209
|
+
const spendOf = (m) => Number(m?.localSpend?.amount ?? 0);
|
|
210
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
211
|
+
const day = (d) => d.toISOString().slice(0, 10);
|
|
212
|
+
const cell = (v) => (typeof v === "string" && /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : String(v));
|
|
213
|
+
/** The App Store app's campaigns only: ads for other apps in the same organization stay out. */
|
|
214
|
+
export async function pullSearchAds(project, client, now = new Date(), days = 30) {
|
|
215
|
+
const adamId = project.spec.stores.appStore?.appId;
|
|
216
|
+
if (!adamId || !/^\d+$/.test(adamId))
|
|
217
|
+
throw new Error("Set stores.appStore.appId (the numeric App Store id) first: campaigns are matched to the app by it.");
|
|
218
|
+
const orgId = await client.org();
|
|
219
|
+
const only = project.spec.stores.appStore?.searchAds?.campaigns;
|
|
220
|
+
const campaigns = (await client.campaigns()).filter((c) => String(c.adamId) === adamId && (!only || only.includes(String(c.id))));
|
|
221
|
+
const to = day(new Date(now.getTime() - 86_400_000));
|
|
222
|
+
const from = day(new Date(now.getTime() - days * 86_400_000));
|
|
223
|
+
const dir = searchAdsDir(project.root);
|
|
224
|
+
await mkdir(dir, { recursive: true });
|
|
225
|
+
const ids = new Set(campaigns.map((c) => c.id));
|
|
226
|
+
const rows = campaigns.length ? (await client.daily(from, to)).filter((r) => ids.has(r.metadata.campaignId)) : [];
|
|
227
|
+
let currency;
|
|
228
|
+
const byDay = new Map();
|
|
229
|
+
for (const r of rows) {
|
|
230
|
+
for (const g of r.granularity ?? []) {
|
|
231
|
+
currency ??= g.localSpend?.currency;
|
|
232
|
+
const d = byDay.get(g.date) ?? { spend: 0, installs: 0, taps: 0, impressions: 0 };
|
|
233
|
+
d.spend += spendOf(g);
|
|
234
|
+
d.installs += installsOf(g);
|
|
235
|
+
d.taps += g.taps ?? 0;
|
|
236
|
+
d.impressions += g.impressions ?? 0;
|
|
237
|
+
byDay.set(g.date, d);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Keep earlier days from previous pulls; this pull's days replace theirs.
|
|
241
|
+
const dailyFile = join(dir, "daily.csv");
|
|
242
|
+
const kept = new Map();
|
|
243
|
+
if (existsSync(dailyFile))
|
|
244
|
+
for (const l of (await readFile(dailyFile, "utf8")).split("\n").slice(1).filter(Boolean))
|
|
245
|
+
kept.set(l.split(",")[0], l);
|
|
246
|
+
for (const [date, d] of byDay)
|
|
247
|
+
kept.set(date, [date, r2(d.spend), d.installs, d.taps, d.impressions, d.installs ? r2(d.spend / d.installs) : ""].join(","));
|
|
248
|
+
await writeFile(dailyFile, `date,spend,installs,taps,impressions,cpi\n${[...kept.keys()].sort().map((k) => kept.get(k)).join("\n")}${kept.size ? "\n" : ""}`);
|
|
249
|
+
const keywords = [];
|
|
250
|
+
const terms = [];
|
|
251
|
+
const errors = [];
|
|
252
|
+
const hasKeywords = (c) => !c.supplySources || c.supplySources.includes("APPSTORE_SEARCH_RESULTS");
|
|
253
|
+
for (const c of campaigns.filter(hasKeywords)) {
|
|
254
|
+
const soft = async (kind) => {
|
|
255
|
+
try {
|
|
256
|
+
return await client.totals(c.id, kind, from, to);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
errors.push(`${c.name} ${kind === "keywords" ? "keywords" : "search terms"}: ${e.message}`);
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
for (const r of await soft("keywords")) {
|
|
264
|
+
if (!r.metadata.keyword)
|
|
265
|
+
continue;
|
|
266
|
+
keywords.push({ text: r.metadata.keyword, match: r.metadata.matchType, campaign: c.name, impressions: r.total?.impressions ?? 0, taps: r.total?.taps ?? 0, installs: installsOf(r.total), spend: spendOf(r.total) });
|
|
267
|
+
}
|
|
268
|
+
for (const r of await soft("searchterms")) {
|
|
269
|
+
const text = r.metadata.searchTermText;
|
|
270
|
+
if (!text)
|
|
271
|
+
continue;
|
|
272
|
+
terms.push({ text, campaign: c.name, impressions: r.total?.impressions ?? 0, taps: r.total?.taps ?? 0, installs: installsOf(r.total), spend: spendOf(r.total) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const sortAgg = (a, b) => b.installs - a.installs || b.taps - a.taps || b.impressions - a.impressions;
|
|
276
|
+
keywords.sort(sortAgg);
|
|
277
|
+
terms.sort(sortAgg);
|
|
278
|
+
const table = (head, list, match) => `${head}${match ? ",match" : ""},campaign,impressions,taps,installs,spend,cpi\n${list.map((k) => [cell(k.text), ...(match ? [k.match ?? ""] : []), cell(k.campaign), k.impressions, k.taps, k.installs, r2(k.spend), k.installs ? r2(k.spend / k.installs) : ""].join(",")).join("\n")}${list.length ? "\n" : ""}`;
|
|
279
|
+
await writeFile(join(dir, "keywords.csv"), table("keyword", keywords, true));
|
|
280
|
+
await writeFile(join(dir, "search-terms.csv"), table("search_term", terms, false));
|
|
281
|
+
const total = [...byDay.values()].reduce((a, d) => ({ spend: a.spend + d.spend, installs: a.installs + d.installs, taps: a.taps + d.taps, impressions: a.impressions + d.impressions }), { spend: 0, installs: 0, taps: 0, impressions: 0 });
|
|
282
|
+
const targets = new Set(Object.values(project.spec.targets).flat().map((t) => t.toLowerCase()));
|
|
283
|
+
const newTerms = terms.filter((t) => t.installs > 0 && !targets.has(t.text.toLowerCase())).slice(0, 15);
|
|
284
|
+
const wasted = keywords.filter((k) => k.spend > 0 && k.installs === 0).sort((a, b) => b.spend - a.spend).slice(0, 10);
|
|
285
|
+
const money = (n) => `${r2(n)}${currency ? ` ${currency}` : ""}`;
|
|
286
|
+
const md = [
|
|
287
|
+
"# Apple Search Ads",
|
|
288
|
+
"",
|
|
289
|
+
`Written by \`search-ads pull\` from your own API key, ${from} to ${to}.`,
|
|
290
|
+
"",
|
|
291
|
+
campaigns.length ? `Campaigns for this app: ${campaigns.map((c) => `${c.name} (${c.displayStatus ?? c.status ?? "?"})`).join(", ")}.` : "No campaign for this app in the organization.",
|
|
292
|
+
"",
|
|
293
|
+
`- Spend: ${money(total.spend)}`,
|
|
294
|
+
`- Installs: ${total.installs}${total.installs ? ` (${money(total.spend / total.installs)} each)` : ""}`,
|
|
295
|
+
`- Taps: ${total.taps} of ${total.impressions} impressions`,
|
|
296
|
+
"",
|
|
297
|
+
"## Search terms that brought installs and are not targets",
|
|
298
|
+
"",
|
|
299
|
+
"People typed these and installed. Each one is a candidate for targets in appeeky.spec.yaml and for the keyword field: ranking for it organically is free.",
|
|
300
|
+
"",
|
|
301
|
+
"| search term | installs | taps | spend |",
|
|
302
|
+
"| --- | --- | --- | --- |",
|
|
303
|
+
...(newTerms.length ? newTerms.map((t) => `| ${t.text.replace(/\|/g, "\\|")} | ${t.installs} | ${t.taps} | ${money(t.spend)} |`) : ["| none in this window | | | |"]),
|
|
304
|
+
"",
|
|
305
|
+
"## Keywords that spent and brought no install",
|
|
306
|
+
"",
|
|
307
|
+
"| keyword | match | spend | taps |",
|
|
308
|
+
"| --- | --- | --- | --- |",
|
|
309
|
+
...(wasted.length ? wasted.map((k) => `| ${k.text.replace(/\|/g, "\\|")} | ${k.match ?? ""} | ${money(k.spend)} | ${k.taps} |`) : ["| none in this window | | | |"]),
|
|
310
|
+
"",
|
|
311
|
+
].join("\n");
|
|
312
|
+
await writeFile(join(dir, "summary.md"), md);
|
|
313
|
+
return {
|
|
314
|
+
orgId,
|
|
315
|
+
campaigns: campaigns.map((c) => ({ id: c.id, name: c.name, status: c.displayStatus ?? c.status })),
|
|
316
|
+
currency,
|
|
317
|
+
last30: { spend: r2(total.spend), installs: total.installs, taps: total.taps, impressions: total.impressions },
|
|
318
|
+
keywords: keywords.length,
|
|
319
|
+
searchTerms: terms.length,
|
|
320
|
+
errors,
|
|
321
|
+
files: ["daily.csv", "keywords.csv", "search-terms.csv", "summary.md"].map((f) => join(dir, f)),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** `ad_installs` and `cpa` per day from appeeky/search-ads/daily.csv, for the App Store app. */
|
|
325
|
+
export function searchAdsMetricSource(root) {
|
|
326
|
+
const file = join(searchAdsDir(root), "daily.csv");
|
|
327
|
+
if (!existsSync(file))
|
|
328
|
+
return null;
|
|
329
|
+
let rows;
|
|
330
|
+
return {
|
|
331
|
+
label: "Apple Search Ads in the repo",
|
|
332
|
+
async window(store, _appId, from, to) {
|
|
333
|
+
if (store !== "app-store")
|
|
334
|
+
return null;
|
|
335
|
+
rows ??= readFile(file, "utf8").then((t) => t
|
|
336
|
+
.split("\n")
|
|
337
|
+
.slice(1)
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
.map((l) => {
|
|
340
|
+
const [date, spend, installs] = l.split(",");
|
|
341
|
+
return { date: date, spend: Number(spend), installs: Number(installs) };
|
|
342
|
+
}));
|
|
343
|
+
const inside = (await rows).filter((r) => r.date >= from && r.date < to);
|
|
344
|
+
if (inside.length === 0)
|
|
345
|
+
return null;
|
|
346
|
+
const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
|
|
347
|
+
const spend = inside.reduce((n, r) => n + r.spend, 0);
|
|
348
|
+
const installs = inside.reduce((n, r) => n + r.installs, 0);
|
|
349
|
+
const values = { ad_installs: installs / days, ...(installs > 0 ? { cpa: spend / installs } : {}) };
|
|
350
|
+
return { from, to, days, values };
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
//# sourceMappingURL=search-ads.js.map
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { MetricSource } from "./metrics.js";
|
|
2
|
+
import type { Project } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* The app's website, for people searching Google and for AI assistants:
|
|
5
|
+
*
|
|
6
|
+
* seo check reads the site the way a crawler does and lists what is missing for search (title,
|
|
7
|
+
* description, canonical, sitemap), for the app (store links, smart banner, app
|
|
8
|
+
* structured data) and for AI assistants (crawlers allowed, llms.txt, text without JS)
|
|
9
|
+
* seo llms writes llms.txt from the listing
|
|
10
|
+
* seo pull Google Search Console clicks, impressions, queries and pages into appeeky/seo/,
|
|
11
|
+
* with your own service account (GSC_CREDENTIALS); no Appeeky account needed
|
|
12
|
+
*/
|
|
13
|
+
export declare const seoDir: (root: string) => string;
|
|
14
|
+
export type CheckArea = "search" | "app" | "ai";
|
|
15
|
+
export interface SiteCheck {
|
|
16
|
+
area: CheckArea;
|
|
17
|
+
level: "ok" | "warn" | "fail";
|
|
18
|
+
message: string;
|
|
19
|
+
/** What to change, when it is not ok. */
|
|
20
|
+
fix?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Crawlers that read sites for AI answers and training; blocking them keeps the app out of those answers. */
|
|
23
|
+
export declare const AI_CRAWLERS: readonly ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-SearchBot", "PerplexityBot", "Google-Extended", "Applebot-Extended"];
|
|
24
|
+
interface RobotsGroup {
|
|
25
|
+
agents: string[];
|
|
26
|
+
allow: string[];
|
|
27
|
+
disallow: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function parseRobots(text: string): {
|
|
30
|
+
groups: RobotsGroup[];
|
|
31
|
+
sitemaps: string[];
|
|
32
|
+
};
|
|
33
|
+
/** Whether robots.txt keeps an agent off the home page: its own group if it has one, else the * group. */
|
|
34
|
+
export declare function blocksHome(robots: {
|
|
35
|
+
groups: RobotsGroup[];
|
|
36
|
+
}, agent: string): boolean;
|
|
37
|
+
/** The text a crawler that does not run JavaScript sees. */
|
|
38
|
+
export declare function visibleText(html: string): string;
|
|
39
|
+
/** Read the site and list what helps or hurts it in search and in AI answers. */
|
|
40
|
+
export declare function checkSite(project: Project, url: string, fetcher?: typeof fetch): Promise<SiteCheck[]>;
|
|
41
|
+
/** llms.txt from the listing: what the app is, where to get it, and the full description. */
|
|
42
|
+
export declare function llmsTxt(project: Project): string;
|
|
43
|
+
export type GoogleCredentials = {
|
|
44
|
+
type: "service_account";
|
|
45
|
+
client_email: string;
|
|
46
|
+
private_key: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: "authorized_user";
|
|
49
|
+
client_id: string;
|
|
50
|
+
client_secret: string;
|
|
51
|
+
refresh_token: string;
|
|
52
|
+
quota_project_id?: string;
|
|
53
|
+
};
|
|
54
|
+
/** @deprecated the service account shape; kept for callers that build it by hand. */
|
|
55
|
+
export type ServiceAccount = {
|
|
56
|
+
client_email: string;
|
|
57
|
+
private_key: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* GSC_CREDENTIALS (the JSON, or a path to it), else GOOGLE_APPLICATION_CREDENTIALS, else your gcloud
|
|
61
|
+
* login on this machine. A service account key or an `authorized_user` file both work.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readServiceAccount(env: Record<string, string | undefined>, opts?: {
|
|
64
|
+
gcloud?: boolean;
|
|
65
|
+
}): Promise<GoogleCredentials | null>;
|
|
66
|
+
export declare function googleToken(creds: GoogleCredentials | ServiceAccount, fetcher?: typeof fetch, now?: Date): Promise<string>;
|
|
67
|
+
export declare function searchConsoleProperty(project: Project): string | null;
|
|
68
|
+
export interface SeoPullResult {
|
|
69
|
+
property: string;
|
|
70
|
+
days: number;
|
|
71
|
+
last28: {
|
|
72
|
+
clicks: number;
|
|
73
|
+
impressions: number;
|
|
74
|
+
};
|
|
75
|
+
previous28: {
|
|
76
|
+
clicks: number;
|
|
77
|
+
impressions: number;
|
|
78
|
+
};
|
|
79
|
+
queries: number;
|
|
80
|
+
pages: number;
|
|
81
|
+
files: string[];
|
|
82
|
+
}
|
|
83
|
+
/** Clicks and impressions per day (merged into daily.csv), and the last 28 days by query and by page. */
|
|
84
|
+
export declare function pullSearchConsole(project: Project, sa: GoogleCredentials | ServiceAccount, fetcher?: typeof fetch, now?: Date): Promise<SeoPullResult>;
|
|
85
|
+
/** `search_clicks` and `search_impressions` per day from appeeky/seo/daily.csv. */
|
|
86
|
+
export declare function seoMetricSource(root: string): MetricSource | null;
|
|
87
|
+
export {};
|