可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。
注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。
在项目的根目录下创建:.cursor\skills\playwright-h5-api-automation
---
name: playwright-h5-api-automation
description: Scaffolds Playwright + Express services that automate H5 landing pages (send-code, submit-order) via browser replay and downstream API capture. Use when building H5 order automation, Playwright API proxy, browser pool, h5Param device simulation, console tracking log capture, session persistence, channel/touchpoint param injection, multi-worker concurrency, unification front-pj integration, or Docker headless browser services.
---
# Playwright H5 自动化 API 服务
将「打开 H5 → 填表 → 点击 → 捕获下游接口 + 插码 console」封装为 HTTP API。适用于落地页链接固定、触点参数由调用方传入、需服务端无头运行的场景。
## 何时使用
- 需求是:**发码/办理** 必须走真实 H5 + 页面 SDK,不能直接调下游(或需兜底)
- 对外提供 REST API,入参为手机号、验证码、自定义 `orderId`(或经 unification 映射的 `orderNo`)等
- 落地页 URL **不能** 挂业务参数,触点需 API 注入
- 需要 H5 设备指纹模拟(UA / 视口 / 网络)、插码 SDK console 回传
- 需要发码会话**持久化**(跨 Worker / 跨容器 / 服务重启后仍可校验)
- 需要 Docker 部署、文件日志、单容器多 Worker 或水平扩容
## 对外契约分层(重要)
| 层级 | 文档 | 入参示例 | 说明 |
|------|------|----------|------|
| **下游 / 业务方** | `unification/doc/front-pj接口文档*.md` | `orderNo`、`messageCode`、`cqtf` 对象 | **不要改**;由 Java unification 转发 |
| **pj 直连(内部)** | `{project}/doc/README.md` | `orderId`、`verifyCode`、顶层平铺 | Postman / 运维调试 |
unification 映射(以 cqtf 为例):
| front-pj | *-order-pj |
|----------|------------|
| `orderNo` | `orderId` |
| `messageCode` | `verifyCode` |
| `cqtf.*` | 顶层平铺 + `h5Param` |
Java 策略层从 pj 响应的 `apiCall.response.body` 提取 `{ code, message, data }` 原样返回下游,**不**暴露 `apiCall` / `consoleLogs`。
## 架构(六层)
```
Express API (server/index.js)
↓
api-envelope (scripts/api-envelope.js) — 入参 unwrap、响应 apiCall 组装
↓
Automation core (scripts/*-automation.js) — 发码/办理、route 注入、二次确认
↓
session-store (scripts/session-store.js) — 发码会话持久化 config/sessions/
↓
Browser pool + h5-param + console-capture
↓
Playwright Chromium
```
| 能力 | 实现要点 |
|------|----------|
| 发码 | 打开页 → 填手机号 → 点发送 → 捕获 `POST .../sms/send` |
| 办理 | 读持久化/内存会话 → 填验证码 → 点办理(含二次确认)→ 捕获 verify |
| 兜底 | 无页面会话时用 `fetch` 直调 verify(**必须**用持久化里的下游 orderId/serialId) |
| 内存会话 | Worker 内 `{ clientOrderId, downstream orderId, serialId, h5Param }`,TTL 10 分钟 |
| 持久化会话 | 发码成功写 `{config}/sessions/{orderId}.json`,校验按 orderId 加载 |
| 并发 | `WORKER_COUNT` 个 Worker;校验 `preferWorkerId` 亲和;持久化可跨 Worker/容器 |
| 插码 | `console-capture.js` → 响应 `consoleLogs` + `trackingEvents` |
## 推荐目录结构
```
{project}/
├── server/
│ ├── index.js
│ └── logger.js
├── scripts/
│ ├── api-envelope.js # unwrapRequestSource、buildSend/VerifyApiResponse
│ ├── session-store.js # save/loadPersistedSession
│ ├── browser-pool.js
│ ├── h5-param.js
│ ├── console-capture.js
│ ├── *-automation.js
│ ├── channel-params.js
│ └── *-cli.js
├── config/ # h5Param 设备 JSON(勿与 sessions/ 混放)
├── docker/dockerfile
├── doc/README.md # pj 直连说明;下游契约指向 unification 文档
├── package.json
└── playwright.config.js
```
## API 设计规范(pj 直连)
### 必备接口
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/health` | 存活 + `pool` 状态 |
| GET/POST | `/api/sms/send` | 发码 |
| GET/POST | `/api/order/submit` | 校验办理 |
### 入参分层
| 类型 | 字段示例 | 拼 URL | 注入 POST |
|------|----------|--------|-----------|
| 基础 | `phone`, `productCode`, `orderId`, `verifyCode` | 否 | 否 |
| 模拟器 | `h5Param` / `userAgent` | 否 | 否 |
| 追踪 | `package_name`, `goodUrl` 等 | **是** | 部分项目注入 goodUrl |
| 触点 | `channelParams` / `touchpoint` | 否 | **是** |
解析逻辑:
1. `unwrapRequestSource(source)`:兼容 `bizParam` 包裹,**推荐顶层平铺**
2. `orderId`(`sid` 同义)发码/校验**必填且一致**
3. 下游 `serialId` **不要求**调用方传递;发码后写入 sessions 文件
4. 校验时 `parseCommonParams(..., { pickRandomH5: false })`,优先复用发码 `h5Param`
### 两套 orderId(内部,勿混用)
| 字段 | 来源 | 用途 |
|------|------|------|
| 请求 `orderId` | 调用方 / `orderNo` 映射 | 会话键、持久化文件名 |
| 下游 `orderId` / `serialId` | 发码 `data` | 校验 POST **必传**,从 sessions 读取 |
**禁止**用请求 `orderId` 作为下游 `orderId`/`serialId` 的 fallback。
### 发码会话持久化
模块:`scripts/session-store.js`
```
{H5_PARAM_CONFIG_DIR}/sessions/{orderId}.json
```
发码成功后同时:`saveSmsSession(worker, ...)` + `savePersistedSession(clientOrderId, ...)`
校验时加载顺序:
1. 内存 `findSessionWorker` + `getSmsSession`
2. `loadPersistedSession(clientOrderId, { phone, productCode, pageUrl })`
3. 均无则报错(勿静默用错误 ID 调下游)
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `SESSION_PERSIST_TTL_MS` | `3600000`(60 分钟) | 持久化文件有效期 |
| `H5_PARAM_CONFIG_DIR` | `/opt/docker/{service}/config` | 与 h5Param 配置同挂载 |
多容器:**共享同一 config 挂载**,sessions 子目录即可跨实例校验;无需下游改入参。
### 对外返回(pj 直连)
```json
{
"orderId": "{CLIENT_ORDER_ID}",
"success": true,
"apiCall": {
"request": { "url": "...", "method": "POST", "body": { } },
"response": { "status": 200, "body": { "code": "200", "message": "success", "data": { } } }
},
"consoleLogs": [ ],
"trackingEvents": [ ]
}
```
- 下游原始 JSON 在 **`apiCall.response.body`**,不再平铺到顶层
- `success` = `apiCall.response.body.code === '200'`
- 参数错误:`{ success: false, message: "...", orderId?: "..." }`(无 apiCall)
模块:`scripts/api-envelope.js` — `buildSendApiResponse` / `buildVerifyApiResponse`
### 校验 POST 注入
即使复用 H5 页面,也应在点击办理前对 verify 路由注册 `installPostOverride`,强制写入持久化中的下游 `orderId`/`serialId`/`goodsUrl`,避免页面 Vue 状态丢失或错误。
### H5 二次确认弹窗
部分产品在 `theme.confirmDialog` 存在时,点击办理仅弹 Vant 确认框。自动化须:
```javascript
async function clickApplyWithConfirm(page) {
await page.locator('.button-wrap').click();
const confirmBtn = page.locator('.van-dialog__confirm, .product-order-confirm-dialog .van-dialog__confirm');
try {
await confirmBtn.first().waitFor({ state: 'visible', timeout: 5000 });
await confirmBtn.first().click();
} catch { /* 无弹窗产品 */ }
}
```
## h5Param 设备模拟
模块:`scripts/h5-param.js`
**优先级:** 请求 `h5Param` > 顶层 `userAgent` > 配置目录随机抽取 > 内置默认。
配置目录仅放设备 JSON;**sessions/** 子目录专存发码会话,避免被 `pickRandomH5Param()` 误读。
## 插码 console 采集
模块:`scripts/console-capture.js`
- 默认过滤 `[tracking]` 及关键词;可解析为 `trackingEvents`
- 环境变量:`CONSOLE_CAPTURE_WAIT_MS`、`CONSOLE_CAPTURE_IDLE_MS`、`CONSOLE_CAPTURE_ALL`
## 触点注入
点击前注册 route,响应后 `teardown`;校验与发码均需时分别注册。
## Browser Pool(多 Worker)
```javascript
return runExclusive(task, { preferWorkerId: boundWorker?.id });
```
- 每 Worker 独立 context/page/内存会话
- 校验优先路由到发码 Worker;若无内存会话,靠 **持久化文件** + fetch 兜底
## Docker 清单
| 用途 | 宿主机 | 容器内 |
|------|--------|--------|
| 日志 | `/data/opt/docker/{service}/logs` | `/opt/docker/{service}/logs` |
| 配置 + 会话 | `/data/opt/docker/{service}/config` | `/opt/docker/{service}/config` |
```bash
mkdir -p /data/opt/docker/{service}/logs /data/opt/docker/{service}/config/sessions
chown -R 1000:1000 /data/opt/docker/{service}
docker run -d \
-v /data/opt/docker/{service}/logs:/opt/docker/{service}/logs \
-v /data/opt/docker/{service}/config:/opt/docker/{service}/config \
-e H5_PARAM_CONFIG_DIR=/opt/docker/{service}/config \
-e WORKER_COUNT=3 \
-e SESSION_PERSIST_TTL_MS=3600000 \
{image}:{tag}
```
Dockerfile 须 `mkdir .../config/sessions` 且 `chown pwuser`。
| 环境变量 | 说明 |
|----------|------|
| `WORKER_COUNT` | 并行 Worker,默认 1,最大 16 |
| `SESSION_PERSIST_TTL_MS` | 持久化会话 TTL |
| `H5_PARAM_CONFIG_DIR` | h5 配置 + sessions 根目录 |
| `CONSOLE_CAPTURE_*` | 插码采集行为 |
## 常见故障
| 现象 | 排查 |
|------|------|
| 未查询到发送记录 | 校验 POST 是否误用 client orderId 作下游 ID;sessions 文件是否存在 |
| 校验找不到会话 | orderId/phone 不一致;TTL 过期;多容器未共享 config 挂载 |
| 90s 超时无 verify 响应 | 是否缺二次确认点击;PROD 是否有 confirmDialog |
| 下游契约被改 | 下游应走 unification front-pj 文档,勿要求传 serialId |
| h5Param 误读会话文件 | sessions 必须在 `config/sessions/`,勿放 config 根目录 |
## 实施顺序
1. 浏览器走通 H5,记录 Network + 插码 console + 是否有二次确认
2. CLI 脚本复现发码/校验捕获
3. `browser-pool` + `h5-param` + `console-capture` + automation
4. `session-store` 持久化 + 校验 route 注入下游 ID
5. `api-envelope` 响应结构 + Express API
6. unification `*ApiClient` 映射 orderNo → orderId;Strategy 提取 `apiCall.response.body`
7. Docker 双挂载 + doc(区分下游文档 vs pj README)
## 脱敏要求
- skill/doc 用占位符,不写真实域名、产品编码
- 参考实现:`front-pj/cqtf-order-pj`
## 延伸阅读
- 代码片段与模板:[reference.md](reference.md)
# Playwright H5 API 自动化 — 参考实现
参考 `front-pj/cqtf-order-pj/`(实施时替换 H5 与下游路径,勿复制业务常量)。
## 模块职责
| 文件 | 职责 |
|------|------|
| `server/index.js` | Express、`unwrapRequestSource`、`parseSend/VerifyParams`、`logBlock` |
| `scripts/api-envelope.js` | `buildSendApiResponse`、`buildVerifyApiResponse`、`buildErrorResponse` |
| `scripts/session-store.js` | `savePersistedSession`、`loadPersistedSession` |
| `scripts/browser-pool.js` | 多 Worker、`findSessionWorker`、`runExclusive(..., { preferWorkerId })` |
| `scripts/h5-param.js` | `mergeH5Param`、`pickRandomH5Param`、`getConfigDir` |
| `scripts/console-capture.js` | `beginConsoleCapture`、`collectConsoleLogs` |
| `scripts/*-automation.js` | 发码/校验、`installPostOverride`、`clickApplyWithConfirm`、`verifyViaFetch` |
| `scripts/channel-params.js` | 追踪/触点参数、`collectCqtfParams` |
## unification 对接模板(Java)
```java
// CqtfOrderApiClient — 下游 orderNo 映射为 pj orderId
body.put("orderId", request.getOrderNo());
body.put("verifyCode", request.getMessageCode());
// cqtf 触点平铺 + h5Param
// CqtfOrderStrategy — 只把页面下游 body 返回给业务方
static JSONObject extractPageApiBody(JSONObject pjResponse) {
JSONObject apiCall = pjResponse.getJSONObject("apiCall");
if (apiCall != null) {
JSONObject response = apiCall.getJSONObject("response");
if (response != null) {
return response.getJSONObject("body");
}
}
return buildErrorResponse(pjResponse.getStr("message"));
}
```
## 入参解析
```javascript
// server/index.js
const normalized = unwrapRequestSource(source); // 兼容 bizParam
const params = parseSendParams(normalized);
function parseSendParams(source) {
const common = parseCommonParams(source); // pickRandomH5 默认 true
return {
phone: source.phone,
orderId: source.orderId ?? source.sid,
...common,
};
}
function parseVerifyParams(source) {
const common = parseCommonParams(source, { pickRandomH5: false });
return {
phone: source.phone,
orderId: source.orderId ?? source.sid,
verifyCode: source.verifyCode ?? source.code ?? source.messageCode,
...common,
};
}
```
## 发码后会话保存
```javascript
if (downstreamOrderId && downstreamSerialId) {
const sessionPayload = {
phone,
pageUrl,
clientOrderId,
productCode,
orderId: downstreamOrderId, // 下游
serialId: downstreamSerialId, // 下游
goodsUrl: innerGoodUrl || '',
h5Param,
h5ParamKey: fingerprint(h5Param),
createdAt: Date.now(),
};
saveSmsSession(worker, sessionPayload);
savePersistedSession(clientOrderId, sessionPayload);
}
```
## 校验会话加载(禁止 client ID fallback)
```javascript
const memorySession = boundWorker ? getSmsSession(boundWorker) : null;
const persistedSession = loadPersistedSession(clientOrderId, {
phone,
productCode,
pageUrl,
});
const cachedSession = memorySession ?? persistedSession;
const downstreamOrderId = cachedSession?.orderId ?? null;
const downstreamSerialId = cachedSession?.serialId ?? null;
if (!downstreamOrderId || !downstreamSerialId) {
throw new Error(`未找到 orderId=${clientOrderId} 的发码记录`);
}
const verifyPostBody = buildVerifyPostBody({
phone,
productCode,
downstreamOrderId,
downstreamSerialId,
verifyCode,
goodsUrl: innerGoodUrl || cachedSession?.goodsUrl || '',
});
// 复用页面时:installPostOverride(page, '**/.../verify', verifyPostBody)
// 无页面时:verifyViaFetch(page, { orderId: downstreamOrderId, serialId: downstreamSerialId, ... })
```
## 响应组装(api-envelope.js)
```javascript
function buildSendApiResponse({
clientOrderId,
smsRequest,
smsResponse,
consoleLogs = [],
trackingEvents = [],
}) {
return {
orderId: clientOrderId,
success: isDownstreamSuccess(smsResponse?.body),
apiCall: buildApiCallEnvelope(smsRequest, smsResponse),
consoleLogs,
trackingEvents,
};
}
```
响应示例:
```json
{
"orderId": "{CLIENT_ORDER_ID}",
"success": true,
"apiCall": {
"request": {
"url": "https://{host}/uni-api/{path}/sms/send",
"method": "POST",
"body": { "accNbr": "1XXXXXXXXXX", "productCode": "PROD_XXX", "goodUrl": "..." }
},
"response": {
"status": 200,
"body": {
"code": "200",
"message": "success",
"data": { "orderId": "{DOWNSTREAM_ORDER_ID}", "serialId": "{DOWNSTREAM_SERIAL_ID}" }
}
}
},
"consoleLogs": [],
"trackingEvents": []
}
```
## session-store 持久化文件
路径:`{getConfigDir()}/sessions/{sanitizedOrderId}.json`
```json
{
"clientOrderId": "{CLIENT_ORDER_ID}",
"phone": "1XXXXXXXXXX",
"productCode": "PROD_XXX",
"pageUrl": "https://{BASE_URL}/?productCode=PROD_XXX&goodUrl=...",
"orderId": "{DOWNSTREAM_ORDER_ID}",
"serialId": "{DOWNSTREAM_SERIAL_ID}",
"goodsUrl": "...",
"h5Param": { "deviceModel": "iPhone 14", "viewport": { "width": 390, "height": 844 } },
"createdAt": 1790152631628
}
```
`.gitignore` 增加 `config/sessions/`;Docker 创建 `config/sessions` 子目录。
## 二次确认弹窗
```javascript
async function clickApplyWithConfirm(page) {
await page.locator('.button-wrap').click();
const confirmBtn = page.locator(
'.product-order-confirm-dialog .van-dialog__confirm, .van-dialog__confirm'
);
try {
await confirmBtn.first().waitFor({ state: 'visible', timeout: 5000 });
await confirmBtn.first().click();
} catch {
/* 无 confirmDialog */
}
}
```
## installPostOverride 模板
```javascript
async function installPostOverride(page, routeGlob, overrides) {
if (!overrides || !Object.keys(overrides).length) {
return { teardown: async () => {}, getMergedBody: () => null };
}
let mergedBody = null;
const handler = async (route) => {
const request = route.request();
if (request.method() !== 'POST') return route.continue();
try {
mergedBody = { ...(request.postDataJSON() || {}), ...overrides };
const response = await route.fetch({ postData: JSON.stringify(mergedBody) });
await route.fulfill({ response });
} catch {
await route.continue();
}
};
await page.route(routeGlob, handler);
return {
teardown: async () => page.unroute(routeGlob, handler),
getMergedBody: () => mergedBody,
};
}
```
## POST 示例
**pj 直连(运维):**
```bash
curl -X POST "http://localhost:{PORT}/api/sms/send" \
-H "Content-Type: application/json" \
-d '{"phone":"1XXXXXXXXXX","orderId":"{CLIENT_ORDER_ID}","productCode":"PROD_XXX"}'
curl -X POST "http://localhost:{PORT}/api/order/submit" \
-H "Content-Type: application/json" \
-d '{"phone":"1XXXXXXXXXX","orderId":"{CLIENT_ORDER_ID}","verifyCode":"123456","productCode":"PROD_XXX"}'
```
**下游经 unification(契约不变):**
```bash
curl -X POST "https://{host}/uni-api/front-pj/{projectCode}/send-code" \
-d '{"orderNo":"{CLIENT_ORDER_ID}","phone":"1XXXXXXXXXX","productCode":"PROD_XXX","cqtf":{}}'
curl -X POST "https://{host}/uni-api/front-pj/{projectCode}/submit-code" \
-d '{"orderNo":"{CLIENT_ORDER_ID}","phone":"1XXXXXXXXXX","messageCode":"123456","productCode":"PROD_XXX"}'
```
## Docker 挂载
```bash
mkdir -p /data/opt/docker/{service}/logs /data/opt/docker/{service}/config/sessions
chown -R 1000:1000 /data/opt/docker/{service}
docker run -d \
-v /data/opt/docker/{service}/logs:/opt/docker/{service}/logs \
-v /data/opt/docker/{service}/config:/opt/docker/{service}/config \
-e LOG_PATH=/opt/docker/{service}/logs \
-e H5_PARAM_CONFIG_DIR=/opt/docker/{service}/config \
-e SESSION_PERSIST_TTL_MS=3600000 \
-e WORKER_COUNT=3 \
{image}:{tag}
```
## 实施检查清单
- [ ] 发码/校验 `orderId` 必填;下游 ID 从 sessions 读取,禁止 client fallback
- [ ] 发码写 `savePersistedSession`;校验 `loadPersistedSession`
- [ ] 校验 verify 路由注入下游 orderId/serialId
- [ ] 有 confirmDialog 的产品实现 `clickApplyWithConfirm`
- [ ] 响应用 `apiCall` 封装;unification 只透传 `apiCall.response.body`
- [ ] sessions 在 `config/sessions/`,不与 h5Param JSON 混放
- [ ] Docker 挂载 config + 创建 sessions 子目录
- [ ] doc:下游看 unification 文档;pj README 标注内部 API
## 容量规划
| 指标 | 单 Worker | 3 Worker |
|------|-----------|----------|
| 内存 | ~400MB/Context | ~1.2GB |
| 会话 | 内存 10min + 文件 60min | 共享 config 可跨容器 |