@appeeky/spec 0.1.0 → 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 (46) hide show
  1. package/README.md +158 -5
  2. package/agents/appeeky-ads.md +50 -0
  3. package/agents/appeeky-aso.md +58 -0
  4. package/agents/appeeky-releases.md +47 -0
  5. package/agents/appeeky-reviews.md +53 -0
  6. package/agents/appeeky-social.md +54 -0
  7. package/agents/appeeky-ugc.md +56 -0
  8. package/agents/cofounder.md +36 -0
  9. package/dist/ads.d.ts +5 -2
  10. package/dist/ads.js +8 -4
  11. package/dist/agents.d.ts +47 -0
  12. package/dist/agents.js +88 -0
  13. package/dist/cli.js +582 -9
  14. package/dist/dataforseo.d.ts +139 -0
  15. package/dist/dataforseo.js +354 -0
  16. package/dist/experiments.d.ts +4 -4
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.js +4 -0
  19. package/dist/report.d.ts +5 -0
  20. package/dist/report.js +42 -0
  21. package/dist/reviews.d.ts +2 -2
  22. package/dist/schema.d.ts +95 -2
  23. package/dist/schema.js +35 -0
  24. package/dist/search-ads.d.ts +137 -0
  25. package/dist/search-ads.js +354 -0
  26. package/dist/seo.d.ts +87 -0
  27. package/dist/seo.js +391 -0
  28. package/dist/setup.js +39 -6
  29. package/dist/social.d.ts +129 -0
  30. package/dist/social.js +189 -0
  31. package/dist/transport.d.ts +1 -0
  32. package/dist/transport.js +1 -1
  33. package/dist/ugc-core.js +1 -0
  34. package/dist/ugc-local.d.ts +116 -0
  35. package/dist/ugc-local.js +174 -0
  36. package/dist/ugc.d.ts +149 -0
  37. package/dist/ugc.js +111 -0
  38. package/dist/visibility-core.js +96 -0
  39. package/dist/visibility-local.d.ts +159 -0
  40. package/dist/visibility-local.js +246 -0
  41. package/dist/visibility.d.ts +3 -0
  42. package/dist/visibility.js +1 -1
  43. package/package.json +7 -3
  44. package/skills/appeeky-spec/SKILL.md +14 -18
  45. package/skills/appeeky-spec-experiments/SKILL.md +12 -2
  46. package/skills/appeeky-spec-visibility/SKILL.md +33 -6
