Fix AMOS runway observations
This commit is contained in:
@@ -39,7 +39,7 @@ export function AmosRunwayPanel({
|
|||||||
|
|
||||||
// Fallback: single-card METAR observation for non-AMOS airports
|
// Fallback: single-card METAR observation for non-AMOS airports
|
||||||
if (!hasAmosRunway) {
|
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 sourceLabel = airportCurrent?.source_label || (isEn ? "METAR" : "机场报文");
|
||||||
const staleNote = airportCurrent?.stale_for_today
|
const staleNote = airportCurrent?.stale_for_today
|
||||||
? isEn ? " (stale)" : "(过旧)"
|
? isEn ? " (stale)" : "(过旧)"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from html import unescape
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ from loguru import logger
|
|||||||
from src.utils.metrics import record_source_call
|
from src.utils.metrics import record_source_call
|
||||||
|
|
||||||
AMOS_BASE_URL = "https://global.amo.go.kr/amosobsnew/AmosRealTimeImage.do"
|
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]] = {
|
AMOS_AIRPORT_CODES: Dict[str, Dict[str, str]] = {
|
||||||
"seoul": {
|
"seoul": {
|
||||||
@@ -72,7 +80,19 @@ def _amos_extract_metar_wind(metar_line: str) -> Optional[float]:
|
|||||||
return None
|
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)</\s*(?:tr|div|p|li|h\d|table)\s*>", "\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.
|
"""Parse the runway-level data from AMOS page HTML text.
|
||||||
|
|
||||||
The page shows data organized by runway direction pairs.
|
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
|
TEMP/DEW 16.5/9.2
|
||||||
PRECIP 0 QNH 1015.8
|
PRECIP 0 QNH 1015.8
|
||||||
"""
|
"""
|
||||||
# Match runway pair headers like 15L/33R
|
lines = _amos_to_lines(text)
|
||||||
rwy_pattern = re.compile(r"(\d{2}[LR]?)\s*/\s*(\d{2}[LR]?)")
|
normalized_text = "\n".join(lines)
|
||||||
runway_pairs = rwy_pattern.findall(text)
|
|
||||||
|
|
||||||
# Temperature/Dew pattern
|
runway_pairs: list[tuple[str, str]] = []
|
||||||
temp_pattern = re.compile(r"TEMP\s*/\s*DEW\s*(\d+\.?\d*)\s*/\s*(\d+\.?\d*)")
|
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
|
pending_temp: float | None = None
|
||||||
qnh_pattern = re.compile(r"QNH\s*(\d+\.?\d*)\s*hPa")
|
|
||||||
|
|
||||||
# Wind direction pattern
|
# Current public page format is line/table-cell based:
|
||||||
wd_pattern = re.compile(r"WD\s*(\d+)\s*\(\s*(\d+)\s*-\s*(\d+)\s*\)")
|
# 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
|
pair_match = re.match(r"^(\d{2}[LR]?)\s*/\s*(\d{2}[LR]?)$", line)
|
||||||
ws_pattern = re.compile(r"WS\s*(\d+\.?\d*)\s*\(\s*(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\s*\)")
|
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
|
wd = re.match(r"^WD\s+(\d+)\s+(\d+)\s+(\d+)\b", line, re.I)
|
||||||
mor_pattern = re.compile(r"MOR\s*(\d+)")
|
if wd:
|
||||||
|
wind_directions.append((int(wd.group(1)), int(wd.group(2)), int(wd.group(3))))
|
||||||
|
continue
|
||||||
|
|
||||||
# RVR pattern
|
ws = re.match(r"^WS\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\b", line, re.I)
|
||||||
rvr_pattern = re.compile(r"RVR\s*P?(\d+)")
|
if ws:
|
||||||
|
wind_speeds.append((float(ws.group(1)), float(ws.group(2)), float(ws.group(3))))
|
||||||
|
continue
|
||||||
|
|
||||||
temps = temp_pattern.findall(text)
|
temp_dew = re.search(
|
||||||
qnhs = qnh_pattern.findall(text)
|
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))
|
temp_match = re.search(r"TEMP\s*\([^)]*\)\s*(\d+(?:\.\d+)?)", line, re.I)
|
||||||
ws_matches = list(ws_pattern.finditer(text))
|
if temp_match:
|
||||||
mor_matches = list(mor_pattern.finditer(text))
|
pending_temp = float(temp_match.group(1))
|
||||||
rvr_matches = list(rvr_pattern.finditer(text))
|
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 {
|
return {
|
||||||
"runway_pairs": [(r[0], r[1]) for r in runway_pairs],
|
"runway_pairs": runway_pairs,
|
||||||
"temperatures": [(float(t[0]), float(t[1])) for t in temps],
|
"temperatures": temperatures,
|
||||||
"pressures_hpa": [float(q) for q in qnhs],
|
"pressures_hpa": pressures_hpa,
|
||||||
"wind_directions": [(int(m.group(1)), int(m.group(2)), int(m.group(3))) for m in wd_matches],
|
"wind_directions": wind_directions,
|
||||||
"wind_speeds": [(float(m.group(1)), float(m.group(2)), float(m.group(3))) for m in ws_matches],
|
"wind_speeds": wind_speeds,
|
||||||
"visibility_mor": [int(m.group(1)) for m in mor_matches],
|
"visibility_mor": visibility_mor,
|
||||||
"rvr": [int(m.group(1)) for m in rvr_matches],
|
"rvr": rvr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -134,30 +219,33 @@ class AmosStationSourceMixin:
|
|||||||
def _amos_get_page(self, icao: str) -> Optional[str]:
|
def _amos_get_page(self, icao: str) -> Optional[str]:
|
||||||
"""Fetch the AMOS page.
|
"""Fetch the AMOS page.
|
||||||
|
|
||||||
The AMOS site (global.amo.go.kr) always loads Incheon (RKSI) by default.
|
The AMOS site loads Incheon (RKSI) by default. Keep the default URL
|
||||||
Airport switching is done via JavaScript — not via URL parameters or POST.
|
for RKSI and try common airport-code query keys for other airports;
|
||||||
This means RKPK (Busan/Gimhae) cannot be fetched reliably from this endpoint.
|
only accept a response when the requested ICAO is present, so ignored
|
||||||
For non-RKSI airports, we fall back to standard METAR sources.
|
parameters cannot accidentally attach RKSI data to Busan/RKPK.
|
||||||
"""
|
"""
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
icao = str(icao or "").strip().upper()
|
||||||
|
urls = [AMOS_BASE_URL]
|
||||||
if icao != "RKSI":
|
if icao != "RKSI":
|
||||||
logger.debug("AMOS page only supports RKSI (Incheon), not {}", icao)
|
urls = [f"{AMOS_BASE_URL}?{key}={icao}" for key in AMOS_AIRPORT_QUERY_KEYS]
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
getter = getattr(self, "_http_get_text", None)
|
for url in urls:
|
||||||
if callable(getter):
|
getter = getattr(self, "_http_get_text", None)
|
||||||
text = str(getter(AMOS_BASE_URL))
|
if callable(getter):
|
||||||
elif hasattr(self, "session"):
|
text = str(getter(url))
|
||||||
resp = self.session.get(AMOS_BASE_URL, timeout=float(getattr(self, "timeout", 4.0)))
|
elif hasattr(self, "session"):
|
||||||
resp.raise_for_status()
|
resp = self.session.get(url, timeout=float(getattr(self, "timeout", 4.0)))
|
||||||
text = resp.text
|
resp.raise_for_status()
|
||||||
else:
|
text = resp.text
|
||||||
return None
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
if text and "RKSI" in text.upper():
|
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)
|
record_source_call("amos", "page", "success", (time.perf_counter() - started) * 1000.0)
|
||||||
return text
|
return text
|
||||||
|
logger.debug("AMOS page did not expose requested airport {}", icao)
|
||||||
return None
|
return None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("AMOS page fetch failed icao={}: {}", icao, exc)
|
logger.debug("AMOS page fetch failed icao={}: {}", icao, exc)
|
||||||
@@ -193,12 +281,13 @@ class AmosStationSourceMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Parse METAR line
|
# 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 = metar_match.group(0) if metar_match else ""
|
||||||
metar_line = re.sub(r"\s+", " ", metar_line).strip()
|
metar_line = re.sub(r"\s+", " ", metar_line).strip()
|
||||||
|
|
||||||
# Parse TAF line
|
# 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 = taf_match.group(0) if taf_match else ""
|
||||||
taf_line = re.sub(r"\s+", " ", taf_line).strip()
|
taf_line = re.sub(r"\s+", " ", taf_line).strip()
|
||||||
|
|
||||||
|
|||||||
@@ -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 """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a>김해공항 RKPK</a>
|
||||||
|
[인천공항 (RKSI) 2026년 05월 09일 07:28 KST]
|
||||||
|
METAR RKSI 082230Z 22005KT CAVOK 14/10 Q1021 NOSIG=
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert FakeCollector()._amos_get_page("RKPK") is None
|
||||||
Reference in New Issue
Block a user