- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
99 lines
3.7 KiB
Go
99 lines
3.7 KiB
Go
package browser
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"image"
|
||
_ "image/jpeg" // 注册 JPEG 解码器:截图的宽高要如实报,不能靠猜
|
||
_ "image/png" // 注册 PNG 解码器
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// 截图落盘的具名常量。
|
||
const (
|
||
// shotFileMode 是截图文件的权限:0600。截图里有页面内容,可能是登录态、二维码、
|
||
// 订单号 —— 它按凭据对待(design/28 §4 第 3 条)。
|
||
shotFileMode os.FileMode = 0o600
|
||
|
||
// shotDirMode 是截图目录的权限:0700(只有本用户可进)。
|
||
shotDirMode os.FileMode = 0o700
|
||
|
||
// shotPrefix 是截图文件名前缀,便于人工在 `$DATA_DIR/browser/shots/` 里辨认。
|
||
shotPrefix = "shot-"
|
||
|
||
// shotExtPNG 与 shotExtJPEG 是两种落盘扩展名,也是「实际用了哪种编码」的凭证。
|
||
shotExtPNG = "png"
|
||
shotExtJPEG = "jpeg"
|
||
)
|
||
|
||
// Shot 是一次截图的落盘结论:**路径 + 指纹 + 真实尺寸**,供 vision/image-crop
|
||
// 循既有路径进上下文,也供面板按路径展示(B4)。
|
||
type Shot struct {
|
||
// Path 是落盘的绝对路径(0600)。它是闭环入口:vision 工具按本地路径读它。
|
||
Path string `json:"path"`
|
||
// Bytes 是落盘字节数(如实,不含任何缩放)。
|
||
Bytes int `json:"bytes"`
|
||
// SHA256 是内容指纹的十六进制全串(对齐 reference/browser-stealth/07 §6 的字段口径)。
|
||
SHA256 string `json:"sha256"`
|
||
// Format 是实际使用的编码("png",或超上限降级后的 "jpeg")。
|
||
Format string `json:"format"`
|
||
// Width / Height 是从字节里解出来的**真实**像素尺寸;解不出来时为 0 且
|
||
// DimensionsKnown=false(不编造数字)。
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
// DimensionsKnown 报告宽高是否可信(false ⇒ 字节不是本函数认识的图像格式)。
|
||
DimensionsKnown bool `json:"dimensionsKnown"`
|
||
// Degraded 报告这张图是否是「PNG 超上限、降级成 JPEG」的产物(如实标注)。
|
||
Degraded bool `json:"degraded"`
|
||
}
|
||
|
||
// saveShot 把截图字节写进 dir(0700),文件 0600,返回结论。
|
||
//
|
||
// 文件名 = `shot-<36 进制纳秒时间戳>.<ext>`:时间戳是的的确确的(便于按时间排序),
|
||
// 而**不含任何 URL/站点信息** —— 截图路径会进上下文与日志,站点名本身就是信息
|
||
// (对齐 design/28 §4「审计只记必要项」)。
|
||
func saveShot(dir string, data []byte, format string) (Shot, error) {
|
||
if len(data) == 0 {
|
||
return Shot{}, fmt.Errorf("browser: 截图字节为空")
|
||
}
|
||
if err := os.MkdirAll(dir, shotDirMode); err != nil {
|
||
return Shot{}, fmt.Errorf("browser: 建截图目录 %s: %w", dir, err)
|
||
}
|
||
ext := shotExtPNG
|
||
if format == shotExtJPEG {
|
||
ext = shotExtJPEG
|
||
}
|
||
name := shotPrefix + strconv.FormatInt(time.Now().UnixNano(), 36) + "." + ext
|
||
path := filepath.Join(dir, name)
|
||
if err := os.WriteFile(path, data, shotFileMode); err != nil {
|
||
return Shot{}, fmt.Errorf("browser: 写截图 %s: %w", path, err)
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
width, height, known := imageSize(data)
|
||
return Shot{
|
||
Path: path,
|
||
Bytes: len(data),
|
||
SHA256: hex.EncodeToString(sum[:]),
|
||
Format: ext,
|
||
Width: width,
|
||
Height: height,
|
||
DimensionsKnown: known,
|
||
Degraded: ext == shotExtJPEG,
|
||
}, nil
|
||
}
|
||
|
||
// imageSize 从字节里解出像素宽高。解不出来返回 (0, 0, false) —— 如实标注未知,
|
||
// 而不是拿配置里的视口尺寸冒充(那是「我们以为的」,不是「图里的」)。
|
||
func imageSize(data []byte) (int, int, bool) {
|
||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||
return 0, 0, false
|
||
}
|
||
return cfg.Width, cfg.Height, true
|
||
}
|