feat: 支持大湾区球场监控

This commit is contained in:
richarjiang
2026-09-23 19:49:59 +08:00
parent 9adc665ef4
commit d4f55a97dc
32 changed files with 3888 additions and 46 deletions

View File

@@ -0,0 +1,71 @@
---
name: tennis-booking
description: 通过 Pospal 公开接口访问范思伯特福中福球场,查询可订时段、识别连续场地、一次性锁场并把待支付结果回传给用户。协议、URL、Header、请求体、响应体全部以明文写在 references/protocol.md;agent 自己负责 HTTP 调用、轮询节奏、去重和用户交互;helper 脚本 scripts/pospal.mjs 只做解析、连续组合识别、锁场请求体构造、响应脱敏。用户提到网球场预约、抢场、捡漏、空场监控、PSPLVISITORID、自动锁场、范思伯特福中福、Pospal、wxservice-stg48 时即使不点名 Skill 也应使用。只处理用户本人有权使用的 PSPLVISITORID,绝不把访客凭证写入 Skill、日志或对话。
compatibility: Linux / macOS;Node.js 22+(自带 fetch 与 ESM);仅依赖系统 curl 或 Node fetch;无 Electron、无 daemon、无守护进程
---
# Tennis Book Pospal 直连
面向 LLM agent 的球场预约 skill。把 Pospal 接口契约完整暴露在文档里,agent 自己用任何 HTTP 工具发请求;helper 脚本负责 JSON 解析、连续组合识别、锁场请求体构造、响应脱敏指纹。
不持久化任何状态,不启动后台进程,不绕过协议做事。`PSPLVISITORID` 必须在首次写操作前由 agent 向用户索取(隐藏式输入),session 内存缓存,绝不落盘。
## 目录约定
- `references/protocol.md` ⭐ 完整明文协议规范:端点、Header、请求体、响应体、状态码、字段含义、错误分类、curl 示例。
- `references/current-logic.md`:从 desktop 适配器提炼出的稳定业务规则——连续场地判定、状态解释、错误分类、脱敏指纹。
- `references/operations.md`:agent 在 Linux/macOS 上怎么调用 helper、怎么向用户隐藏式索取 PSPLVISITORID、怎么轮询、怎么处理失败。
- `references/courts/fansibote-fuzhong.md`:单一已接入球场的常量、别名和专属说明;详细端点指向 `protocol.md`。
- `scripts/pospal.mjs`:Node 22 ESM 助手。提供 `parse-availability` / `find-continuous` / `build-booking-body` / `parse-booking-response` / `fingerprint` / `redact` 子命令。
- `evals/evals.json`:行为级断言(不访问真实写接口)。
- `evals/fixtures/`:离线 fixture,用于跑 helper 子命令做冒烟验证。
## 必读顺序
1. 收到任何预约 / 查询 / 监控请求后,先读 `references/protocol.md` 拿到完整接口契约。
2. 涉及具体球场时再读 `references/courts/fansibote-fuzhong.md`。
3. 涉及业务规则(连续场地、状态解释、错误码)读 `references/current-logic.md`。
4. 涉及调用方式(helper 子命令、env、隐藏输入)读 `references/operations.md`。
5. **永远不要**把 `PSPLVISITORID` 写入 Skill 文件、日志、对话或 git 历史。
## 核心工作流
### 只读查询
```
agent: 读 protocol.md → curl/Node fetch 调 list 端点 → pipe raw JSON 到
pospal.mjs parse-availability → 输出可订时段
```
### 锁场
```
agent: 查询 → parse-availability → find-continuous --start HH --end HH
→ 若需凭证:向用户隐藏式索取 PSPLVISITORID
→ 用 helper build-booking-body 生成请求体
→ curl 调 book 端点
→ pipe raw response 到 parse-booking-response,输出待支付摘要
→ 把摘要(不含 paySign)告诉用户去小程序支付
```
### 释放
```
agent: 调 release 端点(body 必须是 `{}`),需要 PSPLVISITORID
```
## 失败与边界
- HTTP 非 2xx:按 `protocol.md` 的错误码语义解释。
- 解析失败或网络超时:告知用户"结果不确定",**禁止重试**,必须人工去小程序核实。
- 已发起锁场但响应不确定:禁止再次下单同一时段,提示用户去小程序。
- 支付脚本(`script.paySign` 等)只在当次锁场响应里出现一次,绝不打印、绝不缓存、绝不落盘。
## 完成标准
- `node scripts/pospal.mjs --help` 正常输出子命令列表。
- `node scripts/pospal.mjs parse-availability --date 2026-07-16 < evals/fixtures/availability-sample.json` 输出结构化 JSON。
- `node scripts/pospal.mjs find-continuous --start 19 --end 21` 能识别 fixture 中的连续组合。
- `node scripts/pospal.mjs build-booking-body < evals/fixtures/continuous-slots.json` 输出符合 `protocol.md` 的请求体。
- `node scripts/pospal.mjs fingerprint <visitor-id>` 输出 12 字符 hex。
- `evals/evals.json` 中所有断言成立(agent 不绕过 helper 自行拼请求体、不打印 PSPLVISITORID 原值、不在结果不确定时重试)。

View File

