dp-zp-agent/tools/crzp/fetch.py
2026-11-15 16:27:08 +01:00

256 lines
11 KiB
Python

"""Cached CRZP search and record detail (source + storage).
A search may carry a range of submission years and work types; CRZP applies
both itself, so the total, the facets and the read rows already respect them.
Rows are read in pages of 100 up to `depth` (LINKED_ROWS by default, up to
DEEP_ROWS for counts and summaries). The RSS export, which carries the
permalink ids, is capped by CRZP at LINKED_ROWS and does not follow a changed
sort order, so only the first LINKED_ROWS rows (same order; a title mismatch
leaves the row without id instead of guessing) get a permalink; counts and
summaries do not need one.
While the model reads the question, `prepare()` loads the CRZP home page in a
background session, so the first search of the question saves that request.
"""
from __future__ import annotations
import os
import threading
import time
from tools.crzp.source.client import CrzpSession, CrzpUnavailable, PERMALINK
from tools.crzp.source.parse import facet_actions, fold, parse_detail, parse_results, parse_rows, parse_rss
from tools.crzp.storage.cache import CrzpStore
LINKED_ROWS = 200 # the RSS export of CRZP is capped at 200 items
DEEP_ROWS = int(os.getenv("CRZP_DEEP_ROWS", "3000")) # summaries: read in year ranges, several at once
PARALLEL = int(os.getenv("CRZP_PARALLEL", "3")) # year ranges read at the same time (shared throttle)
EXPANDED = ("Rok odovzdania", "Školiteľ") # facets read in full when a result exceeds one page
MAX_ROWS = LINKED_ROWS
PREPARED_SECONDS = 120
_prepared: list = [] # [(created, session with the home page loaded)]
_prepared_lock = threading.Lock()
_store = None
_store_lock = threading.Lock()
_status = {"last_success": None, "last_failure": None, "last_reason": None,
"canary_ok": None, "canary_at": None, "canary_reason": None}
def record_outcome(error: Exception | None) -> None:
if error is None:
_status.update(last_success=time.time())
else:
_status.update(last_failure=time.time(), last_reason=str(error))
def record_canary(ok: bool, reason: str | None = None) -> None:
_status.update(canary_ok=ok, canary_at=time.time(), canary_reason=reason)
def readiness() -> dict:
"""Health of the CRZP tool without contacting CRZP (no traffic on health polls).
"degraded": the last request failed or the daily check of the page structure failed."""
failed_last = bool(_status["last_failure"]) and (_status["last_success"] or 0) < _status["last_failure"]
canary_failed = _status["canary_ok"] is False
reason = _status["canary_reason"] if canary_failed else _status["last_reason"] if failed_last else None
return {"ready": True, "status": "degraded" if failed_last or canary_failed else "ready", "reason": reason,
"index_version": None, "cache": store().stats(),
"structure_check": {"ok": _status["canary_ok"], "checked_at": _status["canary_at"]}}
def store() -> CrzpStore:
global _store
with _store_lock:
if _store is None:
_store = CrzpStore()
return _store
def cache_key(text: str, years: tuple[int, int] | None, work_types: list[str] | None) -> str:
key = " ".join(fold(text).split())
if years:
key += f" |years={years[0]}-{years[1]}"
if work_types:
key += " |types=" + ",".join(sorted(work_types))
return key
def _attach_links(rows: list[dict], links: list[dict]) -> None:
for row, link in zip(rows, links):
if link["sid"] and fold(link["title"]) == fold(row["title"]):
row["sid"] = link["sid"]
row["url"] = PERMALINK.format(sid=link["sid"])
def prepare() -> None:
"""Load the home page in the background (one request) for the next search."""
if os.getenv("CRZP_PREPARE", "1") == "0":
return
def run():
try:
session = CrzpSession()
session.load_home()
except Exception:
return
with _prepared_lock:
_prepared.append((time.monotonic(), session))
while len(_prepared) > 1:
_prepared.pop(0)[1].close()
with _prepared_lock:
if _prepared and time.monotonic() - _prepared[-1][0] < PREPARED_SECONDS:
return
threading.Thread(target=run, name="crzp-prepare", daemon=True).start()
def _session():
with _prepared_lock:
while _prepared:
created, session = _prepared.pop()
if time.monotonic() - created < PREPARED_SECONDS:
return session
session.close()
return CrzpSession()
def _enough(hit: dict, links: bool, depth: int) -> bool:
return ((hit.get("links", True) or not links)
and (hit["complete"] or len(hit["rows"]) >= min(hit["total"], depth)))
def search(text: str, *, years: tuple[int, int] | None = None, work_types: list[str] | None = None,
links: bool = True, depth: int = LINKED_ROWS, session_factory=None,
cache: CrzpStore | None = None) -> dict:
"""{query, years, work_types, total, facets, rows, complete, links, cached}.
links=False skips the RSS export (two requests) when permalinks are not shown;
depth is the number of rows to read (DEEP_ROWS for counts and summaries)."""
cache = cache or store()
key = cache_key(text, years, work_types)
hit = cache.get_search(key)
if hit is not None and _enough(hit, links, depth):
if len(hit["rows"]) > depth: # a deeper cached read: the same rows a shallow read would give
hit = {**hit, "rows": hit["rows"][:depth], "complete": False}
return {**hit, "cached": True}
try:
result = _search_live(text, years, work_types, links or bool(hit and hit.get("links")),
session_factory or _session, depth)
except CrzpUnavailable as error:
record_outcome(error)
raise
record_outcome(None)
cache.put_search(key, result)
return {**result, "cached": False}
def _expanded(session, first: dict, actions: dict) -> dict:
"""All values of the year and supervisor facets (two requests), exact over the whole result."""
full = {}
for title in EXPANDED:
if title in actions and len(first["facets"].get(title, [])) >= 10: # CRZP shows the top 10 only
full[title] = session.show_all(actions[title])
elif title in first["facets"]:
full[title] = first["facets"][title]
return full
def _search_live(text, years, work_types, links, session_factory, depth=LINKED_ROWS) -> dict:
with session_factory() as session:
first = parse_results(session.search(text, years=years, work_types=work_types))
actions = facet_actions(getattr(session, "content", "") or "") # ids from the first result page
rows = first["rows"]
if first["total"] > len(rows) and depth > len(rows): # up to 20 results: one page, no paging requests
rows = parse_rows(session.set_page_size())
while len(rows) < min(first["total"], depth):
page = session.next_page()
if page is None:
break
more = parse_rows(page)
if not more:
break
rows += more
rows = rows[:depth]
if rows and links:
_attach_links(rows[:LINKED_ROWS], parse_rss(session.rss_links()))
if first["total"] > len(first["rows"]): # last: after an expansion CRZP pages no more rows
first["facets"] = {**first["facets"], **_expanded(session, first, actions)}
return {"query": text, "years": list(years) if years else None, "work_types": work_types or None,
"total": first["total"], "facets": first["facets"], "rows": rows, "complete": len(rows) >= first["total"],
"links": links}
def detail(sid: str, *, session_factory=CrzpSession, cache: CrzpStore | None = None) -> dict:
cache = cache or store()
hit = cache.get_detail(sid)
if hit is not None:
return hit
try:
with session_factory() as session:
record = parse_detail(session.detail_by_sid(sid))
except CrzpUnavailable as error:
record_outcome(error)
raise
record_outcome(None)
record["url"] = PERMALINK.format(sid=record["sid"])
cache.put_detail(sid, record)
return record
_chunk_pool = {"pool": None}
def _pool():
"""One shared pool for year-range reads (no new threads per search)."""
from concurrent.futures import ThreadPoolExecutor
with _store_lock:
if _chunk_pool["pool"] is None:
_chunk_pool["pool"] = ThreadPoolExecutor(max_workers=max(1, PARALLEL), thread_name_prefix="crzp-range")
return _chunk_pool["pool"]
def year_counts(result: dict) -> dict[int, int]:
return {int(f["label"]): f["count"] for f in result["facets"].get("Rok odovzdania", [])
if str(f["label"]).isdigit()}
def plan(counts: dict[int, int], years, cap: int, limit: int) -> list[tuple[int, int]]:
"""Year ranges, newest first, each with at most `cap` records (one year may exceed it),
until `limit` records are planned."""
low, high = years or (min(counts), max(counts))
chunks, current, size, planned = [], [], 0, 0
for year, count in sorted(((y, n) for y, n in counts.items() if low <= y <= high), reverse=True):
if planned >= limit:
break
if current and size + count > cap:
chunks.append((min(current), max(current)))
current, size = [], 0
current.append(year)
size += count
planned += count
if current:
chunks.append((min(current), max(current)))
return chunks
def search_deep(text: str, *, years: tuple[int, int] | None = None, work_types: list[str] | None = None,
links: bool = False, limit: int = DEEP_ROWS, cache: CrzpStore | None = None) -> dict:
"""Up to `limit` rows of a result larger than one read, read in year ranges
(the year facet tells how many records each year has), PARALLEL ranges at once.
With links, every range has at most LINKED_ROWS records, so its RSS export
gives a permalink to every row; without links the ranges are larger."""
head = search(text, years=years, work_types=work_types, links=links,
depth=LINKED_ROWS if links else 20, cache=cache)
if head["complete"]:
return head
counts = year_counts(head)
if not counts:
return search(text, years=years, work_types=work_types, links=links, depth=limit, cache=cache)
wanted = min(head["total"], limit)
cap = LINKED_ROWS if links else max(LINKED_ROWS, -(-wanted // PARALLEL))
chunks = plan(counts, years, cap, limit)
parts = list(_pool().map(lambda c: search(text, years=c, work_types=work_types, links=links,
depth=LINKED_ROWS if links else limit, cache=cache), chunks))
rows = [r for part in parts for r in part["rows"]][:limit]
read_total = sum(part["total"] for part in parts)
return {**head, "rows": rows, "complete": len(rows) >= head["total"], "chunks": chunks,
"links": links, "cached": all(part.get("cached") for part in parts),
"chunks_complete": all(part["complete"] for part in parts) and read_total >= head["total"]}