← zurück zur Toolbox
seo_checker_web_v1.py · Quelltext
140073 Bytes · SHA-256: 9bd30c1a9958479026831ce2102daa51595f526850e6675563465b48d6e9ecdc
#!/usr/home/jozapf/public_html/toolenv/bin/python
# -*- coding: utf-8 -*-
# TOOLBOX-TILE: {"title": "SEO Check", "desc": "Leichtgewichtige Prüfung der technischen SEO-Grundlagen nach etablierten Standards — Titel, Beschreibung, Auffindbarkeit u. a., benotet A+…F, dazu eine Teilprüfung der Barrierefreiheit. Prüft auf Wunsch auch die verlinkten Unterseiten. Ein Anfang, keine vollständige Analyse.", "icon": "🔍", "type": "web", "example": "?url=example.com", "order": 15}
"""
SEO Check Web — On-Page-/Technical-SEO-Audit einer URL mit A+..F-Benotung,
optional inklusive der von ihr verlinkten Unterseiten (Website-Check), dazu
eine zweite, eigenständig benotete Teilprüfung der Barrierefreiheit.
Version: 1.2.0
Date: 2026-07-25
Bildet die **Lighthouse-SEO-Kategorie** getreu nach (Referenz, verbindlich:
GoogleChrome/lighthouse @ v12.2.1, `core/config/default-config.js` +
`core/audits/seo/*.js`). Der Score ist der gewichtete Mittelwert der scorenden
Audits — genau wie Lighthouse ihn rechnet, NICHT ein 100-minus-Penalty-Modell:
seo:auditRefs (verbatim, v12.2.1):
is-crawlable weight 93/23 (== 31 % des Scores; Kommentar im Quell-
code: "failing results in the category
failing")
document-title weight 1
meta-description weight 1
http-status-code weight 1
link-text weight 1
crawlable-anchors weight 1
robots-txt weight 1
image-alt weight 1
hreflang weight 1
canonical weight 1
structured-data weight 0 (manuell/informativ -> nur Anzeige, kein Score)
Score = 100 * Σ_anwendbar(w·pass) / Σ_anwendbar(w)
notApplicable-Audits fallen aus Zähler UND Nenner (Renormalisierung, wie LH).
WEBSITE-CHECK (seit 1.1.0, Standard an; `&site=0` schaltet ihn ab):
Die Note gilt weiterhin GENAU für die eingegebene Seite — Lighthouse bewertet
eine Seite, nicht eine Website. Zusätzlich werden die von dieser Seite intern
verlinkten Seiten (gleicher Host, robots.txt-konform) abgerufen und mit
denselben HTML-Audits geprüft; das Ergebnis erscheint als eigener Abschnitt mit
Durchschnittsnote und Mängel-Summen über alle Seiten. Ohne diesen Schritt bleibt
ein site-weiter Mangel unsichtbar, sobald die Startseite ihn nicht hat (typisch:
Meta-Description nur auf der Startseite gepflegt). Bounded: MAX_SITE_PAGES,
Wall-Clock-Budget SITE_BUDGET_S, Bild-Byte-Proben nur auf der Einstiegsseite.
Der Crawl vervielfacht die ausgehenden Requests pro Klick — zusammen mit dem
Rate-Limit des Guards (10/min, 60/h je Client) sind die Schranken oben das, was
dieses Werkzeug daran hindert, als Verstärker gegen fremde Seiten zu wirken.
BARRIEREFREIHEIT (seit 1.2.0, eigene Note): Lighthouse bewertet Barrierefreiheit
in einer EIGENEN Kategorie (57 gewichtete Audits, 404 Punkte) — image-alt steht
dort mit Gewicht 10, link-name mit 7. Nachgebaut sind die 30 Audits (232 Punkte,
57 %), die sich am statischen HTML entscheiden lassen; Gewichte verbatim, Formel
identisch zur SEO-Note. Die restlichen brauchen eine gerenderte Seite (Kontrast,
ARIA-Namen, Fokus, Zielgrößen). Die Note ist dadurch SYSTEMATISCH ZU GUT — das
steht so im Bericht, samt Namensliste der ausgelassenen Audits. Siehe
a11y_checks() und A11Y_OUT_OF_SCOPE.
Zwei blinde Flecken der SEO-Rubrik, die 1.1.0 als Hinweis (nicht als Note)
sichtbar macht — beide fallen durch ALLE zehn bewerteten Regeln durch:
* leeres alt="" — gültige Dekorativ-Markierung, die image-alt bestehen lässt,
aber jeden Bildtext für Suche und Screenreader entfernt.
* verlinkte Bilder ohne Alt-Text -> der Link hat gar keinen Namen (axe-Regel
`link-name`, bei Lighthouse in der Kategorie BARRIEREFREIHEIT, nicht SEO).
Siehe nameless_image_links().
BEWUSSTE Grenzen (im Output ausgewiesen):
* link-text/image-alt werden am STATISCHEN HTML geprüft, nicht am gerenderten
DOM — per JS injizierte Links/Bilder sieht dieses Tool (ohne Headless-Chrome)
nicht. Lighthouse mit echtem Chrome schon.
* Backlinks / Keyword-Rankings / Traffic-Schätzung sind NICHT Teil dieser
Analyse (brauchen einen Web-Index) — das ist ein On-Page-Audit, kein Ahrefs.
* Core Web Vitals (LCP/CLS/INP), font-size, tap-targets: ausserhalb des Scope
(bräuchten Rendering bzw. die Google-PageSpeed/CrUX-API).
Dokumentierte Approximationen ggü. Lighthouse (kein Headless-Chrome verfügbar):
* link-text-Blocklist: EN/JP/ES/PT/KO/SV verbatim aus link-text.js übernommen;
die Tamil-/Persisch-Einträge sind ausgelassen (für die Zielgruppe irrelevant).
* robots-txt-Validierung: LH parst Zeile für Zeile; hier über die bekannte
Direktiven-Liste approximiert (gültige robots.txt -> pass, wie LH).
* hreflang isValidLang: 2-3-Buchstaben-Sprach-Subtag + x-default (LH nutzt eine
feste Sprachcode-Liste).
CGI: liest QUERY_STRING via parse_qs (kein `cgi`-Modul — in Python 3.13 entfernt),
gibt `Content-Type` + HTML aus. Speichert nichts.
"""
import concurrent.futures
import datetime
import html
import os
import re
import sys
import time
import traceback
from email.utils import parsedate_to_datetime
from urllib.parse import parse_qs, urljoin, urlparse, urlunparse
from urllib.robotparser import RobotFileParser
import requests
from bs4 import BeautifulSoup
# Bot/abuse guard (toolbox_guard.py sits next to this tool on the server).
# Fail open on import trouble: the guard is best-effort protection, never a
# reason to take a working tool offline.
try:
from toolbox_guard import guard as _guard
except Exception: # pragma: no cover
def _guard(_tool):
return True
# Hardened, host-validating fetch shared by every executing toolbox tool
# (SSRF-via-redirect defense, DNS pinning, capped bodies). Fail-safe import: if
# the module is missing/broken we fall back to the tool's original request so an
# import problem never takes the tool offline — but prefer safe_get.
try:
from toolbox_fetch import safe_get
except Exception: # pragma: no cover
safe_get = None
USER_AGENT = "SeoCheckWeb/1.2 (+https://jozapf.de)"
# --------------------------------------------------------------------------- #
# Website-Check bounds. The crawl is a real outbound burst against a third-party
# site, so it is bounded three ways: page cap, wall-clock budget (whatever has
# come back when it expires is what gets reported — honestly labelled), and a
# short per-page timeout. Image byte-probes stay entry-page-only.
# --------------------------------------------------------------------------- #
MAX_SITE_PAGES = 100 # hard cap on subpages fetched per run. A site's
# primary navigation is what the entry page links
# to, so this wants to be above a typical full menu
# (the reference site links 93) — otherwise the run
# reports partial coverage of the very structure it
# set out to check.
SITE_BUDGET_S = 30.0 # wall-clock budget for the whole crawl stage.
# A full 94-page run measures ~23 s live, so this is
# headroom for a bad moment rather than the normal
# case: one slow minute on the target used to cost
# 70 of 94 pages (measured), and partial coverage is
# worse for the reader than a few seconds more wait.
SITE_WORKERS = 16 # concurrent fetches. Sized from a live measurement,
# not from taste: at 10 workers a real 94-page
# WordPress site answered ~4 s/page from the web
# space, so only 46 pages fitted the budget and the
# page the user came for was cut off. 16 × 25 s
# covers the whole cap with margin.
SITE_TIMEOUT = 6 # per-page timeout (s)
SITE_MAX_BYTES = 2 * 1024 * 1024 # per-page body cap — same as the entry page's,
# so a crawled page is never audited on a DOM that
# was truncated earlier than the entry page's would be
# Extensions that are not HTML pages — never worth a crawl slot.
_NON_PAGE_EXT = (
".pdf", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico",
".zip", ".rar", ".gz", ".tgz", ".doc", ".docx", ".xls", ".xlsx", ".ppt",
".pptx", ".mp3", ".mp4", ".avi", ".mov", ".webm", ".css", ".js", ".json",
".xml", ".rss", ".txt", ".exe", ".dmg", ".apk",
)
# --------------------------------------------------------------------------- #
# Lighthouse SEO weights (verbatim, default-config.js @ v12.2.1)
# --------------------------------------------------------------------------- #
W_CRAWLABLE = 93 / 23 # == 31 % of the category
W_OTHER = 1.0 # each of the 9 other scored audits
# is-crawlable nominal share = 93/300 = 31.0 %; each other = 23/300 = 7.667 %.
# Grade bands identical to the Security-Headers tool (Mozilla Observatory shape).
# SEO caps at 100 (no bonus) -> a perfect on-page score (100) is A+ [freigegeben].
_GRADE_BANDS = [
(100, "A+", "#059669"),
(90, "A", "#10b981"),
(85, "A-", "#34d399"),
(80, "B+", "#65a30d"),
(70, "B", "#84cc16"),
(65, "B-", "#a3e635"),
(60, "C+", "#ca8a04"),
(50, "C", "#eab308"),
(45, "C-", "#facc15"),
(40, "D+", "#ea580c"),
(30, "D", "#f97316"),
(25, "D-", "#fb923c"),
(0, "F", "#ef4444"),
]
_STATUS_LABEL = {
"pass": "Pass",
"fail": "Fail",
"warn": "Weak",
"info": "Info",
"na": "n/a",
}
# link-text generic blocklist — verbatim from core/audits/seo/link-text.js
# (v12.2.1), lower-cased. Tamil/Persian entries omitted (see module docstring).
GENERIC_LINK_TEXTS = {
# English
"click here", "click this", "go", "here", "information", "learn more",
"more", "more info", "more information", "right here", "read more",
"see more", "start", "this",
# Japanese
"ここをクリック", "こちらをクリック", "リンク", "続きを読む", "続く", "全文表示",
# Spanish
"click aquí", "click aqui", "clicka aquí", "clicka aqui", "pincha aquí",
"pincha aqui", "aquí", "aqui", "más", "mas", "más información",
"más informacion", "mas información", "mas informacion", "este", "enlace",
"este enlace", "empezar",
# Portuguese
"clique aqui", "ir", "mais informação", "mais informações", "mais",
"veja mais",
# Korean
"여기", "여기를 클릭", "클릭", "링크", "자세히", "자세히 보기", "계속", "이동",
"전체 보기",
# Swedish
"här", "klicka här", "läs mer", "mer", "mer info", "mer information",
}
# robots.txt directives Lighthouse's parser accepts (approximation of parseLine).
_ROBOTS_DIRECTIVES = {
"user-agent", "disallow", "allow", "sitemap", "crawl-delay", "host",
"clean-param", "request-rate", "visit-time", "noindex", "cache-delay",
}
_JS_VOID_RE = re.compile(r"javascript:void(\(|)0(\)|)", re.I)
# Display order of the advisory block. The list grew by appending, so related
# items drifted apart (the title's length ended up nine rows below the title
# itself). Order and grouping are declared here instead of falling out of the
# order in which advisories() happens to compute things; anything not named here
# still renders, under "Weiteres", so a new advisory can never vanish.
ADVISORY_GROUPS = [
("Inhalt & Suchergebnis", [
"Title-Länge", "Description-Länge", "H1-Überschrift",
]),
("Bilder", [
"Bildtexte (alt)", "Verlinkte Bilder ohne Linktext",
"Bild-title-Attribute (Info)", "Bildgrößen",
]),
("Technik & Auslieferung", [
"HTTPS", "Server-Antwortzeit", "Viewport (Mobile)", "<html lang>",
]),
("Auffindbarkeit & maschinelles Lesen", [
"sitemap.xml", "Strukturierte Daten (JSON-LD)", "Social-Tags",
"llms.txt (KI-Agenten)",
]),
]
def group_advisories(advs):
"""Bucket the advisories into ADVISORY_GROUPS order. Returns
[(group_title, [advisory, …]), …]; empty groups are dropped and unknown
names land in a trailing "Weiteres" group rather than being lost."""
by_name = {a[0]: a for a in advs}
out, placed = [], set()
for title, names in ADVISORY_GROUPS:
items = [by_name[n] for n in names if n in by_name]
placed.update(n for n in names if n in by_name)
if items:
out.append((title, items))
rest = [a for a in advs if a[0] not in placed]
if rest:
out.append(("Weiteres", rest))
return out
# --------------------------------------------------------------------------- #
# Fetching
# --------------------------------------------------------------------------- #
class _SafeResp(object):
"""Adapter that gives a toolbox_fetch.FetchResult the requests-like surface
the downstream audits/advisories read (adds `.elapsed`, which FetchResult
does not carry)."""
__slots__ = ("url", "status_code", "headers", "text", "content",
"history", "elapsed")
def __init__(self, fr, elapsed):
self.url = fr.url
self.status_code = fr.status_code
self.headers = fr.headers
self.text = fr.text
self.content = fr.content
self.history = fr.history
self.elapsed = elapsed # datetime.timedelta (for TTFB advisory)
@property
def ok(self):
return 200 <= self.status_code < 400
class _PlainResp(object):
"""Minimal requests-like response for the fallback path taken when
toolbox_fetch is unavailable: the body has already been read and capped by
hand, so nothing downstream can pull an unbounded document into memory."""
__slots__ = ("url", "status_code", "headers", "text", "content", "history")
def __init__(self, url, status_code, headers, raw):
self.url = url
self.status_code = status_code
self.headers = headers
self.content = raw
self.history = []
enc = "utf-8"
m = re.search(r"charset=([\w.:+-]+)", headers.get("Content-Type") or "", re.I)
if m:
enc = m.group(1)
try:
self.text = raw.decode(enc, errors="replace")
except (LookupError, TypeError):
self.text = raw.decode("utf-8", errors="replace")
def fetch_url(url: str, timeout: int = 12):
"""Fetch the target page. Returns a response-like object, or None if the
host (or any redirect hop) is non-public/unresolvable (SSRF refusal)."""
headers = {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
if safe_get is not None:
t0 = datetime.datetime.now()
fr = safe_get(url, timeout=timeout, headers=headers)
if fr is None:
return None
return _SafeResp(fr, datetime.datetime.now() - t0)
resp = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
if resp.encoding is None or resp.encoding.lower() == "iso-8859-1":
resp.encoding = "utf-8"
return resp
def fetch_robots(final_url: str, timeout: int = 8):
"""Fetch /robots.txt for the final host. Returns (robots_url, status, text)."""
p = urlparse(final_url)
robots_url = f"{p.scheme}://{p.netloc}/robots.txt"
if safe_get is not None:
fr = safe_get(robots_url, timeout=timeout, headers={"User-Agent": USER_AGENT})
if fr is None:
return robots_url, None, None
return robots_url, fr.status_code, fr.text
try:
r = requests.get(robots_url, headers={"User-Agent": USER_AGENT},
timeout=timeout, allow_redirects=True)
return robots_url, r.status_code, r.text
except Exception:
return robots_url, None, None
def fetch_llms(final_url: str, timeout: int = 8):
"""Fetch /llms.txt (the emerging LLM-index convention). Returns
(llms_url, status, content_type, text)."""
p = urlparse(final_url)
llms_url = f"{p.scheme}://{p.netloc}/llms.txt"
if safe_get is not None:
fr = safe_get(llms_url, timeout=timeout, headers={"User-Agent": USER_AGENT})
if fr is None:
return llms_url, None, "", None
return llms_url, fr.status_code, fr.headers.get("Content-Type", ""), fr.text
try:
r = requests.get(llms_url, headers={"User-Agent": USER_AGENT},
timeout=timeout, allow_redirects=True)
return llms_url, r.status_code, r.headers.get("Content-Type", ""), r.text
except Exception:
return llms_url, None, "", None
def fetch_sitemap(final_url, robots_text, timeout=8):
"""Find an XML sitemap two ways, like search engines do: (a) 'Sitemap:'
directives in robots.txt (the official mechanism), (b) a probe of /sitemap.xml.
Returns (declared[urls], probe_url, probe_ok). The probe reads only the first
bytes to confirm it is XML — sitemaps can be large."""
declared = []
if robots_text:
for line in robots_text.splitlines():
m = re.match(r"\s*sitemap\s*:\s*(\S+)", line, re.I)
if m:
declared.append(m.group(1).strip())
p = urlparse(final_url)
probe_url = f"{p.scheme}://{p.netloc}/sitemap.xml"
probe_ok = False
if safe_get is not None:
# Host-validated fetch, still bounded: only the first bytes are needed to
# confirm the response is XML (sitemaps can be large).
fr = safe_get(probe_url, timeout=timeout, max_bytes=2048,
headers={"User-Agent": USER_AGENT})
if fr is not None and fr.status_code == 200:
head = fr.text.lstrip()
probe_ok = head.startswith("<?xml") or "<urlset" in head or "<sitemapindex" in head
return declared, probe_url, probe_ok
try:
r = requests.get(probe_url, headers={"User-Agent": USER_AGENT},
timeout=timeout, allow_redirects=True, stream=True)
if r.status_code == 200:
head = (r.raw.read(1024, decode_content=True) or b"").decode("utf-8", "replace").lstrip()
probe_ok = head.startswith("<?xml") or "<urlset" in head or "<sitemapindex" in head
r.close()
except Exception:
pass
return declared, probe_url, probe_ok
_JPEG_SOF = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
def _img_dimensions(data: bytes):
"""Parse intrinsic (width, height) in px from the first bytes of an image.
Supports PNG, GIF, JPEG, WebP. Returns (w, h) or None (unknown / too little
data). No decoding — reads the format headers only."""
if not data or len(data) < 24:
return None
# PNG: IHDR right after the signature
if data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
return (int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big"))
# GIF: logical screen descriptor (little-endian)
if data[:6] in (b"GIF87a", b"GIF89a"):
return (int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little"))
# JPEG: walk segments to the Start-Of-Frame marker
if data[:2] == b"\xff\xd8":
i, n = 2, len(data)
while i + 9 < n:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in _JPEG_SOF:
return (int.from_bytes(data[i + 7:i + 9], "big"),
int.from_bytes(data[i + 5:i + 7], "big"))
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
i += 2
else:
i += 2 + int.from_bytes(data[i + 2:i + 4], "big")
return None
# WebP (RIFF container: VP8 / VP8L / VP8X)
if data[:4] == b"RIFF" and data[8:12] == b"WEBP" and len(data) >= 30:
fmt = data[12:16]
try:
if fmt == b"VP8 ":
return (int.from_bytes(data[26:28], "little") & 0x3FFF,
int.from_bytes(data[28:30], "little") & 0x3FFF)
if fmt == b"VP8L":
b0, b1, b2, b3 = data[21], data[22], data[23], data[24]
return (1 + (((b1 & 0x3F) << 8) | b0),
1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6)))
if fmt == b"VP8X":
return (1 + int.from_bytes(data[24:27], "little"),
1 + int.from_bytes(data[27:30], "little"))
except Exception:
return None
return None
def image_report(soup, base_url, max_imgs=20, timeout=4, header_bytes=16384):
"""For each <img src> (bounded, parallel), read the total size and intrinsic
dimensions from the file HEADER only — one ranged GET, at most `header_bytes`
read per image (streamed, so a server that ignores Range still costs little).
Returns (records[(url, size|None, dims|None)], attempted, total_imgs)."""
seen, srcs = set(), []
for img in soup.find_all("img"):
src = (img.get("src") or "").strip()
if not src or src.startswith("data:"):
continue
u = urljoin(base_url, src)
if u.lower().startswith(("http://", "https://")) and u not in seen:
seen.add(u)
srcs.append(u)
total_imgs = len(srcs)
srcs = srcs[:max_imgs]
def _total_from_headers(hdrs, status):
total = None
cr = hdrs.get("Content-Range") # "bytes 0-16383/123456"
if cr and "/" in cr:
tail = cr.rsplit("/", 1)[-1]
if tail.isdigit():
total = int(tail)
if total is None and status == 200: # Range ignored -> full size
cl = hdrs.get("Content-Length")
if cl and cl.isdigit():
total = int(cl)
return total
def probe(u):
# Page-derived (attacker-influenceable) image URLs go through the
# host-validating fetch, still bounded to the header bytes we need.
hdrs = {"User-Agent": USER_AGENT, "Range": f"bytes=0-{header_bytes - 1}"}
if safe_get is not None:
fr = safe_get(u, timeout=timeout, max_bytes=header_bytes, headers=hdrs)
if fr is None:
return (u, None, None)
total = _total_from_headers(fr.headers, fr.status_code)
return (u, total, _img_dimensions(fr.content))
try:
r = requests.get(u, headers=hdrs,
timeout=timeout, allow_redirects=True, stream=True)
total = _total_from_headers(r.headers, r.status_code)
chunk = r.raw.read(header_bytes, decode_content=True) or b""
r.close()
return (u, total, _img_dimensions(chunk))
except Exception:
return (u, None, None)
recs = []
if srcs:
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
recs = list(ex.map(probe, srcs))
return recs, len(srcs), total_imgs
# --------------------------------------------------------------------------- #
# Grade helpers
# --------------------------------------------------------------------------- #
def grade_for(score: int):
score = max(0, score)
for threshold, grade, color in _GRADE_BANDS:
if score >= threshold:
return grade, color
return "F", "#ef4444"
# --------------------------------------------------------------------------- #
# Individual audits — each returns a check dict:
# {id, name, weight, status: pass|fail|na, value, detail, advice}
# status "na" -> notApplicable, excluded from the weighted score (like LH).
# --------------------------------------------------------------------------- #
# Nominal share of a category: the SEO category totals 300/23 weight units in
# this tool's scale, the accessibility category 404. Each check carries the
# denominator of the category it belongs to, so the percentage on a card always
# means "share of ITS category" — mixing them silently showed a weight-7
# accessibility audit as 54 %, the SEO denominator applied to a foreign scale.
SEO_TOTAL_WEIGHT = 300 / 23
def _check(cid, name, weight, status, value, detail, advice="",
denom=SEO_TOTAL_WEIGHT):
return {"id": cid, "name": name, "weight": weight, "status": status,
"value": value, "detail": detail, "advice": advice, "denom": denom}
def audit_http_status(resp):
sc = resp.status_code
if sc < 400:
return _check("http-status-code", "HTTP-Statuscode", W_OTHER, "pass",
sc, f"Die Seite liefert {sc} (erfolgreich).")
return _check("http-status-code", "HTTP-Statuscode", W_OTHER, "fail",
sc, f"Die Seite liefert {sc} — Suchmaschinen können sie nicht indexieren.",
"Liefere für indexierbare Seiten einen 2xx-Status.")
def audit_title(soup):
title = ""
if soup.title:
title = soup.title.get_text(strip=True)
if title:
return _check("document-title", "Title-Tag", W_OTHER, "pass",
title, "Die Seite hat einen Titel. Ob er für das Suchergebnis gut geschnitten ist, steht unten unter „Title-Länge“.")
return _check("document-title", "Title-Tag", W_OTHER, "fail",
None, "Kein (oder leerer) <title> gefunden.",
"Gib der Seite einen prägnanten, einzigartigen <title>.")
def audit_meta_description(soup):
desc = ""
for m in soup.find_all("meta"):
name = (m.get("name") or "").strip().lower()
if name == "description":
desc = (m.get("content") or "").strip()
if desc:
break
if desc:
return _check("meta-description", "Meta-Description", W_OTHER, "pass",
desc, "Die Seite hat eine Meta-Description. Zur Länge siehe unten „Description-Länge“.")
return _check("meta-description", "Meta-Description", W_OTHER, "fail",
None, "Keine (oder leere) Meta-Description.",
"Setze <meta name=\"description\" content=\"…\"> mit einer aussagekräftigen Zusammenfassung.")
def _iter_anchors(soup):
return soup.find_all("a")
def audit_link_text(soup, final_url):
"""Flag anchors whose (static) text is in the generic blocklist.
Exclusions per link-text.js: no href, rel=nofollow, javascript:/mailto:,
same-page anchors."""
final_base = final_url.split("#")[0]
qualifying = 0
flagged = []
for a in _iter_anchors(soup):
href = a.get("href")
if not href:
continue
rel = a.get("rel") or []
rel = " ".join(rel).lower() if isinstance(rel, list) else str(rel).lower()
if "nofollow" in rel:
continue
hl = href.strip().lower()
if hl.startswith("javascript:") or hl.startswith("mailto:"):
continue
if href.strip().startswith("#"):
continue
try:
if urljoin(final_url, href).split("#")[0] == final_base:
continue
except Exception:
pass
qualifying += 1
text = " ".join(a.get_text().split()).lower()
if text in GENERIC_LINK_TEXTS:
flagged.append(text)
if qualifying == 0:
return _check("link-text", "Aussagekräftige Linktexte", W_OTHER, "na",
None, "Keine auswertbaren Links auf der Seite.")
if not flagged:
return _check("link-text", "Aussagekräftige Linktexte", W_OTHER, "pass",
f"{qualifying} Links geprüft",
"Alle Linktexte benennen ihr Ziel — keine Platzhalter wie 'hier' oder 'mehr'.")
sample = ", ".join(sorted(set(flagged))[:6])
return _check("link-text", "Aussagekräftige Linktexte", W_OTHER, "fail",
sample,
f"{len(flagged)} Link(s) mit generischem Text — für Nutzer & Suchmaschinen wenig aussagekräftig.",
"Ersetze generische Linktexte durch beschreibende (Ziel des Links benennen).")
def audit_crawlable_anchors(soup, final_url):
"""Reproduce crawlable-anchors.js: an anchor is uncrawlable if it relies on
JS/onclick instead of a resolvable href. Fails if any anchor is uncrawlable."""
anchors = _iter_anchors(soup)
if not anchors:
return _check("crawlable-anchors", "Crawlbare Links", W_OTHER, "na",
None, "Keine <a>-Elemente auf der Seite.")
uncrawlable = 0
for a in anchors:
raw = re.sub(r"\s", "", a.get("href") or "")
role = (a.get("role") or "").strip()
name_attr = (a.get("name") or "").strip()
has_id = a.get("id") is not None
if role: # role present -> crawlable
continue
if raw.lower().startswith("mailto:"): # mailto -> crawlable
continue
if raw == "" and has_id: # named anchor -> crawlable
continue
if raw == "" and not name_attr: # empty href, no name -> FAIL
uncrawlable += 1
continue
if raw.lower().startswith("file:"):
uncrawlable += 1
continue
if _JS_VOID_RE.search(raw):
uncrawlable += 1
continue
try:
urlparse(urljoin(final_url, raw)) # must resolve
except Exception:
uncrawlable += 1
if uncrawlable == 0:
return _check("crawlable-anchors", "Crawlbare Links", W_OTHER, "pass",
f"{len(anchors)} Links geprüft",
"Alle Links sind über ein auflösbares href crawlbar.")
return _check("crawlable-anchors", "Crawlbare Links", W_OTHER, "fail",
f"{uncrawlable} nicht crawlbar",
f"{uncrawlable} Link(s) sind nur per JavaScript erreichbar (kein auflösbares href).",
"Nutze echte <a href=\"…\">-Links statt onclick/javascript: für Navigation.")
def audit_image_alt(soup):
"""Pass if every <img> has an alt attribute (empty allowed) or an ARIA/role
exemption (approximation of the axe image-alt rule)."""
imgs = soup.find_all("img")
if not imgs:
return _check("image-alt", "Bild-alt-Attribute", W_OTHER, "na",
None, "Keine <img>-Elemente auf der Seite.")
missing = 0
for img in imgs:
if img.get("alt") is not None:
continue
role = (img.get("role") or "").strip().lower()
if role in ("presentation", "none"):
continue
if (img.get("aria-hidden") or "").strip().lower() == "true":
continue
if img.get("aria-label") or img.get("aria-labelledby") or img.get("title"):
continue
missing += 1
if missing == 0:
# Pass is the Lighthouse verdict and stays. But saying only "all images
# have an alt attribute" reads as "image texts are fine" on a page whose
# alts are all empty — so the card names that here instead of letting the
# advisory below carry the correction alone.
empty = sum(1 for i in imgs if i.get("alt") is not None and not i.get("alt").strip())
detail = "Alle Bilder haben ein alt-Attribut (oder eine ARIA-Ausnahme)."
if empty:
detail = (f"Alle {len(imgs)} Bilder haben ein alt-Attribut — {empty} davon ist/sind "
"aber leer (alt=\"\"). Diese Regel bewertet nur, OB das Attribut da ist, "
"nicht ob Text drinsteht; siehe Hinweis „Bildtexte (alt)“ weiter unten.")
return _check("image-alt", "Bild-alt-Attribute", W_OTHER, "pass",
f"{len(imgs)} Bilder geprüft", detail)
return _check("image-alt", "Bild-alt-Attribute", W_OTHER, "fail",
f"{missing} ohne alt",
f"{missing} von {len(imgs)} Bild(ern) fehlt das alt-Attribut.",
"Gib jedem informativen Bild ein aussagekräftiges alt; dekorative Bilder: alt=\"\".")
def nameless_image_links(soup):
"""Links whose ONLY content is image(s) without alt text — so the link has no
accessible name at all: no anchor text for a search engine, nothing for a
screen reader. The classic sponsor-logo wall:
<a href="https://sponsor.example/"><img src="logo.jpg" alt=""></a>
Each of the ten scored audits passes this markup: the <img> HAS an alt
attribute (alt="" is the valid "decorative" marker), the <a> HAS a resolvable
href, and its (empty) text is not in the generic-text blocklist. The defect is
real nonetheless — it is axe-core's `link-name` rule, which Lighthouse scores
in its ACCESSIBILITY category. This tool reproduces the SEO category only, so
the finding is reported as an advisory rather than silently folded into a
score it does not belong to.
A name from any accepted source counts: aria-label/aria-labelledby/title on
the link or on the image, or a non-empty alt."""
out = []
for a in soup.find_all("a", href=True):
if (a.get("aria-label") or a.get("aria-labelledby")
or (a.get("title") or "").strip()):
continue
if a.get_text(strip=True): # real anchor text -> named
continue
imgs = a.find_all("img")
if not imgs: # empty link without image:
continue # a different problem, not this one
named = any((i.get("alt") or "").strip() or i.get("aria-label")
or i.get("aria-labelledby") or (i.get("title") or "").strip()
for i in imgs)
if not named:
out.append(a)
return out
def _is_valid_lang(code: str) -> bool:
"""Approximation of Lighthouse isValidLang: x-default, or a 2-3 letter
language subtag (before the first hyphen)."""
if not code:
return False
code = code.strip().lower()
if code == "x-default":
return True
lang = code.split("-")[0]
return bool(re.fullmatch(r"[a-z]{2,3}", lang))
def audit_hreflang(soup, link_header):
"""Check <link rel=alternate hreflang> in <head> and HTTP Link headers.
Fail on invalid language codes or non-absolute href (per hreflang.js)."""
entries = [] # (hreflang, href)
head = soup.head or soup
for link in head.find_all("link"):
rel = link.get("rel") or []
rel = " ".join(rel).lower() if isinstance(rel, list) else str(rel).lower()
hl = link.get("hreflang")
if hl and "alternate" in rel:
entries.append((hl.strip(), (link.get("href") or "").strip()))
# HTTP Link header: <url>; rel="alternate"; hreflang="xx"
if link_header:
for m in re.finditer(r'<([^>]*)>\s*;\s*([^,]*)', link_header):
href, params = m.group(1).strip(), m.group(2).lower()
hlm = re.search(r'hreflang\s*=\s*"?([a-z0-9-]+)"?', params, re.I)
if "alternate" in params and hlm:
entries.append((hlm.group(1), href))
if not entries:
return _check("hreflang", "hreflang", W_OTHER, "na",
None, "Keine hreflang-Angaben — bei einsprachigen Seiten normal. hreflang verknüpft gleichwertige Seiten in ANDEREN Sprach-/Regionsversionen miteinander (de/en/de-CH …), damit Google die passende ausliefert. Es ist nicht dasselbe wie <html lang>, das nur die Sprache DIESER Seite deklariert — siehe Hinweis „<html lang>“ unten.")
problems = []
for hl, href in entries:
if not _is_valid_lang(hl):
problems.append(f"ungültiger Code '{hl}'")
elif not re.match(r"^https?:", href, re.I):
problems.append(f"relative URL bei '{hl}'")
if not problems:
return _check("hreflang", "hreflang", W_OTHER, "pass",
f"{len(entries)} Angabe(n)",
"Alle hreflang-Angaben haben gültige Sprachcodes und absolute URLs.")
return _check("hreflang", "hreflang", W_OTHER, "fail",
"; ".join(problems[:5]),
"Fehlerhafte hreflang-Angabe(n) gefunden.",
"hreflang braucht gültige Sprach-/Regioncodes und voll qualifizierte (absolute) URLs; x-default ist erlaubt.")
def _norm_url(u):
"""Syntax-based URL normalization for COMPARISON only (RFC 3986 §6.2.2).
Applies just the equivalences that always hold: lowercase scheme and host
(§6.2.2.1) and an empty path equals "/" when there is an authority (§6.2.3).
Nothing else — "/a" and "/a/" are different resources and stay different.
Needed because the two fetch paths report the final URL differently: the
`requests` fallback normalizes `resp.url` ("https://example.com" becomes
"https://example.com/"), while `toolbox_fetch.safe_get` returns the URL as
it was requested. Comparing those raw made a page whose canonical is
"https://example.com/" look like it pointed at a DIFFERENT hreflang variant
than itself — a false "Canonical zeigt auf eine andere hreflang-Variante"
on every check of a bare domain that also sets hreflang.
"""
try:
p = urlparse(u)
except ValueError:
return u
if not p.scheme or not p.netloc:
return u
return urlunparse((p.scheme.lower(), p.netloc.lower(), p.path or "/",
p.params, p.query, p.fragment))
def audit_canonical(soup, final_url, hreflang_hrefs):
"""Reproduce canonical.js failure conditions."""
canon = []
head = soup.head or soup
for link in head.find_all("link"):
rel = link.get("rel") or []
rel = " ".join(rel).lower() if isinstance(rel, list) else str(rel).lower()
if "canonical" in rel:
href = (link.get("href") or "").strip()
if href:
canon.append(href)
if not canon:
return _check("canonical", "Canonical-URL", W_OTHER, "na",
None, "Kein rel=canonical (nicht zwingend nötig, aber empfohlen).")
# Multiple conflicting URLs
distinct = []
for c in canon:
if c not in distinct:
distinct.append(c)
if len(distinct) > 1:
return _check("canonical", "Canonical-URL", W_OTHER, "fail",
", ".join(distinct[:4]),
"Mehrere widersprüchliche Canonical-URLs.",
"Gib genau eine Canonical-URL an.")
raw = distinct[0]
# Relative URL
if not re.match(r"^https?:", raw, re.I):
return _check("canonical", "Canonical-URL", W_OTHER, "fail",
raw, "Canonical ist keine absolute URL.",
"Nutze eine voll qualifizierte (absolute) Canonical-URL.")
# Invalid URL
try:
cu = urlparse(raw)
if not cu.netloc:
raise ValueError
except Exception:
return _check("canonical", "Canonical-URL", W_OTHER, "fail",
raw, "Canonical ist keine gültige URL.",
"Korrigiere die Canonical-URL.")
# Compare on normalized forms — the same URL written two ways is the same
# page, and must not read as "canonical points somewhere else".
n_raw = _norm_url(raw)
n_final = _norm_url(final_url)
n_hreflang = {_norm_url(h) for h in hreflang_hrefs}
cu = urlparse(n_raw)
bu = urlparse(n_final)
# Points to another hreflang location
if n_raw in n_hreflang and n_final in n_hreflang and n_raw != n_final:
return _check("canonical", "Canonical-URL", W_OTHER, "fail",
raw, "Canonical zeigt auf eine andere hreflang-Variante.",
"Jede Sprachvariante sollte sich selbst kanonisieren.")
# Points to the domain root while the page is not the root
if (cu.scheme + "://" + cu.netloc == bu.scheme + "://" + bu.netloc
and cu.path in ("", "/") and bu.path not in ("", "/")):
return _check("canonical", "Canonical-URL", W_OTHER, "fail",
raw, "Canonical zeigt auf die Startseite statt auf diese Seite.",
"Kanonisiere auf die inhaltlich passende Seite, nicht auf die Root-URL.")
return _check("canonical", "Canonical-URL", W_OTHER, "pass",
raw, "Gültige, eindeutige Canonical-URL.")
def _xrobots_blocks(header_value: str) -> str:
"""Return a blocking directive string found in an X-Robots-Tag value, else ''."""
if not header_value:
return ""
for part in header_value.split(","):
token = part.strip()
directive = token
m = re.match(r"^([^,:]+):(.*)$", token)
if m and m.group(1).strip().lower() != "unavailable_after":
directive = m.group(2).strip()
if directive.lower() in ("noindex", "none"):
return token
# unavailable_after: <date> in the past also blocks (per is-crawlable.js)
ua = re.match(r"^\s*(?:[^,:]+:\s*)?unavailable_after:\s*(.+)$", token, re.I)
if ua:
when = _parse_http_date(ua.group(1).strip())
if when and when < datetime.datetime.now(datetime.timezone.utc):
return token
return ""
def _parse_http_date(s: str):
try:
dt = parsedate_to_datetime(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
return dt
except Exception:
return None
def robots_parser(robots_status, robots_text):
"""Parse the site's robots.txt once. Returns a RobotFileParser, or None when
there is nothing usable to parse (missing/erroring robots.txt -> no rules)."""
if not (robots_status and 200 <= robots_status < 300 and robots_text):
return None
rp = RobotFileParser()
try:
rp.parse(robots_text.splitlines())
return rp
except Exception:
return None
def audit_is_crawlable(soup, resp, robots_status, robots_text, final_url, rp=None):
"""The 31%-weight audit. Blocked (fail) if any of: meta robots/googlebot
noindex|none, X-Robots-Tag noindex|none|expired-unavailable_after, or a
robots.txt Disallow of this URL (per is-crawlable.js).
`rp` is an already-parsed RobotFileParser — passed in by the Website-Check so
the site's robots.txt is parsed once, not once per crawled page."""
reasons = []
for bot in ("robots", "googlebot"):
for m in soup.find_all("meta", attrs={"name": re.compile(rf"^{bot}$", re.I)}):
tokens = [t.strip().lower() for t in (m.get("content") or "").split(",")]
if "noindex" in tokens or "none" in tokens:
blk = "noindex" if "noindex" in tokens else "none"
reasons.append(f'<meta name="{bot}"> = {blk}')
xrt = resp.headers.get("X-Robots-Tag")
blk = _xrobots_blocks(xrt)
if blk:
reasons.append(f"X-Robots-Tag: {blk}")
if rp is None:
rp = robots_parser(robots_status, robots_text)
if rp is not None:
try:
if not rp.can_fetch("*", final_url):
reasons.append("robots.txt: Disallow für diese URL")
except Exception:
pass
if not reasons:
return _check("is-crawlable", "Indexierbar / crawlbar", W_CRAWLABLE, "pass",
None, "Die Seite ist für Suchmaschinen indexierbar (kein noindex/Disallow).")
return _check("is-crawlable", "Indexierbar / crawlbar", W_CRAWLABLE, "fail",
"; ".join(reasons),
"Die Seite ist für Suchmaschinen blockiert — sie kann nicht ranken. "
"(Höchstgewichtete Regel: 31 % des Scores.)",
"Entferne noindex/none bzw. den robots.txt-Disallow, wenn die Seite indexiert werden soll.")
def audit_robots_txt(robots_url, status, text):
"""Reproduce robots-txt.js status handling + a line validator (approx.)."""
if status is None:
return _check("robots-txt", "robots.txt", W_OTHER, "na",
robots_url, "robots.txt nicht abrufbar (nicht bewertet).")
if status >= 500:
return _check("robots-txt", "robots.txt", W_OTHER, "fail",
f"HTTP {status}", "robots.txt liefert einen Serverfehler (5xx).",
"Sorge dafür, dass /robots.txt einen 2xx- oder 4xx-Status liefert, keinen 5xx.")
if 400 <= status < 500:
return _check("robots-txt", "robots.txt", W_OTHER, "na",
f"HTTP {status}", "Keine robots.txt vorhanden (das ist zulässig).")
errors = []
seen_ua = False
for i, line in enumerate(text.splitlines(), 1):
s = line.strip()
if not s or s.startswith("#"):
continue
if ":" not in s:
errors.append(i)
continue
directive = s.split(":", 1)[0].strip().lower()
if directive not in _ROBOTS_DIRECTIVES:
errors.append(i)
continue
if directive == "user-agent":
seen_ua = True
elif directive in ("disallow", "allow", "crawl-delay") and not seen_ua:
errors.append(i)
if not errors:
return _check("robots-txt", "robots.txt", W_OTHER, "pass",
robots_url, "robots.txt ist vorhanden und syntaktisch gültig.")
return _check("robots-txt", "robots.txt", W_OTHER, "fail",
f"{len(errors)} Fehlerzeile(n): {', '.join(map(str, errors[:8]))}",
"robots.txt enthält ungültige Zeilen.",
"Korrigiere die markierten Zeilen (unbekannte Direktive, fehlender Doppelpunkt oder Regel vor User-agent).")
# --------------------------------------------------------------------------- #
# Lighthouse ACCESSIBILITY category — the statically decidable part.
#
# Same discipline as the SEO score above: weights verbatim from
# GoogleChrome/lighthouse @ v12.2.1, core/config/default-config.js
# (categories.accessibility.auditRefs), scored as the weighted average over the
# APPLICABLE audits with notApplicable renormalized out — Lighthouse's own
# formula, not a penalty scheme.
#
# The category has 57 weighted audits totalling 404 points. 30 of them need a
# rendered page (computed styles for colour contrast, the accessibility tree for
# the ARIA name audits, layout for target sizes, focusability for aria-hidden-
# focus and skip-link) and are OUT OF SCOPE here — this tool has no headless
# Chrome. What remains is decidable from static HTML alone.
#
# CONSEQUENCE, stated in the output and not to be quietly dropped: leaving those
# audits out of the denominator makes the number SYSTEMATICALLY OPTIMISTIC,
# because the hard ones are the ones missing. It is a partial check
# ("Teilprüfung"), never a Lighthouse accessibility score.
# --------------------------------------------------------------------------- #
A11Y_TOTAL_WEIGHT = 404 # sum over all 57 weighted audits in the category
A11Y_TOTAL_AUDITS = 57
# Weights verbatim from the reference config; only the audits implemented here.
A11Y_WEIGHTS = {
# names & labels
"image-alt": 10, "button-name": 10, "input-button-name": 10,
"input-image-alt": 10, "link-name": 7, "object-alt": 7, "frame-title": 7,
"select-name": 7, "label": 7, "document-title": 7,
# aria (the statically decidable ones)
"aria-hidden-body": 10, "aria-valid-attr": 10, "duplicate-id-aria": 10,
"aria-roles": 7,
# language
"html-has-lang": 7, "html-lang-valid": 7, "valid-lang": 7,
"html-xml-lang-mismatch": 3,
# best practices
"meta-refresh": 10, "meta-viewport": 10,
# tables & lists
"list": 7, "listitem": 7, "definition-list": 7, "dlitem": 7,
"td-headers-attr": 7, "th-has-data-cells": 7,
# navigation
"tabindex": 7, "accesskeys": 7, "heading-order": 3,
# media
"video-caption": 10,
}
# Named in the report so the gap is visible, not merely admitted in prose.
A11Y_OUT_OF_SCOPE = [
("color-contrast", 7, "Farbkontrast — braucht gerenderte CSS-Farben"),
("aria-valid-attr-value", 10, "ARIA-Attributwerte"),
("aria-allowed-attr", 10, "erlaubte ARIA-Attribute je Rolle"),
("aria-required-attr", 10, "erforderliche ARIA-Attribute"),
("aria-required-children", 10, "erforderliche ARIA-Kindelemente"),
("aria-required-parent", 10, "erforderliche ARIA-Elternelemente"),
("aria-hidden-focus", 7, "fokussierbare Elemente in aria-hidden"),
("bypass", 7, "Sprungmarke zum Hauptinhalt"),
("target-size", 7, "Größe von Touch-Zielen — braucht Layout"),
("link-in-text-block", 7, "Links im Fließtext ohne Kontrastunterschied"),
("skip-link", 3, "Funktion der Sprungmarke — braucht Fokus"),
]
# WAI-ARIA 1.2 vocabulary — finite, stable lists, so these audits need no DOM.
_ARIA_ATTRS = {
"aria-activedescendant", "aria-atomic", "aria-autocomplete", "aria-braillelabel",
"aria-brailleroledescription", "aria-busy", "aria-checked", "aria-colcount",
"aria-colindex", "aria-colindextext", "aria-colspan", "aria-controls",
"aria-current", "aria-describedby", "aria-description", "aria-details",
"aria-disabled", "aria-dropeffect", "aria-errormessage", "aria-expanded",
"aria-flowto", "aria-grabbed", "aria-haspopup", "aria-hidden", "aria-invalid",
"aria-keyshortcuts", "aria-label", "aria-labelledby", "aria-level", "aria-live",
"aria-modal", "aria-multiline", "aria-multiselectable", "aria-orientation",
"aria-owns", "aria-placeholder", "aria-posinset", "aria-pressed", "aria-readonly",
"aria-relevant", "aria-required", "aria-roledescription", "aria-rowcount",
"aria-rowindex", "aria-rowindextext", "aria-rowspan", "aria-selected",
"aria-setsize", "aria-sort", "aria-valuemax", "aria-valuemin", "aria-valuenow",
"aria-valuetext",
}
_ARIA_ROLES = {
"alert", "alertdialog", "application", "article", "banner", "blockquote",
"button", "caption", "cell", "checkbox", "code", "columnheader", "combobox",
"command", "complementary", "composite", "contentinfo", "definition",
"deletion", "dialog", "directory", "document", "emphasis", "feed", "figure",
"form", "generic", "grid", "gridcell", "group", "heading", "img", "input",
"insertion", "landmark", "link", "list", "listbox", "listitem", "log", "main",
"mark", "marquee", "math", "menu", "menubar", "menuitem", "menuitemcheckbox",
"menuitemradio", "meter", "navigation", "none", "note", "option", "paragraph",
"presentation", "progressbar", "radio", "radiogroup", "range", "region",
"roletype", "row", "rowgroup", "rowheader", "scrollbar", "search", "searchbox",
"section", "sectionhead", "select", "separator", "slider", "spinbutton",
"status", "strong", "structure", "subscript", "superscript", "switch", "tab",
"table", "tablist", "tabpanel", "term", "textbox", "time", "timer", "toolbar",
"tooltip", "tree", "treegrid", "treeitem", "widget", "window",
}
_ARIA_IDREF_ATTRS = ("aria-labelledby", "aria-describedby", "aria-owns",
"aria-controls", "aria-flowto", "aria-details",
"aria-errormessage", "aria-activedescendant")
def _a11y_hidden(el):
"""True if the element (or an ancestor) is hidden from the accessibility tree
by markup alone. CSS-driven hiding (display:none) is invisible to us — noted
as an approximation: it can only cause MISSED findings, never false ones."""
node = el
while node is not None and getattr(node, "get", None) is not None:
if node.get("hidden") is not None:
return True
if (node.get("aria-hidden") or "").strip().lower() == "true":
return True
node = node.parent
return False
def _labelled_by(el, soup):
"""Non-empty text from the elements an aria-labelledby points at."""
ids = (el.get("aria-labelledby") or "").split()
for i in ids:
try:
target = soup.find(id=i)
except Exception:
target = None
if target is not None and target.get_text(strip=True):
return True
return False
def _named(el, soup, own_text=True):
"""Best-effort accessible name check from static markup: aria-label,
aria-labelledby, title, own text, or the alt text of a contained image."""
if (el.get("aria-label") or "").strip():
return True
if _labelled_by(el, soup):
return True
if (el.get("title") or "").strip():
return True
if own_text and el.get_text(strip=True):
return True
for img in el.find_all("img"):
if (img.get("alt") or "").strip():
return True
return False
def _a11y(cid, name, status, value, detail, advice=""):
return _check(cid, name, A11Y_WEIGHTS[cid], status, value, detail, advice,
denom=A11Y_TOTAL_WEIGHT)
def _na(cid, name, detail):
return _a11y(cid, name, "na", None, detail)
def a11y_checks(soup):
"""The statically decidable part of the Lighthouse accessibility category.
Returns check dicts in the same shape the SEO audits use, so the identical
scoring, grading and rendering code applies."""
out = []
body = soup.body or soup
html_el = soup.html
# ── names & labels ──────────────────────────────────────────────────────
imgs = [i for i in soup.find_all("img") if not _a11y_hidden(i)]
if not imgs:
out.append(_na("image-alt", "Bilder mit alt-Attribut", "Keine Bilder auf der Seite."))
else:
bad = [i for i in imgs
if i.get("alt") is None
and (i.get("role") or "").strip().lower() not in ("presentation", "none")
and not (i.get("aria-label") or "").strip()
and not _labelled_by(i, soup)]
out.append(_a11y("image-alt", "Bilder mit alt-Attribut",
"pass" if not bad else "fail",
f"{len(imgs)} Bild(er)" if not bad else f"{len(bad)} ohne alt",
"Jedes Bild hat ein alt-Attribut oder ist als dekorativ ausgezeichnet."
if not bad else
f"{len(bad)} von {len(imgs)} Bild(ern) fehlt das alt-Attribut — "
"Screenreader lesen dann den Dateinamen vor oder überspringen das Bild.",
"" if not bad else
"Gib jedem informativen Bild ein beschreibendes alt; rein dekorative "
"Bilder bekommen alt=\"\"."))
links = [a for a in soup.find_all("a", href=True) if not _a11y_hidden(a)]
if not links:
out.append(_na("link-name", "Links haben einen Namen", "Keine Links auf der Seite."))
else:
bad = [a for a in links if not _named(a, soup)]
val = f"{len(links)} Link(s)"
if bad:
names = []
for a in bad:
img = a.find("img")
fn = os.path.basename(urlparse(img.get("src") or "").path) if img else ""
if fn and fn not in names:
names.append(fn)
if len(names) == 3:
break
val = f"{len(bad)} ohne Namen"
if names:
val += ": " + ", ".join(names) + (" …" if len(bad) > len(names) else "")
out.append(_a11y("link-name", "Links haben einen Namen",
"pass" if not bad else "fail", val,
"Jeder Link trägt einen Namen (Text, Alt-Text, aria-label oder title)."
if not bad else
f"{len(bad)} von {len(links)} Link(s) haben keinen Namen — meist Links, "
"die nur aus einem Bild ohne Alt-Text bestehen (typisch: Sponsoren- und "
"Partner-Logos). Ein Screenreader kann sie nicht benennen, und "
"Suchmaschinen erhalten keinen Ankertext.",
"" if not bad else
"Gib dem Bild im Link ein sprechendes alt (z. B. alt=\"Sponsor XY\") "
"oder dem Link ein aria-label."))
buttons = [b for b in soup.find_all("button") if not _a11y_hidden(b)]
if not buttons:
out.append(_na("button-name", "Schaltflächen haben einen Namen",
"Keine <button>-Elemente auf der Seite."))
else:
bad = [b for b in buttons if not _named(b, soup)]
out.append(_a11y("button-name", "Schaltflächen haben einen Namen",
"pass" if not bad else "fail",
f"{len(buttons)} Schaltfläche(n)" if not bad else f"{len(bad)} ohne Namen",
"Jede Schaltfläche trägt einen Namen." if not bad else
f"{len(bad)} Schaltfläche(n) haben keinen Namen — Screenreader melden nur "
"„Schaltfläche“, ohne zu sagen, was sie tut.",
"" if not bad else
"Gib der Schaltfläche Text, ein aria-label oder ein Bild mit alt."))
# type=submit/reset carry an implicit default name; only type=button needs one
ibuttons = [i for i in soup.find_all("input") if not _a11y_hidden(i)
and (i.get("type") or "").strip().lower() in ("button", "submit", "reset")]
need_name = [i for i in ibuttons if (i.get("type") or "").strip().lower() == "button"]
if not need_name:
out.append(_na("input-button-name", "Eingabe-Schaltflächen haben einen Namen",
"Keine <input type=\"button\">-Elemente auf der Seite."))
else:
bad = [i for i in need_name
if not (i.get("value") or "").strip() and not _named(i, soup, own_text=False)]
out.append(_a11y("input-button-name", "Eingabe-Schaltflächen haben einen Namen",
"pass" if not bad else "fail",
f"{len(need_name)} Element(e)" if not bad else f"{len(bad)} ohne Namen",
"Jede Eingabe-Schaltfläche trägt einen Namen." if not bad else
f"{len(bad)} <input type=\"button\"> ohne value/aria-label.",
"" if not bad else "Setze ein value-Attribut oder ein aria-label."))
iimgs = [i for i in soup.find_all("input") if not _a11y_hidden(i)
and (i.get("type") or "").strip().lower() == "image"]
if not iimgs:
out.append(_na("input-image-alt", "Bild-Schaltflächen haben alt-Text",
"Keine <input type=\"image\">-Elemente auf der Seite."))
else:
bad = [i for i in iimgs
if not (i.get("alt") or "").strip() and not _named(i, soup, own_text=False)]
out.append(_a11y("input-image-alt", "Bild-Schaltflächen haben alt-Text",
"pass" if not bad else "fail",
f"{len(iimgs)} Element(e)" if not bad else f"{len(bad)} ohne alt",
"Jede Bild-Schaltfläche hat alt-Text." if not bad else
f"{len(bad)} <input type=\"image\"> ohne alt-Text — die Funktion der "
"Schaltfläche ist damit nicht benennbar.",
"" if not bad else "Gib dem Element ein alt, das die Aktion beschreibt."))
objs = [o for o in soup.find_all("object") if not _a11y_hidden(o)]
if not objs:
out.append(_na("object-alt", "<object> hat Alternativtext",
"Keine <object>-Elemente auf der Seite."))
else:
bad = [o for o in objs if not _named(o, soup)]
out.append(_a11y("object-alt", "<object> hat Alternativtext",
"pass" if not bad else "fail",
f"{len(objs)} Element(e)" if not bad else f"{len(bad)} ohne Text",
"Jedes <object> hat einen Alternativtext." if not bad else
f"{len(bad)} <object> ohne Alternativtext.",
"" if not bad else "Gib dem <object> Inhaltstext oder ein aria-label."))
frames = [f for f in soup.find_all(["iframe", "frame"]) if not _a11y_hidden(f)]
if not frames:
out.append(_na("frame-title", "Eingebettete Rahmen haben einen Titel",
"Keine <iframe>/<frame>-Elemente auf der Seite."))
else:
bad = [f for f in frames
if not (f.get("title") or "").strip() and not _named(f, soup, own_text=False)]
out.append(_a11y("frame-title", "Eingebettete Rahmen haben einen Titel",
"pass" if not bad else "fail",
f"{len(frames)} Rahmen" if not bad else f"{len(bad)} ohne Titel",
"Jeder eingebettete Rahmen hat einen Titel." if not bad else
f"{len(bad)} von {len(frames)} Rahmen (z. B. eingebettete Videos oder "
"Karten) haben kein title-Attribut — Screenreader können den Inhalt "
"nicht ankündigen.",
"" if not bad else
"Gib jedem <iframe> ein title, das seinen Inhalt benennt."))
def _has_label(el, soup):
if (el.get("aria-label") or "").strip() or _labelled_by(el, soup):
return True
if (el.get("title") or "").strip():
return True
if el.find_parent("label") is not None:
return True
eid = el.get("id")
if eid:
for lab in soup.find_all("label"):
if lab.get("for") == eid:
return True
return False
selects = [s for s in soup.find_all("select") if not _a11y_hidden(s)]
if not selects:
out.append(_na("select-name", "Auswahlfelder haben einen Namen",
"Keine <select>-Elemente auf der Seite."))
else:
bad = [s for s in selects if not _has_label(s, soup)]
out.append(_a11y("select-name", "Auswahlfelder haben einen Namen",
"pass" if not bad else "fail",
f"{len(selects)} Feld(er)" if not bad else f"{len(bad)} ohne Namen",
"Jedes Auswahlfeld hat einen Namen." if not bad else
f"{len(bad)} <select> ohne zugeordnetes Label.",
"" if not bad else
"Verknüpfe ein <label for=…> oder setze ein aria-label."))
_NO_LABEL_TYPES = ("hidden", "button", "submit", "reset", "image")
fields = [f for f in soup.find_all(["input", "textarea"])
if not _a11y_hidden(f)
and not (f.name == "input"
and (f.get("type") or "text").strip().lower() in _NO_LABEL_TYPES)]
if not fields:
out.append(_na("label", "Formularfelder haben ein Label",
"Keine beschriftungspflichtigen Formularfelder auf der Seite."))
else:
bad = [f for f in fields if not _has_label(f, soup)]
out.append(_a11y("label", "Formularfelder haben ein Label",
"pass" if not bad else "fail",
f"{len(fields)} Feld(er)" if not bad else f"{len(bad)} ohne Label",
"Jedes Formularfeld hat ein Label." if not bad else
f"{len(bad)} von {len(fields)} Formularfeld(ern) haben kein Label. "
"Ein placeholder zählt nicht — er verschwindet bei der Eingabe und wird "
"nicht zuverlässig vorgelesen.",
"" if not bad else
"Verknüpfe <label for=\"feld-id\"> mit dem Feld oder setze ein aria-label."))
title = soup.title.get_text(strip=True) if soup.title else ""
out.append(_a11y("document-title", "Seite hat einen Titel",
"pass" if title else "fail", title or None,
"Die Seite hat einen Titel — Screenreader nennen ihn beim Öffnen zuerst."
if title else "Die Seite hat keinen <title>.",
"" if title else "Gib der Seite einen aussagekräftigen <title>."))
# ── ARIA (statically decidable) ─────────────────────────────────────────
body_hidden = body is not None and (body.get("aria-hidden") or "").strip().lower() == "true"
out.append(_a11y("aria-hidden-body", "<body> nicht vor Screenreadern versteckt",
"fail" if body_hidden else "pass", None,
"Der Seitenkörper ist mit aria-hidden=\"true\" komplett vor Screenreadern "
"verborgen — die Seite ist damit für sie leer."
if body_hidden else
"Der Seitenkörper ist für Screenreader zugänglich.",
"Entferne aria-hidden vom <body>." if body_hidden else ""))
bad_attrs, bad_roles, aria_seen, role_seen = [], [], 0, 0
for el in soup.find_all(True):
for attr in el.attrs:
if attr.startswith("aria-"):
aria_seen += 1
if attr not in _ARIA_ATTRS and attr not in bad_attrs:
bad_attrs.append(attr)
raw_role = el.get("role")
if raw_role:
role_seen += 1
toks = raw_role.split() if isinstance(raw_role, str) else list(raw_role)
for t in toks:
t = t.strip().lower()
if t and t not in _ARIA_ROLES and t not in bad_roles:
bad_roles.append(t)
if not aria_seen:
out.append(_na("aria-valid-attr", "ARIA-Attribute sind gültig",
"Keine ARIA-Attribute auf der Seite."))
else:
out.append(_a11y("aria-valid-attr", "ARIA-Attribute sind gültig",
"pass" if not bad_attrs else "fail",
f"{aria_seen} Attribut(e)" if not bad_attrs else ", ".join(bad_attrs[:5]),
"Alle ARIA-Attributnamen sind gültig." if not bad_attrs else
"Unbekannte ARIA-Attribute — sie werden ignoriert, die beabsichtigte "
"Information kommt nicht an.",
"" if not bad_attrs else "Korrigiere die Schreibweise (WAI-ARIA 1.2)."))
if not role_seen:
out.append(_na("aria-roles", "ARIA-Rollen sind gültig",
"Keine role-Attribute auf der Seite."))
else:
out.append(_a11y("aria-roles", "ARIA-Rollen sind gültig",
"pass" if not bad_roles else "fail",
f"{role_seen} Rolle(n)" if not bad_roles else ", ".join(bad_roles[:5]),
"Alle role-Werte sind gültige ARIA-Rollen." if not bad_roles else
"Ungültige role-Werte — die Elemente behalten ihre Standardbedeutung.",
"" if not bad_roles else "Nutze eine gültige ARIA-Rolle."))
referenced = set()
for el in soup.find_all(True):
for attr in _ARIA_IDREF_ATTRS:
v = el.get(attr)
if v:
referenced.update(v.split() if isinstance(v, str) else v)
if not referenced:
out.append(_na("duplicate-id-aria", "Von ARIA referenzierte IDs sind eindeutig",
"Keine ARIA-Referenzen auf IDs."))
else:
counts = {}
for el in soup.find_all(attrs={"id": True}):
counts[el["id"]] = counts.get(el["id"], 0) + 1
dupes = sorted(i for i in referenced if counts.get(i, 0) > 1)
out.append(_a11y("duplicate-id-aria", "Von ARIA referenzierte IDs sind eindeutig",
"pass" if not dupes else "fail",
f"{len(referenced)} Referenz(en)" if not dupes else ", ".join(dupes[:5]),
"Jede von ARIA referenzierte ID kommt genau einmal vor." if not dupes else
"Mehrfach vergebene IDs werden von ARIA referenziert — Screenreader "
"greifen dann auf das falsche Element zu.",
"" if not dupes else "Vergib eindeutige IDs."))
# ── language ────────────────────────────────────────────────────────────
lang = (html_el.get("lang") if html_el else None) or ""
out.append(_a11y("html-has-lang", "<html> hat ein lang-Attribut",
"pass" if lang.strip() else "fail", lang.strip() or None,
f"Die Seitensprache ist deklariert: {lang.strip()}" if lang.strip() else
"Dem <html>-Element fehlt das lang-Attribut — Screenreader wählen dann die "
"falsche Aussprache.",
"" if lang.strip() else "Setze <html lang=\"de\">."))
if not lang.strip():
out.append(_na("html-lang-valid", "Seitensprache ist ein gültiger Code",
"Kein lang-Attribut vorhanden (siehe oben)."))
else:
ok = _is_valid_lang(lang)
out.append(_a11y("html-lang-valid", "Seitensprache ist ein gültiger Code",
"pass" if ok else "fail", lang.strip(),
"Der Sprachcode ist gültig." if ok else
f"„{lang.strip()}“ ist kein gültiger Sprachcode.",
"" if ok else "Nutze einen BCP-47-Code wie de, en oder de-AT."))
other_langs = [(el.name, el["lang"]) for el in soup.find_all(attrs={"lang": True})
if el is not html_el and (el.get("lang") or "").strip()]
if not other_langs:
out.append(_na("valid-lang", "lang-Attribute im Inhalt sind gültig",
"Keine lang-Attribute an einzelnen Elementen."))
else:
bad = [f"{n}: {v}" for n, v in other_langs if not _is_valid_lang(v)]
out.append(_a11y("valid-lang", "lang-Attribute im Inhalt sind gültig",
"pass" if not bad else "fail",
f"{len(other_langs)} Angabe(n)" if not bad else ", ".join(bad[:4]),
"Alle Sprachwechsel im Inhalt nutzen gültige Codes." if not bad else
"Ungültige Sprachcodes an einzelnen Elementen.",
"" if not bad else "Nutze gültige BCP-47-Codes."))
xml_lang = (html_el.get("xml:lang") if html_el else None) or ""
if not (lang.strip() and xml_lang.strip()):
out.append(_na("html-xml-lang-mismatch", "lang und xml:lang stimmen überein",
"Kein xml:lang am <html>-Element (bei HTML5 normal)."))
else:
ok = lang.strip().lower().split("-")[0] == xml_lang.strip().lower().split("-")[0]
out.append(_a11y("html-xml-lang-mismatch", "lang und xml:lang stimmen überein",
"pass" if ok else "fail", f"{lang.strip()} / {xml_lang.strip()}",
"lang und xml:lang nennen dieselbe Sprache." if ok else
"lang und xml:lang widersprechen sich.",
"" if ok else "Gleiche beide Angaben an."))
# ── best practices ──────────────────────────────────────────────────────
refresh = None
for m in soup.find_all("meta"):
if (m.get("http-equiv") or "").strip().lower() == "refresh":
content = (m.get("content") or "").strip()
head = content.split(";")[0].strip()
try:
if float(head) > 0:
refresh = content
except ValueError:
pass
if refresh is None:
out.append(_a11y("meta-refresh", "Kein automatisches Neuladen", "pass", None,
"Die Seite lädt sich nicht selbsttätig neu."))
else:
out.append(_a11y("meta-refresh", "Kein automatisches Neuladen", "fail", refresh,
"Die Seite lädt sich nach einer Wartezeit selbst neu — wer langsamer "
"liest oder bedient, verliert dabei seine Position.",
"Entferne <meta http-equiv=\"refresh\"> mit Wartezeit."))
vp = soup.find("meta", attrs={"name": re.compile(r"^viewport$", re.I)})
if vp is None:
out.append(_na("meta-viewport", "Zoom ist nicht blockiert",
"Kein Viewport-Meta vorhanden — Zoom wird also nicht eingeschränkt."))
else:
content = (vp.get("content") or "").lower()
blocks = "user-scalable=no" in content.replace(" ", "")
mx = re.search(r"maximum-scale\s*=\s*([0-9.]+)", content)
if mx:
try:
blocks = blocks or float(mx.group(1)) < 5
except ValueError:
pass
out.append(_a11y("meta-viewport", "Zoom ist nicht blockiert",
"fail" if blocks else "pass", vp.get("content"),
"Die Seite unterbindet das Vergrößern — für Menschen mit "
"Sehbeeinträchtigung eine harte Barriere." if blocks else
"Nutzer können die Seite vergrößern.",
"Entferne user-scalable=no und setze maximum-scale auf mindestens 5."
if blocks else ""))
# ── lists & tables ──────────────────────────────────────────────────────
lists = [l for l in soup.find_all(["ul", "ol"]) if not _a11y_hidden(l)]
if not lists:
out.append(_na("list", "Listen enthalten nur Listenelemente",
"Keine Listen auf der Seite."))
else:
allowed = {"li", "script", "template"}
bad = 0
for l in lists:
for child in l.children:
nm = getattr(child, "name", None)
if nm is None:
if str(child).strip():
bad += 1
break
elif nm not in allowed:
bad += 1
break
out.append(_a11y("list", "Listen enthalten nur Listenelemente",
"pass" if not bad else "fail",
f"{len(lists)} Liste(n)" if not bad else f"{bad} fehlerhaft",
"Alle Listen enthalten ausschließlich <li>-Elemente." if not bad else
f"{bad} von {len(lists)} Liste(n) enthalten andere Elemente direkt "
"unter <ul>/<ol> — Screenreader zählen die Einträge dann falsch.",
"" if not bad else "Packe fremde Inhalte in ein <li>."))
items = [li for li in soup.find_all("li") if not _a11y_hidden(li)]
if not items:
out.append(_na("listitem", "Listenelemente stehen in einer Liste",
"Keine <li>-Elemente auf der Seite."))
else:
bad = 0
for li in items:
p = li.parent
pn = getattr(p, "name", None)
prole = (p.get("role") or "").strip().lower() if p is not None and p.get else ""
if pn not in ("ul", "ol", "menu") and prole not in ("list", "menu", "listbox", "group"):
bad += 1
out.append(_a11y("listitem", "Listenelemente stehen in einer Liste",
"pass" if not bad else "fail",
f"{len(items)} Element(e)" if not bad else f"{bad} verwaist",
"Alle <li> stehen in einer Liste." if not bad else
f"{bad} <li> stehen nicht in <ul>/<ol>.",
"" if not bad else "Umschließe sie mit <ul> oder <ol>."))
dls = [d for d in soup.find_all("dl") if not _a11y_hidden(d)]
if not dls:
out.append(_na("definition-list", "Definitionslisten sind korrekt aufgebaut",
"Keine <dl>-Elemente auf der Seite."))
else:
allowed = {"dt", "dd", "div", "script", "template"}
bad = 0
for d in dls:
for child in d.children:
nm = getattr(child, "name", None)
if nm is None:
if str(child).strip():
bad += 1
break
elif nm not in allowed:
bad += 1
break
out.append(_a11y("definition-list", "Definitionslisten sind korrekt aufgebaut",
"pass" if not bad else "fail",
f"{len(dls)} Liste(n)" if not bad else f"{bad} fehlerhaft",
"Alle <dl> enthalten nur <dt>/<dd>." if not bad else
f"{bad} <dl> enthalten fremde Elemente.",
"" if not bad else "In <dl> gehören nur <dt>, <dd> (optional in <div>)."))
ditems = [e for e in soup.find_all(["dt", "dd"]) if not _a11y_hidden(e)]
if not ditems:
out.append(_na("dlitem", "<dt>/<dd> stehen in einer <dl>",
"Keine <dt>/<dd>-Elemente auf der Seite."))
else:
bad = sum(1 for e in ditems if e.find_parent("dl") is None)
out.append(_a11y("dlitem", "<dt>/<dd> stehen in einer <dl>",
"pass" if not bad else "fail",
f"{len(ditems)} Element(e)" if not bad else f"{bad} verwaist",
"Alle <dt>/<dd> stehen in einer <dl>." if not bad else
f"{bad} <dt>/<dd> ohne <dl>-Elternelement.",
"" if not bad else "Umschließe sie mit <dl>."))
tables = [t for t in soup.find_all("table") if not _a11y_hidden(t)]
hdr_cells = [c for t in tables for c in t.find_all(attrs={"headers": True})]
if not hdr_cells:
out.append(_na("td-headers-attr", "headers-Verweise zeigen auf Zellen derselben Tabelle",
"Keine Zellen mit headers-Attribut."))
else:
bad = 0
for c in hdr_cells:
t = c.find_parent("table")
ids = {e.get("id") for e in t.find_all(attrs={"id": True})} if t else set()
if any(ref not in ids for ref in (c.get("headers") or "").split()):
bad += 1
out.append(_a11y("td-headers-attr", "headers-Verweise zeigen auf Zellen derselben Tabelle",
"pass" if not bad else "fail",
f"{len(hdr_cells)} Zelle(n)" if not bad else f"{bad} fehlerhaft",
"Alle headers-Verweise treffen eine Zelle derselben Tabelle."
if not bad else
f"{bad} Zelle(n) verweisen auf IDs, die es in ihrer Tabelle nicht gibt.",
"" if not bad else "Korrigiere die headers-Verweise."))
th_tables = [t for t in tables if t.find("th") is not None]
if not th_tables:
out.append(_na("th-has-data-cells", "Tabellenüberschriften haben Datenzellen",
"Keine Tabellen mit <th>-Überschriften."))
else:
# Approximation (no rendering): a header table with no data cell at all
# cannot have associated data. Lighthouse/axe additionally match each th
# to its own cells, which needs the rendered table grid.
bad = sum(1 for t in th_tables if t.find("td") is None)
out.append(_a11y("th-has-data-cells", "Tabellenüberschriften haben Datenzellen",
"pass" if not bad else "fail",
f"{len(th_tables)} Tabelle(n)" if not bad else f"{bad} ohne Datenzellen",
"Jede Tabelle mit Überschriften enthält auch Datenzellen." if not bad else
f"{bad} Tabelle(n) haben <th>-Überschriften, aber keine <td>-Zellen — "
"vermutlich wird eine Tabelle für Layout statt für Daten benutzt.",
"" if not bad else
"Nutze Tabellen nur für Daten; für Layout ist CSS zuständig."))
# ── navigation ──────────────────────────────────────────────────────────
tabbed = []
for el in soup.find_all(attrs={"tabindex": True}):
try:
if int((el.get("tabindex") or "0").strip()) > 0:
tabbed.append(el.name)
except ValueError:
pass
out.append(_a11y("tabindex", "Keine positiven tabindex-Werte",
"fail" if tabbed else "pass",
", ".join(sorted(set(tabbed))[:5]) if tabbed else None,
f"{len(tabbed)} Element(e) haben tabindex > 0 — das reißt die "
"Tastatur-Reihenfolge aus der Lesereihenfolge heraus." if tabbed else
"Die Tastatur-Reihenfolge folgt der Lesereihenfolge.",
"Nutze tabindex=\"0\" statt positiver Werte." if tabbed else ""))
keys = [(el.get("accesskey") or "").strip().lower()
for el in soup.find_all(attrs={"accesskey": True})]
keys = [k for k in keys if k]
if not keys:
out.append(_na("accesskeys", "accesskey-Werte sind eindeutig",
"Keine accesskey-Attribute auf der Seite."))
else:
dupes = sorted({k for k in keys if keys.count(k) > 1})
out.append(_a11y("accesskeys", "accesskey-Werte sind eindeutig",
"pass" if not dupes else "fail",
f"{len(keys)} Kürzel" if not dupes else ", ".join(dupes[:5]),
"Jedes Tastaturkürzel kommt nur einmal vor." if not dupes else
"Mehrfach vergebene Tastaturkürzel — nur eines davon wirkt.",
"" if not dupes else "Vergib eindeutige accesskey-Werte."))
heads = [h for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"])
if not _a11y_hidden(h)]
if len(heads) < 2:
out.append(_na("heading-order", "Überschriften-Ebenen ohne Sprünge",
"Zu wenige Überschriften für eine Reihenfolge-Prüfung."))
else:
jumps = []
prev = int(heads[0].name[1])
for h in heads[1:]:
lvl = int(h.name[1])
if lvl > prev + 1:
jumps.append(f"h{prev} → h{lvl}")
prev = lvl
out.append(_a11y("heading-order", "Überschriften-Ebenen ohne Sprünge",
"pass" if not jumps else "fail",
f"{len(heads)} Überschriften" if not jumps else ", ".join(jumps[:4]),
"Die Überschriften-Ebenen steigen ohne Sprünge ab." if not jumps else
f"{len(jumps)} Sprung/Sprünge in den Überschriften-Ebenen — wer per "
"Überschriften navigiert, verliert die Gliederung.",
"" if not jumps else
"Überspringe keine Ebene (nach h2 kommt h3, nicht h4)."))
# ── media ───────────────────────────────────────────────────────────────
videos = [v for v in soup.find_all("video") if not _a11y_hidden(v)]
if not videos:
out.append(_na("video-caption", "Videos haben Untertitel",
"Keine <video>-Elemente auf der Seite."))
else:
bad = 0
for v in videos:
if not any((t.get("kind") or "").strip().lower() == "captions"
for t in v.find_all("track")):
bad += 1
out.append(_a11y("video-caption", "Videos haben Untertitel",
"pass" if not bad else "fail",
f"{len(videos)} Video(s)" if not bad else f"{bad} ohne Untertitel",
"Jedes Video hat eine Untertitelspur." if not bad else
f"{bad} von {len(videos)} Video(s) haben keine Untertitel — für "
"gehörlose und schwerhörige Nutzer nicht zugänglich.",
"" if not bad else
"Binde <track kind=\"captions\" src=\"…\"> in das <video> ein."))
return out
def a11y_coverage():
"""(implemented_weight, total_weight, implemented_audits, total_audits)."""
return (sum(A11Y_WEIGHTS.values()), A11Y_TOTAL_WEIGHT,
len(A11Y_WEIGHTS), A11Y_TOTAL_AUDITS)
# --------------------------------------------------------------------------- #
# Advisory checks (NOT scored — surfaced separately, like the security tool's
# information-disclosure/permissions-policy advisories). Referenceable to
# Google Search Essentials, not to the Lighthouse SEO score.
# --------------------------------------------------------------------------- #
def advisories(soup, resp, final_url, llms, sitemap):
out = []
is_https = final_url.lower().startswith("https://")
out.append(("HTTPS", "pass" if is_https else "warn",
final_url,
"Über HTTPS ausgeliefert." if is_https
else "Nicht über HTTPS — Ranking- und Vertrauensnachteil. (Details: Security-Headers-Tool.)"))
# Server response time (TTFB) + HTML size. Measured server-side from our data
# centre — a rough server-responsiveness signal, NOT the browser-perceived load
# time / Core Web Vitals (those need a real browser or field data). Advisory only.
# Threshold per web.dev TTFB guidance: < 800 ms good, < 1800 ms ok, else slow.
ttfb_ms = int(resp.elapsed.total_seconds() * 1000)
html_kb = len(resp.content) / 1024
ttfb_st = "pass" if ttfb_ms < 800 else ("info" if ttfb_ms < 1800 else "warn")
out.append(("Server-Antwortzeit", ttfb_st, f"{ttfb_ms} ms · HTML {html_kb:.0f} KB",
"Zeit bis zur ersten Antwort des Servers (TTFB), von unserem Rechenzentrum aus gemessen "
"— ein grober Server-Richtwert, nicht die im Browser erlebte Ladezeit oder Core Web Vitals "
"(die bräuchten einen echten Browser bzw. Feld-Daten). Richtwert: unter 800 ms gut. "
"Für echte Ladezeit & Core Web Vitals: Google PageSpeed Insights / Lighthouse."))
# Oversized / inefficient images — bounded ranged reads (see image_report()).
# Flags on absolute size (> 200 KB) OR bytes-per-pixel (heavy for the image's
# own dimensions — the Lighthouse "optimized/responsive images" idea).
recs, n_attempted, n_total = image_report(soup, final_url)
if n_total:
sized = [(u, s, d) for (u, s, d) in recs if s is not None]
n_noinfo = n_attempted - len(sized)
total_mb = sum(s for _, s, _ in sized) / (1024 * 1024)
cov = (f"die ersten {n_attempted} von {n_total} Bildern geprüft"
if n_total > n_attempted else f"{n_attempted} Bild(er) geprüft")
if n_noinfo:
cov += f", {n_noinfo} ohne verwertbaren Header"
def _bpp(s, d):
return s / (d[0] * d[1]) if d and d[0] and d[1] else None
offenders = []
for u, s, d in sized:
bpp = _bpp(s, d)
too_big = s > 200 * 1024
inefficient = bpp is not None and bpp > 1.5 and s > 40 * 1024
if too_big or inefficient:
offenders.append((u, s, d, bpp))
offenders.sort(key=lambda t: t[1], reverse=True)
if not sized:
out.append(("Bildgrößen", "info", None,
f"Bildgrößen nicht ermittelbar. ({cov}.)"))
elif offenders:
u0, s0, d0, bpp0 = offenders[0]
fn = os.path.basename(urlparse(u0).path) or u0
dim = f", {d0[0]}×{d0[1]} px = {bpp0:.1f} Byte/Pixel" if d0 else ""
out.append(("Bildgrößen", "warn",
f"gesamt {total_mb:.1f} MB · größtes {s0 / 1024:.0f} KB",
f"{len(offenders)} Bild(er) zu groß oder für ihre Abmessungen ineffizient — "
f"größtes: {fn} ({s0 / 1024:.0f} KB{dim}). Richtwert: einzelne Bilder deutlich unter "
f"200 KB, gut komprimiert (WebP/AVIF) und in Anzeigegröße statt hochskaliert. ({cov}.)"))
else:
out.append(("Bildgrößen", "pass", f"gesamt {total_mb:.1f} MB",
f"Keine übergroßen oder ineffizient kodierten Bilder (Schwellen: 200 KB bzw. Byte/Pixel). ({cov}.)"))
# Empty alt attributes. Deliberately ADVISORY, not scored: alt="" is the
# valid, explicit "this image is decorative" marker — axe/Lighthouse pass it,
# so the scored image-alt audit must too. But a page whose logos, photos and
# sponsor images are all alt="" passes that audit while carrying no image
# text at all for search engines and screen readers. That gap is worth
# naming, without pretending it is a rule violation.
imgs = soup.find_all("img")
empty_alt = [i for i in imgs if i.get("alt") is not None and not i.get("alt").strip()]
no_alt = [i for i in imgs if i.get("alt") is None]
if imgs:
# One finding for "image carries no text", whatever the cause. Splitting
# the missing attribute from the empty one produced the misleading pair
# "1 Bild ohne alt" next to "456 mit leerem alt" — same defect, two lines,
# the small number reading like the whole story.
textless = len(empty_alt) + len(no_alt)
n_i = len(imgs)
if textless == 0:
out.append(("Bildtexte (alt)", "pass", f"{n_i} Bild(er)",
"Jedes Bild trägt einen Alt-Text mit Inhalt."))
else:
how = f"{len(empty_alt)}× leeres alt=\"\""
if no_alt:
how += f", {len(no_alt)}× gar kein alt-Attribut"
st = "warn" if textless >= max(3, n_i / 2) else "info"
out.append(("Bildtexte (alt)", st, f"{textless} von {n_i} ohne Text",
f"{textless} von {n_i} Bild(ern) tragen keinen Bildtext ({how}). "
"Ein leeres alt=\"\" ist formal korrekt und markiert ein Bild als rein "
"dekorativ — die bewertete Regel „Bild-alt-Attribute“ oben lässt es "
"deshalb durchgehen. Für inhaltlich relevante Bilder (Logos, Sponsoren, "
"Fotos, Grafiken) geht damit aber jeder Text für Suchmaschinen und "
"Screenreader verloren. Prüfe, welche dieser Bilder wirklich dekorativ "
"sind."))
# title attribute on images — STRICTLY informational, never a verdict.
# Googles image-SEO documentation does not list the attribute at all, and
# Google stated in 2022 that it is parsed but not used as a ranking signal.
# So it gets no pass/weak/fail: a "Weak" here would mark as a defect something
# that is not one. Where an image genuinely carries no text, the finding
# belongs to "Bildtexte (alt)" and the linked-image advisory, which say so —
# this item must not double-count it under a heading it does not belong to.
if imgs:
n_i = len(imgs)
n_t = sum(1 for i in imgs if (i.get("title") or "").strip())
out.append(("Bild-title-Attribute (Info)", "info", f"{n_t} von {n_i} Bildern",
f"{n_i - n_t} von {n_i} Bild(ern) haben kein title-Attribut. "
"Das ist kein Mangel und geht in keine Bewertung ein: Googles Bild-SEO-"
"Dokumentation führt das Attribut nicht auf, und Google hat 2022 bestätigt, "
"dass es zwar gelesen, aber nicht als Ranking-Signal gewertet wird. Es zeigt "
"einen Tooltip bei Mausberührung (auf Touch-Geräten nicht, per Tastatur nicht "
"erreichbar) und dient als Ersatz-Name, wenn alt fehlt. Das dokumentierte "
"Textsignal für Bildinhalte ist das alt-Attribut — und zwar für die "
"Bildersuche; in der normalen Websuche zählen bei Bildern eher Dateiname und "
"umgebender Text. Die Zahl steht hier nur zur Einordnung."))
# Linked images without any accessible name (see nameless_image_links).
nameless = nameless_image_links(soup)
if nameless:
# The affected image used to be named here as "Beispiel: <datei> → <ziel-url>",
# with the URL hard-cut at 60 characters and no ellipsis — mid-word, so it
# read as broken output rather than as an example. The file names alone
# identify the images without any truncation, and they belong in the value
# field, not glued to the end of a paragraph of explanation.
names = []
for a in nameless:
img = a.find("img")
fn = os.path.basename(urlparse(img.get("src") or "").path) if img else ""
if fn and fn not in names:
names.append(fn)
if len(names) == 3:
break
value = f"{len(nameless)} Link(s)"
if names:
value += ": " + ", ".join(names)
if len(nameless) > len(names):
value += " …"
out.append(("Verlinkte Bilder ohne Linktext", "warn", value,
f"{len(nameless)} Link(s) bestehen nur aus einem Bild ohne Alt-Text — der Link "
"hat damit gar keinen Namen: keinen Ankertext für Suchmaschinen und nichts, "
"was ein Screenreader vorlesen könnte. Typisch für Sponsoren-/Partner-Logos. "
"Alle bewerteten Regeln oben gehen daran vorbei (das alt-Attribut ist ja "
"vorhanden, nur leer); es ist die Regel „link-name“, die Lighthouse in der "
"Kategorie Barrierefreiheit prüft — nicht in SEO. Abhilfe: dem Bild ein "
"sprechendes alt geben (z. B. alt=\"Sponsor XY\") oder dem Link ein "
"aria-label/title."))
elif soup.find_all("a", href=True):
out.append(("Verlinkte Bilder ohne Linktext", "pass", None,
"Jeder Bild-Link trägt einen Namen (Alt-Text, aria-label oder title)."))
h1s = soup.find_all("h1")
if len(h1s) == 1:
out.append(("H1-Überschrift", "pass", _ellipsis(h1s[0].get_text(strip=True), 120),
"Genau eine H1 — klare Seitenstruktur."))
elif len(h1s) == 0:
out.append(("H1-Überschrift", "warn", None,
"Keine H1 gefunden. Eine klare Haupt-Überschrift hilft Nutzern und Suchmaschinen."))
else:
out.append(("H1-Überschrift", "info", f"{len(h1s)} H1-Tags",
"Mehrere H1 sind erlaubt, eine einzelne klare H1 ist aber üblich."))
title = soup.title.get_text(strip=True) if soup.title else ""
if title:
n = len(title)
st = "pass" if 15 <= n <= 60 else "info"
out.append(("Title-Länge", st, f"{n} Zeichen",
"Gute Länge (ca. 15–60)." if st == "pass"
else "Titel kann in den Suchergebnissen abgeschnitten werden (Richtwert ca. 15–60 Zeichen)."))
desc = ""
for m in soup.find_all("meta"):
if (m.get("name") or "").strip().lower() == "description":
desc = (m.get("content") or "").strip()
break
if desc:
n = len(desc)
st = "pass" if 50 <= n <= 160 else "info"
out.append(("Description-Länge", st, f"{n} Zeichen",
"Gute Länge (ca. 50–160)." if st == "pass"
else "Description kann in den Suchergebnissen abgeschnitten werden (Richtwert ca. 50–160 Zeichen)."))
lang = (soup.html.get("lang") if soup.html else None)
out.append(("<html lang>", "pass" if lang else "warn", lang or None,
f"Sprache dieser Seite deklariert: {lang}. Wichtig für Screenreader (Aussprache) und Browser. Google selbst ermittelt die Sprache aus dem sichtbaren Text, nicht aus diesem Attribut. Für Übersetzungen derselben Seite ist hreflang zuständig, nicht lang." if lang
else "Kein lang-Attribut am <html> — hilft Suchmaschinen und Screenreadern."))
vp = soup.find("meta", attrs={"name": re.compile(r"^viewport$", re.I)})
out.append(("Viewport (Mobile)", "pass" if vp else "warn",
vp.get("content") if vp else None,
"Viewport-Meta gesetzt (mobilfreundlich)." if vp
else "Kein <meta name=viewport> — Mobile-Darstellung leidet, Mobile-First-Indexierung betroffen."))
og = soup.find("meta", property=re.compile(r"^og:", re.I))
tw = soup.find("meta", attrs={"name": re.compile(r"^twitter:", re.I)})
if og or tw:
kinds = ", ".join(k for k, v in (("Open Graph", og), ("Twitter Card", tw)) if v)
out.append(("Social-Tags", "pass", kinds,
"Social-Preview-Tags vorhanden (Details: Meta-Debug-Tool)."))
else:
out.append(("Social-Tags", "info", None,
"Keine Open-Graph-/Twitter-Card-Tags — Vorschau beim Teilen unklar (siehe Meta-Debug-Tool)."))
jsonld = soup.find_all("script", attrs={"type": "application/ld+json"})
if jsonld:
bad = 0
import json as _json
for s in jsonld:
try:
_json.loads(s.string or "")
except Exception:
bad += 1
if bad:
out.append(("Strukturierte Daten (JSON-LD)", "warn", f"{len(jsonld)} Block/-Blöcke, {bad} ungültig",
"Strukturierte Daten vorhanden, aber fehlerhaftes JSON dabei (Details: Meta-Debug-Tool)."))
else:
out.append(("Strukturierte Daten (JSON-LD)", "pass", f"{len(jsonld)} Block/-Blöcke",
"Gültige strukturierte Daten vorhanden — Basis für erweiterte Suchergebnisse (Rich Results)."))
else:
out.append(("Strukturierte Daten (JSON-LD)", "info", None,
"Keine strukturierten Daten — erweiterte Suchergebnisse (Rich Results) sind damit unwahrscheinlich."))
# sitemap.xml — SEO discovery signal (not a Lighthouse SEO audit -> advisory).
declared, sm_url, sm_ok = sitemap
if declared:
out.append(("sitemap.xml", "pass",
declared[0] if len(declared) == 1 else f"{len(declared)} Sitemaps",
"In der robots.txt deklariert — der empfohlene Weg, Suchmaschinen die Sitemap zu nennen."))
elif sm_ok:
out.append(("sitemap.xml", "pass", sm_url,
"Unter /sitemap.xml gefunden. Tipp: zusätzlich per Sitemap:-Zeile in der robots.txt deklarieren."))
else:
out.append(("sitemap.xml", "info", None,
"Keine Sitemap gefunden (weder in robots.txt deklariert noch unter /sitemap.xml). "
"Eine XML-Sitemap hilft Suchmaschinen, alle Unterseiten zu finden."))
# llms.txt — emerging convention: a curated Markdown index for LLMs/AI agents.
# Not part of the Lighthouse SEO rubric, hence advisory only.
llms_url, l_status, l_ctype, l_text = llms
l_present = (l_status == 200 and "html" not in (l_ctype or "").lower()
and l_text and l_text.strip() and not l_text.lstrip().startswith("<"))
if l_present:
out.append(("llms.txt (KI-Agenten)", "pass", f"{len(l_text.encode('utf-8'))} Bytes",
"llms.txt gefunden — gibt LLMs/KI-Agenten einen kuratierten, maschinenlesbaren Überblick der Seite."))
else:
out.append(("llms.txt (KI-Agenten)", "info", None,
"Keine llms.txt. Die (noch junge) Konvention liefert LLMs/KI-Agenten einen kuratierten "
"Markdown-Index der wichtigsten Seiten — optional, kein Ranking-Faktor."))
return out
# --------------------------------------------------------------------------- #
# Orchestration
# --------------------------------------------------------------------------- #
def _collect_hreflang_hrefs(soup, link_header, final_url):
hrefs = {final_url}
head = soup.head or soup
for link in head.find_all("link"):
rel = link.get("rel") or []
rel = " ".join(rel).lower() if isinstance(rel, list) else str(rel).lower()
if link.get("hreflang") and "alternate" in rel and link.get("href"):
hrefs.add(urljoin(final_url, link["href"].strip()))
return hrefs
def page_checks(soup, resp, robots_url, robots_status, robots_text, rp=None):
"""The ten scored Lighthouse SEO audits for ONE page. Shared by the entry
page and by every page of the Website-Check, so both are scored by exactly
the same rules (robots-txt is a site-level fact and therefore identical for
every page of a run — included so per-page scores stay comparable)."""
final_url = resp.url or ""
link_header = resp.headers.get("Link")
hreflang_hrefs = _collect_hreflang_hrefs(soup, link_header, final_url)
return [
audit_is_crawlable(soup, resp, robots_status, robots_text, final_url, rp),
audit_title(soup),
audit_meta_description(soup),
audit_http_status(resp),
audit_link_text(soup, final_url),
audit_crawlable_anchors(soup, final_url),
audit_robots_txt(robots_url, robots_status, robots_text),
audit_image_alt(soup),
audit_hreflang(soup, link_header),
audit_canonical(soup, final_url, hreflang_hrefs),
]
def score_checks(checks):
"""Lighthouse's weighted average over the APPLICABLE audits ("na" drops out
of numerator AND denominator — renormalized, exactly like LH)."""
num = sum(c["weight"] for c in checks if c["status"] == "pass")
den = sum(c["weight"] for c in checks if c["status"] in ("pass", "fail"))
return round(100 * num / den) if den else 0
def analyze(resp, robots_url, robots_status, robots_text, llms, sitemap,
site=None):
soup = BeautifulSoup(resp.text, "html.parser")
final_url = resp.url or ""
rp = robots_parser(robots_status, robots_text)
checks = page_checks(soup, resp, robots_url, robots_status, robots_text, rp)
score = score_checks(checks)
grade, color = grade_for(score)
a11y = a11y_checks(soup)
a11y_score = score_checks(a11y)
a11y_grade, a11y_color = grade_for(a11y_score)
result = {
"score": score, "grade": grade, "color": color,
"checks": checks,
"a11y": a11y, "a11y_score": a11y_score,
"a11y_grade": a11y_grade, "a11y_color": a11y_color,
"a11y_applicable": sum(1 for c in a11y if c["status"] != "na"),
"advisories": advisories(soup, resp, final_url, llms, sitemap),
"final_url": final_url, "status_code": resp.status_code,
"num_applicable": sum(1 for c in checks if c["status"] != "na"),
"site": None,
}
# Website-Check: the entered page is only ever one page. A site-wide defect
# that the entry page happens not to have (classic: meta descriptions kept
# on the home page only) stays invisible without this step.
if site is not None:
result["site"] = crawl_site(soup, resp, checks, robots_url,
robots_status, robots_text, rp,
max_pages=site)
return result
# --------------------------------------------------------------------------- #
# Website-Check — bounded crawl of the pages linked from the entry page
# --------------------------------------------------------------------------- #
def _norm_page_url(url):
"""Drop the fragment, keep scheme/host/path/query. Returns "" for anything
that is not an http(s) page URL."""
try:
p = urlparse(url)
except Exception:
return ""
if p.scheme not in ("http", "https") or not p.netloc:
return ""
if p.path.lower().endswith(_NON_PAGE_EXT):
return ""
out = f"{p.scheme}://{p.netloc}{p.path or '/'}"
return out + (f"?{p.query}" if p.query else "")
def discover_pages(soup, base_url, rp=None, limit=MAX_SITE_PAGES):
"""Same-host pages linked from `soup`, in document order (a site's primary
navigation therefore comes first). Returns (candidates, n_found).
Dropped: other hosts, non-HTML file extensions, rel=nofollow (the site's own
"do not follow this" signal), the entry page itself, and anything robots.txt
disallows — this tool does not fetch what a crawler is asked not to fetch."""
host = urlparse(base_url).netloc.lower()
entry = _norm_page_url(base_url)
seen = {entry} if entry else set()
found = []
for a in soup.find_all("a", href=True):
rel = a.get("rel") or []
rel = " ".join(rel).lower() if isinstance(rel, list) else str(rel).lower()
if "nofollow" in rel:
continue
try:
u = _norm_page_url(urljoin(base_url, a["href"]))
except Exception:
continue
if not u or urlparse(u).netloc.lower() != host or u in seen:
continue
if rp is not None:
try:
if not rp.can_fetch(USER_AGENT, u) or not rp.can_fetch("*", u):
continue
except Exception:
pass
seen.add(u)
found.append(u)
return found[:limit], len(found)
def _page_facts(soup, checks, url, status):
"""The per-page numbers the Website-Check aggregates and shows in its table."""
imgs = soup.find_all("img")
no_alt = sum(1 for i in imgs if i.get("alt") is None)
empty_alt = sum(1 for i in imgs
if i.get("alt") is not None and not i.get("alt").strip())
by_id = {c["id"]: c for c in checks}
score = score_checks(checks)
grade, color = grade_for(score)
issues = [c["name"] for c in checks if c["status"] == "fail"]
# Accessibility runs per page too — a site's template decides most of it, but
# not all: content pages carry the images, links and headings that fail.
a11y = a11y_checks(soup)
a_score = score_checks(a11y)
a_grade, a_color = grade_for(a_score)
a_issues = [c["name"] for c in a11y if c["status"] == "fail"]
return {
"url": url,
"status": status,
"score": score, "grade": grade, "color": color,
"a11y_score": a_score, "a11y_grade": a_grade, "a11y_color": a_color,
"a11y_issues": a_issues,
"a11y_fail_ids": [c["id"] for c in a11y if c["status"] == "fail"],
"issues": issues,
"title": (soup.title.get_text(strip=True) if soup.title else ""),
"no_title": by_id.get("document-title", {}).get("status") == "fail",
"no_desc": by_id.get("meta-description", {}).get("status") == "fail",
"blocked": by_id.get("is-crawlable", {}).get("status") == "fail",
"imgs": len(imgs), "imgs_no_alt": no_alt, "imgs_empty_alt": empty_alt,
"imgs_no_title": sum(1 for i in imgs if not (i.get("title") or "").strip()),
"imgs_no_text_at_all": sum(1 for i in imgs
if not (i.get("alt") or "").strip()
and not (i.get("title") or "").strip()),
"links_no_name": len(nameless_image_links(soup)),
"h1": len(soup.find_all("h1")),
}
def crawl_site(entry_soup, entry_resp, entry_checks, robots_url, robots_status,
robots_text, rp, max_pages=MAX_SITE_PAGES):
"""Fetch and audit the pages linked from the entry page, then aggregate.
Bounded three ways: `max_pages`, the SITE_BUDGET_S wall-clock budget (what
has come back when it expires is what gets reported — the coverage is stated
in the output, never silently truncated) and SITE_TIMEOUT per page."""
entry_url = entry_resp.url or ""
cands, n_found = discover_pages(entry_soup, entry_url, rp, limit=max_pages)
pages = [_page_facts(entry_soup, entry_checks, entry_url,
entry_resp.status_code)]
pages[0]["entry"] = True
def _audit_one(u):
headers = {"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8"}
if safe_get is not None:
fr = safe_get(u, timeout=SITE_TIMEOUT, max_bytes=SITE_MAX_BYTES,
headers=headers)
else:
# Fallback only (toolbox_fetch missing). Still capped by hand — this
# runs SITE_WORKERS times in parallel, so an uncapped body here would
# be the one place the crawl could pull megabytes into memory.
try:
r = requests.get(u, headers=headers, timeout=SITE_TIMEOUT,
allow_redirects=True, stream=True)
raw = r.raw.read(SITE_MAX_BYTES, decode_content=True) or b""
r.close()
fr = _PlainResp(r.url, r.status_code, r.headers, raw)
except Exception:
fr = None
if fr is None:
return None
ctype = (fr.headers.get("Content-Type") or "").lower()
if ctype and "html" not in ctype and "xml" not in ctype:
return None # not a page — nothing to audit
s = BeautifulSoup(fr.text, "html.parser")
checks = page_checks(s, fr, robots_url, robots_status, robots_text, rp)
facts = _page_facts(s, checks, u, fr.status_code)
facts["entry"] = False
return facts
budget_hit = False
if cands:
ex = concurrent.futures.ThreadPoolExecutor(max_workers=SITE_WORKERS)
try:
futs = [ex.submit(_audit_one, u) for u in cands]
deadline = time.monotonic() + SITE_BUDGET_S
try:
for f in concurrent.futures.as_completed(
futs, timeout=SITE_BUDGET_S):
try:
rec = f.result()
except Exception:
rec = None
if rec is not None:
pages.append(rec)
if time.monotonic() >= deadline:
budget_hit = True
break
except concurrent.futures.TimeoutError:
budget_hit = True
finally:
ex.shutdown(wait=False, cancel_futures=True)
n_checked = len(pages)
avg = round(sum(p["score"] for p in pages) / n_checked) if n_checked else 0
avg_grade, avg_color = grade_for(avg)
a_avg = round(sum(p["a11y_score"] for p in pages) / n_checked) if n_checked else 0
a_avg_grade, a_avg_color = grade_for(a_avg)
# Which accessibility audits fail across the site, and on how many pages —
# a template defect shows up on nearly every page, a content defect on a few.
a11y_fail_pages = {}
for p in pages:
for cid in p.get("a11y_fail_ids", []):
a11y_fail_pages[cid] = a11y_fail_pages.get(cid, 0) + 1
agg = {
"no_desc": sum(1 for p in pages if p["no_desc"]),
"no_title": sum(1 for p in pages if p["no_title"]),
"blocked": sum(1 for p in pages if p["blocked"]),
"http_err": sum(1 for p in pages if p["status"] >= 400),
"no_h1": sum(1 for p in pages if p["h1"] == 0),
"imgs": sum(p["imgs"] for p in pages),
"imgs_no_alt": sum(p["imgs_no_alt"] for p in pages),
"imgs_empty_alt": sum(p["imgs_empty_alt"] for p in pages),
"pages_imgs_no_alt": sum(1 for p in pages if p["imgs_no_alt"]),
"pages_imgs_empty_alt": sum(1 for p in pages if p["imgs_empty_alt"]),
"links_no_name": sum(p["links_no_name"] for p in pages),
"pages_links_no_name": sum(1 for p in pages if p["links_no_name"]),
"imgs_no_title": sum(p["imgs_no_title"] for p in pages),
"imgs_no_text_at_all": sum(p["imgs_no_text_at_all"] for p in pages),
}
# Worst first — that is the working list; the entry page keeps its marker.
rest = sorted((p for p in pages if not p["entry"]),
key=lambda p: (p["score"], p["url"]))
return {
"found": n_found + 1, # + the entry page itself
"checked": n_checked,
"capped": n_found > len(cands),
"budget_hit": budget_hit,
"avg": avg, "avg_grade": avg_grade, "avg_color": avg_color,
"a11y_avg": a_avg, "a11y_avg_grade": a_avg_grade,
"a11y_avg_color": a_avg_color, "a11y_fail_pages": a11y_fail_pages,
"pages": [pages[0]] + rest,
"agg": agg,
}
# --------------------------------------------------------------------------- #
# HTML rendering
# --------------------------------------------------------------------------- #
def _ellipsis(text, limit):
"""Shorten for display, and say so. A bare slice cuts mid-word and reads as
broken output rather than as an excerpt."""
text = text or ""
return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
def _pill(status):
label = _STATUS_LABEL.get(status, status)
return f'<span class="pill pill-{html.escape(status)}">{html.escape(label)}</span>'
def _weight_label(c):
if c["status"] == "na":
return "n/a"
pct = c["weight"] / c.get("denom", SEO_TOTAL_WEIGHT) * 100
return f"{pct:.0f}%" if pct >= 10 else f"{pct:.1f}%"
# Display order of the scored block: what needs work first. Lighthouse's own
# auditRefs order (is-crawlable, then the 7.667 % audits) is kept WITHIN each
# status group by sorting stably, so the block still maps onto the reference
# table — it is regrouped, not reshuffled. Each card shows its weight anyway.
_STATUS_RANK = {"fail": 0, "warn": 1, "pass": 2, "na": 3, "info": 2}
def sort_checks_for_display(checks):
"""Failing audits first, then weak, then passing, then not-applicable.
Stable: inside a status group the Lighthouse order is untouched."""
return sorted(checks, key=lambda c: _STATUS_RANK.get(c["status"], 2))
def _check_card(c):
value_html = ""
if c.get("value") not in (None, ""):
value_html = f'<div class="cv"><code>{html.escape(str(c["value"]))}</code></div>'
advice_html = ""
if c.get("advice"):
advice_html = f'<p class="ca"><strong>Fix:</strong> {html.escape(c["advice"])}</p>'
return f"""
<div class="check check-{html.escape(c['status'])}">
<div class="ch">
<span class="cn">{html.escape(c['name'])}</span>
<span class="cw" title="Anteil an der Note">{_weight_label(c)}</span>
{_pill(c['status'])}
</div>
{value_html}
<p class="cd">{html.escape(c['detail'])}</p>
{advice_html}
</div>"""
def _adv_row(name, status, value, detail):
v = f'<code>{html.escape(str(value))}</code>' if value not in (None, "") else ""
return f"""<div class="check check-{status}">
<div class="ch"><span class="cn">{html.escape(name)}</span>{_pill(status)}</div>
{('<div class="cv">'+v+'</div>') if v else ''}
<p class="cd">{html.escape(detail)}</p></div>"""
def _short_url(u, entry=False):
"""Path-only label for the page table (the host is the same for all rows)."""
p = urlparse(u)
label = (p.path or "/") + (f"?{p.query}" if p.query else "")
label = _ellipsis(label, 64)
return label + (" · diese Seite" if entry else "")
def _site_block(site):
"""The Website-Check section: coverage, site average, aggregated defects and
the per-page table (worst page first)."""
if not site:
return ""
agg, n = site["agg"], site["checked"]
cov = f"{n} von {site['found']} gefundenen Seiten geprüft"
if site["capped"]:
cov += f" · Deckel: max. {MAX_SITE_PAGES} Unterseiten pro Durchlauf"
if site["budget_hit"]:
cov += f" · Zeitbudget von {SITE_BUDGET_S:.0f} s erreicht, Rest nicht geprüft"
lines = []
if agg["http_err"]:
lines.append(f"{agg['http_err']} Seite(n) antworten mit einem HTTP-Fehlerstatus.")
if agg["blocked"]:
lines.append(f"{agg['blocked']} Seite(n) sind für Suchmaschinen blockiert (noindex bzw. robots.txt).")
if agg["no_title"]:
lines.append(f"{agg['no_title']} Seite(n) haben keinen (oder einen leeren) <title>.")
if agg["no_desc"]:
lines.append(f"<strong>{agg['no_desc']} Seite(n) haben keine Meta-Description.</strong> "
"Google baut den Ergebnis-Snippet dann selbst aus dem Seitentext.")
textless = agg["imgs_empty_alt"] + agg["imgs_no_alt"]
if textless:
how = f"{agg['imgs_empty_alt']}× leeres alt=\"\""
if agg["imgs_no_alt"]:
how += f", {agg['imgs_no_alt']}× gar kein alt-Attribut"
pages_aff = max(agg["pages_imgs_empty_alt"], agg["pages_imgs_no_alt"])
lines.append(f"<strong>{textless} von {agg['imgs']} Bildern tragen keinen Bildtext</strong> "
f"({how}) auf {pages_aff} Seite(n). Ein leeres alt=\"\" ist zulässig und "
"markiert ein Bild als dekorativ — bei inhaltlichen Bildern geht damit aber "
"jeder Text für Suche und Screenreader verloren.")
if agg.get("links_no_name"):
lines.append(f"<strong>Davon sind {agg['links_no_name']} Bild(er) der einzige Inhalt eines "
f"Links</strong> (auf {agg['pages_links_no_name']} Seiten) — diese Links haben "
"damit gar keinen Namen: kein Ankertext für Suchmaschinen, nichts für "
"Screenreader. Typisch für Sponsoren- und Partner-Logos.")
if agg["no_h1"]:
lines.append(f"{agg['no_h1']} Seite(n) haben keine H1-Überschrift.")
# Which accessibility rules fail, and on how many pages — a rule failing on
# nearly every page is a template defect, one failing on a few is content.
fails = site.get("a11y_fail_pages") or {}
if fails:
names = {c: c for c in fails}
parts = ", ".join(f"<code>{html.escape(cid)}</code> ({n} Seiten)"
for cid, n in sorted(fails.items(), key=lambda t: -t[1])[:6])
lines.append("Barrierefreiheit — durchgefallene Regeln über die Website: " + parts
+ ". Eine Regel, die auf fast allen Seiten fällt, sitzt im Template; "
"eine auf wenigen Seiten sitzt im Inhalt.")
if not lines:
lines.append("Keine seitenübergreifenden Mängel gefunden.")
findings = "".join(f"<li>{ln}</li>" for ln in lines)
# Kept out of the findings list on purpose: not a defect, so it must not sit
# among them. Shown because the number was asked for.
info_line = ""
if agg.get("imgs_no_title"):
info_line = (f'<p class="cd" style="margin-top:.9rem;border-top:1px solid var(--bd);'
f'padding-top:.7rem"><strong>Zur Einordnung (kein Mangel):</strong> '
f'{agg["imgs_no_title"]} von {agg["imgs"]} Bildern haben kein '
f'title-Attribut. Das Attribut ist kein Ranking-Signal (siehe Hinweis '
f'„Bild-title-Attribute (Info)“ oben) und geht in keine Bewertung ein.</p>')
rows = []
for p in site["pages"]:
parts = []
if p["issues"]:
parts.append(", ".join(p["issues"]))
if p["imgs_no_alt"]:
parts.append(f"{p['imgs_no_alt']} Bild(er) ohne alt")
if p["imgs_empty_alt"]:
parts.append(f"{p['imgs_empty_alt']}× leeres alt")
if p.get("links_no_name"):
parts.append(f"{p['links_no_name']} Bildlink(s) ohne Namen")
if p["h1"] == 0:
parts.append("keine H1")
issues = " · ".join(parts) if parts else "—"
tr = '<tr class="is-entry">' if p["entry"] else "<tr>"
label = html.escape(_short_url(p["url"], p["entry"]))
rows.append(
tr
+ f'<td><code>{label}</code></td>'
+ f'<td class="pg" style="color:{p["color"]}">{html.escape(p["grade"])}'
+ f'<span>{p["score"]}</span></td>'
+ f'<td class="pg" style="color:{p.get("a11y_color", "#64748b")}">'
+ f'{html.escape(p.get("a11y_grade", "–"))}'
+ f'<span>{p.get("a11y_score", "")}</span></td>'
+ f'<td class="pi">{html.escape(issues)}</td></tr>')
return f"""
<h2 id="website-check">Website-Check — alle verlinkten Seiten <span style="font-size:.9rem;color:var(--tx2);font-weight:400">(eigene Durchschnittsnoten; die beiden Noten oben gelten nur für die eingegebene Seite)</span></h2>
<div class="scorerow">
<div class="sitesum">
<div class="grade" style="color:{site['avg_color']}">{html.escape(site['avg_grade'])}</div>
<div class="scoremeta">
<div class="scoreval">Ø {site['avg']}<span>/100</span></div>
<div class="scorelbl">SEO — <strong>Durchschnitt über alle geprüften Seiten</strong><br>einschließlich der eingegebenen Seite</div>
</div>
</div>
<div class="sitesum">
<div class="grade" style="color:{site.get('a11y_avg_color', '#64748b')}">{html.escape(site.get('a11y_avg_grade', '–'))}</div>
<div class="scoremeta">
<div class="scoreval">Ø {site.get('a11y_avg', 0)}<span>/100</span></div>
<div class="scorelbl">Barrierefreiheit — <strong>Durchschnitt über alle geprüften Seiten</strong><br>Teilprüfung, einschließlich der eingegebenen Seite</div>
</div>
</div>
</div>
<p class="scoreurl" style="margin:0 0 1rem">{html.escape(cov)}</p>
<p class="note"><strong>Reichweite dieser Prüfung:</strong> eine Linkebene — die eingegebene Seite und
alle Seiten desselben Hosts, die <em>von ihr aus</em> verlinkt sind (egal wie tief deren Adresse liegt).
Seiten, die erst über eine Unterseite erreichbar sind, sind <strong>nicht</strong> dabei; die XML-Sitemap
wird dafür nicht ausgewertet. Schranken pro Durchlauf: max. {MAX_SITE_PAGES} Unterseiten,
{SITE_BUDGET_S:.0f} s Zeitbudget, {SITE_TIMEOUT} s je Seite. <code>robots.txt</code> wird beachtet,
<code>rel="nofollow"</code>-Links werden nicht verfolgt.</p>
<div class="card">
<h2 style="margin-top:0;font-size:1.05rem">Seitenübergreifende Befunde</h2>
<ul class="agg">{findings}</ul>
{info_line}
</div>
<div class="card tablecard">
<table class="pages">
<thead><tr><th>Seite</th><th>SEO</th><th>Barr.-frei</th><th>Befunde</th></tr></thead>
<tbody>{''.join(rows)}</tbody>
</table>
</div>
"""
def _sitewide_scorerow(site):
"""The two site averages, repeated directly under the single-page grades.
Four numbers that belong together should be readable in one glance instead of
forcing a scroll to the bottom of the report for half of them."""
if not site:
return ""
return f"""
<div class="scorerow sitewide">
<div class="scorecard" style="border-color:{site['avg_color']}">
<div class="grade" style="color:{site['avg_color']}">{html.escape(site['avg_grade'])}</div>
<div class="scoremeta">
<div class="scoreval">Ø {site['avg']}<span>/100</span></div>
<div class="scorelbl">SEO — <strong>ganze Website</strong><br>Durchschnitt über {site['checked']} Seiten</div>
<a class="jump" href="#website-check">↓ zur Seitenliste</a>
</div>
</div>
<div class="scorecard" style="border-color:{site.get('a11y_avg_color', '#64748b')}">
<div class="grade" style="color:{site.get('a11y_avg_color', '#64748b')}">{html.escape(site.get('a11y_avg_grade', '–'))}</div>
<div class="scoremeta">
<div class="scoreval">Ø {site.get('a11y_avg', 0)}<span>/100</span></div>
<div class="scorelbl">Barrierefreiheit — <strong>ganze Website</strong><br>Teilprüfung, Durchschnitt über {site['checked']} Seiten</div>
<a class="jump" href="#website-check">↓ zur Seitenliste</a>
</div>
</div>
</div>"""
def _a11y_block(result):
"""The accessibility section: score, what it covers, and — just as prominent —
what it cannot cover, so the number is never mistaken for a full Lighthouse
accessibility score."""
if not result.get("a11y"):
return ""
impl_w, tot_w, impl_n, tot_n = a11y_coverage()
missing = "".join(
f"<li><code>{html.escape(aid)}</code> (Gewicht {w}) — {html.escape(why)}</li>"
for aid, w, why in A11Y_OUT_OF_SCOPE)
cards = "".join(_check_card(c) for c in sort_checks_for_display(result["a11y"]))
return f"""
<h2>Barrierefreiheit — bewertete Kriterien <span style="font-size:.9rem;color:var(--tx2);font-weight:400">(ergeben die Barrierefreiheits-Note dieser Seite)</span></h2>
<p class="note"><strong>Was diese Note ist und was nicht: eine Teilprüfung.</strong> Lighthouse bewertet Barrierefreiheit
in einer <em>eigenen Kategorie</em> mit {tot_n} gewichteten Regeln ({tot_w} Punkte) — hier nachgebaut
sind die <strong>{impl_n} Regeln ({impl_w} Punkte, {round(100 * impl_w / tot_w)} %)</strong>, die sich
am statischen HTML entscheiden lassen, mit den Original-Gewichten und derselben Rechenweise wie die
SEO-Note. Die übrigen brauchen eine im Browser gerenderte Seite. <strong>Diese Note ist deshalb
systematisch zu gut</strong> — es fehlen gerade die schwierigen Regeln. Sie ist ein Anhaltspunkt,
kein Ersatz für einen echten Lighthouse- oder axe-Lauf und erst recht keine BITV-/WCAG-Konformitäts-
aussage. Nicht enthalten sind unter anderem:</p>
<div class="card"><ul class="agg">{missing}</ul>
<p class="cd" style="margin-top:.9rem;border-top:1px solid var(--bd);padding-top:.7rem">
<strong>Womit Sie genau diese Regeln prüfen:</strong> in Chrome/Edge über
<em>DevTools → Lighthouse → Barrierefreiheit</em> (dieselben Regeln, aber an der gerenderten Seite),
oder mit der Browser-Erweiterung <em>axe DevTools</em> bzw. <em>WAVE</em>. Für den Farbkontrast
einzelner Elemente reicht die Farbpipette in den DevTools — sie zeigt das Kontrastverhältnis und die
WCAG-Schwelle direkt an. Diese Werkzeuge sehen die Seite so, wie der Browser sie darstellt; dieses
Werkzeug sieht nur den ausgelieferten HTML-Quelltext.</p></div>
{cards}
"""
def build_html_page(url="", result=None, error_message=None, site_on=True):
escaped_url = html.escape(url or "", quote=True)
site_checked = "checked" if site_on else ""
score_block = ""
checks_block = ""
a11y_block = ""
adv_block = ""
site_block = ""
if result:
checks_block = "".join(_check_card(c)
for c in sort_checks_for_display(result["checks"]))
adv_block = "".join(
f'<h3 class="grp">{html.escape(title)}</h3>'
+ "".join(_adv_row(*a) for a in items)
for title, items in group_advisories(result["advisories"]))
site_block = _site_block(result.get("site"))
a11y_block = _a11y_block(result)
score_block = f"""
<div class="scorerow">
<div class="scorecard" style="border-color:{result['color']}">
<div class="grade" style="color:{result['color']}">{html.escape(result['grade'])}</div>
<div class="scoremeta">
<div class="scoreval">{result['score']}<span>/100</span></div>
<div class="scorelbl">SEO — <strong>nur die eingegebene Seite</strong><br>{result['num_applicable']} bewertete Kriterien</div>
</div>
</div>
<div class="scorecard" style="border-color:{result['a11y_color']}">
<div class="grade" style="color:{result['a11y_color']}">{html.escape(result['a11y_grade'])}</div>
<div class="scoremeta">
<div class="scoreval">{result['a11y_score']}<span>/100</span></div>
<div class="scorelbl">Barrierefreiheit — <strong>nur die eingegebene Seite</strong><br>Teilprüfung, {result['a11y_applicable']} bewertete Kriterien</div>
</div>
</div>
</div>
{_sitewide_scorerow(result.get("site"))}
<p class="scoreurl" style="margin:-.5rem 0 1.25rem"><code>{html.escape(result['final_url'])}</code> · HTTP {result['status_code']}</p>"""
error_block = ""
if error_message:
error_block = f'<div class="card error-card"><h2>Fehler</h2><p>{html.escape(error_message)}</p></div>'
page = "Content-Type: text/html; charset=utf-8\n\n"
page += f"""<!DOCTYPE html>
<!-- jozapf.de toolbox · rev jzt-7c3f9a2e -->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jo Zapf Toolbox – SEO Check</title>
<link rel="preconnect" href="https://assets.jozapf.de" crossorigin>
<link rel="stylesheet" href="https://assets.jozapf.de/css/fonts.css">
<style>
:root{{--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;--tx:#f1f5f9;--tx2:#94a3b8;
--accent:#3b82f6;--accent2:#8b5cf6;--bd:#334155;--code:#0f172a;
--ok:#10b981;--warn:#eab308;--err:#ef4444;--na:#64748b;--info:#3b82f6;}}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:var(--bg);color:var(--tx);line-height:1.6;min-height:100vh;padding:2rem}}
.container{{max-width:1000px;margin:0 auto}}
h1{{font-size:2rem;font-weight:700;margin-bottom:.4rem;
background:linear-gradient(135deg,var(--accent),var(--accent2));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}}
.subtitle{{color:var(--tx2);margin-bottom:1.5rem}}
h2{{font-size:1.2rem;margin:1.75rem 0 1rem}}
.card{{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;padding:1.5rem;margin-bottom:1.25rem}}
.error-card{{border-left:4px solid var(--err)}} .error-card h2{{color:var(--err)}}
form{{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center}}
input[type=text]{{flex:1;min-width:280px;padding:.75rem 1rem;border-radius:8px;border:1px solid var(--bd);
background:var(--bg);color:var(--tx);font-size:1rem;font-family:inherit}}
input[type=text]:focus{{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,.2)}}
button{{padding:.75rem 1.5rem;border-radius:8px;border:none;cursor:pointer;background:var(--accent);
color:#fff;font-size:1rem;font-weight:600;font-family:inherit}}
button:hover{{background:#2563eb}}
.form-hint{{width:100%;font-size:.85rem;color:var(--tx2);margin-top:.5rem}}
.scorecard{{display:flex;gap:1.5rem;align-items:center;background:var(--bg2);border:2px solid var(--bd);
border-radius:12px;padding:1.5rem 1.75rem;margin-bottom:1.25rem}}
.grade{{font-size:3.5rem;font-weight:800;line-height:1;min-width:2ch;text-align:center}}
.scoreval{{font-size:1.6rem;font-weight:700}} .scoreval span{{font-size:1rem;color:var(--tx2);font-weight:400}}
.scorelbl{{color:var(--tx2);font-size:.9rem}} .scoreurl{{color:var(--tx2);font-size:.8rem;margin-top:.25rem;word-break:break-all}}
.check{{background:var(--bg);border:1px solid var(--bd);border-left-width:4px;border-radius:8px;
padding:.85rem 1rem;margin-bottom:.6rem}}
.check-pass{{border-left-color:var(--ok)}} .check-fail{{border-left-color:var(--err)}}
.check-warn{{border-left-color:var(--warn)}} .check-info{{border-left-color:var(--info)}}
.check-na{{border-left-color:var(--na)}}
.ch{{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}}
.cn{{font-weight:600;flex:1;min-width:180px}}
.cw{{font-size:.75rem;color:var(--tx2);font-variant-numeric:tabular-nums}}
.pill{{padding:.15em .6em;border-radius:999px;font-size:.75rem;font-weight:600}}
.pill-pass{{background:rgba(16,185,129,.15);color:var(--ok)}}
.pill-fail{{background:rgba(239,68,68,.15);color:var(--err)}}
.pill-warn{{background:rgba(234,179,8,.15);color:var(--warn)}}
.pill-info{{background:rgba(59,130,246,.15);color:var(--info)}}
.pill-na{{background:rgba(100,116,139,.15);color:var(--na)}}
.cv{{margin:.4rem 0}} .cv code{{font-family:'JetBrains Mono',Consolas,monospace;font-size:.8rem;
background:var(--code);color:#e879f9;padding:.15em .45em;border-radius:4px;word-break:break-all}}
.cd{{color:var(--tx2);font-size:.9rem;margin-top:.25rem}}
.ca{{color:var(--tx);font-size:.85rem;margin-top:.4rem}} .ca strong{{color:var(--accent)}}
h3.grp{{font-size:.8rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:var(--tx2);margin:1.4rem 0 .55rem;padding-bottom:.3rem;border-bottom:1px solid var(--bd)}}
h3.grp:first-of-type{{margin-top:.4rem}}
.scorerow{{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:.75rem}}
.scorerow .scorecard,.scorerow .sitesum{{flex:1 1 320px;margin-bottom:0}}
.scorerow.sitewide .grade{{font-size:2.6rem}}
.scorerow.sitewide .scorecard{{padding:1rem 1.25rem}}
.jump{{display:inline-block;font-size:.8rem;color:var(--accent);text-decoration:none;
font-weight:500;margin-top:.15rem}}
.jump:hover{{text-decoration:underline}}
.runtime{{font-size:.85rem;color:var(--tx2);background:var(--bg2);border-left:3px solid var(--accent);
border-radius:0 8px 8px 0;padding:.75rem 1rem;margin-bottom:1.5rem}}
.runtime strong{{color:var(--tx)}}
.sitesum{{display:flex;gap:1.5rem;align-items:center;background:var(--bg2);
border:2px solid var(--bd);border-radius:12px;padding:1.25rem 1.5rem;margin-bottom:1rem}}
.agg{{list-style:none;margin:0}} .agg li{{position:relative;padding-left:1.1rem;margin:.35rem 0;
font-size:.9rem;color:var(--tx2)}}
.agg li::before{{content:"•";position:absolute;left:0;color:var(--accent)}}
.agg strong{{color:var(--tx)}}
.tablecard{{padding:.5rem .75rem;overflow-x:auto}}
table.pages{{width:100%;border-collapse:collapse;font-size:.85rem}}
table.pages th{{text-align:left;padding:.5rem .5rem;color:var(--tx2);font-weight:600;
border-bottom:1px solid var(--bd);white-space:nowrap}}
table.pages td{{padding:.45rem .5rem;border-bottom:1px solid var(--bd);vertical-align:top}}
table.pages tr:last-child td{{border-bottom:none}}
table.pages code{{font-family:'JetBrains Mono',Consolas,monospace;font-size:.78rem;
color:#e879f9;word-break:break-all}}
tr.is-entry td{{background:rgba(59,130,246,.07)}}
td.pg{{font-weight:700;white-space:nowrap;text-align:right}}
td.pg span{{display:block;font-size:.72rem;color:var(--tx2);font-weight:400}}
td.pi{{color:var(--tx2)}}
.checkline{{width:100%;display:flex;align-items:center;gap:.5rem;font-size:.9rem;color:var(--tx2)}}
.checkline input{{width:1.05rem;height:1.05rem;accent-color:var(--accent)}}
.note{{font-size:.82rem;color:var(--tx2);background:var(--bg2);border:1px dashed var(--bd);
border-radius:8px;padding:.9rem 1.1rem;margin-bottom:1.25rem}}
.note strong{{color:var(--tx)}}
footer{{text-align:center;margin-top:1.75rem;font-size:.85rem;color:var(--tx2)}}
footer a{{color:var(--accent);text-decoration:none;font-weight:500}}
@media(max-width:640px){{body{{padding:1rem}}h1{{font-size:1.5rem}}.scorecard{{flex-direction:column;text-align:center}}}}
</style>
</head>
<body>
<div class="container">
<h1>toolbox.jozapf.de | SEO Check v1.2</h1>
<p class="subtitle">Eine leichtgewichtige Prüfung der technischen SEO-Grundlagen nach etablierten Standards — ein erster Überblick, keine vollständige Analyse. Benotet A+ bis F.</p>
<p class="runtime"><strong>Bitte etwas Geduld:</strong> Die Prüfung läuft live gegen die eingegebene
Seite. Eine einzelne Seite dauert meist wenige Sekunden. Mit aktiviertem Website-Check werden zusätzlich
alle verlinkten Unterseiten abgerufen — bei dynamisch erzeugten Seiten (WordPress, Shops, Redaktions-
systeme) kann das je nach Anzahl und Servergeschwindigkeit <strong>{SITE_BUDGET_S:.0f} Sekunden und
mehr</strong> dauern. Das Fenster bleibt so lange leer — nicht neu laden, es läuft.</p>
<div class="card">
<h2 style="margin-top:0">URL analysieren</h2>
<form method="get" action="">
<input type="text" name="url" value="{escaped_url}" placeholder="https://example.com">
<button type="submit">Prüfen</button>
<input type="hidden" name="site" value="0">
<label class="checkline"><input type="checkbox" name="site" value="1" {site_checked}>
Website-Check: die von dieser Seite verlinkten Seiten mitprüfen (eine Linkebene, bis zu {MAX_SITE_PAGES}) — dauert länger</label>
</form>
<p class="form-hint">Ruft die Seite und ihre <code>/robots.txt</code> auf und prüft die wichtigsten SEO-Kriterien —
Titel, Beschreibung, Verlinkung, Bilder, Canonical und ob Suchmaschinen die Seite überhaupt
aufnehmen dürfen. Mit dem Häkchen werden zusätzlich alle Seiten geprüft, die <em>von der eingegebenen
Seite aus verlinkt</em> sind (gleicher Host, robots.txt wird beachtet) — eine Linkebene tief, egal wie
tief deren Adresse liegt; Seiten, die erst über eine Unterseite erreichbar sind, sind nicht dabei.
Die Note oben gilt weiterhin nur für die eingegebene Seite.
<strong>Es wird nichts gespeichert.</strong></p>
</div>
{error_block}
{score_block}
"""
if result:
page += f"""
<h2>SEO — bewertete Kriterien <span style="font-size:.9rem;color:var(--tx2);font-weight:400">(ergeben die SEO-Note dieser Seite)</span></h2>
{checks_block}
{a11y_block}
<h2>Zusätzliche Hinweise zu dieser Seite <span style="font-size:.9rem;color:var(--tx2);font-weight:400">(fließen in keine der Noten ein)</span></h2>
{adv_block}
{site_block}
"""
page += """
<footer>Bewertungsmethode angelehnt an <a href="https://github.com/GoogleChrome/lighthouse" target="_blank" rel="noopener">Google Lighthouse</a> ·
SEO verbessern? <a href="https://jozapf.de" target="_blank" rel="noopener">jozapf.de</a></footer>
</div>
</body>
</html>
"""
return page
def main():
params = parse_qs(os.environ.get("QUERY_STRING", ""))
url = params.get("url", [""])[0].strip()
# Website-Check: on by default, so a bare `?url=…` (link, bookmark, the
# tile's example) gets it too. The form's hidden marker always sends site=0
# and a ticked checkbox appends site=1, so the LAST value is the truth in
# every case — including the unticked box, which sends nothing of its own.
raw_site = params.get("site", [""])[-1].strip().lower()
site_on = raw_site not in ("0", "false", "off", "no")
if not url:
sys.stdout.write(build_html_page(url="", site_on=site_on))
return
# Guard runs only on the executing path (this does outbound HTTP). On a
# blocked request the guard has already written the full response.
if not _guard("seo-check"):
return
if not re.match(r"^https?://", url, re.I):
url = "https://" + url
try:
resp = fetch_url(url, timeout=12)
except Exception as e:
sys.stdout.write(build_html_page(url=url, site_on=site_on,
error_message=f"Fehler beim Abruf: {e}"))
return
if resp is None:
sys.stdout.write(build_html_page(
url=url, site_on=site_on,
error_message="Die Adresse konnte nicht abgerufen werden. Möglich sind: "
"die Seite ist nicht erreichbar (Timeout/DNS), oder das Ziel "
"bzw. eine Weiterleitung verweist auf eine nicht-öffentliche/"
"interne Adresse und wurde aus Sicherheitsgründen blockiert."))
return
try:
robots_url, robots_status, robots_text = fetch_robots(resp.url or url)
llms = fetch_llms(resp.url or url)
sitemap = fetch_sitemap(resp.url or url, robots_text)
result = analyze(resp, robots_url, robots_status, robots_text, llms,
sitemap, site=MAX_SITE_PAGES if site_on else None)
sys.stdout.write(build_html_page(url=url, result=result, site_on=site_on))
# Flush before returning: when the crawl hit its budget, worker threads
# whose fetch is already in flight still drain at interpreter exit (up to
# SITE_TIMEOUT). The finished report must not wait behind that.
sys.stdout.flush()
except Exception:
sys.stdout.write("Content-Type: text/plain; charset=utf-8\n\n")
sys.stdout.write("Fehler bei der Analyse:\n")
traceback.print_exc(file=sys.stdout)
if __name__ == "__main__":
main()