@nurkamol/seo-audit 1.31.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 CHANGED
@@ -30,9 +30,19 @@
30
30
 
31
31
  ```bash
32
32
  # Or anywhere, with nothing installed
33
+ npx @nurkamol/seo-audit https://example.com
34
+
35
+ # Or from the repository, pinned to a major version. Same tool — but this clones
36
+ # 16 MB of app sources and tests to get at a 115 kB crawler.
33
37
  npx github:nurkamol/seo-audit@v1 https://example.com
38
+
39
+ # Or keep it, if you would rather just type `seo-audit`
40
+ npm install -g @nurkamol/seo-audit
34
41
  ```
35
42
 
43
+ Every `npx github:…` in the examples below works as `npx @nurkamol/seo-audit`
44
+ or as a plain `seo-audit`; they are the same bytes, fetched differently.
45
+
36
46
  <p align="center"><img src="docs/terminal.svg" alt="Example output" width="820"></p>
37
47
 
38
48
  > ### Not a terminal person? There is a window.
@@ -243,7 +253,34 @@ nobody has been shown.
243
253
  Opt-in, and the only thing in this tool that needs an account. It reads
244
254
  `GSC_CLIENT_ID`, `GSC_CLIENT_SECRET` and `GSC_REFRESH_TOKEN` from the
245
255
  environment or from `~/.config/seo-audit/.env`, deliberately outside any
246
- repository. A domain property is named `sc-domain:example.com` rather than by
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
247
284
  its URL. Missing credentials, or a property the account cannot read, are a note
248
285
  and the rest of the audit is unaffected.
249
286
 
@@ -623,6 +660,7 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
623
660
  | Check | Level |
624
661
  |---|---|
625
662
  | Page returns 200 and is not a redirect listed in the sitemap | error |
663
+ | The body is actually HTML — an XML or JSON file served as `text/html` is crawled and indexed as a page | warning |
626
664
  | A page the server answers `429` to is reported as rate limited, never as a page that failed — the crawl waits, slows down and comes back first | note |
627
665
  | `noindex` on a page the sitemap advertises | error |
628
666
  | `X-Robots-Tag: noindex` — the same instruction as a header, invisible in the HTML | error |
@@ -640,7 +678,9 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
640
678
  | The canonical target isn't `noindex` — a page that hands its indexing to one leaves the index with it | error |
641
679
  | Page 2 of an archive names itself, not page 1 — Google's guidance is "Don't use the first page of a paginated sequence as the canonical page", and a sitemap almost never lists these pages, so they're read where they're linked | error |
642
680
  | The canonical target isn't itself canonicalised elsewhere — Google needn't follow a chain | warning |
643
- | `og:title`, `og:description`, `og:image` present | warning |
681
+ | `og:title` present — shared links otherwise fall back to the page title | warning |
682
+ | `og:description` present — shared links otherwise preview whatever the platform scrapes | warning |
683
+ | `og:image` present — shared links otherwise preview with no picture | warning |
644
684
  | `og:image` is an absolute URL — a scraper has no page to resolve a relative one against | error |
645
685
  | `og:image` is not WebP — LinkedIn won't render it, WhatsApp is unreliable | warning |
646
686
  | `og:image` declares width and height | note |
@@ -716,6 +756,7 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
716
756
  | No page is linked but missing from the sitemap | warning |
717
757
  | The link and image sweeps say so when they stop at their cap rather than implying they checked everything | note |
718
758
  | Every `og:image` actually loads, and isn't too heavy to scrape | error / warning |
759
+ | A declared `twitter:image` loads, when it differs from `og:image` — its *absence* is fine, X falls back to Open Graph | error |
719
760
 
720
761
  ---
