淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码, 详情页里就是没有 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
298 lines
8.7 KiB
Go
298 lines
8.7 KiB
Go
package task
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"reflect"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func Test正常跑完N个商品(t *testing.T) {
|
||
var mu sync.Mutex
|
||
var doneValues []int
|
||
runner := newTestRunner(Options{
|
||
OnProgress: func(progress Progress) {
|
||
mu.Lock()
|
||
doneValues = append(doneValues, progress.Done)
|
||
mu.Unlock()
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
if progress.Total != 3 || progress.Done != 3 {
|
||
t.Fatalf("完成进度错误:%+v", progress)
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(doneValues, []int{1, 2, 3}) {
|
||
t.Fatalf("进度没有从 0 依次推进到 N:%v", doneValues)
|
||
}
|
||
}
|
||
|
||
func Test中途停止后不再启动剩余商品(t *testing.T) {
|
||
started := make(chan string, 3)
|
||
release := make(chan struct{})
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
started <- product.ID
|
||
if product.ID == "1" {
|
||
<-release
|
||
}
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
if got := <-started; got != "1" {
|
||
t.Fatalf("首个商品错误:%s", got)
|
||
}
|
||
runner.Stop()
|
||
close(release)
|
||
progress := waitForState(t, runner, StateStopped)
|
||
if progress.Done != 1 {
|
||
t.Fatalf("当前商品应处理完再停止:%+v", progress)
|
||
}
|
||
select {
|
||
case id := <-started:
|
||
t.Fatalf("停止后仍启动了商品:%s", id)
|
||
default:
|
||
}
|
||
}
|
||
|
||
func Test登录失效立即触发全局停止门(t *testing.T) {
|
||
var processed []string
|
||
var mu sync.Mutex
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
mu.Lock()
|
||
processed = append(processed, product.ID)
|
||
mu.Unlock()
|
||
if product.ID == "2" {
|
||
return Work{}, errors.Join(ErrLoginRequired, errors.New("深度检查不通过"))
|
||
}
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3", "4"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateLoginRequired)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(processed, []string{"1", "2"}) {
|
||
t.Fatalf("登录失效后仍处理了后续商品:%v", processed)
|
||
}
|
||
if progress.Done != 1 || progress.Failed != 0 {
|
||
t.Fatalf("登录失效商品不能记为单商品失败:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test疑似风控触发停止门且后续商品不处理(t *testing.T) {
|
||
var processed []string
|
||
runner := newTestRunner(Options{Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
processed = append(processed, product.ID)
|
||
if product.ID == "2" {
|
||
return Work{}, errors.Join(ErrLoginRequired, ErrRiskSuspected, RiskSuspectedError{Consecutive: 8})
|
||
}
|
||
return Work{}, nil
|
||
}})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateLoginRequired)
|
||
if !reflect.DeepEqual(processed, []string{"1", "2"}) {
|
||
t.Fatalf("疑似风控后仍处理后续商品:%v", processed)
|
||
}
|
||
if progress.StopReason != StopReasonRiskSuspected {
|
||
t.Fatalf("停止原因应为疑似风控,实际:%+v", progress)
|
||
}
|
||
if progress.RiskEmptyCount != 8 {
|
||
t.Fatalf("连续空结果数应传给前端,实际:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test空结果中途发现视频会清零(t *testing.T) {
|
||
guard := NewEmptyRiskGuard(3)
|
||
if guard.Record(0) || guard.Record(0) {
|
||
t.Fatal("阈值前不应触发")
|
||
}
|
||
if guard.Record(1) || guard.Consecutive() != 0 {
|
||
t.Fatalf("发现视频应清零,实际连续数 %d", guard.Consecutive())
|
||
}
|
||
if guard.Record(0) || guard.Record(0) || !guard.Record(0) {
|
||
t.Fatal("清零后应重新累计到阈值")
|
||
}
|
||
}
|
||
|
||
func Test单商品失败后继续处理后续商品(t *testing.T) {
|
||
var processed []string
|
||
var mu sync.Mutex
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
mu.Lock()
|
||
processed = append(processed, product.ID)
|
||
mu.Unlock()
|
||
if product.ID == "2" {
|
||
return Work{}, errors.New("图搜失败")
|
||
}
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(processed, []string{"1", "2", "3"}) {
|
||
t.Fatalf("单商品失败后没有继续:%v", processed)
|
||
}
|
||
if progress.Failed != 1 || progress.Done != 3 {
|
||
t.Fatalf("失败计数错误:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test已有任务运行时拒绝再次启动(t *testing.T) {
|
||
release := make(chan struct{})
|
||
started := make(chan struct{})
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(context.Context, Product) (Work, error) {
|
||
close(started)
|
||
<-release
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1"}); err != nil {
|
||
t.Fatalf("首次启动失败:%v", err)
|
||
}
|
||
<-started
|
||
if err := runner.Start(context.Background(), []string{"2"}); err == nil || err.Error() != "已有任务在运行" {
|
||
t.Fatalf("重复启动应返回指定中文错误,实际:%v", err)
|
||
}
|
||
close(release)
|
||
waitForState(t, runner, StateCompleted)
|
||
}
|
||
|
||
func Test已完成和无同款商品不进入队列(t *testing.T) {
|
||
statuses := map[string]Product{
|
||
"done": {ID: "done", DownloadStatus: "done"},
|
||
"none": {ID: "none", VideoStatus: "none"},
|
||
"todo": {ID: "todo"},
|
||
}
|
||
var processed []string
|
||
var mu sync.Mutex
|
||
runner := newTestRunner(Options{
|
||
Load: func(_ context.Context, id string) (Product, error) { return statuses[id], nil },
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
mu.Lock()
|
||
processed = append(processed, product.ID)
|
||
mu.Unlock()
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"done", "none", "todo"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(processed, []string{"todo"}) {
|
||
t.Fatalf("断点跳过逻辑错误:%v", processed)
|
||
}
|
||
if progress.Total != 1 || progress.Done != 1 {
|
||
t.Fatalf("跳过后的总数错误:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test下载并发数不超过配置值(t *testing.T) {
|
||
var active int32
|
||
var maximum int32
|
||
jobs := make([]DownloadFunc, 8)
|
||
for index := range jobs {
|
||
jobs[index] = func(context.Context) error {
|
||
current := atomic.AddInt32(&active, 1)
|
||
for {
|
||
old := atomic.LoadInt32(&maximum)
|
||
if current <= old || atomic.CompareAndSwapInt32(&maximum, old, current) {
|
||
break
|
||
}
|
||
}
|
||
time.Sleep(15 * time.Millisecond)
|
||
atomic.AddInt32(&active, -1)
|
||
return nil
|
||
}
|
||
}
|
||
runner := newTestRunner(Options{
|
||
Concurrency: 2,
|
||
Prepare: func(context.Context, Product) (Work, error) {
|
||
return Work{Downloads: jobs}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
if maximum != 2 {
|
||
t.Fatalf("下载同时运行数应为 2,实际为 %d", maximum)
|
||
}
|
||
if progress.Downloaded != len(jobs) {
|
||
t.Fatalf("下载计数错误:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test每处理完一个商品调用一次进度回调(t *testing.T) {
|
||
var calls int32
|
||
runner := newTestRunner(Options{
|
||
OnProgress: func(Progress) { atomic.AddInt32(&calls, 1) },
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3", "4"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
waitForState(t, runner, StateCompleted)
|
||
if calls != 4 {
|
||
t.Fatalf("进度回调次数应等于处理商品数,实际为 %d", calls)
|
||
}
|
||
}
|
||
|
||
func newTestRunner(override Options) *Runner {
|
||
options := Options{
|
||
Concurrency: 1,
|
||
Load: func(_ context.Context, id string) (Product, error) {
|
||
return Product{ID: id, Name: "商品" + id}, nil
|
||
},
|
||
Prepare: func(context.Context, Product) (Work, error) { return Work{}, nil },
|
||
}
|
||
if override.Load != nil {
|
||
options.Load = override.Load
|
||
}
|
||
if override.Prepare != nil {
|
||
options.Prepare = override.Prepare
|
||
}
|
||
if override.Concurrency > 0 {
|
||
options.Concurrency = override.Concurrency
|
||
}
|
||
options.OnProgress = override.OnProgress
|
||
options.OnFinished = override.OnFinished
|
||
return NewRunner(options)
|
||
}
|
||
|
||
func waitForState(t *testing.T, runner *Runner, state string) Progress {
|
||
t.Helper()
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
progress := runner.Snapshot()
|
||
if progress.State == state {
|
||
return progress
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
t.Fatalf("等待任务状态 %s 超时,当前:%+v", state, runner.Snapshot())
|
||
return Progress{}
|
||
}
|