package browser // 本文件是 `DevDocs/reference/browser-stealth/07-有头vs无头对照实测方案.md` 的**可复跑 // 落地版**:同一 URL、同一出口 IP、同一窗口,只改「有头 / 无头」这一个变量,对 // 「headless 被拦 / headful 通过」这句待验命题给出可证伪的读数。 // // 它**默认不跑**(没设 `CYRENE_BROWSER_COMPARE_URLS` 就 Skip):这是一次真的会开浏览器、 // 打外网站点的实验,不该混进普通 `go test`。 // // 用法(Windows / Git Bash): // // CYRENE_BROWSER_COMPARE_URLS="https://example.com/" \ // CYRENE_BROWSER_COMPARE_REPEATS=1 \ // CYRENE_BROWSER_COMPARE_ALLOW=example.com \ // go test ./internal/capability/browser/ -run TestHeadfulVsHeadlessCompare -v -timeout 20m // // 变量(**只服务本测试,不是配置键**,`.env` 冻结口径不受影响): // - `CYRENE_BROWSER_COMPARE_URLS`:逗号分隔的目标 URL;**必填**,否则 Skip。 // - `CYRENE_BROWSER_COMPARE_REPEATS`:交错重复次数(默认 1;reference/07 §5 建议 ≥3)。 // - `CYRENE_BROWSER_COMPARE_OUT`:跑批产物目录(默认落系统临时目录并打印路径)。 // - `CYRENE_BROWSER_COMPARE_ALLOW`:SSRF 白名单例外(测本机 httptest 服务时用)。 // // 判定口径照 reference/07 §3(多信号,不看单一状态码);记录字段照 §6。 // ⚠️ 结论规则(reference/07 §7):**A blocked & B pass 才算「有头在此站有意义」**; // 两边都 pass ⇒ 本站**未被证明**需要有头;两边都拦 ⇒ 如实登记失败,别假装成功。 import ( "context" "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sort" "strconv" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" ) const ( // compareGap 是**同一站两次请求之间**的最小间隔。它是 reference/07 §5 的**去 flake // 纪律**(别让 A/B 两次请求挨在一起,否则读到的是时序噪声),**不是**使用政策上的 // 限速 —— 主人 2026-10-06 定稿的「仅自用、不加人为限速」针对的是使用政策,两者不 // 冲突。想连续打就连着打,这里只是默认值。 compareGap = 5 * time.Second // compareNavTimeout 是单次导航的预算(冷启动 + 渲染 + 挑战页也要能落下来)。 compareNavTimeout = 45 * time.Second // compareSampleBytes 是抓回正文样本的上限(只用于正则判定与人工核对,不落全量 HTML)。 compareSampleBytes = 20000 ) // compareGroup 是一个对照臂。A = 无头(基准),B = 有头(被测变量)。 type compareGroup struct { Name string Headless bool } // compareGroups 返回对照组:reference/07 §1 的 A / B 两组(本落地版不做 C/D 两组 —— // C 需要预热 cookie,D 需要换 CDP 姿势,都属后续切片;见文件末尾「未做」)。 func compareGroups() []compareGroup { return []compareGroup{ {Name: "A", Headless: true}, {Name: "B", Headless: false}, } } // compareRecord 是一条记录(字段对齐 reference/07 §6 的 JSON)。 type compareRecord struct { TS string `json:"ts"` Group string `json:"group"` Mode string `json:"mode"` URL string `json:"url"` FinalURL string `json:"final_url"` Title string `json:"title"` LaunchFlags []string `json:"launch_flags"` CDPRuntimeEnable bool `json:"cdp_runtime_enable"` Driver string `json:"driver"` ChromeVersion string `json:"chrome_version"` UserDataDir string `json:"user_data_dir"` Headless bool `json:"headless"` Webdriver bool `json:"webdriver"` UA string `json:"ua"` UAHeadlessTell bool `json:"ua_headless_tell"` Timezone string `json:"timezone"` Language string `json:"language"` ScreenWidth int `json:"screen_width"` OuterWidth int `json:"outer_width"` HTTPStatus int `json:"http_status"` MatchedMarkers []string `json:"matched_markers"` BodyBytes int `json:"body_bytes"` ScreenshotPath string `json:"screenshot_path"` ScreenshotSHA256 string `json:"screenshot_sha256"` Verdict string `json:"verdict"` Note string `json:"note"` } // TestHeadfulVsHeadlessCompare 跑一次对照实验。默认 Skip(见文件顶部)。 func TestHeadfulVsHeadlessCompare(t *testing.T) { urls := splitList(os.Getenv("CYRENE_BROWSER_COMPARE_URLS")) if len(urls) == 0 { t.Skip("未设 CYRENE_BROWSER_COMPARE_URLS:对照脚本默认不跑(用法见本文件顶部)") } repeats := envInt("CYRENE_BROWSER_COMPARE_REPEATS", 1) outDir := strings.TrimSpace(os.Getenv("CYRENE_BROWSER_COMPARE_OUT")) if outDir == "" { outDir = filepath.Join(os.TempDir(), "cyrene-browser-compare") } runDir := filepath.Join(outDir, "run-"+time.Now().Format("20060102-150405")) if err := os.MkdirAll(runDir, 0o700); err != nil { t.Fatalf("建产物目录 %s: %v", runDir, err) } allow := splitList(os.Getenv("CYRENE_BROWSER_COMPARE_ALLOW")) log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) var records []compareRecord t.Logf("对照开始:urls=%v repeats=%d runDir=%s", urls, repeats, runDir) for _, raw := range urls { for repeat := 0; repeat < repeats; repeat++ { for _, g := range compareGroups() { rec := runCompareOnce(t, raw, g, repeat, runDir, allow, log) records = append(records, rec) t.Logf("%s", summarizeRecord(rec)) time.Sleep(compareGap) } } } writeRecords(t, runDir, records) t.Logf("汇总:\n%s", compareTable(records)) t.Logf("原始记录:%s", filepath.Join(runDir, "records.jsonl")) } // runCompareOnce 跑一臂一次:起浏览器 → 导航 → 采集 → 判定 → 截图落盘 → 关掉。 func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, runDir string, allow []string, log *slog.Logger) compareRecord { t.Helper() profile := filepath.Join(runDir, "profile-"+g.Name) drv := &localChrome{ exe: compareExecutable(t), profileDir: profile, headless: g.Headless, viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight, timeout: compareNavTimeout, log: log, } ctx, cancel := context.WithTimeout(context.Background(), compareNavTimeout*2) defer cancel() s, err := openSession(ctx, drv, configView{ profileDir: profile, shotsDir: runDir, headless: g.Headless, viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight, timeout: compareNavTimeout, idle: time.Minute, shotMaxBytes: browsercfg.DefaultScreenshotMaxBytes, }) if err != nil { t.Fatalf("%s 组第 %d 次起会话失败: %v", g.Name, repeat+1, err) } defer s.Close() rec := compareRecord{ TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL, Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(portOfEndpoint(t, s.endpoint)), CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0) Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint), UserDataDir: profile, Headless: g.Headless, } state, err := s.navigate(ctx, rawURL) if err != nil { rec.Verdict = "error" rec.Note = "导航失败:" + err.Error() return rec } rec.FinalURL, rec.Title = state.URL, state.Title fp, ferr := readFingerprint(ctx, s) if ferr == nil { rec.Webdriver, rec.Timezone, rec.Language = fp.Webdriver, fp.Timezone, fp.Language rec.ScreenWidth, rec.OuterWidth = fp.ScreenWidth, fp.OuterWidth // UA 是**有头与无头唯一必然不同**的那一位(reference/05:`--headless=new` 的 UA // 带 `HeadlessChrome` 段)。把它单独记成一个布尔,是为了让「有头抹掉了什么」 // 这件事从「人工看图」变成可机读的事实。 rec.UA = fp.UserAgent rec.UAHeadlessTell = strings.Contains(strings.ToLower(fp.UserAgent), "headless") } var page struct { HTMLBytes int `json:"htmlBytes"` Sample string `json:"sample"` Status int `json:"status"` } if perr := s.evaluate(ctx, pageSampleExpr(), &page); perr == nil { rec.BodyBytes, rec.HTTPStatus = page.HTMLBytes, page.Status rec.MatchedMarkers = matchedMarkers(page.Sample) } else { rec.Note = "页面采样失败:" + perr.Error() } rec.Verdict = verdictOf(rec) if data, err := s.captureScreenshot(ctx, "png", 0); err == nil { if shot, serr := saveShot(runDir, data, shotExtPNG); serr == nil { rec.ScreenshotPath, rec.ScreenshotSHA256 = shot.Path, shot.SHA256 } } if rec.Note == "" { rec.Note = "自动判定;结论前须人工看图(reference/07 §3)" } return rec } // pageSampleExpr 一次求值取回:文档字节数、正文样本、HTTP 状态。 // `performance.getEntriesByType('navigation')[0].responseStatus` 是 Chrome 109+ 的 // 导航响应状态;取不到就报 0(**不猜**)。 func pageSampleExpr() string { return fmt.Sprintf(`(() => { const nav = performance.getEntriesByType('navigation')[0]; const text = document.body ? (document.body.innerText || '') : ''; const html = document.documentElement ? document.documentElement.outerHTML : ''; return { htmlBytes: html.length, status: nav && nav.responseStatus ? nav.responseStatus : 0, sample: (document.title + '\n' + text).slice(0, %d) }; })()`, compareSampleBytes) } // blockMarkers 是 reference/07 §3 的判定标记表(大小写不敏感;小写存)。 func blockMarkers() []string { return []string{ "just a moment", "cf-chl", "__cf_chl", "/cdn-cgi/challenge-platform", "attention required", "captcha-delivery.com", "datadome", "access denied", "verifying you are human", "unusual traffic", "请求过于频繁", "访问被拒绝", "verify you are human", } } // matchedMarkers 返回样本里命中的拦截标记(去重、稳定顺序)。 func matchedMarkers(sample string) []string { lower := strings.ToLower(sample) var hits []string for _, m := range blockMarkers() { if strings.Contains(lower, m) { hits = append(hits, m) } } sort.Strings(hits) return hits } // verdictOf 按 reference/07 §3 的优先级给判定:强信号 ⇒ blocked/challenge; // 否则 pass。error 由调用方在失败路径上直接写入。 func verdictOf(rec compareRecord) string { switch rec.HTTPStatus { case 403, 429, 503: return "blocked" } if len(rec.MatchedMarkers) > 0 { for _, m := range rec.MatchedMarkers { if strings.Contains(m, "cf-chl") || strings.Contains(m, "challenge-platform") || m == "just a moment" { return "challenge" } } return "blocked" } if rec.BodyBytes <= 0 { return "error" } return "pass" } // compareExecutable 找到本次实验用的浏览器可执行文件(缺了就直接跳过整个测试)。 func compareExecutable(t *testing.T) string { t.Helper() exe, ok := browsercfg.NewProber().FindLocalBrowser() if !ok { t.Skip("本机没有 Chrome/Chromium,对照无法进行") } return exe } // chromeVersionOf 从 CDP 的 /json/version 读浏览器版本(记录用,失败留空不猜)。 func chromeVersionOf(t *testing.T, endpoint string) string { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var v struct { Browser string `json:"Browser"` } if err := httpGetJSON(ctx, endpoint+"/json/version", &v); err != nil { return "" } return v.Browser } // writeRecords 落 records.jsonl(0600:里面含目标 URL 与截图指纹)。 func writeRecords(t *testing.T, runDir string, records []compareRecord) { t.Helper() path := filepath.Join(runDir, "records.jsonl") f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { t.Fatalf("写 %s: %v", path, err) } defer func() { _ = f.Close() }() enc := json.NewEncoder(f) for _, rec := range records { if err := enc.Encode(rec); err != nil { t.Fatalf("编码记录: %v", err) } } } // summarizeRecord 是一条可读摘要(t.Log 用)。 func summarizeRecord(rec compareRecord) string { return fmt.Sprintf("[%s/%s] verdict=%s status=%d body=%d markers=%v webdriver=%v uaHeadlessTell=%v tz=%s screen=%d outer=%d", rec.Group, rec.Mode, rec.Verdict, rec.HTTPStatus, rec.BodyBytes, rec.MatchedMarkers, rec.Webdriver, rec.UAHeadlessTell, rec.Timezone, rec.ScreenWidth, rec.OuterWidth) } // compareTable 输出交付用的汇总表(组 × 判定 + 两件关键指纹事实)。 func compareTable(records []compareRecord) string { var b strings.Builder b.WriteString("group | mode | headless | webdriver | ua_headless | status | verdict | markers | body_bytes | shot_sha256\n") for _, rec := range records { sum := rec.ScreenshotSHA256 if len(sum) > 8 { sum = sum[:8] } b.WriteString(fmt.Sprintf("%-5s | %-8s | %-8v | %-9v | %-11v | %-6d | %-8s | %-7d | %-10d | %s\n", rec.Group, rec.Mode, rec.Headless, rec.Webdriver, rec.UAHeadlessTell, rec.HTTPStatus, rec.Verdict, len(rec.MatchedMarkers), rec.BodyBytes, sum)) } return b.String() } // modeName 把有头/无头映射成记录里的 mode 字段。 func modeName(headless bool) string { if headless { return "headless" } return "headful" } // splitList 拆逗号分隔的列表(去空白与空项)。 func splitList(raw string) []string { var out []string for _, part := range strings.Split(raw, ",") { if p := strings.TrimSpace(part); p != "" { out = append(out, p) } } return out } // envInt 读一个正整数环境变量;缺失/非法/≤0 时返回 def。 func envInt(key string, def int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return def } n, err := strconv.Atoi(raw) if err != nil || n <= 0 { return def } return n }