@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/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(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/ /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.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Project, StoreId } from "./schema.js";
|
|
2
|
+
import type { RankSource } from "./verify.js";
|
|
3
|
+
type Fetcher = typeof fetch;
|
|
4
|
+
export interface FoundApp {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
developer: string;
|
|
8
|
+
bundleId: string;
|
|
9
|
+
rating?: number;
|
|
10
|
+
}
|
|
11
|
+
/** App Store apps matching a name, from Apple's public search. */
|
|
12
|
+
export declare function findApps(term: string, country?: string, fetcher?: Fetcher): Promise<FoundApp[]>;
|
|
13
|
+
/** Whether a Google Play package has a public store page. */
|
|
14
|
+
export declare function playPackageExists(pkg: string, fetcher?: Fetcher): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Searches the listing already speaks to: the parts of the name and subtitle between
|
|
17
|
+
* punctuation ("Sleep Sounds: Rain & Ocean" → "sleep sounds", "rain", "ocean"), their two-word
|
|
18
|
+
* runs, and keyword-field entries. The brand (the name's first part, alone) is left out.
|
|
19
|
+
*/
|
|
20
|
+
export declare function targetCandidates(project: Project, limit?: number, storeName?: string): string[];
|
|
21
|
+
export interface RankedCandidate {
|
|
22
|
+
keyword: string;
|
|
23
|
+
rank: number | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The searches to start with: the ones the app already ranks for (a target you are near is
|
|
27
|
+
* the cheapest win), best first, then two-word phrases from the listing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function suggestTargets(candidates: string[], appId: string | undefined, country: string, source: RankSource, count?: number): Promise<RankedCandidate[]>;
|
|
30
|
+
export declare const WORKFLOWS: Record<string, string>;
|
|
31
|
+
export type CheckStatus = "ok" | "warn" | "fail";
|
|
32
|
+
export interface DoctorCheck {
|
|
33
|
+
area: string;
|
|
34
|
+
status: CheckStatus;
|
|
35
|
+
detail: string;
|
|
36
|
+
/** How to fix it, when it is not ok. */
|
|
37
|
+
fix?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Where each store CLI keeps its saved accounts (`appstore accounts add`, `gps accounts add`). */
|
|
40
|
+
export declare function savedAccountsFile(store: StoreId, env?: NodeJS.ProcessEnv): string;
|
|
41
|
+
/** Names of the accounts saved with the store CLI, if any. */
|
|
42
|
+
export declare function savedAccounts(store: StoreId, env?: NodeJS.ProcessEnv): string[];
|
|
43
|
+
/** What is set up for this repo, and what is missing. Works without a spec (it says so). */
|
|
44
|
+
export declare function diagnose(root: string, project: Project | null, env?: NodeJS.ProcessEnv, nodeVersion?: string): DoctorCheck[];
|
|
45
|
+
export {};
|