gsc-axi 0.1.0 → 0.1.1
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/package.json +29 -7
- package/src/api.js +22 -6
- package/src/commands/index-tools.js +19 -10
- package/src/commands/performance.js +13 -2
package/package.json
CHANGED
|
@@ -1,20 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsc-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "AXI-compliant Google Search Console CLI — search performance, URL inspection, and sitemaps with token-efficient TOON output",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"axi",
|
|
7
|
+
"google-search-console",
|
|
8
|
+
"gsc",
|
|
9
|
+
"seo",
|
|
10
|
+
"agent",
|
|
11
|
+
"cli",
|
|
12
|
+
"toon"
|
|
13
|
+
],
|
|
6
14
|
"repository": {
|
|
7
15
|
"type": "git",
|
|
8
16
|
"url": "git+https://github.com/radityasurya/gsc-axi.git"
|
|
9
17
|
},
|
|
10
18
|
"homepage": "https://github.com/radityasurya/gsc-axi#readme",
|
|
11
|
-
"bugs": {
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/radityasurya/gsc-axi/issues"
|
|
21
|
+
},
|
|
12
22
|
"license": "MIT",
|
|
13
23
|
"type": "module",
|
|
14
|
-
"bin": {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
24
|
+
"bin": {
|
|
25
|
+
"gsc-axi": "bin/gsc-axi.js"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src",
|
|
33
|
+
"skills",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
18
40
|
"scripts": {
|
|
19
41
|
"test": "node --test",
|
|
20
42
|
"build:skill": "node scripts/build-skill.mjs",
|
package/src/api.js
CHANGED
|
@@ -108,22 +108,28 @@ async function exchange(body, fetchImpl, context) {
|
|
|
108
108
|
return payload.access_token;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// Keyed by scope: a token minted for the read scope cannot perform a write, and
|
|
112
|
+
// `sitemaps submit` reads the existing list before it PUTs. Caching one token
|
|
113
|
+
// for the process meant the write reused the read-only one and Google answered
|
|
114
|
+
// "Request had insufficient authentication scopes".
|
|
115
|
+
const cached = new Map();
|
|
116
|
+
|
|
112
117
|
export async function accessToken(options = {}) {
|
|
113
118
|
const { env = process.env, write = false, fetchImpl = fetch } = options;
|
|
114
|
-
|
|
119
|
+
const scope = write ? WRITE_SCOPE : READ_SCOPE;
|
|
120
|
+
if (cached.has(scope)) return cached.get(scope);
|
|
115
121
|
if (!hasCredentials(env)) {
|
|
116
122
|
throw new AxiError("No Google credentials in the environment", "AUTH_REQUIRED", CREDENTIAL_HELP);
|
|
117
123
|
}
|
|
118
|
-
const
|
|
119
|
-
cached = env.GOOGLE_APPLICATION_CREDENTIALS
|
|
124
|
+
const token = env.GOOGLE_APPLICATION_CREDENTIALS
|
|
120
125
|
? await serviceAccountToken(env.GOOGLE_APPLICATION_CREDENTIALS, scope, fetchImpl)
|
|
121
126
|
: await refreshTokenGrant(env, fetchImpl);
|
|
122
|
-
|
|
127
|
+
cached.set(scope, token);
|
|
128
|
+
return token;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export function resetTokenCache() {
|
|
126
|
-
cached
|
|
132
|
+
cached.clear();
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
function apiError(status, payload, path) {
|
|
@@ -137,6 +143,16 @@ function apiError(status, payload, path) {
|
|
|
137
143
|
]);
|
|
138
144
|
}
|
|
139
145
|
if (status === 403) {
|
|
146
|
+
// Google returns 403 for two unrelated causes; sending someone to the
|
|
147
|
+
// property's user list when the problem is the token's scope wastes the
|
|
148
|
+
// one piece of information the error actually carried.
|
|
149
|
+
if (/insufficient (authentication )?scope/i.test(message)) {
|
|
150
|
+
return new AxiError(message, "AUTH_ERROR", [
|
|
151
|
+
"The token was minted for read-only access but this command writes",
|
|
152
|
+
"An OAuth refresh token granted `webmasters.readonly` cannot submit sitemaps",
|
|
153
|
+
"Use a service account, or re-consent with the `webmasters` scope",
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
140
156
|
return new AxiError(message, "AUTH_ERROR", [
|
|
141
157
|
"This account has no access to that property",
|
|
142
158
|
"Add it as a user in Search Console: Settings -> Users and permissions",
|
|
@@ -53,6 +53,9 @@ export async function sitesCommand(argv) {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const verdict = (block) =>
|
|
57
|
+
block?.verdict && block.verdict !== "VERDICT_UNSPECIFIED" ? block.verdict : undefined;
|
|
58
|
+
|
|
56
59
|
/** The inspection payload nests three verdicts an agent has to act on. */
|
|
57
60
|
function inspection(result) {
|
|
58
61
|
const index = result?.indexStatusResult ?? {};
|
|
@@ -66,8 +69,10 @@ function inspection(result) {
|
|
|
66
69
|
? { your_canonical: index.userCanonical }
|
|
67
70
|
: {}),
|
|
68
71
|
...(index.robotsTxtState ? { robots: index.robotsTxtState } : {}),
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// VERDICT_UNSPECIFIED is Google's "no data for this check" — printing it
|
|
73
|
+
// implies a result was returned when none was.
|
|
74
|
+
...(verdict(result?.mobileUsabilityResult) ? { mobile: verdict(result.mobileUsabilityResult) } : {}),
|
|
75
|
+
...(verdict(result?.richResultsResult) ? { rich_results: verdict(result.richResultsResult) } : {}),
|
|
71
76
|
};
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -88,12 +93,15 @@ export async function inspectCommand(argv) {
|
|
|
88
93
|
url,
|
|
89
94
|
site,
|
|
90
95
|
...result,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
// AXI §9: a detail view that fully answers the question takes no suggestions.
|
|
97
|
+
...(result.indexed
|
|
98
|
+
? {}
|
|
99
|
+
: {
|
|
100
|
+
help: [
|
|
101
|
+
"A NEUTRAL or FAIL verdict means Google has not indexed this URL",
|
|
102
|
+
`Run \`${BIN} sitemaps\` to check the sitemap covering it was read`,
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
|
|
@@ -119,8 +127,9 @@ async function sitemapsList(argv) {
|
|
|
119
127
|
type: entry.type ?? "-",
|
|
120
128
|
submitted: String(entry.lastSubmitted ?? "").slice(0, 10),
|
|
121
129
|
last_read: String(entry.lastDownloaded ?? "").slice(0, 10) || "never",
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
// Google returns these as strings; numbers render bare and compare right.
|
|
131
|
+
errors: Number(entry.errors ?? 0),
|
|
132
|
+
warnings: Number(entry.warnings ?? 0),
|
|
124
133
|
pending: Boolean(entry.isPending),
|
|
125
134
|
})),
|
|
126
135
|
};
|
|
@@ -168,12 +168,21 @@ export async function performanceCommand(argv) {
|
|
|
168
168
|
count: `${rows.length} shown`,
|
|
169
169
|
[`by_${dimension}`]: rows.map((entry) => row(entry, [dimension])),
|
|
170
170
|
help: [
|
|
171
|
-
|
|
171
|
+
// Suggesting the view the agent is already looking at is noise (§9).
|
|
172
|
+
...(dimension === "page"
|
|
173
|
+
? [`Run \`${BIN} performance --by query\` for the queries earning these`]
|
|
174
|
+
: [`Run \`${BIN} performance --by page\` for the pages behind these`]),
|
|
172
175
|
`Run \`${BIN} opportunities\` for queries ranking 4-20`,
|
|
173
176
|
],
|
|
174
177
|
};
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
/** Lower average position is better; unchanged within 0.05 is "flat". */
|
|
181
|
+
function direction(now, before) {
|
|
182
|
+
if (Math.abs(now - before) < 0.05) return "flat";
|
|
183
|
+
return now < before ? "better" : "worse";
|
|
184
|
+
}
|
|
185
|
+
|
|
177
186
|
function delta(now, before) {
|
|
178
187
|
if (!before) return now ? "+100%" : "0%";
|
|
179
188
|
const change = ((now - before) / before) * 100;
|
|
@@ -195,7 +204,9 @@ export async function compareCommand(argv) {
|
|
|
195
204
|
clicks: `${current.clicks} (${delta(current.clicks, prior.clicks)})`,
|
|
196
205
|
impressions: `${current.impressions} (${delta(current.impressions, prior.impressions)})`,
|
|
197
206
|
ctr: `${(current.ctr * 100).toFixed(1)}% (was ${(prior.ctr * 100).toFixed(1)}%)`,
|
|
198
|
-
|
|
207
|
+
// Position is the one metric where a bigger number is worse, so say so
|
|
208
|
+
// rather than leaving a reader to infer it from the direction.
|
|
209
|
+
position: `${current.position.toFixed(1)} (was ${prior.position.toFixed(1)}, ${direction(current.position, prior.position)})`,
|
|
199
210
|
};
|
|
200
211
|
|
|
201
212
|
if (!values.by) {
|