narty-2027/gen_viz.py
oskar 06ab1b24aa UI: kolumna dostępności 6–13.02, sekcja Archiwum, nowy termin w nagłówkach
porownanie.html:
- nowa kolumna „Dostępność 6–13.02” z pola `dostepnosc_6_13_02_2027`,
  skrócona do emoji + werdyktu ( DOSTĘPNE / 🔍 do sprawdzenia /
   BRAK (zweryf.)), pełna notatka w atrybucie title
- sortowanie kolumny układa decyzyjnie (wolne → do sprawdzenia → brak),
  nie alfabetycznie po emoji
- wiersz Glemm Lodge na czerwono i z przekreśloną nazwą: zweryfikowany
  brak miejsc bije kolor statusu ceny (reguła CSS niżej wygrywa)
- karty `priorytet: ARCHIWALNA` wypadają z tabeli i z bilansu do osobnej
  sekcji „Archiwum” na dole strony
- legenda i bilans statusów pod tabelą

Termin wyjazdu wyjechał do stałych TERM_LABEL/TERM_SHORT/TERM_NOTE:
nagłówki wszystkich stron (BUNDLE_NAME, też w gen_viz.py), lead
start.html, opis kafla porównania i raport braków biorą go stąd.
Zmiana terminu = zmiana stałych i regeneracja.

Raport braków czyta teraz dostępność z pola frontmattera zamiast zgadywać
z nagłówków w body, i pomija karty archiwalne. Wypis po regeneracji
podaje bilans /🔍/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:00:23 +02:00

