@nomadamas/k-skill 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,181 +1,75 @@
1
- # SRT Booking
1
+ # SRT Live Timetable Lookup
2
2
 
3
3
  ## What this skill does
4
4
 
5
- `SRTrain` 위에 `scripts/srt_booking.py` helper 얹어 SRT 조회와 호차별 좌석번호 확인을 처리하고, 예약과 취소는 고정된 열차/예약을 다시 식별한 뒤 `SRTrain`으로 진행한다.
5
+ `SRTrain`의 시간표 검색 경로를 이용해 현재 SRT 운행 후보와 일반실·특실 예약 가능 여부를 조회한다.
6
6
 
7
- ## When to use
7
+ 스킬은 회원 로그인 불필요한 **라이브 조회 전용**이다.
8
8
 
9
- - "수서에서 부산 가는 SRT 찾아줘"
10
- - "내일 오전 SRT 빈자리 있으면 잡아줘"
11
- - "SRT 5호차 좌석 확인해줘"
12
- - "SRT 11A 좌석이 비었는지 봐줘"
13
- - "창측 좌석을 우선해서 SRT 빈자리 보여줘"
14
- - "예약 내역 확인해줘"
15
- - "이 SRT 예약 취소해줘"
9
+ - `auto_login=False` 익명 client를 사용한다.
10
+ - NetFunnel 대기열과 시간표 검색 요청만 실행한다.
11
+ - 예약, 예약대기, 좌석 선점, 결제, 취소, 자동 재조회는 실행하지 않는다.
12
+ - 정확한 호차·좌석번호를 조회하지 않는다.
13
+ - 구매는 공식 SRT 페이지에서 사용자가 직접 진행한다.
16
14
 
17
- ## When not to use
15
+ 실행 환경에는 Python 3.11 이상과 `uv`가 필요하다. `uv`가 없으면 공식 설치 문서를 안내하고 조회를 실행하지 않는다.
18
16
 
19
- - 돌쇠가 아니며 결제까지 자동으로 끝내야 하는 경우
20
- - 비밀번호를 채팅창에 직접 보내려는 경우
21
- - SRT가 아니라 KTX/Korail 예매인 경우
22
-
23
- ## Prerequisites
24
-
25
- - Python 3.10+
26
- - `python3 -m pip install SRTrain`
27
-
28
- ## Required environment variables
29
-
30
- - `KSKILL_SRT_ID`
31
- - `KSKILL_SRT_PASSWORD`
32
-
33
- ### Credential handling
34
-
35
- - 돌쇠 credential mode에서는 `vault-run` capability를 사용하고, 없으면 `request_vault_credential`을 호출한다. ID/PW 원문을 채팅이나 shell에 넣지 않는다.
36
- - 그 밖의 환경에서는 이미 주입된 환경변수 → host vault → `~/.config/k-skill/secrets.env` 순서로 사용한다.
37
-
38
- ## Inputs
39
-
40
- - 출발역
41
- - 도착역
42
- - 날짜: `YYYYMMDD`
43
- - 희망 시작 시각: `HHMMSS`
44
- - 인원 수와 승객 유형
45
- - 좌석 선호: 일반실 / 특실
46
- - 좌석 상세 조건: 객실 등급, 호차 번호, 좌석 번호, 빈 좌석만 보기, 탐색 우선순위
47
-
48
- ## Workflow
49
-
50
- ### 0. Install the package globally when missing
51
-
52
- `python3 -c 'import SRT'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Python 패키지 설치를 먼저 시도한다.
53
-
54
- ```bash
55
- python3 -m pip install SRTrain
56
- ```
57
-
58
- ### 1. Ensure credentials are available
59
-
60
- 돌쇠 credential mode에서는 `vault-run`의 SRT capability를 확인하고, 없으면 `request_vault_credential`로 앱 vault 입력 UI를 호출한다. generic fallback에서만 `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` 환경변수를 확인한다.
61
-
62
- 시크릿이 없다는 이유로 웹사이트를 직접 긁거나 다른 비공식 경로를 찾지 않는다.
63
-
64
- ### 2. Search first
65
-
66
- 먼저 helper 로 조회해서 후보를 요약한다.
67
-
68
- ```bash
69
- npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- search 수서 부산 20260328 080000 --time-limit 120000 --limit 5
70
- ```
71
-
72
- ### 3. Summarize options before side effects
73
-
74
- 예약 전에는 항상 아래를 짧게 정리한다.
75
-
76
- - 출발/도착 시각
77
- - 일반실/특실 가능 여부
78
- - 예상 운임
79
-
80
- ### 4. Inspect detailed seats when the user asks for seat numbers
81
-
82
- `search` 의 좌석 가능 여부는 열차 단위 플래그다. 사용자가 "남은 좌석 번호", "호차별 좌석", "특정 좌석", "창측/순방향 자리", "예약 전에 자리 확인"처럼 구체적인 좌석을 물으면 예약 전에 `seats` 를 호출한다.
83
-
84
- ```bash
85
- npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- seats 수서 부산 20260328 080000 --train-id <train_id>
86
- ```
87
-
88
- 특정 호차의 빈 좌석만 확인하려면 `--car-no` 와 `--available-only` 를 쓴다.
17
+ ## Commands
89
18
 
90
19
  ```bash
