f8f0d69e5c
httpx 的 cookies API 与 requests 库不同,resp.cookies.jar 在 httpx 中不存在, 导致 PHPSESSID 提取失败、登录报错。改用 self.session.cookies.get() 直接读取。
218 lines
8.2 KiB
Python
218 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Dict, Optional
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from loguru import logger
|
|
|
|
from src.utils.metrics import record_source_call
|
|
|
|
|
|
class AerowebSourceMixin:
|
|
"""Fetch realtime METAR from Météo-France AEROWEB.
|
|
|
|
AEROWEB (https://aviation.meteo.fr) delivers METAR/TAF directly from
|
|
Météo-France's aviation network. Reports typically appear within
|
|
2 minutes of issue time — faster than NOAA's aviationweather.gov.
|
|
|
|
Auth flow:
|
|
1. GET login.php → receive PHPSESSID cookie
|
|
2. POST ajax/login_valid.php (login=<user>&password=<md5(pass)>)
|
|
3. GET ajax/ajax_trajet_get_stations.php (with PHPSESSID)
|
|
|
|
Env vars:
|
|
AEROWEB_USERNAME — login name
|
|
AEROWEB_PASSWORD — plain-text password (MD5 hashed before sending)
|
|
"""
|
|
|
|
AEROWEB_BASE = "https://aviation.meteo.fr"
|
|
# Tight bounding box around LFPB (Paris Le Bourget) in WGS84 degrees.
|
|
# The WFS endpoint uses SRS=EPSG:900913 but accepts lat/lon in practice.
|
|
LFPB_BBOX = "2.42,48.95,2.46,48.99"
|
|
|
|
def _aeroweb_creds(self) -> Optional[tuple]:
|
|
user = os.getenv("AEROWEB_USERNAME", "").strip()
|
|
pwd = os.getenv("AEROWEB_PASSWORD", "").strip()
|
|
if not user or not pwd:
|
|
return None
|
|
return (user, pwd)
|
|
|
|
def _aeroweb_session(self) -> Optional[str]:
|
|
"""Return a valid PHPSESSID, logging in if necessary."""
|
|
creds = self._aeroweb_creds()
|
|
if not creds:
|
|
logger.warning("AEROWEB credentials not configured (AEROWEB_USERNAME / AEROWEB_PASSWORD)")
|
|
return None
|
|
|
|
user, pwd = creds
|
|
now = time.time()
|
|
|
|
# Check cached session
|
|
cached = getattr(self, "_aeroweb_sid", None)
|
|
cached_ts = getattr(self, "_aeroweb_sid_ts", 0)
|
|
# Re-login every 20 minutes to keep the session alive
|
|
if cached and (now - cached_ts) < 1200:
|
|
return cached
|
|
|
|
try:
|
|
# Step 1: get a fresh PHPSESSID
|
|
# Use the shared httpx session so cookies persist across requests.
|
|
resp = self.session.get(
|
|
f"{self.AEROWEB_BASE}/login.php", timeout=self.timeout
|
|
)
|
|
# httpx stores cookies on the session; grab PHPSESSID from there.
|
|
sid = self.session.cookies.get("PHPSESSID")
|
|
if not sid:
|
|
# Fallback: parse Set-Cookie header directly
|
|
set_cookie = resp.headers.get("set-cookie", "")
|
|
for part in set_cookie.replace(",", ";").split(";"):
|
|
part = part.strip()
|
|
if part.startswith("PHPSESSID="):
|
|
sid = part.split("=", 1)[1]
|
|
break
|
|
if not sid:
|
|
logger.error("AEROWEB: could not obtain PHPSESSID from login page")
|
|
return None
|
|
|
|
# Step 2: authenticate (httpx session POST directly)
|
|
pwd_hash = hashlib.md5(pwd.encode()).hexdigest()
|
|
login_url = f"{self.AEROWEB_BASE}/ajax/login_valid.php"
|
|
login_resp = self.session.post(
|
|
login_url,
|
|
data={"login": user, "password": pwd_hash},
|
|
cookies={"PHPSESSID": sid},
|
|
timeout=self.timeout,
|
|
)
|
|
if login_resp.text.strip() != "ok":
|
|
logger.error("AEROWEB login failed: {}", login_resp.text.strip()[:100])
|
|
return None
|
|
|
|
logger.info("AEROWEB login OK, sid={}...", sid[:8])
|
|
self._aeroweb_sid = sid
|
|
self._aeroweb_sid_ts = now
|
|
return sid
|
|
|
|
except Exception:
|
|
logger.exception("AEROWEB login error")
|
|
return None
|
|
|
|
def fetch_from_aeroweb(self) -> Optional[Dict]:
|
|
"""Fetch latest METAR for LFPB from AEROWEB."""
|
|
started = datetime.now()
|
|
|
|
def _elapsed_ms() -> float:
|
|
return (datetime.now() - started).total_seconds() * 1000.0
|
|
|
|
sid = self._aeroweb_session()
|
|
if not sid:
|
|
record_source_call("aeroweb", "station", "noauth", _elapsed_ms())
|
|
return None
|
|
|
|
try:
|
|
url = (
|
|
f"{self.AEROWEB_BASE}/ajax/ajax_trajet_get_stations.php"
|
|
f"?reseau=&type=oaci&SERVICE=WFS&VERSION=1.0.0"
|
|
f"&REQUEST=GetFeature&SRS=EPSG:900913"
|
|
f"&BBOX={self.LFPB_BBOX}&LEVEL=8"
|
|
)
|
|
resp = self._http_get(
|
|
url,
|
|
cookies={"PHPSESSID": sid},
|
|
timeout=self.timeout,
|
|
)
|
|
# If redirected to login page, session expired — clear and retry once
|
|
if "login.php" in str(resp.url) or resp.status_code == 302:
|
|
logger.info("AEROWEB session expired, re-logging in")
|
|
self._aeroweb_sid = None
|
|
sid = self._aeroweb_session()
|
|
if not sid:
|
|
record_source_call("aeroweb", "station", "auth_error", _elapsed_ms())
|
|
return None
|
|
resp = self._http_get(
|
|
url,
|
|
cookies={"PHPSESSID": sid},
|
|
timeout=self.timeout,
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
logger.warning("AEROWEB API returned HTTP {}", resp.status_code)
|
|
record_source_call("aeroweb", "station", "error", _elapsed_ms())
|
|
return None
|
|
|
|
root = ET.fromstring(resp.text)
|
|
# Find LFPB metar element
|
|
metar_el = root.find(".//opmet[@id='LFPB']/messages/metar")
|
|
if metar_el is None:
|
|
record_source_call("aeroweb", "station", "empty", _elapsed_ms())
|
|
return None
|
|
|
|
def _attr_int(name: str) -> Optional[int]:
|
|
v = metar_el.get(name)
|
|
return int(v) if v and v.strip() else None
|
|
|
|
def _attr_float(name: str) -> Optional[float]:
|
|
v = metar_el.get(name)
|
|
return float(v) if v and v.strip() else None
|
|
|
|
temp = _attr_float("tempe")
|
|
td = _attr_float("td")
|
|
wind_dir = _attr_float("dd")
|
|
wind_kt = _attr_float("ff")
|
|
qnh = metar_el.get("qnh", "").strip().replace(" HPA", "")
|
|
pressure = float(qnh) if qnh else None
|
|
visibility = _attr_int("visi")
|
|
raw = (metar_el.text or "").strip()
|
|
is_auto = metar_el.get("auto") == "1"
|
|
|
|
# Build observation time from day/hour/minute attributes
|
|
day = _attr_int("day")
|
|
hour = _attr_int("hour")
|
|
minute = _attr_int("minute")
|
|
obs_time = None
|
|
if day is not None and hour is not None and minute is not None:
|
|
now = datetime.utcnow()
|
|
obs_time = datetime(now.year, now.month, day, hour, minute).isoformat()
|
|
|
|
result: Dict = {
|
|
"current": {
|
|
"temp": temp,
|
|
"dew_point": td,
|
|
"wind_dir": wind_dir,
|
|
"wind_speed_kt": wind_kt,
|
|
"pressure": pressure,
|
|
"visibility_m": visibility,
|
|
"raw_metar": raw,
|
|
"is_auto": is_auto,
|
|
},
|
|
"obs_time": obs_time,
|
|
"station_id": "LFPB",
|
|
"station_label": "Paris Le Bourget (AEROWEB)",
|
|
"lat": 48.9695,
|
|
"lon": 2.4415,
|
|
}
|
|
record_source_call("aeroweb", "station", "success", _elapsed_ms())
|
|
logger.info(
|
|
"AEROWEB LFPB {}:{}Z: temp={}°C dew={}°C wind={}kt@{:03.0f}° QNH={}hPa",
|
|
day,
|
|
f"{hour:02d}{minute:02d}",
|
|
temp,
|
|
td,
|
|
wind_kt,
|
|
wind_dir or 0,
|
|
pressure,
|
|
)
|
|
return result
|
|
|
|
except ET.ParseError:
|
|
logger.exception("AEROWEB XML parse error")
|
|
record_source_call("aeroweb", "station", "parse_error", _elapsed_ms())
|
|
return None
|
|
except Exception:
|
|
logger.exception("AEROWEB fetch failed")
|
|
record_source_call("aeroweb", "station", "error", _elapsed_ms())
|
|
return None
|