Răsfoiți Sursa

feat: 增强 AI 分析控制能力与配置版本管理

sansan 3 luni în urmă
părinte
comite
c4f645161f

+ 10 - 3
README-EN.md

@@ -13,7 +13,7 @@ Deploy in <strong>30 seconds</strong> — Say goodbye to endless scrolling, only
 [![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-v5.3.0-blue.svg)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v5.4.0-blue.svg)](https://github.com/sansan0/TrendRadar)
 [![MCP](https://img.shields.io/badge/MCP-v3.1.7-green.svg)](https://github.com/sansan0/TrendRadar)
 [![RSS](https://img.shields.io/badge/RSS-Feed_Support-orange.svg?style=flat-square&logo=rss&logoColor=white)](https://github.com/sansan0/TrendRadar)
 [![AI Translation](https://img.shields.io/badge/AI-Multi--Language-purple.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
@@ -158,8 +158,8 @@ After communication, the author indicated no concerns about server pressure, but
 
 
 - **GitHub Issues**: Suitable for targeted answers. Please provide complete info when asking (screenshots, error logs, system environment, etc.).
-- **Official Account**: Suitable for quick consultation. Suggest priority to communicate in public comment area of related articles. If private message, please use polite language 😉
-- **Contact**: path@linux.do
+- **Official Account**: Suggested for interaction. Please prioritize public comments under relevant articles. If you need to ask questions, liking, recommending, or sharing articles to show support is highly appreciated! (´▽`ʃ♡ƪ).
+  <br>*(Friendly Reminder: This is a free open-source project, not a commercial service. Please check the documentation first if you encounter issues. Patience and courtesy are expected. I cannot respond to demands for customer support or emotional accusations. Thank you for understanding. Additionally, significant effort went into the documentation; it is strongly recommended to read the [**🚀 Quick Start**](#-quick-start) section first, where most deployment answers can be found.)*
 
 
 | Official Account | WeChat Appreciation | Alipay Appreciation |
@@ -173,6 +173,13 @@ After communication, the author indicated no concerns about server pressure, but
 >**📌 Check Latest Updates**: **[Original Repository Changelog](https://github.com/sansan0/TrendRadar?tab=readme-ov-file#-changelog)**:
 - **Tip**: Check [Changelog] to understand specific [Features]
 
+### 2026/01/23 - v5.4.0
+
+- Added independent control for AI analysis mode, options: follow_report | daily | current | incremental
+- Added time window control for AI analysis, supporting custom execution periods and daily frequency limits
+- Added configuration file version management function
+- Fixed several bugs
+
 ### 2026/01/19 - v5.3.0
 
 > **Major Refactor: AI Module Migration to LiteLLM**

+ 27 - 19
README.md

@@ -13,7 +13,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-v5.3.0-blue.svg)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v5.4.0-blue.svg)](https://github.com/sansan0/TrendRadar)
 [![MCP](https://img.shields.io/badge/MCP-v3.1.7-green.svg)](https://github.com/sansan0/TrendRadar)
 [![RSS](https://img.shields.io/badge/RSS-订阅源支持-orange.svg?style=flat-square&logo=rss&logoColor=white)](https://github.com/sansan0/TrendRadar)
 [![AI翻译](https://img.shields.io/badge/AI-多语言推送-purple.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
@@ -205,8 +205,8 @@
 
 
 - **GitHub Issues**:适合针对性强的解答。提问时请提供完整信息(截图、错误日志、系统环境等)。
-- **公众号交流**:适合快速咨询。建议优先在相关文章下的公共留言区交流,如私信,请文明礼貌用语😉
-- **联系方式**:path@linux.do
+- **公众号交流**:建议优先在相关文章下的公共留言区交流。若需提问,欢迎先点赞、推荐或分享文章表达支持,我在后台都能感受到这份心意哟 (´▽`ʃ♡ƪ)。
+  <br>*(友情提示:本项目为免费开源分享,非商业服务。遇到部署问题请先查阅文档,提问请保持耐心与礼貌。对于将开源作者视为客服或带有情绪的指责,恕难回应,感谢理解。此外,文档倾注了大量心血,强烈建议优先阅读 [**🚀 快速开始**](#-快速开始) 章节,绝大多数部署问题都能从中找到答案。)*
 
 
 |公众号关注 |微信点赞 | 支付宝点赞 |
@@ -220,6 +220,30 @@
 > **📌 查看最新更新**:**[原仓库更新日志](https://github.com/sansan0/TrendRadar?tab=readme-ov-file#-更新日志)** :
 - **提示**:建议查看【历史更新】,明确具体的【功能内容】
 
+
+### 2026/01/23 - v5.4.0
+
+- 增加 AI 分析模式的独立控制功能,可选 follow_report | daily | current | incremental 
+- 新增 AI 分析时间窗口控制,支持自定义运行段及每日频次限制
+- 增加配置文件版本管理功能
+- 修复若干bug
+
+
+### 2026/01/10 - mcp-v3.0.0~v3.1.5
+
+- **Breaking Change**:所有工具返回值统一为 `{success, summary, data, error}` 结构
+- **异步一致性**:所有 21 个工具函数使用 `asyncio.to_thread()` 包装同步调用
+- **MCP Resources**:新增 4 个资源(platforms、rss-feeds、available-dates、keywords)
+- **RSS 增强**:`get_latest_rss` 支持多日查询(days 参数),跨日期 URL 去重
+- **正则匹配修复**:`get_trending_topics` 支持 `/pattern/` 正则语法和 `display_name`
+- **缓存优化**:新增 `make_cache_key()` 函数,参数排序+MD5 哈希确保一致性
+- **新增 check_version 工具**:支持同时检查 TrendRadar 和 MCP Server 版本更新
+
+
+<details>
+<summary>👉 点击展开:<strong>历史更新</strong></summary>
+
+
 ### 2026/01/19 - v5.3.0
 
 > **重大重构:AI 模块迁移至 LiteLLM**
@@ -270,22 +294,6 @@
 
 - 修复若干已知问题,提升系统稳定性
 
-
-### 2026/01/10 - mcp-v3.0.0~v3.1.5
-
-- **Breaking Change**:所有工具返回值统一为 `{success, summary, data, error}` 结构
-- **异步一致性**:所有 21 个工具函数使用 `asyncio.to_thread()` 包装同步调用
-- **MCP Resources**:新增 4 个资源(platforms、rss-feeds、available-dates、keywords)
-- **RSS 增强**:`get_latest_rss` 支持多日查询(days 参数),跨日期 URL 去重
-- **正则匹配修复**:`get_trending_topics` 支持 `/pattern/` 正则语法和 `display_name`
-- **缓存优化**:新增 `make_cache_key()` 函数,参数排序+MD5 哈希确保一致性
-- **新增 check_version 工具**:支持同时检查 TrendRadar 和 MCP Server 版本更新
-
-
-<details>
-<summary>👉 点击展开:<strong>历史更新</strong></summary>
-
-
 ### 2026/01/10 - v5.0.0
 
 > **开发小插曲**:

+ 4 - 1
config/ai_analysis_prompt.txt

@@ -1,5 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar AI 分析提示词配置
+#                      Version: 1.0.0
 # ═══════════════════════════════════════════════════════════════
 #
 # 此文件定义 AI 分析热点新闻时使用的提示词模板
@@ -137,7 +138,9 @@
 
 要求:
 - 必须返回有效的 JSON 格式
+- 返回内容中不要使用 Markdown 格式(如 **加粗**),仅使用纯文本
 - 使用 {language} 输出,语言简练专业
 - 确保 5 个板块不重叠,信息不冗余
 - 若某板块无明显内容,可简写"暂无显著异常"
-- 不要使用 Markdown 格式(如 **加粗**),仅使用纯文本
+- 使用 `【大标题】` 时,不要前面加序号
+- 使用 `1. 序号` 时,行内绝对禁止再使用 `【】` 方括号

+ 1 - 0
config/ai_translation_prompt.txt

@@ -1,5 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar AI 翻译提示词配置
+#                      Version: 1.0.0
 # ═══════════════════════════════════════════════════════════════
 #
 # 此文件定义 AI 翻译内容时使用的提示词模板

+ 37 - 12
config/config.yaml

@@ -1,5 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar 配置文件
+#                      Version: 1.0.0
 # ═══════════════════════════════════════════════════════════════
 
 
@@ -174,7 +175,7 @@ display:
     new_items: true                   # 新增热点区域(含热榜新增 + RSS 新增)
                                       # 注:热点词汇统计中的新增标记🆕不受此配置影响
 
-    rss: true                         # RSS 订阅区域
+    rss: false                         # RSS 订阅区域
                                       # 开启后将对 RSS 进行关键词分析并在通知中展示
                                       # 关闭后跳过分析,但独立展示区不受影响
 
@@ -339,7 +340,7 @@ ai:
   api_base: ""                      # 自定义 API 端点(可选,大多数情况留空)
                                     # 示例: https://api.openai.com/v1(自建代理或兼容接口)
                                     #
-                                    # 💡 高级用法:连接任意兼容 OpenAI 协议的模型商
+                                    # 💡 超级重要:连接任意兼容 OpenAI 协议的模型商
                                     # 如果你使用的模型商不在上述支持列表中,但提供了兼容 OpenAI 的接口:
                                     #
                                     # 1. api_base 填写: 服务商提供的接口地址
@@ -352,13 +353,11 @@ ai:
 
   timeout: 120                      # 请求超时(秒)
 
-  # AI 参数配置
   temperature: 1.0                  # 采样温度 (0.0-2.0)
                                     # 注意:部分模型(如 gpt-5)可能要求必须为 1.0,否则会报错
-
+  
   max_tokens: 5000                  # 最大生成 token 数
                                     # 注意:如果 API 不支持此参数(报 HTTP 400),请设为 0 以禁用发送
-
   # 高级选项
   num_retries: 1                    # 失败重试次数
   fallback_models: []               # 备用模型列表(可选)
@@ -389,6 +388,20 @@ ai:
 ai_analysis:
   enabled: true                     # 是否启用 AI 分析
 
+  # 🕐 AI 分析时间窗口控制(可选功能)
+  # 用途:限制 AI 分析的时间范围,避免非必要时段消耗 API 额度
+  # 适用场景:
+  #   • 只在工作时间进行 AI 分析(如 09:00-18:00)
+  #   • 在特定时段进行深度分析(如 20:00-22:00)
+  # ⚠️ GitHub Actions 用户注意:
+  #   执行时间不稳定,时间范围建议至少留足 2 小时
+  # 💡 想要精准定时?建议使用 Docker 部署在个人服务器上
+  analysis_window:
+    enabled: false                  # 是否启用 AI 分析时间窗口控制
+    start: "12:00"                  # 开始时间(使用 app.timezone 配置的时区)
+    end: "21:00"                    # 结束时间(使用 app.timezone 配置的时区)
+    once_per_day: false             # true=窗口内只分析一次,false=窗口内每次执行都分析
+
   # 分析报告输出语言
   # 格式:自然语言描述
   # 示例: "English", "Korean", "法语"
@@ -397,16 +410,27 @@ ai_analysis:
   # 提示词配置文件路径(相对于 config 目录)
   prompt_file: "ai_analysis_prompt.txt"
 
+  # AI 分析模式(独立于推送报告模式)
+  # 可选值:
+  #   - "follow_report": 跟随 report.mode 的设置(默认)
+  #   - "daily": 强制使用当日汇总模式(分析当天所有新闻)
+  #   - "current": 强制使用当前榜单模式(只分析当前在榜新闻)
+  #   - "incremental": 强制使用增量模式(只分析新增新闻)
+  #
+  # 使用场景:
+  #   - 推送 incremental(避免重复),AI 分析 current(看当前榜单变化)
+  #   - 推送 current(实时热点),AI 分析 daily(全天总结)
+  #
+  mode: "follow_report"
+
   # 分析内容配置
-  max_news_for_analysis: 50         # 参与分析的新闻数量上限(控制成本关键项)
-                                    # 当前默认的【报告模式】是【当前榜单模式】(current),也就是只分析当前在热榜的新闻
-                                    # 如果需要让报告呈现出更有参考价值的完整一天的趋势,且你的 token 充裕
-                                      # 可开启 daily(当日汇总模式)
-                                      # 同时调整 max_news_for_analysis 为 150(你自己视情况调整,推送消息顶部有 ai 分析数目供参考)
+  max_news_for_analysis: 60         # 参与分析的新闻数量上限(控制成本关键项)
+                                    # 推送消息顶部会显示实际的 AI 分析数供参考
 
                                     # api 成本估算 (仅供参考)
-                                      # 按默认推送频率和模型(deepseek)
-                                      # 且 include_rank_timeline 为 false
+                                      # 按默认模型(deepseek)
+                                      # max_news_for_analysis 为 50 条
+                                      # include_rank_timeline 为 false
                                     # 则
                                       # GitHub Action 部署默认推送约 20 次(每小时推送一次), 约 0.1 元/天
                                       # Docker 部署默认推送 48 次(每半小时推送一次), 约 0.2 元/天
@@ -447,6 +471,7 @@ advanced:
   # 版本检查
   version_check_url: "https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version"
   mcp_version_check_url: "https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_mcp"
+  configs_version_check_url: "https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_configs"
 
   # 热榜爬虫技术参数
   crawler:

+ 18 - 15
config/frequency_words.txt

@@ -1,5 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar 频率词配置文件
+#                         Version: 1.0.0
 # ═══════════════════════════════════════════════════════════════
 # 凡是左侧有 # 的都是仅供阅读的说明性文字
 #
@@ -151,15 +152,15 @@
 
 /胖东来|于东来/ => 胖东来
 
-/DeepSeek|梁文锋/i => DeepSeek
+/深度求索|幻方量化|梁文锋|\bDeepSeek\b/ => DeepSeek
 
-/华为|鸿蒙|HarmonyOS|任正非/i => 华为
+/华为|任正非|余承东|鸿蒙|海思|昇腾|鲲鹏|\bHUAWEI\b|\bHarmonyOS\b|\bHiSilicon\b/ => 华为
 
-/比亚迪|王传福|byd/i => 比亚迪
+/比亚迪|王传福|方程豹|腾势|仰望|弗迪|刀片电池|云辇|\bBYD\b|\bDenza\b|\bYangwang\b/ => 比亚迪
 
-/大疆|\bDJI\b/i => 大疆
+/大疆|汪滔|灵眸|如影|\bDJI\b|\bRoboMaster\b|\bMavic\b|\bZenmuse\b/ => 大疆
 
-/宇树|王兴兴|Unitree/ => 宇树机器人
+/宇树|王兴兴|\bUnitree\b/ => 宇树机器人
 
 /智元|灵犀|稚晖君|彭志辉|AgiBot/ => 智元机器人
 /众擎|EngineAI|赵同阳/ => 众擎机器人
@@ -172,23 +173,25 @@
 
 申奥
 
-/京东|刘强东/ => 京东
+/京东|刘强东|\bJD\b|\bJingdong\b/ => 京东
 
-/字节|bytedance|张一鸣/i => 字节跳动
+/字节|张一鸣|梁汝波|抖音|\bByteDance\b|\bTikTok\b|\bDouyin\b|\bLark\b|\bCapCut\b/ => 字节跳动
+
+/腾讯|鹅厂|马化腾|微信|QQ|天美|阅文集团|微众银行|\bTencent\b|\bPony Ma\b|\bWeChat\b|\bLightSpeed\b|\bWeBank\b/ => 腾讯
 
 /qwen|minimax|glm/ => 国产开源模型
 
-/特斯拉|马斯克/ => 特斯拉
+/特斯拉|马斯克|\bTesla\b|\bElon Musk\b|\bCybertruck\b|\bModel 3\b|\bModel Y\b|\bModel S\b|\bModel X\b|\bFSD\b/ => 特斯拉
 
-/英伟达|\bNVIDIA\b|黄仁勋/i => 英伟达
-/\bAMD\b/i => AMD
+/英伟达|黄仁勋|\bNVIDIA\b|\bGeForce\b|\bRTX\b|\bCUDA\b|\bJensen Huang\b/ => 英伟达
+/苏姿丰|锐龙|霄龙|\bAMD\b|\bRyzen\b|\bEPYC\b|\bRadeon\b|\bLisa Su\b/ => AMD
 
-/微软|\bMicrosoft\b/i => 微软
-/谷歌|\bgoogle\b|\bgemini\b|\bdeepMind\b/i => 谷歌
-/\biphone\b|\bipad\b|\bmac\b|\bios\b/i => 苹果
+/微软|\bMicrosoft\b|\bWindows\b|\bAzure\b|\bSatya Nadella\b|\bCopilot\b/ => 微软
+/谷歌|皮查伊|安卓|油管|\bGoogle\b|\bAlphabet\b|\bAndroid\b|\bChrome\b|\bYouTube\b|\bGemini\b|\bDeepMind\b|\bWaymo\b/ => 谷歌
+/库克|\biPhone\b|\biPad\b|\bMacBook\b|\biOS\b|\bVision Pro\b|\bAirPods\b|\bApple\b|\bTim Cook\b/ => 苹果
 
-/\bchatgpt\b|\bopenai\b|\bsora\b/i => OpenAI
-/\bclaude\b|anthropic/i => Claude
+/\bOpenAI\b|\bChatGPT\b|\bSora\b|\bDALL-E\b|\bSam Altman\b|\bGreg Brockman\b/ => OpenAI
+/\bAnthropic\b|\bClaude\b|\bDario Amodei\b/ => Claude
 
 
 # ═══════════════════════════════════════════════════════════════

+ 2 - 1
pyproject.toml

@@ -1,6 +1,6 @@
 [project]
 name = "trendradar"
-version = "5.3.0"
+version = "5.4.0"
 description = "TrendRadar - 热点新闻聚合与分析工具"
 requires-python = ">=3.10"
 dependencies = [
@@ -12,6 +12,7 @@ dependencies = [
     "feedparser>=6.0.0,<7.0.0",
     "boto3>=1.35.0,<2.0.0",
     "litellm>=1.57.0,<2.0.0",
+    "tenacity==8.5.0"
 ]
 
 [project.scripts]

+ 1 - 0
requirements.txt

@@ -6,3 +6,4 @@ websockets>=13.0,<14.0
 boto3>=1.35.0,<2.0.0
 feedparser>=6.0.0,<7.0.0
 litellm>=1.57.0,<2.0.0
+tenacity==8.5.0

+ 1 - 1
trendradar/__init__.py

@@ -9,5 +9,5 @@ TrendRadar - 热点新闻聚合与分析工具
 
 from trendradar.context import AppContext
 
-__version__ = "5.3.0"
+__version__ = "5.4.0"
 __all__ = ["AppContext", "__version__"]

+ 377 - 66
trendradar/__main__.py

@@ -7,6 +7,7 @@ TrendRadar 主程序
 """
 
 import os
+import re
 import webbrowser
 from pathlib import Path
 from typing import Dict, List, Tuple, Optional
@@ -23,10 +24,32 @@ from trendradar.utils.time import is_within_days
 from trendradar.ai import AIAnalyzer, AIAnalysisResult
 
 
-def check_version_update(
-    current_version: str, version_url: str, proxy_url: Optional[str] = None
-) -> Tuple[bool, Optional[str]]:
-    """检查版本更新"""
+def _parse_version(version_str: str) -> Tuple[int, int, int]:
+    """解析版本号字符串为元组"""
+    try:
+        parts = version_str.strip().split(".")
+        if len(parts) >= 3:
+            return int(parts[0]), int(parts[1]), int(parts[2])
+        return 0, 0, 0
+    except:
+        return 0, 0, 0
+
+
+def _compare_version(local: str, remote: str) -> str:
+    """比较版本号,返回状态文字"""
+    local_tuple = _parse_version(local)
+    remote_tuple = _parse_version(remote)
+
+    if local_tuple < remote_tuple:
+        return "⚠️ 需要更新"
+    elif local_tuple > remote_tuple:
+        return "🔮 超前版本"
+    else:
+        return "✅ 已是最新"
+
+
+def _fetch_remote_version(version_url: str, proxy_url: Optional[str] = None) -> Optional[str]:
+    """获取远程版本号"""
     try:
         proxies = None
         if proxy_url:
@@ -38,33 +61,123 @@ def check_version_update(
             "Cache-Control": "no-cache",
         }
 
-        response = requests.get(
-            version_url, proxies=proxies, headers=headers, timeout=10
-        )
+        response = requests.get(version_url, proxies=proxies, headers=headers, timeout=10)
         response.raise_for_status()
+        return response.text.strip()
+    except Exception as e:
+        print(f"[版本检查] 获取远程版本失败: {e}")
+        return None
 
-        remote_version = response.text.strip()
-        print(f"当前版本: {current_version}, 远程版本: {remote_version}")
 
-        # 比较版本
-        def parse_version(version_str):
-            try:
-                parts = version_str.strip().split(".")
-                if len(parts) != 3:
-                    raise ValueError("版本号格式不正确")
-                return int(parts[0]), int(parts[1]), int(parts[2])
-            except:
-                return 0, 0, 0
+def _parse_config_versions(content: str) -> Dict[str, str]:
+    """解析配置文件版本内容为字典"""
+    versions = {}
+    try:
+        if not content:
+            return versions
+        for line in content.splitlines():
+            line = line.strip()
+            if not line or "=" not in line:
+                continue
+            name, version = line.split("=", 1)
+            versions[name.strip()] = version.strip()
+    except Exception as e:
+        print(f"[版本检查] 解析配置版本失败: {e}")
+    return versions
 
-        current_tuple = parse_version(current_version)
-        remote_tuple = parse_version(remote_version)
 
-        need_update = current_tuple < remote_tuple
-        return need_update, remote_version if need_update else None
+def check_all_versions(
+    version_url: str,
+    configs_version_url: Optional[str] = None,
+    proxy_url: Optional[str] = None
+) -> Tuple[bool, Optional[str]]:
+    """
+    统一版本检查:程序版本 + 配置文件版本
+
+    Args:
+        version_url: 远程程序版本检查 URL
+        configs_version_url: 远程配置文件版本检查 URL (返回格式: filename=version)
+        proxy_url: 代理 URL
+
+    Returns:
+        (need_update, remote_version): 程序是否需要更新及远程版本号
+    """
+    # 获取远程版本
+    remote_version = _fetch_remote_version(version_url, proxy_url)
+
+    # 获取远程配置版本(如果有提供 URL)
+    remote_config_versions = {}
+    if configs_version_url:
+        content = _fetch_remote_version(configs_version_url, proxy_url)
+        if content:
+            remote_config_versions = _parse_config_versions(content)
+
+    print("=" * 60)
+    print("版本检查")
+    print("=" * 60)
+
+    if remote_version:
+        print(f"远程程序版本: {remote_version}")
+    else:
+        print("远程程序版本: 获取失败")
+
+    if configs_version_url:
+        if remote_config_versions:
+            print(f"远程配置清单: 获取成功 ({len(remote_config_versions)} 个文件)")
+        else:
+            print("远程配置清单: 获取失败或为空")
 
-    except Exception as e:
-        print(f"版本检查失败: {e}")
-        return False, None
+    print("-" * 60)
+
+    program_status = _compare_version(__version__, remote_version) if remote_version else "(无法比较)"
+    print(f"  主程序版本: {__version__} {program_status}")
+
+    config_files = [
+        Path("config/config.yaml"),
+        Path("config/frequency_words.txt"),
+        Path("config/ai_analysis_prompt.txt"),
+        Path("config/ai_translation_prompt.txt"),
+    ]
+
+    version_pattern = re.compile(r"Version:\s*(\d+\.\d+\.\d+)", re.IGNORECASE)
+
+    for config_file in config_files:
+        if not config_file.exists():
+            print(f"  {config_file.name}: 文件不存在")
+            continue
+
+        try:
+            with open(config_file, "r", encoding="utf-8") as f:
+                local_version = None
+                for i, line in enumerate(f):
+                    if i >= 20:
+                        break
+                    match = version_pattern.search(line)
+                    if match:
+                        local_version = match.group(1)
+                        break
+
+                # 获取该文件的远程版本
+                target_remote_version = remote_config_versions.get(config_file.name)
+
+                if local_version:
+                    if target_remote_version:
+                        status = _compare_version(local_version, target_remote_version)
+                        print(f"  {config_file.name}: {local_version} {status}")
+                    else:
+                        print(f"  {config_file.name}: {local_version} (未找到远程版本)")
+                else:
+                    print(f"  {config_file.name}: 未找到本地版本号")
+        except Exception as e:
+            print(f"  {config_file.name}: 读取失败 - {e}")
+
+    print("=" * 60)
+
+    # 返回程序版本的更新状态
+    if remote_version:
+        need_update = _parse_version(__version__) < _parse_version(remote_version)
+        return need_update, remote_version if need_update else None
+    return False, None
 
 
 # === 主分析器 ===
@@ -93,10 +206,11 @@ class NewsAnalyzer:
         },
     }
 
-    def __init__(self):
-        # 加载配置
-        print("正在加载配置...")
-        config = load_config()
+    def __init__(self, config: Optional[Dict] = None):
+        # 使用传入的配置或加载新配置
+        if config is None:
+            print("正在加载配置...")
+            config = load_config()
         print(f"TrendRadar v{__version__} 配置加载完成")
         print(f"监控平台数量: {len(config['PLATFORMS'])}")
         print(f"时区: {config.get('TIMEZONE', 'Asia/Shanghai')}")
@@ -116,9 +230,7 @@ class NewsAnalyzer:
 
         # 初始化存储管理器(使用 AppContext)
         self._init_storage_manager()
-
-        if self.is_github_actions:
-            self._check_version_update()
+        # 注意:update_info 由 main() 函数设置,避免重复请求远程版本
 
     def _init_storage_manager(self) -> None:
         """初始化存储管理器(使用 AppContext)"""
@@ -162,21 +274,21 @@ class NewsAnalyzer:
         else:
             print("GitHub Actions环境,不使用代理")
 
-    def _check_version_update(self) -> None:
-        """检查版本更新"""
+    def _set_update_info_from_config(self) -> None:
+        """从已缓存的远程版本设置更新信息(不再重复请求)"""
         try:
-            need_update, remote_version = check_version_update(
-                __version__, self.ctx.config["VERSION_CHECK_URL"], self.proxy_url
-            )
-
-            if need_update and remote_version:
-                self.update_info = {
-                    "current_version": __version__,
-                    "remote_version": remote_version,
-                }
-                print(f"发现新版本: {remote_version} (当前: {__version__})")
-            else:
-                print("版本检查完成,当前为最新版本")
+            version_url = self.ctx.config.get("VERSION_CHECK_URL", "")
+            if not version_url:
+                return
+
+            remote_version = _fetch_remote_version(version_url, self.proxy_url)
+            if remote_version:
+                need_update = _parse_version(__version__) < _parse_version(remote_version)
+                if need_update:
+                    self.update_info = {
+                        "current_version": __version__,
+                        "remote_version": remote_version,
+                    }
         except Exception as e:
             print(f"版本检查出错: {e}")
 
@@ -210,12 +322,10 @@ class NewsAnalyzer:
     ) -> bool:
         """检查是否有有效的新闻内容"""
         if self.report_mode == "incremental":
-            # 增量模式:必须有新增标题且匹配了关键词才推送
-            has_new_titles = bool(
-                new_titles and any(len(titles) > 0 for titles in new_titles.values())
-            )
+            # 增量模式:只要有匹配的新闻就推送
+            # count_word_frequency 已经确保只处理新增的新闻(包括当天第一次爬取的情况)
             has_matched_news = any(stat["count"] > 0 for stat in stats)
-            return has_new_titles and has_matched_news
+            return has_matched_news
         elif self.report_mode == "current":
             # current模式:只要stats有内容就说明有匹配的新闻
             return any(stat["count"] > 0 for stat in stats)
@@ -227,6 +337,114 @@ class NewsAnalyzer:
             )
             return has_matched_news or has_new_news
 
+    def _prepare_ai_analysis_data(
+        self,
+        ai_mode: str,
+        current_results: Optional[Dict] = None,
+        current_id_to_name: Optional[Dict] = None,
+    ) -> Tuple[List[Dict], Optional[Dict]]:
+        """
+        为 AI 分析准备指定模式的数据
+
+        Args:
+            ai_mode: AI 分析模式 (daily/current/incremental)
+            current_results: 当前抓取的结果(用于 incremental 模式)
+            current_id_to_name: 当前的平台映射(用于 incremental 模式)
+
+        Returns:
+            Tuple[stats, id_to_name]: 统计数据和平台映射
+        """
+        try:
+            word_groups, filter_words, global_filters = self.ctx.load_frequency_words()
+
+            if ai_mode == "incremental":
+                # incremental 模式:使用当前抓取的数据
+                if not current_results or not current_id_to_name:
+                    print("[AI] incremental 模式需要当前抓取数据,但未提供")
+                    return [], None
+
+                # 准备当前时间信息
+                time_info = self.ctx.format_time()
+                title_info = self._prepare_current_title_info(current_results, time_info)
+
+                # 检测新增标题
+                new_titles = self.ctx.detect_new_titles(list(current_results.keys()))
+
+                # 统计计算
+                stats, _ = self.ctx.count_frequency(
+                    current_results,
+                    word_groups,
+                    filter_words,
+                    current_id_to_name,
+                    title_info,
+                    new_titles,
+                    mode="incremental",
+                    global_filters=global_filters,
+                    quiet=True,
+                )
+
+                # 如果是 platform 模式,转换数据结构
+                if self.ctx.display_mode == "platform" and stats:
+                    from trendradar.core.stats import convert_keyword_stats_to_platform_stats
+                    stats = convert_keyword_stats_to_platform_stats(
+                        stats,
+                        self.ctx.weight_config,
+                        self.ctx.rank_threshold,
+                    )
+
+                return stats, current_id_to_name
+
+            elif ai_mode in ["daily", "current"]:
+                # 加载历史数据
+                analysis_data = self._load_analysis_data(quiet=True)
+                if not analysis_data:
+                    print(f"[AI] 无法加载历史数据用于 {ai_mode} 模式分析")
+                    return [], None
+
+                (
+                    all_results,
+                    id_to_name,
+                    title_info,
+                    new_titles,
+                    _,
+                    _,
+                    _,
+                ) = analysis_data
+
+                # 统计计算
+                stats, _ = self.ctx.count_frequency(
+                    all_results,
+                    word_groups,
+                    filter_words,
+                    id_to_name,
+                    title_info,
+                    new_titles,
+                    mode=ai_mode,
+                    global_filters=global_filters,
+                    quiet=True,
+                )
+
+                # 如果是 platform 模式,转换数据结构
+                if self.ctx.display_mode == "platform" and stats:
+                    from trendradar.core.stats import convert_keyword_stats_to_platform_stats
+                    stats = convert_keyword_stats_to_platform_stats(
+                        stats,
+                        self.ctx.weight_config,
+                        self.ctx.rank_threshold,
+                    )
+
+                return stats, id_to_name
+            else:
+                print(f"[AI] 未知的 AI 模式: {ai_mode}")
+                return [], None
+
+        except Exception as e:
+            print(f"[AI] 准备 {ai_mode} 模式数据时出错: {e}")
+            if self.ctx.config.get("DEBUG", False):
+                import traceback
+                traceback.print_exc()
+            return [], None
+
     def _run_ai_analysis(
         self,
         stats: List[Dict],
@@ -234,39 +452,113 @@ class NewsAnalyzer:
         mode: str,
         report_type: str,
         id_to_name: Optional[Dict],
+        current_results: Optional[Dict] = None,
     ) -> Optional[AIAnalysisResult]:
         """执行 AI 分析"""
         analysis_config = self.ctx.config.get("AI_ANALYSIS", {})
         if not analysis_config.get("ENABLED", False):
             return None
 
+        # AI 分析时间窗口控制
+        analysis_window = analysis_config.get("ANALYSIS_WINDOW", {})
+        if analysis_window.get("ENABLED", False):
+            push_manager = self.ctx.create_push_manager()
+            time_range_start = analysis_window["TIME_RANGE"]["START"]
+            time_range_end = analysis_window["TIME_RANGE"]["END"]
+
+            if not push_manager.is_in_time_range(time_range_start, time_range_end):
+                now = self.ctx.get_time()
+                print(
+                    f"[AI] 分析窗口控制:当前时间 {now.strftime('%H:%M')} 不在分析时间窗口 {time_range_start}-{time_range_end} 内,跳过 AI 分析"
+                )
+                return None
+
+            if analysis_window.get("ONCE_PER_DAY", False):
+                # 检查今天是否已经进行过 AI 分析
+                if push_manager.storage_backend.has_ai_analyzed_today():
+                    print(f"[AI] 分析窗口控制:今天已分析过,跳过本次 AI 分析")
+                    return None
+                else:
+                    print(f"[AI] 分析窗口控制:今天首次分析")
+
         print("[AI] 正在进行 AI 分析...")
         try:
             ai_config = self.ctx.config.get("AI", {})
             debug_mode = self.ctx.config.get("DEBUG", False)
             analyzer = AIAnalyzer(ai_config, analysis_config, self.ctx.get_time, debug=debug_mode)
 
+            # 确定 AI 分析使用的模式
+            ai_mode_config = analysis_config.get("MODE", "follow_report")
+            if ai_mode_config == "follow_report":
+                # 跟随推送报告模式
+                ai_mode = mode
+                ai_stats = stats
+                ai_id_to_name = id_to_name
+            elif ai_mode_config in ["daily", "current", "incremental"]:
+                # 使用独立配置的模式,需要重新准备数据
+                ai_mode = ai_mode_config
+                if ai_mode != mode:
+                    print(f"[AI] 使用独立分析模式: {ai_mode} (推送模式: {mode})")
+                    print(f"[AI] 正在准备 {ai_mode} 模式的数据...")
+
+                    # 根据 AI 模式重新准备数据
+                    ai_stats, ai_id_to_name = self._prepare_ai_analysis_data(
+                        ai_mode, current_results, id_to_name
+                    )
+                    if not ai_stats:
+                        print(f"[AI] 警告: 无法准备 {ai_mode} 模式的数据,回退到推送模式数据")
+                        ai_stats = stats
+                        ai_id_to_name = id_to_name
+                        ai_mode = mode
+                else:
+                    ai_stats = stats
+                    ai_id_to_name = id_to_name
+            else:
+                # 配置错误,回退到跟随模式
+                print(f"[AI] 警告: 无效的 ai_analysis.mode 配置 '{ai_mode_config}',使用推送模式 '{mode}'")
+                ai_mode = mode
+                ai_stats = stats
+                ai_id_to_name = id_to_name
+
             # 提取平台列表
-            platforms = list(id_to_name.values()) if id_to_name else []
+            platforms = list(ai_id_to_name.values()) if ai_id_to_name else []
 
             # 提取关键词列表
-            keywords = [s.get("word", "") for s in stats if s.get("word")] if stats else []
+            keywords = [s.get("word", "") for s in ai_stats if s.get("word")] if ai_stats else []
+
+            # 确定报告类型
+            if ai_mode != mode:
+                # 根据 AI 模式确定报告类型
+                ai_report_type = {
+                    "daily": "当日汇总",
+                    "current": "当前榜单",
+                    "incremental": "增量更新"
+                }.get(ai_mode, report_type)
+            else:
+                ai_report_type = report_type
 
             result = analyzer.analyze(
-                stats=stats,
+                stats=ai_stats,
                 rss_stats=rss_items,
-                report_mode=mode,
-                report_type=report_type,
+                report_mode=ai_mode,
+                report_type=ai_report_type,
                 platforms=platforms,
                 keywords=keywords,
             )
 
+            # 设置 AI 分析使用的模式
             if result.success:
+                result.ai_mode = ai_mode
                 if result.error:
                     # 成功但有警告(如 JSON 解析问题但使用了原始文本)
                     print(f"[AI] 分析完成(有警告: {result.error})")
                 else:
                     print("[AI] 分析完成")
+
+                # 记录 AI 分析(如果启用了 once_per_day)
+                if analysis_window.get("ENABLED", False) and analysis_window.get("ONCE_PER_DAY", False):
+                    push_manager = self.ctx.create_push_manager()
+                    push_manager.storage_backend.record_ai_analysis(ai_mode)
             else:
                 print(f"[AI] 分析失败: {result.error}")
 
@@ -538,7 +830,7 @@ class NewsAnalyzer:
             mode_strategy = self._get_mode_strategy()
             report_type = mode_strategy["report_type"]
             ai_result = self._run_ai_analysis(
-                stats, rss_items, mode, report_type, id_to_name
+                stats, rss_items, mode, report_type, id_to_name, current_results=data_source
             )
 
         # HTML生成(如果启用)
@@ -573,6 +865,7 @@ class NewsAnalyzer:
         rss_new_items: Optional[List[Dict]] = None,
         standalone_data: Optional[Dict] = None,
         ai_result: Optional[AIAnalysisResult] = None,
+        current_results: Optional[Dict] = None,
     ) -> bool:
         """统一的通知发送逻辑,包含所有判断条件,支持热榜+RSS合并推送+AI分析+独立展示区"""
         has_notification = self._has_notification_configured()
@@ -627,7 +920,7 @@ class NewsAnalyzer:
                 ai_config = cfg.get("AI_ANALYSIS", {})
                 if ai_config.get("ENABLED", False):
                     ai_result = self._run_ai_analysis(
-                        stats, rss_items, mode, report_type, id_to_name
+                        stats, rss_items, mode, report_type, id_to_name, current_results=current_results
                     )
 
             # 准备报告数据
@@ -677,13 +970,10 @@ class NewsAnalyzer:
         ):
             mode_strategy = self._get_mode_strategy()
             if self.report_mode == "incremental":
-                has_new = bool(
-                    new_titles and any(len(titles) > 0 for titles in new_titles.values())
-                )
-                if not has_new and not has_rss_content:
-                    print("跳过通知:增量模式下未检测到新增的新闻和RSS")
-                elif not has_new:
-                    print("跳过通知:增量模式下新增新闻未匹配到关键词")
+                if not has_rss_content:
+                    print("跳过通知:增量模式下未检测到匹配的新闻和RSS")
+                else:
+                    print("跳过通知:增量模式下新闻未匹配到关键词")
             else:
                 print(
                     f"跳过通知:{mode_strategy['mode_name']}下未检测到匹配的新闻"
@@ -1299,6 +1589,7 @@ class NewsAnalyzer:
                 rss_new_items=rss_new_items,
                 standalone_data=standalone_data,
                 ai_result=ai_result,
+                current_results=results,
             )
 
         # 打开浏览器(仅在非容器环境)
@@ -1344,7 +1635,27 @@ def main():
     """主程序入口"""
     debug_mode = False
     try:
-        analyzer = NewsAnalyzer()
+        # 先加载配置以获取 version_check_url
+        config = load_config()
+        version_url = config.get("VERSION_CHECK_URL", "")
+        configs_version_url = config.get("CONFIGS_VERSION_CHECK_URL", "")
+
+        # 统一版本检查(程序版本 + 配置文件版本,只请求一次远程)
+        need_update = False
+        remote_version = None
+        if version_url:
+            need_update, remote_version = check_all_versions(version_url, configs_version_url)
+
+        # 复用已加载的配置,避免重复加载
+        analyzer = NewsAnalyzer(config=config)
+
+        # 设置更新信息(复用已获取的远程版本,不再重复请求)
+        if analyzer.is_github_actions and need_update and remote_version:
+            analyzer.update_info = {
+                "current_version": __version__,
+                "remote_version": remote_version,
+            }
+
         # 获取 debug 配置
         debug_mode = analyzer.ctx.config.get("DEBUG", False)
         analyzer.run()

+ 19 - 2
trendradar/ai/analyzer.py

@@ -35,6 +35,7 @@ class AIAnalysisResult:
     max_news_limit: int = 0              # 分析上限配置值
     hotlist_count: int = 0               # 热榜新闻数
     rss_count: int = 0                   # RSS 新闻数
+    ai_mode: str = ""                    # AI 分析使用的模式 (daily/current/incremental)
 
 
 class AIAnalyzer:
@@ -134,6 +135,24 @@ class AIAnalyzer:
         Returns:
             AIAnalysisResult: 分析结果
         """
+        
+        # 打印配置信息方便调试
+        model = self.ai_config.get("MODEL", "unknown")
+        api_key = self.client.api_key or ""
+        api_base = self.ai_config.get("API_BASE", "")
+        masked_key = f"{api_key[:5]}******" if len(api_key) >= 5 else "******"
+        model_display = model.replace("/", "/\u200b") if model else "unknown"
+
+        print(f"[AI] 模型: {model_display}")
+        print(f"[AI] Key : {masked_key}")
+
+        if api_base:
+            print(f"[AI] 接口: 存在自定义 API 端点")
+
+        timeout = self.ai_config.get("TIMEOUT", 120)
+        max_tokens = self.ai_config.get("MAX_TOKENS", 5000)
+        print(f"[AI] 参数: timeout={timeout}, max_tokens={max_tokens}")
+
         if not self.client.api_key:
             return AIAnalysisResult(
                 success=False,
@@ -397,9 +416,7 @@ class AIAnalyzer:
             result.error = "AI 返回空响应"
             return result
 
-        # 尝试解析 JSON
         try:
-            # 提取 JSON 部分
             json_str = response
 
             if "```json" in response:

+ 99 - 61
trendradar/ai/formatter.py

@@ -66,16 +66,22 @@ def render_ai_analysis_markdown(result: AIAnalysisResult) -> str:
         lines.extend(["**核心热点态势**", _format_list_content(result.core_trends), ""])
 
     if result.sentiment_controversy:
-        lines.extend(["**舆论风向争议**", _format_list_content(result.sentiment_controversy), ""])
+        lines.extend(
+            ["**舆论风向争议**", _format_list_content(result.sentiment_controversy), ""]
+        )
 
     if result.signals:
         lines.extend(["**异动与弱信号**", _format_list_content(result.signals), ""])
 
     if result.rss_insights:
-        lines.extend(["**RSS 深度洞察**", _format_list_content(result.rss_insights), ""])
+        lines.extend(
+            ["**RSS 深度洞察**", _format_list_content(result.rss_insights), ""]
+        )
 
     if result.outlook_strategy:
-        lines.extend(["**研判策略建议**", _format_list_content(result.outlook_strategy)])
+        lines.extend(
+            ["**研判策略建议**", _format_list_content(result.outlook_strategy)]
+        )
 
     return "\n".join(lines)
 
@@ -91,16 +97,22 @@ def render_ai_analysis_feishu(result: AIAnalysisResult) -> str:
         lines.extend(["**核心热点态势**", _format_list_content(result.core_trends), ""])
 
     if result.sentiment_controversy:
-        lines.extend(["**舆论风向争议**", _format_list_content(result.sentiment_controversy), ""])
+        lines.extend(
+            ["**舆论风向争议**", _format_list_content(result.sentiment_controversy), ""]
+        )
 
     if result.signals:
         lines.extend(["**异动与弱信号**", _format_list_content(result.signals), ""])
 
     if result.rss_insights:
-        lines.extend(["**RSS 深度洞察**", _format_list_content(result.rss_insights), ""])
+        lines.extend(
+            ["**RSS 深度洞察**", _format_list_content(result.rss_insights), ""]
+        )
 
     if result.outlook_strategy:
-        lines.extend(["**研判策略建议**", _format_list_content(result.outlook_strategy)])
+        lines.extend(
+            ["**研判策略建议**", _format_list_content(result.outlook_strategy)]
+        )
 
     return "\n".join(lines)
 
@@ -113,19 +125,31 @@ def render_ai_analysis_dingtalk(result: AIAnalysisResult) -> str:
     lines = ["### ✨ AI 热点分析", ""]
 
     if result.core_trends:
-        lines.extend(["#### 核心热点态势", _format_list_content(result.core_trends), ""])
+        lines.extend(
+            ["#### 核心热点态势", _format_list_content(result.core_trends), ""]
+        )
 
     if result.sentiment_controversy:
-        lines.extend(["#### 舆论风向争议", _format_list_content(result.sentiment_controversy), ""])
+        lines.extend(
+            [
+                "#### 舆论风向争议",
+                _format_list_content(result.sentiment_controversy),
+                "",
+            ]
+        )
 
     if result.signals:
         lines.extend(["#### 异动与弱信号", _format_list_content(result.signals), ""])
 
     if result.rss_insights:
-        lines.extend(["#### RSS 深度洞察", _format_list_content(result.rss_insights), ""])
+        lines.extend(
+            ["#### RSS 深度洞察", _format_list_content(result.rss_insights), ""]
+        )
 
     if result.outlook_strategy:
-        lines.extend(["#### 研判策略建议", _format_list_content(result.outlook_strategy)])
+        lines.extend(
+            ["#### 研判策略建议", _format_list_content(result.outlook_strategy)]
+        )
 
     return "\n".join(lines)
 
@@ -133,61 +157,73 @@ def render_ai_analysis_dingtalk(result: AIAnalysisResult) -> str:
 def render_ai_analysis_html(result: AIAnalysisResult) -> str:
     """渲染为 HTML 格式(邮件)"""
     if not result.success:
-        return f'<div class="ai-error">⚠️ AI 分析失败: {_escape_html(result.error)}</div>'
+        return (
+            f'<div class="ai-error">⚠️ AI 分析失败: {_escape_html(result.error)}</div>'
+        )
 
-    html_parts = ['<div class="ai-analysis">', '<h3>✨ AI 热点分析</h3>']
+    html_parts = ['<div class="ai-analysis">', "<h3>✨ AI 热点分析</h3>"]
 
     if result.core_trends:
         content = _format_list_content(result.core_trends)
         content_html = _escape_html(content).replace("\n", "<br>")
-        html_parts.extend([
-            '<div class="ai-section">',
-            '<h4>核心热点态势</h4>',
-            f'<div class="ai-content">{content_html}</div>',
-            '</div>'
-        ])
+        html_parts.extend(
+            [
+                '<div class="ai-section">',
+                "<h4>核心热点态势</h4>",
+                f'<div class="ai-content">{content_html}</div>',
+                "</div>",
+            ]
+        )
 
     if result.sentiment_controversy:
         content = _format_list_content(result.sentiment_controversy)
         content_html = _escape_html(content).replace("\n", "<br>")
-        html_parts.extend([
-            '<div class="ai-section">',
-            '<h4>舆论风向争议</h4>',
-            f'<div class="ai-content">{content_html}</div>',
-            '</div>'
-        ])
+        html_parts.extend(
+            [
+                '<div class="ai-section">',
+                "<h4>舆论风向争议</h4>",
+                f'<div class="ai-content">{content_html}</div>',
+                "</div>",
+            ]
+        )
 
     if result.signals:
         content = _format_list_content(result.signals)
         content_html = _escape_html(content).replace("\n", "<br>")
-        html_parts.extend([
-            '<div class="ai-section">',
-            '<h4>异动与弱信号</h4>',
-            f'<div class="ai-content">{content_html}</div>',
-            '</div>'
-        ])
+        html_parts.extend(
+            [
+                '<div class="ai-section">',
+                "<h4>异动与弱信号</h4>",
+                f'<div class="ai-content">{content_html}</div>',
+                "</div>",
+            ]
+        )
 
     if result.rss_insights:
         content = _format_list_content(result.rss_insights)
         content_html = _escape_html(content).replace("\n", "<br>")
-        html_parts.extend([
-            '<div class="ai-section">',
-            '<h4>RSS 深度洞察</h4>',
-            f'<div class="ai-content">{content_html}</div>',
-            '</div>'
-        ])
+        html_parts.extend(
+            [
+                '<div class="ai-section">',
+                "<h4>RSS 深度洞察</h4>",
+                f'<div class="ai-content">{content_html}</div>',
+                "</div>",
+            ]
+        )
 
     if result.outlook_strategy:
         content = _format_list_content(result.outlook_strategy)
         content_html = _escape_html(content).replace("\n", "<br>")
-        html_parts.extend([
-            '<div class="ai-section ai-conclusion">',
-            '<h4>研判策略建议</h4>',
-            f'<div class="ai-content">{content_html}</div>',
-            '</div>'
-        ])
-
-    html_parts.append('</div>')
+        html_parts.extend(
+            [
+                '<div class="ai-section ai-conclusion">',
+                "<h4>研判策略建议</h4>",
+                f'<div class="ai-content">{content_html}</div>',
+                "</div>",
+            ]
+        )
+
+    html_parts.append("</div>")
     return "\n".join(html_parts)
 
 
@@ -202,7 +238,9 @@ def render_ai_analysis_plain(result: AIAnalysisResult) -> str:
         lines.extend(["[核心热点态势]", _format_list_content(result.core_trends), ""])
 
     if result.sentiment_controversy:
-        lines.extend(["[舆论风向争议]", _format_list_content(result.sentiment_controversy), ""])
+        lines.extend(
+            ["[舆论风向争议]", _format_list_content(result.sentiment_controversy), ""]
+        )
 
     if result.signals:
         lines.extend(["[异动与弱信号]", _format_list_content(result.signals), ""])
@@ -239,63 +277,63 @@ def render_ai_analysis_html_rich(result: AIAnalysisResult) -> str:
     # 检查是否成功
     if not result.success:
         error_msg = result.error or "未知错误"
-        return f'''
+        return f"""
                 <div class="ai-section">
                     <div class="ai-error">⚠️ AI 分析失败: {_escape_html(str(error_msg))}</div>
-                </div>'''
+                </div>"""
 
-    ai_html = '''
+    ai_html = """
                 <div class="ai-section">
                     <div class="ai-section-header">
                         <div class="ai-section-title">✨ AI 热点分析</div>
                         <span class="ai-section-badge">AI</span>
-                    </div>'''
+                    </div>"""
 
     if result.core_trends:
         content = _format_list_content(result.core_trends)
         content_html = _escape_html(content).replace("\n", "<br>")
-        ai_html += f'''
+        ai_html += f"""
                     <div class="ai-block">
                         <div class="ai-block-title">核心热点态势</div>
                         <div class="ai-block-content">{content_html}</div>
-                    </div>'''
+                    </div>"""
 
     if result.sentiment_controversy:
         content = _format_list_content(result.sentiment_controversy)
         content_html = _escape_html(content).replace("\n", "<br>")
-        ai_html += f'''
+        ai_html += f"""
                     <div class="ai-block">
                         <div class="ai-block-title">舆论风向争议</div>
                         <div class="ai-block-content">{content_html}</div>
-                    </div>'''
+                    </div>"""
 
     if result.signals:
         content = _format_list_content(result.signals)
         content_html = _escape_html(content).replace("\n", "<br>")
-        ai_html += f'''
+        ai_html += f"""
                     <div class="ai-block">
                         <div class="ai-block-title">异动与弱信号</div>
                         <div class="ai-block-content">{content_html}</div>
-                    </div>'''
+                    </div>"""
 
     if result.rss_insights:
         content = _format_list_content(result.rss_insights)
         content_html = _escape_html(content).replace("\n", "<br>")
-        ai_html += f'''
+        ai_html += f"""
                     <div class="ai-block">
                         <div class="ai-block-title">RSS 深度洞察</div>
                         <div class="ai-block-content">{content_html}</div>
-                    </div>'''
+                    </div>"""
 
     if result.outlook_strategy:
         content = _format_list_content(result.outlook_strategy)
         content_html = _escape_html(content).replace("\n", "<br>")
-        ai_html += f'''
+        ai_html += f"""
                     <div class="ai-block">
                         <div class="ai-block-title">研判策略建议</div>
                         <div class="ai-block-content">{content_html}</div>
-                    </div>'''
+                    </div>"""
 
-    ai_html += '''
-                </div>'''
+    ai_html += """
+                </div>"""
     return ai_html

+ 1 - 1
trendradar/core/config.py

@@ -120,7 +120,7 @@ def limit_accounts(
     """
     if len(accounts) > max_count:
         print(f"⚠️ {channel_name} 配置了 {len(accounts)} 个账号,超过最大限制 {max_count},只使用前 {max_count} 个")
-        print(f"   ⚠️ 警告:如果是 fork 用户,过多账号可能导致 GitHub Actions 运行时间过长,存在账号风险")
+        print(f"   ⚠️ 警告:如果是 fork 用户,过多账号可能导致 GitHub Actions 运行时间过长,存在账号风险")
         return accounts[:max_count]
     return accounts
 

+ 4 - 2
trendradar/core/data.py

@@ -239,10 +239,12 @@ def detect_latest_new_titles_from_storage(
                     historical_titles[source_id].add(item.title)
 
         # 检查是否是当天第一次抓取(没有任何历史标题)
-        # 如果所有平台的历史标题集合都为空,说明只有一个抓取批次,不应该有"新增"标题
+        # 如果所有平台的历史标题集合都为空,说明只有一个抓取批次
+        # 在这种情况下,将所有最新批次的标题视为"新增"(用于增量模式的第一次推送)
         has_historical_data = any(len(titles) > 0 for titles in historical_titles.values())
         if not has_historical_data:
-            return {}
+            # 第一次爬取:返回所有最新标题作为"新增"
+            return latest_titles
 
         # 步骤3:找出新增标题 = 最新批次标题 - 历史标题
         new_titles = {}

+ 25 - 21
trendradar/core/frequency.py

@@ -23,47 +23,51 @@ def _parse_word(word: str) -> Dict:
     """
     解析单个词,识别是否为正则表达式,支持显示名称
 
-    语法:
-    - 普通词:word
-    - 正则表达式:/pattern/ 或 /pattern/i(flags 会被忽略,默认已启用忽略大小写)
-    - 带显示名称:word => 显示名称 或 word=>显示名称(=>两边空格可选)
-    - 正则带显示名称:/pattern/ => 显示名称
-
     Args:
-        word: 原始
+        word: 原始配置行 (e.g. "/京东|刘强东/ => 京东")
 
     Returns:
-        {"word": str, "is_regex": bool, "pattern": Optional[re.Pattern], "display_name": Optional[str]}
+        Dict: 包含 word, is_regex, pattern, display_name
     """
     display_name = None
 
-    # 解析 => 显示名称 语法(支持 => 两边有或没有空格)
-    # 使用正则匹配:空格可选的 =>
-    display_match = re.search(r'\s*=>\s*', word)
-    if display_match:
+    # 1. 优先处理显示名称 (=>)
+    # 先切分出 "配置内容" 和 "显示名称"
+    if '=>' in word:
         parts = re.split(r'\s*=>\s*', word, 1)
-        word = parts[0].strip()
-        display_name = parts[1].strip() if len(parts) > 1 and parts[1].strip() else None
+        word_config = parts[0].strip()
+        # 只有当 => 右边有内容时才作为 display_name
+        if len(parts) > 1 and parts[1].strip():
+            display_name = parts[1].strip()
+    else:
+        word_config = word.strip()
+
+    # 2. 解析正则表达式
+    # 规则:以 / 开头,以 / 结尾(可能跟 flags),中间内容贪婪提取
+    # [a-z]*$ 表示允许末尾有 flags (如 i, g),但在下面代码中会被忽略
+    regex_match = re.match(r'^/(.+)/[a-z]*$', word_config)
 
-    # 解析正则表达式:支持 /pattern/ 或 /pattern/flags(如 /pattern/i)
-    # flags 会被忽略,因为默认已启用 IGNORECASE
-    regex_match = re.match(r'^/(.+)/([gimsux]*)$', word)
     if regex_match:
         pattern_str = regex_match.group(1)
-        # flags 参数被忽略,统一使用 IGNORECASE
         try:
             pattern = re.compile(pattern_str, re.IGNORECASE)
+            
             return {
                 "word": pattern_str,
                 "is_regex": True,
                 "pattern": pattern,
                 "display_name": display_name,
             }
-        except re.error:
-            # 正则表达式无效,当作普通词处理
+        except re.error as e:
+            print(f"Warning: Invalid regex pattern '/{pattern_str}/': {e}")
             pass
 
-    return {"word": word, "is_regex": False, "pattern": None, "display_name": display_name}
+    return {
+        "word": word_config, 
+        "is_regex": False, 
+        "pattern": None, 
+        "display_name": display_name
+    }
 
 
 def _word_matches(word_config: Union[str, Dict], title_lower: str) -> bool:

+ 13 - 0
trendradar/core/loader.py

@@ -55,6 +55,7 @@ def _load_app_config(config_data: Dict) -> Dict:
     advanced = config_data.get("advanced", {})
     return {
         "VERSION_CHECK_URL": advanced.get("version_check_url", ""),
+        "CONFIGS_VERSION_CHECK_URL": advanced.get("configs_version_check_url", ""),
         "SHOW_VERSION_UPDATE": app_config.get("show_version_update", True),
         "TIMEZONE": _get_env_str("TIMEZONE") or app_config.get("timezone", "Asia/Shanghai"),
         "DEBUG": _get_env_bool("DEBUG") if _get_env_bool("DEBUG") is not None else advanced.get("debug", False),
@@ -243,16 +244,28 @@ def _load_ai_config(config_data: Dict) -> Dict:
 def _load_ai_analysis_config(config_data: Dict) -> Dict:
     """加载 AI 分析配置(功能配置,模型配置见 _load_ai_config)"""
     ai_config = config_data.get("ai_analysis", {})
+    analysis_window = ai_config.get("analysis_window", {})
 
     enabled_env = _get_env_bool("AI_ANALYSIS_ENABLED")
+    window_enabled_env = _get_env_bool("AI_ANALYSIS_WINDOW_ENABLED")
+    window_once_per_day_env = _get_env_bool("AI_ANALYSIS_WINDOW_ONCE_PER_DAY")
 
     return {
         "ENABLED": enabled_env if enabled_env is not None else ai_config.get("enabled", False),
         "LANGUAGE": ai_config.get("language", "Chinese"),
         "PROMPT_FILE": ai_config.get("prompt_file", "ai_analysis_prompt.txt"),
+        "MODE": ai_config.get("mode", "follow_report"),
         "MAX_NEWS_FOR_ANALYSIS": ai_config.get("max_news_for_analysis", 50),
         "INCLUDE_RSS": ai_config.get("include_rss", True),
         "INCLUDE_RANK_TIMELINE": ai_config.get("include_rank_timeline", False),
+        "ANALYSIS_WINDOW": {
+            "ENABLED": window_enabled_env if window_enabled_env is not None else analysis_window.get("enabled", False),
+            "TIME_RANGE": {
+                "START": _get_env_str("AI_ANALYSIS_WINDOW_START") or analysis_window.get("start", "09:00"),
+                "END": _get_env_str("AI_ANALYSIS_WINDOW_END") or analysis_window.get("end", "22:00"),
+            },
+            "ONCE_PER_DAY": window_once_per_day_env if window_once_per_day_env is not None else analysis_window.get("once_per_day", False),
+        },
     }
 
 

+ 7 - 0
trendradar/notification/senders.py

@@ -135,6 +135,7 @@ def send_to_feishu(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 预留批次头部空间,避免添加头部后超限
@@ -264,6 +265,7 @@ def send_to_dingtalk(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 预留批次头部空间,避免添加头部后超限
@@ -404,6 +406,7 @@ def send_to_wework(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 获取分批内容,预留批次头部空间
@@ -533,6 +536,7 @@ def send_to_telegram(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 获取分批内容,预留批次头部空间
@@ -842,6 +846,7 @@ def send_to_ntfy(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 获取分批内容,预留批次头部空间
@@ -1028,6 +1033,7 @@ def send_to_bark(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 获取分批内容,预留批次头部空间
@@ -1190,6 +1196,7 @@ def send_to_slack(
                 "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
                 "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
                 "rss_count": getattr(ai_analysis, "rss_count", 0),
+                "ai_mode": getattr(ai_analysis, "ai_mode", ""),
             }
 
     # 获取分批内容,预留批次头部空间

+ 23 - 3
trendradar/notification/splitter.py

@@ -109,12 +109,32 @@ def split_content_into_batches(
     ai_stats_line = ""
     if ai_stats and ai_stats.get("analyzed_news", 0) > 0:
         analyzed_news = ai_stats.get("analyzed_news", 0)
+        total_news = ai_stats.get("total_news", 0)
+        ai_mode = ai_stats.get("ai_mode", "")
+
+        # 构建分析数显示:如果被截断则显示 "实际分析数/总可分析数"
+        if total_news > analyzed_news:
+            news_display = f"{analyzed_news}/{total_news}"
+        else:
+            news_display = str(analyzed_news)
+
+        # 如果 AI 模式与推送模式不同,显示模式标识
+        mode_suffix = ""
+        if ai_mode and ai_mode != mode:
+            mode_map = {
+                "daily": "全天汇总",
+                "current": "当前榜单",
+                "incremental": "增量分析"
+            }
+            mode_label = mode_map.get(ai_mode, ai_mode)
+            mode_suffix = f" ({mode_label})"
+
         if format_type in ("wework", "bark", "ntfy", "feishu", "dingtalk"):
-            ai_stats_line = f"**AI 分析数:** {analyzed_news}\n"
+            ai_stats_line = f"**AI 分析数:** {news_display}{mode_suffix}\n"
         elif format_type == "slack":
-            ai_stats_line = f"*AI 分析数:* {analyzed_news}\n"
+            ai_stats_line = f"*AI 分析数:* {news_display}{mode_suffix}\n"
         elif format_type == "telegram":
-            ai_stats_line = f"AI 分析数: {analyzed_news}\n"
+            ai_stats_line = f"AI 分析数: {news_display}{mode_suffix}\n"
 
     # 构建统一的头部(总是显示总新闻数、时间和类型)
     if format_type in ("wework", "bark"):

+ 27 - 0
trendradar/storage/base.py

@@ -464,6 +464,33 @@ class StorageBackend(ABC):
         """
         pass
 
+    @abstractmethod
+    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
+        """
+        检查指定日期是否已进行过 AI 分析
+
+        Args:
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否已分析
+        """
+        pass
+
+    @abstractmethod
+    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
+        """
+        记录 AI 分析
+
+        Args:
+            analysis_mode: 分析模式(daily/current/incremental)
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否记录成功
+        """
+        pass
+
 
 def convert_crawl_results_to_news_data(
     results: Dict[str, Dict],

+ 12 - 0
trendradar/storage/local.py

@@ -190,6 +190,18 @@ class LocalStorageBackend(SQLiteStorageMixin, StorageBackend):
             print(f"[本地存储] 推送记录已保存: {report_type} at {now_str}")
         return success
 
+    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
+        """检查指定日期是否已进行过 AI 分析"""
+        return self._has_ai_analyzed_today_impl(date)
+
+    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
+        """记录 AI 分析"""
+        success = self._record_ai_analysis_impl(analysis_mode, date)
+        if success:
+            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
+            print(f"[本地存储] AI 分析记录已保存: {analysis_mode} at {now_str}")
+        return success
+
     # ========================================
     # RSS 数据存储方法
     # ========================================

+ 25 - 0
trendradar/storage/manager.py

@@ -307,6 +307,31 @@ class StorageManager:
         """
         return self.get_backend().record_push(report_type, date)
 
+    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
+        """
+        检查指定日期是否已进行过 AI 分析
+
+        Args:
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否已分析
+        """
+        return self.get_backend().has_ai_analyzed_today(date)
+
+    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
+        """
+        记录 AI 分析
+
+        Args:
+            analysis_mode: 分析模式(daily/current/incremental)
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否记录成功
+        """
+        return self.get_backend().record_ai_analysis(analysis_mode, date)
+
 
 def get_storage_manager(
     backend_type: str = "auto",

+ 22 - 0
trendradar/storage/remote.py

@@ -415,6 +415,28 @@ class RemoteStorageBackend(SQLiteStorageMixin, StorageBackend):
 
         return False
 
+    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
+        """检查指定日期是否已进行过 AI 分析"""
+        return self._has_ai_analyzed_today_impl(date)
+
+    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
+        """记录 AI 分析"""
+        success = self._record_ai_analysis_impl(analysis_mode, date)
+
+        if success:
+            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
+            print(f"[远程存储] AI 分析记录已保存: {analysis_mode} at {now_str}")
+
+            # 上传到远程存储 确保记录持久化
+            if self._upload_sqlite(date):
+                print(f"[远程存储] AI 分析记录已同步到远程存储")
+                return True
+            else:
+                print(f"[远程存储] AI 分析记录同步到远程存储失败")
+                return False
+
+        return False
+
     # ========================================
     # RSS 数据存储方法
     # ========================================

+ 4 - 0
trendradar/storage/rss_schema.sql

@@ -65,12 +65,16 @@ CREATE TABLE IF NOT EXISTS rss_crawl_status (
 -- ============================================
 -- 推送记录表
 -- 用于 push_window once_per_day 功能
+-- 以及 ai_analysis analysis_window once_per_day 功能
 -- ============================================
 CREATE TABLE IF NOT EXISTS rss_push_records (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     date TEXT NOT NULL UNIQUE,                -- 日期(YYYY-MM-DD)
     pushed INTEGER DEFAULT 0,                 -- 是否已推送
     push_time TEXT,                           -- 推送时间
+    ai_analyzed INTEGER DEFAULT 0,            -- 是否已进行 AI 分析
+    ai_analysis_time TEXT,                    -- AI 分析时间
+    ai_analysis_mode TEXT,                    -- AI 分析模式
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 );
 

+ 4 - 0
trendradar/storage/schema.sql

@@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS crawl_source_status (
 -- ============================================
 -- 推送记录表
 -- 用于 push_window once_per_day 功能
+-- 以及 ai_analysis analysis_window once_per_day 功能
 -- ============================================
 CREATE TABLE IF NOT EXISTS push_records (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -90,6 +91,9 @@ CREATE TABLE IF NOT EXISTS push_records (
     pushed INTEGER DEFAULT 0,
     push_time TEXT,
     report_type TEXT,
+    ai_analyzed INTEGER DEFAULT 0,
+    ai_analysis_time TEXT,
+    ai_analysis_mode TEXT,
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 );
 

+ 63 - 0
trendradar/storage/sqlite_mixin.py

@@ -755,6 +755,69 @@ class SQLiteStorageMixin:
             print(f"[存储] 记录推送失败: {e}")
             return False
 
+    def _has_ai_analyzed_today_impl(self, date: Optional[str] = None) -> bool:
+        """
+        检查指定日期是否已进行过 AI 分析
+
+        Args:
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否已分析
+        """
+        try:
+            conn = self._get_connection(date)
+            cursor = conn.cursor()
+
+            target_date = self._format_date_folder(date)
+
+            cursor.execute("""
+                SELECT ai_analyzed FROM push_records WHERE date = ?
+            """, (target_date,))
+
+            row = cursor.fetchone()
+            if row:
+                return bool(row[0])
+            return False
+
+        except Exception as e:
+            print(f"[存储] 检查 AI 分析记录失败: {e}")
+            return False
+
+    def _record_ai_analysis_impl(self, analysis_mode: str, date: Optional[str] = None) -> bool:
+        """
+        记录 AI 分析
+
+        Args:
+            analysis_mode: 分析模式(daily/current/incremental)
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否记录成功
+        """
+        try:
+            conn = self._get_connection(date)
+            cursor = conn.cursor()
+
+            target_date = self._format_date_folder(date)
+            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
+
+            cursor.execute("""
+                INSERT INTO push_records (date, ai_analyzed, ai_analysis_time, ai_analysis_mode, created_at)
+                VALUES (?, 1, ?, ?, ?)
+                ON CONFLICT(date) DO UPDATE SET
+                    ai_analyzed = 1,
+                    ai_analysis_time = excluded.ai_analysis_time,
+                    ai_analysis_mode = excluded.ai_analysis_mode
+            """, (target_date, now_str, analysis_mode, now_str))
+
+            conn.commit()
+            return True
+
+        except Exception as e:
+            print(f"[存储] 记录 AI 分析失败: {e}")
+            return False
+
     # ========================================
     # RSS 数据存储
     # ========================================

+ 1 - 1
version

@@ -1 +1 @@
-5.3.0
+5.4.0