Compare commits
24 Commits
a7f33afb66
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e981659621 | |||
| e5315b821e | |||
| 19036b58cc | |||
| 0beaffec45 | |||
| 26e44fc721 | |||
| dc07e19179 | |||
| 45c32a37c4 | |||
| 39d33b76f3 | |||
| 6e4305e182 | |||
| 9edfddae95 | |||
| 9b6f89f437 | |||
| 672ee26357 | |||
| e23d61de84 | |||
| 79216dfbdb | |||
| 8142de5216 | |||
| 2bdac59676 | |||
| 9133fe9497 | |||
| 2346ad7739 | |||
| ca39622ddd | |||
| bed5576efa | |||
| 51ee67c7f3 | |||
| f595e60d40 | |||
| a3b1e58a71 | |||
| 2028532eff |
@@ -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.
@@ -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) |
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# stažený obsah a inventura — do gitu nepatří
|
||||
stazeno/
|
||||
euni_kurzy.json
|
||||
+120
@@ -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 1–9
|
||||
(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
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)} RČ")
|
||||
|
||||
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
@@ -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í.
|
||||
Binary file not shown.
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
ANTHROPIC_API_KEY=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
|
||||
|
||||
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 0–2.3, v ml/min 0–140 — 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,
|
||||
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
{"ts": 1780923974.6234145, "level": "INFO", "msg": "central_logging v1.0 inicializováno (app=medevio_scans_extract, keep_file=False, gateway=http://192.168.1.76:8770)", "logger": "root", "func": "setup_logging", "line": 292}
|
||||
{"ts": 1780923974.6244173, "level": "INFO", "msg": "Start zpracování | cíl=U:\\Dropbox\\Ordinace\\Dokumentace_ke_zpracování\\Ricoh Fi-8040\\KeZpracování", "logger": "root", "func": "<module>", "line": 1401}
|
||||
{"ts": 1780923981.7546782, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780923987.195985, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780923993.7317147, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924001.9359598, "level": "INFO", "msg": "pikepdf C++ to Python logger bridge initialized", "logger": "pikepdf._core", "func": "<module>", "line": 13}
|
||||
{"ts": 1780924002.0304623, "level": "INFO", "msg": "Uložen dokument: 0356030983 2026-05-22 Pelcová, Eliška [LZ praktický lékař] [výpis z dokumentace, vertigo, vertebrogenní sy šíjní, migréna, atop. ekzém].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924003.0304694, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924009.3654826, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924012.0510306, "level": "INFO", "msg": "Uložen dokument: 0356030983 2026-04-07 Pelcová, Eliška [LZ neurologie] [Vertebrogenní sy šíjní blokádový s blokem C obl. hlavových kloubů, ko za3m].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924016.012636, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924023.6001682, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924026.192985, "level": "INFO", "msg": "Uložen dokument: 0356030983 2026-04-01 Pelcová, Eliška [LZ ORL] [houpavé vertigo s minim klin projevem, odesíl. ad neurologie].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924030.0740972, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924038.5451534, "level": "INFO", "msg": "Uložen dokument: 0356030983 2024-11-25 Pelcová, Eliška [LZ oční] [zrn. vidění OP od 16.11, difuz. skotomy OP, OCT - incip. drobný edém pod NSR, Nevanac gtt, ko za2m].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924039.2481399, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924045.081287, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924053.1318147, "level": "INFO", "msg": "Uložen dokument: 0356030983 2022-05-11 Pelcová, Eliška [Výpis ze zdravotní dokumentace] [Eutrof. 58.7kg165cm, polyvalentní alergie, migrény, atop. ekzém, funk. blokáda C páteře].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924059.2452586, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924065.691312, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924074.5240433, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924080.717276, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924081.8739562, "level": "INFO", "msg": "Uložen dokument: 366103079 2026-06-01 Čížkovská, Jaroslava [Laboratoř] [dg. Z000 - Kreatinin 107 (↑), eGFR CHRIG3b, Kyselina močová 439 (↑), Na 146 (↑), Cl 109 (↑), MCHC 319 (↓)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924089.7100272, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924091.6546876, "level": "INFO", "msg": "Uložen dokument: 390523036 2026-05-28 Procházka, Josef [Laboratoř] [dg. N309 - S_Kreatinin 109↑, CKD-EPI CHRIG3b, S_Bilirubin 26.81↑, Leuko 10.4↑, Neutrofily 0.715↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924095.8849175, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924101.1883714, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924103.9648852, "level": "INFO", "msg": "Uložen dokument: 400912170 2026-06-02 Klimek, Štěpán [Laboratoř] [dg. Z000 - Urea 11.60↑, Krea 120↑, CHRIG3b, P_Glukóza 6.4↑, HbA1c 49↑, CRP 7.1↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924105.2750325, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924113.5984268, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924119.8388405, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924128.7810435, "level": "INFO", "msg": "Uložen dokument: 405712023 2024-02-06 Pilná, Marta [LZ kardiologie] [kontrola, drobný prolaps předního mitrálního cípu, aort. stenóza incip., EF 70%, ko za2r].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924129.0471346, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924135.2038016, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924141.9878566, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924143.0382407, "level": "INFO", "msg": "Uložen dokument: 405712023 2024-05-10 Pilná, Marta [RTG páteře a kolena] [Kompres. trauma L1, spondylolisteza L5, gonartroza l.st., kalcifikace menisků].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924147.3552465, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924154.3399203, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924155.8800087, "level": "INFO", "msg": "Uložen dokument: 405712023 2026-01-26 Pilná, Marta [LZ ortopedie] [Masivní degener. změny LS páteře, bursitis troch. l.sin, taping, Depo-Medrol].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924159.4943922, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924164.437885, "level": "INFO", "msg": "Uložen dokument: 405712023 2026-03-03 Pilná, Marta [LZ ortopedie] [kontrola, bursitida troch. l. sin degenerativní, LS páteř, bez obtíží].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924167.3913348, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924173.9319766, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924180.7692726, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924181.5743449, "level": "INFO", "msg": "Uložen dokument: 405712023 2019-06-27 Pilná, Marta [CT hrudníku] [Subpleur. interstic. pruhovité změny S3,S4,S5 vpravo - nejspíše postiradiační, ojedinělý drobný nodul - benigní etiol. Kalcifikace P mamy-stac.].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924186.3322473, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924192.3916535, "level": "INFO", "msg": "Uložen dokument: 405712023 2019-12-11 Pilná, Marta [LZ neurologie] [Lehká paresa pl. brachialis vpravo po luxaci ram. kloubu 23.8., v postupné regresi].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924192.5247924, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924197.7118068, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924204.411034, "level": "INFO", "msg": "Uložen dokument: 405712023 2020-01-20 Pilná, Marta [LZ rehabilitace] [kontrola, leze pl. brachialis dolního typu vpravo, zlepšení motoriky prstů].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924205.0449533, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924212.6433263, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924219.4689233, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924219.4929156, "level": "INFO", "msg": "Uložen dokument: 405712023 2011-02-15 Pilná, Marta [PZ gynekologie] [09–15FEB2011 nezhoubný novotvar vaječníku, LAVH + AE bilat.].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924228.9622266, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924233.8773634, "level": "INFO", "msg": "Uložen dokument: 405712023 2020-01-31 Pilná, Marta [LZ ortopedie] [5M po luxaci ramene, část. paréza pl. brachialis, motorika téměř bez deficitu].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924236.1093464, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924241.9058967, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924248.3274968, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924249.3914232, "level": "INFO", "msg": "Uložen dokument: 405712023 2023-10-26 Pilná, Marta [LZ oční] [VPMD exud. OP, 6. aplikace Beovu do OP, regrese ablace RPE, ko 18.1.2024].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924253.8121676, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924260.6631553, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924263.1219037, "level": "INFO", "msg": "Uložen dokument: 405712023 2023-11-27 Pilná, Marta [EKG] [Bez čerstvých lož. a ischem. změn, norm. repolarizace, bez závažných poruch rytmu].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924267.349394, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924274.210948, "level": "INFO", "msg": "Uložen dokument: 425926081 2026-05-19 Kořínková, Marie [screeningová mamografie] [BI-RADS kat.1, normální nález, typ žlázy BI-RADS A tukový].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924276.7476652, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924283.4529767, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924288.8798294, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924293.6393077, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924293.7892914, "level": "INFO", "msg": "Uložen dokument: 435317067 2026-05-27 Vaněčková, Lenka [Laboratoř] [dg. J069 - Bordetella pertussis IgA 19.8 pozit., Chlamydie pneum. IgA 2.3 pozit., Mycoplasma IgG 1.2].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924299.5956917, "level": "INFO", "msg": "Uložen dokument: 435720013 2026-04-30 Lišková, Jaroslava [Laboratoř] [Kultivace moče negativní].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780924301.2142262, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924306.38782, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924314.7800474, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780924321.5435286, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925927.8907452, "level": "INFO", "msg": "Uložen dokument: 455925093 2026-06-03 Fialová, Růžena [Laboratoř] [dg. Z000, CKD-EPI CHRIG2].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780925935.508606, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925938.4496458, "level": "INFO", "msg": "Uložen dokument: 465704175 2026-05-20 Císařová, Irena [Laboratoř] [dg. I10 - CKD-EPI CHRIG2, Cholesterol 5.47↑, Glukóza 6.1↑, HbA1c 44↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780925943.547366, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925949.5014415, "level": "INFO", "msg": "Uložen dokument: 470612076 2026-05-27 Kokta, Jan [Laboratoř] [dg. I10 - TSH 10.163 (↑), U_Nitrity (↑), U_Leukocyty sed. 26 (↑), U_Bakterie četné].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780925951.353846, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925957.6533313, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925964.3846073, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925965.5630343, "level": "INFO", "msg": "Uložen dokument: 471129130 2026-05-29 Lacina, Petr [Laboratoř] [dg. E119; CKD CHRIG3b; Leuko 10.3↑, Glukóza 6.0↑, HbA1c 49↑, Trombo 134↓, PDW 17.6↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780925970.3455184, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925976.108011, "level": "INFO", "msg": "Uložen dokument: 471129130 2026-06-01 Lacina, Petr [Laboratoř] [dg. E119 - U_pH 5.0, U_Krystaly oxalátu četně].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780925976.7610128, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925982.4779007, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925989.3210292, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780925994.400777, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926014.4221537, "level": "INFO", "msg": "Uložen dokument: 480529219 2026-06-04 Nytra, Vlastimil [LZ urologie] [PIRADS 4 k fúzní Bx prostaty, PSA 062026 8.611, ko 23JUL2026].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926022.9944525, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926023.3339224, "level": "INFO", "msg": "Uložen dokument: 480603717 2026-06-04 Nedúchal, Vladimír [DXA] [BMD L páteře, obou krčků v mezích normy dle WHO].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926029.2288241, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926036.6308203, "level": "INFO", "msg": "Uložen dokument: 505516240 2026-05-28 Michková, Miroslava [Laboratoř] [dg. Z000 - CKD-EPI 1.12 CHRIG2, U_Nitrity+, U_Leukocyty+, U_Krev+, U_Ery sed. 7].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926037.4200857, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926043.4803932, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926049.477445, "level": "INFO", "msg": "Uložen dokument: 6053100801 2026-06-02 Martínková, Hana [RTG rukou] [Rhizartroza vlevo max. II.st, radiální sublux. CMC kl.palce L 3.5mm, volární sublux. CMC kl.palce P 4.3mm].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926052.9086592, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926060.6927774, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926068.8378222, "level": "INFO", "msg": "Uložen dokument: 6110241324 2026-06-03 Pažitný, Josef [Laboratoř] [dg. I10 - S_Urea 8.44↑, S_Kyselina močová 510↑, S_AST 1.08↑, CHRIG3b, Troponin I 3507↑, CK 9.74↑, Myoglobin 582↑, NT-proBNP 391↑, Leu 10.2↑, MCHC 362↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926068.8608148, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926076.16632, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926082.9533389, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926084.3047585, "level": "INFO", "msg": "Uložen dokument: 6162102023 2026-06-01 Vandirkova, Tetjana [Laboratoř] [dg. Z000 - CKD G2, Chol 6.10↑, LDL 4.41↑, Non-HDL 4.9↑, ALP 2.43↑, Glukóza 5.9↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926088.446592, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926094.6209826, "level": "INFO", "msg": "Uložen dokument: 6162102023 2018-03-14 Vandirkova, Tetjana [LZ revmatologie] [Monoartritida 2. MCP kloubu pravé ruky při počínající osteoartróze a chronickém přetížení].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926096.4444125, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926102.7892857, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926111.2473402, "level": "INFO", "msg": "Uložen dokument: 6162102023 2018-08-20 Vandirkova, Tetjana [EKG] [předoper, sinusový rytmus 60min, intermed poloha, fyziologický záznam].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926114.5491564, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926120.6951268, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926131.0870254, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926136.9856477, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926138.2900062, "level": "INFO", "msg": "Uložen dokument: 6312280623 2026-06-03 Holík, Milan [Laboratoř] [dg. E789, Urea 8.51↑, Krea 119↑, CHRIG3a, ALP 0.49↓, HDL 0.99↓, TG 2.76↑, Glc 6.2↑].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926145.0746915, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926147.402122, "level": "INFO", "msg": "Uložen dokument: 6405250808 2026-05-29 Švéda, Jan [Laboratoř] [dg. Z000, CKD-EPI 1.25 mls CHRIG2, HDL 0.94 (↓), P_Glukóza 5.9 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926150.642289, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926157.6604178, "level": "INFO", "msg": "Uložen dokument: 6758120446 2026-06-01 Bečicová, Markéta [Laboratoř] [dg. N309 - kultivace moči kontaminace, směs mikroflóry 10E3 CFUml].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926158.9396725, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926164.4499965, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926172.0751853, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926177.2917879, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926188.0577612, "level": "INFO", "msg": "Uložen dokument: 6758120446 2026-06-01 Bečicová, Markéta [Laboratoř] [dg. K449, Cholesterol 5.10 (↑), LDL 3.47 (↑), Sat.transferinu 15.52% (↓)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926195.667484, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926201.473123, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926222.626016, "level": "INFO", "msg": "Uložen dokument: 6853222079 2021-05-05 Milatová, Martina [EKG] [sinusový rytmus 82min, PQ 0.195, QRS 0.097, horiz. osa, fyziologická křivka].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926230.1025188, "level": "INFO", "msg": "Uložen dokument: 7007270402 2026-06-02 Svozil, Petr [Laboratoř] [dg. Z000, Cholesterol 6.16 (↑), LDL 3.86 (↑), TG 2.30 (↑), Non-HDL 4.4 (↑), P_Glukóza 6.7 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926232.9701731, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926239.5678477, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926246.5669348, "level": "INFO", "msg": "Uložen dokument: 7253282355 2026-06-04 Balousová, Lenka [Laboratoř] [dg. E119, U_Kreatinin 14.44 (↑), U_Ketolátky 1 (↑), U_Krev 1 (↑), U_Erytrocyty sed. 21 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926249.6075084, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926256.6262944, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926263.8793747, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926264.5884688, "level": "INFO", "msg": "Uložen dokument: 7304300047 2026-06-02 Ilem, Václav [Laboratoř] [dg. Z000, GGT 2.02 (↑), HDL 0.93 (↓), TG 3.13 (↑), P_Glukóza 12.6 (↑), HbA1c 76 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926270.359039, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926276.4874163, "level": "INFO", "msg": "Uložen dokument: 7401102830 2026-06-04 Šolc, Michal [Laboratoř] [dg. E789, Kreatinin 110 (↑), CKD-EPI 1.16 mls CHRIG2, TG 2.50 (↑), P_Glukóza 6.0 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926279.0414207, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926285.2367373, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926292.2815356, "level": "INFO", "msg": "Uložen dokument: 7404134804 2026-06-03 Vinduška, Milan [Laboratoř] [dg. Z000, S_AST 0.20 (↓), Cholesterol 5.37 (↑), LDL 3.94 (↑), Non-HDL 4.1 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926294.154831, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926299.4029555, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926305.1279402, "level": "INFO", "msg": "Uložen dokument: 7603311892 2026-06-03 Brendl, David [Laboratoř] [dg. J069, CKD-EPI 1.46 mls CHRIG2, ALP 1.80 (↑), FW 51 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926308.735119, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926315.385951, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926323.7067006, "level": "INFO", "msg": "Uložen dokument: 7856230448 2026-05-29 Kulhánková, Eliška [Laboratoř] [dg. Z123, S_Urea 8.02 (↑), S_Kyselina močová 427 (↑), S_ALP 1.88 (↑), S_HDL 1.07 (↓), S_TSH 10.915 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926326.307719, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926340.4101443, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926340.411144, "level": "WARNING", "msg": "Generování variant názvů selhalo: Expecting value: line 1 column 1 (char 0)", "logger": "root", "func": "generate_name_variants", "line": 1088}
|
||||
{"ts": 1780926347.3057375, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926348.062776, "level": "INFO", "msg": "Uložen dokument: 7857103232 2026-06-02 Dubová, Zita [Laboratoř] [dg. Z000, Chol 6.72 (↑), LDL 4.09 (↑), Non-HDL 4.4 (↑), TIBC 73.1 (↑), Ferritin 8 (↓), U_Hlen přítomen].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926353.3833504, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926358.7494354, "level": "INFO", "msg": "Uložen dokument: 8004110081 2016-12-02 Čuda, Petr [Plíce ZP] [Bez infiltrátů a ložiskových změn, bránice hladké, úhly ostré, srdce nezvětšené].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926359.4631596, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926364.6298096, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926369.6537035, "level": "INFO", "msg": "Uložen dokument: 8004110081 2016-08-10 Čuda, Petr [RTG plic] [přiměřený nález na nitrohrudních orgánech].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926372.5317085, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926378.7702882, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926385.126594, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926390.3291972, "level": "ERROR", "msg": "Chyba při zpracování souboru 8004110081 Čuda, Petr split_056.pdf: [WinError 32] The process cannot access the file because it is being used by another process: 'U:\\\\Dropbox\\\\Ordinace\\\\Dokumentace_ke_zpracování\\\\Ricoh Fi-8040\\\\Zpracováno\\\\.pikepdf.8004110081 2016-08-15 Čuda, Petr [Výpis ze zdravotní dokumentace] [proctocolitis 1996, TK 13080, flegmona kořene jazyka s abscesem 032016].pdfprvjffn3' -> 'U:\\\\Dropbox\\\\Ordinace\\\\Dokumentace_ke_zpracování\\\\Ricoh Fi-8040\\\\Zpracováno\\\\8004110081 2016-08-15 Čuda, Petr [Výpis ze zdravotní dokumentace] [proctocolitis 1996, TK 13080, flegmona kořene jazyka s abscesem 032016].pdf'", "logger": "root", "func": "process_folder", "line": 1383, "exc": "Traceback (most recent call last):\n File \"U:\\ordinaceprojekt\\Medevio\\60 ScansProcessing\\Extract_pacient_info_v1.0.py\", line 1380, in process_folder\n _present_file(analyzed)\n File \"U:\\ordinaceprojekt\\Medevio\\60 ScansProcessing\\Extract_pacient_info_v1.0.py\", line 1332, in _present_file\n set_single_page_view(dest)\n File \"U:\\ordinaceprojekt\\Medevio\\60 ScansProcessing\\Extract_pacient_info_v1.0.py\", line 599, in set_single_page_view\n pdf.save()\n File \"U:\\ordinaceprojekt\\.venv\\Lib\\site-packages\\pikepdf\\_methods.py\", line 438, in save\n with ExitStack() as stack:\n ^^^^^^^^^^^\n File \"C:\\Python\\Lib\\contextlib.py\", line 610, in __exit__\n raise exc_details[1]\n File \"C:\\Python\\Lib\\contextlib.py\", line 595, in __exit__\n if cb(*exc_details):\n ^^^^^^^^^^^^^^^^\n File \"C:\\Python\\Lib\\contextlib.py\", line 144, in __exit__\n next(self.gen)\n File \"U:\\ordinaceprojekt\\.venv\\Lib\\site-packages\\pikepdf\\_io.py\", line 89, in atomic_overwrite\n Path(tf.name).replace(filename)\n File \"C:\\Python\\Lib\\pathlib.py\", line 1376, in replace\n os.replace(self, target)\nPermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'U:\\\\Dropbox\\\\Ordinace\\\\Dokumentace_ke_zpracování\\\\Ricoh Fi-8040\\\\Zpracováno\\\\.pikepdf.8004110081 2016-08-15 Čuda, Petr [Výpis ze zdravotní dokumentace] [proctocolitis 1996, TK 13080, flegmona kořene jazyka s abscesem 032016].pdfprvjffn3' -> 'U:\\\\Dropbox\\\\Ordinace\\\\Dokumentace_ke_zpracování\\\\Ricoh Fi-8040\\\\Zpracováno\\\\8004110081 2016-08-15 Čuda, Petr [Výpis ze zdravotní dokumentace] [proctocolitis 1996, TK 13080, flegmona kořene jazyka s abscesem 032016].pdf'"}
|
||||
{"ts": 1780926393.346066, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926399.4237778, "level": "INFO", "msg": "Uložen dokument: 8004110081 2016-03-08 Čuda, Petr [LZ ORL] [akutní, flegmona kořene jazyka se susp.cystou(absces), hospitalizace].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926399.8179574, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926405.947691, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926410.4677253, "level": "INFO", "msg": "Uložen dokument: 8004110081 2016-03-07 Čuda, Petr [LZ ORL] [stp tonsilitidem, lipoma epiglottidis, zítra incize útvaru na ling. ploše epiglottis].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926414.078505, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926419.0118794, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926423.8065896, "level": "INFO", "msg": "Uložen dokument: 8004110081 2016-03-11 Čuda, Petr [PZ ORL] [08–11MAR2016 flegmona kořene jazyka, incip. absces, konzervativní léčba ATB].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926427.0688245, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926433.2393854, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926440.9683163, "level": "INFO", "msg": "Uložen dokument: 8004110081 2019-05-21 Čuda, Petr [EKG] [Lehká sinus. bradykadie, bez lož. změn, převodní int. v normě, vagotonický typ křivky].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926442.5157003, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926452.0158753, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926452.0179145, "level": "WARNING", "msg": "Generování variant názvů selhalo: Expecting value: line 1 column 1 (char 0)", "logger": "root", "func": "generate_name_variants", "line": 1088}
|
||||
{"ts": 1780926458.835755, "level": "INFO", "msg": "Uložen dokument: 8056010149 2026-06-01 Rejfířová, Sylvie [Laboratoř] [dg. Z000, CKD-EPI 1.27 mls CHRIG2, AST 0.25 (↑), U_Hlen přítomen].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926461.2790043, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926467.2717607, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926473.6899211, "level": "INFO", "msg": "Uložen dokument: 8157220159 2026-06-02 Vrňáková, Lucie [Laboratoř] [dg. Z000, ALP 2.31 (↑), HDL 1.05 (↓), U_Leukocyty 2 (↑), U_Leukocyty sed. 48 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926474.573292, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926481.0812337, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926486.4079983, "level": "INFO", "msg": "Uložen dokument: 8157220159 2026-06-05 Vrňáková, Lucie [sono břicha, sono ŠŽ] [atrofie laloků ŠŽ, uzlinovité útvary kraniálně, ko za2m].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926487.876963, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926493.6019359, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926500.475515, "level": "INFO", "msg": "Uložen dokument: 8910193336 2026-06-04 Štoček, Martin [Laboratoř] [dg. Z000, Cholesterol 5.27 (↑), LDL 3.55 (↑), Non-HDL 4.0 (↑), P_Glukóza 6.3 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926507.762823, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926517.1923342, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926517.194311, "level": "WARNING", "msg": "Generování variant názvů selhalo: Expecting value: line 1 column 1 (char 0)", "logger": "root", "func": "generate_name_variants", "line": 1088}
|
||||
{"ts": 1780926524.1983213, "level": "INFO", "msg": "Uložen dokument: 9062110431 2026-06-03 Chriti Vinš, Jeanette [Laboratoř] [dg. Z000, S_AST 0.13 (↓)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926525.6639242, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926533.4785914, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926533.4795933, "level": "WARNING", "msg": "Generování variant názvů selhalo: Expecting value: line 1 column 1 (char 0)", "logger": "root", "func": "generate_name_variants", "line": 1088}
|
||||
{"ts": 1780926539.042901, "level": "INFO", "msg": "Uložen dokument: 530414098 2026-05-21 Šlik, Petr [Laboratoř] [dg. E789, CKD-EPI CHRIG2].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926542.7802804, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926550.0056698, "level": "INFO", "msg": "HTTP Request: POST https://api.anthropic.com/v1/messages \"HTTP/1.1 200 OK\"", "logger": "httpx", "func": "_send_single_request", "line": 1025}
|
||||
{"ts": 1780926557.5353189, "level": "INFO", "msg": "Uložen dokument: 505426209 2026-05-21 Šliková Součková, Taťana [Laboratoř] [dg. Z000, CKD-EPI 1.26 mls CHRIG2, HDL 0.98 (↓), U_pH 6.5 (↑), U_Leukocyty 3 (↑), U_Krev 2 (↑), U_Erytrocyty 15 (↑)].pdf", "logger": "root", "func": "_present_file", "line": 1335}
|
||||
{"ts": 1780926557.5393188, "level": "INFO", "msg": "Dávka dokončena | zpracováno souborů=63", "logger": "root", "func": "process_folder", "line": 1386}
|
||||
{"ts": 1780926557.5403252, "level": "INFO", "msg": "Konec | cena_USD=2.7410 | doba_s=2582", "logger": "root", "func": "<module>", "line": 1421}
|
||||
@@ -1930,5 +1930,217 @@
|
||||
{
|
||||
"original": "9062110431 2026-06-03 Chriti Vinš, Jeanette [Laboratoř] [dg. Z000, S_AST <0.13 (↓)].pdf",
|
||||
"corrected": "9062110431 2026-06-03 Chriti Vinš, Jeanette [Laboratoř] [dg. Z000, S_AST 0.13 (↓)].pdf"
|
||||
},
|
||||
{
|
||||
"original": "410413024 2026-06-05 Stehno, Oldřich [LZ léčba bolesti] [VAS LS, Coxartroza l.dx pokročilá, TENS L,LS,SIK, RTG LSp a pánve].pdf",
|
||||
"corrected": "410413024 2026-06-05 Stehno, Oldřich [LZ rehabilitace] [VAS LS, Coxartroza l.dx pokročilá, TENS L,LS,SIK, RTG LSp a pánve].pdf"
|
||||
},
|
||||
{
|
||||
"original": "7755035376 2022-11-22 Yates, Hana [LZ cévní] [křečové žíly LDK, CEAP 2, doporučena ligace/koagulace a miniflebectomie].pdf",
|
||||
"corrected": "7755035376 2022-11-22 Yates, Hana [LZ cévní] [křečové žíly LDK, CEAP 2, doporučena ligacekoagulace a miniflebectomie].pdf"
|
||||
},
|
||||
{
|
||||
"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.2–5.6, hodnoty 118–132/54–67 mmHg].pdf",
|
||||
"corrected": "400828108 2026-06-09 Šebek, Josef [domácí měření TK] [záznamy 5.2–5.6, hodnoty 118–13254–67 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.5–9.6, hodnoty TK 100–116/65–79 mmHg, TF 67–92].pdf",
|
||||
"corrected": "7606050518 2026-06-09 Novotný, Pavel [domácí měření TK] [záznamy 28.5–9.6, hodnoty TK 100–11665–79 mmHg, TF 67–92].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á] [21APR2026–19MAY2026 gonartroza st.p.TEP dx., zlepšení, edukace provedena].pdf",
|
||||
"corrected": "5606051143 2026-05-19 Zána, Jan [PZ lázně] [21APR2026–19MAY2026 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á] [26APR2026–24MAY2026, st.p. fract. femoris+humeri l.dx., vertebrogenní sy, DM2, polyneuropatie DKK].pdf",
|
||||
"corrected": "425915482 2026-05-24 Lebedová, Zdenka [PZ lázně] [26APR2026–24MAY2026, 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] [27APR–04MAY2026, Prestance 5/5mg ráno, Agen 100mg večer].pdf",
|
||||
"corrected": "425915482 2026-05-04 Lebedová, Zdenka [domácí měření TK] [27APR–04MAY2026, 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] [18MAY–15JUN2026, průměr 13980, hypertenze 11d, zvýšený TK 8d].pdf",
|
||||
"corrected": "891209 2026-06-15 [Holter TK] [18MAY–15JUN2026, 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"
|
||||
}
|
||||
]
|
||||
@@ -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í CHRIG1–CHRIG5.
|
||||
- **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, 60–89 → CHRIG2, 45–59 → CHRIG3a, 30–44 → CHRIG3b, 15–29 → CHRIG4, < 15 → CHRIG5.
|
||||
- Prahové hodnoty pro orientaci při jednotce ml/s: ≥ 1.50 → G1, 1.00–1.49 → G2, 0.75–0.99 → G3a, 0.50–0.74 → G3b, 0.25–0.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.2–2.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 5–140 (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, 60–89 → CHRIG2, 45–59 → CHRIG3a, 30–44 → CHRIG3b, 15–29 → CHRIG4, < 15 → CHRIG5.
|
||||
- Prahové hodnoty pro orientaci přímo při jednotce ml/s: ≥ 1.50 → G1, 1.00–1.49 → **G2**, 0.75–0.99 → G3a, 0.50–0.74 → G3b, 0.25–0.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]`
|
||||
|
||||
@@ -226,6 +226,7 @@ def setup_logging(
|
||||
gateway: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
env: Optional[str] = None,
|
||||
quiet_loggers: Optional[List[str]] = None,
|
||||
fmt: str = "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
datefmt: str = "%Y-%m-%d %H:%M:%S",
|
||||
spool_dir: Optional[str] = None,
|
||||
@@ -245,7 +246,13 @@ def setup_logging(
|
||||
Returns:
|
||||
nakonfigurovaný root logger (lze i logging.getLogger()).
|
||||
"""
|
||||
lvl_name = (level or DEFAULT_LEVEL).upper()
|
||||
# Konfiguraci čteme z os.environ AŽ TADY (call-time), ne při importu modulu.
|
||||
# Důvod: skripty často načítají vlastní .env (do os.environ) až po importu
|
||||
# této knihovny — kdybychom četli při importu, token/gateway bychom minuli.
|
||||
gw = gateway or os.environ.get("CENTRAL_LOG_GATEWAY", "http://192.168.1.76:8770")
|
||||
tok = token or os.environ.get("CENTRAL_LOG_TOKEN", "change-this-shared-secret")
|
||||
ev = env or os.environ.get("CENTRAL_LOG_ENV", "prod")
|
||||
lvl_name = (level or os.environ.get("CENTRAL_LOG_LEVEL", "INFO")).upper()
|
||||
lvl = getattr(logging, lvl_name, logging.INFO)
|
||||
|
||||
if keep_file is None:
|
||||
@@ -254,6 +261,13 @@ def setup_logging(
|
||||
root = logging.getLogger()
|
||||
root.setLevel(lvl)
|
||||
|
||||
# Ztiš upovídané knihovny (jinak root@INFO chytá jejich šum do centrálu).
|
||||
# Předej quiet_loggers=[] pro vypnutí, nebo vlastní seznam.
|
||||
_default_quiet = ["httpx", "httpcore", "urllib3", "anthropic", "openai",
|
||||
"PIL", "asyncio", "fdb", "fontTools", "pdfminer"]
|
||||
for _name in (_default_quiet if quiet_loggers is None else quiet_loggers):
|
||||
logging.getLogger(_name).setLevel(logging.WARNING)
|
||||
|
||||
# odstraň případné staré handlery (idempotentní setup)
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
@@ -276,9 +290,9 @@ def setup_logging(
|
||||
)
|
||||
sender = _GatewaySender(
|
||||
app_name=app_name,
|
||||
gateway=gateway or DEFAULT_GATEWAY,
|
||||
token=token or DEFAULT_TOKEN,
|
||||
env=env or DEFAULT_ENV,
|
||||
gateway=gw,
|
||||
token=tok,
|
||||
env=ev,
|
||||
spool_dir=spool_base / "_log_spool",
|
||||
)
|
||||
ch = CentralLogHandler(sender)
|
||||
@@ -290,7 +304,7 @@ def setup_logging(
|
||||
atexit.register(sender.flush_and_stop)
|
||||
|
||||
root.info("central_logging v1.0 inicializováno (app=%s, keep_file=%s, gateway=%s)",
|
||||
app_name, keep_file, gateway or DEFAULT_GATEWAY)
|
||||
app_name, keep_file, gw)
|
||||
return root
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}}"
|
||||
@@ -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 ==="
|
||||
@@ -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 ====="
|
||||
@@ -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
|
||||
@@ -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 0–100**, 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
|
||||
```
|
||||
@@ -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'}}]
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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Č (9–10 čí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()
|
||||
@@ -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
|
||||
@@ -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 "RČ", [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 (0–100), ž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} Kč"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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í.
|
||||
@@ -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.
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
*.log
|
||||
__pycache__/
|
||||
_*.html
|
||||
@@ -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`.
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
beautifulsoup4
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"last_id": "560"
|
||||
}
|
||||
@@ -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?"')
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user