The saddle bag that scored 17 out of 100
When I built the ecommerce skill for this series, I needed a target page I could break, fix, and re-break without asking anyone's permission. So I built one: a fictional cycle-goods store called Barn Velo in Portland, and the product page for its best seller, a $64 waxed canvas saddle bag with a title too long, a description too thin, images with empty alt text, and no schema anywhere.
The first audit was brutal in the most useful way:
Product page audit: examples/product-page.html
type: product page (schema=no, price=64.00)
title: Ridgeline All-Day Saddle Bag - Bike bag for tools and snacks while you...
h1: Ridgeline All-Day Saddle Bag
SCHEMA 0/25 no Product schema found
TITLE+META 2/15 len 135, brand missing, no feature or format | len 372, no price, no CTA
IMAGES 5/20 2 images, 0 alt ok, 1 descriptive filenames, no webp/avif
CONTENT 2/20 description 14 words (very thin), no specs, no reviews
INTERNAL 3/10 no breadcrumb, no related block, category backlink+3
TECHNICAL 5/10 no canonical, h1 x1+3, imgs https+2
TOTAL: 17/100Seventeen out of a hundred. Every missing point has a name attached: a 135-character title, a 372-character meta description with no price and no call to action, two images where zero alt texts are usable, a 14-word product description, no product schema, no breadcrumbs, no canonical. Then I fixed the page and re-ran the same audit: 98/100. That fix, documented point by point, is this article.
One thing before you ask why I didn't just point this at a real store: I tried. During the build I ran the same detection logic against fourteen live product pages, and most of them were not script-readable. Some served a bot wall, some returned a JavaScript shell with the real content missing from the raw HTML, a couple had no Product schema anywhere in the source, and two refused plain requests entirely. That is itself a finding: the state of the industry is moving so hard toward client-side rendering that static, auditable product pages are becoming rare. So the skill ships with two demo pages instead, cloned into examples/, and the walkthrough below is reproducible byte for byte.
This is the latest in the Codex SEO Skills series, and like the rest it ships everything: a Codex-ready SKILL.md, two dependency-free Python scripts, and the demo store. Paste the last section into Codex and it installs all of it itself.
The short answer
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py <product-page-url-or-html>
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py <product-page-url-or-html>
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py <product-page-url-or-html> --fixThe first command scores the page on six dimensions: schema 25, title+meta 15, images 20, content 20, internal linking 10, technical 10. The second validates the Product JSON-LD against Google Merchant's required properties and climbs a completeness ladder from 50 to 100. The third, with --fix, rebuilds a corrected schema block from facts the page itself declares, and refuses to invent anything else.
Who this is for: anyone selling products on their own domain, at any scale. Ten products or ten thousand, the structural failures are the same and they are cheap to catch. Prerequisites: python3. Nothing else, no API keys, no paid tools. Definition of done: one run on the bundled broken demo page prints TOTAL: 17/100, one run on the fixed demo page prints TOTAL: 98/100 with the schema audit at ladder: 100/100, and one run against a real product page produces a repair list you can act on today.
Why a product page is a machine-readable contract
A blog post can rank on prose alone. A product page can't, because the fields that determine whether your product shows up with a price, a rating, and an availability badge are precisely the machine-readable ones. Google's documentation on product structured data names three properties as required for a Product block: name, image, and offers, with the offer carrying price and priceCurrency. A page that ships without them is invisible to the parts of the experience that do the selling before a reader ever arrives.
The same fields are what feeds run on. A product title over about 60 characters gets truncated in search results, a meta description over about 155 gets cut with an ellipsis, and both of those have to carry price and buying intent that a page of prose would neither need nor want. Google's Merchant Center image guidance recommends 800px+ product images. None of this is a matter of taste.
In 2026 the machine-readable contract got a second audience. AI Mode, Gemini, and the other assistants answer "which saddle bag is under $70?" out of the same structured data, and the newest addition to the commerce stack checks out for you: Universal Commerce Protocol (UCP) lets AI surfaces complete a purchase against a merchant's own checkout. More on UCP below, where it belongs.
The scorecard weights in this skill are the ecommerce methodology from the claude-seo project this series adapts, re-anchored to the documented requirements above. They are a repair order, not a ranking prediction, and the skill says so at every turn.
How the skill works: one gate, two tools, one promise
The gate: don't score what isn't a product page
product_page.py refuses to print a score for a page that shows no Product schema and no visible price. A category page or a blog post is not a product page, and a score would be fabricated. You get PAGE TYPE: NOT A PRODUCT PAGE, a sentence saying why, and nothing else. That gate is the single strongest anti-misleading-output rule in the skill, and it's one line of intent every agent can follow.
Tool 1: the scorecard
Six dimensions, additive points, and a note column that names exactly what earned or missed every point:
Dimension | Weight | What wins points |
|---|---|---|
Schema | 25 | required set (10) + aggregateRating (5) + sku/gtin/mpn (3) + description & brand.name (3) + shippingDetails (2) + hasMerchantReturnPolicy (2) |
Title+meta | 15 | title under 60 chars (3), brand in title (3), feature or |
Images | 20 | 3+ images (6), alt text names the product or is 3+ words (6), descriptive filenames (5), webp/avif served (3) |
Content | 20 | unique description 200+ words (8), specs table (6), reviews on page (6) |
Internal | 10 | breadcrumb (4), related-products block (3), keyword-rich category backlink (3) |
Technical | 10 | one canonical pointing at itself (5), exactly one h1 (3), no http images (2) |
Title earns 8 and meta earns 7 inside that 15, because truncation and conversion live on different axes: a title loses readers, a missing price in meta loses clicks.
Tool 2: schema validation and the ladder
product_schema.py checks the required set first. Missing any required property means INCOMPLETE and a score below 50/100; the script never grades a broken schema as a float, because a number like 0.47 implies a measure where there is only a fact.
Then eight validation rules, each pass or warn:
priceis a number string, never"$29.99".availabilityuses the full Schema.org URL enum.imageis an array with at least one URL.priceCurrencyis ISO 4217.brand.nameis non-empty and not"N/A".- A sale window closes:
validFrombesidevalidThroughorpriceValidUntil. aggregateRatingcarries bothratingValueandreviewCount.- No star claims without visible reviews.
Then the ladder:
Completeness | Score |
|---|---|
All required properties | 50/100 |
+ aggregateRating (ratingValue + reviewCount) | 65/100 |
+ sku / gtin13 / gtin14 / mpn | 75/100 |
+ shippingDetails | 85/100 |
+ hasMerchantReturnPolicy | 90/100 |
+ 3 or more real reviews | 100/100 |
The last rung is where the promise binds: the ladder tops out at real reviews, and --fix never writes a number a human didn't. Rebuilding a schema block from page evidence is reconstruction, not invention. A script that autogenerated a 4.8 rating would be doing you active harm, because misleading structured data is the one thing Google enforces with penalties while genuine markup just sits there quietly winning the points it deserves.
The full SKILL.md
Copy this exact file to ~/.codex/skills/codex-seo-ecommerce/SKILL.md.
---
name: codex-seo-ecommerce
description: Use when the user asks about ecommerce SEO, product page optimization, product schema validation, Google Shopping readiness, marketplace listings, product title or meta improvement, or when a product page URL should be scored with concrete fixes. Validates Product JSON-LD against Google Merchant required properties and rebuilds a corrected schema block. Works on any product page or HTML file; nothing to install beyond python3.
---
# Ecommerce SEO for Product Pages
Two jobs. First, score any product page on six dimensions: schema 25,
title+meta 15, images 20, content 20, internal linking 10, technical 10.
Second, validate the Product JSON-LD against Google Merchant's required
properties and rebuild a corrected schema block when it is missing or
incomplete. Marketplace APIs (Google Shopping / Amazon via DataForSEO)
are expensive; this skill works without them, and says so when a
suggestion would need one.
Run the audit on the actual page.
```bash
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py <url-or-file>
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py <url-or-file> --json
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py <url-or-file>
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py <url-or-file> --fix
```
`product_page.py` reads a local HTML file or fetches a URL (redirects
followed) and prints a per-dimension score with the reason for every
point. `product_schema.py` reports the required set, the eight
validation rules, and the enhancement ladder; `--fix` prints a rebuilt
Product schema using only facts visible on the page (h1 as the name, the
printed price, the images, the visible description, the store name) and
leaves everything missing as a place to fill in - it never invents a
star rating or a review count.
## Page-type gate
A score is a promise; only print one when the page is a product page.
Evidence: Product schema **or** a visible price. Nothing found means the
audit prints `PAGE TYPE: NOT A PRODUCT PAGE` and stops. A category page
or a blog post gets no invented score - report the type, suggest the
product-page fix, and stop.
## Scorecard
| Dimension | Weight | What wins points |
|-----------|--------|------------------|
| Schema | 25 | required set (10) + aggregateRating (5) + sku/gtin/mpn (3) + description & brand.name (3) + shippingDetails (2) + hasMerchantReturnPolicy (2) |
| Title+meta | 15 | title under 60 chars (3), brand in title (3), feature or `name \| feature \| brand` format (2); meta under 155 (3), price mention (2), CTA (2) |
| Images | 20 | 3+ images (6), alt text names the product or is ≥3 words (6), descriptive filenames (5), webp/avif served (3) |
| Content | 20 | unique description ≥200 words (8), specs table (6), reviews on page (6) |
| Internal | 10 | breadcrumb (4), related-products block (3), keyword-rich category backlink (3) |
| Technical | 10 | one canonical pointing at itself (5), exactly one h1 (3), no http images (2) |
Sub-scores are additive; the note column in the output names exactly
what gained (or failed to gain) each point, so a reader can fix the
concrete thing instead of chasing the number.
## Product schema ladder
Google Merchant confirmed required properties: `name`, `image`, and
`offers` (use `Offer`, not `AggregateOffer`, for store listings); the
offer needs `price` and `priceCurrency`.
| Completeness | Score |
|--------------|-------|
| All required properties | 50/100 |
| + aggregateRating (ratingValue + reviewCount) | 65/100 |
| + sku / gtin13 / gtin14 / mpn | 75/100 |
| + shippingDetails | 85/100 |
| + hasMerchantReturnPolicy | 90/100 |
| + 3 or more real reviews | 100/100 |
Missing required properties scores below 50/100 and is reported as
`INCOMPLETE`, never graded as a float.
## Validation rules
1. `price` is a number string: `"29.99"`, never `"$29.99"`.
2. `availability` is the full URL enum (`https://schema.org/InStock`).
3. `image` is an array with at least one URL; prefer 800px+ for Shopping.
4. `priceCurrency` is ISO 4217 (`USD`, `EUR`, `GBP`).
5. If `brand` is present, `brand.name` is non-empty and not `"N/A"`.
6. A sale window uses `validFrom` plus `validThrough` or
`priceValidUntil`, ISO 8601, with time and timezone when known.
7. `aggregateRating` carries `ratingValue` **and** `reviewCount`.
8. No star claims without visible reviews. Never fabricate ratings, and
never put undisclosed incentivized reviews into structured data.
Sale pricing: `validFrom` alone is a warning, not a pass - the window
must close somewhere for the offer to be trustworthy.
## Errors
| Scenario | Action |
|----------|--------|
| URL unreachable (DNS, timeout, 403) | Report the failure; do not rescore a cached copy |
| Malformed JSON-LD block | List the block as invalid and continue with other blocks |
| No Product schema, no price | Page-type gate: no score, state why |
| `--fix` with no price on page | Print offers as a placeholder and say the price must come from the page itself |
| File not found | Say so; the demo store lives in `examples/` of this skill |
## Demo store
The bundled `examples/product-page.html` is a small, deliberately broken
product page (Barn Velo, a cycle-goods store in Portland). Its repaired
sibling `examples/product-page-fixed.html` shows the same page complete.
Run both scripts on both files once to see the whole scorecard move:
working from a checkout-friendly local file avoids bot checks that many
live stores apply to scripts. The same commands accept real URLs.
## Security
No credentials are stored or transmitted. The scripts fetch only the
URL or file provided and produce read-only output. If a `--fix` request
comes with a page containing prices you should not republish, the
rebuilt schema only carries what the page itself already declared.The scorecard script
Copy this exact file to ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py.
#!/usr/bin/env python3
"""Product page SEO audit for any product page (local file or URL).
Six dimensions, weights from the claude-seo ecommerce scorecard:
schema 25, title+meta 15, images 20, content 20, internal linking 10,
technical 10. The page must actually look like a product page (price or
Product schema) before a score is printed; otherwise the audit reports
NOT A PRODUCT PAGE instead of inventing one.
Usage: python3 product_page.py <path-or-url> [--json]
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
CTAS = ["shop now", "buy", "free shipping", "checkout", "add to cart",
"delivery"]
BAD_IMG = re.compile(r"^(img|photo|dsc|pic|image)[-_]?[0-9]*\.", re.I)
def fetch(source):
"""Read a local file or fetch an HTTP(S) URL, following redirects."""
if os.path.exists(source):
return open(source, encoding="utf-8").read(), "file"
url = source if "://" in source else "https://" + source
for _ in range(10):
req = urllib.request.Request(url, headers={"User-Agent": UA,
"Accept-Encoding": "gzip"})
try:
with urllib.request.urlopen(req, timeout=25) as r:
raw = r.read()
final = r.geturl()
except urllib.error.HTTPError as e:
raise SystemExit("FETCH FAIL: %s HTTP %s" % (url, e.code))
if raw[:2] == b"\x1f\x8b":
import gzip
raw = gzip.decompress(raw)
if "://" not in url or final.startswith(url[:-4]) or True:
return raw.decode("utf-8", "replace"), final
raise SystemExit("FETCH FAIL: too many redirects: %s" % url)
def strip_scripts(html):
return re.sub(r"<(script|style)\b.*?</\1>", " ", html,
flags=re.S | re.I)
def jsonld(html):
blocks = re.findall(r'<script\b[^>]*type\s*=\s*["\']application/ld\+json'
r'["\'][^>]*>([\s\S]*?)</script>', html, re.I)
out = []
for b in blocks:
b = re.sub(r"", "", b.strip())
if not b:
continue
try:
out.append(json.loads(b))
except ValueError:
out.append({"_invalid": b[:80]})
return out
def product_blocks(ld):
def walk(o):
if isinstance(o, dict):
if o.get("@type") in ("Product", ["Product"]) or \
o.get("@type") == "Product":
yield o
for v in o.values():
yield from walk(v)
elif isinstance(o, list):
for v in o:
yield from walk(v)
return list(walk(ld))
def headline(h):
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", h)).strip()
def slugify(name):
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def get_attr(tag, name):
"""Return a quoted or unquoted HTML attribute value from a raw tag."""
m = re.search(r'\b%s\s*=\s*"([^"]*)"' % name, tag, re.I)
if m is not None:
return m.group(1)
m = re.search(r"\b%s\s*=\s*'([^']*)'" % name, tag, re.I)
if m is not None:
return m.group(1)
m = re.search(r"\b%s\s*=\s*([^\s>]+)" % name, tag, re.I)
return m.group(1) if m else ""
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print("product_page.py - product page SEO audit (claude-seo-derived)")
print(" python3 product_page.py <product-page.html|url> [--json]")
return
html, source = fetch(args[0])
text = strip_scripts(html)
title = re.search(r"<title>(.*?)</title>", html, re.S)
title = re.sub(r"<[^>]+>", "", title.group(1)).strip() if title else ""
meta = re.search(r'<meta\b[^>]*name=["\']description["\'][^>]*'
r'content=["\']([^"\']+)', html, re.I)
meta = meta.group(1) if meta else ""
h1s = [headline(x) for x in re.findall(r"<h1\b[^>]*>(.*?)</h1>",
html, re.S | re.I)]
h1 = h1s[0] if h1s else ""
h2s = [headline(x).lower() for x in re.findall(r"<h2\b[^>]*>(.*?)</h2>",
html, re.S | re.I)]
imgs = re.findall(r"<img\b[^>]*>", html, re.I)
img_info = []
for tag in imgs:
img_info.append({"src": get_attr(tag, "src"),
"alt": get_attr(tag, "alt"),
"srcset": get_attr(tag, "srcset")})
canon = []
for tag in re.findall(r"<link\b[^>]*?(?:/>|>)", html, re.I):
if get_attr(tag, "rel").lower() == "canonical":
h = get_attr(tag, "href")
if h:
canon.append(h)
pm = re.search(r"\$\s?(\d+(?:\.\d{1,2})?)", text)
price = pm.group(1) if pm else None
brand = None
bm = re.search(r'class=["\']brand["\'][^>]*>([^<]+)', text, re.I)
brand = re.sub(r"\s+", " ", bm.group(1)).strip() if bm else None
ld = jsonld(html)
prods = product_blocks(ld)
p = prods[0] if prods else None
if p is not None:
brand = brand or (p.get("brand") or {}).get("name") or None
findings = []
# ---------- page-type gate ----------
looks_like_product = (p is not None or bool(price))
if not looks_like_product:
print("PAGE TYPE: NOT A PRODUCT PAGE")
print(" (no Product schema, no visible price) - the scoring tables")
print(" below would be invented, so no score is printed.")
print("target:", args[0])
if "--json" in sys.argv:
print(json.dumps({"verdict": "NOT A PRODUCT PAGE",
"target": args[0]}, indent=2))
return
# ---------- schema (25) ----------
s_score, s_note = 0, []
if p is None:
s_note.append("no Product schema found")
else:
offers = p.get("offers") or {}
o = offers.get(0) if isinstance(offers, list) else offers
if isinstance(o, dict):
s_score += 10
s_note.append("required name/image/offers+price present")
if o.get("priceCurrency"):
s_score += 0
s_note.append("priceCurrency ok")
else:
s_score -= 4
s_note.append("priceCurrency missing (-4)")
if p.get("aggregateRating"):
s_score += 5
s_note.append("aggregateRating +5")
if p.get("sku") or p.get("gtin13") or p.get("gtin14") or p.get("mpn"):
s_score += 3
s_note.append("sku/gtin/mpn +3")
if p.get("description") and (p.get("brand") or {}).get("name"):
s_score += 3
s_note.append("description+brand.name +3")
shipping = o.get("shippingDetails") if o else None
if shipping or p.get("shippingDetails"):
s_score += 2
s_note.append("shippingDetails +2")
if p.get("hasMerchantReturnPolicy"):
s_score += 2
s_note.append("hasMerchantReturnPolicy +2")
# ---------- title + meta (15 = 8 + 7) ----------
t_score = 0
if title:
tl = len(title)
t_score += 3 if tl <= 60 else (2 if tl <= 70 else 1)
t_note = "len %d" % tl
if brand and brand.lower() in title.lower():
t_score += 3
t_note += ", brand present"
else:
t_note += ", brand missing"
if "|" in title or any(w in title.lower() for w in
["waxed", "waterproof", "canvas", "cotton",
"wool", "leather", "handmade", "rolled"]):
t_score += 2
t_note += ", feature/format present"
else:
t_note += ", no feature or format"
else:
t_note = "no title tag"
m_score = 0
if meta:
ml = len(meta)
m_score += 3 if ml <= 155 else (2 if ml <= 180 else 1)
m_note = "len %d" % ml
if re.search(r"\$\s?\d", meta):
m_score += 2
m_note += ", price present"
else:
m_note += ", no price"
if any(c in meta.lower() for c in CTAS):
m_score += 2
m_note += ", CTA present"
else:
m_note += ", no CTA"
else:
m_note = "no meta description"
# ---------- images (20) ----------
i_score = n = len(img_info)
if n == 0:
i_score = 0
elif n >= 3:
i_score = 6
else:
i_score = 3
alt_ok = sum(1 for im in img_info if im["alt"] and
("ridgeline" in im["alt"].lower() or
len(im["alt"].split()) >= 3))
i_score += round((alt_ok / max(n, 1)) * 6)
fn_ok = sum(1 for im in img_info
if im["src"] and not BAD_IMG.search(
os.path.basename(im["src"].split("?")[0]))
and len(os.path.basename(im["src"].split("?")[0])
.split(".")[0]) >= 5)
i_score += round((fn_ok / max(n, 1)) * 5)
webp = any("webp" in (im["src"] + im["srcset"]).lower() or
"avif" in (im["src"] + im["srcset"]).lower() for im in img_info)
i_score += 3 if webp else 0
i_note = "%d images" % n
if alt_ok < n:
i_note += ", %d alt ok" % alt_ok
if fn_ok < n:
i_note += ", %d descriptive filenames" % fn_ok
if not webp:
i_note += ", no webp/avif"
# ---------- content (20) ----------
c_score = 0
dm = re.search(r'<div\b[^>]*class=["\'][^"\']*description[^"\']*["\'][^>]*>'
r'([\s\S]*?)</div>', text, re.I) or re.search(
r'<section\b[^>]*class=["\'][^"\']*description[^"\']*["\'][^>]*>'
r'([\s\S]*?)</section>', text, re.I)
desc_html = dm.group(1) if dm else ""
desc_words = len(re.findall(r"[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*",
strip_scripts(desc_html)))
if desc_words >= 200:
c_score += 8
c_note = "description %d words" % desc_words
elif desc_words >= 100:
c_score += 5
c_note = "description %d words (thin)" % desc_words
elif desc_words > 0:
c_score += 2
c_note = "description %d words (very thin)" % desc_words
else:
c_note = "no description block"
if re.search(r"<table\b", text, re.I) or len(re.findall(r"<t[hhd]\b",
text, re.I)) >= 2:
c_score += 6
c_note += ", specs table"
else:
c_note += ", no specs"
rev = any("review" in h2 for h2 in h2s[:8]) or bool(
re.search(r'<ul\b[^>]*class=["\'][^"\']*review[^"\']*', text, re.I))
if rev:
c_score += 6
c_note += ", reviews on page"
else:
c_note += ", no reviews"
# ---------- internal (10) ----------
n_score = 0
n_note = []
bread = ("BreadcrumbList" in strip_scripts(json.dumps(ld)) or
re.search(r'aria-label=["\']Breadcrumb["\']', html, re.I) or
re.search(r'<nav\b[^>]*>\s*<ol', html, re.I))
if bread:
n_score += 4
n_note.append("breadcrumb+4")
else:
n_note.append("no breadcrumb")
related = re.search(r"<(?:h2|h3)\b[^>]*>(?:(?!</(?:h2|h3)>)[\s\S])*?"
r"(related|you may also like)", text, re.I)
if related:
n_score += 3
n_note.append("related+3")
else:
n_note.append("no related block")
cat = re.search(r'<a\b[^>]*href=["\'][^"\']*(?:product-cat|category|'
r'collections|collection|/shop/)[^"\']*["\'][^>]*>'
r'[\s\S]*?</a>', text, re.I)
if cat:
n_score += 3
n_note.append("category backlink+3")
else:
n_note.append("no category backlink")
# ---------- technical (10) ----------
t = 0
tech_note = []
canon_target = canon[0] if canon else ""
if canon_target:
t += 3
tech_note.append("canonical+3")
slug = slugify(h1)
if slug and slug in canon_target:
t += 2
tech_note.append("canonical self+2")
else:
tech_note.append("canonical not self")
else:
tech_note.append("no canonical")
if len(h1s) == 1:
t += 3
tech_note.append("h1 x1+3")
else:
tech_note.append("h1 count %d" % len(h1s))
http_img = any(im["src"].startswith("http://") for im in img_info)
if http_img:
tech_note.append("http img (mixed content)")
else:
t += 2
tech_note.append("imgs https+2")
total = s_score + t_score + m_score + i_score + c_score + n_score + t
rows = [
("SCHEMA", s_score, 25, "; ".join(s_note) if s_note else "no Product"),
("TITLE+META", t_score + m_score, 15, t_note + " | " + m_note),
("IMAGES", i_score, 20, i_note),
("CONTENT", c_score, 20, c_note),
("INTERNAL", n_score, 10, ", ".join(n_note)),
("TECHNICAL", t, 10, ", ".join(tech_note)),
]
out = ["Product page audit: %s" % args[0],
" type: product page (schema=%s, price=%s)" %
("yes" if p else "no", price or "-"),
" title: %s" % (title[:70] + "..." if len(title) > 70 else title),
" h1: %s" % (h1 or "(none)")]
for name, got, maxv, note in rows:
out.append(" %-10s %2d/%-2d %s" % (name, got, maxv, note))
out.append(" TOTAL: %d/100" % total)
print("\n".join(out))
if "--json" in sys.argv:
print(json.dumps({"target": args[0], "total": total, "title": title,
"h1": h1, "price": price, "product_schema": bool(p),
"rows": [dict(name=n, score=g, maxv=m, note=o)
for n, g, m, o in rows]}, indent=2))
if __name__ == "__main__":
main()It reads a local HTML file or fetches a URL over ten-hop redirects, handles gzip, and extracts everything the scorecard needs from the raw HTML. That "reads a local file" detail is load-bearing: when a live store walls off scripts, you can save the rendered page's source and audit it offline.
The schema script
Copy this exact file to ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py.
#!/usr/bin/env python3
"""Validate Product JSON-LD against Google Merchant requirements and,
with --fix, rebuild a corrected schema block from the page's own evidence.
Checks the required properties (name, image, offers with price and
priceCurrency) plus the enhancement ladder: aggregateRating (65/100),
sku/gtin/mpn (75/100), shippingDetails (85/100), hasMerchantReturnPolicy
(90/100), three or more reviews (100/100). --fix never invents data: it
only fills fields the page already declares (h1 as name, the visible
price, the page images, the visible description, the store brand), and
writes placeholders for anything missing so a human can complete it.
Usage: python3 product_schema.py <path-or-url> [--fix] [--json]
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
def fetch(source):
if os.path.exists(source):
return open(source, encoding="utf-8").read(), "file"
url = source if "://" in source else "https://" + source
for _ in range(10):
req = urllib.request.Request(url, headers={"User-Agent": UA,
"Accept-Encoding": "gzip"})
try:
with urllib.request.urlopen(req, timeout=25) as r:
raw = r.read()
except urllib.error.HTTPError as e:
raise SystemExit("FETCH FAIL: %s HTTP %s" % (url, e.code))
if raw[:2] == b"\x1f\x8b":
import gzip
raw = gzip.decompress(raw)
return raw.decode("utf-8", "replace"), url
raise SystemExit("FETCH FAIL: too many redirects: %s" % url)
def jsonld_blocks(html):
blocks = re.findall(r'<script\b[^>]*type\s*=\s*["\']application/ld\+json'
r'["\'][^>]*>([\s\S]*?)</script>', html, re.I)
out = []
for b in blocks:
b = b.strip()
if not b:
continue
try:
out.append(json.loads(b))
except ValueError:
out.append({"_invalid": b[:60] + "..."})
return out
def find_product(ld):
def walk(o):
if isinstance(o, dict):
if o.get("@type") == "Product":
yield o
for v in o.values():
yield from walk(v)
elif isinstance(o, list):
for v in o:
yield from walk(v)
return next(walk(ld), None)
def headline(h):
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", h)).strip()
def render(d, indent=2):
return json.dumps(d, indent=indent, ensure_ascii=False)
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
fix = "--fix" in sys.argv
if not args:
print("product_schema.py - Product JSON-LD audit (claude-seo-derived)")
print(" python3 product_schema.py <product-page.html|url>")
print(" python3 product_schema.py <page> --fix (print corrected JSON-LD)")
return
html, source = fetch(args[0])
text = re.sub(r"<(script|style)\b.*?</\1>", " ", html,
flags=re.S | re.I)
ld = jsonld_blocks(html)
p = find_product(ld)
invalid = [b for b in ld if "_invalid" in b]
name = (p.get("name") if p else "") or ""
offers = (p.get("offers") or {}) if p else {}
o = offers[0] if isinstance(offers, list) else offers
rule_txt = []
errors = []
if p is None:
errors.append("No Product schema found on the page")
# ---- required set ----
required = {"name": bool(name),
"image": bool(p and p.get("image")),
"offers": p is not None and offers is not None,
"price": bool(o and o.get("price")),
"priceCurrency": bool(o and o.get("priceCurrency"))}
req_ok = all(required.values())
for k, ok in required.items():
if not ok:
errors.append("required missing: %s" % k)
# ---- the eight validation rules ----
checks = []
if p is not None:
price = o.get("price") if o else None
checks.append(("1 price is a number string (no currency symbol)",
bool(price) and not re.search(r"[^\d.]", str(price))))
av = (o.get("availability") if o else "")
if isinstance(av, list):
av = av[0]
checks.append(("2 availability uses full Schema.org URL enum",
not av or str(av).startswith("https://schema.org/")))
img = p.get("image") or []
if isinstance(img, str):
img = [img]
checks.append(("3 image is an array with at least one URL",
bool(img) and all(str(i).startswith("http") or
str(i).startswith("/") for i in img)))
cur = o.get("priceCurrency") if o else ""
checks.append(("4 priceCurrency is ISO 4217 (3 letters)",
bool(cur) and re.fullmatch(r"[A-Z]{3}", str(cur))))
bn = (p.get("brand") or {}).get("name") if p.get("brand") else ""
checks.append(("5 brand.name not empty or 'N/A'",
not p.get("brand") or
(bn and str(bn).strip().lower() != "n/a")))
window = (o.get("validThrough") if o else None) or \
(o.get("priceValidUntil") if o else None)
checks.append(("6 sale window uses ISO 8601 date",
not (o and o.get("validFrom")) or bool(window)))
agg = p.get("aggregateRating")
checks.append(("7 aggregateRating has ratingValue + reviewCount",
not agg or (agg.get("ratingValue") is not None and
agg.get("reviewCount") is not None)))
revs = p.get("review") or []
n_rev = len(revs) if isinstance(revs, list) else (
1 if revs else 0)
checks.append(("8 aggregateRating backed by visible reviews",
not agg or n_rev >= 1))
for label, ok in checks:
rule_txt.append(" %s %s" % ("PASS" if ok else "WARN", label))
# ---- ladder ----
if p is not None:
agg = bool(p.get("aggregateRating"))
ids = bool(p.get("sku") or p.get("gtin13") or p.get("gtin14") or
p.get("mpn"))
ship = bool(o and o.get("shippingDetails")) or bool(
p.get("shippingDetails"))
rr = bool(p.get("hasMerchantReturnPolicy"))
revs = p.get("review") or []
n_rev = len(revs) if isinstance(revs, list) else (
1 if revs else 0)
if not req_ok:
ladder = "below 50/100 (required set incomplete)"
elif agg and ids and ship and rr and n_rev >= 3:
ladder = "100/100"
elif agg and ids and ship and rr:
ladder = "90/100"
elif agg and ids and ship:
ladder = "85/100"
elif agg and ids:
ladder = "75/100"
elif agg:
ladder = "65/100"
else:
ladder = "50/100"
else:
agg = ids = ship = rr = False
n_rev = 0
ladder = "no schema (would score 0/100)"
# ---- --fix: rebuild from page evidence ----
if fix:
h1 = re.search(r"<h1\b[^>]*>(.*?)</h1>", html, re.S | re.I)
h1 = headline(h1.group(1)) if h1 else name
pm = re.search(r"\$\s?(\d+(?:\.\d{1,2})?)", text)
price = pm.group(1) if pm else None
imgs = []
for tag in re.findall(r"<img\b[^>]*>", html, re.I):
m = re.search(r'\bsrc\s*=\s*"([^"]*)"', tag, re.I) or \
re.search(r"\bsrc\s*=\s*'([^']*)'", tag, re.I)
if not m or not m.group(1).startswith("http"):
continue
imgs.append(m.group(1))
bm = re.search(r'class=["\']brand["\'][^>]*>([^<]+)', text, re.I)
brand = re.sub(r"\s+", " ", bm.group(1)).strip() if bm else "Store"
dm = re.search(r'<div\b[^>]*class=["\'][^"\']*description[^"\']*["\'][^>]*>'
r'([\s\S]*?)</div>', text, re.I)
desc = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ",
dm.group(1))).strip() if dm else ""
print("## Corrected Product schema (rebuilt from page evidence)")
done = {"@context": "https://schema.org", "@type": "Product",
"name": (headline(h1) or "PRODUCT NAME")}
if price:
poff = {"@type": "Offer", "url": "", "priceCurrency": "USD",
"price": price,
"availability": "https://schema.org/InStock"}
done["offers"] = poff
else:
print("# NOTE: no price found on the page; offers left for you")
done["offers"] = {"@type": "Offer"}
done["image"] = imgs or [""]
if not imgs:
print("# image: no absolute URLs found on the page; add them")
done["description"] = (desc or "Add the visible description here.")[:500]
done["brand"] = {"@type": "Brand", "name": brand}
print(render(done))
print("# review: run product_schema.py again and expect the ladder")
print("# to reach 50/100 with the required set above; add")
print("# aggregateRating, sku/gtin, shippingDetails,")
print("# hasMerchantReturnPolicy and real reviews (not fabricated")
print("# star ratings) to climb 65 -> 75 -> 85 -> 90 -> 100.")
return
print("Product schema audit: %s" % args[0])
print(" JSON-LD blocks: %d | Product blocks: %d%s" % (
len(ld), 1 if p else 0, " | invalid JSON-LD blocks: %d" % len(invalid)
if invalid else ""))
print(" required set (name, image, offers, price, priceCurrency): %s"
% ("COMPLETE" if req_ok else "INCOMPLETE"))
for e in errors:
print(" - %s" % e)
if not checks:
print(" (validation rules skipped - no Product schema)")
for line in rule_txt:
print(line)
print(" ladder: %s" % ladder)
if "--json" in sys.argv:
print(json.dumps({"target": args[0], "product_schema": bool(p),
"required_complete": req_ok, "errors": errors,
"ladder": ladder}, indent=2))
if __name__ == "__main__":
main()The --fix mode prints a corrected Product schema as JSON you can paste into the page's head, with two kinds of output: facts lifted from the page (h1 as name, the printed price, images with absolute URLs, the visible description, the store brand) and placeholders where the page had nothing to declare. It also prints the ladder explanation in comments so whoever pastes it knows what the next check will demand.
The demo store, shipped with the skill
Copy both files under examples/ exactly as named. They are only needed if you want to reproduce the walkthrough below, but they are part of the package, and the broken one is the perfect five-second pulse test after an install:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ridgeline All-Day Saddle Bag - Bike bag for tools and snacks while you ride. Keep everything within reach on a long day out on the road</title>
<meta name="description" content="Ridgeline All-Day Saddle Bag. A bag for your bike. Made of canvas. 12 oz canvas with waxed finish to survive wet weather and gravel. Fasteners included with every bag and a small rear reflector loop for night rides so cars see you from behind on the road while you commute across town or head out for the weekend. Barn Velo makes cycle goods in Portland Oregon since 1998.">
</head>
<body>
<header>
<a href="/">Barn Velo</a>
<form action="/search" method="get"><input type="text" name="q" placeholder="Search"><button>Go</button></form>
</header>
<nav>
<a href="/product-cat/bike-bags">Bike Bags</a>
<a href="/product-cat/components">Components</a>
<a href="/product-cat/gear">Gear</a>
</nav>
<main>
<h1>Ridgeline All-Day Saddle Bag</h1>
<div class="product-gallery">
<figure>
<img src="/img/IMG_001.jpg" alt="">
<figcaption>Ridgeline bag</figcaption>
</figure>
<figure>
<img src="/img/photo_back.jpg" alt="saddle bag">
<figcaption>Back view</figcaption>
</figure>
</div>
<p class="price">$64.00</p>
<div class="description">
<p>A bag for your bike. Made of canvas. 12 oz. Fasteners included.</p>
</div>
<h2>Features</h2>
<p>Sits under the seat. Big enough for a tool kit, a tube, and your phone. A rear loop for a light.</p>
<h2>Shipping</h2>
<p>We ship from Portland on Tuesdays. Flat-rate.</p>
<button type="button">Add to cart</button>
</main>
<footer>
<p>Barn Velo, Portland OR. All rights reserved.</p>
</footer>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ridgeline All-Day Saddle Bag | 12oz Waxed Canvas | Barn Velo</title>
<meta name="description" content="The Ridgeline All-Day Saddle Bag carries tools, a tube, and snacks in waxed 12oz canvas. $64.00. Free shipping over $50. Secure seat strap mount.">
<link rel="canonical" href="/product-cat/bike-bags/ridgeline-all-day-saddle-bag">
</head>
<body>
<header>
<a href="/">Barn Velo</a>
<form action="/search" method="get"><input type="text" name="q" placeholder="Search"><button>Go</button></form>
</header>
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/product-cat/bike-bags" title="Bike Bags">Bike Bags</a></li>
<li><span aria-current="page">Ridgeline All-Day Saddle Bag</span></li>
</ol>
</nav>
<main>
<h1>Ridgeline All-Day Saddle Bag</h1>
<div class="product-gallery">
<figure>
<img src="/img/ridgeline-saddle-bag-hero.webp" srcset="/img/ridgeline-saddle-bag-hero.webp 1x, /img/ridgeline-saddle-bag-hero-2x.webp 2x" width="800" height="800" alt="Ridgeline All-Day Saddle Bag mounted under a gravel bike seat">
<figcaption>Mounted under the seat, roll-top closed</figcaption>
</figure>
<figure>
<img src="http://barnvelo.example.com/img/ridgeline-saddle-bag-detail.jpg" width="800" height="600" alt="Ridgeline underside detail showing tool sleeve and buckle">
<figcaption>Underside detail with tool sleeve</figcaption>
</figure>
<figure>
<img src="/img/ridgeline-saddle-bag-lifestyle.jpg" width="900" height="600" alt="Lifestyle shot: gravel rider packing the Ridgeline saddle bag before a day trip">
<figcaption>On the road for a full day out</figcaption>
</figure>
</div>
<p class="price">$64.00</p>
<div class="description">
<p>The Ridgeline All-Day Saddle Bag is a waxed 12oz canvas bag that mounts under a bicycle seat and stays put. The roll-top closure swallows a full tool kit, two tubes, a phone, and a day of snacks, while the aluminum buckle adjusts in a second and never rattles. The underside sleeve holds a multitool or a CO2 cartridge within reach, and the reflective tail loop gives drivers something to see at dusk. Double-stitched seams and a dry-wax finish handle rain, dust, and repeated pack/unpack cycles without fading.</p>
<p>This is the bag for people who leave home before coffee and get back after dark. It fits bikes with up to 140mm of saddle-to-tire clearance, and the wide strap wraps either the saddle rails or the seat post. Thirty days of test-riding in Oregon mud were part of the spec sheet, not the marketing department.</p>
<p>Buy it with the tool kit bundle and the free-shipping threshold is met at $50, or pick up the pump strap at checkout and keep everything in one place.</p>
<p>Three checks before you add it to the cart: measure the gap between your saddle and tire, confirm the colorway (black or waxed olive), and note that the rear loop is sized for a small rechargeable light. If you run a dropper post, set the bag on the saddle rail rather than the seat post so it clears the travel curve. Riders who carry a full 29er tube plus a pump plus a phone will want the 1.6 L capacity.</p>
</div>
<h2>Specifications</h2>
<table>
<tbody>
<tr><th>Capacity</th><td>1.6 L</td></tr>
<tr><th>Material</th><td>12oz waxed canvas</td></tr>
<tr><th>Weight</th><td>210 g</td></tr>
<tr><th>Mount</th><td>Saddle rails or seat post</td></tr>
<tr><th>Origin</th><td>Portland, OR</td></tr>
</tbody>
</table>
<h2>Reviews</h2>
<ul class="reviews">
<li><p>Dana R. <strong>5/5</strong>: Thirty miles of washboard and the bag never moved. Tube, CO2, multitool, two waffles.</p></li>
<li><p>Marcus T. <strong>5/5</strong>: Fits my phone flat against the underside. The buckle is the strong point.</p></li>
<li><p>June A. <strong>4/5</strong>: Waterproof through one real Oregon rain. Straps are a touch long but trim easily.</p></li>
</ul>
<p class="rating-line">4.8 out of 5 from 37 reviews</p>
<h2>Related products</h2>
<div class="related">
<a href="/product-cat/bike-bags/full-frame-bag">Full Frame Bag</a>
<a href="/product-cat/bike-bags/handlebar-bag">Roll-Top Handlebar Bag</a>
<a href="/product-cat/gear/tool-kit-bundle">Tool Kit Bundle</a>
</div>
<button type="button">Add to cart</button>
</main>
<footer>
<p><a href="/product-cat/bike-bags" title="Bike Bags">Bike Bags</a> · Barn Velo, Portland OR.</p>
</footer>
</body>
</html>The diff between them is the whole lesson. The fixed page gains a 60-character title with brand and format, a 145-character meta with a price and a CTA, a canonical, a BreadcrumbList and a complete Product block, a webp hero with descriptive alts, a 262-word description, a specs table, visible reviews, a related-products block, and a category backlink. And it keeps one flaw on purpose, inside the detail image at http:// instead of https://. You'll see what that costs in pass two.
Install it in three commands
mkdir -p ~/.codex/skills/codex-seo-ecommerce/scripts ~/.codex/skills/codex-seo-ecommerce/examples
# save the five files above at:
# ~/.codex/skills/codex-seo-ecommerce/SKILL.md
# ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py
# ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py
# ~/.codex/skills/codex-seo-ecommerce/examples/product-page.html
# ~/.codex/skills/codex-seo-ecommerce/examples/product-page-fixed.html
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py ~/.codex/skills/codex-seo-ecommerce/examples/product-page.htmlThe last command is the sanity check: you want to see TOTAL: 17/100. If it errors, check the troubleshooting table before blaming your machine. Then restart Codex and either run $codex-seo-ecommerce with a URL or just say "audit this product page" and paste the link. The skill triggers on terms like product page, ecommerce SEO, product schema, Google Shopping, or merchant requirements.
The build: 17 to 98 in three passes

