B2B lead pipeline: Bun + SQLite + Playwright (98 640 firem za víkend)
Krtek pipeline od základu — stažení ČSÚ RES dumpu, filtr NACE kódů, ARES enrichment, Playwright scraping. Bun + bun:sqlite jako batch engine. Ukazuju kód, schema a NACE strategii.
Většina B2B lead generation tools v Česku tě stojí 5 000–20 000 Kč měsíčně a vrací ti generic kontakty, které mají všichni. Místo platby jsem si postavil vlastní pipeline za víkend. Výsledek: 98 640 firem v databázi, prvotřídně filtrovaných podle NACE kódů, obohacených ARES daty a se scrapnutými emaily z firemních webů.
Projekt se jmenuje Krtek a tady je celá architektura.
Pipeline overview
ČSÚ RES dump (1.5 GB CSV)
↓ NACE filter (692xx → účetnictví)
firmy s IČO + adresa
↓ ARES API enrichment
+ kraj, DIČ, právní forma
↓ web search heuristics
firemní URL
↓ Playwright / fetch
HTML stránky
↓ contact extraction
emaily + telefony
↓
SQLite (bun:sqlite)
5 kroků, každý jako samostatný Bun script. SQLite drží stav a umožňuje resume.
Krok 1 — ČSÚ RES dump
ČSÚ publikuje Registr ekonomických subjektů jako veřejný CSV dump (cca 1.5 GB). Obsahuje IČO, název, adresu, NACE, právní formu, datum vzniku — všechno, co potřebuješ pro segmentaci.
Stahování + parsing v Bun:
import { Database } from 'bun:sqlite';
const db = new Database('krtek.sqlite');
db.exec(`
CREATE TABLE IF NOT EXISTS firmy (
ico TEXT PRIMARY KEY,
nazev TEXT NOT NULL,
nace TEXT,
kraj TEXT,
adresa TEXT,
pf TEXT,
web TEXT,
email TEXT,
telefon TEXT,
discovered_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_nace ON firmy(nace);
CREATE INDEX IF NOT EXISTS idx_kraj ON firmy(kraj);
`);
const file = Bun.file('res-dump.csv');
const stream = file.stream();
const decoder = new TextDecoder('windows-1250'); // ČSÚ stále posílá v 1250
const insert = db.prepare(
'INSERT OR IGNORE INTO firmy (ico, nazev, nace, adresa, pf) VALUES (?, ?, ?, ?, ?)',
);
const tx = db.transaction((rows: string[][]) => {
for (const r of rows) insert.run(...r);
});Klíčový detail: Bun.file().stream() zvládne 1.5 GB bez OOM. Node by ti tohle nedal bez explicitního streaming setupu. Bun to má built-in.
Bun:sqlite dává sync API → batch insert v transakci běží řádově rychleji než Postgres přes drizzle/prisma. Pro tenhle use-case (single-machine, single-writer, read-heavy) je SQLite správná volba.
Krok 2 — NACE filter
NACE je evropský klasifikační systém ekonomických činností. 6920 = "účetnictví, vedení účetních knih, daňové poradenství". Tohle byl můj první cíl, protože celý DokladBot je positionovaný na účetní firmy.
const accountingFirms = db
.query("SELECT * FROM firmy WHERE nace LIKE '6920%'")
.all();
console.log(`${accountingFirms.length} účetních firem`);
// → ~12 800Plánovaná expanze:
- NACE 620 — programování, IT consulting (target pro AI consulting služby)
- NACE 631 — datové zpracování, hosting (target pro infra produkty)
NACE je nedoceněný filtr. Většina nástrojů ti dává jen "industry tags", které jsou mlhavé. NACE 5-místný kód ti dá přesnou cílovou skupinu.
Krok 3 — ARES API enrichment
ČSÚ dump má kraj jen jako PSČ. Pro segmentaci kraj/region potřebuju normalizovaný název. ARES (Administrativní Registr Ekonomických Subjektů) má REST endpoint zdarma:
type AresResponse = { sidlo: { kodKraje: string; nazevKraje: string }; dic?: string };
async function enrichFromAres(ico: string): Promise<AresResponse | null> {
const url = `https://ares.gov.cz/ekonomicke-subjekty-v-be/rest/ekonomicke-subjekty/${ico}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
return (await res.json()) as AresResponse;
}
// rate-limited paralelní enrichment
async function enrichBatch(icos: string[], concurrency = 10) {
const queue = [...icos];
const workers = Array.from({ length: concurrency }, async () => {
while (queue.length) {
const ico = queue.shift();
if (!ico) break;
const ares = await enrichFromAres(ico);
if (ares) {
db.run('UPDATE firmy SET kraj = ?, dic = ? WHERE ico = ?', [
ares.sidlo.nazevKraje,
ares.dic ?? null,
ico,
]);
}
}
});
await Promise.all(workers);
}ARES nemá public rate limit, ale neprotahuju 100 paralelních requestů. Concurrency 10 je sweet spot — žádný 429, full enrichment 12 800 firem za ~25 minut.
Krok 4 — Playwright scraping
Stahování webů firem řeším dvouvrstvově:
- Raw fetch pro statické HTML — 80 % případů, rychlé
- Playwright pro JS-rendered stránky (React/Vue) — 20 %, pomalejší ale spolehlivé
async function scrapeContact(url: string): Promise<{ email?: string; phone?: string }> {
const html = await fetchOrPlaywright(url);
const $ = cheerio.load(html);
const text = $('body').text();
const emailMatch = text.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i);
const phoneMatch = text.match(/(?:\+420\s?)?\d{3}\s?\d{3}\s?\d{3}/);
return {
email: emailMatch?.[0],
phone: phoneMatch?.[0]?.replace(/\s/g, ''),
};
}
async function fetchOrPlaywright(url: string): Promise<string> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const html = await res.text();
if (html.length > 500 && !html.includes('app-root')) return html;
} catch {}
// fallback na Playwright
const page = await browser.newPage();
await page.goto(url, { timeout: 15000 });
return page.content();
}Heuristika html.length > 500 && !html.includes('app-root') zachytí 95 % SPA stránek. Pomalá (Playwright trvá 3–5 s na stránku), ale Bun + 8 paralelních prohlížečů to rozdýchá.
Schema design
| Sloupec | Typ | Proč |
|---|---|---|
ico | TEXT PRIMARY KEY | české IČO je 8 znaků, unikátní |
nace | TEXT | full 5-místný kód, indexovaný |
kraj | TEXT | normalizovaný name z ARES |
web | TEXT | nullable — ne všichni mají web |
email, telefon | TEXT | nullable, scraping může selhat |
discovered_at | INTEGER | unix timestamp pro freshness |
Žádný JSON sloupec. Všechno relační, dotazovatelné indexy.
Výstup za víkend
- 98 640 firem napříč všemi NACE kódy v ČR
- ~12 800 v primární cílovce (účetnictví, NACE 692)
- ~62 % má scrapnutý email nebo telefon (zbytek = bez webu nebo nečitelný)
- SQLite DB cca 180 MB, queryable přímo z dev terminálu
$ bun -e "import {Database} from 'bun:sqlite'; \
const db = new Database('krtek.sqlite'); \
console.log(db.query('SELECT kraj, COUNT(*) c FROM firmy WHERE nace LIKE \"6920%\" GROUP BY kraj ORDER BY c DESC LIMIT 5').all())"
[
{ kraj: 'Hlavní město Praha', c: 4521 },
{ kraj: 'Jihomoravský kraj', c: 1834 },
{ kraj: 'Moravskoslezský kraj', c: 1215 },
...
]Co dál
- Krtek case study → — víc o produktu kolem té DB
- Claude Code workflow → — jak Claude Code napsal 80 % téhle pipeliny
- Multi-language Next.js → — další víkendový projekt
Pokud potřebuješ podobnou pipeline pro vlastní cílovku (jiné NACE, jiný region, jiný typ kontaktu), napiš mi. Stavění nové cílovky trvá ~2 dny.