cbagent
  • Python 92.8%
  • Shell 7.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jan Hošek 03b8ce5432 deploy: volitelný Qdrant jako vektorová DB (VECTOR_DB=qdrant)
Chroma staví HNSW index synchronně v cestě insertu (naměřeno 0,7–1,0 s na
vložení do velké kolekce, jedno jádro). Qdrant indexuje v optimalizátoru na
pozadí — insert se vrátí rychle a graf se dohání mimo request; navíc shardy
a paralelní `upload_points`.

**Přepínač je jedna hodnota** v .env:

    VECTOR_DB=qdrant     # jinak chroma (výchozí)

Compose si kontejner `qdrant` zařadí sám přes `COMPOSE_PROFILES=${VECTOR_DB}`
(takže stačí .env, žádný --profile na příkazové řádce) a OWUI má v
`depends_on` `required: false`, aby při chroma variantě nespadl.

Co je hotové:

* **compose** — služba `qdrant` (profil, pin na digest v1.19.1), volume
  `qdrant-data`, healthcheck přes /dev/tcp (obraz nemá curl/wget),
  telemetrie vypnutá, `MAX_SEARCH_THREADS=4`; `VECTOR_DB` a `QDRANT_*` v env
  OWUI. Zároveň jsem odstranil duplicitní blok komentáře k CHUNK_SIZE, který
  mi zůstal v compose z dřívějška.
* **.env.example** — VECTOR_DB, QDRANT_URI, QDRANT_ON_DISK, QDRANT_API_KEY
  s vysvětlením, kdy přepnout a že je nutný reindex.
* **install.sh** — Qdrant se startuje **před** OWUI (ten se na něj připojuje
  při startu a bez serveru by spadl na 503) a čeká se na health; závěrečná
  zpráva při qdrant větvi upozorní, že starý index v Chromě se nepoužívá a
  je nutný Reindex.
* **preflight.sh** — kontroluje VECTOR_DB (neznámá hodnota = chyba), u qdrant
  varuje na obsazený port 6333 a prázdný API klíč.
* **smoke-test.sh** — u qdrant ověří health kontejneru a že **OWUI zevnitř
  dosáhne na Qdrant** a vidí kolekce.

Ověřeno: `bash -n` na všech skriptech, `docker compose config` v obou
režimech, obě větve závěrečné zprávy, preflight větve (qdrant/neznámá/
prázdný klíč) a hlavně **reálný běh**: qdrant z finálního compose naběhl
healthy, 0× WARN/ERROR v logu, telemetrie disabled, `/collections` odpovídá.

