@appeeky/spec 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +636 -2
  4. package/agents/appeeky-ads.md +50 -0
  5. package/agents/appeeky-aso.md +58 -0
  6. package/agents/appeeky-releases.md +47 -0
  7. package/agents/appeeky-reviews.md +53 -0
  8. package/agents/appeeky-social.md +54 -0
  9. package/agents/appeeky-ugc.md +56 -0
  10. package/agents/cofounder.md +36 -0
  11. package/dist/ads.d.ts +42 -0
  12. package/dist/ads.js +71 -0
  13. package/dist/agents.d.ts +47 -0
  14. package/dist/agents.js +88 -0
  15. package/dist/appeeky-api.d.ts +62 -0
  16. package/dist/appeeky-api.js +240 -0
  17. package/dist/attribution-setup.d.ts +28 -0
  18. package/dist/attribution-setup.js +65 -0
  19. package/dist/attribution.d.ts +121 -0
  20. package/dist/attribution.js +170 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +4 -0
  23. package/dist/category.d.ts +71 -0
  24. package/dist/category.js +198 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +1960 -0
  27. package/dist/competitors.d.ts +74 -0
  28. package/dist/competitors.js +143 -0
  29. package/dist/control.d.ts +21 -0
  30. package/dist/control.js +53 -0
  31. package/dist/core.d.ts +15 -0
  32. package/dist/core.js +15 -0
  33. package/dist/coverage.d.ts +46 -0
  34. package/dist/coverage.js +115 -0
  35. package/dist/data.d.ts +48 -0
  36. package/dist/data.js +298 -0
  37. package/dist/dataforseo.d.ts +139 -0
  38. package/dist/dataforseo.js +354 -0
  39. package/dist/diff.d.ts +11 -0
  40. package/dist/diff.js +33 -0
  41. package/dist/experiments.d.ts +115 -0
  42. package/dist/experiments.js +85 -0
  43. package/dist/files.d.ts +27 -0
  44. package/dist/files.js +129 -0
  45. package/dist/history.d.ts +43 -0
  46. package/dist/history.js +55 -0
  47. package/dist/index.d.ts +24 -0
  48. package/dist/index.js +24 -0
  49. package/dist/limits.d.ts +15 -0
  50. package/dist/limits.js +30 -0
  51. package/dist/lint.d.ts +16 -0
  52. package/dist/lint.js +159 -0
  53. package/dist/loops.d.ts +37 -0
  54. package/dist/loops.js +197 -0
  55. package/dist/measure.d.ts +40 -0
  56. package/dist/measure.js +159 -0
  57. package/dist/metrics.d.ts +290 -0
  58. package/dist/metrics.js +254 -0
  59. package/dist/output.d.ts +21 -0
  60. package/dist/output.js +79 -0
  61. package/dist/project.d.ts +19 -0
  62. package/dist/project.js +35 -0
  63. package/dist/report.d.ts +10 -0
  64. package/dist/report.js +113 -0
  65. package/dist/reviews.d.ts +130 -0
  66. package/dist/reviews.js +287 -0
  67. package/dist/schema.d.ts +612 -0
  68. package/dist/schema.js +253 -0
  69. package/dist/screenshots.d.ts +23 -0
  70. package/dist/screenshots.js +116 -0
  71. package/dist/search-ads.d.ts +137 -0
  72. package/dist/search-ads.js +354 -0
  73. package/dist/seo.d.ts +87 -0
  74. package/dist/seo.js +391 -0
  75. package/dist/setup.d.ts +45 -0
  76. package/dist/setup.js +259 -0
  77. package/dist/social.d.ts +129 -0
  78. package/dist/social.js +189 -0
  79. package/dist/storefronts.d.ts +15 -0
  80. package/dist/storefronts.js +89 -0
  81. package/dist/stores/app-store.d.ts +33 -0
  82. package/dist/stores/app-store.js +159 -0
  83. package/dist/stores/google-play.d.ts +13 -0
  84. package/dist/stores/google-play.js +61 -0
  85. package/dist/stores/types.d.ts +34 -0
  86. package/dist/stores/types.js +2 -0
  87. package/dist/suggest.d.ts +73 -0
  88. package/dist/suggest.js +135 -0
  89. package/dist/text.d.ts +18 -0
  90. package/dist/text.js +55 -0
  91. package/dist/transport.d.ts +99 -0
  92. package/dist/transport.js +170 -0
  93. package/dist/ugc-core.js +1 -0
  94. package/dist/ugc-local.d.ts +116 -0
  95. package/dist/ugc-local.js +174 -0
  96. package/dist/ugc.d.ts +149 -0
  97. package/dist/ugc.js +111 -0
  98. package/dist/verify.d.ts +70 -0
  99. package/dist/verify.js +134 -0
  100. package/dist/visibility-core.js +96 -0
  101. package/dist/visibility-local.d.ts +159 -0
  102. package/dist/visibility-local.js +246 -0
  103. package/dist/visibility.d.ts +152 -0
  104. package/dist/visibility.js +239 -0
  105. package/package.json +80 -4
  106. package/skills/appeeky-spec/SKILL.md +142 -0
  107. package/skills/appeeky-spec-experiments/SKILL.md +93 -0
  108. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  109. package/skills/appeeky-spec-review/SKILL.md +75 -0
  110. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  111. package/skills/appeeky-spec-visibility/SKILL.md +106 -0
