问题:图片 key 是内容 sha1,不下载就无法判断是否已存在; 后端又没存 source_url,无法做 URL 级判重。 于是每次重爬都把几百张已下过的图再下一遍 —— 线上实测 Celine 跑了 62 分钟、约 600MB,入库 0 新增。 修复: - 后端新增 GET /admin/internal/shows/covered?brand_uid= 按 (年份, 季节, 类型) 聚合已入库图片张数 - spider 抓取前预检,整场已入库(>=10 张)就跳过,一张图都不下载 - 加 --force 可强制重抓(默认跳过) - 媒体直传并发改为全进程共享信号量:此前秀级 2 × 每场 6 = 12 路 同时打 Vogue,被限流后单张从 9.8s 掉到 ~90s - 批次下载失败时打印第一条错误(此前只统计个数,查不出原因)
159 lines
4.9 KiB
Go
159 lines
4.9 KiB
Go
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"os/signal"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"my-spiders/internal/config"
|
||
"my-spiders/internal/media"
|
||
"my-spiders/internal/spider"
|
||
)
|
||
|
||
var (
|
||
serveWorkers int
|
||
serveInterval int
|
||
serveMaxCo int
|
||
)
|
||
|
||
// serveCmd 把爬虫变成「常驻服务」:不再由人决定爬谁,而是从后台领取任务。
|
||
//
|
||
// 与 vogue 子命令的区别:
|
||
// - vogue:一次性的,参数指定品牌(--brand),跑完退出;
|
||
// - serve:常驻,循环「领任务 → 执行 → 回报」,没有任务就空转等待。
|
||
//
|
||
// 这样后台(/admin/crawl)就能控制爬哪些品牌,爬虫部署在哪台机器都不影响。
|
||
var serveCmd = &cobra.Command{
|
||
Use: "serve",
|
||
Short: "以服务方式运行:从后台领取品牌抓取任务并执行",
|
||
Run: func(cmd *cobra.Command, args []string) {
|
||
if IngestClient == nil {
|
||
log.Fatal("[致命] 未启用入库管线:config.yml 里 ingest.enabled 必须为 true(serve 模式依赖后台接口领取任务)")
|
||
}
|
||
|
||
host, _ := os.Hostname()
|
||
if strings.TrimSpace(host) == "" {
|
||
host = "spider"
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
// Ctrl+C / SIGTERM 优雅退出:不再领新任务,等在跑的品牌结束。
|
||
sig := make(chan os.Signal, 1)
|
||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||
var wg sync.WaitGroup
|
||
|
||
log.Printf("[serve] 启动 %d 个 worker,轮询间隔 %ds,单品牌并发 %d", serveWorkers, serveInterval, serveMaxCo)
|
||
|
||
for i := 0; i < serveWorkers; i++ {
|
||
wg.Add(1)
|
||
go func(idx int) {
|
||
defer wg.Done()
|
||
workerID := fmt.Sprintf("%s-%d-%d", host, os.Getpid(), idx)
|
||
runServeWorker(ctx, workerID)
|
||
}(i)
|
||
}
|
||
|
||
<-sig
|
||
log.Println("[serve] 收到退出信号,等待进行中的任务结束…")
|
||
cancel()
|
||
wg.Wait()
|
||
log.Println("[serve] 已退出")
|
||
},
|
||
}
|
||
|
||
func runServeWorker(ctx context.Context, workerID string) {
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
task, err := IngestClient.FetchTask(ctx, workerID)
|
||
if err != nil {
|
||
log.Printf("[serve] %s 领取任务失败: %v", workerID, err)
|
||
sleepOrDone(ctx, serveInterval)
|
||
continue
|
||
}
|
||
if task == nil {
|
||
// 队列空:退避轮询,避免打满后台接口。
|
||
sleepOrDone(ctx, serveInterval)
|
||
continue
|
||
}
|
||
|
||
log.Printf("[serve] %s 领取任务 task=%d 品牌=%s(uid=%s) 场次上限=%d",
|
||
workerID, task.ID, task.BrandName, task.BrandUID, task.MaxShows)
|
||
|
||
start := time.Now()
|
||
shows, errMsg := executeBrand(ctx, task.BrandUID, task.BrandName, int(task.MaxShows))
|
||
elapsed := time.Since(start).Round(time.Second)
|
||
|
||
status := "done"
|
||
if errMsg != "" {
|
||
status = "failed"
|
||
}
|
||
if err := IngestClient.ReportTask(ctx, task.ID, status, shows, errMsg); err != nil {
|
||
log.Printf("[serve] %s 回报任务 %d 失败: %v", workerID, task.ID, err)
|
||
// 回报失败不要紧:租约(CrawlStuckTimeoutSec)到期后后台会把任务重新放回队列。
|
||
sleepOrDone(ctx, serveInterval)
|
||
continue
|
||
}
|
||
log.Printf("[serve] %s 任务 %d 完成:status=%s 提交 %d 场,耗时 %s", workerID, task.ID, status, shows, elapsed)
|
||
}
|
||
}
|
||
|
||
// executeBrand 执行单个品牌,异常(panic)也转成错误返回,避免一个坏品牌把整个 serve 进程带崩。
|
||
func executeBrand(ctx context.Context, brandUID, brandName string, maxShows int) (shows int, errMsg string) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
shows = 0
|
||
errMsg = fmt.Sprintf("panic: %v", r)
|
||
log.Printf("[serve] 执行品牌 %s 时 panic: %v", brandName, r)
|
||
}
|
||
}()
|
||
|
||
s := spider.NewVogueSpider(serveMaxCo, 0, maxShows, IngestClient)
|
||
s.Debug = debug
|
||
// 默认跳过已入库的秀:图片 key 是内容 sha1,不下载无法判重,
|
||
// 场次级预检是唯一能在下载前省掉整场带宽的手段。
|
||
s.Force = forceAll
|
||
// 媒体机直传:spider 自己下载并上传图片,后端只写库。
|
||
if config.GlobalConfig.Media.Upload && IngestClient != nil {
|
||
s.Media = media.New(IngestClient, "runway", config.GlobalConfig.Media.Concurrency)
|
||
}
|
||
shows = s.RunBrand(brandUID, brandName, maxShows)
|
||
return shows, ""
|
||
}
|
||
|
||
// sleepOrDone 可被 ctx 取消打断的 sleep。
|
||
func sleepOrDone(ctx context.Context, seconds int) {
|
||
if seconds < 1 {
|
||
seconds = 5
|
||
}
|
||
t := time.NewTimer(time.Duration(seconds) * time.Second)
|
||
defer t.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
case <-t.C:
|
||
}
|
||
}
|
||
|
||
func init() {
|
||
rootCmd.AddCommand(serveCmd)
|
||
serveCmd.Flags().IntVarP(&serveWorkers, "workers", "w", 1, "并发执行的品牌数(每路一个 goroutine)")
|
||
serveCmd.Flags().IntVarP(&serveInterval, "interval", "i", 5, "队列为空时的轮询间隔(秒)")
|
||
serveCmd.Flags().IntVarP(&serveMaxCo, "max-co", "m", 5, "单个品牌内部的抓取并发数")
|
||
// 与 vogue 子命令共用同一个开关:默认跳过已入库的秀,确需刷新时加 --force。
|
||
serveCmd.Flags().BoolVarP(&forceAll, "force", "f", false, "忽略「已入库」预检,强制重抓全部场次")
|
||
}
|