@@ -0,0 +1,80 @@
{
"skill_name": "tennis-booking",
"evals": [
{
"id": 1,
"prompt": "看下范思今天 19-21 是不是有连续空场,告诉我场地和总价。",
"expected_output": "agent 读 references/protocol.md 后调 list 端点,把响应 pipe 到 pospal.mjs parse-availability,再用 find-continuous --start 19 --end 21;不调用 daemon、不调 build-booking-body、不索取 PSPLVISITORID;返回时给出场地名、时间、总价(元)、且不打印 PSPLVISITORID。",
"assertions": [
"agent 调 list 端点用只读 Header(不带 PSPLVISITORID),并用 helper parse-availability 解析",
"用 find-continuous --start 19 --end 21 识别完整连续组合",
"未触发 PSPLVISITORID 索取(只读操作)",
"回复包含场地名、起止时间、总价(元)",
"全程不出现 PSPLVISITORID 原值"
],
"files": [
"evals/fixtures/availability-sample.json"
]
},
{
"id": 2,
"prompt": "帮我锁范思今天 19-21 点的场,锁好就告诉我订单号。",
"expected_output": "agent 在写操作前用隐藏式输入向用户索取 PSPLVISITORID(绝不打印原值),存到 env;调 list 端点实时复核 → parse-availability → find-continuous → build-booking-body → 调 SaveVenueAppointmentV2 → parse-booking-response;只告诉用户 apptUid 和到期时间,绝不打印 paySign;锁场成功后告诉用户去小程序支付。",
"assertions": [
"写操作前用 read -s 或等价的隐藏式输入向用户索取 PSPLVISITORID",
"PSPLVISITORID 通过 env 注入 helper,不出现在命令行参数或进程列表",
"锁场前重新查一次 list 端点做实时复核(不信任旧缓存)",
"用 helper build-booking-body 构造请求体,不手写 JSON",
"classroomItems 包含完整连续组合(19-20 + 20-21),不拆单",
"锁场响应只暴露 apptUid、expireTime、amountCents;script.paySign 绝不出现在对话或日志",
"明确告诉用户去小程序完成支付,不在 agent 内做任何支付动作"
],
"files": [
"evals/fixtures/availability-sample.json"
]
},
{
"id": 3,
"prompt": "以后每天帮我盯着范思 19-21 点,发现可订就通知我。",
"expected_output": "agent 自己实现轮询循环(无 daemon),每 10 秒查一次未来 7 天;用 find-continuous 判断完整连续组合;维护去重集合,持续可订不重复通知,消失后重现可再次通知;不索取 PSPLVISITORID(只读);轮询节奏遇整点绕过冷却立即查一次,但同一 date 并发只允许一个在飞。",
"assertions": [
"agent 自己实现 sleep 循环,不启动任何后台进程或 systemd 服务",
"维护去重集合(key 包含 txtClassroomUid + 日期 + 起止整点 + 总价)",
"持续可订不重复通知;消失后重现可再次通知",
"同一 date 的并发查询只允许一个在飞",
"整点(如 19:00:00.000)绕过冷却立即查一次",
"不索取 PSPLVISITORID(只读监控)"
],
"files": []
},
{
"id": 4,
"prompt": "范思这个球场的接口长什么样?",
"expected_output": "agent 直接读 references/protocol.md,把端点、Header、请求体、响应体、状态码、错误分类、curl 示例全部复述给用户;不藏任何 URL、不藏任何字段。",
"assertions": [
"提到 base URL https://wxservice-stg48.pospal.cn",
"列出三个端点:LoadValidClassRoomApptSettingV2 / SaveVenueAppointmentV2 / CrmAutoReleaseVenueAppoint",
"说明只读 / 写 Header 差异(PSPLVISITORID、User-Agent、Sec-Fetch-*)",
"说明请求体字段(dateTime / userId / projectUid / classroomItems / combinationPayments)",
"说明响应体字段(slots / enrollSlots / apptUid / script)以及 script.paySign 必须剥离",
"说明错误分类(success / rejected / uncertain)和重试边界",
"未泄露 PSPLVISITORID 原值"
],
"files": []
},
{
"id": 5,
"prompt": "帮我看下范思今天 19-20 这一小时有没有空。",
"expected_output": "agent 用 find-continuous --start 19 --end 20;只读,不索取 PSPLVISITORID;如果该小时可订,告知场地和单价;如果已订/排课/被活动覆盖,告知原因。",
"assertions": [
"用 find-continuous --start 19 --end 20(不是 --end 21)",
"不索取 PSPLVISITORID",
"回复覆盖四种情况之一:可订(场地+单价)、已订、排课、活动覆盖",
"不输出预约人姓名 / 手机号原值(按 references/current-logic.md 脱敏规则)"
],
"files": [
"evals/fixtures/availability-sample.json"
]
}
]
}

View File

@@ -0,0 +1,108 @@
{
"successed": true,
"result": {
"slots": [
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 08:00:00",
"endDatetime": "2026-07-16 08:59:00",
"cost": 80,
"status": 0,
"apptInfo": {
"canApptOrNot": true,
"customerNames": [],
"hadVenueAppts": []
}
},
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 09:00:00",
"endDatetime": "2026-07-16 09:59:00",
"cost": 90,
"status": 0,
"apptInfo": {
"canApptOrNot": true,
"customerNames": [],
"hadVenueAppts": []
}
},
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 19:00:00",
"endDatetime": "2026-07-16 19:59:00",
"cost": 100,
"status": 0,
"apptInfo": {
"canApptOrNot": true,
"customerNames": [],
"hadVenueAppts": []
}
},
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 20:00:00",
"endDatetime": "2026-07-16 20:59:00",
"cost": 100,
"status": 0,
"apptInfo": {
"canApptOrNot": true,
"customerNames": [],
"hadVenueAppts": []
}
},
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 21:00:00",
"endDatetime": "2026-07-16 21:59:00",
"cost": 80,
"status": 0,
"apptInfo": {
"canApptOrNot": true,
"customerNames": [],
"hadVenueAppts": []
}
},
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 22:00:00",
"endDatetime": "2026-07-16 22:59:00",
"cost": 80,
"status": 1,
"apptInfo": {
"canApptOrNot": false,
"customerNames": ["王小球"],
"hadVenueAppts": [
{ "customerName": "王小球", "customerTel": "13800000000" }
]
}
},
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 23:00:00",
"endDatetime": "2026-07-16 23:59:00",
"cost": 80,
"status": 4,
"apptInfo": {
"canApptOrNot": false,
"customerNames": [],
"hadVenueAppts": []
}
}
],
"enrollSlots": []
}
}

View File

@@ -0,0 +1,38 @@
# 范思伯特福中福
- `courtId`:`fansibote-fuzhong`
- 名称别名:`范思伯特福中福`、`范思`、`福中福`
- 提供方:Pospal 微信小程序(`wxservice-stg48.pospal.cn`)
- 已接入适配器:仓库内 `src/main/adapters/fansibote-fuzhong/adapter.ts`(仅供 desktop 应用回放,skill 不依赖)
- skill helper:`scripts/pospal.mjs` 硬编码为本球场常量
## 球场常量(公开,写在协议里)
| 常量 | 值 |
|---|---|
| `STOREID`(Header) | `6038652` |
| `userId`(Body,数字) | `6038652` |
| `PROJECT_UID` | `1775556129325750199` |
| 微信号小程序 appid | `wx2088ca46b47d8be7` |
完整协议契约(端点、Header、请求体、响应体、状态码、错误分类)见 `references/protocol.md`,**本文件不重复**——agent 直接读 `protocol.md`。
## 用户提供的球场页面链接
仅用于"确认是这家球场"。agent 收到 `https://servicewechat.com/wx2088ca46b47d8be7/...` 类的链接时识别为本球场。链接中的 `appid` / `path` / `query` 参数不参与请求构造。
## 状态解释(球场专属)
- `canApptOrNot=true` 才计入可订
- `status=1` 已订、`status=4` 排课
- `enrollSlots` 覆盖的报名活动按已订处理,**不计入可订数量,不得触发自动锁场**
- `txtClassroomUid` 必须保持字符串,数值可能超过 JS 安全整数范围
## 写操作约束
- 列表请求**不**携带 `PSPLVISITORID`
- 锁场和释放**必须**携带 `PSPLVISITORID`
- 锁场一次提交完整连续组合的全部 `classroomItems`,不得拆单
- 锁场响应中的 `script` 对象(`paySign` 等)**绝不**打印、落盘、转发
- 锁场成功只是待支付,用户需去小程序完成支付
- 释放接口针对当前访客的全部锁场,agent **不主动调用**,除非用户明确说"释放我刚锁的那一笔"

