v3.0.0 AI 智能分析功能

This commit is contained in:
sansan 2025-10-20 21:41:24 +08:00
parent da81d69309
commit 2afc24e6fb
29 changed files with 6931 additions and 54 deletions

View File

@ -21,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.9"
python-version: "3.10"
- name: Install dependencies
run: |

144
README-Cherry-Studio.md Normal file
View File

@ -0,0 +1,144 @@
# TrendRadar × Cherry Studio 部署指南 🍒
> **适合人群**:零编程基础的用户
> **客户端**Cherry Studio免费开源 GUI 客户端)
---
## 📥 第一步:下载 Cherry Studio
### Windows 用户
访问官网下载https://cherry-ai.com/
或直接下载:[Cherry-Studio-Windows.exe](https://github.com/kangfenmao/cherry-studio/releases/latest)
### Mac 用户
访问官网下载https://cherry-ai.com/
或直接下载:[Cherry-Studio-Mac.dmg](https://github.com/kangfenmao/cherry-studio/releases/latest)
---
## 📦 第二步:获取项目代码
为什么需要获取项目代码?
AI 分析功能需要读取项目中的新闻数据才能工作。无论你使用 GitHub Actions 还是 Docker 部署,爬虫生成的新闻数据都保存在项目的 output 目录中。因此,在配置 MCP 服务器之前,需要先获取完整的项目代码(包含数据文件)。
根据你的技术水平,可以选择以下任一方式获取::
### 方法一Git Clone推荐给技术用户
如果你熟悉 Git可以使用以下命令克隆项目
```bash
git clone https://github.com/你的用户名/你的项目名.git
cd 你的项目名
```
**优点**
- 可以随时拉取一个命令就可以更新最新数据到本地了(`git pull`
### 方法二:直接下载 ZIP 压缩包(推荐给初学者)
1. **访问 GitHub 项目页面**
- 项目链接:`https://github.com/你的用户名/你的项目名`
2. **下载压缩包**
- 点击绿色的 "Code" 按钮
- 选择 "Download ZIP"
- 或直接访问:`https://github.com/你的用户名/你的项目名/archive/refs/heads/master.zip`
**注意事项**
- 步骤稍微麻烦,后续更新数据需要重复上面步骤,然后覆盖本地数据(output 目录)
---
## 🚀 第三步:一键部署 MCP 服务器
### Windows 用户
1. **双击运行**项目文件夹中的 `setup-windows.bat`
2. **等待安装完成**
3. **记录显示的配置信息**(命令路径和参数)
### Mac 用户
1. **打开终端**(在启动台搜索"终端"
2. **拖拽**项目文件夹中的 `setup-mac.sh` 到终端窗口
3. **按回车键**
4. **记录显示的配置信息**
---
## 🔧 第四步:配置 Cherry Studio
### 1. 打开设置
启动 Cherry Studio点击右上角 ⚙️ **设置** 按钮
### 2. 添加 MCP 服务器
在设置页面找到:**MCP** → 点击 **添加**
### 3. 填写配置(重要!)
根据刚才的安装脚本显示的信息填写
### 4. 保存并启用
- 点击 **保存** 按钮
- 确保 MCP 服务器列表中的开关是 **开启** 状态 ✅
---
## ✅ 第五步:验证是否成功
### 1. 测试连接
在 Cherry Studio 的对话框中输入:
```
帮我爬取最新的新闻
```
### 2. 成功标志
如果配置成功AI 会:
- ✅ 调用 TrendRadar 工具
- ✅ 返回真实的新闻数据
- ✅ 显示平台、标题、排名等信息
---
## 🎯 进阶配置
### HTTP 模式(可选)
如果需要远程访问或多客户端共享,可以使用 HTTP 模式:
#### Windows
双击运行 `start-http.bat`
#### Mac
```bash
./start-http.sh
```
然后在 Cherry Studio 中配置:
```
类型: streamableHttp
URL: http://localhost:3333/mcp
```

442
README-MCP-FAQ.md Normal file
View File

@ -0,0 +1,442 @@
# TrendRadar MCP 工具使用问答
> AI 提问指南 - 如何通过对话使用新闻热点分析工具
## ⚙️ 默认设置说明(重要!)
默认采用以下优化策略,主要是为了节约 AI token 消耗:
| 默认设置 | 说明 | 如何调整 |
| -------------- | --------------------------------------- | ------------------------------------- |
| **限制条数** | 默认返回 50 条新闻 | 对话中说"返回前 10 条"或"给我 100 条" |
| **时间范围** | 默认查询今天的数据 | 说"查询昨天"或"最近一周" |
| **URL 链接** | 默认不返回链接(节省约 160 tokens/条) | 说"需要链接"或"包含 URL" |
| **关键词列表** | 默认不使用 frequency_words.txt 过滤新闻 | 只有调用"趋势话题"工具时才使用 |
**⚠️ 重要:** AI 模型的选择直接影响工具调用效果AI 越智能调用越准确。当你解除上面的限制比如从今天的查询放宽到一周的查询首先你要在本地有一周的数据其次token 消耗量可能会倍增(为什么说可能,比如我查询 分析'苹果'最近一周的热度趋势,如果一周中没多少苹果的新闻,那么 token消耗量可能反而很少
## 💰 AI 模型
下面我以 **[硅基流动](https://cloud.siliconflow.cn)** 平台作为例子,里面有很多大模型可选择。在开发和测试本项目的过程中,我使用本平台进行了许多的功能测试和验证。
### 📊 注册方式对比
| 注册方式 | 无邀请链接直接注册 | 含有邀请链接注册 |
|:-------:|:-------:|:-----------------:|
| 注册链接 | [siliconflow.cn](https://cloud.siliconflow.cn) | [邀请链接](https://cloud.siliconflow.cn/i/fqnyVaIU) |
| 免费额度 | 0 tokens | **2000万 tokens** (≈14元) |
| 额外福利 | ❌ | ✅ 邀请者也获得2000万tokens |
> 💡 **提示**:上面的赠送额度,应该可以询问 **200次以上**
### 🚀 快速开始
#### 1⃣ 注册并获取 API 密钥
1. 使用上方链接完成注册
2. 访问 [API 密钥管理页面](https://cloud.siliconflow.cn/me/account/ak)
3. 点击「新建 API 密钥」
4. 复制生成的密钥(请妥善保管)
#### 2⃣ 在 Cherry Studio 中配置
1. 打开 **Cherry Studio**
2. 进入「模型服务」设置
3. 找到「硅基流动」
4. 将复制的密钥粘贴到 **[API密钥]** 输入框
5. 确保右上角勾选框打开后显示为 **绿色**
---
### ✨ 配置完成!
现在你可以开始使用本项目,享受稳定快速的 AI 服务了!
在你测试一次询问后,请立刻去 [硅基流动账单](https://cloud.siliconflow.cn/me/bills) 查询这一次的消耗量,心底有个估算。
## 基础查询
### Q1: 如何查看最新的新闻?
**你可以这样问:**
- "给我看看最新的新闻"
- "查询今天的热点新闻"
- "获取知乎和微博的最新 10 条新闻"
- "查看最新新闻,需要包含链接"
**调用的工具:** `get_latest_news`
**工具返回行为:**
- MCP 工具会返回所有平台的最新 50 条新闻给 AI
- 不包含 URL 链接(节省 token
**AI 展示行为(重要):**
- ⚠️ **AI 通常会自动总结**,只展示部分新闻(如 TOP 10-20 条)
- ✅ 如果你想看全部 50 条,需要明确要求:"展示所有新闻"或"完整列出所有 50 条"
- 💡 这是 AI 模型的自然行为,不是工具的限制
**可以调整:**
- 指定平台:如"只看知乎的"
- 调整数量:如"返回前 20 条"
- 包含链接:如"需要链接"
- **要求完整展示**:如"展示全部,不要总结"
---
### Q2: 如何查询特定日期的新闻?
**你可以这样问:**
- "查询昨天的新闻"
- "看看 3 天前知乎的新闻"
- "2025-10-10 的新闻有哪些"
- "上周一的新闻"
- "给我看看最新新闻"(自动查询今天)
**调用的工具:** `get_news_by_date`
**支持的日期格式:**
- 相对日期今天、昨天、前天、3 天前
- 星期上周一、本周三、last monday
- 绝对日期2025-10-10、10 月 10 日
**工具返回行为:**
- 不指定日期时自动查询今天(节省 token
- MCP 工具会返回所有平台的 50 条新闻给 AI
- 不包含 URL 链接
**AI 展示行为(重要):**
- ⚠️ **AI 通常会自动总结**,只展示部分新闻(如 TOP 10-20 条)
- ✅ 如果你想看全部,需要明确要求:"展示所有新闻,不要总结"
---
### Q3: 如何查看我关注的话题频率统计?
**你可以这样问:**
- "我关注的词今天出现了多少次"
- "看看我的关注词列表中哪些词最热门"
- "统计一下 frequency_words.txt 中的关注词频率"
**调用的工具:** `get_trending_topics`
**重要说明:**
- 本工具**不是**自动提取新闻热点
- 而是统计你在 `config/frequency_words.txt` 中设置的**个人关注词**
- 这是一个**可自定义**的列表,你可以根据兴趣添加关注词
---
## 搜索检索
### Q4: 如何搜索包含特定关键词的新闻?
**你可以这样问:**
- "搜索包含'人工智能'的新闻"
- "查找关于'特斯拉降价'的报道"
- "搜索马斯克相关的新闻,返回前 20 条"
- "查找'iPhone 16 发布'这条新闻的链接"
**调用的工具:** `search_news`
**工具返回行为:**
- 使用关键词模式搜索
- 搜索今天的数据
- MCP 工具会返回最多 50 条结果给 AI
- 不包含 URL 链接
**AI 展示行为(重要):**
- ⚠️ **AI 通常会自动总结**,只展示部分搜索结果
- ✅ 如果你想看全部,需要明确要求:"展示所有搜索结果"
**可以调整:**
- 指定时间范围:如"搜索最近一周的"
- 指定平台:如"只搜索知乎"
- 调整排序:如"按权重排序"
- 包含链接:如"需要链接"
---
### Q5: 如何查找历史相关新闻?
**你可以这样问:**
- "查找昨天与'人工智能突破'相关的新闻"
- "搜索上周关于'特斯拉'的历史报道"
- "找出上个月与'ChatGPT'相关的新闻"
- "看看'iPhone 发布会'相关的历史新闻"
**调用的工具:** `search_related_news_history`
**工具返回行为:**
- 搜索昨天的数据
- 相似度阈值 0.4
- MCP 工具会返回最多 50 条结果给 AI
- 不包含 URL 链接
**AI 展示行为(重要):**
- ⚠️ **AI 通常会自动总结**,只展示部分相关新闻
- ✅ 如果你想看全部,需要明确要求:"展示所有相关新闻"
---
## 趋势分析
### Q6: 如何分析话题的热度趋势?
**你可以这样问:**
- "分析'人工智能'最近一周的热度趋势"
- "看看'特斯拉'话题是昙花一现还是持续热点"
- "检测今天有哪些突然爆火的话题"
- "预测接下来可能的热点话题"
**调用的工具:** `analyze_topic_trend`
**工具返回行为:**
- 热度趋势模式
- 分析最近 7 天数据
- 按天粒度统计
**AI 展示行为:**
- 通常会展示趋势分析结果和图表
- AI 可能会总结关键发现
---
## 数据洞察
### Q7: 如何对比不同平台对话题的关注度?
**你可以这样问:**
- "对比各个平台对'人工智能'话题的关注度"
- "看看哪个平台更新最频繁"
- "分析一下哪些关键词经常一起出现"
**调用的工具:** `analyze_data_insights`
**三种洞察模式:**
| 模式 | 功能 | 示例问法 |
| -------------- | ---------------- | -------------------------- |
| **平台对比** | 对比各平台关注度 | "对比各平台对'AI'的关注度" |
| **活跃度统计** | 统计平台发布频率 | "看看哪个平台更新最频繁" |
| **关键词共现** | 分析关键词关联 | "哪些关键词经常一起出现" |
**工具返回行为:**
- 平台对比模式
- 分析今天的数据
- 关键词共现最小频次 3 次
**AI 展示行为:**
- 通常会展示分析结果和统计数据
- AI 可能会总结洞察发现
---
## 情感分析
### Q8: 如何分析新闻的情感倾向?
**你可以这样问:**
- "分析一下今天新闻的情感倾向"
- "看看'特斯拉'相关新闻是正面还是负面的"
- "分析各平台对'人工智能'的情感态度"
- "看看'比特币'一周内的情感倾向,选择前 20 条最重要的"
**调用的工具:** `analyze_sentiment`
**工具返回行为:**
- 分析今天的数据
- MCP 工具会返回最多 50 条新闻给 AI
- 按权重排序(优先展示重要新闻)
- 不包含 URL 链接
**AI 展示行为(重要):**
- ⚠️ 本工具返回 **AI 提示词**,不是直接的情感分析结果
- AI 会根据提示词生成情感分析报告
- 通常会展示情感分布、关键发现和代表性新闻
**可以调整:**
- 指定话题:如"关于'特斯拉'"
- 指定时间:如"最近一周"
- 调整数量:如"返回前 20 条"
---
### Q9: 如何查找相似的新闻报道?
**你可以这样问:**
- "找出和'特斯拉降价'相似的新闻"
- "查找关于 iPhone 发布的类似报道"
- "看看有没有和这条新闻相似的报道"
- "找相似新闻,需要链接"
**调用的工具:** `find_similar_news`
**工具返回行为:**
- 相似度阈值 0.6
- MCP 工具会返回最多 50 条结果给 AI
- 不包含 URL 链接
**AI 展示行为(重要):**
- ⚠️ **AI 通常会自动总结**,只展示部分相似新闻
- ✅ 如果你想看全部,需要明确要求:"展示所有相似新闻"
---
### Q10: 如何生成每日或每周的热点摘要?
**你可以这样问:**
- "生成今天的新闻摘要报告"
- "给我一份本周的热点总结"
- "生成过去 7 天的新闻分析报告"
**调用的工具:** `generate_summary_report`
**报告类型:**
- 每日摘要:总结当天的热点新闻
- 每周摘要:总结一周的热点趋势
---
## 系统管理
### Q11: 如何查看系统配置?
**你可以这样问:**
- "查看当前系统配置"
- "显示配置文件内容"
- "有哪些可用的平台?"
- "当前的权重配置是什么?"
**调用的工具:** `get_current_config`
**可以查询:**
- 可用平台列表
- 爬虫配置(请求间隔、超时设置)
- 权重配置(排名权重、频次权重)
- 通知配置(钉钉、微信)
---
### Q12: 如何检查系统运行状态?
**你可以这样问:**
- "检查系统状态"
- "系统运行正常吗?"
- "最后一次爬取是什么时候?"
- "有多少天的历史数据?"
**调用的工具:** `get_system_status`
**返回信息:**
- 系统版本和状态
- 最后爬取时间
- 历史数据天数
- 健康检查结果
---
### Q13: 如何手动触发爬取任务?
**你可以这样问:**
- "请你爬取当前的今日头条的新闻"(临时查询)
- "帮我抓取一下知乎和微博的最新新闻并保存"(持久化)
- "触发一次爬取并保存数据"(持久化)
- "获取 36 氪 的实时数据但不保存"(临时查询)
**调用的工具:** `trigger_crawl`
**两种模式:**
| 模式 | 用途 | 示例 |
| -------------- | -------------------- | -------------------- |
| **临时爬取** | 只返回数据不保存 | "爬取今日头条的新闻" |
| **持久化爬取** | 保存到 output 文件夹 | "抓取并保存知乎新闻" |
**工具返回行为:**
- 临时爬取模式(不保存)
- 爬取所有平台
- 不包含 URL 链接
**AI 展示行为(重要):**
- ⚠️ **AI 通常会总结爬取结果**,只展示部分新闻
- ✅ 如果你想看全部,需要明确要求:"展示所有爬取的新闻"
**可以调整:**
- 指定平台:如"只爬取知乎"
- 保存数据:说"并保存"或"保存到本地"
- 包含链接:说"需要链接"
---
## 💡 使用技巧
### 1. 如何让 AI 展示全部数据而不是自动总结?
**背景**: 有时 AI 会自动总结数据,只展示部分内容,即使工具返回了完整的 50 条数据。
**如果 AI 仍然总结,你可以**:
- **方法 1 - 明确要求**: "请展示全部新闻,不要总结"
- **方法 2 - 指定数量**: "展示所有 50 条新闻"
- **方法 3 - 质疑行为**: "为什么只展示了 15 条?我要看全部"
- **方法 4 - 提前说明**: "查询今天的新闻,完整展示所有结果"
**注意**: AI 仍可能根据上下文调整展示方式。
### 2. 如何组合使用多个工具?
**示例:深度分析某个话题**
1. 先搜索:"搜索'人工智能'相关新闻"
2. 再分析趋势:"分析'人工智能'的热度趋势"
3. 最后情感分析:"分析'人工智能'新闻的情感倾向"
**示例:追踪某个事件**
1. 查看最新:"查询今天关于'iPhone'的新闻"
2. 查找历史:"查找上周与'iPhone'相关的历史新闻"
3. 找相似报道:"找出和'iPhone 发布会'相似的新闻"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 259 KiB

View File

@ -6,50 +6,55 @@ WORKDIR /app
ARG TARGETARCH
ENV SUPERCRONIC_VERSION=v0.2.34
# supercronic + locale
RUN set -ex && \
apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
apt-get install -y --no-install-recommends curl ca-certificates locales && \
sed -i -e 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen && \
sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
locale-gen && \
# 根据架构选择并下载 supercronic
case ${TARGETARCH} in \
amd64) \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-amd64; \
export SUPERCRONIC_SHA1SUM=e8631edc1775000d119b70fd40339a7238eece14; \
export SUPERCRONIC=supercronic-linux-amd64; \
;; \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-amd64; \
export SUPERCRONIC_SHA1SUM=e8631edc1775000d119b70fd40339a7238eece14; \
export SUPERCRONIC=supercronic-linux-amd64; \
;; \
arm64) \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-arm64; \
export SUPERCRONIC_SHA1SUM=4ab6343b52bf9da592e8b4bb7ae6eb5a8e21b71e; \
export SUPERCRONIC=supercronic-linux-arm64; \
;; \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-arm64; \
export SUPERCRONIC_SHA1SUM=4ab6343b52bf9da592e8b4bb7ae6eb5a8e21b71e; \
export SUPERCRONIC=supercronic-linux-arm64; \
;; \
arm) \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-arm; \
export SUPERCRONIC_SHA1SUM=4ba4cd0da62082056b6def085fa9377d965fbe01; \
export SUPERCRONIC=supercronic-linux-arm; \
;; \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-arm; \
export SUPERCRONIC_SHA1SUM=4ba4cd0da62082056b6def085fa9377d965fbe01; \
export SUPERCRONIC=supercronic-linux-arm; \
;; \
386) \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-386; \
export SUPERCRONIC_SHA1SUM=80b4fff03a8d7bf2f24a1771f37640337855e949; \
export SUPERCRONIC=supercronic-linux-386; \
;; \
export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-386; \
export SUPERCRONIC_SHA1SUM=80b4fff03a8d7bf2f24a1771f37640337855e949; \
export SUPERCRONIC=supercronic-linux-386; \
;; \
*) \
echo "Unsupported architecture: ${TARGETARCH}"; \
exit 1; \
;; \
echo "Unsupported architecture: ${TARGETARCH}"; \
exit 1; \
;; \
esac && \
echo "Downloading supercronic for ${TARGETARCH} from ${SUPERCRONIC_URL}" && \
# 添加重试机制和超时设置
for i in 1 2 3 4 5; do \
echo "Download attempt $i/5"; \
if curl --fail --silent --show-error --location --retry 3 --retry-delay 2 --connect-timeout 30 --max-time 120 -o "$SUPERCRONIC" "$SUPERCRONIC_URL"; then \
echo "Download successful"; \
break; \
else \
echo "Download attempt $i failed, exit code: $?"; \
if [ $i -eq 5 ]; then \
echo "All download attempts failed"; \
exit 1; \
fi; \
sleep $((i * 2)); \
fi; \
echo "Download attempt $i/5"; \
if curl --fail --silent --show-error --location --retry 3 --retry-delay 2 --connect-timeout 30 --max-time 120 -o "$SUPERCRONIC" "$SUPERCRONIC_URL"; then \
echo "Download successful"; \
break; \
else \
echo "Download attempt $i failed, exit code: $?"; \
if [ $i -eq 5 ]; then \
echo "All download attempts failed"; \
exit 1; \
fi; \
sleep $((i * 2)); \
fi; \
done && \
echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - && \
chmod +x "$SUPERCRONIC" && \
@ -57,6 +62,7 @@ RUN set -ex && \
ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic && \
# 验证安装
supercronic -version && \
# 清理(保留 locales只删除 curl
apt-get remove -y curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@ -77,6 +83,10 @@ RUN sed -i 's/\r$//' /entrypoint.sh.tmp && \
ENV PYTHONUNBUFFERED=1 \
CONFIG_PATH=/app/config/config.yaml \
FREQUENCY_WORDS_PATH=/app/config/frequency_words.txt
FREQUENCY_WORDS_PATH=/app/config/frequency_words.txt \
LANG=zh_CN.UTF-8 \
LANGUAGE=zh_CN:zh:en_US:en \
LC_ALL=zh_CN.UTF-8 \
PYTHONIOENCODING=utf-8
ENTRYPOINT ["/entrypoint.sh"]

7
mcp_server/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""
TrendRadar MCP Server
提供基于MCP协议的新闻聚合数据查询和系统管理接口
"""
__version__ = "1.0.0"

657
mcp_server/server.py Normal file
View File

@ -0,0 +1,657 @@
"""
TrendRadar MCP Server - FastMCP 2.0 实现
使用 FastMCP 2.0 提供生产级 MCP 工具服务器
支持 stdio HTTP 两种传输模式
"""
import json
from typing import List, Optional, Dict
from fastmcp import FastMCP
from .tools.data_query import DataQueryTools
from .tools.analytics import AnalyticsTools
from .tools.search_tools import SearchTools
from .tools.config_mgmt import ConfigManagementTools
from .tools.system import SystemManagementTools
# 创建 FastMCP 2.0 应用
mcp = FastMCP('trendradar-news')
# 全局工具实例(在第一次请求时初始化)
_tools_instances = {}
def _get_tools(project_root: Optional[str] = None):
"""获取或创建工具实例(单例模式)"""
if not _tools_instances:
_tools_instances['data'] = DataQueryTools(project_root)
_tools_instances['analytics'] = AnalyticsTools(project_root)
_tools_instances['search'] = SearchTools(project_root)
_tools_instances['config'] = ConfigManagementTools(project_root)
_tools_instances['system'] = SystemManagementTools(project_root)
return _tools_instances
# ==================== 数据查询工具 ====================
@mcp.tool
async def get_latest_news(
platforms: Optional[List[str]] = None,
limit: int = 50,
include_url: bool = False
) -> str:
"""
获取最新一批爬取的新闻数据快速了解当前热点
Args:
platforms: 平台ID列表 ['zhihu', 'weibo', 'douyin']
- 不指定时使用 config.yaml 中配置的所有平台
- 支持的平台来自 config/config.yaml platforms 配置
- 每个平台都有对应的name字段"知乎""微博"方便AI识别
limit: 返回条数限制默认50最大1000
注意实际返回数量可能少于请求值取决于当前可用的新闻总数
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的新闻列表
**重要数据展示建议**
本工具会返回完整的新闻列表通常50条给你但请注意
- **工具返回**完整的50条数据
- **建议展示**向用户展示全部数据除非用户明确要求总结
- **用户期望**用户可能需要完整数据请谨慎总结
**何时可以总结**
- 用户明确说"给我总结一下""挑重点说"
- 数据量超过100条时可先展示部分并询问是否查看全部
**注意**如果用户询问"为什么只显示了部分"说明他们需要完整数据
"""
tools = _get_tools()
result = tools['data'].get_latest_news(platforms=platforms, limit=limit, include_url=include_url)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def get_trending_topics(
top_n: int = 10,
mode: str = 'current'
) -> str:
"""
获取个人关注词的新闻出现频率统计基于 config/frequency_words.txt
注意本工具不是自动提取新闻热点而是统计你在 config/frequency_words.txt
设置的个人关注词在新闻中出现的频率你可以自定义这个关注词列表
Args:
top_n: 返回TOP N关注词默认10
mode: 模式选择
- daily: 当日累计数据统计
- current: 最新一批数据统计默认
Returns:
JSON格式的关注词频率统计列表
"""
tools = _get_tools()
result = tools['data'].get_trending_topics(top_n=top_n, mode=mode)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def get_news_by_date(
date_query: Optional[str] = None,
platforms: Optional[List[str]] = None,
limit: int = 50,
include_url: bool = False
) -> str:
"""
获取指定日期的新闻数据用于历史数据分析和对比
Args:
date_query: 日期查询可选格式:
- 自然语言: "今天", "昨天", "前天", "3天前"
- 标准日期: "2024-01-15", "2024/01/15"
- 默认值: "今天"节省token
platforms: 平台ID列表 ['zhihu', 'weibo', 'douyin']
- 不指定时使用 config.yaml 中配置的所有平台
- 支持的平台来自 config/config.yaml platforms 配置
- 每个平台都有对应的name字段"知乎""微博"方便AI识别
limit: 返回条数限制默认50最大1000
注意实际返回数量可能少于请求值取决于指定日期的新闻总数
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的新闻列表包含标题平台排名等信息
**重要数据展示建议**
本工具会返回完整的新闻列表通常50条给你但请注意
- **工具返回**完整的50条数据
- **建议展示**向用户展示全部数据除非用户明确要求总结
- **用户期望**用户可能需要完整数据请谨慎总结
**何时可以总结**
- 用户明确说"给我总结一下""挑重点说"
- 数据量超过100条时可先展示部分并询问是否查看全部
**注意**如果用户询问"为什么只显示了部分"说明他们需要完整数据
"""
tools = _get_tools()
result = tools['data'].get_news_by_date(
date_query=date_query,
platforms=platforms,
limit=limit,
include_url=include_url
)
return json.dumps(result, ensure_ascii=False, indent=2)
# ==================== 高级数据分析工具 ====================
@mcp.tool
async def analyze_topic_trend(
topic: str,
analysis_type: str = "trend",
time_range: str = "7d",
granularity: str = "day",
threshold: float = 3.0,
time_window: int = 24,
lookback_days: int = 7,
lookahead_hours: int = 6,
confidence_threshold: float = 0.7
) -> str:
"""
统一话题趋势分析工具 - 整合多种趋势分析模式
Args:
topic: 话题关键词必需
analysis_type: 分析类型可选值
- "trend": 热度趋势分析追踪话题的热度变化
- "lifecycle": 生命周期分析从出现到消失的完整周期
- "viral": 异常热度检测识别突然爆火的话题
- "predict": 话题预测预测未来可能的热点
time_range: 时间范围trend模式默认"7d"7d/24h/1w/1m/2m
granularity: 时间粒度trend模式默认"day"仅支持 day因为底层数据按天聚合
threshold: 热度突增倍数阈值viral模式默认3.0
time_window: 检测时间窗口小时数viral模式默认24
lookback_days: 回溯天数lifecycle模式默认7
lookahead_hours: 预测未来小时数predict模式默认6
confidence_threshold: 置信度阈值predict模式默认0.7
Returns:
JSON格式的趋势分析结果
Examples:
- analyze_topic_trend(topic="人工智能", analysis_type="trend", time_range="7d")
- analyze_topic_trend(topic="特斯拉", analysis_type="lifecycle", lookback_days=7)
- analyze_topic_trend(topic="比特币", analysis_type="viral", threshold=3.0)
- analyze_topic_trend(topic="ChatGPT", analysis_type="predict", lookahead_hours=6)
"""
tools = _get_tools()
result = tools['analytics'].analyze_topic_trend_unified(
topic=topic,
analysis_type=analysis_type,
time_range=time_range,
granularity=granularity,
threshold=threshold,
time_window=time_window,
lookback_days=lookback_days,
lookahead_hours=lookahead_hours,
confidence_threshold=confidence_threshold
)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def analyze_data_insights(
insight_type: str = "platform_compare",
topic: Optional[str] = None,
date_range: Optional[Dict[str, str]] = None,
min_frequency: int = 3,
top_n: int = 20
) -> str:
"""
统一数据洞察分析工具 - 整合多种数据分析模式
Args:
insight_type: 洞察类型可选值
- "platform_compare": 平台对比分析对比不同平台对话题的关注度
- "platform_activity": 平台活跃度统计统计各平台发布频率和活跃时间
- "keyword_cooccur": 关键词共现分析分析关键词同时出现的模式
topic: 话题关键词可选platform_compare模式适用
date_range: 日期范围格式: {"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"}
min_frequency: 最小共现频次keyword_cooccur模式默认3
top_n: 返回TOP N结果keyword_cooccur模式默认20
Returns:
JSON格式的数据洞察分析结果
Examples:
- analyze_data_insights(insight_type="platform_compare", topic="人工智能")
- analyze_data_insights(insight_type="platform_activity", date_range={...})
- analyze_data_insights(insight_type="keyword_cooccur", min_frequency=5, top_n=15)
"""
tools = _get_tools()
result = tools['analytics'].analyze_data_insights_unified(
insight_type=insight_type,
topic=topic,
date_range=date_range,
min_frequency=min_frequency,
top_n=top_n
)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def analyze_sentiment(
topic: Optional[str] = None,
platforms: Optional[List[str]] = None,
date_range: Optional[Dict[str, str]] = None,
limit: int = 50,
sort_by_weight: bool = True,
include_url: bool = False
) -> str:
"""
分析新闻的情感倾向和热度趋势
Args:
keywords: 关键词列表 ["AI", "人工智能"]
date_range: 日期范围天数 7 表示最近7天默认3天
platforms: 平台ID列表 ['zhihu', 'weibo', 'douyin']
- 不指定时使用 config.yaml 中配置的所有平台
- 支持的平台来自 config/config.yaml platforms 配置
- 每个平台都有对应的name字段"知乎""微博"方便AI识别
limit: 返回新闻数量默认50最大100
注意本工具会对新闻标题进行去重同一标题在不同平台只保留一次
因此实际返回数量可能少于请求的 limit
sort_by_weight: 是否按热度权重排序默认True
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的分析结果包含情感分布热度趋势和相关新闻
**重要数据展示策略**
- 本工具返回完整的分析结果和新闻列表
- **默认展示方式**展示完整的分析结果包括所有新闻
- 仅在用户明确要求"总结""挑重点"时才进行筛选
"""
tools = _get_tools()
result = tools['analytics'].analyze_sentiment(
topic=topic,
platforms=platforms,
date_range=date_range,
limit=limit,
sort_by_weight=sort_by_weight,
include_url=include_url
)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def find_similar_news(
reference_title: str,
threshold: float = 0.6,
limit: int = 50,
include_url: bool = False
) -> str:
"""
查找与指定新闻标题相似的其他新闻
Args:
title: 新闻标题完整或部分
threshold: 相似度阈值0-1之间默认0.6
注意阈值越高匹配越严格返回结果越少
limit: 返回条数限制默认50最大100
注意实际返回数量取决于相似度匹配结果可能少于请求值
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的相似新闻列表包含相似度分数
**重要数据展示策略**
- 本工具返回完整的相似新闻列表
- **默认展示方式**展示全部返回的新闻包括相似度分数
- 仅在用户明确要求"总结""挑重点"时才进行筛选
"""
tools = _get_tools()
result = tools['analytics'].find_similar_news(
reference_title=reference_title,
threshold=threshold,
limit=limit,
include_url=include_url
)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def generate_summary_report(
report_type: str = "daily",
date_range: Optional[Dict[str, str]] = None
) -> str:
"""
每日/每周摘要生成器 - 自动生成热点摘要报告
Args:
report_type: 报告类型daily/weekly
date_range: 自定义日期范围可选
Returns:
JSON格式的摘要报告包含Markdown格式内容
"""
tools = _get_tools()
result = tools['analytics'].generate_summary_report(
report_type=report_type,
date_range=date_range
)
return json.dumps(result, ensure_ascii=False, indent=2)
# ==================== 智能检索工具 ====================
@mcp.tool
async def search_news(
query: str,
search_mode: str = "keyword",
date_range: Optional[Dict[str, str]] = None,
platforms: Optional[List[str]] = None,
limit: int = 50,
sort_by: str = "relevance",
threshold: float = 0.6,
include_url: bool = False
) -> str:
"""
统一搜索接口支持多种搜索模式
Args:
query: 搜索关键词或内容片段
search_mode: 搜索模式可选值
- "keyword": 精确关键词匹配默认适合搜索特定话题
- "fuzzy": 模糊内容匹配适合搜索内容片段会过滤相似度低于阈值的结果
- "entity": 实体名称搜索适合搜索人物/地点/机构
threshold: 相似度阈值仅fuzzy模式有效0-1之间默认0.6
注意阈值越高匹配越严格返回结果越少
platforms: 平台ID列表 ['zhihu', 'weibo', 'douyin']
- 不指定时使用 config.yaml 中配置的所有平台
- 支持的平台来自 config/config.yaml platforms 配置
- 每个平台都有对应的name字段"知乎""微博"方便AI识别
lookback_days: 回溯天数默认7天最大30天
limit: 返回条数限制默认50最大1000
注意实际返回数量取决于搜索匹配结果特别是 fuzzy 模式下会过滤低相似度结果
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的搜索结果包含标题平台排名等信息
**重要数据展示策略**
- 本工具返回完整的搜索结果列表
- **默认展示方式**展示全部返回的新闻无需总结或筛选
- 仅在用户明确要求"总结""挑重点"时才进行筛选
"""
tools = _get_tools()
result = tools['search'].search_news_unified(
query=query,
search_mode=search_mode,
date_range=date_range,
platforms=platforms,
limit=limit,
sort_by=sort_by,
threshold=threshold,
include_url=include_url
)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def search_related_news_history(
reference_text: str,
time_range: str = "yesterday",
threshold: float = 0.4,
limit: int = 50,
include_url: bool = False
) -> str:
"""
基于种子新闻在历史数据中搜索相关新闻
Args:
seed_news_title: 种子新闻标题完整或部分
lookback_days: 向前查找的天数范围默认7天最大30天
threshold: 相关性阈值0-1之间默认0.4
注意综合相似度计算70%关键词重合 + 30%文本相似度
阈值越高匹配越严格返回结果越少
platforms: 平台ID列表不指定则搜索所有平台
limit: 返回条数限制默认50最大100
注意实际返回数量取决于相关性匹配结果可能少于请求值
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的相关新闻列表包含相关性分数和时间分布
**重要数据展示策略**
- 本工具返回完整的相关新闻列表
- **默认展示方式**展示全部返回的新闻包括相关性分数
- 仅在用户明确要求"总结""挑重点"时才进行筛选
"""
tools = _get_tools()
result = tools['search'].search_related_news_history(
reference_text=reference_text,
time_range=time_range,
threshold=threshold,
limit=limit,
include_url=include_url
)
return json.dumps(result, ensure_ascii=False, indent=2)
# ==================== 配置与系统管理工具 ====================
@mcp.tool
async def get_current_config(
section: str = "all"
) -> str:
"""
获取当前系统配置
Args:
section: 配置节可选值
- "all": 所有配置默认
- "crawler": 爬虫配置
- "push": 推送配置
- "keywords": 关键词配置
- "weights": 权重配置
Returns:
JSON格式的配置信息
"""
tools = _get_tools()
result = tools['config'].get_current_config(section=section)
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def get_system_status() -> str:
"""
获取系统运行状态和健康检查信息
返回系统版本数据统计缓存状态等信息
Returns:
JSON格式的系统状态信息
"""
tools = _get_tools()
result = tools['system'].get_system_status()
return json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool
async def trigger_crawl(
platforms: Optional[List[str]] = None,
save_to_local: bool = False,
include_url: bool = False
) -> str:
"""
手动触发一次爬取任务可选持久化
Args:
platforms: 指定平台ID列表 ['zhihu', 'weibo', 'douyin']
- 不指定时使用 config.yaml 中配置的所有平台
- 支持的平台来自 config/config.yaml platforms 配置
- 每个平台都有对应的name字段"知乎""微博"方便AI识别
- 注意失败的平台会在返回结果的 failed_platforms 字段中列出
save_to_local: 是否保存到本地 output 目录默认 False
include_url: 是否包含URL链接默认False节省token
Returns:
JSON格式的任务状态信息包含
- platforms: 成功爬取的平台列表
- failed_platforms: 失败的平台列表如有
- total_news: 爬取的新闻总数
- data: 新闻数据
Examples:
- 临时爬取: trigger_crawl(platforms=['zhihu'])
- 爬取并保存: trigger_crawl(platforms=['weibo'], save_to_local=True)
- 使用默认平台: trigger_crawl() # 爬取config.yaml中配置的所有平台
"""
tools = _get_tools()
result = tools['system'].trigger_crawl(platforms=platforms, save_to_local=save_to_local, include_url=include_url)
return json.dumps(result, ensure_ascii=False, indent=2)
# ==================== 启动入口 ====================
def run_server(
project_root: Optional[str] = None,
transport: str = 'stdio',
host: str = '0.0.0.0',
port: int = 3333
):
"""
启动 MCP 服务器
Args:
project_root: 项目根目录路径
transport: 传输模式'stdio' 'http'
host: HTTP模式的监听地址默认 0.0.0.0
port: HTTP模式的监听端口默认 3333
"""
# 初始化工具实例
_get_tools(project_root)
# 打印启动信息
print()
print("=" * 60)
print(" TrendRadar MCP Server - FastMCP 2.0")
print("=" * 60)
print(f" 传输模式: {transport.upper()}")
if transport == 'stdio':
print(" 协议: MCP over stdio (标准输入输出)")
print(" 说明: 通过标准输入输出与 MCP 客户端通信")
elif transport == 'http':
print(f" 监听地址: http://{host}:{port}")
print(f" HTTP端点: http://{host}:{port}/mcp")
print(" 协议: MCP over HTTP (生产环境)")
if project_root:
print(f" 项目目录: {project_root}")
else:
print(" 项目目录: 当前目录")
print()
print(" 已注册的工具:")
print(" === 基础数据查询P0核心===")
print(" 1. get_latest_news - 获取最新新闻")
print(" 2. get_news_by_date - 按日期查询新闻(支持自然语言)")
print(" 3. get_trending_topics - 获取趋势话题")
print()
print(" === 智能检索工具 ===")
print(" 4. search_news - 统一新闻搜索(关键词/模糊/实体)")
print(" 5. search_related_news_history - 历史相关新闻检索")
print()
print(" === 高级数据分析 ===")
print(" 6. analyze_topic_trend - 统一话题趋势分析(热度/生命周期/爆火/预测)")
print(" 7. analyze_data_insights - 统一数据洞察分析(平台对比/活跃度/关键词共现)")
print(" 8. analyze_sentiment - 情感倾向分析")
print(" 9. find_similar_news - 相似新闻查找")
print(" 10. generate_summary_report - 每日/每周摘要生成")
print()
print(" === 配置与系统管理 ===")
print(" 11. get_current_config - 获取当前系统配置")
print(" 12. get_system_status - 获取系统运行状态")
print(" 13. trigger_crawl - 手动触发爬取任务")
print("=" * 60)
print()
# 根据传输模式运行服务器
if transport == 'stdio':
mcp.run(transport='stdio')
elif transport == 'http':
# HTTP 模式(生产推荐)
mcp.run(
transport='http',
host=host,
port=port,
path='/mcp' # HTTP 端点路径
)
else:
raise ValueError(f"不支持的传输模式: {transport}")
if __name__ == '__main__':
import sys
import argparse
parser = argparse.ArgumentParser(
description='TrendRadar MCP Server - 新闻热点聚合 MCP 工具服务器',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
# STDIO 模式(用于 Cherry Studio
uv run python mcp_server/server.py
# HTTP 模式(适合远程访问)
uv run python mcp_server/server.py --transport http --port 3333
Cherry Studio 配置示例:
设置 > MCP Servers > 添加服务器
- 名称: TrendRadar
- 类型: STDIO
- 命令: [UV的完整路径]
- 参数: --directory [项目路径] run python mcp_server/server.py
详细配置教程请查看: README-Cherry-Studio.md
"""
)
parser.add_argument(
'--transport',
choices=['stdio', 'http'],
default='stdio',
help='传输模式stdio (默认) 或 http (生产环境)'
)
parser.add_argument(
'--host',
default='0.0.0.0',
help='HTTP模式的监听地址默认 0.0.0.0'
)
parser.add_argument(
'--port',
type=int,
default=3333,
help='HTTP模式的监听端口默认 3333'
)
parser.add_argument(
'--project-root',
help='项目根目录路径'
)
args = parser.parse_args()
run_server(
project_root=args.project_root,
transport=args.transport,
host=args.host,
port=args.port
)

View File

@ -0,0 +1,5 @@
"""
服务层模块
提供数据访问缓存解析等核心服务
"""

View File

@ -0,0 +1,136 @@
"""
缓存服务
实现TTL缓存机制提升数据访问性能
"""
import time
from typing import Any, Optional
from threading import Lock
class CacheService:
"""缓存服务类"""
def __init__(self):
"""初始化缓存服务"""
self._cache = {}
self._timestamps = {}
self._lock = Lock()
def get(self, key: str, ttl: int = 900) -> Optional[Any]:
"""
获取缓存数据
Args:
key: 缓存键
ttl: 存活时间默认15分钟
Returns:
缓存的值如果不存在或已过期则返回None
"""
with self._lock:
if key in self._cache:
# 检查是否过期
if time.time() - self._timestamps[key] < ttl:
return self._cache[key]
else:
# 已过期,删除缓存
del self._cache[key]
del self._timestamps[key]
return None
def set(self, key: str, value: Any) -> None:
"""
设置缓存数据
Args:
key: 缓存键
value: 缓存值
"""
with self._lock:
self._cache[key] = value
self._timestamps[key] = time.time()
def delete(self, key: str) -> bool:
"""
删除缓存
Args:
key: 缓存键
Returns:
是否成功删除
"""
with self._lock:
if key in self._cache:
del self._cache[key]
del self._timestamps[key]
return True
return False
def clear(self) -> None:
"""清空所有缓存"""
with self._lock:
self._cache.clear()
self._timestamps.clear()
def cleanup_expired(self, ttl: int = 900) -> int:
"""
清理过期缓存
Args:
ttl: 存活时间
Returns:
清理的条目数量
"""
with self._lock:
current_time = time.time()
expired_keys = [
key for key, timestamp in self._timestamps.items()
if current_time - timestamp >= ttl
]
for key in expired_keys:
del self._cache[key]
del self._timestamps[key]
return len(expired_keys)
def get_stats(self) -> dict:
"""
获取缓存统计信息
Returns:
统计信息字典
"""
with self._lock:
return {
"total_entries": len(self._cache),
"oldest_entry_age": (
time.time() - min(self._timestamps.values())
if self._timestamps else 0
),
"newest_entry_age": (
time.time() - max(self._timestamps.values())
if self._timestamps else 0
)
}
# 全局缓存实例
_global_cache = None
def get_cache() -> CacheService:
"""
获取全局缓存实例
Returns:
全局缓存服务实例
"""
global _global_cache
if _global_cache is None:
_global_cache = CacheService()
return _global_cache

View File

@ -0,0 +1,564 @@
"""
数据访问服务
提供统一的数据查询接口,封装数据访问逻辑
"""
import re
from collections import Counter
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from .cache_service import get_cache
from .parser_service import ParserService
from ..utils.errors import DataNotFoundError
class DataService:
"""数据访问服务类"""
def __init__(self, project_root: str = None):
"""
初始化数据服务
Args:
project_root: 项目根目录
"""
self.parser = ParserService(project_root)
self.cache = get_cache()
def get_latest_news(
self,
platforms: Optional[List[str]] = None,
limit: int = 50,
include_url: bool = False
) -> List[Dict]:
"""
获取最新一批爬取的新闻数据
Args:
platforms: 平台ID列表,None表示所有平台
limit: 返回条数限制
include_url: 是否包含URL链接,默认False(节省token)
Returns:
新闻列表
Raises:
DataNotFoundError: 数据不存在
"""
# 尝试从缓存获取
cache_key = f"latest_news:{','.join(platforms or [])}:{limit}:{include_url}"
cached = self.cache.get(cache_key, ttl=900) # 15分钟缓存
if cached:
return cached
# 读取今天的数据
all_titles, id_to_name, timestamps = self.parser.read_all_titles_for_date(
date=None,
platform_ids=platforms
)
# 获取最新的文件时间
if timestamps:
latest_timestamp = max(timestamps.values())
fetch_time = datetime.fromtimestamp(latest_timestamp)
else:
fetch_time = datetime.now()
# 转换为新闻列表
news_list = []
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
# 取第一个排名
rank = info["ranks"][0] if info["ranks"] else 0
news_item = {
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"rank": rank,
"timestamp": fetch_time.strftime("%Y-%m-%d %H:%M:%S")
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobileUrl"] = info.get("mobileUrl", "")
news_list.append(news_item)
# 按排名排序
news_list.sort(key=lambda x: x["rank"])
# 限制返回数量
result = news_list[:limit]
# 缓存结果
self.cache.set(cache_key, result)
return result
def get_news_by_date(
self,
target_date: datetime,
platforms: Optional[List[str]] = None,
limit: int = 50,
include_url: bool = False
) -> List[Dict]:
"""
按指定日期获取新闻
Args:
target_date: 目标日期
platforms: 平台ID列表,None表示所有平台
limit: 返回条数限制
include_url: 是否包含URL链接,默认False(节省token)
Returns:
新闻列表
Raises:
DataNotFoundError: 数据不存在
Examples:
>>> service = DataService()
>>> news = service.get_news_by_date(
... target_date=datetime(2025, 10, 10),
... platforms=['zhihu'],
... limit=20
... )
"""
# 尝试从缓存获取
date_str = target_date.strftime("%Y-%m-%d")
cache_key = f"news_by_date:{date_str}:{','.join(platforms or [])}:{limit}:{include_url}"
cached = self.cache.get(cache_key, ttl=1800) # 30分钟缓存
if cached:
return cached
# 读取指定日期的数据
all_titles, id_to_name, timestamps = self.parser.read_all_titles_for_date(
date=target_date,
platform_ids=platforms
)
# 转换为新闻列表
news_list = []
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
# 计算平均排名
avg_rank = sum(info["ranks"]) / len(info["ranks"]) if info["ranks"] else 0
news_item = {
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"rank": info["ranks"][0] if info["ranks"] else 0,
"avg_rank": round(avg_rank, 2),
"count": len(info["ranks"]),
"date": date_str
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobileUrl"] = info.get("mobileUrl", "")
news_list.append(news_item)
# 按排名排序
news_list.sort(key=lambda x: x["rank"])
# 限制返回数量
result = news_list[:limit]
# 缓存结果(历史数据缓存更久)
self.cache.set(cache_key, result)
return result
def search_news_by_keyword(
self,
keyword: str,
date_range: Optional[Tuple[datetime, datetime]] = None,
platforms: Optional[List[str]] = None,
limit: Optional[int] = None
) -> Dict:
"""
按关键词搜索新闻
Args:
keyword: 搜索关键词
date_range: 日期范围 (start_date, end_date)
platforms: 平台过滤列表
limit: 返回条数限制(可选)
Returns:
搜索结果字典
Raises:
DataNotFoundError: 数据不存在
"""
# 确定搜索日期范围
if date_range:
start_date, end_date = date_range
else:
# 默认搜索今天
start_date = end_date = datetime.now()
# 收集所有匹配的新闻
results = []
platform_distribution = Counter()
# 遍历日期范围
current_date = start_date
while current_date <= end_date:
try:
all_titles, id_to_name, _ = self.parser.read_all_titles_for_date(
date=current_date,
platform_ids=platforms
)
# 搜索包含关键词的标题
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
if keyword.lower() in title.lower():
# 计算平均排名
avg_rank = sum(info["ranks"]) / len(info["ranks"]) if info["ranks"] else 0
results.append({
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"ranks": info["ranks"],
"count": len(info["ranks"]),
"avg_rank": round(avg_rank, 2),
"url": info.get("url", ""),
"mobileUrl": info.get("mobileUrl", ""),
"date": current_date.strftime("%Y-%m-%d")
})
platform_distribution[platform_id] += 1
except DataNotFoundError:
# 该日期没有数据,继续下一天
pass
# 下一天
current_date += timedelta(days=1)
if not results:
raise DataNotFoundError(
f"未找到包含关键词 '{keyword}' 的新闻",
suggestion="请尝试其他关键词或扩大日期范围"
)
# 计算统计信息
total_ranks = []
for item in results:
total_ranks.extend(item["ranks"])
avg_rank = sum(total_ranks) / len(total_ranks) if total_ranks else 0
# 限制返回数量(如果指定)
total_found = len(results)
if limit is not None and limit > 0:
results = results[:limit]
return {
"results": results,
"total": len(results),
"total_found": total_found,
"statistics": {
"platform_distribution": dict(platform_distribution),
"avg_rank": round(avg_rank, 2),
"keyword": keyword
}
}
def get_trending_topics(
self,
top_n: int = 10,
mode: str = "current"
) -> Dict:
"""
获取个人关注词的新闻出现频率统计
注意:本工具基于 config/frequency_words.txt 中的个人关注词列表进行统计,
而不是自动从新闻中提取热点话题用户可以自定义这个关注词列表
Args:
top_n: 返回TOP N关注词
mode: 模式 - daily(当日累计), current(最新一批)
Returns:
关注词频率统计字典
Raises:
DataNotFoundError: 数据不存在
"""
# 尝试从缓存获取
cache_key = f"trending_topics:{top_n}:{mode}"
cached = self.cache.get(cache_key, ttl=1800) # 30分钟缓存
if cached:
return cached
# 读取今天的数据
all_titles, id_to_name, timestamps = self.parser.read_all_titles_for_date()
if not all_titles:
raise DataNotFoundError(
"未找到今天的新闻数据",
suggestion="请确保爬虫已经运行并生成了数据"
)
# 加载关键词配置
word_groups = self.parser.parse_frequency_words()
# 根据mode选择要处理的标题数据
titles_to_process = {}
if mode == "daily":
# daily模式:处理当天所有累计数据
titles_to_process = all_titles
elif mode == "current":
# current模式:只处理最新一批数据(最新时间戳的文件)
if timestamps:
# 找出最新的时间戳
latest_timestamp = max(timestamps.values())
# 重新读取,只获取最新时间的数据
# 这里我们通过timestamps字典反查找最新文件对应的平台
latest_titles, _, _ = self.parser.read_all_titles_for_date()
# 由于read_all_titles_for_date返回所有文件的合并数据,
# 我们需要通过timestamps来过滤出最新批次
# 简化实现:使用当前所有数据作为最新批次
# (更精确的实现需要解析服务支持按时间过滤)
titles_to_process = latest_titles
else:
titles_to_process = all_titles
else:
raise ValueError(
f"不支持的模式: {mode}。支持的模式: daily, current"
)
# 统计词频
word_frequency = Counter()
keyword_to_news = {}
# 遍历要处理的标题
for platform_id, titles in titles_to_process.items():
for title in titles.keys():
# 对每个关键词组进行匹配
for group in word_groups:
all_words = group.get("required", []) + group.get("normal", [])
for word in all_words:
if word and word in title:
word_frequency[word] += 1
if word not in keyword_to_news:
keyword_to_news[word] = []
keyword_to_news[word].append(title)
# 获取TOP N关键词
top_keywords = word_frequency.most_common(top_n)
# 构建话题列表
topics = []
for keyword, frequency in top_keywords:
matched_news = keyword_to_news.get(keyword, [])
topics.append({
"keyword": keyword,
"frequency": frequency,
"matched_news": len(set(matched_news)), # 去重后的新闻数量
"trend": "stable", # TODO: 需要历史数据来计算趋势
"weight_score": 0.0 # TODO: 需要实现权重计算
})
# 构建结果
result = {
"topics": topics,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"mode": mode,
"total_keywords": len(word_frequency),
"description": self._get_mode_description(mode)
}
# 缓存结果
self.cache.set(cache_key, result)
return result
def _get_mode_description(self, mode: str) -> str:
"""获取模式描述"""
descriptions = {
"daily": "当日累计统计",
"current": "最新一批统计"
}
return descriptions.get(mode, "未知模式")
def get_current_config(self, section: str = "all") -> Dict:
"""
获取当前系统配置
Args:
section: 配置节 - all/crawler/push/keywords/weights
Returns:
配置字典
Raises:
FileParseError: 配置文件解析错误
"""
# 尝试从缓存获取
cache_key = f"config:{section}"
cached = self.cache.get(cache_key, ttl=3600) # 1小时缓存
if cached:
return cached
# 解析配置文件
config_data = self.parser.parse_yaml_config()
word_groups = self.parser.parse_frequency_words()
# 根据section返回对应配置
if section == "all" or section == "crawler":
crawler_config = {
"enable_crawler": config_data.get("crawler", {}).get("enable_crawler", True),
"use_proxy": config_data.get("crawler", {}).get("use_proxy", False),
"request_interval": config_data.get("crawler", {}).get("request_interval", 1),
"retry_times": 3,
"platforms": [p["id"] for p in config_data.get("platforms", [])]
}
if section == "all" or section == "push":
push_config = {
"enable_notification": config_data.get("notification", {}).get("enable_notification", True),
"enabled_channels": [],
"message_batch_size": config_data.get("notification", {}).get("message_batch_size", 20),
"push_window": config_data.get("notification", {}).get("push_window", {})
}
# 检测已配置的通知渠道
webhooks = config_data.get("notification", {}).get("webhooks", {})
if webhooks.get("feishu_url"):
push_config["enabled_channels"].append("feishu")
if webhooks.get("dingtalk_url"):
push_config["enabled_channels"].append("dingtalk")
if webhooks.get("wework_url"):
push_config["enabled_channels"].append("wework")
if section == "all" or section == "keywords":
keywords_config = {
"word_groups": word_groups,
"total_groups": len(word_groups)
}
if section == "all" or section == "weights":
weights_config = {
"rank_weight": config_data.get("weight", {}).get("rank_weight", 0.6),
"frequency_weight": config_data.get("weight", {}).get("frequency_weight", 0.3),
"hotness_weight": config_data.get("weight", {}).get("hotness_weight", 0.1)
}
# 组装结果
if section == "all":
result = {
"crawler": crawler_config,
"push": push_config,
"keywords": keywords_config,
"weights": weights_config
}
elif section == "crawler":
result = crawler_config
elif section == "push":
result = push_config
elif section == "keywords":
result = keywords_config
elif section == "weights":
result = weights_config
else:
result = {}
# 缓存结果
self.cache.set(cache_key, result)
return result
def get_system_status(self) -> Dict:
"""
获取系统运行状态
Returns:
系统状态字典
"""
# 获取数据统计
output_dir = self.parser.project_root / "output"
total_storage = 0
oldest_record = None
latest_record = None
total_news = 0
if output_dir.exists():
# 遍历日期文件夹
for date_folder in output_dir.iterdir():
if date_folder.is_dir():
# 解析日期
try:
date_str = date_folder.name
# 格式: YYYY年MM月DD日
date_match = re.match(r'(\d{4})年(\d{2})月(\d{2})日', date_str)
if date_match:
folder_date = datetime(
int(date_match.group(1)),
int(date_match.group(2)),
int(date_match.group(3))
)
if oldest_record is None or folder_date < oldest_record:
oldest_record = folder_date
if latest_record is None or folder_date > latest_record:
latest_record = folder_date
except:
pass
# 计算存储大小
for item in date_folder.rglob("*"):
if item.is_file():
total_storage += item.stat().st_size
# 读取版本信息
version_file = self.parser.project_root / "version"
version = "unknown"
if version_file.exists():
try:
with open(version_file, "r") as f:
version = f.read().strip()
except:
pass
return {
"system": {
"version": version,
"project_root": str(self.parser.project_root)
},
"data": {
"total_storage": f"{total_storage / 1024 / 1024:.2f} MB",
"oldest_record": oldest_record.strftime("%Y-%m-%d") if oldest_record else None,
"latest_record": latest_record.strftime("%Y-%m-%d") if latest_record else None,
},
"cache": self.cache.get_stats(),
"health": "healthy"
}

View File

@ -0,0 +1,355 @@
"""
文件解析服务
提供txt格式新闻数据和YAML配置文件的解析功能
"""
import re
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from datetime import datetime
import yaml
from ..utils.errors import FileParseError, DataNotFoundError
from .cache_service import get_cache
class ParserService:
"""文件解析服务类"""
def __init__(self, project_root: str = None):
"""
初始化解析服务
Args:
project_root: 项目根目录默认为当前目录的父目录
"""
if project_root is None:
# 获取当前文件所在目录的父目录的父目录
current_file = Path(__file__)
self.project_root = current_file.parent.parent.parent
else:
self.project_root = Path(project_root)
# 初始化缓存服务
self.cache = get_cache()
@staticmethod
def clean_title(title: str) -> str:
"""
清理标题文本
Args:
title: 原始标题
Returns:
清理后的标题
"""
# 移除多余空白
title = re.sub(r'\s+', ' ', title)
# 移除特殊字符
title = title.strip()
return title
def parse_txt_file(self, file_path: Path) -> Tuple[Dict, Dict]:
"""
解析单个txt文件的标题数据
Args:
file_path: txt文件路径
Returns:
(titles_by_id, id_to_name) 元组
- titles_by_id: {platform_id: {title: {ranks, url, mobileUrl}}}
- id_to_name: {platform_id: platform_name}
Raises:
FileParseError: 文件解析错误
"""
if not file_path.exists():
raise FileParseError(str(file_path), "文件不存在")
titles_by_id = {}
id_to_name = {}
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
sections = content.split("\n\n")
for section in sections:
if not section.strip() or "==== 以下ID请求失败 ====" in section:
continue
lines = section.strip().split("\n")
if len(lines) < 2:
continue
# 解析header: id | name 或 id
header_line = lines[0].strip()
if " | " in header_line:
parts = header_line.split(" | ", 1)
source_id = parts[0].strip()
name = parts[1].strip()
id_to_name[source_id] = name
else:
source_id = header_line
id_to_name[source_id] = source_id
titles_by_id[source_id] = {}
# 解析标题行
for line in lines[1:]:
if line.strip():
try:
title_part = line.strip()
rank = None
# 提取排名
if ". " in title_part and title_part.split(". ")[0].isdigit():
rank_str, title_part = title_part.split(". ", 1)
rank = int(rank_str)
# 提取 MOBILE URL
mobile_url = ""
if " [MOBILE:" in title_part:
title_part, mobile_part = title_part.rsplit(" [MOBILE:", 1)
if mobile_part.endswith("]"):
mobile_url = mobile_part[:-1]
# 提取 URL
url = ""
if " [URL:" in title_part:
title_part, url_part = title_part.rsplit(" [URL:", 1)
if url_part.endswith("]"):
url = url_part[:-1]
title = self.clean_title(title_part.strip())
ranks = [rank] if rank is not None else [1]
titles_by_id[source_id][title] = {
"ranks": ranks,
"url": url,
"mobileUrl": mobile_url,
}
except Exception as e:
# 忽略单行解析错误
continue
except Exception as e:
raise FileParseError(str(file_path), str(e))
return titles_by_id, id_to_name
def get_date_folder_name(self, date: datetime = None) -> str:
"""
获取日期文件夹名称
Args:
date: 日期对象默认为今天
Returns:
文件夹名称格式: YYYY年MM月DD日
"""
if date is None:
date = datetime.now()
return date.strftime("%Y年%m月%d")
def read_all_titles_for_date(
self,
date: datetime = None,
platform_ids: Optional[List[str]] = None
) -> Tuple[Dict, Dict, Dict]:
"""
读取指定日期的所有标题文件带缓存
Args:
date: 日期对象默认为今天
platform_ids: 平台ID列表None表示所有平台
Returns:
(all_titles, id_to_name, all_timestamps) 元组
- all_titles: {platform_id: {title: {ranks, url, mobileUrl, ...}}}
- id_to_name: {platform_id: platform_name}
- all_timestamps: {filename: timestamp}
Raises:
DataNotFoundError: 数据不存在
"""
# 生成缓存键
date_str = self.get_date_folder_name(date)
platform_key = ','.join(sorted(platform_ids)) if platform_ids else 'all'
cache_key = f"read_all_titles:{date_str}:{platform_key}"
# 尝试从缓存获取
# 对于历史数据非今天使用更长的缓存时间1小时
# 对于今天的数据使用较短的缓存时间15分钟因为可能有新数据
is_today = (date is None) or (date.date() == datetime.now().date())
ttl = 900 if is_today else 3600 # 15分钟 vs 1小时
cached = self.cache.get(cache_key, ttl=ttl)
if cached:
return cached
# 缓存未命中,读取文件
date_folder = self.get_date_folder_name(date)
txt_dir = self.project_root / "output" / date_folder / "txt"
if not txt_dir.exists():
raise DataNotFoundError(
f"未找到 {date_folder} 的数据目录",
suggestion="请先运行爬虫或检查日期是否正确"
)
all_titles = {}
id_to_name = {}
all_timestamps = {}
# 读取所有txt文件
txt_files = sorted(txt_dir.glob("*.txt"))
if not txt_files:
raise DataNotFoundError(
f"{date_folder} 没有数据文件",
suggestion="请等待爬虫任务完成"
)
for txt_file in txt_files:
try:
titles_by_id, file_id_to_name = self.parse_txt_file(txt_file)
# 更新id_to_name
id_to_name.update(file_id_to_name)
# 合并标题数据
for platform_id, titles in titles_by_id.items():
# 如果指定了平台过滤
if platform_ids and platform_id not in platform_ids:
continue
if platform_id not in all_titles:
all_titles[platform_id] = {}
for title, info in titles.items():
if title in all_titles[platform_id]:
# 合并排名
all_titles[platform_id][title]["ranks"].extend(info["ranks"])
else:
all_titles[platform_id][title] = info.copy()
# 记录文件时间戳
all_timestamps[txt_file.name] = txt_file.stat().st_mtime
except Exception as e:
# 忽略单个文件的解析错误,继续处理其他文件
print(f"Warning: 解析文件 {txt_file} 失败: {e}")
continue
if not all_titles:
raise DataNotFoundError(
f"{date_folder} 没有有效的数据",
suggestion="请检查数据文件格式或重新运行爬虫"
)
# 缓存结果
result = (all_titles, id_to_name, all_timestamps)
self.cache.set(cache_key, result)
return result
def parse_yaml_config(self, config_path: str = None) -> dict:
"""
解析YAML配置文件
Args:
config_path: 配置文件路径默认为 config/config.yaml
Returns:
配置字典
Raises:
FileParseError: 配置文件解析错误
"""
if config_path is None:
config_path = self.project_root / "config" / "config.yaml"
else:
config_path = Path(config_path)
if not config_path.exists():
raise FileParseError(str(config_path), "配置文件不存在")
try:
with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
return config_data
except Exception as e:
raise FileParseError(str(config_path), str(e))
def parse_frequency_words(self, words_file: str = None) -> List[Dict]:
"""
解析关键词配置文件
Args:
words_file: 关键词文件路径默认为 config/frequency_words.txt
Returns:
词组列表
Raises:
FileParseError: 文件解析错误
"""
if words_file is None:
words_file = self.project_root / "config" / "frequency_words.txt"
else:
words_file = Path(words_file)
if not words_file.exists():
return []
word_groups = []
try:
with open(words_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# 使用 | 分隔符
parts = [p.strip() for p in line.split("|")]
if not parts:
continue
group = {
"required": [],
"normal": [],
"filter_words": []
}
for part in parts:
if not part:
continue
words = [w.strip() for w in part.split(",")]
for word in words:
if not word:
continue
if word.endswith("+"):
# 必须词
group["required"].append(word[:-1])
elif word.endswith("!"):
# 过滤词
group["filter_words"].append(word[:-1])
else:
# 普通词
group["normal"].append(word)
if group["required"] or group["normal"]:
word_groups.append(group)
except Exception as e:
raise FileParseError(str(words_file), str(e))
return word_groups

View File

@ -0,0 +1,5 @@
"""
MCP 工具模块
包含所有MCP工具的实现
"""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
"""
配置管理工具
实现配置查询和管理功能
"""
from typing import Dict, Optional
from ..services.data_service import DataService
from ..utils.validators import validate_config_section
from ..utils.errors import MCPError
class ConfigManagementTools:
"""配置管理工具类"""
def __init__(self, project_root: str = None):
"""
初始化配置管理工具
Args:
project_root: 项目根目录
"""
self.data_service = DataService(project_root)
def get_current_config(self, section: Optional[str] = None) -> Dict:
"""
获取当前系统配置
Args:
section: 配置节 - all/crawler/push/keywords/weights默认all
Returns:
配置字典
Example:
>>> tools = ConfigManagementTools()
>>> result = tools.get_current_config(section="crawler")
>>> print(result['crawler']['platforms'])
"""
try:
# 参数验证
section = validate_config_section(section)
# 获取配置
config = self.data_service.get_current_config(section=section)
return {
"config": config,
"section": section,
"success": True
}
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}

View File

@ -0,0 +1,284 @@
"""
数据查询工具
实现P0核心的数据查询工具
"""
from typing import Dict, List, Optional
from ..services.data_service import DataService
from ..utils.validators import (
validate_platforms,
validate_limit,
validate_keyword,
validate_date_range,
validate_top_n,
validate_mode,
validate_date_query
)
from ..utils.errors import MCPError
class DataQueryTools:
"""数据查询工具类"""
def __init__(self, project_root: str = None):
"""
初始化数据查询工具
Args:
project_root: 项目根目录
"""
self.data_service = DataService(project_root)
def get_latest_news(
self,
platforms: Optional[List[str]] = None,
limit: Optional[int] = None,
include_url: bool = False
) -> Dict:
"""
获取最新一批爬取的新闻数据
Args:
platforms: 平台ID列表 ['zhihu', 'weibo']
limit: 返回条数限制默认20
include_url: 是否包含URL链接默认False节省token
Returns:
新闻列表字典
Example:
>>> tools = DataQueryTools()
>>> result = tools.get_latest_news(platforms=['zhihu'], limit=10)
>>> print(result['total'])
10
"""
try:
# 参数验证
platforms = validate_platforms(platforms)
limit = validate_limit(limit, default=50)
# 获取数据
news_list = self.data_service.get_latest_news(
platforms=platforms,
limit=limit,
include_url=include_url
)
return {
"news": news_list,
"total": len(news_list),
"platforms": platforms,
"success": True
}
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}
def search_news_by_keyword(
self,
keyword: str,
date_range: Optional[Dict] = None,
platforms: Optional[List[str]] = None,
limit: Optional[int] = None
) -> Dict:
"""
按关键词搜索历史新闻
Args:
keyword: 搜索关键词必需
date_range: 日期范围格式: {"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"}
platforms: 平台过滤列表
limit: 返回条数限制可选默认返回所有
Returns:
搜索结果字典
Example:
>>> tools = DataQueryTools()
>>> result = tools.search_news_by_keyword(
... keyword="人工智能",
... date_range={"start": "2025-10-01", "end": "2025-10-11"},
... limit=50
... )
>>> print(result['total'])
"""
try:
# 参数验证
keyword = validate_keyword(keyword)
date_range_tuple = validate_date_range(date_range)
platforms = validate_platforms(platforms)
if limit is not None:
limit = validate_limit(limit, default=100)
# 搜索数据
search_result = self.data_service.search_news_by_keyword(
keyword=keyword,
date_range=date_range_tuple,
platforms=platforms,
limit=limit
)
return {
**search_result,
"success": True
}
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}
def get_trending_topics(
self,
top_n: Optional[int] = None,
mode: Optional[str] = None
) -> Dict:
"""
获取个人关注词的新闻出现频率统计
注意本工具基于 config/frequency_words.txt 中的个人关注词列表进行统计
而不是自动从新闻中提取热点话题这是一个个人可定制的关注词列表
用户可以根据自己的兴趣添加或删除关注词
Args:
top_n: 返回TOP N关注词默认10
mode: 模式 - daily(当日累计), current(最新一批), incremental(增量)
Returns:
关注词频率统计字典包含每个关注词在新闻中出现的次数
Example:
>>> tools = DataQueryTools()
>>> result = tools.get_trending_topics(top_n=5, mode="current")
>>> print(len(result['topics']))
5
>>> # 返回的是你在 frequency_words.txt 中设置的关注词的频率统计
"""
try:
# 参数验证
top_n = validate_top_n(top_n, default=10)
valid_modes = ["daily", "current", "incremental"]
mode = validate_mode(mode, valid_modes, default="current")
# 获取趋势话题
trending_result = self.data_service.get_trending_topics(
top_n=top_n,
mode=mode
)
return {
**trending_result,
"success": True
}
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}
def get_news_by_date(
self,
date_query: Optional[str] = None,
platforms: Optional[List[str]] = None,
limit: Optional[int] = None,
include_url: bool = False
) -> Dict:
"""
按日期查询新闻支持自然语言日期
Args:
date_query: 日期查询字符串可选默认"今天"支持
- 相对日期今天昨天前天3天前yesterday3 days ago
- 星期上周一本周三last mondaythis friday
- 绝对日期2025-10-1010月10日2025年10月10日
platforms: 平台ID列表 ['zhihu', 'weibo']
limit: 返回条数限制默认50
include_url: 是否包含URL链接默认False节省token
Returns:
新闻列表字典
Example:
>>> tools = DataQueryTools()
>>> # 不指定日期,默认查询今天
>>> result = tools.get_news_by_date(platforms=['zhihu'], limit=20)
>>> # 指定日期
>>> result = tools.get_news_by_date(
... date_query="昨天",
... platforms=['zhihu'],
... limit=20
... )
>>> print(result['total'])
20
"""
try:
# 参数验证 - 默认今天
if date_query is None:
date_query = "今天"
target_date = validate_date_query(date_query)
platforms = validate_platforms(platforms)
limit = validate_limit(limit, default=50)
# 获取数据
news_list = self.data_service.get_news_by_date(
target_date=target_date,
platforms=platforms,
limit=limit,
include_url=include_url
)
return {
"news": news_list,
"total": len(news_list),
"date": target_date.strftime("%Y-%m-%d"),
"date_query": date_query,
"platforms": platforms,
"success": True
}
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}

View File

@ -0,0 +1,664 @@
"""
智能新闻检索工具
提供模糊搜索链接查询历史相关新闻检索等高级搜索功能
"""
import re
from collections import Counter
from datetime import datetime, timedelta
from difflib import SequenceMatcher
from typing import Dict, List, Optional, Tuple
from ..services.data_service import DataService
from ..utils.validators import validate_keyword, validate_limit
from ..utils.errors import MCPError, InvalidParameterError, DataNotFoundError
class SearchTools:
"""智能新闻检索工具类"""
def __init__(self, project_root: str = None):
"""
初始化智能检索工具
Args:
project_root: 项目根目录
"""
self.data_service = DataService(project_root)
# 中文停用词列表
self.stopwords = {
'', '', '', '', '', '', '', '', '', '', '', '',
'一个', '', '', '', '', '', '', '', '', '', '', '没有',
'', '', '自己', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '可以', '',
'已经', '', '', '', '', '因为', '所以', '如果', '虽然', '然而'
}
def search_news_unified(
self,
query: str,
search_mode: str = "keyword",
date_range: Optional[Dict[str, str]] = None,
platforms: Optional[List[str]] = None,
limit: int = 50,
sort_by: str = "relevance",
threshold: float = 0.6,
include_url: bool = False
) -> Dict:
"""
统一新闻搜索工具 - 整合多种搜索模式
Args:
query: 查询内容必需- 关键词内容片段或实体名称
search_mode: 搜索模式可选值
- "keyword": 精确关键词匹配默认
- "fuzzy": 模糊内容匹配使用相似度算法
- "entity": 实体名称搜索自动按权重排序
date_range: 日期范围格式: {"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"}
不指定则默认查询今天
platforms: 平台过滤列表 ['zhihu', 'weibo']
limit: 返回条数限制默认50
sort_by: 排序方式可选值
- "relevance": 按相关度排序默认
- "weight": 按新闻权重排序
- "date": 按日期排序
threshold: 相似度阈值仅fuzzy模式有效0-1之间默认0.6
include_url: 是否包含URL链接默认False节省token
Returns:
搜索结果字典包含匹配的新闻列表
Examples:
- search_news_unified(query="人工智能", search_mode="keyword")
- search_news_unified(query="特斯拉降价", search_mode="fuzzy", threshold=0.4)
- search_news_unified(query="马斯克", search_mode="entity", limit=20)
- search_news_unified(query="iPhone 16发布", search_mode="keyword")
"""
try:
# 参数验证
query = validate_keyword(query)
if search_mode not in ["keyword", "fuzzy", "entity"]:
raise InvalidParameterError(
f"无效的搜索模式: {search_mode}",
suggestion="支持的模式: keyword, fuzzy, entity"
)
if sort_by not in ["relevance", "weight", "date"]:
raise InvalidParameterError(
f"无效的排序方式: {sort_by}",
suggestion="支持的排序: relevance, weight, date"
)
limit = validate_limit(limit, default=50)
threshold = max(0.0, min(1.0, threshold))
# 处理日期范围
if date_range:
from ..utils.validators import validate_date_range
date_range_tuple = validate_date_range(date_range)
start_date, end_date = date_range_tuple
else:
# 默认今天
start_date = end_date = datetime.now()
# 收集所有匹配的新闻
all_matches = []
current_date = start_date
while current_date <= end_date:
try:
all_titles, id_to_name, timestamps = self.data_service.parser.read_all_titles_for_date(
date=current_date,
platform_ids=platforms
)
# 根据搜索模式执行不同的搜索逻辑
if search_mode == "keyword":
matches = self._search_by_keyword_mode(
query, all_titles, id_to_name, current_date, include_url
)
elif search_mode == "fuzzy":
matches = self._search_by_fuzzy_mode(
query, all_titles, id_to_name, current_date, threshold, include_url
)
else: # entity
matches = self._search_by_entity_mode(
query, all_titles, id_to_name, current_date, include_url
)
all_matches.extend(matches)
except DataNotFoundError:
# 该日期没有数据,继续下一天
pass
current_date += timedelta(days=1)
if not all_matches:
time_desc = "今天" if start_date == end_date else f"{start_date.strftime('%Y-%m-%d')}{end_date.strftime('%Y-%m-%d')}"
return {
"success": True,
"results": [],
"total": 0,
"query": query,
"search_mode": search_mode,
"time_range": time_desc,
"message": f"未找到匹配的新闻({time_desc}"
}
# 统一排序逻辑
if sort_by == "relevance":
all_matches.sort(key=lambda x: x.get("similarity_score", 1.0), reverse=True)
elif sort_by == "weight":
from .analytics import calculate_news_weight
all_matches.sort(key=lambda x: calculate_news_weight(x), reverse=True)
elif sort_by == "date":
all_matches.sort(key=lambda x: x.get("date", ""), reverse=True)
# 限制返回数量
results = all_matches[:limit]
# 构建时间范围描述
if start_date == end_date:
time_range_desc = start_date.strftime("%Y-%m-%d")
else:
time_range_desc = f"{start_date.strftime('%Y-%m-%d')}{end_date.strftime('%Y-%m-%d')}"
result = {
"success": True,
"summary": {
"total_found": len(all_matches),
"returned_count": len(results),
"requested_limit": limit,
"search_mode": search_mode,
"query": query,
"platforms": platforms or "所有平台",
"time_range": time_range_desc,
"sort_by": sort_by
},
"results": results
}
if search_mode == "fuzzy":
result["summary"]["threshold"] = threshold
if len(all_matches) < limit:
result["note"] = f"模糊搜索模式下,相似度阈值 {threshold} 仅匹配到 {len(all_matches)} 条结果"
return result
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}
def _search_by_keyword_mode(
self,
query: str,
all_titles: Dict,
id_to_name: Dict,
current_date: datetime,
include_url: bool
) -> List[Dict]:
"""
关键词搜索模式精确匹配
Args:
query: 搜索关键词
all_titles: 所有标题字典
id_to_name: 平台ID到名称映射
current_date: 当前日期
Returns:
匹配的新闻列表
"""
matches = []
query_lower = query.lower()
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
# 精确包含判断
if query_lower in title.lower():
news_item = {
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"date": current_date.strftime("%Y-%m-%d"),
"similarity_score": 1.0, # 精确匹配相似度为1
"ranks": info.get("ranks", []),
"count": len(info.get("ranks", [])),
"rank": info["ranks"][0] if info["ranks"] else 999
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobileUrl"] = info.get("mobileUrl", "")
matches.append(news_item)
return matches
def _search_by_fuzzy_mode(
self,
query: str,
all_titles: Dict,
id_to_name: Dict,
current_date: datetime,
threshold: float,
include_url: bool
) -> List[Dict]:
"""
模糊搜索模式使用相似度算法
Args:
query: 搜索内容
all_titles: 所有标题字典
id_to_name: 平台ID到名称映射
current_date: 当前日期
threshold: 相似度阈值
Returns:
匹配的新闻列表
"""
matches = []
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
# 模糊匹配
is_match, similarity = self._fuzzy_match(query, title, threshold)
if is_match:
news_item = {
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"date": current_date.strftime("%Y-%m-%d"),
"similarity_score": round(similarity, 4),
"ranks": info.get("ranks", []),
"count": len(info.get("ranks", [])),
"rank": info["ranks"][0] if info["ranks"] else 999
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobileUrl"] = info.get("mobileUrl", "")
matches.append(news_item)
return matches
def _search_by_entity_mode(
self,
query: str,
all_titles: Dict,
id_to_name: Dict,
current_date: datetime,
include_url: bool
) -> List[Dict]:
"""
实体搜索模式自动按权重排序
Args:
query: 实体名称
all_titles: 所有标题字典
id_to_name: 平台ID到名称映射
current_date: 当前日期
Returns:
匹配的新闻列表
"""
matches = []
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
# 实体搜索:精确包含实体名称
if query in title:
news_item = {
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"date": current_date.strftime("%Y-%m-%d"),
"similarity_score": 1.0,
"ranks": info.get("ranks", []),
"count": len(info.get("ranks", [])),
"rank": info["ranks"][0] if info["ranks"] else 999
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobileUrl"] = info.get("mobileUrl", "")
matches.append(news_item)
return matches
def _calculate_similarity(self, text1: str, text2: str) -> float:
"""
计算两个文本的相似度
Args:
text1: 文本1
text2: 文本2
Returns:
相似度分数 (0-1之间)
"""
# 使用 difflib.SequenceMatcher 计算序列相似度
return SequenceMatcher(None, text1.lower(), text2.lower()).ratio()
def _fuzzy_match(self, query: str, text: str, threshold: float = 0.3) -> Tuple[bool, float]:
"""
模糊匹配函数
Args:
query: 查询文本
text: 待匹配文本
threshold: 匹配阈值
Returns:
(是否匹配, 相似度分数)
"""
# 直接包含判断
if query.lower() in text.lower():
return True, 1.0
# 计算整体相似度
similarity = self._calculate_similarity(query, text)
if similarity >= threshold:
return True, similarity
# 分词后的部分匹配
query_words = set(self._extract_keywords(query))
text_words = set(self._extract_keywords(text))
if not query_words or not text_words:
return False, 0.0
# 计算关键词重合度
common_words = query_words & text_words
keyword_overlap = len(common_words) / len(query_words)
if keyword_overlap >= 0.5: # 50%的关键词重合
return True, keyword_overlap
return False, similarity
def _extract_keywords(self, text: str, min_length: int = 2) -> List[str]:
"""
从文本中提取关键词
Args:
text: 输入文本
min_length: 最小词长
Returns:
关键词列表
"""
# 移除URL和特殊字符
text = re.sub(r'http[s]?://\S+', '', text)
text = re.sub(r'\[.*?\]', '', text) # 移除方括号内容
# 使用正则表达式分词(中文和英文)
words = re.findall(r'[\w]+', text)
# 过滤停用词和短词
keywords = [
word for word in words
if word and len(word) >= min_length and word not in self.stopwords
]
return keywords
def _calculate_keyword_overlap(self, keywords1: List[str], keywords2: List[str]) -> float:
"""
计算两个关键词列表的重合度
Args:
keywords1: 关键词列表1
keywords2: 关键词列表2
Returns:
重合度分数 (0-1之间)
"""
if not keywords1 or not keywords2:
return 0.0
set1 = set(keywords1)
set2 = set(keywords2)
# Jaccard 相似度
intersection = len(set1 & set2)
union = len(set1 | set2)
if union == 0:
return 0.0
return intersection / union
def search_related_news_history(
self,
reference_text: str,
time_range: str = "yesterday",
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
threshold: float = 0.4,
limit: int = 50,
include_url: bool = False
) -> Dict:
"""
在历史数据中搜索与给定新闻相关的新闻
Args:
reference_text: 参考新闻标题或内容
time_range: 时间范围预设值可选
- "yesterday": 昨天
- "last_week": 上周 (7)
- "last_month": 上个月 (30)
- "custom": 自定义日期范围需要提供 start_date end_date
start_date: 自定义开始日期仅当 time_range="custom" 时有效
end_date: 自定义结束日期仅当 time_range="custom" 时有效
threshold: 相似度阈值 (0-1之间)默认0.4
limit: 返回条数限制默认50
include_url: 是否包含URL链接默认False节省token
Returns:
搜索结果字典包含相关新闻列表
Example:
>>> tools = SearchTools()
>>> result = tools.search_related_news_history(
... reference_text="人工智能技术突破",
... time_range="last_week",
... threshold=0.4,
... limit=50
... )
>>> for news in result['results']:
... print(f"{news['date']}: {news['title']} (相似度: {news['similarity_score']})")
"""
try:
# 参数验证
reference_text = validate_keyword(reference_text)
threshold = max(0.0, min(1.0, threshold))
limit = validate_limit(limit, default=50)
# 确定查询日期范围
today = datetime.now()
if time_range == "yesterday":
search_start = today - timedelta(days=1)
search_end = today - timedelta(days=1)
elif time_range == "last_week":
search_start = today - timedelta(days=7)
search_end = today - timedelta(days=1)
elif time_range == "last_month":
search_start = today - timedelta(days=30)
search_end = today - timedelta(days=1)
elif time_range == "custom":
if not start_date or not end_date:
raise InvalidParameterError(
"自定义时间范围需要提供 start_date 和 end_date",
suggestion="请提供 start_date 和 end_date 参数"
)
search_start = start_date
search_end = end_date
else:
raise InvalidParameterError(
f"不支持的时间范围: {time_range}",
suggestion="请使用 'yesterday', 'last_week', 'last_month''custom'"
)
# 提取参考文本的关键词
reference_keywords = self._extract_keywords(reference_text)
if not reference_keywords:
raise InvalidParameterError(
"无法从参考文本中提取关键词",
suggestion="请提供更详细的文本内容"
)
# 收集所有相关新闻
all_related_news = []
current_date = search_start
while current_date <= search_end:
try:
# 读取该日期的数据
all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(current_date)
# 搜索相关新闻
for platform_id, titles in all_titles.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles.items():
# 计算标题相似度
title_similarity = self._calculate_similarity(reference_text, title)
# 提取标题关键词
title_keywords = self._extract_keywords(title)
# 计算关键词重合度
keyword_overlap = self._calculate_keyword_overlap(
reference_keywords,
title_keywords
)
# 综合相似度 (70% 关键词重合 + 30% 文本相似度)
combined_score = keyword_overlap * 0.7 + title_similarity * 0.3
if combined_score >= threshold:
news_item = {
"title": title,
"platform": platform_id,
"platform_name": platform_name,
"date": current_date.strftime("%Y-%m-%d"),
"similarity_score": round(combined_score, 4),
"keyword_overlap": round(keyword_overlap, 4),
"text_similarity": round(title_similarity, 4),
"common_keywords": list(set(reference_keywords) & set(title_keywords)),
"rank": info["ranks"][0] if info["ranks"] else 0
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobileUrl"] = info.get("mobileUrl", "")
all_related_news.append(news_item)
except DataNotFoundError:
# 该日期没有数据,继续下一天
pass
except Exception as e:
# 记录错误但继续处理其他日期
print(f"Warning: 处理日期 {current_date.strftime('%Y-%m-%d')} 时出错: {e}")
# 移动到下一天
current_date += timedelta(days=1)
if not all_related_news:
return {
"success": True,
"results": [],
"total": 0,
"query": reference_text,
"time_range": time_range,
"date_range": {
"start": search_start.strftime("%Y-%m-%d"),
"end": search_end.strftime("%Y-%m-%d")
},
"message": "未找到相关新闻"
}
# 按相似度排序
all_related_news.sort(key=lambda x: x["similarity_score"], reverse=True)
# 限制返回数量
results = all_related_news[:limit]
# 统计信息
platform_distribution = Counter([news["platform"] for news in all_related_news])
date_distribution = Counter([news["date"] for news in all_related_news])
result = {
"success": True,
"summary": {
"total_found": len(all_related_news),
"returned_count": len(results),
"requested_limit": limit,
"threshold": threshold,
"reference_text": reference_text,
"reference_keywords": reference_keywords,
"time_range": time_range,
"date_range": {
"start": search_start.strftime("%Y-%m-%d"),
"end": search_end.strftime("%Y-%m-%d")
}
},
"results": results,
"statistics": {
"platform_distribution": dict(platform_distribution),
"date_distribution": dict(date_distribution),
"avg_similarity": round(
sum([news["similarity_score"] for news in all_related_news]) / len(all_related_news),
4
) if all_related_news else 0.0
}
}
if len(all_related_news) < limit:
result["note"] = f"相关性阈值 {threshold} 下仅找到 {len(all_related_news)} 条相关新闻"
return result
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}

465
mcp_server/tools/system.py Normal file
View File

@ -0,0 +1,465 @@
"""
系统管理工具
实现系统状态查询和爬虫触发功能
"""
from pathlib import Path
from typing import Dict, List, Optional
from ..services.data_service import DataService
from ..utils.validators import validate_platforms
from ..utils.errors import MCPError, CrawlTaskError
class SystemManagementTools:
"""系统管理工具类"""
def __init__(self, project_root: str = None):
"""
初始化系统管理工具
Args:
project_root: 项目根目录
"""
self.data_service = DataService(project_root)
if project_root:
self.project_root = Path(project_root)
else:
# 获取项目根目录
current_file = Path(__file__)
self.project_root = current_file.parent.parent.parent
def get_system_status(self) -> Dict:
"""
获取系统运行状态和健康检查信息
Returns:
系统状态字典
Example:
>>> tools = SystemManagementTools()
>>> result = tools.get_system_status()
>>> print(result['system']['version'])
"""
try:
# 获取系统状态
status = self.data_service.get_system_status()
return {
**status,
"success": True
}
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e)
}
}
def trigger_crawl(self, platforms: Optional[List[str]] = None, save_to_local: bool = False, include_url: bool = False) -> Dict:
"""
手动触发一次临时爬取任务可选持久化
Args:
platforms: 指定平台列表为空则爬取所有平台
save_to_local: 是否保存到本地 output 目录默认 False
include_url: 是否包含URL链接默认False节省token
Returns:
爬取结果字典包含新闻数据和保存路径如果保存
Example:
>>> tools = SystemManagementTools()
>>> # 临时爬取,不保存
>>> result = tools.trigger_crawl(platforms=['zhihu', 'weibo'])
>>> print(result['data'])
>>> # 爬取并保存到本地
>>> result = tools.trigger_crawl(platforms=['zhihu'], save_to_local=True)
>>> print(result['saved_files'])
"""
try:
import json
import time
import random
import requests
from datetime import datetime
import pytz
import yaml
# 参数验证
platforms = validate_platforms(platforms)
# 加载配置文件
config_path = self.project_root / "config" / "config.yaml"
if not config_path.exists():
raise CrawlTaskError(
"配置文件不存在",
suggestion=f"请确保配置文件存在: {config_path}"
)
# 读取配置
with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
# 获取平台配置
all_platforms = config_data.get("platforms", [])
if not all_platforms:
raise CrawlTaskError(
"配置文件中没有平台配置",
suggestion="请检查 config/config.yaml 中的 platforms 配置"
)
# 过滤平台
if platforms:
target_platforms = [p for p in all_platforms if p["id"] in platforms]
if not target_platforms:
raise CrawlTaskError(
f"指定的平台不存在: {platforms}",
suggestion=f"可用平台: {[p['id'] for p in all_platforms]}"
)
else:
target_platforms = all_platforms
# 获取请求间隔
request_interval = config_data.get("crawler", {}).get("request_interval", 100)
# 构建平台ID列表
ids = []
for platform in target_platforms:
if "name" in platform:
ids.append((platform["id"], platform["name"]))
else:
ids.append(platform["id"])
print(f"开始临时爬取,平台: {[p.get('name', p['id']) for p in target_platforms]}")
# 爬取数据
results = {}
id_to_name = {}
failed_ids = []
for i, id_info in enumerate(ids):
if isinstance(id_info, tuple):
id_value, name = id_info
else:
id_value = id_info
name = id_value
id_to_name[id_value] = name
# 构建请求URL
url = f"https://newsnow.busiyi.world/api/s?id={id_value}&latest"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Connection": "keep-alive",
"Cache-Control": "no-cache",
}
# 重试机制
max_retries = 2
retries = 0
success = False
while retries <= max_retries and not success:
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
data_text = response.text
data_json = json.loads(data_text)
status = data_json.get("status", "未知")
if status not in ["success", "cache"]:
raise ValueError(f"响应状态异常: {status}")
status_info = "最新数据" if status == "success" else "缓存数据"
print(f"获取 {id_value} 成功({status_info}")
# 解析数据
results[id_value] = {}
for index, item in enumerate(data_json.get("items", []), 1):
title = item["title"]
url_link = item.get("url", "")
mobile_url = item.get("mobileUrl", "")
if title in results[id_value]:
results[id_value][title]["ranks"].append(index)
else:
results[id_value][title] = {
"ranks": [index],
"url": url_link,
"mobileUrl": mobile_url,
}
success = True
except Exception as e:
retries += 1
if retries <= max_retries:
wait_time = random.uniform(3, 5)
print(f"请求 {id_value} 失败: {e}. {wait_time:.2f}秒后重试...")
time.sleep(wait_time)
else:
print(f"请求 {id_value} 失败: {e}")
failed_ids.append(id_value)
# 请求间隔
if i < len(ids) - 1:
actual_interval = request_interval + random.randint(-10, 20)
actual_interval = max(50, actual_interval)
time.sleep(actual_interval / 1000)
# 格式化返回数据
news_data = []
for platform_id, titles_data in results.items():
platform_name = id_to_name.get(platform_id, platform_id)
for title, info in titles_data.items():
news_item = {
"platform_id": platform_id,
"platform_name": platform_name,
"title": title,
"ranks": info["ranks"]
}
# 条件性添加 URL 字段
if include_url:
news_item["url"] = info.get("url", "")
news_item["mobile_url"] = info.get("mobileUrl", "")
news_data.append(news_item)
# 获取北京时间
beijing_tz = pytz.timezone("Asia/Shanghai")
now = datetime.now(beijing_tz)
# 构建返回结果
result = {
"success": True,
"task_id": f"crawl_{int(time.time())}",
"status": "completed",
"crawl_time": now.strftime("%Y-%m-%d %H:%M:%S"),
"platforms": list(results.keys()),
"total_news": len(news_data),
"failed_platforms": failed_ids,
"data": news_data,
"saved_to_local": save_to_local
}
# 如果需要持久化,调用保存逻辑
if save_to_local:
try:
import re
# 辅助函数:清理标题
def clean_title(title: str) -> str:
"""清理标题中的特殊字符"""
if not isinstance(title, str):
title = str(title)
cleaned_title = title.replace("\n", " ").replace("\r", " ")
cleaned_title = re.sub(r"\s+", " ", cleaned_title)
cleaned_title = cleaned_title.strip()
return cleaned_title
# 辅助函数:创建目录
def ensure_directory_exists(directory: str):
"""确保目录存在"""
Path(directory).mkdir(parents=True, exist_ok=True)
# 格式化日期和时间
date_folder = now.strftime("%Y年%m月%d")
time_filename = now.strftime("%H时%M分")
# 创建 txt 文件路径
txt_dir = self.project_root / "output" / date_folder / "txt"
ensure_directory_exists(str(txt_dir))
txt_file_path = txt_dir / f"{time_filename}.txt"
# 创建 html 文件路径
html_dir = self.project_root / "output" / date_folder / "html"
ensure_directory_exists(str(html_dir))
html_file_path = html_dir / f"{time_filename}.html"
# 保存 txt 文件(按照 main.py 的格式)
with open(txt_file_path, "w", encoding="utf-8") as f:
for id_value, title_data in results.items():
# id | name 或 id
name = id_to_name.get(id_value)
if name and name != id_value:
f.write(f"{id_value} | {name}\n")
else:
f.write(f"{id_value}\n")
# 按排名排序标题
sorted_titles = []
for title, info in title_data.items():
cleaned = clean_title(title)
if isinstance(info, dict):
ranks = info.get("ranks", [])
url = info.get("url", "")
mobile_url = info.get("mobileUrl", "")
else:
ranks = info if isinstance(info, list) else []
url = ""
mobile_url = ""
rank = ranks[0] if ranks else 1
sorted_titles.append((rank, cleaned, url, mobile_url))
sorted_titles.sort(key=lambda x: x[0])
for rank, cleaned, url, mobile_url in sorted_titles:
line = f"{rank}. {cleaned}"
if url:
line += f" [URL:{url}]"
if mobile_url:
line += f" [MOBILE:{mobile_url}]"
f.write(line + "\n")
f.write("\n")
if failed_ids:
f.write("==== 以下ID请求失败 ====\n")
for id_value in failed_ids:
f.write(f"{id_value}\n")
# 保存 html 文件(简化版)
html_content = self._generate_simple_html(results, id_to_name, failed_ids, now)
with open(html_file_path, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"数据已保存到:")
print(f" TXT: {txt_file_path}")
print(f" HTML: {html_file_path}")
result["saved_files"] = {
"txt": str(txt_file_path),
"html": str(html_file_path)
}
result["note"] = "数据已持久化到 output 文件夹"
except Exception as e:
print(f"保存文件失败: {e}")
result["save_error"] = str(e)
result["note"] = "爬取成功但保存失败,数据仅在内存中"
else:
result["note"] = "临时爬取结果未持久化到output文件夹"
return result
except MCPError as e:
return {
"success": False,
"error": e.to_dict()
}
except Exception as e:
import traceback
return {
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e),
"traceback": traceback.format_exc()
}
}
def _generate_simple_html(self, results: Dict, id_to_name: Dict, failed_ids: List, now) -> str:
"""生成简化的 HTML 报告"""
html = """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP 爬取结果</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
.container { max-width: 900px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; }
h1 { color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }
.platform { margin-bottom: 30px; }
.platform-name { background: #4CAF50; color: white; padding: 10px; border-radius: 5px; margin-bottom: 10px; }
.news-item { padding: 8px; border-bottom: 1px solid #eee; }
.rank { color: #666; font-weight: bold; margin-right: 10px; }
.title { color: #333; }
.link { color: #1976D2; text-decoration: none; margin-left: 10px; font-size: 0.9em; }
.link:hover { text-decoration: underline; }
.failed { background: #ffebee; padding: 10px; border-radius: 5px; margin-top: 20px; }
.failed h3 { color: #c62828; margin-top: 0; }
.timestamp { color: #666; font-size: 0.9em; text-align: right; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>MCP 爬取结果</h1>
"""
# 添加时间戳
html += f' <p class="timestamp">爬取时间: {now.strftime("%Y-%m-%d %H:%M:%S")}</p>\n\n'
# 遍历每个平台
for platform_id, titles_data in results.items():
platform_name = id_to_name.get(platform_id, platform_id)
html += f' <div class="platform">\n'
html += f' <div class="platform-name">{platform_name}</div>\n'
# 排序标题
sorted_items = []
for title, info in titles_data.items():
ranks = info.get("ranks", [])
url = info.get("url", "")
mobile_url = info.get("mobileUrl", "")
rank = ranks[0] if ranks else 999
sorted_items.append((rank, title, url, mobile_url))
sorted_items.sort(key=lambda x: x[0])
# 显示新闻
for rank, title, url, mobile_url in sorted_items:
html += f' <div class="news-item">\n'
html += f' <span class="rank">{rank}.</span>\n'
html += f' <span class="title">{self._html_escape(title)}</span>\n'
if url:
html += f' <a class="link" href="{self._html_escape(url)}" target="_blank">链接</a>\n'
if mobile_url and mobile_url != url:
html += f' <a class="link" href="{self._html_escape(mobile_url)}" target="_blank">移动版</a>\n'
html += ' </div>\n'
html += ' </div>\n\n'
# 失败的平台
if failed_ids:
html += ' <div class="failed">\n'
html += ' <h3>请求失败的平台</h3>\n'
html += ' <ul>\n'
for platform_id in failed_ids:
html += f' <li>{self._html_escape(platform_id)}</li>\n'
html += ' </ul>\n'
html += ' </div>\n'
html += """ </div>
</body>
</html>"""
return html
def _html_escape(self, text: str) -> str:
"""HTML 转义"""
if not isinstance(text, str):
text = str(text)
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#x27;")
)