475 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Generuje viz.html (graph viewer) dla tego bundle'a.
Viewer z GoogleCloudPlatform/knowledge-catalog jest samowystarczalny: cały
bundle jest wpiekany do pliku HTML jako `window.BUNDLE` (nodes/edges/bodies).
Nie czyta plików .md w runtime, więc nie potrzebuje manifestu ani serwera —
źródłem danych jest ten skrypt, uruchamiany po każdej zmianie bundle'a.
Logika viewera (templates/viz.html, static/viz.js, static/viz.css) jest
wstawiana dosłownie, bez zmian. Skrypt koryguje wyłącznie warstwę zbierania
danych, w dwóch punktach, w których upstreamowy generator rozmija się ze
specyfikacją OKF:
1. `_extract_links` pomija linki bundle-relative zaczynające się od `/`
(generator.py:74), czyli dokładnie formę zalecaną przez §5.1. Bundle
trzymający się zalecenia dostaje graf bez krawędzi. Sam viewer te linki
rozumie — viz.js:297 rozwiązuje `/...md` na węzeł — więc dokładamy je do
ekstrakcji krawędzi.
2. `_walk_concepts` pomija tylko `index.md`, przez co zarezerwowany `log.md`
ląduje w grafie jako koncept typu "Unknown". Wg §3.1 zarezerwowane nazwy
nie są konceptami.
Dodatkowo skrypt dokłada do grafu **linki zewnętrzne** jako węzły typu
"external" (kolor teal, odróżnialny od szarych konceptów) z krawędzią
koncept → link. Pełny URL trafia do pola `resource` węzła, bo istniejący
panel szczegółów renderuje `resource` jako klikalny odnośnik z
`target="_blank"` (viz.js:171-183) — to daje "klik otwiera URL w nowej
karcie" bez żadnej zmiany w viewerze.
Węzły konceptów mają w `resource` ścieżkę do karty HTML w `pages/`
(generowanej przez gen_pages.py) — ten sam mechanizm panelu szczegółów daje
przejście z grafu do pełnej treści dokumentu. Jeśli koncept ma własne pole
`resource` we frontmatterze, ląduje ono w danych węzła jako
`resource_frontmatter`.
Skąd brane są adresy — cztery niezależne źródła, każde da się wyłączyć
osobno stałą `SOURCES`:
md-link [tekst](https://...) w body konceptu
bare-url goły https://... w body konceptu
bare-domain goła domena w body (np. "alpincard.at"), normalizowana do https://
frontmatter pola frontmattera niosące adresy: FRONTMATTER_URL_FIELDS
oraz FRONTMATTER_DOMAIN_FIELDS
UWAGA: w obecnym bundle'u body konceptów nie zawiera ani jednego adresu
http(s):// — wszystkie adresy obiektów siedzą we frontmatterze (`www:`,
`gdzie_szukac:`), a w body są tylko gołe domeny ("alpincard.at",
"alpenparks.at"). Same źródła `md-link` i `bare-url` dają dziś 0 węzłów;
są zaimplementowane, bo obsługują przyszłe treści. Źródła `bare-domain`
i `frontmatter` są tym, co faktycznie wyciąga merytoryczne linki bundle'a
(obiekty, skipassy). Żeby zawęzić do samego body — usuń "frontmatter"
z SOURCES.
Pliki zarezerwowane (index.md, log.md) nie są źródłem linków — nie są
konceptami (§3.1) i nie trafiają do grafu.
Wymaga: PyYAML oraz repozytorium knowledge-catalog (domyślnie
/tmp/knowledge-catalog — nadpisz zmienną KNOWLEDGE_CATALOG).
Uruchomienie: python3 gen_viz.py
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import yaml
BUNDLE_ROOT = Path(__file__).resolve().parent
BUNDLE_NAME = "Ski 2027 — wyjazd 613.02.2027"
CATALOG = Path(os.environ.get("KNOWLEDGE_CATALOG", "/tmp/knowledge-catalog"))
sys.path.insert(0, str(CATALOG / "okf" / "src"))
try:
from reference_agent.viewer import generator as G
except ModuleNotFoundError as exc: # pragma: no cover
sys.exit(
f"Nie znaleziono viewera ({exc}).\n"
f"Sklonuj: git clone https://github.com/GoogleCloudPlatform/"
f"knowledge-catalog {CATALOG}"
)
RESERVED = {"index.md", "log.md"}
# Karty HTML konceptów generowane przez gen_pages.py.
PAGES_DIRNAME = "pages"
# --- konfiguracja linków zewnętrznych ---------------------------------
SOURCES = ("md-link", "bare-url", "bare-domain", "frontmatter")
EXTERNAL_TYPE = "external"
EXTERNAL_COLOR = "#0d9488" # teal — koncepty są szare (#94a3b8)
EXTERNAL_SIZE = 22
FRONTMATTER_URL_FIELDS = ("www",) # wartości to pełne URL-e
FRONTMATTER_DOMAIN_FIELDS = ("gdzie_szukac",) # wartości to gołe domeny
# TLD dopuszczone przy rozpoznawaniu gołych domen — świadomie wąska lista,
# żeby nie łapać "viz.html", "index.md" itp.
_TLDS = "at|com|pl|de|eu|net|org|io|info"
_MD_LINK_RE = re.compile(r"\[[^\]]*\]\((https?://[^)\s]+)\)")
_BARE_URL_RE = re.compile(r"https?://[^\s)>\]},;\"']+")
_BARE_DOMAIN_RE = re.compile(
rf"(?<![\w./@-])((?:[a-z0-9][a-z0-9-]*\.)+(?:{_TLDS}))(?![\w-])"
)
# Domeny techniczne — nie są wiedzą o wyjeździe.
def _technical_reason(url: str) -> str | None:
parts = urlsplit(url)
host = parts.netloc.lower().split("@")[-1].split(":")[0]
path = parts.path.lower()
if host == "localhost" or host.endswith(".localhost"):
return "localhost"
if host.startswith("192.168."):
return "192.168.*"
if host.startswith("100.") and host.replace(".", "").isdigit():
return "100.*"
if host in {"github.com", "www.github.com"} and path.startswith(
"/googlecloudplatform"
):
return "github.com/GoogleCloudPlatform"
return None
def normalize_url(url: str) -> str:
"""Bez fragmentu #, bez trailing slash, schemat i host małymi literami."""
if "://" not in url:
url = "https://" + url
parts = urlsplit(url)
return urlunsplit(
(
parts.scheme.lower(),
parts.netloc.lower(),
parts.path.rstrip("/"),
parts.query,
"", # fragment odcięty
)
)
def label_for(url: str) -> str:
"""Hostname + skrócona ścieżka."""
parts = urlsplit(url)
host = parts.netloc
path = parts.path
if not path or path == "/":
return host
segment = path.rsplit("/", 1)[-1] or path
if len(segment) > 24:
segment = segment[:23] + ""
return f"{host}/…/{segment}" if path.count("/") > 1 else f"{host}/{segment}"
def node_id_for(url: str) -> str:
parts = urlsplit(url)
return "ext:" + (parts.netloc + parts.path).rstrip("/")
def _frontmatter(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
if not text.startswith("---\n"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
return yaml.safe_load(text[4:end]) or {}
def _raw_urls(body: str, frontmatter: dict) -> list[tuple[str, str]]:
"""Zwraca [(surowy_url, nazwa_źródła)] dla jednego konceptu."""
found: list[tuple[str, str]] = []
remaining = body
if "md-link" in SOURCES:
for m in _MD_LINK_RE.finditer(body):
found.append((m.group(1), "md-link"))
# Adresy złapane jako pełne URL-e wycinamy z tekstu, żeby nie policzyć
# ich powtórnie jako gołych domen.
remaining = _MD_LINK_RE.sub(" ", remaining)
if "bare-url" in SOURCES:
for m in _BARE_URL_RE.finditer(remaining):
found.append((m.group(0).rstrip(".,;:"), "bare-url"))
remaining = _BARE_URL_RE.sub(" ", remaining)
if "bare-domain" in SOURCES:
for m in _BARE_DOMAIN_RE.finditer(remaining):
found.append((m.group(1), "bare-domain"))
if "frontmatter" in SOURCES:
for field in FRONTMATTER_URL_FIELDS + FRONTMATTER_DOMAIN_FIELDS:
value = frontmatter.get(field)
if not value:
continue
values = value if isinstance(value, list) else [value]
for item in values:
if isinstance(item, str) and item.strip():
found.append((item.strip(), "frontmatter"))
return found
# Raport dla wypisu na końcu.
REPORT: dict[str, list] = {"kept": [], "filtered": []}
def _add_external(graph: dict, concepts: list) -> None:
kept: dict[str, dict] = {} # znormalizowany URL -> {origins, concepts}
filtered: list[tuple[str, str, str]] = []
for concept in concepts:
path = BUNDLE_ROOT / f"{concept.id}.md"
frontmatter = _frontmatter(path) if path.is_file() else {}
for raw, origin in _raw_urls(concept.body or "", frontmatter):
url = normalize_url(raw)
reason = _technical_reason(url)
if reason:
filtered.append((url, reason, concept.id))
continue
entry = kept.setdefault(url, {"origins": set(), "concepts": []})
entry["origins"].add(origin)
if concept.id not in entry["concepts"]:
entry["concepts"].append(concept.id)
concept_ids = {c.id for c in concepts}
seen_edges = set()
for url in sorted(kept):
entry = kept[url]
node_id = node_id_for(url)
graph["nodes"].append(
{
"data": {
"id": node_id,
"label": label_for(url),
"type": EXTERNAL_TYPE,
"description": url,
"resource": url, # panel renderuje to jako link _blank
"tags": [],
"status": EXTERNAL_TYPE,
"generated": {},
"verified": [],
"stale_after": "",
"sources": [],
"trust_tier": "unverified",
"stale": False,
"color": EXTERNAL_COLOR,
"size": EXTERNAL_SIZE,
}
}
)
for source_id in entry["concepts"]:
if source_id not in concept_ids:
continue
key = (source_id, node_id)
if key in seen_edges:
continue
seen_edges.add(key)
graph["edges"].append(
{
"data": {
"id": f"{source_id}__{node_id}",
"source": source_id,
"target": node_id,
}
}
)
REPORT["kept"].append(
(url, sorted(entry["origins"]), entry["concepts"])
)
REPORT["filtered"] = filtered
if kept:
graph["types"] = sorted(set(graph["types"]) | {EXTERNAL_TYPE})
graph["palette"] = {**graph.get("palette", {}), EXTERNAL_TYPE: EXTERNAL_COLOR}
MISSING_PAGES: list[str] = []
def _point_concepts_to_pages(graph: dict) -> None:
"""Węzeł koncept → karta HTML w pages/.
Panel szczegółów renderuje `resource` jako klikalny odnośnik
(viz.js:171-183), więc ustawienie tam ścieżki karty daje przejście
z grafu do pełnej treści bez żadnej zmiany w viewerze. Ścieżka jest
względna wobec viz.html, czyli działa i lokalnie, i po deployu.
Wywoływane przed _add_external — na tym etapie w grafie są wyłącznie
węzły konceptów, więc URL-e węzłów external nie są nadpisywane.
"""
for node in graph["nodes"]:
data = node["data"]
original = data.get("resource") or ""
if original:
# Koncept miał własny `resource` we frontmatterze — nie gubimy go,
# tylko przenosimy obok; `resource` węzła wskazuje kartę.
data["resource_frontmatter"] = original
page = f"{PAGES_DIRNAME}/{data['id']}.html"
data["resource"] = page
if not (BUNDLE_ROOT / page).is_file():
MISSING_PAGES.append(page)
# --- ingerencje w viewer ----------------------------------------------
#
# Dwie, obie w locie na stringach wstawianych do viz.html — repo upstream
# zostaje nietknięte: (1) reguła stylu dla węzłów "external" w viz.js,
# (2) dopisek „powered by” w nagłówku szablonu (viewer nie ma stopki).
#
# Kolor węzła jest sterowany danymi ("background-color": "data(color)"),
# więc odróżnienie kolorem nie wymaga tykania viewera. Kształt już tak:
# cytoscape'owa tablica stylów siedzi w viz.js. Dokładamy do niej DOKŁADNIE
# jedną regułę dla typu "external", analogicznie do istniejących reguł
# `node[?stale]` i `node[status = "deprecated"]` — ta sama konwencja
# selektora po polu danych. Reguła ląduje przed `node[?stale]`, czyli przed
# `node:selected`, więc podświetlenie zaznaczenia nadal wygrywa.
#
# Plik viz.js w repo upstream pozostaje nietknięty — podmiana dzieje się
# w locie, na stringu wstawianym do viz.html.
_EXTERNAL_STYLE_ANCHOR = ' {\n selector: "node[?stale]",'
_EXTERNAL_STYLE_RULE = """ {
// DODANE (gen_viz.py): węzły linków zewnętrznych. Inny kształt niż
// koncepty, żeby jedno i drugie dało się rozróżnić bez czytania
// etykiet i niezależnie od koloru.
selector: 'node[type = "external"]',
style: {
"shape": "round-diamond",
"border-style": "dotted",
"font-size": 10,
"color": "#0f766e",
},
},
"""
_upstream_load_asset = G._load_asset
def _load_asset(name: str) -> str:
"""Upstreamowy asset; do viz.js dokłada regułę stylu dla "external"."""
asset = _upstream_load_asset(name)
if name != "viz.js":
return asset
if _EXTERNAL_STYLE_ANCHOR not in asset:
raise SystemExit(
"gen_viz.py: nie znaleziono kotwicy stylu w viz.js — upstream "
"się zmienił, popraw _EXTERNAL_STYLE_ANCHOR przed regeneracją."
)
return asset.replace(
_EXTERNAL_STYLE_ANCHOR,
_EXTERNAL_STYLE_RULE + _EXTERNAL_STYLE_ANCHOR,
1,
)
G._load_asset = _load_asset
# Viewer nie ma stopki (main wypełnia okno), więc dopisek ląduje w nagłówku,
# obok istniejącego „OKF bundle” — ta sama klasa `.muted`, czyli ten sam mały
# wyciszony font co reszta metadanych paska.
_CREDIT_ANCHOR = ' <span class="muted">OKF bundle</span>\n'
_CREDIT_MARKUP = (
' <span class="muted">· powered by <a href="https://gethumanai.pl"'
' target="_blank" rel="noopener" style="color:inherit">gethumanai.pl</a>'
"</span>\n"
)
_upstream_load_template = G._load_template
def _load_template() -> str:
"""Upstreamowy szablon + dyskretny dopisek „powered by” w nagłówku."""
template = _upstream_load_template()
if _CREDIT_ANCHOR not in template:
raise SystemExit(
"gen_viz.py: nie znaleziono kotwicy nagłówka w templates/viz.html — "
"upstream się zmienił, popraw _CREDIT_ANCHOR przed regeneracją."
)
return template.replace(_CREDIT_ANCHOR, _CREDIT_ANCHOR + _CREDIT_MARKUP, 1)
G._load_template = _load_template
# --- łatki na generator upstreamowy -----------------------------------
_upstream_extract_links = G._extract_links
_upstream_walk_concepts = G._walk_concepts
_upstream_build_graph = G._build_graph
def _extract_links(body: str, doc_dir: Path, bundle_root: Path) -> list[str]:
"""Upstream + linki bundle-relative (§5.1), które upstream odrzuca."""
out = _upstream_extract_links(body, doc_dir, bundle_root)
seen = set(out)
for match in G._LINK_RE.finditer(body):
target = match.group(1)
if "://" in target or not target.startswith("/"):
continue
rel = target[1:]
if rel.endswith(".md"):
rel = rel[:-3]
if rel and rel not in seen:
seen.add(rel)
out.append(rel)
return out
def _walk_concepts(bundle_root: Path):
"""Upstream, ale bez plików zarezerwowanych (§3.1)."""
return [
c
for c in _upstream_walk_concepts(bundle_root)
if f"{c.id}.md".rsplit("/", 1)[-1] not in RESERVED
]
def _build_graph(concepts: list) -> dict:
"""Upstream + karty HTML konceptów + węzły/krawędzie linków zewnętrznych."""
graph = _upstream_build_graph(concepts)
_point_concepts_to_pages(graph)
_add_external(graph, concepts)
return graph
G._extract_links = _extract_links
G._walk_concepts = _walk_concepts
G._build_graph = _build_graph
def main() -> int:
out_path = BUNDLE_ROOT / "viz.html"
stats = G.generate_visualization(
BUNDLE_ROOT, out_path, bundle_name=BUNDLE_NAME
)
external = len(REPORT["kept"])
print(f"Zapisano {out_path}")
print(
f" węzły: {stats['concepts']} konceptów + {external} external, "
f"krawędzie: {stats['edges']}, rozmiar: {stats['bytes'] / 1024:.1f} KiB"
)
print(f" koncepty linkują do kart w {PAGES_DIRNAME}/ (pole resource)")
if MISSING_PAGES:
print(
f" UWAGA: brakuje {len(MISSING_PAGES)} kart — uruchom "
f"gen_pages.py: {', '.join(MISSING_PAGES)}"
)
print()
print(f"Linki zewnętrzne, które weszły do grafu ({external}):")
for url, origins, sources in REPORT["kept"]:
print(f" + {url}")
print(f" źródło: {', '.join(origins)} | z: {', '.join(sources)}")
print()
print(f"Odfiltrowane jako techniczne ({len(REPORT['filtered'])}):")
if REPORT["filtered"]:
for url, reason, concept in REPORT["filtered"]:
print(f" - {url} [{reason}] z: {concept}")
else:
print(" (żadnych — bundle nie zawiera adresów z domen technicznych)")
return 0
if __name__ == "__main__":
raise SystemExit(main())