
Html Seo Review
- 1 installs
- 28 repo stars
- Updated June 29, 2026
- nickcrew/claude-cortex
Audits static HTML files for on-page SEO, content quality, easy-win performance signals, and crawlability before publishing.
About
Audits one or more static HTML files for on-page SEO, content quality, easy-win performance signals, and crawlability. A developer uses it to check static landing pages before publishing, not for framework source or off-page factors.
- On-page SEO, content-quality, and crawlability checks
- Scoped to static HTML only, not Jekyll/Hugo/Astro/Next.js source
Html Seo Review by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,710 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nickcrew/claude-cortex --skill html-seo-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 28 |
| Last updated | June 29, 2026 |
| Repository | nickcrew/claude-cortex ↗ |
What it does
Audits static HTML files for on-page SEO, content quality, easy-win performance signals, and crawlability before publishing.
Files
HTML SEO Review
Overview
Produce an actionable, severity-ranked findings list for one or more static HTML files. The skill bundles a stdlib-only HTML parser that extracts every SEO-relevant signal into a single JSON document, a comprehensive rubric, and a findings report template — together they let an audit run with predictable output every time.
When to use
Use when the input is one or more static .html files (or a directory of them) and the user wants on-page SEO findings they can act on. Triggers include "SEO review", "SEO audit", "check SEO on this HTML", "review meta tags on this page", "audit on-page SEO".
Do not use when:
- The artifact is source for a static-site generator (Jekyll/Hugo/Astro/Eleventy/Next.js). Those need source-aware review; defer to a future
ssg-seo-reviewskill. - The user wants live performance metrics (LCP, CLS, INP). HTML alone cannot produce these; recommend Lighthouse or PageSpeed Insights.
- The user wants backlink, domain authority, or other off-page analysis. That requires third-party tools (Ahrefs, SEMrush, etc.).
Workflow
1. Extract signals
Run the parser against the target. It accepts a single file or a directory (recursive *.html):
python3 scripts/extract_seo_signals.py <path> --prettyOutput is a JSON document with the following top-level keys: lang, title, meta, links, scripts, headings, images, links_audit, body_word_count. Each element carries a line number for citing in findings.
For a directory, the output is {"files": [<per-file object>]}.
The script uses only Python's standard library — no pip install required. If parsing fails on a specific file, the result for that file contains an error field and the audit continues for the rest.
2. Map signals to the rubric
Load references/seo-checklist.md. Walk each section in order and check the corresponding JSON path. The rubric explicitly lists the path to inspect for every check (e.g., images.missing_alt, meta.description.length, scripts.render_blocking_candidates).
For each failed check, record:
- The severity (P0/P1/P2/P3) from the rubric.
- The line number from the JSON output.
- The exact quoted markup or attribute value from the file (re-read the file with the line number to quote verbatim — never paraphrase).
- The fix, written as a concrete before/after diff when the change is mechanical.
3. Verify each finding before reporting
Before writing a finding, confirm the issue mechanically:
- A claim that an attribute is "missing" must come from the parser's structured output, not from skimming the HTML.
- A claim that a value is "too short/long" must cite the actual character count from the JSON.
- Quoted markup must be exact; if a quote can't be reproduced character-for-character from the file, drop it.
This protects against the most common review failure: fabricating a problem that isn't actually there.
4. Produce the findings report
Use assets/findings-template.md as the structure. Fill in:
- Summary paragraph with overall verdict and severity counts.
- One block per finding, sorted by severity (P0 → P3) then by file order.
- An optional "Quick wins" section when the finding count exceeds ~10 — pick the 3-5 highest-leverage fixes.
- An "Out-of-scope items noted" section listing anything the user should follow up with external tools (Lighthouse for real CWV, curl for canonical resolution, etc.).
If the audit covers multiple files, produce one report covering all of them, with findings grouped by file under each severity tier — not one report per file.
5. Skip gracefully
If a check requires information the parser cannot produce (e.g., "is this canonical URL serving a 200?"), do not guess. Note it in the "Out-of-scope items" section of the report and move on.
Reference
scripts/extract_seo_signals.py— HTML parser. Stdlib-only. Outputs structured JSON with line numbers.references/seo-checklist.md— Full rubric, organized by category, with severity tiers and JSON paths for every check.assets/findings-template.md— Output format for the audit report.
SEO Review — <file or directory>
Reviewed: <path> · Date: <YYYY-MM-DD> · Body word count: <n>
Summary
<one-paragraph verdict: overall health, key strengths, top risks>
| Severity | Count |
|---|---|
| P0 — Blocks indexing | <n> |
| P1 — Harms ranking/CTR | <n> |
| P2 — Best-practice gap | <n> |
| P3 — Polish | <n> |
---
Findings
Each finding follows: [Severity] Title path:line — problem, then fix.[P0] <short title>
- Location:
<file>:<line> - Evidence:
<exact quoted attribute or element> - Problem:
<what's wrong and why it matters — reference the rubric section> - Fix:
<concrete, copy-pastable change>
<!-- before -->
<existing markup>
<!-- after -->
<corrected markup>---
[P1] <short title>
- Location:
<file>:<line> - Evidence:
<quoted code> - Problem:
<…> - Fix:
<…>
---
[P2] <short title>
- Location:
<file>:<line> - Evidence:
<quoted code> - Problem:
<…> - Fix:
<…>
---
Out-of-scope items noted
<List anything that requires live fetches, rendering, or external tools — e.g., "Verify canonical resolves to a 200 with curl", "Run Lighthouse for real LCP/CLS numbers". Skip this section if nothing applies.>
Quick wins (do these first)
<Optional ordered list of the highest-leverage fixes — usually 3–5 items selected from the findings above. Useful when there are >10 findings and the user needs a starting point.>
HTML SEO Review Checklist
Comprehensive rubric for auditing static HTML files. Every check below maps to a specific path in the JSON output of scripts/extract_seo_signals.py.
Severity tiers
- P0 — Blocks search visibility. Page won't index, will be deindexed, or critical metadata is absent.
- P1 — Harms ranking or click-through. Real-world SEO impact, but page still indexes.
- P2 — Best-practice gap. Likely no immediate ranking impact, but missing recommended signal.
- P3 — Polish. Minor improvement; bring up only if everything else is clean.
---
1. Document fundamentals
1.1 <html lang="…"> set — P1
- Signal path:
lang - Pass: non-empty string (e.g.,
"en","en-US") - Fail:
nullor empty - Why: Helps search engines and assistive tech determine language for the page; affects regional ranking and accessibility.
1.2 <meta charset> declared — P2
- Signal path:
meta.charset - Pass: present, ideally
"utf-8" - Why: Without an explicit charset, browsers may misrender characters before the parser figures it out.
1.3 Viewport meta — P1
- Signal path:
meta.viewport - Pass: present and contains
width=device-width - Why: Mobile-first indexing. Pages without a viewport meta are flagged as not mobile-friendly and lose ranking on mobile results.
---
2. Title and description
2.1 Title present — P0
- Signal path:
title.text - Pass: non-empty
- Fail: missing or empty
- Why: Title is the single most important on-page SEO signal. Missing titles cause Google to synthesize one (poorly) or skip indexing entirely.
2.2 Title length 30–60 characters — P1
- Signal path:
title.length - Pass: 30 ≤ length ≤ 60
- Soft warn: 25–29 (too short, low keyword surface) or 61–70 (will likely truncate)
- Fail: <25 or >70
- Why: Google truncates titles around 580px in SERPs, which works out to roughly 60 chars depending on glyph width. Too short signals thin content.
2.3 Meta description present — P1
- Signal path:
meta.description - Pass: present
- Why: Not a direct ranking factor since 2009, but heavily influences click-through rate. Google often uses it as the SERP snippet when it matches the query.
2.4 Meta description length 70–160 characters — P2
- Signal path:
meta.description.length - Pass: 70 ≤ length ≤ 160
- Why: Truncation cutoff is ~160 characters on desktop, ~120 on mobile. Below 70 wastes the slot.
---
3. Heading structure
3.1 Exactly one <h1> — P1
- Signal path:
headings.h1_count - Pass:
== 1 - Fail:
0(P1 — no primary heading) or> 1(P2 — competing primary headings; HTML5 technically allows multiple but search engines still prefer one) - Why: Search engines weigh the H1 heavily as a topical signal. Zero H1s leaves the topic implicit; multiple dilute it.
3.2 No skipped heading levels — P2
- Signal path:
headings.outline - Pass: levels increase by at most 1 at a time when descending (e.g., H1 → H2 → H3 OK; H1 → H3 not OK)
- Why: Document outline algorithms (and screen readers) rely on sequential nesting to build the page structure. Skipping levels confuses both.
3.3 H1 not duplicating title verbatim — P3
- Signal paths:
title.text,headings.outline[0] - Why: Some duplication is fine and even expected, but a page where every heading is identical to the title is a weak topical signal.
---
4. Indexability and canonicalization
4.1 Robots meta does not block indexing — P0
- Signal path:
meta.robots.content - Fail: contains
noindex(page won't appear in search) ornofollow(links won't pass authority) - Why: Easiest way to silently disappear from search. Always confirm intent —
noindexmay be deliberate on staging/auth pages.
4.2 Canonical URL declared — P1
- Signal path:
links.canonical - Pass: present and a valid absolute URL
- Fail: missing on a page that may have query-string or trailing-slash variants
- Why: Without canonical, duplicate-content variants compete for the same ranking slot, splitting authority.
4.3 Canonical points to itself or a single source-of-truth — P1
- Signal path:
links.canonical.href - Manual check: confirm the canonical URL matches the page's actual public URL (or intentionally points to a parent/aggregate page).
- Why: Wrong canonical (e.g., copy-paste from a template) can deindex a page entirely.
4.4 hreflang alternates if multilingual — P2
- Signal path:
links.alternates - Why: Without hreflang, Google may serve the wrong language version in the wrong region.
---
5. Open Graph and Twitter Card
5.1 og:title — P2
- Signal path:
meta.og['og:title'] - Why: Drives the preview when the URL is shared on Facebook, LinkedIn, Slack, etc. Falls back to
<title>but a tailored OG title performs better.
5.2 og:description — P2
- Signal path:
meta.og['og:description']
5.3 og:image — P2
- Signal path:
meta.og['og:image'] - Why: Without an OG image, social shares fall back to a default crop or a logo, drastically reducing click-through.
5.4 og:url and og:type — P3
- Signal paths:
meta.og['og:url'],meta.og['og:type']
5.5 twitter:card — P3
- Signal path:
meta.twitter['twitter:card'] - Why: Twitter falls back to OG tags if absent, so this is polish unless the page heavily targets Twitter previews.
---
6. Structured data (JSON-LD)
6.1 At least one valid JSON-LD block — P2 (P1 for content pages)
- Signal path:
scripts.jsonld - Pass:
valid: trueanddata['@type']is appropriate (Article,Product,FAQPage,Recipe, etc.) - Why: Required for rich results (review stars, FAQ accordion, breadcrumbs in SERP). Schema matters more on content/commerce pages than on static landing pages.
6.2 No JSON-LD parse errors — P1
- Signal path:
scripts.jsonld[*].valid - Fail: any
valid: false - Why: Invalid JSON-LD is ignored entirely by Google — silent failure of intended rich-result eligibility.
---
7. Images
7.1 No images missing alt attribute — P1
- Signal path:
images.missing_alt - Pass: empty array
- Why: Direct accessibility regression and a soft SEO signal — alt text is one of the few things Google can use to understand image content.
7.2 Empty alt only on decorative images — P2
- Signal path:
images.empty_alt - Manual check: review each —
alt=""is correct for purely decorative images (icons, dividers) but wrong for content images. - Why: Empty alt explicitly signals "skip me" to screen readers; using it on content images is worse than omitting alt entirely.
7.3 Width and height attributes set — P2
- Signal path:
images.missing_dimensions - Why: Reserves layout space, prevents Cumulative Layout Shift (CLS), which is a Core Web Vital and a ranking factor.
7.4 loading="lazy" on below-the-fold images — P3
- Signal path:
images.no_lazy_loading - Manual check: the first image (often the hero / LCP candidate) should NOT be lazy-loaded; everything below the fold should be.
- Why: Lazy-loading hero images delays Largest Contentful Paint, which is the opposite of what you want.
---
8. Performance signals (easy wins only)
8.1 No render-blocking sync scripts in <head> — P1
- Signal path:
scripts.render_blocking_candidates - Pass: empty, or scripts are inline-only / placed at end of
<body> - Why: Each blocking script delays First Contentful Paint by its download + parse time. Trivial fix: add
defer(preserves order, runs after parse) orasync(no order guarantee).
8.2 Stylesheets minimized — P3
- Signal path:
links.stylesheets - Manual check: small number of stylesheet
<link>tags, ideally one or two. Many small stylesheets = many roundtrips.
8.3 Use of preload for critical resources — P3
- Signal path:
links.preloads - Why: Preloading critical fonts/hero images can move LCP earlier, but only worth flagging on pages already otherwise clean.
---
9. Links
9.1 No empty or hash-only anchor hrefs — P2
- Signal path:
links_audit.empty_or_hash - Pass: empty array (or all entries are clearly buttons/dropdowns implemented as anchors — judge case-by-case)
- Why: Google ignores empty hrefs; on multiple pages, they accumulate as crawl waste.
9.2 External links use rel="noopener" (and usually nofollow or ugc) — P3
- Signal path:
links_audit.external[*].rel - Why: Security (
noopener), and ranking-control (nofollow/ugcfor user-submitted/sponsored links). Not always required.
9.3 Anchor text is descriptive — P2
- Signal path:
links_audit.internal[*].text,links_audit.external[*].text - Manual check: flag anchors with text like "click here," "read more," "this," or empty — they pass no topical signal to the destination.
---
10. Content depth
10.1 Body word count appropriate to page type — P2
- Signal path:
body_word_count - Heuristics: Landing/home: 100+ usually fine. Article/blog: 300+ as a minimum, 600+ for competitive topics. Product page: 150+ of unique copy beyond spec sheets.
- Why: Thin content underperforms. But word count is a coarse proxy — a 200-word answer that perfectly matches intent beats a 2000-word ramble.
---
Checks intentionally NOT in this rubric (out of scope)
The skill is HTML-only. The following require live fetches, rendering, or external services and are explicitly out of scope:
- Real Core Web Vitals (LCP/CLS/INP) — needs a rendering engine.
- Backlink profile, domain authority — needs third-party data.
- robots.txt, sitemap.xml — site-level, not page-level.
- HTTP status codes, redirect chains — needs network access.
- JavaScript-rendered content audit — needs a headless browser.
If a finding requires any of these, note it as "out of scope for HTML review; recommend follow-up with [tool]" rather than guessing.
#!/usr/bin/env python3
"""Extract SEO signals from a static HTML file into a single JSON document.
Stdlib only — no external dependencies.
Usage:
python3 extract_seo_signals.py <path-to-html> [--pretty]
python3 extract_seo_signals.py <path-to-directory> # all *.html, recursive
Output: JSON to stdout. One object per file under "files" when a directory is given.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
SELF_CLOSING = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
}
HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}
class SEOExtractor(HTMLParser):
"""Walks an HTML document and records SEO-relevant elements with positions."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title: dict[str, Any] | None = None
self._in_title = False
self._title_buf: list[str] = []
self._title_pos: tuple[int, int] | None = None
self.metas: list[dict[str, Any]] = []
self.links: list[dict[str, Any]] = []
self.scripts: list[dict[str, Any]] = []
self._in_script = False
self._script_attrs: dict[str, Any] = {}
self._script_pos: tuple[int, int] | None = None
self._script_buf: list[str] = []
self.headings: list[dict[str, Any]] = []
self._heading_stack: list[dict[str, Any]] = []
self._heading_buf: list[str] = []
self.images: list[dict[str, Any]] = []
self.anchors: list[dict[str, Any]] = []
self._anchor_stack: list[dict[str, Any]] = []
self._anchor_buf: list[str] = []
self.html_attrs: dict[str, str] = {}
self.body_word_count = 0
self._in_body = False
self._in_text_skip = 0 # depth counter for <script>/<style>/<noscript>
# ------- helpers -------
@staticmethod
def _attrs_to_dict(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
return {k: (v if v is not None else "") for k, v in attrs}
def _pos(self) -> dict[str, int]:
line, col = self.getpos()
return {"line": line, "col": col}
# ------- handlers -------
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
a = self._attrs_to_dict(attrs)
pos = self._pos()
if tag == "html":
self.html_attrs = a
elif tag == "body":
self._in_body = True
elif tag == "title":
self._in_title = True
self._title_buf = []
self._title_pos = (pos["line"], pos["col"])
elif tag == "meta":
self.metas.append({"attrs": a, **pos})
elif tag == "link":
self.links.append({"attrs": a, **pos})
elif tag == "script":
self._in_script = True
self._script_attrs = a
self._script_pos = (pos["line"], pos["col"])
self._script_buf = []
self._in_text_skip += 1
elif tag in {"style", "noscript"}:
self._in_text_skip += 1
elif tag in HEADING_TAGS:
entry = {"tag": tag, "level": int(tag[1]), "text": "", "attrs": a, **pos}
self._heading_stack.append(entry)
self._heading_buf = []
elif tag == "img":
self.images.append({"attrs": a, **pos})
elif tag == "a":
entry = {"attrs": a, "text": "", **pos}
self._anchor_stack.append(entry)
self._anchor_buf = []
# treat self-closing tags consistently
if tag in SELF_CLOSING:
return
def handle_endtag(self, tag: str) -> None:
if tag == "title" and self._in_title:
self._in_title = False
text = "".join(self._title_buf).strip()
line, col = self._title_pos or (0, 0)
self.title = {"text": text, "length": len(text), "line": line, "col": col}
elif tag == "script" and self._in_script:
self._in_script = False
line, col = self._script_pos or (0, 0)
self.scripts.append({
"attrs": self._script_attrs,
"content": "".join(self._script_buf),
"line": line,
"col": col,
})
self._in_text_skip = max(0, self._in_text_skip - 1)
elif tag in {"style", "noscript"}:
self._in_text_skip = max(0, self._in_text_skip - 1)
elif tag in HEADING_TAGS and self._heading_stack:
entry = self._heading_stack.pop()
entry["text"] = "".join(self._heading_buf).strip()
self.headings.append(entry)
self._heading_buf = []
elif tag == "a" and self._anchor_stack:
entry = self._anchor_stack.pop()
entry["text"] = "".join(self._anchor_buf).strip()
self.anchors.append(entry)
self._anchor_buf = []
elif tag == "body":
self._in_body = False
def handle_data(self, data: str) -> None:
if self._in_title:
self._title_buf.append(data)
if self._in_script:
self._script_buf.append(data)
if self._heading_stack:
self._heading_buf.append(data)
if self._anchor_stack:
self._anchor_buf.append(data)
if self._in_body and self._in_text_skip == 0:
words = re.findall(r"\b\w+\b", data)
self.body_word_count += len(words)
def _summarize_meta(metas: list[dict[str, Any]]) -> dict[str, Any]:
"""Pull commonly-checked meta tags into a flat summary."""
summary: dict[str, Any] = {
"description": None,
"robots": None,
"viewport": None,
"charset": None,
"og": {},
"twitter": {},
"other_named": {},
}
for m in metas:
a = m["attrs"]
if "charset" in a:
summary["charset"] = a["charset"]
continue
name = (a.get("name") or "").lower()
prop = (a.get("property") or "").lower()
content = a.get("content", "")
if name == "description":
summary["description"] = {"content": content, "length": len(content), "line": m["line"]}
elif name == "robots":
summary["robots"] = {"content": content, "line": m["line"]}
elif name == "viewport":
summary["viewport"] = {"content": content, "line": m["line"]}
elif prop.startswith("og:"):
summary["og"][prop] = content
elif name.startswith("twitter:"):
summary["twitter"][name] = content
elif name:
summary["other_named"][name] = content
return summary
def _summarize_links(links: list[dict[str, Any]]) -> dict[str, Any]:
canonical = None
alternates: list[dict[str, Any]] = []
stylesheets: list[dict[str, Any]] = []
preloads: list[dict[str, Any]] = []
for l in links:
a = l["attrs"]
rel = (a.get("rel") or "").lower()
if rel == "canonical":
canonical = {"href": a.get("href", ""), "line": l["line"]}
elif rel == "alternate":
alternates.append({"hreflang": a.get("hreflang"), "href": a.get("href", ""), "line": l["line"]})
elif rel == "stylesheet":
stylesheets.append({"href": a.get("href", ""), "media": a.get("media"), "line": l["line"]})
elif rel == "preload":
preloads.append({"href": a.get("href", ""), "as": a.get("as"), "line": l["line"]})
return {"canonical": canonical, "alternates": alternates, "stylesheets": stylesheets, "preloads": preloads}
def _classify_anchors(anchors: list[dict[str, Any]], page_origin: str | None = None) -> dict[str, Any]:
internal = []
external = []
fragment = []
empty = []
for a in anchors:
href = a["attrs"].get("href", "").strip()
text = a["text"]
rel = a["attrs"].get("rel", "")
line = a["line"]
record = {"href": href, "text": text, "rel": rel, "line": line}
if not href or href == "#":
empty.append(record)
elif href.startswith("#"):
fragment.append(record)
elif re.match(r"^https?://", href):
external.append(record)
else:
internal.append(record)
return {"internal": internal, "external": external, "fragment": fragment, "empty_or_hash": empty}
def _scripts_summary(scripts: list[dict[str, Any]]) -> dict[str, Any]:
jsonld: list[dict[str, Any]] = []
blocking_in_head = []
deferred_async = []
other = []
for s in scripts:
a = s["attrs"]
s_type = (a.get("type") or "").lower()
if s_type == "application/ld+json":
try:
parsed = json.loads(s["content"])
jsonld.append({"line": s["line"], "valid": True, "data": parsed})
except json.JSONDecodeError as e:
jsonld.append({"line": s["line"], "valid": False, "error": str(e)})
continue
has_src = "src" in a
has_async = "async" in a
has_defer = "defer" in a
record = {
"src": a.get("src"),
"async": has_async,
"defer": has_defer,
"type": a.get("type"),
"line": s["line"],
}
if has_src and not (has_async or has_defer):
blocking_in_head.append(record)
elif has_async or has_defer:
deferred_async.append(record)
else:
other.append(record)
return {
"jsonld": jsonld,
"render_blocking_candidates": blocking_in_head,
"async_or_defer": deferred_async,
"inline_or_other": other,
}
def extract_signals(html: str, source_path: str | None = None, file_size: int | None = None) -> dict[str, Any]:
parser = SEOExtractor()
parser.feed(html)
images_summary = []
for img in parser.images:
a = img["attrs"]
images_summary.append({
"src": a.get("src", ""),
"alt": a.get("alt"),
"alt_present": "alt" in a,
"alt_empty": "alt" in a and a.get("alt", "") == "",
"width": a.get("width"),
"height": a.get("height"),
"loading": a.get("loading"),
"line": img["line"],
})
h1s = [h for h in parser.headings if h["level"] == 1]
return {
"source": source_path,
"file_size_bytes": file_size,
"lang": parser.html_attrs.get("lang"),
"title": parser.title,
"meta": _summarize_meta(parser.metas),
"links": _summarize_links(parser.links),
"scripts": _scripts_summary(parser.scripts),
"headings": {
"all": sorted(parser.headings, key=lambda h: (h["line"], h["col"])),
"h1_count": len(h1s),
"outline": [{"level": h["level"], "text": h["text"], "line": h["line"]} for h in sorted(parser.headings, key=lambda h: (h["line"], h["col"]))],
},
"images": {
"all": images_summary,
"total": len(images_summary),
"missing_alt": [i for i in images_summary if not i["alt_present"]],
"empty_alt": [i for i in images_summary if i["alt_empty"]],
"missing_dimensions": [i for i in images_summary if not (i["width"] and i["height"])],
"no_lazy_loading": [i for i in images_summary if i["loading"] != "lazy"],
},
"links_audit": _classify_anchors(parser.anchors),
"body_word_count": parser.body_word_count,
}
def _gather_paths(target: Path) -> list[Path]:
if target.is_file():
return [target]
if target.is_dir():
return sorted(p for p in target.rglob("*.html") if p.is_file())
raise FileNotFoundError(f"Not a file or directory: {target}")
def main() -> int:
ap = argparse.ArgumentParser(description="Extract SEO signals from static HTML.")
ap.add_argument("target", help="Path to .html file or directory of HTML files")
ap.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
args = ap.parse_args()
target = Path(args.target).expanduser().resolve()
paths = _gather_paths(target)
if not paths:
print(json.dumps({"error": "no HTML files found", "target": str(target)}))
return 1
results = []
for p in paths:
try:
html = p.read_text(encoding="utf-8", errors="replace")
size = p.stat().st_size
results.append(extract_signals(html, source_path=str(p), file_size=size))
except Exception as e:
results.append({"source": str(p), "error": f"{type(e).__name__}: {e}"})
payload = results[0] if (target.is_file() and len(results) == 1) else {"files": results}
indent = 2 if args.pretty else None
print(json.dumps(payload, indent=indent, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())