feat: 上报抓取进度(后台显示 3/25 场 · 正在抓什么)
一次品牌抓取可能跑几十分钟,此前后台只有 running/done 两个状态, 看不到「在干什么」,也分不清「慢」和「卡死」。 - getDetail 进/出一场各打一次点(含 404 等失败场次,避免进度永远差几场) - 总数在「预检过滤 + 场次上限」之后定格,与实际要抓的场次一致 - 只对 TaskID != 0(serve 模式)上报,单机 vogue 模式不上报
This commit is contained in:
@ -95,7 +95,7 @@ func runServeWorker(ctx context.Context, workerID string) {
|
|||||||
workerID, task.ID, task.BrandName, task.BrandUID, task.MaxShows)
|
workerID, task.ID, task.BrandName, task.BrandUID, task.MaxShows)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
shows, errMsg := executeBrand(ctx, task.BrandUID, task.BrandName, int(task.MaxShows))
|
shows, errMsg := executeBrand(ctx, task.ID, task.BrandUID, task.BrandName, int(task.MaxShows))
|
||||||
elapsed := time.Since(start).Round(time.Second)
|
elapsed := time.Since(start).Round(time.Second)
|
||||||
|
|
||||||
status := "done"
|
status := "done"
|
||||||
@ -113,7 +113,7 @@ func runServeWorker(ctx context.Context, workerID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeBrand 执行单个品牌,异常(panic)也转成错误返回,避免一个坏品牌把整个 serve 进程带崩。
|
// executeBrand 执行单个品牌,异常(panic)也转成错误返回,避免一个坏品牌把整个 serve 进程带崩。
|
||||||
func executeBrand(ctx context.Context, brandUID, brandName string, maxShows int) (shows int, errMsg string) {
|
func executeBrand(ctx context.Context, taskID uint32, brandUID, brandName string, maxShows int) (shows int, errMsg string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
shows = 0
|
shows = 0
|
||||||
@ -127,6 +127,8 @@ func executeBrand(ctx context.Context, brandUID, brandName string, maxShows int)
|
|||||||
// 默认跳过已入库的秀:图片 key 是内容 sha1,不下载无法判重,
|
// 默认跳过已入库的秀:图片 key 是内容 sha1,不下载无法判重,
|
||||||
// 场次级预检是唯一能在下载前省掉整场带宽的手段。
|
// 场次级预检是唯一能在下载前省掉整场带宽的手段。
|
||||||
s.Force = forceAll
|
s.Force = forceAll
|
||||||
|
// 任务 ID 用于回报执行进度(后台「进度」列)。
|
||||||
|
s.TaskID = taskID
|
||||||
// 媒体机直传:spider 自己下载并上传图片,后端只写库。
|
// 媒体机直传:spider 自己下载并上传图片,后端只写库。
|
||||||
if config.GlobalConfig.Media.Upload && IngestClient != nil {
|
if config.GlobalConfig.Media.Upload && IngestClient != nil {
|
||||||
s.Media = media.New(IngestClient, "runway", config.GlobalConfig.Media.Concurrency)
|
s.Media = media.New(IngestClient, "runway", config.GlobalConfig.Media.Concurrency)
|
||||||
|
|||||||
@ -244,6 +244,44 @@ func (c *Client) CoveredShows(ctx context.Context, brandUID string) (map[string]
|
|||||||
return covered, nil
|
return covered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReportProgress 上报执行进度(每处理完一场秀调用一次)。
|
||||||
|
//
|
||||||
|
// 失败只是观测信息丢失:调用方记日志即可,不要中断抓取。
|
||||||
|
func (c *Client) ReportProgress(ctx context.Context, taskID uint32, done, total int, text string) error {
|
||||||
|
if c == nil || c.Endpoint == "" || taskID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{"done": done, "total": total, "text": text})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ts := NowTS()
|
||||||
|
nonce, err := newNonce()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s/crawl/tasks/%d/progress", c.internalBase(), taskID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Signature", Sign(c.Secret, ts, nonce, string(body)))
|
||||||
|
req.Header.Set("X-Timestamp", ts)
|
||||||
|
req.Header.Set("X-Nonce", nonce)
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("progress http %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ShowCoverage 一场秀在库里的覆盖情况。
|
// ShowCoverage 一场秀在库里的覆盖情况。
|
||||||
type ShowCoverage struct {
|
type ShowCoverage struct {
|
||||||
Year uint16 `json:"year"`
|
Year uint16 `json:"year"`
|
||||||
|
|||||||
@ -46,6 +46,12 @@ type VogueSpider struct {
|
|||||||
// 默认 false:整场已入库就跳过,一张图都不下载——图片 key 是内容 sha1,
|
// 默认 false:整场已入库就跳过,一张图都不下载——图片 key 是内容 sha1,
|
||||||
// 不下载无法判断是否已存在,只能靠这一层预检来省掉整场带宽。
|
// 不下载无法判断是否已存在,只能靠这一层预检来省掉整场带宽。
|
||||||
Force bool
|
Force bool
|
||||||
|
// TaskID 当前抓取任务 ID(serve 模式由后台派发);0 表示不是任务驱动,不上报进度。
|
||||||
|
TaskID uint32
|
||||||
|
// progressDone / progressTotal 本任务的进度计数,供后台列表页显示
|
||||||
|
// 「3/25 场 · Fall 2020 Ready-to-Wear」。并发抓取,故用原子操作。
|
||||||
|
progressDone int32
|
||||||
|
progressTotal int32
|
||||||
// Media 媒体机直传器(可为 nil:为 nil 时走旧路径,上报 URL 由后端下载)。
|
// Media 媒体机直传器(可为 nil:为 nil 时走旧路径,上报 URL 由后端下载)。
|
||||||
Media *media.Uploader
|
Media *media.Uploader
|
||||||
// submitted 本次品牌执行中成功上报的场次数(服务化模式回报给后台用)。
|
// submitted 本次品牌执行中成功上报的场次数(服务化模式回报给后台用)。
|
||||||
@ -295,6 +301,9 @@ func (s *VogueSpider) SpiderStart(task ingest.CrawlBrand) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
showSem := make(chan struct{}, maxShowConcurrency)
|
showSem := make(chan struct{}, maxShowConcurrency)
|
||||||
|
|
||||||
|
// 进度总数在这里定格:这是「预检过滤 + 场次上限」之后真正要抓的数量。
|
||||||
|
atomic.StoreInt32(&s.progressTotal, int32(len(showsList)))
|
||||||
|
|
||||||
for _, list := range showsList {
|
for _, list := range showsList {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|
||||||
@ -328,10 +337,34 @@ func (s *VogueSpider) getShowsList(url string) []gjson.Result {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reportProgress 打一次进度点。done=false 表示「正在抓这一场」,true 表示「这一场处理完了」。
|
||||||
|
//
|
||||||
|
// 单独抽成函数,是为了让调用点读起来像打点,而不是把上报逻辑塞进抓取主流程里。
|
||||||
|
// 上报失败只记日志:进度是观测信息,丢了顶多后台显示旧一点,不该影响抓取。
|
||||||
|
func (s *VogueSpider) reportProgress(title string, done bool) {
|
||||||
|
if s.Ingest == nil || s.TaskID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := int(atomic.LoadInt32(&s.progressDone))
|
||||||
|
if done {
|
||||||
|
n = int(atomic.AddInt32(&s.progressDone, 1))
|
||||||
|
}
|
||||||
|
total := int(atomic.LoadInt32(&s.progressTotal))
|
||||||
|
if err := s.Ingest.ReportProgress(context.Background(), s.TaskID, n, total, title); err != nil {
|
||||||
|
log.Printf("[进度] 上报失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getDetail 获取并解析单个发布会详情
|
// getDetail 获取并解析单个发布会详情
|
||||||
func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) {
|
func (s *VogueSpider) getDetail(brandUID string, info gjson.Result) {
|
||||||
hed := info.Get("hed").String()
|
hed := info.Get("hed").String()
|
||||||
|
|
||||||
|
// 进度打点:进这一场就报「正在抓 X」,退出时(含 404 等提前 return)再报「已完成 n 场」。
|
||||||
|
// 用 defer 而不是在成功分支里报,是为了让失败场次也计入进度,
|
||||||
|
// 否则界面上会出现「25 场永远差几场」的错觉。
|
||||||
|
s.reportProgress(hed, false)
|
||||||
|
defer s.reportProgress(hed, true)
|
||||||
|
|
||||||
// 判重交由后端 worker 处理(按实体键幂等去重),spider 不再直连 brand_runway 判重。
|
// 判重交由后端 worker 处理(按实体键幂等去重),spider 不再直连 brand_runway 判重。
|
||||||
|
|
||||||
// 获取图片(保持你原本的 URL 拼接与请求逻辑)
|
// 获取图片(保持你原本的 URL 拼接与请求逻辑)
|
||||||
|
|||||||
Reference in New Issue
Block a user