diff --git a/config/config.yaml b/config/config.yaml index b743c7c76a447..bb5dddf5d07a8 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -395,6 +395,12 @@ ai: max_tokens: 5000 # 最大生成 token 数 # 注意:如果 API 不支持此参数(报 HTTP 400),请设为 0 以禁用发送 + + # 推理强度(仅推理型模型有效,如 gpt-5 / o 系列 / grok;普通模型请留空) + # 留空 = 不发送该参数 + # 可选值: minimal / low / medium / high + reasoning_effort: "" + # 高级选项 num_retries: 1 # 失败重试次数 fallback_models: [] # 备用模型列表(可选) diff --git a/docker/docker-compose-build.yml b/docker/docker-compose-build.yml index bed6d4549e0c0..ad52f9132829f 100644 --- a/docker/docker-compose-build.yml +++ b/docker/docker-compose-build.yml @@ -12,6 +12,9 @@ services: volumes: - ../config:/app/config:ro - ../output:/app/output + - ../trendradar:/app/trendradar + - ../docker/manage.py:/app/manage.py + - ../docker/entrypoint.sh:/entrypoint.sh:ro environment: - TZ=Asia/Shanghai diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7948f3a298077..10f43748bb82d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,6 +10,9 @@ services: volumes: - ../config:/app/config:ro - ../output:/app/output + - ../trendradar:/app/trendradar + - ../docker/manage.py:/app/manage.py + - ../docker/entrypoint.sh:/entrypoint.sh:ro environment: - TZ=Asia/Shanghai diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100644 new mode 100755 index a14de73ef6c82..fdf4b6eb0209a --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -31,16 +31,16 @@ case "${RUN_MODE:-cron}" in exit 1 fi + # 先启动 Web 服务器,避免 IMMEDIATE_RUN 抓取阻塞面板访问 + echo "🌐 启动 Web 服务器..." + python manage.py start_webserver + # 立即执行一次(如果配置了) if [ "${IMMEDIATE_RUN:-false}" = "true" ]; then echo "▶️ 立即执行一次" python -m trendradar fi - # 启动 Web 服务器 - echo "🌐 启动 Web 服务器..." - python manage.py start_webserver - echo "⏰ 启动supercronic: $CRON_EXPR" echo "🎯 supercronic 将作为 PID 1 运行" diff --git a/docker/manage.py b/docker/manage.py index d58685c4006bf..7f7b7268394af 100644 --- a/docker/manage.py +++ b/docker/manage.py @@ -454,7 +454,10 @@ def _is_expected_webserver_process(pid: int) -> bool: cmdline = _read_proc_cmdline(pid) if not cmdline: return False - return "http.server" in cmdline and str(WEBSERVER_PORT) in cmdline + return ( + str(WEBSERVER_PORT) in cmdline + and ("http.server" in cmdline or "trendradar.web_control" in cmdline) + ) def _terminate_webserver_process(pid: int, require_expected: bool = True) -> bool: @@ -531,7 +534,7 @@ def _cleanup_stale_pid(): def start_webserver(): """启动 Web 服务器托管 output 目录""" print(f"🌐 启动 Web 服务器 (端口: {WEBSERVER_PORT})...") - print(f" 🔒 安全提示:仅提供静态文件访问,限制在 {WEBSERVER_DIR} 目录") + print(f" 🔒 报告目录: {WEBSERVER_DIR};控制面板可手动抓取 / 分析") # 检查是否已经运行 if Path(WEBSERVER_PID_FILE).exists(): @@ -564,12 +567,21 @@ def start_webserver(): # 启动 HTTP 服务器 # 使用 --bind 绑定到 0.0.0.0 使容器内部可访问 # 工作目录限制在 WEBSERVER_DIR,防止访问其他目录 + here = Path(__file__).resolve().parent + project_root = str(here if (here / "trendradar").is_dir() else here.parent) process = subprocess.Popen( - [sys.executable, '-m', 'http.server', str(WEBSERVER_PORT), '--bind', '0.0.0.0'], - cwd=WEBSERVER_DIR, + [ + sys.executable, + "-m", + "trendradar.web_control", + str(WEBSERVER_PORT), + WEBSERVER_DIR, + project_root, + ], + cwd=project_root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True + start_new_session=True, ) # 等待一下确保服务器启动 @@ -581,7 +593,8 @@ def start_webserver(): with open(WEBSERVER_PID_FILE, 'w') as f: f.write(str(process.pid)) print(f" ✅ Web 服务器已启动 (PID: {process.pid})") - print(f" 📁 服务目录: {WEBSERVER_DIR} (只读,仅静态文件)") + print(f" 📁 服务目录: {WEBSERVER_DIR}") + print(f" 🎛️ 控制面板: 报告页顶部可手动抓取 / AI 分析 / 切换预设") print(f" 🌐 访问地址: http://localhost:{WEBSERVER_PORT}") print(f" 📄 首页: http://localhost:{WEBSERVER_PORT}/index.html") print(" 💡 停止服务: python manage.py stop_webserver") diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py new file mode 100644 index 0000000000000..89191383b4745 --- /dev/null +++ b/tests/test_ai_client.py @@ -0,0 +1,79 @@ +# coding=utf-8 +import unittest +from unittest.mock import patch + + +def _fake_response(): + class Message: + content = "ok" + + class Choice: + message = Message() + + class Resp: + choices = [Choice()] + + return Resp() + + +class AIClientParamTests(unittest.TestCase): + def _capture(self, config, kwargs=None): + from trendradar.ai import client as client_mod + + captured = {} + + def fake_completion(**params): + captured.update(params) + return _fake_response() + + with patch.object(client_mod, "completion", fake_completion): + client = client_mod.AIClient(config) + client.chat([{"role": "user", "content": "hi"}], **(kwargs or {})) + return captured + + def test_reasoning_effort_sent_when_set(self): + # openai/ 前缀(含自定义兼容端点)走 extra_body 透传,避免 litellm 白名单校验 + params = self._capture( + {"MODEL": "openai/custom-reasoner", "API_KEY": "sk-x", "REASONING_EFFORT": " HIGH "} + ) + self.assertEqual(params["extra_body"]["reasoning_effort"], "high") + self.assertNotIn("reasoning_effort", params) + + def test_reasoning_effort_top_level_for_other_providers(self): + # 非 openai 提供商走顶层参数,由 litellm 完成跨商映射 + params = self._capture( + {"MODEL": "anthropic/claude-sonnet-4", "API_KEY": "sk-x", "REASONING_EFFORT": "high"} + ) + self.assertEqual(params["reasoning_effort"], "high") + self.assertNotIn("extra_body", params) + + def test_reasoning_effort_omitted_when_empty(self): + params = self._capture( + {"MODEL": "openai/gpt-4o", "API_KEY": "sk-x", "REASONING_EFFORT": ""} + ) + self.assertNotIn("reasoning_effort", params) + self.assertNotIn("extra_body", params) + + def test_reasoning_effort_overridable_per_call(self): + params = self._capture( + {"MODEL": "openai/gpt-5", "API_KEY": "sk-x", "REASONING_EFFORT": "low"}, + {"reasoning_effort": "high"}, + ) + self.assertEqual(params["extra_body"]["reasoning_effort"], "high") + + def test_extra_params_merged_without_override(self): + params = self._capture( + { + "MODEL": "openai/gpt-4o", + "API_KEY": "sk-x", + "TEMPERATURE": 0.5, + "EXTRA_PARAMS": {"top_p": 0.9, "temperature": 2.0}, + } + ) + self.assertEqual(params["top_p"], 0.9) + # 显式配置的 temperature 优先于 extra_params + self.assertEqual(params["temperature"], 0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_control.py b/tests/test_web_control.py new file mode 100644 index 0000000000000..a610e53c39295 --- /dev/null +++ b/tests/test_web_control.py @@ -0,0 +1,414 @@ +# coding=utf-8 +import json +import os +import threading +import unittest +from http.client import HTTPConnection +from http.server import ThreadingHTTPServer +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import MagicMock, patch + + +class ParseJobProgressTests(unittest.TestCase): + def test_parse_platform_and_rss_progress(self): + from trendradar.web_control import parse_job_progress + + log = """ +配置的监控平台: ['今日头条', '百度热搜', '微博'] +开始爬取数据,请求间隔 2000 毫秒 +正在获取 今日头条(1/3)... +获取 toutiao 成功(缓存数据) +正在获取 百度热搜(2/3)... +获取 baidu 成功(缓存数据) +正在获取 微博(3/3)... +获取 weibo 成功(缓存数据) +成功: ['toutiao', 'baidu', 'weibo'], 失败: [] +[RSS] 开始抓取 2 个 RSS 源... +[RSS] 正在获取 Hacker News(1/2)... +[RSS] Hacker News: 获取 20 条 +""" + progress = parse_job_progress(log, mode="crawl") + self.assertEqual(progress["phase"], "rss") + self.assertIn("Hacker News", progress["message"]) + self.assertEqual(progress["platforms_total"], 3) + self.assertEqual(progress["rss_total"], 2) + self.assertGreaterEqual(progress["percent"], 50) + self.assertLess(progress["percent"], 100) + + def test_parse_ai_phase(self): + from trendradar.web_control import parse_job_progress + + progress = parse_job_progress("[AI] 正在进行 AI 分析...\n", mode="analyze") + self.assertEqual(progress["phase"], "ai") + self.assertIn("AI", progress["message"]) + self.assertGreaterEqual(progress["percent"], 40) + + +class WebControlApiTests(unittest.TestCase): + def setUp(self): + from trendradar.web_control import ControlState, make_handler + + self.tmp = TemporaryDirectory() + self.output = Path(self.tmp.name) + (self.output / "index.html").write_text("
report", encoding="utf-8") + + # 模拟项目根目录:config/frequency_words.txt + config/config.yaml + self.project_root = self.output / "project" + self.config_dir = self.project_root / "config" + self.config_dir.mkdir(parents=True) + (self.config_dir / "frequency_words.txt").write_text( + "[GLOBAL_FILTER]\n震惊\n\n[WORD_GROUPS]\n华为\n", encoding="utf-8" + ) + (self.config_dir / "config.yaml").write_text( + "rss:\n" + " enabled: true\n" + " feeds:\n" + " - id: hacker-news\n" + " name: Hacker News\n" + " url: https://hnrss.org/frontpage\n" + "ai:\n" + " model: deepseek/deepseek-v4-flash\n" + " api_key: \"\"\n" + " api_base: \"\"\n", + encoding="utf-8", + ) + + self.runner = MagicMock() + self.runner.is_running.return_value = False + self.runner.status.return_value = { + "running": False, + "mode": None, + "error": None, + "progress": {"phase": "idle", "message": "", "percent": 0}, + "log_lines": 0, + } + self.runner.read_log.return_value = { + "text": "hello log", + "lines": 1, + "tail": 1, + "path": ".webui-job.log", + "exists": True, + } + self.state = ControlState( + output_dir=self.output, runner=self.runner, project_root=self.project_root + ) + + handler = make_handler(self.state) + self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.port = self.server.server_address[1] + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.tmp.cleanup() + + def _request(self, method, path, body=None): + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + payload = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Content-Type": "application/json"} if payload else {} + conn.request(method, path, body=payload, headers=headers) + resp = conn.getresponse() + data = resp.read() + conn.close() + return resp.status, resp.getheader("Content-Type"), data + + def test_root_serves_index_and_injects_toolbar(self): + status, content_type, data = self._request("GET", "/") + html = data.decode("utf-8") + self.assertEqual(status, 200) + self.assertIn("text/html", content_type) + self.assertIn("tr-toolbar", html) + self.assertIn("tr-progress-wrap", html) + self.assertIn("tr-log-panel", html) + self.assertIn("report", html) + + def test_get_settings_contains_presets_and_ai_flag(self): + status, _, data = self._request("GET", "/api/settings") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertIn("ai_analysis_enabled", payload) + ids = [item["id"] for item in payload["presets"]] + self.assertIn("morning_evening", ids) + self.assertIn("off", ids) + + def test_post_settings_persists_overlay(self): + status, _, data = self._request( + "POST", + "/api/settings", + {"ai_analysis_enabled": False, "schedule_preset": "office_hours"}, + ) + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertFalse(payload["ai_analysis_enabled"]) + self.assertEqual(payload["schedule_preset"], "office_hours") + self.assertTrue(payload["schedule_enabled"]) + + _, _, again = self._request("GET", "/api/settings") + self.assertEqual(json.loads(again)["schedule_preset"], "office_hours") + + def test_post_run_crawl_starts_job(self): + status, _, data = self._request("POST", "/api/run", {"mode": "crawl"}) + payload = json.loads(data) + self.assertEqual(status, 202) + self.assertEqual(payload["mode"], "crawl") + self.runner.start.assert_called_once_with("crawl") + + def test_post_run_analyze_starts_job(self): + status, _, data = self._request("POST", "/api/run", {"mode": "analyze"}) + self.assertEqual(status, 202) + self.assertEqual(json.loads(data)["mode"], "analyze") + self.runner.start.assert_called_once_with("analyze") + + def test_post_run_rejects_when_busy(self): + self.runner.is_running.return_value = True + status, _, data = self._request("POST", "/api/run", {"mode": "crawl"}) + self.assertEqual(status, 409) + self.assertIn("running", json.loads(data)["error"]) + + def test_get_logs_returns_text_and_progress(self): + status, _, data = self._request("GET", "/api/logs?tail=100") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["text"], "hello log") + self.assertIn("progress", payload) + self.runner.read_log.assert_called() + # tail query should be forwarded + args, kwargs = self.runner.read_log.call_args + self.assertEqual(kwargs.get("tail") or (args[0] if args else None), 100) + + def test_get_topics_reads_file_when_no_overlay(self): + status, _, data = self._request("GET", "/api/topics") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "file") + self.assertIn("[WORD_GROUPS]", payload["content"]) + self.assertIn("华为", payload["content"]) + self.assertGreaterEqual(payload["group_count"], 1) + + def test_post_and_delete_topics(self): + content = "[WORD_GROUPS]\nwebui词\n" + status, _, data = self._request("POST", "/api/topics", {"content": content}) + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "overlay") + self.assertIn("webui词", payload["content"]) + self.assertEqual(payload["group_count"], 1) + + _, _, again = self._request("GET", "/api/topics") + self.assertEqual(json.loads(again)["source"], "overlay") + + status, _, data = self._request("DELETE", "/api/topics") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "file") + self.assertIn("华为", payload["content"]) + + def test_post_topics_rejects_non_string(self): + status, _, data = self._request("POST", "/api/topics", {"content": 123}) + self.assertEqual(status, 400) + self.assertIn("error", json.loads(data)) + + def test_get_feeds_reads_yaml_when_no_overlay(self): + status, _, data = self._request("GET", "/api/feeds") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "config") + self.assertTrue(payload["rss_enabled"]) + self.assertEqual(len(payload["feeds"]), 1) + self.assertEqual(payload["feeds"][0]["id"], "hacker-news") + self.assertTrue(payload["feeds"][0]["enabled"]) + + def test_post_and_delete_feeds(self): + body = { + "rss_enabled": False, + "feeds": [ + {"name": "My Feed", "url": "https://example.com/rss.xml"}, + { + "id": "second", + "name": "Second", + "url": "https://example.org/feed", + "enabled": False, + "max_age_days": 5, + }, + ], + } + status, _, data = self._request("POST", "/api/feeds", body) + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "overlay") + self.assertFalse(payload["rss_enabled"]) + self.assertEqual(payload["feeds"][0]["id"], "my-feed") + self.assertEqual(payload["feeds"][1]["max_age_days"], 5) + + # overlay 应被 apply_overlay 应用到运行配置 + from trendradar.webui_settings import apply_overlay + + config = { + "RSS": { + "ENABLED": True, + "FEEDS": [{"id": "old", "name": "旧", "url": "https://old.example/rss"}], + } + } + apply_overlay(config, output_dir=self.output) + self.assertFalse(config["RSS"]["ENABLED"]) + self.assertEqual(len(config["RSS"]["FEEDS"]), 2) + + status, _, data = self._request("DELETE", "/api/feeds") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "config") + self.assertEqual(payload["feeds"][0]["id"], "hacker-news") + + def test_post_feeds_rejects_invalid(self): + status, _, data = self._request( + "POST", "/api/feeds", {"rss_enabled": True, "feeds": [{"name": "x", "url": "not-a-url"}]} + ) + self.assertEqual(status, 400) + self.assertIn("error", json.loads(data)) + + def _clean_ai_env(self): + return patch.dict( + os.environ, {"AI_API_KEY": "", "AI_MODEL": "", "AI_API_BASE": ""}, clear=False + ) + + def test_get_ai_reads_yaml_when_no_overlay(self): + with self._clean_ai_env(): + status, _, data = self._request("GET", "/api/ai") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "config") + self.assertEqual(payload["model"], "deepseek/deepseek-v4-flash") + self.assertFalse(payload["api_key_set"]) + self.assertIsNone(payload["api_key_masked"]) + self.assertEqual(payload["reasoning_effort"], "") + # 默认不返回明文密钥字段 + self.assertNotIn("api_key", payload) + + def test_post_and_delete_ai(self): + with self._clean_ai_env(): + status, _, data = self._request( + "POST", + "/api/ai", + { + "model": "openai/gpt-4o", + "api_base": "", + "api_key": "sk-test-1234567890", + "reasoning_effort": "high", + }, + ) + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "overlay") + self.assertTrue(payload["api_key_set"]) + self.assertEqual(payload["api_key_masked"], "sk-***7890") + self.assertEqual(payload["reasoning_effort"], "high") + # 明文密钥绝不能出现在响应里 + self.assertNotIn("sk-test-1234567890", data.decode("utf-8")) + + # 密钥留空再次保存:密钥保留,仅更新模型 + status, _, data = self._request( + "POST", + "/api/ai", + {"model": "deepseek/deepseek-v4-pro", "api_base": "", "api_key": "", "reasoning_effort": ""}, + ) + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["model"], "deepseek/deepseek-v4-pro") + self.assertEqual(payload["reasoning_effort"], "") + self.assertTrue(payload["api_key_set"]) + self.assertNotIn("sk-test-1234567890", data.decode("utf-8")) + + # overlay 应覆盖运行配置中的 AI 设置 + from trendradar.webui_settings import apply_overlay + + config = {"AI": {"MODEL": "old", "API_KEY": "old", "API_BASE": "", "REASONING_EFFORT": ""}} + apply_overlay(config, output_dir=self.output) + self.assertEqual(config["AI"]["MODEL"], "deepseek/deepseek-v4-pro") + self.assertEqual(config["AI"]["API_KEY"], "sk-test-1234567890") + self.assertEqual(config["AI"]["REASONING_EFFORT"], "") + + status, _, data = self._request("DELETE", "/api/ai") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["source"], "config") + self.assertFalse(payload["api_key_set"]) + + def test_get_ai_reveal_returns_plaintext_on_request(self): + with self._clean_ai_env(): + self._request( + "POST", "/api/ai", {"model": "m", "api_base": "", "api_key": "sk-reveal-123456", "reasoning_effort": ""} + ) + # 普通GET:无明文 + _, _, data = self._request("GET", "/api/ai") + self.assertNotIn("sk-reveal-123456", data.decode("utf-8")) + # reveal=1:返回明文(面板“显示”按钮查看已保存密钥) + status, _, data = self._request("GET", "/api/ai?reveal=1") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertEqual(payload["api_key"], "sk-reveal-123456") + self._request("DELETE", "/api/ai") + + def test_post_ai_rejects_invalid(self): + with self._clean_ai_env(): + status, _, data = self._request("POST", "/api/ai", {"model": " ", "api_key": "sk-x"}) + self.assertEqual(status, 400) + self.assertIn("model", json.loads(data)["error"]) + status, _, data = self._request( + "POST", "/api/ai", {"model": "m", "api_base": "ftp://x"} + ) + self.assertEqual(status, 400) + self.assertIn("api_base", json.loads(data)["error"]) + status, _, data = self._request( + "POST", "/api/ai", {"model": "m", "reasoning_effort": "extreme"} + ) + self.assertEqual(status, 400) + self.assertIn("reasoning_effort", json.loads(data)["error"]) + + def test_toolbar_contains_config_modal(self): + status, _, data = self._request("GET", "/") + html = data.decode("utf-8") + self.assertEqual(status, 200) + self.assertIn("tr-config-overlay", html) + self.assertIn("tr-topics-text", html) + self.assertIn("tr-feeds-list", html) + self.assertIn("tr-panel-ai", html) + self.assertIn("tr-ai-key", html) + + def test_get_status_includes_progress_field(self): + self.runner.status.return_value = { + "running": True, + "mode": "crawl", + "error": None, + "progress": {"phase": "platforms", "message": "正在抓取热榜:微博(3/11)", "percent": 40}, + "log_lines": 12, + } + status, _, data = self._request("GET", "/api/status") + payload = json.loads(data) + self.assertEqual(status, 200) + self.assertTrue(payload["running"]) + self.assertEqual(payload["progress"]["percent"], 40) + self.assertIn("微博", payload["progress"]["message"]) + + +class JobRunnerLogTests(unittest.TestCase): + def test_read_log_tail(self): + from trendradar.web_control import JobRunner + + with TemporaryDirectory() as tmp: + output = Path(tmp) + runner = JobRunner(project_root=tmp, output_dir=output) + log_path = output / ".webui-job.log" + log_path.write_text("\n".join(f"line-{i}" for i in range(1, 21)), encoding="utf-8") + payload = runner.read_log(tail=5) + self.assertTrue(payload["exists"]) + self.assertEqual(payload["lines"], 20) + self.assertIn("line-20", payload["text"]) + self.assertIn("已省略前 15 行", payload["text"]) + self.assertNotIn("line-1\n", payload["text"].split("…", 1)[-1] if "…" in payload["text"] else payload["text"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webui_settings.py b/tests/test_webui_settings.py new file mode 100644 index 0000000000000..e18c0c3ef5a38 --- /dev/null +++ b/tests/test_webui_settings.py @@ -0,0 +1,299 @@ +# coding=utf-8 +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +class WebuiSettingsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.output = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_missing_overlay_returns_empty_dict(self): + from trendradar.webui_settings import load_overlay + + self.assertEqual(load_overlay(self.output), {}) + + def test_save_and_load_roundtrip(self): + from trendradar.webui_settings import load_overlay, save_overlay + + saved = save_overlay( + { + "ai_analysis_enabled": False, + "schedule_enabled": True, + "schedule_preset": "morning_evening", + }, + self.output, + ) + self.assertFalse(saved["ai_analysis_enabled"]) + self.assertEqual(load_overlay(self.output)["schedule_preset"], "morning_evening") + self.assertTrue((self.output / ".webui.json").exists()) + + def test_rejects_unknown_preset(self): + from trendradar.webui_settings import save_overlay + + with self.assertRaises(ValueError): + save_overlay({"schedule_preset": "not-a-preset"}, self.output) + + def test_apply_overlay_overrides_yaml_and_env(self): + from trendradar.webui_settings import apply_overlay, save_overlay + + save_overlay( + { + "ai_analysis_enabled": False, + "schedule_enabled": True, + "schedule_preset": "office_hours", + }, + self.output, + ) + config = { + "AI_ANALYSIS": {"ENABLED": True}, + "SCHEDULE": {"enabled": False, "preset": "always_on"}, + } + apply_overlay(config, output_dir=self.output) + self.assertFalse(config["AI_ANALYSIS"]["ENABLED"]) + self.assertTrue(config["SCHEDULE"]["enabled"]) + self.assertEqual(config["SCHEDULE"]["preset"], "office_hours") + + def test_one_shot_env_beats_overlay(self): + from trendradar.webui_settings import apply_overlay, save_overlay + + save_overlay({"ai_analysis_enabled": False}, self.output) + config = {"AI_ANALYSIS": {"ENABLED": False}, "SCHEDULE": {"enabled": False, "preset": "always_on"}} + with patch.dict(os.environ, {"WEBUI_RUN_AI": "true"}, clear=False): + apply_overlay(config, output_dir=self.output) + self.assertTrue(config["AI_ANALYSIS"]["ENABLED"]) + + def test_preset_off_disables_schedule(self): + from trendradar.webui_settings import save_overlay + + saved = save_overlay({"schedule_preset": "off"}, self.output) + self.assertFalse(saved["schedule_enabled"]) + self.assertEqual(saved["schedule_preset"], "off") + + +class WebuiSettingsTopicsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.output = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_save_and_load_frequency_words(self): + from trendradar.webui_settings import load_overlay, save_overlay + + content = "[GLOBAL_FILTER]\n震惊\n\n[WORD_GROUPS]\n华为\n/抖音|TikTok/ => 字节跳动\n" + save_overlay({"frequency_words": content}, self.output) + self.assertEqual(load_overlay(self.output)["frequency_words"], content) + + def test_rejects_non_string_frequency_words(self): + from trendradar.webui_settings import save_overlay + + with self.assertRaises(ValueError): + save_overlay({"frequency_words": ["not", "a", "string"]}, self.output) + + def test_remove_overlay_keys_restores_file_behavior(self): + from trendradar.webui_settings import load_overlay, remove_overlay_keys, save_overlay + + save_overlay({"frequency_words": "华为", "ai_analysis_enabled": False}, self.output) + remove_overlay_keys(["frequency_words"], self.output) + data = load_overlay(self.output) + self.assertNotIn("frequency_words", data) + self.assertFalse(data["ai_analysis_enabled"]) + + +class WebuiSettingsFeedsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.output = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_save_valid_feeds_normalized(self): + from trendradar.webui_settings import load_overlay, save_overlay + + save_overlay( + { + "rss": { + "enabled": True, + "feeds": [ + {"name": "Hacker News", "url": "https://hnrss.org/frontpage"}, + { + "id": "ruanyifeng", + "name": "阮一峰", + "url": "http://www.ruanyifeng.com/blog/atom.xml", + "enabled": False, + "max_age_days": "3", + }, + ], + } + }, + self.output, + ) + feeds = load_overlay(self.output)["rss"]["feeds"] + self.assertEqual(feeds[0]["id"], "hacker-news") + self.assertTrue(feeds[0]["enabled"]) + self.assertNotIn("max_age_days", feeds[0]) + self.assertEqual(feeds[1]["max_age_days"], 3) + self.assertFalse(feeds[1]["enabled"]) + + def test_chinese_name_falls_back_to_url_host(self): + from trendradar.webui_settings import save_overlay, load_overlay + + save_overlay( + { + "rss": { + "feeds": [ + {"name": "阮一峰", "url": "http://www.ruanyifeng.com/blog/atom.xml"}, + {"name": "阮一峰备份", "url": "http://www.ruanyifeng.com/blog/feed.xml"}, + ], + } + }, + self.output, + ) + feeds = load_overlay(self.output)["rss"]["feeds"] + self.assertEqual(feeds[0]["id"], "ruanyifeng") + self.assertEqual(feeds[1]["id"], "ruanyifeng-2") + + def test_rejects_bad_url_and_duplicate_ids(self): + from trendradar.webui_settings import save_overlay + + with self.assertRaises(ValueError): + save_overlay( + {"rss": {"feeds": [{"id": "a", "url": "ftp://example.com/rss"}]}}, + self.output, + ) + with self.assertRaises(ValueError): + save_overlay( + { + "rss": { + "feeds": [ + {"id": "a", "url": "https://x.com/1"}, + {"id": "a", "url": "https://x.com/2"}, + ] + } + }, + self.output, + ) + + def test_rejects_unknown_rss_fields(self): + from trendradar.webui_settings import save_overlay + + with self.assertRaises(ValueError): + save_overlay({"rss": {"enabled": True, "timeout": 15}}, self.output) + + def test_apply_overlay_overrides_rss_config(self): + from trendradar.webui_settings import apply_overlay, save_overlay + + feeds = [{"id": "hn", "name": "HN", "url": "https://hnrss.org/frontpage", "enabled": True}] + save_overlay({"rss": {"enabled": False, "feeds": feeds}}, self.output) + config = { + "RSS": { + "ENABLED": True, + "FEEDS": [{"id": "old", "name": "旧", "url": "https://old.example/rss"}], + } + } + apply_overlay(config, output_dir=self.output) + self.assertFalse(config["RSS"]["ENABLED"]) + self.assertEqual(config["RSS"]["FEEDS"], feeds) + + +class WebuiSettingsAiTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.output = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_save_valid_ai(self): + from trendradar.webui_settings import load_overlay, save_overlay + + save_overlay( + {"ai": {"model": "deepseek/deepseek-v4-flash", "api_key": "sk-test-123456789", "api_base": ""}}, + self.output, + ) + data = load_overlay(self.output)["ai"] + self.assertEqual(data["model"], "deepseek/deepseek-v4-flash") + self.assertEqual(data["api_key"], "sk-test-123456789") + self.assertEqual(data["api_base"], "") + + def test_rejects_empty_model_and_bad_base(self): + from trendradar.webui_settings import save_overlay + + with self.assertRaises(ValueError): + save_overlay({"ai": {"model": " "}}, self.output) + with self.assertRaises(ValueError): + save_overlay({"ai": {"model": "m", "api_base": "ftp://x"}}, self.output) + with self.assertRaises(ValueError): + save_overlay({"ai": {"model": "m", "timeout": 5}}, self.output) + + def test_reasoning_effort_validated_and_applied(self): + from trendradar.webui_settings import apply_overlay, load_overlay, save_overlay + + with self.assertRaises(ValueError): + save_overlay({"ai": {"model": "m", "reasoning_effort": "extreme"}}, self.output) + + save_overlay({"ai": {"model": "m", "reasoning_effort": "HIGH"}}, self.output) + self.assertEqual(load_overlay(self.output)["ai"]["reasoning_effort"], "high") + + config = {"AI": {"MODEL": "m", "REASONING_EFFORT": ""}} + apply_overlay(config, output_dir=self.output) + self.assertEqual(config["AI"]["REASONING_EFFORT"], "high") + + def test_mask_secret(self): + from trendradar.webui_settings import mask_secret + + self.assertIsNone(mask_secret("")) + self.assertIsNone(mask_secret(None)) + self.assertEqual(mask_secret("short"), "***") + self.assertEqual(mask_secret("sk-1234567890abcd"), "sk-***abcd") + + def test_apply_overlay_overrides_ai(self): + from trendradar.webui_settings import apply_overlay, save_overlay + + save_overlay( + {"ai": {"model": "openai/gpt-4o", "api_key": "sk-new-key", "api_base": ""}}, + self.output, + ) + config = {"AI": {"MODEL": "old-model", "API_KEY": "old-key", "API_BASE": "https://old/v1"}} + apply_overlay(config, output_dir=self.output) + self.assertEqual(config["AI"]["MODEL"], "openai/gpt-4o") + self.assertEqual(config["AI"]["API_KEY"], "sk-new-key") + self.assertEqual(config["AI"]["API_BASE"], "") + + +class FrequencyWordsOverlayTests(unittest.TestCase): + def test_context_load_prefers_overlay(self): + from trendradar.context import AppContext + from trendradar.webui_settings import save_overlay + + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) + save_overlay({"frequency_words": "[WORD_GROUPS]\noverlay词"}, output) + config = {"STORAGE": {"LOCAL": {"DATA_DIR": str(output)}}} + ctx = AppContext(config) + groups, _filters, _global = ctx.load_frequency_words() + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["normal"][0]["word"], "overlay词") + + def test_context_load_falls_back_to_file(self): + from trendradar.context import AppContext + + with tempfile.TemporaryDirectory() as tmp: + config = {"STORAGE": {"LOCAL": {"DATA_DIR": tmp}}} + ctx = AppContext(config) + # 未设置 overlay 且指定不存在的文件时应抛 FileNotFoundError + with self.assertRaises(FileNotFoundError): + ctx.load_frequency_words("config/__no_such_file__.txt") + + +if __name__ == "__main__": + unittest.main() diff --git a/trendradar/ai/analyzer.py b/trendradar/ai/analyzer.py index 7408afd1b3d48..59cac776cd3d2 100644 --- a/trendradar/ai/analyzer.py +++ b/trendradar/ai/analyzer.py @@ -140,7 +140,9 @@ def analyze( timeout = self.ai_config.get("TIMEOUT", 120) max_tokens = self.ai_config.get("MAX_TOKENS", 5000) - print(f"[AI] 参数: timeout={timeout}, max_tokens={max_tokens}") + reasoning_effort = str(self.ai_config.get("REASONING_EFFORT") or "").strip() + effort_display = f", reasoning_effort={reasoning_effort}" if reasoning_effort else "" + print(f"[AI] 参数: timeout={timeout}, max_tokens={max_tokens}{effort_display}") if not self.client.api_key: return AIAnalysisResult( diff --git a/trendradar/ai/client.py b/trendradar/ai/client.py index a2a974e01ba55..5de873d4e672c 100644 --- a/trendradar/ai/client.py +++ b/trendradar/ai/client.py @@ -29,6 +29,8 @@ def __init__(self, config: Dict[str, Any]): - TIMEOUT: 请求超时时间(秒) - NUM_RETRIES: 重试次数(可选) - FALLBACK_MODELS: 备用模型列表(可选) + - REASONING_EFFORT: 推理强度(可选,minimal/low/medium/high,仅推理型模型支持) + - EXTRA_PARAMS: 额外请求参数(可选,优先级低于显式参数与调用 kwargs) """ self.model = config.get("MODEL", "deepseek/deepseek-chat") self.api_key = config.get("API_KEY") or os.environ.get("AI_API_KEY", "") @@ -38,6 +40,8 @@ def __init__(self, config: Dict[str, Any]): self.timeout = config.get("TIMEOUT", 120) self.num_retries = config.get("NUM_RETRIES", 2) self.fallback_models = config.get("FALLBACK_MODELS", []) + self.reasoning_effort = str(config.get("REASONING_EFFORT") or "").strip().lower() + self.extra_params = dict(config.get("EXTRA_PARAMS") or {}) def chat( self, @@ -83,6 +87,26 @@ def chat( if self.fallback_models: params["fallbacks"] = self.fallback_models + # 添加推理强度(仅推理型模型支持,未设置时不发送;可被调用参数覆盖) + reasoning_effort = str( + kwargs.get("reasoning_effort") or self.reasoning_effort or "" + ).strip() + if reasoning_effort: + if self.model.startswith("openai/"): + # openai/ 前缀多为自定义兼容端点,litellm 会按模型名做参数白名单校验, + # 自定义模型名不在白名单会抛 UnsupportedParamsError; + # 走 extra_body 原样透传(真正的 OpenAI 推理模型同样接受) + extra_body = dict(params.get("extra_body") or {}) + extra_body["reasoning_effort"] = reasoning_effort + params["extra_body"] = extra_body + else: + # 其他提供商走顶层参数,由 litellm 完成参数映射(如 anthropic 思考预算) + params["reasoning_effort"] = reasoning_effort + + # 合并 extra_params(显式参数优先) + for key, value in self.extra_params.items(): + params.setdefault(key, value) + # 合并其他额外参数 for key, value in kwargs.items(): if key not in params: diff --git a/trendradar/context.py b/trendradar/context.py index 79bc2a9819eb6..7267e2f561fbf 100644 --- a/trendradar/context.py +++ b/trendradar/context.py @@ -237,7 +237,17 @@ def is_first_crawl(self) -> bool: def load_frequency_words( self, frequency_file: Optional[str] = None ) -> Tuple[List[Dict], List[str], List[str]]: - """加载频率词配置""" + """加载频率词配置(Web UI overlay 中的主题词优先于文件)""" + from trendradar.webui_settings import load_overlay + from trendradar.core.frequency import parse_frequency_words_content + + output_dir = ( + self.config.get("STORAGE", {}).get("LOCAL", {}).get("DATA_DIR") or "output" + ) + overlay = load_overlay(output_dir) + content = overlay.get("frequency_words") + if isinstance(content, str) and content.strip(): + return parse_frequency_words_content(content) return load_frequency_words(frequency_file) def matches_word_groups( diff --git a/trendradar/core/__init__.py b/trendradar/core/__init__.py index 255439fbb8156..339d87c109e2c 100644 --- a/trendradar/core/__init__.py +++ b/trendradar/core/__init__.py @@ -10,7 +10,11 @@ get_account_at_index, ) from trendradar.core.loader import load_config -from trendradar.core.frequency import load_frequency_words, matches_word_groups +from trendradar.core.frequency import ( + load_frequency_words, + matches_word_groups, + parse_frequency_words_content, +) from trendradar.core.scheduler import Scheduler, ResolvedSchedule from trendradar.core.data import ( read_all_today_titles_from_storage, @@ -33,6 +37,7 @@ "load_config", "load_frequency_words", "matches_word_groups", + "parse_frequency_words_content", # 数据处理 "read_all_today_titles_from_storage", "read_all_today_titles", diff --git a/trendradar/core/frequency.py b/trendradar/core/frequency.py index 561741ac28455..fcf843469299c 100644 --- a/trendradar/core/frequency.py +++ b/trendradar/core/frequency.py @@ -136,6 +136,23 @@ def load_frequency_words( with open(frequency_path, "r", encoding="utf-8") as f: content = f.read() + return parse_frequency_words_content(content) + + +def parse_frequency_words_content( + content: str, +) -> Tuple[List[Dict], List[str], List[str]]: + """ + 解析频率词配置文本(与文件格式一致) + + 供需要直接解析文本内容的调用方使用(如 Web UI 的 overlay 覆盖配置)。 + + Args: + content: 频率词配置文本 + + Returns: + (词组列表, 词组内过滤词, 全局过滤词) + """ word_groups = [group.strip() for group in content.split("\n\n") if group.strip()] processed_groups = [] diff --git a/trendradar/core/loader.py b/trendradar/core/loader.py index 49e83918a106b..8757a88973943 100644 --- a/trendradar/core/loader.py +++ b/trendradar/core/loader.py @@ -270,6 +270,7 @@ def _load_ai_config(config_data: Dict) -> Dict: # LiteLLM 高级选项 "NUM_RETRIES": ai_config.get("num_retries", 2), "FALLBACK_MODELS": ai_config.get("fallback_models", []), + "REASONING_EFFORT": str(ai_config.get("reasoning_effort") or "").strip().lower(), "EXTRA_PARAMS": ai_config.get("extra_params", {}), } @@ -603,4 +604,11 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]: # 打印通知渠道配置来源 _print_notification_sources(config) + from trendradar.webui_settings import apply_overlay + + output_dir = ( + config.get("STORAGE", {}).get("LOCAL", {}).get("DATA_DIR") or "output" + ) + apply_overlay(config, output_dir=output_dir) + return config diff --git a/trendradar/crawler/fetcher.py b/trendradar/crawler/fetcher.py index 7fbc6d745fa3a..01f0700738f9f 100644 --- a/trendradar/crawler/fetcher.py +++ b/trendradar/crawler/fetcher.py @@ -172,6 +172,7 @@ def crawl_websites( failed_ids = [] domain_rules = domain_rules or {} + total = len(ids_list) for i, id_info in enumerate(ids_list): if isinstance(id_info, tuple): id_value, name = id_info @@ -180,6 +181,7 @@ def crawl_websites( name = id_value id_to_name[id_value] = name + print(f"正在获取 {name}({i + 1}/{total})...", flush=True) response, _, _ = self.fetch_data(id_info) if response: diff --git a/trendradar/crawler/rss/fetcher.py b/trendradar/crawler/rss/fetcher.py index 01addc8d9dd8b..ba968f947b996 100644 --- a/trendradar/crawler/rss/fetcher.py +++ b/trendradar/crawler/rss/fetcher.py @@ -167,7 +167,8 @@ def fetch_all(self) -> RSSData: crawl_time = now.strftime("%H:%M") crawl_date = now.strftime("%Y-%m-%d") - print(f"[RSS] 开始抓取 {len(self.feeds)} 个 RSS 源...") + total = len(self.feeds) + print(f"[RSS] 开始抓取 {total} 个 RSS 源...") for i, feed in enumerate(self.feeds): # 请求间隔(带随机波动) @@ -176,6 +177,7 @@ def fetch_all(self) -> RSSData: jitter = random.uniform(-0.2, 0.2) * interval time.sleep(interval + jitter) + print(f"[RSS] 正在获取 {feed.name}({i + 1}/{total})...", flush=True) items, error = self.fetch_feed(feed) id_to_name[feed.id] = feed.name diff --git a/trendradar/web_control.py b/trendradar/web_control.py new file mode 100644 index 0000000000000..045d63e72f100 --- /dev/null +++ b/trendradar/web_control.py @@ -0,0 +1,1442 @@ +# coding=utf-8 +"""本地 Web 控制面板:静态报告 + 手动抓取 / AI 分析 / 预设 / 进度与日志。""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import threading +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import parse_qs, urlparse + +import yaml + +from trendradar.webui_settings import ( + PRESETS, + _validate_feeds, + load_overlay, + mask_secret, + remove_overlay_keys, + save_overlay, + validate_frequency_words, +) + +TOOLBAR_MARKER = "tr-toolbar" +JOB_LOG_NAME = ".webui-job.log" +LOG_TAIL_DEFAULT = 400 +LOG_TAIL_MAX = 2000 + +# 从任务日志中识别阶段 / 当前条目 +_RE_PLATFORM_LIST = re.compile(r"配置的监控平台:\s*(\[[^\]]*\])") +_RE_FETCHING = re.compile(r"正在获取\s+(.+?)((\d+)/(\d+))") +_RE_FETCHED = re.compile(r"获取\s+(\S+)\s+成功") +_RE_FETCH_FAIL = re.compile(r"请求\s+(\S+)\s+失败") +_RE_RSS_START = re.compile(r"\[RSS\]\s*开始抓取\s+(\d+)\s+个") +_RE_RSS_FETCHING = re.compile(r"\[RSS\]\s*正在获取\s+(.+?)((\d+)/(\d+))") +_RE_RSS_DONE = re.compile(r"\[RSS\]\s*(.+?):\s*(获取\s+\d+\s*条|.+)") +_RE_AI = re.compile(r"\[AI\]\s*(.+)") +_RE_TRANSLATE = re.compile(r"\[翻译\]\s*(.+)") +_RE_PUSH = re.compile(r"\[推送\]\s*(.+)") +_RE_HTML = re.compile(r"HTML报告已生成") +_RE_CRAWL_START = re.compile(r"开始爬取数据") +_RE_SUCCESS_SUMMARY = re.compile(r"^成功:\s*") + + +def _safe_literal_list(text: str) -> List[str]: + try: + value = json.loads(text.replace("'", '"')) + if isinstance(value, list): + return [str(x) for x in value] + except (TypeError, ValueError, json.JSONDecodeError): + pass + return [] + + +def _parse_words_for_api(content: str): + """解析主题词文本用于统计预览,异常时按空配置处理。""" + try: + from trendradar.core.frequency import parse_frequency_words_content + + return parse_frequency_words_content(content) + except Exception: + return [], [], [] + + +def parse_job_progress(log_text: str, mode: Optional[str] = None) -> Dict[str, Any]: + """从任务日志解析当前阶段与进度百分比。""" + lines = [ln.strip() for ln in (log_text or "").splitlines() if ln.strip()] + phase = "starting" + message = "准备中…" + current = "" + step = 0 + total = 0 + platforms_total = 0 + rss_total = 0 + platforms_done = 0 + rss_done = 0 + weight_platform = 55 + weight_rss = 25 + weight_pipeline = 20 + + for line in lines: + m = _RE_PLATFORM_LIST.search(line) + if m: + platforms_total = max(platforms_total, len(_safe_literal_list(m.group(1)))) + continue + + if _RE_CRAWL_START.search(line): + phase = "platforms" + message = "开始抓取热榜平台" + continue + + m = _RE_FETCHING.search(line) + if m: + phase = "platforms" + current = m.group(1).strip() + step = int(m.group(2)) + total = int(m.group(3)) + platforms_total = max(platforms_total, total) + platforms_done = max(platforms_done, step - 1) + message = f"正在抓取热榜:{current}({step}/{total})" + continue + + m = _RE_FETCHED.search(line) + if m: + phase = "platforms" + platforms_done += 1 + if platforms_total: + platforms_done = min(platforms_done, platforms_total) + current = m.group(1) + message = f"已完成热榜:{current}" + continue + + m = _RE_FETCH_FAIL.search(line) + if m: + phase = "platforms" + current = m.group(1) + message = f"热榜失败:{current}" + continue + + if _RE_SUCCESS_SUMMARY.search(line): + phase = "platforms_done" + if platforms_total: + platforms_done = platforms_total + message = "热榜抓取完成" + continue + + m = _RE_RSS_START.search(line) + if m: + phase = "rss" + rss_total = max(rss_total, int(m.group(1))) + message = f"开始抓取 RSS({rss_total} 个源)" + continue + + m = _RE_RSS_FETCHING.search(line) + if m: + phase = "rss" + current = m.group(1).strip() + step = int(m.group(2)) + total = int(m.group(3)) + rss_total = max(rss_total, total) + rss_done = max(rss_done, step - 1) + message = f"正在抓取 RSS:{current}({step}/{total})" + continue + + m = _RE_RSS_DONE.search(line) + if m and "开始抓取" not in line and "抓取完成" not in line and "正在获取" not in line: + name = m.group(1).strip() + if name and not name.startswith("["): + phase = "rss" + current = name + rss_done += 1 + if rss_total: + rss_done = min(rss_done, rss_total) + detail = m.group(2).strip() + message = f"RSS {name}:{detail}" + continue + + if "[RSS] 抓取完成" in line: + phase = "rss_done" + if rss_total: + rss_done = rss_total + message = "RSS 抓取完成" + continue + + m = _RE_AI.search(line) + if m: + phase = "ai" + current = m.group(1).strip() + message = f"AI:{current}" + continue + + m = _RE_TRANSLATE.search(line) + if m: + phase = "translate" + current = m.group(1).strip() + message = f"翻译:{current}" + continue + + m = _RE_PUSH.search(line) + if m: + phase = "push" + current = m.group(1).strip() + message = f"推送:{current}" + continue + + if _RE_HTML.search(line): + phase = "report" + message = "正在生成报告" + continue + + # 估算百分比 + percent = 0 + if mode == "analyze": + if phase in ("starting",): + percent = 5 + elif phase in ("platforms", "platforms_done", "rss", "rss_done"): + percent = 15 + elif phase == "ai": + percent = 55 + elif phase == "translate": + percent = 75 + elif phase == "push": + percent = 88 + elif phase == "report": + percent = 95 + else: + percent = 30 + else: + p_part = 0.0 + if platforms_total > 0: + # 正在抓第 step 项时,算 step-0.3 完成感 + active = platforms_done + if phase == "platforms" and step and total: + active = max(active, step - 0.3) + p_part = min(1.0, active / platforms_total) * weight_platform + elif phase in ("platforms", "platforms_done"): + p_part = weight_platform * (0.5 if phase == "platforms" else 1.0) + + r_part = 0.0 + if phase in ("rss", "rss_done") or rss_done or rss_total: + if rss_total > 0: + active = rss_done + if phase == "rss" and step and total and "RSS" in message: + active = max(active, step - 0.3) + r_part = min(1.0, active / rss_total) * weight_rss + elif phase == "rss_done": + r_part = float(weight_rss) + else: + r_part = weight_rss * 0.4 + if phase in ("platforms_done",) and not rss_total: + # 尚不知是否有 RSS + r_part = 0 + + pipe = 0.0 + if phase == "ai": + pipe = weight_pipeline * 0.45 + elif phase == "translate": + pipe = weight_pipeline * 0.65 + elif phase == "push": + pipe = weight_pipeline * 0.85 + elif phase == "report": + pipe = weight_pipeline * 0.95 + elif phase in ("rss_done",) or (platforms_total and platforms_done >= platforms_total and rss_total and rss_done >= rss_total): + pipe = weight_pipeline * 0.2 + + if phase == "starting": + percent = 2 + else: + base = 0 + if platforms_total or phase in ("platforms", "platforms_done", "rss", "rss_done", "ai", "translate", "push", "report"): + base = p_part + r_part + pipe + percent = int(max(2, min(99, round(base)))) + + return { + "phase": phase, + "message": message, + "current": current, + "step": step or None, + "total": total or None, + "platforms_done": platforms_done or None, + "platforms_total": platforms_total or None, + "rss_done": rss_done or None, + "rss_total": rss_total or None, + "percent": percent, + } + + +TOOLBAR_HTML = """ + +暂无日志+
/正则/、=> 别名、[组名]、+必须词、!排除词、@数量。[GLOBAL_FILTER] 区为全局过滤词。
+ deepseek/deepseek-v4-flash、openai/gpt-4o、ollama/llama3;
+ 使用国内中转或私有部署时填写 API 地址,且模型名加 openai/ 前缀。
+ 推理强度仅对支持的模型生效(gpt-5 / o 系列 / grok 等),普通模型保持「不设置」。
+ 优先级:面板 > 环境变量 > config.yaml;密钥留空保存表示保持不变,输入框为空时点「显示」可查看已保存密钥。
+