feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
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
}