From 84bc32cf90be345ab604c023379575e2f98cba1a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 29 Sep 2026 11:34:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=AF=8F=E6=89=B9=E4=BB=85=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E6=B7=B1=E5=BA=A6=E6=A3=80=E6=9F=A5=E6=B7=98=E5=AE=9D?= =?UTF-8?q?=E7=99=BB=E5=BD=95=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- app.go | 15 +-- app_video_test.go | 105 ++++++++++++++++-- config.example.yaml | 2 +- docs/01-workflow.md | 6 +- docs/02-architecture-and-code-map.md | 8 +- docs/03-business-rules-and-glossary.md | 14 +-- docs/04-local-development-and-verification.md | 8 +- docs/06-troubleshooting.md | 8 +- internal/config/config.go | 2 +- internal/taobao/access_test.go | 65 +++++++++++ internal/taobao/candidates.go | 2 +- internal/taobao/login.go | 38 ++++++- 13 files changed, 235 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79c0854..6419da9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,6 +249,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 ### 内部系统实施授权(2026-09-28) - 本项目为内部系统,不设置独立的方案、原型或高风险实施人工确认门禁。用户已明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进;不得仅因 API 接入、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。 +- 登录检查的产品停止条件属于运行时保护,不是实施审批门禁;本项目允许按用户明确指令调整检查频率与导航流程,修改规则、建单、验证后直接实施,不追加确认。 - 本条优先于本文件及 Wiki 通用流程中“高风险必须停止并等待人工确认”等实施门禁表述。风险等级仍用于确定工单、设计证据、测试、回退和证据记录要求,不代表额外审批。 - 是否建工单继续按现有治理规则判断;需要工单时,以用户明确的实施指令和任务范围作为授权基线,先建单再修改,无需单独确认方案。内部实现选择由 Agent 决定并记录;目标或范围不明确时只澄清缺失信息,不增加审批。 - 不取消凭据保护、真实验证、用户已有改动保护及业务红线。发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移及其他不可逆操作仍须有明确操作授权;已有授权不重复索要。 @@ -266,7 +267,8 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - 同一个 Chrome Profile 不得同时启动两个实例。启动前必须校验 PID 存在、进程命令属于本程序专属 Profile、CDP 端口可访问且存在 `page` 类型目标。 - 淘宝登录失效是全局停止门:必须中断整批任务并保留断点,不得记为单商品失败后继续,不得自动反复请求 MTOP 接口。 -- 每个商品开始处理前都要重新执行服务端深度登录检查,不能只依赖任务开始时的一次结果。 +- 每次新启动或手动恢复的取视频批次,仅在首次实际访问淘宝时打开「我的淘宝」做服务端深度登录检查;后续商品在当前页面检查 Cookie 名称、登录重定向和访问异常,不再为检查导航到「我的淘宝」。单商品/独立图搜入口各自做首次检查;使用者显式点击登录检查仍可深度检查。 +- 首次检查通过不代表后续永久有效:图搜认证失败、安全验证、访问异常和详情登录失效仍须立即全局停止并保留断点,不自动登录、重试或恢复轮询。 - 货憨憨的写操作(上传素材、批量修改商品、删除素材)必须由调用方显式确认,不得作为查询流程的副作用发生。 - 对真实店铺的批量写入、覆盖和删除属于不可逆操作,必须获得用户明确授权后才执行。 - SQLite 是任务状态的唯一事实来源,不得再用 JSON 文件维护第二份任务状态。 diff --git a/app.go b/app.go index c7e52be..5a23238 100644 --- a/app.go +++ b/app.go @@ -388,7 +388,7 @@ type UploadPreview struct { func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) { ctx := a.appContext() riskGuard := task.NewEmptyRiskGuard(a.cfg.Download.RiskEmptyThreshold) - result, work, err := a.prepareVideoFetch(ctx, productID, riskGuard, make(taobao.DetailCache)) + result, work, err := a.prepareVideoFetch(ctx, productID, riskGuard, make(taobao.DetailCache), &taobao.LoginGuard{}) if err != nil { return result, err } @@ -402,7 +402,7 @@ func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) { // prepareVideoFetch 串行完成一个商品所有会触碰 CDP 的步骤,并把纯 HTTP // 下载闭包交给调用方。批量 Runner 因此不会让多个协程争抢同一个页面。 -func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard *task.EmptyRiskGuard, cache taobao.DetailCache) (FetchResult, task.Work, error) { +func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard *task.EmptyRiskGuard, cache taobao.DetailCache, loginGuard *taobao.LoginGuard) (FetchResult, task.Work, error) { productID = strings.TrimSpace(productID) result := FetchResult{ProductID: productID} if a.db == nil { @@ -443,23 +443,23 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard return a.prepareVideoFetchOnPage(ctx, product, cdp, func() ([]taobao.SimilarItem, error) { return taobao.SearchByImage(ctx, cdp, product.MainImage) - }, riskGuard, cache) + }, riskGuard, cache, loginGuard) } // prepareVideoFetchOnPage 将导航和状态收口放在同一流程中,便于离线验证停止门。 -func (a *App) prepareVideoFetchOnPage(ctx context.Context, product store.Product, cdp taobao.PageSession, search func() ([]taobao.SimilarItem, error), riskGuard *task.EmptyRiskGuard, cache taobao.DetailCache) (FetchResult, task.Work, error) { +func (a *App) prepareVideoFetchOnPage(ctx context.Context, product store.Product, cdp taobao.PageSession, search func() ([]taobao.SimilarItem, error), riskGuard *task.EmptyRiskGuard, cache taobao.DetailCache, loginGuard *taobao.LoginGuard) (FetchResult, task.Work, error) { result := FetchResult{ProductID: product.ID} if err := ctx.Err(); err != nil { return result, task.Work{}, err } - login, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second))) + login, err := loginGuard.Check(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second))) if err := ctx.Err(); err != nil { return result, task.Work{}, err } if err != nil { login.Message = err.Error() a.emitTaobaoStatus(login) - a.log.Error("商品 %s 取视频前深度检查淘宝登录失败:%v", product.ID, err) + a.log.Error("商品 %s 取视频前检查淘宝登录失败:%v", product.ID, err) return result, task.Work{}, fmt.Errorf("%w:%v", task.ErrLoginRequired, err) } a.emitTaobaoStatus(login) @@ -868,6 +868,7 @@ func (a *App) StartVideoTask(productIDs []string) error { riskGuard := task.NewEmptyRiskGuard(a.cfg.Download.RiskEmptyThreshold) detailCache := make(taobao.DetailCache) + loginGuard := &taobao.LoginGuard{} runner := task.NewRunner(task.Options{ Concurrency: a.cfg.Download.Concurrency, WaitMin: time.Duration(a.cfg.Download.WaitSecondsMin * float64(time.Second)), @@ -889,7 +890,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, riskGuard, detailCache) + _, work, err := a.prepareVideoFetch(ctx, product.ID, riskGuard, detailCache, loginGuard) return work, err }, OnProgress: func(progress task.Progress) { diff --git a/app_video_test.go b/app_video_test.go index dc48c4d..1881517 100644 --- a/app_video_test.go +++ b/app_video_test.go @@ -17,10 +17,12 @@ import ( ) type videoTestPage struct { - url string - navigations []string - blocked string - deepBlocked bool + url string + navigations []string + blocked string + deepBlocked bool + missingCookie bool + evaluateErr bool } func (p *videoTestPage) Navigate(url string, _ time.Duration) error { @@ -29,9 +31,15 @@ func (p *videoTestPage) Navigate(url string, _ time.Duration) error { return nil } func (p *videoTestPage) CookieNames(string) (map[string]bool, error) { + if p.missingCookie { + return map[string]bool{}, nil + } return map[string]bool{"_m_h5_tk": true, "_m_h5_tk_enc": true, "_tb_token_": true, "tracknick": true}, nil } func (p *videoTestPage) Evaluate(string) (string, error) { + if p.evaluateErr { + return "", errors.New("fictional page read failure") + } text := "normal fictional page" if strings.Contains(p.url, "item.htm") || p.deepBlocked { text = p.blocked @@ -89,7 +97,7 @@ func TestRiskStopPreservesSQLiteProductVideosAndRemainingQueue(t *testing.T) { Load: func(_ context.Context, id string) (task.Product, error) { return task.Product{ID: id}, nil }, Prepare: func(ctx context.Context, p task.Product) (task.Work, error) { product, _, _ := a.db.GetProduct(p.ID) - _, work, err := a.prepareVideoFetchOnPage(ctx, product, page, search, guard, make(taobao.DetailCache)) + _, work, err := a.prepareVideoFetchOnPage(ctx, product, page, search, guard, make(taobao.DetailCache), &taobao.LoginGuard{}) return work, err }, OnFinished: func(p task.Progress) { finished <- p }, @@ -125,14 +133,18 @@ func TestRiskStopPreservesSQLiteProductVideosAndRemainingQueue(t *testing.T) { } } -func TestEachProductDeepChecksOnceAndCandidatesReuseWithoutLoginNavigation(t *testing.T) { +func TestBatchDeepChecksOnceAndCandidatesReuseWithoutLoginNavigation(t *testing.T) { a := newVideoTestApp(t) product, _, _ := a.db.GetProduct("current") page := &videoTestPage{} cache := make(taobao.DetailCache) + loginGuard := &taobao.LoginGuard{} search := func() ([]taobao.SimilarItem, error) { return []taobao.SimilarItem{{ItemID: "1"}, {ItemID: "2"}}, nil } for i := 0; i < 2; i++ { - _, work, err := a.prepareVideoFetchOnPage(context.Background(), product, page, search, task.NewEmptyRiskGuard(8), cache) + if i == 1 { + product, _, _ = a.db.GetProduct("remaining") + } + _, work, err := a.prepareVideoFetchOnPage(context.Background(), product, page, search, task.NewEmptyRiskGuard(8), cache, loginGuard) if err != nil || !work.Skipped { t.Fatalf("normal empty detail: %v", err) } @@ -145,7 +157,7 @@ func TestEachProductDeepChecksOnceAndCandidatesReuseWithoutLoginNavigation(t *te detail++ } } - if deep != 2 || detail != 2 { + if deep != 1 || detail != 2 { t.Fatalf("incorrect deep/candidate navigation counts: %d/%d", deep, detail) } } @@ -174,7 +186,7 @@ func TestCancelledPreparationPreservesProduct(t *testing.T) { product, _, _ := a.db.GetProduct("current") ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := a.prepareVideoFetchOnPage(ctx, product, &videoTestPage{}, func() ([]taobao.SimilarItem, error) { t.Fatal("cancelled search called"); return nil, nil }, nil, nil) + _, _, err := a.prepareVideoFetchOnPage(ctx, product, &videoTestPage{}, func() ([]taobao.SimilarItem, error) { t.Fatal("cancelled search called"); return nil, nil }, nil, nil, &taobao.LoginGuard{}) if !errors.Is(err, context.Canceled) { t.Fatal(err) } @@ -183,3 +195,78 @@ func TestCancelledPreparationPreservesProduct(t *testing.T) { t.Fatal("cancel changed state") } } + +func TestSecondProductCurrentGuardStopsBeforeSearchAndPreservesSQLite(t *testing.T) { + for _, scenario := range []string{"login", "punish", "risk", "cookie", "read"} { + t.Run(scenario, func(t *testing.T) { + a := newVideoTestApp(t) + if err := a.db.UpsertProducts([]store.Product{{ID: "first", ItemID: "fictional-first"}}, "fictional-time"); err != nil { + t.Fatal(err) + } + before, _, _ := a.db.GetProduct("current") + remaining, _, _ := a.db.GetProduct("remaining") + videos, _ := a.db.ListVideos("current") + page := &videoTestPage{} + loginGuard := &taobao.LoginGuard{} + cache := make(taobao.DetailCache) + searches := 0 + finished := make(chan task.Progress, 1) + runner := task.NewRunner(task.Options{ + Load: func(_ context.Context, id string) (task.Product, error) { return task.Product{ID: id}, nil }, + Prepare: func(ctx context.Context, p task.Product) (task.Work, error) { + if p.ID == "current" { + switch scenario { + case "login": + page.url = "https://login.taobao.com/member/login.jhtml" + case "punish": + page.url = "https://item.taobao.com/punish" + case "risk": + page.url = "https://item.taobao.com/item.htm?id=prior" + page.blocked = "系统检测到当前访问存在异常" + case "cookie": + page.missingCookie = true + case "read": + page.evaluateErr = true + } + } + product, _, _ := a.db.GetProduct(p.ID) + _, work, err := a.prepareVideoFetchOnPage(ctx, product, page, func() ([]taobao.SimilarItem, error) { + searches++ + return []taobao.SimilarItem{{ItemID: "fictional-candidate"}}, nil + }, task.NewEmptyRiskGuard(8), cache, loginGuard) + return work, err + }, + OnFinished: func(p task.Progress) { finished <- p }, + }) + if err := runner.Start(context.Background(), []string{"first", "current", "remaining"}); err != nil { + t.Fatal(err) + } + select { + case progress := <-finished: + if progress.State != task.StateLoginRequired || progress.Done != 1 || progress.Failed != 0 || progress.Skipped != 1 { + t.Fatalf("wrong stop: %+v", progress) + } + if (scenario == "risk" || scenario == "punish") && progress.StopReason != task.StopReasonRiskBlocked { + t.Fatal("lost risk stop reason") + } + case <-time.After(time.Second): + t.Fatal("second product guard failed to stop") + } + deep := 0 + for _, u := range page.navigations { + if strings.Contains(u, "my_itaobao") { + deep++ + } + } + if deep != 1 || searches != 1 { + t.Fatalf("guard navigated or searched after failure: deep=%d search=%d", deep, searches) + } + after, _, _ := a.db.GetProduct("current") + remainingAfter, _, _ := a.db.GetProduct("remaining") + videosAfter, _ := a.db.ListVideos("current") + if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(remaining, remainingAfter) || !reflect.DeepEqual(videos, videosAfter) { + t.Fatal("current guard failure changed states") + } + }) + } +} diff --git a/config.example.yaml b/config.example.yaml index 9a3b1d9..a4baff7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -77,7 +77,7 @@ download: # 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。 detail_wait_seconds: 8 - # 每个商品开始前访问「我的淘宝」的深度登录守卫等待秒数。 + # 每批首次访问「我的淘宝」的深度登录守卫等待秒数。 guard_wait_seconds: 3 # 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。 diff --git a/docs/01-workflow.md b/docs/01-workflow.md index 5e19ed9..362ae63 100644 --- a/docs/01-workflow.md +++ b/docs/01-workflow.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Development-Workflow wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Development-Workflow.- -wiki_revision: 9bee25b223a7e74ca4d80b4a662fa29551e9118f -synchronized_at: 2026-09-28T09:47:38Z +wiki_revision: d563ae5ac8988e71bb6e105e34bc659ab62f38a8 +synchronized_at: 2026-09-29T03:33:19Z # 开发工作流 @@ -48,6 +48,8 @@ Gitea 暂时不可用时可以准备工单和 Wiki 草稿,但不得把本地 ## 内部系统实施授权(2026-09-28) +2026-09-29 补充:登录失效和风控停止条件属于运行时保护,不是实施审批门禁。用户明确要求调整检查频率或导航流程时,更新 AGENTS.md 规则、建单并验证后直接实施,不追加确认。当前规则是每批首次深度检查,后续当前页面守卫;真实店铺不可逆写入与凭据保护仍按原边界执行。 + 本项目为内部系统,不设置独立方案、原型或高风险实施人工确认门禁。用户明确要求实施且目标与预期行为明确时,Agent 完成必要分析后直接推进,不因 API、配置、凭据存取、SQLite 迁移或并发等风险分类再次索要实施许可。此规则优先于通用流程中的额外实施审批表述。 风险仍用于确定工单、设计证据、测试、回退和证据要求。需要工单时,以用户实施指令和明确范围作为授权基线,先建单再修改,无需单独审批方案。目标或必要信息不明确时澄清,不增加审批。凭据保护、业务红线、真实验证、工作区保护与最终验收保持有效;发布、支付、真实店铺批量写入/覆盖/删除、破坏性迁移和其他不可逆操作仍需明确操作授权,已有授权不重复索要。只要求分析、建单或文档时不扩大为产品实施。真实依赖未满足或违反安全规则时仍停止;工单完成后保持待验收,用户验收后关闭。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 2731211..2a8a073 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,15 +2,15 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Architecture-and-Code-Map.- -wiki_revision: 6e4a9680d98c0bf634a8b1e40195551bdba03cdf -synchronized_at: 2026-09-29T02:18:10Z +wiki_revision: e9367a04bc7af1ffbf956c9ba406fdd99f0df8ee +synchronized_at: 2026-09-29T03:33:19Z # 架构与代码地图 ## 淘宝访问异常停止与候选复用(2026-09-29) -`App.prepareVideoFetch` 建立专属 Chrome 连接;`prepareVideoFetchOnPage` 在每个商品开始前调用 `taobao.CheckLogin` 深度检查。图搜首页、MTOP 返回和详情页中的明确访问异常均映射到任务全局停止门,签名算法及请求参数不变。 +`App.prepareVideoFetch` 建立专属 Chrome 连接;`prepareVideoFetchOnPage` 调用本批 `taobao.LoginGuard.Check`:首次实际处理调用 `CheckLogin` 打开「我的淘宝」深度检查,后续商品调用 `CheckCurrentLogin` 读取当前 URL/标题/有限正文和 Cookie 名称,不发送检查导航。图搜首页、MTOP 返回和详情页中的明确访问异常均映射到任务全局停止门,签名算法及请求参数不变。 `internal/taobao/candidates.go` 的 `SelectVideo` 串行检查不同淘宝商品 ID,仅调用详情页快速守卫,不在候选间导航到「我的淘宝」。正常详情 URL/空结果在一次批次内复用,异常不缓存,批次结束清空;不缓存页面正文、Cookie 或签名。缓存是临时查询结果,SQLite 仍是任务状态唯一事实来源。 @@ -137,7 +137,7 @@ cmsp/ → 事件推送进度 ``` -每个商品开始前重新执行深度登录检查。登录失效是全局停止条件,不是单个商品的失败。 +每个新启动或手动恢复的批次只在首次实际访问淘宝时做深度登录检查;后续商品在当前页面检查。`LoginGuard` 仅记录本批首次深度检查通过标志,不缓存凭据或服务器登录有效结论。单商品、独立图搜各自首次深度检查;显式登录检查按钮仍可深度检查。登录失效及访问异常是全局停止条件,不是单个商品的失败。 详细的登录检查、签名和接口约定见[业务规则与术语](03-business-rules-and-glossary.md)的「淘宝登录」与「淘宝以图搜」两节。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index ea6bc91..1455a91 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Business-Rules-and-Glossary.- -wiki_revision: 93e37d506753dcfaaf6b89770cf5342943a585ad -synchronized_at: 2026-09-29T02:18:10Z +wiki_revision: 6fa4581023a61ca0d5479cf46d696688b4d266f3 +synchronized_at: 2026-09-29T03:33:20Z # 业务规则与术语 @@ -13,7 +13,7 @@ synchronized_at: 2026-09-29T02:18:10Z - 明确访问异常:页面出现「当前访问存在异常」「恢复正常访问方式」「访问受限」、安全验证/验证码或 `/punish` 路径时立即停批,`stopReason=risk_blocked`。即使 Cookie 齐全或页面残留视频 URL,也不继续访问。 - 登录重定向、必要 Cookie 缺失以及 MTOP token/session 过期走 `login_required`;正常新访问详情连续无视频仍走 `risk_suspected`,缓存命中不增加连续空页面计数。 - 三类停止均保留当前原有状态及后续断点,不记当前商品失败或无视频,不自动重试或轮询恢复。用户在专属 Chrome 手动处理,恢复后手动启动。 -- 每个待处理商品都深度检查一次;候选之间保留快速守卫,取消额外登录页跳转。正常详情按淘宝商品 ID 同批复用,异常及普通读取失败不缓存。 +- 每批首次实际处理做一次深度检查,后续商品只检查当前页面与 Cookie 名称;候选之间保留快速守卫,不再为检查跳转登录页。正常详情按淘宝商品 ID 同批复用,异常及普通读取失败不缓存。 - 默认最多检查 5 个不同同款,商品和候选间等待 10—20 秒;页面加载等待仍为详情 8 秒、深度守卫 3 秒。此参数仅降低负载,不是平台公布安全阈值,不承诺解除限制。既有配置正常读取时不自动覆盖。 - `concurrency` 仅控制视频 CDN 文件下载,淘宝页面访问保持串行。 @@ -66,9 +66,9 @@ synchronized_at: 2026-09-29T02:18:10Z ### 淘宝登录 1. 登录由使用者在专属 Chrome 中手动完成,程序不代填账号密码、不绕过验证码或安全验证。 -2. 登录状态判定必须同时满足两层:`_m_h5_tk`、`_m_h5_tk_enc`、`_tb_token_`、`tracknick` 四个 Cookie 均存在;且「我的淘宝」页面标题、最终地址与正文都不含阻断词。 -3. 只有 Cookie 齐全不足以判定登录有效,必须做服务端深度检查。 -4. 每个商品开始处理前都要重新执行深度登录检查,不能只依赖任务开始时的一次结果。 +2. 每批首次深度检查需满足两层:`_m_h5_tk`、`_m_h5_tk_enc`、`_tb_token_`、`tracknick` 四个 Cookie 均存在;且「我的淘宝」页面标题、最终地址与正文无登录/访问阻断。 +3. Cookie 齐全不足以证明服务器登录有效,首次做深度检查;后续以当前页面状态和实际 MTOP/详情响应持续守卫,不长期复用首次有效结论。 +4. 后续商品开始前读取当前页面 URL/标题/有限正文和 Cookie 名称,不再打开「我的淘宝」;缺失、登录重定向、访问异常或读取失败立即停止。新启动/手动恢复批次重新首次深度检查;单商品与独立图搜各自首次检查;显式检查登录按钮仍可深度检查。 5. 登录失效时立即中断整批任务并保存断点,提示使用者重新登录;不得自动重试 MTOP 接口。 ### 淘宝以图搜 @@ -77,7 +77,7 @@ synchronized_at: 2026-09-29T02:18:10Z 7. `_m_h5_tk` 每次任务都从当前 Chrome 会话重新读取,不缓存、不长期复用。 8. 请求必须在已登录的淘宝页面上下文中发出,由浏览器按规则自动携带 Cookie,程序不手工拼接 Cookie 请求头。 9. 商品列表从响应的 `data.itemsArray` 读取;商品 ID 依次尝试 `item_id`、`itemId`、`nid`,取不到则忽略该条。 -10. Token 过期时最多自动重签重试一次,仍失败则转入深度登录检查。 +10. Token/session 过期立即全局停止并保留断点,不自动重签重试或再次跳转我的淘宝;由使用者手动处理后启动新批次。 ### 视频抓取与下载 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index b58686d..7f33435 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Local-Development-and-Verification.- -wiki_revision: 7b70ec840faeaa739a962d0dc9cf6932aa5cee0a -synchronized_at: 2026-09-29T02:18:11Z +wiki_revision: 5027339b211f8d7762449b4e579a4886a2e8bff4 +synchronized_at: 2026-09-29T03:33:20Z # 本地开发与验证 @@ -17,10 +17,10 @@ npm --prefix frontend run build go test ./... go test -race ./... go vet ./... -& "$env:USERPROFILE\go\bin\wails.exe" build -o cmsp21.exe +& "$env:USERPROFILE\go\bin\wails.exe" build -o cmsp22.exe ``` -`-race` 需要本机支持 CGO 的 C 编译器。`app_video_test.go` 使用临时 SQLite 和模拟页面,覆盖深度检查、图搜、详情及疑似风控停止后的状态保留。`internal/taobao/access_test.go` 覆盖明确提示、重定向、同批去重/复用、候选上限与可取消等待;`internal/task/task_test.go` 覆盖停批不计失败及保留当前位置。测试不打开真实专属 Profile、不向淘宝发请求、不写真实店铺。 +`-race` 需要本机支持 CGO 的 C 编译器。`app_video_test.go` 使用临时 SQLite 和模拟页面,覆盖同批多商品只深度检查一次、后续当前页面守卫不导航且在图搜前停止,以及深度检查、图搜、详情及疑似风控停止后的状态保留。`internal/taobao/access_test.go` 覆盖批次首次检查、新批次重新深度检查、后续登录/风控重定向、Cookie 缺失、页面读取/JSON 失败,以及明确提示、同批去重/复用、候选上限与可取消等待;`internal/task/task_test.go` 覆盖停批不计失败及保留当前位置。测试不打开真实专属 Profile、不向淘宝发请求、不写真实店铺。 新默认配置为 `search_top_n=5`、`wait_seconds_min=10`、`wait_seconds_max=20`,旧明确配置保持兼容。本次本机旧组合 20/2/4 已按用户授权改为新参数,仅修改这三个数值,未改变其他字段和凭据。平台恢复时间、真实风控触发率及 Windows WebView2 实际提示须由用户恢复访问后观察,模拟测试不能证明平台解除限制。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 7ca49d3..a71a11d 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Troubleshooting -wiki_revision: bc67a730d9eae1d350a4aa9b5884aeb031ca4dab -synchronized_at: 2026-09-29T02:18:11Z +wiki_revision: 8e56ccb600bd56584ca513fe87bd2cd05f4328da +synchronized_at: 2026-09-29T03:33:21Z # 故障排查 @@ -16,6 +16,8 @@ synchronized_at: 2026-09-29T02:18:11Z 三种停止原因:`risk_blocked` 是明确访问异常/安全验证;`login_required` 是登录或 token/session 失效;`risk_suspected` 是正常新详情连续无视频,属于保守停止,不能据此证明被平台封禁。恢复后手动启动,SQLite 已完成记录继续跳过。历史无视频状态不自动清空。 +每次新启动或手动恢复仅首次处理打开「我的淘宝」,后续在当前页面检查,不再为检查跳转。当前页面守卫不主动证明服务器登录有效;MTOP 认证失效或详情异常仍会停批,首次成功不代表后续永久有效。显式登录检查按钮仍会打开「我的淘宝」。 + 先以默认候选 5、等待 10—20 秒小批量观察。增加等待仅降低访问负载,不保证解除限制;下载并发数只控制 CDN 文件,不增加淘宝页面并行度。 ## erpgo 店铺与商品同步排查(2026-09-28) @@ -93,7 +95,7 @@ synchronized_at: 2026-09-29T02:18:11Z 按顺序看:HTTP 状态码 → 业务返回码 → 商品数量。 - 状态码非 200:网络或接口地址问题。 -- 状态码 200 但业务返回码不含成功标记:多为签名或登录态问题,先重新读取 `_m_h5_tk` 重签一次。 +- 状态码 200 但业务返回码不含成功标记:检查固定错误分类;token/session 失效立即停批,不自动重签重试,用户手动处理后新启动。其他错误不输出完整返回正文。 - 成功但商品数为 0:多为主图不清晰或商品过于小众,建议更换主图,不是程序缺陷。 ### 6. 检查下载 diff --git a/internal/config/config.go b/internal/config/config.go index e069241..d02a660 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -398,7 +398,7 @@ download: # 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。 detail_wait_seconds: %s - # 每个商品开始前访问「我的淘宝」的深度登录守卫等待秒数。 + # 每批首次访问「我的淘宝」的深度登录守卫等待秒数。 guard_wait_seconds: %s # 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。 diff --git a/internal/taobao/access_test.go b/internal/taobao/access_test.go index 2316b86..735caa2 100644 --- a/internal/taobao/access_test.go +++ b/internal/taobao/access_test.go @@ -17,6 +17,8 @@ type fakePage struct { cookies map[string]bool fail bool onNavigate func() + evaluateErr bool + evaluateRaw string } func (p *fakePage) Navigate(url string, _ time.Duration) error { @@ -31,6 +33,12 @@ func (p *fakePage) Navigate(url string, _ time.Duration) error { return nil } func (p *fakePage) Evaluate(string) (string, error) { + if p.evaluateErr { + return "", errors.New("fictional evaluate failure") + } + if p.evaluateRaw != "" { + return p.evaluateRaw, nil + } page := p.pages[p.url] if page.URL == "" { page.URL = p.url @@ -38,6 +46,63 @@ func (p *fakePage) Evaluate(string) (string, error) { raw, _ := json.Marshal(page) return string(raw), nil } + +func TestLoginGuardFirstDeepThenCurrentPageWithoutNavigation(t *testing.T) { + p := &fakePage{} + guard := &LoginGuard{} + for i := 0; i < 3; i++ { + status, err := guard.Check(p, 0) + if err != nil || !status.Valid { + t.Fatalf("normal guard %d: %+v %v", i, status, err) + } + } + if len(p.navigations) != 1 { + t.Fatalf("repeated deep navigation: %v", p.navigations) + } + // A fresh batch, including a manually resumed batch, does not inherit the flag. + status, err := (&LoginGuard{}).Check(p, 0) + if err != nil || !status.Valid || len(p.navigations) != 2 { + t.Fatal("new batch skipped deep check") + } +} + +func TestCurrentGuardDetectsRedirectPromptCookiesAndReadFailureWithoutNavigation(t *testing.T) { + for _, scenario := range []string{"login", "punish", "risk", "cookie", "read", "json"} { + t.Run(scenario, func(t *testing.T) { + p := &fakePage{pages: map[string]detailPage{}} + guard := &LoginGuard{} + if status, err := guard.Check(p, 0); err != nil || !status.Valid { + t.Fatal("initial guard failed") + } + p.url = "https://item.taobao.com/item.htm?id=fictional" + switch scenario { + case "login": + p.url = "https://login.taobao.com/member/login.jhtml" + case "punish": + p.url = "https://item.taobao.com/punish" + case "risk": + p.pages[p.url] = detailPage{Text: "系统检测到当前访问存在异常"} + case "cookie": + p.cookies = completeCookieNames() + delete(p.cookies, "_m_h5_tk") + case "read": + p.evaluateErr = true + case "json": + p.evaluateRaw = "not-json" + } + status, err := guard.Check(p, 0) + if status.Valid || len(p.navigations) != 1 { + t.Fatalf("current guard bypassed or navigated: %+v %v", status, err) + } + if (scenario == "read" || scenario == "json") && err == nil { + t.Fatal("read failure swallowed") + } + if (scenario == "risk" || scenario == "punish") && status.StopReason != "risk_blocked" { + t.Fatal("lost risk classification") + } + }) + } +} func (p *fakePage) CookieNames(string) (map[string]bool, error) { if p.cookies != nil { return p.cookies, nil diff --git a/internal/taobao/candidates.go b/internal/taobao/candidates.go index 36bd907..b286fe7 100644 --- a/internal/taobao/candidates.go +++ b/internal/taobao/candidates.go @@ -16,7 +16,7 @@ type CandidateOptions struct { OnDetail func(int) error } -// SelectVideo 不导航到登录页。调用方必须先完成当前商品的深度登录检查。 +// SelectVideo 不导航到登录页。调用方必须先完成批次首次深度检查及当前商品页面守卫。 // 访问异常立即返回;失败和异常均不缓存,也不降级为空结果。 func SelectVideo(ctx context.Context, page PageSession, items []SimilarItem, options CandidateOptions, cache DetailCache) (SimilarItem, []string, error) { var firstError error diff --git a/internal/taobao/login.go b/internal/taobao/login.go index 2ec137f..5080b56 100644 --- a/internal/taobao/login.go +++ b/internal/taobao/login.go @@ -35,6 +35,23 @@ type LoginStatus struct { Message string `json:"message"` } +// LoginGuard 仅在单次串行批次内记录首次深度检查是否通过。 +// 不保存凭据,也不将首次成功当成后续登录仍有效的证据。 +type LoginGuard struct { + deepChecked bool +} + +func (g *LoginGuard) Check(cdp PageSession, wait time.Duration) (LoginStatus, error) { + if g.deepChecked { + return CheckCurrentLogin(cdp) + } + status, err := CheckLogin(cdp, wait) + if err == nil && status.Valid { + g.deepChecked = true + } + return status, err +} + // CheckLogin 先检查必要 Cookie 名称,再到「我的淘宝」做服务端深度检查。 // 它不会读取 Cookie 值,也不会自动登录、重试或处理任何安全验证。 func CheckLogin(cdp PageSession, wait time.Duration) (LoginStatus, error) { @@ -53,9 +70,26 @@ func CheckLogin(cdp PageSession, wait time.Duration) (LoginStatus, error) { if err := cdp.Navigate("https://i.taobao.com/my_itaobao", wait); err != nil { return LoginStatus{}, fmt.Errorf("打开「我的淘宝」进行登录检查失败:%w", err) } + return checkPageLogin(cdp, cookieNames) +} + +// CheckCurrentLogin 只读取当前页面及 Cookie 名称,不发送导航请求。 +// 它不主动验证服务器登录;实际请求的认证和风控响应仍由全局停止门处理。 +func CheckCurrentLogin(cdp PageSession) (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 checkPageLogin(cdp, cookieNames) +} + +func checkPageLogin(cdp PageSession, cookieNames map[string]bool) (LoginStatus, error) { pageJSON, err := cdp.Evaluate(`JSON.stringify({url:location.href,title:document.title,text:(document.body?.innerText||'').slice(0,1200)})`) if err != nil { - return LoginStatus{}, fmt.Errorf("读取「我的淘宝」页面状态失败:%w", err) + return LoginStatus{}, fmt.Errorf("读取淘宝页面状态失败:%w", err) } var page struct { URL string `json:"url"` @@ -63,7 +97,7 @@ func CheckLogin(cdp PageSession, wait time.Duration) (LoginStatus, error) { Text string `json:"text"` } if err := json.Unmarshal([]byte(pageJSON), &page); err != nil { - return LoginStatus{}, fmt.Errorf("「我的淘宝」页面状态不是有效 JSON:%w", err) + return LoginStatus{}, fmt.Errorf("淘宝页面状态不是有效 JSON:%w", err) } return judgePageLogin(cookieNames, page.Title, page.Text, page.URL), nil }