117 lines
4.5 KiB
Python
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())
|