Technical SEO Audit skill for Codex
Find what stops Google indexing your pages. Codex audits per template instead of per warning: it compares raw and rendered HTML, reconciles meta robots, X-Robots-Tag, robots.txt and canonicals, and runs a bundled standard-library crawler to collect status codes and redirect hops. Findings come back ranked by pages affected, each with a curl command or report row as evidence.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- technical-seo-audit Installs to ~/.agents/skills/technical-seo-audit/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install technical-seo-audit The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project technical-seo-audit Installs to .agents/skills/technical-seo-audit/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/technical-seo-audit.zipunzip -o technical-seo-audit.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) technical-seo-audit PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $technical-seo-audit, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Audits a site's crawlability and indexation end to end — status codes, redirect chains, canonicals, noindex and X-Robots-Tag, robots.txt, XML sitemaps, rendering, duplicate URLs and Core Web Vitals — and returns a prioritized fix list tied to evidence. Use when pages are not indexed, traffic dropped after a release, Search Console shows "Crawled - currently not indexed" or canonical conflicts, or before and after a redesign, framework change or launch.
Use it when
- Pages are missing from Google or the Page indexing report is getting worse
- A release, CMS change or redesign may have broken crawlability
- You need a prioritized technical backlog before a launch
- Canonical, redirect or noindex signals look inconsistent
Use something else when
- You are moving domains or restructuring URLs and need a redirect map: use seo-migrations
- The problem is slow pages rather than indexing: use core-web-vitals
- You only need to fix sitemap or robots.txt files: use sitemaps-robots
How to check the result
Re-run the crawler until it reports zero error-level issues, then confirm the fixed templates in Search Console URL Inspection with a live test.
The mistake it prevents
Disallowing a URL in robots.txt to remove it from the index, which hides the noindex tag from Google and can leave the URL indexed without content.
A good audit ends with a short list of fixes ranked by how many indexable, valuable URLs each one affects, every finding backed by a URL, a status code, a header or a report row. A long list of tool warnings with no ranking is not an audit.
Ground rules
- Audit what Googlebot receives, not what the browser shows after login or cookies.
Fetch raw HTML with
curl, compare with rendered HTML (URL Inspection in Search Console, or a headless browser), and note the difference. - Never crawl a site the user does not own or have permission to test. Keep crawl rate polite (default 0.5 s between requests) and respect robots.txt unless auditing your own staging environment.
- Crawling needs network access. In a sandbox with network off, ask for approval or work from exports the user provides (crawler CSV, Search Console exports, server logs).
- Every finding gets a severity from the table below. Cosmetic issues on ten URLs never outrank an indexation blocker on ten thousand.
Severity model
| Severity | Meaning | Examples |
|---|---|---|
| Critical | Valuable pages cannot be crawled or indexed | Sitewide noindex, Disallow: /, canonicals all pointing at the homepage, 5xx on templates, JS-only content that fails to render |
| High | Signals conflict or crawl is wasted at scale | Canonical to a redirect/404, sitemap full of non-200 URLs, faceted URLs crawlable without limit, redirect chains on internal links |
| Medium | Weak but working | Missing self-canonicals, duplicate titles on a template, orphan pages, deep click depth |
| Low | Hygiene | Missing meta descriptions, single redirect hops in old content |
Workflow
- [ ] 1. Scope: domains, protocols, subdomains, key templates, recent changes
- [ ] 2. Collect evidence: crawl, Search Console, robots.txt, sitemaps, logs
- [ ] 3. Check indexation controls per template
- [ ] 4. Check URL canonicalization and duplicates
- [ ] 5. Check redirects and status codes
- [ ] 6. Check rendering and content parity
- [ ] 7. Check page experience (CWV, mobile, HTTPS)
- [ ] 8. Rank findings and write the report
1. Scope
- Read
AGENTS.mdand the repo’s routing/config to learn the framework, rendering mode (SSR, SSG, CSR) and where headers, redirects and meta tags are set. - List templates (home, category, product/article, search, tag, pagination, user profile). Pick 3–5 sample URLs per template; most technical issues are per-template.
- Ask what changed recently: deploys, CMS migrations, domain or protocol moves. A traffic drop dated to a deploy is usually a template-level regression.
2. Collect evidence
Run the bundled crawler against the site (network required):
python3 scripts/crawl_audit.py https://www.example.com/ --max-pages 1000 --out crawl.csv
It follows internal links on the start host, records each URL’s status, redirect hops, canonical, meta robots and X-Robots-Tag, title, H1 count, click depth and inlinks, and prints grouped issues. Exit code 1 means error-level findings (broken targets, canonicals to non-200 URLs, multiple canonicals). For large sites, use it on a sample per template and ask for a full crawler export (Screaming Frog, Sitebulb) instead.
Spot-check individual URLs:
curl -sI https://www.example.com/page | grep -iE '^(HTTP|location|x-robots-tag|link|content-type)'
curl -sL -o /dev/null -w '%{http_code} %{num_redirects} %{url_effective}\n' http://example.com/page
curl -s https://www.example.com/page | grep -ioE '<(link[^>]+canonical|meta[^>]+robots)[^>]*>'
curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' -I https://www.example.com/
Also gather: robots.txt, every sitemap (start from robots.txt Sitemap: lines),
Search Console Page indexing report, Crawl stats (Settings), and server logs filtered to
verified Googlebot if available. Hand Search Console analysis to google-search-console
and sitemap/robots detail to sitemaps-robots when they need depth.
3. Indexation controls
For each template, record the effective directive from all sources:
| Source | Check |
|---|---|
<meta name="robots"> / googlebot | noindex, nofollow, none, max-snippet |
X-Robots-Tag header | Often set by CDN or framework for PDFs, previews, staging |
| robots.txt | A disallowed URL cannot be crawled, so its noindex is never seen |
| Rendered vs raw HTML | JS that adds or removes noindex after load is unreliable |
Rules:
noindexplusDisallowon the same URL is a contradiction: Google may index the URL without content from external links. To remove a URL, allow crawling and servenoindex(or 404/410) until it drops out, then optionally disallow.- Staging
noindexleaking to production is the most common critical finding after a release. Grep the codebase for it:
grep -rniE "noindex|x-robots-tag" --exclude-dir=node_modules --exclude-dir=.next \
--include='*.ts' --include='*.tsx' --include='*.js' --include='*.mjs' --include='*.json' \
--include='*.yml' --include='*.yaml' --include='*.toml' --include='*.conf' --include='*.php' .
grep -rnE '^\s*Disallow:\s*/\s*$' --include='robots*' . # a bare "Disallow: /" blocks everything
4. Canonicalization and duplicates
- Each indexable page should carry one absolute self-referencing
rel=canonicalthat returns 200 and is not itself redirected or noindexed. Multiple canonical tags cause Google to ignore all of them. - Pick one host and protocol (
https://www.orhttps://) and 301 every variant to it. Test all four combinations of http/https and www/non-www. - Trailing slash, uppercase paths,
index.html, tracking parameters (utm_*,gclid) and sort/filter parameters create duplicates. Canonicalize parameters that do not change the main content; let parameters that do (pagination, real filters with demand) self-canonicalize. - Paginated series: each page self-canonicalizes. Never canonicalize page 2+ to page 1;
Google no longer uses
rel=prev/nextas an indexing signal. - Canonical is a hint. If Search Console says “Duplicate, Google chose different canonical than user”, the internal links, sitemaps, redirects and hreflang disagree with the tag. Align all signals rather than repeating the tag louder.
5. Redirects and status codes
| Situation | Correct response |
|---|---|
| Permanent move | 301 or 308, one hop, to the final 200 URL |
| Temporary (A/B, maintenance) | 302/307; 503 with Retry-After for planned downtime |
| Removed, no replacement | 404 or 410 (410 drops slightly faster) |
| Removed, close replacement exists | 301 to the closest equivalent, not the homepage |
| “Not found” page returning 200 | Soft 404: fix to return 404 |
- Google follows up to 10 hops but chains waste crawl and dilute reliability. Flatten to one hop and update internal links to point at final URLs.
- Meta refresh and JS redirects work but are weaker and slower. Prefer server redirects.
- Site moves and bulk URL changes belong to
seo-migrations.
6. Rendering and content parity
- Compare
curloutput with the rendered DOM. Primary content, links (<a href>, notonclick), titles, canonicals and structured data should be in the server HTML for important templates. Client-only rendering is indexable but delayed and fragile. - Blocked resources: if robots.txt disallows JS/CSS/API paths the page needs to render, Googlebot renders a broken page. Check in URL Inspection’s rendered screenshot.
- Infinite scroll and “load more” must have paginated URLs with real links.
- Mobile and desktop must serve the same content and directives (mobile-first indexing).
7. Page experience
- Core Web Vitals at the 75th percentile of real users (CrUX or Search Console report):
LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1. INP replaced FID in March 2024; ignore FID-era
advice. Diagnosis and fixes belong to
core-web-vitals. - HTTPS everywhere, no mixed content, HSTS once all subdomains are HTTPS.
- No intrusive interstitials covering content on mobile load.
8. Rank and report
Rank by (pages affected × page value) and effort. Fix order is almost always: indexation blockers → canonical/redirect conflicts → crawl waste → rendering → hygiene.
Gotchas
curlwithout-Amay get a different response than Googlebot if the CDN or bot protection varies by user agent. Test both; verify real Googlebot by reverse DNS, not by the UA string.- A 200 response on
/robots.txtthat is actually an HTML error page means Google treats it as an empty file (crawl everything). A 5xx on robots.txt can pause crawling. - Hreflang, canonical and sitemap must all reference the same canonical URLs; one disagreeing signal is enough for Google to pick its own canonical.
- Search Console data lags 2–3 days and the Page indexing report updates slower. Do not declare a fix failed after one day.
- “Discovered - currently not indexed” on a large share of URLs is usually quality or crawl-budget driven, not a tag problem. Check internal linking and thin templates.
Verification
- Re-run
scripts/crawl_audit.pyafter fixes; error-level count must be 0 and the redirect/canonical counts should drop to the expected baseline. - For each fixed template, run URL Inspection → Test live URL and confirm “URL is available to Google”, the user-declared canonical, and correct rendered HTML.
- Resubmit affected sitemaps and use Validate fix in the Page indexing report; track the validation over the following 2–4 weeks.
Output template
# Technical SEO audit: example.com (YYYY-MM-DD)
Scope: https://www.example.com, 1,000 URLs crawled, templates: home, category, product, blog.
## Critical
1. **Product pages send `X-Robots-Tag: noindex`** (≈12,400 URLs)
Evidence: `curl -sI /p/blue-widget` → `x-robots-tag: noindex`; set in `next.config.js:42`.
Fix: limit the header to preview deployments. Effort: S.
## High
2. **Canonicals point to redirected URLs** (318 URLs) ...
## Medium / Low
...
## Verified healthy
robots.txt, HTTPS redirects, sitemap index (all 200, all canonical).
## Next checks
Validate fix in Search Console after deploy; re-crawl in 7 days. What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Technical SEO Audit"
short_description: "Find what stops Google indexing your pages"
default_prompt: "$technical-seo-audit Our organic traffic halved after Tuesday's deploy, audit the site for indexing problems" scripts/crawl_audit.py
#!/usr/bin/env python3
"""Same-host crawler for technical SEO audits. Standard library only.
Usage:
python3 scripts/crawl_audit.py https://example.com/ [--max-pages 500] [--delay 0.5]
[--out crawl.csv] [--user-agent UA] [--ignore-robots]
Records status codes, redirect chains, canonicals, meta robots / X-Robots-Tag,
titles, H1 counts and internal inlinks, then prints a grouped issue summary.
Exit code: 0 = no error-level issues, 1 = error-level issues found, 2 = could not start.
"""
from __future__ import annotations
import argparse
import csv
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import urllib.robotparser
from collections import defaultdict, deque
from html.parser import HTMLParser
DEFAULT_UA = "Mozilla/5.0 (compatible; seo-crawl-audit/1.0)"
MAX_HOPS = 10
MAX_BODY = 5 * 1024 * 1024
class PageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title = ""
self._in_title = False
self.meta_robots: list[str] = []
self.meta_description = None
self.canonicals: list[str] = []
self.links: list[str] = []
self.h1 = 0
self.base = None
def handle_starttag(self, tag, attrs):
a = {k.lower(): (v or "") for k, v in attrs}
if tag == "title":
self._in_title = True
elif tag == "meta":
name = a.get("name", "").lower()
if name in ("robots", "googlebot"):
self.meta_robots.append(a.get("content", "").lower())
elif name == "description":
self.meta_description = a.get("content", "")
elif tag == "link" and "canonical" in a.get("rel", "").lower().split():
self.canonicals.append(a.get("href", "").strip())
elif tag == "a" and a.get("href"):
rel = a.get("rel", "").lower().split()
if "nofollow" not in rel:
self.links.append(a["href"].strip())
elif tag == "h1":
self.h1 += 1
elif tag == "base" and a.get("href"):
self.base = a["href"]
def handle_endtag(self, tag):
if tag == "title":
self._in_title = False
def handle_data(self, data):
if self._in_title:
self.title += data
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def normalize(url: str, base: str) -> str | None:
joined = urllib.parse.urljoin(base, url)
parts = urllib.parse.urlsplit(joined)
if parts.scheme not in ("http", "https"):
return None
path = parts.path or "/"
return urllib.parse.urlunsplit((parts.scheme, parts.netloc.lower(), path, parts.query, ""))
class Crawler:
def __init__(self, start: str, ua: str, delay: float, max_pages: int, ignore_robots: bool):
self.start = start
self.host = urllib.parse.urlsplit(start).netloc.lower()
self.scheme = urllib.parse.urlsplit(start).scheme
self.ua = ua
self.delay = delay
self.max_pages = max_pages
self.opener = urllib.request.build_opener(NoRedirect())
self.resolved: dict[str, dict] = {}
self.pages: dict[str, dict] = {}
self.inlinks: dict[str, set[str]] = defaultdict(set)
self.depth: dict[str, int] = {}
self.blocked: set[str] = set()
self.http_links: set[tuple[str, str]] = set()
self.robots = None
if not ignore_robots:
rp = urllib.robotparser.RobotFileParser()
rp.set_url(f"{self.scheme}://{self.host}/robots.txt")
try:
rp.read()
self.robots = rp
except Exception as exc: # robots.txt unreachable: note it, crawl anyway
print(f"note: could not read robots.txt ({exc}); crawling without it", file=sys.stderr)
def fetch(self, url: str):
req = urllib.request.Request(url, headers={"User-Agent": self.ua, "Accept": "text/html,*/*"})
try:
resp = self.opener.open(req, timeout=20)
status, headers = resp.status, resp.headers
except urllib.error.HTTPError as err:
resp, status, headers = err, err.code, err.headers
except Exception as exc:
return 0, {}, None, str(exc)
body = None
ctype = (headers.get("Content-Type") or "").lower()
if status == 200 and "html" in ctype:
raw = resp.read(MAX_BODY)
charset = headers.get_content_charset() or "utf-8"
body = raw.decode(charset, errors="replace")
resp.close()
if self.delay:
time.sleep(self.delay)
return status, headers, body, None
def resolve(self, url: str) -> dict:
if url in self.resolved:
return self.resolved[url]
hops, current, seen = [], url, set()
while True:
status, headers, body, error = self.fetch(current)
hops.append((current, status))
if 300 <= status < 400 and headers.get("Location"):
nxt = normalize(headers["Location"], current)
if nxt is None or nxt in seen or len(hops) > MAX_HOPS:
result = {"final": current, "status": status, "hops": hops, "headers": headers,
"body": None, "error": "redirect loop or too many hops"}
break
seen.add(current)
current = nxt
continue
result = {"final": current, "status": status, "hops": hops, "headers": headers,
"body": body, "error": error}
break
self.resolved[url] = result
return result
def allowed(self, url: str) -> bool:
return self.robots is None or self.robots.can_fetch(self.ua, url)
def crawl(self) -> None:
queue = deque([self.start])
self.depth[self.start] = 0
while queue and len(self.pages) < self.max_pages:
url = queue.popleft()
if not self.allowed(url):
self.blocked.add(url)
continue
res = self.resolve(url)
final = res["final"]
page = {"url": url, "status": res["status"], "final": final, "hops": len(res["hops"]) - 1,
"error": res["error"], "title": "", "canonical": "", "robots": "",
"h1": "", "description": None}
xrt = (res["headers"].get("X-Robots-Tag", "") if res["headers"] else "").lower()
same_host = urllib.parse.urlsplit(final).netloc.lower() == self.host
if page["hops"] and same_host:
# Audit the redirect target as its own URL instead of under the redirecting one.
if final not in self.depth:
self.depth[final] = self.depth[url]
queue.append(final)
page["robots"] = xrt
elif res["body"] is not None and same_host:
parser = PageParser()
try:
parser.feed(res["body"])
except Exception:
pass
base = normalize(parser.base, final) if parser.base else final
page.update(title=" ".join(parser.title.split()), h1=parser.h1,
description=parser.meta_description,
canonical=";".join(normalize(c, base) or c for c in parser.canonicals),
robots=",".join(filter(None, parser.meta_robots + [xrt])))
for href in parser.links:
target = normalize(href, base)
if not target:
continue
parts = urllib.parse.urlsplit(target)
if parts.netloc != self.host:
continue
if self.scheme == "https" and parts.scheme == "http":
self.http_links.add((url, target))
self.inlinks[target].add(url)
if target not in self.depth:
self.depth[target] = self.depth[url] + 1
queue.append(target)
else:
page["robots"] = xrt
self.pages[url] = page
def issues_for(c: Crawler) -> dict[tuple[str, str], list[str]]:
found: dict[tuple[str, str], list[str]] = defaultdict(list)
titles: dict[str, list[str]] = defaultdict(list)
for url, p in c.pages.items():
linked = bool(c.inlinks.get(url))
if p["error"]:
found[("ERROR", f"fetch failed: {p['error']}")].append(url)
elif p["status"] >= 400 or p["status"] == 0:
found[("ERROR", f"internal link target returns {p['status']}")].append(url)
if p["hops"] > 1:
found[("WARN", "redirect chain longer than one hop")].append(url)
elif p["hops"] == 1 and linked:
found[("INFO", "internal link points at a redirect (link to the final URL)")].append(url)
is_html_ok = p["status"] == 200 and p["h1"] != ""
if not is_html_ok or p["hops"]:
continue
if "noindex" in p["robots"]:
found[("WARN", "noindex page is linked internally")].append(url)
continue
canon = p["canonical"].split(";") if p["canonical"] else []
if not canon:
found[("WARN", "missing rel=canonical")].append(url)
elif len(canon) > 1:
found[("ERROR", "multiple rel=canonical tags (Google ignores all of them)")].append(url)
elif canon[0] != url:
target = canon[0]
if urllib.parse.urlsplit(target).netloc != c.host:
found[("WARN", "canonical points to another host")].append(url)
else:
res = c.resolve(target)
if res["status"] != 200 or len(res["hops"]) > 1:
found[("ERROR", "canonical target is not a 200 page")].append(url)
else:
found[("INFO", "canonicalised to a different URL")].append(url)
if not p["title"]:
found[("WARN", "missing <title>")].append(url)
else:
titles[p["title"].lower()].append(url)
if p["h1"] == 0:
found[("INFO", "no <h1>")].append(url)
if not p["description"]:
found[("INFO", "missing meta description")].append(url)
if c.depth.get(url, 0) > 3:
found[("INFO", "more than 3 clicks from the start URL")].append(url)
for title, urls in titles.items():
if len(urls) > 1:
found[("WARN", "duplicate <title> across pages")].extend(urls)
for url in sorted(c.blocked):
found[("INFO", "linked but disallowed by robots.txt")].append(url)
for src, target in sorted(c.http_links):
found[("WARN", "https page links to http:// URL")].append(f"{src} -> {target}")
return found
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("start")
ap.add_argument("--max-pages", type=int, default=500)
ap.add_argument("--delay", type=float, default=0.5, help="seconds between requests (be polite)")
ap.add_argument("--out", default="crawl.csv")
ap.add_argument("--user-agent", default=DEFAULT_UA)
ap.add_argument("--ignore-robots", action="store_true", help="only for sites you own, e.g. staging")
args = ap.parse_args()
start = normalize(args.start, args.start)
if not start:
print("error: start URL must be http(s)", file=sys.stderr)
return 2
c = Crawler(start, args.user_agent, args.delay, args.max_pages, args.ignore_robots)
c.crawl()
first = c.pages.get(start)
if not first or first["status"] == 0:
print(f"error: could not fetch start URL {start}: {first and first['error']}", file=sys.stderr)
return 2
found = issues_for(c)
per_url: dict[str, list[str]] = defaultdict(list)
for (sev, label), urls in found.items():
for u in urls:
per_url[u.split(" -> ")[0]].append(f"{sev}: {label}")
with open(args.out, "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["url", "status", "final_url", "hops", "depth", "inlinks", "title", "canonical",
"robots", "h1_count", "issues"])
for url, p in c.pages.items():
w.writerow([url, p["status"], p["final"], p["hops"], c.depth.get(url, ""),
len(c.inlinks.get(url, ())), p["title"], p["canonical"], p["robots"], p["h1"],
" | ".join(per_url.get(url, []))])
order = {"ERROR": 0, "WARN": 1, "INFO": 2}
print(f"Crawled {len(c.pages)} URLs on {c.host} (limit {args.max_pages}); details in {args.out}\n")
for (sev, label), urls in sorted(found.items(), key=lambda kv: (order[kv[0][0]], -len(kv[1]))):
print(f"[{sev}] {label}: {len(urls)}")
for u in urls[:5]:
print(f" {u}")
if len(urls) > 5:
print(f" ... {len(urls) - 5} more")
if not found:
print("No issues found.")
errors = sum(len(v) for (sev, _), v in found.items() if sev == "ERROR")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.