Stabilize KTX seat lookup review fixes

Constraint: PR #293 review found fallback korail2 imports failed under PYTHONNOUSERSITE and seat lookup needed clearer Dynapath/power-seat contracts.
Rejected: Leave research endpoints outside DYNAPATH_PATHS | would keep the auth-header behavior ambiguous for live seat lookup.
Confidence: high
Scope-risk: narrow
Directive: Keep fallback-only tests independent of installed korail2 so CI catches missing dependency behavior.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help; node --test scripts/skill-docs.test.js
Not-tested: live Korail seat lookup against production endpoints
This commit is contained in:
iamiks 2026-05-28 22:59:24 +09:00
commit 9b9f5bc7a2
4 changed files with 53 additions and 5 deletions

View file

@ -97,7 +97,7 @@ python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <t
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
```
특실 좌석을 확인하려면 `--room special`, KTX 외 열차를 조회했다면 `search` 와 같은 `--train-type` 을 함께 넘긴다.
`--power-only` 는 KTX/KTX-산천에서 알려진 호차 배치 힌트 기반의 best-effort 필터다. 특실 좌석을 확인하려면 `--room special`, KTX 외 열차를 조회했다면 `search` 와 같은 `--train-type` 을 함께 넘긴다.
```bash
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \

View file

@ -141,7 +141,7 @@ python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <t
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
```
콘센트 꿀팁 자리부터 확인하려면 `--power-only` 를 붙인다. 응답의 `power_outlet``direct`, `adjacent`, `none` 중 하나다.
콘센트 꿀팁 자리부터 확인하려면 `--power-only` 를 붙인다. 응답의 `power_outlet``direct`, `adjacent`, `none` 중 하나다. 이 필터는 KTX/KTX-산천에서 알려진 호차 배치 힌트 기반 best-effort 이다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only

View file

@ -103,7 +103,7 @@ except ModuleNotFoundError as exc:
class _FallbackKorailModule:
EMAIL_REGEX = re.compile(r".+@.+")
PHONE_NUMBER_REGEX = re.compile(r"^\d+$")
PHONE_NUMBER_REGEX = re.compile(r"^01\d-?\d{3,4}-?\d{4}$")
korail_mod = _FallbackKorailModule()
else:
@ -127,6 +127,8 @@ DEFAULT_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; SM-S928N Build/UP1A.23
DYNAPATH_PATHS = [
"/classes/com.korail.mobile.certification.TicketReservation",
"/classes/com.korail.mobile.nonMember.NonMemTicket",
"/classes/com.korail.mobile.research.ResidualSeatsResearch.do",
"/classes/com.korail.mobile.research.TrainResearch",
"/classes/com.korail.mobile.seatMovie.ScheduleView",
"/classes/com.korail.mobile.seatMovie.ScheduleViewSpecial",
"/classes/com.korail.mobile.trn.prcFare.do",
@ -899,10 +901,15 @@ def command_seats(args: argparse.Namespace) -> None:
car_payloads: list[dict[str, object]] = []
for car in cars:
raw = client.car_seats(raw_train, str(car["car_no_raw"]), passenger_count, room_class)
raw_seats = raw.get("seat_infos", {}).get("seat_info", [])
seat_infos = raw.get("seat_infos", {}) if isinstance(raw, dict) else {}
raw_seats = seat_infos.get("seat_info", []) if isinstance(seat_infos, dict) else []
if isinstance(raw_seats, dict):
raw_seats = [raw_seats]
all_seats = [normalize_seat(seat) for seat in raw_seats if seat.get("h_con_seat_no") != "0A"]
all_seats = [
normalize_seat(seat)
for seat in raw_seats
if isinstance(seat, dict) and seat.get("h_con_seat_no") != "0A"
]
available_seats = [seat for seat in all_seats if seat["available"]]
seats = all_seats
if args.available_only:

View file

@ -327,6 +327,10 @@ class KtxBookingTests(unittest.TestCase):
self.assertFalse(ktx_booking.is_phone_login_id("1234567890"))
self.assertFalse(ktx_booking.is_phone_login_id("user@example.com"))
def test_seat_lookup_urls_receive_dynapath_headers(self):
self.assertTrue(any(path in ktx_booking.KORAIL_CARS_INFO for path in ktx_booking.DYNAPATH_PATHS))
self.assertTrue(any(path in ktx_booking.KORAIL_CAR_DETAIL for path in ktx_booking.DYNAPATH_PATHS))
def test_command_search_replays_selected_train_type(self):
selected = FakeTrain(
train_no="2080",
@ -598,6 +602,43 @@ class KtxBookingTests(unittest.TestCase):
self.assertIn("car_no 5", str(exc.exception))
def test_command_seats_treats_malformed_seat_infos_as_empty(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
)
client.car_seats = lambda *args, **kwargs: {"seat_infos": []}
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=True,
power_only=False,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual(result["cars"][0]["available_seat_count"], 0)
self.assertEqual(result["cars"][0]["seats"], [])
def test_build_parser_has_ncard_commands(self):
parser = ktx_booking.build_parser()
help_text = parser.format_help()