package browser import ( "context" "errors" "fmt" "log/slog" "os" "os/exec" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" ) // 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。 const ( // portPollInterval 是「等调试端点就绪」的轮询间隔:冷启动通常 0.3~1.5s, // 100ms 的粒度既不迟钝也不白烧 CPU。 portPollInterval = 100 * time.Millisecond // defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐 // (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json) // 尚未开放语言项 —— 见本片报告「未做」。 defaultLang = "zh-CN" ) // endpointProbeTimeout 是一次端点探活的超时:「要不要复用已在跑的实例」与「本次启动就绪了 // 没有」两处共用同一档。它先问「端口有没有在听」,再问 `/json/version` 答不答话,两问都给得 // 短 —— 前者是启动前的判定,后者是就绪轮询里的一次尝试,都不该拖住启动路径。 const endpointProbeTimeout = 2 * time.Second // 启动失败的**分类标记**:几种情形必须在错误消息里一眼可分(design/28 §7 判据 4 // 「如实标注」/ §6 坑 5「失败要如实报」)—— // // ① 浏览器进程自己退了(参数被拒 / 委托给已跑实例 / profile 锁被占); // ② 我们自己的等待窗口用完(**我们选的那个**调试端口一直没答上 CDP); // ③ **调用方的预算先没了**:浏览器没坏,只是没时间了; // ④ 端口在听、但不是可用的 CDP 端点(握手失败)—— 它既不是「进程退出」也不是「端口没出现」, // 必须单独说,因为它有**自己的**处置方式(见 endpointBrokenHint)。这一条有三个入口: // 启动**前**探到旧记录指向坏端点、启动**中**发现选的端口被别人占了、以及会话就绪后的 // 复用判定(都在本包内共用同一个标记)。 // // 哨兵仍然只有 ErrLaunch 一个(`errors.Is` 口径不变),这些标记是消息里可机读的那一段。 const ( // launchMarkerProcessExited 标记 ①。 launchMarkerProcessExited = "浏览器进程立刻退出" // launchMarkerPortTimeout 标记 ②。 launchMarkerPortTimeout = "等调试端口就绪超时" // launchMarkerBudget 标记 ③。 launchMarkerBudget = "调用方预算耗尽" // launchMarkerEndpointBroken 标记 ④:端口在听却握不上手。它与「陈记录」(端口没人听) // 是**两回事**:陈记录可以安全清掉后照常启动,这里只能如实报 —— 静默忽略它去另起一个 // 进程,换来的只是「委托给已跑实例后立刻退出」,把原因糊掉。 launchMarkerEndpointBroken = "调试端口在听但不是可用的 CDP 端点" ) const ( // chromeOutputDrainWait 是「进程退出/被杀之后,把它已经写进管道的输出收干净」的 // 等待上限:诊断不该反过来拖住失败路径。 chromeOutputDrainWait = 500 * time.Millisecond // launchDeadlineSlack 容忍「调用方的截止时间与我们这一次等待的截止时间几乎同时」 // 这种不可分情形:差值在它之内时按**我们自己等端口超时**报(此刻的事实正是这一次 // 等待用满了配置的 timeout,真机常态如此:外层 openSession 与本次等待同源同值)。 launchDeadlineSlack = 250 * time.Millisecond ) // dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把 // 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。 type dialer interface { // Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。 Dial(ctx context.Context) (endpoint string, stop func(), err error) // Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。 Describe() string // Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾 // 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。 Local() bool } // localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。 // // 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地,并经 // `DevDocs/reference/chromium-cdp/` 的官方源码 + 本机实测修订了两处(见 portrecord.go 的文件头): // - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、 // `--no-default-browser-check`、`--remote-debugging-port=<我们选的显式端口>`; // - **不传**:`--disable-blink-features=AutomationControlled`(显式端口下它对 // `navigator.webdriver` 已是空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒ // 每次有头启动弹一条「不受支持的命令行标记」信息栏)、 // `--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、 // `--disable-extensions` / `--disable-default-apps` / // `--disable-component-extensions-with-background-pages` / `--disable-component-update` / // `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、 // 以及一切 `--headless*`(有头就是不能有它)。 // // 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍: // 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows // 上不支持**;何况官方源码里 pipe 与 `--headless` 一样会打开 `AutomationControlled` // (reference/chromium-cdp/02 §4.1),换它并不能解决问题。因此 Windows 主路径用 // `--remote-debugging-port=<显式端口>`:**仍是我们出站连它**,不开任何对外监听端口 // (AGENTS §9;端口由内核分配号、Chromium 自己 bind 在 127.0.0.1,见 portrecord.go)。 type localChrome struct { exe string profileDir string headless bool viewportW int viewportH int timeout time.Duration log *slog.Logger } // Describe 实现 dialer。 func (l *localChrome) Describe() string { mode := "headful" if l.headless { mode = "headless" } return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode) } // Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。 func (l *localChrome) Local() bool { return true } // Dial 拉起浏览器并等调试端点就绪。任何一步失败都返回带分类原因的 ErrLaunch。 // // 四条**先于启动**的判定(前三条都是为了让「上一次会话留下的浏览器」不至于把功能卡死): // // 1. **预算已耗尽就不起进程**:ctx 已经结束(这一轮的工具预算用完 / 会话被取消)时 // 立刻返回「预算耗尽」分类,**不 spawn** —— 起了也只是生一个会被马上杀掉的窗口, // 并把「没时间了」伪装成「浏览器起不来」(真机踩过:窗口上跳出浏览器的抱怨,日志 // 里却像浏览器坏了)。判定做在**启动之前**,因为它要省掉的正是那次启动。 // 2. **能复用就复用**(`probeExistingEndpoint`):这个 profile 上记的调试端口还在 // (`cyrene-debug-port`,或老二进制留下的 `DevToolsActivePort`)、端口**确实在听**、 // `/json/version` 也**真有合法响应** ⇒ 直接出站连它,**不再起第二个**。 // 这不是优化,是**正确性**:同一个 user-data-dir 上起第二个实例时 Chromium 会把手上的 // 命令**委托给已在跑的那个**然后自己退出(真机实测:新进程 exit 0、不写新的端口记录, // stderr 只有一句「正在现有的浏览器会话中打开。」),于是新进程永远不就绪 ⇒ 不复用的话, // 这一档会一直报启动失败,直到人工杀掉那个进程。复用还正好贴合 design/28 §2「profile // 长期保留、任务间复用」。 // 3. **端口在听、却握不上手 ⇒ 如实报错**(`launchMarkerEndpointBroken`):绝不「忽略掉它、 // 自己起一个」—— 占着这个端口的多半就是同一个 profile 的浏览器,起进程只会让新进程委托 // 出去后立刻退出,把一句本就查不明白的失败再糊一层。 // 4. **陈的记录先清掉**(`clearStalePortRecords`):只在真的要走启动路径时才清;不清的话, // 上一轮的端口会被当成本次可复用的端点(理由见那个方法的注释)。 // // 起进程与「等它就绪」由 launchWithRetry(含一次换端口重试)与 launchOnce 承担;子进程的 // stdout/stderr **接进缓冲**(`chromeOutput`):启动失败时把浏览器自己的话与本次 argv 一起 // 报进**日志**,并把浏览器原话附进**返回的错误**(`withChromeOutput`)—— 用户侧看到的工具 // 结果就是从这条错误来的。成功时只报字节数(Debug),不把 Chrome 的常规输出刷进日志。 // // 停止函数只对**本次启动**的进程有效(复用来的实例不归我们杀):进程退出后 Chrome 的 // 渲染进程随之退出,因此进程级 Kill 足够。 func (l *localChrome) Dial(ctx context.Context) (string, func(), error) { if strings.TrimSpace(l.exe) == "" { return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch) } if err := ctx.Err(); err != nil { return "", nil, budgetLaunchErr(ctx) } endpoint, err := l.probeExistingEndpoint(ctx) if err != nil { // 复用不了、又不许起第二个:如实报(错误里已带处置建议),一条 WARN 留现场。 l.log.Warn("browser launch blocked by unusable debug endpoint", "profile", l.profileDir, "error", err) return "", nil, err } if endpoint != "" { l.log.Info("browser debug endpoint reused", "endpoint", endpoint, "profile", l.profileDir, "detail", "复用已存在的调试端点(同 profile 的浏览器仍在跑,不再起第二个)") return endpoint, func() {}, nil } if err := os.MkdirAll(l.profileDir, 0o700); err != nil { return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err) } if err := l.clearStalePortRecords(); err != nil { return "", nil, err } return l.launchWithRetry(ctx) } // launchWithRetry 起浏览器,并把**本次实际用的那个端口**记进 profile 目录的端口记录 // (`cyrene-debug-port`,见 portrecord.go:显式端口下 Chromium 不写 DevToolsActivePort, // 端口只能我们自己记 —— 记不下就等于下一次启动无法复用,所以写失败按硬失败报,并把刚起的 // 进程收掉,不留孤儿)。 // // 只在一种情形下**换端口**重试:证据表明我们选的端口被别人占了(`portTakenError`,即就绪 // 等待期间那个端口在听、却答不上 CDP)。那是「listen(:0) 取号」与「Chromium bind」之间的 // TOCTOU,换一个号就好,且**没有**碰任何不属于我们的进程。其余失败一律如实上报 —— 无差别 // 重试只会把原因糊掉,还把预算烧在重复等待上。 func (l *localChrome) launchWithRetry(ctx context.Context) (string, func(), error) { var lastErr error for attempt := 0; attempt < launchPortAttempts; attempt++ { if err := ctx.Err(); err != nil { // 重试之前预算先没了:这一轮的事实是「没时间了」,如实报(不是端口的问题)。 return "", nil, budgetLaunchErr(ctx) } port, err := pickDebugPort(ctx) if err != nil { return "", nil, fmt.Errorf("%w: %v", ErrLaunch, err) } endpoint, stop, err := l.launchOnce(ctx, port) if err == nil { if werr := writePortRecord(l.profileDir, port); werr != nil { stop() return "", nil, fmt.Errorf("%w: %v", ErrLaunch, werr) } return endpoint, stop, nil } lastErr = err var taken *portTakenError if !errors.As(err, &taken) { return "", nil, err } l.log.Warn("browser launch retrying with another debug port", "port", port, "profile", l.profileDir, "error", err) } // 换端口也还是被别人抢:**如实报同一类**(端口在听但不是 CDP),不冒充别的失败。 // 这时 lastErr 里已带标记与处置建议,直接交回它,不另编一句更好听的。 return "", nil, lastErr } // launchOnce 起一次浏览器(用给定的显式端口)并等它就绪。失败时**自己收掉刚起的进程**, // 并把浏览器原话与本次 argv 报进日志、把原话附进返回的错误。 func (l *localChrome) launchOnce(ctx context.Context, port int) (string, func(), error) { args := l.launchArgs(port) cmd := exec.Command(l.exe, args...) out := &chromeOutput{} cmd.Stdout = out cmd.Stderr = out if err := cmd.Start(); err != nil { return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err) } exited := make(chan struct{}) go func() { defer close(exited) // 用 cmd.Wait(而不是 cmd.Process.Wait):它会等两个输出复制协程收工, // 于是 exited 关闭时缓冲里就是子进程说过的**全部**话(下面要在失败时上报它)。 _ = cmd.Wait() }() endpoint, err := l.waitDebugReady(ctx, port, exited) if err != nil { stopProcess(cmd) l.waitExited(exited) l.log.Warn("browser launch failed", "exe", l.exe, "argv", strings.Join(args, " "), "chrome_output", out.preview(), "chrome_output_bytes", out.totalBytes(), "error", err) return "", nil, withChromeOutput(err, out.preview()) } l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir, "chrome_output_bytes", out.totalBytes()) return endpoint, func() { stopProcess(cmd) }, nil } // waitExited 等子进程真正收尾(管道里的在途字节落进缓冲)再上报:Kill 之后 Wait 还要 // 一点点时间,不等就会漏掉最后几行 —— 而最后几行往往正是原因。等不到就照现有内容报, // 不把失败路径拖住。 func (l *localChrome) waitExited(exited <-chan struct{}) { timer := time.NewTimer(chromeOutputDrainWait) defer timer.Stop() select { case <-exited: case <-timer.C: } } // budgetLaunchErr 是 ③「调用方预算耗尽」的**唯一文案**。它必须读起来像「没时间了」, // 而不是像「浏览器坏了」:取消与截止时间到分别点明(两者都是调用方自己的事)。 func budgetLaunchErr(ctx context.Context) error { detail := "上下文截止时间到(这一轮的工具预算已耗尽)" if errors.Is(ctx.Err(), context.Canceled) { detail = "上下文被取消(会话/这一轮已结束)" } return fmt.Errorf("%w: %s:%s,浏览器未启动完成", ErrLaunch, launchMarkerBudget, detail) } // 启动前的三条判定与它们的处置文案在 adopt.go:复用活端点(probeExistingEndpoint)、 // 清掉陈的端口记录(clearStalePortRecords)、端点不可用时的如实上报(withChromeOutput 与两条 hint)。 // launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。port 是我们 // **已经选好**的显式调试端口(取号与记录见 portrecord.go)。 func (l *localChrome) launchArgs(port int) []string { args := []string{ // profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。 "--user-data-dir=" + l.profileDir, // **显式**端口(不是 `=0`):Chromium 把 `port==0` 当作 ChromeDriver 式自动化启动, // 会主动打开 Blink 的 AutomationControlled ⇒ `navigator.webdriver === true`(官方 // runtime_features.cc:389-404;实测 reference/chromium-cdp/07 的 g1 vs g7);显式端口 // 不被这样处理,因此这一条同时替掉了原来的 // `--disable-blink-features=AutomationControlled`(那个 flag 在显式端口下是空操作, // 却会命中官方 kBadFlags、每次有头启动弹一条「不受支持的命令行标记」信息栏)。 // 代价:Chromium 只在 port==0 时才写 /DevToolsActivePort,端口改由我们自己记。 fmt.Sprintf("--remote-debugging-port=%d", port), // 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝; // reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」, // 不开任何对外服务面。 "--remote-allow-origins=*", "--no-first-run", "--no-default-browser-check", fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH), "--lang=" + defaultLang, "about:blank", } if l.headless { // 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。 args = append([]string{"--headless=new"}, args...) } return args } // waitDebugReady 等**我们自己选的那个端口**真的成为可用的 CDP 端点(就绪判据从「端口文件 // 出现」换成了「端点答得上 `/json/version`」—— 显式端口下 Chromium 不再写端口文件,而且 // 这样判别的是**事实**而不是一条可能陈旧的记录)。三条出口都要**说清原因**,而且三者互不 // 冒充(design/28 §7 判据 4「如实标注」): // // ① 浏览器进程自己退了(多半是 profile 锁被占 / 浏览器已在运行)——错误里带 // launchMarkerProcessExited **与可操作的处置建议**(profileBusyHint);子进程说的原话 // 由 launchOnce 追加进这条错误(withChromeOutput),用户侧因此看得见 Chromium 那句 // 「正在现有的浏览器会话中打开。」; // ② 我们自己的等待窗口用完 —— 带 launchMarkerPortTimeout;若这期间那个端口**在听却答不上 // CDP**,说明它被别的进程抢走了(listen(:0) 取号与 Chromium bind 之间的 TOCTOU), // 改报 launchMarkerEndpointBroken 并交给 launchWithRetry 换端口重试(portTakenError); // ③ 调用方的预算先没了(ctx 结束)—— 带 launchMarkerBudget,**不**说浏览器坏。 // // ② 与 ③ 的分辨靠两者的截止时间:ctx 的截止时间**明显早于**我们这一次等待的截止时间 // 时,结束它的是调用方;几乎同时到点时按 ② 报(那是这一次等待真的用满了配置的 timeout, // 也是真机常态:外层 openSession 的预算与本次等待同源同值)。 // // 还有一条**不走这里**的出口:启动前探到「端口在听却握不上手」时,Dial 会在起进程之前就 // 报 launchMarkerEndpointBroken(见 adopt.go)—— 那种情形根本不该起这个进程。 func (l *localChrome) waitDebugReady(ctx context.Context, port int, exited <-chan struct{}) (string, error) { launchDeadline := time.Now().Add(l.timeout) deadline := time.NewTimer(l.timeout) defer deadline.Stop() ticker := time.NewTicker(portPollInterval) defer ticker.Stop() foreign := false for { switch state, _ := probeDebugEndpoint(ctx, port); state { case endpointReady: return endpointURL(port), nil case endpointForeign: foreign = true case endpointAbsent: } select { case <-exited: // 浏览器进程自己退了,端点不会再就绪 —— **立刻**报,不把超时白等完。 // 最常见的原因是同一 profile 已被另一个实例占用:Chromium 把命令**委托给已在跑的 // 那个**之后自己退出(真机实测 stderr 原话「正在现有的浏览器会话中打开。」)、新 // 进程不写端口记录。这里给可操作建议,但不碰别人的进程。 return "", fmt.Errorf("%w: %s。%s", ErrLaunch, launchMarkerProcessExited, profileBusyHint(l.profileDir)) case <-ctx.Done(): if dl, ok := ctx.Deadline(); !ok || dl.Add(launchDeadlineSlack).Before(launchDeadline) { return "", budgetLaunchErr(ctx) } return "", l.portTimeoutErr(port, foreign) case <-deadline.C: return "", l.portTimeoutErr(port, foreign) case <-ticker.C: } } } // portTimeoutErr 是 ②「我们自己的等待窗口用完」的唯一文案(两条出口共用,口径只有一处)。 // foreign=true 时改报「端口在听但不是可用的 CDP 端点」并带上换端口重试的标记:那是被别的 // 进程抢占的样子,与「浏览器起不来」是两回事。 func (l *localChrome) portTimeoutErr(port int, foreign bool) error { if foreign { return &portTakenError{err: fmt.Errorf( "%w: %s(%s):%s 在听,但一直没有答出可用的 CDP 响应(可能被别的进程抢走了端口)。%s", ErrLaunch, launchMarkerEndpointBroken, l.timeout, endpointURL(port), portTakenHint())} } return fmt.Errorf("%w: %s(%s):%s 一直没有应答", ErrLaunch, launchMarkerPortTimeout, l.timeout, endpointURL(port)) } // portTakenError 标记「这一次失败是因为我们选的端口被别人占了」:它只在**进程内**用来决定 // 「换一个端口再试一次」(错误文案仍是 launchMarkerEndpointBroken —— 与启动前探到坏端点 // 共用同一类,因为对用户来说都是「端口在听但不是 CDP」)。 type portTakenError struct{ err error } // Error 实现 error(文案由 portTimeoutErr 一次写清,这里不另编一句更好听的)。 func (e *portTakenError) Error() string { return e.err.Error() } // Unwrap 让 errors.Is / errors.As 仍能穿过这一层(ErrLaunch 口径不变)。 func (e *portTakenError) Unwrap() error { return e.err } // portTakenHint 是「我们选的端口被抢」的处置建议:重试即可(会自动换端口),但绝不代杀别人的进程。 func portTakenHint() string { return "处置:会自动换一个端口重试一次;若反复出现,先查是谁占着这个端口 —— " + "本代理不杀任何不属于自己的进程。" } // remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium, // 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。 type remoteCDP struct{ endpoint string } // Describe 实现 dialer。 func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint } // Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。 func (r *remoteCDP) Local() bool { return false } // Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。 func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) { endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/") if endpoint == "" { return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable) } var version struct { Browser string `json:"Browser"` } if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil { return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err) } return endpoint, func() {}, nil } // stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚, // 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。 // Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。 func stopProcess(cmd *exec.Cmd) { if cmd == nil || cmd.Process == nil { return } _ = cmd.Process.Kill() } // newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。 // 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。 func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer { if e := strings.TrimSpace(avail.Endpoint); e != "" { return &remoteCDP{endpoint: e} } return &localChrome{ exe: strings.TrimSpace(avail.Executable), profileDir: cfg.ProfileDir, headless: cfg.Headless, viewportW: cfg.ViewportWidth, viewportH: cfg.ViewportHeight, timeout: cfg.Timeout(), log: log, } }