@@ -0,0 +1,170 @@
1
+ /** Both the self-hosted server (plain JSON) and the Appeeky API (`{ data }`) answer here. */
2
+ const unwrap = (body) => (body && typeof body === "object" && "data" in body ? body.data : body);
3
+ export function selfHostedClient(config, token, fetcher = fetch, app = appIdsOf(config)[0]) {
4
+ if (!config.url)
5
+ throw new Error("attribution.url is needed with via: self (your Appeeky Analytics server).");
6
+ const base = `${config.url.replace(/\/$/, "")}/v1/apps/${encodeURIComponent(app)}`;
7
+ const call = async (view, init, query = {}) => {
8
+ const url = new URL(`${base}/${view}`);
9
+ for (const [k, v] of Object.entries(query))
10
+ url.searchParams.set(k, v);
11
+ const res = await fetcher(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers ?? {}) } });
12
+ const body = (await res.json().catch(() => null));
13
+ if (!res.ok)
14
+ throw new Error(`Attribution ${view}: ${body?.error?.message ?? `HTTP ${res.status}`}`);
15
+ return unwrap(body);
16
+ };
17
+ return {
18
+ label: "Appeeky Analytics (self-hosted)",
19
+ get: (view, query) => call(view, { method: "GET" }, query),
20
+ post: (view, body) => call(view, { method: "POST", body: JSON.stringify(body) }),
21
+ };
22
+ }
23
+ export function hostedClient(config, api, app = appIdsOf(config)[0]) {
24
+ const base = `/analytics/apps/${encodeURIComponent(app)}`;
25
+ return {
26
+ label: "Appeeky Analytics",
27
+ get: async (view, query) => unwrap(await api.get(`${base}/${view}`, query)),
28
+ post: async (view, body) => unwrap(await api.post(`${base}/${view}`, body)),
29
+ };
30
+ }
31
+ function appIdsOf(config) {
32
+ return typeof config.app === "string" ? [config.app] : [config.app["app-store"], config.app["google-play"]].filter((x) => Boolean(x));
33
+ }
34
+ /** The Appeeky Analytics app for each store: one id for every store in the spec, or one per store. */
35
+ export function attributionApps(spec) {
36
+ const config = spec.attribution;
37
+ if (!config)
38
+ return {};
39
+ if (typeof config.app !== "string")
40
+ return { ...config.app };
41
+ const out = {};
42
+ if (spec.stores.appStore)
43
+ out["app-store"] = config.app;
44
+ if (spec.stores.googlePlay)
45
+ out["google-play"] = config.app;
46
+ if (!out["app-store"] && !out["google-play"])
47
+ out["app-store"] = config.app;
48
+ return out;
49
+ }
50
+ /** A client per store the spec's `attribution:` covers, or why there are none. */
51
+ export function attributionClients(spec, api, env = process.env) {
52
+ const config = spec.attribution;
53
+ if (!config)
54
+ return { missing: "No attribution: in appeeky.spec.yaml." };
55
+ if (config.via === "self" && !env.ATTRIBUTION_TOKEN)
56
+ return { missing: "Set ATTRIBUTION_TOKEN to your Appeeky Analytics server's ADMIN_TOKEN." };
57
+ if (config.via !== "self" && !api)
58
+ return { missing: "Set APPEEKY_API_KEY to read attribution from Appeeky." };
59
+ const made = new Map();
60
+ const out = {};
61
+ for (const [store, app] of Object.entries(attributionApps(spec))) {
62
+ // One app id shared by both stores is one client.
63
+ if (!made.has(app))
64
+ made.set(app, config.via === "self" ? selfHostedClient(config, env.ATTRIBUTION_TOKEN, fetch, app) : hostedClient(config, api, app));
65
+ out[store] = made.get(app);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * The client for one store, or the first one. Without a store, Google Play comes first:
71
+ * its install referrer is what lets a smart link count installs.
72
+ */
73
+ export function attributionClient(spec, api, env = process.env, store) {
74
+ const clients = attributionClients(spec, api, env);
75
+ if ("missing" in clients)
76
+ return clients;
77
+ const client = store ? clients[store] : (clients["google-play"] ?? clients["app-store"]);
78
+ return client ?? { missing: `No attribution app for ${store} in appeeky.spec.yaml.` };
79
+ }
80
+ /** Which store the client `attributionClient` picks without one belongs to. */
81
+ export function linkStore(spec) {
82
+ const apps = attributionApps(spec);
83
+ return apps["google-play"] ? "google-play" : apps["app-store"] ? "app-store" : undefined;
84
+ }
85
+ export const isClient = (c) => !("missing" in c);
86
+ export const hasClients = (c) => !("missing" in c);
87
+ /** Installs from organic store search (App Store or Google Play), the ones a listing change moves. */
88
+ export function organicRow(summary) {
89
+ const rows = summary.bySource.filter((r) => r.source === "organic");
90
+ return {
91
+ installs: rows.reduce((n, r) => n + r.installs, 0),
92
+ payers: rows.reduce((n, r) => n + r.payers, 0),
93
+ revenueUsd: rows.reduce((n, r) => n + r.revenueUsd, 0),
94
+ };
95
+ }
96
+ const DAY = 86_400_000;
97
+ /**
98
+ * Organic installs and what they paid, around a push. The server answers for the
99
+ * installs first seen in the last N days, so a window that ends before today is the
100
+ * difference of two answers. Revenue counts what those installs paid up to now, so
101
+ * an older window has had longer to pay: a rise in organic revenue after a push is
102
+ * a conservative reading.
103
+ */
104
+ export function attributionMetricSource(clients, now = new Date()) {
105
+ const daysBack = (iso) => Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / DAY));
106
+ const forStore = (store) => ("get" in clients ? clients : clients[store]);
107
+ const any = "get" in clients ? clients : Object.values(clients)[0];
108
+ return {
109
+ label: any?.label ?? "Appeeky Analytics",
110
+ async window(store, _appId, from, to) {
111
+ const client = forStore(store);
112
+ if (!client)
113
+ return null;
114
+ const summary = (days) => client.get("summary", { days: String(days) });
115
+ const fromBack = daysBack(from);
116
+ const toBack = daysBack(to);
117
+ if (fromBack <= toBack || fromBack > 180)
118
+ return null;
119
+ const all = organicRow(await summary(fromBack));
120
+ const later = toBack > 0 ? organicRow(await summary(toBack)) : { installs: 0, payers: 0, revenueUsd: 0 };
121
+ const days = fromBack - toBack;
122
+ const installs = all.installs - later.installs;
123
+ if (installs <= 0)
124
+ return null;
125
+ return {
126
+ from,
127
+ to,
128
+ days,
129
+ values: {
130
+ organic_installs: installs / days,
131
+ organic_revenue: (all.revenueUsd - later.revenueUsd) / days,
132
+ organic_paid: (all.payers - later.payers) / installs,
133
+ },
134
+ currency: "USD",
135
+ };
136
+ },
137
+ };
138
+ }
139
+ /** A smart link for an experiment: one URL that sends each visitor to the right store, counted to the install. */
140
+ export async function createExperimentLink(client, experimentId, stores, channel) {
141
+ return client.post("links", {
142
+ name: experimentId,
143
+ campaign: experimentId,
144
+ ...(stores.appStoreId ? { iosUrl: `https://apps.apple.com/app/id${stores.appStoreId}` } : {}),
145
+ ...(stores.packageName ? { androidUrl: `https://play.google.com/store/apps/details?id=${stores.packageName}` } : {}),
146
+ ...(channel ? { channel } : {}),
147
+ });
148
+ }
149
+ /** Clicks, installs and revenue of one smart link over the last 30 days. */
150
+ export async function linkResult(client, slug) {
151
+ const data = await client.get("links", { range: "30d" });
152
+ return data?.links?.find((l) => l.link.slug === slug) ?? null;
153
+ }
154
+ /**
155
+ * On iOS the App Store passes nothing from a link click to the installed app, and the
156
+ * service does not fingerprint devices, so a new iOS install is never tied to a click.
157
+ * The SDK reads the link only on the app's first launch, so on iOS a link install is
158
+ * counted only when that first launch comes through the link (a deep link opening an
159
+ * app that was installed but never opened). Link installs are, in practice, Google
160
+ * Play installs (the install referrer). For an App Store only app, measure clicks, or
161
+ * organic installs around the post.
162
+ */
163
+ export const IOS_LINK_NOTE = " iOS: the App Store does not pass a link to a new install, so link installs and revenue are, in practice, Google Play\n" +
164
+ " installs. For this App Store only app use --metric link_clicks (or organic_installs).";
165
+ /** An installs or revenue metric on a link that can only send people to the App Store. */
166
+ export function iosOnlyLink(spec, metric) {
167
+ const ios = spec.attribution ? linkStore(spec) === "app-store" : Boolean(spec.stores.appStore && !spec.stores.googlePlay);
168
+ return ios && (metric === "link_installs" || metric === "link_revenue");
169
+ }
170
+ //# sourceMappingURL=attribution.js.map
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./cli.js";
3
+ await run();
4
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1,71 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { MetricSource } from "./metrics.js";
3
+ import type { Chart, Project, StoreId } from "./schema.js";
4
+ /**
5
+ * The app's rank in its category chart, one row per day, store and storefront in
6
+ * appeeky/category.csv. Ranks are public, so the file is committed with the spec
7
+ * and `verify` reads it as the `category_rank` metric.
8
+ *
9
+ * App Store charts come from Apple's public RSS (no key). Google Play charts come
10
+ * through the Appeeky API (APPEEKY_API_KEY).
11
+ */
12
+ /** Charts list the top 100 apps; an app below that is "not in the chart". */
13
+ export declare const CHART_SIZE = 100;
14
+ export declare const categoryFile: (root: string) => string;
15
+ export interface CategoryRank {
16
+ date: string;
17
+ /** Category name for people; not written to the file. */
18
+ name?: string;
19
+ store: StoreId;
20
+ country: string;
21
+ chart: Chart;
22
+ category: string;
23
+ /** null = not in the top 100. */
24
+ rank: number | null;
25
+ }
26
+ export interface ChartSource {
27
+ readonly label: string;
28
+ /** App ids in chart order, best first, or null when the chart could not be read. */
29
+ chart(store: StoreId, category: string, country: string, chart: Chart): Promise<string[] | null>;
30
+ }
31
+ /** Apple's public category charts: the real per-genre chart, not the overall one filtered. */
32
+ export declare function appleChartSource(fetcher?: typeof fetch): ChartSource;
33
+ /** Google Play category charts through the Appeeky API. */
34
+ export declare function playChartSource(api: AppeekyApi): ChartSource;
35
+ /** Both stores: Apple's RSS for the App Store, the Appeeky API for Google Play when there is a key. */
36
+ export declare function chartSources(api: AppeekyApi | undefined, fetcher?: typeof fetch): ChartSource;
37
+ /** The App Store app's genre and whether it is paid, from Apple's public lookup. */
38
+ export declare function appleAppInfo(appId: string, country: string, fetcher?: typeof fetch): Promise<{
39
+ genreId: string;
40
+ genre: string;
41
+ paid: boolean;
42
+ } | null>;
43
+ export interface CategoryPlan {
44
+ store: StoreId;
45
+ appId: string;
46
+ category: string;
47
+ chart: Chart;
48
+ countries: string[];
49
+ /** Category name for people, e.g. "Health & Fitness". */
50
+ name?: string;
51
+ }
52
+ /** Which charts to read: the configured category, or the App Store app's own genre. */
53
+ export declare function categoryPlans(project: Project, fetcher?: typeof fetch): Promise<{
54
+ plans: CategoryPlan[];
55
+ notes: string[];
56
+ }>;
57
+ /** Read every planned chart once and find the app in it. Charts that cannot be read are left out. */
58
+ export declare function readCategoryRanks(plans: CategoryPlan[], source: ChartSource, now?: Date): Promise<{
59
+ ranks: CategoryRank[];
60
+ failed: string[];
61
+ }>;
62
+ export declare function readCategoryHistory(root: string): Promise<CategoryRank[]>;
63
+ /** Add today's ranks to appeeky/category.csv (one row per day, store, storefront and chart). */
64
+ export declare function saveCategoryRanks(root: string, ranks: CategoryRank[]): Promise<string>;
65
+ /** The latest row before `date` for the same store, storefront and chart. */
66
+ export declare function previousRank(history: CategoryRank[], r: CategoryRank): CategoryRank | undefined;
67
+ /**
68
+ * `category_rank` from the recorded history: the average rank over the days in
69
+ * the window, across the recorded storefronts. Needs two recorded days.
70
+ */
71
+ export declare function categoryMetricSource(root: string): MetricSource;
@@ -0,0 +1,198 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { mergeCsv } from "./data.js";
5
+ import { SPEC_DIR } from "./files.js";
6
+ import { rankAppIds } from "./verify.js";
7
+ /**
8
+ * The app's rank in its category chart, one row per day, store and storefront in
9
+ * appeeky/category.csv. Ranks are public, so the file is committed with the spec
10
+ * and `verify` reads it as the `category_rank` metric.
11
+ *
12
+ * App Store charts come from Apple's public RSS (no key). Google Play charts come
13
+ * through the Appeeky API (APPEEKY_API_KEY).
14
+ */
15
+ /** Charts list the top 100 apps; an app below that is "not in the chart". */
16
+ export const CHART_SIZE = 100;
17
+ export const categoryFile = (root) => join(root, SPEC_DIR, "category.csv");
18
+ const RSS_FEED = {
19
+ "top-free": "topfreeapplications",
20
+ "top-paid": "toppaidapplications",
21
+ "top-grossing": "topgrossingapplications",
22
+ };
23
+ /** Apple's public category charts: the real per-genre chart, not the overall one filtered. */
24
+ export function appleChartSource(fetcher = fetch) {
25
+ return {
26
+ label: "App Store charts",
27
+ async chart(store, category, country, chart) {
28
+ if (store !== "app-store")
29
+ return null;
30
+ const url = `https://itunes.apple.com/${encodeURIComponent(country)}/rss/${RSS_FEED[chart]}/limit=${CHART_SIZE}/genre=${encodeURIComponent(category)}/json`;
31
+ const res = await fetcher(url, { headers: { "User-Agent": "appeeky-spec" } });
32
+ if (!res.ok)
33
+ return null;
34
+ const json = (await res.json().catch(() => null));
35
+ if (!json?.feed)
36
+ return null;
37
+ const entries = json.feed.entry === undefined ? [] : Array.isArray(json.feed.entry) ? json.feed.entry : [json.feed.entry];
38
+ return entries.map((e) => e.id?.attributes?.["im:id"]).filter((id) => Boolean(id));
39
+ },
40
+ };
41
+ }
42
+ /** Google Play category charts through the Appeeky API. */
43
+ export function playChartSource(api) {
44
+ return {
45
+ label: "Google Play charts (Appeeky API)",
46
+ async chart(store, category, country, chart) {
47
+ if (store !== "google-play")
48
+ return null;
49
+ const data = await api.get(`/categories/${encodeURIComponent(category)}/top`, {
50
+ platform: "google",
51
+ country,
52
+ chart,
53
+ limit: String(CHART_SIZE),
54
+ });
55
+ const apps = data?.apps ?? [];
56
+ // The API answers an empty list when it could not read the chart: unknown, not "unranked".
57
+ if (apps.length === 0)
58
+ return null;
59
+ return [...apps].sort((a, b) => a.rank - b.rank).map((a) => a.appId);
60
+ },
61
+ };
62
+ }
63
+ /** Both stores: Apple's RSS for the App Store, the Appeeky API for Google Play when there is a key. */
64
+ export function chartSources(api, fetcher = fetch) {
65
+ const apple = appleChartSource(fetcher);
66
+ const play = api ? playChartSource(api) : undefined;
67
+ return {
68
+ label: [apple.label, play?.label].filter(Boolean).join(" + "),
69
+ chart: (store, category, country, chart) => (store === "app-store" ? apple.chart(store, category, country, chart) : play ? play.chart(store, category, country, chart) : Promise.resolve(null)),
70
+ };
71
+ }
72
+ /** The App Store app's genre and whether it is paid, from Apple's public lookup. */
73
+ export async function appleAppInfo(appId, country, fetcher = fetch) {
74
+ const res = await fetcher(`https://itunes.apple.com/lookup?id=${encodeURIComponent(appId)}&country=${encodeURIComponent(country)}`, { headers: { "User-Agent": "appeeky-spec" } });
75
+ if (!res.ok)
76
+ return null;
77
+ const json = (await res.json().catch(() => null));
78
+ const app = json?.results?.[0];
79
+ if (!app?.primaryGenreId)
80
+ return null;
81
+ return { genreId: String(app.primaryGenreId), genre: app.primaryGenreName ?? String(app.primaryGenreId), paid: (app.price ?? 0) > 0 };
82
+ }
83
+ /** Which charts to read: the configured category, or the App Store app's own genre. */
84
+ export async function categoryPlans(project, fetcher = fetch) {
85
+ const cfg = project.spec.category ?? {};
86
+ const ids = rankAppIds(project);
87
+ const countries = cfg.countries ?? (Object.keys(project.spec.targets).length ? Object.keys(project.spec.targets) : ["us"]);
88
+ const plans = [];
89
+ const notes = [];
90
+ if (ids["app-store"]) {
91
+ let category = cfg.appStore;
92
+ let chart = cfg.chart;
93
+ const info = await appleAppInfo(ids["app-store"], countries[0], fetcher).catch(() => null);
94
+ category ??= info?.genreId;
95
+ chart ??= info?.paid ? "top-paid" : "top-free";
96
+ const name = info && info.genreId === category ? info.genre : undefined;
97
+ if (category)
98
+ plans.push({ store: "app-store", appId: ids["app-store"], category, chart, countries, ...(name ? { name } : {}) });
99
+ else
100
+ notes.push("App Store: could not read the app's genre. Set category.appStore in appeeky.spec.yaml (e.g. 6013).");
101
+ }
102
+ else if (project.spec.stores.appStore) {
103
+ notes.push("App Store: category charts need the numeric appId in stores.appStore.");
104
+ }
105
+ if (ids["google-play"]) {
106
+ if (cfg.googlePlay)
107
+ plans.push({ store: "google-play", appId: ids["google-play"], category: cfg.googlePlay, chart: cfg.chart ?? "top-free", countries, name: cfg.googlePlay.toLowerCase().replace(/_/g, " ") });
108
+ else
109
+ notes.push("Google Play: set category.googlePlay in appeeky.spec.yaml (e.g. HEALTH_AND_FITNESS) to record its chart.");
110
+ }
111
+ return { plans, notes };
112
+ }
113
+ /** Read every planned chart once and find the app in it. Charts that cannot be read are left out. */
114
+ export async function readCategoryRanks(plans, source, now = new Date()) {
115
+ const date = now.toISOString().slice(0, 10);
116
+ const ranks = [];
117
+ const failed = [];
118
+ for (const p of plans) {
119
+ for (const country of p.countries) {
120
+ try {
121
+ const ids = await source.chart(p.store, p.category, country, p.chart);
122
+ if (!ids) {
123
+ failed.push(`${p.store} ${country.toUpperCase()}: chart not available`);
124
+ continue;
125
+ }
126
+ const index = ids.indexOf(p.appId);
127
+ ranks.push({ date, store: p.store, country, chart: p.chart, category: p.category, rank: index >= 0 ? index + 1 : null, ...(p.name ? { name: p.name } : {}) });
128
+ }
129
+ catch (e) {
130
+ failed.push(`${p.store} ${country.toUpperCase()}: ${e.message}`);
131
+ }
132
+ }
133
+ }
134
+ return { ranks, failed };
135
+ }
136
+ const COLUMNS = ["date", "store", "country", "chart", "category", "rank"];
137
+ export async function readCategoryHistory(root) {
138
+ const file = categoryFile(root);
139
+ if (!existsSync(file))
140
+ return [];
141
+ const [head, ...lines] = (await readFile(file, "utf8")).trim().split("\n");
142
+ if (!head)
143
+ return [];
144
+ const cols = head.split(",");
145
+ return lines.map((line) => {
146
+ const v = Object.fromEntries(line.split(",").map((x, i) => [cols[i], x]));
147
+ return {
148
+ date: v.date ?? "",
149
+ store: v.store,
150
+ country: v.country ?? "",
151
+ chart: v.chart,
152
+ category: v.category ?? "",
153
+ rank: v.rank ? Number(v.rank) : null,
154
+ };
155
+ });
156
+ }
157
+ /** Add today's ranks to appeeky/category.csv (one row per day, store, storefront and chart). */
158
+ export async function saveCategoryRanks(root, ranks) {
159
+ const { path } = await mergeCsv(join(root, SPEC_DIR), {
160
+ name: "category",
161
+ about: "Rank in the category chart",
162
+ key: ["date", "store", "country", "chart"],
163
+ columns: COLUMNS,
164
+ rows: ranks.map((r) => ({ ...r, rank: r.rank ?? "" })),
165
+ });
166
+ return path;
167
+ }
168
+ /** The latest row before `date` for the same store, storefront and chart. */
169
+ export function previousRank(history, r) {
170
+ return history
171
+ .filter((h) => h.store === r.store && h.country === r.country && h.chart === r.chart && h.date < r.date)
172
+ .sort((a, b) => b.date.localeCompare(a.date))[0];
173
+ }
174
+ /** Not in the chart counts as just past its end, so "out → out" is no move. */
175
+ const asRank = (r) => r ?? CHART_SIZE + 1;
176
+ /**
177
+ * `category_rank` from the recorded history: the average rank over the days in
178
+ * the window, across the recorded storefronts. Needs two recorded days.
179
+ */
180
+ export function categoryMetricSource(root) {
181
+ let history;
182
+ return {
183
+ label: "Category charts in the repo",
184
+ async window(store, _appId, from, to) {
185
+ history ??= readCategoryHistory(root);
186
+ const rows = (await history).filter((h) => h.store === store && h.date >= from && h.date < to);
187
+ if (new Set(rows.map((r) => r.date)).size < 2)
188
+ return null;
189
+ // Every storefront out of the chart the whole time says nothing.
190
+ if (rows.every((r) => r.rank === null))
191
+ return null;
192
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
193
+ const values = { category_rank: rows.reduce((n, r) => n + asRank(r.rank), 0) / rows.length };
194
+ return { from, to, days, values };
195
+ },
196
+ };
197
+ }
198
+ //# sourceMappingURL=category.js.map
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function buildProgram(): Command;
3
+ export declare function run(argv?: string[]): Promise<void>;