2026-05-23 09:24:18 +08:00
|
|
|
|
"""Daily weather report for Chinese cities — AI-generated narrative pushed to Telegram."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
2026-05-23 09:59:37 +08:00
|
|
|
|
import re
|
2026-05-23 09:24:18 +08:00
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
from datetime import timezone as _utc_tz
|
|
|
|
|
|
from datetime import timedelta as _td
|
|
|
|
|
|
|
|
|
|
|
|
ZoneInfo = None # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
from src.data_collection.city_registry import CITY_REGISTRY
|
|
|
|
|
|
from src.data_collection.weather_sources import WeatherDataCollector
|
2026-05-27 07:50:05 +08:00
|
|
|
|
from src.utils.telegram_i18n import copy_text, normalize_push_language, telegram_push_language
|
2026-05-23 09:24:18 +08:00
|
|
|
|
|
|
|
|
|
|
TARGET_CITIES: List[str] = [
|
|
|
|
|
|
"beijing",
|
|
|
|
|
|
"shanghai",
|
|
|
|
|
|
"guangzhou",
|
|
|
|
|
|
"chengdu",
|
|
|
|
|
|
"chongqing",
|
|
|
|
|
|
"wuhan",
|
|
|
|
|
|
"qingdao",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-24 16:03:29 +08:00
|
|
|
|
FORUM_CHAT_ID = "-1003927451869"
|
2026-05-23 09:24:18 +08:00
|
|
|
|
|
|
|
|
|
|
CITY_NAME_ZH: Dict[str, str] = {
|
|
|
|
|
|
"beijing": "北京",
|
|
|
|
|
|
"shanghai": "上海",
|
|
|
|
|
|
"guangzhou": "广州",
|
|
|
|
|
|
"chengdu": "成都",
|
|
|
|
|
|
"chongqing": "重庆",
|
|
|
|
|
|
"wuhan": "武汉",
|
|
|
|
|
|
"qingdao": "青岛",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 09:59:37 +08:00
|
|
|
|
# weather.com.cn city codes
|
|
|
|
|
|
CMA_CITY_CODES: Dict[str, str] = {
|
|
|
|
|
|
"beijing": "101010100",
|
|
|
|
|
|
"shanghai": "101020100",
|
|
|
|
|
|
"guangzhou": "101280101",
|
|
|
|
|
|
"chengdu": "101270101",
|
|
|
|
|
|
"chongqing": "101040100",
|
|
|
|
|
|
"wuhan": "101200101",
|
|
|
|
|
|
"qingdao": "101120201",
|
|
|
|
|
|
}
|
2026-05-23 09:24:18 +08:00
|
|
|
|
|
2026-05-23 09:59:37 +08:00
|
|
|
|
_CMA_FORECAST_URL = "http://www.weather.com.cn/weather/{code}.shtml"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _env_bool(name: str, default: bool) -> bool:
|
|
|
|
|
|
raw = os.getenv(name)
|
|
|
|
|
|
if raw is None:
|
|
|
|
|
|
return default
|
|
|
|
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _env_int(name: str, default: int, min_val: int = 0) -> int:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return max(min_val, int(os.getenv(name, str(default))))
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 07:50:05 +08:00
|
|
|
|
def _daily_report_language() -> str:
|
|
|
|
|
|
return telegram_push_language(
|
|
|
|
|
|
"DAILY_WEATHER_REPORT_LANGUAGE",
|
|
|
|
|
|
"TELEGRAM_PUSH_LANGUAGE",
|
|
|
|
|
|
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 09:59:37 +08:00
|
|
|
|
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)
|
|
|
|
|
|
if not code:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
url = _CMA_FORECAST_URL.format(code=code)
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = httpx.get(
|
|
|
|
|
|
url,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": (
|
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
|
|
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
|
|
|
|
"Chrome/120.0.0.0 Safari/537.36"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=httpx.Timeout(timeout=10.0, connect=5.0, read=10.0),
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
html = resp.text
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"daily_weather_report: CMA fetch failed for {}: {}", city_key, exc
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Parse today's weather block from the 7-day forecast page.
|
|
|
|
|
|
# The HTML structure has entries like:
|
|
|
|
|
|
# <p class="wea">晴转多云</p>
|
|
|
|
|
|
# <p class="tem"><span>25℃</span> / <i>19℃</i></p>
|
|
|
|
|
|
# We target the first occurrence (today).
|
|
|
|
|
|
|
|
|
|
|
|
weather = _extract_first(html, r'<p[^>]*class="wea"[^>]*>([^<]+)</p>')
|
|
|
|
|
|
tem_text = _extract_first(html, r'<p[^>]*class="tem"[^>]*>(.+?)</p>')
|
|
|
|
|
|
|
|
|
|
|
|
high_str: Optional[str] = None
|
|
|
|
|
|
low_str: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
if tem_text:
|
2026-05-23 10:13:16 +08:00
|
|
|
|
# Strip all HTML tags, then find numbers followed by degree
|
|
|
|
|
|
clean = re.sub(r"<[^>]+>", " ", tem_text)
|
|
|
|
|
|
nums = re.findall(r"(-?\d+)\s*(?:℃|°C|°c|°)?", clean)
|
|
|
|
|
|
if len(nums) >= 1:
|
|
|
|
|
|
high_str = nums[0]
|
|
|
|
|
|
if len(nums) >= 2:
|
|
|
|
|
|
low_str = nums[1]
|
2026-05-23 09:59:37 +08:00
|
|
|
|
|
|
|
|
|
|
if not weather and not high_str:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
result: Dict[str, Any] = {"source": "cma"}
|
|
|
|
|
|
if weather:
|
|
|
|
|
|
result["weather"] = weather.strip()
|
|
|
|
|
|
if high_str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result["forecast_high"] = float(high_str)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
result["forecast_high"] = None
|
|
|
|
|
|
if low_str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result["forecast_low"] = float(low_str)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
result["forecast_low"] = None
|
|
|
|
|
|
|
2026-05-23 10:06:52 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"daily_weather_report: CMA parsed {} weather={} high={} low={}",
|
|
|
|
|
|
city_key,
|
|
|
|
|
|
result.get("weather"),
|
|
|
|
|
|
result.get("forecast_high"),
|
|
|
|
|
|
result.get("forecast_low"),
|
|
|
|
|
|
)
|
2026-05-23 09:59:37 +08:00
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_first(html: str, pattern: str) -> Optional[str]:
|
2026-05-23 10:18:36 +08:00
|
|
|
|
m = re.search(pattern, html, re.IGNORECASE | re.DOTALL)
|
2026-05-23 09:59:37 +08:00
|
|
|
|
return m.group(1) if m else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_city_data(
|
|
|
|
|
|
collector: WeatherDataCollector, city_key: str
|
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
name = CITY_NAME_ZH.get(city_key, city_key)
|
2026-05-27 07:50:05 +08:00
|
|
|
|
info = CITY_REGISTRY.get(city_key)
|
|
|
|
|
|
name_en = str((info or {}).get("name") or city_key.title())
|
2026-05-23 09:59:37 +08:00
|
|
|
|
|
2026-05-23 10:13:16 +08:00
|
|
|
|
# 1. Try CMA first for weather description
|
2026-05-23 09:59:37 +08:00
|
|
|
|
cma = _fetch_cma_forecast(city_key)
|
|
|
|
|
|
|
2026-05-23 10:13:16 +08:00
|
|
|
|
# 2. Get Open-Meteo data for temperature fallback
|
|
|
|
|
|
om_high = None
|
|
|
|
|
|
if info:
|
|
|
|
|
|
try:
|
|
|
|
|
|
results = collector.fetch_all_sources(
|
|
|
|
|
|
city_key,
|
|
|
|
|
|
lat=info["lat"],
|
|
|
|
|
|
lon=info["lon"],
|
|
|
|
|
|
include_taf=False,
|
|
|
|
|
|
include_ensemble=False,
|
|
|
|
|
|
include_multi_model=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
if isinstance(results, dict):
|
|
|
|
|
|
om = results.get("open-meteo", {})
|
|
|
|
|
|
daily = om.get("daily", {}) if isinstance(om, dict) else {}
|
|
|
|
|
|
daily_highs = daily.get("temperature_2m_max", []) or []
|
|
|
|
|
|
om_high = daily_highs[0] if daily_highs else None
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"daily_weather_report: OM fetch failed for {}: {}", city_key, exc
|
|
|
|
|
|
)
|
2026-05-23 09:59:37 +08:00
|
|
|
|
|
2026-05-23 10:13:16 +08:00
|
|
|
|
# Use CMA weather + CMA high, fall back to OM high
|
|
|
|
|
|
weather: Optional[str] = None
|
|
|
|
|
|
forecast_high: Optional[float] = None
|
2026-05-23 09:59:37 +08:00
|
|
|
|
|
2026-05-23 10:13:16 +08:00
|
|
|
|
if cma:
|
|
|
|
|
|
weather = cma.get("weather")
|
|
|
|
|
|
forecast_high = cma.get("forecast_high")
|
2026-05-23 09:59:37 +08:00
|
|
|
|
if forecast_high is None:
|
2026-05-23 10:13:16 +08:00
|
|
|
|
forecast_high = om_high
|
|
|
|
|
|
|
|
|
|
|
|
if not weather and not forecast_high:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"daily_weather_report: {} weather={} high={} (cma_ok={})",
|
|
|
|
|
|
city_key,
|
|
|
|
|
|
weather or "?",
|
|
|
|
|
|
forecast_high,
|
|
|
|
|
|
bool(cma and cma.get("weather")),
|
|
|
|
|
|
)
|
2026-05-23 09:59:37 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"city": city_key,
|
|
|
|
|
|
"name": name,
|
2026-05-27 07:50:05 +08:00
|
|
|
|
"name_en": name_en,
|
2026-05-23 10:13:16 +08:00
|
|
|
|
"weather": weather or "?",
|
2026-05-23 09:59:37 +08:00
|
|
|
|
"forecast_high": forecast_high,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wmo_to_weather(code: Any) -> str:
|
|
|
|
|
|
"""Translate WMO weather code to Chinese (fallback only)."""
|
2026-05-23 09:53:12 +08:00
|
|
|
|
try:
|
|
|
|
|
|
c = int(code or 0)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return "未知"
|
|
|
|
|
|
if c == 0:
|
|
|
|
|
|
return "晴"
|
|
|
|
|
|
if 1 <= c <= 3:
|
|
|
|
|
|
return "多云"
|
|
|
|
|
|
if c in (45, 48):
|
|
|
|
|
|
return "雾"
|
|
|
|
|
|
if 51 <= c <= 67:
|
|
|
|
|
|
return "雨"
|
|
|
|
|
|
if 71 <= c <= 86:
|
|
|
|
|
|
return "雪"
|
|
|
|
|
|
if 95 <= c <= 99:
|
|
|
|
|
|
return "雷暴"
|
|
|
|
|
|
return "阴"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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:")
|
2026-05-23 10:27:07 +08:00
|
|
|
|
for c in cities_data:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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."
|
|
|
|
|
|
)
|
2026-05-23 10:27:07 +08:00
|
|
|
|
return "\n".join(lines)
|
2026-05-23 09:24:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _call_ai(prompt: str) -> Optional[str]:
|
2026-05-28 08:24:50 +08:00
|
|
|
|
api_key = os.getenv("DAILY_REPORT_AI_API_KEY", "")
|
2026-05-23 09:24:18 +08:00
|
|
|
|
base_url = os.getenv(
|
2026-05-28 08:24:50 +08:00
|
|
|
|
"DAILY_REPORT_AI_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1"
|
2026-05-23 09:24:18 +08:00
|
|
|
|
)
|
2026-05-28 08:24:50 +08:00
|
|
|
|
model = os.getenv("DAILY_REPORT_AI_MODEL", "mimo-v2.5-pro")
|
2026-05-23 09:24:18 +08:00
|
|
|
|
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
logger.warning("daily_weather_report: AI API key not configured")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"messages": [
|
|
|
|
|
|
{"role": "user", "content": prompt},
|
|
|
|
|
|
],
|
2026-05-23 10:27:07 +08:00
|
|
|
|
"max_tokens": 800,
|
|
|
|
|
|
"temperature": 0.5,
|
2026-05-23 09:24:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
timeout = httpx.Timeout(timeout=30.0, connect=8.0, read=30.0)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with httpx.Client(timeout=timeout) as client:
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"{base_url}/chat/completions",
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {api_key}",
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
},
|
|
|
|
|
|
json=payload,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
data = resp.json()
|
2026-05-23 09:50:08 +08:00
|
|
|
|
choice = (data.get("choices") or [{}])[0]
|
|
|
|
|
|
content = choice.get("message", {}).get("content", "")
|
|
|
|
|
|
finish = choice.get("finish_reason", "")
|
|
|
|
|
|
if not str(content or "").strip():
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"daily_weather_report: AI empty content finish_reason={} model={}",
|
|
|
|
|
|
finish,
|
|
|
|
|
|
model,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
return str(content).strip()
|
2026-05-23 09:24:18 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(f"daily_weather_report: AI call failed: {exc}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _runner(bot: Any, config: Dict[str, Any]) -> None:
|
|
|
|
|
|
enabled = _env_bool("DAILY_WEATHER_REPORT_ENABLED", True)
|
|
|
|
|
|
if not enabled:
|
|
|
|
|
|
logger.info("daily_weather_report: disabled by env")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
tz_name = str(os.getenv("DAILY_WEATHER_REPORT_TIMEZONE") or "Asia/Shanghai").strip()
|
|
|
|
|
|
report_hour = _env_int("DAILY_WEATHER_REPORT_HOUR", 8)
|
|
|
|
|
|
report_minute = _env_int("DAILY_WEATHER_REPORT_MINUTE", 0)
|
|
|
|
|
|
|
|
|
|
|
|
if ZoneInfo is None:
|
|
|
|
|
|
local_tz = _utc_tz(_td(hours=8))
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
local_tz = ZoneInfo(tz_name)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
local_tz = ZoneInfo("Asia/Shanghai")
|
|
|
|
|
|
|
|
|
|
|
|
collector = WeatherDataCollector(config)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"daily_weather_report: started tz={} time={:02d}:{:02d} cities={}",
|
|
|
|
|
|
tz_name,
|
|
|
|
|
|
report_hour,
|
|
|
|
|
|
report_minute,
|
|
|
|
|
|
len(TARGET_CITIES),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
sent_today = False
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
now = datetime.now(local_tz)
|
|
|
|
|
|
|
|
|
|
|
|
if now.hour == 0 and now.minute < 5:
|
|
|
|
|
|
sent_today = False
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
now.hour == report_hour
|
|
|
|
|
|
and now.minute >= report_minute
|
|
|
|
|
|
and not sent_today
|
|
|
|
|
|
):
|
|
|
|
|
|
logger.info("daily_weather_report: generating report...")
|
|
|
|
|
|
|
|
|
|
|
|
cities_data: List[Dict[str, Any]] = []
|
|
|
|
|
|
for city_key in TARGET_CITIES:
|
|
|
|
|
|
data = _fetch_city_data(collector, city_key)
|
|
|
|
|
|
if data:
|
|
|
|
|
|
cities_data.append(data)
|
|
|
|
|
|
|
|
|
|
|
|
if not cities_data:
|
|
|
|
|
|
logger.warning("daily_weather_report: no city data available")
|
|
|
|
|
|
sent_today = True
|
|
|
|
|
|
time.sleep(60)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-05-27 07:50:05 +08:00
|
|
|
|
language = _daily_report_language()
|
2026-05-23 09:38:08 +08:00
|
|
|
|
report_date = now.strftime("%m月%d日")
|
2026-05-27 07:50:05 +08:00
|
|
|
|
prompt = _build_ai_prompt(cities_data, report_date, language=language)
|
2026-05-23 09:24:18 +08:00
|
|
|
|
report_text = _call_ai(prompt)
|
|
|
|
|
|
|
|
|
|
|
|
if not report_text:
|
|
|
|
|
|
logger.warning("daily_weather_report: AI returned empty content")
|
|
|
|
|
|
sent_today = True
|
|
|
|
|
|
time.sleep(60)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
report_text += "\n\n" + copy_text(
|
|
|
|
|
|
language,
|
|
|
|
|
|
"⚠️ Rough forecast only. For reference.",
|
|
|
|
|
|
"⚠️ 以上为粗略预测,仅供参考。",
|
|
|
|
|
|
)
|
2026-05-23 09:24:18 +08:00
|
|
|
|
bot.send_message(
|
|
|
|
|
|
FORUM_CHAT_ID,
|
|
|
|
|
|
report_text,
|
|
|
|
|
|
message_thread_id=0,
|
|
|
|
|
|
parse_mode="HTML",
|
|
|
|
|
|
disable_web_page_preview=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"daily_weather_report: sent successfully chars={} cities={}",
|
|
|
|
|
|
len(report_text),
|
|
|
|
|
|
len(cities_data),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("daily_weather_report: send failed: {}", exc)
|
|
|
|
|
|
|
|
|
|
|
|
sent_today = True
|
|
|
|
|
|
|
|
|
|
|
|
time.sleep(60)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(f"daily_weather_report: cycle error: {exc}")
|
|
|
|
|
|
time.sleep(60)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_daily_weather_report_loop(
|
|
|
|
|
|
bot: Any, config: Dict[str, Any]
|
|
|
|
|
|
) -> threading.Thread:
|
|
|
|
|
|
thread = threading.Thread(
|
|
|
|
|
|
target=_runner,
|
|
|
|
|
|
args=(bot, config),
|
|
|
|
|
|
daemon=True,
|
|
|
|
|
|
name="daily-weather-report-loop",
|
|
|
|
|
|
)
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
return thread
|