可以把技能放到本机用户目录的 ~/.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, channel/touchpoint param injection, or Docker headless browser services for similar requirements.
---
# Playwright H5 自动化 API 服务
将「打开 H5 → 填表 → 点击 → 捕获下游接口」封装为 HTTP API。适用于落地页链接固定、触点参数由调用方传入、需服务端无头运行的场景。
## 何时使用
- 需求是:**发码/办理** 必须走真实 H5 + 页面 SDK,不能直接调下游(或需兜底)
- 对外提供 REST API,入参为手机号、验证码、会话 ID 等
- 落地页 URL **不能** 挂业务参数,触点需 API 注入
- 需要 Docker 部署、文件日志、单实例串行
## 架构(四层)
```
Express API (server/index.js)
↓
Automation core (scripts/*-automation.js) — 发码/办理流程
↓
Browser pool (scripts/browser-pool.js) — 单例浏览器 + 串行队列 + 会话
↓
Playwright Chromium — 打开 H5、点击、拦截 XHR
```
| 能力 | 实现要点 |
|------|----------|
| 发码 | 打开页 → 填手机号 → 点发送 → 捕获 `POST .../sms/send`(路径按项目替换) |
| 办理 | 复用发码会话页 → 填验证码 → 点办理 → 捕获 `POST .../order/submit` |
| 兜底 | 无会话时用页面内 SDK + `fetch` 直调办理接口 |
| 会话 | 发码成功后内存保存 `{ phone, pageUrl, sid, channelTemplate }`,TTL 建议 10 分钟 |
| 并发 | `runExclusive` 串行队列;单实例有效并发 **1**,扩容靠多容器 |
## 推荐目录结构
```
{project}/
├── server/
│ ├── index.js # Express 路由、入参解析、日志块
│ └── logger.js # 控制台 + 挂载目录双写
├── scripts/
│ ├── browser-pool.js # launch、页面缓存、会话、warmup、shutdown
│ ├── *-automation.js # 发码/办理、route 注入、兜底 fetch
│ ├── channel-params.js # 触点字段白名单与别名(与 H5 主题配置对齐)
│ └── *-cli.js # 本地调试 CLI(可选)
├── docker/dockerfile # 官方 Playwright 基础镜像
├── doc/README.md # 部署与 API 说明
├── package.json
└── playwright.config.js
```
## API 设计规范
### 必备接口
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/health` | 存活探针 |
| GET/POST | `/api/sms/send` | 发码 |
| GET/POST | `/api/order/submit` | 办理(需 sid + verifyCode) |
### 入参分层(重要)
| 类型 | 字段示例 | 是否拼落地页 URL | 是否注入下游 POST |
|------|----------|------------------|-------------------|
| 基础 | `phone`, `productCode`, `userAgent`, `headless` | 否 | 否 |
| 页面追踪 | `cpparam` 等 | **是** | 否(仅 H5 侧) |
| 触点 | `channelParams` / `touchpoint` | **否** | **是** |
解析逻辑:
1. 保留字段不进 URL、不进触点
2. 触点字段名与 H5 项目 `productThemes`(或等价配置)白名单一致
3. 支持短别名(如 `rmn` → `reportMaterialsNo`),与 H5 侧别名表同步
4. 落地页 URL 仅保留 `productCode` + 页面追踪类 query
### 对外返回
- 发码:返回**下游原始 JSON**,并在顶层追加 `sid`(优先取唯一流水号,勿用固定占位码)
- 办理:返回**下游办理接口原始 JSON**
- 内部日志:`API 请求/响应`、`下游请求/响应` 分块 JSON 打印
## 触点注入(链接不能带参时)
**禁止**把触点拼进落地页 URL(易触发风控或与 `proxyOrderUrl` 不一致)。
使用 Playwright **路由拦截**,在下游 POST 发出前合并 `channelParams`:
```javascript
// 1. 点击之前注册 route
const routeHook = await installChannelPostOverride(page, SMS_PATTERN, channelParams);
try {
const responsePromise = page.waitForResponse(matchSmsPost);
await page.locator(SEND_BTN).click();
const response = await responsePromise;
const body = routeHook.getMergedBody() || parseRequestBody(response.request().postData());
// ...
} finally {
await routeHook.teardown(); // 必须在收到 response 之后
}
```
### 拦截实现要点
| 规则 | 说明 |
|------|------|
| 时机 | **点击前注册,响应后卸载**;页面常在 SDK 校验后才发 POST,点击后立即 unroute 会失效 |
| 方式 | 优先 `route.fetch({ postData })` + `route.fulfill({ response })` |
| 匹配 | 路由用 glob `**/.../sms/send`,响应用 RegExp 均可 |
| 日志 | 记录 `getMergedBody()`,勿仅信 `request.postData()`(可能是注入前快照) |
## Browser Pool 规范
```javascript
// 串行队列 — Playwright 单页非线程安全
function runExclusive(task) {
const run = queue.then(task, task);
queue = run.catch(() => {});
return run;
}
```
- 单例 `browser` + 缓存 `page`;URL 或 UA 变化时重建
- `pageUsed` 标记:办理/失败后 reload 重置表单
- 启动 `warmup(defaultPageUrl)` 缩短首请求耗时
- `SIGINT`/`SIGTERM` 时 `shutdown()` 关浏览器
### 会话限制(务必写进 doc)
- 全局仅 **1 份** 发码会话;多用户交错时后者覆盖前者
- 办理尽量发码后立刻调用;跨实例需粘滞或接受 SDK 兜底
## Docker 清单
```dockerfile
FROM mcr.microsoft.com/playwright:v{VERSION}-jammy
WORKDIR /app
COPY package.json package-lock.json ./
RUN NPM_CONFIG_PRODUCTION=false npm ci && node -e "require('playwright')"
ENV NODE_ENV=production
COPY server ./server
COPY scripts ./scripts
ENV HEADLESS=true
ENV LOG_PATH=/opt/docker/{service}/logs
CMD ["node", "server/index.js"]
```
| 坑 | 处理 |
|----|------|
| `Cannot find module 'playwright'` | `playwright` 放 **dependencies**;`npm ci` 在 `NODE_ENV=production` **之前** |
| 构建上下文 | `docker build -f docker/dockerfile -t {tag} .` 在项目根目录,非 docker 子目录 |
| 日志挂载 | uid 1000(pwuser);多实例各用独立 logs 目录 |
## 文件日志
- `logger.js`:production 双写;路径 `/opt/docker/{service}/logs/{service}.log`
- 按日/100MB 轮转,对齐 Java logback 习惯
- 环境变量:`LOG_PATH`、`LOG_TO_FILE`
## 实施顺序
1. 手工在浏览器走通 H5,记录 Network 下发码/办理 **Request Payload** 字段
2. 用 Playwright CLI 脚本复现点击与捕获(无 API)
3. 抽 browser-pool + automation core
4. 加 Express API + 结构化日志
5. 加 `channelParams` 注入(若链接不能带参)
6. Docker + doc/README
7. 对比浏览器与 API 日志字段逐项 diff
## 常见故障
| 现象 | 排查 |
|------|------|
| 触点参数未生效 | route 是否过早 unroute;日志是否用 merged body |
| 下游 999 风控 | 对比成功/失败请求的触点字段;勿随意改 `pageName` 等未报备字段 |
| 办理 sid 失效 | 会话被覆盖、超时、或实例漂移 |
| 首请求慢 | warmup;`waitUntil: 'load'` 而非 `networkidle` |
| Docker 模块缺失 | dependencies + NPM_CONFIG_PRODUCTION=false |
## 脱敏要求
编写 skill、doc、注释时:
- 用 `{BASE_URL}`、`{PRODUCT_CODE}`、`{DOWNSTREAM_SMS_PATH}` 占位
- 不写真实域名、产品编码、素材编号、运营商名称
- 触点字段名可保留(通常为对接文档标准字段)
## 延伸阅读
- 模块职责与代码片段:[reference.md](reference.md)
# Playwright H5 API 自动化 — 参考实现
本仓库可参考 `front-pj/*-order-pj/` 下已落地项目(实施时替换为你的 H5 与下游路径,勿复制业务常量)。
## 模块职责
| 文件 | 职责 |
|------|------|
| `server/index.js` | Express、`parseSendParams` / `parseOrderParams`、`logBlock`、graceful shutdown |
| `server/logger.js` | 双写、轮转、`LOG_PATH` |
| `scripts/browser-pool.js` | `runExclusive`、`preparePage`、`saveSession`、`warmup` |
| `scripts/*-automation.js` | `sendViaPage`、`submitViaPage`、`installChannelPostOverride`、`submitViaFetch` |
| `scripts/channel-params.js` | `collectTouchpointParams`、白名单、别名 |
## channel-params 白名单模板
与 H5 前端 `productThemes`(或等价)保持同步:
```javascript
const CHANNEL_PARAM_KEYS = new Set([
'equityProductCode',
'accessChannel',
'packageName',
'packageRoute',
'sChannelNumber',
'firstChannel',
'secondChannel',
'reportMaterialsNo',
'fixedPositionCode',
'fixedPositionPath',
'pageName',
'pageRoute',
'clientIp',
'proxyOrderUrl',
'userAgent',
]);
const CHANNEL_PARAM_ALIASES = {
rmn: 'reportMaterialsNo',
fpc: 'fixedPositionCode',
fpp: 'fixedPositionPath',
pgn: 'pageName',
// ... 与 H5 侧一致
};
```
## installChannelPostOverride 模板
```javascript
async function installChannelPostOverride(page, urlPattern, channelParams) {
const overrides =
channelParams && Object.keys(channelParams).length > 0 ? channelParams : null;
if (!overrides) {
return { teardown: async () => {}, getMergedBody: () => null };
}
let mergedBody = null;
const routeGlob = '**/your-downstream-path/sms/send'; // 按项目替换
const handler = async (route) => {
const request = route.request();
if (request.method() !== 'POST') {
await route.continue();
return;
}
let body = {};
try {
body = request.postDataJSON() || {};
} catch {
await route.continue();
return;
}
mergedBody = { ...body, ...overrides };
const response = await route.fetch({ postData: JSON.stringify(mergedBody) });
await route.fulfill({ response });
};
await page.route(routeGlob, handler);
return {
teardown: async () => page.unroute(routeGlob, handler),
getMergedBody: () => mergedBody,
};
}
```
## API 入参解析模板
```javascript
function parseSendParams(source) {
const reserved = new Set([
'phone', 'productCode', 'userAgent', 'headless',
'extraParams', 'channelParams', 'touchpoint',
]);
const { hncnParams: channelParams, urlParams: extraParams } =
collectTouchpointParams(source, reserved);
return {
phone: source.phone,
productCode: source.productCode || DEFAULT_PRODUCT_CODE,
channelParams,
extraParams,
pageUrl: buildPageUrl({ productCode, extraParams }),
// ...
};
}
```
## POST 调用示例(脱敏)
```bash
curl -X POST "http://localhost:{PORT}/api/sms/send" \
-H "Content-Type: application/json" \
-d '{
"phone": "1XXXXXXXXXX",
"productCode": "PROD_XXX",
"channelParams": {
"reportMaterialsNo": "{APPROVED_MATERIAL_ID}",
"fixedPositionCode": "{POSITION_CODE}"
}
}'
```
```bash
curl -X POST "http://localhost:{PORT}/api/order/submit" \
-H "Content-Type: application/json" \
-d '{
"phone": "1XXXXXXXXXX",
"sid": "{SEND_REQ_NO}",
"verifyCode": "123456",
"productCode": "PROD_XXX",
"channelParams": {
"reportMaterialsNo": "{APPROVED_MATERIAL_ID}"
}
}'
```
## sid 提取优先级
```javascript
function extractSid(body) {
const data = body?.data;
if (!data) return null;
return data.sendReqNo ?? data.querySmsNumber ?? data.sid ?? data.verifyNo ?? null;
}
```
勿把固定占位 verifyNo 当作 sid。
## 容量规划
| 指标 | 单实例参考 |
|------|------------|
| Playwright 并发 | 1 |
| 预热后发码 | ~2s/次,约 30 次/分钟 |
| 会话 TTL | 10 分钟 |
| 内存 | 建议 ≥ 2G |
高 QPS:多实例 + 负载均衡,每实例独立浏览器与会话。