@nomadamas/k-skill 0.1.0 → 0.2.1

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.
Files changed (142) hide show
  1. package/bin/k-skill.js +35 -5
  2. package/package.json +2 -2
  3. package/skills/assembly-bill-vote-search/skill.json +0 -1
  4. package/skills/biz-health-check/instruction.md +2 -2
  5. package/skills/biz-health-check/skill.json +0 -1
  6. package/skills/bok-ecos-stats/instruction.md +8 -8
  7. package/skills/bok-ecos-stats/skill.json +0 -1
  8. package/skills/building-register-search/instruction.md +6 -6
  9. package/skills/building-register-search/scripts/building_register.py +0 -0
  10. package/skills/cheap-gas-nearby/skill.json +0 -1
  11. package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +0 -0
  12. package/skills/corporate-registration-consulting/skill.json +3 -1
  13. package/skills/coupang-product-search/instruction.md +10 -10
  14. package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +0 -0
  15. package/skills/court-auction-notice-search/skill.json +2 -1
  16. package/skills/court-payment-order-assistant/skill.json +1 -1
  17. package/skills/d2b-notice-search/skill.json +2 -1
  18. package/skills/daangn-cars-search/instruction.md +3 -3
  19. package/skills/daangn-cars-search/scripts/daangn_cars.py +0 -0
  20. package/skills/daangn-jobs-search/instruction.md +2 -2
  21. package/skills/daangn-jobs-search/scripts/daangn_jobs.py +0 -0
  22. package/skills/daangn-jobs-search/skill.json +3 -1
  23. package/skills/daangn-realty-search/instruction.md +1 -1
  24. package/skills/daangn-realty-search/scripts/daangn_realty.py +0 -0
  25. package/skills/daangn-used-goods-search/instruction.md +2 -2
  26. package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +0 -0
  27. package/skills/daishin-report-search/skill.json +0 -1
  28. package/skills/danawa-price-search/instruction.md +4 -4
  29. package/skills/danawa-price-search/scripts/danawa_search.py +0 -0
  30. package/skills/ev-charger-nearby/instruction.md +5 -5
  31. package/skills/ev-charger-nearby/skill.json +1 -2
  32. package/skills/express-bus-booking/instruction.md +2 -2
  33. package/skills/express-bus-booking/scripts/kobus_express_booking.py +0 -0
  34. package/skills/express-bus-booking/skill.json +2 -0
  35. package/skills/fine-dust-location/instruction.md +1 -1
  36. package/skills/fine-dust-location/scripts/fine_dust.py +549 -0
  37. package/skills/fine-dust-location/skill.json +7 -1
  38. package/skills/flight-ticket-search/instruction.md +7 -7
  39. package/skills/foresttrip-vacancy/instruction.md +7 -7
  40. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +0 -0
  41. package/skills/fsc-corporate-info/instruction.md +1 -1
  42. package/skills/fsc-corporate-info/skill.json +0 -1
  43. package/skills/g2b-order-plan-search/instruction.md +2 -2
  44. package/skills/g2b-order-plan-search/skill.json +2 -1
  45. package/skills/g2b-sanctioned-supplier/instruction.md +1 -1
  46. package/skills/g2b-sanctioned-supplier/skill.json +0 -1
  47. package/skills/geeknews-search/instruction.md +3 -3
  48. package/skills/geeknews-search/scripts/geeknews_search.py +0 -0
  49. package/skills/gov-overseas-trip-report/instruction.md +12 -12
  50. package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +0 -0
  51. package/skills/han-river-water-level/skill.json +0 -1
  52. package/skills/highway-traffic-status/instruction.md +2 -2
  53. package/skills/highway-traffic-status/skill.json +0 -1
  54. package/skills/household-waste-info/skill.json +0 -1
  55. package/skills/intercity-bus-booking/instruction.md +3 -3
  56. package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +0 -0
  57. package/skills/intercity-bus-booking/skill.json +2 -0
  58. package/skills/iros-registry-automation/instruction.md +2 -2
  59. package/skills/iros-registry-automation/skill.json +2 -1
  60. package/skills/job-posting-match/instruction.md +2 -2
  61. package/skills/job-posting-match/skill.json +1 -1
  62. package/skills/jobkorea-talent-search/instruction.md +1 -1
  63. package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +0 -0
  64. package/skills/jobkorea-talent-search/skill.json +2 -1
  65. package/skills/joseon-sillok-search/instruction.md +6 -6
  66. package/skills/k-skill-cleaner/instruction.md +2 -2
  67. package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +0 -0
  68. package/skills/k-skill-setup/instruction.md +1 -1
  69. package/skills/k-skill-setup/scripts/check-setup.sh +29 -0
  70. package/skills/k-skill-setup/skill.json +8 -2
  71. package/skills/kakaotalk-mac/skill.json +1 -1
  72. package/skills/keris-academic-search/instruction.md +4 -4
  73. package/skills/korea-weather/skill.json +0 -1
  74. package/skills/korean-character-count/instruction.md +8 -8
  75. package/skills/korean-heritage-search/instruction.md +5 -5
  76. package/skills/korean-humanizer/instruction.md +1 -1
  77. package/skills/korean-jangbu-for/instruction.md +3 -3
  78. package/skills/korean-jangbu-for/scripts/install.sh +0 -0
  79. package/skills/korean-middle-korean/instruction.md +6 -6
  80. package/skills/korean-middle-korean/scripts/korean_middle_korean.js +0 -0
  81. package/skills/korean-patent-search/instruction.md +4 -4
  82. package/skills/korean-privacy-terms/instruction.md +4 -4
  83. package/skills/korean-privacy-terms/scripts/install.sh +0 -0
  84. package/skills/korean-scholarship-search/instruction.md +10 -10
  85. package/skills/korean-slang-writing/instruction.md +8 -8
  86. package/skills/korean-spell-check/instruction.md +3 -3
  87. package/skills/korean-spell-check/skill.json +0 -1
  88. package/skills/korean-stock-search/skill.json +0 -1
  89. package/skills/korean-transit-route/skill.json +0 -1
  90. package/skills/kosis-stats/instruction.md +13 -26
  91. package/skills/kr-whois-lookup/skill.json +1 -2
  92. package/skills/kstartup-search/instruction.md +8 -8
  93. package/skills/ktx-booking/instruction.md +15 -15
  94. package/skills/ktx-booking/scripts/ktx_booking.py +1316 -0
  95. package/skills/ktx-booking/skill.json +7 -1
  96. package/skills/lck-analytics/instruction.md +4 -4
  97. package/skills/lck-analytics/scripts/_lib.js +0 -0
  98. package/skills/lck-analytics/scripts/analyze-live-game.js +0 -0
  99. package/skills/lck-analytics/scripts/build-match-report.js +0 -0
  100. package/skills/lck-analytics/scripts/sync-oracle.js +0 -0
  101. package/skills/localdata-business-status/instruction.md +2 -2
  102. package/skills/mfds-drug-safety/instruction.md +3 -3
  103. package/skills/mfds-drug-safety/skill.json +0 -1
  104. package/skills/mfds-food-safety/instruction.md +6 -6
  105. package/skills/mfds-food-safety/skill.json +0 -1
  106. package/skills/myrealtrip-search/instruction.md +11 -11
  107. package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +0 -0
  108. package/skills/national-pension-workplace/instruction.md +1 -1
  109. package/skills/national-pension-workplace/skill.json +0 -1
  110. package/skills/naver-ad-performance/instruction.md +7 -7
  111. package/skills/naver-blog-research/instruction.md +4 -4
  112. package/skills/naver-news-search/skill.json +0 -1
  113. package/skills/nhis-care-checkup-search/skill.json +1 -2
  114. package/skills/nts-business-registration/instruction.md +3 -3
  115. package/skills/nts-tax-delinquency/instruction.md +1 -1
  116. package/skills/ohou-today-deal/instruction.md +4 -4
  117. package/skills/ohou-today-deal/scripts/ohou_today_deal.py +0 -0
  118. package/skills/popbill/instruction.md +9 -9
  119. package/skills/real-estate-search/skill.json +0 -1
  120. package/skills/s2b-notice-search/skill.json +2 -1
  121. package/skills/saramin-talent-search/skill.json +2 -1
  122. package/skills/seoul-bike/instruction.md +3 -3
  123. package/skills/seoul-bike/scripts/seoul_bike.py +0 -0
  124. package/skills/seoul-bike/skill.json +0 -1
  125. package/skills/seoul-density/instruction.md +4 -4
  126. package/skills/seoul-density/skill.json +0 -1
  127. package/skills/srt-booking/instruction.md +4 -4
  128. package/skills/subway-lost-property/instruction.md +2 -2
  129. package/skills/subway-lost-property/scripts/subway_lost_property.py +0 -0
  130. package/skills/subway-lost-property/skill.json +1 -1
  131. package/skills/ticket-availability/instruction.md +3 -3
  132. package/skills/ticket-availability/skill.json +2 -1
  133. package/skills/yebigun-training/skill.json +2 -1
  134. package/skills/zipcode-search/instruction.md +2 -2
  135. package/skills/zipcode-search/scripts/zipcode_search.py +0 -0
  136. package/src/assemble.js +148 -4
  137. package/src/execute.js +81 -0
  138. package/templates/action-recruiting.md +5 -0
  139. package/templates/legal.md +6 -0
  140. package/templates/operations.md +3 -0
  141. package/templates/action-communication.md +0 -5
  142. package/templates/hard-boundary.md +0 -3
@@ -0,0 +1,1316 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import base64
6
+ import json
7
+ import os
8
+ import random
9
+ import re
10
+ import string
11
+ 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",
168
+ )
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}
701
+
702
+
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}"
707
+
708
+
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))
714
+ 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
+
725
+
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
+
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에서 주입해 주세요."
933
+ )
934
+ client = PatchedKorail(korail_id, korail_pw)
935
+ if not client.logined:
936
+ raise NeedToLoginError()
937
+ return client
938
+
939
+
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
+ })
958
+
959
+
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)
975
+ if match is None:
976
+ raise SystemExit(TRAIN_ID_STALE_MESSAGE)
977
+
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
+
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)
1039
+
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
+
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"
1055
+ )
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]:
1120
+ 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),
1129
+ }
1130
+
1131
+
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})
1184
+
1185
+
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="경로 수")
1195
+
1196
+
1197
+ 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
+
1301
+ return parser
1302
+
1303
+
1304
+ def main() -> int:
1305
+ parser = build_parser()
1306
+ args = parser.parse_args()
1307
+ 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
1313
+
1314
+
1315
+ if __name__ == "__main__":
1316
+ raise SystemExit(main())