From fe9bf61ad66a6100ede28f383aa0862df5912472 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 13 May 2026 00:33:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A6=99=E6=B8=AF=E5=A4=A9?= =?UTF-8?q?=E6=96=87=E5=8F=B0=20HKO=20+=20=E6=B5=81=E6=B5=AE=E5=B1=B1=20LF?= =?UTF-8?q?S=201=E5=88=86=E9=92=9F=E5=AE=9E=E6=97=B6=E6=B8=A9=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HKO 公共天气 API 免费无注册,提供 1 分钟温度 CSV。 两个站点加入高频推送,与首尔/釜山同级。 Station: HK Observatory (HKO) 27.0°C, Lau Fau Shan (LFS) 25.9°C Interval: 60s Tested: pytest 176 passed, HKO API 实测通过 --- src/analysis/market_alert_engine.py | 2 +- src/bot/runtime_coordinator.py | 2 +- src/data_collection/hko_obs_sources.py | 148 +++++++++++++++++++++++++ src/data_collection/weather_sources.py | 37 ++++++- src/utils/telegram_push.py | 11 +- 5 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 src/data_collection/hko_obs_sources.py diff --git a/src/analysis/market_alert_engine.py b/src/analysis/market_alert_engine.py index b5023677..17290fd9 100644 --- a/src/analysis/market_alert_engine.py +++ b/src/analysis/market_alert_engine.py @@ -813,7 +813,7 @@ def _build_advice_cn( return ",".join(parts) + "。" -_AIRPORT_ICAO_MAP = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB"} +_AIRPORT_ICAO_MAP = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS"} def _calc_airport_rapid_temp_change( diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 9a0458ee..ec232b5a 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -190,7 +190,7 @@ class StartupCoordinator: details = { "mode": "airport-periodic", "interval_sec": interval, - "cities": ["seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris"], + "cities": ["seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan"], "chat_targets": len(chat_ids), "window": "DEB proximity ≤3°C", } diff --git a/src/data_collection/hko_obs_sources.py b/src/data_collection/hko_obs_sources.py new file mode 100644 index 00000000..0ad52fe0 --- /dev/null +++ b/src/data_collection/hko_obs_sources.py @@ -0,0 +1,148 @@ +"""HKO (Hong Kong Observatory) 1-minute real-time data source. + +Fetches 1-minute temperature from HKO's public regional-weather API +for Hong Kong Observatory (HKO) and Lau Fau Shan (LFS) stations. +No API key required. +""" + +from __future__ import annotations + +import csv +import io +import time +from datetime import datetime +from typing import Any, Dict, Optional + +from loguru import logger + +from src.utils.metrics import record_source_call + +HKO_BASE_URL = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather" +HKO_STATIONS = { + "hong kong": { + "code": "HK Observatory", + "icao": "HKO", + "label": "HK Observatory 1min (HKO)", + }, + "lau fau shan": { + "code": "Lau Fau Shan", + "icao": "LFS", + "label": "Lau Fau Shan 1min (HKO)", + }, +} + + +class HkoObsSourceMixin: + def _hko_http_get(self, url: str) -> str: + getter = getattr(self, "_http_get", None) + if callable(getter): + resp = getter(url) + return resp.text if hasattr(resp, "text") else resp + resp = self.session.get(url, timeout=self.timeout) + resp.raise_for_status() + return resp.text + + def fetch_hko_obs_current( + self, + city: str, + use_fahrenheit: bool = False, + ) -> Optional[Dict[str, Any]]: + started = time.perf_counter() + city_key = str(city or "").strip().lower() + meta = HKO_STATIONS.get(city_key) or {} + if not meta: + return None + + cache_key = f"hko_obs:{city_key}:{use_fahrenheit}" + now_ts = time.time() + with self._hko_obs_cache_lock: + cached = self._hko_obs_cache.get(cache_key) + if cached and now_ts - cached["t"] < self.hko_obs_cache_ttl_sec: + record_source_call("hko_obs", "current", "cache_hit", + (time.perf_counter() - started) * 1000.0) + return cached["d"] + + try: + csv_text = self._hko_http_get( + f"{HKO_BASE_URL}/latest_1min_temperature.csv" + ) + reader = csv.DictReader(io.StringIO(csv_text)) + temp_c = None + obs_time = None + for row in reader: + if row.get("Automatic Weather Station", "").strip() == meta["code"]: + try: + temp_c = float(row["Air Temperature(degree Celsius)"]) + except (ValueError, TypeError): + pass + obs_time = row.get("Date time", "")[:12] + break + + if temp_c is None: + record_source_call("hko_obs", "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) + obs_iso = None + if obs_time and len(obs_time) == 12: + try: + dt = datetime.strptime(obs_time, "%Y%m%d%H%M") + obs_iso = dt.isoformat() + except Exception: + obs_iso = obs_time + + result = { + "source": "hko_obs", + "timestamp": datetime.utcnow().isoformat(), + "station_code": meta["code"], + "station_name": meta["label"], + "icao": meta["icao"], + "obs_time": obs_iso or datetime.utcnow().isoformat(), + "current": { + "temp": temp, + }, + "temp_c": temp_c, + } + + with self._hko_obs_cache_lock: + self._hko_obs_cache[cache_key] = {"d": result, "t": now_ts} + record_source_call("hko_obs", "current", "success", + (time.perf_counter() - started) * 1000.0) + return result + + except Exception as exc: + logger.warning("HKO obs fetch failed city={} error={}", city_key, exc) + with self._hko_obs_cache_lock: + stale = self._hko_obs_cache.get(cache_key) + if stale: + record_source_call("hko_obs", "current", "stale_cache", + (time.perf_counter() - started) * 1000.0) + return stale["d"] + record_source_call("hko_obs", "current", "error", + (time.perf_counter() - started) * 1000.0) + return None + + def fetch_hko_obs_official_nearby( + self, + city: str, + use_fahrenheit: bool = False, + ) -> list[Dict[str, Any]]: + current = self.fetch_hko_obs_current(city, use_fahrenheit=use_fahrenheit) + if not current: + return [] + meta = HKO_STATIONS.get(str(city or "").strip().lower()) or {} + return [ + { + "name": meta.get("label") or "HKO Station", + "station_label": meta.get("label"), + "lat": 22.3020, + "lon": 114.1743, + "temp": (current.get("current") or {}).get("temp"), + "icao": meta.get("icao"), + "istNo": meta.get("icao"), + "source": "hko_obs", + "source_label": "HKO", + "obs_time": current.get("obs_time"), + } + ] diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 5b5b7a4f..8cd5c979 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -17,10 +17,11 @@ from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin from src.data_collection.amos_station_sources import AmosStationSourceMixin from src.data_collection.fmi_sources import FmiSourceMixin from src.data_collection.knmi_sources import KnmiSourceMixin +from src.data_collection.hko_obs_sources import HkoObsSourceMixin from src.database.db_manager import DBManager -class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, FmiSourceMixin, KnmiSourceMixin): +class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin): """ Multi-source weather data collector @@ -233,6 +234,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._knmi_cache: Dict[str, Dict] = {} self._knmi_cache_lock = threading.Lock() + self.hko_obs_cache_ttl_sec = int( + os.getenv("HKO_OBS_CACHE_TTL_SEC", "60") + ) + self._hko_obs_cache: Dict[str, Dict] = {} + self._hko_obs_cache_lock = threading.Lock() self.cwa_open_data_auth = ( os.getenv("CWA_OPEN_DATA_AUTH") or os.getenv("CWA_OPEN_DATA_API_KEY") @@ -745,6 +751,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._fmi_cache.pop(f"fmi:{normalized}:{use_fahrenheit}", None) with self._knmi_cache_lock: 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._nmc_cache_lock: self._nmc_cache.pop(f"{normalized}:{use_fahrenheit}", None) with self._ru_station_cache_lock: @@ -958,6 +966,31 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour except Exception: logger.exception("airport_obs_log append failed for fmi city={}", city_lower) + def _attach_hko_obs_official_nearby( + self, results: Dict, city_lower: str, use_fahrenheit: bool + ) -> None: + if city_lower not in ("hong kong", "lau fau shan"): + return + rows = self.fetch_hko_obs_official_nearby( + city_lower, use_fahrenheit=use_fahrenheit + ) + if not rows: + return + results["hko_obs_nearby"] = rows + if "mgm_nearby" not in results: + results["mgm_nearby"] = rows + results["nearby_source"] = "hko_obs" + try: + row = rows[0] if rows else {} + DBManager().append_airport_obs( + icao=str(row.get("icao") or "HKO"), + city=city_lower, + temp_c=row.get("temp"), + obs_time=str(row.get("obs_time") or datetime.now().isoformat()), + ) + except Exception: + logger.exception("airport_obs_log append failed for hko_obs city={}", city_lower) + def _attach_korean_amos_data( self, results: Dict, city_lower: str, use_fahrenheit: bool ) -> None: @@ -1120,6 +1153,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) self._attach_fmi_official_nearby(results, city_lower, use_fahrenheit) self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit) + self._attach_hko_obs_official_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) @@ -1164,6 +1198,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) self._attach_fmi_official_nearby(results, city_lower, use_fahrenheit) self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit) + self._attach_hko_obs_official_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) diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 7ddc3cb8..88a7ed98 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -690,8 +690,8 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th # ── high-freq airport push loop ── -HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris"} -HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB"} +HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan"} +HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS"} _AIRPORT_PUSH_STATE_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), @@ -751,7 +751,8 @@ def _build_airport_status_message( ) -> str: _AIRPORT_EN = {"seoul": "Incheon", "busan": "Gimhae", "tokyo": "Haneda", "ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol", - "istanbul": "Airport", "paris": "Le Bourget"} + "istanbul": "Airport", "paris": "Le Bourget", + "hong kong": "Observatory", "lau fau shan": "Lau Fau Shan"} en_name = city.title() ap_name = _AIRPORT_EN.get(city, "") time_suffix = f" {local_time}" if local_time else "" @@ -816,7 +817,9 @@ _AIRPORT_PUSH_INTERVAL = { "helsinki": 600, # FMI 10-min "amsterdam": 600, # KNMI 10-min "istanbul": 600, # MGM ~10-min - "paris": 900, # AROME HD 15-min model + "paris": 900, # AROME HD 15-min model + "hong kong": 60, # HKO 1-min + "lau fau shan": 60, # HKO 1-min } _DEB_PROXIMITY_THRESHOLD_C = 3.0