@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.
- package/package.json +1 -1
- package/skills/ktx-booking/instruction.md +38 -221
- package/skills/ktx-booking/references/AUTOMATION-LEGAL-STATEMENT.md +10 -0
- package/skills/ktx-booking/scripts/ktx_booking.py +176 -1274
- package/skills/ktx-booking/scripts/ktx_booking.py.lock +27 -0
- package/skills/ktx-booking/scripts/ktx_timetable.py +141 -0
- package/skills/ktx-booking/skill.json +11 -5
- package/skills/seoul-weather-risk/instruction.md +28 -35
- package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +15 -68
- package/skills/seoul-weather-risk/skill.json +2 -2
- package/skills/srt-booking/instruction.md +48 -154
- package/skills/srt-booking/references/AUTOMATION-LEGAL-STATEMENT.md +25 -0
- package/skills/srt-booking/scripts/srt_booking.py +134 -235
- package/skills/srt-booking/scripts/srt_booking.py.lock +207 -0
- package/skills/srt-booking/skill.json +5 -7
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/src/assemble.js +1 -1
- package/src/execute.js +13 -3
- package/skills/srt-booking/scripts/srt_seats.py +0 -156
|
@@ -1,1316 +1,218 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
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
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
710
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
def
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
"
|
|
746
|
-
"
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
"
|
|
750
|
-
"
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
935
|
-
if not client.logined:
|
|
936
|
-
raise NeedToLoginError()
|
|
937
|
-
return client
|
|
81
|
+
return candidates
|
|
938
82
|
|
|
939
83
|
|
|
940
|
-
def
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
|
961
|
-
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
"
|
|
1122
|
-
"
|
|
1123
|
-
"
|
|
1124
|
-
"
|
|
1125
|
-
"
|
|
1126
|
-
"
|
|
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
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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="
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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.
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
218
|
+
sys.exit(main())
|