Files
spider/cmd/serve.go
toom1996 7fce365d75 feat: 上报抓取进度(后台显示 3/25 场 · 正在抓什么)
一次品牌抓取可能跑几十分钟,此前后台只有 running/done 两个状态,
看不到「在干什么」,也分不清「慢」和「卡死」。

- getDetail 进/出一场各打一次点(含 404 等失败场次,避免进度永远差几场)
- 总数在「预检过滤 + 场次上限」之后定格,与实际要抓的场次一致
- 只对 TaskID != 0(serve 模式)上报,单机 vogue 模式不上报
2026-10-25 20:33:45 +08:00

161 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.ID, 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, taskID uint32, 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
// 任务 ID 用于回报执行进度(后台「进度」列)。
s.TaskID = taskID
// 媒体机直传: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, "忽略「已入库」预检,强制重抓全部场次")
}