How to Diagnose a Single Page's SEO Problems with Codex (Full SKILL.md Included)

Key takeaways

Point Codex at any URL and get a five-category page diagnosis: on-page elements, content signals, technical meta, schema, and images, with a score card and a prioritized fix list.

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:

bash
mkdir -p ~/.codex/skills/codex-seo-page/scripts

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

markdown
---
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:

python
#!/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

bash
python3 ~/.codex/skills/codex-seo-page/scripts/page_scan.py https://your-url.com --json

The 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:

text
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&#39;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: 3015

Now 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 author in the JSON-LD though: the scanner found no meta author and no Author: 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:

Code
Overall Score:        XX/100
On-Page SEO:          XX/100
Content Quality:      XX/100
Technical:            XX/100
Schema:               XX/100
Images:               XX/100

Rate 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 in data-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

401/403

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)

ERR: in status

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 run python3 ~/.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.

Explore this topic

Keep following the same growth thread