Files
Cyrene_v2/internal/capability/browser/compare_test.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

366 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
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
}