# SEO Ops

> Structural checklist and checker script for SEO foundation engineering. Use when reviewing or accepting a site/page for SEO/GEO structural compliance (robots, sitemap, canonical, 301 canonicalization, JSON-LD, OG, hreflang, CWV, AI crawler access, rendering strategy), when running the machine checks to produce a report, or when judging whether a frontend change will affect indexing and citation. Also for answering "what exactly does this check require" and "why does it exist".

- Skill: `tigerless-labs/seo-ops` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add tigerless-labs/seo-ops`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tigerless-labs/seo-ops/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: tigerless-labs (https://skillmd.com/u/tigerless-labs)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/tigerless-labs/seo-ops

---


# seo-ops · SEO foundation engineering

Review a site's templates and output against the **C set** (30 structural checks), or run the checker to produce an acceptance report.

## How to work

When this skill triggers, introduce it in **one sentence** — "30 structural checks (the C set): can crawlers
fetch, read, index, and cite this site" — then immediately ask **one question** (as selectable options if your
environment has an option/question UI):

> **What should I check?**
> 1. **A URL** — live domain or local deployment (`http://localhost:<port>`), both fine; produces a report
> 2. **The code** — review templates/pages in this repo against the checklist, no script

That one answer decides the path; don't ask anything else before starting — **no config is needed**, the checker
runs with defaults on a bare `--target`. Never bring configuration up on your own; it only becomes relevant if
the user asks for the extras (CrUX field data, IndexNow, multi-site roster — see "**Configuration**").

| The user gave | Path | Section to follow |
|---|---|---|
| a domain / URL (live or localhost) | run the checker, produce a report | "**Run the checker**" → then "**Read the report**" |
| code, templates, a PR, page files | no script; review the machine-readable output surface against the C set item by item | "**Scope**" sets the boundary; `references/checklist/checklist.md` lists the items — but judge each one by its canonical doc `references/checklist/references/C<N>.md`, not by the index row alone |

Once intent is confirmed, **do not keep asking for permission**. Everything else:
the user asks why a check exists / how to fix it → "**After the report**";
dependencies won't install → "**Environment**".

Both paths **treat [references/checklist/checklist.md](references/checklist/checklist.md) as authoritative** —
never judge from memory; if unsure about an item, read `references/checklist/references/C<N>.md`.
Cite items by ID (`C12`); IDs are permanent — never renumber or reorder.

### After the report

**The report itself is the conclusion — don't walk through it item by item unprompted.** Each row carries a **Docs** column: the GitHub link to that check's canonical doc
— that link is for **the person reading the report** (it opens wherever the report is sent). **What you yourself read is the local copy**:
`references/checklist/references/C<N>.md`.

**When the user asks about a check, read `references/checklist/references/C<N>.md` before answering**
(for C12, read `references/checklist/references/C12.md`): "why it's a problem" comes from `## Introduction`,
"how to fix it" from `## Implementation guidance`. **Never answer by inferring from the evidence** — evidence only says which pages failed;
it can't tell you why the check exists, what the correct fix is, or where the authoritative reference lives.

### Environment

```bash
python3 -c "import requests, yaml" || pip install -r scripts/requirements.txt
```

On `externally-managed-environment` (PEP 668, common on Debian/Ubuntu), use the system packages
`apt install python3-requests python3-yaml` or create a venv — **do not add `--break-system-packages`**;
that gambles the system Python for a two-line dependency.

## Scope

**SEO foundation engineering** = the machine-readable structure a site must have so that search engines and AI retrieval can **fetch, read, index, and cite** it.

**What it checks**: the machine-readable surface of the final HTTP/HTML output — regardless of who produced it (backend templates, frontend code,
third-party scripts); if a crawler can see it, it's covered. The boundary is "machine-readable surface / human-perceivable surface", not frontend/backend.

**What it doesn't check**: styling, interaction, UX, code quality; content truthfulness and quality; ranking and traffic; process.
**All green ≠ fully compliant.**

