@appeeky/spec 0.0.0-stage → 0.1.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 +483 -2
- package/dist/ads.d.ts +39 -0
- package/dist/ads.js +67 -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 +1387 -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/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 +270 -0
- package/dist/metrics.js +250 -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 +5 -0
- package/dist/report.js +71 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +519 -0
- package/dist/schema.js +218 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +226 -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 +98 -0
- package/dist/transport.js +170 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility.d.ts +149 -0
- package/dist/visibility.js +239 -0
- package/package.json +76 -4
- package/skills/appeeky-spec/SKILL.md +146 -0
- package/skills/appeeky-spec-experiments/SKILL.md +83 -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 +79 -0
package/dist/text.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Text helpers shared by the lint rules and the coverage report. */
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
/** UTF-8 byte length: the unit Apple uses for the 100-byte keyword field. */
|
|
4
|
+
export function byteLength(value) {
|
|
5
|
+
return encoder.encode(value).length;
|
|
6
|
+
}
|
|
7
|
+
/** Character count as the stores count it (user-perceived characters, not UTF-16 units). */
|
|
8
|
+
export function charLength(value) {
|
|
9
|
+
return [...value].length;
|
|
10
|
+
}
|
|
11
|
+
export function normalize(value) {
|
|
12
|
+
return value.normalize("NFKC").toLocaleLowerCase();
|
|
13
|
+
}
|
|
14
|
+
/** Lowercased words; punctuation and symbols split words, apostrophes inside a word are kept. */
|
|
15
|
+
export function words(value) {
|
|
16
|
+
if (!value)
|
|
17
|
+
return [];
|
|
18
|
+
return (normalize(value)
|
|
19
|
+
.replace(/[’']/g, "'")
|
|
20
|
+
.match(/[\p{L}\p{N}]+(?:'[\p{L}\p{N}]+)*/gu) ?? []);
|
|
21
|
+
}
|
|
22
|
+
/** Entries of an App Store keyword field, as written (trimmed, empty ones dropped). */
|
|
23
|
+
export function keywordEntries(field) {
|
|
24
|
+
if (!field)
|
|
25
|
+
return [];
|
|
26
|
+
return field
|
|
27
|
+
.split(",")
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
/** Naive English singular form, only used to flag likely plural pairs. */
|
|
32
|
+
export function singular(word) {
|
|
33
|
+
if (word.length > 4 && word.endsWith("ies"))
|
|
34
|
+
return `${word.slice(0, -3)}y`;
|
|
35
|
+
if (word.length > 3 && word.endsWith("es") && /(s|x|z|ch|sh)es$/.test(word))
|
|
36
|
+
return word.slice(0, -2);
|
|
37
|
+
if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss"))
|
|
38
|
+
return word.slice(0, -1);
|
|
39
|
+
return word;
|
|
40
|
+
}
|
|
41
|
+
export function isEnglish(locale) {
|
|
42
|
+
return locale.toLowerCase().startsWith("en");
|
|
43
|
+
}
|
|
44
|
+
export function unique(items) {
|
|
45
|
+
return [...new Set(items)];
|
|
46
|
+
}
|
|
47
|
+
/** Short English words that do not decide a search; ignored when a phrase has other words. */
|
|
48
|
+
export const STOPWORDS = new Set(["a", "an", "the", "and", "or", "for", "to", "of", "in", "on", "with", "my", "your", "by", "at", "app", "apps"]);
|
|
49
|
+
/** Words of a target that matter for coverage: stopwords drop out unless they are all there is. */
|
|
50
|
+
export function keyWords(value) {
|
|
51
|
+
const all = words(value);
|
|
52
|
+
const kept = all.filter((w) => !STOPWORDS.has(w));
|
|
53
|
+
return kept.length > 0 ? kept : all;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=text.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { StoreId, TransportMode } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Both store CLIs expose the same named operations locally (`appstore ops call`,
|
|
4
|
+
* `gps call`) and on Appeeky's hosted gateways (`POST /v1/ops/<name>`), so a
|
|
5
|
+
* store adapter only needs `call(op, args)`.
|
|
6
|
+
*/
|
|
7
|
+
export interface OpsTransport {
|
|
8
|
+
readonly kind: "local" | "hosted";
|
|
9
|
+
readonly label: string;
|
|
10
|
+
call<T = unknown>(op: string, args: Record<string, unknown>, options?: {
|
|
11
|
+
write?: boolean;
|
|
12
|
+
}): Promise<T>;
|
|
13
|
+
}
|
|
14
|
+
export declare class TransportError extends Error {
|
|
15
|
+
readonly op?: string | undefined;
|
|
16
|
+
constructor(message: string, op?: string | undefined);
|
|
17
|
+
}
|
|
18
|
+
interface StoreCli {
|
|
19
|
+
bin: string;
|
|
20
|
+
npmPackage: string;
|
|
21
|
+
/** argv before the op name, e.g. `ops call` vs `call`. */
|
|
22
|
+
callArgs: string[];
|
|
23
|
+
/** Flag that picks a named account or profile. */
|
|
24
|
+
accountFlag: string;
|
|
25
|
+
hostedUrl: string;
|
|
26
|
+
writeHeader: string;
|
|
27
|
+
setupHint: string;
|
|
28
|
+
}
|
|
29
|
+
export declare const STORE_CLIS: Record<StoreId, StoreCli>;
|
|
30
|
+
export declare function onPath(bin: string, env?: NodeJS.ProcessEnv): boolean;
|
|
31
|
+
export type Runner = (command: string, args: string[], env?: Record<string, string>) => Promise<{
|
|
32
|
+
code: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
}>;
|
|
36
|
+
export interface LocalCliOptions {
|
|
37
|
+
/** Named account (`appstore --account`) or profile (`gps --profile`). */
|
|
38
|
+
account?: string;
|
|
39
|
+
/** Run the CLI through `npx` when it is not installed. */
|
|
40
|
+
useNpx?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Run a specific executable instead of the CLI on PATH, e.g. an app that
|
|
43
|
+
* bundles the store CLI: `{ file: process.execPath, args: ["/path/to/bin.js"] }`.
|
|
44
|
+
*/
|
|
45
|
+
command?: {
|
|
46
|
+
file: string;
|
|
47
|
+
args: string[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Extra environment for the CLI, e.g. credentials:
|
|
51
|
+
* `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY` or `GPS_CREDENTIALS` (inline JSON).
|
|
52
|
+
*/
|
|
53
|
+
env?: Record<string, string>;
|
|
54
|
+
label?: string;
|
|
55
|
+
runner?: Runner;
|
|
56
|
+
}
|
|
57
|
+
/** Runs operations through the store CLI on this machine, with the user's own key. */
|
|
58
|
+
export declare class LocalCliTransport implements OpsTransport {
|
|
59
|
+
private readonly store;
|
|
60
|
+
private readonly options;
|
|
61
|
+
readonly kind: "local";
|
|
62
|
+
readonly label: string;
|
|
63
|
+
constructor(store: StoreId, options?: LocalCliOptions);
|
|
64
|
+
call<T>(op: string, args: Record<string, unknown>, options?: {
|
|
65
|
+
write?: boolean;
|
|
66
|
+
}): Promise<T>;
|
|
67
|
+
}
|
|
68
|
+
export type Fetcher = typeof fetch;
|
|
69
|
+
/** Runs operations on Appeeky's hosted gateway with an Appeeky API key; the store key never leaves Appeeky. */
|
|
70
|
+
export declare class HostedTransport implements OpsTransport {
|
|
71
|
+
private readonly store;
|
|
72
|
+
private readonly apiKey;
|
|
73
|
+
private readonly options;
|
|
74
|
+
readonly kind: "hosted";
|
|
75
|
+
readonly label: string;
|
|
76
|
+
constructor(store: StoreId, apiKey: string, options?: {
|
|
77
|
+
baseUrl?: string;
|
|
78
|
+
fetcher?: Fetcher;
|
|
79
|
+
});
|
|
80
|
+
call<T>(op: string, args: Record<string, unknown>, options?: {
|
|
81
|
+
write?: boolean;
|
|
82
|
+
}): Promise<T>;
|
|
83
|
+
}
|
|
84
|
+
export interface TransportOptions {
|
|
85
|
+
via: TransportMode;
|
|
86
|
+
account?: string;
|
|
87
|
+
apiKey?: string;
|
|
88
|
+
env?: NodeJS.ProcessEnv;
|
|
89
|
+
runner?: Runner;
|
|
90
|
+
fetcher?: Fetcher;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `auto` prefers the local CLI when it is installed (your key, nothing leaves
|
|
94
|
+
* your machine), then the hosted gateway when an Appeeky API key is set, and
|
|
95
|
+
* finally the CLI through npx.
|
|
96
|
+
*/
|
|
97
|
+
export declare function createTransport(store: StoreId, options: TransportOptions): OpsTransport;
|
|
98
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
export class TransportError extends Error {
|
|
5
|
+
op;
|
|
6
|
+
constructor(message, op) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.op = op;
|
|
9
|
+
this.name = "TransportError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const STORE_CLIS = {
|
|
13
|
+
"app-store": {
|
|
14
|
+
bin: "appstore",
|
|
15
|
+
npmPackage: "@appeeky/app-store-connect-cli",
|
|
16
|
+
callArgs: ["ops", "call"],
|
|
17
|
+
accountFlag: "--account",
|
|
18
|
+
hostedUrl: "https://asc-api.appeeky.com",
|
|
19
|
+
writeHeader: "X-ASC-Allow-Writes",
|
|
20
|
+
setupHint: "Save your App Store Connect API key once with the App Store Connect CLI: `npx -p @appeeky/app-store-connect-cli appstore accounts add my-team --key-id … --issuer-id … --private-key-path AuthKey.p8 --default` (github.com/appeeky/app-store-connect-cli). Or set APPEEKY_API_KEY to use your connection in Appeeky.",
|
|
21
|
+
},
|
|
22
|
+
"google-play": {
|
|
23
|
+
bin: "gps",
|
|
24
|
+
npmPackage: "@appeeky/google-play-store-cli",
|
|
25
|
+
callArgs: ["call"],
|
|
26
|
+
accountFlag: "--account",
|
|
27
|
+
hostedUrl: "https://gps-api.appeeky.com",
|
|
28
|
+
writeHeader: "X-GPS-Allow-Writes",
|
|
29
|
+
setupHint: "Save your Google Play service account once with the Google Play CLI: `npx -p @appeeky/google-play-store-cli gps accounts add my-co --credentials service-account.json --default` (github.com/appeeky/google-play-console-cli). Or set APPEEKY_API_KEY to use your connection in Appeeky.",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
export function onPath(bin, env = process.env) {
|
|
33
|
+
const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD").split(";") : [""];
|
|
34
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
35
|
+
if (!dir)
|
|
36
|
+
continue;
|
|
37
|
+
for (const ext of exts)
|
|
38
|
+
if (existsSync(join(dir, bin + ext)))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const defaultRunner = (command, args, env) => new Promise((resolve, reject) => {
|
|
44
|
+
const child = spawn(command, args, {
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
shell: process.platform === "win32",
|
|
47
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
48
|
+
});
|
|
49
|
+
let stdout = "";
|
|
50
|
+
let stderr = "";
|
|
51
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
52
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
53
|
+
child.on("error", reject);
|
|
54
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
55
|
+
});
|
|
56
|
+
/** Runs operations through the store CLI on this machine, with the user's own key. */
|
|
57
|
+
export class LocalCliTransport {
|
|
58
|
+
store;
|
|
59
|
+
options;
|
|
60
|
+
kind = "local";
|
|
61
|
+
label;
|
|
62
|
+
constructor(store, options = {}) {
|
|
63
|
+
this.store = store;
|
|
64
|
+
this.options = options;
|
|
65
|
+
const cli = STORE_CLIS[store];
|
|
66
|
+
this.label = options.label ?? (options.useNpx ? `npx ${cli.npmPackage}` : `local ${cli.bin} CLI`);
|
|
67
|
+
}
|
|
68
|
+
async call(op, args, options = {}) {
|
|
69
|
+
const cli = STORE_CLIS[this.store];
|
|
70
|
+
const argv = ["--json"];
|
|
71
|
+
if (this.options.account)
|
|
72
|
+
argv.push(cli.accountFlag, this.options.account);
|
|
73
|
+
if (options.write)
|
|
74
|
+
argv.push("--confirm");
|
|
75
|
+
argv.push(...cli.callArgs, op, JSON.stringify(args));
|
|
76
|
+
const [command, full] = this.options.command
|
|
77
|
+
? [this.options.command.file, [...this.options.command.args, ...argv]]
|
|
78
|
+
: this.options.useNpx
|
|
79
|
+
? ["npx", ["-y", "-p", cli.npmPackage, cli.bin, ...argv]]
|
|
80
|
+
: [cli.bin, argv];
|
|
81
|
+
const run = this.options.runner ?? defaultRunner;
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await run(command, full, this.options.env);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw new TransportError(`Could not start ${command}: ${err.message}. ${cli.setupHint}`, op);
|
|
88
|
+
}
|
|
89
|
+
if (result.code !== 0) {
|
|
90
|
+
const detail = (result.stderr || result.stdout).trim().split("\n").slice(-3).join(" ");
|
|
91
|
+
throw new TransportError(`${cli.bin} ${op} failed: ${detail || `exit ${result.code}`}`, op);
|
|
92
|
+
}
|
|
93
|
+
const text = result.stdout.trim();
|
|
94
|
+
if (!text)
|
|
95
|
+
return undefined;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(text);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new TransportError(`${cli.bin} ${op} returned output that is not JSON: ${text.slice(0, 200)}`, op);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Runs operations on Appeeky's hosted gateway with an Appeeky API key; the store key never leaves Appeeky. */
|
|
105
|
+
export class HostedTransport {
|
|
106
|
+
store;
|
|
107
|
+
apiKey;
|
|
108
|
+
options;
|
|
109
|
+
kind = "hosted";
|
|
110
|
+
label;
|
|
111
|
+
constructor(store, apiKey, options = {}) {
|
|
112
|
+
this.store = store;
|
|
113
|
+
this.apiKey = apiKey;
|
|
114
|
+
this.options = options;
|
|
115
|
+
this.label = `hosted ${new URL(options.baseUrl ?? STORE_CLIS[store].hostedUrl).host}`;
|
|
116
|
+
}
|
|
117
|
+
async call(op, args, options = {}) {
|
|
118
|
+
const cli = STORE_CLIS[this.store];
|
|
119
|
+
const base = (this.options.baseUrl ?? cli.hostedUrl).replace(/\/$/, "");
|
|
120
|
+
const headers = {
|
|
121
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"User-Agent": "appeeky-spec",
|
|
124
|
+
};
|
|
125
|
+
if (options.write)
|
|
126
|
+
headers[cli.writeHeader] = "true";
|
|
127
|
+
const doFetch = this.options.fetcher ?? fetch;
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await doFetch(`${base}/v1/ops/${op}`, { method: "POST", headers, body: JSON.stringify(args) });
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw new TransportError(`Could not reach ${base}: ${err.message}`, op);
|
|
134
|
+
}
|
|
135
|
+
const body = (await res.json().catch(() => null));
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
const message = body?.error?.message ?? `HTTP ${res.status}`;
|
|
138
|
+
throw new TransportError(`${op} failed on ${new URL(base).host}: ${message}`, op);
|
|
139
|
+
}
|
|
140
|
+
return body?.data;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `auto` prefers the local CLI when it is installed (your key, nothing leaves
|
|
145
|
+
* your machine), then the hosted gateway when an Appeeky API key is set, and
|
|
146
|
+
* finally the CLI through npx.
|
|
147
|
+
*/
|
|
148
|
+
export function createTransport(store, options) {
|
|
149
|
+
const env = options.env ?? process.env;
|
|
150
|
+
const apiKey = options.apiKey ?? env.APPEEKY_API_KEY;
|
|
151
|
+
const cli = STORE_CLIS[store];
|
|
152
|
+
const local = (useNpx) => new LocalCliTransport(store, { account: options.account, useNpx, runner: options.runner });
|
|
153
|
+
const hostedBase = store === "app-store" ? env.APPEEKY_ASC_URL : env.APPEEKY_GPS_URL;
|
|
154
|
+
const hosted = (key) => new HostedTransport(store, key, { baseUrl: hostedBase, fetcher: options.fetcher });
|
|
155
|
+
switch (options.via) {
|
|
156
|
+
case "local":
|
|
157
|
+
return local(!onPath(cli.bin, env));
|
|
158
|
+
case "hosted":
|
|
159
|
+
if (!apiKey)
|
|
160
|
+
throw new TransportError(`Set APPEEKY_API_KEY to use the hosted ${store} gateway.`);
|
|
161
|
+
return hosted(apiKey);
|
|
162
|
+
case "auto":
|
|
163
|
+
if (onPath(cli.bin, env))
|
|
164
|
+
return local(false);
|
|
165
|
+
if (apiKey)
|
|
166
|
+
return hosted(apiKey);
|
|
167
|
+
return local(true);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=transport.js.map
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Project, StoreId } from "./schema.js";
|
|
2
|
+
/** Where a store app's search rank for a keyword comes from. `null` = not in the results. */
|
|
3
|
+
export interface RankSource {
|
|
4
|
+
readonly label: string;
|
|
5
|
+
/** Ranks for several keywords in one storefront. Missing keys mean "unknown". */
|
|
6
|
+
ranks(store: StoreId, appId: string, country: string, keywords: string[]): Promise<Record<string, number | null>>;
|
|
7
|
+
}
|
|
8
|
+
/** country → keyword → rank */
|
|
9
|
+
export type RankSnapshot = Record<string, Record<string, number | null>>;
|
|
10
|
+
export interface RankBaseline {
|
|
11
|
+
at: string;
|
|
12
|
+
source: string;
|
|
13
|
+
ranks: Partial<Record<StoreId, RankSnapshot>>;
|
|
14
|
+
}
|
|
15
|
+
/** Store ids for rank lookups: numeric App Store id, Google Play package name. */
|
|
16
|
+
export declare function rankAppIds(project: Project): Partial<Record<StoreId, string>>;
|
|
17
|
+
/** Ranks for every target of the given stores; failures leave that storefront out. */
|
|
18
|
+
export declare function snapshotRanks(project: Project, source: RankSource, stores: StoreId[]): Promise<RankBaseline>;
|
|
19
|
+
export interface RankMove {
|
|
20
|
+
country: string;
|
|
21
|
+
keyword: string;
|
|
22
|
+
before: number | null;
|
|
23
|
+
after: number | null;
|
|
24
|
+
}
|
|
25
|
+
export type Verdict = "won" | "lost" | "mixed" | "flat";
|
|
26
|
+
export interface RankEvaluation {
|
|
27
|
+
improved: RankMove[];
|
|
28
|
+
dropped: RankMove[];
|
|
29
|
+
/** Top-20 terms that fell out of the top 20 by 10+ places, or stopped ranking. */
|
|
30
|
+
regressions: RankMove[];
|
|
31
|
+
verdict: Verdict;
|
|
32
|
+
/** Set when ranks did not move and the verdict came from these outcome metrics instead. */
|
|
33
|
+
decidedBy?: string[];
|
|
34
|
+
/** The changed storefronts against the unchanged ones over the same days. */
|
|
35
|
+
control?: import("./control.js").ControlComparison;
|
|
36
|
+
/** Why the verdict differs from the raw rank count, in words. */
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compare two snapshots of one store. A release "lost" when one top-10 term
|
|
41
|
+
* or two top-20 terms fell away (the rule the co-founder uses to propose a
|
|
42
|
+
* revert), "won" when more terms rose than fell, "mixed" or "flat" otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export declare function evaluateRanks(before: RankSnapshot, after: RankSnapshot): RankEvaluation;
|
|
45
|
+
/** App Store ranks from Apple's public search API: no key needed, top 200 results. */
|
|
46
|
+
export declare function itunesRankSource(fetcher?: typeof fetch): RankSource;
|
|
47
|
+
/** Combine sources: the first one that knows a keyword wins. */
|
|
48
|
+
export declare function firstRankSource(...sources: RankSource[]): RankSource;
|
|
49
|
+
/** Store-listing conversion over a date range, e.g. installs / product page visitors. */
|
|
50
|
+
export interface ConversionWindow {
|
|
51
|
+
/** installs ÷ visitors, 0–1 */
|
|
52
|
+
rate: number;
|
|
53
|
+
visitors: number;
|
|
54
|
+
installs: number;
|
|
55
|
+
days: number;
|
|
56
|
+
}
|
|
57
|
+
/** Where conversion numbers come from (App Store Connect analytics, Play Console, a local cache). */
|
|
58
|
+
export interface ConversionSource {
|
|
59
|
+
readonly label: string;
|
|
60
|
+
/** Conversion between two ISO dates (inclusive start, exclusive end); null when there is no data. */
|
|
61
|
+
conversion(store: StoreId, appId: string, from: string, to: string): Promise<ConversionWindow | null>;
|
|
62
|
+
}
|
|
63
|
+
export interface ConversionChange {
|
|
64
|
+
before: ConversionWindow;
|
|
65
|
+
after: ConversionWindow;
|
|
66
|
+
/** Relative change of the rate, e.g. 0.12 = +12%. */
|
|
67
|
+
change: number;
|
|
68
|
+
}
|
|
69
|
+
/** Compare the conversion of the `days` before a push with the `days` after it. */
|
|
70
|
+
export declare function measureConversion(source: ConversionSource, store: StoreId, appId: string, pushedAt: string, days: number): Promise<ConversionChange | null>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { normalize } from "./text.js";
|
|
2
|
+
/** Store ids for rank lookups: numeric App Store id, Google Play package name. */
|
|
3
|
+
export function rankAppIds(project) {
|
|
4
|
+
const ids = {};
|
|
5
|
+
const asc = project.spec.stores.appStore?.appId;
|
|
6
|
+
if (asc && /^\d+$/.test(asc))
|
|
7
|
+
ids["app-store"] = asc;
|
|
8
|
+
if (project.spec.stores.googlePlay)
|
|
9
|
+
ids["google-play"] = project.spec.stores.googlePlay.packageName;
|
|
10
|
+
return ids;
|
|
11
|
+
}
|
|
12
|
+
/** Ranks for every target of the given stores; failures leave that storefront out. */
|
|
13
|
+
export async function snapshotRanks(project, source, stores) {
|
|
14
|
+
const ids = rankAppIds(project);
|
|
15
|
+
const baseline = { at: new Date().toISOString(), source: source.label, ranks: {} };
|
|
16
|
+
for (const store of stores) {
|
|
17
|
+
const appId = ids[store];
|
|
18
|
+
if (!appId)
|
|
19
|
+
continue;
|
|
20
|
+
const snap = {};
|
|
21
|
+
for (const [country, targets] of Object.entries(project.spec.targets)) {
|
|
22
|
+
const keywords = [...new Set(targets.map((k) => normalize(k).trim()))];
|
|
23
|
+
if (keywords.length === 0)
|
|
24
|
+
continue;
|
|
25
|
+
try {
|
|
26
|
+
snap[country] = await source.ranks(store, appId, country, keywords);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A storefront that cannot be read now is simply not part of the baseline.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (Object.keys(snap).length > 0)
|
|
33
|
+
baseline.ranks[store] = snap;
|
|
34
|
+
}
|
|
35
|
+
return baseline;
|
|
36
|
+
}
|
|
37
|
+
/** A move only counts when it is at least this many places. */
|
|
38
|
+
const MIN_MOVE = 3;
|
|
39
|
+
/** Same thresholds as the co-founder's ranking-term guard and auto-revert. */
|
|
40
|
+
const TOP = 20;
|
|
41
|
+
const DROP_PLACES = 10;
|
|
42
|
+
/** Not ranked counts as just past the 200 results the stores return, so "out → out" is no move. */
|
|
43
|
+
const UNRANKED = 201;
|
|
44
|
+
const asRank = (r) => (r === null || r === undefined ? UNRANKED : r);
|
|
45
|
+
/**
|
|
46
|
+
* Compare two snapshots of one store. A release "lost" when one top-10 term
|
|
47
|
+
* or two top-20 terms fell away (the rule the co-founder uses to propose a
|
|
48
|
+
* revert), "won" when more terms rose than fell, "mixed" or "flat" otherwise.
|
|
49
|
+
*/
|
|
50
|
+
export function evaluateRanks(before, after) {
|
|
51
|
+
const improved = [];
|
|
52
|
+
const dropped = [];
|
|
53
|
+
const regressions = [];
|
|
54
|
+
for (const [country, ranks] of Object.entries(before)) {
|
|
55
|
+
for (const [keyword, b] of Object.entries(ranks)) {
|
|
56
|
+
if (!(keyword in (after[country] ?? {})))
|
|
57
|
+
continue;
|
|
58
|
+
const a = after[country][keyword] ?? null;
|
|
59
|
+
const move = { country, keyword, before: b, after: a };
|
|
60
|
+
const from = asRank(b);
|
|
61
|
+
const to = asRank(a);
|
|
62
|
+
if (to + MIN_MOVE <= from)
|
|
63
|
+
improved.push(move);
|
|
64
|
+
else if (from + MIN_MOVE <= to)
|
|
65
|
+
dropped.push(move);
|
|
66
|
+
if (b !== null && b <= TOP && (a === null || (a > TOP && a - b >= DROP_PLACES)))
|
|
67
|
+
regressions.push(move);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const lost = regressions.length >= 2 || regressions.some((r) => (r.before ?? UNRANKED) <= 10);
|
|
71
|
+
const verdict = lost
|
|
72
|
+
? "lost"
|
|
73
|
+
: improved.length > dropped.length
|
|
74
|
+
? "won"
|
|
75
|
+
: improved.length === 0 && dropped.length === 0
|
|
76
|
+
? "flat"
|
|
77
|
+
: "mixed";
|
|
78
|
+
return { improved, dropped, regressions, verdict };
|
|
79
|
+
}
|
|
80
|
+
/** App Store ranks from Apple's public search API: no key needed, top 200 results. */
|
|
81
|
+
export function itunesRankSource(fetcher = fetch) {
|
|
82
|
+
return {
|
|
83
|
+
label: "App Store search",
|
|
84
|
+
async ranks(store, appId, country, keywords) {
|
|
85
|
+
if (store !== "app-store")
|
|
86
|
+
return {};
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const keyword of keywords) {
|
|
89
|
+
const url = `https://itunes.apple.com/search?media=software&entity=software&limit=200&country=${encodeURIComponent(country)}&term=${encodeURIComponent(keyword)}`;
|
|
90
|
+
const res = await fetcher(url, { headers: { "User-Agent": "appeeky-spec" } });
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
continue;
|
|
93
|
+
const json = (await res.json());
|
|
94
|
+
const index = (json.results ?? []).findIndex((r) => String(r.trackId) === appId);
|
|
95
|
+
out[keyword] = index >= 0 ? index + 1 : null;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Combine sources: the first one that knows a keyword wins. */
|
|
102
|
+
export function firstRankSource(...sources) {
|
|
103
|
+
return {
|
|
104
|
+
label: sources.map((s) => s.label).join(" + "),
|
|
105
|
+
async ranks(store, appId, country, keywords) {
|
|
106
|
+
const out = {};
|
|
107
|
+
let left = keywords;
|
|
108
|
+
for (const s of sources) {
|
|
109
|
+
if (left.length === 0)
|
|
110
|
+
break;
|
|
111
|
+
try {
|
|
112
|
+
Object.assign(out, await s.ranks(store, appId, country, left));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// try the next source
|
|
116
|
+
}
|
|
117
|
+
left = keywords.filter((k) => !(k in out));
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Compare the conversion of the `days` before a push with the `days` after it. */
|
|
124
|
+
export async function measureConversion(source, store, appId, pushedAt, days) {
|
|
125
|
+
const t = Date.parse(pushedAt);
|
|
126
|
+
const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
127
|
+
const span = Math.max(1, days) * 86_400_000;
|
|
128
|
+
const before = await source.conversion(store, appId, iso(t - span), iso(t));
|
|
129
|
+
const after = await source.conversion(store, appId, iso(t + 86_400_000), iso(t + 86_400_000 + span));
|
|
130
|
+
if (!before || !after || before.visitors === 0 || after.visitors === 0)
|
|
131
|
+
return null;
|
|
132
|
+
return { before, after, change: before.rate > 0 ? after.rate / before.rate - 1 : 0 };
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=verify.js.map
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { AppeekyApi } from "./appeeky-api.js";
|
|
2
|
+
import type { MetricSource } from "./metrics.js";
|
|
3
|
+
import type { Project, SpecFile, StoreId } from "./schema.js";
|
|
4
|
+
/**
|
|
5
|
+
* AI visibility in the repo: whether ChatGPT, Claude, Gemini and Perplexity name
|
|
6
|
+
* the app when people ask for an app like it, who they name instead, and which
|
|
7
|
+
* pages they cite. The intents and prompts live in appeeky.spec.yaml; Appeeky AI
|
|
8
|
+
* Visibility asks the assistants on a schedule, and `visibility pull` writes what
|
|
9
|
+
* they answered to appeeky/visibility/<country>.yaml.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MODELS: readonly ["chatgpt", "claude", "gemini", "perplexity"];
|
|
12
|
+
export type Model = (typeof MODELS)[number];
|
|
13
|
+
export type AiIntent = NonNullable<SpecFile["aiVisibility"]>["intents"][string][number];
|
|
14
|
+
export declare const visibilityDir: (root: string) => string;
|
|
15
|
+
export declare const visibilityChangesFile: (root: string) => string;
|
|
16
|
+
export interface VisibilityTarget {
|
|
17
|
+
store: StoreId;
|
|
18
|
+
/** The store id: numeric App Store id or Google Play package name. */
|
|
19
|
+
appId: string;
|
|
20
|
+
countries: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Which app and storefronts to watch. */
|
|
23
|
+
export declare function visibilityTarget(project: Project): VisibilityTarget | null;
|
|
24
|
+
/** ISO 639-1 language for a storefront's prompts, from the listing locale it indexes first. */
|
|
25
|
+
export declare function languageFor(project: Project, country: string): string;
|
|
26
|
+
export interface RemoteIntentRow {
|
|
27
|
+
intentId: string;
|
|
28
|
+
label: string;
|
|
29
|
+
description: string | null;
|
|
30
|
+
status: "active" | "paused" | "archived";
|
|
31
|
+
visibilityPct: number;
|
|
32
|
+
promptsTotal: number;
|
|
33
|
+
}
|
|
34
|
+
export interface RemotePrompt {
|
|
35
|
+
promptId: string;
|
|
36
|
+
text: string;
|
|
37
|
+
status: "active" | "paused" | "archived";
|
|
38
|
+
}
|
|
39
|
+
interface Opportunity {
|
|
40
|
+
intentId: string;
|
|
41
|
+
label: string;
|
|
42
|
+
status: "missing" | "weak" | "winning" | "no_data";
|
|
43
|
+
ownerVisibilityPct: number | null;
|
|
44
|
+
byModel: Partial<Record<Model, {
|
|
45
|
+
visibilityPct: number;
|
|
46
|
+
positionAvg: number | null;
|
|
47
|
+
promptsTotal: number;
|
|
48
|
+
date: string;
|
|
49
|
+
}>>;
|
|
50
|
+
winners: Array<{
|
|
51
|
+
name: string;
|
|
52
|
+
sharePct: number;
|
|
53
|
+
models: Model[];
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
export declare function remoteIntents(api: AppeekyApi, appId: string, country: string): Promise<RemoteIntentRow[]>;
|
|
57
|
+
export declare function remotePrompts(api: AppeekyApi, appId: string, country: string, intentId: string): Promise<RemotePrompt[]>;
|
|
58
|
+
export interface VisibilityFile {
|
|
59
|
+
pulledAt: string;
|
|
60
|
+
country: string;
|
|
61
|
+
store: StoreId;
|
|
62
|
+
appId: string;
|
|
63
|
+
/** Intents by status: missing = no assistant names the app, weak = some do, rarely. */
|
|
64
|
+
summary: {
|
|
65
|
+
winning: number;
|
|
66
|
+
weak: number;
|
|
67
|
+
missing: number;
|
|
68
|
+
noData: number;
|
|
69
|
+
};
|
|
70
|
+
intents: Array<{
|
|
71
|
+
label: string;
|
|
72
|
+
status: Opportunity["status"];
|
|
73
|
+
/** Share of prompts whose answer names the app, averaged over the assistants (0–100). */
|
|
74
|
+
you?: number;
|
|
75
|
+
byModel?: Partial<Record<Model, number>>;
|
|
76
|
+
/** Who the assistants name instead. */
|
|
77
|
+
namedInstead?: string[];
|
|
78
|
+
}>;
|
|
79
|
+
/** Pages the assistants cite, most cited first. */
|
|
80
|
+
sources: Array<{
|
|
81
|
+
domain: string;
|
|
82
|
+
count: number;
|
|
83
|
+
yours?: true;
|
|
84
|
+
url: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** What Appeeky suggests changing, still open. */
|
|
87
|
+
fixes: Array<{
|
|
88
|
+
intent: string;
|
|
89
|
+
title: string;
|
|
90
|
+
why: string;
|
|
91
|
+
page?: string;
|
|
92
|
+
}>;
|
|
93
|
+
}
|
|
94
|
+
export declare function fetchVisibility(api: AppeekyApi, target: VisibilityTarget, country: string, now?: Date): Promise<VisibilityFile>;
|
|
95
|
+
export declare const visibilityPath: (root: string, country: string) => string;
|
|
96
|
+
export declare function readVisibility(root: string, country: string): Promise<VisibilityFile | null>;
|
|
97
|
+
/** What moved since the last pull: an intent the assistants started or stopped naming the app for. */
|
|
98
|
+
export declare function visibilityChanges(prev: VisibilityFile, next: VisibilityFile): string[];
|
|
99
|
+
/** Pull every watched storefront and write its file. */
|
|
100
|
+
export declare function pullVisibility(api: AppeekyApi, project: Project, countries?: string[], now?: Date): Promise<Array<{
|
|
101
|
+
country: string;
|
|
102
|
+
file?: string;
|
|
103
|
+
data?: VisibilityFile;
|
|
104
|
+
changes: string[];
|
|
105
|
+
error?: string;
|
|
106
|
+
}>>;
|
|
107
|
+
/** The intents and prompts Appeeky tracks for a storefront, in the spec's shape. */
|
|
108
|
+
export declare function readRemoteIntents(api: AppeekyApi, appId: string, country: string): Promise<AiIntent[]>;
|
|
109
|
+
/** Ask Appeeky to write intents and prompts from the store listing (runs in the background). */
|
|
110
|
+
export declare function bootstrapIntents(api: AppeekyApi, appId: string, country: string, language: string): Promise<{
|
|
111
|
+
queued: boolean;
|
|
112
|
+
already: boolean;
|
|
113
|
+
}>;
|
|
114
|
+
export interface SyncPlan {
|
|
115
|
+
country: string;
|
|
116
|
+
addIntents: AiIntent[];
|
|
117
|
+
addPrompts: Array<{
|
|
118
|
+
intentId: string;
|
|
119
|
+
label: string;
|
|
120
|
+
text: string;
|
|
121
|
+
}>;
|
|
122
|
+
/** Tracked in Appeeky but not in the spec: archived only with --prune. */
|
|
123
|
+
extraIntents: Array<{
|
|
124
|
+
intentId: string;
|
|
125
|
+
label: string;
|
|
126
|
+
}>;
|
|
127
|
+
extraPrompts: Array<{
|
|
128
|
+
promptId: string;
|
|
129
|
+
label: string;
|
|
130
|
+
text: string;
|
|
131
|
+
}>;
|
|
132
|
+
}
|
|
133
|
+
/** Compare the spec's intents for a storefront with what Appeeky tracks. */
|
|
134
|
+
export declare function planSync(api: AppeekyApi, appId: string, country: string, wanted: AiIntent[]): Promise<SyncPlan>;
|
|
135
|
+
/** Apply a plan: add what the spec has; archive what it does not only when `prune` is set. */
|
|
136
|
+
export declare function applySync(api: AppeekyApi, appId: string, plan: SyncPlan, language: string, prune: boolean): Promise<{
|
|
137
|
+
intents: number;
|
|
138
|
+
prompts: number;
|
|
139
|
+
archived: number;
|
|
140
|
+
}>;
|
|
141
|
+
/** Ask the assistants now instead of waiting for the schedule. Charged in AI Visibility credits. */
|
|
142
|
+
export declare function scanVisibility(api: AppeekyApi, appId: string, country: string): Promise<unknown>;
|
|
143
|
+
/**
|
|
144
|
+
* `ai_visibility` around a push: the share of prompts whose answer names the
|
|
145
|
+
* app, averaged over the scanned days in the window and the assistants (0–1).
|
|
146
|
+
* Reads Appeeky's daily series once per assistant per run.
|
|
147
|
+
*/
|
|
148
|
+
export declare function visibilityMetricSource(api: AppeekyApi, project: Project): MetricSource | null;
|
|
149
|
+
export {};
|