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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user