@nomadamas/k-skill 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/k-skill-cleaner/instruction.md +1 -1
- package/skills/k-skill-setup/instruction.md +114 -157
- package/skills/k-skill-setup/skill.json +2 -2
- package/skills/ktx-booking/instruction.md +38 -221
- package/skills/ktx-booking/references/AUTOMATION-LEGAL-STATEMENT.md +10 -0
- package/skills/ktx-booking/scripts/ktx_booking.py +176 -1274
- package/skills/ktx-booking/scripts/ktx_booking.py.lock +27 -0
- package/skills/ktx-booking/scripts/ktx_timetable.py +141 -0
- package/skills/ktx-booking/skill.json +11 -5
- package/skills/seoul-weather-risk/instruction.md +28 -35
- package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +15 -68
- package/skills/seoul-weather-risk/skill.json +2 -2
- package/skills/srt-booking/instruction.md +48 -154
- package/skills/srt-booking/references/AUTOMATION-LEGAL-STATEMENT.md +25 -0
- package/skills/srt-booking/scripts/srt_booking.py +134 -235
- package/skills/srt-booking/scripts/srt_booking.py.lock +207 -0
- package/skills/srt-booking/skill.json +5 -7
- package/skills/store-longevity-radar/instruction.md +7 -3
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/skills/store-longevity-radar/scripts/store_longevity_download.py +136 -0
- package/skills/store-longevity-radar/scripts/store_longevity_radar.py +1 -51
- package/src/assemble.js +1 -1
- package/src/execute.js +13 -3
- package/skills/srt-booking/scripts/srt_seats.py +0 -156
- package/skills/used-car-price-search/instruction.md +0 -109
- package/skills/used-car-price-search/references/TRADEMARK-LEGAL-STATEMENT.md +0 -9
- package/skills/used-car-price-search/skill.json +0 -8
|
@@ -1,181 +1,75 @@
|
|
|
1
|
-
# SRT
|
|
1
|
+
# SRT Live Timetable Lookup
|
|
2
2
|
|
|
3
3
|
## What this skill does
|
|
4
4
|
|
|
5
|
-
`SRTrain
|
|
5
|
+
`SRTrain`의 시간표 검색 경로를 이용해 현재 SRT 운행 후보와 일반실·특실 예약 가능 여부를 조회한다.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
이 스킬은 회원 로그인 불필요한 **라이브 조회 전용**이다.
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- "예약 내역 확인해줘"
|
|
15
|
-
- "이 SRT 예약 취소해줘"
|
|
9
|
+
- `auto_login=False` 익명 client를 사용한다.
|
|
10
|
+
- NetFunnel 대기열과 시간표 검색 요청만 실행한다.
|
|
11
|
+
- 예약, 예약대기, 좌석 선점, 결제, 취소, 자동 재조회는 실행하지 않는다.
|
|
12
|
+
- 정확한 호차·좌석번호를 조회하지 않는다.
|
|
13
|
+
- 구매는 공식 SRT 페이지에서 사용자가 직접 진행한다.
|
|
16
14
|
|
|
17
|
-
|
|
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 --
|
|
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
|
-
|
|
30
|
+
현재 라이브 조회 endpoint와 안전 경계:
|
|
95
31
|
|
|
96
32
|
```bash
|
|
97
|
-
npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py --
|
|
33
|
+
npx -y @nomadamas/k-skill@0 exec srt-booking scripts/srt_booking.py -- source
|
|
98
34
|
```
|
|
99
35
|
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
2. vault-backed login을 사용하고 결제수단/할인/승객/예약 정보를 확인한다.
|
|
139
|
-
3. 실제 결제 버튼 직전에 `clarify`로 열차, 날짜·시각, 승객, 좌석 등급, 총액을 보여주고 승인받는다.
|
|
140
|
-
4. 승인되면 결제를 실행하고 결제 완료 화면, 예약번호, 영수증/결제 상태를 확인한다.
|
|
62
|
+
## Failure modes
|
|
141
63
|
|
|
142
|
-
|
|
64
|
+
- NetFunnel 대기 또는 SRT 접근 제한
|
|
65
|
+
- `SRTrain` 설치·호환성 문제
|
|
66
|
+
- 날짜·시간 또는 역명 오류
|
|
67
|
+
- 조건에 맞는 열차 없음
|
|
143
68
|
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
+
import re
|
|
11
13
|
import sys
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
from
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
97
|
-
|
|
29
|
+
def build_client() -> SRT:
|
|
30
|
+
NetFunnelHelper.NETFUNNEL_URL = NETFUNNEL_URL
|
|
31
|
+
return SRT("", "", auto_login=False)
|
|
98
32
|
|
|
99
33
|
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
107
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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:
|
|
61
|
+
def normalize_train(train: Any) -> dict[str, Any]:
|
|
128
62
|
return {
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
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
|
|
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
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
230
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
search =
|
|
237
|
-
|
|
238
|
-
search.add_argument("--
|
|
239
|
-
search.add_argument("--
|
|
240
|
-
search.add_argument("--
|
|
241
|
-
search.
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
267
|
-
args.
|
|
268
|
-
|
|
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
|
-
|
|
171
|
+
sys.exit(main())
|