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:
@ -10,7 +10,6 @@ import (
|
||||
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// 本文件收口「品牌抓取任务」的控制面:
|
||||
@ -22,26 +21,10 @@ import (
|
||||
//
|
||||
// 与入库任务(ingest_jobs)的分工见 model.CrawlTask 的注释。
|
||||
|
||||
// crawlRowView 列表展示结构(时间已格式化为字符串,模板里没有日期函数)。
|
||||
type crawlRowView struct {
|
||||
ID uint32
|
||||
BrandUID string
|
||||
BrandName string
|
||||
Status string
|
||||
StatusLabel string
|
||||
MaxShows int16
|
||||
Shows int32
|
||||
Attempts int16
|
||||
Worker string
|
||||
UpdatedAt string
|
||||
LastError string
|
||||
// ProgressLabel 「3/25 场 · Fall 2020 Ready-to-Wear」,空表示还没有进度。
|
||||
ProgressLabel string
|
||||
// Stale 执行中但进度长时间没推进(多半是卡住,而不是慢)。
|
||||
Stale bool
|
||||
CanRetry bool
|
||||
CanCancel bool
|
||||
}
|
||||
// 本文件保留:进度格式化(progressLabel / progressStale)、状态文案(crawlStatusLabel /
|
||||
// crawlStatusClass,品牌页的「抓取状态」列在用),以及 spider 侧的全部接口。
|
||||
// 原先的列表页视图 crawlRowView 与 CrawlList 已随「抓取任务」页一起下线,
|
||||
// 页面改由 backstage_spider.go 的「爬虫」页承担。
|
||||
|
||||
// progressStaleSec 进度心跳超过这个时长没更新,就认为可能卡住了。
|
||||
// 一场秀的图片下载在正常情况下是分钟级;10 分钟没有心跳基本可以断定卡住。
|
||||
@ -116,201 +99,6 @@ func crawlStatusLabel(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// CrawlList 渲染抓取任务列表页。
|
||||
func (h *BackstageHandler) CrawlList(c *gin.Context) {
|
||||
if h.crawl == nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取任务未装配(crawl repository 为空)"})
|
||||
return
|
||||
}
|
||||
p := pagination.Parse(c, "page", "pageSize", 1, 30, 200)
|
||||
rows, total, err := h.crawl.List(c.Request.Context(), p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]crawlRowView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ua := "-"
|
||||
if r.UpdatedAt > 0 {
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
counts, _ := h.crawl.Counts(c.Request.Context())
|
||||
meta := p.NewMeta(total)
|
||||
renderPage(c, http.StatusOK, "crawl", gin.H{
|
||||
"Rows": views,
|
||||
"Total": total,
|
||||
"Pager": pagination.Pager{
|
||||
PrevURL: pagination.PageLinkWith(c, meta.PrevPage, "pageSize", p.PageSize),
|
||||
NextURL: pagination.PageLinkWith(c, meta.NextPage, "pageSize", p.PageSize),
|
||||
Label: fmt.Sprintf("第 %d / %d 页", meta.Page, meta.TotalPages),
|
||||
HasPrev: meta.HasPrev,
|
||||
HasNext: meta.HasNext,
|
||||
},
|
||||
"Queued": counts[model.CrawlQueued],
|
||||
"Running": counts[model.CrawlRunning],
|
||||
"Done": counts[model.CrawlDone],
|
||||
"Failed": counts[model.CrawlFailed],
|
||||
// 批量入队后回显「本次加入了多少个」
|
||||
"Added": atoiDefault(c.Query("added"), 0),
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if h.crawl == nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取任务未装配"})
|
||||
return
|
||||
}
|
||||
brandID, err := hashid.Decode(c.Param("uid"))
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "品牌 ID 非法"})
|
||||
return
|
||||
}
|
||||
maxShows := atoiDefault(c.PostForm("maxShows"), 0)
|
||||
if _, err := h.crawl.Enqueue(c.Request.Context(), brandID, maxShows, 10); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, c.PostForm("back"))
|
||||
}
|
||||
|
||||
// CrawlBulk 把「品牌管理页勾选的品牌」批量加入抓取队列。
|
||||
//
|
||||
// 这是批量操作的唯一入口(原先的「按品牌名前缀批量」已下线):
|
||||
// 勾选哪几个就加哪几个,作用域明确,不会因为前缀写错而误抓几千个品牌。
|
||||
func (h *BackstageHandler) CrawlBulk(c *gin.Context) {
|
||||
if h.crawl == nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取任务未装配"})
|
||||
return
|
||||
}
|
||||
uids := c.PostFormArray("uid")
|
||||
if len(uids) == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "没有勾选任何品牌"})
|
||||
return
|
||||
}
|
||||
maxShows := atoiDefault(c.PostForm("maxShows"), 3)
|
||||
|
||||
added := 0
|
||||
for _, uid := range uids {
|
||||
id, derr := hashid.Decode(strings.TrimSpace(uid))
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
ok, err := h.crawl.Enqueue(c.Request.Context(), id, maxShows, 5)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
added++
|
||||
}
|
||||
}
|
||||
c.Redirect(http.StatusFound, fmt.Sprintf("/admin/brands?added=%d", added))
|
||||
}
|
||||
|
||||
// CrawlRetry 把任务重置为待领取(失败 / 取消 / 已完成后想再跑一遍)。
|
||||
func (h *BackstageHandler) CrawlRetry(c *gin.Context) {
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id <= 0 || h.crawl == nil {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "任务 ID 非法"})
|
||||
return
|
||||
}
|
||||
if err := h.crawl.SetStatus(c.Request.Context(), uint32(id), model.CrawlQueued); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/crawl")
|
||||
}
|
||||
|
||||
// CrawlCancel 取消任务(执行中的任务无法真正打断 spider,只保证不再被领取)。
|
||||
func (h *BackstageHandler) CrawlCancel(c *gin.Context) {
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id <= 0 || h.crawl == nil {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "任务 ID 非法"})
|
||||
return
|
||||
}
|
||||
if err := h.crawl.SetStatus(c.Request.Context(), uint32(id), model.CrawlCanceled); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/crawl")
|
||||
}
|
||||
|
||||
// CrawlDelete 删除任务记录(已入库的数据不受影响)。
|
||||
func (h *BackstageHandler) CrawlDelete(c *gin.Context) {
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id <= 0 || h.crawl == nil {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "任务 ID 非法"})
|
||||
return
|
||||
}
|
||||
if err := h.crawl.Delete(c.Request.Context(), uint32(id)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/crawl")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spider 服务接口(HMAC 验签,由 router 的 /admin/internal 组挂载)
|
||||
@ -376,12 +164,14 @@ func (h *BackstageHandler) WorkerShowsCovered(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(coverage))
|
||||
for k, n := range coverage {
|
||||
for k, st := range coverage {
|
||||
items = append(items, gin.H{
|
||||
"year": k.Year,
|
||||
"season": k.Season,
|
||||
"collection_type": k.CollectionType,
|
||||
"images": n,
|
||||
"images": st.Images,
|
||||
// expected=0 表示历史数据没有记录应有张数,spider 会退化为按张数阈值判断。
|
||||
"expected": st.Expected,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
|
||||
@ -776,96 +776,6 @@ func (h *BackstageHandler) SuggestTitleCn(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"title_cn": cn})
|
||||
}
|
||||
|
||||
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
|
||||
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
|
||||
offset := p.Offset()
|
||||
jobs, total, err := h.ingest.ListJobs(c.Request.Context(), offset, p.PageSize)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
views := make([]ingestJobView, 0, len(jobs))
|
||||
for _, j := range jobs {
|
||||
ua := ""
|
||||
if j.UpdatedAt > 0 {
|
||||
ua = time.Unix(int64(j.UpdatedAt), 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
views = append(views, ingestJobView{
|
||||
ID: j.ID,
|
||||
Status: j.Status,
|
||||
Attempts: j.Attempts,
|
||||
LastError: j.LastError,
|
||||
UpdatedAt: ua,
|
||||
})
|
||||
}
|
||||
// 越界页码(如手动改 URL 成大数)钳制到最后一页展示,与原有行为一致。
|
||||
// meta.Clamp 仅调整展示用的 Page/Prev/Next,不改变上面已算好的查询 offset。
|
||||
meta := p.NewMeta(total)
|
||||
meta.Clamp()
|
||||
renderPage(c, http.StatusOK, "ingest-jobs", gin.H{
|
||||
"Jobs": views,
|
||||
"Page": meta.Page,
|
||||
"PageSize": p.PageSize,
|
||||
"Total": total,
|
||||
"TotalPages": meta.TotalPages,
|
||||
"Pager": pagination.Pager{
|
||||
PrevURL: pagination.PageLinkWith(c, meta.PrevPage, "pageSize", p.PageSize),
|
||||
NextURL: pagination.PageLinkWith(c, meta.NextPage, "pageSize", p.PageSize),
|
||||
HasPrev: meta.HasPrev,
|
||||
HasNext: meta.HasNext,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// IngestJobRetry 把一条 failed 任务重新入队(重置为 pending),worker 会在下一个周期重新处理。
|
||||
func (h *BackstageHandler) IngestJobRetry(c *gin.Context) {
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "任务不存在: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
if err := h.ingest.RetryJob(c.Request.Context(), uint32(id)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/ingest-jobs")
|
||||
}
|
||||
|
||||
// reviewFormFields 从表单收集可编辑字段(runway + street 一并收集,
|
||||
// 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。
|
||||
func reviewFormFields(c *gin.Context) map[string]any {
|
||||
f := map[string]any{}
|
||||
if v := c.PostForm("title_en"); v != "" {
|
||||
f["title_en"] = v
|
||||
}
|
||||
if v := c.PostForm("title_cn"); v != "" {
|
||||
f["title_cn"] = v
|
||||
}
|
||||
f["description_en"] = c.PostForm("description_en")
|
||||
f["description_cn"] = c.PostForm("description_cn")
|
||||
if v := c.PostForm("season"); v != "" {
|
||||
f["season"] = v
|
||||
}
|
||||
if v := c.PostForm("collection_type"); v != "" {
|
||||
f["collection_type"] = v
|
||||
}
|
||||
if v := c.PostForm("season_code"); v != "" {
|
||||
f["season_code"] = v
|
||||
}
|
||||
if v := c.PostForm("title"); v != "" {
|
||||
f["title"] = v
|
||||
}
|
||||
if v := c.PostForm("city"); v != "" {
|
||||
f["city"] = v
|
||||
}
|
||||
if v := c.PostForm("year"); v != "" {
|
||||
if y, err := strconv.Atoi(v); err == nil {
|
||||
f["year"] = uint16(y)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// usernameString 把中间件注入的 username(any)兜底成字符串。
|
||||
func usernameString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
|
||||
@ -28,14 +28,13 @@ func TestBackstageTemplatesParsed(t *testing.T) {
|
||||
"articles",
|
||||
"brand-form",
|
||||
"brands",
|
||||
"crawl",
|
||||
"dashboard",
|
||||
"error",
|
||||
"ingest-jobs",
|
||||
"login",
|
||||
"runway-detail",
|
||||
"runway-edit",
|
||||
"snap-edit",
|
||||
"spider",
|
||||
"street-snap-detail",
|
||||
"users",
|
||||
}
|
||||
@ -74,8 +73,9 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
||||
"Images": []any{},
|
||||
// 列表数据
|
||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
|
||||
// 抓取任务页(crawl.html):列表 + 四个状态计数
|
||||
"Rows": []any{}, "Queued": int64(0), "Running": int64(0), "Done": int64(0), "Failed": int64(0),
|
||||
// 爬虫页(spider.html):状态条 + 正在抓 + 待抓队列 + 最近入库结果
|
||||
"State": "空闲", "StateClass": "done", "RunningCount": 0, "QueuedCount": 0, "PendingIngest": int64(0),
|
||||
"Running": []spiderRunView{}, "Queued": []spiderQueueView{}, "Results": []spiderResultView{},
|
||||
// 品牌页的「抓取状态 / 上次抓取」列(按 hashid 索引)
|
||||
"CrawlStates": map[string]any{}, "Stats": map[string]any{}, "Added": 0,
|
||||
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||
|
||||
296
internal/handler/backstage_spider.go
Normal file
296
internal/handler/backstage_spider.go
Normal file
@ -0,0 +1,296 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
)
|
||||
|
||||
// 本文件收口后台「爬虫」页。
|
||||
//
|
||||
// 为什么把两条队列合成一页:
|
||||
//
|
||||
// spider 抓图 → POST /ingest → ingest_jobs → 后端 worker 下载/写库
|
||||
// ↑ 抓取进度写 crawl_tasks ↑ 入库结果写 ingest_jobs.outcome/note
|
||||
//
|
||||
// 这两张表描述的是同一条流水线的上下游。此前分成「抓取任务」「入库任务」两页,
|
||||
// 任何一页都看不全「现在到底在干什么」,排障要在两页之间来回跳。
|
||||
//
|
||||
// 页面是**只读**的:抓取卡死由 300 秒租约自动回收(且有心跳续命),
|
||||
// 入队操作仍在品牌页(勾选 → 加入抓取队列 → 一律全量)。
|
||||
|
||||
// spiderRunView 「正在抓」一行。
|
||||
type spiderRunView struct {
|
||||
ID uint32
|
||||
BrandName string
|
||||
Progress string
|
||||
// Beat 距最近一次心跳多久(见 progressStale 的说明)。
|
||||
Beat string
|
||||
Stale bool
|
||||
Worker string
|
||||
}
|
||||
|
||||
// spiderQueueView 「待抓队列」一行。
|
||||
type spiderQueueView struct {
|
||||
BrandName string
|
||||
Since string
|
||||
Waiting string
|
||||
}
|
||||
|
||||
// spiderResultView 「最近入库结果」一行。
|
||||
type spiderResultView struct {
|
||||
At string
|
||||
BrandName string
|
||||
Title string
|
||||
Result string
|
||||
Note string
|
||||
Bad bool
|
||||
}
|
||||
|
||||
// SpiderBoard 渲染爬虫管理页。
|
||||
func (h *BackstageHandler) SpiderBoard(c *gin.Context) {
|
||||
if h.crawl == nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取仓储未装配"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
now := time.Now().Unix()
|
||||
|
||||
active, err := h.crawl.ListActive(ctx)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
runs, queues := splitActive(active, now)
|
||||
|
||||
// 入库侧查询失败不该让整页 500:抓取部分仍然有参考价值。
|
||||
results := make([]spiderResultView, 0, 50)
|
||||
if raw, rErr := h.ingest.RecentCrawlResults(ctx, 50); rErr == nil {
|
||||
for _, r := range raw {
|
||||
label, bad := crawlOutcomeLabel(r.Status, r.Outcome)
|
||||
results = append(results, spiderResultView{
|
||||
At: formatUnix(int64(r.UpdatedAt), "01-02 15:04"),
|
||||
BrandName: r.BrandName,
|
||||
Title: r.Title,
|
||||
Result: label,
|
||||
Note: r.Note,
|
||||
Bad: bad,
|
||||
})
|
||||
}
|
||||
}
|
||||
pending, pErr := h.ingest.CountPendingJobs(ctx)
|
||||
if pErr != nil {
|
||||
pending = 0
|
||||
}
|
||||
|
||||
state, class := spiderState(active, now)
|
||||
renderPage(c, http.StatusOK, "spider", gin.H{
|
||||
"State": state,
|
||||
"StateClass": class,
|
||||
"RunningCount": len(runs),
|
||||
"QueuedCount": len(queues),
|
||||
"PendingIngest": pending,
|
||||
"Running": runs,
|
||||
"Queued": queues,
|
||||
"Results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// splitActive 把 running / queued 拆成两种视图,并按页面需要格式化时间。
|
||||
func splitActive(rows []model.CrawlTaskRow, now int64) ([]spiderRunView, []spiderQueueView) {
|
||||
runs := make([]spiderRunView, 0, 2)
|
||||
queues := make([]spiderQueueView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch r.Status {
|
||||
case model.CrawlRunning:
|
||||
runs = append(runs, spiderRunView{
|
||||
ID: r.ID,
|
||||
BrandName: r.BrandName,
|
||||
Progress: progressLabel(r.CrawlTask),
|
||||
Beat: humanWait(now - lastBeat(r.CrawlTask)),
|
||||
Stale: progressStale(r.CrawlTask, now),
|
||||
Worker: r.Worker,
|
||||
})
|
||||
case model.CrawlQueued:
|
||||
since := int64(r.CreatedAt)
|
||||
queues = append(queues, spiderQueueView{
|
||||
BrandName: r.BrandName,
|
||||
Since: formatUnix(since, "01-02 15:04"),
|
||||
Waiting: humanWait(now - since),
|
||||
})
|
||||
}
|
||||
}
|
||||
return runs, queues
|
||||
}
|
||||
|
||||
// lastBeat 任务最近一次「有动静」的时间:优先进度心跳,还没有心跳时退化为领取时间。
|
||||
func lastBeat(t model.CrawlTask) int64 {
|
||||
if t.ProgressAt > 0 {
|
||||
return int64(t.ProgressAt)
|
||||
}
|
||||
return int64(t.LockedAt)
|
||||
}
|
||||
|
||||
// spiderState 给出状态条上的一句话和配色。
|
||||
func spiderState(rows []model.CrawlTaskRow, now int64) (string, string) {
|
||||
running := make([]model.CrawlTaskRow, 0, 1)
|
||||
for _, r := range rows {
|
||||
if r.Status == model.CrawlRunning {
|
||||
running = append(running, r)
|
||||
}
|
||||
}
|
||||
if len(running) == 0 {
|
||||
return "空闲", "done"
|
||||
}
|
||||
for _, r := range running {
|
||||
if progressStale(r.CrawlTask, now) {
|
||||
return "可能卡住", "failed"
|
||||
}
|
||||
}
|
||||
return "在线", "done"
|
||||
}
|
||||
|
||||
// SpiderLive 轮询接口:只返回会变化的部分(状态 + 正在抓 + 队列数量)。
|
||||
//
|
||||
// 最近入库结果不参与轮询——那是历史,除了新增行不会变,每 4 秒重传毫无意义。
|
||||
func (h *BackstageHandler) SpiderLive(c *gin.Context) {
|
||||
if h.crawl == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "抓取仓储未装配"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
now := time.Now().Unix()
|
||||
active, err := h.crawl.ListActive(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
runs, queues := splitActive(active, now)
|
||||
state, class := spiderState(active, now)
|
||||
|
||||
items := make([]gin.H, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
items = append(items, gin.H{
|
||||
"id": r.ID,
|
||||
"brand": r.BrandName,
|
||||
"progress": r.Progress,
|
||||
"beat": r.Beat,
|
||||
"stale": r.Stale,
|
||||
"worker": r.Worker,
|
||||
})
|
||||
}
|
||||
pending := int64(0)
|
||||
if n, pErr := h.ingest.CountPendingJobs(ctx); pErr == nil {
|
||||
pending = n
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"state": state,
|
||||
"state_class": class,
|
||||
"running": items,
|
||||
"queued": len(queues),
|
||||
"pending_ingest": pending,
|
||||
})
|
||||
}
|
||||
|
||||
// SpiderEnqueue 把单个品牌加入抓取队列(品牌页行内按钮)。一律全量。
|
||||
func (h *BackstageHandler) SpiderEnqueue(c *gin.Context) {
|
||||
if h.crawl == nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取仓储未装配"})
|
||||
return
|
||||
}
|
||||
brandID, err := hashid.Decode(c.Param("uid"))
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "品牌 ID 非法"})
|
||||
return
|
||||
}
|
||||
// maxShows 固定 0(全量):小批量模式已下线,抓多少由「已抓全就跳过」决定。
|
||||
if _, err := h.crawl.Enqueue(c.Request.Context(), brandID, 0, 10); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, c.PostForm("back"))
|
||||
}
|
||||
|
||||
// SpiderBulk 把「品牌管理页勾选的品牌」批量加入队列。一律全量。
|
||||
func (h *BackstageHandler) SpiderBulk(c *gin.Context) {
|
||||
if h.crawl == nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": "抓取仓储未装配"})
|
||||
return
|
||||
}
|
||||
uids := c.PostFormArray("uid")
|
||||
if len(uids) == 0 {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "没有勾选任何品牌"})
|
||||
return
|
||||
}
|
||||
added := 0
|
||||
for _, uid := range uids {
|
||||
id, derr := hashid.Decode(uid)
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
ok, err := h.crawl.Enqueue(c.Request.Context(), id, 0, 5)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
added++
|
||||
}
|
||||
}
|
||||
c.Redirect(http.StatusFound, fmt.Sprintf("/admin/brands?added=%d", added))
|
||||
}
|
||||
|
||||
// crawlOutcomeLabel 把入库结果翻译成中文,并标出是否为失败。
|
||||
func crawlOutcomeLabel(status, outcome string) (string, bool) {
|
||||
if status == model.IngestStatusFailed {
|
||||
return "失败", true
|
||||
}
|
||||
if status == model.IngestStatusPending || status == model.IngestStatusProcessing {
|
||||
return "处理中", false
|
||||
}
|
||||
switch outcome {
|
||||
case model.IngestOutcomeCreated:
|
||||
return "新建", false
|
||||
case model.IngestOutcomeRepaired:
|
||||
return "补齐缺图", false
|
||||
case model.IngestOutcomeReused:
|
||||
return "复用被驳回", false
|
||||
case model.IngestOutcomeSkippedExisting:
|
||||
return "已存在跳过", false
|
||||
case model.IngestOutcomeSkippedDeleted:
|
||||
return "已删除跳过", false
|
||||
case model.IngestOutcomeCleaned:
|
||||
return "清理", false
|
||||
case model.IngestOutcomeNoop:
|
||||
return "无需处理", false
|
||||
}
|
||||
return "完成", false
|
||||
}
|
||||
|
||||
// formatUnix 把 Unix 秒格式化成指定布局;0 返回占位符。
|
||||
func formatUnix(sec int64, layout string) string {
|
||||
if sec <= 0 {
|
||||
return "—"
|
||||
}
|
||||
return time.Unix(sec, 0).Format(layout)
|
||||
}
|
||||
|
||||
// humanWait 把等待秒数说成人话(队列里看「等了多久」比看时间戳直观)。
|
||||
func humanWait(sec int64) string {
|
||||
if sec < 0 {
|
||||
sec = 0
|
||||
}
|
||||
switch {
|
||||
case sec < 60:
|
||||
return fmt.Sprintf("%d 秒", sec)
|
||||
case sec < 3600:
|
||||
return fmt.Sprintf("%d 分钟", sec/60)
|
||||
default:
|
||||
return fmt.Sprintf("%d 小时 %d 分", sec/3600, (sec%3600)/60)
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/handler/spider_render_test.go
Normal file
76
internal/handler/spider_render_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestSpiderPageRenders 守住「爬虫」页的三块内容与轮询锚点。
|
||||
//
|
||||
// 这类模板错误不会编译失败,只会在打开页面时 500 或空白,
|
||||
// 所以用一个渲染测试把「有任务 / 卡住 / 有入库结果」这几种情况固定下来。
|
||||
func TestSpiderPageRenders(t *testing.T) {
|
||||
tpl, ok := pageTemplates["spider"]
|
||||
if !ok {
|
||||
t.Fatal("spider 模板未注册")
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"NavKey": "spider",
|
||||
"PendingReviews": 0,
|
||||
"State": "可能卡住",
|
||||
"StateClass": "failed",
|
||||
"RunningCount": 1,
|
||||
"QueuedCount": 2,
|
||||
"PendingIngest": 3,
|
||||
"Running": []spiderRunView{{
|
||||
ID: 85,
|
||||
BrandName: "Amiri",
|
||||
Progress: "12/47 场 · Fall 2021 Menswear(24/38 张)",
|
||||
Beat: "11 分钟",
|
||||
Stale: true,
|
||||
Worker: "vm-1-74218-0",
|
||||
}},
|
||||
"Queued": []spiderQueueView{
|
||||
{BrandName: "Celine", Since: "10-25 23:30", Waiting: "8 分钟"},
|
||||
{BrandName: "Gucci", Since: "10-25 23:35", Waiting: "3 分钟"},
|
||||
},
|
||||
"Results": []spiderResultView{
|
||||
{At: "10-25 23:12", BrandName: "Amiri", Title: "Fall 2019 Menswear", Result: "新建", Note: "新建 34 张图"},
|
||||
{At: "10-25 23:08", BrandName: "Gucci", Title: "Fall 2020 RTW", Result: "失败", Note: "图片下载超时", Bad: true},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
t.Fatalf("渲染爬虫页失败: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`id="running-body"`, // 轮询重建的目标容器
|
||||
"/admin/spider/live", // 轮询地址
|
||||
"12/47 场 · Fall 2021 Menswear(24/38 张)", // 进度文案原样输出
|
||||
"Celine", // 待抓队列
|
||||
"新建 34 张图", // 入库结果说明
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("页面缺少 %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// 卡住要能看出来:进度单元格带 stale 样式。
|
||||
if !strings.Contains(out, "progress stale") {
|
||||
t.Error("疑似卡住的任务没有 stale 样式")
|
||||
}
|
||||
// 失败结果要走 failed 徽标,正常结果走 done 徽标。
|
||||
if !strings.Contains(out, `class="badge failed"`) {
|
||||
t.Error("失败结果没有 failed 徽标")
|
||||
}
|
||||
if !strings.Contains(out, `class="badge done"`) {
|
||||
t.Error("正常结果没有 done 徽标")
|
||||
}
|
||||
}
|
||||
@ -32,17 +32,13 @@
|
||||
</div>
|
||||
|
||||
{{/* 批量操作表单:勾选框用 form="bulk" 关联,避免与行内表单嵌套 */}}
|
||||
<form id="bulk" method="post" action="/admin/crawl/bulk"></form>
|
||||
<form id="bulk" method="post" action="/admin/spider/bulk"></form>
|
||||
<div class="ops" style="margin:10px 0">
|
||||
<label class="muted">每品牌场次上限
|
||||
<select name="maxShows" form="bulk">
|
||||
<option value="1">1 场(最快探有无数据)</option>
|
||||
<option value="3" selected>3 场(最近,推荐)</option>
|
||||
<option value="0">全量(所有历史场次)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" form="bulk" class="op">把勾选的品牌加入抓取队列</button>
|
||||
<span class="muted">(勾选本页任意行,先点复选框列头可全选本页)</span>
|
||||
<span class="muted">
|
||||
(勾选本页任意行,先点复选框列头可全选本页。抓取一律全量:
|
||||
已抓全的场次会被自动跳过,不用担心重复下载。)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
@ -62,8 +58,8 @@
|
||||
<td class="muted">{{if $cs}}{{$cs.LastAt}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<a class="view" href="/admin/brands/{{.UID}}">编辑</a>
|
||||
<form class="inline" method="post" action="/admin/crawl/brands/{{.UID}}" onsubmit="return confirm('把「{{.NameEn}}」加入抓取队列?默认只抓最近 3 场。')">
|
||||
<input type="hidden" name="back" value="/admin/brands"><input type="hidden" name="maxShows" value="3">
|
||||
<form class="inline" method="post" action="/admin/spider/brands/{{.UID}}" onsubmit="return confirm('把「{{.NameEn}}」加入抓取队列?(全量,已抓全的场次自动跳过)')">
|
||||
<input type="hidden" name="back" value="/admin/brands">
|
||||
<button type="submit" class="op">加入队列</button>
|
||||
</form>
|
||||
<form class="inline" method="post" action="/admin/brands/{{.UID}}/delete" onsubmit="return confirm('确认删除品牌「{{.NameEn}}」?该品牌下若还有图集会拒绝删除。')">
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
{{define "title"}}抓取任务 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 品牌抓取任务{{end}}
|
||||
{{define "barRight"}}<a href="/admin/brands">去品牌管理加任务</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>抓取任务</h1>
|
||||
<p class="muted">
|
||||
任务是「从品牌管理页加入」的:勾选品牌 → 选场次上限 → 加入抓取队列。
|
||||
这里看的是执行状态与失败原因。真正干活的是 spider 服务(可在另一台机器)。
|
||||
</p>
|
||||
<p class="muted">
|
||||
待领取 {{.Queued}} · 执行中 {{.Running}} · 已完成 {{.Done}} · 失败 {{.Failed}},共 {{.Total}} 条。
|
||||
<strong>「已提交 0 场」不是错误</strong>——Vogue 上很多品牌没有走秀页面(实测 A 开头里约 75%)。
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead><tr>
|
||||
<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" 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 data-live-shows="{{.ID}}">{{.Shows}}</td>
|
||||
<td>{{.Attempts}}</td>
|
||||
<td class="muted">{{if .Worker}}{{.Worker}}{{else}}—{{end}}</td>
|
||||
<td class="muted">{{.UpdatedAt}}</td>
|
||||
<td class="err break">{{.LastError}}</td>
|
||||
<td>
|
||||
<div class="ops">
|
||||
{{if .CanRetry}}<form class="inline" method="post" action="/admin/crawl/tasks/{{.ID}}/retry"><button class="op" type="submit">重试</button></form>{{end}}
|
||||
{{if .CanCancel}}<form class="inline" method="post" action="/admin/crawl/tasks/{{.ID}}/cancel"><button class="op" type="submit">取消</button></form>{{end}}
|
||||
<form class="inline" method="post" action="/admin/crawl/tasks/{{.ID}}/delete" onsubmit="return confirm('删除任务记录?已入库的数据不受影响。')"><button class="op" type="submit">删除</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</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}}
|
||||
@ -1,24 +0,0 @@
|
||||
{{define "title"}}入库任务 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 爬虫入库任务{{end}}
|
||||
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>爬虫入库任务</h1>
|
||||
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Jobs}}<tr>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{.Attempts}}</td>
|
||||
<td class="err break">{{.LastError}}</td>
|
||||
<td class="muted">{{.UpdatedAt}}</td>
|
||||
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "pager" .Pager}}
|
||||
</div>
|
||||
{{end}}
|
||||
134
internal/handler/templates/pages/spider.html
Normal file
134
internal/handler/templates/pages/spider.html
Normal file
@ -0,0 +1,134 @@
|
||||
{{define "title"}}爬虫 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 爬虫{{end}}
|
||||
{{define "barRight"}}<a href="/admin/brands">去品牌管理加任务</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<h1>爬虫</h1>
|
||||
<p class="muted">
|
||||
这里看「爬虫现在在干什么」:<strong>正在抓</strong>是当下,
|
||||
<strong>待抓队列</strong>是排队等着的品牌,
|
||||
<strong>最近入库结果</strong>是后端已经处理完的场次。
|
||||
</p>
|
||||
<p class="muted">
|
||||
入队请去<a href="/admin/brands">品牌管理</a>勾选品牌。抓取一律全量,
|
||||
已经抓全的场次会被自动跳过,不会重复下载。
|
||||
</p>
|
||||
|
||||
<p class="muted" id="spider-status">
|
||||
爬虫 <span class="badge {{.StateClass}}" id="st-state">{{.State}}</span>
|
||||
· 正在抓 <strong id="st-running">{{.RunningCount}}</strong> 个
|
||||
· 队列 <strong id="st-queued">{{.QueuedCount}}</strong> 个
|
||||
· 待入库 <strong id="st-pending">{{.PendingIngest}}</strong> 条
|
||||
</p>
|
||||
|
||||
<h2>正在抓</h2>
|
||||
<table>
|
||||
<thead><tr><th>品牌</th><th>进度</th><th>心跳</th><th>Worker</th></tr></thead>
|
||||
<tbody id="running-body">
|
||||
{{range .Running}}<tr>
|
||||
<td><strong>{{.BrandName}}</strong></td>
|
||||
<td class="progress{{if .Stale}} stale{{end}}">{{if .Progress}}{{.Progress}}{{else}}刚领取,正在拉取秀列表…{{end}}</td>
|
||||
<td class="muted{{if .Stale}} stale{{end}}">{{.Beat}}前</td>
|
||||
<td class="muted">{{.Worker}}</td>
|
||||
</tr>{{end}}
|
||||
{{if not .Running}}<tr id="running-empty"><td colspan="4" class="muted">当前没有正在抓取的任务。</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>待抓队列({{.QueuedCount}})</h2>
|
||||
<table>
|
||||
<thead><tr><th>品牌</th><th>入队时间</th><th>已等待</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Queued}}<tr>
|
||||
<td>{{.BrandName}}</td>
|
||||
<td class="muted">{{.Since}}</td>
|
||||
<td class="muted">{{.Waiting}}</td>
|
||||
</tr>{{end}}
|
||||
{{if not .Queued}}<tr><td colspan="3" class="muted">队列为空。</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>最近入库结果</h2>
|
||||
<p class="muted">最近 50 条。「已存在跳过」是正常状态——说明这场秀之前抓过,没有重复写库。</p>
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>品牌</th><th>场次</th><th>结果</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Results}}<tr>
|
||||
<td class="muted">{{.At}}</td>
|
||||
<td>{{.BrandName}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td>{{if .Bad}}<span class="badge failed">{{.Result}}</span>{{else}}<span class="badge done">{{.Result}}</span>{{end}}</td>
|
||||
<td class="err break">{{.Note}}</td>
|
||||
</tr>{{end}}
|
||||
{{if not .Results}}<tr><td colspan="5" class="muted">还没有入库记录。</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
// 抓取进度轮询:只刷新状态条与「正在抓」表格。
|
||||
//
|
||||
// 用 DOM API 建行而不是拼 innerHTML:品牌名来自数据库,直接拼字符串会把
|
||||
// 特殊字符当成标签解析(既会破版,也是一条注入面)。
|
||||
(function () {
|
||||
var body = document.getElementById('running-body');
|
||||
if (!body) return;
|
||||
|
||||
function cell(text, cls) {
|
||||
var td = document.createElement('td');
|
||||
td.textContent = text;
|
||||
if (cls) td.className = cls;
|
||||
return td;
|
||||
}
|
||||
|
||||
function paint(list) {
|
||||
while (body.firstChild) body.removeChild(body.firstChild);
|
||||
if (!list.length) {
|
||||
var tr0 = document.createElement('tr');
|
||||
var td0 = document.createElement('td');
|
||||
td0.colSpan = 4;
|
||||
td0.className = 'muted';
|
||||
td0.textContent = '当前没有正在抓取的任务。';
|
||||
tr0.appendChild(td0);
|
||||
body.appendChild(tr0);
|
||||
return;
|
||||
}
|
||||
list.forEach(function (it) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdBrand = document.createElement('td');
|
||||
var b = document.createElement('strong');
|
||||
b.textContent = it.brand || '—';
|
||||
tdBrand.appendChild(b);
|
||||
tr.appendChild(tdBrand);
|
||||
|
||||
tr.appendChild(cell(it.progress || '刚领取,正在拉取秀列表…', it.stale ? 'progress stale' : 'progress'));
|
||||
tr.appendChild(cell((it.beat || '0 秒') + '前', it.stale ? 'muted stale' : 'muted'));
|
||||
tr.appendChild(cell(it.worker || '—', 'muted'));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function tick() {
|
||||
fetch('/admin/spider/live', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
var st = document.getElementById('st-state');
|
||||
if (st) { st.textContent = d.state; st.className = 'badge ' + d.state_class; }
|
||||
var rn = document.getElementById('st-running');
|
||||
if (rn) rn.textContent = (d.running || []).length;
|
||||
var qn = document.getElementById('st-queued');
|
||||
if (qn) qn.textContent = d.queued;
|
||||
var pn = document.getElementById('st-pending');
|
||||
if (pn) pn.textContent = d.pending_ingest;
|
||||
paint(d.running || []);
|
||||
})
|
||||
.catch(function () { /* 网络抖动忽略,下一轮再拉 */ });
|
||||
}
|
||||
|
||||
setInterval(tick, 4000);
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@ -18,8 +18,7 @@
|
||||
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
|
||||
<a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
|
||||
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
|
||||
<a href="/admin/crawl" class="{{if eq .NavKey "crawl"}}on{{end}}">抓取任务</a>
|
||||
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
|
||||
<a href="/admin/spider" class="{{if eq .NavKey "spider"}}on{{end}}">爬虫</a>
|
||||
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
|
||||
<span class="sep"></span>
|
||||
{{template "barRight" .}}
|
||||
|
||||
Reference in New Issue
Block a user