feat: add weekly reward loop, i18n support, and scan terminal dashboard components

This commit is contained in:
2569718930@qq.com
2026-05-27 07:50:05 +08:00
parent 2670e2f8ee
commit 79d94bed5f
14 changed files with 786 additions and 76 deletions
+52 -8
View File
@@ -22,6 +22,7 @@ except Exception:
from src.data_collection.city_registry import CITY_REGISTRY
from src.data_collection.weather_sources import WeatherDataCollector
from src.utils.telegram_i18n import copy_text, normalize_push_language, telegram_push_language
TARGET_CITIES: List[str] = [
"beijing",
@@ -73,6 +74,14 @@ def _env_int(name: str, default: int, min_val: int = 0) -> int:
return default
def _daily_report_language() -> str:
return telegram_push_language(
"DAILY_WEATHER_REPORT_LANGUAGE",
"TELEGRAM_PUSH_LANGUAGE",
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
)
def _fetch_cma_forecast(city_key: str) -> Optional[Dict[str, Any]]:
"""Scrape today's forecast from weather.com.cn (CMA)."""
code = CMA_CITY_CODES.get(city_key)
@@ -158,13 +167,14 @@ def _fetch_city_data(
collector: WeatherDataCollector, city_key: str
) -> Optional[Dict[str, Any]]:
name = CITY_NAME_ZH.get(city_key, city_key)
info = CITY_REGISTRY.get(city_key)
name_en = str((info or {}).get("name") or city_key.title())
# 1. Try CMA first for weather description
cma = _fetch_cma_forecast(city_key)
# 2. Get Open-Meteo data for temperature fallback
om_high = None
info = CITY_REGISTRY.get(city_key)
if info:
try:
results = collector.fetch_all_sources(
@@ -209,6 +219,7 @@ def _fetch_city_data(
return {
"city": city_key,
"name": name,
"name_en": name_en,
"weather": weather or "?",
"forecast_high": forecast_high,
}
@@ -235,12 +246,40 @@ def _wmo_to_weather(code: Any) -> str:
return ""
def _build_ai_prompt(cities_data: List[Dict[str, Any]], report_date: str) -> str:
lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"]
lines.append("城市天气数据:")
def _build_ai_prompt(
cities_data: List[Dict[str, Any]],
report_date: str,
language: Optional[str] = None,
) -> str:
language = normalize_push_language(language or _daily_report_language())
if language == "zh":
lines = [f"今天是 {report_date}。请用自然亲切的中文写一段天气日报。\n"]
lines.append("城市天气数据:")
for c in cities_data:
lines.append(f"{c['name']}{c['weather']},最高{c['forecast_high']}")
lines.append("\n要求:每城一行播报,城市名<b>加粗</b>,开头问候,禁止结尾废话。")
return "\n".join(lines)
if language == "en":
lines = [f"Today is {report_date}. Write a concise Telegram weather briefing in English.\n"]
lines.append("City weather data:")
for c in cities_data:
lines.append(
f"{c.get('name_en') or c['city'].title()}: weather={c['weather']}, high={c['forecast_high']}°C"
)
lines.append("\nRequirements: one line per city, bold city names with <b>, start with a short greeting, no generic closing.")
return "\n".join(lines)
lines = [f"Today is {report_date}. Write a bilingual Telegram weather briefing.\n"]
lines.append("City weather data:")
for c in cities_data:
lines.append(f"{c['name']}{c['weather']},最高{c['forecast_high']}")
lines.append("\n要求:每城一行播报,城市名<b>加粗</b>,开头问候,禁止结尾废话。")
lines.append(
f"{c.get('name_en') or c['city'].title()} / {c['name']}: weather={c['weather']}, high={c['forecast_high']}°C"
)
lines.append(
"\nRequirements: one line per city, English first then Chinese in the same line, "
"bold city names with <b>, start with a short bilingual greeting, no generic closing."
)
return "\n".join(lines)
@@ -352,8 +391,9 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None:
time.sleep(60)
continue
language = _daily_report_language()
report_date = now.strftime("%m月%d")
prompt = _build_ai_prompt(cities_data, report_date)
prompt = _build_ai_prompt(cities_data, report_date, language=language)
report_text = _call_ai(prompt)
if not report_text:
@@ -363,7 +403,11 @@ def _runner(bot: Any, config: Dict[str, Any]) -> None:
continue
try:
report_text += "\n\n⚠️ 以上为粗略预测,仅供参考。"
report_text += "\n\n" + copy_text(
language,
"⚠️ Rough forecast only. For reference.",
"⚠️ 以上为粗略预测,仅供参考。",
)
bot.send_message(
FORUM_CHAT_ID,
report_text,
+41
View File
@@ -0,0 +1,41 @@
import os
from typing import Optional
def normalize_push_language(raw: Optional[str]) -> str:
value = str(raw or "").strip().lower().replace("_", "-")
if value in {"zh", "zh-cn", "cn", "chinese"}:
return "zh"
if value in {"en", "en-us", "english"}:
return "en"
if value in {"both", "bilingual", "dual", "en-zh", "zh-en"}:
return "both"
return "both"
def telegram_push_language(*env_names: str, default: str = "both") -> str:
names = env_names or (
"TELEGRAM_PUSH_LANGUAGE",
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
)
for name in names:
raw = os.getenv(name)
if raw:
return normalize_push_language(raw)
return normalize_push_language(default)
def is_zh(language: Optional[str]) -> bool:
return normalize_push_language(language) == "zh"
def is_bilingual(language: Optional[str]) -> bool:
return normalize_push_language(language) == "both"
def copy_text(language: Optional[str], en: str, zh: str) -> str:
if is_zh(language):
return zh
if is_bilingual(language):
return f"{en} / {zh}"
return en
+108 -48
View File
@@ -19,6 +19,13 @@ from src.database.runtime_state import (
)
from src.data_collection.city_registry import CITY_REGISTRY
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
from src.utils.telegram_i18n import (
copy_text as _copy,
is_bilingual as _is_bilingual,
is_zh as _is_zh,
normalize_push_language as _normalize_push_language,
telegram_push_language as _resolve_telegram_push_language,
)
# Forum topic routing: maps city_key -> message_thread_id for the push forum group.
# Created by scripts/create_forum_topics.py, stored in the runtime data dir.
@@ -144,6 +151,14 @@ def _env_float(name: str, default: float) -> float:
return default
def _telegram_push_language() -> str:
return _resolve_telegram_push_language(
"TELEGRAM_AIRPORT_PUSH_LANGUAGE",
"TELEGRAM_PUSH_LANGUAGE",
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
)
def _norm_prob(v: Any) -> Optional[float]:
if v is None:
return None
@@ -631,11 +646,16 @@ WIND_REGIME: Dict[str, Dict[str, Tuple[int, int]]] = {
# Legacy alias for backward compat with existing _select_focus_runway_obs / _focus_runway_pairs_for_city
FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS
_FUNCTION_HASHTAGS = {
_FUNCTION_HASHTAGS_ZH = {
"runway": "#跑道观测",
"airport": "#机场观测",
"trade": "#交易机会",
}
_FUNCTION_HASHTAGS_EN = {
"runway": "#RunwayObs",
"airport": "#AirportObs",
"trade": "#TradeAlert",
}
def _city_hashtag(city: Optional[str]) -> Optional[str]:
@@ -659,15 +679,27 @@ def _build_telegram_hashtag_line(
city: Optional[str] = None,
station: Optional[str] = None,
extra: Optional[List[str]] = None,
language: Optional[str] = None,
) -> str:
tags: List[str] = []
primary = _FUNCTION_HASHTAGS.get(kind)
if primary:
tags.append(primary)
tag_maps = (
(_FUNCTION_HASHTAGS_EN, _FUNCTION_HASHTAGS_ZH)
if _is_bilingual(language)
else ((_FUNCTION_HASHTAGS_ZH,) if _is_zh(language) else (_FUNCTION_HASHTAGS_EN,))
)
for tag_map in tag_maps:
primary = tag_map.get(kind)
if primary and primary not in tags:
tags.append(primary)
for item in extra or []:
tag = _FUNCTION_HASHTAGS.get(item, item)
if tag and tag not in tags:
tags.append(tag)
added = False
for tag_map in tag_maps:
tag = tag_map.get(item)
if tag and tag not in tags:
tags.append(tag)
added = True
if not added and item and item not in tags:
tags.append(item)
city_tag = _city_hashtag(city)
if city_tag and city_tag not in tags:
tags.append(city_tag)
@@ -752,7 +784,7 @@ def _is_settlement_runway(city: str, r1: str, r2: str) -> bool:
return _runway_pair_key(r1, r2) in pair_set
def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]:
def _wind_regime_label(city: str, wind_dir: Optional[int], language: Optional[str] = None) -> Optional[str]:
"""Classify wind direction into a thermal regime label."""
if wind_dir is None:
return None
@@ -760,9 +792,9 @@ def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]:
sea = regimes.get("sea_breeze")
warm = regimes.get("warm_advection")
if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]:
return "海风降温"
return _copy(language, "sea-breeze cooling", "海风降温")
if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]:
return "暖平流增强"
return _copy(language, "warm advection", "暖平流增强")
return None
@@ -789,18 +821,21 @@ def _runway_heat_signal(
slope_15m: Optional[float],
wind_dir: Optional[int],
city: str,
language: Optional[str] = None,
) -> str:
"""Compute a simple runway heat signal label."""
if slope_15m is None:
return ""
regime = _wind_regime_label(city, wind_dir)
regime = _wind_regime_label(city, wind_dir, language)
warm_regime = _copy(language, "warm advection", "暖平流增强")
sea_regime = _copy(language, "sea-breeze cooling", "海风降温")
if slope_15m >= 1.0:
return "🚀 冲顶增强" if regime == "暖平流增强" else "🔥 升温中"
return _copy(language, "🚀 Peak push strengthening", "🚀 冲顶增强") if regime == warm_regime else _copy(language, "🔥 Warming", "🔥 升温中")
if slope_15m >= 0.5:
return "🔥 升温中"
return _copy(language, "🔥 Warming", "🔥 升温中")
if slope_15m >= -0.2:
return "⚠️ 高位观察" if regime == "海风降温" else "⏸️ 高位横盘"
return "🧊 过峰风险"
return _copy(language, "⚠️ Watch sea-breeze cooling", "⚠️ 高位观察") if regime == sea_regime else _copy(language, "⏸️ Holding near high", "⏸️ 高位横盘")
return _copy(language, "🧊 Peak-risk cooling", "🧊 过峰风险")
def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]:
@@ -953,7 +988,9 @@ def _build_airport_status_message(
source_label: str = "",
arome_temp: Optional[float] = None,
aeroweb_available: bool = False,
language: Optional[str] = None,
) -> str:
language = _normalize_push_language(language or _telegram_push_language())
_AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda",
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
"istanbul": "Airport", "paris": "Le Bourget",
@@ -1018,8 +1055,8 @@ def _build_airport_status_message(
# ── Heat model ──
wind_dir = amos.get("wind_dir") if is_amsc else None
slope_15m = _compute_slope_15m(amos_icao, display_temp) if is_amsc and display_temp is not None else None
heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city) if is_amsc else ""
wind_label = _wind_regime_label(city, wind_dir) if is_amsc and wind_dir is not None else None
heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city, language) if is_amsc else ""
wind_label = _wind_regime_label(city, wind_dir, language) if is_amsc and wind_dir is not None else None
max_so_far, max_temp_time = _get_airport_daily_high(city_weather)
# ── Build message ──
@@ -1029,6 +1066,7 @@ def _build_airport_status_message(
hashtag_line = _build_telegram_hashtag_line(
"runway" if has_runway else "airport",
city=city,
language=language,
)
icao_display = f"{amos_icao} · " if amos_icao else ""
settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else ""
@@ -1055,7 +1093,7 @@ def _build_airport_status_message(
mid = pts.get("mid_temp")
end = pts.get("end_temp")
is_settlement = _is_settlement_runway(city, r1, r2)
marker = " ★结算" if is_settlement else ""
marker = _copy(language, " ★Settlement", " ★结算") if is_settlement else ""
tmax = pts.get("target_runway_max")
if tdz is not None or mid is not None or end is not None:
line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}"
@@ -1063,7 +1101,8 @@ def _build_airport_status_message(
line += f" max:{tmax:.1f}"
lines.append(line)
else:
lines.append(f"{r1}/{r2}{marker} {t:.1f}°C")
temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip()
lines.append(f"{r1}/{r2}{marker} {t:.1f}{temp_symbol}")
# Summary stats
lines.append("")
@@ -1072,36 +1111,43 @@ def _build_airport_status_message(
if city == "paris":
if aeroweb_available and display_temp is not None:
lines.append(f"当前实况:{cur_str}")
lines.append(f"{_copy(language, 'Current observation', '当前实况')}: {cur_str}")
else:
lines.append("当前实况:暂无")
lines.append(_copy(language, "Current observation: unavailable", "当前实况:暂无"))
else:
if has_runway:
lines.append(f"结算跑道当前:{cur_str}")
current_label = (
_copy(language, "Settlement runway now", "结算跑道当前")
if settlement_pair
else _copy(language, "Runway now", "跑道当前")
)
lines.append(f"{current_label}: {cur_str}")
else:
lines.append(f"当前:{cur_str}")
lines.append(f"{_copy(language, 'Current', '当前')}: {cur_str}")
if city == "paris":
if aeroweb_available and max_so_far is not None:
time_str = f"{max_temp_time}" if max_temp_time else ""
lines.append(f"日高实况:{max_so_far:.1f}{temp_symbol}{time_str}")
time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"{max_temp_time}" if max_temp_time else "")
lines.append(f"{_copy(language, 'Observed high', '日高实况')}: {max_so_far:.1f}{temp_symbol}{time_str}")
elif not aeroweb_available:
last_temp = _LAST_AEROWEB.get("temp")
if last_temp is not None:
last_time = _LAST_AEROWEB.get("max_time", "")
time_str = f"{last_time}" if last_time else ""
lines.append(f"最近实况:{last_temp:.1f}{temp_symbol}{time_str}")
time_str = f" ({last_time})" if last_time and not _is_zh(language) else (f"{last_time}" if last_time else "")
lines.append(f"{_copy(language, 'Latest observation', '最近实况')}: {last_temp:.1f}{temp_symbol}{time_str}")
elif max_so_far is not None:
time_str = f"{max_temp_time}" if max_temp_time else ""
time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"{max_temp_time}" if max_temp_time else "")
if has_runway:
lines.append(f"今日跑道高点:{max_so_far:.1f}{temp_symbol}{time_str}")
high_label = _copy(language, "Today's runway high", "今日跑道高点")
lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}")
else:
lines.append(f"日高:{max_so_far:.1f}{temp_symbol}{time_str}")
high_label = _copy(language, "Today's high", "日高")
lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}")
if slope_15m is not None:
sign = "+" if slope_15m >= 0 else ""
lines.append(f"15分钟趋势:{sign}{slope_15m:.1f}°C")
lines.append(f"{_copy(language, '15m trend', '15分钟趋势')}: {sign}{slope_15m:.1f}°")
if wind_dir is not None:
wind_str = f"风向:{wind_dir}°"
wind_str = f"{_copy(language, 'Wind dir', '风向')}: {wind_dir}°"
if wind_label:
wind_str += f" {wind_label}"
lines.append(wind_str)
@@ -1127,7 +1173,7 @@ def _build_airport_status_message(
bj_h = hh + 8
if bj_h >= 24:
bj_h -= 24
metar_time = f"北京时 {bj_h:02d}:{mm}"
metar_time = f"{_copy(language, 'Beijing time', '北京时')} {bj_h:02d}:{mm}"
break
if metar_temp or metar_time:
bits = []
@@ -1135,46 +1181,54 @@ def _build_airport_status_message(
bits.append(f"{metar_temp}{temp_symbol}")
if metar_time:
bits.append(metar_time)
lines.append(f"报文: {' '.join(bits)}")
lines.append(f"{_copy(language, 'METAR', '报文')}: {' '.join(bits)}")
if deb_pred is not None:
if city == "paris":
if aeroweb_available and display_temp is not None:
diff = display_temp - deb_pred
sign = "+" if diff >= 0 else ""
lines.append(f"DEB{deb_pred:.1f}{temp_symbol}(距实况 {sign}{diff:.1f}°)")
suffix = _copy(language, f" (vs obs {sign}{diff:.1f}°)", f"(距实况 {sign}{diff:.1f}°)")
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB{deb_pred:.1f}{temp_symbol}{suffix}")
elif not aeroweb_available:
last_temp = _LAST_AEROWEB.get("temp")
if last_temp is not None:
diff = last_temp - deb_pred
sign = "+" if diff >= 0 else ""
lines.append(f"DEB{deb_pred:.1f}{temp_symbol}(距最近实况 {sign}{diff:.1f}°)")
suffix = _copy(language, f" (vs latest obs {sign}{diff:.1f}°)", f"(距最近实况 {sign}{diff:.1f}°)")
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB{deb_pred:.1f}{temp_symbol}{suffix}")
else:
lines.append(f"DEB{deb_pred:.1f}{temp_symbol}")
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB{deb_pred:.1f}{temp_symbol}")
else:
lines.append(f"DEB{deb_pred:.1f}{temp_symbol}")
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB{deb_pred:.1f}{temp_symbol}")
elif display_temp is not None and display_temp > deb_pred:
lines.append(f"DEB{deb_pred:.1f}{temp_symbol}(已突破 +{display_temp - deb_pred:.1f}°)")
suffix = _copy(language, f" (already above +{display_temp - deb_pred:.1f}°)", f"(已突破 +{display_temp - deb_pred:.1f}°)")
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB{deb_pred:.1f}{temp_symbol}{suffix}")
else:
lines.append(f"DEB{deb_pred:.1f}{temp_symbol}")
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB{deb_pred:.1f}{temp_symbol}")
if city == "paris":
lines.append("")
if aeroweb_available and display_temp is not None:
lines.append(f"📡 AEROWEB 机场实况:{display_temp:.1f}{temp_symbol} · Météo-France")
label = _copy(language, "AEROWEB airport obs", "AEROWEB 机场实况")
lines.append(f"📡 {label}: {display_temp:.1f}{temp_symbol} · Météo-France")
else:
lines.append("📡 AEROWEB 机场实况:暂不可用")
lines.append(_copy(language, "📡 AEROWEB airport obs: unavailable", "📡 AEROWEB 机场实况:暂不可用"))
if arome_temp is not None:
if aeroweb_available and display_temp is not None:
d = arome_temp - display_temp
sign = "+" if d >= 0 else ""
lines.append(f"🕐 AROME HD 15分钟临近预报:{arome_temp:.1f}{temp_symbol}(较实况 {sign}{d:.1f}°)")
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
suffix = _copy(language, f" (vs obs {sign}{d:.1f}°)", f"(较实况 {sign}{d:.1f}°)")
lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}{suffix}")
else:
lines.append(f"🕐 AROME HD 15分钟临近预报:{arome_temp:.1f}{temp_symbol}")
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}")
else:
lines.append("🕐 AROME HD 15分钟临近预报--")
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
lines.append(f"🕐 {label}: --")
if not aeroweb_available:
lines.append("")
lines.append("提示:当前仅显示模型参考,不更新实况日高。")
lines.append(_copy(language, "Note: showing model reference only; observed high is not updating.", "提示:当前仅显示模型参考,不更新实况日高。"))
elif source_label:
lines.append("")
lines.append(f"📡 {source_label}")
@@ -1186,8 +1240,14 @@ def _build_airport_status_message(
if len(vals) >= 2:
lo, hi = vals[0][0], vals[-1][0]
spread = hi - lo
spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧")
lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}")
if spread <= 2.0:
spread_label = _copy(language, "low dispersion", "低分歧")
elif spread <= 4.0:
spread_label = _copy(language, "moderate dispersion", "中等分歧")
else:
spread_label = _copy(language, "high dispersion", "高分歧")
range_label = _copy(language, "Model range", "模型区间")
lines.append(f"{range_label}: {lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}")
return "\n".join(lines)