diff --git a/mcp_server/services/data_service.py b/mcp_server/services/data_service.py index 7ad0f8c60baaf..fbcf64711ad37 100644 --- a/mcp_server/services/data_service.py +++ b/mcp_server/services/data_service.py @@ -7,7 +7,7 @@ import re from collections import Counter from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from .cache_service import get_cache from .parser_service import ParserService @@ -17,6 +17,21 @@ class DataService: """数据访问服务类""" + @staticmethod + def _make_json_safe(value: Any): + """将配置对象转换为 JSON-safe 结构。""" + if isinstance(value, re.Pattern): + return { + "type": "regex", + "pattern": value.pattern, + "flags": value.flags, + } + if isinstance(value, dict): + return {k: DataService._make_json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [DataService._make_json_safe(v) for v in value] + return value + # 中文停用词列表(用于 auto_extract 模式) STOPWORDS = { '的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', @@ -553,7 +568,7 @@ def get_current_config(self, section: str = "all") -> Dict: else: result = {} - return result + return self._make_json_safe(result) def get_available_date_range(self, db_type: str = "news") -> Tuple[Optional[datetime], Optional[datetime]]: """ @@ -595,14 +610,23 @@ def get_system_status(self) -> Dict: # 读取版本信息 version_file = self.parser.project_root / "version" - version = "unknown" - if version_file.exists(): + version = "" + try: + from trendradar import __version__ as package_version + version = (package_version or "").strip() + except (ImportError, AttributeError): + version = "" + + if not version and version_file.exists(): try: with open(version_file, "r") as f: version = f.read().strip() except (OSError, ValueError): pass + if not version: + version = "unknown" + return { "system": { "version": version, diff --git a/tests/test_config_json_safe.py b/tests/test_config_json_safe.py new file mode 100644 index 0000000000000..4f844d86daadd --- /dev/null +++ b/tests/test_config_json_safe.py @@ -0,0 +1,43 @@ +import json +import re +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from mcp_server.tools.config_mgmt import ConfigManagementTools + + +class GetCurrentConfigJsonSafeTest(unittest.TestCase): + def test_keywords_config_with_compiled_regex_is_json_serializable(self): + with tempfile.TemporaryDirectory() as tmp: + project_root = Path(tmp) + (project_root / "config").mkdir(parents=True, exist_ok=True) + (project_root / "config" / "config.yaml").write_text( + "advanced: {}\nplatforms:\n enabled: true\n sources: []\n", + encoding="utf-8", + ) + + tools = ConfigManagementTools(str(project_root)) + + fake_word_groups = [ + { + "name": "regex-group", + "patterns": [re.compile(r"AI|LLM", re.IGNORECASE)], + } + ] + + with patch.object( + tools.data_service.parser, + "parse_frequency_words", + return_value=fake_word_groups, + ): + result = tools.get_current_config(section="keywords") + + self.assertTrue(result["success"]) + payload = json.dumps(result, ensure_ascii=False) + self.assertIn("regex-group", payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rss_guid_migration.py b/tests/test_rss_guid_migration.py new file mode 100644 index 0000000000000..17323e047fe56 --- /dev/null +++ b/tests/test_rss_guid_migration.py @@ -0,0 +1,82 @@ +import importlib.util +import sqlite3 +import tempfile +import unittest +from datetime import datetime +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parent.parent / "trendradar" / "storage" / "sqlite_mixin.py" +spec = importlib.util.spec_from_file_location("trendradar_sqlite_mixin_test", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) +SQLiteStorageMixin = module.SQLiteStorageMixin + + +class _DummyStorage(SQLiteStorageMixin): + def __init__(self, db_path: Path): + self.db_path = db_path + + def _get_connection(self, date=None, db_type="news"): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _get_configured_time(self): + return datetime(2026, 5, 17, 12, 0, 0) + + def _format_date_folder(self, date=None): + return "2026-05-17" + + def _format_time_filename(self): + return "12-00" + + +class RssGuidMigrationTest(unittest.TestCase): + def test_init_tables_migrates_legacy_rss_db_with_missing_guid_column(self): + with tempfile.TemporaryDirectory() as tmp: + db_path = Path(tmp) / "legacy.db" + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE rss_feeds ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE rss_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + feed_id TEXT NOT NULL, + url TEXT NOT NULL, + published_at TEXT, + summary TEXT, + author TEXT, + first_crawl_time TEXT NOT NULL, + last_crawl_time TEXT NOT NULL, + crawl_count INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO rss_items ( + title, feed_id, url, published_at, summary, author, + first_crawl_time, last_crawl_time, crawl_count + ) VALUES ( + 'test', 'feed-1', 'https://example.com/a', '2026-05-17T00:00:00', + 'summary', 'author', '12:00', '12:00', 1 + ); + """ + ) + conn.commit() + conn.close() + + storage = _DummyStorage(db_path) + with storage._get_connection(db_type="rss") as conn2: + storage._init_tables(conn2, db_type="rss") + columns = [row[1] for row in conn2.execute("PRAGMA table_info(rss_items)").fetchall()] + self.assertIn("guid", columns) + guid = conn2.execute("SELECT guid FROM rss_items WHERE id = 1").fetchone()[0] + self.assertEqual(guid, "https://example.com/a") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system_version_resolution.py b/tests/test_system_version_resolution.py new file mode 100644 index 0000000000000..4523ad1fad5e8 --- /dev/null +++ b/tests/test_system_version_resolution.py @@ -0,0 +1,27 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from mcp_server.services.data_service import DataService + + +class SystemVersionResolutionTest(unittest.TestCase): + def test_get_system_status_prefers_package_version_when_version_file_empty(self): + with tempfile.TemporaryDirectory() as tmp: + project_root = Path(tmp) + output_dir = project_root / "output" + output_dir.mkdir(parents=True, exist_ok=True) + (project_root / "version").write_text("", encoding="utf-8") + + service = DataService(str(project_root)) + + with patch.object(service, "get_available_date_range", return_value=(None, None)): + status = service.get_system_status() + + self.assertIn("version", status["system"]) + self.assertNotEqual(status["system"]["version"], "unknown") + + +if __name__ == "__main__": + unittest.main() diff --git a/trendradar/storage/sqlite_mixin.py b/trendradar/storage/sqlite_mixin.py index 5921bbf44c1c7..e786c334d08ac 100644 --- a/trendradar/storage/sqlite_mixin.py +++ b/trendradar/storage/sqlite_mixin.py @@ -80,6 +80,9 @@ def _init_tables(self, conn: sqlite3.Connection, db_type: str = "news") -> None: conn: 数据库连接 db_type: 数据库类型 ("news" 或 "rss") """ + if db_type == "rss": + self._ensure_legacy_rss_guid_column(conn) + schema_path = self._get_schema_path(db_type) if schema_path.exists(): @@ -101,6 +104,13 @@ def _init_tables(self, conn: sqlite3.Connection, db_type: str = "news") -> None: conn.commit() + def _ensure_legacy_rss_guid_column(self, conn: sqlite3.Connection) -> None: + """在执行新版 RSS schema 前,先为 legacy 表补齐 guid 列,避免索引脚本报错。""" + cursor = conn.execute("PRAGMA table_info(rss_items)") + columns = {row[1] for row in cursor.fetchall()} + if columns and "guid" not in columns: + conn.execute("ALTER TABLE rss_items ADD COLUMN guid TEXT DEFAULT ''") + def _migrate_rss_schema(self, conn: sqlite3.Connection) -> None: """迁移 rss_items 表结构(为已有数据库添加 guid 列)""" cursor = conn.execute("PRAGMA table_info(rss_items)") @@ -112,6 +122,16 @@ def _migrate_rss_schema(self, conn: sqlite3.Connection) -> None: ON rss_items(guid, feed_id) WHERE guid != '' """) + conn.execute( + """ + UPDATE rss_items + SET guid = url + WHERE (guid IS NULL OR guid = '') + AND url IS NOT NULL + AND url != '' + """ + ) + # ======================================== # 新闻数据存储 # ========================================