@taole/dd-playwright 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  增强型 stdio MCP 服务与 CLI 工具:Playwright 浏览器自动化 + 隔离 whistle 代理 + 点点账号 storageState 免登注入。
4
4
  可挂载到任意 AI 客户端(omp / Cursor / Claude Desktop / Codex 等)使用,不依赖任何特定宿主项目。
5
5
 
6
+ > 📖 **团队知识库与使用手册**:[dd-playwright 钉钉在线文档](https://alidocs.dingtalk.com/i/nodes/14lgGw3P8vL9wkv1FnOLZAlkV5daZ90D?utm_scene=person_space)(持续同步最新工具契约与最佳实践)
6
7
  ## 能力
7
8
 
8
9
  | 工具 | 说明 |
@@ -19,6 +20,7 @@
19
20
  | `browser_get_dom` | 提取当前页面的结构化可交互 DOM 树(按钮/链接/输入框及其选择器),免去盲猜 |
20
21
  | `browser_eval_js` | 页面上下文执行 JS |
21
22
  | `browser_set_proxy_rule` | 声明「本地 dev server → 干净 `*.tuwan.com` 域名」映射,隔离 whistle 代理自动托管 |
23
+ | `browser_load_proxy_rules_dir` | 批量加载项目规则目录(如 `.whistle-rules`),全量注入并激活域名映射与 Mock 规则 |
22
24
  | `browser_status` | 查询登录态挂载情况、当前 UID、代理状态、数据目录,排查免登问题先调它 |
23
25
 
24
26
  ## 安装与挂载
@@ -30,20 +32,11 @@ npm install
30
32
  npx playwright install chromium
31
33
  ```
32
34
 
33
- MCP 客户端配置(以 omp / Cursor / Claude 为例):
34
-
35
- ```json
36
- {
37
- "mcpServers": {
38
- "dd-playwright": {
39
- "command": "dd-playwright-mcp"
40
- }
41
- }
42
- }
43
- ```
44
-
45
- 或者直接指向本地入口:`node D:/tools/dd-browser-mcp/bin/mcp-server.mjs`
35
+ ### MCP 客户端挂载配置
46
36
 
37
+ 启动后台控制台后,直接在浏览器打开 **`http://127.0.0.1:12309`**:
38
+ - 切换到 **「MCP 客户端配置」** 选项卡。
39
+ - 页面会自动根据你当前的系统与安装路径生成准确的配置 JSON,支持**一键复制**或**一键安装到 Cursor**,开箱即用,无需手动拼接路径。
47
40
  ## 数据与配置
48
41
 
49
42
  - **数据目录**:默认自动固定为 `~/.dd-browser-mcp`(账号库 `runtime.db` 与截图目录),无需手动设置环境变量。
@@ -72,6 +65,9 @@ dd-pw statusweb
72
65
  # 停止后台运行的服务:
73
66
  dd-pw stopweb
74
67
 
68
+ # 一键检查并升级到最新版本:
69
+ dd-pw update
70
+
75
71
  # (可选)前台阻塞运行(Ctrl+C 退出):
76
72
  dd-pw serve [--port 12309]
77
73
  ```
package/SKILL.md CHANGED
@@ -17,10 +17,19 @@ description: >-
17
17
  ## 2. 本地开发服务联调(Dev Server 代理映射)
18
18
  - 当本地启动了前端开发服务器(如 Vite/Webpack 监听本地端口)时,**严禁修改系统 hosts**。
19
19
  - 必须先调用本 MCP 的 `browser_set_proxy_rule` 将本地端口映射到合法的点点子域名(子域名不要求在线上真实存在,支持任意自定义前缀)。
20
- - **示例**:
20
+ - **单域名映射示例**:
21
21
  - 本地 dev server 运行在 `127.0.0.1:9527` 时,可映射为自定义域名 `p9527-test.tuwan.com`:
22
22
  `browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })`
23
23
  - 随后调用 `browser_navigate({ url: "http://p9527-test.tuwan.com/" })` 访问,进程内隔离 Whistle 代理会自动转发流量并正确携带点点线上 Cookie。
24
+ - **项目规则目录批量加载(推荐)**:
25
+ - 若项目中存在 `.whistle-rules` 等包含多份代理/Mock 规则文件的目录,直接调用 `browser_load_proxy_rules_dir({ rulesDir: "绝对路径" })` 全量注入激活。
26
+
27
+ ### 附:常用 Whistle 规则语法参考(编写 .whistle-rules 目录时)
28
+ 1. **域名与端口映射**:`p9527-test.tuwan.com http://127.0.0.1:9527`
29
+ 2. **接口 Mock / 响应替换**:`yapi.tuwan.com/act resBody://D:/mock/data.json`
30
+ 3. **静态资源替换**:`act.tuwan.com/cdn/index.js D:/dist/index.js`
31
+ 4. **请求头注入**:`y.tuwan.com reqHeaders://{debug: '1'}`
32
+ > 更多高级协议与规则参考官方文档:https://wproxy.org/whistle/rules/
24
33
 
