Files
odoo-at-payroll/personalverrechnung/tools/fetch_kv_portal.py
T
fegger 5b629a5916 [ADD] personalverrechnung: machine-readable KV library (all current collective agreements)
Cross-checked catalogue of all currently valid Austrian collective
agreements, per the source policy: main source = the responsible
employer-side chamber, kollektivvertrag.at (OEGB-Verlag) as complete
cross-check baseline.

- oegb/: 593 variants from kollektivvertrag.at with full consolidated
  texts (JSON primary format, Markdown for review/diff) plus
  catalog.json/.csv; fetched via the portal's JSON servlets
  (dashboard/structure/slices/topics), resumable
- wko/: 614 current documents from the WKO KV database (1,820 branch
  overviews across 10 regions) with texts and catalog; match-report
  against the baseline: 407 pairs, 30 date disagreements flagged
- chambers/chambers.json: curated registry of chamber-side sources and
  publication gaps (GOeD member-only, RA/Notariat not public, OCR-less
  scan PDFs, missing hospital-doctors KV)
- kv-catalog.json/.csv: merged catalogue, 768 entries, chamber
  classification, main_source, cross_check status, manual_review lists
- tools/fetch_kv_portal.py / fetch_wko_kv.py / build_kv_catalog.py:
  stdlib-only fetchers and merge step, each with a --refresh diff mode
- RUNBOOK.md: update cycle for keeping the library current (annual,
  tied to the Wartung rhythm and KV rounds)

Lohntafel PDFs are linked but not downloaded; wage tables contained in
the KV texts are converted. No Odoo module changes.
2026-09-09 19:10:07 +02:00

793 lines
30 KiB
Python

