"""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())