mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
* 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>
156 lines
5 KiB
Python
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
|