yakisoba0728/srt-mobile-api

GitHub: yakisoba0728/srt-mobile-api

一个韩国 SRT 高速铁道移动端 API 的 Python 客户端,支持列车查询与预订,并通过多层同意机制确保状态变更操作的安全性。

Stars: 0 | Forks: 0

# srt-mobile-api [![文档](https://img.shields.io/badge/%EB%AC%B8%EC%84%9C-yaki.kr-1f6feb)](https://yaki.kr/srt-mobile-api/) ![Python](https://img.shields.io/badge/python-3.11%2B-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green) 在 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逆向, 交通出行, 恶意软件库, 无后门, 网络调试, 自动化, 运行时操纵, 逆向工具