Compare commits

..

22 Commits

Author SHA1 Message Date
Vladimir Buzalka e981659621 notebookvb 2026-06-19 11:30:52 +02:00
administrator e5315b821e z230 2026-06-18 12:15:57 +02:00
Vladimir Buzalka 19036b58cc notebookvb 2026-06-18 05:32:36 +02:00
administrator 0beaffec45 z230 2026-06-17 15:06:06 +02:00
administrator 26e44fc721 Merge remote-tracking branch 'origin/master' 2026-06-17 12:06:53 +02:00
administrator dc07e19179 z230 2026-06-17 11:53:54 +02:00
Vladimir Buzalka 45c32a37c4 notebookvb 2026-06-17 05:22:34 +02:00
Vladimir Buzalka 39d33b76f3 notebookvb 2026-06-16 19:38:33 +02:00
Vladimir Buzalka 6e4305e182 notebookvb 2026-06-16 19:38:25 +02:00
administrator 9edfddae95 z230 2026-06-16 17:56:47 +02:00
Vladimir Buzalka 9b6f89f437 notebookvb 2026-06-16 10:21:19 +02:00
Vladimir Buzalka 672ee26357 notebookvb 2026-06-16 06:48:35 +02:00
Vladimir Buzalka e23d61de84 Přejmenování Insurance/KdoJeLékař → KdoJeLekar (bez diakritiky)
Adresář přejmenován bez diakritiky kvůli problémům s kódováním cesty.
Historie zachována přes git mv. Nadpis v NOTES.md sjednocen s názvem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:10:59 +02:00
Vladimir Buzalka 79216dfbdb notebookvb 2026-06-15 18:07:32 +02:00
administrator 8142de5216 z230 2026-06-15 16:10:24 +02:00
Vladimir Buzalka 2bdac59676 notebookvb 2026-06-14 12:07:35 +02:00
Vladimir Buzalka 9133fe9497 notebookvb 2026-06-14 08:22:25 +02:00
Vladimir Buzalka 2346ad7739 notebookvb 2026-06-13 21:46:11 +02:00
administrator ca39622ddd z230 2026-06-12 16:28:02 +02:00
administrator bed5576efa z230 2026-06-12 15:32:22 +02:00
administrator 51ee67c7f3 z230 2026-06-10 09:25:19 +02:00
administrator f595e60d40 z230 2026-06-10 09:11:30 +02:00
104 changed files with 12102 additions and 53 deletions
+8
View File
@@ -30,9 +30,17 @@ __pycache__/
# Logy
*.log
# Dočasné zámkové soubory MS Office (vznikají při otevřeném Excelu/Wordu)
~$*
**/~$*
# Chrome profily (Playwright) — nikdy do gitu
**/chrome_profile/
# Cookies (session tokeny)
**/vozp_cookies.json
**/vzp_cookies.json
# Telegram user session (Telethon) — drží přihlášení, nikdy do gitu!
**/*.session
**/*.session-journal
Binary file not shown.
Binary file not shown.
+5
View File
@@ -41,6 +41,8 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
| `mysql_db.py` | — | Připojení a operace s MySQL databází |
| `medicus_db.py` | — | Připojení k databázi Medicus (Firebird) |
| `vzpb2b_client.py` | — | Klient pro VZP B2B API (stav pojištění) |
| `telegram_notify.py` | `posli_telegram()`, `zeptej_se_telegram()` | Notifikace a obousměrná komunikace přes Telegram **bota** (@Vlado_Claude_Bot) |
| `telegram_user.py` | `posli_jako_ja()`, `zeptej_se_jako()` | Komunikace přes plnohodnotný **user účet** agenta (Telethon, víc agentů = víc sessions) |
## Přehled skriptů
@@ -48,3 +50,6 @@ Import vždy přes `sys.path` na kořen projektu nebo přímou cestou.
|--------|---------|-------|
| `stahni_str8ts.py` | `SběrDatRůzné/DailyStr8ts/` | Stahuje daily Str8ts puzzle jako PDF, odesílá emailem — viz [NOTES.md](SběrDatRůzné/DailyStr8ts/NOTES.md) |
| `10_StahnoutXML.py`, `11_ParseXML.py` | `Recepty/NačteníPředpisuWithClaude/` | Pipeline pro stahování detailů receptů z eRecept SÚKL — viz [NacistPredpis_DOKUMENTACE.md](Recepty/NačteníPředpisuWithClaude/NacistPredpis_DOKUMENTACE.md) |
| `watcher.py` | `Webináře/` | Hlídá nové webináře na praktickylekar.online, přes Telegram potvrdí a přihlásí Buzalkovi — viz [NOTES.md](Webináře/NOTES.md) |
| `stahni_video.py` | `Video/` | Stahuje videa (Vimeo, YouTube…) přes yt-dlp; soukromá/nedostupná sám přeskočí — viz [NOTES.md](Video/NOTES.md) |
| `euni_stahni.py`, `euni_db.py`, `euni_report.py` | `Euni/` | Stahování kurzů z euni.cz (PDF + videa) s trackingem v MongoDB EUNI (idempotentní) — viz [NOTES.md](Euni/NOTES.md) |
+408
View File
@@ -0,0 +1,408 @@
# -*- coding: utf-8 -*-
"""
Načte DASTA XML soubory a uloží je do PostgreSQL databáze `ordinace`
do tabulek s prefixem `dasta_`.
Vše čistě v Pythonu přes psycopg (v3). Skript je IDEMPOTENTNÍ:
- databázi `ordinace` založí, jen pokud neexistuje
- tabulky vytvoří přes CREATE TABLE IF NOT EXISTS
- každý soubor se nahrává podle klíče = název souboru (bez přípony);
při opakovaném běhu se zpráva UPSERTne a její výsledky/diagnózy
se smažou a vloží znovu → výsledek je vždy stejný, žádné duplicity.
Připojení se bere z Medevio/.env (PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DB).
Použití:
python nahraj_do_postgres.py # zdroj = U:\\DASTA (výchozí)
python nahraj_do_postgres.py D:\\jine\\dasta # jiný zdrojový adresář
python nahraj_do_postgres.py U:\\DASTA --limit 50 # jen prvních 50 (test)
python nahraj_do_postgres.py --recreate # zahodí dasta_ tabulky a založí znovu
Tabulky:
dasta_pacient (rodne_cislo PK)
dasta_zprava (soubor PK) → pacient
dasta_vysledek (id PK) → zprava [jednotlivé analyty]
dasta_diagnoza (id PK) → zprava
"""
from __future__ import annotations
import os
import re
import sys
from datetime import date, datetime
from pathlib import Path
from xml.etree import ElementTree as ET
import psycopg
ZDROJ_VYCHOZI = Path(r"U:\DASTA")
# ---------------------------------------------------------------------------
# .env
# ---------------------------------------------------------------------------
def _load_env() -> None:
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
_load_env()
PG = dict(
host=os.environ.get("PG_HOST", "localhost"),
port=os.environ.get("PG_PORT", "5432"),
user=os.environ.get("PG_USER"),
password=os.environ.get("PG_PASSWORD"),
)
PG_DB = os.environ.get("PG_DB", "ordinace")
# ---------------------------------------------------------------------------
# Konverze hodnot
# ---------------------------------------------------------------------------
def num(s: str | None) -> float | None:
"""Český zápis čísla ('7,5') → float. Nečíselné vrací None."""
if s is None:
return None
t = s.strip().replace("\xa0", "").replace(" ", "").replace(",", ".")
try:
return float(t)
except ValueError:
return None
def ts(s: str | None) -> datetime | None:
"""'2016-06-20T11:15:18' nebo '2017-05-18T07:30' → datetime."""
if not s:
return None
try:
return datetime.fromisoformat(s.strip())
except ValueError:
return None
def dat(s: str | None) -> date | None:
if not s:
return None
try:
return date.fromisoformat(s.strip()[:10])
except ValueError:
return None
def _text(el, tag):
if el is None:
return None
c = el.find(tag)
return c.text.strip() if (c is not None and c.text) else None
# ---------------------------------------------------------------------------
# Schéma
# ---------------------------------------------------------------------------
DDL = """
CREATE TABLE IF NOT EXISTS dasta_pacient (
rodne_cislo text PRIMARY KEY,
jmeno text,
prijmeni text,
dat_narozeni date,
sex text
);
CREATE TABLE IF NOT EXISTS dasta_zprava (
soubor text PRIMARY KEY,
id_soubor text,
ozn_soub text,
dat_vytvoreni timestamp,
verze_ds text,
typ_odesm text,
zdroj_prog text,
zdroj_verze text,
odesilatel_icp text,
odesilatel_ico text,
odesilatel_nazev text,
prijemce_icp text,
prijemce_nazev text,
rodne_cislo text REFERENCES dasta_pacient(rodne_cislo)
);
CREATE TABLE IF NOT EXISTS dasta_vysledek (
id bigserial PRIMARY KEY,
soubor text NOT NULL REFERENCES dasta_zprava(soubor) ON DELETE CASCADE,
klic_nclp text,
nazev text,
jednotka text,
hodnota_raw text,
hodnota_num double precision,
dat_odber timestamp,
dat_odber_typ text,
dat_vydani timestamp,
autor text,
stav text,
typ_kvant text,
ref_low double precision,
ref_high double precision,
mimo_normu smallint
);
CREATE TABLE IF NOT EXISTS dasta_diagnoza (
id bigserial PRIMARY KEY,
soubor text NOT NULL REFERENCES dasta_zprava(soubor) ON DELETE CASCADE,
poradi int,
kod text
);
CREATE INDEX IF NOT EXISTS ix_dasta_vysledek_soubor ON dasta_vysledek(soubor);
CREATE INDEX IF NOT EXISTS ix_dasta_vysledek_nclp ON dasta_vysledek(klic_nclp);
CREATE INDEX IF NOT EXISTS ix_dasta_vysledek_odber ON dasta_vysledek(dat_odber);
CREATE INDEX IF NOT EXISTS ix_dasta_zprava_rc ON dasta_zprava(rodne_cislo);
"""
DROP = """
DROP TABLE IF EXISTS dasta_vysledek CASCADE;
DROP TABLE IF EXISTS dasta_diagnoza CASCADE;
DROP TABLE IF EXISTS dasta_zprava CASCADE;
DROP TABLE IF EXISTS dasta_pacient CASCADE;
"""
def ensure_database() -> None:
"""Založí DB `ordinace`, pokud neexistuje (mimo transakci)."""
with psycopg.connect(dbname="postgres", autocommit=True, connect_timeout=10, **PG) as c:
exists = c.execute(
"SELECT 1 FROM pg_database WHERE datname = %s", (PG_DB,)
).fetchone()
if not exists:
# TEMPLATE template0 obchází collation version mismatch u template1
c.execute(f'CREATE DATABASE "{PG_DB}" TEMPLATE template0')
print(f"Databáze {PG_DB} vytvořena.")
else:
print(f"Databáze {PG_DB} už existuje.")
# ---------------------------------------------------------------------------
# Parsování jednoho souboru → (pacient, zprava, vysledky, diagnozy)
# ---------------------------------------------------------------------------
_RE_ENC = re.compile(r"encoding=['\"][^'\"]+['\"]", re.I)
def _nacti_root(raw: bytes):
"""Naparsuje XML; když selže (špatně deklarované kódování), zkusí UTF-8."""
try:
return ET.fromstring(raw)
except ET.ParseError:
# Některé soubory deklarují Windows-1250, ale jsou v UTF-8.
text = raw.decode("utf-8", errors="replace")
text = _RE_ENC.sub("", text, count=1) # odstraň chybnou deklaraci
return ET.fromstring(text)
def parse_file(cesta: Path):
root = _nacti_root(cesta.read_bytes())
soubor = cesta.stem
zdroj = root.find("zdroj_is")
pm = root.find("pm")
is_el = root.find("is")
pm_a = pm.find("a") if pm is not None else None
is_a = is_el.find("a") if is_el is not None else None
ip = is_el.find("ip") if is_el is not None else None
# pacient
rodne_cislo = _text(ip, "rodcis") if ip is not None else None
pacient = None
if rodne_cislo:
pacient = (
rodne_cislo,
_text(ip, "jmeno"),
_text(ip, "prijmeni"),
dat(_text(ip, "dat_dn")),
_text(ip, "sex"),
)
# pojišťovna se sem nedává (lze doplnit), držíme se zadaného rozsahu
zprava = (
soubor,
root.get("id_soubor"),
root.get("ozn_soub"),
ts(root.get("dat_vb")),
root.get("verze_ds"),
root.get("typ_odesm"),
zdroj.get("kod_prog") if zdroj is not None else None,
zdroj.get("verze_prog") if zdroj is not None else None,
is_el.get("icp") if is_el is not None else None,
is_el.get("ico") if is_el is not None else None,
_text(is_a, "jmeno") if is_a is not None else None,
pm.get("icp") if pm is not None else None,
_text(pm_a, "jmeno") if pm_a is not None else None,
rodne_cislo,
)
vysledky = []
diagnozy = []
if ip is not None:
dg = ip.find("dg")
if dg is not None:
for i, diag in enumerate(dg.iter("diag"), 1):
if diag.text:
diagnozy.append((soubor, i, diag.text.strip()))
v = ip.find("v")
if v is not None:
for vr in v.findall("vr"):
vrn = vr.find("vrn")
nazvy = vrn.find("nazvy") if vrn is not None else None
skala = vrn.find("skala") if vrn is not None else None
dat_du = vr.find("dat_du")
ref_low = ref_high = None
if skala is not None:
ref_low = num(_text(skala, "s4"))
ref_high = num(_text(skala, "s5"))
hodnota_raw = _text(vrn, "hodnota") if vrn is not None else None
hodnota_num = num(hodnota_raw)
mimo = None
if hodnota_num is not None and (ref_low is not None or ref_high is not None):
mimo = 0
if ref_low is not None and hodnota_num < ref_low:
mimo = 1
if ref_high is not None and hodnota_num > ref_high:
mimo = 1
vysledky.append((
soubor,
vr.get("klic_nclp"),
_text(vr, "nazev_lclp"),
nazvy.get("jednotka") if nazvy is not None else None,
hodnota_raw,
hodnota_num,
ts(dat_du.text if dat_du is not None else None),
dat_du.get("typ") if dat_du is not None else None,
ts(_text(vr, "dat_vv")),
_text(vr, "autor"),
vr.get("stav_vys"),
vrn.get("priznak_kvant") if vrn is not None else None,
ref_low,
ref_high,
mimo,
))
return pacient, zprava, vysledky, diagnozy
# ---------------------------------------------------------------------------
# Zápis (idempotentní)
# ---------------------------------------------------------------------------
UPSERT_PACIENT = """
INSERT INTO dasta_pacient (rodne_cislo, jmeno, prijmeni, dat_narozeni, sex)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (rodne_cislo) DO UPDATE SET
jmeno=EXCLUDED.jmeno, prijmeni=EXCLUDED.prijmeni,
dat_narozeni=EXCLUDED.dat_narozeni, sex=EXCLUDED.sex;
"""
UPSERT_ZPRAVA = """
INSERT INTO dasta_zprava (soubor,id_soubor,ozn_soub,dat_vytvoreni,verze_ds,typ_odesm,
zdroj_prog,zdroj_verze,odesilatel_icp,odesilatel_ico,odesilatel_nazev,
prijemce_icp,prijemce_nazev,rodne_cislo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (soubor) DO UPDATE SET
id_soubor=EXCLUDED.id_soubor, ozn_soub=EXCLUDED.ozn_soub,
dat_vytvoreni=EXCLUDED.dat_vytvoreni, verze_ds=EXCLUDED.verze_ds,
typ_odesm=EXCLUDED.typ_odesm, zdroj_prog=EXCLUDED.zdroj_prog,
zdroj_verze=EXCLUDED.zdroj_verze, odesilatel_icp=EXCLUDED.odesilatel_icp,
odesilatel_ico=EXCLUDED.odesilatel_ico, odesilatel_nazev=EXCLUDED.odesilatel_nazev,
prijemce_icp=EXCLUDED.prijemce_icp, prijemce_nazev=EXCLUDED.prijemce_nazev,
rodne_cislo=EXCLUDED.rodne_cislo;
"""
INS_VYSLEDEK = """
INSERT INTO dasta_vysledek (soubor,klic_nclp,nazev,jednotka,hodnota_raw,hodnota_num,
dat_odber,dat_odber_typ,dat_vydani,autor,stav,typ_kvant,ref_low,ref_high,mimo_normu)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s);
"""
INS_DIAGNOZA = "INSERT INTO dasta_diagnoza (soubor,poradi,kod) VALUES (%s,%s,%s);"
def main() -> None:
args = sys.argv[1:]
recreate = "--recreate" in args
limit = None
if "--limit" in args:
limit = int(args[args.index("--limit") + 1])
pozicni = [a for a in args if not a.startswith("--")]
# odfiltruj hodnotu za --limit
if limit is not None and pozicni and pozicni[0] == str(limit):
pozicni = pozicni[1:]
zdroj = Path(pozicni[0]) if pozicni else ZDROJ_VYCHOZI
print(f"Zdroj: {zdroj}")
print(f"Cíl: postgresql://{PG['host']}:{PG['port']}/{PG_DB} (tabulky dasta_*)")
ensure_database()
soubory = sorted(zdroj.glob("*.xml"))
if limit:
soubory = soubory[:limit]
print(f"Souborů ke zpracování: {len(soubory)}")
print("-" * 60)
ok = chyb = 0
chyby = []
# autocommit=True → každý soubor je samostatná transakce (conn.transaction),
# takže chyba u jednoho souboru nikdy neovlivní ostatní.
with psycopg.connect(dbname=PG_DB, autocommit=True, connect_timeout=10, **PG) as conn:
with conn.cursor() as cur:
if recreate:
cur.execute(DROP)
print("Tabulky dasta_* zahozeny.")
cur.execute(DDL)
cur = conn.cursor()
for i, src in enumerate(soubory, 1):
try:
pacient, zprava, vysledky, diagnozy = parse_file(src)
with conn.transaction(): # savepoint pro tento soubor
if pacient:
cur.execute(UPSERT_PACIENT, pacient)
cur.execute(UPSERT_ZPRAVA, zprava)
cur.execute("DELETE FROM dasta_vysledek WHERE soubor=%s", (src.stem,))
cur.execute("DELETE FROM dasta_diagnoza WHERE soubor=%s", (src.stem,))
if vysledky:
cur.executemany(INS_VYSLEDEK, vysledky)
if diagnozy:
cur.executemany(INS_DIAGNOZA, diagnozy)
ok += 1
except Exception as e:
chyb += 1
chyby.append(f"{src.name}: {type(e).__name__} {e}")
continue
if i % 500 == 0:
print(f" ... {i}/{len(soubory)}")
print("-" * 60)
print(f"Hotovo. Zpráv OK: {ok} Chyb: {chyb}")
if chyby:
print("Chyby:")
for c in chyby[:20]:
print(" " + c)
# Souhrn
with psycopg.connect(dbname=PG_DB, **PG) as conn:
for t in ("dasta_pacient", "dasta_zprava", "dasta_vysledek", "dasta_diagnoza"):
n = conn.execute(f"SELECT count(*) FROM {t}").fetchone()[0]
print(f" {t:18}: {n}")
if __name__ == "__main__":
main()
+260
View File
@@ -0,0 +1,260 @@
# -*- coding: utf-8 -*-
"""
Parser DASTA XML (Datový standard MZ ČR, verze DS 03.01.01).
Rozebírá laboratorní zprávy (typ odesílatele LB) do strukturovaných dat.
Soubory jsou v kódování windows-1250 (uvedeno v XML deklaraci) a obsahují
DOCTYPE odkaz na lokální DTD (ds030101.dtd), který při parsování ignorujeme.
Struktura DASTA dávky (zjednodušeně):
dasta kořen, hlavička dávky (datum, verze, odesílatel)
zdroj_is informační systém, který dávku vytvořil
pm (icp) příjemce zprávy (ordinace) + adresa (a typ="P")
is (icp, ico) odesílatel = laboratoř + adresa (a typ="O")
ip (id_pac) pacient: rodné číslo, jméno, dat. narození, sex
pv / p pojišťovna (kodpoj, typpoj)
dg / dgz / diag diagnózy
v blok výsledků
vr (klic_nclp...) jeden laboratorní výsledek (analyt)
nazev_lclp název položky (WBC, RBC, ...)
dat_du/dat_pl/dat_vv odběr / příjem / vydání výsledku
autor validující lékař
vrn číselný výsledek
hodnota naměřená hodnota
nazvy@jednotka měrná jednotka
skala s1..s8 referenční pásma (meze)
interpret_g_z grafická interpretace | | * | |
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
from xml.etree import ElementTree as ET
# ---------------------------------------------------------------------------
# Datové třídy
# ---------------------------------------------------------------------------
@dataclass
class Vysledek:
klic_nclp: str # kód NČLP (Národní číselník laboratorních položek)
nazev: str # lokální název položky (WBC, RBC, ...)
hodnota: str | None
jednotka: str | None
dat_odber: str | None # dat_du datum a čas odběru
dat_vydani: str | None # dat_vv datum a čas vydání výsledku
autor: str | None
stav: str | None # stav_vys (A = definitivní)
typ_kvant: str | None # priznak_kvant (R = reálné číslo)
interpret: str | None # grafická interpretace mezí
meze: list[str] = field(default_factory=list) # s1..s8
@property
def referencni_mez(self) -> str | None:
"""Klinicky relevantní referenční rozmezí = s4 (dolní) až s5 (horní)."""
if len(self.meze) >= 5:
return f"{self.meze[3]} {self.meze[4]}"
return None
@dataclass
class Pacient:
id_pac: str | None
rodne_cislo: str | None
jmeno: str | None
prijmeni: str | None
datum_narozeni: str | None
sex: str | None
pojistovna: str | None
typ_pojisteni: str | None
diagnozy: list[str] = field(default_factory=list)
vysledky: list[Vysledek] = field(default_factory=list)
@dataclass
class Adresa:
jmeno: str | None = None
radky: list[str] = field(default_factory=list)
psc: str | None = None
mesto: str | None = None
@dataclass
class DastaZprava:
ozn_soub: str | None
id_soubor: str | None
datum_vytvoreni: str | None
verze_ds: str | None
typ_odesilatele: str | None
zdroj_program: str | None
zdroj_verze: str | None
prijemce_icp: str | None
prijemce: Adresa | None
odesilatel_icp: str | None
odesilatel_ico: str | None
odesilatel: Adresa | None
pacienti: list[Pacient] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Pomocné funkce
# ---------------------------------------------------------------------------
def _text(el, tag: str) -> str | None:
"""Vrátí text potomka `tag` nebo None."""
if el is None:
return None
child = el.find(tag)
return child.text.strip() if (child is not None and child.text) else None
def _parse_adresa(el) -> Adresa | None:
if el is None:
return None
a = el.find("a")
if a is None:
return None
radky = [v for v in (_text(a, "adr"), _text(a, "dop1"), _text(a, "dop2")) if v]
return Adresa(
jmeno=_text(a, "jmeno"),
radky=radky,
psc=_text(a, "psc"),
mesto=_text(a, "mesto"),
)
# ---------------------------------------------------------------------------
# Hlavní parser
# ---------------------------------------------------------------------------
def parse_dasta(cesta: str | Path) -> DastaZprava:
cesta = Path(cesta)
# XML obsahuje DOCTYPE s odkazem na DTD; vypneme načítání externích entit
# tím, že parsujeme bez resolveru. ElementTree DTD ignoruje automaticky.
raw = cesta.read_bytes()
# ElementTree si kódování přečte z XML deklarace (<?xml ... encoding=...?>).
root = ET.fromstring(raw)
zdroj = root.find("zdroj_is")
pm = root.find("pm")
is_el = root.find("is")
zprava = DastaZprava(
ozn_soub=root.get("ozn_soub"),
id_soubor=root.get("id_soubor"),
datum_vytvoreni=root.get("dat_vb"),
verze_ds=root.get("verze_ds"),
typ_odesilatele=root.get("typ_odesm"),
zdroj_program=zdroj.get("kod_prog") if zdroj is not None else None,
zdroj_verze=zdroj.get("verze_prog") if zdroj is not None else None,
prijemce_icp=pm.get("icp") if pm is not None else None,
prijemce=_parse_adresa(pm),
odesilatel_icp=is_el.get("icp") if is_el is not None else None,
odesilatel_ico=is_el.get("ico") if is_el is not None else None,
odesilatel=_parse_adresa(is_el),
)
if is_el is None:
return zprava
for ip in is_el.findall("ip"):
# pojišťovna bere se z elementu <p> (přímo nebo uvnitř <pv>)
p = ip.find("p")
if p is None:
pv = ip.find("pv")
p = pv.find("p") if pv is not None else None
pacient = Pacient(
id_pac=ip.get("id_pac"),
rodne_cislo=_text(ip, "rodcis"),
jmeno=_text(ip, "jmeno"),
prijmeni=_text(ip, "prijmeni"),
datum_narozeni=_text(ip, "dat_dn"),
sex=_text(ip, "sex"),
pojistovna=_text(p, "kodpoj") if p is not None else None,
typ_pojisteni=_text(p, "typpoj") if p is not None else None,
)
# diagnózy
dg = ip.find("dg")
if dg is not None:
for diag in dg.iter("diag"):
if diag.text:
pacient.diagnozy.append(diag.text.strip())
# výsledky
v = ip.find("v")
if v is not None:
for vr in v.findall("vr"):
vrn = vr.find("vrn")
nazvy = vrn.find("nazvy") if vrn is not None else None
skala = vrn.find("skala") if vrn is not None else None
meze = []
interpret = None
if skala is not None:
for i in range(1, 9):
meze.append(_text(skala, f"s{i}") or "")
interpret = _text(skala, "interpret_g_z")
pacient.vysledky.append(Vysledek(
klic_nclp=vr.get("klic_nclp"),
nazev=_text(vr, "nazev_lclp"),
hodnota=_text(vrn, "hodnota") if vrn is not None else None,
jednotka=nazvy.get("jednotka") if nazvy is not None else None,
dat_odber=_text(vr, "dat_du"),
dat_vydani=_text(vr, "dat_vv"),
autor=_text(vr, "autor"),
stav=vr.get("stav_vys"),
typ_kvant=vrn.get("priznak_kvant") if vrn is not None else None,
interpret=interpret,
meze=meze,
))
zprava.pacienti.append(pacient)
return zprava
# ---------------------------------------------------------------------------
# Výpis přehledu
# ---------------------------------------------------------------------------
def vypis_prehled(z: DastaZprava) -> None:
print("=" * 70)
print(f"DASTA dávka {z.ozn_soub} (DS {z.verze_ds}, typ {z.typ_odesilatele})")
print(f"Vytvořeno: {z.datum_vytvoreni}")
print(f"Program: {z.zdroj_program} {z.zdroj_verze}")
print(f"ID souboru: {z.id_soubor}")
print("-" * 70)
if z.odesilatel:
print(f"Odesílatel (laboratoř) IČP {z.odesilatel_icp} IČO {z.odesilatel_ico}")
print(f" {z.odesilatel.jmeno} | {', '.join(z.odesilatel.radky)}")
print(f" {z.odesilatel.psc} {z.odesilatel.mesto}")
if z.prijemce:
print(f"Příjemce (ordinace) IČP {z.prijemce_icp}")
print(f" {z.prijemce.jmeno} | {', '.join(z.prijemce.radky)}")
print("=" * 70)
for pac in z.pacienti:
print(f"\nPacient: {pac.prijmeni} {pac.jmeno} r.č. {pac.rodne_cislo}"
f" nar. {pac.datum_narozeni} {pac.sex}")
print(f" Pojišťovna {pac.pojistovna} (typ {pac.typ_pojisteni})"
f" Diagnózy: {', '.join(pac.diagnozy) or ''}")
print(f" Výsledků: {len(pac.vysledky)}")
print()
print(f" {'Položka':<14}{'Hodnota':>10} {'Jedn.':<9}"
f"{'Ref. mez':<18}{'Interpr.':<12}NČLP")
print(" " + "-" * 75)
for vys in pac.vysledky:
ref = vys.referencni_mez or ""
interp = (vys.interpret or "").strip()
print(f" {vys.nazev or '':<14}{vys.hodnota or '':>10} "
f"{vys.jednotka or '':<9}{ref:<18}{interp:<12}{vys.klic_nclp}")
if __name__ == "__main__":
if len(sys.argv) > 1:
cesta = sys.argv[1]
else:
cesta = r"u:\Dropbox\Ordinace\pomoc\DASTA\RLB05E6T.xml"
zprava = parse_dasta(cesta)
vypis_prehled(zprava)
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""
Roztřídí DASTA XML soubory do adresářové struktury podle DATA ODBĚRU.
Zdroj: u:\\Dropbox\\Ordinace\\pomoc\\DASTA\\*.xml
Cíl: U:\\DASTA_SUBGROUPS\\RRRR\\MM\\DD\\<soubor>.xml (kopie, originál zůstává)
Datum odběru = první element <dat_du> v souboru (první potomek prvního <vr>).
Hodnota má formát DTS (2016-06-20T08:00:00) nebo DT (2017-05-18T07:30)
v obou případech začíná YYYY-MM-DD, takže rok/měsíc/den čteme z prvních znaků.
Speciální případy:
_BEZ_DATUMU soubor neobsahuje žádný <dat_du>
_CHYBY soubor se nepodařilo naparsovat
Použití:
python roztrid_dle_odberu.py # zdroj = výchozí (Dropbox)
python roztrid_dle_odberu.py U:\\DASTA # jiný zdrojový adresář
python roztrid_dle_odberu.py U:\\DASTA --dry-run # jen vypíše, co by udělal
"""
from __future__ import annotations
import re
import shutil
import sys
from collections import Counter
from pathlib import Path
from xml.etree import ElementTree as ET
ZDROJ_VYCHOZI = Path(r"u:\dasta")
CIL = Path(r"U:\DASTA_SUBGROUPS")
# Záchytný regex pro případ, že ElementTree selže (poškozená hlavička apod.)
_RE_DAT_DU = re.compile(rb"<dat_du[^>]*>\s*(\d{4})-(\d{2})-(\d{2})")
def datum_odberu(cesta: Path) -> tuple[str, str, str] | None:
"""Vrátí (rok, měsíc, den) z prvního <dat_du>, nebo None když chybí."""
raw = cesta.read_bytes()
# Rychlá a robustní cesta: najdi první <dat_du> v bytech.
m = _RE_DAT_DU.search(raw)
if m:
return m.group(1).decode(), m.group(2).decode(), m.group(3).decode()
# Záloha přes ElementTree (kdyby byl dat_du formátovaný jinak)
try:
root = ET.fromstring(raw)
el = root.find(".//dat_du")
if el is not None and el.text:
d = el.text.strip()
return d[0:4], d[5:7], d[8:10]
except ET.ParseError:
raise
return None
def main() -> None:
dry = "--dry-run" in sys.argv
pozicni = [a for a in sys.argv[1:] if not a.startswith("--")]
zdroj = Path(pozicni[0]) if pozicni else ZDROJ_VYCHOZI
soubory = sorted(zdroj.glob("*.xml"))
print(f"Zdroj: {zdroj}")
print(f"Cíl: {CIL}")
print(f"Nalezeno souborů: {len(soubory)}"
f"{' [DRY-RUN]' if dry else ''}")
print("-" * 60)
if not dry:
CIL.mkdir(parents=True, exist_ok=True)
stat = Counter()
roky = Counter()
chyby: list[str] = []
for src in soubory:
try:
d = datum_odberu(src)
except ET.ParseError as e:
d = None
cilovy_dir = CIL / "_CHYBY"
chyby.append(f"{src.name}: parse error {e}")
stat["chyba"] += 1
else:
if d is None:
cilovy_dir = CIL / "_BEZ_DATUMU"
stat["bez_datumu"] += 1
else:
rok, mes, den = d
cilovy_dir = CIL / rok / mes / den
stat["ok"] += 1
roky[rok] += 1
dst = cilovy_dir / src.name
if dry:
continue
cilovy_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
print("Hotovo:")
print(f" zařazeno dle data odběru : {stat['ok']}")
print(f" bez data (_BEZ_DATUMU) : {stat['bez_datumu']}")
print(f" chyby parsování (_CHYBY) : {stat['chyba']}")
if roky:
print("\nRozložení podle roku:")
for rok in sorted(roky):
print(f" {rok}: {roky[rok]}")
if chyby:
print("\nDetail chyb:")
for c in chyby:
print(f" {c}")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
# Přihlašovací údaje k euni.cz — zkopíruj do souboru .env a vyplň.
# (.env je v .gitignore, do gitu se nedostane.)
EUNI_USERNAME=tvoje_prihlasovaci_jmeno
EUNI_PASSWORD=tvoje_heslo
+3
View File
@@ -0,0 +1,3 @@
# stažený obsah a inventura — do gitu nepatří
stazeno/
euni_kurzy.json
+120
View File
@@ -0,0 +1,120 @@
# Euni — stahování a tracking kurzů z euni.cz
Přihlásí se na euni.cz, projde kurzy, vytěží odkazy + metadata a stahuje obsah
(PDF/prezentace a videa Vimeo/YouTube). Vše se trackuje v **MongoDB EUNI**, takže
stahování je idempotentní — skript ví, co už má, a netahá dvakrát.
## Soubory
| Soubor | Popis |
|--------|-------|
| `euni_stahni.py` | hlavní pipeline: login → scrape → ingest do Mongo → stahování → záloha do SeaweedFS |
| `euni_db.py` | připojení a operace nad MongoDB EUNI (kolekce, indexy, upserty) |
| `euni_seaweed.py` | nahrávání/stahování souborů do SeaweedFS (filer HTTP API) |
| `euni_restore.py` | obnova všech souborů ze SeaweedFS na disk (na jakémkoli PC) |
| `euni_report.py` | dashboard: přehled stavu (kolik staženo/čeká/přeskočeno) |
| `.env` | `EUNI_USERNAME`, `EUNI_PASSWORD` (v .gitignore) |
| `euni_kurzy.json` | poslední inventura (záloha; primární zdroj je Mongo) |
| `stazeno/` | stažený obsah, `stazeno/<id>-<slug>/{dokumenty,videa}/` |
## Závislosti
```bat
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg pymongo
```
Video stahuje sdílený modul `../Video/stahni_video.py` (yt-dlp + static-ffmpeg,
soukromá videa sám přeskočí).
## MongoDB EUNI
Server `mongodb://192.168.1.76:27017` (bez hesla), DB `EUNI`. Lze přepsat env
proměnnou `EUNI_MONGO_URI`.
### Kolekce `kurzy` (1 dokument na kurz)
`_id` = euni ID kurzu. Pole: `slug, nazev, url, profese[], autor,
autor_medailonek_url, datum_publikace, revidovano, akreditace, kredity,
pocet_videi, pocet_dokumentu, first_seen, updated_at`.
### Kolekce `materialy` (1 dokument na soubor)
Unikátní index `{kurz_id, klic}`. Pole: `kurz_id, kurz_nazev, druh
(video|dokument), platforma (vimeo|youtube), klic (vimeo:ID / youtube:ID /
doc:hash), zdroj_url, watch_url, popis, pripona, stav, duvod, soubor,
velikost_b, pokusy, posledni_chyba, first_seen, updated_at, stazeno_at`.
**Stavy:** `ceka``stazeno` / `preskoceno` (soukromé video) / `chyba`.
**SeaweedFS reference** (po nahrání kopie): `seaweed_path` (cesta ve filer =
identifikátor pro vyžádání, např. `euni/5618-.../dokumenty/x.pdf`),
`seaweed_fids` (fid chunků = čísla souborů v SeaweedFS), `seaweed_md5`,
`seaweed_size`, `seaweed_at`.
## SeaweedFS záloha + obnova
Každý stažený soubor se nahraje do **SeaweedFS** (filer na Unraidu,
default `http://192.168.1.50:8888`, přepíše env `EUNI_FILER`). Do Mongo se k
materiálu uloží `seaweed_path` + `seaweed_fids`, takže soubor lze kdykoli vyžádat.
- Strukturu na disku zrcadlí cesta: `euni/<id>-<slug>/<typ>/<soubor>`.
- Filer metadata (mapa cesta→chunky) jsou v Mongo DB `seaweedfs` na 192.168.1.76;
bloby na poli Unraidu. (Setup: `U:\\PythonProject\\Janssen\\SeaweedFS\\`.)
- Pozn.: přímý přístup přes raw fid/volume zvenčí nefunguje (volume se uvnitř
Dockeru jmenuje `seaweed-volume`); proto se čte/zapisuje přes filer.
**Obnova kdekoliv** (stačí síť na Mongo + filer):
```bat
python euni_restore.py # vše → ./obnoveno
python euni_restore.py --out D:\Euni # jiný cíl
python euni_restore.py --kurz 5618 # jen jeden kurz
python euni_restore.py --dry-run # jen výpis
```
**Backfill** (dohrát do SeaweedFS soubory stažené dřív):
```bat
python euni_stahni.py --seaweed-backfill --from-json
```
### Idempotence
- Scrape dělá *upsert*: nový materiál → `ceka`; existující si **drží stav**
(nepřepíše stažené). Lze tedy bez obav scrapovat opakovaně.
- Stahování bere jen `stav: ceka` (a volitelně `chyba` pro retry).
## Použití
Nejjednodušší: **`python euni_menu.py`** — interaktivní menu s volbami 19
(test / dokumenty / vše / 720p / dashboard / obnova / backfill / re-scrape).
Po doběhnutí akce se vrátí do menu, `Ctrl+C` přeruší jen aktuální akci.
Ručně přes CLI:
```bat
python euni_stahni.py --scrape-only # jen inventura → Mongo + JSON
python euni_stahni.py --no-videos # scrape + stáhne jen dokumenty
python euni_stahni.py # scrape + dokumenty + videa
python euni_stahni.py --from-json --no-videos # přeskočí scrape, stáhne z Mongo/JSON
python euni_stahni.py --professions all # všechny profese (2,4,5,6,7)
python euni_stahni.py --limit 3 # jen prvních 3 kurzy (test)
python euni_stahni.py --no-mongo # bez zápisu do Mongo
python euni_stahni.py --frags 20 # víc paralelních HLS fragmentů (rychlejší)
python euni_stahni.py --video-format "bestvideo[height<=720]+bestaudio/best" # 720p
python euni_report.py # přehled stavu
python euni_report.py --soukroma # seznam přeskočených videí
```
## Jak to funguje (ověřeno)
- **Login** `/sign/` — formulář se parsuje (kopírují se skrytá Nette pole `_do`).
- **Seznam kurzů** — signál `studyAreaList-nextPage` vrací JSON snippet, stránkuje
se dokud přibývají kurzy (profese: 2=Lékař, 4=Farmaceut, 5/6=studenti, 7=NLZP).
- **Detail kurzu** — server-rendered HTML; videa z `<iframe>` (u Vimea se zachová
`?h=` hash), dokumenty z přímých odkazů i `/redirect/<base64>`.
- Metadata z bloků `lecture-info-label``lecture-info-mark`.
## Úskalí
- **Vimeo** dává oddělené video/audio HLS → nutný ffmpeg (řeší static-ffmpeg).
Domain-restricted videa se stahují s referer `https://www.euni.cz/`.
- **Soukromá videa** (autor je zamkl) nejdou stáhnout — skript je označí
`preskoceno` s důvodem, nepadá.
- Anotace kurzu na stránce není (jen obecný text webu) → neukládá se.
- Diakritika v názvech: v konzoli cp1250 OK; výpis má pojistku proti pádu.
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
euni_db.py — připojení a operace nad MongoDB databází EUNI.
Server: mongodb://192.168.1.76:27017 (bez hesla), databáze "EUNI".
Kolekce:
kurzy — 1 dokument na kurz (metadata + počty)
materialy — 1 dokument na stahovatelný soubor (video/dokument) + stav stahování
Idempotence: materialy mají unikátní index {kurz_id, klic}. Upsert nový soubor
založí jako "ceka"; u existujícího NEPŘEPÍŠE stav stahování (jen popisná pole).
"""
import os
from datetime import datetime, timezone
import pymongo
MONGO_URI = os.environ.get("EUNI_MONGO_URI", "mongodb://192.168.1.76:27017")
DB_NAME = "EUNI"
# stavy materiálu
CEKA = "ceka"
STAZENO = "stazeno"
PRESKOCENO = "preskoceno"
CHYBA = "chyba"
def now():
return datetime.now(timezone.utc)
def get_db():
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=4000)
client.admin.command("ping")
return client[DB_NAME]
def ensure_indexes(db=None):
if db is None:
db = get_db()
db.materialy.create_index([("kurz_id", 1), ("klic", 1)], unique=True,
name="uniq_kurz_klic")
db.materialy.create_index("stav", name="stav")
db.materialy.create_index([("druh", 1), ("stav", 1)], name="druh_stav")
db.kurzy.create_index("profese", name="profese")
return db
# ----------------------------------------------------------------- kurzy ------
def upsert_kurz(db, kurz: dict):
"""Vloží/aktualizuje kurz. Zachová first_seen, profese sjednotí."""
_id = kurz["id"]
sets = {
"slug": kurz.get("slug"),
"nazev": kurz.get("nazev") or kurz.get("title"),
"url": kurz.get("url"),
"autor": kurz.get("autor"),
"autor_medailonek_url": kurz.get("autor_medailonek_url"),
"datum_publikace": kurz.get("datum_publikace"),
"revidovano": kurz.get("revidovano"),
"akreditace": kurz.get("akreditace"),
"kredity": kurz.get("kredity"),
"pocet_videi": kurz.get("pocet_videi"),
"pocet_dokumentu": kurz.get("pocet_dokumentu"),
"updated_at": now(),
}
profese = kurz.get("profese") or []
db.kurzy.update_one(
{"_id": _id},
{
"$set": sets,
"$setOnInsert": {"first_seen": now()},
"$addToSet": {"profese": {"$each": profese}} if profese else {},
} if profese else {
"$set": sets,
"$setOnInsert": {"first_seen": now()},
},
upsert=True,
)
# -------------------------------------------------------------- materialy -----
def upsert_material(db, mat: dict):
"""Idempotentní upsert souboru. Nepřepíše stav existujícího záznamu."""
klic_filter = {"kurz_id": mat["kurz_id"], "klic": mat["klic"]}
popisne = {
"kurz_nazev": mat.get("kurz_nazev"),
"druh": mat.get("druh"),
"platforma": mat.get("platforma"),
"zdroj_url": mat.get("zdroj_url"),
"watch_url": mat.get("watch_url"),
"popis": mat.get("popis"),
"pripona": mat.get("pripona"),
"updated_at": now(),
}
db.materialy.update_one(
klic_filter,
{
"$set": popisne,
"$setOnInsert": {
"stav": CEKA,
"duvod": None,
"soubor": None,
"velikost_b": None,
"pokusy": 0,
"posledni_chyba": None,
"stazeno_at": None,
"first_seen": now(),
},
},
upsert=True,
)
def set_status(db, kurz_id, klic, stav, soubor=None, velikost_b=None,
duvod=None, chyba=None):
"""Nastaví výsledek stahování jednoho materiálu."""
sets = {"stav": stav, "updated_at": now()}
if stav == STAZENO:
sets.update({"soubor": soubor, "velikost_b": velikost_b,
"duvod": None, "posledni_chyba": None, "stazeno_at": now()})
elif stav == PRESKOCENO:
sets.update({"duvod": duvod})
elif stav == CHYBA:
sets.update({"posledni_chyba": chyba})
upd = {"$set": sets}
if stav in (STAZENO, CHYBA):
upd["$inc"] = {"pokusy": 1}
db.materialy.update_one({"kurz_id": kurz_id, "klic": klic}, upd)
def set_seaweed(db, kurz_id, klic, path, fids=None, md5=None, size=None):
"""Uloží referenci na kopii v SeaweedFS (cesta + fid chunků)."""
db.materialy.update_one(
{"kurz_id": kurz_id, "klic": klic},
{"$set": {
"seaweed_path": path,
"seaweed_fids": fids or [],
"seaweed_md5": md5,
"seaweed_size": size,
"seaweed_at": now(),
"updated_at": now(),
}},
)
def materialy_bez_seaweed(db):
"""Stažené materiály, které ještě nemají kopii v SeaweedFS (pro backfill)."""
return list(db.materialy.find({
"stav": STAZENO,
"soubor": {"$ne": None},
"$or": [{"seaweed_path": {"$exists": False}}, {"seaweed_path": None}],
}))
def materialy_v_seaweed(db):
"""Materiály s kopií v SeaweedFS (pro restore)."""
return list(db.materialy.find({"seaweed_path": {"$exists": True, "$ne": None}}))
def cekajici_materialy(db, druh=None, vcetne_chyb=False):
"""Vrátí materiály ke stažení (stav 'ceka', volitelně i 'chyba')."""
stavy = [CEKA] + ([CHYBA] if vcetne_chyb else [])
q = {"stav": {"$in": stavy}}
if druh:
q["druh"] = druh
return list(db.materialy.find(q))
# ----------------------------------------------------------------- stats ------
def stats(db=None):
if db is None:
db = get_db()
out = {"kurzy": db.kurzy.count_documents({})}
pipe = [{"$group": {"_id": {"druh": "$druh", "stav": "$stav"},
"n": {"$sum": 1}}}]
for row in db.materialy.aggregate(pipe):
d = row["_id"]["druh"]
st = row["_id"]["stav"]
out.setdefault(d, {})[st] = row["n"]
return out
if __name__ == "__main__":
import json
db = ensure_indexes()
print("Připojeno k EUNI na", MONGO_URI)
print(json.dumps(stats(db), ensure_ascii=False, indent=2))
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
euni_menu.py — interaktivní menu pro stahování kurzů z euni.cz.
Spuštění:
python euni_menu.py
Jen vyber číslo a Enter. Každá volba spustí příslušný skript a po doběhnutí
se vrátíš do menu (Ctrl+C přeruší aktuální akci, ne celé menu).
"""
import os
import subprocess
import sys
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
PY = sys.executable
# klíč -> (popis, skript, argumenty)
AKCE = {
"1": ("Test - 3 kurzy, jen dokumenty (rychle)",
"euni_stahni.py", ["--from-json", "--no-videos", "--limit", "3"]),
"2": ("Vsechny dokumenty (PDF/prezentace)",
"euni_stahni.py", ["--from-json", "--no-videos"]),
"3": ("Vse vcetne videi - nejvyssi kvalita (1080p, velke)",
"euni_stahni.py", ["--from-json"]),
"4": ("Vse vcetne videi - 720p (mensi, rychlejsi)",
"euni_stahni.py",
["--from-json", "--video-format",
"bestvideo[height<=720]+bestaudio/best"]),
"5": ("Jen videa (1080p)",
"euni_stahni.py", ["--from-json", "--no-docs"]),
"6": ("Prehled stavu (dashboard)", "euni_report.py", []),
"7": ("Obnova ze SeaweedFS na disk", "euni_restore.py", []),
"8": ("Backfill - dohrat chybejici kopie do SeaweedFS",
"euni_stahni.py", ["--seaweed-backfill", "--from-json"]),
"9": ("Aktualizovat seznam kurzu (znovu scrape do Mongo)",
"euni_stahni.py", ["--scrape-only"]),
}
def vycisti_obrazovku():
os.system("cls" if os.name == "nt" else "clear")
def vypis_menu():
print("=" * 60)
print(" EUNI - stahovani kurzu z euni.cz")
print("=" * 60)
print()
for k in sorted(AKCE):
print(f" {k}) {AKCE[k][0]}")
print()
print(" 0) Konec")
print()
def main():
while True:
vycisti_obrazovku()
vypis_menu()
try:
volba = input("Vyber cislo a stiskni Enter: ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if volba in ("0", "q", "exit", "konec"):
break
akce = AKCE.get(volba)
if not akce:
continue
_, skript, args = akce
print()
try:
subprocess.run([PY, str(SKRIPT_DIR / skript), *args],
cwd=str(SKRIPT_DIR))
except KeyboardInterrupt:
print("\nPreruseno uzivatelem.")
try:
input("\n=== HOTOVO. Stiskni Enter pro navrat do menu ===")
except (EOFError, KeyboardInterrupt):
break
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
euni_report.py — přehled stavu stahování z databáze EUNI.
python euni_report.py # souhrnný přehled
python euni_report.py --chyby # vypíše materiály ve stavu chyba
python euni_report.py --soukroma # vypíše přeskočená (soukromá) videa
"""
import argparse
import sys
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
import euni_db as edb
CARA = "" * 56
def lidsky(n):
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
if n >= j:
return f"{n/j:.1f} {u}"
return f"{n} B"
def main():
p = argparse.ArgumentParser()
p.add_argument("--chyby", action="store_true", help="vypiš materiály ve stavu chyba")
p.add_argument("--soukroma", action="store_true", help="vypiš přeskočená videa")
a = p.parse_args()
db = edb.get_db()
print(CARA)
print(f" EUNI — přehled ({edb.MONGO_URI})")
print(CARA)
print(f" Kurzů: {db.kurzy.count_documents({})}")
kr = db.kurzy.aggregate([{"$group": {"_id": None, "k": {"$sum": "$kredity"}}}])
kr = next(kr, {}).get("k") or 0
print(f" Kreditů celkem (akreditované kurzy): {kr}")
print(CARA)
for druh in ("video", "dokument"):
print(f" {druh.upper()}:")
pipe = [{"$match": {"druh": druh}},
{"$group": {"_id": "$stav", "n": {"$sum": 1},
"b": {"$sum": {"$ifNull": ["$velikost_b", 0]}}}}]
celkem = 0
for row in sorted(db.materialy.aggregate(pipe), key=lambda r: r["_id"]):
vel = f" ({lidsky(row['b'])})" if row["b"] else ""
print(f" {row['_id']:<11} {row['n']:>5}{vel}")
celkem += row["n"]
print(f" {'celkem':<11} {celkem:>5}")
print(CARA)
if a.chyby:
print(" CHYBY:")
for m in db.materialy.find({"stav": edb.CHYBA}):
print(f" - [{m['druh']}] {m.get('kurz_nazev','')[:40]} | "
f"{m.get('posledni_chyba','')[:60]}")
print(f" {m['zdroj_url']}")
if a.soukroma:
print(" PŘESKOČENÁ VIDEA (soukromá/nedostupná):")
for m in db.materialy.find({"stav": edb.PRESKOCENO}):
print(f" - {m.get('kurz_nazev','')[:45]} | {m.get('duvod','')}")
print(f" {m.get('watch_url') or m['zdroj_url']}")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
euni_restore.py — obnoví všechny stažené soubory ze SeaweedFS na disk.
Funguje na libovolném počítači: čte reference (cesty/fid) z MongoDB EUNI a každý
soubor stáhne z filer SeaweedFS zpět do souborového systému se stejnou strukturou
jako stazeno/<id>-<slug>/<typ>/<soubor>.
Potřebuje jen síťový přístup k Mongu (192.168.1.76) a filer (192.168.1.50) a:
python -m pip install pymongo requests
Použití:
python euni_restore.py # obnoví do ./obnoveno
python euni_restore.py --out D:\\Euni # jiný cílový adresář
python euni_restore.py --kurz 5618 # jen jeden kurz
python euni_restore.py --dry-run # jen vypíše, co by stáhl
"""
import argparse
import sys
from pathlib import Path
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(errors="backslashreplace")
except Exception:
pass
import euni_db as edb
import euni_seaweed as sw
def lidsky(n):
n = n or 0
for j, u in [(1e9, "GB"), (1e6, "MB"), (1e3, "kB")]:
if n >= j:
return f"{n/j:.1f} {u}"
return f"{n} B"
def main():
p = argparse.ArgumentParser(description="Obnoví soubory ze SeaweedFS na disk.")
p.add_argument("--out", default="obnoveno", help="cílový adresář (výchozí ./obnoveno)")
p.add_argument("--kurz", help="obnovit jen tento kurz_id")
p.add_argument("--dry-run", action="store_true", help="jen vypsat, nestahovat")
a = p.parse_args()
out = Path(a.out)
db = edb.get_db()
if not sw.ping():
sys.exit(f"SeaweedFS filer nedostupný ({sw.FILER}).")
mats = edb.materialy_v_seaweed(db)
if a.kurz:
mats = [m for m in mats if m.get("kurz_id") == a.kurz]
print(f"Obnovuji {len(mats)} souborů z {sw.FILER} -> {out.resolve()}")
ok = preskoc = chyb = 0
bajtu = 0
for m in mats:
remote = m["seaweed_path"]
# lokální cesta: zrcadlí seaweed cestu bez prefixu 'euni/'
parts = remote.split("/")
rel = Path(*parts[1:]) if parts and parts[0] == sw.PREFIX else Path(*parts)
dest = out / rel
want = m.get("seaweed_size")
if dest.exists() and (want is None or dest.stat().st_size == want):
preskoc += 1
continue
if a.dry_run:
print(f" [BY STÁHL] {rel} ({lidsky(want)})")
ok += 1
continue
try:
n = sw.download(remote, dest)
bajtu += n
ok += 1
print(f" [OK] {rel} ({lidsky(n)})")
except Exception as e:
chyb += 1
print(f" [CHYBA] {rel} ({str(e)[:60]})")
print(f"\nHotovo: {ok} obnoveno, {preskoc} přeskočeno (už je), {chyb} chyb. "
f"Staženo {lidsky(bajtu)}.")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
euni_seaweed.py — nahrávání/stahování souborů do SeaweedFS přes filer HTTP API.
Filer běží na Unraidu (default http://192.168.1.50:8888). Soubory se ukládají
podle cesty, která zrcadlí lokální strukturu: euni/<id>-<slug>/<typ>/<soubor>.
Filer metadata jdou do Mongo "seaweedfs" (na 192.168.1.76) — viz README v
U:\\PythonProject\\Janssen\\SeaweedFS\\.
Identifikátor pro vyžádání souboru = cesta (filer). Navíc se ukládají fid(y)
jednotlivých chunků (číslo souboru v SeaweedFS).
Přepsání endpointu: env EUNI_FILER.
"""
import os
from urllib.parse import quote
import requests
FILER = os.environ.get("EUNI_FILER", "http://192.168.1.50:8888")
PREFIX = "euni" # kořenová složka v SeaweedFS
def _url(remote_path):
return f"{FILER}/" + quote(remote_path.lstrip("/"), safe="/")
def entry_meta(remote_path, timeout=30):
"""Detailní metadata souboru (vč. chunků s fid), nebo None když neexistuje."""
try:
r = requests.get(_url(remote_path) + "?metadata=true", timeout=timeout)
if r.status_code == 200:
return r.json()
except requests.RequestException:
pass
return None
def exists(remote_path):
return entry_meta(remote_path) is not None
def upload(local_path, remote_path, timeout=900):
"""Nahraje soubor na filer. Vrátí dict: path, fids, size, md5."""
fname = os.path.basename(remote_path)
with open(local_path, "rb") as f:
r = requests.post(_url(remote_path), files={"file": (fname, f)},
timeout=timeout)
r.raise_for_status()
meta = entry_meta(remote_path) or {}
fids = [c.get("file_id") for c in (meta.get("chunks") or []) if c.get("file_id")]
return {
"path": remote_path,
"fids": fids,
"size": meta.get("FileSize"),
"md5": meta.get("Md5"),
}
def download(remote_path, local_path, timeout=900):
"""Stáhne soubor z fileru na lokální cestu. Vrátí velikost v bajtech."""
r = requests.get(_url(remote_path), stream=True, timeout=timeout)
r.raise_for_status()
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
tmp = str(local_path) + ".part"
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
if chunk:
f.write(chunk)
os.replace(tmp, local_path)
return os.path.getsize(local_path)
def ping():
try:
r = requests.get(f"{FILER}/?limit=1", headers={"Accept": "application/json"},
timeout=5)
return r.status_code == 200
except requests.RequestException:
return False
if __name__ == "__main__":
print("Filer:", FILER, "dostupný:" , ping())
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""
euni_stahni.py — přihlásí se na euni.cz, projde kurzy a stáhne, co se stáhnout dá
(dokumenty: PDF/DOCX/PPTX/XLSX/ZIP a videa: Vimeo/YouTube).
Postup:
1) login přes /sign/ (formulář se parsuje, kopírují se i skrytá Nette pole)
2) sběr kurzů přes signál studyAreaList-nextPage (stránkování, dokud přibývají)
3) z každého kurzu se vytáhnou <iframe> videa a odkazy na dokumenty
(vč. /redirect/<base64>)
4) vše se stáhne do stazeno/<id>-<slug>/ (dokumenty/ a videa/)
Soukromá / nedostupná videa se samo přeskočí (nepadá).
Závislosti:
python -m pip install -U requests beautifulsoup4 python-dotenv yt-dlp static-ffmpeg
Údaje: Euni/.env -> EUNI_USERNAME=... EUNI_PASSWORD=...
Příklady:
python euni_stahni.py # vše: scrape + dokumenty + videa (profese Lékař)
python euni_stahni.py --scrape-only # jen inventura do euni_kurzy.json
python euni_stahni.py --from-json # přeskočí scrape, použije euni_kurzy.json
python euni_stahni.py --no-videos # jen dokumenty
python euni_stahni.py --professions 2,4 # více profesí (2=Lékař,4=Farmaceut,7=NLZP)
python euni_stahni.py --limit 3 # jen první 3 kurzy (test)
"""
import argparse
import base64
import hashlib
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin, unquote, urlparse
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
# výpis ať nikdy nespadne na znaku mimo kódování konzole
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
load_dotenv(SKRIPT_DIR / ".env")
# reuse stahovače videí z ../Video/stahni_video.py
sys.path.insert(0, str(SKRIPT_DIR.parent / "Video"))
try:
import stahni_video as sv
except Exception:
sv = None
try:
import euni_db as edb
except Exception:
edb = None
try:
import euni_seaweed as sw
except Exception:
sw = None
BASE = "https://www.euni.cz"
LOGIN_URL = f"{BASE}/sign/?bid=1"
LIST_URL = f"{BASE}/seznam-kurzu?bid=1"
NEXTPAGE = f"{BASE}/seznam-kurzu?studyAreaList-professionId={{prof}}&bid=1&do=studyAreaList-nextPage"
DOC_RE = re.compile(r"\.(pdf|docx?|pptx?|xlsx?|zip)(\?|$)", re.I)
FILE_PATH_RE = re.compile(r"fileUploader/download|files/resources", re.I)
VIDEO_RE = re.compile(r"vimeo|youtube|youtu\.be", re.I)
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120 Safari/537.36")
# ---------------------------------------------------------------- pomocné -----
def bezpecny_nazev(s: str, max_len: int = 120) -> str:
"""Očistí řetězec na bezpečný název souboru/složky pro Windows."""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", s).strip(" .")
s = re.sub(r"\s+", " ", s)
return (s[:max_len].strip() or "bez_nazvu")
def make_session():
s = requests.Session()
s.headers.update({"User-Agent": UA})
return s
def _relpath(p):
"""Cesta k souboru relativně k adresáři Euni (pro uložení do DB)."""
if not p:
return None
try:
return str(Path(p).resolve().relative_to(SKRIPT_DIR))
except Exception:
return str(p)
def _seaweed_path(dest, out_root):
"""Cesta v SeaweedFS zrcadlící lokální strukturu: euni/<id-slug>/<typ>/<soubor>."""
try:
rel = Path(dest).resolve().relative_to(Path(out_root).resolve())
except Exception:
rel = Path(dest).name
return sw.PREFIX + "/" + "/".join(Path(rel).parts)
def _zaloh_do_seaweed(db, dest, out_root, kurz_id, klic):
"""Nahraje soubor do SeaweedFS a uloží referenci (fid) k materiálu do Mongo."""
if sw is None or not dest or not Path(dest).exists():
return None
remote = _seaweed_path(dest, out_root)
try:
meta = sw.entry_meta(remote)
if meta and meta.get("FileSize") == Path(dest).stat().st_size:
# už tam je se stejnou velikostí — jen zaznamenat referenci
info = {"path": remote,
"fids": [c.get("file_id") for c in (meta.get("chunks") or [])
if c.get("file_id")],
"size": meta.get("FileSize"), "md5": meta.get("Md5")}
else:
info = sw.upload(str(dest), remote)
if db is not None:
edb.set_seaweed(db, kurz_id, klic, info["path"],
fids=info.get("fids"), md5=info.get("md5"),
size=info.get("size"))
return info
except Exception as e:
print(f" [SEAWEED-CHYBA] {remote} ({str(e)[:60]})")
return None
# ----------------------------------------------------------------- login ------
def login(s):
r = s.get(LOGIN_URL, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
form = next((f for f in soup.find_all("form")
if f.find("input", {"type": "password"})), None)
if not form:
raise RuntimeError("Přihlašovací formulář nenalezen.")
data, user_field, pass_field = {}, None, None
for inp in form.find_all("input"):
name = inp.get("name")
if not name:
continue
itype = (inp.get("type") or "text").lower()
data[name] = inp.get("value", "") # zachová skrytá pole (_do, _token...)
if itype == "password":
pass_field = name
elif itype in ("text", "email") and user_field is None:
user_field = name
user = os.environ.get("EUNI_USERNAME")
pwd = os.environ.get("EUNI_PASSWORD")
if not user or not pwd:
sys.exit("Chybí EUNI_USERNAME / EUNI_PASSWORD. Vyplň je v Euni/.env "
"(vzor je v .env.example).")
data[user_field] = user
data[pass_field] = pwd
action = urljoin(LOGIN_URL, form.get("action") or LOGIN_URL)
r = s.post(action, data=data, headers={"Referer": LOGIN_URL}, timeout=30)
r.raise_for_status()
if "Odhlásit" not in r.text and "odhlasit" not in r.text.lower():
raise RuntimeError("Přihlášení se nezdařilo zkontroluj údaje v .env.")
print("✓ Přihlášeno")
# ------------------------------------------------------------- seznam kurzů ----
def get_courses_for_profession(s, profession_id):
# inicializace stránkování pro danou profesi
s.get(f"{BASE}/seznam-kurzu?studyAreaList-professionId={profession_id}&bid=1",
timeout=30)
seen, prev, guard = {}, -1, 0
while guard < 200:
guard += 1
r = s.get(NEXTPAGE.format(prof=profession_id),
headers={"X-Requested-With": "XMLHttpRequest"}, timeout=30)
r.raise_for_status()
try:
snippet = r.json().get("snippets", {}).get(
"snippet-studyAreaList-areaList", "")
except ValueError:
break
if not snippet:
break
soup = BeautifulSoup(snippet, "html.parser")
for a in soup.select("a.workshop"):
href = (a.get("href") or "").split("?")[0]
m = re.match(r"/lecture/(\d+)-(.+)", href)
if m:
seen[m.group(1)] = {
"id": m.group(1),
"slug": m.group(2),
"title": (a.find("h3").get_text(strip=True)
if a.find("h3") else m.group(2)),
"url": urljoin(BASE, href),
"profession": profession_id,
}
if len(seen) == prev:
break
prev = len(seen)
time.sleep(0.25)
return list(seen.values())
def get_all_courses(s, professions):
vse = {}
for prof in professions:
kurzy = get_courses_for_profession(s, prof)
print(f" profese {prof}: {len(kurzy)} kurzů")
for k in kurzy:
vse.setdefault(k["id"], k)
return list(vse.values())
# --------------------------------------------------------- extrakce odkazů ----
def decode_redirect(href):
m = re.search(r"/redirect/([A-Za-z0-9+/=]+)", href)
if m:
try:
return base64.b64decode(m.group(1)).decode("utf-8", "ignore")
except Exception:
pass
return None
def watch_url(embed):
m = re.search(r"player\.vimeo\.com/video/(\d+)", embed)
if m:
return f"https://vimeo.com/{m.group(1)}"
m = re.search(r"youtube\.com/embed/([\w-]+)", embed)
if m:
return f"https://www.youtube.com/watch?v={m.group(1)}"
return embed
def _text(el):
return " ".join(el.get_text(" ", strip=True).split()) if el else None
def _parse_date(s):
m = re.search(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})", s or "")
if m:
try:
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1)))
except ValueError:
return None
return None
def _mark_for_label(soup, label_text):
"""Najde hodnotu (lecture-info-mark/bold) ve stejném containeru jako daný label."""
for lab in soup.select(".lecture-info-label"):
if label_text.lower() in lab.get_text(strip=True).lower():
par = lab.parent
mark = (par.select_one(".lecture-info-mark")
or par.select_one(".lecture-info-bold"))
if mark:
return _text(mark)
return None
def extract_course_meta(soup):
meta = {}
autor_el = soup.select_one(".lecture-info-column-author")
if autor_el:
meta["autor"] = _text(autor_el.select_one(".lecture-info-mark"))
href = autor_el.get("href") or ""
if "vimeo" in href or "youtube" in href:
meta["autor_medailonek_url"] = href
if not meta.get("autor"):
meta["autor"] = (_mark_for_label(soup, "Autor kurzu")
or _mark_for_label(soup, "Autorka kurzu"))
meta["datum_publikace"] = _parse_date(_mark_for_label(soup, "Datum publikace"))
meta["revidovano"] = _parse_date(_mark_for_label(soup, "Revidováno"))
meta["akreditace"] = _mark_for_label(soup, "Akreditace")
m = re.search(r"(\d+)\s*kredit", soup.get_text(" "), re.I)
meta["kredity"] = int(m.group(1)) if m else None
return meta
def material_klic(druh, item):
"""Vrátí (klic, platforma) pro deduplikaci materiálu."""
if druh == "video":
e = item["embed"]
m = re.search(r"vimeo\.com/(?:video/)?(\d+)", e)
if m:
return f"vimeo:{m.group(1)}", "vimeo"
m = (re.search(r"youtube\.com/embed/([\w-]+)", e)
or re.search(r"youtu\.be/([\w-]+)", e)
or re.search(r"[?&]v=([\w-]+)", e))
if m:
return f"youtube:{m.group(1)}", "youtube"
return "video:" + hashlib.sha1(e.encode()).hexdigest()[:16], None
return "doc:" + hashlib.sha1(item["url"].encode()).hexdigest()[:16], None
def _pripona(url):
m = re.search(r"\.([a-z0-9]{2,4})(\?|$)", url, re.I)
return m.group(1).lower() if m else None
def extract_course_links(s, course_url):
r = s.get(course_url, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
videos, vseen = [], set()
for f in soup.find_all("iframe"):
src = f.get("src") or f.get("data-src") or ""
if src.startswith("//"):
src = "https:" + src
if VIDEO_RE.search(src) and src not in vseen:
vseen.add(src)
videos.append({"embed": src, "watch": watch_url(src)})
docs, seen = [], set()
for a in soup.find_all("a", href=True):
target = decode_redirect(a["href"]) or urljoin(BASE, a["href"])
if DOC_RE.search(target) or FILE_PATH_RE.search(target):
url = unquote(target)
if url in seen:
continue
seen.add(url)
docs.append({
"label": " ".join(a.get_text(" ", strip=True).split())[:70],
"url": url,
})
return {"videos": videos, "documents": docs, "meta": extract_course_meta(soup)}
# ------------------------------------------------------------- stahování ------
def stahni_dokument(s, url, out_dir: Path, label=""):
out_dir.mkdir(parents=True, exist_ok=True)
r = s.get(url, stream=True, timeout=120)
r.raise_for_status()
# jméno souboru z Content-Disposition, jinak z URL
fname = None
cd = r.headers.get("Content-Disposition", "")
m = re.search(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)", cd)
if m:
fname = unquote(m.group(1))
if not fname:
fname = os.path.basename(urlparse(url).path) or "soubor"
fname = bezpecny_nazev(fname)
if "." not in fname and label:
fname = bezpecny_nazev(label)
dest = out_dir / fname
if dest.exists() and dest.stat().st_size > 0:
return ("existuje", dest.name)
tmp = dest.with_suffix(dest.suffix + ".part")
with open(tmp, "wb") as fp:
for chunk in r.iter_content(chunk_size=65536):
if chunk:
fp.write(chunk)
tmp.replace(dest)
return ("staženo", dest.name)
def stahni_video(embed, out_dir: Path, referer, fmt="bestvideo*+bestaudio/best",
frags=10):
"""Stáhne video přes yt-dlp; soukromé/nedostupné přeskočí. Vrací (stav, info, fp)."""
if sv is None:
return ("chyba", "modul stahni_video není dostupný", None)
try:
import yt_dlp
from yt_dlp.utils import DownloadError
except ImportError:
return ("chyba", "yt-dlp není nainstalován", None)
out_dir.mkdir(parents=True, exist_ok=True)
ff_dir = sv.priprav_ffmpeg()
opts = {
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
"format": fmt,
"concurrent_fragment_downloads": frags, # paralelní HLS fragmenty = rychlejší
"merge_output_format": "mp4",
"logger": sv._TichyLogger(),
"progress_hooks": [sv._progress_hook],
"noprogress": True,
"noplaylist": True,
"http_headers": {"Referer": referer, "User-Agent": UA},
}
if ff_dir:
opts["ffmpeg_location"] = ff_dir
try:
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(embed, download=True)
fp = None
rd = (info or {}).get("requested_downloads")
if rd:
fp = rd[0].get("filepath")
return ("staženo", info.get("title", embed) if info else embed, fp)
except DownloadError as e:
duvod = sv.klasifikuj_chybu(str(e))
if duvod:
return ("přeskočeno", duvod, None)
return ("chyba", str(e).split("\n")[0], None)
except Exception as e:
return ("chyba", str(e), None)
def _ingest_course(db, c):
"""Zapíše kurz + jeho materiály do Mongo (idempotentně)."""
meta = c.get("meta") or {}
nazev = c.get("nazev") or c.get("title")
kurz = {
"id": c["id"], "slug": c.get("slug"), "nazev": nazev, "url": c.get("url"),
"profese": [c["profession"]] if c.get("profession") else c.get("profese", []),
"pocet_videi": len(c.get("videos", [])),
"pocet_dokumentu": len(c.get("documents", [])),
}
for k in ("autor", "autor_medailonek_url", "datum_publikace", "revidovano",
"akreditace", "kredity"):
kurz[k] = meta.get(k)
edb.upsert_kurz(db, kurz)
for v in c.get("videos", []):
klic, plat = material_klic("video", v)
edb.upsert_material(db, {
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "video",
"platforma": plat, "klic": klic, "zdroj_url": v["embed"],
"watch_url": v.get("watch"), "popis": None, "pripona": "mp4",
})
for d in c.get("documents", []):
klic, _ = material_klic("dokument", d)
edb.upsert_material(db, {
"kurz_id": c["id"], "kurz_nazev": nazev, "druh": "dokument",
"platforma": None, "klic": klic, "zdroj_url": d["url"],
"watch_url": None, "popis": d.get("label"), "pripona": _pripona(d["url"]),
})
# ---------------------------------------------------------------- hlavní ------
def main():
p = argparse.ArgumentParser(description="Stáhne obsah kurzů z euni.cz.")
p.add_argument("--professions", default="2",
help="ID profesí oddělené čárkou (2=Lékař,4=Farmaceut,7=NLZP), nebo 'all'")
p.add_argument("--scrape-only", action="store_true", help="jen inventura do JSON")
p.add_argument("--from-json", action="store_true",
help="přeskočí scrape, použije existující euni_kurzy.json")
p.add_argument("--no-videos", action="store_true", help="nestahovat videa")
p.add_argument("--no-docs", action="store_true", help="nestahovat dokumenty")
p.add_argument("--video-format", default="bestvideo*+bestaudio/best",
help="yt-dlp formát videa (např. \"bestvideo[height<=720]+bestaudio/best\")")
p.add_argument("--frags", type=int, default=10,
help="počet paralelně stahovaných HLS fragmentů videa (default 10)")
p.add_argument("--limit", type=int, default=0, help="jen prvních N kurzů (test)")
p.add_argument("--out", default=str(SKRIPT_DIR / "stazeno"), help="výstupní adresář")
p.add_argument("--json", default=str(SKRIPT_DIR / "euni_kurzy.json"),
help="cesta k inventurnímu JSON")
p.add_argument("--no-mongo", action="store_true",
help="nezapisovat do MongoDB (jen JSON / stahování)")
p.add_argument("--no-seaweed", action="store_true",
help="nenahrávat kopie do SeaweedFS")
p.add_argument("--seaweed-backfill", action="store_true",
help="jen dohraje do SeaweedFS stažené soubory, které tam chybí")
a = p.parse_args()
json_path = Path(a.json)
out_root = Path(a.out)
s = make_session()
db = None
if not a.no_mongo:
if edb is None:
print("UPOZORNĚNÍ: modul euni_db nedostupný — pokračuji bez Mongo.")
else:
try:
db = edb.ensure_indexes()
print(f"✓ Mongo EUNI připojeno ({edb.MONGO_URI})")
except Exception as e:
print(f"UPOZORNĚNÍ: Mongo nedostupné ({e}) — pokračuji bez něj.")
use_seaweed = not a.no_seaweed and sw is not None
if use_seaweed:
if sw.ping():
print(f"✓ SeaweedFS filer dostupný ({sw.FILER})")
else:
print(f"UPOZORNĚNÍ: SeaweedFS filer nedostupný ({sw.FILER}) — "
f"pokračuji bez záloh.")
use_seaweed = False
# režim: jen dohrát do SeaweedFS chybějící stažené soubory
if a.seaweed_backfill:
if db is None or not use_seaweed:
sys.exit("Backfill potřebuje Mongo i SeaweedFS.")
chybi = edb.materialy_bez_seaweed(db)
print(f"Backfill do SeaweedFS: {len(chybi)} souborů")
ok = 0
for m in chybi:
dest = SKRIPT_DIR / m["soubor"]
if not dest.exists():
continue
remote = _seaweed_path(dest, out_root)
info = _zaloh_do_seaweed(db, dest, out_root, m["kurz_id"], m["klic"])
if info:
ok += 1
print(f" [SEAWEED] {remote}")
print(f"Hotovo: {ok}/{len(chybi)} nahráno.")
return
if a.from_json:
if not json_path.exists():
sys.exit(f"JSON {json_path} neexistuje — spusť nejdřív bez --from-json.")
results = json.loads(json_path.read_text(encoding="utf-8"))
print(f"✓ Načteno z JSON: {len(results)} kurzů")
login(s) # přihlášení potřeba pro stahování dokumentů
else:
login(s)
if a.professions.lower() == "all":
profs = [2, 4, 5, 6, 7]
else:
profs = [int(x) for x in a.professions.split(",") if x.strip()]
print(f"Sbírám kurzy (profese {profs})…")
courses = get_all_courses(s, profs)
print(f"✓ Nalezeno kurzů: {len(courses)}")
if a.limit:
courses = courses[: a.limit]
print(f" (--limit: zpracuji jen prvních {len(courses)})")
results = []
for i, c in enumerate(courses, 1):
try:
links = extract_course_links(s, c["url"])
except Exception as e:
links = {"videos": [], "documents": [], "error": str(e)}
course = {**c, **links}
results.append(course)
if db is not None and "error" not in links:
try:
_ingest_course(db, course)
except Exception as e:
print(f" [MONGO-CHYBA] {c['id']}: {e}")
print(f"[{i}/{len(courses)}] {c['title']}"
f"{len(links.get('videos', []))} videí, "
f"{len(links.get('documents', []))} dokumentů")
time.sleep(0.35)
json_path.write_text(
json.dumps(results, ensure_ascii=False, indent=2, default=str),
encoding="utf-8")
print(f"✓ Inventura uložena: {json_path}")
# souhrn inventury
n_vid = sum(len(c.get("videos", [])) for c in results)
n_doc = sum(len(c.get("documents", [])) for c in results)
print(f"\nCelkem: {len(results)} kurzů, {n_vid} videí, {n_doc} dokumentů")
if a.scrape_only:
return
# stahování
if a.limit:
results = results[: a.limit]
stat = {"doc_ok": 0, "doc_skip": 0, "doc_err": 0,
"vid_ok": 0, "vid_skip": 0, "vid_err": 0, "sw_ok": 0}
for i, c in enumerate(results, 1):
folder = out_root / bezpecny_nazev(f"{c['id']}-{c.get('slug', '')}", 80)
print(f"\n[{i}/{len(results)}] {c.get('title', c['id'])}")
if not a.no_docs:
for d in c.get("documents", []):
klic = material_klic("dokument", d)[0]
try:
stav, name = stahni_dokument(s, d["url"], folder / "dokumenty",
d.get("label", ""))
dest = folder / "dokumenty" / name
if stav == "staženo":
stat["doc_ok"] += 1
print(f" [DOK] {name}")
else:
stat["doc_skip"] += 1
if db is not None:
sz = dest.stat().st_size if dest.exists() else None
edb.set_status(db, c["id"], klic, edb.STAZENO,
soubor=_relpath(dest), velikost_b=sz)
if use_seaweed and dest.exists():
if _zaloh_do_seaweed(db, dest, out_root, c["id"], klic):
stat["sw_ok"] += 1
except Exception as e:
stat["doc_err"] += 1
print(f" [DOK-CHYBA] {d['url']} ({e})")
if db is not None:
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=str(e))
if not a.no_videos:
for v in c.get("videos", []):
klic = material_klic("video", v)[0]
stav, info, fp = stahni_video(v["embed"], folder / "videa", c["url"],
fmt=a.video_format, frags=a.frags)
if stav == "staženo":
stat["vid_ok"] += 1
print(f" [VIDEO] {info}")
if db is not None:
sz = (Path(fp).stat().st_size
if fp and Path(fp).exists() else None)
edb.set_status(db, c["id"], klic, edb.STAZENO,
soubor=_relpath(fp) if fp else None,
velikost_b=sz)
if use_seaweed and fp and Path(fp).exists():
if _zaloh_do_seaweed(db, fp, out_root, c["id"], klic):
stat["sw_ok"] += 1
elif stav == "přeskočeno":
stat["vid_skip"] += 1
print(f" [VIDEO-PŘESKOČENO] {info}")
if db is not None:
edb.set_status(db, c["id"], klic, edb.PRESKOCENO, duvod=info)
else:
stat["vid_err"] += 1
print(f" [VIDEO-CHYBA] {info}")
if db is not None:
edb.set_status(db, c["id"], klic, edb.CHYBA, chyba=info)
print("\n=== SOUHRN STAHOVÁNÍ ===")
print(f" dokumenty: {stat['doc_ok']} staženo, {stat['doc_skip']} přeskočeno, "
f"{stat['doc_err']} chyb")
print(f" videa: {stat['vid_ok']} staženo, {stat['vid_skip']} přeskočeno "
f"(soukromá/nedostupná), {stat['vid_err']} chyb")
if not a.no_seaweed:
print(f" SeaweedFS: {stat['sw_ok']} souborů zazálohováno")
print(f" výstup: {out_root}")
if __name__ == "__main__":
main()
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
01_zakoupeno.py — workflow status 0: Zakoupeno (datum 31.12.2024)
=================================================================
První stav reconciliačního workflow. Všem pacientům ze zakoupeného souboru
(CSV příloha smlouvy, 1712 RČ) nastaví status 0 "Zakoupeno" k 31.12.2024.
- Pacienti ze smlouvy, kteří už jsou v Mongo (registrovaní k 1.1.2025) → status 0.
- Pacienti ze smlouvy, kteří v Mongo nejsou (odhlášeni u Buzalkové před předáním)
→ doplněni z Medicus kar s markerem `mimo_vzp_populaci` + status 0.
- Pacienti v Mongo mimo smlouvu → označeni `ve_smlouve=False` (status 0 nedostanou).
Workflow stav drží:
status, status_popis, status_datum (aktuální stav)
status_historie[] (postup stavů — pro další kroky)
Idempotentní — opakované spuštění status 0 nezduplikuje.
"""
import csv
import re
import sys
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import pymongo
from Knihovny.medicus_db import get_medicus_connection
# ── Konfigurace ────────────────────────────────────────────────────────────────
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "ordinace"
MONGO_COLL = "registrovani_tracking"
CSV_PATH = Path(__file__).resolve().parent / "Inputs" / "2025-01-01 seznam_pacientu_jmeno_rc.csv"
STATUS = 0
STATUS_POPIS = "Zakoupeno"
STATUS_DATUM = "2024-12-31"
POJ_ZKR = {"111": "VZP", "201": "VoZP", "205": "ČPZP", "207": "OZP",
"209": "ZPŠ", "211": "ZPMV ČR", "213": "RBP"}
norm = lambda s: re.sub(r"\D", "", s or "")
def status_entry(now):
return {"status": STATUS, "status_popis": STATUS_POPIS,
"status_datum": STATUS_DATUM, "zapsano": now}
def main():
# ── CSV (zakoupený soubor) ──────────────────────────────────────────────────
csv_rc = {}
with CSV_PATH.open(encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
csv_rc[norm(row["Rodné číslo"])] = row["Příjmení a jméno"]
contract = set(csv_rc)
print(f"Zakoupený soubor (CSV): {len(contract)}")
cli = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)
coll = cli[MONGO_DB][MONGO_COLL]
coll.create_index("status")
coll.create_index("ve_smlouve")
pop = {norm(d["_id"]): d["_id"] for d in coll.find({}, {"_id": 1})}
popset = set(pop)
present = contract & popset
absent = contract - popset
extra = popset - contract
now = datetime.now(timezone.utc)
# ── 1) Pacienti ze smlouvy už v Mongo → status 0 (idempotentně) ─────────────
n_pres = 0
for rc in present:
_id = pop[rc]
d = coll.find_one({"_id": _id}, {"status_historie": 1})
sh = d.get("status_historie", [])
if not any(e.get("status") == STATUS for e in sh):
sh = sh + [status_entry(now)]
coll.update_one({"_id": _id}, {"$set": {
"status": STATUS, "status_popis": STATUS_POPIS, "status_datum": STATUS_DATUM,
"ve_smlouve": True, "status_historie": sh, "updated_at": now}})
n_pres += 1
# ── 2) Pacienti v Mongo mimo smlouvu → ve_smlouve = False ───────────────────
coll.update_many({"_id": {"$in": [pop[rc] for rc in extra]}},
{"$set": {"ve_smlouve": False, "updated_at": now}})
# ── 3) Pacienti ze smlouvy chybějící v Mongo → doplnit z kar + status 0 ─────
conn = get_medicus_connection()
cur = conn.cursor()
kar = {}
abslist = list(absent)
for i in range(0, len(abslist), 500):
b = abslist[i:i + 500]
ph = ",".join("?" for _ in b)
cur.execute(f"""
SELECT TRIM(k.rodcis), TRIM(k.prijmeni), TRIM(k.jmeno), TRIM(k.poj),
(SELECT MAX(r.datum_zruseni) FROM registr r JOIN icp i ON r.idicp=i.idicp
WHERE r.idpac=k.idpac AND i.icp='09305001' AND i.odb='001')
FROM kar k WHERE k.rodcis IN ({ph})""", b)
for rc, p, j, poj, zrus in cur.fetchall():
kar[(rc or "").strip()] = {"prijmeni": p, "jmeno": j,
"poj": (poj or "").strip(), "zruseni": zrus}
conn.close()
n_ins = 0
for rc in absent:
if coll.find_one({"_id": rc}):
continue
k = kar.get(rc, {})
poj = k.get("poj", "")
zrus = k.get("zruseni")
zrus_s = zrus.strftime("%Y-%m-%d") if zrus else None
snap = {
"k_datu": "2025-01-01", "kategorie": "ODHLASEN_PRED_PREDANIM",
"kategorie_popis": "Registrace u Buzalkové zrušena před předáním (1.1.2025)",
"v_zakoupenem_souboru": False,
"flag": "NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ",
"flag_duvod": f"registrace u Buzalkové zrušena {zrus_s} (před předáním)",
"praktik_nazev": None, "praktik_icz": None, "praktik_icp": None,
"praktik_od": None, "datum_zahajeni": None, "datum_ukonceni": None,
"medicus_zruseni": zrus_s,
}
coll.insert_one({
"_id": rc, "rc": rc,
"prijmeni": k.get("prijmeni"), "jmeno": k.get("jmeno"),
"pojistovna": {"kod": poj, "zkratka": POJ_ZKR.get(poj, poj)},
"medicus_poj": poj,
"status": STATUS, "status_popis": STATUS_POPIS, "status_datum": STATUS_DATUM,
"ve_smlouve": True, "mimo_vzp_populaci": True,
"vychozi_datum": "2025-01-01", "aktualni": snap,
"historie": [{**snap, "zmena": "doplněn ze smlouvy (mimo VZP populaci)"}],
"status_historie": [status_entry(now)],
"created_at": now, "updated_at": now,
})
n_ins += 1
# ── Souhrn ──────────────────────────────────────────────────────────────────
print(f"present (status 0 nastaveno) : {n_pres}")
print(f"absent doplněno ze smlouvy (insert) : {n_ins}")
print(f"extra mimo smlouvu (ve_smlouve=False): {len(extra)}")
print()
print(f"status 0 (Zakoupeno) celkem : {coll.count_documents({'status': 0})}")
print(f"ve_smlouve = True : {coll.count_documents({'ve_smlouve': True})}")
print(f"kolekce celkem : {coll.count_documents({})}")
cli.close()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
# FinalReconcilliation — sledování stavu registrovaných pacientů
## Cíl
Jednoznačně roztřídit pacienty **registrované v Medicusu** podle **skutečnosti ověřené u pojišťovny**:
kdo je k danému dni jejich registrující **praktik (odbornost 001)** dle VZP B2B.
- praktik = **Buzalková (IČP 09305001)** → pacient **je** v zakoupeném souboru pacientů (OK)
- praktik = kdokoli jiný / žádný → **„NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ"**
„Registrovaný v Medicusu" je jen stav v software; tohle ověřuje realitu u pojišťovny.
## Úložiště — MongoDB
| | |
|---|---|
| Server | `mongodb://192.168.1.76:27017` (stejný stroj jako MySQL `medevio`) |
| Databáze | `ordinace` |
| Kolekce | `registrovani_tracking` |
| Klíč | `_id` = rodné číslo (1 dokument na pacienta) |
### Schéma dokumentu
```json
{
"_id": "8202...", "rc": "8202...", "prijmeni": "...", "jmeno": "...",
"pojistovna": {"kod": "111", "zkratka": "VZP"},
"vychozi_datum": "2025-01-01",
"aktualni": { ...snímek... },
"historie": [ { ...snímek..., "zmena": "výchozí snímek" } ],
"created_at": ISODate, "updated_at": ISODate
}
```
Snímek (`aktualni` i položky `historie[]`):
`k_datu, kategorie, kategorie_popis, v_zakoupenem_souboru (bool), flag, flag_duvod,
praktik_nazev, praktik_icz, praktik_icp, praktik_od, datum_zahajeni, datum_ukonceni`
- **`praktik_nazev` / `praktik_icz` / `praktik_icp`** = KDO je registrující praktik dle VZP
(u `OK_BUZALKOVA` Buzalková, u `JINY_PRAKTIK` cizí ZZZ).
- **`praktik_od`** (= `datum_zahajeni`) = OD KDY je u tohoto praktika registrován.
- **`flag_duvod`** = čitelný důvod mimo soubor, např. `"jiný praktik: MOJE AMBULANCE A.S.
(IČZ 91777000) od 2014-01-01"`.
### Kategorie (plné podkategorie)
| kategorie | význam | v souboru |
|---|---|---|
| `OK_BUZALKOVA` | praktik 001 = Buzalková (IČP 09305001) | ✅ ano |
| `JINY_PRAKTIK` | praktik 001 je jiné ZZZ | 🚩 ne |
| `BEZ_PRAKTIKA_VZP` | u VZP záznam (jiná odb.), ale praktik 001 ne | 🚩 ne |
| `BEZ_ZAZNAMU_VZP` | VZP nevrátila žádný záznam (jiná pojišťovna / neplatné RČ / zaniklé pojištění) | 🚩 ne |
## Stav k výchozímu snímku 1.1.2025
Populace = 1688 pacientů registrovaných v Medicusu k 1.1.2025 (= RČ v `vzp_registrace_raw` pro to datum).
| kategorie | počet |
|---|---:|
| OK_BUZALKOVA | 1537 |
| JINY_PRAKTIK | 53 |
| BEZ_ZAZNAMU_VZP | 50 |
| BEZ_PRAKTIKA_VZP | 48 |
| **v souboru / mimo** | **1537 / 151** |
## Skript `seed_tracking.py`
Zdroj klasifikace = MySQL `medevio` tabulky `vzp_registrace_raw` + `vzp_registrace_lekari`
(plní je skripty z `Insurance/KdoJeLekar/`).
```
python seed_tracking.py # výchozí snímek k 2025-01-01
python seed_tracking.py 2026-05-02 # aplikuje další snímek (appendne změny do historie)
```
Funkce `apply_snapshot(coll, mysql, k_datu)`:
- nový pacient → vloží dokument s historií `["výchozí snímek"]`
- existující pacient → při změně `kategorie` nebo `praktik_icp` appendne položku do `historie[]`
a aktualizuje `aktualni`; jinak jen `updated_at`
→ tím se **postupně trackují změny stavu** mezi jednotlivými běhy.
### Doplnění jmen (BEZ_ZAZNAMU_VZP)
50 pacientů bez žádného VZP záznamu nemá jméno v MySQL `vzp_registrace_lekari`.
Jména + pojišťovnu jim doplňujeme z Medicus Firebird (tabulka `kar`) — uloženo i pole
`medicus_poj`. Pozn.: kdo má `medicus_poj=111` (VZP), ale je `BEZ_ZAZNAMU_VZP`, je reálně
podezřelý (zaniklé pojištění/úmrtí); 201/205/207/211 jsou prostě jiné pojišťovny.
## Reconciliation workflow — statusy
Zakoupený soubor (příloha smlouvy) = `Inputs/2025-01-01 seznam_pacientu_jmeno_rc.csv`
(OCR ze skenu; `;`-CSV, UTF-8 BOM; sloupce *Příjmení a jméno; Rodné číslo; Strana; Řádek*).
**1712 RČ.** (Opraven 1 OCR překlep RČ: Slavíková Zuzana `8956534235`→`8956039037`.)
Každý dokument nese workflow stav:
| pole | význam |
|---|---|
| `status` (int) | aktuální stav workflow |
| `status_popis` | název stavu |
| `status_datum` | datum platnosti stavu |
| `status_historie[]` | postup stavů (`status, status_popis, status_datum, zapsano`) |
| `ve_smlouve` (bool) | je pacient v zakoupeném souboru 1712? |
| `mimo_vzp_populaci` | true = doplněn ze smlouvy, nebyl ve VZP populaci k 1.1.2025 |
### Stavy
| status | popis | datum | skript |
|---|---|---|---|
| **0** | **Zakoupeno** | 31.12.2024 | `01_zakoupeno.py` |
`01_zakoupeno.py` (idempotentní): nastaví status 0 všem 1712 ze smlouvy.
- 1678 už v Mongo → status 0
- 34 chybělo (odhlášeni u Buzalkové před předáním) → doplněno z `kar`, `mimo_vzp_populaci=true`,
`aktualni.kategorie="ODHLASEN_PRED_PREDANIM"` + `medicus_zruseni`
- 10 v Mongo mimo smlouvu → `ve_smlouve=false` (status 0 nedostali)
Kolekce po kroku 0: **1722 dokumentů** (1712 ve smlouvě + 10 mimo).
### Reconciliation 1712 (k 1.1.2025)
```
1712 zakoupeno (status 0)
34 registrace zrušena před 1.1.2025 (mimo_vzp_populaci)
─────
1678 registrovaní v Medicusu k 1.1.2025
├ 1531 OK Buzalková · 50 jiný praktik · 49 bez záznamu · 48 bez praktika
```
## Další kroky (workflow)
- Definovat status 1, 2, … (např. 1 = ověřeno u VZP / registrovaný u Buzalkové).
- Aplikovat snímky z dalších běhů (29.4. a 2.5.2026 v MySQL) → naplní `historie[]`.
- Doplnit ověření **stavu pojištění** (`vzp_stav_pojisteni`).
- Finální reconciliation Excel + MCP nástroj nad kolekcí.
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
report_tracking.py
==================
Excel report nad MongoDB `ordinace.registrovani_tracking`.
Pro každého pacienta zobrazí:
jméno, datum narození, rodné číslo, pojišťovnu, stav a důvod (kdo + od kdy).
Identifikační údaje (jméno, datum narození, pojišťovna) se berou AUTORITATIVNĚ
z Medicus Firebird tabulky `kar` (přes Knihovny.medicus_db.get_medicus_connection).
Stav a důvod (kategorie, flag, flag_duvod, praktik kdo/od kdy) z Mongo trackingu.
Výstup: report_registrovani_<vychozi_datum>.xlsx v tomto adresáři.
"""
import sys
from pathlib import Path
from datetime import date
from collections import defaultdict
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import pymongo
from Knihovny.medicus_db import get_medicus_connection
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
# ── Konfigurace ────────────────────────────────────────────────────────────────
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "ordinace"
MONGO_COLL = "registrovani_tracking"
POJ_NAZVY = {
"111": "VZP", "201": "VoZP", "205": "ČPZP", "207": "OZP",
"209": "ZPŠ", "211": "ZPMV ČR", "213": "RBP",
}
# Barvy podle kategorie
BLUE_HEADER = "1F497D"
WHITE = "FFFFFF"
BARVA_KAT = {
"OK_BUZALKOVA": "EBF1DE", # zelená
"JINY_PRAKTIK": "FCE4D6", # červená
"BEZ_PRAKTIKA_VZP": "FFF2CC", # žlutá
"BEZ_ZAZNAMU_VZP": "DCE6F1", # modrá
}
STAV_TEXT = {
"OK_BUZALKOVA": "V souboru",
"JINY_PRAKTIK": "NEBYL v souboru",
"BEZ_PRAKTIKA_VZP": "NEBYL v souboru",
"BEZ_ZAZNAMU_VZP": "NEBYL v souboru",
}
def chunked(seq, n):
for i in range(0, len(seq), n):
yield seq[i:i + n]
def nacti_kar(conn, rcs):
"""Vrátí {rc: {prijmeni, jmeno, datnar, poj}} z Medicus kar."""
out = {}
cur = conn.cursor()
for batch in chunked(rcs, 500): # Firebird IN má limit 1500 prvků
ph = ",".join("?" for _ in batch)
cur.execute(
f"SELECT TRIM(rodcis), TRIM(prijmeni), TRIM(jmeno), datnar, TRIM(poj) "
f"FROM kar WHERE rodcis IN ({ph})", batch)
for rc, prij, jm, datnar, poj in cur.fetchall():
out[(rc or "").strip()] = {
"prijmeni": prij, "jmeno": jm,
"datnar": datnar, "poj": (poj or "").strip(),
}
return out
def main():
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)
coll = client[MONGO_DB][MONGO_COLL]
docs = list(coll.find({}))
vychozi = docs[0].get("vychozi_datum", "snimek") if docs else "snimek"
rcs = [d["_id"] for d in docs]
print(f"Pacientů v trackingu: {len(rcs)}")
print("Načítám kar z Medicusu ...")
conn = get_medicus_connection()
kar = nacti_kar(conn, rcs)
conn.close()
print(f"Dohledáno v kar: {len(kar)}")
# ── Sestavení řádků ────────────────────────────────────────────────────────
rows = []
for d in docs:
rc = d["_id"]
a = d.get("aktualni", {})
k = kar.get(rc, {})
prijmeni = k.get("prijmeni") or d.get("prijmeni") or ""
jmeno = k.get("jmeno") or d.get("jmeno") or ""
datnar = k.get("datnar")
poj_kod = k.get("poj") or (d.get("pojistovna") or {}).get("kod") or ""
kat = a.get("kategorie", "")
rows.append({
"prijmeni": prijmeni,
"jmeno": jmeno,
"datnar": datnar.strftime("%d.%m.%Y") if datnar else "",
"rc": rc,
"poj": f"{poj_kod} {POJ_NAZVY.get(poj_kod, '')}".strip(),
"stav": STAV_TEXT.get(kat, kat),
"kategorie": a.get("kategorie_popis", ""),
"duvod": a.get("flag_duvod", ""),
"kat_kod": kat,
})
# Řazení: nejdřív flagnutí (mimo soubor), pak podle příjmení
rows.sort(key=lambda r: (r["kat_kod"] == "OK_BUZALKOVA", r["prijmeni"], r["jmeno"]))
# ── Excel ──────────────────────────────────────────────────────────────────
wb = Workbook()
# List 1: Přehled
ws_p = wb.active
ws_p.title = "Přehled"
ws_p.column_dimensions["A"].width = 34
ws_p.column_dimensions["B"].width = 14
ws_p.merge_cells("A1:B1")
t = ws_p["A1"]
t.value = f"Registrovaní pacienti k {vychozi} — ověření praktika u VZP"
t.font = Font(name="Arial", bold=True, size=13, color=WHITE)
t.fill = PatternFill("solid", fgColor=BLUE_HEADER)
t.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws_p.row_dimensions[1].height = 34
ws_p["A2"] = f"Vygenerováno: {date.today().strftime('%d.%m.%Y')}"
ws_p["A2"].font = Font(name="Arial", italic=True, size=9, color="595959")
counts = defaultdict(int)
for r in rows:
counts[r["kat_kod"]] += 1
ws_p.cell(row=4, column=1, value="Kategorie / stav").font = Font(bold=True)
ws_p.cell(row=4, column=2, value="Počet").font = Font(bold=True)
poradi = ["OK_BUZALKOVA", "JINY_PRAKTIK", "BEZ_PRAKTIKA_VZP", "BEZ_ZAZNAMU_VZP"]
KAT_POPIS = {
"OK_BUZALKOVA": "V souboru (praktik Buzalková)",
"JINY_PRAKTIK": "Mimo soubor — jiný praktik",
"BEZ_PRAKTIKA_VZP": "Mimo soubor — bez praktika u VZP",
"BEZ_ZAZNAMU_VZP": "Mimo soubor — bez záznamu u VZP",
}
for i, kat in enumerate(poradi):
r = 5 + i
c1 = ws_p.cell(row=r, column=1, value=KAT_POPIS[kat])
c2 = ws_p.cell(row=r, column=2, value=counts[kat])
fill = PatternFill("solid", fgColor=BARVA_KAT[kat])
c1.fill = fill; c2.fill = fill
c1.font = Font(name="Arial", size=10)
ws_p.cell(row=9, column=1, value="CELKEM").font = Font(bold=True)
ws_p.cell(row=9, column=2, value=len(rows)).font = Font(bold=True)
mimo = len(rows) - counts["OK_BUZALKOVA"]
ws_p.cell(row=10, column=1, value="z toho NEBYL v zakoupeném souboru").font = Font(bold=True, color="C00000")
ws_p.cell(row=10, column=2, value=mimo).font = Font(bold=True, color="C00000")
# List 2: Pacienti
ws = wb.create_sheet("Pacienti")
COLS = [
("Příjmení", 20), ("Jméno", 14), ("Datum narození", 14),
("Rodné číslo", 14), ("Pojišťovna", 14), ("Stav", 16),
("Kategorie", 30), ("Důvod (kdo / od kdy)", 52),
]
for ci, (h, w) in enumerate(COLS, 1):
c = ws.cell(row=1, column=ci, value=h)
c.font = Font(name="Arial", bold=True, color=WHITE, size=10)
c.fill = PatternFill("solid", fgColor=BLUE_HEADER)
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws.column_dimensions[get_column_letter(ci)].width = w
ws.row_dimensions[1].height = 30
ws.freeze_panes = "A2"
thin = Side(style="thin", color="D9D9D9")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
for ri, r in enumerate(rows, 2):
bg = BARVA_KAT.get(r["kat_kod"], "FFFFFF")
data = [r["prijmeni"], r["jmeno"], r["datnar"], r["rc"], r["poj"],
r["stav"], r["kategorie"], r["duvod"]]
for ci, val in enumerate(data, 1):
c = ws.cell(row=ri, column=ci, value=val)
c.font = Font(name="Arial", size=9)
c.fill = PatternFill("solid", fgColor=bg)
c.border = border
c.alignment = Alignment(vertical="center", wrap_text=(ci == 8))
if ci == 6 and r["kat_kod"] != "OK_BUZALKOVA":
c.font = Font(name="Arial", size=9, bold=True, color="C00000")
ws.auto_filter.ref = f"A1:{get_column_letter(len(COLS))}{len(rows) + 1}"
out = Path(__file__).resolve().parent / f"report_registrovani_{vychozi}.xlsx"
wb.save(out)
print(f"\nUloženo: {out}")
print(f"Řádků: {len(rows)} | v souboru: {counts['OK_BUZALKOVA']} | mimo: {mimo}")
client.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
seed_tracking.py
================
Naplní MongoDB databázi `ordinace`, kolekci `registrovani_tracking`, výchozím
snímkem registrovaných pacientů a jejich OVĚŘENÝM stavem u VZP.
Logika "v zakoupeném souboru pacientů":
- "Registrovaný v Medicusu" je jen stav v software.
- Skutečnost ověřujeme u pojišťovny: kdo je k danému dni registrující praktik
(odbornost 001) daného pacienta.
* praktik = Buzalková (IČP 09305001) -> v pořádku, v zakoupeném souboru
* praktik = někdo jiný / žádný -> NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ
Kategorie (plné podkategorie):
OK_BUZALKOVA praktik 001 je Buzalková (IČP 09305001)
JINY_PRAKTIK praktik 001 je jiné ZZZ
BEZ_PRAKTIKA_VZP pacient má u VZP záznam (jiná odbornost), ale praktika 001 ne
BEZ_ZAZNAMU_VZP VZP nevrátila žádný záznam (typicky jiná pojišťovna / neplatné RČ)
Schéma dokumentu (1 dokument na pacienta, _id = rodné číslo):
{
"_id": "8202...", "rc": "...", "prijmeni": "...", "jmeno": "...",
"pojistovna": {"kod": "111", "zkratka": "VZP"},
"vychozi_datum": "2025-01-01",
"aktualni": { ...snímek... },
"historie": [ { ...snímek..., "zmena": "výchozí snímek" }, ... ],
"created_at": ..., "updated_at": ...
}
Snímek (aktualni i položka historie):
{ "k_datu", "kategorie", "kategorie_popis", "v_zakoupenem_souboru" (bool),
"flag", "praktik_nazev", "praktik_icz", "praktik_icp",
"datum_zahajeni", "datum_ukonceni" }
Spuštění:
python seed_tracking.py # seed k 2025-01-01
python seed_tracking.py 2026-05-02 # aplikuje další snímek (appendne změny do historie)
"""
import sys
from pathlib import Path
from datetime import datetime, date, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import pymongo
from Knihovny.mysql_db import connect_mysql
# ── KONFIGURACE ───────────────────────────────────────────────────────────────
MONGO_URI = "mongodb://192.168.1.76:27017"
MONGO_DB = "ordinace"
MONGO_COLL = "registrovani_tracking"
ICP_BUZALKOVA = "09305001"
KATEGORIE_POPIS = {
"OK_BUZALKOVA": "OK praktik je Buzalková (IČP 09305001)",
"JINY_PRAKTIK": "Registrován u jiného praktika",
"BEZ_PRAKTIKA_VZP": "U VZP bez praktika (odb. 001)",
"BEZ_ZAZNAMU_VZP": "VZP nevrátila žádný záznam (jiná pojišťovna / neplatné RČ)",
}
FLAG_MIMO_SOUBOR = "NEBYL V ZAKOUPENÉM SOUBORU PACIENTŮ"
def klasifikuj(praktik_001: dict | None, ma_nejaky_zaznam: bool) -> dict:
"""Vrátí snímek stavu (bez k_datu) na základě 001 záznamu z VZP."""
if praktik_001 and praktik_001.get("ICP") == ICP_BUZALKOVA:
kat = "OK_BUZALKOVA"
elif praktik_001:
kat = "JINY_PRAKTIK"
elif ma_nejaky_zaznam:
kat = "BEZ_PRAKTIKA_VZP"
else:
kat = "BEZ_ZAZNAMU_VZP"
v_souboru = (kat == "OK_BUZALKOVA")
nazev = (praktik_001 or {}).get("nazev_zzz")
icz = (praktik_001 or {}).get("ICZ")
od = (praktik_001 or {}).get("datum_zahajeni")
# Čitelný důvod, proč pacient NENÍ v zakoupeném souboru (kdo + od kdy)
if kat == "JINY_PRAKTIK":
flag_duvod = f"jiný praktik: {nazev} (IČZ {icz}) od {od}"
elif kat == "BEZ_PRAKTIKA_VZP":
flag_duvod = "u VZP bez registrujícího praktika (odb. 001)"
elif kat == "BEZ_ZAZNAMU_VZP":
flag_duvod = "VZP nevrátila žádný záznam (jiná pojišťovna / neplatné RČ / zaniklé pojištění)"
else:
flag_duvod = ""
return {
"kategorie": kat,
"kategorie_popis": KATEGORIE_POPIS[kat],
"v_zakoupenem_souboru": v_souboru,
"flag": "" if v_souboru else FLAG_MIMO_SOUBOR,
"flag_duvod": flag_duvod,
# "kdo" a "od kdy" registrujícího praktika dle VZP
"praktik_nazev": nazev,
"praktik_icz": icz,
"praktik_icp": (praktik_001 or {}).get("ICP"),
"praktik_od": od,
"datum_zahajeni": od,
"datum_ukonceni": (praktik_001 or {}).get("datum_ukonceni"),
}
def nacti_snimek_z_mysql(mysql, k_datu: str) -> dict:
"""
Vrátí {rc: {prijmeni, jmeno, pojistovna{}, praktik_001 | None, ma_zaznam}}
pro populaci registrovaných dotázaných k danému datu.
"""
cur = mysql.cursor()
# Populace = všechna dotázaná RČ (raw) k tomuto datu
cur.execute("SELECT rc FROM vzp_registrace_raw WHERE k_datu = %s", (k_datu,))
populace = [r[0] for r in cur.fetchall()]
# Parsované záznamy lékařů k tomuto datu
cur.execute("""
SELECT rc, prijmeni, jmeno, kod_odbornosti, ICP, ICZ, nazev_zzz,
poj_kod, poj_zkratka, datum_zahajeni, datum_ukonceni
FROM vzp_registrace_lekari
WHERE k_datu = %s
""", (k_datu,))
data: dict[str, dict] = {rc: {"prijmeni": None, "jmeno": None,
"pojistovna": {"kod": None, "zkratka": None},
"praktik_001": None, "ma_zaznam": False}
for rc in populace}
for (rc, prijmeni, jmeno, odb, icp, icz, nazev_zzz,
poj_kod, poj_zkr, dat_zah, dat_uk) in cur.fetchall():
d = data.setdefault(rc, {"prijmeni": None, "jmeno": None,
"pojistovna": {"kod": None, "zkratka": None},
"praktik_001": None, "ma_zaznam": False})
d["ma_zaznam"] = True
if prijmeni and not d["prijmeni"]:
d["prijmeni"] = prijmeni
if jmeno and not d["jmeno"]:
d["jmeno"] = jmeno
# Pojišťovnu vezmi z jakéhokoli záznamu (preferuj 001 níže)
if poj_kod and not d["pojistovna"]["kod"]:
d["pojistovna"] = {"kod": poj_kod, "zkratka": poj_zkr}
if odb == "001":
d["praktik_001"] = {
"ICP": icp, "ICZ": icz, "nazev_zzz": nazev_zzz,
"poj_kod": poj_kod, "poj_zkratka": poj_zkr,
"datum_zahajeni": str(dat_zah) if dat_zah else None,
"datum_ukonceni": str(dat_uk) if dat_uk else None,
}
# Pojišťovna z 001 má přednost
if poj_kod:
d["pojistovna"] = {"kod": poj_kod, "zkratka": poj_zkr}
return data
def apply_snapshot(coll, mysql, k_datu: str) -> dict:
"""
Klasifikuje populaci k danému datu a upsertne do Mongo.
Při změně kategorie/praktika oproti `aktualni` appendne do `historie`.
Vrátí statistiku.
"""
data = nacti_snimek_z_mysql(mysql, k_datu)
now = datetime.now(timezone.utc)
stats = {"novych": 0, "zmen": 0, "beze_zmeny": 0, "kategorie": {}}
for rc, d in data.items():
snimek = klasifikuj(d["praktik_001"], d["ma_zaznam"])
snimek_s_datem = {"k_datu": k_datu, **snimek}
stats["kategorie"][snimek["kategorie"]] = stats["kategorie"].get(snimek["kategorie"], 0) + 1
existing = coll.find_one({"_id": rc})
if existing is None:
doc = {
"_id": rc, "rc": rc,
"prijmeni": d["prijmeni"], "jmeno": d["jmeno"],
"pojistovna": d["pojistovna"],
"vychozi_datum": k_datu,
"aktualni": snimek_s_datem,
"historie": [{**snimek_s_datem, "zmena": "výchozí snímek"}],
"created_at": now, "updated_at": now,
}
coll.insert_one(doc)
stats["novych"] += 1
else:
akt = existing.get("aktualni", {})
zmena = (akt.get("kategorie") != snimek["kategorie"]
or akt.get("praktik_icp") != snimek["praktik_icp"])
update = {"aktualni": snimek_s_datem, "updated_at": now}
if d["prijmeni"]:
update["prijmeni"] = d["prijmeni"]
if d["jmeno"]:
update["jmeno"] = d["jmeno"]
ops = {"$set": update}
if zmena:
popis = (f"{akt.get('kategorie')}{snimek['kategorie']}")
ops["$push"] = {"historie": {**snimek_s_datem, "zmena": popis}}
stats["zmen"] += 1
else:
stats["beze_zmeny"] += 1
coll.update_one({"_id": rc}, ops)
return stats
def main():
k_datu = sys.argv[1] if len(sys.argv) > 1 else "2025-01-01"
mysql = connect_mysql()
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)
client.admin.command("ping")
coll = client[MONGO_DB][MONGO_COLL]
# Indexy pro běžné dotazy
coll.create_index("aktualni.kategorie")
coll.create_index("aktualni.v_zakoupenem_souboru")
coll.create_index("prijmeni")
print(f"Aplikuji snímek k {k_datu} do {MONGO_DB}.{MONGO_COLL} ...")
stats = apply_snapshot(coll, mysql, k_datu)
print(f"\nNových pacientů : {stats['novych']}")
print(f"Změn stavu : {stats['zmen']}")
print(f"Beze změny : {stats['beze_zmeny']}")
print("\nRozpad podle kategorií:")
for kat, n in sorted(stats["kategorie"].items(), key=lambda x: -x[1]):
print(f" {kat:18s} {n:5d} {KATEGORIE_POPIS[kat]}")
celkem = sum(stats["kategorie"].values())
mimo = celkem - stats["kategorie"].get("OK_BUZALKOVA", 0)
print(f"\nCelkem v populaci: {celkem}")
print(f" v zakoupeném souboru (Buzalková): {stats['kategorie'].get('OK_BUZALKOVA', 0)}")
print(f" NEBYL v zakoupeném souboru : {mimo}")
mysql.close()
client.close()
if __name__ == "__main__":
main()
@@ -1,4 +1,4 @@
# KdoJeLékař — poznámky k vývoji
# KdoJeLekar — poznámky k vývoji
## Cíl
@@ -0,0 +1,87 @@
# VZP (111) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuVZP.py` (Playwright + Chrome):
1. **Přihlásí se** certifikátem na VZP Point (auto-výběr cert z Windows store)
2. Projde **ODESLANÁ PODÁNÍ** (řazeno od nejnovějšího) a najde podání typu
„Seznam registrovaných pojištěnců"
3. Stahuje **přiložené datové dávky** `F111MMRR.nnn` (CP852) do
`…\Zúčtovací zprávy\SeznamyPojištěnců\` od nejnovějšího a **zastaví se na první
už stažené dávce** (inkrementálně — starší jsou stažené, nejde hluboko do minulosti).
4. **Podá novou žádost** o výpis (datové rozhraní) za nejnovější dostupné období
(zjištěno z configu) — výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se příště.
Dávky pak zpracovává `Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py`.
## Platforma — ODLIŠNÁ
VZP běží na **point.vzp.cz** (VZP Point), NE portalzp.cz ani eforms. Login je
certifikátem přes Chrome — politika `AutoSelectCertificateForUrls` vybere cert
automaticky (issuer `I.CA Public CA/RSA 06/2022`), bez NMSigneru. Plně Playwright.
## Jak se seznam získává
VZP seznam **není** samočinná zpráva — musí se **požádat podáním**:
- NOVÉ PODÁNÍ → „Seznam registrovaných pojištěnců ke dni"
- **Formát výstupu = „Datové rozhraní"** (NE „PDF"!) + období (měsíc/rok)
- VZP požadavek zpracuje (~minuty) a výsledek = datová dávka III-1.1.2,
stažitelná z detailu zpracovaného podání (sloupec „Přiložený soubor").
> Pozn.: pokud se zvolí formát „PDF", výsledkem je PDF (p…pdf), které parser neumí.
> Vždy volit „Datové rozhraní".
## Formát dávky (III-1.1.2)
Soubor `F111MMRR.nnn`, pevná šířka, **CP852**. Hlavička typ H:
`H09305001` (IČP) + počet + RRMMDD. Věty typu I: příjmení, jméno, číslo poj.,
datum registrace, kód pojišťovny. (Detaily v `SeznamPojistencu/01_parse_seznam_dg_tool.py`.)
## Stažení dávky z detailu podání
Detail `/Desk/Form/Detail/{id}` → záložka „Výsledky zpracování" → odkaz s názvem
`F111MMRR.nnn` (href="#", JS handler). Stahuje se Playwright klikem
(`expect_download` + `dispatch_event('click')`) — žádná přímá URL.
## Podání žádosti (REST API — bez podpisu!)
Podání jde čistě přes REST API Pointu (Bearer token z inline `"bearerToken"` na dashboardu),
**žádný elektronický podpis** — autentizace stačí přes session + token. Tři kroky:
1. **Config** (zjištění období): `GET /api/desk/draft/form65/config`
`periodLimits {from, until}` + `defaultModel.period {month, year}`.
Podává se za **nejnovější dostupné období** (`until` / `defaultModel`), ne za kalendářní
měsíc (ten portál odmítne — HTTP 400 při publish).
2. **Vytvoř koncept**: `POST /api/desk/draft/form65/{partnerId}`
body `{"outputFormat":"Text","period":{"month":M,"year":Y}}``{"draftId":"...","state":"Verified"}`
- `outputFormat:"Text"` = **Datové rozhraní** (NE "Pdf"!)
- partnerId = `3197807` (subjekt MUDr. Buzalková)
3. **Publikuj**: `POST /api/desk/draft/form65/{draftId}/publish` (prázdné tělo)
`{"formId": <id odeslaného podání>}`
Token se čte stejně jako v `StahováníZpráv/111 VZP/stahovanipodani.py`.
### Jak bylo zjištěno
Formulář Form65 je React SPA s custom comboboxem, který nešel proklikat headless ani
naslepo. Odchyceno tak, že uživatel podal jedno podání ručně a do stránky byl vložen
háček ukládající fetch/XHR do `localStorage` (přežije přesměrování) — z toho se vyčetly
přesné endpointy a payloady.
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuVZP.py` | Login + stažení datových dávek z podání |
## Parametry
- **IČP**: 09305001, **IČZ**: 09305000 (MUDr. Michaela Buzalková)
- **Login**: certifikát ve Windows store (sdílený profil `StahováníZpráv/111 VZP/chrome_profile`)
## Stav
Hotovo a otestováno (17.06.2026): login ✓, backfill 23 dávek `F111….0NN` (všechny `H09305001`),
inkrementální běh zastaví na první už stažené dávce ✓, **podání žádosti přes REST API ✓**
(auto období z configu = 04/2026, create+publish → formId). Download i podání plně automatické.
@@ -0,0 +1,322 @@
"""
Stahování seznamu registrovaných pojištěnců VZP (111) — VZP Point (Playwright).
VZP běží na ODLIŠNÉ platformě (point.vzp.cz) — ne portalzp.cz, ne eforms:
- login: certifikát přes Chrome (auto-výběr z Windows store, politika
AutoSelectCertificateForUrls), Playwright. Bez NMSigneru.
- seznam: požaduje se podáním "Seznam registrovaných pojištěnců" s formátem
výstupu "Datové rozhraní". Výsledek = datová dávka III-1.1.2
(soubor F111MMRR.nnn, CP852, hlavička H09305001), stažitelná
z detailu zpracovaného podání.
Tento skript STAHUJE výsledky už zpracovaných podání "Seznam registrovaných
pojištěnců" (datová dávka) do složky SeznamyPojištěnců.
Podání žádosti (NOVÉ PODÁNÍ) zatím dělá uživatel ručně na portálu — viz NOTES.md.
Soubory dávek pak zpracovává Insurance/SeznamPojistencu/01_parse_seznam_dg_tool.py.
"""
import json
import os
import re
import sys
import time
import winreg
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
POINT_URL = "https://point.vzp.cz"
DASHBOARD_URL = f"{POINT_URL}/Desk/FormDashboard"
INBOX_URL = f"{POINT_URL}/Inbox/Message"
# Sdílené s VZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "111 VZP"))
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vzp_cookies.json")
DEST_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
CERT_ISSUER_CN = "I.CA Public CA/RSA 06/2022"
# Název podání i přílohy
PODANI_NAZEV = "Seznam registrovaných pojištěnců"
DAVKA_RE = re.compile(r"^F\d{7}\.\d+$") # F111MMRR.nnn
# Podání žádosti (REST API, ověřeno odchytem)
PARTNER_ID = "3197807" # subjekt MUDr. Buzalková (partnerId z formuláře Form65)
OUTPUT_FORMAT = "Text" # "Text" = Datové rozhraní (NE "Pdf"!)
# Období podávané žádosti se zjistí automaticky z configu (nejnovější dostupné, viz
# config.defaultModel / periodLimits.until). Pro ruční přepsání nastav OVERRIDE_OBDOBI
# na (měsíc, rok), jinak ponech None.
OVERRIDE_OBDOBI: tuple[int, int] | None = None
# Kolikrát max. kliknout 'Načíst další' při hledání podání (dashboard míchá typy).
# Stahování se stejně zastaví na první už stažené dávce, takže do minulosti nejde hluboko.
MAX_LOADS = 8
def _set_chrome_cert_policy() -> None:
policy = json.dumps({"pattern": "https://[*.]vzp.cz",
"filter": {"ISSUER": {"CN": CERT_ISSUER_CN}}})
try:
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Policies\Google\Chrome\AutoSelectCertificateForUrls")
winreg.SetValueEx(key, "1", 0, winreg.REG_SZ, policy)
winreg.CloseKey(key)
except Exception as e:
print(f" Varování: nelze nastavit Chrome politiku: {e}")
def _load_cookies(context) -> int:
if not os.path.exists(COOKIES_FILE):
return 0
try:
with open(COOKIES_FILE, encoding="utf-8") as f:
context.add_cookies(json.load(f))
return 1
except Exception:
return 0
def _save_cookies(context) -> None:
try:
vzp = [c for c in context.cookies() if "vzp.cz" in c.get("domain", "")]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(vzp, f, indent=2, ensure_ascii=False)
except Exception:
pass
def prihlaseni(context):
"""Zajistí přihlášení na VZP Point. Vrátí přihlášenou page."""
_load_cookies(context)
page = context.new_page()
page.goto(DASHBOARD_URL, wait_until="domcontentloaded", timeout=30_000)
if page.url.startswith("https://auth.vzp.cz/signin"):
print("Přihlašuji certifikátem...")
cert_btn = page.locator("a, button").filter(has_text=re.compile(r"certifikát", re.I)).first
cert_btn.wait_for(state="visible", timeout=10_000)
cert_btn.click(no_wait_after=True)
try:
page.wait_for_url("https://point.vzp.cz/**", timeout=60_000)
except Exception:
pass
if not page.url.startswith(POINT_URL):
raise RuntimeError(f"Přihlášení selhalo. URL: {page.url}")
print("Přihlášení OK.")
_save_cookies(context)
return page
def _bearer_token(page) -> str:
"""Vytáhne Bearer token z inline <script> na stránce VZP Point."""
scripts = page.evaluate(
"() => Array.from(document.querySelectorAll('script:not([src])')).map(s => s.textContent)"
)
for text in scripts:
m = re.search(r'"bearerToken"\s*:\s*"([^"]+)"', text)
if m:
return m.group(1)
raise RuntimeError("bearerToken nenalezen na stránce")
def zjisti_obdobi(page) -> tuple[int, int]:
"""Vrátí nejnovější dostupné období (měsíc, rok) z configu formuláře Form65."""
token = _bearer_token(page)
cfg = page.evaluate(
"""async (token) => {
const r = await fetch('/api/desk/draft/form65/config',
{headers:{'Authorization':'Bearer '+token, 'Accept':'application/json'}});
return await r.json();
}""",
token,
)
period = (cfg.get("defaultModel") or {}).get("period") \
or (cfg.get("periodLimits") or {}).get("until") or {}
return int(period["month"]), int(period["year"])
def podej_zadost(page, mesic: int, rok: int) -> int | None:
"""Podá žádost 'Seznam registrovaných pojištěnců' (datové rozhraní) za období mesic/rok.
Vytvoří koncept (POST .../form65/{partnerId}) a publikuje ho
(POST .../form65/{draftId}/publish). Vrátí formId odeslaného podání nebo None.
"""
token = _bearer_token(page)
res = page.evaluate(
"""async ({token, partner, fmt, mesic, rok}) => {
const h = {'Authorization':'Bearer '+token,
'Content-Type':'application/json', 'Accept':'application/json'};
const r1 = await fetch('/api/desk/draft/form65/'+partner, {
method:'POST', headers:h,
body: JSON.stringify({outputFormat: fmt, period: {month: mesic, year: rok}})});
let j1=null; try { j1 = await r1.json(); } catch(e){}
if (!r1.ok || !j1 || !j1.draftId)
return {ok:false, step:'create', status:r1.status, body: JSON.stringify(j1)};
const r2 = await fetch('/api/desk/draft/form65/'+j1.draftId+'/publish', {
method:'POST', headers:h});
let j2=null; try { j2 = await r2.json(); } catch(e){}
return {ok: r2.ok, step:'publish', status:r2.status,
formId: j2 && j2.formId, state: j1.state};
}""",
{"token": token, "partner": PARTNER_ID, "fmt": OUTPUT_FORMAT, "mesic": mesic, "rok": rok},
)
if res.get("ok"):
print(f" OK — podání odesláno, formId: {res.get('formId')} (stav konceptu: {res.get('state')})")
return res.get("formId")
print(f" Podání selhalo ({res.get('step')}, HTTP {res.get('status')}): {res.get('body','')[:200]}")
return None
def _seznam_podani_v_dom(page) -> list[dict]:
"""Vrátí podání 'Seznam registrovaných pojištěnců' aktuálně načtená v DOMu (pořadí = nejnovější první)."""
podani = page.evaluate(r"""() => {
return Array.from(document.querySelectorAll('a[href*="/Desk/Form/Detail/"]'))
.map(a => ({ text: (a.innerText || a.title || '').replace(/\s+/g, ' ').trim(),
href: a.getAttribute('href') }))
.filter(x => /Seznam registrovaných pojištěnců/i.test(x.text));
}""")
seen, out = set(), []
for p in podani:
if p["href"] in seen:
continue
seen.add(p["href"])
out.append(p)
return out
def _nacti_dalsi(page) -> bool:
"""Klikne 'Načíst další záznamy'. Vrátí True pokud tlačítko existovalo."""
clicked = page.evaluate("""() => {
const a = Array.from(document.querySelectorAll('a,button'))
.find(e => /Načíst další/i.test(e.innerText || ''));
if (a) { a.scrollIntoView(); a.click(); return true; }
return false;
}""")
if clicked:
page.wait_for_timeout(1500)
return clicked
def stahni_davku_z_podani(page, href: str, already: set) -> tuple[int, bool]:
"""Otevře detail podání a stáhne přiloženou datovou dávku (F...).
Vrátí (počet_stažených, narazil_na_uz_stazenou). Druhý příznak je True, pokud
má podání dávku, kterou už máme v archivu — signál, že jsme dorazili do už
stažené minulosti a stahování lze ukončit.
"""
page.goto(POINT_URL + href, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2000)
fnames = page.evaluate(r"""() => Array.from(document.querySelectorAll('a'))
.map(a => (a.innerText || '').trim())
.filter(t => /^F\d{7}\.\d+$/.test(t))""")
fnames = list(dict.fromkeys(fnames))
downloaded = 0
hit_existing = False
for fname in fnames:
if fname in already or os.path.exists(os.path.join(DEST_DIR, fname)):
print(f" [stop] dávka už stažena: {fname}")
hit_existing = True
continue
link = page.locator("a", has_text=fname).first
try:
with page.expect_download(timeout=30_000) as di:
link.dispatch_event("click")
body = di.value
target = os.path.join(DEST_DIR, fname)
body.save_as(target)
with open(target, "rb") as fh:
head = fh.read(9)
if not head.decode("cp852", errors="ignore").startswith("H09305001"):
print(f" POZOR: {fname} nemá hlavičku H09305001 (přesto uloženo)")
print(f" OK: {fname}")
already.add(fname)
downloaded += 1
time.sleep(0.5)
except Exception as e:
print(f" Chyba při stahování {fname}: {e}")
return downloaded, hit_existing
def hlavni() -> None:
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
sys.exit(1)
os.makedirs(DEST_DIR, exist_ok=True)
_set_chrome_cert_policy()
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
accept_downloads=True,
args=["--force-renderer-accessibility"],
)
try:
page = prihlaseni(context)
already = set(os.listdir(DEST_DIR))
print(f"V archivu: {len(already)} souborů.\n")
# Nasbírej podání 'Seznam...' — ODESLANÁ PODÁNÍ řadí od nejnovějšího.
# Dashboard míchá typy podání, proto je potřeba pár 'Načíst další'.
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2500)
for _ in range(MAX_LOADS):
if not _nacti_dalsi(page):
break
podani = _seznam_podani_v_dom(page)
print(f"Nalezeno podání '{PODANI_NAZEV}': {len(podani)}\n")
# Stahuj od nejnovějšího; jakmile narazíš na už staženou dávku, skonči
# (starší jsou všechny stažené — není třeba jít hlouběji do minulosti).
celkem = 0
for pdn in podani:
print(f"Podání: {pdn['text']}")
dl, hit_existing = stahni_davku_z_podani(page, pdn["href"], already)
celkem += dl
if hit_existing:
print("Dosaženo už stažené dávky — končím (starší jsou stažené).")
break
print(f"\nStaženo nových dávek: {celkem}")
# Podání žádosti o nový výpis (datové rozhraní) za zvolené období.
# Výsledek dorazí do ODESLANÝCH PODÁNÍ a stáhne se při příštím spuštění.
page.goto(DASHBOARD_URL, wait_until="networkidle", timeout=40_000)
page.wait_for_timeout(2000)
mesic, rok = OVERRIDE_OBDOBI if OVERRIDE_OBDOBI else zjisti_obdobi(page)
print(f"\n=== Podávám žádost za období {mesic:02d}/{rok} ===")
podej_zadost(page, mesic, rok)
print("\nHotovo.")
finally:
_save_cookies(context)
context.close()
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,87 @@
# VoZP (201) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuVoZP.py` provede v jednom spuštění:
1. **Přihlásí se** certifikátem na portál VoZP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/201 VoZP/vozp_cookies.json`
2. **Stáhne nové výpisy** ze schránky `vypis-registrovanych-pacientu-praktickeho-lekare`
— stahuje soubory s hlavičkou `H09305001` (PDF protokoly se přeskočí)
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o aktuální výpis (datové rozhraní)
## Platforma
VoZP běží na stejné platformě jako **ZPŠ, OZP, RBP** (portalzp.cz / json-api).
Login identický, jen `BASE_URL = https://portal.vozp.cz`.
## Schránka a stažení
Schránka má **vlastní URL** (ne `schranky-vypis-pojistencu-v-kapitaci` jako OZP/RBP):
`/app/vypis-registrovanych-pacientu-praktickeho-lekare`
Stažení přílohy: GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datové soubory `f201MMRR.001`, hlavička `H09305001`.
Ve schránce bývá i PDF protokol — header checkem se přeskočí.
## Podání žádosti
Formulář `106-zadost-o-vypis` je **nejjednodušší** — jen IČZ + Třídění, žádné datum ani typ.
Výpis je aktuální snímek registrovaných pacientů. Pro datový soubor se volí třídění = `d`
(Datové rozhraní). Žádný stav.json.
POST `https://portal.vozp.cz/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">
<PolozkaFiltru Nazev="nicoz">-109305000</PolozkaFiltru>
<PolozkaFiltru Nazev="trideni">d</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `nicoz` | `-109305000` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). Pozor: záporné! Ověřeno. |
| `trideni` | `d` | `p`=příjmení, `i`=IČP+příjmení, `r`=rodná čísla, **`d`=Datové rozhraní** (datový soubor) |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP/RBP.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
`ulozit-formular`. První ostré podání: **ref. 179776197** (17.06.2026).
## Srovnání platformy portalzp.cz
| | ZPŠ (209) | OZP (207) | RBP (213) | VoZP (201) |
|--|-----------|-----------|-----------|------------|
| Schránka | schranka-vypis-… | schranky-vypis-… | schranky-vypis-… | vypis-registrovanych-pacientu-… |
| Formulář | 29-… | 108-… | 110-… | 106-… |
| Schránka/filtr | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP | VypisPojKap / ZZ_VYP_REG | SEZNAM_KAP |
| IČZ položka | icz=25520 | nicoz=13074913 | icz=933189 | nicoz=-109305000 |
| datum | poslední den měsíce | — | Ke dni (dnešek) | — |
| typ/trideni | razeni+typ=soubor | trideni=p+typ=soubor | razeni+typ=soubor | trideni=d (Datové rozhraní) |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuVoZP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `-109305000`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (3 datové soubory, PDF přeskočeno),
podání ✓ (ref. 179776197). Výpis z prvního podání dorazí do schránky.
@@ -0,0 +1,408 @@
"""
Stahování seznamu registrovaných pojištěnců VoZP (201) — čistý Python, bez NMSigneru.
VoZP běží na stejné platformě jako ZPŠ/OZP/RBP (portalzp.cz / json-api), s rozdíly:
- schránka: /app/vypis-registrovanych-pacientu-praktickeho-lekare
- formulář: 106-zadost-o-vypis
- filtr XML: NazevSchranky = NazevFiltru = "SEZNAM_KAP" (jako OZP)
- položky: nicoz (interní ID = -109305000), trideni (p/i/r/d)
trideni="d" = Datové rozhraní → datový soubor f201MMRR.001
- BEZ pole "datum" a BEZ pole "typ" — výpis je aktuální snímek registrovaných pacientů.
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy ze schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis (datové rozhraní)
Log podání: log_podani.json — seznam { ref_cislo, podano_kdy }
"""
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.vozp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/106-zadost-o-vypis/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/vypis-registrovanych-pacientu-praktickeho-lekare"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "-109305000" # IČZ 09305000 — interní ID položky "nicoz"
TRIDENI = "d" # p=příjmení, i=IČP+příjmení, r=rodná čísla, d=Datové rozhraní
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s VoZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "201 VoZP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "vozp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.vozp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml() -> str:
"""Sestaví XML žádosti o aktuální výpis registrovaných pacientů (datové rozhraní)."""
return (
f'<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">\r\n'
f'<PolozkaFiltru Nazev="nicoz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="trideni">{TRIDENI}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session) -> str | None:
"""Odešle podepsanou žádost o aktuální výpis. Vrátí referenční číslo nebo None."""
xml = build_xml()
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o aktuální výpis
print("=== Podávám žádost o aktuální výpis (datové rozhraní) ===")
ref = odeslat_zadost(session)
if ref:
uloz_log(ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,6 @@
[
{
"ref_cislo": "179776533",
"podano_kdy": "2026-06-17 05:48:36"
}
]
@@ -0,0 +1,96 @@
# OZP (207) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuOZP.py` provede v jednom spuštění čtyři kroky:
1. **Přihlásí se** certifikátem na portál OZP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/207 OZP/ozp_cookies.json`
2. **Stáhne nové výpisy** z výpisové schránky `schranky-vypis-pojistencu-v-kapitaci`
— stahuje soubory, jejichž obsah začíná `H09305001`
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— zastaví se při první již stažené zprávě
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o aktuální výpis (typ=soubor, třídění dle příjmení)
## Platforma
OZP běží na stejné platformě jako **ZPŠ, VoZP, RBP** (portalzp.cz / json-api).
Login je identický se ZPŠ. Liší se URL schránky, ID formuláře a názvy filtru/položek.
## Flow přihlášení (stejné jako ZPŠ)
1. GET `/app/prihlaseni` → session cookie
2. POST `/json-api/prihlaseni/prihlasovaci-zprava` → challenge (`zprava`)
3. Podpis challenge certifikátem (PKCS7/SHA-256, **s** certifikátem)
4. POST `/json-api/prihlaseni/prihlaseni-certifikatem` → autentizovaná session
## Stažení přílohy
GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` se získá z `onclick="SchrPolOpenFile(<id>)"` v řádcích tabulky schránky.
Soubory ve schránce mají název `F207MMRR.xxx` (MM/RR = měsíc/rok generování).
## Podání žádosti (KLÍČOVÝ ROZDÍL oproti ZPŠ)
OZP **nemá pole „datum/měsíc"** — výpis je *aktuální snímek* platných registrací
(„připraveno do příštího dne"). Nepodává se za konkrétní měsíc, nepočítá se „další měsíc".
Při každém běhu se podá jedna žádost o aktuální výpis. Žádný stavový soubor s měsícem.
POST `https://portal.ozp.cz/json-api/formular-schranky/108-vypis-pojistencu-v-registraci/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">
<PolozkaFiltru Nazev="nicoz">13074913</PolozkaFiltru>
<PolozkaFiltru Nazev="trideni">p</PolozkaFiltru>
<PolozkaFiltru Nazev="typ">soubor</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `nicoz` | `13074913` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). Ověřeno: posílá se interní ID, ne číslo IČZ. |
| `trideni` | `p` | `p`=podle příjmení, `i`=IČP+příjmení, `r`=rodná čísla |
| `typ` | `soubor` | `soubor`=datový soubor dle rozhraní, `sestava`=tiskový výstup |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu v podpisu (`NoCerts`) — stejně jako ZPŠ formulář.
Server certifikát v podpisu odmítá.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy formuláře daly názvy
schránky/filtru a položek, odchycený XHR na `ulozit-formular` potvrdil přesný payload.
První ostré podání: **ref. 179774883** (17.06.2026).
## Srovnání se ZPŠ
| | ZPŠ (209) | OZP (207) |
|--|-----------|-----------|
| Schránka URL | `schranka-vypis-…` (jedn.) | `schranky-vypis-…` (množ.) |
| Formulář | `29-vypis-registrov-pojistencu` | `108-vypis-pojistencu-v-registraci` |
| NazevSchranky / NazevFiltru | `VypisPojKap` / `ZZ_VYP_REG` | `SEZNAM_KAP` / `SEZNAM_KAP` |
| Položka IČZ | `icz` = 25520 | `nicoz` = 13074913 (interní ID) |
| Pole datum | ano (za měsíc) | **ne** (aktuální snímek) |
| Stav | `stav.json` (měsíc) | jen `log_podani.json` |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuOZP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `13074913`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (3 výpisy), podání ✓ (ref. 179774883).
Výpis z prvního podání dorazí do schránky do příštího dne.
@@ -0,0 +1,412 @@
"""
Stahování seznamu registrovaných pojištěnců OZP (207) — čistý Python, bez NMSigneru.
OZP běží na stejné platformě jako ZPŠ (portalzp.cz / json-api), ale s rozdíly:
- schránka: /app/schranky-vypis-pojistencu-v-kapitaci (množné "schranky")
- formulář: 108-vypis-pojistencu-v-registraci
- filtr XML: NazevSchranky = NazevFiltru = "SEZNAM_KAP"
- položky: nicoz (IČZ interní ID), trideni (p/i/r), typ (soubor/sestava)
- BEZ pole "datum" — výpis je aktuální snímek platných registrací
("připraveno do příštího dne"), nepodává se za konkrétní měsíc.
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy z výpisové schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis (typ=soubor, třídění dle příjmení)
Log podání: log_podani.json — seznam { ref_cislo, podano_kdy }
"""
import io
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
# UTF-8 výstup i na Windows konzoli (cp1252 by padal na českých znacích)
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.ozp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/108-vypis-pojistencu-v-registraci/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/schranky-vypis-pojistencu-v-kapitaci"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "13074913" # IČZ 09305000 — interní ID položky "nicoz"
TRIDENI = "p" # p = podle příjmení, i = IČP+příjmení, r = rodná čísla
TYP = "soubor" # soubor = datový soubor, sestava = tiskový výstup
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s OZP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "207 OZP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "ozp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.ozp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml() -> str:
"""Sestaví XML žádosti o aktuální výpis pojištěnců (bez data — aktuální snímek)."""
return (
f'<SchrankaZadost NazevSchranky="SEZNAM_KAP" NazevFiltru="SEZNAM_KAP">\r\n'
f'<PolozkaFiltru Nazev="nicoz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="trideni">{TRIDENI}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="typ">{TYP}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session) -> str | None:
"""Odešle podepsanou žádost o aktuální výpis. Vrátí referenční číslo nebo None."""
xml = build_xml()
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o aktuální výpis
print("=== Podávám žádost o aktuální výpis ===")
ref = odeslat_zadost(session)
if ref:
uloz_log(ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,6 @@
[
{
"ref_cislo": "179774959",
"podano_kdy": "2026-06-17 05:21:08"
}
]
@@ -78,5 +78,10 @@
"datum": "30.06.2026",
"ref_cislo": "178258393",
"podano_kdy": "2026-05-13 21:03:20"
},
{
"datum": "31.07.2026",
"ref_cislo": "179746549",
"podano_kdy": "2026-06-16 10:21:54"
}
]
@@ -1 +1 @@
{"mesic": 6, "rok": 2026}
{"mesic": 7, "rok": 2026}
@@ -0,0 +1,64 @@
# ZPMVČR (211) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuZPMVCR.py` (čistý Python, requests + bs4):
1. **Přihlásí se** PIN + heslem (POST formulář, bez certifikátu / NMSigneru)
2. **Projde stránkovaný přehled** všech registrací pro IČP 09305001
3. **Uloží CSV** do `…\Zúčtovací zprávy\SeznamyPojištěnců\`
## Platforma — ODLIŠNÁ od ostatních
ZPMVČR běží na **eforms.zpmvcr.cz**, NE na portalzp.cz. Žádné certifikáty, žádné schránky,
žádné datové rozhraní .001. Login je PIN + heslo.
## Zásadní rozdíl: NENÍ datový soubor
Ostatní pojišťovny dávají datový soubor (.001 / F-soubor). ZPMVČR **nemá** ekvivalent:
- EP2 sekce (`dokumenty_ke_stazeni/ep2`) je prázdná — *"nebylo stahování dokumentů nastaveno"*.
- Jediný zdroj seznamu je **HTML přehled** na stránce `registrovani_pojistenci`,
který se musí naparsovat → proto výstupem je **CSV**, ne datový soubor.
## Přihlášení
POST `https://eforms.zpmvcr.cz/eforms/ekomunikace`
Pole: `pin` (9023895287), `pin2` (prázdné), `pwd` (heslo).
## Stažení seznamu
POST `https://eforms.zpmvcr.cz/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci`
| Pole | Hodnota | Význam |
|------|---------|--------|
| `icp` | `09305001` | IČP (nebo "Vše") |
| `arztart` | `` (prázdné = Vše) | odbornost D/G/P/S |
| `mesic` / `rok` | aktuální měsíc/rok | období |
| `registrace` | `3` | 1=platné, 2=neplatné, **3=všechny** |
| `tridit` | `1` | 1=příjmení, 2=číslo pojištěnce |
| `vyhledat` | `Vyhledat` | submit |
Výsledek je **stránkovaný** (~20 řádků/strana). Další strany: POST + pole `page=N`.
Řádky v HTML: `<tr class="c1|c2">`, hodnoty za `<span class="responsiveColumn">Label:</span>`.
Hláška "Přehled ... (celkem N)" udává očekávaný počet (kontrola úplnosti).
## CSV výstup
Soubor `YYYY-MM-DD 211 ZPMVČR vsechny registrace.csv`, kódování utf-8-sig (Excel),
oddělovač `;`. Sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do.
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuZPMVCR.py` | Hlavní skript — login + scrape přehledu → CSV |
## Parametry
- **IČP**: 09305001 (MUDr. Michaela Buzalková)
- **Login**: PIN 9023895287 + heslo (v kódu, stejně jako StahováníZpráv/211)
## Stav
Hotovo a otestováno (17.06.2026): login ✓, staženo 172 registrací (9 stran, sedí s "celkem 172"),
CSV uloženo. Volba uživatele: VŠECHNY registrace (registrace=3).
@@ -0,0 +1,174 @@
"""
Stahování seznamu registrovaných pojištěnců ZPMVČR (211) — čistý Python (requests + bs4).
ZPMVČR běží na ODLIŠNÉ platformě (eforms.zpmvcr.cz) — ne portalzp.cz:
- login: PIN + heslo (POST formulář), bez certifikátu a bez NMSigneru
- seznam: NENÍ datový soubor jako u ostatních pojišťoven (EP2 sekce je prázdná).
Jediný zdroj je HTML "Přehled registrací" na stránce registrovani_pojistenci,
který se naparsuje a uloží jako CSV.
Co skript dělá:
1. Přihlásí se (PIN + heslo)
2. Projde stránkovaný přehled VŠECH registrací (platné i neplatné) pro IČP 09305001
3. Uloží výsledek jako CSV do složky SeznamyPojištěnců (sloupce níže)
CSV sloupce: Číslo pojištěnce; Titul; Příjmení; Jméno; Registrace od; Registrace do
"""
import csv
import os
import sys
from datetime import date
import requests
from bs4 import BeautifulSoup
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
# ── Přihlašovací údaje ────────────────────────────────────────────────────────
PIN = "9023895287"
PIN2 = ""
HESLO = "Ax162q8+"
# ─────────────────────────────────────────────────────────────────────────────
BASE_URL = "https://eforms.zpmvcr.cz"
LOGIN_URL = f"{BASE_URL}/eforms/ekomunikace"
SEZNAM_URL = f"{BASE_URL}/eforms/smluvni_zdravotnicke_zarizeni/registrovani_pojistenci"
ICP = "09305001" # IČP MUDr. Michaela Buzalková
REGISTRACE = "3" # 1=platné, 2=neplatné, 3=všechny
TRIDIT = "1" # 1=příjmení, 2=číslo pojištěnce
CSV_HLAVICKA = ["Číslo pojištěnce", "Titul", "Příjmení", "Jméno", "Registrace od", "Registrace do"]
DEST_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
def prihlaseni() -> requests.Session:
"""Přihlásí se PIN + heslem, vrátí session."""
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
session.get(LOGIN_URL, timeout=15).raise_for_status()
r = session.post(LOGIN_URL, data={"pin": PIN, "pin2": PIN2, "pwd": HESLO}, timeout=15)
r.raise_for_status()
if 'name="pin"' in r.text and "Přihlásit" in r.text:
raise RuntimeError("Přihlášení selhalo — zkontroluj PIN a heslo")
print("Přihlášení úspěšné!")
return session
def parse_rows(html: str) -> list[list[str]]:
"""Naparsuje řádky přehledu. Vrátí seznam [číslo, titul, příjmení, jméno, reg_od, reg_do]."""
soup = BeautifulSoup(html, "html.parser")
rows = []
for tr in soup.select("tr.c1, tr.c2"):
vals = []
for td in tr.find_all("td"):
for sp in td.select("span.responsiveColumn"):
sp.extract()
vals.append(td.get_text(strip=True))
# platný datový řádek má vyplněné číslo pojištěnce v prvním sloupci
if len(vals) >= 6 and vals[0]:
rows.append(vals[:6])
return rows
def precti_celkem(html: str) -> int | None:
"""Z hlášky 'Přehled ... (celkem N)' získá očekávaný počet."""
import re
m = re.search(r"celkem\s+(\d+)", html)
return int(m.group(1)) if m else None
def stahni_seznam(session: requests.Session) -> list[list[str]]:
"""Projde stránkovaný přehled a vrátí všechny řádky."""
base_data = {
"icp": ICP, "arztart": "",
"mesic": str(date.today().month), "rok": str(date.today().year),
"registrace": REGISTRACE, "tridit": TRIDIT, "vyhledat": "Vyhledat",
}
vsechny: list[list[str]] = []
videno: set = set()
celkem_ocekavano = None
page = 1
while page <= 200:
data = dict(base_data)
if page > 1:
data["page"] = str(page)
r = session.post(SEZNAM_URL, data=data, timeout=30)
r.raise_for_status()
if celkem_ocekavano is None:
celkem_ocekavano = precti_celkem(r.text)
if celkem_ocekavano is not None:
print(f"Přehled hlásí celkem {celkem_ocekavano} registrací.")
rows = parse_rows(r.text)
nove = [row for row in rows if tuple(row) not in videno]
if not nove:
break
for row in nove:
videno.add(tuple(row))
vsechny.extend(nove)
print(f" Strana {page}: +{len(nove)} (celkem {len(vsechny)})")
# poslední strana — méně řádků než plná stránka
if len(rows) < 20:
break
page += 1
if celkem_ocekavano is not None and len(vsechny) != celkem_ocekavano:
print(f" POZOR: staženo {len(vsechny)}, ale přehled hlásil {celkem_ocekavano}.")
return vsechny
def uloz_csv(rows: list[list[str]]) -> str:
"""Uloží řádky jako CSV (Excel-friendly: utf-8-sig, oddělovač ;). Vrátí cestu."""
os.makedirs(DEST_DIR, exist_ok=True)
dnes = date.today().strftime("%Y-%m-%d")
filename = f"{dnes} 211 ZPMVČR vsechny registrace.csv"
path = os.path.join(DEST_DIR, filename)
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f, delimiter=";")
w.writerow(CSV_HLAVICKA)
w.writerows(rows)
return path
def hlavni() -> None:
session = prihlaseni()
print("\n=== Stahování přehledu registrací ===")
rows = stahni_seznam(session)
print(f"Staženo: {len(rows)} registrací.")
if not rows:
print("Žádné registrace — CSV se neuloží.")
return
path = uloz_csv(rows)
print(f"\nHotovo — uloženo: {path}")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,90 @@
# RBP (213) — Stahování seznamu registrovaných pojištěnců
## Co skript dělá
`StahniSeznamPojistencuRBP.py` provede v jednom spuštění:
1. **Přihlásí se** certifikátem na portál RBP (čistý Python, bez NMSigneru)
— uloží cookies do sdíleného `StahováníZpráv/213 RBP/rbp_cookies.json`
2. **Stáhne nové výpisy** z výpisové schránky `schranky-vypis-pojistencu-v-kapitaci`
— stahuje soubory, jejichž obsah začíná `H09305001` (textové `odpoved.txt` se přeskočí)
— ukládá do `…\Zúčtovací zprávy\SeznamyPojištěnců\` (Dropbox)
— zastaví se při první již stažené zprávě
— po stahování se **znovu přihlásí** (Playwright invaliduje requests session)
3. **Podá žádost** o výpis ke dnešnímu dni (typ=soubor, třídění dle příjmení)
## Platforma
RBP běží na stejné platformě jako **ZPŠ, OZP, VoZP** (portalzp.cz / json-api).
Login identický se ZPŠ/OZP, jen `BASE_URL = https://portal.rbp-zp.cz`.
## Stažení přílohy
GET `/html/prehled-zprav-ve-schrankach/zobrazit-prilohu?zprava_id={fileId}`
`fileId` z `onclick="SchrPolOpenFile(<id>)"`. Datový výpis má hlavičku `H09305001`.
## Podání žádosti
RBP je **hybrid ZPŠ/OZP**: schránka/filtr jako ZPŠ, ale `datum` je „Ke dni" (aktuální
snímek platných registrací k danému dni, default dnešní datum). Nepočítá se měsíc,
žádný stav.json — při každém běhu se podá žádost ke dni `date.today()`.
POST `https://portal.rbp-zp.cz/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular`
Body: `{"schrXml": "...", "schrSign": "-----BEGIN PKCS7-----...", "schrFiles": []}`
### XML žádosti (řádky `\r\n`)
```xml
<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">
<PolozkaFiltru Nazev="icz">933189</PolozkaFiltru>
<PolozkaFiltru Nazev="datum">17.06.2026</PolozkaFiltru>
<PolozkaFiltru Nazev="razeni">jmeno</PolozkaFiltru>
<PolozkaFiltru Nazev="typ">soubor</PolozkaFiltru>
</SchrankaZadost>
```
| Položka | Hodnota | Význam |
|---------|---------|--------|
| `icz` | `933189` | **interní ID** položky IČZ (zobrazené IČZ = 09305000). |
| `datum` | `DD.MM.YYYY` | „Ke dni" — den, ke kterému chceme snímek (použijeme dnešek). |
| `razeni` | `jmeno` | `jmeno`=příjmení a jména, `rc`=rodná čísla |
| `typ` | `soubor` | `soubor`=datový soubor netříděno, `sestava`=PDF |
### Podpis XML
PKCS7/SHA-256, **bez** certifikátu (`NoCerts`) — stejně jako ZPŠ/OZP.
## Jak byly endpointy zjištěny
Odposlechem reálného podání v Chrome (MCP) — `data-xml-*` atributy + odchycený XHR na
`ulozit-formular`. Skrytý input datumu vypadal jako JWT, ale odchycený XML potvrdil
prostý formát `DD.MM.YYYY`. První ostré podání: **ref. 179775430** (17.06.2026).
## Srovnání
| | ZPŠ (209) | OZP (207) | RBP (213) |
|--|-----------|-----------|-----------|
| NazevSchranky | `VypisPojKap` | `SEZNAM_KAP` | `VypisPojKap` |
| NazevFiltru | `ZZ_VYP_REG` | `SEZNAM_KAP` | `ZZ_VYP_REG` |
| Formulář | `29-…` | `108-…` | `110-…` |
| Položka IČZ | `icz`=25520 | `nicoz`=13074913 | `icz`=933189 |
| Pole datum | ano (poslední den měsíce) | ne | ano (Ke dni, dnešek) |
| razeni / typ | jmeno / soubor | trideni=p / typ=soubor | jmeno / soubor |
## Soubory
| Soubor | Popis |
|--------|-------|
| `StahniSeznamPojistencuRBP.py` | Hlavní skript — stažení výpisů + podání žádosti |
| `log_podani.json` | Historie podání s referenčními čísly |
## Parametry
- **IČZ**: 09305000 (IČP: 09305001, MUDr. Michaela Buzalková), interní ID `933189`
- **Certifikát**: `Insurance/Certificates/MBQualifiedCert.pfx`
## Stav
Hotovo a otestováno (17.06.2026): login ✓, stažení ✓ (odpoved.txt správně přeskočeny),
podání ✓ (ref. 179775430). Výpis z prvního podání dorazí do schránky do příštího dne
— při dalším spuštění ověřit, že hlavička `H09305001` u RBP datového souboru sedí.
@@ -0,0 +1,415 @@
"""
Stahování seznamu registrovaných pojištěnců RBP (213) — čistý Python, bez NMSigneru.
RBP běží na stejné platformě jako ZPŠ/OZP/VoZP (portalzp.cz / json-api).
- schránka: /app/schranky-vypis-pojistencu-v-kapitaci
- formulář: 110-vypis-pojistencu-reg-u-pzs
- filtr XML: NazevSchranky="VypisPojKap", NazevFiltru="ZZ_VYP_REG" (jako ZPŠ)
- položky: icz (interní ID), datum (Ke dni), razeni (jmeno/rc), typ (soubor/sestava)
- datum = "Ke dni" aktuální snímek platných registrací — použijeme dnešní datum,
nepočítá se měsíc, žádný stav.json (jako OZP).
Co skript dělá v jednom spuštění:
1. Přihlásí se certifikátem (uloží cookies pro Playwright)
2. Stáhne nové výpisy z výpisové schránky (soubory s hlavičkou H09305001)
3. Znovu se přihlásí (Playwright invaliduje requests session)
4. Podá jednu žádost o aktuální výpis ke dnešnímu dni
Log podání: log_podani.json — seznam { ref_cislo, datum, podano_kdy }
"""
import json
import os
import re
import sys
import time
from datetime import date, datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
# UTF-8 výstup i na Windows konzoli
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
from Knihovny.najdi_dropbox import get_dropbox_root
PFX_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Certificates", "MBQualifiedCert.pfx"))
PFX_PASSWORD = b"Vlado7309208104++"
BASE_URL = "https://portal.rbp-zp.cz"
CHALLENGE_URL = f"{BASE_URL}/json-api/prihlaseni/prihlasovaci-zprava"
CERTLOGIN_URL = f"{BASE_URL}/json-api/prihlaseni/prihlaseni-certifikatem"
SUBMIT_URL = f"{BASE_URL}/json-api/formular-schranky/110-vypis-pojistencu-reg-u-pzs/ulozit-formular"
VYPIS_URL = f"{BASE_URL}/app/schranky-vypis-pojistencu-v-kapitaci"
DOWNLOAD_URL = f"{BASE_URL}/html/prehled-zprav-ve-schrankach/zobrazit-prilohu"
# Hodnoty filtru (ověřeno odchytem reálného podání na portálu)
ICZ_INTERNAL = "933189" # IČZ 09305000 — interní ID položky "icz"
RAZENI = "jmeno" # jmeno = příjmení a jména, rc = rodná čísla
TYP = "soubor" # soubor = datový soubor, sestava = PDF sestava
# Hlavička platného výpisu pojištěnců (IČP 09305001 = MUDr. Buzalková)
HLAVICKA = "H09305001"
LOG_FILE = os.path.join(os.path.dirname(__file__), "log_podani.json")
# Sdílené soubory s RBP skriptem pro stahování zpráv
STAHUJ_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "StahováníZpráv", "213 RBP"))
COOKIES_FILE = os.path.join(STAHUJ_DIR, "rbp_cookies.json")
CHROME_PROFILE = os.path.join(STAHUJ_DIR, "chrome_profile")
DOWNLOAD_DIR = os.path.join(
get_dropbox_root(),
"Ordinace", "Dokumentace_ke_zpracování", "Zúčtovací zprávy", "SeznamyPojištěnců",
)
# ---------------------------------------------------------------------------
# Přihlášení
# ---------------------------------------------------------------------------
def prihlaseni() -> requests.Session:
"""Přihlásí se certifikátem, vrátí autentizovanou session. Uloží cookies pro Playwright."""
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Origin": BASE_URL,
"Referer": BASE_URL + "/",
})
r = session.get(f"{BASE_URL}/app/prihlaseni")
r.raise_for_status()
session.cookies.set("pzp_sign", "CERT", domain="portal.rbp-zp.cz", path="/")
r = session.post(CHALLENGE_URL, json={"login_sign": "CERT"},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
zprava = r.json()["data"]["zprava"]
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
podpis = (
pkcs7.PKCS7SignatureBuilder()
.set_data(zprava.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature])
.decode("ascii").strip()
)
r = session.post(CERTLOGIN_URL, json={"zprava": zprava, "podpis": podpis},
headers={"Content-Type": "application/json; charset=UTF-8"})
r.raise_for_status()
data = r.json()["data"]
if not data.get("prihlasen"):
raise RuntimeError(f"Přihlášení selhalo: {r.json().get('errMsg', '')}")
print("Přihlášení úspěšné!")
cookies = [
{
"name": c.name,
"value": c.value,
"domain": c.domain if c.domain.startswith(".") else "." + c.domain,
"path": c.path or "/",
"expires": int(c.expires) if c.expires else -1,
"secure": bool(c.secure),
"httpOnly": False,
"sameSite": "Lax",
}
for c in session.cookies
]
with open(COOKIES_FILE, "w", encoding="utf-8") as f:
json.dump(cookies, f, indent=2, ensure_ascii=False)
return session
# ---------------------------------------------------------------------------
# Stahování z výpisové schránky
# ---------------------------------------------------------------------------
def safe_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def parse_date(date_str: str) -> str:
try:
return datetime.strptime(date_str.strip()[:19], "%d.%m.%Y %H:%M:%S").strftime("%Y-%m-%d")
except Exception:
try:
return datetime.strptime(date_str.strip()[:10], "%d.%m.%Y").strftime("%Y-%m-%d")
except Exception:
return "0000-00-00"
def parse_row(cells: list) -> dict:
"""Z buněk řádku schránky vytvoří popis a cílový název souboru."""
date_raw = cells[1].strip() if len(cells) > 1 else ""
desc_raw = cells[2].strip() if len(cells) > 2 else ""
fname_raw = cells[3].strip() if len(cells) > 3 else ""
desc_lines = [l.strip() for l in desc_raw.split("\n") if l.strip()]
if len(desc_lines) >= 3:
description = desc_lines[2]
elif len(desc_lines) >= 2:
description = desc_lines[1]
else:
description = desc_lines[0] if desc_lines else ""
description = description[:80]
fname_match = re.match(r'^(.+?)\s*\(\d{2}\.\d{2}\.\d{4}\)\s*$', fname_raw)
original = fname_match.group(1).strip() if fname_match else fname_raw.split("(")[0].strip()
orig_path = Path(original)
stem = orig_path.stem or "zprava"
ext = orig_path.suffix or ""
date_iso = parse_date(date_raw)
name = f"{date_iso} {safe_filename(description)} ({safe_filename(stem)}){ext}"
if len(name) > 240:
name = f"{date_iso} ({safe_filename(stem)}){ext}"
return {"date": date_iso, "desc": description, "original": original, "filename": name}
def stahni_nove_vypisy() -> int:
"""Stáhne nové výpisy z výpisové schránky. Vrátí počet stažených souborů."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("Chybí playwright: pip install playwright && playwright install chrome")
return 0
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
with open(COOKIES_FILE, encoding="utf-8") as f:
cookies = json.load(f)
downloaded = 0
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=CHROME_PROFILE,
channel="chrome",
headless=False,
slow_mo=100,
ignore_https_errors=True,
)
try:
context.add_cookies(cookies)
page = context.new_page()
page.goto(f"{VYPIS_URL}/", wait_until="domcontentloaded", timeout=30_000)
if "prihlaseni" in page.url or "login" in page.url.lower():
print("Session v prohlížeči expirovala — stahování přeskočeno")
return 0
print("Prohlížeč přihlášen OK\n")
already = set(os.listdir(DOWNLOAD_DIR))
print(f"V archivu: {len(already)} souborů.\n")
page_num = 1
seen_ids: set = set()
while True:
url = f"{VYPIS_URL}/stranka-{page_num}"
print(f" Stránka {page_num}: {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f" Navigace selhala: {e}")
break
page.wait_for_load_state("networkidle", timeout=15_000)
data = page.evaluate("""() => {
const rows = [];
for (const tr of document.querySelectorAll('table tr')) {
const cells = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
if (cells.length < 4) continue;
const dlLink = tr.querySelector('a[onclick*="SchrPolOpenFile"]');
if (!dlLink) continue;
const mFile = dlLink.getAttribute('onclick').match(/\\d+/);
rows.push({ cells, fileId: mFile ? mFile[0] : null });
}
return rows;
}""")
rows = [r for r in data if r["fileId"]]
if not rows:
print(f" Stránka {page_num} — žádné řádky, konec schránky.")
break
current_ids = {r["fileId"] for r in rows}
if current_ids & seen_ids:
print(f" Stránka {page_num} — opakující se obsah, konec schránky.")
break
seen_ids.update(current_ids)
print(f" Nalezeno {len(rows)} zpráv.")
stop = False
for row in rows:
info = parse_row(row["cells"])
target = os.path.join(DOWNLOAD_DIR, info["filename"])
if info["filename"] in already or os.path.exists(target):
print(f" [stop] Nalezena již stažená zpráva: {info['filename']}")
stop = True
break
dl_url = f"{DOWNLOAD_URL}?zprava_id={row['fileId']}"
try:
r = context.request.get(dl_url, headers={"Referer": VYPIS_URL}, timeout=30_000)
if not r.ok:
print(f" HTTP {r.status} příloha (id={row['fileId']})")
else:
body = r.body()
if not body[:len(HLAVICKA)].decode("ascii", errors="ignore").startswith(HLAVICKA):
print(f" přeskočeno (není výpis pojištěnců): {info['filename']}")
else:
with open(target, "wb") as fh:
fh.write(body)
print(f" OK: {info['filename']}")
already.add(info["filename"])
downloaded += 1
except Exception as e:
print(f" Chyba příloha (id={row['fileId']}): {e}")
time.sleep(1.0)
if stop:
break
page_num += 1
finally:
context.close()
return downloaded
# ---------------------------------------------------------------------------
# Sestavení XML a podpis žádosti
# ---------------------------------------------------------------------------
def build_xml(datum: date) -> str:
"""Sestaví XML žádosti o výpis pojištěnců ke dni `datum`."""
datum_str = datum.strftime("%d.%m.%Y")
return (
f'<SchrankaZadost NazevSchranky="VypisPojKap" NazevFiltru="ZZ_VYP_REG">\r\n'
f'<PolozkaFiltru Nazev="icz">{ICZ_INTERNAL}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="datum">{datum_str}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="razeni">{RAZENI}</PolozkaFiltru>\r\n'
f'<PolozkaFiltru Nazev="typ">{TYP}</PolozkaFiltru>\r\n'
f'</SchrankaZadost>'
)
def sign_xml(xml: str) -> str:
"""Podepíše XML certifikátem (PKCS7 detached, bez certifikátu — server cert v podpisu odmítá)."""
with open(PFX_PATH, "rb") as f:
private_key, cert, _ = pkcs12.load_key_and_certificates(f.read(), PFX_PASSWORD)
pem = (
pkcs7.PKCS7SignatureBuilder()
.set_data(xml.encode("utf-8"))
.add_signer(cert, private_key, hashes.SHA256())
.sign(serialization.Encoding.PEM, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCerts])
.decode("ascii")
)
return pem.replace("\r\n", "\n").replace("\n", "\r\n")
def odeslat_zadost(session: requests.Session, datum: date) -> str | None:
"""Odešle podepsanou žádost o výpis ke dni `datum`. Vrátí referenční číslo nebo None."""
xml = build_xml(datum)
podpis = sign_xml(xml)
payload = {"schrXml": xml, "schrSign": podpis, "schrFiles": []}
r = session.post(SUBMIT_URL, json=payload, headers={
"Content-Type": "application/json; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE_URL + "/",
})
r.raise_for_status()
try:
resp = r.json()
except Exception:
print(f" Odpověď není JSON: {r.text[:300]}")
return None
resp_str = json.dumps(resp, ensure_ascii=False)
if resp.get("errMsg") or resp.get("error"):
print(f" Chyba od serveru: {resp.get('errMsg') or resp.get('error')}")
return None
m = re.search(r'\b(1[5-9]\d{7})\b', resp_str)
ref = m.group(1) if m else None
if ref:
print(f" OK — ref. číslo: {ref}")
else:
print(f" Odpověď (bez ref. čísla): {resp_str[:300]}")
return ref or ("OK" if r.ok else None)
# ---------------------------------------------------------------------------
# Log
# ---------------------------------------------------------------------------
def uloz_log(datum: date, ref_cislo: str) -> None:
log = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, encoding="utf-8") as f:
log = json.load(f)
log.append({
"ref_cislo": ref_cislo,
"datum": datum.strftime("%d.%m.%Y"),
"podano_kdy": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, indent=2, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Hlavní funkce
# ---------------------------------------------------------------------------
def hlavni() -> None:
# 1. Přihlášení — uloží cookies pro Playwright
prihlaseni()
# 2. Stažení nových výpisů z výpisové schránky
print("\n=== Stahování nových výpisů ===")
stazeno = stahni_nove_vypisy()
print(f"Staženo: {stazeno} souborů.\n")
# 3. Znovu přihlásit — Playwright mohl invalidovat předchozí session
print("=== Znovu přihlašuji před podáním ===")
session = prihlaseni()
# 4. Podání žádosti o výpis ke dnešnímu dni
datum = date.today()
print(f"=== Podávám žádost o výpis ke dni {datum.strftime('%d.%m.%Y')} ===")
ref = odeslat_zadost(session, datum)
if ref:
uloz_log(datum, ref)
print(f"\nHotovo — žádost podána, ref: {ref}")
else:
print("\nPodání selhalo — žádost nebyla zaevidována.")
if __name__ == "__main__":
hlavni()
@@ -0,0 +1,7 @@
[
{
"ref_cislo": "179775825",
"datum": "15.05.2026",
"podano_kdy": "2026-06-17 05:40:28"
}
]
@@ -1,7 +1,7 @@
[
{
"name": "SID",
"value": "323fa186a7c38b49f8f40e6798f019a1",
"value": "786ed43afb46b3c7432371f7f2ee282e",
"domain": ".portal.ozp.cz",
"path": "/",
"expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT",
"domain": ".portal.ozp.cz",
"path": "/",
"expires": 1808541892,
"expires": 1813202467,
"secure": true,
"httpOnly": false,
"sameSite": "Lax"
@@ -1,7 +1,7 @@
[
{
"name": "SID",
"value": "0589c59247aa8fa221c380eec74c9cef",
"value": "1be176fa462a5f32ad908b07b0b380ac",
"domain": ".portal.zpskoda.cz",
"path": "/",
"expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT",
"domain": ".portal.zpskoda.cz",
"path": "/",
"expires": 1810234998,
"expires": 1813134113,
"secure": true,
"httpOnly": false,
"sameSite": "Lax"
@@ -1,7 +1,7 @@
[
{
"name": "SID",
"value": "01bb61e3cd536ffbf7c4f2b74260466e",
"value": "22319828cc5b7600290e217c8f533ca0",
"domain": ".portal.rbp-zp.cz",
"path": "/",
"expires": -1,
@@ -14,7 +14,7 @@
"value": "CERT",
"domain": ".portal.rbp-zp.cz",
"path": "/",
"expires": 1808541922,
"expires": 1813203627,
"secure": true,
"httpOnly": false,
"sameSite": "Lax"
+77 -5
View File
@@ -2,29 +2,101 @@
# Připojení k Firebird databázi Medicus (medicus.fdb). Volí DSN podle názvu počítače.
# Obsahuje třídu MedicusDB s metodami pro dotazy na pacienty, registrace a faktury.
import os
import socket
import fdb
def get_medicus_connection():
"""
Připojí se k Firebird medicus.fdb podle názvu počítače.
Vrátí fdb.Connection nebo vyhodí RuntimeError pro neznámý počítač.
Připojí se k Firebird medicus.fdb. DSN se vybere takto:
1) env MEDICUS_FDB_DSN (má přednost — nutné v dockeru, kde hostname = ID kontejneru),
2) podle názvu počítače (dsn_map),
3) default.
Vrátí fdb.Connection nebo vyhodí RuntimeError.
"""
computer_name = socket.gethostname().upper()
dsn_map = {
"LEKAR": r"localhost:M:\medicus\data\medicus.fdb",
"SESTRA": r"192.168.1.10:m:\medicus\data\medicus.fdb",
"LENOVO": r"192.168.1.10:m:\medicus\data\medicus.fdb",
"NTBVBHP470G10": r"reporter:c:\medicus\medicus.fdb",
"Z230": r"reporter:c:\medicus\medicus.fdb",
"NTBVBHP470G10": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14
"Z230": r"192.168.1.76:/firebird/data/medicus.fdb", # přepnuto z reporteru na tower 2026-06-14
"TOWER": r"192.168.1.76:/firebird/data/medicus.fdb", # Firebird 2.5 docker kontejner na toweru
}
dsn = dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb")
dsn = (os.environ.get("MEDICUS_FDB_DSN")
or dsn_map.get(computer_name, r"localhost:c:\medicus 3\data\medicus.fdb"))
import sys
print(f"[medicus_db] Pripojuji se jako {computer_name} -> {dsn}", file=sys.stderr, flush=True)
return fdb.connect(dsn=dsn, user="SYSDBA", password="masterkey", charset="win1250")
class ReconnectingConnection:
"""Obal nad fdb.Connection, který se sám znovu připojí, když spojení umře.
Dlouho běžící procesy (např. MCP server) drží jedno spojení od startu.
To se rozpadne, když:
* se notebook uspí/hibernuje (TCP socket odumře), nebo
* na serveru proběhne denní gbak restore (Firebird zabije stará spojení).
Výsledkem je `SQLCODE -902 Error writing data to the connection`.
Tato třída ověří před každým použitím, že spojení žije, a když ne,
tiše ho znovu naváže. Volající kód používá .cursor()/.commit() beze změny.
"""
def __init__(self, connect_fn=get_medicus_connection):
self._connect = connect_fn
self._conn = None
def _alive(self):
if self._conn is None:
return False
try:
cur = self._conn.cursor()
cur.execute("SELECT 1 FROM RDB$DATABASE")
cur.fetchone()
return True
except Exception:
return False
def _ensure(self):
if not self._alive():
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
self._conn = None
self._conn = self._connect()
return self._conn
def cursor(self):
return self._ensure().cursor()
def commit(self):
return self._ensure().commit()
def rollback(self):
if self._conn is not None:
return self._conn.rollback()
def close(self):
if self._conn is not None:
try:
self._conn.close()
finally:
self._conn = None
def __getattr__(self, name):
# ostatní atributy/metody proxy na živé spojení
return getattr(self._ensure(), name)
def get_medicus_connection_reconnecting():
"""Vrátí spojení s automatickým reconnectem (vhodné pro dlouho běžící procesy)."""
return ReconnectingConnection(get_medicus_connection)
def get_medicus_db():
"""Vrátí MedicusDB instanci s připojením podle názvu počítače."""
conn = get_medicus_connection()
+6 -2
View File
@@ -4,8 +4,12 @@ import pymysql
def _print(msg):
print(msg, file=sys.stdout, flush=True) if sys.stdout.encoding and sys.stdout.encoding.lower() in ("utf-8", "utf8") \
else print(msg.encode("utf-8", errors="replace").decode("ascii", errors="replace"), flush=True)
# Diagnostika jde na stderr — stdout je u MCP serverů vyhrazen pro JSON-RPC.
if sys.stderr.encoding and sys.stderr.encoding.lower() in ("utf-8", "utf8"):
print(msg, file=sys.stderr, flush=True)
else:
print(msg.encode("utf-8", errors="replace").decode("ascii", errors="replace"),
file=sys.stderr, flush=True)
_LOCAL_HOSTS = {"lekar", "sestra", "lenovo"}
+184
View File
@@ -0,0 +1,184 @@
"""
telegram_notify.py
------------------
Notifikace a obousměrná komunikace přes Telegram Bot API
(bot ClaudeBot @Vlado_Claude_Bot).
Token a výchozí chat_id se načítají z `Medevio/.env`:
TELEGRAM_BOT_TOKEN=123456789:AAE...
TELEGRAM_CHAT_ID=6639316354
Použití ze skriptu:
from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram
posli_telegram("Pipeline 08 hotová, 142 záznamů")
odpoved = zeptej_se_telegram("Mám reimportovat i archiv? (ano/ne)")
if odpoved and odpoved.strip().lower() == "ano":
...
Použití z příkazové řádky:
python -m Knihovny.telegram_notify "Hotovo"
python -m Knihovny.telegram_notify --ask "Pokracovat? (ano/ne)"
POZN.: getUpdates smí v jednu chvíli pollovat jen JEDEN proces. Pokud běží
víc skriptů naráz, které čekají na odpověď, kradou si navzájem zprávy —
v praxi se ptá vždy jen jeden agent.
"""
import os
import sys
import time
from pathlib import Path
import requests
# =========================
# Načtení .env (Medevio/.env)
# =========================
def _load_env():
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
API_BASE = "https://api.telegram.org/bot{token}/{method}"
def _token() -> str:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN v Medevio/.env")
return token
def _resolve_chat_id(chat_id: str | None) -> str:
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
if not chat_id:
raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v Medevio/.env)")
return str(chat_id)
def _call(method: str, *, http_timeout: int = 15, **params):
"""Zavolá Telegram Bot API metodu a vrátí pole `result`."""
url = API_BASE.format(token=_token(), method=method)
r = requests.post(url, json=params, timeout=http_timeout)
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}")
return data["result"]
def posli_telegram(
text: str,
*,
chat_id: str | None = None,
parse_mode: str | None = None,
disable_notification: bool = False,
) -> dict:
"""
Pošle zprávu přes Telegram bota.
:param text: text zprávy (max 4096 znaků)
:param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID
:param parse_mode: None | "Markdown" | "MarkdownV2" | "HTML"
:param disable_notification: True = tichá zpráva (bez upozornění)
:return: odeslaná zpráva (dict z Telegram API)
"""
params = {
"chat_id": _resolve_chat_id(chat_id),
"text": text,
"disable_notification": disable_notification,
}
if parse_mode:
params["parse_mode"] = parse_mode
return _call("sendMessage", **params)
def zeptej_se_telegram(
otazka: str,
*,
chat_id: str | None = None,
timeout: int = 300,
poll_timeout: int = 30,
parse_mode: str | None = None,
) -> str | None:
"""
Pošle otázku a BLOKUJÍCÍ čeká na textovou odpověď uživatele.
Zahodí starší zprávy a bere jen tu, která přijde PO odeslání otázky.
:param otazka: text otázky
:param chat_id: cílový chat; výchozí z TELEGRAM_CHAT_ID
:param timeout: celkové čekání na odpověď v sekundách (pak vrátí None)
:param poll_timeout: délka jednoho long-poll cyklu v sekundách
:param parse_mode: formátování otázky (None | "HTML" | "Markdown")
:return: text odpovědi, nebo None když nikdo neodpoví do timeoutu
"""
cid = _resolve_chat_id(chat_id)
# Zjisti poslední update_id, ať bereme jen NOVÉ zprávy po otázce.
existujici = _call("getUpdates", http_timeout=15)
offset = (existujici[-1]["update_id"] + 1) if existujici else 0
posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
zbyva = int(deadline - time.monotonic())
if zbyva <= 0:
break
lp = max(1, min(poll_timeout, zbyva))
updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp)
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message") or {}
if str(msg.get("chat", {}).get("id")) != cid:
continue # zpráva z jiného chatu — ignoruj
text = msg.get("text")
if text:
return text
return None
def _safe_print(text: str):
"""Výpis odolný vůči kódování Windows konzole (cp1252)."""
try:
print(text)
except UnicodeEncodeError:
print(text.encode("ascii", "replace").decode("ascii"))
if __name__ == "__main__":
# Ať projdou i diakritika/emoji na Windows konzoli.
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
args = sys.argv[1:]
if not args:
print('Použití:')
print(' python -m Knihovny.telegram_notify "text zprávy"')
print(' python -m Knihovny.telegram_notify --ask "otázka?"')
sys.exit(1)
if args[0] == "--ask":
otazka = " ".join(args[1:]) or "?"
odpoved = zeptej_se_telegram(otazka, timeout=240)
if odpoved is None:
_safe_print("(bez odpovědi — vypršel timeout)")
sys.exit(2)
_safe_print(odpoved)
else:
posli_telegram(" ".join(args))
_safe_print("Odesláno OK")
+302
View File
@@ -0,0 +1,302 @@
"""
telegram_user.py
----------------
Ovládání PLNOHODNOTNÉHO Telegram účtu (ne bota) přes user API (MTProto / Telethon).
Na rozdíl od bota umí napsat komukoli a unese VÍCE souběžných agentů na jednom účtu
(jako Telegram otevřený zároveň na PC, tabletu i mobilu).
⚠️ Jedná JMÉNEM přihlášeného účtu. Session soubor = plný přístup k účtu.
⚠️ Nové účty na automatizaci Telegram rychle banuje (zvlášť VoIP čísla — použij reálnou SIM).
────────────────────────────────────────────────────────────────────────
VÍCE AGENTŮ NA JEDNOM ÚČTU
────────────────────────────────────────────────────────────────────────
- api_id/api_hash se SDÍLÍ (identifikují „aplikaci", ne zařízení).
- Každý agent musí mít VLASTNÍ session soubor (= vlastní autorizace / „zařízení").
Sdílet jednu session mezi procesy NELZE (database is locked / AUTH_KEY_DUPLICATED).
→ každý agent se přihlásí zvlášť: `login --jako <jmeno>` (jeden SMS kód na agenta).
- Všechny sessions vidí stejný chat, proto se odpovědi směrují přes Telegram **Reply**:
agent pošle označenou otázku a bere jen tu odpověď, která je Reply na *jeho* zprávu
(shoda `reply_to_msg_id`). Tím se odpovědi více agentů nepomíchají.
Konfigurace v `Medevio/.env` (api_id/api_hash z https://my.telegram.org):
TELEGRAM_API_ID=1234567
TELEGRAM_API_HASH=abcdef0123456789abcdef0123456789
TELEGRAM_PHONE=+420... # nepovinné (jinak se zeptá při loginu)
Session soubory: `Medevio/agent_telegram_<jmeno>.session` (gitignored).
────────────────────────────────────────────────────────────────────────
CLI
────────────────────────────────────────────────────────────────────────
Jednorázové přihlášení agenta (spusť ve svém terminálu — čeká na kód z SMS):
python -m Knihovny.telegram_user login --jako recepty
python -m Knihovny.telegram_user login --jako kalendar
Poslání zprávy ("me" = Uložené zprávy / Saved Messages):
python -m Knihovny.telegram_user send me "Test" --jako recepty
Otázka + čekání na Reply odpověď (vypíše odpověď na stdout):
python -m Knihovny.telegram_user ask recepty "Mam pokracovat? (ano/ne)"
────────────────────────────────────────────────────────────────────────
ZE SKRIPTU
────────────────────────────────────────────────────────────────────────
from Knihovny.telegram_user import posli_jako_ja, zeptej_se_jako
posli_jako_ja("me", "Pipeline 08 hotová", session="recepty")
odp = zeptej_se_jako("recepty", "Našel jsem 3 sporné záznamy. Pokračovat?")
if odp and odp.strip().lower() == "ano":
...
"""
import argparse
import os
import sys
import time
from pathlib import Path
# telethon.sync zpřístupní metody synchronně (bez async/await)
from telethon.sync import TelegramClient
from telethon.errors import SessionPasswordNeededError, PhoneNumberUnoccupiedError
def _load_env():
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
def _api_id() -> int:
val = os.environ.get("TELEGRAM_API_ID")
if not val:
raise RuntimeError("Chybí TELEGRAM_API_ID v Medevio/.env (z https://my.telegram.org)")
return int(val)
def _api_hash() -> str:
val = os.environ.get("TELEGRAM_API_HASH")
if not val:
raise RuntimeError("Chybí TELEGRAM_API_HASH v Medevio/.env (z https://my.telegram.org)")
return val
def _session_path(jmeno: str | None) -> Path:
base = f"agent_telegram_{jmeno}" if jmeno else "agent_telegram"
return Path(__file__).resolve().parent.parent / "Medevio" / base
def _new_client(session: str | None = None) -> TelegramClient:
return TelegramClient(str(_session_path(session)), _api_id(), _api_hash())
def prihlas(session: str | None = None) -> None:
"""
Jednorázové přihlášení dané session. Interaktivně se zeptá na kód z SMS
a případně na heslo dvoufázového ověření. Vytvoří session soubor.
SPOUŠTĚJ V TERMINÁLU (potřebuje input).
"""
client = _new_client(session)
client.start(phone=os.environ.get("TELEGRAM_PHONE") or (lambda: input("Telefon (+420...): ")))
me = client.get_me()
print(f"Session '{session or 'default'}' přihlášena jako "
f"{me.first_name or ''} (@{me.username}) id={me.id}")
client.disconnect()
def _phone() -> str:
val = os.environ.get("TELEGRAM_PHONE")
if not val:
raise RuntimeError("Chybí TELEGRAM_PHONE v Medevio/.env")
return val
def login_posli_kod(session: str | None = None) -> None:
"""
1. krok přihlášení (řízeného na dálku): vyžádá si od Telegramu kód.
Vytiskne `PHONE_CODE_HASH=...`, který je potřeba pro 2. krok.
"""
client = _new_client(session)
client.connect()
try:
sent = client.send_code_request(_phone())
print("PHONE_CODE_HASH=" + sent.phone_code_hash)
finally:
client.disconnect()
def login_dokonci(code, phone_code_hash: str, session: str | None = None,
password: str | None = None) -> None:
"""
2. krok přihlášení: dokončí login zadaným kódem (a případně heslem 2FA).
Při úspěchu uloží session soubor.
"""
client = _new_client(session)
client.connect()
try:
try:
client.sign_in(phone=_phone(), code=str(code), phone_code_hash=phone_code_hash)
except SessionPasswordNeededError:
if not password:
print("NEED_PASSWORD")
return
client.sign_in(password=password)
except PhoneNumberUnoccupiedError:
print("UCET_NEEXISTUJE - nejdriv zaregistruj cislo v aplikaci Telegram")
return
me = client.get_me()
print(f"OK prihlaseno jako {me.first_name or ''} (@{me.username}) id={me.id}")
finally:
client.disconnect()
def posli_jako_ja(komu, text: str, *, session: str | None = None):
"""
Pošle zprávu jménem přihlášeného účtu z dané session.
:param komu: "me" (Saved Messages) | "@username" | telefon | int id
:param text: text zprávy
:param session: jméno session (které přihlášení použít)
:return: odeslaná zpráva (Telethon Message)
"""
with _new_client(session) as client:
if not client.is_user_authorized():
raise RuntimeError(
f"Session '{session or 'default'}' není přihlášena — "
f"spusť: python -m Knihovny.telegram_user login"
+ (f" --jako {session}" if session else "")
)
return client.send_message(komu, text)
def precti_zpravy(komu, limit: int = 10, *, session: str | None = None):
"""
Vrátí posledních `limit` zpráv z daného chatu.
:return: list dictů {"id", "text", "odeslal_ja", "reply_na", "datum"}
"""
out = []
with _new_client(session) as client:
if not client.is_user_authorized():
raise RuntimeError(f"Session '{session or 'default'}' není přihlášena.")
for msg in client.iter_messages(komu, limit=limit):
out.append({
"id": msg.id,
"text": msg.text or "",
"odeslal_ja": bool(msg.out),
"reply_na": msg.reply_to_msg_id,
"datum": msg.date,
})
return out
def zeptej_se_jako(
agent: str,
otazka: str,
*,
komu="me",
session: str | None = None,
timeout: int = 300,
poll_interval: int = 3,
vyzaduj_reply: bool = True,
) -> str | None:
"""
Pošle označenou otázku ("[agent] otázka") a BLOKUJÍCÍ čeká na odpověď.
Při více agentech naráz se odpovědi rozlišují přes Telegram **Reply**:
bere jen tu příchozí zprávu, která je Reply na právě odeslanou otázku.
:param agent: jméno agenta (objeví se v textu otázky jako štítek)
:param otazka: text otázky
:param komu: kam poslat ("me" = Saved Messages | "@username" | id)
:param session: jméno session; výchozí = `agent` (každý agent svůj soubor)
:param timeout: celkové čekání v sekundách (pak vrátí None)
:param poll_interval: jak často kontrolovat nové zprávy (s)
:param vyzaduj_reply: True = bere jen Reply na svou otázku (bezpečné pro víc agentů);
False = vezme první příchozí zprávu (jen pro 1 agenta)
:return: text odpovědi, nebo None při timeoutu
"""
session = session or agent
with _new_client(session) as client:
if not client.is_user_authorized():
raise RuntimeError(
f"Session '{session}' není přihlášena — "
f"spusť: python -m Knihovny.telegram_user login --jako {session}"
)
sent = client.send_message(komu, f"[{agent}] {otazka}")
qid = sent.id
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
# jen zprávy novější než naše otázka, od nejstarší
for msg in client.iter_messages(komu, min_id=qid, reverse=True):
if msg.out:
continue # naše vlastní zpráva
if vyzaduj_reply:
if msg.reply_to_msg_id == qid:
return msg.text
else:
return msg.text
zbyva = deadline - time.monotonic()
if zbyva <= 0:
break
time.sleep(min(poll_interval, max(1, zbyva)))
return None
def _safe_print(text: str):
try:
print(text)
except UnicodeEncodeError:
print(text.encode("ascii", "replace").decode("ascii"))
def _main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(prog="telegram_user", description="Telegram user účet (Telethon)")
sub = parser.add_subparsers(dest="cmd", required=True)
p_login = sub.add_parser("login", help="jednorázové přihlášení session")
p_login.add_argument("--jako", dest="jako", default=None, help="jméno session/agenta")
p_send = sub.add_parser("send", help="poslat zprávu")
p_send.add_argument("komu", help='"me" | "@username" | telefon | id')
p_send.add_argument("text", help="text zprávy")
p_send.add_argument("--jako", dest="jako", default=None, help="jméno session")
p_ask = sub.add_parser("ask", help="poslat otázku a počkat na Reply odpověď")
p_ask.add_argument("agent", help="jméno agenta (štítek + výchozí session)")
p_ask.add_argument("text", help="text otázky")
p_ask.add_argument("--komu", dest="komu", default="me", help='kam (výchozí "me")')
p_ask.add_argument("--timeout", dest="timeout", type=int, default=240, help="čekání v s")
args = parser.parse_args()
if args.cmd == "login":
prihlas(args.jako)
elif args.cmd == "send":
posli_jako_ja(args.komu, args.text, session=args.jako)
_safe_print("Odesláno OK")
elif args.cmd == "ask":
odp = zeptej_se_jako(args.agent, args.text, komu=args.komu, timeout=args.timeout)
if odp is None:
_safe_print("(bez odpovědi — vypršel timeout)")
sys.exit(2)
_safe_print(odp)
if __name__ == "__main__":
_main()
+5 -4
View File
@@ -3,6 +3,7 @@
from requests_pkcs12 import Pkcs12Adapter
import requests
import sys
import uuid
from datetime import date
@@ -108,14 +109,14 @@ class VZPB2BClient:
headers = {"Content-Type": "text/xml; charset=utf-8"}
print(f"Calling: {endpoint}")
print(f"Calling: {endpoint}", file=sys.stderr, flush=True)
response = self.session.post(
endpoint,
data=soap.encode("utf-8"),
headers=headers,
timeout=30
)
print("HTTP:", response.status_code)
print("HTTP:", response.status_code, file=sys.stderr, flush=True)
return response.text
def stav_pojisteni(self, rc: str, k_datu: str = None, prijmeni: str = None):
@@ -156,10 +157,10 @@ class VZPB2BClient:
"SOAPAction": "process"
}
print(f"Calling: {endpoint}")
print(f"Calling: {endpoint}", file=sys.stderr, flush=True)
resp = self.session.post(endpoint, data=soap.encode("utf-8"),
headers=headers, timeout=30)
print("HTTP:", resp.status_code)
print("HTTP:", resp.status_code, file=sys.stderr, flush=True)
return resp.text
def registrace_lekare(self, rc: str, k_datu: str = None,
+17
View File
@@ -2,3 +2,20 @@ ANTHROPIC_API_KEY=sk-ant-api03-ucHN0ArOVm9T8HVlB1yq9FP42nw9uF8mRWOCSNygSckmH-OqM
CENTRAL_LOG_TOKEN=b1e95b3ca9b64769d14bb80370a07882958cac95a0eb9d7758933f151a053c08
CENTRAL_LOG_GATEWAY=http://192.168.1.76:8770
# Telegram bot (ClaudeBot @Vlado_Claude_Bot) — notifikace o průběhu
TELEGRAM_BOT_TOKEN=8821687113:AAF9U9S989ZJ0OG2St3o8CyHUSKg7RqyYVM
TELEGRAM_CHAT_ID=6639316354
# Telegram USER účet (Telethon) — plnohodnotný účet agenta
# api_id/api_hash z https://my.telegram.org (přihlas se číslem nového účtu)
TELEGRAM_API_ID=39599696
TELEGRAM_API_HASH=f93ed362cdbfb4f5df85072a0350a8fc
TELEGRAM_PHONE=+420705920457
# PostgreSQL (ordinace) — pro DASTA loader
PG_HOST=192.168.1.76
PG_PORT=5432
PG_USER=vladimir.buzalka
PG_PASSWORD=Vlado7309208104++
PG_DB=ordinace
@@ -909,6 +909,97 @@ def build_corrections_prompt() -> str:
return "\n".join(lines) + "\n\n"
# ─── CHRI / CKD klasifikace (deterministická pojistka) ───────────────────────
# Claude opakovaně plete jednotky eGFR: hodnotu v ml/s (např. 1.27) klasifikuje,
# jako by byla v ml/min (→ CHRIG5), místo přepočtu ×60 (1.27 ml/s = 76 ml/min → CHRIG2).
# Proto stadium počítáme v Pythonu z hodnoty + jednotky, kterou Claude vrátí v poli
# "egfr", a opravíme jím navržený název i varianty.
def _parse_egfr(egfr) -> tuple[float | None, str | None]:
"""Z pole "egfr" (dict / číslo / text) vytáhne (hodnota, jednotka)."""
if egfr is None:
return None, None
if isinstance(egfr, dict):
h = egfr.get("hodnota", egfr.get("value"))
j = egfr.get("jednotka", egfr.get("unit"))
try:
h = float(str(h).replace(",", ".")) if h is not None else None
except (TypeError, ValueError):
h = None
return h, (str(j) if j else None)
if isinstance(egfr, (int, float)):
return float(egfr), None
if isinstance(egfr, str):
m = re.search(r"\d+[.,]?\d*", egfr)
h = float(m.group(0).replace(",", ".")) if m else None
jl = egfr.lower()
if "ml/s" in jl or "ml/sec" in jl or "ml/sek" in jl:
j = "ml/s"
elif "ml/min" in jl:
j = "ml/min"
else:
j = None
return h, j
return None, None
def klasifikuj_chri(hodnota: float | None, jednotka: str | None = None) -> str | None:
"""Vrátí stadium CHRI ('G1'..'G5', vč. 'G3a'/'G3b') z eGFR hodnoty.
Jednotku použije z parametru, jinak heuristicky podle velikosti: eGFR v ml/s má
fyziologicky hodnoty zhruba 02.3, v ml/min 0140 — proto hodnotu < 3 bereme jako
ml/s a násobíme ×60. Prahy (v ml/min/1.73m²): ≥90 G1, ≥60 G2, ≥45 G3a, ≥30 G3b,
≥15 G4, <15 G5.
"""
if hodnota is None:
return None
j = (jednotka or "").lower()
if "min" in j:
egfr = hodnota # ml/min explicitně
elif "ml/s" in j or "ml/sec" in j or "ml/sek" in j:
egfr = hodnota * 60.0 # ml/s explicitně → ml/min
else:
egfr = hodnota * 60.0 if hodnota < 3.0 else hodnota # heuristika dle velikosti
if egfr >= 90:
return "G1"
if egfr >= 60:
return "G2"
if egfr >= 45:
return "G3a"
if egfr >= 30:
return "G3b"
if egfr >= 15:
return "G4"
return "G5"
# Najde zmínku CHRI/CKD klasifikace: prefix (+ volitelná hodnota/jednotka) + 'G<číslo>'.
_CHRI_RE = re.compile(
r"(CHRI|CKD(?:[\s-]?EPI)?|CK[\s-]?EPI)" # prefix
r"(\s*(?:[\d.,]+\s*(?:ml\s*/?\s*s(?:ec|ek)?|ml\s*/?\s*min)?\s*)?)" # volit. hodnota+jednotka
r"G\s*[1-5](?:\s*[ab])?", # staré stadium
re.IGNORECASE,
)
def oprav_chri_klasifikaci(text: str, stupen: str | None) -> str:
"""Opraví číslo CHRI/CKD stadia v textu na spočtené `stupen` ('G2', 'G3a'…).
Zachová prefix (CHRI vs CKD) i případnou číselnou hodnotu, mění jen stadium.
Pro G1 (norma) zmínku odstraní i s hodnotou a uklidí okolní oddělovače.
"""
if not text or not stupen:
return text
if stupen == "G1":
out = _CHRI_RE.sub("", text)
out = re.sub(r",\s*,", ",", out) # dvojitá čárka
out = re.sub(r"\[\s*,\s*", "[", out) # čárka hned za [
out = re.sub(r"\s*,\s*\]", "]", out) # čárka hned před ]
out = re.sub(r"\s{2,}", " ", out).replace("[ ", "[").replace(" ]", "]")
return out.strip()
return _CHRI_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}{stupen}", text)
# ─── Claude Vision API ────────────────────────────────────────────────────────
def extract_info(pdf_path: Path, known_patient: str | None = None, known_rc: str | None = None) -> dict:
@@ -967,7 +1058,11 @@ def extract_info(pdf_path: Path, known_patient: str | None = None, known_rc: str
"Teprve pokud závěr chybí, shrň obsah z celé zprávy.\n"
f"- \"nazev_souboru\": název souboru ve formátu {nazev_format}\n"
"- \"rotace\": o kolik stupňů CCW je třeba otočit obrázek aby byl text čitelně na výšku nebo šířku "
"(hodnoty: 0, 90, 180, 270). Pokud je text již správně orientovaný, vrať 0.\n\n"
"(hodnoty: 0, 90, 180, 270). Pokud je text již správně orientovaný, vrať 0.\n"
"- \"egfr\": pokud zpráva obsahuje glomerulární filtraci (eGFR, CKD-EPI, CK-EPI), vrať objekt "
"{\"hodnota\": <číslo přesně jak je na zprávě>, \"jednotka\": \"ml/s\" nebo \"ml/min\" dle zprávy}. "
"Hodnotu i jednotku jen opiš, NEKLASIFIKUJ stadium — slouží jen pro kontrolní přepočet CHRI v Pythonu. "
"Pokud filtrace ve zprávě není, vrať null.\n\n"
"Pokud pole nenajdeš, použij null. Nepiš nic jiného než JSON."
)
@@ -1260,6 +1355,19 @@ def _analyze_file(pdf_path: Path) -> dict:
if not is_ekg and nazev:
varianty = generate_name_variants(info, nazev)
# Deterministická oprava CHRI/CKD stadia — Claude plete ml/s vs ml/min.
hodnota_egfr, jednotka_egfr = _parse_egfr(info.get("egfr"))
chri_stupen = klasifikuj_chri(hodnota_egfr, jednotka_egfr)
if chri_stupen:
nazev_pred = nazev
nazev = oprav_chri_klasifikaci(nazev, chri_stupen)
varianty = [oprav_chri_klasifikaci(v, chri_stupen) for v in varianty]
if nazev != nazev_pred:
jed = f" {jednotka_egfr}" if jednotka_egfr else ""
info_lines.append(f"✓ CHRI přepočteno: {hodnota_egfr}{jed} → CHRI{chri_stupen}")
print(f" ✓ CHRI klasifikace opravena → CHRI{chri_stupen} "
f"(eGFR {hodnota_egfr}{jed})")
return {
"path": pdf_path,
"is_ekg": is_ekg,
+200
View File
@@ -1942,5 +1942,205 @@
{
"original": "8362022900 2026-04-22 Ekhard, Petra [LZ kardiologie] [Norm. kinetika LKS, trojcípá jemná AoV, stop. AR, bez dilatace PS/LS, nezáv. MR+TR 1+, norm. diast. funkce, bubble test neg.].pdf",
"corrected": "8362022900 2026-04-22 Ekhard, Petra [LZ kardiologie] [Norm. kinetika LKS, trojcípá jemná AoV, stop. AR, bez dilatace PSLS, nezáv. MR+TR 1+, norm. diast. funkce, bubble test neg.].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [EKG] [bez hodnocení].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [EKG] [bez hodnocení].pdf"
},
{
"original": "8351112693 2026-06-10 Zelenková, Petra [EKG] [bez hodnocení].pdf",
"corrected": "8351112693 2026-06-10 Zelenková, Petra [EKG] [bez hodnocení].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [deník TK] [záznamy 5.25.6, hodnoty 118132/5467 mmHg].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [domácí měření TK] [záznamy 5.25.6, hodnoty 1181325467 mmHg].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [lékařský posudek řidič] [zdravotně způsobilý s podmínkou A1, AM, B1, B s brýlemi, platnost do 09.06.2028].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [posudek ŘP] [zdravotně způsobilý s podmínkou A1, AM, B1, B s brýlemi, platnost do 09.06.2028].pdf"
},
{
"original": "400828108 2026-06-09 Šebek, Josef [Prohlášení zdravotní způsobilosti] [cítí se zdráv, užívá metformin, ACI, léky na kyselinu močovou, na tuky].pdf",
"corrected": "400828108 2026-06-09 Šebek, Josef [prohlášení ŘP] [cítí se zdráv, užívá metformin, ACI, léky na kyselinu močovou, na tuky].pdf"
},
{
"original": "491118063 2026-05-28 Sedláček, Jaroslav [LZ diabetologie] [DM2 kontrola, HbA1c 49, kompenzace zlepšena, CKD G3a, kombinovaná hyperlipidémie, ko konec10-zač11/2026].pdf",
"corrected": "491118063 2026-05-28 Sedláček, Jaroslav [LZ diabetologie] [DM2 kontrola, HbA1c 49, kompenzace zlepšena, CKD G3a, kombinovaná hyperlipidémie, ko konec10-zač112026].pdf"
},
{
"original": "505805215 2026-05-12 Sedláčková, Vlasta [LZ ORL] [postnasal drip].pdf",
"corrected": "505805215 2026-05-12 Sedláčková, Vlasta [LZ ORL] [postnasal drip, dlouhodobý kašel, Mommox].pdf"
},
{
"original": "7606050518 2026-06-09 Novotný, Pavel [domácí měření TK] [záznamy 28.59.6, hodnoty TK 100116/6579 mmHg, TF 6792].pdf",
"corrected": "7606050518 2026-06-09 Novotný, Pavel [domácí měření TK] [záznamy 28.59.6, hodnoty TK 1001166579 mmHg, TF 6792].pdf"
},
{
"original": "7606050518 Novotný, Pavel split_004.pdf",
"corrected": "7606050518 2026-06-04 Novotný, Pavel [domácí měření TK] [pěkná kompenzace].pdf"
},
{
"original": "330613108 2026-06-01 Schořálek, Jaroslav [domácí péče] [4 do 30JUN2026, 06311 ad hoc, 06315 1xd3xt, 06329 1xd3xt].pdf",
"corrected": "330613108 2026-06-01 Schořálek, Jaroslav [domácí péče updated] [4 do 30JUN2026, 06311 ad hoc, 06315 1xd3xt, 06329 1xd3xt].pdf"
},
{
"original": "436225107 2026-02-09 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza-osteopénie, CHOPN, art. hypertenze, prolia 60mg, ko 6/2026].pdf",
"corrected": "436225107 2026-02-09 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza-osteopénie, CHOPN, art. hypertenze, prolia 60mg, ko 62026].pdf"
},
{
"original": "436225107 2025-12-22 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza (nyní osteopénie dle DEXA), CHOPN, art. hypertenze, aplikace Prolia 60mg s.c., ko 6/2026].pdf",
"corrected": "436225107 2025-12-22 Krejbichová, Jarmila [LZ revmatologie] [kontrola, osteoporóza (nyní osteopénie dle DXA), CHOPN, art. hypertenze, aplikace Prolia 60mg s.c., ko 62026].pdf"
},
{
"original": "465418044 2026-06-10 Dvořáková, Zdeňka [Laboratoř] [moč: URO +1, PRO +/-, GLU +4 (111 mmol/L)].pdf",
"corrected": "465418044 2026-06-10 Dvořáková, Zdeňka [Uritex] [moč URO +1, PRO +-, GLU +4 (111 mmolL)].pdf"
},
{
"original": "5606051143 2026-05-19 Zána, Jan [PZ lázeňská] [21APR202619MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf",
"corrected": "5606051143 2026-05-19 Zána, Jan [PZ lázně] [21APR202619MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf"
},
{
"original": "6561150607 2026-06-05 Tipplová, Michaela [Laboratoř] [moč: Streptococcus agalactiae 10E5 CFU/ml, citlivý na amoxicilin, cotrimoxazol, nitrofurantoin].pdf",
"corrected": "6561150607 2026-06-05 Tipplová, Michaela [Laboratoř] [moč Streptococcus agalactiae 10E5 CFUml, citlivý na amoxicilin, cotrimoxazol, nitrofurantoin].pdf"
},
{
"original": "6708101114 2026-04-22 Pospíšil, Jiří [EKG] [SR 84/min, PQ 150ms, QRS 85ms, QTc 404ms, bez patologických změn ST-T].pdf",
"corrected": "6708101114 2026-04-22 Pospíšil, Jiří [EKG] [SR 84min, PQ 150ms, QRS 85ms, QTc 404ms, bez patologických změn ST-T].pdf"
},
{
"original": "9651301253 2026-06-10 Kut Citores, Markéta [Uritex] [moč GLU +- 5.5 mmolL, ostatní v normě].pdf",
"corrected": "9651301253 2026-06-10 KutCitores, Markéta [Uritex] [moč GLU +- 5.5 mmolL, ostatní v normě].pdf"
},
{
"original": "5606051143 2026-06-10 Zána, Jan [EKG] [bez hodnocení].pdf",
"corrected": "5606051143 2026-06-10 Zána, Jan [EKG] [bez hodnocení].pdf"
},
{
"original": "0057130183 2026-05-27 Kreibichová, Jiřina [Souhlas s úhradou] [G809 Mozková obrna NS, léčebně rehab. péče, platnost do 26AUG2026].pdf",
"corrected": "0057130183 2026-05-27 Kreibichová, Jiřina [schválení lázně] [G809 Mozková obrna NS, léčebně rehab. péče, platnost do 26AUG2026].pdf"
},
{
"original": "6358097207 2026-06-08 Broulímová, Marija [rozhodnutí ZP] [návrh schválen, lázně VI/3 kořenové syndromy, 21 dní, platnost do 08.12.2026].pdf",
"corrected": "6358097207 2026-06-08 Broulímová, Marija [schválení lázně] [návrh schválen, lázně VI3 kořenové syndromy, 21 dní, platnost do 08.12.2026].pdf"
},
{
"original": "štoček.pdf",
"corrected": "8910193336 2026-06-03 Štoček, Martin [výpis z dokumentace] [od předchozího PL].pdf"
},
{
"original": "7353270419 2026-03-16 Rusková, Jaroslava [Laboratoř] [dg. D830 — výsledky bez viditelných hodnot (strana neúplná)].pdf",
"corrected": "7353270419 2026-03-16 Rusková, Jaroslava [Laboratoř] [dg. D830 — alergologie výsledky na dalších stranách].pdf"
},
{
"original": "7353270419 2026-06-11 Rusková, Jaroslava [EKG] [bez hodnocení].pdf",
"corrected": "7353270419 2026-06-11 Rusková, Jaroslava [EKG] [bez hodnocení].pdf"
},
{
"original": "7602044780 2026-04-19 Suchý, Vladimír [domácí péče] [1 do 18JUL2026, 06315 1xd3xt, 06330 1xd3xt, 06137 ad hoc].pdf",
"corrected": "7602044780 2026-04-19 Suchý, Vladimír [domácí péče] [1 do 31MAY2026, 06315 1xd3xt, 06330 1xd3xt, 06137 ad hoc].pdf"
},
{
"original": "460509135 2026-04-29 Novotný, Miroslav [domácí péče] [6 do 28JUL2026, 06315 1xd3xt, 06329 1xd3xt, 06137 ad hoc].pdf",
"corrected": "460509135 2026-04-29 Novotný, Miroslav [domácí péče] [6 do 30JUN2026 06315 1xd3xt, 06329 1xd3xt, 06137 ad hoc].pdf"
},
{
"original": "380314026 2026-06-05 Chomát, Jiří [Laboratoř] [dg. M5449, S_Urea 9.32↑, CHRIG5, S_ALP 2.17↑].pdf",
"corrected": "380314026 2026-06-05 Chomát, Jiří [Laboratoř] [dg. M5449, S_Urea 9.32↑, CHRIG2, S_ALP 2.17↑].pdf"
},
{
"original": "391111080 2026-06-03 Veltruský, Jaroslav [Laboratoř] [dg. I10, Urea 18.15↑, Krea 122↑, CHRIG3b, GGT 3.61↑, ALP 2.32↑, VitB12 710↑, NT-proBNP 615↑, Hb 124↓, Trombo 144↓].pdf",
"corrected": "391111080 2026-06-03 Veltruský, Jaroslav [Laboratoř] [dg. I10, Urea 18.15↑, Krea 122↑, CHRIG3a, GGT 3.61↑, ALP 2.32↑, VitB12 710↑, NT-proBNP 615↑, Hb 124↓, Trombo 144↓].pdf"
},
{
"original": "401120069 2026-05-28 Císař, Petr [LZ hematologie] [kontrola, CLL z B-lymfocytů, B-CLL/SLL 28% malých monoklon. B lymfocytů, del 13q14].pdf",
"corrected": "401120069 2026-05-28 Císař, Petr [LZ hematologie] [kontrola, CLL z B-lymfocytů, B-CLLSLL 28% malých monoklon. B lymfocytů, del 13q14].pdf"
},
{
"original": "425915482 2026-05-24 Lebedová, Zdenka [PZ lázeňská] [26APR202624MAY2026, st.p. fract. femoris+humeri l.dx., vertebrogenní sy, DM2, polyneuropatie DKK].pdf",
"corrected": "425915482 2026-05-24 Lebedová, Zdenka [PZ lázně] [26APR202624MAY2026, st.p. fract. femoris+humeri l.dx., vertebrogenní sy, DM2, polyneuropatie DKK].pdf"
},
{
"original": "476014105 2026-03-24 Šmídová, Zdeňka [předoperační příprava] [TEP kolenního kloubu, nástup 22.06.2026, výkon 23.06.2026, albumin mimo normu].pdf",
"corrected": "476014105 2026-03-24 Šmídová, Zdeňka [žádost o předoperační vyšetření] [TEP kolenního kloubu, nástup 22.06.2026, výkon 23.06.2026, albumin mimo normu].pdf"
},
{
"original": "476014105 2026-05-25 Šmídová, Zdeňka [LZ gynekologie] [osteopenie, mírný sestup přední stěny poševní, ko 10/26].pdf",
"corrected": "476014105 2026-05-25 Šmídová, Zdeňka [LZ gynekologie] [osteopenie, mírný sestup přední stěny poševní, ko 1026].pdf"
},
{
"original": "5458071212 2026-06-05 Zívrová, Helena [LZ gastroenterologie] [kontrola, CN extenzivní postižení ilea, switch na ustekinumab 3/2025].pdf",
"corrected": "5458071212 2026-06-05 Zívrová, Helena [LZ gastroenterologie] [kontrola, CN extenzivní postižení ilea, switch na ustekinumab 32025].pdf"
},
{
"original": "5853126928 2026-06-09 Fialová, Marta [Laboratoř] [dg. E78, C_CKD-EPI 1.45 ml/s → CHRIG2, S_Na 141↑].pdf",
"corrected": "5853126928 2026-06-09 Fialová, Marta [Laboratoř] [dg. E78, C_CKD-EPI 1.45 mls → CHRIG2, S_Na 141↑].pdf"
},
{
"original": "7356020441 2026-06-09 Billouz, Hana [Laboratoř] [Stěr/Výtěr nos primokultivace: Negativní].pdf",
"corrected": "7356020441 2026-06-09 Billouz, Hana [Laboratoř] [StěrVýtěr nos primokultivace Negativní].pdf"
},
{
"original": "8001030422 2026-05-15 Kalous, Petr [Laboratoř] [dg. M790, S_Anti-CCP IgG <1.0 negativní].pdf",
"corrected": "8001030422 2026-05-15 Kalous, Petr [Laboratoř] [dg. M790, S_Anti-CCP IgG 1.0 negativní].pdf"
},
{
"original": "425915482 2026-05-04 Lebedová, Zdenka [deník krevního tlaku] [27APR04MAY2026, Prestance 5/5mg ráno, Agen 100mg večer].pdf",
"corrected": "425915482 2026-05-04 Lebedová, Zdenka [domácí měření TK] [27APR04MAY2026, Prestance 55mg ráno, Agen 100mg večer].pdf"
},
{
"original": "536117166 2026-06-15 Jiráková, Božena [EKG] [bez hodnocení].pdf",
"corrected": "536117166 2026-06-15 Jiráková, Božena [EKG] [bez hodnocení].pdf"
},
{
"original": "7355180789 2026-06-15 Švecová, Jitka [EKG] [bez hodnocení].pdf",
"corrected": "7355180789 2026-06-15 Švecová, Jitka [EKG] [bez hodnocení].pdf"
},
{
"original": "7857173940 2026-06-15 Bytsiv, Lyubov [EKG] [bez hodnocení].pdf",
"corrected": "7857173940 2026-06-15 Bytsiv, Lyubov [EKG] [bez hodnocení].pdf"
},
{
"original": "0562280048 2026-06-16 [EKG] [bez hodnocení].pdf",
"corrected": "0562280048 2026-06-16 [EKG] [bez hodnocení].pdf"
},
{
"original": "7751120333 2026-06-10 Šmídová, Šárka [Laboratoř] [B_MPV 11 (↑), S_anti-HBs >1000 arbj (↑), eGFR , vit.D 43.8 nmol/l].pdf",
"corrected": "7751120333 2026-06-10 Šmídová, Šárka [Laboratoř] [B_MPV 11 (↑), S_anti-HBs 1000 arbj (↑), eGFR , vit.D 43.8 nmoll].pdf"
},
{
"original": "891209 2026-06-15 [domácí měření TK] [18MAY15JUN2026, průměr 13980, hypertenze 11d, zvýšený TK 8d].pdf",
"corrected": "891209 2026-06-15 [Holter TK] [18MAY15JUN2026, průměr 139_80, hypertenze 11d, zvýšený TK 8d].pdf"
},
{
"original": "7952090443 Kalousová, Eva split_011.pdf",
"corrected": "7952090443 2026-06-09 Kalousová, Eva [LZ urologie] [recidivující IMC].pdf"
},
{
"original": "7952090443 Kalousová, Eva split_012.pdf",
"corrected": "7952090443 2026-06-02 Kalousová, Eva [kultivace moč] [negativní].pdf"
},
{
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr: Astenie, BMI 16.43].pdf",
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Výstupní prohlídka, závěr Astenie, BMI 16.43].pdf"
},
{
"original": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 117/74].pdf",
"corrected": "0612204703 2025-12-12 Štibrányi, Erik [LZ pediatrie] [Přítomný stav, BMI 16.43, váha 60.6 kg, výška 192.5 cm, TK 11774].pdf"
},
{
"original": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62/min, norma, LK norm, způsobilý ke sportu].pdf",
"corrected": "0612204703 2025-03-17 Štibrányi, Erik [EKG] [sinusový rytmus 62min, norma, LK norm, způsobilý ke sportu].pdf"
},
{
"original": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG: sinus fr 67/min, bez abnorm. nálezů, způsobilý ke sportu].pdf",
"corrected": "0612204703 2023-03-30 Štibrányi, Erik [LZ kardiologie] [EKG sinus fr 67min, bez abnorm. nálezů, způsobilý ke sportu].pdf"
},
{
"original": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AU/ml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf",
"corrected": "0612204703 2018-11-26 Štibrányi, Erik [Laboratoř] [dg. B949 - Borrelia IgG 122.00 AUml (↑), IgM WB pozitivní, IgG WB hraniční, VlsE ++].pdf"
},
{
"original": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH <0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf",
"corrected": "0662204730 2025-01-13 Štibrányi, Gitta [LZ endokrinologie] [Tyreotoxikóza NS, TSH 0.003, fT4 11.4, fT3 3.93, TRAK 6.9, léčba Thyrozolem, ko za2m].pdf"
}
]
+13 -5
View File
@@ -14,12 +14,14 @@ Tato pravidla platí vždy při generování polí `poznamka` a `nazev_souboru`.
3. Když je dokument typ "Laboratoř", do `poznamka` uváděj POUZE hodnoty mimo normu (patologické nálezy) — hodnoty v normě vynech. **Osmolalitu séra (Osmolalita, Osm, osmolality) NIKDY nezmiňuj — ani když je mimo normu, ani v jakékoli zkratce.** Toto je absolutní výjimka: osmolalita se do názvu souboru ani do poznámky nepíše nikdy za žádných okolností. Chybně: `C_Osmolalita 293 (↑)` — správně: tuto hodnotu zcela vynech.
4. Pokud laboratorní výsledky obsahují glomerulární filtraci — bývá označena jako eGFR, CKD-EPI nebo CK-EPI — do `poznamka` nikdy nepiš číselnou hodnotu eGFR. Místo toho uveď pouze klasifikaci dle stadií CHRIG1CHRIG5.
- **Jednotka:** Nejprve zkontroluj jednotku uvedenou v laboratoři:
- Pokud je hodnota v **ml/s** nebo **ml/sec** (typicky malá čísla jako 0.8, 1.14, 1.5…), přenásob ×60 pro převod na ml/min.
- Pokud je hodnota v **ml/min** nebo **ml/min/1.73m²** (typicky velká čísla jako 55, 68, 90…), použij přímo.
- **Klasifikace** (v ml/min/1.73m²): ≥ 90 → CHRIG1, 6089 → CHRIG2, 4559 → CHRIG3a, 3044 → CHRIG3b, 1529 → CHRIG4, < 15 → CHRIG5.
- Prahové hodnoty pro orientaci při jednotce ml/s: ≥ 1.50 → G1, 1.001.49 → G2, 0.750.99 → G3a, 0.500.74 → G3b, 0.250.49 → G4, < 0.25 → G5.
- **NEJDŮLEŽITĚJŠÍ — jednotka:** Hodnota glomerulární filtrace bývá v ČR uvedena ve **dvou různých jednotkách** a klasifikace stadia se MUSÍ dělat až po převodu na ml/min:
- **ml/s** (resp. ml/sec, ml/s/1.73m²) — typicky malá čísla cca 0.22.3 (např. 0.8, **1.27**, 1.5). Tuto hodnotu **přenásob ×60**, abys dostal ml/min.
- **ml/min** (resp. ml/min/1.73m²) — typicky velká čísla 5140 (např. 55, 68, 90). Použij přímo.
- **POZOR na typickou chybu:** malé číslo jako `1.27` je v **ml/s**, tj. `1.27 × 60 = 76 ml/min → CHRIG2`. NIKDY ho neklasifikuj jako by bylo v ml/min (76 by jinak vyšlo špatně jako CHRIG5). Pokud je hodnota menší než ~3, je téměř jistě v ml/s a patří přenásobit ×60.
- **Klasifikace** (vždy až v ml/min/1.73m²): ≥ 90 → CHRIG1, 6089 → CHRIG2, 4559 → CHRIG3a, 3044 → CHRIG3b, 1529 → CHRIG4, < 15 → CHRIG5.
- Prahové hodnoty pro orientaci přímo při jednotce ml/s: ≥ 1.50 → G1, 1.001.49 → **G2**, 0.750.99 → G3a, 0.500.74 → G3b, 0.250.49 → G4, < 0.25 → G5.
- Klasifikaci uváděj pouze pokud je CHRIG2 nebo horší (tj. eGFR < 90 ml/min nebo < 1.50 ml/s) — CHRIG1 je v normě, nezmiňuj ho.
- Příklady: `1.27 ml/s → CHRIG2`, `0.92 ml/s → CHRIG3a`, `0.55 ml/s → CHRIG3b`, `68 ml/min → CHRIG2`, `38 ml/min → CHRIG3b`.
5. Když je dokument typ "Laboratoř" a zpráva obsahuje diagnózu (dg., dg:, diagnóza), umísti ji do `nazev_souboru` jako první část druhé závorky, tedy: `[Laboratoř] [dg. XY00 - stručná poznamka]`.
6. Zkratky a pojmenování: slovo „sono" (sonografie/ultrazvuk) piš vždy malými písmeny — `sono břicha`, `sono ŠŽ`, nikoli `SONO`. Štítnou žlázu označuj vždy zkratkou `ŠŽ`. Sonografii prsu/prsů (sono mamm., sono mamografie, sono mamma apod.) piš vždy jako `sono prsů`. Denzitometrii (DEXA, DXA, denzitometrie) piš vždy pouze jako `[DXA]` — bez prefixu LZ. Algologii piš vždy jako `[LZ léčba bolesti]`. Dermatovenerologii (dermatologie, dermatovenerologie, kožní) piš vždy jako `[LZ kožní]`. Angiologii piš vždy jako `[LZ cévní]`.
7. V číselných hodnotách VŽDY používej desetinnou tečku, nikoli desetinnou čárku. Toto pravidlo platí absolutně pro všechna čísla v `poznamka` i `nazev_souboru` — např. `TG 4.73`, nikoli `TG 4,73`.
@@ -63,3 +65,9 @@ Tato pravidla platí vždy při generování polí `poznamka` a `nazev_souboru`.
- `{kód} {N}xd{M}xt` — pokud je týdenní četnost M > 0; N = četnost denně, M = četnost týdně. Např. pro 1x denně 3x týdně: `06313 1xd3xt`.
- Příklad (oba výkony ad hoc): `[domácí péče] [1 do 30JUN2026, 06313 ad hoc, 06323 ad hoc]`
- Příklad (pravidelné výkony): `[domácí péče] [2 do 31AUG2026, 06313 1xd5xt, 06321 2xd7xt]`
13. Posudek na řidičské oprávnění: Lékařský posudek o zdravotní způsobilosti k řízení motorových vozidel má v první závorce vždy `[posudek ŘP]` — bez prefixu LZ, nikdy varianty jako `[lékařský posudek řidič]`, `[posudek řidičský průkaz]` apod.
- Příklad: `400828108 2026-06-09 Šebek, Josef [posudek ŘP] [zdravotně způsobilý s podmínkou A1, AM, B1, B s brýlemi, platnost do 09.06.2028]`
14. Prohlášení posuzované osoby ke zdravotní způsobilosti pro řidičské oprávnění: První závorka je vždy `[prohlášení ŘP]` — nikdy varianty jako `[Prohlášení zdravotní způsobilosti]`, `[prohlášení posuzované osoby]` apod.
- Příklad: `400828108 2026-06-09 Šebek, Josef [prohlášení ŘP] [cítí se zdráv, užívá metformin, ACI, léky na kyselinu močovou, na tuky]`
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RECON ONLY — nic nezakládá, nic neodesílá.
Otevře testovacího pacienta Vladko, otevře "Nový požadavek",
zachytí dostupné typy požadavků a podívá se na formulář "Recept".
Ukládá: screenshoty, HTML, plný GraphQL provoz (request + response).
"""
from pathlib import Path
from datetime import datetime
import sys, json, time
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HERE = Path(__file__).resolve().parent
STATE_FILE = HERE.parent / "medevio_storage.json"
PATIENT_UUID = "0210db7b-8fb0-4b47-b1d8-ec7a10849a63" # Vladko - testovaci aplikace
PATIENT_URL = f"https://my.medevio.cz/mudr-buzalkova/klinika/pacienti?pacient={PATIENT_UUID}"
OUT = HERE / "recon_recept"
OUT.mkdir(exist_ok=True)
GQL_LOG = OUT / f"graphql_{int(time.time())}.jsonl"
def log(msg):
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=150)
context = browser.new_context(storage_state=str(STATE_FILE))
page = context.new_page()
# ---- capture GraphQL request + response bodies ----
def on_response(resp):
try:
req = resp.request
if "graphql" in req.url and req.method == "POST":
rec = {"op": None, "request": None, "response": None,
"status": resp.status}
try:
rec["request"] = json.loads(req.post_data or "{}")
rec["op"] = rec["request"].get("operationName")
except Exception:
pass
try:
rec["response"] = resp.json()
except Exception:
pass
with open(GQL_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
page.on("response", on_response)
log(f"Otevírám kartu pacienta…")
page.goto(PATIENT_URL, wait_until="networkidle")
time.sleep(2)
page.screenshot(path=str(OUT / "01_card.png"), full_page=True)
# ---- detect login / session expiry ----
url_now = page.url
if "login" in url_now or "prihlaseni" in url_now or "auth" in url_now:
log(f"!!! Vypadá to na odhlášení / propadlou session. URL: {url_now}")
(OUT / "_SESSION_EXPIRED.txt").write_text(url_now, encoding="utf-8")
browser.close()
return
# is the card actually visible?
card_ok = False
try:
page.get_by_text("Historie požadavků").wait_for(timeout=8000)
card_ok = True
log("Karta pacienta načtena (vidím 'Historie požadavků').")
except PWTimeout:
log("!!! Nevidím 'Historie požadavků' — možná jiný layout nebo session.")
(OUT / "01_card.html").write_text(page.content(), encoding="utf-8")
if not card_ok:
browser.close()
return
# ---- open "Nový požadavek" ----
try:
page.get_by_role("button", name="Nový požadavek").click()
time.sleep(1.0)
page.screenshot(path=str(OUT / "02_new_request_open.png"), full_page=True)
(OUT / "02_new_request_open.html").write_text(page.content(), encoding="utf-8")
log("Kliknuto 'Nový požadavek'.")
except Exception as e:
log(f"!!! Nepodařilo se kliknout 'Nový požadavek': {e}")
browser.close()
return
# ---- capture all available request-type options (empty query) ----
try:
opts = page.locator("[role='option']").all_text_contents()
(OUT / "03_all_options.txt").write_text(
"\n".join(opts), encoding="utf-8")
log(f"Dostupných typů (bez filtru): {len(opts)}")
except Exception as e:
log(f"options(all) chyba: {e}")
# ---- type 'recept' and capture filtered options ----
try:
page.keyboard.type("recept")
time.sleep(1.0)
opts2 = page.locator("[role='option']").all_text_contents()
(OUT / "04_recept_options.txt").write_text(
"\n".join(opts2), encoding="utf-8")
page.screenshot(path=str(OUT / "04_recept_options.png"), full_page=True)
(OUT / "04_recept_options.html").write_text(page.content(), encoding="utf-8")
log(f"Po napsání 'recept' nabízí: {opts2}")
except Exception as e:
log(f"options(recept) chyba: {e}")
log("RECON hotovo — NIC nezaloženo. Zavírám za 3s.")
time.sleep(3)
browser.close()
log(f"Artefakty v: {OUT}")
log(f"GraphQL log: {GQL_LOG}")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Otevře přihlašovací okno Medevia. PŘIHLAŠ SE RUČNĚ.
Skript sám pozná, že už nejsi na přihlašovací stránce, počká na ustálení
a uloží session do medevio_storage.json. Žádné stisknutí Enter není třeba.
"""
from pathlib import Path
from datetime import datetime
import sys, time
from playwright.sync_api import sync_playwright
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HERE = Path(__file__).resolve().parent
STATE_FILE = HERE.parent / "medevio_storage.json"
LOGIN_URL = "https://my.medevio.cz/prihlaseni"
TIMEOUT_S = 300 # 5 minut na přihlášení
def log(msg):
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
def is_logged_in(url: str) -> bool:
return ("medevio.cz" in url
and "prihlaseni" not in url
and "auth" not in url
and "login" not in url)
def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=False, slow_mo=80)
context = browser.new_context()
page = context.new_page()
page.goto(LOGIN_URL, wait_until="load")
log("=== PŘIHLAS SE v otevřeném okně Medevia ===")
log("Skript čeká, až opustíš přihlašovací stránku…")
deadline = time.time() + TIMEOUT_S
logged = False
while time.time() < deadline:
try:
if is_logged_in(page.url):
# počkej na ustálení redirectů
time.sleep(4)
if is_logged_in(page.url):
logged = True
break
except Exception:
pass
time.sleep(2)
if not logged:
log("!!! Nepřihlášeno do limitu. Session NEULOŽENA.")
browser.close()
return
log(f"Přihlášeno (URL: {page.url}). Ukládám session…")
context.storage_state(path=str(STATE_FILE))
log(f"Session uložena: {STATE_FILE}")
time.sleep(1)
browser.close()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1 @@
https://my.medevio.cz/prihlaseni
+63
View File
@@ -241,6 +241,69 @@ request {
}
```
### Request Creation (Vytvoření požadavku "Recept na léky") — ODCHYCENO/OVĚŘENO 2026-06-13
Přes API **lze založit požadavek s plně vyplněným pacientským dotazníkem** (oba fieldy),
takže vypadá jako reálné podání pacientem. Funkce: `mcp_medevio.zaloz_pozadavek_recept`.
(Pozn.: lékařské UI „Nový požadavek" pole dotazníku NEzobrazí — ale API je přijme.)
**Dvoukrok (+ volitelně štítek):**
```graphql
# 1) vyplň ECRF formulář → vrátí ecrfFill.id
mutation Step_FillECRFForm($input: FillECRFFormInput!) {
patientEcrfFill: fillECRFForm(input: $input) { id }
}
# input: {
# patientId, sid: "ERECEPT_SIMPLEST_BEZ_DAVKOVANI", stepId: "erecept-gp-request",
# byDoctor: false,
# fields: [{ fieldName: "nazev-leku", value: "<léky>", checkedEnumerations: [] }]
# } → pole "Název léků" v dotazníku
# 2) vytvoř požadavek
mutation ...CreatePatientRequestWithoutReservation($clinicSlug: String!, $input: ...) {
patientRequest: createPatientRequestWithoutReservation(clinicSlug: $clinicSlug, input: $input) { id }
}
# input: {
# patientId, userECRFId, ecrfFillIds: [<id z kroku 1>], medicalRecordIds: [], challengeId: null,
# userNote: "<poznámka>", ← zobrazí se jako pole "Poznámka" v dotazníku
# createdByDoctor: false
# }
```
POZOR: `createPatientRequest` (bez „WithoutReservation") požadavek vytvoří, ale
NEZOBRAZÍ se v žádné frontě — používat `createPatientRequestWithoutReservation`.
| Klíč | Hodnota |
|------|---------|
| ECRF „Recept na léky" `userECRFId` | `79488e86-e9e5-47e3-8b19-7e5229427f23` |
| ECRF `sid` | `ERECEPT_SIMPLEST_BEZ_DAVKOVANI` |
| ECRF `stepId` | `erecept-gp-request` |
| pole 1 `fieldName` | `nazev-leku` (→ „Název léků") |
| pole 2 | `userNote` v create inputu (→ „Poznámka") |
Seznam typů požadavků: `UserEcrfAutocomplete_ListUserECRFsByClinic`.
### Tagy / štítky požadavku — ODCHYCENO 2026-06-13
```graphql
query TagRequestEditModal_ListTags($clinicSlug: String!, $requestId: UUID!) { ... } # seznam štítků + zda jsou přiřazené
mutation TagRequestEditModal_AssignTagToRequest($clinicSlug: String!, $requestId: UUID!, $tagId: UUID!) {
tagRequest: assignTagToPatientRequest(clinicSlug: $clinicSlug, patientRequestId: $requestId, tagId: $tagId) { id }
}
# Vytvoření nového štítku:
mutation TagEditModal_CreateTag($clinicSlug: String!, $input: CreateTagInput!) {
tag: createTag(clinicSlug: $clinicSlug, input: $input) { id name color icon important isOrganizationWide }
}
# input: { name, color (např. "SKY"/"ORCHID"), icon: null, important: false, type: "patient_request", isOrganizationWide: false }
```
| Štítek | tagId | barva |
|--------|-------|-------|
| `CLAUDE` | `c136aeca-0625-4c43-b81f-fc3949ec6ba6` | ORCHID |
| `OVĚŘIT PACIENTA` | `9d3271b3-309d-4d20-93ee-285f3e56ba42` | SKY |
| `NEZAPOMENOUT` | `5bced917-83d2-46db-896c-c8e615de1a69` | GREY |
### Request Detail
| Operation | Variables | Response |
+8 -1
View File
@@ -709,12 +709,19 @@ ORDER BY h.DATUM DESC
| Nástroj | Popis |
|---|---|
| `get_patient(idpac)` | Základní info o pacientovi z KAR — jmeno, prijmeni, rc, datnar, pojistovna |
| `search_patients(query)` | Hledání pacienta podle příjmení/jména/RC, max 50 výsledků |
| `search_patients(query, datum_narozeni?)` | Hledání pacienta podle jména/RC, max 50 výsledků |
| `get_patient_timeline(idpac, datum_od?, datum_do?)` | Chronologický přehled z DOCLIST — všechny záznamy pacienta |
| `parse_histdoc_data(idhistdoc)` | Dekóduje DATA blob z HISTDOC — vrátí dict {Kod, Nazev, Pocet, Cena, Stav, Doklad…} |
| `get_table_info(table)` | Rozšířené info o tabulce — typy sloupců, nullable, PK, počet záznamů |
| `safe_query(sql, params?)` | SELECT s ochranou — varuje pro velké tabulky bez WHERE, limit 500 řádků |
### Nové nástroje (přidáno 2026-06-12)
| Nástroj | Popis |
|---|---|
| `search_patients(query, datum_narozeni?)`**rozšířeno** | Jméno nyní bez ohledu na diakritiku a pořadí slov („mateju petr" najde „Petr Matějů"); RC podle číslic; volitelný filtr data narození; nově vrací i datnar a vyrazen |
| `search_patient_by_contact(kontakt)` | Pacient podle e-mailu/telefonu z KARKONTAKT (TYP: 1=pevná, 2=mobil, 3=e-mail); telefony porovnává jen po číslicích, ignoruje +420 a mezery |
| `get_columns_overview(table, sample_rows?)` | Sémantika sloupců — ze vzorku N řádků (výchozí 1000) top 5 hodnot + četnosti per sloupec (např. zjistí, že RECEPT.STORNO je 'T'/'F') |
### Velké tabulky vyžadující WHERE (safe_query varuje automaticky)
LOG, ZURNAL, LABVD, DOCLIST, PZT, LEKY, DEKLINK
+95
View File
@@ -0,0 +1,95 @@
# MedicusFirebird — Firebird 2.5 zrcadlo Medicus DB na toweru
Kontejnerizované zrcadlo ostré Medicus databáze (Firebird) na serveru **tower** (Unraid, 192.168.1.76).
Nahrazuje dosavadní restore na Windows VM **reporter** — tu lze po ověření na Firebird části vypnout.
## Proč
Všechny ostatní DB (MySQL, PostgreSQL, MongoDB, Redis) běží na toweru jako Docker.
Firebird sem logicky patří taky: jeden host, jeden režim záloh/monitoringu, žádná Windows VM navíc.
## Tok dat
```
Ordinace: gbak -> zip -> rsync na tower (~02:15)
Tower: /mnt/user/OrdinaceSynology/MedicusBackup/MEDICUS_RRMMDD_HHMM.zip (zalohy se HROMADI)
restore_medicus.sh (denne):
1) nejnovejsi MEDICUS_*.zip podle nazvu; pokud == last_restored.txt -> skip
2) pocka az velikost prestane rust (probiha-li jeste rsync) + overi unzip -t
3) unzip .fbk -> gbak -r do medicus_new.fdb -> stop+swap+start kontejneru
4) zapise marker (last_restored.txt)
5) GFS retence zaloh (prune_backups.sh)
Kontejner: firebird-medicus -> serve tower:3050 /firebird/data/medicus.fdb
```
Zdrojová DB: **Firebird 2.5.7**, ODS 11.2, dialect 3, page size 8192.
Image: `jacobalberty/firebird:2.5-ss` = **Firebird 2.5.9** (restore 2.5.7 → 2.5.9 v rámci řady OK).
## Soubory
| Soubor | Popis |
|--------|-------|
| `firebird_create.sh` | Jednorázové vytvoření / znovuvytvoření kontejneru |
| `restore_medicus.sh` | Denní rutina: obnova z nejnovější zálohy + retence (cron) |
| `prune_backups.sh` | GFS retence záloh (volá se z restore; lze i samostatně) |
| `verify_firebird.sh` | Kontrola: verze enginu, ODS, počet pacientů |
| `last_restored.txt` | Marker poslední úspěšně restorované zálohy (vzniká za běhu) |
| `restore.log` | Log denních běhů |
## Umístění na toweru
- Skripty: `/mnt/user/Scripts/MedicusFirebird/`
- Data kontejneru: `/mnt/user/appdata/firebird-medicus/fb` (→ `/firebird`, soubor `data/medicus.fdb`)
- Rozbalovací prostor pro `.fbk`: `/mnt/user/appdata/firebird-medicus/work` (→ `/work`)
## Kontejner
```
docker run -d --name firebird-medicus --restart unless-stopped \
-p 3050:3050 -e ISC_PASSWORD=masterkey -e TZ=Europe/Prague \
-v /mnt/user/appdata/firebird-medicus/fb:/firebird \
-v /mnt/user/appdata/firebird-medicus/work:/work \
jacobalberty/firebird:2.5-ss
```
Pozn.: `gbak`/`isql` jsou v `/usr/local/firebird/bin/` (nejsou v PATH → volat plnou cestou).
Hesla jsou v `security2.fdb` (nastaveno přes `ISC_PASSWORD`), ne v `medicus.fdb` — restore dat heslo nemění.
## Robustnost restoru
Zálohy se v adresáři **hromadí** a nejnovější se může právě **přenášet přes rsync**, proto:
- výběr nejnovější podle **názvu** (`RRMMDD_HHMM` → lexikálně = chronologicky)
- **stav** v `last_restored.txt` → když není nic novějšího, nic se nedělá
- **čeká na dokončení přenosu** (velikost se ustálí) a ověří integritu `unzip -t` — nikdy nezpracuje nekompletní soubor
- marker se zapíše **až po úspěšném** restoru; zámek (`flock`) proti souběhu
## Retence záloh (GFS, sekvenční, počítaná)
`prune_backups.sh` drží v adresáři záloh schéma:
1. **30 posledních dní** — nech všechny denní
2. **pak 8 týdnů** — z každého ISO-týdne 1× (nejnovější = konec týdne)
3. **pak 12 měsíců** — z každého měsíce 1× (nejnovější)
4. starší → smazat
Datum se čte **z názvu** (ne mtime). Neparsovatelné názvy se nikdy nemažou.
Bezpečnostní přepínač `DRY_RUN=1` (jen výpis) / `DRY_RUN=0` (maže). V denní rutině řízeno `RETENTION_DRYRUN`
v `restore_medicus.sh` (ostré = 0).
## Připojení klientů (fdb / DSN)
```
192.168.1.76:/firebird/data/medicus.fdb SYSDBA / masterkey, charset win1250
# nebo: tower:/firebird/data/medicus.fdb
```
V `Knihovny/medicus_db.py` je odpovídající záznam v `dsn_map` (klíč `TOWER`).
Cutover skriptů/MCP z reporteru (2.5.7) na tower (2.5.9) = otevřené rozhodnutí.
## Cron (na toweru)
Záloha přistává ~02:15; denní rutina poté. Plánovat přes **User Scripts plugin**
(vzor: `PostgreSQLRestoreFromBackup`), spouštět:
```
/mnt/user/Scripts/MedicusFirebird/restore_medicus.sh # napr. 06:30 denne
```
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Vytvori (nebo znovuvytvori) Firebird 2.5 kontejner = zrcadlo Medicus DB na toweru.
# Spousti se jednorazove pri zakladani / zmene konfigurace.
set -euo pipefail
NAME=firebird-medicus
IMAGE=jacobalberty/firebird:2.5-ss
APPDATA=/mnt/user/appdata/firebird-medicus
FBDIR="$APPDATA/fb" # -> /firebird (data, system, security2.fdb)
WORKDIR="$APPDATA/work" # -> /work (sem se rozbaluje .fbk pred restorem)
PASS=masterkey
mkdir -p "$FBDIR" "$WORKDIR"
# odstran stary kontejner, pokud existuje (data v appdata zustanou)
docker rm -f "$NAME" 2>/dev/null || true
docker run -d \
--name "$NAME" \
--restart unless-stopped \
-p 3050:3050 \
-e ISC_PASSWORD="$PASS" \
-e TZ=Europe/Prague \
-v "$FBDIR":/firebird \
-v "$WORKDIR":/work \
"$IMAGE"
echo "Kontejner $NAME vytvoren. Cekam na start serveru..."
sleep 10
docker ps --filter "name=$NAME" --format "{{.Names}} {{.Status}} {{.Ports}}"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# GFS retence PLNYCH zaloh Medicus (kazda zaloha je kompletni -> mazani ostatnich je bezpecne).
#
# SEKVENCNI, POCITANE tiery (jdou ZA sebou, neprekryvaji se), od nejnovejsiho zpet:
# 1) DENNI : poslednich 30 dni -> nech VSECHNY
# 2) TYDENNI : pak presne 8 tydnu dozadu -> z kazdeho ISO-tydne 1x (nejnovejsi = konec tydne)
# 3) MESICNI : pak presne 12 mesicu dozadu -> z kazdeho mesice 1x (nejnovejsi)
# 4) starsi : smazat
# Reference "ted" = datum NEJNOVEJSI zalohy. Datum se cte Z NAZVU MEDICUS_RRMMDD_HHMM.zip.
#
# BEZPECNOST: DRY_RUN=1 (default) jen vypisuje, NIC nemaze. DRY_RUN=0 skutecne maze.
# Neznamy/neparsovatelny nazev se NIKDY nemaze.
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/mnt/user/OrdinaceSynology/MedicusBackup}"
DAILY_DAYS="${DAILY_DAYS:-30}"
WEEKLY_WEEKS="${WEEKLY_WEEKS:-8}"
MONTHLY_MONTHS="${MONTHLY_MONTHS:-12}"
DRY_RUN="${DRY_RUN:-1}"
date_from_name() { local d="${1#MEDICUS_}"; d="${d:0:6}"; echo "20${d:0:2}-${d:2:2}-${d:4:2}"; }
mapfile -t FILES < <(cd "$BACKUP_DIR" && ls -1 MEDICUS_*.zip 2>/dev/null | sort -r) # nejnovejsi prvni
[ "${#FILES[@]}" -eq 0 ] && { echo "Zadne zalohy v $BACKUP_DIR"; exit 0; }
REF=$(date_from_name "${FILES[0]}")
date -d "$REF" >/dev/null 2>&1 || { echo "CHYBA: nelze precist datum z ${FILES[0]}"; exit 1; }
D_CUT=$(date -d "$REF -${DAILY_DAYS} days" +%F)
echo "REF=$REF denni>=$D_CUT, pak ${WEEKLY_WEEKS}x tydenni, pak ${MONTHLY_MONTHS}x mesicni (starsi smazat)"
declare -A KEEP seen_week seen_month
dn=0; w=0; m=0
for f in "${FILES[@]}"; do
dt=$(date_from_name "$f")
if ! date -d "$dt" >/dev/null 2>&1; then KEEP[$f]="?"; continue; fi # neparsovatelne -> ponechat
if [[ ! "$dt" < "$D_CUT" ]]; then KEEP[$f]="d"; dn=$((dn+1)); continue; fi # 1) denni (30 dni)
if [ "$w" -lt "$WEEKLY_WEEKS" ]; then # 2) tydenni (8x)
wk=$(date -d "$dt" +%G-%V)
[ -z "${seen_week[$wk]:-}" ] && { seen_week[$wk]=1; w=$((w+1)); KEEP[$f]="w"; }
continue
fi
if [ "$m" -lt "$MONTHLY_MONTHS" ]; then # 3) mesicni (12x)
mo=$(date -d "$dt" +%Y-%m)
[ -z "${seen_month[$mo]:-}" ] && { seen_month[$mo]=1; m=$((m+1)); KEEP[$f]="m"; }
continue
fi
done
mode="DRY-RUN (nic se nemaze)"; [ "$DRY_RUN" = "0" ] && mode="OSTRY (maze!)"
echo "=== GFS retence $mode | $BACKUP_DIR ==="
echo "schema: ${DAILY_DAYS}d / ${WEEKLY_WEEKS}t / ${MONTHLY_MONTHS}m | celkem: ${#FILES[@]} | ponechano: ${#KEEP[@]} (denni=$dn tydenni=$w mesicni=$m)"
del=0
for f in "${FILES[@]}"; do
if [ -n "${KEEP[$f]:-}" ]; then
printf ' KEEP [%s] %s\n' "${KEEP[$f]}" "$f"
else
printf ' DEL %s\n' "$f"; del=$((del+1))
[ "$DRY_RUN" = "0" ] && rm -f -- "$BACKUP_DIR/$f"
fi
done
echo "=== ke smazani: $del ==="
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# Denni obnova zrcadla Medicus DB z nejnovejsi gbak zalohy do Firebird kontejneru
# + GFS retence zaloh.
#
# Zalohy se v adresari HROMADI a nejnovejsi se muze prave PRENASET pres rsync. Proto:
# - vybira nejnovejsi MEDICUS_*.zip podle nazvu (RRMMDD_HHMM -> lexikalne = chronologicky)
# - pamatuje si posledni uspesne restorovanou (last_restored.txt) -> neni-li nic novejsiho, konci
# - ceka, az velikost prestane rust (probiha-li rsync), a overi integritu (unzip -t),
# teprve pak restoruje -> nikdy nezpracuje nekompletni prenos
# - marker se zapise az PO uspesnem restoru
# - na konci spusti GFS retenci zaloh (prune_backups.sh)
set -euo pipefail
NAME=firebird-medicus
APPDATA=/mnt/user/appdata/firebird-medicus
DATA="$APPDATA/fb/data"
WORK="$APPDATA/work"
BACKUP_DIR=/mnt/user/OrdinaceSynology/MedicusBackup
GBAK=/usr/local/firebird/bin/gbak
PASS=masterkey
BASE_DIR=/mnt/user/Scripts/MedicusFirebird
STATE="$BASE_DIR/last_restored.txt"
LOG="$BASE_DIR/restore.log"
# Retence: ostra (maze dle GFS 30d/8t/12m). Pro testovaci beh prepnout na 1 (jen vypis).
RETENTION_DRYRUN="${RETENTION_DRYRUN:-0}"
exec >>"$LOG" 2>&1
exec 9>"$BASE_DIR/.restore.lock"
flock -n 9 || { echo "$(date '+%F %T') jiny restore uz bezi -> koncim."; exit 0; }
echo "===== $(date '+%F %T') restore start ====="
mkdir -p "$WORK" "$DATA"
# --- 1) nejnovejsi zaloha podle nazvu ---
ZIP=$(ls -1 "$BACKUP_DIR"/MEDICUS_*.zip 2>/dev/null | sort | tail -1 || true)
if [ -z "${ZIP:-}" ]; then echo "CHYBA: zadna zaloha v $BACKUP_DIR"; exit 1; fi
ZIP_BASE=$(basename "$ZIP")
LAST=$(cat "$STATE" 2>/dev/null || echo "")
echo "Nejnovejsi: $ZIP_BASE | posledni restorovana: ${LAST:-<zadna>}"
# --- 2) uz restorovana? ---
if [ "$ZIP_BASE" = "$LAST" ]; then
echo "Nic noveho -> restore preskocen."
else
# --- 3) pockej na dokonceni prenosu (velikost se ustali) ---
prev=-1; stable=0
for i in $(seq 1 80); do # max ~20 min cekani na rsync
cur=$(stat -c %s "$ZIP" 2>/dev/null || echo 0)
if [ "$cur" = "$prev" ] && [ "$cur" -gt 0 ]; then stable=1; break; fi
echo " ...$ZIP_BASE = $cur B, cekam na ustaleni"
prev=$cur; sleep 15
done
[ "$stable" = "1" ] || { echo "CHYBA: $ZIP_BASE se stale meni -> koncim (priste)."; exit 1; }
# --- 4) integrita (nekompletni/poskozeny zip neprojde) ---
echo "unzip -t ..."
unzip -tqq "$ZIP" || { echo "CHYBA: $ZIP_BASE neprosel unzip -t -> koncim."; exit 1; }
# --- 5) rozbaleni .fbk ---
rm -f "$WORK"/*.fbk
unzip -o "$ZIP" -d "$WORK" >/dev/null
FBK=$(ls -1t "$WORK"/*.fbk | head -1)
FBK_BASE=$(basename "$FBK")
echo "FBK: $FBK_BASE ($(du -h "$FBK" | cut -f1))"
# --- 6) restore pres bezici server do noveho souboru ---
docker start "$NAME" >/dev/null 2>&1 || true
sleep 8
echo "gbak restore -> medicus_new.fdb ..."
docker exec "$NAME" rm -f /firebird/data/medicus_new.fdb
docker exec "$NAME" "$GBAK" -r -p 8192 -user SYSDBA -password "$PASS" \
"/work/$FBK_BASE" "localhost:/firebird/data/medicus_new.fdb"
# --- 7) atomicky swap + restart ---
echo "swap + restart ..."
docker stop "$NAME" >/dev/null
mv -f "$DATA/medicus_new.fdb" "$DATA/medicus.fdb"
docker start "$NAME" >/dev/null
sleep 8
rm -f "$WORK"/*.fbk
# --- 8) marker az po uspechu ---
echo "$ZIP_BASE" > "$STATE"
echo "restore OK: $ZIP_BASE"
fi
# --- 9) GFS retence zaloh ---
echo "--- retence zaloh (DRY_RUN=$RETENTION_DRYRUN) ---"
DRY_RUN="$RETENTION_DRYRUN" "$BASE_DIR/prune_backups.sh" || echo "VAROVANI: prune_backups.sh selhal"
echo "===== $(date '+%F %T') hotovo ====="
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Rychla kontrola obnoveneho zrcadla: verze enginu, ODS, pocet pacientu.
set -euo pipefail
NAME=firebird-medicus
PASS=masterkey
ISQL=/usr/local/firebird/bin/isql
DB=localhost:/firebird/data/medicus.fdb
docker exec -i "$NAME" "$ISQL" -user SYSDBA -password "$PASS" "$DB" <<'SQL'
SELECT rdb$get_context('SYSTEM','ENGINE_VERSION') AS ENGINE FROM rdb$database;
SELECT MON$ODS_MAJOR AS ODS_MAJOR, MON$ODS_MINOR AS ODS_MINOR, MON$PAGE_SIZE AS PAGE_SIZE FROM MON$DATABASE;
SELECT COUNT(*) AS PACIENTU FROM KAR;
QUIT;
SQL
+184
View File
@@ -0,0 +1,184 @@
# OrdinaceAgentEmail — agent na žádosti o recept
Hledá ve schránce **ordinace@buzalkova.cz** e-maily, kde pacient žádá
o předepsání léku (recept), vytěžuje pacienta + požadované léky a pacienta
ověřuje v kartotéce Medicusu.
## Stav: nasazeno na toweru (produkce), DELTA režim
`recepty_agent.py` zpracuje **všechny nové maily od posledního zpracovaného**
(vodoznak), klasifikuje Claude modelem, identifikuje pacienta a u vysoké jistoty
založí požadavek v Medeviu, jinak dá dotaz do fronty (Telegram). Označuje maily
kategoriemi. Běží na toweru — viz „Nasazení na tower" níže.
## Tok
1. **Graph API — DELTA** `nove_inbox_messages(mailbox, since_iso)`: všechny maily
s `receivedDateTime gt vodoznak`, řazeno vzestupně, stránkováno (max
`MAX_PER_RUN`=200/běh). Vodoznak = `_last_processed.txt` (2 řádky: čas + ID
posl. mailu). POZOR: Graph má sub-sekundovou přesnost, ale `receivedDateTime`
se zobrazuje oříznutě na sekundy → `gt` vrací i hraniční už zpracovaný mail;
ten se odfiltruje podle uloženého ID. První běh (vodoznak chybí) jen nastaví
vodoznak na nejnovější mail a od příště jede dopředně (historie se nedohání).
2. **AI klasifikace + vytěžení (Claude `claude-haiku-4-5`)** — pro každý mail
JSON: `je_zadost_o_recept`, `pacient` (může se lišit od odesílatele —
příbuzní píší za pacienta), `rodne_cislo` (přesně jak je v textu),
`datum_narozeni`, `leky[]` (nazev + poznamka), `poznamka`, `duvod`.
3. **Ověření v Medicusu (`MedicusLookup`)** — celá kartotéka se načte do
paměti (KAR ~6300 pacientů + kontakty z KARKONTAKT: ~70 e-mailů,
~4150 telefonů; TYP: 1=pevná, 2=mobil, 3=e-mail). Párování v pořadí
spolehlivosti:
1. **RČ** z textu mailu (Medicus ukládá RČ bez lomítka) — jednoznačné,
2. **e-mail odesílatele** proti KARKONTAKT,
3. **telefon z textu mailu** proti KARKONTAKT (jen číslice, bez +420 —
telefonů je v kartotéce hodně, často rozhodne i duplicitní jména),
4. **jméno** — bez diakritiky, bez ohledu na pořadí slov (Jaroslav Klíma
= Klíma Jaroslav); při více kandidátech zúžení datem narození
(z `datum_narozeni` nebo odvozeným z nesedícího RČ).
Výstup: `[SHODA RČ/E-MAIL/JMÉNO/JMÉNO+DATUM]` s detaily pacienta
(RČ, datum narození, pojišťovna, idpac, příznak vyřazení), nebo
`[NENALEZEN]`.
4. **Nejednoznačnost (více pacientů stejného jména, např. otec a syn)**
`resolve_by_prescriptions()`: načte nestornované recepty kandidátů
z tabulky `RECEPT` (`STORNO <> 'T'`, posledních `RECEPT_MONTHS` = 24
měsíců) a rozhodne podle shody požadovaných léků s historií:
1. **Deterministicky**`_drug_matches()`: substring oběma směry
(„tadalafil" ~ „TADALAFIL ACCORD") + prefix prvních slov od 5 znaků
(„Concord" ~ „CONCOR"). Jediný kandidát s nejvyšším nenulovým
skóre vyhrává → `[SHODA JMÉNO+LÉKY V HISTORII]`.
2. **Claude fallback** — když deterministika nerozhodne (nikdo/více se
shodou), model dostane požadované léky + seznamy předepsaných léků
kandidátů a rozhodne i přes generika/účinné látky → `[SHODA
JMÉNO+LÉKY+AI]`. Když ani AI nerozhodne → `[NEROZHODNUTO]`
+ výpis kandidátů k ruční kontrole.
5. Report + cena AI za běh (~0,04 Kč/mail).
## Sdílená infrastruktura
- `EmailAgent/graph_mail.py` — import přes `sys.path` (stejná app registrace,
Mail.Read Application). Credentials natvrdo tam.
- `Knihovny/medicus_db.py` — Firebird připojení k Medicusu (DSN podle názvu
počítače, na Z230 → `reporter:c:\medicus\medicus.fdb`).
- `ANTHROPIC_API_KEY` z `Medevio/.env`.
## Vytvoření požadavku v Medeviu — `mcp_medevio.zaloz_pozadavek_recept`
Jakmile agent správně identifikuje pacienta + léky, založí mu v Medeviu požadavek
**„Recept na léky"** přesně jako by ho podal pacient v aplikaci — vyplní **oba fieldy
dotazníku** a přidá **štítek CLAUDE**. Vše v jednom volání:
```python
import mcp_medevio
mcp_medevio.zaloz_pozadavek_recept(patient_uuid, leky="Euthyrox 100", poznamka="docházejí mi léky")
```
Mapování (ověřeno naživo na Vladkovi 2026-06-13):
- `leky` → dotazník pole **„Název léků"** (přes ECRF field `nazev-leku`)
- `poznamka` → dotazník pole **„Poznámka"** (jde přes `userNote`**funguje** i z klinické strany!)
- `stitek=True` (default) → přiřadí **štítek CLAUDE** (`assignTagToPatientRequest`)
Postup uvnitř: `fillECRFForm` (oba fieldy, `byDoctor:False`) → `createPatientRequestWithoutReservation`
(`createdByDoctor:False`) → `assignTagToPatientRequest`. Auth: Bearer token z `Medevio/token.txt`
(auto-refresh při 401). Konstanty/mutace viz `Medevio/medevio_api_notes.md`.
Agent (`recepty_agent.py`) volá tuto funkci automaticky po jednoznačné identifikaci
pacienta; `leky_str` z `_format_leky`, `pozn_str` z `_format_poznamka` (hlavička + zkomprimované tělo mailu).
UUID pacienta hledá `_medevio_find_patient` v MySQL `medevio_pacient` (RČ → `patient_id`).
POZN.: požadavky v Medeviu nejdou smazat, jen zavřít („Vyřídit") — proto testovat na
testovacím pacientovi Vladko (`0210db7b-…`).
## Skóre jistoty identifikace pacienta — `skore_jistoty`
Než agent založí požadavek, spočítá **skóre 0100**, jak jistě nalezený pacient
odpovídá pacientovi z mailu. Kombinuje víc nezávislých signálů; **rozpor srazí dolů**
(tím se ošetří díra, kdy shoda na RČ s překlepem trefí jiného pacienta).
| Signál (shoda) | + | Rozpor | |
|---|---|---|---|
| RČ sedí | 55 | jméno úplně jiné | 45 |
| jméno přesně / příjmení / částečně | 30 / 15 / 8 | datum narození nesedí | 35 |
| datum narození sedí | 20 | RČ nesedí na pacienta | 35 |
| e-mail odesílatele v kartotéce | 30 | | |
| telefon z mailu v kartotéce | 20 | | |
| lék v historii receptů | 10 | | |
Rozhodnutí (jediný práh `SCORE_AUTO=85`):
- **≥ 85** → založí požadavek automaticky (štítek CLAUDE).
- **< 85** → **NIC nezaloží** a místo toho se **zeptá člověka přes Telegram**
(viz níže). Důvod: vytvoření požadavku je **nevratné a hned viditelné pacientovi**
— pacienta v něm nejde přepsat ani požadavek smazat. „Založit a pak ověřit"
proto nedává smysl; ověřujeme PŘED založením.
Skóre i důvody jdou do logu. Funkce je čistá (testovatelná stubem), bez zápisů.
## Human-in-the-loop přes Telegram (nejistá identifikace)
Když je jistota < 85, agent jen zapíše dotaz do fronty a jde dál. Vyřízení dělá
samostatný proces. Moduly:
| Modul | Role |
|-------|------|
| `recept_pending.py` | fronta dotazů (`_pending_recepty.json`, atomický zápis), stavy `ceka``zalozeno`/`preskoceno` |
| `recept_dialog.py` | čistě: `format_otazka` (text do Telegramu) + `parse_odpoved` (RČ / číslo kandidáta / „ne") |
| `recept_telegram.py` | přenos přes **user účet agenta** (Telethon, `Knihovny/telegram_user.py`), vlastní session `recepty`, píše Vladovi (`6639316354`) |
| `recept_resolver.py` | proces s vlastní session: pošle otázky, krátce polluje odpovědi (přes `precti_zpravy`, since_id), podle odpovědi založí (správné RČ je definitivní) a označí mail |
Tok: e-mailový agent (vysoká jistota → založí; jinak → `recept_pending.pridej`).
Resolver: otázka z účtu agenta → odpověď jako **reply** (párování přes
`reply_to_msg_id`) → `mcp_medevio.zaloz_pozadavek_recept` správnému pacientovi
→ mail dostane `ClaudeZpracovalRecept`. Bez odpovědi záznam zůstává `ceka`
(čeká se libovolně dlouho, znovu se neptá).
Telegram infrastruktura je popsaná v Trilium „2026-06-14 Telegram — bot, user
účet agenta a MCP server". User účet (na rozdíl od bota) unese víc souběžných
sessions, každá vidí všechny zprávy → odpovědi se rozlišují přes reply.
**Jednorázový krok (uživatel, v terminálu — čeká na SMS kód):**
```
python -m Knihovny.telegram_user login --jako recepty
```
Pak spuštění resolveru: `python recept_resolver.py`
Pozn.: nová session nezná Vladovu „entitu" → posílání by spadlo na *Could not
find the input entity*. Resolver to řeší sám: na startu volá `recept_telegram.priprav()`
(`get_dialogs` → entita se uloží do session). Login lze řídit i na dálku
dvoukrokově: `login_posli_kod('recepty')` → PHONE_CODE_HASH → `login_dokonci(kod, hash, 'recepty')`.
**Ověřeno naživo 2026-06-14:** celý round-trip — agent zapsal nejistý dotaz →
resolver poslal otázku do Telegramu → Vlado odpověděl RČ jako reply → resolver
založil „Recept na léky" správnému pacientovi (dotazník Název léků + Poznámka,
štítek CLAUDE) a poslal potvrzení zpět.
## Známé limity / TODO
- E-mailových kontaktů je v kartotéce málo (~70 z 6300 pacientů) — párování
e-mailem zabere zřídka; telefonů je ~4150, proto se vytěžuje i telefon
z textu mailu. Do budoucna by šlo e-mail odesílatele po ručním potvrzení
do KARKONTAKT doplňovat.
- Párování jménem vyžaduje přesnou shodu množiny slov — překlepy ve jméně
nenajde (kandidát: fuzzy matching / nabídka podobných jmen).
- Bez summary e-mailu a bez odpovědi pacientovi — kandidáti na další krok
(vzor: `EmailAgent/faktury_agent.py`).
- **Idempotence**: po úspěšném založení požadavku se mail označí kategorií
`ClaudeZpracovalRecept` (`graph_mail.ensure_category` / `add_category`,
vyžaduje Mail.ReadWrite — ověřeno, app ho má). Při dalším běhu se takto
označené maily přeskočí (ještě před AI klasifikací). Maily čekající na
odpověď přes Telegram se přeskočí podle `recept_pending.je_mail_pending`
(znovu se neptá).
- DELTA režim: zpracuje vše po vodoznaku (ne jen N nejnovějších). Strop
`MAX_PER_RUN`=200/běh (kdyby byl vodoznak hodně zpět — zbytek dobere další běh).
Vodoznak lze ručně posunout úpravou `_last_processed.txt` (např. backfill).
## Spuštění
Vývoj (notebook/Z230):
```powershell
python U:\ordinaceprojekt\OrdinaceAgentEmail\recepty_agent.py
```
Produkce (tower, python-runner) — viz „Nasazení na tower":
```
docker exec -e PYTHONIOENCODING=utf-8 -e MEDICUS_FDB_DSN=192.168.1.76:/firebird/data/medicus.fdb \
python-runner python /scripts/OrdinaceReceptAgent/OrdinaceAgentEmail/recepty_agent.py
```
+179
View File
@@ -0,0 +1,179 @@
======================================================================
START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech
REŽIM: read-only (ve schránce se nic nemění)
Načteno 5 mailů.
--- [1/5] 2026-06-12T09:17:38Z ---
Od: Petr <pmateju@tiscali.cz>
Předmět: žádost o eRecept
=> ŽÁDOST O RECEPT — Pisatel explicitně žádá o poslání eReceptu na konkrétní lék (tadalafil)
Pacient: Petr Matějů
Lék: tadalafil
Poznámka: Tel.: 602614966
--- [2/5] 2026-06-12T09:12:57Z ---
Od: jardatep@seznam.cz <jardatep@seznam.cz>
Předmět: Recept
=> ŽÁDOST O RECEPT — Pisatel explicitně žádá o léky ('prosím, potřebuji léky'), konkrétně jmenuje dva léky a uvádí své jméno a rodné číslo.
Pacient: Klíma Jaroslav
Rodné číslo: 1965-04-16
Lék: Tezao
Lék: Concord
--- [3/5] 2026-06-12T08:01:18Z ---
Od: Medatron <medatron@medatron.cz>
Předmět: Expedice Vaší objednávky 2026000537
=> NENÍ žádost o recept — Jedná se o automatické potvrzení expedice objednávky od e-shopu Medatron, nikoli o žádost o předpis léku. E-mail obsahuje informace o doručování zboží, není to komunikace pacienta nebo jeho zástupce s lékařem.
--- [4/5] 2026-06-12T05:31:30Z ---
Od: kriz@distribucecz.cz <kriz@distribucecz.cz>
Předmět: Faktura
=> NENÍ žádost o recept — Email obsahuje fakturu za vakcíny od dodavatele. Nejde o žádost pacienta o předpis léku, ale o obchodní komunikaci týkající se fakturace.
--- [5/5] 2026-06-11T15:27:02Z ---
Od: neodpovidejte@portalzp.cz <neodpovidejte@portalzp.cz>
Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY"
=> NENÍ žádost o recept — Jedná se o automatickou notifikaci z portálu VoZP ČR o nové zprávě v schránce zúčtovacích zpráv. Nejedná se o žádost pacienta o předepsání léku, ale o systémové oznámení zdravotní pojišťovny.
HOTOVO: 5 mailů, žádostí o recept: 2.
CENA AI: 5 volání, tokeny input=4060 output=733, $0.0077 ≈ 0.19 Kč
======================================================================
START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech
REŽIM: read-only (ve schránce se nic nemění)
Načteno 5 mailů.
Medicus: kartotéka načtena (6347 pacientů, 69 e-mailových kontaktů).
--- [1/5] 2026-06-12T09:17:38Z ---
Od: Petr <pmateju@tiscali.cz>
Předmět: žádost o eRecept
=> ŽÁDOST O RECEPT — E-mail obsahuje explicitní žádost o poslání eReceptu na konkrétní lék (tadalafil). Jde o jasnou žádost o předpis léku.
Pacient: Petr Matějů
Lék: tadalafil
Poznámka: Žádost o eRecept; telefonní kontakt: 602614966
Medicus: [NEJEDNOZNAČNÉ — JMÉNO, 2 kandidátů]
- Matějů Petr, RČ 520422227, nar. 1952-04-22, poj. 211, idpac 4860
- Matějů Petr, RČ 8203280437, nar. 1982-03-28, poj. 211, idpac 4861
--- [2/5] 2026-06-12T09:12:57Z ---
Od: jardatep@seznam.cz <jardatep@seznam.cz>
Předmět: Recept
=> ŽÁDOST O RECEPT — E-mail obsahuje jasnou žádost o předepsání léků ("prosím, potřebuji léky") se specifikací konkrétních látek (Tezao, Concord). Jedná se o typickou žádost o recept.
Pacient: Klíma Jaroslav
Narozen: 1965-04-16
Lék: Tezao
Lék: Concord
Poznámka: Léky na tlak (hypertenzi)
Medicus: [SHODA JMÉNO] Klíma Jaroslav, RČ 6504161928, nar. 1965-04-16, poj. 111, idpac 4356
--- [3/5] 2026-06-12T08:01:18Z ---
Od: Medatron <medatron@medatron.cz>
Předmět: Expedice Vaší objednávky 2026000537
=> NENÍ žádost o recept — E-mail je automatická notifikace od e-shopu Medatron o expedici objednávky. Nejedná se o žádost o předpis léku či vystavení receptu, nýbrž o potvrzení expedice zboží.
--- [4/5] 2026-06-12T05:31:30Z ---
Od: kriz@distribucecz.cz <kriz@distribucecz.cz>
Předmět: Faktura
=> NENÍ žádost o recept — E-mail obsahuje fakturu za vakcíny od distributora, nikoliv žádost o předpis léku od pacienta či jeho zástupce.
--- [5/5] 2026-06-11T15:27:02Z ---
Od: neodpovidejte@portalzp.cz <neodpovidejte@portalzp.cz>
Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY"
=> NENÍ žádost o recept — Jedná se o automatizované oznámení od pojišťovny VoZP o nové zprávě ve schránce zúčtovacích zpráv. Neobsahuje žádost o předpis léku ani recept.
HOTOVO: 5 mailů, žádostí o recept: 2.
CENA AI: 5 volání, tokeny input=4510 output=806, $0.0085 ≈ 0.21 Kč
======================================================================
START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech
REŽIM: read-only (ve schránce se nic nemění)
Načteno 5 mailů.
Medicus: kartotéka načtena (6347 pacientů, 69 e-mailových kontaktů).
--- [1/5] 2026-06-12T09:17:38Z ---
Od: Petr <pmateju@tiscali.cz>
Předmět: žádost o eRecept
=> ŽÁDOST O RECEPT — E-mail explicitně obsahuje žádost o předpis léku (eRecept na tadalafil). Jedná se o jasnou žádost o recept.
Pacient: Petr Matějů
Lék: tadalafil
Poznámka: Požadavek na eRecept; telefonní číslo: 602614966
Medicus: [NEJEDNOZNAČNÉ — JMÉNO, 2 kandidátů] — rozhoduji podle historie receptů:
- idpac 4860: 13 receptů/24 měs., shoda léků: tadalafil (historie: ATORIS, SILDENAFIL ACTAVIS, TADALAFIL ACCORD)
- idpac 4861: 0 receptů/24 měs., shoda léků: žádná (historie: prázdná)
Medicus: [SHODA JMÉNO+LÉKY V HISTORII] Matějů Petr, RČ 520422227, nar. 1952-04-22, poj. 211, idpac 4860
--- [2/5] 2026-06-12T09:12:57Z ---
Od: jardatep@seznam.cz <jardatep@seznam.cz>
Předmět: Recept
=> ŽÁDOST O RECEPT — Pacient explicitně žádá o předepsání léků ("potřebuji léky") s konkrétním uvedením názvů dvou preparátů. Jedná se jasně o žádost o recept.
Pacient: Klíma Jaroslav
Narozen: 1965-04-16
Lék: Tezao
Lék: Concord
Poznámka: Léky na krevní tlak
Medicus: [SHODA JMÉNO] Klíma Jaroslav, RČ 6504161928, nar. 1965-04-16, poj. 111, idpac 4356
--- [3/5] 2026-06-12T08:01:18Z ---
Od: Medatron <medatron@medatron.cz>
Předmět: Expedice Vaší objednávky 2026000537
=> NENÍ žádost o recept — Jedná se o automatický e-mail od společnosti Medatron informující o expedici objednávky. Nejde o žádost o předpis léku, ale o potvrzení doručení zboží dopravci.
--- [4/5] 2026-06-12T05:31:30Z ---
Od: kriz@distribucecz.cz <kriz@distribucecz.cz>
Předmět: Faktura
=> NENÍ žádost o recept — E-mail je fakturu za vakcíny od distributora. Nejedná se o žádost pacienta o předepsání léku, ale o obchodní korespondenci týkající se dodávky zboží.
--- [5/5] 2026-06-11T15:27:02Z ---
Od: neodpovidejte@portalzp.cz <neodpovidejte@portalzp.cz>
Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY"
=> NENÍ žádost o recept — E-mail je automatickou notifikací z portálu VoZP ČR o nové zprávě ve schránce 'ZÚČTOVACÍ ZPRÁVY'. Nejedná se o žádost o předpis léku, recept nebo libovolnou službu od ordinace. Jde o administrativní zprávu zdravotní pojišťovny.
HOTOVO: 5 mailů, žádostí o recept: 2.
CENA AI: 5 volání, tokeny input=4510 output=834, $0.0087 ≈ 0.22 Kč
======================================================================
START — schránka=ordinace@buzalkova.cz, test na 5 nejnovějších mailech
REŽIM: read-only (ve schránce se nic nemění)
Načteno 5 mailů.
Medicus: kartotéka načtena (6347 pacientů, 69 e-mailů, 4156 telefonů).
--- [1/5] 2026-06-12T09:17:38Z ---
Od: Petr <pmateju@tiscali.cz>
Předmět: žádost o eRecept
=> ŽÁDOST O RECEPT — E-mail obsahuje explicitní žádost o vydání receptu na konkrétní lék (tadalafil)
Pacient: Petr Matějů
Telefon: 602614966
Lék: tadalafil
Poznámka: Žádost o eRecept (elektronický recept)
Medicus: [SHODA TELEFON] Matějů Petr, RČ 520422227, nar. 1952-04-22, poj. 211, idpac 4860
--- [2/5] 2026-06-12T09:12:57Z ---
Od: jardatep@seznam.cz <jardatep@seznam.cz>
Předmět: Recept
=> ŽÁDOST O RECEPT — Písemně žádá o předepsání léků ("prosím, potřebuji léky"), konkrétně se jedná o lékařský předpis na Tezao a Concord.
Pacient: Klíma Jaroslav
Narozen: 1965-04-16
Lék: Tezao
Lék: Concord
Poznámka: Pacienta zajímají léky na tlak (hypertenzi)
Medicus: [SHODA JMÉNO] Klíma Jaroslav, RČ 6504161928, nar. 1965-04-16, poj. 111, idpac 4356
--- [3/5] 2026-06-12T08:01:18Z ---
Od: Medatron <medatron@medatron.cz>
Předmět: Expedice Vaší objednávky 2026000537
=> NENÍ žádost o recept — Jedná se o automatické potvrzení expedice objednávky od e-shopu Medatron, nikoliv o žádost o předpis léku či vystavení receptu. Email informuje o doručování zboží.
--- [4/5] 2026-06-12T05:31:30Z ---
Od: kriz@distribucecz.cz <kriz@distribucecz.cz>
Předmět: Faktura
=> NENÍ žádost o recept — E-mail je fakturu za vakcíny od dodavatele, nikoliv žádost o předpis léku od pacienta či jeho zástupce.
--- [5/5] 2026-06-11T15:27:02Z ---
Od: neodpovidejte@portalzp.cz <neodpovidejte@portalzp.cz>
Předmět: Portál VoZP ČR: nová zpráva ve schránce "ZÚČTOVACÍ ZPRÁVY"
=> NENÍ žádost o recept — Jedná se o automatickou notifikaci od pojišťovny VoZP o nové zprávě v účtovací schránce, nikoliv o žádost o předpis léku.
HOTOVO: 5 mailů, žádostí o recept: 2.
CENA AI: 5 volání, tokeny input=4705 output=802, $0.0087 ≈ 0.22 Kč
[Medevio hledání pacienta selhalo] RuntimeError: GraphQL error [Search]: [{'message': 'Cannot query field "search" on type "Query".', 'locations': [{'line': 3, 'column': 3}], 'extensions': {'code': 'GRAPHQL_VALIDATION_FAILED'}}]
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
recept_dialog.py — formátování otázky do Telegramu a parsování odpovědi člověka.
Čisté funkce bez vedlejších efektů (snadno testovatelné).
"""
import re
import unicodedata
def _bez_diakritiky(s: str) -> str:
s = unicodedata.normalize("NFKD", s or "")
return "".join(c for c in s if not unicodedata.combining(c))
def format_otazka(rec: dict) -> str:
"""Sestaví text otázky do Telegramu z pending záznamu."""
sk = rec.get("skore")
lines = ["🟡 Žádost o recept — nejistá identifikace"
+ (f" (jistota {sk}/100)" if sk is not None else "")]
if rec.get("sender"):
lines.append(f"Od: {rec['sender']}")
if rec.get("email_subject"):
lines.append(f"Předmět: {rec['email_subject']}")
if rec.get("leky_str"):
lines.append(f"Léky: {rec['leky_str']}")
if rec.get("duvody"):
lines.append("Proč nejisté: " + "; ".join(rec["duvody"]))
kand = rec.get("kandidati") or []
if kand:
lines.append("")
lines.append("Kandidáti:")
for i, k in enumerate(kand, 1):
lines.append(
f" {i}) {k.get('prijmeni', '')} {k.get('jmeno', '')}"
f", RČ {k.get('rc', '?')}, nar. {k.get('datnar', '?')}"
f", poj. {k.get('poj', '?')}"
)
else:
lines.append("")
lines.append("(žádný kandidát v kartotéce)")
lines.append("")
lines.append("Odpověz jako reply na tuto zprávu:")
lines.append("• RČ správného pacienta (definitivní)")
if kand:
lines.append("• nebo číslo kandidáta (1, 2, …)")
lines.append("• nebo „ne“ = nezakládat")
return "\n".join(lines)
_SKIP = {
"ne", "nezakladat", "preskoc", "preskocit", "zahodit", "zahod",
"ignoruj", "nic", "stop", "nezaklada", "nezakladej",
}
def parse_odpoved(text: str) -> dict:
"""Rozparsuje odpověď člověka.
Vrací dict:
{"akce": "rc", "rc": "7309208104"} definitivní RČ pacienta
{"akce": "kandidat", "index": 1} výběr kandidáta podle pořadí
{"akce": "preskoc"} nezakládat
{"akce": "nejasne"} nerozpoznáno
"""
t = (text or "").strip()
low = _bez_diakritiky(t).lower().strip().rstrip(".!")
if low in _SKIP:
return {"akce": "preskoc"}
digits = re.sub(r"\D", "", t)
if len(digits) in (9, 10):
return {"akce": "rc", "rc": digits}
m = re.fullmatch(r"\s*(\d{1,2})\s*", t)
if m:
return {"akce": "kandidat", "index": int(m.group(1))}
return {"akce": "nejasne"}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
recept_pending.py — fronta "čekajících" žádostí o recept, u kterých si agent
NENÍ jistý identifikací pacienta a potřebuje potvrzení člověka přes Telegram.
E-mailový agent sem zapíše záznam (stav 'ceka') a NIC dalšího nedělá.
Resolver (recept_resolver.py) záznamy bere, pošle otázku do Telegramu a podle
odpovědi člověka založí požadavek správnému pacientovi.
Úložiště: JSON soubor _pending_recepty.json vedle tohoto modulu (atomický zápis).
Stavy záznamu: 'ceka''zalozeno' | 'preskoceno'.
"""
import json
import os
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
STORE = Path(__file__).resolve().parent / "_pending_recepty.json"
def _load() -> list:
if not STORE.exists():
return []
try:
return json.loads(STORE.read_text(encoding="utf-8"))
except Exception:
return []
def _save(items: list) -> None:
tmp = tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=str(STORE.parent), delete=False, suffix=".tmp"
)
try:
json.dump(items, tmp, ensure_ascii=False, indent=2)
tmp.flush()
os.fsync(tmp.fileno())
tmp.close()
os.replace(tmp.name, STORE)
except Exception:
try:
os.unlink(tmp.name)
except Exception:
pass
raise
def pridej(*, email_message_id: str, email_subject: str = "", sender: str = "",
leky_str: str = "", pozn_str: str = "", skore=None,
duvody=None, kandidati=None) -> dict:
"""Přidá nový čekající dotaz. leky_str/pozn_str se použijí při pozdějším
založení požadavku, kandidáti se ukážou člověku v otázce."""
items = _load()
rec = {
"id": uuid.uuid4().hex,
"vytvoreno": datetime.now().isoformat(timespec="seconds"),
"email_message_id": email_message_id,
"email_subject": email_subject,
"sender": sender,
"leky_str": leky_str,
"pozn_str": pozn_str,
"skore": skore,
"duvody": duvody or [],
"kandidati": kandidati or [],
"otazka_message_id": None, # vyplní resolver po odeslání otázky
"stav": "ceka", # ceka | zalozeno | preskoceno
"vysledek": None,
}
items.append(rec)
_save(items)
return rec
def cekajici() -> list:
return [r for r in _load() if r.get("stav") == "ceka"]
def cekajici_bez_otazky() -> list:
"""Záznamy, na které se ještě neposlala otázka do Telegramu."""
return [r for r in _load()
if r.get("stav") == "ceka" and not r.get("otazka_message_id")]
def je_mail_pending(email_message_id: str) -> bool:
return any(r.get("email_message_id") == email_message_id
and r.get("stav") == "ceka" for r in _load())
def najdi_dle_otazky(otazka_message_id) -> dict | None:
if otazka_message_id is None:
return None
for r in _load():
if r.get("otazka_message_id") == otazka_message_id:
return r
return None
def aktualizuj(rec_id: str, **fields) -> dict | None:
items = _load()
out = None
for r in items:
if r.get("id") == rec_id:
r.update(fields)
out = r
break
if out is not None:
_save(items)
return out
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
recept_resolver.py — vyřizuje "čekající" žádosti o recept přes Telegram.
Jediný proces, který mluví s Telegramem (kvůli getUpdates). Ve smyčce:
1) pošle otázky pro nové pending záznamy (které ještě otázku nemají),
2) long-polluje odpovědi; odpověď (reply na otázku) → podle obsahu:
• RČ (910 číslic) → definitivní volba pacienta → založí požadavek
• číslo kandidáta → vybere kandidáta → založí požadavek
• „ne“ → přeskočí (nezakládá)
Po založení označí původní e-mail kategorií ClaudeZpracovalRecept.
Telegram přenos je v recept_telegram.py (token/chat dodá uživatel).
Bez odpovědi záznam zůstává 'ceka' (čeká se libovolně dlouho).
Spuštění: python recept_resolver.py
"""
import re
import sys
import time
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "EmailAgent"))
sys.path.insert(0, str(HERE.parent))
import recept_pending as PEND # noqa: E402
import recept_dialog as DLG # noqa: E402
import recept_telegram as TG # noqa: E402
import graph_mail # noqa: E402 (značení mailu)
import mcp_medevio as MED # noqa: E402 (zaloz_pozadavek_recept)
from Knihovny.mysql_db import connect_mysql # noqa: E402
MAILBOX = "ordinace@buzalkova.cz"
PROCESSED_CATEGORY = "ClaudeZpracovalRecept"
SINCE_FILE = HERE / "_resolver_since.txt" # poslední zpracované message_id
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def _norm_rc(s: str) -> str:
return re.sub(r"\D", "", s or "")
def najdi_pacienta_dle_rc(rc: str):
"""RČ → (uuid, jmeno, prijmeni) z medevio_pacient, nebo None."""
rc = _norm_rc(rc)
if not rc:
return None
try:
conn = connect_mysql()
conn.ping(reconnect=True)
cur = conn.cursor()
cur.execute(
"SELECT patient_id, name, surname FROM medevio_pacient "
"WHERE REPLACE(identification_number,'/','') = %s LIMIT 1",
[rc],
)
row = cur.fetchone()
return row if row else None
except Exception as e:
log(f"[uuid lookup chyba] {type(e).__name__}: {e}")
return None
def _load_since():
try:
return int(SINCE_FILE.read_text(encoding="utf-8").strip())
except Exception:
return None
def _save_since(s: int) -> None:
SINCE_FILE.write_text(str(s), encoding="utf-8")
def posli_nove_otazky() -> None:
"""Pošle otázku do Telegramu pro každý pending záznam bez otázky."""
for rec in PEND.cekajici_bez_otazky():
try:
mid = TG.posli_otazku(DLG.format_otazka(rec))
PEND.aktualizuj(rec["id"], otazka_message_id=mid)
log(f"otázka odeslána (pending {rec['id'][:8]}, msg {mid})")
except Exception as e:
log(f"otázku nelze odeslat ({type(e).__name__}: {e}) — zkusím příště")
break # nejspíš výpadek / nenakonfigurováno → nech na příští kolo
def _zaloz_pro_rc(rec: dict, rc: str) -> None:
info = najdi_pacienta_dle_rc(rc)
if not info:
TG.posli_zpravu(f"⚠ RČ {rc} není v Medeviu — nezakládám. Zkus jiné RČ.")
return
uuid_, jmeno, prijmeni = info[0], info[1], info[2]
try:
res = MED.zaloz_pozadavek_recept(
uuid_, rec.get("leky_str", ""), rec.get("pozn_str", "")
)
except Exception as e:
TG.posli_zpravu(f"❌ Chyba při zakládání: {type(e).__name__}: {e}")
return
PEND.aktualizuj(rec["id"], stav="zalozeno",
vysledek={"request_id": res["request_id"], "rc": rc,
"pacient": f"{prijmeni} {jmeno}"})
try:
graph_mail.add_category(MAILBOX, rec["email_message_id"], PROCESSED_CATEGORY)
except Exception as e:
log(f"[mail označení] {type(e).__name__}: {e}")
TG.posli_zpravu(f"✅ Založeno: {prijmeni} {jmeno} (RČ {rc}) — "
f"{rec.get('leky_str', '')}")
log(f"založeno {res['request_id']} pro {prijmeni} {jmeno}")
def zpracuj_odpoved(u: dict) -> None:
"""Zpracuje jednu příchozí Telegram zprávu."""
rid = u.get("reply_to_message_id")
rec = PEND.najdi_dle_otazky(rid) if rid else None
if rec is None:
cekaji = PEND.cekajici()
if len(cekaji) == 1:
rec = cekaji[0] # jediný čekající → ber to na něj
else:
if cekaji:
TG.posli_zpravu("Odpověz prosím jako reply na konkrétní dotaz "
"(čeká jich víc).")
return
if rec.get("stav") != "ceka":
return
d = DLG.parse_odpoved(u.get("text", ""))
if d["akce"] == "preskoc":
PEND.aktualizuj(rec["id"], stav="preskoceno")
TG.posli_zpravu("OK, nezakládám.")
elif d["akce"] == "rc":
_zaloz_pro_rc(rec, d["rc"])
elif d["akce"] == "kandidat":
kand = rec.get("kandidati") or []
i = d["index"] - 1
if 0 <= i < len(kand):
_zaloz_pro_rc(rec, kand[i].get("rc", ""))
else:
TG.posli_zpravu(f"Kandidát {d['index']} neexistuje (mám {len(kand)}).")
else:
TG.posli_zpravu("Nerozumím. Pošli RČ pacienta, číslo kandidáta, nebo „ne“.")
def smycka(poll_s: int = 5) -> None:
try:
TG.priprav() # naprimuj entitu Vlada (jinak fresh session spadne)
except Exception as e:
log(f"[priprav] {type(e).__name__}: {e}")
since = _load_since()
if since is None:
# první start — vezmi aktuální stav jako základ, starou historii ignoruj
since = TG.baseline_since()
_save_since(since)
log(f"baseline since_id={since}")
log("Resolver běží (Ctrl+C ukončí).")
while True:
try:
posli_nove_otazky()
nove, since = TG.nacti_odpovedi(since)
for u in nove:
zpracuj_odpoved(u)
_save_since(since)
except KeyboardInterrupt:
log("Konec.")
break
except Exception as e:
log(f"[smyčka] {type(e).__name__}: {e}")
time.sleep(poll_s)
if __name__ == "__main__":
smycka()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
recept_telegram.py — Telegram přenos pro recept-resolver.
Používá USER účet agenta (Claude Buzalka @vlado_claude_agent) přes Telethon
z Knihovny/telegram_user.py — VLASTNÍ session "recepty" (per-agent autorizace).
Odpovědi se párují přes Telegram reply (reply_to_msg_id), takže víc agentů na
témž účtu se nepoplete a každý si hlídá jen své odpovědi.
JEDNORÁZOVÝ KROK (dělá uživatel v terminálu — čeká na SMS kód):
python -m Knihovny.telegram_user login --jako recepty
Konfigurace (Medevio/.env): TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_PHONE
(viz Trilium „2026-06-14 Telegram — bot, user účet agenta a MCP server").
"""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from Knihovny.telegram_user import posli_jako_ja, precti_zpravy, _new_client # noqa: E402
# Vlastní session recept-agenta (jedno přihlášení: login --jako recepty).
SESSION = "recepty"
# Komu agent píše = Vladův hlavní účet (z Trilium / TELEGRAM_CHAT_ID).
VLADO_UID = int(os.environ.get("TELEGRAM_CHAT_ID", "6639316354"))
def priprav() -> None:
"""Načte dialogy → do session cache se uloží entita Vlada. Nová session by ji
jinak neznala a posílání by spadlo na 'Could not find the input entity'.
Volá se jednou při startu resolveru (idempotentní, levné)."""
with _new_client(SESSION) as client:
if client.is_user_authorized():
client.get_dialogs(limit=50)
def posli_otazku(text: str) -> int:
"""Pošle otázku Vladovi z účtu agenta. Vrátí message_id (pro párování reply)."""
msg = posli_jako_ja(VLADO_UID, text, session=SESSION)
return msg.id
def posli_zpravu(text: str) -> None:
"""Pošle prostou zprávu (potvrzení / chybu)."""
posli_jako_ja(VLADO_UID, text, session=SESSION)
def baseline_since() -> int:
"""Aktuální nejvyšší message_id v chatu — výchozí bod při prvním startu
(aby resolver nezpracoval starou historii)."""
zpravy = precti_zpravy(VLADO_UID, limit=1, session=SESSION)
return max((z["id"] for z in zpravy), default=0)
def nacti_odpovedi(since_id: int = 0, limit: int = 50):
"""Vrátí (nove_odpovedi, novy_since_id).
Bere jen PŘÍCHOZÍ zprávy (ne naše) s id > since_id. Každá:
{message_id, text, reply_to_message_id}
"""
zpravy = precti_zpravy(VLADO_UID, limit=limit, session=SESSION)
out = []
new_since = since_id
for z in zpravy:
mid = z["id"]
if mid > new_since:
new_since = mid
if mid > since_id and not z["odeslal_ja"]:
out.append({
"message_id": mid,
"text": z["text"],
"reply_to_message_id": z["reply_na"],
})
out.sort(key=lambda x: x["message_id"]) # od nejstarší
return out, new_since
+866
View File
@@ -0,0 +1,866 @@
"""
recepty_agent.py
----------------
Agent, který ve schránce ordinace@buzalkova.cz hledá ŽÁDOSTI O PŘEDPIS
(recept) od pacientů a vytěžuje z nich pacienta a požadované léky.
TESTOVACÍ REŽIM: čte N nejnovějších mailů z Inboxu (read-only, ve schránce
nic nemění) a vypíše report do konzole + logu.
Tok:
1. Microsoft Graph: načti N nejnovějších mailů z Inboxu (bez ohledu na přílohy).
2. AI KLASIFIKACE + VYTĚŽENÍ (Claude): u každého mailu rozhodne, zda jde
o žádost o předpis, a vytěží jméno pacienta (může se lišit od odesílatele),
rodné číslo (pokud je v textu) a seznam požadovaných léků s dávkováním.
3. OVĚŘENÍ V MEDICUSU: pacienta dohledá v kartotéce (KAR + KARKONTAKT)
v pořadí rodné číslo > e-mail odesílatele > jméno.
4. NEJEDNOZNAČNOST (více pacientů stejného jména): načte historii receptů
kandidátů (tabulka RECEPT) a rozhodne podle shody s požadovanými léky —
nejdřív deterministicky (název léku v historii), sporné případy dořeší
Claude nad seznamy předepsaných léků.
5. Vypíše report.
"""
import json
import os
import re
import sys
import unicodedata
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
import requests
# graph_mail.py sdílíme s EmailAgent (stejná app registrace, Mail.Read).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "EmailAgent"))
import graph_mail # noqa: E402
# medicus_db.py z Knihoven (Firebird, DSN podle názvu počítače).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from Knihovny.medicus_db import get_medicus_db # noqa: E402
import mcp_medevio as _medevio # noqa: E402 GraphQL API + zaloz_pozadavek_recept
sys.path.insert(0, str(Path(__file__).resolve().parent))
import recept_pending as _pending # noqa: E402 fronta dotazů (nejistá identifikace)
# =========================
# NASTAVENÍ
# =========================
MAILBOX = "ordinace@buzalkova.cz"
# DELTA REŽIM: zpracují se všechny maily s receivedDateTime > vodoznak
# (čas posledního zpracovaného mailu, uložen v _last_processed.txt). Při prvním
# běhu (vodoznak chybí) se vodoznak nastaví na nejnovější mail v Inboxu a od
# příště se zpracuje jen to, co přijde POTÉ (historie se nedohání).
MAX_PER_RUN = 200 # pojistka: max mailů na jeden běh (kdyby byl vodoznak hodně zpět)
# Kategorie (štítek na mailu), kterou agent označí mail po úspěšném založení
# požadavku v Medeviu. Při dalším běhu se takto označené maily přeskočí
# → idempotence, nezakládá duplicitní požadavky.
PROCESSED_CATEGORY = "ClaudeZpracovalRecept"
# Kategorie pro maily, které nešlo vyřídit automaticky (k ruční kontrole).
MANUAL_CATEGORY = "ReceptRucne"
# Práh jistoty pro PLNĚ automatické založení požadavku. Vytvoření požadavku je
# nevratné a hned viditelné pacientovi → pod tímto prahem agent NIC nezaloží
# a místo toho se zeptá člověka přes Telegram (přes pending frontu, viz
# recept_pending.py / recept_resolver.py).
SCORE_AUTO = 85
# Claude model pro klasifikaci + vytěžení.
ANTHROPIC_MODEL = "claude-haiku-4-5"
# Kolik měsíců historie receptů načíst při rozhodování nejednoznačnosti.
RECEPT_MONTHS = 24
# Cena Claude API — USD za 1M tokenů (input, output). Kurz pro přepočet.
USD_TO_CZK = 25.0
PRICING = {
"claude-haiku-4-5": (1.00, 5.00),
"claude-sonnet-4-6": (3.00, 15.00),
"claude-opus-4-8": (5.00, 25.00),
}
_cost = {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "calls": 0}
HERE = Path(__file__).resolve().parent
LOG_FILE = HERE / "_log_recepty.txt"
WATERMARK_FILE = HERE / "_last_processed.txt" # ISO čas (receivedDateTime) posledního zpracovaného mailu
# =========================
# ENV (Anthropic klíč)
# =========================
def _load_env():
env_path = Path(__file__).resolve().parent.parent / "Medevio" / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip().strip('"').strip("'")
_load_env()
def log(msg: str) -> None:
print(msg)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(msg + "\n")
# =========================
# ČTENÍ MAILŮ (Graph, read-only)
# =========================
_SELECT = "id,subject,from,receivedDateTime,bodyPreview,body,categories"
def _load_watermark() -> tuple[str | None, str | None]:
"""Vrátí (receivedDateTime, id) posledního zpracovaného mailu (řádek 1 a 2
v _last_processed.txt). id slouží k odfiltrování hraničního mailu, který se
kvůli sub-sekundové přesnosti vrací i při filtru `gt` na oříznutý čas."""
try:
lines = WATERMARK_FILE.read_text(encoding="utf-8").splitlines()
t = (lines[0].strip() if lines else "") or None
i = (lines[1].strip() if len(lines) > 1 else "") or None
return t, i
except Exception:
return None, None
def _save_watermark(iso: str, msg_id: str = "") -> None:
WATERMARK_FILE.write_text(f"{iso}\n{msg_id}\n", encoding="utf-8")
def newest_received(mailbox: str) -> tuple[str, str]:
"""(receivedDateTime, id) nejnovějšího mailu v Inboxu — seed vodoznaku při
prvním běhu. Když je schránka prázdná, vrátí (aktuální UTC, '')."""
url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages"
params = {"$orderby": "receivedDateTime desc", "$select": "id,receivedDateTime", "$top": 1}
r = requests.get(url, headers=graph_mail._headers(), params=params, timeout=60)
r.raise_for_status()
vals = r.json().get("value", [])
if vals:
return vals[0]["receivedDateTime"], vals[0]["id"]
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), ""
def nove_inbox_messages(mailbox: str, since_iso: str) -> list[dict]:
"""Všechny maily z Inboxu s receivedDateTime > since_iso, od NEJSTARŠÍHO.
Stránkuje přes @odata.nextLink, max MAX_PER_RUN za jeden běh."""
url = f"{graph_mail.GRAPH}/users/{mailbox}/mailFolders/inbox/messages"
params = {
"$filter": f"receivedDateTime gt {since_iso}",
"$orderby": "receivedDateTime asc",
"$select": _SELECT,
"$top": 50,
}
headers = {**graph_mail._headers(), "Prefer": 'outlook.body-content-type="text"'}
out: list[dict] = []
while url and len(out) < MAX_PER_RUN:
r = requests.get(url, headers=headers, params=params, timeout=60)
r.raise_for_status()
data = r.json()
out.extend(data.get("value", []))
url = data.get("@odata.nextLink")
params = None # nextLink už nese všechny parametry
return out[:MAX_PER_RUN]
# =========================
# AI KLASIFIKACE + VYTĚŽENÍ (Claude)
# =========================
PROMPT = """Jsi asistent ordinace praktického lékaře (MUDr. Michaela Buzalková). \
Rozhoduješ, zda e-mail obsahuje ŽÁDOST O PŘEDPIS LÉKU (recept), a pokud ano, \
vytěžíš detaily.
Pravidla:
- "je_zadost_o_recept": true POUZE pokud pisatel žádá o předepsání léku / \
vystavení receptu (i opakovaného, i "prosím o léky jako obvykle").
- NENÍ žádost o recept: objednání na vyšetření, dotaz na výsledky, omluva, \
faktura, newsletter, zdravotní zpráva z nemocnice, žádanka, potvrzení.
- "pacient": celé jméno pacienta, pro kterého má být lék předepsán. POZOR: \
může se lišit od odesílatele (rodič píše za dítě, manžel za manželku). \
Pokud jméno z mailu neplyne, použij jméno odesílatele.
- "rodne_cislo": rodné číslo pacienta PŘESNĚ jak je v textu napsané (jen číslice, \
příp. s lomítkem — NEPŘEVÁDĚJ na datum narození), jinak null.
- "datum_narozeni": datum narození ve formátu YYYY-MM-DD, pokud je v textu \
uvedeno (a není to rodné číslo), jinak null.
- "leky": seznam požadovaných léků; u každého "nazev" a "poznamka" \
(síla/dávkování/množství/„jako obvykle", pokud je uvedeno, jinak null). \
Pokud pacient žádá o "své obvyklé léky" bez konkrét, vrať jeden záznam \
{"nazev": "obvyklé léky", "poznamka": "bez upřesnění"}.
- "telefon": telefonní číslo uvedené v mailu (jen číslice, jak je napsané), jinak null.
- "poznamka": cokoliv důležitého navíc (spěchá, vyzvedne osobně...), jinak null.
Vrať POUZE JSON:
{"je_zadost_o_recept": true/false, "pacient": "..."|null, "rodne_cislo": "..."|null,
"datum_narozeni": "YYYY-MM-DD"|null, "telefon": "..."|null,
"leky": [{"nazev": "...", "poznamka": "..."|null}], "poznamka": "..."|null,
"duvod": "krátké zdůvodnění rozhodnutí"}
E-MAIL:
Odesílatel: %(sender)s
Předmět: %(subject)s
Přijato: %(received)s
Tělo (zkráceno):
%(body)s
"""
def _claude_json(prompt: str, model: str, max_tokens: int) -> dict:
"""Zavolá Claude a vrátí JSON objekt z odpovědi."""
r = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
timeout=60,
)
r.raise_for_status()
data = r.json()
usage = data.get("usage", {})
in_tok = usage.get("input_tokens", 0)
out_tok = usage.get("output_tokens", 0)
price_in, price_out = PRICING.get(model, PRICING["claude-haiku-4-5"])
_cost["input_tokens"] += in_tok
_cost["output_tokens"] += out_tok
_cost["usd"] += in_tok / 1_000_000 * price_in + out_tok / 1_000_000 * price_out
_cost["calls"] += 1
text = data["content"][0]["text"].strip()
m = re.search(r"\{.*\}", text, re.DOTALL)
if not m:
raise ValueError(f"Claude nevrátil JSON: {text}")
return json.loads(m.group(0))
def classify(msg: dict) -> dict:
sender = (msg.get("from") or {}).get("emailAddress", {})
body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or ""
prompt = PROMPT % {
"sender": f"{sender.get('name', '')} <{sender.get('address', '')}>",
"subject": msg.get("subject") or "",
"received": msg.get("receivedDateTime") or "",
"body": body[:6000],
}
return _claude_json(prompt, ANTHROPIC_MODEL, 500)
# Rozhodnutí nejednoznačného pacienta podle historie předepsaných léků.
AMBIG_PROMPT = """V kartotéce je více pacientů stejného jména. Podle požadovaných léků \
z e-mailu a historie předepsaných léků jednotlivých kandidátů rozhodni, který pacient \
o recept žádá.
Požadované léky z e-mailu: %(leky)s
Kandidáti a jejich léky předepsané v minulosti:
%(kandidati)s
Pravidla:
- Vyber kandidáta, jehož historie odpovídá požadovaným lékům (stejný lék, stejná \
účinná látka, ekvivalentní generikum/originál, lék na stejnou diagnózu).
- Pokud nelze spolehlivě rozhodnout (žádná smysluplná vazba), vrať idpac: null.
Vrať POUZE JSON:
{"idpac": 1234|null, "duvod": "krátké zdůvodnění"}
"""
# =========================
# OVĚŘENÍ PACIENTA V MEDICUSU
# =========================
def _norm_text(s: str) -> str:
"""Bez diakritiky, velkými písmeny, sjednocené mezery."""
s = unicodedata.normalize("NFKD", s or "")
s = "".join(c for c in s if not unicodedata.combining(c))
return re.sub(r"\s+", " ", s).strip().upper()
def _norm_rc(s: str) -> str:
"""Z rodného čísla nechá jen číslice (Medicus ukládá RČ bez lomítka)."""
return re.sub(r"\D", "", s or "")
def _norm_phone(s: str) -> str:
"""Z telefonu nechá jen číslice, bez předvolby 420."""
digits = re.sub(r"\D", "", s or "")
if digits.startswith("420"):
digits = digits[3:]
return digits
def _rc_to_birthdate(rc: str) -> str | None:
"""Z RČ odvodí datum narození YYYY-MM-DD (ženy mají měsíc +50)."""
rc = _norm_rc(rc)
if len(rc) not in (9, 10):
return None
yy, mm, dd = int(rc[0:2]), int(rc[2:4]), int(rc[4:6])
if mm > 50:
mm -= 50
year = yy + (2000 if len(rc) == 10 and yy < 54 else 1900)
try:
from datetime import date
return date(year, mm, dd).isoformat()
except ValueError:
return None
def _drug_matches(requested: str, prescribed: str) -> bool:
"""
Shoda názvu léku z mailu s názvem z receptu: substring oběma směry
("tadalafil" ~ "TADALAFIL ACCORD") + prefix prvních slov od 5 znaků
("Concord" ~ "CONCOR COR" — překlepy/varianty názvu).
"""
a, b = _norm_text(requested), _norm_text(prescribed)
if not a or not b:
return False
if a in b or b in a:
return True
ta, tb = a.split()[0], b.split()[0]
shorter, longer = sorted((ta, tb), key=len)
return len(shorter) >= 5 and longer.startswith(shorter)
class MedicusLookup:
"""Kartotéka v paměti: pacienti z KAR + e-maily z KARKONTAKT (TYP=3).
Drží otevřené spojení pro dotazy na historii receptů (RECEPT)."""
def __init__(self):
self.db = get_medicus_db()
self.patients = self.db.query_dict(
"SELECT k.IDPAC, k.RODCIS, k.PRIJMENI, k.JMENO, k.DATNAR, "
"k.POJ, k.VYRAZEN FROM KAR k "
"WHERE k.PRIJMENI IS NOT NULL AND k.PRIJMENI <> ''"
)
# TYP: 1 = pevná linka, 2 = mobil, 3 = e-mail.
contacts = self.db.query_dict(
"SELECT kk.IDPAC, kk.KONTAKT, kk.TYP FROM KARKONTAKT kk "
"WHERE kk.KONTAKT IS NOT NULL AND kk.KONTAKT <> ''"
)
self.by_rc = {}
self.by_name = {}
by_id = {}
for p in self.patients:
by_id[p["idpac"]] = p
rc = _norm_rc(p.get("rodcis") or "")
if rc:
self.by_rc[rc] = p
key = _norm_text(f"{p.get('jmeno') or ''} {p.get('prijmeni') or ''}")
if key:
self.by_name.setdefault(frozenset(key.split()), []).append(p)
self.by_email = {}
self.by_phone = {}
for c in contacts:
p = by_id.get(c["idpac"])
if not p:
continue
kontakt = (c["kontakt"] or "").strip()
if "@" in kontakt:
self.by_email.setdefault(kontakt.lower(), []).append(p)
else:
phone = _norm_phone(kontakt)
if len(phone) >= 9:
self.by_phone.setdefault(phone, []).append(p)
@staticmethod
def describe(p: dict) -> str:
rc = p.get("rodcis") or "?"
datnar = p.get("datnar")
vyrazen = " [VYŘAZEN]" if (p.get("vyrazen") or "") == "A" else ""
return (f"{p.get('prijmeni','')} {p.get('jmeno','')}, RČ {rc}, "
f"nar. {datnar}, poj. {p.get('poj','?')}, idpac {p.get('idpac')}{vyrazen}")
def match(self, verdict: dict, sender_email: str) -> tuple[str, list[dict]]:
"""
Vrátí (typ_shody, kandidáti). Pořadí spolehlivosti: RČ (jednoznačné) >
e-mail odesílatele > telefon z mailu > jméno (+ příp. datum narození).
"""
# 1) Rodné číslo z textu mailu — nejspolehlivější.
rc = _norm_rc(verdict.get("rodne_cislo") or "")
if rc and rc in self.by_rc:
return "", [self.by_rc[rc]]
# 2) E-mail odesílatele v kartotéce.
hits = self.by_email.get((sender_email or "").strip().lower(), [])
if hits:
return "E-MAIL", hits
# 3) Telefon z textu mailu v kartotéce.
phone = _norm_phone(verdict.get("telefon") or "")
if len(phone) >= 9:
hits = self.by_phone.get(phone, [])
if hits:
return "TELEFON", hits
# 4) Jméno (bez diakritiky, bez ohledu na pořadí slov).
name_key = frozenset(_norm_text(verdict.get("pacient") or "").split())
candidates = self.by_name.get(name_key, []) if name_key else []
# Zúžení datem narození (z pole datum_narozeni nebo z RČ, které nesedlo).
birth = verdict.get("datum_narozeni") or (_rc_to_birthdate(rc) if rc else None)
if len(candidates) > 1 and birth:
narrowed = [p for p in candidates if str(p.get("datnar") or "")[:10] == birth]
if narrowed:
return "JMÉNO+DATUM", narrowed
if candidates:
return "JMÉNO", candidates
return "NENALEZEN", []
def close(self) -> None:
self.db.close()
def prescriptions(self, idpac: int, months: int = RECEPT_MONTHS) -> list[dict]:
"""Nestornované recepty pacienta za posledních N měsíců, nejnovější první."""
since = (date.today() - timedelta(days=months * 30)).isoformat()
return self.db.query_dict(
"SELECT r.DATUM, r.LEK, r.DSIG FROM RECEPT r "
"WHERE r.IDPAC = ? AND r.DATUM >= ? AND r.STORNO <> 'T' "
"ORDER BY r.DATUM DESC",
(idpac, since),
)
def resolve_by_prescriptions(
self, candidates: list[dict], leky: list[dict]
) -> tuple[dict | None, str, list[str]]:
"""
Rozhodne nejednoznačnost podle historie receptů kandidátů.
Vrátí (vítěz|None, popis_metody, řádky_detailu pro log).
"""
requested = [(lek.get("nazev") or "").strip() for lek in leky or []]
requested = [r for r in requested if r]
detail: list[str] = []
# Historie + skóre (kolik požadovaných léků má kandidát v historii).
infos = []
for p in candidates:
history = self.prescriptions(p["idpac"])
drugs = sorted({(h.get("lek") or "").strip() for h in history if h.get("lek")})
matched = sorted(
{req for req in requested if any(_drug_matches(req, d) for d in drugs)}
)
infos.append({"p": p, "drugs": drugs, "matched": matched})
detail.append(
f"idpac {p['idpac']}: {len(history)} receptů/{RECEPT_MONTHS} měs., "
f"shoda léků: {', '.join(matched) if matched else 'žádná'} "
f"(historie: {', '.join(drugs) if drugs else 'prázdná'})"
)
# Deterministicky: jediný kandidát s nejvyšším nenulovým skóre vyhrává.
best = max(len(i["matched"]) for i in infos)
winners = [i for i in infos if len(i["matched"]) == best]
if best > 0 and len(winners) == 1:
return winners[0]["p"], "LÉKY V HISTORII", detail
# Sporné (nikdo/více se shodou) → Claude nad seznamy léků.
if any(i["drugs"] for i in infos):
try:
prompt = AMBIG_PROMPT % {
"leky": ", ".join(requested) or "(neuvedeno)",
"kandidati": "\n".join(
f"- idpac {i['p']['idpac']} "
f"({self.describe(i['p'])}): "
f"{', '.join(i['drugs']) if i['drugs'] else 'žádné recepty'}"
for i in infos
),
}
v = _claude_json(prompt, ANTHROPIC_MODEL, 300)
idpac = v.get("idpac")
winner = next((i["p"] for i in infos if i["p"]["idpac"] == idpac), None)
if winner:
detail.append(f"AI rozhodnutí: {v.get('duvod', '')}")
return winner, "LÉKY+AI", detail
detail.append(f"AI nerozhodlo: {v.get('duvod', '')}")
except Exception as e:
detail.append(f"AI rozhodování selhalo: {type(e).__name__}: {e}")
return None, "", detail
# =========================
# SKÓRE JISTOTY IDENTIFIKACE PACIENTA
# =========================
def skore_jistoty(verdict: dict, patient: dict, sender_email: str,
lookup: "MedicusLookup") -> tuple[int, list[str]]:
"""Kvantifikuje jistotu (0100), že `patient` z kartotéky je opravdu pacient
z e-mailu. Vrací (skóre, důvody). Více nezávislých shod = vyšší jistota;
rozpor (jiné jméno / datum / RČ) skóre tvrdě srazí a označí ⚠.
Tím se ošetří díra, kdy shoda na RČ (např. překlep) trefí jiného pacienta —
bez souhlasu jména spadne z 'jisté' do pásma 'nutná kontrola'."""
body = 0
duvody: list[str] = []
e_rc = _norm_rc(verdict.get("rodne_cislo") or "")
p_rc = _norm_rc(patient.get("rodcis") or "")
e_name = frozenset(_norm_text(verdict.get("pacient") or "").split())
p_name = frozenset(
_norm_text(f"{patient.get('jmeno') or ''} {patient.get('prijmeni') or ''}").split()
)
p_surname = _norm_text(patient.get("prijmeni") or "")
e_dob = verdict.get("datum_narozeni") or (_rc_to_birthdate(e_rc) if e_rc else None)
p_dob = (str(patient.get("datnar") or "")[:10]) or None
idpac = patient.get("idpac")
# Rodné číslo
if e_rc and p_rc:
if e_rc == p_rc:
body += 55; duvody.append("RČ sedí (+55)")
else:
body -= 35; duvody.append("⚠ RČ z mailu NESEDÍ na pacienta (35)")
# Jméno
if e_name and p_name:
if e_name == p_name:
body += 30; duvody.append("jméno přesně (+30)")
elif p_surname and p_surname in e_name:
body += 15; duvody.append("příjmení sedí (+15)")
elif e_name & p_name:
body += 8; duvody.append("částečná shoda jména (+8)")
else:
body -= 45; duvody.append("⚠ jméno NESOUHLASÍ (45)")
# Datum narození (z pole nebo odvozené z RČ)
if e_dob and p_dob:
if e_dob == p_dob:
body += 20; duvody.append("datum narození sedí (+20)")
else:
body -= 35; duvody.append("⚠ datum narození NESEDÍ (35)")
# E-mail odesílatele v kartotéce pacienta
em = (sender_email or "").strip().lower()
if em and any(p.get("idpac") == idpac for p in lookup.by_email.get(em, [])):
body += 30; duvody.append("e-mail odesílatele v kartotéce (+30)")
# Telefon z textu mailu v kartotéce pacienta
ph = _norm_phone(verdict.get("telefon") or "")
if len(ph) >= 9 and any(p.get("idpac") == idpac for p in lookup.by_phone.get(ph, [])):
body += 20; duvody.append("telefon v kartotéce (+20)")
# Požadovaný lék v historii receptů pacienta
try:
requested = [(l.get("nazev") or "").strip() for l in (verdict.get("leky") or [])]
requested = [r for r in requested if r]
if requested and idpac is not None:
drugs = {(h.get("lek") or "").strip()
for h in lookup.prescriptions(idpac) if h.get("lek")}
if any(_drug_matches(req, d) for req in requested for d in drugs):
body += 10; duvody.append("lék v historii receptů (+10)")
except Exception:
pass
return max(0, min(100, body)), duvody
# =========================
# MEDEVIO — ZÁPIS POŽADAVKU
# =========================
# Markery oddělující forward/citaci v těle mailu (Outlook CZ/EN, Gmail > styl).
_FORWARD_MARKERS_RE = re.compile(
r"^-{3,}\s*(original message|forwarded message|původní zpráva|pův\.?\s*zpráva"
r"|weitergeleitete nachricht)",
re.IGNORECASE,
)
def _compress_body(body: str) -> str:
"""Odstraní forwardovanou/citovanou část a smrskne dvojité prázdné řádky."""
lines = (body or "").splitlines()
cut_at = None
for i, line in enumerate(lines):
s = line.strip()
sl = s.lower()
# Oddělovač Outlooku (--- Original Message --- apod.)
if _FORWARD_MARKERS_RE.match(s):
cut_at = i
break
# Citované řádky > (Gmail/Thunderbird)
if s.startswith(">"):
cut_at = i
break
# Outlook CZ: "Od: Jméno <email>" + "Odesláno:" do 5 řádků
if re.match(r"^od:\s*.+@", sl):
lookahead = " ".join(lines[i + 1 : i + 6]).lower()
if "odesláno:" in lookahead or "odeslano:" in lookahead:
cut_at = i
break
# Outlook EN: "From: Name <email>" + "Sent:" do 5 řádků
if re.match(r"^from:\s*.+@", sl):
lookahead = " ".join(lines[i + 1 : i + 6]).lower()
if "sent:" in lookahead:
cut_at = i
break
if cut_at is not None:
lines = lines[:cut_at]
# Odstraň trailing prázdné řádky
while lines and not lines[-1].strip():
lines.pop()
text = "\n".join(lines)
# Dva a více prázdných řádků → jeden prázdný řádek
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _kand_info(p: dict) -> dict:
"""Z Medicus pacienta udělá lehký dict kandidáta pro Telegram dotaz."""
return {
"idpac": p.get("idpac"),
"rc": _norm_rc(p.get("rodcis") or ""),
"jmeno": p.get("jmeno") or "",
"prijmeni": p.get("prijmeni") or "",
"datnar": str(p.get("datnar") or "")[:10],
"poj": p.get("poj") or "",
}
def _format_leky(leky: list) -> str:
"""Formátuje seznam léků pro pole 'Název léků' — čárkami oddělený výčet."""
parts = []
for lek in leky or []:
nazev = (lek.get("nazev") or "").strip()
if not nazev:
continue
pozn = (lek.get("poznamka") or "").strip()
parts.append(f"{nazev} ({pozn})" if pozn else nazev)
return ", ".join(parts)
def _format_poznamka(msg: dict) -> str:
"""Sestaví userNote pro Medevio: hlavička + zkomprimované tělo mailu."""
sender = (msg.get("from") or {}).get("emailAddress", {})
name = sender.get("name", "").strip()
email_addr = sender.get("address", "").strip()
received_raw = msg.get("receivedDateTime") or ""
try:
from dateutil import parser as _dtparser, tz as _tz
dt = _dtparser.isoparse(received_raw).astimezone(_tz.gettz("Europe/Prague"))
header_date = f"{dt.day}.{dt.month}.{dt.year} {dt.strftime('%H:%M')}"
except Exception:
header_date = received_raw
header = f"{header_date} | {name} <{email_addr}>"
body = (msg.get("body") or {}).get("content") or msg.get("bodyPreview") or ""
compressed = _compress_body(body)
return f"{header}\n\n{compressed}"
def _medevio_find_patient(rc_normalized: str) -> str | None:
"""Najde UUID pacienta v Medeviu podle normalizovaného RČ (jen číslice).
Používá MySQL zrcadlo medevio_pacient — patient_id je identické s GraphQL API."""
try:
from Knihovny.mysql_db import connect_mysql
conn = connect_mysql()
conn.ping(reconnect=True)
cur = conn.cursor()
cur.execute(
"SELECT patient_id FROM medevio_pacient "
"WHERE REPLACE(identification_number,'/','') = %s LIMIT 1",
[rc_normalized],
)
row = cur.fetchone()
return row[0] if row else None
except Exception as e:
log(f" [Medevio hledání pacienta selhalo] {type(e).__name__}: {e}")
return None
# =========================
# HLAVNÍ BĚH
# =========================
def main() -> None:
log("\n" + "=" * 70)
log(f"START — schránka={MAILBOX}, DELTA režim (vše po posledním zpracovaném)")
log(f"REŽIM: zakládá požadavky v Medeviu; zpracované maily značí štítkem "
f"'{PROCESSED_CATEGORY}' (a příště přeskakuje)")
# Zajisti kategorii v master-listu schránky (s barvou). Best-effort.
try:
graph_mail.ensure_category(MAILBOX, PROCESSED_CATEGORY)
except Exception as e:
log(f"[POZOR] kategorii '{PROCESSED_CATEGORY}' nelze zajistit "
f"({type(e).__name__}: {e}) — chybí asi Mail.ReadWrite oprávnění")
watermark, last_id = _load_watermark()
if watermark is None:
# první běh — neznáme „poslední zpracovaný"; nastav vodoznak na nejnovější
# mail a od příště zpracovávej jen to, co přijde potom (historie se nedohání).
watermark, seed_id = newest_received(MAILBOX)
_save_watermark(watermark, seed_id)
log(f"První běh — vodoznak nastaven na {watermark}. "
f"Příští běh zpracuje maily přijaté po tomto čase.")
return
msgs = nove_inbox_messages(MAILBOX, watermark)
# `gt` na oříznutý (sekundový) čas vrací i hraniční už zpracovaný mail
# (Graph má sub-sekundovou přesnost) → odfiltruj ho podle ID.
if last_id:
msgs = [m for m in msgs if m.get("id") != last_id]
cap = " (dosažen strop MAX_PER_RUN)" if len(msgs) >= MAX_PER_RUN else ""
log(f"Vodoznak: {watermark} → nových mailů: {len(msgs)}{cap}")
if not msgs:
log("Nic nového — končím.")
return
lookup = MedicusLookup()
log(f"Medicus: kartotéka načtena ({len(lookup.patients)} pacientů, "
f"{len(lookup.by_email)} e-mailů, {len(lookup.by_phone)} telefonů).\n")
requests_found = 0
for i, msg in enumerate(msgs, 1):
sender = (msg.get("from") or {}).get("emailAddress", {})
subj = msg.get("subject") or "(bez předmětu)"
log(f"--- [{i}/{len(msgs)}] {msg.get('receivedDateTime', '')} ---")
log(f" Od: {sender.get('name', '')} <{sender.get('address', '')}>")
log(f" Předmět: {subj}")
# Idempotence: mail už agent jednou zpracoval → přeskoč (žádný duplicitní požadavek).
if PROCESSED_CATEGORY in (msg.get("categories") or []):
log(f" => PŘESKOČENO — již zpracováno (štítek {PROCESSED_CATEGORY})\n")
continue
# Už čeká na potvrzení člověka přes Telegram → znovu se neptej.
if _pending.je_mail_pending(msg["id"]):
log(" => PŘESKOČENO — čeká na odpověď přes Telegram\n")
continue
try:
v = classify(msg)
except Exception as e:
log(f" [AI CHYBA] {type(e).__name__}: {e}\n")
continue
if not v.get("je_zadost_o_recept"):
log(f" => NENÍ žádost o recept — {v.get('duvod', '')}\n")
continue
requests_found += 1
log(f" => ŽÁDOST O RECEPT — {v.get('duvod', '')}")
log(f" Pacient: {v.get('pacient') or '(neuvedeno)'}")
if v.get("rodne_cislo"):
log(f" Rodné číslo: {v['rodne_cislo']}")
if v.get("datum_narozeni"):
log(f" Narozen: {v['datum_narozeni']}")
if v.get("telefon"):
log(f" Telefon: {v['telefon']}")
for lek in v.get("leky") or []:
pozn = f"{lek['poznamka']}" if lek.get("poznamka") else ""
log(f" Lék: {lek.get('nazev', '?')}{pozn}")
if v.get("poznamka"):
log(f" Poznámka: {v['poznamka']}")
# Ověření v Medicusu.
identified_patient = None
match_type, candidates = lookup.match(v, sender.get("address", ""))
if match_type == "NENALEZEN":
log(" Medicus: [NENALEZEN] pacient v kartotéce nedohledán")
elif len(candidates) == 1:
log(f" Medicus: [SHODA {match_type}] {lookup.describe(candidates[0])}")
identified_patient = candidates[0]
else:
log(f" Medicus: [NEJEDNOZNAČNÉ — {match_type}, "
f"{len(candidates)} kandidátů] — rozhoduji podle historie receptů:")
winner, method, detail = lookup.resolve_by_prescriptions(
candidates, v.get("leky") or []
)
for line in detail:
log(f" - {line}")
if winner:
log(f" Medicus: [SHODA {match_type}+{method}] "
f"{lookup.describe(winner)}")
identified_patient = winner
else:
log(" Medicus: [NEROZHODNUTO] historie receptů "
"nejednoznačnost nevyřešila — nutná ruční kontrola")
for p in candidates:
log(f" - {lookup.describe(p)}")
# Skóre jistoty identifikace → rozhodnutí: založit / zeptat se člověka.
if identified_patient:
skore, duvody = skore_jistoty(
v, identified_patient, sender.get("address", ""), lookup
)
else:
skore, duvody = 0, ["pacient nedohledán v kartotéce"]
log(f" Jistota: {skore}/100 — {'; '.join(duvody) or 'bez signálů'}")
leky_str = _format_leky(v.get("leky") or [])
pozn_str = _format_poznamka(msg)
if identified_patient and skore >= SCORE_AUTO:
# Vysoká jistota → založ rovnou.
rc = _norm_rc(identified_patient.get("rodcis") or "")
patient_uuid = _medevio_find_patient(rc)
if not patient_uuid:
log(f" Medevio: [NENÍ V MEDEVIU] RČ {rc} — k ruční kontrole")
try:
graph_mail.add_category(MAILBOX, msg["id"], MANUAL_CATEGORY)
log(f" Mail: [OZNAČEN] {MANUAL_CATEGORY}")
except Exception as e:
log(f" Mail: [POZOR] štítek nenastaven "
f"({type(e).__name__}: {e})")
else:
try:
result = _medevio.zaloz_pozadavek_recept(
patient_uuid, leky_str, pozn_str
)
log(f" Medevio: [ZALOZENO] požadavek "
f"{result['request_id']} [{skore}/100] | léky: {leky_str}")
# Označ mail jako zpracovaný → příště se přeskočí (idempotence).
try:
graph_mail.add_category(MAILBOX, msg["id"], PROCESSED_CATEGORY)
log(f" Mail: [OZNAČEN] štítek {PROCESSED_CATEGORY}")
except Exception as e:
log(f" Mail: [POZOR] štítek nenastaven "
f"({type(e).__name__}: {e}) — riziko duplicity při dalším běhu!")
except Exception as e:
log(f" Medevio: [CHYBA] {type(e).__name__}: {e}")
else:
# Nejistá identifikace → NEZAKLÁDAT, zeptat se člověka přes Telegram.
kandidati = [_kand_info(p) for p in candidates]
_pending.pridej(
email_message_id=msg["id"],
email_subject=subj,
sender=f"{sender.get('name', '')} <{sender.get('address', '')}>",
leky_str=leky_str,
pozn_str=pozn_str,
skore=skore,
duvody=duvody,
kandidati=kandidati,
)
log(f" Rozhodnutí: [DOTAZ] jistota {skore} < {SCORE_AUTO} "
f"— čeká na potvrzení přes Telegram ({len(kandidati)} kandidátů)")
log("")
lookup.close()
_save_watermark(msgs[-1]["receivedDateTime"], msgs[-1].get("id", "")) # posun na nejnovější zpracovaný
log(f"HOTOVO: {len(msgs)} mailů, žádostí o recept: {requests_found}. "
f"Nový vodoznak: {msgs[-1]['receivedDateTime']}")
log(
f"CENA AI: {_cost['calls']} volání, "
f"tokeny input={_cost['input_tokens']} output={_cost['output_tokens']}, "
f"${_cost['usd']:.4f}{_cost['usd'] * USD_TO_CZK:.2f}"
)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
# Video — stahování videí
## stahni_video.py
Stahuje videa z Vimea, YouTube a dalších webů přes **yt-dlp**. Nejlepší dostupná
kvalita, sloučení video+audio do `.mp4`. Soukromá / nedostupná videa sám pozná
a přeskočí (nespadne).
### Závislosti (jednorázově)
```bat
python -m pip install -U yt-dlp static-ffmpeg
```
- **yt-dlp** — vlastní downloader.
- **static-ffmpeg** — dodá `ffmpeg.exe` + `ffprobe.exe` (v systému ffmpeg není).
Skript si přes `static_ffmpeg.add_paths()` cestu nastaví sám; binárky se
stáhnou při prvním běhu do `site-packages\static_ffmpeg\bin\`.
### Použití
```bat
python stahni_video.py URL [URL2 ...]
python stahni_video.py # vezme URL z urls.txt (1 na řádek)
python stahni_video.py --cookies-from-browser firefox URL # video za přihlášením
python stahni_video.py -o D:\nekam URL # jiný výstupní adresář
```
Výchozí výstupní adresář je tento (`Video/`). Soubory: `%(title)s [%(id)s].mp4`.
### Jak pozná soukromé/nedostupné video
yt-dlp vyhodí `DownloadError` s textem chyby. Funkce `klasifikuj_chybu()` hledá
v textu známé fráze (`private video`, `video unavailable`, `removed`,
`members-only`, …) a vrátí český popis → video se přeskočí. Jiné chyby (síť,
chybí ffmpeg) se vypíšou jako `[CHYBA]`, ale běh pokračuje na další URL.
Na konci se vypíše souhrn (staženo / přeskočeno / chyby).
### Poznámky / úskalí
- **Soukromé YouTube video opravdu nejde stáhnout**, pokud k němu přihlášený
účet nemá udělený přístup — to je záměr, skript ho jen přeskočí.
- **Diakritika v názvech**: cesty se zkomolí, když se předávají Windows binárce
přes Bug Bash pipe; v běžné konzoli (cp1250) je vše v pořádku.
- **Vimeo** dává oddělené video/audio HLS streamy → ffmpeg je nutný pro sloučení.
- Při prvním běhu může yt-dlp varovat na chybějící JavaScript runtime (deno);
pro běžná veřejná videa to nevadí.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
stahni_video.py — stahování videí (Vimeo, YouTube, …) přes yt-dlp.
Co umí:
* Automaticky nastaví cestu k ffmpeg (přes balík static-ffmpeg) — netřeba ho
mít v systému.
* Stáhne nejlepší dostupnou kvalitu a sloučí video+audio do .mp4.
* Pokud je video SOUKROMÉ / nedostupné / odstraněné, sám to pozná, vypíše
srozumitelnou hlášku a přeskočí ho (nespadne, jede dál na další URL).
Použití:
python stahni_video.py URL [URL2 ...]
python stahni_video.py # vezme URL z urls.txt (1 na řádek)
python stahni_video.py --cookies-from-browser firefox URL # video za přihlášením
Instalace závislostí (jednorázově):
python -m pip install -U yt-dlp static-ffmpeg
"""
import argparse
import os
import shutil
import sys
from pathlib import Path
# Pojistka: ať výpis nikdy nespadne na znaku, který kódování konzole nezná
# (zachová kódování konzole, jen neznámý znak escapne místo pádu programu).
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(errors="backslashreplace")
except Exception:
pass
SKRIPT_DIR = Path(__file__).resolve().parent
# --- důvody, proč video NEJDE stáhnout (→ přeskočit, ne padat) ----------------
# klíč hledáme (case-insensitive) v textu chyby od yt-dlp
DUVODY_PRESKOCIT = [
("private video", "video je soukromé"),
("video is private", "video je soukromé"),
("this is a private video", "video je soukromé"),
("video unavailable", "video není dostupné"),
("this video is unavailable", "video není dostupné"),
("video has been removed", "video bylo odstraněno"),
("removed by the uploader", "video odstranil autor"),
("no longer available", "video už není dostupné"),
("members-only", "jen pro členy kanálu"),
("available to members", "jen pro členy kanálu"),
("account associated with this video has been terminated",
"účet autora byl zrušen"),
("has been terminated", "účet autora byl zrušen"),
("blocked it on copyright", "blokováno kvůli autorským právům"),
("not available in your country", "nedostupné ve tvé zemi"),
("not available on this app", "nedostupné pro tohoto klienta"),
("sign in to confirm your age", "věkově omezené (nutné přihlášení)"),
("requires payment", "placené video"),
("this live event will begin", "živý přenos zatím nezačal"),
("premieres in", "video bude teprve uvedeno (premiéra)"),
]
def klasifikuj_chybu(msg: str):
"""Vrátí český popis důvodu k přeskočení, nebo None pokud jde o jinou chybu."""
m = msg.lower()
for klic, popis in DUVODY_PRESKOCIT:
if klic in m:
return popis
return None
class _TichyLogger:
"""Potlačí ukecaný výpis yt-dlp; chyby si hlídáme sami přes výjimky."""
def debug(self, msg):
pass
def info(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
pass
def _progress_hook(d):
if d.get("status") == "downloading":
pct = (d.get("_percent_str") or "").strip()
spd = (d.get("_speed_str") or "").strip()
print(f"\r stahuji {pct} {spd} ", end="", flush=True)
elif d.get("status") == "finished":
print(f"\r staženo, zpracovávám… ")
def priprav_ffmpeg():
"""Zajistí ffmpeg/ffprobe a vrátí adresář s binárkami (nebo None)."""
try:
import static_ffmpeg
static_ffmpeg.add_paths() # přidá ffmpeg/ffprobe do PATH (1. běh = stáhne)
except ImportError:
pass
ff = shutil.which("ffmpeg")
if ff:
return os.path.dirname(ff)
print("UPOZORNĚNÍ: ffmpeg nenalezen — sloučení video+audio nemusí fungovat.")
print(" Nainstaluj: python -m pip install -U static-ffmpeg")
return None
def nacti_urls(args_urls):
if args_urls:
return args_urls
soubor = SKRIPT_DIR / "urls.txt"
if soubor.exists():
radky = [r.strip() for r in soubor.read_text(encoding="utf-8").splitlines()]
return [r for r in radky if r and not r.startswith("#")]
return []
def stahni(urls, out_dir: Path, cookies_browser=None):
try:
import yt_dlp
from yt_dlp.utils import DownloadError
except ImportError:
sys.exit("Chybí yt-dlp. Nainstaluj: python -m pip install -U yt-dlp")
ff_dir = priprav_ffmpeg()
out_dir.mkdir(parents=True, exist_ok=True)
ydl_opts = {
"outtmpl": str(out_dir / "%(title)s [%(id)s].%(ext)s"),
"format": "bestvideo*+bestaudio/best",
"merge_output_format": "mp4",
"logger": _TichyLogger(),
"progress_hooks": [_progress_hook],
"noprogress": True, # vlastní progress řešíme hookem
"noplaylist": True,
}
if ff_dir:
ydl_opts["ffmpeg_location"] = ff_dir
if cookies_browser:
ydl_opts["cookiesfrombrowser"] = (cookies_browser,)
stazeno, preskoceno, chyby = 0, [], []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] {url}")
try:
info = ydl.extract_info(url, download=True)
nazev = info.get("title", url) if info else url
print(f" [HOTOVO] {nazev}")
stazeno += 1
except DownloadError as e:
duvod = klasifikuj_chybu(str(e))
if duvod:
print(f" [PRESKOCENO] {duvod}")
preskoceno.append((url, duvod))
else:
strucne = str(e).split("\n")[0]
print(f" [CHYBA] {strucne}")
chyby.append((url, strucne))
except Exception as e: # nečekané — taky nezhasnout celý běh
print(f" [CHYBA] {e}")
chyby.append((url, str(e)))
print("\n=== SOUHRN ===")
print(f" staženo: {stazeno}")
print(f" přeskočeno: {len(preskoceno)}")
for url, duvod in preskoceno:
print(f" - {url} ({duvod})")
if chyby:
print(f" chyby: {len(chyby)}")
for url, msg in chyby:
print(f" - {url} ({msg})")
return stazeno, preskoceno, chyby
def main():
p = argparse.ArgumentParser(
description="Stáhne videa přes yt-dlp; soukromá/nedostupná sám přeskočí.")
p.add_argument("urls", nargs="*", help="URL videí (nebo nech prázdné a použij urls.txt)")
p.add_argument("-o", "--out-dir", default=str(SKRIPT_DIR),
help="výstupní adresář (výchozí: tento adresář)")
p.add_argument("--cookies-from-browser", dest="cookies",
help="prohlížeč pro cookies u videí za přihlášením (firefox/chrome/edge…)")
a = p.parse_args()
urls = nacti_urls(a.urls)
if not urls:
sys.exit("Nezadal jsi žádné URL. Předej je jako argumenty nebo do urls.txt.")
stahni(urls, Path(a.out_dir), cookies_browser=a.cookies)
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
.env
*.log
__pycache__/
_*.html
+90
View File
@@ -0,0 +1,90 @@
# Webináře — hlídač nových webinářů (praktickylekar.online)
## Účel
Jednou denně (8:00, Plánovač úloh) zkontroluje [praktickylekar.online](https://www.praktickylekar.online/),
zda přibyl nový webinář. Když ano → přes **Telegram** se zeptá, jestli má přihlásit
osoby z `config.json` (Michaela + Vladimír Buzalkovi), po potvrzení je přihlásí a
výsledek pošle zpět na Telegram. Po přihlášení chodí potvrzovací e-mail automaticky z webu.
## Soubory
| Soubor | Popis |
|--------|-------|
| `watcher.py` | hlavní skript |
| `config.json` | URL + údaje přihlašovaných osob |
| `state.json` | vytvoří se sám; pamatuje poslední zpracované `idwebinar` |
| `watcher.log` | log běhů |
## Přepínače v `watcher.py` (nahoře)
- `POSILATINFOPOKAZDEKONTROLE``True` = pošle Telegram zprávu po **každé** ranní
kontrole (i když nic nového; vhodné při zaběhávání). `True` je teď nastaveno.
Až bude vše ověřené → přepnout na `False` (ozve se jen při novém webináři).
- `DRY_RUN``True` = nic se reálně neodešle (registrace se jen simuluje), Telegram
dotaz proběhne. `False` = ostrý režim (reálné přihlášení po potvrzení „ano").
- `ASK_TIMEOUT` — kolik sekund ráno čekat na odpověď ano/ne (default 1800 = 30 min).
## CLI
```
python watcher.py # ostrý denní běh
python watcher.py --test # ignoruje state + VŽDY dry-run (otestuje plumbing)
python watcher.py --reset # smaže state.json
```
## Ověřená struktura webu (k 2026-06-17)
1. **Banner** na hlavní stránce: `<a href="/webinar.php?idwebinar=560">` → z něj se čte ID.
2. **Brána** `POST /check2.php` s `zdravotnicky-pracovnik=on` & `laicka-verejnost=on`
→ nastaví cookie `souhlas=1`. **Bez ní se registrační formulář vůbec nezobrazí.**
3. **Registrace** `POST /registrovat4.php`, pole:
- `email` (povinné)
- `clen` = `1` (člen SVL Ano) / `2` (Ne) → Buzalkovi `1`
- `prukaz` = číslo průkazu SVL (povinné když clen=1)
- `clk` = evidenční číslo ČLK, **přesně 10 znaků** (`pattern=.{10,10}`)
- `titul1, jmeno, prijmeni, pracoviste, mesto` — jen pro nečleny (clen=2)
- `souhlas` = `on` (souhlas se zpracováním OÚ, povinné)
- **skrytá** `webid` (= idwebinar) a `cislo` (= `PL` + DDMMRRRR, dle data webináře)
**čtou se živě z formuláře, nehádají se.**
> Pokud provozovatel změní názvy polí / strukturu, skript loguje, co našel
> (`watcher.log`) — podle toho se selektory upraví.
## Nasazení na tower (PRODUKCE) — Unraid, python-runner
Běží na **toweru** (Unraid, 192.168.1.76) v kontejneru **`python-runner`**,
plánováno přes **User Scripts plugin** na **8:00 denně**.
- Soubory: `/mnt/user/Scripts/Webinare/` → v kontejneru `/scripts/Webinare/`
- Telegram: na serveru **není** `Knihovny/` ani `Medevio/.env`, proto je přibalená
kopie `telegram_notify.py` + lokální `/scripts/Webinare/.env`
(jen `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID`, práva 600).
- Wrapper: `/boot/config/plugins/user.scripts/scripts/WebinarWatcher/script`
(`flock` + `docker exec`, log `/mnt/user/Scripts/logs/webinar_watcher.log`).
- Rozvrh: záznam v `schedule.json` (`custom: 0 8 * * *`) + řádek v
`customSchedule.cron``update_cron``/etc/cron.d/root`.
- `state.json` na serveru seedován na `560` (na ten jste registrovaní).
### Nasazení / správa z Windows — `deploy_tower.py`
Heslo NIKDY v souboru, bere se z env `TOWER_PW`:
```bash
TOWER_PW=... python deploy_tower.py recon # zmapuje server (jen čte)
TOWER_PW=... python deploy_tower.py deploy # nahraje soubory (+ seed state.json)
TOWER_PW=... python deploy_tower.py env # naplní serverový .env z Medevio/.env
TOWER_PW=... python deploy_tower.py smoke # test: telegram .env + detekce (neodesílá)
TOWER_PW=... python deploy_tower.py schedule # založí/aktualizuje rozvrh 8:00
TOWER_PW=... python deploy_tower.py prodrun # ruční spuštění ostrého běhu
```
Po změně `watcher.py`/`config.json` lokálně → `deploy` znovu (idempotentní,
`state.json` ani `.env` nepřepisuje).
### Heartbeat → tichý režim
Server běží s `POSILATINFOPOKAZDEKONTROLE=True` (ranní „zkontrolováno"). Až bude
ověřeno, v lokálním `watcher.py` přepnout na `False` a `deploy` znovu.
## Alternativa — Plánovač úloh (Windows), pokud poběží lokálně
```powershell
schtasks /Create /TN "WebinarWatcher" /SC DAILY /ST 08:00 ^
/TR "python \"U:\ordinaceprojekt\Webináře\watcher.py\"" /F
```
## Notifikace
Přes sdílenou knihovnu `Knihovny/telegram_notify.py`
(`posli_telegram`, `zeptej_se_telegram`), bot **@Vlado_Claude_Bot**,
token/chat_id z `Medevio/.env`.
+28
View File
@@ -0,0 +1,28 @@
{
"watch_url": "https://www.praktickylekar.online/",
"base_url": "https://www.praktickylekar.online",
"registrants": [
{
"jmeno": "Michaela",
"prijmeni": "Buzalková",
"titul1": "",
"email": "michaela.buzalkova@buzalka.cz",
"clen": "1",
"prukaz": "761790",
"clk": "5141811171",
"pracoviste": "",
"mesto": ""
},
{
"jmeno": "Vladimír",
"prijmeni": "Buzalka",
"titul1": "",
"email": "vladimir.buzalka@buzalka.cz",
"clen": "1",
"prukaz": "761791",
"clk": "1143687173",
"pracoviste": "",
"mesto": ""
}
]
}
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
deploy_tower.py — nasazení webinar-watcheru na tower (Unraid, python-runner).
Heslo se NIKDY neukládá do souboru — bere se z proměnné prostředí TOWER_PW:
TOWER_PW=... python deploy_tower.py recon
TOWER_PW=... python deploy_tower.py deploy
TOWER_PW=... python deploy_tower.py schedule
TOWER_PW=... python deploy_tower.py smoke # rychlý test (neblokuje na Telegramu)
Vzor převzat z EmailAgent / MedicusFirebird:
- skripty v /mnt/user/Scripts/<Název>/ → v kontejneru /scripts/<Název>/
- spouští se: docker exec python-runner python3 /scripts/Webinare/watcher.py
- plánování přes Unraid User Scripts plugin (wrapper + schedule.json cron)
"""
import os
import sys
import json
import posixpath
import paramiko
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HOST = "192.168.1.76"
USER = "root"
CONTAINER = "python-runner"
LOCAL_DIR = os.path.dirname(os.path.abspath(__file__))
HOST_DIR = "/mnt/user/Scripts/Webinare" # na hostiteli (Unraid)
CONT_DIR = "/scripts/Webinare" # uvnitř kontejneru
PLUGIN_DIR = "/boot/config/plugins/user.scripts"
USERSCRIPTS = PLUGIN_DIR + "/scripts"
SCHEDULE_JSON = PLUGIN_DIR + "/schedule.json"
CUSTOM_CRON = PLUGIN_DIR + "/customSchedule.cron"
US_NAME = "WebinarWatcher"
CRON_EXPR = "0 8 * * *"
# soubory, které kopírujeme na server (telegram_notify.py = přibalená kopie,
# protože /scripts/Knihovny na serveru není)
FILES = ["watcher.py", "telegram_notify.py", "config.json", "requirements.txt", "NOTES.md"]
def connect():
pw = os.environ.get("TOWER_PW")
if not pw:
sys.exit("Chybí TOWER_PW v prostředí.")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=pw, timeout=20, allow_agent=False, look_for_keys=False)
return c
def run(c, cmd, timeout=180):
_in, out, err = c.exec_command(cmd, timeout=timeout)
o = out.read().decode("utf-8", "replace")
e = err.read().decode("utf-8", "replace")
rc = out.channel.recv_exit_status()
return rc, o, e
def show(c, cmd, timeout=180):
rc, o, e = run(c, cmd, timeout)
print(f"$ {cmd}")
body = (o + (("\n[stderr] " + e) if e.strip() else "")).rstrip()
print(body if body else "(prázdné)")
print(f" rc={rc}\n")
return rc, o, e
# ── RECON ────────────────────────────────────────────────────────────────────
def recon(c):
show(c, "hostname; uname -r")
show(c, "docker ps --format '{{.Names}}' | sort")
show(c, "ls -la /mnt/user/Scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls /scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls -la /scripts/Knihovny/telegram_notify.py")
show(c, f"docker exec {CONTAINER} sh -lc 'test -f /scripts/Medevio/.env && grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" /scripts/Medevio/.env || echo NENI_ENV'")
show(c, f"docker exec {CONTAINER} python3 -c \"import requests,bs4;print('deps_ok requests',requests.__version__,'bs4',bs4.__version__)\"")
show(c, f"ls -la {USERSCRIPTS}/ | head -50")
# vzor existujícího wrapperu + rozvrhu (StahovaniFaktur)
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/script 2>/dev/null")
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/schedule.json 2>/dev/null")
show(c, "grep -n 'Scripts' /etc/cron.d/root 2>/dev/null | head")
# ── DEPLOY (kopie souborů) ───────────────────────────────────────────────────
def deploy(c):
run(c, f"mkdir -p {HOST_DIR} /mnt/user/Scripts/logs")
sftp = c.open_sftp()
for f in FILES:
lp = os.path.join(LOCAL_DIR, f)
if not os.path.exists(lp):
print(f" přeskakuji (není lokálně): {f}")
continue
rp = posixpath.join(HOST_DIR, f)
sftp.put(lp, rp)
print(f"{f}{rp}")
# seed state.json jen když na serveru ještě není (ať se nepřemazává běhový stav)
rp_state = posixpath.join(HOST_DIR, "state.json")
rc, _o, _e = run(c, f"test -f {rp_state}")
if rc != 0:
lp_state = os.path.join(LOCAL_DIR, "state.json")
if os.path.exists(lp_state):
sftp.put(lp_state, rp_state)
print(f" ↑ state.json (seed) → {rp_state}")
else:
with sftp.open(rp_state, "w") as fh:
fh.write('{"last_id": null}\n')
print(" ↑ state.json (prázdný)")
else:
print(" state.json na serveru už existuje — neměním.")
sftp.close()
show(c, f"ls -la {HOST_DIR}/")
# ── ENV (naplní /scripts/Webinare/.env Telegram klíči z lokálního Medevio/.env) ─
def env(c):
src = os.path.join(os.path.dirname(LOCAL_DIR), "Medevio", ".env")
if not os.path.exists(src):
sys.exit("Lokální Medevio/.env nenalezen.")
chteji = ("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID")
radky = []
with open(src, encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if "=" in s and not s.startswith("#"):
k = s.split("=", 1)[0].strip()
if k in chteji:
radky.append(s)
keys = [r.split("=", 1)[0] for r in radky]
if not all(k in keys for k in chteji):
sys.exit(f"V Medevio/.env chybí některý z klíčů: {chteji}")
run(c, f"mkdir -p {HOST_DIR}")
sftp = c.open_sftp()
with sftp.open(posixpath.join(HOST_DIR, ".env"), "w") as fh:
fh.write("\n".join(radky) + "\n")
sftp.chmod(posixpath.join(HOST_DIR, ".env"), 0o600)
sftp.close()
print(f" .env zapsán na server ({', '.join(keys)}) — hodnoty se nevypisují.")
show(c, f"docker exec {CONTAINER} sh -lc 'grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" {CONT_DIR}/.env'")
# ── CRON RECON (zjistí, jak User Scripts ukládá rozvrh) ──────────────────────
def cron(c):
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/StahovaniFaktur/")
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/")
show(c, "cat /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/schedule.json 2>/dev/null || echo bez_schedule_json")
show(c, "ls -la /etc/cron.d/")
show(c, "cat /etc/cron.d/root 2>/dev/null")
show(c, "crontab -l 2>/dev/null | tail -40")
# ── CRONSTORE RECON (kam plugin persistuje rozvrh přes reboot) ───────────────
def cronstore(c):
show(c, "ls -la /boot/config/plugins/user.scripts/")
show(c, "find /boot/config/plugins/user.scripts/ -maxdepth 1 -type f -exec ls -la {} +")
show(c, "grep -rsl 'StahovaniFaktur' /boot/config/ 2>/dev/null | grep -v '/scripts/StahovaniFaktur/'")
show(c, "grep -rsn '6,18\\|cron\\|schedule' /boot/config/plugins/user.scripts/ --include='*.json' --include='*.cfg' --include='*.dat' --include='*.php' 2>/dev/null | head -40")
# ── CRONFILES (dump přesného formátu schedule.json + customSchedule.cron) ────
def cronfiles(c):
show(c, "sed -n '185,210p' /boot/config/plugins/user.scripts/schedule.json")
show(c, "head -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "tail -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "cat /boot/config/plugins/user.scripts/customSchedule.cron")
show(c, "ls -la /usr/local/sbin/update_cron /usr/local/emhttp/plugins/user.scripts/startCustom.php 2>&1")
# ── SMOKE TEST (neblokuje na Telegramu) ──────────────────────────────────────
def smoke(c):
# ověří přibalený telegram modul + načtení .env (jen délky, ne hodnoty)
# + detekci webináře na webu. NEodesílá Telegram ani registraci.
py = (
"import sys; sys.path.insert(0,'/scripts/Webinare');"
"import telegram_notify as t;"
"print('telegram .env OK: token_len',len(t._token()),'chat_id_set',bool(t._resolve_chat_id(None)));"
"import json,requests,re;"
"from bs4 import BeautifulSoup;"
"cfg=json.load(open('/scripts/Webinare/config.json',encoding='utf-8'));"
"s=requests.Session(); s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"r=s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"a=BeautifulSoup(r.text,'html.parser').select('a[href*=\\\"webinar.php?idwebinar=\\\"]')[0];"
"print('detekce OK webinar=',re.search(r'idwebinar=(\\\\d+)',a['href']).group(1))"
)
show(c, f"docker exec {CONTAINER} python3 -c \"{py}\"", timeout=90)
# ── SCHEDULE (User Scripts plugin, denně 8:00) ───────────────────────────────
def schedule(c):
d = f"{USERSCRIPTS}/{US_NAME}"
script_path = f"{d}/script"
cron_line = (f"{CRON_EXPR} /usr/local/emhttp/plugins/user.scripts/startCustom.php "
f"{script_path} > /dev/null 2>&1")
# wrapper (styl převzat z StahovaniFaktur: flock + docker exec + log s datem/rc)
wrapper = (
"#!/bin/bash\n"
"# WebinarWatcher - denne 8:00, hlidac webinaru praktickylekar.online. flock proti prekryvu.\n"
"LOG=/mnt/user/Scripts/logs/webinar_watcher.log\n"
"mkdir -p /mnt/user/Scripts/logs\n"
"exec 9>/tmp/webinar_watcher.lock\n"
"flock -n 9 || exit 0\n"
"OUT=$(docker exec -e PYTHONIOENCODING=utf-8 -e TZ=Europe/Prague " + CONTAINER + " python3 " + CONT_DIR + "/watcher.py 2>&1)\n"
"RC=$?\n"
"{ echo \"===== $(date '+%F %T') (rc=$RC) =====\"; echo \"$OUT\"; } >> \"$LOG\"\n"
)
run(c, f"mkdir -p {d}")
sftp = c.open_sftp()
with sftp.open(script_path, "w") as fh:
fh.write(wrapper)
with sftp.open(f"{d}/name", "w") as fh:
fh.write(US_NAME)
with sftp.open(f"{d}/description", "w") as fh:
fh.write("Hlidac webinaru praktickylekar.online, denne 8:00")
# ── schedule.json: přidej/aktualizuj záznam (se zálohou) ──
run(c, f"cp -a {SCHEDULE_JSON} {SCHEDULE_JSON}.bak_webinar")
with sftp.open(SCHEDULE_JSON, "r") as fh:
data = json.loads(fh.read().decode("utf-8"))
data[script_path] = {
"script": script_path,
"frequency": "custom",
"id": "schedule" + US_NAME,
"custom": CRON_EXPR,
}
with sftp.open(SCHEDULE_JSON, "w") as fh:
fh.write(json.dumps(data, indent=2))
# ── customSchedule.cron: přidej řádek (se zálohou), pokud chybí ──
with sftp.open(CUSTOM_CRON, "r") as fh:
cron_txt = fh.read().decode("utf-8")
if script_path not in cron_txt:
run(c, f"cp -a {CUSTOM_CRON} {CUSTOM_CRON}.bak_webinar")
with sftp.open(CUSTOM_CRON, "w") as fh:
fh.write(cron_txt.rstrip() + "\n\n" + cron_line + "\n")
sftp.close()
run(c, f"chmod +x {script_path}")
# ── regeneruj systémový cron + ověř ──
show(c, "/usr/local/sbin/update_cron")
print("── OVĚŘENÍ ──")
show(c, f"ls -la {d}/")
show(c, f"grep -n '{US_NAME}' {CUSTOM_CRON}")
show(c, f"grep -n '{US_NAME}' /etc/cron.d/root")
show(c, f"grep -n '{US_NAME}' {SCHEDULE_JSON}")
# ── PRODRUN (spustí přesně to, co pustí cron — pro ruční test/trigger) ────────
def prodrun(c):
show(c, f"docker exec -e PYTHONIOENCODING=utf-8 {CONTAINER} python3 {CONT_DIR}/watcher.py",
timeout=200)
MODES = {"recon": recon, "deploy": deploy, "env": env, "cron": cron,
"cronstore": cronstore, "cronfiles": cronfiles, "smoke": smoke,
"schedule": schedule, "prodrun": prodrun}
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "recon"
if mode not in MODES:
sys.exit(f"Neznámý režim '{mode}'. Použij: {', '.join(MODES)}")
c = connect()
try:
print(f"=== {mode.upper()} na {USER}@{HOST} ===\n")
MODES[mode](c)
finally:
c.close()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
requests
beautifulsoup4
+3
View File
@@ -0,0 +1,3 @@
{
"last_id": "560"
}
+115
View File
@@ -0,0 +1,115 @@
"""
telegram_notify.py — PŘIBALENÁ kopie pro běh na serveru (python-runner)
=======================================================================
Na toweru není balík `Knihovny/` ani `Medevio/.env`, proto má watcher tuto
soběstačnou kopii. Funkce jsou shodné s `Knihovny/telegram_notify.py`.
Token a chat_id se hledají v `.env` na víc místech (první nalezené vyhrává):
1) `.env` ve stejném adresáři jako tento soubor (server: /scripts/Webinare/.env)
2) `../Medevio/.env` (lokální vývoj)
3) `../../Medevio/.env` (kořen projektu)
TELEGRAM_BOT_TOKEN=123456789:AAE...
TELEGRAM_CHAT_ID=6639316354
"""
import os
import sys
import time
from pathlib import Path
import requests
def _load_env():
here = Path(__file__).resolve().parent
kandidati = [
here / ".env",
here.parent / "Medevio" / ".env",
here.parent.parent / "Medevio" / ".env",
]
for env_path in kandidati:
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
_load_env()
API_BASE = "https://api.telegram.org/bot{token}/{method}"
def _token() -> str:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("Chybí TELEGRAM_BOT_TOKEN (.env)")
return token
def _resolve_chat_id(chat_id):
chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID")
if not chat_id:
raise RuntimeError("Chybí TELEGRAM_CHAT_ID (zadej argumentem nebo v .env)")
return str(chat_id)
def _call(method, *, http_timeout=15, **params):
url = API_BASE.format(token=_token(), method=method)
r = requests.post(url, json=params, timeout=http_timeout)
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram {method} selhal [{r.status_code}]: {data}")
return data["result"]
def posli_telegram(text, *, chat_id=None, parse_mode=None, disable_notification=False):
params = {
"chat_id": _resolve_chat_id(chat_id),
"text": text,
"disable_notification": disable_notification,
}
if parse_mode:
params["parse_mode"] = parse_mode
return _call("sendMessage", **params)
def zeptej_se_telegram(otazka, *, chat_id=None, timeout=300, poll_timeout=30, parse_mode=None):
cid = _resolve_chat_id(chat_id)
existujici = _call("getUpdates", http_timeout=15)
offset = (existujici[-1]["update_id"] + 1) if existujici else 0
posli_telegram(otazka, chat_id=cid, parse_mode=parse_mode)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
zbyva = int(deadline - time.monotonic())
if zbyva <= 0:
break
lp = max(1, min(poll_timeout, zbyva))
updates = _call("getUpdates", http_timeout=lp + 10, offset=offset, timeout=lp)
for u in updates:
offset = u["update_id"] + 1
msg = u.get("message") or {}
if str(msg.get("chat", {}).get("id")) != cid:
continue
text = msg.get("text")
if text:
return text
return None
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
args = sys.argv[1:]
if args and args[0] == "--ask":
print(zeptej_se_telegram(" ".join(args[1:]) or "?", timeout=240) or "(bez odpovědi)")
elif args:
posli_telegram(" ".join(args))
print("Odesláno OK")
else:
print('Použití: python telegram_notify.py "text" | --ask "otázka?"')
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""
watcher.py — Hlídač nových webinářů na praktickylekar.online
============================================================
Co dělá při každém spuštění (cíleno na 1× denně v 8:00 přes Plánovač úloh):
1. Stáhne hlavní stránku a najde banner s nadcházejícím webinářem
(odkaz `webinar.php?idwebinar=<ID>`).
2. Porovná ID s posledním zpracovaným (uloženo ve `state.json`).
3. Pokud je webinář NOVÝ:
a) projde "bránu" (potvrzení zdravotnického odborníka, POST /check2.php) —
teprve potom se na stránce webináře objeví registrační formulář,
b) z formuláře ŽIVĚ přečte skrytá pole `webid` a `cislo`
(cislo = PL + DDMMRRRR, mění se podle data — NIKDY se nehádá),
c) přes Telegram se ZEPTÁ, jestli má osoby z config.json přihlásit,
d) po potvrzení ("ano") odešle registraci za každou osobu,
e) výsledek potvrdí přes Telegram.
4. Pokud nový webinář NENÍ a POSILATINFOPOKAZDEKONTROLE=True, pošle ráno
informaci "zkontrolováno, nic nového".
Po přihlášení chodí potvrzovací e-mail automaticky z webu — e-mail tedy
neřešíme, notifikace jdou jen přes Telegram.
CLI:
python watcher.py # ostrý denní běh
python watcher.py --test # test: ignoruje state, VŽDY dry-run (nic neodešle)
python watcher.py --reset # smaže state.json (zapomene poslední webinář)
"""
import json
import logging
import os
import re
import sys
from pathlib import Path
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
# ── Telegram: lokálně sdílená knihovna z kořene, na serveru přibalená kopie ──
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
try:
# lokálně (Windows): kořen projektu má balík Knihovny + Medevio/.env
from Knihovny.telegram_notify import posli_telegram, zeptej_se_telegram # noqa: E402
except ModuleNotFoundError:
# server (python-runner): Knihovny tu není → přibalená kopie + lokální .env
from telegram_notify import posli_telegram, zeptej_se_telegram # noqa: E402
# ════════════════════════════════════════════════════════════════════════════
# PŘEPÍNAČE
# ════════════════════════════════════════════════════════════════════════════
# True = po KAŽDÉ ranní kontrole pošli na Telegram zprávu "zkontrolováno"
# (i když není nic nového) — užitečné při zaběhávání, ať víš, že to jede.
# False = ozvi se jen když je NOVÝ webinář. (Nastav, až bude vše ověřené.)
POSILATINFOPOKAZDEKONTROLE = True
# True = NIC se reálně neodešle (registrace se jen "nasucho" simuluje a vypíše).
# Telegram dotaz/potvrzení proběhne normálně. Pro bezpečné otestování.
# False = ostrý režim — po potvrzení "ano" na Telegramu se reálně přihlásí.
DRY_RUN = False
# Jak dlouho (s) čekat ráno na odpověď ano/ne na Telegramu, než to vzdá.
ASK_TIMEOUT = 1800 # 30 minut
# ════════════════════════════════════════════════════════════════════════════
HERE = Path(__file__).resolve().parent
CONFIG_PATH = HERE / "config.json"
STATE_PATH = HERE / "state.json"
LOG_PATH = HERE / "watcher.log"
HEADERS = {"User-Agent": "Mozilla/5.0 (webinar-watcher; osobni pouziti)"}
TIMEOUT = 30
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_PATH, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("watcher")
# ── pomocné I/O ──────────────────────────────────────────────────────────────
def load_json(path: Path, default=None):
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def save_json(path: Path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ── krok 1: najdi nadcházející webinář na hlavní stránce ─────────────────────
def find_upcoming_webinar(session, watch_url):
"""Vrátí (id, text_banneru, absolutni_url) nebo None."""
r = session.get(watch_url, headers=HEADERS, timeout=TIMEOUT)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
# Zakomentované bannery jsou HTML komentáře → BeautifulSoup je nebere jako <a>.
odkazy = soup.select('a[href*="webinar.php?idwebinar="]')
if not odkazy:
return None
if len(odkazy) > 1:
log.warning("Na stránce je víc odkazů na webinář (%d), beru první.", len(odkazy))
a = odkazy[0]
href = a.get("href", "")
m = re.search(r"idwebinar=(\d+)", href)
if not m:
return None
wid = m.group(1)
text = " ".join(a.get_text().split())
return wid, text, urljoin(watch_url, href)
# ── krok 2: projdi bránu (potvrzení zdravotnického odborníka) ────────────────
def projdi_branu(session, base_url, reg_url):
"""
POST /check2.php se dvěma checkboxy → nastaví cookie souhlas=1, díky které
se na stránce webináře objeví registrační formulář. Vrací True/False.
"""
data = {"zdravotnicky-pracovnik": "on", "laicka-verejnost": "on"}
r = session.post(
urljoin(base_url, "/check2.php"),
data=data,
headers={**HEADERS, "Referer": reg_url},
timeout=TIMEOUT,
)
r.raise_for_status()
ok = session.cookies.get("souhlas") == "1"
log.info("Brána check2.php: %s (cookies=%s)", "OK" if ok else "?", session.cookies.get_dict())
return ok
# ── krok 3: přečti registrační formulář a jeho skrytá pole ───────────────────
def parse_registration_form(session, reg_url):
"""
Načte stránku webináře (už po projití brány) a vrátí
(action_url, hidden_fields_dict). Skrytá pole (webid, cislo) se ČTOU,
nehádají. Hledá konkrétně formulář mířící na 'registrovat'.
"""
r = session.get(reg_url, headers={**HEADERS, "Referer": reg_url}, timeout=TIMEOUT)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
form = None
for f in soup.find_all("form"):
if "registrovat" in (f.get("action") or "").lower():
form = f
break
if form is None:
raise RuntimeError(
"Registrační formulář nenalezen (brána neprošla, nebo se změnila struktura webu)."
)
action = urljoin(reg_url, form.get("action", ""))
hidden = {}
for inp in form.find_all("input", attrs={"type": "hidden"}):
name = inp.get("name")
if name:
hidden[name] = inp.get("value", "")
return action, hidden
# ── krok 4: sestav a odešli registraci ───────────────────────────────────────
def build_payload(person, hidden):
payload = {
"email": person["email"],
"clen": person.get("clen", "1"),
"prukaz": person.get("prukaz", ""),
"clk": person.get("clk", ""),
"titul1": person.get("titul1", ""),
"jmeno": person.get("jmeno", ""),
"prijmeni": person.get("prijmeni", ""),
"pracoviste": person.get("pracoviste", ""),
"mesto": person.get("mesto", ""),
"souhlas": "on", # souhlas se zpracováním osobních údajů (nutné pro odeslání)
}
payload.update(hidden) # webid, cislo, … (živě z formuláře)
return payload
def register_person(session, action_url, reg_url, person, hidden):
"""Vrátí (ok: bool, info: str)."""
payload = build_payload(person, hidden)
cele_jmeno = f"{person['jmeno']} {person['prijmeni']}"
if DRY_RUN:
log.info("DRY_RUN NEodesílám. Payload pro %s: %s", cele_jmeno, payload)
return True, "DRY-RUN (nic neodesláno)"
r = session.post(
action_url,
data=payload,
headers={**HEADERS, "Referer": reg_url},
timeout=TIMEOUT,
)
r.raise_for_status()
txt_low = r.text.lower()
ok = any(k in txt_low for k in ("úspěš", "uspes", "zaregistr", "děkuj", "dekuj"))
# snippet pro případnou ruční kontrolu
snippet = " ".join(BeautifulSoup(r.text, "html.parser").get_text().split())[:200]
return ok, f"HTTP {r.status_code} | {snippet}"
# ── Telegram dotaz ano/ne ────────────────────────────────────────────────────
def je_souhlas(odpoved: str | None) -> bool:
if not odpoved:
return False
return odpoved.strip().lower() in ("ano", "a", "yes", "y", "jo", "ok")
# ── hlavní logika ────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
test_mode = "--test" in args
if "--reset" in args:
if STATE_PATH.exists():
STATE_PATH.unlink()
log.info("state.json smazán.")
return
cfg = load_json(CONFIG_PATH)
if not cfg:
log.error("Chybí config.json"); sys.exit(1)
dry = DRY_RUN or test_mode # --test vždy jen nasucho
globals()["DRY_RUN"] = dry
state = load_json(STATE_PATH, default={"last_id": None})
session = requests.Session()
session.get(cfg["watch_url"], headers=HEADERS, timeout=TIMEOUT) # init PHPSESSID
found = find_upcoming_webinar(session, cfg["watch_url"])
if not found:
log.info("Žádný nadcházející webinář na stránce nenalezen.")
if POSILATINFOPOKAZDEKONTROLE:
posli_telegram("🔎 Webináře: zkontrolováno, žádný nadcházející webinář na stránce.")
return
wid, banner, reg_url = found
banner_clean = banner.replace("\n", " ")
log.info("Nadcházející webinář: id=%s | %s | %s", wid, banner_clean, reg_url)
je_novy = test_mode or state.get("last_id") != wid
if not je_novy:
log.info("Beze změny (id=%s už zpracováno).", wid)
if POSILATINFOPOKAZDEKONTROLE:
posli_telegram(
f"✅ Webináře: zkontrolováno v 8:00, nic nového.\n"
f"Aktuální (už řešený): {banner_clean}"
)
return
# ── NOVÝ webinář ─────────────────────────────────────────────────────────
log.info("NOVÝ webinář! id=%s", wid)
try:
if not projdi_branu(session, cfg["base_url"], reg_url):
log.warning("Bránu se nepodařilo projít zkouším formulář i tak.")
action_url, hidden = parse_registration_form(session, reg_url)
except Exception as e:
log.exception("Chyba při čtení formuláře.")
posli_telegram(f"⚠️ Webináře: nový webinář {banner_clean}, ale NEPODAŘILO se přečíst formulář:\n{e}")
return
log.info("Formulář action=%s, skrytá pole=%s", action_url, hidden)
jmena = ", ".join(f"{p['jmeno']} {p['prijmeni']}" for p in cfg["registrants"])
# ── Telegram: zeptej se na souhlas s přihlášením ─────────────────────────
otazka = (
f"🆕 NOVÝ webinář na praktickylekar.online!\n\n"
f"{banner_clean}\n{reg_url}\n"
f"(webid={hidden.get('webid','?')}, cislo={hidden.get('cislo','?')})\n\n"
f"Mám přihlásit: {jmena}?\n"
f"{'[TEST nic se reálně neodešle] ' if dry else ''}"
f"Odpověz ANO / NE."
)
odpoved = zeptej_se_telegram(otazka, timeout=ASK_TIMEOUT)
if odpoved is None:
log.info("Bez odpovědi (timeout) state NEukládám, zeptám se zítra znovu.")
return
if not je_souhlas(odpoved):
log.info("Odpověď '%s' → NEpřihlašuji.", odpoved)
state["last_id"] = wid # rozhodnuto (ne) → příště se neptat znovu
save_json(STATE_PATH, state)
posli_telegram(f"👌 OK, webinář {banner_clean} nechávám bez přihlášení.")
return
# ── přihlášení ───────────────────────────────────────────────────────────
vysledky = []
for p in cfg["registrants"]:
cele = f"{p['jmeno']} {p['prijmeni']}"
try:
ok, info = register_person(session, action_url, reg_url, p, hidden)
vysledky.append(f"{'' if ok else ''} {cele}: {'OK' if ok else 'NEJISTÉ zkontroluj'}")
log.info("Registrace %s: %s | %s", cele, ok, info)
except Exception as e:
vysledky.append(f"{cele}: CHYBA {e}")
log.exception("Chyba při registraci %s", p["email"])
# state ukládáme až po pokusu o registraci
state["last_id"] = wid
save_json(STATE_PATH, state)
shrnuti = (
f"{'🧪 TEST (nic neodesláno) ' if dry else '📨 '}Přihlášení na webinář:\n"
f"{banner_clean}\n\n" + "\n".join(vysledky) +
("\n\n(Po reálném přihlášení dorazí potvrzovací e-mail z webu.)" if not dry else "")
)
posli_telegram(shrnuti)
log.info("Hotovo (last_id=%s).", wid)
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
# WireGuard road-warrior na MikroTiku (router-hosted)
Nastaveno 2026-06-18 podle runbooku `wireguard-mikrotik-runbook.md`.
## Router
- **MikrotikFirewall** (hEX, RouterOS 7.19.6), LAN IP `192.168.1.2`, SSH port **22**.
- WAN = `pppoe-out1`, veřejná IP `78.80.38.51` (PPPoE, bere se jako statická).
## DŮLEŽITÉ — proč port 51821, ne 51820
Na routeru **už běží jiná WireGuard VPN na Unraidu** (`192.168.1.76`): NAT rule
„WireGuard to Unraid" DST-NATuje příchozí UDP **51820** na Unraid. Proto tahle
nová, **na routeru hostovaná** VPN běží na **UDP 51821** (51820 by se nikdy
nedostalo k routeru). Existující Unraid VPN ani tunel `10.253.0.0/24` nejsou dotčené.
## Parametry této VPN
| | |
|---|---|
| WG rozhraní | `wg-vpn`, listen-port **51821** |
| Server public key | `CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=` |
| Tunelová síť | `10.10.10.0/24`, router `10.10.10.1` |
| Klienti | `10.10.10.2` (client2), `.3` (client3), `.4` (client4) |
| Endpoint | `78.80.38.51:51821` |
| Split tunel | AllowedIPs = `192.168.1.0/24` (jen LAN přes VPN) |
| DNS klientů | `192.168.1.2` (router) |
## Přidané firewall pravidla (jen accept, nic nemazáno/nepřeřazeno)
- input: accept udp dst-port 51821 in-interface=pppoe-out1 „WireGuard in (router)"
- input: accept in-interface=wg-vpn „WG -> router (DNS/ping)" (DNS a ping na router z tunelu)
- forward: accept in-interface=wg-vpn „WG -> LAN"
Všechna vložena PŘED příslušné `drop` v daném chainu.
NAT hairpin **nepřidán** — LAN hosti mají router jako default gw, návratová cesta funguje.
## Skripty
- `rosrun.py` — spouští RouterOS příkazy přes SSH. Creds z env: `ROS_HOST/ROS_PORT/ROS_USER/ROS_PASS`.
Pozn.: v Git Bash nutné `MSYS_NO_PATHCONV=1` a příkazy přes stdin (ne `--cmd`, mangluje `/...`).
- `gen_clients.py` — generuje klíče (wg.exe) + `.conf` + QR PNG do `wg-clients/`, a `_peers_add.rsc`.
## Klientské konfigurace
`wg-clients/clientN.conf` (import na notebook) + `wg-clients/clientN.png` (QR pro mobilní app).
**Obsahují privátní klíče** — po rozdání na zařízení smaž, ať neleží zbytečně.
## Test (jen zvenku, ne z LAN!)
Telefon na mobilních datech → naskenuj QR → ověř `ping 192.168.1.2`. Z LAN to
handshake neudělá (accept je vázán na in-interface=pppoe-out1, hairpin pro 51821 není).
## Rollback
```
/interface wireguard peers remove [find interface=wg-vpn]
/ip firewall filter remove [find comment="WG -> LAN"]
/ip firewall filter remove [find comment="WG -> router (DNS/ping)"]
/ip firewall filter remove [find comment="WireGuard in (router)"]
/ip address remove [find interface=wg-vpn]
/interface wireguard remove [find name=wg-vpn]
```
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Generate WireGuard road-warrior client configs + QR PNGs, and emit RouterOS peer-add commands."""
import subprocess, pathlib, qrcode
WG = r"C:\Program Files\WireGuard\wg"
SERVER_PUB = "CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo="
ENDPOINT = "78.80.38.51:51821"
LAN = "192.168.1.0/24" # split tunnel -> only LAN goes through VPN
DNS = "192.168.1.2" # router LAN IP
CLIENTS = [2, 3, 4]
outdir = pathlib.Path(__file__).resolve().parent / "wg-clients"
outdir.mkdir(exist_ok=True)
def wg(*args, inp=None):
return subprocess.run([WG, *args], input=inp, capture_output=True,
text=True, check=True).stdout.strip()
peer_cmds = []
for i in CLIENTS:
name = f"client{i}"
priv = wg("genkey")
pub = wg("pubkey", inp=priv)
psk = wg("genpsk")
conf = f"""[Interface]
PrivateKey = {priv}
Address = 10.10.10.{i}/32
DNS = {DNS}
[Peer]
PublicKey = {SERVER_PUB}
PresharedKey = {psk}
AllowedIPs = {LAN}
Endpoint = {ENDPOINT}
PersistentKeepalive = 25
"""
(outdir / f"{name}.conf").write_text(conf, encoding="utf-8")
img = qrcode.make(conf)
img.save(outdir / f"{name}.png")
peer_cmds.append(
f'/interface wireguard peers add interface=wg-vpn '
f'public-key="{pub}" preshared-key="{psk}" '
f'allowed-address=10.10.10.{i}/32 comment="{name}"'
)
print(f"[ok] {name}: pub={pub} -> {name}.conf, {name}.png")
(outdir / "_peers_add.rsc").write_text("\n".join(peer_cmds) + "\n", encoding="utf-8")
print("\n--- RouterOS peer-add commands written to wg-clients/_peers_add.rsc ---")
for c in peer_cmds:
print(c)
+8
View File
@@ -0,0 +1,8 @@
# ROLLBACK — obnova Unraid WireGuard objektů na routeru MikrotikFirewall
# Odstraněno 2026-06-18 na žádost uživatele. Spusť tyto příkazy pro obnovu.
/ip firewall nat add chain=dstnat action=dst-nat to-addresses=192.168.1.76 to-ports=51820 protocol=udp in-interface=pppoe-out1 dst-port=51820 comment="WireGuard to Unraid"
/ip firewall filter add chain=input action=accept protocol=udp in-interface=pppoe-out1 dst-port=51820 comment="Allow WireGuard"
/ip firewall filter add chain=forward action=accept src-address=10.253.0.0/24 comment="Allow VPN to LAN"
/ip firewall filter add chain=forward action=accept dst-address=10.253.0.0/24 comment="Allow LAN to VPN"
/ip route add dst-address=10.253.0.0/24 gateway=192.168.1.76 comment="Route to WireGuard VPN via Unraid"
# Pozn.: po obnově zkontroluj pořadí filter pravidel (accept musí být PŘED drop).
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Run RouterOS commands over SSH. Creds from env: ROS_HOST, ROS_PORT, ROS_USER, ROS_PASS.
Commands: one per line on stdin, or via --cmd. Prints output per command."""
import os, sys, paramiko
host = os.environ["ROS_HOST"]
port = int(os.environ.get("ROS_PORT", "22"))
user = os.environ["ROS_USER"]
pw = os.environ["ROS_PASS"]
cmds = []
if "--cmd" in sys.argv:
cmds = [sys.argv[sys.argv.index("--cmd") + 1]]
else:
cmds = [l.rstrip("\n") for l in sys.stdin if l.strip()]
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(host, port=port, username=user, password=pw,
look_for_keys=False, allow_agent=False, timeout=20)
for c in cmds:
print(f"\n===== CMD: {c}")
stdin, stdout, stderr = cli.exec_command(c, timeout=30)
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
sys.stdout.write(out)
if err.strip():
sys.stdout.write("--- stderr ---\n" + err)
cli.close()
+3
View File
@@ -0,0 +1,3 @@
/interface wireguard peers add interface=wg-vpn public-key="nToZ1GzONgfW1ve3O1WeEpGbgzUMhDVKE7qrD/Jc23c=" preshared-key="Y6eHm6MbLa+tyleSgwbPc8oJqLZkXZkMEUJZDU7f5kg=" allowed-address=10.10.10.2/32 comment="client2"
/interface wireguard peers add interface=wg-vpn public-key="tqA98HvVupGGYpR1PUe7/j9DO8MtaNP3Fh5tkpqgqD0=" preshared-key="94TmjBE+mTZi3KDy/tWefq/wXPpvmBtjPlX/LZnAKbE=" allowed-address=10.10.10.3/32 comment="client3"
/interface wireguard peers add interface=wg-vpn public-key="j/3kzNQ6vmUL4xFmqq5PL6Qf1xVWPzVWEXoOkBIDxFk=" preshared-key="pHR1441168wSrjlLZ2E44J4WrHpLRuWdjfsNHk23CQ8=" allowed-address=10.10.10.4/32 comment="client4"
+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = YPvh0rKU+xi82eQftBucCnuQzZNqk9jOHLwfEH0wsGk=
Address = 10.10.10.2/32
DNS = 192.168.1.2
[Peer]
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
PresharedKey = Y6eHm6MbLa+tyleSgwbPc8oJqLZkXZkMEUJZDU7f5kg=
AllowedIPs = 192.168.1.0/24
Endpoint = 78.80.38.51:51821
PersistentKeepalive = 25
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = 8JFWJp/zvoRYl7w2Jon0Xv+9YidiguiC26qGbr4ozlg=
Address = 10.10.10.3/32
DNS = 192.168.1.2
[Peer]
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
PresharedKey = 94TmjBE+mTZi3KDy/tWefq/wXPpvmBtjPlX/LZnAKbE=
AllowedIPs = 192.168.1.0/24
Endpoint = 78.80.38.51:51821
PersistentKeepalive = 25
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+11
View File
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = oLcUtFkDW/e0/xmDgdBMlWIpGGL+eOvMxgnyXxtd5Ww=
Address = 10.10.10.4/32
DNS = 192.168.1.2
[Peer]
PublicKey = CGGFHYR83W8IuTB46cJ49IuL/tL3w4yu3o0hQh0Cxwo=
PresharedKey = pHR1441168wSrjlLZ2E44J4WrHpLRuWdjfsNHk23CQ8=
AllowedIPs = 192.168.1.0/24
Endpoint = 78.80.38.51:51821
PersistentKeepalive = 25
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+40
View File
@@ -0,0 +1,40 @@
{
"mcpServers": {
"medicus-firebird": {
"command": "python",
"args": [
"U:\\ordinaceprojekt\\mcp_firebird.py"
]
},
"medevio-api": {
"command": "python",
"args": [
"U:\\ordinaceprojekt\\mcp_medevio.py"
]
},
"medevio-mysql": {
"command": "python",
"args": [
"U:\\ordinaceprojekt\\mcp_medevio_mysql.py"
]
},
"insurance": {
"command": "python",
"args": [
"U:\\ordinaceprojekt\\mcp_insurance.py"
]
},
"janssen-mongo": {
"command": "python",
"args": [
"U:\\PythonProject\\Janssen\\mcp_mongo.py"
]
},
"janssen-postgres": {
"command": "python",
"args": [
"U:\\PythonProject\\Janssen\\mcp_postgres.py"
]
}
}
}
+148 -18
View File
@@ -12,20 +12,23 @@ from typing import Optional
from mcp.server.fastmcp import FastMCP
sys.path.insert(0, str(Path(__file__).resolve().parent))
from Knihovny.medicus_db import get_medicus_connection
from Knihovny.medicus_db import get_medicus_connection_reconnecting
# Všechny logy MUSÍ jít na stderr — stdout je rezervován pro JSON-RPC
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
# Připojení k Firebirdu
# Připojení k Firebirdu — lazy + auto-reconnect.
# Spojení se naváže až při prvním dotazu a samo se obnoví, když umře
# (uspání notebooku, denní gbak restore na serveru). Proto nepadáme při startu,
# i kdyby DB zrovna nebyla dostupná.
conn = get_medicus_connection_reconnecting()
try:
conn = get_medicus_connection()
conn.cursor().execute("SELECT 1 FROM RDB$DATABASE")
log("✓ Připojeno k Firebirdu (Medicus)")
except Exception as e:
log(f"✗ Chyba připojení k Firebirdu: {e}")
sys.exit(1)
log(f"⚠ Firebird zatím nedostupný, zkusím se připojit při prvním dotazu: {e}")
def rows_to_json(rows, description):
@@ -207,30 +210,112 @@ def get_patient(idpac: int) -> dict:
raise
def _strip_diacritics(s: str) -> str:
"""Bez diakritiky, velkými písmeny, sjednocené mezery."""
import re
import unicodedata
s = unicodedata.normalize('NFKD', s or '')
s = ''.join(c for c in s if not unicodedata.combining(c))
return re.sub(r'\s+', ' ', s).strip().upper()
@mcp.tool()
def search_patients(query: str) -> list:
"""Vyhledá pacienty podle příjmení, jména nebo rodného čísla (částečná shoda).
Vrátí max. 50 výsledků: idpac, jmeno, prijmeni, rc, pojistovna.
def search_patients(query: str, datum_narozeni: Optional[str] = None) -> list:
"""Vyhledá pacienty podle jména/příjmení (bez ohledu na diakritiku a pořadí slov,
např. "Mateju Petr" najde "Petr Matějů") nebo rodného čísla (částečná shoda, jen číslice).
datum_narozeni: volitelný filtr YYYY-MM-DD.
Vrátí max. 50 výsledků: idpac, jmeno, prijmeni, rc, datnar, pojistovna, vyrazen.
"""
try:
import datetime
import re
cur = conn.cursor()
q = query.strip().upper()
cur.execute("""
SELECT FIRST 50 IDPAC, JMENO, PRIJMENI, RODCIS, POJ
SELECT IDPAC, JMENO, PRIJMENI, RODCIS, DATNAR, POJ, VYRAZEN
FROM KAR
WHERE UPPER(PRIJMENI) LIKE ? OR UPPER(JMENO) LIKE ? OR RODCIS LIKE ?
ORDER BY PRIJMENI, JMENO
""", [f'%{q}%', f'%{q}%', f'%{q}%'])
rows = cur.fetchall()
return [
{'idpac': r[0], 'jmeno': r[1], 'prijmeni': r[2], 'rc': r[3], 'pojistovna': r[4]}
for r in rows
]
WHERE PRIJMENI IS NOT NULL AND PRIJMENI <> ''
""")
q_digits = re.sub(r'\D', '', query)
q_tokens = _strip_diacritics(query).split()
results = []
for r in cur.fetchall():
idpac, jmeno, prijmeni, rc, datnar, poj, vyrazen = r
datnar_iso = datnar.isoformat() if isinstance(datnar, datetime.date) else datnar
if datum_narozeni and str(datnar_iso or '')[:10] != datum_narozeni:
continue
if q_digits and len(q_digits) >= 4:
if q_digits not in (rc or ''):
continue
elif q_tokens:
name_norm = _strip_diacritics(f"{jmeno or ''} {prijmeni or ''}")
if not all(t in name_norm for t in q_tokens):
continue
elif not datum_narozeni:
continue # prázdný dotaz bez filtru data — nevracet celou kartotéku
results.append({
'idpac': idpac, 'jmeno': jmeno, 'prijmeni': prijmeni, 'rc': rc,
'datnar': datnar_iso, 'pojistovna': poj, 'vyrazen': vyrazen == 'A',
})
results.sort(key=lambda p: (p['prijmeni'] or '', p['jmeno'] or ''))
return results[:50]
except Exception:
log(f"search_patients chyba: {traceback.format_exc()}")
raise
@mcp.tool()
def search_patient_by_contact(kontakt: str) -> list:
"""Vyhledá pacienty podle kontaktu (e-mail nebo telefon) v tabulce KARKONTAKT.
Částečná shoda bez ohledu na velikost písmen; u telefonů se porovnávají jen
číslice (ignoruje mezery a +420). Vrátí max. 50 výsledků: pacient (idpac,
jmeno, prijmeni, rc, datnar, pojistovna, vyrazen) + kontakt (typ, popis, vztah).
"""
try:
import datetime
import re
cur = conn.cursor()
cur.execute("""
SELECT kk.IDPAC, kk.KONTAKT, kk.TYP, kk.POPIS, kk.VZTAH,
k.JMENO, k.PRIJMENI, k.RODCIS, k.DATNAR, k.POJ, k.VYRAZEN
FROM KARKONTAKT kk
JOIN KAR k ON k.IDPAC = kk.IDPAC
WHERE kk.KONTAKT IS NOT NULL AND kk.KONTAKT <> ''
""")
q = kontakt.strip().lower()
q_digits = re.sub(r'\D', '', kontakt)
if q_digits.startswith('420'):
q_digits = q_digits[3:]
results = []
for r in cur.fetchall():
(idpac, kont, typ, popis, vztah,
jmeno, prijmeni, rc, datnar, poj, vyrazen) = r
kont = (kont or '').strip()
hit = q and q in kont.lower()
if not hit and len(q_digits) >= 6:
kont_digits = re.sub(r'\D', '', kont)
if kont_digits.startswith('420'):
kont_digits = kont_digits[3:]
hit = q_digits in kont_digits
if not hit:
continue
results.append({
'idpac': idpac, 'jmeno': jmeno, 'prijmeni': prijmeni, 'rc': rc,
'datnar': datnar.isoformat() if isinstance(datnar, datetime.date) else datnar,
'pojistovna': poj, 'vyrazen': vyrazen == 'A',
'kontakt': kont, 'typ': typ, 'popis': popis or '', 'vztah': vztah or '',
})
return results[:50]
except Exception:
log(f"search_patient_by_contact chyba: {traceback.format_exc()}")
raise
@mcp.tool()
def get_patient_timeline(idpac: int, datum_od: Optional[str] = None, datum_do: Optional[str] = None) -> dict:
"""Chronologický přehled všech záznamů pacienta z DOCLIST.
@@ -377,6 +462,51 @@ def get_table_info(table_name: str) -> dict:
raise
@mcp.tool()
def get_columns_overview(table_name: str, sample_rows: int = 1000) -> dict:
"""Přehled obsahu sloupců tabulky pro pochopení sémantiky (např. co znamená
KARKONTAKT.TYP=3 nebo RECEPT.STORNO='T'). Ze vzorku posledních N řádků
(výchozí 1000) vrátí pro každý sloupec: počet vyplněných, počet distinct
hodnot a top 5 nejčastějších hodnot s četností. Hodnoty zkráceny na 80 znaků.
"""
try:
from collections import Counter
cur = conn.cursor()
cur.execute(f'SELECT FIRST {int(sample_rows)} * FROM {table_name.upper()}')
rows = rows_to_json(cur.fetchall(), cur.description or [])
if not rows:
return {'tabulka': table_name.upper(), 'sample': 0, 'sloupce': {}}
overview = {}
for col in rows[0].keys():
values = [r[col] for r in rows if r[col] is not None and r[col] != '']
counter = Counter(
str(v)[:80] for v in values
)
overview[col] = {
'vyplneno': len(values),
'distinct': len(counter),
'top': [
{'hodnota': v, 'pocet': n} for v, n in counter.most_common(5)
],
}
result = {
'tabulka': table_name.upper(),
'sample': len(rows),
'sloupce': overview,
}
if table_name.upper() in LARGE_TABLES:
result['warning'] = (
f'Tabulka {table_name.upper()} je velká — přehled je jen '
f'ze vzorku prvních {len(rows)} řádků.'
)
return result
except Exception:
log(f"get_columns_overview chyba: {traceback.format_exc()}")
raise
@mcp.tool()
def safe_query(sql: str, params: Optional[list] = None) -> dict:
"""Bezpečný SELECT s ochranou před timeoutem na velkých tabulkách.
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP server pro pojišťovny (insurance) — používá oficiální MCP SDK (FastMCP)
Spustit: python mcp_insurance.py
Dotazy na VZP B2B portál pomocí klientského certifikátu (mTLS).
Vychází z knihovny Knihovny/vzpb2b_client.py a skriptů v Insurance/.
Nástroje:
- registrovani_lekari(rodne_cislo, k_datu) — kdo jsou registrující lékaři
pacienta (praktik 001, gynekolog 002, stomatolog 014, ...) ke dni k_datu.
- stav_pojisteni(rodne_cislo, k_datu, prijmeni) — jestli je pacient ke dni
k_datu platně pojištěný a u které pojišťovny.
"""
import re
import sys
import traceback
from pathlib import Path
from datetime import date
from typing import Optional
from mcp.server.fastmcp import FastMCP
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from Knihovny.vzpb2b_client import VZPB2BClient
# Všechny logy MUSÍ jít na stderr — stdout je rezervován pro JSON-RPC
def log(msg: str):
print(msg, file=sys.stderr, flush=True)
# ── Certifikát ──────────────────────────────────────────────────────────────
PFX_PATH = PROJECT_ROOT / "Insurance" / "Certificates" / "picka.pfx"
PFX_PASS = "Vlado7309208104+"
if not PFX_PATH.exists():
log(f"Chyba: certifikát nenalezen: {PFX_PATH}")
sys.exit(1)
try:
vzp = VZPB2BClient("prod", str(PFX_PATH), PFX_PASS)
log("VZP B2B klient inicializován (prod)")
except Exception as e:
log(f"Chyba inicializace VZP B2B klienta: {e}")
sys.exit(1)
def _norm_rc(rodne_cislo: str) -> str:
"""Rodné číslo bez lomítka a nečíselných znaků."""
return re.sub(r"\D", "", rodne_cislo or "")
# MCP server
mcp = FastMCP("insurance")
@mcp.tool()
def registrovani_lekari(rodne_cislo: str, k_datu: Optional[str] = None) -> dict:
"""Zjisti registrující lékaře pacienta u VZP ke dni k_datu.
Dotáže se VZP B2B služby RegistracePojistencePZSB2B a vrátí, kdo je
registrující praktický lékař (odbornost 001), gynekolog (002),
stomatolog (014) a případně další, pokud je VZP eviduje.
Args:
rodne_cislo: Rodné číslo pacienta (lomítko nevadí).
k_datu: Datum ve formátu YYYY-MM-DD. Pokud chybí, použije se dnešek.
Returns:
dict s rodným číslem, datem dotazu, počtem nalezených registrací
a seznamem lékařů (každý: kód a název odbornosti, IČZ, IČP, jméno
lékaře, název ZZZ, pojišťovna, data registrace/zahájení/ukončení).
"""
try:
rc = _norm_rc(rodne_cislo)
if not rc:
return {"error": "Neplatné rodné číslo."}
if k_datu:
k_datu = k_datu.strip()
else:
k_datu = date.today().isoformat()
xml = vzp.registrace_lekare(rc=rc, k_datu=k_datu, odbornosti=None)
zaznamy = vzp.parse_registrace_lekare(xml)
return {
"rodne_cislo": rc,
"k_datu": k_datu,
"pocet": len(zaznamy),
"lekari": zaznamy,
}
except Exception:
log(f"registrovani_lekari chyba: {traceback.format_exc()}")
raise
# Význam pole "stav" v odpovědi stavPojisteniB2B
_STAV_POPIS = {
"1": "pojištěn u uvedené pojišťovny",
"4": "cizinec (smluvní/EU pojištění) — považováno za pojištěného",
}
_STAV_POJISTEN = ("1", "4") # tyto kódy znamenají platné pojištění
@mcp.tool()
def stav_pojisteni(rodne_cislo: str, k_datu: Optional[str] = None,
prijmeni: Optional[str] = None) -> dict:
"""Zjisti, jestli je pacient ke dni k_datu platně pojištěný a u které pojišťovny.
Dotáže se VZP B2B služby stavPojisteniB2B. VZP centrálně eviduje pojištěnce
všech pojišťoven, takže odpověď vrátí i pojišťovnu jinou než VZP.
Args:
rodne_cislo: Rodné číslo pacienta (lomítko nevadí).
k_datu: Datum ve formátu YYYY-MM-DD. Pokud chybí, použije se dnešek.
prijmeni: Příjmení (volitelné) — VZP umožňuje křížovou kontrolu se jménem.
Returns:
dict s rodným číslem, datem dotazu, příznakem pojisteny (True/False),
kódem a popisem stavu, kódem zpracování požadavku a údaji o pojišťovně
(kód, název, kód pojistného vztahu).
"""
try:
rc = _norm_rc(rodne_cislo)
if not rc:
return {"error": "Neplatné rodné číslo."}
k_datu = k_datu.strip() if k_datu else date.today().isoformat()
prijmeni = prijmeni.strip() if prijmeni else None
xml = vzp.stav_pojisteni(rc=rc, k_datu=k_datu, prijmeni=prijmeni)
parsed = vzp.parse_stav_pojisteni(xml)
stav = parsed.get("stav")
pojisteny = stav in _STAV_POJISTEN
return {
"rodne_cislo": rc,
"k_datu": k_datu,
"pojisteny": pojisteny,
"stav": stav,
"stav_popis": _STAV_POPIS.get(stav, "nepojištěn u této pojišťovny / nenalezen"),
"stav_vyrizeni": parsed.get("stavVyrizeni"),
"kod_pojistovny": parsed.get("kodPojistovny"),
"nazev_pojistovny": parsed.get("nazevPojistovny"),
"pojisteni_kod": parsed.get("pojisteniKod"),
}
except Exception:
log(f"stav_pojisteni chyba: {traceback.format_exc()}")
raise
if __name__ == "__main__":
log("MCP Insurance server spuštěn (FastMCP)")
mcp.run()

Some files were not shown because too many files have changed in this diff Show More