// collector — hash 差異偵測 collector(SDD ingest-hash-trigger,Go 版骨架)。 // // 用法: // // collector scan --root <知識資料夾> --manifest \ // [--max-removed-ratio 0.4] [--dry-run] // // 一次掃描:走訪 root、對照 manifest、把事件(符合 // schemas/collector-trigger.v1.schema.json)以 JSON 輸出到 stdout,並更新 manifest // (--dry-run 不寫)。daemon 常駐/launchd/R2 上傳/打 named-webhook=之後的 task, // 本階段不接網路。 package main import ( "encoding/json" "flag" "fmt" "os" "path/filepath" ) func main() { if len(os.Args) < 2 || os.Args[1] != "scan" { fmt.Fprintln(os.Stderr, "用法: collector scan --root --manifest [--max-removed-ratio 0.4] [--dry-run]") os.Exit(2) } fs := flag.NewFlagSet("scan", flag.ExitOnError) root := fs.String("root", "", "知識資料夾根路徑(必填)") manifestPath := fs.String("manifest", "", "manifest JSON 檔路徑(必填;不存在會建新)") ratio := fs.Float64("max-removed-ratio", DefaultMaxRemovedRatio, "大量刪除防呆門檻(removed 數 > manifest 條目 × 本值 → 全部不下架、只發警告)") dryRun := fs.Bool("dry-run", false, "只輸出事件,不更新 manifest") if err := fs.Parse(os.Args[2:]); err != nil { os.Exit(2) } if *root == "" || *manifestPath == "" { fmt.Fprintln(os.Stderr, "錯誤:--root 與 --manifest 皆為必填") os.Exit(2) } absRoot, err := filepath.Abs(*root) if err != nil { fatal(err) } absManifest, err := filepath.Abs(*manifestPath) if err != nil { fatal(err) } m, err := LoadManifest(absManifest, absRoot) if err != nil { fatal(err) } payload, err := Scan(absRoot, m, ScanOptions{ MaxRemovedRatio: *ratio, SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己 }) if err != nil { fatal(err) } if !*dryRun { if err := m.Save(absManifest); err != nil { fatal(err) } } out, err := json.MarshalIndent(payload, "", " ") if err != nil { fatal(err) } fmt.Println(string(out)) } func fatal(err error) { fmt.Fprintln(os.Stderr, "collector:", err) os.Exit(1) }