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:
@ -47,12 +47,20 @@ type CrawlRepository interface {
|
||||
//
|
||||
// crawl_tasks 每品牌只有一行且会被覆盖,历史只存在 brand_crawl_logs 里。
|
||||
ListCrawlLogs(ctx context.Context, brandID uint32, limit int) ([]model.CrawlLog, error)
|
||||
// ShowCoverage 返回某品牌「已入库的秀 → 已有图片张数」,键是实体键(年份+季节+类型)。
|
||||
// ShowCoverage 返回某品牌「已入库的秀 → 覆盖率」,键是实体键(年份+季节+类型)。
|
||||
//
|
||||
// 用途:spider 抓取前预检,**整场跳过**已入库的秀。
|
||||
// 用途:spider 抓取前预检,整场跳过已经抓全的秀。
|
||||
// 没有它,重爬会把几百张已经下过的图再下一遍——图片 key 是内容 sha1,
|
||||
// 不下载就无法知道它其实已经存在,带宽全白烧(线上实测 62 分钟 0 新增)。
|
||||
ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]int, error)
|
||||
ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]ShowCoverageStat, error)
|
||||
}
|
||||
|
||||
// ShowCoverageStat 一场秀的图片覆盖情况。
|
||||
//
|
||||
// Expected 为 0 表示未知(该字段上线前入库的历史数据),调用方应退化为按张数阈值判断。
|
||||
type ShowCoverageStat struct {
|
||||
Images int // 库里实际图片张数
|
||||
Expected int // 应有张数(spider 上报,0=未知)
|
||||
}
|
||||
|
||||
// ShowKey 一场秀的业务主键,与 ingest 的实体键一致(品牌 + 年份 + 季节 + 类型)。
|
||||
@ -208,13 +216,19 @@ func (r *crawlRepository) Claim(ctx context.Context, worker string) (*model.Craw
|
||||
}
|
||||
}()
|
||||
|
||||
// 回收租约过期的 running(worker 崩溃 / 断电),否则任务永远卡着。
|
||||
// 回收「静默」的 running(worker 崩溃 / 断电),否则任务永远卡着。
|
||||
//
|
||||
// 存活判定用**两个信号取较晚者**:locked_at(领取时刻)与 progress_at(进度心跳)。
|
||||
// 只看 locked_at 会误杀长任务——一次全量抓取可能跑几个小时,而 locked_at 一写就不再更新;
|
||||
// 加上心跳后,正常在跑的任务永远不会被回收,只有真的 5 分钟没动静才落回队列。
|
||||
if now > model.CrawlStuckTimeoutSec {
|
||||
cutoff := now - model.CrawlStuckTimeoutSec
|
||||
if err := tx.Exec(`
|
||||
UPDATE crawl_tasks
|
||||
SET status = 'queued', locked_at = 0, worker = '', updated_at = ?
|
||||
WHERE status = 'running' AND locked_at > 0 AND locked_at < ?`,
|
||||
now, now-model.CrawlStuckTimeoutSec).Error; err != nil {
|
||||
WHERE status = 'running' AND locked_at > 0
|
||||
AND locked_at < ? AND coalesce(progress_at, 0) < ?`,
|
||||
now, cutoff, cutoff).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@ -448,16 +462,17 @@ func (r *crawlRepository) Counts(ctx context.Context) (map[string]int64, error)
|
||||
// 只统计未软删的图:一张被删过的秀如果只剩几张,说明当初没抓全,
|
||||
// 调用方会按阈值判断是否值得重抓,而不是一律跳过。
|
||||
// JOIN 保证 image_count > 0,没有图的秀不会出现在结果里。
|
||||
func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]int, error) {
|
||||
func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map[ShowKey]ShowCoverageStat, error) {
|
||||
var rows []struct {
|
||||
Year uint16 `gorm:"column:year"`
|
||||
Season string `gorm:"column:season"`
|
||||
CollectionType string `gorm:"column:collection_type"`
|
||||
Images int `gorm:"column:images"`
|
||||
Expected int `gorm:"column:expected"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Raw(
|
||||
`SELECT r.year, coalesce(r.season, '') AS season, coalesce(r.collection_type, '') AS collection_type,
|
||||
count(i.id) AS images
|
||||
count(i.id) AS images, max(r.expected_images) AS expected
|
||||
FROM brand_runways r
|
||||
JOIN brand_runway_images i ON i.runway_id = r.id AND i.is_deleted = 0
|
||||
WHERE r.brand_id = ? AND r.is_deleted = 0
|
||||
@ -465,9 +480,10 @@ func (r *crawlRepository) ShowCoverage(ctx context.Context, brandID uint32) (map
|
||||
brandID).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[ShowKey]int, len(rows))
|
||||
out := make(map[ShowKey]ShowCoverageStat, len(rows))
|
||||
for _, row := range rows {
|
||||
out[ShowKey{Year: row.Year, Season: row.Season, CollectionType: row.CollectionType}] = row.Images
|
||||
out[ShowKey{Year: row.Year, Season: row.Season, CollectionType: row.CollectionType}] =
|
||||
ShowCoverageStat{Images: row.Images, Expected: row.Expected}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user