@zaofan/dsh-qqbot 1.6.1 → 1.6.3

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,725 @@
1
+ /**
2
+ * qun-login.ts — 免扫码登录 qun.qq.com(自带浏览器 + CDP,**零第三方依赖**)
3
+ *
4
+ * ── 原理(2026-10-02 实测结论,别再猜)──
5
+ * ① 「点头像授权登录」靠两个来源,任意一个在就能用:
6
+ * a) 浏览器 profile 里的 QQ 长期登录 cookie(ptui_loginuin / qlogin_uid / pt4_token / RK …)
7
+ * b) 本机 QQ 客户端的本地服务(https://localhost.ptlogin2.qq.com:4301/pt_get_uins)
8
+ * 实测:QQ 客户端关掉后 (b) 立刻消失(端口无监听、页面只剩"扫码登录"),
9
+ * 但只要 (a) 还在,登录页**照样显示头像**、点一下就能换新凭据。
10
+ * ② 因此本模块的核心价值:**只要 profile 活着,登录就是全自动的,永远不用再扫码**。
11
+ * 第一次(profile 里还没有 QQ cookie)才需要扫码 —— 那时把二维码截图交给上层发给用户。
12
+ * ③ skey 约 1 天过期,但过期后不需要用户做任何事:重新打开登录页 → 自动点头像 → 换新的。
13
+ *
14
+ * ── 实现 ──
15
+ * 启动系统 Edge/Chrome 的 headless + 独立 user-data-dir + 调试端口,
16
+ * 用 Node 内置 WebSocket 连 CDP,执行 JS 点头像 / 读 cookie。
17
+ * 用户零安装、插件零依赖。
18
+ */
19
+ import { spawn, spawnSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { loadQunCookie, verifyQunCookieRaw } from './qun-admin.js';
23
+ /**
24
+ * 两个入口页面,**必须分开用**(实测踩坑,卡了很久):
25
+ *
26
+ * ① CHECK_URL —— 用来判断"profile 里是不是已经有有效登录态"。
27
+ * 用 qun.qq.com 本体:已登录就直接进群管理页,读 cookie + 验证接口即可,一次都不用登。
28
+ *
29
+ * ② LOGIN_URL —— 用来"让人登录"(点头像 / 扫二维码)。
30
+ * 用 xui.ptlogin2 的快捷登录面板页,因为:
31
+ * · **qun.qq.com 的登录面板是 iframe**(嵌的正是这个 xlogin 页)→ 在主文档里
32
+ * `document.querySelector('#qrlogin_img')` **永远查不到二维码**,头像同理 ✗
33
+ * · xlogin 页本身就是那个面板,二维码(#qrlogin_img)/头像(#qlogin_list) 都在**主文档**里 ✓
34
+ * · 但 xlogin 页只认 ptlogin2 域的身份 cookie(会忽略 qun.qq.com 已有的登录态),
35
+ * 所以"检查是否已登录"这件事还是得交给 qun.qq.com。
36
+ */
37
+ const CHECK_URL = 'https://qun.qq.com/';
38
+ const LOGIN_URL = 'https://xui.ptlogin2.qq.com/cgi-bin/xlogin?appid=715030901&daid=73&style=20'
39
+ + '&hide_title_bar=1&low_login=0&qlogin_auto_login=1&no_verifyimg=1&link_target=blank'
40
+ + '&proj_self_def=1&s_url=' + encodeURIComponent('https://qun.qq.com/');
41
+ /** 二维码元素的候选选择器(页面改版时在这里加就行) */
42
+ const QR_SELECTORS = [
43
+ '#qrlogin_img',
44
+ '#qrlogin_img_box img',
45
+ '#qr_img',
46
+ '.qrcode img',
47
+ '#qlogin_qr img',
48
+ ];
49
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
50
+ // ───────────────────────── 找浏览器 ─────────────────────────
51
+ /** 找系统浏览器(Edge 优先:Windows 必装)。可用 QUN_BROWSER 环境变量覆盖。 */
52
+ export function findSystemBrowser() {
53
+ const env = String(process.env['QUN_BROWSER'] ?? '').trim();
54
+ if (env && existsSync(env))
55
+ return env;
56
+ const pf = process.env['ProgramFiles'] ?? 'C:\\Program Files';
57
+ const pf86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
58
+ const lad = process.env['LOCALAPPDATA'] ?? '';
59
+ const cands = [
60
+ join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
61
+ join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
62
+ join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
63
+ join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
64
+ join(lad, 'Google', 'Chrome', 'Application', 'chrome.exe'),
65
+ '/usr/bin/microsoft-edge', '/usr/bin/google-chrome', '/usr/bin/chromium',
66
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
67
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
68
+ ];
69
+ for (const c of cands) {
70
+ try {
71
+ if (c && existsSync(c))
72
+ return c;
73
+ }
74
+ catch { /* ignore */ }
75
+ }
76
+ return null;
77
+ }
78
+ async function connectCdp(wsUrl) {
79
+ const ws = new WebSocket(wsUrl);
80
+ await new Promise((res, rej) => {
81
+ const t = setTimeout(() => rej(new Error('CDP 连接超时')), 15000);
82
+ ws.addEventListener('open', () => { clearTimeout(t); res(); }, { once: true });
83
+ ws.addEventListener('error', () => { clearTimeout(t); rej(new Error('CDP 连接失败')); }, { once: true });
84
+ });
85
+ let seq = 0;
86
+ const pending = new Map();
87
+ ws.addEventListener('message', (ev) => {
88
+ try {
89
+ const m = JSON.parse(String(ev.data));
90
+ if (typeof m.id !== 'number')
91
+ return;
92
+ const p = pending.get(m.id);
93
+ if (!p)
94
+ return;
95
+ pending.delete(m.id);
96
+ if (m.error)
97
+ p.rej(new Error(m.error.message ?? 'CDP error'));
98
+ else
99
+ p.res(m.result);
100
+ }
101
+ catch { /* ignore */ }
102
+ });
103
+ return {
104
+ send(method, params) {
105
+ const id = ++seq;
106
+ return new Promise((res, rej) => {
107
+ pending.set(id, { res, rej });
108
+ try {
109
+ ws.send(JSON.stringify({ id, method, params: params ?? {} }));
110
+ }
111
+ catch (e) {
112
+ pending.delete(id);
113
+ rej(e);
114
+ return;
115
+ }
116
+ setTimeout(() => { if (pending.has(id)) {
117
+ pending.delete(id);
118
+ rej(new Error('CDP 超时: ' + method));
119
+ } }, 30000);
120
+ });
121
+ },
122
+ close() { try {
123
+ ws.close();
124
+ }
125
+ catch { /* ignore */ } },
126
+ };
127
+ }
128
+ async function evalJs(cdp, expr) {
129
+ const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
130
+ return r?.result?.value;
131
+ }
132
+ /** 清掉 profile 的进程锁(残留进程占用时,浏览器会以 code=21 立刻退出) */
133
+ function cleanProfileLocks(dir) {
134
+ for (const f of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
135
+ try {
136
+ rmSync(join(dir, f), { force: true, recursive: true });
137
+ }
138
+ catch { /* ignore */ }
139
+ }
140
+ }
141
+ /**
142
+ * 杀掉浏览器**整个进程树**。
143
+ * ⚠️ 不能用 proc.kill():它只杀主进程,渲染/GPU 子进程会活下来继续锁住 profile,
144
+ * 下次启动就 code=21(实测踩坑)。
145
+ */
146
+ function killTree(proc) {
147
+ try {
148
+ if (process.platform === 'win32' && proc.pid) {
149
+ spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
150
+ return;
151
+ }
152
+ }
153
+ catch { /* 落到下面的兜底 */ }
154
+ try {
155
+ proc.kill('SIGKILL');
156
+ }
157
+ catch { /* ignore */ }
158
+ }
159
+ async function launchBrowser(exe, userDataDir, headless) {
160
+ try {
161
+ mkdirSync(userDataDir, { recursive: true });
162
+ }
163
+ catch { /* ignore */ }
164
+ cleanProfileLocks(userDataDir);
165
+ const args = [
166
+ ...(headless ? ['--headless=new'] : []),
167
+ '--remote-debugging-port=0',
168
+ '--user-data-dir=' + userDataDir,
169
+ '--no-first-run',
170
+ '--no-default-browser-check',
171
+ '--disable-gpu',
172
+ '--disable-extensions',
173
+ '--disable-background-networking',
174
+ '--disable-sync',
175
+ '--window-size=560,760',
176
+ // 沙箱里跑的浏览器不需要额外权限;headless 下这几项能少很多噪音日志
177
+ '--no-service-autorun',
178
+ '--password-store=basic',
179
+ CHECK_URL, // 启动先开 qun.qq.com 判断是否已有登录态;需要人登录时再跳到 xlogin 面板
180
+ ];
181
+ // ⚠️ windowsHide 千万不能设 true:对 msedge 这类 GUI 程序,它会让浏览器主窗口**根本显示不出来**
182
+ // (实测:进程起来了、CDP 也通,但 11 个进程 MainWindowHandle 全为 0,用户看不到二维码)
183
+ const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: false });
184
+ const port = await new Promise((res, rej) => {
185
+ let buf = '';
186
+ const t = setTimeout(() => rej(new Error('浏览器启动超时(没拿到调试端口)')), 25000);
187
+ proc.stderr?.on('data', (d) => {
188
+ buf += d.toString();
189
+ const m = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//.exec(buf);
190
+ if (m && m[1]) {
191
+ clearTimeout(t);
192
+ res(Number(m[1]));
193
+ }
194
+ });
195
+ proc.on('exit', (c) => { clearTimeout(t); rej(new Error('浏览器提前退出 code=' + String(c))); });
196
+ proc.on('error', (e) => { clearTimeout(t); rej(e); });
197
+ });
198
+ const kill = () => { killTree(proc); };
199
+ return { proc, port, kill };
200
+ }
201
+ /** 等页面目标出现,返回它的 debugger ws 地址 */
202
+ async function pageWsUrl(port, timeoutMs = 20000) {
203
+ const deadline = Date.now() + timeoutMs;
204
+ while (Date.now() < deadline) {
205
+ try {
206
+ const r = await fetch(`http://127.0.0.1:${port}/json/list`);
207
+ const list = (await r.json());
208
+ const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
209
+ if (page?.webSocketDebuggerUrl)
210
+ return page.webSocketDebuggerUrl;
211
+ }
212
+ catch { /* ignore */ }
213
+ await sleep(300);
214
+ }
215
+ throw new Error('没找到浏览器页面目标');
216
+ }
217
+ const sessions = new Map();
218
+ function closeSession(dataRoot) {
219
+ const s = sessions.get(dataRoot);
220
+ if (!s)
221
+ return;
222
+ try {
223
+ s.cdp.close();
224
+ }
225
+ catch { /* ignore */ }
226
+ try {
227
+ s.handle.kill();
228
+ }
229
+ catch { /* ignore */ }
230
+ sessions.delete(dataRoot);
231
+ }
232
+ // ── 进程兜底:绝不让浏览器进程攒着(2026-10-02 实测踩到:攒了 9 个同 profile 的 msedge)──
233
+ /** 会话空闲多久自动关掉(与 wait 的 5 分钟上限一致) */
234
+ const SESSION_IDLE_MS = 5 * 60 * 1000;
235
+ /**
236
+ * 宿主退出时把所有浏览器一起带走。
237
+ * ⚠️ 不装这个钩子的话,宿主一退出子进程就成**孤儿**,会一直挂在后台(而且锁着 profile,
238
+ * 下次启动还会 code=21)。
239
+ */
240
+ let exitHookInstalled = false;
241
+ function installExitHook() {
242
+ if (exitHookInstalled)
243
+ return;
244
+ exitHookInstalled = true;
245
+ const killAll = () => {
246
+ for (const dr of Array.from(sessions.keys())) {
247
+ try {
248
+ closeSession(dr);
249
+ }
250
+ catch { /* ignore */ }
251
+ }
252
+ };
253
+ try {
254
+ process.on('exit', killAll);
255
+ }
256
+ catch { /* ignore */ }
257
+ try {
258
+ process.on('SIGINT', () => { killAll(); process.exit(0); });
259
+ }
260
+ catch { /* ignore */ }
261
+ try {
262
+ process.on('SIGTERM', () => { killAll(); process.exit(0); });
263
+ }
264
+ catch { /* ignore */ }
265
+ }
266
+ /** 空闲清理:每分钟扫一次,超过 SESSION_IDLE_MS 没动静的会话连浏览器一起关掉 */
267
+ let sweeper = null;
268
+ function ensureSweeper() {
269
+ if (sweeper)
270
+ return;
271
+ sweeper = setInterval(() => {
272
+ const now = Date.now();
273
+ for (const [dr, s] of Array.from(sessions.entries())) {
274
+ if (now - s.at > SESSION_IDLE_MS) {
275
+ try {
276
+ closeSession(dr);
277
+ }
278
+ catch { /* ignore */ }
279
+ }
280
+ }
281
+ // 没会话了就把定时器也停掉(别让空转的 timer 拖住进程退出)
282
+ if (sessions.size === 0 && sweeper) {
283
+ clearInterval(sweeper);
284
+ sweeper = null;
285
+ }
286
+ }, 60 * 1000);
287
+ try {
288
+ sweeper.unref?.();
289
+ }
290
+ catch { /* ignore */ }
291
+ }
292
+ /**
293
+ * 杀掉占用指定 profile 的**残留**浏览器进程(历史泄漏的兜底清理;只在启动出问题时调用)。
294
+ *
295
+ * ⚠️ 必须用 spawnSync **同步等待**:异步 spawn 的话,调用方还没来得及重试、PowerShell 才刚启动,
296
+ * 于是又撞一次 code=21(实测踩过:重试仍然失败,看着像"重试没用",其实是清理没跑完)。
297
+ */
298
+ function killByProfile(profileDir) {
299
+ if (process.platform !== 'win32')
300
+ return;
301
+ try {
302
+ const safe = profileDir.replace(/'/g, "''");
303
+ const cmd = 'Get-CimInstance Win32_Process -Filter "Name=\'msedge.exe\'"'
304
+ + ` | Where-Object { $_.CommandLine -like '*${safe}*' }`
305
+ + ' | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }';
306
+ spawnSync('powershell', ['-NoProfile', '-Command', cmd], { stdio: 'ignore', windowsHide: true, timeout: 20000 });
307
+ }
308
+ catch { /* ignore */ }
309
+ }
310
+ export function qunLoginSessionAlive(dataRoot) {
311
+ return sessions.has(dataRoot);
312
+ }
313
+ export function qunLoginSessionClose(dataRoot) {
314
+ closeSession(dataRoot);
315
+ }
316
+ /** 从 document.cookie 串里抠出凭据 */
317
+ function parseCookie(raw) {
318
+ const pick = (k) => {
319
+ const m = new RegExp('(?:^|;\\s*)' + k + '=([^;]*)').exec(raw);
320
+ return m && m[1] ? m[1] : '';
321
+ };
322
+ const uinRaw = pick('uin') || pick('p_uin');
323
+ const uin = (/(\d{5,12})/.exec(uinRaw) ?? [])[1] ?? '';
324
+ const skey = pick('skey');
325
+ const p_skey = pick('p_skey');
326
+ if (!uin || !skey)
327
+ return null;
328
+ return { uin, skey, p_skey };
329
+ }
330
+ /** 页面还在登录页吗(用来判断是否已跳进 qun.qq.com) */
331
+ async function currentHref(cdp) {
332
+ try {
333
+ return (await evalJs(cdp, 'location.href')) ?? '';
334
+ }
335
+ catch {
336
+ return '';
337
+ }
338
+ }
339
+ /**
340
+ * 尝试在页面上读凭据;读到**并且实测有效**才返回。
341
+ *
342
+ * ⚠️ 为什么要"实测":页面只判断 cookie 在不在(skey 失效也不会跳登录页),
343
+ * 所以会出现"看起来已登录、服务端却早就不认账"(`{"ec":4,"em":"no login"}`)。
344
+ * 实测踩过:面板显示"已登录"、群列表却是空的 —— 就是因为读了这份**已被顶掉的** skey。
345
+ * 不验证的话,登录流程会一直"复用"这份废凭据,永远等不到重新授权。
346
+ */
347
+ async function tryReadCookie(cdp) {
348
+ try {
349
+ const href = await currentHref(cdp);
350
+ if (!/qun\.qq\.com/.test(href) || /\/login/.test(href))
351
+ return null;
352
+ const raw = (await evalJs(cdp, 'document.cookie')) ?? '';
353
+ const c = parseCookie(String(raw));
354
+ if (!c)
355
+ return null;
356
+ const ok = await verifyQunCookieRaw(c);
357
+ return ok ? c : null;
358
+ }
359
+ catch {
360
+ return null;
361
+ }
362
+ }
363
+ /**
364
+ * 找头像元素并点它;返回点了几个。
365
+ * ⚠️ 不能只看容器存不存在 —— `#qlogin_list` 在"没有可授权账号"时也照样存在(空容器),
366
+ * 必须要求里面有**可见的** a/img 才算真头像(实测踩过:误判导致日志说"点了头像但没跳转")。
367
+ */
368
+ async function clickAvatar(cdp) {
369
+ const expr = `(function(){
370
+ const root = document.querySelector('#qlogin_list')
371
+ || document.querySelector('.qlogin_list')
372
+ || document.querySelector('#qlogin');
373
+ if (!root) return 0;
374
+ const cand = root.querySelectorAll('a, img, .face');
375
+ for (const el of cand) {
376
+ const r = el.getBoundingClientRect();
377
+ if (!r || r.width < 16 || r.height < 16) continue; // 不可见的忽略
378
+ const target = (el.tagName === 'IMG' && el.parentElement) ? el.parentElement : el;
379
+ try { target.click(); } catch (e) { /* ignore */ }
380
+ return 1;
381
+ }
382
+ return 0;
383
+ })()`;
384
+ try {
385
+ const n = await evalJs(cdp, expr);
386
+ return Number(n) || 0;
387
+ }
388
+ catch {
389
+ return 0;
390
+ }
391
+ }
392
+ /**
393
+ * 把二维码元素截图存盘。
394
+ *
395
+ * ⚠️ **找不到二维码就返回 undefined,绝不整页截图兜底** ——
396
+ * 实测踩过:页面停在"假已登录"状态时没有二维码元素,整页截图把群管理页拍下来当二维码发了出去,
397
+ * 主人拿到一张"已登录界面"的图,完全没法扫,还以为是登录成功了。
398
+ * 宁可不给图,也不要给错图(上层可以据此提示"没拿到二维码")。
399
+ */
400
+ async function shotQr(cdp, dataRoot) {
401
+ const out = join(dataRoot, 'qun-login-qr.png');
402
+ try {
403
+ const sels = JSON.stringify(QR_SELECTORS);
404
+ const box = await evalJs(cdp, `(function(){
405
+ const sels = ${sels};
406
+ for (const s of sels) {
407
+ const el = document.querySelector(s);
408
+ if (el && el.getBoundingClientRect) {
409
+ const r = el.getBoundingClientRect();
410
+ if (r.width > 40 && r.height > 40) return { x: r.x, y: r.y, w: r.width, h: r.height };
411
+ }
412
+ }
413
+ const img = Array.from(document.images || []).find(function(i){ return i.width > 80 && i.height > 80; });
414
+ if (img) { const r = img.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }
415
+ return null;
416
+ })()`);
417
+ if (!box || box.w <= 40 || box.h <= 40)
418
+ return undefined; // 没二维码 → 不出图
419
+ const r = await cdp.send('Page.captureScreenshot', {
420
+ format: 'png',
421
+ clip: { x: Math.max(0, box.x - 8), y: Math.max(0, box.y - 8), width: box.w + 16, height: box.h + 16, scale: 1 },
422
+ });
423
+ const b64 = r?.data;
424
+ if (!b64)
425
+ return undefined;
426
+ try {
427
+ mkdirSync(dataRoot, { recursive: true });
428
+ }
429
+ catch { /* ignore */ }
430
+ writeFileSync(out, Buffer.from(b64, 'base64'));
431
+ return out;
432
+ }
433
+ catch {
434
+ return undefined;
435
+ }
436
+ }
437
+ /**
438
+ * 第一步:开浏览器 → 有头像就自动点(免扫码),没头像就出二维码。
439
+ * 拿到凭据就返回 cookie 并关掉浏览器;需要扫码则**保留浏览器**,让上层把 qrPath 发给用户,
440
+ * 之后用 qunBrowserLoginWait 继续等。
441
+ */
442
+ export async function qunBrowserLoginStart(dataRoot, opts = {}) {
443
+ const exe = findSystemBrowser();
444
+ if (!exe) {
445
+ return { ok: false, msg: '没找到系统浏览器(Edge/Chrome)。可设环境变量 QUN_BROWSER 指向浏览器可执行文件。' };
446
+ }
447
+ // ★ 已有活着的会话就**直接复用**,绝不"先杀掉再新建":
448
+ // 反复启停正是 code=21 的根源(短命进程持有长命 profile,Windows 的文件锁来不及释放)。
449
+ const existing = sessions.get(dataRoot);
450
+ if (existing) {
451
+ try {
452
+ const ck0 = await tryReadCookie(existing.cdp);
453
+ if (ck0) {
454
+ closeSession(dataRoot);
455
+ return { ok: true, msg: `复用已有浏览器会话并拿到凭据,uin=${ck0.uin}`, cookie: ck0 };
456
+ }
457
+ // 会话还活着但没登录 → 试试点头像(本机 QQ 客户端/身份 cookie 可能已就绪)
458
+ const n0 = await clickAvatar(existing.cdp);
459
+ if (n0 > 0) {
460
+ existing.clicked = true;
461
+ await sleep(2500);
462
+ const ck1 = await tryReadCookie(existing.cdp);
463
+ if (ck1) {
464
+ closeSession(dataRoot);
465
+ return { ok: true, msg: `复用会话 + 点头像授权成功,uin=${ck1.uin}`, cookie: ck1 };
466
+ }
467
+ }
468
+ // 还是没登录 → 就把上一次的二维码继续用(3 分钟内有效)
469
+ if (existing.qrPath) {
470
+ existing.at = Date.now();
471
+ return { ok: true, qrPath: existing.qrPath, msg: '复用上一次的二维码(仍在有效期内)。请扫完调 wait。' };
472
+ }
473
+ }
474
+ catch { /* 会话坏了 → 往下走,重新开一个 */ }
475
+ }
476
+ closeSession(dataRoot); // 清掉可能残留的坏会话
477
+ const profileDir = opts.profileDir ?? join(dataRoot, 'browser-profile');
478
+ const headless = opts.headless !== false;
479
+ // ★ 起新浏览器**之前**就把占用该 profile 的残留进程清掉。
480
+ // 只在"失败后重试"里清是来不及的 —— 实测反复撞 code=21,就是因为清理(异步 PowerShell)
481
+ // 还没跑完就重试了。走到这里说明没有可复用的活会话,所以清掉不会有副作用。
482
+ killByProfile(profileDir);
483
+ cleanProfileLocks(profileDir);
484
+ await sleep(700);
485
+ let handle;
486
+ try {
487
+ handle = await launchBrowser(exe, profileDir, headless);
488
+ }
489
+ catch {
490
+ // code=21 = profile 被上次残留的进程占着
491
+ // → 先杀掉占用该 profile 的残留进程(历史泄漏兜底),再清锁、等一下、重试
492
+ killByProfile(profileDir);
493
+ cleanProfileLocks(profileDir);
494
+ await sleep(1500);
495
+ try {
496
+ handle = await launchBrowser(exe, profileDir, headless);
497
+ }
498
+ catch (e2) {
499
+ return { ok: false, msg: '启动浏览器失败:' + String(e2?.message ?? e2).slice(0, 160) };
500
+ }
501
+ }
502
+ let cdp = null;
503
+ try {
504
+ const wsUrl = await pageWsUrl(handle.port);
505
+ cdp = await connectCdp(wsUrl);
506
+ await cdp.send('Runtime.enable');
507
+ await cdp.send('Page.enable');
508
+ // 有头模式下把窗口顶到最前面(不然可能被别的窗口盖住,用户找不到二维码)
509
+ if (opts.headless === false) {
510
+ try {
511
+ await cdp.send('Page.bringToFront');
512
+ }
513
+ catch { /* ignore */ }
514
+ }
515
+ // ★ 关键一步:把插件保存的凭据注入浏览器。
516
+ // 为什么必须这么做:浏览器 profile 里的 **session cookie 不会持久化**
517
+ // (Chromium 正常退出时会清掉它们,实测同一 profile 第二次打开 qun.qq.com 仍是未登录)。
518
+ // 所以插件自己的 cookie 文件才是唯一真相源:每次启动都把凭据喂回浏览器,
519
+ // 页面直接就是已登录状态;skey 若已过期,页面会落到登录页,那就走点头像/扫码。
520
+ const saved = loadQunCookie(dataRoot);
521
+ if (saved) {
522
+ try {
523
+ await cdp.send('Network.enable');
524
+ const pairs = [
525
+ ['uin', 'o' + saved.uin],
526
+ ['p_uin', 'o' + saved.uin],
527
+ ['skey', saved.skey],
528
+ ];
529
+ if (saved.p_skey)
530
+ pairs.push(['p_skey', saved.p_skey]);
531
+ for (const [name, value] of pairs) {
532
+ if (!value)
533
+ continue;
534
+ try {
535
+ await cdp.send('Network.setCookie', { name, value, domain: '.qq.com', path: '/', secure: true });
536
+ }
537
+ catch { /* 单个 cookie 失败不影响其它 */ }
538
+ }
539
+ await cdp.send('Page.navigate', { url: CHECK_URL });
540
+ await sleep(1800);
541
+ // ★★ 注入之后必须**验证**:这份凭据很可能早就被顶掉了(skey 失效但 cookie 还在)。
542
+ // 不清掉的话,页面会一直停在"看起来已登录"的假状态(没有头像、也没有二维码),
543
+ // 于是登录流程永远拿不到新凭据 —— 实测就是这么卡住的(面板"已登录"但群列表为空)。
544
+ const stillOk = await verifyQunCookieRaw(saved);
545
+ if (!stillOk) {
546
+ try {
547
+ await cdp.send('Network.enable');
548
+ for (const n of ['uin', 'skey', 'p_skey', 'p_uin', 'pt4_token', 'RK']) {
549
+ for (const d of ['.qq.com', 'qun.qq.com']) {
550
+ try {
551
+ await cdp.send('Network.deleteCookies', { name: n, domain: d });
552
+ }
553
+ catch { /* ignore */ }
554
+ }
555
+ }
556
+ // ⚠️ 光删 cookie 不够!qun.qq.com 是 SPA,登录痕迹还在 localStorage/sessionStorage 里,
557
+ // 不清它的话前端路由依然显示"已登录",**登录面板(头像/二维码)根本不会出现**
558
+ // —— 实测就是这么卡死的:页面看着登着、接口说 no login、也出不了二维码。
559
+ try {
560
+ await evalJs(cdp, 'try{localStorage.clear();sessionStorage.clear();}catch(e){}');
561
+ }
562
+ catch { /* ignore */ }
563
+ // ⚠️ 同 URL 的 navigate 在 SPA(hash 路由)里可能只改 hash、**不会真的重新加载**,
564
+ // 页面就永远停在"假已登录"(既没头像也没二维码)。所以:先 ignoreCache 强刷,
565
+ // 再带时间戳导航一次,确保走的是全新的文档。
566
+ try {
567
+ await cdp.send('Page.reload', { ignoreCache: true });
568
+ }
569
+ catch { /* ignore */ }
570
+ await sleep(900);
571
+ await cdp.send('Page.navigate', { url: LOGIN_URL + '?_t=' + Date.now() });
572
+ await sleep(2600);
573
+ }
574
+ catch { /* 清不掉就照常走 */ }
575
+ }
576
+ }
577
+ catch { /* 注入失败就照常走未登录流程 */ }
578
+ }
579
+ // 等页面就绪:**先看 profile 里还登着没**(登着就一次都不用登,直接拿走)
580
+ let hit = null;
581
+ for (let i = 0; i < 24; i++) {
582
+ await sleep(500);
583
+ const ready = await evalJs(cdp, 'document.readyState');
584
+ if (ready !== 'complete' && ready !== 'interactive')
585
+ continue;
586
+ // ① 最快路径:已有有效登录态
587
+ hit = await tryReadCookie(cdp);
588
+ if (hit)
589
+ break;
590
+ // ② 登录面板渲染出来了吗(头像/二维码都是 JS 动态塞的)
591
+ const has = await evalJs(cdp, `(function(){ return (document.querySelector('#qlogin_list') || document.querySelector('#qrlogin_img') || document.querySelector('#qr_login') || document.querySelector('.qlogin_list')) ? 1 : 0; })()`);
592
+ if (Number(has) === 1 && i >= 3)
593
+ break;
594
+ }
595
+ if (hit) {
596
+ closeSession(dataRoot);
597
+ return { ok: true, msg: `免扫码:profile 里的登录态还有效,直接取用(uin=${hit.uin})`, cookie: hit };
598
+ }
599
+ // 再补一次(页面可能刚跳完)
600
+ hit = await tryReadCookie(cdp);
601
+ if (hit) {
602
+ closeSession(dataRoot);
603
+ return { ok: true, msg: `免扫码:profile 里的登录态还有效,直接取用(uin=${hit.uin})`, cookie: hit };
604
+ }
605
+ // ★★ 需要人登录 → 必须**主动跳到 xlogin 面板页**。
606
+ // 为什么不能留在 qun.qq.com:它的登录面板是 **iframe**,二维码(#qrlogin_img)/头像(#qlogin_list)
607
+ // 都在 iframe 的主文档里,从外层 document 查不到 —— 会误判成"页面没有二维码"。
608
+ // (这段以前被包在 if (saved) 里,没有凭据文件时就整段跳过 ⇒ 卡在 qun.qq.com 出不了码)
609
+ try {
610
+ await cdp.send('Page.navigate', { url: LOGIN_URL + '&_t=' + Date.now() });
611
+ await sleep(2800);
612
+ hit = await tryReadCookie(cdp);
613
+ if (hit) {
614
+ closeSession(dataRoot);
615
+ return { ok: true, msg: `登录态有效(uin=${hit.uin})`, cookie: hit };
616
+ }
617
+ }
618
+ catch { /* 跳不过去就继续走头像/二维码 */ }
619
+ // ③ 尝试免扫码:点头像授权(profile 里或本机 QQ 客户端提供了账号)
620
+ const n = await clickAvatar(cdp);
621
+ if (n > 0) {
622
+ for (let i = 0; i < 30; i++) {
623
+ await sleep(500);
624
+ const ck = await tryReadCookie(cdp);
625
+ if (ck) {
626
+ closeSession(dataRoot);
627
+ return { ok: true, msg: `免扫码登录成功(点头像授权),uin=${ck.uin}`, cookie: ck };
628
+ }
629
+ }
630
+ // 点了但没跳过去:可能是环境校验,继续走扫码兜底
631
+ }
632
+ // ④ 兜底:出二维码
633
+ // 出图前先打一行诊断(页面到底停在哪儿、有没有二维码元素)—— 这类"看起来登着却出不了码"的问题
634
+ // 光看结果猜不出来,必须让页面自己说话。
635
+ try {
636
+ const diag = await evalJs(cdp, `(function(){
637
+ try {
638
+ const imgs = Array.from(document.images || []);
639
+ return JSON.stringify({
640
+ href: String(location.href).slice(0, 120),
641
+ title: document.title,
642
+ qloginList: !!document.querySelector('#qlogin_list'),
643
+ qrImg: !!document.querySelector('#qrlogin_img'),
644
+ qrBox: !!document.querySelector('#qrlogin_img_box'),
645
+ imgs: imgs.length,
646
+ bigImgs: imgs.filter(function(i){ return i.width > 80 && i.height > 80; }).length,
647
+ text: String((document.body && document.body.innerText) || '').replace(/\\s+/g, ' ').slice(0, 150)
648
+ });
649
+ } catch (e) { return 'diag-err: ' + e; }
650
+ })()`);
651
+ console.log('[qun-login-diag] ' + diag);
652
+ }
653
+ catch { /* ignore */ }
654
+ const qrPath = await shotQr(cdp, dataRoot);
655
+ sessions.set(dataRoot, { handle, cdp, dataRoot, at: Date.now(), qrPath, clicked: n > 0 });
656
+ installExitHook(); // 宿主退出时把浏览器一起带走(不然会成孤儿进程)
657
+ ensureSweeper(); // 空闲超时自动关(不然扫码没扫成会一直挂着)
658
+ if (!qrPath) {
659
+ return {
660
+ ok: true,
661
+ msg: '没能拿到二维码:这个页面上没有二维码元素(可能卡在"假已登录"状态,或登录页结构变了)。'
662
+ + '可以再调一次 login 重试。注意:宁可不给图也不给错图(曾经把整页截图当二维码发出去)。',
663
+ };
664
+ }
665
+ return {
666
+ ok: true,
667
+ qrPath,
668
+ msg: n > 0
669
+ ? '点了头像但没跳转成功,已改出二维码。请把二维码发给用户,扫完后调 wait 继续。'
670
+ : '这台机器没有可用的 QQ 登录态(浏览器 profile 里没有、本机也没有 QQ 客户端),已出二维码。请发给用户扫,扫完后调 wait 继续。',
671
+ };
672
+ }
673
+ catch (e) {
674
+ try {
675
+ if (cdp)
676
+ cdp.close();
677
+ }
678
+ catch { /* ignore */ }
679
+ closeSession(dataRoot);
680
+ handle.kill();
681
+ return { ok: false, msg: '浏览器登录出错:' + String(e?.message ?? e).slice(0, 200) };
682
+ }
683
+ }
684
+ /**
685
+ * 第二步:在已开的会话上继续等(扫码 or 点头像跳转完成)。
686
+ * budgetMs 默认 25 秒 —— 别让工具调用卡太久,用户可以多次调。
687
+ */
688
+ export async function qunBrowserLoginWait(dataRoot, budgetMs = 25000) {
689
+ const s = sessions.get(dataRoot);
690
+ if (!s)
691
+ return { ok: false, done: false, msg: '没有正在进行的登录会话,请重新调 start' };
692
+ if (Date.now() - s.at > 5 * 60 * 1000) {
693
+ closeSession(dataRoot);
694
+ return { ok: false, done: false, msg: '登录会话超过 5 分钟已关闭,请重新调 start' };
695
+ }
696
+ const deadline = Date.now() + Math.max(3000, budgetMs);
697
+ let last = '等待扫码/授权…';
698
+ while (Date.now() < deadline) {
699
+ const ck = await tryReadCookie(s.cdp);
700
+ if (ck) {
701
+ closeSession(dataRoot);
702
+ return { ok: true, done: true, msg: `登录成功,uin=${ck.uin}`, cookie: ck };
703
+ }
704
+ // 用户可能扫码后卡在确认;顺带再看看头像(万一 QQ 客户端中途起来了)
705
+ if (!s.clicked) {
706
+ const n = await clickAvatar(s.cdp);
707
+ if (n > 0)
708
+ s.clicked = true;
709
+ }
710
+ const href = await currentHref(s.cdp);
711
+ if (href)
712
+ last = /xui\.ptlogin2/.test(href) ? '等待扫码/授权…' : '已跳转,正在取凭据…';
713
+ await sleep(1500);
714
+ }
715
+ return { ok: true, done: false, msg: `${last}(这一轮没等到,可以再调 wait;二维码 3 分钟内有效)` };
716
+ }
717
+ /** 退出登录:清掉浏览器 profile 里的 QQ 登录态(一般不用,留着以后"切换账号"用) */
718
+ export function clearBrowserProfile(dataRoot) {
719
+ closeSession(dataRoot);
720
+ try {
721
+ rmSync(join(dataRoot, 'browser-profile'), { recursive: true, force: true });
722
+ }
723
+ catch { /* ignore */ }
724
+ }
725
+ //# sourceMappingURL=qun-login.js.map