package/dist/seo.js ADDED
@@ -0,0 +1,391 @@
1
+ import { createSign } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { SPEC_DIR } from "./files.js";
6
+ import { rankAppIds } from "./verify.js";
7
+ /**
8
+ * The app's website, for people searching Google and for AI assistants:
9
+ *
10
+ * seo check reads the site the way a crawler does and lists what is missing for search (title,
11
+ * description, canonical, sitemap), for the app (store links, smart banner, app
12
+ * structured data) and for AI assistants (crawlers allowed, llms.txt, text without JS)
13
+ * seo llms writes llms.txt from the listing
14
+ * seo pull Google Search Console clicks, impressions, queries and pages into appeeky/seo/,
15
+ * with your own service account (GSC_CREDENTIALS); no Appeeky account needed
16
+ */
17
+ export const seoDir = (root) => join(root, SPEC_DIR, "seo");
18
+ /** Crawlers that read sites for AI answers and training; blocking them keeps the app out of those answers. */
19
+ export const AI_CRAWLERS = ["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-SearchBot", "PerplexityBot", "Google-Extended", "Applebot-Extended"];
20
+ export function parseRobots(text) {
21
+ const groups = [];
22
+ const sitemaps = [];
23
+ let current = null;
24
+ let lastWasAgent = false;
25
+ for (const line of text.split(/\r?\n/)) {
26
+ const clean = line.replace(/#.*/, "").trim();
27
+ const m = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(clean);
28
+ if (!m)
29
+ continue;
30
+ const field = m[1].toLowerCase();
31
+ const value = m[2].trim();
32
+ if (field === "sitemap") {
33
+ if (value)
34
+ sitemaps.push(value);
35
+ continue;
36
+ }
37
+ if (field === "user-agent") {
38
+ if (!current || !lastWasAgent) {
39
+ current = { agents: [], allow: [], disallow: [] };
40
+ groups.push(current);
41
+ }
42
+ current.agents.push(value.toLowerCase());
43
+ lastWasAgent = true;
44
+ continue;
45
+ }
46
+ lastWasAgent = false;
47
+ if (!current)
48
+ continue;
49
+ if (field === "allow" && value)
50
+ current.allow.push(value);
51
+ if (field === "disallow" && value)
52
+ current.disallow.push(value);
53
+ }
54
+ return { groups, sitemaps };
55
+ }
56
+ /** Whether robots.txt keeps an agent off the home page: its own group if it has one, else the * group. */
57
+ export function blocksHome(robots, agent) {
58
+ const a = agent.toLowerCase();
59
+ const group = robots.groups.find((g) => g.agents.includes(a)) ?? robots.groups.find((g) => g.agents.includes("*"));
60
+ if (!group)
61
+ return false;
62
+ const root = (r) => r === "/" || r === "/*" || r === "*";
63
+ return group.disallow.some(root) && !group.allow.some((r) => root(r) || r === "/$");
64
+ }
65
+ const attr = (tag, name) => new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag)?.slice(1).find((x) => x !== undefined);
66
+ function metaTags(html) {
67
+ return [...html.matchAll(/<meta\b[^>]*>/gi)].map((m) => ({ key: (attr(m[0], "name") ?? attr(m[0], "property") ?? "").toLowerCase(), content: decode(attr(m[0], "content") ?? "") }));
68
+ }
69
+ const decode = (s) => s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ");
70
+ /** The text a crawler that does not run JavaScript sees. */
71
+ export function visibleText(html) {
72
+ const body = /<body\b[^>]*>([\s\S]*)<\/body>/i.exec(html)?.[1] ?? html.replace(/<head\b[\s\S]*?<\/head>/i, " ");
73
+ return decode(body
74
+ .replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1>/gi, " ")
75
+ .replace(/<[^>]+>/g, " ")
76
+ .replace(/\s+/g, " ")).trim();
77
+ }
78
+ function jsonLdTypes(html) {
79
+ const types = [];
80
+ const visit = (v) => {
81
+ if (Array.isArray(v))
82
+ v.forEach(visit);
83
+ else if (v && typeof v === "object") {
84
+ const o = v;
85
+ const t = o["@type"];
86
+ if (typeof t === "string")
87
+ types.push(t);
88
+ if (Array.isArray(t))
89
+ types.push(...t.filter((x) => typeof x === "string"));
90
+ if (o["@graph"])
91
+ visit(o["@graph"]);
92
+ }
93
+ };
94
+ for (const m of html.matchAll(/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
95
+ try {
96
+ visit(JSON.parse(m[1]));
97
+ }
98
+ catch {
99
+ types.push("(invalid JSON-LD)");
100
+ }
101
+ }
102
+ return types;
103
+ }
104
+ async function get(fetcher, url) {
105
+ try {
106
+ const res = await fetcher(url, { headers: { "user-agent": "Mozilla/5.0 (compatible; AppeekySpec/1.0; +https://spec.appeeky.com)" }, redirect: "follow", signal: AbortSignal.timeout(20_000) });
107
+ return { status: res.status, url: res.url || url, text: await res.text(), headers: res.headers };
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ /** Read the site and list what helps or hurts it in search and in AI answers. */
114
+ export async function checkSite(project, url, fetcher = fetch) {
115
+ const out = [];
116
+ const add = (area, level, message, fix) => out.push({ area, level, message, ...(fix && level !== "ok" ? { fix } : {}) });
117
+ const page = await get(fetcher, url);
118
+ if (!page)
119
+ return [{ area: "search", level: "fail", message: `${url} did not answer`, fix: "Check the URL in web.url" }];
120
+ if (page.status >= 400)
121
+ return [{ area: "search", level: "fail", message: `${url} answered HTTP ${page.status}`, fix: "Check the URL in web.url" }];
122
+ const origin = new URL(page.url).origin;
123
+ const html = page.text;
124
+ const metas = metaTags(html);
125
+ const meta = (k) => metas.find((m) => m.key === k)?.content.trim();
126
+ const ids = rankAppIds(project);
127
+ // Search
128
+ add("search", origin.startsWith("https:") ? "ok" : "fail", origin.startsWith("https:") ? "Served over HTTPS" : "Not served over HTTPS", "Serve the site over HTTPS");
129
+ const title = decode(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "").trim();
130
+ if (!title)
131
+ add("search", "fail", "No <title>", "Give the page a title with the app's name and what it does");
132
+ else
133
+ add("search", title.length > 65 ? "warn" : "ok", `Title: "${title}" (${title.length} characters)`, "Keep the title under about 60 characters so Google shows all of it");
134
+ const desc = meta("description");
135
+ if (!desc)
136
+ add("search", "fail", "No meta description", 'Add <meta name="description"> of 120–160 characters: what the app does and for whom');
137
+ else
138
+ add("search", desc.length < 70 || desc.length > 170 ? "warn" : "ok", `Meta description: ${desc.length} characters`, "Aim for 120–160 characters");
139
+ const robotsMeta = `${meta("robots") ?? ""} ${page.headers.get("x-robots-tag") ?? ""}`.toLowerCase();
140
+ if (robotsMeta.includes("noindex"))
141
+ add("search", "fail", "The page asks not to be indexed (noindex)", "Remove noindex from the robots meta tag or X-Robots-Tag header");
142
+ const canonical = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => m[0]).find((t) => /rel\s*=\s*["']?canonical/i.test(t));
143
+ add("search", canonical ? "ok" : "warn", canonical ? `Canonical: ${attr(canonical, "href")}` : "No canonical link", 'Add <link rel="canonical" href="..."> with the page\'s preferred URL');
144
+ add("search", /<html\b[^>]*\blang\s*=/i.test(html) ? "ok" : "warn", /<html\b[^>]*\blang\s*=/i.test(html) ? "Page language set" : "No lang on <html>", 'Set <html lang="en"> (or the page\'s language)');
145
+ const h1 = (html.match(/<h1\b/gi) ?? []).length;
146
+ add("search", h1 === 1 ? "ok" : "warn", h1 === 1 ? "One <h1>" : `${h1} <h1> headings`, "Use exactly one <h1> that says what the app does");
147
+ add("search", meta("og:title") && meta("og:image") ? "ok" : "warn", meta("og:image") ? "Open Graph title and image" : "No Open Graph image", "Add og:title, og:description and og:image so shared links show a card");
148
+ const robotsRes = await get(fetcher, `${origin}/robots.txt`);
149
+ const robots = parseRobots(robotsRes && robotsRes.status < 400 ? robotsRes.text : "");
150
+ if (blocksHome(robots, "Googlebot"))
151
+ add("search", "fail", "robots.txt blocks Googlebot from the site", "Remove Disallow: / for Googlebot and *");
152
+ const sitemapUrl = robots.sitemaps[0] ?? `${origin}/sitemap.xml`;
153
+ const sitemap = await get(fetcher, sitemapUrl);
154
+ const sitemapOk = sitemap && sitemap.status < 400 && /<(urlset|sitemapindex)\b/i.test(sitemap.text);
155
+ add("search", sitemapOk ? "ok" : "warn", sitemapOk ? `Sitemap: ${sitemapUrl}` : "No sitemap found", "Publish /sitemap.xml and name it in robots.txt (Sitemap: ...)");
156
+ // The app
157
+ const appStoreId = ids["app-store"];
158
+ const playId = ids["google-play"];
159
+ if (appStoreId) {
160
+ const linked = new RegExp(`apps\\.apple\\.com/[^"'\\s]*id${appStoreId}`).test(html);
161
+ add("app", linked ? "ok" : "warn", linked ? "Links to the App Store listing" : "No link to the App Store listing", `Link to https://apps.apple.com/app/id${appStoreId}`);
162
+ const banner = meta("apple-itunes-app");
163
+ const bannerOk = banner?.includes(`app-id=${appStoreId}`);
164
+ add("app", bannerOk ? "ok" : "warn", bannerOk ? "Smart App Banner" : "No Smart App Banner", `Add <meta name="apple-itunes-app" content="app-id=${appStoreId}"> so Safari offers the app`);
165
+ }
166
+ if (playId) {
167
+ const linked = html.includes(`play.google.com/store/apps/details?id=${playId}`);
168
+ add("app", linked ? "ok" : "warn", linked ? "Links to the Google Play listing" : "No link to the Google Play listing", `Link to https://play.google.com/store/apps/details?id=${playId}`);
169
+ }
170
+ const types = jsonLdTypes(html);
171
+ const appType = types.find((t) => /^(SoftwareApplication|MobileApplication|WebApplication)$/.test(t));
172
+ add("app", appType ? "ok" : "warn", appType ? `App structured data (${appType})` : types.length ? `Structured data without the app (${[...new Set(types)].join(", ")})` : "No structured data", "Add JSON-LD of @type MobileApplication with name, operatingSystem, applicationCategory, offers and aggregateRating");
173
+ const text = visibleText(html);
174
+ const name = project.spec.app.name.split(/[:\-–—|]/)[0].trim();
175
+ add("app", text.toLowerCase().includes(name.toLowerCase()) ? "ok" : "warn", text.toLowerCase().includes(name.toLowerCase()) ? `The page names the app ("${name}")` : `The page text does not name "${name}"`, "Say the app's name in the page text, not only in the logo");
176
+ // AI assistants
177
+ const blocked = AI_CRAWLERS.filter((a) => blocksHome(robots, a));
178
+ add("ai", blocked.length ? "fail" : "ok", blocked.length ? `robots.txt blocks ${blocked.join(", ")}` : "AI crawlers may read the site", "Allow the AI crawlers in robots.txt so assistants can read and cite the site");
179
+ const llms = await get(fetcher, `${origin}/llms.txt`);
180
+ const llmsOk = llms && llms.status < 400 && /^#\s/m.test(llms.text) && !/<html/i.test(llms.text);
181
+ add("ai", llmsOk ? "ok" : "warn", llmsOk ? "llms.txt" : "No llms.txt", "Publish /llms.txt: run appeeky-spec seo llms --out public/llms.txt");
182
+ const words = text.split(/\s+/).filter(Boolean).length;
183
+ add("ai", words >= 150 ? "ok" : "warn", `${words} words readable without JavaScript`, "Render the page's text on the server: most AI crawlers do not run JavaScript");
184
+ const targets = [...new Set(Object.values(project.spec.targets).flat())];
185
+ if (targets.length) {
186
+ const lower = text.toLowerCase();
187
+ const found = targets.filter((k) => lower.includes(k.toLowerCase()));
188
+ const share = found.length / targets.length;
189
+ add("ai", share >= 0.3 ? "ok" : "warn", `${found.length} of ${targets.length} target keywords appear on the page`, "Use the phrases people search for (targets in appeeky.spec.yaml) in the page's copy, e.g. in an FAQ");
190
+ }
191
+ add("ai", types.includes("FAQPage") ? "ok" : "warn", types.includes("FAQPage") ? "FAQ structured data" : "No FAQ", "Answer the questions people ask assistants on the page, with FAQPage structured data");
192
+ return out;
193
+ }
194
+ // ── llms.txt ────────────────────────────────────────────────────────
195
+ /** llms.txt from the listing: what the app is, where to get it, and the full description. */
196
+ export function llmsTxt(project) {
197
+ const locale = project.spec.app.primaryLocale;
198
+ const as = project.listings["app-store"][locale] ?? Object.values(project.listings["app-store"])[0];
199
+ const gp = Object.values(project.listings["google-play"])[0];
200
+ const ids = rankAppIds(project);
201
+ const name = as?.name ?? gp?.title ?? project.spec.app.name;
202
+ const summary = as?.subtitle ?? gp?.shortDescription;
203
+ const description = (as?.description ?? gp?.fullDescription ?? "").trim();
204
+ const links = [
205
+ ids["app-store"] ? `- [${name} on the App Store](https://apps.apple.com/app/id${ids["app-store"]})` : null,
206
+ ids["google-play"] ? `- [${name} on Google Play](https://play.google.com/store/apps/details?id=${ids["google-play"]})` : null,
207
+ project.spec.web?.url ? `- [Website](${project.spec.web.url})` : null,
208
+ as?.supportUrl ? `- [Support](${as.supportUrl})` : null,
209
+ ].filter(Boolean);
210
+ // The first storefront's targets: the others are other languages.
211
+ const keywords = (Object.values(project.spec.targets)[0] ?? []).slice(0, 12);
212
+ return [
213
+ `# ${name}`,
214
+ "",
215
+ summary ? `> ${summary}` : null,
216
+ summary ? "" : null,
217
+ description.split(/\n\s*\n/)[0] ?? "",
218
+ "",
219
+ links.length ? "## Get the app" : null,
220
+ ...links,
221
+ links.length ? "" : null,
222
+ keywords.length ? "## Good for" : null,
223
+ ...keywords.map((k) => `- ${k}`),
224
+ keywords.length ? "" : null,
225
+ description ? "## About" : null,
226
+ description || null,
227
+ ]
228
+ .filter((l) => l !== null)
229
+ .join("\n")
230
+ .replace(/\n{3,}/g, "\n\n")
231
+ .trim()
232
+ .concat("\n");
233
+ }
234
+ const GCLOUD_ADC = () => join(process.env.HOME ?? "", ".config", "gcloud", "application_default_credentials.json");
235
+ /**
236
+ * GSC_CREDENTIALS (the JSON, or a path to it), else GOOGLE_APPLICATION_CREDENTIALS, else your gcloud
237
+ * login on this machine. A service account key or an `authorized_user` file both work.
238
+ */
239
+ export async function readServiceAccount(env, opts = {}) {
240
+ let raw = env.GSC_CREDENTIALS?.trim() || env.GOOGLE_APPLICATION_CREDENTIALS?.trim();
241
+ if (!raw && opts.gcloud && existsSync(GCLOUD_ADC()))
242
+ raw = GCLOUD_ADC();
243
+ if (!raw)
244
+ return null;
245
+ const text = raw.startsWith("{") ? raw : await readFile(raw, "utf8");
246
+ const j = JSON.parse(text);
247
+ if (j.type === "authorized_user" && j.client_id && j.client_secret && j.refresh_token)
248
+ return { type: "authorized_user", client_id: j.client_id, client_secret: j.client_secret, refresh_token: j.refresh_token, ...(j.quota_project_id ? { quota_project_id: j.quota_project_id } : {}) };
249
+ if (j.client_email && j.private_key)
250
+ return { type: "service_account", client_email: j.client_email, private_key: j.private_key };
251
+ throw new Error("The Search Console credentials need a service account JSON key (client_email, private_key) or a gcloud application-default login.");
252
+ }
253
+ const b64url = (s) => Buffer.from(s).toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
254
+ export async function googleToken(creds, fetcher = fetch, now = new Date()) {
255
+ let body;
256
+ if ("type" in creds && creds.type === "authorized_user") {
257
+ body = new URLSearchParams({ grant_type: "refresh_token", client_id: creds.client_id, client_secret: creds.client_secret, refresh_token: creds.refresh_token });
258
+ }
259
+ else {
260
+ const sa = creds;
261
+ const iat = Math.floor(now.getTime() / 1000);
262
+ const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
263
+ const claims = b64url(JSON.stringify({ iss: sa.client_email, scope: "https://www.googleapis.com/auth/webmasters.readonly", aud: "https://oauth2.googleapis.com/token", iat, exp: iat + 3600 }));
264
+ const signer = createSign("RSA-SHA256");
265
+ signer.update(`${head}.${claims}`);
266
+ body = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${head}.${claims}.${b64url(signer.sign(sa.private_key))}` });
267
+ }
268
+ const res = await fetcher("https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: body.toString() });
269
+ const json = (await res.json().catch(() => ({})));
270
+ if (!res.ok || !json.access_token)
271
+ throw new Error(`Google sign-in failed: ${json.error_description ?? json.error ?? res.status}`);
272
+ return json.access_token;
273
+ }
274
+ export function searchConsoleProperty(project) {
275
+ const web = project.spec.web;
276
+ if (!web)
277
+ return null;
278
+ return web.searchConsole ?? `sc-domain:${new URL(web.url).hostname.replace(/^www\./, "")}`;
279
+ }
280
+ async function query(fetcher, token, property, body, quotaProject) {
281
+ const res = await fetcher(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`, {
282
+ method: "POST",
283
+ // A user login (gcloud) bills API quota to a project; a service account uses its own.
284
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(quotaProject ? { "x-goog-user-project": quotaProject } : {}) },
285
+ body: JSON.stringify(body),
286
+ });
287
+ const json = (await res.json().catch(() => ({})));
288
+ if (!res.ok) {
289
+ const msg = json.error?.message ?? `HTTP ${res.status}`;
290
+ if (res.status === 403)
291
+ throw new Error(`Search Console: ${msg} Make sure the account (or the service account's email) is a user of ${property} in Search Console.`);
292
+ throw new Error(`Search Console: ${msg}`);
293
+ }
294
+ return json.rows ?? [];
295
+ }
296
+ const day = (d) => d.toISOString().slice(0, 10);
297
+ const addDays = (d, n) => new Date(d.getTime() + n * 86_400_000);
298
+ const csvCell = (v) => (typeof v === "string" && /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : String(v));
299
+ const r2 = (n) => Math.round(n * 100) / 100;
300
+ /** Clicks and impressions per day (merged into daily.csv), and the last 28 days by query and by page. */
301
+ export async function pullSearchConsole(project, sa, fetcher = fetch, now = new Date()) {
302
+ const property = searchConsoleProperty(project);
303
+ if (!property)
304
+ throw new Error("Set web.url in appeeky.spec.yaml (and web.searchConsole if the property is not the domain).");
305
+ const token = await googleToken(sa, fetcher, now);
306
+ const quota = "type" in sa && sa.type === "authorized_user" ? sa.quota_project_id : undefined;
307
+ // Search Console data settles after about two days.
308
+ const end = addDays(now, -2);
309
+ const daily = await query(fetcher, token, property, { startDate: day(addDays(end, -59)), endDate: day(end), dimensions: ["date"], rowLimit: 100 }, quota);
310
+ const from28 = day(addDays(end, -27));
311
+ const queries = await query(fetcher, token, property, { startDate: from28, endDate: day(end), dimensions: ["query"], rowLimit: 250 }, quota);
312
+ const pages = await query(fetcher, token, property, { startDate: from28, endDate: day(end), dimensions: ["page"], rowLimit: 100 }, quota);
313
+ const dir = seoDir(project.root);
314
+ await mkdir(dir, { recursive: true });
315
+ const dailyFile = join(dir, "daily.csv");
316
+ const rows = new Map();
317
+ if (existsSync(dailyFile))
318
+ for (const l of (await readFile(dailyFile, "utf8")).split("\n").slice(1).filter(Boolean))
319
+ rows.set(l.split(",")[0], l);
320
+ for (const r of daily)
321
+ rows.set(r.keys[0], [r.keys[0], r.clicks, r.impressions, r2(r.ctr * 100), r2(r.position)].join(","));
322
+ await writeFile(dailyFile, `date,clicks,impressions,ctr_pct,position\n${[...rows.keys()].sort().map((k) => rows.get(k)).join("\n")}\n`);
323
+ const table = (name, list) => `${name},clicks,impressions,ctr_pct,position\n${list.map((r) => [csvCell(r.keys[0]), r.clicks, r.impressions, r2(r.ctr * 100), r2(r.position)].join(",")).join("\n")}\n`;
324
+ await writeFile(join(dir, "queries.csv"), table("query", queries));
325
+ await writeFile(join(dir, "pages.csv"), table("page", pages));
326
+ const sum = (from, to) => daily.filter((r) => r.keys[0] >= from && r.keys[0] <= to).reduce((a, r) => ({ clicks: a.clicks + r.clicks, impressions: a.impressions + r.impressions }), { clicks: 0, impressions: 0 });
327
+ const last28 = sum(from28, day(end));
328
+ const previous28 = sum(day(addDays(end, -55)), day(addDays(end, -28)));
329
+ const targets = [...new Set(Object.values(project.spec.targets).flat())].map((t) => t.toLowerCase());
330
+ const nearTargets = queries.filter((q) => targets.some((t) => q.keys[0].toLowerCase().includes(t)));
331
+ const close = queries.filter((q) => q.position > 3 && q.position <= 15 && q.impressions >= 20).sort((a, b) => b.impressions - a.impressions);
332
+ const pct = (a, b) => (b > 0 ? ` (${a >= b ? "+" : ""}${Math.round(((a - b) / b) * 100)}%)` : "");
333
+ const lines = (list) => (list.length ? list.map(line) : ["| none in this window | | | |"]);
334
+ const line = (r) => `| ${r.keys[0].replace(/\|/g, "\\|")} | ${r.clicks} | ${r.impressions} | ${r2(r.position)} |`;
335
+ const md = [
336
+ "# Google search",
337
+ "",
338
+ `Written by \`seo pull\` from Search Console (${property}), ${day(addDays(end, -27))} to ${day(end)}.`,
339
+ "",
340
+ `- Clicks: ${last28.clicks}${pct(last28.clicks, previous28.clicks)} vs the 28 days before`,
341
+ `- Impressions: ${last28.impressions}${pct(last28.impressions, previous28.impressions)}`,
342
+ "",
343
+ "## Top queries",
344
+ "",
345
+ "| query | clicks | impressions | position |",
346
+ "| --- | --- | --- | --- |",
347
+ ...lines(queries.slice(0, 15)),
348
+ "",
349
+ "## Close to the top (position 4–15)",
350
+ "",
351
+ "A better title, description or section for these can move them onto the first screen.",
352
+ "",
353
+ "| query | clicks | impressions | position |",
354
+ "| --- | --- | --- | --- |",
355
+ ...lines(close.slice(0, 10)),
356
+ ...(targets.length ? ["", "## Queries with your store target keywords", "", "| query | clicks | impressions | position |", "| --- | --- | --- | --- |", ...lines(nearTargets.slice(0, 10))] : []),
357
+ "",
358
+ ].join("\n");
359
+ await writeFile(join(dir, "summary.md"), md);
360
+ return { property, days: daily.length, last28, previous28, queries: queries.length, pages: pages.length, files: ["daily.csv", "queries.csv", "pages.csv", "summary.md"].map((f) => join(dir, f)) };
361
+ }
362
+ /** `search_clicks` and `search_impressions` per day from appeeky/seo/daily.csv. */
363
+ export function seoMetricSource(root) {
364
+ const file = join(seoDir(root), "daily.csv");
365
+ if (!existsSync(file))
366
+ return null;
367
+ let rows;
368
+ return {
369
+ label: "Google Search Console in the repo",
370
+ async window(_store, _appId, from, to) {
371
+ rows ??= readFile(file, "utf8").then((t) => t
372
+ .split("\n")
373
+ .slice(1)
374
+ .filter(Boolean)
375
+ .map((l) => {
376
+ const [date, clicks, impressions] = l.split(",");
377
+ return { date: date, clicks: Number(clicks), impressions: Number(impressions) };
378
+ }));
379
+ const inside = (await rows).filter((r) => r.date >= from && r.date < to);
380
+ if (inside.length < 3)
381
+ return null;
382
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
383
+ const values = {
384
+ search_clicks: inside.reduce((n, r) => n + r.clicks, 0) / inside.length,
385
+ search_impressions: inside.reduce((n, r) => n + r.impressions, 0) / inside.length,
386
+ };
387
+ return { from, to, days, values };
388
+ },
389
+ };
390
+ }
391
+ //# sourceMappingURL=seo.js.map
package/dist/setup.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { installedTargets } from "./agents.js";
3
4
  import { SPEC_DIR, SPEC_FILE } from "./files.js";
4
5
  import { normalize, words } from "./text.js";
5
6
  import { STORE_CLIS, onPath } from "./transport.js";
@@ -94,11 +95,12 @@ jobs:
94
95
  fetch-depth: 0 # the report compares with main
95
96
  - uses: appeeky/appeeky-spec@v0
96
97
  `,
97
- "listing-push.yml": `# Appeeky Spec: push the listing to the stores when a change is merged.
98
+ "listing-push.yml": `# Appeeky Spec: push the listing to the stores when a change is merged, and send the
99
+ # social posts and make the UGC clips a merged pull request approved.
98
100
  on:
99
101
  push:
100
102
  branches: [main]
101
- paths: ["appeeky/store/**", "appeeky.spec.yaml"]
103
+ paths: ["appeeky/store/**", "appeeky.spec.yaml", "appeeky/social/drafts/**", "appeeky/ugc/briefs/**"]
102
104
  permissions:
103
105
  contents: write # to commit the release file
104
106
  jobs:
@@ -107,15 +109,22 @@ jobs:
107
109
  environment: stores # optional: a required approval before it runs
108
110
  steps:
109
111
  - uses: actions/checkout@v4
110
- - run: npx @appeeky/spec push --yes --note "\${{ github.event.head_commit.message }}"
112
+ - run: |
113
+ npx @appeeky/spec push --yes --note "\${{ github.event.head_commit.message }}"
114
+ npx @appeeky/spec social push --yes
115
+ npx @appeeky/spec ugc generate --yes \${FAL_KEY:+--local}
111
116
  env:
112
117
  APPEEKY_API_KEY: \${{ secrets.APPEEKY_API_KEY }}
118
+ FAL_KEY: \${{ secrets.FAL_KEY }} # optional: render clips on your own fal account
119
+ - uses: actions/upload-artifact@v4 # clips made with FAL_KEY, kept with the run
120
+ if: hashFiles('.appeeky/media/*.mp4') != ''
121
+ with: { name: ugc-clips, path: .appeeky/media/*.mp4 }
113
122
  - run: |
114
123
  git config user.name "appeeky-spec"
115
124
  git config user.email "spec@users.noreply.github.com"
116
- git add appeeky && git commit -m "Record release" && git push || true
125
+ git add appeeky && git commit -m "Record release, posts and clips" && git push || true
117
126
  `,
118
- "listing-daily.yml": `# Appeeky Spec: record the category rank and AI visibility, measure releases on day 7 and 14, refresh reviews and competitors.
127
+ "listing-daily.yml": `# Appeeky Spec: record the category rank, AI visibility and Google search, measure releases on day 7 and 14, refresh reviews and competitors.
119
128
  on:
120
129
  schedule: [{ cron: "0 6 * * *" }] # 06:00 UTC
121
130
  workflow_dispatch:
@@ -132,12 +141,34 @@ jobs:
132
141
  npx @appeeky/spec verify --playbook
133
142
  npx @appeeky/spec reviews pull || true
134
143
  npx @appeeky/spec competitors pull || true
144
+ npx @appeeky/spec social pull || true
145
+ npx @appeeky/spec ugc pull || true
146
+ # Your own keys: Search Console with a service account, Apple Search Ads with an API user, Google ranks on
147
+ # DataForSEO every SEO_RANKS_EVERY days (default 7), and AI visibility every N days
148
+ # (set the repository variable VISIBILITY_EVERY, e.g. 7) on OpenAI, Anthropic, Gemini or Perplexity.
149
+ if [ -n "$GSC_CREDENTIALS" ]; then npx @appeeky/spec seo pull || true; fi
150
+ if [ -n "$ASA_CLIENT_ID" ]; then npx @appeeky/spec search-ads pull || true; fi
151
+ if [ -n "$DATAFORSEO_LOGIN" ]; then npx @appeeky/spec seo ranks --every "\${SEO_RANKS_EVERY:-7}" --yes || true; fi
152
+ if [ -n "$VISIBILITY_EVERY" ]; then npx @appeeky/spec visibility scan --local --every "$VISIBILITY_EVERY" --yes || true; fi
135
153
  env:
136
154
  APPEEKY_API_KEY: \${{ secrets.APPEEKY_API_KEY }}
155
+ GSC_CREDENTIALS: \${{ secrets.GSC_CREDENTIALS }}
156
+ ASA_CLIENT_ID: \${{ secrets.ASA_CLIENT_ID }}
157
+ ASA_TEAM_ID: \${{ secrets.ASA_TEAM_ID }}
158
+ ASA_KEY_ID: \${{ secrets.ASA_KEY_ID }}
159
+ ASA_PRIVATE_KEY: \${{ secrets.ASA_PRIVATE_KEY }}
160
+ DATAFORSEO_LOGIN: \${{ secrets.DATAFORSEO_LOGIN }}
161
+ DATAFORSEO_PASSWORD: \${{ secrets.DATAFORSEO_PASSWORD }}
162
+ SEO_RANKS_EVERY: \${{ vars.SEO_RANKS_EVERY }}
163
+ VISIBILITY_EVERY: \${{ vars.VISIBILITY_EVERY }}
164
+ OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
165
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
166
+ GEMINI_API_KEY: \${{ secrets.GEMINI_API_KEY }}
167
+ PERPLEXITY_API_KEY: \${{ secrets.PERPLEXITY_API_KEY }}
137
168
  - run: |
138
169
  git config user.name "appeeky-spec"
139
170
  git config user.email "spec@users.noreply.github.com"
140
- git add appeeky && git commit -m "Daily: category, AI visibility, verify, reviews, competitors" && git push || true
171
+ git add appeeky && git commit -m "Daily: category, AI visibility, search, Search Ads, verify, reviews, competitors, social, UGC" && git push || true
141
172
  `,
142
173
  };
143
174
  const ok = (area, detail) => ({ area, status: "ok", detail });
@@ -218,6 +249,8 @@ export function diagnose(root, project, env = process.env, nodeVersion = process
218
249
  out.push(found.length ? ok("GitHub Actions", found.join(", ")) : warn("GitHub Actions", "No workflow runs the spec", "Optional: `npx @appeeky/spec init --ci` writes the pull request check, push on merge and daily verify."));
219
250
  const skills = [".claude/skills/appeeky-spec", ".agents/skills/appeeky-spec", ".cursor/skills/appeeky-spec"].some((p) => existsSync(join(root, p)));
220
251
  out.push(skills ? ok("agent skills", "installed") : warn("agent skills", "Not installed in this repo", "Optional: `npx skills add appeeky/appeeky-spec`."));
252
+ const specialists = installedTargets(root);
253
+ out.push(specialists.length ? ok("specialist agents", `installed for ${specialists.join(", ")}`) : warn("specialist agents", "Not installed: no co-founder review in this repo", "Optional: `npx @appeeky/spec agents install` (add --for opencode or all)."));
221
254
  const ignore = existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "";
222
255
  if (!/\.appeeky\/?(\s|$)|\.appeeky\/data|\.appeeky\/\*/m.test(ignore))
223
256
  out.push(warn(".gitignore", ".appeeky/ is not ignored: data and cache could be committed", "Add `.appeeky/` to .gitignore."));
@@ -0,0 +1,129 @@
1
+ import { z } from "zod";
2
+ import type { AppeekyApi } from "./appeeky-api.js";
3
+ /**
4
+ * Organic social media in the repo, through the accounts connected in Appeeky (APPEEKY_API_KEY,
5
+ * Startup plan or above):
6
+ *
7
+ * appeeky/social/accounts.yaml the connected accounts (platform, name, id)
8
+ * appeeky/social/posts.jsonl recent posts, one per line, with each platform's result
9
+ * appeeky/social/summary.md the last 30 days per platform, and the posts that did best
10
+ * appeeky/social/drafts/<name>.yaml a post: drafted, reviewed in a pull request, then sent
11
+ *
12
+ * Sending creates the post in Appeeky, which publishes it right away, or at `when`.
13
+ */
14
+ export declare const METRICS: readonly ["views", "impressions", "reach", "likes", "comments", "shares", "reposts", "saves", "clicks"];
15
+ export type SocialMetric = (typeof METRICS)[number];
16
+ export interface SocialAccount {
17
+ id: string;
18
+ platform: string;
19
+ name: string;
20
+ active: boolean;
21
+ }
22
+ export interface SocialPlatformResult {
23
+ platform: string;
24
+ status: string;
25
+ url?: string;
26
+ publishedAt?: string;
27
+ error?: string;
28
+ metrics?: Partial<Record<SocialMetric, number>>;
29
+ }
30
+ export interface SocialPost {
31
+ id: string;
32
+ status: string;
33
+ text: string;
34
+ createdAt: string;
35
+ scheduledFor?: string;
36
+ platforms: SocialPlatformResult[];
37
+ }
38
+ export declare const socialDir: (root: string) => string;
39
+ export declare const draftsDir: (root: string) => string;
40
+ interface AccountRow {
41
+ id: string;
42
+ platform: string;
43
+ displayName?: string | null;
44
+ platformUserId?: string | null;
45
+ isActive?: boolean | null;
46
+ needsReconnect?: boolean | null;
47
+ }
48
+ interface PostRow {
49
+ id: string;
50
+ content: string;
51
+ status: string;
52
+ created_at: string;
53
+ scheduled_for: string | null;
54
+ social_media_platform_posts?: Array<{
55
+ platform: string;
56
+ status: string;
57
+ platform_post_url: string | null;
58
+ published_at: string | null;
59
+ error_message: string | null;
60
+ }>;
61
+ }
62
+ interface AnalyticsItem {
63
+ platform: string;
64
+ analytics: Record<string, unknown>;
65
+ }
66
+ export declare function toAccount(r: AccountRow): SocialAccount;
67
+ export declare function toPost(r: PostRow, analytics?: AnalyticsItem[]): SocialPost;
68
+ /** Connected accounts and the latest posts, with analytics for the ones that went out. */
69
+ export declare function fetchSocial(api: AppeekyApi, options?: {
70
+ limit?: number;
71
+ }): Promise<{
72
+ accounts: SocialAccount[];
73
+ posts: SocialPost[];
74
+ }>;
75
+ export declare function summaryMarkdown(accounts: SocialAccount[], posts: SocialPost[], now?: Date): string;
76
+ export declare function saveSocial(root: string, data: {
77
+ accounts: SocialAccount[];
78
+ posts: SocialPost[];
79
+ }, now?: Date): Promise<void>;
80
+ export declare function readAccounts(root: string): Promise<SocialAccount[]>;
81
+ export declare const draftSchema: z.ZodObject<{
82
+ /** Platforms (twitter, instagram, tiktok, …) or account ids from accounts.yaml. */
83
+ accounts: z.ZodArray<z.ZodString, "many">;
84
+ text: z.ZodString;
85
+ /** ISO time to schedule it; without it, the post goes out when it is sent. */
86
+ when: z.ZodOptional<z.ZodString>;
87
+ /** Public image or video URLs. Instagram, TikTok and Pinterest need one. */
88
+ media: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
89
+ /** Why this post, for whoever reviews the pull request. */
90
+ why: z.ZodOptional<z.ZodString>;
91
+ status: z.ZodDefault<z.ZodEnum<["draft", "sent"]>>;
92
+ postId: z.ZodOptional<z.ZodString>;
93
+ sentAt: z.ZodOptional<z.ZodString>;
94
+ }, "strict", z.ZodTypeAny, {
95
+ status: "draft" | "sent";
96
+ accounts: string[];
97
+ text: string;
98
+ when?: string | undefined;
99
+ media?: string[] | undefined;
100
+ why?: string | undefined;
101
+ postId?: string | undefined;
102
+ sentAt?: string | undefined;
103
+ }, {
104
+ accounts: string[];
105
+ text: string;
106
+ status?: "draft" | "sent" | undefined;
107
+ when?: string | undefined;
108
+ media?: string[] | undefined;
109
+ why?: string | undefined;
110
+ postId?: string | undefined;
111
+ sentAt?: string | undefined;
112
+ }>;
113
+ export type Draft = z.infer<typeof draftSchema>;
114
+ /** Character limits per platform, for `check`-style warnings before sending. */
115
+ export declare const TEXT_LIMIT: Record<string, number>;
116
+ export declare function readDrafts(root: string): Promise<Array<Draft & {
117
+ file: string;
118
+ }>>;
119
+ /** The connected accounts a draft goes to: platform names match every active account on that platform. */
120
+ export declare function resolveAccounts(draft: Draft, accounts: SocialAccount[]): {
121
+ accounts: SocialAccount[];
122
+ missing: string[];
123
+ };
124
+ export declare function draftIssues(draft: Draft, accounts: SocialAccount[], now?: Date): string[];
125
+ /** Create the post in Appeeky (it publishes now, or at `when`) and mark the draft sent. */
126
+ export declare function sendDraft(api: AppeekyApi, draft: Draft & {
127
+ file: string;
128
+ }, accounts: SocialAccount[], now?: Date): Promise<string>;
129
+ export {};