View File

@ -0,0 +1,5 @@
"""
工具类模块
提供参数验证错误处理等辅助功能
"""

View File

@ -0,0 +1,278 @@
"""
日期解析工具
支持多种自然语言日期格式解析包括相对日期和绝对日期
"""
import re
from datetime import datetime, timedelta
from .errors import InvalidParameterError
class DateParser:
"""日期解析器类"""
# 中文日期映射
CN_DATE_MAPPING = {
"今天": 0,
"昨天": 1,
"前天": 2,
"大前天": 3,
}
# 英文日期映射
EN_DATE_MAPPING = {
"today": 0,
"yesterday": 1,
}
# 星期映射
WEEKDAY_CN = {
"": 0, "": 1, "": 2, "": 3,
"": 4, "": 5, "": 6, "": 6
}
WEEKDAY_EN = {
"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
"friday": 4, "saturday": 5, "sunday": 6
}
@staticmethod
def parse_date_query(date_query: str) -> datetime:
"""
解析日期查询字符串
支持的格式
- 相对日期中文今天昨天前天大前天N天前
- 相对日期英文todayyesterdayN days ago
- 星期中文上周一上周二本周三
- 星期英文last mondaythis friday
- 绝对日期2025-10-1010月10日2025年10月10日
Args:
date_query: 日期查询字符串
Returns:
datetime对象
Raises:
InvalidParameterError: 日期格式无法识别
Examples:
>>> DateParser.parse_date_query("今天")
datetime(2025, 10, 11)
>>> DateParser.parse_date_query("昨天")
datetime(2025, 10, 10)
>>> DateParser.parse_date_query("3天前")
datetime(2025, 10, 8)
>>> DateParser.parse_date_query("2025-10-10")
datetime(2025, 10, 10)
"""
if not date_query or not isinstance(date_query, str):
raise InvalidParameterError(
"日期查询字符串不能为空",
suggestion="请提供有效的日期查询今天、昨天、2025-10-10"
)
date_query = date_query.strip().lower()
# 1. 尝试解析中文常用相对日期
if date_query in DateParser.CN_DATE_MAPPING:
days_ago = DateParser.CN_DATE_MAPPING[date_query]
return datetime.now() - timedelta(days=days_ago)
# 2. 尝试解析英文常用相对日期
if date_query in DateParser.EN_DATE_MAPPING:
days_ago = DateParser.EN_DATE_MAPPING[date_query]
return datetime.now() - timedelta(days=days_ago)
# 3. 尝试解析 "N天前" 或 "N days ago"
cn_days_ago_match = re.match(r'(\d+)\s*天前', date_query)
if cn_days_ago_match:
days = int(cn_days_ago_match.group(1))
if days > 365:
raise InvalidParameterError(
f"天数过大: {days}",
suggestion="请使用小于365天的相对日期或使用绝对日期"
)
return datetime.now() - timedelta(days=days)
en_days_ago_match = re.match(r'(\d+)\s*days?\s+ago', date_query)
if en_days_ago_match:
days = int(en_days_ago_match.group(1))
if days > 365:
raise InvalidParameterError(
f"天数过大: {days}",
suggestion="请使用小于365天的相对日期或使用绝对日期"
)
return datetime.now() - timedelta(days=days)
# 4. 尝试解析星期(中文):上周一、本周三
cn_weekday_match = re.match(r'(上|本)周([一二三四五六日天])', date_query)
if cn_weekday_match:
week_type = cn_weekday_match.group(1) # 上 或 本
weekday_str = cn_weekday_match.group(2)
target_weekday = DateParser.WEEKDAY_CN[weekday_str]
return DateParser._get_date_by_weekday(target_weekday, week_type == "")
# 5. 尝试解析星期英文last monday、this friday
en_weekday_match = re.match(r'(last|this)\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)', date_query)
if en_weekday_match:
week_type = en_weekday_match.group(1) # last 或 this
weekday_str = en_weekday_match.group(2)
target_weekday = DateParser.WEEKDAY_EN[weekday_str]
return DateParser._get_date_by_weekday(target_weekday, week_type == "last")
# 6. 尝试解析绝对日期YYYY-MM-DD
iso_date_match = re.match(r'(\d{4})-(\d{1,2})-(\d{1,2})', date_query)
if iso_date_match:
year = int(iso_date_match.group(1))
month = int(iso_date_match.group(2))
day = int(iso_date_match.group(3))
try:
return datetime(year, month, day)
except ValueError as e:
raise InvalidParameterError(
f"无效的日期: {date_query}",
suggestion=f"日期值错误: {str(e)}"
)
# 7. 尝试解析中文日期MM月DD日 或 YYYY年MM月DD日
cn_date_match = re.match(r'(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日', date_query)
if cn_date_match:
year_str = cn_date_match.group(1)
month = int(cn_date_match.group(2))
day = int(cn_date_match.group(3))
# 如果没有年份,使用当前年份
if year_str:
year = int(year_str)
else:
year = datetime.now().year
# 如果月份大于当前月份,说明是去年
current_month = datetime.now().month
if month > current_month:
year -= 1
try:
return datetime(year, month, day)
except ValueError as e:
raise InvalidParameterError(
f"无效的日期: {date_query}",
suggestion=f"日期值错误: {str(e)}"
)
# 8. 尝试解析斜杠格式YYYY/MM/DD 或 MM/DD
slash_date_match = re.match(r'(?:(\d{4})/)?(\d{1,2})/(\d{1,2})', date_query)
if slash_date_match:
year_str = slash_date_match.group(1)
month = int(slash_date_match.group(2))
day = int(slash_date_match.group(3))
if year_str:
year = int(year_str)
else:
year = datetime.now().year
current_month = datetime.now().month
if month > current_month:
year -= 1
try:
return datetime(year, month, day)
except ValueError as e:
raise InvalidParameterError(
f"无效的日期: {date_query}",
suggestion=f"日期值错误: {str(e)}"
)
# 如果所有格式都不匹配
raise InvalidParameterError(
f"无法识别的日期格式: {date_query}",
suggestion=(
"支持的格式:\n"
"- 相对日期: 今天、昨天、前天、3天前、today、yesterday、3 days ago\n"
"- 星期: 上周一、本周三、last monday、this friday\n"
"- 绝对日期: 2025-10-10、10月10日、2025年10月10日"
)
)
@staticmethod
def _get_date_by_weekday(target_weekday: int, is_last_week: bool) -> datetime:
"""
根据星期几获取日期
Args:
target_weekday: 目标星期 (0=周一, 6=周日)
is_last_week: 是否是上周
Returns:
datetime对象
"""
today = datetime.now()
current_weekday = today.weekday()
# 计算天数差
if is_last_week:
# 上周的某一天
days_diff = current_weekday - target_weekday + 7
else:
# 本周的某一天
days_diff = current_weekday - target_weekday
if days_diff < 0:
days_diff += 7
return today - timedelta(days=days_diff)
@staticmethod
def format_date_folder(date: datetime) -> str:
"""
将日期格式化为文件夹名称
Args:
date: datetime对象
Returns:
文件夹名称格式: YYYY年MM月DD日
Examples:
>>> DateParser.format_date_folder(datetime(2025, 10, 11))
'2025年10月11日'
"""
return date.strftime("%Y年%m月%d")
@staticmethod
def validate_date_not_future(date: datetime) -> None:
"""
验证日期不在未来
Args:
date: 待验证的日期
Raises:
InvalidParameterError: 日期在未来
"""
if date.date() > datetime.now().date():
raise InvalidParameterError(
f"不能查询未来的日期: {date.strftime('%Y-%m-%d')}",
suggestion="请使用今天或过去的日期"
)
@staticmethod
def validate_date_not_too_old(date: datetime, max_days: int = 365) -> None:
"""
验证日期不太久远
Args:
date: 待验证的日期
max_days: 最大天数
Raises:
InvalidParameterError: 日期太久远
"""
days_ago = (datetime.now().date() - date.date()).days
if days_ago > max_days:
raise InvalidParameterError(
f"日期太久远: {date.strftime('%Y-%m-%d')} ({days_ago}天前)",
suggestion=f"请查询{max_days}天内的数据"
)

