· 7 min čtení

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.

SEOPythonAutomation

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.md

Reálné výstupy z posledních dvou auditů:

ProjektKeywords analyzedOpportunitiesCache hitAPI spend
akceostrava.cz31218481 %$0.74
ondrejknedla.cz57430678 %$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/live je $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.md je nejlepší dashboard.

Co dál

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.