@nurkamol/seo-audit 1.31.0 → 1.32.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 +15 -1
- 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 +12 -1
- package/src/parse.mjs +31 -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.
|
|
@@ -623,6 +633,7 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
623
633
|
| Check | Level |
|
|
624
634
|
|---|---|
|
|
625
635
|
| Page returns 200 and is not a redirect listed in the sitemap | error |
|
|
636
|
+
| The body is actually HTML — an XML or JSON file served as `text/html` is crawled and indexed as a page | warning |
|
|
626
637
|
| 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
638
|
| `noindex` on a page the sitemap advertises | error |
|
|
628
639
|
| `X-Robots-Tag: noindex` — the same instruction as a header, invisible in the HTML | error |
|
|
@@ -640,7 +651,9 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
640
651
|
| The canonical target isn't `noindex` — a page that hands its indexing to one leaves the index with it | error |
|
|
641
652
|
| 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
653
|
| The canonical target isn't itself canonicalised elsewhere — Google needn't follow a chain | warning |
|
|
643
|
-
| `og:title
|
|
654
|
+
| `og:title` present — shared links otherwise fall back to the page title | warning |
|
|
655
|
+
| `og:description` present — shared links otherwise preview whatever the platform scrapes | warning |
|
|
656
|
+
| `og:image` present — shared links otherwise preview with no picture | warning |
|
|
644
657
|
| `og:image` is an absolute URL — a scraper has no page to resolve a relative one against | error |
|
|
645
658
|
| `og:image` is not WebP — LinkedIn won't render it, WhatsApp is unreliable | warning |
|
|
646
659
|
| `og:image` declares width and height | note |
|
|
@@ -716,6 +729,7 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
716
729
|
| No page is linked but missing from the sitemap | warning |
|
|
717
730
|
| The link and image sweeps say so when they stop at their cap rather than implying they checked everything | note |
|
|
718
731
|
| Every `og:image` actually loads, and isn't too heavy to scrape | error / warning |
|
|
732
|
+
| A declared `twitter:image` loads, when it differs from `og:image` — its *absence* is fine, X falls back to Open Graph | error |
|
|
719
733
|
|
|
720
734
|
---
|
|
721
735
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurkamol/seo-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.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
|
@@ -41,9 +41,20 @@ export function matchGlob(pattern, path) {
|
|
|
41
41
|
|
|
42
42
|
/** A rule matches a finding when the id matches and, if the rule names URLs,
|
|
43
43
|
* one of them matches the finding's path. */
|
|
44
|
+
// Ids that used to be one check. A config in somebody's repository is written
|
|
45
|
+
// against the ids that existed when they wrote it, and splitting a check is our
|
|
46
|
+
// decision, not theirs — so the old id keeps silencing what it used to silence.
|
|
47
|
+
// The alternative is a check they had accepted starting to fail their build on
|
|
48
|
+
// an upgrade that promised to be compatible.
|
|
49
|
+
const RETIRED_IDS = {
|
|
50
|
+
// Split in 1.32.0: one id covered three tags, so a group named one of them
|
|
51
|
+
// and counted all three.
|
|
52
|
+
'og-missing': ['og-title-missing', 'og-description-missing', 'og-image-missing'],
|
|
53
|
+
};
|
|
54
|
+
|
|
44
55
|
function ruleMatches(rule, finding) {
|
|
45
56
|
const id = typeof rule === 'string' ? rule : rule.id;
|
|
46
|
-
if (id !== finding.id) return false;
|
|
57
|
+
if (id !== finding.id && !RETIRED_IDS[id]?.includes(finding.id)) return false;
|
|
47
58
|
const urls = typeof rule === 'string' ? null : rule.urls;
|
|
48
59
|
if (!urls?.length) return true;
|
|
49
60
|
if (!finding.url) return false;
|
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/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
|
}
|