跳过正文
  1. 全部文章/

用 Rust 构建 BOCHK 预约监控工具

目录

1. 背景与问题
#

香港中银(BOCHK)开户预约系统的可用名额释放毫无规律,有时在非工作时间批量放出,抢先一步的意义很大。手动刷新网页效率低、容易错过,而市面上也没有现成工具针对这套系统做自动化监控。

核心痛点在于:光知道"有位"还不够,开户需要锁定具体的分行、日期和时段,而名额放出后很快就被抢空。因此需要一个工具:定时轮询预约接口,一旦发现可预约名额立即深度查询出具体分行与时段,通过 Bark 推送到手机。

项目地址:github.com/xjoker/bochk_check,在线演示:bochk-check.onrender.com


2. 接口发现与抓包分析
#

动手写代码之前,最重要的工作是搞清楚预约系统的接口结构。BOCHK 的预约入口是一个运行在微信内的 H5 页面,因此抓包的方式是在 Mac 上用 Proxyman 做中间人代理,手机微信配置代理后直接访问预约页面,流量全部过一遍代理。

初步发现接口链路
#

抓包之后,页面操作一遍完整流程(选日期 -> 选时段 -> 选区域 -> 选分行),可以清晰地看到以下请求序列:

1
2
3
4
5
6
GET  continueInput.action                  <- 打开预约页面,建立会话
POST jsonAvailableDateAndTime.action       <- 加载日期列表
POST jsonAvailableDateAndTime.action       <- 选中某日期后,加载时段
POST jsonAvailableBrsByDT.action           <- 选中时段后,加载可用区域
POST jsonAvailableBrsByDT.action           <- 选中区域后,加载可用分行
GET  jsonBranchDetail.action               <- 切换分行时,加载分行详情

这 6 个接口构成了完整的"日期优先"查询链路。仔细看还能发现另一条"分行优先"路径(先选分行再选日期),两条路径共用 continueInput.actionjsonBranchDetail.action

解析响应结构
#

确认接口之后,重点是理解各字段的语义。以最核心的 jsonAvailableDateAndTime.action 为例:

bean.appDate=(空)时返回全局日期配额:

1
2
3
4
5
6
7
8
{
  "dateQuota": {
    "20260312": "A",
    "20260311": "F",
    "20260310": "F"
  },
  "eaiCode": "SUCCESS"
}

bean.appDate=12/03/2026 时返回单日时段:

1
2
3
4
5
6
7
8
{
  "dateTimeQuota": {
    "P01_F": "09:00",
    "P05_A": "14:00",
    "P09_D": "17:00"
  },
  "eaiCode": "SUCCESS"
}

注意 dateTimeQuota 的 key 格式是 {slot_id}_{status},状态直接编码在 key 里。区域和分行列表里的 value 字段也是同样的模式:

1
2
{ "messageCn": "西贡区",     "value": "_sai_kung_district_A" }
{ "messageCn": "日出康城",   "value": "952_A" }

分行代码就是 _ 分割后去掉状态后缀的部分(952),这个解析逻辑需要在 parser 里处理。

从前端 JS 确认状态码语义
#

A / F / D 三个状态码的含义不是从接口文档得到的(没有文档),而是通过对比前端脚本行为推断并验证:

  • A:可预约。前端将对应项渲染为可点击选项。
  • F:已满。前端渲染为灰色禁用项,并标注"已满"文案。
  • D:不可选。前端直接跳过,不渲染到界面上。

WHKEQR888 这个业务错误码也是在前端 JS 里找到的:前端会拦截 eaiCode == "WHKEQR888" 并弹出"操作逾时,请重新提交"的提示。这为后来的重试逻辑提供了依据。

理解会话机制
#

通过对比多次请求,发现 continueInput.action 的核心作用是给后续请求建立服务端会话上下文。如果跳过这一步直接发 POST,接口大概率返回 WHKEQR888。更关键的是:会话状态不是全局共享的,两个并发请求如果共用同一个 JSESSIONID,彼此的会话状态会互相干扰。这个结论直接影响了后来多日期并发查询的设计——每个日期必须独立建立会话。


3. 核心思路
#

整个系统的设计围绕两个核心判断展开:

第一个判断:有没有可预约日期?

先用一次轻量请求拉取 dateQuota。只有出现状态为 A 的日期才值得继续深挖,否则直接进入等待。

第二个判断:这次深挖结果完整吗?

深度查询链路有 6 层,每一层都可能因为 WHKEQR888 或网络问题中断。如果深挖不完整,宁可跳过这轮通知,等下一轮补完再推送,避免推送残缺信息。

