diff --git a/extension/manifest.json b/extension/manifest.json index 90f4ff41..7fdc413b 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "PolyWeather Side Panel", "description": "PolyWeather 右侧城市卡片(浏览器侧边栏)", - "version": "0.1.1", + "version": "0.1.2", "icons": { "16": "icon-16.png", "32": "icon-32.png", diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 09a04f52..c8f208f3 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -267,6 +267,7 @@ export interface CityDetail { current: CurrentConditions; mgm?: MgmData; mgm_nearby?: NearbyStation[]; + nearby_source?: string; forecast?: ForecastData; multi_model?: Record; deb?: DebForecast; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 5a30cbed..8b1f4cd4 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -1132,11 +1132,16 @@ export function getShortTermNowcastLines( ? detail.metar_recent_obs.slice(-4) : []; const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : []; + const nearbySource = String(detail.nearby_source || "").toLowerCase(); const sourceLabel = - detail.name === "ankara" + nearbySource === "mgm" || detail.name === "ankara" ? isEnglish(locale) ? "MGM nearby stations" : "MGM 周边站" + : nearbySource === "official_cluster" + ? isEnglish(locale) + ? "Official nearby stations" + : "官方周边站" : isEnglish(locale) ? "METAR nearby stations" : "METAR 周边站"; diff --git a/src/data_collection/settlement_sources.py b/src/data_collection/settlement_sources.py index 62639599..63a6974f 100644 --- a/src/data_collection/settlement_sources.py +++ b/src/data_collection/settlement_sources.py @@ -9,6 +9,9 @@ from loguru import logger class SettlementSourceMixin: + IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1" + IMGW_METEO_API_TOKEN = "p4DXKjsYadfBV21TYrDk" + def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]: now_ts = time.time() with self._settlement_cache_lock: @@ -260,6 +263,84 @@ class SettlementSourceMixin: logger.warning(f"CWA Forecast request failed: {exc}") return None + def _imgw_api_get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + try: + query = {"token": self.IMGW_METEO_API_TOKEN} + if isinstance(params, dict): + query.update(params) + response = self.session.get( + f"{self.IMGW_METEO_API_BASE}/{path}", + params=query, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() if response.content else None + except Exception as exc: + logger.warning(f"IMGW API request failed path={path}: {exc}") + return None + + def fetch_imgw_synoptic_station_current( + self, + localization: str, + *, + display_name: Optional[str] = None, + use_fahrenheit: bool = False, + ) -> Optional[Dict[str, Any]]: + cache_key = f"imgw:synoptic:{str(localization or '').strip().lower()}" + cached = self._get_settlement_cache(cache_key) + if cached: + return cached + + location_payload = self._imgw_api_get("geo/search", {"name": localization}) + location_rows = (location_payload or {}).get("data") or [] + if not isinstance(location_rows, list) or not location_rows: + return None + location_row = location_rows[0] if isinstance(location_rows[0], dict) else None + if not isinstance(location_row, dict): + return None + + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + synoptic_payload = self._imgw_api_get( + "forecast/synoptic", + {"date": date_str, "localization": localization}, + ) + data = (synoptic_payload or {}).get("data") or {} + if not isinstance(data, dict) or not data: + return None + + latest_row = None + for key in sorted(data.keys()): + row = data.get(key) + if isinstance(row, dict) and row.get("temp") is not None: + latest_row = row + if not isinstance(latest_row, dict): + return None + + temp_kelvin = self._safe_float(latest_row.get("temp")) + if temp_kelvin is None: + return None + temp_c = temp_kelvin - 273.15 + temp_value = (temp_c * 9 / 5) + 32 if use_fahrenheit else temp_c + wind_speed_ms = self._safe_float(latest_row.get("ws")) + + payload = { + "name": display_name or str(location_row.get("name") or localization), + "lat": self._safe_float(location_row.get("lat")), + "lon": self._safe_float(location_row.get("lon")), + "temp": round(temp_value, 1), + "icao": "IMGW", + "istNo": "IMGW", + "wind_dir": self._safe_float(latest_row.get("wd")), + "wind_speed": wind_speed_ms, + "wind_speed_kt": round(float(wind_speed_ms) * 1.943844, 1) if wind_speed_ms is not None else None, + "source": "imgw_synoptic", + "raw_time": latest_row.get("fd"), + } + if payload["lat"] is None or payload["lon"] is None: + return None + self._set_settlement_cache(cache_key, payload) + return payload + def fetch_settlement_current(self, city: str) -> Optional[Dict[str, Any]]: normalized = str(city or "").strip().lower() if normalized == "hong kong": diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index a629e4f4..5bf30e9a 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -585,6 +585,32 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) if cluster_data: results["mgm_nearby"] = cluster_data + results["nearby_source"] = "metar_cluster" + + def _attach_warsaw_official_nearby( + self, results: Dict, use_fahrenheit: bool + ) -> None: + if "mgm_nearby" in results: + return + + official_rows = [] + epwa_rows = self.fetch_metar_nearby_cluster(["EPWA"], use_fahrenheit=use_fahrenheit) + if epwa_rows: + epwa = dict(epwa_rows[0]) + epwa["name"] = "Warszawa-Okęcie (EPWA)" + official_rows.append(epwa) + + imgw_row = self.fetch_imgw_synoptic_station_current( + "Warszawa", + display_name="Warszawa (IMGW synoptic)", + use_fahrenheit=use_fahrenheit, + ) + if imgw_row: + official_rows.append(imgw_row) + + if official_rows: + results["mgm_nearby"] = official_rows + results["nearby_source"] = "official_cluster" def _attach_nws_and_models( self, @@ -649,6 +675,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["metar"] = metar_data self._attach_turkish_mgm_data(results, city_lower) + if city_lower == "warsaw": + self._attach_warsaw_official_nearby(results, use_fahrenheit) self._attach_global_nearby_cluster( results, city_lower, use_fahrenheit ) @@ -668,6 +696,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["metar"] = metar_data self._attach_turkish_mgm_data(results, city_lower) + if city_lower == "warsaw": + self._attach_warsaw_official_nearby(results, use_fahrenheit) self._attach_global_nearby_cluster( results, city_lower, use_fahrenheit ) diff --git a/web/analysis_service.py b/web/analysis_service.py index 6787c3eb..3ad81f47 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -635,6 +635,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: }, "mgm": mgm_data, "mgm_nearby": raw.get("mgm_nearby", []), + "nearby_source": raw.get("nearby_source") or ("mgm" if city.lower() == "ankara" else "metar_cluster"), "forecast": { "today_high": om_today, "daily": forecast_daily, @@ -837,7 +838,7 @@ def _build_city_detail_payload( "weather_gov": {}, "mgm": data.get("mgm") or {}, "mgm_nearby": data.get("mgm_nearby") or [], - "nearby_source": "mgm" if data.get("name") == "ankara" else "metar_cluster", + "nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"), }, "timeseries": { "metar_recent_obs": data.get("metar_recent_obs") or [], @@ -856,6 +857,7 @@ def _build_city_detail_payload( "dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []}, "market_scan": market_scan, "risk": data.get("risk"), + "nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"), "ai_analysis": data.get("ai_analysis") or "", "errors": {}, }