diff --git a/web/app.py b/web/app.py index 3914a87b..603c225e 100644 --- a/web/app.py +++ b/web/app.py @@ -22,8 +22,10 @@ from web.analysis_service import ( # noqa: E402 ) from web.core import app # noqa: E402 from web.routes import router # noqa: E402 +from web.monitor_routes import router as monitor_router # noqa: E402 app.include_router(router) +app.include_router(monitor_router) __all__ = [ "app", diff --git a/web/monitor_routes.py b/web/monitor_routes.py new file mode 100644 index 00000000..eca9125b --- /dev/null +++ b/web/monitor_routes.py @@ -0,0 +1,167 @@ +"""市场监控网页版 — 寄生 FastAPI,复用 _analyze() 全量数据。""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from loguru import logger + +from web.analysis_service import _analyze + +router = APIRouter() +templates = Jinja2Templates(directory="web/templates") + +# ── city config (与 telegram_push 一致) ── + +_CITIES: List[Dict[str, Any]] = [ + {"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True}, + {"key": "busan", "en_name": "Busan", "icao": "RKPK", "airport": "Gimhae", "tz": 9, "tz_abbr": "KST", "rw": True}, + {"key": "tokyo", "en_name": "Tokyo", "icao": "44166", "airport": "Haneda", "tz": 9, "tz_abbr": "JST", "rw": False}, + {"key": "ankara", "en_name": "Ankara", "icao": "17128", "airport": "Esenboğa", "tz": 3, "tz_abbr": "TRT", "rw": False}, + {"key": "helsinki", "en_name": "Helsinki", "icao": "EFHK", "airport": "Vantaa", "tz": 3, "tz_abbr": "EEST", "rw": False}, + {"key": "amsterdam", "en_name": "Amsterdam", "icao": "EHAM", "airport": "Schiphol", "tz": 2, "tz_abbr": "CEST", "rw": False}, + {"key": "istanbul", "en_name": "Istanbul", "icao": "17058", "airport": "Airport", "tz": 3, "tz_abbr": "TRT", "rw": False}, + {"key": "paris", "en_name": "Paris", "icao": "LFPB", "airport": "Le Bourget", "tz": 2, "tz_abbr": "CEST", "rw": False}, + {"key": "hong kong", "en_name": "Hong Kong", "icao": "HKO", "airport": "Observatory", "tz": 8, "tz_abbr": "HKT", "rw": False}, + {"key": "lau fau shan","en_name": "Lau Fau Shan","icao": "LFS", "airport": "Lau Fau Shan", "tz": 8, "tz_abbr": "HKT", "rw": False}, + {"key": "taipei", "en_name": "Taipei", "icao": "466920", "airport": "Songshan", "tz": 8, "tz_abbr": "TST", "rw": False}, +] + +# ── helpers ── + +def _sf(v: Any) -> Optional[float]: + """Safe float.""" + if v is None: + return None + try: + return round(float(v), 1) + except (ValueError, TypeError): + return None + +def _trend_info(icao: str) -> tuple[str, str]: + """Return (symbol, css_class) from _check_rising_trend.""" + try: + from src.utils.telegram_push import _check_rising_trend + ok = _check_rising_trend(icao) + except Exception: + return ("→", "flat") + if ok: + return ("↑", "rising") + # Check if falling (temp decreasing) + try: + from src.database.db_manager import DBManager + obs = DBManager().get_airport_obs_recent(icao, minutes=60) + temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None] + if len(temps) >= 4 and temps[-1] < temps[len(temps)//2]: + return ("↓", "falling") + except Exception: + pass + return ("→", "flat") + +def _obs_age(obs_time_str: Optional[str]) -> Optional[int]: + """Compute minutes since observation time.""" + if not obs_time_str: + return None + try: + # Try parsing various formats + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"): + try: + dt = datetime.strptime(str(obs_time_str)[:26], fmt) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - dt).total_seconds() + return max(0, int(age // 60)) + except ValueError: + continue + # Try as epoch + ts = float(obs_time_str) + if ts > 1_000_000_000: + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + age = (datetime.now(timezone.utc) - dt).total_seconds() + return max(0, int(age // 60)) + except (ValueError, TypeError): + pass + return None + +def _runway_pairs(city_weather: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract runway pairs from AMOS data.""" + amos = city_weather.get("amos") or {} + rw_obs = (amos.get("runway_obs") or {}) if amos else {} + pairs = rw_obs.get("runway_pairs") or [] + temps = rw_obs.get("temperatures") or [] + result = [] + for (r1, r2), (t, _d) in zip(pairs, temps): + if t is not None: + result.append({"label": f"{r1}/{r2}", "temp": round(t, 1)}) + return result + +def _build_city_card(city: str, city_weather: Dict[str, Any], cfg: Dict[str, Any]) -> Dict[str, Any]: + """Build a single city's card data.""" + ac = city_weather.get("airport_current") or {} + cur = city_weather.get("current") or {} + ct = _sf(ac.get("temp")) or _sf(cur.get("temp")) + max_so_far = ac.get("max_so_far") + max_temp_time = ac.get("max_temp_time") + obs_time_str = ac.get("obs_time") or "" + local_time = city_weather.get("local_time") or "" + new_high = (ct is not None and max_so_far is not None and ct >= max_so_far + 0.3) + + trend_sym, trend_css = _trend_info(cfg["icao"]) + age = _obs_age(obs_time_str) + rw = _runway_pairs(city_weather) if cfg.get("rw") else [] + + return { + "en_name": cfg["en_name"], + "airport": cfg["airport"], + "icao": cfg["icao"], + "obs_time_str": obs_time_str or local_time, + "local_time": local_time, + "current_temp": ct, + "max_so_far": _sf(max_so_far), + "max_temp_time": max_temp_time, + "trend_sym": trend_sym, + "trend_css": trend_css, + "obs_age_min": age, + "new_high": new_high, + "runway_pairs": rw, + } + +def _load_all_cities() -> List[Dict[str, Any]]: + cards = [] + for cfg in _CITIES: + try: + cw = _analyze(cfg["key"]) + card = _build_city_card(cfg["key"], cw, cfg) + cards.append(card) + except Exception: + logger.exception("monitor: failed to load city {}", cfg["key"]) + # Sort by temp descending, None at bottom + cards.sort(key=lambda c: (c["current_temp"] is not None, c["current_temp"] or -999), reverse=True) + return cards + +# ── routes ── + +@router.get("/monitor", response_class=HTMLResponse) +async def monitor_page(request: Request): + cities = _load_all_cities() + return templates.TemplateResponse("monitor.html", { + "request": request, + "cities": cities, + "full_page": True, + "generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"), + }) + +@router.get("/monitor/cards", response_class=HTMLResponse) +async def monitor_cards(request: Request): + cities = _load_all_cities() + return templates.TemplateResponse("monitor.html", { + "request": request, + "cities": cities, + "full_page": False, + "generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"), + }) diff --git a/web/templates/monitor.html b/web/templates/monitor.html new file mode 100644 index 00000000..7c93af55 --- /dev/null +++ b/web/templates/monitor.html @@ -0,0 +1,187 @@ + + +
+ + +