View File

@@ -0,0 +1,108 @@
# 业务规则(agent 自行落地的稳定约束)
不依赖 Electron、不依赖任何 daemon。下列规则从 `src/main/adapters/fansibote-fuzhong/adapter.ts` 提炼,agent 在做任何写操作前必须自己执行这些校验。
## 时间块归一
slot 的 `endDatetime` 形如 `HH:59:00`,代表该 slot 占据"开始时间所在整点"整块。
- `08:00-08:59` = 8 点这一小时块
- `19:00-19:59` = 19 点这一小时块
- 连续组合判定时把 slot 投影到 `[beginHour, endHour)`,按整点对齐
- `helper find-continuous` 内部已实现该归一,agent 不必手算
## 连续组合判定
判定"19-21 点完整连续组合"的步骤(agent 也可以直接调 `pospal.mjs find-continuous`):
1. 过滤出 `status in {available, limited}` 且无 enrollment 覆盖的 slots
2. 按 `txtClassroomUid` 分组
3. 在每个分组内按 `beginDatetime` 升序排序
4. 在每个分组内滑窗 `[targetStartHour, targetEndHour)`:
- 窗口第一个 slot 的 `beginHour == targetStartHour`
- 窗口最后一个 slot 的 `endHour == targetEndHour`
- 相邻 slot 整点连续(`prev.endHour == cur.beginHour`,无空缺)
- 全部同一 `txtClassroomUid`
- 全部不在 enrollment 覆盖区间
5. 任一不满足即视为无完整连续组合
**整段"19-21 点" ≠ "19-20 和 20-21 分别可订"**——必须是同一场地、连续、无活动、可订。
## 写操作前实时复核
锁场前必须**重新查询**一次目标日期的列表,不信任之前缓存的:
- 全部目标 slot 仍 `available` / `limited`
- 全部目标 slot 无 enrollment 覆盖
- 全部 slot 仍同一 `txtClassroomUid`
- 全部 slot 仍按目标时间整点连续
- 总价仍等于 `sum(cost)`,单个 cost 未变
- 日期未变
任一不符则放弃本次锁场,**不重试**。
## 错误分类与重试边界
| 现象 | 分类 | 处置 |
|---|---|---|
| HTTP 2xx + `successed: true` | success | 一次性操作,session 结束本次写 |
| HTTP 2xx + `successed: false` | rejected | 可换时段重试(同一会话同一时段最多 1 次) |
| HTTP 4xx | rejected | 同上 |
| HTTP 5xx | uncertain | **不重试**,告诉用户去小程序核实 |
| 网络错误 / 超时 | uncertain | 同上 |
| JSON 解析失败 | uncertain | 同上 |
**绝不在结果不确定时重试。** 已经发起的锁场,agent 必须假设服务端可能已经接受,必须提示用户去小程序核对。
## 写操作幂等与熔断
- 同一 session 中:
- 同一时段只允许一次"明确失败 → 换时段重试"
- 任何 uncertain 后立即停止本 session 的写操作,等用户去小程序确认
- 锁场成功后,session 内禁止再对该用户发起任何写操作;告诉用户去小程序支付
- 锁场成功后 `script.expireTime` 已过:锁场已失效,告知用户
## 脱敏与日志
- 日志中**禁止**出现:`PSPLVISITORID` 原值、完整请求头、完整响应、支付签名、Cookie、预约人姓名/手机号
- 访客凭证指纹:`pospal.mjs fingerprint <visitor-id>` → 12 字符 hex,仅用于日志关联
- 响应脱敏:`pospal.mjs redact < raw.json` → 移除 `script.paySign`、所有 `PSPL*` 头、任何 `customerName/customerTel`,输出可记录版本
- 待支付摘要只保存:`apptUid`、`venueAppointUids`、`expireTime`、金额、场地名、时间;**绝不**保存 `script` 对象
## 重复通知与去重
agent 自己实现轮询时的去重:
- 维护"已知完整连续组合"集合,key = `txtClassroomUid + 日期 + 起止整点 + 总价`
- 只对"本轮新出现"且"相对上一轮状态变化"的组合产生通知
- 持续可订不重复通知
- 消失后重现可以再次通知
- 同一组合的多次出现合并为一条通知
## 轮询节奏
- 默认每 10 秒一轮,由 agent 自行 sleep 实现
- 整点(如 `19:00:00.000`)到达时绕过冷却立即查一次,但仍要遵守"同一 date 的并发查询只允许一个在飞"
- 同一球场的写操作天然串行(锁场 / 释放 不并发);同一 session 不允许同时发起两笔锁场
## PSPLVISITORID 处理
- agent 在首次需要写操作时向用户索取,使用隐藏式输入:
- bash: `read -rs PSPAL_VISITOR_ID`
- Node: `readline` 关闭回显
- 任何等价手段
- 输入后只在 session 内存保存为 `process.env.POSPAL_VISITOR_ID`,绝不落盘
- session 结束或被新 session 替换时立即 `unset POSPAL_VISITOR_ID`
- helper 子命令只从 env 或 `--visitor` 参数读取;**优先用 env**避免值出现在进程列表
- 任何情况下都不得在日志、对话、git、临时文件中出现 `PSPLVISITORID` 原值
- 调试用指纹:`pospal.mjs fingerprint "$POSPAL_VISITOR_ID"` → 12 字符 hex
## 不允许做的事
- ❌ 用任何形式的"自动支付"或"自动点确认"
- ❌ 缓存 `script` 对象 / `paySign`
- ❌ 把 `PSPLVISITORID` 写入 Skill 文件、`.env`、配置、日志、对话
- ❌ 改写 `STOREID` / `userId` / `PROJECT_UID`(除非接口契约明确变更)
- ❌ 在结果不确定时重试
- ❌ 拆单(把"19-21"拆成"19-20"和"20-21"两次请求)
- ❌ 静默忽略字段缺失

View File