91
- npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --available-only
20
+ npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- \
21
+ search \
22
+ --dep 수서 \
23
+ --arr 부산 \
24
+ --date 20260819 \
25
+ --time 0600 \
26
+ --time-limit 1200 \
27
+ --limit 5
92
28
  ```
93
29
 
94
- 특정 좌석이 비었는지 확인하려면 `--seat` 를 붙인다.
30
+ 현재 라이브 조회 endpoint와 안전 경계:
95
31
 
96
32
  ```bash
97
- npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --seat 11A
33
+ npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- source
98
34
  ```
99
35
 
100
- 특정 호차를 지정하지 않으면 가운데 호차부터 탐색한다. `--car-priority center|low|high` 로 호차 탐색 순서를 바꾸고, `--seat-priority forward-window|window-forward|row-low` 로 좌석 정렬 우선순위를 바꾼다.
101
-
102
- 상세 좌석 응답을 보여줄 때는 아래를 우선 요약한다.
103
-
104
- - 호차별 `available_seat_count`
105
- - 남은 좌석 번호 (`available_seats`)
106
- - 좌석별 `direction`, `position`
107
- - 특정 좌석 요청이면 `requested_seat_available`
108
-
109
- 이 기능은 좌석을 선택/선점하지 않는다. 실제 예약은 다음 단계에서만 진행한다.
110
-
111
- ### 5. Reserve only after the train is fixed
36
+ 출력:
112
37
 
113
- 예약은 부작용이 있으므로 정확한 열차를 고른 뒤에만 진행한다.
38
+ - 열차번호·열차종류
39
+ - 출발역·도착역
40
+ - 현재 출발·도착 시각
41
+ - 일반실·특실 예약 가능 여부
42
+ - 사용한 라이브 search endpoint
43
+ - 공식 SRT 페이지
114
44
 
115
- ```bash
116
- python3 - <<'PY'
117
- import os
118
- from SRT import Adult, SRT, SeatType
119
-
120
- srt = SRT(os.environ["KSKILL_SRT_ID"], os.environ["KSKILL_SRT_PASSWORD"])
121
- trains = srt.search_train("수서", "부산", "20260328", "080000", time_limit="120000")
122
- reservation = srt.reserve(
123
- trains[0],
124
- passengers=[Adult(1)],
125
- special_seat=SeatType.GENERAL_FIRST,
126
- )
127
- print(reservation)
128
- PY
129
- ```
45
+ ## Workflow
130
46
 
131
- ### 6. Continue to payment in Dolshoi
47
+ 1. 출발역, 도착역, 날짜, 시간대를 확인한다.
48
+ 2. `search`를 한 번 실행한다. `수서역`처럼 `역`이 붙은 입력은 helper가 표준 역명으로 정규화한다.
49
+ 3. 현재 운행 후보를 제시한다.
50
+ 4. 좌석 구매가 필요하면 `booking_url`을 제공하고 종료한다.
51
+ 5. 사용자가 다시 요청하지 않는 한 polling·매진 감시를 시작하지 않는다.
132
52
 
133
- 예약이 성공하면 예약번호, 운임, 구입기한을 즉시 알려서 **좌석 확보는 완료되었다**고 명확히 말한다.
53
+ ## Hard boundaries
134
54
 
135
- 돌쇠에서 사용자가 예매 완료를 요청했다면 여기서 멈추지 않는다.
55
+ - `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` 또는 회원 로그인을 요구하지 않는다.
56
+ - helper에 `reserve`, `reservations`, `cancel`, `payment`, waiting-list 명령을 추가하지 않는다.
57
+ - 예약 endpoint, 결제 endpoint 또는 계정 endpoint를 호출하지 않는다.
58
+ - NetFunnel key를 예약·선점 자동화에 사용하지 않는다.
59
+ - CAPTCHA·접근 거부·계정 제한이 발생하면 즉시 중단한다.
60
+ - 사용자 요청 한 번을 반복 수집이나 장기 실행으로 확장하지 않는다.
136
61
 
137
- 1. CloakBrowser의 공식 SRT 예약/결제 화면에서 방금 만든 예약을 다시 식별한다.
138
- 2. vault-backed login을 사용하고 결제수단/할인/승객/예약 정보를 확인한다.
139
- 3. 실제 결제 버튼 직전에 `clarify`로 열차, 날짜·시각, 승객, 좌석 등급, 총액을 보여주고 승인받는다.
140
- 4. 승인되면 결제를 실행하고 결제 완료 화면, 예약번호, 영수증/결제 상태를 확인한다.
62
+ ## Failure modes
141
63
 
142
- 돌쇠가 아니거나 공식 결제 표면을 사용할 수 없으면 예약번호와 구입기한을 제공하고 generic handoff로 종료한다.
64
+ - NetFunnel 대기 또는 SRT 접근 제한
65
+ - `SRTrain` 설치·호환성 문제
66
+ - 날짜·시간 또는 역명 오류
67
+ - 조건에 맞는 열차 없음
143
68
 
144
- ### 7. Inspect or cancel
69
+ 경우 차단을 우회하지 않고 [SRT 공식 조회 페이지](https://etk.srail.kr/hpg/hra/01/selectScheduleList.do?pageId=TK0101010000)를 안내한다.
145
70
 
146
- 취소 전에는 대상 예약을 다시 식별한다.
71
+ ## Legal notice
147
72
 
148
73
  ```bash
