diff --git a/internal/ingest/client.go b/internal/ingest/client.go index bad60ce..f3703f5 100644 --- a/internal/ingest/client.go +++ b/internal/ingest/client.go @@ -200,11 +200,11 @@ func ShowCoverageKey(year int, season, collectionType string) string { return strconv.Itoa(year) + "|" + season + "|" + collectionType } -// CoveredShows 取某品牌已入库的秀,返回「预检键 → 已有图片张数」。 +// CoveredShows 取某品牌已入库的秀,返回「预检键 → 覆盖情况」。 // -// 用途:抓取前整场跳过已入库的秀。图片 key 是内容 sha1,不下载就无法知道 +// 用途:抓取前整场跳过已经抓全的秀。图片 key 是内容 sha1,不下载就无法知道 // 一张图是否已经存在,所以「跳过」只能在场这一级做。 -func (c *Client) CoveredShows(ctx context.Context, brandUID string) (map[string]int, error) { +func (c *Client) CoveredShows(ctx context.Context, brandUID string) (map[string]ShowCoverage, error) { if c == nil || c.Endpoint == "" { return nil, fmt.Errorf("ingest client 未配置") } @@ -237,9 +237,9 @@ func (c *Client) CoveredShows(ctx context.Context, brandUID string) (map[string] if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("shows/covered 解析失败: %w", err) } - covered := make(map[string]int, len(out.Items)) + covered := make(map[string]ShowCoverage, len(out.Items)) for _, it := range out.Items { - covered[ShowCoverageKey(int(it.Year), it.Season, it.CollectionType)] = it.Images + covered[ShowCoverageKey(int(it.Year), it.Season, it.CollectionType)] = it } return covered, nil } @@ -287,7 +287,10 @@ type ShowCoverage struct { Year uint16 `json:"year"` Season string `json:"season"` CollectionType string `json:"collection_type"` - Images int `json:"images"` + // Images 库里实际图片张数。 + Images int `json:"images"` + // Expected 应有张数(spider 上次上报)。0 表示历史数据没有记录,判定时退化为阈值。 + Expected int `json:"expected"` } func (c *Client) FetchPendingEmbeddings(ctx context.Context, kind string, limit int) ([]PendingEmbedding, int, error) { diff --git a/internal/ingest/payload.go b/internal/ingest/payload.go index ce1d259..d5bf07f 100644 --- a/internal/ingest/payload.go +++ b/internal/ingest/payload.go @@ -25,6 +25,9 @@ type RunwayIngest struct { CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall City string `json:"city"` // 地区/城市(street 用) PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year + // ExpectedImages 本场秀应有的图片总数(含细节图)。 + // 后端用它做覆盖率判定:实际入库张数接近它才算抓全;否则下次重爬会来补。 + ExpectedImages int `json:"expected_images,omitempty"` Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表 // Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。 // 结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图)。 diff --git a/internal/spider/coverage_test.go b/internal/spider/coverage_test.go new file mode 100644 index 0000000..d1d43bc --- /dev/null +++ b/internal/spider/coverage_test.go @@ -0,0 +1,33 @@ +package spider + +import ( + "testing" + + "my-spiders/internal/ingest" +) + +// TestIsCovered 固定「爬过的就不再爬」的判定边界。 +// +// 这个函数决定要不要整场重新下载(几百张图、十几分钟带宽), +// 判错的代价是双向的:判松了漏图永远补不上,判紧了每次全量抓都白下一遍。 +func TestIsCovered(t *testing.T) { + cases := []struct { + name string + st ingest.ShowCoverage + want bool + }{ + {"完全齐全", ingest.ShowCoverage{Images: 50, Expected: 50}, true}, + {"缺 5 张(容忍上限)", ingest.ShowCoverage{Images: 45, Expected: 50}, true}, + {"缺 6 张(超容忍)", ingest.ShowCoverage{Images: 44, Expected: 50}, false}, + {"只抓了一半", ingest.ShowCoverage{Images: 25, Expected: 50}, false}, + {"历史数据无应有张数且图够多", ingest.ShowCoverage{Images: 40, Expected: 0}, true}, + {"历史数据无应有张数但只有 3 张", ingest.ShowCoverage{Images: 3, Expected: 0}, false}, + {"有应有张数但一张都没有", ingest.ShowCoverage{Images: 0, Expected: 30}, false}, + } + for _, c := range cases { + if got := isCovered(c.st); got != c.want { + t.Errorf("%s: 期望 %v 实际 %v (images=%d expected=%d)", + c.name, c.want, got, c.st.Images, c.st.Expected) + } + } +} diff --git a/internal/spider/vogue.go b/internal/spider/vogue.go index d480a42..33102bc 100644 --- a/internal/spider/vogue.go +++ b/internal/spider/vogue.go @@ -223,11 +223,28 @@ func (s *VogueSpider) filterUncrawled(showsList []gjson.Result) []gjson.Result { return showsList } -// coveredMinImages 一场秀至少已入库多少张,才算「抓全了、可以整场跳过」。 +// 跳过判定的两个刻度: // -// 用阈值而不是「>0」:只存了两三张说明当初大面积失败,这时重抓补齐 -// 比跳过更有价值;而几百张的秀几乎不可能再变,跳过无损失。 -const coveredMinImages = 10 +// coverageSlack 允许缺几张。个别图在 Vogue 侧是永久 404,要求一张不差会让这场秀 +// 永远判「不完整」,每次全量抓都重下一遍、却永远补不齐。 +// coveredMinImages 应有张数未知(该字段上线前的历史数据)时的回落阈值: +// 只存了两三张说明当初大面积失败,值得重抓;几十张就认为抓过了。 +const ( + coverageSlack = 5 + coveredMinImages = 10 +) + +// isCovered 判断一场秀是否已经抓全、可以整场跳过。 +// +// 优先用后端记录的「应有张数」算覆盖率;没有记录时退化为张数阈值。 +// 缺图超过容忍度就不跳过——下次重爬会把整场图重新下载并上传(内容寻址,幂等), +// 后端按 key 比对后只把缺的那几行补进库。 +func isCovered(st ingest.ShowCoverage) bool { + if st.Expected > 0 { + return st.Images >= st.Expected-coverageSlack + } + return st.Images >= coveredMinImages +} // skipCoveredShows 询问后端「这个品牌哪些秀已经入库了」,把命中的整场剔除。 // @@ -256,7 +273,7 @@ func (s *VogueSpider) skipCoveredShows(brandUID, brandName string, list []gjson. ct, season := ingest.ParseCollection(hed) // 年份取不到时不做跳过:键对不上,宁可重抓也不错判成「已有」。 if year := s.parseYear(hed); year > 0 { - if n, ok := covered[ingest.ShowCoverageKey(year, season, ct)]; ok && n >= coveredMinImages { + if st, ok := covered[ingest.ShowCoverageKey(year, season, ct)]; ok && isCovered(st) { skipped++ continue } @@ -478,6 +495,8 @@ func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) { Season: season, CollectionType: collectionType, PublishedAt: publishEpoch(publishedAt), + // 应有张数:后端据此判断这场是不是真的抓全了(缺得多就下次补抓)。 + ExpectedImages: totalImages, // Looks 优先:结构化承载「主图 + 细节图」分组;Images 仅铺平主图做兼容兜底。 Looks: looks, Images: mainImages,