@xiaobuyu/nodesign 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +22 -22
- package/server/api/assets/notes.js +144 -144
- package/server/api/assets.js +896 -896
- package/server/api/board.js +57 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +464 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1024 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +339 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +155 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +103 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +110 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +60 -60
- package/server/lib/board-kind-sizes.js +62 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-store.js +640 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +54 -54
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +36 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
- package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
- package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
- package/web/dist/index.html +17 -17
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
|
@@ -1,307 +1,307 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/engine/browse/registry.js — 常驻浏览器登记处(2026-08-18)
|
|
3
|
-
*
|
|
4
|
-
* agent 用浏览器看设计参考这条线的本体。**进程级单例,按 projectId 键。**
|
|
5
|
-
*
|
|
6
|
-
* ## 为什么是常驻,为什么按项目键
|
|
7
|
-
*
|
|
8
|
-
* 在这之前所有浏览器工具都是 launch→用→close 即弃(`screenshot-url.js` 那套),
|
|
9
|
-
* 所以**没有会话**:点不了链接、翻不了子页、登录态留不住、Cookie 同意弹窗每次重新
|
|
10
|
-
* 弹,想看第三层页面只能猜 URL。用户要的正是这条:让 agent 能真的**逛**。
|
|
11
|
-
*
|
|
12
|
-
* 键按 **projectId**(用户拍板):项目 owner 唯一,所以这等于 per-user per-project;
|
|
13
|
-
* 登录态天然跨会话复用 —— 用户的原话是「**此后**从浏览器中获得的可复用内容」,
|
|
14
|
-
* 那个"此后"包含下一次开新对话。
|
|
15
|
-
*
|
|
16
|
-
* ## 这台机器只有 1 vCPU,所以每条约束都是硬的
|
|
17
|
-
*
|
|
18
|
-
* - 常驻浏览器 **≤ 2**:空闲实测 185 MiB PSS / 0.1% CPU,内存不是矛盾,但超了就
|
|
19
|
-
* 按 LRU 关最久没用的**空闲**那个;一个都腾不出来就**拒绝并告诉 agent**,
|
|
20
|
-
* 不静默排队(静默降级是这个仓库的老账)
|
|
21
|
-
* - 同项目的调用走 **mutex 串行**:同一个项目可能同时开着两个会话,两个 agent 抢
|
|
22
|
-
* 一个 page 会互相把对方导航走
|
|
23
|
-
* - 空闲 5 分钟回收(保留 profile)。timer `.unref()` —— 别让它拖着进程不退出
|
|
24
|
-
*
|
|
25
|
-
* ## 跟感知通道刻意分开的两条通道
|
|
26
|
-
*
|
|
27
|
-
* `perception-page.js` 的 `launchPerceptionBrowser` 是**感知通道**:URL 由我们构造
|
|
28
|
-
* (artifact-file loopback)、允许 loopback、用完即弃。这里是**浏览通道**:URL 由
|
|
29
|
-
* agent 自由指定、**loopback 一律禁**(SSRF 闸)、常驻带持久 profile。
|
|
30
|
-
* 两个函数并排放着而不是合成一个带 flag 的 —— 「同一件东西有多个实例」的反面是
|
|
31
|
-
* 「两件不同的东西硬塞进一个函数」,安全语义会被 flag 搅浑。共享的只有
|
|
32
|
-
* `FIDELITY_LAUNCH_ARGS` 那一份常量。
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import path from 'node:path';
|
|
36
|
-
import { mutex } from 'async-mutex-lite';
|
|
37
|
-
import { getProjectWorkspace } from '../../projects/workspace.js';
|
|
38
|
-
import { FIDELITY_LAUNCH_ARGS } from '../mcp/tools/helpers/perception-page.js';
|
|
39
|
-
import { attachSsrfGuard } from '../../lib/ssrf-guard.js';
|
|
40
|
-
import { startBrowseProxy } from '../../lib/browse-proxy.js';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* 常驻上限(内存)。1 vCPU 上活跃画面流另有 ≤1 的上限,见 screencast 那一层。
|
|
44
|
-
*
|
|
45
|
-
* ⚠️ 写法要 NaN-safe:`Number('yes')` 是 NaN,而 `live.size >= NaN` **恒为 false**
|
|
46
|
-
* → 上限静默消失,chromium 无限起。这台机器 1 核 8G **无 swap**,尖峰直接进
|
|
47
|
-
* 内核 OOM killer。(隔壁 IDLE_MS 用的 `|| 默认` 写法本来就是 NaN-safe 的。)
|
|
48
|
-
*/
|
|
49
|
-
const MAX_RESIDENT = Number(process.env.ND_BROWSE_MAX) > 0
|
|
50
|
-
? Math.floor(Number(process.env.ND_BROWSE_MAX)) : 2;
|
|
51
|
-
/** 空闲多久回收。仿 h3box 的 ControlPersist=600 与 WS grace 的思路。 */
|
|
52
|
-
const IDLE_MS = Number(process.env.ND_BROWSE_IDLE_MS || 0) || 5 * 60 * 1000;
|
|
53
|
-
const NAV_TIMEOUT_MS = 30_000;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* 视口:桌面宽(看设计参考要的是版面不是移动端),**且坐标 1:1**。
|
|
57
|
-
* 08-21 从 1440×900 改 1366×768:改的时候 shot-pipeline 阈值还是 1568/1.15MP,1440×900=1.30MP
|
|
58
|
-
* 会被缩 0.94 倍 → browser_computer 从图上读的坐标和页面像素差 6%。同日阈值抬到 2000px/3.75MP
|
|
59
|
-
* 后 1440×900 其实也不缩了,但 1366×768 留着:便宜(≈1372 token/张)、是文档推荐档、坐标 1:1。
|
|
60
|
-
* 也是 Anthropic computer-use 文档给 web 应用推荐的档位。改它要连 browse-computer.test.js
|
|
61
|
-
* 的"视口在阈值内"断言一起过。
|
|
62
|
-
*/
|
|
63
|
-
const VIEWPORT = { width: 1366, height: 768 };
|
|
64
|
-
|
|
65
|
-
/** projectId → entry */
|
|
66
|
-
const live = new Map();
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* ⭐ 把 UA 里的 HeadlessChrome 换成 Chrome。
|
|
70
|
-
*
|
|
71
|
-
* 依据是一篇受控实验(arXiv:2606.14525,Tranco Top 10K × 4 万次抓取):在「只有
|
|
72
|
-
* headless 被封」的 784 个站上,**仅仅**把 UA 与 Client Hints 里的 `HeadlessChrome`
|
|
73
|
-
* 换成 `Chrome`,590 个(**75%**)当场转 HTTP 2xx。也就是说 headed 相对 headless
|
|
74
|
-
* 那 8 个百分点的优势,四分之三来自这个头部信号 —— 不是「headed 本身更像人」。
|
|
75
|
-
* 所以我们不装 Xvfb(1 vCPU 付不起一颗核,而且 headed vs **新** headless 全世界
|
|
76
|
-
* 没人测过),改这个字符串就吃满大头。
|
|
77
|
-
*
|
|
78
|
-
* ⚠️ 这不是为了绕强反爬(数据中心 IP 是改不掉的底噪,最凶的墙怎么配都拦 ——
|
|
79
|
-
* 那是「人接手」存在的理由)。这是为了不在最脏的一档上白挨拦。
|
|
80
|
-
*/
|
|
81
|
-
function chromeUa(raw) {
|
|
82
|
-
return String(raw).replace(/HeadlessChrome\//g, 'Chrome/');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function launchBrowseBrowser(projectId) {
|
|
86
|
-
const { chromium } = await import('playwright');
|
|
87
|
-
// profile 必须落在 `<pid>/` 下、`shared/` **之外**:shared 是 agent 的 cwd 也是
|
|
88
|
-
// artifact-file 的服务根 —— 放进去等于 (a) cookie jar 能被当文件服出去
|
|
89
|
-
// (b) agent 能 Read 到自己的 cookie (c) 进 per-project git。
|
|
90
|
-
// 放兄弟位就都避开了,而且删项目时 removeProjectWorkspace 删的是整个 `<pid>/`
|
|
91
|
-
// (核过),profile 跟着走,不用额外写清理。
|
|
92
|
-
const userDataDir = path.join(getProjectWorkspace(projectId), '.browser', 'default');
|
|
93
|
-
|
|
94
|
-
// ⭐ 出网闸在**代理层**(2026-08-18 第二遍):`--proxy-server` 之后 chromium 的
|
|
95
|
-
// 每一次连接都从这儿走 —— 包括 CDP 的 Fetch 阶段看不见的那些(WebSocket 握手、
|
|
96
|
-
// `<link rel=prefetch>`、sendBeacon、还没装上闸的弹窗)。理由与实测见
|
|
97
|
-
// lib/browse-proxy.js 文件头。CDP 那道闸保留当纵深,不再是唯一一道。
|
|
98
|
-
// ⚠️ `bypass: ''` 是必须的:默认会放过 loopback,那正好是我们最要拦的。
|
|
99
|
-
const { port: proxyPort } = await startBrowseProxy();
|
|
100
|
-
|
|
101
|
-
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
102
|
-
channel: 'chromium', // ⭐ 不是默认的 headless_shell,见 chromeUa 的注释
|
|
103
|
-
headless: true,
|
|
104
|
-
proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '' },
|
|
105
|
-
// ⭐ WebRTC 是代理**看不见**的那条路:它走 UDP,不经 HTTP 代理,能被用来
|
|
106
|
-
// 探内网存活(STUN 打内网地址)。看设计参考完全不需要 WebRTC,所以直接
|
|
107
|
-
// 掐掉非代理 UDP —— 这是 Chromium 官方给的那个开关,不是我们发明的。
|
|
108
|
-
args: [...FIDELITY_LAUNCH_ARGS, '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'],
|
|
109
|
-
viewport: VIEWPORT,
|
|
110
|
-
locale: 'zh-CN', // 面向中文用户;顺带摆脱默认的 en-US@posix 那种怪指纹
|
|
111
|
-
timezoneId: 'Asia/Shanghai',
|
|
112
|
-
acceptDownloads: false, // 下载不是这条线要的,而且是一条额外的写盘面
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// proxied: true —— 第二道(连上后看对端 IP)在代理下是纯冗余,而且会把每个页面
|
|
116
|
-
// 都误杀(代理自己就是 127.0.0.1)。第一道(Fetch 阶段)保留当纵深。
|
|
117
|
-
const guard = await attachSsrfGuard(context, undefined, { proxied: true });
|
|
118
|
-
// 持久 context 自带一个空白页;没有就造一个
|
|
119
|
-
const page = context.pages()[0] || await context.newPage();
|
|
120
|
-
await guard.armPage(page); // ⭐ 必须 await 完才允许导航(竞态是攻出来的)
|
|
121
|
-
|
|
122
|
-
// UA 与 Client Hints 一起改 —— 只改 UA 字符串的话 sec-ch-ua 还在报 headless
|
|
123
|
-
const cdp = await context.newCDPSession(page);
|
|
124
|
-
const realUa = await page.evaluate(() => navigator.userAgent);
|
|
125
|
-
const ua = chromeUa(realUa);
|
|
126
|
-
const m = realUa.match(/Chrome\/(\d+)/);
|
|
127
|
-
const major = m ? m[1] : '147';
|
|
128
|
-
await cdp.send('Emulation.setUserAgentOverride', {
|
|
129
|
-
userAgent: ua,
|
|
130
|
-
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
131
|
-
platform: 'Linux x86_64',
|
|
132
|
-
userAgentMetadata: {
|
|
133
|
-
brands: [
|
|
134
|
-
{ brand: 'Chromium', version: major },
|
|
135
|
-
{ brand: 'Google Chrome', version: major },
|
|
136
|
-
{ brand: 'Not?A_Brand', version: '24' },
|
|
137
|
-
],
|
|
138
|
-
fullVersion: `${major}.0.0.0`,
|
|
139
|
-
platform: 'Linux',
|
|
140
|
-
platformVersion: '6.1.0',
|
|
141
|
-
architecture: 'x86',
|
|
142
|
-
model: '',
|
|
143
|
-
mobile: false,
|
|
144
|
-
},
|
|
145
|
-
}).catch(() => { /* 覆盖不上就退回只改 UA 字符串那一档,别因此起不来 */ });
|
|
146
|
-
|
|
147
|
-
return { context, page, guard, ua };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function touch(entry) {
|
|
151
|
-
entry.lastUsed = Date.now();
|
|
152
|
-
clearTimeout(entry.idleTimer);
|
|
153
|
-
entry.idleTimer = setTimeout(() => { closeFor(entry.projectId, 'idle').catch(() => {}); }, IDLE_MS);
|
|
154
|
-
entry.idleTimer.unref?.();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** 关掉某个项目的浏览器(**保留 profile** —— 登录态是资产) */
|
|
158
|
-
export async function closeFor(projectId, why = 'explicit') {
|
|
159
|
-
const entry = live.get(projectId);
|
|
160
|
-
if (!entry) return false;
|
|
161
|
-
live.delete(projectId);
|
|
162
|
-
clearTimeout(entry.idleTimer);
|
|
163
|
-
// 先让画面流那一层知道(它要通知正在看的人,并且停掉编码),再关浏览器 ——
|
|
164
|
-
// 反过来的话 CDP 会话已经死了,stopScreencast 只会抛一堆没意义的错
|
|
165
|
-
try { (await import('./screencast.js')).forget(projectId); } catch { /* */ }
|
|
166
|
-
try { await entry.context.close(); } catch { /* 已经死了就算了 */ }
|
|
167
|
-
console.log(`[browse] closed ${projectId} (${why})`);
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** 腾一个位子出来:按 LRU 关最久没用的**空闲**实例。@returns 腾出来了没 */
|
|
172
|
-
async function evictOne() {
|
|
173
|
-
const idle = [...live.values()].filter(e => !e.busy).sort((a, b) => a.lastUsed - b.lastUsed);
|
|
174
|
-
if (!idle.length) return false;
|
|
175
|
-
await closeFor(idle[0].projectId, 'evicted (LRU)');
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* 拿到某个项目的浏览器(懒启动)。**同项目串行**。
|
|
181
|
-
*
|
|
182
|
-
* @param {string} projectId
|
|
183
|
-
* @param {(h: {page: import('playwright').Page, guard: object, ua: string}) => Promise<any>} fn
|
|
184
|
-
* @returns {Promise<any>} fn 的返回值
|
|
185
|
-
* @throws 超上限时抛错,**不静默排队** —— agent 该看到"满了"这件事
|
|
186
|
-
*/
|
|
187
|
-
export async function withBrowser(projectId, fn) {
|
|
188
|
-
return mutex(`browse:${projectId}`, async () => {
|
|
189
|
-
let entry = live.get(projectId);
|
|
190
|
-
// ⚠️ 页可能已经不在了而 entry 还在:出网闸连到坏 IP 时会 `page.close()`
|
|
191
|
-
// (ssrf-guard 的 [connected] 兜底)。任意页面塞一个
|
|
192
|
-
// `<link rel=prefetch href=http://127.0.0.1:4001/>` 就能让常驻页变成一具尸体,
|
|
193
|
-
// 而 5 分钟空闲计时器才会清它 —— 等于给外站开了个远程杀开关。
|
|
194
|
-
// 这里发现就重建(别让 agent 拿着 "Target has been closed" 猜发生了什么)。
|
|
195
|
-
if (entry && entry.page.isClosed()) {
|
|
196
|
-
console.log(`[browse] ${projectId} 的页已被掐(多半是出网闸),重建`);
|
|
197
|
-
await closeFor(projectId, 'page was closed underneath us');
|
|
198
|
-
entry = null;
|
|
199
|
-
}
|
|
200
|
-
if (!entry) {
|
|
201
|
-
if (live.size >= MAX_RESIDENT && !(await evictOne())) {
|
|
202
|
-
throw Object.assign(
|
|
203
|
-
new Error(`浏览器实例已满(${live.size}/${MAX_RESIDENT} 都在用)。`
|
|
204
|
-
+ '等一会儿再试,或者让别的会话先收工 —— 这台机器只有 1 个 CPU 核,常驻上限是硬的。'),
|
|
205
|
-
{ status: 503 },
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
const t0 = Date.now();
|
|
209
|
-
const h = await launchBrowseBrowser(projectId);
|
|
210
|
-
entry = { projectId, ...h, lastUsed: Date.now(), busy: false, idleTimer: null };
|
|
211
|
-
live.set(projectId, entry);
|
|
212
|
-
console.log(`[browse] launched ${projectId} in ${Date.now() - t0}ms (${live.size}/${MAX_RESIDENT} resident)`);
|
|
213
|
-
}
|
|
214
|
-
entry.busy = true;
|
|
215
|
-
try {
|
|
216
|
-
touch(entry);
|
|
217
|
-
return await fn({ page: entry.page, guard: entry.guard, ua: entry.ua, context: entry.context });
|
|
218
|
-
} finally {
|
|
219
|
-
entry.busy = false;
|
|
220
|
-
touch(entry); // 用完重新起计时,别让长调用被自己的旧计时器掐掉
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ⚠️ **刻意没有 onSessionEnd。** 计划里写了「会话结束时在 session-loop 的 finally
|
|
226
|
-
// 里关掉该项目的浏览器」,落地时否掉了,理由两条:
|
|
227
|
-
// ① `active-runs` 是按 sessionId 键的,**没有「这个项目还有别的活会话吗」这个
|
|
228
|
-
// 查询**。同一个项目同时开两个会话是常见的(用户经常这么用),照着关就是把
|
|
229
|
-
// 另一个会话正在看的页面关掉。
|
|
230
|
-
// ② 5 分钟空闲回收已经覆盖了"人走了"这件事,而且它按**实际使用**判,不按会话
|
|
231
|
-
// 生命周期判 —— 后者跟"浏览器还有没有人要用"其实不是一回事。
|
|
232
|
-
// 与其造一个用不上的导出摆在这儿(这个仓库有过"全仓无人写入的字段假装第一优先级"
|
|
233
|
-
// 那种账),不如写清为什么没有。
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* 把某个项目的浏览器**钉住**:期间它不空闲、不会被 LRU 挤掉、不会被空闲计时器关。
|
|
237
|
-
*
|
|
238
|
-
* 为什么需要(审查抓到的):`browser_request_help` 是在 `withBrowser` **外面**等人的
|
|
239
|
-
* (它必须放开 mutex,否则人在窗里点的每一下都会排在 agent 这次调用后面 = 死锁)。
|
|
240
|
-
* 于是等人的那 120 秒里 `busy` 是 false、`lastUsed` 不动 —— `evictOne` 正好挑
|
|
241
|
-
* "最久没用的空闲实例",它就是那个**人正在过验证码的浏览器**。
|
|
242
|
-
*
|
|
243
|
-
* @returns {() => void} 松手(幂等)
|
|
244
|
-
*/
|
|
245
|
-
export function hold(projectId, why = 'held') {
|
|
246
|
-
const entry = live.get(projectId);
|
|
247
|
-
if (!entry) return () => {};
|
|
248
|
-
entry.busy = true;
|
|
249
|
-
entry.holds = (entry.holds || 0) + 1;
|
|
250
|
-
touch(entry);
|
|
251
|
-
let released = false;
|
|
252
|
-
return () => {
|
|
253
|
-
if (released) return;
|
|
254
|
-
released = true;
|
|
255
|
-
const e = live.get(projectId);
|
|
256
|
-
if (!e) return;
|
|
257
|
-
e.holds = Math.max(0, (e.holds || 1) - 1);
|
|
258
|
-
if (!e.holds) e.busy = false;
|
|
259
|
-
touch(e); // 松手时重新起计时,别让它带着旧计时器立刻被回收
|
|
260
|
-
console.log(`[browse] ${projectId} 松手(${why})`);
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* 人在窗里动了一下 → 重置空闲计时。
|
|
266
|
-
* 没有这条的话:人安静看五分钟,浏览器在他眼前被关掉(空闲计时只认 agent 的调用)。
|
|
267
|
-
*/
|
|
268
|
-
export function touchProject(projectId) {
|
|
269
|
-
const entry = live.get(projectId);
|
|
270
|
-
if (entry) touch(entry);
|
|
271
|
-
return !!entry;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* 刷新页面之后拿回「现在有没有浏览器 / agent 是不是正举着手」。
|
|
276
|
-
* 求助 banner 原来只靠一次性的 WS 事件,刷新即失传 —— agent 在那儿等两分钟没人知道。
|
|
277
|
-
*/
|
|
278
|
-
export function browseState(projectId) {
|
|
279
|
-
const e = live.get(projectId);
|
|
280
|
-
return {
|
|
281
|
-
live: !!e,
|
|
282
|
-
url: e ? e.page.url() : null,
|
|
283
|
-
busy: e ? !!e.busy : false,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* 拿到某项目**已经在跑**的浏览器(不懒启动)。
|
|
289
|
-
* 给 WS 画面通道用:用户开窗时如果 agent 还没开始浏览,就该显示"还没开始",
|
|
290
|
-
* 而不是替 agent 起一个浏览器(那会让 1 vCPU 上的常驻名额被看客占掉)。
|
|
291
|
-
*/
|
|
292
|
-
export function peek(projectId) {
|
|
293
|
-
const e = live.get(projectId);
|
|
294
|
-
return e ? { page: e.page, context: e.context, guard: e.guard } : null;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** 给体检/日志用 */
|
|
298
|
-
export function status() {
|
|
299
|
-
return [...live.values()].map(e => ({
|
|
300
|
-
projectId: e.projectId,
|
|
301
|
-
url: (() => { try { return e.page.url(); } catch { return '(gone)'; } })(),
|
|
302
|
-
idleMs: Date.now() - e.lastUsed,
|
|
303
|
-
busy: e.busy,
|
|
304
|
-
}));
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
export const _limits = { MAX_RESIDENT, IDLE_MS, NAV_TIMEOUT_MS, VIEWPORT };
|
|
1
|
+
/**
|
|
2
|
+
* server/engine/browse/registry.js — 常驻浏览器登记处(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* agent 用浏览器看设计参考这条线的本体。**进程级单例,按 projectId 键。**
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么是常驻,为什么按项目键
|
|
7
|
+
*
|
|
8
|
+
* 在这之前所有浏览器工具都是 launch→用→close 即弃(`screenshot-url.js` 那套),
|
|
9
|
+
* 所以**没有会话**:点不了链接、翻不了子页、登录态留不住、Cookie 同意弹窗每次重新
|
|
10
|
+
* 弹,想看第三层页面只能猜 URL。用户要的正是这条:让 agent 能真的**逛**。
|
|
11
|
+
*
|
|
12
|
+
* 键按 **projectId**(用户拍板):项目 owner 唯一,所以这等于 per-user per-project;
|
|
13
|
+
* 登录态天然跨会话复用 —— 用户的原话是「**此后**从浏览器中获得的可复用内容」,
|
|
14
|
+
* 那个"此后"包含下一次开新对话。
|
|
15
|
+
*
|
|
16
|
+
* ## 这台机器只有 1 vCPU,所以每条约束都是硬的
|
|
17
|
+
*
|
|
18
|
+
* - 常驻浏览器 **≤ 2**:空闲实测 185 MiB PSS / 0.1% CPU,内存不是矛盾,但超了就
|
|
19
|
+
* 按 LRU 关最久没用的**空闲**那个;一个都腾不出来就**拒绝并告诉 agent**,
|
|
20
|
+
* 不静默排队(静默降级是这个仓库的老账)
|
|
21
|
+
* - 同项目的调用走 **mutex 串行**:同一个项目可能同时开着两个会话,两个 agent 抢
|
|
22
|
+
* 一个 page 会互相把对方导航走
|
|
23
|
+
* - 空闲 5 分钟回收(保留 profile)。timer `.unref()` —— 别让它拖着进程不退出
|
|
24
|
+
*
|
|
25
|
+
* ## 跟感知通道刻意分开的两条通道
|
|
26
|
+
*
|
|
27
|
+
* `perception-page.js` 的 `launchPerceptionBrowser` 是**感知通道**:URL 由我们构造
|
|
28
|
+
* (artifact-file loopback)、允许 loopback、用完即弃。这里是**浏览通道**:URL 由
|
|
29
|
+
* agent 自由指定、**loopback 一律禁**(SSRF 闸)、常驻带持久 profile。
|
|
30
|
+
* 两个函数并排放着而不是合成一个带 flag 的 —— 「同一件东西有多个实例」的反面是
|
|
31
|
+
* 「两件不同的东西硬塞进一个函数」,安全语义会被 flag 搅浑。共享的只有
|
|
32
|
+
* `FIDELITY_LAUNCH_ARGS` 那一份常量。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { mutex } from 'async-mutex-lite';
|
|
37
|
+
import { getProjectWorkspace } from '../../projects/workspace.js';
|
|
38
|
+
import { FIDELITY_LAUNCH_ARGS } from '../mcp/tools/helpers/perception-page.js';
|
|
39
|
+
import { attachSsrfGuard } from '../../lib/ssrf-guard.js';
|
|
40
|
+
import { startBrowseProxy } from '../../lib/browse-proxy.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 常驻上限(内存)。1 vCPU 上活跃画面流另有 ≤1 的上限,见 screencast 那一层。
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ 写法要 NaN-safe:`Number('yes')` 是 NaN,而 `live.size >= NaN` **恒为 false**
|
|
46
|
+
* → 上限静默消失,chromium 无限起。这台机器 1 核 8G **无 swap**,尖峰直接进
|
|
47
|
+
* 内核 OOM killer。(隔壁 IDLE_MS 用的 `|| 默认` 写法本来就是 NaN-safe 的。)
|
|
48
|
+
*/
|
|
49
|
+
const MAX_RESIDENT = Number(process.env.ND_BROWSE_MAX) > 0
|
|
50
|
+
? Math.floor(Number(process.env.ND_BROWSE_MAX)) : 2;
|
|
51
|
+
/** 空闲多久回收。仿 h3box 的 ControlPersist=600 与 WS grace 的思路。 */
|
|
52
|
+
const IDLE_MS = Number(process.env.ND_BROWSE_IDLE_MS || 0) || 5 * 60 * 1000;
|
|
53
|
+
const NAV_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 视口:桌面宽(看设计参考要的是版面不是移动端),**且坐标 1:1**。
|
|
57
|
+
* 08-21 从 1440×900 改 1366×768:改的时候 shot-pipeline 阈值还是 1568/1.15MP,1440×900=1.30MP
|
|
58
|
+
* 会被缩 0.94 倍 → browser_computer 从图上读的坐标和页面像素差 6%。同日阈值抬到 2000px/3.75MP
|
|
59
|
+
* 后 1440×900 其实也不缩了,但 1366×768 留着:便宜(≈1372 token/张)、是文档推荐档、坐标 1:1。
|
|
60
|
+
* 也是 Anthropic computer-use 文档给 web 应用推荐的档位。改它要连 browse-computer.test.js
|
|
61
|
+
* 的"视口在阈值内"断言一起过。
|
|
62
|
+
*/
|
|
63
|
+
const VIEWPORT = { width: 1366, height: 768 };
|
|
64
|
+
|
|
65
|
+
/** projectId → entry */
|
|
66
|
+
const live = new Map();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ⭐ 把 UA 里的 HeadlessChrome 换成 Chrome。
|
|
70
|
+
*
|
|
71
|
+
* 依据是一篇受控实验(arXiv:2606.14525,Tranco Top 10K × 4 万次抓取):在「只有
|
|
72
|
+
* headless 被封」的 784 个站上,**仅仅**把 UA 与 Client Hints 里的 `HeadlessChrome`
|
|
73
|
+
* 换成 `Chrome`,590 个(**75%**)当场转 HTTP 2xx。也就是说 headed 相对 headless
|
|
74
|
+
* 那 8 个百分点的优势,四分之三来自这个头部信号 —— 不是「headed 本身更像人」。
|
|
75
|
+
* 所以我们不装 Xvfb(1 vCPU 付不起一颗核,而且 headed vs **新** headless 全世界
|
|
76
|
+
* 没人测过),改这个字符串就吃满大头。
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ 这不是为了绕强反爬(数据中心 IP 是改不掉的底噪,最凶的墙怎么配都拦 ——
|
|
79
|
+
* 那是「人接手」存在的理由)。这是为了不在最脏的一档上白挨拦。
|
|
80
|
+
*/
|
|
81
|
+
function chromeUa(raw) {
|
|
82
|
+
return String(raw).replace(/HeadlessChrome\//g, 'Chrome/');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function launchBrowseBrowser(projectId) {
|
|
86
|
+
const { chromium } = await import('playwright');
|
|
87
|
+
// profile 必须落在 `<pid>/` 下、`shared/` **之外**:shared 是 agent 的 cwd 也是
|
|
88
|
+
// artifact-file 的服务根 —— 放进去等于 (a) cookie jar 能被当文件服出去
|
|
89
|
+
// (b) agent 能 Read 到自己的 cookie (c) 进 per-project git。
|
|
90
|
+
// 放兄弟位就都避开了,而且删项目时 removeProjectWorkspace 删的是整个 `<pid>/`
|
|
91
|
+
// (核过),profile 跟着走,不用额外写清理。
|
|
92
|
+
const userDataDir = path.join(getProjectWorkspace(projectId), '.browser', 'default');
|
|
93
|
+
|
|
94
|
+
// ⭐ 出网闸在**代理层**(2026-08-18 第二遍):`--proxy-server` 之后 chromium 的
|
|
95
|
+
// 每一次连接都从这儿走 —— 包括 CDP 的 Fetch 阶段看不见的那些(WebSocket 握手、
|
|
96
|
+
// `<link rel=prefetch>`、sendBeacon、还没装上闸的弹窗)。理由与实测见
|
|
97
|
+
// lib/browse-proxy.js 文件头。CDP 那道闸保留当纵深,不再是唯一一道。
|
|
98
|
+
// ⚠️ `bypass: ''` 是必须的:默认会放过 loopback,那正好是我们最要拦的。
|
|
99
|
+
const { port: proxyPort } = await startBrowseProxy();
|
|
100
|
+
|
|
101
|
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
102
|
+
channel: 'chromium', // ⭐ 不是默认的 headless_shell,见 chromeUa 的注释
|
|
103
|
+
headless: true,
|
|
104
|
+
proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '' },
|
|
105
|
+
// ⭐ WebRTC 是代理**看不见**的那条路:它走 UDP,不经 HTTP 代理,能被用来
|
|
106
|
+
// 探内网存活(STUN 打内网地址)。看设计参考完全不需要 WebRTC,所以直接
|
|
107
|
+
// 掐掉非代理 UDP —— 这是 Chromium 官方给的那个开关,不是我们发明的。
|
|
108
|
+
args: [...FIDELITY_LAUNCH_ARGS, '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'],
|
|
109
|
+
viewport: VIEWPORT,
|
|
110
|
+
locale: 'zh-CN', // 面向中文用户;顺带摆脱默认的 en-US@posix 那种怪指纹
|
|
111
|
+
timezoneId: 'Asia/Shanghai',
|
|
112
|
+
acceptDownloads: false, // 下载不是这条线要的,而且是一条额外的写盘面
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// proxied: true —— 第二道(连上后看对端 IP)在代理下是纯冗余,而且会把每个页面
|
|
116
|
+
// 都误杀(代理自己就是 127.0.0.1)。第一道(Fetch 阶段)保留当纵深。
|
|
117
|
+
const guard = await attachSsrfGuard(context, undefined, { proxied: true });
|
|
118
|
+
// 持久 context 自带一个空白页;没有就造一个
|
|
119
|
+
const page = context.pages()[0] || await context.newPage();
|
|
120
|
+
await guard.armPage(page); // ⭐ 必须 await 完才允许导航(竞态是攻出来的)
|
|
121
|
+
|
|
122
|
+
// UA 与 Client Hints 一起改 —— 只改 UA 字符串的话 sec-ch-ua 还在报 headless
|
|
123
|
+
const cdp = await context.newCDPSession(page);
|
|
124
|
+
const realUa = await page.evaluate(() => navigator.userAgent);
|
|
125
|
+
const ua = chromeUa(realUa);
|
|
126
|
+
const m = realUa.match(/Chrome\/(\d+)/);
|
|
127
|
+
const major = m ? m[1] : '147';
|
|
128
|
+
await cdp.send('Emulation.setUserAgentOverride', {
|
|
129
|
+
userAgent: ua,
|
|
130
|
+
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
131
|
+
platform: 'Linux x86_64',
|
|
132
|
+
userAgentMetadata: {
|
|
133
|
+
brands: [
|
|
134
|
+
{ brand: 'Chromium', version: major },
|
|
135
|
+
{ brand: 'Google Chrome', version: major },
|
|
136
|
+
{ brand: 'Not?A_Brand', version: '24' },
|
|
137
|
+
],
|
|
138
|
+
fullVersion: `${major}.0.0.0`,
|
|
139
|
+
platform: 'Linux',
|
|
140
|
+
platformVersion: '6.1.0',
|
|
141
|
+
architecture: 'x86',
|
|
142
|
+
model: '',
|
|
143
|
+
mobile: false,
|
|
144
|
+
},
|
|
145
|
+
}).catch(() => { /* 覆盖不上就退回只改 UA 字符串那一档,别因此起不来 */ });
|
|
146
|
+
|
|
147
|
+
return { context, page, guard, ua };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function touch(entry) {
|
|
151
|
+
entry.lastUsed = Date.now();
|
|
152
|
+
clearTimeout(entry.idleTimer);
|
|
153
|
+
entry.idleTimer = setTimeout(() => { closeFor(entry.projectId, 'idle').catch(() => {}); }, IDLE_MS);
|
|
154
|
+
entry.idleTimer.unref?.();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 关掉某个项目的浏览器(**保留 profile** —— 登录态是资产) */
|
|
158
|
+
export async function closeFor(projectId, why = 'explicit') {
|
|
159
|
+
const entry = live.get(projectId);
|
|
160
|
+
if (!entry) return false;
|
|
161
|
+
live.delete(projectId);
|
|
162
|
+
clearTimeout(entry.idleTimer);
|
|
163
|
+
// 先让画面流那一层知道(它要通知正在看的人,并且停掉编码),再关浏览器 ——
|
|
164
|
+
// 反过来的话 CDP 会话已经死了,stopScreencast 只会抛一堆没意义的错
|
|
165
|
+
try { (await import('./screencast.js')).forget(projectId); } catch { /* */ }
|
|
166
|
+
try { await entry.context.close(); } catch { /* 已经死了就算了 */ }
|
|
167
|
+
console.log(`[browse] closed ${projectId} (${why})`);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 腾一个位子出来:按 LRU 关最久没用的**空闲**实例。@returns 腾出来了没 */
|
|
172
|
+
async function evictOne() {
|
|
173
|
+
const idle = [...live.values()].filter(e => !e.busy).sort((a, b) => a.lastUsed - b.lastUsed);
|
|
174
|
+
if (!idle.length) return false;
|
|
175
|
+
await closeFor(idle[0].projectId, 'evicted (LRU)');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 拿到某个项目的浏览器(懒启动)。**同项目串行**。
|
|
181
|
+
*
|
|
182
|
+
* @param {string} projectId
|
|
183
|
+
* @param {(h: {page: import('playwright').Page, guard: object, ua: string}) => Promise<any>} fn
|
|
184
|
+
* @returns {Promise<any>} fn 的返回值
|
|
185
|
+
* @throws 超上限时抛错,**不静默排队** —— agent 该看到"满了"这件事
|
|
186
|
+
*/
|
|
187
|
+
export async function withBrowser(projectId, fn) {
|
|
188
|
+
return mutex(`browse:${projectId}`, async () => {
|
|
189
|
+
let entry = live.get(projectId);
|
|
190
|
+
// ⚠️ 页可能已经不在了而 entry 还在:出网闸连到坏 IP 时会 `page.close()`
|
|
191
|
+
// (ssrf-guard 的 [connected] 兜底)。任意页面塞一个
|
|
192
|
+
// `<link rel=prefetch href=http://127.0.0.1:4001/>` 就能让常驻页变成一具尸体,
|
|
193
|
+
// 而 5 分钟空闲计时器才会清它 —— 等于给外站开了个远程杀开关。
|
|
194
|
+
// 这里发现就重建(别让 agent 拿着 "Target has been closed" 猜发生了什么)。
|
|
195
|
+
if (entry && entry.page.isClosed()) {
|
|
196
|
+
console.log(`[browse] ${projectId} 的页已被掐(多半是出网闸),重建`);
|
|
197
|
+
await closeFor(projectId, 'page was closed underneath us');
|
|
198
|
+
entry = null;
|
|
199
|
+
}
|
|
200
|
+
if (!entry) {
|
|
201
|
+
if (live.size >= MAX_RESIDENT && !(await evictOne())) {
|
|
202
|
+
throw Object.assign(
|
|
203
|
+
new Error(`浏览器实例已满(${live.size}/${MAX_RESIDENT} 都在用)。`
|
|
204
|
+
+ '等一会儿再试,或者让别的会话先收工 —— 这台机器只有 1 个 CPU 核,常驻上限是硬的。'),
|
|
205
|
+
{ status: 503 },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const t0 = Date.now();
|
|
209
|
+
const h = await launchBrowseBrowser(projectId);
|
|
210
|
+
entry = { projectId, ...h, lastUsed: Date.now(), busy: false, idleTimer: null };
|
|
211
|
+
live.set(projectId, entry);
|
|
212
|
+
console.log(`[browse] launched ${projectId} in ${Date.now() - t0}ms (${live.size}/${MAX_RESIDENT} resident)`);
|
|
213
|
+
}
|
|
214
|
+
entry.busy = true;
|
|
215
|
+
try {
|
|
216
|
+
touch(entry);
|
|
217
|
+
return await fn({ page: entry.page, guard: entry.guard, ua: entry.ua, context: entry.context });
|
|
218
|
+
} finally {
|
|
219
|
+
entry.busy = false;
|
|
220
|
+
touch(entry); // 用完重新起计时,别让长调用被自己的旧计时器掐掉
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ⚠️ **刻意没有 onSessionEnd。** 计划里写了「会话结束时在 session-loop 的 finally
|
|
226
|
+
// 里关掉该项目的浏览器」,落地时否掉了,理由两条:
|
|
227
|
+
// ① `active-runs` 是按 sessionId 键的,**没有「这个项目还有别的活会话吗」这个
|
|
228
|
+
// 查询**。同一个项目同时开两个会话是常见的(用户经常这么用),照着关就是把
|
|
229
|
+
// 另一个会话正在看的页面关掉。
|
|
230
|
+
// ② 5 分钟空闲回收已经覆盖了"人走了"这件事,而且它按**实际使用**判,不按会话
|
|
231
|
+
// 生命周期判 —— 后者跟"浏览器还有没有人要用"其实不是一回事。
|
|
232
|
+
// 与其造一个用不上的导出摆在这儿(这个仓库有过"全仓无人写入的字段假装第一优先级"
|
|
233
|
+
// 那种账),不如写清为什么没有。
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 把某个项目的浏览器**钉住**:期间它不空闲、不会被 LRU 挤掉、不会被空闲计时器关。
|
|
237
|
+
*
|
|
238
|
+
* 为什么需要(审查抓到的):`browser_request_help` 是在 `withBrowser` **外面**等人的
|
|
239
|
+
* (它必须放开 mutex,否则人在窗里点的每一下都会排在 agent 这次调用后面 = 死锁)。
|
|
240
|
+
* 于是等人的那 120 秒里 `busy` 是 false、`lastUsed` 不动 —— `evictOne` 正好挑
|
|
241
|
+
* "最久没用的空闲实例",它就是那个**人正在过验证码的浏览器**。
|
|
242
|
+
*
|
|
243
|
+
* @returns {() => void} 松手(幂等)
|
|
244
|
+
*/
|
|
245
|
+
export function hold(projectId, why = 'held') {
|
|
246
|
+
const entry = live.get(projectId);
|
|
247
|
+
if (!entry) return () => {};
|
|
248
|
+
entry.busy = true;
|
|
249
|
+
entry.holds = (entry.holds || 0) + 1;
|
|
250
|
+
touch(entry);
|
|
251
|
+
let released = false;
|
|
252
|
+
return () => {
|
|
253
|
+
if (released) return;
|
|
254
|
+
released = true;
|
|
255
|
+
const e = live.get(projectId);
|
|
256
|
+
if (!e) return;
|
|
257
|
+
e.holds = Math.max(0, (e.holds || 1) - 1);
|
|
258
|
+
if (!e.holds) e.busy = false;
|
|
259
|
+
touch(e); // 松手时重新起计时,别让它带着旧计时器立刻被回收
|
|
260
|
+
console.log(`[browse] ${projectId} 松手(${why})`);
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 人在窗里动了一下 → 重置空闲计时。
|
|
266
|
+
* 没有这条的话:人安静看五分钟,浏览器在他眼前被关掉(空闲计时只认 agent 的调用)。
|
|
267
|
+
*/
|
|
268
|
+
export function touchProject(projectId) {
|
|
269
|
+
const entry = live.get(projectId);
|
|
270
|
+
if (entry) touch(entry);
|
|
271
|
+
return !!entry;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 刷新页面之后拿回「现在有没有浏览器 / agent 是不是正举着手」。
|
|
276
|
+
* 求助 banner 原来只靠一次性的 WS 事件,刷新即失传 —— agent 在那儿等两分钟没人知道。
|
|
277
|
+
*/
|
|
278
|
+
export function browseState(projectId) {
|
|
279
|
+
const e = live.get(projectId);
|
|
280
|
+
return {
|
|
281
|
+
live: !!e,
|
|
282
|
+
url: e ? e.page.url() : null,
|
|
283
|
+
busy: e ? !!e.busy : false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 拿到某项目**已经在跑**的浏览器(不懒启动)。
|
|
289
|
+
* 给 WS 画面通道用:用户开窗时如果 agent 还没开始浏览,就该显示"还没开始",
|
|
290
|
+
* 而不是替 agent 起一个浏览器(那会让 1 vCPU 上的常驻名额被看客占掉)。
|
|
291
|
+
*/
|
|
292
|
+
export function peek(projectId) {
|
|
293
|
+
const e = live.get(projectId);
|
|
294
|
+
return e ? { page: e.page, context: e.context, guard: e.guard } : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 给体检/日志用 */
|
|
298
|
+
export function status() {
|
|
299
|
+
return [...live.values()].map(e => ({
|
|
300
|
+
projectId: e.projectId,
|
|
301
|
+
url: (() => { try { return e.page.url(); } catch { return '(gone)'; } })(),
|
|
302
|
+
idleMs: Date.now() - e.lastUsed,
|
|
303
|
+
busy: e.busy,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const _limits = { MAX_RESIDENT, IDLE_MS, NAV_TIMEOUT_MS, VIEWPORT };
|