ondrej.
· 8 min čtení

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.

b2bdata-pipelinebunscraping

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 800

Plá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ě:

  1. Raw fetch pro statické HTML — 80 % případů, rychlé
  2. 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

SloupecTypProč
icoTEXT PRIMARY KEYčeské IČO je 8 znaků, unikátní
naceTEXTfull 5-místný kód, indexovaný
krajTEXTnormalizovaný name z ARES
webTEXTnullable — ne všichni mají web
email, telefonTEXTnullable, scraping může selhat
discovered_atINTEGERunix 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

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.