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 进制纳秒时间戳>.`:时间戳是的的确确的(便于按时间排序), // 而**不含任何 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 }