feat: 抓取进度可见 + 已入库秀预检(避免重复下载)

一、防重复下载
图片 key 是内容 sha1,不下载无法判断是否已存在;又没存 source_url,
于是每次重爬都把几百张已下过的图再下一遍(线上实测 Celine 62 分钟、0 新增)。
新增 GET /admin/internal/shows/covered?brand_uid=,按 (年份,季节,类型)
聚合已入库图片数,供 spider 抓前整场跳过。

二、进度可见
一次抓取可能跑几十分钟,此前只有 running/done,看不出「在干什么」,
也分不清「慢」和「卡死」。
- crawl_tasks 增加 progress_done/total/text/at
- POST /admin/internal/crawl/tasks/:id/progress(spider 每场 + 每批图片上报)
- GET /admin/crawl/live(页面 4 秒轮询,局部刷新状态/进度/已提交)
- progress_at 超过 10 分钟没更新标黄「可能卡住」
- 渲染测试守住进度列与轮询锚点
This commit is contained in:
toom1996
2026-10-25 21:22:56 +08:00
parent db3e3efab9
commit 51e3d13383
8 changed files with 408 additions and 21 deletions

View File

@ -540,3 +540,8 @@ tbody tr:last-child td { border-bottom: 0; }
}
/* 近邻若落在默认隐藏的副图(.cell.det)上,锚点定位后必须可见,否则跳过去是一片空白。 */
.cell.det:target { display: flex; }
/* 抓取进度:正常更新的进度用灰字;心跳停了(可能卡住)才用警示色。
用颜色而不是弹窗,是因为轮询每 4 秒一次,弹窗会变成骚扰。 */
.progress { color: #666; font-size: 12px; }
.progress.stale { color: #b45309; font-weight: 600; }

View File

@ -35,8 +35,53 @@ type crawlRowView struct {
Worker string
UpdatedAt string
LastError string
CanRetry bool
CanCancel bool
// ProgressLabel 「3/25 场 · Fall 2020 Ready-to-Wear」,空表示还没有进度。
ProgressLabel string
// Stale 执行中但进度长时间没推进(多半是卡住,而不是慢)。
Stale bool
CanRetry bool
CanCancel bool
}
// progressStaleSec 进度心跳超过这个时长没更新,就认为可能卡住了。
// 一场秀的图片下载在正常情况下是分钟级;10 分钟没有心跳基本可以断定卡住。
const progressStaleSec = 600
// progressLabel 拼出「3/25 场 · 秀标题」。
//
// 只显示数字没意义(不知道在处理哪一场),只显示标题也没意义(不知道还剩多少),
// 所以两者拼在一起。
func progressLabel(t model.CrawlTask) string {
out := ""
switch {
case t.ProgressTotal > 0:
out = fmt.Sprintf("%d/%d 场", t.ProgressDone, t.ProgressTotal)
case t.ProgressDone > 0:
out = fmt.Sprintf("%d 场", t.ProgressDone)
}
if t.ProgressText != "" {
if out != "" {
out += " · "
}
out += t.ProgressText
}
return out
}
// progressStale 判断「执行中但心跳停了」。
//
// 用 progress_at 而不是 updated_at:updated_at 在领取、回报时都会变,
// 用它判断会把「刚领取、还没开始抓」误判成卡死。
// 还没上报过进度时退化为 locked_at(领取时间)。
func progressStale(t model.CrawlTask, now int64) bool {
if t.Status != model.CrawlRunning {
return false
}
beat := int64(t.ProgressAt)
if beat == 0 {
beat = int64(t.LockedAt)
}
return beat > 0 && now-beat > progressStaleSec
}
// crawlStatusClass 把抓取状态映射到后台已有的徽标配色类(admin.css 只有
@ -91,19 +136,21 @@ func (h *BackstageHandler) CrawlList(c *gin.Context) {
ua = time.Unix(int64(r.UpdatedAt), 0).Format("2006-01-02 15:04")
}
views = append(views, crawlRowView{
ID: r.ID,
BrandUID: hashid.Encode(r.BrandID),
BrandName: r.BrandName,
Status: r.Status,
StatusLabel: crawlStatusLabel(r.Status),
MaxShows: r.MaxShows,
Shows: r.ShowsSubmitted,
Attempts: r.Attempts,
Worker: r.Worker,
UpdatedAt: ua,
LastError: r.LastError,
CanRetry: r.Status == model.CrawlFailed || r.Status == model.CrawlDone || r.Status == model.CrawlCanceled,
CanCancel: r.Status == model.CrawlQueued || r.Status == model.CrawlRunning,
ID: r.ID,
BrandUID: hashid.Encode(r.BrandID),
BrandName: r.BrandName,
Status: r.Status,
StatusLabel: crawlStatusLabel(r.Status),
MaxShows: r.MaxShows,
Shows: r.ShowsSubmitted,
Attempts: r.Attempts,
Worker: r.Worker,
UpdatedAt: ua,
LastError: r.LastError,
ProgressLabel: progressLabel(r.CrawlTask),
Stale: progressStale(r.CrawlTask, time.Now().Unix()),
CanRetry: r.Status == model.CrawlFailed || r.Status == model.CrawlDone || r.Status == model.CrawlCanceled,
CanCancel: r.Status == model.CrawlQueued || r.Status == model.CrawlRunning,
})
}
@ -128,6 +175,47 @@ func (h *BackstageHandler) CrawlList(c *gin.Context) {
})
}
// crawlLiveItem 进度轮询的返回项(只带页面需要刷新的几个字段)。
type crawlLiveItem struct {
ID uint32 `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
Shows int32 `json:"shows"`
Progress string `json:"progress"`
Stale bool `json:"stale"`
}
// CrawlLive 给抓取列表页做轻量轮询:只返回 running/queued 的行。
//
// 为什么是轮询而不是 WebSocket:
// - 进度事件低频(每场秀一次,几十秒到几分钟),长连接的优势用不上;
// - spider 是「拉任务」的短连接模型,为进度再维持一条长连接不划算;
// - 轮询断线自愈,前端只需一个 setInterval。
func (h *BackstageHandler) CrawlLive(c *gin.Context) {
if h.crawl == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"})
return
}
rows, err := h.crawl.ListActive(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
now := time.Now().Unix()
items := make([]crawlLiveItem, 0, len(rows))
for _, r := range rows {
items = append(items, crawlLiveItem{
ID: r.ID,
Status: r.Status,
StatusLabel: crawlStatusLabel(r.Status),
Shows: r.ShowsSubmitted,
Progress: progressLabel(r.CrawlTask),
Stale: progressStale(r.CrawlTask, now),
})
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// CrawlEnqueue 把单个品牌加入抓取队列(品牌列表页的行内按钮)。
// 品牌 id 用 hashid(与品牌页其它动作一致),避免暴露自增主键。
func (h *BackstageHandler) CrawlEnqueue(c *gin.Context) {
@ -263,6 +351,75 @@ func (h *BackstageHandler) WorkerTask(c *gin.Context) {
}})
}
// WorkerShowsCovered 返回某品牌「已入库的秀 → 已有图片张数」,供 spider 抓取前预检。
//
// 为什么需要它:图片 key 是内容 sha1,**不下载就不知道一张图是否已存在**。
// 于是重爬会把几百张已下过的图再下一遍(线上实测 Celine 62 分钟、约 600MB,0 新增)。
// 这个接口让 spider 在抓之前就知道「这场我已经有了」,整场跳过,一张都不下。
//
// 请求:GET /admin/internal/shows/covered?brand_uid=<hashid>
// 响应:{"items":[{"year":2020,"season":"fall","collection_type":"rtw","images":415}]}
func (h *BackstageHandler) WorkerShowsCovered(c *gin.Context) {
if h.crawl == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"})
return
}
uid := strings.TrimSpace(c.Query("brand_uid"))
brandID, err := hashid.Decode(uid)
if err != nil || brandID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "brand_uid 非法"})
return
}
coverage, err := h.crawl.ShowCoverage(c.Request.Context(), brandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query coverage failed"})
return
}
items := make([]gin.H, 0, len(coverage))
for k, n := range coverage {
items = append(items, gin.H{
"year": k.Year,
"season": k.Season,
"collection_type": k.CollectionType,
"images": n,
})
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// WorkerProgress 接收 spider 的执行进度(每处理完一场秀上报一次)。
//
// 请求:POST /admin/internal/crawl/tasks/:id/progress
//
// {"done": 3, "total": 25, "text": "Fall 2020 Ready-to-Wear"}
//
// 回报失败不影响抓取:进度是观测信息,丢了只是后台显示旧一点。
func (h *BackstageHandler) WorkerProgress(c *gin.Context) {
if h.crawl == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crawl repository not configured"})
return
}
id := atoiDefault(c.Param("id"), 0)
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "任务 ID 非法"})
return
}
var body struct {
Done int `json:"done"`
Total int `json:"total"`
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
if err := h.crawl.UpdateProgress(c.Request.Context(), uint32(id), body.Done, body.Total, body.Text); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update progress failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// WorkerUploadURL 给媒体机签发 S4 直传 URL(PUT 预签名)。
//
// 设计取舍:不在 spider 里配 AK/SK、也不引入 S3 SDK —— 密钥只留在后端,

View File

@ -0,0 +1,66 @@
package handler
import (
"bytes"
"strings"
"testing"
"fashionapi/internal/model"
"fashionapi/internal/pkg/pagination"
"github.com/gin-gonic/gin"
)
// TestCrawlPageRendersProgress 守住抓取列表页的「进度」列。
//
// 这一列由模板 + 前端轮询共同驱动,出错时不会编译失败、只会在打开页面时 500 或空白,
// 所以用一个渲染测试把「运行中 + 卡住 + 有进度文案」这几种情况固定下来。
func TestCrawlPageRendersProgress(t *testing.T) {
tpl, ok := pageTemplates["crawl"]
if !ok {
t.Fatal("crawl 模板未注册")
}
data := gin.H{
"NavKey": "crawl",
"PendingReviews": 0,
"Rows": []crawlRowView{{
ID: 7,
BrandUID: "abc123",
BrandName: "Celine",
Status: model.CrawlRunning,
StatusLabel: "执行中",
Shows: 3,
Worker: "vm-1-100-0",
UpdatedAt: "2026-10-25 21:00",
ProgressLabel: "3/13 场 · Fall 2021 Menswear(24/38 张)",
Stale: true,
CanCancel: true,
}},
"Total": 1,
"Queued": 0,
"Running": 1,
"Done": 0,
"Failed": 0,
"Added": 0,
"Pager": pagination.Pager{Label: "第 1 / 1 页"},
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil {
t.Fatalf("渲染抓取列表页失败: %v", err)
}
out := buf.String()
for _, want := range []string{
`data-live-progress="7"`, // 轮询锚点:JS 靠它定位单元格
`data-live-status="7"`, // 状态徽标也要能刷新
"3/13 场 · Fall 2021 Menswear(24/38 张)", // 进度文案原样输出
"progress stale", // 心跳停了要有警示样式
"/admin/crawl/live", // 轮询地址
} {
if !strings.Contains(out, want) {
t.Errorf("页面缺少 %q", want)
}
}
}

View File

@ -16,15 +16,16 @@
<table>
<thead><tr>
<th>ID</th><th>品牌</th><th>状态</th><th>场次上限</th><th>已提交</th><th>尝试</th><th>Worker</th><th>更新时间</th><th>错误</th><th>操作</th>
<th>ID</th><th>品牌</th><th>状态</th><th>进度</th><th>场次上限</th><th>已提交</th><th>尝试</th><th>Worker</th><th>更新时间</th><th>错误</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Rows}}<tr>
<td><code>{{.ID}}</code></td>
<td><a class="view" href="/admin/brands/{{.BrandUID}}">{{.BrandName}}</a></td>
<td><span class="badge">{{.StatusLabel}}</span></td>
<td><span class="badge" data-live-status="{{.ID}}">{{.StatusLabel}}</span></td>
<td class="progress{{if .Stale}} stale{{end}}" data-live-progress="{{.ID}}">{{if .ProgressLabel}}{{.ProgressLabel}}{{else}}—{{end}}</td>
<td>{{if eq .MaxShows 0}}<strong>全量</strong>{{else}}{{.MaxShows}}{{end}}</td>
<td>{{.Shows}}</td>
<td data-live-shows="{{.ID}}">{{.Shows}}</td>
<td>{{.Attempts}}</td>
<td class="muted">{{if .Worker}}{{.Worker}}{{else}}—{{end}}</td>
<td class="muted">{{.UpdatedAt}}</td>
@ -41,4 +42,42 @@
</table>
{{template "pager" .Pager}}
</div>
<script>
// 抓取进度轮询:每 4 秒只刷新「状态 / 进度 / 已提交」三列。
//
// 为什么不用 WebSocket:进度是每场秀一次的低频事件,轮询足够;
// 而且局部更新避免了整页刷新——整页刷会打断滚动位置和正在填的表单。
// 为什么不用整页 meta refresh:同上,且会把「失败原因」的展开状态一起冲掉。
(function () {
var cells = document.querySelectorAll('[data-live-progress]');
if (!cells.length) return;
function paint(items) {
var byId = {};
for (var i = 0; i < items.length; i++) byId[String(items[i].id)] = items[i];
for (var j = 0; j < cells.length; j++) {
var cell = cells[j];
var id = cell.getAttribute('data-live-progress');
var it = byId[id];
if (!it) continue;
cell.textContent = it.progress || '—';
if (it.stale) cell.classList.add('stale'); else cell.classList.remove('stale');
var st = document.querySelector('[data-live-status="' + id + '"]');
if (st) st.textContent = it.status_label;
var sh = document.querySelector('[data-live-shows="' + id + '"]');
if (sh) sh.textContent = it.shows;
}
}
function tick() {
fetch('/admin/crawl/live', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.items) paint(d.items); })
.catch(function () { /* 网络抖动忽略,下一轮再拉 */ });
}
setInterval(tick, 4000);
tick();
})();
</script>
{{end}}