Parcourir la source

v4.6.0: 新增 display_mode 配置,支持按平台分组显示。祝大家新年快乐~

sansan il y a 4 mois
Parent
commit
b9a9f809cc

+ 40 - 1
README-EN.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-v4.5.0-blue.svg)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v4.6.0-blue.svg)](https://github.com/sansan0/TrendRadar)
 [![MCP](https://img.shields.io/badge/MCP-v2.0.0-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)
 
@@ -135,6 +135,12 @@ 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/01 - v4.6.0
+
+- **Fix RSS HTML Display**: Merged RSS content into trending HTML page, grouped by source
+- **New display_mode Config**: Support `keyword` (group by keyword) and `platform` (group by platform) display modes
+
+
 ### 2025/12/30 - v4.5.0
 
 - **RSS Feed Support**: Added RSS/Atom feed crawling, keyword-based grouping and statistics (consistent with trending format)
@@ -726,6 +732,7 @@ rss:
 |---------|-------------|---------|
 | **Push Time Window Control** | Set push time range (e.g., 09:00-18:00) to avoid non-work hours notifications | Disabled |
 | **Content Order Configuration** | Adjust display order of "Trending Keywords Stats" and "New Trending News" (v3.5.0 new) | Stats first |
+| **Display Mode Switch** | `keyword`=group by keyword, `platform`=group by platform (v4.6.0 new) | keyword |
 
 > 💡 For detailed configuration, see [Configuration Guide - Report Configuration](#7-report-configuration) and [Configuration Guide - Push Window](#8-push-window-configuration)
 
@@ -2559,6 +2566,7 @@ After MCP service starts, configure based on your client:
 ```yaml
 report:
   mode: "daily"                    # Push mode
+  display_mode: "keyword"          # Display mode (v4.6.0 new)
   rank_threshold: 5                # Ranking highlight threshold
   sort_by_position_first: false    # Sorting priority
   max_news_per_keyword: 0          # Maximum display count per keyword
@@ -2570,11 +2578,42 @@ report:
 | Config Item | Type | Default | Description |
 |------------|------|---------|-------------|
 | `mode` | string | `daily` | Push mode, options: `daily`/`incremental`/`current`, see [Push Mode Details](#3-push-mode-details) |
+| `display_mode` | string | `keyword` | Display mode, options: `keyword`/`platform`, see below |
 | `rank_threshold` | int | `5` | Ranking highlight threshold, news with rank ≤ this value will be displayed in bold |
 | `sort_by_position_first` | bool | `false` | Sorting priority: `false`=sort by news count, `true`=sort by config position |
 | `max_news_per_keyword` | int | `0` | Maximum display count per keyword, `0`=unlimited |
 | `reverse_content_order` | bool | `false` | Content order: `false`=trending keywords stats first, `true`=new trending news first |
 
+#### Display Mode Configuration (v4.6.0 New)
+
+Controls how news is grouped in push messages and HTML reports:
+
+| Mode | Grouping | Title Prefix | Use Case |
+|------|----------|--------------|----------|
+| `keyword` (default) | Group by keyword | `[Platform]` | Users focusing on specific topics |
+| `platform` | Group by platform | `[Keyword]` | Users focusing on specific platforms |
+
+**Example Comparison:**
+
+```
+# keyword mode (group by keyword)
+📊 Trending Keywords Stats
+🔥 [1/3] AI : 12 items
+  1. [Weibo] OpenAI releases GPT-5 #1-#3 - 08:30 (5 times)
+  2. [Zhihu] How to view AI replacing programmers #2 - 09:15 (3 times)
+
+# platform mode (group by platform)
+📊 Trending News Stats
+🔥 [1/4] Weibo : 12 items
+  1. [AI] OpenAI releases GPT-5 #1-#3 - 08:30 (5 times)
+  2. [Trump] Trump announces major policy #2 - 09:15 (3 times)
+```
+
+**Docker Environment Variable:**
+```bash
+DISPLAY_MODE=platform
+```
+
 #### Content Order Configuration (v3.5.0 New)
 
 Controls display order of two content sections in push messages and HTML reports:

+ 39 - 1
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-v4.5.0-blue.svg)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v4.6.0-blue.svg)](https://github.com/sansan0/TrendRadar)
 [![MCP](https://img.shields.io/badge/MCP-v2.0.0-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)
 
@@ -184,6 +184,11 @@
 > **📌 查看最新更新**:**[原仓库更新日志](https://github.com/sansan0/TrendRadar?tab=readme-ov-file#-更新日志)** :
 - **提示**:建议查看【历史更新】,明确具体的【功能内容】
 
+### 2026/01/01 - v4.6.0
+
+- **修复 RSS HTML 显示**:将 RSS 内容合并到热榜 HTML 页面,按源分组显示
+- **新增 display_mode 配置**:支持 `keyword`(按关键词分组)和 `platform`(按平台分组)两种显示模式
+
 
 ### 2025/12/30 - v4.5.0
 
@@ -764,6 +769,7 @@ rss:
 |------|------|------|
 | **推送时间窗口控制** | 设定推送时间范围(如 09:00-18:00),避免非工作时间打扰 | 关闭 |
 | **内容顺序配置** | 调整"热点词汇统计"和"新增热点新闻"的显示顺序(v3.5.0 新增) | 统计在前 |
+| **显示模式切换** | `keyword`=按关键词分组,`platform`=按平台分组(v4.6.0 新增) | keyword |
 
 > 💡 详细配置教程见 [配置详解 - 报告配置](#7-报告配置) 和 [配置详解 - 推送时间窗口](#8-推送时间窗口配置)
 
@@ -2597,6 +2603,7 @@ MCP 服务启动后,根据不同客户端进行配置:
 ```yaml
 report:
   mode: "daily"                    # 推送模式
+  display_mode: "keyword"          # 显示模式(v4.6.0 新增)
   rank_threshold: 5                # 排名高亮阈值
   sort_by_position_first: false    # 排序优先级
   max_news_per_keyword: 0          # 每个关键词最大显示数量
@@ -2608,11 +2615,42 @@ report:
 | 配置项 | 类型 | 默认值 | 说明 |
 |-------|------|-------|------|
 | `mode` | string | `daily` | 推送模式,可选 `daily`/`incremental`/`current`,详见 [推送模式详解](#3-推送模式详解) |
+| `display_mode` | string | `keyword` | 显示模式,可选 `keyword`/`platform`,详见下方说明 |
 | `rank_threshold` | int | `5` | 排名高亮阈值,排名 ≤ 该值的新闻会加粗显示 |
 | `sort_by_position_first` | bool | `false` | 排序优先级:`false`=按热点条数排序,`true`=按配置位置排序 |
 | `max_news_per_keyword` | int | `0` | 每个关键词最大显示数量,`0`=不限制 |
 | `reverse_content_order` | bool | `false` | 内容顺序:`false`=热点词汇统计在前,`true`=新增热点新闻在前 |
 
+#### 显示模式配置(v4.6.0 新增)
+
+控制推送消息和 HTML 报告中新闻的分组方式:
+
+| 模式 | 分组方式 | 标题前缀 | 适用场景 |
+|------|---------|---------|---------|
+| `keyword`(默认) | 按关键词分组 | `[平台名]` | 关注特定话题的用户 |
+| `platform` | 按平台分组 | `[关键词]` | 关注特定平台的用户 |
+
+**示例对比:**
+
+```
+# keyword 模式(按关键词分组)
+📊 热点词汇统计
+🔥 [1/3] AI : 12 条
+  1. [微博] OpenAI发布GPT-5 #1-#3 - 08:30 (5次)
+  2. [知乎] 如何看待AI取代程序员 #2 - 09:15 (3次)
+
+# platform 模式(按平台分组)
+📊 热点新闻统计
+🔥 [1/4] 微博 : 12 条
+  1. [AI] OpenAI发布GPT-5 #1-#3 - 08:30 (5次)
+  2. [特朗普] 特朗普宣布重大政策 #2 - 09:15 (3次)
+```
+
+**Docker 环境变量:**
+```bash
+DISPLAY_MODE=platform
+```
+
 #### 内容顺序配置(v3.5.0 新增)
 
 控制推送消息和 HTML 报告中两部分内容的显示顺序:

+ 3 - 0
config/config.yaml

@@ -118,6 +118,9 @@ rss:
 # ===============================================================
 report:
   mode: "current"                     # 可选: daily | current | incremental
+  display_mode: "keyword"             # 可选: keyword | platform
+                                      # keyword: 按关键词分组显示(默认)
+                                      # platform: 按平台/来源分组显示
   rank_threshold: 5                   # 排名高亮阈值
   sort_by_position_first: false       # true=按配置位置排序,false=按热点条数排序
   max_news_per_keyword: 0             # 每个关键词最大显示数量(0=不限制)

+ 1 - 1
pyproject.toml

@@ -1,6 +1,6 @@
 [project]
 name = "trendradar"
-version = "4.5.0"
+version = "4.6.0"
 description = "TrendRadar - 热点新闻聚合与分析工具"
 requires-python = ">=3.10"
 dependencies = [

+ 1 - 1
trendradar/__init__.py

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

+ 30 - 2
trendradar/__main__.py

@@ -16,6 +16,7 @@ import requests
 from trendradar.context import AppContext
 from trendradar import __version__
 from trendradar.core import load_config
+from trendradar.core.analyzer import convert_keyword_stats_to_platform_stats
 from trendradar.crawler import DataFetcher
 from trendradar.storage import convert_crawl_results_to_news_data
 from trendradar.utils.time import is_within_days
@@ -305,6 +306,8 @@ class NewsAnalyzer:
         is_daily_summary: bool = False,
         global_filters: Optional[List[str]] = None,
         quiet: bool = False,
+        rss_items: Optional[List[Dict]] = None,
+        rss_new_items: Optional[List[Dict]] = None,
     ) -> Tuple[List[Dict], Optional[str]]:
         """统一的分析流水线:数据处理 → 统计计算 → HTML生成"""
 
@@ -321,6 +324,14 @@ class NewsAnalyzer:
             quiet=quiet,
         )
 
+        # 如果是 platform 模式,转换数据结构
+        if self.ctx.display_mode == "platform" and stats:
+            stats = convert_keyword_stats_to_platform_stats(
+                stats,
+                self.ctx.weight_config,
+                self.ctx.rank_threshold,
+            )
+
         # HTML生成(如果启用)
         html_file = None
         if self.ctx.config["STORAGE"]["FORMATS"]["HTML"]:
@@ -333,6 +344,8 @@ class NewsAnalyzer:
                 mode=mode,
                 is_daily_summary=is_daily_summary,
                 update_info=self.update_info if self.ctx.config["SHOW_VERSION_UPDATE"] else None,
+                rss_items=rss_items,
+                rss_new_items=rss_new_items,
             )
 
         return stats, html_file
@@ -492,6 +505,8 @@ class NewsAnalyzer:
             id_to_name,
             is_daily_summary=True,
             global_filters=global_filters,
+            rss_items=rss_items,
+            rss_new_items=rss_new_items,
         )
 
         if html_file:
@@ -512,7 +527,12 @@ class NewsAnalyzer:
 
         return html_file
 
-    def _generate_summary_html(self, mode: str = "daily") -> Optional[str]:
+    def _generate_summary_html(
+        self,
+        mode: str = "daily",
+        rss_items: Optional[List[Dict]] = None,
+        rss_new_items: Optional[List[Dict]] = None,
+    ) -> Optional[str]:
         """生成汇总HTML"""
         summary_type = "当前榜单汇总" if mode == "current" else "当日汇总"
         print(f"生成{summary_type}HTML...")
@@ -538,6 +558,8 @@ class NewsAnalyzer:
             is_daily_summary=True,
             global_filters=global_filters,
             quiet=True,
+            rss_items=rss_items,
+            rss_new_items=rss_new_items,
         )
 
         if html_file:
@@ -1009,6 +1031,8 @@ class NewsAnalyzer:
                     historical_id_to_name,
                     failed_ids=failed_ids,
                     global_filters=global_filters,
+                    rss_items=rss_items,
+                    rss_new_items=rss_new_items,
                 )
 
                 combined_id_to_name = {**historical_id_to_name, **id_to_name}
