@taole/dd-playwright 0.2.0 → 0.4.0

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
@@ -75,10 +75,21 @@ dd-pw version
75
75
  # 一键检查并升级到最新版本:
76
76
  dd-pw update
77
77
 
78
+ # 环境诊断(Node 版本 / Chromium 内核 / 账号库 / Web 服务 / 版本检查):
79
+ dd-pw doctor
80
+
81
+ # 诊断并自动修复安全项(当前为缺失的 Chromium 内核):
82
+ dd-pw doctor --fix
83
+
84
+ # 获取 npm 最新发布的 Skill 内容和来源版本(JSON,不写本地文件):
85
+ dd-pw skill get
86
+
78
87
  # (可选)前台阻塞运行(Ctrl+C 退出):
79
88
  dd-pw serve [--port 12309]
80
89
  ```
81
90
 
91
+ MCP 的 `skill_get_latest` 和 CLI 的 `dd-pw skill get` 获取 npm 最新发布包中的 `SKILL.md`,校验完整性后返回内容与来源版本,不安装 Skill、不修改本地文件或 MCP 配置。Agent 可按当前工具支持的路径安装;覆盖可能包含本地修改的文件前应取得用户授权。升级 dd-pw 后需重新连接 MCP 才能发现新工具;更新 Skill 后可能需要重载 Agent 或开启新会话。
92
+
82
93
  1. 启动后在浏览器打开 `http://127.0.0.1:12309`。在「全局运行配置」的「软件更新」中可检查 npm 最新版本;发现新版本时,可在终端执行 `dd-pw update`,更新后在 Agent 或 IDE 中重载 MCP。源码或项目依赖运行时请按对应安装方式手动更新。
83
94
  2. 点击右上角 **「+ 添加绑定账号」**,后台会以独立的 Playwright 实例拉起扫码页并渲染二维码。
84
95
  3. 使用微信或点点 App 扫码登录,完成后会自动捕获 Cookie 与 `storageState`,提取点点账号 UID、昵称与头像,并落库至 `$BROWSER_MCP_DATA_DIR/runtime.db`。
package/SKILL.md CHANGED
@@ -9,6 +9,12 @@ description: >-
9
9
 
10
10
  本技能必须通过 **`dd-playwright` MCP 服务**提供的 `browser_*` 系列工具执行。
11
11
 
12
+ ## Skill 更新
13
+ - 用户要求更新 dd-playwright Skill 时,调用 MCP `skill_get_latest` 获取 npm 最新发布的完整内容及版本;该工具只读取内容,不安装或修改本地文件。
14
+ - 根据当前工具实际支持的 Skill 路径安装;不确定当前工具、路径或作用域时询问用户,不猜测。尊重用户对 `.agents` 等目录的选择。
15
+ - 写入前检查现有文件并比较内容;若不一致且无法确认没有本地修改,先告知路径和覆盖风险,取得用户明确授权后再覆盖。内容一致则无需写入。
16
+ - 报告来源版本与实际路径;可能需要重载 Agent 或开启新会话。不修改 MCP 配置,也不升级 dd-pw。旧 MCP 缺少该工具时先升级 dd-pw 并重新连接 MCP。
17
+
12
18
  ## 1. 登录态与身份切换
13
19
  - **每次导航必须显式选择身份**:默认账号使用 `identity: { mode: "default" }`;游客使用 `identity: { mode: "anonymous" }`;指定账号使用 `identity: { mode: "uid", uid: "28689781" }`。缺少身份或参数冲突会报错。
14
20
  - **身份隔离**:相同身份导航复用上下文;切换身份会关闭当前页面并重建上下文。游客模式不注入登录态,默认账号模式使用当前激活账号或配置的凭据。
package/bin/cli.mjs CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  getStoredWebPort,
10
10
  } from "../src/server/daemon.mjs";
11
11
  import { getLocalVersion, handleUpdateCommand } from "../src/updater.mjs";
12
+ import { runDoctor, formatDoctorReport } from "../src/doctor.mjs";
13
+ import { getLatestSkill } from "../src/skill-manager.mjs";
12
14
 
13
15
  const args = process.argv.slice(2);
14
16
  const command = args[0];
