From 4e82acedc2c6544dd51ea44341ad926e41cb1d18 Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 01:52:39 +0100 Subject: [PATCH 1/2] feat(seo-hermit): new SEO/site-health domain plugin (PROP-014) Weekly watcher over Search Console, broken links, and Core Web Vitals with a regression-to-commit judgment layer. Ships hatch (live GSC verify), the site-health-check routine plus a mechanical ledger diff engine, and the site-regression-triage / site-draft-fix skills. Zero-dep Bun client (scripts/site-api.ts) with node:crypto JWT-bearer GSC auth. Read-only toward Google, approval-gated toward the operator's own repo. Overrides the domain-hermit freeze at explicit operator request; corrects PROP-014's non-existent bulk Index Coverage API to budgeted per-URL inspection. Claude-Session: https://claude.ai/code/session_014tRmc6NhfXWu1sB6jtUMFW --- .claude-plugin/marketplace.json | 21 + .github/workflows/test-seo.yml | 42 + .../.claude-plugin/hermit-meta.json | 6 + plugins/seo-hermit/.claude-plugin/plugin.json | 25 + plugins/seo-hermit/.gitignore | 14 + plugins/seo-hermit/CHANGELOG.md | 12 + plugins/seo-hermit/CLAUDE.md | 104 +++ plugins/seo-hermit/LICENSE | 21 + plugins/seo-hermit/README.md | 72 ++ plugins/seo-hermit/docs/knowledge-schema.md | 37 + plugins/seo-hermit/scripts/site-api.ts | 814 ++++++++++++++++++ plugins/seo-hermit/settings.json | 23 + plugins/seo-hermit/skills/hatch/SKILL.md | 237 +++++ .../seo-hermit/skills/site-draft-fix/SKILL.md | 102 +++ .../skills/site-health-check/SKILL.md | 136 +++ .../skills/site-regression-triage/SKILL.md | 83 ++ .../state-templates/CLAUDE-APPEND.md | 38 + .../fixtures/gsc-searchanalytics-ok.json | 7 + plugins/seo-hermit/tests/fixtures/psi-ok.json | 12 + plugins/seo-hermit/tests/ledger.test.ts | 171 ++++ plugins/seo-hermit/tests/run-all.sh | 30 + plugins/seo-hermit/tests/site-api.test.ts | 355 ++++++++ .../seo-hermit/tests/skill-structure.test.ts | 63 ++ plugins/seo-hermit/tests/test-utils.ts | 31 + 24 files changed, 2456 insertions(+) create mode 100644 .github/workflows/test-seo.yml create mode 100644 plugins/seo-hermit/.claude-plugin/hermit-meta.json create mode 100644 plugins/seo-hermit/.claude-plugin/plugin.json create mode 100644 plugins/seo-hermit/.gitignore create mode 100644 plugins/seo-hermit/CHANGELOG.md create mode 100644 plugins/seo-hermit/CLAUDE.md create mode 100644 plugins/seo-hermit/LICENSE create mode 100644 plugins/seo-hermit/README.md create mode 100644 plugins/seo-hermit/docs/knowledge-schema.md create mode 100644 plugins/seo-hermit/scripts/site-api.ts create mode 100644 plugins/seo-hermit/settings.json create mode 100644 plugins/seo-hermit/skills/hatch/SKILL.md create mode 100644 plugins/seo-hermit/skills/site-draft-fix/SKILL.md create mode 100644 plugins/seo-hermit/skills/site-health-check/SKILL.md create mode 100644 plugins/seo-hermit/skills/site-regression-triage/SKILL.md create mode 100644 plugins/seo-hermit/state-templates/CLAUDE-APPEND.md create mode 100644 plugins/seo-hermit/tests/fixtures/gsc-searchanalytics-ok.json create mode 100644 plugins/seo-hermit/tests/fixtures/psi-ok.json create mode 100644 plugins/seo-hermit/tests/ledger.test.ts create mode 100644 plugins/seo-hermit/tests/run-all.sh create mode 100644 plugins/seo-hermit/tests/site-api.test.ts create mode 100644 plugins/seo-hermit/tests/skill-structure.test.ts create mode 100644 plugins/seo-hermit/tests/test-utils.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index efb4dd05..efdc54f4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -129,6 +129,27 @@ "maintainer", "gtapps" ] + }, + { + "name": "seo-hermit", + "source": "./plugins/seo-hermit", + "description": "SEO/site-health layer for claude-code-hermit — weekly Search Console, broken-link, and Core Web Vitals deltas, with regression-to-commit correlation and drafted fixes", + "version": "0.0.1", + "category": "seo", + "author": { + "name": "gtapps", + "url": "https://github.com/gtapps" + }, + "license": "MIT", + "homepage": "https://github.com/gtapps/claude-code-hermit", + "repository": "https://github.com/gtapps/claude-code-hermit", + "keywords": [ + "seo", + "search-console", + "core-web-vitals", + "site-health", + "gtapps" + ] } ] } diff --git a/.github/workflows/test-seo.yml b/.github/workflows/test-seo.yml new file mode 100644 index 00000000..249b69c5 --- /dev/null +++ b/.github/workflows/test-seo.yml @@ -0,0 +1,42 @@ +name: Test SEO Hermit + +on: + push: + branches: [main] + paths: + - 'plugins/seo-hermit/**' + - '.github/workflows/test-seo.yml' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + pull_request: + paths: + - 'plugins/seo-hermit/**' + - '.github/workflows/test-seo.yml' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' + - run: bun install --frozen-lockfile + - run: bunx tsc + + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugins/seo-hermit + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' + - name: Run test suite + run: bash tests/run-all.sh diff --git a/plugins/seo-hermit/.claude-plugin/hermit-meta.json b/plugins/seo-hermit/.claude-plugin/hermit-meta.json new file mode 100644 index 00000000..ce3b203d --- /dev/null +++ b/plugins/seo-hermit/.claude-plugin/hermit-meta.json @@ -0,0 +1,6 @@ +{ + "required_core_version": ">=1.2.14", + "requires": { + "claude-code-hermit": ">=1.2.14" + } +} diff --git a/plugins/seo-hermit/.claude-plugin/plugin.json b/plugins/seo-hermit/.claude-plugin/plugin.json new file mode 100644 index 00000000..bb0b9d62 --- /dev/null +++ b/plugins/seo-hermit/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "seo-hermit", + "version": "0.0.1", + "description": "SEO/site-health layer for claude-code-hermit — weekly Search Console, broken-link, and Core Web Vitals deltas, with regression-to-commit correlation and drafted fixes", + "author": { + "name": "gtapps", + "url": "https://github.com/gtapps" + }, + "repository": "https://github.com/gtapps/claude-code-hermit", + "homepage": "https://github.com/gtapps/claude-code-hermit/tree/main/plugins/seo-hermit", + "license": "MIT", + "keywords": [ + "seo", + "search-console", + "core-web-vitals", + "site-health", + "gtapps" + ], + "dependencies": [ + { + "name": "claude-code-hermit", + "version": "^1.2.14" + } + ] +} diff --git a/plugins/seo-hermit/.gitignore b/plugins/seo-hermit/.gitignore new file mode 100644 index 00000000..6bb764a4 --- /dev/null +++ b/plugins/seo-hermit/.gitignore @@ -0,0 +1,14 @@ +# Secrets — never commit +.env +.env.* +.claude.local/ + +# Claude Code local state (when developing the plugin against a local target project) +.claude/ +.claude-code-hermit/ + +# OS / IDE +.DS_Store +.obsidian/ +*.swp +*~ diff --git a/plugins/seo-hermit/CHANGELOG.md b/plugins/seo-hermit/CHANGELOG.md new file mode 100644 index 00000000..ec685ef4 --- /dev/null +++ b/plugins/seo-hermit/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog — seo-hermit + +## [Unreleased] + +### Added +- **Scaffold + hatch** — new domain plugin `seo-hermit`. One-time `/seo-hermit:hatch` wizard collects the site URL, sitemap URL, a Google Search Console service-account JSON path, and an optional PageSpeed Insights key, then verifies GSC access with a live `searchAnalytics.query` round-trip. Requires `claude-code-hermit` ≥1.2.14. +- **`scripts/site-api.ts`** — zero-dependency Bun client for the Search Console and PageSpeed Insights APIs. Ships the `check` (credential probe: `missing`/`invalid`/`unreachable`/`ok`) and `search-analytics` subcommands. Service-account auth is a JWT-bearer flow signed with `node:crypto` — no npm packages. +- **`site-health-weekly` routine + ledger** — weekly `/seo-hermit:site-health-check` pulls Search Console deltas, link-checks the sitemap, samples Core Web Vitals via PageSpeed Insights, and inspects a rotating budget of URLs for index status. Everything diffs against `state/site-health-ledger.json`; only changes are reported. A quiet week is one line. +- **Judgment layer** — `/seo-hermit:site-regression-triage` correlates a regression with recent commits in a locally-configured site repo; `/seo-hermit:site-draft-fix` drafts mechanical fixes (broken internal links, stale sitemap entries, missing redirects/meta tags, CLS image dimensions) on a branch, behind an explicit approval gate. Read-only toward Google; write-only toward the operator's own repo. + +### Upgrade Instructions +New plugin — no in-place migration. Install with `claude plugin install seo-hermit@claude-code-hermit`, then run `/seo-hermit:hatch` in the target project. diff --git a/plugins/seo-hermit/CLAUDE.md b/plugins/seo-hermit/CLAUDE.md new file mode 100644 index 00000000..b271cde4 --- /dev/null +++ b/plugins/seo-hermit/CLAUDE.md @@ -0,0 +1,104 @@ +# seo-hermit + +An SEO/site-health domain layer for `claude-code-hermit`: a weekly watcher over Google Search +Console, broken links, and Core Web Vitals, with a judgment layer that correlates regressions with +site-repo commits and drafts mechanical fixes. + +## This Repo is a Plugin + +This repo is structured as a Claude Code plugin. It is NOT a standalone project — it gets installed +into other projects via: + +``` +claude plugin marketplace add gtapps/claude-code-hermit +claude plugin install seo-hermit@claude-code-hermit --scope local +``` + +After install, run `/seo-hermit:hatch` in the target project. The core hermit +(`claude-code-hermit` ≥1.2.14) must be installed and hatched first — `hatch` will prompt if it isn't. + +## Plugin Structure + +- `skills/hatch/` — one-time setup wizard (`/seo-hermit:hatch`) +- `skills/site-health-check/` — weekly routine: pull deltas, diff the ledger, report changes +- `skills/site-regression-triage/` — correlate a flagged regression with recent site-repo commits +- `skills/site-draft-fix/` — draft a mechanical fix on a branch, behind an approval gate +- `scripts/site-api.ts` — zero-dep Bun client for Search Console + PageSpeed Insights +- `state-templates/CLAUDE-APPEND.md` — SEO Workflow block injected into the consumer's CLAUDE.md by `hatch` +- `docs/knowledge-schema.md` — work-product types and retention rules +- `settings.json` — pre-approved permissions for the site-api script and read-class git +- `.claude-plugin/plugin.json` / `hermit-meta.json` — manifests + +## Hatch target routing + +`/hatch` Step 6 reads `.claude-code-hermit/state/hatch-options.json` (`target` field) to route the +CLAUDE-APPEND block: `"local"` → `CLAUDE.local.md`, `"committed"` → `CLAUDE.md`. If no +`hatch-options.json` exists, it detects `core_install_scope` from `claude plugin list --json` and +stamps the canonical 5-field schema. + +## Core Rules + +- **Never Read the service-account JSON.** It holds an RSA private key. Check credential state with + `site-api.ts check` (self-reports `missing`/`invalid`/`unreachable`/`ok`); verify the key file's + *existence* only. +- **Credentials never touch a command line.** `site-api.ts` loads them from `.env` / + `SEO_HERMIT_GSC_CREDENTIALS`. The four `SEO_HERMIT_*` var names deliberately avoid the + `TOKEN`/`SECRET`/`API_KEY` deny substrings so `.env` can be read with the Read tool. +- **Read-only toward Google.** No writes to Search Console or search-facing config, ever. +- **Site-repo writes are approval-gated.** All fixes go through `site-draft-fix`: draft on a branch, + show the diff, wait for approval, never push, never touch a protected branch. +- **URL Inspection is budgeted.** Per-URL index checks respect a weekly budget (~2,000/day/property + quota), rotating the sitemap via the ledger `inspect_cursor`. + +## Memory Conventions + +- `state/site-health-ledger.json` — rolling machine-readable diff state (search history, broken + links, CWV history, index sample, inspect cursor). Permanent, trimmed in place. +- `raw/` — ephemeral API pulls, aged out per `knowledge.raw_retention_days`. +- `compiled/` — durable dated reports (`site-health-*`, `site-triage-*`, `site-fix-*`). + +Do NOT create subdirectories inside `raw/` or `compiled/`. Flat layout only (base hermit storage contract). + +## `site-api.ts` subcommands + +| Subcommand | Purpose | +|------------|---------| +| `check` | Credential probe. Prints `missing`/`invalid`/`unreachable`/`ok` (+ optional `psi:`). | +| `search-analytics --start --end [--dimensions] [--row-limit]` | Search Console query → `{ok,data:{rows,totals}}`. | +| `sitemap` / `link-check` / `psi` / `inspect` / `ledger` | Weekly-routine data pulls + the diff engine (see `site-health-check`). | + +## Routines + +The weekly `site-health-weekly` routine invokes the `site-health-check` **skill directly** +(`{"skill": "seo-hermit:site-health-check", ...}`), the core `weekly-review` pattern — not a +`prompt_file` drop. Routine logic lives in the shipped skill so it upgrades with the plugin and is +covered by `skill-structure.test.ts`; a dropped prompt file would be install-once and never receive +upgrades. + +## Development + +Test locally against a target project without publishing: + +``` +cd /path/to/target-project +claude --plugin-dir /path/to/plugins/seo-hermit +``` + +Under `--plugin-dir`, `${CLAUDE_PLUGIN_ROOT}` is NOT substituted — use the absolute plugin path in +commands. `${CLAUDE_PLUGIN_ROOT}` is never a runtime env var; the script self-resolves its own dir. + +Run tests (from inside the plugin dir): + +``` +bash tests/run-all.sh # bun site-api + skill-structure tests +``` + +Typecheck from the repo root: `bunx tsc` (strict; `plugins/**/*.ts` is included). + +## Development constraints + +- `tests/skill-structure.test.ts` hardcodes the `SKILLS` array — add a skill → update that array. +- No runtime dependencies: `site-api.ts` imports only the standard library and Bun/`node:*` built-ins. +- The base hermit deny-patterns hook blocks any Bash arg containing `TOKEN`/`SECRET`/`API_KEY`. Keep + credential values off command lines. +- CLAUDE-APPEND must not restate `config.json` values (routine schedules, flags). Describe behaviors. diff --git a/plugins/seo-hermit/LICENSE b/plugins/seo-hermit/LICENSE new file mode 100644 index 00000000..91ad9ddd --- /dev/null +++ b/plugins/seo-hermit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 gtapps + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/seo-hermit/README.md b/plugins/seo-hermit/README.md new file mode 100644 index 00000000..37af073d --- /dev/null +++ b/plugins/seo-hermit/README.md @@ -0,0 +1,72 @@ +# seo-hermit + +A weekly SEO/site-health watcher for [claude-code-hermit](https://github.com/gtapps/claude-code-hermit). + +It pulls Google Search Console deltas, checks the sitemap for broken links, samples Core Web Vitals +via PageSpeed Insights, and inspects a rotating budget of URLs for index status — then reports **only +what changed** since last week. When a metric regresses, it correlates the regression with recent +commits in your site's repo and can draft the mechanical fix on a branch for your approval. + +## Positioning (read this first) + +The raw monitoring here is commodity — cheap incumbents and a cron + link-checker cover most of it, +and the signals move weekly, not hourly. seo-hermit earns its keep in the **judgment layer**: +deciding which of many Search Console warnings matter, tying a Core Web Vitals regression to a +specific deploy, and drafting the fix when your site's repo is available locally. If you just want +raw dashboards, use the incumbents. If you want a hermit that watches, correlates, and drafts, this +is that. + +## What runs weekly + +1. Search Console click/impression/position deltas (current week vs prior, ~3-day data lag applied). +2. Sitemap link-check (bounded concurrency, HEAD-first). +3. Core Web Vitals (LCP / INP / CLS) for the homepage + top pages, via PageSpeed Insights (optional). +4. URL Inspection index status for a rotating budget of sitemap URLs. + +All of it diffs against `state/site-health-ledger.json`. A quiet week is one line; a week with +changes gets a compact report and a channel brief. + +## Requirements + +- Core `claude-code-hermit` ≥1.2.14, installed and hatched. +- A **Google Search Console** property and a **service account** with read access to it. +- Optionally, a **PageSpeed Insights** API key (enables the Core Web Vitals checks). + +### Search Console setup + +1. In Google Cloud console, create/pick a project and enable the **Search Console API**. +2. Create a **service account**, download its JSON key, save it to `.claude.local/gsc-service-account.json`. +3. In Search Console, add the service account's `client_email` as a **user** on your property. + +## Install + +``` +claude plugin marketplace add gtapps/claude-code-hermit +claude plugin install seo-hermit@claude-code-hermit --scope local +``` + +Then run `/seo-hermit:hatch` in the target project. + +## Environment (`.env`) + +| Variable | Required | Meaning | +|----------|----------|---------| +| `SEO_HERMIT_SITE_URL` | yes | GSC property — `sc-domain:example.com` or `https://www.example.com/`. | +| `SEO_HERMIT_SITEMAP_URL` | yes | Full sitemap URL. | +| `SEO_HERMIT_GSC_CREDENTIALS` | yes | Path to the service-account JSON (keep it in `.claude.local/`). | +| `SEO_HERMIT_PSI_KEY` | no | PageSpeed Insights API key. Blank → CWV checks skipped. | + +The variable names deliberately avoid `TOKEN`/`SECRET`/`API_KEY` so the base hermit's deny-patterns +hook doesn't block routine reads of `.env`. + +## Safety posture + +- **Read-only toward Google.** No writes to Search Console or any search-facing config. +- **Fix-drafting is approval-gated.** `site-draft-fix` only ever drafts on a branch inside your + configured local site repo, shows the full diff, and waits for your approval. It never pushes and + never touches a protected branch. The closed set of fixes it will draft: broken internal links + (on a confirmed slug rename), stale static-sitemap entries, missing redirects for renamed slugs, + empty ``/meta-description on a regressed page, and missing image width/height behind a CLS + regression. +- **Quota-aware.** URL Inspection is capped to a weekly budget (default 20 URLs), rotating through + the sitemap across weeks (Google's quota is ~2,000/day/property). diff --git a/plugins/seo-hermit/docs/knowledge-schema.md b/plugins/seo-hermit/docs/knowledge-schema.md new file mode 100644 index 00000000..26cfe527 --- /dev/null +++ b/plugins/seo-hermit/docs/knowledge-schema.md @@ -0,0 +1,37 @@ +# seo-hermit knowledge schema + +Work-product and raw-capture types this plugin produces. Frontmatter on every `compiled/` +artifact: `title` (quoted), `type` (one of the keys below), `created` (ISO 8601 with offset), +`tags` (array), plus optional domain fields. Storage is flat — no subdirectories in `raw/` or +`compiled/` (base hermit storage contract). + +## Work Products + +- **site-health** — weekly site-health report. Only the metrics that changed since the last run; + a quiet week is a single line. Producer: `seo-hermit:site-health-check`. Location: + `compiled/site-health-<YYYY-MM-DD>.md`. Frontmatter carries `verdict: quiet | changes`. +- **site-triage** — regression-to-commit correlation for a flagged week: suspect commits, confidence + tier, whether a mechanical fix exists. Producer: `seo-hermit:site-regression-triage`. Location: + `compiled/site-triage-<YYYY-MM-DD>.md`. +- **site-fix** — record of a drafted mechanical fix: branch name, diff summary, approval outcome. + Producer: `seo-hermit:site-draft-fix`. Location: `compiled/site-fix-<YYYY-MM-DD>.md`. + +## Raw Captures + +Retention: 14 days (base hermit `knowledge.raw_retention_days`). + +- **site-health-snapshot** — the assembled weekly snapshot fed to the ledger diff. + `raw/site-health-snapshot-<date>.json`. +- **gsc-search-analytics** — raw Search Console rows for the current + prior window. + `raw/gsc-search-analytics-<date>.json`. +- **site-linkcheck** — sitemap link-check results. `raw/site-linkcheck-<date>.json`. +- **site-psi** — PageSpeed Insights field/lab metrics per sampled page. `raw/site-psi-<date>.json`. +- **site-inspect** — URL Inspection index-status results for the rotating budget. + `raw/site-inspect-<date>.json`. + +## State + +- **site-health-ledger** — `state/site-health-ledger.json`. The rolling machine-readable ledger the + weekly routine diffs against: search history (12 weeks), broken-link set with first/last-seen, + CWV history per page (8 entries), index status sample, and the `inspect_cursor` for URL rotation. + Permanent (trimmed in place, never archived). diff --git a/plugins/seo-hermit/scripts/site-api.ts b/plugins/seo-hermit/scripts/site-api.ts new file mode 100644 index 00000000..d4477660 --- /dev/null +++ b/plugins/seo-hermit/scripts/site-api.ts @@ -0,0 +1,814 @@ +#!/usr/bin/env bun +// seo-hermit site API client. Zero runtime deps — Bun + fetch + node:crypto only. +// Credentials are read from the environment / .env, never from argv, and never printed. +// +// Subcommands (PR1): check, search-analytics. +// Output contract: `check` prints a bare status token per line; every other subcommand +// prints exactly one JSON document `{ok:true,data:…}` or `{ok:false,error:…}` to stdout. + +import { createSign } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"; +const REQUIRED_VARS = ["SEO_HERMIT_SITE_URL", "SEO_HERMIT_SITEMAP_URL", "SEO_HERMIT_GSC_CREDENTIALS"] as const; + +type FetchImpl = typeof fetch; + +interface ServiceAccount { + client_email: string; + private_key: string; + token_uri: string; +} + +export interface Env { + SEO_HERMIT_SITE_URL: string; + SEO_HERMIT_SITEMAP_URL: string; + SEO_HERMIT_GSC_CREDENTIALS: string; + SEO_HERMIT_PSI_KEY: string; +} + +// HTTP errors carry the status so callers can distinguish auth (401/403) from other failures. +// A thrown non-HttpError (fetch rejection) means the endpoint was unreachable. +export class HttpError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = "HttpError"; + } +} + +// ---- env / .env ---- + +export function loadDotEnv(root: string): Record<string, string> { + const out: Record<string, string> = {}; + const path = resolve(root, ".env"); + if (!existsSync(path)) return out; + for (const line of readFileSync(path, "utf-8").split("\n")) { + const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/); + if (!m) continue; + let value = m[2]; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[m[1]] = value; + } + return out; +} + +// process.env wins over .env so container/CI injection overrides the file. +export function resolveEnv(root: string): Env { + const fromFile = loadDotEnv(root); + const pick = (k: keyof Env): string => process.env[k] ?? fromFile[k] ?? ""; + return { + SEO_HERMIT_SITE_URL: pick("SEO_HERMIT_SITE_URL"), + SEO_HERMIT_SITEMAP_URL: pick("SEO_HERMIT_SITEMAP_URL"), + SEO_HERMIT_GSC_CREDENTIALS: pick("SEO_HERMIT_GSC_CREDENTIALS"), + SEO_HERMIT_PSI_KEY: pick("SEO_HERMIT_PSI_KEY"), + }; +} + +function loadServiceAccount(path: string): ServiceAccount | null { + if (!path || !existsSync(path)) return null; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } + const sa = parsed as Partial<ServiceAccount>; + if (!sa.client_email || !sa.private_key || !sa.token_uri) return null; + return { client_email: sa.client_email, private_key: sa.private_key, token_uri: sa.token_uri }; +} + +// CLI commands that need GSC auth call this to load-or-exit in one step. +function requireServiceAccount(env: Env): ServiceAccount { + const sa = loadServiceAccount(env.SEO_HERMIT_GSC_CREDENTIALS); + if (!sa) { + emit(false, "GSC credentials missing or unreadable"); + process.exit(1); + } + return sa; +} + +// ---- JWT-bearer auth ---- + +function base64url(input: string | Buffer): string { + return Buffer.from(input).toString("base64url"); +} + +export function buildJwt(sa: ServiceAccount, nowSec: number, scope: string = GSC_SCOPE): string { + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const claims = base64url( + JSON.stringify({ + iss: sa.client_email, + scope, + aud: sa.token_uri, + iat: nowSec, + exp: nowSec + 3600, + }), + ); + const signingInput = `${header}.${claims}`; + const signature = createSign("RSA-SHA256").update(signingInput).sign(sa.private_key); + return `${signingInput}.${base64url(signature)}`; +} + +export async function mintAccessToken( + sa: ServiceAccount, + nowSec: number, + fetchImpl: FetchImpl, + scope: string = GSC_SCOPE, +): Promise<string> { + const jwt = buildJwt(sa, nowSec, scope); + const res = await fetchImpl(sa.token_uri, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }).toString(), + }); + if (!res.ok) throw new HttpError(res.status, `token mint failed (${res.status})`); + const json = (await res.json()) as { access_token?: string }; + if (!json.access_token) throw new Error("token response missing access_token"); + return json.access_token; +} + +// ---- Search Console ---- + +export interface SearchRow { + keys: string[]; + clicks: number; + impressions: number; + ctr: number; + position: number; +} + +export async function gscSearchAnalytics( + accessToken: string, + siteUrl: string, + body: Record<string, unknown>, + fetchImpl: FetchImpl, +): Promise<{ rows?: SearchRow[] }> { + const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`; + const res = await fetchImpl(url, { + method: "POST", + headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new HttpError(res.status, `searchAnalytics query failed (${res.status})`); + return (await res.json()) as { rows?: SearchRow[] }; +} + +export interface SearchTotals { + clicks: number; + impressions: number; + ctr: number; + position: number; +} + +// Aggregate rows into totals: summed clicks/impressions, derived CTR, impression-weighted position. +export function shapeSearchAnalytics(raw: { rows?: SearchRow[] }): { + rows: SearchRow[]; + totals: SearchTotals; +} { + const rows = raw.rows ?? []; + let clicks = 0; + let impressions = 0; + let weightedPosition = 0; + for (const r of rows) { + clicks += r.clicks; + impressions += r.impressions; + weightedPosition += r.position * r.impressions; + } + return { + rows, + totals: { + clicks, + impressions, + ctr: impressions > 0 ? clicks / impressions : 0, + position: impressions > 0 ? weightedPosition / impressions : 0, + }, + }; +} + +// ---- PageSpeed Insights ---- + +function psiEndpoint(url: string, key: string, strategy: string): string { + return ( + `https://www.googleapis.com/pagespeedonline/v5/runPagespeed` + + `?url=${encodeURIComponent(url)}&key=${encodeURIComponent(key)}&strategy=${encodeURIComponent(strategy)}` + ); +} + +export async function psiProbe( + url: string, + key: string, + strategy: string, + fetchImpl: FetchImpl, +): Promise<"ok" | "invalid" | "unreachable"> { + try { + const res = await fetchImpl(psiEndpoint(url, key, strategy)); + if (res.ok) return "ok"; + return res.status === 400 || res.status === 403 ? "invalid" : "unreachable"; + } catch { + return "unreachable"; + } +} + +// A PSI-probe URL must be a real http(s) URL. `sc-domain:` properties aren't, so fall back to +// the sitemap's origin. +export function probeUrl(env: Env): string { + if (/^https?:\/\//.test(env.SEO_HERMIT_SITE_URL)) return env.SEO_HERMIT_SITE_URL; + try { + return new URL(env.SEO_HERMIT_SITEMAP_URL).origin + "/"; + } catch { + return env.SEO_HERMIT_SITE_URL; + } +} + +// ---- check ---- + +export interface CheckResult { + gsc: "missing" | "invalid" | "unreachable" | "ok"; + psi?: "ok" | "invalid" | "unreachable"; +} + +function ymd(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +export async function runCheck(env: Env, nowMs: number, fetchImpl: FetchImpl): Promise<CheckResult> { + for (const v of REQUIRED_VARS) { + if (!env[v]) return { gsc: "missing" }; + } + const sa = loadServiceAccount(env.SEO_HERMIT_GSC_CREDENTIALS); + if (!sa) return { gsc: "missing" }; + + const result: CheckResult = { gsc: "ok" }; + try { + const token = await mintAccessToken(sa, Math.floor(nowMs / 1000), fetchImpl); + await gscSearchAnalytics( + token, + env.SEO_HERMIT_SITE_URL, + { startDate: ymd(nowMs - 7 * 86_400_000), endDate: ymd(nowMs), rowLimit: 1 }, + fetchImpl, + ); + } catch (err) { + result.gsc = err instanceof HttpError && (err.status === 401 || err.status === 403) ? "invalid" : "unreachable"; + } + + if (env.SEO_HERMIT_PSI_KEY) { + result.psi = await psiProbe(probeUrl(env), env.SEO_HERMIT_PSI_KEY, "mobile", fetchImpl); + } + return result; +} + +// ---- sitemap ---- + +// Extract <loc> URLs from a sitemap or sitemap-index document (regex — no XML dep). +export function parseSitemapLocs(xml: string): string[] { + const out: string[] = []; + const re = /<loc>\s*([^<\s]+)\s*<\/loc>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) out.push(m[1]); + return out; +} + +export async function fetchSitemap( + sitemapUrl: string, + limit: number, + fetchImpl: FetchImpl, +): Promise<string[]> { + const res = await fetchImpl(sitemapUrl); + if (!res.ok) throw new HttpError(res.status, `sitemap fetch failed (${res.status})`); + const xml = await res.text(); + const locs = parseSitemapLocs(xml); + const isIndex = /<sitemapindex[\s>]/i.test(xml); + const urls: string[] = []; + if (isIndex) { + // Recurse one level into child sitemaps. + for (const child of locs) { + if (urls.length >= limit) break; + try { + const childRes = await fetchImpl(child); + if (!childRes.ok) continue; + urls.push(...parseSitemapLocs(await childRes.text())); + } catch { + // skip unreachable child sitemap + } + } + } else { + urls.push(...locs); + } + return urls.slice(0, limit); +} + +// ---- link check ---- + +export interface LinkResult { + url: string; + status: number; + ok: boolean; + final_url: string; +} + +async function checkOneLink(url: string, fetchImpl: FetchImpl): Promise<LinkResult> { + const attempt = async (method: "HEAD" | "GET"): Promise<Response> => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + return await fetchImpl(url, { method, redirect: "follow", signal: controller.signal }); + } finally { + clearTimeout(timer); + } + }; + try { + let res = await attempt("HEAD"); + if (res.status === 405 || res.status === 501) res = await attempt("GET"); + return { url, status: res.status, ok: res.ok, final_url: res.url || url }; + } catch { + return { url, status: 0, ok: false, final_url: url }; + } +} + +export async function linkCheck( + urls: string[], + budget: number, + concurrency: number, + fetchImpl: FetchImpl, +): Promise<LinkResult[]> { + const queue = urls.slice(0, budget); + const results: LinkResult[] = []; + let cursor = 0; + async function worker(): Promise<void> { + while (cursor < queue.length) { + const i = cursor++; + results[i] = await checkOneLink(queue[i], fetchImpl); + } + } + await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, queue.length)) }, worker)); + return results; +} + +// ---- PageSpeed Insights (field + lab metrics) ---- + +export interface CwvResult { + url: string; + strategy: string; + lcp_ms: number | null; + inp_ms: number | null; + cls: number | null; + perf_score: number | null; +} + +interface PsiApiResponse { + loadingExperience?: { metrics?: Record<string, { percentile?: number }> }; + lighthouseResult?: { categories?: { performance?: { score?: number } } }; +} + +export function shapePsi(url: string, strategy: string, raw: PsiApiResponse): CwvResult { + const metrics = raw.loadingExperience?.metrics ?? {}; + const cruxMs = (key: string): number | null => { + const p = metrics[key]?.percentile; + return typeof p === "number" ? p : null; + }; + const clsRaw = metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile; + const score = raw.lighthouseResult?.categories?.performance?.score; + return { + url, + strategy, + lcp_ms: cruxMs("LARGEST_CONTENTFUL_PAINT_MS"), + inp_ms: cruxMs("INTERACTION_TO_NEXT_PAINT"), + cls: typeof clsRaw === "number" ? clsRaw / 100 : null, + perf_score: typeof score === "number" ? score : null, + }; +} + +export async function psiFetch( + url: string, + key: string, + strategy: string, + fetchImpl: FetchImpl, +): Promise<CwvResult> { + const res = await fetchImpl(psiEndpoint(url, key, strategy)); + if (!res.ok) throw new HttpError(res.status, `PSI failed (${res.status})`); + return shapePsi(url, strategy, (await res.json()) as PsiApiResponse); +} + +// Core Web Vitals verdict per Google thresholds; overall = worst metric present. +export function cwvVerdict(r: Pick<CwvResult, "lcp_ms" | "inp_ms" | "cls">): "good" | "needs-improvement" | "poor" { + const rank = { good: 0, "needs-improvement": 1, poor: 2 } as const; + const scores: (keyof typeof rank)[] = []; + if (r.lcp_ms != null) scores.push(r.lcp_ms <= 2500 ? "good" : r.lcp_ms > 4000 ? "poor" : "needs-improvement"); + if (r.inp_ms != null) scores.push(r.inp_ms <= 200 ? "good" : r.inp_ms > 500 ? "poor" : "needs-improvement"); + if (r.cls != null) scores.push(r.cls <= 0.1 ? "good" : r.cls > 0.25 ? "poor" : "needs-improvement"); + if (scores.length === 0) return "good"; + return scores.reduce((worst, s) => (rank[s] > rank[worst] ? s : worst), "good"); +} + +// ---- URL Inspection (budgeted index status) ---- + +export interface InspectResult { + url: string; + verdict: string; + coverage_state: string; + last_crawl_time: string | null; + robots_txt_state: string; +} + +export async function inspectUrls( + accessToken: string, + siteUrl: string, + urls: string[], + budget: number, + fetchImpl: FetchImpl, +): Promise<InspectResult[]> { + const out: InspectResult[] = []; + for (const url of urls.slice(0, budget)) { + const res = await fetchImpl("https://searchconsole.googleapis.com/v1/urlInspection/index:inspect", { + method: "POST", + headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" }, + body: JSON.stringify({ inspectionUrl: url, siteUrl }), + }); + if (!res.ok) throw new HttpError(res.status, `urlInspection failed (${res.status})`); + const json = (await res.json()) as { + inspectionResult?: { indexStatusResult?: Record<string, string> }; + }; + const idx = json.inspectionResult?.indexStatusResult ?? {}; + out.push({ + url, + verdict: idx.verdict ?? "VERDICT_UNSPECIFIED", + coverage_state: idx.coverageState ?? "", + last_crawl_time: idx.lastCrawlTime ?? null, + robots_txt_state: idx.robotsTxtState ?? "", + }); + } + return out; +} + +// ---- ledger diff engine ---- + +export interface Ledger { + version: 1; + site_url: string; + updated_at: string; + inspect_cursor: number; + search: { history: SearchLedgerEntry[] }; + links: { broken: Record<string, BrokenLink>; last_checked: { date: string; count: number } | null }; + cwv: Record<string, { history: CwvLedgerEntry[] }>; + index: Record<string, { verdict: string; coverage_state: string; last_inspected: string }>; +} + +interface SearchLedgerEntry { + week_end: string; + clicks: number; + impressions: number; + ctr: number; + position: number; +} +interface BrokenLink { + status: number; + first_seen: string; + last_seen: string; +} +interface CwvLedgerEntry { + date: string; + strategy: string; + lcp_ms: number | null; + inp_ms: number | null; + cls: number | null; + perf_score: number | null; + verdict: string; +} + +export interface Snapshot { + date: string; + week_end: string; + search_current: { totals: SearchTotals } | null; + search_prior: { totals: SearchTotals } | null; + links: LinkResult[]; + cwv: CwvResult[]; + index: InspectResult[]; + sitemap_count: number; +} + +export interface LedgerChanges { + quiet: boolean; + regressions: string[]; + improvements: string[]; + new_broken: string[]; + resolved: string[]; + notes: string[]; +} + +export function emptyLedger(siteUrl: string): Ledger { + return { + version: 1, + site_url: siteUrl, + updated_at: "", + inspect_cursor: 0, + search: { history: [] }, + links: { broken: {}, last_checked: null }, + cwv: {}, + index: {}, + }; +} + +const SEARCH_TRIM = 12; +const CWV_TRIM = 8; +const WOW_THRESHOLD = 0.2; + +// Diff a snapshot against the ledger, mutating the ledger in place and returning the changes. +export function diffLedger(ledger: Ledger, snap: Snapshot, nowIso: string): LedgerChanges { + const changes: LedgerChanges = { + quiet: true, + regressions: [], + improvements: [], + new_broken: [], + resolved: [], + notes: [], + }; + const firstRun = ledger.search.history.length === 0 && Object.keys(ledger.index).length === 0; + + // --- search WoW --- + if (snap.search_current) { + const cur = snap.search_current.totals; + const prior = snap.search_prior?.totals ?? ledger.search.history.at(-1) ?? null; + if (prior && !firstRun) { + for (const metric of ["clicks", "impressions"] as const) { + const p = prior[metric]; + const c = cur[metric]; + if (p > 0) { + const delta = (c - p) / p; + if (delta <= -WOW_THRESHOLD) changes.regressions.push(`search ${metric} ${pct(delta)} (${p}→${c})`); + else if (delta >= WOW_THRESHOLD) changes.improvements.push(`search ${metric} ${pct(delta)} (${p}→${c})`); + } + } + } + ledger.search.history.push({ + week_end: snap.week_end, + clicks: cur.clicks, + impressions: cur.impressions, + ctr: cur.ctr, + position: cur.position, + }); + ledger.search.history = ledger.search.history.slice(-SEARCH_TRIM); + } + + // --- links --- + const brokenNow = new Set<string>(); + for (const link of snap.links) { + if (link.ok) continue; + brokenNow.add(link.url); + const existing = ledger.links.broken[link.url]; + if (existing) { + existing.last_seen = snap.date; + existing.status = link.status; + } else { + ledger.links.broken[link.url] = { status: link.status, first_seen: snap.date, last_seen: snap.date }; + changes.new_broken.push(`${link.url} (${link.status || "unreachable"})`); + } + } + for (const url of Object.keys(ledger.links.broken)) { + if (!brokenNow.has(url) && snap.links.length > 0) { + changes.resolved.push(url); + delete ledger.links.broken[url]; + } + } + if (snap.links.length > 0) ledger.links.last_checked = { date: snap.date, count: snap.links.length }; + + // --- CWV verdict transitions --- + for (const c of snap.cwv) { + const verdict = cwvVerdict(c); + const bucket = (ledger.cwv[c.url] ??= { history: [] }); + const prev = bucket.history.at(-1); + if (prev && prev.verdict !== verdict && !firstRun) { + changes[verdict === "good" ? "improvements" : "regressions"].push( + `CWV ${c.url} ${prev.verdict}→${verdict}`, + ); + } + bucket.history.push({ + date: snap.date, + strategy: c.strategy, + lcp_ms: c.lcp_ms, + inp_ms: c.inp_ms, + cls: c.cls, + perf_score: c.perf_score, + verdict, + }); + bucket.history = bucket.history.slice(-CWV_TRIM); + } + + // --- index verdict flips --- + for (const ins of snap.index) { + const prev = ledger.index[ins.url]; + if (prev && prev.verdict !== ins.verdict && !firstRun) { + changes[ins.verdict === "PASS" ? "improvements" : "regressions"].push( + `index ${ins.url} ${prev.verdict}→${ins.verdict}`, + ); + } + ledger.index[ins.url] = { + verdict: ins.verdict, + coverage_state: ins.coverage_state, + last_inspected: snap.date, + }; + } + + // --- inspect cursor advance (rotate through the sitemap) --- + if (snap.index.length > 0 && snap.sitemap_count > 0) { + ledger.inspect_cursor = (ledger.inspect_cursor + snap.index.length) % snap.sitemap_count; + } + + ledger.updated_at = nowIso; + + if (firstRun) { + changes.quiet = false; + changes.notes.push("baseline established"); + } else { + changes.quiet = + changes.regressions.length === 0 && + changes.improvements.length === 0 && + changes.new_broken.length === 0 && + changes.resolved.length === 0; + } + return changes; +} + +function pct(delta: number): string { + const sign = delta >= 0 ? "+" : ""; + return `${sign}${Math.round(delta * 100)}%`; +} + +// ---- CLI ---- + +interface ParsedArgs { + positional: string[]; + flags: Record<string, string>; +} + +function parseArgs(argv: string[]): ParsedArgs { + const positional: string[] = []; + const flags: Record<string, string> = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = "true"; + } + } else { + positional.push(a); + } + } + return { positional, flags }; +} + +function emit(ok: true, data: unknown): void; +function emit(ok: false, error: string): void; +function emit(ok: boolean, payload: unknown): void { + console.log(JSON.stringify(ok ? { ok: true, data: payload } : { ok: false, error: payload })); +} + +async function main(): Promise<void> { + const argv = process.argv.slice(2); + const cmd = argv[0]; + const { positional, flags } = parseArgs(argv.slice(1)); + const root = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const env = resolveEnv(root); + const nowMs = Date.now(); + + if (cmd === "check") { + const result = await runCheck(env, nowMs, fetch); + console.log(result.gsc); + if (result.psi) console.log(`psi:${result.psi}`); + process.exit(result.gsc === "ok" ? 0 : 1); + } + + if (cmd === "search-analytics") { + const start = flags.start; + const end = flags.end; + if (!start || !end) { + emit(false, "search-analytics requires --start and --end (YYYY-MM-DD)"); + process.exit(1); + } + const sa = requireServiceAccount(env); + const dimensions = (flags.dimensions ?? "page").split(","); + const rowLimit = flags["row-limit"] ? Number(flags["row-limit"]) : 20; + try { + const token = await mintAccessToken(sa, Math.floor(nowMs / 1000), fetch); + const raw = await gscSearchAnalytics( + token, + env.SEO_HERMIT_SITE_URL, + { startDate: start, endDate: end, dimensions, rowLimit }, + fetch, + ); + emit(true, shapeSearchAnalytics(raw)); + process.exit(0); + } catch (err) { + emit(false, errMessage(err)); + process.exit(1); + } + } + + if (cmd === "sitemap") { + const limit = flags.limit ? Number(flags.limit) : 5000; + try { + emit(true, { urls: await fetchSitemap(env.SEO_HERMIT_SITEMAP_URL, limit, fetch) }); + process.exit(0); + } catch (err) { + emit(false, errMessage(err)); + process.exit(1); + } + } + + if (cmd === "link-check") { + const budget = flags.budget ? Number(flags.budget) : 200; + const concurrency = flags.concurrency ? Number(flags.concurrency) : 4; + try { + const urls = await fetchSitemap(env.SEO_HERMIT_SITEMAP_URL, budget, fetch); + emit(true, { results: await linkCheck(urls, budget, concurrency, fetch) }); + process.exit(0); + } catch (err) { + emit(false, errMessage(err)); + process.exit(1); + } + } + + if (cmd === "psi") { + const url = positional[0]; + if (!url) { + emit(false, "psi requires a URL argument"); + process.exit(1); + } + if (!env.SEO_HERMIT_PSI_KEY) { + emit(false, "SEO_HERMIT_PSI_KEY not set"); + process.exit(1); + } + try { + emit(true, await psiFetch(url, env.SEO_HERMIT_PSI_KEY, flags.strategy ?? "mobile", fetch)); + process.exit(0); + } catch (err) { + emit(false, errMessage(err)); + process.exit(1); + } + } + + if (cmd === "inspect") { + if (positional.length === 0) { + emit(false, "inspect requires at least one URL"); + process.exit(1); + } + const sa = requireServiceAccount(env); + const budget = flags.budget ? Number(flags.budget) : 20; + try { + const token = await mintAccessToken(sa, Math.floor(nowMs / 1000), fetch); + emit(true, { results: await inspectUrls(token, env.SEO_HERMIT_SITE_URL, positional, budget, fetch) }); + process.exit(0); + } catch (err) { + emit(false, errMessage(err)); + process.exit(1); + } + } + + if (cmd === "ledger") { + const snapshotPath = flags.snapshot; + if (!snapshotPath) { + emit(false, "ledger requires --snapshot <file>"); + process.exit(1); + } + const dir = flags.dir ?? ".claude-code-hermit"; + try { + const snap = JSON.parse(readFileSync(resolve(root, snapshotPath), "utf-8")) as Snapshot; + const ledgerPath = resolve(root, dir, "state", "site-health-ledger.json"); + const ledger: Ledger = existsSync(ledgerPath) + ? (JSON.parse(readFileSync(ledgerPath, "utf-8")) as Ledger) + : emptyLedger(env.SEO_HERMIT_SITE_URL); + const changes = diffLedger(ledger, snap, new Date(nowMs).toISOString()); + mkdirSync(dirname(ledgerPath), { recursive: true }); + writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2)); + emit(true, changes); + process.exit(0); + } catch (err) { + emit(false, errMessage(err)); + process.exit(1); + } + } + + emit(false, `unknown command: ${cmd ?? "(none)"}`); + process.exit(1); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +if (import.meta.main) { + void main(); +} diff --git a/plugins/seo-hermit/settings.json b/plugins/seo-hermit/settings.json new file mode 100644 index 00000000..4cfd1bbf --- /dev/null +++ b/plugins/seo-hermit/settings.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "Bash(git status)", + "Bash(git log *)", + "Bash(git diff *)", + "Bash(git show *)", + "Bash(bun *scripts/site-api.ts *)" + ], + "deny": [ + "Bash(rm -rf *)", + "Bash(git push --force*)", + "Bash(git reset --hard*)", + "Bash(chmod 777*)", + "Bash(curl * | bash*)", + "Bash(wget * | bash*)", + "Edit(**/.claude-code-hermit/OPERATOR.md)", + "Write(**/.claude-code-hermit/OPERATOR.md)", + "Edit(.env)", + "Write(.env)" + ] + } +} diff --git a/plugins/seo-hermit/skills/hatch/SKILL.md b/plugins/seo-hermit/skills/hatch/SKILL.md new file mode 100644 index 00000000..1f53be60 --- /dev/null +++ b/plugins/seo-hermit/skills/hatch/SKILL.md @@ -0,0 +1,237 @@ +--- +name: hatch +description: One-time SEO/site-health hermit setup. Collects Search Console + PageSpeed credentials, verifies GSC access with a live round-trip, and wires the weekly site-health routine into config.json. Run once per project after /claude-code-hermit:hatch. +--- + +# Hatch — seo-hermit + +Idempotent setup wizard for the SEO/site-health plugin. Run **after** `/claude-code-hermit:hatch` has been completed. + +--- + +## Step 1 — Prerequisite check + +Read `.claude-code-hermit/config.json`. + +If the file does not exist or `_hermit_versions["claude-code-hermit"]` is absent or empty: + +> "The base hermit is not set up in this project yet. Run `/claude-code-hermit:hatch` first, then return here." + +Use `AskUserQuestion`: "Would you like to run `/claude-code-hermit:hatch` now? (yes / no)" + +- **yes** → Follow the domain hatch continuation protocol (documented in `claude-code-hermit:hatch`): + 1. Write `.claude-code-hermit/state/hatch-resume.json` with `{ "skill": "seo-hermit:hatch" }`. + 2. Print: "(If setup doesn't continue automatically when core finishes, re-run `/seo-hermit:hatch`.)" + 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. +- **no** → stop. + +If present but below `1.2.14` (compare major.minor.patch numerically): + +> "Base hermit version is {version}; this plugin requires ≥1.2.14. Run `/claude-code-hermit:hermit-evolve` to upgrade, then re-run this hatch." + +Stop. + +--- + +## Step 2 — Idempotency check + +Read `_hermit_versions["seo-hermit"]` from `.claude-code-hermit/config.json`. + +Read `version` from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. + +If the versions match: + +> "seo-hermit {version} is already installed. Reply 'verify' to re-run checks only, or 'full' to re-run the full wizard." + +Use `AskUserQuestion`: "(verify / full)" + +- **verify** → skip to Step 5. +- **full** → continue from Step 3. + +If absent or stale: continue from Step 3. + +--- + +## Step 3 — Credentials + .env verification + +**Do NOT `cat`, `grep`, `echo`, or Read the service-account JSON file.** It contains an RSA `private_key`; pulling it into context risks leaking it into session artifacts. Verify the file *exists* only (an `ls <path>` is fine — the path holds no denied substring). The live `check` in Step 5 proves the key actually works. The four `SEO_HERMIT_*` variable names avoid the `TOKEN`/`SECRET`/`API_KEY` deny substrings, so reading `.env` itself with the **Read tool** is safe — but never place a credential value on a Bash command line. + +Tell the operator: + +> "This plugin needs Google Search Console access and, optionally, a PageSpeed Insights key. +> +> **Search Console (required):** +> 1. In Google Cloud console, create (or pick) a project and **enable the Search Console API**. +> 2. Create a **service account** and download its JSON key. +> 3. Save the key to `.claude.local/gsc-service-account.json` (gitignored local-secrets dir). +> 4. In Search Console, add the service account's `client_email` as a **user** on your property (read access is enough). +> +> **PageSpeed Insights (optional — enables Core Web Vitals checks):** +> - Create an API key in the same Cloud project and enable the PageSpeed Insights API. +> +> Then fill `.env` in the project root: +> +> ``` +> SEO_HERMIT_SITE_URL=sc-domain:example.com # or https://www.example.com/ for a URL-prefix property +> SEO_HERMIT_SITEMAP_URL=https://www.example.com/sitemap.xml +> SEO_HERMIT_GSC_CREDENTIALS=.claude.local/gsc-service-account.json +> SEO_HERMIT_PSI_KEY= # optional; leave blank to skip CWV +> ``` +> +> Reply 'done' when set, or 'abort' to stop." + +Use `AskUserQuestion`: "(done / abort)" + +- **abort** → stop. +- **done** → continue. + +Use the **Read tool** to read `.env`. Verify `SEO_HERMIT_SITE_URL`, `SEO_HERMIT_SITEMAP_URL`, and `SEO_HERMIT_GSC_CREDENTIALS` are present and non-empty (`SEO_HERMIT_PSI_KEY` may be blank). Confirm the path in `SEO_HERMIT_GSC_CREDENTIALS` exists (existence check only — do not Read it). If anything is missing, report which and loop back to the `AskUserQuestion` above. + +--- + +## Step 4 — Verify consumer .gitignore + +Read the consumer's `.gitignore` (this file is not a secret). Check that `.env`, `.env.*`, and `.claude.local/` are present. Append any missing patterns via Edit: + +``` +.env +.env.* +.claude.local/ +``` + +--- + +## Step 5 — Live credential check + +Run: `bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts check` + +Line 1 is the GSC status; an optional `psi:<status>` line follows when a PSI key is set. + +- **`missing`** → a required var is unset or the service-account file is absent/unparseable. Return to Step 3. +- **`invalid`** → credentials were rejected (401/403). Most common cause: the service account's `client_email` has not been added as a user on the Search Console property. Fix that, then re-run. +- **`unreachable`** → the API could not be reached (network/egress blocked). In Docker, verify the DNS allowlist below. Re-run once connectivity is confirmed. +- **`ok`** → continue. + +GSC `ok` is **required** to proceed. A `psi:invalid`/`psi:unreachable` line is informational — CWV checks will be skipped until the PSI key works, but hatch continues. + +--- + +## Step 6 — CLAUDE.md / CLAUDE.local.md inject + +**Resolve target file:** Read `.claude-code-hermit/state/hatch-options.json`. Use the `"target"` field: +- `"local"` → `target_file = CLAUDE.local.md` +- `"committed"` or absent → `target_file = CLAUDE.md` +- If the file doesn't exist (operator's core hermit predates scope-aware hatch): detect `core_install_scope` from `claude plugin list --json` using the same precedence as core hatch (filter `claude-code-hermit` entries where `enabled == true`; precedence `local` > `project` (both require `projectPath == project root`) > `user` (any `projectPath`) > `null`; map `project` → `committed`, `local`/`user`/`null` → `local`). Ask with `AskUserQuestion` (header: "Visibility") — scope-derived default at position 0 with `(recommended)`: **`.local` files** (gitignored) / **Committed files** (shared). Write the canonical 5-field schema to `.claude-code-hermit/state/hatch-options.json`: + + ```json + { + "target": "<choice>", + "core_install_scope": "<project|local|user|null>", + "stamped_at": "<current ISO 8601 timestamp with timezone offset>", + "stamped_by": "seo-hermit:hatch", + "version": "<current seo-hermit plugin version from ${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json>" + } + ``` + +Read `target_file`. Search for the opening marker `<!-- seo-hermit: SEO Workflow -->`. + +- **`target_file` does not exist** → treat as marker-absent; Edit will create the file. +- **Marker absent** → append the full contents of `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md` using Edit. +- **Marker present** → skip (up-to-date; `hermit-evolve` handles block replacement on upgrade). + +--- + +## Step 7 — Knowledge-schema extension + +Read `.claude-code-hermit/knowledge-schema.md`. + +Check for `site-health:` in the file. If absent, append under `## Work Products`: + +``` +- site-health: weekly site-health report — only the metrics that changed since last run. Producer: seo-hermit:site-health-check routine. location: compiled/site-health-<YYYY-MM-DD>.md +- site-triage: regression-to-commit correlation for a flagged week. Producer: seo-hermit:site-regression-triage. location: compiled/site-triage-<YYYY-MM-DD>.md +- site-fix: record of a drafted mechanical fix (branch, diff summary, approval outcome). Producer: seo-hermit:site-draft-fix. location: compiled/site-fix-<YYYY-MM-DD>.md +``` + +And under `## Raw Captures`: + +``` +- site-health-snapshot: assembled weekly snapshot fed to the ledger diff. Retention: 14 days. location: raw/site-health-snapshot-<date>.json +- gsc-search-analytics: raw Search Console rows for the current + prior window. Retention: 14 days. location: raw/gsc-search-analytics-<date>.json +- site-linkcheck: sitemap link-check results. Retention: 14 days. location: raw/site-linkcheck-<date>.json +- site-psi: PageSpeed Insights field/lab metrics per sampled page. Retention: 14 days. location: raw/site-psi-<date>.json +- site-inspect: URL Inspection index-status results for the rotating budget. Retention: 14 days. location: raw/site-inspect-<date>.json +``` + +Use Edit. Skip if already present (idempotent). + +--- + +## Step 8 — Stamp + register in config.json + +Use the `config.json` content loaded in Step 1. + +**Stamp version**: set `_hermit_versions["seo-hermit"]` to the plugin version from Step 2. + +**Merge routine**: in the `routines` array, check for `id: "site-health-weekly"`. If absent, append it. If present (by `id`), skip — do not clobber operator edits. + +```json +{"id": "site-health-weekly", "schedule": "0 8 * * 1", "skill": "seo-hermit:site-health-check", "enabled": true, "run_during_waiting": true} +``` + +The routine invokes the `site-health-check` skill directly (core `weekly-review` pattern) — no +`prompt_file`. Monday 08:00 in the operator's `config.timezone`. + +**Set plugin config**: ensure `config["seo-hermit"]` exists. Ask the operator with `AskUserQuestion`: +"Path to the local clone of this site's source repo? (enables regression→commit correlation and +drafted fixes; leave blank to skip)". Then write: + +```json +"seo-hermit": {"site_repo": "<absolute path or null>", "inspect_budget": 20, "link_check_budget": 200} +``` + +If the key already exists, preserve `site_repo` and any operator-tuned budgets; only fill missing fields. + +Write the updated `config.json` via Write tool (full file replacement for valid JSON). + +--- + +## Step 9 — Final report + +``` +seo-hermit {version} setup complete. + +Installation summary: + ✓ Prerequisite: claude-code-hermit {base_version} confirmed + ✓ .env: SEO_HERMIT_SITE_URL, SEO_HERMIT_SITEMAP_URL, SEO_HERMIT_GSC_CREDENTIALS present + ✓ GSC credential check: ok{, PSI: <status> if a key was set} + ✓ .gitignore: .env and .claude.local/ covered + ✓ CLAUDE.md: SEO Workflow block injected (or already present) + ✓ knowledge-schema.md: site-health types added (or already present) + ✓ config.json: _hermit_versions stamped, site-health-weekly routine registered + +Next steps: + - Restart Claude Code so the updated CLAUDE.md loads. + - Run /claude-code-hermit:hermit-routines load to activate the weekly site-health check. + +Installed skills: + /seo-hermit:hatch — this setup wizard + /seo-hermit:site-health-check — weekly deltas-only site-health report (routine: Mon 08:00) + /seo-hermit:site-regression-triage — correlate a regression with recent site-repo commits + /seo-hermit:site-draft-fix — draft a mechanical fix on a branch (approval-gated) + +Security reminder: the service-account JSON (in .claude.local/) and the PSI key (in .env) +are local-only credentials. Verify .claude.local/ and .env are gitignored before any git push. +``` + +--- + +## Docker network requirements + +Read by `/claude-code-hermit:docker-security` when the operator enables LAN containment + DNS policy. + +### Domains (DNS allowlist) + +- searchconsole.googleapis.com +- googleapis.com (covers oauth2.googleapis.com, www.googleapis.com, pagespeedonline.googleapis.com) +- the site's own domain (for sitemap fetch + link checking) diff --git a/plugins/seo-hermit/skills/site-draft-fix/SKILL.md b/plugins/seo-hermit/skills/site-draft-fix/SKILL.md new file mode 100644 index 00000000..2576e911 --- /dev/null +++ b/plugins/seo-hermit/skills/site-draft-fix/SKILL.md @@ -0,0 +1,102 @@ +--- +name: site-draft-fix +description: Draft a mechanical SEO fix on a branch inside the configured local site repo, behind an explicit approval gate. Handles broken internal links, stale sitemap entries, missing redirects, empty title/meta tags, and CLS image dimensions. Never pushes; never touches a protected branch. +--- + +# site-draft-fix — seo-hermit + +Drafts a **mechanical** fix for a finding from `/seo-hermit:site-regression-triage`, on a branch, and +stops for approval before committing. Write-only toward the operator's own repo; read-only toward +everything else. This skill does the one thing incumbents can't: turn a diagnosed regression into a +reviewable diff. + +--- + +## Closed fix list (nothing outside this) + +Draft **only** these, and only when triage positively identified the cause: + +1. **Broken internal link** → rewrite the `href` to the resolvable target — only when git history + shows the slug was renamed (the old target once existed at the new path). +2. **Deleted page still in a static sitemap file** → remove the stale `<url>` entry. +3. **Missing redirect for a renamed slug** → add a redirect rule, only in a format you can positively + identify in the repo: `_redirects`, `netlify.toml`, or `vercel.json`. +4. **Missing/empty `<title>` or meta-description on a regressed page** → populate from the page's `h1` + (or existing frontmatter title). Never invent marketing prose. +5. **CLS-attributed image missing `width`/`height`** → add the intrinsic attributes, only when triage + tied the CLS regression to that image. + +Anything else — content rewrites, config redesigns, dependency changes, "while I'm here" edits — is +**out of scope**. If the fix isn't on this list, say so and stop. + +## Gate — before any change + +Read `config.json["seo-hermit"].site_repo`. Then, inside that repo, verify **all** of: + +- `site_repo` is set and is a git work tree (`git -C <repo> rev-parse --is-inside-work-tree`). Unset → stop: "No site_repo configured; nothing to draft against." +- **Clean tree**: `git -C <repo> status --porcelain` is empty. Dirty → stop and surface the diff; let the operator commit/stash first. +- **Not detached HEAD**: `git -C <repo> symbolic-ref -q HEAD` succeeds. +- The change set is **≤10 files**. More than that → do **not** draft; file a proposal via + `/claude-code-hermit:proposal-create` describing the scope, and stop. + +If any gate fails, stop with the specific reason. Do not attempt a workaround. + +## Step 1 — Branch + +Determine the repo's default branch (`git -C <repo> symbolic-ref refs/remotes/origin/HEAD` or fall +back to the current branch). **Refuse to proceed if the current branch is a protected branch** +(`main`/`master`, or the repo's own configured protected list). Create: + +``` +git -C <repo> checkout -b seo-hermit/fix-<YYYY-MM-DD>-<slug> +``` + +`<slug>` is a short kebab descriptor of the fix (e.g. `broken-blog-links`). + +## Step 2 — Apply the mechanical edits + +Make only the edits from the closed list, only for the findings triage confirmed. Use the Edit tool. +Keep each edit surgical — every changed line must trace to a specific finding. + +## Step 3 — Surface, then approve + +Show the operator the **full diff** (`git -C <repo> diff`). Summarize what changed and why (which +finding each edit addresses). Then ask with `AskUserQuestion`: + +> "Commit these fixes on branch `seo-hermit/fix-…`? (commit / discard)" + +- **discard** → `git -C <repo> checkout .` and delete the branch; report that nothing was committed. +- **commit** → continue. + +**Never commit without this explicit approval.** **Never `git push`.** + +## Step 4 — Commit + +``` +git -C <repo> add -A +git -C <repo> commit -m "fix(seo): <summary>" +``` + +## Step 5 — PR handoff (optional) + +- If `claude-code-dev-hermit` is installed and hatched in the site repo → offer to open the PR via + `/claude-code-dev-hermit:dev-pr --cwd <site_repo>` (its Gate 0, protected-branch, and test gates + apply; `--cwd` targets the site repo's own remote). +- Otherwise → stop at the local branch and print the manual push/PR steps. Do not push. + +## Step 6 — Record + +Write `compiled/site-fix-<date>.md`: + +```yaml +--- +title: "Site fix draft — <date>" +type: site-fix +created: <ISO 8601 with offset> +session: <current session ID from SHELL.md> +tags: [site-fix] +--- +``` + +Body: branch name, the findings addressed, a diff summary (files + one line each), and the approval +outcome (committed / discarded, PR opened or manual steps printed). Log one line to `SHELL.md`. diff --git a/plugins/seo-hermit/skills/site-health-check/SKILL.md b/plugins/seo-hermit/skills/site-health-check/SKILL.md new file mode 100644 index 00000000..84ecfa24 --- /dev/null +++ b/plugins/seo-hermit/skills/site-health-check/SKILL.md @@ -0,0 +1,136 @@ +--- +name: site-health-check +description: Weekly SEO/site-health routine. Pulls Search Console deltas, link-checks the sitemap, samples Core Web Vitals, inspects a rotating budget of URLs, diffs against the ledger, and reports only what changed. Runs via the site-health-weekly routine or on demand. +--- + +# site-health-check — seo-hermit + +The weekly site-health pass. Everything runs through `${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts` +(under `--plugin-dir`, substitute the absolute plugin path — `${CLAUDE_PLUGIN_ROOT}` is not +expanded there). The diff is mechanical: the script owns the ledger comparison, so "report only +changes" is deterministic and a quiet week reliably collapses to one line. + +Read `.claude-code-hermit/config.json` once for `["seo-hermit"]` settings (`inspect_budget` default +20, `link_check_budget` default 200) and `timezone`. All raw pulls are written flat to `raw/`. + +--- + +## Step 1 — Preflight + +Run `bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts check`. + +- Line 1 not `ok` → send one channel line ("Site health: GSC credentials {status} — run `/seo-hermit:hatch`") per the core channel-send convention, log it to `SHELL.md`, and **stop**. +- `ok` → continue. Note whether a `psi:ok` line is present; if not, skip the CWV step (Step 4). + +## Step 2 — Compute windows + +Search Console data lags ~3 days. Let `end = today − 3 days`. + +- **current window**: the 7 days ending `end`. +- **prior window**: the 7 days immediately before the current window. + +Record `week_end = end` (the current window's last day). + +## Step 3 — Search Console deltas + +For each window, run: + +``` +bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts search-analytics --start <start> --end <end> --dimensions page --row-limit 20 +``` + +Parse the two `{ok,data:{rows,totals}}` documents. Write both to `raw/gsc-search-analytics-<date>.json`. + +## Step 4 — Core Web Vitals (skip if no `psi:ok`) + +Pick the homepage plus the top 3 pages by clicks from the current-window rows (≤4 URLs). For each: + +``` +bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts psi <url> --strategy mobile +``` + +Collect the `{url, strategy, lcp_ms, inp_ms, cls, perf_score}` results into `raw/site-psi-<date>.json`. + +## Step 5 — Link check + +``` +bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts link-check --budget <link_check_budget> +``` + +Write the `{results:[…]}` to `raw/site-linkcheck-<date>.json`. + +## Step 6 — Index inspection (rotating budget) + +``` +bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts sitemap +``` + +Take `inspect_budget` URLs from the sitemap starting at the ledger's `inspect_cursor` (read +`state/site-health-ledger.json`; default cursor 0; wrap around the end). Then: + +``` +bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts inspect <url1> <url2> … --budget <inspect_budget> +``` + +Write to `raw/site-inspect-<date>.json`. (The script advances the cursor when the ledger runs.) + +## Step 7 — Assemble the snapshot and diff the ledger + +Write `raw/site-health-snapshot-<date>.json` with this exact shape (the ledger engine's input): + +```json +{ + "date": "<today YYYY-MM-DD>", + "week_end": "<week_end>", + "search_current": { "totals": { "clicks": 0, "impressions": 0, "ctr": 0, "position": 0 } }, + "search_prior": { "totals": { "clicks": 0, "impressions": 0, "ctr": 0, "position": 0 } }, + "links": [ { "url": "", "status": 200, "ok": true, "final_url": "" } ], + "cwv": [ { "url": "", "strategy": "mobile", "lcp_ms": 0, "inp_ms": 0, "cls": 0, "perf_score": 0 } ], + "index": [ { "url": "", "verdict": "PASS", "coverage_state": "", "last_crawl_time": null, "robots_txt_state": "" } ], + "sitemap_count": 0 +} +``` + +(Use the `totals` from Step 3, the `results` arrays from Steps 4–6, and the sitemap length from Step +6. Omit `search_current`/`cwv` entries you don't have — pass `null` / `[]`.) + +Then run the diff engine, which reads/writes `state/site-health-ledger.json` and prints the change set: + +``` +bun ${CLAUDE_PLUGIN_ROOT}/scripts/site-api.ts ledger --snapshot raw/site-health-snapshot-<date>.json +``` + +Parse `{ok,data:{quiet, regressions, improvements, new_broken, resolved, notes}}`. + +## Step 8 — Write the report + +Write `compiled/site-health-<date>.md` with frontmatter: + +```yaml +--- +title: "Site health — w/e <week_end>" +type: site-health +created: <ISO 8601 with offset> +session: <current session ID from SHELL.md> +tags: [site-health] +verdict: <quiet|changes> +--- +``` + +Body: **only** the non-empty change categories, one short section each (Regressions, Improvements, +New broken links, Resolved, Notes). If `quiet`, the body is a single line: "No changes since last +week." Never pad with unchanged metrics. + +## Step 9 — Channel brief + +Per the core channel-send convention (channel first, push-notification fallback, else `SHELL.md` +Progress Log — send at most once): + +- **quiet** → one line: `"Site health w/e <week_end>: no changes (<N> links, <M> URLs inspected, CWV stable)."` +- **changes** → ≤8 lines: lead with regressions and new broken links, then improvements/resolved. Link the report path. + +## Step 10 — Regression handoff + +If `regressions` or `new_broken` is non-empty, add a final report line: +"Regressions found — run `/seo-hermit:site-regression-triage` to correlate with recent commits." +Log one line to `SHELL.md` and close idle. diff --git a/plugins/seo-hermit/skills/site-regression-triage/SKILL.md b/plugins/seo-hermit/skills/site-regression-triage/SKILL.md new file mode 100644 index 00000000..8cc1e1b8 --- /dev/null +++ b/plugins/seo-hermit/skills/site-regression-triage/SKILL.md @@ -0,0 +1,83 @@ +--- +name: site-regression-triage +description: Correlate a flagged site-health regression with recent commits in the configured local site repo. Read-only — maps regressed pages to source files, ranks suspect commits by confidence, and writes a triage report. Run after site-health-check flags a regression. +--- + +# site-regression-triage — seo-hermit + +Read-only investigation. It never edits the site repo — it explains *why* a regression likely +happened and whether a mechanical fix exists. Drafting fixes is `/seo-hermit:site-draft-fix`. + +--- + +## Step 1 — Load the regression set + +Read `state/site-health-ledger.json` and the latest `compiled/site-health-<date>.md`. Build the +regression set from the most recent report's Regressions + New-broken-links sections: + +- regressed **pages** (search click/impression drops, index verdict flips), +- regressed **queries**, +- **CWV** pages that crossed to `needs-improvement`/`poor`, +- **broken internal links**. + +Define the regression window: prior report's date → current report's date (fall back to the last +7–14 days if only one report exists). + +## Step 2 — Resolve the site repo + +Read `config.json["seo-hermit"].site_repo`. + +- **unset / null** → **report-only mode**: skip commit correlation, still produce the triage artifact + from the ledger evidence, and note "no site_repo configured — external cause cannot be ruled in/out." +- **set** → confirm the path exists and is a git repo (`git -C <repo> rev-parse --is-inside-work-tree`). If not, fall back to report-only mode and say so. + +## Step 3 — Map regressed URLs to source files + +For each regressed page URL, derive candidate source files. Use **read-only git only** — never write: + +- `git -C <repo> log --since=<window-start> --until=<window-end> --stat` — commits in the window. +- `git -C <repo> show --stat <sha>` — files touched by a specific commit. + +Map heuristics (best-effort, framework-agnostic): +- the URL path → a matching route/page/content file (e.g. `/blog/x` → `**/blog/x.*`, `content/blog/x.md`), +- shared **templates/layouts**, `robots.txt`, sitemap generators, and redirect files → global suspects + for site-wide regressions. + +## Step 4 — Correlate and rank + +Assign each regression a confidence tier: + +- **high** — a commit in the window touched the file backing the regressed page. +- **medium** — a commit touched a shared template/layout/config (robots.txt, sitemap generator, + redirects, global CSS/JS affecting CWV). +- **low** — nothing in the window matches → "no candidate commit; likely external (algorithm/SERP + shift, competitor, seasonality)." + +For each regression, note whether a **mechanical fix** exists (the closed list `site-draft-fix` +handles): broken internal link on a confirmed slug rename, deleted page still in a static sitemap, +missing redirect for a renamed slug, missing/empty `<title>`/meta-description on a regressed page, +CLS-attributed image missing width/height. + +## Step 5 — Write the triage report + +Write `compiled/site-triage-<date>.md`: + +```yaml +--- +title: "Site regression triage — <date>" +type: site-triage +created: <ISO 8601 with offset> +session: <current session ID from SHELL.md> +tags: [site-triage] +--- +``` + +Body, per regression: metric + delta, confidence tier, suspect commits (sha, subject, files), +mechanical-fix availability. Close with a one-line verdict per regression. + +## Step 6 — Report + +Channel summary per the core channel-send convention: the top regressions with their most likely +cause. If any mechanical fix was identified and `site_repo` is set, offer: +"Run `/seo-hermit:site-draft-fix` to draft the fix(es) on a branch for review." +Log one line to `SHELL.md`. diff --git a/plugins/seo-hermit/state-templates/CLAUDE-APPEND.md b/plugins/seo-hermit/state-templates/CLAUDE-APPEND.md new file mode 100644 index 00000000..b092e5f2 --- /dev/null +++ b/plugins/seo-hermit/state-templates/CLAUDE-APPEND.md @@ -0,0 +1,38 @@ +<!-- seo-hermit: SEO Workflow --> +## SEO / Site-Health Workflow + +This project runs the **seo-hermit** layer: a weekly-cadence watcher over Google Search Console, +broken links, and Core Web Vitals. The raw monitoring is commodity; the value is the judgment +layer — deciding which signal changes matter, correlating a regression with a specific deploy, and +drafting the fix. Everything below that line is a diff-and-report. + +### Core rules + +- **Read-only toward Google.** The plugin never writes to Search Console or any search-facing + config. All GSC/PSI calls go through `scripts/site-api.ts`, which reads credentials from `.env` + and never accepts them on a command line. +- **Never Read or relay the service-account JSON.** It holds an RSA private key. Its path lives in + `SEO_HERMIT_GSC_CREDENTIALS`; credential validity is proven by `site-api.ts check`, never by + opening the file. +- **Report only changes.** The weekly routine diffs against `state/site-health-ledger.json` and + surfaces only what moved (new broken links, coverage flips, CWV threshold crossings, search + deltas beyond ±20%). A quiet week is one line. +- **Writes to the site repo are approval-gated.** `site-draft-fix` drafts mechanical fixes on a + branch inside the configured local site repo, shows the full diff, and waits for explicit + approval before committing. It never pushes and never touches a protected branch. +- **URL Inspection is budgeted.** Index status is checked per-URL against a weekly budget + (~2,000/day/property quota), rotating through the sitemap via a cursor in the ledger. + +### Skills + +- `/seo-hermit:hatch` — one-time setup (credentials, live GSC check, routine wiring). +- `/seo-hermit:site-health-check` — weekly routine: pull deltas, diff the ledger, report changes. +- `/seo-hermit:site-regression-triage` — correlate a flagged regression with recent site-repo commits. +- `/seo-hermit:site-draft-fix` — draft a mechanical fix on a branch, behind the approval gate. + +### Conventions + +- Machine-readable diff state lives in `state/site-health-ledger.json` (rolling history, trimmed). +- Durable dated reports go to `compiled/site-health-<date>.md`; raw API pulls to `raw/` (flat, no subdirs). +- Credentials: service-account JSON in `.claude.local/`, PSI key in `.env`. Both gitignored. +<!-- /seo-hermit: SEO Workflow --> diff --git a/plugins/seo-hermit/tests/fixtures/gsc-searchanalytics-ok.json b/plugins/seo-hermit/tests/fixtures/gsc-searchanalytics-ok.json new file mode 100644 index 00000000..46dc7c66 --- /dev/null +++ b/plugins/seo-hermit/tests/fixtures/gsc-searchanalytics-ok.json @@ -0,0 +1,7 @@ +{ + "rows": [ + { "keys": ["https://example.com/"], "clicks": 60, "impressions": 1200, "ctr": 0.05, "position": 8.3 }, + { "keys": ["https://example.com/blog"], "clicks": 20, "impressions": 800, "ctr": 0.025, "position": 14.1 } + ], + "responseAggregationType": "byPage" +} diff --git a/plugins/seo-hermit/tests/fixtures/psi-ok.json b/plugins/seo-hermit/tests/fixtures/psi-ok.json new file mode 100644 index 00000000..7a9ad1ce --- /dev/null +++ b/plugins/seo-hermit/tests/fixtures/psi-ok.json @@ -0,0 +1,12 @@ +{ + "loadingExperience": { + "metrics": { + "LARGEST_CONTENTFUL_PAINT_MS": { "percentile": 2100, "category": "FAST" }, + "INTERACTION_TO_NEXT_PAINT": { "percentile": 180, "category": "FAST" }, + "CUMULATIVE_LAYOUT_SHIFT_SCORE": { "percentile": 5, "category": "FAST" } + } + }, + "lighthouseResult": { + "categories": { "performance": { "score": 0.92 } } + } +} diff --git a/plugins/seo-hermit/tests/ledger.test.ts b/plugins/seo-hermit/tests/ledger.test.ts new file mode 100644 index 00000000..6fcedc71 --- /dev/null +++ b/plugins/seo-hermit/tests/ledger.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { + diffLedger, + emptyLedger, + type InspectResult, + type Ledger, + type LinkResult, + type SearchTotals, + type Snapshot, +} from "../scripts/site-api"; + +const ISO = "2026-07-06T08:00:00.000Z"; + +function snapshot(over: Partial<Snapshot>): Snapshot { + return { + date: "2026-07-06", + week_end: "2026-06-28", + search_current: null, + search_prior: null, + links: [], + cwv: [], + index: [], + sitemap_count: 0, + ...over, + }; +} + +function totals(clicks: number, impressions: number): { totals: SearchTotals } { + return { totals: { clicks, impressions, ctr: impressions ? clicks / impressions : 0, position: 5 } }; +} + +function link(url: string, status: number): LinkResult { + return { url, status, ok: status >= 200 && status < 400, final_url: url }; +} + +function indexEntry(url: string, verdict: string): InspectResult { + return { url, verdict, coverage_state: "", last_crawl_time: null, robots_txt_state: "" }; +} + +// Seed a non-empty ledger so diffs are not treated as the baseline first run. +function seededLedger(): Ledger { + const l = emptyLedger("sc-domain:example.com"); + l.index["https://example.com/seed"] = { + verdict: "PASS", + coverage_state: "Submitted and indexed", + last_inspected: "2026-06-01", + }; + return l; +} + +describe("first run", () => { + test("establishes a baseline: not quiet, notes it, no false regressions", () => { + const l = emptyLedger("sc-domain:example.com"); + const c = diffLedger(l, snapshot({ search_current: totals(100, 1000) }), ISO); + expect(c.quiet).toBe(false); + expect(c.notes).toContain("baseline established"); + expect(c.regressions).toHaveLength(0); + expect(l.search.history).toHaveLength(1); + }); +}); + +describe("search WoW", () => { + test("flags a regression at the -20% boundary", () => { + const l = seededLedger(); + l.search.history.push({ week_end: "2026-06-21", clicks: 100, impressions: 1000, ctr: 0.1, position: 5 }); + const c = diffLedger(l, snapshot({ search_current: totals(80, 1000) }), ISO); + expect(c.regressions.some((r) => r.includes("clicks"))).toBe(true); + expect(c.quiet).toBe(false); + }); + + test("flags an improvement at the +20% boundary", () => { + const l = seededLedger(); + l.search.history.push({ week_end: "2026-06-21", clicks: 100, impressions: 1000, ctr: 0.1, position: 5 }); + const c = diffLedger(l, snapshot({ search_current: totals(120, 1000) }), ISO); + expect(c.improvements.some((r) => r.includes("clicks"))).toBe(true); + }); + + test("stays quiet inside the ±20% band", () => { + const l = seededLedger(); + l.search.history.push({ week_end: "2026-06-21", clicks: 100, impressions: 1000, ctr: 0.1, position: 5 }); + const c = diffLedger(l, snapshot({ search_current: totals(110, 1050), index: [indexEntry("https://example.com/seed", "PASS")], sitemap_count: 5 }), ISO); + expect(c.regressions).toHaveLength(0); + expect(c.improvements).toHaveLength(0); + }); +}); + +describe("broken-link lifecycle", () => { + test("new → persists with last_seen bump → resolved and removed", () => { + const l = seededLedger(); + + let c = diffLedger(l, snapshot({ date: "2026-07-06", links: [link("https://example.com/dead", 404), link("https://example.com/ok", 200)] }), ISO); + expect(c.new_broken.some((x) => x.includes("dead"))).toBe(true); + expect(l.links.broken["https://example.com/dead"].first_seen).toBe("2026-07-06"); + + c = diffLedger(l, snapshot({ date: "2026-07-13", links: [link("https://example.com/dead", 404)] }), ISO); + expect(c.new_broken).toHaveLength(0); + expect(l.links.broken["https://example.com/dead"].last_seen).toBe("2026-07-13"); + + c = diffLedger(l, snapshot({ date: "2026-07-20", links: [link("https://example.com/dead", 200)] }), ISO); + expect(c.resolved).toContain("https://example.com/dead"); + expect(l.links.broken["https://example.com/dead"]).toBeUndefined(); + }); +}); + +describe("CWV verdict transition", () => { + test("good → poor is a regression", () => { + const l = seededLedger(); + diffLedger(l, snapshot({ date: "d1", cwv: [{ url: "https://example.com/", strategy: "mobile", lcp_ms: 2000, inp_ms: 100, cls: 0.05, perf_score: 0.9 }] }), ISO); + const c = diffLedger(l, snapshot({ date: "d2", cwv: [{ url: "https://example.com/", strategy: "mobile", lcp_ms: 5000, inp_ms: 100, cls: 0.05, perf_score: 0.4 }] }), ISO); + expect(c.regressions.some((r) => r.includes("CWV"))).toBe(true); + }); +}); + +describe("index verdict flip", () => { + test("PASS → FAIL is a regression", () => { + const l = seededLedger(); + l.search.history.push({ week_end: "w0", clicks: 1, impressions: 1, ctr: 1, position: 1 }); + diffLedger(l, snapshot({ index: [indexEntry("https://example.com/u", "PASS")], sitemap_count: 5 }), ISO); + const c = diffLedger(l, snapshot({ index: [indexEntry("https://example.com/u", "FAIL")], sitemap_count: 5 }), ISO); + expect(c.regressions.some((r) => r.includes("index"))).toBe(true); + }); +}); + +describe("trimming and cursor", () => { + test("search history trims to 12 weeks", () => { + const l = seededLedger(); + for (let i = 0; i < 15; i++) { + diffLedger(l, snapshot({ week_end: `w${i}`, search_current: totals(i + 1, 100) }), ISO); + } + expect(l.search.history).toHaveLength(12); + }); + + test("inspect_cursor advances and wraps around the sitemap size", () => { + const l = seededLedger(); + l.inspect_cursor = 8; + diffLedger( + l, + snapshot({ + index: [ + indexEntry("https://example.com/a", "PASS"), + indexEntry("https://example.com/b", "PASS"), + indexEntry("https://example.com/c", "PASS"), + indexEntry("https://example.com/d", "PASS"), + ], + sitemap_count: 10, + }), + ISO, + ); + expect(l.inspect_cursor).toBe(2); + }); +}); + +describe("quiet week", () => { + test("no changes → quiet true", () => { + const l = emptyLedger("sc-domain:example.com"); + l.index["https://example.com/u"] = { verdict: "PASS", coverage_state: "", last_inspected: "d0" }; + const c = diffLedger( + l, + snapshot({ + date: "d1", + links: [link("https://example.com/ok", 200)], + index: [indexEntry("https://example.com/u", "PASS")], + sitemap_count: 5, + }), + ISO, + ); + expect(c.quiet).toBe(true); + expect(c.regressions).toHaveLength(0); + expect(c.new_broken).toHaveLength(0); + }); +}); diff --git a/plugins/seo-hermit/tests/run-all.sh b/plugins/seo-hermit/tests/run-all.sh new file mode 100644 index 00000000..ac470dc6 --- /dev/null +++ b/plugins/seo-hermit/tests/run-all.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PLUGIN_DIR" + +echo "=== seo-hermit test suite ===" + +EXIT=0 + +echo "" +echo "--- bun tests (site-api + ledger) ---" +if ! bun test tests/site-api.test.ts tests/ledger.test.ts; then + EXIT=1 +fi + +echo "" +echo "--- bun tests (skill-structure) ---" +if ! bun test tests/skill-structure.test.ts; then + EXIT=1 +fi + +echo "" +if [ "$EXIT" -eq 0 ]; then + echo "All tests passed." +else + echo "Some tests failed." >&2 +fi + +exit "$EXIT" diff --git a/plugins/seo-hermit/tests/site-api.test.ts b/plugins/seo-hermit/tests/site-api.test.ts new file mode 100644 index 00000000..4ea400e2 --- /dev/null +++ b/plugins/seo-hermit/tests/site-api.test.ts @@ -0,0 +1,355 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createVerify, generateKeyPairSync, randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildJwt, + cwvVerdict, + fetchSitemap, + gscSearchAnalytics, + HttpError, + linkCheck, + loadDotEnv, + mintAccessToken, + parseSitemapLocs, + probeUrl, + resolveEnv, + runCheck, + shapePsi, + shapeSearchAnalytics, + type Env, +} from "../scripts/site-api"; + +type FetchImpl = typeof fetch; + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs) rmSync(d, { recursive: true, force: true }); +}); + +function makeTmpDir(): string { + const d = mkdtempSync(join(tmpdir(), "seo-hermit-")); + tmpDirs.push(d); + return d; +} + +function makeKeypair() { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return { + publicKey, + privatePem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +function makeServiceAccountFile(privatePem: string): string { + const dir = makeTmpDir(); + const path = join(dir, `sa-${randomUUID()}.json`); + writeFileSync( + path, + JSON.stringify({ + client_email: "svc@test.iam.gserviceaccount.com", + private_key: privatePem, + token_uri: "https://oauth2.googleapis.com/token", + }), + ); + return path; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +// Fetch stub routed by URL. token_uri → access token; searchAnalytics → configurable. +function routedFetch(opts: { token?: Response; search?: Response; psi?: Response }): FetchImpl { + return (async (input: string | URL | Request): Promise<Response> => { + const url = String(input); + if (url.includes("oauth2.googleapis.com/token")) { + return opts.token ?? jsonResponse({ access_token: "tok-123" }); + } + if (url.includes("searchAnalytics")) { + return opts.search ?? jsonResponse({ rows: [] }); + } + if (url.includes("pagespeedonline")) { + return opts.psi ?? jsonResponse({ lighthouseResult: {} }); + } + return jsonResponse({}, 404); + }) as unknown as FetchImpl; +} + +function baseEnv(overrides: Partial<Env> = {}): Env { + return { + SEO_HERMIT_SITE_URL: "sc-domain:example.com", + SEO_HERMIT_SITEMAP_URL: "https://example.com/sitemap.xml", + SEO_HERMIT_GSC_CREDENTIALS: "", + SEO_HERMIT_PSI_KEY: "", + ...overrides, + }; +} + +describe("JWT-bearer auth", () => { + test("buildJwt signs a verifiable RS256 assertion with correct claims", () => { + const { publicKey, privatePem } = makeKeypair(); + const sa = { + client_email: "svc@test.iam.gserviceaccount.com", + private_key: privatePem, + token_uri: "https://oauth2.googleapis.com/token", + }; + const jwt = buildJwt(sa, 1_000); + const [header, claims, signature] = jwt.split("."); + + const verifier = createVerify("RSA-SHA256"); + verifier.update(`${header}.${claims}`); + expect(verifier.verify(publicKey, Buffer.from(signature, "base64url"))).toBe(true); + + const decoded = JSON.parse(Buffer.from(claims, "base64url").toString()); + expect(decoded.iss).toBe(sa.client_email); + expect(decoded.aud).toBe(sa.token_uri); + expect(decoded.scope).toContain("webmasters"); + expect(decoded.exp - decoded.iat).toBe(3600); + expect(decoded.iat).toBe(1_000); + }); + + test("mintAccessToken returns the access token on success", async () => { + const { privatePem } = makeKeypair(); + const sa = { + client_email: "svc@test.iam.gserviceaccount.com", + private_key: privatePem, + token_uri: "https://oauth2.googleapis.com/token", + }; + const token = await mintAccessToken(sa, 1_000, routedFetch({})); + expect(token).toBe("tok-123"); + }); + + test("mintAccessToken throws HttpError(401) on rejected credentials", async () => { + const { privatePem } = makeKeypair(); + const sa = { + client_email: "svc@test.iam.gserviceaccount.com", + private_key: privatePem, + token_uri: "https://oauth2.googleapis.com/token", + }; + const fetchImpl = routedFetch({ token: jsonResponse({ error: "invalid_grant" }, 401) }); + await expect(mintAccessToken(sa, 1_000, fetchImpl)).rejects.toBeInstanceOf(HttpError); + }); +}); + +describe("runCheck state machine", () => { + const now = Date.UTC(2026, 6, 3); + + test("missing when required env vars are unset", async () => { + const result = await runCheck(baseEnv(), now, routedFetch({})); + expect(result.gsc).toBe("missing"); + }); + + test("missing when the service-account file does not exist", async () => { + const env = baseEnv({ SEO_HERMIT_GSC_CREDENTIALS: "/no/such/file.json" }); + const result = await runCheck(env, now, routedFetch({})); + expect(result.gsc).toBe("missing"); + }); + + test("ok when token mint and searchAnalytics probe both succeed", async () => { + const { privatePem } = makeKeypair(); + const env = baseEnv({ SEO_HERMIT_GSC_CREDENTIALS: makeServiceAccountFile(privatePem) }); + const result = await runCheck(env, now, routedFetch({})); + expect(result.gsc).toBe("ok"); + expect(result.psi).toBeUndefined(); + }); + + test("invalid when searchAnalytics returns 403", async () => { + const { privatePem } = makeKeypair(); + const env = baseEnv({ SEO_HERMIT_GSC_CREDENTIALS: makeServiceAccountFile(privatePem) }); + const fetchImpl = routedFetch({ search: jsonResponse({ error: "forbidden" }, 403) }); + const result = await runCheck(env, now, fetchImpl); + expect(result.gsc).toBe("invalid"); + }); + + test("unreachable when the network rejects", async () => { + const { privatePem } = makeKeypair(); + const env = baseEnv({ SEO_HERMIT_GSC_CREDENTIALS: makeServiceAccountFile(privatePem) }); + const fetchImpl = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as FetchImpl; + const result = await runCheck(env, now, fetchImpl); + expect(result.gsc).toBe("unreachable"); + }); + + test("adds a psi line only when the PSI key is set", async () => { + const { privatePem } = makeKeypair(); + const env = baseEnv({ + SEO_HERMIT_GSC_CREDENTIALS: makeServiceAccountFile(privatePem), + SEO_HERMIT_PSI_KEY: "psi-key", + }); + const result = await runCheck(env, now, routedFetch({})); + expect(result.gsc).toBe("ok"); + expect(result.psi).toBe("ok"); + }); +}); + +describe("shapeSearchAnalytics", () => { + test("aggregates rows into totals with impression-weighted position", () => { + const raw = JSON.parse( + readFixture("gsc-searchanalytics-ok.json"), + ); + const shaped = shapeSearchAnalytics(raw); + expect(shaped.rows).toHaveLength(2); + expect(shaped.totals.clicks).toBe(80); + expect(shaped.totals.impressions).toBe(2000); + expect(shaped.totals.ctr).toBeCloseTo(0.04, 5); + expect(shaped.totals.position).toBeCloseTo(10.62, 2); + }); + + test("empty rows yield zeroed totals without dividing by zero", () => { + const shaped = shapeSearchAnalytics({ rows: [] }); + expect(shaped.totals).toEqual({ clicks: 0, impressions: 0, ctr: 0, position: 0 }); + }); +}); + +describe("gscSearchAnalytics", () => { + test("throws HttpError with the status on non-2xx", async () => { + const fetchImpl = routedFetch({ search: jsonResponse({ error: "nope" }, 500) }); + await expect( + gscSearchAnalytics("tok", "sc-domain:example.com", { startDate: "a", endDate: "b" }, fetchImpl), + ).rejects.toMatchObject({ status: 500 }); + }); +}); + +describe("env loading", () => { + test("loadDotEnv parses KEY=VALUE and strips surrounding quotes", () => { + const dir = makeTmpDir(); + writeFileSync( + join(dir, ".env"), + ['SEO_HERMIT_SITE_URL="https://example.com/"', "SEO_HERMIT_PSI_KEY=raw-value", "# comment", ""].join("\n"), + ); + const parsed = loadDotEnv(dir); + expect(parsed.SEO_HERMIT_SITE_URL).toBe("https://example.com/"); + expect(parsed.SEO_HERMIT_PSI_KEY).toBe("raw-value"); + }); + + test("resolveEnv lets process.env override the .env file", () => { + const dir = makeTmpDir(); + writeFileSync(join(dir, ".env"), "SEO_HERMIT_SITE_URL=from-file"); + process.env.SEO_HERMIT_SITE_URL = "from-env"; + try { + expect(resolveEnv(dir).SEO_HERMIT_SITE_URL).toBe("from-env"); + } finally { + delete process.env.SEO_HERMIT_SITE_URL; + } + }); +}); + +describe("probeUrl", () => { + test("uses the site URL when it is an http URL", () => { + expect(probeUrl(baseEnv({ SEO_HERMIT_SITE_URL: "https://example.com/" }))).toBe("https://example.com/"); + }); + + test("falls back to the sitemap origin for sc-domain properties", () => { + expect(probeUrl(baseEnv())).toBe("https://example.com/"); + }); +}); + +function readFixture(name: string): string { + return require("node:fs").readFileSync(join(import.meta.dir, "fixtures", name), "utf-8"); +} + +const SITEMAP_INDEX = `<?xml version="1.0"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <sitemap><loc>https://example.com/sitemap1.xml</loc></sitemap> +</sitemapindex>`; +const SITEMAP1 = `<?xml version="1.0"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/a</loc></url> + <url><loc>https://example.com/b</loc></url> +</urlset>`; + +describe("sitemap parsing", () => { + test("parseSitemapLocs extracts <loc> entries", () => { + expect(parseSitemapLocs(SITEMAP1)).toEqual(["https://example.com/a", "https://example.com/b"]); + }); + + test("fetchSitemap recurses one level into a sitemap index", async () => { + const fetchImpl = (async (input: string | URL | Request): Promise<Response> => { + const url = String(input); + if (url.endsWith("/sitemap-index.xml")) return new Response(SITEMAP_INDEX, { status: 200 }); + if (url.endsWith("/sitemap1.xml")) return new Response(SITEMAP1, { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as FetchImpl; + const urls = await fetchSitemap("https://example.com/sitemap-index.xml", 100, fetchImpl); + expect(urls).toEqual(["https://example.com/a", "https://example.com/b"]); + }); + + test("fetchSitemap honours the limit", async () => { + const fetchImpl = (async () => new Response(SITEMAP1, { status: 200 })) as unknown as FetchImpl; + const urls = await fetchSitemap("https://example.com/sitemap.xml", 1, fetchImpl); + expect(urls).toHaveLength(1); + }); +}); + +describe("link check", () => { + const linkFetch = (async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.endsWith("/dead")) return new Response("", { status: 404 }); + if (url.endsWith("/head405")) return new Response("", { status: method === "HEAD" ? 405 : 200 }); + if (url.endsWith("/boom")) throw new Error("network down"); + return new Response("", { status: 200 }); + }) as unknown as FetchImpl; + + test("classifies ok, broken, HEAD→GET fallback, and network failure", async () => { + const results = await linkCheck( + ["https://x/ok", "https://x/dead", "https://x/head405", "https://x/boom"], + 200, + 4, + linkFetch, + ); + const byUrl = Object.fromEntries(results.map((r) => [r.url, r])); + expect(byUrl["https://x/ok"].ok).toBe(true); + expect(byUrl["https://x/dead"].status).toBe(404); + expect(byUrl["https://x/dead"].ok).toBe(false); + expect(byUrl["https://x/head405"].ok).toBe(true); // GET fallback returned 200 + expect(byUrl["https://x/boom"].ok).toBe(false); + expect(byUrl["https://x/boom"].status).toBe(0); + }); + + test("respects the budget cap", async () => { + const results = await linkCheck(["https://x/1", "https://x/2", "https://x/3"], 2, 4, linkFetch); + expect(results).toHaveLength(2); + }); +}); + +describe("PageSpeed Insights shaping", () => { + test("shapePsi trims to CrUX field metrics and lab score", () => { + const raw = JSON.parse(readFixture("psi-ok.json")); + const shaped = shapePsi("https://example.com/", "mobile", raw); + expect(shaped.lcp_ms).toBe(2100); + expect(shaped.inp_ms).toBe(180); + expect(shaped.cls).toBeCloseTo(0.05, 5); + expect(shaped.perf_score).toBe(0.92); + }); + + test("shapePsi tolerates missing metrics", () => { + const shaped = shapePsi("https://example.com/", "mobile", {}); + expect(shaped.lcp_ms).toBeNull(); + expect(shaped.perf_score).toBeNull(); + }); +}); + +describe("cwvVerdict", () => { + test("all-good metrics → good", () => { + expect(cwvVerdict({ lcp_ms: 2000, inp_ms: 100, cls: 0.05 })).toBe("good"); + }); + + test("worst metric wins → poor", () => { + expect(cwvVerdict({ lcp_ms: 2000, inp_ms: 100, cls: 0.4 })).toBe("poor"); + }); + + test("mid metric → needs-improvement", () => { + expect(cwvVerdict({ lcp_ms: 3000, inp_ms: 100, cls: 0.05 })).toBe("needs-improvement"); + }); + + test("no metrics present → good (nothing to flag)", () => { + expect(cwvVerdict({ lcp_ms: null, inp_ms: null, cls: null })).toBe("good"); + }); +}); diff --git a/plugins/seo-hermit/tests/skill-structure.test.ts b/plugins/seo-hermit/tests/skill-structure.test.ts new file mode 100644 index 00000000..44e34f82 --- /dev/null +++ b/plugins/seo-hermit/tests/skill-structure.test.ts @@ -0,0 +1,63 @@ +// Structural invariants for SKILL.md files in seo-hermit. +// Run with: bun test tests/skill-structure.test.ts + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseFrontmatter, makeReporter } from './test-utils'; + +const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); + +const SKILLS = [ + { name: 'hatch', gates: 0 }, + { name: 'site-health-check', gates: 0 }, + { name: 'site-regression-triage', gates: 0 }, + { name: 'site-draft-fix', gates: 0 }, +]; + +const { ok, summary } = makeReporter(); + +for (const { name, gates } of SKILLS) { + console.log(`\n${name}/SKILL.md:`); + const file = path.join(SKILL_DIR, name, 'SKILL.md'); + ok('file exists', fs.existsSync(file), file); + if (!fs.existsSync(file)) continue; + + const text = fs.readFileSync(file, 'utf-8'); + const fm = parseFrontmatter(text); + ok('frontmatter parseable', fm !== null); + if (!fm) continue; + + ok('frontmatter has name', !!fm.fields.name, JSON.stringify(fm.fields)); + ok('frontmatter name matches dir', fm.fields.name === name, `${fm.fields.name} vs ${name}`); + ok('frontmatter has description', !!fm.fields.description && fm.fields.description.length > 20); + + const gateMatches = fm.body.match(/^### Gate \d+ —/gm) || []; + ok(`expected ${gates} Gate headers`, gateMatches.length === gates, `found ${gateMatches.length}`); + + if (gates > 0) { + ok('Gate 0 present', /^### Gate 0 —/m.test(fm.body)); + ok(`Gate ${gates - 1} present`, new RegExp(`^### Gate ${gates - 1} —`, 'm').test(fm.body)); + } + + // Internal links: resolve [text](relative/path) and verify the target exists. + const linkRe = /\[[^\]]+\]\(([^)]+)\)/g; + const skillBaseDir = path.dirname(file); + let linkMatch: RegExpExecArray | null; + let linksChecked = 0; + let linksBad = 0; + while ((linkMatch = linkRe.exec(fm.body)) !== null) { + const target = linkMatch[1]; + if (/^(https?:|mailto:|#)/.test(target)) continue; + const cleanTarget = target.split('#')[0]; + if (!cleanTarget) continue; + const resolved = path.resolve(skillBaseDir, cleanTarget); + linksChecked += 1; + if (!fs.existsSync(resolved)) { + linksBad += 1; + console.error(` bad link: ${target} → ${resolved}`); + } + } + ok(`internal links resolve (${linksChecked} checked)`, linksBad === 0, `${linksBad} bad`); +} + +process.exit(summary() === 0 ? 0 : 1); diff --git a/plugins/seo-hermit/tests/test-utils.ts b/plugins/seo-hermit/tests/test-utils.ts new file mode 100644 index 00000000..0d89bd26 --- /dev/null +++ b/plugins/seo-hermit/tests/test-utils.ts @@ -0,0 +1,31 @@ +function parseFrontmatter(text: string) { + const m = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!m) return null; + const fields: Record<string, string> = {}; + for (const line of m[1].split('\n')) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (kv) fields[kv[1]] = kv[2].trim(); + } + return { raw: m[1], fields, body: text.slice(m[0].length) }; +} + +function makeReporter() { + let passed = 0; + let failed = 0; + function ok(name: string, cond: boolean, detail?: string) { + if (cond) { + console.log(` ✓ ${name}`); + passed += 1; + } else { + console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); + failed += 1; + } + } + function summary(): number { + console.log(`\nResults: ${passed} passed, ${failed} failed`); + return failed; + } + return { ok, summary }; +} + +export { parseFrontmatter, makeReporter }; From c2bfa63332efa75585c2978dc295770350004904 Mon Sep 17 00:00:00 2001 From: Gabriel Tavares <gabrieltavaresw@gmail.com> Date: Fri, 3 Jul 2026 10:58:10 +0100 Subject: [PATCH 2/2] fix(seo-hermit): correct ledger diff mis-reports in change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review fixes to the weekly diff engine, all cases where the report lied to the operator rather than crashed: - Broken links outside this run's link-check budget/set are no longer falsely reported as "resolved" — only URLs actually re-checked this run can clear. Prevents a persistent broken link from being silently cleared when the sitemap grows or reorders past the budget window. - CWV and index verdict transitions are classified by direction (shared CWV_RANK ordinal / PASS-in vs PASS-out) instead of equality-to-best, so a poor→needs-improvement recovery is an improvement, not a regression. Directionless index flips (FAIL→NEUTRAL) go to notes and keep the week non-quiet rather than being swallowed. - Link checker retries GET on 403/400 as well as 405/501, so HEAD-hostile CDNs/WAFs don't surface pages that serve fine on GET as broken links. Adds regression tests for each. --- plugins/seo-hermit/scripts/site-api.ts | 38 +++++++++++++++++------ plugins/seo-hermit/tests/ledger.test.ts | 30 ++++++++++++++++++ plugins/seo-hermit/tests/site-api.test.ts | 7 +++++ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/plugins/seo-hermit/scripts/site-api.ts b/plugins/seo-hermit/scripts/site-api.ts index d4477660..0a131a14 100644 --- a/plugins/seo-hermit/scripts/site-api.ts +++ b/plugins/seo-hermit/scripts/site-api.ts @@ -327,7 +327,10 @@ async function checkOneLink(url: string, fetchImpl: FetchImpl): Promise<LinkResu }; try { let res = await attempt("HEAD"); - if (res.status === 405 || res.status === 501) res = await attempt("GET"); + // Some servers/CDNs reject HEAD (405/501) or wall it behind a WAF (403/400) while serving + // GET fine. Re-check with GET so those don't surface as false broken links; a page that is + // genuinely 4xx will return the same on GET and stay broken. + if ([405, 501, 403, 400].includes(res.status)) res = await attempt("GET"); return { url, status: res.status, ok: res.ok, final_url: res.url || url }; } catch { return { url, status: 0, ok: false, final_url: url }; @@ -398,15 +401,18 @@ export async function psiFetch( return shapePsi(url, strategy, (await res.json()) as PsiApiResponse); } +// Ordinal severity of a Core Web Vitals verdict — lower is better. Shared by the verdict +// computation and the ledger transition diff so improvements aren't misread as regressions. +const CWV_RANK: Record<string, number> = { good: 0, "needs-improvement": 1, poor: 2 }; + // Core Web Vitals verdict per Google thresholds; overall = worst metric present. export function cwvVerdict(r: Pick<CwvResult, "lcp_ms" | "inp_ms" | "cls">): "good" | "needs-improvement" | "poor" { - const rank = { good: 0, "needs-improvement": 1, poor: 2 } as const; - const scores: (keyof typeof rank)[] = []; + const scores: ("good" | "needs-improvement" | "poor")[] = []; if (r.lcp_ms != null) scores.push(r.lcp_ms <= 2500 ? "good" : r.lcp_ms > 4000 ? "poor" : "needs-improvement"); if (r.inp_ms != null) scores.push(r.inp_ms <= 200 ? "good" : r.inp_ms > 500 ? "poor" : "needs-improvement"); if (r.cls != null) scores.push(r.cls <= 0.1 ? "good" : r.cls > 0.25 ? "poor" : "needs-improvement"); if (scores.length === 0) return "good"; - return scores.reduce((worst, s) => (rank[s] > rank[worst] ? s : worst), "good"); + return scores.reduce((worst, s) => (CWV_RANK[s] > CWV_RANK[worst] ? s : worst), "good"); } // ---- URL Inspection (budgeted index status) ---- @@ -560,7 +566,9 @@ export function diffLedger(ledger: Ledger, snap: Snapshot, nowIso: string): Ledg // --- links --- const brokenNow = new Set<string>(); + const checkedNow = new Set<string>(); for (const link of snap.links) { + checkedNow.add(link.url); if (link.ok) continue; brokenNow.add(link.url); const existing = ledger.links.broken[link.url]; @@ -572,8 +580,10 @@ export function diffLedger(ledger: Ledger, snap: Snapshot, nowIso: string): Ledg changes.new_broken.push(`${link.url} (${link.status || "unreachable"})`); } } + // Only resolve links we actually re-checked this run — a link outside the link-check + // budget/set was not verified, so it must stay broken rather than be falsely cleared. for (const url of Object.keys(ledger.links.broken)) { - if (!brokenNow.has(url) && snap.links.length > 0) { + if (checkedNow.has(url) && !brokenNow.has(url)) { changes.resolved.push(url); delete ledger.links.broken[url]; } @@ -586,7 +596,10 @@ export function diffLedger(ledger: Ledger, snap: Snapshot, nowIso: string): Ledg const bucket = (ledger.cwv[c.url] ??= { history: [] }); const prev = bucket.history.at(-1); if (prev && prev.verdict !== verdict && !firstRun) { - changes[verdict === "good" ? "improvements" : "regressions"].push( + // ?? 0 ranks an unknown/stale persisted verdict as "good" so a corrupt ledger entry + // can never fabricate a regression. + const improved = (CWV_RANK[verdict] ?? 0) < (CWV_RANK[prev.verdict] ?? 0); + changes[improved ? "improvements" : "regressions"].push( `CWV ${c.url} ${prev.verdict}→${verdict}`, ); } @@ -606,9 +619,13 @@ export function diffLedger(ledger: Ledger, snap: Snapshot, nowIso: string): Ledg for (const ins of snap.index) { const prev = ledger.index[ins.url]; if (prev && prev.verdict !== ins.verdict && !firstRun) { - changes[ins.verdict === "PASS" ? "improvements" : "regressions"].push( - `index ${ins.url} ${prev.verdict}→${ins.verdict}`, - ); + // PASS is the only unambiguous index state: entering it is an improvement, leaving it a + // regression. A flip between two non-PASS states (e.g. FAIL→NEUTRAL) has no clear direction, + // so it goes to notes — still surfaced in the report, just not scored as a regression. + const label = `index ${ins.url} ${prev.verdict}→${ins.verdict}`; + if (ins.verdict === "PASS") changes.improvements.push(label); + else if (prev.verdict === "PASS") changes.regressions.push(label); + else changes.notes.push(label); } ledger.index[ins.url] = { verdict: ins.verdict, @@ -632,7 +649,8 @@ export function diffLedger(ledger: Ledger, snap: Snapshot, nowIso: string): Ledg changes.regressions.length === 0 && changes.improvements.length === 0 && changes.new_broken.length === 0 && - changes.resolved.length === 0; + changes.resolved.length === 0 && + changes.notes.length === 0; } return changes; } diff --git a/plugins/seo-hermit/tests/ledger.test.ts b/plugins/seo-hermit/tests/ledger.test.ts index 6fcedc71..df1c5233 100644 --- a/plugins/seo-hermit/tests/ledger.test.ts +++ b/plugins/seo-hermit/tests/ledger.test.ts @@ -100,6 +100,16 @@ describe("broken-link lifecycle", () => { expect(c.resolved).toContain("https://example.com/dead"); expect(l.links.broken["https://example.com/dead"]).toBeUndefined(); }); + + test("a broken link not re-checked this run is NOT falsely resolved", () => { + const l = seededLedger(); + diffLedger(l, snapshot({ date: "2026-07-06", links: [link("https://example.com/dead", 404)] }), ISO); + + // Next run checks a different slice of the sitemap — /dead is out of budget, never verified. + const c = diffLedger(l, snapshot({ date: "2026-07-13", links: [link("https://example.com/other", 200)] }), ISO); + expect(c.resolved).not.toContain("https://example.com/dead"); + expect(l.links.broken["https://example.com/dead"]).toBeDefined(); + }); }); describe("CWV verdict transition", () => { @@ -109,6 +119,14 @@ describe("CWV verdict transition", () => { const c = diffLedger(l, snapshot({ date: "d2", cwv: [{ url: "https://example.com/", strategy: "mobile", lcp_ms: 5000, inp_ms: 100, cls: 0.05, perf_score: 0.4 }] }), ISO); expect(c.regressions.some((r) => r.includes("CWV"))).toBe(true); }); + + test("poor → needs-improvement is an improvement, not a regression", () => { + const l = seededLedger(); + diffLedger(l, snapshot({ date: "d1", cwv: [{ url: "https://example.com/", strategy: "mobile", lcp_ms: 5000, inp_ms: 100, cls: 0.05, perf_score: 0.3 }] }), ISO); + const c = diffLedger(l, snapshot({ date: "d2", cwv: [{ url: "https://example.com/", strategy: "mobile", lcp_ms: 3000, inp_ms: 100, cls: 0.05, perf_score: 0.6 }] }), ISO); + expect(c.improvements.some((r) => r.includes("CWV"))).toBe(true); + expect(c.regressions).toHaveLength(0); + }); }); describe("index verdict flip", () => { @@ -119,6 +137,18 @@ describe("index verdict flip", () => { const c = diffLedger(l, snapshot({ index: [indexEntry("https://example.com/u", "FAIL")], sitemap_count: 5 }), ISO); expect(c.regressions.some((r) => r.includes("index"))).toBe(true); }); + + test("a flip between two non-PASS verdicts is neither a regression nor an improvement", () => { + const l = seededLedger(); + l.search.history.push({ week_end: "w0", clicks: 1, impressions: 1, ctr: 1, position: 1 }); + diffLedger(l, snapshot({ index: [indexEntry("https://example.com/u", "FAIL")], sitemap_count: 5 }), ISO); + const c = diffLedger(l, snapshot({ index: [indexEntry("https://example.com/u", "NEUTRAL")], sitemap_count: 5 }), ISO); + expect(c.regressions.some((r) => r.includes("index"))).toBe(false); + expect(c.improvements.some((r) => r.includes("index"))).toBe(false); + // Directionless, but still a real change — surfaced as a note, so the week isn't quiet. + expect(c.notes.some((r) => r.includes("index"))).toBe(true); + expect(c.quiet).toBe(false); + }); }); describe("trimming and cursor", () => { diff --git a/plugins/seo-hermit/tests/site-api.test.ts b/plugins/seo-hermit/tests/site-api.test.ts index 4ea400e2..02bc78aa 100644 --- a/plugins/seo-hermit/tests/site-api.test.ts +++ b/plugins/seo-hermit/tests/site-api.test.ts @@ -293,6 +293,7 @@ describe("link check", () => { const method = init?.method ?? "GET"; if (url.endsWith("/dead")) return new Response("", { status: 404 }); if (url.endsWith("/head405")) return new Response("", { status: method === "HEAD" ? 405 : 200 }); + if (url.endsWith("/head403")) return new Response("", { status: method === "HEAD" ? 403 : 200 }); if (url.endsWith("/boom")) throw new Error("network down"); return new Response("", { status: 200 }); }) as unknown as FetchImpl; @@ -313,6 +314,12 @@ describe("link check", () => { expect(byUrl["https://x/boom"].status).toBe(0); }); + test("a HEAD-hostile 403 is re-checked with GET before being called broken", async () => { + const results = await linkCheck(["https://x/head403"], 200, 4, linkFetch); + expect(results[0].ok).toBe(true); // GET fallback returned 200 + expect(results[0].status).toBe(200); + }); + test("respects the budget cap", async () => { const results = await linkCheck(["https://x/1", "https://x/2", "https://x/3"], 2, 4, linkFetch); expect(results).toHaveLength(2);