diff --git a/app.py b/app.py index a58b362..93c78ad 100644 --- a/app.py +++ b/app.py @@ -4,14 +4,17 @@ 启动: /home/hz1/miniconda3/envs/openclaw/bin/python app.py (默认端口 16062) """ import os +import re import threading import time +import zipfile from datetime import datetime from flask import Flask, jsonify, request, send_file, send_from_directory import store import db +import notify from engine import CrawlJob, probe_links from scheduler import Scheduler, cron_next, interval_delta @@ -674,6 +677,103 @@ def api_file(): return send_file(full) +# ---------------- API: 打包导出 ---------------- + +EXPORT_DIR = os.path.join(HERE, "data", "exports") +EXPORT_KEEP = 10 # 最多保留的打包文件数 + + +def _safe_zip_name(task): + """任务名安全化为文件名 (保留中文, 去非法字符)""" + name = re.sub(r'[\\/:*?"<>|\s]+', "_", task.get("name", "")).strip("_") + return (name or task["id"])[:60] + + +def _make_zip(task): + """把任务输出目录打包为 zip, 返回 (zip_path, err) 或 (None, 错误信息)""" + out_dir = os.path.realpath(resolve_out_dir(task)) + if not os.path.isdir(out_dir): + return None, "输出目录不存在" + os.makedirs(EXPORT_DIR, exist_ok=True) + zip_path = os.path.join(EXPORT_DIR, f"{task['id']}_{int(time.time())}.zip") + prefix = _safe_zip_name(task) + "/" + count = 0 + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(out_dir): + for f in files: + full = os.path.join(root, f) + rel = os.path.relpath(full, out_dir) + zf.write(full, prefix + rel) + count += 1 + except Exception as e: + try: + os.remove(zip_path) + except OSError: + pass + return None, f"打包失败: {e}" + _cleanup_exports() + return zip_path, None + + +def _cleanup_exports(): + """清理旧的打包文件, 只保留最近 EXPORT_KEEP 个""" + try: + files = sorted( + (os.path.join(EXPORT_DIR, f) for f in os.listdir(EXPORT_DIR) + if f.endswith(".zip")), + key=os.path.getmtime, reverse=True, + ) + for f in files[EXPORT_KEEP:]: + os.remove(f) + except Exception: + pass + + +@app.route("/api/export") +def api_export(): + """打包任务输出目录为 zip 并提供下载""" + tid = request.args.get("task_id", "") + task = store.get_task(tid) + if not task: + return jsonify({"error": "任务不存在"}), 404 + zip_path, err = _make_zip(task) + if err: + return jsonify({"error": err}), 400 + return send_file( + zip_path, as_attachment=True, + download_name=_safe_zip_name(task) + ".zip", + mimetype="application/zip", + ) + + +@app.route("/api/export/email", methods=["POST"]) +def api_export_email(): + """打包任务输出目录为 zip 并发送到指定邮箱 (默认 wlq@tphai.com)""" + body = request.get_json(force=True) or {} + tid = body.get("task_id", "") + email = (body.get("email") or "").strip() or "wlq@tphai.com" + task = store.get_task(tid) + if not task: + return jsonify({"error": "任务不存在"}), 404 + if "@" not in email: + return jsonify({"error": "邮箱格式不正确"}), 400 + zip_path, err = _make_zip(task) + if err: + return jsonify({"error": err}), 400 + size_mb = round(os.path.getsize(zip_path) / 1048576, 1) + body_text = (f"项目: {task['name']}\n" + f"输出目录: {resolve_out_dir(task)}\n" + f"打包文件: {os.path.basename(zip_path)}\n" + f"压缩包大小: {size_mb} MB\n\n" + f"打包时间: {now_str()}") + ok, msg = notify.send_attachment( + f"[爬虫打包] {task['name']}", body_text, email, zip_path) + if not ok: + return jsonify({"error": f"邮件发送失败: {msg}"}), 500 + return jsonify({"ok": True, "msg": f"已发送到 {email} (zip {size_mb} MB)"}) + + # ---------------- 启动 ---------------- scheduler = Scheduler(start_run) diff --git a/data/exports/t_0edd3eaa940f_1786498881.zip b/data/exports/t_0edd3eaa940f_1786498881.zip new file mode 100644 index 0000000..95d3b91 Binary files /dev/null and b/data/exports/t_0edd3eaa940f_1786498881.zip differ diff --git a/data/exports/t_28f489e06be1_1786498905.zip b/data/exports/t_28f489e06be1_1786498905.zip new file mode 100644 index 0000000..a7537ba Binary files /dev/null and b/data/exports/t_28f489e06be1_1786498905.zip differ diff --git a/data/exports/t_f4978a9becdd_1786498881.zip b/data/exports/t_f4978a9becdd_1786498881.zip new file mode 100644 index 0000000..a7f6c93 Binary files /dev/null and b/data/exports/t_f4978a9becdd_1786498881.zip differ diff --git a/data/exports/t_f4978a9becdd_1786498886.zip b/data/exports/t_f4978a9becdd_1786498886.zip new file mode 100644 index 0000000..a7f6c93 Binary files /dev/null and b/data/exports/t_f4978a9becdd_1786498886.zip differ diff --git a/data/exports/t_f4978a9becdd_1786498902.zip b/data/exports/t_f4978a9becdd_1786498902.zip new file mode 100644 index 0000000..a7f6c93 Binary files /dev/null and b/data/exports/t_f4978a9becdd_1786498902.zip differ diff --git a/notify.py b/notify.py index 1824931..780e596 100644 --- a/notify.py +++ b/notify.py @@ -33,11 +33,24 @@ def notify_email(task, run): if len(results) > 50: lines.append(f" ... 共 {len(results)} 条") body = "\n".join(lines) + return send_attachment(f"[爬虫完成] {task['name']}", body, to) + + +def send_attachment(subject, body, to, attach_path=None): + """发送带附件的邮件 (复用 send_email.py), 返回 (bool, msg) + attach_path: 附件文件路径, 可多个 + """ + if not os.path.exists(SEND_EMAIL): + return False, "send_email.py 不存在" + cmd = [sys.executable, SEND_EMAIL, subject, body, "--to", to] + if attach_path: + if isinstance(attach_path, str): + attach_path = [attach_path] + for p in attach_path: + if os.path.isfile(p): + cmd += ["--attach", p] try: - r = subprocess.run( - [sys.executable, SEND_EMAIL, f"[爬虫完成] {task['name']}", body, "--to", to], - timeout=60, capture_output=True, - ) + r = subprocess.run(cmd, timeout=600, capture_output=True) return r.returncode == 0, (r.stderr or b"").decode(errors="ignore")[:200] except Exception as e: return False, str(e) diff --git a/static/app.js b/static/app.js index 4166278..828e08d 100644 --- a/static/app.js +++ b/static/app.js @@ -606,6 +606,8 @@ function renderDetail() { ${t.mode === "auto" ? ` ` : ""} + + `; const chips = runs.map((r) => ` @@ -732,6 +734,59 @@ function stopLogPoll() { if (state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; } } +/* ---------------- 打包导出 ---------------- */ +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");