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:
@ -65,6 +65,15 @@ type IngestRepository interface {
|
||||
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
|
||||
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
||||
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
||||
// RunwayImageState 返回某场秀已有图片的 key→id 映射与最大排序号,供「补齐缺图」比对。
|
||||
//
|
||||
// 图片 key 是内容 sha1,spider 直传时会一并上报,所以**不用下载**就能知道哪张缺。
|
||||
RunwayImageState(ctx context.Context, runwayID uint32) (RunwayImageState, error)
|
||||
// CreateRunwayImage 插入一张图片(回填自增 id,供细节图挂 parent_image_id)。
|
||||
CreateRunwayImage(ctx context.Context, img *model.BrandRunwayImage) error
|
||||
// SyncRunwayImageStats 重算 image_count(主图行数),并抬高 expected_images。
|
||||
// expected 只增不减:抓取中途失败导致张数变少时,不应把「应有张数」改小。
|
||||
SyncRunwayImageStats(ctx context.Context, runwayID uint32, expected int) error
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
|
||||
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||
@ -77,6 +86,13 @@ type IngestRepository interface {
|
||||
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||
CountJobs(ctx context.Context) (int64, error)
|
||||
// CountPendingJobs 返回尚未被 worker 处理的入库任务数(后台状态条)。
|
||||
CountPendingJobs(ctx context.Context) (int64, error)
|
||||
// RecentCrawlResults 取最近若干条爬虫入库结果,带品牌名与场次标题,供「最近入库结果」表格。
|
||||
//
|
||||
// 场次标题从 payload JSON 里取:入库表只存原始载荷,没有 title 列,
|
||||
// 为展示再冗余一列不值得——payload 里本来就有。
|
||||
RecentCrawlResults(ctx context.Context, limit int) ([]CrawlResultRow, error)
|
||||
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
|
||||
RetryJob(ctx context.Context, id uint32) error
|
||||
// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。
|
||||
@ -656,3 +672,124 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// CrawlResultRow 一条最近的爬虫入库结果(后台展示用)。
|
||||
type CrawlResultRow struct {
|
||||
UpdatedAt uint32
|
||||
Status string
|
||||
Outcome string
|
||||
Note string
|
||||
BrandName string
|
||||
Title string // 场次英文标题(来自 payload)
|
||||
}
|
||||
|
||||
// CountPendingJobs 统计待处理的入库任务。
|
||||
func (r *ingestRepository) CountPendingJobs(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).Raw(
|
||||
`SELECT count(*) FROM ingest_jobs WHERE status = ?`, model.IngestStatusPending).Scan(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RecentCrawlResults 取最近 N 条爬虫入库结果。
|
||||
//
|
||||
// payload 用 `LIKE '{%'` 先挡一道再转 jsonb:正常载荷一定是 JSON 对象,
|
||||
// 但历史脏数据/空串会让 `::jsonb` 抛错并整页 500——展示页不该被一条坏数据拖垮。
|
||||
func (r *ingestRepository) RecentCrawlResults(ctx context.Context, limit int) ([]CrawlResultRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []struct {
|
||||
UpdatedAt uint32 `gorm:"column:updated_at"`
|
||||
Status string `gorm:"column:status"`
|
||||
Outcome string `gorm:"column:outcome"`
|
||||
Note string `gorm:"column:note"`
|
||||
BrandName string `gorm:"column:brand_name"`
|
||||
Title string `gorm:"column:title"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT j.updated_at, j.status, coalesce(j.outcome, '') AS outcome,
|
||||
coalesce(j.note, '') AS note,
|
||||
coalesce(b.name_en, '') AS brand_name,
|
||||
CASE WHEN j.payload LIKE '{%' THEN coalesce(j.payload::jsonb->>'title_en', '') ELSE '' END AS title
|
||||
FROM ingest_jobs j
|
||||
LEFT JOIN brands b ON b.id = j.brand_id
|
||||
WHERE j.kind = ?
|
||||
ORDER BY j.id DESC
|
||||
LIMIT ?`, model.IngestKindCrawl, limit).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CrawlResultRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, CrawlResultRow{
|
||||
UpdatedAt: row.UpdatedAt, Status: row.Status, Outcome: row.Outcome,
|
||||
Note: row.Note, BrandName: row.BrandName, Title: row.Title,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RunwayImageState 一场秀已有图片的快照:key→id 映射 + 最大排序号。
|
||||
type RunwayImageState struct {
|
||||
// Keys 图片存储 key(如 runway/<sha1>.jpg)→ 图片行 id。
|
||||
Keys map[string]uint32
|
||||
// MaxSort 当前最大 sort_order;补齐的新图从它往后排。
|
||||
MaxSort uint32
|
||||
}
|
||||
|
||||
// RunwayImageState 读取已有图片(只含未删除的行)。
|
||||
//
|
||||
// 用 key 而不是 URL 做比对:库里存的就是 key,spider 直传后上报的也是 key,
|
||||
// 于是「这张图有没有」变成一次纯内存查表,不用下载、不用请求存储。
|
||||
func (r *ingestRepository) RunwayImageState(ctx context.Context, runwayID uint32) (RunwayImageState, error) {
|
||||
var rows []struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Image string `gorm:"column:image"`
|
||||
SortOrder uint32 `gorm:"column:sort_order"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Raw(
|
||||
`SELECT id, coalesce(image, '') AS image, coalesce(sort_order, 0) AS sort_order
|
||||
FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0`,
|
||||
runwayID).Scan(&rows).Error; err != nil {
|
||||
return RunwayImageState{}, err
|
||||
}
|
||||
st := RunwayImageState{Keys: make(map[string]uint32, len(rows))}
|
||||
for _, row := range rows {
|
||||
if row.Image != "" {
|
||||
st.Keys[row.Image] = row.ID
|
||||
}
|
||||
if row.SortOrder > st.MaxSort {
|
||||
st.MaxSort = row.SortOrder
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// CreateRunwayImage 插入一张图片并回填自增 id。
|
||||
// 细节图需要把父主图的 id 写进 parent_image_id,所以必须拿回 id。
|
||||
func (r *ingestRepository) CreateRunwayImage(ctx context.Context, img *model.BrandRunwayImage) error {
|
||||
now := uint32(time.Now().Unix())
|
||||
img.CreatedAt, img.UpdatedAt = now, now
|
||||
return r.db.WithContext(ctx).Create(img).Error
|
||||
}
|
||||
|
||||
// SyncRunwayImageStats 重算主图张数并抬高应有张数。
|
||||
//
|
||||
// image_count 用子查询从实际行数算,而不是传入累加值:
|
||||
// 并发补图或中途失败时,累加值容易和真实行数漂移,而 image_count 会直接影响
|
||||
// 公开卡片的「N 张」角标与热度排序,必须与真实行一致。
|
||||
func (r *ingestRepository) SyncRunwayImageStats(ctx context.Context, runwayID uint32, expected int) error {
|
||||
if expected < 0 {
|
||||
expected = 0
|
||||
}
|
||||
return r.db.WithContext(ctx).Exec(`
|
||||
UPDATE brand_runways SET
|
||||
image_count = (SELECT count(*) FROM brand_runway_images
|
||||
WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0),
|
||||
expected_images = greatest(expected_images, ?),
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
runwayID, expected, uint32(time.Now().Unix()), runwayID).Error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user