yakisoba0728/korail-mobile-api

GitHub: yakisoba0728/korail-mobile-api

KORAIL 移动应用 API 的 Python 客户端,提供列车查询、车票管理、预订支付等功能,并通过显式同意机制严格控制状态变更操作。

Stars: 0 | Forks: 0

# korail-mobile-api [![文档](https://img.shields.io/badge/%EB%AC%B8%EC%84%9C-yaki.kr-1f6feb)](https://yaki.kr/korail-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 中直接调用 KORAIL 应用使用的 API。执行登录、查询列车、 读取车票与预订信息。进行锁定座位或支付、退款等操作时,必须传入 consent 对象才会 执行。 📖 **文档: ** — 包含完整的 API 参考与示例。 ## 安装说明 尚未上传至 PyPI。可以直接从代码库中获取。 ``` python3 -m pip install "korail-mobile-api @ git+https://github.com/yakisoba0728/korail-mobile-api" ``` | 项目 | 值 | | --- | --- | | Python | 3.11+ | | 依赖项 | `httpx`, `cryptography` | | 类型提示 | 附带 `py.typed` | ## 快速开始 这里是一个只读示例。 ``` from korail_mobile_api import KorailClient, KorailConfig, TrainSearchQuery client = KorailClient(KorailConfig(enable_dynapath=True)) client.login("<회원번호·이메일·휴대폰번호>", "<비밀번호>") query = TrainSearchQuery("서울", "부산", "20260810", departure_time="080000") for train in client.search_trains(query).trains[:5]: print(train.train_no, train.departure_time, train.general_availability_name) client.logout() client.close() ``` 站点可以通过名称或代码传入。日期格式为 `YYYYMMDD`,时间格式为 `HHMMSS`,与 App 保持一致。下一页通过将 `result.next_page()` 传入 `search_trains(query, continuation=...)` 来获取。 ### DynaPath `x-dynapath-m-token` 是 App 的反自动化 header。**默认为关闭状态**,可以通过 `KorailConfig(enable_dynapath=True)` 开启。由于它是用于绕过自动化检测的值, 本包不负责决定是否发送。 如果未开启此功能即尝试登录,在发出请求前会触发 `KorailDynaPathRequiredError` 被拦截。 被拦截的只有 `login.Login`,只读操作无需 token 即可发出。开启后, 该 token 仅附加到 `DYNAPATH_ALLOWLIST_PATHS` 中的 6 个路径上。设备相关数值是合成的,如需使用真实设备参数,请通过环境变量传入,并使用 `KorailClient(build_config_from_env())` 进行构建。 ``` export KORAIL_DYNAPATH_DEVICE_ID="" export KORAIL_DYNAPATH_OS_VERSION="15" # Build.VERSION.RELEASE export KORAIL_DYNAPATH_DEVICE_MODEL="SM-S928N" # Build.MODEL ``` #### 这些值从哪里获取 通过 USB 调试连接设备,并使用 `adb` 读取即可。 | 环境变量 | 原始来源 | 命令 | | --- | --- | --- | | `KORAIL_DYNAPATH_DEVICE_ID` | `Settings.Secure.ANDROID_ID` | `adb shell settings get secure android_id` | | `KORAIL_DYNAPATH_OS_VERSION` | `Build.VERSION.RELEASE` | `adb shell getprop ro.build.version.release` | | `KORAIL_DYNAPATH_DEVICE_MODEL` | `Build.MODEL` | `adb shell getprop ro.product.model` | 自 Android 8 起,`ANDROID_ID` 会根据 App 的签名密钥而有所不同。`adb shell` 看到的值与 KORAIL App 看到的值是不同的。这对本库没有影响 — `di` 所需的 只是 16 位 hex 字符串,使用 env 路径的原因并非因为它是真实值,而是为了能跨进程保持 **稳定**。 #### App 版本与 API 版本不同 请求中携带的 `Version=250601003` 既不是 App 版本(`6.5.0`)也不是 versionCode(`60500002`), 而是一个独立的常量。它存在于 APK 内部,因此无法通过设备属性直接获取。 ``` adb shell dumpsys package com.korail.talk | grep versionName # 6.5.0 adb shell pm path com.korail.talk # APK 경로 adb pull <위 경로> unzip -p base.apk 'classes*.dex' | strings | grep -m1 'Device=AD&Version=' # Device=AD&Version=250601003&Key=korail1234567890 ``` 最后一行是附加到所有请求中的三个公共字段 — 分别对应 `KORAIL_DEVICE_ANDROID`、`KORAIL_API_VERSION` 和 `KORAIL_APP_KEY`。如果服务器 提高了最低版本要求,则需要在此处进行更新。 ### 与 srt-mobile-api 搭配使用时 有些名称相同但类型不同。如果同时使用两者,请使用别名进行 import。 | 名称 | korail | srt | | --- | --- | --- | | `TrainSearchQuery.passengers` | `int`, 默认为 `1` | `PassengerCounts` | | `TrainSearchQuery.departure_time` | `"000000"` | `"060000"` | | `DiscountCoupon` | `coupon_no`, `discount_values` | `coupon_number`, `discount_rate` | | `MutationCategory` | 7 个 | 5 个 (共有 4 个) | ## 能做些什么 边界内包含 60 个路由和 77 个公开方法。路由由 58 个只读路由加上 登录/登出组成,9 个变更路由不在只读白名单中。在所有方法中, 有 13 个变更方法需要通过 consent 验证,其余 64 个方法仅执行 登录/只读操作或完全不进行任何操作。 ### 读取 | 想做的事 | 方法 | | --- | --- | | 按日期查询列车 | `search_trains(query)` | | 没有直达时 | `search_trains_with_transfer_fallback(query)`, `search_transfer_trains(query)`, `get_transfer_stations(...)` | | 车厢与座位图 | `get_seat_cars(train)`, `get_seat_inventory(train, car_no)` | | 停靠站、运行日、站点列表 | `get_train_schedule(...)`, `get_train_calendar()`, `get_station_data()`, `get_station_info()` | | 车票、预订、购买历史 | `get_ticket_list()`, `get_ticket_reservation_detail(request)`, `get_reservation_history()`, `get_product_reservations(...)` | | 退票手续费、座位变更、原票查询 | `get_refund_commission(ticket)`, `get_refund_ticket_detail(ticket)`, `get_self_seat_change_info(request)`, `get_original_ticket_inquiry(tickets)` | | 积分、里程 | `get_korail_point_summary()`, `get_mileage_history(request)` | | N卡、定期票、豪华大巴 | `get_discount_card_usage_history(card_no)`, `get_pass_menu(menu_no)`, `get_pass_schedule(request)`, `get_limousine_schedules(query)`, `get_limousine_seat_inventory(query)`, `get_limousine_schedule_view(query)` | 有些功能无需登录即可使用,例如 `get_service_status()`, `get_app_data()`、 `get_notice()`, `get_uuid()`, `get_maas_menu_list()`。每个方法对应的路由详见 [docs/api-status-by-service.md](docs/api-status-by-service.md)。 ### 预订 一个 `reserve` 方法涵盖了预订界面的四种操作。通过 `job_type` 进行选择,若省略则 默认为不指定座位。乘客通过 `KorailPassengerCounts`(8种类型,最多总计9人)传入,座位等级 通过 `KorailSeatClass` 传入。 | `job_type` | `txtJobId` | 预订内容 | | --- | --- | --- | | `IMMEDIATE` (默认) | `1101` | 不指定座位 | | `SEAT_DESIGNATED` | `1103` | 指定座位 | | `STANDBY` | `1102` | 预订候补 — 需通过 `confirm_standby_hold` 完成 | | `MERGE_STANDING` | `1202` | 站票+座票合并预订的第一次 hold — 第二次通过 `reserve_merge` | 换乘预订通过 `reserve_transfer(legs, ...)`,持有折扣卡的乘客通过 `reserve_with_discount_card(train, card_no=...)` 预订。指定座位时存在一个陷阱 — 表单中发送的是 `KorailSeatAssignment.seat_no`,但服务器返回的是 `seat_spec`,因此必须使用 `seat_spec` 和 `h_seat_no` 进行比对。 ### 更改状态 | 方法 | consent | 功能说明 | | --- | --- | --- | | `cancel_unpaid_hold(hold, …)` | `cancel` | 释放支付前的 hold | | `pay_with_fake_card(hold, card, …)` | `payment` | 不实际扣费的测试卡 | | `pay_with_card(hold, card, …)` | `payment` | 实体卡。默认阻止 | | `refund(ticket, …)` | `refund` | 退还已支付的车票 | | `recalculate_price(request, …)` | `price_recalculation` | 重新计算折扣变更后 hold 的票价 | | `add_to_cart(request, …)` | `cart` | 将锁定的 PNR 加入购物车 | | `register_discount_card(request, …)` | `discount_card` | 购买 N卡 | | `extend_discount_card(ticket, …)` | `discount_card` | N卡有效期延长 | ### 虚拟候车室 `KorailNetFunnelClient` 用于处理 `nf.letskorail.com` 的排队队列。 **默认关闭。** 通过 `KorailConfig(netfunnel_enabled=True)` 开启,并在 `with queue.slot(inquiry_action(...))` 上下文中调用。虽然已验证了 握手与释放机制,但**排队路径从未在真实服务器上测试过。** ## 安全模型 不是依靠惯例,而是直接通过代码拦截,并由离线测试套件保证其有效性。 - 13 个变更方法全部以 `require_mutation_consent` 开始。没有关闭开关。 - 类别标志默认全为 `False`,因此获得预订授权的 consent 无法用于取消操作。 - `dry_run=True` 是默认设置。不进行网络通信,仅返回 `MutationPreview`,且 payload 会 经过 `redact_payload` 处理。 - 接触到变更路由的方法只有 `post_mutation_form`,并且会再次检查路由与类别是否匹配。 - 实际产生扣费的实体卡只能通过 `pay_with_card` 发送。必须**同时**设置 `real_card_acknowledged` 和 `fake_card_only=False`,如果只设置其中一个,发送门会拒绝执行。 ``` from korail_mobile_api import MutationConsent preview = client.reserve(train, consent=MutationConsent(allow_reserve=True)) preview.payload # 마스킹된 폼. 아무것도 나가지 않았습니다 hold = client.reserve(train, consent=MutationConsent(allow_reserve=True, dry_run=False)) client.cancel_unpaid_hold(hold, consent=MutationConsent(allow_cancel=True, dry_run=False)) ``` ## 错误处理 服务器失败是根据 App 实际进行分支处理的 `h_msg_cd` 来区分的。 不通过韩语文案进行判断。以下十余项是 `KorailAppError` 的子类型。 ### 错误分类 | 异常 | 代码 | 含义及下一步操作 | | --- | --- | --- | | `KorailNoResultsError` | `WRG000000`, `P114`, `P100`*, `WRT300005`* | 无结果。请求正常,请更改条件 | | `KorailNoDirectTrainError` | `WRD000061` | 无直达列车。可以尝试查询换乘 | | `KorailSoldOutError` | `ERR211161` | 已售罄。需要选择其他列车 | | `KorailSeatUnavailableError` | `WRI411345`, `ERR911081`, `WRT800176` | 不是列车问题,而是座位问题。可以尝试取消指定座位 | | `KorailReservationRefusedError` | `WRR800029`, `ERR911531`, `ERR911051` | 预订被拒绝。原因见 `message` | | `KorailInvalidRequestError` | `WRG200018`*, `WRT100002`*, `WRT100124`* | payload 错误。需要修正 | | `KorailNotEntitledError` | `ERR299943`* | 该账户无权购买该运价票 | | `KorailServiceUnavailableError` | `SEMGTK` | 后端服务宕机 | | `KorailAppUpdateRequiredError` | `SUPDATE` | 客户端版本被拒。与下文伪装的情况不同 | | `KorailAppError` | 其他 | 未分类。`code` 和 `raw` 保持原样 | | `KorailSessionExpiredError` | `P058` | 会话断开,需重新登录。不是 `KorailAppError` | | `KorailDynaPathError` | *(响应头)* | 被触发了标志,而非频率限制 | 如果只想查看映射而不抛出异常,可以使用 `classify_app_error`。带有 `*` 的项目并非通过 APK 分支判断,而是 在真实服务器上观测到的,具体哪些属于哪种情况,以及**一个故意未分类而保留下来的观测记录**详见 [docs/verification-record.md](docs/verification-record.md)。带有警告代码的成功响应**依然会被视为成功。** **本库不会自动重试。** 因为重试预订会导致重复预订。 ### 提示要求更新 App 但并非真正的版本问题时 如果登录失败并提示更新 App,通常不是版本问题。服务器对看起来不像 App 的客户端回复了 `MACRO ERROR`,然后向用户展示 *"为了顺利使用服务,请将应用更新至最新版本后..."。 真正的版本限制会拦截所有操作,因此可以通过检查 `get_app_data()` 能否成功但只有 `login` 失败, 并确认 `error.code` 是否为 `SUPDATE` 来进行区分。 ## 局限性 能够构造请求与服务器是否接受是两码事。各个操作的状态详见 [docs/MUTATION_HANDOFF.md](docs/MUTATION_HANDOFF.md)。 | 状态 | 对象 | | --- | ---| 已确认至往返 | 即时、指定座位、预订候补、站票+座票 hold、`confirm_standby_hold`、`cancel_unpaid_hold`、`add_to_cart`、未扣费即被拒绝的 `pay_with_fake_card` | | 收到业务响应 | `get_self_seat_change_info` → `WRT800176 座位变更不可用时间` | | 已实现但从未发送过 | `pay_with_card`、`refund`、`reserve_merge`、`recalculate_price`、折扣卡全部功能、`get_original_ticket_inquiry` | **换乘功能已实现但未在真实服务器上验证。** `search_transfer_trains` 和 `reserve_transfer` 从未实际发送过。如果在真实环境中进行换乘 hold, 如果没有准备好在 KORAIL App 中取消,请勿发送。 有些功能是故意不包含在内的。 - **身份证明文件提交** — 会发送身份证号片段,且无法进行验证。 - **携带密码的积分路由** — 如果错误会导致账号被锁。 - **定期票购买** — 价格为 15 万至 25 万韩元,但没有取消/退款路由。 - **旅行变更与回滚、预订人数变更** — 没有干净的撤销机制。 - **非会员线下退款、Check-in、会员信息变更** — 此版本中不存在。 - **乘务员呼叫** — `/classes/com.korail.mobile.push.callCrew.do` 始终被排除在 transport 白名单之外。 - **认证、NetFunnel、DynaPath 绕过,通用 WebView 自动化** — 永久超出范围。 ## 文档 | 文档 | 内容 | | --- | --- | | [文档站点](https://yaki.kr/korail-mobile-api/) | 将此 README 与 API 参考合并在一起 | | [docs/verification-record.md](docs/verification-record.md) | 证据记录。APK 引用、各执行代码、勘误 | | [docs/MUTATION_HANDOFF.md](docs/MUTATION_HANDOFF.md) | 变更表面的验证状态 | | [docs/api-status-by-service.md](docs/api-status-by-service.md) | 165 个 Retrofit 项按服务划分的状态 | | [docs/RELEASE.md](docs/RELEASE.md) | 发布门控 | | [docs/README.md](docs/README.md) | 文档总索引 | | [CHANGELOG.md](CHANGELOG.md) | 更新日志 | 测试门控命令为 `python3 -m pytest -q -m "not live"` 且不使用网络 — `2437 passed, 1 deselected`。被跳过的那个是只有在提供 `KORAIL_MOBILE_API_LIVE=1` 时才会运行的 真实服务器测试。贡献指南请参阅 [CONTRIBUTING.md](CONTRIBUTING.md),规范请参阅 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。 ## 许可证 Apache License 2.0 — [LICENSE](LICENSE), [NOTICE](NOTICE).
标签:API客户端, Python, 无后门, 第三方SDK, 网络调试, 自动化, 车票查询, 运行时操纵, 逆向工具, 韩国铁道