@@ -1045,6 +1069,8 @@ class NewsAnalyzer:
                 id_to_name,
                 failed_ids=failed_ids,
                 global_filters=global_filters,
+                rss_items=rss_items,
+                rss_new_items=rss_new_items,
             )
             if html_file:
                 print(f"HTML报告已生成: {html_file}")
@@ -1070,7 +1096,9 @@ class NewsAnalyzer:
             if mode_strategy["should_send_realtime"]:
                 # 如果已经发送了实时通知,汇总只生成HTML不发送通知
                 summary_html = self._generate_summary_html(
-                    mode_strategy["summary_mode"]
+                    mode_strategy["summary_mode"],
+                    rss_items=rss_items,
+                    rss_new_items=rss_new_items,
                 )
             else:
                 # daily模式:直接生成汇总报告并发送通知(合并RSS)

+ 14 - 1
trendradar/context.py

@@ -115,6 +115,11 @@ class AppContext:
         """获取 RSS 源列表"""
         return self.rss_config.get("FEEDS", [])
 
+    @property
+    def display_mode(self) -> str:
+        """获取显示模式 (keyword | platform)"""
+        return self.config.get("DISPLAY_MODE", "keyword")
+
     # === 时间操作 ===
 
     def get_time(self) -> datetime:
@@ -280,6 +285,8 @@ class AppContext:
         mode: str = "daily",
         is_daily_summary: bool = False,
         update_info: Optional[Dict] = None,
