Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions mcp_server/services/data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一',
Expand Down Expand Up @@ -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]]:
"""
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions tests/test_config_json_safe.py
Original file line number Diff line number Diff line change
@@ -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()
82 changes: 82 additions & 0 deletions tests/test_rss_guid_migration.py
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 27 additions & 0 deletions tests/test_system_version_resolution.py
Original file line number Diff line number Diff line change
@@ -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()
20 changes: 20 additions & 0 deletions trendradar/storage/sqlite_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)")
Expand All @@ -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 != ''
"""
)

# ========================================
# 新闻数据存储
# ========================================
Expand Down