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:
......
......@@ -7,7 +7,22 @@
{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
{# ── 页头:源名 + 状态 + 排期 ───────────────────────────── #}
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:4px">
<h1 style="margin:0">{{ name }}</h1>
{% if agent_running %}
<span class="badge ok" style="font-size:13px">● 运行中</span>
{% elif business_result %}
<span class="badge {{ 'ok' if business_result.status == 'SUCCESS' else 'fail' }}" style="font-size:13px">
{{ '上次成功' if business_result.status == 'SUCCESS' else '上次失败' }}
</span>
{% else %}
<span class="badge unknown" style="font-size:13px">无运行记录</span>
{% endif %}
{% if run_time and run_time != '-' %}
<span style="color:#888;font-size:13px">每天 {{ run_time }} 自动采集</span>
{% endif %}
</div>
{% if continue_status and continue_status.running %}
<div class="running-banner">
......@@ -17,11 +32,13 @@
</div>
{% endif %}
{# ── 主要操作 ──────────────────────────────────────────── #}
<div class="actions">
<a class="btn" href="{{ url_for('edit_source', name=name) }}">编辑配置</a>
<form method="POST" action="{{ url_for('run_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定手动触发 {{ name }} 抓取?')">
<button type="submit" class="btn btn-primary" {% if continue_status and continue_status.running %}disabled{% endif %}>手动触发</button>
<button type="submit" class="btn btn-primary" {% if (continue_status and continue_status.running) or agent_running %}disabled{% endif %}>
{% if agent_running %}采集运行中…{% else %}手动触发{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('continue_source', name=name) }}" style="display:inline"
onsubmit="return confirmContinue('resume', '{{ name }}', '{{ conf.get("FALLBACK_PROVIDER", "") }}', '{{ conf.get("PROVIDER", "") }}')">
......@@ -49,82 +66,72 @@
{% if continue_status and continue_status.running and continue_status.mode == 'repair' %}修复中…{% else %}采集修复{% endif %}
</button>
</form>
<script>
function confirmContinue(mode, source, fallbackProvider, currentProvider) {
var selects = document.querySelectorAll('select[name="provider"]');
var form = event.target.closest('form');
var sel = form.querySelector('select[name="provider"]');
var selectedProvider = sel ? sel.value : '';
var modeLabel = mode === 'repair' ? '采集修复' : '继续采集';
</div>
<script>
function confirmContinue(mode, source, fallbackProvider, currentProvider) {
var form = event.target.closest('form');
var sel = form.querySelector('select[name="provider"]');
var selectedProvider = sel ? sel.value : '';
var modeLabel = mode === 'repair' ? '采集修复' : '继续采集';
// 没有选 provider (用默认),且没有 fallback
if (!selectedProvider && !fallbackProvider) {
var msg = '确定对 ' + source + ' 执行' + modeLabel + '?\n' +
'将恢复上次 Claude 对话' +
(mode === 'repair' ? ',清理之前归档的错误数据,重新完整采集并 force 推送' : ',在已有基础上继续完成中断的采集') + '。\n\n' +
'⚠️ 该数据源没有配置备用 provider(FALLBACK_PROVIDER),\n' +
'将使用与上次相同的 provider 重试。确认继续?';
return confirm(msg);
}
if (mode === 'repair') {
return confirm('⚠️ 确定对 ' + source + ' 执行采集修复?\n\n' +
'这将:\n1. 恢复上次 Claude 对话\n2. 清理之前归档的错误数据\n' +
'3. 重新完整采集并 force 推送\n\n此操作会删除之前本次采集的文章,无法撤销!');
}
return confirm('确定对 ' + source + ' 执行继续采集?\n将恢复上次 Claude 对话,在已有基础上继续完成中断的采集。');
if (!selectedProvider && !fallbackProvider) {
var msg = '确定对 ' + source + ' 执行' + modeLabel + '?\n' +
'将恢复上次 Claude 对话' +
(mode === 'repair' ? ',清理之前归档的错误数据,重新完整采集并 force 推送' : ',在已有基础上继续完成中断的采集') + '。\n\n' +
'⚠️ 该数据源没有配置备用 provider(FALLBACK_PROVIDER),\n' +
'将使用与上次相同的 provider 重试。确认继续?';
return confirm(msg);
}
</script>
<a class="btn" href="{{ url_for('index') }}">返回列表</a>
<form method="POST" action="{{ url_for('disable_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定停用栏目 {{ name }}?\n\n这将:\n1. 停止并 disable 定时器\n2. 清空运行时间(不再触发)\n\n文件保留,可随时通过编辑重新设置时间来启用。')">
<button type="submit" class="btn btn-danger" {% if continue_status and continue_status.running %}disabled{% endif %}>停用栏目</button>
</form>
</div>
if (mode === 'repair') {
return confirm('⚠️ 确定对 ' + source + ' 执行采集修复?\n\n' +
'这将:\n1. 恢复上次 Claude 对话\n2. 清理之前归档的错误数据\n' +
'3. 重新完整采集并 force 推送\n\n此操作会删除之前本次采集的文章,无法撤销!');
}
return confirm('确定对 ' + source + ' 执行继续采集?\n将恢复上次 Claude 对话,在已有基础上继续完成中断的采集。');
}
</script>
<h2>Timer 信息</h2>
{# ── 运行概览(合并原 Timer 信息 / 最近运行 / JSON 元数据 的高频字段) ── #}
<h2>运行概览</h2>
<div class="cards">
<div class="card">
<div class="label">运行时间</div>
<div class="value"><code>{{ run_time }}</code></div>
</div>
<div class="card">
<div class="label">状态</div>
<div class="value">
{% if props.active == 'active' %}
<span class="green">active</span>
{% else %}
<span class="red">{{ props.active }}</span>
{% endif %}
</div>
<div class="value {{ 'green' if state_label in ('运行中', 'active') else '' }}">{{ state_label }}</div>
</div>
<div class="card">
<div class="label">上次触发</div>
<div class="value" style="font-size:14px">{{ props.last }}</div>
<div class="label">上次运行</div>
<div class="value" style="font-size:14px">{{ last_trigger }}</div>
</div>
<div class="card">
<div class="label">下次触发</div>
<div class="value" style="font-size:14px">{{ props.next }}</div>
<div class="value" style="font-size:14px">{{ next_trigger }}</div>
</div>
{% if log_info %}
<div class="card">
<div class="label">上次成本</div>
<div class="value">{{ log_info.get('cost', '-') }}</div>
</div>
<div class="card">
<div class="label">上次耗时</div>
<div class="value" style="font-size:14px">{{ log_info.get('duration', '-') }}</div>
</div>
<div class="card">
<div class="label">Provider</div>
<div class="value" style="font-size:14px">{{ log_info.get('provider', '-') }}</div>
</div>
{% endif %}
</div>
{% if conf %}
<h2>栏目配置 ({{ name }}.conf)</h2>
<div class="table-wrap">
<table>
<tbody>
{% for k, v in conf.items() %}
<tr><td><code>{{ k }}</code></td><td>{{ v }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<h2>栏目配置</h2>
<p style="color:#666">无独立配置,继承 <code>default.conf</code></p>
{% if log_info and log_info.get('session') %}
<p style="color:#666;font-size:12px;margin:6px 0 0">Session: <span class="session-id">{{ log_info.session }}</span>
{% if log_info.get('turns') %} · Turns {{ log_info.get('turns') }}{% endif %}
{% if json_meta and json_meta.get('duration_api') %} · API {{ json_meta.duration_api }} ms{% endif %}
</p>
{% endif %}
{# ── 采集结果 ──────────────────────────────────────────── #}
{% if business_result %}
<h2>采集结果</h2>
<h2>采集结果{% if result_time %} <span style="font-size:13px;color:#666;font-weight:normal">{{ result_time }}</span>{% endif %}</h2>
<div class="cards">
<div class="card"><div class="label">结果状态</div><div class="value {{ 'green' if business_result.status == 'SUCCESS' else 'red' }}">{{ '成功' if business_result.status == 'SUCCESS' else '失败' }}</div></div>
<div class="card"><div class="label">成功 / 失败 / 目标</div><div class="value">{{ business_result.succeeded_count }} / {{ business_result.failed_count }} / {{ business_result.requested_count }}</div></div>
......@@ -167,98 +174,67 @@
<pre>{{ legacy_result }}</pre>
{% endif %}
{% if log_info %}
<h2>最近运行</h2>
<div class="cards">
<div class="card">
<div class="label">状态</div>
<div class="value
{% if '成功' in log_info.get('status', '') %}green
{% elif '失败' in log_info.get('status', '') %}red{% endif %}">
{{ log_info.get('status', '?') }}
</div>
</div>
<div class="card">
<div class="label">成本</div>
<div class="value">{{ log_info.get('cost', '-') }}</div>
</div>
<div class="card">
<div class="label">耗时</div>
<div class="value" style="font-size:14px">{{ log_info.get('duration', '-') }}</div>
</div>
<div class="card">
<div class="label">Turns</div>
<div class="value">{{ log_info.get('turns', '-') }}</div>
</div>
<div class="card">
<div class="label">Provider</div>
<div class="value" style="font-size:14px">{{ log_info.get('provider', '-') }}</div>
</div>
<div class="card">
<div class="label">修改时间</div>
<div class="value" style="font-size:14px">{{ log_info.get('mtime', '?') }}</div>
</div>
{# ── 历史执行记录(高频,提前) ──────────────────────────── #}
<h2>历史执行记录</h2>
{% if history_runs %}
<div class="table-wrap">
<table>
<thead>
<tr><th>运行时间</th><th>类型</th><th>状态</th><th>文章(成功 / 失败 / 目标)</th><th>Provider</th><th>耗时</th><th></th></tr>
</thead>
<tbody>
{% for run in history_runs %}
<tr>
<td><code>{{ run.run_at }}</code></td>
<td>{{ run.mode }}</td>
<td><span class="badge {{ 'ok' if run.status == 'SUCCESS' else 'fail' if run.status == 'FAILED' else 'unknown' }}">
{{ '成功' if run.status == 'SUCCESS' else '失败' if run.status == 'FAILED' else '旧版记录' if run.status == 'LEGACY' else run.status }}
</span></td>
<td>{% if run.requested_count is not none %}{{ run.succeeded_count }} / {{ run.failed_count }} / {{ run.requested_count }}{% else %}—{% endif %}</td>
<td>{{ run.provider }}</td>
<td>{{ run.duration }}</td>
<td><a class="btn btn-sm" href="{{ url_for('source_history_detail', name=name, run_file=run.filename) }}">查看</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color:#666">暂无历史执行记录</p>
{% endif %}
{% if log_info.get('session') %}
<div class="card" style="margin-bottom:16px">
<div class="label">Claude Session ID</div>
<div class="value" style="font-size:13px">
<span class="session-id">{{ log_info.session }}</span>
</div>
{# ── 次要操作 ──────────────────────────────────────────── #}
<div class="actions" style="margin-top:16px">
<a class="btn" href="{{ url_for('edit_source', name=name) }}">编辑配置</a>
<form method="POST" action="{{ url_for('disable_source', name=name) }}" style="display:inline"
onsubmit="return confirm('确定停用栏目 {{ name }}?\n\n这将:\n1. 停止并 disable 定时器\n2. 清空运行时间(不再触发)\n\n文件保留,可随时通过编辑重新设置时间来启用。')">
<button type="submit" class="btn btn-danger">停用栏目</button>
</form>
<a class="btn" href="{{ url_for('index') }}">返回列表</a>
</div>
{% endif %}
<h2>运行日志 (.last.log)</h2>
{# ── 折叠区:日志 / Claude 明细 / 配置 ───────────────────── #}
{% if log_info %}
<details style="margin-top:16px">
<summary><strong>运行日志 (.last.log)</strong></summary>
<pre>{{ log_info.raw }}</pre>
{% else %}
<h2>最近运行</h2>
<p style="color:#666">暂无运行记录</p>
</details>
{% endif %}
{% if json_meta %}
<h2>Claude JSON 元数据</h2>
<div class="cards">
<div class="card">
<div class="label">总成本</div>
<div class="value">${{ json_meta.cost }}</div>
</div>
<div class="card">
<div class="label">Turns</div>
<div class="value">{{ json_meta.turns }}</div>
</div>
<div class="card">
<div class="label">API 耗时</div>
<div class="value" style="font-size:14px">{{ json_meta.duration_api }} ms</div>
</div>
<div class="card">
<div class="label">is_error</div>
<div class="value {% if not json_meta.is_error %}green{% else %}red{% endif %}">
{{ json_meta.is_error }}
</div>
</div>
<div class="card">
<div class="label">Terminal</div>
<div class="value" style="font-size:14px">{{ json_meta.terminal }}</div>
</div>
<details style="margin-top:8px">
<summary><strong>Claude 运行明细</strong></summary>
<div class="cards" style="margin-top:8px">
<div class="card"><div class="label">is_error</div><div class="value {{ 'green' if not json_meta.is_error else 'red' }}">{{ json_meta.is_error }}</div></div>
<div class="card"><div class="label">Terminal</div><div class="value" style="font-size:14px">{{ json_meta.terminal }}</div></div>
<div class="card"><div class="label">Turns</div><div class="value">{{ json_meta.turns }}</div></div>
<div class="card"><div class="label">API 耗时</div><div class="value" style="font-size:14px">{{ json_meta.duration_api }} ms</div></div>
</div>
{% if json_meta.session_id and json_meta.session_id != '-' %}
<div class="card" style="margin-bottom:16px">
<div class="label">Session ID</div>
<div class="value" style="font-size:13px">
<span class="session-id">{{ json_meta.session_id }}</span>
</div>
</div>
{% endif %}
{% if json_meta.usage %}
<h3>Token 用量</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>类型</th><th>Token 数</th></tr>
</thead>
<thead><tr><th>类型</th><th>Token 数</th></tr></thead>
<tbody>
{% for k, v in json_meta.usage.items() %}
<tr><td><code>{{ k }}</code></td><td>{{ v }}</td></tr>
......@@ -267,41 +243,29 @@
</table>
</div>
{% endif %}
{% if json_meta.permission_denials %}
<h3 style="color:#f44747">权限拒绝 ({{ json_meta.permission_denials|length }} 次)</h3>
<pre>{{ json_meta.permission_denials | tojson(indent=2) }}</pre>
{% endif %}
<p style="color:#666;font-size:12px;margin-top:8px">来源: {{ json_info.path }}</p>
</details>
{% endif %}
<h2>历史执行记录</h2>
{% if history_runs %}
<div class="table-wrap">
<details style="margin-top:8px">
<summary><strong>栏目配置{% if conf %} ({{ name }}.conf){% endif %}</strong></summary>
{% if conf %}
<div class="table-wrap" style="margin-top:8px">
<table>
<thead>
<tr><th>运行时间</th><th>类型</th><th>状态</th><th>文章(成功 / 失败 / 目标)</th><th>Provider</th><th>耗时</th><th></th></tr>
</thead>
<tbody>
{% for run in history_runs %}
<tr>
<td><code>{{ run.run_at }}</code></td>
<td>{{ run.mode }}</td>
<td><span class="badge {{ 'ok' if run.status == 'SUCCESS' else 'fail' if run.status == 'FAILED' else 'unknown' }}">
{{ '成功' if run.status == 'SUCCESS' else '失败' if run.status == 'FAILED' else '旧版记录' if run.status == 'LEGACY' else run.status }}
</span></td>
<td>{% if run.requested_count is not none %}{{ run.succeeded_count }} / {{ run.failed_count }} / {{ run.requested_count }}{% else %}—{% endif %}</td>
<td>{{ run.provider }}</td>
<td>{{ run.duration }}</td>
<td><a class="btn btn-sm" href="{{ url_for('source_history_detail', name=name, run_file=run.filename) }}">查看</a></td>
</tr>
{% for k, v in conf.items() %}
<tr><td><code>{{ k }}</code></td><td>{{ v }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color:#666">暂无可读取的历史执行记录</p>
<p style="color:#666;margin-top:8px">无独立配置,继承 <code>default.conf</code></p>
{% endif %}
</details>
{% endblock %}
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