Files
cmsp/internal/config/config.go
T
QiuSWandClaude Opus 5 6cc334ab92 fix: 取视频链路与 Python 参考实现对齐,识别风控降级 (#15)
淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码,
详情页里就是没有 mp4 地址。原实现识别不了,把它当成「这个同款没视频」,
继续跑满 20 个同款加重风控,最后写 video_status='none',
而断点规则会永久跳过 none,导致账号恢复后这批商品也不再重试。

与参考实现对齐:

- 详情页等待 4 秒改为 8 秒,提为 detail_wait_seconds
- 每个同款详情页前做一次 my_itaobao 深度守卫,在两次详情页之间插入
  正常页面访问;守卫等待提为 guard_wait_seconds
- 详情页后补一次 Cookie 级快速守卫,复用 judgeLogin
- 淘宝首页导航等待 3 秒改为 6 秒。签名请求在该页面上下文里 fetch,
  页面没加载完就发可能带不上 Cookie
- 下载失败重试,5xx 与连接错误重试 download_retries 次,
  4xx 和 ffprobe 校验失败不重试
- 每次下载尝试各有独立的 180 秒超时。此前超时套在整个重试循环外面,
  大视频首次跑到一半失败后剩余预算不足,重试等于不生效

新增风控降级识别:连续 risk_empty_threshold 个同款打开成功但取不到视频时,
判为疑似风控,中断整批任务并保持商品 pending,绝不写入 none。
停止原因通过 stopReason 枚举传给前端,不依赖中文文本分支。

数据订正提供显式按钮,不写进 migrations。刻意不做日期过滤:
synced_at 记录的是商品数据何时从货憨憨拉取,与 video_status 何时被写成
none 无关,每次「下载数据」都会把它刷成当天。某商品是否需要重做的长期
答案来自货憨憨每次全量拉取覆盖的 video_diagnosis,不来自本地时间戳。

淘宝与 Chrome 相关逻辑由假实现覆盖,真机验证尚未进行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 14:29:03 +08:00

473 lines
17 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 config 负责读写和校验程序的参数设置。
//
// 配置文件是程序目录下的 config.yaml,用 YAML 而不是 JSON,
// 是因为 YAML 能写注释——很多坑(比如密码必须加引号)只有写在
// 字段旁边,同事才不会踩。
//
// 设计约定(改代码前请先读):
//
// - config.yaml 含密码,已在 .gitignore 里,绝不能提交进 Git。
// 进 Git 的是 config.example.yaml,那份不含真实凭据。
//
// - 淘宝没有账号密码字段,将来也不要加。淘宝登录必须由使用者
// 在专属 Chrome 里手动完成,程序只保存浏览器路径,不碰凭据。
// 这是 AGENTS.md 里的红线。
//
// - 保存配置时用 Render() 按固定模板重新渲染,不要用 yaml.Marshal,
// 否则注释会被全部冲掉。
//
// - 新增一个字段要改四处:结构体、Default()、Validate()、
// Render() 里的模板。漏掉任何一处,单元测试会失败。
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Config 是程序的全部参数设置,对应界面上的「参数设置」页。
//
// yaml tag 就是配置文件里的键名。改名会导致老配置读不出来,
// 所以除非有充分理由,不要改已有字段的 tag。
type Config struct {
Huohanhan HuohanhanConfig `yaml:"huohanhan" json:"huohanhan"`
Taobao TaobaoConfig `yaml:"taobao" json:"taobao"`
Download DownloadConfig `yaml:"download" json:"download"`
}
// HuohanhanConfig 是货憨憨 ERP 的连接信息。
type HuohanhanConfig struct {
// BaseURL 是货憨憨网站地址,不带结尾的斜杠。
BaseURL string `yaml:"base_url" json:"baseUrl"`
// Account 是登录账号。
Account string `yaml:"account" json:"account"`
// Password 是登录密码。属于凭据,禁止写入日志、工单和 Wiki。
Password string `yaml:"password" json:"password"`
// OCRURL 是识别登录验证码的外部服务地址。
OCRURL string `yaml:"ocr_url" json:"ocrUrl"`
}
// TaobaoConfig 是淘宝专属浏览器的设置。
//
// 这里故意没有账号和密码字段:淘宝登录由使用者手动完成,
// 登录态保存在 Chrome 用户数据目录里,程序不读取也不保存。
type TaobaoConfig struct {
// ChromePath 是 chrome.exe 的完整路径,注意是文件不是目录。
ChromePath string `yaml:"chrome_path" json:"chromePath"`
// UserDataDir 是专属 Chrome 的用户数据目录,淘宝登录态存在这里。
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
// DebugPortStart / DebugPortEnd 是分配调试端口的范围。
DebugPortStart int `yaml:"debug_port_start" json:"debugPortStart"`
DebugPortEnd int `yaml:"debug_port_end" json:"debugPortEnd"`
}
// DownloadConfig 是下载与任务参数。
type DownloadConfig struct {
// VideoDir 是下载的视频保存目录。
VideoDir string `yaml:"video_dir" json:"videoDir"`
// MaxVideosPerProduct 是每个商品最多下载几个视频。
MaxVideosPerProduct int `yaml:"max_videos_per_product" json:"maxVideosPerProduct"`
// SearchTopN 是图搜结果里取前几个同款去找视频。
SearchTopN int `yaml:"search_top_n" json:"searchTopN"`
// Concurrency 是同时下载几个视频。
// 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
Concurrency int `yaml:"concurrency" json:"concurrency"`
// WaitSecondsMin / WaitSecondsMax 是处理完一个商品后随机等待的秒数区间。
WaitSecondsMin float64 `yaml:"wait_seconds_min" json:"waitSecondsMin"`
WaitSecondsMax float64 `yaml:"wait_seconds_max" json:"waitSecondsMax"`
DetailWaitSeconds float64 `yaml:"detail_wait_seconds" json:"detailWaitSeconds"`
GuardWaitSeconds float64 `yaml:"guard_wait_seconds" json:"guardWaitSeconds"`
RiskEmptyThreshold int `yaml:"risk_empty_threshold" json:"riskEmptyThreshold"`
DownloadRetries int `yaml:"download_retries" json:"downloadRetries"`
}
// Default 返回一份可以直接使用的默认配置。
//
// 账号和密码故意留空:程序不内置任何凭据,必须由使用者自己填。
func Default() Config {
return Config{
Huohanhan: HuohanhanConfig{
BaseURL: "https://www.huohanhan.com",
Account: "",
Password: "",
OCRURL: "https://ocr.ilapage.cn/ocr",
},
Taobao: TaobaoConfig{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
UserDataDir: DefaultChromeUserDataDir(),
DebugPortStart: 19666,
DebugPortEnd: 19765,
},
Download: DownloadConfig{
VideoDir: DefaultVideoDir(),
MaxVideosPerProduct: 3,
SearchTopN: 20,
Concurrency: 2,
WaitSecondsMin: 2,
WaitSecondsMax: 4,
DetailWaitSeconds: 8,
GuardWaitSeconds: 3,
RiskEmptyThreshold: 8,
DownloadRetries: 3,
},
}
}
// DefaultChromeUserDataDir 返回专属 Chrome 用户数据目录的默认位置。
//
// 这个路径沿用迁移前 Python 版本的目录,改动它会导致同事需要重新
// 扫码登录淘宝,所以不要随手改。
func DefaultChromeUserDataDir() string {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", "淘宝浏览器", "默认账号")
}
base = home
}
return filepath.Join(base, "电商视频自动下载工具", "淘宝浏览器", "默认账号")
}
// DataRoot 返回程序存放数据的根目录。
//
// 规则很简单:**配置文件在哪,数据就在哪**。
//
// 这样两种用法都对:
// - 打包后双击 exe:config.yaml 在 exe 旁边,数据也在 exe 旁边
// - 开发模式:config.yaml 在项目根目录(wails dev 的工作目录),
// 数据也落在项目根目录
//
// 不能直接用 exe 所在目录:wails dev 跑的是 buildin\cmsp-dev.exe,
// 数据会落进 buildin,而 `wails build -clean` 会清空那个目录,
// 已经下载好的视频会被一起删掉。
func DataRoot() string {
return filepath.Dir(DefaultPath())
}
// DefaultVideoDir 返回视频默认保存目录:数据根目录下的「运行数据/视频」。
func DefaultVideoDir() string {
return filepath.Join(DataRoot(), "运行数据", "视频")
}
// Validate 检查配置是否可用。返回的错误信息会直接显示给使用者,
// 所以要写成一句能看懂的中文,并说明允许范围。
//
// 这里不校验账号密码对不对,那要等真正登录时才知道。
// 这里只保证「格式上能用」。
func (c Config) Validate() error {
h := c.Huohanhan
if strings.TrimSpace(h.BaseURL) == "" {
return fmt.Errorf("货憨憨网址不能为空")
}
if !strings.HasPrefix(h.BaseURL, "http://") && !strings.HasPrefix(h.BaseURL, "https://") {
return fmt.Errorf("货憨憨网址必须以 http:// 或 https:// 开头")
}
if strings.TrimSpace(h.OCRURL) == "" {
return fmt.Errorf("OCR 识别服务地址不能为空")
}
t := c.Taobao
if strings.TrimSpace(t.ChromePath) == "" {
return fmt.Errorf("Chrome 可执行文件路径不能为空")
}
if strings.TrimSpace(t.UserDataDir) == "" {
return fmt.Errorf("Chrome 用户数据目录不能为空")
}
if err := checkPort(t.DebugPortStart, "调试端口起始"); err != nil {
return err
}
if err := checkPort(t.DebugPortEnd, "调试端口结束"); err != nil {
return err
}
if t.DebugPortEnd < t.DebugPortStart {
return fmt.Errorf("调试端口结束不能小于起始端口")
}
d := c.Download
if strings.TrimSpace(d.VideoDir) == "" {
return fmt.Errorf("视频保存目录不能为空")
}
if err := checkIntRange(d.MaxVideosPerProduct, 1, 10, "每个商品最多下载视频数"); err != nil {
return err
}
if err := checkIntRange(d.SearchTopN, 1, 60, "图搜取前 N 个同款"); err != nil {
return err
}
if err := checkIntRange(d.Concurrency, 1, 8, "下载并发数"); err != nil {
return err
}
if d.WaitSecondsMin < 0 || d.WaitSecondsMin > 60 {
return fmt.Errorf("商品间最短等待秒数不合法,允许范围 0—60")
}
if d.WaitSecondsMax < 0 || d.WaitSecondsMax > 120 {
return fmt.Errorf("商品间最长等待秒数不合法,允许范围 0—120")
}
if d.WaitSecondsMax < d.WaitSecondsMin {
return fmt.Errorf("商品间最长等待秒数不能小于最短等待秒数")
}
if d.DetailWaitSeconds < 3 || d.DetailWaitSeconds > 30 {
return fmt.Errorf("详情页加载等待秒数不合法,允许范围 3—30")
}
if d.GuardWaitSeconds < 1 || d.GuardWaitSeconds > 15 {
return fmt.Errorf("登录守卫等待秒数不合法,允许范围 1—15")
}
if err := checkIntRange(d.RiskEmptyThreshold, 3, 50, "疑似风控连续空结果阈值"); err != nil {
return err
}
if err := checkIntRange(d.DownloadRetries, 0, 5, "下载重试次数"); err != nil {
return err
}
return nil
}
func checkPort(port int, name string) error {
if port < 1024 || port > 65535 {
return fmt.Errorf("%s不合法,允许范围 1024—65535", name)
}
return nil
}
func checkIntRange(value, min, max int, name string) error {
if value < min || value > max {
return fmt.Errorf("%s不合法,允许范围 %d—%d", name, min, max)
}
return nil
}
// Desensitized 返回一份把密码换成固定占位符的副本。
//
// 任何要写日志、写工单,或者传给不需要密码的地方的场景,都用这个方法,
// 不要直接传 Config。
func (c Config) Desensitized() Config {
copied := c
if copied.Huohanhan.Password != "" {
copied.Huohanhan.Password = "******"
}
return copied
}
// Load 从 path 读取配置。
//
// 文件不存在时返回默认配置而不是错误——第一次启动本来就没有配置文件,
// 这时应该让程序正常打开、显示默认值,由使用者去填。
func Load(path string) (Config, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return Default(), nil
}
if err != nil {
return Config{}, fmt.Errorf("读取配置文件失败:%w", err)
}
// 先铺上默认值再解析,这样老配置文件缺少新字段时,
// 新字段会保留默认值而不是变成零值。
cfg := Default()
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return Config{}, fmt.Errorf("配置文件不是有效的 YAML:%w", err)
}
return cfg, nil
}
// Save 校验并写入配置。
//
// 先校验再写,避免把一份用不了的配置存进去。
// 写入的是 Render() 渲染的带注释版本,不是 yaml.Marshal 的裸数据。
func Save(path string, cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("创建配置目录失败:%w", err)
}
// 权限 0600:只有当前用户能读。文件里有密码。
if err := os.WriteFile(path, []byte(cfg.Render()), 0o600); err != nil {
return fmt.Errorf("写入配置文件失败:%w", err)
}
return nil
}
// Render 按固定模板把配置渲染成带注释的 YAML。
//
// 为什么不用 yaml.Marshal:那样会把注释全部丢掉,同事下次打开
// config.yaml 就只剩一堆键值对,不知道每项什么意思、有什么坑。
//
// 新增字段时记得在这里的模板中也加上,并补一句说明。
func (c Config) Render() string {
return fmt.Sprintf(`# cmsp 本机配置
#
# 本文件由「参数设置」页保存时自动重写,注释会保留。
# 也可以直接用记事本改,改完重启程序生效。
#
# [必须] 本文件含密码,已在 .gitignore 里,绝不能提交进 Git,
# 也不要打包发给别人、截图或粘贴到工单和日志里。
# [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成
# 整数,读取时直接报错,前导 0 也会丢。
huohanhan:
# 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。
base_url: %s
# 登录账号。
account: %s
# [必须] 登录密码,加双引号。
password: %s
# 登录验证码的自动识别服务。
# [注意] 验证码图片会被发送到这个地址,换成别人的服务前先评估一下。
ocr_url: %s
taobao:
# [必须] 这里没有淘宝账号和密码,将来也不要加。
# 淘宝登录必须由你在程序打开的专属 Chrome 里手动扫码完成,
# 登录态保存在下面的 user_data_dir 里,程序不读取也不保存。
# 程序不会代填密码,也不会绕过验证码或滑块。
# chrome.exe 的完整路径。注意是文件不是目录。
chrome_path: %s
# 专属 Chrome 的用户数据目录,淘宝登录态就存在这里。
# [注意] 改了这个路径就要重新扫码登录一次,不要随手改。
# 这个目录不要提交、打包或共享。
user_data_dir: %s
# 给专属 Chrome 分配调试端口的范围。端口被占用时会往后找。
debug_port_start: %d
debug_port_end: %d
download:
# 下载的视频保存到哪个目录。
video_dir: %s
# 每个商品最多下载几个视频,允许 1—10。
max_videos_per_product: %d
# 图搜结果里取前几个同款去找视频,允许 1—60。取太多会明显变慢。
search_top_n: %d
# 同时下载几个视频,允许 1—8。
# [注意] 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
concurrency: %d
# 处理完一个商品后随机等待的秒数区间,用来降低被风控的概率。
# 改小了跑得快但更容易被拦,不建议低于 2 秒。
wait_seconds_min: %s
wait_seconds_max: %s
# 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。
detail_wait_seconds: %s
# 每个同款详情页前访问「我的淘宝」的深度登录守卫等待秒数。
guard_wait_seconds: %s
# 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。
risk_empty_threshold: %d
# 网络层下载失败后的重试次数;HTTP 4xx 和 ffprobe 校验失败不会重试。
download_retries: %d
`,
yamlString(c.Huohanhan.BaseURL),
yamlString(c.Huohanhan.Account),
quoted(c.Huohanhan.Password),
yamlString(c.Huohanhan.OCRURL),
quoted(c.Taobao.ChromePath),
quoted(c.Taobao.UserDataDir),
c.Taobao.DebugPortStart,
c.Taobao.DebugPortEnd,
quoted(c.Download.VideoDir),
c.Download.MaxVideosPerProduct,
c.Download.SearchTopN,
c.Download.Concurrency,
trimFloat(c.Download.WaitSecondsMin),
trimFloat(c.Download.WaitSecondsMax),
trimFloat(c.Download.DetailWaitSeconds),
trimFloat(c.Download.GuardWaitSeconds),
c.Download.RiskEmptyThreshold,
c.Download.DownloadRetries,
)
}
// quoted 把值渲染成带双引号的 YAML 字符串。
//
// Windows 路径里有反斜杠和空格,密码可能是纯数字或含特殊字符,
// 这些都必须加引号,否则 YAML 解析会出错或类型不对。
func quoted(v string) string {
// YAML 双引号字符串里,反斜杠和双引号要转义。
escaped := strings.ReplaceAll(v, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
// yamlString 渲染普通字符串。空值写成一对空引号,避免出现裸的冒号后什么都没有。
func yamlString(v string) string {
if strings.TrimSpace(v) == "" {
return `""`
}
// 含特殊字符时一律加引号,省得判断哪些安全。
if strings.ContainsAny(v, `:#{}[],&*?|<>=!%@\"' `) {
return quoted(v)
}
return v
}
// trimFloat 把 2.0 渲染成 2,把 2.5 保留成 2.5,让配置文件更好看。
func trimFloat(v float64) string {
s := fmt.Sprintf("%.2f", v)
s = strings.TrimRight(s, "0")
return strings.TrimSuffix(s, ".")
}
// DefaultPath 返回配置文件的默认位置。
//
// 放在程序目录而不是系统目录,是为了让同事能直接看到和备份它。
//
// 查找顺序(这个顺序是为了同时照顾两种用法,改之前先读完):
//
// 1. exe 旁边已有 config.yaml → 用它。这是同事双击 exe 的正常情况。
// 2. 当前工作目录已有 config.yaml → 用它。这是开发模式的情况:
// `wails dev` 跑的是 build\bin\cmsp-dev.exe,按 exe 目录算会把配置
// 写到 build\bin\ 里,开发的人在项目根目录怎么找都找不到,
// 还以为保存没生效。
// 3. 两个都没有 → 新建在 exe 旁边。
//
// 换句话说:已经存在的配置优先,谁都不存在时才按 exe 目录建。
func DefaultPath() string {
return resolvePath("config.yaml")
}
// resolvePath 按「exe 目录 → 工作目录 → exe 目录(兜底)」找一个文件。
// 配置文件和数据库都用这套规则,行为保持一致。
func resolvePath(name string) string {
exeDir := ""
if exe, err := os.Executable(); err == nil {
exeDir = filepath.Dir(exe)
if candidate := filepath.Join(exeDir, name); fileExists(candidate) {
return candidate
}
}
if wd, err := os.Getwd(); err == nil {
if candidate := filepath.Join(wd, name); fileExists(candidate) {
return candidate
}
}
if exeDir != "" {
return filepath.Join(exeDir, name)
}
return name
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// ResolveDataPath 供其它包复用同一套查找规则,例如数据库文件。
func ResolveDataPath(name string) string {
return resolvePath(name)
}