**The human gate is a separate set**: the R1-R8 prohibitions (buying links, cloaking, fake structured data, PII in prompts, etc.) live in
[redlines.md](redlines.md); the machine doesn't check them. Read them before touching content strategy, links, or structured data.

## What's here

| Path | What it is |
|---|---|
| [references/checklist/checklist.md](references/checklist/checklist.md) | The **C set** index: 30 checks, split into site-level / per indexed page / conditional |
| `references/checklist/references/C<N>.md` | Each C check's canonical doc: `## Introduction` (why) + `## Implementation guidance` (how to fix) + authoritative references |
| `references/content/` | The **T set**: for tracing a red C back to "who owes the input"; outside this skill's duties |
| `scripts/run.py` · `scripts/config.py` | Check logic / check parameters |
| `references/ai-crawlers.yaml` | The AI crawler UA roster for C1, read at runtime |
| `references/{sites,config}.example.yaml` · `references/.env.example` | Three config templates, see "**Configuration**" |

`<N>` maps one-to-one to the IDs in the checklist; C12's canonical doc is `references/checklist/references/C12.md`.
Gaps in the numbering are normal (retired IDs are never recycled).

**The skill directory is read-only — zero writes.** Updates overwrite the whole package; anything written inside will be lost.

## Run the checker

**Confirm the target is an origin → run.** No config file is required and none should be asked about —
defaults cover everything; the optional files exist for users who come asking for more (see "Configuration").

**One "site" = one origin** (scheme + host[:port], no path/query). Subdomains count as separate sites
(`blog.` / `docs.` are each one); the apex domain and www don't count as two — they should 301 to a single canonical host,
which is exactly what C3 checks, so only the one you pick counts.

### Single site: no config file needed

```bash
python3 scripts/run.py --target https://example.com      # live site
python3 scripts/run.py --target http://localhost:3000    # local deployment, auto-detected as local mode
```

`--target` must be an origin; a URL with path/query exits with an error immediately — no guessing.

**Public staging domains are not supported** — they'd be tested as production domains, and the canonicalization check (C3) would false-red for sure.
To test before launch, pick one: run a local deployment at `http://localhost:<port>`, or wait and run against the production domain after launch.

### Multi-site: run a batch in one go

"Multi-site" = you have several domains to accept at once (e.g. `example.com` + `example.org` + `blog.example.com`),
don't want to retype `--target` every time, and want each site to carry its own rendering strategy and must-test pages. After creating `sites.yaml` (see "Configuration"):

```bash
python3 scripts/run.py               # run every site in the roster
python3 scripts/run.py --site <id>   # run just one
```

