diff --git a/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx b/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx index bb7142a7..234bab45 100644 --- a/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx +++ b/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx @@ -39,7 +39,7 @@ export function AmosRunwayPanel({ // Fallback: single-card METAR observation for non-AMOS airports if (!hasAmosRunway) { - if (!airportCurrent?.temp && !airportCurrent?.wind_speed_kt) return null; + if (airportCurrent?.temp == null && airportCurrent?.wind_speed_kt == null) return null; const sourceLabel = airportCurrent?.source_label || (isEn ? "METAR" : "机场报文"); const staleNote = airportCurrent?.stale_for_today ? isEn ? " (stale)" : "(过旧)" diff --git a/src/data_collection/amos_station_sources.py b/src/data_collection/amos_station_sources.py index b13b5003..0e46220c 100644 --- a/src/data_collection/amos_station_sources.py +++ b/src/data_collection/amos_station_sources.py @@ -8,6 +8,7 @@ from __future__ import annotations import re import time +from html import unescape from datetime import datetime from typing import Any, Dict, Optional @@ -16,6 +17,13 @@ from loguru import logger from src.utils.metrics import record_source_call AMOS_BASE_URL = "https://global.amo.go.kr/amosobsnew/AmosRealTimeImage.do" +AMOS_AIRPORT_QUERY_KEYS = ( + "stnCd", + "icao", + "airport", + "airportCd", + "airPort", +) AMOS_AIRPORT_CODES: Dict[str, Dict[str, str]] = { "seoul": { @@ -72,7 +80,19 @@ def _amos_extract_metar_wind(metar_line: str) -> Optional[float]: return None -def _amos_parse_runway_table(text: str) -> list[dict[str, Any]]: +def _amos_to_lines(text: str) -> list[str]: + """Convert the AMOS HTML/plain text page to parseable text lines.""" + normalized = unescape(str(text or "")) + # The public AMOS page is table-heavy. Preserve cell boundaries as + # whitespace/newlines so regexes work both on raw HTML and crawler text. + normalized = re.sub(r"(?i)<\s*br\s*/?\s*>", "\n", normalized) + normalized = re.sub(r"(?i)", "\n", normalized) + normalized = re.sub(r"<[^>]+>", " ", normalized) + normalized = normalized.replace("\xa0", " ") + return [re.sub(r"\s+", " ", line).strip() for line in normalized.splitlines() if line.strip()] + + +def _amos_parse_runway_table(text: str) -> dict[str, Any]: """Parse the runway-level data from AMOS page HTML text. The page shows data organized by runway direction pairs. @@ -85,44 +105,109 @@ def _amos_parse_runway_table(text: str) -> list[dict[str, Any]]: TEMP/DEW 16.5/9.2 PRECIP 0 QNH 1015.8 """ - # Match runway pair headers like 15L/33R - rwy_pattern = re.compile(r"(\d{2}[LR]?)\s*/\s*(\d{2}[LR]?)") - runway_pairs = rwy_pattern.findall(text) + lines = _amos_to_lines(text) + normalized_text = "\n".join(lines) - # Temperature/Dew pattern - temp_pattern = re.compile(r"TEMP\s*/\s*DEW\s*(\d+\.?\d*)\s*/\s*(\d+\.?\d*)") + runway_pairs: list[tuple[str, str]] = [] + temperatures: list[tuple[float, float]] = [] + pressures_hpa: list[float] = [] + wind_directions: list[tuple[int, int, int]] = [] + wind_speeds: list[tuple[float, float, float]] = [] + visibility_mor: list[int] = [] + rvr: list[int] = [] - # Pressure pattern - qnh_pattern = re.compile(r"QNH\s*(\d+\.?\d*)\s*hPa") + pending_temp: float | None = None - # Wind direction pattern - wd_pattern = re.compile(r"WD\s*(\d+)\s*\(\s*(\d+)\s*-\s*(\d+)\s*\)") + # Current public page format is line/table-cell based: + # 15R AVG MIN MAX + # WD 240 220 250 + # WS 4.7 2.9 6.8 + # TEMP(℃) 13.7 + # DEW (℃) 9.8 + # QNH (hPa) 1021.0 + # 33L AVG MIN MAX + # Older crawler output may use "15R/33L" and "TEMP/DEW 13.7/9.8". + for line in lines: + runway_header = re.match(r"^(\d{2}[LR]?)\s+AVG\s+MIN\s+MAX\b", line, re.I) + if runway_header: + continue - # Wind speed pattern - ws_pattern = re.compile(r"WS\s*(\d+\.?\d*)\s*\(\s*(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\s*\)") + pair_match = re.match(r"^(\d{2}[LR]?)\s*/\s*(\d{2}[LR]?)$", line) + if not pair_match: + pair_match = re.match(r"^(\d{2}[LR]?)\s+(\d{2}[LR]?)$", line) + if pair_match: + pair = (pair_match.group(1), pair_match.group(2)) + # Ignore bare duplicated orientation rows such as "15 33" when + # richer L/R pair labels are present nearby, but keep them as a + # fallback for airports without side designators. + if pair not in runway_pairs: + runway_pairs.append(pair) + continue - # Visibility pattern - mor_pattern = re.compile(r"MOR\s*(\d+)") + wd = re.match(r"^WD\s+(\d+)\s+(\d+)\s+(\d+)\b", line, re.I) + if wd: + wind_directions.append((int(wd.group(1)), int(wd.group(2)), int(wd.group(3)))) + continue - # RVR pattern - rvr_pattern = re.compile(r"RVR\s*P?(\d+)") + ws = re.match(r"^WS\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\b", line, re.I) + if ws: + wind_speeds.append((float(ws.group(1)), float(ws.group(2)), float(ws.group(3)))) + continue - temps = temp_pattern.findall(text) - qnhs = qnh_pattern.findall(text) + temp_dew = re.search( + r"TEMP\s*/\s*DEW\s*(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)", + line, + re.I, + ) + if temp_dew: + temperatures.append((float(temp_dew.group(1)), float(temp_dew.group(2)))) + continue - wd_matches = list(wd_pattern.finditer(text)) - ws_matches = list(ws_pattern.finditer(text)) - mor_matches = list(mor_pattern.finditer(text)) - rvr_matches = list(rvr_pattern.finditer(text)) + temp_match = re.search(r"TEMP\s*\([^)]*\)\s*(\d+(?:\.\d+)?)", line, re.I) + if temp_match: + pending_temp = float(temp_match.group(1)) + continue + + dew_match = re.search(r"DEW\s*\([^)]*\)\s*(\d+(?:\.\d+)?)", line, re.I) + if dew_match and pending_temp is not None: + temperatures.append((pending_temp, float(dew_match.group(1)))) + pending_temp = None + continue + + qnh = re.search(r"QNH\s*(?:\(\s*hPa\s*\))?\s*(\d+(?:\.\d+)?)", line, re.I) + if qnh: + pressures_hpa.append(float(qnh.group(1))) + continue + + mor = re.match(r"^MOR\s+(\d+)", line, re.I) + if mor: + visibility_mor.append(int(mor.group(1))) + continue + + rvr_match = re.match(r"^RVR\s+P?(\d+)", line, re.I) + if rvr_match: + rvr.append(int(rvr_match.group(1))) + + # Prefer concrete runway-side pairs (15L/33R) over repeated orientation + # rows (15/33). If no paired label exists, pair runway headers in order. + side_pairs = [p for p in runway_pairs if any(ch in "".join(p) for ch in ("L", "R", "C"))] + if side_pairs: + runway_pairs = side_pairs + elif not runway_pairs: + headers = re.findall(r"^(\d{2}[LRC]?)\s+AVG\s+MIN\s+MAX\b", normalized_text, re.I | re.M) + runway_pairs = [ + (headers[i], headers[i + 1]) + for i in range(0, len(headers) - 1, 2) + ] return { - "runway_pairs": [(r[0], r[1]) for r in runway_pairs], - "temperatures": [(float(t[0]), float(t[1])) for t in temps], - "pressures_hpa": [float(q) for q in qnhs], - "wind_directions": [(int(m.group(1)), int(m.group(2)), int(m.group(3))) for m in wd_matches], - "wind_speeds": [(float(m.group(1)), float(m.group(2)), float(m.group(3))) for m in ws_matches], - "visibility_mor": [int(m.group(1)) for m in mor_matches], - "rvr": [int(m.group(1)) for m in rvr_matches], + "runway_pairs": runway_pairs, + "temperatures": temperatures, + "pressures_hpa": pressures_hpa, + "wind_directions": wind_directions, + "wind_speeds": wind_speeds, + "visibility_mor": visibility_mor, + "rvr": rvr, } @@ -134,30 +219,33 @@ class AmosStationSourceMixin: def _amos_get_page(self, icao: str) -> Optional[str]: """Fetch the AMOS page. - The AMOS site (global.amo.go.kr) always loads Incheon (RKSI) by default. - Airport switching is done via JavaScript — not via URL parameters or POST. - This means RKPK (Busan/Gimhae) cannot be fetched reliably from this endpoint. - For non-RKSI airports, we fall back to standard METAR sources. + The AMOS site loads Incheon (RKSI) by default. Keep the default URL + for RKSI and try common airport-code query keys for other airports; + only accept a response when the requested ICAO is present, so ignored + parameters cannot accidentally attach RKSI data to Busan/RKPK. """ started = time.perf_counter() + icao = str(icao or "").strip().upper() + urls = [AMOS_BASE_URL] if icao != "RKSI": - logger.debug("AMOS page only supports RKSI (Incheon), not {}", icao) - return None + urls = [f"{AMOS_BASE_URL}?{key}={icao}" for key in AMOS_AIRPORT_QUERY_KEYS] try: - getter = getattr(self, "_http_get_text", None) - if callable(getter): - text = str(getter(AMOS_BASE_URL)) - elif hasattr(self, "session"): - resp = self.session.get(AMOS_BASE_URL, timeout=float(getattr(self, "timeout", 4.0))) - resp.raise_for_status() - text = resp.text - else: - return None + for url in urls: + getter = getattr(self, "_http_get_text", None) + if callable(getter): + text = str(getter(url)) + elif hasattr(self, "session"): + resp = self.session.get(url, timeout=float(getattr(self, "timeout", 4.0))) + resp.raise_for_status() + text = resp.text + else: + return None - if text and "RKSI" in text.upper(): - record_source_call("amos", "page", "success", (time.perf_counter() - started) * 1000.0) - return text + if text and re.search(rf"\({icao}\)|\b(?:METAR|TAF)\s+{icao}\b", text, re.I): + record_source_call("amos", "page", "success", (time.perf_counter() - started) * 1000.0) + return text + logger.debug("AMOS page did not expose requested airport {}", icao) return None except Exception as exc: logger.debug("AMOS page fetch failed icao={}: {}", icao, exc) @@ -193,12 +281,13 @@ class AmosStationSourceMixin: return None # Parse METAR line - metar_match = re.search(r"METAR\s+(RKSI|RKPK)\s.*?=", html, re.DOTALL) + icao_pattern = re.escape(icao) + metar_match = re.search(rf"METAR\s+{icao_pattern}\s.*?=", html, re.DOTALL) metar_line = metar_match.group(0) if metar_match else "" metar_line = re.sub(r"\s+", " ", metar_line).strip() # Parse TAF line - taf_match = re.search(r"TAF\s+(RKSI|RKPK)\s.*?=", html, re.DOTALL) + taf_match = re.search(rf"TAF\s+{icao_pattern}\s.*?=", html, re.DOTALL) taf_line = taf_match.group(0) if taf_match else "" taf_line = re.sub(r"\s+", " ", taf_line).strip() diff --git a/tests/test_amos_station_sources.py b/tests/test_amos_station_sources.py new file mode 100644 index 00000000..6965240a --- /dev/null +++ b/tests/test_amos_station_sources.py @@ -0,0 +1,64 @@ +from src.data_collection.amos_station_sources import AmosStationSourceMixin, _amos_parse_runway_table + + +def test_amos_parser_handles_current_public_page_format(): + text = """ + AMOS + [인천공항 (RKSI) 2026년 05월 09일 07:28 KST] + + 15 33 + 15L AVG MIN MAX + WD 230 220 250 + WS 5.6 4.3 6.3 + MOR 10000 + RVR P2000 + 33R AVG MIN MAX + WD 230 190 260 + WS 5.0 2.8 8.3 + MOR 10000 + RVR P2000 + + 15L 33R + 15R AVG MIN MAX + WD 240 220 250 + WS 4.7 2.9 6.8 + MOR 10000 + RVR P2000 + TEMP(℃) 13.7 + DEW (℃) 9.8 + PRECIP(㎜) 0 + QNH (hPa) 1021.0 + QNH (inHg) 30.15 + 33L AVG MIN MAX + WD 230 210 250 + WS 4.2 3.0 5.9 + 15R 33L + """ + + parsed = _amos_parse_runway_table(text) + + assert parsed["runway_pairs"] == [("15L", "33R"), ("15R", "33L")] + assert parsed["temperatures"] == [(13.7, 9.8)] + assert parsed["pressures_hpa"] == [1021.0] + assert parsed["wind_directions"][0] == (230, 220, 250) + assert parsed["wind_speeds"][1] == (5.0, 2.8, 8.3) + assert parsed["visibility_mor"][0] == 10000 + assert parsed["rvr"][0] == 2000 + + +def test_amos_get_page_rejects_ignored_query_returning_default_rksi(): + class FakeCollector(AmosStationSourceMixin): + timeout = 1.0 + + def _http_get_text(self, url): + return """ + + + 김해공항 RKPK + [인천공항 (RKSI) 2026년 05월 09일 07:28 KST] + METAR RKSI 082230Z 22005KT CAVOK 14/10 Q1021 NOSIG= + + + """ + + assert FakeCollector()._amos_get_page("RKPK") is None