Files
universal-crawler/static/app.js
T
hz4th_coder 909e8e01b5 新增继续爬取: auto 任务达 max_pages 上限停止后, 有待爬缓存链接时可在详情页一键继续
- POST /api/tasks/<tid>/continue: 校验 auto 模式 + pending 非空, skip_seed 启动
- engine skip_seed: 跳过起始网址直接从缓存队列消费 (不重爬 seed, 不重复入 visited)
- 关键修复: max_pages 原为累计上限(visited>=max_pages), 已达标任务无法继续; 继续模式改为按本次新增页数重新计算上限, 可反复继续直到缓存耗尽
- 详情弹窗 auto 任务且有缓存时显示 [ 继续爬取(缓存 N 条)] 按钮
- 端到端测试: max_pages=2 链路 执行(seed+p1,缓存4) → 继续(p2,p3,缓存2) → 继续(p4,p5,缓存0) → 无缓存报错 
2026-08-12 09:57:46 +08:00

1021 lines
41 KiB
JavaScript
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.
/* 通用爬虫系统 - 前端逻辑 */
"use strict";
const $ = (id) => document.getElementById(id);
const MODE_LABEL = { batch: "批量", scheduled: "定时", auto: "自动" };
const state = {
tasks: [],
editTask: null, // 正在编辑的任务
mode: "batch", // 当前表单模式
detail: { task: null, runId: null, logOffset: 0, timer: null },
logTimer: null,
};
let formDirty = false; // 新建/编辑表单是否有未保存修改
/* ---------------- 工具 ---------------- */
function toast(msg, isErr) {
const t = document.createElement("div");
t.className = "toast" + (isErr ? " error" : "");
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2600);
}
async function api(url, opts) {
const res = await fetch(url, opts);
let data = null;
try { data = await res.json(); } catch (e) { /* ignore */ }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
function fmtTime(iso) { return iso ? String(iso).replace("T", " ").slice(0, 19) : "—"; }
/* ---------------- 任务列表 ---------------- */
async function loadTasks() {
try {
state.tasks = await api("/api/tasks");
renderTasks();
const running = state.tasks.filter((t) => t.running).length;
const pill = $("statusPill");
pill.textContent = `运行中任务: ${running}`;
pill.classList.toggle("active", running > 0);
const st = await api("/api/status");
$("version").textContent = st && st.version ? `v${st.version}` : "v-";
} catch (e) {
toast("加载任务失败: " + e.message, true);
}
loadStats();
}
const STAT_IDS = ["stTasks", "stRunning", "stRuns", "stOk", "stFail", "stImgs", "stDisk"];
async function loadStats() {
// 未读到真实数据前, 一律显示 "-"
STAT_IDS.forEach((id) => { $(id).textContent = "-"; });
$("trashCount").textContent = "-";
try {
const s = await api("/api/stats");
$("stTasks").textContent = s.tasks ?? "-";
$("stRunning").textContent = s.running ?? "-";
$("stRuns").textContent = s.runs ?? "-";
$("stOk").textContent = s.ok ?? "-";
$("stFail").textContent = s.fail ?? "-";
$("stImgs").textContent = s.images ?? "-";
const mb = Number(s.disk_mb);
$("stDisk").textContent = Number.isFinite(mb) && s.disk_mb != null
? (mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb + " MB") : "-";
$("trashCount").textContent = s.trash ?? "-";
} catch (e) { /* 统计失败保持 "-" */ }
}
function taskStatusBadge(t) {
if (t.running) return '<span class="badge running">● 运行中</span>';
const r = t.latest_run;
if (r && r.status === "paused") return '<span class="badge paused">⏸ 已暂停</span>';
if (t.mode === "scheduled" && t.schedule && t.schedule.enabled) {
const next = t.schedule.next_run ? ` · 下次 ${t.schedule.next_run.slice(11)}` : "";
return `<span class="badge scheduled-wait">⏰ 等待调度${esc(next)}</span>`;
}
if (r) return `<span class="badge ${r.status}">${({ completed: "✅ 已完成", failed: "❌ 失败", stopped: "⏹ 已停止" })[r.status] || esc(r.status)}</span>`;
return '<span class="badge">未运行</span>';
}
const GROUPS = [
{ key: "batch", label: "📄 批量爬取", desc: "一次性爬取指定网址列表" },
{ key: "scheduled", label: "⏰ 定时爬取", desc: "按间隔或 cron 表达式定时执行" },
{ key: "auto", label: "🤖 自动爬取", desc: "从起始网址自动发现链接,持续递归爬取到无新链接为止" },
];
function renderTasks() {
const box = $("taskList");
$("emptyState").classList.toggle("hidden", state.tasks.length > 0);
box.innerHTML = GROUPS.map((g) => {
const items = state.tasks.filter((t) => t.mode === g.key);
if (!items.length) return "";
return `
<div class="group">
<div class="group-head">
<span class="group-title">${g.label}</span>
<span class="group-desc">${g.desc}</span>
<span class="group-count">${items.length} 个任务</span>
</div>
<div class="task-grid">${items.map(taskCard).join("")}</div>
</div>`;
}).join("");
}
function taskCard(t) {
const r = t.latest_run;
const cfg = t.config || {};
const prog = r && (r.status === "running" || r.status === "paused") ? `
<div class="progress"><div style="width:${r.progress.percent || 0}%"></div></div>
<div class="progress-label">
<span>${r.progress.done || 0} / ${r.progress.total || "?"}</span>
<span class="url-cell" style="max-width:220px">${esc(r.progress.current_url || "")}</span>
</div>` : "";
const stats = r ? `
<div class="stats">
<span class="ok">✅ ${r.stats.ok || 0}</span>
<span class="fail">❌ ${r.stats.fail || 0}</span>
<span class="img">🖼️ ${r.stats.images || 0}</span>
</div>` : "";
const urlCount = (t.urls || []).length;
const line2 = t.mode === "auto"
? `起始: <b>${esc((t.auto && t.auto.seed_url) || "")}</b>`
: `网址: <b>${urlCount}</b> 个`;
const line3 = t.mode === "scheduled"
? `调度: <b>${t.schedule.type === "cron" ? esc(t.schedule.cron) : "每 " + (t.schedule.interval_value || "-") + " " + ({ minutes: "分钟", hours: "小时", days: "天" }[t.schedule.interval_unit] || "")}</b>`
: "";
const outDir = cfg.out_dir || "out/" + t.id;
let acts = "";
if (t.running) {
acts = `
<button class="btn sm" onclick="actTask('${t.id}','pause')">⏸ 暂停</button>
<button class="btn sm" onclick="actTask('${t.id}','stop')">⏹ 终止</button>`;
} else {
const canStart = t.mode !== "scheduled" || (t.urls || []).length > 0;
acts = `<button class="btn sm primary" ${canStart ? "" : "disabled"} onclick="actTask('${t.id}','start')">▶ 开始</button>`;
}
acts += `
<button class="btn sm" onclick="openEdit('${t.id}')">✏️ 编辑</button>
<button class="btn sm" onclick="openDetail('${t.id}')">📋 详情</button>
<button class="btn sm danger" onclick="delTask('${t.id}')">🗑️</button>`;
return `
<div class="card">
<div class="card-head">
<span class="card-name" title="${esc(t.name)}">${esc(t.name)}</span>
${taskStatusBadge(t)}
</div>
<div class="card-meta">
<span class="badge ${t.mode}">${MODE_LABEL[t.mode]}</span>
<span class="card-line">${line2}</span>
</div>
${line3 ? `<div class="card-line">${line3}</div>` : ""}
<div class="card-line">输出: <b>${esc(outDir)}</b></div>
${prog}
${stats}
<div class="card-actions">${acts}</div>
</div>`;
}
/* ---------------- 任务操作 ---------------- */
async function actTask(tid, act) {
try {
const r = await api(`/api/tasks/${tid}/${act}`, { method: "POST" });
if (r.msg) toast(r.msg);
loadTasks();
if (state.detail.task && state.detail.task.id === tid) openDetail(tid);
} catch (e) { toast(e.message, true); }
}
async function delTask(tid) {
const t = state.tasks.find((x) => x.id === tid);
if (!confirm(`确定删除任务「${t ? t.name : tid}」?\n任务将移入回收站,可在回收站中恢复或彻底删除。`)) return;
try {
const r = await api(`/api/tasks/${tid}`, { method: "DELETE" });
toast(r.msg || "已删除");
loadTasks();
} catch (e) { toast(e.message, true); }
}
/* ---------------- 网址文件导入 ---------------- */
const MAX_IMPORT_SIZE = 2 * 1024 * 1024; // 2MB 上限
async function readFileSmart(file) {
/* 自动识别 UTF-8 / GBK 编码 */
const buf = await file.arrayBuffer();
let text = new TextDecoder("utf-8").decode(buf);
if (text.includes("\uFFFD")) {
try { text = new TextDecoder("gbk").decode(buf); } catch (e) { /* 保留 utf-8 结果 */ }
}
return text;
}
function importUrlsText(text, mode) {
const ta = $("taskForm").elements["urls"];
const clean = (s) => s.split(" #")[0].trim(); // 去掉行内注释 (URL 不含空格, 安全)
const existing = new Set(
mode === "overwrite" ? [] : ta.value.split(/\r?\n/).map(clean).filter(Boolean)
);
const fresh = text.split(/\r?\n/).map(clean).filter(Boolean);
let added = 0;
for (const line of fresh) {
if (!existing.has(line)) { existing.add(line); added++; }
}
ta.value = Array.from(existing).join("\n");
return { total: fresh.length, added };
}
$("btnImportUrls").onclick = () => $("urlFileInput").click();
$("urlFileInput").onchange = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = ""; // 允许重复选择同一文件
if (!file) return;
if (file.size > MAX_IMPORT_SIZE) {
toast("文件过大(上限 2MB),请拆分后导入", true);
return;
}
try {
const text = await readFileSmart(file);
const mode = $("importMode").value;
const r = importUrlsText(text, mode);
formDirty = true;
if (mode === "overwrite") {
toast(`已导入 ${file.name}:共 ${r.total} 行(覆盖原列表,新增 ${r.added} 条)`);
} else {
toast(`已导入 ${file.name}:共 ${r.total} 行,新增 ${r.added} 条网址(追加)`);
}
} catch (err) {
toast("文件读取失败: " + err.message, true);
}
};
/* ---------------- 搜索 ---------------- */
async function doSearch() {
const q = $("searchInput").value.trim();
if (!q) { toast("请输入搜索关键词", true); return; }
try {
const d = await api(`/api/search?q=${encodeURIComponent(q)}`);
renderSearch(d);
showModal("searchModal");
} catch (e) { toast(e.message, true); }
}
function renderSearch(d) {
const body = $("searchBody");
if (!d.results.length) {
body.innerHTML = `<div class="empty" style="padding:40px 0"><div class="empty-icon">🔍</div><p>没有找到与「${esc(d.q)}」相关的内容</p></div>`;
return;
}
const items = d.results.map((r) => {
const badge = `<span class="badge ${esc(r.mode)}">${MODE_LABEL[r.mode] || esc(r.mode)}</span>`;
if (r.type === "task") {
return `
<div class="search-item">
<div class="row">
${badge}
<span class="title">📋 ${esc(r.task_name)}</span>
<span class="badge">任务</span>
</div>
<div class="url">${r.seed_url ? "起始: " + esc(r.seed_url) : "网址 " + (r.urls_count || 0) + " 个"} · 创建于 ${esc(r.created_at || "")}</div>
<div class="row">
<button class="btn sm" onclick="searchOpenDetail('${r.task_id}')">📋 查看任务</button>
</div>
</div>`;
}
const stCls = r.status === "OK" ? "t-ok" : "t-fail";
const html = r.html_file ? `<span class="file-link" onclick="searchPreview('${r.task_id}','${esc(r.html_file)}','HTML: ${esc(r.url)}')">HTML</span>` : "";
const txt = r.txt_file ? `<span class="file-link" onclick="searchPreview('${r.task_id}','${esc(r.txt_file)}','文本: ${esc(r.url)}')">TXT</span>` : "";
const meta = r.meta_file ? `<span class="file-link" onclick="searchMeta('${r.task_id}','${esc(r.meta_file)}')">📋 元数据</span>` : "";
return `
<div class="search-item">
<div class="row">
${badge}
<span class="title" title="${esc(r.title)}">${esc(r.title) || "(无标题)"}</span>
<span class="badge ${stCls}">${r.status}</span>
<span class="badge">${esc(r.run_status)}</span>
<span class="card-line">${esc(r.crawl_time || "")}</span>
</div>
<div class="url">🌐 ${esc(r.url)}${r.images ? ` · 🖼️ ${r.images}` : ""}</div>
<div class="row">
${html}${txt}${meta}
<button class="btn sm" onclick="searchOpenDetail('${r.task_id}')">📋 任务详情</button>
</div>
</div>`;
}).join("");
body.innerHTML = `<div class="probe-summary"><span>关键词: <b>${esc(d.q)}</b></span><span>共 <b>${d.count}</b> 条结果</span></div>${items}`;
}
/* 搜索结果的跳转动作: 先关搜索弹窗再打开目标 */
function searchOpenDetail(tid) {
hideModal("searchModal");
openDetail(tid);
}
function searchPreview(tid, path, title) {
hideModal("searchModal");
previewFile(tid, path, title);
}
function searchMeta(tid, metaFile) {
hideModal("searchModal");
showMeta(tid, metaFile);
}
/* ---------------- 回收站 ---------------- */
async function openTrash() {
try {
const items = await api("/api/trash");
state.trashItems = items;
renderTrash(items);
showModal("trashModal");
} catch (e) { toast(e.message, true); }
}
function renderTrash(items) {
const body = $("trashBody");
if (!items.length) {
body.innerHTML = `<div class="empty" style="padding:40px 0"><div class="empty-icon">🗑️</div><p>回收站是空的</p></div>`;
return;
}
body.innerHTML = items.map((t) => `
<div class="trash-item">
<div class="card-head" style="min-width:0">
<span class="card-name" title="${esc(t.name)}">${esc(t.name)}</span>
<span class="badge ${esc(t.mode)}">${MODE_LABEL[t.mode] || esc(t.mode)}</span>
</div>
<div class="card-line">删除时间: <b>${esc(t.deleted_at)}</b> · 运行次数: <b>${t.runs_count || 0}</b></div>
<div class="card-line">输出目录: <b>${esc(t.out_dir || "")}</b></div>
<div class="card-actions">
<button class="btn sm primary" onclick="restoreTrash('${t.id}')">↩️ 恢复</button>
<button class="btn sm danger" onclick="purgeTrashTask('${t.id}')">🗑️ 彻底删除</button>
</div>
</div>`).join("")
+ `<div class="trash-foot">
<span class="card-line">共 ${items.length} 项 · 彻底删除将移除任务配置与运行记录(默认输出目录一并清理)</span>
<button class="btn danger" onclick="clearTrash()">🧹 清空回收站</button>
</div>`;
}
async function restoreTrash(tid) {
try {
const r = await api(`/api/trash/${tid}/restore`, { method: "POST" });
toast(r.msg || "已恢复");
openTrash();
loadTasks();
} catch (e) { toast(e.message, true); }
}
async function purgeTrashTask(tid) {
const t = state.trashItems && state.trashItems.find((x) => x.id === tid);
if (!confirm(`彻底删除任务「${t ? t.name : tid}」?\n任务配置与所有运行记录将永久删除,不可恢复!`)) return;
try {
const r = await api(`/api/trash/${tid}`, { method: "DELETE" });
toast(r.files_removed ? "已彻底删除(含输出文件)" : "已彻底删除(自定义输出目录已保留)");
openTrash();
loadTasks();
} catch (e) { toast(e.message, true); }
}
async function clearTrash() {
if (!confirm(`确定清空回收站?\n回收站中所有任务将永久删除,不可恢复!`)) return;
try {
const r = await api("/api/trash", { method: "DELETE" });
toast(`已清空 ${r.purged} 项`);
openTrash();
loadTasks();
} catch (e) { toast(e.message, true); }
}
/* ---------------- 新建 / 编辑表单 ---------------- */
function switchMode(mode, lock) {
state.mode = mode;
document.querySelectorAll("#modeTabs .tab").forEach((b) => {
b.classList.toggle("active", b.dataset.mode === mode);
b.disabled = !!lock;
});
$("urlsField").classList.toggle("hidden", mode !== "batch" && mode !== "scheduled");
$("scheduleBox").classList.toggle("hidden", mode !== "scheduled");
$("autoBox").classList.toggle("hidden", mode !== "auto");
}
function toLocalInputVal(iso) {
if (!iso) return "";
const d = new Date(String(iso).replace(" ", "T"));
if (isNaN(d.getTime())) return "";
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function openCreate() {
state.editTask = null;
formDirty = false;
$("modalTitle").textContent = "新建爬取任务";
$("taskForm").reset();
$("taskForm").elements["delay_min"].value = 2;
$("taskForm").elements["delay_max"].value = 5;
$("taskForm").elements["timeout"].value = 60;
$("taskForm").elements["retry_count"].value = 2;
$("taskForm").elements["retry_interval"].value = 3;
$("taskForm").elements["notify_email"].value = "wlq@tphai.com";
$("formHint").textContent = "";
switchMode("batch", false);
showModal("taskModal");
}
async function openEdit(tid) {
const t = state.tasks.find((x) => x.id === tid) || await api(`/api/tasks/${tid}`);
state.editTask = t;
$("modalTitle").textContent = `编辑任务「${t.name}」`;
const f = $("taskForm");
f.reset();
const cfg = t.config || {};
f.elements["name"].value = t.name || "";
f.elements["out_dir"].value = cfg.out_dir || "";
f.elements["delay_min"].value = cfg.delay_min ?? 2;
f.elements["delay_max"].value = cfg.delay_max ?? 5;
f.elements["timeout"].value = cfg.timeout ?? 60;
f.elements["retry_count"].value = cfg.retry_count ?? 2;
f.elements["retry_interval"].value = cfg.retry_interval ?? 3;
f.elements["crawl_images"].checked = !!cfg.crawl_images;
f.elements["notify"].checked = !!cfg.notify;
f.elements["notify_email"].value = cfg.notify_email || "wlq@tphai.com";
f.elements["urls"].value = (t.urls || []).join("\n");
if (t.mode === "scheduled" && t.schedule) {
f.elements["schedule_type"].value = t.schedule.type || "interval";
f.elements["schedule_enabled"].checked = t.schedule.enabled !== false;
f.elements["interval_value"].value = t.schedule.interval_value ?? 24;
f.elements["interval_unit"].value = t.schedule.interval_unit || "hours";
f.elements["cron"].value = t.schedule.cron || "";
f.elements["first_run"].value = toLocalInputVal(t.schedule.next_run || "");
}
if (t.mode === "auto" && t.auto) {
f.elements["seed_url"].value = t.auto.seed_url || "";
f.elements["include"].value = (t.auto.include || []).join("\n");
f.elements["exclude"].value = (t.auto.exclude || []).join("\n");
f.elements["same_domain"].checked = t.auto.same_domain !== false;
f.elements["use_regex"].checked = !!t.auto.use_regex;
f.elements["max_pages"].value = t.auto.max_pages ?? 1000;
f.elements["max_depth"].value = t.auto.max_depth ?? 0;
}
syncScheduleUI();
$("formHint").textContent = t.running
? "⚠️ 任务运行中:参数修改将热更新(网址/规则改动下次运行生效)"
: "";
formDirty = false;
switchMode(t.mode, true);
showModal("taskModal");
}
function syncScheduleUI() {
const f = $("taskForm");
const t = f.elements["schedule_type"].value;
$("intervalBox").classList.toggle("hidden", t !== "interval");
$("cronBox").classList.toggle("hidden", t !== "cron");
}
function splitLines(v) { return String(v || "").split("\n").map((s) => s.trim()).filter(Boolean); }
async function submitForm(e) {
e.preventDefault();
const f = $("taskForm");
const name = f.elements["name"].value.trim();
if (!name) { toast("请填写项目名称", true); return; }
const mode = state.mode;
const config = {
out_dir: f.elements["out_dir"].value.trim(),
delay_min: parseFloat(f.elements["delay_min"].value) || 2,
delay_max: parseFloat(f.elements["delay_max"].value) || 5,
timeout: parseInt(f.elements["timeout"].value) || 60,
retry_count: parseInt(f.elements["retry_count"].value) || 0,
retry_interval: parseFloat(f.elements["retry_interval"].value) || 3,
crawl_images: f.elements["crawl_images"].checked,
notify: f.elements["notify"].checked,
notify_email: f.elements["notify_email"].value.trim() || "wlq@tphai.com",
};
const urls = splitLines(f.elements["urls"].value);
const payload = { name, mode, config, urls };
if (mode === "auto") {
payload.auto = {
seed_url: f.elements["seed_url"].value.trim(),
include: splitLines(f.elements["include"].value),
exclude: splitLines(f.elements["exclude"].value),
same_domain: f.elements["same_domain"].checked,
use_regex: f.elements["use_regex"].checked,
max_pages: parseInt(f.elements["max_pages"].value) || 0,
max_depth: parseInt(f.elements["max_depth"].value) || 0,
};
}
if (mode === "scheduled") {
payload.schedule = {
type: f.elements["schedule_type"].value,
enabled: f.elements["schedule_enabled"].checked,
interval_unit: f.elements["interval_unit"].value,
interval_value: parseInt(f.elements["interval_value"].value) || 1,
cron: f.elements["cron"].value.trim(),
first_run: f.elements["first_run"].value || "",
};
}
try {
if (state.editTask) {
await api(`/api/tasks/${state.editTask.id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
toast("任务已更新");
} else {
const t = await api("/api/tasks", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
toast(`任务「${t.name}」已创建`);
}
formDirty = false;
hideModal("taskModal");
loadTasks();
} catch (err) { toast(err.message, true); }
}
/* ---------------- 详情 ---------------- */
const DETAIL_PAGE_SIZE = 100; // 详情结果每页条数
async function openDetail(tid) {
try {
const t = await api(`/api/tasks/${tid}?page_size=${DETAIL_PAGE_SIZE}`);
state.detail.task = t;
const runs = t.runs || [];
const cur = t.run;
state.detail.runId = cur ? cur.id : (runs[0] ? runs[0].id : null);
state.detail.logOffset = 0;
$("detailTitle").textContent = `任务详情 · ${t.name}`;
renderDetail();
showModal("detailModal");
startLogPoll();
} catch (e) { toast(e.message, true); }
}
async function loadRunPage(tid, rid, page) {
return api(`/api/tasks/${tid}?run=${rid}&page=${page}&page_size=${DETAIL_PAGE_SIZE}`);
}
async function selectRun(rid) {
const t = state.detail.task;
if (!t) return;
try {
const d = await loadRunPage(t.id, rid, 1);
state.detail.task = d;
state.detail.runId = d.run ? d.run.id : null;
state.detail.logOffset = 0;
renderDetail();
startLogPoll();
} catch (e) { toast(e.message, true); }
}
async function goRunPage(p) {
const t = state.detail.task;
if (!t || !state.detail.runId) return;
try {
const d = await loadRunPage(t.id, state.detail.runId, p);
state.detail.task = d;
renderDetail();
} catch (e) { toast(e.message, true); }
}
function renderDetail() {
const t = state.detail.task;
const runs = t.runs || [];
const run = t.run || null;
state.detail.runId = run ? run.id : (runs[0] ? runs[0].id : null);
const cfg = t.config || {};
const meta = `
<div class="detail-meta">
<span>模式: <b>${MODE_LABEL[t.mode] || t.mode}</b></span>
<span>任务ID: <b>${t.id}</b></span>
<span>创建: <b>${fmtTime(t.created_at)}</b></span>
<span>输出: <b>${esc(cfg.out_dir || "out/" + t.id)}</b></span>
${t.mode === "auto" ? `
<span>起始: <b>${esc((t.auto && t.auto.seed_url) || "")}</b></span>
<span>待爬缓存: <b>${t.auto_pending_count ?? "-"}</b> 条(停止后可继续爬取)</span>
<span>已爬: <b>${t.auto_visited_count ?? "-"}</b> 页</span>` : ""}
</div>`;
let sched = "";
if (t.mode === "scheduled" && t.schedule) {
const s = t.schedule;
sched = `<div class="detail-meta">
<span>调度: <b>${s.type === "cron" ? esc(s.cron) : "每 " + (s.interval_value || "-") + " " + ({ minutes: "分钟", hours: "小时", days: "天" }[s.interval_unit] || "")}</b></span>
<span>启用: <b>${s.enabled ? "是" : "否"}</b></span>
<span>下次: <b>${esc(s.next_run || "—")}</b></span>
<span>上次: <b>${esc(s.last_run || "—")}</b></span>
<span>已运行: <b>${s.runs_count || 0}</b> 次</span>
</div>`;
}
const acts = `
<div class="detail-actions">
${t.running ? `
<button class="btn" onclick="actTask('${t.id}','pause')">⏸ 暂停</button>
<button class="btn danger" onclick="actTask('${t.id}','stop')">⏹ 终止</button>`
: `
<button class="btn primary" onclick="actTask('${t.id}','start')">▶ 立即执行</button>
${t.mode === "auto" && (t.auto_pending_count || 0) > 0 ? `
<button class="btn" onclick="contTask('${t.id}')">⏩ 继续爬取(缓存 ${t.auto_pending_count} 条)</button>` : ""}`}
${t.mode === "auto" ? `<button class="btn" onclick="probeTask('${t.id}')">🧪 试爬取</button>
<button class="btn" ${t.running ? "disabled" : ""} onclick="clearCache('${t.id}')">🧹 清空缓存</button>` : ""}
<button class="btn" onclick="openEdit('${t.id}')">✏️ 编辑配置</button>
<button class="btn" onclick="exportZip('${t.id}')">📦 打包下载</button>
<button class="btn" onclick="exportEmail('${t.id}')">📧 发邮箱</button>
</div>`;
const chips = runs.map((r) => `
<span class="run-chip ${r.id === state.detail.runId ? "active" : ""}"
onclick="selectRun('${r.id}')">
${fmtTime(r.started_at).slice(5)} · ${r.status} · ${r.progress.done || 0}/${r.progress.total || "?"}
</span>`).join("");
const body = run ? renderRunPanel(t, run) : '<div class="card-line">暂无运行记录,点击「立即执行」开始第一次爬取。</div>';
$("detailBody").innerHTML = `
${meta}
${sched}
${acts}
<div class="runs-bar">${chips || '<span class="card-line">暂无运行记录</span>'}</div>
${body}`;
}
function renderRunPanel(t, run) {
const s = run.stats || { ok: 0, fail: 0, images: 0 };
const prog = run.status === "running" || run.status === "paused" ? `
<div class="progress"><div style="width:${run.progress.percent || 0}%"></div></div>` : "";
const results = run.results || [];
const rows = results.map((r, i) => {
const statusCls = r.status === "OK" ? "t-ok" : "t-fail";
const html = r.html_file ? `<span class="file-link" onclick="previewFile('${t.id}','${esc(r.html_file)}','HTML: ${esc(r.url)}')">HTML</span>` : "—";
const txt = r.txt_file ? `<span class="file-link" onclick="previewFile('${t.id}','${esc(r.txt_file)}','文本: ${esc(r.url)}')">TXT</span>` : "—";
const imgs = (r.images || []).length
? `<span class="file-link" onclick="scrollThumbs()">🖼️ ${(r.images || []).length}</span>` : "—";
const ctime = r.crawl_time ? `<span title="${esc(r.crawl_time)}">${esc(r.crawl_time.slice(5, 19))}</span>` : "—";
const meta = r.meta_file
? `<span class="file-link" onclick="showMeta('${t.id}','${esc(r.meta_file)}')">📋 元数据</span>` : "—";
return `<tr>
<td>${(run.results_total ? (run.run_page - 1) * DETAIL_PAGE_SIZE : 0) + i + 1}</td>
<td class="${statusCls}">${r.status}</td>
<td class="title-cell" title="${esc(r.title)}">${esc(r.title)}</td>
<td class="url-cell" title="${esc(r.url)}">${esc(r.url)}</td>
<td>${ctime}</td>
<td>${html}</td><td>${txt}</td><td>${imgs}</td>
<td>${meta}</td>
<td title="${esc(r.error || "")}">${esc((r.error || "").slice(0, 50))}</td>
</tr>`;
}).join("");
const thumbs = results.flatMap((r) => r.images || []).slice(0, 60);
const thumbHtml = thumbs.length ? `
<div id="thumbsBox" class="thumbs">
${thumbs.map((im) => `<img src="/api/file?task_id=${t.id}&path=${esc(im.file)}" loading="lazy"
onclick="previewFile('${t.id}','${esc(im.file)}','图片预览')">`).join("")}
</div>` : "";
const total = run.results_total || results.length;
const pages = run.run_pages || 1;
const page = run.run_page || 1;
const pager = total > 0 ? `
<div class="pager">
${total > results.length ? `
<button class="btn sm" ${page <= 1 ? "disabled" : ""} onclick="goRunPage(${page - 1})">◀ 上一页</button>
<span class="pager-info">第 <b>${page}</b> / ${pages} 页 · 共 ${total} 条</span>
<button class="btn sm" ${page >= pages ? "disabled" : ""} onclick="goRunPage(${page + 1})">下一页 ▶</button>`
: `<span class="pager-info">共 ${total} 条</span>`}
</div>` : "";
return `
<div class="run-panel">
<div class="run-stats">
<span>运行ID: <b>${run.id}</b></span>
<span>状态: <b class="${run.status === "running" ? "t-ok" : ""}">${run.status}</b></span>
<span>开始: <b>${fmtTime(run.started_at)}</b></span>
<span>结束: <b>${fmtTime(run.finished_at)}</b></span>
<span class="ok">✅ ${s.ok}</span>
<span class="fail">❌ ${s.fail}</span>
<span class="img">🖼️ ${s.images}</span>
</div>
${prog}
<div class="card-line">当前: <b>${esc(run.progress.current_url || "")}</b></div>
${results.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>状态</th><th>标题</th><th>网址</th><th>爬取时间</th><th>HTML</th><th>TXT</th><th>图片</th><th>元数据</th><th>错误</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
${pager}` : '<div class="card-line">暂无结果</div>'}
${thumbHtml}
<div class="logs" id="logBox"></div>
</div>`;
}
function scrollThumbs() {
const box = $("thumbsBox");
if (box) box.scrollIntoView({ behavior: "smooth", block: "center" });
}
async function startLogPoll() {
stopLogPoll();
const t = state.detail.task;
if (!t || !state.detail.runId) return;
const rid = state.detail.runId;
const tick = async () => {
try {
const d = await api(`/api/runs/${rid}/logs?offset=${state.detail.logOffset}`);
if (d.logs && d.logs.length) {
const box = $("logBox");
if (box) {
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
d.logs.forEach((l) => {
const div = document.createElement("div");
div.className = l.level;
div.textContent = `[${l.ts}] ${l.msg}`;
box.appendChild(div);
});
if (atBottom) box.scrollTop = box.scrollHeight;
state.detail.logOffset += d.logs.length;
}
}
} catch (e) { /* ignore */ }
loadTasks(); // 后台保持列表刷新
};
tick();
state.logTimer = setInterval(tick, 2500);
}
function stopLogPoll() {
if (state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; }
}
async function contTask(tid) {
try {
const r = await api(`/api/tasks/${tid}/continue`, { method: "POST" });
toast("已开始继续爬取缓存队列");
loadTasks();
if (state.detail.task && state.detail.task.id === tid) openDetail(tid);
} catch (e) { toast(e.message, true); }
}
/* ---------------- 打包导出 ---------------- */
async function exportZip(tid) {
const btn = window.event && window.event.target;
if (btn) { btn.disabled = true; btn.textContent = "⏳ 打包中..."; }
try {
const res = await fetch(`/api/export?task_id=${tid}`);
if (!res.ok) {
let d = null;
try { d = await res.json(); } catch (e) { /* ignore */ }
throw new Error((d && d.error) || `HTTP ${res.status}`);
}
let fname = "export.zip";
const disp = res.headers.get("Content-Disposition") || "";
const m = disp.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
if (m) { try { fname = decodeURIComponent(m[1]); } catch (e) { fname = m[1]; } }
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
toast(`打包下载完成 ${fname} (${(blob.size / 1048576).toFixed(1)} MB)`);
} catch (e) {
toast("打包失败: " + e.message, true);
} finally {
if (btn) { btn.disabled = false; btn.textContent = "📦 打包下载"; }
}
}
async function exportEmail(tid) {
const t = (state.detail.task && state.detail.task.id === tid)
? state.detail.task : state.tasks.find((x) => x.id === tid);
const def = (t && t.config && t.config.notify_email) || "wlq@tphai.com";
const email = prompt("发送到邮箱(留空默认 wlq@tphai.com:", def);
if (email === null) return; // 用户取消
const btn = window.event && window.event.target;
if (btn) { btn.disabled = true; btn.textContent = "⏳ 打包发送中..."; }
try {
const r = await api("/api/export/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_id: tid, email: email.trim() || "wlq@tphai.com" }),
});
toast(r.msg || "已发送");
} catch (e) {
toast("发送失败: " + e.message, true);
} finally {
if (btn) { btn.disabled = false; btn.textContent = "📧 发邮箱"; }
}
}
/* ---------------- 试爬取 ---------------- */
function collectProbeFromForm() {
const f = $("taskForm");
return {
seed_url: f.elements["seed_url"].value.trim(),
include: splitLines(f.elements["include"].value),
exclude: splitLines(f.elements["exclude"].value),
same_domain: f.elements["same_domain"].checked,
use_regex: f.elements["use_regex"].checked,
timeout: parseInt(f.elements["timeout"].value) || 60,
};
}
async function runProbe(params, btn) {
if (btn) { btn.disabled = true; btn.textContent = "⏳ 探测中..."; }
try {
const r = await api("/api/probe", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
renderProbe(r);
} catch (e) { toast(e.message, true); }
finally {
if (btn) { btn.disabled = false; btn.textContent = "🧪 试爬取"; }
}
}
async function probeTask(tid) {
try {
const r = await api(`/api/tasks/${tid}/probe`, { method: "POST" });
renderProbe(r);
} catch (e) { toast(e.message, true); }
}
async function clearCache(tid) {
if (!confirm("清空待爬缓存与已爬记录?\n下次运行将从起始网址重新开始爬取。")) return;
try {
const r = await api(`/api/tasks/${tid}/clear_cache`, { method: "POST" });
toast(r.msg || "已清空");
openDetail(tid);
} catch (e) { toast(e.message, true); }
}
function renderProbe(r) {
const body = $("probeBody");
if (!r.ok) {
body.innerHTML = `<div class="card-line">❌ ${esc(r.error || "试爬取失败")}</div>`;
showModal("probeModal");
return;
}
const included = r.included || [];
const excluded = r.excluded || [];
const incHtml = included.length
? included.map((u) => `<div class="probe-item included"><span class="u">${esc(u)}</span></div>`).join("")
: '<div class="card-line">没有符合条件的链接,请调整规则</div>';
const excHtml = excluded.length
? excluded.map((x) => `<div class="probe-item excluded"><span class="u">${esc(x.url)}</span><span class="reason">${esc(x.reason)}</span></div>`).join("")
: '<div class="card-line">无</div>';
const moreInc = r.total_included > included.length
? `<div class="card-line">... 还有 ${r.total_included - included.length} 条未显示</div>` : "";
const moreExc = r.total_excluded > excluded.length
? `<div class="card-line">... 还有 ${r.total_excluded - excluded.length} 条未显示</div>` : "";
body.innerHTML = `
<div class="probe-summary">
<span>页面标题: <b>${esc(r.title)}</b></span>
<span>抓取时间: <b>${esc(r.crawled_at)}</b></span>
</div>
<div class="probe-summary">
<span>发现链接: <b>${r.total_links}</b> 条</span>
<span class="t-ok">✅ 将爬取: <b>${r.total_included}</b> 条</span>
<span class="t-fail">🚫 被排除: <b>${r.total_excluded}</b> 条</span>
</div>
<div class="card-line" style="font-weight:600">✅ 符合规则、将爬取的链接(显示前 ${included.length} 条):</div>
<div class="probe-list">${incHtml}${moreInc}</div>
<div class="card-line" style="font-weight:600;margin-top:8px">🚫 被排除的链接及原因(显示前 ${excluded.length} 条):</div>
<div class="probe-list">${excHtml}${moreExc}</div>`;
showModal("probeModal");
}
/* ---------------- 元数据查看 ---------------- */
const META_FIELDS = [
["type", "资源类型"], ["mode", "爬取模式"], ["task_name", "任务名称"],
["task_id", "任务ID"], ["run_id", "运行ID"], ["crawl_time", "爬取时间"],
["url", "爬取网址"], ["source_url", "来源链接"], ["depth", "爬取深度"],
["title", "页面标题"], ["status", "状态"], ["attempts", "尝试次数"],
["error", "错误信息"], ["html_file", "HTML文件"], ["txt_file", "文本文件"],
["page_url", "所属页面"],
];
async function showMeta(taskId, metaFile) {
try {
const data = await api(`/api/file?task_id=${taskId}&path=${encodeURIComponent(metaFile)}`);
const rows = META_FIELDS.filter(([k]) => data[k] !== undefined && data[k] !== "" && data[k] !== null)
.map(([k, label]) => {
let v = data[k];
if (k === "status") v = `<span class="${v === "OK" ? "t-ok" : "t-fail"}">${esc(v)}</span>`;
if (k === "mode") v = `<span class="badge ${esc(v)}">${MODE_LABEL[v] || esc(v)}</span>`;
if (k === "type") v = v === "images" ? "🖼️ 图片集" : "📄 网页";
if (k === "depth") v = v === 0 ? "0(起始页)" : esc(v);
return `<tr><td>${label}</td><td>${v}</td></tr>`;
}).join("");
// 来源链接空值也显示(起始页无来源)
const srcRow = `<tr><td>来源链接</td><td>${data.source_url ? esc(data.source_url) : "—(起始页,无来源)"}</td></tr>`;
const finalRows = rows.includes(srcRow) ? rows : rows + srcRow;
let imgs = "";
if (data.images && data.images.length) {
imgs = `
<div class="card-line" style="font-weight:600;margin-top:8px">🖼️ 图片明细(${data.images.length} 张):</div>
<div class="table-wrap" style="max-height:200px">
<table><thead><tr><th>文件</th><th>原始URL</th><th>大小</th><th>下载时间</th></tr></thead><tbody>
${data.images.map((im) => `<tr>
<td>${esc(im.file)}</td>
<td class="url-cell" title="${esc(im.url)}">${esc(im.url)}</td>
<td>${im.size ? Math.round(im.size / 1024) + " KB" : "—"}</td>
<td>${esc(im.download_time || "")}</td></tr>`).join("")}
</tbody></table>
</div>`;
}
$("metaBody").innerHTML = `<table class="meta-table"><tbody>${finalRows}</tbody></table>${imgs}`;
showModal("metaModal");
} catch (e) { toast(e.message, true); }
}
/* ---------------- 文件预览 ---------------- */
function previewFile(tid, path, title) {
$("previewTitle").textContent = title || "预览";
$("previewFrame").src = `/api/file?task_id=${tid}&path=${encodeURIComponent(path)}`;
showModal("previewModal");
}
/* ---------------- 弹窗控制 ---------------- */
function showModal(id) { $(id).classList.remove("hidden"); }
function hideModal(id) { $(id).classList.add("hidden"); }
/* 关闭任务弹窗: 有未保存修改时先确认 */
function safeCloseTaskModal() {
if (formDirty && !confirm("有未保存的修改,确定要放弃吗?")) return false;
formDirty = false;
hideModal("taskModal");
return true;
}
/* ---------------- 主题切换 ---------------- */
function applyTheme(theme) {
document.body.dataset.theme = theme;
$("btnTheme").textContent = theme === "light" ? "🌙" : "☀️";
try { localStorage.setItem("crawler_theme", theme); } catch (e) { /* ignore */ }
}
$("btnTheme").onclick = () => {
applyTheme(document.body.dataset.theme === "light" ? "dark" : "light");
};
try {
applyTheme(localStorage.getItem("crawler_theme") || "dark");
} catch (e) {
applyTheme("dark");
}
/* ---------------- 事件绑定 ---------------- */
$("btnNew").onclick = openCreate;
$("btnNew2").onclick = openCreate;
$("btnRefresh").onclick = loadTasks;
$("btnTrash").onclick = openTrash;
$("btnSearch").onclick = doSearch;
$("searchInput").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
$("btnProbe").onclick = () => {
const p = collectProbeFromForm();
if (!p.seed_url) { toast("请先填写起始网址", true); return; }
runProbe(p, $("btnProbe"));
};
document.querySelectorAll("#modeTabs .tab").forEach((b) => {
b.onclick = () => { switchMode(b.dataset.mode, false); };
});
$("taskForm").onsubmit = submitForm;
$("taskForm").elements["schedule_type"].onchange = syncScheduleUI;
document.querySelectorAll("[data-close]").forEach((b) => b.onclick = safeCloseTaskModal);
document.querySelectorAll("[data-close-detail]").forEach((b) => b.onclick = () => { stopLogPoll(); hideModal("detailModal"); });
document.querySelectorAll("[data-close-preview]").forEach((b) => b.onclick = () => { $("previewFrame").src = "about:blank"; hideModal("previewModal"); });
document.querySelectorAll("[data-close-probe]").forEach((b) => b.onclick = () => hideModal("probeModal"));
document.querySelectorAll("[data-close-meta]").forEach((b) => b.onclick = () => hideModal("metaModal"));
document.querySelectorAll("[data-close-trash]").forEach((b) => b.onclick = () => hideModal("trashModal"));
document.querySelectorAll("[data-close-search]").forEach((b) => b.onclick = () => hideModal("searchModal"));
/* 表单改动监听 -> 脏标记 */
$("taskForm").addEventListener("input", () => { formDirty = true; });
$("taskForm").addEventListener("change", () => { formDirty = true; });
document.querySelectorAll(".modal-overlay").forEach((ov) => {
ov.addEventListener("mousedown", (e) => {
if (e.target === ov) {
if (ov.id === "taskModal") {
safeCloseTaskModal();
} else {
if (ov.id === "detailModal") stopLogPoll();
if (ov.id === "previewModal") $("previewFrame").src = "about:blank";
ov.classList.add("hidden");
}
}
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("taskModal").classList.contains("hidden")) {
safeCloseTaskModal();
return;
}
stopLogPoll();
$("previewFrame").src = "about:blank";
["detailModal", "probeModal", "metaModal", "trashModal", "searchModal", "previewModal"].forEach((id) => $(id).classList.add("hidden"));
}
});
/* ---------------- 启动 ---------------- */
loadTasks();
setInterval(() => {
const detailOpen = !$("detailModal").classList.contains("hidden");
if (!detailOpen) loadTasks();
}, 4000);