@@ -23,6 +25,8 @@ dd-playwright (别名: dd-pw) CLI
23
25
  dd-pw statusweb 查看后台 Web 控制台运行状态与端口
24
26
  dd-pw serve [--port 12309] 前台阻塞启动 Web 控制台(按 Ctrl+C 退出)
25
27
  dd-pw version, --version, -v 查看当前版本
28
+ dd-pw doctor [--fix] 诊断环境(Node/Chromium/数据库/Web 服务/版本)
29
+ dd-pw skill get 输出 npm 最新发布的 Skill 内容及版本(JSON)
26
30
  dd-pw update 检查远端最新版本并自动升级
27
31
  dd-pw mcp 以 stdio 模式启动 MCP 浏览器服务
28
32
  dd-pw --help, -h 查看帮助信息
@@ -167,6 +171,20 @@ async function main() {
167
171
  console.error(`[dd-playwright] 启动失败:`, err);
168
172
  process.exit(1);
169
173
  }
174
+ } else if (command === "doctor") {
175
+ const report = await runDoctor({
176
+ config: resolveConfig(),
177
+ fix: args.includes("--fix"),
178
+ });
179
+ console.log(formatDoctorReport(report));
180
+ // 用 exitCode 而非 process.exit():Windows 下显式退出与在途 fetch 的
181
+ // handle 关闭竞争会触发 uv async.c 断言崩溃
182
+ process.exitCode = report.ok ? 0 : 1;
183
+ } else if (command === "skill") {
184
+ if (args.length !== 2 || args[1] !== "get") {
185
+ throw new Error("用法:dd-pw skill get");
186
+ }
187
+ console.log(JSON.stringify(await getLatestSkill(), null, 2));
170
188
  } else if (command === "update" || command === "upgrade") {
171
189
  await handleUpdateCommand();
172
190
  } else if (command === "mcp") {
@@ -9,6 +9,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
  import * as z from "zod/v4";
10
10
  import { resolveConfig } from "../src/config.mjs";
11
11
  import { PlaywrightBrowserManager } from "../src/browser-manager.mjs";
12
+ import { getLatestSkill } from "../src/skill-manager.mjs";
12
13
  import { pathToFileURL } from "node:url";
13
14
 
14
15
  export const MCP_VERSION = "0.1.14";
@@ -445,6 +446,21 @@ export function createBrowserMcpServer({ manager, config }) {
445
446
  });
446
447
  },
447
448
  );
449
+ server.registerTool(
450
+ "skill_get_latest",
451
+ {
452
+ description: "从 npm 最新发布包获取并校验 dd-playwright 的 SKILL.md,返回包名、版本及完整内容;不安装、不修改本地文件或 MCP 配置。",
453
+ inputSchema: {},
454
+ },
455
+ async () => {
456
+ try {
457
+ return textResult(await getLatestSkill());
458
+ } catch (err) {
459
+ return errorResult("获取 Skill 失败", err);
460
+ }
461
+ },
462
+ );
463
+
448
464
  return server;
449
465
  }
