#!/usr/bin/env python3
"""Renderuje karty HTML konceptów bundle'a do katalogu pages/.
Wejściem dla człowieka jest `pages/start.html` (landing decyzyjny), a nie graf:
graf to załącznik pokazujący strukturę powiązań. Karty pokazują treść — ceny,
dostępność, plusy/minusy, listy do zrobienia. Węzeł w grafie linkuje do karty
przez pole `resource` (patrz gen_viz.py).
Poza kartą na koncept skrypt generuje trzy strony zbiorcze:
pages/index.html spis wszystkich kart, pogrupowany katalogami
pages/porownanie.html jedna tabela WSZYSTKICH aktualnych ofert (nie
regionów), sortowalna klikiem, złożona z samych
frontmatterów; na górze podsumowanie per region
z `wyjazd.md`, na dole sekcja „Archiwum” z kartami
`priorytet: ARCHIWALNA` (riscercz na poprzedni termin)
pages/start.html landing: trzy kafle (porównanie / analiza / graf)
plus linki do kart czterech regionów
`start.html` jest jedyną stroną z linkami root-absolutnymi (`/viz.html`,
`/pages/...`), bo ten sam plik jest serwowany pod dwiema ścieżkami: jako
`/index.html` (korzeń serwisu) i jako `/pages/start.html`. Ścieżki względne
działałyby tylko pod jedną z nich. Wymaga to serwowania bundle'a z korzenia
domeny — lokalnie `python3 -m http.server` w katalogu bundle'a, na PIHA
korzeń wolumenu (komenda deployu na końcu wypisu skryptu).
Renderer markdown: używa `python-markdown`, jeśli jest zainstalowany
(rozszerzenia tables + fenced_code + sane_lists); w przeciwnym razie własnego
renderera na samej bibliotece standardowej — obsługuje nagłówki, akapity,
tabele GFM, listy (w tym `- [ ]`), cytaty, bloki kodu, linie poziome oraz
inline: **bold**, *kursywa*, `kod`, [linki](...). Który tor zadziałał, skrypt
wypisuje przy uruchomieniu.
Pliki .md NIE są modyfikowane — to czysty generator, tak samo jak gen_viz.py.
Pliki zarezerwowane (index.md, log.md) nie są konceptami (§3.1) i nie
dostają kart; linki do nich prowadzą do pages/index.html.
Uruchomienie: python3 gen_pages.py
"""
from __future__ import annotations
import html
import io
import posixpath
import re
import shutil
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
import yaml
BUNDLE_ROOT = Path(__file__).resolve().parent
PAGES_DIRNAME = "pages"
PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME
BUNDLE_NAME = "Ski 2027 — wyjazd 6–13.02.2027"
# Termin wyjazdu w jednym miejscu: leci do nagłówków stron i do raportu
# braków. Zmiana terminu = zmiana tych dwóch stałych i regeneracja.
TERM_LABEL = "6–13.02.2027"
TERM_SHORT = "6–13.02" # nagłówek kolumny w tabeli porównawczej
TERM_NOTE = "tydzień Fasching, szczyt sezonu"
RESERVED = {"index.md", "log.md"}
# Pobieralna paczka źródeł dla AI (patrz sekcja niżej): same .md bundle'a
# plus wygenerowany AI-README.md, bez stron, zdjęć i generatorów.
ZIP_NAME = "ski-2027-kb.zip"
ZIP_ROOT = "ski-2027-kb" # katalog wewnątrz archiwum, żeby się nie rozsypało
AI_README = "AI-README.md"
# Stały czas modyfikacji wpisów: archiwum ma być bajt w bajt identyczne,
# dopóki nie zmieni się treść — inaczej każda regeneracja robiłaby gitowi
# diff z niczego. 1980-01-01 to minimum formatu ZIP.
ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
# Publiczny adres wystawki — jedyne miejsce, w którym siedzi domena. Używa go
# WYŁĄCZNIE pages/linki.html (spis do kopiowania i wysyłania grupie); wszystkie
# pozostałe strony linkują względnie albo root-absolutnie, żeby działały tak
# samo lokalnie (python3 -m http.server) i po deployu. Zmiana domeny = zmiana
# tej jednej stałej i regeneracja.
BASE_URL = "https://narty27.kapala.org"
try: # renderer preferowany, jeśli dostępny w systemie
import markdown as _markdown_lib
except ModuleNotFoundError:
_markdown_lib = None
RENDERER = "python-markdown" if _markdown_lib else "fallback (biblioteka standardowa)"
# --- model ------------------------------------------------------------
@dataclass
class Concept:
id: str # np. "oferty/glemm-lodge"
path: Path
frontmatter: dict
body: str
@property
def page(self) -> str:
"""Ścieżka karty względem katalogu pages/."""
return f"{self.id}.html"
@property
def title(self) -> str:
return str(self.frontmatter.get("title") or self.id)
def load_concepts() -> list[Concept]:
concepts: list[Concept] = []
for path in sorted(BUNDLE_ROOT.rglob("*.md")):
if ".git" in path.parts or path.name in RESERVED:
continue
text = path.read_text(encoding="utf-8")
frontmatter: dict = {}
body = text
if text.startswith("---\n"):
end = text.find("\n---", 3)
if end != -1:
frontmatter = yaml.safe_load(text[4:end]) or {}
body = text[end + 4 :]
concepts.append(
Concept(
id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(),
path=path,
frontmatter=frontmatter,
body=body.lstrip("\n"),
)
)
return concepts
# --- linki ------------------------------------------------------------
_LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+\.md)((?:#[^)\s]*)?)\)")
def rewrite_links(body: str, source: Concept, pages: dict[str, str]) -> str:
"""Linki do plików .md → względne ścieżki do kart. External bez zmian."""
here = posixpath.dirname(source.page)
def repl(match: re.Match) -> str:
target, fragment = match.group(1), match.group(2)
if "://" in target:
return match.group(0)
if target.startswith("/"):
rel = target[1:]
else:
rel = posixpath.normpath(posixpath.join(posixpath.dirname(source.id), target))
concept_id = rel[:-3] if rel.endswith(".md") else rel
if concept_id in pages:
dest = pages[concept_id]
elif posixpath.basename(rel) in RESERVED:
dest = "index.html" # index.md / log.md nie mają kart
else:
return match.group(0)
return "](" + posixpath.relpath(dest, here or ".") + fragment + ")"
return _LINK_TARGET_RE.sub(repl, body)
# --- renderer markdown: fallback na stdlib -----------------------------
_CODE_RE = re.compile(r"`([^`]+)`")
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
_ITALIC_STAR_RE = re.compile(r"(? str:
codes: list[str] = []
def stash(match: re.Match) -> str:
codes.append(match.group(1))
return f"\x00c{len(codes) - 1}\x00"
text = _CODE_RE.sub(stash, text)
text = html.escape(text, quote=False)
def link(match: re.Match) -> str:
label, href = match.group(1), match.group(2)
attrs = ""
if "://" in href or href.startswith("mailto:"):
attrs = ' target="_blank" rel="noopener" class="ext"'
return f'{label}'
text = _LINK_RE.sub(link, text)
text = _BOLD_RE.sub(r"\1", text)
text = _ITALIC_STAR_RE.sub(r"\1", text)
text = _ITALIC_UNDER_RE.sub(r"\1", text)
for i, code in enumerate(codes):
text = text.replace(f"\x00c{i}\x00", f"{html.escape(code, quote=False)}")
return text
def _alignments(sep: str) -> list[str]:
out = []
for cell in _cells(sep):
left, right = cell.startswith(":"), cell.endswith(":")
out.append("center" if left and right else "right" if right else "left")
return out
def _cells(row: str) -> list[str]:
return [c.strip() for c in row.strip().strip("|").split("|")]
def _starts_block(line: str) -> bool:
s = line.strip()
return (
not s
or bool(_HEADING_RE.match(s))
or bool(_HR_RE.match(s))
or s.startswith(("```", ">"))
or bool(_LIST_RE.match(line))
)
def render_blocks(text: str) -> str:
lines = text.split("\n")
out: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
if stripped.startswith("```"):
i += 1
code: list[str] = []
while i < len(lines) and not lines[i].strip().startswith("```"):
code.append(lines[i])
i += 1
i += 1
out.append(f"
{html.escape(chr(10).join(code), quote=False)}
")
continue
heading = _HEADING_RE.match(stripped)
if heading:
level = len(heading.group(1))
out.append(f"{render_inline(heading.group(2))}")
i += 1
continue
if _HR_RE.match(stripped):
out.append("")
i += 1
continue
if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
header = _cells(stripped)
align = _alignments(lines[i + 1])
i += 2
rows = []
while i < len(lines) and "|" in lines[i] and lines[i].strip():
rows.append(_cells(lines[i]))
i += 1
head = "".join(
f'
'
f"{render_inline(c)}
"
for n, c in enumerate(header)
)
body = "".join(
"
"
+ "".join(
f'
'
f"{render_inline(c)}
"
for n, c in enumerate(row)
)
+ "
"
for row in rows
)
out.append(
f'
{head}
'
f"{body}
"
)
continue
if stripped.startswith(">"):
quoted = []
while i < len(lines) and lines[i].strip().startswith(">"):
quoted.append(lines[i].strip()[1:].lstrip())
i += 1
out.append(f"
{render_blocks(chr(10).join(quoted))}
")
continue
item = _LIST_RE.match(line)
if item:
ordered = item.group(1)[0].isdigit()
items: list[str] = []
while i < len(lines):
current = lines[i]
match = _LIST_RE.match(current)
if match:
items.append(match.group(2))
i += 1
elif current.startswith((" ", "\t")) and current.strip() and items:
items[-1] += " " + current.strip()
i += 1
else:
break
rendered = []
for raw in items:
task = _TASK_RE.match(raw)
if task:
checked = " checked" if task.group(1).lower() == "x" else ""
rendered.append(
f'
'
f"{render_inline(task.group(2))}
"
)
else:
rendered.append(f"
{render_inline(raw)}
")
tag = "ol" if ordered else "ul"
out.append(f"<{tag}>{''.join(rendered)}{tag}>")
continue
paragraph = [stripped]
i += 1
while i < len(lines) and not _starts_block(lines[i]):
if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
break
paragraph.append(lines[i].strip())
i += 1
out.append(f"
'
""
)
_URLISH_RE = re.compile(
r"^(https?://\S+|(?:[a-z0-9][a-z0-9-]*\.)+(?:at|com|pl|de|eu|net|org|io|info))$"
)
def _link_value(value: str) -> str:
"""Wartość frontmattera; adresy robi klikalnymi, resztę tylko escape'uje."""
escaped = html.escape(value, quote=False)
if _URLISH_RE.match(value.strip()):
href = value.strip()
if "://" not in href:
href = "https://" + href
return f'{escaped}'
if value.strip().startswith("mailto:") or "@" in value and " " not in value.strip():
return f'{escaped}'
return escaped
def render_page(concept: Concept, pages: dict[str, str]) -> str:
body = rewrite_links(concept.body, concept, pages)
rendered = md_to_html(body)
# Pierwszy nagłówek H1 body dubluje tytuł karty — awansujemy go na
# strony zamiast dokładać drugi. Sam `title` i tak jest w tabelce metadanych.
match = re.match(r"\s*
(.*?)
", rendered, re.S)
if match:
headline = match.group(1)
rendered = rendered[match.end() :]
else:
headline = html.escape(concept.title)
frontmatter = concept.frontmatter
chip = html.escape(str(frontmatter.get("type") or "—"))
description = str(frontmatter.get("description") or "")
lead = f'
{html.escape(description)}
' if description else ""
images = offer_images(concept) if is_offer(concept) else []
gallery = render_gallery(images, str(frontmatter.get("nazwa") or concept.title))
content = (
f'{chip}\n'
f"
{headline}
\n{lead}\n{gallery}\n"
f"{_meta_table(frontmatter)}\n{rendered}"
)
return _page_shell(
concept.title,
concept.id.count("/"),
content,
script=GALLERY_JS if len(images) > 1 else "",
)
_INDEX_HEADING_RE = re.compile(r"^#\s+(.+)$", re.M)
def group_heading(dirname: str) -> str:
"""Nagłówek grupy = pierwszy nagłówek `index.md` katalogu (§8).
Indexy podkatalogów same nazywają swoją zawartość, więc karta zbiorcza
nie musi mieć zaszytej listy katalogów — dorzucenie nowego regionu do
bundle'a wystarczy, żeby pojawił się tu z własną nazwą.
"""
index = BUNDLE_ROOT / dirname / "index.md"
if index.is_file():
match = _INDEX_HEADING_RE.search(index.read_text(encoding="utf-8"))
if match:
return match.group(1).strip()
return dirname
def render_index(concepts: list[Concept]) -> str:
"""Karta zbiorcza: koncepty z korzenia, potem grupa na katalog najwyższego
poziomu. Każdy koncept bundle'a ląduje w dokładnie jednej grupie."""
by_dir: dict[str, list[Concept]] = {}
for concept in concepts:
if "/" in concept.id:
by_dir.setdefault(concept.id.split("/", 1)[0], []).append(concept)
groups: list[tuple[str, list[Concept]]] = [
("Wyjazd i wiedza ogólna", [c for c in concepts if "/" not in c.id])
]
for dirname in sorted(by_dir):
# region.md przed oferty/* — płycej w drzewie znaczy ogólniej.
items = sorted(by_dir[dirname], key=lambda c: (c.id.count("/"), c.id))
groups.append((group_heading(dirname), items))
sections = []
for heading, items in groups:
if not items:
continue
cards = []
for concept in items:
description = str(concept.frontmatter.get("description") or "")
status = concept.frontmatter.get("status_ceny")
badge = (
f'{html.escape(str(status).split("—")[0].strip())} '
if status
else ""
)
cards.append(
f'
Karty konceptów bundle\'a — pełna treść każdego dokumentu '
"(ceny, dostępność, plusy/minusy). Struktura powiązań: "
'graf.
\n' + "\n".join(sections)
)
return _page_shell("Karty", 0, content)
# --- zdjęcia obiektów (pages/img//, patrz gen_images.py) -----------
IMG_DIRNAME = "img"
IMG_DIR = PAGES_DIR / IMG_DIRNAME
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
def offer_image_paths(concept: Concept) -> list[str]:
"""Zdjęcia oferty jako ścieżki względne wobec katalogu `pages/`.
Mapowanie id → zdjęcia jest konwencją katalogową (pages/img//NN.jpg),
nie polem frontmattera — pliki .md zostają nietknięte.
"""
offer_id = str(concept.frontmatter.get("id") or "").strip()
if not offer_id:
return []
directory = IMG_DIR / offer_id
if not directory.is_dir():
return []
files = sorted(p for p in directory.iterdir() if p.suffix.lower() in IMG_EXTS)
return [f"{IMG_DIRNAME}/{offer_id}/{p.name}" for p in files]
def offer_images(concept: Concept) -> list[str]:
"""To samo, ale względem katalogu karty konceptu."""
up = "../" * concept.id.count("/")
return [up + path for path in offer_image_paths(concept)]
def initials(name: str) -> str:
"""Inicjały obiektu — placeholder dla ofert bez zdjęć."""
words = [w for w in re.split(r"[\s/–—-]+", name) if w and w[0].isalnum()]
return "".join(w[0].upper() for w in words[:2]) or "?"
GALLERY_JS = """
document.querySelectorAll('.gallery').forEach(function (gallery) {
var big = gallery.querySelector('.gallery-main img');
gallery.querySelectorAll('.gallery-thumbs button').forEach(function (button) {
button.addEventListener('click', function () {
var img = button.querySelector('img');
big.src = img.dataset.full || img.src;
gallery.querySelectorAll('.gallery-thumbs button').forEach(function (b) {
b.removeAttribute('aria-current');
});
button.setAttribute('aria-current', 'true');
});
});
});
"""
def render_gallery(images: list[str], alt: str) -> str:
if not images:
return ""
escaped_alt = html.escape(alt, quote=True)
first = html.escape(images[0], quote=True)
thumbs = ""
if len(images) > 1:
buttons = "".join(
f''
for n, src in enumerate(images)
)
thumbs = f'
{buttons}
'
return (
''
f'
'
f"{thumbs}"
)
# --- podsumowanie per region (z wyjazd.md) ----------------------------
TRIP_FILE = "wyjazd.md"
BUDGET_ROW = "budżet grupy razem"
FALLBACK_ROW = "charakter"
_TABLE_LINK_RE = re.compile(r"\[([^\]]+)\]\(/([^)\s]+\.md)\)")
_EMPHASIS_RE = re.compile(r"\*\*|\*|`")
@dataclass
class Region:
concept_id: str # np. "ischgl/region"
dirname: str # np. "ischgl" ("" dla Saalbach z korzenia)
label: str # np. "Ischgl/Paznaun"
budget: str = ""
conclusion: str = ""
def _plain(cell: str) -> str:
"""Komórka tabeli markdown → czysty tekst (linki na same etykiety)."""
text = _LINK_RE.sub(r"\1", cell)
return _EMPHASIS_RE.sub("", text).strip()
def parse_regions() -> list[Region]:
"""Regiony + budżet całkowity + jeden wniosek — z tabeli w `wyjazd.md`.
Nic nie jest tu zaszyte na sztywno: kolumny bierzemy z nagłówka tabeli
porównawczej (te, które linkują do `*/region.md`), budżet z wiersza
„Budżet grupy razem”, a wniosek z pierwszej pozycji „Wniosków
przekrojowych”, która dany region wymienia. Dorzucenie piątego wariantu
do `wyjazd.md` wystarczy, żeby pojawił się na stronach.
"""
path = BUNDLE_ROOT / TRIP_FILE
if not path.is_file():
return []
text = path.read_text(encoding="utf-8")
regions: list[Region] = []
rows: dict[str, list[str]] = {}
lines = text.split("\n")
for n, line in enumerate(lines):
if not line.strip().startswith("|"):
continue
header = _cells(line)
targets = [_TABLE_LINK_RE.search(c) for c in header[1:]]
if sum(1 for t in targets if t and t.group(2).endswith("region.md")) < 2:
continue
if n + 1 >= len(lines) or not _TABLE_SEP_RE.match(lines[n + 1]):
continue
for match in targets:
if not match or not match.group(2).endswith("region.md"):
continue
concept_id = match.group(2)[: -len(".md")]
regions.append(
Region(
concept_id=concept_id,
dirname=posixpath.dirname(concept_id),
label=_plain(match.group(1)),
)
)
for row in lines[n + 2 :]:
if not row.strip().startswith("|"):
break
cells = _cells(row)
rows[_plain(cells[0]).lower()] = [_plain(c) for c in cells[1:]]
break
conclusions = parse_conclusions(text)
for index, region in enumerate(regions):
for key in (BUDGET_ROW, FALLBACK_ROW):
values = rows.get(key) or []
if index < len(values) and values[index]:
if key == BUDGET_ROW:
region.budget = values[index]
elif not region.conclusion:
region.conclusion = values[index]
keyword = region.dirname or region.label.split()[0]
for raw in conclusions:
if keyword.lower() in raw.lower():
region.conclusion = _plain(raw.split(". ")[0]).rstrip(".") + "."
break
return regions
_ORDERED_ITEM_RE = re.compile(r"^\s*\d+[.)]\s+(.*)$")
def parse_conclusions(text: str) -> list[str]:
"""Pozycje listy numerowanej z sekcji „Wnioski przekrojowe”."""
section = re.split(r"^#{2,4}\s+Wnioski przekrojowe\s*$", text, flags=re.M)
if len(section) < 2:
return []
items: list[str] = []
for line in section[1].split("\n"):
if line.startswith("#"):
break
match = _ORDERED_ITEM_RE.match(line)
if match:
items.append(match.group(1).strip())
elif line.startswith((" ", "\t")) and line.strip() and items:
items[-1] += " " + line.strip()
return items
# --- karta porównania ofert -------------------------------------------
# (nagłówek, pole frontmattera / klucz specjalny, czy sortować liczbowo)
COLUMNS: list[tuple[str, str, bool]] = [
("Oferta", "_nazwa", False),
(f"Dostępność {TERM_SHORT}", "_dostepnosc", False),
("Priorytet", "priorytet", False),
("Region", "_region", False),
("Miejscowość", "miejscowosc", False),
("€ / apart. / tydzień", "cena_za_apartament_tydzien", True),
("Rodzina 2+2", "koszt_rodzina_2p2", True),
("Rodzina 2+1", "koszt_rodzina_2p1", True),
("Status ceny", "status_ceny", False),
("Od wyciągu", "odleglosc_od_wyciagu", False),
("WWW", "www", False),
]
_DIGIT_GAP_RE = re.compile(r"(?<=\d)[\s ](?=\d)")
_NUMBER_RE = re.compile(r"\d+(?:[.,]\d+)?")
def sort_number(value: str) -> float | None:
"""Pierwsza liczba w tekście, ze spacją jako separatorem tysięcy."""
match = _NUMBER_RE.search(_DIGIT_GAP_RE.sub("", value))
return float(match.group(0).replace(",", ".")) if match else None
def is_offer(concept: Concept) -> bool:
return str(concept.frontmatter.get("type", "")).startswith("oferta")
# --- dostępność terminu (pole `dostepnosc_6_13_02_2027`) ---------------
AVAILABILITY_FIELD = "dostepnosc_6_13_02_2027"
# (emoji z legendy, skrót do komórki, klucz sortowania). Kolejność kluczy
# układa kolumnę decyzyjnie: potwierdzone wolne → do sprawdzenia → brak.
AVAILABILITY_MARKS = (
("✅", "✅ DOSTĘPNE", "1"),
("🔍", "🔍 do sprawdzenia", "2"),
("❌", "❌ BRAK (zweryf.)", "3"),
)
def availability(concept: Concept) -> str:
return str(concept.frontmatter.get(AVAILABILITY_FIELD) or "").strip()
def availability_mark(value: str) -> tuple[str, str, str] | None:
for mark in AVAILABILITY_MARKS:
if value.startswith(mark[0]):
return mark
return None
def _availability_cell(value: str) -> str:
"""Komórka dostępności: emoji + jedno słowo, pełna treść w `title`.
Wartości z frontmattera są zdaniami („🔍 BRAK DANYCH — silnik online
pokaże od ręki”), a tabela ma się skanować wzrokiem — więc w komórce
zostaje sam werdykt, a uzasadnienie czeka pod kursorem.
"""
if not value:
return '
—
'
mark = availability_mark(value)
if not mark:
return _cell(value, False)
_, short, key = mark
return (
f'
{html.escape(short)}
'
)
def is_archived(concept: Concept) -> bool:
"""Karty z riserczu na poprzedni termin — poza tabelą i statusami."""
return str(concept.frontmatter.get("priorytet") or "").upper().startswith(
"ARCHIWALNA"
)
def _shorten(value: str) -> str:
"""Sam rdzeń wartości; doprecyzowanie w nawiasie/po myślniku idzie do title.
„SZACUNEK — silnik online na alpenparks.at” w komórce rozpycha tabelę na
trzy wiersze, a i tak koduje ją kolor wiersza. Pełna treść zostaje pod
kursorem, nic nie ginie.
"""
for separator in (" — ", " (", " - ", " dla "):
head = value.split(separator)[0].strip()
if head and head != value:
return head
return value
# Kolumny, w których doprecyzowanie JEST treścią — skracanie zabrałoby sens.
NO_SHORTEN = {"odleglosc_od_wyciagu"}
def _cell(value: str, numeric: bool, suffix: str = "", short: bool = True) -> str:
attrs = ""
if not value:
return '
—
'
if numeric:
number = sort_number(value)
if number is not None:
attrs += f' data-num="{number:g}"'
else:
attrs += f' data-key="{html.escape(value, quote=True)}"'
shown = _shorten(value) if short else value
if shown != value:
attrs += f' title="{html.escape(value, quote=True)}"'
return f"
{html.escape(shown + suffix)}
"
def _offer_rows(offers: list[Concept], by_dir: dict[str, Region]) -> str:
"""Wiersze tabeli porównawczej dla podanych ofert."""
rows = []
for concept in offers:
frontmatter = concept.frontmatter
status = str(frontmatter.get("status_ceny") or "")
upper = status.upper()
classes = [
"verified" if "ZWERYFIKOWANE" in upper
else "estimate" if "SZACUNEK" in upper
else ""
]
# Zweryfikowany brak miejsc to najmocniejszy komunikat w wierszu —
# ważniejszy niż to, czy cena jest pewna.
if availability(concept).startswith("❌"):
classes.append("unavailable")
dirname = posixpath.dirname(posixpath.dirname(concept.id))
region = by_dir.get(dirname)
region_label = region.label if region else (dirname or "—")
href = html.escape(posixpath.relpath(concept.page, "."), quote=True)
images = offer_image_paths(concept) # porownanie.html leży w pages/
name = str(frontmatter.get("nazwa") or concept.title)
if images:
thumb = (
f''
)
else:
thumb = (
f''
f"{html.escape(initials(name))}"
)
cells = [f'
{thumb}
']
for _, field, numeric in COLUMNS:
if field == "_nazwa":
cells.append(
f'
'
)
elif field == "_region":
page = html.escape(f"{region.concept_id}.html", quote=True) if region else ""
label = html.escape(region_label)
shown = f'{label}' if region else label
cells.append(
f'
{shown}
'
)
elif field == "_dostepnosc":
cells.append(_availability_cell(availability(concept)))
elif field == "www":
url = str(frontmatter.get("www") or "").strip()
if url:
host = re.sub(r"^https?://(www\.)?", "", url).split("/")[0]
cells.append(
f'
')
else:
value = str(frontmatter.get(field) or "")
suffix = ""
if not value and field == "cena_za_apartament_tydzien":
# Dom dla całej grupy wyceniany jest w całości — pokazujemy
# to zamiast pustej komórki, ale z etykietą „za co”.
value = str(frontmatter.get("cena_za_calosc_tydzien") or "")
suffix = " (cały dom)" if value else ""
cells.append(
_cell(value, numeric, suffix, short=field not in NO_SHORTEN)
)
css_row = " ".join(c for c in classes if c)
klass = f' class="{css_row}"' if css_row else ""
rows.append(f"
"
)
def _by_price(concept: Concept) -> tuple[float, str]:
# Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie,
# które grupa zadaje. Resztę porządków daje sortowanie klikiem.
return (
sort_number(str(concept.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9,
concept.title,
)
def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
"""Jedna tabela wszystkich aktualnych ofert bundle'a, prosto z frontmatterów.
Karty archiwalne (riscercz na poprzedni termin) idą do osobnej tabeli
na dole strony — nie mieszają się do porównania ani do statusów
dostępności, ale nie znikają z bazy.
"""
by_dir = {r.dirname: r for r in regions}
offers = sorted((c for c in concepts if is_offer(c)), key=_by_price)
active = [c for c in offers if not is_archived(c)]
archived = [c for c in offers if is_archived(c)]
table = _comparison_table(active, by_dir)
counts: dict[str, int] = {}
for concept in active:
mark = availability_mark(availability(concept))
counts[mark[1] if mark else "—"] = counts.get(mark[1] if mark else "—", 0) + 1
tally = " · ".join(f"{label}: {n}" for label, n in sorted(counts.items()))
archive = ""
if archived:
archive = (
'\n'
"
Archiwum — poza paczką riserczową 08.2026
\n"
f'
Karty z riserczu na poprzedni termin '
f"(16–23.01.2027). Ceny i dostępność nie były weryfikowane na "
f"{html.escape(TERM_LABEL)} — nie wchodzą do porównania wyżej ani "
"do statusów dostępności. Zostają, bo tropy są nadal dobre.
\n"
f"{_comparison_table(archived, by_dir)}\n"
""
)
summary = ""
if regions:
items = []
for region in regions:
budget = (
f'Budżet grupy: '
f"{html.escape(region.budget)}"
if region.budget
else ""
)
conclusion = (
f"
Wszystkie {len(active)} rozważanych noclegów na termin '
f"{html.escape(TERM_LABEL)} ({html.escape(TERM_NOTE)}) "
"w jednej tabeli — dane prosto z frontmatterów kart. Kliknij nagłówek, "
"żeby posortować; domyślnie od najtańszej rodziny 2+2.
\n"
f"{summary}\n"
"
Wszystkie oferty
\n"
f"{table}\n"
'
cena zweryfikowana '
' szacunek do potwierdzenia '
' zweryfikowany brak miejsc '
f"Dostępność {html.escape(TERM_SHORT)}: ✅ DOSTĘPNE (zweryfikowane) · "
"🔍 do sprawdzenia (obiekt nie publikuje kalendarza) · "
"❌ BRAK (zweryfikowane). "
f"Bilans: {html.escape(tally)}. Najedź na komórkę, żeby zobaczyć "
"pełną notatkę.
\n"
f"{archive}"
)
return _page_shell(
"Porównanie ofert", 0, content, body_class="wide", script=SORT_JS
)
# --- landing decyzyjny ------------------------------------------------
# start.html jest serwowany i jako /index.html, i jako /pages/start.html —
# tylko ścieżki root-absolutne działają pod obiema (patrz docstring modułu).
TILES = [
("/pages/porownanie.html", "Porównanie ofert",
"Wszystkie noclegi w jednej sortowalnej tabeli: dostępność na "
f"{TERM_SHORT}, ceny, status ceny, priorytet, odległość od wyciągu."),
("/pages/wyjazd.html", "Analiza wariantów",
"Cztery regiony obok siebie: skipassy, dojazd, budżet całkowity grupy "
"i wnioski przekrojowe."),
("/viz.html", "Graf powiązań",
"Struktura bazy wiedzy — koncepty, linki między nimi i adresy "
"zewnętrzne. Załącznik, nie punkt wejścia."),
("/pages/linki.html", "Wszystkie linki",
"Pełne adresy wszystkich stron wystawki, z przyciskiem „kopiuj” — "
"do wrzucenia grupie."),
(f"/{PAGES_DIRNAME}/{ZIP_NAME}", "Pobierz bazę (.zip) — wrzuć do ChatGPT/Claude'a i pytaj",
"Wszystkie źródła wiedzy w markdownie, bez stron i zdjęć — model "
"przeczyta całość i odpowie na pytania o wyjazd."),
]
def render_start(concepts: list[Concept], regions: list[Region]) -> str:
tiles = "".join(
f'
"
for href, title, description in TILES
)
by_id = {c.id: c for c in concepts}
cards = []
for region in regions:
concept = by_id.get(region.concept_id)
description = region.budget or (
str(concept.frontmatter.get("description") or "") if concept else ""
)
style = ""
hero = region_hero(concepts, region)
if hero:
style = (
' class="hero" style="background-image:linear-gradient('
"rgba(15,23,42,.62),rgba(15,23,42,.72)),url('"
f"/pages/{html.escape(hero, quote=True)}')\""
)
label = html.escape(region.label)
budget = (
f"Budżet grupy: {html.escape(region.budget)}"
if region.budget
else f"{html.escape(description)}"
)
cards.append(
f'
{html.escape(TERM_LABEL)} '
f"({html.escape(TERM_NOTE)}), 4 rodziny / 14 osób, wyjazd z Warszawy. "
"Od czego zacząć:
\n"
f'
{tiles}
\n'
"
Regiony
\n"
'
Karta regionu = skipassy, dojazd, terminy, koszty '
"życia i budżet całkowity.
\n"
f'
{"".join(cards)}
'
)
nav = (
'Wszystkie karty · '
'Porównanie ofert · '
'Linki · '
'Graf'
)
return _page_shell("Start", 0, content, nav=nav)
def region_hero(concepts: list[Concept], region: Region) -> str:
"""Pierwsze zdjęcie najlepszej oferty regionu (priorytet PLAN A)."""
offers = [
c
for c in concepts
if is_offer(c) and posixpath.dirname(posixpath.dirname(c.id)) == region.dirname
]
def rank(concept: Concept) -> tuple[int, str]:
priority = str(concept.frontmatter.get("priorytet") or "").upper()
for n, marker in enumerate(("PLAN A", "PLAN B", "PLAN C")):
if priority.startswith(marker):
return (n, concept.id)
return (9, concept.id)
for concept in sorted(offers, key=rank):
images = offer_image_paths(concept)
if images:
return images[0]
return ""
# --- spis linków do wysłania ------------------------------------------
@dataclass
class Link:
url: str
label: str
description: str = ""
def site_url(path: str) -> str:
"""Ścieżka w bundle'u → pełny adres publiczny."""
return f"{BASE_URL}/{path.lstrip('/')}"
def _describe(concept: Concept | None, limit: int = 110) -> str:
"""Opis konceptu przycięty do jednej linijki — bez skracania po separatorze,
bo w opisach nawias i myślnik niosą treść (inaczej niż w polach tabeli)."""
if not concept:
return ""
text = str(concept.frontmatter.get("description") or "")
if len(text) > limit:
text = text[: limit - 1].rsplit(" ", 1)[0] + "…"
return text
def link_sections(
concepts: list[Concept], regions: list[Region]
) -> list[tuple[str, list[Link]]]:
"""Spis wszystkich stron wystawki — z tych samych danych co reszta stron.
Nic tu nie jest wypisane ręcznie: regiony biorą się z `wyjazd.md`, oferty
z katalogów i frontmatterów, a „Pozostałe” to po prostu koncepty, których
nie złapała żadna wcześniejsza sekcja. Nowa oferta czy piąty wariant
pojawiają się na liście przy najbliższej regeneracji, same z siebie.
"""
by_id = {c.id: c for c in concepts}
used: set[str] = set()
entry = [
Link(site_url(""), "Start", "Landing decyzyjny — od tego zaczyna grupa."),
Link(
site_url("pages/porownanie.html"),
"Porównanie ofert",
"Wszystkie noclegi w jednej sortowalnej tabeli, ze zdjęciami.",
),
Link(
site_url("pages/wyjazd.html"),
"Analiza wariantów",
_describe(by_id.get("wyjazd")) or "Cztery regiony obok siebie.",
),
Link(
site_url("pages/index.html"),
"Spis wszystkich kart",
"Każdy dokument bazy wiedzy w jednym miejscu.",
),
Link(site_url("viz.html"), "Graf powiązań", "Struktura bazy — załącznik."),
Link(site_url("pages/linki.html"), "Ten spis linków", "Strona, którą czytasz."),
Link(
site_url(f"{PAGES_DIRNAME}/{ZIP_NAME}"),
"Pobierz bazę (.zip) — wrzuć do ChatGPT/Claude'a i pytaj",
"Wszystkie źródła wiedzy w markdownie, bez stron i zdjęć — model "
"przeczyta całość i odpowie na pytania o wyjazd.",
),
]
used.add("wyjazd")
sections: list[tuple[str, list[Link]]] = [("Wejście", entry)]
region_links = []
for region in regions:
concept = by_id.get(region.concept_id)
if not concept:
continue
used.add(region.concept_id)
budget = f"Budżet grupy {region.budget}. " if region.budget else ""
region_links.append(
Link(
site_url(f"{PAGES_DIRNAME}/{concept.page}"),
region.label,
budget + _describe(concept, 90),
)
)
if region_links:
sections.append(("Regiony", region_links))
for region in regions:
offers = [
c
for c in concepts
if is_offer(c)
and posixpath.dirname(posixpath.dirname(c.id)) == region.dirname
]
if not offers:
continue
links = []
for concept in sorted(offers, key=_priority_rank):
used.add(concept.id)
priority = _shorten(str(concept.frontmatter.get("priorytet") or ""))
place = str(concept.frontmatter.get("miejscowosc") or "")
links.append(
Link(
site_url(f"{PAGES_DIRNAME}/{concept.page}"),
str(concept.frontmatter.get("nazwa") or concept.title),
" · ".join(part for part in (priority, place) if part),
)
)
sections.append((f"Oferty — {region.label}", links))
rest = [
Link(
site_url(f"{PAGES_DIRNAME}/{c.page}"),
c.title,
_describe(c),
)
for c in concepts
if c.id not in used
]
rest.append(
Link(
site_url(f"{PAGES_DIRNAME}/start.html"),
"Start pod własną ścieżką",
"Ten sam landing co adres główny — przydaje się w zakładkach.",
)
)
sections.append(("Pozostałe", rest))
return sections
def _priority_rank(concept: Concept) -> tuple[int, str]:
priority = str(concept.frontmatter.get("priorytet") or "").upper()
for n, marker in enumerate(("PLAN A", "PLAN B", "PLAN C")):
if priority.startswith(marker):
return (n, concept.title)
return (9, concept.title)
LINKS_JS = """
function collectAll() {
var lines = [];
document.querySelectorAll('section[data-section]').forEach(function (section) {
lines.push(section.dataset.section + ':');
section.querySelectorAll('.url').forEach(function (code) {
lines.push(code.textContent.trim());
});
lines.push('');
});
return lines.join('\\n').trim() + '\\n';
}
function say(message) {
var status = document.querySelector('.copy-status');
status.textContent = message;
window.setTimeout(function () {
if (status.textContent === message) status.textContent = '';
}, 4000);
}
// navigator.clipboard istnieje tylko w bezpiecznym kontekście (https albo
// localhost) — po http z adresu IP trzeba starą drogą przez zaznaczenie.
function copyLegacy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.top = '-1000px';
document.body.appendChild(area);
area.select();
var copied = false;
try { copied = document.execCommand('copy'); } catch (error) { copied = false; }
document.body.removeChild(area);
return copied;
}
function copyText(text, what) {
var ok = function () { say('Skopiowane: ' + what); };
var no = function () { say('Nie udało się skopiować — zaznacz adres ręcznie.'); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(ok, function () {
if (copyLegacy(text)) { ok(); } else { no(); }
});
} else if (copyLegacy(text)) { ok(); } else { no(); }
}
document.getElementById('copy-all').addEventListener('click', function () {
copyText(collectAll(), 'cała lista');
});
document.getElementById('copy-start').addEventListener('click', function () {
copyText(this.dataset.url, 'link startowy');
});
"""
def render_links(concepts: list[Concept], regions: list[Region]) -> str:
sections = link_sections(concepts, regions)
blocks = []
total = 0
for title, links in sections:
if not links:
continue
items = []
for link in links:
total += 1
escaped = html.escape(link.url, quote=True)
description = (
f"
Wszystkie {total} stron wystawki jako pełne adresy — '
"do skopiowania i wrzucenia grupie. Lista powstaje z tych samych danych "
"co reszta stron, więc nowa oferta pojawi się tu sama.
\n"
'
'
''
f'"
''
"
\n" + "\n".join(blocks)
)
return _page_shell("Linki", 0, content, script=LINKS_JS)
# --- pobieralna paczka źródeł dla AI ----------------------------------
def bundle_sources() -> list[Path]:
"""Pliki .md bundle'a — z zachowaniem struktury katalogów."""
return sorted(
path
for path in BUNDLE_ROOT.rglob("*.md")
if ".git" not in path.parts
and PAGES_DIRNAME not in path.relative_to(BUNDLE_ROOT).parts
)
def bundle_date(concepts: list[Concept]) -> str:
"""Data treści = najnowsze `ostatnia_aktualizacja` w bundle'u.
Świadomie NIE bierzemy zegara: paczka ma być identyczna, dopóki nie
zmieni się wiedza. Data z zegara robiłaby nową wersję pliku każdego
dnia, także wtedy, gdy nic nie doszło.
"""
dates = [
str(c.frontmatter.get("ostatnia_aktualizacja") or "").strip() for c in concepts
]
return max((d for d in dates if d), default="—")
def render_ai_readme(concepts: list[Concept]) -> str:
"""AI-README.md do paczki — skrót index.md dla modelu po drugiej stronie."""
offers = [c for c in concepts if is_offer(c)]
active = [c for c in offers if not is_archived(c)]
regions = sorted(
c.id.split("/")[0] for c in concepts if c.id.endswith("/region")
)
date = bundle_date(concepts)
return f"""# Ski 2027 — baza wiedzy wyjazdu ({TERM_LABEL})
## PL
To baza wiedzy wyjazdu narciarskiego czterech rodzin (14 osób: 8 dorosłych,
4 nastolatków, 2 dzieci) z Warszawy w Alpy w terminie **{TERM_LABEL}**
({TERM_NOTE}). Format to OKF: markdown z frontmatterem YAML, gdzie jeden
plik = jeden koncept, `index.md` w każdym katalogu jest listingiem, a
`log.md` historią zmian; porównywane są {len(regions)} regiony
({", ".join(regions)}) w schemacie `/region.md` +
`/oferty/*.md`, razem {len(active)} aktualnych kart noclegów.
Odpowiadaj na pytania o ten wyjazd **wyłącznie na podstawie tych plików** i
podawaj, z którego pliku pochodzi odpowiedź.
O pewności danych mówią dwa pola frontmattera: `status_ceny`
(ZWERYFIKOWANE = odczytane z cennika obiektu, SZACUNEK = nasza estymacja,
do potwierdzenia) oraz `{AVAILABILITY_FIELD}` ze statusem ✅ DOSTĘPNE
(zweryfikowane) / ❌ BRAK (zweryfikowane) / 🔍 BRAK DANYCH (do
weryfikacji). Nie przedstawiaj szacunków jako cen potwierdzonych i nie
zgaduj dostępności tam, gdzie stoi 🔍. Karty z `priorytet: ARCHIWALNA`
pochodzą z riserczu na poprzedni termin (16–23.01.2027) — traktuj je jako
historyczne, nie jako aktualną ofertę.
**Data treści (najnowsza aktualizacja w bazie): {date}.**
## EN
This is the knowledge base of a ski trip for four families (14 people:
8 adults, 4 teenagers, 2 children) driving from Warsaw to the Alps on
**{TERM_LABEL}** (carnival week, peak season). The format is OKF: markdown
with YAML frontmatter, one file = one concept, `index.md` in each directory
is a listing and `log.md` is the change history; {len(regions)} regions
({", ".join(regions)}) are compared using the same schema
`/region.md` + `/oferty/*.md`, with {len(active)} current
accommodation cards in total. Answer questions about this trip **only from
these files** and say which file an answer comes from.
Two frontmatter fields carry the confidence of the data: `status_ceny`
(ZWERYFIKOWANE = read from the property's own price list, SZACUNEK = our
estimate, unconfirmed) and `{AVAILABILITY_FIELD}` (✅ AVAILABLE,
verified / ❌ NOT AVAILABLE, verified / 🔍 NO DATA, to be checked). Never
present an estimate as a confirmed price and never guess availability where
the value is 🔍. Cards marked `priorytet: ARCHIWALNA` come from research
for the previous date (16–23.01.2027) — treat them as historical.
**Content date (latest update in the base): {date}.**
"""
def write_bundle_zip(concepts: list[Concept]) -> Path:
"""Buduje pages/ski-2027-kb.zip — deterministycznie, bajt w bajt."""
entries: list[tuple[str, str]] = [
(f"{ZIP_ROOT}/{AI_README}", render_ai_readme(concepts))
]
for path in bundle_sources():
rel = path.relative_to(BUNDLE_ROOT).as_posix()
entries.append((f"{ZIP_ROOT}/{rel}", path.read_text(encoding="utf-8")))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for name, text in sorted(entries):
info = zipfile.ZipInfo(name, date_time=ZIP_TIMESTAMP)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, text.encode("utf-8"))
target = PAGES_DIR / ZIP_NAME
target.write_bytes(buffer.getvalue())
return target
# --- raport braków ----------------------------------------------------
def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]:
"""Czego brakuje w ofertach — bez zgadywania, tylko z tego, co jest."""
report = []
for concept in concepts:
if not is_offer(concept) or is_archived(concept):
continue
gaps = []
status = str(concept.frontmatter.get("status_ceny") or "")
if not status:
gaps.append("brak pola `status_ceny`")
elif "SZACUNEK" in status.upper():
gaps.append(f"cena niepotwierdzona ({status})")
# Dostępność czytamy z pola frontmattera, nie z nagłówka w tekście —
# werdykt ✅/❌/🔍 jest danymi, a nie tym, czy ktoś napisał sekcję.
value = availability(concept)
if not value:
gaps.append(f"brak pola `{AVAILABILITY_FIELD}`")
elif value.startswith("🔍"):
gaps.append(f"dostępność {TERM_LABEL} do weryfikacji ({_shorten(value)})")
open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M))
if open_todos:
gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}")
if gaps:
report.append((concept.id, gaps))
return report
# --- main -------------------------------------------------------------
DEPLOY_COMMAND = """ssh piha 'rm -rf /tmp/narty27-deploy && mkdir -p /tmp/narty27-deploy' \\
&& scp -r viz.html pages piha:/tmp/narty27-deploy/ \\
&& ssh piha 'docker run --rm -v narty27_narty27_content:/content \\
-v /tmp/narty27-deploy:/src:ro alpine sh -c \\
"rm -rf /content/pages && cp -r /src/viz.html /src/pages /content/ \\
&& cp /src/pages/start.html /content/index.html && ls -la /content"' \\
&& ssh piha 'rm -rf /tmp/narty27-deploy'"""
def main() -> int:
concepts = load_concepts()
pages = {c.id: c.page for c in concepts}
regions = parse_regions()
# Katalog img/ jest wejściem, nie wyjściem tego skryptu (wypełnia go
# gen_images.py) — czyścimy wszystko poza nim.
PAGES_DIR.mkdir(parents=True, exist_ok=True)
for entry in PAGES_DIR.iterdir():
if entry.name == IMG_DIRNAME:
continue
shutil.rmtree(entry) if entry.is_dir() else entry.unlink()
written = []
for concept in concepts:
out = PAGES_DIR / concept.page
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render_page(concept, pages), encoding="utf-8")
written.append(out)
for name, markup in (
("index.html", render_index(concepts)),
("porownanie.html", render_comparison(concepts, regions)),
("linki.html", render_links(concepts, regions)),
("start.html", render_start(concepts, regions)),
):
path = PAGES_DIR / name
path.write_text(markup, encoding="utf-8")
written.append(path)
written.append(write_bundle_zip(concepts))
print(f"Renderer markdown: {RENDERER}")
print(f"Zapisano {len(written)} plików w {PAGES_DIR}:")
for path in written:
size = path.stat().st_size / 1024
print(f" {path.relative_to(BUNDLE_ROOT)} ({size:.1f} KiB)")
print()
offers = [c for c in concepts if is_offer(c)]
with_photos = [c for c in offers if offer_image_paths(c)]
print(
f"Regiony z wyjazd.md: {len(regions)} "
f"({', '.join(r.label for r in regions) or '—'})"
)
active = [c for c in offers if not is_archived(c)]
print(
f"Oferty w tabeli porównawczej: {len(active)} "
f"(+{len(offers) - len(active)} archiwalnych w osobnej sekcji), "
f"ze zdjęciami: {len(with_photos)} (zdjęcia dokłada gen_images.py)"
)
marks = [availability_mark(availability(c)) for c in active]
print(
f"Dostępność {TERM_LABEL}: "
+ " · ".join(
f"{label}: {sum(1 for m in marks if m and m[1] == label)}"
for _, label, _ in AVAILABILITY_MARKS
)
+ f" · bez pola: {sum(1 for m in marks if m is None)}"
)
gaps = missing_data(concepts)
print()
if gaps:
print(f"Oferty z brakami w danych ({len(gaps)}) — do uzupełnienia ręcznie:")
for concept_id, items in gaps:
print(f" ! {concept_id}")
for item in items:
print(f" – {item}")
else:
print("Braków w danych ofert nie wykryto.")
print()
print("Deploy na PIHA (korzeń serwisu = start.html, graf pod /viz.html):")
print(DEPLOY_COMMAND)
return 0
if __name__ == "__main__":
raise SystemExit(main())