k-skill/scripts/srt_seats.py
iamiks 52dbfee064
feat(srt-booking): SRT 좌석 확인과 탐색 우선순위 개선 (#305)
* feat(srt): 좌석 조회와 탐색 우선순위 추가

SRT search 결과의 stable train_id로 객차별 좌석을 조회하고, 특정 호차/좌석 확인과 탐색 우선순위 옵션을 제공한다.

Constraint: SRT와 KTX는 별도 upstream 표면이므로 SRT HTML 파서와 테스트를 분리함
Rejected: KTX 좌석 helper 공유 | Korail API와 SRT 웹 좌석선택 HTML 계약이 달라 혼용하면 파서 안정성이 낮아짐
Confidence: medium
Scope-risk: moderate
Directive: SRT 좌석선택 HTML에서 노출되지 않는 속성은 추정하지 말고 명시적으로 처리할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_srt_booking scripts.test_ktx_booking; python3 -m py_compile scripts/srt_booking.py scripts/srt_seats.py scripts/test_srt_booking.py
Not-tested: 실제 예약 API에 우선순위 좌석 선택을 연결하는 흐름

* fix(srt): 좌석 조회 JSON 출력 안정화

SRT 대기열 메시지가 stdout에 섞여 seats JSON을 깨는 실제 표면 문제를 막고, 누락된 좌석 방향/위치 속성을 unknown으로 정규화한다.

Constraint: issue #303 범위는 예약 부작용이 없는 좌석 조회 보조 흐름으로 제한됨
Rejected: 실제 예약 subcommand 추가 | 좌석 선점/예약은 외부 부작용이라 이번 acceptance criteria에 포함되지 않음
Confidence: high
Scope-risk: narrow
Directive: SRTrain upstream 출력이 추가되더라도 helper stdout은 JSON 전용으로 유지할 것
Tested: RED→GREEN in .omo/ulw-loop/evidence/srt-c002-red-green-tests.txt; live SRT tmux QA in .omo/ulw-loop/evidence/srt-c001-live-search-seats.txt; npm run ci in .omo/ulw-loop/evidence/srt-c003-regression-ci.txt
Not-tested: 실제 예약/결제/취소 부작용 흐름

* test(srt): split seat helper regression coverage

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-06-09 10:57:54 +09:00

156 lines
5 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import re
from typing import TypedDict
class SrtCar(TypedDict):
car_no: int
car_no_raw: str
room_class: str
available: bool
current: bool
class SrtSeat(TypedDict):
seat: str
seat_no: str
available: bool
direction: str
position: str
notes: list[str]
CAR_RE = re.compile(
r'<li class="scar-(?P<car>\d+)(?P<class>[^"]*)">(?P<body>.*?)</li>',
re.DOTALL,
)
SEAT_LINK_RE = re.compile(
r"<a[^>]+selectSeatInfo\(this,\s*'(?P<seat_no>[^']+)',\s*'(?P<seat>[^']+)'\)[^>]*>"
r".*?<em>\((?P<detail>[^)]*)\)</em>",
re.DOTALL,
)
SEAT_SPAN_RE = re.compile(
r"<span>\s*(?P<seat>\d+[A-Z])\s*<strong><em>\((?P<detail>[^)]*)\)</em></strong></span>",
re.DOTALL,
)
TAG_RE = re.compile(r"<[^>]+>")
def strip_tags(value: str) -> str:
return TAG_RE.sub(" ", value).replace("\xa0", " ").strip()
def parse_detail(detail: str) -> tuple[str, str, list[str]]:
parts = [part.strip() for part in detail.split(",")]
direction = next((part for part in parts if part in {"정방향", "역방향"}), "unknown")
position = next((part for part in parts if part in {"창측", "내측", "1인석"}), "unknown")
notes = [part for part in parts if part not in {direction, position} and part]
return direction, position, notes
def parse_cars(html: str) -> list[SrtCar]:
cars: list[SrtCar] = []
for match in CAR_RE.finditer(html):
body = match.group("body")
text = strip_tags(body)
room_class = "특실" if "특실" in text else "일반실"
css_class = match.group("class")
has_link = "selectScarInfo" in body
cars.append(
{
"car_no": int(match.group("car")),
"car_no_raw": f"{int(match.group('car')):04d}",
"room_class": room_class,
"available": has_link and "off" not in css_class.split(),
"current": "on" in css_class.split(),
}
)
return cars
def parse_seats(html: str) -> list[SrtSeat]:
seats: list[SrtSeat] = []
seen: set[str] = set()
for match in SEAT_LINK_RE.finditer(html):
direction, position, notes = parse_detail(match.group("detail"))
seat = match.group("seat")
seen.add(seat)
seats.append(
{
"seat": seat,
"seat_no": match.group("seat_no"),
"available": True,
"direction": direction,
"position": position,
"notes": notes,
}
)
for match in SEAT_SPAN_RE.finditer(html):
seat = match.group("seat")
if seat in seen:
continue
direction, position, notes = parse_detail(match.group("detail"))
seats.append(
{
"seat": seat,
"seat_no": "",
"available": False,
"direction": direction,
"position": position,
"notes": notes,
}
)
return seats
def parse_seat_label(seat_label: str) -> tuple[int | None, str]:
match = re.match(r"^(\d+)([A-Z])$", seat_label)
if match is None:
return None, ""
return int(match.group(1)), match.group(2)
def car_center_priority(car: SrtCar, car_numbers: list[int]) -> tuple[float, int]:
if not car_numbers:
return (0.0, car["car_no"])
center = (min(car_numbers) + max(car_numbers)) / 2
return (abs(car["car_no"] - center), car["car_no"])
def sort_cars_for_booking(cars: list[SrtCar], priority: str = "center") -> list[SrtCar]:
match priority:
case "center":
car_numbers = [car["car_no"] for car in cars]
return sorted(cars, key=lambda car: car_center_priority(car, car_numbers))
case "low":
return sorted(cars, key=lambda car: car["car_no"])
case "high":
return sorted(cars, key=lambda car: car["car_no"], reverse=True)
case _:
raise ValueError(f"unsupported car priority: {priority}")
def seat_preference_key(seat: SrtSeat, priority: str = "forward-window") -> tuple[int, int, int, str]:
row, column = parse_seat_label(seat["seat"])
forward_rank = 0 if seat["direction"] == "정방향" else 1
window_rank = 0 if seat["position"] in {"창측", "1인석"} else 1
row_rank = 999 if row is None else row
match priority:
case "forward-window":
return (forward_rank, window_rank, row_rank, column)
case "window-forward":
return (window_rank, forward_rank, row_rank, column)
case "row-low":
return (row_rank, forward_rank, window_rank, column)
case _:
raise ValueError(f"unsupported seat priority: {priority}")
def sort_seats_for_booking(seats: list[SrtSeat], priority: str = "forward-window") -> list[SrtSeat]:
return sorted(seats, key=lambda seat: seat_preference_key(seat, priority))
sort_cars = sort_cars_for_booking
sort_seats = sort_seats_for_booking