Compare commits

..
4 Commits
7 changed files with 882 additions and 56 deletions
+17 -1
View File
@@ -23,6 +23,8 @@
### 1. 前端管理界面
- **总体统计区**:任务总数 / 运行中 / 累计运行次数 / 成功失败页面 / 图片数 / 磁盘占用
- **🔍 基本搜索**:顶部搜索框,按网址 / 标题 / 任务名即时查找已爬内容,可直接预览文件、查看元数据、跳转任务详情
- **🗑️ 回收站**:删除的任务统一进回收站(可恢复);只有手动在回收站中才能彻底删除或清空,防止误删
- **日间/夜间双主题**:右上角按钮一键切换,自动记忆选择
- 任务卡片总览:状态、进度、统计、下次调度时间一目了然
- 一键操作:开始 / 暂停 / 恢复 / 终止 / 编辑 / 删除
@@ -31,7 +33,7 @@
- 运行中的任务参数支持**热更新**(修改后从下一页起生效)
### 2. 批量爬取模式
一次粘贴多个网址(每行一个,`#` 注释),可配置
一次粘贴多个网址(每行一个,`#` 注释),或点击「📂 导入网址文件」上传 .txt 文件批量导入(自动识别 UTF-8/GBK 编码、自动去重、自动清理行内注释)
- 项目名称、输出目录(默认 `out/<任务ID>`,可填绝对路径)
- 爬取间隔(随机秒数区间,防封 IP)、单页超时
- 失败重试次数 / 重试间隔
@@ -45,12 +47,19 @@
### 4. 自动爬取模式
给定一个起始网址,系统自动从页面里发现链接、按规则筛选后 BFS 爬取:
- **🧪 试爬取**:先用起始网址试跑一次,展示规则筛选后的链接清单(将爬取哪些、排除哪些及原因),确认规则符合预期后再正式开爬
- **包含规则**:只爬包含指定子串(或正则)的链接
- **排除规则**:跳过匹配的链接(如 login、/tag/
- **仅同域名**:限制在起始网站内
- **最大页数 / 最大深度**:控制爬取规模
- 其余参数(间隔、重试、图片、通知)同批量模式
### 5. 资源操作信息(元数据)
每个爬取的网页和图片都自动生成 `.meta.json` 操作信息文件:
- **网页**`<文件名>.meta.json`):爬取模式(批量/定时/自动)、爬取时间、爬取网址、**来源链接**(自动模式下从哪个页面发现)、爬取深度、任务/运行 ID、页面标题、状态、尝试次数、文件列表、图片明细
- **图片集**`<文件名>_img/meta.json`):所属页面、来源链接、每张图片的原始 URL / 大小 / 下载时间
- 详情页结果表中点「📋 元数据」即可在线查看
## 输出文件
每个任务输出到独立目录(默认 `out/<任务ID>/`):
@@ -69,6 +78,13 @@
| POST | `/api/tasks/<id>/pause` | 暂停 |
| POST | `/api/tasks/<id>/resume` | 恢复 |
| POST | `/api/tasks/<id>/stop` | 终止 |
| GET | `/api/trash` | 回收站列表 |
| GET | `/api/search?q=` | 基本搜索(网址/标题/任务名子串匹配,轻量) |
| POST | `/api/trash/<id>/restore` | 从回收站恢复任务 |
| DELETE | `/api/trash/<id>` | 彻底删除单个(默认输出目录一并清理) |
| DELETE | `/api/trash` | 清空回收站 |
| POST | `/api/probe` | 试爬取(表单规则预览链接清单) |
| POST | `/api/tasks/<id>/probe` | 对已保存的自动任务试爬取 |
| GET | `/api/runs/<rid>` | 运行详情(结果+日志) |
| GET | `/api/runs/<rid>/logs?offset=N` | 增量日志 |
| GET | `/api/file?task_id=&path=` | 读取输出文件(HTML/TXT/图片) |
+189 -7
View File
@@ -10,7 +10,7 @@ from datetime import datetime
from flask import Flask, jsonify, request, send_file, send_from_directory
import store
from engine import CrawlJob
from engine import CrawlJob, probe_links
from scheduler import Scheduler, cron_next, interval_delta
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -143,7 +143,7 @@ def api_status():
@app.route("/api/tasks", methods=["GET"])
def api_tasks():
tasks = store.load_tasks()
tasks = [t for t in store.load_tasks() if not t.get("deleted_at")]
for t in tasks:
runs = store.get_runs(t["id"])
t["latest_run"] = runs[-1] if runs else None
@@ -154,6 +154,19 @@ def api_tasks():
return jsonify(tasks)
def _clean_urls(lines):
"""清洗网址行: 去空白, 去行内 # 注释 (URL 本身不含空格, 安全), 自动补 https 前缀"""
out = []
for u in lines or []:
u = str(u).split(" #")[0].strip()
if not u:
continue
if not u.startswith("http"):
u = "https://" + u
out.append(u)
return out
@app.route("/api/tasks", methods=["POST"])
def api_create_task():
body = request.get_json(force=True) or {}
@@ -171,7 +184,7 @@ def api_create_task():
"created_at": now_str(),
"updated_at": now_str(),
"config": {**DEFAULT_CONFIG, **(body.get("config") or {})},
"urls": [u.strip() for u in (body.get("urls") or []) if u.strip()],
"urls": _clean_urls(body.get("urls") or []),
}
if mode == "auto":
@@ -221,6 +234,8 @@ def api_update_task(tid):
task = store.get_task(tid)
if not task:
return jsonify({"error": "任务不存在"}), 404
if task.get("deleted_at"):
return jsonify({"error": "任务在回收站中,请先恢复"}), 400
body = request.get_json(force=True) or {}
with JOBS_LOCK:
job = JOBS.get(tid)
@@ -228,7 +243,7 @@ def api_update_task(tid):
if "name" in body and str(body["name"]).strip():
task["name"] = str(body["name"]).strip()
if "urls" in body:
task["urls"] = [u.strip() for u in body["urls"] if u.strip()]
task["urls"] = _clean_urls(body["urls"])
if "config" in body:
merged = {**task.get("config", {}), **body["config"]}
task["config"] = merged
@@ -250,11 +265,71 @@ def api_update_task(tid):
@app.route("/api/tasks/<tid>", methods=["DELETE"])
def api_delete_task(tid):
"""删除任务 -> 移入回收站 (可恢复)"""
task = store.get_task(tid)
if not task:
return jsonify({"error": "任务不存在"}), 404
if task.get("deleted_at"):
return jsonify({"error": "任务已在回收站中"}), 400
_stop_job(tid)
with JOBS_LOCK:
JOBS.pop(tid, None)
store.delete_task(tid)
return jsonify({"ok": True})
store.soft_delete_task(tid, now_str())
return jsonify({"ok": True, "msg": "已移入回收站"})
# ---------------- API: 回收站 ----------------
def _purge_out_dir(out_dir):
"""删除任务输出目录; 仅当目录位于项目 out/ 下才删 (自定义目录保留), 返回是否删除"""
try:
d = os.path.realpath(out_dir)
base = os.path.realpath(os.path.join(HERE, "out"))
if d.startswith(base + os.sep) and os.path.isdir(d):
import shutil
shutil.rmtree(d, ignore_errors=True)
return True
except Exception:
pass
return False
@app.route("/api/trash", methods=["GET"])
def api_trash_list():
items = store.list_trash()
for t in items:
t["runs_count"] = len(store.get_runs(t["id"]))
t["out_dir"] = resolve_out_dir(t)
return jsonify(items)
@app.route("/api/trash/<tid>/restore", methods=["POST"])
def api_trash_restore(tid):
task = store.get_task(tid)
if not task or not task.get("deleted_at"):
return jsonify({"error": "任务不在回收站中"}), 404
store.restore_task(tid)
return jsonify({"ok": True, "msg": "已恢复"})
@app.route("/api/trash/<tid>", methods=["DELETE"])
def api_trash_purge(tid):
task = store.get_task(tid)
if not task or not task.get("deleted_at"):
return jsonify({"error": "任务不在回收站中"}), 404
out_dir = resolve_out_dir(task)
removed = _purge_out_dir(out_dir)
store.purge_task(tid)
return jsonify({"ok": True, "purged": True, "files_removed": removed, "out_dir": out_dir})
@app.route("/api/trash", methods=["DELETE"])
def api_trash_clear():
items = store.list_trash()
dirs = [resolve_out_dir(t) for t in items]
store.purge_trash()
removed = sum(1 for d in dirs if _purge_out_dir(d))
return jsonify({"ok": True, "purged": len(items), "dirs_removed": removed})
# ---------------- API: 运行控制 ----------------
@@ -301,7 +376,8 @@ def api_resume(tid):
@app.route("/api/stats")
def api_stats():
tasks = store.load_tasks()
tasks = [t for t in store.load_tasks() if not t.get("deleted_at")]
trash_count = sum(1 for t in store.load_tasks() if t.get("deleted_at"))
total_runs = ok = fail = imgs = 0
for t in tasks:
for r in store.get_runs(t["id"]):
@@ -334,9 +410,115 @@ def api_stats():
"fail": fail,
"images": imgs,
"disk_mb": round(size / 1048576, 1),
"trash": trash_count,
})
# ---------------- API: 试爬取 ----------------
@app.route("/api/probe", methods=["POST"])
def api_probe():
"""试爬取: 按表单给出的规则探测起始页, 返回将爬取的链接清单"""
body = request.get_json(force=True) or {}
seed = (body.get("seed_url") or "").strip()
if not seed:
return jsonify({"error": "请填写起始网址"}), 400
if not seed.startswith("http"):
seed = "https://" + seed
result = probe_links(
seed,
include=[x.strip() for x in (body.get("include") or []) if x.strip()],
exclude=[x.strip() for x in (body.get("exclude") or []) if x.strip()],
same_domain=body.get("same_domain", True),
use_regex=bool(body.get("use_regex", False)),
timeout=int(body.get("timeout") or 45),
)
return jsonify(result)
@app.route("/api/tasks/<tid>/probe", methods=["POST"])
def api_task_probe(tid):
"""对已保存的自动任务执行试爬取 (使用保存的规则)"""
task = store.get_task(tid)
if not task:
return jsonify({"error": "任务不存在"}), 404
if task.get("mode") != "auto":
return jsonify({"error": "仅自动爬取任务支持试爬取"}), 400
auto = task.get("auto", {})
result = probe_links(
auto.get("seed_url", ""),
include=auto.get("include", []),
exclude=auto.get("exclude", []),
same_domain=auto.get("same_domain", True),
use_regex=bool(auto.get("use_regex", False)),
timeout=int((task.get("config") or {}).get("timeout", 60)),
)
return jsonify(result)
# ---------------- API: 搜索 ----------------
@app.route("/api/search")
def api_search():
"""基本搜索: 对任务名/网址/标题做子串匹配 (仅遍历本地内存数据, 轻量)"""
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"q": q, "count": 0, "results": []})
ql = q.lower()
results = []
seen = set()
for task in store.load_tasks():
if task.get("deleted_at"):
continue # 回收站任务不参与搜索
auto = task.get("auto") or {}
task_hit = (
ql in (task.get("name") or "").lower()
or any(ql in u.lower() for u in task.get("urls", []))
or ql in ((auto.get("seed_url") or "").lower())
)
for run in store.get_runs(task["id"]):
for r in run.get("results", []):
url = r.get("url") or ""
title = r.get("title") or ""
if ql not in url.lower() and ql not in title.lower():
continue
key = (task["id"], run.get("id"), url)
if key in seen:
continue
seen.add(key)
results.append({
"type": "page",
"task_id": task["id"],
"task_name": task.get("name", ""),
"mode": task.get("mode", ""),
"run_id": run.get("id", ""),
"run_status": run.get("status", ""),
"url": url,
"title": title,
"status": r.get("status", ""),
"crawl_time": r.get("crawl_time", ""),
"html_file": r.get("html_file", ""),
"txt_file": r.get("txt_file", ""),
"meta_file": r.get("meta_file", ""),
"images": len(r.get("images", []) or []),
})
if len(results) >= 100:
break
if len(results) >= 100:
break
if task_hit and len(results) < 100:
results.append({
"type": "task",
"task_id": task["id"],
"task_name": task.get("name", ""),
"mode": task.get("mode", ""),
"urls_count": len(task.get("urls", [])),
"created_at": task.get("created_at", ""),
"seed_url": auto.get("seed_url", ""),
})
return jsonify({"q": q, "count": len(results), "results": results})
# ---------------- API: 运行记录与文件 ----------------
@app.route("/api/runs/<rid>")
+193 -40
View File
@@ -3,6 +3,8 @@
通用爬虫引擎 (Playwright + stealth + 系统 Chrome)
- 批量模式: 逐条爬取网址列表
- 自动模式: 从起始网址按匹配规则自动发现链接并 BFS 爬取
- 试爬取: 仅抓取起始页, 列出按规则将爬取的链接(不保存文件)
- 每个页面/图片生成 .meta.json 操作信息(模式/时间/网址/来源链接/深度等)
- 支持: 随机延迟 / 重试 / 图片下载 / 暂停恢复 / 终止 / 配置热更新 / cookie 复用
"""
import json
@@ -40,7 +42,6 @@ def is_challenge_page(title, html):
for mark in _CHALLENGE_MARKS:
if mark in t or mark in low:
return True
# 极小页面 + 无正文结构 -> 疑似验证壳
if len(html) < 5000 and ("<article" not in low and "<main" not in low):
return True
return False
@@ -52,6 +53,122 @@ def safe_name(url, idx):
return f"{idx:04d}_{host}_{ts}"
def _settle_wait(page, timeout_s):
"""等待页面稳定(无 stop/pause 检查, 供试爬取等独立流程使用)"""
last_title, stable = "", 0
start = time.time()
while time.time() - start < timeout_s:
time.sleep(1)
try:
title = page.title()
html = page.content()
except Exception:
continue
if is_challenge_page(title, html):
stable = 0
continue
if title == last_title:
stable += 1
if stable >= 2 and len(html) > 1000:
return True, title, html
else:
stable = 0
last_title = title
return True, page.title(), page.content()
def filter_links(hrefs, seed_url, include=None, exclude=None,
same_domain=True, use_regex=False):
"""按规则过滤链接, 返回 (included, excluded); excluded 含排除原因"""
include = include or []
exclude = exclude or []
included, excluded = [], []
seed_host = urllib.parse.urlparse(seed_url).hostname or ""
for h in hrefs:
if not str(h).startswith("http"):
continue
if same_domain and seed_host:
host = urllib.parse.urlparse(h).hostname or ""
if host != seed_host and not host.endswith("." + seed_host):
excluded.append({"url": h, "reason": "不在同域名内"})
continue
if use_regex:
if include and not any(re.search(p, h) for p in include):
excluded.append({"url": h, "reason": "未匹配包含规则"})
continue
hit = next((p for p in exclude if re.search(p, h)), None)
if hit:
excluded.append({"url": h, "reason": f"命中排除规则: {hit}"})
continue
else:
if include and not any(p.lower() in h.lower() for p in include):
excluded.append({"url": h, "reason": "未匹配包含规则"})
continue
hit = next((p for p in exclude if p.lower() in h.lower()), None)
if hit:
excluded.append({"url": h, "reason": f"命中排除规则: {hit}"})
continue
included.append(h)
return included, excluded
def probe_links(seed_url, include=None, exclude=None,
same_domain=True, use_regex=False, timeout=45):
"""试爬取: 抓取起始页并列出按规则将爬取的链接 (不保存任何文件)"""
result = {"ok": False, "error": "", "seed_url": seed_url, "title": "",
"crawled_at": "", "total_links": 0,
"total_included": 0, "total_excluded": 0,
"included": [], "excluded": []}
try:
p = sync_playwright().start()
browser = p.chromium.launch(
headless=True, executable_path=CHROME,
args=["--disable-blink-features=AutomationControlled",
"--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
)
ctx = browser.new_context(
user_agent=DEFAULT_UA,
viewport={"width": 1920, "height": 1080},
locale="en-US",
)
Stealth().apply_stealth_sync(ctx)
page = ctx.new_page()
try:
page.goto(seed_url, wait_until="domcontentloaded", timeout=timeout * 1000)
_ok, title, html = _settle_wait(page, timeout)
if is_challenge_page(title, html):
result["error"] = f"起始页被反爬拦截: title={title!r}"
else:
try:
hrefs = page.evaluate(
"() => Array.from(document.querySelectorAll('a[href]')).map(a => a.href)"
)
except Exception:
hrefs = []
included, excluded = filter_links(
hrefs, seed_url, include, exclude, same_domain, use_regex)
result.update(
ok=True, title=title, crawled_at=store.now_str(),
total_links=len(hrefs),
total_included=len(included), total_excluded=len(excluded),
included=included[:200], excluded=excluded[:200],
)
except Exception as e:
result["error"] = f"试爬取失败: {e}"
finally:
try:
browser.close()
except Exception:
pass
try:
p.stop()
except Exception:
pass
except Exception as e:
result["error"] = f"浏览器启动失败: {e}"
return result
class CrawlJob:
"""一次爬取执行 (独立线程运行)"""
@@ -195,8 +312,8 @@ class CrawlJob:
text = ""
return title, html, text
def _crawl_images(self, ctx, page, out_dir, base):
"""下载页面图片, 返回 [{file, url, size}]"""
def _crawl_images(self, ctx, page, out_dir, base, page_url, source_url):
"""下载页面图片并生成图片集 meta.json, 返回 [{file,url,size,download_time}]"""
try:
urls = page.evaluate(
"() => Array.from(document.querySelectorAll('img'))"
@@ -222,26 +339,78 @@ class CrawlJob:
os.makedirs(img_dir, exist_ok=True)
with open(os.path.join(img_dir, fname), "wb") as f:
f.write(resp.body())
saved.append({"file": f"{base}_img/{fname}", "url": u, "size": len(resp.body())})
saved.append({
"file": f"{base}_img/{fname}", "url": u,
"size": len(resp.body()), "download_time": store.now_str(),
})
except Exception:
continue
if saved:
try:
meta = {
"type": "images",
"mode": self.run.get("mode"),
"task_id": self.task["id"],
"task_name": self.task.get("name", ""),
"run_id": self.run.get("id"),
"crawl_time": store.now_str(),
"page_url": page_url,
"source_url": source_url,
"images": saved,
}
with open(os.path.join(img_dir, "meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
except Exception:
pass
return saved
def _retry_crawl(self, page, ctx, url, idx, out_dir):
"""带重试的单页爬取, 返回结果 entry"""
def _write_page_meta(self, out_dir, base, entry):
"""为每个爬取页面生成操作信息 meta.json"""
meta = {
"type": "page",
"mode": self.run.get("mode"),
"task_id": self.task["id"],
"task_name": self.task.get("name", ""),
"run_id": self.run.get("id"),
"crawl_time": entry.get("crawl_time", ""),
"url": entry.get("url", ""),
"source_url": entry.get("source_url", ""),
"depth": entry.get("depth"),
"title": entry.get("title", ""),
"status": entry.get("status", ""),
"error": entry.get("error", ""),
"attempts": entry.get("attempts", 1),
"html_file": entry.get("html_file", ""),
"txt_file": entry.get("txt_file", ""),
"images": entry.get("images", []),
}
try:
with open(os.path.join(out_dir, base + ".meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _retry_crawl(self, page, ctx, url, idx, out_dir, source_url="", depth=None):
"""带重试的单页爬取, 返回结果 entry (含 meta 信息)"""
timeout = int(self._cfg("timeout", 60))
retries = int(self._cfg("retry_count", 2))
retry_wait = float(self._cfg("retry_interval", 3))
crawl_images = bool(self._cfg("crawl_images", False))
entry = {"url": url, "title": "", "status": "FAIL", "error": "",
"html_file": "", "txt_file": "", "images": []}
base = safe_name(url, idx)
entry = {
"url": url, "title": "", "status": "FAIL", "error": "",
"html_file": "", "txt_file": "", "meta_file": base + ".meta.json",
"crawl_time": store.now_str(),
"source_url": source_url, "depth": depth,
"images": [], "attempts": 0,
}
for attempt in range(retries + 1):
if self._stop.is_set():
entry["error"] = "任务已终止"
break
self._wait_if_paused()
entry["attempts"] += 1
try:
title, html, text = self._crawl_one(page, url, timeout)
html_path = os.path.join(out_dir, base + ".html")
@@ -251,13 +420,15 @@ class CrawlJob:
with open(txt_path, "w", encoding="utf-8") as f:
f.write(text)
entry.update(title=title, status="OK",
html_file=base + ".html", txt_file=base + ".txt")
html_file=base + ".html", txt_file=base + ".txt",
error="", crawl_time=store.now_str())
if crawl_images:
entry["images"] = self._crawl_images(ctx, page, out_dir, base)
entry["images"] = self._crawl_images(ctx, page, out_dir, base, url, source_url)
self._log("info", f"OK {title[:50]!r} html={len(html)//1024}KB 图片={len(entry['images'])}")
break
except Exception as e:
entry["error"] = str(e)
entry["crawl_time"] = store.now_str()
self._log("warn", f"{attempt + 1}次失败 {url}: {e}")
if attempt < retries:
self._wait_if_paused()
@@ -267,6 +438,7 @@ class CrawlJob:
break
self._wait_if_paused()
time.sleep(0.3)
self._write_page_meta(out_dir, base, entry)
return entry
def _delay(self):
@@ -317,7 +489,6 @@ class CrawlJob:
self._log("error", f"邮件通知失败: {msg}")
except Exception as e:
self._log("error", f"邮件通知异常: {e}")
self._persist()
self._log("info", f"任务结束: {run['status']} 成功{run['stats'].get('ok', 0)} 失败{run['stats'].get('fail', 0)}")
self._persist()
@@ -353,37 +524,18 @@ class CrawlJob:
def _discover_links(self, page):
"""从当前页面提取符合规则的链接"""
auto = self.task.get("auto", {})
include = [x.strip() for x in (auto.get("include") or []) if x.strip()]
exclude = [x.strip() for x in (auto.get("exclude") or []) if x.strip()]
use_regex = bool(auto.get("use_regex", False))
same_domain = auto.get("same_domain", True)
seed_host = urllib.parse.urlparse(auto.get("seed_url", "")).hostname or ""
try:
hrefs = page.evaluate(
"() => Array.from(document.querySelectorAll('a[href]')).map(a => a.href)"
)
except Exception:
return []
out = []
for h in hrefs:
if not str(h).startswith("http"):
continue
if same_domain and seed_host:
host = urllib.parse.urlparse(h).hostname or ""
if host != seed_host and not host.endswith("." + seed_host):
continue
if use_regex:
if include and not any(re.search(p, h) for p in include):
continue
if any(re.search(p, h) for p in exclude):
continue
else:
if include and not any(p.lower() in h.lower() for p in include):
continue
if any(p.lower() in h.lower() for p in exclude):
continue
out.append(h)
return out
included, _excluded = filter_links(
hrefs, auto.get("seed_url", ""),
auto.get("include", []), auto.get("exclude", []),
auto.get("same_domain", True), bool(auto.get("use_regex", False)),
)
return included
def _crawl_auto(self):
run = self.run
@@ -398,14 +550,14 @@ class CrawlJob:
self._persist()
p, browser, ctx, page, cookie_file = self._open_browser()
queue = [(seed, 0)]
queue = [(seed, 0, "")] # (url, depth, 来源链接)
visited = set()
queued = set([seed])
idx = 0
try:
while queue and not self._stop.is_set():
self._wait_if_paused()
url, depth = queue.pop(0)
url, depth, src = queue.pop(0)
if url in visited:
continue
if len(visited) >= max_pages:
@@ -415,7 +567,8 @@ class CrawlJob:
run["progress"]["current_url"] = url
run["progress"]["done"] = len(visited)
self._persist()
entry = self._retry_crawl(page, ctx, url, idx, out_dir)
entry = self._retry_crawl(page, ctx, url, idx, out_dir,
source_url=src, depth=depth)
run["results"].append(entry)
self._bump_stats(entry)
self._persist()
@@ -423,7 +576,7 @@ class CrawlJob:
for link in self._discover_links(page):
if link not in visited and link not in queued:
queued.add(link)
queue.append((link, depth + 1))
queue.append((link, depth + 1, url))
if entry["status"] == "OK":
self._delay()
finally:
+319 -6
View File
@@ -63,6 +63,7 @@ async function loadStats() {
$("stImgs").textContent = s.images;
$("stDisk").textContent = s.disk_mb >= 1024
? (s.disk_mb / 1024).toFixed(1) + " GB" : s.disk_mb + " MB";
$("trashCount").textContent = s.trash || 0;
} catch (e) { /* 统计失败忽略 */ }
}
@@ -152,10 +153,190 @@ async function actTask(tid, act) {
async function delTask(tid) {
const t = state.tasks.find((x) => x.id === tid);
if (!confirm(`确定删除任务「${t ? t.name : tid}」?\n运行记录与任务配置将被删除(已爬取的文件保留在磁盘上)`)) return;
if (!confirm(`确定删除任务「${t ? t.name : tid}」?\n任务将移入回收站,可在回收站中恢复或彻底删除`)) return;
try {
await api(`/api/tasks/${tid}`, { method: "DELETE" });
toast("已删除");
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) {
const ta = $("taskForm").elements["urls"];
const clean = (s) => s.split(" #")[0].trim(); // 去掉行内注释 (URL 不含空格, 安全)
const existing = new Set(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 r = importUrlsText(text);
formDirty = true;
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); }
}
@@ -359,6 +540,7 @@ function renderDetail() {
<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" ? `<button class="btn" onclick="probeTask('${t.id}')">🧪 试爬取</button>` : ""}
<button class="btn" onclick="openEdit('${t.id}')">✏️ 编辑配置</button>
</div>`;
@@ -389,13 +571,18 @@ function renderRunPanel(t, run) {
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>${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 title="${esc(r.error || "")}">${esc((r.error || "").slice(0, 60))}</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);
@@ -421,7 +608,7 @@ function renderRunPanel(t, run) {
${results.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>状态</th><th>标题</th><th>网址</th><th>HTML</th><th>TXT</th><th>图片</th><th>错误</th></tr></thead>
<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>` : '<div class="card-line">暂无结果</div>'}
@@ -475,6 +662,120 @@ function stopLogPoll() {
if (state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; }
}
/* ---------------- 试爬取 ---------------- */
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); }
}
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 || "预览";
@@ -513,6 +814,14 @@ try {
$("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); };
@@ -523,6 +832,10 @@ $("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; });
@@ -549,7 +862,7 @@ document.addEventListener("keydown", (e) => {
}
stopLogPoll();
$("previewFrame").src = "about:blank";
["detailModal", "previewModal"].forEach((id) => $(id).classList.add("hidden"));
["detailModal", "probeModal", "metaModal", "trashModal", "searchModal", "previewModal"].forEach((id) => $(id).classList.add("hidden"));
}
});
+61 -2
View File
@@ -10,8 +10,13 @@
<header>
<div class="logo">🕷️ 通用爬虫系统 <span id="version" class="version"></span></div>
<div class="header-right">
<div class="search-box">
<input id="searchInput" placeholder="搜索网址 / 标题 / 任务名..." maxlength="100">
<button id="btnSearch" class="btn sm primary" title="搜索">🔍</button>
</div>
<span class="status-pill" id="statusPill">运行中任务: 0</span>
<button id="btnTheme" class="btn ghost" title="切换日间/夜间主题">☀️</button>
<button id="btnTrash" class="btn ghost" title="回收站">🗑️ 回收站 <span id="trashCount" class="trash-count">0</span></button>
<button id="btnRefresh" class="btn ghost">⟳ 刷新</button>
<button id="btnNew" class="btn primary"> 新建任务</button>
</div>
@@ -68,7 +73,13 @@
<div class="field"><label>通知邮箱</label><input name="notify_email" value="wlq@tphai.com"></div>
</div>
<div class="field" id="urlsField"><label>网址列表(每行一个,# 开头为注释)</label>
<div class="field" id="urlsField">
<label>网址列表(每行一个,# 开头为注释)</label>
<div class="row2" style="align-items:center">
<button type="button" class="btn sm" id="btnImportUrls">📂 导入网址文件(.txt)</button>
<span class="card-line">支持 UTF-8 / GBK 编码,自动去重追加</span>
<input type="file" id="urlFileInput" accept=".txt,.csv,.urls,text/plain" hidden>
</div>
<textarea name="urls" rows="5" placeholder="https://www.example.com/&#10;https://www.example.com/page2"></textarea>
</div>
@@ -102,7 +113,11 @@
<div id="autoBox" class="hidden box">
<div class="field"><label>起始网址 *(系统将自动发现符合规则的链接并爬取)</label>
<input name="seed_url" placeholder="https://example.com/news"></div>
<div class="row2">
<input name="seed_url" placeholder="https://example.com/news" style="flex:1">
<button type="button" class="btn sm" id="btnProbe">🧪 试爬取</button>
</div>
</div>
<div class="row2">
<div class="field"><label>包含规则(每行一个,子串或正则)</label>
<textarea name="include" rows="3" placeholder="techpowerup.com/review&#10;/news/"></textarea></div>
@@ -139,6 +154,50 @@
</div>
</div>
<!-- 搜索结果弹窗 -->
<div id="searchModal" class="modal-overlay hidden">
<div class="modal wide">
<div class="modal-head">
<span>🔍 搜索结果</span>
<button class="btn ghost sm" data-close-search></button>
</div>
<div id="searchBody" class="detail-wrap"></div>
</div>
</div>
<!-- 回收站弹窗 -->
<div id="trashModal" class="modal-overlay hidden">
<div class="modal wide">
<div class="modal-head">
<span>🗑️ 回收站(删除的任务统一在这里,可恢复或彻底删除)</span>
<button class="btn ghost sm" data-close-trash></button>
</div>
<div id="trashBody" class="detail-wrap"></div>
</div>
</div>
<!-- 试爬取结果弹窗 -->
<div id="probeModal" class="modal-overlay hidden">
<div class="modal wide">
<div class="modal-head">
<span>🧪 试爬取结果(规则筛选预览)</span>
<button class="btn ghost sm" data-close-probe></button>
</div>
<div id="probeBody" class="detail-wrap"></div>
</div>
</div>
<!-- 元数据弹窗 -->
<div id="metaModal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-head">
<span>📋 资源操作信息(元数据)</span>
<button class="btn ghost sm" data-close-meta></button>
</div>
<div id="metaBody" class="detail-wrap"></div>
</div>
</div>
<!-- 文件预览弹窗 -->
<div id="previewModal" class="modal-overlay hidden">
<div class="modal wide tall">
+39
View File
@@ -215,6 +215,45 @@ td.title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
/* ---------- preview ---------- */
#previewFrame { flex: 1; border: none; background: var(--preview-bg); border-radius: 0 0 14px 14px; }
/* ---------------- 回收站 ---------------- */
.search-box { display: flex; gap: 6px; align-items: center; }
.search-box input { width: 250px; }
.search-item {
border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
display: flex; flex-direction: column; gap: 5px; background: var(--panel2);
}
.search-item .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.search-item .title { font-weight: 600; max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-item .url { color: var(--muted); word-break: break-all; font-size: 12px; }
.trash-count {
display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px;
background: var(--red); color: #fff; font-size: 11px; line-height: 18px;
text-align: center; margin-left: 2px;
}
.trash-item {
border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px;
display: flex; flex-direction: column; gap: 6px; background: var(--panel2);
}
.trash-foot {
display: flex; justify-content: space-between; align-items: center;
gap: 10px; padding-top: 8px; border-top: 1px solid var(--border); margin-top: 4px;
}
/* ---------- 试爬取结果 / 元数据 ---------- */
.probe-summary { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; }
.probe-list {
border: 1px solid var(--border); border-radius: 8px; max-height: 260px;
overflow: auto; padding: 8px 12px; display: flex; flex-direction: column;
gap: 5px; font-size: 12px; background: var(--log-bg);
}
.probe-item { display: flex; gap: 10px; align-items: baseline; }
.probe-item .u { color: var(--text); word-break: break-all; }
.probe-item.included .u { color: var(--green); }
.probe-item.excluded .u { color: var(--muted); text-decoration: line-through; }
.probe-item .reason { color: var(--yellow); font-size: 11px; flex-shrink: 0; }
.meta-table td { white-space: normal; word-break: break-all; }
.meta-table td:first-child { width: 110px; color: var(--muted); }
/* ---------- misc ---------- */
.toast {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
+64
View File
@@ -67,6 +67,7 @@ def upsert_task(task):
def delete_task(task_id):
"""彻底删除任务 (任务 + 运行记录)"""
with _lock:
tasks = _load(TASKS_FILE, [])
tasks = [t for t in tasks if t["id"] != task_id]
@@ -76,6 +77,69 @@ def delete_task(task_id):
_save(RUNS_FILE, runs)
# ---------------- 回收站 ----------------
def soft_delete_task(task_id, ts):
"""删除任务 -> 移入回收站 (软删除, 记录保留可恢复)"""
with _lock:
tasks = _load(TASKS_FILE, [])
for t in tasks:
if t["id"] == task_id:
t["deleted_at"] = ts
break
_save(TASKS_FILE, tasks)
def restore_task(task_id):
"""从回收站恢复任务"""
with _lock:
tasks = _load(TASKS_FILE, [])
for t in tasks:
if t["id"] == task_id:
t.pop("deleted_at", None)
break
_save(TASKS_FILE, tasks)
def list_trash():
"""回收站任务列表 (按删除时间倒序)"""
with _lock:
tasks = _load(TASKS_FILE, [])
return sorted(
[t for t in tasks if t.get("deleted_at")],
key=lambda x: x.get("deleted_at", ""), reverse=True,
)
def purge_task(task_id):
"""从回收站彻底删除单个任务 (任务 + 运行记录)"""
with _lock:
tasks = _load(TASKS_FILE, [])
tasks = [t for t in tasks if t["id"] != task_id]
_save(TASKS_FILE, tasks)
runs = _load(RUNS_FILE, {})
runs.pop(task_id, None)
_save(RUNS_FILE, runs)
def purge_trash():
"""清空回收站, 返回被清空的任务 id 列表"""
with _lock:
tasks = _load(TASKS_FILE, [])
kept, purged = [], []
for t in tasks:
if t.get("deleted_at"):
purged.append(t["id"])
else:
kept.append(t)
_save(TASKS_FILE, kept)
runs = _load(RUNS_FILE, {})
for tid in purged:
runs.pop(tid, None)
_save(RUNS_FILE, runs)
return purged
# ---------------- 运行记录 ----------------
def load_runs_map():