cursor-Playwright自动化框架相关的skills

我爱海鲸 2026-09-24 18:35:35 暂无标签

简介nodejs、node、Playwright、pw

可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。


注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。

在项目的根目录下创建:.cursor\skills\playwright-h5-api-automation

SKILL.md:

---
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)

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 可跨容器 |

 

你好:我的2025

上一篇:Playwright自动化框架

下一篇:最好的告别