mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-15 21:46:29 +03:00
Merge dedicated AEO skill with implementation references, templates, scripts, evals, and SEO routing.
136 lines
5.3 KiB
Python
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())
|