721
762
 
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.31.0",
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/areas.mjs CHANGED
@@ -29,7 +29,7 @@ export const CATEGORIES = [
29
29
  const CATEGORY_OF = {
30
30
  // Indexability
31
31
  noindex: 'Indexability', 'x-robots-noindex': 'Indexability', 'nofollow-page': 'Indexability',
32
- 'page-status': 'Indexability', unreachable: 'Indexability', 'nothing-crawlable': 'Indexability',
32
+ 'page-status': 'Indexability', 'body-not-html': 'Indexability', unreachable: 'Indexability', 'nothing-crawlable': 'Indexability',
33
33
  'crawl-rate-limited': 'Indexability',
34
34
  'rate-limited': 'Indexability',
35
35
  'canonical-missing': 'Indexability', 'canonical-multiple': 'Indexability',
@@ -78,8 +78,9 @@ const CATEGORY_OF = {
78
78
  'img-lazy-priority': 'Images',
79
79
 
80
80
  // Social
81
- 'og-missing': 'Social', 'og-webp': 'Social', 'og-no-dimensions': 'Social',
82
- 'og-image-relative': 'Social', 'og-image-broken': 'Social', 'og-image-heavy': 'Social',
81
+ 'og-title-missing': 'Social', 'og-description-missing': 'Social',
82
+ 'og-image-missing': 'Social', 'og-webp': 'Social', 'og-no-dimensions': 'Social',
83
+ 'og-image-relative': 'Social', 'og-image-broken': 'Social', 'twitter-image-broken': 'Social', 'og-image-heavy': 'Social',
83
84
 
84
85
  // Structured data
85
86
  'jsonld-invalid': 'Structured data', 'jsonld-no-type': 'Structured data',
package/src/audit.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  // Orchestration: find the pages, fetch them, run the checks.
2
2
  import { Fetcher, mapLimit } from './http.mjs';
3
- import { parseHtml, parseSitemap } from './parse.mjs';
3
+ import { bodyKind, parseHtml, parseSitemap } from './parse.mjs';
4
4
  import { parseRobots, robotsVerdict } from './robots.mjs';
5
5
  import { redirectChecks } from './redirects.mjs';
6
6
  import { pageChecks, crossPageChecks, sitemapChecks } from './checks.mjs';
7
- import { siteChecks } from './site.mjs';
7
+ import { certificateExpiry, siteChecks } from './site.mjs';
8
8
  import { linkGraph } from './graph.mjs';
9
9
  import { compareAgents } from './compare.mjs';
10
10
  import { searchConsole } from './console.mjs';
@@ -125,7 +125,8 @@ async function crawlByLinks(origin, fetcher, { limit, concurrency, robotsGroups,
125
125
  url: final.url,
126
126
  res: final,
127
127
  html: final.body,
128
- doc: final.ok && isHtml ? parseHtml(final.body, final.url) : null,
128
+ // Same as the sitemap path: the header is a claim, the body is the fact.
129
+ doc: final.ok && isHtml && !bodyKind(final.body) ? parseHtml(final.body, final.url) : null,
129
130
  };
130
131
  });
131
132
 
@@ -222,17 +223,51 @@ export async function audit(target, opts = {}) {
222
223
  // A host that never answered is not a sitemap problem, and following links
223
224
  // from a page that does not load would find nothing either.
224
225
  if (!urls.length && !fetcher.reachable) {
225
- findings.push({
226
- level: 'error',
227
- id: 'unreachable',
228
- title: 'The site did not answer a single request',
229
- detail:
230
- `Tried: ${tried.join(', ')}. The TLS connection succeeds but no response arrives, which ` +
231
- 'usually means a bot-protection rule is stalling non-browser clients Cloudflare Bot ' +
232
- "Fight Mode does exactly this. If it is your site, allow this crawler's user agent, or " +
233
- 'pass --user-agent to present a different one.',
234
- url: origin,
235
- });
226
+ // Read the certificate before blaming bot protection.
227
+ //
228
+ // A browser refuses an expired certificate and so does `fetch`, so "nothing
229
+ // answered" and "the certificate lapsed years ago" are indistinguishable
230
+ // from here — except that one of them is knowable, over a socket that does
231
+ // not validate. This used to assert "the TLS connection succeeds" and point
232
+ // at Cloudflare Bot Fight Mode, which for expired.badssl.com was wrong
233
+ // twice over and sends somebody to the wrong dashboard. The site checks
234
+ // that would have caught it never run, because the crawl gives up first.
235
+ const expiresAt =
236
+ url.protocol === 'https:'
237
+ ? await (opts.readCertificateExpiry ?? certificateExpiry)(url.hostname)
238
+ : null;
239
+ const lapsed = expiresAt ? Math.floor(((opts.now ?? Date.now()) - expiresAt) / 86_400_000) : 0;
240
+
241
+ findings.push(
242
+ lapsed > 0
243
+ ? {
244
+ level: 'error',
245
+ id: 'tls-expired',
246
+ title: `The TLS certificate expired ${lapsed} day(s) ago`,
247
+ detail:
248
+ `It ran out on ${new Date(expiresAt).toISOString().slice(0, 10)}, which is why nothing here ` +
249
+ 'could be fetched — browsers refuse the site outright. Renew it; nothing else about this ' +
250
+ 'site can be measured until then.',
251
+ url: origin,
252
+ }
253
+ : {
254
+ level: 'error',
255
+ id: 'unreachable',
256
+ title: 'The site did not answer a single request',
257
+ detail:
258
+ `Tried: ${tried.join(', ')}. ` +
259
+ // Only said where it was actually established. The hosted Worker
260
+ // has no socket to read a certificate over, so it gets null here
261
+ // and must not be made to assert that the certificate is fine —
262
+ // a runtime that cannot run a check says so rather than implying
263
+ // a result.
264
+ (expiresAt ? 'The certificate is valid, so this is ' : 'This is ') +
265
+ 'usually a bot-protection rule stalling non-browser clients — Cloudflare Bot Fight Mode ' +
266
+ "does exactly this. If it is your site, allow this crawler's user agent, or pass " +
267
+ '--user-agent to present a different one.',
268
+ url: origin,
269
+ },
270
+ );
236
271
  return {
237
272
  findings,
238
273
  meta: {
@@ -326,7 +361,10 @@ export async function audit(target, opts = {}) {
326
361
  url: pageUrl,
327
362
  res,
328
363
  html: res.body,
329
- doc: res.ok && isHtml ? parseHtml(res.body, pageUrl) : null,
364
+ // `isHtml` is the server's claim; `bodyKind` is what actually came
365
+ // back. A sitemap that lists an XML file the server labels text/html
366
+ // otherwise gets parsed as a page and reported for having no h1.
367
+ doc: res.ok && isHtml && !bodyKind(res.body) ? parseHtml(res.body, pageUrl) : null,
330
368
  };
331
369
  });
332
370
  } else {
@@ -411,7 +449,7 @@ export async function audit(target, opts = {}) {
411
449
  let home = null;
412
450
  if (!pages.some((p) => p.doc && p.res.ok && new URL(p.url).pathname.replace(/\/$/, '') === '')) {
413
451
  const { final } = await fetcher.chain(origin);
414
- if (final.ok && /text\/html/i.test(final.headers.get('content-type') ?? '')) {
452
+ if (final.ok && /text\/html/i.test(final.headers.get('content-type') ?? '') && !bodyKind(final.body)) {
415
453
  home = { url: final.url, doc: parseHtml(final.body, final.url) };
416
454
  }
417
455
  }
package/src/checks.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { linkGraph, key as graphKey } from './graph.mjs';
14
14
 
15
- import { attr, stripMarkupInAttributes } from './parse.mjs';
15
+ import { attr, bodyKind, stripMarkupInAttributes } from './parse.mjs';
16
16
  import { cluster } from './dupes.mjs';
17
17
 
18
18
  // Defaults, overridable per site under `limits` in the config file. A
@@ -265,6 +265,23 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
265
265
  res.error ? `Request failed: ${res.error}` : `HTTP ${res.status}`, url));
266
266
  return out;
267
267
  }
268
+ // A URL the server calls HTML that is not HTML. The body was already read, so
269
+ // this costs no request — and it matters, because Google indexes whatever
270
+ // comes back under `text/html` as a page.
271
+ //
272
+ // Raised *instead of* the page checks below, not alongside them. A sitemap
273
+ // listing an XML file labelled text/html was reported for having no title, no
274
+ // h1, no viewport, no charset, thin content and three missing Open Graph tags
275
+ // — thirteen true statements about a document that was never a page, and not
276
+ // one of them the thing to fix.
277
+ if (!doc && res.ok) {
278
+ const kind = bodyKind(page.html);
279
+ if (kind && /text\/html/i.test(res.headers?.get?.('content-type') ?? '')) {
280
+ out.push(f('warn', 'body-not-html', `Served as HTML, but the body is ${kind}`,
281
+ 'The server sends Content-Type: text/html, so this is crawled and indexed as a page. ' +
282
+ 'Either serve it with its own content type or take it out of the sitemap.', url));
283
+ }
284
+ }
268
285
  if (!doc) return out;
269
286
 
270
287
  // --- Indexability -------------------------------------------------------
@@ -426,9 +443,23 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
426
443
  }
427
444
 
428
445
  // --- Social -------------------------------------------------------------
429
- for (const tag of ['og:title', 'og:description', 'og:image']) {
430
- if (!doc.og[tag]) out.push(f('warn', 'og-missing', `Missing ${tag}`,
431
- 'Shared links will preview with whatever the platform scrapes.', url));
446
+ // One id per tag, not one for all three. They shared `og-missing` until a run
447
+ // against a real site printed "Missing og:description ×6" over four pages —
448
+ // three of which were missing only the description, and one missing all
449
+ // three. The grouping takes its title from whichever finding it saw first, so
450
+ // a row named one tag and counted another two. Every finding was true and the
451
+ // summary above them was not, which is the failure grouping exists to avoid.
452
+ //
453
+ // They are also three different repairs: a missing og:image is a picture
454
+ // somebody has to make, and a missing og:title is one line of template. Being
455
+ // able to `--ignore og-image-missing` and keep the other two is worth an id.
456
+ const OG_REQUIRED = [
457
+ ['og:title', 'og-title-missing', 'Shared links fall back to the page title, if the platform finds one.'],
458
+ ['og:description', 'og-description-missing', 'Shared links preview with whatever text the platform scrapes.'],
459
+ ['og:image', 'og-image-missing', 'Shared links preview with no picture, which is most of what gets clicked.'],
460
+ ];
461
+ for (const [tag, id, consequence] of OG_REQUIRED) {
462
+ if (!doc.og[tag]) out.push(f('warn', id, `Missing ${tag}`, consequence, url));
432
463
  }
433
464
  const ogImage = doc.og['og:image'];
434
465
  // The Open Graph spec requires an absolute URL. A scraper has no page context
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
 
@@ -41,9 +69,20 @@ export function matchGlob(pattern, path) {
41
69
 
42
70
  /** A rule matches a finding when the id matches and, if the rule names URLs,
43
71
  * one of them matches the finding's path. */
72
+ // Ids that used to be one check. A config in somebody's repository is written
73
+ // against the ids that existed when they wrote it, and splitting a check is our
74
+ // decision, not theirs — so the old id keeps silencing what it used to silence.
75
+ // The alternative is a check they had accepted starting to fail their build on
76
+ // an upgrade that promised to be compatible.
77
+ const RETIRED_IDS = {
78
+ // Split in 1.32.0: one id covered three tags, so a group named one of them
79
+ // and counted all three.
80
+ 'og-missing': ['og-title-missing', 'og-description-missing', 'og-image-missing'],
81
+ };
82
+
44
83
  function ruleMatches(rule, finding) {
45
84
  const id = typeof rule === 'string' ? rule : rule.id;
46
- if (id !== finding.id) return false;
85
+ if (id !== finding.id && !RETIRED_IDS[id]?.includes(finding.id)) return false;
47
86
  const urls = typeof rule === 'string' ? null : rule.urls;
48
87
  if (!urls?.length) return true;
49
88
  if (!finding.url) return false;
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 { readFileSync, existsSync } from 'node:fs';
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
- export function findCredential(name, env = process.env, read = readFileSync) {
23
- if (env[name]) return env[name];
24
- const dotfile = join(homedir(), '.config', 'seo-audit', '.env');
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
- const shown = [...traffic.values()].reduce((n, t) => n + t.impressions, 0);
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 are on pages Google has shown, ` +
143
- `${shown.toLocaleString()} times between them over 28 days. Findings are ordered by that where it ` +
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: 'not yet needs an OAuth client, and has never run against the live API' },
63
+ { flag: '--search-console-login', query: null, app: 'noit 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/parse.mjs CHANGED
@@ -345,3 +345,34 @@ export function parseSitemap(xml) {
345
345
 
346
346
  return { urls: locs, sitemaps: [], entries };
347
347
  }
348
+
349
+ /**
350
+ * What a response body actually is, when the server has already claimed it is
351
+ * HTML. Returns `null` for anything that might be HTML, and a noun for the
352
+ * cases that provably are not.
353
+ *
354
+ * Deliberately conservative — it answers on positive evidence only. A fragment
355
+ * with no `<html>` wrapper is still HTML; a page behind a byte-order mark is
356
+ * still HTML; and XHTML opens with `<?xml` and *is* HTML, which is why the
357
+ * markers are checked before the prologue. Guessing wrong here would silence
358
+ * every check on a real page, which is a worse failure than the noise it is
359
+ * meant to remove.
360
+ */
361
+ export function bodyKind(body) {
362
+ if (typeof body !== 'string') return null;
363
+ const head = body.replace(/^/, '').trimStart().slice(0, 2048);
364
+ if (!head) return null;
365
+ // Any of these and it is HTML, whatever it opened with.
366
+ if (/<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i.test(head)) return null;
367
+ if (/^%PDF-/.test(head)) return 'a PDF';
368
+ if (/^<\?xml[\s?]/i.test(head) || /^<(urlset|sitemapindex|rss|feed|kml|svg)[\s>]/i.test(head)) return 'XML';
369
+ if (/^[[{]/.test(head)) {
370
+ try {
371
+ JSON.parse(body);
372
+ return 'JSON';
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+ return null;
378
+ }
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 { readFileSync, existsSync } from 'node:fs';
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
- if (process.env.PSI_API_KEY) return process.env.PSI_API_KEY;
38
- const dotfile = join(homedir(), '.config', 'seo-audit', '.env');
39
- if (!existsSync(dotfile)) return null;
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) => {
package/src/site.mjs CHANGED
@@ -710,5 +710,34 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
710
710
  }
711
711
  }
712
712
 
713
+ // twitter:image, and only when it is a different picture from og:image.
714
+ //
715
+ // The *absence* of a Twitter card is deliberately not a finding and stays
716
+ // that way: X falls back to Open Graph correctly, so reporting it would
717
+ // invent a defect. A twitter:image that is declared and does not load is the
718
+ // opposite case — nothing falls back to anything, and the one platform that
719
+ // was handed its own tag previews blank. Same conservatism as above: a 403 is
720
+ // hotlink protection working.
721
+ const twitterImages = new Map();
722
+ for (const page of pages) {
723
+ const src = page.doc?.twitter?.['twitter:image'];
724
+ if (!src || !/^(https?:)?\/\//i.test(src)) continue;
725
+ // Identical to og:image means the sweep above already judged this picture.
726
+ // Two findings about one file is the noise this project keeps refusing.
727
+ if (src === page.doc?.og?.['og:image']) continue;
728
+ twitterImages.set(src, page.url);
729
+ }
730
+ const twitterResults = await mapLimit([...twitterImages.keys()], 4, async (src) => {
731
+ const { final } = await fetcher.chain(src);
732
+ return { src, final };
733
+ });
734
+ for (const { src, final } of twitterResults) {
735
+ if (final.status === 404 || final.status === 410 || final.status === 0) {
736
+ out.push(f('error', 'twitter-image-broken', 'twitter:image does not load',
737
+ `HTTP ${final.status || final.error} for ${src} — it differs from og:image, so X has nothing to fall back to.`,
738
+ twitterImages.get(src)));
739
+ }
740
+ }
741
+
713
742
  return out;
714
743
  }