@viyzhu/boss-cli-fork 0.7.3 → 0.8.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.
@@ -1,675 +1,675 @@
1
- import { execFile, spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import readline from 'node:readline';
4
- import path from 'node:path';
5
- import { promisify } from 'node:util';
6
- import puppeteer from 'puppeteer-core';
7
- import { BROWSER_USER_DATA_DIR, ensureAppDataLayout } from '../config.js';
8
- /** 与 @puppeteer/browsers 一致,解析 Chrome 启动日志中的 CDP WebSocket URL(可能在 stdout 或 stderr)。 */
9
- const CDP_WEBSOCKET_ENDPOINT_REGEX = /^DevTools listening on (ws:\/\/.*)$/;
10
- const LAUNCH_READY_MS = 30_000;
11
- /**
12
- * 固定的远程调试端口:boss-cli 使用独立的 user-data-dir,因此可以稳定占用一个端口,
13
- * 让多个命令直接通过 `http://127.0.0.1:<port>/json/version` 复用同一只浏览器。
14
- * 可用 `BOSS_BROWSER_REMOTE_DEBUGGING_PORT` 覆盖。
15
- */
16
- export const REMOTE_DEBUGGING_PORT = (() => {
17
- const raw = process.env.BOSS_BROWSER_REMOTE_DEBUGGING_PORT?.trim();
18
- if (raw) {
19
- const n = Number.parseInt(raw, 10);
20
- if (Number.isFinite(n) && n > 0 && n <= 65535)
21
- return n;
22
- }
23
- return 53470;
24
- })();
25
- let spawnedChromeChild = null;
26
- export function clearSpawnedChromeProcessRef() {
27
- spawnedChromeChild = null;
28
- }
29
- /**
30
- * 是否以无头(隐藏)方式启动。
31
- *
32
- * 优先级:`BOSS_BROWSER_HEADLESS`(本 CLI 专属)> `RECRUIT_BROWSER_HIDDEN`(招聘工具链共读的
33
- * 统一覆盖开关,**只在显式设置时生效**)> **BOSS 自己的默认:有头**,与上游 `joohw/boss-cli` 一致。
34
- *
35
- * **2026-08-19:默认从无头翻回有头。** 本 fork 曾把默认改成无头,理由是招聘浏览器不该
36
- * 抢前景与键盘焦点,当时对代价的评估是「UA 里多个 `HeadlessChrome`,没有观测到实际危害」。
37
- * 现在观测到了:
38
- *
39
- * - 一个账号被 BOSS 限制 web 端登录,页面文案明确写「检测到您的账号存在使用第三方招聘
40
- * 管理系统、插件、外挂、软件等辅助工具」——判定的是**工具指纹**,不是打招呼频率。
41
- * - 另一个团队用上游版(默认有头)长期没事,他们的 AI 擅自改走无头之后当天封号。
42
- *
43
- * 两个独立样本都指向无头。抢焦点是体验问题,被限 web 端登录是业务问题。
44
- * 真要无头,显式设 `RECRUIT_BROWSER_HIDDEN=true`(或 `BOSS_BROWSER_HEADLESS=true`),
45
- * 并且清楚这是在拿账号冒险。
46
- *
47
- * **liepin-cli 那边默认仍是无头**:猎聘的风控形态一次都没观测过,没有证据支持翻它的默认。
48
- * 所以共读变量是「统一覆盖」而非「提供默认值」——不设时两个 CLI 各用自己的默认。
49
- */
50
- export function resolveHeadlessFromEnv() {
51
- const own = process.env.BOSS_BROWSER_HEADLESS?.trim().toLowerCase();
52
- if (own === 'true' || own === '1' || own === 'yes' || own === 'y')
53
- return true;
54
- if (own === 'false' || own === '0' || own === 'no' || own === 'n')
55
- return false;
56
- const shared = process.env.RECRUIT_BROWSER_HIDDEN?.trim().toLowerCase();
57
- if (shared === 'true' || shared === '1' || shared === 'yes' || shared === 'y')
58
- return true;
59
- if (shared === 'false' || shared === '0' || shared === 'no' || shared === 'n')
60
- return false;
61
- return false;
62
- }
63
- /**
64
- * 无头模式追加的启动参数。
65
- *
66
- * 无头虚拟屏默认是 800x600(Chromium 文档化的默认值),这是个已知的强自动化指纹,
67
- * 而 `--window-size` **抬不动它** —— 实测只有 `--screen-info` 能改(Chrome 142+,
68
- * 且仅无头下有效)。`workAreaBottom=40` 让 `screen.availHeight` 小于 `screen.height`,
69
- * 模拟真实桌面的任务栏。注意命名参数是 workAreaTop/Bottom/Left/Right 四个分开写,
70
- * 写成 `workArea=` 会让 Chrome 直接启动失败。
71
- */
72
- const LAUNCH_ARGS_HEADLESS_SCREEN = ['--screen-info={0,0 1920x1080 workAreaBottom=40}'];
73
- /**
74
- * 探测固定调试端口上已在跑的那只浏览器是不是无头:读 `/json/version` 的
75
- * User-Agent,无头 Chrome 报 `HeadlessChrome/<ver>`,有头报 `Chrome/<ver>`
76
- * (实测确认,这是两种模式之间唯一的指纹差异)。
77
- *
78
- * 必须这样读**进程外的真实状态**:一次性命令(如 `boss login`)刚起进程时,
79
- * 任何进程内变量都是空的,靠它们判断等于不判断。
80
- *
81
- * 返回 null 表示端口上没有实例在跑。
82
- *
83
- * ⚠️ 一旦决定伪装 UA 来规避指纹,这个判据就失效,需要换信号。
84
- */
85
- export async function probeRemoteHeadless(port = REMOTE_DEBUGGING_PORT, timeoutMs = 800) {
86
- const ctrl = new AbortController();
87
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
88
- try {
89
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ctrl.signal });
90
- if (!res.ok)
91
- return null;
92
- const data = (await res.json());
93
- const ua = data['User-Agent'];
94
- return typeof ua === 'string' ? /HeadlessChrome/i.test(ua) : null;
95
- }
96
- catch {
97
- return null;
98
- }
99
- finally {
100
- clearTimeout(timer);
101
- }
102
- }
103
- /**
104
- * 关掉固定端口上已在跑的浏览器(本进程没有它的引用时用,例如一次性命令要切换模式)。
105
- * 登录态在 user-data-dir 里,不会因此丢失。
106
- */
107
- export async function closeRemoteBrowser(port = REMOTE_DEBUGGING_PORT) {
108
- const wsUrl = await probeRemoteDebuggingWsEndpoint(port, 800);
109
- if (!wsUrl)
110
- return false;
111
- try {
112
- const browser = await puppeteer.connect({ browserWSEndpoint: wsUrl });
113
- await browser.close();
114
- return true;
115
- }
116
- catch {
117
- return false;
118
- }
119
- }
120
- /**
121
- * 探测固定调试端口上是否已有在跑的 Chrome:直接命中 `/json/version` 拿当前
122
- * `webSocketDebuggerUrl`,避免依赖 `DevToolsActivePort` 这种二级状态文件
123
- * (可能被陈旧/清理/路径 UUID 漂移影响)。命中即可复用,未命中表示需要 spawn。
124
- */
125
- async function probeRemoteDebuggingWsEndpoint(port, timeoutMs) {
126
- const ctrl = new AbortController();
127
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
128
- try {
129
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
130
- signal: ctrl.signal,
131
- });
132
- if (!res.ok)
133
- return undefined;
134
- const data = (await res.json());
135
- const ws = data.webSocketDebuggerUrl;
136
- return typeof ws === 'string' && ws.length > 0 ? ws : undefined;
137
- }
138
- catch {
139
- return undefined;
140
- }
141
- finally {
142
- clearTimeout(timer);
143
- }
144
- }
145
- function waitForDevToolsWebSocketUrl(proc, userDataDir, timeoutMs) {
146
- const streams = [proc.stdout, proc.stderr].filter((s) => s != null);
147
- if (streams.length === 0) {
148
- return Promise.reject(new Error('浏览器子进程无 stdout/stderr,无法获取 CDP 地址'));
149
- }
150
- return new Promise((resolve, reject) => {
151
- const rls = [];
152
- let settled = false;
153
- let timer;
154
- const cleanup = () => {
155
- for (const rl of rls) {
156
- try {
157
- rl.close();
158
- }
159
- catch {
160
- /* ignore */
161
- }
162
- }
163
- rls.length = 0;
164
- };
165
- const finish = (fn) => {
166
- if (settled)
167
- return;
168
- settled = true;
169
- if (timer !== undefined) {
170
- clearTimeout(timer);
171
- timer = undefined;
172
- }
173
- proc.off('exit', onExit);
174
- proc.off('error', onProcError);
175
- cleanup();
176
- fn();
177
- };
178
- timer = setTimeout(() => {
179
- finish(() => {
180
- reject(new Error(`等待 Chrome 输出 DevTools 地址超时(${timeoutMs}ms)`));
181
- });
182
- }, timeoutMs);
183
- const onExit = (code) => {
184
- finish(() => {
185
- reject(new Error(code === 0
186
- ? `浏览器进程立即以代码 0 退出:user-data-dir「${userDataDir}」可能正被另一只「无远程调试端口」的 Chrome 持有(Chrome 单例锁会让我们 spawn 的新进程把命令行交还给它后立刻退出)。请关闭占用该目录的 Chrome 窗口后重试。`
187
- : `浏览器进程在就绪前退出(代码 ${code ?? 'unknown'})`));
188
- });
189
- };
190
- const onProcError = (err) => {
191
- finish(() => {
192
- reject(err);
193
- });
194
- };
195
- const onLine = (line) => {
196
- const m = line.trim().match(CDP_WEBSOCKET_ENDPOINT_REGEX);
197
- if (m?.[1]) {
198
- finish(() => {
199
- resolve(m[1]);
200
- });
201
- }
202
- };
203
- proc.once('exit', onExit);
204
- proc.once('error', onProcError);
205
- for (const s of streams) {
206
- const rl = readline.createInterface(s);
207
- rls.push(rl);
208
- rl.on('line', onLine);
209
- }
210
- });
211
- }
212
- const execFileAsync = promisify(execFile);
213
- /**
214
- * 把 exe + argv 拼成一条 Windows 命令行(`CreateProcess` 的 `lpCommandLine` 语义):
215
- * 含空白或引号的参数整体加双引号,内部 `"` 前补反斜杠,结尾反斜杠成对翻倍。
216
- *
217
- * `--screen-info={0,0 1920x1080 workAreaBottom=40}` 这类带空格的参数不加引号会被拆成多个
218
- * 参数,Chrome 直接启动失败。`spawn()` 在 Windows 上由 libuv 做同样的拼接,走 WMI 就得自己做。
219
- */
220
- export function toWindowsCommandLine(exe, args) {
221
- const quote = (s) => {
222
- if (s.length > 0 && !/[\s"]/.test(s))
223
- return s;
224
- let out = '"';
225
- let pendingBackslashes = 0;
226
- for (const ch of s) {
227
- if (ch === '\\') {
228
- pendingBackslashes++;
229
- continue;
230
- }
231
- if (ch === '"') {
232
- out += '\\'.repeat(pendingBackslashes * 2 + 1) + '"';
233
- pendingBackslashes = 0;
234
- continue;
235
- }
236
- out += '\\'.repeat(pendingBackslashes) + ch;
237
- pendingBackslashes = 0;
238
- }
239
- return out + '\\'.repeat(pendingBackslashes * 2) + '"';
240
- };
241
- return [exe, ...args].map(quote).join(' ');
242
- }
243
- /**
244
- * Windows 上是否让浏览器脱离调用方的 Job Object(默认开;`BOSS_SPAWN_BREAKAWAY=false` 关)。
245
- *
246
- * recruiting-copilot#43(与 liepin-cli#21 同因):从 AI Agent 宿主调用 CLI 时,宿主会把整棵
247
- * 进程树放进一个 `KILL_ON_JOB_CLOSE` 的 Job Object。`spawn({ detached: true })` 在 Windows 上
248
- * 只是新建进程组,**逃不出 Job**,于是 CLI 进程一结束 Chrome 就被连带 `TerminateProcess`:
249
- * profile 留下 `exit_type: Crashed`,会话级 cookie 随进程消失,只能反复重新扫码——而高频
250
- * 重登本身就是平台风控信号。
251
- */
252
- export function shouldBreakawayFromJob() {
253
- if (process.platform !== 'win32')
254
- return false;
255
- const v = process.env.BOSS_SPAWN_BREAKAWAY?.trim().toLowerCase();
256
- return !(v === 'false' || v === '0' || v === 'no' || v === 'n');
257
- }
258
- /**
259
- * 经 WMI `Win32_Process.Create` 拉起浏览器:进程由系统服务 `WmiPrvSE.exe` 创建,因此不在
260
- * 调用方的 Job Object 里,但仍属于当前交互登录会话(有头窗口照常可见)。
261
- * 命令行经环境变量交给 PowerShell,省掉再套一层引号转义。
262
- *
263
- * 抛错由调用方接住并**显著告警后退回 `spawn`**,理由见 `warnBreakawayUnavailable`。
264
- *
265
- * **WMI 被策略拒时的备选(备查,未实现)**:`explorer.exe <临时 .cmd>` 借 shell 重新 parent,
266
- * 同样能脱离调用方的 Job。recruiting-copilot#43 实测有效——心跳文件跨两次调用边界活了 33 秒,
267
- * 同批对照组的普通 detached spawn 立即被杀。参数转义不要让 explorer 直接携带 chrome 的参数,
268
- * 全写进 .cmd 内部;PID 靠调试端口回探,或让 .cmd 自己把子进程 pid 写进临时文件。
269
- * **没实现是权衡后的结果**:留临时文件、拿不到可靠 pid、多一层 shell,而真正需要它的环境
270
- * 恰恰是 WMI 起不来的那种——不值得为此把主路径复杂化。要用再说。
271
- *
272
- * 另两条已排除:`CREATE_BREAKAWAY_FROM_JOB` 死路(#43 实测 WinError 5,对照组只带
273
- * `DETACHED_PROCESS` 成功 ⇒ 外层 Job 没设 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`);`schtasks`
274
- * **未得出结论**(报告人宿主的程序黑名单把它拦在启动前,不是 Windows 拒的,别当成不可行的证据)。
275
- */
276
- export async function spawnViaWmi(commandLine,
277
- /** 仅供测试注入必定失败的 stub;生产路径永远用默认值。 */
278
- powershellExe = 'powershell.exe') {
279
- let stdout;
280
- try {
281
- ({ stdout } = await execFileAsync(powershellExe, [
282
- '-NoProfile',
283
- '-NonInteractive',
284
- '-Command',
285
- '$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create ' +
286
- '-Arguments @{CommandLine=$env:BOSS_SPAWN_CMDLINE}; ' +
287
- 'if ($r.ReturnValue -ne 0) { exit 1 }; $r.ProcessId',
288
- ], {
289
- env: { ...process.env, BOSS_SPAWN_CMDLINE: commandLine },
290
- timeout: 15_000,
291
- windowsHide: true,
292
- }));
293
- }
294
- catch (e) {
295
- throw new Error(`经 WMI 启动浏览器失败(${e instanceof Error ? e.message : String(e)})。` +
296
- 'WMI 用于让浏览器脱离调用方的 Job Object,否则 CLI 一退出 Chrome 就被连带杀掉、登录态丢失。' +
297
- '若本机禁用了 WMI/PowerShell,可显式设 BOSS_SPAWN_BREAKAWAY=false 退回普通启动(届时 #43 会复现)。');
298
- }
299
- const pid = Number.parseInt(stdout.trim(), 10);
300
- if (!Number.isFinite(pid) || pid <= 0) {
301
- throw new Error(`WMI 已受理启动请求但未返回有效 PID(stdout: ${JSON.stringify(stdout)})。`);
302
- }
303
- return pid;
304
- }
305
- /**
306
- * WMI 拉不起来时的显著告警。打完这条就退回普通 `spawn`。
307
- *
308
- * **这是 AGENTS.md「禁止回退逻辑 / 失败直接暴露」的一处有意例外,别顺手删掉。**
309
- * 那条规则禁的是**静默**兜底;这里告警是刷屏级的,用户不可能看不见,不属于「掩盖根因」。
310
- *
311
- * 为什么必须退回(recruiting-copilot#43 验收反馈,2026-09-21):报告人那台 Windows 上
312
- * `Invoke-CimMethod Win32_Process Create` 返回 `ReturnValue=2`(拒绝访问)——读操作正常,
313
- * 单单「创建进程」被策略拒了。硬失败的结果是 `search` / `recommend` / `list` 全部不可用,
314
- * 他只能设 `BOSS_SPAWN_BREAKAWAY=false` 兜住,而那恰好把 #43 原样装回去,还绕过了这条告警。
315
- * 权衡很清楚:**退回后是「浏览器可能被连带杀掉」(可恢复,重扫码),硬失败是「CLI 完全不能用」**
316
- * (不可恢复,除非用户自己找到那个环境变量)。前者更轻,且带告警时用户知道自己在什么状态。
317
- *
318
- * 退回**只覆盖「WMI 创建进程失败」这一步**。进程已创建但调试端口没起来,仍然硬失败——
319
- * 那时候端口上可能已经有一只正在启动的 Chrome,再 spawn 一只会撞车。
320
- */
321
- export function warnBreakawayUnavailable(cause) {
322
- const reason = cause instanceof Error ? cause.message : String(cause);
323
- console.error([
324
- '',
325
- '='.repeat(72),
326
- '⚠️ 浏览器无法脱离调用方的 Job Object —— 已退回普通启动方式',
327
- '='.repeat(72),
328
- `原因:${reason}`,
329
- '',
330
- '影响:从 AI Agent 宿主调用时,本条命令结束后 Chrome 可能被连带杀掉,',
331
- ' profile 会留下 exit_type: Crashed,会话级登录态丢失、需要重新扫码。',
332
- ' 高频重登本身就是平台风控信号(见 recruiting-copilot#43)。',
333
- '',
334
- '常见成因:本机策略拒绝了 WMI 创建进程(Invoke-CimMethod Win32_Process Create',
335
- ' 返回 ReturnValue=2),或 PowerShell 被禁用。自查:',
336
- ` powershell -NoProfile -Command "(Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='cmd.exe /c exit'}).ReturnValue"`,
337
- ' 返回 0 表示可用;2 表示被拒。',
338
- '',
339
- '缓解:把 login / 业务动作 / shutdown 放进同一次调用,收尾用 boss shutdown 干净退出。',
340
- ' 确认本机就是起不来、不想再看这条告警:设 BOSS_SPAWN_BREAKAWAY=false。',
341
- '='.repeat(72),
342
- '',
343
- ].join('\n'));
344
- }
345
- /** 进程是否还活着(signal 0 只做存在性探测,不投递信号)。 */
346
- function isProcessAlive(pid) {
347
- try {
348
- process.kill(pid, 0);
349
- return true;
350
- }
351
- catch {
352
- return false;
353
- }
354
- }
355
- /**
356
- * WMI 路径下没有子进程的 stdout/stderr 可读,改为轮询固定调试端口等待就绪。
357
- * 端口本来就是固定的(见 `REMOTE_DEBUGGING_PORT`),不需要解析 Chrome 启动日志。
358
- */
359
- async function waitForRemoteDebuggingWsEndpoint(pid, userDataDir, timeoutMs) {
360
- const deadline = Date.now() + timeoutMs;
361
- while (Date.now() < deadline) {
362
- const wsUrl = await probeRemoteDebuggingWsEndpoint(REMOTE_DEBUGGING_PORT, 800);
363
- if (wsUrl)
364
- return wsUrl;
365
- if (!isProcessAlive(pid)) {
366
- throw new Error(`浏览器进程在就绪前退出:user-data-dir「${userDataDir}」可能正被另一只「无远程调试端口」的 Chrome 持有(Chrome 单例锁会让新进程把命令行交还给它后立刻退出)。请关闭占用该目录的 Chrome 窗口后重试。`);
367
- }
368
- await new Promise((r) => setTimeout(r, 300));
369
- }
370
- throw new Error(`等待浏览器在端口 ${REMOTE_DEBUGGING_PORT} 就绪超时(${timeoutMs}ms,PID ${pid})。`);
371
- }
372
- /** 在未配置路径时,尝试常见安装位置(Chrome / Edge / Chromium)。 */
373
- function findLocalChromiumExecutable() {
374
- const candidates = [];
375
- if (process.platform === 'win32') {
376
- const local = process.env.LOCALAPPDATA;
377
- const pf = process.env.PROGRAMFILES;
378
- const pf86 = process.env['PROGRAMFILES(X86)'];
379
- if (local) {
380
- candidates.push(path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'));
381
- }
382
- if (pf) {
383
- candidates.push(path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'));
384
- candidates.push(path.join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
385
- }
386
- if (pf86) {
387
- candidates.push(path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'));
388
- candidates.push(path.join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
389
- }
390
- }
391
- else if (process.platform === 'darwin') {
392
- candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium');
393
- }
394
- else {
395
- candidates.push('/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge-stable', '/usr/bin/microsoft-edge');
396
- }
397
- for (const p of candidates) {
398
- if (existsSync(p))
399
- return p;
400
- }
401
- return undefined;
402
- }
403
- /** 减轻「正受到自动测试软件的控制」提示与常见自动化特征(非万能,站点仍可能用其它方式检测)。手动开 Chrome 并接 CDP 时可复用。 */
404
- export const LAUNCH_ARGS_LESS_AUTOMATION = [
405
- '--disable-infobars',
406
- ];
407
- /** 仅用于本地调试:尽量放宽同源/CORS 限制,便于跨域 iframe/canvas 处理。 */
408
- export const LAUNCH_ARGS_ALLOW_ALL_CORS = [
409
- '--disable-web-security',
410
- '--allow-running-insecure-content',
411
- ];
412
- /**
413
- * 启动本机浏览器(puppeteer-core 底层为 Chrome DevTools Protocol)。
414
- *
415
- * 环境变量(可选):
416
- * - `CHROME_PATH` / `PUPPETEER_EXECUTABLE_PATH` — 启动本机浏览器可执行文件路径(高于自动探测)
417
- * - `BOSS_BROWSER_USER_DATA_DIR` — 启动浏览器时复用的用户数据目录;未设置时默认 `~/.boss-cli/.cache/browser-data`
418
- * - `BOSS_BROWSER_PROFILE_DIRECTORY` — 启动浏览器时指定 profile(如 `Default`)
419
- * - `BOSS_BROWSER_REMOTE_DEBUGGING_PORT` — 远程调试端口(默认 53470);同一 user-data-dir 跨命令复用该端口
420
- * - `BOSS_BROWSER_ALLOW_ALL_CORS` — 设为 `true` 时附加放宽同源/CORS 的启动参数(仅调试)
421
- * - `BOSS_BROWSER_DISABLE_GPU` — 设为 `true` 时附加 `--disable-gpu`
422
- *
423
- * 若以上均未设置,会按系统尝试常见 Chrome / Edge / Chromium 安装路径。
424
- * - `RECRUIT_BROWSER_HIDDEN` — 招聘工具链共读的隐藏开关;**默认有界面**,设为 `true` 才无头(有账号风险,见 `resolveHeadlessFromEnv`)。
425
- * - `BOSS_BROWSER_HEADLESS` — 本 CLI 专属覆盖项,优先级高于 `RECRUIT_BROWSER_HIDDEN`。
426
- * - `BOSS_BROWSER_VIEWPORT_WIDTH` / `BOSS_BROWSER_VIEWPORT_HEIGHT` — 启动时显式指定视口;未设置时不覆盖浏览器窗口尺寸
427
- */
428
- /** 启动浏览器时的默认视口(与环境变量一致);截图恢复时 `viewport()` 为 null 也可用其兜底。 */
429
- export function defaultViewportFromEnv() {
430
- const w = Number.parseInt(process.env.BOSS_BROWSER_VIEWPORT_WIDTH?.trim() ?? '', 10);
431
- const h = Number.parseInt(process.env.BOSS_BROWSER_VIEWPORT_HEIGHT?.trim() ?? '', 10);
432
- return {
433
- width: Number.isFinite(w) && w > 0 ? w : 1280,
434
- height: Number.isFinite(h) && h > 0 ? h : 1200,
435
- };
436
- }
437
- /** 仅在显式配置了视口环境变量时返回启动视口;否则返回 null,不覆盖浏览器实际窗口尺寸。 */
438
- function launchViewportFromEnv() {
439
- const rawW = process.env.BOSS_BROWSER_VIEWPORT_WIDTH?.trim() ?? '';
440
- const rawH = process.env.BOSS_BROWSER_VIEWPORT_HEIGHT?.trim() ?? '';
441
- if (!rawW && !rawH) {
442
- return null;
443
- }
444
- return defaultViewportFromEnv();
445
- }
446
- export async function connectBrowser(options = {}) {
447
- const executablePath = options.executablePath?.trim() ||
448
- process.env.CHROME_PATH?.trim() ||
449
- process.env.PUPPETEER_EXECUTABLE_PATH?.trim() ||
450
- findLocalChromiumExecutable();
451
- const envUserData = process.env.BOSS_BROWSER_USER_DATA_DIR?.trim();
452
- if (!envUserData) {
453
- ensureAppDataLayout();
454
- }
455
- const userDataDir = options.userDataDir?.trim() || envUserData || BROWSER_USER_DATA_DIR;
456
- const profileDirectory = options.profileDirectory?.trim() || process.env.BOSS_BROWSER_PROFILE_DIRECTORY?.trim();
457
- if (!executablePath) {
458
- throw new Error('未找到本机 Chrome/Edge:请设置 CHROME_PATH / PUPPETEER_EXECUTABLE_PATH(可执行文件路径)。');
459
- }
460
- const headless = options.headless ?? resolveHeadlessFromEnv();
461
- const allowAllCors = options.allowAllCors ?? process.env.BOSS_BROWSER_ALLOW_ALL_CORS === 'true';
462
- const disableGpu = process.env.BOSS_BROWSER_DISABLE_GPU === 'true';
463
- clearSpawnedChromeProcessRef();
464
- /**
465
- * 优先直连固定调试端口上的已有实例:boss-cli 使用独立 user-data-dir,
466
- * 端口稳定可期,命中即跨命令复用同一只浏览器(同一登录态、同一标签)。
467
- */
468
- const existingWsUrl = await probeRemoteDebuggingWsEndpoint(REMOTE_DEBUGGING_PORT, 800);
469
- if (existingWsUrl) {
470
- return await puppeteer.connect({
471
- browserWSEndpoint: existingWsUrl,
472
- defaultViewport: launchViewportFromEnv(),
473
- });
474
- }
475
- // 默认保留 WebAssembly:`typeof WebAssembly === 'undefined'` 本身就是强自动化指纹。
476
- // aegis_bg.wasm 已在 CDP `Fetch.enable` 层被阻断,不需要再禁用 WASM 引擎。
477
- // 仅当显式设置 BOSS_BROWSER_DISABLE_WASM=true/1 时才追加 --noexpose_wasm。
478
- const disableWasm = process.env.BOSS_BROWSER_DISABLE_WASM === 'true' || process.env.BOSS_BROWSER_DISABLE_WASM === '1';
479
- const userArgs = [
480
- ...LAUNCH_ARGS_LESS_AUTOMATION,
481
- // 上一只若被外力杀掉(Job Object 连带、任务管理器),别弹「要恢复页面吗?Chrome 未正确关闭」
482
- '--hide-crash-restore-bubble',
483
- ...(headless ? LAUNCH_ARGS_HEADLESS_SCREEN : []),
484
- ...(disableGpu ? ['--disable-gpu'] : []),
485
- ...(disableWasm ? ['--js-flags=--noexpose_wasm'] : []),
486
- ...(allowAllCors ? LAUNCH_ARGS_ALLOW_ALL_CORS : []),
487
- ...(profileDirectory ? [`--profile-directory=${profileDirectory}`] : []),
488
- ];
489
- let chromeArgs = puppeteer
490
- .defaultArgs({
491
- browser: 'chrome',
492
- userDataDir,
493
- headless,
494
- args: userArgs,
495
- })
496
- .filter((a) => a !== '--enable-automation' && a !== 'about:blank' && a !== 'data:,');
497
- if (!chromeArgs.some((a) => a.startsWith('--remote-debugging-'))) {
498
- chromeArgs.push(`--remote-debugging-port=${REMOTE_DEBUGGING_PORT}`);
499
- }
500
- /**
501
- * Windows 默认经 WMI 启动,让浏览器脱离调用方的 Job Object(见 `shouldBreakawayFromJob`)。
502
- * 这条路径没有子进程句柄,因此既不会被 Job 连带杀掉,也不会有 stdio 管道拖住 Node 退出。
503
- */
504
- let breakawayPid = null;
505
- if (shouldBreakawayFromJob()) {
506
- try {
507
- breakawayPid = await spawnViaWmi(toWindowsCommandLine(executablePath, chromeArgs));
508
- }
509
- catch (e) {
510
- // 只有「创建进程」这一步失败才退回 spawn;告警很吵,是故意的(见函数注释)。
511
- warnBreakawayUnavailable(e);
512
- }
513
- }
514
- if (breakawayPid !== null) {
515
- // 注意这句在 try 外:进程已创建但调试端口没起来是真故障,不能再 spawn 一只去撞端口。
516
- const wsUrl = await waitForRemoteDebuggingWsEndpoint(breakawayPid, userDataDir, LAUNCH_READY_MS);
517
- return await puppeteer.connect({
518
- browserWSEndpoint: wsUrl,
519
- defaultViewport: launchViewportFromEnv(),
520
- });
521
- }
522
- /**
523
- * 不使用 `puppeteer.launch()`:其依赖的 `@puppeteer/browsers` 会在 **Node 进程 `exit` 时 kill 浏览器子进程**,
524
- * 导致交互模式 / `npm run dev` 退出时窗口被一并关掉。改为自行 `spawn` + `connect`,退出时只断 CDP,浏览器可保留。
525
- */
526
- const proc = spawn(executablePath, chromeArgs, {
527
- detached: true,
528
- env: process.env,
529
- stdio: ['ignore', 'pipe', 'pipe'],
530
- });
531
- spawnedChromeChild = proc;
532
- let wsUrl;
533
- try {
534
- wsUrl = await waitForDevToolsWebSocketUrl(proc, userDataDir, LAUNCH_READY_MS);
535
- }
536
- catch (e) {
537
- try {
538
- proc.kill();
539
- }
540
- catch {
541
- /* ignore */
542
- }
543
- clearSpawnedChromeProcessRef();
544
- throw e;
545
- }
546
- /**
547
- * `resume()` 排空管道(不读会把 Chrome 的 stderr 写满阻塞住),`unref()` 解掉管道对
548
- * event loop 的引用——`proc.unref()` 只作用于子进程句柄,**解不掉 stdio 管道**,
549
- * 少了这一步 Node 要等常驻 Chrome 退出才返回(#43 里「结果已打印却挂住」的直接原因)。
550
- */
551
- try {
552
- for (const s of [proc.stdout, proc.stderr]) {
553
- if (!s)
554
- continue;
555
- s.resume();
556
- // 运行时是 net.Socket(有 unref),Readable 的类型签名里没有
557
- s.unref?.();
558
- }
559
- }
560
- catch {
561
- /* ignore */
562
- }
563
- /** 单例移交时子进程已退出,无句柄可 unref;仅在本进程真正拉起 Chrome 时 unref,避免拖住 Node 退出。 */
564
- if (proc.exitCode === null && proc.signalCode === null) {
565
- try {
566
- proc.unref();
567
- }
568
- catch {
569
- /* ignore */
570
- }
571
- }
572
- else {
573
- clearSpawnedChromeProcessRef();
574
- }
575
- try {
576
- return await puppeteer.connect({
577
- browserWSEndpoint: wsUrl,
578
- defaultViewport: launchViewportFromEnv(),
579
- });
580
- }
581
- catch (e) {
582
- try {
583
- proc.kill();
584
- }
585
- catch {
586
- /* ignore */
587
- }
588
- clearSpawnedChromeProcessRef();
589
- throw e;
590
- }
591
- }
592
- /**
593
- * 是否禁止 CLI 把 Boss 窗口抢到前台。
594
- *
595
- * 默认允许:`page.bringToFront()` 走 `Target.activateTarget`,Windows 上会把**最小化**的窗口
596
- * 还原并夺取前台焦点。把 CLI 接进后台系统定时跑的人(例如把 boss-cli 打通到内部招聘系统)
597
- * 会被每条命令弹一次窗口打断办公,所以给一个显式关闭项。
598
- */
599
- export function resolveNoForegroundFromEnv() {
600
- const v = process.env.BOSS_BROWSER_NO_FOREGROUND?.trim().toLowerCase();
601
- return v === 'true' || v === '1' || v === 'yes' || v === 'y';
602
- }
603
- /**
604
- * 判定这次要不要抢前台:环境变量关了 → 不抢;窗口已被人最小化 → 不抢(尊重人的选择);
605
- * 其余情况照旧。抽出来是为了让判定逻辑不依赖真实浏览器就能测。
606
- */
607
- export function decideBringToFront(windowState, noForeground) {
608
- if (noForeground)
609
- return 'skipped-env';
610
- if (windowState === 'minimized')
611
- return 'skipped-minimized';
612
- return 'raised';
613
- }
614
- /**
615
- * `page.bringToFront()` 的替代:先问 `Browser.getWindowForTarget` 窗口状态,最小化就不动它。
616
- * 单标签窗口下不抢前台对自动化没有影响;CDP 操作不需要窗口可见。
617
- */
618
- export async function bringToFrontUnlessMinimized(page) {
619
- const noForeground = resolveNoForegroundFromEnv();
620
- let windowState;
621
- if (!noForeground) {
622
- const cdp = await page.createCDPSession();
623
- try {
624
- ({ windowState } = (await cdp.send('Browser.getWindowForTarget')).bounds);
625
- }
626
- finally {
627
- await cdp.detach();
628
- }
629
- }
630
- const outcome = decideBringToFront(windowState, noForeground);
631
- if (outcome === 'raised')
632
- await page.bringToFront();
633
- return outcome;
634
- }
635
- /**
636
- * 需要真实渲染的操作(在线简历 canvas、截图)在**最小化**窗口里会永远等不到新帧
637
- * (实测 `Page.captureScreenshot` 第二次起就挂死)。这里临时把窗口还原成 normal,
638
- * 跑完立刻再最小化——短暂闪一下,但不把「最小化」这个人的选择永久推翻。
639
- * 窗口本来没最小化时什么都不做。
640
- */
641
- export async function withWindowVisible(page, fn) {
642
- const cdp = await page.createCDPSession();
643
- let windowId;
644
- let wasMinimized;
645
- try {
646
- const info = await cdp.send('Browser.getWindowForTarget');
647
- windowId = info.windowId;
648
- wasMinimized = info.bounds.windowState === 'minimized';
649
- if (wasMinimized) {
650
- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
651
- }
652
- }
653
- catch (e) {
654
- await cdp.detach();
655
- throw e;
656
- }
657
- try {
658
- return await fn();
659
- }
660
- finally {
661
- try {
662
- if (wasMinimized) {
663
- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'minimized' } });
664
- }
665
- }
666
- finally {
667
- await cdp.detach();
668
- }
669
- }
670
- }
671
- /** 对某一页创建原生 CDP Session(需要低层域如 `Network.*`、`Fetch.*` 时使用)。 */
672
- export async function createPageCDPSession(page) {
673
- return page.createCDPSession();
674
- }
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import readline from 'node:readline';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import puppeteer from 'puppeteer-core';
7
+ import { BROWSER_USER_DATA_DIR, ensureAppDataLayout } from '../config.js';
8
+ /** 与 @puppeteer/browsers 一致,解析 Chrome 启动日志中的 CDP WebSocket URL(可能在 stdout 或 stderr)。 */
9
+ const CDP_WEBSOCKET_ENDPOINT_REGEX = /^DevTools listening on (ws:\/\/.*)$/;
10
+ const LAUNCH_READY_MS = 30_000;
11
+ /**
12
+ * 固定的远程调试端口:boss-cli 使用独立的 user-data-dir,因此可以稳定占用一个端口,
13
+ * 让多个命令直接通过 `http://127.0.0.1:<port>/json/version` 复用同一只浏览器。
14
+ * 可用 `BOSS_BROWSER_REMOTE_DEBUGGING_PORT` 覆盖。
15
+ */
16
+ export const REMOTE_DEBUGGING_PORT = (() => {
17
+ const raw = process.env.BOSS_BROWSER_REMOTE_DEBUGGING_PORT?.trim();
18
+ if (raw) {
19
+ const n = Number.parseInt(raw, 10);
20
+ if (Number.isFinite(n) && n > 0 && n <= 65535)
21
+ return n;
22
+ }
23
+ return 53470;
24
+ })();
25
+ let spawnedChromeChild = null;
26
+ export function clearSpawnedChromeProcessRef() {
27
+ spawnedChromeChild = null;
28
+ }
29
+ /**
30
+ * 是否以无头(隐藏)方式启动。
31
+ *
32
+ * 优先级:`BOSS_BROWSER_HEADLESS`(本 CLI 专属)> `RECRUIT_BROWSER_HIDDEN`(招聘工具链共读的
33
+ * 统一覆盖开关,**只在显式设置时生效**)> **BOSS 自己的默认:有头**,与上游 `joohw/boss-cli` 一致。
34
+ *
35
+ * **2026-08-19:默认从无头翻回有头。** 本 fork 曾把默认改成无头,理由是招聘浏览器不该
36
+ * 抢前景与键盘焦点,当时对代价的评估是「UA 里多个 `HeadlessChrome`,没有观测到实际危害」。
37
+ * 现在观测到了:
38
+ *
39
+ * - 一个账号被 BOSS 限制 web 端登录,页面文案明确写「检测到您的账号存在使用第三方招聘
40
+ * 管理系统、插件、外挂、软件等辅助工具」——判定的是**工具指纹**,不是打招呼频率。
41
+ * - 另一个团队用上游版(默认有头)长期没事,他们的 AI 擅自改走无头之后当天封号。
42
+ *
43
+ * 两个独立样本都指向无头。抢焦点是体验问题,被限 web 端登录是业务问题。
44
+ * 真要无头,显式设 `RECRUIT_BROWSER_HIDDEN=true`(或 `BOSS_BROWSER_HEADLESS=true`),
45
+ * 并且清楚这是在拿账号冒险。
46
+ *
47
+ * **liepin-cli 那边默认仍是无头**:猎聘的风控形态一次都没观测过,没有证据支持翻它的默认。
48
+ * 所以共读变量是「统一覆盖」而非「提供默认值」——不设时两个 CLI 各用自己的默认。
49
+ */
50
+ export function resolveHeadlessFromEnv() {
51
+ const own = process.env.BOSS_BROWSER_HEADLESS?.trim().toLowerCase();
52
+ if (own === 'true' || own === '1' || own === 'yes' || own === 'y')
53
+ return true;
54
+ if (own === 'false' || own === '0' || own === 'no' || own === 'n')
55
+ return false;
56
+ const shared = process.env.RECRUIT_BROWSER_HIDDEN?.trim().toLowerCase();
57
+ if (shared === 'true' || shared === '1' || shared === 'yes' || shared === 'y')
58
+ return true;
59
+ if (shared === 'false' || shared === '0' || shared === 'no' || shared === 'n')
60
+ return false;
61
+ return false;
62
+ }
63
+ /**
64
+ * 无头模式追加的启动参数。
65
+ *
66
+ * 无头虚拟屏默认是 800x600(Chromium 文档化的默认值),这是个已知的强自动化指纹,
67
+ * 而 `--window-size` **抬不动它** —— 实测只有 `--screen-info` 能改(Chrome 142+,
68
+ * 且仅无头下有效)。`workAreaBottom=40` 让 `screen.availHeight` 小于 `screen.height`,
69
+ * 模拟真实桌面的任务栏。注意命名参数是 workAreaTop/Bottom/Left/Right 四个分开写,
70
+ * 写成 `workArea=` 会让 Chrome 直接启动失败。
71
+ */
72
+ const LAUNCH_ARGS_HEADLESS_SCREEN = ['--screen-info={0,0 1920x1080 workAreaBottom=40}'];
73
+ /**
74
+ * 探测固定调试端口上已在跑的那只浏览器是不是无头:读 `/json/version` 的
75
+ * User-Agent,无头 Chrome 报 `HeadlessChrome/<ver>`,有头报 `Chrome/<ver>`
76
+ * (实测确认,这是两种模式之间唯一的指纹差异)。
77
+ *
78
+ * 必须这样读**进程外的真实状态**:一次性命令(如 `boss login`)刚起进程时,
79
+ * 任何进程内变量都是空的,靠它们判断等于不判断。
80
+ *
81
+ * 返回 null 表示端口上没有实例在跑。
82
+ *
83
+ * ⚠️ 一旦决定伪装 UA 来规避指纹,这个判据就失效,需要换信号。
84
+ */
85
+ export async function probeRemoteHeadless(port = REMOTE_DEBUGGING_PORT, timeoutMs = 800) {
86
+ const ctrl = new AbortController();
87
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
88
+ try {
89
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ctrl.signal });
90
+ if (!res.ok)
91
+ return null;
92
+ const data = (await res.json());
93
+ const ua = data['User-Agent'];
94
+ return typeof ua === 'string' ? /HeadlessChrome/i.test(ua) : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+ /**
104
+ * 关掉固定端口上已在跑的浏览器(本进程没有它的引用时用,例如一次性命令要切换模式)。
105
+ * 登录态在 user-data-dir 里,不会因此丢失。
106
+ */
107
+ export async function closeRemoteBrowser(port = REMOTE_DEBUGGING_PORT) {
108
+ const wsUrl = await probeRemoteDebuggingWsEndpoint(port, 800);
109
+ if (!wsUrl)
110
+ return false;
111
+ try {
112
+ const browser = await puppeteer.connect({ browserWSEndpoint: wsUrl });
113
+ await browser.close();
114
+ return true;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /**
121
+ * 探测固定调试端口上是否已有在跑的 Chrome:直接命中 `/json/version` 拿当前
122
+ * `webSocketDebuggerUrl`,避免依赖 `DevToolsActivePort` 这种二级状态文件
123
+ * (可能被陈旧/清理/路径 UUID 漂移影响)。命中即可复用,未命中表示需要 spawn。
124
+ */
125
+ async function probeRemoteDebuggingWsEndpoint(port, timeoutMs) {
126
+ const ctrl = new AbortController();
127
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
128
+ try {
129
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
130
+ signal: ctrl.signal,
131
+ });
132
+ if (!res.ok)
133
+ return undefined;
134
+ const data = (await res.json());
135
+ const ws = data.webSocketDebuggerUrl;
136
+ return typeof ws === 'string' && ws.length > 0 ? ws : undefined;
137
+ }
138
+ catch {
139
+ return undefined;
140
+ }
141
+ finally {
142
+ clearTimeout(timer);
143
+ }
144
+ }
145
+ function waitForDevToolsWebSocketUrl(proc, userDataDir, timeoutMs) {
146
+ const streams = [proc.stdout, proc.stderr].filter((s) => s != null);
147
+ if (streams.length === 0) {
148
+ return Promise.reject(new Error('浏览器子进程无 stdout/stderr,无法获取 CDP 地址'));
149
+ }
150
+ return new Promise((resolve, reject) => {
151
+ const rls = [];
152
+ let settled = false;
153
+ let timer;
154
+ const cleanup = () => {
155
+ for (const rl of rls) {
156
+ try {
157
+ rl.close();
158
+ }
159
+ catch {
160
+ /* ignore */
161
+ }
162
+ }
163
+ rls.length = 0;
164
+ };
165
+ const finish = (fn) => {
166
+ if (settled)
167
+ return;
168
+ settled = true;
169
+ if (timer !== undefined) {
170
+ clearTimeout(timer);
171
+ timer = undefined;
172
+ }
173
+ proc.off('exit', onExit);
174
+ proc.off('error', onProcError);
175
+ cleanup();
176
+ fn();
177
+ };
178
+ timer = setTimeout(() => {
179
+ finish(() => {
180
+ reject(new Error(`等待 Chrome 输出 DevTools 地址超时(${timeoutMs}ms)`));
181
+ });
182
+ }, timeoutMs);
183
+ const onExit = (code) => {
184
+ finish(() => {
185
+ reject(new Error(code === 0
186
+ ? `浏览器进程立即以代码 0 退出:user-data-dir「${userDataDir}」可能正被另一只「无远程调试端口」的 Chrome 持有(Chrome 单例锁会让我们 spawn 的新进程把命令行交还给它后立刻退出)。请关闭占用该目录的 Chrome 窗口后重试。`
187
+ : `浏览器进程在就绪前退出(代码 ${code ?? 'unknown'})`));
188
+ });
189
+ };
190
+ const onProcError = (err) => {
191
+ finish(() => {
192
+ reject(err);
193
+ });
194
+ };
195
+ const onLine = (line) => {
196
+ const m = line.trim().match(CDP_WEBSOCKET_ENDPOINT_REGEX);
197
+ if (m?.[1]) {
198
+ finish(() => {
199
+ resolve(m[1]);
200
+ });
201
+ }
202
+ };
203
+ proc.once('exit', onExit);
204
+ proc.once('error', onProcError);
205
+ for (const s of streams) {
206
+ const rl = readline.createInterface(s);
207
+ rls.push(rl);
208
+ rl.on('line', onLine);
209
+ }
210
+ });
211
+ }
212
+ const execFileAsync = promisify(execFile);
213
+ /**
214
+ * 把 exe + argv 拼成一条 Windows 命令行(`CreateProcess` 的 `lpCommandLine` 语义):
215
+ * 含空白或引号的参数整体加双引号,内部 `"` 前补反斜杠,结尾反斜杠成对翻倍。
216
+ *
217
+ * `--screen-info={0,0 1920x1080 workAreaBottom=40}` 这类带空格的参数不加引号会被拆成多个
218
+ * 参数,Chrome 直接启动失败。`spawn()` 在 Windows 上由 libuv 做同样的拼接,走 WMI 就得自己做。
219
+ */
220
+ export function toWindowsCommandLine(exe, args) {
221
+ const quote = (s) => {
222
+ if (s.length > 0 && !/[\s"]/.test(s))
223
+ return s;
224
+ let out = '"';
225
+ let pendingBackslashes = 0;
226
+ for (const ch of s) {
227
+ if (ch === '\\') {
228
+ pendingBackslashes++;
229
+ continue;
230
+ }
231
+ if (ch === '"') {
232
+ out += '\\'.repeat(pendingBackslashes * 2 + 1) + '"';
233
+ pendingBackslashes = 0;
234
+ continue;
235
+ }
236
+ out += '\\'.repeat(pendingBackslashes) + ch;
237
+ pendingBackslashes = 0;
238
+ }
239
+ return out + '\\'.repeat(pendingBackslashes * 2) + '"';
240
+ };
241
+ return [exe, ...args].map(quote).join(' ');
242
+ }
243
+ /**
244
+ * Windows 上是否让浏览器脱离调用方的 Job Object(默认开;`BOSS_SPAWN_BREAKAWAY=false` 关)。
245
+ *
246
+ * recruiting-copilot#43(与 liepin-cli#21 同因):从 AI Agent 宿主调用 CLI 时,宿主会把整棵
247
+ * 进程树放进一个 `KILL_ON_JOB_CLOSE` 的 Job Object。`spawn({ detached: true })` 在 Windows 上
248
+ * 只是新建进程组,**逃不出 Job**,于是 CLI 进程一结束 Chrome 就被连带 `TerminateProcess`:
249
+ * profile 留下 `exit_type: Crashed`,会话级 cookie 随进程消失,只能反复重新扫码——而高频
250
+ * 重登本身就是平台风控信号。
251
+ */
252
+ export function shouldBreakawayFromJob() {
253
+ if (process.platform !== 'win32')
254
+ return false;
255
+ const v = process.env.BOSS_SPAWN_BREAKAWAY?.trim().toLowerCase();
256
+ return !(v === 'false' || v === '0' || v === 'no' || v === 'n');
257
+ }
258
+ /**
259
+ * 经 WMI `Win32_Process.Create` 拉起浏览器:进程由系统服务 `WmiPrvSE.exe` 创建,因此不在
260
+ * 调用方的 Job Object 里,但仍属于当前交互登录会话(有头窗口照常可见)。
261
+ * 命令行经环境变量交给 PowerShell,省掉再套一层引号转义。
262
+ *
263
+ * 抛错由调用方接住并**显著告警后退回 `spawn`**,理由见 `warnBreakawayUnavailable`。
264
+ *
265
+ * **WMI 被策略拒时的备选(备查,未实现)**:`explorer.exe <临时 .cmd>` 借 shell 重新 parent,
266
+ * 同样能脱离调用方的 Job。recruiting-copilot#43 实测有效——心跳文件跨两次调用边界活了 33 秒,
267
+ * 同批对照组的普通 detached spawn 立即被杀。参数转义不要让 explorer 直接携带 chrome 的参数,
268
+ * 全写进 .cmd 内部;PID 靠调试端口回探,或让 .cmd 自己把子进程 pid 写进临时文件。
269
+ * **没实现是权衡后的结果**:留临时文件、拿不到可靠 pid、多一层 shell,而真正需要它的环境
270
+ * 恰恰是 WMI 起不来的那种——不值得为此把主路径复杂化。要用再说。
271
+ *
272
+ * 另两条已排除:`CREATE_BREAKAWAY_FROM_JOB` 死路(#43 实测 WinError 5,对照组只带
273
+ * `DETACHED_PROCESS` 成功 ⇒ 外层 Job 没设 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`);`schtasks`
274
+ * **未得出结论**(报告人宿主的程序黑名单把它拦在启动前,不是 Windows 拒的,别当成不可行的证据)。
275
+ */
276
+ export async function spawnViaWmi(commandLine,
277
+ /** 仅供测试注入必定失败的 stub;生产路径永远用默认值。 */
278
+ powershellExe = 'powershell.exe') {
279
+ let stdout;
280
+ try {
281
+ ({ stdout } = await execFileAsync(powershellExe, [
282
+ '-NoProfile',
283
+ '-NonInteractive',
284
+ '-Command',
285
+ '$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create ' +
286
+ '-Arguments @{CommandLine=$env:BOSS_SPAWN_CMDLINE}; ' +
287
+ 'if ($r.ReturnValue -ne 0) { exit 1 }; $r.ProcessId',
288
+ ], {
289
+ env: { ...process.env, BOSS_SPAWN_CMDLINE: commandLine },
290
+ timeout: 15_000,
291
+ windowsHide: true,
292
+ }));
293
+ }
294
+ catch (e) {
295
+ throw new Error(`经 WMI 启动浏览器失败(${e instanceof Error ? e.message : String(e)})。` +
296
+ 'WMI 用于让浏览器脱离调用方的 Job Object,否则 CLI 一退出 Chrome 就被连带杀掉、登录态丢失。' +
297
+ '若本机禁用了 WMI/PowerShell,可显式设 BOSS_SPAWN_BREAKAWAY=false 退回普通启动(届时 #43 会复现)。');
298
+ }
299
+ const pid = Number.parseInt(stdout.trim(), 10);
300
+ if (!Number.isFinite(pid) || pid <= 0) {
301
+ throw new Error(`WMI 已受理启动请求但未返回有效 PID(stdout: ${JSON.stringify(stdout)})。`);
302
+ }
303
+ return pid;
304
+ }
305
+ /**
306
+ * WMI 拉不起来时的显著告警。打完这条就退回普通 `spawn`。
307
+ *
308
+ * **这是 AGENTS.md「禁止回退逻辑 / 失败直接暴露」的一处有意例外,别顺手删掉。**
309
+ * 那条规则禁的是**静默**兜底;这里告警是刷屏级的,用户不可能看不见,不属于「掩盖根因」。
310
+ *
311
+ * 为什么必须退回(recruiting-copilot#43 验收反馈,2026-09-21):报告人那台 Windows 上
312
+ * `Invoke-CimMethod Win32_Process Create` 返回 `ReturnValue=2`(拒绝访问)——读操作正常,
313
+ * 单单「创建进程」被策略拒了。硬失败的结果是 `search` / `recommend` / `list` 全部不可用,
314
+ * 他只能设 `BOSS_SPAWN_BREAKAWAY=false` 兜住,而那恰好把 #43 原样装回去,还绕过了这条告警。
315
+ * 权衡很清楚:**退回后是「浏览器可能被连带杀掉」(可恢复,重扫码),硬失败是「CLI 完全不能用」**
316
+ * (不可恢复,除非用户自己找到那个环境变量)。前者更轻,且带告警时用户知道自己在什么状态。
317
+ *
318
+ * 退回**只覆盖「WMI 创建进程失败」这一步**。进程已创建但调试端口没起来,仍然硬失败——
319
+ * 那时候端口上可能已经有一只正在启动的 Chrome,再 spawn 一只会撞车。
320
+ */
321
+ export function warnBreakawayUnavailable(cause) {
322
+ const reason = cause instanceof Error ? cause.message : String(cause);
323
+ console.error([
324
+ '',
325
+ '='.repeat(72),
326
+ '⚠️ 浏览器无法脱离调用方的 Job Object —— 已退回普通启动方式',
327
+ '='.repeat(72),
328
+ `原因:${reason}`,
329
+ '',
330
+ '影响:从 AI Agent 宿主调用时,本条命令结束后 Chrome 可能被连带杀掉,',
331
+ ' profile 会留下 exit_type: Crashed,会话级登录态丢失、需要重新扫码。',
332
+ ' 高频重登本身就是平台风控信号(见 recruiting-copilot#43)。',
333
+ '',
334
+ '常见成因:本机策略拒绝了 WMI 创建进程(Invoke-CimMethod Win32_Process Create',
335
+ ' 返回 ReturnValue=2),或 PowerShell 被禁用。自查:',
336
+ ` powershell -NoProfile -Command "(Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='cmd.exe /c exit'}).ReturnValue"`,
337
+ ' 返回 0 表示可用;2 表示被拒。',
338
+ '',
339
+ '缓解:把 login / 业务动作 / shutdown 放进同一次调用,收尾用 boss shutdown 干净退出。',
340
+ ' 确认本机就是起不来、不想再看这条告警:设 BOSS_SPAWN_BREAKAWAY=false。',
341
+ '='.repeat(72),
342
+ '',
343
+ ].join('\n'));
344
+ }
345
+ /** 进程是否还活着(signal 0 只做存在性探测,不投递信号)。 */
346
+ function isProcessAlive(pid) {
347
+ try {
348
+ process.kill(pid, 0);
349
+ return true;
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ }
355
+ /**
356
+ * WMI 路径下没有子进程的 stdout/stderr 可读,改为轮询固定调试端口等待就绪。
357
+ * 端口本来就是固定的(见 `REMOTE_DEBUGGING_PORT`),不需要解析 Chrome 启动日志。
358
+ */
359
+ async function waitForRemoteDebuggingWsEndpoint(pid, userDataDir, timeoutMs) {
360
+ const deadline = Date.now() + timeoutMs;
361
+ while (Date.now() < deadline) {
362
+ const wsUrl = await probeRemoteDebuggingWsEndpoint(REMOTE_DEBUGGING_PORT, 800);
363
+ if (wsUrl)
364
+ return wsUrl;
365
+ if (!isProcessAlive(pid)) {
366
+ throw new Error(`浏览器进程在就绪前退出:user-data-dir「${userDataDir}」可能正被另一只「无远程调试端口」的 Chrome 持有(Chrome 单例锁会让新进程把命令行交还给它后立刻退出)。请关闭占用该目录的 Chrome 窗口后重试。`);
367
+ }
368
+ await new Promise((r) => setTimeout(r, 300));
369
+ }
370
+ throw new Error(`等待浏览器在端口 ${REMOTE_DEBUGGING_PORT} 就绪超时(${timeoutMs}ms,PID ${pid})。`);
371
+ }
372
+ /** 在未配置路径时,尝试常见安装位置(Chrome / Edge / Chromium)。 */
373
+ function findLocalChromiumExecutable() {
374
+ const candidates = [];
375
+ if (process.platform === 'win32') {
376
+ const local = process.env.LOCALAPPDATA;
377
+ const pf = process.env.PROGRAMFILES;
378
+ const pf86 = process.env['PROGRAMFILES(X86)'];
379
+ if (local) {
380
+ candidates.push(path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'));
381
+ }
382
+ if (pf) {
383
+ candidates.push(path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'));
384
+ candidates.push(path.join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
385
+ }
386
+ if (pf86) {
387
+ candidates.push(path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'));
388
+ candidates.push(path.join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
389
+ }
390
+ }
391
+ else if (process.platform === 'darwin') {
392
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium');
393
+ }
394
+ else {
395
+ candidates.push('/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge-stable', '/usr/bin/microsoft-edge');
396
+ }
397
+ for (const p of candidates) {
398
+ if (existsSync(p))
399
+ return p;
400
+ }
401
+ return undefined;
402
+ }
403
+ /** 减轻「正受到自动测试软件的控制」提示与常见自动化特征(非万能,站点仍可能用其它方式检测)。手动开 Chrome 并接 CDP 时可复用。 */
404
+ export const LAUNCH_ARGS_LESS_AUTOMATION = [
405
+ '--disable-infobars',
406
+ ];
407
+ /** 仅用于本地调试:尽量放宽同源/CORS 限制,便于跨域 iframe/canvas 处理。 */
408
+ export const LAUNCH_ARGS_ALLOW_ALL_CORS = [
409
+ '--disable-web-security',
410
+ '--allow-running-insecure-content',
411
+ ];
412
+ /**
413
+ * 启动本机浏览器(puppeteer-core 底层为 Chrome DevTools Protocol)。
414
+ *
415
+ * 环境变量(可选):
416
+ * - `CHROME_PATH` / `PUPPETEER_EXECUTABLE_PATH` — 启动本机浏览器可执行文件路径(高于自动探测)
417
+ * - `BOSS_BROWSER_USER_DATA_DIR` — 启动浏览器时复用的用户数据目录;未设置时默认 `~/.boss-cli/.cache/browser-data`
418
+ * - `BOSS_BROWSER_PROFILE_DIRECTORY` — 启动浏览器时指定 profile(如 `Default`)
419
+ * - `BOSS_BROWSER_REMOTE_DEBUGGING_PORT` — 远程调试端口(默认 53470);同一 user-data-dir 跨命令复用该端口
420
+ * - `BOSS_BROWSER_ALLOW_ALL_CORS` — 设为 `true` 时附加放宽同源/CORS 的启动参数(仅调试)
421
+ * - `BOSS_BROWSER_DISABLE_GPU` — 设为 `true` 时附加 `--disable-gpu`
422
+ *
423
+ * 若以上均未设置,会按系统尝试常见 Chrome / Edge / Chromium 安装路径。
424
+ * - `RECRUIT_BROWSER_HIDDEN` — 招聘工具链共读的隐藏开关;**默认有界面**,设为 `true` 才无头(有账号风险,见 `resolveHeadlessFromEnv`)。
425
+ * - `BOSS_BROWSER_HEADLESS` — 本 CLI 专属覆盖项,优先级高于 `RECRUIT_BROWSER_HIDDEN`。
426
+ * - `BOSS_BROWSER_VIEWPORT_WIDTH` / `BOSS_BROWSER_VIEWPORT_HEIGHT` — 启动时显式指定视口;未设置时不覆盖浏览器窗口尺寸
427
+ */
428
+ /** 启动浏览器时的默认视口(与环境变量一致);截图恢复时 `viewport()` 为 null 也可用其兜底。 */
429
+ export function defaultViewportFromEnv() {
430
+ const w = Number.parseInt(process.env.BOSS_BROWSER_VIEWPORT_WIDTH?.trim() ?? '', 10);
431
+ const h = Number.parseInt(process.env.BOSS_BROWSER_VIEWPORT_HEIGHT?.trim() ?? '', 10);
432
+ return {
433
+ width: Number.isFinite(w) && w > 0 ? w : 1280,
434
+ height: Number.isFinite(h) && h > 0 ? h : 1200,
435
+ };
436
+ }
437
+ /** 仅在显式配置了视口环境变量时返回启动视口;否则返回 null,不覆盖浏览器实际窗口尺寸。 */
438
+ function launchViewportFromEnv() {
439
+ const rawW = process.env.BOSS_BROWSER_VIEWPORT_WIDTH?.trim() ?? '';
440
+ const rawH = process.env.BOSS_BROWSER_VIEWPORT_HEIGHT?.trim() ?? '';
441
+ if (!rawW && !rawH) {
442
+ return null;
443
+ }
444
+ return defaultViewportFromEnv();
445
+ }
446
+ export async function connectBrowser(options = {}) {
447
+ const executablePath = options.executablePath?.trim() ||
448
+ process.env.CHROME_PATH?.trim() ||
449
+ process.env.PUPPETEER_EXECUTABLE_PATH?.trim() ||
450
+ findLocalChromiumExecutable();
451
+ const envUserData = process.env.BOSS_BROWSER_USER_DATA_DIR?.trim();
452
+ if (!envUserData) {
453
+ ensureAppDataLayout();
454
+ }
455
+ const userDataDir = options.userDataDir?.trim() || envUserData || BROWSER_USER_DATA_DIR;
456
+ const profileDirectory = options.profileDirectory?.trim() || process.env.BOSS_BROWSER_PROFILE_DIRECTORY?.trim();
457
+ if (!executablePath) {
458
+ throw new Error('未找到本机 Chrome/Edge:请设置 CHROME_PATH / PUPPETEER_EXECUTABLE_PATH(可执行文件路径)。');
459
+ }
460
+ const headless = options.headless ?? resolveHeadlessFromEnv();
461
+ const allowAllCors = options.allowAllCors ?? process.env.BOSS_BROWSER_ALLOW_ALL_CORS === 'true';
462
+ const disableGpu = process.env.BOSS_BROWSER_DISABLE_GPU === 'true';
463
+ clearSpawnedChromeProcessRef();
464
+ /**
465
+ * 优先直连固定调试端口上的已有实例:boss-cli 使用独立 user-data-dir,
466
+ * 端口稳定可期,命中即跨命令复用同一只浏览器(同一登录态、同一标签)。
467
+ */
468
+ const existingWsUrl = await probeRemoteDebuggingWsEndpoint(REMOTE_DEBUGGING_PORT, 800);
469
+ if (existingWsUrl) {
470
+ return await puppeteer.connect({
471
+ browserWSEndpoint: existingWsUrl,
472
+ defaultViewport: launchViewportFromEnv(),
473
+ });
474
+ }
475
+ // 默认保留 WebAssembly:`typeof WebAssembly === 'undefined'` 本身就是强自动化指纹。
476
+ // aegis_bg.wasm 已在 CDP `Fetch.enable` 层被阻断,不需要再禁用 WASM 引擎。
477
+ // 仅当显式设置 BOSS_BROWSER_DISABLE_WASM=true/1 时才追加 --noexpose_wasm。
478
+ const disableWasm = process.env.BOSS_BROWSER_DISABLE_WASM === 'true' || process.env.BOSS_BROWSER_DISABLE_WASM === '1';
479
+ const userArgs = [
480
+ ...LAUNCH_ARGS_LESS_AUTOMATION,
481
+ // 上一只若被外力杀掉(Job Object 连带、任务管理器),别弹「要恢复页面吗?Chrome 未正确关闭」
482
+ '--hide-crash-restore-bubble',
483
+ ...(headless ? LAUNCH_ARGS_HEADLESS_SCREEN : []),
484
+ ...(disableGpu ? ['--disable-gpu'] : []),
485
+ ...(disableWasm ? ['--js-flags=--noexpose_wasm'] : []),
486
+ ...(allowAllCors ? LAUNCH_ARGS_ALLOW_ALL_CORS : []),
487
+ ...(profileDirectory ? [`--profile-directory=${profileDirectory}`] : []),
488
+ ];
489
+ let chromeArgs = puppeteer
490
+ .defaultArgs({
491
+ browser: 'chrome',
492
+ userDataDir,
493
+ headless,
494
+ args: userArgs,
495
+ })
496
+ .filter((a) => a !== '--enable-automation' && a !== 'about:blank' && a !== 'data:,');
497
+ if (!chromeArgs.some((a) => a.startsWith('--remote-debugging-'))) {
498
+ chromeArgs.push(`--remote-debugging-port=${REMOTE_DEBUGGING_PORT}`);
499
+ }
500
+ /**
501
+ * Windows 默认经 WMI 启动,让浏览器脱离调用方的 Job Object(见 `shouldBreakawayFromJob`)。
502
+ * 这条路径没有子进程句柄,因此既不会被 Job 连带杀掉,也不会有 stdio 管道拖住 Node 退出。
503
+ */
504
+ let breakawayPid = null;
505
+ if (shouldBreakawayFromJob()) {
506
+ try {
507
+ breakawayPid = await spawnViaWmi(toWindowsCommandLine(executablePath, chromeArgs));
508
+ }
509
+ catch (e) {
510
+ // 只有「创建进程」这一步失败才退回 spawn;告警很吵,是故意的(见函数注释)。
511
+ warnBreakawayUnavailable(e);
512
+ }
513
+ }
514
+ if (breakawayPid !== null) {
515
+ // 注意这句在 try 外:进程已创建但调试端口没起来是真故障,不能再 spawn 一只去撞端口。
516
+ const wsUrl = await waitForRemoteDebuggingWsEndpoint(breakawayPid, userDataDir, LAUNCH_READY_MS);
517
+ return await puppeteer.connect({
518
+ browserWSEndpoint: wsUrl,
519
+ defaultViewport: launchViewportFromEnv(),
520
+ });
521
+ }
522
+ /**
523
+ * 不使用 `puppeteer.launch()`:其依赖的 `@puppeteer/browsers` 会在 **Node 进程 `exit` 时 kill 浏览器子进程**,
524
+ * 导致交互模式 / `npm run dev` 退出时窗口被一并关掉。改为自行 `spawn` + `connect`,退出时只断 CDP,浏览器可保留。
525
+ */
526
+ const proc = spawn(executablePath, chromeArgs, {
527
+ detached: true,
528
+ env: process.env,
529
+ stdio: ['ignore', 'pipe', 'pipe'],
530
+ });
531
+ spawnedChromeChild = proc;
532
+ let wsUrl;
533
+ try {
534
+ wsUrl = await waitForDevToolsWebSocketUrl(proc, userDataDir, LAUNCH_READY_MS);
535
+ }
536
+ catch (e) {
537
+ try {
538
+ proc.kill();
539
+ }
540
+ catch {
541
+ /* ignore */
542
+ }
543
+ clearSpawnedChromeProcessRef();
544
+ throw e;
545
+ }
546
+ /**
547
+ * `resume()` 排空管道(不读会把 Chrome 的 stderr 写满阻塞住),`unref()` 解掉管道对
548
+ * event loop 的引用——`proc.unref()` 只作用于子进程句柄,**解不掉 stdio 管道**,
549
+ * 少了这一步 Node 要等常驻 Chrome 退出才返回(#43 里「结果已打印却挂住」的直接原因)。
550
+ */
551
+ try {
552
+ for (const s of [proc.stdout, proc.stderr]) {
553
+ if (!s)
554
+ continue;
555
+ s.resume();
556
+ // 运行时是 net.Socket(有 unref),Readable 的类型签名里没有
557
+ s.unref?.();
558
+ }
559
+ }
560
+ catch {
561
+ /* ignore */
562
+ }
563
+ /** 单例移交时子进程已退出,无句柄可 unref;仅在本进程真正拉起 Chrome 时 unref,避免拖住 Node 退出。 */
564
+ if (proc.exitCode === null && proc.signalCode === null) {
565
+ try {
566
+ proc.unref();
567
+ }
568
+ catch {
569
+ /* ignore */
570
+ }
571
+ }
572
+ else {
573
+ clearSpawnedChromeProcessRef();
574
+ }
575
+ try {
576
+ return await puppeteer.connect({
577
+ browserWSEndpoint: wsUrl,
578
+ defaultViewport: launchViewportFromEnv(),
579
+ });
580
+ }
581
+ catch (e) {
582
+ try {
583
+ proc.kill();
584
+ }
585
+ catch {
586
+ /* ignore */
587
+ }
588
+ clearSpawnedChromeProcessRef();
589
+ throw e;
590
+ }
591
+ }
592
+ /**
593
+ * 是否禁止 CLI 把 Boss 窗口抢到前台。
594
+ *
595
+ * 默认允许:`page.bringToFront()` 走 `Target.activateTarget`,Windows 上会把**最小化**的窗口
596
+ * 还原并夺取前台焦点。把 CLI 接进后台系统定时跑的人(例如把 boss-cli 打通到内部招聘系统)
597
+ * 会被每条命令弹一次窗口打断办公,所以给一个显式关闭项。
598
+ */
599
+ export function resolveNoForegroundFromEnv() {
600
+ const v = process.env.BOSS_BROWSER_NO_FOREGROUND?.trim().toLowerCase();
601
+ return v === 'true' || v === '1' || v === 'yes' || v === 'y';
602
+ }
603
+ /**
604
+ * 判定这次要不要抢前台:环境变量关了 → 不抢;窗口已被人最小化 → 不抢(尊重人的选择);
605
+ * 其余情况照旧。抽出来是为了让判定逻辑不依赖真实浏览器就能测。
606
+ */
607
+ export function decideBringToFront(windowState, noForeground) {
608
+ if (noForeground)
609
+ return 'skipped-env';
610
+ if (windowState === 'minimized')
611
+ return 'skipped-minimized';
612
+ return 'raised';
613
+ }
614
+ /**
615
+ * `page.bringToFront()` 的替代:先问 `Browser.getWindowForTarget` 窗口状态,最小化就不动它。
616
+ * 单标签窗口下不抢前台对自动化没有影响;CDP 操作不需要窗口可见。
617
+ */
618
+ export async function bringToFrontUnlessMinimized(page) {
619
+ const noForeground = resolveNoForegroundFromEnv();
620
+ let windowState;
621
+ if (!noForeground) {
622
+ const cdp = await page.createCDPSession();
623
+ try {
624
+ ({ windowState } = (await cdp.send('Browser.getWindowForTarget')).bounds);
625
+ }
626
+ finally {
627
+ await cdp.detach();
628
+ }
629
+ }
630
+ const outcome = decideBringToFront(windowState, noForeground);
631
+ if (outcome === 'raised')
632
+ await page.bringToFront();
633
+ return outcome;
634
+ }
635
+ /**
636
+ * 需要真实渲染的操作(在线简历 canvas、截图)在**最小化**窗口里会永远等不到新帧
637
+ * (实测 `Page.captureScreenshot` 第二次起就挂死)。这里临时把窗口还原成 normal,
638
+ * 跑完立刻再最小化——短暂闪一下,但不把「最小化」这个人的选择永久推翻。
639
+ * 窗口本来没最小化时什么都不做。
640
+ */
641
+ export async function withWindowVisible(page, fn) {
642
+ const cdp = await page.createCDPSession();
643
+ let windowId;
644
+ let wasMinimized;
645
+ try {
646
+ const info = await cdp.send('Browser.getWindowForTarget');
647
+ windowId = info.windowId;
648
+ wasMinimized = info.bounds.windowState === 'minimized';
649
+ if (wasMinimized) {
650
+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
651
+ }
652
+ }
653
+ catch (e) {
654
+ await cdp.detach();
655
+ throw e;
656
+ }
657
+ try {
658
+ return await fn();
659
+ }
660
+ finally {
661
+ try {
662
+ if (wasMinimized) {
663
+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'minimized' } });
664
+ }
665
+ }
666
+ finally {
667
+ await cdp.detach();
668
+ }
669
+ }
670
+ }
671
+ /** 对某一页创建原生 CDP Session(需要低层域如 `Network.*`、`Fetch.*` 时使用)。 */
672
+ export async function createPageCDPSession(page) {
673
+ return page.createCDPSession();
674
+ }
675
675
  //# sourceMappingURL=cdp_browser.js.map