淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码, 详情页里就是没有 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
226 lines
8.3 KiB
Go
226 lines
8.3 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|