Commercial SEO crawlers are excellent. You do not need one of them to learn the pattern: read robots.txt, fetch a few pages politely, measure a handful of on-page signals, and write the findings somewhere a human can act on.
In this tutorial we build a small, polite Python script that:
- reads
robots.txtand stops ifUser-agent: *(and our bot) may not fetch the start URL, - crawls a tiny site breadth-first with hard caps (pages, depth, timeout, body size),
- per page records title / meta description length, H1 count, canonical, meta robots /
X-Robots-Tag, basic Open Graph presence, images missingalt, internal vs external link counts, HTTP status and redirect hops, - checks a few broken internal links only (HEAD, with GET fallback),
- reads whether
robots.txtnames common AI crawlers (GPTBot, Claude-Web, Google-Extended, …) — read rules only, never impersonate those bots, - optionally probes for
llms.txtandsitemap.xml, - writes CSV and JSON, and attaches a simple 0–100 score with plain-English fix hints.
Dependencies: Python 3.9+ (tested on 3.13) and requests. HTML and robots parsing use the standard library. The full script is at the end. Every figure below comes from live runs on 6 November 2026.
The checks are a small educational subset. They are incomplete on purpose. Treat them as teaching examples, not as a product rule catalogue.
Why pure Python (and only requests)
There is nothing wrong with Playwright, Scrapy or a hosted auditor. For a tutorial, a single file you can read top to bottom beats a framework:
-
urllib.robotparserandhtml.parserare in the standard library. -
requestshandles TLS, redirects and a session cookie jar without ceremony. - Hard caps (pages, depth, bytes, link-check budget) keep the script safe to point at a stranger's documentation site once.
- You can paste the script into a throwaway venv and delete it when you are done.
If you later move the same ideas into an async service, keep the politeness contract: robots first, clear User-Agent, rate limit, capped bodies, no personal data.
What we will not do
- Crawl a whole production site. Default caps are 10 pages and depth 2.
- Log in, solve captchas, rotate proxies or ignore
Disallow. - Harvest
mailto:/tel:links, form contents or anything that looks like a person. - Send
User-Agent: GPTBot(or any other third-party bot name) to see what happens. We only read those rules fromrobots.txt. - Pitch a hosted product. This is a standalone script you can read and run.
One site, several layers of signal
Take crawlee.dev from the live run on 6 November 2026. After robots.txt allowed our bot and *, an eight-page crawl (depth ≤ 2) returned:
| Layer | What showed up |
|---|---|
| Homepage | HTTP 200, title 40 chars, meta description 146 chars, one H1, self-canonical, Open Graph present, score 100 |
| Site extras |
sitemap.xml declared in robots and present; llms.txt HTTP 200 |
| Deeper pages | Blog title 107 chars (soft-max notice); a couple of JS/Python pages with images missing alt
|
| Roll-up | Average score 97.8 across 8 pages; no broken internal links in the sample |
That mix is the point of the exercise. A homepage that scores 100 can still sit next to a blog index with a long title and no H1. A useful mini auditor shows both — still with a tiny crawl budget.
Step 1: be a polite bot
Identify yourself. The educational User-Agent in this tutorial is:
LindenwerkDataEducationalBot/0.1 (+https://dev.to/lindenwerkdata; educational tiny SEO site-audit tutorial; honours robots.txt)
Then honour RFC 9309-style robots rules with urllib.robotparser. The script fetches /robots.txt first (capped body size). Practical rules we use:
-
HTTP 404 / other 4xx on robots.txt → treat as no rules, allow the fetch (common convention;
example.comreturned 404 in our run and we continued). - HTTP 5xx on robots.txt → treat as disallow (conservative).
- Otherwise parse the file and require both our bot name and
User-agent: *to be allowed for the URL we are about to request. - Every later page and internal link check goes through the same parser.
On 6 November 2026, crawlee.dev and docs.python.org served normal robots files that allow / for * and our bot. A unit check of RobotFileParser with Disallow: / correctly returns False for /.
from urllib.parse import urlsplit, urlunsplit
from urllib.robotparser import RobotFileParser
def robots_allows(session, page_url):
parts = urlsplit(page_url)
robots_url = urlunsplit((parts.scheme, parts.netloc, "/robots.txt", "", ""))
r = session.get(robots_url, timeout=15, stream=True)
# ... read a capped body ...
if 400 <= r.status_code < 500:
return True, f"robots.txt {r.status_code} (no rules → allow)"
if r.status_code >= 500:
return False, f"robots.txt {r.status_code} (treat as disallow)"
rp = RobotFileParser()
rp.parse(body.decode("utf-8", "replace").splitlines())
target = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", parts.query, ""))
allowed = rp.can_fetch("LindenwerkDataEducationalBot", target) and rp.can_fetch("*", target)
return allowed, "robots.txt OK"
If robots say no, the site row is stored as robots_disallowed and no HTML crawl is started.
Step 2: a tiny breadth-first crawl
from collections import deque
queue = deque([(start_url, 0)])
seen = set()
while queue and len(pages) < max_pages:
url, depth = queue.popleft()
if url in seen:
continue
seen.add(url)
if not robots_allows_url(robots, url):
continue
# GET with timeout + 1 MB body cap, then parse …
if depth < max_depth:
for href in internal_hrefs:
queue.append((href, depth + 1))
Defaults in the script: 10 pages, depth 2, 0.75 s pause between requests, 15 s timeout, 1 MB HTML cap. Query strings are stripped when discovering new URLs (keeps the frontier small). Static asset extensions (.png, .css, .js, …) are skipped. mailto: and tel: are never followed.
Live on 6 November 2026 with --max-pages 8 --max-depth 2:
-
example.com→ 1 page (no further internal links) -
crawlee.dev→ 8 pages -
docs.python.org→ 8 pages -
httpbin.org→ 2 pages
Step 3: per-page on-page checks
An HTMLParser subclass collects <title>, meta tags, H1 count, rel=canonical, Open Graph keys, image alt presence and anchor hrefs — without walking mailto: / tel:.
For each page the script records:
| Signal | How we treat it (educational soft targets) |
|---|---|
| HTTP status / redirects | 4xx/5xx = error; ≥2 redirect hops = warning |
| Title length | missing = error; < 30 = warning; > 60 = notice |
| Meta description | missing = warning; outside ~70–160 = notice |
| H1 | 0 = warning; > 1 = notice |
| Canonical | missing = notice; cross-domain = warning; self vs other labelled |
| Indexability |
noindex in meta robots or X-Robots-Tag = warning |
| Open Graph | no og:title / og:description / og:image / og:url = notice |
| Images | count of <img> with missing/empty alt
|
example.com on 6 November 2026 is a useful teaching case. The live HTML is a short documentation stub: title "Example Domain" (14 characters), no H1, no meta description, no canonical, no Open Graph. Score 86. (Older textbook copies of that page had an H1 — the public page changed; the script reports what the network returns today.)
crawlee.dev/ scored 100 on the same day: title 40 chars, description 146, one H1, self-canonical, Open Graph keys present, 14 images all with alt text.
Step 4: a few broken internal links only
Full-site link checking is expensive and easy to abuse. The educational script keeps a small pool of internal URLs discovered during the crawl that were not already fetched as pages, then:
- skips any URL robots disallow,
- sends HEAD (falls back to a tiny GET if the host rejects HEAD),
- stops after
--max-link-checks(default 15).
Broken samples land on the site row as broken_internal_links and contribute a site-level broken_internal_link issue. On our 6 November 2026 runs across example.com, crawlee.dev, docs.python.org, httpbin.org and cloudflare.com, the sampled internal links all responded OK — which is a fine outcome; the plumbing is what you are learning.
Step 5: read AI-bot rules (do not wear their badges)
Many sites now name AI crawlers explicitly in robots.txt. The script looks for a short list (GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, Anthropic-AI, Claude-Web, Google-Extended, PerplexityBot, CCBot, …) and, for each:
- whether the name appears in the file,
- whether
RobotFileParser.can_fetch(bot_name, homepage)is true, - a one-line policy hint.
Important: page fetches and link checks always use our educational User-Agent. We never send User-Agent: GPTBot to "see what the CDN does". Impersonating another bot is rude, often against terms, and unnecessary for reading a public rules file.
On 6 November 2026, cloudflare.com/robots.txt explicitly named GPTBot, ChatGPT-User, Google-Extended, Anthropic-AI, Claude-Web, CCBot, PerplexityBot (and Cohere-ai) with Allow: / for each. The script reported named_in_robots: true and homepage_allowed: true for those entries. crawlee.dev and docs.python.org named none of the AI bots in our teaching list — they fall under User-agent: *.
Step 6: optional llms.txt and sitemap presence
Two cheap HEAD/GET probes (still rate-limited):
-
/llms.txt— present or not (reported as information; search engines may ignore it). - Sitemap — first
Sitemap:URL from robots.txt, else/sitemap.xml.
Live on 6 November 2026:
| Site | Sitemap | llms.txt |
|---|---|---|
| example.com | 404 | 404 |
| crawlee.dev | 200 (from robots) | 200 |
| docs.python.org | 200 (from robots) | 404 |
| httpbin.org | 404 | 404 |
| cloudflare.com | 200 (from robots) | 200 |
Step 7: score, fix hints, CSV and JSON
Each page starts at 100. Unique issue ids subtract 10 (error), 4 (warning) or 1 (notice). Every issue carries a short fix string a non-engineer can read:
{
"id": "title_short",
"severity": "warning",
"message": "Title only 14 chars (soft min ~30)",
"fix": "Expand the title toward ~30–60 characters without stuffing."
}
Outputs:
-
JSON — site row (
rowType: "site") plus one page row per URL (rowType: "page"), including the AI-bot matrix and broken-link samples. -
CSV — flat spreadsheet of page metrics plus a
[site]summary row with average score and top issue ids.
Soft length targets are teaching defaults, not Google policy quotes. Adjust them for your own style guide.
Limitations you should expect
- Client-rendered apps may ship an almost empty HTML shell. A single GET will under-count H1s and text. Headless browsers are out of scope here on purpose.
- Soft title/description ranges are conventions, not guarantees of ranking.
-
Broken-link sample ≠ full link audit. Raising
--max-link-checksstill only samples the frontier; it is not a substitute for a dedicated checker. - AI-bot matrix is a reading aid. Bot names change; keep the list short and honest.
- The rule list is tiny. Real auditors have dozens of rule types (hreflang, JSON-LD, thin content, mixed content, …). Growing yours is fine — keep evidence, keep caps, keep robots.
Scheduling a weekly re-audit
Companion file crontab.txt:
CRON_TZ=Europe/Berlin
30 7 * * 1 cd /path/to/code && ./venv/bin/python seo_audit.py -f domains.txt --max-pages 10 --max-depth 2 --out-dir out >> out/cron.log 2>&1
Keep max-pages low. A free shared runner is not a reason to hammer strangers. Diff this week's JSON against last week's if you want a change watch — the educational script itself does not ship a state file (unlike a one-page tech detector); a jq diff is enough to start.
Live results (6 November 2026)
Fresh venv, Python 3.13.5, requests 2.32.4. Public sites, educational caps:
| Input | Pages | Avg score | robots | Notable findings |
|---|---|---|---|---|
| example.com | 1 | 86.0 | 404 → allow | Title 14 chars; no H1 / meta description / canonical / OG; no sitemap / llms.txt |
| crawlee.dev | 8 | 97.8 | allow | Homepage score 100; sitemap + llms.txt present; some long titles / missing alt deeper in |
| docs.python.org | 8 | 95.0 | allow | Many short titles (docs pattern); sitemap present; llms.txt 404 |
| httpbin.org | 2 | 83.0 | allow | Homepage short title; /forms/post missing title |
| cloudflare.com | 5 | 95.6 | allow | AI bots explicitly named + allowed; sitemap + llms.txt present |
Outputs from these runs contained zero e-mail addresses and zero phone numbers. The script never parses mailto: / tel: and never posts forms.
Run it yourself
python3 -m venv venv
./venv/bin/pip install -r requirements.txt # pins requests
./venv/bin/python seo_audit.py \
example.com crawlee.dev docs.python.org \
--max-pages 10 --max-depth 2 --out-dir out
Useful flags: --max-link-checks 15, --no-llms, --no-sitemap, --file domains.txt, --pause 1.0. An example crontab.txt in the companion folder shows a Monday weekly re-audit; it is not installed for you.
Where to take it next
- Feed a CSV of your own sites or client domains and keep the pause (or slower).
- Raise caps only when you have permission and a reason.
- Add duplicate-title detection across the small page set you already fetched.
- Add security-header scoring (HSTS, CSP) as a separate optional block.
- Export the issues array into a spreadsheet your editor can tick through.
- For agency lead lists, start from domains you are allowed to review — do not scrape personal contact details to build those lists.
Still a tiny crawl. Still no personal data. Still your script, under your control.
Privacy checklist (short)
Before you point this at someone else's property in bulk:
- You have a legitimate reason (your inventory, a client who asked, research you are allowed to do).
- robots.txt is honoured; a clear bot name is in the User-Agent; requests are rate-limited.
- Outputs are scanned for e-mails and phone numbers (our live runs: zero).
- Form posts, account areas,
mailto:/tel:and cookie values are off limits. - AI-bot names are read from robots.txt — never sent as the request User-Agent.
- Soft SEO scores are not sold as a ranking certificate.
That checklist is operational hygiene, not legal advice.
Terms and honesty
Only public HTML, headers and robots.txt are used. Honour robots.txt and site terms. Do not use this pattern to build bulk marketing lists of people. Vendor and bot names (Google, OpenAI, Anthropic, Cloudflare, …) are used as factual labels for publicly visible rules and pages; this tutorial is independent and not affiliated with or endorsed by those organisations. Soft title/description lengths are teaching conventions, not quotations of any search engine's documentation.
Full script: seo_audit.py
#!/usr/bin/env python3
"""Educational mini SEO site auditor (pure Python).
Polite tiny crawl: robots.txt first, breadth-first with hard caps, per-page
on-page checks, a few internal broken-link HEAD/GETs, AI-bot rules READ from
robots.txt (never impersonated), optional llms.txt / sitemap.xml presence,
CSV + JSON + a simple 0–100 score with plain-English fix hints.
No personal data: skips mailto:/tel:, never stores form contents.
Companion to the Lindenwerk Data DEV tutorial. Independent educational script.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.parse import urldefrag, urljoin, urlsplit, urlunsplit
from urllib.robotparser import RobotFileParser
import requests
# Brand-safe educational bot identity (no Store URL, no personal email).
USER_AGENT = (
"LindenwerkDataEducationalBot/0.1 "
"(+https://dev.to/lindenwerkdata; educational tiny SEO site-audit tutorial; "
"honours robots.txt)"
)
ROBOTS_UA = "LindenwerkDataEducationalBot"
MAX_BODY = 1_000_000 # bytes per HTML page
MAX_ROBOTS = 200_000
TIMEOUT = 15
PAUSE_BETWEEN_REQUESTS = 0.75
DEFAULT_MAX_PAGES = 10
DEFAULT_MAX_DEPTH = 2
DEFAULT_MAX_LINK_CHECKS = 15 # broken INTERNAL links only
# AI / search crawlers we only *read* rules for — never send their User-Agent.
AI_BOT_NAMES = [
"GPTBot",
"ChatGPT-User",
"OAI-SearchBot",
"ClaudeBot",
"Anthropic-AI",
"Claude-Web",
"Google-Extended",
"Googlebot",
"Bingbot",
"PerplexityBot",
"CCBot",
"Applebot-Extended",
"Bytespider",
"meta-externalagent",
]
# Title / description soft targets (common teaching ranges, not dogma).
TITLE_MIN, TITLE_MAX = 30, 60
DESC_MIN, DESC_MAX = 70, 160
# ---------------------------------------------------------------------------
# HTML collector — tags/attrs only; never walks mailto:/tel:
# ---------------------------------------------------------------------------
class _PageCollector(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title_parts: list[str] = []
self._in_title = False
self.metas: dict[str, str] = {} # name/property → content
self.h1_count = 0
self.canonical: str | None = None
self.og_keys: set[str] = set()
self.images_total = 0
self.images_missing_alt = 0
self.anchors: list[tuple[str, str]] = [] # (href, text_snippet)
self._cur_a_href: str | None = None
self._cur_a_text: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
ad = {k.lower(): (v or "") for k, v in attrs}
if tag == "title":
self._in_title = True
elif tag == "meta":
name = (ad.get("name") or ad.get("property") or ad.get("http-equiv") or "").lower()
content = ad.get("content", "")
if name:
self.metas[name] = content
if name.startswith("og:"):
self.og_keys.add(name)
elif tag == "link":
rel = ad.get("rel", "").lower()
href = ad.get("href", "")
if "canonical" in rel.split() and href and not href.lower().startswith(("mailto:", "tel:")):
self.canonical = href
elif tag == "h1":
self.h1_count += 1
elif tag == "img":
self.images_total += 1
# missing or empty alt counts
if "alt" not in ad or ad.get("alt", "").strip() == "":
self.images_missing_alt += 1
elif tag == "a":
href = ad.get("href", "").strip()
if href and not href.lower().startswith(("mailto:", "tel:", "javascript:", "data:")):
self._cur_a_href = href
self._cur_a_text = []
else:
self._cur_a_href = None
self._cur_a_text = []
def handle_endtag(self, tag: str) -> None:
if tag == "title":
self._in_title = False
elif tag == "a" and self._cur_a_href is not None:
text = re.sub(r"\s+", " ", "".join(self._cur_a_text)).strip()[:80]
self.anchors.append((self._cur_a_href, text))
self._cur_a_href = None
self._cur_a_text = []
def handle_data(self, data: str) -> None:
if self._in_title:
self.title_parts.append(data)
if self._cur_a_href is not None:
self._cur_a_text.append(data)
@dataclass
class PageResult:
url: str
final_url: str
status: int
depth: int
redirect_hops: int
title: str = ""
title_length: int = 0
meta_description: str = ""
meta_description_length: int = 0
h1_count: int = 0
canonical: str | None = None
canonical_status: str = "missing" # missing | self | other | cross_domain
meta_robots: str = ""
x_robots_tag: str = ""
indexable: bool = True
og_present: bool = False
og_keys: list[str] = field(default_factory=list)
images_total: int = 0
images_missing_alt: int = 0
internal_links: int = 0
external_links: int = 0
internal_hrefs: list[str] = field(default_factory=list)
issues: list[dict[str, str]] = field(default_factory=list)
score: int = 100
error: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def normalise_start(raw: str) -> str:
raw = raw.strip()
if not raw:
raise ValueError("empty URL")
if "://" not in raw:
raw = "https://" + raw
parts = urlsplit(raw)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError(f"unsupported URL: {raw}")
path = parts.path or "/"
return urlunsplit((parts.scheme, parts.netloc.lower(), path, "", ""))
def host_of(url: str) -> str:
return (urlsplit(url).hostname or "").lower()
def same_site(a: str, b: str) -> bool:
"""Same registrable-ish host: exact match or www. sibling."""
ha, hb = host_of(a), host_of(b)
if not ha or not hb:
return False
if ha == hb:
return True
return ha.removeprefix("www.") == hb.removeprefix("www.")
def clean_url(base: str, href: str) -> str | None:
abs_url = urljoin(base, href)
abs_url, _frag = urldefrag(abs_url)
parts = urlsplit(abs_url)
if parts.scheme not in ("http", "https") or not parts.netloc:
return None
# Drop common non-HTML noise
path_l = parts.path.lower()
if any(path_l.endswith(ext) for ext in (
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
".pdf", ".zip", ".gz", ".css", ".js", ".woff", ".woff2", ".mp4", ".mp3",
)):
return None
# Normalise: strip query for crawl discovery (keep for link-check targets we already have)
return urlunsplit((parts.scheme, parts.netloc.lower(), parts.path or "/", "", ""))
def fetch_bytes(session: requests.Session, url: str, max_bytes: int, method: str = "GET",
allow_redirects: bool = True) -> tuple[requests.Response, bytes]:
r = session.request(method, url, timeout=TIMEOUT, stream=True, allow_redirects=allow_redirects)
buf = b""
try:
for chunk in r.iter_content(16384):
buf += chunk
if len(buf) >= max_bytes:
break
finally:
r.close()
return r, buf
# ---------------------------------------------------------------------------
# robots.txt — fetch once per site, honour for crawl + link checks
# ---------------------------------------------------------------------------
@dataclass
class RobotsInfo:
allowed_start: bool
note: str
text: str
parser: RobotFileParser | None
ai_bots: dict[str, dict[str, Any]]
sitemap_hints: list[str]
def load_robots(session: requests.Session, start_url: str) -> RobotsInfo:
parts = urlsplit(start_url)
robots_url = urlunsplit((parts.scheme, parts.netloc, "/robots.txt", "", ""))
ai_bots: dict[str, dict[str, Any]] = {}
sitemap_hints: list[str] = []
try:
r, buf = fetch_bytes(session, robots_url, MAX_ROBOTS)
code = r.status_code
except requests.RequestException as exc:
return RobotsInfo(False, f"robots.txt unreachable ({type(exc).__name__})", "", None, {}, [])
if 400 <= code < 500:
# No rules → allow (common convention)
return RobotsInfo(True, f"robots.txt {code} (no rules → allow)", "", None, {}, [])
if code >= 500:
return RobotsInfo(False, f"robots.txt {code} (treat as disallow)", "", None, {}, [])
text = buf.decode("utf-8", "replace")
rp = RobotFileParser()
rp.parse(text.splitlines())
target = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", parts.query, ""))
allowed_us = rp.can_fetch(ROBOTS_UA, target)
allowed_star = rp.can_fetch("*", target)
allowed = allowed_us and allowed_star
note = f"robots.txt OK (bot={allowed_us}, *={allowed_star})"
for line in text.splitlines():
low = line.strip()
if low.lower().startswith("sitemap:"):
sm = low.split(":", 1)[1].strip()
if sm:
sitemap_hints.append(sm)
# READ AI-bot rules only — never impersonate by sending their UA on page fetches.
home = urlunsplit((parts.scheme, parts.netloc, "/", "", ""))
for bot in AI_BOT_NAMES:
named = bot.lower() in text.lower()
try:
allowed_bot = rp.can_fetch(bot, home)
except Exception:
allowed_bot = True
ai_bots[bot] = {
"named_in_robots": named,
"homepage_allowed": allowed_bot,
"policy_hint": (
"explicitly named; homepage allowed" if named and allowed_bot
else "explicitly named; homepage disallowed" if named and not allowed_bot
else "not named; falls under * / default"
),
}
return RobotsInfo(allowed, note, text, rp, ai_bots, sitemap_hints)
def robots_allows_url(info: RobotsInfo, url: str) -> bool:
if info.parser is None:
return True
return info.parser.can_fetch(ROBOTS_UA, url) and info.parser.can_fetch("*", url)
# ---------------------------------------------------------------------------
# Per-page analysis
# ---------------------------------------------------------------------------
def analyse_html(url: str, final_url: str, status: int, depth: int, redirect_hops: int,
headers: dict[str, str], html: str, start_url: str) -> PageResult:
row = PageResult(
url=url, final_url=final_url, status=status, depth=depth, redirect_hops=redirect_hops,
)
collector = _PageCollector()
try:
collector.feed(html)
collector.close()
except Exception:
pass
title = re.sub(r"\s+", " ", "".join(collector.title_parts)).strip()
row.title = title[:300]
row.title_length = len(title)
desc = collector.metas.get("description", "")
row.meta_description = desc[:500]
row.meta_description_length = len(desc)
row.h1_count = collector.h1_count
row.meta_robots = collector.metas.get("robots", "")
row.x_robots_tag = headers.get("x-robots-tag", "")
row.og_keys = sorted(collector.og_keys)
row.og_present = bool(collector.og_keys & {"og:title", "og:description", "og:image", "og:url"})
row.images_total = collector.images_total
row.images_missing_alt = collector.images_missing_alt
# Canonical
can_raw = collector.canonical
if can_raw:
can_abs = urljoin(final_url, can_raw)
can_abs, _ = urldefrag(can_abs)
row.canonical = can_abs
fu = urlsplit(final_url)
cu = urlsplit(can_abs)
fu_norm = urlunsplit((fu.scheme, fu.netloc.lower(), fu.path or "/", "", ""))
cu_norm = urlunsplit((cu.scheme, cu.netloc.lower(), cu.path or "/", "", ""))
if host_of(can_abs) != host_of(final_url) and host_of(can_abs).removeprefix("www.") != host_of(final_url).removeprefix("www."):
row.canonical_status = "cross_domain"
elif cu_norm.rstrip("/") == fu_norm.rstrip("/"):
row.canonical_status = "self"
else:
row.canonical_status = "other"
else:
row.canonical_status = "missing"
# Indexability (meta robots + X-Robots-Tag)
robots_blob = f"{row.meta_robots} {row.x_robots_tag}".lower()
if "noindex" in robots_blob:
row.indexable = False
# Links
internal: list[str] = []
external = 0
for href, _text in collector.anchors:
abs_u = clean_url(final_url, href)
if not abs_u:
continue
if same_site(abs_u, start_url):
internal.append(abs_u)
else:
external += 1
# unique internal for crawl queue; count is all internal anchors
row.internal_links = len(internal)
row.external_links = external
# preserve order, unique
seen: set[str] = set()
uniq: list[str] = []
for u in internal:
if u not in seen:
seen.add(u)
uniq.append(u)
row.internal_hrefs = uniq
# Issues + score
issues: list[dict[str, str]] = []
def add(issue_id: str, severity: str, message: str, fix: str) -> None:
issues.append({"id": issue_id, "severity": severity, "message": message, "fix": fix})
if status >= 400:
add("http_error", "error", f"HTTP {status}", "Fix the URL or redirect it to a live page.")
if redirect_hops >= 2:
add("redirect_chain", "warning", f"Redirect chain of {redirect_hops} hops",
"Point links at the final URL; avoid multi-hop chains.")
if not title:
add("title_missing", "error", "Missing <title>", "Add a unique, descriptive title tag.")
elif row.title_length < TITLE_MIN:
add("title_short", "warning", f"Title only {row.title_length} chars (soft min ~{TITLE_MIN})",
"Expand the title toward ~30–60 characters without stuffing.")
elif row.title_length > TITLE_MAX:
add("title_long", "notice", f"Title {row.title_length} chars (soft max ~{TITLE_MAX})",
"Shorten so the important words stay visible in SERPs.")
if not desc:
add("meta_description_missing", "warning", "Missing meta description",
"Add a one- or two-sentence meta description (~70–160 chars).")
elif row.meta_description_length < DESC_MIN:
add("meta_description_short", "notice", f"Meta description {row.meta_description_length} chars",
"A little more context often helps click-through.")
elif row.meta_description_length > DESC_MAX:
add("meta_description_long", "notice", f"Meta description {row.meta_description_length} chars",
"Trim so the snippet is less likely to be truncated.")
if row.h1_count == 0:
add("h1_missing", "warning", "No H1 on the page", "Add one clear H1 that matches the page topic.")
elif row.h1_count > 1:
add("h1_multiple", "notice", f"{row.h1_count} H1 tags", "Prefer a single H1; demote extras to H2.")
if row.canonical_status == "missing":
add("canonical_missing", "notice", "No canonical link",
"Add <link rel=\"canonical\"> pointing at the preferred URL.")
elif row.canonical_status == "cross_domain":
add("canonical_cross_domain", "warning", "Canonical points at another domain",
"Confirm this is intentional (syndication); otherwise fix it.")
if not row.indexable:
add("noindex", "warning", "Page is noindex (meta robots or X-Robots-Tag)",
"Remove noindex if the page should appear in search.")
if not row.og_present:
add("og_missing", "notice", "No basic Open Graph tags",
"Add og:title, og:description and og:image for nicer social shares.")
if row.images_missing_alt and row.images_total:
add("img_alt_missing", "warning",
f"{row.images_missing_alt}/{row.images_total} images missing alt",
"Write short alt text for informative images; use empty alt for decorative ones.")
# Score: 100 − 10/error − 4/warning − 1/notice (unique issue ids)
score = 100
seen_ids: set[str] = set()
for iss in issues:
if iss["id"] in seen_ids:
continue
seen_ids.add(iss["id"])
if iss["severity"] == "error":
score -= 10
elif iss["severity"] == "warning":
score -= 4
else:
score -= 1
row.score = max(0, min(100, score))
row.issues = issues
return row
# ---------------------------------------------------------------------------
# Broken internal-link checks (small cap)
# ---------------------------------------------------------------------------
def check_broken_internal(
session: requests.Session,
robots: RobotsInfo,
candidates: list[str],
already_fetched: set[str],
max_checks: int,
) -> list[dict[str, Any]]:
broken: list[dict[str, Any]] = []
checked = 0
for url in candidates:
if checked >= max_checks:
break
# Skip URLs we already fetched successfully as pages
if url in already_fetched:
continue
if not robots_allows_url(robots, url):
continue
checked += 1
time.sleep(PAUSE_BETWEEN_REQUESTS)
try:
# Prefer HEAD; some hosts dislike HEAD → fall back to GET with tiny read
r = session.head(url, timeout=TIMEOUT, allow_redirects=True)
status = r.status_code
if status in (405, 501) or (status >= 400 and status != 404):
r2, _ = fetch_bytes(session, url, 8192, method="GET")
status = r2.status_code
final = str(r2.url)
else:
final = str(r.url)
if status >= 400:
broken.append({"url": url, "status": status, "final_url": final})
except requests.RequestException as exc:
broken.append({"url": url, "status": None, "error": f"{type(exc).__name__}: {exc}"})
return broken
# ---------------------------------------------------------------------------
# Optional presence probes
# ---------------------------------------------------------------------------
def probe_presence(session: requests.Session, start_url: str, path: str) -> dict[str, Any]:
parts = urlsplit(start_url)
url = urlunsplit((parts.scheme, parts.netloc, path, "", ""))
out: dict[str, Any] = {"url": url, "present": False, "status": None}
try:
r = session.head(url, timeout=TIMEOUT, allow_redirects=True)
status = r.status_code
if status in (405, 501):
r2, _ = fetch_bytes(session, url, 4096, method="GET")
status = r2.status_code
final = str(r2.url)
else:
final = str(r.url)
out["status"] = status
out["final_url"] = final
out["present"] = status == 200
except requests.RequestException as exc:
out["error"] = f"{type(exc).__name__}: {exc}"
return out
# ---------------------------------------------------------------------------
# Crawl one site
# ---------------------------------------------------------------------------
def audit_site(
session: requests.Session,
raw: str,
max_pages: int,
max_depth: int,
max_link_checks: int,
check_llms: bool,
check_sitemap: bool,
) -> dict[str, Any]:
start = normalise_start(raw)
site_host = host_of(start)
fetched_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
site: dict[str, Any] = {
"rowType": "site",
"input": raw.strip(),
"start_url": start,
"host": site_host,
"fetched_at": fetched_at,
"robots": "",
"ai_bots": {},
"sitemap_in_robots": [],
"sitemap_probe": None,
"llms_txt": None,
"pages_audited": 0,
"avg_score": None,
"broken_internal_links": [],
"top_issues": [],
"status": "",
"error": None,
}
robots = load_robots(session, start)
site["robots"] = robots.note
site["ai_bots"] = robots.ai_bots
site["sitemap_in_robots"] = robots.sitemap_hints
if not robots.allowed_start:
site["status"] = "robots_disallowed"
site["error"] = robots.note
return {"site": site, "pages": []}
if check_sitemap:
time.sleep(PAUSE_BETWEEN_REQUESTS)
# Prefer first robots hint; else /sitemap.xml
if robots.sitemap_hints:
sm_url = robots.sitemap_hints[0]
try:
r = session.head(sm_url, timeout=TIMEOUT, allow_redirects=True)
site["sitemap_probe"] = {
"url": sm_url,
"present": r.status_code == 200,
"status": r.status_code,
"source": "robots.txt",
}
except requests.RequestException as exc:
site["sitemap_probe"] = {"url": sm_url, "present": False, "error": str(exc), "source": "robots.txt"}
else:
site["sitemap_probe"] = {**probe_presence(session, start, "/sitemap.xml"), "source": "default"}
if check_llms:
time.sleep(PAUSE_BETWEEN_REQUESTS)
site["llms_txt"] = probe_presence(session, start, "/llms.txt")
# BFS
queue: deque[tuple[str, int]] = deque([(start, 0)])
seen: set[str] = set()
pages: list[PageResult] = []
fetched_ok: set[str] = set()
link_pool: list[str] = []
while queue and len(pages) < max_pages:
url, depth = queue.popleft()
if url in seen:
continue
seen.add(url)
if not robots_allows_url(robots, url):
continue
if depth > 0:
time.sleep(PAUSE_BETWEEN_REQUESTS)
try:
r, buf = fetch_bytes(session, url, MAX_BODY, method="GET")
ctype = r.headers.get("Content-Type", "")
charset = "utf-8"
if "charset=" in ctype.lower():
charset = ctype.lower().split("charset=", 1)[1].split(";")[0].strip() or "utf-8"
html = buf.decode(charset, "replace")
headers = {k.lower(): v for k, v in r.headers.items()}
final = str(r.url)
hops = len(r.history)
status = r.status_code
except requests.RequestException as exc:
pages.append(PageResult(
url=url, final_url=url, status=0, depth=depth, redirect_hops=0,
error=f"{type(exc).__name__}: {exc}", score=0,
issues=[{"id": "fetch_error", "severity": "error",
"message": f"Fetch failed: {type(exc).__name__}",
"fix": "Retry later or check DNS/TLS."}],
))
continue
# Only parse HTML-ish
if status < 400 and "html" not in ctype.lower() and buf[:200].lstrip()[:1] != b"<":
# still record status
pr = PageResult(
url=url, final_url=final, status=status, depth=depth, redirect_hops=hops,
issues=[{"id": "not_html", "severity": "notice", "message": f"Non-HTML content-type: {ctype}",
"fix": "Skip binary URLs in the crawl frontier."}],
score=99,
)
pages.append(pr)
continue
pr = analyse_html(url, final, status, depth, hops, headers, html, start)
pages.append(pr)
if status < 400:
fetched_ok.add(url)
fetched_ok.add(urlunsplit(urlsplit(final)._replace(query="", fragment="")))
# Enqueue internal links
if depth < max_depth:
for href in pr.internal_hrefs:
if href not in seen and len(seen) + len(queue) < max_pages * 3:
queue.append((href, depth + 1))
if href not in fetched_ok:
link_pool.append(href)
# Broken internal links (candidates not already fetched as pages)
# Prefer link_pool order; unique
uniq_pool: list[str] = []
seen_pool: set[str] = set()
for u in link_pool:
if u not in seen_pool and u not in fetched_ok:
seen_pool.add(u)
uniq_pool.append(u)
broken = check_broken_internal(session, robots, uniq_pool, fetched_ok, max_link_checks)
site["broken_internal_links"] = broken
# Attach broken-link issues onto referring isn't tracked simply; add site-level + bump pages that linked?
# Keep site-level only for educational simplicity; also flag homepage if any.
if broken and pages:
for b in broken[:5]:
# add as site top issue only
pass
# Site roll-up
site["pages_audited"] = len(pages)
if pages:
site["avg_score"] = round(sum(p.score for p in pages) / len(pages), 1)
# Top issues by frequency
freq: dict[str, dict[str, Any]] = {}
for p in pages:
for iss in p.issues:
slot = freq.setdefault(iss["id"], {"id": iss["id"], "severity": iss["severity"],
"message": iss["message"], "fix": iss["fix"], "count": 0})
slot["count"] += 1
if broken:
freq["broken_internal_link"] = {
"id": "broken_internal_link",
"severity": "error",
"message": f"{len(broken)} broken internal link(s) sampled",
"fix": "Update or remove links that return 4xx/5xx.",
"count": len(broken),
}
top = sorted(freq.values(), key=lambda x: (
{"error": 0, "warning": 1, "notice": 2}.get(x["severity"], 3),
-x["count"],
))
site["top_issues"] = top[:10]
site["status"] = "ok"
return {"site": site, "pages": pages}
def page_to_dict(p: PageResult) -> dict[str, Any]:
return {
"rowType": "page",
"url": p.url,
"final_url": p.final_url,
"http_status": p.status,
"depth": p.depth,
"redirect_hops": p.redirect_hops,
"title": p.title,
"title_length": p.title_length,
"meta_description_length": p.meta_description_length,
"h1_count": p.h1_count,
"canonical": p.canonical,
"canonical_status": p.canonical_status,
"meta_robots": p.meta_robots,
"x_robots_tag": p.x_robots_tag,
"indexable": p.indexable,
"og_present": p.og_present,
"og_keys": p.og_keys,
"images_total": p.images_total,
"images_missing_alt": p.images_missing_alt,
"internal_links": p.internal_links,
"external_links": p.external_links,
"score": p.score,
"issues": p.issues,
"error": p.error,
}
def write_outputs(results: list[dict[str, Any]], out_dir: Path, stamp: str) -> tuple[Path, Path]:
out_dir.mkdir(parents=True, exist_ok=True)
json_path = out_dir / f"seo-audit-{stamp}.json"
csv_path = out_dir / f"seo-audit-{stamp}.csv"
payload = []
flat = []
for res in results:
site = res["site"]
payload.append(site)
for p in res["pages"]:
pd = page_to_dict(p)
pd["site"] = site["host"]
payload.append(pd)
flat.append({
"site": site["host"],
"url": pd["url"],
"http_status": pd["http_status"],
"depth": pd["depth"],
"title_length": pd["title_length"],
"meta_description_length": pd["meta_description_length"],
"h1_count": pd["h1_count"],
"canonical_status": pd["canonical_status"],
"indexable": pd["indexable"],
"og_present": pd["og_present"],
"images_missing_alt": pd["images_missing_alt"],
"images_total": pd["images_total"],
"internal_links": pd["internal_links"],
"external_links": pd["external_links"],
"score": pd["score"],
"issue_ids": ";".join(i["id"] for i in pd["issues"]),
"error": pd.get("error") or "",
})
# site summary row in CSV too
flat.append({
"site": site["host"],
"url": f"[site] {site.get('start_url')}",
"http_status": "",
"depth": "",
"title_length": "",
"meta_description_length": "",
"h1_count": "",
"canonical_status": "",
"indexable": "",
"og_present": "",
"images_missing_alt": "",
"images_total": "",
"internal_links": "",
"external_links": "",
"score": site.get("avg_score") if site.get("avg_score") is not None else "",
"issue_ids": ";".join(i["id"] for i in (site.get("top_issues") or [])),
"error": site.get("error") or "",
})
json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
fields = list(flat[0].keys()) if flat else ["site", "url"]
with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(flat)
return csv_path, json_path
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("urls", nargs="*", help="start domains or URLs")
p.add_argument("--file", "-f", help="text file with one domain/URL per line")
p.add_argument("--max-pages", type=int, default=DEFAULT_MAX_PAGES)
p.add_argument("--max-depth", type=int, default=DEFAULT_MAX_DEPTH)
p.add_argument("--max-link-checks", type=int, default=DEFAULT_MAX_LINK_CHECKS)
p.add_argument("--llms", action=argparse.BooleanOptionalAction, default=True)
p.add_argument("--sitemap", action=argparse.BooleanOptionalAction, default=True)
p.add_argument("--out-dir", default="out")
p.add_argument("--pause", type=float, default=PAUSE_BETWEEN_REQUESTS)
args = p.parse_args()
urls: list[str] = list(args.urls)
if args.file:
for line in Path(args.file).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
urls.append(line)
if not urls:
p.error("pass one or more URLs, or --file")
# Apply CLI pause to module-level default used by crawl helpers.
globals()["PAUSE_BETWEEN_REQUESTS"] = args.pause
session = requests.Session()
session.headers.update({
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5",
"Accept-Language": "en,de;q=0.8",
})
results: list[dict[str, Any]] = []
for i, raw in enumerate(urls):
if i:
time.sleep(max(args.pause, 1.0))
print(f"→ {raw}", file=sys.stderr)
try:
res = audit_site(
session, raw,
max_pages=args.max_pages,
max_depth=args.max_depth,
max_link_checks=args.max_link_checks,
check_llms=args.llms,
check_sitemap=args.sitemap,
)
except ValueError as exc:
res = {"site": {"rowType": "site", "input": raw, "status": "bad_input",
"error": str(exc), "host": "", "pages_audited": 0,
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"top_issues": [], "broken_internal_links": [], "ai_bots": {}},
"pages": []}
site = res["site"]
print(
f" {site.get('status')} pages={site.get('pages_audited')} "
f"avg_score={site.get('avg_score')} broken={len(site.get('broken_internal_links') or [])} "
f"| {site.get('robots')}",
file=sys.stderr,
)
results.append(res)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
csv_path, json_path = write_outputs(results, Path(args.out_dir), stamp)
print(f"Wrote {csv_path} and {json_path}", file=sys.stderr)
if __name__ == "__main__":
main()
Signals observed on public pages and public robots.txt files on 6 November 2026. Independent educational tutorial from Lindenwerk Data; not affiliated with or endorsed by the vendors or bot operators named in examples.
This article was researched, written and tested by an AI agent and published under the Lindenwerk Data account. All figures come from our own live runs of the script on 6 November 2026; robots handling follows RFC 9309 practice as described above.
Top comments (0)