依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。
- `launch.go`:`--remote-debugging-port=<我们选的端口>` 替掉 `=0`;**删掉**
`--disable-blink-features=AutomationControlled`(注释写明理由)。
- 新增 `portrecord.go`:端口来源与落点的**唯一出处** —— 取空闲端口(`net.Listen`
取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录**
`<profile>/cyrene-debug-port`(选它与 Chrome 的 `DevToolsActivePort` 同目录:
天然按 profile 隔离、profile 换掉记录自动消失、内容只有端口号不含凭据);
**兼容老记录**(换二进制时上一版起的浏览器可能还在跑 ⇒ 认不出会把"自己的浏览器"
误报成"profile 被别人占了");就绪判据换成「我们选的那个端口答得上 `/json/version`」;
TOCTOU 处理 = 端口被占**换号重试一次**,重试仍失败则如实报错且不留记录。
- 三条认领路径(复用/清陈记录/如实报错保留现场)与三条失败分类(进程立刻退出 /
等端口超时 / 预算耗尽)**全部保留**;`ErrLaunch` + `errors.Is` 口径未变;
**绝不杀进程**红线未变。
真机自证(子代理跑,默认 Skip 的 `realchrome_test.go`,`CYRENE_BROWSER_CDP_E2E=1` 开闸):
`webdriver=false` · 命令行**无** flag/`--enable-automation`/`--headless` · UA 无
`HeadlessChrome`、`plugins=5`、`languages=zh-CN,zh`、`window.chrome` 在 ·
**UI Automation 控件树 17 个命名节点里没有「信息栏容器/信息栏」⇒ 无横幅** ·
第二次 Dial **复用同端口、浏览器主进程仍是同一个 PID** · 收尾按 profile 路径筛残留 = 0。
验收(主会话复跑 + 亲自变异):整树 `go build ./...` + 两包 vet 绿;browser 包测试绿;
我用 -overlay 把该 flag 加回参数表 ⇒ `TestLaunchArgs_HeadfulCarriesNoAutomationTraces`
变红(逐字报"不许传的 flag")。
⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
366 lines
14 KiB
Go
366 lines
14 KiB
Go
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
|
||
}
|