- Python 92.8%
- Shell 7.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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ší.
|
||
| data | ||
| deploy | ||
| .gitignore | ||
| bench_embedding.py | ||
| cbweb.py | ||
| cron_daily.sh | ||
| cron_weekly.sh | ||
| diff_contacts.py | ||
| edesky.py | ||
| export_cookies.py | ||
| extract_text.py | ||
| fetch_106.py | ||
| fetch_contacts.py | ||
| fetch_deska.py | ||
| fetch_dotace.py | ||
| fetch_rada.py | ||
| fetch_smlouvy.py | ||
| fetch_uzemni_plan.py | ||
| fetch_vybory.py | ||
| fetch_zastupitelstvo.py | ||
| hlasy.py | ||
| hlidac.py | ||
| opravit_smlouvy_text.py | ||
| prepis.py | ||
| prepis_paralelne.py | ||
| pyproject.toml | ||
| README.md | ||
| run_years.sh | ||
| smlouvy.py | ||
| smlouvy_organizace.json | ||
| tabulka_usneseni.py | ||
| TODO.md | ||
| usneseni.py | ||
| uv.lock | ||
| zasedani.py | ||
| zplostit_kb.py | ||
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í zusneseni.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}]}]
}
cisloarokjsou z bloku… přijalo u s n e s e n í č. N/YYYYv sekciSoubor usnesení;cislojenullu nepřijatých a u syntetického lístku.bodje číslo bodu z narace zápisu;textje vlastní znění usnesení bez redundantní hlavičky (u nepřijatých celé znění zNepřijatá usnesení).predkladatelje jedno pole ve tvaruJméno Příjmeníbez titulů, nebonull. Bere se z hlavičky PDF materiálu (Předkládá:), a když materiál není, z narace (Materiál uvedl/a <osoba>).hlasovanije 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.rozhodujicioznačuje lístek, který odpovídá souhrnu(a,b,c,d/x)ze zápisu (a souhlasí sprijato).hlasyjsou jmenovité hlasy zHlasy.txt(symboly+ - X 0;A= nepřítomen se vynechává), nebonullkdyž jmenovité hlasy nejsou k dispozici.diskutujicishrnuje řečníky k danému bodu zdiskuse.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_hlasovalahlasovanise počítají jen ze jmenovitých hlasů (lístky sezdroj: "opendata"). Zasedání, kde opendata nejsou (2014, 2025024), přispějí jen dobody_diskuseacas_diskuse_s; kdo hlasoval jen tam, má nulu. Je to vidět v logu:chybí usneseni.jsonu zasedání bez zápisu.hlasovanije 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(symbol0– byl přítomen, ale netiskl) aniA(nepřítomen, v datech vůbec není). body_diskuseje počet bodů, ne projevů: kdo k jednomu bodu mluvil třikrát, má 1.cas_diskuse_sje 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_SaMINIMUM_Sjsou vusneseni.py.
- 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
body_navrhovateljsou body, kde je daný člověkpredkladatel. 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 jetorch.loaddefaultněweights_onlya modely pyannote se nenačtoupyannote.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:
.docxa.docbez textu — jsou to skeny vložené jako obrázky (DOCX má jenword/media/*.jpg,.docprojdecatdoc, ale nic nedá). Text z nich neudělá aniextract_text.py; chtěly by OCR obrázků. Naměřeno: 2017000294, 2017000437, 2017000448..msgje 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.pyproto.msgbere 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í:
- shodné
cisloSmlouvy— ověřeno, že Hlídač vede městské číslo smlouvy (2019002029,2026002062), takže se s webem shoduje přímo - shodné IČO protistrany a datum uzavření a podobný předmět
- 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í302na login. -
Licence
CC BY 3.0 CZ(info.licenseve 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émsmlouva.jsoni v obou.md. -
cisloSmlouvy= městské číslo smlouvy. To je nejlepší párovací klíč a ověřeno na konkrétních smlouvách. -
prilohy[].plainTextContentnese hotový text (u smlouvy na Lodní rampu 164 964 znaků) a k tomuwordCount,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/hledatodmítnestrana > 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. Anirazenipodle 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 (
resultspokryjetotal): 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
razeniodmí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-hlidacje 0,6 s — nad naměřenou bezpečnou hranicí 0,5 s, takže krátkodobé429by 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í
429a hodinu nepustí nic z/smlouvy/*(Retry-Afterroste z60na3600)./api/v2/Checkpř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-Afterse nezkracuje a běh hodinu počká. Klient dostane-liRetry-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/SIGTERMse ignorují (cbweb.nastav_signaly, volá se vrun_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álCtrl+C(SIGINT),SIGQUITneboSIGKILL. Přesto je lepší spouštět dlouhý sběr vtmux/screennebo přesnohup— ignorovanýSIGHUPje 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_jehotové 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/hledatprilohynevrací (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/hledatodmítnestrana > 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. Anirazenipodle 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 (
resultspokryjetotal): 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
razeniodmí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.txtHlí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:
- 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 potvrzujeindex.xml, který na stejném serveru vypisujevelikostDumpuahashDumpu). Dump másubjekt/smluvniStranas IČO,predmet,datumUzavreni,cisloSmlouvy, hodnoty i<prilohy>s<odkaz>na soubor. Texty v dumpu nejsou — jen metadata a odkazy. - 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ého429. Odkaz neseprilohy[].odkazz 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é/smlouvyvrací 404. - Výchozí filtr je poslední měsíc (
od/dopř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. ICfiltruje protistranu, ne město. Stránka už obsahuje jen smlouvy města, takžeIC=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í bezIC;--icose 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
ICse 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.txtuvádíCrawl-delay: 10; výchozí--pauseje 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čí sEXIT_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.neuvedenajetrue, 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.mdjsou 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/atext/) - 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):
hlidac— hotový text z Hlídačepdftotext— lokální extrakce z PDF- (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.jsonje předává. - Přílohy jsou i
.doc, ne jen PDF (ověřenoz._pronajmu_stavby_ztv_juvel.doc). - Skeny bez textové vrstvy jsou běžné (rozhodnutí stavebního úřadu) —
pdftotextvrátí 1 znak, prototext.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:
.txtendpoint vracíorig_urls původním PDF na webu města (ověřeno nadio_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.txtedesky 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.jsonje vazbaedeskavedená 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)nebosmlouva 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-vybora 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í jenonclick="window.open(…)"beztypeititle→ kdo čte jentype=, 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_obrazekv záložní cestěpdftoppm+tesseract(_ocr_rucne); kdyžPillowchybí, 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í
401s 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 -OJsá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.mdjsou 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é):
uvnení v cronově PATH. Cron spouští s/usr/bin:/bin, aleuvje v~/.local/bin. Skript skončil hned na prvním řádku kódem 127 (uv: command not found).- Fallback
PY=(...)byl mrtvý kód. Skript sicePYnastavil, ale na posledním řádku volaluv runnatvrdo — proměnná se nikdy nepoužila. >> cron_daily.logchytá 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ě:
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.pya soubor překopíruj.curl– skripty volají systémovýcurl.- Pokud server nemá
uv, stačípython3;aiohttpje potřeba jen pro export cookies arun_years.shsi sám vybereuv run python, nebopython3.
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:
autovscuda:--device autovyberecuda, jen když je vidí (přes CTranslate2/torch), a teprve pak zvolífloat16. Na serveru bez CUDA runtime ale--device cudaskončí 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 „--fulla paměť“), takže špička zůstává ~2 GB. Na 16GB stroji by--fullbez 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_TOKENpatří do prostředí serveru (export, nebotmuxrelace). Bez nějidentifyfunguje bez diarizace – porovná embedding celého úseku.- Na serveru musí být
ffmpeg(vyřezávání úseků).