25
34
  ## 3. 页面交互规范
26
35
  - **元素定位**:在动态页面或选择器不确定时,优先调用 `browser_get_dom` 提取当前可见的交互元素结构(包含按钮、输入框、链接及其选择器),严禁盲猜选择器。
package/bin/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  stopWebDaemon,
8
8
  getWebDaemonStatus,
9
9
  } from "../src/server/daemon.mjs";
10
+ import { handleUpdateCommand } from "../src/updater.mjs";
10
11
 
11
12
  const args = process.argv.slice(2);
12
13
  const command = args[0];
@@ -20,6 +21,7 @@ dd-playwright (别名: dd-pw) CLI
20
21
  dd-pw stopweb 停止后台运行的 Web 控制台
21
22
  dd-pw statusweb 查看后台 Web 控制台运行状态与端口
22
23
  dd-pw serve [--port 12309] 前台阻塞启动 Web 控制台(按 Ctrl+C 退出)
24
+ dd-pw update 检查远端最新版本并自动升级
23
25
  dd-pw mcp 以 stdio 模式启动 MCP 浏览器服务
24
26
  dd-pw --help, -h 查看帮助信息
25
27
 
@@ -158,6 +160,8 @@ async function main() {
158
160
  console.error(`[dd-playwright] 启动失败:`, err);
159
161
  process.exit(1);
160
162
  }
