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 CHANGED
@@ -1,20 +1,42 @@
1
1
  {
2
2
  "name": "gsc-axi",
3
- "version": "0.1.0",
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": ["axi", "google-search-console", "gsc", "seo", "agent", "cli", "toon"],
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": { "url": "https://github.com/radityasurya/gsc-axi/issues" },
19
+ "bugs": {
20
+ "url": "https://github.com/radityasurya/gsc-axi/issues"
21
+ },
12
22
  "license": "MIT",
13
23
  "type": "module",
14
- "bin": { "gsc-axi": "bin/gsc-axi.js" },
15
- "exports": { ".": "./src/cli.js" },
16
- "files": ["bin", "src", "skills", "README.md", "LICENSE"],
17
- "engines": { "node": ">=20" },
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
- let cached;
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
- if (cached) return cached;
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 scope = write ? WRITE_SCOPE : READ_SCOPE;
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
- return cached;
127
+ cached.set(scope, token);
128
+ return token;
123
129
  }
124
130
 
125
131
  export function resetTokenCache() {
126
- cached = undefined;
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
- ...(result?.mobileUsabilityResult?.verdict ? { mobile: result.mobileUsabilityResult.verdict } : {}),
70
- ...(result?.richResultsResult?.verdict ? { rich_results: result.richResultsResult.verdict } : {}),
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
- 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
- ],
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
- errors: entry.errors ?? 0,
123
- warnings: entry.warnings ?? 0,
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
- `Run \`${BIN} performance --by page\` for the pages behind these`,
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
- position: `${current.position.toFixed(1)} (was ${prior.position.toFixed(1)})`,
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) {