淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码, 详情页里就是没有 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
329 lines
12 KiB
Go
329 lines
12 KiB
Go
package config
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 默认配置必须是合法的,否则第一次启动程序就会报错。
|
||
// 新增字段忘了写默认值或校验规则时,这个测试会失败。
|
||
func TestDefaultConfigIsValid(t *testing.T) {
|
||
if err := Default().Validate(); err != nil {
|
||
t.Fatalf("默认配置应当合法,却报错:%v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRejectsBadValues(t *testing.T) {
|
||
// 每个用例只改一个字段,这样失败时能一眼看出是哪条规则出问题。
|
||
cases := []struct {
|
||
name string
|
||
modify func(*Config)
|
||
expect string
|
||
}{
|
||
{"网址为空", func(c *Config) { c.Huohanhan.BaseURL = "" }, "网址不能为空"},
|
||
{"网址缺协议", func(c *Config) { c.Huohanhan.BaseURL = "www.huohanhan.com" }, "http://"},
|
||
{"OCR 地址为空", func(c *Config) { c.Huohanhan.OCRURL = "" }, "OCR"},
|
||
{"Chrome 路径为空", func(c *Config) { c.Taobao.ChromePath = "" }, "Chrome 可执行文件"},
|
||
{"用户数据目录为空", func(c *Config) { c.Taobao.UserDataDir = "" }, "用户数据目录"},
|
||
{"端口过小", func(c *Config) { c.Taobao.DebugPortStart = 80 }, "1024"},
|
||
{"端口区间颠倒", func(c *Config) { c.Taobao.DebugPortEnd = c.Taobao.DebugPortStart - 1 }, "不能小于起始端口"},
|
||
{"视频目录为空", func(c *Config) { c.Download.VideoDir = "" }, "视频保存目录"},
|
||
{"每商品视频数为 0", func(c *Config) { c.Download.MaxVideosPerProduct = 0 }, "1—10"},
|
||
{"图搜取数过大", func(c *Config) { c.Download.SearchTopN = 61 }, "1—60"},
|
||
{"并发数过大", func(c *Config) { c.Download.Concurrency = 9 }, "1—8"},
|
||
{"等待区间颠倒", func(c *Config) { c.Download.WaitSecondsMax = 1; c.Download.WaitSecondsMin = 5 }, "不能小于最短"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
cfg := Default()
|
||
tc.modify(&cfg)
|
||
err := cfg.Validate()
|
||
if err == nil {
|
||
t.Fatalf("期望校验失败,却通过了")
|
||
}
|
||
if !strings.Contains(err.Error(), tc.expect) {
|
||
t.Fatalf("错误信息应包含 %q,实际是 %q", tc.expect, err.Error())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 配置文件不存在时必须返回默认配置,而不是报错。
|
||
// 第一次启动程序就是这个场景。
|
||
func TestLoadMissingFileReturnsDefault(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "not-exist.yaml")
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("文件不存在时不应报错,却报了:%v", err)
|
||
}
|
||
if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart {
|
||
t.Fatalf("应当返回默认配置")
|
||
}
|
||
}
|
||
|
||
func TestSaveThenLoadKeepsValues(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
|
||
saved := Default()
|
||
saved.Huohanhan.Account = "13500000000" // 虚构测试数据,非真实账号
|
||
saved.Huohanhan.Password = `测试"密码\含转义` // 故意含引号和反斜杠
|
||
saved.Download.MaxVideosPerProduct = 5
|
||
saved.Download.WaitSecondsMin = 1.5
|
||
|
||
if err := Save(path, saved); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("读取失败:%v", err)
|
||
}
|
||
if loaded.Huohanhan.Account != saved.Huohanhan.Account {
|
||
t.Fatalf("账号应当读回 %q,实际 %q", saved.Huohanhan.Account, loaded.Huohanhan.Account)
|
||
}
|
||
if loaded.Huohanhan.Password != saved.Huohanhan.Password {
|
||
t.Fatalf("含引号和反斜杠的密码应当原样读回,期望 %q,实际 %q",
|
||
saved.Huohanhan.Password, loaded.Huohanhan.Password)
|
||
}
|
||
if loaded.Download.MaxVideosPerProduct != 5 {
|
||
t.Fatalf("每商品视频数应当读回 5,实际 %d", loaded.Download.MaxVideosPerProduct)
|
||
}
|
||
if loaded.Download.WaitSecondsMin != 1.5 {
|
||
t.Fatalf("小数应当读回 1.5,实际 %v", loaded.Download.WaitSecondsMin)
|
||
}
|
||
}
|
||
|
||
// Windows 路径全是反斜杠,必须能原样存取。
|
||
func TestSaveThenLoadKeepsWindowsPath(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
|
||
saved := Default()
|
||
saved.Taobao.ChromePath = `C:\Program Files\Google\Chrome\Application\chrome.exe`
|
||
saved.Taobao.UserDataDir = `C:\Users\某人\AppData\Local\电商视频自动下载工具\淘宝浏览器\默认账号`
|
||
|
||
if err := Save(path, saved); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("读取失败:%v", err)
|
||
}
|
||
if loaded.Taobao.ChromePath != saved.Taobao.ChromePath {
|
||
t.Fatalf("Chrome 路径应当原样读回\n期望 %q\n实际 %q",
|
||
saved.Taobao.ChromePath, loaded.Taobao.ChromePath)
|
||
}
|
||
if loaded.Taobao.UserDataDir != saved.Taobao.UserDataDir {
|
||
t.Fatalf("用户数据目录应当原样读回\n期望 %q\n实际 %q",
|
||
saved.Taobao.UserDataDir, loaded.Taobao.UserDataDir)
|
||
}
|
||
}
|
||
|
||
// 纯数字密码不加引号会被 YAML 当成整数导致读取失败,这是踩过的坑。
|
||
func TestSaveThenLoadKeepsNumericPassword(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
|
||
saved := Default()
|
||
saved.Huohanhan.Password = "0123456789" // 虚构测试数据,注意前导 0
|
||
|
||
if err := Save(path, saved); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
loaded, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("纯数字密码应当能正常读取,却报错:%v", err)
|
||
}
|
||
if loaded.Huohanhan.Password != "0123456789" {
|
||
t.Fatalf("前导 0 应当保留,期望 0123456789,实际 %q", loaded.Huohanhan.Password)
|
||
}
|
||
}
|
||
|
||
// 保存后注释必须还在,否则同事下次打开就只剩键值对。
|
||
func TestSaveKeepsComments(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
if err := Save(path, Default()); err != nil {
|
||
t.Fatalf("保存失败:%v", err)
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("读取文件失败:%v", err)
|
||
}
|
||
text := string(raw)
|
||
|
||
for _, must := range []string{
|
||
"# cmsp 本机配置",
|
||
"绝不能提交进 Git",
|
||
"密码要用双引号包起来",
|
||
"这里没有淘宝账号和密码",
|
||
"不会代填密码",
|
||
"提高淘宝风控概率",
|
||
} {
|
||
if !strings.Contains(text, must) {
|
||
t.Fatalf("保存后应当保留注释 %q,实际内容:\n%s", must, text)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 老配置文件缺少新字段时,新字段要保留默认值,不能变成 0 或空字符串。
|
||
func TestLoadFillsMissingFieldsWithDefaults(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "old.yaml")
|
||
old := "huohanhan:\n account: \"13500000000\"\ndownload:\n max_videos_per_product: 7\n"
|
||
if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
|
||
t.Fatalf("准备测试文件失败:%v", err)
|
||
}
|
||
|
||
cfg, err := Load(path)
|
||
if err != nil {
|
||
t.Fatalf("读取失败:%v", err)
|
||
}
|
||
if cfg.Download.MaxVideosPerProduct != 7 {
|
||
t.Fatalf("已有字段应当读回 7,实际 %d", cfg.Download.MaxVideosPerProduct)
|
||
}
|
||
if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart {
|
||
t.Fatalf("缺失字段应当保留默认值 %d,实际 %d",
|
||
Default().Taobao.DebugPortStart, cfg.Taobao.DebugPortStart)
|
||
}
|
||
if cfg.Huohanhan.BaseURL != Default().Huohanhan.BaseURL {
|
||
t.Fatalf("缺失的网址应当保留默认值")
|
||
}
|
||
if cfg.Download.DetailWaitSeconds != 8 || cfg.Download.GuardWaitSeconds != 3 || cfg.Download.RiskEmptyThreshold != 8 || cfg.Download.DownloadRetries != 3 {
|
||
t.Fatalf("旧配置缺失新字段时应使用默认值,实际:%+v", cfg.Download)
|
||
}
|
||
}
|
||
|
||
func Test详情页等待秒数边界校验(t *testing.T) {
|
||
for _, seconds := range []float64{2, 31} {
|
||
cfg := Default()
|
||
cfg.Download.DetailWaitSeconds = seconds
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Fatalf("详情页等待秒数 %v 应被拒绝", seconds)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save 必须拒绝不合法的配置,避免把用不了的配置写进文件。
|
||
func TestSaveRejectsInvalidConfig(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
bad := Default()
|
||
bad.Download.Concurrency = 99
|
||
|
||
if err := Save(path, bad); err == nil {
|
||
t.Fatalf("应当拒绝不合法的配置")
|
||
}
|
||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||
t.Fatalf("校验失败时不应写出文件")
|
||
}
|
||
}
|
||
|
||
// 密码绝不能原样出现在脱敏后的配置里。
|
||
func TestDesensitizedHidesPassword(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.Huohanhan.Password = "这是一个不该出现在日志里的密码"
|
||
|
||
safe := cfg.Desensitized()
|
||
if strings.Contains(safe.Huohanhan.Password, "不该出现") {
|
||
t.Fatalf("脱敏后仍能看到密码:%q", safe.Huohanhan.Password)
|
||
}
|
||
if cfg.Huohanhan.Password == safe.Huohanhan.Password {
|
||
t.Fatalf("Desensitized 不应修改原配置")
|
||
}
|
||
}
|
||
|
||
// 淘宝配置里绝不能出现账号密码字段。加了就是违反 AGENTS.md 的红线,
|
||
// 这个测试就是为了挡住那种改动。
|
||
func TestTaobaoConfigHasNoCredentialFields(t *testing.T) {
|
||
rendered := Default().Render()
|
||
for _, forbidden := range []string{
|
||
"taobao_password", "taobao_account",
|
||
"\n password:", // taobao 段里不该有 password
|
||
"\n username:",
|
||
} {
|
||
// huohanhan 段里的 password 缩进也是两格,所以要精确定位 taobao 段。
|
||
taobaoSection := rendered[strings.Index(rendered, "taobao:"):strings.Index(rendered, "download:")]
|
||
if strings.Contains(taobaoSection, forbidden) {
|
||
t.Fatalf("taobao 配置段不允许出现 %q,淘宝登录必须由使用者手动完成", forbidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 配置文件的查找顺序必须同时照顾两种用法:
|
||
// 打包后 exe 旁边、开发模式下项目根目录。
|
||
func TestResolvePath优先使用已存在的文件(t *testing.T) {
|
||
dir := t.TempDir()
|
||
existing := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(existing, []byte("huohanhan:\n account: \"x\"\n"), 0o600); err != nil {
|
||
t.Fatalf("准备测试文件失败:%v", err)
|
||
}
|
||
|
||
// 把工作目录切到那个临时目录,模拟开发模式:
|
||
// exe 在 build\bin 里没有配置,但工作目录有。
|
||
old, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("读取工作目录失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("切换工作目录失败:%v", err)
|
||
}
|
||
|
||
got := DefaultPath()
|
||
if filepath.Base(got) != "config.yaml" {
|
||
t.Fatalf("应当返回 config.yaml,实际 %q", got)
|
||
}
|
||
// 关键:不能返回 exe 目录下那个不存在的路径,
|
||
// 而应当命中工作目录里已经存在的这一份。
|
||
if !fileExists(got) {
|
||
t.Fatalf("应当返回已存在的配置文件,实际 %q 不存在", got)
|
||
}
|
||
}
|
||
|
||
func TestFileExists目录不算文件(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if fileExists(dir) {
|
||
t.Fatalf("目录不应被当成文件")
|
||
}
|
||
if fileExists(filepath.Join(dir, "不存在.yaml")) {
|
||
t.Fatalf("不存在的路径不应返回 true")
|
||
}
|
||
}
|
||
|
||
// 数据根目录必须跟随配置文件所在位置,不能按 exe 目录算。
|
||
//
|
||
// 曾经真实踩过:wails dev 跑的是 build\bin\cmsp-dev.exe,
|
||
// 按 exe 目录算会把视频下载进 build\bin,而 wails build -clean
|
||
// 会清空那个目录,已下载的视频被一起删掉。
|
||
func TestDataRoot跟随配置文件位置(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfgPath := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(cfgPath, []byte("huohanhan:\n account: \"x\"\n"), 0o600); err != nil {
|
||
t.Fatalf("准备配置文件失败:%v", err)
|
||
}
|
||
|
||
old, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("读取工作目录失败:%v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||
if err := os.Chdir(dir); err != nil {
|
||
t.Fatalf("切换工作目录失败:%v", err)
|
||
}
|
||
|
||
root := DataRoot()
|
||
if root != filepath.Dir(DefaultPath()) {
|
||
t.Fatalf("数据根目录应当是配置文件所在目录,实际 %q", root)
|
||
}
|
||
|
||
videoDir := DefaultVideoDir()
|
||
if !strings.HasPrefix(videoDir, root) {
|
||
t.Fatalf("视频目录应当在数据根目录之下\n根目录 %q\n视频目录 %q", root, videoDir)
|
||
}
|
||
if !strings.HasSuffix(videoDir, filepath.Join("运行数据", "视频")) {
|
||
t.Fatalf("视频目录应当以 运行数据/视频 结尾,实际 %q", videoDir)
|
||
}
|
||
// 关键:不能落进 build\bin
|
||
if strings.Contains(videoDir, filepath.Join("build", "bin")) {
|
||
t.Fatalf("视频目录不得落在 build\bin 下,那里会被 wails build -clean 清空:%q", videoDir)
|
||
}
|
||
}
|