移除俄罗斯 pogodaiklimat 数据源:周边观测站不需要

This commit is contained in:
2569718930@qq.com
2026-05-19 16:57:00 +08:00
parent f2ab62ba83
commit a6e7d6682d
5 changed files with 1 additions and 458 deletions
-1
View File
@@ -76,7 +76,6 @@ JMA_AMEDAS_CACHE_TTL_SEC=120
# FMI_BASE_URL=https://opendata.fmi.fi/wfs
# HKO_BASE_URL=https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather
# SINGAPORE_MSS_BASE_URL=https://api.data.gov.sg/v1/environment/air-temperature
# RUSSIA_POGODAIKLIMAT_BASE_URL=https://www.pogodaiklimat.ru
########################################
# 4) Auth / entitlement
-2
View File
@@ -560,7 +560,6 @@ def _kma_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
return out
def _ru_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
rows = raw.get("ru_official_nearby") or []
out: List[Dict[str, Any]] = []
for row in rows:
@@ -574,7 +573,6 @@ def _ru_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
lat=row.get("lat"),
lon=row.get("lon"),
obs_time=row.get("obs_time"),
source_code="ru_station_web",
source_label="Russia station web",
is_official=True,
is_airport_station=_bool(row.get("is_airport_station")),
@@ -1,365 +0,0 @@
from __future__ import annotations
import html
import math
import re
import os
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from loguru import logger
from src.utils.metrics import record_source_call
RUSSIA_MOSCOW_STATIONS: Dict[str, Dict[str, Any]] = {
"27524": {
"station_code": "27524",
"station_label": "Vnukovo",
"lat": 55.5870,
"lon": 37.2500,
},
"27500": {
"station_code": "27500",
"station_label": "Tolstopaltsevo",
"lat": 55.5900,
"lon": 37.1940,
},
"27518": {
"station_code": "27518",
"station_label": "Podmoskovnaya",
"lat": 55.7084,
"lon": 37.1823,
},
"27515": {
"station_code": "27515",
"station_label": "Nemchinovka",
"lat": 55.7065,
"lon": 37.3719,
},
"27504": {
"station_code": "27504",
"station_label": "Moscow (Butovo)",
"lat": 55.5780,
"lon": 37.5541,
},
"27614": {
"station_code": "27614",
"station_label": "Mikhailovskoye",
"lat": 55.3671,
"lon": 37.2033,
},
"27601": {
"station_code": "27601",
"station_label": "Krasnogorsk",
"lat": 55.8069,
"lon": 37.3446,
},
"27416": {
"station_code": "27416",
"station_label": "Moscow (Strogino)",
"lat": 55.7976,
"lon": 37.3982,
},
"27605": {
"station_code": "27605",
"station_label": "Moscow (Balchug)",
"lat": 55.7455,
"lon": 37.6300,
},
"27619": {
"station_code": "27619",
"station_label": "Moscow (Tushino)",
"lat": 55.8783,
"lon": 37.4367,
},
"27621": {
"station_code": "27621",
"station_label": "Gorki Leninskie",
"lat": 55.5079,
"lon": 37.7755,
},
}
RUSSIA_MOSCOW_MAP_STATION_LIMIT = 10
class RussiaStationSourceMixin:
def _ru_http_get_text(self, url: str) -> str:
getter = getattr(self, "_http_get", None)
if callable(getter):
response = getter(url)
else:
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
return response.text
@staticmethod
def _ru_safe_float(value: Any) -> Optional[float]:
try:
if value in (None, "", "-", ""):
return None
text = str(value).strip().replace(",", ".")
return float(text)
except Exception:
return None
@staticmethod
def _ru_distance_km(
lat1: Optional[float],
lon1: Optional[float],
lat2: Optional[float],
lon2: Optional[float],
) -> Optional[float]:
if None in (lat1, lon1, lat2, lon2):
return None
try:
r = 6371.0
d_lat = math.radians(float(lat2) - float(lat1))
d_lon = math.radians(float(lon2) - float(lon1))
a = (
math.sin(d_lat / 2) ** 2
+ math.cos(math.radians(float(lat1)))
* math.cos(math.radians(float(lat2)))
* math.sin(d_lon / 2) ** 2
)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return round(r * c, 2)
except Exception:
return None
@staticmethod
def _ru_clean_cell(cell_html: str) -> str:
text = re.sub(r"<[^>]+>", " ", str(cell_html or ""))
text = html.unescape(text)
text = text.replace("\xa0", " ")
text = re.sub(r"\s+", " ", text)
return text.strip()
@classmethod
def _ru_parse_table_rows(cls, table_html: str) -> List[List[str]]:
rows: List[List[str]] = []
for row_html in re.findall(r"<tr[^>]*>(.*?)</tr>", table_html, flags=re.S | re.I):
cells = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", row_html, flags=re.S | re.I)
cleaned = [cls._ru_clean_cell(cell) for cell in cells]
if cleaned:
rows.append(cleaned)
return rows
@staticmethod
def _ru_build_obs_time(hour_text: str, day_month_text: str) -> Optional[str]:
hour_match = re.search(r"(\d{1,2})", str(hour_text or ""))
day_match = re.search(r"(\d{1,2})\.(\d{1,2})", str(day_month_text or ""))
if not hour_match or not day_match:
return None
hour = int(hour_match.group(1))
day = int(day_match.group(1))
month = int(day_match.group(2))
now_utc = datetime.now(timezone.utc)
year = now_utc.year
try:
candidate = datetime(year, month, day, hour, 0, tzinfo=timezone.utc)
except ValueError:
return None
if candidate > now_utc and (candidate - now_utc).days > 40:
candidate = datetime(year - 1, month, day, hour, 0, tzinfo=timezone.utc)
return candidate.isoformat()
def _ru_parse_station_current_from_weather_html(self, html_text: str) -> Optional[Dict[str, Any]]:
tables = re.findall(r"<table[^>]*>(.*?)</table>", html_text, flags=re.S | re.I)
if len(tables) < 2:
return None
time_rows = self._ru_parse_table_rows(tables[0])
data_rows = self._ru_parse_table_rows(tables[1])
if len(time_rows) < 2 or len(data_rows) < 2:
return None
pair_count = min(len(time_rows), len(data_rows)) - 1
for idx in range(pair_count - 1, -1, -1):
time_row = time_rows[idx + 1]
data_row = data_rows[idx + 1]
if len(time_row) < 2 or len(data_row) < 6:
continue
temp_c = self._ru_safe_float(data_row[5])
if temp_c is None:
continue
obs_time = self._ru_build_obs_time(time_row[0], time_row[1])
return {
"temp_c": round(temp_c, 1),
"obs_time": obs_time,
"raw_hour": time_row[0],
"raw_day_month": time_row[1],
}
return None
def _ru_cached_station_current(
self,
station_code: str,
station_meta: Dict[str, Any],
use_fahrenheit: bool = False,
) -> Optional[Dict[str, Any]]:
cache_key = f"{station_code}:{use_fahrenheit}"
now_ts = time.time()
with self._ru_station_cache_lock:
cached = self._ru_station_cache.get(cache_key)
if cached and now_ts - cached["t"] < self.ru_station_cache_ttl_sec:
return cached["d"]
started = time.perf_counter()
try:
_ru_base = os.getenv('RUSSIA_POGODAIKLIMAT_BASE_URL', '').strip() or 'https://www.pogodaiklimat.ru'
url = f"{_ru_base}/weather.php?id={station_code}"
html_text = self._ru_http_get_text(url)
parsed = self._ru_parse_station_current_from_weather_html(html_text)
if not parsed:
record_source_call(
"ru_station_web",
"current",
"empty",
(time.perf_counter() - started) * 1000.0,
)
return None
obs_time = parsed.get("obs_time")
max_stale_sec = max(
0,
int(getattr(self, "ru_station_max_stale_sec", 4 * 3600)),
)
if obs_time and max_stale_sec > 0:
try:
obs_dt = datetime.fromisoformat(str(obs_time))
obs_age_sec = (datetime.now(timezone.utc) - obs_dt).total_seconds()
if obs_age_sec > max_stale_sec:
logger.info(
"Russia station web row is stale station={} obs_time={} age_hours={:.1f}",
station_code,
obs_time,
obs_age_sec / 3600.0,
)
record_source_call(
"ru_station_web",
"current",
"stale_row",
(time.perf_counter() - started) * 1000.0,
)
return None
except Exception:
pass
temp_c = parsed.get("temp_c")
if temp_c is None:
record_source_call(
"ru_station_web",
"current",
"no_temperature",
(time.perf_counter() - started) * 1000.0,
)
return None
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
result = {
"station_code": station_code,
"station_label": station_meta.get("station_label") or f"RU {station_code}",
"name": station_meta.get("station_label") or f"RU {station_code}",
"lat": station_meta.get("lat"),
"lon": station_meta.get("lon"),
"temp": temp,
"obs_time": parsed.get("obs_time"),
"source": "ru_station_web",
"source_label": "Russia station web",
"source_code": "ru_station_web",
"is_official": True,
"is_airport_station": station_code == "27524",
"is_settlement_anchor": False,
"page_url": f"{_ru_base}/weather.php?id={station_code}",
}
with self._ru_station_cache_lock:
self._ru_station_cache[cache_key] = {"d": result, "t": now_ts}
record_source_call(
"ru_station_web",
"current",
"success",
(time.perf_counter() - started) * 1000.0,
)
return result
except Exception as exc:
logger.warning("Russia station web fetch failed station={} error={}", station_code, exc)
with self._ru_station_cache_lock:
stale = self._ru_station_cache.get(cache_key)
if stale:
record_source_call(
"ru_station_web",
"current",
"stale_cache",
(time.perf_counter() - started) * 1000.0,
)
return stale["d"]
record_source_call(
"ru_station_web",
"current",
"error",
(time.perf_counter() - started) * 1000.0,
)
return None
def fetch_russia_moscow_official_nearby(
self,
city: str,
use_fahrenheit: bool = False,
) -> List[Dict[str, Any]]:
started = time.perf_counter()
city_key = str(city or "").strip().lower()
if city_key != "moscow":
record_source_call(
"ru_station_web",
"nearby",
"unsupported_city",
(time.perf_counter() - started) * 1000.0,
)
return []
city_meta = self.CITY_REGISTRY.get(city_key) or {}
anchor_lat = self._ru_safe_float(city_meta.get("lat"))
anchor_lon = self._ru_safe_float(city_meta.get("lon"))
rows: List[Dict[str, Any]] = []
try:
for station_code, station_meta in RUSSIA_MOSCOW_STATIONS.items():
current = self._ru_cached_station_current(
station_code,
station_meta,
use_fahrenheit=use_fahrenheit,
)
if not current:
continue
row = dict(current)
row["distance_km"] = self._ru_distance_km(
anchor_lat,
anchor_lon,
self._ru_safe_float(row.get("lat")),
self._ru_safe_float(row.get("lon")),
)
row["icao"] = station_code
row["istNo"] = station_code
rows.append(row)
rows.sort(
key=lambda item: (
item.get("distance_km") is None,
item.get("distance_km") if item.get("distance_km") is not None else 9999,
item.get("station_label") or "",
)
)
trimmed = rows[:RUSSIA_MOSCOW_MAP_STATION_LIMIT]
record_source_call(
"ru_station_web",
"nearby",
"success" if trimmed else "empty",
(time.perf_counter() - started) * 1000.0,
)
return trimmed
except Exception as exc:
logger.warning("Russia station nearby fetch failed city={} error={}", city_key, exc)
record_source_call(
"ru_station_web",
"nearby",
"error",
(time.perf_counter() - started) * 1000.0,
)
return []
+1 -29
View File
@@ -11,7 +11,6 @@ from src.data_collection.settlement_sources import SettlementSourceMixin
from src.data_collection.metar_sources import MetarSourceMixin
from src.data_collection.mgm_sources import MgmSourceMixin
from src.data_collection.jma_amedas_sources import JmaAmedasSourceMixin
from src.data_collection.russia_station_sources import RussiaStationSourceMixin
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
from src.data_collection.amos_station_sources import AmosStationSourceMixin
from src.data_collection.amsc_awos_sources import AmscAwosSourceMixin
@@ -23,7 +22,7 @@ from src.data_collection.singapore_mss_sources import SingaporeMssSourceMixin
from src.database.db_manager import DBManager
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin):
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin):
"""
Multi-source weather data collector
@@ -208,14 +207,6 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
)
self._jma_cache: Dict[str, Dict] = {}
self._jma_cache_lock = threading.Lock()
self.ru_station_cache_ttl_sec = int(
os.getenv("RU_STATION_CACHE_TTL_SEC", "300")
)
self.ru_station_max_stale_sec = int(
os.getenv("RU_STATION_MAX_STALE_SEC", str(4 * 3600))
)
self._ru_station_cache: Dict[str, Dict] = {}
self._ru_station_cache_lock = threading.Lock()
self.settlement_cache_ttl_sec = int(
os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120")
)
@@ -767,8 +758,6 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self._knmi_cache.pop(f"knmi:{normalized}:{use_fahrenheit}", None)
with self._hko_obs_cache_lock:
self._hko_obs_cache.pop(f"hko_obs:{normalized}:{use_fahrenheit}", None)
with self._ru_station_cache_lock:
self._ru_station_cache.pop(f"{normalized}:{use_fahrenheit}", None)
with self._settlement_cache_lock:
city_meta = self.CITY_REGISTRY.get(normalized) or {}
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
@@ -1195,21 +1184,6 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"airport_obs_log append failed for singapore_mss city={}", city_lower
)
def _attach_russia_official_nearby(
self, results: Dict, city_lower: str, use_fahrenheit: bool
) -> None:
if city_lower != "moscow":
return
official_rows = self.fetch_russia_moscow_official_nearby(
city_lower, use_fahrenheit=use_fahrenheit
)
if not official_rows:
return
# Pogodaiklimat station rows are SYNOP/archive-style reference observations,
# not realtime enough for the map. Keep them out of official_nearby/mgm_nearby
# so Moscow uses the live METAR cluster for nearby map temperatures.
results["ru_reference_nearby"] = official_rows
def _attach_warsaw_official_nearby(
self, results: Dict, use_fahrenheit: bool
) -> None:
@@ -1375,7 +1349,6 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit)
self._attach_hko_obs_official_nearby(results, city_lower, use_fahrenheit)
self._attach_cwa_settlement_nearby(results, city_lower, use_fahrenheit)
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
if city_lower == "warsaw":
self._attach_warsaw_official_nearby(results, use_fahrenheit)
self._attach_global_nearby_cluster(
@@ -1424,7 +1397,6 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit)
self._attach_hko_obs_official_nearby(results, city_lower, use_fahrenheit)
self._attach_cwa_settlement_nearby(results, city_lower, use_fahrenheit)
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
if city_lower == "warsaw":
self._attach_warsaw_official_nearby(results, use_fahrenheit)
self._attach_global_nearby_cluster(
-61
View File
@@ -1,61 +0,0 @@
from src.data_collection.russia_station_sources import (
RUSSIA_MOSCOW_MAP_STATION_LIMIT,
RUSSIA_MOSCOW_STATIONS,
RussiaStationSourceMixin,
)
def test_moscow_station_registry_includes_vnukovo_nearby_ring():
assert "27524" in RUSSIA_MOSCOW_STATIONS
assert "27500" in RUSSIA_MOSCOW_STATIONS
assert RUSSIA_MOSCOW_STATIONS["27500"]["station_label"] == "Tolstopaltsevo"
assert RUSSIA_MOSCOW_MAP_STATION_LIMIT == 10
def test_fetch_moscow_official_nearby_keeps_nearest_ten_station_rows():
class FakeRussiaSource(RussiaStationSourceMixin):
CITY_REGISTRY = {
"moscow": {
"lat": 55.5915,
"lon": 37.2615,
}
}
def _ru_cached_station_current(self, station_code, station_meta, use_fahrenheit=False):
return {
"station_code": station_code,
"station_label": station_meta["station_label"],
"name": station_meta["station_label"],
"lat": station_meta["lat"],
"lon": station_meta["lon"],
"temp": 10.0,
"source_code": "ru_station_web",
}
rows = FakeRussiaSource().fetch_russia_moscow_official_nearby("moscow")
assert len(rows) == RUSSIA_MOSCOW_MAP_STATION_LIMIT
assert rows[0]["station_code"] == "27524"
assert rows[1]["station_code"] == "27500"
assert all(row["distance_km"] is not None for row in rows)
def test_russia_weather_archive_parser_uses_latest_valid_row():
html = """
<table>
<tr><th>hour</th><th>date</th></tr>
<tr><td>09</td><td>11.04</td></tr>
<tr><td>21</td><td>17.04</td></tr>
</table>
<table>
<tr><th>a</th><th>b</th><th>c</th><th>d</th><th>cloud</th><th>temp</th></tr>
<tr><td>x</td><td>x</td><td>x</td><td>x</td><td>x</td><td>+4.2</td></tr>
<tr><td>x</td><td>x</td><td>x</td><td>x</td><td>x</td><td>+5.8</td></tr>
</table>
"""
parsed = RussiaStationSourceMixin()._ru_parse_station_current_from_weather_html(html)
assert parsed["temp_c"] == 5.8
assert parsed["raw_hour"] == "21"
assert parsed["raw_day_month"] == "17.04"