dp-zp-agent/tools/crzp/monitor.py
2026-11-15 03:36:33 +01:00

117 lines
4.5 KiB
Python

"""Daily check of the CRZP page structure and optional cache warm-up.
The check reads one known, stable record end to end (search with filters,
result row, RSS permalink, detail with abstract) bypassing the cache, and
records the outcome; /health then reports "degraded" instead of failing only
on the next user question. It costs about seven requests a day.
Warm-up (off by default) searches given names at night, so the first question
about them is answered from the cache. It only runs when CRZP_PREWARM_NAMES is
set, e.g. "Daniel Hládek;Ján Staš".
python -m tools.crzp.monitor check
python -m tools.crzp.monitor prewarm "Daniel Hládek" "Ján Staš"
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import threading
from tools.crzp import fetch
from tools.crzp.source.client import CrzpSession, CrzpUnavailable
from tools.crzp.source.parse import fold, parse_detail, parse_results, parse_rss
logger = logging.getLogger(__name__)
# A published record that has every field the parser reads (checked 2026-11-14).
KNOWN = {"text": "Databázová implementácia IoT dát s časovou značkou", "year": 2025,
"work_type": "bachelor_thesis", "sid": "C218772124E30DDCC4088BA0749F"}
def check(session_factory=CrzpSession) -> dict:
"""{"ok": bool, "reason": str | None}; also recorded for /health."""
try:
with session_factory() as session:
result = parse_results(session.search(KNOWN["text"], years=(KNOWN["year"], KNOWN["year"]),
work_types=[KNOWN["work_type"]]))
row = next((r for r in result["rows"] if fold(r["title"]) == fold(KNOWN["text"])), None)
if row is None or not (row["authors"] and row["supervisors"] and row["keywords"] and row["year"]):
raise CrzpUnavailable("known_record_row_changed")
if KNOWN["sid"] not in {i["sid"] for i in parse_rss(session.rss_links())}:
raise CrzpUnavailable("known_record_permalink_changed")
detail = parse_detail(session.detail_by_sid(KNOWN["sid"]))
if not (detail["abstract"] and detail["pages"] and detail["authors"]):
raise CrzpUnavailable("known_record_detail_changed")
except (CrzpUnavailable, ValueError) as error:
fetch.record_canary(False, str(error))
logger.warning("CRZP structure check failed: %s", error)
return {"ok": False, "reason": str(error)}
fetch.record_canary(True)
return {"ok": True, "reason": None}
def prewarm(names: list[str]) -> dict:
"""Search each name once (normal cache); failures are counted, not raised."""
done, failed = 0, 0
for name in names:
try:
fetch.search(name)
done += 1
except CrzpUnavailable:
failed += 1
return {"searched": done, "failed": failed}
def configured_names() -> list[str]:
return [n.strip() for n in os.getenv("CRZP_PREWARM_NAMES", "").split(";") if n.strip()]
class Monitor:
"""Background thread: first run after a delay, then every interval (0 = off)."""
def __init__(self):
self.stop_event = threading.Event()
self.thread = None
def start(self) -> None:
interval = float(os.getenv("CRZP_CHECK_INTERVAL_SECONDS", "86400"))
if interval <= 0:
return
delay = float(os.getenv("CRZP_CHECK_DELAY_SECONDS", "300"))
def run():
if self.stop_event.wait(delay):
return
while True:
check()
names = configured_names()
if names:
prewarm(names)
if self.stop_event.wait(interval):
return
self.thread = threading.Thread(target=run, name="crzp-monitor", daemon=True)
self.thread.start()
def stop(self) -> None:
self.stop_event.set()
if self.thread:
self.thread.join(timeout=5)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("check")
warm = sub.add_parser("prewarm")
warm.add_argument("names", nargs="*")
args = parser.parse_args()
result = check() if args.command == "check" else prewarm(args.names or configured_names())
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("ok", True) else 1
if __name__ == "__main__":
raise SystemExit(main())