Each entry takes: `id` (used for the report filename and the `checks` table's site column), `production` (origin, required),
`rendering` (ssr/ssg/isr; C15 branches on it), `sitemap` (defaults to `<production>/sitemap.xml`),
`samples` (must-test pages fetched in addition to the sitemap; mark `ymyl: true` to trigger the C21 human review).
If the roster can't be found, the script exits and prints where to create it — it never silently runs empty.

### Common flags

One-off overrides: `--page-sample N` `--sitemap-sample N` `--max-pages N` `--sleep S` `--workers N`.
Output overrides: `--state-dir <path>` / `--out <path>`.
`--verify-only`: run only the two drift guards, no network; exits 1 on drift (for CI).

Runtime: at the default single worker with a 1s interval, roughly `2 × sitemap entries` requests. A 271-page site takes about 7 minutes
(**tell the user roughly how long the wait is before running**); for speed, sample with `--page-sample 100` —
structural problems are template-level, so a sample and a full run see the same set.

## Read the report

Each run writes two outputs to `~/Documents/seo-ops/out/` (`--state-dir` / `--out` / `$SEO_OPS_DIR` to change):

| Output | For | What it is |
|---|---|---|
| `report-<site>-<date>.md` | humans | mirrors the checklist one-to-one; the same 30 rows every run |
| `checks.db` | machines | SQLite, `checks(site, url, rule_id, status, evidence, checked_at)`, primary key `(site,url,rule_id)`; accumulates across runs for diffing |

| Result | Meaning |
|---|---|
| ✅ pass | tested, passed |
| 🔴 fail | tested, failed — the Evidence column shows the violating pages and why |
| ⚪ N.A. | **not tested**; the reason code is in parentheses. **"Not tested" is not "fine"** |
| 👤 human review | the script doesn't judge; listed to remind a human to go through it |

Common N.A. reason codes: `need-domain` (local mode), `need-crux-key` / `need-crux-data` (C4),
`need-key-declaration` (C5), `need-declaration` (no rendering strategy declared), `no-pages`,
`crawl-capped` (crawl hit the cap), `throttled` (throttled by the target; rerun slower).

**Check the warning lines at the top of the report first** — if any are present, this run's verdict is incomplete:

- 🚦 **throttled by the target** (429/503) — we hit it too fast; the site is not at fault. Affected verdicts are recorded as N.A.,
  not fail (**a false red is more dangerous than a slow run**). For a complete verdict: halve `--workers` or raise `--sleep` and rerun.
- ⚠️ **sample fetches failed** (not throttling) — genuinely unreachable; these pages are excluded from the denominator.

Priorities: **P0 = existence/incident layer** (can't be fetched or indexed); **P1 = performance layer** (ranking and citation are discounted); **P2 = optimizations**.

## Configuration

**None of the three files is required.** They live in `~/.config/seo-ops/` (`$SEO_OPS_CONFIG_DIR` to change);
just copy from the templates — **do not edit `scripts/config.py`**; it's the skill body and gets overwritten wholesale on update.

```bash
mkdir -p ~/.config/seo-ops
```

| File | When you need it | Cost of not creating it | How to create |
|---|---|---|---|
| `sites.yaml` | multi-site, or to declare a rendering strategy / mark YMYL pages | single-site runs fine with `--target`; C15 records `need-declaration`, C21 has no human-review list | `cp references/sites.example.yaml ~/.config/seo-ops/sites.yaml` |
| `.env` | **only when the user wants a "complete verdict"** (CrUX / IndexNow key) | **not required**; C4 records `need-crux-key`, C5 records `need-key-declaration`, everything else runs | `cp references/.env.example ~/.config/seo-ops/.env` |
| `config.yaml` | only to change thresholds | none; all defaults apply | `cp references/config.example.yaml ~/.config/seo-ops/config.yaml` |

`.env` is a hidden file; `ls` without `-a` makes the directory look empty.
**Exported environment variables always beat `.env`** — a key injected by CI can't be overridden by someone's local file.
Legacy locations (`<state-dir>/sites.yaml`, `<state-dir>/.env`, `scripts/.env`) are still read,
but whenever a value is actually taken from one, a warning prompts you to move it — a silently misplaced config makes the same command give different verdicts.

### What config.yaml can tune

Every line in the template is the **current default**; keeping it as-is equals not configuring it. Change only the lines you need. Commonly tuned:

| Parameter | Effect |
|---|---|
| `FETCH_SLEEP` / `FETCH_CONCURRENCY` | fetch throttling; overall QPS ≈ workers / interval. Defaults 1 / 1 |
| `PAGE_SAMPLE_SIZE` | page-level check coverage: `0` = full, `>0` = sample cap |
| `CRAWL_MAX_PAGES` | internal crawl cap (C6) |
| `TITLE_MAX_CHARS` / `DESC_MAX_CHARS` | C11 length limits |
| `THROTTLE_*` | throttle backoff and recovery |
| `DOC_BASE_URL` | base URL for the report's Docs column links; switch it to a commit SHA to pin a version |

**Unregistered keys and type mismatches exit with an error**, never silently ignored — a typo'd key producing a report on defaults is more dangerous than an error.

**Three categories can't be tuned here**: **secrets** (live in `.env`); **constants** (the three CWV thresholds are Google's official "good" lines,
the sitemap 50k cap is a protocol hard limit, the viewport token is the spec value — change them and it's no longer the same check);
**structural judgments** (`TYPE_REQUIRED`, `LD_REJECTED_TYPES`, `BODY_HIDE_PATTERNS` —
changing them changes the check logic; that belongs in a human-reviewed PR, not hidden in someone's local yaml).

