一、防重复下载 图片 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 分钟没更新标黄「可能卡住」 - 渲染测试守住进度列与轮询锚点
84 lines
3.8 KiB
HTML
84 lines
3.8 KiB
HTML
{{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}}
|