@nomadamas/k-skill 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1316 +1,218 @@
1
- #!/usr/bin/env python3
1
+ #!/usr/bin/env -S uv run --locked --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["openpyxl==3.1.5"]
5
+ # ///
6
+ """Read official Korail timetable files without login or reservation actions."""
7
+
2
8
  from __future__ import annotations
3
9
 
4
10
  import argparse
5
- import base64
6
11
  import json
7
- import os
8
- import random
9
12
  import re
10
- import string
11
13
  import sys
12
- import time
13
- from functools import reduce
14
-
15
- try:
16
- from Crypto.Cipher import AES
17
- from Crypto.Util.Padding import pad
18
- except ModuleNotFoundError as exc:
19
- AES = None
20
- pad = None
21
- _CRYPTO_IMPORT_ERROR = exc
22
- else:
23
- _CRYPTO_IMPORT_ERROR = None
24
-
25
- try:
26
- from korail2 import (
27
- AdultPassenger,
28
- ChildPassenger,
29
- Korail,
30
- KorailError,
31
- NeedToLoginError,
32
- NoResultsError,
33
- Passenger,
34
- ReserveOption,
35
- SeniorPassenger,
36
- SoldOutError,
37
- ToddlerPassenger,
38
- TrainType,
39
- )
40
- import korail2.korail2 as korail_mod
41
- except ModuleNotFoundError as exc:
42
- _KORAIL_IMPORT_ERROR = exc
43
-
44
- class KorailError(Exception):
45
- pass
46
-
47
- class NeedToLoginError(KorailError):
48
- pass
49
-
50
- class NoResultsError(KorailError):
51
- pass
52
-
53
- class SoldOutError(KorailError):
54
- pass
55
-
56
- class Passenger:
57
- def __init__(self, count: int = 1):
58
- self.count = count
59
-
60
- @staticmethod
61
- def reduce(passengers):
62
- return passengers
63
-
64
- def get_dict(self, _: int) -> dict[str, str]:
65
- return {}
66
-
67
- class AdultPassenger(Passenger):
68
- pass
69
-
70
- class ChildPassenger(Passenger):
71
- pass
72
-
73
- class ToddlerPassenger(Passenger):
74
- pass
75
-
76
- class SeniorPassenger(Passenger):
77
- pass
78
-
79
- class ReserveOption:
80
- GENERAL_FIRST = "GENERAL_FIRST"
81
- GENERAL_ONLY = "GENERAL_ONLY"
82
- SPECIAL_FIRST = "SPECIAL_FIRST"
83
- SPECIAL_ONLY = "SPECIAL_ONLY"
84
-
85
- class TrainType:
86
- # Fallback constants used only when korail2 is missing so module
87
- # import succeeds and ensure_runtime_dependencies() can surface
88
- # the install message. Values mirror upstream korail2.TrainType.
89
- KTX = "100"
90
- KTX_SANCHEON = "100"
91
- ITX_SAEMAEUL = "101"
92
- SAEMAEUL = "101"
93
- MUGUNGHWA = "102"
94
- NURIRO = "102"
95
- TONGGUEN = "103"
96
- ITX_CHEONGCHUN = "104"
97
- AIRPORT = "105"
98
- ALL = "109"
99
-
100
- class Korail:
101
- def __init__(self, *args, **kwargs):
102
- raise ModuleNotFoundError("korail2")
103
-
104
- class _FallbackKorailModule:
105
- EMAIL_REGEX = re.compile(r".+@.+")
106
- PHONE_NUMBER_REGEX = re.compile(r"(\d{3})-(\d{3,4})-(\d{4})")
107
-
108
- korail_mod = _FallbackKorailModule()
109
- else:
110
- _KORAIL_IMPORT_ERROR = None
111
-
112
- try:
113
- from korail2 import NCardPassenger
114
- _NCARD_AVAILABLE = True
115
- except ImportError:
116
- _NCARD_AVAILABLE = False
117
-
118
- class NCardPassenger(AdultPassenger):
119
- def __init__(self, count=1, card_no='', card='', card_pw='', discount_type='153'):
120
- AdultPassenger.__init__(self, count)
121
- self.card_no = card_no
122
- self.card = card
123
- self.card_pw = card_pw
124
- self.discount_type = discount_type
125
-
126
- DEFAULT_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; SM-S928N Build/UP1A.231005.007)"
127
- DYNAPATH_PATHS = [
128
- "/classes/com.korail.mobile.certification.TicketReservation",
129
- "/classes/com.korail.mobile.nonMember.NonMemTicket",
130
- "/classes/com.korail.mobile.research.TrainResearch",
131
- "/classes/com.korail.mobile.research.ResidualSeatsResearch.do",
132
- "/classes/com.korail.mobile.seatMovie.ScheduleView",
133
- "/classes/com.korail.mobile.seatMovie.ScheduleViewSpecial",
134
- "/classes/com.korail.mobile.trn.prcFare.do",
135
- "/classes/com.korail.mobile.login.Login",
136
- ]
137
- KORAIL_CARS_INFO = "https://smart.letskorail.com:443/classes/com.korail.mobile.research.TrainResearch"
138
- KORAIL_CAR_DETAIL = "https://smart.letskorail.com:443/classes/com.korail.mobile.research.ResidualSeatsResearch.do"
139
- RESERVE_OPTION_MAP = {
140
- "general-first": ReserveOption.GENERAL_FIRST,
141
- "general-only": ReserveOption.GENERAL_ONLY,
142
- "special-first": ReserveOption.SPECIAL_FIRST,
143
- "special-only": ReserveOption.SPECIAL_ONLY,
144
- }
145
- TRAIN_TYPE_MAP = {
146
- "ktx": TrainType.KTX, # 100 — KTX/KTX-산천
147
- "itx-saemaeul": TrainType.ITX_SAEMAEUL, # 101 — ITX-새마을
148
- "mugunghwa": TrainType.MUGUNGHWA, # 102 — 무궁화호
149
- "nuriro": TrainType.NURIRO, # 102 — 누리로
150
- "tonggeun": TrainType.TONGGUEN, # 103 — 통근열차
151
- "itx-cheongchun": TrainType.ITX_CHEONGCHUN, # 104 — ITX-청춘
152
- "airport": TrainType.AIRPORT, # 105 — 공항직통
153
- "all": TrainType.ALL, # 109 — 전체
154
- }
155
- TRAIN_ID_PREFIX = "ktx:v1:"
156
- TRAIN_ID_INVALID_MESSAGE = "train_id is invalid; rerun search and copy a fresh train_id"
157
- TRAIN_ID_STALE_MESSAGE = "train_id no longer matches any current search result; rerun search and choose a fresh train_id"
158
- TRAIN_ID_FIELDS = (
159
- "train_no",
160
- "dep_date",
161
- "dep_time",
162
- "arr_date",
163
- "arr_time",
164
- "run_date",
165
- "train_group",
166
- "dep_code",
167
- "arr_code",
14
+ from dataclasses import asdict, dataclass
15
+ from datetime import date as calendar_date
16
+ from datetime import time
17
+ from io import BytesIO
18
+ from typing import Any
19
+ from urllib.error import HTTPError, URLError
20
+ from urllib.request import Request, urlopen
21
+
22
+ from ktx_timetable import parse_timetable_rows
23
+ from openpyxl import load_workbook
24
+
25
+ BOARD_URL = (
26
+ "https://www.korail.com/com/userBoard.do"
27
+ "?schBcid=ticketTable&mode=list&page=1&schStr=KTX&bdCode=&Device=BH&Version=999999999"
168
28
  )
169
-
170
- PHONE_NUMBER_DIGITS_REGEX = re.compile(r"^01\d{8,9}$")
171
- ROOM_CLASS_MAP = {
172
- "general": "1",
173
- "special": "2",
174
- }
175
- ROOM_CLASS_NAME = {
176
- "1": "일반실",
177
- "2": "특실",
178
- }
179
- SEAT_DIRECTION_NAME = {
180
- "009": "순방향",
181
- "010": "역방향",
182
- }
183
- SEAT_POSITION_NAME = {
184
- "011": "1인",
185
- "012": "창측",
186
- "013": "내측",
187
- }
188
- SEAT_TYPE_NAME = {
189
- "015": "일반석",
190
- "018": "2층석",
191
- "019": "유아동반석",
192
- "021": "휠체어석",
193
- "023": "4인동반석",
194
- "027": "4인석",
195
- "028": "전동휠체어석",
196
- "032": "자전거",
197
- "052": "대피도우미",
198
- }
199
- POWER_OUTLET_ROWS = {1, 3, 5, 7, 10, 12, 14, 15}
200
- POWER_OUTLET_DIRECT_COLUMNS = {"A", "D"}
201
- POWER_OUTLET_ADJACENT_COLUMNS = {"B", "C"}
202
-
203
-
204
- def is_phone_login_id(korail_id: str) -> bool:
205
- return bool(korail_mod.PHONE_NUMBER_REGEX.fullmatch(korail_id) or PHONE_NUMBER_DIGITS_REGEX.fullmatch(korail_id))
206
-
207
-
208
- def ensure_runtime_dependencies() -> None:
209
- missing: list[str] = []
210
- if _KORAIL_IMPORT_ERROR is not None:
211
- missing.append("korail2")
212
- if _CRYPTO_IMPORT_ERROR is not None:
213
- missing.append("pycryptodome")
214
- if missing:
215
- install_command = f"python3 -m pip install {' '.join(missing)}"
216
- raise SystemExit(
217
- "scripts/ktx_booking.py requires additional Python packages "
218
- f"({', '.join(missing)}). Install them before running this helper: {install_command}"
219
- )
220
-
221
-
222
- class DynaPathMasterEngine:
223
- APP_ID = "com.korail.talk"
224
- AS_VALUE = "%5B38ff229cb34c7dda8e28220a2d750cce%5D"
225
- DEVICE_MODEL = "SM-S928N"
226
- OS_TYPE = "Android"
227
- SDK_VERSION = "v1"
228
-
229
- def __init__(self) -> None:
230
- self.table = "3FE9jgRD4KdCyuawklqGJYmvfMn15P7US8XbxeLQtWT6OicBAopINs2Vh0HZrz"
231
- self.i8 = 161
232
- self.i9 = 30
233
- self.i10 = 2
234
- self.app_start_ts = str(int(time.time() * 1000))
235
-
236
- def string2xa1s(self, data: str) -> list[int]:
237
- result: list[int] = []
238
- idx = 0
239
- while idx < len(data):
240
- codepoint = ord(data[idx])
241
- idx += 1
242
- if codepoint < 128:
243
- result.append(codepoint)
244
- elif codepoint < 2048:
245
- result.append(128 | ((codepoint >> 7) & 15))
246
- result.append(codepoint & 127)
247
- elif codepoint >= 262144:
248
- result.append(160)
249
- result.append((codepoint >> 14) & 127)
250
- result.append((codepoint >> 7) & 127)
251
- result.append(codepoint & 127)
252
- elif (63488 & codepoint) != 55296:
253
- result.append(((codepoint >> 14) & 15) | 144)
254
- result.append((codepoint >> 7) & 127)
255
- result.append(codepoint & 127)
256
- return result
257
-
258
- def make_key(self, key: str) -> int:
259
- total = 0
260
- for char in key:
261
- codepoint = ord(char)
262
- bit = 32768
263
- for _ in range(16):
264
- if bit & codepoint:
265
- break
266
- bit >>= 1
267
- total = (total * (bit << 1)) + codepoint
268
- return total
269
-
270
- def internal_char(self, base_table: str, remainder: int, current: str) -> str:
271
- seen = 0
272
- for char in base_table:
273
- if char in current:
274
- continue
275
- if seen == remainder:
276
- return char
277
- seen += 1
278
- return " "
279
-
280
- def make_encode_table(self, number: int, encode_size: int, base_table: str) -> str:
281
- chars = ""
282
- temp = number
283
- for index in range(encode_size):
284
- divisor = encode_size - index
285
- remainder = temp % divisor
286
- chars += self.internal_char(base_table, remainder, chars)
287
- temp //= divisor
288
- return chars
289
-
290
- def encode_normal_be(self, data: str, table: str) -> str:
291
- values = self.string2xa1s(data)
292
- output: list[str] = []
293
- digits = [0] * (self.i10 + 1)
294
- idx = 0
295
- tail = len(values) % self.i10
296
- body_size = len(values) - tail
297
- while idx < body_size:
298
- value = 0
299
- for _ in range(self.i10):
300
- value = (value * self.i8) + values[idx]
301
- idx += 1
302
- for digit_index in range(self.i10 + 1):
303
- digits[digit_index] = value % self.i9
304
- value //= self.i9
305
- for digit_index in range(self.i10, -1, -1):
306
- output.append(table[digits[digit_index]])
307
- if tail > 0:
308
- value = 0
309
- for _ in range(tail):
310
- value = (value * self.i8) + values[idx]
311
- idx += 1
312
- for digit_index in range(tail + 1):
313
- digits[digit_index] = value % self.i9
314
- value //= self.i9
315
- while tail >= 0:
316
- output.append(table[digits[tail]])
317
- tail -= 1
318
- return "".join(output)
319
-
320
- def generate_token(self, device_id: str, timestamp_ms: int, nonce: str) -> str:
321
- plaintext = (
322
- f"ai={self.APP_ID}&di={device_id}&as={self.AS_VALUE}&su=false&dbg=false&emu=false&hk=false"
323
- 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}"
324
- )
325
- dyn_key = f"v1+{nonce}+{timestamp_ms}"
326
- key_encoded = self.encode_normal_be(dyn_key, self.table)
327
- table = self.make_encode_table(self.make_key(dyn_key), self.i9, self.table)
328
- body_encoded = self.encode_normal_be(plaintext, table)
329
- return f"bEeEP{self.table[len(key_encoded)]}{key_encoded}{body_encoded}"
330
-
331
-
332
- class PatchedKorail(Korail):
333
- _device = "AD"
334
- _version = "250601002"
335
- _sid_key = b"2485dd54d9deaa36"
336
- _device_id = "558a4f02041657ea"
337
-
338
- def __init__(self, korail_id: str, korail_pw: str, auto_login: bool = True, want_feedback: bool = False):
339
- import requests
340
-
341
- self._session = requests.session()
342
- self._session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
343
- self._engine = DynaPathMasterEngine()
344
- super().__init__(korail_id, korail_pw, auto_login=False, want_feedback=want_feedback)
345
- self._session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
346
- if auto_login:
347
- self.login(korail_id, korail_pw)
348
-
349
- def _generate_sid(self, timestamp_ms: int) -> str:
350
- ensure_runtime_dependencies()
351
- plaintext = f"{self._device}{timestamp_ms}".encode("utf-8")
352
- cipher = AES.new(self._sid_key, AES.MODE_CBC, iv=self._sid_key)
353
- return base64.b64encode(cipher.encrypt(pad(plaintext, 16))).decode("utf-8") + "\n"
354
-
355
- def _auth_headers_and_sid(self, url: str) -> tuple[dict[str, str], str | None]:
356
- headers: dict[str, str] = {}
357
- sid = None
358
- if any(path in url for path in DYNAPATH_PATHS):
359
- timestamp_ms = int(time.time() * 1000)
360
- nonce = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
361
- headers["x-dynapath-m-token"] = self._engine.generate_token(self._device_id, timestamp_ms, nonce)
362
- sid = self._generate_sid(timestamp_ms)
363
- return headers, sid
364
-
365
- def login(self, korail_id: str | None = None, korail_pw: str | None = None) -> bool:
366
- if korail_id is None:
367
- korail_id = self.korail_id
368
- else:
369
- self.korail_id = korail_id
370
-
371
- if korail_pw is None:
372
- korail_pw = self.korail_pw
373
- else:
374
- self.korail_pw = korail_pw
375
-
376
- if korail_mod.EMAIL_REGEX.match(korail_id):
377
- input_flag = "5"
378
- elif is_phone_login_id(korail_id):
379
- input_flag = "4"
380
- else:
381
- input_flag = "2"
382
-
383
- headers, sid = self._auth_headers_and_sid(korail_mod.KORAIL_LOGIN)
384
- payload = {
385
- "Device": self._device,
386
- "Version": self._version,
387
- "txtInputFlg": input_flag,
388
- "txtMemberNo": korail_id,
389
- "txtPwd": self._Korail__enc_password(korail_pw),
390
- "idx": self._idx,
391
- }
392
- if sid:
393
- payload["Sid"] = sid
394
-
395
- response = self._session.post(korail_mod.KORAIL_LOGIN, data=payload, headers=headers)
396
- data = json.loads(response.text)
397
- if data["strResult"] == "SUCC" and data.get("strMbCrdNo") is not None:
398
- self._key = data["Key"]
399
- self.membership_number = data["strMbCrdNo"]
400
- self.name = data["strCustNm"]
401
- self.email = data["strEmailAdr"]
402
- self.logined = True
403
- return True
404
-
405
- self.logined = False
406
- return False
407
-
408
- def search_train_details(
409
- self,
410
- dep: str,
411
- arr: str,
412
- date: str | None = None,
413
- time_value: str | None = None,
414
- train_type: str = TrainType.ALL,
415
- passengers: list[Passenger] | None = None,
416
- include_no_seats: bool = False,
417
- include_waiting_list: bool = False,
418
- ):
419
- kst_now = korail_mod.datetime.now(korail_mod.timezone.utc) + korail_mod.timedelta(hours=9)
420
- if date is None:
421
- date = kst_now.strftime("%Y%m%d")
422
- if time_value is None:
423
- time_value = kst_now.strftime("%H%M%S")
424
- if passengers is None:
425
- passengers = [AdultPassenger()]
426
-
427
- passengers = Passenger.reduce(passengers)
428
- adult_count = reduce(lambda total, passenger: total + passenger.count, [p for p in passengers if isinstance(p, AdultPassenger)], 0)
429
- child_count = reduce(lambda total, passenger: total + passenger.count, [p for p in passengers if isinstance(p, ChildPassenger)], 0)
430
- toddler_count = reduce(
431
- lambda total, passenger: total + passenger.count,
432
- [p for p in passengers if isinstance(p, ToddlerPassenger)],
433
- 0,
434
- )
435
- senior_count = reduce(lambda total, passenger: total + passenger.count, [p for p in passengers if isinstance(p, SeniorPassenger)], 0)
436
-
437
- headers, sid = self._auth_headers_and_sid(korail_mod.KORAIL_SEARCH_SCHEDULE)
438
- payload = {
439
- "Device": self._device,
440
- "radJobId": "1",
441
- "selGoTrain": train_type,
442
- "txtCardPsgCnt": "0",
443
- "txtGdNo": "",
444
- "txtGoAbrdDt": date,
445
- "txtGoEnd": arr,
446
- "txtGoHour": time_value,
447
- "txtGoStart": dep,
448
- "txtJobDv": "",
449
- "txtMenuId": "11",
450
- "txtPsgFlg_1": adult_count,
451
- "txtPsgFlg_2": child_count,
452
- "txtPsgFlg_8": toddler_count,
453
- "txtPsgFlg_3": senior_count,
454
- "txtPsgFlg_4": "0",
455
- "txtPsgFlg_5": "0",
456
- "txtSeatAttCd_2": "000",
457
- "txtSeatAttCd_3": "000",
458
- "txtSeatAttCd_4": "015",
459
- "txtTrnGpCd": train_type,
460
- "Version": self._version,
461
- }
462
- if sid:
463
- payload["Sid"] = sid
464
-
465
- response = self._session.post(korail_mod.KORAIL_SEARCH_SCHEDULE, params=payload, headers=headers)
466
- data = json.loads(response.text)
467
- if self._result_check(data):
468
- train_infos = data["trn_infos"]["trn_info"]
469
- if isinstance(train_infos, dict):
470
- train_infos = [train_infos]
471
- details = [(korail_mod.Train(info), info) for info in train_infos]
472
- details = [(train, info) for train, info in details if train.dep_name == dep and train.arr_name == arr]
473
- filters = [lambda train: train.has_seat()]
474
- if include_no_seats:
475
- filters.append(lambda train: not train.has_seat())
476
- if include_waiting_list:
477
- filters.append(lambda train: train.has_waiting_list())
478
- details = [(train, info) for train, info in details if any(check(train) for check in filters)]
479
- if not details:
480
- raise NoResultsError()
481
- return details
482
-
483
- def search_train(
484
- self,
485
- dep: str,
486
- arr: str,
487
- date: str | None = None,
488
- time_value: str | None = None,
489
- train_type: str = TrainType.ALL,
490
- passengers: list[Passenger] | None = None,
491
- include_no_seats: bool = False,
492
- include_waiting_list: bool = False,
493
- ):
494
- return [
495
- train
496
- for train, _ in self.search_train_details(
497
- dep,
498
- arr,
499
- date,
500
- time_value,
501
- train_type=train_type,
502
- passengers=passengers,
503
- include_no_seats=include_no_seats,
504
- include_waiting_list=include_waiting_list,
505
- )
506
- ]
507
-
508
- def train_cars(self, raw_train: dict[str, object], passenger_count: int = 1, room_class: str = "1") -> list[dict[str, object]]:
509
- payload = self._seat_lookup_payload(raw_train, passenger_count, room_class)
510
- headers, sid = self._auth_headers_and_sid(KORAIL_CARS_INFO)
511
- if sid:
512
- payload["Sid"] = sid
513
- response = self._session.post(KORAIL_CARS_INFO, data=payload, headers=headers)
514
- data = json.loads(response.text)
515
- if self._result_check(data):
516
- cars = data.get("srcar_infos", {}).get("srcar_info", [])
517
- if isinstance(cars, dict):
518
- cars = [cars]
519
- return cars
520
- return []
521
-
522
- def car_seats(
523
- self,
524
- raw_train: dict[str, object],
525
- car_no: str,
526
- passenger_count: int = 1,
527
- room_class: str = "1",
528
- ) -> dict[str, object]:
529
- payload = self._seat_lookup_payload(raw_train, passenger_count, room_class)
530
- payload["txtSrcarNo"] = car_no
531
- headers, sid = self._auth_headers_and_sid(KORAIL_CAR_DETAIL)
532
- if sid:
533
- payload["Sid"] = sid
534
- response = self._session.post(KORAIL_CAR_DETAIL, data=payload, headers=headers)
535
- data = json.loads(response.text)
536
- if self._result_check(data):
537
- return data
538
- return {}
539
-
540
- def _seat_lookup_payload(self, raw_train: dict[str, object], passenger_count: int, room_class: str) -> dict[str, object]:
541
- return {
542
- "Device": self._device,
543
- "Version": self._version,
544
- "Key": self._key,
545
- "txtArvRsStnCd": raw_train.get("h_arv_rs_stn_cd", ""),
546
- "txtArvStnRunOrdr": raw_train.get("h_arv_stn_run_ordr", ""),
547
- "txtDptDt": raw_train.get("h_dpt_dt", ""),
548
- "txtDptRsStnCd": raw_train.get("h_dpt_rs_stn_cd", ""),
549
- "txtDptStnRunOrdr": raw_train.get("h_dpt_stn_run_ordr", ""),
550
- "txtGdNo": "",
551
- "txtMenuId": "11",
552
- "txtPsrmClCd": room_class,
553
- "txtRunDt": raw_train.get("h_run_dt", ""),
554
- "txtSeatAttCd": "015",
555
- "txtTotPsgCnt": str(passenger_count),
556
- "txtTrnClsfCd": raw_train.get("h_trn_clsf_cd", ""),
557
- "txtTrnGpCd": raw_train.get("h_trn_gp_cd", ""),
558
- "txtTrnNo": raw_train.get("h_trn_no", ""),
559
- }
560
-
561
- def reserve(self, train, passengers=None, option=ReserveOption.GENERAL_FIRST, try_waiting=False):
562
- reserving_seat = True
563
- try:
564
- if not train.has_seat():
565
- raise SoldOutError()
566
- if option == ReserveOption.GENERAL_ONLY:
567
- if train.has_general_seat():
568
- seat_type = "1"
569
- else:
570
- raise SoldOutError()
571
- elif option == ReserveOption.SPECIAL_ONLY:
572
- if train.has_special_seat():
573
- seat_type = "2"
574
- else:
575
- raise SoldOutError()
576
- elif option == ReserveOption.GENERAL_FIRST:
577
- seat_type = "1" if train.has_general_seat() else "2"
578
- elif option == ReserveOption.SPECIAL_FIRST:
579
- seat_type = "2" if train.has_special_seat() else "1"
580
- else:
581
- raise ValueError(f"unsupported reserve option: {option}")
582
- except SoldOutError:
583
- if try_waiting and option != ReserveOption.SPECIAL_ONLY and train.has_general_waiting_list():
584
- reserving_seat = False
585
- seat_type = "1"
586
- else:
587
- raise
588
-
589
- if passengers is None:
590
- passengers = [AdultPassenger()]
591
-
592
- passengers = Passenger.reduce(passengers)
593
- passenger_count = reduce(lambda total, passenger: total + passenger.count, passengers, 0)
594
- headers, sid = self._auth_headers_and_sid(korail_mod.KORAIL_TICKETRESERVATION)
595
- payload = {
596
- "Device": self._device,
597
- "Version": self._version,
598
- "Key": self._key,
599
- "txtGdNo": "",
600
- "txtJobId": "1101" if reserving_seat else "1102",
601
- "txtTotPsgCnt": passenger_count,
602
- "txtSeatAttCd1": "000",
603
- "txtSeatAttCd2": "000",
604
- "txtSeatAttCd3": "000",
605
- "txtSeatAttCd4": "015",
606
- "txtSeatAttCd5": "000",
607
- "hidFreeFlg": "N",
608
- "txtStndFlg": "N",
609
- "txtMenuId": "11",
610
- "txtSrcarCnt": "0",
611
- "txtJrnyCnt": "1",
612
- "txtJrnySqno1": "001",
613
- "txtJrnyTpCd1": "11",
614
- "txtDptDt1": train.dep_date,
615
- "txtDptRsStnCd1": train.dep_code,
616
- "txtDptTm1": train.dep_time,
617
- "txtArvRsStnCd1": train.arr_code,
618
- "txtTrnNo1": train.train_no,
619
- "txtRunDt1": train.run_date,
620
- "txtTrnClsfCd1": train.train_type,
621
- "txtPsrmClCd1": seat_type,
622
- "txtTrnGpCd1": train.train_group,
623
- "txtChgFlg1": "",
624
- "txtJrnySqno2": "",
625
- "txtJrnyTpCd2": "",
626
- "txtDptDt2": "",
627
- "txtDptRsStnCd2": "",
628
- "txtDptTm2": "",
629
- "txtArvRsStnCd2": "",
630
- "txtTrnNo2": "",
631
- "txtRunDt2": "",
632
- "txtTrnClsfCd2": "",
633
- "txtPsrmClCd2": "",
634
- "txtChgFlg2": "",
635
- }
636
- if sid:
637
- payload["Sid"] = sid
638
-
639
- for index, passenger in enumerate(passengers, start=1):
640
- payload.update(passenger.get_dict(index))
641
-
642
- response = self._session.get(korail_mod.KORAIL_TICKETRESERVATION, params=payload, headers=headers)
643
- data = json.loads(response.text)
644
- if self._result_check(data):
645
- reservation_id = data["h_pnr_no"]
646
- matches = [reservation for reservation in self.reservations() if reservation.rsv_id == reservation_id]
647
- if len(matches) == 1:
648
- return matches[0]
649
- raise KorailError(f"reservation {reservation_id} was created but could not be reloaded")
650
-
651
- def reservations(self):
652
- payload = {"Device": self._device, "Version": self._version, "Key": self._key}
653
- response = self._session.get(korail_mod.KORAIL_MYRESERVATIONLIST, params=payload)
654
- data = json.loads(response.text)
655
- try:
656
- if self._result_check(data):
657
- return [
658
- korail_mod.Reservation(train_info)
659
- for journey in data["jrny_infos"]["jrny_info"]
660
- for train_info in journey["train_infos"]["train_info"]
661
- ]
662
- except NoResultsError:
663
- return []
664
- return []
665
-
666
- def cancel(self, reservation):
667
- assert isinstance(reservation, korail_mod.Reservation)
668
- payload = {
669
- "Device": self._device,
670
- "Version": self._version,
671
- "Key": self._key,
672
- "txtPnrNo": reservation.rsv_id,
673
- "txtJrnySqno": reservation.journey_no,
674
- "txtJrnyCnt": reservation.journey_cnt,
675
- "hidRsvChgNo": reservation.rsv_chg_no,
676
- }
677
- response = self._session.get(korail_mod.KORAIL_CANCEL, params=payload)
678
- data = json.loads(response.text)
679
- if self._result_check(data):
680
- return True
681
- return False
682
-
683
-
684
- def parse_passengers(args: argparse.Namespace) -> list[Passenger]:
685
- passengers: list[Passenger] = []
686
- if args.adults:
687
- passengers.append(AdultPassenger(args.adults))
688
- if args.children:
689
- passengers.append(ChildPassenger(args.children))
690
- if args.toddlers:
691
- passengers.append(ToddlerPassenger(args.toddlers))
692
- if args.seniors:
693
- passengers.append(SeniorPassenger(args.seniors))
694
- if not passengers:
695
- passengers.append(AdultPassenger())
696
- return passengers
697
-
698
-
699
- def build_train_id_payload(train) -> dict[str, str]:
700
- return {field: getattr(train, field) for field in TRAIN_ID_FIELDS}
29
+ FILE_BASE_URL = "https://www.korail.com/file/cubedata/COMMON/"
30
+ BOOKING_URL = "https://www.korail.com/ticket/search"
31
+ USER_AGENT = "k-skill/ktx-readonly (+https://github.com/NomaDamas/k-skill)"
32
+ KTX_TITLE = re.compile(
33
+ r"(?:KTX|경부선|호남선|전라선|경전선|동해선|강릉선|중앙선|중부내륙선).*(?:시간표|시각표)"
34
+ )
35
+ EFFECTIVE_DATE = re.compile(r"(20\d{2})[.\s년]+(\d{1,2})[.\s월]+(\d{1,2})")
701
36
 
