Site QA with AI: Broken Links, Missing Alt, Thin Pages

A reproducible site-QA pass with copy-paste AI prompts, exact shell verifiers, and a CI gate that fails the build on broken internal links and missing tags.

Most site bugs are not interesting: a missing alt attribute, a dead internal link after a slug rename, a 180-word page that should never have shipped. None of them throw an error, so they pile up silently until Search Console flags them. An AI agent reading your built dist/ against a narrow checklist finds them in about ten minutes. The trick is to keep every prompt single-purpose and to cross-check what it returns with a shell command you trust more than the model.

TL;DR

Build the site, point an AI agent at dist/, ask six narrow questions (broken links, missing alt, thin pages, orphans, title sanity, frontmatter), verify each finding with a shell one-liner, then wire the two critical gates (broken internal links + missing frontmatter) into CI so regressions fail the build. The AI does the first sweep; a script holds the line.

When this pass pays off

  • You restructured routes, renamed slugs, or migrated content and have not re-crawled since.
  • The Page indexing report in Search Console (renamed from “Coverage” in 2026) shows rising “Crawled - currently not indexed” or “Not found (404)” counts.
  • You shipped a batch of new pages quickly and suspect some carry bugs.
  • It has been 60+ days since a full crawl on an actively edited site.

What you need

  • A local production build in dist/ (this guide assumes Astro static output; the shell works for any static folder of index.html files).
  • An AI agent with local file-read: Codex CLI (runs GPT-5.5) or Claude Code (runs Claude Opus 4.7 / Sonnet 4.6). Either reads a few hundred HTML files comfortably; for very large sites, scope a prompt to one route prefix at a time.
  • grep, linkinator (v7.6.1 as of June 2026), and jq installed. Add lychee if you also want external-link checking.

Step 1: Build, then QA the output (not the source)

npm run build
ls dist/   # confirm pages exist

Run QA against dist/, never against src/. Many bugs appear only after rendering: meta tags that go empty when frontmatter is null, broken :key interpolations, a layout that drops <title> on one template. Source looks clean; the rendered page is broken.

Prompt:

[CONTEXT] Build output is dist/ (Astro static). Internal links look like
/en/articles/<slug>/ or /zh/articles/<slug>/.
[TASK] List every internal link in dist/**/*.html whose href points to a path
that has NO corresponding index.html in dist/.
Output a table: source_file, broken_href

Shell verifier (this is your source of truth, not the model):

# Build the set of URLs that actually resolve
find dist -name 'index.html' | sed 's|dist||;s|/index.html|/|' | sort > /tmp/live-urls.txt
# Extract internal hrefs used across every page
grep -RhoE 'href="(/[a-z]+/articles/[a-z0-9-]+/)"' dist | sed 's/href="//;s/"//' \
  | sort -u > /tmp/used-urls.txt
# Used minus live = broken
comm -23 /tmp/used-urls.txt /tmp/live-urls.txt | head

For a full crawl that also follows the pages it finds, run linkinator against the build folder:

# --recurse follows on-site links; --skip drops external http(s) with a regex
npx linkinator dist --recurse --skip '^https?://'

linkinator exits non-zero when any link fails, so it gates CI directly. There is no --silent flag; use --verbosity none if you only want the exit code, or --format csv to pipe results.

Step 3: Missing alt attributes

Prompt:

[TASK] In dist/**/*.html, list every <img> tag that lacks an alt attribute
or has alt="". Ignore decorative images already marked alt="" + role="presentation".
Output a table: file, line_excerpt

Verifier:

grep -RHn '<img[^>]*>' dist | grep -v 'alt="[^"]\+"' | head

Missing alt does not directly penalize rankings, but it breaks screen readers and image search, and it is a low-effort accessibility win that helps Lighthouse hit 100.

Step 4: Thin pages

Prompt:

[TASK] For every dist/**/index.html, extract the visible body text
(strip nav, header, footer, scripts) and count words.
List pages with body word count < 400, lowest first.
Output a table: file, word_count

Word count is a starting flag, not a verdict. A 200-word page that answers one specific question cleanly is fine. Use the list to find pages that are thin and trying to rank for a competitive query, then either deepen them or merge them into a stronger page.

Step 5: Orphan pages

Prompt:

