@taole/dd-playwright 0.0.0-stage → 0.1.1
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 +98 -2
- package/SKILL.md +28 -0
- package/bin/cli.mjs +173 -0
- package/bin/mcp-server.mjs +416 -0
- package/package.json +30 -4
- package/src/account-store.mjs +234 -0
- package/src/auth/diandian-auth.mjs +279 -0
- package/src/browser-manager.mjs +516 -0
- package/src/config.mjs +61 -0
- package/src/server/daemon.mjs +183 -0
- package/src/server/web-server.mjs +930 -0
- package/src/whistle-proxy.mjs +232 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stdio MCP: 独立 Playwright 浏览器自动化服务。
|
|
4
|
+
* 自动注入 SQLite 中激活的点点测试账号 storageState,托管隔离 whistle 代理。
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import * as z from "zod/v4";
|
|
9
|
+
import { resolveConfig } from "../src/config.mjs";
|
|
10
|
+
import { PlaywrightBrowserManager } from "../src/browser-manager.mjs";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const MCP_VERSION = "0.1.1";
|
|
14
|
+
|
|
15
|
+
function textResult(payload) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function errorResult(prefix, err) {
|
|
22
|
+
return {
|
|
23
|
+
isError: true,
|
|
24
|
+
content: [{ type: "text", text: `${prefix}: ${err.message}` }],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createBrowserMcpServer({ manager, config }) {
|
|
29
|
+
const server = new McpServer({
|
|
30
|
+
name: "dd-playwright",
|
|
31
|
+
version: MCP_VERSION,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
server.registerTool(
|
|
35
|
+
"browser_navigate",
|
|
36
|
+
{
|
|
37
|
+
description:
|
|
38
|
+
"使用内置 Playwright 访问目标 URL。" +
|
|
39
|
+
"可显式指定 uid 选择绑定的点点账号(不传则使用默认激活账号);" +
|
|
40
|
+
"若传 anonymous: true 则以完全未登录的游客身份访问。" +
|
|
41
|
+
"返回 HTTP 状态码、页面标题与控制台报错统计。",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
url: z.string().describe("需要访问的目标页面 URL"),
|
|
44
|
+
uid: z
|
|
45
|
+
.string()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("显式指定的点点账号 UID。若不传,则默认使用当前激活账号"),
|
|
48
|
+
anonymous: z
|
|
49
|
+
.boolean()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("是否以匿名/未登录状态访问(默认 false)。若为 true,则不注入任何 Cookie/storageState"),
|
|
52
|
+
waitFor: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("可选等待的目标 DOM 选择器(CSS 或 XPath)"),
|
|
56
|
+
timeout: z
|
|
57
|
+
.number()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("导航与等待超时时间(毫秒),默认 15000"),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
async ({ url, uid, anonymous, waitFor, timeout }) => {
|
|
63
|
+
try {
|
|
64
|
+
return textResult(
|
|
65
|
+
await manager.navigate({ url, uid, anonymous, waitFor, timeout }),
|
|
66
|
+
);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return errorResult("导航失败", err);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
server.registerTool(
|
|
74
|
+
"browser_screenshot",
|
|
75
|
+
{
|
|
76
|
+
description:
|
|
77
|
+
"截取当前页面或指定元素截图,落盘至 BROWSER_MCP_DATA_DIR/screenshots,返回本地文件路径。",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
selector: z
|
|
80
|
+
.string()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("可选选择器,仅对该元素截图;留空则截取视口或全页面"),
|
|
83
|
+
fullPage: z
|
|
84
|
+
.boolean()
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("是否截取整页滚动长图,默认 false"),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
async ({ selector, fullPage }) => {
|
|
90
|
+
try {
|
|
91
|
+
return textResult(await manager.screenshot({ selector, fullPage }));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return errorResult("截图失败", err);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
server.registerTool(
|
|
99
|
+
"browser_get_console_errors",
|
|
100
|
+
{
|
|
101
|
+
description:
|
|
102
|
+
"获取自页面导航以来的所有 console.error 和未捕获异常日志,用于验证前端脚本是否存在运行时报错。",
|
|
103
|
+
inputSchema: {},
|
|
104
|
+
},
|
|
105
|
+
async () => {
|
|
106
|
+
try {
|
|
107
|
+
return textResult(manager.getConsoleErrors());
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return errorResult("获取控制台错误失败", err);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
server.registerTool(
|
|
115
|
+
"browser_click_element",
|
|
116
|
+
{
|
|
117
|
+
description:
|
|
118
|
+
"在页面中模拟点击指定 DOM 选择器的元素(如按钮、Tab、确认框)。",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
selector: z
|
|
121
|
+
.string()
|
|
122
|
+
.describe("需要点击的目标元素 CSS 或 XPath 选择器"),
|
|
123
|
+
timeout: z
|
|
124
|
+
.number()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("等待元素并点击的超时时间(毫秒),默认 10000"),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
async ({ selector, timeout }) => {
|
|
130
|
+
try {
|
|
131
|
+
return textResult(await manager.clickElement({ selector, timeout }));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return errorResult("点击元素失败", err);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
server.registerTool(
|
|
138
|
+
"browser_fill_text",
|
|
139
|
+
{
|
|
140
|
+
description:
|
|
141
|
+
"在指定 DOM 选择器的输入框(input、textarea)中填入文本,原生触发 input 与 change 事件。",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
selector: z.string().describe("目标输入框的 CSS 或 XPath 选择器"),
|
|
144
|
+
text: z.string().describe("需要输入的文本内容"),
|
|
145
|
+
timeout: z
|
|
146
|
+
.number()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe("等待元素并填写的超时时间(毫秒),默认 10000"),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
async ({ selector, text, timeout }) => {
|
|
152
|
+
try {
|
|
153
|
+
return textResult(await manager.fillText({ selector, text, timeout }));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return errorResult("填写文本失败", err);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
server.registerTool(
|
|
161
|
+
"browser_press_key",
|
|
162
|
+
{
|
|
163
|
+
description:
|
|
164
|
+
"模拟键盘按键(如 Enter、Tab、Escape、Backspace、ArrowDown 等),可聚焦特定元素按键或向全局页面按键。",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
key: z
|
|
167
|
+
.string()
|
|
168
|
+
.describe(
|
|
169
|
+
"键盘按键名称,如 'Enter'、'Tab'、'Escape'、'Backspace'、'Control+A' 等",
|
|
170
|
+
),
|
|
171
|
+
selector: z
|
|
172
|
+
.string()
|
|
173
|
+
.optional()
|
|
174
|
+
.describe("可选目标元素选择器;若提供则先聚焦该元素再按键"),
|
|
175
|
+
timeout: z
|
|
176
|
+
.number()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe("等待元素的超时时间(毫秒),默认 10000"),
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
async ({ key, selector, timeout }) => {
|
|
182
|
+
try {
|
|
183
|
+
return textResult(await manager.pressKey({ key, selector, timeout }));
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return errorResult("键盘按键失败", err);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
server.registerTool(
|
|
191
|
+
"browser_hover",
|
|
192
|
+
{
|
|
193
|
+
description:
|
|
194
|
+
"将鼠标光标悬停在指定 DOM 元素上,触发 hover 状态、下拉菜单或 Tooltip 浮层。",
|
|
195
|
+
inputSchema: {
|
|
196
|
+
selector: z.string().describe("需要悬停的目标元素 CSS 或 XPath 选择器"),
|
|
197
|
+
timeout: z
|
|
198
|
+
.number()
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("等待元素并悬停的超时时间(毫秒),默认 10000"),
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
async ({ selector, timeout }) => {
|
|
204
|
+
try {
|
|
205
|
+
return textResult(await manager.hover({ selector, timeout }));
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return errorResult("悬停元素失败", err);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
server.registerTool(
|
|
213
|
+
"browser_scroll",
|
|
214
|
+
{
|
|
215
|
+
description:
|
|
216
|
+
"滚动页面视口或滚动特定元素使其进入视图(如触底加载更多、翻页)。",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
direction: z
|
|
219
|
+
.enum(["down", "up"])
|
|
220
|
+
.optional()
|
|
221
|
+
.describe("视口滚动方向,默认 'down'"),
|
|
222
|
+
deltaY: z
|
|
223
|
+
.number()
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("精确滚动像素高度,正数向下,负数向上;若指定则优先于 direction"),
|
|
226
|
+
selector: z
|
|
227
|
+
.string()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe("可选目标元素选择器;若提供则滚动该元素进入当前视口"),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
async ({ direction, deltaY, selector }) => {
|
|
233
|
+
try {
|
|
234
|
+
return textResult(await manager.scroll({ direction, deltaY, selector }));
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return errorResult("页面滚动失败", err);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
server.registerTool(
|
|
242
|
+
"browser_wait_for",
|
|
243
|
+
{
|
|
244
|
+
description:
|
|
245
|
+
"等待页面特定状态(如网络空闲 load/networkidle)或等待特定 DOM 元素出现/消失。",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
selector: z
|
|
248
|
+
.string()
|
|
249
|
+
.optional()
|
|
250
|
+
.describe("可选等待的目标 DOM 选择器;留空则等待整页状态"),
|
|
251
|
+
state: z
|
|
252
|
+
.enum(["visible", "hidden", "attached", "detached"])
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("元素等待状态,默认 'visible'"),
|
|
255
|
+
timeout: z
|
|
256
|
+
.number()
|
|
257
|
+
.optional()
|
|
258
|
+
.describe("等待超时时间(毫秒),默认 15000"),
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
async ({ selector, state, timeout }) => {
|
|
262
|
+
try {
|
|
263
|
+
return textResult(await manager.waitFor({ selector, state, timeout }));
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return errorResult("等待超时或失败", err);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
server.registerTool(
|
|
271
|
+
"browser_get_dom",
|
|
272
|
+
{
|
|
273
|
+
description:
|
|
274
|
+
"获取当前页面的结构化可交互 DOM 树(包含链接、按钮、输入框、下拉框等交互元素的文本和对应选择器),方便 AI Agent 精准定位元素,免去盲猜选择器或截大图肉眼识别。",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
interactiveOnly: z
|
|
277
|
+
.boolean()
|
|
278
|
+
.optional()
|
|
279
|
+
.describe("是否仅提取可交互元素(按钮、输入框、链接等),默认 true"),
|
|
280
|
+
maxDepth: z
|
|
281
|
+
.number()
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("DOM 遍历最大深度,默认 10"),
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
async ({ interactiveOnly, maxDepth }) => {
|
|
287
|
+
try {
|
|
288
|
+
return textResult(
|
|
289
|
+
await manager.getDom({ interactiveOnly, maxDepth }),
|
|
290
|
+
);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
return errorResult("获取页面 DOM 失败", err);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
server.registerTool(
|
|
298
|
+
"browser_set_proxy_rule",
|
|
299
|
+
{
|
|
300
|
+
description:
|
|
301
|
+
`声明域名映射规则:将本地 dev server 映射到一个 *.${config.domainSuffix} 子域名。` +
|
|
302
|
+
"本地端口由你自行发现(读取项目 vite.config.js / package.json / dev 启动日志)。" +
|
|
303
|
+
"域名前缀由你决定:包含 -test / -dev / -local 任一后缀即可触发域名类测试环境判定;" +
|
|
304
|
+
"例外是 ChatRoomSvelteNew 项目,必须用白名单域名 y-dev.tuwan.com 或 y-test.tuwan.com(y-slt-test 同属白名单)且访问 URL 带 ?env=1。" +
|
|
305
|
+
"例如:xdd-test.tuwan.com -> 127.0.0.1:5173 之后,浏览器访问 https://xdd-test.tuwan.com/ 即可," +
|
|
306
|
+
"无需在 URL 中携带本地端口,MAIN_DOMAIN 推导结果干净。" +
|
|
307
|
+
"注意:调用此工具后浏览器会自动重启以加载新规则。",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
domain: z
|
|
310
|
+
.string()
|
|
311
|
+
.describe(
|
|
312
|
+
`目标域名,必须是 *.${config.domainSuffix} 子域名(如 xdd-test.${config.domainSuffix})`,
|
|
313
|
+
),
|
|
314
|
+
port: z
|
|
315
|
+
.number()
|
|
316
|
+
.int()
|
|
317
|
+
.positive()
|
|
318
|
+
.describe("本地 dev server 监听的端口号"),
|
|
319
|
+
protocol: z
|
|
320
|
+
.enum(["http", "https"])
|
|
321
|
+
.optional()
|
|
322
|
+
.describe("本地 dev server 使用的协议,默认 http"),
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
async ({ domain, port, protocol = "http" }) => {
|
|
326
|
+
try {
|
|
327
|
+
const res = await manager.setProxyRule({ domain, port, protocol });
|
|
328
|
+
return textResult({
|
|
329
|
+
success: true,
|
|
330
|
+
message: `域名映射已生效。浏览器访问 ${res.url} 即可。`,
|
|
331
|
+
...res,
|
|
332
|
+
});
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return errorResult("设置代理规则失败", err);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
server.registerTool(
|
|
340
|
+
"browser_eval_js",
|
|
341
|
+
{
|
|
342
|
+
description:
|
|
343
|
+
"在页面上下文执行 JavaScript 表达式并返回结果,用于断言全局变量、计算样式或页面状态。",
|
|
344
|
+
inputSchema: {
|
|
345
|
+
script: z.string().describe("要在页面内执行的 JS 脚本或表达式"),
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
async ({ script }) => {
|
|
349
|
+
try {
|
|
350
|
+
return textResult(await manager.evalJs({ script }));
|
|
351
|
+
} catch (err) {
|
|
352
|
+
return errorResult("执行 JS 失败", err);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
server.registerTool(
|
|
358
|
+
"browser_status",
|
|
359
|
+
{
|
|
360
|
+
description:
|
|
361
|
+
"查询服务当前状态:当前使用的点点 UID/激活状态、storageState 是否已加载、代理规则、数据目录。排查免登未生效时先调用此工具。",
|
|
362
|
+
inputSchema: {},
|
|
363
|
+
},
|
|
364
|
+
async () => {
|
|
365
|
+
return textResult({
|
|
366
|
+
version: MCP_VERSION,
|
|
367
|
+
storageStateLoaded: manager.hasAuth,
|
|
368
|
+
currentUid: manager.currentUid,
|
|
369
|
+
isAnonymous: manager.isAnonymous,
|
|
370
|
+
headless: manager.headless,
|
|
371
|
+
proxyStarted: manager.whistle.started,
|
|
372
|
+
proxyRule: manager.proxyRule,
|
|
373
|
+
dataDir: config.dataDir,
|
|
374
|
+
domainSuffix: config.domainSuffix,
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
return server;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function main() {
|
|
382
|
+
const config = resolveConfig();
|
|
383
|
+
const manager = new PlaywrightBrowserManager(config);
|
|
384
|
+
const server = createBrowserMcpServer({ manager, config });
|
|
385
|
+
const transport = new StdioServerTransport();
|
|
386
|
+
|
|
387
|
+
let closing;
|
|
388
|
+
const shutdown = (reason) => {
|
|
389
|
+
closing ??= (async () => {
|
|
390
|
+
await server.close();
|
|
391
|
+
await manager.close();
|
|
392
|
+
// whistle 内部后台定时器(数据 GC 等)没有公开停止接口,
|
|
393
|
+
// 清理完成后主动退出,避免代理在客户端断开后存活
|
|
394
|
+
process.exit(process.exitCode || 0);
|
|
395
|
+
})().catch((err) => {
|
|
396
|
+
console.error("dd-browser-mcp 资源清理失败:", err);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
});
|
|
399
|
+
return closing;
|
|
400
|
+
};
|
|
401
|
+
process.stdin.once("end", () => shutdown("stdin end"));
|
|
402
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
403
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
404
|
+
|
|
405
|
+
await server.connect(transport);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (
|
|
409
|
+
process.argv[1] &&
|
|
410
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
411
|
+
) {
|
|
412
|
+
main().catch((err) => {
|
|
413
|
+
console.error("dd-browser-mcp 服务启动失败:", err);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
});
|
|
416
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taole/dd-playwright",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Standalone stdio MCP & CLI: Playwright browser automation + isolated whistle proxy + diandian account storageState injection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dd-playwright": "bin/cli.mjs",
|
|
9
|
+
"dd-pw": "bin/cli.mjs",
|
|
10
|
+
"dd-playwright-mcp": "bin/mcp-server.mjs",
|
|
11
|
+
"dd-browser": "bin/cli.mjs",
|
|
12
|
+
"dd-browser-mcp": "bin/mcp-server.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.13.0"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
19
|
+
"playwright": "1.64.1",
|
|
20
|
+
"whistle": "2.10.11",
|
|
21
|
+
"zod": "4.6.7"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin",
|
|
29
|
+
"src",
|
|
30
|
+
"SKILL.md"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export class AccountStore {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} dbPath SQLite 数据库绝对路径
|
|
8
|
+
*/
|
|
9
|
+
constructor(dbPath) {
|
|
10
|
+
this.dbPath = dbPath;
|
|
11
|
+
const dir = dirname(dbPath);
|
|
12
|
+
if (!existsSync(dir)) {
|
|
13
|
+
mkdirSync(dir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
this.db = new DatabaseSync(dbPath);
|
|
16
|
+
this._initSchema();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_initSchema() {
|
|
20
|
+
this.db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS diandian_accounts (
|
|
22
|
+
account_id TEXT PRIMARY KEY,
|
|
23
|
+
nickname TEXT NOT NULL,
|
|
24
|
+
avatar TEXT,
|
|
25
|
+
cookie_header TEXT NOT NULL,
|
|
26
|
+
storage_state TEXT NOT NULL,
|
|
27
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
28
|
+
created_at TEXT NOT NULL,
|
|
29
|
+
updated_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS app_settings (
|
|
32
|
+
key TEXT PRIMARY KEY,
|
|
33
|
+
value TEXT NOT NULL,
|
|
34
|
+
updated_at TEXT NOT NULL
|
|
35
|
+
);
|
|
36
|
+
`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getSetting(key, defaultValue = null) {
|
|
40
|
+
const row = this.db
|
|
41
|
+
.prepare("SELECT value FROM app_settings WHERE key = ?")
|
|
42
|
+
.get(String(key));
|
|
43
|
+
if (!row) return defaultValue;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(row.value);
|
|
46
|
+
} catch {
|
|
47
|
+
return row.value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setSetting(key, value) {
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
const raw = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value);
|
|
54
|
+
this.db
|
|
55
|
+
.prepare(`
|
|
56
|
+
INSERT INTO app_settings (key, value, updated_at)
|
|
57
|
+
VALUES (?, ?, ?)
|
|
58
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
59
|
+
value = excluded.value,
|
|
60
|
+
updated_at = excluded.updated_at
|
|
61
|
+
`)
|
|
62
|
+
.run(String(key), raw, now);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
listAccounts() {
|
|
66
|
+
const rows = this.db
|
|
67
|
+
.prepare(
|
|
68
|
+
"SELECT account_id, nickname, avatar, cookie_header, is_active, created_at, updated_at FROM diandian_accounts ORDER BY is_active DESC, updated_at DESC",
|
|
69
|
+
)
|
|
70
|
+
.all();
|
|
71
|
+
return rows.map((r) => ({
|
|
72
|
+
accountId: r.account_id,
|
|
73
|
+
nickname: r.nickname,
|
|
74
|
+
avatar: r.avatar,
|
|
75
|
+
cookieHeader: r.cookie_header,
|
|
76
|
+
isActive: Boolean(r.is_active),
|
|
77
|
+
createdAt: r.created_at,
|
|
78
|
+
updatedAt: r.updated_at,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getActiveAccount() {
|
|
83
|
+
const row = this.db
|
|
84
|
+
.prepare(
|
|
85
|
+
"SELECT account_id, nickname, avatar, cookie_header, storage_state, is_active, created_at, updated_at FROM diandian_accounts WHERE is_active = 1 LIMIT 1",
|
|
86
|
+
)
|
|
87
|
+
.get();
|
|
88
|
+
if (!row) return null;
|
|
89
|
+
let storageState = null;
|
|
90
|
+
try {
|
|
91
|
+
storageState = JSON.parse(row.storage_state);
|
|
92
|
+
} catch {}
|
|
93
|
+
return {
|
|
94
|
+
accountId: row.account_id,
|
|
95
|
+
nickname: row.nickname,
|
|
96
|
+
avatar: row.avatar,
|
|
97
|
+
cookieHeader: row.cookie_header,
|
|
98
|
+
storageState,
|
|
99
|
+
isActive: true,
|
|
100
|
+
createdAt: row.created_at,
|
|
101
|
+
updatedAt: row.updated_at,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getAccount(accountId) {
|
|
106
|
+
if (!accountId) return null;
|
|
107
|
+
const row = this.db
|
|
108
|
+
.prepare(
|
|
109
|
+
"SELECT account_id, nickname, avatar, cookie_header, storage_state, is_active, created_at, updated_at FROM diandian_accounts WHERE account_id = ?",
|
|
110
|
+
)
|
|
111
|
+
.get(String(accountId));
|
|
112
|
+
if (!row) return null;
|
|
113
|
+
let storageState = null;
|
|
114
|
+
try {
|
|
115
|
+
storageState = JSON.parse(row.storage_state);
|
|
116
|
+
} catch {}
|
|
117
|
+
return {
|
|
118
|
+
accountId: row.account_id,
|
|
119
|
+
nickname: row.nickname,
|
|
120
|
+
avatar: row.avatar,
|
|
121
|
+
cookieHeader: row.cookie_header,
|
|
122
|
+
storageState,
|
|
123
|
+
isActive: Boolean(row.is_active),
|
|
124
|
+
createdAt: row.created_at,
|
|
125
|
+
updatedAt: row.updated_at,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
saveAccount({
|
|
130
|
+
accountId,
|
|
131
|
+
nickname = "点点用户",
|
|
132
|
+
avatar = "",
|
|
133
|
+
cookieHeader = "",
|
|
134
|
+
storageState = {},
|
|
135
|
+
isActive = true,
|
|
136
|
+
} = {}) {
|
|
137
|
+
const id = String(accountId || "").trim();
|
|
138
|
+
if (!id) {
|
|
139
|
+
throw new Error("保存点点账号必须提供有效 accountId");
|
|
140
|
+
}
|
|
141
|
+
const now = new Date().toISOString();
|
|
142
|
+
const rawStorage =
|
|
143
|
+
typeof storageState === "string"
|
|
144
|
+
? storageState
|
|
145
|
+
: JSON.stringify(storageState || {});
|
|
146
|
+
|
|
147
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
148
|
+
try {
|
|
149
|
+
if (isActive) {
|
|
150
|
+
this.db.prepare("UPDATE diandian_accounts SET is_active = 0").run();
|
|
151
|
+
}
|
|
152
|
+
this.db
|
|
153
|
+
.prepare(`
|
|
154
|
+
INSERT INTO diandian_accounts (account_id, nickname, avatar, cookie_header, storage_state, is_active, created_at, updated_at)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
156
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
157
|
+
nickname = excluded.nickname,
|
|
158
|
+
avatar = excluded.avatar,
|
|
159
|
+
cookie_header = excluded.cookie_header,
|
|
160
|
+
storage_state = excluded.storage_state,
|
|
161
|
+
is_active = excluded.is_active,
|
|
162
|
+
updated_at = excluded.updated_at
|
|
163
|
+
`)
|
|
164
|
+
.run(
|
|
165
|
+
id,
|
|
166
|
+
String(nickname || "点点用户"),
|
|
167
|
+
avatar || "",
|
|
168
|
+
String(cookieHeader || ""),
|
|
169
|
+
rawStorage,
|
|
170
|
+
isActive ? 1 : 0,
|
|
171
|
+
now,
|
|
172
|
+
now,
|
|
173
|
+
);
|
|
174
|
+
this.db.exec("COMMIT");
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.db.exec("ROLLBACK");
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return this.getAccount(id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setActiveAccount(accountId) {
|
|
184
|
+
if (!accountId) return;
|
|
185
|
+
const id = String(accountId);
|
|
186
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
187
|
+
try {
|
|
188
|
+
this.db
|
|
189
|
+
.prepare(
|
|
190
|
+
"UPDATE diandian_accounts SET is_active = CASE WHEN account_id = ? THEN 1 ELSE 0 END",
|
|
191
|
+
)
|
|
192
|
+
.run(id);
|
|
193
|
+
this.db.exec("COMMIT");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
this.db.exec("ROLLBACK");
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
deleteAccount(accountId) {
|
|
201
|
+
if (!accountId) return;
|
|
202
|
+
const id = String(accountId);
|
|
203
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
204
|
+
try {
|
|
205
|
+
const target = this.getAccount(id);
|
|
206
|
+
this.db
|
|
207
|
+
.prepare("DELETE FROM diandian_accounts WHERE account_id = ?")
|
|
208
|
+
.run(id);
|
|
209
|
+
if (target?.isActive) {
|
|
210
|
+
// 如果删除了激活账号,将最近更新的账号激活
|
|
211
|
+
const next = this.db
|
|
212
|
+
.prepare(
|
|
213
|
+
"SELECT account_id FROM diandian_accounts ORDER BY updated_at DESC LIMIT 1",
|
|
214
|
+
)
|
|
215
|
+
.get();
|
|
216
|
+
if (next?.account_id) {
|
|
217
|
+
this.db
|
|
218
|
+
.prepare("UPDATE diandian_accounts SET is_active = 1 WHERE account_id = ?")
|
|
219
|
+
.run(next.account_id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.db.exec("COMMIT");
|
|
223
|
+
} catch (err) {
|
|
224
|
+
this.db.exec("ROLLBACK");
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
close() {
|
|
230
|
+
try {
|
|
231
|
+
this.db.close();
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
}
|