yakisoba0728/srt-mobile-api
GitHub: yakisoba0728/srt-mobile-api
一个韩国 SRT 高速铁道移动端 API 的 Python 客户端,支持列车查询与预订,并通过多层同意机制确保状态变更操作的安全性。
Stars: 0 | Forks: 0
# srt-mobile-api
[](https://yaki.kr/srt-mobile-api/)  
在 Python 中直接调用 SRT(水西高速铁道)App 所使用的 HTTP API。通过与 App 相同的路径,
携带相同的表单字段,读取查询、票价和座位图,并进行预约、取消、支付和退款。
作为可安装的基础只读包,任何更改状态的请求只有在每次调用时传入同意对象后才会生成。
📖 **文档: ** — 包含完整的 API 参考和示例。
## 安装
| 项目 | 值 |
| --- | --- |
| Python | 3.11 及以上 |
| 运行时依赖 | 仅 `httpx` |
| 类型 | 附带 `py.typed` |
| 许可证 | Apache-2.0 |
无 PyPI 发布。
```
python3 -m pip install "srt-mobile-api @ git+https://github.com/yakisoba0728/srt-mobile-api"
# 如果你想修复它,请在 clone 之后
python3 -m pip install -e ".[test]"
python3 -m pytest -q -m "not live" # 1770 passed, 1 deselected
```
唯一缺少的是需要额外设置 `SRT_MOBILE_API_LIVE=1` 的生产服务测试。`-m "not live"`
与 CI 和 [docs/RELEASE.md](docs/RELEASE.md) 所使用的检查门一致。
## 快速开始
全都是读取操作。不会创建任何内容,也不会产生任何费用。
```
from srt_mobile_api import PassengerCounts, SrtClient, TrainSearchQuery
client = SrtClient()
client.login("you@example.com", "password") # 이메일·휴대폰번호·회원번호 중 하나
query = TrainSearchQuery(
departure_station_code="0551", # 수서
arrival_station_code="0015", # 동대구
departure_date="20260809", # YYYYMMDD
departure_time="060000", # HHMMSS — 이 시각부터 검색
passengers=PassengerCounts(adult=1),
)
for train in client.search_trains(query).trains:
print(train.train_no, train.departure_time,
train.general_seat_availability_name) # 예: "예약가능"
for reservation in client.get_reservations().reservations: # 없으면 빈 리스트
print(reservation.pnr_no)
client.close()
```
车站代码位于 `srt_mobile_api.stations`。`SrtClient()` 无需配置即可运行。
`SrtConfig` 用于设置超时、User-Agent 和设备密钥,并且会拒绝除 `app.srail.or.kr` 和
NetFunnel 源(`nf.letskorail.com`)之外的任何地址。
设备密钥的默认值是一个类似 `ANDROID_ID` 格式的占位符。如果想使用真实设备的值,
可以通过 `SRT_DEVICE_KEY` 传入,其值可以通过 `adb shell settings get secure android_id`
读取。从 Android 8 开始,该值会根据应用签名密钥进行隔离,因此 `adb shell` 看到的值
与 SRT App 看到的值是不同的 —— 这里关键的不是它是否真实,而是在每次执行期间是否
保持稳定。
### 收窄类型的参数
有三个参数是导出的 `Literal` 别名,因此拼写错误会引发类型错误。从响应中读取的代码不进行收窄,
直接保留为 `str`。
| 别名 | 值 |
| --- | --- |
| `SrtSeatAttrCode` | `"015"`, `"021"`, `"028"` |
| `SrtTrainGroupCode` | `"300"`, `"900"`, `"109"` |
| `MutationCategory` | `reserve`, `cancel`, `payment`, `refund`, `coupon` |
### 与 `korail-mobile-api` 混用时的注意事项
`TrainSearchQuery`, `DiscountCoupon`, `MutationCategory` 虽然由两个包分别导出,
但互不兼容。这里的 `passengers` 是 `PassengerCounts`,而 KORAIL 是 `int`;
`departure_time` 的默认值也不同,分别是 `"060000"` 和 `"000000"`。
如果导入了错误的包,类型检查仍然会通过,但会导致发出错误的请求。如果同时使用两者,
请务必加上包名前缀,例如 `srt_mobile_api.TrainSearchQuery`。
## 功能说明
所有操作都是 `SrtClient` 的方法,每个 docstring 都会说明其依据(APK 文件、行号或实际响应)。
**读取操作 —— 无需同意。**
| 方法 | 功能说明 |
| --- | --- |
| `login` / `logout` / `clear_session` / `close` | 按照与 App 相同的顺序进行登录、结束会话、丢弃本地状态、关闭连接。 |
| `search_trains` / `search_group_trains` / `search_transfer_trains` / `search_public_discount_trains` | 查询直达、团体(10人及以上)、换乘、公共折扣。 |
| `iter_train_search_pages(query, group=False, max_pages=10)` | 查询的延迟分页。如果游标断开则停止。 |
| `get_timetable` / `get_fare` / `get_seat_page` / `get_seat_grid` | 停靠站、各座位等级票价(单程)、剩余车厢、座位图。 |
| `get_reservations` / `get_ticket_list` / `get_discount_coupons` / `get_public_discounts` | 预约列表(唯一带有类型的读取操作)、车票页面、持有优惠券、已批准的公共折扣资格。 |
| `get_typed_notice_list` / `get_notice_list` / `get_main` / `get_station_selector` 等 | 公告(解析版/原始版)以及 App 所经历的页面和弹窗选择界面。 |
**状态更改 —— 必须将 `consent: MutationConsent` 作为关键字参数传入,且在默认的 `dry_run=True` 模式下
仅返回 `MutationPreview`。** 请务必先阅读下方的 **安全模型** 章节。
| 方法 | 功能说明 |
| --- | --- |
| `reserve(train, consent=...)` | 个人预约。包含未支付挂起和 PNR。可选参数包括:`standby=True`(预约等待 `jobId=1102`)、`round_trip=True`(往返 `rtnDv=1`)、`designated_seats=`(指定座位 `jobId=1103`)、`seat_attr_code=`。 |
| `reserve_transfer(itinerary, ...)` | 换乘预约。一个请求包含两段行程。 |
| `cancel(hold_or_pnr, ...)` | 释放未支付的挂起。仅在使用原始 PNR 取消时才需要直接提供 `journey_count`。 |
| `pay_with_card(reservation, card, ...)` | 信用卡支付。除了同意外,还需要额外声明卡片类型。 |
| `get_refund_ticket_info(pnr)` → `refund(ticket_info, ...)` | 退款。前者是读取操作,后者是状态更改操作。 |
| `register_discount_coupon(number, password, ...)` | 注册优惠券。目前仅支持预览操作。 |
对于不合理的组合(如不提供预约等待的列车、人数与座位数不符、包含 KORAIL 专属站的往返),
会在发送请求前抛出 `ValueError` 加以拦截。
## 安全模型
更改状态的请求必须通过全部四个关卡才能发出。
| 关卡 | 内容 |
| --- | --- |
| 同意对象 | 状态更改方法接收一个仅限关键字传入的 `consent`。新创建的 `MutationConsent()` 的 `allow_*` 全部为 `False`;如果未提供该对象或类别不符,会在构建请求前抛出 `SrtMutationNotAllowedError`。 |
| 类别 | 需分别开启 `reserve`, `cancel`, `payment`, `refund`, `coupon`。同意也与路径绑定。 |
| `dry_run=True`(默认) | 构建并验证表单后,返回 `MutationPreview` 且不发送。卡号、密码、出生日期、PNR、优惠券号、NetFunnel 密钥会显示为 `[REDACTED]`,且在 `repr` 中也会被隐藏。 |
| 发送阻断开关 | `safety.SRT_LIVE_MUTATION_CATEGORIES` 会无视同意设置,按下表进行检查。 |
| 类别 | 发送 | 实际确认的范围 |
| --- | --- | --- |
| `reserve` | 是 | 仅限成人 1 名、1 趟行程、普通室、直达。多人、预约等待、指定座位未经验证。 |
| `cancel` | 是 | 仅限释放上述提到的挂起。 |
| `payment` | 是 | 仅限个人信用卡全额付款一笔。团体、公司卡、分期付款未经验证。 |
| `refund` | 是 | 仅限上述支付的那张车票一笔。 |
| `coupon` | **否** | 虽然实现了预览功能,但从未收到过实际响应。 |
白名单中只有 26 个经过审查的只读路径。五个状态更改路径不在此列,
因此 `assert_read_only_request` 会从结构上予以拒绝。信用卡敏感信息(`stlCrCdNo1`,
`vanPwd1`, `crdVlidTrm1`, `athnVal1`)不是通过路径,而是通过请求体进行拦截,仅允许从
`payment` 发送。
```
from srt_mobile_api import MutationConsent
preview = client.reserve(train, consent=MutationConsent(allow_reserve=True))
print(preview.route, preview.payload, preview.note) # "dry-run: not sent"
# 以下不是预览,而是向真实账户实际占座的调用
hold = client.reserve(train, consent=MutationConsent(allow_reserve=True, dry_run=False))
print(hold.pnr_no) # 미결제 홀드 — 이제 호출자 책임
```
支付还需要再明确一种卡片类型。必须在 `fake_card_only=True`(不产生费用的测试卡,默认值)
和 `real_card_acknowledged=True`(实际 PAN,会产生真实交易)中**确切指定其中一个**,如果两者
都留空或都设置,则会被拒绝。
除非需要重新获取 NetFunnel 密钥,否则不会自动重试。预约请求绝对不会重试——
因为重试预约会导致重复预约。
## 错误处理
失败会以特定的异常抛出。它们全部继承自 `SrtApiError`,并包含服务器返回的 `.code`
和 `.raw`。将代码映射到异常的规则由 `classify_app_error` 定义。
| 异常 | 父类 | 触发时机 |
| --- | --- | --- |
| `SrtApiError` | `Exception` | 所有失败的基类。 |
| `SrtTransportError` | `SrtApiError` | 连接、超时等未能接收到响应的失败。 |
| `SrtProtocolError` | `SrtApiError` | 响应不符合约定格式,或试图发送到未注册的路径时。 |
| `SrtAuthError` | `SrtApiError` | 身份验证类异常的基类。 |
| `SrtSessionExpiredError` | `SrtAuthError` | 会话已断开。重新登录即可。 |
| `SrtIpBlockedError` | `SrtAuthError` | 服务器封锁了此 IP。 |
| `SrtAppError` | `SrtApiError` | 服务器正常响应中返回的业务错误。 |
| `SrtNoResultsError` | `SrtAppError` | 请求正常,但没有结果。 |
| `SrtNoDirectTrainError` | `SrtNoResultsError` | 没有直达列车。可以尝试查询换乘。 |
| `SrtSeatUnavailableError` | `SrtAppError` | 没有空座。 |
| `SrtInvalidRequestError` | `SrtAppError` | 服务器直接拒绝了请求。 |
| `SrtNetFunnelError` | `SrtApiError` | 排队系统的基类异常。 |
| `SrtNetFunnelKeyError` | `SrtNetFunnelError` | 未能获取排队密钥。 |
| `SrtQueueRejectedError` | `SrtNetFunnelError` | 排队系统拒绝通过。 |
| `SrtMutationNotAllowedError` | `SrtApiError` | 同意、类别、路径、发送这几个关卡中有一个被拦截。 |
## 局限性
SRT App 实际上是一个 **WebView 外壳**。屏幕大部分是由服务器渲染的 HTML,而支付环节需要
经过 TransKey 虚拟键盘和 RaonSecure FIDO SDK,这无法通过 HTTP 客户端来重现。
- **团体预约** —— 刻意去除了。因为其端点返回的不是 PNR 挂起,而是服务器渲染的支付页面。
查询功能仍然保留。
- **预约等待与往返** —— 虽然已实现,但未在生产环境中验证过。
- **换乘预约** —— 仅验证了查询功能,第二个槽位的五个字段名是基于推测的。
- **折扣优惠券注册** —— 已实现,但发送开关处于关闭状态。
- **折扣票与青年查询** —— 请求格式有依据,但效果未经验证。
- **App 的 WebView 支付路径**(TransKey + FIDO)、原生桥接、外部 KORAIL 座位图 —— 均不包含在内。
关于如何分别验证每一项,请参阅 [docs/VERIFICATION.md](docs/VERIFICATION.md)。
查询操作位于 NetFunnel 队列之后,在服务器看来,此客户端与 App 并无区别。如果在循环中
频繁请求,自身会遭到 IP 封锁,也会污染他人的排队环境。
## 文档
| 文档 | 包含内容 |
| --- | --- |
| [文档网站](https://yaki.kr/srt-mobile-api/) | 整合了此 README 和 API 参考的内容 |
| [docs/VERIFICATION.md](docs/VERIFICATION.md) | 验证记录。包括验证代码、**未能**最终确认的内容,以及所有路径的静态来源。 |
| [docs/IMPLEMENTATION_PROGRESS.md](docs/IMPLEMENTATION_PROGRESS.md) | 实现日志。 |
| [docs/analysis/](docs/analysis/README.md) | 静态分析产物以及与参考客户端的对比结果。 |
| [docs/RELEASE.md](docs/RELEASE.md) | 发布前运行的构建和验证检查门。 |
| [SECURITY.md](SECURITY.md) | 凭据处理方式、禁止提交的内容、漏洞报告途径。 |
| [CONTRIBUTING](CONTRIBUTING.md) | 三个离线检查门以及变更所需的依据等级。 |
| [CHANGELOG.md](CHANGELOG.md) / [NOTICE](NOTICE) | 更新日志 / 关于如何处理参考客户端的说明。 |
运维脚本位于 `scripts/` 目录下。包括只读冒烟测试、保存原始响应、预约与取消往返验证,
以及用于解除挂起状态的 `recover_hold.py`。
## 许可证
Apache License 2.0 — [LICENSE](LICENSE), [NOTICE](NOTICE).
本项目与 SR(水西高速铁道)没有任何合作、认可或赞助关系,使用 "SRT" 仅为描述互操作性。
标签:API客户端, HTTP接口, Python, Web逆向, 交通出行, 恶意软件库, 无后门, 网络调试, 自动化, 运行时操纵, 逆向工具