702
37
 
703
- def build_train_id(train) -> str:
704
- payload = json.dumps(build_train_id_payload(train), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
705
- encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
706
- return f"{TRAIN_ID_PREFIX}{encoded}"
38
+ @dataclass(frozen=True)
39
+ class TimetableSource:
40
+ title: str
41
+ published_at: str
42
+ download_url: str
43
+ source_url: str = BOARD_URL
707
44
 
708
45
 
709
- def parse_train_id(train_id: str) -> dict[str, str]:
710
- if not train_id.startswith(TRAIN_ID_PREFIX):
711
- raise SystemExit("train_id must start with ktx:v1:")
712
- encoded = train_id.removeprefix(TRAIN_ID_PREFIX)
713
- padded = encoded + ("=" * ((4 - len(encoded) % 4) % 4))
46
+ def fetch_json(url: str, timeout: float = 20.0) -> dict[str, Any]:
47
+ request = Request(url, headers={"User-Agent": USER_AGENT})
714
48
  try:
715
- payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
716
- except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
717
- raise SystemExit(TRAIN_ID_INVALID_MESSAGE) from exc
718
- if not isinstance(payload, dict):
719
- raise SystemExit(TRAIN_ID_INVALID_MESSAGE)
720
- invalid_fields = [field for field in TRAIN_ID_FIELDS if not isinstance(payload.get(field), str) or not payload[field]]
721
- if invalid_fields:
722
- raise SystemExit(TRAIN_ID_INVALID_MESSAGE)
723
- return {field: payload[field] for field in TRAIN_ID_FIELDS}
724
-
49
+ with urlopen(request, timeout=timeout) as response:
50
+ return json.load(response)
51
+ except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
52
+ raise RuntimeError(f"Korail official timetable index unavailable: {exc}") from exc
725
53
 
726
- def find_train_by_id(trains, train_id: str):
727
- expected = parse_train_id(train_id)
728
- for train in trains:
729
- if build_train_id_payload(train) == expected:
730
- return train
731
- return None
732
54
 
733
-
734
- def find_train_detail_by_id(details, train_id: str):
735
- expected = parse_train_id(train_id)
736
- for train, raw_train in details:
737
- if build_train_id_payload(train) == expected:
738
- return train, raw_train
739
- return None
740
-
741
-
742
- def normalize_train(train, index: int) -> dict[str, object]:
743
- return {
744
- "index": index,
745
- "train_id": build_train_id(train),
746
- "train_no": train.train_no,
747
- "train_type": train.train_type_name,
748
- "dep_name": train.dep_name,
749
- "dep_date": train.dep_date,
750
- "dep_time": train.dep_time,
751
- "arr_name": train.arr_name,
752
- "arr_date": train.arr_date,
753
- "arr_time": train.arr_time,
754
- "has_general_seat": train.has_general_seat(),
755
- "has_special_seat": train.has_special_seat(),
756
- "has_waiting_list": train.has_waiting_list(),
757
- "description": str(train),
758
- }
759
-
760
-
761
- def parse_seat_label(seat_label: str) -> tuple[int | None, str]:
762
- match = re.match(r"^(\d+)([A-Za-z])$", seat_label or "")
763
- if not match:
764
- return None, ""
765
- return int(match.group(1)), match.group(2).upper()
766
-
767
-
768
- def power_outlet_match(seat_label: str) -> str:
769
- row, column = parse_seat_label(seat_label)
770
- if row not in POWER_OUTLET_ROWS:
771
- return "none"
772
- if column in POWER_OUTLET_DIRECT_COLUMNS:
773
- return "direct"
774
- if column in POWER_OUTLET_ADJACENT_COLUMNS:
775
- return "adjacent"
776
- return "none"
777
-
778
-
779
- def normalize_seat(raw_seat: dict[str, object]) -> dict[str, object]:
780
- seat_label = str(raw_seat.get("h_con_seat_no", ""))
781
- return {
782
- "seat": seat_label,
783
- "seat_no": str(raw_seat.get("h_seat_no", "")),
784
- "available": raw_seat.get("h_sale_psb_flg") == "Y",
785
- "direction": SEAT_DIRECTION_NAME.get(str(raw_seat.get("h_for_rev_dir_dv", "")), str(raw_seat.get("h_for_rev_dir_dv", ""))),
786
- "position": SEAT_POSITION_NAME.get(str(raw_seat.get("h_sigl_win_in_dv", "")), str(raw_seat.get("h_sigl_win_in_dv", ""))),
787
- "seat_type": SEAT_TYPE_NAME.get(str(raw_seat.get("h_dmd_seat_att", "")), str(raw_seat.get("h_dmd_seat_att", ""))),
788
- "near_door": raw_seat.get("h_door_nbor_flg") == "Y",
789
- "power_outlet": power_outlet_match(seat_label),
790
- }
791
-
792
-
793
- def validate_raw_seat(raw_seat: dict[str, object]) -> None:
794
- required_fields = ("h_con_seat_no", "h_seat_no", "h_sale_psb_flg")
795
- if any(raw_seat.get(field) in (None, "") for field in required_fields):
796
- raise ValueError("seat row is missing required fields")
797
-
798
-
799
- def parse_nonnegative_int_field(raw: object, field_name: str) -> int:
800
- text = "" if raw is None else str(raw)
801
- if not text.isdigit():
802
- raise ValueError(f"{field_name} is not a non-negative integer")
803
- return int(text)
804
-
805
-
806
- def normalize_car(raw_car: object) -> dict[str, object]:
807
- if not isinstance(raw_car, dict):
808
- raise ValueError("car row is not an object")
809
- return {
810
- "car_no": parse_nonnegative_int_field(raw_car.get("h_srcar_no"), "h_srcar_no"),
811
- "car_no_raw": str(raw_car.get("h_srcar_no", "")),
812
- "room_class": ROOM_CLASS_NAME.get(str(raw_car.get("h_psrm_cl_cd", "")), str(raw_car.get("h_psrm_cl_nm", ""))),
813
- "room_class_code": str(raw_car.get("h_psrm_cl_cd", "")),
814
- "total_seats": parse_nonnegative_int_field(raw_car.get("h_seat_cnt"), "h_seat_cnt"),
815
- "remaining_seats": parse_nonnegative_int_field(raw_car.get("h_rest_seat_cnt"), "h_rest_seat_cnt"),
816
- }
817
-
818
-
819
- DEFAULT_PREFERRED_CAR_NO = 5
820
-
821
- # 상세 조회 raw_train의 편성 분류. 산천은 분류 코드가 07/10 두 값으로 관측되어
822
- # 모두 같은 편성으로 처리한다. 명칭(h_trn_clsf_nm)을 먼저 보고 코드(h_trn_clsf_cd)로
823
- # 보완한다.
824
- TRAIN_FORMATION_BY_NAME = {
825
- "KTX": "ktx",
826
- "KTX-산천": "ktx-sancheon",
827
- "KTX-청룡": "ktx-cheongryong",
828
- }
829
- TRAIN_FORMATION_BY_CODE = {
830
- "00": "ktx",
831
- "07": "ktx-sancheon",
832
- "10": "ktx-sancheon",
833
- "19": "ktx-cheongryong",
834
- }
835
-
836
-
837
- # 편성별 기본 탐색 시작 호차. 현재는 모든 편성이 5호차 최우선이며,
838
- # 편성별로 다른 규칙이 필요해지면 이 테이블만 바꾼다.
839
- PREFERRED_CAR_BY_FORMATION = {
840
- "ktx": 5,
841
- "ktx-sancheon": 5,
842
- "ktx-cheongryong": 5,
843
- }
844
-
845
-
846
- def classify_train_formation(raw_train: dict[str, object] | None) -> str | None:
847
- if not isinstance(raw_train, dict):
848
- return None
849
- name = str(raw_train.get("h_trn_clsf_nm", ""))
850
- if name in TRAIN_FORMATION_BY_NAME:
851
- return TRAIN_FORMATION_BY_NAME[name]
852
- code = str(raw_train.get("h_trn_clsf_cd", ""))
853
- if code in TRAIN_FORMATION_BY_CODE:
854
- return TRAIN_FORMATION_BY_CODE[code]
855
- return None
856
-
857
-
858
- def preferred_car_no_for(raw_train: dict[str, object] | None) -> int:
859
- formation = classify_train_formation(raw_train)
860
- return PREFERRED_CAR_BY_FORMATION.get(formation, DEFAULT_PREFERRED_CAR_NO)
861
-
862
-
863
- def car_priority(car: dict[str, object], preferred_car_no: int) -> tuple[int, int]:
864
- car_no = int(car["car_no"])
865
- return (abs(car_no - preferred_car_no), car_no)
866
-
867
-
868
- def sort_cars_for_booking(
869
- cars: list[dict[str, object]],
870
- raw_train: dict[str, object] | None = None,
871
- ) -> list[dict[str, object]]:
872
- # 5호차(편성별 preferred car)를 최우선으로 두고, 없으면 5호차와의 거리,
873
- # 같은 거리에서는 낮은 호차 번호 순으로 결정적으로 정렬한다.
874
- preferred = preferred_car_no_for(raw_train)
875
- return sorted(cars, key=lambda car: car_priority(car, preferred))
876
-
877
-
878
- def seat_preference_key(seat: dict[str, object]) -> tuple[int, int, int, str]:
879
- power_rank = {"direct": 0, "adjacent": 1, "none": 2}.get(str(seat.get("power_outlet")), 2)
880
- direction_rank = 0 if seat.get("direction") == "순방향" else 1
881
- row, column = parse_seat_label(str(seat.get("seat", "")))
882
- return (power_rank, direction_rank, row if row is not None else 999, column)
883
-
884
-
885
- def sort_seats_for_booking(seats: list[dict[str, object]]) -> list[dict[str, object]]:
886
- return sorted(seats, key=seat_preference_key)
887
-
888
-
889
- def mask_identifier(value: object, visible: int = 4) -> str:
890
- text = str(value or "")
891
- if not text:
892
- return ""
893
- if len(text) <= visible:
894
- return "*" * len(text)
895
- return f"{'*' * (len(text) - visible)}{text[-visible:]}"
896
-
897
-
898
- def normalize_reservation(reservation) -> dict[str, object]:
899
- return {
900
- "reservation_id": reservation.rsv_id,
901
- "train_no": reservation.train_no,
902
- "train_type": reservation.train_type_name,
903
- "dep_name": reservation.dep_name,
904
- "dep_date": reservation.dep_date,
905
- "dep_time": reservation.dep_time,
906
- "arr_name": reservation.arr_name,
907
- "arr_date": reservation.arr_date,
908
- "arr_time": reservation.arr_time,
909
- "seat_count": reservation.seat_no_count,
910
- "price": reservation.price,
911
- "buy_limit_date": reservation.buy_limit_date,
912
- "buy_limit_time": reservation.buy_limit_time,
913
- "journey_no": reservation.journey_no,
914
- "journey_cnt": reservation.journey_cnt,
915
- "rsv_chg_no": reservation.rsv_chg_no,
916
- "description": str(reservation),
917
- }
918
-
919
-
920
- def print_json(payload: dict[str, object]) -> None:
921
- print(json.dumps(payload, ensure_ascii=False, indent=2))
922
-
923
-
924
- def build_client() -> PatchedKorail:
925
- ensure_runtime_dependencies()
926
- korail_id = os.environ.get("KSKILL_KTX_ID")
927
- korail_pw = os.environ.get("KSKILL_KTX_PASSWORD")
928
- if not korail_id or not korail_pw:
929
- raise SystemExit(
930
- "이 작업에는 KSKILL_KTX_ID, KSKILL_KTX_PASSWORD 환경변수가 필요합니다. "
931
- "환경변수가 설정되어 있지 않으면 ~/.config/k-skill/secrets.env 에 추가하거나 "
932
- "에이전트의 secret vault에서 주입해 주세요."
55
+ def download_bytes(url: str, timeout: float = 30.0) -> bytes:
56
+ request = Request(url, headers={"User-Agent": USER_AGENT, "Referer": BOOKING_URL})
57
+ try:
58
+ with urlopen(request, timeout=timeout) as response:
59
+ return response.read()
60
+ except (HTTPError, URLError, TimeoutError) as exc:
61
+ raise RuntimeError(f"Korail official timetable file unavailable: {exc}") from exc
62
+
63
+
64
+ def timetable_candidates(payload: dict[str, Any]) -> list[TimetableSource]:
65
+ candidates: list[TimetableSource] = []
66
+ for item in payload.get("boardList", []):
67
+ title = str(item.get("bdTitle", "")).strip()
68
+ file_ids = item.get("fileId") or []
69
+ if not KTX_TITLE.search(title) or not file_ids:
70
+ continue
71
+ file_id = str(file_ids[0]).lstrip("/")
72
+ if not file_id.lower().endswith((".xlsx", ".xlsm")):
73
+ continue
74
+ candidates.append(
75
+ TimetableSource(
76
+ title=title,
77
+ published_at=str(item.get("regdt", "")),
78
+ download_url=FILE_BASE_URL + file_id,
79
+ )
933
80
  )
934
- client = PatchedKorail(korail_id, korail_pw)
935
- if not client.logined:
936
- raise NeedToLoginError()
937
- return client
81
+ return candidates
938
82
 
939
83
 
940
- def command_search(args: argparse.Namespace) -> None:
941
- client = build_client()
942
- passengers = parse_passengers(args)
943
- trains = client.search_train(
944
- args.dep,
945
- args.arr,
946
- args.date,
947
- args.time,
948
- train_type=TRAIN_TYPE_MAP[args.train_type],
949
- passengers=passengers,
950
- include_no_seats=args.include_no_seats,
951
- include_waiting_list=args.include_waiting_list,
952
- )
953
- visible_trains = trains[: args.limit]
954
- print_json({
955
- "count": len(visible_trains),
956
- "trains": [normalize_train(train, index) for index, train in enumerate(visible_trains, start=1)],
957
- })
84
+ def choose_latest_timetable(payload: dict[str, Any]) -> TimetableSource:
85
+ candidates = timetable_candidates(payload)
86
+ if not candidates:
87
+ raise RuntimeError("Korail published no readable KTX timetable attachment")
88
+ return max(candidates, key=lambda source: (source.published_at, source.title))
958
89
 
959
90
 
960
- def command_seats(args: argparse.Namespace) -> None:
961
- client = build_client()
962
- passengers = parse_passengers(args)
963
- passenger_count = sum(passenger.count for passenger in Passenger.reduce(passengers))
964
- details = client.search_train_details(
965
- args.dep,
966
- args.arr,
967
- args.date,
968
- args.time,
969
- train_type=TRAIN_TYPE_MAP[args.train_type],
970
- passengers=passengers,
971
- include_no_seats=True,
972
- include_waiting_list=True,
973
- )
974
- match = find_train_detail_by_id(details, args.train_id)
91
+ def effective_date(source: TimetableSource) -> str:
92
+ match = EFFECTIVE_DATE.search(source.title)
975
93
  if match is None:
976
- raise SystemExit(TRAIN_ID_STALE_MESSAGE)
94
+ return source.published_at.replace("-", "")
95
+ year, month, day = match.groups()
96
+ return f"{year}{int(month):02d}{int(day):02d}"
977
97
 
978
- train, raw_train = match
979
- room_class = ROOM_CLASS_MAP[args.room]
980
- seat_car_unavailable = f"seat car data is unavailable for {args.room}; retry search or choose another train"
981
- try:
982
- cars = [normalize_car(car) for car in client.train_cars(raw_train, passenger_count, room_class)]
983
- except (TypeError, ValueError, AttributeError) as exc:
984
- raise SystemExit(seat_car_unavailable) from exc
985
- if not cars:
986
- raise SystemExit(seat_car_unavailable)
987
- if args.car_no is not None:
988
- cars = [car for car in cars if car["car_no"] == args.car_no]
989
- if not cars:
990
- raise SystemExit(f"car_no {args.car_no} is not available for {args.room}")
991
- else:
992
- cars = sort_cars_for_booking(cars, raw_train=raw_train)
993
98
 
994
- car_payloads: list[dict[str, object]] = []
995
- for car in cars:
996
- raw = client.car_seats(raw_train, str(car["car_no_raw"]), passenger_count, room_class)
997
- seat_infos = raw.get("seat_infos") if isinstance(raw, dict) else None
998
- seat_detail_unavailable = (
999
- f"seat detail data is unavailable for car_no {car['car_no']}; "
1000
- "retry search or choose another train"
1001
- )
1002
- if not isinstance(seat_infos, dict):
1003
- raise SystemExit(seat_detail_unavailable)
1004
- if "seat_info" not in seat_infos:
1005
- raise SystemExit(seat_detail_unavailable)
1006
- raw_seats = seat_infos["seat_info"]
1007
- if isinstance(raw_seats, dict):
1008
- raw_seats = [raw_seats]
1009
- if not isinstance(raw_seats, list):
1010
- raise SystemExit(seat_detail_unavailable)
1011
- if any(not isinstance(seat, dict) for seat in raw_seats):
1012
- raise SystemExit(seat_detail_unavailable)
1013
- try:
1014
- for raw_seat in raw_seats:
1015
- validate_raw_seat(raw_seat)
1016
- except ValueError as exc:
1017
- raise SystemExit(seat_detail_unavailable) from exc
1018
- remaining_seats = car["remaining_seats"]
1019
- if not isinstance(remaining_seats, int):
1020
- raise SystemExit(seat_detail_unavailable)
1021
- if not raw_seats and remaining_seats > 0:
1022
- raise SystemExit(seat_detail_unavailable)
1023
- all_seats = [normalize_seat(seat) for seat in raw_seats if seat.get("h_con_seat_no") != "0A"]
1024
- if not all_seats and remaining_seats > 0:
1025
- raise SystemExit(seat_detail_unavailable)
1026
- seats = sort_seats_for_booking(all_seats)
1027
- if args.available_only:
1028
- seats = [seat for seat in seats if seat["available"]]
1029
- if args.power_only:
1030
- seats = [seat for seat in seats if seat["power_outlet"] != "none"]
1031
- available_seats = [seat for seat in seats if seat["available"]]
1032
- seats = seats[: args.limit]
1033
- car_payload = dict(car)
1034
- car_payload["available_seat_count"] = len(available_seats)
1035
- car_payload["available_seats"] = [seat["seat"] for seat in available_seats]
1036
- car_payload["shown_seat_count"] = len(seats)
1037
- car_payload["seats"] = seats
1038
- car_payloads.append(car_payload)
99
+ def choose_timetable_for_date(payload: dict[str, Any], date: str) -> TimetableSource:
100
+ candidates = timetable_candidates(payload)
101
+ applicable = [source for source in candidates if effective_date(source) <= date]
102
+ if not applicable:
103
+ raise RuntimeError(f"Korail published no KTX timetable applicable to {date}")
104
+ return max(applicable, key=lambda source: (effective_date(source), source.published_at))
1039
105
 
1040
- print_json({
1041
- "train": normalize_train(train, 1),
1042
- "room": args.room,
1043
- "passenger_count": passenger_count,
1044
- "available_only": args.available_only,
1045
- "power_only": args.power_only,
1046
- "cars": car_payloads,
1047
- })
1048
106
 
1049
-
1050
- def ensure_ncard_available() -> None:
1051
- if not _NCARD_AVAILABLE:
1052
- raise SystemExit(
1053
- "N카드 기능을 사용하려면 korail2-ncard 패키지가 필요합니다: "
1054
- "pip install korail2-ncard pycryptodome"
107
+ def load_workbook_bytes(content: bytes):
108
+ try:
109
+ return load_workbook(BytesIO(content), read_only=True, data_only=True)
110
+ except Exception as exc:
111
+ raise RuntimeError(f"Korail timetable workbook could not be parsed: {exc}") from exc
112
+
113
+
114
+ def search_public_timetable(
115
+ *,
116
+ dep: str,
117
+ arr: str,
118
+ date: str,
119
+ earliest: str,
120
+ latest: str,
121
+ limit: int,
122
+ ) -> dict[str, Any]:
123
+ validate_date(date)
124
+ start = validate_time(earliest)
125
+ end = validate_time(latest)
126
+ if start > end:
127
+ raise ValueError("--time must not be later than --time-limit")
128
+ source = choose_timetable_for_date(fetch_json(BOARD_URL), date)
129
+ workbook = load_workbook_bytes(download_bytes(source.download_url))
130
+ requested_date = calendar_date(int(date[:4]), int(date[4:6]), int(date[6:8]))
131
+ trains: list[dict[str, str]] = []
132
+ route_found = False
133
+ for sheet_name in workbook.sheetnames:
134
+ worksheet = workbook[sheet_name]
135
+ sheet_trains, sheet_route_found = parse_timetable_rows(
136
+ worksheet.iter_rows(values_only=True),
137
+ dep=dep,
138
+ arr=arr,
139
+ requested_date=requested_date,
140
+ earliest=start,
141
+ latest=end,
1055
142
  )
1056
-
1057
-
1058
- def resolve_ncard_no(client: PatchedKorail, ncard_index: int | None, ncard_no: str | None) -> str | None:
1059
- if ncard_index is None and not ncard_no:
1060
- return None
1061
- ensure_ncard_available()
1062
- if ncard_index is None:
1063
- return ncard_no
1064
- ncards = client.owned_ncards()
1065
- if not ncards:
1066
- raise SystemExit("보유한 N카드가 없습니다.")
1067
- if ncard_index < 1 or ncard_index > len(ncards):
1068
- raise SystemExit(f"ncard-index는 1~{len(ncards)} 사이여야 합니다.")
1069
- selected = ncards[ncard_index - 1]
1070
- selected_no = getattr(selected, "discount_card_no", None)
1071
- if not selected_no:
1072
- raise SystemExit("선택한 N카드에서 카드 번호를 확인할 수 없습니다.")
1073
- return selected_no
1074
-
1075
-
1076
- def command_reserve(args: argparse.Namespace) -> None:
1077
- client = build_client()
1078
- ncard_no = resolve_ncard_no(
1079
- client,
1080
- getattr(args, "ncard_index", None),
1081
- getattr(args, "ncard_no", None),
1082
- )
1083
- if ncard_no:
1084
- passengers = [NCardPassenger(card_no=ncard_no)]
1085
- else:
1086
- passengers = parse_passengers(args)
1087
- include_waiting_list = args.include_waiting_list or args.try_waiting
1088
- trains = client.search_train(
1089
- args.dep,
1090
- args.arr,
1091
- args.date,
1092
- args.time,
1093
- train_type=TRAIN_TYPE_MAP[args.train_type],
1094
- passengers=passengers,
1095
- include_no_seats=args.include_no_seats,
1096
- include_waiting_list=include_waiting_list,
1097
- )
1098
- selected_train = find_train_by_id(trains, args.train_id)
1099
- if selected_train is None:
1100
- raise SystemExit(TRAIN_ID_STALE_MESSAGE)
1101
- reservation = client.reserve(
1102
- selected_train,
1103
- passengers=passengers,
1104
- option=RESERVE_OPTION_MAP[args.seat_option],
1105
- try_waiting=args.try_waiting,
1106
- )
1107
- print_json({"reservation": normalize_reservation(reservation)})
1108
-
1109
-
1110
- def command_reservations(_: argparse.Namespace) -> None:
1111
- client = build_client()
1112
- reservations = client.reservations()
1113
- print_json({
1114
- "count": len(reservations),
1115
- "reservations": [normalize_reservation(reservation) for reservation in reservations],
1116
- })
1117
-
1118
-
1119
- def normalize_ncard(ncard, index: int) -> dict[str, object]:
143
+ trains.extend(sheet_trains)
144
+ route_found = route_found or sheet_route_found
145
+ if not route_found:
146
+ raise RuntimeError(f"Korail timetable station pair not found: {dep} -> {arr}")
147
+ unique = {(train["train_no"], train["dep_time"], train["arr_time"]): train for train in trains}
148
+ ordered = sorted(unique.values(), key=lambda train: (train["dep_time"], train["train_no"]))[:limit]
1120
149
  return {
1121
- "index": index,
1122
- "card_no": mask_identifier(getattr(ncard, "discount_card_no", "")),
1123
- "card_no_masked": True,
1124
- "ticket_kind": ncard.ticket_kind_name or "",
1125
- "dep_name": ncard.dep_name or "",
1126
- "arr_name": ncard.arr_name or "",
1127
- "valid": ncard.valid or "",
1128
- "description": str(ncard),
150
+ "count": len(ordered),
151
+ "trains": ordered,
152
+ "date": date,
153
+ "schedule_note": "공개 운행계획 기준이며 실시간 잔여석·운휴·지연 정보가 아닙니다.",
154
+ "source": {"operator": "한국철도공사", **asdict(source)},
155
+ "booking_url": BOOKING_URL,
1129
156
  }
1130
157
 
1131
158
 
1132
- def normalize_ncard_train(train, index: int) -> dict[str, object]:
1133
- base = normalize_train(train, index)
1134
- base["price"] = getattr(train, "price", None)
1135
- base["discount_name"] = getattr(train, "discount_name", None)
1136
- base["general_remaining_seats"] = getattr(train, "general_remaining_seats", None)
1137
- base["standing_remaining_seats"] = getattr(train, "standing_remaining_seats", None)
1138
- return base
1139
-
1140
-
1141
- def command_ncard_list(args: argparse.Namespace) -> None:
1142
- ensure_ncard_available()
1143
- client = build_client()
1144
- ncards = client.owned_ncards()
1145
- print_json({
1146
- "count": len(ncards),
1147
- "ncards": [normalize_ncard(ncard, index) for index, ncard in enumerate(ncards, start=1)],
1148
- })
1149
-
1150
-
1151
- def command_ncard_search(args: argparse.Namespace) -> None:
1152
- ensure_ncard_available()
1153
- client = build_client()
1154
- ncards = client.owned_ncards()
1155
- if not ncards:
1156
- raise SystemExit("보유한 N카드가 없습니다.")
1157
- if args.ncard_index < 1 or args.ncard_index > len(ncards):
1158
- raise SystemExit(f"ncard-index는 1~{len(ncards)} 사이여야 합니다.")
1159
- ncard = ncards[args.ncard_index - 1]
1160
- trains = client.search_owned_ncard_trains(
1161
- ncard,
1162
- dep=args.dep,
1163
- arr=args.arr,
1164
- date=args.date,
1165
- time=args.time,
1166
- train_type=TRAIN_TYPE_MAP[args.train_type],
1167
- )
1168
- visible_trains = trains[: args.limit]
1169
- print_json({
1170
- "count": len(visible_trains),
1171
- "ncard": normalize_ncard(ncard, args.ncard_index),
1172
- "trains": [normalize_ncard_train(train, index) for index, train in enumerate(visible_trains, start=1)],
1173
- })
1174
-
1175
-
1176
- def command_cancel(args: argparse.Namespace) -> None:
1177
- client = build_client()
1178
- reservations = client.reservations()
1179
- match = next((reservation for reservation in reservations if reservation.rsv_id == args.reservation_id), None)
1180
- if match is None:
1181
- raise SystemExit(f"reservation {args.reservation_id} not found")
1182
- client.cancel(match)
1183
- print_json({"cancelled": True, "reservation_id": args.reservation_id})
159
+ def validate_date(value: str) -> str:
160
+ if not re.fullmatch(r"\d{8}", value):
161
+ raise ValueError("date must use YYYYMMDD")
162
+ try:
163
+ calendar_date(int(value[:4]), int(value[4:6]), int(value[6:8]))
164
+ except ValueError as exc:
165
+ raise ValueError("date must use a valid YYYYMMDD value") from exc
166
+ return value
1184
167
 
1185
168
 
1186
- def add_common_trip_args(parser: argparse.ArgumentParser) -> None:
1187
- parser.add_argument("dep", help="출발역")
1188
- parser.add_argument("arr", help="도착역")
1189
- parser.add_argument("date", help="출발일 YYYYMMDD")
1190
- parser.add_argument("time", help="희망 시작 시각 HHMMSS")
1191
- parser.add_argument("--adults", type=int, default=1, help="성인 수")
1192
- parser.add_argument("--children", type=int, default=0, help="어린이 ")
1193
- parser.add_argument("--toddlers", type=int, default=0, help="유아 수")
1194
- parser.add_argument("--seniors", type=int, default=0, help="경로 수")
169
+ def validate_time(value: str) -> str:
170
+ if not re.fullmatch(r"\d{4}", value):
171
+ raise ValueError("time must use HHMM")
172
+ try:
173
+ time.fromisoformat(f"{value[:2]}:{value[2:]}")
174
+ except ValueError as exc:
175
+ raise ValueError("time must use a valid HHMM value") from exc
176
+ return f"{value[:2]}:{value[2:]}"
1195
177
 
1196
178
 
1197
179
  def build_parser() -> argparse.ArgumentParser:
1198
- parser = argparse.ArgumentParser(description="Patched KTX/Korail booking helper for k-skill")
1199
- subparsers = parser.add_subparsers(dest="command", required=True)
1200
-
1201
- search_parser = subparsers.add_parser("search", help="KTX/Korail 열차를 조회합니다")
1202
- add_common_trip_args(search_parser)
1203
- search_parser.add_argument("--limit", type=int, default=5, help="출력할 최대 열차 수")
1204
- search_parser.add_argument(
1205
- "--train-type",
1206
- choices=sorted(TRAIN_TYPE_MAP),
1207
- default="ktx",
1208
- help="조회할 열차 종류 (기본 ktx). ITX-청춘 노선은 itx-cheongchun, 무궁화는 mugunghwa, 전체는 all 사용",
1209
- )
1210
- search_parser.add_argument("--include-no-seats", action="store_true", help="매진 열차도 포함")
1211
- search_parser.add_argument("--include-waiting-list", action="store_true", help="예약 대기 가능 열차도 포함")
1212
- search_parser.set_defaults(func=command_search)
1213
-
1214
- seats_parser = subparsers.add_parser("seats", help="조회 결과 중 하나의 호차별 좌석번호를 조회합니다")
1215
- add_common_trip_args(seats_parser)
1216
- seats_parser.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")
1217
- seats_parser.add_argument(
1218
- "--room",
1219
- choices=sorted(ROOM_CLASS_MAP),
1220
- default="general",
1221
- help="좌석을 조회할 객실 등급 (기본 general)",
1222
- )
1223
- seats_parser.add_argument(
1224
- "--train-type",
1225
- choices=sorted(TRAIN_TYPE_MAP),
1226
- default="ktx",
1227
- help="재조회할 열차 종류 — search 단계에서 사용한 값과 동일하게 지정 (기본 ktx)",
1228
- )
1229
- seats_parser.add_argument("--car-no", type=int, default=None, help="특정 호차만 조회")
1230
- seats_parser.add_argument(
1231
- "--available-only",
1232
- "--remaining-only",
1233
- dest="available_only",
1234
- action="store_true",
1235
- help="예약 가능한/남은 좌석만 출력",
1236
- )
1237
- seats_parser.add_argument("--power-only", action="store_true", help="콘센트 꿀팁 좌석(direct/adjacent)만 출력")
1238
- seats_parser.add_argument("--limit", type=int, default=100, help="호차별 출력할 최대 좌석 수")
1239
- seats_parser.set_defaults(func=command_seats)
1240
-
1241
- reserve_parser = subparsers.add_parser("reserve", help="조회 결과 중 하나를 예약합니다")
1242
- add_common_trip_args(reserve_parser)
1243
- reserve_parser.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")
1244
- reserve_parser.add_argument("--seat-option", choices=sorted(RESERVE_OPTION_MAP), default="general-first")
1245
- reserve_parser.add_argument(
1246
- "--train-type",
1247
- choices=sorted(TRAIN_TYPE_MAP),
1248
- default="ktx",
1249
- help="재조회할 열차 종류 — search 단계에서 사용한 값과 동일하게 지정 (기본 ktx)",
1250
- )
1251
- reserve_parser.add_argument("--include-no-seats", action="store_true", help="검색 시 매진 열차도 포함")
1252
- reserve_parser.add_argument("--include-waiting-list", action="store_true", help="검색 시 예약대기 열차도 포함")
1253
- reserve_parser.add_argument(
1254
- "--try-waiting",
1255
- action="store_true",
1256
- help="좌석이 없으면 예약대기를 시도 (reserve 재조회 시 예약대기 열차 자동 포함)",
1257
- )
1258
- reserve_parser.add_argument(
1259
- "--ncard-index",
1260
- type=int,
1261
- metavar="N",
1262
- default=None,
1263
- help="ncard-list 결과의 N카드 순번 (권장). 지정하면 N카드 할인 승객으로 예약",
1264
- )
1265
- reserve_parser.add_argument(
1266
- "--ncard-no",
1267
- metavar="CARD_NO",
1268
- default=None,
1269
- help="N카드 번호 직접 입력 (비권장: 셸 히스토리에 남을 수 있음)",
1270
- )
1271
- reserve_parser.set_defaults(func=command_reserve)
1272
-
1273
- ncard_list_parser = subparsers.add_parser("ncard-list", help="보유한 N카드 목록을 조회합니다")
1274
- ncard_list_parser.set_defaults(func=command_ncard_list)
1275
-
1276
- ncard_search_parser = subparsers.add_parser("ncard-search", help="N카드 할인 열차를 조회합니다")
1277
- ncard_search_parser.add_argument("dep", help="출발역")
1278
- ncard_search_parser.add_argument("arr", help="도착역")
1279
- ncard_search_parser.add_argument("date", help="출발일 YYYYMMDD")
1280
- ncard_search_parser.add_argument("time", help="희망 시작 시각 HHMMSS")
1281
- ncard_search_parser.add_argument(
1282
- "--ncard-index", type=int, required=True, metavar="N",
1283
- help="ncard-list 결과의 N카드 순번 (1부터)",
1284
- )
1285
- ncard_search_parser.add_argument("--limit", type=int, default=5, help="출력할 최대 열차 수")
1286
- ncard_search_parser.add_argument(
1287
- "--train-type",
1288
- choices=sorted(TRAIN_TYPE_MAP),
1289
- default="ktx",
1290
- help="조회할 열차 종류 (기본 ktx)",
1291
- )
1292
- ncard_search_parser.set_defaults(func=command_ncard_search)
1293
-
1294
- reservations_parser = subparsers.add_parser("reservations", help="현재 예약 목록을 조회합니다")
1295
- reservations_parser.set_defaults(func=command_reservations)
1296
-
1297
- cancel_parser = subparsers.add_parser("cancel", help="예약번호로 예약을 취소합니다")
1298
- cancel_parser.add_argument("reservation_id", help="취소할 예약번호")
1299
- cancel_parser.set_defaults(func=command_cancel)
1300
-
180
+ parser = argparse.ArgumentParser(description="Korail KTX official timetable lookup (read-only)")
181
+ commands = parser.add_subparsers(dest="command", required=True)
182
+ search = commands.add_parser("search", help="search a published KTX operating timetable")
183
+ search.add_argument("--dep", required=True)
184
+ search.add_argument("--arr", required=True)
185
+ search.add_argument("--date", required=True, help="YYYYMMDD")
186
+ search.add_argument("--time", default="0000", help="earliest departure, HHMM")
187
+ search.add_argument("--time-limit", default="2359", help="latest departure, HHMM")
188
+ search.add_argument("--limit", type=int, default=10)
189
+ commands.add_parser("source", help="show the current official timetable source")
1301
190
  return parser
1302
191
 
1303
192
 
1304
- def main() -> int:
193
+ def main(argv: list[str] | None = None) -> int:
1305
194
  parser = build_parser()
1306
- args = parser.parse_args()
195
+ args = parser.parse_args(argv)
1307
196
  try:
1308
- args.func(args)
1309
- except (KorailError, NeedToLoginError, NoResultsError, SoldOutError) as exc:
1310
- print(str(exc), file=sys.stderr)
1311
- return 1
1312
- return 0
197
+ if args.command == "source":
198
+ print(json.dumps(asdict(choose_latest_timetable(fetch_json(BOARD_URL))), ensure_ascii=False, indent=2))
199
+ return 0
200
+ if args.limit < 1 or args.limit > 50:
201
+ raise ValueError("--limit must be between 1 and 50")
202
+ result = search_public_timetable(
203
+ dep=args.dep,
204
+ arr=args.arr,
205
+ date=args.date,
206
+ earliest=args.time,
207
+ latest=args.time_limit,
208
+ limit=args.limit,
209
+ )
210
+ print(json.dumps(result, ensure_ascii=False, indent=2))
211
+ return 0
212
+ except (RuntimeError, ValueError) as exc:
213
+ parser.error(str(exc))
214
+ return 2
1313
215
 
1314
216
 
1315
217
  if __name__ == "__main__":
1316
- raise SystemExit(main())
218
+ sys.exit(main())