Files
cmsp/internal/downloader/downloader_test.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

226 lines
8.3 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 downloader
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func Test文件名特殊字符不会生成非法路径(t *testing.T) {
got := Filename(`商品:/\*?"<>|..`, 2)
if strings.ContainsAny(got, `:/\*?"<>|`) {
t.Fatalf("文件名不得含 Windows 非法字符:%q", got)
}
if strings.Contains(got, "..") {
t.Fatalf("文件名不得含 ..,否则可能逃出目标目录:%q", got)
}
if !strings.HasSuffix(got, "_2.mp4") {
t.Fatalf("文件名应以 _序号.mp4 结尾,实际 %q", got)
}
}
// 文件名前缀必须是蝦皮商品 ID,后续别的程序靠它区分商品。
func Test文件名以蝦皮商品ID为前缀(t *testing.T) {
if got := Filename("40583431295", 1); got != "40583431295_1.mp4" {
t.Fatalf("应当是 40583431295_1.mp4,实际 %q", got)
}
if got := Filename("40583431295", 0); got != "40583431295_1.mp4" {
t.Fatalf("序号小于 1 时应回落为 1,实际 %q", got)
}
if got := Filename("40583431295", 3); got != "40583431295_3.mp4" {
t.Fatalf("应当是 40583431295_3.mp4,实际 %q", got)
}
}
func Test已存在非空文件直接跳过下载(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = io.WriteString(w, "不应下载")
}))
defer server.Close()
target := filepath.Join(t.TempDir(), "已有.mp4")
if err := os.WriteFile(target, []byte("existing"), 0o644); err != nil {
t.Fatalf("准备已有文件失败:%v", err)
}
var probes atomic.Int32
d := NewWithOptions(server.Client(), func(context.Context, string) (ProbeResult, error) {
probes.Add(1)
return ProbeResult{}, nil
})
result, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/", target, 0)
if err != nil {
t.Fatalf("已有文件应当跳过:%v", err)
}
if !result.Skipped || requests.Load() != 0 || probes.Load() != 0 {
t.Fatalf("应跳过网络和校验:result=%+v requests=%d probes=%d", result, requests.Load(), probes.Load())
}
}
func Test校验失败只删除临时文件不留下正式文件(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") != "Mozilla/5.0" {
t.Errorf("User-Agent 不正确:%q", r.Header.Get("User-Agent"))
}
if r.Header.Get("Referer") != "https://item.taobao.com/item.htm?id=1" {
t.Errorf("Referer 不正确:%q", r.Header.Get("Referer"))
}
_, _ = io.WriteString(w, "broken video")
}))
defer server.Close()
target := filepath.Join(t.TempDir(), "失败.mp4")
d := NewWithOptions(server.Client(), func(context.Context, string) (ProbeResult, error) {
return ProbeResult{}, io.ErrUnexpectedEOF
})
if _, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/item.htm?id=1", target, 0); err == nil {
t.Fatal("校验失败应当返回错误")
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("校验失败不得留下正式文件:%v", err)
}
if _, err := os.Stat(target + ".part"); !os.IsNotExist(err) {
t.Fatalf("校验失败应删除 .part:%v", err)
}
}
func Test下载校验成功后改名为正式文件(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "fake video bytes")
}))
defer server.Close()
target := filepath.Join(t.TempDir(), "成功.mp4")
d := NewWithOptions(server.Client(), func(_ context.Context, path string) (ProbeResult, error) {
if !strings.HasSuffix(path, ".part") {
t.Fatalf("ffprobe 必须校验 .part 文件:%s", path)
}
return ProbeResult{Duration: 12.5, Size: 16, FormatName: "mov,mp4"}, nil
})
result, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/", target, 0)
if err != nil {
t.Fatalf("下载应成功:%v", err)
}
if result.Duration != 12.5 || result.Size != 16 {
t.Fatalf("校验结果未记录:%+v", result)
}
if info, err := os.Stat(target); err != nil || info.Size() == 0 {
t.Fatalf("正式文件不存在或为空:info=%v err=%v", info, err)
}
if _, err := os.Stat(target + ".part"); !os.IsNotExist(err) {
t.Fatalf("成功后不应留下 .part:%v", err)
}
}
func Test五百错误重试到上限后失败(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer server.Close()
d := NewWithOptions(server.Client(), nil)
if _, err := d.Download(context.Background(), server.URL, "", filepath.Join(t.TempDir(), "x.mp4"), 3); err == nil {
t.Fatal("5xx 重试到上限后应失败")
}
if requests.Load() != 4 {
t.Fatalf("初次加 3 次重试应请求 4 次,实际 %d", requests.Load())
}
}
func Test每次重试都拿到独立的超时预算(t *testing.T) {
// 超时若套在整个重试循环外面,第一次尝试耗掉大部分预算后,
// 后续重试会因为父 context 已到期而根本发不出请求。
// 这里让前两次各消耗一段时间再失败,断言第三次仍然能真正跑起来并成功。
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if requests.Add(1) < 3 {
time.Sleep(120 * time.Millisecond)
w.WriteHeader(http.StatusBadGateway)
return
}
_, _ = io.WriteString(w, "video-bytes")
}))
defer server.Close()
d := NewWithOptions(server.Client(), func(context.Context, string) (ProbeResult, error) {
return ProbeResult{Duration: 1, Size: 11, FormatName: "mp4"}, nil
})
if _, err := d.Download(context.Background(), server.URL, "", filepath.Join(t.TempDir(), "x.mp4"), 3); err != nil {
t.Fatalf("第三次尝试应当成功,实际失败:%v", err)
}
if requests.Load() != 3 {
t.Fatalf("应在第三次成功,实际请求 %d 次", requests.Load())
}
}
func Test四百错误不重试(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1); w.WriteHeader(http.StatusNotFound) }))
defer server.Close()
d := NewWithOptions(server.Client(), nil)
if _, err := d.Download(context.Background(), server.URL, "", filepath.Join(t.TempDir(), "x.mp4"), 3); err == nil {
t.Fatal("4xx 应失败")
}
if requests.Load() != 1 {
t.Fatalf("4xx 不应重试,实际 %d 次", requests.Load())
}
}
func Test校验失败不重试(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1); _, _ = io.WriteString(w, "bad") }))
defer server.Close()
d := NewWithOptions(server.Client(), func(context.Context, string) (ProbeResult, error) { return ProbeResult{}, io.ErrUnexpectedEOF })
if _, err := d.Download(context.Background(), server.URL, "", filepath.Join(t.TempDir(), "x.mp4"), 3); err == nil {
t.Fatal("校验失败应失败")
}
if requests.Load() != 1 {
t.Fatalf("校验失败绝不重试,实际 %d 次", requests.Load())
}
}
func Test下载重试尊重Context取消(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
d := NewWithOptions(server.Client(), nil)
if _, err := d.Download(ctx, server.URL, "", filepath.Join(t.TempDir(), "x.mp4"), 3); err == nil {
t.Fatal("取消的 context 应返回错误")
}
}
// 目录名直接来自货憨憨返回的蝦皮商品 ID。正常是纯数字,
// 但不能假定:一旦返回带路径分隔符或 .. 的值,拼进路径就能写到
// 视频目录之外。这个测试守住清洗逻辑。
func TestSafeDirName拒绝危险输入(t *testing.T) {
cases := []struct {
in string
want string
}{
{"40583431295", "40583431295"},
{"", "unknown"},
{" ", "unknown"},
{"..", "unknown"},
{"../../windows", "______windows"},
{`a\b`, "a_b"},
{"a/b", "a_b"},
{"a:b", "a_b"},
{"CON", "CON"},
}
for _, c := range cases {
got := SafeDirName(c.in)
if got != c.want {
t.Fatalf("SafeDirName(%q) 应当是 %q,实际 %q", c.in, c.want, got)
}
if strings.ContainsAny(got, `/\:`) || got == ".." {
t.Fatalf("清洗后仍含危险字符:%q", got)
}
}
}