#!/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 ofert (nie regionów),
sortowalna klikiem, złożona z samych frontmatterów;
na górze podsumowanie per region z `wyjazd.md`
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 posixpath
import re
import shutil
import sys
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 — Skicircus Saalbach"
RESERVED = {"index.md", "log.md"}
# 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_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'
{render_inline(' '.join(paragraph))}
") return "\n".join(out) def md_to_html(text: str) -> str: if _markdown_lib: return _markdown_lib.markdown( text, extensions=["tables", "fenced_code", "sane_lists"] ) return render_blocks(text) # --- szablon ---------------------------------------------------------- CSS = """ :root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff; --line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; } @media (prefers-color-scheme: dark) { :root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155; --accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; } } * { box-sizing: border-box; } body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg); -webkit-text-size-adjust: 100%; } nav { font-size: .85rem; margin-bottom: 1.25rem; } nav a { color: var(--muted); } h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; } h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem; border-top: 1px solid var(--line); } h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; } p, ul, ol { margin: .6rem 0; } ul, ol { padding-left: 1.3rem; } li { margin: .25rem 0; } li.task { list-style: none; margin-left: -1.3rem; } li.task input { margin-right: .45rem; } a { color: var(--accent); } a.ext::after { content: " ↗"; font-size: .8em; color: var(--muted); } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; background: var(--chip); padding: .1em .35em; border-radius: 4px; } pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; } pre code { background: none; padding: 0; } blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote); border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; } blockquote p { margin: .2rem 0; } hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; } .table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; } table { border-collapse: collapse; width: 100%; font-size: .92rem; } th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; } th { background: var(--chip); font-weight: 600; } .chip { display: inline-block; background: var(--chip); color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .5rem; border-radius: 999px; } .lead { color: var(--muted); margin: .35rem 0 1.25rem; } .meta { margin: 0 0 1.5rem; } .meta summary { cursor: pointer; font-size: .85rem; color: var(--muted); padding: .3rem 0; } .meta table { font-size: .85rem; } .meta th { width: 38%; text-align: left; font-weight: 500; word-break: break-word; } .cards { list-style: none; padding: 0; margin: 0; } .cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem; margin: .6rem 0; } .cards a { font-weight: 600; text-decoration: none; } .cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); } footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); font-size: .78rem; color: var(--muted); } /* landing: kafle i lista regionów */ body.wide { max-width: 72rem; } .tiles, .regions { list-style: none; padding: 0; } .tiles { margin: 1.25rem 0 2rem; display: grid; gap: .75rem; } .regions { margin: .6rem 0 0; display: grid; gap: .5rem; } @media (min-width: 34rem) { .tiles { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); } .regions { grid-template-columns: repeat(2, 1fr); } } .tiles a, .regions a { display: block; border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; text-decoration: none; color: var(--fg); } .tiles a { font-size: 1.05rem; font-weight: 600; } .tiles a:hover, .tiles a:focus, .regions a:hover, .regions a:focus { border-color: var(--accent); } .tiles small, .regions small { display: block; margin-top: .3rem; font-weight: 400; font-size: .85rem; color: var(--muted); } .regions a { font-weight: 600; } /* podsumowanie per region na karcie porównania */ .summary { list-style: none; padding: 0; margin: .8rem 0 2rem; display: grid; gap: .6rem; } @media (min-width: 48rem) { .summary { grid-template-columns: repeat(2, 1fr); } } .summary li { border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 8px; padding: .65rem .85rem; } .summary .budget { font-size: .95rem; } .summary p { margin: .35rem 0 0; font-size: .88rem; color: var(--muted); } /* tabela porównawcza: sortowanie klikiem + kolor statusu ceny */ table.sortable th { cursor: pointer; user-select: none; white-space: nowrap; } table.sortable th::after { content: " ↕"; font-size: .8em; opacity: .35; } table.sortable th[data-dir="asc"]::after { content: " ↑"; opacity: 1; } table.sortable th[data-dir="desc"]::after { content: " ↓"; opacity: 1; } table.sortable td { font-size: .88rem; padding: .35rem .5rem; } table.sortable td[data-num] { white-space: nowrap; } table.sortable .name { font-weight: 600; } tr.verified td { background: rgba(13, 148, 136, .14); } tr.estimate td { background: rgba(100, 116, 139, .10); color: var(--muted); } tr.estimate td.name a, tr.verified td.name a { color: var(--accent); } .legend { font-size: .82rem; color: var(--muted); margin: .5rem 0 0; } .legend .box { display: inline-block; width: .8em; height: .8em; border-radius: 3px; border: 1px solid var(--line); vertical-align: -.05em; margin-right: .25em; } .legend .box.v { background: rgba(13, 148, 136, .35); } .legend .box.e { background: rgba(100, 116, 139, .25); } /* miniatury w tabeli */ td.thumb { padding: .25rem; width: 128px; } td.thumb img { width: 120px; height: 80px; object-fit: cover; border-radius: 6px; display: block; } td.thumb .placeholder { display: flex; width: 120px; height: 80px; border-radius: 6px; align-items: center; justify-content: center; background: var(--chip); color: var(--muted); font-weight: 700; letter-spacing: .05em; text-decoration: none; } /* galeria na karcie oferty */ .gallery { margin: 0 0 1.25rem; } .gallery-main img { width: 100%; max-height: 24rem; object-fit: cover; border-radius: 10px; display: block; background: var(--chip); } .gallery-thumbs { display: flex; gap: .4rem; margin-top: .4rem; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: .2rem; } .gallery-thumbs button { flex: 0 0 auto; padding: 0; border: 2px solid transparent; border-radius: 6px; background: none; cursor: pointer; } .gallery-thumbs button[aria-current] { border-color: var(--accent); } .gallery-thumbs img { width: 5.5rem; height: 3.6rem; object-fit: cover; border-radius: 4px; display: block; } /* spis linków do kopiowania */ .copybar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: 1rem 0 1.5rem; } .copybar button { font: inherit; font-size: .9rem; padding: .45rem .9rem; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: #f8fafc; cursor: pointer; } .copybar button + button { background: none; color: var(--accent); } .copybar button:hover { filter: brightness(1.08); } .copy-status { font-size: .85rem; color: var(--muted); } .links { list-style: none; padding: 0; margin: .5rem 0 0; } .links li { padding: .55rem 0; border-bottom: 1px solid var(--line); } .links li:last-child { border-bottom: 0; } .links a { font-weight: 600; text-decoration: none; } .links code.url { display: block; margin: .2rem 0; font-size: .82rem; background: none; padding: 0; color: var(--muted); word-break: break-all; user-select: all; } .links p { margin: 0; font-size: .85rem; color: var(--muted); } /* kafel regionu ze zdjęciem w tle */ .regions a.hero { background-size: cover; background-position: center; border-color: transparent; color: #f8fafc; min-height: 6.5rem; display: flex; flex-direction: column; justify-content: flex-end; } .regions a.hero small { color: #e2e8f0; } """ SORT_JS = """ document.querySelectorAll('table.sortable').forEach(function (table) { var head = table.tHead.rows[0]; Array.prototype.forEach.call(head.cells, function (th, i) { th.tabIndex = 0; th.setAttribute('role', 'button'); th.addEventListener('click', function () { sortTable(table, i, th); }); th.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sortTable(table, i, th); } }); }); }); function sortTable(table, index, th) { var dir = th.dataset.dir === 'asc' ? 'desc' : 'asc'; Array.prototype.forEach.call(table.tHead.rows[0].cells, function (h) { delete h.dataset.dir; h.setAttribute('aria-sort', 'none'); }); th.dataset.dir = dir; th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending'); var body = table.tBodies[0]; var rows = Array.prototype.slice.call(body.rows); var mul = dir === 'asc' ? 1 : -1; rows.sort(function (a, b) { var x = a.cells[index], y = b.cells[index]; var nx = x.dataset.num, ny = y.dataset.num; if (nx !== undefined && ny !== undefined) return (parseFloat(nx) - parseFloat(ny)) * mul; // Wiersze bez wartości liczbowej zawsze na końcu — w obie strony. if (nx !== undefined) return -1; if (ny !== undefined) return 1; var kx = x.dataset.key !== undefined ? x.dataset.key : x.textContent; var ky = y.dataset.key !== undefined ? y.dataset.key : y.textContent; if (!kx) return 1; if (!ky) return -1; return kx.localeCompare(ky, 'pl') * mul; }); rows.forEach(function (row) { body.appendChild(row); }); } """ def _page_shell( title: str, depth: int, content: str, *, body_class: str = "", nav: str | None = None, script: str = "", ) -> str: up = "../" * depth if nav is None: nav = ( f'← Start · ' f'Wszystkie karty · ' f'Porównanie ofert · ' f'Linki · ' f'Graf' ) css_class = f' class="{body_class}"' if body_class else "" script_tag = f"\n" if script else "" return 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"{html.escape(description)}
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/{render_inline(region.conclusion)}
" if region.conclusion else "" ) items.append( f'Wszystkie {len(offers)} rozważanych noclegów 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" "cena zweryfikowana ' ' szacunek do potwierdzenia
' ) 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: 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."), ] def render_start(concepts: list[Concept], regions: list[Region]) -> str: tiles = "".join( f'16–23.01.2027, 4 rodziny / 14 osób, wyjazd z Warszawy. ' "Od czego zacząć:
\n" f'Karta regionu = skipassy, dojazd, terminy, koszty ' "życia i budżet całkowity.
\n" f'{html.escape(link.description)}
" if link.description else "" ) items.append( f'{html.escape(link.url)}{description}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" '\n" + "\n".join(blocks) ) return _page_shell("Linki", 0, content, script=LINKS_JS) # --- 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 str(concept.frontmatter.get("type", "")).startswith("oferta"): 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})") # Danymi o dostępności jest osobna sekcja (jak w glemm-lodge), a nie # samo słowo w tekście — "duża dostępność" w opisie się nie liczy. if not re.search(r"^#+\s*.*dostępnoś", concept.body, re.IGNORECASE | re.M): gaps.append("brak danych o dostępności terminu 16–23.01.2027") 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) 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 '—'})" ) print( f"Oferty w tabeli porównawczej: {len(offers)}, " f"ze zdjęciami: {len(with_photos)} (zdjęcia dokłada gen_images.py)" ) 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())