Four thousand words of content, and the page still sits on page three. Or: the article is great, but when anyone shares it on LinkedIn, the preview is a blank square. Or: the images are gorgeous and the page takes six seconds to load.
These sound like different problems. Usually they belong to the same page checklist. You fix one, the page moves; you fix nothing, and the 4,000-word essay stays buried no matter how many times you "write better content." Single-page SEO diagnosis is the part of the discipline where a raw analysis of one URL explains most of your wins and losses.
What you get from this article: a Codex skill that reads any URL, scores five categories (on-page, content quality, technical meta, schema, images), and hands you a fix list ordered by what actually moves ranking. Same rules as the rest of this series: nothing to install beyond python3.
The diagnosis starts with symptoms
Symptom you noticed | Likely category | What the skill checks |
|---|---|---|
"I wrote 4,000 words and nothing ranks" | On-page, content | Title, description, heading structure, word count against intent |
"My share links always look broken" | Technical | Open Graph tags, Twitter card |
"The pages are slow and the hero images are huge" | Images, CWV references | Image count, missing alt, missing dimensions, format mix |
"I don't know which schema I have" | Schema | JSON-LD types, invalid blocks, missing opportunities |
"I copied this page from an old template" | On-page | Duplicate title, missing description, canonical |
Run the scan; the five categories resolve most of the table.
Why one-page diagnosis matters more than you think
Ranking is decided page by page. A title under 60 characters, one H1, a description that says what the page answers, self-referencing canonical, alt text on images, and a valid Article schema block: six bits of metadata that cost maybe an hour of work. Missing more than one of them tells the search engine the page is either a template, an afterthought, or an unmaintained copy. None of that quietly works "anyway." The signal compounds: several missing metadata elements cost more than a slightly thinner essay gains.
There is a second, cheaper reason to do it as a habit: pages decay. A fix from last year reads fine this year until a deploy removes a canonical tag or a site redesign breaks the og:image path. A ten-second scan checks all of it.
Install the skill
Two files. First the folder:
mkdir -p ~/.codex/skills/codex-seo-page/scriptsSave this as ~/.codex/skills/codex-seo-page/SKILL.md:
---
name: codex-seo-page
description: Use when the user gives a single URL and asks for a page-level SEO review - title and meta description quality, heading structure, Open Graph share cards, canonical, image alt text, thumbnail sizes, schema types, word count, or E-E-A-T on that one page. Triggers on "analyze this page", "check this page's SEO", "single URL review", "page analysis", "why does this page not rank".
---
# Single Page SEO Review
One URL, five categories, one score card. The scanner fetches the page and
collects the evidence; you rate against this checklist and write the
recommendations.
## Run
```bash
python3 ~/.codex/skills/codex-seo-page/scripts/page_scan.py <url> [--json]
```
Missing-data note: content behind login (401/403) and client-side-rendered
shells (word count near zero, many scripts) cannot be judged from raw HTML.
Say so, and analyze only what the HTML proves.
## The five categories
### 1. On-page
| Element | Good | Red flag |
|---------|------|----------|
| Title | 50-60 chars, primary keyword near front, unique per page | Missing, duplicated, >60 chars (the site suffix often causes this) |
| Meta description | 150-160 chars, includes keyword, honest | Missing, >160 chars, duplicate across pages |
| H1 | Exactly one, matches page intent | Zero or multiple; H3 without H2 (heading level skip) |
| URL | Short, hyphenated, descriptive | >100 chars, parameter-string content pages |
| Internal links | Contextual, keyword-bearing anchors, no orphans | No links to or from the page |
| External links | To authoritative sources, reasonable count | Link farms or broken authority links |
### 2. Content quality
- Word count vs page type: blog/pillar >=1000-1500 words, product pages >=300,
form and utility pages judged by intent, not count.
- Readability below grade 10-11 for general audiences; Flesch target ~60-70.
- Keyword usage: natural 1-3% density plus semantic variations, never stuffed.
- E-E-A-T signals: byline or author meta, published/updated date, first-hand
experience markers (hands-on examples beats speculation).
- Freshness: a stated `datePublished`/`dateModified`; score old dates as
opportunities to refresh.
The scanner reports `author_meta`, `byline_text_present`, `published_date`.
Missing author meta on a site that publishes articles is a concrete fix: add
JSON-LD `author` on each post or a meta author tag.
### 3. Technical elements
- Canonical: present and self-referencing (relative canonicals are okay,
redirecting canonicals are not).
- Robots meta: `index, follow` unless intentionally blocked.
- Open Graph: og:title, og:description, og:image, og:url present for share
cards. Missing og:image = link previews look broken on LinkedIn/Slack.
- Twitter card: `summary_large_image` on article pages.
- Hreflang: correct when the site is multilingual (see the hreflang article
later in this series).
### 4. Schema
- Detect all JSON-LD types and flag invalid JSON (invisible to Google).
- Validate required properties of the detected type: Article needs headline,
datePublished, author; Organization needs name/url/logo; BreadcrumbList
needs numbered items.
- Opportunity check: Article is missing on a blog post? Person missing? For
Q&A content use `QAPage`, never `FAQPage` - since 2026-05-07 Google retired
FAQ rich results for all sites. Do not recommend HowTo schema (deprecated
September 2023), and never claim FAQ schema earns AI citations.
### 5. Images
| Check | Rule |
|-------|------|
| Alt | Every meaningful image has descriptive alt |
| Dimensions | width+height set (CLS prevention); scan reports missing counts |
| Size | >200KB warn, >500KB critical; check a file with `curl -sI <img-url> \| grep -i content-length` |
| Format | WebP/AVIF preferred over JPEG/PNG for photos; SVG for icons |
| Lazy loading | Native `loading="lazy"` or a JS lazy loader (`data-src`); do NOT flag JS-embedded lazy loaders as "not lazy" |
### Core Web Vitals (reference only)
Raw HTML cannot measure CWV. Flag potential problems (huge hero image for LCP,
no async/defer on big scripts for INP, missing image dimensions for CLS) and
defer measurement to the codex-seo-google data skill.
## Score card output
```
Overall Score: XX/100
On-Page SEO: XX/100
Content Quality: XX/100
Technical: XX/100
Schema: XX/100
Images: XX/100
```
Rate each category 0-100 with anchored descriptions: 90+ solid, 70-89 fixable,
50-69 weak, <50 broken. Overall = weighted average (on-page 20, content 25,
technical 20, schema 15, images 20).
Then buckets:
- **Critical**: broken canonical, noindex on a page you want indexed, invalid
schema blocks, mega-sized images holding back LCP.
- **High**: duplicate or >60-char titles, missing description, heading
skips, all images missing alt, multiple H1s.
- **Medium**: missing og:image, missing author/date signals, no hreflang on a
multilingual page.
- **Low**: format conversions, exact-length tweaks.
Every recommendation carries the falsify check: "add og:image, then share the
URL on LinkedIn and confirm a thumbnail; if the crawler has the old thumbnail,
that is cache, not failure".
## Errors
| Scenario | Action |
|----------|--------|
| URL unreachable | Report DNS/connection error, ask for the URL to be verified, never guess content |
| 401/403 | Say the page is behind auth; ask for a public URL or a saved HTML file |
| Word count near zero + many scripts | Note likely client-side rendering; analyze what raw HTML proves; offer Playwright as a next step |
| Huge page | Report size, still analyze; it might have JS bundles that are fine in reality |And the collector as ~/.codex/skills/codex-seo-page/scripts/page_scan.py. Standard library, no pip install:
#!/usr/bin/env python3
"""Deep single-page SEO data collector. Standard library only."""
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
TIMEOUT = 15
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 "
"codex-seo-page/1.0")
def fetch(url):
"""Return (status, final_url, html, redirects). Follows up to 8 hops."""
cur, chain, seen = url, [], set()
while cur not in seen and len(chain) < 8:
seen.add(cur)
chain.append(cur)
req = urllib.request.Request(cur, headers={"User-Agent": UA})
try:
resp = urllib.request.urlopen(req, timeout=TIMEOUT)
return resp.status, resp.geturl(), resp.read().decode(
"utf-8", "replace"), chain
except urllib.error.HTTPError as e:
loc = dict(e.headers).get("Location")
if e.code in (301, 302, 303, 307, 308) and loc:
cur = urllib.parse.urljoin(cur, loc)
continue
return e.code, cur, "", chain
except urllib.error.URLError as e:
return "ERR:" + str(e.reason), cur, "", chain
return "LOOP", cur, "", chain
def main():
url = sys.argv[1] if len(sys.argv) > 1 else ""
if not url:
print("usage: python3 page_scan.py <url> [--json]")
sys.exit(1)
want_json = "--json" in sys.argv
if not url.startswith("http"):
url = "https://" + url
status, final, html, chain = fetch(url)
r = {"url": url, "status": status, "final_url": final,
"redirects": len(chain) - 1, "size_kb": round(len(html.encode()) / 1024, 1)}
if not html:
r["error"] = "no HTML body; status=" + str(status)
print(json.dumps(r, indent=2) if want_json else r["error"])
sys.exit(0 if status == 200 else 1)
def m(p):
return re.findall(p, html, re.I)
# on-page elements
title = m(r"<title[^>]*>([^<]+)</title>")
desc = m(r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']{1,500})["\']')
h1 = m(r"<h1[^>]*>([^<]*)</h1>")
h2 = m(r"<h2[^>]*>")
h3 = m(r"<h3[^>]*>")
h4 = m(r"<h4[^>]*>")
canon = m(r'<link[^>]+rel=["\']canonical["\'][^>]*>')
robots_meta = m(r'<meta[^>]+name=["\']robots["\'][^>]*content=["\']([^"\']+)["\']')
og = {}
for k, v in re.findall(r'<meta[^>]+property=["\'](og:[^"\']+)["\'][^>]+content=["\']([^"\']{1,300})["\']', html):
og[k] = v[:120]
tw = {}
for k, v in re.findall(r'<meta[^>]+name=["\'](twitter:[^"\']+)["\'][^>]+content=["\']([^"\']{1,300})["\']', html):
tw[k] = v[:120]
hreflang = m(r'<link[^>]+hreflang=["\']([^"\']+)["\']')
imgs = m(r"<img\b[^>]*>")
no_alt = [i for i in imgs if not re.search(r'<\s*img[^>]*alt=["\'][^"\']+["\']', i)]
no_dims = [i for i in imgs if not (re.search(r'width="[^"]+"', i) and re.search(r'height="[^"]+"', i))]
native_lazy = [i for i in imgs if re.search(r'loading=["\']lazy["\']', i)]
js_lazy = [i for i in imgs if re.search(r'data-src=(?:"[^"]*"|\S+)', i)]
big_imgs = [x for x in m(r'<img[^>]+src=["\']([^"\']+)["\']') if re.search(r"\.(jpg|jpeg|png|webp|gif|avif)", x)][:10]
ext_counts = {}
for src in m(r'<img[^>]+src=["\']([^"\']+)["\']'):
ex = re.search(r"\.(jpg|jpeg|png|webp|avif|gif|svg)(?:\?|$)", src.lower())
if ex:
ext = ex.group(1)
ext_counts[ext] = ext_counts.get(ext, 0) + 1
# content quality
text = re.sub(r"(?is)<script.*?</script>|<style.*?</style>|<[^>]+>", " ", html)
text = re.sub(r"\s+", " ", text).strip()
words = len(text.split())
# E-E-A-T signals
author_meta = m(r'<meta[^>]+name=["\']author["\'][^>]+content=["\']([^"\']{1,120})["\']')
byline = bool(re.search(r"(?i)\b(?:autho?red by|written by)\b", text))
dates = m(r'<time[^>]*datetime=["\']([\d-]{4,10})["\']')
pub_date = m(r'(?i)(datePublished|published_time|article:published_time)["\']?\s*[:>]?\s*["\']?([\d-]{4,10})')
freshness = dates[0] if dates else (pub_date[0][1] if pub_date else None)
# internal vs external links
links = m(r'<a\s[^>]*href=["\']([^"\']+)["\']')
root = re.search(r"https?://([^/]+)", final)
domain = root.group(1) if root else ""
internal = [l for l in links if l.startswith("/") or domain in l]
external = [l for l in links if l not in internal and l.startswith("http")]
gaps = []
if h3 and not h2:
gaps.append("h3 without h2")
if h4 and not h3:
gaps.append("h4 without h3")
r["onpage"] = {
"title": title[0][:100] if title else None,
"title_len": len(title[0]) if title else 0,
"meta_description": desc[0][:100] if desc else None,
"description_len": len(desc[0]) if desc else 0,
"h1_count": len(h1), "h2_count": len(h2), "h3_count": len(h3),
"h4_count": len(h4),
"heading_gaps": gaps,
"canonical": (re.search(r'href=["\']([^"\']+)["\']', canon[0]).group(1)
if canon else None),
"robots_meta": robots_meta,
"og_present": sorted(og.keys()), "twitter_present": sorted(tw.keys()),
"hreflang_links": len(hreflang),
"internal_links": len(internal), "external_links": len(external),
}
r["related_alerts"] = []
if title and not desc:
r["related_alerts"].append("Warn: no meta description")
if h1 and len(h1) > 1:
r["related_alerts"].append("Warn: %d H1 tags (use exactly one)" % len(h1))
if not h1:
r["related_alerts"].append("Warn: no H1 on page")
if "og:title" not in og:
r["related_alerts"].append("Info: share card missing og:title")
if "og:image" not in og:
r["related_alerts"].append("Info: share card missing og:image")
r["images"] = {
"total": len(imgs),
"missing_alt": len(no_alt),
"missing_width_or_height": len(no_dims),
"native_lazy": len(native_lazy),
"js_lazy_loader": len(js_lazy),
"formats": ext_counts,
"file_names": big_imgs,
}
# schema
jsonld = m(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>')
types, invalid = [], []
for js in jsonld:
payload = js[js.find("{"):]
try:
d = json.loads(payload)
t = d.get("@type")
types += ([t] if isinstance(t, str) else (t or []))
except Exception:
invalid.append(payload[:80])
r["schema"] = {"blocks": len(jsonld), "types": types, "invalid_blocks": len(invalid)}
r["schema_opportunities"] = [t for t in ["Article", "Person",
"BreadcrumbList"] if t not in types]
r["content"] = {"words": words,
"author_meta": author_meta,
"byline_text_present": byline,
"published_date": freshness}
if want_json:
print(json.dumps(r, indent=2))
else:
lines = ["page_scan %s" % r["url"],
" status %s size %skB redirects %d" % (r["status"], r["size_kb"], r["redirects"])]
o = r["onpage"]
lines.append(" title(%d): %s" % (o["title_len"], o["title"]) if o["title"] else " no title")
lines.append(" desc(%d) h1×%d h2×%d h3×%d h4×%d canon=%s" %
(o["description_len"], o["h1_count"], o["h2_count"],
o["h3_count"], o["h4_count"], o["canonical"] or "MISSING"))
lines.append(" shared: og=%d fields, twitter=%d fields" %
(len(o["og_present"]), len(o["twitter_present"])))
lines.append(" links: internal %d, external %d" %
(o["internal_links"], o["external_links"]))
im = r["images"]
lines.append(" images: %d total, %d missing alt, %d missing dims, lazy native %d / js %d" %
(im["total"], im["missing_alt"], im["missing_width_or_height"],
im["native_lazy"], im["js_lazy_loader"]))
lines.append(" schema: %s invalid %d | words: %d" %
(",".join(r["schema"]["types"]) or "none",
r["schema"]["invalid_blocks"], r["content"]["words"]))
for a in r["related_alerts"]:
lines.append(" - " + a)
print("\n".join(lines))
if __name__ == "__main__":
main()Run it
python3 ~/.codex/skills/codex-seo-page/scripts/page_scan.py https://your-url.com --jsonThe summary output is meant to be read. The JSON is meant to be pasted to Codex for the full score card write-up.
A real diagnosis: my first article in this series
I just ran the scanner on the first article I published in this series. Actual output from the skill:
page_scan https://auspia.ai/blog/codex-seo-skills-getting-started
status 200 size 99.4kB redirects 0
title(95): How to Set Up Codex SEO Skills: A Beginner's Guide (Full SKILL.md Included) | AuspiaAI Blog
desc(180) h1×1 h2×26 h3×0 h4×5 canon=https://auspia.ai/blog/codex-seo-skills-getting-started
shared: og=6 fields, twitter=4 fields
links: internal 64, external 4
images: 16 total, 14 missing alt, 3 missing dims, lazy native 13 / js 0
schema: BlogPosting invalid 0 | words: 3015Now run the diagnosis against it:
- Title: 95 characters. The site template appends the site name. The browser limit is 50-60 chars for the full display; this one is over. Fix: shorten the primary part so even with the suffix it stays roughly under 65. The keyword "Codex SEO" stays near the front.
- Description: 180 characters. Same suffix story. Long descriptions get truncated at 160 in the SERP; the visible part still contains the promise.
- H1: exactly one. Good. H2 count: 26. That includes the embedded SKILL.md code blocks in the rendered page, which is legitimate.
- Images: 16, 14 missing alt. Here is the honest part: most of those 14 are template chrome (logo, navigation assets, social icons). Meaningful images have alt. The scanner doesn't know which images are decorative; you do. So the rule is: fix the alt on content images, and give template assets empty alt (
alt="") rather than leaving it absent. - 3 images missing dimensions. Real CLS risk on those elements. Fix: add width and height, or let the responsive stack handle it.
- Schema: BlogPosting, invalid 0. Good. Missing
authorin the JSON-LD though: the scanner found nometa authorand noAuthor:in JSON-LD. That is already a legit E-E-A-T improvement for the site-level styling. - words: 3015. Within the 1,000-1,500+ pillar guideline. Fine.
- Canonical self-referencing. Good.
The useful part: in three minutes the skill found three concrete fixes (title length, description length, image dimension attributes) and confirmed the page's foundation was sound. If this page ranked poorly, the fix list tells you the page isn't the problem. That conclusion has real value: it stops you from rewriting an essay that was never the issue.
How to read the score card
The skill emits this shape:
Overall Score: XX/100
On-Page SEO: XX/100
Content Quality: XX/100
Technical: XX/100
Schema: XX/100
Images: XX/100Rate the category using the anchored scale (90+ solid, 70-89 fixable, 50-69 weak, <50 broken), then weight the overall: on-page 20%, content 25%, technical 20%, schema 15%, images 20%. Then order the fixes: Critical first (broken canonical, a not-indexed page marked into noindex, invalid schema), then High (duplicate titles, missing descriptions, heading skips, all images without alt). Medium and Low can wait a crawler cycle.
And the rule that separates this from a random "SEO score" website: every fix gets a falsify check. Add og:image, share the URL, confirm the thumbnail appears. Fix the canonical, then check the duplicate-cluster behavior over two weeks before declaring anything failed.
Where the false alarms hide
Three rules from the source methodology worth making explicit, because they stop you from "fixing" things that aren't broken:
- Lazy loading false positive. A page using a JS lazy-loading plugin (Perfmatters, EWWW, lazysizes) actively strips the native
loading="lazy"and swaps indata-src. The scanner reports native lazy count and JS lazy count separately. If the JS count is high, do not hunt the native attr; that install is deliberate. - Site suffix. A CMS that appends "| Site Name" to the title is not a duplication problem. The check is about the primary segment length.
- Template chrome vs content images. Decorative images without alt are fine as empty alt. Do not "optimize" them. The skill says this explicitly rather than confidently reporting 14 broken alts.
Failure table
What you see | Meaning | Action |
|---|---|---|
| Auth-gated page | Ask for a public URL or the rendered HTML; don't analyze a login page |
Word count near zero + many scripts | Client-side rendered shell | Analyze what raw HTML proves; mark the rest as needs-browser (Playwright) |
| DNS/connection problem | Verify the URL and network; never guess the page content |
Schema invalid JSON | A block didn't parse | Copy it into the schema.org validator and fix the syntax |
Massive image count | Large template/chrome set | Focus alt/format work on content images only |
Install this skill by pasting to Codex
Copy this paragraph into Codex with the two code blocks above:
Read the two code blocks in the current message. Create~/.codex/skills/codex-seo-page/SKILL.md(markdown block) and~/.codex/skills/codex-seo-page/scripts/page_scan.py(python block) exactly as written. Then runpython3 ~/.codex/skills/codex-seo-page/scripts/page_scan.py https://your-site.com/your-page --json, build the score card, and give me the five category scores plus the top five fixes in priority order.
FAQ
Is this different from the technical audit skill in this series? Yes. That one checks site-level infrastructure and nine technical categories. This one examines a single URL as a reader-facing product: title, description, headings, share cards, schema, images, intent alignment. The full-site audit uses both.
Does word count actually matter? For ranking, intent matters more, but thin pages (under ~300 words for a page you want to rank) are a real ceiling. The skill treats count against page type, not a one-size number, and flags it as a hint, not a verdict.
Can I run this on my competitors? Yes. The scanner only reads public HTML. Same output, same score card; the diagnosis becomes "what does the ranking page have that mine lacks."
What about FAQ schema? The skill will never tell you to add FAQPage for rich results: Google retired FAQ rich results for all sites on 2026-05-07, and HowTo has been deprecated since September 2023. Genuine Q&A pages should use QAPage.
Next in the series (post 03 of 20): [How to Set Up Codex E-E-A-T Content Analysis (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-e-e-a-t-content) - e-e-a-t and content quality scoring.
Previous in the series: How to Audit Technical SEO with Codex (Full SKILL.md Included). The full series roadmap lists all 20 posts.
Author: Simon Vale, 11-Year Search Intent Researcher at Auspia. Simon writes about buyer queries, SERP patterns, intent mapping, and content alignment.




