// 内容监控核心:规范化 → 哈希 → 差异计算。 // // 设计目标:判断"接口返回的数据是否发生变化",而不是"服务是否可用"。 // 因此必须先区分两种情况: // - 采集异常(状态码非 2xx、空响应、错误页)→ 不应报"内容变更" // - 内容变更(拿到了有效数据但与基线不同)→ 才告警 // // 规范化是关键:时间戳、nonce、trace_id 这类每次必变的字段必须先剔除, // 否则会产生大量误报。 // Logan.Li package siteprobe import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "regexp" "sort" "strconv" "strings" ) // ContentCheckMode 内容检查模式。 // Logan.Li const ( ContentModeOff = "off" ContentModeText = "text" ContentModeJSON = "json" ) // PathValue JSONPath 提取出的单个值。 // Logan.Li type PathValue struct { Path string Value any } // ContentSnapshot 规范化后的内容快照。 // Logan.Li type ContentSnapshot struct { // Canonical 用于计算哈希的规范化文本 Canonical string // Hash 规范化文本的 sha256 Hash string // Mode 产生该快照的模式 Mode string // Invalid 非空表示采集异常(此时不应参与变更比对) Invalid string } // BuildSnapshot 按任务配置把原始响应体规范化为可哈希的快照。 // // 返回 Invalid 非空表示"采集异常",调用方应将其视为拨测失败而不是内容变更。 // Logan.Li func BuildSnapshot(mode, body string, paths, ignoreRules []string, minLength int, sortLines bool) ContentSnapshot { snap := ContentSnapshot{Mode: mode} if mode == ContentModeOff { snap.Invalid = "内容检查未启用" return snap } trimmed := strings.TrimSpace(body) if trimmed == "" { snap.Invalid = "响应体为空" return snap } // 最小长度用于挡住 CDN 拦截页、网关错误页这类"200 但内容不是数据"的情况。 // Logan.Li if minLength > 0 && len(trimmed) < minLength { snap.Invalid = fmt.Sprintf("响应体长度 %d 小于最小有效长度 %d", len(trimmed), minLength) return snap } var canonical string var err error switch mode { case ContentModeJSON: canonical, err = normalizeJSON(trimmed, paths, ignoreRules) default: canonical = normalizeText(trimmed, ignoreRules, sortLines) } if err != nil { snap.Invalid = "内容规范化失败: " + err.Error() return snap } if strings.TrimSpace(canonical) == "" { snap.Invalid = "规范化后内容为空" return snap } snap.Canonical = canonical sum := sha256.Sum256([]byte(canonical)) snap.Hash = hex.EncodeToString(sum[:]) return snap } // normalizeText 文本模式规范化: // 1. 统一换行、逐行去首尾空白、丢弃空行 // 2. 按忽略规则做正则替换(时间戳/nonce 等) // 3. 可选排序(顺序无关的清单类接口,如 IP 段列表) // Logan.Li func normalizeText(body string, ignoreRules []string, sortLines bool) string { body = strings.ReplaceAll(body, "\r\n", "\n") body = strings.ReplaceAll(body, "\r", "\n") for _, rule := range ignoreRules { rule = strings.TrimSpace(rule) if rule == "" { continue } if re, err := regexp.Compile(rule); err == nil { body = re.ReplaceAllString(body, "") } } lines := make([]string, 0, 64) for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" { continue } lines = append(lines, line) } if sortLines { sort.Strings(lines) } return strings.Join(lines, "\n") } // normalizeJSON JSON 模式规范化:只保留 JSONPath 指定的字段,其余一律不参与比对。 // // 这比"忽略规则"更可靠——没被点名的字段根本进不了哈希。 // Logan.Li func normalizeJSON(body string, paths, ignoreRules []string) (string, error) { var root any if err := json.Unmarshal([]byte(body), &root); err != nil { return "", fmt.Errorf("非法 JSON: %w", err) } if len(paths) == 0 { return "", fmt.Errorf("JSON 模式必须至少配置一个 JSONPath") } flat := map[string]any{} for _, p := range paths { p = strings.TrimSpace(p) if p == "" { continue } values, err := EvalPath(root, p) if err != nil { // 字段不存在不算致命错误,但要体现在快照里, // 这样"字段从有到无"也能被识别为变更。 // Logan.Li flat[p] = "" continue } if len(values) == 1 { flat[p] = scrubValue(values[0], ignoreRules) } else { list := make([]any, 0, len(values)) for _, v := range values { list = append(list, scrubValue(v, ignoreRules)) } flat[p] = list } } // json.Marshal 对 map 的 key 自动排序,天然得到稳定输出。 // Logan.Li out, err := json.Marshal(flat) if err != nil { return "", err } return string(out), nil } // scrubValue 对提取出的字段值应用忽略规则(字符串值按正则替换)。 // Logan.Li func scrubValue(v any, ignoreRules []string) any { s, ok := v.(string) if !ok || len(ignoreRules) == 0 { return v } for _, rule := range ignoreRules { rule = strings.TrimSpace(rule) if rule == "" { continue } if re, err := regexp.Compile(rule); err == nil { s = re.ReplaceAllString(s, "") } } return s } // ─────────────────────── JSONPath(轻量实现)─────────────────────── // // 支持语法(覆盖绝大多数接口监控场景): // $.data.status 取字段 // $.data.ips[0] 取数组下标 // $.data.ips[*].ip 通配遍历数组 // $[0] 根为数组时取下标 // // 不支持过滤表达式 [?()]、递归下降 ..、切片 [1:3]。 // 刻意不引第三方库:实现只有几十行,且避免为一个小功能增加依赖风险。 // Logan.Li // EvalPath 计算 JSONPath,返回匹配到的所有值。 // Logan.Li func EvalPath(root any, path string) ([]any, error) { segs, err := parsePath(path) if err != nil { return nil, err } current := []any{root} for _, seg := range segs { next := make([]any, 0, len(current)) for _, node := range current { switch seg.kind { case segField: if m, ok := node.(map[string]any); ok { if v, exists := m[seg.name]; exists { next = append(next, v) } } case segIndex: if arr, ok := node.([]any); ok && seg.index >= 0 && seg.index < len(arr) { next = append(next, arr[seg.index]) } case segWildcard: if arr, ok := node.([]any); ok { next = append(next, arr...) } else if m, ok := node.(map[string]any); ok { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { next = append(next, m[k]) } } } } if len(next) == 0 { return nil, fmt.Errorf("路径未命中: %s", path) } current = next } return current, nil } const ( segField = iota segIndex segWildcard ) type pathSegment struct { kind int name string index int } // parsePath 把 "$.a.b[0].c" 解析成段序列。 // Logan.Li func parsePath(path string) ([]pathSegment, error) { p := strings.TrimSpace(path) if p == "" { return nil, fmt.Errorf("JSONPath 为空") } if strings.HasPrefix(p, "$") { p = p[1:] } segs := []pathSegment{} i := 0 for i < len(p) { switch p[i] { case '.': i++ start := i for i < len(p) && p[i] != '.' && p[i] != '[' { i++ } if i > start { segs = append(segs, pathSegment{kind: segField, name: p[start:i]}) } case '[': end := strings.IndexByte(p[i:], ']') if end < 0 { return nil, fmt.Errorf("JSONPath 方括号未闭合: %s", path) } inner := strings.TrimSpace(p[i+1 : i+end]) if inner == "*" { segs = append(segs, pathSegment{kind: segWildcard}) } else if n, err := strconv.Atoi(inner); err == nil { segs = append(segs, pathSegment{kind: segIndex, index: n}) } else { // ["key"] / ['key'] 写法 inner = strings.Trim(inner, `"'`) segs = append(segs, pathSegment{kind: segField, name: inner}) } i += end + 1 default: // 省略前导点的写法(如 "data.status") start := i for i < len(p) && p[i] != '.' && p[i] != '[' { i++ } segs = append(segs, pathSegment{kind: segField, name: p[start:i]}) } } if len(segs) == 0 { return nil, fmt.Errorf("无效 JSONPath: %s", path) } return segs, nil } // ─────────────────────── 差异计算 ─────────────────────── // TextDiffLine 文本行级差异(供并排视图渲染)。 // Logan.Li type TextDiffLine struct { Type string `json:"type"` // same / add / del Old string `json:"old,omitempty"` New string `json:"new,omitempty"` } // FieldDiff 字段级差异(JSON 模式)。 // Logan.Li type FieldDiff struct { Path string `json:"path"` Old string `json:"old"` New string `json:"new"` Kind string `json:"kind"` // changed / added / removed } // DiffText 基于 LCS 的行级差异,规模受行数平方限制, // 对接口响应这种量级(通常几十到几千行)足够。 // Logan.Li func DiffText(oldText, newText string) []TextDiffLine { oldLines := splitLines(oldText) newLines := splitLines(newText) n, m := len(oldLines), len(newLines) // 行数过大时退化为整体替换,避免 O(n*m) 内存爆掉。 // Logan.Li if n*m > 4_000_000 { out := make([]TextDiffLine, 0, n+m) for _, l := range oldLines { out = append(out, TextDiffLine{Type: "del", Old: l}) } for _, l := range newLines { out = append(out, TextDiffLine{Type: "add", New: l}) } return out } lcs := make([][]int, n+1) for i := range lcs { lcs[i] = make([]int, m+1) } for i := n - 1; i >= 0; i-- { for j := m - 1; j >= 0; j-- { if oldLines[i] == newLines[j] { lcs[i][j] = lcs[i+1][j+1] + 1 } else if lcs[i+1][j] >= lcs[i][j+1] { lcs[i][j] = lcs[i+1][j] } else { lcs[i][j] = lcs[i][j+1] } } } out := []TextDiffLine{} i, j := 0, 0 for i < n && j < m { switch { case oldLines[i] == newLines[j]: out = append(out, TextDiffLine{Type: "same", Old: oldLines[i], New: newLines[j]}) i++ j++ case lcs[i+1][j] >= lcs[i][j+1]: out = append(out, TextDiffLine{Type: "del", Old: oldLines[i]}) i++ default: out = append(out, TextDiffLine{Type: "add", New: newLines[j]}) j++ } } for ; i < n; i++ { out = append(out, TextDiffLine{Type: "del", Old: oldLines[i]}) } for ; j < m; j++ { out = append(out, TextDiffLine{Type: "add", New: newLines[j]}) } return out } // DiffJSONFields 字段级差异:两份规范化 JSON 逐 key 比对。 // Logan.Li func DiffJSONFields(oldText, newText string) []FieldDiff { oldMap := map[string]any{} newMap := map[string]any{} _ = json.Unmarshal([]byte(oldText), &oldMap) _ = json.Unmarshal([]byte(newText), &newMap) keys := map[string]struct{}{} for k := range oldMap { keys[k] = struct{}{} } for k := range newMap { keys[k] = struct{}{} } sorted := make([]string, 0, len(keys)) for k := range keys { sorted = append(sorted, k) } sort.Strings(sorted) out := []FieldDiff{} for _, k := range sorted { ov, oOK := oldMap[k] nv, nOK := newMap[k] os := stringifyValue(ov) ns := stringifyValue(nv) switch { case oOK && !nOK: out = append(out, FieldDiff{Path: k, Old: os, New: "", Kind: "removed"}) case !oOK && nOK: out = append(out, FieldDiff{Path: k, Old: "", New: ns, Kind: "added"}) case os != ns: out = append(out, FieldDiff{Path: k, Old: os, New: ns, Kind: "changed"}) } } return out } func splitLines(s string) []string { s = strings.ReplaceAll(s, "\r\n", "\n") if s == "" { return nil } return strings.Split(s, "\n") } // stringifyValue 把任意值转成便于展示的字符串。 // Logan.Li func stringifyValue(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } b, err := json.Marshal(v) if err != nil { return fmt.Sprintf("%v", v) } return string(b) } // ParseRules 把前端传来的 JSON 数组或换行分隔文本解析成规则列表, // 兼容用户直接粘贴多行正则的写法。 // Logan.Li func ParseRules(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } var arr []string if strings.HasPrefix(raw, "[") && json.Unmarshal([]byte(raw), &arr) == nil { return arr } out := []string{} for _, line := range strings.Split(raw, "\n") { if line = strings.TrimSpace(line); line != "" { out = append(out, line) } } return out }