DataForSEO + Python: automatizovaný SEO audit pro Vercel projekty
Postavil jsem Python modul, který přes DataForSEO API běží SEO audity nad Vercel deployi. 574 keywords, 306 prioritizovaných příležitostí, 78 % cache hit. Reálný kód, výstupy a lessons.
Většina SEO auditů v ČR vypadá takhle: konzultant ti pošle PDF s 30 stránkami screenshotů z Ahrefs a fakturu na 25 000 Kč. Pro akceostrava.cz a ondrejknedla.cz jsem to potřeboval jinak - automatizovaně, opakovatelně a v Pythonu, který si pustím kdykoliv. Postavil jsem si modul nad DataForSEO API, který za jeden běh vyplivne 574 analyzovaných keywords a 306 prioritizovaných příležitostí. A dělá to za $1.80 v API kreditech.
Tady je celý setup.
Proč DataForSEO a ne Ahrefs API
Ahrefs API stojí $500/měsíc minimum, Semrush podobně. DataForSEO je pay-as-you-go: SERP request stojí $0.0006, keyword data $0.0011, on-page audit $0.00125. Pro mě, který si pustí audit jednou za 14 dní pro 3-4 projekty, je to 20× levnější.
Trade-off: API je surovější. Místo "domain rating" musíš sám počítat skóre. Místo "content gap" musíš sám crawlit konkurenty a diff-ovat keywordy. To je ale přesně to, kde Python září.
Architektura modulu
seo_audit/
├── client.py # DataForSEO HTTP client + retry + cache
├── models.py # Pydantic dataclasses pro responses
├── tasks/
│ ├── keywords.py # Keyword research, SERP, volume
│ ├── onpage.py # On-page audit (technical SEO)
│ ├── backlinks.py # Backlink profile
│ └── gaps.py # Content gap analysis vs konkurence
├── reports/
│ └── markdown.py # Jinja2 → MD report do reports/{date}.md
└── cli.py # python -m seo_audit run --domain ondrejknedla.cz
Každý task je samostatná funkce, která vrací typovaný Pydantic model. Reportér dostane list modelů a vyrenderuje markdown.
Client s retry a SQLite cache
DataForSEO občas vrátí 5xx, taky občas trvá 30 sekund na response u SERP queries. Bez retry a cache se ti audit protahuje na hodiny.
import hashlib
import json
import sqlite3
import time
from typing import Any
import httpx
class DataForSEOClient:
def __init__(self, login: str, password: str, cache_path: str = '.seo-cache.sqlite'):
self._auth = (login, password)
self._http = httpx.Client(base_url='https://api.dataforseo.com/v3', timeout=60.0)
self._cache = sqlite3.connect(cache_path)
self._cache.execute(
'CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT, ts INTEGER)'
)
def post(self, path: str, payload: list[dict[str, Any]], ttl: int = 86_400) -> dict:
key = hashlib.sha256(f'{path}|{json.dumps(payload, sort_keys=True)}'.encode()).hexdigest()
row = self._cache.execute(
'SELECT v, ts FROM cache WHERE k = ?', (key,)
).fetchone()
if row and time.time() - row[1] < ttl:
return json.loads(row[0])
for attempt in range(4):
try:
r = self._http.post(path, json=payload, auth=self._auth)
r.raise_for_status()
data = r.json()
self._cache.execute(
'INSERT OR REPLACE INTO cache (k, v, ts) VALUES (?, ?, ?)',
(key, json.dumps(data), int(time.time())),
)
self._cache.commit()
return data
except httpx.HTTPError:
if attempt == 3:
raise
time.sleep(2 ** attempt)
raise RuntimeError('unreachable')TTL 24 hodin je sweet spot. SERP se mění během dne, ale nehoupe. Když si týden ladím prompty pro report, 78 % requestů jde z cache a já neutrácím kredity zbytečně.
Pydantic modely místo dictů
Hlavní důvod: DataForSEO má hluboce vnořené responses s tasks[0].result[0].items[] a typování ti šetří hodiny ladění.
from pydantic import BaseModel, Field
class KeywordItem(BaseModel):
keyword: str
search_volume: int | None = None
cpc: float | None = None
competition: float | None = None
difficulty: int | None = Field(default=None, alias='keyword_difficulty')
serp_url: str | None = None
class KeywordsResult(BaseModel):
items: list[KeywordItem]
@property
def opportunities(self) -> list[KeywordItem]:
# priority = vysoký volume, nízký difficulty
return sorted(
[k for k in self.items if (k.difficulty or 100) < 35 and (k.search_volume or 0) > 50],
key=lambda k: -(k.search_volume or 0),
)Pro akceostrava.cz (dance event web) tahle heuristika vytahuje keywordy jako "halloween rave ostrava" (volume 480, difficulty 22) místo "halloween" (volume 60 000, difficulty 88). To je rozdíl mezi rankováním do 3 měsíců vs nikdy.
Content gap přes konkurenční crawl
Tohle je nejcennější část. DataForSEO nemá out-of-box "content gap" endpoint, takže si ho stavíš sám:
async def find_content_gaps(domain: str, competitors: list[str]) -> list[KeywordItem]:
my_kw = client.post('/dataforseo_labs/google/ranked_keywords/live',
[{'target': domain, 'language_code': 'cs'}])
my_set = {item['keyword'] for item in extract_items(my_kw)}
gaps: dict[str, KeywordItem] = {}
for comp in competitors:
comp_kw = client.post('/dataforseo_labs/google/ranked_keywords/live',
[{'target': comp, 'language_code': 'cs'}])
for item in extract_items(comp_kw):
kw = item['keyword']
if kw in my_set:
continue
if kw not in gaps or item['search_volume'] > gaps[kw].search_volume:
gaps[kw] = KeywordItem(**item)
return sorted(gaps.values(), key=lambda k: -(k.search_volume or 0))Pro ondrejknedla.cz jsem jako konkurenty hodil tři české freelance weby. Gap analýza vrátila 142 keywordů, kde mě konkurence předbíhá - z toho 38 vyšlo jako reálná příležitost (nízký difficulty, relevantní intent).
Markdown report přes Jinja2
Výstup auditu nechci JSON. Chci markdown, který si přečtu v Obsidianu nebo zapíchnu do GitHub issue.
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader('templates'), trim_blocks=True, lstrip_blocks=True)
tmpl = env.get_template('audit.md.j2')
report_md = tmpl.render(
domain=domain,
generated_at=datetime.utcnow().isoformat(),
keywords=kw_result.opportunities[:50],
onpage=onpage_result,
gaps=gaps[:30],
summary={
'total_keywords': len(kw_result.items),
'opportunities': len(kw_result.opportunities),
'cache_hit_rate': client.stats.cache_hit_rate,
},
)Šablona audit.md.j2 má sekce: Top 50 keyword opportunities, Technical SEO issues, Content gaps vs konkurence, Backlink profile snapshot.
CLI a první run
python -m seo_audit run --domain ondrejknedla.cz \
--competitors webdeveloper.cz,frontendista.cz,konzultacent.cz \
--output reports/2026-04-25-ondrejknedla.mdReálné výstupy z posledních dvou auditů:
| Projekt | Keywords analyzed | Opportunities | Cache hit | API spend |
|---|---|---|---|---|
| akceostrava.cz | 312 | 184 | 81 % | $0.74 |
| ondrejknedla.cz | 574 | 306 | 78 % | $1.80 |
306 prioritizovaných keywordů v jedné tabulce, kterou si můžu projet za 20 minut a označit, na které články chci napsat outline. To je hodnota, kterou bych z PDF auditu netáhl.
Lessons
- Cache je největší úspora. 78 % hit rate snížil náklady 5×. SQLite cache stačí, žádný Redis netřeba.
- DataForSEO Labs endpointy jsou levnější než klasické SERP queries pro aggregate data.
ranked_keywords/liveje $0.011 za doménu, ne za keyword. - Difficulty < 35 + volume > 50 je pravidlo, které mi roky funguje pro česky-jazyčné SERP. EN trh musí mít přísnější (volume > 200), protože konkurence je 10×.
- Pydantic > dict access šetří hodiny. DataForSEO mění schema bez warning, model validuje a křičí na první požití.
- Markdown reporty se dají commitnout do repa jako historie SEO.
git diff reports/2026-04-11.md reports/2026-04-25.mdje nejlepší dashboard.
Co dál
- Akce Ostrava case study → - projekt, kde tenhle audit běžel poprvé
- Claude Code workflow → - jak Claude Code dopsal většinu téhle pipeliny
- B2B lead pipeline → - podobně laděný víkendový projekt v Bun
Pokud řešíš podobný automatizovaný SEO audit pro vlastní portfolio nebo agency klienty, napiš mi. Setup za půl dne, ROI v prvním měsíci.