seo-agent
Daily SEO audit-and-fix loop for a website you own, driven by one config file per site. Crawls the live site with free stdlib Python scripts (technical crawl, indexability, internal-link graph with da
Files15
View on GitHubSKILL.mdskills/web/seo-agent/SKILL.md
GitHubSEO Agent
One engine, one config per site. Every run: measure, diff against yesterday, fix the few things that matter through PRs, and write down what moved.
Requirements
- Claude Code with Bash. Python 3 (scripts are stdlib only, except
gsc.py:pip install google-auth). - Optional: Chrome/Chromium (for
render_check.py),npm i -g lighthouse(forcwv.py),ghCLI (PRs and preview-host discovery). - A site config at
sites/<slug>.md. Copysites/example.mdand fill the placeholders. - GSC is optional but turns guesses into measurements. Auth is yours:
$GSC_SERVICE_ACCOUNT_JSON(service account added as a Restricted user on your property) orgcloud auth application-default loginwith thewebmasters.readonlyscope.
All commands below run from this skill folder (S=scripts). Output goes to
~/.claude/seo-reports/ (reports) and ~/.claude/seo-state/ (snapshots).
Modes
| mode | trigger | does |
|---|---|---|
| daily (default) | /seo-agent <slug> |
steps 1-6, lean and diff-driven |
| technical | /seo-agent <slug> technical, "deep audit", weekly, or after any routing/middleware/sitemap/layout change |
full checklist in references/technical-seo.md |
| audit-only | the user says read-only | steps 1-4 and 6; every fix becomes a proposal with an owner; no branches, PRs or CMS writes |
Steps
1. Load the config
Read sites/<slug>.md first. If it does not exist, stop and offer to create it from
sites/example.md (ask for domain, repo, build command, GSC property). Never redo
anything under known_wins.
2. Measure (free, no paid APIs)
Run in this order; each writes machine-readable output the report uses.
python3 $S/linkgraph.py <domain> # link graph + orphans, snapshot, diff vs last run
python3 $S/crawl.py --sitemap <domain> --sample 30 # per-page title/meta/canonical/H1/words/schema
python3 $S/validate_schema.py <money page URLs> # JSON-LD parses, @types, canonical mismatch
python3 $S/psi.py <5-10 top pages> # mobile LCP/CLS/INP + lab TBT (PSI_KEY optional)
bash -c "python3 $S/techaudit.py <domain> --out <dir> --max 3000 --thin 250 --depth-limit 4 \
--preview-from-gh <owner/repo> $PROBES" # when linkgraph shows new 4xx/orphans/lost schema
Use bash -c for anything with $PROBES (--probe /api/ --probe /admin ...): zsh does not word-split it and the probes silently vanish.
GSC (if configured): preflight under a 60 s cap, then snapshot every run.
perl -e 'alarm 60; exec @ARGV' python3 $S/gsc.py <property> preflight
python3 $S/gsc.py <property> snapshot # 28d pages+queries, movers vs last snapshot
python3 $S/gsc.py <property> searchanalytics --days 28 --dim query --top 200
Check robots.txt by hand if techaudit did not run: GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended, CCBot must not be blocked unless that is a deliberate policy.
3. Research
- With GSC: queries at average position 11-30 with real impressions are the cheapest page-1 pushes. Start there.
- Without GSC: WebSearch the config's
prioritiesterms, note who ranks and which SERPs are weak. - Prefer long-tail problem language (what buyers type before they know the category) over head terms in
avoid.
4. Prioritise
Build one table: issue | impact H/M/L | effort S/M/L | evidence (URL + observed value) | action.
Rank in this order:
- Linkgraph regressions (inlink drops, lost schema, new orphans, title drift). Highest confidence, usually small.
- Anything blocking indexing: noindex/canonical conflicts, 4xx/5xx in sitemap, robots blocks, preview hosts indexable.
- GSC page-1 pushes: position 10-20 with impressions in the top quartile for the site.
- Thin money pages (<600 words), missing/invalid schema, title 25-65 chars and description 70-165 chars violations.
- CWV failures at p75: LCP >2.5 s, CLS >0.1, INP >200 ms (field data only; lab is diagnosis).
Defer low-impact/high-effort work and anything needing a business call; list it under "needs a human".
5. Ship fixes (review gate)
Code (only if repo_path is set):
- Branch
fix/seo-<topic>offdefault_branch. One concern per PR. - Run the config's
build; never push a failing build. gh pr createwith: what changed, the evidence row it fixes, build result, what to watch in GSC.- Never push to the default branch, never merge, never touch design/CSS/components unless asked.
CMS (dispatch on cms.driver; token from the env var named in token_env):
- Patch scalar fields only: title, meta description, excerpt. Do not rewrite rich-text bodies unless asked.
- Hard gate before every patch:
python3 $S/lint_brand.py "<new text>" <each brand_voice.forbidden item>. Exit 1 means rewrite and lint again. driver: noneor token missing: write the change as a proposal in the report instead.
Optional: after shipping changed URLs, python3 $S/indexnow.py <domain> <key> <urls> (Bing and others; key file must be served at /<key>.txt).
6. Report
Write ~/.claude/seo-reports/<slug>-<YYYY-MM-DD>.md (format below). Every 7th run
also write <slug>-week-<YYYY-Www>.md: what shipped vs what moved in GSC.
Lean rule: if the linkgraph diff is empty, GSC shows no material movement and no priority changed, write a 3-line "no material changes" report and stop. Do not manufacture work.
Decision rules
- Numbers in reports: same source, same window length, before/after pairs via
--start/--endaround the change date. Never compare a UI export to an API pull or a 7-day window to a 28-day one. - Lab vs field CWV never share one claim.
- FAQ rich results only show for authoritative government/health sites; FAQ markup elsewhere is fine for parsers but promises no SERP feature.
- Keyword overlap is not cannibalisation until GSC query-by-page shows two URLs swapping for the same query.
- A preview host (
*.vercel.app,*.netlify.app) is yours only after you check its title; look-alike names belong to strangers.
Failure handling
| problem | do this |
|---|---|
| GSC preflight fails or hangs | record the exact error, use the newest ~/.claude/seo-state/<host>/gsc-*.json labelled "last-known, window X", continue; mark GSC rows "not measured today" |
| no GSC configured | WebSearch site: checks; label every number "inferred, not measured" |
| linkgraph refuses to save (>5% of URLs failed) | network blip: rerun once; if it repeats, report the failing URLs; use --force only if the failures are real 4xx |
| sitemap missing/invalid | that is the top critical finding; crawl from / via techaudit.py instead |
| Lighthouse/Chrome missing | use psi.py (Google-hosted) and say lab data came from PSI |
cwv.py slow (~90 s/URL) |
run it with nohup ... & and poll its log; long silent foreground commands can time out |
| build fails on a fix branch | fix it or drop the PR; never push red |
Report format
# SEO daily: example.com, 2026-03-04
Crawl: 412 URLs, 398 indexable, 0 5xx. Linkgraph: 2 regressions (below).
| issue | impact | effort | evidence | action |
|---|---|---|---|---|
| /pricing lost 6 inlinks | H | S | footer nav change, 9 -> 3 inlinks | PR #41 restores footer link |
| /blog/x missing Article schema | M | S | validate_schema: 0 JSON-LD | PR #42 |
Shipped: PR #41, PR #42 (build green). CMS: 1 title patch, lint_brand PASS.
GSC 28d vs last: /guide/y pos 14.2 -> 11.8, impressions 1,920 -> 2,410 (same window length).
Needs a human: merge PRs; decide whether /old-landing should 301 to /pricing.
Carry forward: page-1 push for "<query>"; thin /features page (310 words).
Deep technical audits use the severity/evidence/fix/effort/owner contract and report
skeleton in references/technical-seo.md.
Running it daily
In-session: /loop 24h /seo-agent <slug> (only fires while Claude Code is open).
Unattended: scripts/daily.sh <slug> from cron on a server; see references/deploy.md.