gsc-axi 0.1.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/LICENSE +21 -0
- package/README.md +128 -0
- package/bin/gsc-axi.js +8 -0
- package/package.json +27 -0
- package/skills/gsc-axi/SKILL.md +64 -0
- package/src/api.js +241 -0
- package/src/args.js +128 -0
- package/src/cli.js +137 -0
- package/src/commands/index-tools.js +169 -0
- package/src/commands/performance.js +271 -0
- package/src/commands/setup.js +103 -0
- package/src/range.js +74 -0
- package/src/skill.js +80 -0
- package/src/version.js +7 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { runAxiCli } from "axi-sdk-js";
|
|
2
|
+
// The SDK renders command output itself but does not re-export its encoder,
|
|
3
|
+
// so the static top-level help encodes through the same official TOON library.
|
|
4
|
+
import { encode } from "@toon-format/toon";
|
|
5
|
+
import { CREDENTIAL_HELP, hasCredentials, listSites, resolveSite } from "./api.js";
|
|
6
|
+
import { BIN } from "./args.js";
|
|
7
|
+
import { inspectCommand, sitemapsCommand, sitesCommand } from "./commands/index-tools.js";
|
|
8
|
+
import {
|
|
9
|
+
compareCommand,
|
|
10
|
+
opportunitiesCommand,
|
|
11
|
+
performanceCommand,
|
|
12
|
+
query,
|
|
13
|
+
totals,
|
|
14
|
+
} from "./commands/performance.js";
|
|
15
|
+
import { setupCommand } from "./commands/setup.js";
|
|
16
|
+
import { previous, window } from "./range.js";
|
|
17
|
+
import { VERSION } from "./version.js";
|
|
18
|
+
|
|
19
|
+
export const DESCRIPTION =
|
|
20
|
+
"Read Google Search Console — search performance, indexing status, and sitemaps";
|
|
21
|
+
|
|
22
|
+
const HOME_ROWS = 5;
|
|
23
|
+
|
|
24
|
+
export const TOP_HELP = `${encode({
|
|
25
|
+
usage: `${BIN} [command] [args] [flags]`,
|
|
26
|
+
commands: {
|
|
27
|
+
"(none)": "dashboard — this month's search traffic and top queries",
|
|
28
|
+
sites: "properties this account can reach",
|
|
29
|
+
performance: "clicks, impressions, CTR, position by query, page, country, device",
|
|
30
|
+
compare: "this window against the one before it",
|
|
31
|
+
opportunities: "queries ranking 4-20 with real volume",
|
|
32
|
+
inspect: "whether Google indexed a URL, and what it saw",
|
|
33
|
+
sitemaps: "list, submit",
|
|
34
|
+
setup: "hooks, status, uninstall",
|
|
35
|
+
},
|
|
36
|
+
globals: { "--site": "Target property (or GSC_SITE)" },
|
|
37
|
+
auth: "GOOGLE_APPLICATION_CREDENTIALS (service account), or GSC_CLIENT_ID/SECRET/REFRESH_TOKEN",
|
|
38
|
+
note: "Search Console finalises data on a 2-3 day delay; windows end there, not today",
|
|
39
|
+
examples: [
|
|
40
|
+
BIN,
|
|
41
|
+
`${BIN} performance --by page --range 90d`,
|
|
42
|
+
`${BIN} opportunities`,
|
|
43
|
+
`${BIN} compare --by query`,
|
|
44
|
+
`${BIN} inspect https://example.com/post`,
|
|
45
|
+
],
|
|
46
|
+
help: [`Run \`${BIN} <command> --help\` for a command reference`],
|
|
47
|
+
})}\n`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* AXI §8: no-args shows live state. Missing credentials are reported as data
|
|
51
|
+
* with a fix, not as a failure — this view is what a SessionStart hook runs.
|
|
52
|
+
*/
|
|
53
|
+
async function home() {
|
|
54
|
+
if (!hasCredentials()) {
|
|
55
|
+
return { search: "no Google credentials in the environment", help: CREDENTIAL_HELP };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sites = await listSites({});
|
|
59
|
+
if (sites.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
search: "0 Search Console properties visible to this account",
|
|
62
|
+
help: [
|
|
63
|
+
"Add the account as a user on the property: Settings -> Users and permissions",
|
|
64
|
+
"A service account needs its `client_email` added there",
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (sites.length > 1 && !process.env.GSC_SITE) {
|
|
69
|
+
return {
|
|
70
|
+
count: `${sites.length} properties`,
|
|
71
|
+
sites: sites.slice(0, HOME_ROWS).map((entry) => entry.siteUrl),
|
|
72
|
+
help: [
|
|
73
|
+
`Run \`${BIN} performance --site <property>\` for one of them`,
|
|
74
|
+
"Export GSC_SITE to make one the default",
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const site = await resolveSite(undefined);
|
|
80
|
+
const dates = window({});
|
|
81
|
+
const before = previous(dates);
|
|
82
|
+
const [current, prior, queries] = await Promise.all([
|
|
83
|
+
totals(site, dates),
|
|
84
|
+
totals(site, before),
|
|
85
|
+
query(site, dates, { dimensions: ["query"], rowLimit: HOME_ROWS }),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
if (current.impressions === 0) {
|
|
89
|
+
return {
|
|
90
|
+
site,
|
|
91
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
92
|
+
search: "0 impressions in this window",
|
|
93
|
+
help: [
|
|
94
|
+
`Run \`${BIN} performance --range 90d\` for a wider window`,
|
|
95
|
+
`Run \`${BIN} sitemaps\` to check Google has read a sitemap`,
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const change = prior.clicks ? Math.round(((current.clicks - prior.clicks) / prior.clicks) * 100) : null;
|
|
101
|
+
return {
|
|
102
|
+
site,
|
|
103
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
104
|
+
clicks: change === null ? current.clicks : `${current.clicks} (${change >= 0 ? "+" : ""}${change}% vs previous)`,
|
|
105
|
+
impressions: current.impressions,
|
|
106
|
+
ctr: `${(current.ctr * 100).toFixed(1)}%`,
|
|
107
|
+
position: Number(current.position.toFixed(1)),
|
|
108
|
+
top_queries: queries.map((entry) => ({
|
|
109
|
+
query: entry.keys?.[0] ?? "-",
|
|
110
|
+
clicks: entry.clicks,
|
|
111
|
+
position: Number((entry.position ?? 0).toFixed(1)),
|
|
112
|
+
})),
|
|
113
|
+
help: [
|
|
114
|
+
`Run \`${BIN} opportunities\` for queries ranking 4-20`,
|
|
115
|
+
`Run \`${BIN} performance --by page\` for the pages earning this`,
|
|
116
|
+
`Run \`${BIN} compare --by query\` for what moved`,
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function main() {
|
|
122
|
+
await runAxiCli({
|
|
123
|
+
description: DESCRIPTION,
|
|
124
|
+
version: VERSION,
|
|
125
|
+
topLevelHelp: TOP_HELP,
|
|
126
|
+
home,
|
|
127
|
+
commands: {
|
|
128
|
+
sites: sitesCommand,
|
|
129
|
+
performance: performanceCommand,
|
|
130
|
+
compare: compareCommand,
|
|
131
|
+
opportunities: opportunitiesCommand,
|
|
132
|
+
inspect: inspectCommand,
|
|
133
|
+
sitemaps: sitemapsCommand,
|
|
134
|
+
setup: setupCommand,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { gsc, inspectionBase, listSites, resolveSite, sitePath } from "../api.js";
|
|
3
|
+
import { BIN, helpFor, makeDispatcher, parse, required, wantsHelp } from "../args.js";
|
|
4
|
+
|
|
5
|
+
const HELP = {
|
|
6
|
+
sites: helpFor({
|
|
7
|
+
command: "sites",
|
|
8
|
+
description: "Properties this account can reach, and its permission on each",
|
|
9
|
+
usage: `${BIN} sites`,
|
|
10
|
+
examples: [`${BIN} sites`],
|
|
11
|
+
}),
|
|
12
|
+
inspect: helpFor({
|
|
13
|
+
command: "inspect",
|
|
14
|
+
description: "Whether Google has indexed a URL, and what it saw",
|
|
15
|
+
usage: `${BIN} inspect <url> [--site <property>]`,
|
|
16
|
+
examples: [`${BIN} inspect https://example.com/blog/post`],
|
|
17
|
+
}),
|
|
18
|
+
list: helpFor({
|
|
19
|
+
command: "sitemaps list",
|
|
20
|
+
description: "Submitted sitemaps, when they were last read, and any errors",
|
|
21
|
+
usage: `${BIN} sitemaps [list] [--site <property>]`,
|
|
22
|
+
examples: [`${BIN} sitemaps`],
|
|
23
|
+
}),
|
|
24
|
+
submit: helpFor({
|
|
25
|
+
command: "sitemaps submit",
|
|
26
|
+
description: "Submit a sitemap (idempotent — resubmitting an existing one is a no-op)",
|
|
27
|
+
usage: `${BIN} sitemaps submit <url> [--site <property>]`,
|
|
28
|
+
examples: [`${BIN} sitemaps submit https://example.com/sitemap.xml`],
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function sitesCommand(argv) {
|
|
33
|
+
if (wantsHelp(argv)) return HELP.sites;
|
|
34
|
+
parse(argv, { command: "sites" });
|
|
35
|
+
const sites = await listSites({});
|
|
36
|
+
|
|
37
|
+
if (sites.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
sites: "0 properties visible to this account",
|
|
40
|
+
help: [
|
|
41
|
+
"Add the account as a user on the property in Search Console: Settings -> Users and permissions",
|
|
42
|
+
"A service account needs its `client_email` added there, not your own address",
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
count: `${sites.length} total`,
|
|
48
|
+
sites: sites.map((entry) => ({ property: entry.siteUrl, permission: entry.permissionLevel })),
|
|
49
|
+
help: [
|
|
50
|
+
`Run \`${BIN} performance --site <property>\` for its search traffic`,
|
|
51
|
+
"Export GSC_SITE to make one the default",
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The inspection payload nests three verdicts an agent has to act on. */
|
|
57
|
+
function inspection(result) {
|
|
58
|
+
const index = result?.indexStatusResult ?? {};
|
|
59
|
+
return {
|
|
60
|
+
verdict: index.verdict ?? "UNKNOWN",
|
|
61
|
+
coverage: index.coverageState ?? "-",
|
|
62
|
+
indexed: index.verdict === "PASS",
|
|
63
|
+
...(index.lastCrawlTime ? { last_crawled: String(index.lastCrawlTime).slice(0, 19).replace("T", " ") } : {}),
|
|
64
|
+
...(index.googleCanonical ? { google_canonical: index.googleCanonical } : {}),
|
|
65
|
+
...(index.userCanonical && index.userCanonical !== index.googleCanonical
|
|
66
|
+
? { your_canonical: index.userCanonical }
|
|
67
|
+
: {}),
|
|
68
|
+
...(index.robotsTxtState ? { robots: index.robotsTxtState } : {}),
|
|
69
|
+
...(result?.mobileUsabilityResult?.verdict ? { mobile: result.mobileUsabilityResult.verdict } : {}),
|
|
70
|
+
...(result?.richResultsResult?.verdict ? { rich_results: result.richResultsResult.verdict } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function inspectCommand(argv) {
|
|
75
|
+
if (wantsHelp(argv)) return HELP.inspect;
|
|
76
|
+
const { values, positionals } = parse(argv, { command: "inspect" });
|
|
77
|
+
const url = required(positionals[0], "<url>", "inspect", `${BIN} inspect https://example.com/page`);
|
|
78
|
+
const site = await resolveSite(values.site);
|
|
79
|
+
|
|
80
|
+
const payload = await gsc("/urlInspection/index:inspect", {
|
|
81
|
+
base: inspectionBase,
|
|
82
|
+
method: "POST",
|
|
83
|
+
body: { inspectionUrl: url, siteUrl: site },
|
|
84
|
+
});
|
|
85
|
+
const result = inspection(payload?.inspectionResult);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
url,
|
|
89
|
+
site,
|
|
90
|
+
...result,
|
|
91
|
+
help: result.indexed
|
|
92
|
+
? []
|
|
93
|
+
: [
|
|
94
|
+
"A NEUTRAL or FAIL verdict means Google has not indexed this URL",
|
|
95
|
+
`Run \`${BIN} sitemaps\` to check the sitemap covering it was read`,
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function sitemapsList(argv) {
|
|
101
|
+
if (wantsHelp(argv)) return HELP.list;
|
|
102
|
+
const { values } = parse(argv, { command: "sitemaps list" });
|
|
103
|
+
const site = await resolveSite(values.site);
|
|
104
|
+
const payload = await gsc(sitePath(site, "/sitemaps"), {});
|
|
105
|
+
const sitemaps = payload.sitemap ?? [];
|
|
106
|
+
|
|
107
|
+
if (sitemaps.length === 0) {
|
|
108
|
+
return {
|
|
109
|
+
site,
|
|
110
|
+
sitemaps: "0 sitemaps submitted for this property",
|
|
111
|
+
help: [`Run \`${BIN} sitemaps submit <url>\` to add one`],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
site,
|
|
116
|
+
count: `${sitemaps.length} total`,
|
|
117
|
+
sitemaps: sitemaps.map((entry) => ({
|
|
118
|
+
path: entry.path,
|
|
119
|
+
type: entry.type ?? "-",
|
|
120
|
+
submitted: String(entry.lastSubmitted ?? "").slice(0, 10),
|
|
121
|
+
last_read: String(entry.lastDownloaded ?? "").slice(0, 10) || "never",
|
|
122
|
+
errors: entry.errors ?? 0,
|
|
123
|
+
warnings: entry.warnings ?? 0,
|
|
124
|
+
pending: Boolean(entry.isPending),
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function sitemapsSubmit(argv) {
|
|
130
|
+
if (wantsHelp(argv)) return HELP.submit;
|
|
131
|
+
const { values, positionals } = parse(argv, { command: "sitemaps submit" });
|
|
132
|
+
const url = required(
|
|
133
|
+
positionals[0],
|
|
134
|
+
"<url>",
|
|
135
|
+
"sitemaps submit",
|
|
136
|
+
`${BIN} sitemaps submit https://example.com/sitemap.xml`,
|
|
137
|
+
);
|
|
138
|
+
if (!/^https?:\/\//.test(url)) {
|
|
139
|
+
throw new AxiError("a sitemap must be submitted as a full URL", "VALIDATION_ERROR", [
|
|
140
|
+
`Example: ${BIN} sitemaps submit https://example.com/sitemap.xml`,
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
const site = await resolveSite(values.site);
|
|
144
|
+
|
|
145
|
+
const existing = await gsc(sitePath(site, "/sitemaps"), {});
|
|
146
|
+
const already = (existing.sitemap ?? []).some((entry) => entry.path === url);
|
|
147
|
+
|
|
148
|
+
// PUT is idempotent upstream, but saying so beats a second identical call
|
|
149
|
+
// looking like it changed something.
|
|
150
|
+
await gsc(sitePath(site, `/sitemaps/${encodeURIComponent(url)}`), { method: "PUT", write: true });
|
|
151
|
+
return {
|
|
152
|
+
site,
|
|
153
|
+
sitemap: url,
|
|
154
|
+
...(already ? { unchanged: true, note: "already submitted (no-op)" } : { submitted: true }),
|
|
155
|
+
help: [`Run \`${BIN} sitemaps\` in a few hours to see when Google read it`],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const sitemapsCommand = makeDispatcher(
|
|
160
|
+
"sitemaps",
|
|
161
|
+
{ list: sitemapsList, submit: sitemapsSubmit },
|
|
162
|
+
{
|
|
163
|
+
fallback: "list",
|
|
164
|
+
summary: {
|
|
165
|
+
list: "Submitted sitemaps and their read status (default)",
|
|
166
|
+
submit: "Submit a sitemap (idempotent)",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { gsc, resolveSite, sitePath } from "../api.js";
|
|
3
|
+
import { DATE_FLAGS, dateFlagHelp, previous, window } from "../range.js";
|
|
4
|
+
import { BIN, helpFor, parse, positiveInt, wantsHelp } from "../args.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LIMIT = 20;
|
|
7
|
+
|
|
8
|
+
// Search Console's dimension names, plus the plurals an agent is likely to type.
|
|
9
|
+
export const DIMENSIONS = ["query", "page", "country", "device", "date", "searchAppearance"];
|
|
10
|
+
const PLURALS = new Map([
|
|
11
|
+
["queries", "query"],
|
|
12
|
+
["pages", "page"],
|
|
13
|
+
["countries", "country"],
|
|
14
|
+
["devices", "device"],
|
|
15
|
+
["dates", "date"],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const TYPES = ["web", "image", "video", "news", "discover", "googleNews"];
|
|
19
|
+
|
|
20
|
+
const HELP = {
|
|
21
|
+
performance: helpFor({
|
|
22
|
+
command: "performance",
|
|
23
|
+
description: "Clicks, impressions, CTR, and average position, broken down by a dimension",
|
|
24
|
+
usage: `${BIN} performance [--by query|page|country|device|date] [--range <window>] [--limit <n>] [--type web|discover|...]`,
|
|
25
|
+
flags: {
|
|
26
|
+
...dateFlagHelp(),
|
|
27
|
+
"--by": `Dimension to group by (default query): ${DIMENSIONS.join(", ")}`,
|
|
28
|
+
"--limit": `Rows to show (default ${DEFAULT_LIMIT})`,
|
|
29
|
+
"--type": `Search type (default web): ${TYPES.join(", ")}`,
|
|
30
|
+
"--country": "Only this country, as a 3-letter code (e.g. nld)",
|
|
31
|
+
"--device": "Only this device: desktop, mobile, tablet",
|
|
32
|
+
"--contains": "Only rows whose dimension value contains this text",
|
|
33
|
+
},
|
|
34
|
+
examples: [
|
|
35
|
+
`${BIN} performance`,
|
|
36
|
+
`${BIN} performance --by page --range 90d`,
|
|
37
|
+
`${BIN} performance --by query --contains openpanel`,
|
|
38
|
+
],
|
|
39
|
+
}),
|
|
40
|
+
compare: helpFor({
|
|
41
|
+
command: "compare",
|
|
42
|
+
description: "This window against the one immediately before it",
|
|
43
|
+
usage: `${BIN} compare [--range <window>] [--by query|page]`,
|
|
44
|
+
flags: {
|
|
45
|
+
...dateFlagHelp(),
|
|
46
|
+
"--by": "Also break the change down by this dimension",
|
|
47
|
+
"--limit": `Rows when --by is given (default ${DEFAULT_LIMIT})`,
|
|
48
|
+
},
|
|
49
|
+
examples: [`${BIN} compare`, `${BIN} compare --range 90d --by query`],
|
|
50
|
+
}),
|
|
51
|
+
opportunities: helpFor({
|
|
52
|
+
command: "opportunities",
|
|
53
|
+
description: "Queries ranking 4-20 with real volume — the cheapest positions to improve",
|
|
54
|
+
usage: `${BIN} opportunities [--min-impressions <n>] [--range <window>] [--limit <n>]`,
|
|
55
|
+
flags: {
|
|
56
|
+
...dateFlagHelp(),
|
|
57
|
+
"--min-impressions": "Volume floor (default 50)",
|
|
58
|
+
"--limit": `Rows to show (default ${DEFAULT_LIMIT})`,
|
|
59
|
+
},
|
|
60
|
+
examples: [`${BIN} opportunities`, `${BIN} opportunities --min-impressions 200 --range 90d`],
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function resolveDimension(input) {
|
|
65
|
+
const wanted = String(input);
|
|
66
|
+
if (DIMENSIONS.includes(wanted)) return wanted;
|
|
67
|
+
const lower = wanted.toLowerCase();
|
|
68
|
+
const exact = DIMENSIONS.find((dimension) => dimension.toLowerCase() === lower);
|
|
69
|
+
if (exact) return exact;
|
|
70
|
+
const singular = PLURALS.get(lower);
|
|
71
|
+
if (singular) return singular;
|
|
72
|
+
throw new AxiError(`unknown dimension ${input}`, "VALIDATION_ERROR", [
|
|
73
|
+
`valid dimensions: ${DIMENSIONS.join(", ")}`,
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function filters(values) {
|
|
78
|
+
const built = [];
|
|
79
|
+
if (values.country) built.push({ dimension: "country", operator: "equals", expression: values.country });
|
|
80
|
+
if (values.device) {
|
|
81
|
+
built.push({ dimension: "device", operator: "equals", expression: String(values.device).toUpperCase() });
|
|
82
|
+
}
|
|
83
|
+
if (values.contains) {
|
|
84
|
+
built.push({ dimension: values.by ?? "query", operator: "contains", expression: values.contains });
|
|
85
|
+
}
|
|
86
|
+
return built.length ? [{ filters: built }] : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ctr arrives as a fraction and position with float noise. */
|
|
90
|
+
function row(entry, dimensions) {
|
|
91
|
+
const projected = {};
|
|
92
|
+
dimensions.forEach((dimension, index) => {
|
|
93
|
+
projected[dimension] = entry.keys?.[index] ?? "-";
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
...projected,
|
|
97
|
+
clicks: entry.clicks ?? 0,
|
|
98
|
+
impressions: entry.impressions ?? 0,
|
|
99
|
+
ctr: `${((entry.ctr ?? 0) * 100).toFixed(1)}%`,
|
|
100
|
+
position: Number((entry.position ?? 0).toFixed(1)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function query(site, { startDate, endDate }, body, options = {}) {
|
|
105
|
+
const payload = await gsc(sitePath(site, "/searchAnalytics/query"), {
|
|
106
|
+
...options,
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: { startDate, endDate, dataState: "final", ...body },
|
|
109
|
+
});
|
|
110
|
+
return payload.rows ?? [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Totals for a window: one query with no dimensions. */
|
|
114
|
+
export async function totals(site, dates, extra = {}, options = {}) {
|
|
115
|
+
const [row_] = await query(site, dates, { ...extra }, options);
|
|
116
|
+
return {
|
|
117
|
+
clicks: row_?.clicks ?? 0,
|
|
118
|
+
impressions: row_?.impressions ?? 0,
|
|
119
|
+
ctr: row_?.ctr ?? 0,
|
|
120
|
+
position: row_?.position ?? 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function performanceCommand(argv) {
|
|
125
|
+
if (wantsHelp(argv)) return HELP.performance;
|
|
126
|
+
const { values } = parse(argv, {
|
|
127
|
+
command: "performance",
|
|
128
|
+
flags: {
|
|
129
|
+
...DATE_FLAGS,
|
|
130
|
+
by: { type: "string" },
|
|
131
|
+
limit: { type: "string" },
|
|
132
|
+
type: { type: "string" },
|
|
133
|
+
country: { type: "string" },
|
|
134
|
+
device: { type: "string" },
|
|
135
|
+
contains: { type: "string" },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const dimension = resolveDimension(values.by ?? "query");
|
|
139
|
+
const limit = positiveInt(values.limit, "--limit", DEFAULT_LIMIT);
|
|
140
|
+
const type = values.type ?? "web";
|
|
141
|
+
if (!TYPES.includes(type)) {
|
|
142
|
+
throw new AxiError(`unknown --type ${type}`, "VALIDATION_ERROR", [`valid types: ${TYPES.join(", ")}`]);
|
|
143
|
+
}
|
|
144
|
+
const dates = window(values);
|
|
145
|
+
const site = await resolveSite(values.site);
|
|
146
|
+
|
|
147
|
+
const rows = await query(site, dates, {
|
|
148
|
+
dimensions: [dimension],
|
|
149
|
+
rowLimit: limit,
|
|
150
|
+
type,
|
|
151
|
+
dimensionFilterGroups: filters({ ...values, by: dimension }),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (rows.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
site,
|
|
157
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
158
|
+
[dimension]: `0 rows in this window`,
|
|
159
|
+
help: [
|
|
160
|
+
`Run \`${BIN} performance --range 90d\` for a wider window`,
|
|
161
|
+
"Search Console finalises data on a 2-3 day delay",
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
site,
|
|
167
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
168
|
+
count: `${rows.length} shown`,
|
|
169
|
+
[`by_${dimension}`]: rows.map((entry) => row(entry, [dimension])),
|
|
170
|
+
help: [
|
|
171
|
+
`Run \`${BIN} performance --by page\` for the pages behind these`,
|
|
172
|
+
`Run \`${BIN} opportunities\` for queries ranking 4-20`,
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function delta(now, before) {
|
|
178
|
+
if (!before) return now ? "+100%" : "0%";
|
|
179
|
+
const change = ((now - before) / before) * 100;
|
|
180
|
+
return `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function compareCommand(argv) {
|
|
184
|
+
if (wantsHelp(argv)) return HELP.compare;
|
|
185
|
+
const { values } = parse(argv, {
|
|
186
|
+
command: "compare",
|
|
187
|
+
flags: { ...DATE_FLAGS, by: { type: "string" }, limit: { type: "string" } },
|
|
188
|
+
});
|
|
189
|
+
const dates = window(values);
|
|
190
|
+
const before = previous(dates);
|
|
191
|
+
const site = await resolveSite(values.site);
|
|
192
|
+
|
|
193
|
+
const [current, prior] = await Promise.all([totals(site, dates), totals(site, before)]);
|
|
194
|
+
const summary = {
|
|
195
|
+
clicks: `${current.clicks} (${delta(current.clicks, prior.clicks)})`,
|
|
196
|
+
impressions: `${current.impressions} (${delta(current.impressions, prior.impressions)})`,
|
|
197
|
+
ctr: `${(current.ctr * 100).toFixed(1)}% (was ${(prior.ctr * 100).toFixed(1)}%)`,
|
|
198
|
+
position: `${current.position.toFixed(1)} (was ${prior.position.toFixed(1)})`,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (!values.by) {
|
|
202
|
+
return {
|
|
203
|
+
site,
|
|
204
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
205
|
+
against: `${before.startDate}..${before.endDate}`,
|
|
206
|
+
change: summary,
|
|
207
|
+
help: [`Run \`${BIN} compare --by query\` to see which queries moved`],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const dimension = resolveDimension(values.by);
|
|
212
|
+
const limit = positiveInt(values.limit, "--limit", DEFAULT_LIMIT);
|
|
213
|
+
const [nowRows, beforeRows] = await Promise.all([
|
|
214
|
+
query(site, dates, { dimensions: [dimension], rowLimit: 200 }),
|
|
215
|
+
query(site, before, { dimensions: [dimension], rowLimit: 200 }),
|
|
216
|
+
]);
|
|
217
|
+
const priorClicks = new Map(beforeRows.map((entry) => [entry.keys?.[0], entry.clicks ?? 0]));
|
|
218
|
+
|
|
219
|
+
const moved = nowRows
|
|
220
|
+
.map((entry) => {
|
|
221
|
+
const key = entry.keys?.[0];
|
|
222
|
+
const was = priorClicks.get(key) ?? 0;
|
|
223
|
+
return { [dimension]: key, clicks: entry.clicks, was, change: (entry.clicks ?? 0) - was };
|
|
224
|
+
})
|
|
225
|
+
.sort((a, b) => Math.abs(b.change) - Math.abs(a.change))
|
|
226
|
+
.slice(0, limit);
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
site,
|
|
230
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
231
|
+
against: `${before.startDate}..${before.endDate}`,
|
|
232
|
+
change: summary,
|
|
233
|
+
moved,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function opportunitiesCommand(argv) {
|
|
238
|
+
if (wantsHelp(argv)) return HELP.opportunities;
|
|
239
|
+
const { values } = parse(argv, {
|
|
240
|
+
command: "opportunities",
|
|
241
|
+
flags: { ...DATE_FLAGS, "min-impressions": { type: "string" }, limit: { type: "string" } },
|
|
242
|
+
});
|
|
243
|
+
const floor = positiveInt(values["min-impressions"], "--min-impressions", 50);
|
|
244
|
+
const limit = positiveInt(values.limit, "--limit", DEFAULT_LIMIT);
|
|
245
|
+
const dates = window(values);
|
|
246
|
+
const site = await resolveSite(values.site);
|
|
247
|
+
|
|
248
|
+
// The API cannot filter on position, so pull a wide page and rank locally.
|
|
249
|
+
const rows = await query(site, dates, { dimensions: ["query"], rowLimit: 1000 });
|
|
250
|
+
const found = rows
|
|
251
|
+
.filter((entry) => (entry.position ?? 0) >= 4 && (entry.position ?? 0) <= 20)
|
|
252
|
+
.filter((entry) => (entry.impressions ?? 0) >= floor)
|
|
253
|
+
.sort((a, b) => b.impressions - a.impressions)
|
|
254
|
+
.slice(0, limit);
|
|
255
|
+
|
|
256
|
+
if (found.length === 0) {
|
|
257
|
+
return {
|
|
258
|
+
site,
|
|
259
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
260
|
+
opportunities: `0 queries rank 4-20 with at least ${floor} impressions`,
|
|
261
|
+
help: [`Run \`${BIN} opportunities --min-impressions 10\` to lower the floor`],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
site,
|
|
266
|
+
window: `${dates.startDate}..${dates.endDate}`,
|
|
267
|
+
count: `${found.length} of ${rows.length} queries`,
|
|
268
|
+
note: "ranking 4-20 — a page of one already, so position gains convert fastest",
|
|
269
|
+
opportunities: found.map((entry) => row(entry, ["query"])),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import {
|
|
3
|
+
installSessionStartHooks,
|
|
4
|
+
sessionStartHookStatus,
|
|
5
|
+
uninstallSessionStartHooks,
|
|
6
|
+
} from "axi-sdk-js";
|
|
7
|
+
import { BIN, helpFor, makeDispatcher, parse, wantsHelp } from "../args.js";
|
|
8
|
+
|
|
9
|
+
const MARKER = "gsc-axi";
|
|
10
|
+
|
|
11
|
+
const HELP = {
|
|
12
|
+
hooks: helpFor({
|
|
13
|
+
command: "setup hooks",
|
|
14
|
+
description: "Install SessionStart hooks so agents see your deployments before acting",
|
|
15
|
+
usage: `${BIN} setup hooks [--repo]`,
|
|
16
|
+
flags: { "--repo": "Install into this repository instead of the user config" },
|
|
17
|
+
examples: [`${BIN} setup hooks`, `${BIN} setup hooks --repo`],
|
|
18
|
+
}),
|
|
19
|
+
status: helpFor({
|
|
20
|
+
command: "setup status",
|
|
21
|
+
description: "Report which agent session hooks are installed",
|
|
22
|
+
usage: `${BIN} setup status [--repo]`,
|
|
23
|
+
examples: [`${BIN} setup status`],
|
|
24
|
+
}),
|
|
25
|
+
uninstall: helpFor({
|
|
26
|
+
command: "setup uninstall",
|
|
27
|
+
description: "Remove the session hooks this tool installed",
|
|
28
|
+
usage: `${BIN} setup uninstall [--repo]`,
|
|
29
|
+
examples: [`${BIN} setup uninstall`],
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function scopeOf(values) {
|
|
34
|
+
return values.repo ? { scope: "project", projectDir: process.cwd() } : { scope: "user" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SCOPE_FLAG = { repo: { type: "boolean" } };
|
|
38
|
+
|
|
39
|
+
function statusRows(status) {
|
|
40
|
+
return [
|
|
41
|
+
{ agent: "claude", installed: status.claude.installed, path: status.claude.path },
|
|
42
|
+
{ agent: "codex", installed: status.codex.installed, path: status.codex.path },
|
|
43
|
+
{ agent: "opencode", installed: status.opencode.installed, path: status.opencode.path },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function hooks(argv) {
|
|
48
|
+
if (wantsHelp(argv)) return HELP.hooks;
|
|
49
|
+
const { values } = parse(argv, { command: "setup hooks", flags: SCOPE_FLAG });
|
|
50
|
+
const scope = scopeOf(values);
|
|
51
|
+
const problems = [];
|
|
52
|
+
installSessionStartHooks({ marker: MARKER, ...scope, onError: (m) => problems.push(m) });
|
|
53
|
+
const status = sessionStartHookStatus({ marker: MARKER, ...scope });
|
|
54
|
+
return {
|
|
55
|
+
scope: status.scope,
|
|
56
|
+
hooks: statusRows(status),
|
|
57
|
+
...(status.codex.installed && !status.codex.userFeatureEnabled
|
|
58
|
+
? { note: `Codex needs [features].hooks = true in ${status.codex.userFeaturePath}` }
|
|
59
|
+
: {}),
|
|
60
|
+
...(problems.length ? { warning: problems.join("; ") } : {}),
|
|
61
|
+
help: ["Restart your agent session so the new hook takes effect"],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function status(argv) {
|
|
66
|
+
if (wantsHelp(argv)) return HELP.status;
|
|
67
|
+
const { values } = parse(argv, { command: "setup status", flags: SCOPE_FLAG });
|
|
68
|
+
const result = sessionStartHookStatus({ marker: MARKER, ...scopeOf(values) });
|
|
69
|
+
const installed = statusRows(result).filter((row) => row.installed);
|
|
70
|
+
return {
|
|
71
|
+
scope: result.scope,
|
|
72
|
+
hooks: statusRows(result),
|
|
73
|
+
...(installed.length === 0
|
|
74
|
+
? { help: [`Run \`${BIN} setup hooks\` to install them`] }
|
|
75
|
+
: { help: [`Run \`${BIN} setup uninstall\` to remove them`] }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function uninstall(argv) {
|
|
80
|
+
if (wantsHelp(argv)) return HELP.uninstall;
|
|
81
|
+
const { values } = parse(argv, { command: "setup uninstall", flags: SCOPE_FLAG });
|
|
82
|
+
const scope = scopeOf(values);
|
|
83
|
+
const problems = [];
|
|
84
|
+
uninstallSessionStartHooks({ marker: MARKER, ...scope, onError: (m) => problems.push(m) });
|
|
85
|
+
const result = sessionStartHookStatus({ marker: MARKER, ...scope });
|
|
86
|
+
return {
|
|
87
|
+
scope: result.scope,
|
|
88
|
+
hooks: statusRows(result),
|
|
89
|
+
...(problems.length ? { warning: problems.join("; ") } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const setupCommand = makeDispatcher(
|
|
94
|
+
"setup",
|
|
95
|
+
{ hooks, status, uninstall },
|
|
96
|
+
{
|
|
97
|
+
summary: {
|
|
98
|
+
hooks: "Install SessionStart hooks for Claude Code, Codex, and OpenCode",
|
|
99
|
+
status: "Report which session hooks are installed",
|
|
100
|
+
uninstall: "Remove the session hooks this tool installed",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
);
|