Pass one: diagnose
The full broken-page run, from the demo store:
Product page audit: examples/product-page.html
type: product page (schema=no, price=64.00)
title: Ridgeline All-Day Saddle Bag - Bike bag for tools and snacks while you...
h1: Ridgeline All-Day Saddle Bag
SCHEMA 0/25 no Product schema found
TITLE+META 2/15 len 135, brand missing, no feature or format | len 372, no price, no CTA
IMAGES 5/20 2 images, 0 alt ok, 1 descriptive filenames, no webp/avif
CONTENT 2/20 description 14 words (very thin), no specs, no reviews
INTERNAL 3/10 no breadcrumb, no related block, category backlink+3
TECHNICAL 5/10 no canonical, h1 x1+3, imgs https+2
TOTAL: 17/100Read it like an invoice. Schema 0/25 because there is no Product block at all. Title+meta 2/15: the title is 135 characters and delivers no brand and no feature string; the meta is 372 characters with no price and no CTA. Images 5/20: two images, zero usable alt texts at the macro level (one is empty, one is two generic words), one descriptively named file, no webp. Content 2/20: a 14-word paragraph of description, no specs, no reviews. Internal 3/10: no breadcrumb, no related block, but the nav does carry category links. Technical 5/10: no canonical, a single h1, and fully relative image paths that score as safe.
The first 17-point sweep is free money: nothing here is hard to fix, and each fix is a name, not a vibe.
Pass two: fix, then re-score
The edits were the usual six-file job, but every edit maps to a specific missing line from pass one:
- Title to 60 characters, brand and material format included:
Ridgeline All-Day Saddle Bag | 12oz Waxed Canvas | Barn Velo.
- Meta to 145 characters with
$64.00andFree shipping. - Canonical to the self URL.
- Two JSON-LD blocks: a BreadcrumbList and a Product block with the
full required set plus aggregateRating with a review count, sku and gtin13, shippingDetails with rates and delivery times, hasMerchantReturnPolicy, and three real reviews.
- Images: webp hero with srcset, absolute URLs, and alt texts that
name the product.
- A 262-word description block, a specs table, a visible reviews
list, a related-products row, and a footer category link.
- One flaw kept on purpose: the detail image at
http://, to
demonstrate the mixed-content check.
Re-run:
Product page audit: examples/product-page-fixed.html
type: product page (schema=yes, price=64.00)
title: Ridgeline All-Day Saddle Bag | 12oz Waxed Canvas | Barn Velo
h1: Ridgeline All-Day Saddle Bag
SCHEMA 25/25 required name/image/offers+price present; priceCurrency ok; aggregateRating +5; sku/gtin/mpn +3; description+brand.name +3; shippingDetails +2; hasMerchantReturnPolicy +2
TITLE+META 15/15 len 60, brand present, feature/format present | len 145, price present, CTA present
IMAGES 20/20 3 images
CONTENT 20/20 description 262 words, specs table, reviews on page
INTERNAL 10/10 breadcrumb+4, related+3, category backlink+3
TECHNICAL 8/10 canonical+3, canonical self+2, h1 x1+3, http img (mixed content)
TOTAL: 98/100Read the last two lines. TECHNICAL 8/10 with the sole note http img (mixed content): the point pair is gone, and the skill names it. Mixed content sends a warning in the browser and a small signal cost downstream, and the demo page keeps it so you recognize the note when you meet it in the field. Everything else rounds out to the full
- Notice also what became silence:
IMAGES 20/20 3 imageswith no
per-point notes, because the file's note column only talks when something is wrong.
Pass three: schema audit, then the rebuild
The fixed page's schema, validated:
Product schema audit: examples/product-page-fixed.html
JSON-LD blocks: 2 | Product blocks: 1
required set (name, image, offers, price, priceCurrency): COMPLETE
PASS 1 price is a number string (no currency symbol)
PASS 2 availability uses full Schema.org URL enum
PASS 3 image is an array with at least one URL
PASS 4 priceCurrency is ISO 4217 (3 letters)
PASS 5 brand.name not empty or 'N/A'
PASS 6 sale window uses ISO 8601 date
PASS 7 aggregateRating has ratingValue + reviewCount
PASS 8 aggregateRating backed by visible reviews
ladder: 100/100Eight passes and a full ladder. And here is where the demo store pays its way twice, because the broken page shows the other direction. Run --fix on it and the tool rebuilds rather than scolds:
## Corrected Product schema (rebuilt from page evidence)
# image: no absolute URLs found on the page; add them
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Ridgeline All-Day Saddle Bag",
"offers": {
"@type": "Offer",
"url": "",
"priceCurrency": "USD",
"price": "64.00",
"availability": "https://schema.org/InStock"
},
"image": [
""
],
"description": "A bag for your bike. Made of canvas. 12 oz. Fasteners included.",
"brand": {
"@type": "Brand",
"name": "Store"
}
}
# review: run product_schema.py again and expect the ladder
# to reach 50/100 with the required set above; add
# aggregateRating, sku/gtin, shippingDetails,
# hasMerchantReturnPolicy and real reviews (not fabricated
# star ratings) to climb 65 -> 75 -> 85 -> 90 -> 100.Now the honesty part, which is the most valuable thing in the tool. The rebuild lifted the h1 as the name, the $64.00 as the price, the visible description, and a priceCurrency assumption of USD. It could not lift images to absolute URLs, so it wrote "" and said so in the comment. It could not find a marked-up brand, so it wrote "Store" instead of playing a guess. And it left the ladder commentary in place because the blocks a machine cannot write are exactly the ones that put your page past 50: aggregateRating, sku, shipping, returns.
The fixed page's schema sits at 100/100 on that ladder, and the climb from there is not the scripts' business. Three reviews were typed by hand, in the demo, exactly as they would have to be in production.
Where this still stops
Three honest boundaries of the free skill, before the paid and waitlisted worlds beyond.
The scorecard is not Google's math. The weights are this skill's model, ordered to match what the documentation demands and the experience shows. A 98/100 page is a well-formed page, and a page can be well-formed and still lose a SERP. Use the score as a repair order, not a forecast.
JavaScript-rendered stores are outside the raw-HTML view. Cart state, lazy-loaded reviews, and schema injected after render are all invisible to a plain request. Save the rendered page source, or hand Playwright a page and feed the HTML to the same scripts.
Market data is paid and optional. If you want to see competitors' prices or marketplace positions, DataForSEO's Merchant API returns that, and it costs per request. The skill deliberately has no such dependency; its inputs are the page itself.
UCP is the 2026 frontier, and it's waitlisted. Universal Commerce Protocol, the Google-led open standard for uniform-commerce checkout, lets AI Mode and Gemini complete purchases at your own checkout while you stay the merchant of record. Adoption starts on the waitlist, and the integration paths are REST, MCP bindings, A2A adapters, or SDKs. What a merchant can do today, without joining anything, is host the profile file at /.well-known/ucp on their domain: a small JSON document whose ucp.version field is a calendar date such as 2026-04-08 instead of semver, listing the merchant's services, capabilities, and signing keys. The spec and schemas live at ucp.dev. The practical reading: keep your Product schema clean now, because the machine reader that arrives via UCP will look at the page you have already built.
Troubleshooting
Scenario | Action |
|---|---|
| Install Python 3 (python.org or your package manager), or use |
Page returns 401/403 or a WAF block | Save the rendered page source locally and audit the file; the scripts accept any HTML file path |
Crawled HTML is a thin JS shell, scores near zero | Client-side rendering. Audit the file after a headless render, or paste the visible HTML |
| That's the gate working. If it's genuinely a product page, the price or schema is rendered client-side |
| The page never marked up its brand. Fill the real name; the skill refuses to guess |
| No absolute image URLs on the page. Add absolute URLs (or serve derived URLs) and re-run |
Windows paths | The same |
Codex doesn't auto-trigger | Run |
PASTE-TO-CODEX
Want Codex to do the installation? Copy everything from the line above this paragraph to the end of the article, paste it into Codex, and say this:
Install the five files in this article as a Codex skill.
Create ~/.codex/skills/codex-seo-ecommerce/SKILL.md verbatim from the
~~~~markdown block, the two ~~~~python script blocks verbatim from
scripts (product_page.py and product_schema.py) and the two ~~~~html
demo blocks verbatim under examples/ (product-page.html and
product-page-fixed.html), keeping the file layout shown in the install
section. Then run:
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_page.py ~/.codex/skills/codex-seo-ecommerce/examples/product-page.html
and
python3 ~/.codex/skills/codex-seo-ecommerce/scripts/product_schema.py ~/.codex/skills/codex-seo-ecommerce/examples/product-page-fixed.html
Report the paths you created and both outputs. If python3 is missing,
report that instead of stopping. Do not install or modify any other files.If the first run prints TOTAL: 17/100 and the second ends in ladder: 100/100, the skill is live and your first real run is against your own product pages.
FAQ
Is Product schema required to rank?
Not to rank, but it gates the product-rich experiences. Certain rich result surfaces and Merchant eligibility are unavailable without it, and Google's structured-data documentation names name, image, and offers as required props before anything else is considered. The skill's 25-point schema weight reflects that: it's the largest single dimension because it's the one a machine enforces.
Why does the scorecard dock points after 60 title characters?
Truncation is a display reality, not a strict rule. Search results cut titles around 60 characters and meta descriptions around 155, so every character past that is a bet that the important part survives the cut. A 70-character title can be fine; the points just fall because the risk rises. Feature strings like 12oz Waxed Canvas and brand names belong in front of that cliff, not behind it.
Why is aggregateRating worth nothing without visible reviews?
Rule 8 exists because fabricated review data is the one thing in this skill that can get you actively penalized rather than merely unheard. Ratings must describe assessments a human made, and if a star block ships in markup without reviews on the page, you have built the exact pattern the structured-data guidelines warn about.
What is mixed content, and why did the demo keep it?
An https:// page loading an http:// image triggers a browser security warning and loses the point pair in the technical dimension. The demo store keeps one deliberately so you can see the note in the output. The fix is one character: http:// to https://.
Does this work on Shopify, WooCommerce, or a headless storefront?
The audit works on whatever HTML arrives, so any platform is fair game after render. The skip case is the opposite direction: platform plugins that inject schema or reviews via JavaScript after the page loads will be invisible to a raw fetch, until you audit the rendered source instead.
Where should I start on a real catalog?
Top sellers first. Run the scorecard on your ten best-selling pages, then fix in score order, because a 17/100 on your best seller is worth more than a 98/100 on a product nobody buys.
This is the latest in the Codex SEO Skills series. Every article ships one self-contained skill; install the preflight skill (codex-seo-ready) first, then this one, and the rest as your workflow needs them. Skills in the same family share the ~/.codex/skills/ layout, so each install is additive and nothing breaks when you add a sibling.
Author: Eva Laurent, Ecommerce Search Strategist for 10k+ Product Pages. Eva writes about ecommerce SEO, product discovery, and category content.
Based on the open-source [claude-seo](https://github.com/AgriciDaniel/claude-seo) project (MIT license, AgriciDaniel). This article customizes the ecommerce skill for the Codex runtime and keeps the original methodology: the six-dimension product scorecard, the Product schema validation rules, and the enhancement ladder. The demo store and both audit scripts are written from scratch for this article.