+        rss_items: Optional[List[Dict]] = None,
+        rss_new_items: Optional[List[Dict]] = None,
     ) -> str:
         """生成HTML报告"""
         return generate_html_report(
@@ -295,7 +302,7 @@ class AppContext:
             output_dir="output",
             date_folder=self.format_date(),
             time_filename=self.format_time(),
-            render_html_func=lambda *args, **kwargs: self.render_html(*args, **kwargs),
+            render_html_func=lambda *args, **kwargs: self.render_html(*args, rss_items=rss_items, rss_new_items=rss_new_items, **kwargs),
             matches_word_groups_func=self.matches_word_groups,
             load_frequency_words_func=self.load_frequency_words,
             enable_index_copy=True,
@@ -308,6 +315,8 @@ class AppContext:
         is_daily_summary: bool = False,
         mode: str = "daily",
         update_info: Optional[Dict] = None,
+        rss_items: Optional[List[Dict]] = None,
+        rss_new_items: Optional[List[Dict]] = None,
     ) -> str:
         """渲染HTML内容"""
         return render_html_content(
@@ -318,6 +327,9 @@ class AppContext:
             update_info=update_info,
             reverse_content_order=self.config.get("REVERSE_CONTENT_ORDER", False),
             get_time_func=self.get_time,
+            rss_items=rss_items,
+            rss_new_items=rss_new_items,
+            display_mode=self.display_mode,
         )
 
     # === 通知内容渲染 ===
