@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.
- package/bin/k-skill.js +35 -5
- package/package.json +2 -2
- package/skills/assembly-bill-vote-search/skill.json +0 -1
- package/skills/biz-health-check/instruction.md +2 -2
- package/skills/biz-health-check/skill.json +0 -1
- package/skills/bok-ecos-stats/instruction.md +8 -8
- package/skills/bok-ecos-stats/skill.json +0 -1
- package/skills/building-register-search/instruction.md +6 -6
- package/skills/building-register-search/scripts/building_register.py +0 -0
- package/skills/cheap-gas-nearby/skill.json +0 -1
- package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +0 -0
- package/skills/corporate-registration-consulting/skill.json +3 -1
- package/skills/coupang-product-search/instruction.md +10 -10
- package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +0 -0
- package/skills/court-auction-notice-search/skill.json +2 -1
- package/skills/court-payment-order-assistant/skill.json +1 -1
- package/skills/d2b-notice-search/skill.json +2 -1
- package/skills/daangn-cars-search/instruction.md +3 -3
- package/skills/daangn-cars-search/scripts/daangn_cars.py +0 -0
- package/skills/daangn-jobs-search/instruction.md +2 -2
- package/skills/daangn-jobs-search/scripts/daangn_jobs.py +0 -0
- package/skills/daangn-jobs-search/skill.json +3 -1
- package/skills/daangn-realty-search/instruction.md +1 -1
- package/skills/daangn-realty-search/scripts/daangn_realty.py +0 -0
- package/skills/daangn-used-goods-search/instruction.md +2 -2
- package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +0 -0
- package/skills/daishin-report-search/skill.json +0 -1
- package/skills/danawa-price-search/instruction.md +4 -4
- package/skills/danawa-price-search/scripts/danawa_search.py +0 -0
- package/skills/ev-charger-nearby/instruction.md +5 -5
- package/skills/ev-charger-nearby/skill.json +1 -2
- package/skills/express-bus-booking/instruction.md +2 -2
- package/skills/express-bus-booking/scripts/kobus_express_booking.py +0 -0
- package/skills/express-bus-booking/skill.json +2 -0
- package/skills/fine-dust-location/instruction.md +1 -1
- package/skills/fine-dust-location/scripts/fine_dust.py +549 -0
- package/skills/fine-dust-location/skill.json +7 -1
- package/skills/flight-ticket-search/instruction.md +7 -7
- package/skills/foresttrip-vacancy/instruction.md +7 -7
- package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +0 -0
- package/skills/fsc-corporate-info/instruction.md +1 -1
- package/skills/fsc-corporate-info/skill.json +0 -1
- package/skills/g2b-order-plan-search/instruction.md +2 -2
- package/skills/g2b-order-plan-search/skill.json +2 -1
- package/skills/g2b-sanctioned-supplier/instruction.md +1 -1
- package/skills/g2b-sanctioned-supplier/skill.json +0 -1
- package/skills/geeknews-search/instruction.md +3 -3
- package/skills/geeknews-search/scripts/geeknews_search.py +0 -0
- package/skills/gov-overseas-trip-report/instruction.md +12 -12
- package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +0 -0
- package/skills/han-river-water-level/skill.json +0 -1
- package/skills/highway-traffic-status/instruction.md +2 -2
- package/skills/highway-traffic-status/skill.json +0 -1
- package/skills/household-waste-info/skill.json +0 -1
- package/skills/intercity-bus-booking/instruction.md +3 -3
- package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +0 -0
- package/skills/intercity-bus-booking/skill.json +2 -0
- package/skills/iros-registry-automation/instruction.md +2 -2
- package/skills/iros-registry-automation/skill.json +2 -1
- package/skills/job-posting-match/instruction.md +2 -2
- package/skills/job-posting-match/skill.json +1 -1
- package/skills/jobkorea-talent-search/instruction.md +1 -1
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +0 -0
- package/skills/jobkorea-talent-search/skill.json +2 -1
- package/skills/joseon-sillok-search/instruction.md +6 -6
- package/skills/k-skill-cleaner/instruction.md +2 -2
- package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +0 -0
- package/skills/k-skill-setup/instruction.md +1 -1
- package/skills/k-skill-setup/scripts/check-setup.sh +29 -0
- package/skills/k-skill-setup/skill.json +8 -2
- package/skills/kakaotalk-mac/skill.json +1 -1
- package/skills/keris-academic-search/instruction.md +4 -4
- package/skills/korea-weather/skill.json +0 -1
- package/skills/korean-character-count/instruction.md +8 -8
- package/skills/korean-heritage-search/instruction.md +5 -5
- package/skills/korean-humanizer/instruction.md +1 -1
- package/skills/korean-jangbu-for/instruction.md +3 -3
- package/skills/korean-jangbu-for/scripts/install.sh +0 -0
- package/skills/korean-middle-korean/instruction.md +6 -6
- package/skills/korean-middle-korean/scripts/korean_middle_korean.js +0 -0
- package/skills/korean-patent-search/instruction.md +4 -4
- package/skills/korean-privacy-terms/instruction.md +4 -4
- package/skills/korean-privacy-terms/scripts/install.sh +0 -0
- package/skills/korean-scholarship-search/instruction.md +10 -10
- package/skills/korean-slang-writing/instruction.md +8 -8
- package/skills/korean-spell-check/instruction.md +3 -3
- package/skills/korean-spell-check/skill.json +0 -1
- package/skills/korean-stock-search/skill.json +0 -1
- package/skills/korean-transit-route/skill.json +0 -1
- package/skills/kosis-stats/instruction.md +13 -26
- package/skills/kr-whois-lookup/skill.json +1 -2
- package/skills/kstartup-search/instruction.md +8 -8
- package/skills/ktx-booking/instruction.md +15 -15
- package/skills/ktx-booking/scripts/ktx_booking.py +1316 -0
- package/skills/ktx-booking/skill.json +7 -1
- package/skills/lck-analytics/instruction.md +4 -4
- package/skills/lck-analytics/scripts/_lib.js +0 -0
- package/skills/lck-analytics/scripts/analyze-live-game.js +0 -0
- package/skills/lck-analytics/scripts/build-match-report.js +0 -0
- package/skills/lck-analytics/scripts/sync-oracle.js +0 -0
- package/skills/localdata-business-status/instruction.md +2 -2
- package/skills/mfds-drug-safety/instruction.md +3 -3
- package/skills/mfds-drug-safety/skill.json +0 -1
- package/skills/mfds-food-safety/instruction.md +6 -6
- package/skills/mfds-food-safety/skill.json +0 -1
- package/skills/myrealtrip-search/instruction.md +11 -11
- package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +0 -0
- package/skills/national-pension-workplace/instruction.md +1 -1
- package/skills/national-pension-workplace/skill.json +0 -1
- package/skills/naver-ad-performance/instruction.md +7 -7
- package/skills/naver-blog-research/instruction.md +4 -4
- package/skills/naver-news-search/skill.json +0 -1
- package/skills/nhis-care-checkup-search/skill.json +1 -2
- package/skills/nts-business-registration/instruction.md +3 -3
- package/skills/nts-tax-delinquency/instruction.md +1 -1
- package/skills/ohou-today-deal/instruction.md +4 -4
- package/skills/ohou-today-deal/scripts/ohou_today_deal.py +0 -0
- package/skills/popbill/instruction.md +9 -9
- package/skills/real-estate-search/skill.json +0 -1
- package/skills/s2b-notice-search/skill.json +2 -1
- package/skills/saramin-talent-search/skill.json +2 -1
- package/skills/seoul-bike/instruction.md +3 -3
- package/skills/seoul-bike/scripts/seoul_bike.py +0 -0
- package/skills/seoul-bike/skill.json +0 -1
- package/skills/seoul-density/instruction.md +4 -4
- package/skills/seoul-density/skill.json +0 -1
- package/skills/srt-booking/instruction.md +4 -4
- package/skills/subway-lost-property/instruction.md +2 -2
- package/skills/subway-lost-property/scripts/subway_lost_property.py +0 -0
- package/skills/subway-lost-property/skill.json +1 -1
- package/skills/ticket-availability/instruction.md +3 -3
- package/skills/ticket-availability/skill.json +2 -1
- package/skills/yebigun-training/skill.json +2 -1
- package/skills/zipcode-search/instruction.md +2 -2
- package/skills/zipcode-search/scripts/zipcode_search.py +0 -0
- package/src/assemble.js +148 -4
- package/src/execute.js +81 -0
- package/templates/action-recruiting.md +5 -0
- package/templates/legal.md +6 -0
- package/templates/operations.md +3 -0
- package/templates/action-communication.md +0 -5
- 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())
|