#!/usr/bin/env python3
"""Build the machine-readable KV library from kollektivvertrag.at (ÖGB-Verlag).
The portal of the Austrian trade-union federation (ÖGB) publishes every
currently valid Kollektivvertrag as consolidated text. It is the only
complete, all-chambers source and therefore serves as the cross-check
baseline for the chamber-specific main sources (WKO, Ärztekammer,
Apoothekerkammer, Rechtsanwaltskammer, Notariatskammer, LWK, public
sector) that are matched against it in kv-catalog.json.
Run from the repository root:
python3 personalverrechnung/tools/fetch_kv_portal.py # full fetch
python3 personalverrechnung/tools/fetch_kv_portal.py --limit 3 # smoke test
python3 personalverrechnung/tools/fetch_kv_portal.py --build-only
python3 personalverrechnung/tools/fetch_kv_portal.py --refresh # update run
Outputs (curated, versioned):
personalverrechnung/quellen/kv/oegb/catalog.json|.csv
personalverrechnung/quellen/kv/oegb/texts/<variant-id>.json|.md
personalverrechnung/quellen/kv/oegb/update-reports/update-<date>.json
Raw API mirror (unversioned, .firecrawl/ is gitignored):
.firecrawl/kv-portal/sitemap.xml
.firecrawl/kv-portal/groups/<group-id>.json|html
.firecrawl/kv-portal/variants/<variant-id>/structure.json
.firecrawl/kv-portal/variants/<variant-id>/slices/<slice-id>.json
.firecrawl/kv-portal/variants/<variant-id>/topics/<topic-id>.json
Semantics of the portal API (verified 2026-09-09):
- sitemap.xml lists one dashboard URL per variant-group/variant.
- Each dashboard page embeds data-variant-group-json with every variant
of the group: provinces, employmentTypes (arb/ang), unions, validity
(validFrom/lastContentUpdate) and the topic ids.
- kvfulltext.kv-structure.json?structure-id=<variant-id> returns the
doc-set tree; its children are KV_TIME_SLICE parts whose ids embed the
version date (<paraid>_<YYYYMMDD>). The latest date per slice name is
the current version of that part; older entries are history.
- kvfulltext.kv-slices.json?slice-id=<id> returns the paragraph tree
(titleHtml/contentHtml) of one slice.
- kvdashboard.kv-topics.json?topic-id=<id> returns the dashboard topic
content (the portal's own "current" view per theme).
"""
from __future__ import annotations
import argparse
import concurrent.futures as cf
import csv
import datetime as dt
import html
import html.parser
import json
import re
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
RAW = REPO / ".firecrawl" / "kv-portal"
OUT = REPO / "personalverrechnung" / "quellen" / "kv" / "oegb"
TEXTS = OUT / "texts"
REPORTS = OUT / "update-reports"
BASE = "https://www.kollektivvertrag.at"
DASHBOARD = BASE + "/kv?variant-group-id={group}&variant-id={variant}&topic-id=1"
STRUCTURE = (BASE + "/content/kollektivvertrag/at/de/system/kv/fulltext/"
"jcr:content/root/container/container/kvfulltext.kv-structure.json"
"?structure-id={variant}")
SLICES = (BASE + "/content/kollektivvertrag/at/de/system/kv/fulltext/"
"jcr:content/root/container/container/kvfulltext.kv-slices.json"
"?slice-id={slice}")
TOPICS = (BASE + "/content/kollektivvertrag/at/de/system/kv/dashboard/"
"jcr:content/root/container/container/kvdashboard.kv-topics.json"
"?topic-id={topic}")
HEADERS = {
"User-Agent": "odoo-at-payroll-kv-library/1.0 (legal-source mirror; "
"contact: repo maintainer)",
"Accept": "application/json,text/html;q=0.9,*/*;q=0.5",
"Accept-Language": "de-AT,de;q=0.9",
"Accept-Encoding": "identity",
}
PROVINCE_NAMES = {
"b": "Burgenland", "k": "Kärnten", "": "Niederösterreich",
"": "Oberösterreich", "s": "Salzburg", "st": "Steiermark",
"t": "Tirol", "v": "Vorarlberg", "w": "Wien",
}
EMPLOYMENT_TYPE_NAMES = {"arb": "Arbeiter:innen", "ang": "Angestellte"}
UNION_NAMES = {
"gpa": "GPA-djp", "gpa-djp": "GPA-djp", "younion": "younion",
"vida": "vida", "gbh": "Gewerkschaft Bau-Holz",
"proge": "PRO-GE", "pro-ge": "PRO-GE",
"gpf": "Gewerkschaft der Post- und Fernmeldebediensteten",
"goed": "Gewerkschaft Öffentlicher Dienst (GÖD)",
}
def union_name(u: str) -> str:
return UNION_NAMES.get(u, UNION_NAMES.get(u.replace("-", ""), u))
# Employer-side chambers that can be recognised in the consolidated texts.
CHAMBER_PATTERNS = [
("Aerztekammer", re.compile(r"Ärztekammer|Ärzt(innen)?kammer", re.I)),
("Apoothekerkammer", re.compile(r"Apoothekerkammer", re.I)),
("Rechtsanwaltskammer", re.compile(r"Rechtsanw(a|ä)ltskammer", re.I)),
("Notariatskammer", re.compile(r"Notariatskammer", re.I)),
("Landwirtschaftskammer", re.compile(r"Landwirtschaftskammer|L(a|ä)ndliche\s+Genossenschaften", re.I)),
("Staedtebund_Gemeindebund", re.compile(r"Städtebund|Gemeindebund|Stadtgemeinden", re.I)),
("WKO", re.compile(r"Wirtschaftskammer|Wirtschaftskammern|Fachverband|Bundesinnung|Landesinnung|Landessektion|Bundesgruppe", re.I)),
]
WORKERS = 4
RETRIES = 3
TIMEOUT = 40
class FetchError(RuntimeError):
pass
def log(msg: str) -> None:
print(msg, flush=True)
def fetch(url: str, binary: bool = False, force: bool = False,
cache: Path | None = None) -> bytes | str:
"""GET a URL with disk cache; returns text (or bytes for binary=True)."""
if cache is not None and cache.exists() and not force:
return cache.read_bytes() if binary else cache.read_text(encoding="utf-8")
last_err: Exception | None = None
for attempt in range(RETRIES):
try:
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
data = resp.read()
if cache is not None:
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(data)
return data if binary else data.decode("utf-8", "replace")
except Exception as exc: # noqa: BLE001 - retry any transport error
last_err = exc
time.sleep(2 * (attempt + 1))
raise FetchError(f"{url}: {last_err}")
def fetch_json(url: str, cache: Path, force: bool = False) -> dict:
raw = fetch(url, cache=cache, force=force)
text = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
stripped = text.lstrip()
if stripped.startswith("<!DOCTYPE") or stripped.startswith("<html"):
raise FetchError(f"{url}: HTML error page instead of JSON")
return json.loads(text)
# ---------------------------------------------------------------- sitemap
def parse_sitemap(force: bool = False) -> list[dict]:
"""Return [{'group': ..., 'variant': ...}] from the portal sitemap."""
cache = RAW / "sitemap.xml"
text = fetch(BASE + "/sitemap.xml", cache=cache, force=force)
entries = []
for loc in re.findall(r"<loc>([^<]+)</loc>", text):
url = html.unescape(loc)
if "/kv?variant-group-id=" not in url:
continue
m = re.search(r"variant-group-id=([^&]+)&variant-id=([^&]+)", url)
if m:
entries.append({"group": m.group(1), "variant": m.group(2),
"url": url})
return entries
# ------------------------------------------------------------- dashboard
def fetch_group(group_id: str, variant_id: str, force: bool = False) -> dict:
"""Fetch one dashboard page and return its data-variant-group-json."""
cache_html = RAW / "groups" / f"{group_id}.html"
cache_json = RAW / "groups" / f"{group_id}.json"
if cache_json.exists() and not force:
return json.loads(cache_json.read_text(encoding="utf-8"))
page = fetch(DASHBOARD.format(group=group_id, variant=variant_id),
cache=cache_html, force=force)
m = re.search(r"<ex-kv-dashboard[^>]*>", page)
if not m:
raise FetchError(f"dashboard {group_id}: no ex-kv-dashboard element")
tag = m.group(0)
m = re.search(r'data-variant-group-json="([^"]*)"', tag)
if not m:
raise FetchError(f"dashboard {group_id}: no variant-group json")
data = json.loads(html.unescape(m.group(1)))
cache_json.parent.mkdir(parents=True, exist_ok=True)
cache_json.write_text(json.dumps(data, ensure_ascii=False, indent=1),
encoding="utf-8")
return data
# ---------------------------------------------------- structure & slices
def slice_version_date(slice_id: str) -> str:
"""'328261_20191201' -> '20191201' (may be empty for odd ids)."""
m = re.search(r"_(\d{8})$", slice_id)
return m.group(1) if m else ""
def fetch_structure(variant_id: str, force: bool = False) -> dict:
return fetch_json(
STRUCTURE.format(variant=variant_id),
RAW / "variants" / variant_id / "structure.json", force)
def fetch_slice(variant_id: str, slice_id: str, force: bool = False) -> dict:
return fetch_json(
SLICES.format(slice=slice_id),
RAW / "variants" / variant_id / "slices" / f"{slice_id}.json", force)
def fetch_topic(variant_id: str, topic_id: str, force: bool = False) -> dict:
return fetch_json(
TOPICS.format(topic=topic_id),
RAW / "variants" / variant_id / "topics" / f"{topic_id}.json", force)
# ------------------------------------------------------ html -> markdown
class _MD(html.parser.HTMLParser):
"""Conservative HTML-to-Markdown renderer for ÖGB-Verlag fragments.
The JSON keeps the original htmlContent; this rendering exists for
review/diffing, not as the primary machine format.
"""
HEADINGS = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### ",
"h5": "##### ", "h6": "###### "}
BLOCKS = {"p", "div", "li", "tr", "table", "ul", "ol", "h1", "h2", "h3",
"h4", "h5", "h6", "br", "blockquote"}
SKIP = {"script", "style", "svg"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out: list[str] = []
self._buf: list[str] = []
self._list_depth = 0
self._cell: list[str] | None = None
self._row: list[str] | None = None
self._table: list[list[str]] | None = None
self._skip = 0
# -- buffer handling ------------------------------------------------
def _emit(self, text: str) -> None:
if self._cell is not None:
self._cell.append(text)
else:
self._buf.append(text)
def _flush_block(self, prefix: str = "") -> None:
text = "".join(self._buf)
self._buf = []
text = re.sub(r"[ \t\u00a0]+", " ", text).strip()
if text or prefix:
self.out.append(prefix + text)
def handle_starttag(self, tag, attrs):
if tag in self.SKIP:
self._skip += 1
return
if self._skip:
return
if tag in ("b", "strong"):
self._emit("**")
elif tag in ("i", "em"):
self._emit("*")
elif tag == "u":
self._emit("__")
elif tag == "br":
if self._cell is None:
self._flush_block()
elif tag == "a":
href = dict(attrs).get("href", "")
if href:
self._emit("\x01")
self._emit(f"\x02{href}\x02")
elif tag in ("ul", "ol"):
self._list_depth += 1
elif tag == "li":
self._flush_block()
elif tag == "table":
self._flush_block()
self._table = []
elif tag == "tr":
self._row = []
elif tag in ("td", "th"):
self._cell = []
elif tag in self.HEADINGS:
self._flush_block(self.HEADINGS[tag])
elif tag in self.BLOCKS:
self._flush_block()
def handle_endtag(self, tag):
if tag in self.SKIP:
self._skip = max(0, self._skip - 1)
return
if self._skip:
return
if tag in ("b", "strong"):
self._emit("**")
elif tag in ("i", "em"):
self._emit("*")
elif tag == "u":
self._emit("__")
elif tag in ("ul", "ol"):
self._list_depth = max(0, self._list_depth - 1)
elif tag == "li":
bullet = " " * (self._list_depth - 1) + "- "
self._flush_block(bullet)
elif tag in ("td", "th"):
if self._cell is not None and self._row is not None:
cell = re.sub(r"\s+", " ", "".join(self._cell)).strip()
cell = cell.replace("|", "\\|")
self._row.append(cell)
self._cell = None
elif tag == "tr":
if self._row is not None and self._table is not None:
self._table.append(self._row)
self._row = None
elif tag == "table":
if self._table:
width = max(len(r) for r in self._table)
for r in self._table:
r.extend([""] * (width - len(r)))
lines = ["| " + " | ".join(self._table[0]) + " |",
"|" + "---|" * width]
lines += ["| " + " | ".join(r) + " |" for r in self._table[1:]]
self.out.extend(lines)
self._table = None
elif tag in self.HEADINGS:
self._flush_block()
elif tag in self.BLOCKS:
self._flush_block()
def handle_data(self, data):
if not self._skip:
self._emit(data)
def result(self) -> str:
self._flush_block()
text = "\n\n".join(p for p in self.out if p.strip())
# resolve link markers produced by handle_starttag('a')
text = re.sub(r"\x01([^\x01\x02]*)\x02([^\x02]*)\x02", r"\1 (\2)", text)
text = text.replace("\x01", "").replace("\x02", "")
return re.sub(r"\n{3,}", "\n\n", text).strip()
def html_to_md(fragment: str) -> str:
if not fragment or not fragment.strip():
return ""
parser = _MD()
try:
parser.feed(fragment)
parser.close()
return parser.result()
except Exception: # noqa: BLE001 - never fail a render
return re.sub(r"<[^>]+>", " ", fragment).strip()
def title_of(title_html: str) -> str:
text = re.sub(r"<[^>]+>", " ", title_html or "")
return re.sub(r"\s+", " ", html.unescape(text)).strip()
# --------------------------------------------------------- curation
def current_slices(structure: dict) -> list[dict]:
"""Latest version per slice name, in structure order."""
main = structure.get("main", structure)
latest: dict[str, dict] = {}
order: list[str] = []
for child in (main.get("children") or []):
link = child.get("link", {})
if link.get("type") != "KV_TIME_SLICE":
continue
name = link.get("name") or link.get("id")
node = {
"slice_id": link.get("id"),
"name": name,
"valid_from": (child.get("validFrom") or "")[:10],
"version": slice_version_date(link.get("id") or ""),
}
if name not in latest:
order.append(name)
latest[name] = node
elif node["version"] >= latest[name]["version"]:
latest[name] = node
return [latest[n] for n in order]
def all_slice_ids(structure: dict) -> list[dict]:
main = structure.get("main", structure)
out = []
for child in (main.get("children") or []):
link = child.get("link", {})
if link.get("type") == "KV_TIME_SLICE":
out.append({"slice_id": link.get("id"), "name": link.get("name"),
"valid_from": (child.get("validFrom") or "")[:10]})
return out
def classify_chamber(texts: list[str]) -> str:
for chamber, pattern in CHAMBER_PATTERNS:
for t in texts:
if t and pattern.search(t):
return chamber
return "unclassified"
def collect_text(node: dict, acc: list[str], limit: int = 6000) -> None:
acc.append(title_of(node.get("titleHtml") or ""))
acc.append(re.sub(r"<[^>]+>", " ", html.unescape(
node.get("contentHtml") or "")))
for child in (node.get("children") or [])[:6]:
collect_text(child, acc, limit)
if sum(len(a) for a in acc) > limit:
return
def build_variant_doc(group_data: dict, variant: dict, structure: dict,
slices: dict[str, dict], topics: dict[str, dict],
fetched_at: str) -> dict:
fm = variant.get("facetMapping", {})
props = variant.get("properties", {})
validity = props.get("validity", {})
cur = current_slices(structure)
current_ids = {c["slice_id"] for c in cur}
doc = {
"variant_id": variant.get("id"),
"variant_group_id": group_data.get("id"),
"title": variant.get("name") or group_data.get("title"),
"employment_types": fm.get("employmentTypes", []),
"employment_types_names": [EMPLOYMENT_TYPE_NAMES.get(e, e)
for e in fm.get("employmentTypes", [])],
"provinces": fm.get("provinces", []),
"provinces_names": [PROVINCE_NAMES.get(p, p)
for p in fm.get("provinces", [])],
"unions": props.get("unions", []),
"unions_names": [union_name(u) for u in props.get("unions", [])],
"validity": {
"valid_from": (validity.get("validFrom") or "")[:10],
"last_content_update": (validity.get("lastContentUpdate") or "")[:10],
},
"fetched_at": fetched_at,
"source_urls": {
"dashboard": DASHBOARD.format(group=group_data.get("id"),
variant=variant.get("id")),
"volltext": BASE + "/volltext?doc-set-id=" + str(variant.get("id")),
"portal": BASE,
},
"chamber": None, # filled by the cross-check/catalog step
"slices": cur,
"slice_history": [s for s in all_slice_ids(structure)
if s["slice_id"] not in current_ids],
"topics": [{"id": t.get("id"), "title": t.get("title")}
for t in variant.get("topics", [])],
}
# chamber heuristic from the head of the current texts
head_texts = []
for cid in list(current_ids)[:2]:
sl = slices.get(cid, {})
acc: list[str] = []
for child in (sl.get("children") or [])[:4]:
collect_text(child, acc)
head_texts.append(" ".join(acc))
doc["chamber_heuristic"] = classify_chamber(head_texts)
# full content
doc["content"] = {
"slices": {cid: slices[cid] for cid in sorted(current_ids)
if cid in slices},
"topics": {tid: topics[tid] for tid in sorted(topics)
if tid in topics},
}
return doc
def render_markdown(doc: dict) -> str:
lines = [
f"# {doc['title']}",
"",
f"- **Variante:** `{doc['variant_id']}` "
f"(Gruppe `{doc['variant_group_id']}`)",
f"- **Anstellungsart:** {', '.join(doc['employment_types_names']) or 'n/a'}",
f"- **Bundesländer:** {', '.join(doc['provinces_names']) or 'Österreichweit'}",
f"- **Gewerkschaft:** {', '.join(doc['unions_names']) or 'n/a'}",
f"- **Gültig ab / Stand:** {doc['validity']['valid_from']} / "
f"{doc['validity']['last_content_update']}",
f"- **Quelle:** {doc['source_urls']['dashboard']}",
f"- **Abgerufen:** {doc['fetched_at']}",
"",
]
for s in doc["slices"]:
lines += [f"## Teil: {s['name']} "
f"(Version {s['version']}, gültig ab {s['valid_from']})",
""]
sl = doc["content"]["slices"].get(s["slice_id"], {})
lines.append(_render_slice_children(sl))
lines.append("")
for t in doc["topics"]:
body = doc["content"]["topics"].get(t["id"], {})
items = body.get("items") or []
if not items:
continue
lines += [f"## Thema: {t['title']}", ""]
for item in items:
md = html_to_md(item.get("htmlContent") or "")
if md:
lines += [md, ""]
return "\n".join(lines).rstrip() + "\n"
def _render_slice_children(slice_doc: dict, depth: int = 0) -> str:
out = []
for child in (slice_doc.get("children") or []):
title = title_of(child.get("titleHtml") or "")
if title:
level = "#" * min(depth + 2, 6)
out.append(f"{level} {title}")
out.append("")
body = html_to_md(child.get("contentHtml") or "")
if body:
out.append(body)
out.append("")
sub = _render_slice_children(child, depth + 1)
if sub:
out.append(sub)
return "\n".join(out)
# ------------------------------------------------------------- pipeline
def variant_jobs(group_datas: dict[str, dict]) -> list[dict]:
"""All variants to process, deduped by variant id."""
jobs: dict[str, dict] = {}
for group_id, data in group_datas.items():
variants = list((data.get("variants") or {}).get("items") or [])
if data.get("variant"):
variants.append(data["variant"])
for v in variants:
vid = v.get("id")
if vid and vid not in jobs:
jobs[vid] = {"group": group_id, "variant": v}
return list(jobs.values())
def process_variant(job: dict, group_data: dict, force: bool) -> dict:
variant = job["variant"]
vid = variant["id"]
structure = fetch_structure(vid, force)
slice_docs = {}
for s in all_slice_ids(structure):
slice_docs[s["slice_id"]] = fetch_slice(vid, s["slice_id"], force)
topic_docs = {}
for t in variant.get("topics", []):
tid = t.get("id")
if tid:
topic_docs[tid] = fetch_topic(vid, tid, force)
fetched_at = dt.date.today().isoformat()
return build_variant_doc(group_data, variant, structure, slice_docs,
topic_docs, fetched_at)
def write_outputs(docs: list[dict]) -> None:
if not docs:
raise SystemExit("no variant docs to write (fetch failures?)")
TEXTS.mkdir(parents=True, exist_ok=True)
catalog = []
for doc in docs:
vid = doc["variant_id"]
(TEXTS / f"{vid}.json").write_text(
json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
(TEXTS / f"{vid}.md").write_text(render_markdown(doc), encoding="utf-8")
catalog.append({
"variant_group_id": doc["variant_group_id"],
"variant_id": vid,
"title": doc["title"],
"employment_types": doc["employment_types"],
"provinces": doc["provinces"],
"unions": doc["unions"],
"valid_from": doc["validity"]["valid_from"],
"last_content_update": doc["validity"]["last_content_update"],
"chamber_heuristic": doc["chamber_heuristic"],
"current_slices": [{"name": s["name"], "slice_id": s["slice_id"],
"version": s["version"]}
for s in doc["slices"]],
"n_slice_history": len(doc["slice_history"]),
"n_topics": len(doc["topics"]),
"dashboard_url": doc["source_urls"]["dashboard"],
})
catalog.sort(key=lambda e: (e["variant_group_id"], e["variant_id"]))
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "catalog.json").write_text(
json.dumps({"generated_at": dt.datetime.now(dt.timezone.utc)
.isoformat(timespec="seconds"),
"source": BASE,
"n_variants": len(catalog),
"variants": catalog},
ensure_ascii=False, indent=1), encoding="utf-8")
with (OUT / "catalog.csv").open("w", newline="",
encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=list(catalog[0].keys()))
writer.writeheader()
writer.writerows(catalog)
def run(force: bool, limit: int | None, build_only: bool) -> None:
if build_only:
docs = _load_docs_from_raw()
write_outputs(docs)
log(f"built {len(docs)} variant docs from raw mirror")
return
entries = parse_sitemap(force)
groups: dict[str, str] = {}
for e in entries:
groups.setdefault(e["group"], e["variant"])
log(f"sitemap: {len(entries)} urls, {len(groups)} unique groups")
ids = list(groups.items())
if limit:
ids = ids[:limit]
group_datas = {}
failures: list[str] = []
with cf.ThreadPoolExecutor(max_workers=WORKERS) as pool:
futs = {pool.submit(fetch_group, g, v, force): g for g, v in ids}
for fut in cf.as_completed(futs):
g = futs[fut]
try:
group_datas[g] = fut.result()
except Exception as exc: # noqa: BLE001
failures.append(f"group {g}: {exc}")
log(f"groups fetched: {len(group_datas)} (failed: {len(failures)})")
jobs = variant_jobs(group_datas)
if limit:
jobs = jobs[:limit]
log(f"variants to process: {len(jobs)}")
docs = []
with cf.ThreadPoolExecutor(max_workers=WORKERS) as pool:
futs = {}
for job in jobs:
group_data = group_datas[job["group"]]
futs[pool.submit(process_variant, job, group_data, force)] = job
done = 0
for fut in cf.as_completed(futs):
job = futs[fut]
try:
docs.append(fut.result())
done += 1
if done % 25 == 0:
log(f" variants done: {done}/{len(jobs)}")
except Exception as exc: # noqa: BLE001
failures.append(f"variant {job['variant']['id']}: {exc}")
docs.sort(key=lambda d: (d["variant_group_id"], d["variant_id"]))
write_outputs(docs)
(RAW / "failures.log").write_text("\n".join(failures) + "\n",
encoding="utf-8")
log(f"done: {len(docs)} docs, {len(failures)} failures "
f"(see .firecrawl/kv-portal/failures.log)")
def _load_docs_from_raw() -> list[dict]:
"""Rebuild curated outputs from the raw mirror without network."""
docs = []
for cache in sorted((RAW / "groups").glob("*.json")):
group_data = json.loads(cache.read_text(encoding="utf-8"))
for job in variant_jobs({group_data["id"]: group_data}):
vid = job["variant"]["id"]
vdir = RAW / "variants" / vid
struct_cache = vdir / "structure.json"
if not struct_cache.exists():
continue
structure = json.loads(struct_cache.read_text(encoding="utf-8"))
slices = {}
for s in all_slice_ids(structure):
p = vdir / "slices" / f"{s['slice_id']}.json"
if p.exists():
slices[s["slice_id"]] = json.loads(
p.read_text(encoding="utf-8"))
topics = {}
for t in job["variant"].get("topics", []):
p = vdir / "topics" / f"{t['id']}.json"
if p.exists():
topics[t["id"]] = json.loads(
p.read_text(encoding="utf-8"))
docs.append(build_variant_doc(
group_data, job["variant"], structure, slices, topics,
dt.date.today().isoformat()))
return docs
def run_refresh() -> None:
"""Diff sitemap + group metadata against the catalog; refetch changes."""
catalog_path = OUT / "catalog.json"
if not catalog_path.exists():
log("no catalog yet -- run a full fetch first")
sys.exit(2)
old = {v["variant_id"]: v for v in
json.loads(catalog_path.read_text(encoding="utf-8"))["variants"]}
entries = parse_sitemap(force=True)
groups: dict[str, str] = {}
for e in entries:
groups.setdefault(e["group"], e["variant"])
log(f"sitemap: {len(entries)} urls, {len(groups)} groups "
f"(catalog has {len(old)})")
group_datas = {}
with cf.ThreadPoolExecutor(max_workers=WORKERS) as pool:
futs = {pool.submit(fetch_group, g, v, True): g for g, v in groups.items()}
for fut in cf.as_completed(futs):
g = futs[fut]
try:
group_datas[g] = fut.result()
except Exception as exc: # noqa: BLE001
log(f" WARN group {g}: {exc}")
changed, new, removed = [], [], []
for job in variant_jobs(group_datas):
vid = job["variant"]["id"]
v = job["variant"]
validity = v.get("properties", {}).get("validity", {})
stamp = ((validity.get("validFrom") or "")[:10],
(validity.get("lastContentUpdate") or "")[:10])
if vid not in old:
new.append(vid)
elif (old[vid]["valid_from"], old[vid]["last_content_update"]) != stamp:
changed.append(vid)
removed = [vid for vid in old if vid not in
{j["variant"]["id"] for j in variant_jobs(group_datas)}]
log(f"changed: {len(changed)}, new: {len(new)}, removed: {len(removed)}")
docs = []
for job in variant_jobs(group_datas):
vid = job["variant"]["id"]
if vid in changed or vid in new:
try:
docs.append(process_variant(job, group_datas[job["group"]],
force=True))
except Exception as exc: # noqa: BLE001
log(f" WARN variant {vid}: {exc}")
if docs:
# changed variants are already re-fetched into the raw mirror;
# rebuild the full curated set from raw so nothing gets lost
write_outputs(_load_docs_from_raw())
report = {
"run_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
"groups_seen": len(group_datas),
"changed": changed,
"new": new,
"removed": removed,
}
REPORTS.mkdir(parents=True, exist_ok=True)
path = REPORTS / f"update-{dt.date.today().isoformat()}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=1),
encoding="utf-8")
log(f"update report: {path}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--force", action="store_true",
help="refetch even when a raw file exists")
ap.add_argument("--limit", type=int, default=None,
help="only process the first N groups (smoke test)")
ap.add_argument("--build-only", action="store_true",
help="rebuild curated output from the raw mirror")
ap.add_argument("--refresh", action="store_true",
help="diff against the catalog and update changed KVs")
args = ap.parse_args()
if args.refresh:
run_refresh()
else:
run(force=args.force, limit=args.limit, build_only=args.build_only)
if __name__ == "__main__":
main()