#!/usr/bin/env python3 """ General-purpose HTTP server for maru-hleda-byt. Serves static files from DATA_DIR and additionally handles: GET /scrapers-status → SSR scraper status page GET /api/ratings → ratings.json contents POST /api/ratings → save entire ratings object GET /api/ratings/export → same as GET, with download header GET /api/status → status.json contents (JSON) GET /api/status/history → scraper_history.json contents (JSON) """ from __future__ import annotations import functools import json import logging import os import sys from datetime import datetime from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path PORT = int(os.environ.get("SERVER_PORT", 8080)) DATA_DIR = Path(os.environ.get("DATA_DIR", ".")) RATINGS_FILE = DATA_DIR / "ratings.json" _LOG_LEVEL = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO) logging.basicConfig( level=_LOG_LEVEL, format="%(asctime)s [server] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) log = logging.getLogger(__name__) # ── Helpers ────────────────────────────────────────────────────────────────── COLORS = { "sreality": "#1976D2", "realingo": "#7B1FA2", "bezrealitky": "#E65100", "idnes": "#C62828", "psn": "#2E7D32", "cityhome": "#00838F", } MONTHS_CZ = [ "ledna", "února", "března", "dubna", "května", "června", "července", "srpna", "září", "října", "listopadu", "prosince", ] def _load_json(path: Path, default=None): """Read and parse JSON file; return default on missing or parse error.""" log.debug("_load_json: %s", path.resolve()) try: if path.exists(): return json.loads(path.read_text(encoding="utf-8")) except Exception as e: log.warning("Failed to load %s: %s", path, e) return default def _fmt_date(iso_str: str) -> str: """Format ISO timestamp as Czech date string.""" try: d = datetime.fromisoformat(iso_str) return f"{d.day}. {MONTHS_CZ[d.month - 1]} {d.year}, {d.hour:02d}:{d.minute:02d}" except Exception: return iso_str def load_ratings() -> dict: return _load_json(RATINGS_FILE, default={}) def save_ratings(data: dict) -> None: RATINGS_FILE.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) # ── SSR status page ────────────────────────────────────────────────────────── _CSS = """\ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; padding: 24px; max-width: 640px; margin: 0 auto; } h1 { font-size: 22px; margin-bottom: 4px; } .subtitle { color: #888; font-size: 13px; margin-bottom: 24px; } .card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); margin-bottom: 16px; } .card h2 { font-size: 15px; margin-bottom: 12px; color: #555; } .timestamp { font-size: 28px; font-weight: 700; color: #1976D2; } .timestamp-sub { font-size: 13px; color: #999; margin-top: 2px; } .summary-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f0f0f0; } .summary-row:last-child { border-bottom: none; } .summary-label { font-size: 13px; color: #666; } .summary-value { font-size: 18px; font-weight: 700; } .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; color: white; } .badge-ok { background: #4CAF50; } .badge-err { background: #F44336; } .badge-skip { background: #FF9800; } .bar-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; } .bar-track { flex: 1; height: 20px; background: #f0f0f0; border-radius: 4px; overflow: hidden; } .bar-fill { height: 100%; border-radius: 4px; } .bar-count { font-size: 12px; width: 36px; font-variant-numeric: tabular-nums; } .loader-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; } .spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1976D2; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .loader-text { margin-top: 16px; color: #999; font-size: 14px; } .link-row { text-align: center; margin-top: 8px; } .link-row a { color: #1976D2; text-decoration: none; font-size: 14px; } .history-table { width: 100%; border-collapse: collapse; font-size: 12px; } .history-table th { text-align: left; font-weight: 600; color: #999; font-size: 11px; padding: 4px 6px 8px 6px; border-bottom: 2px solid #f0f0f0; } .history-table td { padding: 7px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; } .history-table tr:last-child td { border-bottom: none; } .history-table tr.latest td { background: #f8fbff; font-weight: 600; } .src-nums { display: flex; gap: 4px; flex-wrap: wrap; } .src-chip { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; color: white; font-variant-numeric: tabular-nums; } .clickable-row { cursor: pointer; } .clickable-row:hover td { background: #f0f7ff !important; } /* Modal */ #md-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: flex-start; justify-content: center; z-index: 1000; padding: 40px 16px; overflow-y: auto; } #md-box { background: white; border-radius: 12px; padding: 24px; width: 100%; max-width: 620px; position: relative; box-shadow: 0 8px 32px rgba(0,0,0,0.24); margin: auto; } #md-close { position: absolute; top: 10px; right: 14px; background: none; border: none; font-size: 26px; cursor: pointer; color: #aaa; line-height: 1; } #md-close:hover { color: #333; } #md-box h3 { font-size: 15px; margin-bottom: 14px; padding-right: 24px; } .md-summary { display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; margin-bottom: 16px; color: #555; } .md-summary b { color: #333; } .detail-table { width: 100%; border-collapse: collapse; font-size: 12px; } .detail-table th { text-align: left; color: #999; font-size: 11px; font-weight: 600; padding: 4px 8px 6px 0; border-bottom: 2px solid #f0f0f0; white-space: nowrap; } .detail-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid #f5f5f5; vertical-align: top; } .detail-table tr:last-child td { border-bottom: none; } """ _SOURCE_ORDER = ["Sreality", "Realingo", "Bezrealitky", "iDNES", "PSN", "CityHome"] _SOURCE_ABBR = ["Sre", "Rea", "Bez", "iDN", "PSN", "CH"] def _sources_html(sources: list) -> str: if not sources: return "" max_count = max((s.get("accepted", 0) for s in sources), default=1) or 1 parts = ['
| Datum | Trvání | Přijato / Dedup | Zdroje | OK | ', '
|---|---|---|---|---|
| {_fmt_date(entry.get("timestamp", ""))} | ' f'{dur} | ' f'{entry.get("total_accepted", "-")} / {entry.get("deduplicated", "-")} | ' f'{chips} | '
f'{ok_badge} | ' f'
Status není k dispozici.