163
+ } else if (command === "update" || command === "upgrade") {
164
+ await handleUpdateCommand();
161
165
  } else if (command === "mcp") {
162
166
  // 转发至 mcp-server.mjs
163
167
  await import("./mcp-server.mjs");
@@ -11,7 +11,7 @@ import { resolveConfig } from "../src/config.mjs";
11
11
  import { PlaywrightBrowserManager } from "../src/browser-manager.mjs";
12
12
  import { pathToFileURL } from "node:url";
13
13
 
14
- export const MCP_VERSION = "0.1.6";
14
+ export const MCP_VERSION = "0.1.8";
15
15
 
16
16
  function textResult(payload) {
17
17
  return {
@@ -336,6 +336,37 @@ export function createBrowserMcpServer({ manager, config }) {
336
336
  }
337
337
  },
338
338
  );
339
+ server.registerTool(
340
+ "browser_load_proxy_rules_dir",
341
+ {
342
+ description:
343
+ "从指定本地目录批量加载 Whistle 规则文件(如项目的 .whistle-rules 目录),将其全量注入并激活到进程内 Whistle 代理中。" +
344
+ "支持目录下包含多个 .rule / .txt / .whistle 文件(如域名端口映射、Mock 接口 resBody、静态资源重定向等)。" +
345
+ "调用后浏览器会自动重启连接以加载新规则。",
346
+ inputSchema: {
347
+ rulesDir: z
348
+ .string()
349
+ .describe("包含 Whistle 规则文件的本地绝对路径目录"),
350
+ clearExisting: z
351
+ .boolean()
352
+ .optional()
353
+ .describe("是否清空已有的旧规则,默认 false(增量合并注入)"),
354
+ },
355
+ },
356
+ async ({ rulesDir, clearExisting = false }) => {
357
+ try {
358
+ const res = await manager.loadProxyRulesDir({ rulesDir, clearExisting });
359
+ return textResult({
360
+ success: true,
361
+ message: `已成功加载目录规则:${rulesDir}`,
362
+ ...res,
363
+ });
364
+ } catch (err) {
365
+ return errorResult("加载目录代理规则失败", err);
366
+ }
367
+ },
368
+ );
369
+
339
370
 
340
371
  server.registerTool(
341
372
  "browser_eval_js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taole/dd-playwright",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Standalone stdio MCP & CLI: Playwright browser automation + isolated whistle proxy + diandian account storageState injection",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -140,6 +140,18 @@ export class PlaywrightBrowserManager {
140
140
  await this._closeBrowserOnly();
141
141
  return this.proxyRule;
142
142
  }
143
+ /**
144
+ * 从指定目录批量加载 Whistle 规则并重启浏览器加载规则。
145
+ * @param {{rulesDir: string, clearExisting?: boolean}} params
146
+ */
147
+ async loadProxyRulesDir({ rulesDir, clearExisting = false }) {
148
+ const whistle = await this.ensureProxy();
149
+ const result = await whistle.loadRulesFromDirectory(rulesDir, { clearExisting });
150
+ // 重启浏览器以清除连接与 DNS 缓存,确保新规则立即生效
151
+ await this._closeBrowserOnly();
152
+ return result;
153
+ }
154
+
143
155
 
144
156
  /**
145
157
  * 根据指定的 uid 或 anonymous 状态准备浏览器上下文。
@@ -381,10 +381,9 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
381
381
  • 访问点点业务页面必须调用本 MCP 的 <code>browser_navigate</code> 工具,自动注入当前激活账号的 storageState(含 <code>Tuwan_Passport</code> 鉴权 Cookie)免登。<br>
382
382
  • 多角色或指定账号测试时传入特定 <code>uid</code>,自动隔离并重建独立浏览器上下文。<br><br>
383
383
  <b>2. 本地开发服务联调(Dev Server 代理映射):</b><br>
384
- • 本地服务启动后严禁改动系统 hosts,必须先调用 <code>browser_set_proxy_rule</code> 映射到点点子域名。<br>
385
- • <b>示例</b>:本地 dev server 运行在 <code>127.0.0.1:9527</code> 时,可映射为自定义域名 <code>p9527-test.tuwan.com</code>(域名无需线上真实存在):<br>
386
- <code style="color: #38bdf8;">browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })</code><br>
387
- 随后访问该域名,内部隔离 Whistle 代理会自动转发流量并正确携带线上 Cookie。<br><br>
384
+ • 本地服务启动后严禁改动系统 hosts,可通过单域名映射或项目规则目录全量注入:<br>
385
+ • <b>单条映射</b>:<code>browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })</code><br>
386
+ • <b>目录批量规则(推荐)</b>:若项目有 <code>.whistle-rules</code> 目录,直接调用 <code>browser_load_proxy_rules_dir({ rulesDir: "目录绝对路径" })</code> 全量激活代理与 Mock。<br><br>
388
387
  <b>3. 页面交互规范:</b><br>
389
388
  • 复杂或动态页面定位困难时,优先调用 <code>browser_get_dom</code> 获取可见元素清单,严禁盲猜选择器。<br>
390
389
  • 表单填写使用 <code>browser_fill_text</code>,按键使用 <code>browser_press_key</code>。
@@ -742,18 +741,6 @@ export function createWebServer({ accountStore, authManager }) {
742
741
  new URL("../../bin/mcp-server.mjs", import.meta.url),
743
742
  ).replaceAll("\\", "/");
744
743
 
745
- const globalConfigStr = JSON.stringify(
746
- {
747
- mcpServers: {
748
- "dd-playwright": {
749
- command: "dd-playwright-mcp",
750
- },
751
- },
752
- },
753
- null,
754
- 2,
755
- );
756
-
757
744
  const localConfigStr = JSON.stringify(
758
745
  {
759
746
  mcpServers: {
@@ -0,0 +1,111 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { execSync } from "node:child_process";
4
+
5
+ export const PACKAGE_NAME = "@taole/dd-playwright";
6
+
7
+ /**
8
+ * 比较两个语义化版本号 (semver)
9
+ * @param {string} v1
10
+ * @param {string} v2
11
+ * @returns {number} 1: v1 > v2; -1: v1 < v2; 0: v1 == v2
12
+ */
13
+ export function compareSemver(v1, v2) {
14
+ const parse = (v) =>
15
+ (v || "")
16
+ .replace(/^v/i, "")
17
+ .split("-")[0]
18
+ .split(".")
19
+ .map((n) => parseInt(n, 10) || 0);
20
+
21
+ const [maj1, min1, pat1] = parse(v1);
22
+ const [maj2, min2, pat2] = parse(v2);
23
+
24
+ if (maj1 !== maj2) return maj1 > maj2 ? 1 : -1;
25
+ if (min1 !== min2) return min1 > min2 ? 1 : -1;
26
+ if (pat1 !== pat2) return pat1 > pat2 ? 1 : -1;
27
+ return 0;
28
+ }
29
+
30
+ /**
31
+ * 获取本地安装的版本号
32
+ * @returns {string}
33
+ */
34
+ export function getLocalVersion() {
35
+ try {
36
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
37
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
38
+ return pkg.version || "0.0.0";
39
+ } catch {
40
+ return "0.0.0";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 查询 NPM 远端最新版本号
46
+ * @returns {Promise<string|null>}
47
+ */
48
+ export async function getRemoteLatestVersion() {
49
+ const registries = [
50
+ "https://registry.npmjs.org/",
51
+ "https://registry.npmmirror.com/",
52
+ ];
53
+
54
+ for (const reg of registries) {
55
+ try {
56
+ const url = `${reg}${PACKAGE_NAME}/latest`;
57
+ const res = await fetch(url, {
58
+ headers: { Accept: "application/json" },
59
+ signal: AbortSignal.timeout(4000),
60
+ });
61
+ if (res.ok) {
62
+ const data = await res.json();
63
+ if (data.version) return data.version;
64
+ }
65
+ } catch {}
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * 执行 update 命令核心流程
72
+ */
73
+ export async function handleUpdateCommand(overrideCurrentVersion = null) {
74
+ const currentVersion = overrideCurrentVersion || getLocalVersion();
75
+ console.log(`[dd-playwright] 正在检查 npm 远端最新版本...`);
76
+
77
+ const latestVersion = await getRemoteLatestVersion();
78
+ if (!latestVersion) {
79
+ console.warn(`[dd-playwright] 无法连接到 npm 注册表查询最新版本,请检查网络。`);
80
+ console.log(`您可直接尝试执行手动更新命令:\n npm install -g ${PACKAGE_NAME}@latest\n`);
81
+ return;
82
+ }
83
+
84
+ const cmp = compareSemver(latestVersion, currentVersion);
85
+ if (cmp <= 0) {
86
+ console.log(`🎉 恭喜!当前已是最新版本 (v${currentVersion}),无需更新。\n`);
87
+ return;
88
+ }
89
+
90
+ console.log(`\n==================================================`);
91
+ console.log(` 🚀 发现新版本可用: v${currentVersion} -> v${latestVersion}`);
92
+ console.log(`==================================================\n`);
93
+
94
+ console.log(`[dd-playwright] 正在尝试自动执行全局升级 (npm install -g ${PACKAGE_NAME}@latest)...`);
95
+
96
+ try {
97
+ // 优先尝试自动升级
98
+ execSync(`npm install -g ${PACKAGE_NAME}@latest`, {
99
+ stdio: "inherit",
100
+ windowsHide: true,
101
+ });
102
+ console.log(`\n🎉 自动更新完成!已成功升级至最新版本 v${latestVersion}。\n`);
103
+ console.log(`提示:若当前后台正在运行 Web 服务,建议重启生效:`);
104
+ console.log(` dd-pw stopweb && dd-pw startweb\n`);
105
+ } catch (err) {
106
+ // 若因系统权限不足等原因报错,优雅降级,打印完整手工升级命令
107
+ console.warn(`\n⚠️ 自动升级未完成(可能需要管理员权限或当前终端权限受限)。`);
108
+ console.log(`请复制下方命令并在终端中执行升级:\n`);
109
+ console.log(`👉 npm install -g ${PACKAGE_NAME}@latest\n`);
110
+ }
111
+ }
@@ -8,9 +8,18 @@
8
8
  * - 仅监听 127.0.0.1,不对外暴露
9
9
  * - 域名后缀限制可声明规则;启用后浏览器流量均经代理转发
10
10
  */
11
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
11
+ import {
12
+ mkdtempSync,
13
+ writeFileSync,
14
+ mkdirSync,
15
+ rmSync,
16
+ existsSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ statSync,
20
+ } from "node:fs";
12
21
  import { tmpdir } from "node:os";
13
- import { join } from "node:path";
22
+ import { join, extname } from "node:path";
14
23
  import { createRequire } from "node:module";
15
24
 
16
25
  let startWhistleFn = null;
@@ -169,6 +178,103 @@ export class WhistleProxyManager {
169
178
  const url = protocol === "https" ? `https://${domain}/` : `http://${domain}/`;
170
179
  return { url, rule: ruleText };
171
180
  }
181
+ /**
182
+ * 从指定目录加载 Whistle 规则文件并全量激活。
183
+ * @param {string} rulesDir 规则文件所在的本地绝对路径
184
+ * @param {{clearExisting?: boolean}} [options] 是否清空已有的旧规则(默认 false,增量覆盖)
185
+ * @returns {Promise<{success: boolean, loadedFiles: string[], totalRulesCount: number, rulesSummary: string}>}
186
+ */
187
+ async loadRulesFromDirectory(rulesDir, { clearExisting = false } = {}) {
188
+ if (!this.started) {
189
+ throw new Error("Whistle proxy not started. Call start() first.");
190
+ }
191
+ if (!rulesDir || typeof rulesDir !== "string") {
192
+ throw new Error("必须提供有效的 rulesDir 路径");
193
+ }
194
+ if (!existsSync(rulesDir) || !statSync(rulesDir).isDirectory()) {
195
+ throw new Error(`目标路径不存在或不是有效目录: ${rulesDir}`);
196
+ }
197
+
198
+ const entries = readdirSync(rulesDir);
199
+ const loadedFiles = [];
200
+ const collectedRuleLines = [];
201
+
202
+ for (const file of entries) {
203
+ const fullPath = join(rulesDir, file);
204
+ try {
205
+ const stat = statSync(fullPath);
206
+ if (stat.isDirectory()) continue;
207
+
208
+ // 读取文本文件内容
209
+ const content = readFileSync(fullPath, "utf8").trim();
210
+ if (!content) continue;
211
+
212
+ loadedFiles.push(file);
213
+ collectedRuleLines.push(`# === From File: ${file} ===`);
214
+ collectedRuleLines.push(content);
215
+ collectedRuleLines.push("");
216
+ } catch {}
217
+ }
218
+
219
+ if (loadedFiles.length === 0) {
220
+ return {
221
+ success: true,
222
+ loadedFiles: [],
223
+ totalRulesCount: 0,
224
+ rulesSummary: "目录中未发现可读的规则文件",
225
+ };
226
+ }
227
+
228
+ let finalRulesText = collectedRuleLines.join("\n");
229
+
230
+ // 如果不需要清空已有规则,读取当前 default 规则合并
231
+ if (!clearExisting) {
232
+ try {
233
+ const listRes = await fetch(
234
+ `http://127.0.0.1:${this.port}/cgi-bin/rules/list`,
235
+ );
236
+ const listData = await listRes.json();
237
+ const currentDefault = listData.list?.find((r) => r.name === "default")?.data || "";
238
+ if (currentDefault.trim()) {
239
+ finalRulesText = `${currentDefault.trim()}\n\n${finalRulesText}`;
240
+ }
241
+ } catch {}
242
+ }
243
+
244
+ // 通过 WebUI CGI 写入 default 规则并激活
245
+ const res = await fetch(
246
+ `http://127.0.0.1:${this.port}/cgi-bin/rules/select`,
247
+ {
248
+ method: "POST",
249
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
250
+ body: new URLSearchParams({
251
+ name: "default",
252
+ value: finalRulesText,
253
+ }).toString(),
254
+ },
255
+ );
256
+
257
+ if (!res.ok) {
258
+ throw new Error(`Failed to set directory whistle rules: HTTP ${res.status}`);
259
+ }
260
+ const result = await res.json();
261
+ if (result.ec !== 0) {
262
+ throw new Error(`Failed to set whistle rules: ${JSON.stringify(result)}`);
263
+ }
264
+
265
+ const nonCommentRules = finalRulesText
266
+ .split("\n")
267
+ .map((l) => l.trim())
268
+ .filter((l) => l && !l.startsWith("#"));
269
+
270
+ return {
271
+ success: true,
272
+ loadedFiles,
273
+ totalRulesCount: nonCommentRules.length,
274
+ rulesSummary: finalRulesText,
275
+ };
276
+ }
277
+
172
278
 
173
279
  /**
174
280
  * 浏览器应使用的 Chromium 启动参数。