450
466
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taole/dd-playwright",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",
package/src/doctor.mjs ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * dd-pw doctor:环境健康度诊断。只读检查,不启动浏览器/代理,不做修复。
3
+ * 每项检查返回 { name, ok, detail, hint };exit code 由汇总结果决定。
4
+ */
5
+ import { existsSync, accessSync, constants, readFileSync } from "node:fs";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { join } from "node:path";
8
+ import { getChromiumMetadata } from "./browser-check.mjs";
9
+ import { getLocalVersion, getRemoteLatestVersion, compareSemver } from "./updater.mjs";
10
+ import { getStoredWebPort, getStoredWebPid } from "./server/daemon.mjs";
11
+
12
+ const MIN_NODE = [22, 13, 0];
13
+
14
+ function check(name, fn) {
15
+ return Promise.resolve()
16
+ .then(fn)
17
+ .then((r) => ({ name, ok: r.ok !== false, detail: r.detail ?? "", hint: r.hint ?? "" }))
18
+ .catch((err) => ({ name, ok: false, detail: String(err.message || err), hint: "" }));
19
+ }
20
+
21
+ function checkNodeVersion() {
22
+ const [maj, min, pat] = process.versions.node.split(".").map((n) => parseInt(n, 10));
23
+ const ok =
24
+ maj > MIN_NODE[0] ||
25
+ (maj === MIN_NODE[0] && min > MIN_NODE[1]) ||
26
+ (maj === MIN_NODE[0] && min === MIN_NODE[1] && pat >= MIN_NODE[2]);
27
+ return {
28
+ detail: `当前 ${process.version},要求 >= ${MIN_NODE.join(".")}`,
29
+ ok,
30
+ hint: ok ? "" : "升级 Node.js 后重试(依赖内置 node:sqlite)",
31
+ };
32
+ }
33
+
34
+ async function checkChromium(fix) {
35
+ const meta = await getChromiumMetadata();
36
+ if (meta.installed && meta.version) {
37
+ return { detail: `${meta.version} (${meta.executablePath})` };
38
+ }
39
+ const hint = "执行 npx playwright install chromium 补齐内核";
40
+ if (fix) {
41
+ const { ensureChromiumInstalled } = await import("./browser-check.mjs");
42
+ const done = await ensureChromiumInstalled({ silent: true });
43
+ if (done) {
44
+ const after = await getChromiumMetadata();
45
+ return { detail: `已自动修复:${after.version} (${after.executablePath})` };
46
+ }
47
+ }
48
+ return { ok: false, detail: meta.error || `内核缺失:${meta.executablePath || "未知路径"}`, hint };
49
+ }
50
+
51
+ function checkDataDir(config) {
52
+ const dir = config.dataDir;
53
+ if (!existsSync(dir)) {
54
+ return { ok: true, detail: `${dir} 尚不存在(首次使用时自动创建)` };
55
+ }
56
+ accessSync(dir, constants.R_OK | constants.W_OK);
57
+ return { detail: `${dir} 可读写` };
58
+ }
59
+
60
+ function checkDatabase(config) {
61
+ const dbPath = config.dbPath;
62
+ if (!existsSync(dbPath)) {
63
+ return { ok: true, detail: `${dbPath} 不存在(未绑定账号属正常)` };
64
+ }
65
+ const db = new DatabaseSync(dbPath, { readOnly: true });
66
+ try {
67
+ const tables = db
68
+ .prepare("SELECT name FROM sqlite_master WHERE type='table'")
69
+ .all()
70
+ .map((r) => r.name);
71
+ const missing = ["diandian_accounts", "app_settings"].filter((t) => !tables.includes(t));
72
+ if (missing.length) {
73
+ return {
74
+ ok: false,
75
+ detail: `数据库缺少表:${missing.join(", ")}`,
76
+ hint: "数据库可能来自旧版本或已损坏;可备份后删除 runtime.db 重新扫码绑定",
77
+ };
78
+ }
79
+ const row = db
80
+ .prepare("SELECT account_id, nickname, storage_state FROM diandian_accounts WHERE is_active = 1 LIMIT 1")
81
+ .get();
82
+ if (!row) {
83
+ return { ok: true, detail: "数据库正常,未设置激活账号" };
84
+ }
85
+ try {
86
+ JSON.parse(row.storage_state);
87
+ return { detail: `激活账号 ${row.account_id}(${row.nickname || "未知昵称"}),storageState 可解析` };
88
+ } catch {
89
+ return {
90
+ ok: false,
91
+ detail: `激活账号 ${row.account_id} 的 storageState 无法解析`,
92
+ hint: "在 Web 控制台重新扫码绑定该账号",
93
+ };
94
+ }
95
+ } finally {
96
+ db.close();
97
+ }
98
+ }
99
+
100
+ async function checkWebDaemon(config) {
101
+ const pid = getStoredWebPid(config.webPidFile);
102
+ if (!pid) {
103
+ return { ok: true, detail: "后台 Web 控制台未运行(dd-pw startweb 启动)" };
104
+ }
105
+ const storedPort = getStoredWebPort(config.dataDir) || 12309;
106
+ try {
107
+ const res = await fetch(`http://127.0.0.1:${storedPort}/api/accounts`, {
108
+ signal: AbortSignal.timeout(2000),
109
+ });
110
+ if (res.ok) {
111
+ return { detail: `运行中 PID=${pid},http://127.0.0.1:${storedPort} 可访问` };
112
+ }
113
+ return { ok: false, detail: `PID=${pid} 存在但端口 ${storedPort} 响应 ${res.status}`, hint: "dd-pw stopweb 后重新 startweb" };
114
+ } catch {
115
+ return { ok: false, detail: `PID=${pid} 存在但端口 ${storedPort} 不可访问`, hint: "PID 文件可能残留,执行 dd-pw stopweb 清理后重新 startweb" };
116
+ }
117
+ }
118
+
119
+ async function checkVersion() {
120
+ const local = getLocalVersion();
121
+ const remote = await getRemoteLatestVersion();
122
+ if (!remote) {
123
+ return { ok: true, detail: `本地 ${local},远端版本查询失败(网络问题,不影响使用)` };
124
+ }
125
+ const cmp = compareSemver(local, remote);
126
+ if (cmp < 0) {
127
+ return { ok: false, detail: `本地 ${local} 落后于远端 ${remote}`, hint: "执行 dd-pw update 升级" };
128
+ }
129
+ return { detail: `本地 ${local} 已是最新` };
130
+ }
131
+
132
+ /**
133
+ * 执行全部诊断
134
+ * @param {object} options
135
+ * @param {object} options.config resolveConfig() 结果
136
+ * @param {boolean} [options.fix] 允许自动修复安全项(当前仅 Chromium 内核缺失)
137
+ * @returns {Promise<{ok: boolean, results: Array}>}
138
+ */
139
+ export async function runDoctor({ config, fix = false }) {
140
+ const results = await Promise.all([
141
+ check("Node 版本", checkNodeVersion),
142
+ check("Chromium 内核", () => checkChromium(fix)),
143
+ check("数据目录", () => checkDataDir(config)),
144
+ check("账号数据库", () => checkDatabase(config)),
145
+ check("Web 控制台", () => checkWebDaemon(config)),
146
+ check("包版本", checkVersion),
147
+ ]);
148
+ return { ok: results.every((r) => r.ok), results };
149
+ }
150
+
151
+ export function formatDoctorReport({ ok, results }) {
152
+ const lines = results.map((r) => {
153
+ const mark = r.ok ? "✔" : "✘";
154
+ let line = `${mark} ${r.name}: ${r.detail}`;
155
+ if (!r.ok && r.hint) line += `\n 建议: ${r.hint}`;
156
+ return line;
157
+ });
158
+ lines.push(ok ? "\n全部检查通过。" : `\n发现 ${results.filter((r) => !r.ok).length} 项问题。`);
159
+ return lines.join("\n");
160
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { gunzipSync } from 'node:zlib';
3
+
4
+ const PACKAGE = '@taole/dd-playwright';
5
+ const MAX_ARCHIVE = 20 * 1024 * 1024;
6
+ const MAX_UNPACKED = 50 * 1024 * 1024;
7
+
8
+ async function fetchBytes(fetchImpl, url, limit) {
9
+ const response = await fetchImpl(url);
10
+ if (!response.ok) throw new Error(`Request failed (${response.status}): ${url}`);
11
+ const length = Number(response.headers?.get('content-length'));
12
+ if (length > limit) throw new Error('Download exceeds size limit');
13
+ // Stream when available to avoid buffering an unbounded response.
14
+ if (response.body?.getReader) {
15
+ const reader = response.body.getReader();
16
+ const chunks = []; let size = 0;
17
+ try {
18
+ for (;;) {
19
+ const { done, value } = await reader.read();
20
+ if (done) break;
21
+ size += value.length;
22
+ if (size > limit) { await reader.cancel(); throw new Error('Download exceeds size limit'); }
23
+ chunks.push(Buffer.from(value));
24
+ }
25
+ } finally { reader.releaseLock(); }
26
+ return Buffer.concat(chunks, size);
27
+ }
28
+ const data = Buffer.from(await response.arrayBuffer());
29
+ if (data.length > limit) throw new Error('Download exceeds size limit');
30
+ return data;
31
+ }
32
+
33
+ function verifyIntegrity(data, integrity) {
34
+ if (typeof integrity !== 'string') throw new Error('Missing npm dist.integrity');
35
+ const tokens = integrity.trim().split(/\s+/).map(token => /^(sha512|sha384|sha256)-([A-Za-z0-9+/]+={0,2})(?:\?.*)?$/.exec(token)).filter(Boolean);
36
+ const algorithm = ['sha512', 'sha384', 'sha256'].find(name => tokens.some(token => token[1] === name));
37
+ if (!algorithm) throw new Error('Unsupported npm dist.integrity');
38
+ const actual = createHash(algorithm).update(data).digest();
39
+ if (!tokens.some(token => {
40
+ if (token[1] !== algorithm) return false;
41
+ const expected = Buffer.from(token[2], 'base64');
42
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
43
+ })) throw new Error('npm dist.integrity mismatch');
44
+ }
45
+
46
+ function extractSkill(archive) {
47
+ const tar = gunzipSync(archive, { maxOutputLength: MAX_UNPACKED });
48
+ let found = null;
49
+ for (let offset = 0; offset + 512 <= tar.length;) {
50
+ const header = tar.subarray(offset, offset + 512);
51
+ if (header.every(byte => byte === 0)) break;
52
+ const text = (start, end) => header.subarray(start, end).toString('utf8').replace(/\0.*$/, '');
53
+ const name = text(0, 100);
54
+ const prefix = text(345, 500);
55
+ const sizeText = text(124, 136).trim();
56
+ if (!/^[0-7]+$/.test(sizeText)) throw new Error('Invalid tar entry size');
57
+ const size = parseInt(sizeText, 8);
58
+ const start = offset + 512;
59
+ const next = start + Math.ceil(size / 512) * 512;
60
+ if (!Number.isSafeInteger(next) || next > tar.length) throw new Error('Truncated tar archive');
61
+ if ((prefix ? `${prefix}/${name}` : name) === 'package/SKILL.md') {
62
+ if (found || !['0', '\0'].includes(String.fromCharCode(header[156]))) throw new Error('Invalid SKILL.md tar entry');
63
+ found = Buffer.from(tar.subarray(start, start + size));
64
+ }
65
+ offset = next;
66
+ }
67
+ if (!found || !found.length) throw new Error('Package does not contain SKILL.md');
68
+ return found;
69
+ }
70
+
71
+ export async function getLatestSkill({ fetchImpl = globalThis.fetch, registry = 'https://registry.npmjs.org' } = {}) {
72
+ const metadata = JSON.parse((await fetchBytes(fetchImpl, `${registry.replace(/\/$/, '')}/${encodeURIComponent(PACKAGE)}/latest`, 2 * 1024 * 1024)).toString('utf8'));
73
+ if (metadata.name !== PACKAGE || typeof metadata.version !== 'string' || !metadata.version || typeof metadata.dist?.tarball !== 'string') throw new Error('Invalid npm package metadata');
74
+ const url = new URL(metadata.dist.tarball);
75
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') throw new Error('Insecure npm tarball URL');
76
+ const archive = await fetchBytes(fetchImpl, url.href, MAX_ARCHIVE);
77
+ verifyIntegrity(archive, metadata.dist.integrity);
78
+ return { package: PACKAGE, version: metadata.version, content: extractSkill(archive).toString('utf8') };
79
+ }
80
+
package/src/updater.mjs CHANGED
@@ -122,6 +122,15 @@ export async function handleUpdateCommand(overrideCurrentVersion = null) {
122
122
  });
123
123
  console.log(`\n🎉 自动升级成功!已成功升级至最新版本 v${latestVersion}。\n`);
124
124
 
125
+ // 升级后 Playwright 期望的 Chromium revision 可能变化,自动补齐内核,
126
+ // 避免下次启动 MCP 时报“浏览器二进制缺失”
127
+ try {
128
+ const { ensureChromiumInstalled } = await import("./browser-check.mjs");
129
+ await ensureChromiumInstalled();
130
+ } catch (err) {
131
+ console.warn(`[dd-playwright] Chromium 内核自动补齐失败: ${err.message}`);
132
+ console.log(`可稍后执行 dd-pw doctor --fix 重试。\n`);
133
+ }
125
134
  if (wasWebRunning) {
126
135
  console.log(`[dd-playwright] 正在自动为您重启后台 Web 服务...`);
127
136
  const restartRes = await startWebDaemon({