Files
magnus919_agent-skills/aeo/scripts/aeo_audit.py
T
Magnus HedemarkandGitHub b57fe513e8 feat: add dedicated AEO skill
Merge dedicated AEO skill with implementation references, templates, scripts, evals, and SEO routing.
2026-08-18 20:45:14 -04:00

136 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Read-only structural AEO audit for local HTML or an HTTP(S) URL."""
from __future__ import annotations
import argparse
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.parse import urljoin
class PageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title = ""
self.headings = []
self.links = []
self.jsonld = []
self.visible = []
self._tag = None
self._buf = []
self._script_buf = []
self.meta = []
self.canonical = None
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
self._tag = tag
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self._buf = []
self.headings.append({"level": int(tag[1]), "text": "", "question": False})
elif tag == "title":
self._buf = []
elif tag == "a" and attrs.get("href"):
self.links.append(attrs["href"])
elif tag == "link" and (attrs.get("rel") or "").lower() == "canonical":
self.canonical = attrs.get("href")
elif tag == "meta":
self.meta.append(attrs)
elif tag == "script" and attrs.get("type", "").lower() == "application/ld+json":
self._script_buf = []
def handle_data(self, data):
if self._tag == "script":
self._script_buf.append(data)
elif self._tag in {"title", "h1", "h2", "h3", "h4", "h5", "h6"}:
self._buf.append(data)
elif self._tag not in {"style", "noscript"}:
self.visible.append(data)
def handle_endtag(self, tag):
text = re.sub(r"\s+", " ", "".join(self._buf)).strip()
if tag == "title":
self.title = text
elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"} and self.headings:
self.headings[-1]["text"] = text
self.headings[-1]["question"] = text.endswith("?")
elif tag == "script" and self._script_buf:
raw = "".join(self._script_buf).strip()
try:
self.jsonld.append(json.loads(raw))
except json.JSONDecodeError:
self.jsonld.append({"_parse_error": True, "raw_prefix": raw[:120]})
self._script_buf = []
self._tag = None
self._buf = []
def load(source: str) -> tuple[str, str]:
if source.startswith(("http://", "https://")):
req = Request(source, headers={"User-Agent": "aeo-audit/1.0 (read-only)"})
with urlopen(req, timeout=20) as response:
return response.read().decode(response.headers.get_content_charset() or "utf-8", "replace"), response.geturl()
return Path(source).read_text(encoding="utf-8"), Path(source).resolve().as_uri()
def audit(source: str) -> dict:
html, final_url = load(source)
parser = PageParser()
parser.feed(html)
visible = re.sub(r"\s+", " ", " ".join(parser.visible)).strip()
robots = next((m.get("content", "") for m in parser.meta if (m.get("name") or "").lower() == "robots"), "")
types = []
parse_errors = 0
for block in parser.jsonld:
if block.get("_parse_error"):
parse_errors += 1
continue
values = block if isinstance(block, list) else [block]
for value in values:
for item in value.get("@graph", [value]) if isinstance(value, dict) else []:
if isinstance(item, dict) and item.get("@type"):
types.extend(item["@type"] if isinstance(item["@type"], list) else [item["@type"]])
return {
"source": source,
"final_url": final_url,
"title": parser.title,
"canonical": urljoin(final_url, parser.canonical) if parser.canonical else None,
"robots_meta": robots,
"headings": parser.headings,
"h1_count": sum(h["level"] == 1 for h in parser.headings),
"question_heading_count": sum(h["question"] for h in parser.headings),
"answer_signals": {"question_marks": visible.count("?"), "word_count": len(visible.split())},
"link_count": len(parser.links),
"jsonld_types": sorted(set(types)),
"jsonld_blocks": len(parser.jsonld),
"jsonld_parse_errors": parse_errors,
"findings": [
*(["missing title"] if not parser.title else []),
*(["expected one H1"] if sum(h["level"] == 1 for h in parser.headings) != 1 else []),
*(["no question-shaped headings detected"] if not any(h["question"] for h in parser.headings) else []),
*(["JSON-LD parse error"] if parse_errors else []),
],
"limitations": ["Read-only structure check; does not prove indexing, retrieval, citation, ranking, or conversion.", "Static parsing may miss content rendered only after JavaScript."],
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("source", help="local HTML file or HTTP(S) URL")
ap.add_argument("--json", action="store_true", dest="as_json", help="emit JSON (default)")
args = ap.parse_args()
try:
print(json.dumps(audit(args.source), indent=2, sort_keys=True))
except (OSError, ValueError, TimeoutError) as exc:
print(f"aeo-audit: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())