@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 +43 -2
- package/action.yml +10 -0
- package/bin/seo-audit.mjs +37 -0
- package/package.json +1 -1
- package/src/areas.mjs +4 -3
- package/src/audit.mjs +54 -16
- package/src/checks.mjs +35 -4
- package/src/config.mjs +40 -1
- package/src/console.mjs +209 -14
- package/src/options.mjs +2 -1
- package/src/parse.mjs +31 -0
- package/src/psi.mjs +4 -9
- package/src/serve.mjs +5 -0
- package/src/site.mjs +29 -0
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.
|
|
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
|
|
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.
|
|
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-
|
|
82
|
-
'og-image-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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 {
|
|
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/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 {
|
|
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) => {
|
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
|
}
|