import requests import re import time from typing import Optional, Dict, List from datetime import datetime, timedelta from loguru import logger class WeatherDataCollector: """ Multi-source weather data collector Supports: - OpenWeatherMap (free, fast updates) - Weather Underground (Polymarket settlement source) - Visual Crossing (rich historical data) - NOAA Aviation Weather (METAR - airport observations) """ # Polymarket 12 个天气市场对应的 ICAO 机场代码 # 这些是 Weather Underground 结算源使用的气象站 CITY_TO_ICAO = { "seattle": "KSEA", # Seattle-Tacoma Airport "london": "EGLC", # London City Airport "dallas": "KDAL", # Dallas Love Field "miami": "KMIA", # Miami International "atlanta": "KATL", # Hartsfield-Jackson "chicago": "KORD", # O'Hare International "new york": "KLGA", # LaGuardia Airport "nyc": "KLGA", # Alias "seoul": "RKSI", # Incheon International "ankara": "LTAC", # Esenboğa International "toronto": "CYYZ", # Toronto Pearson "wellington": "NZWN", # Wellington International "buenos aires": "SAEZ", # Ezeiza International } def __init__(self, config: dict): self.config = config self.wunderground_key = config.get("wunderground_api_key") self.timeout = 10 self.session = requests.Session() # 设置代理 proxy = config.get("proxy") if proxy: if not proxy.startswith("http"): proxy = f"http://{proxy}" self.session.proxies = {"http": proxy, "https": proxy} logger.info(f"正在使用天气数据代理: {proxy}") logger.info("天气数据采集器初始化完成。") def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]: """ Fetch current weather and forecast from OpenWeatherMap Args: city: City name country: Country code (optional) Returns: dict: Weather data """ if not getattr(self, "openweather_key", None): return None query = f"{city},{country}" if country else city try: # Current weather current_url = "https://api.openweathermap.org/data/2.5/weather" current_response = self.session.get( current_url, params={"q": query, "appid": self.openweather_key, "units": "metric"}, timeout=self.timeout, ) current_response.raise_for_status() current_data = current_response.json() # 5-day forecast forecast_url = "https://api.openweathermap.org/data/2.5/forecast" forecast_response = self.session.get( forecast_url, params={"q": query, "appid": self.openweather_key, "units": "metric"}, timeout=self.timeout, ) forecast_response.raise_for_status() forecast_data = forecast_response.json() return { "source": "openweathermap", "timestamp": datetime.utcnow().isoformat(), "current": { "temp": current_data["main"]["temp"], "feels_like": current_data["main"]["feels_like"], "temp_min": current_data["main"]["temp_min"], "temp_max": current_data["main"]["temp_max"], "humidity": current_data["main"]["humidity"], "pressure": current_data["main"]["pressure"], "wind_speed": current_data["wind"]["speed"], "clouds": current_data["clouds"]["all"], "description": current_data["weather"][0]["description"], }, "forecast": self._parse_openweather_forecast(forecast_data), } except requests.exceptions.RequestException as e: logger.error(f"OpenWeatherMap request failed: {e}") return None def _parse_openweather_forecast(self, data: dict) -> List[Dict]: """Parse OpenWeatherMap forecast data""" forecasts = [] for item in data.get("list", []): forecasts.append( { "datetime": item["dt_txt"], "temp": item["main"]["temp"], "temp_min": item["main"]["temp_min"], "temp_max": item["main"]["temp_max"], "humidity": item["main"]["humidity"], "description": item["weather"][0]["description"], } ) return forecasts def fetch_from_visualcrossing( self, city: str, start_date: str = None, end_date: str = None ) -> Optional[Dict]: """ Fetch historical weather data from Visual Crossing Args: city: City name start_date: Start date (YYYY-MM-DD) end_date: End date (YYYY-MM-DD) Returns: dict: Historical weather data """ if not getattr(self, "visualcrossing_key", None): return None # Default to last 30 days if no dates provided if not end_date: end_date = datetime.now().strftime("%Y-%m-%d") if not start_date: start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") try: url = f"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{city}/{start_date}/{end_date}" response = self.session.get( url, params={ "unitGroup": "metric", "key": self.visualcrossing_key, "contentType": "json", "include": "days", }, timeout=self.timeout, ) response.raise_for_status() data = response.json() return { "source": "visualcrossing", "timestamp": datetime.utcnow().isoformat(), "location": data.get("resolvedAddress"), "timezone": data.get("timezone"), "days": [ { "date": day["datetime"], "temp_max": day.get("tempmax"), "temp_min": day.get("tempmin"), "temp_avg": day.get("temp"), "humidity": day.get("humidity"), "precip": day.get("precip"), "conditions": day.get("conditions"), } for day in data.get("days", []) ], } except requests.exceptions.RequestException as e: logger.error(f"Visual Crossing request failed: {e}") return None def get_icao_code(self, city: str) -> Optional[str]: """ 根据城市名获取对应的 ICAO 机场代码 """ normalized = city.lower().strip() # 直接匹配 if normalized in self.CITY_TO_ICAO: return self.CITY_TO_ICAO[normalized] # 模糊匹配 for key, icao in self.CITY_TO_ICAO.items(): if key in normalized or normalized in key: return icao return None def fetch_metar(self, city: str, use_fahrenheit: bool = False) -> Optional[Dict]: """ 从 NOAA Aviation Weather Center 获取 METAR 航空气象数据 这是 Polymarket 天气市场的结算数据源 (Weather Underground) 使用的相同气象站 Args: city: 城市名称 use_fahrenheit: 是否转换为华氏度 Returns: dict: METAR 数据,包含温度、露点、风速等 """ icao = self.get_icao_code(city) if not icao: logger.warning(f"未找到城市 {city} 对应的 ICAO 代码") return None try: # NOAA Aviation Weather API (免费,无需 Key) url = "https://aviationweather.gov/api/data/metar" params = { "ids": icao, "format": "json", "hours": 3, # 获取最近3小时的观测 "_t": int(time.time()), # 禁用缓存 } response = self.session.get( url, params=params, headers={"Cache-Control": "no-cache", "Pragma": "no-cache"}, timeout=self.timeout ) response.raise_for_status() data = response.json() if not data: logger.warning(f"METAR 数据为空: {icao}") return None # 取最新的观测记录 latest = data[0] # 提取温度 (METAR 原始单位是摄氏度) temp_c = latest.get("temp") dewp_c = latest.get("dewp") # 转换为华氏度(如果需要) if use_fahrenheit and temp_c is not None: temp = temp_c * 9 / 5 + 32 dewp = dewp_c * 9 / 5 + 32 if dewp_c is not None else None unit = "fahrenheit" else: temp = temp_c dewp = dewp_c unit = "celsius" # 解析观测时间 obs_time = latest.get("reportTime", "") result = { "source": "metar", "icao": icao, "station_name": latest.get("name", icao), "timestamp": datetime.utcnow().isoformat(), "observation_time": obs_time, "raw_metar": latest.get("rawOb", ""), "current": { "temp": round(temp, 1) if temp is not None else None, "dewpoint": round(dewp, 1) if dewp is not None else None, "humidity": latest.get("rh"), # 相对湿度 "wind_speed_kt": latest.get("wspd"), # 风速 (knots) "wind_dir": latest.get("wdir"), # 风向 (度) "visibility_miles": latest.get("visib"), # 能见度 (英里) "altimeter": latest.get("altim"), # 气压 "flight_category": latest.get("fltcat"), # VFR/IFR 等 "clouds": latest.get("clouds", []), }, "unit": unit, } logger.info( f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} " f"(obs: {obs_time})" ) return result except requests.exceptions.RequestException as e: logger.error(f"METAR 请求失败 ({icao}): {e}") return None except (KeyError, IndexError, TypeError) as e: logger.error(f"METAR 数据解析失败 ({icao}): {e}") return None def fetch_from_open_meteo( self, lat: float, lon: float, forecast_days: int = 14, use_fahrenheit: bool = False, ) -> Optional[Dict]: """ Fetch weather from Open-Meteo with forecast data Args: lat: Latitude lon: Longitude forecast_days: Number of forecast days to fetch (default 14 to cover all market dates) use_fahrenheit: Whether to return temperatures in Fahrenheit (for US markets) """ try: url = "https://api.open-meteo.com/v1/forecast" params = { "latitude": lat, "longitude": lon, "current_weather": "true", "hourly": "temperature_2m", "daily": "temperature_2m_max,apparent_temperature_max", "timezone": "auto", "forecast_days": forecast_days, "_t": int(time.time()), # 禁用缓存,强制刷新 } # 对于美国市场,使用华氏度 if use_fahrenheit: params["temperature_unit"] = "fahrenheit" response = self.session.get( url, params=params, headers={"Cache-Control": "no-cache", "Pragma": "no-cache"}, timeout=self.timeout, ) response.raise_for_status() data = response.json() current = data.get("current_weather", {}) utc_offset = data.get("utc_offset_seconds", 0) timezone_name = data.get("timezone", "UTC") # 计算精确的当地时间而不是气象站 bucket 时间 now_utc = datetime.utcnow() local_now = now_utc + timedelta(seconds=utc_offset) local_time_str = local_now.strftime("%Y-%m-%d %H:%M") return { "source": "open-meteo", "timestamp": now_utc.isoformat(), "timezone": timezone_name, "utc_offset": utc_offset, "current": { "temp": current.get("temperature"), "local_time": local_time_str, }, "hourly": data.get("hourly", {}), "daily": data.get("daily", {}), "unit": "fahrenheit" if use_fahrenheit else "celsius", } except Exception as e: logger.error(f"Open-Meteo forecast failed: {e}") return None def extract_date_from_title(self, title: str) -> Optional[str]: """ 从标题中提取日期并标准化为 YYYY-MM-DD 支持: "February 6", "2月6日", "2-6" 等 """ # 1. 尝试英文月份 months = { "January": "01", "February": "02", "March": "03", "April": "04", "May": "05", "June": "06", "July": "07", "August": "08", "September": "09", "October": "10", "November": "11", "December": "12", } for month_name, month_val in months.items(): if month_name in title: match = re.search(f"{month_name}\\s+(\\d+)", title) if match: day = int(match.group(1)) year = datetime.now().year return f"{year}-{month_val}-{day:02d}" # 2. 尝试中文格式 "2月7日" 或 "02月07日" zh_match = re.search(r"(\d{1,2})月(\d{1,2})日", title) if zh_match: month = int(zh_match.group(1)) day = int(zh_match.group(2)) year = datetime.now().year return f"{year}-{month:02d}-{day:02d}" # 3. 尝试 ISO 格式 YYYY-MM-DD iso_match = re.search(r"(\d{4})-(\d{2})-(\d{2})", title) if iso_match: return iso_match.group(0) return None def get_coordinates(self, city: str) -> Optional[Dict[str, float]]: """ 使用 Open-Meteo Geocoding API 获取城市坐标 (免费, 无需 Key) """ # 预设常用城市坐标,避免网络波动导致启动失败 static_coords = { "london": {"lat": 51.5074, "lon": -0.1278}, "new york": {"lat": 40.7128, "lon": -74.0060}, "new york's central park": {"lat": 40.7812, "lon": -73.9665}, "nyc": {"lat": 40.7128, "lon": -74.0060}, "seattle": {"lat": 47.6062, "lon": -122.3321}, "chicago": {"lat": 41.8781, "lon": -87.6298}, "dallas": {"lat": 32.7767, "lon": -96.7970}, "miami": {"lat": 25.7617, "lon": -80.1918}, "atlanta": {"lat": 33.7490, "lon": -84.3880}, "seoul": {"lat": 37.5665, "lon": 126.9780}, "toronto": {"lat": 43.6532, "lon": -79.3832}, "ankara": {"lat": 39.9334, "lon": 32.8597}, "wellington": {"lat": -41.2865, "lon": 174.7762}, "buenos aires": {"lat": -34.6037, "lon": -58.3816}, } normalized_city = city.lower().strip() if normalized_city in static_coords: return static_coords[normalized_city] # 模糊匹配映射 (针对包含城市名的情况) for key in static_coords: if key in normalized_city: logger.debug(f"地理编码命中模糊映射: {city} -> {key}") return static_coords[key] try: url = "https://geocoding-api.open-meteo.com/v1/search" response = self.session.get( url, params={"name": city, "count": 1, "language": "en", "format": "json"}, timeout=15, # 增加超时时间到 15s ) response.raise_for_status() results = response.json().get("results", []) if results: res = results[0] return { "lat": res.get("latitude"), "lon": res.get("longitude"), "name": res.get("name"), "country": res.get("country"), } except Exception as e: logger.error(f"地理编码失败 ({city}): {e}") return None def extract_city_from_question(self, question: str) -> Optional[str]: """ 从 Polymarket 问题描述或 Slug 中提取城市名称 """ q = question.lower() # 1. 优先尝试已知城市列表 (硬编码匹配) known_cities = { "london": "London", "伦敦": "London", "new york": "New York", "new york's central park": "New York", "nyc": "New York", "纽约": "New York", "seattle": "Seattle", "西雅图": "Seattle", "chicago": "Chicago", "芝加哥": "Chicago", "dallas": "Dallas", "达拉斯": "Dallas", "miami": "Miami", "迈阿密": "Miami", "atlanta": "Atlanta", "亚特兰大": "Atlanta", "seoul": "Seoul", "首尔": "Seoul", "toronto": "Toronto", "多伦多": "Toronto", "ankara": "Ankara", "安卡拉": "Ankara", "wellington": "Wellington", "惠灵顿": "Wellington", "buenos aires": "Buenos Aires", "布宜诺斯艾利斯": "Buenos Aires" } for key, val in known_cities.items(): if key in q: return val # 2. 从英文模板中提取 triggers = ["temperature in ", "temp in ", "weather in ", "highest-temperature-in-", "temperature-in-"] for trigger in triggers: if trigger in q: part = q.split(trigger)[1] delimiters = [" on ", " at ", " above ", " below ", " be ", " is ", " will ", " has ", " reached ", "?", " (", ", ", "-"] city = part for d in delimiters: if d in city: city = city.split(d)[0] return city.strip().title() return None def fetch_all_sources( self, city: str, lat: float = None, lon: float = None, country: str = None ) -> Dict: """ Fetch weather data from all available sources """ results = {} # 判断是否为美国市场(使用华氏度) us_cities = { "dallas", "nyc", "new york", "seattle", "miami", "atlanta", "chicago", "los angeles", "san francisco", "washington", "boston", "houston", "phoenix", "philadelphia", "new york's central park", "portland", "denver", "austin", "san diego", "detroit", "cleveland", "minneapolis", "st. louis", } city_lower = city.lower().strip() # 检查城市名是否在美国城市列表中(支持完全匹配或包含关系) use_fahrenheit = city_lower in us_cities or any( us_city in city_lower for us_city in us_cities ) if use_fahrenheit: logger.info(f"🌡️ {city} 使用华氏度 (°F)") else: logger.info(f"🌡️ {city} 使用摄氏度 (°C)") # METAR (Airport Weather - Same source as Weather Underground settlement) metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit) if metar_data: results["metar"] = metar_data # Open-Meteo (Primary Free Source - No Key) if lat and lon: open_meteo = self.fetch_from_open_meteo( lat, lon, use_fahrenheit=use_fahrenheit ) if open_meteo: results["open-meteo"] = open_meteo return results def check_consensus(self, forecasts: Dict) -> Dict: """ Check consensus across multiple weather sources Args: forecasts: Dict of forecasts from different sources Returns: dict: Consensus analysis """ predictions = [] for source, data in forecasts.items(): if data and "current" in data: predictions.append({"source": source, "temp": data["current"]["temp"]}) if len(predictions) == 0: return {"consensus": False, "reason": "No weather data available"} temps = [p["temp"] for p in predictions] avg_temp = sum(temps) / len(temps) # If only one source, consensus is implicitly true if len(predictions) == 1: return { "consensus": True, "average_temp": avg_temp, "max_difference": 0.0, "predictions": predictions, "note": "Single source only", } max_diff = max(abs(t - avg_temp) for t in temps) # Consensus if all predictions within 2.5°C is_consensus = max_diff <= 2.5 return { "consensus": is_consensus, "average_temp": avg_temp, "max_difference": max_diff, "predictions": predictions, }