370 lines
17 KiB
Python
370 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import unicodedata
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, Optional
|
|
|
|
from loguru import logger
|
|
|
|
from src.utils.metrics import record_source_call
|
|
|
|
|
|
class MgmSourceMixin:
|
|
@staticmethod
|
|
def _normalize_mgm_text(value: str) -> str:
|
|
text = unicodedata.normalize("NFD", str(value or ""))
|
|
return "".join(ch for ch in text if unicodedata.category(ch) != "Mn").lower()
|
|
|
|
def fetch_from_mgm(self, istno: str) -> Optional[Dict]:
|
|
"""
|
|
从土耳其气象局 (MGM) 获取实时数据和预测 (由用户提供其内部 API)
|
|
"""
|
|
started = datetime.now()
|
|
base_url = "https://servis.mgm.gov.tr/web"
|
|
# 必须带 Origin,否则会被反爬拦截
|
|
headers = {
|
|
"Origin": "https://www.mgm.gov.tr",
|
|
"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",
|
|
}
|
|
results = {}
|
|
|
|
try:
|
|
# 1. 实时数据 (添加时间戳防止 CDN 缓存)
|
|
import time
|
|
|
|
obs_resp = self._http_get(
|
|
f"{base_url}/sondurumlar?istno={istno}&_={int(time.time() * 1000)}",
|
|
headers=headers,
|
|
timeout=self.timeout,
|
|
)
|
|
if obs_resp.status_code == 200:
|
|
data = obs_resp.json()
|
|
if data:
|
|
latest = data[0] if isinstance(data, list) else data
|
|
# MGM 数据字段映射
|
|
# ruzgarHiz 实测为 km/h,转为 m/s 需要除以 3.6
|
|
ruz_hiz_kmh = latest.get("ruzgarHiz", 0)
|
|
|
|
# MGM 返回 -9999 表示数据缺失,需要过滤
|
|
def _valid(v):
|
|
return v is not None and v > -9000
|
|
|
|
results["current"] = {
|
|
"temp": latest.get("sicaklik")
|
|
if _valid(latest.get("sicaklik"))
|
|
else None,
|
|
"feels_like": latest.get("hissedilenSicaklik")
|
|
if _valid(latest.get("hissedilenSicaklik"))
|
|
else None,
|
|
"humidity": latest.get("nem")
|
|
if _valid(latest.get("nem"))
|
|
else None,
|
|
"wind_speed_ms": round(ruz_hiz_kmh / 3.6, 1)
|
|
if _valid(ruz_hiz_kmh)
|
|
else None,
|
|
"wind_speed_kt": round(ruz_hiz_kmh / 1.852, 1)
|
|
if _valid(ruz_hiz_kmh)
|
|
else None,
|
|
"wind_dir": latest.get("ruzgarYon")
|
|
if _valid(latest.get("ruzgarYon"))
|
|
else None,
|
|
"rain_24h": latest.get("toplamYagis")
|
|
if _valid(latest.get("toplamYagis"))
|
|
else None,
|
|
"pressure": latest.get("aktuelBasinc")
|
|
if _valid(latest.get("aktuelBasinc"))
|
|
else None,
|
|
"cloud_cover": latest.get("kapalilik"), # 0-8 八分位云量
|
|
"mgm_max_temp": latest.get("maxSicaklik")
|
|
if _valid(latest.get("maxSicaklik"))
|
|
else None,
|
|
"time": latest.get("veriZamani"),
|
|
"station_name": latest.get("istasyonAd")
|
|
or latest.get("adi")
|
|
or latest.get("merkezAd")
|
|
or f"MGM Station {istno}",
|
|
}
|
|
|
|
# 2. 每日预报
|
|
# 2026-03: /api/tahminler/gunluk now returns 403 consistently, while
|
|
# /web/tahminler/gunluk is reachable but may legitimately return [] for
|
|
# station-level forecast. Keep the reachable endpoint only and fall
|
|
# back to hourly/current data when station forecast is unavailable.
|
|
forecast_urls = [
|
|
f"{base_url}/tahminler/gunluk?istno={istno}",
|
|
]
|
|
for forecast_url in forecast_urls:
|
|
try:
|
|
daily_resp = self._http_get(
|
|
forecast_url, headers=headers, timeout=self.timeout
|
|
)
|
|
if daily_resp.status_code == 200:
|
|
forecasts = daily_resp.json()
|
|
if forecasts and isinstance(forecasts, list):
|
|
# Store today extra clearly
|
|
today = forecasts[0]
|
|
high_val = today.get("enYuksekGun1")
|
|
low_val = today.get("enDusukGun1")
|
|
if high_val is not None:
|
|
results["today_high"] = high_val
|
|
results["today_low"] = low_val
|
|
logger.info(f"📋 MGM 每日预报: 今天的最高温 {high_val}°C")
|
|
|
|
# Store all 5 days for multi_model_daily
|
|
results["daily_forecasts"] = {}
|
|
for i, day in enumerate(forecasts[:5]):
|
|
d_high = day.get("enYuksekGun1")
|
|
if d_high is not None:
|
|
# Calculate date (today + offset)
|
|
target_date = (datetime.now() + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
results["daily_forecasts"][target_date] = d_high
|
|
break
|
|
logger.debug(
|
|
f"MGM daily forecast unavailable for istno={istno}: "
|
|
f"{forecast_url} returned empty payload"
|
|
)
|
|
else:
|
|
logger.debug(
|
|
f"MGM forecast URL {forecast_url} returned {daily_resp.status_code}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"MGM forecast URL {forecast_url} failed: {e}")
|
|
|
|
# 3. 小时预报
|
|
try:
|
|
hourly_resp = self._http_get(
|
|
f"{base_url}/tahminler/saatlik?istno={istno}",
|
|
headers=headers,
|
|
timeout=self.timeout
|
|
)
|
|
if hourly_resp.status_code == 200:
|
|
h_data = hourly_resp.json()
|
|
if h_data and isinstance(h_data, list):
|
|
tahmin_list = h_data[0].get("tahmin", [])
|
|
results["hourly"] = []
|
|
for t_data in tahmin_list:
|
|
if "tarih" in t_data and "sicaklik" in t_data:
|
|
results["hourly"].append({
|
|
"time": t_data["tarih"],
|
|
"temp": t_data["sicaklik"]
|
|
})
|
|
except Exception as e:
|
|
logger.debug(f"MGM hourly failed: {e}")
|
|
|
|
# 4. Fallback for today_high (if daily forecast is missing it)
|
|
if "today_high" not in results:
|
|
# Try from current max
|
|
cur_max = results.get("current", {}).get("mgm_max_temp")
|
|
if cur_max is not None:
|
|
results["today_high"] = cur_max
|
|
logger.info(f"📋 MGM 每日预报: 使用当前测站最高温作为今日预报回退: {cur_max}°C")
|
|
elif "hourly" in results and results["hourly"]:
|
|
# Try from hourly
|
|
h_max = max((h["temp"] for h in results["hourly"] if h["temp"] is not None), default=None)
|
|
if h_max is not None:
|
|
results["today_high"] = h_max
|
|
logger.info(f"📋 MGM 每日预报: 使用小时预报最高温作为今日预报回退: {h_max}°C")
|
|
|
|
# 5. Fallback for daily_forecasts from hourly data
|
|
if not results.get("daily_forecasts") and results.get("hourly"):
|
|
# Guardrail: avoid treating short intraday snippets as full-day highs.
|
|
hourly_rows = results.get("hourly") or []
|
|
parsed_times = []
|
|
for h in hourly_rows:
|
|
t = str(h.get("time") or "")
|
|
if "T" not in t:
|
|
continue
|
|
try:
|
|
parsed_times.append(datetime.fromisoformat(t.replace("Z", "+00:00")))
|
|
except Exception:
|
|
continue
|
|
|
|
horizon_hours = 0.0
|
|
if len(parsed_times) >= 2:
|
|
parsed_times.sort()
|
|
horizon_hours = (
|
|
parsed_times[-1] - parsed_times[0]
|
|
).total_seconds() / 3600.0
|
|
|
|
if len(hourly_rows) >= 24 or horizon_hours >= 30:
|
|
from collections import defaultdict
|
|
|
|
daily_max = defaultdict(list)
|
|
for h in hourly_rows:
|
|
t = h.get("time", "")
|
|
temp = h.get("temp")
|
|
if t and temp is not None:
|
|
# Extract date from ISO timestamp like "2026-03-05T12:00:00.000Z"
|
|
date_str = t[:10]
|
|
daily_max[date_str].append(temp)
|
|
if daily_max:
|
|
results["daily_forecasts"] = {}
|
|
for d, temps in sorted(daily_max.items()):
|
|
results["daily_forecasts"][d] = max(temps)
|
|
logger.info(
|
|
f"📋 MGM daily_forecasts (from hourly fallback): "
|
|
f"{dict(results['daily_forecasts'])}"
|
|
)
|
|
else:
|
|
logger.info(
|
|
"📋 Skip MGM daily_forecasts hourly fallback: "
|
|
f"hourly points={len(hourly_rows)}, horizon={horizon_hours:.1f}h"
|
|
)
|
|
|
|
record_source_call(
|
|
"mgm",
|
|
"station",
|
|
"success" if "current" in results else "empty",
|
|
(datetime.now() - started).total_seconds() * 1000.0,
|
|
)
|
|
return results if "current" in results else None
|
|
except Exception as e:
|
|
logger.error(f"MGM API 请求失败 ({istno}): {e}")
|
|
record_source_call(
|
|
"mgm",
|
|
"station",
|
|
"error",
|
|
(datetime.now() - started).total_seconds() * 1000.0,
|
|
)
|
|
return None
|
|
|
|
def fetch_mgm_nearby_stations(self, province: str, root_ist_no: str = None) -> list:
|
|
"""
|
|
获取一个土耳其省份内所有气象站的当前温度及经纬度
|
|
使用多线程辅助抓取,因为直接通过 il={province} 往往只返回 1 个站。
|
|
"""
|
|
started = datetime.now()
|
|
base_url = "https://servis.mgm.gov.tr/web"
|
|
headers = {
|
|
"Origin": "https://www.mgm.gov.tr",
|
|
"User-Agent": "Mozilla/5.0",
|
|
}
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
results = []
|
|
try:
|
|
# 1. 加载测站元数据 (缓存到实例中),用于过滤属于该省份的站点
|
|
if not getattr(self, "mgm_stations_meta", None):
|
|
meta_resp = self._http_get(
|
|
f"{base_url}/istasyonlar",
|
|
headers=headers,
|
|
timeout=self.timeout,
|
|
)
|
|
if meta_resp.status_code == 200:
|
|
meta_json = meta_resp.json()
|
|
if isinstance(meta_json, list):
|
|
self.mgm_stations_meta = {s["istNo"]: s for s in meta_json if "istNo" in s}
|
|
else:
|
|
self.mgm_stations_meta = {}
|
|
|
|
metadata = getattr(self, "mgm_stations_meta", {})
|
|
|
|
# 2. 找出属于该省份的所有站点 istNo
|
|
province_normalized = self._normalize_mgm_text(province)
|
|
province_ist_nos = [
|
|
ist_no for ist_no, s in metadata.items()
|
|
if self._normalize_mgm_text(s.get("il") or "") == province_normalized
|
|
]
|
|
|
|
if not province_ist_nos:
|
|
logger.warning(f"MGM 找不到省份 {province} 的站点元数据")
|
|
record_source_call(
|
|
"mgm",
|
|
"nearby",
|
|
"empty",
|
|
(datetime.now() - started).total_seconds() * 1000.0,
|
|
)
|
|
return []
|
|
|
|
# 同时确保我们关心的几个核心站一定在里面
|
|
target_ist_nos = [str(i) for i in province_ist_nos[:25]]
|
|
# 17130: 安卡拉总站 (市区核心)
|
|
if 17130 in province_ist_nos or "17130" in province_ist_nos:
|
|
if "17130" not in target_ist_nos:
|
|
target_ist_nos.append("17130")
|
|
# 17128: 机场官方站
|
|
if 17128 in province_ist_nos or "17128" in province_ist_nos:
|
|
if "17128" not in target_ist_nos:
|
|
target_ist_nos.append("17128")
|
|
if root_ist_no:
|
|
rs = str(root_ist_no)
|
|
if rs not in target_ist_nos:
|
|
target_ist_nos.append(rs)
|
|
|
|
# 3. 多线程获取每个站点的最新观测 (sondurumlar)
|
|
def fetch_single_station(ist_no):
|
|
try:
|
|
# sondurumlar?istno={ist_no} 是目前最稳的获取多站数据的办法
|
|
url = f"{base_url}/sondurumlar?istno={ist_no}&_={int(time.time() * 1000)}"
|
|
resp = self._http_get(url, headers=headers, timeout=5)
|
|
if resp.status_code == 200:
|
|
obs_list = resp.json()
|
|
if obs_list:
|
|
obs = obs_list[0] if isinstance(obs_list, list) else obs_list
|
|
temp = obs.get("sicaklik")
|
|
wind_speed = obs.get("ruzgarHiz")
|
|
wind_dir = obs.get("ruzgarYon")
|
|
if temp is not None and temp > -9000:
|
|
return ist_no, {"temp": temp, "wind_speed": wind_speed, "wind_dir": wind_dir}
|
|
except Exception:
|
|
pass
|
|
return None, None
|
|
|
|
# 并发抓取
|
|
station_temps = {}
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
fetch_results = list(executor.map(fetch_single_station, target_ist_nos))
|
|
for ist_no, data in fetch_results:
|
|
if ist_no is not None:
|
|
station_temps[ist_no] = data
|
|
|
|
# 4. 组装最终结果
|
|
for ist_no, temp in station_temps.items():
|
|
sid = str(ist_no)
|
|
# metadata 可能使用 int 或 str 作为 key
|
|
meta = metadata.get(sid) or metadata.get(int(sid))
|
|
if not meta:
|
|
continue
|
|
|
|
lat = meta.get("enlem")
|
|
lon = meta.get("boylam")
|
|
# 优先显示区县名,地图更清晰
|
|
display_name = (meta.get("ilce") or meta.get("istAd") or f"Station {ist_no}").title()
|
|
|
|
# 特殊处理核心站点的显示名称
|
|
sid = str(ist_no)
|
|
if sid == "17130":
|
|
display_name = "Ankara (Bölge/Center)"
|
|
elif sid == "17128":
|
|
display_name = "Airport (MGM/17128)"
|
|
|
|
results.append({
|
|
"name": display_name,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"temp": temp.get("temp") if isinstance(temp, dict) else temp,
|
|
"wind_speed": temp.get("wind_speed") if isinstance(temp, dict) else None,
|
|
"wind_dir": temp.get("wind_dir") if isinstance(temp, dict) else None,
|
|
"istNo": ist_no
|
|
})
|
|
|
|
logger.info(f"📍 MGM 周边测站: 成功并发抓取 {len(results)} 个 {province} 站点的实时气温")
|
|
record_source_call(
|
|
"mgm",
|
|
"nearby",
|
|
"success" if results else "empty",
|
|
(datetime.now() - started).total_seconds() * 1000.0,
|
|
)
|
|
return results
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch MGM nearby stations for {province}: {e}")
|
|
record_source_call(
|
|
"mgm",
|
|
"nearby",
|
|
"error",
|
|
(datetime.now() - started).total_seconds() * 1000.0,
|
|
)
|
|
return []
|
|
|