Files
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
依据 `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 整链路(未碰)。
2026-10-07 11:18:25 +08:00

366 lines
14 KiB
Go
Raw Permalink 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(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
}