#!/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'
  • (?P.*?)
  • ', re.DOTALL, ) SEAT_LINK_RE = re.compile( r"]+selectSeatInfo\(this,\s*'(?P[^']+)',\s*'(?P[^']+)'\)[^>]*>" r".*?\((?P[^)]*)\)", re.DOTALL, ) SEAT_SPAN_RE = re.compile( r"\s*(?P\d+[A-Z])\s*\((?P[^)]*)\)", 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