@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,74 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { SpecFile, StoreId } from "./schema.js";
3
+ /**
4
+ * Competitors in the repo: their listing, the searches they rank for, and what
5
+ * their users say, one file per competitor in appeeky/competitors/. Pull again
6
+ * and the git diff shows what they changed.
7
+ */
8
+ export type Competitor = SpecFile["competitors"][number];
9
+ export declare const competitorsDir: (root: string) => string;
10
+ export declare function competitorSlug(c: Competitor): string;
11
+ export interface CompetitorStore {
12
+ id: string;
13
+ title?: string;
14
+ developer?: string;
15
+ rating?: number;
16
+ ratings?: number;
17
+ version?: string;
18
+ updated?: string;
19
+ releaseNotes?: string;
20
+ description?: string;
21
+ /** The searches it ranks for in `country`, best first. */
22
+ ranks: Array<{
23
+ keyword: string;
24
+ rank: number;
25
+ popularity?: number;
26
+ trend?: string;
27
+ }>;
28
+ /** Where it ranks for your own targets. */
29
+ yourTargets: Array<{
30
+ keyword: string;
31
+ rank: number;
32
+ }>;
33
+ recentReviews?: {
34
+ count: number;
35
+ average: number;
36
+ low: Array<{
37
+ rating: number;
38
+ text: string;
39
+ }>;
40
+ };
41
+ }
42
+ export interface CompetitorFile {
43
+ name: string;
44
+ pulledAt: string;
45
+ country: string;
46
+ stores: Partial<Record<StoreId, CompetitorStore>>;
47
+ }
48
+ export declare function fetchCompetitorStore(api: AppeekyApi, store: StoreId, id: string, country: string, targets: string[]): Promise<CompetitorStore>;
49
+ /**
50
+ * What a competitor changed since the last pull: its title, a new version, a rewritten
51
+ * description, or a climb into the top 10 for one of your targets.
52
+ */
53
+ export declare function competitorChanges(prev: CompetitorFile, next: CompetitorFile): string[];
54
+ export declare const changesFile: (root: string) => string;
55
+ /** Competitor changes recorded on or after a date, from changes.md. */
56
+ export declare function readChanges(root: string): Promise<Array<{
57
+ date: string;
58
+ name: string;
59
+ change: string;
60
+ }>>;
61
+ /** Pull every competitor listed in the spec and write its file. */
62
+ export declare function pullCompetitors(api: AppeekyApi, root: string, spec: SpecFile, now?: Date): Promise<Array<{
63
+ file: string;
64
+ errors: string[];
65
+ changes: string[];
66
+ }>>;
67
+ /** Apps like yours, to pick competitors from. */
68
+ export declare function similarApps(api: AppeekyApi, store: StoreId, id: string, country: string): Promise<Array<{
69
+ id: string;
70
+ title: string;
71
+ rating?: number;
72
+ ratings?: number;
73
+ }>>;
74
+ export declare function readCompetitor(file: string): Promise<CompetitorFile | null>;
@@ -0,0 +1,143 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { SPEC_DIR } from "./files.js";
6
+ import { normalize } from "./text.js";
7
+ export const competitorsDir = (root) => join(root, SPEC_DIR, "competitors");
8
+ export function competitorSlug(c) {
9
+ const base = c.name ?? c.appStore ?? c.googlePlay ?? "competitor";
10
+ return normalize(base).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "competitor";
11
+ }
12
+ const platform = (store) => (store === "app-store" ? "apple" : "google");
13
+ const trim = (s, n) => (s ? (s.length > n ? `${s.slice(0, n).trimEnd()}…` : s) : undefined);
14
+ const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ""));
15
+ export async function fetchCompetitorStore(api, store, id, country, targets) {
16
+ const q = { country, platform: platform(store) };
17
+ const app = (await api.get(`/apps/${encodeURIComponent(id)}`, q)) ?? {};
18
+ const kw = await api.get(`/apps/${encodeURIComponent(id)}/keywords`, q).catch(() => null);
19
+ const rv = await api.get(`/apps/${encodeURIComponent(id)}/reviews`, { ...q, sortBy: "mostRecent", page: "1" }).catch(() => null);
20
+ const ranked = (kw?.keywords ?? []).filter((k) => typeof k.rank === "number").sort((a, b) => a.rank - b.rank);
21
+ const wanted = new Set(targets.map((t) => normalize(t).trim()));
22
+ const reviews = rv?.reviews ?? [];
23
+ const m = app.metadata ?? {};
24
+ return clean({
25
+ id,
26
+ title: app.title ?? m.trackName,
27
+ developer: app.developer,
28
+ rating: round(app.averageUserRating ?? m.averageUserRating),
29
+ ratings: app.userRatingCount ?? m.userRatingCount,
30
+ version: m.version,
31
+ updated: m.currentVersionReleaseDate?.slice(0, 10),
32
+ releaseNotes: trim(m.releaseNotes, 300),
33
+ description: trim(m.description, 600),
34
+ ranks: ranked.slice(0, 25).map((k) => clean({ keyword: k.keyword, rank: k.rank, popularity: k.popularity ?? k.volumeScore, trend: k.trend })),
35
+ yourTargets: ranked.filter((k) => wanted.has(normalize(k.keyword).trim())).map((k) => ({ keyword: k.keyword, rank: k.rank })),
36
+ recentReviews: reviews.length
37
+ ? {
38
+ count: reviews.length,
39
+ average: round(reviews.reduce((n, r) => n + r.rating, 0) / reviews.length),
40
+ low: reviews
41
+ .filter((r) => r.rating <= 2)
42
+ .slice(0, 5)
43
+ .map((r) => ({ rating: r.rating, text: trim([r.title, r.content].filter(Boolean).join(". ").replace(/\s+/g, " "), 160) })),
44
+ }
45
+ : undefined,
46
+ });
47
+ }
48
+ function round(n) {
49
+ return n === undefined ? undefined : Math.round(n * 100) / 100;
50
+ }
51
+ /**
52
+ * What a competitor changed since the last pull: its title, a new version, a rewritten
53
+ * description, or a climb into the top 10 for one of your targets.
54
+ */
55
+ export function competitorChanges(prev, next) {
56
+ const out = [];
57
+ for (const store of ["app-store", "google-play"]) {
58
+ const a = prev.stores[store];
59
+ const b = next.stores[store];
60
+ if (!a || !b)
61
+ continue;
62
+ const where = store === "app-store" ? "App Store" : "Google Play";
63
+ if (a.title && b.title && a.title !== b.title)
64
+ out.push(`${where}: title "${a.title}" → "${b.title}"`);
65
+ if (a.version && b.version && a.version !== b.version)
66
+ out.push(`${where}: version ${b.version}${b.releaseNotes ? `: ${trim(b.releaseNotes.replace(/\s+/g, " "), 90)}` : ""}`);
67
+ if (a.description && b.description && a.description !== b.description)
68
+ out.push(`${where}: rewrote the description`);
69
+ const before = new Map(a.yourTargets.map((t) => [t.keyword, t.rank]));
70
+ for (const t of b.yourTargets) {
71
+ const was = before.get(t.keyword);
72
+ if (t.rank <= 10 && (was === undefined || was > 10))
73
+ out.push(`${where}: now #${t.rank} for "${t.keyword}" (was ${was === undefined ? "not ranked" : `#${was}`})`);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ export const changesFile = (root) => join(competitorsDir(root), "changes.md");
79
+ /** Add the day's changes to appeeky/competitors/changes.md, newest first. */
80
+ async function recordChanges(root, date, name, changes) {
81
+ if (changes.length === 0)
82
+ return;
83
+ const file = changesFile(root);
84
+ const old = existsSync(file) ? await readFile(file, "utf8") : "# Competitor changes\n\nWritten by `competitors pull`: what each competitor changed since the pull before.\n";
85
+ const [head, ...rest] = old.split(/\n(?=- )/);
86
+ const lines = changes.map((c) => `- ${date} · ${name} · ${c}`);
87
+ await writeFile(file, [head.trimEnd(), "", ...lines, ...rest.map((r) => r.trimEnd())].join("\n") + "\n");
88
+ }
89
+ /** Competitor changes recorded on or after a date, from changes.md. */
90
+ export async function readChanges(root) {
91
+ const file = changesFile(root);
92
+ if (!existsSync(file))
93
+ return [];
94
+ const out = [];
95
+ for (const line of (await readFile(file, "utf8")).split("\n")) {
96
+ const m = line.match(/^- (\d{4}-\d{2}-\d{2}) · (.+?) · (.+)$/);
97
+ if (m)
98
+ out.push({ date: m[1], name: m[2], change: m[3] });
99
+ }
100
+ return out;
101
+ }
102
+ /** Pull every competitor listed in the spec and write its file. */
103
+ export async function pullCompetitors(api, root, spec, now = new Date()) {
104
+ const country = Object.keys(spec.targets)[0] ?? "us";
105
+ const targets = spec.targets[country] ?? [];
106
+ const out = [];
107
+ await mkdir(competitorsDir(root), { recursive: true });
108
+ for (const c of spec.competitors) {
109
+ const data = { name: c.name ?? c.appStore ?? c.googlePlay ?? "competitor", pulledAt: now.toISOString().slice(0, 10), country, stores: {} };
110
+ const errors = [];
111
+ for (const [store, id] of [["app-store", c.appStore], ["google-play", c.googlePlay]]) {
112
+ if (!id)
113
+ continue;
114
+ try {
115
+ data.stores[store] = await fetchCompetitorStore(api, store, id, country, targets);
116
+ if (!c.name && data.stores[store]?.title)
117
+ data.name = data.stores[store].title;
118
+ }
119
+ catch (e) {
120
+ errors.push(`${store}: ${e.message}`);
121
+ }
122
+ }
123
+ const file = join(competitorsDir(root), `${competitorSlug({ ...c, name: data.name })}.yaml`);
124
+ let changes = [];
125
+ if (Object.keys(data.stores).length) {
126
+ const prev = await readCompetitor(file);
127
+ changes = prev ? competitorChanges(prev, data) : [];
128
+ await recordChanges(root, data.pulledAt, data.name, changes);
129
+ await writeFile(file, YAML.stringify(data, { lineWidth: 0 }));
130
+ }
131
+ out.push({ file, errors, changes });
132
+ }
133
+ return out;
134
+ }
135
+ /** Apps like yours, to pick competitors from. */
136
+ export async function similarApps(api, store, id, country) {
137
+ const data = await api.get(`/apps/${encodeURIComponent(id)}/similar`, { country, platform: platform(store) });
138
+ return (data ?? []).map((a) => clean({ id: String(a.appId), title: a.title, rating: a.rating, ratings: a.reviewsCount }));
139
+ }
140
+ export async function readCompetitor(file) {
141
+ return existsSync(file) ? YAML.parse(await readFile(file, "utf8")) : null;
142
+ }
143
+ //# sourceMappingURL=competitors.js.map
@@ -0,0 +1,21 @@
1
+ import type { ReleaseRecord } from "./history.js";
2
+ import type { Project } from "./schema.js";
3
+ import type { RankSnapshot } from "./verify.js";
4
+ export interface ControlComparison {
5
+ /** Storefronts whose indexed listing changed in this release. */
6
+ changed: string[];
7
+ /** Storefronts with targets whose listing did not change. */
8
+ control: string[];
9
+ /** Average places gained per target (positive is better), in each group. */
10
+ changedMove: number;
11
+ controlMove: number;
12
+ /** changedMove − controlMove: what the release did beyond the store-wide move. */
13
+ net: number;
14
+ }
15
+ /** Storefronts in the baseline whose listing this release changed. */
16
+ export declare function changedStorefronts(project: Project, release: ReleaseRecord, storefronts: string[]): string[];
17
+ /** The fewest targets on each side for the comparison to mean anything. */
18
+ export declare const MIN_CONTROL_TARGETS = 3;
19
+ export declare function compareWithControl(project: Project, release: ReleaseRecord, before: RankSnapshot, after: RankSnapshot): ControlComparison | null;
20
+ /** A gain smaller than this, beyond the control group's, is the store moving, not the release. */
21
+ export declare const MIN_NET_PLACES = 2;
@@ -0,0 +1,53 @@
1
+ import { indexedLocales, playLanguageFor } from "./storefronts.js";
2
+ /**
3
+ * A control group for a release: the storefronts whose listing did not change.
4
+ *
5
+ * A push to en-US changes what the US store reads; the German store reads de-DE and did not
6
+ * change. Ranks there still move with everything else: the store's algorithm, the season,
7
+ * competitors. Comparing the changed storefronts with the unchanged ones over the same days
8
+ * takes that common move out, so a store-wide lift does not count as the release's win.
9
+ */
10
+ /** Not ranked counts as just past the 200 results the stores return. */
11
+ const UNRANKED = 201;
12
+ const asRank = (r) => (r === null || r === undefined ? UNRANKED : r);
13
+ /** Storefronts in the baseline whose listing this release changed. */
14
+ export function changedStorefronts(project, release, storefronts) {
15
+ const locales = new Set(release.changes.map((c) => c.locale));
16
+ return storefronts.filter((cc) => {
17
+ if (release.store === "app-store")
18
+ return indexedLocales(project, cc).some((l) => locales.has(l));
19
+ const lang = playLanguageFor(project, cc);
20
+ return lang !== undefined && locales.has(lang);
21
+ });
22
+ }
23
+ function averageMove(before, after, storefronts) {
24
+ let sum = 0;
25
+ let n = 0;
26
+ for (const cc of storefronts)
27
+ for (const [keyword, b] of Object.entries(before[cc] ?? {})) {
28
+ if (!(keyword in (after[cc] ?? {})))
29
+ continue;
30
+ // Cap each move so one keyword going from unranked to #5 does not drown the rest.
31
+ sum += Math.max(-50, Math.min(50, asRank(b) - asRank(after[cc][keyword])));
32
+ n++;
33
+ }
34
+ return { move: n ? sum / n : 0, targets: n };
35
+ }
36
+ /** The fewest targets on each side for the comparison to mean anything. */
37
+ export const MIN_CONTROL_TARGETS = 3;
38
+ export function compareWithControl(project, release, before, after) {
39
+ const storefronts = Object.keys(before);
40
+ const changed = changedStorefronts(project, release, storefronts);
41
+ const control = storefronts.filter((cc) => !changed.includes(cc));
42
+ if (changed.length === 0 || control.length === 0)
43
+ return null;
44
+ const a = averageMove(before, after, changed);
45
+ const b = averageMove(before, after, control);
46
+ if (a.targets < MIN_CONTROL_TARGETS || b.targets < MIN_CONTROL_TARGETS)
47
+ return null;
48
+ const round = (x) => Math.round(x * 10) / 10;
49
+ return { changed, control, changedMove: round(a.move), controlMove: round(b.move), net: round(a.move - b.move) };
50
+ }
51
+ /** A gain smaller than this, beyond the control group's, is the store moving, not the release. */
52
+ export const MIN_NET_PLACES = 2;
53
+ //# sourceMappingURL=control.js.map
package/dist/core.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Browser-safe part of Appeeky Spec: schema, limits, lint rules, keyword
3
+ * coverage and diffing. No Node built-ins, so editors can run it in the UI.
4
+ */
5
+ export * from "./schema.js";
6
+ export * from "./limits.js";
7
+ export * from "./text.js";
8
+ export * from "./lint.js";
9
+ export * from "./coverage.js";
10
+ export * from "./storefronts.js";
11
+ export * from "./diff.js";
12
+ export * from "./suggest.js";
13
+ export * from "./verify.js";
14
+ export * from "./metrics.js";
15
+ export type { FieldChange, PushResult, RemoteState, StoreAdapter } from "./stores/types.js";
package/dist/core.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Browser-safe part of Appeeky Spec: schema, limits, lint rules, keyword
3
+ * coverage and diffing. No Node built-ins, so editors can run it in the UI.
4
+ */
5
+ export * from "./schema.js";
6
+ export * from "./limits.js";
7
+ export * from "./text.js";
8
+ export * from "./lint.js";
9
+ export * from "./coverage.js";
10
+ export * from "./storefronts.js";
11
+ export * from "./diff.js";
12
+ export * from "./suggest.js";
13
+ export * from "./verify.js";
14
+ export * from "./metrics.js";
15
+ //# sourceMappingURL=core.js.map
@@ -0,0 +1,46 @@
1
+ import type { Project, StoreId } from "./schema.js";
2
+ /**
3
+ * How well the listing covers a target search, strongest first.
4
+ * - `title`: the exact phrase is in the name (App Store) or title (Google Play)
5
+ * - `subtitle`: every word is in the name or subtitle (App Store)
6
+ * - `short`: every word is in the short description (Google Play)
7
+ * - `full`: every word is indexed somewhere in one locale
8
+ * - `split`: every word is indexed, but across different locales (App Store; may not combine)
9
+ * - `partial`: some words are indexed
10
+ * - `none`: no word is indexed
11
+ */
12
+ export type CoverageLevel = "title" | "subtitle" | "short" | "full" | "split" | "partial" | "none";
13
+ export interface KeywordCoverage {
14
+ keyword: string;
15
+ level: CoverageLevel;
16
+ /** Locale that gives the best level. */
17
+ locale?: string;
18
+ /** Words of the keyword the listing does not index. */
19
+ missing: string[];
20
+ /** 0–100 search popularity from the Appeeky API, when fetched. */
21
+ popularity?: number;
22
+ difficulty?: number;
23
+ }
24
+ export interface StorefrontCoverage {
25
+ store: StoreId;
26
+ country: string;
27
+ locales: string[];
28
+ keywords: KeywordCoverage[];
29
+ covered: number;
30
+ total: number;
31
+ /** Share of targets fully indexed (title, subtitle, short or full), 0–100. */
32
+ percent: number;
33
+ /** Same share weighted by popularity, when popularity is known for every target. */
34
+ weightedPercent?: number;
35
+ }
36
+ export declare function isCovered(level: CoverageLevel): boolean;
37
+ export type PopularityLookup = (keyword: string, country: string, store: StoreId) => {
38
+ popularity?: number;
39
+ difficulty?: number;
40
+ } | undefined;
41
+ /** App Store coverage of one storefront's targets across the locales that storefront indexes. */
42
+ export declare function appStoreCoverage(project: Project, country: string, lookup?: PopularityLookup): StorefrontCoverage;
43
+ /** Google Play coverage of one storefront's targets in the language that storefront most likely shows. */
44
+ export declare function googlePlayCoverage(project: Project, country: string, lookup?: PopularityLookup): StorefrontCoverage;
45
+ /** Coverage for every storefront with targets, for each store the spec ships to. */
46
+ export declare function projectCoverage(project: Project, lookup?: PopularityLookup): StorefrontCoverage[];
@@ -0,0 +1,115 @@
1
+ import { indexedLocales, playLanguageFor } from "./storefronts.js";
2
+ import { keyWords, keywordEntries, normalize, words } from "./text.js";
3
+ const RANK = { title: 6, subtitle: 5, short: 5, full: 4, split: 2, partial: 1, none: 0 };
4
+ const COVERED = new Set(["title", "subtitle", "short", "full"]);
5
+ export function isCovered(level) {
6
+ return COVERED.has(level);
7
+ }
8
+ function containsPhrase(haystack, phrase) {
9
+ if (phrase.length === 0)
10
+ return false;
11
+ outer: for (let i = 0; i + phrase.length <= haystack.length; i++) {
12
+ for (let j = 0; j < phrase.length; j++)
13
+ if (haystack[i + j] !== phrase[j])
14
+ continue outer;
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ function scoreAppStoreLocale(keyword, listing) {
20
+ const name = words(listing.name);
21
+ const titled = new Set([...name, ...words(listing.subtitle)]);
22
+ const all = new Set([...titled, ...keywordEntries(listing.keywords).flatMap((e) => words(e))]);
23
+ const missing = keyword.filter((w) => !all.has(w));
24
+ if (containsPhrase(name, keyword))
25
+ return { level: "title", missing: [] };
26
+ if (keyword.every((w) => titled.has(w)))
27
+ return { level: "subtitle", missing: [] };
28
+ if (missing.length === 0)
29
+ return { level: "full", missing };
30
+ if (missing.length < keyword.length)
31
+ return { level: "partial", missing };
32
+ return { level: "none", missing };
33
+ }
34
+ function scoreGooglePlay(keyword, listing) {
35
+ const title = words(listing.title);
36
+ const short = new Set(words(listing.shortDescription));
37
+ const all = new Set([...title, ...short, ...words(listing.fullDescription)]);
38
+ const missing = keyword.filter((w) => !all.has(w));
39
+ if (containsPhrase(title, keyword))
40
+ return { level: "title", missing: [] };
41
+ if (keyword.every((w) => short.has(w) || title.includes(w)))
42
+ return { level: "short", missing: [] };
43
+ if (missing.length === 0)
44
+ return { level: "full", missing };
45
+ if (missing.length < keyword.length)
46
+ return { level: "partial", missing };
47
+ return { level: "none", missing };
48
+ }
49
+ function summarize(store, country, locales, keywords) {
50
+ const covered = keywords.filter((k) => isCovered(k.level)).length;
51
+ const total = keywords.length;
52
+ const result = {
53
+ store,
54
+ country,
55
+ locales,
56
+ keywords,
57
+ covered,
58
+ total,
59
+ percent: total === 0 ? 0 : Math.round((covered / total) * 100),
60
+ };
61
+ if (total > 0 && keywords.every((k) => typeof k.popularity === "number")) {
62
+ const weight = keywords.reduce((s, k) => s + Math.max(k.popularity, 1), 0);
63
+ const hit = keywords.filter((k) => isCovered(k.level)).reduce((s, k) => s + Math.max(k.popularity, 1), 0);
64
+ result.weightedPercent = Math.round((hit / weight) * 100);
65
+ }
66
+ return result;
67
+ }
68
+ /** App Store coverage of one storefront's targets across the locales that storefront indexes. */
69
+ export function appStoreCoverage(project, country, lookup) {
70
+ const locales = indexedLocales(project, country);
71
+ const targets = project.spec.targets[country] ?? [];
72
+ const keywords = targets.map((raw) => {
73
+ const kw = keyWords(raw);
74
+ let best = { level: "none", missing: kw };
75
+ const union = new Set();
76
+ for (const locale of locales) {
77
+ const listing = project.listings["app-store"][locale] ?? {};
78
+ const scored = scoreAppStoreLocale(kw, listing);
79
+ kw.filter((w) => !scored.missing.includes(w)).forEach((w) => union.add(w));
80
+ if (RANK[scored.level] > RANK[best.level] || (RANK[scored.level] === RANK[best.level] && scored.missing.length < best.missing.length)) {
81
+ best = { ...scored, locale };
82
+ }
83
+ }
84
+ if (!isCovered(best.level) && kw.length > 0 && kw.every((w) => union.has(w))) {
85
+ best = { level: "split", missing: [], locale: undefined };
86
+ }
87
+ return { keyword: normalize(raw).trim(), level: best.level, locale: best.locale, missing: best.missing, ...lookup?.(raw, country, "app-store") };
88
+ });
89
+ return summarize("app-store", country, locales, keywords);
90
+ }
91
+ /** Google Play coverage of one storefront's targets in the language that storefront most likely shows. */
92
+ export function googlePlayCoverage(project, country, lookup) {
93
+ const language = playLanguageFor(project, country);
94
+ const listing = language ? (project.listings["google-play"][language] ?? {}) : {};
95
+ const targets = project.spec.targets[country] ?? [];
96
+ const keywords = targets.map((raw) => {
97
+ const scored = scoreGooglePlay(keyWords(raw), listing);
98
+ return { keyword: normalize(raw).trim(), level: scored.level, locale: language, missing: scored.missing, ...lookup?.(raw, country, "google-play") };
99
+ });
100
+ return summarize("google-play", country, language ? [language] : [], keywords);
101
+ }
102
+ /** Coverage for every storefront with targets, for each store the spec ships to. */
103
+ export function projectCoverage(project, lookup) {
104
+ const out = [];
105
+ for (const country of Object.keys(project.spec.targets)) {
106
+ if (project.spec.stores.appStore && Object.keys(project.listings["app-store"]).length > 0) {
107
+ out.push(appStoreCoverage(project, country, lookup));
108
+ }
109
+ if (project.spec.stores.googlePlay && Object.keys(project.listings["google-play"]).length > 0) {
110
+ out.push(googlePlayCoverage(project, country, lookup));
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ //# sourceMappingURL=coverage.js.map
package/dist/data.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { AttributionClient, AttributionClients } from "./attribution.js";
3
+ import type { Project } from "./schema.js";
4
+ /**
5
+ * Your app's numbers as plain CSV files an agent can read: the same sources Appeeky
6
+ * Desktop keeps locally (App Store Connect and Google Play analytics, Apple Search Ads,
7
+ * RevenueCat, Meta and TikTok), pulled through the connections in Appeeky.
8
+ *
9
+ * They are private business data, so they go to .appeeky/data/ (gitignored) unless the
10
+ * spec says `data: { commit: true }`, which writes them to appeeky/data/ instead.
11
+ * Pulling again merges by date, so a scheduled pull keeps one growing file per source.
12
+ */
13
+ export declare function dataDir(project: Project): string;
14
+ type Row = Record<string, string | number | null | undefined>;
15
+ export interface DataFile {
16
+ name: string;
17
+ /** What it holds, for the index. */
18
+ about: string;
19
+ key: string[];
20
+ columns: string[];
21
+ rows: Row[];
22
+ }
23
+ /** Merge rows into a CSV file by key: new rows replace old ones with the same key. */
24
+ export declare function mergeCsv(dir: string, file: DataFile): Promise<{
25
+ path: string;
26
+ rows: number;
27
+ from?: string;
28
+ to?: string;
29
+ }>;
30
+ /** [from, to] inclusive, split into chunks the API accepts. */
31
+ export declare function dateChunks(from: string, to: string, maxDays?: number): Array<[string, string]>;
32
+ export interface PullResult {
33
+ files: Array<{
34
+ name: string;
35
+ path: string;
36
+ rows: number;
37
+ from?: string;
38
+ to?: string;
39
+ about: string;
40
+ }>;
41
+ errors: Array<{
42
+ source: string;
43
+ message: string;
44
+ }>;
45
+ }
46
+ /** Every source this spec can read, over the `days` up to yesterday. */
47
+ export declare function pullData(api: AppeekyApi | undefined, project: Project, days: number, now?: Date, attributionIn?: AttributionClient | AttributionClients): Promise<PullResult>;
48
+ export {};