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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
1 parent
ea150ceea9
commit
f1fbc249fd
21 files changed
+3579
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user