Poznámka: OWUI vede Qdrant jako „community-supported, best-effort" a
`CHUNK_SIZE=3000` zůstává páka i s ním (méně insertů), jen je každý levnější.
2026-10-03 23:33:24 +02:00
data zplostit_kb: vynechat texty smluv, které jsou doslova v dokumenty/*.md 2026-10-03 19:03:14 +02:00
deploy deploy: volitelný Qdrant jako vektorová DB (VECTOR_DB=qdrant) 2026-10-03 23:33:24 +02:00
.gitignore zplostit_kb.py: zploštěný strom pro oikb + DATA.md do data/ 2026-10-03 10:13:42 +02:00
bench_embedding.py bench_embedding.py: benchmark embedding modelů na datech repa 2026-10-03 10:13:31 +02:00
cbweb.py smlouvy z webu: rozpoznat skutečný formát dokumentu, ne jen PDF 2026-09-27 15:38:06 +02:00
cron_daily.sh deploy: ollama na hostu, klíče bez ručního kroku, zploštěný strom, --local 2026-10-03 10:13:52 +02:00
cron_weekly.sh cron: denni a tydenni aktualizace 2026-09-24 16:31:31 +02:00
diff_contacts.py telefonni seznam magistratu: denni snapshoty a prehled zmen 2026-09-26 14:32:04 +02:00
edesky.py uredni deska 2026-09-24 10:03:56 +02:00
export_cookies.py Stahování materiálů zasedání Rady města České Budějovice 2026-09-15 15:41:11 +02:00
extract_text.py smlouvy: sken bez textové vrstvy se už neuloží jako „text smlouvy“ 2026-10-03 11:02:14 +02:00
fetch_106.py textova vrstva dokumentu: <soubor>.md vedle kazdeho dokumentu 2026-09-26 14:32:37 +02:00
fetch_contacts.py telefonni seznam magistratu: denni snapshoty a prehled zmen 2026-09-26 14:32:04 +02:00
fetch_deska.py textova vrstva dokumentu: <soubor>.md vedle kazdeho dokumentu 2026-09-26 14:32:37 +02:00
fetch_dotace.py dotace, uzemni plan, FOI a vybory 2026-09-24 16:10:40 +02:00
fetch_rada.py textova vrstva dokumentu: <soubor>.md vedle kazdeho dokumentu 2026-09-26 14:32:37 +02:00
fetch_smlouvy.py smlouvy: sken bez textové vrstvy se už neuloží jako „text smlouvy“ 2026-10-03 11:02:14 +02:00
fetch_uzemni_plan.py dotace, uzemni plan, FOI a vybory 2026-09-24 16:10:40 +02:00
fetch_vybory.py textova vrstva dokumentu: <soubor>.md vedle kazdeho dokumentu 2026-09-26 14:32:37 +02:00
fetch_zastupitelstvo.py textova vrstva dokumentu: <soubor>.md vedle kazdeho dokumentu 2026-09-26 14:32:37 +02:00
hlasy.py hlasy.py: poradit správnou opravu, když zmizí nativní knihovna 2026-10-03 10:13:24 +02:00
hlidac.py smlouvy 2026-09-24 10:04:35 +02:00
opravit_smlouvy_text.py smlouvy: sken bez textové vrstvy se už neuloží jako „text smlouvy“ 2026-10-03 11:02:14 +02:00
prepis.py Závislosti: torch z indexu cu124 místo výchozího CUDA 13 z PyPI 2026-09-27 15:24:22 +02:00
prepis_paralelne.py Paralel version of transciption. 2026-10-01 10:49:14 +02:00
pyproject.toml Oprava: uv sync na Pythonu 3.10 spadl na onnxruntime bez wheelu 2026-09-27 16:06:22 +02:00
README.md zplostit_kb.py: zploštěný strom pro oikb + DATA.md do data/ 2026-10-03 10:13:42 +02:00
run_years.sh Chybějící nebo vadný dokument už nezacyklí run_years.sh 2026-09-21 17:43:26 +02:00
smlouvy.py smlouvy: sken bez textové vrstvy se už neuloží jako „text smlouvy“ 2026-10-03 11:02:14 +02:00
smlouvy_organizace.json smlouvy 2026-09-24 10:04:35 +02:00
tabulka_usneseni.py Revert 'Rozlišit chybějící délku projevu od nulové' 2026-09-21 20:06:09 +02:00
TODO.md bench_embedding.py: benchmark embedding modelů na datech repa 2026-10-03 10:13:31 +02:00
usneseni.py Chybějící délky projevů dopočítat z času dalšího řečníka 2026-09-21 20:11:21 +02:00
uv.lock Oprava: uv sync na Pythonu 3.10 spadl na onnxruntime bez wheelu 2026-09-27 16:06:22 +02:00
zasedani.py dotace, uzemni plan, FOI a vybory 2026-09-24 16:10:40 +02:00
zplostit_kb.py zplostit_kb: vynechat texty smluv, které jsou doslova v dokumenty/*.md 2026-10-03 19:03:14 +02:00

cb_radnice

Nástroje pro stahování dat z českobudějovického radničního webu c-budejovice.cz:

  • fetch_rada.py – materiály a zápisy zasedání Rady města (nutné přihlášení),
  • fetch_zastupitelstvo.py – materiály, zápisy, audio diskuse a anotace ze zasedání Zastupitelstva města (veřejné, bez přihlášení),
  • usneseni.py – strojový záznam o usneseních ze stažených zápisů (potřebuje systémový pdftotext),
  • tabulka_usneseni.py – CSV souhrn aktivity zastupitelů a hostů za řadu zasedání (vychází z usneseni.json).

Všechna stažená data jsou v data/ a git je nesleduje – dá se to kdykoli vygenerovat znovu ze zápisů a opendata. Git drží jen kód a dokumentaci.

Rychlý start

Oba skripty potřebují jen curl a standardní knihovnu; aiohttp je potřeba jen pro export_cookies.py (řeší pyproject.toml, stačí uv run).

# Rada města – nejdřív se přihlas v prohlížeči a vyexportuj cookies:
uv run export_cookies.py --out cookies.txt
uv run fetch_rada.py --cookies cookies.txt --year 2026

# Zastupitelstvo – bez přihlášení:
uv run fetch_zastupitelstvo.py --year 2026

Uspořádání

cb_radnice/
├── fetch_rada.py              # rada města (materiály, zápisy)
├── fetch_zastupitelstvo.py    # zastupitelstvo (+ audio a anotace diskuse)
├── usneseni.py                # usnesení a zastupitelé ze zápisů (pdftotext)
├── tabulka_usneseni.py        # CSV souhrn aktivity za řadu zasedání
├── prepis.py                  # přepis audio projevů na text (whisper/canary)
├── hlasy.py                   # hlasové otisky zastupitelů a identifikace
├── fetch_smlouvy.py           # smlouvy města a organizací (Hlídač + web města)
├── smlouvy.py                 # indexy smluv, sloučení stromů (--sluc)
├── fetch_deska.py             # úřední deska města (denní sběr, mizí po svěšení)
├── edesky.py                  # zrcadlo edesky.cz: OCR texty a historie desky
├── hlidac.py                  # klient API Hlídače státu
├── smlouvy_organizace.json    # které subjekty sbírat (IČO) – verzované
├── export_cookies.py          # cookies z běžícího prohlížeče (CDP)
├── extract_text.py            # textová vrstva dokumentů: <soubor>.md (PDF, Word, Excel, RTF, ZIP, MSG)
├── run_years.sh               # více let za sebou, překoná blokace webu
├── cookies.txt                # přihlašovací cookies (mimo git)
├── .env                       # tokeny (HLIDACSTATU_API_TOKEN) – mimo git
└── data/                      # všechna stažená data (mimo git, kromě DATA.md)
    ├── DATA.md                # MAPA dat: co je v data/ a jak v tom hledat
    ├── rada/
    │   └── 2026/01_2026/
    │       ├── 001/                  # materiály bodu 01
    │       ├── 062.01/               # podbod 62.01
    │       ├── 999/                  # body bez čísla ("-")
    │       ├── z_rm_a_19-01-2026.pdf # zápis
    │       └── 01_2026.md            # tabulka bodů + odkazy
    ├── zastupitelstvo/
    │   └── 2026/2026031/
    │       ├── 001/                    # materiály bodu 01
    │       ├── audio/2026031.mp3       # zvukový záznam diskuse (jinde i .mp4)
    │       ├── opendata/               # hlasy, poslanci, výsledky (UTF-8)
    │       ├── 2026031-...pdf          # zápis
    │       ├── diskuse.json            # anotace strojově
    │       ├── diskuse.md              # anotace čitelně
    │       ├── usneseni.json           # usnesení strojově (ze zápisu + opendata)
    │       ├── zastupitele.json        # zastupitelé a kluby tohoto zasedání
    │       ├── prepis.json             # přepis projevů strojově (prepis.py)
    │       ├── prepis.md               # přepis projevů čitelně
    │       └── 2026031.md              # tabulka bodů + odkazy
    ├── smlouvy/
    │   ├── index.json, index.md        # souhrn: součty, dodavatelé, organizace
    │   └── 2026/2026002062/
    │       ├── smlouva.json            # metadata všech zdrojů + párování
    │       ├── smlouvy.md              # čitelně
    │       ├── dokumenty/              # PDF z webu města (--web)
    │       └── text/                   # hlidac.txt, web.txt
    └── deska/
        └── 2026/300832-stanoveni-.../
            ├── deska.json              # metadata + dokumenty + texty
            ├── deska.md                # čitelně
            ├── dokumenty/              # PDF přílohy (web-1-*.pdf)
            └── text/                   # deska.txt (pdftotext), edeska.txt (OCR)

Detailní mapa dat je v data/DATA.md — typy souborů, schémata JSON, co je stažené vs. odvozené, jak zjistit co chybí a příklady hledání. Leží uvnitř data/ záměrně: má sloužit jako základní orientace pro agenta, který má data/ namountované (Open Terminal, oikb) a vidí jen ten adresář.

Databáze hlasových otisků (hlasy.json) a výsledky identifikace (identify.json) leží v kořeni repa vedle cookies.txt; obojí je v .gitignore, protože se dá kdykoli přegenerovat a má stovky MB.

Číslo bodu → adresář: 01 → 001, 62.01 → 062.01, - → 999. U rady je adresář zasedání <NN>_<ROK> (např. 01_2026), u zastupitelstva celé číslo zasedání (např. 2026031).

data/ je celý v .gitignore – dokumenty, audio i textové výstupy (indexy .md, diskuse.json, usneseni.json, zastupitele.json, .files.jsonl, opendata/*.txt) se dají vygenerovat znovu ze zápisů a opendata, takže v gitu nejsou. Drží se jen kód a dokumentace.

Audio a anotace diskuse (zastupitelstvo)

Ke každému zasedání se stahuje zvukový záznam diskuse a anotace: který bod, kdo mluvil, kdy a jak dlouho. Anotace se berou z opendata archivu (.../<číslo zasedání>.zip), který má i délku projevu a klub; když archiv neexistuje (starší zasedání), složí se z HTML stránky /zasedani-zm/<id>/diskuse.

diskuse.json vypadá takto:

{
 "session": 2026031,
 "date": "2026-07-20",
 "audio": "audio/2026031.mp3",
 "source": "opendata",
 "points": [
  {"bod": "00", "name": "Zahájení, ...", "first_speech": "09:08:09",
   "speeches": [
    {"speaker": "doc. Dr. Ing. Dagmar Škodová Parmová", "party": "NAŠE ČESKO",
     "clock": "09:08:09", "offset": 0, "length": "1:47"}]}]}

offset je pozice ve zvukovém záznamu v sekundách (vhodné pro přepis a pro skok na daný projev), clock je čas ze zápisu, length délka projevu.

Pozor na kódování: opendata soubory (.zip → .txt) jsou v CP1250, ne v UTF-8. Skript je překódovává do UTF-8 (data/.../opendata/*.txt i texty v diskuse.json). HTML stránky jsou UTF-8.

Pozor na audio: stránka diskuse uvádí odkazy na .mp4, .ogg i .mp3, ale na serveru existuje jen jeden z nich – u starších zasedání (2014–2015) i jinde (např. 2016018, 2022001, 2025026) je to .mp4 a .mp3/.ogg vrací 404. Skript proto zkouší zdroje v pořadí .mp3, pak ostatní a použije první, který se opravdu stáhne; chybovou HTML stránku nikdy neuloží jako audio.

Přípona .mp4 přitom neznamená MP4 kontejner: u řady zasedání (např. 2016018) je to holý ADTS AAC (ff f9) a u jiných zasedání naopak .mp3 není ID3/ff fb, ale MPEG-1 Layer III začínající ff fa. Rozpoznávání audia proto kontroluje synchronizační slovo 0xFFE0 (0xFF + tři jedničky), které pokrývá MP3 i AAC; na konkrétní masku po synchronizaci se nehledí. diskuse.json zapisuje skutečné jméno staženého souboru (např. audio/2022001.mp4), takže prepis.py ho vždy najde.

Usnesení (usneseni.json) a zastupitelé (zastupitele.json)

usneseni.py doplní ke každému zasedání dva strojově čitelné soubory vedle diskuse.json. Zdroj pravdy je text zápisu (číslo usnesení i jeho rozhodující znění), hlasy se berou z opendata. Přijatá i nepřijatá usnesení se ukládají, rozlišená příznakem prijato.

python3 usneseni.py                       # celý strom data/zastupitelstvo
python3 usneseni.py --session 2026031     # jedno zasedání
python3 usneseni.py --year 2026           # jeden rok

Skript potřebuje systémový pdftotext (balíček poppler-utils); když chybí, skončí kódem 5 (EXIT_FATAL) s jasnou hláškou.

usneseni.json:

{
 "session": 2026031, "datum": "2026-07-20",
 "zdroj": {"zapis": "z_zm_a_20-07-2026.pdf", "opendata": true},
 "usneseni": [{
   "cislo": "119/2026", "cislo_cast": 119, "rok": 2026,
   "bod": "1", "nazev": "Poskytnutí neinvestiční dotace … z. s.",
   "cislo_jednaci": "KP-ZM/271/2026/M/138", "prijato": true,
   "text": "zastupitelstvo města\nI. s c h v a l u j e\n1. poskytnutí …",
   "predkladatel": "Petr Maroš",
   "hlasovani": [{"cislo": 6, "cas": "09:48:36", "pritomno": 35,
                  "pro": 35, "proti": 0, "zdrzeli_se": 0, "nehlasovali": 0,
                  "vysledek": "+", "rozhodujici": true, "zdroj": "opendata",
                  "hlasy": {"pro": ["Lukáš Bajt", "Jaroslav Berka"],
                            "proti": [], "zdrzeli_se": [], "nehlasovali": []}}],
   "diskutujici": [{"jmeno": "Petr Maroš", "klub": "NAŠE ČESKO",
                    "projevy": 2, "sekund": 145, "cas": "2:25",
                    "odhadnuto": 0}]}]
}
  • cislo a rok jsou z bloku … přijalo u s n e s e n í č. N/YYYY v sekci Soubor usnesení; cislo je null u nepřijatých a u syntetického lístku.
  • bod je číslo bodu z narace zápisu; text je vlastní znění usnesení bez redundantní hlavičky (u nepřijatých celé znění z Nepřijatá usnesení).
  • predkladatel je jedno pole ve tvaru Jméno Příjmení bez titulů, nebo null. Bere se z hlavičky PDF materiálu (Předkládá:), a když materiál není, z narace (Materiál uvedl/a <osoba>).
  • hlasovani je vždy seznam: obvykle jeden lístek, u odděleného hlasování víc, u sloučeného hlasování jedno číslo hlasování sdílené víc usneseními. rozhodujici označuje lístek, který odpovídá souhrnu (a,b,c,d/x) ze zápisu (a souhlasí s prijato). hlasy jsou jmenovité hlasy z Hlasy.txt (symboly + - X 0; A = nepřítomen se vynechává), nebo null když jmenovité hlasy nejsou k dispozici.
  • diskutujici shrnuje řečníky k danému bodu z diskuse.json: počet projevů a součet délek v sekundách. Kde délka projevu v datech chybí, doplní se z času dalšího řečníka (viz níže) – odhadnuto říká, u kolika projevů.

Párování: usnesení z bloků Soubor usnesení / Nepřijatá usnesení se spojují s narativními markery Přijato usnesení č. N/YYYY resp. Usnesení nebylo přijato (a,b,c,d/x) podle čísla jednacího, záložně podle pořadí. Hlasování se hledá v openodata Vysledky.txt podle č.j., a když je prázdné (nebo chybí), podle čísla bodu; použijí se jen řádky typu U – procedurální hlasování (I, SP, VK, UD, US) se ignoruje. Když hlasování typu U k bodu v opendata chybí (např. bod bez materiálu), vznikne syntetický lístek ze souhrnu v zápisu (zdroj: "zapis", hlasy: null).

Jména se sjednocují na Jméno Příjmení bez titulů (nazev_osoby). Zastupitel se hledá podle celého příjmení jako slovní skupiny kdekoli v označení, takže Ing. Moravec, předseda kontrolního výboru, doc. Dr. Ing. Dagmar Škodová Parmová i víceslovné Škodová Parmová dají jednoho člověka. Roster nemá kolize příjmení, proto je nález jednoznačný; predkladatel, diskutujici[].jmeno a jména v hlasy jsou ve stejném tvaru jako jmeno v zastupitele.json, takže se klub dohledá podle jména. Hosté (mimo roster, např. Jan Piskač) zůstávají tak, jak jsou v datech, jen bez titulů.

zastupitele.json je tabulka zastupitelů a jejich klubů pro dané zasedání (kluby se mezi zasedáními mění):

{"session": 2026031, "zdroj": "opendata",
 "zastupitele": [{"id": 32, "jmeno": "Jan Zahradník", "titul": "RNDr.",
                  "klub": "NAŠE ČESKO"}]}

zdroj je opendata (z Poslanci.txt; Hosté a Tajemník se vylučují), nebo zapis (z výčtu Přítomni: a Omluveni: v zápisu, když opendata neexistují – pak má id i klub hodnotu null).

Chybějící zápis znamená nedokončený download, ne trvalý stav: usneseni.json se nevygeneruje a v logu je hláška zápis není (nedotažený download?). Po dotažení zápisu stačí skript spustit znovu. Opendata chybět mohou i trvale (2014 je nemá, zasedání 2025021 ještě nebylo dotaženo) – skript se bez nich obejde a hlasování vezme ze souhrnu v zápisu, jen bez jmen.

Tabulka zastupitelů a hostů (tabulka_usneseni.py)

tabulka_usneseni.py spočítá z usneseni.json a zastupitele.json souhrn aktivity pro řadu zasedání a zapíše ho jako CSV:

python3 tabulka_usneseni.py --od 2024011 --do 2026032 --out tabulka.csv
Přepínač Význam
--od N číslo prvního zasedání (včetně); bez zadání nejnižší na disku
--do N číslo posledního zasedání (včetně); bez zadání nejvyšší na disku
--out-dir DIR kořen se zasedáními (výchozí data/zastupitelstvo)
--out FILE výstupní CSV (výchozí tabulka_<od>_<do>.csv)
--separator Z oddělovač (výchozí ,; pro český Excel se hodí ;)

CSV začíná utf-8-sig, takže se v Excelu otevře se správnou diakritikou. Za zastupiteli následují hosté.

Sloupec Význam
typ zastupitel, nebo host
jmeno Jméno Příjmení (stejný tvar jako v usneseni.json)
klub klub, za který byl v rozsahu naposledy; prázdný, když klub není známý
zasedani_hlasoval počet zasedání, kde alespoň jednou hlasoval
hlasovani počet hlasování, kde hlasoval (pro/proti/zdržel se)
body_navrhovatel počet bodů, kde je navrhovatelem (predkladatel)
body_diskuse počet bodů, kde se účastnil diskuse
cas_diskuse_s celkový čas diskuse v sekundách

Co se počítá a co ne:

  • Zastupitel je každý, kdo je v rozsahu v některém zastupitele.json. Host je každý, kdo v rozsahu v žádném seznamu zastupitelů není – takže hosté se poznají i tehdy, když v jednom zasedání zrovna chybí opendata.
  • zasedani_hlasoval a hlasovani se počítají jen ze jmenovitých hlasů (lístky se zdroj: "opendata"). Zasedání, kde opendata nejsou (2014, 2025024), přispějí jen do body_diskuse a cas_diskuse_s; kdo hlasoval jen tam, má nulu. Je to vidět v logu: chybí usneseni.json u zasedání bez zápisu.
  • hlasovani je počet jednotlivých hlasování, ne počet usnesení: u sloučeného hlasování (jedno hlasování pro víc bodů) se počítá jednou.
  • Do „hlasoval“ se nepočítá nehlasovali (symbol 0 – byl přítomen, ale netiskl) ani A (nepřítomen, v datech vůbec není).
  • body_diskuse je počet bodů, ne projevů: kdo k jednomu bodu mluvil třikrát, má 1. cas_diskuse_s je součet délek všech jeho projevů.
  • Kde délka projevu v datech chybí, dopočítá se z času dalšího řečníka. Délky jsou jen v opendata archivu (<zasedání>.zip); pět zasedání (2014030, 2024015, 2024016, 2024018, 2025024) archiv na webu nemá, takže anotace pocházejí z HTML a nesou jen čas začátku projevu. U nich (a u 52 projevů v 2025019, kde archiv existuje, ale délky chybí) se délka bere jako čas, kdy začal mluvit další řečník – počítá se přes celé zasedání, ne jen v rámci bodu.
    • Přestávka se nezapočítává. Když je mezera delší než 30 minut (oběd, přerušení – v datech je vidět 46–69 minut), není to řečnický čas; řečník před přestávkou i úplně poslední řečník dne dostanou POSLEDNI_S = 60 sekund.
    • Přesnost: na zasedáních, kde délky známe, se takový odhad liší od skutečnosti v mediánu o +1 s (součty za zasedání vyjdou o 1,8–19,8 % vyšší, protože se do délky počítá i pauza mezi řečníky). Kde dvěma projevům vyšel stejný čas, dostane projev aspoň MINIMUM_S = 5 s.
    • Prahy PRESTAVKA_S, POSLEDNI_S a MINIMUM_S jsou v usneseni.py.
  • body_navrhovatel jsou body, kde je daný člověk predkladatel. V jednom bodu je navrhovatel jen jeden, ale jeho víc usnesení se počítá jako jeden bod.
  • Klub se bere jako poslední neprázdný v rozsahu, protože kluby se mezi zasedáními mění (a u zasedání bez opendata nejsou známé vůbec).

Přepis audio a hlasové otisky (prepis.py, hlasy.py)

Dva nástroje nad zvukovými záznamy: prepis.py přepíše projevy na text, hlasy.py postaví databázi hlasových otisků a hledá v ní lidi (i v cizím záznamu, např. v televizním přenosu).

Závislosti (včetně ML) řeší jeden uv sync – nic zvlášť instalovat netřeba:

uv sync

python3 prepis.py --session 2026031 --dry-run     # jen vypíše segmenty, nic nepřepisuje
python3 prepis.py --session 2026031 --limit 20    # prvních 20 projevů na vyzkoušení
python3 prepis.py --session 2026031               # celé zasedání

python3 hlasy.py enroll --session 2026031 --session 2026027
python3 hlasy.py list
python3 hlasy.py identify --vstup zaznam.mp4 --vystup identify.json
python3 hlasy.py verify --a jeden.wav --b druhy.wav

Torch a CUDA. pyproject.toml bere torch/torchaudio z indexu PyTorchu (https://download.pytorch.org/whl/cu124, ne z PyPI), takže se nestáhne výchozí CUDA 13 kolo, které na starším ovladači spadne. Verze jsou svázané:

  • CUDA 12.4 → torch 2.5.1+cu124 (index pro cu124 končí na 2.6.0)
  • torch <2.6 → od torchu 2.6 je torch.load defaultně weights_only a modely pyannote se nenačtou
  • pyannote.audio <4 → verze 4.x chce torch ≥2.8, což s cu124 nejde

Bez GPU použij pytorch-cpu místo pytorch-cu124. Ověření: python3 -c "import torch; print(torch.__version__, torch.cuda.is_available())".

Skripty hlásí chybějící závislost jasnou hláškou (a EXIT_FATAL), ne tracebackem: prepis.py bez faster-whisper, hlasy.py bez torch/ pyannote, oba bez systémového ffmpeg.

Mapování anotace na pozici v audiu

Zvukový záznam je spojitý záznam celého jednání, ne slepenec po bodech, takže se pozice počítá jako

audio_pos = clock_s(první projev zasedání)  →  start = clock_s(projev) − audio_pos

offset v diskuse.json se pro řezání nepoužívá – je relativní k bodu (attach_offsets ve fetch_zastupitelstvo.py), takže by řezy seděly jen u prvního bodu zasedání. clock je jediný správný zdroj.

Ověřeno na všech 21 zasedáních se záznamem: rozdíl délky audia proti rozsahu clock je medián 117 s (hlava a patka záznamu), plný rozsah 19 s – 1123 s. Polední přestávka sedí: u 2025024 předpovězená mezera 10644–13669 s a silencedetect naměřil ticho 10805–13671 s.

Délka projevu a přestávky

Délka se bere z anotace (čas dalšího projevu ve stejném bodě, když je blíž než přestávka), jinak z length z opendata, jinak jako minuta (POSLEDNI_S). Kde existují obě čísla, liší se mediánově o 1 s; mezera je ale správná i pro sloučené bloky, kde opendata délku podhodnocují (2023007 bod 24.03: length 300 s, mezera 1137 s a uvnitř ní je 98 % řeči).

end se ořezává na začátek dalšího projevu a na délku záznamu. Délka se nezkracuje na pevný strop: projevů s anotovanou délkou ≥ 600 s je v datech 22 (nejdelší 3231 s) a uvnitř nich je 100 % řeči.

prepis.json (a vedle prepis.md) leží v adresáři zasedání vedle diskuse.json:

{"session": 2026031, "datum": "2026-07-20", "model": "large-v3",
 "backend": "faster-whisper", "jazyk": "cs", "audio": "audio/2026031.mp3",
 "projevy": [
   {"bod": "00", "speaker": "doc. Dr. Ing. Dagmar Škodová Parmová",
    "osoba": "Dagmar Škodová Parmová", "klub": "NAŠE ČESKO",
    "start": 210.0, "end": 337.0, "clock": "09:32:10", "zdroj_delky": "anotace",
    "text": "…", "slova": [{"t": "Dobrý", "start": 210.4, "end": 210.9}]}]}

speaker je původní označení z anotace, osoba je sjednocené Jméno Příjmení (stejná logika jako usneseni.py:nazev_osoby, včetně kolizí příjmení – zdroj pravdy je usneseni.py, kdyby se logika měnila, musí se změnit na obou místech). Hosté zůstávají tak, jak jsou v datech, jen bez titulů. zdroj_delky říká, odkud délka je: anotace, length, nebo odhad.

--full a paměť (blokový VAD)

Bez --full se audio řeže po projevech (medián 50 s), takže paměť je zanedbatelná. --full má ale v faster-whisperu jinou cestu: s vad_filter=True se celý soubor dekóduje do RAM a spočítá se z něj mel spektrogram najednou. Ten roste lineárně s délkou (naměřeno ~0,58 MB/s):

délka záznamu špička jen na mel spektrogram
30 min ~1,2 GB
1 h ~2,3 GB
4 h ~8,5 GB
11,8 h (nejdelší zasedání) ~24 GB

Na stroji s 16 GB RAM to skončí OOM killerem (dmesg: Out of memory: Killed process … anon-rss:8361536kB), a protože OOM killer bere největší proces v cgroup, může vzít i shell nebo tmux session.

Proto --full nepouští VAD na celý záznam, ale po blocích: záznam se rozdělí na ~30min úseky (hranice se hledá na začátku projevu, aby řez nepadl doprostřed) a každý blok se vyřízne do WAV a zpracuje zvlášť. Časy slov se posouvají o začátek bloku, takže výsledek je stejný jako dřív. Blok se smí protáhnout do MAX_BLOK_S (60 min), když má projev delší než cíl; když v okně žádný začátek projevu není (polední přestávka), blok se tvrdě uřízne na 60 min – paměť je důležitější než hranice na projevu a řez padá do ticha.

Naměřeno na skutečných blocích: špička 1,7 GB a neroste s délkou záznamu (RSS se recykluje mezi bloky). Nejdelší blok napříč všemi 38 zasedáními je 60 min → ~2,0 GB. Konstanta BLOK_VAD_S je v prepis.py.

Projevy, na které po VAD nezbylo žádné slovo (tichý hlas), se dořeší krátkým řezem bez VAD jako v běžném režimu.

Přepínače prepis.py

Přepínač Význam
--out DIR kořen se zasedáními (výchozí data/zastupitelstvo)
--year ROK jen daný rok; lze vícekrát
--session N jen dané zasedání; lze vícekrát
--model JMÉNO model pro přepis (výchozí large-v3)
--backend X faster-whisper (výchozí), nebo canary
--device X cpu (výchozí), cuda, auto
--compute-type X výchozí int8 na CPU, float16 na CUDA
--threads N počet vláken CPU (0 = nechat na knihovně)
--beam N beam size (výchozí 5; 1 je výrazně rychlejší)
--full přepis s VAD (odstraní ticho), po ~30min blocích kvůli paměti
--diarizace doplnit skutečné řečníky z diarizace (potřebuje HF_TOKEN a hlasy.json)
--limit N přepsat jen prvních N projevů
--force přepsat i hotové prepis.json
--dry-run jen vypsat segmenty, nic nepřepisovat

Rychlost na CPU. large-v3 int8 na tomto stroji (i7-10710U v Xenu, 8 vCPU bez AVX-512) měřeno na klidném stroji: --beam 5 (výchozí) jede rtf 3,2 a --beam 1 rtf 1,6, tedy 100 h audia ≈ 3–7 dní. Na vyzkoušení proto --limit 20; celý korpus se přepisuje na GPU serveru (--device cuda --compute-type float16). Pozor: při souběžných úlohách (víc přepisů nebo přepis + enroll) se rtf i zdvojnásobí – vyplatí se pouštět jednu věc naráz.

Režim --full je na GPU výhodný (VAD odstraní ticho a model jde přes zvuk jednou), na CPU je pomalejší než výchozí řezání po projevech; podrobnosti a paměťové nároky viz sekce „--full a paměť“ výše.

Backend canary-1b-v2

--backend canary použije nvidia/canary-1b-v2 přes NeMo. Vyžaduje GPU (Ampere+) a nemo_toolkit[asr], které nejsou v pyproject.toml (je to jen pro GPU server):

pip install nemo_toolkit['asr']
python3 prepis.py --backend canary --device cuda --session 2026031

Když NeMo chybí, skript to zaloguje a skončí s EXIT_FATAL – nic tiše nepřepne na jiný model.

Hlasové otisky

hlasy.json (v kořeni, nebo podle --db) obsahuje pro každou osobu 256 čísel – průměr normalizovaných embeddingů jejích projevů. Projevy kratší než MIN_ENROLL_S (5 s) se neberou a z jednoho projevu se použije nejvýš MAX_VZORKU_S (30 s); celkem_s je součet těchto použitých vzorků (nikoli celková délka projevů), takže slaby_otisk porovnává právě nasbíraný materiál. Bez --force se doplňují jen osoby, které v databázi ještě nejsou (otisk se rekonstruuje z uloženého průměru, takže se nic neztratí). Databáze se ukládá po každém zasedání, aby přerušený běh nepřišel o hotovou práci.

{"model": "pyannote/wespeaker-voxceleb-resnet34-LM", "dim": 256,
 "osoby": [{"jmeno": "Petr Maroš", "klub": "NAŠE ČESKO", "pocet_projevu": 120,
            "celkem_s": 5400.0, "slaby_otisk": false, "embedding": [0.0123, …]}]}

Příkazy hlasy.py:

Příkaz Význam
enroll postaví/aktualizuje databázi z prepis.json; bez --force doplní jen chybějící osoby
identify najde osoby z databáze v záznamu (--vstup), volitelně --start/--end
verify kosinová vzdálenost dvou nahrávek (--a, --b)
list vypíše databázi

identify přiřadí osobu, jen když je kosinová vzdálenost pod MAX_DIST a aspoň MARGIN (0,05) blíž než druhý kandidát; jinak vypíše neznámý.

Prahy jsou kalibrované a měřené na datech. Na 51 osobě (6 vzorků na osobu) vychází otisk ze 2–4 projevů proti jinému projevu téhož řečníka s mediánem vzdálenosti 0,23, proti jinému řečníkovi s mediánem 0,72; vyvážené optimum prahu je 0,47. Nastaveno je konzervativních 0,45, protože přiřknout v úředním záznamu hlas špatnému člověku je horší než říct „neznámý“.

End-to-end (databáze z 1–2 zasedání, test na drženém zasedání, proti anotaci v diskuse.json):

Databáze Správně Špatná osoba Neznámý Vzdálenost u správných
14 osob (1 zasedání) 66,3 % 5,1 % 28,6 % medián 0,13
38 osob (2 zasedání) 82,5 % 4,9 % 12,6 % medián 0,18

Podíl špatně přiřazených zůstává ~5 % a s rostoucí databází se hlavně snižuje podíl „neznámý“. Na malé databázi se navíc všechny ženské hlasy táhnou k jediné ženě v databázi – čím víc zasedání se do enroll dá, tím menší je tento artefakt. Prahy jsou v hlasy.py a při jiném korpusu (delší projevy, jiný mikrofon) se dají přeměřit stejným postupem.

identify je pomůcka, ne důkaz. I s dobrou databází asi 5 % úseků dostane jméno někoho jiného, a to i při vzdálenosti pod prahem. Když na výsledku záleží (jmenovitě někomu přisoudit výrok), ověřte to poslechem nebo podle textu přepisu – záznamy jsou veřejné a hlas lze dohledat.

Diarizace je gated. pyannote/speaker-diarization-3.1 i segmentation-3.0 vyžadují HF token a přijetí podmínek na webu:

export HF_TOKEN=hf_…
python3 hlasy.py identify --vstup zaznam.mp4

Bez HF_TOKEN se identify nerozbije: udělá embedding z celého zadaného úseku a porovná ho s databází (tedy „kdo tam mluví“, bez hranic mluvčích). Model embeddingů (wespeaker-voxceleb-resnet34-LM) gated není, takže enroll a verify token nepotřebují. Diarizace se pouští po hodinových blocích, aby delší záznam nesežral paměť.

Embeddingy jsou jazykově nezávislé (wespeaker je trénovaný na VoxCelebu, tj. angličtině); pro český hlas jde o běžný cross-lingual postup.

Přepínače hlasy.py

Přepínač Význam
--out DIR kořen se zasedáními (výchozí data/zastupitelstvo)
--db FILE databáze otisků (výchozí hlasy.json)
--device X cpu (výchozí), cuda, auto
--year ROK, --session N jen daná zasedání (enroll); lze vícekrát
--limit N jen prvních N projevů na zasedání (enroll)
--force enroll: přepočítat i hotové osoby
--vstup ZÁZNAM identify: zvukový nebo video záznam
--start S, --end S identify: jen zadaný úsek
--top N identify: vypsat jen N nejdelších úseků
--bez-diarizace identify: jen embedding celého úseku
--vystup FILE identify: zapsat výsledek do JSON
--quiet identify: nevypisovat jednotlivé úseky
--hf-token T token pro gated pyannote (výchozí $HF_TOKEN)
--a FILE, --b FILE verify: dvě nahrávky k porovnání

Audio a ffmpeg

Oba nástroje volají systémový ffmpeg/ffprobe. Audio se vždy dekóduje po úsecích na 16 kHz mono, nikdy celé do paměti – jak kvůli velikosti nahrávek, tak proto, že pyannote.audio není potřeba předhazovat celý soubor. V téhle sestavě (pyannote 3.4) se torchcodec vůbec nepoužívá – nastupuje až ve 4.x a pro cu124 na indexu PyTorchu ani neexistuje. Dekódování tedy řeší výhradně ffmpeg přes embedding_z_wav/vyrizni_wav.

Dočasné vyřezané WAV soubory jdou do tmp/ v repu (v .gitignore), ne do /tmp: ten bývá malý tmpfs a nahrávka zasedání má i stovky MB. Kvůli paměti a disku se vyplatí spouštět přepis i enroll po jednom zasedání, ne vše najednou.

Smlouvy města a městských organizací (fetch_smlouvy.py, smlouvy.py)

Třetí datová doména vedle zasedání: smlouvy. Sbírají se pro město (IČO 00244732) a jeho organizace — příspěvkové organizace, městské firmy, divadlo, školy a školky. Seznam subjektů je v smlouvy_organizace.json (verzovaný v repu, protože je to znalost, ne data); obsahuje 46 organizací s IČO dohledanými v ARES a navíc seznam subjektů, které se nesbírají (patří kraji nebo státu — Nemocnice ČB, Výstaviště, Budvar, Hvězdárna…).

Dva zdroje a jejich párování

Zdroj Co dává Poznámka
Hlídač státu metadata a hotový textový přepis token v .env, licence CC BY 3.0 CZ
Web města metadata + dokumenty smluv má i smlouvy, které v Registru smluv nejsou

Formát dokumentů z webu se určuje podle obsahu, ne podle hlaviček. Web posílá u smluv Content-Type: application/pdf i Content-Disposition: filename="Smlouva.pdf" úplně u všech dokumentů, ale naměřeno (2026-09-27) mezi nimi byly i DOCX, starý .doc, Outlook .msg a RTF. Soubor se proto ukládá pod skutečnou příponou (web-1-Smlouva.docx) a zahodí se jen tehdy, když obsahem není žádný známý dokument (typicky chybová HTML stránka). cbweb.document_ext_soubor to rozliší podle magic bytes; u OLE2 se čte i konec souboru, protože jména streamů (WordDocument, __substg1.0_, Workbook) jsou v adresáři až za daty.

Konkrétní chytáky:

  • .docx a .doc bez textu — jsou to skeny vložené jako obrázky (DOCX má jen word/media/*.jpg, .doc projde catdoc, ale nic nedá). Text z nich neudělá ani extract_text.py; chtěly by OCR obrázků. Naměřeno: 2017000294, 2017000437, 2017000448.
  • .msg je e-mail ze skeneru (canon_sws@c-budejovice.cz, předmět „skenovaný dokument") a vlastní smlouva je PDF uvnitř jako příloha. extract_text.py proto .msg bere jako kontejner (jako ZIP) a text dělá z přílohy – včetně OCR, když je skenovaná. Naměřeno: 2017002385, 2017002474, 2018000029.

Textový přepis se nevyrábí znovu: Hlídač vrací plainTextContent z extrakce PDF (u velkých smluv desítky tisíc slov). Pro smlouvy, které Hlídač nemá, se text udělá lokálně přes pdftotext -layout.

Párování je klíčové, protože část smluv je v obou zdrojích. Určuje se v tomto pořadí:

  1. shodné cisloSmlouvy — ověřeno, že Hlídač vede městské číslo smlouvy (2019002029, 2026002062), takže se s webem shoduje přímo
  2. shodné IČO protistrany a datum uzavření a podobný předmět
  3. shodné IČO protistrany a shodná hodnota bez DPH

Nikdy jen podle IČO — město má s jedním dodavatelem desítky smluv, takže by se slily dohromady. Když se shoda najít nedá, zůstane parovani.stav = "oba" s jistota: null a poznámkou „posuzuj ručně“ — radši přiznat nejistotu než tvrdit nepravdivou shodu.

Obě verze se vždy uchovávají, i když jde o týž úkon: liší se obsahem (jedna má PDF z webu, druhá přílohy Registru smluv) a společně dokazují úplnost.

Uspořádání dat

data/smlouvy/
├── index.json, index.md          # souhrn: součty, dodavatelé, organizace
└── 2026/
    ├── index.json, index.md      # tabulka smluv roku s odkazy
    └── 2026002062/
        ├── smlouva.json          # metadata všech zdrojů + párování + texty
        ├── smlouvy.md            # čitelný přehled
        ├── dokumenty/            # stažené soubory (web-1-Smlouva.pdf)
        └── text/                 # web.txt (pdftotext), hlidac.txt

<ROK> se bere z čísla smlouvy (2026002062 → 2026); když číslo rok neobsahuje (objednávky OBJ/1160/2017/114, čísla organizací 2024/0001/1010), z data uzavření. U smluv bez městského čísla je klíč hs-<id>.

Pole rok v smlouva.json i zařazení do ročního indexu se řídí stejným pravidlem (rok_smlouvy), takže řádek nespadne do nezarazeno, když leží v roční složce. Klíč adresáře (klic_smlouvy) nikdy nezačíná ani nekončí tečkou: čísla jako č.4/2025 by jinak založila skrytý adresář (.4-2025), který přeskakuje glob.glob, tar i rsync. Zbytky po starších pravidlech (skryté jméno, duplicitní adresář pro tutéž smlouvu) srovná smlouvy.py --index (srovnej_adresare) — je idempotentní.

Příkazy

# 1) Hlídač: metadata + hotové texty. Vyžaduje token v .env:
#    HLIDACSTATU_API_TOKEN=… (zdarma na https://www.hlidacstatu.cz/api)
python3 fetch_smlouvy.py --hlidac --rok-od 2010 --rok-do 2026

# jeden subjekt / menší vzorek na vyzkoušení
python3 fetch_smlouvy.py --hlidac --ico 00244732 --limit 20

# 2) web města: metadata + PDF (pozor na WAF, viz níže)
#    BEZ --ico: stránka obsahuje už jen smlouvy města
python3 fetch_smlouvy.py --web --rok-od 2026 --rok-do 2026
#    s --ico: cíleně smlouvy s jedním dodavatelem/organizací (IC = protistrana)
python3 fetch_smlouvy.py --web --ico 25166115 --rok-od 2026 --rok-do 2026

# 3) indexy (dělá se i samo po sběru)
python3 smlouvy.py --index
Přepínač Význam
--hlidac sbírat přes Hlídač API (metadata + texty)
--isrs zatím alias na --hlidac (ISRS dumpy umí totéž a mají i odkazy na PDF, ale nejsou v kódu zapojené — viz „Texty bez kvóty“)
--web sbírat z archivu na webu města (metadata + PDF)
--ico jen jeden subjekt
--rok-od/--rok-do/--roky rozsah let (web i Hlídač); web pak jede měsíc po měsíci
--role role našeho subjektu v Hlídači: platce (výchozí), prijemce, ico
--bez-textu nestahovat texty (rychlejší)
--pause prodleva pro web města (výchozí 15 s, viz WAF)
--pause-hlidac prodleva pro Hlídač (výchozí 0,6 s; měřeno 0,5 s = 0× 429)
--rozpocet zastaví běh po N požadavcích (výchozí 0 = bez limitu; při vyčerpání kvóty se čeká automaticky)
--limit jen N smluv
--debug detailní log: každá stránka, každá smlouva, důvod přeskočení
--force přepsat i hotové záznamy
--index na konci přegenerovat indexy

Hlídač API — co jsme zjistili měřením

  • Token je povinný; hlavička Authorization: Token …. Bez tokenu API vrací 302 na login.

  • Licence CC BY 3.0 CZ (info.license ve swaggeru): data lze sdílet i upravovat, i komerčně; podmínkou je uvést zdroj plným funkčním odkazem a vyznačit změny. Atribuce je proto v každém smlouva.json i v obou .md.

  • cisloSmlouvy = městské číslo smlouvy. To je nejlepší párovací klíč a ověřeno na konkrétních smlouvách.

  • prilohy[].plainTextContent nese hotový text (u smlouvy na Lodní rampu 164 964 znaků) a k tomu wordCount, pages, enoughExtractedText, blurredPages — přebíráme i příznak rozmazaných stránek, protože znamená „text nebude úplný“.

  • Strop stránkování: /api/v2/smlouvy/hledat odmítne strana > 200 (400 Hodnota 'strana' nemůže být větší než 200). Jeden dotaz tedy unese 5 000 záznamů, což na roční okno zatím stačí (naměřeno: nejvíc má město 3 731 smluv v roce 2025 → 150 stran).

  • Řazení při stránkování je nestabilní a vynechává záznamy. Výchozí razeni=0 řadí podle relevance počítané za běhu; u shodných skóre chybí stabilní tie-break, takže se pořadí mezi dvěma požadavky posune, jeden záznam na hranici stránek se vrátí dvakrát a jiný vypadne. Naměřeno: jeden běh přečetl „všech 1028 řádků“ pro rok 2019, ale část přeskočil a další běh hlásil 7 „nových“, zveřejněných už 2019. Ani razeni podle data uzavření to nespraví – stovky smluv mají shodné datum, takže tie-break může být nestabilní pořád.

  • Řešením není „lepší řazení“, ale nestránkovat. Úplnost je zaručená jen tam, kde API nestránkuje, tj. když se celé okno vejde do jedné odpovědi (results pokryje total): pak se řazení mezi požadavky nemohlo posunout. Sběr proto okna půlí (rok → měsíc → … → den), dokud se do jedné odpovědi nevejdou. Naměřeno na skutečných datech: 97,3 % dnů má ≤ 25 smluv, takže se do jedné stránky vejde (nejhustší den má 89). Cena: místo ~1 100 dotazů na celou historii (~1,4 h) jich je ~6 300 (~8 h) – za garantovanou úplnost.

  • Hustý den se nevejde ani po dělení na jediný den (v datech 87 z 3 160 dnů). Tam se dočítá stránkováním s konvergencí (opakování průchodů, dokud nepřibude nic nového) a běh o tom napíše souhrn – tam je úplnost jen ověřená, ne dokázaná, a je lepší o tom vědět než to tiše ztratit.

  • Kdyby API razeni odmítlo, běh se o tom ohlásí a spadne na výchozí (úplnost i tak stojí na dělení oken, ne na řazení).

  • Stránkovaný dotaz vrací tytéž smlouvy víckrát (duplikáty i uvnitř stránky) — sběr je deduplikuje podle id, takže se do dat ani do počtu nedostanou.

  • Rate limit je bucketový a nižší než dokumentovaný. Dokumentace uvádí 4 req/s, ale měřeno (20 požadavků za sebou, na jednu IP a token):

    pauza výsledek
    0,0 s 15× 200, 5× 429 (2,5 req/s, ale s chybami)
    0,5 s 20× 200, 0× 429 (1,6 req/s)
    1,0 s 20× 200, 0× 429 (0,9 req/s)
    1,5 s 20× 200, 0× 429 (0,6 req/s)

    Při nulové pauze limit padne po ~4 rychlých požadavcích. Výchozí --pause-hlidac je 0,6 s — nad naměřenou bezpečnou hranicí 0,5 s, takže krátkodobé 429 by neměly vznikat vůbec.

  • Pozor: kromě krátkodobého limitu má API ještě hodinovou kvótu, a to je úplně jiná veličina. Naměřeno na reálném běhu: po ~800 požadavcích v jedné hodině API vrátí 429 a hodinu nepustí nic z /smlouvy/* (Retry-After roste z 60 na 3600). /api/v2/Check přitom odpovídá dál, takže ban je na datech, ne na tokenu.

    • Kvóta se nedá odečíst — API neposílá žádnou rate-limit hlavičku.

    • Okno je vázané na celou hodinu (wall clock), ne „3600 s od chyby“: ban z 13:33 držel ještě v 13:51 a povolil po 14:00.

    • Retry-After se nezkracuje a běh hodinu počká. Klient dostane-li Retry-After ≥ 60 s, přečká ho celý a pak pokračuje tam, kde skončil. Během čekání neposílá nic (každý požadavek by kvótu znovu nastartoval) a do logu píše řádek každých 5 minut:

      Hlídač: vyčerpaná hodinová kvóta (Retry-After 3600s) – čekám do 15:12:34, pak pokračuji od /api/v2/smlouvy/hledat
      Hlídač: stále čekám na kvótu – zbývá 55 min
      Hlídač: stále čekám na kvótu – zbývá 50 min
      Hlídač: kvóta vypršela, pokračuji
      
    • Takže jeden běh zvládne i víc hodinových kvót za sebou; není třeba ho opakovat ručně. Nad 2 h by čekání bylo nesmyslné, tam se běh ukončí (KvotaVycerpana).

    • Běh přežije odpojení terminálu. SIGHUP/SIGTERM se ignorují (cbweb.nastav_signaly, volá se v run_cli, takže pro všechny skripty), protože dřív při odpojení SSH proces tiše zmizel — v logu byl jen poslední řádek a konec, bez chyby i bez „přerušeno uživatelem“. Ukončit běh lze dál Ctrl+C (SIGINT), SIGQUIT nebo SIGKILL. Přesto je lepší spouštět dlouhý sběr v tmux/screen nebo přes nohup — ignorovaný SIGHUP je pojistka, ne náhrada za správné spuštění.

    • --rozpocet (výchozí 0 = bez limitu) běh dobrovolně zastaví po N požadavcích, kdyby bylo potřeba krátké dávky. Při zastavení uložené smlouvy zůstávají a další běh naváže (_zdroj_uz_je hotové přeskočí).

  • Ověřeno na reálném běhu: celý rok 2026 (1262 výsledků API = 1020 unikátních smluv, Hlídač vrací tytéž smlouvy víckrát) za 3,6 minuty: 313 nových + 949 přeskočených, 0 chyb, 0× 429.

  • Kolik to trvá: město má 12 625 záznamů (celá historie; unikátních smluv je míň). S pauzou 0,6 s to je ~505 požadavků na metadata bez textů (~5 minut) a ~13 100 požadavků s texty. Texty jsou jen v detailu — /smlouvy/hledat prilohy nevrací (ověřeno: 0 z 25 i u smluv, které text mají), takže každá smlouva chce jeden požadavek navíc. Při kvótě ~800/h to s texty znamená jeden běh, který ~17× počká na kvótu (~17 h); rychlejší je vzít texty z PDF Registru smluv (viz „Texty bez kvóty“) nebo sbírat jen metadata (--bez-textu).

  • Strop stránkování: /api/v2/smlouvy/hledat odmítne strana > 200 (400 Hodnota 'strana' nemůže být větší než 200). Jeden dotaz tedy unese 5 000 záznamů, což na roční okno zatím stačí (naměřeno: nejvíc má město 3 731 smluv v roce 2025 → 150 stran).

  • Řazení při stránkování je nestabilní a vynechává záznamy. Výchozí razeni=0 řadí podle relevance počítané za běhu; u shodných skóre chybí stabilní tie-break, takže se pořadí mezi dvěma požadavky posune, jeden záznam na hranici stránek se vrátí dvakrát a jiný vypadne. Naměřeno: jeden běh přečetl „všech 1028 řádků“ pro rok 2019, ale část přeskočil a další běh hlásil 7 „nových“, zveřejněných už 2019. Ani razeni podle data uzavření to nespraví – stovky smluv mají shodné datum, takže tie-break může být nestabilní pořád.

  • Řešením není „lepší řazení“, ale nestránkovat. Úplnost je zaručená jen tam, kde API nestránkuje, tj. když se celé okno vejde do jedné odpovědi (results pokryje total): pak se řazení mezi požadavky nemohlo posunout. Sběr proto okna půlí (rok → měsíc → … → den), dokud se do jedné odpovědi nevejdou. Naměřeno na skutečných datech: 97,3 % dnů má ≤ 25 smluv, takže se do jedné stránky vejde (nejhustší den má 89). Cena: místo ~1 100 dotazů na celou historii (~1,4 h) jich je ~6 300 (~8 h) – za garantovanou úplnost.

  • Hustý den se nevejde ani po dělení na jediný den (v datech 87 z 3 160 dnů). Tam se dočítá stránkováním s konvergencí (opakování průchodů, dokud nepřibude nic nového) a běh o tom napíše souhrn – tam je úplnost jen ověřená, ne dokázaná, a je lepší o tom vědět než to tiše ztratit.

  • Kdyby API razeni odmítlo, běh se o tom ohlásí a spadne na výchozí (úplnost i tak stojí na dělení oken, ne na řazení).

  • Stránkovaný dotaz vrací tytéž smlouvy víckrát (duplikáty i uvnitř stránky) — sběr je deduplikuje podle id, takže se do dat ani do počtu nedostanou.

  • robots.txt Hlídače zakazuje /api/ crawlerům; API s tokenem je zamýšlený způsob použití dle dokumentace. HTML stránky Hlídače se nestahují.

Texty bez kvóty (PDF z Registru smluv)

Když kvóta Hlídače nestačí na texty všech smluv, je tu cesta mimo něj:

  1. Metadata + odkazy na přílohy vzít z ISRS dumpu. Oficiální otevřená data (/stranka/otevrena-data): data.smlouvy.gov.cz/dump_<YYYY>_<MM>.xml (masku i velikosti potvrzuje index.xml, který na stejném serveru vypisuje velikostDumpu a hashDumpu). Dump má subjekt/smluvniStrana s IČO, predmet, datumUzavreni, cisloSmlouvy, hodnoty i <prilohy> s <odkaz> na soubor. Texty v dumpu nejsou — jen metadata a odkazy.
  2. PDF stáhnout přímo a udělat text lokálně. Přílohy jsou veřejné a stahují se bez tokenu i bez kvóty — ověřeno: https://smlouvy.gov.cz/smlouva/soubor/<id>/<nazev> vrátí %PDF- (1,5 MB) bez jediného 429. Odkaz nese prilohy[].odkaz z Hlídače i <odkaz> z ISRS dumpu, text pak dělá pdftotext (stejně jako u --web).

Hlídačovy vlastní dumpZip texty neobsahují (jen id + lastUpdate), na texty tedy nestačí. Pozor: ISRS server je občas vlažný (connection reset, SSL handshake timeout) — chce opakování a delší timeouty.

Web města — WAF a past na výchozí filtr

  • Správná cesta je /smlouvy-uzavrene-mestem-ceske-budejovice; zkrácené /smlouvy vrací 404.
  • Výchozí filtr je poslední měsíc (od/do předvyplněné na aktuální období). Bez explicitního rozsahu by sběr tiše viděl jen ~69 záznamů a všechno starší přeskočil. Proto se vždy posílá explicitní rozsah.
  • Stahuje se po měsících, ne po letech. U ročního rozsahu web lže v pageru (tvrdí jednu stránku) a výsledek se „utne“ uprostřed — naměřeno: za rok 2026 vracelo 1000 smluv, aniž by skončilo, a stránky nešly spolehlivě uzavřít. Měsíční okno je malé a uzavře se zacyklením (např. 2026-03 = 157 smluv na 7 stranách, 2026-08 = 93 na 4). Průchod je proto pro každý rok → pro každý měsíc, s hlášením souhrnu za měsíc i za rok.
  • Filtry: IC (IČO protistrany), nazev, typ, odbor, kratkodobe, cislo; stránkování ?page=N, 25 na stránku.
  • IC filtruje protistranu, ne město. Stránka už obsahuje jen smlouvy města, takže IC=00244732 (IČO města) je nesmysl: hledá smlouvy, kde je město protistranou samo sobě, a vrací náhodný vzorek (za 2026 naměřeno 11 záznamů, z toho jen 7 v nefiltrovaném výpisu — výsledek tedy ani není podmnožina). Sběr proto chodí bez IC; --ico se na webu hodí jen cíleně na dodavatele (--ico 25166115 = Dopravní podnik). Pro srovnání: nefiltrovaně má web za 2026 625 záznamů.
  • Stránkování se hlídá proti zacyklení: kdyby některá stránka vrátila totéž (u IC se to děje), sběr skončí, místo aby tytéž smlouvy hlásil 201× jako „přeskočeno“.
  • PDF není v HTML seznamu, ale na detailu jako /smlouva/document/{a}/{b} (např. /smlouva/document/999420/849268, 5,4 MB, application/pdf).
  • robots.txt uvádí Crawl-delay: 10; výchozí --pause je proto 15 s (rezerva). Souběžné požadavky jsou zakázané — jeden běh, jedna smyčka. Po osmi selháních v řadě skript skončí s EXIT_BLOCKED.
  • Stažené PDF se kontroluje na magic bytes %PDF; web umí vrátit HTML pod PDF cestou.

Co je v datech dál

  • Dodatky (navazanyZaznam, souvisejiciSmlouvy) se ukládají, aby šlo spočítat navýšení ceny.
  • Verze záznamu (idVerze, platnyZaznam, znepristupnitPredchoziZaznam) — smlouva se dá změnit a zveřejnit znovu; změna je signál, ne duplikát.
  • Ceny: hodnota.neuvedena je true, když hodnota chybí; takové smlouvy se do součtů nepočítají (jinak by součty lhaly). Cizí měna se nepočítá do Kč.
  • Součty v index.md jsou podle „bez DPH“ a jen pro CZK.

Když se „nic nestahuje“

Sběr je přírůstkový: co už má zdroj web (resp. hlidac), se přeskočí. Souhrn po každém roce to řekne rovnou:

rok 2026: 625 nalezených smluv → 0 nových, 625 přeskočeno (už byly), 0 chyb
(vše už bylo staženo – pro opětovné stažení přidej --force)

Když je potřeba vidět proč (a co se kde uložilo), je tu --debug:

— web: smlouvy města (bez filtru IČO), rok 2026
    · GET …/smlouvy-uzavrene-mestem-ceske-budejovice?…&page=0
    ·   stránka 1: 25 řádků v tabulce
    ·   nových (ještě neviděných): 25
    ·   → 2024001079 | Odkoupení staveb ZTV … | Lidl Česká republika s.r.o. | 26.01.2026
    ·     přeskočeno: 2024/2024001079 už má zdroj 'web'
    ·     výsledek: preskoceno

Užitečné kombinace při ladění:

# co je v archivu města za rok (a co z toho ještě nemáme)
python3 fetch_smlouvy.py --web --roky 2026 --debug

# vynutit opětovné stažení (přepíše i hotové záznamy)
python3 fetch_smlouvy.py --web --roky 2026 --force

# jen pár smluv na vyzkoušení
python3 fetch_smlouvy.py --web --roky 2026 --limit 3 --debug

Časté příčiny prázdného běhu: rok je v archivu bez nových smluv; --limit se vyčerpal přeskočenými; nebo je smlouva uložená z Hlídače a zdroj web k ní teprve doplňujeme (pak se nestahuje znovu celá, jen se přidá web).

Sběr ze dvou strojů a sloučení

Sběr se dá rozdělit — např. --web na jednom stroji a --hlidac na druhém — a výsledné stromy sloučit. rsync na to nestačí: smlouva.json je jeden soubor na smlouvu, takže kopie přes sebe jeden zdroj přepíše. Ověřeno: po rsync zůstal jen web a hlídačský záznam zmizel (a stejně tak --ignore-existing nebo --update zahodí ten druhý).

Místo kopírování se stromy slévají příkazem smlouvy.py --sluc:

# stroj A (web města: bez --ico, stránka je už jen o městě)
python3 fetch_smlouvy.py --web --rok-od 2026 --rok-do 2026

# stroj B (Hlídač: klidně současně, jiné API a jiný limit)
python3 fetch_smlouvy.py --hlidac --rok-od 2026 --rok-do 2026

# přenos: stačí protistrana zkopírovat (rsync na holý strom je v pořádku)
rsync -av serverA:~/cb_radnice/data/smlouvy/ /tmp/smlouvy-A/

# sloučení do jednoho stromu (doplní zdroje, PDF i texty, které chybí)
python3 smlouvy.py --out data/smlouvy --sluc /tmp/smlouvy-A/

--sluc u každé smlouvy:

  • chybí-li v cíli → zkopíruje ji celou (včetně dokumenty/ a text/)
  • existuje-li → slije záznamy zdrojů (web + hlidac), doplní soubory, které v cíli nejsou, přepočítá párování a souhrn (protistrany, hodnota)
  • je idempotentní: druhý běh hlásí 0 doplněných

Přeskočení je odvozené od zdroje, ne od souboru. Když na stroji B leží smlouva.json vytvořený na stroji A (jen web), běh --hlidac ji doplní místo aby ji přeskočil. To je přesně to, co dělá dvoustrojový sběr použitelným — a stejně tak se dají texty dohnat později (--hlidac na existujícím stromě doplní jen to, co chybí).

Pozn.: smlouvy bez městského čísla (objednávky OBJ/1080/2024/100) nemají společný klíč, takže je web a Registr smluv spárují jen přes IČO + datum + předmět (a když to nejde, zůstanou dva záznamy s poznámkou „posuzuj ručně“). Naopak dodatky a verze (2024001476/1, 2017002182/Dodatek č. 4) mají městské číslo jako prefix a párují se přes cislo_zaklad.

Texty

Pořadí zdrojů textu se ukládá (text.*.zdroj):

  1. hlidac — hotový text z Hlídače
  2. pdftotext — lokální extrakce z PDF
  3. (OCR záměrně není — Hlídač text včetně OCR dodává)

Ke každému textu se vede kontrola kvality: počet znaků a slov a příznak podezrely (< 200 znaků), aby se „nemáme text“ nepletlo s „text se nepovedl“.

Úřední deska (fetch_deska.py, edesky.py)

Čtvrtá doména vedle zasedání a smluv: úřední deska města (/uredni-deska). Na rozdíl od ostatních zdrojů je nestálá — položky se po uplynutí doby vyvěšení z webu odstraňují a archiv na webu neexistuje (/uredni-deska/archiv → 404). Sběr proto musí být průběžný, ideálně denně; co se nestihne, je nenávratně pryč.

Co na desce je

~200 aktuálně vyvěšených položek, 50 na stránku. Každá má nadpis, vyvěšeno od–do, kategorii a instituci:

Hodnoty (ověřeno)
Kategorie Exekuce–dražby, Veřejné vyhlášky, Prodej, Pronájmy, Oznámení, Rozhodnutí, Veřejná vyhláška, Doručení veřejnou vyhláškou, Volby, Grantová témata
Instituce 27 odborů a organizací — Stavební úřad, Odbor dopravy, Finanční odbor, SPRÁVA DOMŮ s.r.o., KŠZ p.o., …

Detaily jsou převážně PDF (výjimečně .doc): povolení staveb, přechodné úpravy provozu, dražební vyhlášky, prodeje a pronájmy majetku, grantová témata. Často se objeví týdny před tím, než se o věci mluví v zastupitelstvu.

Příkazy

python3 fetch_deska.py                       # denně: nové položky
python3 fetch_deska.py --debug               # + každá položka a důvod přeskočení
python3 fetch_deska.py --force --limit 5     # přepsat i hotové (na vyzkoušení)

python3 edesky.py                            # doplní OCR texty z edesky.cz
python3 edesky.py --stran 20                 # + historie (543 stran celkem)
Přepínač Význam
--out DIR kořen (výchozí data/deska)
--pause S prodleva (výchozí 15 s; robots.txt města uvádí Crawl-delay: 10)
--limit N jen N položek
--debug detailní log
--force přepsat i hotové

edesky.py má navíc --stran N (kolik stránek historie), --pause (5 s) a --pause-txt (2 s).

Co jsme zjistili měřením

  • Klíč položky je Drupal node ID (node-300834), ne nadpis — ten se dá upravit. Rok se bere z data vyvěšení.
  • Kategorie a instituce jsou jen v řádku tabulky, na detailu chybí → parser je bere ze seznamu a do deska.json je předává.
  • Přílohy jsou i .doc, ne jen PDF (ověřeno z._pronajmu_stavby_ztv_juvel.doc).
  • Skeny bez textové vrstvy jsou běžné (rozhodnutí stavebního úřadu) — pdftotext vrátí 1 znak, proto text.deska.podezrely: true.
  • edesky.cz zrcadlí desku ČB (dashboard_id=63), ale se zpožděním: čerstvé položky tam ještě nejsou. Má ale historii ~543 stran (~27 000 dokumentů od ~2011), kterou městský web nemá.
  • Párování edesky ↔ město jde přes název PDF souboru: .txt endpoint vrací orig_url s původním PDF na webu města (ověřeno na dio_16766-26.pdf).

Podmínky užití edesky.cz (důležité)

  • API nepoužíváme. VOP edesky zakazují poskytovat data třetím osobám („Klient není oprávněn údaje… dále úplatně či bezplatně poskytovat třetím osobám“) se smluvní pokutou 100 000 Kč za porušení; přístup je „testovací provoz“ a stránkování API vyžaduje potvrzení e-mailem.
  • Používáme jen veřejné HTML stránky (/desky/…, /dokument/{id}.txt). robots.txt edesky zakazuje /attachments/ — z toho se nestahuje nic.
  • Data z edesky jsou jen pro vlastní potřebu; publikovatelný archiv je z webu města. V deska.json je vazba edeska vedená jako evidenční.

Bot-wall (Anubis)

edesky.cz má JS proof-of-work ochranu („Making sure you're not a bot!“), která reaguje na User-Agent:

UA Výsledek
Mozilla/5.0 … (ten, co posílá Client na web města) bot-wall
Seznambot (uvedený v robots.txt s Request-rate: 10/10s) OK
bez UA OK

edesky.py proto posílá Seznambot a tempuje sám. Když se bot-wall přesto objeví, běh se zastaví s hlášením a zkusí se znovu později.

Kadence

Co Jak často Proč
fetch_deska.py denně položky po svěšení z webu mizí
edesky.py týdně doplní OCR texty a historii (edeska má zpoždění)
fetch_vybory.py týdně přibývají zápisy z jednání výborů a komisí
extract_text.py denně (přírůstkově) nové dokumenty mají dostat .md hned, ne až při ručním backfillu

Dotace a participativní rozpočet (fetch_dotace.py)

Pátá doména: dotace. Nedají se vzít z jednoho místa — město nemá stránku se seznamem příjemců (/dotace, /granty → 404). Skript proto bere čtyři zdroje a výsledky sčítá:

Zdroj Co dává Poznámka
PRO Budějce (probudejce.cz) projekty participativního rozpočtu WordPress REST API bez klíče; robot.txt zakazuje jen /wp-admin/
materiály ZM (data/zastupitelstvo) body jednání o dotacích fulltext v indexech, nic se nestahuje
smlouvy (data/smlouvy) příjemci, IČO, částka, rok vzniká z Registru smluv → je úplnější než materiály
web města (/dotace-mesta + 8 oblastí) pravidla a seznamy schválených žádostí stahují se jen seznamy, ne formuláře

PRO Budějce — REST API WordPressu

Ověřeno na živém API: posts vrátí 131 postů ve 2 stránkách (X-WP-Total: 131), media 570 souborů. Projekty jsou v posts; vlastní post typ neexistuje (/wp/v2/types → post, page, attachment, wp_block). Pozor: wp/v2/pages vrací 302 (smyčka přes sso-dot.c-budejovice.cz), takže se nestahuje.

uv run fetch_dotace.py --pb --debug        # participativní rozpočet
uv run fetch_dotace.py --mesto --debug     # dotace města (ZM + smlouvy + web)

Jak se pozná smlouva o dotaci

Město má 17 097 smluv, ale jen 2 003 z nich jsou dotace města jako poskytovatele. Filtr stojí na předmětu (smlouvy.norm_text):

  • pozitivní: poskytnutí (neinvestiční|investiční|finančního|…) (dotace|dotaci|příspěvku|grantu) nebo smlouva o dotaci,
  • holé Veřejnoprávní smlouva (180 smluv) se berou taky — u města jde vždy o dotaci/příspěvek (sport, kultura, sociální oblast, hasiči),
  • vyloučené (34 smluv): předmět zmiňuje žádost o dotaci, dotační management, právní pomoc ve věci vrácení dotace, systém pro správu dotačních projektů, kompletaci žádostí — město tu dotaci nedává, ale kupuje službu.

Navíc musí být město poskytovatel (zdroje.*.platce.je_nas), aby do seznamu nepronikly dotace, které město dostává od kraje.

Uspořádání dat

data/dotace/
├── index.json, index.md       # souhrn obou částí
├── pb/
│   ├── stranky/<N>.json       # surové stránky WP API (100 postů)
│   ├── <ROK>/posts.json       # posty podle roku
│   ├── media.json             # metadata medií (soubory se nestahují)
│   └── pb.md                  # čitelný přehled projektů
└── mesta/
    ├── <ROK>/body.json        # body ZM s dotacemi
    ├── <ROK>/smlouvy.json     # příjemci (číslo, IČO, hodnota, datum)
    ├── <ROK>/programy/        # stažené seznamy schválených žádostí
    └── programy.json          # stránky oblastí + všechny odkazy

Materiály ZM vs. smlouvy: materiály říkají, co se projednávalo (bod, důvodová zpráva, žádosti v PDF), smlouvy říkají, kdo kolik dostal. Proto se nevybírá jedno — obojí je ve výstupu a odlišuje je cesta.

Územní plán (fetch_uzemni_plan.py)

Samostatná stránka /uzemni-plan neexistuje (404); úřad ji má pod /rozvoj-mesta (sekce „Územní plánování“) a ta vede na /uzemni-planovani a /priprava-noveho-uzemniho-planu. Rozcestník se skládá z obou stránek.

Vlastní obsah změn je v materiálech ZM: každý návrh na pořízení změny, její vydání nebo úprava zadání ÚPnM je samostatný bod jednání s přílohami („Obsah Změny ÚPnM“, výřezy výkresů, ortofotomapa). Čte se z indexů (zasedani.py), takže se nic nestahuje.

uv run fetch_uzemni_plan.py --debug
uv run fetch_uzemni_plan.py --bez-webu        # jen z materiálů ZM

Filtr rozhoduje podle názvu bodu, ne příloh. Materiály k majetkovým dispozicím mívají mezi přílohami „Výřez z výkresu ÚPnM“ (mapu k prodeji pozemku), ale o změně plánu nejde — kdyby se hledalo i v přílohách, dostaly by se do změn ÚPnM desítky majetkových bodů (ověřeno na zasedání 2023005, body 27.04 a 25.07). Stejně se filtrují „Změna č. N Programového rámce IROP/OPŽP“, což o územním plánu není.

Dokumenty se nestahují — výkresy mají desítky MB. rozcestnik.json vede jen odkazy a index.md je vypisuje.

Archiv 106/1999 Sb. a generátor žádostí (fetch_106.py)

Město vede archiv poskytnutých informací na /archiv-poskytnutych-informaci-<ROK> (2011–2026 → 200; 2010 → 404). Tabulka má sice hlavičku Předmět | Odpověď | Ze dne, ale v HTML je různý počet prázdných buněk (2015 a 2021 mají jiný tvar než 2013), proto se nečte po sloupcích: předmět je první buňka, datum poslední a odkazy na odpovědi se berou z celého řádku.

uv run fetch_106.py --rok 2022 --debug     # jeden rok
uv run fetch_106.py --vse                  # všechny roky (2011–2026)
uv run fetch_106.py --zadosti              # texty žádostí (NIC neodesílá)

Ověřeno na roce 2022: 108 žádostí, 120 odkazů na PDF (k jedné žádosti bývá víc příloh). PDF se stahují — jsou malá (100–200 kB, největší 2,7 MB) a nesou obsah, který na webu jinde není (platy ředitelů škol, anonymizovaná rozhodnutí, přehledy kontrol).

zadosti.md — co město nezveřejňuje

Šest žádostí podle zákona č. 106/1999 Sb. (15denní lhůta, § 14 odst. 1). Každá má adresáta, důvod (co bylo ověřeno měřením) a požadovaný formát (CSV/JSON, ne PDF):

# Co žádat Proč to nejde dohledat
1 usnesení výborů a komisí nad rámec zápisů zápisy zveřejňované jsou, usnesení jako strojový výstup ne
2 zřizovací listiny škol a příspěvkových organizací web vede jen seznam škol, žádný dokument (ověřeno)
3 archiv úřední desky položky se po svěšení mažou, /uredni-deska/archiv → 404
4 rozpočet a plnění v CSV/JSON, účetní data PO GORDIC portál je v chybovém stavu, otevřená data nejsou
5 podklady k zakázkám malého rozsahu web zveřejňuje jen vítěze (/vitez-zakazky/<id>), ne nabídky
6 nesplněné úkoly a majetkové přehledy „Nevyřešené úkoly“ vyšly 4× jako příloha, majetek nikdy

Texty se jen generují. Odesílání, úhrady podle § 17 a komunikace s úřadem jsou věc uživatele — kód do toho nezasahuje.

Výbory zastupitelstva a komise rady (fetch_vybory.py)

Pozor na domněnku: zápisy výborů a komisí jsou veřejné. Průzkum (~/Documents/cb_samosprava) je vedl jako „nejde dohledat“ — to platilo pro dobu před vznikem stránek /financni-vybor, /kontrolni-vybor a stránek komisí. Měření na živém webu to vyvrátilo, proto se sbírají.

Kde Co
/vybory-zastupitelstva-mesta rozcestník na finanční a kontrolní výbor
/financni-vybor, /kontrolni-vybor 29 a 28 souborů (od 2022 + archivy minulých období)
/komise-rady-mesta rozcestník na komise rady
/kulturni-komise, /sportovni-komise, … 2–45 souborů na komisi (od 2022)
uv run fetch_vybory.py                  # všechny výbory a komise (jen nové)
uv run fetch_vybory.py --vybor          # jen výbory
uv run fetch_vybory.py --limit 3 --debug

Seznam orgánů se odvozuje z rozcestníků (ne z pevného seznamu), aby se nová komise začala sbírat sama. K zápisům se dělá textová vrstva (pdftotext -layout) jako u desky, takže se dají prohledávat fulltextem.

Dvě pasti webu (ověřeno)

  • Odkazy na orgány jsou absolutní (https://www.c-budejovice.cz/financni-vybor), ne relativní → parser musí brát obě podoby, jinak výbory vypadnou.
  • Dvě podoby odkazů na soubory: novější mají type="application/pdf; length=142566", starší (např. komise pro rozvoj metropolitní oblasti) mají jen onclick="window.open(…)" bez type i title → kdo čte jen type=, tomu jeden orgán zmizí (0 souborů).
  • Rok není vždy v cestě: novější /…/<orgán>/<ROK>/soubor.pdf, starší /…/filefield_paths/soubor.pdf. Rok se proto hledá v cestě, ve jménu i v popisku; když není nikde, je ? (nevymýšlí se podle data běhu).

Ukládají se ZIP archivy minulých volebních období (obsahují desítky zápisů); u ZIP se eviduje seznam členů, aby byl obsah dohledatelný bez rozbalení.

stav.json — co je veřejné a co ne

Vedle zápisů vzniká data/vybory/stav.json: přehled, co o výborech a komisích existuje a kde to hledat, bez stahování čehokoli dalšího.

Co Kde to je V datech
zápisy z jednání stránky orgánů na webu města data/vybory/<slug>/
zprávy o činnosti body jednání ZM (předkládají se zastupitelstvu) stav.json → zpravy (12 z 136 zásahů)
pozvánky na jednání úřední deska stav.json → deska (0 — na desce nejsou)
usnesení jako strojový výstup nikde → žádost v data/foi/zadosti.md

soubor u zpravy míří do domény ZM, u deska do domény desky — stav.json je rozcestník přes domény. Filtr organ je přísný: kmen orgánu musí stát těsně před slovem výbor/komise, takže se nechytají body o „dotačním programu na podporu sportu“ ani o „památkové rezervaci“, a procedurální „návrhová/volební komise“ se za orgán nepovažuje.

uv run fetch_vybory.py --out data/vybory
uv run fetch_vybory.py --vybor --limit 3 --debug

Textová vrstva dokumentů (extract_text.py)

Ke každému dokumentu v data/ (PDF, .doc, .docx, .xls, .xlsx, .rtf a všem členům ZIP archivů) vzniká vedle něj soubor <soubor>.<přípona>.md: YAML hlavička s odkazem na originál a URL, pak vlastní text. Dokumenty se dají prohledávat fulltextem a předávat LLM bez toho, aby každý nástroj musel umět PDF, Word a Excel.

uv run extract_text.py                     # přírůstkově celé data/ (4 paralelně)
uv run extract_text.py --jen zastupitelstvo --limit 20 --debug
uv run extract_text.py --souhrn            # jen statistika: co chybí
uv run extract_text.py --force             # přepsat i hotové
uv run extract_text.py --bez-ocr           # jen pdftotext (skeny odloží)
uv run extract_text.py --karantena karantena   # selhané zkopírovat stranou
Přepínač Význam
--root DIR kořen k procházení (výchozí data)
--jen DOMÉNA omezit na doménu (zastupitelstvo, rada, vybory, deska, foi, dotace, uzemni_plan); lze víckrát
--force přepsat i hotové .md
--bez-ocr neOCRovat skeny (jen pdftotext); sken se jen odloží, .md nevznikne
--ocr-mapy OCRovat i mapy/výkresy (výchozí vypnuto)
--bez-deskew vypnout --deskew u ocrmypdf (výchozí zapnuto)
--limit N jen N dokumentů (na vyzkoušení)
--jobs N kolik dokumentů paralelně (výchozí 4; ocrmypdf dostane -j 1)
--karantena DIR zkopírovat soubory, které se nepodařilo zpracovat, do DIR k pozdější analýze (viz níže)
--debug log každý soubor
--souhrn jen vypsat statistiku, nic nedělat
Formát Čím Poznámka
.pdf pdftotext -layout ~20 ms/soubor
.pdf bez textové vrstvy ocrmypdf -l ces sken; --deskew zapnuto, --rotate-pages na tomto stroji padá
.pdf z Wordu / už OCRované / podepsané ocrmypdf --force-ocr --invalidate-digital-signatures má vrstvu, ale pdftotext z ní dal < 200 znaků (naměřeno 329 B → 1005 B)
.doc catdoc bez -d (s ním selže na desítkách souborů); fallback antiword, pak LibreOffice
.docx zipfile + word/document.xml stdlib, bez závislosti na procesu
.xlsx openpyxl deklarováno v pyproject.toml (dřív byl jen v systémovém Pythonu)
.xls libreoffice --headless --convert-to csv vlastní profil, aby se souběžné běhy nepraly o zámek
.rtf ruční dekódování cp1250 unrtf ničí diakritiku, pandoc kódování neumí
.zip členové v paměti jeden <archiv>.zip.md, členové jako ## <člen>; jména členů bez UTF-8 příznaku se dekódují z CP852

pandoc se nepoužívá: .doc/.xls/.xlsx neumí a .rtf kazí.

Karanténa selhaných dokumentů (--karantena DIR)

Někdy potřebuješ zjistit, proč se dokument nepodařilo zpracovat, a mít originál po ruce. --karantena DIR zkopíruje každý takový soubor do DIR a položí k němu <soubor>.chyba.txt:

karantena/
└── deska/2026/300732-stanoveni-…/dokumenty/
    ├── web-2-….pdf
    └── web-2-….pdf.chyba.txt
puvodni_cesta: data/deska/2026/…/web-2-….pdf
popis: web-2-….pdf
chyba: ocrmypdf vrátil 2: UnsupportedImageFormatError
zdroj: ocr
zkopirovano: 2026-09-29

Karanténuje se jen skutečná chyba (meta["chyba"]), což je přesně ten stav, který běh počítá do chyb a hlásí !. Neznamená to „chybí .md" – .md se záměrně nepíše i u skenů odložených --bez-ocr (jinak by zablokovaly pozdější OCR) a u map/výkresů se naopak píše s preskoceno. Kdyby se karanténovalo podle chybějícího .md, naplnily by ji tisíce souborů, se kterými je vše v pořádku; naměřeno: sken s --bez-ocr i mapa/výkres karanténu nechají prázdnou.

Patří sem i kontejnery, které se nepodařilo vůbec otevřít – .zip, který není ZIP, a .msg, který není OLE2 (typicky přejmenovaný jiný formát):

! Foto 3.dwfx.zip: ZIP nelze přečíst: File is not a zip file
! MSG rozklad ceny položky č. 83.msg: MSG nelze přečíst: InvalidFileFormatError: not an OLE2 structured storage file

Dřív se takový soubor jen zalogoval a zmizel: neotevřený kontejner se nedostal mezi položky k opracování, takže se nepočítal do chyb, nedostal se do karantény a nikdy se to nezkusilo znovu. Teď je z něj chybová položka (Prace.chyba), takže projde karanténou i součtem.

Soubory se ukládají pod stromem zdroje (karantena/<cesta v data>/…), takže dva různé Smlouva.pdf z různých adresářů se nepřepíšou. Když jméno v karanténě už existuje (opakovaný běh), přidá se (2) jako všude jinde.

Do data/ se nic nezapisuje – jde o kopie vedle, aby zdroj zůstal čistý. Karanténu je vhodné mít v .gitignore (obsahuje binární dokumenty).

Co je v hlavičce

---
original: KP-ZM-404-2025-M-184.pdf      # u člena ZIPu navíc `#<člen>`
url: https://www.c-budejovice.cz/…      # když je známá (viz níže)
zdroj: pdftotext                        # pdftotext|ocr|catdoc|docx|openpyxl|libreoffice|rtf|zip
znaku: 12345
stran: 3                                # jen když je znám
ocr: false
kvalita: 0.94                           # podíl alfanumerických znaků
podezrely: true                         # jen když je text kratší než 200 znaků
preskoceno: mapa/výkres                 # jen u přeskočených
stazeno: 2026-09-26                     # kdy vznikl .md (ne datum stažení dokumentu)
---

kvalita a podezrely jsou pro člověka i LLM: pod ~0,7 jde obvykle o balast (špatný sken, graf) a textu nemá smysl věřit. Naměřeno: dobrý OCR sken ~0,94, mapa ~0,77.

Kdy se .md zapíše a kdy ne

Rozhoduje trvale v metadatech extraktoru:

Situace .md Proč
text je ano hotovo
sken odložený --bez-ocr ne jinak by .md zablokoval pozdější OCR bez --force
mapa/výkres ano, preskoceno: mapa/výkres OCR mapy nemá smysl, ať se to příště nezkouší
mapa + --ocr-mapy ano, text z OCR přepínač filtr vypne
OCR skenu selhalo (trvalá chyba: DecompressionBombError, UnsupportedImageFormatError, …) ano, preskoceno: OCR selhal: <výjimka> pdftotext dal aspoň razítko; ať se to neopakuje
OCR skenu selhalo přechodně (málo místa, timeout, proces zabit) ne při dalším běhu se zkusí znovu
OCR proběhl, ale nic nepřečetl (prázdný sken) ano, preskoceno: OCR nenašel text trvalý stav, ne přechodný
dokument nemá text (prázdný Word, tabulka bez buněk) ano, preskoceno: dokument nemá text trvalý stav, ne přechodný
na webu prázdný soubor (0 B) ano, zdroj: prazdny, preskoceno: na webu prázdný soubor web takové dokumenty má; chyba to není a nepatří do karantény
soubor má špatnou příponu (DOC/.doc, ale obsah je RTF/DOCX) ano rozhoduje obsah, ne přípona (viz níže)
archiv, kde aspoň jeden člen dal text ano, texty jako ## <člen> obsah archivu je jedna jednotka
archiv, kde některý člen čeká na OCR (--bez-ocr) ne stejné pravidlo jako u jednotlivého souboru
archiv, kde všechny členy přeskočeny natrvalo ano, preskoceno ať se archiv znovu nerozbaluje

Chybějící .md tedy znamená jen „ještě se nezpracovávalo nebo se čeká na OCR" — nikdy „hotovo bez textu".

.doc, kde je obsah jen jako obrázek

Některé .doc nemají textový obsah vůbec — celý dokument je sken vložený jako obrázek (zlibovaný EMF v WordDocument streamu). Naměřeno na rada/2011/30_2011/097/Žádost zhotovitele o přesun prací.doc (1,8 MB): WordDocument má textový rozsah 1536–2050, ale samé nuly, a catdoc, antiword i libreoffice --convert-to txt vrátí 0 znaků.

Řešením je převod .doc → PDF (LibreOffice) a pak OCR — dá 2081 znaků čitelného dopisu za ~15 s. Dělá to _doc_pres_ocr, ale jen jako poslední záchrana: zkusí se až po catdoc, antiword a tabulkovém čtení, takže běžné .doc se nezpracovávají drahou cestou (naměřeno: 3 dokumenty z deska/ za 0,25 s, bez OCR).

Formát se určuje podle obsahu, ne podle přípony

Web města posílá dokumenty pod špatnou příponou, takže se dřív posílaly do nesprávného extraktoru a hlásily se jako nezpracovatelné. Naměřeno na karanténě (49 souborů z rada/): z 18 .doc/.DOC bylo 8 ve skutečnosti RTF a 6 DOCX — catdoc na nich vrátil prázdno, i když text měly.

text_soubor proto příponu ignoruje a rozhoduje podle obsahu (cbweb.document_ext_soubor: PDF, RTF, ZIP→docx/xlsx/pptx, OLE2→doc/xls/msg). Stejně to funguje i pro členy ZIP archivu (text_z_bytes), protože ti se přes text_soubor také zpracovávají.

Prázdné soubory nejsou chyba

Web má dokumenty, které vydá jako 0bajtové (naměřeno: 29 .msg ze skeneru v rada/). Nejsou to vady ke zkoumání, takže se nehlásí jako chyba a nedostanou se do karantény — zapíší se jako zdroj: prazdny, preskoceno: na webu prázdný soubor (fetch_rada.py je v indexu stejně označuje „(na webu prázdný)").

Mapy a výkresy se neOCRují

Z 1633 skenovaných PDF je 892 (55 %) map, výkresů a fotek (pozná se to podle názvu). OCR na nich vyrábí balast — Hlavní výkres změny č. 62 ÚPnM.pdf dá 1001 znaků typu I, C;, NÍ PŘEDMĚSTÍ za 14 s. Takové soubory se zapíší s preskoceno: mapa/výkres; --ocr-mapy to zvrátí. Obrázky (.png/.jpg/.tif) se nechávají bez textu — jsou to náhledy map.

Když OCR skončí chybou (naměřeno: velkoformátový výkres 2381×8345 pt shodí rasterizaci na DecompressionBombError), uloží se místo chyby to, co dal pdftotext (obvykle razítko nebo podpis) a hlavička dostane preskoceno: OCR selhal: <výjimka>, aby se soubor nezkoušel při každém běhu znovu.

Šedá textura skenu shodí tesseract — vyhlazuje se

Některé skeny (Canon MC561) mají tečkovanou šedou texturu papíru, která rozhodí tesseract s výchozím --psm 3 (plná analýza stránky) natolik, že stránka trvá 4 m 51 s a vrátí 0 znaků — běh pak spadne na TIMEOUT_TESSERACT a soubor jde do karantény. Naměřeno na smlouvě rada/2017/16_2017/097 (3 stránky, 2,4 MB, bez textové vrstvy).

Řešení: medián 3×3 před OCR (tečkování zmizí, tahy písma zůstanou)

  • ImageOps.autocontrast. Stejná stránka pak dá 2,4 s a 2444 znaků; celý 3stranný soubor 4944 znaků za 9,5 s. Dělá to _predzpracuj_obrazek v záložní cestě pdftoppm + tesseract (_ocr_rucne); když Pillow chybí, obrázek se použije neupravený.

Časový limit OCR se počítá na stránku

Pevný limit 900 s na soubor nezvládal velké skeny. Smlouvy z roku 2010 mají 16–70 stran a ocrmypdf potřebuje 3,9–8,4 s/stránku, takže 70stranný sken se do 900 s nevešel a skončil v karanténě jako ocrmypdf vypršel po 900 s — přitom by se zpracoval za ~330 s samostatně.

Limit je proto podlaha + na_stranku × počet stran (~2300 s pro 70 stran) a při souběžném běhu se násobí odmocninou počtu úloh (čtyři procesy na osmi jádrech jsou ~2× pomalejší na soubor, ne 4×). Počet stran se bere z pdfinfo; když se nezjistí, platí podlaha.

Co je potřeba na serveru

ocrmypdf, tesseract (ces+eng), catdoc, poppler-utils (pdftotext, pdfinfo, pdftoppm), libreoffice, python3-openpyxl. Když některý chybí, běh nespadne — soubor se napočítá do chyb. pandoc, unrtf, pytesseract a antiword nejsou potřeba (testovány a zamítnuty, antiword jen jako záchrana pro .doc).

Dočasné soubory patří na disk, ne do /tmp

ocrmypdf i pdftoppm píšou mezivýsledky (rastry, přepsané PDF) do TMPDIR. Na tomto stroji je /tmp tmpfs s 1 GB — OCR velkého skenu se do něj nevejde, ocrmypdf spadne na OSError: [Errno 28] No space left on device a po padlém procesu zůstanou v /tmp stovky megabajtů, takže padá i další běh. extract_text.py proto nastavuje podprocesům TMPDIR=<repo>/tmp a všechny dočasné adresáře vytváří tam (_tmpdir). Když přesto dojde k chybě, rozlišuje se trvalá (DecompressionBombError, UnsupportedImageFormatError, InputFileError, EncryptedPdfError) od přechodné (vyčerpané místo, timeout): trvalá se zapíše do .md jako preskoceno, přechodná se nechá na příští běh.

URL originálu

Odkud se bere url: zastupitelstvo/rada z .files.jsonl, vybory z zapisy.json, deska z deska.json, foi ze zadosti.json. Dotace a ÚPnM URL ve svých JSONech nemají, proto zůstávají bez url (neodhaduje se).

Přírůstkovost a napojení na stahovače

Běh je přírůstkový (hotové .md se přeskočí), takže se dá kdykoli přerušit a pustit znovu. Stahovací skripty (fetch_zastupitelstvo.py, fetch_rada.py, fetch_deska.py, fetch_vybory.py, fetch_106.py) na konci main() volají extract_text.dopln_pro_adresar(sdir, args), takže text vzniká i u nově stažených dokumentů. Textová vrstva je odvozená data — dá se smazat a přegenerovat.

fetch_vybory.py má vlastní text/ (pdftotext do .txt, zdroj zapisy.json[*].text) — to zůstává, extract_text.py k tomu přidává .md vedle dokumentů.

Cena

~7300 dokumentů, z toho ~740 skenů na OCR (~11 s za 12stranný sken). Zbytek jsou milisekundy na soubor. Celkem jednotky hodin; --jobs 4 s ocrmypdf -j 1 (aby se procesy nepraly o jádra).

Telefonní seznam magistrátu (fetch_contacts.py, diff_contacts.py)

Denní snapshoty telefonního seznamu úřadu (/kontakt-telefonni-seznam-magistratu-vyhledavani, stránkovaný po 50 osobách) a z nich odvozený přehled změn mezi zaměstnanci.

uv run fetch_contacts.py     # stáhne dnešní snapshot, aktualizuje contacts.csv
uv run diff_contacts.py      # přepočítá osoby.json (příchody/odchody/přesuny)

Uspořádání dat

data/magistrat_contacts/
├── snapshots/cb_contacts_<RRRR-MM-DD>.csv   denní snapshoty (věrný přepis webu)
├── contacts.csv                             aktuální stav (poslední snapshot)
└── osoby.json                               přehled změn (viz níže)

Formát CSV je stejný jako u původního crawl.py: Name,Phone,Email, Department,Position,Address,Door, UTF-8, LF.

Proč původní crawl.py přestal fungovat (změřeno)

crawl.py bral první div.view-content na stránce a z něj čítal řádky osob. Web ale před seznam přidal informační pruh (platební portál), a ten je v DOM první. Skript tak čítal pruh místo osob: snapshoty 2026-09-24 a 2026-09-25 skončily jako 13 prázdných řádků místo 591 osob. Pozná se to podle nuly osob ve snapshotu; diff_contacts.py takové dny vyřazuje.

Nový fetch_contacts.py řádky osob poznává podle přítomnosti polí osoby (views-field-field-osoba-*), ne podle pořadí v dokumentu. Stránkuje, dokud je v pageru další stránka (reálně 13 stran), a prázdný výsledek odmítne zapsat – radši nechat stará data než uložit prázdno.

osoby.json — přehled změn a nejistota

Klíč Co obsahuje
aktualni kdo je v úřadu podle posledního snapshotu
nastupy, odchody kdo se objevil / zmizel, s oknem kdy to mohlo být
presuny komu se změnil odbor nebo pozice (z → na)
osoby na osobu: aktuální údaje, první/poslední výskyt, historie změn
snapshots období, vyřazené dny a mezery ve sběru
pocty souhrnná čísla

Snapshot je stav k jednomu dni, změna může být kdykoli mezi nimi. Každá událost proto nese okno kdy_od..kdy_do; presne: true znamená, že snapshoty jdou po sobě, presne: false znamená mezeru ve sběru a jen odhad okna. Kdo byl v úřadu už v prvním snapshotu, má prichod_neznamy: true (před začátkem pozorování) a mezi nástupy se neuvádí.

Naměřeno na datech 2024-11-07 … 2026-09-25 (435 použitelných snapshotů): 601 osob aktuálně, 183 nástupů, 161 odchodů, 87 přesunů. Mezi 2026-01-27 a 2026-09-24 je 241denní mezera (sběr neběžel) – události v ní mají jen odhad okna, ne přesné datum.

Identita osob (změřeno)

Klíčem je jméno; když má jedno jméno v jednom dni víc různých e-mailů (skuteční jmenovci), rozliší se e-mailem. Měřené důvody, proč to není e-mail: e-mail se recykluje (reditelna@msopletala.cz přešel z Lafatové na Cirhanovou) a u jednoho člověka se mění (masarj@cbsport.cz → masarj@szcb.cz), kdežto jméno je stabilní. Jeden člověk ve dvou rolích (stejný e-mail, dva odbory) se slije a odbory se spojí |.

Past: vnořený field-content

U městské policie má web prázdné pole s vnořenou třídou (class="field-content views-field-…-1"), takže naivní hledání class="field-content" přesně selhalo a vrátilo popisek („Organizace:") místo hodnoty. Parser proto připouští třídy za field-content.

Přepínače

Společné pro oba skripty:

Přepínač Význam
--out DIR kořenový adresář (rada data/rada, zastupitelstvo data/zastupitelstvo)
--year ROK rok; lze vícekrát
--session N číslo zasedání; u rady --session 17, u zastupitelstva --session 2026031
--pause S prodleva mezi stahováními v sekundách (výchozí 3)
--markdown-only jen přepsat indexy a anotace, nic nestahovat

fetch_rada.py navíc --cookies FILE; run_years.sh ROK ROK … stahuje více let za sebou a opakuje neúspěšné roky. Prostředím se ladí BLOCKED_WAIT a FAILED_WAIT (čekání mezi pokusy) a MAX_TRIES (strop pokusů na rok).

usneseni.py má vlastní trojici přepínačů: --out DIR (výchozí data/zastupitelstvo), --year ROK a --session N (oba lze vícekrát). Nic nestahuje, jen čte stažená data.

Opakované spuštění je bezpečné: co už je staženo, se přeskočí (stav drží .files.jsonl v adresáři zasedání), takže po přerušení stačí spustit znovu. Ve výpisu hledej chyb – počet neúspěchů.

Návratové kódy (run_years.sh se jimi řídí):

Kód Význam Co dělá run_years.sh
0 hotovo jde na další rok/zdroj
1 materiály ještě nejsou zveřejněné čeká 10 min a zkouší znovu
2 část dokumentů selhala čeká 10 min a zkouší znovu
3 web stahování zablokoval čeká 5 min a zkouší znovu
4 pro ten rok nejsou žádná zasedání přeskočí (trvalé)
5 nepřihlášený, nebo neočekávaná chyba skončí (opakování nepomůže)

Kód 5 je záměrně oddělený: bez něj by neodchycená výjimka v Pythonu skončila kódem 1, což znamená „zkus znovu", a běh by se zacyklil. cbweb.run_cli() proto neočekávané chyby překlápí na 5.

Opakování má strop. Jeden rok a zdroj se zkusí nejvýš MAX_TRIES-krát (výchozí 10). Když ani pak není hotový, run_years.sh ten rok přeskočí, na konci ho vypíše („hotovo, ale nedokončeno") a skončí kódem 6. Jeden vadný dokument tak nikdy nezacyklí běh na věky; stažené soubory zůstanou, takže další spuštění je přeskočí a zkusí jen to, co chybí. MAX_TRIES lze zvýšit přes proměnnou prostředí.

Co se počítá jako chyba (kód 2): stažení, které vrátí jinou chybu než 404/410, nebo odpověď bez konce (web umí poslat soubor bez Content-Length, který teče pořád dál). Ty se obvykle spraví dalším pokusem, proto se čeká a zkouší znovu.

Dokument, který na webu vůbec není (HTTP 404/410), se za chybu nepočítá. Web u části materiálů vede odkaz, ale soubor na serveru chybí. Takový dokument se přeskočí, v logu se ohlásí chybí na webu a v indexu .md zůstane u položky — *selhalo: soubor na webu není (HTTP 404)*. Kdyby se počítal jako chyba, run_years.sh by se kvůli jednomu trvale chybějícímu souboru opakoval pořád dokola a nikdy neskončil. Chybová stránka se nikdy neuloží jako dokument.

403 se za chybějící soubor nepovažuje: web jím odpovídá i na blokaci (WEDOS „Intrusion Prevention Violation"), takže by se platný soubor omylem označil za trvale chybějící. Blokace se opakuje podle kódu 3.

Prázdný soubor (200 s nulovou délkou) chyba není – web takové dokumenty má, uloží se a v .md jsou označené *(na webu prázdný)*.

Nedostupné audio se za chybu nepočítá – jen se poznamená, protože web u starších zasedání odkazy na audio uvádí, i když soubor neexistuje.

Materiály se zveřejňují až později. Zasedání se v přehledu objeví dřív, než k němu visí materiály – takové zasedání má jen odkaz „Program" (např. 19. zasedání rady 2026 v den konání). Skript to pozná a vrátí 1; run_years.sh pak zkusí za 10 minut znovu. Když je naopak problém v přihlášení, hlásí to výslovně a skončí kódem 5 – tam je potřeba vygenerovat nové cookies a běh spustit znovu.

Když web stahování zablokuje (TLS chyby u několika požadavků v řadě), skript se zastaví s návratovým kódem 3 místo aby donekonečna opakoval požadavky. Blokace obvykle povolí během minut; pak stačí skript spustit znovu a doplní se jen chybějící soubory.

export_cookies.py

Přečte cookies z běžícího prohlížeče přes DevTools protokol, takže funguje i pro profily, jejichž cookies jsou zašifrované klíčem z OS keyringu. Prohlížeč musí být spuštěný a přihlášený. Potřebuje aiohttp a používá se jen pro radu.

python3 export_cookies.py --out cookies.txt          # najde prohlížeč sám
python3 export_cookies.py --port 36131               # konkrétní DevTools port
python3 export_cookies.py --all-hosts --out all.txt  # všechny cookies

Web a jeho ochrana

  • Web je za WEDOS Global Protection (CDN). Občas vyžádá ověření prohlížeče („ATP challenge", ALTCHA) a místo stránky vrátí 401 s HTML výzvou. Skript si toho všimne, vyřeší proof-of-work (najde číslo, jehož SHA-256(salt + číslo) dá zadaný hash) a požadavek zopakuje. Ověření je vázané na IP adresu, ne na cookie, takže pak platí pro celý běh.
  • Přehled zasedání rady i stránky s jejími materiály vidí jen přihlášený uživatel (nepřihlášený dostane 403/401 a v přehledu chybí odkazy). Zastupitelstvo je veřejné.
  • Server omezuje rychlost. Při paralelním stahování nebo bez prodlev začne vracet TLS chyby, pak 401/502 a blokace trvá desítky sekund až minuty. Proto se stahuje postupně s --pause (výchozí 3 s).
  • Web občas pošle odpověď bez Content-Length, která nikdy neskončí (jedno „PDF" u zastupitelstva teklo přes 200 MB). Stahování se proto zastaví podle času i velikosti, aby nezaplnilo disk. Limit je 1000 MB / 600 s, protože zvukové záznamy zasedání mají běžně 200–350 MB (nejdelší přes 600 MB).
  • curl -OJ sám nestačí: část dokumentů nemá Content-Disposition (ukládaly by se pod UUID), názvy mohou obsahovat /, přesáhnout limit 255 bajtů (curl pak selže s chybou 23) a dva dokumenty v jednom bodě mohou mít stejný název. Řeší to název z popisku odkazu, doplnění přípony podle obsahu a rozlišení (2).
  • Některé „dokumenty" jsou jen krátké poznámky („uloženo v sekretariátu RM/ZM") – ukládají se jako .txt. Některé jsou na webu opravdu 0 bajtů; uloží se a v .md jsou označené *(na webu prázdný)*.
  • Web se v čase mění: dokumenty i audio přibývají (třeba zápis se zveřejní až později) a někdy i mizí. Chybějící zápis se jen zapíše do logu (zápis zatím na webu není) a běh pokračuje; při dalším spuštění se nové soubory doplní. Stav drží .files.jsonl, a když v něm záznam chybí, ale soubor na disku je, jen se záznam doplní (nestahuje se znovu).
  • Nepoužívej hromadné „Vytvořit archiv" na webu – stahujeme jednotlivé soubory.

Pravidelné aktualizace (cron_daily.sh, cron_weekly.sh)

Dva skripty pro cron — denní a týdenní. Rozdělení je podle toho, jak často se zdroj mění, ne podle skriptu:

Skript Co Proč tak často
cron_daily.sh úřední deska, zápisy výborů a komisí, PB, textová vrstva dokumentů položky desky se po svěšení mažou — co se nestihne, je nevratně pryč; .md chybí u nových dokumentů
cron_weekly.sh edesky, ÚPnM, FOI, dotace města, smlouvy (Hlídač), usnesení mění se v dávkách (zasedání, dotační kola)
# crontab -e
20 5 * * *  /home/user/projects/cb_radnice/cron_daily.sh
10 4 * * 1  /home/user/projects/cb_radnice/cron_weekly.sh

Proč skript v cronu nefungoval

Tři chyby, každá sama stačila k neúspěchu (všechny reprodukované):

  1. uv není v cronově PATH. Cron spouští s /usr/bin:/bin, ale uv je v ~/.local/bin. Skript skončil hned na prvním řádku kódem 127 (uv: command not found).
  2. Fallback PY=(...) byl mrtvý kód. Skript sice PY nastavil, ale na posledním řádku volal uv run natvrdo — proměnná se nikdy nepoužila.
  3. >> cron_daily.log chytá jen stdout. Chyba „command not found“ jde na stderr, takže v logu nebylo nic — odtud dojem „nějak to nefunguje“, i když to selhalo okamžitě.

Opraveno: PATH se rozšíří o ~/.local/bin, použije se uv když je k dispozici a jinak systémový python3 (skripty potřebují jen stdlib — curl, pdftotext a ffmpeg jsou v /usr/bin), a do logu jde stdout i stderr s časovými značkami. Log se při růstu nad 6000 řádků zkrátí na posledních 4000.

Jak to ověřit

# přesně to prostředí, ve kterém to dřív selhávalo:
env -i PATH=/usr/bin:/bin HOME="$HOME" bash cron_daily.sh; echo "exit=$?"
# → musí doběhnout (ne 127) a v cron_daily.log musí být i "=== konec"

Bez uv to funguje taky (skripty jsou stdlib-only, ověřeno i pod systémovým Pythonem 3.14). Když něco selže, je to v logu jako !!! návratový kód N: <příkaz> a skript vrátí nenulový kód — cron to pozná.

Zakomentované řádky v obou skriptech jsou pro zdroje, které ještě nedoběhly nebo se mění méně často. Odkomentuj je, až budeš chtít.

Stahování na serveru v tmuxu

Skripty běží dlouho (stovky až tisíce souborů na rok) a web je občas na pár minut zablokuje, proto se hodí nechat je běžet v tmuxu. run_years.sh dělá roky jeden po druhém a blokace i chyby sám opakuje.

# na serveru: přenes projekt (data se přenášet nemusí) a cookies
rsync -av --exclude 'data/' --exclude '.venv/' \
  ~/projects/cb_radnice/ server:~/cb_radnice/

ssh server
cd ~/cb_radnice
tmux new -s rada

# rada města (potřebuje cookies.txt z přihlášeného prohlížeče)
uv sync
./run_years.sh 2022 2021 2020 2019 2018 2017 2016 2015 2014 2013 2012

# zastupitelstvo (bez přihlášení; stahuje i audio a anotace)
for y in $(seq 2026 -1 2014); do
  python3 fetch_zastupitelstvo.py --year "$y"
done

# smlouvy města: web (PDF) a Hlídač (texty) se doplňují do stejného stromu
python3 fetch_smlouvy.py --web --rok-od 2010 --rok-do 2026
python3 fetch_smlouvy.py --hlidac --rok-od 2010 --rok-do 2026

# úřední deska: denně (položky po svěšení mizí!), občas OCR z edesky
python3 fetch_deska.py
python3 edesky.py

# odpojení: Ctrl-b d   |   návrat: tmux attach -t rada

Zastupitelstvo má jen ~10 zasedání ročně (celkem ~100 od 2014), ale každé audio má desítky MB, takže se vyplatí spouštět po rocích.

Co je potřeba doplnit ručně:

  1. cookies.txt – jen pro radu. Zkopíruj vyexportovaný soubor z prohlížeče, kde jsi přihlášený (na serveru prohlížeč není). Platnost přihlašovací cookie je omezená (řádově týdny), po expiraci se přehlas v prohlížeči, znovu spusť export_cookies.py a soubor překopíruj.
  2. curl – skripty volají systémový curl.
  3. Pokud server nemá uv, stačí python3; aiohttp je potřeba jen pro export cookies a run_years.sh si sám vybere uv run python, nebo python3.

Data stažená jinde se přenášet nemusí – skripty si drží stav v .files.jsonl v adresáři zasedání a už stažené soubory přeskočí. Když ale nějaká data přeneseš, nic se nerozbije.

Přepis a hlasové otisky na GPU serveru

Přepis celého korpusu (~100 h audia) je na CPU otázkou dní (--beam 5 → rtf 3,2), na GPU je to hodiny. Skripty se přenášejí stejně jako stahování; data/ (tedy i prepis.json) se přenést musí – bez nich není co přepisovat ani z čeho stavět otisky.

rsync -av --exclude '.venv/' --exclude 'tmp/' \
  ~/projects/cb_radnice/ server:~/cb_radnice/
# data/ se přenést musí (audio + diskuse.json + prepis.json):
# rsync -av ~/projects/cb_radnice/data/ server:~/cb_radnice/data/

ssh server && cd ~/cb_radnice && tmux new -s prepis

# 1) závislosti: jeden `uv sync` nainstaluje i torch/torchaudio (z indexu
#    PyTorchu dle pyproject.toml – cu124). Ověř, že CUDA sedí s ovladačem:
uv sync
python3 -c "import torch; print(torch.__version__, torch.cuda.is_available())"
# Očekávej torch 2.5.1+cu124. Když je `is_available()` false nebo to hlásí
# „insufficient driver", zkontroluj `nvidia-smi` a verze v pyproject.toml
# (komentář „Torch a CUDA" vysvětluje, co který strop svazuje).

# 2) přepis celého korpusu na GPU (bez filtrů = vše, hotové se přeskočí)
python3 prepis.py --device cuda
# hotový prepis.json se přeskočí, takže běh je možné kdykoli přerušit
# (Ctrl-C) a znovu spustit – naváže tam, kde skončil.  Předělá se --force.

# 3) databáze hlasových otisků ze všech přepsaných zasedání
python3 hlasy.py enroll
python3 hlasy.py list

# 4) diarizace (gated model) – vyžaduje HF token a přijetí podmínek
export HF_TOKEN=hf_…
python3 hlasy.py identify --vstup zaznam.mp4 --start 0 --end 1800 --vystup identify.json
# odpojení: Ctrl-b d   |   návrat: tmux attach -t prepis

Kdy použít canary místo whisperu. --backend canary (nvidia/canary-1b-v2) na CUDA serveru obvykle dává lepší český přepis; potřebuje nemo_toolkit[asr] a GPU Ampere+:

pip install nemo_toolkit['asr']
python3 prepis.py --backend canary --device cuda --year 2026

Výsledek se ukládá do prepis.json s "backend": "canary", takže je poznat, čím byl přepis pořízen (--force přepíše i hotový přepis jiným backendem). Whisper a canary mají stejný výstup, takže se dají míchat zasedání od zasedání.

Přenos výsledků zpět (přenášejí se jen výstupy, ne audio):

rsync -av --include='*/' --include='prepis.json' --include='prepis.md' \
  --exclude='*' server:~/cb_radnice/data/ ~/projects/cb_radnice/data/
rsync -av server:~/cb_radnice/hlasy.json ~/projects/cb_radnice/

Poznámky k běhu na serveru:

  • auto vs cuda: --device auto vybere cuda, jen když je vidí (přes CTranslate2/torch), a teprve pak zvolí float16. Na serveru bez CUDA runtime ale --device cuda skončí chybou při načtení modelu – pak použij --device cpu --compute-type int8.
  • Paměť: přepis jde projev po projevu, takže na GPU stačí i menší VRAM. --full (VAD) je náročnější na RAM, ne na VRAM – řeší to blokový VAD (viz „--full a paměť“), takže špička zůstává ~2 GB. Na 16GB stroji by --full bez bloků skončil OOM killerem už u čtyřhodinového záznamu.
  • OOM killer bere největší proces v cgroup, takže při pádu vezme i shell nebo tmux. Když si nejsi jistý, spouštěj s ochranou: systemd-run --user --scope -p MemoryMax=11G --pty uv run prepis.py --full ….
  • HF_TOKEN patří do prostředí serveru (export, nebo tmux relace). Bez něj identify funguje bez diarizace – porovná embedding celého úseku.
  • Na serveru musí být ffmpeg (vyřezávání úseků).