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
This commit is contained in:
QiuSW
2026-09-03 14:29:03 +08:00
co-authored by Claude Opus 5
parent ce729cd1c8
commit 6cc334ab92
15 changed files with 570 additions and 54 deletions
+53 -10
View File
@@ -206,7 +206,7 @@ func (a *App) CheckTaobaoLogin() (taobao.LoginStatus, error) {
}
defer cdp.Close()
status, err := taobao.CheckLogin(cdp)
status, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if err != nil {
status.Message = err.Error()
a.emitTaobaoStatus(status)
@@ -328,7 +328,7 @@ func (a *App) SearchTaobaoByProduct(productID string) ([]taobao.SimilarItem, err
}
defer cdp.Close()
status, err := taobao.CheckLogin(cdp)
status, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if err != nil {
status.Message = err.Error()
a.emitTaobaoStatus(status)
@@ -370,7 +370,8 @@ type VideoSummary struct {
// 单商品入口与批量任务共用 prepareVideoFetch;这里保持原有串行下载行为。
func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) {
ctx := a.appContext()
result, work, err := a.prepareVideoFetch(ctx, productID)
riskGuard := task.NewEmptyRiskGuard(a.cfg.Download.RiskEmptyThreshold)
result, work, err := a.prepareVideoFetch(ctx, productID, riskGuard)
if err != nil {
return result, err
}
@@ -384,7 +385,7 @@ func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) {
// prepareVideoFetch 串行完成一个商品所有会触碰 CDP 的步骤,并把纯 HTTP
// 下载闭包交给调用方。批量 Runner 因此不会让多个协程争抢同一个页面。
func (a *App) prepareVideoFetch(ctx context.Context, productID string) (FetchResult, task.Work, error) {
func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard *task.EmptyRiskGuard) (FetchResult, task.Work, error) {
productID = strings.TrimSpace(productID)
result := FetchResult{ProductID: productID}
if a.db == nil {
@@ -413,7 +414,7 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string) (FetchRes
}
defer cdp.Close()
login, err := taobao.CheckLogin(cdp)
login, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if err != nil {
login.Message = err.Error()
a.emitTaobaoStatus(login)
@@ -453,7 +454,15 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string) (FetchRes
}
item := similar[i]
a.log.Info("商品 %s 正在检查第 %d/%d 个淘宝同款 %s", product.ID, i+1, limit, item.ItemID)
urls, detailErr := taobao.ExtractDetailVideos(cdp, item.ItemID, 4*time.Second)
login, guardErr := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if guardErr != nil || !login.Valid {
message := login.Message
if guardErr != nil {
message = guardErr.Error()
}
return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, message)
}
detail, detailErr := taobao.ExtractDetailVideos(cdp, item.ItemID, time.Duration(a.cfg.Download.DetailWaitSeconds*float64(time.Second)))
if errors.Is(detailErr, taobao.ErrLoginInvalid) {
a.emitTaobaoStatus(taobao.LoginStatus{Message: detailErr.Error()})
a.log.Warn("商品 %s 访问详情页时登录失效,已立即停止", product.ID)
@@ -464,12 +473,23 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string) (FetchRes
a.log.Warn("商品 %s 的同款 %s 详情读取失败:%v", product.ID, item.ItemID, detailErr)
continue
}
if len(urls) == 0 {
quick, quickErr := taobao.QuickCheckLogin(cdp, detail.Title, detail.Text)
if quickErr != nil || !quick.Valid {
message := quick.Message
if quickErr != nil {
message = quickErr.Error()
}
return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, message)
}
if riskGuard != nil && riskGuard.Record(len(detail.URLs)) {
return result, task.Work{}, fmt.Errorf("%w:%w:%w", task.ErrLoginRequired, task.ErrRiskSuspected, task.RiskSuspectedError{Consecutive: riskGuard.Consecutive()})
}
if len(detail.URLs) == 0 {
a.log.Info("淘宝同款 %s 未提取到可信视频", item.ItemID)
continue
}
selected = item
videoURLs = urls
videoURLs = detail.URLs
break
}
@@ -515,7 +535,7 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string) (FetchRes
host := videoURLHost(sourceURL)
a.log.Info("商品 %s 正在下载视频 %d/%d(主机 %s,文件 %s)",
product.ID, i+1, len(videoURLs), host, name)
downloaded, downloadErr := downloaderClient.Download(downloadCtx, sourceURL, referer, target)
downloaded, downloadErr := downloaderClient.Download(downloadCtx, sourceURL, referer, target, a.cfg.Download.DownloadRetries)
record := store.Video{SourceItem: selected.ItemID, SourceURL: sourceURL,
LocalPath: target, Status: store.VideoStatusFailed, CreatedAt: now}
if downloadErr != nil {
@@ -841,6 +861,7 @@ func (a *App) StartVideoTask(productIDs []string) error {
return fmt.Errorf("已有任务在运行")
}
riskGuard := task.NewEmptyRiskGuard(a.cfg.Download.RiskEmptyThreshold)
runner := task.NewRunner(task.Options{
Concurrency: a.cfg.Download.Concurrency,
WaitMin: time.Duration(a.cfg.Download.WaitSecondsMin * float64(time.Second)),
@@ -862,7 +883,7 @@ func (a *App) StartVideoTask(productIDs []string) error {
}, nil
},
Prepare: func(ctx context.Context, product task.Product) (task.Work, error) {
_, work, err := a.prepareVideoFetch(ctx, product.ID)
_, work, err := a.prepareVideoFetch(ctx, product.ID, riskGuard)
return work, err
},
OnProgress: func(progress task.Progress) {
@@ -885,6 +906,28 @@ func (a *App) StartVideoTask(productIDs []string) error {
return runner.Start(a.appContext(), productIDs)
}
// CountResettableNoneProducts 供设置页在人工确认前显示影响范围。
func (a *App) CountResettableNoneProducts() (int, error) {
if a.db == nil {
return 0, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.CountResettableNoneProducts()
}
// ResetNoneProducts 仅在未运行取视频任务时执行显式数据订正。
func (a *App) ResetNoneProducts() (int, error) {
if a.db == nil {
return 0, fmt.Errorf("数据库未就绪,请查看运行日志")
}
a.videoTaskMu.Lock()
runner := a.videoTask
a.videoTaskMu.Unlock()
if runner != nil && runner.Snapshot().State == task.StateRunning {
return 0, fmt.Errorf("取视频任务运行中,不能重置商品")
}
return a.db.ResetNoneProducts()
}
// StopVideoTask 请求在当前商品已启动的下载全部结束后停止。
func (a *App) StopVideoTask() error {
a.videoTaskMu.Lock()
+12
View File
@@ -67,3 +67,15 @@ download:
# 改小了跑得快但更容易被拦,不建议低于 2 秒。
wait_seconds_min: 2
wait_seconds_max: 4
# 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。
detail_wait_seconds: 8
# 每个同款详情页前访问「我的淘宝」的深度登录守卫等待秒数。
guard_wait_seconds: 3
# 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。
risk_empty_threshold: 8
# 网络层下载失败后的重试次数;HTTP 4xx 和 ffprobe 校验失败不会重试。
download_retries: 3
+5 -2
View File
@@ -599,9 +599,12 @@ async function onTaskLoginRequired(progress) {
applyTaskProgress(progress)
taskStopping.value = false
await search(false)
const riskSuspected = progress?.stopReason === 'risk_suspected'
dialog.warning({
title: '淘宝登录已失效',
content: '淘宝登录已失效,整批任务已停止。\n已完成的部分不会重跑。请重新登录后再试',
title: riskSuspected ? '淘宝疑似风控' : '淘宝登录已失效',
content: riskSuspected
? `淘宝疑似风控:登录仍然有效,但连续 ${progress?.riskEmptyCount || '多个'} 个同款都取不到视频。\n整批任务已停止,相关商品保持待处理,可稍后重试。`
: '淘宝登录已失效,整批任务已停止。\n已完成的部分不会重跑。请重新登录后再试',
positiveText: '前往参数设置',
negativeText: '稍后处理',
onPositiveClick: goToSettings,
+91 -3
View File
@@ -10,8 +10,9 @@
* 2. 路径不要求手打:chrome.exe 用文件选择框,两个目录用目录选择框。
* Windows 路径手打太容易错。
*/
import { onMounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import { onMounted, onUnmounted, ref } from 'vue'
import { useDialog, useMessage } from 'naive-ui'
import * as AppBindings from '../../wailsjs/go/main/App'
import {
CheckTaobaoLogin,
GetConfig,
@@ -23,11 +24,15 @@ import {
} from '../../wailsjs/go/main/App'
const message = useMessage()
const dialog = useDialog()
const cfg = ref(null)
const saving = ref(false)
const openingTaobao = ref(false)
const checkingTaobao = ref(false)
const taobaoStatus = ref(null)
const resettingNoneProducts = ref(false)
const videoTaskRunning = ref(false)
let taskPollTimer = 0
async function load() {
try {
@@ -55,6 +60,43 @@ async function reset() {
message.info('已还原默认值,点「保存设置」才会生效')
}
async function resetNoneProducts() {
if (resettingNoneProducts.value) return
try {
const count = await AppBindings.CountResettableNoneProducts()
dialog.warning({
title: '确认重置',
content: `将把 ${count} 条标记为「无同款视频」的商品退回待处理,`
+ '它们会在下次批量任务中重新搜索淘宝。是否继续?',
positiveText: '继续重置',
negativeText: '取消',
onPositiveClick: async () => {
resettingNoneProducts.value = true
try {
const changed = await AppBindings.ResetNoneProducts()
message.success(`已重置 ${changed} 条商品`)
} catch (err) {
message.error(String(err))
} finally {
resettingNoneProducts.value = false
}
},
})
} catch (err) {
message.error(String(err))
}
}
async function refreshTaskState() {
try {
const progress = await AppBindings.GetVideoTaskProgress()
videoTaskRunning.value = progress?.state === 'running'
} catch (_) {
// 无法读取状态时保守地不发起写操作;服务端也会再次拒绝运行中的请求。
videoTaskRunning.value = true
}
}
// 使用者点了取消时后端返回空字符串,这时保持原值不动。
async function pickChrome() {
const picked = await PickChromeExe()
@@ -94,7 +136,14 @@ async function checkTaobaoLogin() {
}
}
onMounted(load)
onMounted(async () => {
await load()
await refreshTaskState()
taskPollTimer = window.setInterval(refreshTaskState, 1000)
})
onUnmounted(() => {
if (taskPollTimer) window.clearInterval(taskPollTimer)
})
</script>
<template>
@@ -128,6 +177,37 @@ onMounted(load)
<n-input v-model:value="cfg.huohanhan.password" type="password" show-password-on="click" />
</n-form-item>
</div>
<div class="two">
<n-form-item label="详情页加载等待秒数(3—30)">
<n-input-number v-model:value="cfg.download.detailWaitSeconds" :min="3" :max="30" />
</n-form-item>
<n-form-item label="登录守卫等待秒数(1—15)">
<n-input-number v-model:value="cfg.download.guardWaitSeconds" :min="1" :max="15" />
</n-form-item>
</div>
<div class="two">
<n-form-item label="疑似风控连续空结果阈值(3—50)">
<n-input-number v-model:value="cfg.download.riskEmptyThreshold" :min="3" :max="50" />
</n-form-item>
<n-form-item label="下载重试次数(0—5)">
<n-input-number v-model:value="cfg.download.downloadRetries" :min="0" :max="5" />
</n-form-item>
</div>
<div class="reset-none">
<n-button
type="warning"
:loading="resettingNoneProducts"
:disabled="videoTaskRunning"
@click="resetNoneProducts"
>
重置误判为无视频的商品
</n-button>
<div class="faint">
一次性工具:把「淘宝没搜到同款视频」的商品退回待处理,好让它们重新走一遍。
只在风控期间产生过误判时才需要点,平时不用。它不会改动货憨憨的诊断结果,
也不会删除已下载的视频记录。
</div>
</div>
</n-form>
</n-card>
@@ -296,6 +376,14 @@ onMounted(load)
color: var(--faint);
font-size: 11px;
}
.reset-none {
margin-top: 4px;
}
.reset-none .faint {
margin-top: 6px;
line-height: 1.6;
}
.taobao-actions {
min-height: 34px;
display: flex;
+38 -2
View File
@@ -77,8 +77,12 @@ type DownloadConfig struct {
// 调大会提高淘宝风控概率,没有实测结论前不要超过 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"`
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 返回一份可以直接使用的默认配置。
@@ -105,6 +109,10 @@ func Default() Config {
Concurrency: 2,
WaitSecondsMin: 2,
WaitSecondsMax: 4,
DetailWaitSeconds: 8,
GuardWaitSeconds: 3,
RiskEmptyThreshold: 8,
DownloadRetries: 3,
},
}
}
@@ -202,6 +210,18 @@ func (c Config) Validate() error {
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
}
@@ -338,6 +358,18 @@ download:
# 改小了跑得快但更容易被拦,不建议低于 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),
@@ -353,6 +385,10 @@ download:
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,
)
}
+13
View File
@@ -187,6 +187,19 @@ func TestLoadFillsMissingFieldsWithDefaults(t *testing.T) {
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 必须拒绝不合法的配置,避免把用不了的配置写进文件。
+50 -15
View File
@@ -95,59 +95,94 @@ func safeID(value string) string {
}
// Download 下载到 .part,ffprobe 校验成功后才改名为正式文件。
func (d *Downloader) Download(ctx context.Context, sourceURL, referer, target string) (Result, error) {
func (d *Downloader) Download(ctx context.Context, sourceURL, referer, target string, retries int) (Result, error) {
if info, err := os.Stat(target); err == nil && !info.IsDir() && info.Size() > 0 {
return Result{Path: target, Size: info.Size(), Skipped: true}, nil
}
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return Result{}, fmt.Errorf("创建视频目录失败:%w", err)
}
part := target + ".part"
if retries < 0 {
retries = 0
}
// 超时按「每次尝试」计,和参考实现的 curl --max-time 180 一致:
// curl 每次重试都是一次独立调用,各有完整 180 秒。
// 若把超时套在整个重试循环外面,大视频跑到一半失败后剩余预算不足,
// 重试必然再次超时,等于没有重试。
for attempt := 0; attempt <= retries; attempt++ {
result, retryable, err := d.attemptDownload(ctx, sourceURL, referer, target, part)
if err == nil {
return result, nil
}
if !retryable || attempt == retries {
return Result{}, err
}
wait := time.Second << attempt
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return Result{}, ctx.Err()
case <-timer.C:
}
}
return Result{}, fmt.Errorf("下载视频请求失败")
}
// attemptDownload 为单次尝试套上独立的 180 秒超时。父 context 被取消时
// 仍然立即中止,停止任务不会卡在这里。
func (d *Downloader) attemptDownload(ctx context.Context, sourceURL, referer, target, part string) (Result, bool, error) {
attemptCtx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
return d.downloadOnce(attemptCtx, sourceURL, referer, target, part)
}
// downloadOnce 只把可由网络恢复的失败标为 retryable;内容校验失败绝不重试。
func (d *Downloader) downloadOnce(ctx context.Context, sourceURL, referer, target, part string) (Result, bool, error) {
_ = os.Remove(part)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return Result{}, fmt.Errorf("视频地址格式无效")
return Result{}, false, fmt.Errorf("视频地址格式无效")
}
request.Header.Set("User-Agent", "Mozilla/5.0")
request.Header.Set("Referer", referer)
response, err := d.client.Do(request)
if err != nil {
// net/http 的错误通常包含完整 URL(包括查询签名),不能向日志上抛。
return Result{}, fmt.Errorf("下载视频请求失败")
return Result{}, true, fmt.Errorf("下载视频请求失败")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, response.Body)
return Result{}, fmt.Errorf("下载视频失败:HTTP %d", response.StatusCode)
return Result{}, response.StatusCode >= 500, fmt.Errorf("下载视频失败:HTTP %d", response.StatusCode)
}
file, err := os.OpenFile(part, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return Result{}, fmt.Errorf("创建视频临时文件失败:%w", err)
return Result{}, false, fmt.Errorf("创建视频临时文件失败:%w", err)
}
_, copyErr := io.Copy(file, response.Body)
closeErr := file.Close()
if copyErr != nil {
_ = os.Remove(part)
return Result{}, fmt.Errorf("写入视频临时文件失败:%w", copyErr)
return Result{}, true, fmt.Errorf("写入视频临时文件失败:%w", copyErr)
}
if closeErr != nil {
_ = os.Remove(part)
return Result{}, fmt.Errorf("关闭视频临时文件失败:%w", closeErr)
return Result{}, false, fmt.Errorf("关闭视频临时文件失败:%w", closeErr)
}
probe, err := d.probe(ctx, part)
if err != nil {
_ = os.Remove(part)
return Result{}, fmt.Errorf("视频完整性校验失败:%w", err)
return Result{}, false, fmt.Errorf("视频完整性校验失败:%w", err)
}
info, err := os.Stat(part)
if err != nil {
_ = os.Remove(part)
return Result{}, fmt.Errorf("读取视频临时文件失败:%w", err)
return Result{}, false, fmt.Errorf("读取视频临时文件失败:%w", err)
}
size := probe.Size
if size <= 0 {
@@ -155,17 +190,17 @@ func (d *Downloader) Download(ctx context.Context, sourceURL, referer, target st
}
if size <= 0 {
_ = os.Remove(part)
return Result{}, fmt.Errorf("视频完整性校验失败:文件大小为 0")
return Result{}, false, fmt.Errorf("视频完整性校验失败:文件大小为 0")
}
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
_ = os.Remove(part)
return Result{}, fmt.Errorf("替换空目标文件失败:%w", err)
return Result{}, false, fmt.Errorf("替换空目标文件失败:%w", err)
}
if err := os.Rename(part, target); err != nil {
_ = os.Remove(part)
return Result{}, fmt.Errorf("保存正式视频文件失败:%w", err)
return Result{}, false, fmt.Errorf("保存正式视频文件失败:%w", err)
}
return Result{Path: target, Size: size, Duration: probe.Duration}, nil
return Result{Path: target, Size: size, Duration: probe.Duration}, false, nil
}
func runFFProbe(ctx context.Context, path string) (ProbeResult, error) {
+82 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
)
func Test文件名特殊字符不会生成非法路径(t *testing.T) {
@@ -54,7 +55,7 @@ func Test已存在非空文件直接跳过下载(t *testing.T) {
probes.Add(1)
return ProbeResult{}, nil
})
result, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/", target)
result, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/", target, 0)
if err != nil {
t.Fatalf("已有文件应当跳过:%v", err)
}
@@ -78,7 +79,7 @@ func Test校验失败只删除临时文件不留下正式文件(t *testing.T) {
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); err == nil {
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) {
@@ -101,7 +102,7 @@ func Test下载校验成功后改名为正式文件(t *testing.T) {
}
return ProbeResult{Duration: 12.5, Size: 16, FormatName: "mov,mp4"}, nil
})
result, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/", target)
result, err := d.Download(context.Background(), server.URL, "https://item.taobao.com/", target, 0)
if err != nil {
t.Fatalf("下载应成功:%v", err)
}
@@ -116,6 +117,84 @@ func Test下载校验成功后改名为正式文件(t *testing.T) {
}
}
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。正常是纯数字,
// 但不能假定:一旦返回带路径分隔符或 .. 的值,拼进路径就能写到
// 视频目录之外。这个测试守住清洗逻辑。
+32
View File
@@ -321,3 +321,35 @@ func (s *Store) UpdateProductStatus(id, videoStatus, downloadStatus, uploadStatu
}
return nil
}
// CountResettableNoneProducts 返回当前被标记为「无同款视频」的商品数量。
func (s *Store) CountResettableNoneProducts() (int, error) {
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM products WHERE video_status = ?`, VideoNone).Scan(&count); err != nil {
return 0, fmt.Errorf("统计可重置无视频商品失败:%w", err)
}
return count, nil
}
// ResetNoneProducts 是一次性的人工数据订正工具,用来清掉风控期间被误标为
// 「无同款视频」的记录。它不触碰诊断和 videos 历史。
//
// 这里刻意不做日期过滤。曾经想用 synced_at 限定范围,但那个字段记录的是
// 「商品数据什么时候从货憨憨拉下来的」,和「video_status 什么时候被写成 none」
// 没有关系——每点一次「下载数据」它就会被刷成当天,过滤条件随即失效。
//
// 「某个商品是否需要重做」的长期答案来自货憨憨每次全量拉取覆盖的
// video_diagnosis,不来自本地时间戳。R4c 之后风控也不会再误写 none,
// 所以本工具用完基本不会再需要。
func (s *Store) ResetNoneProducts() (int, error) {
result, err := s.db.Exec(`UPDATE products SET video_status = ?, download_status = ?, last_error = '' WHERE video_status = ?`,
VideoPending, DownloadPending, VideoNone)
if err != nil {
return 0, fmt.Errorf("重置无视频商品失败:%w", err)
}
count, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("读取重置商品数量失败:%w", err)
}
return int(count), nil
}
+56
View File
@@ -109,3 +109,59 @@ func Test重复写入商品不会清空诊断结果(t *testing.T) {
t.Fatalf("重复写入后诊断结果被清空:videoDiagnosis=%q qualityLevel=%q", got.VideoDiagnosis, got.QualityLevel)
}
}
func Test重置误判无视频商品只动none且保留其它状态(t *testing.T) {
s := newTestStore(t)
items := []Product{{ID: "none-1", ItemID: "1"}, {ID: "none-2", ItemID: "2"}, {ID: "done", ItemID: "3"}, {ID: "found", ItemID: "4"}}
if err := s.UpsertProducts(items, "2026-09-01 12:00:00"); err != nil {
t.Fatalf("写入商品失败:%v", err)
}
for _, id := range []string{"none-1", "none-2"} {
if err := s.UpdateProductStatus(id, VideoNone, DownloadFailed, "", "旧错误"); err != nil {
t.Fatalf("准备 none 状态失败:%v", err)
}
}
if err := s.UpdateProductStatus("done", VideoFound, DownloadDone, "", ""); err != nil {
t.Fatalf("准备 done 状态失败:%v", err)
}
if err := s.UpdateProductStatus("found", VideoFound, DownloadPending, "", ""); err != nil {
t.Fatalf("准备 found 状态失败:%v", err)
}
if _, err := s.DB().Exec(`INSERT INTO videos (product_id, source_item, status) VALUES ('none-1', 'source', 'downloaded')`); err != nil {
t.Fatalf("准备视频记录失败:%v", err)
}
before, _, _ := s.GetProduct("none-1")
count, err := s.CountResettableNoneProducts()
if err != nil || count != 2 {
t.Fatalf("应统计 2 条 none,count=%d err=%v", count, err)
}
changed, err := s.ResetNoneProducts()
if err != nil || changed != 2 {
t.Fatalf("应重置 2 条,changed=%d err=%v", changed, err)
}
for _, id := range []string{"none-1", "none-2"} {
p, _, _ := s.GetProduct(id)
if p.VideoStatus != VideoPending || p.DownloadStatus != DownloadPending || p.LastError != "" {
t.Fatalf("%s 未正确重置:%+v", id, p)
}
}
// 不是 none 的商品一律不得被碰。
done, _, _ := s.GetProduct("done")
found, _, _ := s.GetProduct("found")
if done.VideoStatus != VideoFound || done.DownloadStatus != DownloadDone {
t.Fatalf("已完成商品被误改:%+v", done)
}
if found.VideoStatus != VideoFound {
t.Fatalf("已找到视频的商品被误改:%+v", found)
}
// 诊断和 videos 历史都不受影响。
if p, _, _ := s.GetProduct("none-1"); p.VideoDiagnosis != before.VideoDiagnosis {
t.Fatalf("诊断不得被重置:%+v", p)
}
var videos int
if err := s.DB().QueryRow(`SELECT COUNT(*) FROM videos WHERE product_id = 'none-1'`).Scan(&videos); err != nil || videos != 1 {
t.Fatalf("videos 记录不得删除:count=%d err=%v", videos, err)
}
}
+15 -7
View File
@@ -33,28 +33,36 @@ type detailPage struct {
URLs []string `json:"urls"`
}
// DetailResult 是详情页已读取的安全摘要,供调用方紧接着执行快速登录守卫。
// 不包含 HTML 或 Cookie 值,避免把敏感会话内容带出 taobao 包。
type DetailResult struct {
Title string
Text string
URLs []string
}
// ExtractDetailVideos 打开一个淘宝商品详情页并提取可信的视频 CDN 地址。
func ExtractDetailVideos(cdp *CDP, itemID string, wait time.Duration) ([]string, error) {
func ExtractDetailVideos(cdp *CDP, itemID string, wait time.Duration) (DetailResult, error) {
itemID = strings.TrimSpace(itemID)
if itemID == "" {
return nil, fmt.Errorf("淘宝同款商品 ID 不能为空")
return DetailResult{}, fmt.Errorf("淘宝同款商品 ID 不能为空")
}
itemURL := "https://item.taobao.com/item.htm?id=" + url.QueryEscape(itemID)
if err := cdp.Navigate(itemURL, wait); err != nil {
return nil, fmt.Errorf("打开淘宝商品 %s 详情页失败:%w", itemID, err)
return DetailResult{}, fmt.Errorf("打开淘宝商品 %s 详情页失败:%w", itemID, err)
}
raw, err := cdp.Evaluate(detailVideoExpression)
if err != nil {
return nil, fmt.Errorf("读取淘宝商品 %s 详情页失败:%w", itemID, err)
return DetailResult{}, fmt.Errorf("读取淘宝商品 %s 详情页失败:%w", itemID, err)
}
var page detailPage
if err := json.Unmarshal([]byte(raw), &page); err != nil {
return nil, fmt.Errorf("淘宝商品 %s 详情页结果不是有效 JSON:%w", itemID, err)
return DetailResult{}, fmt.Errorf("淘宝商品 %s 详情页结果不是有效 JSON:%w", itemID, err)
}
if detailPageRequiresLogin(page) {
return nil, fmt.Errorf("%w:商品 %s 详情页要求重新登录或安全验证", ErrLoginInvalid, itemID)
return DetailResult{}, fmt.Errorf("%w:商品 %s 详情页要求重新登录或安全验证", ErrLoginInvalid, itemID)
}
return filterVideoURLs(page.URLs), nil
return DetailResult{Title: page.Title, Text: page.Text, URLs: filterVideoURLs(page.URLs)}, nil
}
func detailPageRequiresLogin(page detailPage) bool {
+3 -1
View File
@@ -34,7 +34,9 @@ func SearchByImage(ctx context.Context, cdp *CDP, imageURL string) ([]SimilarIte
if err != nil {
return nil, err
}
if err := cdp.Navigate("https://www.taobao.com/", 3*time.Second); err != nil {
// 6 秒与参考实现一致。后面的签名请求是在这个页面的上下文里 fetch 的,
// 页面没加载完就发,可能带不上 Cookie,表现为图搜莫名失败。不要调小。
if err := cdp.Navigate("https://www.taobao.com/", 6*time.Second); err != nil {
return nil, fmt.Errorf("打开淘宝首页失败:%w", err)
}
strimg, err := resizeImage(cdp, dataURL)
+15 -2
View File
@@ -23,7 +23,7 @@ type LoginStatus struct {
// CheckLogin 先检查必要 Cookie 名称,再到「我的淘宝」做服务端深度检查。
// 它不会读取 Cookie 值,也不会自动登录、重试或处理任何安全验证。
func CheckLogin(cdp *CDP) (LoginStatus, error) {
func CheckLogin(cdp *CDP, wait time.Duration) (LoginStatus, error) {
if cdp == nil {
return LoginStatus{}, fmt.Errorf("Chrome 调试连接未建立")
}
@@ -36,7 +36,7 @@ func CheckLogin(cdp *CDP) (LoginStatus, error) {
return initial, nil
}
if err := cdp.Navigate("https://i.taobao.com/my_itaobao", 6*time.Second); err != nil {
if err := cdp.Navigate("https://i.taobao.com/my_itaobao", wait); err != nil {
return LoginStatus{}, fmt.Errorf("打开「我的淘宝」进行登录检查失败:%w", err)
}
pageJSON, err := cdp.Evaluate(`JSON.stringify({title:document.title,text:(document.body?.innerText||'').slice(0,1200)})`)
@@ -53,6 +53,19 @@ func CheckLogin(cdp *CDP) (LoginStatus, error) {
return judgeLogin(cookieNames, page.Title, page.Text), nil
}
// QuickCheckLogin 使用已读取的页面信息做不导航的 Cookie 级快速守卫。
// 它严格复用 judgeLogin,避免详情页和深度守卫出现两套判定标准。
func QuickCheckLogin(cdp *CDP, title, body string) (LoginStatus, error) {
if cdp == nil {
return LoginStatus{}, fmt.Errorf("Chrome 调试连接未建立")
}
cookieNames, err := cdp.CookieNames("taobao.com")
if err != nil {
return LoginStatus{}, fmt.Errorf("检查淘宝 Cookie 名称失败:%w", err)
}
return judgeLogin(cookieNames, title, body), nil
}
// judgeLogin 是不依赖 Chrome 的纯判定函数,单元测试用它覆盖停止门边界。
func judgeLogin(cookieNames map[string]bool, title, body string) LoginStatus {
status := LoginStatus{PageTitle: title, MissingCookies: []string{}}
+68 -9
View File
@@ -21,19 +21,59 @@ const (
// ErrLoginRequired 是全局停止门。Prepare 返回此错误后,Runner 不再启动后续商品。
var ErrLoginRequired = errors.New("淘宝登录已失效")
// ErrRiskSuspected 表示登录仍有效但详情页连续不下发视频资源。
// 它和 ErrLoginRequired 共用全局停止门,前端以 StopReason 区分提示。
var ErrRiskSuspected = errors.New("淘宝疑似风控降级")
const StopReasonRiskSuspected = "risk_suspected"
type RiskSuspectedError struct{ Consecutive int }
func (e RiskSuspectedError) Error() string {
return fmt.Sprintf("连续 %d 个同款详情页未下发视频", e.Consecutive)
}
// Progress 是批量任务对界面公开的只读进度。
type Progress struct {
Total int `json:"total"`
Done int `json:"done"`
Current string `json:"current"`
CurrentName string `json:"currentName"`
Downloaded int `json:"downloaded"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
ElapsedSec int `json:"elapsedSec"`
State string `json:"state"`
Total int `json:"total"`
Done int `json:"done"`
Current string `json:"current"`
CurrentName string `json:"currentName"`
Downloaded int `json:"downloaded"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
ElapsedSec int `json:"elapsedSec"`
State string `json:"state"`
StopReason string `json:"stopReason"`
RiskEmptyCount int `json:"riskEmptyCount"`
}
// EmptyRiskGuard 在一次调用或一个批量任务内累计正常详情页的连续空结果。
// 详情页打开失败不调用 Record,因此不会误判为会话级降级。
type EmptyRiskGuard struct {
threshold int
consecutive int
}
func NewEmptyRiskGuard(threshold int) *EmptyRiskGuard {
if threshold < 1 {
threshold = 1
}
return &EmptyRiskGuard{threshold: threshold}
}
// Record 返回是否达到疑似风控阈值。发现任一视频即清零。
func (g *EmptyRiskGuard) Record(videoCount int) bool {
if videoCount > 0 {
g.consecutive = 0
return false
}
g.consecutive++
return g.consecutive >= g.threshold
}
func (g *EmptyRiskGuard) Consecutive() int { return g.consecutive }
// Product 是 Runner 判断断点与显示当前商品所需的最小信息。
type Product struct {
ID string
@@ -178,6 +218,13 @@ func (r *Runner) run(ctx context.Context, queue []queuedProduct) {
} else {
work, err := r.options.Prepare(ctx, item.product)
if errors.Is(err, ErrLoginRequired) {
if errors.Is(err, ErrRiskSuspected) {
r.setStopReason(StopReasonRiskSuspected)
var risk RiskSuspectedError
if errors.As(err, &risk) {
r.setRiskEmptyCount(risk.Consecutive)
}
}
r.finish(StateLoginRequired)
return
}
@@ -208,6 +255,18 @@ func (r *Runner) run(ctx context.Context, queue []queuedProduct) {
r.finish(StateCompleted)
}
func (r *Runner) setStopReason(reason string) {
r.mu.Lock()
r.progress.StopReason = reason
r.mu.Unlock()
}
func (r *Runner) setRiskEmptyCount(count int) {
r.mu.Lock()
r.progress.RiskEmptyCount = count
r.mu.Unlock()
}
func (r *Runner) setCurrent(product Product) {
r.mu.Lock()
r.progress.Current = product.ItemID
+37
View File
@@ -93,6 +93,43 @@ func Test登录失效立即触发全局停止门(t *testing.T) {
}
}
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