用 Python 重写市场监控网页版:FastAPI+Jinja2+HTMX,复用 _analyze()

This commit is contained in:
2569718930@qq.com
2026-05-13 23:19:49 +08:00
parent bfffbdedd0
commit a221b3e969
3 changed files with 356 additions and 0 deletions
+2
View File
@@ -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",
+167
View File
@@ -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"),
})
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Market Monitor — PolyWeather</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f1117;color:#c8cdd4;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
.page{max-width:1500px;margin:0 auto;padding:24px 28px}
.header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:28px;padding-bottom:14px;border-bottom:1px solid #1e2130}
.header h1{font-size:24px;font-weight:600;color:#e8eaed}
.header-right{display:flex;align-items:center;gap:12px}
.notify-btn{background:none;border:1px solid #2a2e40;border-radius:6px;padding:3px 8px;font-size:16px;cursor:pointer;transition:border-color .2s}
.notify-btn:hover{border-color:#4a5160}
.notify-btn.muted{opacity:.4}
.updated{font-size:13px;color:#5a6170}
.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
@media(max-width:1100px){.card-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:600px){.card-grid{grid-template-columns:1fr}}
.card{background:#161822;border:1px solid #1e2130;border-radius:12px;padding:22px 26px;transition:border-color .3s,box-shadow .3s;position:relative}
.card:hover{border-color:#2a2e40}
.card.new-high-card{border-color:rgba(124,58,237,.3)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:15px;flex-wrap:wrap}
.city-name{color:#e0e3e8;font-weight:700}
.airport{color:#6a7180;font-weight:400}
.local-time{margin-left:auto;color:#4a5160;font-variant-numeric:tabular-nums;font-size:14px}
.badge{font-size:12px;padding:2px 7px;border-radius:4px;font-weight:600}
.badge.new-high{background:rgba(124,58,237,.18);color:#a78bfa}
.card-temp{margin:8px 0 10px;font-weight:700;line-height:1.15}
.temp-value{font-size:52px;color:#e8eaed;letter-spacing:-.03em}
.temp-value.na{color:#3a4050;font-size:32px}
.temp-value.warm{color:#f59e0b}
.temp-value.new-high-val{color:#c084fc}
.temp-unit{font-size:22px;color:#5a6170;margin-left:3px}
.card-meta{display:flex;flex-direction:column;gap:5px;margin-bottom:2px}
.meta-row{display:flex;align-items:baseline;gap:8px;font-size:14px}
.meta-row .label{color:#4a5160}
.meta-row .value{color:#9aa0b0;font-variant-numeric:tabular-nums}
.meta-row .value.na{color:#3a4050}
.meta-row .trend{margin-left:auto}
.trend{font-size:18px;font-weight:700}
.trend.rising{color:#34d399}
.trend.falling{color:#60a5fa}
.trend.flat{color:#5a6170}
.max-time{font-size:12px;color:#4a5160;margin-left:2px}
.obs-age{font-size:13px;color:#5a6170}
.card-runway{margin-top:12px}
.runway-divider{height:1px;background:#1e2130;margin-bottom:8px}
.runway-row{display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px}
.runway-label{color:#4a5160}
.runway-temp{color:#7a8290;font-variant-numeric:tabular-nums}
.htmx-indicator{text-align:center;padding:14px;font-size:14px;color:#3a4050;display:none}
.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:block}
</style>
<meta http-equiv="refresh" content="120">
</head>
<body>
<div class="page">
<header class="header">
<h1>🔥 Market Monitor</h1>
<div class="header-right">
<button id="notify-toggle" class="notify-btn" onclick="toggleNotify()" title="新高提醒">
<span id="notify-icon">🔔</span>
</button>
<span class="updated">{{ generated_at }}</span>
</div>
</header>
{% if full_page or not full_page %}
<div id="card-grid" class="card-grid"
hx-get="/monitor/cards" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner">
{% endif %}
<span class="updated" style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px">{{ generated_at }}</span>
{% for c in cities %}
<div class="card{% if c.new_high %} new-high-card{% endif %}" data-new-high="{{ c.new_high | lower }}">
<div class="card-top">
<span class="city-name">{{ c.en_name }}</span>
<span class="airport">/ {{ c.airport }}</span>
<span class="local-time">{{ c.obs_time_str or c.local_time }}</span>
{% if c.new_high %}
<span class="badge new-high">◆新高</span>
{% endif %}
</div>
<div class="card-temp">
{% if c.current_temp is not none %}
<span class="temp-value{% if c.current_temp >= 30 %} warm{% endif %}{% if c.new_high %} new-high-val{% endif %}">{{ "%.1f"|format(c.current_temp) }}</span><span class="temp-unit">°C</span>
{% else %}
<span class="temp-value na">--</span>
{% endif %}
</div>
<div class="card-meta">
<div class="meta-row">
<span class="label">High</span>
{% if c.max_so_far is not none %}
<span class="value">{{ "%.1f"|format(c.max_so_far) }}°C</span>
{% if c.max_temp_time %}
<span class="max-time">{{ c.max_temp_time }}</span>
{% endif %}
{% else %}
<span class="value na">--</span>
{% endif %}
<span class="trend {{ c.trend_css }}">{{ c.trend_sym }}</span>
</div>
<div class="meta-row">
<span class="label">Obs</span>
{% if c.obs_age_min is not none %}
<span class="obs-age">{{ c.obs_age_min }} min ago</span>
{% else %}
<span class="value na">--</span>
{% endif %}
</div>
</div>
{% if c.runway_pairs %}
<div class="card-runway">
<div class="runway-divider"></div>
{% for rw in c.runway_pairs %}
<div class="runway-row">
<span class="runway-label">{{ rw.label }}</span>
<span class="runway-temp">{{ "%.1f"|format(rw.temp) }}°C</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div id="spinner" class="htmx-indicator">Refreshing…</div>
</div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
var NOTIFY_ENABLED = localStorage.getItem('monitor_notify') !== 'off';
var NOTIFIED_HIGHS = JSON.parse(localStorage.getItem('monitor_notified_highs') || '{}');
function toggleNotify(){
NOTIFY_ENABLED = !NOTIFY_ENABLED;
localStorage.setItem('monitor_notify', NOTIFY_ENABLED ? 'on' : 'off');
updateNotifyIcon();
if(NOTIFY_ENABLED && Notification.permission === 'default') Notification.requestPermission();
}
function updateNotifyIcon(){
var icon = document.getElementById('notify-icon');
var btn = document.getElementById('notify-toggle');
if(NOTIFY_ENABLED){ icon.textContent = '🔔'; btn.classList.remove('muted'); }
else{ icon.textContent = '🔕'; btn.classList.add('muted'); }
}
function fireNotification(enName, temp){
if(!NOTIFY_ENABLED || Notification.permission !== 'granted') return;
var key = enName + '|' + temp;
var today = new Date().toDateString();
if(NOTIFIED_HIGHS._day !== today) NOTIFIED_HIGHS = {_day: today};
if(NOTIFIED_HIGHS[key]) return;
NOTIFIED_HIGHS[key] = true;
localStorage.setItem('monitor_notified_highs', JSON.stringify(NOTIFIED_HIGHS));
new Notification('🔴 New High — ' + enName, {
body: temp + '°C\nNew daily high.',
tag: key, requireInteraction: true
});
}
updateNotifyIcon();
(function(){
let prev = {};
document.body.addEventListener('htmx:afterSwap', function(evt){
if(evt.detail.target.id !== 'card-grid') return;
document.querySelectorAll('.card').forEach(function(card){
var name = card.querySelector('.city-name').textContent;
var tv = card.querySelector('.temp-value').textContent;
var old = prev[name]; prev[name] = tv;
if(old && old !== tv && tv !== '--'){
card.style.transition = 'none';
card.style.boxShadow = '0 0 14px rgba(52,211,153,0.40)';
requestAnimationFrame(function(){
card.style.transition = 'box-shadow 2s ease-out';
card.style.boxShadow = '';
});
}
if(card.dataset.newHigh === 'true') fireNotification(name, tv);
});
});
})();
</script>
</body>
</html>