Files
Cyrene_v2/internal/capability/browser/shot.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

99 lines
3.7 KiB
Go
Raw 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
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
}