[TASK] For every article page in dist/, count incoming internal links from
other pages in dist/ (exclude the page's own self-links and pagination).
List pages with 0 incoming internal links (orphans).
Output a table: file, incoming_count

Verifier:

# Pages never referenced as a link target by any other page
for url in $(cat /tmp/live-urls.txt); do
  count=$(grep -RlE "href=\"$url\"" dist | grep -v "dist$url" | wc -l)
  [ "$count" -eq 0 ] && echo "ORPHAN: $url"
done | head

Orphans are the silent killer after a restructure: Google can still reach them via the sitemap, but with no internal links they get crawled rarely and ranked weakly. Every article should have at least two incoming links from related pages.

Step 6: Title sanity

Prompt:

[TASK] For every dist/**/*.html, list pages where <title> is:
  - missing or empty
  - longer than 60 characters (Google truncates around 580px / ~60 chars)
  - duplicated across pages
Output a table: file, issue, title_text

Duplicate titles are the one to fix first: two pages with the same title compete with each other in search and confuse Google about which to rank.

Step 7: Frontmatter consistency (run on source)

This one check belongs on source, because missing frontmatter is exactly what produces the empty meta tags you saw in dist/. Keep it tiny:

// scripts/frontmatter-consistency.mjs
import { readdirSync, readFileSync } from 'node:fs';
import matter from 'gray-matter';
const REQUIRED = ['title', 'description', 'urlSlug', 'category', 'tags',
                  'publishedAt', 'lang', 'translationKey'];
for (const lang of ['en', 'zh']) {
  for (const cat of readdirSync(`src/content/articles/${lang}`)) {
    for (const f of readdirSync(`src/content/articles/${lang}/${cat}`)) {
      if (!f.endsWith('.mdx')) continue;
      const { data } = matter(readFileSync(`src/content/articles/${lang}/${cat}/${f}`, 'utf8'));
      const missing = REQUIRED.filter((k) => data[k] === undefined || data[k] === '');
      if (missing.length) console.log(`${lang}/${cat}/${f}: missing ${missing.join(',')}`);
    }
  }
}

Step 8: Wire the critical gates into CI

Open one issue per category, not one per file, or the backlog drowns you. Then gate the two categories that should never regress: broken internal links and missing frontmatter.

# .github/workflows/qa.yml (excerpt)
- name: QA gates
  run: |
    node scripts/frontmatter-consistency.mjs              # exits non-zero on missing keys
    npx linkinator dist --recurse --skip '^https?://'     # internal-link gate
    node scripts/audit-pillars.mjs                        # orphan gate

For external links, add a separate scheduled job with lychee so flaky third-party sites do not block your deploys:

- name: External links (nightly, non-blocking on PRs)
  uses: lycheeverse/lychee-action@v2
  with:
    args: --base . 'dist/**/*.html'
    fail: true   # set false on PR runs if you only want a report

lychee returns a non-zero exit code (2) when links fail, and the action exposes steps.<id>.outputs.exit_code so you can open a tracking issue instead of failing the build.

What “done” looks like

CategoryVerified byGate?
Broken internal linkscomm diff + linkinatorYes, fail build
Missing altgrep <img> filterNo, weekly report
Thin pages (<400w)AI body-text countNo, manual triage
Orphans (0 inlinks)grep target loopYes, fail build
Title issuesAI scanNo, weekly report
Missing frontmatterfrontmatter-consistency.mjsYes, fail build

Re-run each prompt after fixes: critical categories should return 0. Expect Search Console Page indexing counts to improve over the following 4-8 weeks (Google re-crawls on its own schedule, not instantly), and sampled Lighthouse SEO + Accessibility scores to hit 100.

Common pitfalls

  • QA on src/ instead of dist/. Rendered-only bugs slip through: empty meta from null frontmatter, dropped <title>, broken interpolations.
  • Trusting “looks fine to me” from the model. Always demand an explicit list or table. No list, nothing to verify.
  • Letting the AI count be the gate. The model is non-deterministic; if it says 23 broken links and your comm diff says 18, trust the script and use the AI list only to investigate the discrepancy.
  • Fixing inline without tracking. Open issues per category or you will reintroduce the same bug next month.
  • Skipping orphans. They are the most common and most invisible regression after a route restructure.

FAQ

  • Which model should run the QA sweep? Either works. Claude Code on Sonnet 4.6 (1M-token context as of June 2026) reads a large HTML batch in one pass and is the cheaper workhorse; GPT-5.5 via Codex is strong on shell reasoning if you want it to draft the verifier too. The model only does the first pass; the shell script is what actually gates.
  • Can I fully automate this in CI? Yes for the deterministic gates (broken internal links via linkinator, missing frontmatter via the script, orphans via your pillar audit). Keep the fuzzy categories (thin pages, title quality) as a human-reviewed weekly report; they need judgment, not a hard fail.
  • What about external link rot? Use a dedicated checker. lychee (async, Rust) or linkinator handle thousands of external URLs far faster than an AI agent, and they understand redirects and rate limits.
  • How often should I run a full pass? Monthly for an actively edited site, quarterly otherwise, and always immediately after a route/slug change or content migration.
  • Why does the AI report a different count than my shell verifier? The model approximates; the shell command is exact. Reconcile the gap (often it counts links inside <head> or off-site URLs you meant to exclude), tighten the prompt, and gate on the script.

Tags: #Indie dev #AI-assisted build #Workflow #Technical SEO