1. 背景与问题#
香港中银(BOCHK)开户预约系统的可用名额释放毫无规律,有时在非工作时间批量放出,抢先一步的意义很大。手动刷新网页效率低、容易错过,而市面上也没有现成工具针对这套系统做自动化监控。
核心痛点在于:光知道"有位"还不够,开户需要锁定具体的分行、日期和时段,而名额放出后很快就被抢空。因此需要一个工具:定时轮询预约接口,一旦发现可预约名额立即深度查询出具体分行与时段,通过 Bark 推送到手机。
项目地址:github.com/xjoker/bochk_check,在线演示:bochk-check.onrender.com
2. 接口发现与抓包分析#
动手写代码之前,最重要的工作是搞清楚预约系统的接口结构。BOCHK 的预约入口是一个运行在微信内的 H5 页面,因此抓包的方式是在 Mac 上用 Proxyman 做中间人代理,手机微信配置代理后直接访问预约页面,流量全部过一遍代理。
初步发现接口链路#
抓包之后,页面操作一遍完整流程(选日期 -> 选时段 -> 选区域 -> 选分行),可以清晰地看到以下请求序列:
| |
这 6 个接口构成了完整的"日期优先"查询链路。仔细看还能发现另一条"分行优先"路径(先选分行再选日期),两条路径共用 continueInput.action 和 jsonBranchDetail.action。
解析响应结构#
确认接口之后,重点是理解各字段的语义。以最核心的 jsonAvailableDateAndTime.action 为例:
传 bean.appDate=(空)时返回全局日期配额:
| |
传 bean.appDate=12/03/2026 时返回单日时段:
| |
注意 dateTimeQuota 的 key 格式是 {slot_id}_{status},状态直接编码在 key 里。区域和分行列表里的 value 字段也是同样的模式:
| |
分行代码就是 _ 分割后去掉状态后缀的部分(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 并发执行,日期内各层串行(因为每层请求依赖上层数据):
| |
单日期内的串行链路:
| |
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 中查询,保证每个时段的"首次出现时间"准确追溯。
| |
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