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.

  • Growth & SEO
  • $technical-seo-audit
  • 210 lines
  • 3 files
  • ~2,550 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- technical-seo-audit

Installs to ~/.agents/skills/technical-seo-audit/ for every project on this machine.

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.

~/.agents/skills/technical-seo-audit/SKILL.md

--- name: technical-seo-audit description: Audits a site's crawlability and indexation end to end — status codes, redirect chains, canonicals, noindex and X-Robots-Tag, robots.txt, XML… license: MIT ---

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

SeverityMeaningExamples
CriticalValuable pages cannot be crawled or indexedSitewide noindex, Disallow: /, canonicals all pointing at the homepage, 5xx on templates, JS-only content that fails to render
HighSignals conflict or crawl is wasted at scaleCanonical to a redirect/404, sitemap full of non-200 URLs, faceted URLs crawlable without limit, redirect chains on internal links
MediumWeak but workingMissing self-canonicals, duplicate titles on a template, orphan pages, deep click depth
LowHygieneMissing 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.md and 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:

SourceCheck
<meta name="robots"> / googlebotnoindex, nofollow, none, max-snippet
X-Robots-Tag headerOften set by CDN or framework for PDFs, previews, staging
robots.txtA disallowed URL cannot be crawled, so its noindex is never seen
Rendered vs raw HTMLJS that adds or removes noindex after load is unreliable

Rules:

  • noindex plus Disallow on the same URL is a contradiction: Google may index the URL without content from external links. To remove a URL, allow crawling and serve noindex (or 404/410) until it drops out, then optionally disallow.
  • Staging noindex leaking 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=canonical that 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. or https://) 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/next as 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

SituationCorrect response
Permanent move301 or 308, one hop, to the final 200 URL
Temporary (A/B, maintenance)302/307; 503 with Retry-After for planned downtime
Removed, no replacement404 or 410 (410 drops slightly faster)
Removed, close replacement exists301 to the closest equivalent, not the homepage
“Not found” page returning 200Soft 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 curl output with the rendered DOM. Primary content, links (<a href>, not onclick), 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

  • curl without -A may 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.txt that 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.py after 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.yamlgenerated from this page

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.py310 lines, 13.1 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem