Files
Jeffrey (Dongkyu) Kim 1e13cc10e1 Keep PR #19 CI stable without user Python site-packages
The validate job was failing because the KTX helper regression suite imported optional runtime dependencies directly from the module top level. GitHub Actions does not inherit the local user site-packages that made those imports succeed on the workstation, so the test suite died before it could exercise the helper logic.

This change makes the helper import-safe in minimal environments by deferring requests usage, providing lightweight fallbacks for optional Korail/Crypto imports during unit tests, and surfacing an explicit install command when the real runtime dependencies are actually needed. The docs now list pycryptodome alongside korail2, and the regression suite forces PYTHONNOUSERSITE=1 so CI keeps exercising the dependency-light path instead of accidentally relying on a developer machine.

Constraint: PR #19 must keep npm run ci green on GitHub Actions without assuming user-level Python packages
Constraint: The KTX helper still needs the real korail2 and pycryptodome packages for live reservation flows
Rejected: Installing ad-hoc Python packages in the CI workflow | hides the import-safety regression instead of fixing the helper/test contract
Rejected: Removing the Python regression suite from skill-docs coverage | would lose the guard on the train_id reservation flow
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the KTX helper importable under PYTHONNOUSERSITE=1 and document every required runtime package in both the skill and install docs
Tested: PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p test_ktx_booking.py
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Not-tested: GitHub Actions validate rerun after push
2026-03-27 22:51:33 +09:00

