dsh-browser-playwright-codex 0.7.8 → 0.8.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.
@@ -0,0 +1,829 @@
1
+ /**
2
+ * 进场特效:把一个页面「抹白再用光标群重新画出来」。
3
+ *
4
+ * 这不是扩展。效果本体是 assets/swarm-rebuild.js,由 Playwright 直接注入当前
5
+ * 页面执行,所以它跑在插件自己那个窗口里 —— 也就是用户已经登录的那个浏览器。
6
+ *
7
+ * **授权状态(0.8.1 起已解决)**:那份脚本来自 Dhvanil Patel 的
8
+ * github.com/DhvanilPatel/swarm-cursors。2026-11-11 他邮件书面许可本项目
9
+ * **打包与修改**("you are welcome to bundle and modify it as needed"),
10
+ * 所以资产重新随 npm 包分发;许可没有附 MIT 许可文本,也只覆盖本项目,
11
+ * 不给第三方通用许可。底层的动效设计与编排来自 Joel Califa 的
12
+ * califa/swarm-site,他 2026-11-08 已书面许可、唯一条件是署名。
13
+ * 功能仍然**默认关闭**(config.swarm.enabled),打开才播。
14
+ *
15
+ * 三个约束决定了这里的实现方式:
16
+ *
17
+ * 1. 只在「新网站第一次打开」时播(config.swarm.oncePerSite)。每页都播会
18
+ * 拖慢真正的工作,也会让人烦。播放过的域名记在
19
+ * ~/.dsh/dsh-browser-swarm.json,删掉这个文件就全部重来。
20
+ * 2. 播完自己结束,不等用户操作:这里只负责等它,超时(config.swarm.maxMs,
21
+ * 0 = 完整播完)就主动叫停,避免长页面上卡着不动。
22
+ * 3. 被整屏遮罩盖住的页面(登录墙、密码框)默认不播:遮罩普遍是前端做的,
23
+ * 底下没解锁的内容也在 DOM 里,画出来等于替人泄露。开关是
24
+ * config.swarm.playOnVeiled / 设置卡上的「遮罩页面也播」。
25
+ *
26
+ * @module dsh-browser-playwright-codex/swarm-intro
27
+ */
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import * as runtimeState from "./runtime-state.js";
33
+ import { dropCurtain } from "./swarm-curtain.js";
34
+ /** 正在播的页面。用 WeakSet:页面关掉就自动放松,不需要清理。 */
35
+ const PLAYING = new WeakSet();
36
+ /** 每个页面这次播放的「AI 动手时怎么办」,stopSwarm 靠它决定要不要让路。 */
37
+ const PLAY_MODES = new WeakMap();
38
+ function scriptFile() {
39
+ // lib/*.js 运行时就在包根下的 assets/ 里;src/*.ts 走 tsx 时也是同一个相对位置。
40
+ return fileURLToPath(new URL('../assets/swarm-rebuild.js', import.meta.url));
41
+ }
42
+ function readScript() {
43
+ // 每次现读,不缓存。这个文件才 10 KB,而"热更新之后还在跑旧脚本"这件事
44
+ // 排查成本高得多(2026-11-08 就为了它多绕了两轮)。
45
+ return fs.readFileSync(scriptFile(), 'utf8');
46
+ }
47
+ /**
48
+ * 「这趟浏览器里播过哪些站」——默认只记在内存里。
49
+ *
50
+ * 用户 2026-11-09 的要求:把整个插件浏览器窗口关掉,下次再打开必须重新播,
51
+ * 不能像以前那样写进磁盘、关了也记着。所以记录跟着浏览器上下文走:
52
+ * 上下文一关(用户关窗 / 空闲回收 / 宿主重启),调用 forgetOnBrowserClose()
53
+ * 把内存清空即可,磁盘上什么都不留。
54
+ *
55
+ * 只有设置卡里的「一直记住、不重播」打开时,才把记录写盘(见
56
+ * runtimeState.getSwarmTuning().persistPlayed)。老版本留在磁盘上的那个文件
57
+ * 不再被读取,避免「关了窗却还记着」。
58
+ */
59
+ /**
60
+ * 每个浏览器上下文各记各的:非持久化模式下可以同时开多个 context(多个会话
61
+ * 窗口),关掉其中一个不能把别的窗口的记录一起清掉(2026-11-09 复核 find B)。
62
+ * WeakMap 的键就是那个 context;context 被回收时它那份记录自动消失。
63
+ */
64
+ const playedByContext = new WeakMap();
65
+ /** 没有 context 可归属时(测试、启动页)用的兜底记录,跟随「关窗」一并遗忘。 */
66
+ let playedFallback = new Set();
67
+ /** 当前活跃、可用于判断「还有没有别的窗口」的 context 集合。 */
68
+ const liveContexts = new Set();
69
+ /** 测试的临时文件覆盖:让测试不必碰真实用户状态,也保留旧测试语义。 */
70
+ function playedFile() {
71
+ const override = process.env.DSH_BROWSER_SWARM_FILE;
72
+ if (override !== undefined && override !== '')
73
+ return override;
74
+ return path.join(os.homedir(), '.dsh', 'dsh-browser-swarm.json');
75
+ }
76
+ /** 这次查询/记账落在哪个容器里;没有指定 context 时用兜底那份。 */
77
+ function playedSet(ctx) {
78
+ if (ctx === undefined)
79
+ return playedFallback;
80
+ let set = playedByContext.get(ctx);
81
+ if (set === undefined) {
82
+ set = new Set();
83
+ playedByContext.set(ctx, set);
84
+ liveContexts.add(ctx);
85
+ }
86
+ return set;
87
+ }
88
+ /** 把所有内存里的记录合起来(判断「这站播过没」用)。 */
89
+ function allPlayed() {
90
+ const all = new Set(playedFallback);
91
+ // WeakMap 不可遍历(且编译目标不含 .values):用 liveContexts 逐个取。
92
+ for (const ctx of liveContexts) {
93
+ const set = playedByContext.get(ctx);
94
+ if (set !== undefined)
95
+ for (const h of set)
96
+ all.add(h);
97
+ }
98
+ return all;
99
+ }
100
+ function readPlayed() {
101
+ if (runtimeState.getSwarmTuning().persistPlayed !== true) {
102
+ const out = {};
103
+ for (const h of allPlayed())
104
+ out[h] = 1;
105
+ return out;
106
+ }
107
+ try {
108
+ const parsed = JSON.parse(fs.readFileSync(playedFile(), 'utf8'));
109
+ const hosts = parsed?.hosts;
110
+ if (hosts === undefined || hosts === null || typeof hosts !== 'object')
111
+ return {};
112
+ const out = {};
113
+ for (const [host, at] of Object.entries(hosts)) {
114
+ if (typeof at === 'number')
115
+ out[host] = at;
116
+ }
117
+ // 内存里的这趟也要算上(用户可能中途才打开「一直记住」)。
118
+ for (const h of allPlayed())
119
+ if (!(h in out))
120
+ out[h] = 1;
121
+ return out;
122
+ }
123
+ catch {
124
+ return {};
125
+ }
126
+ }
127
+ /** 这个域名播过没有。跨所有活着的窗口判断。 */
128
+ export function hasPlayed(host) {
129
+ return typeof readPlayed()[host] === 'number';
130
+ }
131
+ /** 记下这个域名已经播过;ctx 传所属浏览器上下文,不传则用兜底。 */
132
+ export function markPlayed(host, ctx) {
133
+ playedSet(ctx).add(host);
134
+ if (runtimeState.getSwarmTuning().persistPlayed !== true)
135
+ return;
136
+ flushPlayedToDisk();
137
+ }
138
+ /** 把内存里这趟的记录写回磁盘(只在「一直记住」打开时用)。 */
139
+ function flushPlayedToDisk() {
140
+ try {
141
+ const hosts = readPlayed();
142
+ for (const h of allPlayed())
143
+ if (!(h in hosts))
144
+ hosts[h] = Date.now();
145
+ const target = playedFile();
146
+ fs.mkdirSync(path.dirname(target), { recursive: true });
147
+ const tmp = target + '.tmp';
148
+ fs.writeFileSync(tmp, JSON.stringify({ hosts }, null, 2));
149
+ fs.renameSync(tmp, target);
150
+ }
151
+ catch {
152
+ /* 写不进去就只在这趟里记着 */
153
+ }
154
+ }
155
+ /** 清空播放记录(设置卡上的「清空记录」用):内存和磁盘一起清。 */
156
+ export function resetPlayed() {
157
+ playedFallback = new Set();
158
+ for (const ctx of liveContexts)
159
+ playedByContext.set(ctx, new Set());
160
+ try {
161
+ fs.rmSync(playedFile(), { force: true });
162
+ }
163
+ catch {
164
+ /* 已经不存在 */
165
+ }
166
+ }
167
+ /**
168
+ * 「一直记住」开关切换时调用:打开就把这趟已有记录立刻落盘(别等关窗,
169
+ * 免得宿主崩溃丢记录);关掉就删掉磁盘记录,防止以后切回来时幽灵复活
170
+ * (2026-11-09 复核 find A)。这个文件是插件自己写的,删它不动用户数据。
171
+ */
172
+ export function onPersistPlayedChanged(enabled) {
173
+ if (enabled === true) {
174
+ flushPlayedToDisk();
175
+ return;
176
+ }
177
+ playedFallback = new Set();
178
+ for (const ctx of liveContexts)
179
+ playedByContext.set(ctx, new Set());
180
+ try {
181
+ fs.rmSync(playedFile(), { force: true });
182
+ }
183
+ catch {
184
+ /* 已经不存在 */
185
+ }
186
+ }
187
+ /**
188
+ * 某个浏览器上下文关掉时调用:只忘掉**它自己**那趟播过哪些站。
189
+ * 还有别的窗口活着时,别的窗口的记录一律保留(复核 find B)。
190
+ * persist 打开时把这趟记录落盘,下次接着记。
191
+ * 关单个标签不会走到这里 —— 那是 closeTab,不是 context 关闭。
192
+ */
193
+ export function forgetOnBrowserClose(ctx) {
194
+ if (runtimeState.getSwarmTuning().persistPlayed === true) {
195
+ flushPlayedToDisk();
196
+ return;
197
+ }
198
+ if (ctx === undefined) {
199
+ // 没带窗口标识 = 整个关掉:所有窗口的记录一起忘,下次全部重播。
200
+ for (const live of liveContexts)
201
+ playedByContext.set(live, new Set());
202
+ playedFallback = new Set();
203
+ return;
204
+ }
205
+ playedByContext.delete(ctx);
206
+ liveContexts.delete(ctx);
207
+ // 还有别的窗口活着就只忘这一个窗口的;全关了才连兜底那份一起忘。
208
+ if (liveContexts.size > 0)
209
+ return;
210
+ playedFallback = new Set();
211
+ }
212
+ /** 这个页面正在播吗。 */
213
+ export function isPlaying(page) {
214
+ return PLAYING.has(page);
215
+ }
216
+ /**
217
+ * 关掉正在播的特效。任何工具动作开始前都调用它 —— 覆盖层本身不接管鼠标
218
+ * (pointer-events: none),所以拦不住点击;但 AI 动手时它该让开,画面才
219
+ * 不会被人当成页面坏了。
220
+ *
221
+ * 失败一律吞掉:页面可能已经关掉、正在跳转,或者根本没播过。
222
+ */
223
+ export async function stopSwarm(page) {
224
+ if (!PLAYING.has(page))
225
+ return;
226
+ // 「动画继续跑」模式:这一层已经不接管鼠标了,AI 动手不需要动它,让它自己播完。
227
+ // 但要把"AI 已经在用这个页面"记进页面里:整页模式的滚动循环看到这个标记就停滚,
228
+ // 否则 AI 干活时页面还在脚下一屏屏跳(2026-11-09 复核提的语义问题)。
229
+ if (PLAY_MODES.get(page) === 'keep-alive') {
230
+ try {
231
+ await page.evaluate(() => {
232
+ ;
233
+ window.__dshSwarmAiWorking = true;
234
+ });
235
+ }
236
+ catch {
237
+ /* 页面已经走了 */
238
+ }
239
+ return;
240
+ }
241
+ PLAYING.delete(page);
242
+ // 先把页面滚回播放前的位置,再停:停完 app 就没了,那时再想找"真正在滚的那个
243
+ // 容器"已经找不到(局部滚动页面里 window.scrollY 永远是 0,2026-11-09 复核实测)。
244
+ await restoreScroll(page);
245
+ try {
246
+ await page.evaluate(() => {
247
+ const w = window;
248
+ if (typeof w.__dshSwarmStop === 'function')
249
+ w.__dshSwarmStop();
250
+ else if (typeof w.__dshSwarm?.stop === 'function')
251
+ w.__dshSwarm.stop();
252
+ // 让路必须立刻生效:资产退出时先恢复页面、再淡出 1.2 秒才移除宿主。那 1.2 秒里
253
+ // 覆盖层还在,后续动作的命中测试会判成"被遮挡"(实测点击直接失败)。这里把它
254
+ // 直接摘掉——页面在 destroy 开头就已经恢复可见了,摘掉不影响观感。
255
+ for (const host of document.querySelectorAll('swarm-rebuild'))
256
+ host.remove();
257
+ });
258
+ }
259
+ catch {
260
+ /* 页面没了,或者已经开始跳转:没有东西需要关 */
261
+ }
262
+ }
263
+ /**
264
+ * 把页面滚回播放前的位置。
265
+ *
266
+ * 关键:**不能只认 `window.scrollY`**。后台、小红书这类页面的滚动发生在某个
267
+ * `overflow:auto` 的容器里,这时 window 一直是 0,只有引擎的 env 知道那个容器滚到
268
+ * 哪儿了。位置在播放刚开始时就被记进 `window.__dshSwarmStart`(那时扫描还没滚),
269
+ * 还原时优先用 env,拿不到才退回 window(2026-11-09 复核发现原实现只还原 window)。
270
+ */
271
+ async function restoreScroll(page) {
272
+ try {
273
+ await page.evaluate(() => {
274
+ const w = window;
275
+ const start = w.__dshSwarmStart;
276
+ if (start === undefined)
277
+ return;
278
+ const env = w.__swarmRebuild?.app?.env;
279
+ if (env !== undefined && typeof env.scrollTo === 'function')
280
+ env.scrollTo(start.y, start.x);
281
+ else
282
+ window.scrollTo(start.x, start.y);
283
+ // 还原完就把这份位置清掉:别让它一直挂在页面上被页面脚本读到。
284
+ delete w.__dshSwarmStart;
285
+ });
286
+ }
287
+ catch {
288
+ /* 页面已经走了,没有东西要还原 */
289
+ }
290
+ }
291
+ /**
292
+ * 藏掉原版脚本自带的控制条,并把速度调到最快、留下"立刻退出"的入口。
293
+ *
294
+ * 为什么现在是一个普通函数而不是拼字符串:以前用 `new Function(FIND_ROOT)` 在页面里
295
+ * 造函数,页面一旦禁用 eval(CSP 里没有 'unsafe-eval')或者注入后立刻导航,那一句就
296
+ * 直接抛错,整段"藏控制条"一次都做不到 —— 控制条就留在屏幕上。改成普通函数后,
297
+ * Playwright 直接把函数源码序列化进页面,不需要 eval(2026-11-09 改)。
298
+ *
299
+ * 返回 `pending` = 还没拿到影子树,调用方继续重试;`blocked` = 这个站的 CSP 把影子树里
300
+ * 的样式表一起挡了,特效本身是坏的,调用方应当撤掉覆盖层且不记账。
301
+ */
302
+ const HIDE_CONTROLS = ({ clicks, keepAlive, pageMode, }) => {
303
+ const w = window;
304
+ /** 找到效果挂上去的 shadow root(原版脚本用 mode: 'open' 建的那个)。 */
305
+ const findRoot = () => {
306
+ const a = w.__swarmRebuild && w.__swarmRebuild.app;
307
+ if (a && a.view && a.view.root)
308
+ return a.view.root;
309
+ if (w.__swarmRebuild && w.__swarmRebuild.root)
310
+ return w.__swarmRebuild.root;
311
+ for (const el of Array.from(document.body.children)) {
312
+ if (el.shadowRoot && el.shadowRoot.querySelector('.hud'))
313
+ return el.shadowRoot;
314
+ }
315
+ // 兜底:宿主不一定是 body 的直接子节点,整页扫一遍(只在没拿到 app 时走)。
316
+ for (const el of Array.from(document.querySelectorAll('*'))) {
317
+ const sr = el.shadowRoot;
318
+ if (sr && sr.querySelector('.hud'))
319
+ return sr;
320
+ }
321
+ return null;
322
+ };
323
+ const root = findRoot();
324
+ if (root === null)
325
+ return { status: 'pending' };
326
+ // 严格 CSP(`style-src 'self'`)的站点会连影子树里的 `<style>` 一起挡掉 —— 包括
327
+ // 原版脚本自己的样式表。这时覆盖层没有样式、控制条退化成页面上一行普通文字,
328
+ // 特效本身就是坏的,藏不藏控制条都没意义。判据:影子树里一张样式表都没有。
329
+ // 2026-11-09 复核实测:普通页 styleSheets.length = 1;`style-src 'self'` 页 = 0,
330
+ // 而且 .hud 的计算 display 回到默认值 block、高度 21px。
331
+ if (root.styleSheets.length === 0)
332
+ return { status: 'blocked' };
333
+ // 藏控制条:**用 CSSOM 写内联样式**,而不是只靠 <style> 规则。
334
+ // 随包那份脚本的控制条已经被补丁删掉了(scripts/swarm-patch.mjs),所以这段现在
335
+ // 是兜底:万一有人把未打补丁的原版塞回来,控制条照样不会露面。
336
+ // 写法上保留两层 —— ① 脚本写内联样式不受 `style-src` 限制;② <style> 规则能压住
337
+ // 以后重建的节点(2026-11-09 实测:另造的 .hud 也是 display:none)。
338
+ const hideNow = () => {
339
+ const el = root.querySelector('.hud');
340
+ if (el !== null && el.style.display !== 'none')
341
+ el.style.setProperty('display', 'none', 'important');
342
+ };
343
+ hideNow();
344
+ // <style> 规则留作双保险(CSP 允许时生效,重建的节点也一并压住)。
345
+ // 2026-11-09 实测:插规则后另造一个 .hud,同样 display:none,动画照常推进。
346
+ if (root.querySelector('style[data-dsh-swarm-hide]') === null) {
347
+ const st = document.createElement('style');
348
+ st.setAttribute('data-dsh-swarm-hide', '1');
349
+ st.textContent = '.hud{display:none!important}';
350
+ root.appendChild(st);
351
+ }
352
+ // 兜底:脚本以后重建控制条时,观察者把新节点再压一次。**只在真的有控制条时才挂**:
353
+ // 随包那份已经把控制条删了,而动画每帧都会大改影子树,观察者回调会把主线程吃满、
354
+ // 引擎看着"没进展"(2026-11-09 实测:动画停在 32 个图层)。没控制条就别挂。
355
+ const hadHud = root.querySelector('.hud') !== null;
356
+ const watched = root;
357
+ if (hadHud && watched.__dshHudWatched !== true) {
358
+ watched.__dshHudWatched = true;
359
+ new MutationObserver(() => hideNow()).observe(root, { childList: true, subtree: true });
360
+ }
361
+ // 「动画继续跑」模式:必须对宿主元素下手。宿主自带内联的
362
+ // pointer-events:auto !important,影子树里再写规则也压不过它 —— 同一元素上
363
+ // 后写的 !important 才能覆盖(2026-11-08 实测:改之前点击 4 秒超时)。
364
+ if (keepAlive)
365
+ root.host.style.setProperty('pointer-events', 'none', 'important');
366
+ // 播放速度:随包那份脚本的控制条已被删掉(见 scripts/swarm-patch.mjs),所以直接改
367
+ // 引擎的速度值 —— 原版 speeds = [1, 2, 3],按钮点 N 次本来就等于 1+N 倍。
368
+ // 兜底保留点按钮:万一有人把没打补丁的原版塞回来,这条路仍然可用。
369
+ const wanted = Math.min(3, Math.max(1, 1 + clicks));
370
+ const engine = w.__swarmRebuild?.app?.engine;
371
+ if (engine !== undefined) {
372
+ engine.speed = wanted;
373
+ }
374
+ else {
375
+ const speedBtn = root.querySelector('[data-a="speed"]');
376
+ for (let i = 0; i < clicks; i++)
377
+ speedBtn?.click();
378
+ }
379
+ // 播放范围:整页模式交给**原版自己的 Tour 机制**,不再由插件外层硬滚。
380
+ //
381
+ // 原版控制条上的 Tour 按钮(`data-a="tour"`)点击后只做两件事:设 `this.tour=true`
382
+ // 和 `engine.start()`,之后每帧 `tourStep()` 会按引擎自己的完成度慢慢往下滚
383
+ // (backlog 小就滚、大就停),滚动和光标是同一个引擎驱动,从顶部连续往下。
384
+ // 之前我自己写的"每 1.4 秒 env.scrollBy 一屏"和资产的扫描/frontier 打架,
385
+ // 会把起点推到页面中段、光标反复飞(2026-11-09 用户实测)。这里直接打开原生开关。
386
+ // 控制条已被补丁删掉,所以设字段而不是点按钮;`engine.start()` 只在真的没开始时调。
387
+ if (pageMode) {
388
+ const app = w.__swarmRebuild?.app;
389
+ if (app !== undefined) {
390
+ app.tour = true;
391
+ const eng = app.engine;
392
+ if (eng !== undefined && typeof eng.start === 'function' && eng.started !== true)
393
+ eng.start();
394
+ }
395
+ }
396
+ // 退出:补丁后的脚本没有 ✕ 按钮了,直接调它自己的 `stop(false)`——那条路是
397
+ // destroy(false),立即取消 RAF 并移除宿主,比点按钮更干净。按钮那条留作兜底,
398
+ // 兼容没打补丁的原版。
399
+ w.__dshSwarmStop = () => {
400
+ const api = w.__swarmRebuild;
401
+ if (api !== undefined && typeof api.stop === 'function') {
402
+ api.stop(false);
403
+ return;
404
+ }
405
+ const again = findRoot();
406
+ const button = again?.querySelector('[data-a="live"]') ?? again?.querySelector('[data-a="close"]');
407
+ button?.click();
408
+ };
409
+ return { status: 'ok' };
410
+ };
411
+ /** 注入并播放,播完自己退出。 */
412
+ async function playSwarm(page, options) {
413
+ if (PLAYING.has(page))
414
+ return;
415
+ let code;
416
+ try {
417
+ code = readScript();
418
+ }
419
+ catch {
420
+ return; // 资源缺失就当没这个功能,不能影响导航
421
+ }
422
+ PLAYING.add(page);
423
+ PLAY_MODES.set(page, options.keepAlive === true ? 'keep-alive' : 'auto-close');
424
+ try {
425
+ // 记下播放前的位置:自动滚动会把页面带到下方,播完要送回来。
426
+ // 先记一份 window 的位置兜底:注入前还拿不到引擎的 env。内滚容器(overflow:auto)
427
+ // 的位置会在注入后由 HIDE_CONTROLS 用 env.geom() 覆盖掉。
428
+ await page
429
+ .evaluate(() => {
430
+ ;
431
+ window.__dshSwarmStart = {
432
+ x: window.scrollX,
433
+ y: window.scrollY,
434
+ };
435
+ })
436
+ .catch(() => { });
437
+ // 「同一个引擎」:导航前打了标记时,原版引擎已在第一帧由 addInitScript 启动。
438
+ // 这时**绝不能再注入一遍** —— 重新执行会新建 App、白底和 18 个光标全部重来,
439
+ // 等于又换了一次人,正是用户反复指出的断层。只有引擎确实没在才回退到旧的注入。
440
+ const early = await page
441
+ .evaluate(() => {
442
+ const w = window;
443
+ return w.__dshSwarmEarly === true && w.__swarmRebuild?.app?.view !== undefined;
444
+ })
445
+ .catch(() => false);
446
+ if (early !== true)
447
+ await page.evaluate(code);
448
+ // 注入刚返回时 App 已经建好(含 env),而它的扫描(会滚一遍页面催懒加载)还没开始
449
+ // —— 这是唯一能拿准"真正滚动位置"的时机。局部滚动页面里 window.scrollY 恒为 0,
450
+ // 只有 env.geom() 知道那个 overflow:auto 的容器滚到哪儿了(2026-11-09 实测验过:
451
+ // 放到重试循环里记,会记到扫描滚到半路的位置,回滚就停在中途)。
452
+ await page
453
+ .evaluate(() => {
454
+ const w = window;
455
+ const g = w.__swarmRebuild?.app?.env?.geom?.();
456
+ if (g !== undefined)
457
+ w.__dshSwarmStart = { x: g.sx, y: g.sy };
458
+ })
459
+ .catch(() => { });
460
+ // 记账挪到"界面也藏好了"之后再写:注入成功但藏控制条十二次全失败时,
461
+ // 名额不该被占掉(否则这个站以后再也不会重试)。
462
+ // 原版脚本自带一条底部控制条(速度 / Tour / Go live / ✕)。自动播放时它
463
+ // 只是噪音:藏掉,把速度调到最快、打开自动滚动,并留下一个"立刻退出"的
464
+ // 入口给 stopSwarm 用。
465
+ //
466
+ // 这一步必须重试:脚本是异步建界面的,注入刚返回时影子节点可能还没挂上,
467
+ // 一次没找到就永远留着那条控制条(2026-11-08 实测到过)。
468
+ // 这一步必须重试:脚本是异步建界面的,注入刚返回时影子节点可能还没挂上
469
+ // (2026-11-08 实测到过)。窗口给到 25 × 160ms ≈ 4 秒:慢页面也要藏得到。
470
+ let hidden = false;
471
+ let blocked = false;
472
+ for (let attempt = 0; attempt < 25; attempt++) {
473
+ // 页面关了、或者这一页已经被别处收掉,就别再空转剩下的重试。
474
+ if (page.isClosed() || !PLAYING.has(page))
475
+ return;
476
+ let status = 'pending';
477
+ try {
478
+ const result = await page.evaluate(HIDE_CONTROLS, {
479
+ clicks: options.speedClicks,
480
+ keepAlive: options.keepAlive === true,
481
+ pageMode: options.range === 'page',
482
+ });
483
+ status = result.status;
484
+ }
485
+ catch {
486
+ // 重试期间页面导航或关闭:这一轮跳过,不能让整段流程断在这里。
487
+ status = 'pending';
488
+ }
489
+ if (status === 'ok') {
490
+ hidden = true;
491
+ break;
492
+ }
493
+ if (status === 'blocked') {
494
+ blocked = true;
495
+ break;
496
+ }
497
+ await new Promise((resolve) => setTimeout(resolve, 160));
498
+ }
499
+ if (blocked) {
500
+ // 这个站把影子树里的样式表挡了:覆盖层没有样式、控制条裸在外面,是半成品。
501
+ // 撤掉它,并且**不记账** —— 站点的 CSP 以后可能变,留着以后重试的机会。
502
+ // 先还原滚动:扫描阶段照样会滚一遍页面,不还原就把人扔在半路。
503
+ await dropCurtain(page);
504
+ await restoreScroll(page);
505
+ await page
506
+ .evaluate(() => {
507
+ const host = document.querySelector('swarm-rebuild');
508
+ if (host !== null)
509
+ host.remove();
510
+ })
511
+ .catch(() => { });
512
+ return;
513
+ }
514
+ // 藏好控制条后**不要撤任何东西**:现在画面就是原版引擎自己(自造白幕已删),
515
+ // 它要继续往下画。旧代码在这里撤白幕,是两套引擎时代留下的动作。
516
+ // 只有真的藏到了才算"播过"。藏不到还占名额,等于把"藏不住"升级成"再也不重试",
517
+ // 正是 2026-11-09 复核抓到的坑。带上所属 context,关窗时只忘这一趟的。
518
+ // 测试的假页面没有 context(),读不到就落回兜底那份,不能让记账把播放带崩。
519
+ if (hidden && options.markHost !== undefined && options.markHost !== '') {
520
+ let ctx;
521
+ try {
522
+ ctx = page.context();
523
+ }
524
+ catch {
525
+ ctx = undefined;
526
+ }
527
+ markPlayed(options.markHost, ctx);
528
+ }
529
+ // 短页面(没有可滚动距离)时,脚本里的 gate 永远等不到 frontier 推进 —— 下半页任务
530
+ // 就永久不可用,引擎永远到不了 complete,只能靠看门狗硬收尾。这里一次性把 frontier
531
+ // 拉满:那些任务在视口外画完(看不见),但状态是"完成",收尾干净。
532
+ await page
533
+ .evaluate(() => {
534
+ const app = window.__swarmRebuild?.app;
535
+ if (app === undefined)
536
+ return;
537
+ const g = app.env?.geom?.();
538
+ const height = app.env?.scrollHeight?.();
539
+ if (g === undefined || height === undefined)
540
+ return;
541
+ if (height <= g.vh + 8) {
542
+ const engine = app.engine;
543
+ if (engine !== undefined)
544
+ engine.frontier = height + 1000;
545
+ }
546
+ })
547
+ .catch(() => { });
548
+ const deadline = Date.now() + options.maxMs;
549
+ let reachedEnd = false;
550
+ // 进度看门狗:连续 2.5 秒画不出新图层就当画完了。
551
+ // 整页模式的滚动完全由原版 Tour 机制在页内驱动(HIDE_CONTROLS 里设 app.tour),
552
+ // 插件外层只负责等它播完、回滚、收尾,不再自己滚 —— 之前外层硬滚会打架,
553
+ // 把起点推到页面中段(2026-11-09 用户实测"从中间开始、上下重复")。
554
+ let lastDone = -1;
555
+ let stalledMs = 0;
556
+ while (Date.now() < deadline) {
557
+ if (page.isClosed())
558
+ return;
559
+ const state = await page.evaluate(() => {
560
+ const app = window.__swarmRebuild?.app;
561
+ if (app === undefined)
562
+ return { kind: 'gone', done: -1, started: false };
563
+ const engine = app.engine;
564
+ return {
565
+ kind: app.complete === true ? 'complete' : 'playing',
566
+ done: engine?.doneCount ?? -1,
567
+ // 还没开始画(等放行 / 扫描中):不算停滞,否则看门狗会在开场就收掉。
568
+ started: engine?.started === true,
569
+ };
570
+ });
571
+ if (state.kind === 'gone') {
572
+ // 外部把动画停了(AI 动手、用户点了退出):先还原滚动再走,别把人扔在半路。
573
+ await restoreScroll(page);
574
+ return;
575
+ }
576
+ if (state.kind === 'complete') {
577
+ reachedEnd = true;
578
+ break;
579
+ }
580
+ if (state.started !== true) {
581
+ // 开场阶段(等放行 / 扫描中):不算停滞。
582
+ lastDone = state.done;
583
+ stalledMs = 0;
584
+ await new Promise((resolve) => setTimeout(resolve, 350));
585
+ continue;
586
+ }
587
+ if (state.done === lastDone) {
588
+ stalledMs += 350;
589
+ if (stalledMs >= 2500) {
590
+ reachedEnd = true;
591
+ break;
592
+ }
593
+ }
594
+ else {
595
+ lastDone = state.done;
596
+ stalledMs = 0;
597
+ }
598
+ await new Promise((resolve) => setTimeout(resolve, 350));
599
+ }
600
+ // 播完了,停一下让人看清最后成形的样子,然后退回真实页面;
601
+ // 超时没播完也照样退,别让页面一直动下去。
602
+ if (reachedEnd)
603
+ await new Promise((resolve) => setTimeout(resolve, 1200));
604
+ // 还原滚动要赶在 stop **之前**:停完 app 就没了,那时再想找"真正在滚的那个容器"
605
+ // 已经找不到(局部滚动页面里 window.scrollY 恒为 0,2026-11-09 复核实测)。
606
+ // 覆盖层是 fixed 且每帧跟随滚动,所以"边滚边播"不会打架,不需要等宿主消失。
607
+ await restoreScroll(page);
608
+ PLAYING.delete(page);
609
+ try {
610
+ await page.evaluate(() => {
611
+ const stop = window.__dshSwarmStop;
612
+ if (typeof stop === 'function')
613
+ stop();
614
+ });
615
+ }
616
+ catch {
617
+ /* 页面已经走了 */
618
+ }
619
+ }
620
+ catch {
621
+ // 注入失败(页面受保护、正在跳转、已经被关掉)都不是错误:静默跳过。
622
+ }
623
+ finally {
624
+ PLAYING.delete(page);
625
+ }
626
+ }
627
+ /** 把设置里的数夹到合理范围,坏值不会把页面卡死。 */
628
+ function clamp(value, min, max, fallback) {
629
+ const num = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
630
+ return Math.min(max, Math.max(min, num));
631
+ }
632
+ /**
633
+ * 页面是不是被一层全屏遮罩盖着(登录墙、密码框、年龄确认这类)。
634
+ *
635
+ * 这类遮罩普遍是**前端**做的:真正的内容早就写在 DOM 里,只是被一块不透明的
636
+ * 层盖住。特效会跳过透明度和 display 检查,却看不见"被谁盖住",于是会把底下
637
+ * 的内容画出来 —— 等于绕过遮罩放了一遍。实测 2026-11-08:WorkBuddy 面板没输
638
+ * 密码时,页面树里已经能读到全部账号与模型清单。
639
+ *
640
+ * 所以只要检测到这样的遮罩就不播:宁可少一次观感,不替人泄露他没解锁的东西。
641
+ */
642
+ async function hasBlockingVeil(page) {
643
+ try {
644
+ return await page.evaluate(() => {
645
+ const vw = window.innerWidth;
646
+ const vh = window.innerHeight;
647
+ const nodes = document.body ? document.body.querySelectorAll('div,section,main,aside,article') : [];
648
+ for (const el of nodes) {
649
+ const cs = getComputedStyle(el);
650
+ if (cs.position !== 'fixed' && cs.position !== 'absolute')
651
+ continue;
652
+ const z = Number.parseInt(cs.zIndex, 10);
653
+ if (!(z >= 100))
654
+ continue;
655
+ const r = el.getBoundingClientRect();
656
+ if (r.width * r.height < vw * vh * 0.9)
657
+ continue;
658
+ const bg = cs.backgroundColor;
659
+ const m = /^rgba?\(([^)]+)\)$/.exec(bg);
660
+ let alpha = 1;
661
+ if (m !== null) {
662
+ const parts = (m[1] ?? '').split(',').map((s) => Number.parseFloat(s));
663
+ if (parts.length === 4)
664
+ alpha = parts[3] ?? 1;
665
+ }
666
+ if (alpha > 0.35)
667
+ return true; // 接近不透明的整屏盖板
668
+ // 半透明登录墙(最常见的写法)常常 alpha 只有 0.3–0.5:只要这块盖板里
669
+ // 有密码框或表单,就按遮罩处理,别把底下的内容画出来。
670
+ if (alpha > 0.05 && el.querySelector('input[type="password"], form') !== null)
671
+ return true;
672
+ if (cs.backdropFilter !== 'none' && cs.backdropFilter !== '')
673
+ return true;
674
+ }
675
+ return false;
676
+ });
677
+ }
678
+ catch {
679
+ // 读不出来就不播:安全默认值。
680
+ return true;
681
+ }
682
+ }
683
+ /**
684
+ * 启动页。原来浏览器刚打开时停在 about:blank —— 一张纯白页,除了占位没有
685
+ * 任何用处,用户看见只会问「这是干嘛的」。这里换成一张说明卡:告诉人它已经
686
+ * 就绪、地址栏为什么是 about:blank、以及怎么用。没有外部资源,纯内联样式,
687
+ * 断网也是秒开。
688
+ */
689
+ export const WELCOME_HTML = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
690
+ <title>浏览器已就绪</title>
691
+ <style>
692
+ :root { color-scheme: light; }
693
+ * { box-sizing: border-box; }
694
+ body {
695
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
696
+ background: #f5f6f8; color: #1d1d1f;
697
+ font: 15px/1.7 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
698
+ }
699
+ .card {
700
+ width: min(560px, 86vw); padding: 38px 40px 34px; background: #fff; border-radius: 18px;
701
+ box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 12px 40px rgba(0,0,0,.08);
702
+ }
703
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: #34c759; display: inline-block; margin-right: 9px; vertical-align: middle; }
704
+ h1 { font-size: 23px; margin: 0 0 6px; letter-spacing: -.01em; }
705
+ .sub { color: #6b6b70; margin: 0 0 26px; }
706
+ .row { display: flex; gap: 12px; padding: 13px 0; border-top: 1px solid #ececf0; }
707
+ .k { color: #8a8a8f; min-width: 96px; font-size: 13px; }
708
+ .v { flex: 1; font-size: 14px; }
709
+ code { font-family: ui-monospace, Consolas, monospace; background: #f2f2f5; padding: 1px 6px; border-radius: 5px; font-size: 13px; }
710
+ .foot { margin: 24px 0 0; color: #9a9aa0; font-size: 12.5px; }
711
+ </style></head><body>
712
+ <div class="card">
713
+ <h1><span class="dot"></span>浏览器已就绪</h1>
714
+ <p class="sub">想让我打开哪个网站,说一句就行。</p>
715
+ <div class="row"><div class="k">地址栏</div><div class="v"><code>about:blank</code> —— 浏览器内置的空白页,表示还没去任何网站</div></div>
716
+ <div class="row"><div class="k">这一页</div><div class="v">是光标一笔一笔画出来的,按 <code>Esc</code> 可以立即跳过</div></div>
717
+ <div class="row"><div class="k">登录状态</div><div class="v">保存在这个浏览器里,关掉窗口也不会丢</div></div>
718
+ <p class="foot">第一次打开某个网站时,那页也会这样自己长出来一次。</p>
719
+ </div></body></html>`;
720
+ /**
721
+ * 把空白页换成启动卡片,并在上面放一次特效。特效不等它播完(启动不能被
722
+ * 拖住 9 秒),放出去就返回;真有工具动作要执行时 stopSwarm 会先把它关掉。
723
+ */
724
+ export async function showWelcome(page, config) {
725
+ try {
726
+ await page.setContent(WELCOME_HTML, { waitUntil: 'domcontentloaded' });
727
+ }
728
+ catch {
729
+ return; // 页面还没准备好:留一张空白页也不影响任何事
730
+ }
731
+ const userChoice = runtimeState.getSwarm();
732
+ const enabled = userChoice ?? config?.swarm?.enabled ?? true;
733
+ if (!enabled)
734
+ return;
735
+ const tuning = runtimeState.getSwarmTuning();
736
+ const speed = clamp(tuning.speed ?? config?.swarm?.speed, 1, 3, 3);
737
+ const rawMax = tuning.maxMs ?? config?.swarm?.maxMs;
738
+ void playSwarm(page, {
739
+ maxMs: rawMax === 0 ? 120000 : clamp(rawMax, 3000, 30000, 15000),
740
+ speedClicks: speed - 1,
741
+ });
742
+ }
743
+ /**
744
+ * 这次导航要不要播(只看不费时的条件,用来决定"开场白幕"要不要提前挂)。
745
+ *
746
+ * 注意传的是**目标地址**:导航前的 `page.url()` 还是旧页面,拿它判断会永远不播。
747
+ * 遮罩页那种需要读页面的判断留到注入之后做,那时会顺手把白幕撤掉。
748
+ */
749
+ export function willPlaySwarm(targetUrl, config, headless) {
750
+ try {
751
+ if (headless === true)
752
+ return false;
753
+ if (!/^https?:/i.test(targetUrl))
754
+ return false;
755
+ const host = new URL(targetUrl).hostname;
756
+ if (host === '')
757
+ return false;
758
+ const userChoice = runtimeState.getSwarm();
759
+ const enabled = userChoice ?? config?.swarm?.enabled ?? true;
760
+ if (!enabled)
761
+ return false;
762
+ const tuning = runtimeState.getSwarmTuning();
763
+ const oncePerSite = tuning.oncePerSite ?? config?.swarm?.oncePerSite ?? true;
764
+ if (oncePerSite && hasPlayed(host))
765
+ return false;
766
+ return true;
767
+ }
768
+ catch {
769
+ return false;
770
+ }
771
+ }
772
+ /** 导航之后调用:够条件就播一次,不等它播完(导航照常立刻返回快照)。 */
773
+ export function maybePlaySwarm(page, config, headless) {
774
+ void (async () => {
775
+ try {
776
+ // 无头浏览器没人看着:不播。测试和无头任务都跑在这里,播了只会挡住点击。
777
+ // 也不去碰过场:无头下 `shouldShowPrelude` 本来就返回 false,从没盖过。
778
+ if (headless === true)
779
+ return;
780
+ const url = page.url();
781
+ if (!/^https?:/i.test(url))
782
+ return; // about:blank、chrome:// 这类页面没有意义
783
+ const host = new URL(url).hostname;
784
+ if (host === '')
785
+ return;
786
+ // 用户的开关优先;没表态就用配置里的默认值(出厂是开)。
787
+ const userChoice = runtimeState.getSwarm();
788
+ const enabled = userChoice ?? config?.swarm?.enabled ?? true;
789
+ if (!enabled) {
790
+ // 关着也要把可能已提前启动的引擎撤掉:标记可能是上一次导航残留的,
791
+ // 不清就会白屏卡住(2026-11-09 复核 find B)。
792
+ await dropCurtain(page);
793
+ return;
794
+ }
795
+ // 设置卡上的三行参数优先,没有就落到配置默认值。
796
+ const tuning = runtimeState.getSwarmTuning();
797
+ const oncePerSite = tuning.oncePerSite ?? config?.swarm?.oncePerSite ?? true;
798
+ if (oncePerSite && hasPlayed(host)) {
799
+ await dropCurtain(page);
800
+ return;
801
+ }
802
+ // 被全屏遮罩盖着的页面默认不播(登录墙/密码框),也不记账:等真正解锁后
803
+ // 再访问时会正常播一次。用户可以在设置里把「遮罩页面也播」打开。
804
+ const allowVeiled = tuning.playOnVeiled ?? config?.swarm?.playOnVeiled ?? false;
805
+ if (!allowVeiled && (await hasBlockingVeil(page))) {
806
+ await dropCurtain(page);
807
+ return;
808
+ }
809
+ const speed = clamp(tuning.speed ?? config?.swarm?.speed, 1, 3, 3);
810
+ const rawMax = tuning.maxMs ?? config?.swarm?.maxMs;
811
+ // 0 = 完整播完:给一个很宽的上限,仍然留一条兜底,免得哪天真卡住。
812
+ const maxMs = rawMax === 0 ? 120000 : clamp(rawMax, 3000, 30000, 15000);
813
+ // 播放范围:默认只画首屏;选「整页」时由 playSwarm 一屏一屏滚着画。
814
+ const range = (tuning.range ?? config?.swarm?.range) === 'page' ? 'page' : 'viewport';
815
+ await playSwarm(page, {
816
+ maxMs,
817
+ speedClicks: speed - 1,
818
+ // 「AI 工作时」:auto-close(默认)= 动作前让路;keep-alive = 动画继续但不挡点击。
819
+ keepAlive: tuning.whileAiWorks === 'keep-alive',
820
+ // 记账交给 playSwarm:注入成功之后才写,失败不占名额。
821
+ markHost: oncePerSite ? host : '',
822
+ range,
823
+ });
824
+ }
825
+ catch {
826
+ // 任何意外都不能影响导航本身。
827
+ }
828
+ })();
829
+ }