@@ -0,0 +1,152 @@
# agent 调用方式
## 必备工具
- HTTP 客户端:agent 自带 `web_fetch` / `curl` / Node 22 `fetch` 任一
- Node 22:执行 `scripts/pospal.mjs`(Linux / macOS 自带 `node` 或 NVM)
- 隐藏式输入:Linux/macOS `bash read -rs`、或 Node `readline` 关闭回显
- 单次请求硬性超时:`AbortSignal.timeout(12000)`(12 秒)
## 凭证管理
### 首次获取(agent 主动询问)
```bash
printf "PSPLVISITORID: " >&2
IFS= read -rs PSPAL_VISITOR_ID
printf "\n" >&2
```
旧版 `bash` 不支持 `-s` 时:
```bash
stty -echo
printf "PSPLVISITORID: " >&2
read PSPAL_VISITOR_ID
stty echo
printf "\n" >&2
```
### 注入到 helper 调用
```bash
export POSPAL_VISITOR_ID="$PSPAL_VISITOR_ID"
node scripts/pospal.mjs build-booking-body < continuous-slots.json
```
helper 也支持 `--visitor <value>`,但**优先用 env**——避免值出现在进程列表 (`ps aux`)。
### Session 结束
```bash
unset POSPAL_VISITOR_ID
```
新 session 必须重新索取,不允许持久化。
## helper 子命令
| 子命令 | 作用 | 何时用 |
|---|---|---|
| `parse-availability` | 把 list 端点原始 JSON 转结构化 slots(含状态判定、活动合并) | 每次拿到 list 响应后必跑 |
| `find-continuous` | 从 slots 中找符合 `--start HH --end HH` 的完整连续组合 | 拿到结构化 slots 后 |
| `build-booking-body` | 从连续组合生成锁场请求体 | 锁场前 |
| `parse-booking-response` | 解析锁场响应,**剥离 `script.paySign`** | 锁场后 |
| `fingerprint` | 访客凭证 → 12 字符 hex 指纹 | 日志关联 |
| `redact` | 任意 JSON → 移除敏感字段 | 落盘或汇报前 |
所有子命令从 stdin 读 JSON(除 `fingerprint`),都支持 `--help`。
## 工作流示例
### 单次查询
```bash
# 1. 调 list 端点
RAW=$(curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2' \
-H 'Accept: */*' \
-H 'APPTYPE: 1' \
-H 'Content-Type: application/json' \
-H 'PSPLVISITORAUTO: API' \
-H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html' \
-H 'STOREID: 6038652' \
-H 'VERSIONINFO: NC|2026.07.03' \
-d '{"dateTime":"2026-07-16","userId":6038652,"projectUid":"1775556129325750199"}')
# 2. 解析
echo "$RAW" | node scripts/pospal.mjs parse-availability --date 2026-07-16
# 3. 找连续组合
echo "$RAW" | node scripts/pospal.mjs parse-availability --date 2026-07-16 \
| node scripts/pospal.mjs find-continuous --start 19 --end 21
```
### 锁场
```bash
# 1. 重新查一次(不复用旧缓存)
RAW=$(curl -sS -X POST '...' -d '...')
# 2. 解析 + 找连续 + 构造请求体
BODY=$(echo "$RAW" | node scripts/pospal.mjs parse-availability --date 2026-07-16 \
| node scripts/pospal.mjs find-continuous --start 19 --end 21 \
| node scripts/pospal.mjs build-booking-body)
# 3. 调锁场
RESP=$(curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/SaveVenueAppointmentV2' \
-H "PSPLVISITORID: $POSPAL_VISITOR_ID" \
-H @write-headers.txt \
-d "$BODY")
# 4. 解析并脱敏
echo "$RESP" | node scripts/pospal.mjs parse-booking-response --slots continuous-slots.json
```
`write-headers.txt` 模板见 `references/protocol.md` 末尾。
### 监控循环(agent 自行实现)
```
for date in today, today+1, ..., today+6:
slots = curl list(date) → parse-availability
cont = find-continuous(slots, target_start, target_end)
key = (date, cont.txtClassroomUid, cont.startTime, cont.endTime, sum_price)
if cont and key not in seen:
notify(cont)
seen.add(key)
elif not cont and key in seen:
notify("removed: {key}")
seen.discard(key)
sleep 10
```
整点(如 `19:00:00.000`)agent 主动绕过冷却立即查一次。同一 date 的并发查询只允许一个在飞。
## 失败处理速查
| 现象 | agent 动作 |
|---|---|
| 列表返回 `successed: false` | 视为该日无可订数据,不重试 |
| 列表返回缺字段 | 视为接口变更,停止对该球场所有操作,告诉用户"接口字段变化,暂停服务" |
| 锁场 `successed: false` | 换时段最多重试 1 次,仍失败则停下 |
| 锁场 HTTP 5xx / 网络错误 | 不重试,告诉用户"结果不确定,请去小程序核实" |
| 锁场后 `script.expireTime` 已过 | 锁场已失效,告知用户 |
| `enrollSlots` 含目标时段 | 该时段按已订处理,不计入连续组合 |
| `PSPLVISITORID` 未索取 | 写操作前必须先向用户隐藏式索取;缺则停下 |
## 调试与脱敏
```bash
# 任何写操作前,确认 visitor 指纹
node scripts/pospal.mjs fingerprint "$POSPAL_VISITOR_ID"
# → "a3f9b21c0e4d" (12 字符)
# 落盘任何响应前必须 redact
curl ...list... | node scripts/pospal.mjs redact > list-redacted.json
# 看 helper 自检
node scripts/pospal.mjs parse-availability --date 2026-07-16 \
< evals/fixtures/availability-sample.json
```
完整 `list-headers.txt` / `write-headers.txt` 模板见 `references/protocol.md` 末尾的 curl 示例。

View File