View File

@ -0,0 +1,93 @@
"""
自定义错误类
定义MCP Server使用的所有自定义异常类型
"""
from typing import Optional
class MCPError(Exception):
"""MCP工具错误基类"""
def __init__(self, message: str, code: str = "MCP_ERROR", suggestion: Optional[str] = None):
super().__init__(message)
self.code = code
self.message = message
self.suggestion = suggestion
def to_dict(self) -> dict:
"""转换为字典格式"""
error_dict = {
"code": self.code,
"message": self.message
}
if self.suggestion:
error_dict["suggestion"] = self.suggestion
return error_dict
class DataNotFoundError(MCPError):
"""数据不存在错误"""
def __init__(self, message: str, suggestion: Optional[str] = None):
super().__init__(
message=message,
code="DATA_NOT_FOUND",
suggestion=suggestion or "请检查日期范围或等待爬取任务完成"
)
class InvalidParameterError(MCPError):
"""参数无效错误"""
def __init__(self, message: str, suggestion: Optional[str] = None):
super().__init__(
message=message,
code="INVALID_PARAMETER",
suggestion=suggestion or "请检查参数格式是否正确"
)
class ConfigurationError(MCPError):
"""配置错误"""
def __init__(self, message: str, suggestion: Optional[str] = None):
super().__init__(
message=message,
code="CONFIGURATION_ERROR",
suggestion=suggestion or "请检查配置文件是否正确"
)
class PlatformNotSupportedError(MCPError):
"""平台不支持错误"""
def __init__(self, platform: str):
super().__init__(
message=f"平台 '{platform}' 不受支持",
code="PLATFORM_NOT_SUPPORTED",
suggestion="支持的平台: zhihu, weibo, douyin, bilibili, baidu, toutiao, qq, 36kr, sspai, hellogithub, thepaper"
)
class CrawlTaskError(MCPError):
"""爬取任务错误"""
def __init__(self, message: str, suggestion: Optional[str] = None):
super().__init__(
message=message,
code="CRAWL_TASK_ERROR",
suggestion=suggestion or "请稍后重试或查看日志"
)
class FileParseError(MCPError):
"""文件解析错误"""
def __init__(self, file_path: str, reason: str):
super().__init__(
message=f"解析文件 {file_path} 失败: {reason}",
code="FILE_PARSE_ERROR",
suggestion="请检查文件格式是否正确"
)