761 lines
28 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import random
import re
import string
import sys
import time
from functools import reduce
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
except ModuleNotFoundError as exc:
AES = None
pad = None
_CRYPTO_IMPORT_ERROR = exc
else:
_CRYPTO_IMPORT_ERROR = None
try:
from korail2 import (
AdultPassenger,
ChildPassenger,
Korail,
KorailError,
NeedToLoginError,
NoResultsError,
Passenger,
ReserveOption,
SeniorPassenger,
SoldOutError,
ToddlerPassenger,
TrainType,
)
import korail2.korail2 as korail_mod
except ModuleNotFoundError as exc:
_KORAIL_IMPORT_ERROR = exc
class KorailError(Exception):
pass
class NeedToLoginError(KorailError):
pass
class NoResultsError(KorailError):
pass
class SoldOutError(KorailError):
pass
class Passenger:
def __init__(self, count: int = 1):
self.count = count
@staticmethod
def reduce(passengers):
return passengers
def get_dict(self, _: int) -> dict[str, str]:
return {}
class AdultPassenger(Passenger):
pass
class ChildPassenger(Passenger):
pass
class ToddlerPassenger(Passenger):
pass
class SeniorPassenger(Passenger):
pass
class ReserveOption:
GENERAL_FIRST = "GENERAL_FIRST"
GENERAL_ONLY = "GENERAL_ONLY"
SPECIAL_FIRST = "SPECIAL_FIRST"
SPECIAL_ONLY = "SPECIAL_ONLY"
class TrainType:
ALL = "ALL"
KTX = "KTX"
class Korail:
def __init__(self, *args, **kwargs):
raise ModuleNotFoundError("korail2")
class _FallbackKorailModule:
EMAIL_REGEX = re.compile(r".+@.+")
PHONE_NUMBER_REGEX = re.compile(r"^\d+$")
korail_mod = _FallbackKorailModule()
else:
_KORAIL_IMPORT_ERROR = None
DEFAULT_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; SM-S928N Build/UP1A.231005.007)"
DYNAPATH_PATHS = [
"/classes/com.korail.mobile.certification.TicketReservation",
"/classes/com.korail.mobile.nonMember.NonMemTicket",
"/classes/com.korail.mobile.seatMovie.ScheduleView",
"/classes/com.korail.mobile.seatMovie.ScheduleViewSpecial",
"/classes/com.korail.mobile.trn.prcFare.do",
"/classes/com.korail.mobile.login.Login",
]
RESERVE_OPTION_MAP = {
"general-first": ReserveOption.GENERAL_FIRST,
"general-only": ReserveOption.GENERAL_ONLY,
"special-first": ReserveOption.SPECIAL_FIRST,
"special-only": ReserveOption.SPECIAL_ONLY,
}
TRAIN_ID_PREFIX = "ktx:v1:"
TRAIN_ID_INVALID_MESSAGE = "train_id is invalid; rerun search and copy a fresh train_id"
TRAIN_ID_STALE_MESSAGE = "train_id no longer matches any current search result; rerun search and choose a fresh train_id"
TRAIN_ID_FIELDS = (
"train_no",
"dep_date",
"dep_time",
"arr_date",
"arr_time",
"run_date",
"train_group",
"dep_code",
"arr_code",
)
def ensure_runtime_dependencies() -> None:
missing: list[str] = []
if _KORAIL_IMPORT_ERROR is not None:
missing.append("korail2")
if _CRYPTO_IMPORT_ERROR is not None:
missing.append("pycryptodome")
if missing:
install_command = f"python3 -m pip install {' '.join(missing)}"
raise SystemExit(
"scripts/ktx_booking.py requires additional Python packages "
f"({', '.join(missing)}). Install them before running this helper: {install_command}"
)
class DynaPathMasterEngine:
APP_ID = "com.korail.talk"
AS_VALUE = "%5B38ff229cb34c7dda8e28220a2d750cce%5D"
DEVICE_MODEL = "SM-S928N"
OS_TYPE = "Android"
SDK_VERSION = "v1"
def __init__(self) -> None:
self.table = "3FE9jgRD4KdCyuawklqGJYmvfMn15P7US8XbxeLQtWT6OicBAopINs2Vh0HZrz"
self.i8 = 161
self.i9 = 30
self.i10 = 2
self.app_start_ts = str(int(time.time() * 1000))
def string2xa1s(self, data: str) -> list[int]:
result: list[int] = []
idx = 0
while idx < len(data):
codepoint = ord(data[idx])
idx += 1
if codepoint < 128:
result.append(codepoint)
elif codepoint < 2048:
result.append(128 | ((codepoint >> 7) & 15))
result.append(codepoint & 127)
elif codepoint >= 262144:
result.append(160)
result.append((codepoint >> 14) & 127)
result.append((codepoint >> 7) & 127)
result.append(codepoint & 127)
elif (63488 & codepoint) != 55296:
result.append(((codepoint >> 14) & 15) | 144)
result.append((codepoint >> 7) & 127)
result.append(codepoint & 127)
return result
def make_key(self, key: str) -> int:
total = 0
for char in key:
codepoint = ord(char)
bit = 32768
for _ in range(16):
if bit & codepoint:
break
bit >>= 1
total = (total * (bit << 1)) + codepoint
return total
def internal_char(self, base_table: str, remainder: int, current: str) -> str:
seen = 0
for char in base_table:
if char in current:
continue
if seen == remainder:
return char
seen += 1
return " "
def make_encode_table(self, number: int, encode_size: int, base_table: str) -> str:
chars = ""
temp = number
for index in range(encode_size):
divisor = encode_size - index
remainder = temp % divisor
chars += self.internal_char(base_table, remainder, chars)
temp //= divisor
return chars
def encode_normal_be(self, data: str, table: str) -> str:
values = self.string2xa1s(data)
output: list[str] = []
digits = [0] * (self.i10 + 1)
idx = 0
tail = len(values) % self.i10
body_size = len(values) - tail
while idx < body_size:
value = 0
for _ in range(self.i10):
value = (value * self.i8) + values[idx]
idx += 1
for digit_index in range(self.i10 + 1):
digits[digit_index] = value % self.i9
value //= self.i9
for digit_index in range(self.i10, -1, -1):
output.append(table[digits[digit_index]])
if tail > 0:
value = 0
for _ in range(tail):
value = (value * self.i8) + values[idx]
idx += 1
for digit_index in range(tail + 1):
digits[digit_index] = value % self.i9
value //= self.i9
while tail >= 0:
output.append(table[digits[tail]])
tail -= 1
return "".join(output)
def generate_token(self, device_id: str, timestamp_ms: int, nonce: str) -> str:
plaintext = (
f"ai={self.APP_ID}&di={device_id}&as={self.AS_VALUE}&su=false&dbg=false&emu=false&hk=false"
f"&it={self.app_start_ts}&ts={timestamp_ms}&rt=0&os=13&dm={self.DEVICE_MODEL}&st={self.OS_TYPE}&sv={self.SDK_VERSION}"
)
dyn_key = f"v1+{nonce}+{timestamp_ms}"
key_encoded = self.encode_normal_be(dyn_key, self.table)
table = self.make_encode_table(self.make_key(dyn_key), self.i9, self.table)
body_encoded = self.encode_normal_be(plaintext, table)
return f"bEeEP{self.table[len(key_encoded)]}{key_encoded}{body_encoded}"
class PatchedKorail(Korail):
_device = "AD"
_version = "250601002"
_sid_key = b"2485dd54d9deaa36"
_device_id = "558a4f02041657ea"
def __init__(self, korail_id: str, korail_pw: str, auto_login: bool = True, want_feedback: bool = False):
import requests
self._session = requests.session()
self._session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
self._engine = DynaPathMasterEngine()
super().__init__(korail_id, korail_pw, auto_login=False, want_feedback=want_feedback)
self._session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
if auto_login:
self.login(korail_id, korail_pw)
def _generate_sid(self, timestamp_ms: int) -> str:
ensure_runtime_dependencies()
plaintext = f"{self._device}{timestamp_ms}".encode("utf-8")
cipher = AES.new(self._sid_key, AES.MODE_CBC, iv=self._sid_key)
return base64.b64encode(cipher.encrypt(pad(plaintext, 16))).decode("utf-8") + "\n"
def _auth_headers_and_sid(self, url: str) -> tuple[dict[str, str], str | None]:
headers: dict[str, str] = {}
sid = None
if any(path in url for path in DYNAPATH_PATHS):
timestamp_ms = int(time.time() * 1000)
nonce = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
headers["x-dynapath-m-token"] = self._engine.generate_token(self._device_id, timestamp_ms, nonce)
sid = self._generate_sid(timestamp_ms)
return headers, sid
def login(self, korail_id: str | None = None, korail_pw: str | None = None) -> bool:
if korail_id is None:
korail_id = self.korail_id
else:
self.korail_id = korail_id
if korail_pw is None:
korail_pw = self.korail_pw
else:
self.korail_pw = korail_pw
if korail_mod.EMAIL_REGEX.match(korail_id):
input_flag = "5"
elif korail_mod.PHONE_NUMBER_REGEX.match(korail_id):
input_flag = "4"
else:
input_flag = "2"
headers, sid = self._auth_headers_and_sid(korail_mod.KORAIL_LOGIN)
payload = {
"Device": self._device,
"Version": self._version,
"txtInputFlg": input_flag,
"txtMemberNo": korail_id,
"txtPwd": self._Korail__enc_password(korail_pw),
"idx": self._idx,
}
if sid:
payload["Sid"] = sid
response = self._session.post(korail_mod.KORAIL_LOGIN, data=payload, headers=headers)
data = json.loads(response.text)
if data["strResult"] == "SUCC" and data.get("strMbCrdNo") is not None:
self._key = data["Key"]
self.membership_number = data["strMbCrdNo"]
self.name = data["strCustNm"]
self.email = data["strEmailAdr"]
self.logined = True
return True
self.logined = False
return False
def search_train(
self,
dep: str,
arr: str,
date: str | None = None,
time_value: str | None = None,
train_type: str = TrainType.ALL,
passengers: list[Passenger] | None = None,
include_no_seats: bool = False,
include_waiting_list: bool = False,
):
kst_now = korail_mod.datetime.now(korail_mod.timezone.utc) + korail_mod.timedelta(hours=9)
if date is None:
date = kst_now.strftime("%Y%m%d")
if time_value is None:
time_value = kst_now.strftime("%H%M%S")
if passengers is None:
passengers = [AdultPassenger()]
passengers = Passenger.reduce(passengers)
adult_count = reduce(lambda total, passenger: total + passenger.count, [p for p in passengers if isinstance(p, AdultPassenger)], 0)
child_count = reduce(lambda total, passenger: total + passenger.count, [p for p in passengers if isinstance(p, ChildPassenger)], 0)
toddler_count = reduce(
lambda total, passenger: total + passenger.count,
[p for p in passengers if isinstance(p, ToddlerPassenger)],
0,
)
senior_count = reduce(lambda total, passenger: total + passenger.count, [p for p in passengers if isinstance(p, SeniorPassenger)], 0)
headers, sid = self._auth_headers_and_sid(korail_mod.KORAIL_SEARCH_SCHEDULE)
payload = {
"Device": self._device,
"radJobId": "1",
"selGoTrain": train_type,
"txtCardPsgCnt": "0",
"txtGdNo": "",
"txtGoAbrdDt": date,
"txtGoEnd": arr,
"txtGoHour": time_value,
"txtGoStart": dep,
"txtJobDv": "",
"txtMenuId": "11",
"txtPsgFlg_1": adult_count,
"txtPsgFlg_2": child_count,
"txtPsgFlg_8": toddler_count,
"txtPsgFlg_3": senior_count,
"txtPsgFlg_4": "0",
"txtPsgFlg_5": "0",
"txtSeatAttCd_2": "000",
"txtSeatAttCd_3": "000",
"txtSeatAttCd_4": "015",
"txtTrnGpCd": train_type,
"Version": self._version,
}
if sid:
payload["Sid"] = sid
response = self._session.post(korail_mod.KORAIL_SEARCH_SCHEDULE, params=payload, headers=headers)
data = json.loads(response.text)
if self._result_check(data):
trains = [korail_mod.Train(info) for info in data["trn_infos"]["trn_info"]]
trains = [train for train in trains if train.dep_name == dep and train.arr_name == arr]
filters = [lambda train: train.has_seat()]
if include_no_seats:
filters.append(lambda train: not train.has_seat())
if include_waiting_list:
filters.append(lambda train: train.has_waiting_list())
trains = [train for train in trains if any(check(train) for check in filters)]
if not trains:
raise NoResultsError()
return trains
def reserve(self, train, passengers=None, option=ReserveOption.GENERAL_FIRST, try_waiting=False):
reserving_seat = True
try:
if not train.has_seat():
raise SoldOutError()
if option == ReserveOption.GENERAL_ONLY:
if train.has_general_seat():
seat_type = "1"
else:
raise SoldOutError()
elif option == ReserveOption.SPECIAL_ONLY:
if train.has_special_seat():
seat_type = "2"
else:
raise SoldOutError()
elif option == ReserveOption.GENERAL_FIRST:
seat_type = "1" if train.has_general_seat() else "2"
elif option == ReserveOption.SPECIAL_FIRST:
seat_type = "2" if train.has_special_seat() else "1"
else:
raise ValueError(f"unsupported reserve option: {option}")
except SoldOutError:
if try_waiting and option != ReserveOption.SPECIAL_ONLY and train.has_general_waiting_list():
reserving_seat = False
seat_type = "1"
else:
raise
if passengers is None:
passengers = [AdultPassenger()]
passengers = Passenger.reduce(passengers)
passenger_count = reduce(lambda total, passenger: total + passenger.count, passengers, 0)
headers, sid = self._auth_headers_and_sid(korail_mod.KORAIL_TICKETRESERVATION)
payload = {
"Device": self._device,
"Version": self._version,
"Key": self._key,
"txtGdNo": "",
"txtJobId": "1101" if reserving_seat else "1102",
"txtTotPsgCnt": passenger_count,
"txtSeatAttCd1": "000",
"txtSeatAttCd2": "000",
"txtSeatAttCd3": "000",
"txtSeatAttCd4": "015",
"txtSeatAttCd5": "000",
"hidFreeFlg": "N",
"txtStndFlg": "N",
"txtMenuId": "11",
"txtSrcarCnt": "0",
"txtJrnyCnt": "1",
"txtJrnySqno1": "001",
"txtJrnyTpCd1": "11",
"txtDptDt1": train.dep_date,
"txtDptRsStnCd1": train.dep_code,
"txtDptTm1": train.dep_time,
"txtArvRsStnCd1": train.arr_code,
"txtTrnNo1": train.train_no,
"txtRunDt1": train.run_date,
"txtTrnClsfCd1": train.train_type,
"txtPsrmClCd1": seat_type,
"txtTrnGpCd1": train.train_group,
"txtChgFlg1": "",
"txtJrnySqno2": "",
"txtJrnyTpCd2": "",
"txtDptDt2": "",
"txtDptRsStnCd2": "",
"txtDptTm2": "",
"txtArvRsStnCd2": "",
"txtTrnNo2": "",
"txtRunDt2": "",
"txtTrnClsfCd2": "",
"txtPsrmClCd2": "",
"txtChgFlg2": "",
}
if sid:
payload["Sid"] = sid
for index, passenger in enumerate(passengers, start=1):
payload.update(passenger.get_dict(index))
response = self._session.get(korail_mod.KORAIL_TICKETRESERVATION, params=payload, headers=headers)
data = json.loads(response.text)
if self._result_check(data):
reservation_id = data["h_pnr_no"]
matches = [reservation for reservation in self.reservations() if reservation.rsv_id == reservation_id]
if len(matches) == 1:
return matches[0]
raise KorailError(f"reservation {reservation_id} was created but could not be reloaded")
def reservations(self):
payload = {"Device": self._device, "Version": self._version, "Key": self._key}
response = self._session.get(korail_mod.KORAIL_MYRESERVATIONLIST, params=payload)
data = json.loads(response.text)
try:
if self._result_check(data):
return [
korail_mod.Reservation(train_info)
for journey in data["jrny_infos"]["jrny_info"]
for train_info in journey["train_infos"]["train_info"]
]
except NoResultsError:
return []
return []
def cancel(self, reservation):
assert isinstance(reservation, korail_mod.Reservation)
payload = {
"Device": self._device,
"Version": self._version,
"Key": self._key,
"txtPnrNo": reservation.rsv_id,
"txtJrnySqno": reservation.journey_no,
"txtJrnyCnt": reservation.journey_cnt,
"hidRsvChgNo": reservation.rsv_chg_no,
}
response = self._session.get(korail_mod.KORAIL_CANCEL, params=payload)
data = json.loads(response.text)
if self._result_check(data):
return True
return False
def parse_passengers(args: argparse.Namespace) -> list[Passenger]:
passengers: list[Passenger] = []
if args.adults:
passengers.append(AdultPassenger(args.adults))
if args.children:
passengers.append(ChildPassenger(args.children))
if args.toddlers:
passengers.append(ToddlerPassenger(args.toddlers))
if args.seniors:
passengers.append(SeniorPassenger(args.seniors))
if not passengers:
passengers.append(AdultPassenger())
return passengers
def build_train_id_payload(train) -> dict[str, str]:
return {field: getattr(train, field) for field in TRAIN_ID_FIELDS}
def build_train_id(train) -> str:
payload = json.dumps(build_train_id_payload(train), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
return f"{TRAIN_ID_PREFIX}{encoded}"
def parse_train_id(train_id: str) -> dict[str, str]:
if not train_id.startswith(TRAIN_ID_PREFIX):
raise SystemExit("train_id must start with ktx:v1:")
encoded = train_id.removeprefix(TRAIN_ID_PREFIX)
padded = encoded + ("=" * ((4 - len(encoded) % 4) % 4))
try:
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
raise SystemExit(TRAIN_ID_INVALID_MESSAGE) from exc
if not isinstance(payload, dict):
raise SystemExit(TRAIN_ID_INVALID_MESSAGE)
invalid_fields = [field for field in TRAIN_ID_FIELDS if not isinstance(payload.get(field), str) or not payload[field]]
if invalid_fields:
raise SystemExit(TRAIN_ID_INVALID_MESSAGE)
return {field: payload[field] for field in TRAIN_ID_FIELDS}
def find_train_by_id(trains, train_id: str):
expected = parse_train_id(train_id)
for train in trains:
if build_train_id_payload(train) == expected:
return train
return None
def normalize_train(train, index: int) -> dict[str, object]:
return {
"index": index,
"train_id": build_train_id(train),
"train_no": train.train_no,
"train_type": train.train_type_name,
"dep_name": train.dep_name,
"dep_date": train.dep_date,
"dep_time": train.dep_time,
"arr_name": train.arr_name,
"arr_date": train.arr_date,
"arr_time": train.arr_time,
"has_general_seat": train.has_general_seat(),
"has_special_seat": train.has_special_seat(),
"has_waiting_list": train.has_waiting_list(),
"description": str(train),
}
def normalize_reservation(reservation) -> dict[str, object]:
return {
"reservation_id": reservation.rsv_id,
"train_no": reservation.train_no,
"train_type": reservation.train_type_name,
"dep_name": reservation.dep_name,
"dep_date": reservation.dep_date,
"dep_time": reservation.dep_time,
"arr_name": reservation.arr_name,
"arr_date": reservation.arr_date,
"arr_time": reservation.arr_time,
"seat_count": reservation.seat_no_count,
"price": reservation.price,
"buy_limit_date": reservation.buy_limit_date,
"buy_limit_time": reservation.buy_limit_time,
"journey_no": reservation.journey_no,
"journey_cnt": reservation.journey_cnt,
"rsv_chg_no": reservation.rsv_chg_no,
"description": str(reservation),
}
def print_json(payload: dict[str, object]) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2))
def build_client() -> PatchedKorail:
ensure_runtime_dependencies()
korail_id = os.environ.get("KSKILL_KTX_ID")
korail_pw = os.environ.get("KSKILL_KTX_PASSWORD")
if not korail_id or not korail_pw:
raise SystemExit(
"이 작업에는 KSKILL_KTX_ID, KSKILL_KTX_PASSWORD 가 필요합니다. "
"값을 채팅창에 붙여 넣지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤 "
"sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요."
)
client = PatchedKorail(korail_id, korail_pw)
if not client.logined:
raise NeedToLoginError()
return client
def command_search(args: argparse.Namespace) -> None:
client = build_client()
passengers = parse_passengers(args)
trains = client.search_train(
args.dep,
args.arr,
args.date,
args.time,
train_type=TrainType.KTX,
passengers=passengers,
include_no_seats=args.include_no_seats,
include_waiting_list=args.include_waiting_list,
)
visible_trains = trains[: args.limit]
print_json({
"count": len(visible_trains),
"trains": [normalize_train(train, index) for index, train in enumerate(visible_trains, start=1)],
})
def command_reserve(args: argparse.Namespace) -> None:
client = build_client()
passengers = parse_passengers(args)
include_waiting_list = args.include_waiting_list or args.try_waiting
trains = client.search_train(
args.dep,
args.arr,
args.date,
args.time,
train_type=TrainType.KTX,
passengers=passengers,
include_no_seats=args.include_no_seats,
include_waiting_list=include_waiting_list,
)
selected_train = find_train_by_id(trains, args.train_id)
if selected_train is None:
raise SystemExit(TRAIN_ID_STALE_MESSAGE)
reservation = client.reserve(
selected_train,
passengers=passengers,
option=RESERVE_OPTION_MAP[args.seat_option],
try_waiting=args.try_waiting,
)
print_json({"reservation": normalize_reservation(reservation)})
def command_reservations(_: argparse.Namespace) -> None:
client = build_client()
reservations = client.reservations()
print_json({
"count": len(reservations),
"reservations": [normalize_reservation(reservation) for reservation in reservations],
})
def command_cancel(args: argparse.Namespace) -> None:
client = build_client()
reservations = client.reservations()
match = next((reservation for reservation in reservations if reservation.rsv_id == args.reservation_id), None)
if match is None:
raise SystemExit(f"reservation {args.reservation_id} not found")
client.cancel(match)
print_json({"cancelled": True, "reservation_id": args.reservation_id})
def add_common_trip_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("dep", help="출발역")
parser.add_argument("arr", help="도착역")
parser.add_argument("date", help="출발일 YYYYMMDD")
parser.add_argument("time", help="희망 시작 시각 HHMMSS")
parser.add_argument("--adults", type=int, default=1, help="성인 수")
parser.add_argument("--children", type=int, default=0, help="어린이 수")
parser.add_argument("--toddlers", type=int, default=0, help="유아 수")
parser.add_argument("--seniors", type=int, default=0, help="경로 수")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Patched KTX/Korail booking helper for k-skill")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="KTX 열차를 조회합니다")
add_common_trip_args(search_parser)
search_parser.add_argument("--limit", type=int, default=5, help="출력할 최대 열차 수")
search_parser.add_argument("--include-no-seats", action="store_true", help="매진 열차도 포함")
search_parser.add_argument("--include-waiting-list", action="store_true", help="예약 대기 가능 열차도 포함")
search_parser.set_defaults(func=command_search)
reserve_parser = subparsers.add_parser("reserve", help="조회 결과 중 하나를 예약합니다")
add_common_trip_args(reserve_parser)
reserve_parser.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")
reserve_parser.add_argument("--seat-option", choices=sorted(RESERVE_OPTION_MAP), default="general-first")
reserve_parser.add_argument("--include-no-seats", action="store_true", help="검색 시 매진 열차도 포함")
reserve_parser.add_argument("--include-waiting-list", action="store_true", help="검색 시 예약대기 열차도 포함")
reserve_parser.add_argument(
"--try-waiting",
action="store_true",
help="좌석이 없으면 예약대기를 시도 (reserve 재조회 시 예약대기 열차 자동 포함)",
)
reserve_parser.set_defaults(func=command_reserve)
reservations_parser = subparsers.add_parser("reservations", help="현재 예약 목록을 조회합니다")
reservations_parser.set_defaults(func=command_reservations)
cancel_parser = subparsers.add_parser("cancel", help="예약번호로 예약을 취소합니다")
cancel_parser.add_argument("reservation_id", help="취소할 예약번호")
cancel_parser.set_defaults(func=command_cancel)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except (KorailError, NeedToLoginError, NoResultsError, SoldOutError) as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())