diff --git a/cmd/serve.go b/cmd/serve.go index ee9c1d7..db835c1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -124,6 +124,9 @@ func executeBrand(ctx context.Context, brandUID, brandName string, maxShows int) s := spider.NewVogueSpider(serveMaxCo, 0, maxShows, IngestClient) s.Debug = debug + // 默认跳过已入库的秀:图片 key 是内容 sha1,不下载无法判重, + // 场次级预检是唯一能在下载前省掉整场带宽的手段。 + s.Force = forceAll // 媒体机直传:spider 自己下载并上传图片,后端只写库。 if config.GlobalConfig.Media.Upload && IngestClient != nil { s.Media = media.New(IngestClient, "runway", config.GlobalConfig.Media.Concurrency) @@ -150,4 +153,6 @@ func init() { serveCmd.Flags().IntVarP(&serveWorkers, "workers", "w", 1, "并发执行的品牌数(每路一个 goroutine)") serveCmd.Flags().IntVarP(&serveInterval, "interval", "i", 5, "队列为空时的轮询间隔(秒)") serveCmd.Flags().IntVarP(&serveMaxCo, "max-co", "m", 5, "单个品牌内部的抓取并发数") + // 与 vogue 子命令共用同一个开关:默认跳过已入库的秀,确需刷新时加 --force。 + serveCmd.Flags().BoolVarP(&forceAll, "force", "f", false, "忽略「已入库」预检,强制重抓全部场次") } diff --git a/cmd/vogue.go b/cmd/vogue.go index e5c4a51..a11a317 100644 --- a/cmd/vogue.go +++ b/cmd/vogue.go @@ -12,6 +12,7 @@ var ( brandID int64 maxCo int maxShows int + forceAll bool // --force:忽略「已入库」预检,强制重抓 ) var vogueCmd = &cobra.Command{ @@ -24,6 +25,8 @@ var vogueCmd = &cobra.Command{ if config.GlobalConfig.Media.Upload && IngestClient != nil { vogueSpider.Media = media.New(IngestClient, "runway", config.GlobalConfig.Media.Concurrency) } + // --force:跳过「已入库的秀」预检,强制重抓(默认跳过,避免重复下载)。 + vogueSpider.Force = forceAll // 2. 修复方法名:将 Execute() 改为 Run() vogueSpider.Run() @@ -41,4 +44,7 @@ func init() { // 小批量试爬:仅抓取单个品牌的前 N 场发布会(0=不限制)。用于控制入库量 / 先看效果。 vogueCmd.Flags().IntVarP(&maxShows, "max-shows", "s", 0, "单个品牌最多抓取的发布会场次(0=不限制,小批量试爬用)") + + // 默认跳过已入库的秀(省带宽);确需刷新元数据时加 --force。 + vogueCmd.Flags().BoolVarP(&forceAll, "force", "f", false, "忽略「已入库」预检,强制重抓全部场次") } diff --git a/internal/ingest/client.go b/internal/ingest/client.go index ddfbf49..f62ed7f 100644 --- a/internal/ingest/client.go +++ b/internal/ingest/client.go @@ -195,6 +195,63 @@ type EmbeddingFailure struct { } // FetchPendingEmbeddings 拉一批缺向量的图片。remaining 为后端统计的剩余量(-1 = 统计失败)。 +// ShowCoverageKey 生成「秀覆盖」预检键:年份|季节|类型,与后端的实体键一一对应。 +func ShowCoverageKey(year int, season, collectionType string) string { + return strconv.Itoa(year) + "|" + season + "|" + collectionType +} + +// CoveredShows 取某品牌已入库的秀,返回「预检键 → 已有图片张数」。 +// +// 用途:抓取前整场跳过已入库的秀。图片 key 是内容 sha1,不下载就无法知道 +// 一张图是否已经存在,所以「跳过」只能在场这一级做。 +func (c *Client) CoveredShows(ctx context.Context, brandUID string) (map[string]int, error) { + if c == nil || c.Endpoint == "" { + return nil, fmt.Errorf("ingest client 未配置") + } + url := c.internalBase() + "/shows/covered?brand_uid=" + neturl.QueryEscape(brandUID) + ts := NowTS() + nonce, err := newNonce() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Signature", Sign(c.Secret, ts, nonce, "")) + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Nonce", nonce) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("shows/covered http %d: %s", resp.StatusCode, string(raw)) + } + var out struct { + Items []ShowCoverage `json:"items"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("shows/covered 解析失败: %w", err) + } + covered := make(map[string]int, len(out.Items)) + for _, it := range out.Items { + covered[ShowCoverageKey(int(it.Year), it.Season, it.CollectionType)] = it.Images + } + return covered, nil +} + +// ShowCoverage 一场秀在库里的覆盖情况。 +type ShowCoverage struct { + Year uint16 `json:"year"` + Season string `json:"season"` + CollectionType string `json:"collection_type"` + Images int `json:"images"` +} + func (c *Client) FetchPendingEmbeddings(ctx context.Context, kind string, limit int) ([]PendingEmbedding, int, error) { if c == nil || c.Endpoint == "" { return nil, 0, fmt.Errorf("ingest client 未配置") diff --git a/internal/media/upload.go b/internal/media/upload.go index 5edb293..470f4c1 100644 --- a/internal/media/upload.go +++ b/internal/media/upload.go @@ -31,8 +31,27 @@ import ( type Uploader struct { Client *ingest.Client // 复用它来签发预签名 URL(HMAC) Prefix string // 存储前缀:runway / street - Concurrency int // 下载与上传的并发数 + Concurrency int // **全进程共享**的下载/上传并发数 HTTP *http.Client + + // sem 全进程共享的信号量,避免「每场秀各开 N 路」叠乘。 + // + // 早先每个 UploadAll 调用各自 make 一个信号量:秀级并发 2 × 每场 6 = 12 路 + // 同时打 Vogue,被对方限流,单张从 9.8s 掉到 ~90s,还伴随 50% 失败。 + once sync.Once + sem chan struct{} +} + +// semaphore 惰性初始化共享信号量(并发数非法时兜底为 1,否则无缓冲 channel 会死锁)。 +func (u *Uploader) semaphore() chan struct{} { + u.once.Do(func() { + c := u.Concurrency + if c <= 0 { + c = 1 + } + u.sem = make(chan struct{}, c) + }) + return u.sem } // Result 单张图的直传结果;Err 非空表示这张失败(调用方应回退为上报 URL)。 @@ -94,7 +113,19 @@ func (u *Uploader) uploadBatch(ctx context.Context, urls []string, results []Res } data[i] = body }) - log.Printf("[media] 下载完成 %d 张(失败 %d)", countOK(data), n-countOK(data)) + // 失败时把**第一条**错误打出来:失败通常是同一个原因(限流 / 连接重置), + // 全打会把日志刷爆,而一条就足以定位。此前只统计个数,导致 50% 失败却查不出原因。 + if ok := countOK(data); ok < n { + for i := 0; i < n; i++ { + if errs[i] != nil { + log.Printf("[media][warn] 下载失败 %s: %v", clipURL(urls[start+i]), errs[i]) + break + } + } + log.Printf("[media] 下载完成 %d 张(失败 %d)", ok, n-ok) + } else { + log.Printf("[media] 下载完成 %d 张(失败 0)", ok) + } // ② 生成本批 key(内容 sha1 + 原扩展名) for i := 0; i < n; i++ { @@ -164,11 +195,10 @@ func (u *Uploader) parallel(n int, fn func(i int)) { if n == 0 { return } - if u.Concurrency <= 0 { - // 兜底:并发为 0 会造成无缓冲 channel 死锁(曾把整条抓取卡住)。 - u.Concurrency = 1 - } - sem := make(chan struct{}, u.Concurrency) + // 共享信号量:并发上限是**整个进程**的,不是每次调用各自一份。 + // 早先每次 UploadAll 各建一个:秀级并发 2 × 每场 6 = 12 路同时打 Vogue, + // 被对方限流后单张从 9.8s 掉到 ~90s,并伴随大量失败。 + sem := u.semaphore() var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) @@ -224,6 +254,14 @@ func (u *Uploader) put(ctx context.Context, putURL, originalURL string, data []b return nil } +// clipURL 截短 URL 只用于日志(完整 URL 太长,会淹没真正有用的错误信息)。 +func clipURL(u string) string { + if len(u) <= 90 { + return u + } + return u[:90] + "..." +} + // countOK 数一下成功下载的张数(用于日志定位卡在哪一步)。 func countOK(data [][]byte) int { n := 0 diff --git a/internal/spider/vogue.go b/internal/spider/vogue.go index 2151797..dde9506 100644 --- a/internal/spider/vogue.go +++ b/internal/spider/vogue.go @@ -42,6 +42,10 @@ type VogueSpider struct { BrandID uint // 客户端指定的品牌 ID(--brand,单品牌调试用) MaxShows int // 小批量模式:单品牌最多抓取的发布会场次(0=不限制) Debug bool // Debug 模式:仅输出不入库 + // Force 忽略「已入库」预检,强制重抓全部场次(--force)。 + // 默认 false:整场已入库就跳过,一张图都不下载——图片 key 是内容 sha1, + // 不下载无法判断是否已存在,只能靠这一层预检来省掉整场带宽。 + Force bool // Media 媒体机直传器(可为 nil:为 nil 时走旧路径,上报 URL 由后端下载)。 Media *media.Uploader // submitted 本次品牌执行中成功上报的场次数(服务化模式回报给后台用)。 @@ -206,6 +210,53 @@ func (s *VogueSpider) filterUncrawled(showsList []gjson.Result) []gjson.Result { return showsList } +// coveredMinImages 一场秀至少已入库多少张,才算「抓全了、可以整场跳过」。 +// +// 用阈值而不是「>0」:只存了两三张说明当初大面积失败,这时重抓补齐 +// 比跳过更有价值;而几百张的秀几乎不可能再变,跳过无损失。 +const coveredMinImages = 10 + +// skipCoveredShows 询问后端「这个品牌哪些秀已经入库了」,把命中的整场剔除。 +// +// 这是唯一能在下载**之前**判重的地方:图片 key 是内容 sha1, +// 必须先下载才能算出 key,所以「这张图有没有」无法在下载前回答, +// 但「这场秀有没有」可以——秀的身份是 品牌+年份+季节+类型。 +// +// 预检失败不阻断抓取:宁可多下一遍,也不能因为一次查询失败把整场丢掉。 +func (s *VogueSpider) skipCoveredShows(brandUID, brandName string, list []gjson.Result) []gjson.Result { + if s.Ingest == nil || s.Force || len(list) == 0 { + return list + } + covered, err := s.Ingest.CoveredShows(context.Background(), brandUID) + if err != nil { + log.Printf("[skip][warn] 预检已入库秀失败,按全量抓取: %v", err) + return list + } + if len(covered) == 0 { + return list + } + + out := make([]gjson.Result, 0, len(list)) + skipped := 0 + for _, info := range list { + hed := info.Get("hed").String() + ct, season := ingest.ParseCollection(hed) + // 年份取不到时不做跳过:键对不上,宁可重抓也不错判成「已有」。 + if year := s.parseYear(hed); year > 0 { + if n, ok := covered[ingest.ShowCoverageKey(year, season, ct)]; ok && n >= coveredMinImages { + skipped++ + continue + } + } + out = append(out, info) + } + if skipped > 0 { + log.Printf("[skip] [%s] 跳过 %d 场已入库的秀(各 ≥%d 张),剩余 %d 场待抓", + brandName, skipped, coveredMinImages, len(out)) + } + return out +} + // SpiderStart 针对单个品牌开启抓取 func (s *VogueSpider) SpiderStart(task ingest.CrawlBrand) { brandName := s.getTaskName(task.Name) @@ -220,6 +271,9 @@ func (s *VogueSpider) SpiderStart(task ingest.CrawlBrand) { // 抓取前不再做预检(source_url 预检已下线);直接抓取,去重由后端 worker 按实体键幂等收敛。 showsList = s.filterUncrawled(showsList) + // 场次级预检:整场已入库的秀直接跳过,一张图都不下载。 + // 这是唯一能在「下载之前」判重的位置(图片 key 是内容 sha1,下载后才能算出来)。 + showsList = s.skipCoveredShows(task.BrandUID, brandName, showsList) if len(showsList) == 0 { log.Printf("[Info] 品牌 [%s] 的图集均已爬取过,全部跳过", brandName) return