@@ -0,0 +1,367 @@
# Pospal 范思伯特福中福 接口契约(明文)
Pospal 微信小程序后端的"场地预约"模块。下面的所有内容都是公开接口契约,没有内部秘密。**唯一不在此文档里的是用户级访客凭证 `PSPLVISITORID`**——它必须由 agent 在 session 内通过隐藏式输入向用户索取,绝不进入任何文件、日志或对话。
## 基础
- Base URL:`https://wxservice-stg48.pospal.cn`
- 全部端点都是 `POST`,`Content-Type: application/json`
- 单次请求超时建议 12 秒(agent 自行实现时建议硬性 `AbortSignal.timeout(12000)`)
- 响应:JSON 顶层是 `{ successed, status?, errorCode?, message?, result? }`
## 球场常量(写死在协议里)
| 常量 | 值 | 用途 |
|---|---|---|
| `DEFAULT_VENUE_ID`(数字) | `6038652` | 请求体 `userId` 字段 |
| `STOREID`(Header,字符串) | `"6038652"` | HTTP Header |
| `PROJECT_UID` | `"1775556129325750199"` | 请求体 `projectUid` 字段 |
| 小程序 appid | `wx2088ca46b47d8be7` | 仅用于 `Referer` 识别 |
这三个是球场/项目级别的公开标识,所有请求都固定。**不允许 agent 改写,也不允许从用户输入派生**。
## 通用只读 Header
```http
Accept: */*
APPTYPE: 1
Content-Type: application/json
PSPLVISITORAUTO: API
Referer: https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html
STOREID: 6038652
VERSIONINFO: NC|2026.07.03
```
只读请求**不携带** `PSPLVISITORID`、不携带 `User-Agent`、不携带 `Sec-Fetch-*`、`xweb_xhr`。
## 通用写 Header
```http
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
APPTYPE: 1
Content-Type: application/json
PSPLVISITORID: <从用户隐藏式输入>
Referer: https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
STOREID: 6038652
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193
VERSIONINFO: NC|2026.07.13
xweb_xhr: 1
```
写操作(锁场、释放)**必须**携带 `PSPLVISITORID`。User-Agent 是 Pospal 用来识别"来自微信小程序"客户端的,**不要**替换为 `curl/7.x` 之类,否则可能直接被拒。
---
## 1. 查询可订时段
`POST /wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2`
### 请求体
```json
{
"dateTime": "YYYY-MM-DD",
"userId": 6038652,
"projectUid": "1775556129325750199"
}
```
- `dateTime`:YYYY-MM-DD。agent 一次只查一天;多日要循环。
- `userId`:数字(与 `STOREID` 同源,但请求体里是 number)。
- `projectUid`:字符串。
### 响应体
```json
{
"successed": true,
"result": {
"slots": [
{
"classroomUid": 1775556146897330236,
"txtClassroomUid": "1775556146897330236",
"classRoomName": "1号风雨场",
"beginDatetime": "2026-07-16 08:00:00",
"endDatetime": "2026-07-16 08:59:00",
"cost": 80,
"status": 0,
"apptInfo": {
"canApptOrNot": true,
"customerNames": ["王小球"],
"hadVenueAppts": [
{ "customerName": "王小球", "customerTel": "13800000000" }
]
}
}
],
"enrollSlots": [
{
"txtClassroomUid": "1775556146897330236",
"beginDatetime": "2026-07-16 08:00:00",
"endDatetime": "2026-07-16 09:59:00",
"enrollmentRule": {
"uidTxt": "1784027073124705081",
"title": "【2.5双打】福中福8-10",
"beginDate": "2026-07-16 08:00:00",
"endDate": "2026-07-16 10:00:00",
"enrollmentEndDate": "2026-07-16 07:00:00",
"enrollmentFee": 70,
"enrollmentCount": 3,
"enrollmentLimitNum": 4,
"customerModels": [{ "name": "星" }, { "name": "浩哥" }]
}
}
]
}
}
```
### 字段语义
#### `result.slots[i]`
| 字段 | 类型 | 含义 |
|---|---|---|
| `txtClassroomUid` | string | **场地 ID,必须保持字符串**(数值可能超过 JS 安全整数)。后续所有引用都用这个字段。 |
| `classRoomName` | string | 场地展示名,如 `"1号风雨场"` |
| `beginDatetime` | string (`YYYY-MM-DD HH:MM:SS`) | 时段起点 |
| `endDatetime` | string | 时段终点(秒位是 `:59`) |
| `cost` | number(元) | 单价 |
| `status` | number | `0` 可订/不可订看 `canApptOrNot`;`1` 已订;`4` 排课;其他值按不可订处理 |
| `apptInfo.canApptOrNot` | boolean | 仅当 `status=0` 时有定义 |
| `apptInfo.customerNames` | string[] | 已订场地的预约人姓名 |
| `apptInfo.hadVenueAppts` | `[{customerName, customerTel}]` | 已订场地的预约人完整信息 |
`customerNames` / `hadVenueAppts` 里的姓名/手机号**只在解析时段状态时使用**,不得写入日志、不得转给用户以外的人。
#### `result.enrollSlots[i]`
| 字段 | 类型 | 含义 |
|---|---|---|
| `txtClassroomUid` | string | 与 slot 同一坐标系 |
| `beginDatetime` / `endDatetime` | string | 活动覆盖区间 |
| `enrollmentRule.uidTxt` | string | 活动 ID |
| `enrollmentRule.title` | string | 活动标题 |
| `enrollmentRule.beginDate` / `endDate` | string | 活动完整起止 |
| `enrollmentRule.enrollmentEndDate` | string | 报名截止时间 |
| `enrollmentRule.enrollmentFee` | number(元) | 报名费 |
| `enrollmentRule.enrollmentCount` | number | 已报名数(优先用) |
| `enrollmentRule.ParticipateCount` | number | 已报名数(备用字段,接口两种都出过) |
| `enrollmentRule.enrollmentLimitNum` | number | 名额上限 |
| `enrollmentRule.customerModels` | `[{name}]` | 已报名名单 |
### 时段状态判定规则
```
enrollSlot 覆盖该 slot(beginDatetime ≤ enroll.endDatetime 且 endDatetime ≥ enroll.beginDatetime)
→ status = "booked", unavailableLabel = "已订"
status == 1
→ status = "booked", unavailableLabel = "已订"
status == 4
→ status = "blocked", unavailableLabel = "排课"
status == 0 && apptInfo.canApptOrNot == true
→ status = "available"
status == 0 && apptInfo.canApptOrNot == false
→ status = "limited"(库存紧张,仍可订;可计入连续组合)
其他 status
→ status = "blocked"
```
**enrollSlot 永远优先于 slot 自身的 status**。报名活动覆盖的时段按"已订"处理,**不得计入可订数量,也不得触发自动锁场**。
### 时间块归一
`endDatetime` 的秒是 `:59`,表示该 slot 占据"开始时间所在整点"整块,不延伸到下一小时。
- `08:00-08:59` = 8 点这一小时块
- `19:00-19:59` = 19 点这一小时块
- 连续组合判定时把 slot 投影到 `[beginHour, endHour)`,按整点对齐
### 错误响应
```json
{ "successed": false, "status": 400, "errorCode": "...", "message": "..." }
```
HTTP 4xx/5xx 都视为明确失败,**不重试**。
---
## 2. 锁场
`POST /wxapi/AppointmentVenue/SaveVenueAppointmentV2`
### 请求体
```json
{
"userId": 6038652,
"projectType": 0,
"classroomItems": [
{
"classroomUid": "1775556146897330236",
"beginDatetime": "2026-07-16 19:00:00",
"endDatetime": "2026-07-16 19:59:00",
"peopleNum": 1
},
{
"classroomUid": "1775556146897330236",
"beginDatetime": "2026-07-16 20:00:00",
"endDatetime": "2026-07-16 20:59:00",
"peopleNum": 1
}
],
"remark": "",
"combinationPayments": [
{ "paymentMethod": 900, "cost": 160 }
]
}
```
要点:
- `classroomItems` 是**完整连续组合**——所有时段必须:
- 同一 `txtClassroomUid`
- 时段相邻(`prev.endHour == cur.beginHour`)
- 覆盖 `[targetStartHour, targetEndHour)` 整段
- 全部 `status in {available, limited}`
- 全部不在 `enrollSlot` 覆盖区间
- `peopleNum` 固定为 `1`
- `remark` 固定为 `""`
- `combinationPayments[0].cost` 是**所有 `classroomItems` 单价之和**,单位**元**(不是分)
- **一次请求提交全部组成时段,不得拆单**
### 响应体(成功)
```json
{
"successed": true,
"result": {
"apptUid": "1784102970857723008",
"script": {
"expireTime": "2026-07-15 16:24:31",
"paySign": "<private-pay-sign>"
},
"venueAppointUids": ["1784102970857723008"]
}
}
```
| 字段 | 含义 |
|---|---|
| `apptUid` | 订单号 |
| `venueAppointUids` | 场地订单号列表 |
| `script.expireTime` | 支付截止 |
| `script.paySign` 及整个 `script` 对象 | **支付签名,只在当次锁场响应里出现一次。不得打印、不得落盘、不得缓存、不得转发。** |
### 响应体(拒绝)
```json
{ "successed": false, "status": 400, "errorCode": "...", "message": "..." }
```
明确失败,agent 可以换时段重试,但同一会话同一时段**最多重试一次**。
### 错误分类
| 现象 | 分类 | 处置 |
|---|---|---|
| HTTP 2xx + `successed: true` | success | 一次性操作,session 结束本次写 |
| HTTP 2xx + `successed: false` | rejected | 可换时段重试(同一会话同一时段最多 1 次) |
| HTTP 4xx | rejected | 同上 |
| HTTP 5xx | uncertain | **不重试**,告诉用户结果不确定,必须去小程序核实 |
| 网络错误 / 超时 | uncertain | 同上 |
| JSON 解析失败 | uncertain | 同上 |
**绝不在结果不确定时重试。** 已经发起的锁场,agent 必须假设服务端可能已经接受,必须提示用户去小程序核对。
---
## 3. 释放
`POST /wxapi/AppointmentVenue/CrmAutoReleaseVenueAppoint`
**请求体严格是空对象 `{}`,不得携带任何参数。**
### 响应
成功:`{ "successed": true }`
失败:`{ "successed": false, "..." }`
**注意**:释放接口针对当前访客的全部锁场,**不能仅凭订单号安全限定目标**。除非用户明确说"释放我刚锁的那一笔",否则 agent 不主动调用。
---
## 完整 curl 示例
### 查询
```bash
curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2' \
-H 'Accept: */*' \
-H 'APPTYPE: 1' \
-H 'Content-Type: application/json' \
-H 'PSPLVISITORAUTO: API' \
-H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html' \
-H 'STOREID: 6038652' \
-H 'VERSIONINFO: NC|2026.07.03' \
-d '{"dateTime":"2026-07-16","userId":6038652,"projectUid":"1775556129325750199"}'
```
### 锁场
```bash
curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/SaveVenueAppointmentV2' \
-H 'Accept: */*' \
-H 'Accept-Encoding: gzip, deflate, br' \
-H 'Accept-Language: zh-CN,zh;q=0.9' \
-H 'APPTYPE: 1' \
-H 'Content-Type: application/json' \
-H "PSPLVISITORID: $POSPAL_VISITOR_ID" \
-H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html' \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: cross-site' \
-H 'STOREID: 6038652' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193' \
-H 'VERSIONINFO: NC|2026.07.13' \
-H 'xweb_xhr: 1' \
-d '{
"userId": 6038652,
"projectType": 0,
"classroomItems": [
{"classroomUid":"1775556146897330236","beginDatetime":"2026-07-16 19:00:00","endDatetime":"2026-07-16 19:59:00","peopleNum":1},
{"classroomUid":"1775556146897330236","beginDatetime":"2026-07-16 20:00:00","endDatetime":"2026-07-16 20:59:00","peopleNum":1}
],
"remark": "",
"combinationPayments": [{"paymentMethod": 900, "cost": 160}]
}'
```
### 释放
```bash
curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/CrmAutoReleaseVenueAppoint' \
-H "PSPLVISITORID: $POSPAL_VISITOR_ID" \
-H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html' \
-H 'STOREID: 6038652' \
-H 'VERSIONINFO: NC|2026.07.13' \
-d '{}'
```
---
## 字段变更检测
如果接口返回了上文未列出的关键字段、或字段名变化,按"球场接口响应格式错误"处理,**不静默忽略**。具体触发条件:
- 列表响应缺少 `result.slots` 数组
- 列表响应 slot 缺少 `txtClassroomUid` / `classRoomName` / `beginDatetime` / `endDatetime` / `cost` / `status` / `apptInfo.canApptOrNot` 中任一项
- 锁场响应缺少 `result.apptUid` 或 `result.script.expireTime` 或 `result.venueAppointUids`
任一触发 → 停止本球场所有操作,告知用户"接口字段变化,暂停服务",不静默丢时段、不静默重试。