View File

@ -0,0 +1,324 @@
"""
参数验证工具
提供统一的参数验证功能
"""
from datetime import datetime
from typing import List, Optional
import os
import yaml
from .errors import InvalidParameterError
from .date_parser import DateParser
def get_supported_platforms() -> List[str]:
"""
config.yaml 动态获取支持的平台列表
Returns:
平台ID列表
Note:
- 读取失败时返回空列表允许所有平台通过降级策略
- 平台列表来自 config/config.yaml 中的 platforms 配置
"""
try:
# 获取 config.yaml 路径(相对于当前文件)
current_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(current_dir, "..", "..", "config", "config.yaml")
config_path = os.path.normpath(config_path)
with open(config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
platforms = config.get('platforms', [])
return [p['id'] for p in platforms if 'id' in p]
except Exception as e:
# 降级方案:返回空列表,允许所有平台
print(f"警告:无法加载平台配置 ({config_path}): {e}")
return []
def validate_platforms(platforms: Optional[List[str]]) -> List[str]:
"""
验证平台列表
Args:
platforms: 平台ID列表None表示使用 config.yaml 中配置的所有平台
Returns:
验证后的平台列表
Raises:
InvalidParameterError: 平台不支持
Note:
- platforms=None 返回 config.yaml 中配置的平台列表
- 会验证平台ID是否在 config.yaml platforms 配置中
- 配置加载失败时允许所有平台通过降级策略
"""
supported_platforms = get_supported_platforms()
if platforms is None:
# 返回配置文件中的平台列表(用户的默认配置)
return supported_platforms if supported_platforms else []
if not isinstance(platforms, list):
raise InvalidParameterError("platforms 参数必须是列表类型")
if not platforms:
# 空列表时,返回配置文件中的平台列表
return supported_platforms if supported_platforms else []
# 如果配置加载失败supported_platforms为空允许所有平台通过
if not supported_platforms:
print("警告:平台配置未加载,跳过平台验证")
return platforms
# 验证每个平台是否在配置中
invalid_platforms = [p for p in platforms if p not in supported_platforms]
if invalid_platforms:
raise InvalidParameterError(
f"不支持的平台: {', '.join(invalid_platforms)}",
suggestion=f"支持的平台来自config.yaml: {', '.join(supported_platforms)}"
)
return platforms
def validate_limit(limit: Optional[int], default: int = 20, max_limit: int = 1000) -> int:
"""
验证数量限制参数
Args:
limit: 限制数量
default: 默认值
max_limit: 最大限制
Returns:
验证后的限制值
Raises:
InvalidParameterError: 参数无效
"""
if limit is None:
return default
if not isinstance(limit, int):
raise InvalidParameterError("limit 参数必须是整数类型")
if limit <= 0:
raise InvalidParameterError("limit 必须大于0")
if limit > max_limit:
raise InvalidParameterError(
f"limit 不能超过 {max_limit}",
suggestion=f"请使用分页或降低limit值"
)
return limit
def validate_date(date_str: str) -> datetime:
"""
验证日期格式
Args:
date_str: 日期字符串 (YYYY-MM-DD)
Returns:
datetime对象
Raises:
InvalidParameterError: 日期格式错误
"""
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise InvalidParameterError(
f"日期格式错误: {date_str}",
suggestion="请使用 YYYY-MM-DD 格式,例如: 2025-10-11"
)
def validate_date_range(date_range: Optional[dict]) -> Optional[tuple]:
"""
验证日期范围
Args:
date_range: 日期范围字典 {"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"}
Returns:
(start_date, end_date) 元组 None
Raises:
InvalidParameterError: 日期范围无效
"""
if date_range is None:
return None
if not isinstance(date_range, dict):
raise InvalidParameterError("date_range 必须是字典类型")
start_str = date_range.get("start")
end_str = date_range.get("end")
if not start_str or not end_str:
raise InvalidParameterError(
"date_range 必须包含 start 和 end 字段",
suggestion='例如: {"start": "2025-10-01", "end": "2025-10-11"}'
)
start_date = validate_date(start_str)
end_date = validate_date(end_str)
if start_date > end_date:
raise InvalidParameterError(
"开始日期不能晚于结束日期",
suggestion=f"start: {start_str}, end: {end_str}"
)
return (start_date, end_date)
def validate_keyword(keyword: str) -> str:
"""
验证关键词
Args:
keyword: 搜索关键词
Returns:
处理后的关键词
Raises:
InvalidParameterError: 关键词无效
"""
if not keyword:
raise InvalidParameterError("keyword 不能为空")
if not isinstance(keyword, str):
raise InvalidParameterError("keyword 必须是字符串类型")
keyword = keyword.strip()
if not keyword:
raise InvalidParameterError("keyword 不能为空白字符")
if len(keyword) > 100:
raise InvalidParameterError(
"keyword 长度不能超过100个字符",
suggestion="请使用更简洁的关键词"
)
return keyword
def validate_top_n(top_n: Optional[int], default: int = 10) -> int:
"""
验证TOP N参数
Args:
top_n: TOP N数量
default: 默认值
Returns:
验证后的值
Raises:
InvalidParameterError: 参数无效
"""
return validate_limit(top_n, default=default, max_limit=100)
def validate_mode(mode: Optional[str], valid_modes: List[str], default: str) -> str:
"""
验证模式参数
Args:
mode: 模式字符串
valid_modes: 有效模式列表
default: 默认模式
Returns:
验证后的模式
Raises:
InvalidParameterError: 模式无效
"""
if mode is None:
return default
if not isinstance(mode, str):
raise InvalidParameterError("mode 必须是字符串类型")
if mode not in valid_modes:
raise InvalidParameterError(
f"无效的模式: {mode}",
suggestion=f"支持的模式: {', '.join(valid_modes)}"
)
return mode
def validate_config_section(section: Optional[str]) -> str:
"""
验证配置节参数
Args:
section: 配置节名称
Returns:
验证后的配置节
Raises:
InvalidParameterError: 配置节无效
"""
valid_sections = ["all", "crawler", "push", "keywords", "weights"]
return validate_mode(section, valid_sections, "all")
def validate_date_query(
date_query: str,
allow_future: bool = False,
max_days_ago: int = 365
) -> datetime:
"""
验证并解析日期查询字符串
Args:
date_query: 日期查询字符串
allow_future: 是否允许未来日期
max_days_ago: 允许查询的最大天数
Returns:
解析后的datetime对象
Raises:
InvalidParameterError: 日期查询无效
Examples:
>>> validate_date_query("昨天")
datetime(2025, 10, 10)
>>> validate_date_query("2025-10-10")
datetime(2025, 10, 10)
"""
if not date_query:
raise InvalidParameterError(
"日期查询字符串不能为空",
suggestion="请提供日期查询今天、昨天、2025-10-10"
)
# 使用DateParser解析日期
parsed_date = DateParser.parse_date_query(date_query)
# 验证日期不在未来
if not allow_future:
DateParser.validate_date_not_future(parsed_date)
# 验证日期不太久远
DateParser.validate_date_not_too_old(parsed_date, max_days=max_days_ago)
return parsed_date

25
pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[project]
name = "trendradar-mcp"
version = "1.0.0"
description = "TrendRadar MCP Server - 新闻热点聚合工具"
requires-python = ">=3.10"
dependencies = [
"requests>=2.32.5",
"pytz>=2025.2",
"PyYAML>=6.0.3",
"fastmcp>=2.12.0",
"websockets>=13.0,<14.0",
]
[project.scripts]
trendradar = "mcp_server.server:run_server"
[dependency-groups]
dev = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_server"]

111
readme.md
View File

@ -1,5 +1,3 @@
> 预告:下一次更新是 v3.0.0 版本,主要是 **AI** 有关的各种分析新闻资讯的功能
<div align="center" id="trendradar">
<a href="https://github.com/sansan0/TrendRadar" title="TrendRadar">
@ -13,7 +11,7 @@
[![GitHub Stars](https://img.shields.io/github/stars/sansan0/TrendRadar?style=flat-square&logo=github&color=yellow)](https://github.com/sansan0/TrendRadar/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/sansan0/TrendRadar?style=flat-square&logo=github&color=blue)](https://github.com/sansan0/TrendRadar/network/members)
[![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg?style=flat-square)](LICENSE)
[![Version](https://img.shields.io/badge/version-v2.4.4-green.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
[![Version](https://img.shields.io/badge/version-v3.0.0-green.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
[![企业微信通知](https://img.shields.io/badge/企业微信-通知-00D4AA?style=flat-square)](https://work.weixin.qq.com/)
[![Telegram通知](https://img.shields.io/badge/Telegram-通知-00D4AA?style=flat-square)](https://telegram.org/)
@ -22,16 +20,16 @@
[![邮件通知](https://img.shields.io/badge/Email-通知-00D4AA?style=flat-square)](#)
[![ntfy通知](https://img.shields.io/badge/ntfy-通知-00D4AA?style=flat-square)](https://github.com/binwiederhier/ntfy)
[![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-自动化-2088FF?style=flat-square&logo=github-actions&logoColor=white)](https://github.com/sansan0/TrendRadar)
[![GitHub Pages](https://img.shields.io/badge/GitHub_Pages-部署-4285F4?style=flat-square&logo=github&logoColor=white)](https://sansan0.github.io/TrendRadar)
[![Docker](https://img.shields.io/badge/Docker-部署-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/)
[![MCP Support](https://img.shields.io/badge/MCP-AI分析支持-FF6B6B?style=flat-square&logo=ai&logoColor=white)](https://modelcontextprotocol.io/)
</div>
> 本项目以轻量,易部署为目标
>
> 开源路上,感谢有你~😉
> 本项目以轻量,易部署为目标。开源路上,感谢有你~😉
- 感谢**耐心反馈 bug** 的贡献者,你们的每一条反馈让项目更加完善;
- 感谢**为项目点 star** 的观众们,你们的每一个 star 都是对开源精神最好的支持;
@ -39,7 +37,7 @@
- 感谢**给予资金支持** 的朋友们,你们的慷慨已化身为键盘旁的零食饮料,陪伴着项目的每一次迭代。
<details>
<summary>👉 点击查看<strong>致谢名单</strong> (当前 <strong>🔥32🔥</strong> 位)</summary>
<summary>👉 点击查看<strong>致谢名单</strong> (当前 <strong>🔥39🔥</strong> 位)</summary>
### 数据支持
@ -59,6 +57,13 @@
| 点赞人 | 金额 | 日期 | 备注 |
| :-------------------------: | :----: | :----: | :-----------------------: |
| P*n | 1 | 2025.10.20 | |
| *杰 | 1 | 2025.10.19 | |
| *徐 | 1 | 2025.10.18 | |
| *志 | 1 | 2025.10.17 | |
| *😀 | 10 | 2025.10.16 | 点赞 |
| **杰 | 10 | 2025.10.16 | |
| *啸 | 10 | 2025.10.16 | |
| *纪 | 5 | 2025.10.14 | TrendRadar |
| J*d | 1 | 2025.10.14 | 谢谢你的工具,很好玩... |
| *H | 1 | 2025.10.14 | |
@ -336,7 +341,7 @@ OPPO
- **持续性分析**:区分一次性热点话题和持续发酵的深度新闻
- **跨平台对比**:同一新闻在不同平台的排名表现,看出媒体关注度差异
**实际效果**不再错过重要新闻的完整发展过程,从话题萌芽到高峰热议,全程掌握
> 不再错过重要新闻的完整发展过程,从话题萌芽到高峰热议,全程掌握
<details>
<summary><strong>👉 推送格式说明</strong></summary>
@ -403,9 +408,7 @@ OPPO
- **关注持续出现的话题**占30%):反复出现的新闻更重要
- **考虑排名质量**占10%):不仅多次出现,还经常排在前列
**实际效果**:把分散在各个平台的热搜合并起来,按照你关心的热度重新排序
> 这三个比例可以选择适合自己的场景进行调整
> 把分散在各个平台的热搜合并起来,按照你关心的热度重新排序,这三个比例可以选择适合自己的场景进行调整
<details>
<summary><strong>👉 热点权重调整</strong></summary>
@ -451,6 +454,21 @@ weight:
- **Docker部署**:支持多架构容器化运行
- **数据持久化**HTML/TXT多格式历史记录保存
### **AI 智能分析v3.0.0 新增)**
基于 MCP (Model Context Protocol) 协议的 AI 对话分析系统,让你用自然语言深度挖掘新闻数据
- **对话式查询**:用自然语言提问,如"查询昨天知乎的热点"、"分析比特币最近的热度趋势"
- **13 种分析工具**:涵盖基础查询、智能检索、趋势分析、数据洞察、情感分析等
- **多客户端支持**Cherry StudioGUI 配置、Claude Desktop、Cursor、Cline 等
- **深度分析能力**
- 话题趋势追踪(热度变化、生命周期、爆火检测、趋势预测)
- 跨平台数据对比(活跃度统计、关键词共现)
- 智能摘要生成、相似新闻查找、历史关联检索
> 告别手动翻阅数据文件AI 助手帮你秒懂新闻背后的故事
### **零技术门槛部署**
GitHub 一键 Fork 即可使用,无需编程基础。
@ -482,6 +500,35 @@ GitHub 一键 Fork 即可使用,无需编程基础。
- **小版本更新**:从 v2.x 升级到 v2.y, 用本项目的 `main.py` 代码替换你 fork 仓库中的对应文件
- **大版本升级**:从 v1.x 升级到 v2.y, 建议删除现有 fork 后重新 fork这样更省力且避免配置冲突
### 2025/10/20 - v3.0.0
**重大更新 - AI 分析功能上线** 🤖
- **核心功能**
- 新增基于 MCP (Model Context Protocol) 的 AI 分析服务器
- 支持13种智能分析工具基础查询、智能检索、高级分析、系统管理
- 自然语言交互:通过对话方式查询和分析新闻数据
- 多客户端支持Claude Desktop、Cherry Studio、Cursor、Cline 等
- **快速部署方案**
- Cherry Studio 一键部署推荐新手GUI 配置5分钟完成
- Claude Desktop 配置技术用户JSON 配置文件
- 完整的部署文档和使用指南
- **分析能力**
- 话题趋势分析(热度追踪、生命周期、爆火检测、趋势预测)
- 数据洞察(平台对比、活跃度统计、关键词共现)
- 情感分析、相似新闻查找、智能摘要生成
- 历史相关新闻检索、多模式搜索
- **更新提示**
- 这是独立的 AI 分析功能,不影响现有的推送功能
- 可选择性使用,无需升级现有部署
<details>
<summary><strong>👉 历史更新</strong></summary>
### 2025/10/15 - v2.4.4
- **更新内容**
@ -492,10 +539,6 @@ GitHub 一键 Fork 即可使用,无需编程基础。
- 建议【小版本升级】
<details>
<summary><strong>👉 历史更新</strong></summary>
### 2025/10/10 - v2.4.3
> 感谢 [nidaye996](https://github.com/sansan0/TrendRadar/issues/98) 发现的体验问题
@ -1236,12 +1279,48 @@ docker exec -it trend-radar ls -la /app/config/
</details>
## 🤖 AI 智能分析部署
TrendRadar v3.0.0 新增了基于 **MCP (Model Context Protocol)** 的 AI 分析功能,让你可以通过自然语言与新闻数据对话,进行深度分析。
### 快速部署
Cherry Studio 提供 GUI 配置界面,可快速部署。
**详细教程**[README-Cherry-Studio.md](README-Cherry-Studio.md)
### 与 AI 对话的姿势
**基础查询**
```
"给我看看最新的新闻"
"查询昨天知乎的热点"
"我关注的词今天出现了多少次"
```
**趋势分析**
```
"分析'比特币'最近一周的热度趋势"
"看看'iPhone'话题是昙花一现还是持续热点"
```
**详细教程**[README-MCP-FAQ.md](README-MCP-FAQ.md)
>如果还有配置部署方面的问题,后续我会根据反馈出个**图文教程**,届时会更新到我的公众号上
## ☕问题答疑与1元点赞
> 心意到就行,收到的**点赞**用于提高开发者开源的积极性。**点赞**已收录于**致谢名单**
- **GitHub Issues**:适合针对性强的解答。提问时请提供完整信息(截图、错误日志、系统环境等)。
- **公众号交流**:适合快速咨询和使用疑问。可以在文章下留言或私信交流。
- **公众号交流**:适合快速咨询。尽量别私信,建议优先在相关文章下的公共留言区交流,有个收敛度
> 对于极少数私信中表达的那种'摸索了好久都部署不了'的焦急心态,我能理解,但我认为这不足以成为我必须立刻抽空帮你解决的理由
>
> 请舒缓情绪后再表达,别让我觉得'我分享了项目,你部署不了反而首先怪我',对于这种非商业化的个人开源小作品,多一点担待~好不?😉
>
> 项目的 issues 和留言我还算卖力解决,除了有自己写的项目哪怕再烂也得含泪😢维护的念头,主要还是因为有上面感谢的那群人的支持😘
|公众号关注 |微信点赞 | 支付宝点赞 |
|:---:|:---:|:---:|

View File

@ -1,3 +1,5 @@
requests==2.32.4
pytz==2025.2
PyYAML==6.0.2
requests>=2.32.5
pytz>=2025.2
PyYAML>=6.0.3
fastmcp>=2.12.0
websockets>=13.0,<14.0

118
setup-mac.sh Normal file
View File

@ -0,0 +1,118 @@
#!/bin/bash
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
echo -e "${BOLD}╔════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ TrendRadar MCP 一键部署 (Mac) ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════╝${NC}"
echo ""
# 获取项目根目录
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
echo -e "📍 项目目录: ${BLUE}${PROJECT_ROOT}${NC}"
echo ""
# 检查 UV 是否已安装
if ! command -v uv &> /dev/null; then
echo -e "${YELLOW}[1/3] 🔧 UV 未安装,正在自动安装...${NC}"
echo "提示: UV 是一个快速的 Python 包管理器,只需安装一次"
echo ""
curl -LsSf https://astral.sh/uv/install.sh | sh
echo ""
echo "正在刷新 PATH 环境变量..."
echo ""
# 添加 UV 到 PATH
export PATH="$HOME/.cargo/bin:$PATH"
# 验证 UV 是否真正可用
if ! command -v uv &> /dev/null; then
echo -e "${RED}❌ [错误] UV 安装失败${NC}"
echo ""
echo "可能的原因:"
echo " 1. 网络连接问题,无法下载安装脚本"
echo " 2. 安装路径权限不足"
echo " 3. 安装脚本执行异常"
echo ""
echo "解决方案:"
echo " 1. 检查网络连接是否正常"
echo " 2. 手动安装: https://docs.astral.sh/uv/getting-started/installation/"
echo " 3. 或运行: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
echo -e "${GREEN}✅ [成功] UV 已安装${NC}"
echo -e "${YELLOW}⚠️ 请重新运行此脚本以继续${NC}"
exit 0
else
echo -e "${GREEN}[1/3] ✅ UV 已安装${NC}"
uv --version
fi
echo ""
echo "[2/3] 📦 安装项目依赖..."
echo "提示: 这可能需要 1-2 分钟,请耐心等待"
echo ""
# 创建虚拟环境并安装依赖
uv sync
if [ $? -ne 0 ]; then
echo ""
echo -e "${RED}❌ [错误] 依赖安装失败${NC}"
echo "请检查网络连接后重试"
exit 1
fi
echo ""
echo -e "${GREEN}[3/3] ✅ 检查配置文件...${NC}"
echo ""
# 检查配置文件
if [ ! -f "config/config.yaml" ]; then
echo -e "${YELLOW}⚠️ [警告] 未找到配置文件: config/config.yaml${NC}"
echo "请确保配置文件存在"
echo ""
fi
# 添加执行权限
chmod +x start-http.sh 2>/dev/null || true
# 获取 UV 路径
UV_PATH=$(which uv)
echo ""
echo -e "${BOLD}╔════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ 部署完成! ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════╝${NC}"
echo ""
echo "📋 下一步操作:"
echo ""
echo " 1⃣ 打开 Cherry Studio"
echo " 2⃣ 进入 设置 > MCP Servers > 添加服务器"
echo " 3⃣ 填入以下配置:"
echo ""
echo " 名称: TrendRadar"
echo " 描述: 新闻热点聚合工具"
echo " 类型: STDIO"
echo -e " 命令: ${BLUE}${UV_PATH}${NC}"
echo " 参数(每个占一行):"
echo -e " ${BLUE}--directory${NC}"
echo -e " ${BLUE}${PROJECT_ROOT}${NC}"
echo -e " ${BLUE}run${NC}"
echo -e " ${BLUE}python${NC}"
echo -e " ${BLUE}-m${NC}"
echo -e " ${BLUE}mcp_server.server${NC}"
echo ""
echo " 4⃣ 保存并启用 MCP 开关"
echo ""
echo "📖 详细教程请查看: README-Cherry-Studio.md本窗口别关待会儿用于填入参数"
echo ""

114
setup-windows.bat Normal file
View File

@ -0,0 +1,114 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ╔════════════════════════════════════════╗
echo ║ TrendRadar MCP 一键部署 (Windows) ║
echo ╚════════════════════════════════════════╝
echo.
REM 获取当前目录作为项目根目录
set "PROJECT_ROOT=%CD%"
echo 📍 项目目录: %PROJECT_ROOT%
echo.
REM 检查 UV 是否已安装
where uv >nul 2>&1
if %errorlevel% neq 0 (
echo [1/3] 🔧 UV 未安装,正在自动安装...
echo 提示: UV 是一个快速的 Python 包管理器,只需安装一次
echo.
powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"
echo.
echo 🔄 刷新环境变量并检测 UV 安装状态...
echo.
REM 刷新 PATH 环境变量
for /f "tokens=2*" %%a in ('reg query "HKCU\Environment" /v PATH 2^>nul') do set "USER_PATH=%%b"
for /f "tokens=2*" %%a in ('reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v PATH 2^>nul') do set "SYSTEM_PATH=%%b"
set "PATH=%USER_PATH%;%SYSTEM_PATH%"
REM 再次检查 UV 是否可用
where uv >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ [错误] UV 安装失败 - 无法找到 UV 命令
echo 可能的原因:
echo - 网络连接问题,安装脚本未成功下载
echo - 安装路径未正确添加到 PATH
echo.
echo 解决方案:
echo 1. 请关闭此窗口,重新打开命令提示符后再次运行本脚本
echo 2. 或手动安装: https://docs.astral.sh/uv/getting-started/installation/
pause
exit /b 1
)
echo ✅ [成功] UV 已安装
echo ⚠️ 请关闭此窗口,重新运行本脚本以继续安装依赖
pause
exit /b 0
) else (
echo [1/3] ✅ UV 已安装
uv --version
)
echo.
echo [2/3] 📦 安装项目依赖...
echo 提示: 这可能需要 1-2 分钟,请耐心等待
echo.
REM 创建虚拟环境并安装依赖
uv sync
if %errorlevel% neq 0 (
echo.
echo ❌ [错误] 依赖安装失败
echo 请检查网络连接后重试
pause
exit /b 1
)
echo.
echo [3/3] ✅ 检查配置文件...
echo.
REM 检查配置文件
if not exist "config\config.yaml" (
echo ⚠️ [警告] 未找到配置文件: config\config.yaml
echo 请确保配置文件存在
echo.
)
REM 获取 UV 的完整路径
for /f "tokens=*" %%i in ('where uv') do set "UV_PATH=%%i"
echo.
echo ╔════════════════════════════════════════╗
echo ║ 部署完成! ║
echo ╚════════════════════════════════════════╝
echo.
echo 📋 下一步操作:
echo.
echo 1⃣ 打开 Cherry Studio
echo 2⃣ 进入 设置 ^> MCP Servers ^> 添加服务器
echo 3⃣ 填入以下配置:
echo.
echo 名称: TrendRadar
echo 描述: 新闻热点聚合工具
echo 类型: STDIO
echo 命令: %UV_PATH%
echo 参数(每个占一行):
echo --directory
echo %PROJECT_ROOT%
echo run
echo python
echo -m
echo mcp_server.server
echo.
echo 4⃣ 保存并启用 MCP 开关
echo.
echo 📖 详细教程请查看: README-Cherry-Studio.md本窗口别关待会儿用于填入参数
echo.
pause

25
start-http.bat Normal file
View File

@ -0,0 +1,25 @@
@echo off
chcp 65001 >nul
echo ╔════════════════════════════════════════╗
echo ║ TrendRadar MCP Server (HTTP 模式) ║
echo ╚════════════════════════════════════════╝
echo.
REM 检查虚拟环境
if not exist ".venv\Scripts\python.exe" (
echo ❌ [错误] 虚拟环境未找到
echo 请先运行 setup-windows.bat 进行部署
echo.
pause
exit /b 1
)
echo [模式] HTTP (适合远程访问)
echo [地址] http://localhost:3333/mcp
echo [提示] 按 Ctrl+C 停止服务
echo.
uv run python mcp_server/server.py --transport http --host 0.0.0.0 --port 3333
pause

21
start-http.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
echo "╔════════════════════════════════════════╗"
echo "║ TrendRadar MCP Server (HTTP 模式) ║"
echo "╚════════════════════════════════════════╝"
echo ""
# 检查虚拟环境
if [ ! -d ".venv" ]; then
echo "❌ [错误] 虚拟环境未找到"
echo "请先运行 ./setup-mac.sh 进行部署"
echo ""
exit 1
fi
echo "[模式] HTTP (适合远程访问)"
echo "[地址] http://localhost:3333/mcp"
echo "[提示] 按 Ctrl+C 停止服务"
echo ""
uv run python mcp_server/server.py --transport http --host 0.0.0.0 --port 3333

View File

@ -1 +1 @@
2.4.4
3.0.0