@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 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`, `og:description`, `og:image` present | warning |
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.31.0",
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-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
@@ -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
  }