---
title: "SEO audit script"
description: "A post-build script that catches the defects in a static site which look fine in a browser and broken to a crawler."
canonical: https://yonnia.com/en/projects/seo-audit
language: en
status: active
year: 2026
stack: ["Node.js"]
repo: https://github.com/yonnia/yonnia.com
---
# SEO audit script

A post-build script that catches the defects in a static site which look fine in a browser and broken to a crawler.

Half of this site's build pipeline. It runs against `dist/` and checks for one specific class of defect: things that are perfectly fine in a browser and broken to a crawler.

## What it checks

Split into failures and warnings by what the defect actually costs. A failure exits non-zero in CI.

**Per page:**

- Whether the canonical exists, is absolute, is on this origin, and matches where the file is actually served
- Whether two pages claim the same canonical — the exact accident canonical tags exist to prevent
- Whether `og:url` agrees with the canonical (when it does not, the shared link and the indexed link are two different URLs)
- Whether there is exactly one H1
- Title and description length, with per-language thresholds — a Chinese character is about twice as wide in a result as a Latin one, so a single absolute count is wrong for both locales
- Whether the hreflang set includes the page itself (self-reference is required), has exactly one `x-default`, and uses valid BCP 47 tags
- Whether every JSON-LD block parses, has `@type`, defines no duplicate `@id`, and contains no unescaped `</script`
- Whether every `<img>` has alt text and both dimensions (missing dimensions is a direct CLS cost)
- Whether external links carry `rel=noopener`

**Across pages:**

- Whether hreflang is reciprocal. Google drops the entire language cluster when A points at B and B does not point back, which is why this is a failure and not a warning.
- Whether every internal link resolves to a file that actually exists in `dist/`
- Whether every indexable page is in the sitemap, and every noindex page is **not**

## What it has actually caught

The immediate payoff was two bugs I would not have found myself.

One: the language switcher on Chinese tag pages pointed at `/en/blog/tags/工程实践` — a slug derived from a Chinese label has no English counterpart. You only see it by clicking, and I was never going to click every tag.

Two: several English pages had meta descriptions of 10 to 19 characters. All inherited from UI strings, all fine locally, and all a content-free line in a search result.
