diff --git a/requirements.txt b/requirements.txt index e25f9783..9a79aed8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,4 @@ lightgbm web3 fastapi uvicorn -jinja2 websockets diff --git a/web/monitor_routes.py b/web/monitor_routes.py index 43875488..ebad1460 100644 --- a/web/monitor_routes.py +++ b/web/monitor_routes.py @@ -1,4 +1,4 @@ -"""市场监控网页版 — 寄生 FastAPI,复用 _analyze() 全量数据。""" +"""市场监控网页版 — f-string 拼 HTML,零模板引擎依赖。""" from __future__ import annotations @@ -7,15 +7,13 @@ 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 一致) ── +# ── city config ── _CITIES: List[Dict[str, Any]] = [ {"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True}, @@ -34,147 +32,213 @@ _CITIES: List[Dict[str, Any]] = [ # ── 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 + if v is None: return None + try: return round(float(v), 1) + except: return None -def _trend_info(icao: str) -> tuple[str, str]: - """Return (symbol, css_class) from _check_rising_trend.""" +def _trend_info(icao: str) -> tuple: 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) + if _check_rising_trend(icao): return ("↑", "rising") + except: pass 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 + except: 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 + 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"): + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): 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 + dt = datetime.strptime(str(obs_time_str)[:26], fmt).replace(tzinfo=timezone.utc) + return max(0, int((datetime.now(timezone.utc) - dt).total_seconds() // 60)) + except: continue 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 max(0, int((datetime.now(timezone.utc) - dt).total_seconds() // 60)) + except: pass return None -def _runway_pairs(city_weather: Dict[str, Any]) -> list: - """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((f"{r1}/{r2}", round(float(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]]: +def _build_cards() -> tuple: 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 + ac = cw.get("airport_current") or {} + cur = cw.get("current") or {} + ct = _sf(ac.get("temp")) or _sf(cur.get("temp")) + msf = ac.get("max_so_far") + mtt = ac.get("max_temp_time") or "" + obs = ac.get("obs_time") or "" + local_time = cw.get("local_time") or "" + new_high = (ct is not None and msf is not None and ct >= msf + 0.3) + tsym, tcss = _trend_info(cfg["icao"]) + age = _obs_age(obs) + + rw_html = "" + if cfg.get("rw"): + amos = cw.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 [] + for (r1, r2), (t, _d) in zip(pairs, temps): + if t is not None: + rw_html += f'
{r1}/{r2}{round(float(t),1):.1f}°C
\n' + + cards.append({ + "en_name": cfg["en_name"], "airport": cfg["airport"], + "time": obs or local_time, + "ct": ct, "msf": _sf(msf), "mtt": mtt, + "tsym": tsym, "tcss": tcss, "age": age, + "new_high": new_high, "rw_html": rw_html, + "warm": ct is not None and ct >= 30, + }) + except Exception: + logger.exception("monitor: failed city {}", cfg["key"]) + + cards.sort(key=lambda c: (c["ct"] is not None, c["ct"] or -999), reverse=True) + return cards, datetime.now(timezone.utc).strftime("%H:%M:%S UTC") + +def _render(cards, gts): + """Build card grid HTML.""" + cards_html = [] + for c in cards: + nc = " new-high-card" if c["new_high"] else "" + wc = " warm" if c["warm"] else "" + nv = " new-high-val" if c["new_high"] else "" + nh = '◆新高' if c["new_high"] else "" + ct_str = f'{c["ct"]:.1f}' if c["ct"] is not None else "--" + ct_na = ' na' if c["ct"] is None else "" + msf_str = f'{c["msf"]:.1f}°C' if c["msf"] is not None else "" + msf_na = ' na' if c["msf"] is None else "" + mtt_str = f'{c["mtt"]}' if c["mtt"] else "" + age_str = f'{c["age"]} min ago' if c["age"] is not None else "" + age_na = ' na' if c["age"] is None else "" + + cards_html.append(f'''
+
+ {c['en_name']} + / {c['airport']} + {c['time']}{"" + nh + "" if nh else ""} +
+
+ {ct_str}°C +
+
+
+ High + {msf_str}{mtt_str} + {c['tsym']} +
+
+ Obs + {age_str} +
+
{"
" + c['rw_html'] + "
" if c['rw_html'] else ""} +
''') + + return f'''
+{gts} +{chr(10).join(cards_html)} +
+
Refreshing…
''' + + +PAGE_HTML = """ + + + +Market Monitor — PolyWeather + + + + +
+
+

🔥 Market Monitor

+
+ + %s +
+
+%s +
+ + + +""" -# ── routes ── @router.get("/m", response_class=HTMLResponse) async def monitor_page(request: Request): - cities = _load_all_cities() - # Debug: render without airport_current dicts - simple = [{"en_name": c["en_name"], "airport": c["airport"], - "current_temp": c["current_temp"], "new_high": c["new_high"], - "trend_sym": c["trend_sym"], "trend_css": c["trend_css"], - "obs_age_min": c["obs_age_min"], "local_time": c["local_time"], - "obs_time_str": c["obs_time_str"], - "max_so_far": c["max_so_far"], "max_temp_time": c["max_temp_time"]} for c in cities] - return templates.TemplateResponse("monitor.html", { - "request": request, - "cities": simple, - "full_page": True, - "generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"), - }) + cards, gts = _build_cards() + return HTMLResponse(PAGE_HTML % (gts, _render(cards, gts))) @router.get("/m/cards", response_class=HTMLResponse) async def monitor_cards(request: Request): - cities = _load_all_cities() - simple = [{"en_name": c["en_name"], "airport": c["airport"], - "current_temp": c["current_temp"], "new_high": c["new_high"], - "trend_sym": c["trend_sym"], "trend_css": c["trend_css"], - "obs_age_min": c["obs_age_min"], "local_time": c["local_time"], - "obs_time_str": c["obs_time_str"], - "max_so_far": c["max_so_far"], "max_temp_time": c["max_temp_time"]} for c in cities] - return templates.TemplateResponse("monitor.html", { - "request": request, - "cities": simple, - "full_page": False, - "generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"), - }) + cards, gts = _build_cards() + return HTMLResponse(_render(cards, gts)) diff --git a/web/templates/monitor.html b/web/templates/monitor.html deleted file mode 100644 index 3a25d328..00000000 --- a/web/templates/monitor.html +++ /dev/null @@ -1,174 +0,0 @@ - - - - - -Market Monitor — PolyWeather - - - - -
-
-

🔥 Market Monitor

-
- - {{ generated_at }} -
-
- -
-{{ generated_at }} -{% for c in cities %} -
-
- {{ c.en_name }} - / {{ c.airport }} - {{ c.obs_time_str or c.local_time }} - {% if c.new_high %} - ◆新高 - {% endif %} -
- -
- {% if c.current_temp is not none %} - {{ "%.1f"|format(c.current_temp) }}°C - {% else %} - -- - {% endif %} -
- -
-
- High - {% if c.max_so_far is not none %} - {{ "%.1f"|format(c.max_so_far) }}°C - {% if c.max_temp_time %} - {{ c.max_temp_time }} - {% endif %} - {% else %} - -- - {% endif %} - {{ c.trend_sym }} -
-
- Obs - {% if c.obs_age_min is not none %} - {{ c.obs_age_min }} min ago - {% else %} - -- - {% endif %} -
-
- -
-{% endfor %} -
- -
Refreshing…
-
- - - - -