149
- python3 - <<'PY'
150
- import os
151
- from SRT import SRT
152
-
153
- srt = SRT(os.environ["KSKILL_SRT_ID"], os.environ["KSKILL_SRT_PASSWORD"])
154
- reservations = srt.get_reservations()
155
- print(reservations)
156
- PY
74
+ npx -y @nomadamas/k-skill@0 read srt-booking references/AUTOMATION-LEGAL-STATEMENT.md
157
75
  ```
158
-
159
- 취소 실행 직전에 `clarify`로 예약번호, 열차, 날짜·시각, 승객, 환불/위약금 정보를 확인하고 승인받는다.
160
-
161
- ## Done when
162
-
163
- - 조회 요청이면 후보 열차가 정리되어 있다
164
- - 좌석 상세 확인이면 호차별 남은 좌석번호나 특정 좌석 공석 여부가 정리되어 있다
165
- - 예약 요청이면 예약 결과, 운임, 구입기한이 확인되어 좌석 확보 완료를 안내했다
166
- - 돌쇠의 예매 완료 요청이면 `clarify` 승인 후 결제 완료 상태와 영수증/예약번호를 확인했다
167
- - 취소 요청이면 어떤 예약을 취소했는지 명확하다
168
-
169
- ## Failure modes
170
-
171
- - 로그인 오류: 계정 정보나 SRT site policy 변경 가능성 확인
172
- - 매진: 다른 시간대나 좌석 타입으로 재조회
173
- - 좌석선택 페이지 형식 변경: helper 파서 업데이트 필요
174
- - 네트워크 오류: 짧게 재시도하되 aggressive polling은 피하기
175
-
176
- ## Notes
177
-
178
- - 상세 좌석 확인은 SRT 웹 좌석선택 페이지를 조회 전용으로 읽는다
179
- - `SRTrain`은 SRT 전용 라이브러리라서 스킬 의도가 더 선명하다
180
- - 결제 자동화 금지는 generic fallback에만 적용한다. 돌쇠에서는 `clarify` 승인 후 공식 결제 표면으로 완료한다
181
- - 자동 재시도 루프는 계정 보호 차원에서 짧고 보수적으로 유지한다
@@ -0,0 +1,25 @@
1
+ # SRT 라이브 자동조회 관련 고지
2
+
3
+ > 2026-08-12에 확인한 공개 자료를 바탕으로 한 일반적인 위험 안내이며 법률자문이나 주식회사 에스알의 유권해석이 아니다.
4
+
5
+ ## 결론
6
+
7
+ 이 스킬은 로그인 없이 `SRTrain`의 시간표 검색 endpoint만 호출하고 예약·결제·취소·좌석 선점을 제공하지 않는다. 따라서 기존 자동예매보다 위험은 낮다.
8
+
9
+ 그러나 에스알은 공식 공지에서 매크로 프로그램을 이용한 **승차권 예매**를 금지한다. 조회 전용이라는 사실이 자동 접근에 대한 에스알의 허가를 뜻하지는 않는다.
10
+
11
+ ## 기술적 경계
12
+
13
+ - `SRT("", "", auto_login=False)` 익명 client
14
+ - NetFunnel 대기열과 `selectListAra10007_n.do` 시간표 검색만 실행
15
+ - 일반실·특실 가능 여부만 조회
16
+ - 예약·예약대기·결제·취소·계정 endpoint 없음
17
+ - 한 사용자 요청에 한 번만 실행하고 polling 없음
18
+
19
+ ## 공식 근거
20
+
21
+ [매크로 프로그램을 이용한 승차권 예매 금지 안내](https://etk.srail.kr/cms/article/view.do?pageId=TK0502000000&postNo=793)는 매크로를 이용한 예매가 시스템 과부하와 다른 고객의 예매 기회 방해를 초래할 수 있고 적발 시 회원 탈퇴 처리될 수 있다고 안내한다.
22
+
23
+ [에스알 회원가입 및 이용에 관한 약관](https://etk.srail.kr/cms/archive.do?pageId=TK0503010000) 제10조는 다량 예약 후 임박 취소, 타인 계정 도용과 건전한 서비스 이용 저해를 탈퇴 사유로 규정한다.
24
+
25
+ NetFunnel 오류, 접근 거부, CAPTCHA 또는 정책 변경이 발생하면 우회하지 않고 즉시 중단한다.
@@ -1,272 +1,171 @@
1
- #!/usr/bin/env python3
1
+ #!/usr/bin/env -S uv run --locked --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["SRTrain==2.6.7"]
5
+ # ///
6
+ """Live, anonymous, read-only SRT timetable lookup through SRTrain."""
7
+
2
8
  from __future__ import annotations
3
9
 
4
10
  import argparse
5
- import base64
6
- import contextlib
7
- import io
8
- import importlib
9
11
  import json
10
- import os
12
+ import re
11
13
  import sys
12
- from types import ModuleType
13
- from typing import Protocol
14
-
15
- from srt_seats import parse_cars, parse_seats, sort_cars_for_booking, sort_seats_for_booking
16
-
17
- SRT_SEAT_ENDPOINT = "https://etk.srail.kr/hpg/hra/01/selectPassengerResearchList.do"
18
- TRAIN_ID_PREFIX = "srt:v1:"
19
- TRAIN_ID_FIELDS = (
20
- "train_number",
21
- "dep_date",
22
- "dep_time",
23
- "arr_date",
24
- "arr_time",
25
- "train_code",
26
- "dep_station_code",
27
- "arr_station_code",
28
- "dep_station_run_order",
29
- "arr_station_run_order",
30
- )
31
- ROOM_CODE = {"general": "1", "special": "2"}
32
- ROOM_NAME = {"general": "일반실", "special": "특실"}
33
-
34
-
35
- class SrtTrainLike(Protocol):
36
- train_number: str
37
- dep_date: str
38
- dep_time: str
39
- arr_date: str
40
- arr_time: str
41
- train_code: str
42
- train_name: str
43
- dep_station_code: str
44
- dep_station_name: str
45
- arr_station_code: str
46
- arr_station_name: str
47
- dep_station_run_order: str
48
- arr_station_run_order: str
49
- general_seat_state: str
50
- special_seat_state: str
51
- reserve_wait_possible_code: str
52
-
53
- def general_seat_available(self) -> bool: ...
54
-
55
- def special_seat_available(self) -> bool: ...
56
-
57
- def reserve_standby_available(self) -> bool: ...
58
-
59
-
60
- class ResponseLike(Protocol):
61
- text: str
62
-
63
-
64
- class SessionLike(Protocol):
65
- def get(self, url: str, params: dict[str, str]) -> ResponseLike: ...
66
-
67
-
68
- class SrtClientLike(Protocol):
69
- _session: SessionLike
70
-
71
- def search_train(
72
- self,
73
- dep: str,
74
- arr: str,
75
- date: str,
76
- time: str,
77
- time_limit: str | None = None,
78
- available_only: bool = True,
79
- ) -> list[SrtTrainLike]: ...
80
-
81
-
82
- def load_srt_module() -> ModuleType:
83
- try:
84
- return importlib.import_module("SRT")
85
- except ModuleNotFoundError as exc:
86
- raise SystemExit("scripts/srt_booking.py requires SRTrain: python3 -m pip install SRTrain")
14
+ from contextlib import redirect_stdout
15
+ from datetime import date as calendar_date
16
+ from datetime import time
17
+ from typing import Any
87
18
 
19
+ from requests import RequestException
20
+ from SRT import SRT
21
+ from SRT.constants import API_ENDPOINTS, STATION_CODE
22
+ from SRT.errors import SRTError, SRTNetFunnelError
23
+ from SRT.netfunnel import NetFunnelHelper
88
24
 
89
- def build_client(auto_login: bool = False) -> SrtClientLike:
90
- srt_module = load_srt_module()
91
- srt_id = os.environ.get("KSKILL_SRT_ID", "")
92
- srt_pw = os.environ.get("KSKILL_SRT_PASSWORD", "")
93
- return srt_module.SRT(srt_id, srt_pw, auto_login=auto_login)
25
+ BOOKING_URL = "https://etk.srail.kr/hpg/hra/01/selectScheduleList.do?pageId=TK0101010000"
26
+ NETFUNNEL_URL = "https://nf.letskorail.com/ts.wseq"
94
27
 
95
28
 
96
- def train_id_payload(train: SrtTrainLike) -> dict[str, str]:
97
- return {field: getattr(train, field) for field in TRAIN_ID_FIELDS}
29
+ def build_client() -> SRT:
30
+ NetFunnelHelper.NETFUNNEL_URL = NETFUNNEL_URL
31
+ return SRT("", "", auto_login=False)
98
32
 
99
33
 
100
- def build_train_id(train: SrtTrainLike) -> str:
101
- raw = json.dumps(train_id_payload(train), ensure_ascii=False, separators=(",", ":")).encode()
102
- encoded = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
103
- return f"{TRAIN_ID_PREFIX}{encoded}"
34
+ def source_info() -> dict[str, str]:
35
+ return {
36
+ "mode": "live",
37
+ "transport": "SRTrain",
38
+ "operator": "주식회사 에스알",
39
+ "endpoint": API_ENDPOINTS["search_schedule"],
40
+ "queue_endpoint": NETFUNNEL_URL,
41
+ "authentication": "anonymous",
42
+ "mutation": "none; timetable search only",
43
+ "booking_url": BOOKING_URL,
44
+ }
104
45
 
105
46
 
106
- def parse_train_id(train_id: str) -> dict[str, str]:
107
- if not train_id.startswith(TRAIN_ID_PREFIX):
108
- raise SystemExit("train_id must start with srt:v1:")
109
- encoded = train_id.removeprefix(TRAIN_ID_PREFIX)
110
- padded = encoded + ("=" * ((4 - len(encoded) % 4) % 4))
111
- try:
112
- payload = json.loads(base64.urlsafe_b64decode(padded.encode()).decode())
113
- except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
114
- raise SystemExit("train_id is invalid; rerun search and copy a fresh train_id") from exc
115
- if not isinstance(payload, dict):
116
- raise SystemExit("train_id is invalid; rerun search and copy a fresh train_id")
117
- if any(not isinstance(payload.get(field), str) or not payload[field] for field in TRAIN_ID_FIELDS):
118
- raise SystemExit("train_id is invalid; rerun search and copy a fresh train_id")
119
- return {field: payload[field] for field in TRAIN_ID_FIELDS}
47
+ def format_time(value: str) -> str:
48
+ return f"{value[:2]}:{value[2:4]}"
120
49
 
121
50
 
122
- def find_train_by_id(trains: list[SrtTrainLike], train_id: str) -> SrtTrainLike | None:
123
- expected = parse_train_id(train_id)
124
- return next((train for train in trains if train_id_payload(train) == expected), None)
51
+ def normalize_station(value: str) -> str:
52
+ """Accept common "...역" input for a canonical SRT station name."""
53
+ name = value.strip()
54
+ if name in STATION_CODE:
55
+ return name
56
+ if name.endswith("역") and name[:-1] in STATION_CODE:
57
+ return name[:-1]
58
+ return name
125
59
 
126
60
 
127
- def normalize_train(train: SrtTrainLike, index: int) -> dict[str, str | bool | int]:
61
+ def normalize_train(train: Any) -> dict[str, Any]:
128
62
  return {
129
- "index": index,
130
- "train_id": build_train_id(train),
131
- "train_no": train.train_number,
132
- "train_type": train.train_name,
133
- "dep_name": train.dep_station_name,
134
- "dep_date": train.dep_date,
135
- "dep_time": train.dep_time,
136
- "arr_name": train.arr_station_name,
137
- "arr_date": train.arr_date,
138
- "arr_time": train.arr_time,
139
- "has_general_seat": train.general_seat_available(),
140
- "has_special_seat": train.special_seat_available(),
141
- "has_waiting_list": train.reserve_standby_available(),
63
+ "train_no": str(train.train_number),
64
+ "train_type": str(train.train_name),
65
+ "dep": str(train.dep_station_name),
66
+ "arr": str(train.arr_station_name),
67
+ "dep_date": str(train.dep_date),
68
+ "dep_time": format_time(str(train.dep_time)),
69
+ "arr_time": format_time(str(train.arr_time)),
70
+ "general_seat_available": bool(train.general_seat_available()),
71
+ "special_seat_available": bool(train.special_seat_available()),
142
72
  }
143
73
 
144
74
 
145
- def seat_page_params(train: SrtTrainLike, room: str, car_no: int | None) -> dict[str, str]:
75
+ def search_live_timetable(
76
+ *,
77
+ dep: str,
78
+ arr: str,
79
+ date: str,
80
+ earliest: str,
81
+ latest: str,
82
+ limit: int,
83
+ ) -> dict[str, Any]:
84
+ validate_date(date)
85
+ start = validate_time(earliest)
86
+ end = validate_time(latest)
87
+ if start > end:
88
+ raise ValueError("--time must not be later than --time-limit")
89
+ client = build_client()
90
+ with redirect_stdout(sys.stderr):
91
+ trains = client.search_train(
92
+ dep=normalize_station(dep),
93
+ arr=normalize_station(arr),
94
+ date=date,
95
+ time=start,
96
+ time_limit=end,
97
+ available_only=False,
98
+ )
146
99
  return {
147
- "runDt1": train.dep_date,
148
- "dptDt1": train.dep_date,
149
- "dptTm1": train.dep_time,
150
- "trnNo1": f"{int(train.train_number):05d}",
151
- "trnGpCd1": "300",
152
- "dptRsStnCd1": train.dep_station_code,
153
- "arvRsStnCd1": train.arr_station_code,
154
- "dptStnRunOrdr1": train.dep_station_run_order,
155
- "arvStnRunOrdr1": train.arr_station_run_order,
156
- "seatAttCd1": "015",
157
- "psrmClCd1": ROOM_CODE[room],
158
- "index1": "0",
159
- "scarNo1": "" if car_no is None else f"{car_no:04d}",
160
- "chtnDvCd": "1",
161
- "jrnySqno": "001",
162
- "mode": "1",
163
- "psgNum": "1",
164
- "pageId": "",
100
+ "count": min(len(trains), limit),
101
+ "trains": [normalize_train(train) for train in trains[:limit]],
102
+ "date": date,
103
+ "source": source_info(),
104
+ "schedule_note": "실시간 시간표·좌석 가능 여부 조회이며 예약·좌석 선점은 실행하지 않습니다.",
105
+ "booking_url": BOOKING_URL,
165
106
  }
166
107
 
167
108
 
168
- def fetch_seat_page(client: SrtClientLike, train: SrtTrainLike, room: str, car_no: int | None) -> str:
169
- with contextlib.redirect_stdout(io.StringIO()):
170
- response = client._session.get(SRT_SEAT_ENDPOINT, params=seat_page_params(train, room, car_no))
171
- return response.text
172
-
173
-
174
- def command_search(args: argparse.Namespace) -> None:
175
- client = build_client(auto_login=False)
176
- with contextlib.redirect_stdout(io.StringIO()):
177
- trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, args.available_only)
178
- print_json({"count": len(trains[: args.limit]), "trains": [normalize_train(train, index) for index, train in enumerate(trains[: args.limit], 1)]})
179
-
180
-
181
- def command_seats(args: argparse.Namespace) -> None:
182
- client = build_client(auto_login=False)
183
- with contextlib.redirect_stdout(io.StringIO()):
184
- trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, available_only=False)
185
- train = find_train_by_id(trains, args.train_id)
186
- if train is None:
187
- raise SystemExit("train_id no longer matches any current search result; rerun search and choose a fresh train_id")
188
-
189
- initial_html = fetch_seat_page(client, train, args.room, args.car_no)
190
- cars = [car for car in parse_cars(initial_html) if car["room_class"] == ROOM_NAME[args.room]]
191
- if args.car_no is not None:
192
- cars = [car for car in cars if car["car_no"] == args.car_no]
193
- else:
194
- cars = [car for car in cars if car["available"]]
195
- if not cars:
196
- raise SystemExit(f"seat car data is unavailable for {args.room}; retry search or choose another train")
197
-
198
- car_payloads: list[dict[str, object]] = []
199
- for car in sort_cars_for_booking(cars, args.car_priority):
200
- html = initial_html if args.car_no == car["car_no"] else fetch_seat_page(client, train, args.room, car["car_no"])
201
- seats = parse_seats(html)
202
- if args.seat:
203
- seats = [seat for seat in seats if seat["seat"] == args.seat]
204
- seats = sort_seats_for_booking(seats, args.seat_priority)
205
- if args.available_only:
206
- seats = [seat for seat in seats if seat["available"]]
207
- available_seats = [seat for seat in seats if seat["available"]]
208
- limited = seats[: args.limit]
209
- payload = dict(car)
210
- payload["available_seat_count"] = len(available_seats)
211
- payload["available_seats"] = [seat["seat"] for seat in available_seats]
212
- payload["shown_seat_count"] = len(limited)
213
- payload["seats"] = limited
214
- if args.seat:
215
- payload["requested_seat"] = args.seat
216
- payload["requested_seat_available"] = any(seat["available"] for seat in seats)
217
- car_payloads.append(payload)
218
-
219
- print_json({
220
- "train": normalize_train(train, 1),
221
- "room": args.room,
222
- "available_only": args.available_only,
223
- "car_priority": args.car_priority,
224
- "seat_priority": args.seat_priority,
225
- "cars": car_payloads,
226
- })
109
+ def validate_date(value: str) -> str:
110
+ if not re.fullmatch(r"\d{8}", value):
111
+ raise ValueError("date must use YYYYMMDD")
112
+ try:
113
+ calendar_date(int(value[:4]), int(value[4:6]), int(value[6:]))
114
+ except ValueError as exc:
115
+ raise ValueError("date must use a valid YYYYMMDD value") from exc
116
+ return value
227
117
 
228
118
 
229
- def print_json(payload: dict[str, object]) -> None:
230
- print(json.dumps(payload, ensure_ascii=False, indent=2))
119
+ def validate_time(value: str) -> str:
120
+ if not re.fullmatch(r"\d{4}", value):
121
+ raise ValueError("time must use HHMM")
122
+ try:
123
+ time.fromisoformat(f"{value[:2]}:{value[2:]}")
124
+ except ValueError as exc:
125
+ raise ValueError("time must use a valid HHMM value") from exc
126
+ return value + "00"
231
127
 
232
128
 
233
129
  def build_parser() -> argparse.ArgumentParser:
234
- parser = argparse.ArgumentParser(description="SRT search and seat lookup helper for k-skill")
235
- subparsers = parser.add_subparsers(dest="command", required=True)
236
- search = subparsers.add_parser("search", help="SRT 열차를 조회합니다")
237
- add_trip_args(search)
238
- search.add_argument("--time-limit", default=None, help="조회 종료 시각 HHMMSS")
239
- search.add_argument("--available-only", action="store_true", default=False, help="예약 가능한 열차만 출력")
240
- search.add_argument("--limit", type=int, default=5, help="출력할 최대 열차 수")
241
- search.set_defaults(func=command_search)
242
-
243
- seats = subparsers.add_parser("seats", help="SRT 호차별 좌석번호를 조회합니다")
244
- add_trip_args(seats)
245
- seats.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")
246
- seats.add_argument("--time-limit", default=None, help="조회 종료 시각 HHMMSS")
247
- seats.add_argument("--room", choices=sorted(ROOM_CODE), default="general")
248
- seats.add_argument("--car-no", type=int, default=None, help="특정 호차만 조회")
249
- seats.add_argument("--seat", default=None, help="특정 좌석번호만 조회, 예: 6C")
250
- seats.add_argument("--available-only", action="store_true", help="빈 좌석만 출력")
251
- seats.add_argument("--car-priority", choices=("center", "low", "high"), default="center")
252
- seats.add_argument("--seat-priority", choices=("forward-window", "window-forward", "row-low"), default="forward-window")
253
- seats.add_argument("--limit", type=int, default=100, help="호차별 출력할 최대 좌석 수")
254
- seats.set_defaults(func=command_seats)
130
+ parser = argparse.ArgumentParser(description="SRT live timetable lookup through SRTrain (read-only)")
131
+ commands = parser.add_subparsers(dest="command", required=True)
132
+ search = commands.add_parser("search", help="query the current SRT timetable")
133
+ search.add_argument("--dep", required=True)
134
+ search.add_argument("--arr", required=True)
135
+ search.add_argument("--date", required=True, help="YYYYMMDD")
136
+ search.add_argument("--time", default="0000", help="earliest departure, HHMM")
137
+ search.add_argument("--time-limit", default="2359", help="latest departure, HHMM")
138
+ search.add_argument("--limit", type=int, default=10)
139
+ commands.add_parser("source", help="show the read-only live query endpoint")
255
140
  return parser
256
141
 
257
142
 
258
- def add_trip_args(parser: argparse.ArgumentParser) -> None:
259
- parser.add_argument("dep", help="출발역")
260
- parser.add_argument("arr", help="도착역")
261
- parser.add_argument("date", help="출발일 YYYYMMDD")
262
- parser.add_argument("time", help="희망 시작 시각 HHMMSS")
263
-
264
-
265
143
  def main(argv: list[str] | None = None) -> int:
266
- args = build_parser().parse_args(argv)
267
- args.func(args)
268
- return 0
144
+ parser = build_parser()
145
+ args = parser.parse_args(argv)
146
+ try:
147
+ if args.command == "source":
148
+ print(json.dumps(source_info(), ensure_ascii=False, indent=2))
149
+ return 0
150
+ if args.limit < 1 or args.limit > 50:
151
+ raise ValueError("--limit must be between 1 and 50")
152
+ result = search_live_timetable(
153
+ dep=args.dep,
154
+ arr=args.arr,
155
+ date=args.date,
156
+ earliest=args.time,
157
+ latest=args.time_limit,
158
+ limit=args.limit,
159
+ )
160
+ print(json.dumps(result, ensure_ascii=False, indent=2))
161
+ return 0
162
+ except (SRTNetFunnelError, RequestException):
163
+ parser.error(f"SRT timetable lookup unavailable; use the official page: {BOOKING_URL}")
164
+ return 2
165
+ except (SRTError, ValueError) as exc:
166
+ parser.error(str(exc))
167
+ return 2
269
168
 
270
169
 
271
170
  if __name__ == "__main__":
272
- raise SystemExit(main(sys.argv[1:]))
171
+ sys.exit(main())