Proč skriptované audity v Pythonu
Skriptované audity jsou systematická, opakovatelná měření webu, která vytváříte jako kód. V oblasti programmatic SEO a technických kontrol přinášejí deterministickou reprodukovatelnost, měřitelnost (výstupy do CSV/SQLite), jednoduchou automatizaci (CRON/CI) a možnost kombinovat HTTP, HTML, XML, JavaScript analýzu v jednom nástroji. Python poskytuje bohatý ekosystém (aiohttp/httpx, lxml/BeautifulSoup, urllib.robotparser, pandas, pydantic) a výbornou podporu pro paralelní I/O.
Architektura auditu: od discovery po reporting
- Discovery: získání seznamu URL (sitemap, logy, export z databáze, crawl).
- Fetching: rychlé a ohleduplné stahování (HTTP/2, timeouty, retry, rate limit, robots).
- Parsing: HTML/JSON-LD/XML hlavičky, meta tagy, link rel, strukturovaná data.
- Kontroly: pravidla (např. délka title, canonical, hreflang, meta robots, stavové kódy, redirect řetězce).
- Uložení: CSV/Parquet/SQLite pro audit trail a srovnání v čase.
- Report: agregace, KPI, grafy, dify vs. předchozí běh.
Standardní struktura repozitáře
python-seo-audits/ ├─ audits/ │ ├─ __init__.py │ ├─ sitemap_discovery.py │ ├─ fetcher.py │ ├─ html_checks.py │ ├─ indexation_checks.py │ ├─ links_checks.py │ ├─ performance_probe.py │ ├─ schema.py # pydantic datové modely │ └─ reporters/ │ ├─ csv_reporter.py │ ├─ sqlite_reporter.py │ └─ md_summary.py ├─ bin/ │ ├─ run_audit.py │ └─ run_diff.py ├─ tests/ │ ├─ test_html_checks.py │ └─ fixtures/ ├─ data/ # do .gitignore (cache, dočasné výstupy) ├─ pyproject.toml # poetry/pip-tools ├─ Makefile # make lint, test, audit ├─ .pre-commit-config.yaml # ruff, black, mypy, yaml-lint └─ README.md
Zásady bezpečného fetchování a etiky
- Respektujte robots.txt a
Crawl-delay(pokud robot akceptuje), nastavte vlastníUser-Agents kontaktním emailem. - Dodržujte rate limiting (např. 2–5 req/s/host), timeouty a retry s exponenciálním backoffem.
- Nestahujte citlivé sekce, nespouštějte testy během špiček v provozu, výsledky anonymizujte, pokud obsahují osobní údaje.
Modul: načítání URL ze sitemap
# audits/sitemap_discovery.py
from __future__ import annotations
import asyncio
import aiohttp
from urllib.parse import urljoin
from lxml import etree
XMLNS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
async def fetch(session: aiohttp.ClientSession, url: str) -> bytes:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as r:
r.raise_for_status()
return await r.read()
def parse_sitemap(content: bytes) -> list[str]:
root = etree.fromstring(content)
# sitemapindex nebo urlset
if root.tag.endswith("sitemapindex"):
return [loc.text for loc in root.findall(".//sm:sitemap/sm:loc", namespaces=XMLNS)]
return [loc.text for loc in root.findall(".//sm:url/sm:loc", namespaces=XMLNS)]
async def discover_from_index(index_url: str) -> list[str]:
async with aiohttp.ClientSession(headers={"User-Agent": "SEO-Auditor/1.0 "}) as s:
content = await fetch(s, index_url)
nodes = parse_sitemap(content)
urls: list[str] = []
# drill-down do child sitemap
if nodes and nodes[0].endswith(".xml"):
for node in nodes:
c = await fetch(s, node)
urls.extend(parse_sitemap(c))
return urls
return nodes
Modul: respektování robots.txt
# audits/robots_guard.py
import asyncio
import aiohttp
import urllib.robotparser as rp
from urllib.parse import urlparse
class RobotsGuard:
def __init__(self, ua: str):
self.ua = ua
self.cache: dict[str, rp.RobotFileParser] = {}
async def allowed(self, session: aiohttp.ClientSession, url: str) -> bool:
host = urlparse(url).netloc
if host not in self.cache:
robots_url = f"https://{host}/robots.txt"
try:
async with session.get(robots_url, timeout=10) as r:
text = await r.text(errors="ignore")
except Exception:
text = ""
parser = rp.RobotFileParser()
parser.parse(text.splitlines())
self.cache[host] = parser
return self.cache[host].can_fetch(self.ua, url)
Asynchronní fetcher s limity a retry
# audits/fetcher.py
import asyncio, random
import aiohttp
from contextlib import asynccontextmanager
@asynccontextmanager
async def session_ctx(ua: str):
timeout = aiohttp.ClientTimeout(total=25, connect=10)
async with aiohttp.ClientSession(headers={"User-Agent": ua}, timeout=timeout) as s:
yield s
async def get_with_retry(session: aiohttp.ClientSession, url: str, retries: int = 2) -> aiohttp.ClientResponse:
delay = 0.5
for attempt in range(retries + 1):
try:
resp = await session.get(url, allow_redirects=True)
if resp.status in (429, 500, 502, 503, 504):
raise aiohttp.ClientResponseError(resp.request_info, resp.history, status=resp.status)
return resp
except Exception:
if attempt == retries:
raise
await asyncio.sleep(delay + random.random() * 0.5)
delay *= 2
HTML kontroly: title, meta, canonical, hreflang
# audits/html_checks.py
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
def norm(s: str | None) -> str:
return (s or "").strip()
def check_html(url: str, html: str) -> dict:
soup = BeautifulSoup(html, "lxml")
title = norm((soup.title.string if soup.title else None))
meta_desc = norm(next((m.get("content") for m in soup.select("meta[name='description']")), ""))
robots = norm(next((m.get("content") for m in soup.select("meta[name='robots']")), ""))
canon = norm(next((l.get("href") for l in soup.select("link[rel='canonical']")), ""))
hreflang = [(l.get("hreflang", "").lower(), l.get("href", "")) for l in soup.select("link[rel='alternate'][hreflang]")]
issues = []
if not title:
issues.append("missing_title")
if len(title) > 70:
issues.append("long_title")
if len(meta_desc) == 0:
issues.append("missing_description")
if canon:
# absolutní canonical a stejný host
if not urlparse(canon).netloc:
issues.append("canonical_relative")
else:
issues.append("missing_canonical")
# hreflang páry
if hreflang and ("x-default", None) is None:
pass
return {
"url": url,
"title": title,
"meta_description_len": len(meta_desc),
"robots_meta": robots,
"canonical": canon,
"hreflang_count": len(hreflang),
"issues": ";".join(issues)
}
Indexační signály: stavové kódy, řetězce přesměrování, noindex
# audits/indexation_checks.py
def summarize_response(url: str, history, status: int, headers: dict, html_snippet: str) -> dict:
chain = " -> ".join([f"{h.status}" for h in history] + [str(status)])
x_robots = headers.get("x-robots-tag", "")
has_noindex = "noindex" in x_robots.lower()
return {
"url": url,
"status": status,
"redirects": len(history),
"chain": chain,
"content_type": headers.get("content-type", ""),
"x_robots_tag": x_robots,
"noindex": has_noindex
}
Výkonnostní „probe“ bez renderování
Pro rychlé porovnání latencí a přenosů využijte pouze síťové metriky. Plné renderování (např. pomocí Playwright) přidejte jen pro vzorek nebo pro klíčové šablony.
# audits/performance_probe.py
import time
async def timed_fetch(session, url: str) -> dict:
t0 = time.perf_counter()
async with session.get(url) as r:
await r.read()
t1 = time.perf_counter()
return {
"url": url,
"status": r.status,
"ttfb_ms": r.headers.get("server-timing", ""), # pokud je dostupné
"elapsed_ms": round((t1 - t0) * 1000, 1),
"bytes": int(r.headers.get("content-length", "0") or 0)
}
Spojování modulů: hlavní runner
# bin/run_audit.py
import asyncio, csv
from audits.sitemap_discovery import discover_from_index
from audits.fetcher import session_ctx, get_with_retry
from audits.robots_guard import RobotsGuard
from audits.html_checks import check_html
from audits.indexation_checks import summarize_response
async def audit(sitemap_url: str, out_csv: str):
urls = await discover_from_index(sitemap_url)
guard = RobotsGuard("SEO-Auditor/1.0 ")
rows = []
async with session_ctx("SEO-Auditor/1.0 ") as s:
for url in urls:
if not await guard.allowed(s, url):
rows.append({"url": url, "issue": "blocked_by_robots"})
continue
resp = await get_with_retry(s, url)
html = (await resp.text(errors="ignore")) if "text/html" in resp.headers.get("content-type", "") else ""
index = summarize_response(str(resp.url), resp.history, resp.status, resp.headers, html[:2000])
html_metrics = check_html(str(resp.url), html) if html else {}
rows.append({**index, **html_metrics})
await asyncio.sleep(0.2) # rate limit
with open(out_csv, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=sorted({k for r in rows for k in r.keys()}))
writer.writeheader()
writer.writerows(rows)
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--sitemap", required=True)
ap.add_argument("--out", default="audit.csv")
args = ap.parse_args()
asyncio.run(audit(args.sitemap, args.out))
Rozšíření: validace hreflang párů a návratových odkazů
Pro hreflang je klíčové, aby každá URL měla return link (reciproční odkaz) a existovala i x-default varianta. Rozšiřte model o mapování párů a kontrolu jejich existence.
# audits/links_checks.py
from collections import defaultdict
def validate_hreflang(records: list[dict]) -> list[dict]:
# records musí obsahovat pole: url, hreflang_count a extrahované páry (rozšiřte check_html)
# Zde jen ilustrace reciprocity na úrovni hostu/cesty.
mapping = defaultdict(set)
for r in records:
# předpoklad: r["hreflang_pairs"] = [(lang, href), ...]
for lang, href in r.get("hreflang_pairs", []):
mapping[r["url"]].add((lang, href))
issues = []
for url, pairs in mapping.items():
for lang, href in pairs:
rev = mapping.get(href, set())
if (lang, url) not in rev:
issues.append({"url": url, "issue": "hreflang_missing_return", "lang": lang, "target": href})
return issues
Ukládání do SQLite a porovnávání běhů
# audits/reporters/sqlite_reporter.py
import sqlite3
DDL = """
CREATE TABLE IF NOT EXISTS audit (
run_id TEXT,
url TEXT,
status INT,
redirects INT,
content_type TEXT,
title TEXT,
meta_description_len INT,
canonical TEXT,
robots_meta TEXT,
noindex INT,
issues TEXT,
PRIMARY KEY(run_id, url)
);
"""
def save_rows(db_path: str, run_id: str, rows: list[dict]):
con = sqlite3.connect(db_path)
con.execute(DDL)
cols = ["run_id","url","status","redirects","content_type","title","meta_description_len",
"canonical","robots_meta","noindex","issues"]
with con:
for r in rows:
con.execute(f"INSERT OR REPLACE INTO audit ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
[run_id, *[r.get(c.split('run_id,')[-1], None) for c in cols[1:]]])
def diff(db_path: str, run_a: str, run_b: str) -> list[tuple]:
con = sqlite3.connect(db_path)
q = """
SELECT a.url, a.status AS old_status, b.status AS new_status, a.canonical AS old_canon, b.canonical AS new_canon
FROM audit a JOIN audit b USING(url)
WHERE a.run_id=? AND b.run_id=? AND (a.status!=b.status OR a.canonical!=b.canonical)
"""
return list(con.execute(q, (run_a, run_b)))
Minimalistický Playwright „render check“ (vzorek)
Pro detekci problémů závislých na JS (hydration, prerender) použijte Playwright jen pro malý vzorek URL.
# audits/js_render_probe.py
import asyncio
from playwright.async_api import async_playwright
async def render_probe(urls: list[str]) -> list[dict]:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
out = []
for u in urls[:50]:
try:
resp = await page.goto(u, wait_until="domcontentloaded", timeout=15000)
html = await page.content()
out.append({"url": u, "render_status": resp.status if resp else None, "html_len": len(html), "has_title": "" in html.lower()})
except Exception as e:
out.append({"url": u, "render_error": str(e)})
await browser.close()
return out
Programmatic SEO: kontrola šablon a generátorů
- Šablonové konzistence: fixní délka a struktura <title>, povinná pole v JSON-LD (Product, Article, FAQ), unikátnost H1.
- Kanonicita: správný
rel=canonicalbez samokanibalizace (žádné odkazy na parametry, staging, http). - Indexační hygiena: vyhněte se
noindexna kategoriích, které mají být přístupné; odstraňte řetězce přesměrování. - Interní prolinkování: při programatickém generování stránek kontrolujte, zda každá entita má alespoň




























