@taole/dd-playwright 0.0.0-stage → 0.1.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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Playwright 浏览器管理器:自动挂载点点账号 storageState,托管隔离 whistle 代理。
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { join } from "node:path";
7
+ import { chromium } from "playwright";
8
+ import { WhistleProxyManager } from "./whistle-proxy.mjs";
9
+
10
+ /**
11
+ * 从账号库或文件加载指定账号或激活账号的 storageState。
12
+ * 优先级:
13
+ * 1. anonymous === true -> null
14
+ * 2. uid 显式指定 -> 查询该 uid 账号的 storageState(不存在则报错)
15
+ * 3. 显式 storageStateFile -> 读取该文件
16
+ * 4. SQLite 中 is_active = 1 的账号
17
+ * 5. null(无登录态)
18
+ * @param {{dbPath: string, storageStateFile?: string|null, uid?: string|null, anonymous?: boolean}} options
19
+ */
20
+ export function resolveStorageState({
21
+ dbPath,
22
+ storageStateFile,
23
+ uid,
24
+ anonymous = false,
25
+ }) {
26
+ if (anonymous) return null;
27
+
28
+ if (uid) {
29
+ if (!existsSync(dbPath)) {
30
+ throw new Error(`无法按 UID [${uid}] 加载账号:数据库文件不存在 (${dbPath})`);
31
+ }
32
+ const db = new DatabaseSync(dbPath, { readOnly: true });
33
+ try {
34
+ const row = db
35
+ .prepare(
36
+ "SELECT account_id, nickname, storage_state FROM diandian_accounts WHERE account_id = ?",
37
+ )
38
+ .get(String(uid));
39
+ if (!row || !row.storage_state) {
40
+ throw new Error(`未找到 UID 为 [${uid}] 的点点账号`);
41
+ }
42
+ return JSON.parse(row.storage_state);
43
+ } finally {
44
+ db.close();
45
+ }
46
+ }
47
+
48
+ if (storageStateFile) {
49
+ if (!existsSync(storageStateFile)) {
50
+ throw new Error(
51
+ `BROWSER_MCP_STORAGE_STATE 指向的文件不存在: ${storageStateFile}`,
52
+ );
53
+ }
54
+ return JSON.parse(readFileSync(storageStateFile, "utf8"));
55
+ }
56
+
57
+ if (!existsSync(dbPath)) return null;
58
+ try {
59
+ const db = new DatabaseSync(dbPath, { readOnly: true });
60
+ try {
61
+ const row = db
62
+ .prepare(
63
+ "SELECT storage_state FROM diandian_accounts WHERE is_active = 1 LIMIT 1",
64
+ )
65
+ .get();
66
+ if (!row || !row.storage_state) return null;
67
+ try {
68
+ return JSON.parse(row.storage_state);
69
+ } catch {
70
+ return null;
71
+ }
72
+ } finally {
73
+ db.close();
74
+ }
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export function loadActiveStorageState(config) {
81
+ return resolveStorageState(config);
82
+ }
83
+ export class PlaywrightBrowserManager {
84
+ /**
85
+ * @param {object} options
86
+ * @param {string} options.screenshotsDir 截图落盘根目录
87
+ * @param {RegExp} options.domainRegex 代理规则允许的域名白名单
88
+ * @param {boolean} [options.headless]
89
+ * @param {string} [options.dbPath]
90
+ * @param {string|null} [options.storageStateFile]
91
+ * @param {object} [options.storageState] 直接注入,跳过自动加载
92
+ */
93
+ constructor({
94
+ screenshotsDir,
95
+ domainRegex,
96
+ headless = true,
97
+ dbPath,
98
+ storageStateFile = null,
99
+ storageState,
100
+ } = {}) {
101
+ this.headless = headless;
102
+ this.screenshotsDir = screenshotsDir;
103
+ this.dbPath = dbPath;
104
+ this.storageStateFile = storageStateFile;
105
+ this.customStorageState = storageState;
106
+ this.storageState =
107
+ storageState !== undefined
108
+ ? storageState
109
+ : resolveStorageState({ dbPath, storageStateFile });
110
+ this.currentUid = null;
111
+ this.isAnonymous = false;
112
+ this.browser = null;
113
+ this.context = null;
114
+ this.page = null;
115
+ this.consoleErrors = [];
116
+ this.whistle = new WhistleProxyManager({ domainRegex });
117
+ this.proxyRule = null; // { domain, port, protocol, url, rule }
118
+ }
119
+ get hasAuth() {
120
+ return Boolean(this.storageState);
121
+ }
122
+
123
+ async ensureProxy() {
124
+ if (!this.whistle.started) {
125
+ await this.whistle.start();
126
+ }
127
+ return this.whistle;
128
+ }
129
+
130
+ /**
131
+ * 声明域名映射:把本地 dev server 映射到干净域名。
132
+ * @param {{domain: string, port: number, protocol?: "http"|"https"}} params
133
+ */
134
+ async setProxyRule({ domain, port, protocol = "http" }) {
135
+ const whistle = await this.ensureProxy();
136
+ const { url, rule } = await whistle.setRule(domain, port, protocol);
137
+ this.proxyRule = { domain, port, protocol, url, rule };
138
+ // 已有浏览器需要重启才能加载新规则;代理保持运行
139
+ await this._closeBrowserOnly();
140
+ return this.proxyRule;
141
+ }
142
+
143
+ /**
144
+ * 根据指定的 uid 或 anonymous 状态准备浏览器上下文。
145
+ * @param {{uid?: string|null, anonymous?: boolean}} [authOptions]
146
+ */
147
+ async ensurePage({ uid = null, anonymous = false } = {}) {
148
+ const targetAnonymous = Boolean(anonymous);
149
+ const targetUid = targetAnonymous ? null : (uid ? String(uid) : null);
150
+
151
+ // 检查是否需要重建上下文(当前模式与目标不一致)
152
+ const authChanged =
153
+ this.isAnonymous !== targetAnonymous ||
154
+ (targetUid !== null && this.currentUid !== targetUid) ||
155
+ (targetUid === null && this.currentUid !== null && !targetAnonymous);
156
+
157
+ if (authChanged && this.context) {
158
+ if (this.page && !this.page.isClosed()) {
159
+ await this.page.close().catch(() => {});
160
+ this.page = null;
161
+ }
162
+ await this.context.close().catch(() => {});
163
+ this.context = null;
164
+ }
165
+
166
+ if (!this.browser) {
167
+ const launchArgs = this.whistle.started ? this.whistle.getBrowserArgs() : [];
168
+ this.browser = await chromium.launch({
169
+ headless: this.headless,
170
+ args: launchArgs,
171
+ });
172
+ }
173
+
174
+ if (!this.context) {
175
+ let state = null;
176
+ if (targetAnonymous) {
177
+ state = null;
178
+ } else if (targetUid) {
179
+ state = resolveStorageState({
180
+ dbPath: this.dbPath,
181
+ uid: targetUid,
182
+ });
183
+ } else if (this.customStorageState !== undefined) {
184
+ state = this.customStorageState;
185
+ } else {
186
+ state = resolveStorageState({
187
+ dbPath: this.dbPath,
188
+ storageStateFile: this.storageStateFile,
189
+ });
190
+ }
191
+
192
+ this.storageState = state;
193
+ this.currentUid = targetUid;
194
+ this.isAnonymous = targetAnonymous;
195
+
196
+ const contextOptions = {
197
+ viewport: { width: 1280, height: 800 },
198
+ };
199
+ if (state) {
200
+ contextOptions.storageState = state;
201
+ }
202
+ if (this.whistle.started) {
203
+ contextOptions.ignoreHTTPSErrors = true;
204
+ }
205
+ this.context = await this.browser.newContext(contextOptions);
206
+ }
207
+
208
+ if (!this.page || this.page.isClosed()) {
209
+ this.page = await this.context.newPage();
210
+ this.consoleErrors = [];
211
+
212
+ this.page.on("console", (msg) => {
213
+ if (msg.type() === "error") {
214
+ this.consoleErrors.push({
215
+ type: "console.error",
216
+ text: msg.text(),
217
+ location: msg.location(),
218
+ timestamp: new Date().toISOString(),
219
+ });
220
+ }
221
+ });
222
+
223
+ this.page.on("pageerror", (err) => {
224
+ this.consoleErrors.push({
225
+ type: "uncaught.error",
226
+ text: err.message,
227
+ stack: err.stack,
228
+ timestamp: new Date().toISOString(),
229
+ });
230
+ });
231
+ }
232
+
233
+ return this.page;
234
+ }
235
+
236
+ async navigate({ url, waitFor, timeout = 15000, uid, anonymous = false }) {
237
+ const page = await this.ensurePage({ uid, anonymous });
238
+ const response = await page.goto(url, {
239
+ timeout,
240
+ waitUntil: "domcontentloaded",
241
+ });
242
+
243
+ if (waitFor) {
244
+ await page.waitForSelector(waitFor, { timeout });
245
+ }
246
+
247
+ const title = await page.title();
248
+ const status = response ? response.status() : 200;
249
+ return {
250
+ status,
251
+ title,
252
+ url: page.url(),
253
+ hasErrors: this.consoleErrors.length > 0,
254
+ errorCount: this.consoleErrors.length,
255
+ };
256
+ }
257
+
258
+ async screenshot({ selector, fullPage = false } = {}) {
259
+ const page = await this.ensurePage();
260
+ const dir = this.screenshotsDir;
261
+ mkdirSync(dir, { recursive: true });
262
+ const filename = `screenshot-${Date.now()}-${Math.random()
263
+ .toString(36)
264
+ .slice(2, 8)}.png`;
265
+ const outputPath = join(dir, filename);
266
+
267
+ if (selector) {
268
+ const locator = page.locator(selector).first();
269
+ await locator.screenshot({ path: outputPath });
270
+ } else {
271
+ await page.screenshot({ path: outputPath, fullPage: Boolean(fullPage) });
272
+ }
273
+
274
+ return {
275
+ outputPath,
276
+ fullPage: Boolean(fullPage),
277
+ selector: selector || null,
278
+ };
279
+ }
280
+
281
+ getConsoleErrors() {
282
+ return {
283
+ total: this.consoleErrors.length,
284
+ errors: [...this.consoleErrors],
285
+ };
286
+ }
287
+
288
+ async clickElement({ selector, timeout = 10000 }) {
289
+ const page = await this.ensurePage();
290
+ const locator = page.locator(selector).first();
291
+ await locator.click({ timeout });
292
+ return {
293
+ clicked: true,
294
+ selector,
295
+ url: page.url(),
296
+ };
297
+ }
298
+ async fillText({ selector, text, timeout = 10000 }) {
299
+ const page = await this.ensurePage();
300
+ const locator = page.locator(selector).first();
301
+ await locator.fill(String(text), { timeout });
302
+ return {
303
+ filled: true,
304
+ selector,
305
+ text: String(text),
306
+ url: page.url(),
307
+ };
308
+ }
309
+
310
+ async pressKey({ key, selector, timeout = 10000 }) {
311
+ const page = await this.ensurePage();
312
+ if (selector) {
313
+ const locator = page.locator(selector).first();
314
+ await locator.press(key, { timeout });
315
+ } else {
316
+ await page.keyboard.press(key);
317
+ }
318
+ return {
319
+ pressed: true,
320
+ key,
321
+ selector: selector || null,
322
+ url: page.url(),
323
+ };
324
+ }
325
+
326
+ async hover({ selector, timeout = 10000 }) {
327
+ const page = await this.ensurePage();
328
+ const locator = page.locator(selector).first();
329
+ await locator.hover({ timeout });
330
+ return {
331
+ hovered: true,
332
+ selector,
333
+ url: page.url(),
334
+ };
335
+ }
336
+
337
+ async scroll({ direction = "down", deltaY, selector } = {}) {
338
+ const page = await this.ensurePage();
339
+ if (selector) {
340
+ const locator = page.locator(selector).first();
341
+ await locator.scrollIntoViewIfNeeded();
342
+ return {
343
+ scrolled: true,
344
+ selector,
345
+ url: page.url(),
346
+ };
347
+ }
348
+
349
+ const distance =
350
+ typeof deltaY === "number"
351
+ ? deltaY
352
+ : direction === "up"
353
+ ? -600
354
+ : 600;
355
+ await page.mouse.wheel(0, distance);
356
+ await page.waitForTimeout(200);
357
+ return {
358
+ scrolled: true,
359
+ deltaY: distance,
360
+ url: page.url(),
361
+ };
362
+ }
363
+
364
+ async waitFor({ selector, state = "visible", timeout = 15000 }) {
365
+ const page = await this.ensurePage();
366
+ if (selector) {
367
+ await page.waitForSelector(selector, { state, timeout });
368
+ return {
369
+ waited: true,
370
+ selector,
371
+ state,
372
+ url: page.url(),
373
+ };
374
+ }
375
+ await page.waitForLoadState(state === "visible" ? "load" : state, { timeout });
376
+ return {
377
+ waited: true,
378
+ state,
379
+ url: page.url(),
380
+ };
381
+ }
382
+
383
+ async getDom({ interactiveOnly = true, maxDepth = 10 } = {}) {
384
+ const page = await this.ensurePage();
385
+ const snapshot = await page.evaluate(
386
+ ({ onlyInteractive, maxD }) => {
387
+ function isVisible(el) {
388
+ if (!el || el.nodeType !== 1) return false;
389
+ const style = window.getComputedStyle(el);
390
+ if (
391
+ style.display === "none" ||
392
+ style.visibility === "hidden" ||
393
+ style.opacity === "0"
394
+ )
395
+ return false;
396
+ const rect = el.getBoundingClientRect();
397
+ return rect.width > 0 && rect.height > 0;
398
+ }
399
+
400
+ function buildSelector(el) {
401
+ if (el.id) return `#${el.id}`;
402
+ let path = el.tagName.toLowerCase();
403
+ if (el.className && typeof el.className === "string") {
404
+ const classes = el.className
405
+ .trim()
406
+ .split(/\s+/)
407
+ .filter((c) => c && !c.includes(":") && !c.includes("/"))
408
+ .slice(0, 2);
409
+ if (classes.length) path += `.${classes.join(".")}`;
410
+ }
411
+ return path;
412
+ }
413
+
414
+ const interactiveTags = new Set([
415
+ "A",
416
+ "BUTTON",
417
+ "INPUT",
418
+ "SELECT",
419
+ "TEXTAREA",
420
+ "DETAILS",
421
+ "SUMMARY",
422
+ ]);
423
+
424
+ function extract(el, depth = 0) {
425
+ if (depth > maxD || !isVisible(el)) return null;
426
+ const tag = el.tagName;
427
+ const isInteractive =
428
+ interactiveTags.has(tag) ||
429
+ el.getAttribute("role") === "button" ||
430
+ el.onclick != null ||
431
+ el.hasAttribute("tabindex");
432
+
433
+ const text =
434
+ el.children.length === 0 ? (el.innerText || "").trim().slice(0, 100) : "";
435
+
436
+ const children = [];
437
+ for (const child of el.children) {
438
+ const childRes = extract(child, depth + 1);
439
+ if (childRes) {
440
+ if (Array.isArray(childRes)) children.push(...childRes);
441
+ else children.push(childRes);
442
+ }
443
+ }
444
+
445
+ if (onlyInteractive) {
446
+ if (isInteractive) {
447
+ return {
448
+ tag: tag.toLowerCase(),
449
+ selector: buildSelector(el),
450
+ text: text || el.getAttribute("aria-label") || el.getAttribute("placeholder") || "",
451
+ id: el.id || undefined,
452
+ role: el.getAttribute("role") || undefined,
453
+ type: el.getAttribute("type") || undefined,
454
+ children: children.length ? children : undefined,
455
+ };
456
+ }
457
+ return children.length ? children : null;
458
+ }
459
+
460
+ return {
461
+ tag: tag.toLowerCase(),
462
+ selector: buildSelector(el),
463
+ text: text || undefined,
464
+ children: children.length ? children : undefined,
465
+ };
466
+ }
467
+
468
+ return {
469
+ title: document.title,
470
+ url: window.location.href,
471
+ elements: extract(document.body) || [],
472
+ };
473
+ },
474
+ { onlyInteractive: Boolean(interactiveOnly), maxD: maxDepth },
475
+ );
476
+
477
+ return snapshot;
478
+ }
479
+
480
+ async evalJs({ script }) {
481
+ const page = await this.ensurePage();
482
+ const result = await page.evaluate((code) => {
483
+ // eslint-disable-next-line no-eval
484
+ return window.eval(code);
485
+ }, script);
486
+ return {
487
+ result,
488
+ };
489
+ }
490
+
491
+ async _closeBrowserOnly() {
492
+ if (this.page && !this.page.isClosed()) {
493
+ try {
494
+ await this.page.close();
495
+ } catch {}
496
+ this.page = null;
497
+ }
498
+ if (this.context) {
499
+ try {
500
+ await this.context.close();
501
+ } catch {}
502
+ this.context = null;
503
+ }
504
+ if (this.browser) {
505
+ try {
506
+ await this.browser.close();
507
+ } catch {}
508
+ this.browser = null;
509
+ }
510
+ }
511
+
512
+ async close() {
513
+ await this._closeBrowserOnly();
514
+ await this.whistle.close();
515
+ }
516
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 环境配置解析。所有配置均来自环境变量,无项目目录约定。
3
+ */
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { existsSync } from "node:fs";
7
+ import { DatabaseSync } from "node:sqlite";
8
+
9
+ export const DEFAULT_DOMAIN_SUFFIX = "tuwan.com";
10
+
11
+ /**
12
+ * @param {NodeJS.ProcessEnv} [env]
13
+ */
14
+ export function resolveConfig(env = process.env) {
15
+ // 数据目录固定为默认值 ~/.dd-browser-mcp,无需环境变量覆盖
16
+ const dataDir = resolve(join(homedir(), ".dd-browser-mcp"));
17
+ const dbPath = join(dataDir, "runtime.db");
18
+
19
+ const domainSuffix = (
20
+ env.BROWSER_MCP_DOMAIN_SUFFIX || DEFAULT_DOMAIN_SUFFIX
21
+ ).trim().toLowerCase();
22
+
23
+ // 优先从 SQLite 库读取 Web 控制台设置的 headless 开关,默认 true
24
+ let headless = true;
25
+ if (existsSync(dbPath)) {
26
+ try {
27
+ const db = new DatabaseSync(dbPath, { readOnly: true });
28
+ try {
29
+ const row = db
30
+ .prepare("SELECT value FROM app_settings WHERE key = 'headless'")
31
+ .get();
32
+ if (row && row.value !== undefined) {
33
+ headless = JSON.parse(row.value);
34
+ }
35
+ } finally {
36
+ db.close();
37
+ }
38
+ } catch {}
39
+ }
40
+ return {
41
+ dataDir,
42
+ dbPath,
43
+ screenshotsDir: join(dataDir, "screenshots"),
44
+ webPidFile: join(dataDir, "web.pid"),
45
+ webLogFile: join(dataDir, "web.log"),
46
+ domainSuffix,
47
+ domainRegex: buildDomainRegex(domainSuffix),
48
+ headless,
49
+ storageStateFile: env.BROWSER_MCP_STORAGE_STATE?.trim() || null,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * 由域名后缀构造子域名匹配正则(等价原 TUWAN_DOMAIN_RE 的泛化版)。
55
+ * @param {string} suffix 如 "tuwan.com"
56
+ */
57
+ export function buildDomainRegex(suffix) {
58
+ const label = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
59
+ const escaped = suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
60
+ return new RegExp(`^(?:${label}\\.)*${escaped}$`, "i");
61
+ }