209 lines
8.3 KiB
Python
209 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from loguru import logger
|
|
|
|
from src.utils.metrics import record_source_call
|
|
|
|
|
|
NMC_CITY_REFERENCES: Dict[str, Dict[str, Any]] = {
|
|
"shanghai": {
|
|
"region_label": "浦东",
|
|
"page_url": "https://m.nmc.cn/publish/forecast/ASH/pudong.html",
|
|
"station_code": "atcMf",
|
|
},
|
|
"beijing": {
|
|
"region_label": "顺义",
|
|
"page_url": "https://m.nmc.cn/publish/forecast/ABJ/shunyi.html",
|
|
"station_code": "MKoqG",
|
|
},
|
|
"chongqing": {
|
|
"region_label": "渝北",
|
|
"page_url": "https://m.nmc.cn/publish/forecast/ACQ/yubei.html",
|
|
"station_code": "xFVYU",
|
|
},
|
|
"chengdu": {
|
|
"region_label": "双流",
|
|
"page_url": "https://m.nmc.cn/publish/forecast/ASC/shuangliu.html",
|
|
"station_code": "grFhZ",
|
|
},
|
|
"wuhan": {
|
|
"region_label": "武汉",
|
|
"page_url": "https://m.nmc.cn/publish/forecast/AHB/wuhan.html",
|
|
"station_code": "bSpCz",
|
|
},
|
|
"shenzhen": {
|
|
"region_label": "深圳",
|
|
"page_url": "https://m.nmc.cn/publish/forecast/AGD/shenzuo.html",
|
|
"station_code": "AhpEU",
|
|
},
|
|
}
|
|
|
|
|
|
class NmcSourceMixin:
|
|
def _nmc_http_get(self, url: str):
|
|
getter = getattr(self, "_http_get", None)
|
|
if callable(getter):
|
|
return getter(url)
|
|
return self.session.get(url, timeout=self.timeout)
|
|
|
|
def _nmc_http_get_json(self, url: str):
|
|
getter = getattr(self, "_http_get_json", None)
|
|
if callable(getter):
|
|
return getter(url)
|
|
response = self.session.get(url, timeout=self.timeout)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
@staticmethod
|
|
def _nmc_optional_text(value: Any) -> Optional[str]:
|
|
text = str(value or "").strip()
|
|
if text in ("", "9999"):
|
|
return None
|
|
return text
|
|
|
|
@staticmethod
|
|
def _nmc_optional_float(value: Any) -> Optional[float]:
|
|
if value in (None, "", "9999", 9999, 9999.0):
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except Exception:
|
|
return None
|
|
|
|
def _resolve_nmc_station_code(self, city: str) -> Optional[str]:
|
|
city_key = str(city or "").strip().lower()
|
|
meta = NMC_CITY_REFERENCES.get(city_key) or {}
|
|
station_code = str(meta.get("station_code") or "").strip()
|
|
if station_code:
|
|
return station_code
|
|
|
|
page_url = str(meta.get("page_url") or "").strip()
|
|
if not page_url:
|
|
return None
|
|
|
|
try:
|
|
resp = self._nmc_http_get(page_url)
|
|
resp.raise_for_status()
|
|
match = re.search(
|
|
r"renderWeatherRealPanel\('([^']+)',\s*'([^']+)'\)",
|
|
resp.text,
|
|
)
|
|
if not match:
|
|
return None
|
|
station_code = str(match.group(1) or "").strip()
|
|
if station_code:
|
|
meta["station_code"] = station_code
|
|
return station_code
|
|
except Exception as exc:
|
|
logger.warning("NMC station code resolve failed city={} error={}", city_key, exc)
|
|
return None
|
|
|
|
def fetch_nmc_region_current(
|
|
self,
|
|
city: str,
|
|
use_fahrenheit: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
started = time.perf_counter()
|
|
city_key = str(city or "").strip().lower()
|
|
meta = NMC_CITY_REFERENCES.get(city_key) or {}
|
|
if not meta:
|
|
record_source_call("nmc", "current", "unsupported_city", (time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
cache_key = f"{city_key}:{use_fahrenheit}"
|
|
now_ts = time.time()
|
|
with self._nmc_cache_lock:
|
|
cached = self._nmc_cache.get(cache_key)
|
|
if cached and now_ts - cached["t"] < self.nmc_cache_ttl_sec:
|
|
record_source_call("nmc", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
|
|
return cached["d"]
|
|
|
|
station_code = self._resolve_nmc_station_code(city_key)
|
|
if not station_code:
|
|
record_source_call("nmc", "current", "missing_station_code", (time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
try:
|
|
url = f"https://www.nmc.cn/rest/real/{station_code}"
|
|
payload = self._nmc_http_get_json(url)
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("weather"), dict):
|
|
record_source_call("nmc", "current", "empty", (time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
weather = payload.get("weather") or {}
|
|
temp_c = weather.get("temperature")
|
|
if temp_c in (None, "", "9999"):
|
|
record_source_call("nmc", "current", "no_temperature", (time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
temp_c = float(temp_c)
|
|
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
|
|
|
|
station = payload.get("station") or {}
|
|
result = {
|
|
"source": "nmc",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"station_code": station_code,
|
|
"station_name": station.get("city") or meta.get("region_label") or city_key.title(),
|
|
"page_url": meta.get("page_url"),
|
|
"publish_time": payload.get("publish_time"),
|
|
"current": {
|
|
"temp": temp,
|
|
"humidity": self._nmc_optional_float(weather.get("humidity")),
|
|
"rain": self._nmc_optional_float(weather.get("rain")),
|
|
"airpressure": self._nmc_optional_float(weather.get("airpressure")),
|
|
"wx_desc": weather.get("info"),
|
|
"wind_direction": self._nmc_optional_text((payload.get("wind") or {}).get("direct")),
|
|
"wind_power": self._nmc_optional_text((payload.get("wind") or {}).get("power")),
|
|
},
|
|
}
|
|
with self._nmc_cache_lock:
|
|
self._nmc_cache[cache_key] = {"d": result, "t": now_ts}
|
|
record_source_call("nmc", "current", "success", (time.perf_counter() - started) * 1000.0)
|
|
return result
|
|
except Exception as exc:
|
|
logger.warning("NMC current fetch failed city={} code={} error={}", city_key, station_code, exc)
|
|
with self._nmc_cache_lock:
|
|
stale = self._nmc_cache.get(cache_key)
|
|
if stale:
|
|
record_source_call("nmc", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
|
return stale["d"]
|
|
record_source_call("nmc", "current", "error", (time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
def fetch_nmc_official_nearby(
|
|
self,
|
|
city: str,
|
|
use_fahrenheit: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
current = self.fetch_nmc_region_current(city, use_fahrenheit=use_fahrenheit)
|
|
if not current:
|
|
return []
|
|
meta = NMC_CITY_REFERENCES.get(str(city or "").strip().lower()) or {}
|
|
city_meta = self.CITY_REGISTRY.get(str(city or "").strip().lower()) or {}
|
|
return [
|
|
{
|
|
"name": f"{meta.get('region_label') or current.get('station_name')}区域实况 (NMC)",
|
|
"station_label": f"{meta.get('region_label') or current.get('station_name')}区域实况 (NMC)",
|
|
"lat": city_meta.get("lat"),
|
|
"lon": city_meta.get("lon"),
|
|
"temp": current.get("current", {}).get("temp"),
|
|
"istNo": current.get("station_code"),
|
|
"icao": current.get("station_code"),
|
|
"source": "nmc",
|
|
"source_label": "NMC",
|
|
"obs_time": current.get("publish_time"),
|
|
"page_url": current.get("page_url"),
|
|
"humidity": current.get("current", {}).get("humidity"),
|
|
"rain": current.get("current", {}).get("rain"),
|
|
"airpressure": current.get("current", {}).get("airpressure"),
|
|
"wx_desc": current.get("current", {}).get("wx_desc"),
|
|
"wind_direction_text": current.get("current", {}).get("wind_direction"),
|
|
"wind_power_text": current.get("current", {}).get("wind_power"),
|
|
}
|
|
]
|