How to Set Up Codex for Programmatic SEO (Full SKILL.md Included)

A Codex skill and zero-dependency scanner for pages at scale: it groups your sitemap into template families, measures how unique each family really is against the 40% gate and 30% hard stop, and reports five scored categories with the evidence lines left in.

The contract

Programmatic SEO is how sites reach 10,000 pages from one template and one data file. It is also how sites hit scaled content abuse attacks, and the band-aid is expensive to remove.

Who this is for

Site owners and SEOs whose sitemap contains hundreds or thousands of similar URLs from one template family (tools, locations, integrations, glossary entries, product cards)

What you get

A Codex skill and a zero-dependency scanner that inventories URL patterns, measures how unique each family really is, applies the quality gates, and prints a six-category scorecard with evidence lines

Prerequisites

Codex with skills enabled, python3, one public sitemap URL. No API keys, no paid data, no packages to install

Time

15 minutes end to end, including the real run below and a scan of your own site

Definition of done

A pattern inventory of your sitemap, a PASS/FLAG/HARD STOP verdict per template family, a scorecard, and a ranked list of fixes that each name the evidence line they came from

Everything you need to run the skill yourself is embedded below: the SKILL.md, the scanner, the install commands, a replayable self-test fixture, and two real runs against a site with three template families.

The short version

A sitemap with 4,354 URLs told me more in one pass than a month of page-by-page reviews, and it did so without judging anything. Here is the shape the inspection discovers:

  • Pages from one template family share 66% of their unique-top content on the blog set and 72% on the tool set. Both clear the 40% gate, so neither is a mad-libs pattern.
  • The URL check found 4 URLs over 100 characters, and 3 of them are one article.
  • The blog sitemap has no lastmod at all, which is the single reason its Index Management score is 70 instead of 100.
  • 274 slug names appear in more than one pattern, which is exactly what a 21-locale hreflang mirror setup looks like. Not a violation. But you can only know that because the scanner printed it as information rather than a penalty.

The skill's verdicts stay honest: six categories are scored, and five of them are computed. The sixth, Data Quality, prints n/a, because no scanner can see inside your database. That row is yours.

Why pages at scale need their own inspection

One mad-libs page is invisible. Two hundred of them are a pattern, and Google's scaled content abuse policy (March 2024, with enforcement escalation through June and August 2025, and a reported 45% drop in low-quality unoriginal content after it began) treats the pattern as the offense, not the individual pages. A site that publishes 500 near-identical location pages is not publishing 500 pages of content. It is publishing one page of content 500 times, and the risk lands on the domain.

Programmatic SEO inspection exists to catch that multiplication before it ships. It also exists for the cheaper failure modes: duplicate slugs from a migration, missing lastmod timestamps, canonical gaps in a 10,000-page set, and template families whose pages are 200 words of filler between identical sections. None of these are visible page by page. All of them show up in a pattern-shaped scan.

The workflow, step by step

The skill runs one command and answers three questions about every template family it finds. This section is the playbook; the embed below is the full text.

Step 1: Inventory the sitemap

Every scan starts with robots.txt and the sitemap files it points to. The sitemap protocol caps each file at 50,000 URLs or 50MB uncompressed, whichever comes first; above that, sites should use a sitemap index, and index maintenance is the codex-seo-sitemap skill's job in this series. All of this is public and free to fetch.

The scanner also records whether each URL has a lastmod entry, because the protocol says lastmod must reflect the real data update time, not the generation run. Zero lastmod coverage across a 4,354-URL file rounds to a 30-point Index Management penalty, which is what happened in the second run below.

Step 2: Detect pattern families

URLs are grouped by shape. Path segments are classified:

  • digit runs and UUIDs become #
  • long or hyphenated segments become <slug>
  • known locale prefixes (ja, zh-hans, pt-br) stay literal, so each language gets its own family instead of being merged across languages

So https://auspia.ai/tools/ai_overview becomes the family /tools/<slug>, and https://auspia.ai/ja/tools/ai_overview becomes /ja/tools/<slug>. The <slug> shorthand is deliberate: the family is defined by the template, not by any one page.

Step 3: Sample and measure

For each family with 5 or more URLs, the scanner fetches a small, evenly spaced sample (8 pages by default). Per page it measures:

Signal

How

Body words

Visible text from h1, p, list, table content. Header, nav, footer, aside, script, style are excluded, so shared boilerplate does not count

Unique content

Top-1 Jaccard similarity against the other sampled pages of the same family

Canonical

rel=canonical present and self-referencing

Indexing

noindex directive on a page that is still in the sitemap

Internal links

Same-host links per page

The uniqueness metric is the heart of it. Two pages that swap one city name and keep 390 identical words score close to 100% similar, which is the mad-libs signal. Two pages that genuinely cover different material score far apart.

Step 4: Apply the quality gates

Metric

Threshold

Action

Unique content per page

<40%

FLAG as thin content (penalty risk)

Unique content per page

<30%

HARD STOP: require explicit user approval

Word count per page

<300

FLAG for review

Pages in a family

100+

WARNING: confirm a staged rollout and review sample

A HARD STOP halts further analysis and records the gate line. That is what a scanner should do when the template reproduces itself: refuse to invent reassurance.

Step 5: Check URL, canonical and sitemap rules

Across the full URL list, violations are counted: query strings on primary URLs, URLs over 100 characters, uppercase in paths, duplicate end-slugs within any family, mixed trailing slash, and missing lastmod. Canonical self-reference is judged from the sampled pages.

Step 6: Score and write the plan

Five measured categories, each 0-100, plus the total. The formulas sit in the SKILL.md so you can see exactly what any score means:

Category

Formula (short form)

URL Structure

100 minus penalties for query strings, long URLs, uppercase and duplicate slugs

Template Uniqueness

the average top-1 unique percentage itself

Thin Content Risk

minus 35 per sub-300-word page share, minus 45 when unique <40%, minus 60 when <30%

Internal Linking

actual links vs 4 per 1000 words expected

Index Management

minus penalties for non-self canonicals, sitemap noindex, missing lastmod

Recommendations come out ranked CRITICAL to LOW, and every one cites its evidence line, so a plan survives a second look a month later.

The skill

markdown
---
name: codex-seo-programmatic
description: Use when the user asks about programmatic SEO, pages generated at scale from a data source, template pages, dynamic or data-driven pages, thin content at scale, scaled content abuse risk, index bloat, faceted navigation, auto-generated internal links, or a sitemap filled with many similar URLs. Audits a sitemap for repeating URL patterns and measures uniqueness, word counts, canonicals, and link density against the quality gates.
---
# Programmatic SEO: Pages at Scale

Programmatic SEO builds pages from a data source (a CSV, an API, a database)
through a template. The quality problem is not volume. One thousand pages
multiply whatever the template does - they multiply its weaknesses faster
than its strengths, and they multiply your risk if the template is thin.

## Core insight

A "mad-libs" template is invisible one page at a time. Swap the city name in
an identical 400-word paragraph and no single page looks wrong. Publish it
200 times and Google sees a scaled content abuse pattern, and the site - not
just those pages - starts losing trust. Every programmatic rollout has two
mechanical questions that a scanner can answer and one that only judgment
can:

1. Is each template family unique enough to exist on its own?
2. Are the URLs, canonicals and sitemap entries behaving themselves?
3. Does the data source actually differentiate the pages? (judgment)

Mechanics are the script's job. Judgment is yours.

## Commands

```
python3 pp_scan.py <sitemap-url-or-local-file> [--pattern /tools/]
                                                [--sample 8] [--json]
```

- `pp_scan.py` groups sitemap URLs into pattern families (e.g.
  `/tools/<slug>`), samples pages per family, and measures: unique content
  share (top-1 Jaccard), body word counts, canonical self-reference,
  noindex directives, internal link density, and URL rule violations.
- `--pattern` limits the scan to URLs containing that substring. Use it when
  you already know which pattern family you care about.
- `--json` prints machine-readable output for larger scripts.
- Local sitemap files work too: `python3 pp_scan.py sitemap.xml`, which
  makes the scan fully replayable.

## What the scanner measures

| Signal | How | Gate |
|--------|-----|------|
| Unique content | Top-1 Jaccard similarity between sampled pages of one pattern | PASS ≥40%; FLAG <40%; HARD STOP <30% |
| Body depth | Word count of visible body text (headers/nav/footer excluded) | FLAG <300 words |
| Canonical | `rel=canonical` present and self-referencing | Report % |
| Indexing | `noindex` inside sitemap-scoped pages | Report count |
| Internal links | Links to same host per page | Score vs 3-5/1000 words |
| URL rules | Query strings, >100 chars, uppercase, duplicate slugs | No-violation target |

The scanner excludes `<header>`, `<nav>`, `<footer>`, `<aside>`, `<script>`,
`<style>` from body text and link counting, so shared boilerplate does not
feed the similarity numbers.

## What the scanner cannot see (judgment tasks)

| Not observable from outside | Why |
|------------------------------|-----|
| Data source row quality | Rows, uniqueness, missing values live behind the CMS |
| Data freshness | Stale input produces stale pages |
| Review pipeline | Whether someone reads pages before publish |
| Standalone value | "Worth publishing if no sibling existed" |
| Locale duplication intent | hreflang mirrors vs duplicate content - use `codex-seo-hreflang` |

The output prints these as `n/a` on purpose: a score for a number you did
not measure is a lie.

## Quality gates (scaled content abuse, 2024-2026)

Google's Scaled Content Abuse policy came into force March 2024 and
enforcement escalated through 2025 (manual actions in June 2025, stronger
SpamBrain detection reported from August 2025). Google reported a 45%
reduction in low-quality, unoriginal content in its results after the
policy began. The gates below come from that context and from the
open-source methodology this skill adapts:

| Metric | Threshold | Action |
|--------|-----------|--------|
| Pages without content review | 100+ | WARNING: audit before relying on scale |
| Pages without justification | 500+ | HARD STOP: explicit approval required |
| Unique content per page | <40% | FLAG as thin content (penalty risk) |
| Word count per page | <300 | FLAG for review (may lack value) |

Hardened values for high-volume sites: treat <30% unique content as a HARD
STOP, keep 5-10% human review of generated pages, and publish in batches of
50-100 pages with 2-4 weeks of indexing/ranking observation before scaling
further.

### Safe patterns (work at scale)

- Integration pages with real setup docs, API details, screenshots
- Template/tool pages with downloadable content and usage instructions
- Glossary pages: 200+ word definitions with examples and related terms
- Product pages with unique specs, reviews, comparison data
- Data-driven pages with unique per-record statistics and analysis

### Penalty-risk patterns (avoid at scale)

- Location pages that only swap the city name in identical text
- "Best tool for [industry]" without industry-specific value
- "[Competitor] alternative" without real comparison data
- AI-generated pages without human review and unique value-add
- Pages where >60% of content is shared template boilerplate

