Commit ed91787c authored by 郭阔's avatar 郭阔

feat: macOS platform adaptation, concurrency guard and detail page redesign

- web/app.py: IS_DARWIN branches (launchctl kickstart for manual trigger,
  direct execution for digest/continue without sudo); _active_harvests()
  concurrency guard on /run and /continue; normalized detail state
  (last run time from log filename, next trigger computed from schedule)
- detail.html: layout redesign — status badge header, real overview cards,
  merged duplicate run metadata, collapsible logs/config, history moved up
- gitignore: exclude .zcode/
parent 4e3e086a
......@@ -37,3 +37,4 @@ __pycache__/
# Claude Provider 配置(含敏感 token,不提交;只保留 .example.json)
config/providers/*.json
!config/providers/*.example.json
.zcode/
......@@ -21,7 +21,7 @@ import re
import subprocess
import sys
import threading
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from flask import (
......@@ -44,6 +44,11 @@ MODIFY_SOURCE = str(WORK_DIR / "bin" / "modify-source.sh")
CONTINUE_SCRIPT = str(WORK_DIR / "bin" / "continue.sh")
DIGEST_SCRIPT = str(WORK_DIR / "bin" / "daily-digest.sh")
# ── 平台适配 ──────────────────────────────────────────────
# macOS 上调度走 launchd(标签 com.newsharvester.<source>),无 systemctl/sudo -u。
IS_DARWIN = sys.platform == "darwin"
LAUNCHD_GUI = f"gui/{os.getuid()}"
# ── Flask 应用 ─────────────────────────────────────────────
app = Flask(__name__)
app.secret_key = os.environ.get("WEB_SECRET", "news-harvester-web-change-me")
......@@ -502,6 +507,31 @@ def run_script(cmd, timeout=30):
return 1, "", str(e)
def _active_harvests():
"""返回正在运行的采集源名列表(并发保护用)。
macOS 查 launchd agent 状态;Linux 查 systemd service is-active。
单个源查询失败视为未运行,不影响页面。"""
names = []
for timer in get_timer_files():
name = timer.name[len("news-harvester@"):-len(".timer")]
if IS_DARWIN:
rc, out, _ = run_script(
["launchctl", "print", f"{LAUNCHD_GUI}/com.newsharvester.{name}"],
timeout=5,
)
if rc == 0 and "state = running" in out:
names.append(name)
else:
rc, out, _ = run_script(
["systemctl", "is-active", f"news-harvester@{name}.service"],
timeout=5,
)
if rc == 0 and out.strip() == "active":
names.append(name)
return names
# ── 路由 ───────────────────────────────────────────────────
@app.route("/")
......@@ -578,11 +608,56 @@ def source_detail(name):
business_result, legacy_result = get_business_result(json_info["data"]) if json_info else (None, None)
history_runs = get_history_runs(name)
# 采集结果的运行时间:文件名编码开始时间,JSON 里有总耗时
result_time = None
result_start = None
if json_info:
m = re.search(r"\.(\d{8})\.(\d{6})\.json$", json_info["path"])
if m:
try:
start = datetime.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S")
result_start = start.strftime("%Y-%m-%d %H:%M:%S")
result_time = "{} 开始 · 耗时 {}".format(
result_start,
format_duration_ms(json_info["data"].get("duration_ms")),
)
except ValueError:
pass
# 调度状态归一化:macOS 读 launchd,Linux 读 systemd;下次触发从排期计算兜底
agent_running = name in _active_harvests()
if agent_running:
state_label = "运行中"
elif props.get("active") == "active":
state_label = "active"
else:
state_label = "空闲"
last_trigger = result_start or props.get("last") or "-"
next_trigger = props.get("next") if props.get("next") not in (None, "-") else None
if not next_trigger:
cands = []
for t in (run_time or "").split(","):
t = t.strip()
if ":" not in t:
continue
try:
hh, mm = t.split(":")[:2]
cand = datetime.now().replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
except ValueError:
continue
if cand <= datetime.now():
cand += timedelta(days=1)
cands.append(cand)
next_trigger = min(cands).strftime("%m-%d %H:%M") if cands else "-"
return render_template("detail.html", name=name, run_time=run_time,
props=props, log_info=log_info, json_info=json_info,
json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result,
continue_status=continue_status, auto_refresh=auto_refresh,
providers=get_providers(), history_runs=history_runs)
providers=get_providers(), history_runs=history_runs,
result_time=result_time, result_start=result_start,
agent_running=agent_running, state_label=state_label,
last_trigger=last_trigger, next_trigger=next_trigger)
@app.route("/source/<name>/history/<run_file>")
......@@ -704,9 +779,18 @@ def edit_source(name):
def run_source(name):
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
rc, stdout, stderr = run_script(
["systemctl", "start", f"news-harvester@{name}.service"], timeout=10
active = _active_harvests()
if active:
flash(
f"已有采集任务在运行({('、'.join(active))}),为避免并发冲突已拒绝触发,请等其完成后再试。",
"error",
)
return redirect(url_for("source_detail", name=name))
if IS_DARWIN:
trigger = ["launchctl", "kickstart", f"{LAUNCHD_GUI}/com.newsharvester.{name}"]
else:
trigger = ["systemctl", "start", f"news-harvester@{name}.service"]
rc, stdout, stderr = run_script(trigger, timeout=10)
if rc == 0:
flash(f"已触发 {name} 抓取", "success")
else:
......@@ -722,9 +806,10 @@ def run_digest():
return redirect(url_for("index"))
# 后台启动 daily-digest.sh(非阻塞)
# 以 yichu 用户执行,确保文件权限/邮件/PATH 全部正确
# Linux 以 yichu 用户执行,确保文件权限/邮件/PATH 全部正确;macOS 直接当前用户
digest_cmd = [DIGEST_SCRIPT] if IS_DARWIN else ["sudo", "-u", "yichu", DIGEST_SCRIPT]
proc = subprocess.Popen(
["sudo", "-u", "yichu", DIGEST_SCRIPT],
digest_cmd,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
......@@ -758,6 +843,14 @@ def continue_source(name):
flash(f"{name} 已有续采/修复任务在运行中,请等待完成", "error")
return redirect(url_for("source_detail", name=name))
active = _active_harvests()
if active:
flash(
f"已有采集任务在运行({('、'.join(active))}),续采/修复会与其争用浏览器,已拒绝启动。",
"error",
)
return redirect(url_for("source_detail", name=name))
mode = request.form.get("mode", "resume").strip()
if mode not in ("resume", "repair"):
mode = "resume"
......@@ -765,8 +858,9 @@ def continue_source(name):
provider = request.form.get("provider", "").strip()
# 后台启动 continue.sh(非阻塞)
# 以 yichu 用户执行(通过 sudo -u),确保文件权限/邮件/PATH 全部正确
cmd = ["sudo", "-u", "yichu", CONTINUE_SCRIPT, name, "--mode", mode, "--yes"]
# Linux 以 yichu 用户执行(通过 sudo -u);macOS 直接以当前用户执行(继承面板环境)
cmd = ([CONTINUE_SCRIPT] if IS_DARWIN else ["sudo", "-u", "yichu", CONTINUE_SCRIPT]) \
+ [name, "--mode", mode, "--yes"]
if debug_flag:
cmd.append("--debug")
if provider:
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment