@nurkamol/seo-audit 1.32.0 → 1.33.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/README.md +28 -1
- package/action.yml +10 -0
- package/bin/seo-audit.mjs +37 -0
- package/package.json +1 -1
- package/src/config.mjs +28 -0
- package/src/console.mjs +209 -14
- package/src/options.mjs +2 -1
- package/src/psi.mjs +4 -9
- package/src/serve.mjs +5 -0
package/README.md
CHANGED
|
@@ -253,7 +253,34 @@ nobody has been shown.
|
|
|
253
253
|
Opt-in, and the only thing in this tool that needs an account. It reads
|
|
254
254
|
`GSC_CLIENT_ID`, `GSC_CLIENT_SECRET` and `GSC_REFRESH_TOKEN` from the
|
|
255
255
|
environment or from `~/.config/seo-audit/.env`, deliberately outside any
|
|
256
|
-
repository.
|
|
256
|
+
repository.
|
|
257
|
+
|
|
258
|
+
Getting the third one used to be left as an exercise, which is why this had
|
|
259
|
+
never run against the live API. Now:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
# once, in console.cloud.google.com:
|
|
263
|
+
# enable the Search Console API, then create an OAuth client of type
|
|
264
|
+
# "Desktop app", and put its two values in ~/.config/seo-audit/.env
|
|
265
|
+
# GSC_CLIENT_ID=…apps.googleusercontent.com
|
|
266
|
+
# GSC_CLIENT_SECRET=…
|
|
267
|
+
|
|
268
|
+
npx @nurkamol/seo-audit --search-console-login
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
That opens a browser, you sign in, and the refresh token is written to the same
|
|
272
|
+
file at mode `600`. It is never printed — a token echoed to a terminal is a
|
|
273
|
+
token in a scrollback buffer and probably in a shell history file. The scope is
|
|
274
|
+
read-only. Afterwards it lists the properties the account can actually read,
|
|
275
|
+
because a token that can read nothing looks exactly like one that works, right
|
|
276
|
+
up until an audit reports the property was not found.
|
|
277
|
+
|
|
278
|
+
Interactive by nature, so it is deliberately **not** a GitHub Action input: a
|
|
279
|
+
flag CI can accept and never satisfy is worse than no flag. In CI, set the
|
|
280
|
+
three variables as secrets.
|
|
281
|
+
|
|
282
|
+
The whole setup, the property-naming trap and what each failure note means:
|
|
283
|
+
[docs/search-console.md](docs/search-console.md). A domain property is named `sc-domain:example.com` rather than by
|
|
257
284
|
its URL. Missing credentials, or a property the account cannot read, are a note
|
|
258
285
|
and the rest of the audit is unaffected.
|
|
259
286
|
|
package/action.yml
CHANGED
|
@@ -32,6 +32,15 @@ inputs:
|
|
|
32
32
|
under it, sampled. Set the PSI_API_KEY env var from a secret to lift the
|
|
33
33
|
quota.
|
|
34
34
|
required: false
|
|
35
|
+
search-console:
|
|
36
|
+
description: >
|
|
37
|
+
Order findings by what the pages actually do in Google. The property
|
|
38
|
+
exactly as Search Console names it — a domain property is
|
|
39
|
+
"sc-domain:example.com", not a URL. Needs GSC_CLIENT_ID,
|
|
40
|
+
GSC_CLIENT_SECRET and GSC_REFRESH_TOKEN in the environment, from secrets;
|
|
41
|
+
get them once with --search-console-login on a machine with a browser.
|
|
42
|
+
See docs/search-console.md.
|
|
43
|
+
required: false
|
|
35
44
|
check-external:
|
|
36
45
|
description: >
|
|
37
46
|
Also check links pointing off the site. Off by default because other
|
|
@@ -143,6 +152,7 @@ runs:
|
|
|
143
152
|
[ -n "$INPUT_LIMIT" ] && args+=(--limit "$INPUT_LIMIT")
|
|
144
153
|
[ -n "$INPUT_PSI" ] && args+=(--psi "$INPUT_PSI")
|
|
145
154
|
[ -n "$INPUT_PSI_SAMPLE" ] && args+=(--psi-sample "$INPUT_PSI_SAMPLE")
|
|
155
|
+
[ -n "$INPUT_SEARCH_CONSOLE" ] && args+=(--search-console "$INPUT_SEARCH_CONSOLE")
|
|
146
156
|
[ -n "$INPUT_VERBOSE" ] && args+=(--verbose)
|
|
147
157
|
[ -n "$INPUT_CHECK_EXTERNAL" ] && args+=(--check-external)
|
|
148
158
|
[ -n "$INPUT_SETTLE" ] && args+=(--settle "$INPUT_SETTLE")
|
package/bin/seo-audit.mjs
CHANGED
|
@@ -82,6 +82,11 @@ const HELP = `
|
|
|
82
82
|
GSC_REFRESH_TOKEN in the environment or in
|
|
83
83
|
~/.config/seo-audit/.env. A domain property is named
|
|
84
84
|
"sc-domain:example.com" rather than by its URL
|
|
85
|
+
--search-console-login
|
|
86
|
+
sign in to Google once and write GSC_REFRESH_TOKEN to
|
|
87
|
+
~/.config/seo-audit/.env. Needs GSC_CLIENT_ID and
|
|
88
|
+
GSC_CLIENT_SECRET there first, from an OAuth client of
|
|
89
|
+
type "Desktop app". Opens a browser; not for CI
|
|
85
90
|
--compare-as <name> fetch a sample of pages a second time as this browser
|
|
86
91
|
or crawler and report what changed. A page that differs
|
|
87
92
|
with the reader is cloaking, or bot protection misfiring
|
|
@@ -152,6 +157,7 @@ function parseArgs(argv) {
|
|
|
152
157
|
const next = argv[i + 1];
|
|
153
158
|
opts.serve = next && /^\d+$/.test(next) ? Number(argv[++i]) : true;
|
|
154
159
|
}
|
|
160
|
+
else if (arg === '--search-console-login') opts.searchConsoleLogin = true;
|
|
155
161
|
else if (arg === '--search-console') {
|
|
156
162
|
// Optionally the property name, since a domain property is not a URL.
|
|
157
163
|
const next = argv[i + 1];
|
|
@@ -316,6 +322,37 @@ if (opts.serve !== undefined) {
|
|
|
316
322
|
}
|
|
317
323
|
} else {
|
|
318
324
|
|
|
325
|
+
// --- sign in, and stop ----------------------------------------------------
|
|
326
|
+
// Before anything that needs a URL: this takes none. It is the one thing here
|
|
327
|
+
// that is interactive by nature, and the reason `--search-console` had never
|
|
328
|
+
// run against the live API — the three variables were documented and there was
|
|
329
|
+
// no way to obtain the third.
|
|
330
|
+
if (opts.searchConsoleLogin) {
|
|
331
|
+
const { login } = await import('../src/console.mjs');
|
|
332
|
+
try {
|
|
333
|
+
const { dotfile, properties } = await login({
|
|
334
|
+
onNote: (note) => process.stderr.write(` ${note}\n`),
|
|
335
|
+
});
|
|
336
|
+
process.stdout.write(`\n Refresh token written to ${dotfile}\n`);
|
|
337
|
+
if (properties.length) {
|
|
338
|
+
process.stdout.write('\n Properties this account can read:\n');
|
|
339
|
+
for (const p of properties) process.stdout.write(` ${p.url} (${p.permission})\n`);
|
|
340
|
+
process.stdout.write(`\n Try: seo-audit https://example.com --search-console ${properties[0].url}\n\n`);
|
|
341
|
+
} else {
|
|
342
|
+
// A token that can read nothing looks exactly like one that works, right
|
|
343
|
+
// up until an audit reports the property was not found.
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
'\n This account can read no properties. Verify the site in Search Console first,\n' +
|
|
346
|
+
' or sign in as the account that already has it.\n\n',
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
process.exit(properties.length ? 0 : 1);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
process.stderr.write(`\n ${err.message}\n\n`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
319
356
|
// Nothing to audit. If a person is there to ask, ask; otherwise this is a
|
|
320
357
|
// script or a CI runner and the help text is the right answer.
|
|
321
358
|
if (!sites.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurkamol/seo-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.mjs
CHANGED
|
@@ -22,6 +22,34 @@
|
|
|
22
22
|
// ]
|
|
23
23
|
// }
|
|
24
24
|
import { readFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One secret, environment first, then `~/.config/seo-audit/.env`.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately outside this repository, which is public. There were two copies
|
|
32
|
+
* of this — one in `psi.mjs`, one in `console.mjs` — and the second built its
|
|
33
|
+
* pattern with `new RegExp` and a template literal, where `\\\\s` survives as an
|
|
34
|
+
* escaped backslash rather than as whitespace. It compiled, it never threw, and
|
|
35
|
+
* it could not match a single line of a real `.env`. Search Console's dotfile
|
|
36
|
+
* fallback had therefore never worked, and nothing said so because the only
|
|
37
|
+
* tests it had used a fake API and injected credentials.
|
|
38
|
+
*/
|
|
39
|
+
export function readSecret(name, env = process.env, read = readFileSync) {
|
|
40
|
+
if (env[name]) return env[name];
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
// A missing file and an unreadable one are the same answer, and catching
|
|
44
|
+
// beats an `existsSync` guard: it is one syscall rather than two, it closes
|
|
45
|
+
// the gap between the two calls, and it lets a test hand in its own reader
|
|
46
|
+
// without the real filesystem deciding whether the test runs.
|
|
47
|
+
text = read(join(homedir(), '.config', 'seo-audit', '.env'), 'utf8');
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return text.match(new RegExp(`^\\s*${name}\\s*=\\s*(\\S+)`, 'm'))?.[1] ?? null;
|
|
52
|
+
}
|
|
25
53
|
|
|
26
54
|
const FILENAMES = ['seo-audit.config.json', '.seo-audit.json'];
|
|
27
55
|
|
package/src/console.mjs
CHANGED
|
@@ -9,23 +9,17 @@
|
|
|
9
9
|
// Opt-in, and the only thing in this tool that needs an account. Credentials
|
|
10
10
|
// are read the way the PageSpeed key is — the environment first, then
|
|
11
11
|
// ~/.config/seo-audit/.env — and never from the repository.
|
|
12
|
-
import {
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
12
|
+
import { readSecret } from './config.mjs';
|
|
15
13
|
|
|
16
14
|
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
17
15
|
const API = 'https://searchconsole.googleapis.com/webmasters/v3/sites';
|
|
18
16
|
|
|
19
17
|
const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
|
|
20
18
|
|
|
21
|
-
/** One credential, environment first.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!existsSync(dotfile)) return null;
|
|
26
|
-
const match = read(dotfile, 'utf8').match(new RegExp(`^\\\\s*${name}\\\\s*=\\\\s*(\\\\S+)`, 'm'));
|
|
27
|
-
return match?.[1] ?? null;
|
|
28
|
-
}
|
|
19
|
+
/** One credential, environment first. The loader is shared with the PageSpeed
|
|
20
|
+
* key: this file had its own copy, and its copy could not read the dotfile at
|
|
21
|
+
* all. */
|
|
22
|
+
export const findCredential = readSecret;
|
|
29
23
|
|
|
30
24
|
/** All three, or a sentence saying which is missing. */
|
|
31
25
|
export function credentials(env = process.env) {
|
|
@@ -136,11 +130,212 @@ export async function searchConsole(origin, findings, opts = {}) {
|
|
|
136
130
|
matched++;
|
|
137
131
|
}
|
|
138
132
|
|
|
139
|
-
|
|
133
|
+
// Impressions on the pages this crawl actually reached, counted once per
|
|
134
|
+
// page. The first live run summed every row in the property instead — the
|
|
135
|
+
// report said one finding had been "shown 98 times between them" when its
|
|
136
|
+
// page had 13, because the other 85 were on pages the crawl never touched.
|
|
137
|
+
// Both numbers are worth having; they are two different sentences.
|
|
138
|
+
const counted = new Set();
|
|
139
|
+
let shown = 0;
|
|
140
|
+
for (const finding of findings) {
|
|
141
|
+
if (!finding.traffic || counted.has(finding.url)) continue;
|
|
142
|
+
counted.add(finding.url);
|
|
143
|
+
shown += finding.traffic.impressions;
|
|
144
|
+
}
|
|
145
|
+
const everywhere = [...traffic.values()].reduce((n, t) => n + t.impressions, 0);
|
|
146
|
+
|
|
140
147
|
return [
|
|
141
148
|
f('info', 'search-console', `Search Console has ${traffic.size.toLocaleString()} pages for this site`,
|
|
142
|
-
`${matched.toLocaleString()} of this crawl's findings
|
|
143
|
-
`${
|
|
149
|
+
`${matched.toLocaleString()} of this crawl's findings ${matched === 1 ? 'is' : 'are'} on ` +
|
|
150
|
+
`${counted.size.toLocaleString()} page${counted.size === 1 ? '' : 's'} Google has shown, ` +
|
|
151
|
+
`${shown.toLocaleString()} time${shown === 1 ? '' : 's'} over 28 days — out of ` +
|
|
152
|
+
`${everywhere.toLocaleString()} across the whole property. Findings are ordered by that where it ` +
|
|
144
153
|
'is known, and by how much of the site links to a page where it is not.', origin),
|
|
145
154
|
];
|
|
146
155
|
}
|
|
156
|
+
|
|
157
|
+
// --- Getting a refresh token ------------------------------------------------
|
|
158
|
+
//
|
|
159
|
+
// The three variables above were documented for a year and there was never a
|
|
160
|
+
// way to obtain the third one. That is why `--search-console` has never run
|
|
161
|
+
// against the live API: not the code, the paperwork in front of it.
|
|
162
|
+
//
|
|
163
|
+
// Loopback OAuth, which is what Google's own docs call the installed-app flow.
|
|
164
|
+
// A desktop client may redirect to any port on 127.0.0.1 without registering
|
|
165
|
+
// it, so this listens on an ephemeral one, and the browser does the signing in.
|
|
166
|
+
// The token is written to the same file the key lives in and is never printed:
|
|
167
|
+
// a refresh token in a terminal is a refresh token in a scrollback buffer.
|
|
168
|
+
|
|
169
|
+
import { createServer } from 'node:http';
|
|
170
|
+
import { spawn } from 'node:child_process';
|
|
171
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
172
|
+
import { homedir } from 'node:os';
|
|
173
|
+
import { join, dirname } from 'node:path';
|
|
174
|
+
import { randomBytes } from 'node:crypto';
|
|
175
|
+
|
|
176
|
+
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
177
|
+
const SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
|
|
178
|
+
|
|
179
|
+
export const DOTFILE = join(homedir(), '.config', 'seo-audit', '.env');
|
|
180
|
+
|
|
181
|
+
/** Where the browser is sent. `prompt=consent` is not politeness: without it
|
|
182
|
+
* Google returns no refresh token at all on a second authorisation, which is
|
|
183
|
+
* the confusing half of this flow. Read-only scope, because this only ever
|
|
184
|
+
* reads. */
|
|
185
|
+
export function authUrl({ clientId, redirectUri, state }) {
|
|
186
|
+
const params = new URLSearchParams({
|
|
187
|
+
client_id: clientId,
|
|
188
|
+
redirect_uri: redirectUri,
|
|
189
|
+
response_type: 'code',
|
|
190
|
+
scope: SCOPE,
|
|
191
|
+
access_type: 'offline',
|
|
192
|
+
prompt: 'consent',
|
|
193
|
+
state,
|
|
194
|
+
});
|
|
195
|
+
return `${AUTH_URL}?${params}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The code the browser came back with, traded for the long-lived half. */
|
|
199
|
+
export async function exchangeCode({ clientId, clientSecret, code, redirectUri }, fetcher = fetch) {
|
|
200
|
+
const res = await fetcher(TOKEN_URL, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
203
|
+
body: new URLSearchParams({
|
|
204
|
+
client_id: clientId,
|
|
205
|
+
client_secret: clientSecret,
|
|
206
|
+
code,
|
|
207
|
+
redirect_uri: redirectUri,
|
|
208
|
+
grant_type: 'authorization_code',
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
const data = await res.json();
|
|
212
|
+
if (!res.ok || !data.refresh_token) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
data.error_description ??
|
|
215
|
+
data.error ??
|
|
216
|
+
'Google returned no refresh token. This happens when the account has authorised this ' +
|
|
217
|
+
'client before — revoke it at myaccount.google.com/permissions and try again.',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return data;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What this account can actually read. Printed after a login because a token
|
|
224
|
+
* that works for nothing looks exactly like a token that works. */
|
|
225
|
+
export async function listProperties(token, fetcher = fetch) {
|
|
226
|
+
const res = await fetcher(API, { headers: { authorization: `Bearer ${token}` } });
|
|
227
|
+
const data = await res.json();
|
|
228
|
+
if (!res.ok) throw new Error(data.error?.message ?? `HTTP ${res.status} listing properties`);
|
|
229
|
+
return (data.siteEntry ?? []).map((s) => ({ url: s.siteUrl, permission: s.permissionLevel }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** One line rewritten, the rest of the file untouched.
|
|
233
|
+
*
|
|
234
|
+
* Kept pure and exported so it can be tested: this writes to the file holding
|
|
235
|
+
* somebody's PageSpeed key, and clobbering that to save a Search Console token
|
|
236
|
+
* would be a poor trade. */
|
|
237
|
+
export function upsertSecret(text, name, value) {
|
|
238
|
+
const line = `${name}=${value}`;
|
|
239
|
+
const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, 'm');
|
|
240
|
+
if (pattern.test(text)) return text.replace(pattern, line);
|
|
241
|
+
return text.length && !text.endsWith('\n') ? `${text}\n${line}\n` : `${text}${line}\n`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function openBrowser(url) {
|
|
245
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
246
|
+
try {
|
|
247
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
|
|
248
|
+
return true;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const donePage = (heading, body) =>
|
|
255
|
+
`<!doctype html><meta charset="utf-8"><title>${heading}</title>` +
|
|
256
|
+
'<style>body{font:16px/1.6 -apple-system,system-ui,sans-serif;margin:20vh auto;max-width:32rem;' +
|
|
257
|
+
'padding:0 1.5rem;color:#111}h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#555;margin:0}' +
|
|
258
|
+
'@media(prefers-color-scheme:dark){body{background:#111;color:#eee}p{color:#aaa}}</style>' +
|
|
259
|
+
`<h1>${heading}</h1><p>${body}</p>`;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Sign in once, and write the refresh token where the audit will look for it.
|
|
263
|
+
*
|
|
264
|
+
* Interactive by nature, which is why it is not an Action input: a flag CI can
|
|
265
|
+
* accept and never satisfy is worse than no flag. The pieces that can be wrong
|
|
266
|
+
* quietly — the authorisation URL, the token exchange, rewriting a file that
|
|
267
|
+
* already holds somebody's PageSpeed key — are separate exported functions with
|
|
268
|
+
* tests. What is left here is a socket and a browser.
|
|
269
|
+
*/
|
|
270
|
+
export async function login({
|
|
271
|
+
fetcher = fetch,
|
|
272
|
+
openUrl = openBrowser,
|
|
273
|
+
onNote = () => {},
|
|
274
|
+
dotfile = DOTFILE,
|
|
275
|
+
timeout = 300_000,
|
|
276
|
+
// Injectable for the same reason the certificate reader is: otherwise the
|
|
277
|
+
// only way to exercise this is to have real credentials on the machine, and
|
|
278
|
+
// a test that needs those is a test nobody runs.
|
|
279
|
+
client,
|
|
280
|
+
} = {}) {
|
|
281
|
+
const clientId = client?.clientId ?? readSecret('GSC_CLIENT_ID');
|
|
282
|
+
const clientSecret = client?.clientSecret ?? readSecret('GSC_CLIENT_SECRET');
|
|
283
|
+
if (!clientId || !clientSecret) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'No OAuth client yet. In console.cloud.google.com: enable the Search Console API, then ' +
|
|
286
|
+
'create an OAuth client of type "Desktop app". Put its two values in ' +
|
|
287
|
+
`${dotfile} as GSC_CLIENT_ID and GSC_CLIENT_SECRET, and run this again.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const state = randomBytes(16).toString('hex');
|
|
292
|
+
const server = createServer();
|
|
293
|
+
// Port 0: a desktop client may redirect to any port on the loopback address
|
|
294
|
+
// without registering it, so nothing here has to be configured or be free.
|
|
295
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
296
|
+
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
|
|
297
|
+
|
|
298
|
+
const code = await new Promise((resolve, reject) => {
|
|
299
|
+
const timer = setTimeout(
|
|
300
|
+
() => reject(new Error(`Nothing came back within ${Math.round(timeout / 1000)}s. Nothing was written.`)),
|
|
301
|
+
timeout,
|
|
302
|
+
);
|
|
303
|
+
server.on('request', (req, res) => {
|
|
304
|
+
const asked = new URL(req.url, redirectUri);
|
|
305
|
+
if (asked.pathname !== '/callback') {
|
|
306
|
+
res.writeHead(404).end();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const returned = asked.searchParams.get('code');
|
|
310
|
+
const failed = asked.searchParams.get('error');
|
|
311
|
+
// The state is the only thing standing between this and a page on the
|
|
312
|
+
// internet quietly posting a code to a port on the machine.
|
|
313
|
+
const mismatched = asked.searchParams.get('state') !== state;
|
|
314
|
+
res.writeHead(failed || !returned || mismatched ? 400 : 200, { 'content-type': 'text/html; charset=utf-8' });
|
|
315
|
+
if (failed || !returned || mismatched) {
|
|
316
|
+
res.end(donePage('That did not work', 'Nothing was written. The terminal has the detail.'));
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
reject(new Error(mismatched ? 'The reply did not match the request this started.' : (failed ?? 'No code came back.')));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
res.end(donePage('Signed in', 'You can close this tab and go back to the terminal.'));
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
resolve(returned);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const url = authUrl({ clientId, redirectUri, state });
|
|
327
|
+
onNote(openUrl(url) ? `waiting on ${redirectUri}` : `open this and sign in:\n\n ${url}\n`);
|
|
328
|
+
}).finally(() => server.close());
|
|
329
|
+
|
|
330
|
+
const granted = await exchangeCode({ clientId, clientSecret, code, redirectUri }, fetcher);
|
|
331
|
+
|
|
332
|
+
mkdirSync(dirname(dotfile), { recursive: true });
|
|
333
|
+
const existing = existsSync(dotfile) ? readFileSync(dotfile, 'utf8') : '';
|
|
334
|
+
// 0600, and never printed: a refresh token echoed to a terminal is a refresh
|
|
335
|
+
// token in a scrollback buffer and probably in a shell history file.
|
|
336
|
+
writeFileSync(dotfile, upsertSecret(existing, 'GSC_REFRESH_TOKEN', granted.refresh_token), { mode: 0o600 });
|
|
337
|
+
|
|
338
|
+
// A token that can read nothing looks exactly like a token that works, until
|
|
339
|
+
// an audit says the property was not found.
|
|
340
|
+
return { dotfile, properties: await listProperties(granted.access_token, fetcher) };
|
|
341
|
+
}
|
package/src/options.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export const OPTIONS = [
|
|
|
30
30
|
{ flag: '--browser', query: 'browser', app: true },
|
|
31
31
|
{ flag: '--os', query: 'os', app: true },
|
|
32
32
|
{ flag: '--user-agent', query: 'userAgent', app: true },
|
|
33
|
+
{ flag: '--search-console', query: 'search-console', app: true },
|
|
33
34
|
{ flag: '--write-sitemap', query: 'sitemap-out', app: true, via: 'the Export menu' },
|
|
34
35
|
{ flag: '--ignore', query: 'ignore', app: true, via: 'right-clicking a finding, and the Settings list' },
|
|
35
36
|
{ flag: '--psi', query: 'psi', app: true, via: 'Settings → Performance' },
|
|
@@ -59,7 +60,7 @@ export const OPTIONS = [
|
|
|
59
60
|
|
|
60
61
|
|
|
61
62
|
// --- not yet, and that is a decision rather than an oversight ------------
|
|
62
|
-
{ flag: '--search-console', query: null, app: '
|
|
63
|
+
{ flag: '--search-console-login', query: null, app: 'no — it opens a browser and writes a credential to disk, which is a terminal errand, not a control in a window' },
|
|
63
64
|
{ flag: '--against', query: null, app: 'not yet — the window compares two kept runs instead of two live deployments' },
|
|
64
65
|
{ flag: '--compare-as', query: null, app: 'not yet — it fetches a sample of pages a second time, and the window has no control for spending that' },
|
|
65
66
|
{ flag: '--compare-sample', query: null, app: 'not yet — see --compare-as' },
|
package/src/psi.mjs
CHANGED
|
@@ -11,10 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// A key is optional but raises the quota well above the anonymous limit. Set
|
|
13
13
|
// PSI_API_KEY, or put it in ~/.config/seo-audit/.env — never in the repo.
|
|
14
|
-
import {
|
|
15
|
-
import { homedir } from 'node:os';
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
-
import { matchGlob } from './config.mjs';
|
|
14
|
+
import { matchGlob, readSecret } from './config.mjs';
|
|
18
15
|
|
|
19
16
|
const ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
|
|
20
17
|
|
|
@@ -34,11 +31,9 @@ const CWV = {
|
|
|
34
31
|
};
|
|
35
32
|
|
|
36
33
|
export function findKey() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const match = readFileSync(dotfile, 'utf8').match(/^\s*PSI_API_KEY\s*=\s*(\S+)/m);
|
|
41
|
-
return match?.[1] ?? null;
|
|
34
|
+
// Shared with the Search Console credentials rather than copied. The copy
|
|
35
|
+
// this file did not have was broken for months without anything noticing.
|
|
36
|
+
return readSecret('PSI_API_KEY');
|
|
42
37
|
}
|
|
43
38
|
|
|
44
39
|
async function run(url, strategy, key) {
|
package/src/serve.mjs
CHANGED
|
@@ -38,6 +38,11 @@ export async function serve({ port = 4321, host = '127.0.0.1', maxPages, allowed
|
|
|
38
38
|
// this unset, where a stranger passing ?psi= would be spending somebody
|
|
39
39
|
// else's.
|
|
40
40
|
ALLOW_PSI: '1',
|
|
41
|
+
// Same reasoning, sharper stakes: these credentials read somebody's Search
|
|
42
|
+
// Console. On the loopback address the person running the server is the
|
|
43
|
+
// person whose account it is; a deployed Worker leaves this unset, where
|
|
44
|
+
// `?search-console=` would hand a stranger somebody else's traffic data.
|
|
45
|
+
ALLOW_SEARCH_CONSOLE: '1',
|
|
41
46
|
};
|
|
42
47
|
|
|
43
48
|
const server = createServer(async (incoming, outgoing) => {
|