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
---
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
#!/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.
mkdir -p ~/.codex/skills/codex-seo-programmatic/scripts \
~/.codex/skills/codex-seo-programmatic/fixturesSave the three code blocks above as:
~/.codex/skills/codex-seo-programmatic/SKILL.md~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py~/.codex/skills/codex-seo-programmatic/fixtures/smoke.xml(save the 12-URL fixture below)
<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:
python3 ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py \
~/.codex/skills/codex-seo-programmatic/fixtures/smoke.xmlExpected in the output:
URL pattern inventorylists exactly one pattern,/tools/<slug>, with 12 pages- one gate line and one scorecard appear for that pattern
URL Structure 100and no rule violations
Then point it at your own site:
python3 ~/.codex/skills/codex-seo-programmatic/scripts/pp_scan.py https://yourdomain.com/sitemap.xmlIf 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
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
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

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.
- 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. - 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.
- 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 |
|---|---|---|
| Wrong path or a 403; check robots.txt | Confirm the Sitemap line in robots.txt; run with the exact URL from the site |
| 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 | Fewer than 5 URLs in the family | Below the reporting threshold; the family is not part of the programmatic scale problem |
| 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 |
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.
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]
- `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 |
~~~~
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.