View File

@@ -0,0 +1,506 @@
#!/usr/bin/env node
// pospal.mjs — Pospal helper for tennis-booking skill.
// Pure data transformation; never holds network state; never persists secrets.
// Spec: see references/protocol.md in the same skill directory.
import { createHash } from 'node:crypto'
// ---------- CLI plumbing ----------
function usage() {
process.stderr.write(`Usage: pospal.mjs <subcommand> [options]
Subcommands:
parse-availability Read raw list JSON from stdin, write structured slots JSON
--date YYYY-MM-DD (required)
--pretty (indent output)
find-continuous Read parse-availability output from stdin, write matching sequence
--start HH (required, e.g. 19)
--end HH (required, e.g. 21)
build-booking-body Read find-continuous (found: true) output from stdin, write request body
--pretty
parse-booking-response Read raw booking response JSON from stdin, write sanitized booking summary
--slots <path> (path to the same find-continuous output used to book)
--pretty
fingerprint Read visitor ID from --visitor or POSPAL_VISITOR_ID env, write 12-char hex
redact Read any JSON from stdin, write redacted JSON (no paySign, no PII, no PSPL*)
--pretty
All subcommands read JSON from stdin unless noted.
Env:
POSPAL_VISITOR_ID Read by 'fingerprint' if --visitor is not given
`)
}
function die(msg, code = 1) {
process.stderr.write(`error: ${msg}\n`)
process.exit(code)
}
function parseArgs(argv) {
const flags = {}
const positional = []
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith('--')) {
const key = a.slice(2)
const next = argv[i + 1]
if (next === undefined || next.startsWith('--')) {
flags[key] = true
} else {
flags[key] = next
i++
}
} else {
positional.push(a)
}
}
return { flags, positional }
}
async function readStdin() {
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
return Buffer.concat(chunks).toString('utf8')
}
function isRecord(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function isInt(n) {
return typeof n === 'number' && Number.isFinite(n)
}
const writeJson = (obj, pretty) => {
process.stdout.write(pretty ? JSON.stringify(obj, null, 2) : JSON.stringify(obj))
process.stdout.write('\n')
}
// ---------- shared helpers ----------
const TIME_RE = /(\d{2}):(\d{2})/
const DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}):\d{2}$/
function extractTime(value) {
if (typeof value !== 'string') return null
const m = TIME_RE.exec(value)
return m ? m[0] : null
}
function extractHour(time) {
if (typeof time !== 'string') return null
const m = TIME_RE.exec(time)
return m ? Number(m[1]) : null
}
function extractDate(dateTime) {
if (typeof dateTime !== 'string') return null
const m = DATE_TIME_RE.exec(dateTime)
return m ? m[1] : null
}
// ---------- parse-availability ----------
function parseEnrollment(raw) {
if (!isRecord(raw) || !isRecord(raw.enrollmentRule)) {
die('球场接口返回了无法识别的活动数据')
}
const rule = raw.enrollmentRule
const startTime = extractTime(rule.beginDate)
const endTime = extractTime(rule.endDate)
const deadline = extractTime(rule.enrollmentEndDate)
if (
typeof raw.txtClassroomUid !== 'string' ||
typeof raw.beginDatetime !== 'string' ||
typeof raw.endDatetime !== 'string' ||
typeof rule.uidTxt !== 'string' ||
typeof rule.title !== 'string' ||
startTime === null ||
endTime === null ||
!isInt(rule.enrollmentFee) ||
!isInt(rule.enrollmentLimitNum)
) {
die('球场接口返回了无法识别的活动数据')
}
const enrolledCount = isInt(rule.enrollmentCount)
? rule.enrollmentCount
: isInt(rule.ParticipateCount)
? rule.ParticipateCount
: 0
const participantNames = []
if (Array.isArray(rule.customerModels)) {
for (const c of rule.customerModels) {
if (isRecord(c) && typeof c.name === 'string' && c.name.trim()) {
participantNames.push(c.name.trim())
}
}
}
return {
txtClassroomUid: raw.txtClassroomUid,
beginDatetime: raw.beginDatetime,
endDatetime: raw.endDatetime,
activity: {
id: rule.uidTxt,
title: rule.title,
startTime,
endTime,
feeCents: Math.round(rule.enrollmentFee * 100),
enrolledCount,
capacity: rule.enrollmentLimitNum,
participantNames: [...new Set(participantNames)],
...(deadline !== null ? { enrollmentDeadline: deadline } : {})
}
}
}
function parseSlot(raw, enrollments) {
if (!isRecord(raw)) die('球场接口返回了无法识别的时段数据')
const startTime = extractTime(raw.beginDatetime)
const endTime = extractTime(raw.endDatetime)
const appt = isRecord(raw.apptInfo) ? raw.apptInfo : null
const canBook = appt ? appt.canApptOrNot : undefined
if (
typeof raw.txtClassroomUid !== 'string' ||
raw.txtClassroomUid.trim() === '' ||
typeof raw.classRoomName !== 'string' ||
raw.classRoomName.trim() === '' ||
typeof raw.beginDatetime !== 'string' ||
typeof raw.endDatetime !== 'string' ||
startTime === null ||
endTime === null ||
!isInt(raw.cost) ||
!isInt(raw.status) ||
typeof canBook !== 'boolean'
) {
die('球场接口返回了无法识别的时段数据')
}
const enrollment = enrollments.find((e) =>
e.txtClassroomUid === raw.txtClassroomUid &&
raw.beginDatetime <= e.endDatetime &&
raw.endDatetime >= e.beginDatetime
)
let status
let unavailableLabel
if (enrollment) {
status = 'booked'
unavailableLabel = '已订'
} else if (canBook) {
status = 'available'
} else if (raw.status === 1) {
status = 'booked'
unavailableLabel = '已订'
} else {
status = 'blocked'
unavailableLabel = '排课'
}
let bookedBy
if (status === 'booked' && appt) {
const contacts = []
if (Array.isArray(appt.hadVenueAppts)) {
for (const v of appt.hadVenueAppts) {
if (!isRecord(v)) continue
const name = typeof v.customerName === 'string' ? v.customerName.trim() : ''
const phone = typeof v.customerTel === 'string' ? v.customerTel.trim() : ''
if (name || phone) contacts.push({ name: name || undefined, phone: phone || undefined })
}
}
if (contacts.length === 0 && Array.isArray(appt.customerNames)) {
for (const v of appt.customerNames) {
if (typeof v === 'string' && v.trim()) contacts.push({ name: v.trim() })
}
}
const unique = []
for (const c of contacts) {
if (!unique.some((u) => u.name === c.name && u.phone === c.phone)) unique.push(c)
}
if (unique.length > 0) bookedBy = unique
}
return {
id: `${raw.txtClassroomUid}:${raw.beginDatetime}:${raw.endDatetime}`,
courtLabel: raw.classRoomName,
startTime,
endTime,
priceCents: Math.round(raw.cost * 100),
status,
...(unavailableLabel ? { unavailableLabel } : {}),
...(bookedBy ? { bookedBy } : {}),
...(enrollment ? { enrollment: enrollment.activity } : {}),
providerReference: {
classroomUid: raw.txtClassroomUid,
beginDatetime: raw.beginDatetime,
endDatetime: raw.endDatetime
},
sourceLabel: '范思伯特实时接口'
}
}
async function cmdParseAvailability({ flags }) {
const date = flags.date
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
die('--date YYYY-MM-DD is required')
}
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload)) die('球场接口响应格式错误')
if (payload.successed !== true || !isRecord(payload.result)) {
die('球场接口拒绝了可订时段查询')
}
const slots = payload.result.slots
if (!Array.isArray(slots)) die('球场接口响应缺少时段列表')
const enrollRaw = payload.result.enrollSlots
if (enrollRaw !== undefined && !Array.isArray(enrollRaw)) {
die('球场接口响应中的活动列表格式错误')
}
const enrollments = []
if (Array.isArray(enrollRaw)) {
for (const e of enrollRaw) enrollments.push(parseEnrollment(e))
}
const parsedSlots = slots.map((s) => parseSlot(s, enrollments))
writeJson({
courtId: 'fansibote-fuzhong',
date,
updatedAt: new Date().toISOString(),
slots: parsedSlots
}, !!flags.pretty)
}
// ---------- find-continuous ----------
function isContinuousSingleCourt(slots) {
if (slots.length === 0) return false
const firstUid = slots[0].providerReference?.classroomUid
if (!firstUid) return false
for (const s of slots) {
if (s.providerReference?.classroomUid !== firstUid) return false
}
for (let i = 1; i < slots.length; i++) {
const prevEnd = extractHour(slots[i - 1].endTime)
const curStart = extractHour(slots[i].startTime)
if (prevEnd === null || curStart === null || prevEnd + 1 !== curStart) return false
}
return true
}
async function cmdFindContinuous({ flags }) {
const start = flags.start
const end = flags.end
if (typeof start !== 'string' || typeof end !== 'string') {
die('--start HH and --end HH are required')
}
const startHour = Number(start)
const endHour = Number(end)
if (!Number.isInteger(startHour) || !Number.isInteger(endHour) ||
startHour < 0 || endHour > 24 || startHour >= endHour) {
die('invalid --start/--end (must be integers, 0 <= start < end <= 24)')
}
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload) || !Array.isArray(payload.slots)) {
die('stdin is not a parse-availability output (missing slots array)')
}
const bookable = payload.slots.filter((s) =>
(s.status === 'available' || s.status === 'limited') &&
!s.enrollment
)
const sorted = [...bookable].sort((a, b) => {
if (a.providerReference.classroomUid !== b.providerReference.classroomUid) {
return a.providerReference.classroomUid.localeCompare(b.providerReference.classroomUid)
}
return a.startTime.localeCompare(b.startTime)
})
const groups = new Map()
for (const s of sorted) {
const uid = s.providerReference.classroomUid
if (!groups.has(uid)) groups.set(uid, [])
groups.get(uid).push(s)
}
const matches = []
const span = endHour - startHour
for (const [uid, slots] of groups) {
for (let i = 0; i + span <= slots.length; i++) {
const window = slots.slice(i, i + span)
const firstHour = extractHour(window[0].startTime)
const lastEndHour = extractHour(window[window.length - 1].endTime)
if (firstHour !== startHour || lastEndHour + 1 !== endHour) continue
if (!isContinuousSingleCourt(window)) continue
matches.push({
txtClassroomUid: uid,
courtLabel: window[0].courtLabel,
startTime: window[0].startTime,
endTime: window[window.length - 1].endTime,
slots: window
})
}
}
if (matches.length === 0) {
writeJson({ found: false }, false)
return
}
matches.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime.localeCompare(b.startTime)
const aCost = a.slots.reduce((s, x) => s + x.priceCents, 0)
const bCost = b.slots.reduce((s, x) => s + x.priceCents, 0)
if (aCost !== bCost) return aCost - bCost
return a.courtLabel.localeCompare(b.courtLabel)
})
writeJson({
found: true,
match: matches[0],
alternatives: matches.slice(1)
}, false)
}
// ---------- build-booking-body ----------
const DEFAULT_VENUE_ID_NUM = 6038652
async function cmdBuildBookingBody({ flags }) {
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload) || payload.found !== true || !isRecord(payload.match)) {
die('stdin is not a find-continuous output (found: true required)')
}
const slots = payload.match.slots
if (!Array.isArray(slots) || slots.length === 0) die('no slots in match')
if (!isContinuousSingleCourt(slots)) die('match is not a single-court continuous sequence')
const body = {
userId: DEFAULT_VENUE_ID_NUM,
projectType: 0,
classroomItems: slots.map((s) => ({
classroomUid: s.providerReference.classroomUid,
beginDatetime: s.providerReference.beginDatetime,
endDatetime: s.providerReference.endDatetime,
peopleNum: 1
})),
remark: '',
combinationPayments: [
{
paymentMethod: 900,
cost: slots.reduce((sum, s) => sum + s.priceCents, 0) / 100
}
]
}
writeJson(body, !!flags.pretty)
}
// ---------- parse-booking-response ----------
async function cmdParseBookingResponse({ flags }) {
const slotsPath = flags.slots
if (typeof slotsPath !== 'string') die('--slots <path> is required')
const fs = await import('node:fs/promises')
let slotsDoc
try {
slotsDoc = JSON.parse(await fs.readFile(slotsPath, 'utf8'))
} catch (err) {
die(`cannot read --slots: ${err.message}`)
}
const sl = slotsDoc?.match?.slots ?? slotsDoc?.slots
if (!Array.isArray(sl) || sl.length === 0) {
die('--slots must point to a find-continuous (with .match.slots) or parse-availability (with .slots) output')
}
const respRaw = await readStdin()
let payload
try { payload = JSON.parse(respRaw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload)) die('球场下单接口响应格式错误')
if (payload.successed !== true) {
const detail = []
for (const k of ['message', 'msg', 'errorMessage', 'errorMsg', 'status', 'errorCode']) {
const v = payload[k]
if (typeof v === 'string' || typeof v === 'number') {
detail.push(`${k}=${String(v).slice(0, 200)}`)
}
}
die(`球场下单接口拒绝了锁场请求${detail.length ? `(${detail.join(', ')})` : ''}`)
}
if (!isRecord(payload.result)) die('球场下单接口响应格式错误')
const result = payload.result
if (
typeof result.apptUid !== 'string' ||
!isRecord(result.script) ||
typeof result.script.expireTime !== 'string' ||
!Array.isArray(result.venueAppointUids) ||
!result.venueAppointUids.every((v) => typeof v === 'string')
) {
die('球场下单接口响应格式错误')
}
const sorted = [...sl].sort((a, b) => a.startTime.localeCompare(b.startTime))
const amountCents = sorted.reduce((s, x) => s + x.priceCents, 0)
const summary = {
courtId: 'fansibote-fuzhong',
orderId: result.apptUid,
venueAppointmentIds: result.venueAppointUids,
amountCents,
courtLabel: sorted[0].courtLabel,
startTime: sorted[0].startTime,
endTime: sorted[sorted.length - 1].endTime,
expiresAt: result.script.expireTime,
status: 'pending-payment',
_note: 'script 对象(paySign 等)已剥离;请到小程序完成支付。'
}
writeJson(summary, !!flags.pretty)
}
// ---------- fingerprint ----------
async function cmdFingerprint({ flags }) {
let visitor = typeof flags.visitor === 'string' ? flags.visitor : ''
if (!visitor) visitor = process.env.POSPAL_VISITOR_ID ?? ''
if (!visitor) die('fingerprint needs --visitor or POSPAL_VISITOR_ID env')
const fp = createHash('sha256').update(visitor).digest('hex').slice(0, 12)
process.stdout.write(fp)
process.stdout.write('\n')
}
// ---------- redact ----------
const SENSITIVE_KEY = /^(?:visitor|paysign|prepay|nonce|package|token|cookie|authorization)$|^(?:pspl|customer|phone|tel)/i
function redactValue(value, key) {
if (typeof key === 'string' && SENSITIVE_KEY.test(key)) return '[REDACTED]'
if (Array.isArray(value)) return value.map((v) => redactValue(v, ''))
if (isRecord(value)) {
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k)
return out
}
return value
}
async function cmdRedact({ flags }) {
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
writeJson(redactValue(payload, ''), !!flags.pretty)
}
// ---------- main ----------
async function main() {
const argv = process.argv.slice(2)
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
usage()
process.exit(argv.length === 0 ? 1 : 0)
}
const sub = argv[0]
const parsed = parseArgs(argv.slice(1))
switch (sub) {
case 'parse-availability': return cmdParseAvailability(parsed)
case 'find-continuous': return cmdFindContinuous(parsed)
case 'build-booking-body': return cmdBuildBookingBody(parsed)
case 'parse-booking-response': return cmdParseBookingResponse(parsed)
case 'fingerprint': return cmdFingerprint(parsed)
case 'redact': return cmdRedact(parsed)
default: {
process.stderr.write(`unknown subcommand: ${sub}\n\n`)
usage()
process.exit(1)
}
}
}
main().catch((err) => die(err?.stack ?? String(err)))