## Uniqueness calculation

```
unique content % = (words unique to this page) / (total words on page) × 100
```

Measured against all other pages in the same template family. Shared
headers, footers and navigation are excluded. Template boilerplate text is
INCLUDED: identical paragraphs count against you even when they look
harmless.

## URL rules

- Lowercase, hyphenated slugs derived from the data
- No duplicate slugs; enforce uniqueness at generation time
- Keep URLs under 100 characters
- No query parameters for primary content URLs
- Consistent trailing slash usage (match the site's existing pattern)
- Every programmatic page carries a self-referencing canonical

## Internal linking

- Hub/spoke: category hub pages link to every page in their family
- Related items: auto-link 3-5 pages sharing a data attribute
- Breadcrumbs: BreadcrumbList schema from the URL hierarchy
- Anchor text: descriptive and varied, never the same exact-match string
- Density: 3-5 internal links per 1000 words

## Sitemap integration

- Split at 50,000 URLs or 50MB uncompressed per file, whichever comes first
- Use a sitemap index when the set needs multiple files
- `<lastmod>` reflects the real data update time, never generation time
- Exclude noindexed pages; register the sitemap in robots.txt
- Regenerate as records are added to the data source

## Index bloat prevention

- Noindex low-value pages instead of keeping them indexed
- Canonicalize true duplicates and low-value filtered views to their base
- For >10k programmatic URLs, monitor crawl stats in Search Console
- Merge records with insufficient data into aggregated pages instead of
  one page each
- Monthly check: indexed count vs intended count

## Scoring

The script scores five measurable categories, each 0-100:

| Category | How the score is computed |
|----------|---------------------------|
| URL Structure | 100 minus 30×query-fraction, 30×long-fraction, 20×uppercase, 20 if duplicate slugs |
| Template Uniqueness | = average top-1 unique % (0-100) |
| Thin Content Risk | 100 minus 35 per sampled-page share under 300 words, minus 45 if unique <40%, minus 60 if <30% |
| Internal Linking | min(100, actual links ÷ (words/1000 × 4) × 100) |
| Index Management | 100 minus 30 per non-self-canonical share, 30 per noindex share, 30 per lastmod-missing share |

Total = mean of the measured categories. Data Quality is always `n/a` -
the data source is not observable from the public web, and no score is
printed for it.

## Output contract

1. Pattern inventory: every URL family with 5+ pages, counts sorted.
2. Per-pattern measurements with gate notes (PASS / FLAG / HARD STOP /
   WARNING / NOTE).
3. URL rule check over the full set.
4. Six-category scorecard with TOTAL.
5. Recommendations, ranked CRITICAL / HIGH / MEDIUM / LOW, each with the
   evidence line it came from.

## Error handling

| Scenario | Action |
|----------|--------|
| Sitemap fetch fails | Report the error and URL; confirm robots.txt cites the sitemap |
| No repeating pattern found | The site has no generated page family; stop and say so |
| Pattern too small to sample | Report it in the inventory, skip measurement |
| Page fetch fails (SSL EOF, 403, timeout) | Record the error per URL, continue; report under-sampling |
| <30% unique content | Print the HARD STOP gate; require user approval before further analysis |
| Non-Latin script pages | Skip the word-count gate; similarity within the same language group is still valid |

The scanner

python
#!/usr/bin/env python3
"""
pp_scan.py - programmatic page scanner (zero dependencies, stdlib only)

Reads a sitemap, groups its URLs into generated-page patterns (the repeated
URL shapes that hold thousands of pages), samples a few pages per pattern,
and measures the signals the programmatic-SEO quality gates depend on:

  - unique content share (Jaccard similarity between sampled pages)
  - word counts of the content body (boilerplate excluded)
  - canonical self-reference and noindex directives
  - internal link density
  - URL rule violations (long URLs, uppercase, query strings, duplicate slugs)

The scanner measures. The model judges: data-source quality, freshness, and
the rollout plan are not observable from the outside and stay human
decisions. The output lists what was measured, what it means against the
thresholds in the SKILL.md, and what needs a human.

Usage:
  python3 pp_scan.py https://example.com/sitemap.xml
  python3 pp_scan.py https://example.com/sitemap.xml --pattern /tools/
  python3 pp_scan.py smoke-sitemap.xml --pattern /tools/
  python3 pp_scan.py --json https://example.com/sitemap.xml
"""
import argparse
import gzip
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser

UA = ("Mozilla/5.0 (compatible; codex-seo-programmatic/1.0; "
      "+https://auspia.ai/blog/codex-seo-programmatic)")
SKIP_TAGS = {"script", "style", "noscript", "svg", "header", "nav", "footer",
             "aside", "iframe", "form"}
CAP = 6_000_000  # per-document byte cap

# --- fetching ---------------------------------------------------------


def fetch(url, timeout=25):
    """Return decoded text of url. Local paths and file:// URLs work too."""
    if url.startswith(("http://", "https://")):
        pass
    else:
        path = url[len("file://"):] if url.startswith("file://") else url
        with open(urllib.request.url2pathname(path),
                  encoding="utf-8", errors="replace") as fh:
            return fh.read()
    req = urllib.request.Request(url, headers={
        "User-Agent": UA, "Accept-Encoding": "gzip"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        raw = r.read(CAP)
        if r.headers.get("Content-Encoding") == "gzip":
            raw = gzip.decompress(raw)
        return raw.decode("utf-8", "replace")


def sitemap_urls(xml):
    """Return list of (url, lastmod_or_None) from a sitemap document."""
    entries = re.findall(r"<url>(.*?)</url>", xml, re.S)
    if entries:
        out = []
        for block in entries:
            loc = re.search(r"<loc>(.*?)</loc>", block, re.S)
            mod = re.search(r"<lastmod>(.*?)</lastmod>", block, re.S)
            if loc:
                out.append((loc.group(1).strip(),
                            mod.group(1).strip() if mod else None))
        return out
    return []


def load_sitemap(target, seen=None):
    """Fetch a sitemap URL, expanding one level of sitemap index files."""
    seen = seen or set()
    if target in seen:
        return []
    seen.add(target)
    xml = fetch(target)
    if "<sitemapindex" in xml[:4000] or ("<sitemap>" in xml
                                         and "<urlset" not in xml[:4000]):
        urls = []
        for m in re.finditer(r"<sitemap>(.*?)</sitemap>", xml, re.S):
            loc = re.search(r"<loc>(.*?)</loc>", m.group(1), re.S)
            if loc and loc.group(1).strip().endswith(".xml"):
                urls += load_sitemap(loc.group(1).strip(), seen)
        return urls
    return sitemap_urls(xml)


# --- URL pattern derivation -------------------------------------------

UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
                     r"[0-9a-f]{4}-[0-9a-f]{12}", re.I)
HEX_RE = re.compile(r"[0-9a-f]{16,}", re.I)
DIGITS_RE = re.compile(r"^\d+$")
# Known language prefixes stay literal so every locale gets its own
# (same language) pattern group instead of being merged by the slug rule.
LOCALES = {"en", "ja", "ko", "zh", "zh-hans", "zh-hant", "pt", "pt-br",
           "ru", "he", "ar", "th", "vi", "uk", "tr", "pl", "ms", "it", "id",
           "hi", "fr", "es", "de", "nl", "sv", "da", "fi", "nb", "no", "cs",
           "sk", "hu", "ro", "bg", "el", "fa", "et", "lv", "lt", "sl",
           "hr", "ca", "az", "sr", "sw"}


def segment_key(seg):
    """Classify one path segment: template word, number, or produced slug."""
    seg = seg.strip("/")
    if not seg:
        return ""
    if DIGITS_RE.match(seg) or UUID_RE.fullmatch(seg) or HEX_RE.fullmatch(seg):
        return "#"
    if seg.lower() in LOCALES:
        return seg.lower()
    if "-" in seg or len(seg) > 20:
        return "<slug>"
    return seg.lower()


def pattern_of(path):
    """Normalize a URL path into a pattern key like /tools/<slug>."""
    path = path.split("?")[0].rstrip("/")
    segs = [segment_key(s) for s in path.split("/") if s]
    return "/" + "/".join(s for s in segs if s)


def collapse_small_groups(raw_groups, min_count):
    """Re-key single-word-slug groups: /tools/xenonlens -> /tools/<slug>."""
    groups = {}
    small = {}
    for key, items in raw_groups.items():
        if len(items) >= min_count:
            groups[key] = items
        else:
            small[key] = items
    for key, items in small.items():
        if len(key.split("/")) < 2:
            continue
        rebuilt = key.split("/")
        rebuilt.pop()
        rebuilt.append("<slug>")
        groups.setdefault("/".join(rebuilt), []).extend(items)
    return groups


def group_patterns(entries):
    """group entries (url, lastmod) into {pattern: [(url, lastmod)]}."""
    raw = {}
    for url, mod in entries:
        parsed = urllib.parse.urlparse(url)
        key = pattern_of(parsed.path)
        if key:
            raw.setdefault(key, []).append((url, mod))
    return collapse_small_groups(raw, 5)


# --- page signals ------------------------------------------------------


def head_signals(html):
    """Regex-extract title, canonical and robots directives from <head>."""
    title = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
    canon = re.search(r'<link[^>]*rel=["\']?canonical["\']?[^>]*>', html, re.I)
    robots = re.findall(r'<meta[^>]*name=["\']robots["\'][^>]*>', html, re.I)
    noindex = False
    for tag in robots:
        m = re.search(r'content=["\']([^"\']*)["\']', tag, re.I)
        if m and "noindex" in m.group(1).lower():
            noindex = True
    href = None
    if canon:
        m = re.search(r'href=["\']([^"\']*)["\']', canon.group(0), re.I)
        if m:
            href = m.group(1)
    return (title.group(1).strip() if title else None,
            href,
            noindex)


class BodyParser(HTMLParser):
    """Visible text, h1 texts and internal link hrefs, boilerplate excluded."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.stack = []
        self.parts = []
        self.h1s = []
        self.links = []
        self._h1 = None

    def _visible(self):
        return not any(t in SKIP_TAGS for t in self.stack)

    def handle_starttag(self, tag, attrs):
        tag = tag.lower()
        if tag not in SKIP_TAGS:
            if tag == "a" and self._visible():
                href = dict(attrs).get("href", "")
                if href:
                    self.links.append(href)
            elif tag == "h1":
                self._h1 = []
        self.stack.append(tag)

    def handle_endtag(self, tag):
        tag = tag.lower()
        if tag == "h1" and self._h1 is not None:
            self.h1s.append(" ".join("".join(self._h1).split()))
            self._h1 = None
        if tag in self.stack:
            while self.stack:
                if self.stack.pop() == tag:
                    break

    def handle_data(self, data):
        if not self._visible():
            return
        self.parts.append(data)
        if self._h1 is not None:
            self._h1.append(data)

    @property
    def text(self):
        return " ".join(self.parts)


def page_signals(html, page_url):
    """Measure one page. Returns a dict of mechanical signals."""
    title, canon_href, noindex = head_signals(html)
    parser = BodyParser()
    parser.feed(html)
    text = parser.text
    tokens = re.findall(r"[0-9A-Za-z]+(?:['’][0-9A-Za-z]+)?", text.lower())
    cjk = len(re.findall(r"[぀-ヿ一-鿿가-힯]", text))
    canon_self = None
    if canon_href:
        pin = urllib.parse.urlsplit(page_url)
        cin = urllib.parse.urlsplit(urllib.parse.urljoin(page_url, canon_href))
        canon_self = (pin.netloc == cin.netloc and
                      pin.path.rstrip("/") == cin.path.rstrip("/"))
    internal = 0
    host = urllib.parse.urlsplit(page_url).netloc
    for h in parser.links:
        h = h.strip()
        if not h or h.startswith(("#", "mailto:", "tel:", "javascript:")):
            continue
        a = urllib.parse.urljoin(page_url, h)
        if urllib.parse.urlsplit(a).netloc == host:
            internal += 1
    return {
        "url": page_url,
        "status_ok": True,
        "title": (title or "")[:90],
        "words": len(tokens),
        "cjk": cjk,
        "h1": parser.h1s,
        "canonical": canon_href,
        "canonical_self": canon_self,
        "noindex": noindex,
        "internal_links": internal,
        "tokens": set(tokens),
    }


def similarity(a, b):
    """Jaccard similarity of two token sets."""
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)


# --- measurement -------------------------------------------------------


def sample_indexes(total, n):
    """Evenly spaced sample indexes across a list."""
    if total <= n:
        return list(range(total))
    step = total / n
    return [min(int(i * step), total - 1) for i in range(n)]


def measure_pattern(pattern, items, args, errors):
    page = {}
    idxs = sample_indexes(len(items), args.sample)
    for i in idxs:
        url = items[i][0]
        try:
            page[i] = page_signals(fetch(url), url)
        except Exception as exc:  # noqa: BLE001 - report and move on
            errors.append(f"{url}: {type(exc).__name__}: {exc}")
    n = len(page)
    if n < 2:
        return None, page, errors
    max_sim = {}
    for i in page:
        sims = [similarity(page[i]["tokens"], page[j]["tokens"])
                for j in page if j != i]
        max_sim[i] = max(sims) if sims else 0.0
    unique = [100.0 * (1 - max_sim[i]) for i in page]
    words = [page[i]["words"] for i in page]
    links = [page[i]["internal_links"] for i in page]
    thin = [page[i] for i in page if page[i]["words"] < 300]
    canon = [page[i]["canonical_self"] for i in page if page[i]["canonical"]]
    met = {
        "pages_sampled": n,
        "avg_words": round(sum(words) / len(words)),
        "min_words": min(words),
        "pages_under_300_words": len(thin),
        "avg_top_1_unique_pct": round(sum(unique) / len(unique)),
        "min_unique_pct": round(min(unique)),
        "cjk_samples": sum(1 for i in page if page[i]["cjk"] > 200),
        "canonical_self_pct": (round(100 * sum(canon) / len(canon))
                               if canon else None),
        "noindex_in_sample": sum(1 for i in page if page[i]["noindex"]),
        "avg_internal_links": round(sum(links) / len(links), 1),
        "thin_pages": [p["url"] for p in thin],
        "low_uniqueness_pages": sorted(
            ({page[i]["url"]: round(max_sim[i] * 100) for i in page}
             .items()), key=lambda kv: -kv[1])[:2],
        "nonlatin_caveat": [i for i in page if page[i]["cjk"] > 200],
    }
    return met, page, errors


# --- url rules and scores ----------------------------------------------


def url_rules(groups, host):
    """URL rule violations, count and duplicate slugs judged per pattern."""
    q = lng = up = 0
    total = 0
    dups = 0
    trailing = set()
    for pattern, items in groups.items():
        slugs = {}
        for url, _ in items:
            parsed = urllib.parse.urlsplit(url)
            path = parsed.path
            total += 1
            if parsed.query:
                q += 1
            if len(url) > 100:
                lng += 1
            if any(c.isupper() for c in path):
                up += 1
            trailing.add(path.endswith("/"))
            last = path.rstrip("/").split("/")[-1].lower()
            slugs[last] = slugs.get(last, 0) + 1
        dups += sum(1 for v in slugs.values() if v > 1)
    return {
        "patterns_checked": len(groups),
        "with_query": q, "over_100_chars": lng, "uppercase_path": up,
        "duplicate_slugs": dups, "mixed_trailing_slash": len(trailing) > 1,
        "pct": {"query": 100 * q / total if total else 0,
                "long": 100 * lng / total if total else 0,
                "upper": 100 * up / total if total else 0},
    }


def score(met, rules, sitemap_no_lastmod_pct):
    """Convert measured signals to the six-category scorecard."""
    url = max(0, 100 - round(30 * (rules["pct"]["query"] / 100)
                             + 30 * (rules["pct"]["long"] / 100)
                             + 20 * (rules["pct"]["upper"] / 100)
                             + 20 * (rules["duplicate_slugs"] > 0)))
    if met is None:
        return {"Data Quality": "n/a", "URL Structure": url,
                "Template Uniqueness": "n/a", "Thin Content Risk": "n/a",
                "Internal Linking": "n/a", "Index Management": "n/a"}
    uni = met["avg_top_1_unique_pct"]
    thin = 100
    under = met["pages_under_300_words"]
    if met["nonlatin_caveat"]:
        under = 0  # word gate not applicable to non-Latin token counts
    thin -= 35 * (under / met["pages_sampled"])
    if uni < 40:
        thin -= 45
    if uni < 30:
        thin -= 60
    thin = max(0, round(thin))
    expected = 4 * met["avg_words"] / 1000 if met["avg_words"] else 1
    link = min(100, round(100 * met["avg_internal_links"] / max(1, expected)))
    idx = 100
    if met["canonical_self_pct"] is not None:
        idx -= round(30 * (100 - met["canonical_self_pct"]) / 100)
    idx -= 30 * (met["noindex_in_sample"] / met["pages_sampled"])
    if sitemap_no_lastmod_pct is not None:
        idx -= round(30 * sitemap_no_lastmod_pct / 100)
    idx = max(0, round(idx))
    return {"Data Quality": "n/a", "URL Structure": url,
            "Template Uniqueness": uni, "Thin Content Risk": thin,
            "Internal Linking": link, "Index Management": idx}


# --- gates -------------------------------------------------------------

GATE_UNIQUE_FLAG = 40    # original quality gate: below 40% -> thin
GATE_UNIQUE_STOP = 30    # hardening recommendation: below 30% -> hard stop
GATE_WORDS = 300         # original quality gate: below 300 words -> review


def gates_of(pattern, count, met):
    """Apply the quality gates in the SKILL.md. Returns list of gate notes."""
    out = []
    if count >= 100:
        out.append("WARNING pattern size %d pages - confirm a staged rollout "
                   "and a review sample before relying on this pattern"
                   % count)
    if met is None:
        out.append("PATTERN NOT SAMPLED (fetch errors) - treat all gates "
                   "as unresolved")
        return out
    if count >= 100 and met["pages_sampled"] < 5:
        out.append("WARNING sample smaller than 5 - inconclusive gates")
    if met["nonlatin_caveat"]:
        out.append("NOTE %d sampled pages show non-Latin script (CJK/kana/"
                   "hangul): word counts are token counts and undercount. "
                   "Words gate skipped; similarity within this language "
                   "group is still comparable."
                   % len(met["nonlatin_caveat"]))
    elif met["pages_under_300_words"]:
        out.append("FLAG %d/%d sampled pages under %d words (review list: %s)"
                   % (met["pages_under_300_words"], met["pages_sampled"],
                      GATE_WORDS, "; ".join(met["thin_pages"][:2])))
    if met["avg_top_1_unique_pct"] < GATE_UNIQUE_STOP:
        out.append("HARD STOP avg top-1 unique content %.0f%% < %d%% - the "
                   "template reproduces itself; do not publish more of this "
                   "pattern"
                   % (met["avg_top_1_unique_pct"], GATE_UNIQUE_STOP))
    elif met["avg_top_1_unique_pct"] < GATE_UNIQUE_FLAG:
        out.append("FLAG avg top-1 unique content %.0f%% < %d%% - thin "
                   "content risk; differentiate pages before scaling"
                   % (met["avg_top_1_unique_pct"], GATE_UNIQUE_FLAG))
    else:
        out.append("PASS unique content %.0f%% - gate satisfied"
                   % met["avg_top_1_unique_pct"])
    return out


# --- report ------------------------------------------------------------

SEPARATOR = "-" * 62


def cross_pattern_duplicates(patterns):
    """Slug names appearing in more than one pattern (locale mirrors)."""
    seen = {}
    for pattern, items in patterns:
        if len(items) < 5:
            continue
        for url, _ in items:
            last = urllib.parse.urlsplit(url).path.rstrip("/")
            last = last.split("/")[-1].lower()
            seen.setdefault(last, set()).add(pattern)
    return sum(1 for v in seen.values() if len(v) > 1)


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("sitemap", help="sitemap URL or local .xml path")
    ap.add_argument("--pattern", default=None,
                    help="only URLs containing this substring (e.g. /tools/)")
    ap.add_argument("--sample", type=int, default=8,
                    help="pages fetched per pattern (default 8)")
    ap.add_argument("--max-fetch", type=int, default=24,
                    help="hard cap on fetched pages (default 24)")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args(argv)

    errors = []
    try:
        entries = load_sitemap(args.sitemap)
    except Exception as exc:  # noqa: BLE001
        print("SITEMAP FETCH FAILED: %s: %s" % (type(exc).__name__, exc))
        return 2
    if not entries:
        print("SITEMAP EMPTY or unparseable: %s" % args.sitemap)
        return 2
    if args.pattern:
        filtered = [(u, m) for u, m in entries if args.pattern in u]
        if not filtered:
            print("NO URLS MATCH --pattern %s (sitemap has %d URLs)"
                  % (args.pattern, len(entries)))
            return 2
        entries = filtered
    total = len(entries)
    groups = group_patterns(entries)
    if not groups:
        print("NO REPEATING URL PATTERN FOUND in %d URLs - nothing here "
              "looks generated at scale" % total)
        return 2

    tested = []
    fetched = 0
    patterns = sorted(groups.items(), key=lambda kv: -len(kv[1]))
    for pattern, items in patterns:
        if fetched >= args.max_fetch or len(items) < 5:
            continue
        met, pages, errs = measure_pattern(pattern, items, args, errors)
        fetched += len(pages)
        if met is None and not pages:
            continue
        tested.append((pattern, items, met, pages))

    sitemap_no_lastmod = sum(1 for _, m in entries if not m)
    sitemap_pct = 100 * sitemap_no_lastmod / total if total else None
    rules = url_rules(groups, urllib.parse.urlsplit(entries[0][0]).netloc)

    if args.json:
        return json_report(entries, total, groups, tested, rules,
                           sitemap_pct, errors)

    print("Programmatic scan: %s" % args.sitemap)
    if args.pattern:
        print("  filter --pattern %s | URLs in scope: %d" % (args.pattern,
                                                             total))
    print("  sitemap: %d URLs | %d%% lack <lastmod>"
          % (total, round(sitemap_pct or 0)))
    print(SEPARATOR)
    print("URL pattern inventory (count >= 5):")
    sampled_keys = {p for p, _, _, _ in tested}
    for pattern, items in patterns:
        if len(items) < 5:
            continue
        print("  %-34s %5d pages%s" % (pattern, len(items),
                                       "   (sampled)" if pattern in
                                       sampled_keys else ""))
    print(SEPARATOR)
    per_pattern = {}
    for pattern, items, met, _ in tested:
        per_pattern[pattern] = met
        print("Pattern: %s (%d pages)" % (pattern, len(items)))
        if met["nonlatin_caveat"]:
            print("  non-Latin script detected; word counts are token "
                  "counts, see note below")
        print("  sampled:      %d pages evenly spaced over %d"
              % (met["pages_sampled"], len(items)))
        print("  avg body words:      %d   (min %d, pages < %d words: %d)"
              % (met["avg_words"], met["min_words"], GATE_WORDS,
                 met["pages_under_300_words"]))
        print("  avg top-1 unique:  %d%%   (min %d%%)"
              % (met["avg_top_1_unique_pct"], met["min_unique_pct"]))
        print("  canonical self-ref:  %s"
              % (str(met["canonical_self_pct"]) + "%"
                 if met["canonical_self_pct"] is not None else "no canonicals"))
        print("  noindex in sample:   %d" % met["noindex_in_sample"])
        print("  avg internal links:  %.1f" % met["avg_internal_links"])
        for gate in gates_of(pattern, len(items), met):
            print("  [GATE] %s" % gate)
        print()
    print(SEPARATOR)
    print("URL rule check over all %d URLs:" % total)
    print("  query strings: %d | >100 chars: %d | uppercase in path: %d"
          % (rules["with_query"], rules["over_100_chars"],
             rules["uppercase_path"]))
    print("  duplicate end-slugs across %d patterns (within-pattern): %d"
          % (rules["patterns_checked"], rules["duplicate_slugs"]))
    print("  mixed trailing slash: %s" % rules["mixed_trailing_slash"])
    cross = cross_pattern_duplicates(patterns)
    if cross:
        print("  cross-pattern duplicate slugs: %d (locale mirrors of the "
              "same page count here; judge, don't penalize)"
              % cross)
    print(SEPARATOR)
    print("Programmatic score (mechanics only; Data Quality is a human "
          "call - the data source lives behind the CMS):")
    for i, (pattern, items, met, _) in enumerate(tested):
        if i:
            print()
        sc = score(met, rules, sitemap_pct)
        print("  %s" % pattern)
        for k, v in sc.items():
            print("    %-22s %s" % (k, v))
        vals = [v for v in sc.values() if isinstance(v, (int, float))]
        if vals:
            print("    TOTAL (mean of measured): %d/100"
                  % round(sum(vals) / len(vals)))
    print(SEPARATOR)
    if errors:
        print("Fetch errors (%d):" % len(errors))
        for e in errors[:6]:
            print("  " + e)
    return 0


def json_report(entries, total, groups, tested, rules, sitemap_pct, errors):
    payload = {
        "sitemap_url": None,
        "total_urls": total,
        "url_rules": rules,
        "sitemap_no_lastmod_pct": sitemap_pct,
        "patterns": {},
        "fetch_errors": errors,
    }
    for pattern, items, met, _ in tested:
        payload["patterns"][pattern] = {
            "count": len(items),
            "gate": gates_of(pattern, len(items), met),
            "metrics": met,
            "score": score(met, rules, sitemap_pct),
        }
    json.dump(payload, sys.stdout, indent=1, ensure_ascii=False)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Install

Three files, one directory, zero packages. If you are new to Codex skills, the getting started post in this series covers the skills list check and the directory mechanics in more depth.

Code
mkdir -p ~/.codex/skills/codex-seo-programmatic/scripts \
         ~/.codex/skills/codex-seo-programmatic/fixtures

Save the three code blocks above as:

  1. ~/.codex/skills/codex-seo-programmatic/SKILL.md
  2. ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py
  3. ~/.codex/skills/codex-seo-programmatic/fixtures/smoke.xml (save the 12-URL fixture below)
Code
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://auspia.ai/tools/landing-page-diagnostic</loc></url>
<url><loc>https://auspia.ai/tools/website-seo-score-checker</loc></url>
<url><loc>https://auspia.ai/tools/ai-search-visibility-checker</loc></url>
<url><loc>https://auspia.ai/tools/llms-txt-generator-checker</loc></url>
<url><loc>https://auspia.ai/tools/robots-txt-ai-crawler-checker</loc></url>
<url><loc>https://auspia.ai/tools/ai_overview</loc></url>
<url><loc>https://auspia.ai/tools/one-page-geo-citation-audit</loc></url>
<url><loc>https://auspia.ai/tools/best-tools-list-debiaser</loc></url>
<url><loc>https://auspia.ai/tools/google-index-checker</loc></url>
<url><loc>https://auspia.ai/tools/http-status-code-checker</loc></url>
<url><loc>https://auspia.ai/tools/on-page-seo-audit</loc></url>
<url><loc>https://auspia.ai/tools/googlebot-spider-simulator</loc></url>
</urlset>

The fixture uses real tool pages of a real site, so the self-test measures real content rather than a toy. It is built for replay, not for zeros.

Verifying the install

Run the self-test from your machine:

Code
python3 ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py \
  ~/.codex/skills/codex-seo-programmatic/fixtures/smoke.xml

Expected in the output:

  • URL pattern inventory lists exactly one pattern, /tools/<slug>, with 12 pages
  • one gate line and one scorecard appear for that pattern
  • URL Structure 100 and no rule violations

Then point it at your own site:

Code
python3 ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py https://yourdomain.com/sitemap.xml

If the output ends with a HARD STOP gate, the skill's job right then is not to plan a continuation. Say so and ask for a human decision. This is the one case where the series' installed skills err on the side of refusing.

Two real runs, one site

The site is auspia.ai, which serves three public template families from one CMS: a tool directory (21 English tools mirrored into 20 locales), an editorial blog (932 English posts), and the localized sets of each. The first run filters to the tool patterns. The second scans the blog sitemap leaf. Both runs were made with the exact scanner above, default sampling, on a desktop Python.

Run 1: the tool family, 441 URLs after filter

Code
Programmatic scan: https://auspia.ai/sitemap.xml
  filter --pattern /tools/ | URLs in scope: 441
  sitemap: 441 URLs | 0% lack <lastmod>
--------------------------------------------------------------
URL pattern inventory (count >= 5):
  /tools/<slug>                         21 pages   (sampled)
  /ja/tools/<slug>                      21 pages   (sampled)
  /ko/tools/<slug>                      21 pages   (sampled)
  /zh-hant/tools/<slug>                 21 pages
  /zh-hans/tools/<slug>                 21 pages
  /ar/tools/<slug>                      21 pages
  /he/tools/<slug>                      21 pages
  /ru/tools/<slug>                      21 pages
  /th/tools/<slug>                      21 pages
  /pt-br/tools/<slug>                   21 pages
  /es/tools/<slug>                      21 pages
  /de/tools/<slug>                      21 pages
  /fr/tools/<slug>                      21 pages
  /id/tools/<slug>                      21 pages
  /it/tools/<slug>                      21 pages
  /tr/tools/<slug>                      21 pages
  /uk/tools/<slug>                      21 pages
  /pl/tools/<slug>                      21 pages
  /hi/tools/<slug>                      21 pages
  /vi/tools/<slug>                      21 pages
  /ms/tools/<slug>                      21 pages
--------------------------------------------------------------
Pattern: /tools/<slug> (21 pages)
  sampled:      8 pages evenly spaced over 21
  avg body words:      741   (min 316, pages < 300 words: 0)
  avg top-1 unique:  72%   (min 62%)
  canonical self-ref:  100%
  noindex in sample:   0
  avg internal links:  2.1
  [GATE] PASS unique content 72% - gate satisfied

Pattern: /ja/tools/<slug> (21 pages)
  non-Latin script detected; word counts are token counts, see note below
  sampled:      8 pages evenly spaced over 21
  avg body words:      94   (min 44, pages < 300 words: 8)
  avg top-1 unique:  68%   (min 50%)
  canonical self-ref:  100%
  noindex in sample:   0
  avg internal links:  2.1
  [GATE] NOTE 8 sampled pages show non-Latin script (CJK/kana/hangul): word counts are token counts and undercount. Words gate skipped; similarity within this language group is still comparable.
  [GATE] PASS unique content 68% - gate satisfied

Pattern: /ko/tools/<slug> (21 pages)
  non-Latin script detected; word counts are token counts, see note below
  sampled:      8 pages evenly spaced over 21
  avg body words:      96   (min 42, pages < 300 words: 8)
  avg top-1 unique:  68%   (min 48%)
  canonical self-ref:  100%
  noindex in sample:   0
  avg internal links:  2.1
  [GATE] NOTE 8 sampled pages show non-Latin script (CJK/kana/hangul): word counts are token counts and undercount. Words gate skipped; similarity within this language group is still comparable.
  [GATE] PASS unique content 68% - gate satisfied

--------------------------------------------------------------
URL rule check over all 441 URLs:
  query strings: 0 | >100 chars: 0 | uppercase in path: 0
  duplicate end-slugs across 21 patterns (within-pattern): 0
  mixed trailing slash: False
  cross-pattern duplicate slugs: 21 (locale mirrors of the same page count here; judge, don't penalize)
--------------------------------------------------------------
Programmatic score (mechanics only; Data Quality is a human call - the data source lives behind the CMS):
  /tools/<slug>
    Data Quality           n/a
    URL Structure          100
    Template Uniqueness    72
    Thin Content Risk      100
    Internal Linking       71
    Index Management       100
    TOTAL (mean of measured): 89/100

  /ja/tools/<slug>
    Data Quality           n/a
    URL Structure          100
    Template Uniqueness    68
    Thin Content Risk      100
    Internal Linking       100
    Index Management       100
    TOTAL (mean of measured): 94/100

  /ko/tools/<slug>
    Data Quality           n/a
    URL Structure          100
    Template Uniqueness    68
    Thin Content Risk      100
    Internal Linking       100
    Index Management       100
    TOTAL (mean of measured): 94/100
--------------------------------------------------------------

Run 2: the blog family, 4,354 URLs

Code
Programmatic scan: https://auspia.ai/blog/blog-0.xml
  sitemap: 4354 URLs | 100% lack <lastmod>
--------------------------------------------------------------
URL pattern inventory (count >= 5):
  /blog/<slug>                         932 pages   (sampled)
  /ja/blog/<slug>                      266 pages   (sampled)
  /zh-hant/blog/<slug>                 256 pages   (sampled)
  /zh-hans/blog/<slug>                 249 pages
  /ko/blog/<slug>                      194 pages
  /pt-br/blog/<slug>                   179 pages
  /es/blog/<slug>                      161 pages
  /ru/blog/<slug>                      161 pages
  /de/blog/<slug>                      159 pages
  /fr/blog/<slug>                      158 pages
  /th/blog/<slug>                      151 pages
  /id/blog/<slug>                      150 pages
  /it/blog/<slug>                      150 pages
  /ar/blog/<slug>                      149 pages
  /tr/blog/<slug>                      149 pages
  /he/blog/<slug>                      147 pages
  /pl/blog/<slug>                      145 pages
  /uk/blog/<slug>                      145 pages
  /hi/blog/<slug>                      144 pages
  /ms/blog/<slug>                      144 pages
  /vi/blog/<slug>                      144 pages
--------------------------------------------------------------
Pattern: /blog/<slug> (932 pages)
  sampled:      8 pages evenly spaced over 932
  avg body words:      2243   (min 634, pages < 300 words: 0)
  avg top-1 unique:  66%   (min 61%)
  canonical self-ref:  100%
  noindex in sample:   0
  avg internal links:  10.9
  [GATE] WARNING pattern size 932 pages - confirm a staged rollout and a review sample before relying on this pattern
  [GATE] PASS unique content 66% - gate satisfied

Pattern: /ja/blog/<slug> (266 pages)
  non-Latin script detected; word counts are token counts, see note below
  sampled:      8 pages evenly spaced over 266
  avg body words:      355   (min 140, pages < 300 words: 5)
  avg top-1 unique:  58%   (min 44%)
  canonical self-ref:  100%
  noindex in sample:   0
  avg internal links:  8.0
  [GATE] WARNING pattern size 266 pages - confirm a staged rollout and a review sample before relying on this pattern
  [GATE] NOTE 8 sampled pages show non-Latin script (CJK/kana/hangul): word counts are token counts and undercount. Words gate skipped; similarity within this language group is still comparable.
  [GATE] PASS unique content 58% - gate satisfied

Pattern: /zh-hant/blog/<slug> (256 pages)
  non-Latin script detected; word counts are token counts, see note below
  sampled:      8 pages evenly spaced over 256
  avg body words:      572   (min 167, pages < 300 words: 3)
  avg top-1 unique:  68%   (min 59%)
  canonical self-ref:  100%
  noindex in sample:   0
  avg internal links:  9.2
  [GATE] WARNING pattern size 256 pages - confirm a staged rollout and a review sample before relying on this pattern
  [GATE] NOTE 8 sampled pages show non-Latin script (CJK/kana/hangul): word counts are token counts and undercount. Words gate skipped; similarity within this language group is still comparable.
  [GATE] PASS unique content 68% - gate satisfied

--------------------------------------------------------------
URL rule check over all 4354 URLs:
  query strings: 0 | >100 chars: 4 | uppercase in path: 0
  duplicate end-slugs across 42 patterns (within-pattern): 0
  mixed trailing slash: False
  cross-pattern duplicate slugs: 274 (locale mirrors of the same page count here; judge, don't penalize)
--------------------------------------------------------------
Programmatic score (mechanics only; Data Quality is a human call - the data source lives behind the CMS):
  /blog/<slug>
    Data Quality           n/a
    URL Structure          100
    Template Uniqueness    66
    Thin Content Risk      100
    Internal Linking       100
    Index Management       70
    TOTAL (mean of measured): 87/100

  /ja/blog/<slug>
    Data Quality           n/a
    URL Structure          100
    Template Uniqueness    58
    Thin Content Risk      100
    Internal Linking       100
    Index Management       70
    TOTAL (mean of measured): 86/100

  /zh-hant/blog/<slug>
    Data Quality           n/a
    URL Structure          100
    Template Uniqueness    68
    Thin Content Risk      100
    Internal Linking       100
    Index Management       70
    TOTAL (mean of measured): 88/100
--------------------------------------------------------------

Reading the two runs

Programmatic SEO quality gate checklist with real scan results from the auspia.ai tool and blog sitemaps

Tool family, English set: 89/100, pass. The number that matters is the internal linking score: 71. The pages carry 2.1 internal links on average; the density target for a 741-word body is about 3 links. That is the single weakest measured category, and the evidence line is right there: avg internal links: 2.1. The fix is hub/spoke structure: a category hub linking into all 21 tool pages, plus "related tools" cross-links. Template Uniqueness 72% says the template is healthy; nothing is scoring at thin. Canonical self-reference and sitemap hygiene are each perfect.

Blog family: 87/100, with three findings.

  1. Index Management 70, and the cause is a sitemap, not a page. 100% lack <lastmod> across 4,354 URLs. The pages are fine; the sitemap never got timestamps. Since the sitemap integration contract says lastmod reflects the real data update time and noindexed pages are excluded, do anything, this one line is the entire fix.
  2. The >100-char URL rule caught the interesting thing the duplicate-slug rule missed. Four URLs exceed the limit, and three of them are one article: the base URL plus two suffixes (ms9qsrac-2 and mseahdj6-2), all live and returning 200. Working generator would never emit that shape. The scanner cannot call it duplicate content because the slugs differ, and it is right not to. What it does is leave the shape visible. Migration residue of this kind is a manual decision, not a rule violation.
  3. The 932-page WARNING gate fired on the biggest, best-kept pattern. That is honest behavior: the gate is about review verification, not about content. The editorial blog has an editing pipeline and real bylines; the review sample exists by construction. Discarding the WARNING is a judgment call, and the skill prints the note so you can make it deliberately with evidence.

Locale mirrors show up as information, not violations. The scanner reports cross-pattern duplicate slugs: 274 and says outright: judge, do not penalize. A 21-locale site will mirror every slug in every language. The same site pattern can be 274 duplicates in the hands of a mono-lingual site, and the scanner will report it identically. Hreflang verification belongs to the codex-seo-hreflang skill in this series.

Where the judgment goes

The scanner prints Data Quality n/a on purpose. Four decisions are yours and yours alone, no matter how pretty the scorecard:

Decision

What it needs

Data source quality

Row uniqueness, missing values, field completeness (an API, CSV or DB audit)

Freshness

Whether the source updates more often than the template renders

Human review

Whether a person reads a sample of pages before publishing; hard gate says 5-10%

Standalone value

"Would this page be worth publishing if no sibling existed?"

Programmatic SEO wins when the data source does the differentiating and the template does only the choreography. When the template starts doing the differentiating, the unique-content number tells you first.

Troubleshooting

Symptom

Likely cause

Fix

SITEMAP FETCH FAILED

Wrong path or a 403; check robots.txt

Confirm the Sitemap line in robots.txt; run with the exact URL from the site

NO REPEATING URL PATTERN FOUND

Site has no generated page family; or URLs all differ in word segments

Client-side rendering is a separate problem; you may need to check whether pages are SSR at all

Pattern shows but sampled is missing

Fewer than 5 URLs in the family

Below the reporting threshold; the family is not part of the programmatic scale problem

URLError: SSL EOF on one page

Range of transient TLS failures, common on some hosting chains and proxies

Re-run the scan; errors are recorded per URL and the scan continues

Non-Latin script note on a pattern

CJK/kana/hangul pages tokenize differently

Word count gate is skipped for that pattern; similarity within the language group is still valid

HARD STOP gate printed

Unique content below 30%

Stop and treat it as policy: no further scaling, human review, differentiated content or consolidation

Numbers look too good

Sample of 8 over a 4,000-URL family

Raise --sample, then re-check. Sampling is a proxy, not a census

PASTE-TO-CODEX

Paste this whole block into a fresh Codex conversation. Codex will create the three files, run the self-test, and report back.

Code
My task: install the codex-seo-programmatic skill the easy way.
Read the three code blocks below, one at a time.

A. Save this as ~/.codex/skills/codex-seo-programmatic/SKILL.md:

~~~~markdown
---
name: codex-seo-programmatic
description: Use when the user asks about programmatic SEO, pages generated at scale from a data source, template pages, dynamic or data-driven pages, thin content at scale, scaled content abuse risk, index bloat, faceted navigation, auto-generated internal links, or a sitemap filled with many similar URLs. Audits a sitemap for repeating URL patterns and measures uniqueness, word counts, canonicals, and link density against the quality gates.
---
# Programmatic SEO: Pages at Scale

Programmatic SEO builds pages from a data source (a CSV, an API, a database)
through a template. The quality problem is not volume. One thousand pages
multiply whatever the template does - they multiply its weaknesses faster
than its strengths, and they multiply your risk if the template is thin.

## Core insight

A "mad-libs" template is invisible one page at a time. Swap the city name in
an identical 400-word paragraph and no single page looks wrong. Publish it
200 times and Google sees a scaled content abuse pattern, and the site - not
just those pages - starts losing trust. Every programmatic rollout has two
mechanical questions that a scanner can answer and one that only judgment
can:

1. Is each template family unique enough to exist on its own?
2. Are the URLs, canonicals and sitemap entries behaving themselves?
3. Does the data source actually differentiate the pages? (judgment)

Mechanics are the script's job. Judgment is yours.

## Commands

python3 pp_scan.py <sitemap-url-or-local-file> [--pattern /tools/] [--sample 8] [--json]

Code

- `pp_scan.py` groups sitemap URLs into pattern families (e.g.
  `/tools/<slug>`), samples pages per family, and measures: unique content
  share (top-1 Jaccard), body word counts, canonical self-reference,
  noindex directives, internal link density, and URL rule violations.
- `--pattern` limits the scan to URLs containing that substring. Use it when
  you already know which pattern family you care about.
- `--json` prints machine-readable output for larger scripts.
- Local sitemap files work too: `python3 pp_scan.py sitemap.xml`, which
  makes the scan fully replayable.

## What the scanner measures

| Signal | How | Gate |
|--------|-----|------|
| Unique content | Top-1 Jaccard similarity between sampled pages of one pattern | PASS ≥40%; FLAG <40%; HARD STOP <30% |
| Body depth | Word count of visible body text (headers/nav/footer excluded) | FLAG <300 words |
| Canonical | `rel=canonical` present and self-referencing | Report % |
| Indexing | `noindex` inside sitemap-scoped pages | Report count |
| Internal links | Links to same host per page | Score vs 3-5/1000 words |
| URL rules | Query strings, >100 chars, uppercase, duplicate slugs | No-violation target |

The scanner excludes `<header>`, `<nav>`, `<footer>`, `<aside>`, `<script>`,
`<style>` from body text and link counting, so shared boilerplate does not
feed the similarity numbers.

## What the scanner cannot see (judgment tasks)

| Not observable from outside | Why |
|------------------------------|-----|
| Data source row quality | Rows, uniqueness, missing values live behind the CMS |
| Data freshness | Stale input produces stale pages |
| Review pipeline | Whether someone reads pages before publish |
| Standalone value | "Worth publishing if no sibling existed" |
| Locale duplication intent | hreflang mirrors vs duplicate content - use `codex-seo-hreflang` |

The output prints these as `n/a` on purpose: a score for a number you did
not measure is a lie.

## Quality gates (scaled content abuse, 2024-2026)

Google's Scaled Content Abuse policy came into force March 2024 and
enforcement escalated through 2025 (manual actions in June 2025, stronger
SpamBrain detection reported from August 2025). Google reported a 45%
reduction in low-quality, unoriginal content in its results after the
policy began. The gates below come from that context and from the
open-source methodology this skill adapts:

| Metric | Threshold | Action |
|--------|-----------|--------|
| Pages without content review | 100+ | WARNING: audit before relying on scale |
| Pages without justification | 500+ | HARD STOP: explicit approval required |
| Unique content per page | <40% | FLAG as thin content (penalty risk) |
| Word count per page | <300 | FLAG for review (may lack value) |

Hardened values for high-volume sites: treat <30% unique content as a HARD
STOP, keep 5-10% human review of generated pages, and publish in batches of
50-100 pages with 2-4 weeks of indexing/ranking observation before scaling
further.

### Safe patterns (work at scale)

- Integration pages with real setup docs, API details, screenshots
- Template/tool pages with downloadable content and usage instructions
- Glossary pages: 200+ word definitions with examples and related terms
- Product pages with unique specs, reviews, comparison data
- Data-driven pages with unique per-record statistics and analysis

### Penalty-risk patterns (avoid at scale)

- Location pages that only swap the city name in identical text
- "Best tool for [industry]" without industry-specific value
- "[Competitor] alternative" without real comparison data
- AI-generated pages without human review and unique value-add
- Pages where >60% of content is shared template boilerplate

## Uniqueness calculation

unique content % = (words unique to this page) / (total words on page) × 100

Code

Measured against all other pages in the same template family. Shared
headers, footers and navigation are excluded. Template boilerplate text is
INCLUDED: identical paragraphs count against you even when they look
harmless.

## URL rules

- Lowercase, hyphenated slugs derived from the data
- No duplicate slugs; enforce uniqueness at generation time
- Keep URLs under 100 characters
- No query parameters for primary content URLs
- Consistent trailing slash usage (match the site's existing pattern)
- Every programmatic page carries a self-referencing canonical

## Internal linking

- Hub/spoke: category hub pages link to every page in their family
- Related items: auto-link 3-5 pages sharing a data attribute
- Breadcrumbs: BreadcrumbList schema from the URL hierarchy
- Anchor text: descriptive and varied, never the same exact-match string
- Density: 3-5 internal links per 1000 words

## Sitemap integration

- Split at 50,000 URLs or 50MB uncompressed per file, whichever comes first
- Use a sitemap index when the set needs multiple files
- `<lastmod>` reflects the real data update time, never generation time
- Exclude noindexed pages; register the sitemap in robots.txt
- Regenerate as records are added to the data source

## Index bloat prevention

- Noindex low-value pages instead of keeping them indexed
- Canonicalize true duplicates and low-value filtered views to their base
- For >10k programmatic URLs, monitor crawl stats in Search Console
- Merge records with insufficient data into aggregated pages instead of
  one page each
- Monthly check: indexed count vs intended count

## Scoring

The script scores five measurable categories, each 0-100:

| Category | How the score is computed |
|----------|---------------------------|
| URL Structure | 100 minus 30×query-fraction, 30×long-fraction, 20×uppercase, 20 if duplicate slugs |
| Template Uniqueness | = average top-1 unique % (0-100) |
| Thin Content Risk | 100 minus 35 per sampled-page share under 300 words, minus 45 if unique <40%, minus 60 if <30% |
| Internal Linking | min(100, actual links ÷ (words/1000 × 4) × 100) |
| Index Management | 100 minus 30 per non-self-canonical share, 30 per noindex share, 30 per lastmod-missing share |

Total = mean of the measured categories. Data Quality is always `n/a` -
the data source is not observable from the public web, and no score is
printed for it.

## Output contract

1. Pattern inventory: every URL family with 5+ pages, counts sorted.
2. Per-pattern measurements with gate notes (PASS / FLAG / HARD STOP /
   WARNING / NOTE).
3. URL rule check over the full set.
4. Six-category scorecard with TOTAL.
5. Recommendations, ranked CRITICAL / HIGH / MEDIUM / LOW, each with the
   evidence line it came from.

## Error handling

| Scenario | Action |
|----------|--------|
| Sitemap fetch fails | Report the error and URL; confirm robots.txt cites the sitemap |
| No repeating pattern found | The site has no generated page family; stop and say so |
| Pattern too small to sample | Report it in the inventory, skip measurement |
| Page fetch fails (SSL EOF, 403, timeout) | Record the error per URL, continue; report under-sampling |
| <30% unique content | Print the HARD STOP gate; require user approval before further analysis |
| Non-Latin script pages | Skip the word-count gate; similarity within the same language group is still valid |

~~~~

B. Save this as ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py:

~~~~python
#!/usr/bin/env python3
"""
pp_scan.py - programmatic page scanner (zero dependencies, stdlib only)

Reads a sitemap, groups its URLs into generated-page patterns (the repeated
URL shapes that hold thousands of pages), samples a few pages per pattern,
and measures the signals the programmatic-SEO quality gates depend on:

  - unique content share (Jaccard similarity between sampled pages)
  - word counts of the content body (boilerplate excluded)
  - canonical self-reference and noindex directives
  - internal link density
  - URL rule violations (long URLs, uppercase, query strings, duplicate slugs)

The scanner measures. The model judges: data-source quality, freshness, and
the rollout plan are not observable from the outside and stay human
decisions. The output lists what was measured, what it means against the
thresholds in the SKILL.md, and what needs a human.

Usage:
  python3 pp_scan.py https://example.com/sitemap.xml
  python3 pp_scan.py https://example.com/sitemap.xml --pattern /tools/
  python3 pp_scan.py smoke-sitemap.xml --pattern /tools/
  python3 pp_scan.py --json https://example.com/sitemap.xml
"""
import argparse
import gzip
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser

UA = ("Mozilla/5.0 (compatible; codex-seo-programmatic/1.0; "
      "+https://auspia.ai/blog/codex-seo-programmatic)")
SKIP_TAGS = {"script", "style", "noscript", "svg", "header", "nav", "footer",
             "aside", "iframe", "form"}
CAP = 6_000_000  # per-document byte cap

# --- fetching ---------------------------------------------------------


def fetch(url, timeout=25):
    """Return decoded text of url. Local paths and file:// URLs work too."""
    if url.startswith(("http://", "https://")):
        pass
    else:
        path = url[len("file://"):] if url.startswith("file://") else url
        with open(urllib.request.url2pathname(path),
                  encoding="utf-8", errors="replace") as fh:
            return fh.read()
    req = urllib.request.Request(url, headers={
        "User-Agent": UA, "Accept-Encoding": "gzip"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        raw = r.read(CAP)
        if r.headers.get("Content-Encoding") == "gzip":
            raw = gzip.decompress(raw)
        return raw.decode("utf-8", "replace")


def sitemap_urls(xml):
    """Return list of (url, lastmod_or_None) from a sitemap document."""
    entries = re.findall(r"<url>(.*?)</url>", xml, re.S)
    if entries:
        out = []
        for block in entries:
            loc = re.search(r"<loc>(.*?)</loc>", block, re.S)
            mod = re.search(r"<lastmod>(.*?)</lastmod>", block, re.S)
            if loc:
                out.append((loc.group(1).strip(),
                            mod.group(1).strip() if mod else None))
        return out
    return []


def load_sitemap(target, seen=None):
    """Fetch a sitemap URL, expanding one level of sitemap index files."""
    seen = seen or set()
    if target in seen:
        return []
    seen.add(target)
    xml = fetch(target)
    if "<sitemapindex" in xml[:4000] or ("<sitemap>" in xml
                                         and "<urlset" not in xml[:4000]):
        urls = []
        for m in re.finditer(r"<sitemap>(.*?)</sitemap>", xml, re.S):
            loc = re.search(r"<loc>(.*?)</loc>", m.group(1), re.S)
            if loc and loc.group(1).strip().endswith(".xml"):
                urls += load_sitemap(loc.group(1).strip(), seen)
        return urls
    return sitemap_urls(xml)


# --- URL pattern derivation -------------------------------------------

UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
                     r"[0-9a-f]{4}-[0-9a-f]{12}", re.I)
HEX_RE = re.compile(r"[0-9a-f]{16,}", re.I)
DIGITS_RE = re.compile(r"^\d+$")
# Known language prefixes stay literal so every locale gets its own
# (same language) pattern group instead of being merged by the slug rule.
LOCALES = {"en", "ja", "ko", "zh", "zh-hans", "zh-hant", "pt", "pt-br",
           "ru", "he", "ar", "th", "vi", "uk", "tr", "pl", "ms", "it", "id",
           "hi", "fr", "es", "de", "nl", "sv", "da", "fi", "nb", "no", "cs",
           "sk", "hu", "ro", "bg", "el", "fa", "et", "lv", "lt", "sl",
           "hr", "ca", "az", "sr", "sw"}


def segment_key(seg):
    """Classify one path segment: template word, number, or produced slug."""
    seg = seg.strip("/")
    if not seg:
        return ""
    if DIGITS_RE.match(seg) or UUID_RE.fullmatch(seg) or HEX_RE.fullmatch(seg):
        return "#"
    if seg.lower() in LOCALES:
        return seg.lower()
    if "-" in seg or len(seg) > 20:
        return "<slug>"
    return seg.lower()


def pattern_of(path):
    """Normalize a URL path into a pattern key like /tools/<slug>."""
    path = path.split("?")[0].rstrip("/")
    segs = [segment_key(s) for s in path.split("/") if s]
    return "/" + "/".join(s for s in segs if s)


def collapse_small_groups(raw_groups, min_count):
    """Re-key single-word-slug groups: /tools/xenonlens -> /tools/<slug>."""
    groups = {}
    small = {}
    for key, items in raw_groups.items():
        if len(items) >= min_count:
            groups[key] = items
        else:
            small[key] = items
    for key, items in small.items():
        if len(key.split("/")) < 2:
            continue
        rebuilt = key.split("/")
        rebuilt.pop()
        rebuilt.append("<slug>")
        groups.setdefault("/".join(rebuilt), []).extend(items)
    return groups


def group_patterns(entries):
    """group entries (url, lastmod) into {pattern: [(url, lastmod)]}."""
    raw = {}
    for url, mod in entries:
        parsed = urllib.parse.urlparse(url)
        key = pattern_of(parsed.path)
        if key:
            raw.setdefault(key, []).append((url, mod))
    return collapse_small_groups(raw, 5)


# --- page signals ------------------------------------------------------


def head_signals(html):
    """Regex-extract title, canonical and robots directives from <head>."""
    title = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
    canon = re.search(r'<link[^>]*rel=["\']?canonical["\']?[^>]*>', html, re.I)
    robots = re.findall(r'<meta[^>]*name=["\']robots["\'][^>]*>', html, re.I)
    noindex = False
    for tag in robots:
        m = re.search(r'content=["\']([^"\']*)["\']', tag, re.I)
        if m and "noindex" in m.group(1).lower():
            noindex = True
    href = None
    if canon:
        m = re.search(r'href=["\']([^"\']*)["\']', canon.group(0), re.I)
        if m:
            href = m.group(1)
    return (title.group(1).strip() if title else None,
            href,
            noindex)


class BodyParser(HTMLParser):
    """Visible text, h1 texts and internal link hrefs, boilerplate excluded."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.stack = []
        self.parts = []
        self.h1s = []
        self.links = []
        self._h1 = None

    def _visible(self):
        return not any(t in SKIP_TAGS for t in self.stack)

    def handle_starttag(self, tag, attrs):
        tag = tag.lower()
        if tag not in SKIP_TAGS:
            if tag == "a" and self._visible():
                href = dict(attrs).get("href", "")
                if href:
                    self.links.append(href)
            elif tag == "h1":
                self._h1 = []
        self.stack.append(tag)

    def handle_endtag(self, tag):
        tag = tag.lower()
        if tag == "h1" and self._h1 is not None:
            self.h1s.append(" ".join("".join(self._h1).split()))
            self._h1 = None
        if tag in self.stack:
            while self.stack:
                if self.stack.pop() == tag:
                    break

    def handle_data(self, data):
        if not self._visible():
            return
        self.parts.append(data)
        if self._h1 is not None:
            self._h1.append(data)

    @property
    def text(self):
        return " ".join(self.parts)


def page_signals(html, page_url):
    """Measure one page. Returns a dict of mechanical signals."""
    title, canon_href, noindex = head_signals(html)
    parser = BodyParser()
    parser.feed(html)
    text = parser.text
    tokens = re.findall(r"[0-9A-Za-z]+(?:['’][0-9A-Za-z]+)?", text.lower())
    cjk = len(re.findall(r"[぀-ヿ一-鿿가-힯]", text))
    canon_self = None
    if canon_href:
        pin = urllib.parse.urlsplit(page_url)
        cin = urllib.parse.urlsplit(urllib.parse.urljoin(page_url, canon_href))
        canon_self = (pin.netloc == cin.netloc and
                      pin.path.rstrip("/") == cin.path.rstrip("/"))
    internal = 0
    host = urllib.parse.urlsplit(page_url).netloc
    for h in parser.links:
        h = h.strip()
        if not h or h.startswith(("#", "mailto:", "tel:", "javascript:")):
            continue
        a = urllib.parse.urljoin(page_url, h)
        if urllib.parse.urlsplit(a).netloc == host:
            internal += 1
    return {
        "url": page_url,
        "status_ok": True,
        "title": (title or "")[:90],
        "words": len(tokens),
        "cjk": cjk,
        "h1": parser.h1s,
        "canonical": canon_href,
        "canonical_self": canon_self,
        "noindex": noindex,
        "internal_links": internal,
        "tokens": set(tokens),
    }


def similarity(a, b):
    """Jaccard similarity of two token sets."""
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)


# --- measurement -------------------------------------------------------


def sample_indexes(total, n):
    """Evenly spaced sample indexes across a list."""
    if total <= n:
        return list(range(total))
    step = total / n
    return [min(int(i * step), total - 1) for i in range(n)]


def measure_pattern(pattern, items, args, errors):
    page = {}
    idxs = sample_indexes(len(items), args.sample)
    for i in idxs:
        url = items[i][0]
        try:
            page[i] = page_signals(fetch(url), url)
        except Exception as exc:  # noqa: BLE001 - report and move on
            errors.append(f"{url}: {type(exc).__name__}: {exc}")
    n = len(page)
    if n < 2:
        return None, page, errors
    max_sim = {}
    for i in page:
        sims = [similarity(page[i]["tokens"], page[j]["tokens"])
                for j in page if j != i]
        max_sim[i] = max(sims) if sims else 0.0
    unique = [100.0 * (1 - max_sim[i]) for i in page]
    words = [page[i]["words"] for i in page]
    links = [page[i]["internal_links"] for i in page]
    thin = [page[i] for i in page if page[i]["words"] < 300]
    canon = [page[i]["canonical_self"] for i in page if page[i]["canonical"]]
    met = {
        "pages_sampled": n,
        "avg_words": round(sum(words) / len(words)),
        "min_words": min(words),
        "pages_under_300_words": len(thin),
        "avg_top_1_unique_pct": round(sum(unique) / len(unique)),
        "min_unique_pct": round(min(unique)),
        "cjk_samples": sum(1 for i in page if page[i]["cjk"] > 200),
        "canonical_self_pct": (round(100 * sum(canon) / len(canon))
                               if canon else None),
        "noindex_in_sample": sum(1 for i in page if page[i]["noindex"]),
        "avg_internal_links": round(sum(links) / len(links), 1),
        "thin_pages": [p["url"] for p in thin],
        "low_uniqueness_pages": sorted(
            ({page[i]["url"]: round(max_sim[i] * 100) for i in page}
             .items()), key=lambda kv: -kv[1])[:2],
        "nonlatin_caveat": [i for i in page if page[i]["cjk"] > 200],
    }
    return met, page, errors


# --- url rules and scores ----------------------------------------------


def url_rules(groups, host):
    """URL rule violations, count and duplicate slugs judged per pattern."""
    q = lng = up = 0
    total = 0
    dups = 0
    trailing = set()
    for pattern, items in groups.items():
        slugs = {}
        for url, _ in items:
            parsed = urllib.parse.urlsplit(url)
            path = parsed.path
            total += 1
            if parsed.query:
                q += 1
            if len(url) > 100:
                lng += 1
            if any(c.isupper() for c in path):
                up += 1
            trailing.add(path.endswith("/"))
            last = path.rstrip("/").split("/")[-1].lower()
            slugs[last] = slugs.get(last, 0) + 1
        dups += sum(1 for v in slugs.values() if v > 1)
    return {
        "patterns_checked": len(groups),
        "with_query": q, "over_100_chars": lng, "uppercase_path": up,
        "duplicate_slugs": dups, "mixed_trailing_slash": len(trailing) > 1,
        "pct": {"query": 100 * q / total if total else 0,
                "long": 100 * lng / total if total else 0,
                "upper": 100 * up / total if total else 0},
    }


def score(met, rules, sitemap_no_lastmod_pct):
    """Convert measured signals to the six-category scorecard."""
    url = max(0, 100 - round(30 * (rules["pct"]["query"] / 100)
                             + 30 * (rules["pct"]["long"] / 100)
                             + 20 * (rules["pct"]["upper"] / 100)
                             + 20 * (rules["duplicate_slugs"] > 0)))
    if met is None:
        return {"Data Quality": "n/a", "URL Structure": url,
                "Template Uniqueness": "n/a", "Thin Content Risk": "n/a",
                "Internal Linking": "n/a", "Index Management": "n/a"}
    uni = met["avg_top_1_unique_pct"]
    thin = 100
    under = met["pages_under_300_words"]
    if met["nonlatin_caveat"]:
        under = 0  # word gate not applicable to non-Latin token counts
    thin -= 35 * (under / met["pages_sampled"])
    if uni < 40:
        thin -= 45
    if uni < 30:
        thin -= 60
    thin = max(0, round(thin))
    expected = 4 * met["avg_words"] / 1000 if met["avg_words"] else 1
    link = min(100, round(100 * met["avg_internal_links"] / max(1, expected)))
    idx = 100
    if met["canonical_self_pct"] is not None:
        idx -= round(30 * (100 - met["canonical_self_pct"]) / 100)
    idx -= 30 * (met["noindex_in_sample"] / met["pages_sampled"])
    if sitemap_no_lastmod_pct is not None:
        idx -= round(30 * sitemap_no_lastmod_pct / 100)
    idx = max(0, round(idx))
    return {"Data Quality": "n/a", "URL Structure": url,
            "Template Uniqueness": uni, "Thin Content Risk": thin,
            "Internal Linking": link, "Index Management": idx}


# --- gates -------------------------------------------------------------

GATE_UNIQUE_FLAG = 40    # original quality gate: below 40% -> thin
GATE_UNIQUE_STOP = 30    # hardening recommendation: below 30% -> hard stop
GATE_WORDS = 300         # original quality gate: below 300 words -> review


def gates_of(pattern, count, met):
    """Apply the quality gates in the SKILL.md. Returns list of gate notes."""
    out = []
    if count >= 100:
        out.append("WARNING pattern size %d pages - confirm a staged rollout "
                   "and a review sample before relying on this pattern"
                   % count)
    if met is None:
        out.append("PATTERN NOT SAMPLED (fetch errors) - treat all gates "
                   "as unresolved")
        return out
    if count >= 100 and met["pages_sampled"] < 5:
        out.append("WARNING sample smaller than 5 - inconclusive gates")
    if met["nonlatin_caveat"]:
        out.append("NOTE %d sampled pages show non-Latin script (CJK/kana/"
                   "hangul): word counts are token counts and undercount. "
                   "Words gate skipped; similarity within this language "
                   "group is still comparable."
                   % len(met["nonlatin_caveat"]))
    elif met["pages_under_300_words"]:
        out.append("FLAG %d/%d sampled pages under %d words (review list: %s)"
                   % (met["pages_under_300_words"], met["pages_sampled"],
                      GATE_WORDS, "; ".join(met["thin_pages"][:2])))
    if met["avg_top_1_unique_pct"] < GATE_UNIQUE_STOP:
        out.append("HARD STOP avg top-1 unique content %.0f%% < %d%% - the "
                   "template reproduces itself; do not publish more of this "
                   "pattern"
                   % (met["avg_top_1_unique_pct"], GATE_UNIQUE_STOP))
    elif met["avg_top_1_unique_pct"] < GATE_UNIQUE_FLAG:
        out.append("FLAG avg top-1 unique content %.0f%% < %d%% - thin "
                   "content risk; differentiate pages before scaling"
                   % (met["avg_top_1_unique_pct"], GATE_UNIQUE_FLAG))
    else:
        out.append("PASS unique content %.0f%% - gate satisfied"
                   % met["avg_top_1_unique_pct"])
    return out


# --- report ------------------------------------------------------------

SEPARATOR = "-" * 62


def cross_pattern_duplicates(patterns):
    """Slug names appearing in more than one pattern (locale mirrors)."""
    seen = {}
    for pattern, items in patterns:
        if len(items) < 5:
            continue
        for url, _ in items:
            last = urllib.parse.urlsplit(url).path.rstrip("/")
            last = last.split("/")[-1].lower()
            seen.setdefault(last, set()).add(pattern)
    return sum(1 for v in seen.values() if len(v) > 1)


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("sitemap", help="sitemap URL or local .xml path")
    ap.add_argument("--pattern", default=None,
                    help="only URLs containing this substring (e.g. /tools/)")
    ap.add_argument("--sample", type=int, default=8,
                    help="pages fetched per pattern (default 8)")
    ap.add_argument("--max-fetch", type=int, default=24,
                    help="hard cap on fetched pages (default 24)")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args(argv)

    errors = []
    try:
        entries = load_sitemap(args.sitemap)
    except Exception as exc:  # noqa: BLE001
        print("SITEMAP FETCH FAILED: %s: %s" % (type(exc).__name__, exc))
        return 2
    if not entries:
        print("SITEMAP EMPTY or unparseable: %s" % args.sitemap)
        return 2
    if args.pattern:
        filtered = [(u, m) for u, m in entries if args.pattern in u]
        if not filtered:
            print("NO URLS MATCH --pattern %s (sitemap has %d URLs)"
                  % (args.pattern, len(entries)))
            return 2
        entries = filtered
    total = len(entries)
    groups = group_patterns(entries)
    if not groups:
        print("NO REPEATING URL PATTERN FOUND in %d URLs - nothing here "
              "looks generated at scale" % total)
        return 2

    tested = []
    fetched = 0
    patterns = sorted(groups.items(), key=lambda kv: -len(kv[1]))
    for pattern, items in patterns:
        if fetched >= args.max_fetch or len(items) < 5:
            continue
        met, pages, errs = measure_pattern(pattern, items, args, errors)
        fetched += len(pages)
        if met is None and not pages:
            continue
        tested.append((pattern, items, met, pages))

    sitemap_no_lastmod = sum(1 for _, m in entries if not m)
    sitemap_pct = 100 * sitemap_no_lastmod / total if total else None
    rules = url_rules(groups, urllib.parse.urlsplit(entries[0][0]).netloc)

    if args.json:
        return json_report(entries, total, groups, tested, rules,
                           sitemap_pct, errors)

    print("Programmatic scan: %s" % args.sitemap)
    if args.pattern:
        print("  filter --pattern %s | URLs in scope: %d" % (args.pattern,
                                                             total))
    print("  sitemap: %d URLs | %d%% lack <lastmod>"
          % (total, round(sitemap_pct or 0)))
    print(SEPARATOR)
    print("URL pattern inventory (count >= 5):")
    sampled_keys = {p for p, _, _, _ in tested}
    for pattern, items in patterns:
        if len(items) < 5:
            continue
        print("  %-34s %5d pages%s" % (pattern, len(items),
                                       "   (sampled)" if pattern in
                                       sampled_keys else ""))
    print(SEPARATOR)
    per_pattern = {}
    for pattern, items, met, _ in tested:
        per_pattern[pattern] = met
        print("Pattern: %s (%d pages)" % (pattern, len(items)))
        if met["nonlatin_caveat"]:
            print("  non-Latin script detected; word counts are token "
                  "counts, see note below")
        print("  sampled:      %d pages evenly spaced over %d"
              % (met["pages_sampled"], len(items)))
        print("  avg body words:      %d   (min %d, pages < %d words: %d)"
              % (met["avg_words"], met["min_words"], GATE_WORDS,
                 met["pages_under_300_words"]))
        print("  avg top-1 unique:  %d%%   (min %d%%)"
              % (met["avg_top_1_unique_pct"], met["min_unique_pct"]))
        print("  canonical self-ref:  %s"
              % (str(met["canonical_self_pct"]) + "%"
                 if met["canonical_self_pct"] is not None else "no canonicals"))
        print("  noindex in sample:   %d" % met["noindex_in_sample"])
        print("  avg internal links:  %.1f" % met["avg_internal_links"])
        for gate in gates_of(pattern, len(items), met):
            print("  [GATE] %s" % gate)
        print()
    print(SEPARATOR)
    print("URL rule check over all %d URLs:" % total)
    print("  query strings: %d | >100 chars: %d | uppercase in path: %d"
          % (rules["with_query"], rules["over_100_chars"],
             rules["uppercase_path"]))
    print("  duplicate end-slugs across %d patterns (within-pattern): %d"
          % (rules["patterns_checked"], rules["duplicate_slugs"]))
    print("  mixed trailing slash: %s" % rules["mixed_trailing_slash"])
    cross = cross_pattern_duplicates(patterns)
    if cross:
        print("  cross-pattern duplicate slugs: %d (locale mirrors of the "
              "same page count here; judge, don't penalize)"
              % cross)
    print(SEPARATOR)
    print("Programmatic score (mechanics only; Data Quality is a human "
          "call - the data source lives behind the CMS):")
    for i, (pattern, items, met, _) in enumerate(tested):
        if i:
            print()
        sc = score(met, rules, sitemap_pct)
        print("  %s" % pattern)
        for k, v in sc.items():
            print("    %-22s %s" % (k, v))
        vals = [v for v in sc.values() if isinstance(v, (int, float))]
        if vals:
            print("    TOTAL (mean of measured): %d/100"
                  % round(sum(vals) / len(vals)))
    print(SEPARATOR)
    if errors:
        print("Fetch errors (%d):" % len(errors))
        for e in errors[:6]:
            print("  " + e)
    return 0


def json_report(entries, total, groups, tested, rules, sitemap_pct, errors):
    payload = {
        "sitemap_url": None,
        "total_urls": total,
        "url_rules": rules,
        "sitemap_no_lastmod_pct": sitemap_pct,
        "patterns": {},
        "fetch_errors": errors,
    }
    for pattern, items, met, _ in tested:
        payload["patterns"][pattern] = {
            "count": len(items),
            "gate": gates_of(pattern, len(items), met),
            "metrics": met,
            "score": score(met, rules, sitemap_pct),
        }
    json.dump(payload, sys.stdout, indent=1, ensure_ascii=False)
    return 0


if __name__ == "__main__":
    sys.exit(main())

~~~~

C. Save this as ~/.codex/skills/codex-seo-programmatic/fixtures/smoke.xml:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://auspia.ai/tools/landing-page-diagnostic</loc></url>
<url><loc>https://auspia.ai/tools/website-seo-score-checker</loc></url>
<url><loc>https://auspia.ai/tools/ai-search-visibility-checker</loc></url>
<url><loc>https://auspia.ai/tools/llms-txt-generator-checker</loc></url>
<url><loc>https://auspia.ai/tools/robots-txt-ai-crawler-checker</loc></url>
<url><loc>https://auspia.ai/tools/ai_overview</loc></url>
<url><loc>https://auspia.ai/tools/one-page-geo-citation-audit</loc></url>
<url><loc>https://auspia.ai/tools/best-tools-list-debiaser</loc></url>
<url><loc>https://auspia.ai/tools/google-index-checker</loc></url>
<url><loc>https://auspia.ai/tools/http-status-code-checker</loc></url>
<url><loc>https://auspia.ai/tools/on-page-seo-audit</loc></url>
<url><loc>https://auspia.ai/tools/googlebot-spider-simulator</loc></url>
</urlset>

Then run:
  mkdir -p ~/.codex/skills/codex-seo-programmatic/scripts
  mkdir -p ~/.codex/skills/codex-seo-programmatic/fixtures
  python3 --version
  # place the files as above, then:
  python3 ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py \
    ~/.codex/skills/codex-seo-programmatic/fixtures/smoke.xml

Answers I need from you:
1. Did you place all three files? Report the byte counts of each.
2. Does the self-test output show one pattern, 12 pages, and URL Structure 100?
3. If the smoke test fails, report the exact error, do not try to fix it yourself.
4. Keep the skill in ~/.codex/skills/codex-seo-programmatic. Do not touch anything else in my skills directories.

FAQ

What counts as "programmatic" in the first place? Anything generated from a data source through a template: tools, city pages, integration pages, glossary terms, product cards. The scanner does not decide this for you; it detects repetitions and lets the numbers speak.

Is the similarity number a plagiarism detector? No. It measures how much of one page's visible token set appears in another, restricting the boilerplate. A 40% unique page may still be perfectly publishable. The gate is a decision support signal, not a verdict.

Why 300 words? It comes from the original methodology's thin-content threshold: a page under 300 words of content body rarely holds enough for the search demand it was built for, at scale. For tool pages and glossary entries, apply judgment and check the note.

My pages are translations of a single English set. Will this blow up the gates? No. Locale patterns are separated by design; the scanner only compares within one language group. The duplication report is informational and explicitly says so.

The lastmod line is missing. Must I add it? The protocol says lastmod means the real update time of the content, not the generation timestamp. If a generator writes the current time into every URL, it defeats the purpose, and you should just avoid it. If it is genuinely missing, the update will show in indexing behavior, not in a gate.

Is this a free tool? It edits nothing, owns no API keys, and makes one GET per page with a hard budget limit. A full audit of a large site costs nothing but time, because the sample cap applies within a scan. Richer signals (live SERPs, index counts) would need a paid SEO API; several skills in this series are built around that, and this one deliberately is not.

The skill asks the only question worth asking about a template: what does this page add that its sibling does not? A 4,354-URL sitemap answers that question 4,354 times, and the answer is usually the same. Audit before scale, and scale the pages that survive.

Next in the series (post 17 of 20): [How to Set Up Codex for Competitor Comparison Pages (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-competitor-pages) - source-backed competitor comparison pages.

Previous in the series: How to Diagnose Search Experience Optimization with Codex (Full SKILL.md Included). The full series roadmap lists all 20 posts.

Author: Daniel Cross, Programmatic SEO Architect for 50k+ Page Systems at Auspia. Daniel writes about scaled page systems, index management, and templates that hold up at volume.

Based on the open-source [claude-seo](https://github.com/AgriciDaniel/claude-seo) project (MIT license, AgriciDaniel). This article customizes the programmatic SEO skill for the Codex runtime and keeps the original methodology: the quality gates and threshold values, the uniqueness calculation, the URL pattern and canonical rules, and the sitemap integration contract. The scanner and every line of it are written from scratch for this article.

Explore this topic

Keep following the same growth thread