通知策略选择按「快照差异」推送:将当前快照与上次通知时的快照做 diff,只在有实际变化时推送,并标注新增/消失的时段数量。


4. 技术选型与原理
#

语言与运行时
#

选 Rust 纯粹是因为想写 Rust。这个项目的规模用 Python 或 Go 都能搞定,但正好拿来练手 async Rust,顺便享受单二进制部署的便利。实际用下来,Arc<RwLock<T>> 在编译期就能抓住共享状态的并发问题,reqwest + tokio + axum 这套组合也足够成熟,写起来体验不错。

持久化
#

方案适用场景局限
内存(HashMap)零依赖重启即丢失历史快照
JSON 文件简单可读并发写不安全
SQLite (rusqlite)单机嵌入式、事务安全不适合多进程并发写

SQLite 用事务管理快照更新,WAL 模式保证读写并发,user_version pragma 做 schema 版本控制,版本不匹配时自动重建所有表,无需手动迁移。


5. 具体实现
#

5.1 会话管理
#

BOCHK 预约接口依赖 JSESSIONID 会话状态。每一轮监控都会重新请求 continueInput.action 初始化全新会话。深度查询阶段,每个可预约日期更会独立建立自己的 Client 和 session,彻底隔离会话状态——这是解决并发场景下 cookie store 互相污染的关键。

build_client 启用 cookie_store(true),让 reqwest 自动管理后续请求中的 Cookie,无需手动附加 JSESSIONID

5.2 深度查询链路
#

dateQuota 中出现 A 时,触发多层下钻。多个日期之间用 FuturesUnordered 并发执行,日期内各层串行(因为每层请求依赖上层数据):

1
2
3
4
5
6
7
8
9
// 每个日期独立并发,互不阻塞
let mut date_tasks: FuturesUnordered<_> = available_dates
    .iter()
    .cloned()
    .map(|date| {
        let proxy_url = proxy_url.to_string();
        async move { drill_down_date(&proxy_url, &date).await }
    })
    .collect();

单日期内的串行链路:

1
2
3
4
5
6
continueInput.action              <- 建立独立会话
  -> jsonAvailableDateAndTime     <- bean.appDate= 激活会话状态
  -> jsonAvailableDateAndTime     <- bean.appDate=DD/MM/YYYY 获取单日时段
  -> jsonAvailableBrsByDT         <- district 为空,获取可用区域列表
  -> jsonAvailableBrsByDT         <- 带 district,获取区域内可用分行
  -> jsonBranchDetail             <- 补全分行地址与电话

5.3 业务错误 WHKEQR888
#

WHKEQR888 在查询区域列表时偶发,原因是会话初始化后立即发起下一层查询,服务端状态尚未就绪。处理策略:首次遇到等待 250ms 重试一次。若重试仍失败,将该时段计入 soft_skipped_slots,整轮查询标记为不完整,跳过 Bark 通知,等下一轮再试。

5.4 分行联系信息两级缓存
#

每次深度查询获取分行列表后,需要通过 jsonBranchDetail.action 补全分行的中文地址和电话。为避免重复请求,使用两级缓存:当轮次内存 BTreeMap + SQLite branches 表持久化。只有两级都未命中时才发起 API 请求,命中后回写到 SQLite 供后续轮次复用。

5.5 SQLite 快照差异持久化
#

每轮完成后,对 (branch_code, date, time) 三元组做集合 diff,将新增(appeared)和消失(disappeared)的条目写入 slot_events 表,并用事务原子替换 current_slots 快照。first_seen_at 从历史 slot_events 中查询,保证每个时段的"首次出现时间"准确追溯。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- slot_events 记录每次变化事件
CREATE TABLE slot_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    event_at TEXT NOT NULL,
    event_type TEXT NOT NULL,   -- 'appeared' | 'disappeared'
    branch_code TEXT NOT NULL,
    appointment_date TEXT NOT NULL,
    appointment_time TEXT NOT NULL
);

-- current_slots 保存当前快照,含首次/最后捕获时间
CREATE TABLE current_slots (
    branch_code TEXT NOT NULL,
    appointment_date TEXT NOT NULL,
    appointment_time TEXT NOT NULL,
    first_seen_at TEXT NOT NULL,
    last_seen_at TEXT NOT NULL,
    PRIMARY KEY (branch_code, appointment_date, appointment_time)
);

5.6 智能调度(半点聚焦模式)
#

