Commit 4e3e086a authored by 谢宇轩's avatar 谢宇轩

feat: new feature

parent faf1233d
# Repository Guidelines
## Project Structure & Module Organization
All Bash scripts live in `bin/`: `harvest.sh` performs one source fetch, `daily-digest.sh` builds the daily report, and `provider.sh` is a shared library; `add-source.sh` and `modify-source.sh` manage per-source timers and conf, while `migrate.sh` and `deploy.sh` manage installation and deployment (repo-level tools, not copied into the runtime directory). Keep systemd unit and timer definitions in `systemd/`, and keep only redacted configuration examples in `config/`. The optional Flask administration UI lives in `web/app.py`; its Jinja templates belong in `web/templates/`.
The deployed layout matters: runtime scripts (everything in `bin/` except `deploy.sh`/`migrate.sh`) and the web app are copied to `~/Documents/News-Harvester/bin/` and `~/Documents/News-Harvester/web/`, while systemd units and live configuration reside under `/etc/systemd/system/` and `/etc/news-harvester/`.
## Build, Test, and Development Commands
There is no build step or automated test suite. Before committing script changes, run:
```bash
bash -n bin/*.sh
python3 -m py_compile web/app.py
```
On an Arch Linux server, install the optional panel dependency with `sudo pacman -S python-flask`. For an end-to-end smoke test after installation, run `sudo systemctl start news-harvester@nikkei-world.service` and inspect its journal. Use `./bin/deploy.sh <host> --no-systemd` only when the change does not require updating units.
## Coding Style & Naming Conventions
Write Bash for Bash (`#!/bin/bash`), use `set -euo pipefail` for mutating scripts, quote expansions, and expose installation paths through the existing `${VAR:-default}` pattern. `status.sh` intentionally tolerates read-only command failures; preserve that behavior. Use lowercase, hyphenated source names such as `nikkei-world`; the management scripts accept only letters, digits, `_`, and `-`.
Use four spaces and `snake_case` in Python. Keep Flask route handlers small, validate source/provider input before invoking subprocesses, and place UI changes in the matching template. Preserve existing systemd naming: `news-harvester@<source>.timer` pairs with the template service.
## Testing & Change Validation
No coverage threshold is configured. Run the syntax checks above for every change. For timer or configuration changes, validate with `systemctl daemon-reload` and `systemctl list-timers`; for UI changes, start the panel in a configured server environment and exercise the changed route. Record the command and result in the pull request.
## Commits, Pull Requests & Security
Existing history uses concise Conventional Commit-style subjects, e.g. `feat: add provider support`; use `fix:`, `feat:`, or `docs:` with an imperative summary. Keep pull requests focused, explain any deployment-path or systemd impact, link relevant issues, and include screenshots for template/UI changes.
Never commit live provider JSON, SMTP credentials, passwords, logs, or generated output. Add sanitized `*.example` files instead and keep secret files permission-restricted (typically `600`).
This diff is collapsed.
This diff is collapsed.
...@@ -256,7 +256,8 @@ def format_summary(result): ...@@ -256,7 +256,8 @@ def format_summary(result):
return "\n".join(lines) return "\n".join(lines)
def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=None): def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=None,
provider="default", max_budget_usd=15):
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
result = empty_result(source, count) result = empty_result(source, count)
task_failed = False task_failed = False
...@@ -359,6 +360,8 @@ def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=Non ...@@ -359,6 +360,8 @@ def normalize(envelope, source, count, process_rc, work_dir=None, vault_root=Non
output["is_error"] = result["status"] == "FAILED" output["is_error"] = result["status"] == "FAILED"
output["process_exit_code"] = process_rc output["process_exit_code"] = process_rc
output["exit_code"] = process_rc if process_rc else (1 if output["is_error"] else 0) output["exit_code"] = process_rc if process_rc else (1 if output["is_error"] else 0)
output["provider"] = provider
output["max_budget_usd"] = max_budget_usd
return output return output
...@@ -371,6 +374,8 @@ def main(): ...@@ -371,6 +374,8 @@ def main():
parser.add_argument("--exit-code", type=int, default=0) parser.add_argument("--exit-code", type=int, default=0)
parser.add_argument("--work-dir", type=Path) parser.add_argument("--work-dir", type=Path)
parser.add_argument("--vault-root", type=Path) parser.add_argument("--vault-root", type=Path)
parser.add_argument("--provider", default="default")
parser.add_argument("--max-budget-usd", type=float, default=15)
args = parser.parse_args() args = parser.parse_args()
if args.count < 1: if args.count < 1:
parser.error("采集数量必须为正整数") parser.error("采集数量必须为正整数")
...@@ -387,7 +392,7 @@ def main(): ...@@ -387,7 +392,7 @@ def main():
except (ValueError, OSError): except (ValueError, OSError):
envelope = {} envelope = {}
output = normalize(envelope, args.source, args.count, args.exit_code, output = normalize(envelope, args.source, args.count, args.exit_code,
args.work_dir, args.vault_root) args.work_dir, args.vault_root, args.provider, args.max_budget_usd)
temporary = args.path.with_suffix(".tmp") temporary = args.path.with_suffix(".tmp")
temporary.write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") temporary.write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(args.path) temporary.replace(args.path)
......
...@@ -24,6 +24,8 @@ normalize_result() { ...@@ -24,6 +24,8 @@ normalize_result() {
--count "$COUNT" --count "$COUNT"
--exit-code "$RC" --exit-code "$RC"
--work-dir "$WORK_DIR" --work-dir "$WORK_DIR"
--provider "${USED_PROVIDER:-default}"
--max-budget-usd "${RESOLVED_MAX_BUDGET_USD:-${MAX_BUDGET_USD:-15}}"
) )
if [ -n "${OBSIDIAN_VAULT_PATH:-}" ]; then if [ -n "${OBSIDIAN_VAULT_PATH:-}" ]; then
normalize_args+=(--vault-root "$OBSIDIAN_VAULT_PATH") normalize_args+=(--vault-root "$OBSIDIAN_VAULT_PATH")
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -112,6 +112,8 @@ class ResultTests(ArchiveFixtureMixin, unittest.TestCase): ...@@ -112,6 +112,8 @@ class ResultTests(ArchiveFixtureMixin, unittest.TestCase):
self.assertFalse(output['is_error']) self.assertFalse(output['is_error'])
self.assertEqual(output['session_id'], 'fixture-session') self.assertEqual(output['session_id'], 'fixture-session')
self.assertEqual(output['result_text'], '原始文字结果') self.assertEqual(output['result_text'], '原始文字结果')
self.assertEqual(output['provider'], 'default')
self.assertEqual(output['max_budget_usd'], 15)
result_module.validate(envelope([item])['structured_output'], result_module.validate(envelope([item])['structured_output'],
json.loads(result_module.SCHEMA_PATH.read_text())) json.loads(result_module.SCHEMA_PATH.read_text()))
...@@ -337,6 +339,9 @@ class ScriptTests(TemporaryEnvironment): ...@@ -337,6 +339,9 @@ class ScriptTests(TemporaryEnvironment):
self.assertEqual(process.returncode, 0, process.stderr) self.assertEqual(process.returncode, 0, process.stderr)
args = json.loads((self.base / 'calls').read_text()) args = json.loads((self.base / 'calls').read_text())
self.assertEqual(args[args.index('--max-budget-usd') + 1], '9.25') self.assertEqual(args[args.index('--max-budget-usd') + 1], '9.25')
result = json.loads(next(self.logs.glob('*.json')).read_text())
self.assertEqual(result['provider'], 'default')
self.assertEqual(result['max_budget_usd'], 9.25)
self.assertIn('成功', (self.logs / 'sample.last.log').read_text()) self.assertIn('成功', (self.logs / 'sample.last.log').read_text())
self.assertIn('测试新闻 | 测试新闻中文 | 归档 ✓ | 推送 updated', self.assertIn('测试新闻 | 测试新闻中文 | 归档 ✓ | 推送 updated',
(self.logs / 'sample.last.log').read_text()) (self.logs / 'sample.last.log').read_text())
...@@ -385,20 +390,45 @@ class ScriptTests(TemporaryEnvironment): ...@@ -385,20 +390,45 @@ class ScriptTests(TemporaryEnvironment):
def test_digest_retains_failures_without_sending_full_bodies_to_model(self): def test_digest_retains_failures_without_sending_full_bodies_to_model(self):
today = time.strftime('%Y%m%d') today = time.strftime('%Y%m%d')
output = result_module.normalize(envelope([self.make_article('短文' * 100)]), run_envelope = envelope([self.make_article('短文' * 100)])
'sample', 1, 0, self.base, self.base) run_envelope.update(total_cost_usd=1.98, duration_ms=125000)
output = result_module.normalize(run_envelope, 'sample', 1, 0, self.base, self.base,
'expensive-provider', 2)
(self.logs / f'sample.continue.{today}.143000.json').write_text(json.dumps(output)) (self.logs / f'sample.continue.{today}.143000.json').write_text(json.dumps(output))
(self.logs / f'sample.{today}.090000.json').write_text(json.dumps( (self.logs / f'sample.{today}.090000.json').write_text(json.dumps(
envelope([self.make_article()]) | {'structured_output': None})) envelope([self.make_article()]) | {'structured_output': None}))
self.save_fixture({'structured_output': {'alert': False, 'alert_reasons': [], 'anomalies': [], 'recommendations': [], 'failed_sources': []}}) self.env.update(MAIL_TO='test@example.invalid', DIGEST_MAIL_ALWAYS='1')
self.save_fixture({'structured_output': {
'alert': True,
'alert_reasons': ['sample 接近预算上限并中断'],
'issues': [{
'source': 'sample', 'category': 'BUDGET_EXCEEDED',
'summary': '采集在接近预算上限时中断',
'evidence': '成本 $1.98,预算 $2.00',
'recommendation': '提高该 provider 的独立预算或减少单批文章数。',
}],
'orchestration_recommendations': ['错开 expensive-provider 的长任务。'],
'failed_sources': ['sample'],
}})
process = self.run_script('daily-digest.sh') process = self.run_script('daily-digest.sh')
self.assertEqual(process.returncode, 0, process.stderr) self.assertEqual(process.returncode, 0, process.stderr)
summary = json.loads((self.logs / f'.digest-summary.{today}.json').read_text()) summary = json.loads((self.logs / f'.digest-summary.{today}.json').read_text())
failed = next(row for row in summary if row['time'] == '143000') failed = next(row for row in summary if row['time'] == '143000')
self.assertEqual(failed['status'], 'FAILED') self.assertEqual(failed['status'], 'FAILED')
self.assertEqual(failed['provider'], 'expensive-provider')
self.assertEqual(failed['attempted_count'], 1)
self.assertNotIn('body', failed['result']['articles'][0]) self.assertNotIn('body', failed['result']['articles'][0])
self.assertNotIn('body', failed['result']['articles'][0]['archive']) self.assertNotIn('body', failed['result']['articles'][0]['archive'])
self.assertIn('采集结果校验失败', (self.logs / f'digest.{today}.md').read_text()) digest = (self.logs / f'digest.{today}.md').read_text()
self.assertIn('Provider 成本', digest)
self.assertIn('文章 1/0/1', digest)
self.assertIn('2.1 分钟', digest)
mail = (self.base / 'calls.mail').read_text()
self.assertIn('expensive-provider', mail)
self.assertIn('异常分析与排查建议', mail)
self.assertIn('BUDGET_EXCEEDED', mail)
self.assertNotIn('Turns', mail)
self.assertNotIn('完整存档', mail)
class ScheduleTests(TemporaryEnvironment): class ScheduleTests(TemporaryEnvironment):
...@@ -502,6 +532,32 @@ class WebTests(TemporaryEnvironment): ...@@ -502,6 +532,32 @@ class WebTests(TemporaryEnvironment):
self.assertIn('新的采集记录', html) self.assertIn('新的采集记录', html)
self.assertIn('未经过新版正文质量核验', html) self.assertIn('未经过新版正文质量核验', html)
def test_history_lists_runs_and_shows_one_run_detail(self):
first = result_module.normalize(envelope([self.make_article()]), 'sample', 1, 0,
self.base, self.base)
first.update(provider='primary', duration_ms=65000)
second = result_module.normalize(envelope([self.make_article()]), 'sample', 1, 0,
self.base, self.base)
second.update(provider='fallback', duration_ms=2000)
(self.logs / 'sample.20260907.090000.json').write_text(json.dumps(first))
latest_name = 'sample.continue.20260908.101500.json'
(self.logs / latest_name).write_text(json.dumps(second))
html = self.client.get('/source/sample').get_data(as_text=True)
self.assertIn('历史执行记录', html)
self.assertIn('2026-09-08 10:15:00', html)
self.assertIn('续采 / 修复', html)
self.assertIn('1m05s', html)
self.assertIn(f'/source/sample/history/{latest_name}', html)
response = self.client.get(f'/source/sample/history/{latest_name}')
self.assertEqual(response.status_code, 200)
history_html = response.get_data(as_text=True)
self.assertIn('历史执行详情', history_html)
self.assertIn('fallback', history_html)
self.assertIn('成功 / 失败 / 目标', history_html)
self.assertEqual(self.client.get('/source/sample/history/not-a-run.json').status_code, 404)
def test_forms_accept_many_times_reject_invalid(self): def test_forms_accept_many_times_reject_invalid(self):
with patch.object(self.web, 'run_script', return_value=(0, '', '')) as run: with patch.object(self.web, 'run_script', return_value=(0, '', '')) as run:
self.assertEqual(self.client.post('/add', data={'source': 'sample', 'time': '09:00,20:00'}).status_code, 302) self.assertEqual(self.client.post('/add', data={'source': 'sample', 'time': '09:00,20:00'}).status_code, 302)
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
路由: 路由:
GET / List 所有 source(timer 状态 + 最近运行摘要) GET / List 所有 source(timer 状态 + 最近运行摘要)
GET /source/<name> Source 详情(timer 信息 + 日志 + JSON 元数据) GET /source/<name> Source 详情(timer 信息 + 最新结果 + 历史执行记录)
GET /source/<name>/history/<run_file> 某次历史执行详情
GET /add 添加 source 表单 GET /add 添加 source 表单
POST /add 执行 add-source.sh POST /add 执行 add-source.sh
GET /edit/<name> 编辑 source 表单(预填当前值) GET /edit/<name> 编辑 source 表单(预填当前值)
...@@ -14,7 +15,6 @@ ...@@ -14,7 +15,6 @@
""" """
import functools import functools
import glob
import json import json
import os import os
import re import re
...@@ -252,18 +252,130 @@ def parse_last_log(source): ...@@ -252,18 +252,130 @@ def parse_last_log(source):
def get_latest_json(source): def get_latest_json(source):
"""读取最新的 Claude JSON 输出文件""" """读取最新的 Claude JSON 输出文件"""
pattern = str(LOG_DIR / f"{source}.*.json") runs = get_history_runs(source, include_data=True)
files = sorted(glob.glob(pattern), key=lambda path: Path(path).stat().st_mtime_ns, reverse=True) if not runs:
if not files:
return None return None
latest = runs[0]
return {"path": str(latest["path"]), "data": latest["data"]}
def _history_filename_pattern(source):
"""返回某个 source 的历史结果文件名正则,避免路径穿越。"""
return re.compile(
rf"^{re.escape(source)}(?P<continue>\.continue)?\."
rf"(?P<date>\d{{8}})\.(?P<time>\d{{6}})\.json$"
)
def format_duration_ms(value):
"""将 Claude 返回的毫秒耗时格式化为简明文本。"""
if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0:
return "-"
total_seconds = round(value / 1000)
minutes, seconds = divmod(total_seconds, 60)
if minutes:
return f"{minutes}m{seconds:02d}s"
return f"{seconds}s"
def _history_summary(source, path, data, match):
"""从一份运行 JSON 构造历史列表的安全、紧凑摘要。"""
try: try:
with open(files[0], encoding="utf-8") as f: run_at = datetime.strptime(
data = json.load(f) match.group("date") + match.group("time"), "%Y%m%d%H%M%S"
).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
run_at = datetime.fromtimestamp(path.stat().st_mtime).strftime(
"%Y-%m-%d %H:%M:%S"
)
result = data.get("result")
if isinstance(result, dict) and result.get("schema_version") == 1:
status = result.get("status", "UNKNOWN")
succeeded_count = result.get("succeeded_count", 0)
failed_count = result.get("failed_count", 0)
requested_count = result.get("requested_count", 0)
else:
status = "FAILED" if data.get("is_error") is True else "LEGACY"
succeeded_count = failed_count = requested_count = None
return {
"filename": path.name,
"path": path,
"data": data,
"run_at": run_at,
"mode": "续采 / 修复" if match.group("continue") else "常规采集",
"status": status,
"succeeded_count": succeeded_count,
"failed_count": failed_count,
"requested_count": requested_count,
"provider": data.get("provider", "-") or "-",
"duration": format_duration_ms(data.get("duration_ms")),
}
def get_history_runs(source, include_data=False):
"""读取一个 source 的全部有效执行记录,按运行时间倒序返回。"""
pattern = _history_filename_pattern(source)
runs = []
for path in LOG_DIR.glob(f"{source}.*.json"):
if not path.is_file():
continue
match = pattern.match(path.name)
if not match:
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(data, dict): if not isinstance(data, dict):
return None continue
return {"path": files[0], "data": data} run = _history_summary(source, path, data, match)
except Exception: if not include_data:
run.pop("data")
run.pop("path")
runs.append(run)
return sorted(runs, key=lambda run: run["run_at"], reverse=True)
def get_history_run(source, filename):
"""按严格校验过的文件名读取一条历史运行记录。"""
if not _history_filename_pattern(source).match(filename):
return None
path = LOG_DIR / filename
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None return None
if not isinstance(data, dict):
return None
return _history_summary(source, path, data, _history_filename_pattern(source).match(filename))
def build_json_meta(data):
"""提取详情页展示所需的 Claude 运行元数据。"""
return {
"cost": data.get("total_cost_usd", "-"),
"turns": data.get("num_turns", "-"),
"duration_api": data.get("duration_api_ms", "-"),
"session_id": data.get("session_id", "-"),
"is_error": data.get("is_error", False),
"terminal": data.get("terminal_reason", "-"),
"usage": data.get("usage", {}),
"permission_denials": data.get("permission_denials", []),
}
def get_business_result(data):
"""区分新版结构化结果和旧版文本结果。"""
value = data.get("result")
if isinstance(value, dict) and value.get("schema_version") == 1:
return value, None
if isinstance(value, str):
return None, value
return None, None
def read_conf(source): def read_conf(source):
...@@ -462,35 +574,32 @@ def source_detail(name): ...@@ -462,35 +574,32 @@ def source_detail(name):
log_info = parse_last_log(name) log_info = parse_last_log(name)
json_info = get_latest_json(name) json_info = get_latest_json(name)
# 解析 JSON 中的元数据 json_meta = build_json_meta(json_info["data"]) if json_info else None
json_meta = None business_result, legacy_result = get_business_result(json_info["data"]) if json_info else (None, None)
if json_info and json_info["data"]: history_runs = get_history_runs(name)
d = json_info["data"]
json_meta = {
"cost": d.get("total_cost_usd", "-"),
"turns": d.get("num_turns", "-"),
"duration_api": d.get("duration_api_ms", "-"),
"session_id": d.get("session_id", "-"),
"is_error": d.get("is_error", False),
"terminal": d.get("terminal_reason", "-"),
"usage": d.get("usage", {}),
"permission_denials": d.get("permission_denials", []),
}
business_result = None
legacy_result = None
if json_info:
value = json_info["data"].get("result")
if isinstance(value, dict) and value.get("schema_version") == 1:
business_result = value
elif isinstance(value, str):
legacy_result = value
return render_template("detail.html", name=name, run_time=run_time, return render_template("detail.html", name=name, run_time=run_time,
props=props, log_info=log_info, json_info=json_info, props=props, log_info=log_info, json_info=json_info,
json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result, json_meta=json_meta, conf=conf, business_result=business_result, legacy_result=legacy_result,
continue_status=continue_status, auto_refresh=auto_refresh, continue_status=continue_status, auto_refresh=auto_refresh,
providers=get_providers()) providers=get_providers(), history_runs=history_runs)
@app.route("/source/<name>/history/<run_file>")
@require_auth
def source_history_detail(name, run_file):
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
run = get_history_run(name, run_file)
if not run:
abort(404)
business_result, legacy_result = get_business_result(run["data"])
return render_template(
"history_detail.html", name=name, run=run,
json_meta=build_json_meta(run["data"]),
business_result=business_result, legacy_result=legacy_result,
)
@app.route("/add", methods=["GET", "POST"]) @app.route("/add", methods=["GET", "POST"])
......
...@@ -94,12 +94,25 @@ ...@@ -94,12 +94,25 @@
color: #888; color: #888;
border-bottom: 1px solid #2a2d37; border-bottom: 1px solid #2a2d37;
letter-spacing: 0.5px; letter-spacing: 0.5px;
white-space: nowrap;
} }
td { td {
padding: 10px 12px; padding: 10px 12px;
border-bottom: 1px solid #1e2128; border-bottom: 1px solid #1e2128;
font-size: 14px; font-size: 14px;
vertical-align: top;
overflow-wrap: break-word;
}
/* 超宽内容在容器内横向滚动,不撑破页面 */
.table-wrap { overflow-x: auto; }
/* 长标识符(如 Session ID)在表格中截断显示,悬停看全文 */
.truncate {
display: inline-block;
max-width: 18ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
vertical-align: bottom;
} }
tr:hover { background: #161922; } tr:hover { background: #161922; }
tr.ok td:first-child { border-left: 3px solid #4ec9b0; } tr.ok td:first-child { border-left: 3px solid #4ec9b0; }
...@@ -193,7 +206,7 @@ ...@@ -193,7 +206,7 @@
.card .value.red { color: #f44747; } .card .value.red { color: #f44747; }
.actions { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; } .actions { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
.session-id { font-family: monospace; font-size: 12px; color: #569cd6; } .session-id { font-family: monospace; font-size: 12px; color: #569cd6; word-break: break-all; }
a.source-link { color: #569cd6; text-decoration: none; } a.source-link { color: #569cd6; text-decoration: none; }
a.source-link:hover { text-decoration: underline; } a.source-link:hover { text-decoration: underline; }
.footer { text-align: center; color: #555; font-size: 12px; margin-top: 30px; padding: 16px; } .footer { text-align: center; color: #555; font-size: 12px; margin-top: 30px; padding: 16px; }
......
...@@ -109,6 +109,7 @@ ...@@ -109,6 +109,7 @@
{% if conf %} {% if conf %}
<h2>栏目配置 ({{ name }}.conf)</h2> <h2>栏目配置 ({{ name }}.conf)</h2>
<div class="table-wrap">
<table> <table>
<tbody> <tbody>
{% for k, v in conf.items() %} {% for k, v in conf.items() %}
...@@ -116,6 +117,7 @@ ...@@ -116,6 +117,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<h2>栏目配置</h2> <h2>栏目配置</h2>
<p style="color:#666">无独立配置,继承 <code>default.conf</code></p> <p style="color:#666">无独立配置,继承 <code>default.conf</code></p>
...@@ -132,6 +134,7 @@ ...@@ -132,6 +134,7 @@
{% endfor %} {% endfor %}
{% for warning in business_result.warnings %}<p>提示:{{ warning }}</p>{% endfor %} {% for warning in business_result.warnings %}<p>提示:{{ warning }}</p>{% endfor %}
{% if business_result.source_exhausted %}<p>已无更多新文章:{{ business_result.exhaustion_reason }}</p>{% endif %} {% if business_result.source_exhausted %}<p>已无更多新文章:{{ business_result.exhaustion_reason }}</p>{% endif %}
<div class="table-wrap">
<table> <table>
<thead><tr><th>文章</th><th>状态</th><th>正文长度</th><th>分类 / 标签</th><th>失败原因</th></tr></thead> <thead><tr><th>文章</th><th>状态</th><th>正文长度</th><th>分类 / 标签</th><th>失败原因</th></tr></thead>
<tbody> <tbody>
...@@ -156,6 +159,7 @@ ...@@ -156,6 +159,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<details><summary>查看统一 JSON</summary><pre>{{ business_result | tojson(indent=2) }}</pre></details> <details><summary>查看统一 JSON</summary><pre>{{ business_result | tojson(indent=2) }}</pre></details>
{% elif legacy_result is not none %} {% elif legacy_result is not none %}
<h2>历史采集结果</h2> <h2>历史采集结果</h2>
...@@ -250,6 +254,7 @@ ...@@ -250,6 +254,7 @@
{% if json_meta.usage %} {% if json_meta.usage %}
<h3>Token 用量</h3> <h3>Token 用量</h3>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>类型</th><th>Token 数</th></tr> <tr><th>类型</th><th>Token 数</th></tr>
...@@ -260,6 +265,7 @@ ...@@ -260,6 +265,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endif %} {% endif %}
{% if json_meta.permission_denials %} {% if json_meta.permission_denials %}
...@@ -270,4 +276,32 @@ ...@@ -270,4 +276,32 @@
<p style="color:#666;font-size:12px;margin-top:8px">来源: {{ json_info.path }}</p> <p style="color:#666;font-size:12px;margin-top:8px">来源: {{ json_info.path }}</p>
{% endif %} {% endif %}
<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 %}
{% endblock %} {% endblock %}
{% extends "base.html" %}
{% block title %}{{ name }} 历史执行 — News Harvester{% endblock %}
{% block content %}
<h1>{{ name }} · 历史执行详情</h1>
<div class="actions">
<a class="btn" href="{{ url_for('source_detail', name=name) }}">返回数据源详情</a>
</div>
<div class="cards">
<div class="card"><div class="label">运行时间</div><div class="value" style="font-size:14px">{{ run.run_at }}</div></div>
<div class="card"><div class="label">执行类型</div><div class="value" style="font-size:14px">{{ run.mode }}</div></div>
<div class="card"><div class="label">结果状态</div><div class="value {{ 'green' if run.status == 'SUCCESS' else 'red' if run.status == 'FAILED' else '' }}">{{ '成功' if run.status == 'SUCCESS' else '失败' if run.status == 'FAILED' else '旧版记录' if run.status == 'LEGACY' else run.status }}</div></div>
<div class="card"><div class="label">Provider</div><div class="value" style="font-size:14px">{{ run.provider }}</div></div>
<div class="card"><div class="label">耗时</div><div class="value">{{ run.duration }}</div></div>
</div>
{% if business_result %}
<h2>采集结果</h2>
<div class="cards">
<div class="card"><div class="label">成功 / 失败 / 目标</div><div class="value">{{ business_result.succeeded_count }} / {{ business_result.failed_count }} / {{ business_result.requested_count }}</div></div>
<div class="card"><div class="label">实际尝试</div><div class="value">{{ business_result.attempted_count }}</div></div>
</div>
{% for failure in business_result.failures %}
<p style="color:#f44747"><strong>{{ failure.type }}</strong> · {{ failure.message }}{% if failure.url %}({{ failure.url }}){% endif %}</p>
{% endfor %}
{% for warning in business_result.warnings %}<p>提示:{{ warning }}</p>{% endfor %}
{% if business_result.source_exhausted %}<p>已无更多新文章:{{ business_result.exhaustion_reason }}</p>{% endif %}
<div class="table-wrap">
<table>
<thead><tr><th>文章</th><th>状态</th><th>正文长度</th><th>失败原因</th></tr></thead>
<tbody>
{% for article in business_result.articles %}
{% set archive = article.archive if article.archive is defined else {} %}
<tr>
<td>{% if article.url.startswith('https://') or article.url.startswith('http://') %}<a class="source-link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">{{ article.title or article.url }}</a>{% else %}{{ article.title }}{% endif %}
<p style="color:#888;font-size:12px;margin:6px 0 0">归档:{{ article.archive_path or '未归档' }}</p></td>
<td><span class="badge {{ 'ok' if article.status == 'SUCCESS' else 'fail' }}">{{ '成功' if article.status == 'SUCCESS' else '失败' }}</span></td>
<td>{% if archive.get('body_length') is not none %}{{ archive.body_length }} 字符{% elif article.body_length is defined %}{{ article.body_length }} 字符{% else %}—{% endif %}</td>
<td>{{ article.failure_type or '—' }}{% if article.failure_reason %}<br>{{ article.failure_reason }}{% endif %}{% for evidence in article.quality_evidence %}{% if evidence != article.failure_reason %}<br>{{ evidence }}{% endif %}{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<details><summary>查看统一 JSON</summary><pre>{{ business_result | tojson(indent=2) }}</pre></details>
{% elif legacy_result is not none %}
<h2>采集结果</h2>
<p>此记录使用旧版文本格式,未经过新版正文质量核验。</p>
<pre>{{ legacy_result }}</pre>
{% else %}
<h2>采集结果</h2>
<p style="color:#666">该记录不包含可展示的业务结果。</p>
{% endif %}
<h2>运行元数据</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">Terminal</div><div class="value" style="font-size:14px">{{ json_meta.terminal }}</div></div>
</div>
{% 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">来源: {{ run.filename }}</p>
{% endblock %}
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
</div> </div>
<h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1> <h1>栏目列表 <span style="font-size:14px;color:#666">({{ sources|length }} 个)</span></h1>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -33,8 +34,7 @@ ...@@ -33,8 +34,7 @@
<th>状态</th> <th>状态</th>
<th>运行时间</th> <th>运行时间</th>
<th>Provider</th> <th>Provider</th>
<th>上次触发</th> <th>上次 / 下次触发</th>
<th>下次触发</th>
<th>最近运行</th> <th>最近运行</th>
<th>成本</th> <th>成本</th>
<th>Session</th> <th>Session</th>
...@@ -69,18 +69,14 @@ ...@@ -69,18 +69,14 @@
<span style="font-size:11px;color:#888" title="备用 provider(继承 default.conf 全局默认)">→ {{ default_fallback }}</span> <span style="font-size:11px;color:#888" title="备用 provider(继承 default.conf 全局默认)">→ {{ default_fallback }}</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ s.last }}</td> <td>
<td>{{ s.next }}</td> <span style="color:#666;font-size:12px">上次</span> {{ s.last }}<br>
<span style="color:#666;font-size:12px">下次</span> {{ s.next }}
</td>
<td> <td>
{% if s.log %} {% if s.log %}
{{ s.log.mtime }} · {% set status_text = s.log.get('status', '') %}
{% if '成功' in s.log.get('status', '') %} {{ s.log.mtime }}{% if status_text and '成功' not in status_text and '失败' not in status_text %} · {{ status_text }}{% endif %}
<span class="badge ok">成功</span>
{% elif '失败' in s.log.get('status', '') %}
<span class="badge fail">失败</span>
{% else %}
{{ s.log.get('status', '?') }}
{% endif %}
{% else %} {% else %}
<span style="color:#666">—</span> <span style="color:#666">—</span>
{% endif %} {% endif %}
...@@ -96,7 +92,7 @@ ...@@ -96,7 +92,7 @@
</td> </td>
<td> <td>
{% if s.log and s.log.get('session') %} {% if s.log and s.log.get('session') %}
<span class="session-id" title="{{ s.log.session }}">{{ s.log.session }}</span> <span class="session-id truncate" title="{{ s.log.session }}">{{ s.log.session }}</span>
{% else %} {% else %}
<span style="color:#666">—</span> <span style="color:#666">—</span>
{% endif %} {% endif %}
...@@ -109,8 +105,10 @@ ...@@ -109,8 +105,10 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<h2>每日汇总</h2> <h2>每日汇总</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -146,5 +144,6 @@ ...@@ -146,5 +144,6 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
<div class="flash error">JSON 解析失败: {{ provider.error }}</div> <div class="flash error">JSON 解析失败: {{ provider.error }}</div>
{% else %} {% else %}
<h2>环境变量配置 <span style="font-size:12px;color:#666">(敏感值已脱敏)</span></h2> <h2>环境变量配置 <span style="font-size:12px;color:#666">(敏感值已脱敏)</span></h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -35,6 +36,7 @@ ...@@ -35,6 +36,7 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
</div>
<p style="color:#666;font-size:12px;margin-top:12px"> <p style="color:#666;font-size:12px;margin-top:12px">
此 provider 在 Claude 启动时通过 <code>--settings '{"env": {...}}'</code> 注入。 此 provider 在 Claude 启动时通过 <code>--settings '{"env": {...}}'</code> 注入。
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
<code>default</code> 或未设置 = 使用系统全局配置(不加 --settings)。 <code>default</code> 或未设置 = 使用系统全局配置(不加 --settings)。
</p> </p>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -52,5 +53,6 @@ ...@@ -52,5 +53,6 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% 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