@@ -394,6 +406,7 @@ class AppContext:
             rss_items=rss_items,
             rss_new_items=rss_new_items,
             timezone=self.config.get("TIMEZONE", "Asia/Shanghai"),
+            display_mode=self.display_mode,
         )
 
     # === 通知发送 ===

+ 70 - 0
trendradar/core/analyzer.py

@@ -688,3 +688,73 @@ def count_rss_frequency(
         print(f"[RSS] 关键词分组统计:{matched_count}/{total_items} 条匹配")
 
     return stats, total_items
+
+
+def convert_keyword_stats_to_platform_stats(
+    keyword_stats: List[Dict],
+    weight_config: Dict,
+    rank_threshold: int = 5,
+) -> List[Dict]:
+    """
+    将按关键词分组的统计数据转换为按平台分组的统计数据
+
+    Args:
+        keyword_stats: 原始按关键词分组的统计数据
+        weight_config: 权重配置
+        rank_threshold: 排名阈值
+
+    Returns:
+        按平台分组的统计数据,格式与原 stats 一致
+    """
+    # 1. 收集所有新闻,按平台分组
+    platform_map: Dict[str, List[Dict]] = {}
+
+    for stat in keyword_stats:
+        keyword = stat["word"]
+        for title_data in stat["titles"]:
+            source_name = title_data["source_name"]
+
+            if source_name not in platform_map:
+                platform_map[source_name] = []
+
+            # 复制 title_data 并添加匹配的关键词
+            title_with_keyword = title_data.copy()
+            title_with_keyword["matched_keyword"] = keyword
+            platform_map[source_name].append(title_with_keyword)
+
+    # 2. 去重(同一平台下相同标题只保留一条,保留第一个匹配的关键词)
+    for source_name, titles in platform_map.items():
+        seen_titles: Dict[str, bool] = {}
+        unique_titles = []
+        for title_data in titles:
+            title_text = title_data["title"]
+            if title_text not in seen_titles:
+                seen_titles[title_text] = True
+                unique_titles.append(title_data)
+        platform_map[source_name] = unique_titles
+
+    # 3. 按权重排序每个平台内的新闻
+    for source_name, titles in platform_map.items():
+        platform_map[source_name] = sorted(
+            titles,
+            key=lambda x: (
+                -calculate_news_weight(x, rank_threshold, weight_config),
+                min(x["ranks"]) if x["ranks"] else 999,
+                -x["count"],
+            ),
+        )
+
+    # 4. 构建平台统计结果
+    platform_stats = []
+    for source_name, titles in platform_map.items():
+        platform_stats.append({
+            "word": source_name,  # 平台名作为分组标识
+            "count": len(titles),
+            "titles": titles,
+            "percentage": 0,  # 可后续计算
+        })
+
+    # 5. 按新闻条数排序平台
+    platform_stats.sort(key=lambda x: -x["count"])
+
+    return platform_stats

+ 2 - 0
trendradar/core/loader.py

@@ -70,9 +70,11 @@ def _load_report_config(config_data: Dict) -> Dict:
     sort_by_position_env = _get_env_bool("SORT_BY_POSITION_FIRST")
     reverse_content_env = _get_env_bool("REVERSE_CONTENT_ORDER")
     max_news_env = _get_env_int("MAX_NEWS_PER_KEYWORD")
+    display_mode_env = _get_env_str("DISPLAY_MODE")
 
     return {
         "REPORT_MODE": _get_env_str("REPORT_MODE") or report_config.get("mode", "daily"),
+        "DISPLAY_MODE": display_mode_env or report_config.get("display_mode", "keyword"),
         "RANK_THRESHOLD": report_config.get("rank_threshold", 10),
         "SORT_BY_POSITION_FIRST": sort_by_position_env if sort_by_position_env is not None else report_config.get("sort_by_position_first", False),
         "MAX_NEWS_PER_KEYWORD": max_news_env or report_config.get("max_news_per_keyword", 0),

+ 25 - 18
trendradar/notification/splitter.py

@@ -34,6 +34,7 @@ def split_content_into_batches(
     rss_items: Optional[list] = None,
     rss_new_items: Optional[list] = None,
     timezone: str = "Asia/Shanghai",
+    display_mode: str = "keyword",
 ) -> List[str]:
     """分批处理消息内容,确保词组标题+至少第一条新闻的完整性(支持热榜+RSS合并)
 
@@ -53,6 +54,7 @@ def split_content_into_batches(
         rss_items: RSS 统计条目列表(按源分组,用于合并推送)
         rss_new_items: RSS 新增条目列表(可选,用于新增区块)
         timezone: 时区名称(用于 RSS 时间格式化)
+        display_mode: 显示模式 (keyword=按关键词分组, platform=按平台分组)
 
     Returns:
         分批后的消息内容列表
@@ -120,20 +122,22 @@ def split_content_into_batches(
         if update_info:
             base_footer += f"\n_TrendRadar 发现新版本 *{update_info['remote_version']}*,当前 *{update_info['current_version']}_"
 
+    # 根据 display_mode 选择统计标题
+    stats_title = "热点词汇统计" if display_mode == "keyword" else "热点新闻统计"
     stats_header = ""
     if report_data["stats"]:
         if format_type in ("wework", "bark"):
-            stats_header = f"📊 **热点词汇统计**\n\n"
+            stats_header = f"📊 **{stats_title}**\n\n"
         elif format_type == "telegram":
-            stats_header = f"📊 热点词汇统计\n\n"
+            stats_header = f"📊 {stats_title}\n\n"
         elif format_type == "ntfy":
-            stats_header = f"📊 **热点词汇统计**\n\n"
+            stats_header = f"📊 **{stats_title}**\n\n"
         elif format_type == "feishu":
-            stats_header = f"📊 **热点词汇统计**\n\n"
+            stats_header = f"📊 **{stats_title}**\n\n"
         elif format_type == "dingtalk":
-            stats_header = f"📊 **热点词汇统计**\n\n"
+            stats_header = f"📊 **{stats_title}**\n\n"
         elif format_type == "slack":
-            stats_header = f"📊 *热点词汇统计*\n\n"
+            stats_header = f"📊 *{stats_title}*\n\n"
 
     current_batch = base_header
     current_batch_has_content = False
@@ -244,32 +248,35 @@ def split_content_into_batches(
                     word_header = f"📌 {sequence_display} *{word}* : {count} 条\n\n"
 
             # 构建第一条新闻
+            # display_mode: keyword=显示来源, platform=显示关键词
+            show_source = display_mode == "keyword"
+            show_keyword = display_mode == "platform"
             first_news_line = ""
             if stat["titles"]:
                 first_title_data = stat["titles"][0]
                 if format_type in ("wework", "bark"):
                     formatted_title = format_title_for_platform(
-                        "wework", first_title_data, show_source=True
+                        "wework", first_title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "telegram":
                     formatted_title = format_title_for_platform(
-                        "telegram", first_title_data, show_source=True
+                        "telegram", first_title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "ntfy":
                     formatted_title = format_title_for_platform(
-                        "ntfy", first_title_data, show_source=True
+                        "ntfy", first_title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "feishu":
                     formatted_title = format_title_for_platform(
-                        "feishu", first_title_data, show_source=True
+                        "feishu", first_title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "dingtalk":
                     formatted_title = format_title_for_platform(
-                        "dingtalk", first_title_data, show_source=True
+                        "dingtalk", first_title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "slack":
                     formatted_title = format_title_for_platform(
-                        "slack", first_title_data, show_source=True
+                        "slack", first_title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 else:
                     formatted_title = f"{first_title_data['title']}"
@@ -302,27 +309,27 @@ def split_content_into_batches(
                 title_data = stat["titles"][j]
                 if format_type in ("wework", "bark"):
                     formatted_title = format_title_for_platform(
-                        "wework", title_data, show_source=True
+                        "wework", title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "telegram":
                     formatted_title = format_title_for_platform(
-                        "telegram", title_data, show_source=True
+                        "telegram", title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "ntfy":
                     formatted_title = format_title_for_platform(
-                        "ntfy", title_data, show_source=True
+                        "ntfy", title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "feishu":
                     formatted_title = format_title_for_platform(
-                        "feishu", title_data, show_source=True
+                        "feishu", title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "dingtalk":
                     formatted_title = format_title_for_platform(
-                        "dingtalk", title_data, show_source=True
+                        "dingtalk", title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 elif format_type == "slack":
                     formatted_title = format_title_for_platform(
-                        "slack", title_data, show_source=True
+                        "slack", title_data, show_source=show_source, show_keyword=show_keyword
                     )
                 else:
                     formatted_title = f"{title_data['title']}"

+ 30 - 6
trendradar/report/formatter.py

@@ -11,7 +11,7 @@ from trendradar.report.helpers import clean_title, html_escape, format_rank_disp
 
 
 def format_title_for_platform(
-    platform: str, title_data: Dict, show_source: bool = True
+    platform: str, title_data: Dict, show_source: bool = True, show_keyword: bool = False
 ) -> str:
     """统一的标题格式化方法
 
@@ -37,7 +37,9 @@ def format_title_for_platform(
             - url: PC端链接
             - mobile_url: 移动端链接(优先使用)
             - is_new: 是否为新增标题(可选)
-        show_source: 是否显示来源名称
+            - matched_keyword: 匹配的关键词(可选,platform 模式使用)
+        show_source: 是否显示来源名称(keyword 模式使用)
+        show_keyword: 是否显示关键词标签(platform 模式使用)
 
     Returns:
         格式化后的标题字符串
@@ -49,6 +51,9 @@ def format_title_for_platform(
     link_url = title_data["mobile_url"] or title_data["url"]
     cleaned_title = clean_title(title_data["title"])
 
+    # 获取关键词标签(platform 模式使用)
+    keyword = title_data.get("matched_keyword", "") if show_keyword else ""
+
     if platform == "feishu":
         if link_url:
             formatted_title = f"[{cleaned_title}]({link_url})"
@@ -59,6 +64,8 @@ def format_title_for_platform(
 
         if show_source:
             result = f"<font color='grey'>[{title_data['source_name']}]</font> {title_prefix}{formatted_title}"
+        elif show_keyword and keyword:
+            result = f"<font color='blue'>[{keyword}]</font> {title_prefix}{formatted_title}"
         else:
             result = f"{title_prefix}{formatted_title}"
 
@@ -81,6 +88,8 @@ def format_title_for_platform(
 
         if show_source:
             result = f"[{title_data['source_name']}] {title_prefix}{formatted_title}"
+        elif show_keyword and keyword:
+            result = f"[{keyword}] {title_prefix}{formatted_title}"
         else:
             result = f"{title_prefix}{formatted_title}"
 
@@ -104,6 +113,8 @@ def format_title_for_platform(
 
         if show_source:
             result = f"[{title_data['source_name']}] {title_prefix}{formatted_title}"
+        elif show_keyword and keyword:
+            result = f"[{keyword}] {title_prefix}{formatted_title}"
         else:
             result = f"{title_prefix}{formatted_title}"
 
@@ -126,6 +137,8 @@ def format_title_for_platform(
 
         if show_source:
             result = f"[{title_data['source_name']}] {title_prefix}{formatted_title}"
+        elif show_keyword and keyword:
+            result = f"<b>[{html_escape(keyword)}]</b> {title_prefix}{formatted_title}"
         else:
             result = f"{title_prefix}{formatted_title}"
 
@@ -148,6 +161,8 @@ def format_title_for_platform(
 
         if show_source:
             result = f"[{title_data['source_name']}] {title_prefix}{formatted_title}"
+        elif show_keyword and keyword:
+            result = f"[{keyword}] {title_prefix}{formatted_title}"
         else:
             result = f"{title_prefix}{formatted_title}"
 
@@ -172,6 +187,8 @@ def format_title_for_platform(
 
         if show_source:
             result = f"[{title_data['source_name']}] {title_prefix}{formatted_title}"
+        elif show_keyword and keyword:
+            result = f"*[{keyword}]* {title_prefix}{formatted_title}"
         else:
             result = f"{title_prefix}{formatted_title}"
 
@@ -198,13 +215,20 @@ def format_title_for_platform(
         escaped_title = html_escape(cleaned_title)
         escaped_source_name = html_escape(title_data["source_name"])
 
+        # 构建前缀(来源或关键词)
+        if show_source:
+            prefix = f'<span class="source-tag">[{escaped_source_name}]</span> '
+        elif show_keyword and keyword:
+            escaped_keyword = html_escape(keyword)
+            prefix = f'<span class="keyword-tag">[{escaped_keyword}]</span> '
+        else:
+            prefix = ""
+
         if link_url:
             escaped_url = html_escape(link_url)
-            formatted_title = f'[{escaped_source_name}] <a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
+            formatted_title = f'{prefix}<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
         else:
-            formatted_title = (
-                f'[{escaped_source_name}] <span class="no-link">{escaped_title}</span>'
-            )
+            formatted_title = f'{prefix}<span class="no-link">{escaped_title}</span>'
 
         if rank_display:
             formatted_title += f" {rank_display}"

+ 235 - 8
trendradar/report/html.py

@@ -6,7 +6,7 @@ HTML 报告渲染模块
 """
 
 from datetime import datetime
-from typing import Dict, Optional, Callable
+from typing import Dict, List, Optional, Callable
 
 from trendradar.report.helpers import html_escape
 
@@ -20,6 +20,9 @@ def render_html_content(
     *,
     reverse_content_order: bool = False,
     get_time_func: Optional[Callable[[], datetime]] = None,
+    rss_items: Optional[List[Dict]] = None,
+    rss_new_items: Optional[List[Dict]] = None,
+    display_mode: str = "keyword",
 ) -> str:
     """渲染HTML内容
 
@@ -31,6 +34,9 @@ def render_html_content(
         update_info: 更新信息(可选)
         reverse_content_order: 是否反转内容顺序(新增热点在前)
         get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now)
+        rss_items: RSS 统计条目列表(可选)
+        rss_new_items: RSS 新增条目列表(可选)
+        display_mode: 显示模式 ("keyword"=按关键词分组, "platform"=按平台分组)
 
     Returns:
         渲染后的 HTML 字符串
@@ -255,6 +261,15 @@ def render_html_content(
                 font-weight: 500;
             }
 
+            .keyword-tag {
+                color: #2563eb;
+                font-size: 12px;
+                font-weight: 500;
+                background: #eff6ff;
+                padding: 2px 6px;
+                border-radius: 4px;
+            }
+
             .rank-num {
                 color: #fff;
                 background: #6b7280;
@@ -464,6 +479,119 @@ def render_html_content(
                     width: 100%;
                 }
             }
+
+            /* RSS 订阅内容样式 */
+            .rss-section {
+                margin-top: 32px;
+                padding-top: 24px;
+                border-top: 2px solid #e5e7eb;
+            }
+
+            .rss-section-header {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                margin-bottom: 20px;
+            }
+
+            .rss-section-title {
+                font-size: 18px;
+                font-weight: 600;
+                color: #059669;
+            }
+
+            .rss-section-count {
+                color: #6b7280;
+                font-size: 14px;
+            }
+
+            .feed-group {
+                margin-bottom: 24px;
+            }
+
+            .feed-group:last-child {
+                margin-bottom: 0;
+            }
+
+            .feed-header {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                margin-bottom: 12px;
+                padding-bottom: 8px;
+                border-bottom: 2px solid #10b981;
+            }
+
+            .feed-name {
+                font-size: 15px;
+                font-weight: 600;
+                color: #059669;
+            }
+
+            .feed-count {
+                color: #666;
+                font-size: 13px;
+                font-weight: 500;
+            }
+
+            .rss-item {
+                margin-bottom: 12px;
+                padding: 14px;
+                background: #f0fdf4;
+                border-radius: 8px;
+                border-left: 3px solid #10b981;
+            }
+
+            .rss-item:last-child {
+                margin-bottom: 0;
+            }
+
+            .rss-meta {
+                display: flex;
+                align-items: center;
+                gap: 12px;
+                margin-bottom: 6px;
+                flex-wrap: wrap;
+            }
+
+            .rss-time {
+                color: #6b7280;
+                font-size: 12px;
+            }
+
+            .rss-author {
+                color: #059669;
+                font-size: 12px;
+                font-weight: 500;
+            }
+
+            .rss-title {
+                font-size: 14px;
+                line-height: 1.5;
+                margin-bottom: 6px;
+            }
+
+            .rss-link {
+                color: #1f2937;
+                text-decoration: none;
+                font-weight: 500;
+            }
+
+            .rss-link:hover {
+                color: #059669;
+                text-decoration: underline;
+            }
+
+            .rss-summary {
+                font-size: 13px;
+                color: #6b7280;
+                line-height: 1.5;
+                margin: 0;
+                display: -webkit-box;
+                -webkit-line-clamp: 2;
+                -webkit-box-orient: vertical;
+                overflow: hidden;
+            }
         </style>
     </head>
     <body>
@@ -578,8 +706,17 @@ def render_html_content(
                     <div class="news-item {new_class}">
                         <div class="news-number">{j}</div>
                         <div class="news-content">
-                            <div class="news-header">
-                                <span class="source-name">{html_escape(title_data["source_name"])}</span>"""
+                            <div class="news-header">"""
+
+                # 根据 display_mode 决定显示来源还是关键词
+                if display_mode == "keyword":
+                    # keyword 模式:显示来源
+                    stats_html += f'<span class="source-name">{html_escape(title_data["source_name"])}</span>'
+                else:
+                    # platform 模式:显示关键词
+                    matched_keyword = title_data.get("matched_keyword", "")
+                    if matched_keyword:
+                        stats_html += f'<span class="keyword-tag">[{html_escape(matched_keyword)}]</span>'
 
                 # 处理排名显示
                 ranks = title_data.get("ranks", [])
@@ -706,13 +843,103 @@ def render_html_content(
         new_titles_html += """
                 </div>"""
 
-    # 根据配置决定内容顺序
+    # 生成 RSS 统计内容
+    def render_rss_stats_html(items: List[Dict], title: str = "RSS 订阅更新") -> str:
+        if not items:
+            return ""
+
+        rss_html = ""
+        rss_count = len(items)
+        rss_html += f"""
+                <div class="rss-section">
+                    <div class="rss-section-header">
+                        <div class="rss-section-title">{title}</div>
+                        <div class="rss-section-count">{rss_count} 条</div>
+                    </div>"""
+
+        # 按 feed_id 分组
+        feeds_grouped = {}
+        for item in items:
+            feed_id = item.get("feed_id", "unknown")
+            if feed_id not in feeds_grouped:
+                feeds_grouped[feed_id] = {
+                    "name": item.get("feed_name", feed_id),
+                    "items": []
+                }
+            feeds_grouped[feed_id]["items"].append(item)
+
+        # 渲染每个 feed 分组
+        for feed_id, feed_data in feeds_grouped.items():
+            feed_name = feed_data["name"]
+            feed_items = feed_data["items"]
+            feed_item_count = len(feed_items)
+
+            rss_html += f"""
+                    <div class="feed-group">
+                        <div class="feed-header">
+                            <div class="feed-name">{html_escape(feed_name)}</div>
+                            <div class="feed-count">{feed_item_count} 条</div>
+                        </div>"""
+
+            for item in feed_items:
+                item_title = item.get("title", "")
+                url = item.get("url", "")
+                published_at = item.get("published_at", "")
+                author = item.get("author", "")
+
+                # 格式化发布时间
+                time_str = ""
+                if published_at:
+                    if isinstance(published_at, datetime):
+                        time_str = published_at.strftime("%m-%d %H:%M")
+                    else:
+                        time_str = str(published_at)[:16] if len(str(published_at)) > 16 else str(published_at)
+
+                rss_html += """
+                        <div class="rss-item">
+                            <div class="rss-meta">"""
+
+                if time_str:
+                    rss_html += f'<span class="rss-time">{html_escape(time_str)}</span>'
+
+                if author:
+                    rss_html += f'<span class="rss-author">by {html_escape(author)}</span>'
+
+                rss_html += """
+                            </div>
+                            <div class="rss-title">"""
+
+                escaped_title = html_escape(item_title)
+                if url:
+                    escaped_url = html_escape(url)
+                    rss_html += f'<a href="{escaped_url}" target="_blank" class="rss-link">{escaped_title}</a>'
+                else:
+                    rss_html += escaped_title
+
+                rss_html += """
+                            </div>
+                        </div>"""
+
+            rss_html += """
+                    </div>"""
+
+        rss_html += """
+                </div>"""
+        return rss_html
+
+    # 生成 RSS 统计和新增 HTML
+    rss_stats_html = render_rss_stats_html(rss_items, "RSS 订阅更新") if rss_items else ""
+    rss_new_html = render_rss_stats_html(rss_new_items, "RSS 新增更新") if rss_new_items else ""
+
+    # 根据配置决定内容顺序(与推送逻辑一致)
     if reverse_content_order:
-        # 新增热点在前,热点词汇统计在后
-        html += new_titles_html + stats_html
+        # 新增在前,统计在后
+        # 顺序:热榜新增 → RSS新增 → 热榜统计 → RSS统计
+        html += new_titles_html + rss_new_html + stats_html + rss_stats_html
     else:
-        # 默认:热点词汇统计在前,新增热点在后
-        html += stats_html + new_titles_html
+        # 默认:统计在前,新增在后
+        # 顺序:热榜统计 → RSS统计 → 热榜新增 → RSS新增
+        html += stats_html + rss_stats_html + new_titles_html + rss_new_html
 
     html += """
             </div>

+ 1 - 1
version

@@ -1 +1 @@
-4.5.0
+4.6.0