通过观察历史数据发现名额在每小时半点前后释放的频率更高,因此设计了 half_hour_focus 调度模式:每小时 25-35 分钟进入"聚焦窗口"以 10 秒轮询,平时 60 秒,深夜(凌晨 1-6 点)降为 180 秒,凌晨 0:00-0:05 用 5 秒间隔(推测为名额重置时刻)。

所有阈值均可通过 TOML 配置或环境变量覆盖,也支持切换为 fixed 固定间隔模式。


6. 端到端流程
#

flowchart TD
    A["启动程序"] --> B["加载配置(TOML + 环境变量)"]
    B --> C["从 SQLite 恢复运行时状态"]
    C --> D["启动 axum Web 服务(/api/status 等)"]
    D --> E["主监控循环"]
    E --> F["重建 reqwest::Client + 初始化会话"]
    F --> G["拉取 dateQuota"]
    G --> H{"存在状态=A 的日期?"}
    H -- 否 --> N["更新 Web 共享状态"]
    H -- 是 --> I["并发深度查询(每个日期独立会话)"]
    I --> J{"所有日期均完整查询?"}
    J -- 否(有 soft_skip) --> K["跳过本轮 Bark 通知"]
    J -- 是 --> L["与上轮快照做 diff"]
    L --> M{"快照有变化?"}
    M -- 是 --> O["发送 Bark 通知(标题含 +N / -M)"]
    M -- 否 --> P["记录无变化日志"]
    O --> Q["写入 SQLite(slot_events + current_slots)"]
    K --> N
    P --> Q
    Q --> N
    N --> R["按调度策略等待(聚焦/常规/深夜/凌晨)"]
    R --> E

请求失败时 fail_count 递增,达到 max_fail_count 阈值后推送 Bark 异常告警(含连续失败次数、最后错误、已运行时长)。之后每额外 10 次失败再次告警,避免告警风暴。恢复正常后归零。


7. 踩坑与解决
#

坑 1:WHKEQR888 导致深度查询静默失败
#

eaiCode: WHKEQR888 在区域查询时偶发,深度查询看似完成但实际未覆盖全部日期。根因是会话初始化后立即发起下一层查询,服务端状态尚未就绪。解决方案见第 5.3 节的 soft_skipped_slots 机制——宁可跳过一轮通知,也不推送不完整的结果。

坑 2:共享 Client 导致并发会话污染
#

多个日期共享一个 reqwest::Client 时,cookie store 中的 JSESSIONID 在并发请求下互相覆盖,接口返回旧会话数据。解决方法:每个日期独立调用 build_client()initialize_session(),彻底隔离。

坑 3:多段表格场景下 sticky 表头消失
#

Web 页面同时展示多个日期段时,部分表头 th 的时间列不显示。根因是 position: sticky 在多段表格场景下,sticky 元素的层叠上下文与父容器冲突,触发浏览器渲染异常。移除 th 的 sticky 样式即可解决。

坑 4:部署到 Render 后端口无法绑定
#

Render Web Services 通过 PORT 环境变量动态注入端口,程序默认用配置文件端口 32141 导致服务无法暴露。端口读取优先级调整为:PORT > BOCHK_WEB_PORT > TOML 配置 > 默认值,Web 服务绑定地址固定为 0.0.0.0


8. 结语
#

做这个项目的初衷很简单——自己需要开一个香港中银的户头,但预约名额实在难抢。与其每天手动刷页面碰运气,不如花点时间写个工具把整个流程自动化。最终靠这个监控工具在名额放出后几秒内收到推送,顺利约到了想要的分行和时段。

值得一提的是,这个项目的代码几乎全部由 Claude Code 完成。从 HTTP 客户端封装、会话隔离设计、SQLite 快照差异逻辑,到 axum Web 服务和 Bark 推送——我没有手写多少代码,更多的工作是在描述需求、评审输出、判断方向

这其实代表了一种正在发生的角色转变:开发者从"写代码的人"变成了 orchestrator(协调者)和 supervisor(监督者)。我的核心贡献不是敲出 Arc<RwLock<T>> 或调试 FuturesUnordered 的生命周期,而是抓包分析出接口链路、发现会话污染的根因、决定用快照差异而非全量推送——这些判断力和工程直觉才是真正不可替代的部分。

当 AI Agent 的能力足够强,“写代码"本身不再是门槛,每个人都能借助 AI 把想法变成可运行的软件。但知道该做什么、能判断做得好不好,仍然需要经验和思考。未来的开发者更像是导演而非演员——你不需要亲自演每一场戏,但你得知道这场戏该怎么拍。

项目已开源:github.com/xjoker/bochk_check,在线演示:bochk-check.onrender.com

相关文章