feat: 爬虫页取代抓取任务/入库任务两页,抓取改全量+覆盖率跳过

一、后台只留一个「爬虫」页(/admin/spider)
抓取与入库本就是同一条流水线的上下游,分两页看谁都看不全。
新页三块:状态条(在线/卡住 + 队列数 + 待入库数)、正在抓(含进度与心跳)、
最近入库结果(品牌/场次/结果/说明)。只读,取消与重试一并下线。

二、抓取一律全量
删除品牌页的场次上限下拉与 hidden maxShows=3,入队固定 0(全量)。
crawl_tasks.max_shows 列保留,历史任务照常显示。

三、跳过判定改覆盖率
- brand_runways 增加 expected_images(spider 上报的应有张数)
- /shows/covered 返回 images + expected
- 实际张数 >= 应有 - 5 才跳过;expected=0 时回落 10 张阈值
- 实体已存在时不再直接跳过:按 key 比对把缺的图补进库(repaired)
  否则「判定不完整→重爬→后端跳过」会形成死循环,带宽白花、缺图永远补不上

四、租约 900→300 秒,且心跳算存活
只看 locked_at 会把跑了几小时的全量抓取误判成卡死;
加上 progress_at 后,真卡住 5 分钟才回收。
This commit is contained in:
toom1996
2026-10-25 23:59:54 +08:00
parent b30f18fc20
commit e930ac41c8
22 changed files with 992 additions and 527 deletions

View File

@ -155,6 +155,16 @@ func (s *IngestService) RetryJob(ctx context.Context, id uint32) error {
return s.repo.RetryJob(ctx, id)
}
// CountPendingJobs 待处理入库任务数(后台爬虫页状态条)。
func (s *IngestService) CountPendingJobs(ctx context.Context) (int64, error) {
return s.repo.CountPendingJobs(ctx)
}
// RecentCrawlResults 最近若干条入库结果(后台爬虫页的「最近入库结果」表)。
func (s *IngestService) RecentCrawlResults(ctx context.Context, limit int) ([]repository.CrawlResultRow, error) {
return s.repo.RecentCrawlResults(ctx, limit)
}
// Run 启动 worker 循环:定时领取 pending 任务并处理,直到 ctx 取消。
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
@ -361,14 +371,29 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
}
}
if action == ingestSkip {
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus)
// 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。
// 实体已存在 ≠ 抓全了:先按 key 比对把缺的图补上。
//
// 不补的话,「覆盖率判定说这场不完整 → spider 重爬 → 后端直接跳过」会形成死循环:
// 每次重爬都把整场图下一遍,库里却一张不增,缺的图永远补不上。
added, mErr := s.mergeMissingImages(ctx, reuseID, brandID, p)
if mErr != nil {
s.failOrRetry(ctx, job.ID, "merge missing images: "+mErr.Error())
return
}
// 回填发布时间与缺失年份(不重下图片)。
if p.PublishedAt != 0 {
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
return
}
}
if added > 0 {
log.Printf("[ingest] job=%d runway 实体已存在,补齐 %d 张缺图", job.ID, added)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeRepaired,
fmt.Sprintf("已存在(status=%s),补齐 %d 张缺图", existStatus, added))
return
}
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),图片齐全,跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID, model.IngestOutcomeSkippedExisting, "已存在(status="+existStatus+"),跳过重下")
return
}
@ -432,6 +457,10 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
PublishedAt: p.PublishedAt,
Cover: cover,
ImageCount: imageCount,
// 应有张数取「spider 上报值」与「本次实际入库数」的较大者:
// 去重会砍掉重复图,payload 里的总数可能偏高;而实际入库数就是本次能拿到的全部。
// 取较大者保证覆盖率判定不会因为去重而永远判「不完整」。
ExpectedImages: maxInt(p.ExpectedImages, int(imageCount)),
Status: model.StatusPending,
}
@ -593,6 +622,107 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
return cover, rows, vecs, keys, failed, timing
}
// maxInt 取两数较大者(expected_images 只增不减)。
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// mergeMissingImages 把 payload 里「库里还没有」的图片补进一场已存在的秀,返回补了几张。
//
// 为什么不用下载:媒体机直传时 payload 带的是 `main_key` / `detail_keys`(内容 sha1),
// 库里的 `image` 列存的也是同一个 key,所以「这张图有没有」是一次纯内存查表。
// 图片本来就已经在对象存储里了,补的是**数据库行**,不产生任何网络流量。
//
// 老爬虫只上报 URL(没有 key)时无法比对——那种情况直接返回 0,
// 由「覆盖率判定」保证新爬虫一定上报 key,从而让补齐这条路可用。
func (s *IngestService) mergeMissingImages(ctx context.Context, runwayID, brandID uint32, p dto.RunwayIngest) (int, error) {
if runwayID == 0 || len(p.Looks) == 0 {
return 0, nil
}
st, err := s.repo.RunwayImageState(ctx, runwayID)
if err != nil {
return 0, err
}
added := 0
order := st.MaxSort
for li, look := range p.Looks {
lookNo := li + 1
// 主图:已有就复用 id(细节图要挂它),没有就新建一行。
var parentID uint32
if look.MainKey != "" {
if id, ok := st.Keys[look.MainKey]; ok {
parentID = id
} else {
order++
img := model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: brandID,
Image: look.MainKey,
Name: fmt.Sprintf("Look %d", lookNo),
SortOrder: order,
IsDetail: 0,
ImageSize: look.MainSize,
}
if err := s.repo.CreateRunwayImage(ctx, &img); err != nil {
return added, err
}
parentID = img.ID
st.Keys[look.MainKey] = img.ID
added++
}
}
// 细节图:必须能挂到父主图,否则宁可跳过(挂不上的细节图在后台无法展开)。
if parentID == 0 {
continue
}
for di, dk := range look.DetailKeys {
if dk == "" {
continue
}
if _, ok := st.Keys[dk]; ok {
continue
}
order++
img := model.BrandRunwayImage{
RunwayID: runwayID,
BrandID: brandID,
Image: dk,
Name: fmt.Sprintf("Look %d — Detail %d", lookNo, di+1),
SortOrder: order,
IsDetail: 1,
ParentImageID: parentID,
ImageSize: sizeAt(look.DetailSizes, di),
}
if err := s.repo.CreateRunwayImage(ctx, &img); err != nil {
return added, err
}
st.Keys[dk] = img.ID
added++
}
}
if added > 0 || p.ExpectedImages > 0 {
if err := s.repo.SyncRunwayImageStats(ctx, runwayID, p.ExpectedImages); err != nil {
return added, err
}
}
return added, nil
}
// sizeAt 越界时返回 0(spider 可能只上报部分 size)。
func sizeAt(sizes []int64, i int) int64 {
if i >= 0 && i < len(sizes) {
return sizes[i]
}
return 0
}
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
func countMainImages(images []model.BrandRunwayImage) uint16 {