@appeeky/spec 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +636 -2
- package/agents/appeeky-ads.md +50 -0
- package/agents/appeeky-aso.md +58 -0
- package/agents/appeeky-releases.md +47 -0
- package/agents/appeeky-reviews.md +53 -0
- package/agents/appeeky-social.md +54 -0
- package/agents/appeeky-ugc.md +56 -0
- package/agents/cofounder.md +36 -0
- package/dist/ads.d.ts +42 -0
- package/dist/ads.js +71 -0
- package/dist/agents.d.ts +47 -0
- package/dist/agents.js +88 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1960 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/dataforseo.d.ts +139 -0
- package/dist/dataforseo.js +354 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +290 -0
- package/dist/metrics.js +254 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +10 -0
- package/dist/report.js +113 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +612 -0
- package/dist/schema.js +253 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/search-ads.d.ts +137 -0
- package/dist/search-ads.js +354 -0
- package/dist/seo.d.ts +87 -0
- package/dist/seo.js +391 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +259 -0
- package/dist/social.d.ts +129 -0
- package/dist/social.js +189 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +99 -0
- package/dist/transport.js +170 -0
- package/dist/ugc-core.js +1 -0
- package/dist/ugc-local.d.ts +116 -0
- package/dist/ugc-local.js +174 -0
- package/dist/ugc.d.ts +149 -0
- package/dist/ugc.js +111 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility-core.js +96 -0
- package/dist/visibility-local.d.ts +159 -0
- package/dist/visibility-local.js +246 -0
- package/dist/visibility.d.ts +152 -0
- package/dist/visibility.js +239 -0
- package/package.json +80 -4
- package/skills/appeeky-spec/SKILL.md +142 -0
- package/skills/appeeky-spec-experiments/SKILL.md +93 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +106 -0
package/dist/project.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { diffListings } from "./diff.js";
|
|
2
|
+
import { AppStoreAdapter } from "./stores/app-store.js";
|
|
3
|
+
import { GooglePlayAdapter } from "./stores/google-play.js";
|
|
4
|
+
import { createTransport } from "./transport.js";
|
|
5
|
+
/** Stores configured in the spec, optionally narrowed to one. */
|
|
6
|
+
export function configuredStores(project, only) {
|
|
7
|
+
const stores = [];
|
|
8
|
+
if (project.spec.stores.appStore)
|
|
9
|
+
stores.push("app-store");
|
|
10
|
+
if (project.spec.stores.googlePlay)
|
|
11
|
+
stores.push("google-play");
|
|
12
|
+
return only ? stores.filter((s) => s === only) : stores;
|
|
13
|
+
}
|
|
14
|
+
export function storeHandle(project, store, overrides = {}, transportOverride) {
|
|
15
|
+
if (store === "app-store") {
|
|
16
|
+
const cfg = project.spec.stores.appStore;
|
|
17
|
+
if (!cfg)
|
|
18
|
+
throw new Error("The spec has no stores.appStore section.");
|
|
19
|
+
const transport = transportOverride ?? createTransport(store, { via: cfg.via, account: cfg.account, ...overrides });
|
|
20
|
+
return { store, transport, adapter: new AppStoreAdapter(transport, cfg.appId, cfg.platform) };
|
|
21
|
+
}
|
|
22
|
+
const cfg = project.spec.stores.googlePlay;
|
|
23
|
+
if (!cfg)
|
|
24
|
+
throw new Error("The spec has no stores.googlePlay section.");
|
|
25
|
+
const transport = transportOverride ?? createTransport(store, { via: cfg.via, account: cfg.account ?? cfg.profile, ...overrides });
|
|
26
|
+
return { store, transport, adapter: new GooglePlayAdapter(transport, cfg.packageName) };
|
|
27
|
+
}
|
|
28
|
+
/** Pull the store, diff it with the spec and mark what cannot be pushed yet. */
|
|
29
|
+
export async function planStore(project, handle) {
|
|
30
|
+
const state = await handle.adapter.pull();
|
|
31
|
+
const local = project.listings[handle.store];
|
|
32
|
+
const changes = handle.adapter.annotate(diffListings(handle.store, local, state.listings), state, local);
|
|
33
|
+
return { store: handle.store, via: handle.transport.label, state, changes };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=project.js.map
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Project } from "./schema.js";
|
|
2
|
+
/** Load the spec as it was at a git ref (e.g. `origin/main`), or null when it did not exist there. */
|
|
3
|
+
export declare function loadProjectAtRef(root: string, ref: string): Promise<Project | null>;
|
|
4
|
+
/** Markdown for a pull request: listing changes, check results and coverage before → after. */
|
|
5
|
+
export declare function specReport(base: Project | null, head: Project): string;
|
|
6
|
+
/**
|
|
7
|
+
* The posts and clips a merge sends out: social drafts and UGC briefs still marked draft, so a
|
|
8
|
+
* reviewer sees them next to the listing change. Empty when there are none.
|
|
9
|
+
*/
|
|
10
|
+
export declare function outboxReport(root: string, now?: Date): Promise<string>;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { projectCoverage } from "./coverage.js";
|
|
7
|
+
import { diffListings } from "./diff.js";
|
|
8
|
+
import { loadProject, SPEC_DIR, SPEC_FILE } from "./files.js";
|
|
9
|
+
import { lintProject } from "./lint.js";
|
|
10
|
+
import { STORES } from "./schema.js";
|
|
11
|
+
import { draftIssues, readAccounts, readDrafts } from "./social.js";
|
|
12
|
+
import { readBriefs } from "./ugc.js";
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
/** Load the spec as it was at a git ref (e.g. `origin/main`), or null when it did not exist there. */
|
|
15
|
+
export async function loadProjectAtRef(root, ref) {
|
|
16
|
+
const { stdout: top } = await run("git", ["rev-parse", "--show-toplevel"], { cwd: root });
|
|
17
|
+
const prefix = relative(await realpath(top.trim()), await realpath(root));
|
|
18
|
+
const paths = [SPEC_FILE, SPEC_DIR].map((p) => (prefix ? `${prefix}/${p}` : p));
|
|
19
|
+
const dir = await mkdtemp(join(tmpdir(), "appeeky-spec-base-"));
|
|
20
|
+
try {
|
|
21
|
+
const archive = await run("git", ["archive", "--format=tar", ref, "--", ...paths], { cwd: top.trim(), encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }).catch(() => null);
|
|
22
|
+
if (!archive)
|
|
23
|
+
return null;
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
const tar = execFile("tar", ["-x", "-C", dir], (err) => (err ? reject(err) : resolve()));
|
|
26
|
+
tar.stdin.end(archive.stdout);
|
|
27
|
+
});
|
|
28
|
+
return await loadProject(join(dir, prefix));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
// loadProject read everything into memory; the copy is no longer needed.
|
|
35
|
+
await rm(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const key = (r) => `${r.store}:${r.country}`;
|
|
39
|
+
const label = (r) => `${r.store === "app-store" ? "App Store" : "Google Play"} ${r.country.toUpperCase()}`;
|
|
40
|
+
/** Markdown for a pull request: listing changes, check results and coverage before → after. */
|
|
41
|
+
export function specReport(base, head) {
|
|
42
|
+
const out = ["## Appeeky Spec"];
|
|
43
|
+
const changes = STORES.flatMap((store) => diffListings(store, head.listings[store], (base?.listings[store] ?? {})));
|
|
44
|
+
out.push("", `**${changes.length} listing field${changes.length === 1 ? "" : "s"} changed**`);
|
|
45
|
+
if (changes.length > 0) {
|
|
46
|
+
out.push("", "| Store | Locale | Field | After |", "| --- | --- | --- | --- |");
|
|
47
|
+
for (const c of changes.slice(0, 40)) {
|
|
48
|
+
out.push(`| ${c.store} | ${c.locale} | ${c.field} | ${c.after.replace(/\n+/g, " ").replace(/\|/g, "\\|").slice(0, 90)} |`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const issues = lintProject(head).filter((i) => i.severity !== "info");
|
|
52
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
53
|
+
out.push("", `**Check:** ${errors} error${errors === 1 ? "" : "s"}, ${issues.length - errors} warning${issues.length - errors === 1 ? "" : "s"}`);
|
|
54
|
+
for (const i of issues.slice(0, 20))
|
|
55
|
+
out.push(`- ${i.severity === "error" ? "❌" : "⚠️"} ${i.store} ${i.locale}${i.field ? ` ${i.field}` : ""}: ${i.message}`);
|
|
56
|
+
const before = new Map((base ? projectCoverage(base) : []).map((r) => [key(r), r]));
|
|
57
|
+
const after = projectCoverage(head);
|
|
58
|
+
if (after.length > 0) {
|
|
59
|
+
out.push("", "| Storefront | Coverage | Gained | Lost |", "| --- | --- | --- | --- |");
|
|
60
|
+
for (const r of after) {
|
|
61
|
+
const b = before.get(key(r));
|
|
62
|
+
const covered = (x) => new Set((x?.keywords ?? []).filter((k) => ["title", "subtitle", "short", "full"].includes(k.level)).map((k) => k.keyword));
|
|
63
|
+
const was = covered(b);
|
|
64
|
+
const now = covered(r);
|
|
65
|
+
const gained = [...now].filter((k) => !was.has(k));
|
|
66
|
+
const lost = [...was].filter((k) => !now.has(k));
|
|
67
|
+
const pct = b ? `${b.percent}% → ${r.percent}%` : `${r.percent}%`;
|
|
68
|
+
out.push(`| ${label(r)} | ${pct} | ${gained.join(", ") || "—"} | ${lost.length > 0 ? `**${lost.join(", ")}**` : "—"} |`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out.join("\n");
|
|
72
|
+
}
|
|
73
|
+
const cell = (s, n = 90) => s.replace(/\n+/g, " ").replace(/\|/g, "\\|").slice(0, n);
|
|
74
|
+
/**
|
|
75
|
+
* The posts and clips a merge sends out: social drafts and UGC briefs still marked draft, so a
|
|
76
|
+
* reviewer sees them next to the listing change. Empty when there are none.
|
|
77
|
+
*/
|
|
78
|
+
export async function outboxReport(root, now = new Date()) {
|
|
79
|
+
const out = [];
|
|
80
|
+
const rel = (f) => relative(root, f);
|
|
81
|
+
try {
|
|
82
|
+
const drafts = (await readDrafts(root)).filter((d) => d.status === "draft");
|
|
83
|
+
if (drafts.length > 0) {
|
|
84
|
+
const accounts = await readAccounts(root);
|
|
85
|
+
out.push("", `**${drafts.length} post${drafts.length === 1 ? " goes" : "s go"} out on merge**`, "", "| Draft | Accounts | When | Text |", "| --- | --- | --- | --- |");
|
|
86
|
+
for (const d of drafts)
|
|
87
|
+
out.push(`| ${rel(d.file)} | ${d.accounts.join(", ")} | ${d.when ?? "on merge"} | ${cell(d.text)} |`);
|
|
88
|
+
if (accounts.length === 0)
|
|
89
|
+
out.push("", "- ⚠️ No accounts in appeeky/social/accounts.yaml: run `social pull` so the drafts can be checked.");
|
|
90
|
+
else
|
|
91
|
+
for (const d of drafts)
|
|
92
|
+
for (const issue of draftIssues(d, accounts, now))
|
|
93
|
+
out.push(`- ⚠️ ${rel(d.file)}: ${issue}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
out.push("", `- ❌ Social drafts: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const briefs = (await readBriefs(root)).filter((b) => b.status === "draft");
|
|
101
|
+
if (briefs.length > 0) {
|
|
102
|
+
out.push("", `**${briefs.length} clip${briefs.length === 1 ? "" : "s"} made on merge**`, "", "| Brief | Persona | Length | Script |", "| --- | --- | --- | --- |");
|
|
103
|
+
for (const b of briefs)
|
|
104
|
+
out.push(`| ${rel(b.file)} | ${b.persona} | ${b.durationSec ?? 8}s ${b.aspectRatio ?? "9:16"} | ${cell(b.script)} |`);
|
|
105
|
+
out.push("", "Run `ugc generate --dry-run` (or `--local --dry-run`) for the cost.");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
out.push("", `- ❌ UGC briefs: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
return out.join("\n");
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AppeekyApi } from "./appeeky-api.js";
|
|
3
|
+
import type { MetricSource } from "./metrics.js";
|
|
4
|
+
import type { StoreId } from "./schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* Customer reviews in the repo, so an agent can read what users say next to the
|
|
7
|
+
* listing, and answer them through a pull request like any other change.
|
|
8
|
+
*
|
|
9
|
+
* appeeky/reviews/<store>/<YYYY-MM>.jsonl one review per line, newest first
|
|
10
|
+
* appeeky/reviews/summary.md ratings, replies, versions, last 30 days
|
|
11
|
+
* appeeky/reviews/replies/<id>.yaml a reply: drafted, reviewed in a PR, then sent
|
|
12
|
+
*
|
|
13
|
+
* Reviews come from the store connections in Appeeky (APPEEKY_API_KEY).
|
|
14
|
+
*/
|
|
15
|
+
export interface Review {
|
|
16
|
+
id: string;
|
|
17
|
+
store: StoreId;
|
|
18
|
+
rating: number;
|
|
19
|
+
title?: string;
|
|
20
|
+
body: string;
|
|
21
|
+
author?: string;
|
|
22
|
+
/** App Store territory (US) or Google Play language (en). */
|
|
23
|
+
where?: string;
|
|
24
|
+
version?: string;
|
|
25
|
+
date: string;
|
|
26
|
+
reply?: {
|
|
27
|
+
body: string;
|
|
28
|
+
date?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare function reviewsDir(root: string): string;
|
|
32
|
+
interface AscReviewRow {
|
|
33
|
+
id: string;
|
|
34
|
+
rating: number;
|
|
35
|
+
title: string | null;
|
|
36
|
+
body: string | null;
|
|
37
|
+
reviewer_nickname: string | null;
|
|
38
|
+
territory: string | null;
|
|
39
|
+
app_version: string | null;
|
|
40
|
+
review_created_at: string;
|
|
41
|
+
response_body: string | null;
|
|
42
|
+
response_last_modified: string | null;
|
|
43
|
+
}
|
|
44
|
+
interface PlayReviewRow {
|
|
45
|
+
review_id: string;
|
|
46
|
+
author_name: string | null;
|
|
47
|
+
reviewer_language: string | null;
|
|
48
|
+
star_rating: number;
|
|
49
|
+
review_text: string | null;
|
|
50
|
+
review_last_modified_at: string;
|
|
51
|
+
app_version_name: string | null;
|
|
52
|
+
developer_reply_text: string | null;
|
|
53
|
+
developer_reply_last_modified_at: string | null;
|
|
54
|
+
}
|
|
55
|
+
export declare function fromAsc(r: AscReviewRow): Review;
|
|
56
|
+
export declare function fromPlay(r: PlayReviewRow): Review;
|
|
57
|
+
/** Newest reviews first, paging back until `since` (or `max` reviews). */
|
|
58
|
+
export declare function fetchReviews(api: AppeekyApi, store: StoreId, appId: string, options?: {
|
|
59
|
+
since?: string;
|
|
60
|
+
max?: number;
|
|
61
|
+
}): Promise<Review[]>;
|
|
62
|
+
export declare function readReviews(root: string, store?: StoreId): Promise<Review[]>;
|
|
63
|
+
/**
|
|
64
|
+
* Merge fetched reviews into the monthly files: a review that changed (edited,
|
|
65
|
+
* answered) replaces the old line. Returns how many were new and how many changed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function saveReviews(root: string, fetched: Review[]): Promise<{
|
|
68
|
+
added: number;
|
|
69
|
+
updated: number;
|
|
70
|
+
}>;
|
|
71
|
+
export interface ReviewStats {
|
|
72
|
+
count: number;
|
|
73
|
+
average: number | null;
|
|
74
|
+
/** Share of 1- and 2-star reviews. */
|
|
75
|
+
low: number | null;
|
|
76
|
+
unanswered: number;
|
|
77
|
+
distribution: Record<1 | 2 | 3 | 4 | 5, number>;
|
|
78
|
+
}
|
|
79
|
+
export declare function reviewStats(reviews: Review[]): ReviewStats;
|
|
80
|
+
/**
|
|
81
|
+
* What users ask for in their own words: words and two-word phrases that come up in three or
|
|
82
|
+
* more reviews and appear nowhere in your listing. Candidates for targets, or for the product.
|
|
83
|
+
* English stop words only, so other languages show more noise.
|
|
84
|
+
*/
|
|
85
|
+
export declare function reviewPhrases(reviews: Review[], listingText: string, limit?: number): Array<{
|
|
86
|
+
phrase: string;
|
|
87
|
+
reviews: number;
|
|
88
|
+
low: number;
|
|
89
|
+
}>;
|
|
90
|
+
export declare function summaryMarkdown(reviews: Review[], now?: Date, listingText?: string): string;
|
|
91
|
+
export declare function writeSummary(root: string, now?: Date, listingText?: string): Promise<string>;
|
|
92
|
+
export declare const replySchema: z.ZodObject<{
|
|
93
|
+
id: z.ZodString;
|
|
94
|
+
store: z.ZodEnum<["app-store", "google-play"]>;
|
|
95
|
+
/** The review being answered, for whoever reviews the pull request. */
|
|
96
|
+
review: z.ZodOptional<z.ZodString>;
|
|
97
|
+
text: z.ZodString;
|
|
98
|
+
status: z.ZodDefault<z.ZodEnum<["draft", "sent"]>>;
|
|
99
|
+
sentAt: z.ZodOptional<z.ZodString>;
|
|
100
|
+
}, "strict", z.ZodTypeAny, {
|
|
101
|
+
status: "draft" | "sent";
|
|
102
|
+
store: "app-store" | "google-play";
|
|
103
|
+
id: string;
|
|
104
|
+
text: string;
|
|
105
|
+
sentAt?: string | undefined;
|
|
106
|
+
review?: string | undefined;
|
|
107
|
+
}, {
|
|
108
|
+
store: "app-store" | "google-play";
|
|
109
|
+
id: string;
|
|
110
|
+
text: string;
|
|
111
|
+
status?: "draft" | "sent" | undefined;
|
|
112
|
+
sentAt?: string | undefined;
|
|
113
|
+
review?: string | undefined;
|
|
114
|
+
}>;
|
|
115
|
+
export type Reply = z.infer<typeof replySchema>;
|
|
116
|
+
export declare const repliesDir: (root: string) => string;
|
|
117
|
+
export declare function writeReply(root: string, reply: z.input<typeof replySchema>): Promise<string>;
|
|
118
|
+
export declare function readReplies(root: string): Promise<Array<Reply & {
|
|
119
|
+
file: string;
|
|
120
|
+
}>>;
|
|
121
|
+
/** Store limits for a developer reply. */
|
|
122
|
+
export declare const REPLY_LIMIT: Record<StoreId, number>;
|
|
123
|
+
export declare function replyIssues(reply: Reply): string[];
|
|
124
|
+
/** Send one draft reply to the store and mark it sent. */
|
|
125
|
+
export declare function sendReply(api: AppeekyApi, reply: Reply & {
|
|
126
|
+
file: string;
|
|
127
|
+
}, packageName: string | undefined, now?: Date): Promise<void>;
|
|
128
|
+
/** Ratings around a push, from the pulled review files: no API call. */
|
|
129
|
+
export declare function reviewMetricSource(root: string): MetricSource;
|
|
130
|
+
export {};
|
package/dist/reviews.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { words } from "./text.js";
|
|
7
|
+
import { SPEC_DIR, SpecError } from "./files.js";
|
|
8
|
+
export function reviewsDir(root) {
|
|
9
|
+
return join(root, SPEC_DIR, "reviews");
|
|
10
|
+
}
|
|
11
|
+
const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined && v !== ""));
|
|
12
|
+
export function fromAsc(r) {
|
|
13
|
+
return clean({
|
|
14
|
+
id: String(r.id),
|
|
15
|
+
store: "app-store",
|
|
16
|
+
rating: r.rating,
|
|
17
|
+
title: r.title ?? undefined,
|
|
18
|
+
body: r.body ?? "",
|
|
19
|
+
author: r.reviewer_nickname ?? undefined,
|
|
20
|
+
where: r.territory ?? undefined,
|
|
21
|
+
version: r.app_version ?? undefined,
|
|
22
|
+
date: r.review_created_at,
|
|
23
|
+
reply: r.response_body ? clean({ body: r.response_body, date: r.response_last_modified ?? undefined }) : undefined,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function fromPlay(r) {
|
|
27
|
+
return clean({
|
|
28
|
+
id: String(r.review_id),
|
|
29
|
+
store: "google-play",
|
|
30
|
+
rating: r.star_rating,
|
|
31
|
+
body: r.review_text ?? "",
|
|
32
|
+
author: r.author_name ?? undefined,
|
|
33
|
+
where: r.reviewer_language ?? undefined,
|
|
34
|
+
version: r.app_version_name ?? undefined,
|
|
35
|
+
date: r.review_last_modified_at,
|
|
36
|
+
reply: r.developer_reply_text ? clean({ body: r.developer_reply_text, date: r.developer_reply_last_modified_at ?? undefined }) : undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Newest reviews first, paging back until `since` (or `max` reviews). */
|
|
40
|
+
export async function fetchReviews(api, store, appId, options = {}) {
|
|
41
|
+
const max = options.max ?? 2000;
|
|
42
|
+
const out = [];
|
|
43
|
+
let before;
|
|
44
|
+
while (out.length < max) {
|
|
45
|
+
const query = { limit: "200", ...(before ? { before } : {}) };
|
|
46
|
+
const page = store === "app-store"
|
|
47
|
+
? ((await api.get("/connect/reviews", { ...query, appId }))?.reviews ?? []).map(fromAsc)
|
|
48
|
+
: ((await api.get("/connect/google-play/reviews", { ...query, packageName: appId }))?.reviews ?? []).map(fromPlay);
|
|
49
|
+
const fresh = options.since ? page.filter((r) => r.date >= options.since) : page;
|
|
50
|
+
out.push(...fresh);
|
|
51
|
+
if (page.length < 200 || fresh.length < page.length)
|
|
52
|
+
break;
|
|
53
|
+
before = page[page.length - 1].date;
|
|
54
|
+
}
|
|
55
|
+
return out.slice(0, max);
|
|
56
|
+
}
|
|
57
|
+
const monthOf = (iso) => iso.slice(0, 7);
|
|
58
|
+
export async function readReviews(root, store) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const s of store ? [store] : ["app-store", "google-play"]) {
|
|
61
|
+
const dir = join(reviewsDir(root), s);
|
|
62
|
+
if (!existsSync(dir))
|
|
63
|
+
continue;
|
|
64
|
+
for (const f of (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).sort()) {
|
|
65
|
+
for (const line of (await readFile(join(dir, f), "utf8")).split("\n")) {
|
|
66
|
+
if (line.trim())
|
|
67
|
+
out.push(JSON.parse(line));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out.sort((a, b) => b.date.localeCompare(a.date));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Merge fetched reviews into the monthly files: a review that changed (edited,
|
|
75
|
+
* answered) replaces the old line. Returns how many were new and how many changed.
|
|
76
|
+
*/
|
|
77
|
+
export async function saveReviews(root, fetched) {
|
|
78
|
+
const existing = new Map((await readReviews(root)).map((r) => [`${r.store}:${r.id}`, r]));
|
|
79
|
+
let added = 0;
|
|
80
|
+
let updated = 0;
|
|
81
|
+
for (const r of fetched) {
|
|
82
|
+
const key = `${r.store}:${r.id}`;
|
|
83
|
+
const old = existing.get(key);
|
|
84
|
+
if (!old)
|
|
85
|
+
added++;
|
|
86
|
+
else if (JSON.stringify(old) !== JSON.stringify(r))
|
|
87
|
+
updated++;
|
|
88
|
+
existing.set(key, r);
|
|
89
|
+
}
|
|
90
|
+
const files = new Map();
|
|
91
|
+
for (const r of existing.values()) {
|
|
92
|
+
const file = join(reviewsDir(root), r.store, `${monthOf(r.date)}.jsonl`);
|
|
93
|
+
files.set(file, [...(files.get(file) ?? []), r]);
|
|
94
|
+
}
|
|
95
|
+
for (const [file, rows] of files) {
|
|
96
|
+
await mkdir(join(file, ".."), { recursive: true });
|
|
97
|
+
rows.sort((a, b) => b.date.localeCompare(a.date));
|
|
98
|
+
await writeFile(file, rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
99
|
+
}
|
|
100
|
+
return { added, updated };
|
|
101
|
+
}
|
|
102
|
+
export function reviewStats(reviews) {
|
|
103
|
+
const distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
104
|
+
for (const r of reviews)
|
|
105
|
+
if (r.rating >= 1 && r.rating <= 5)
|
|
106
|
+
distribution[r.rating]++;
|
|
107
|
+
const count = reviews.length;
|
|
108
|
+
return {
|
|
109
|
+
count,
|
|
110
|
+
average: count ? reviews.reduce((n, r) => n + r.rating, 0) / count : null,
|
|
111
|
+
low: count ? (distribution[1] + distribution[2]) / count : null,
|
|
112
|
+
unanswered: reviews.filter((r) => !r.reply).length,
|
|
113
|
+
distribution,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const fmt = (n, digits = 2) => (n === null ? "—" : n.toFixed(digits));
|
|
117
|
+
const pct = (n) => (n === null ? "—" : `${Math.round(n * 100)}%`);
|
|
118
|
+
/** `appeeky/reviews/summary.md`: what users say, in numbers, for people and agents. */
|
|
119
|
+
// Words that say nothing about what users want.
|
|
120
|
+
const STOP = new Set("a an and are as at be but by can do does for from get got has have i if in is it its it's just me my no not of on or so that the this to too was we were what when with would you your app apps very really love like great good nice best please thanks thank use using used one all also only much more can't don't doesn't didn't i'm ive i've it's".split(" "));
|
|
121
|
+
/**
|
|
122
|
+
* What users ask for in their own words: words and two-word phrases that come up in three or
|
|
123
|
+
* more reviews and appear nowhere in your listing. Candidates for targets, or for the product.
|
|
124
|
+
* English stop words only, so other languages show more noise.
|
|
125
|
+
*/
|
|
126
|
+
export function reviewPhrases(reviews, listingText, limit = 10) {
|
|
127
|
+
const listing = ` ${words(listingText).join(" ")} `;
|
|
128
|
+
const counts = new Map();
|
|
129
|
+
for (const r of reviews) {
|
|
130
|
+
const ws = words(`${r.title ?? ""} ${r.body}`).filter((w) => w.length > 2);
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
for (let i = 0; i < ws.length; i++) {
|
|
133
|
+
if (!STOP.has(ws[i]))
|
|
134
|
+
seen.add(ws[i]);
|
|
135
|
+
if (i + 1 < ws.length && !STOP.has(ws[i]) && !STOP.has(ws[i + 1]))
|
|
136
|
+
seen.add(`${ws[i]} ${ws[i + 1]}`);
|
|
137
|
+
}
|
|
138
|
+
for (const p of seen) {
|
|
139
|
+
const c = counts.get(p) ?? { reviews: 0, low: 0 };
|
|
140
|
+
c.reviews++;
|
|
141
|
+
if (r.rating <= 3)
|
|
142
|
+
c.low++;
|
|
143
|
+
counts.set(p, c);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const ranked = [...counts.entries()]
|
|
147
|
+
.filter(([p, c]) => c.reviews >= 3 && !listing.includes(` ${p} `))
|
|
148
|
+
// A phrase beats its own words when it is nearly as common.
|
|
149
|
+
.sort((a, b) => b[1].reviews + (b[0].includes(" ") ? 1 : 0) - (a[1].reviews + (a[0].includes(" ") ? 1 : 0)));
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const [phrase, c] of ranked) {
|
|
152
|
+
if (out.some((o) => o.phrase.split(" ").includes(phrase) && o.reviews >= c.reviews * 0.7))
|
|
153
|
+
continue;
|
|
154
|
+
out.push({ phrase, ...c });
|
|
155
|
+
if (out.length >= limit)
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
export function summaryMarkdown(reviews, now = new Date(), listingText = "") {
|
|
161
|
+
const day = 86_400_000;
|
|
162
|
+
const recentFrom = new Date(now.getTime() - 30 * day).toISOString();
|
|
163
|
+
const prevFrom = new Date(now.getTime() - 60 * day).toISOString();
|
|
164
|
+
const lines = ["# Reviews", "", `Pulled ${now.toISOString().slice(0, 10)}. Written by \`appeeky-spec reviews pull\`; do not edit by hand.`, ""];
|
|
165
|
+
for (const store of ["app-store", "google-play"]) {
|
|
166
|
+
const all = reviews.filter((r) => r.store === store);
|
|
167
|
+
if (all.length === 0)
|
|
168
|
+
continue;
|
|
169
|
+
const recent = reviewStats(all.filter((r) => r.date >= recentFrom));
|
|
170
|
+
const prev = reviewStats(all.filter((r) => r.date >= prevFrom && r.date < recentFrom));
|
|
171
|
+
const total = reviewStats(all);
|
|
172
|
+
lines.push(`## ${store === "app-store" ? "App Store" : "Google Play"}`, "");
|
|
173
|
+
lines.push("| | reviews | average | 1–2 stars | unanswered |", "| --- | --- | --- | --- | --- |");
|
|
174
|
+
lines.push(`| last 30 days | ${recent.count} | ${fmt(recent.average)} | ${pct(recent.low)} | ${recent.unanswered} |`);
|
|
175
|
+
lines.push(`| 30 days before | ${prev.count} | ${fmt(prev.average)} | ${pct(prev.low)} | ${prev.unanswered} |`);
|
|
176
|
+
lines.push(`| all pulled | ${total.count} | ${fmt(total.average)} | ${pct(total.low)} | ${total.unanswered} |`, "");
|
|
177
|
+
lines.push(`Stars: ${[5, 4, 3, 2, 1].map((s) => `${s}★ ${total.distribution[s]}`).join(" · ")}`, "");
|
|
178
|
+
const versions = new Map();
|
|
179
|
+
for (const r of all)
|
|
180
|
+
if (r.version)
|
|
181
|
+
versions.set(r.version, [...(versions.get(r.version) ?? []), r]);
|
|
182
|
+
const byVersion = [...versions.entries()].sort((a, b) => (b[1][0].date > a[1][0].date ? 1 : -1)).slice(0, 5);
|
|
183
|
+
if (byVersion.length) {
|
|
184
|
+
lines.push("| version | reviews | average | 1–2 stars |", "| --- | --- | --- | --- |");
|
|
185
|
+
for (const [v, rs] of byVersion) {
|
|
186
|
+
const s = reviewStats(rs);
|
|
187
|
+
lines.push(`| ${v} | ${s.count} | ${fmt(s.average)} | ${pct(s.low)} |`);
|
|
188
|
+
}
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
const waiting = all.filter((r) => !r.reply && r.rating <= 2).slice(0, 5);
|
|
192
|
+
if (waiting.length) {
|
|
193
|
+
lines.push("Low ratings without a reply:", "");
|
|
194
|
+
for (const r of waiting)
|
|
195
|
+
lines.push(`- \`${r.id}\` ${r.rating}★ ${r.date.slice(0, 10)}${r.where ? ` ${r.where}` : ""}: ${(r.title ? `${r.title}. ` : "") + r.body.replace(/\s+/g, " ").slice(0, 140)}`);
|
|
196
|
+
lines.push("");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const phrases = listingText ? reviewPhrases(reviews, listingText) : [];
|
|
200
|
+
if (phrases.length) {
|
|
201
|
+
lines.push("## Words users use that your listing does not", "");
|
|
202
|
+
lines.push("Candidates for targets (check their popularity with `suggest`), or things to build. From reviews in both stores.", "");
|
|
203
|
+
lines.push("| words | reviews | of them 1–3 stars |", "| --- | --- | --- |");
|
|
204
|
+
for (const p of phrases)
|
|
205
|
+
lines.push(`| ${p.phrase} | ${p.reviews} | ${p.low} |`);
|
|
206
|
+
lines.push("");
|
|
207
|
+
}
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
}
|
|
210
|
+
export async function writeSummary(root, now = new Date(), listingText = "") {
|
|
211
|
+
const file = join(reviewsDir(root), "summary.md");
|
|
212
|
+
await mkdir(reviewsDir(root), { recursive: true });
|
|
213
|
+
await writeFile(file, summaryMarkdown(await readReviews(root), now, listingText));
|
|
214
|
+
return file;
|
|
215
|
+
}
|
|
216
|
+
// ── Replies ─────────────────────────────────────────────────────────────────
|
|
217
|
+
export const replySchema = z
|
|
218
|
+
.object({
|
|
219
|
+
id: z.string().min(1),
|
|
220
|
+
store: z.enum(["app-store", "google-play"]),
|
|
221
|
+
/** The review being answered, for whoever reviews the pull request. */
|
|
222
|
+
review: z.string().optional(),
|
|
223
|
+
text: z.string().min(1),
|
|
224
|
+
status: z.enum(["draft", "sent"]).default("draft"),
|
|
225
|
+
sentAt: z.string().optional(),
|
|
226
|
+
})
|
|
227
|
+
.strict();
|
|
228
|
+
export const repliesDir = (root) => join(reviewsDir(root), "replies");
|
|
229
|
+
export async function writeReply(root, reply) {
|
|
230
|
+
const parsed = replySchema.parse(reply);
|
|
231
|
+
await mkdir(repliesDir(root), { recursive: true });
|
|
232
|
+
const file = join(repliesDir(root), `${parsed.store}-${parsed.id}.yaml`);
|
|
233
|
+
await writeFile(file, YAML.stringify(parsed, { lineWidth: 0 }));
|
|
234
|
+
return file;
|
|
235
|
+
}
|
|
236
|
+
export async function readReplies(root) {
|
|
237
|
+
const dir = repliesDir(root);
|
|
238
|
+
if (!existsSync(dir))
|
|
239
|
+
return [];
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort()) {
|
|
242
|
+
const parsed = replySchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
|
|
243
|
+
if (!parsed.success)
|
|
244
|
+
throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
|
|
245
|
+
out.push({ ...parsed.data, file: join(dir, f) });
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/** Store limits for a developer reply. */
|
|
250
|
+
export const REPLY_LIMIT = { "app-store": 5970, "google-play": 350 };
|
|
251
|
+
export function replyIssues(reply) {
|
|
252
|
+
const issues = [];
|
|
253
|
+
if (reply.text.length > REPLY_LIMIT[reply.store])
|
|
254
|
+
issues.push(`${reply.text.length}/${REPLY_LIMIT[reply.store]} characters`);
|
|
255
|
+
return issues;
|
|
256
|
+
}
|
|
257
|
+
/** Send one draft reply to the store and mark it sent. */
|
|
258
|
+
export async function sendReply(api, reply, packageName, now = new Date()) {
|
|
259
|
+
if (reply.store === "app-store") {
|
|
260
|
+
await api.post(`/connect/reviews/${encodeURIComponent(reply.id)}/response`, { responseBody: reply.text });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
if (!packageName)
|
|
264
|
+
throw new Error("googlePlay.packageName is not set in appeeky.spec.yaml");
|
|
265
|
+
await api.post(`/connect/google-play/apps/${encodeURIComponent(packageName)}/reviews/${encodeURIComponent(reply.id)}/reply`, { replyText: reply.text });
|
|
266
|
+
}
|
|
267
|
+
const { file, ...rest } = reply;
|
|
268
|
+
await writeFile(file, YAML.stringify({ ...rest, status: "sent", sentAt: now.toISOString() }, { lineWidth: 0 }));
|
|
269
|
+
}
|
|
270
|
+
// ── Measuring ───────────────────────────────────────────────────────────────
|
|
271
|
+
/** Ratings around a push, from the pulled review files: no API call. */
|
|
272
|
+
export function reviewMetricSource(root) {
|
|
273
|
+
return {
|
|
274
|
+
label: "Reviews in the repo",
|
|
275
|
+
async window(store, _appId, from, to) {
|
|
276
|
+
const rs = (await readReviews(root, store)).filter((r) => r.date >= from && r.date < to);
|
|
277
|
+
// A handful of reviews says nothing about a change.
|
|
278
|
+
if (rs.length < 5)
|
|
279
|
+
return null;
|
|
280
|
+
const s = reviewStats(rs);
|
|
281
|
+
const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
|
|
282
|
+
const values = { rating: s.average, low_ratings: s.low };
|
|
283
|
+
return { from, to, days, values };
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=reviews.js.map
|