diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000..bd941fe601843 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg +.venv/ +venv/ + +# 输出文件 +output/ + +# 数据库 +*.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# 环境变量 +.env +.env.local + +# 操作系统 +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/config/ai_analysis_prompt.txt b/config/ai_analysis_prompt.txt index e696b3a9dcfd2..68db9064ecca9 100644 --- a/config/ai_analysis_prompt.txt +++ b/config/ai_analysis_prompt.txt @@ -1,6 +1,6 @@ # ═══════════════════════════════════════════════════════════════ # TrendRadar AI 分析提示词配置 -# Version: 2.0.0 +# Version: 3.0.0 # ═══════════════════════════════════════════════════════════════ # # 此文件定义 AI 分析热点新闻时使用的提示词模板 @@ -21,130 +21,72 @@ # ═══════════════════════════════════════════════════════════════ [system] -你是一名高级情报分析师。你的核心能力是从海量、碎片化的公开来源情报(OSINT)中提炼核心逻辑,并识别被大众忽略的弱信号。 +你是一名资深新闻编辑,负责把多个平台的热点新闻整理成一份信息完整、条理清晰的内容总结。 -## 核心思维模型 (Mental Models) +## 你的任务 -1. 见微知著 (Signal Detection):不要只盯着榜首的大新闻。要善于从"排名第50的冷门技术贴"与"排名第1的热门事件"中找到潜在的因果联系。 -2. 交叉验证 (Triangulation):利用"热榜"(大众情绪)与"RSS"(专家视角)的差异。当两者观点冲突时,通常隐藏着认知套利的机会。 -3. 反直觉思考 (Counter-Intuitive):当全网都在叫好时,寻找风险;当全网都在恐慌时,寻找机会。拒绝平庸的共识。 -4. 结构化输出 (MECE):确保分析维度相互独立且完全穷尽,避免逻辑混乱。 +各平台(微博、知乎、抖音、百度、今日头条等)的热点榜单中,同一事件或主题往往会被多个平台以不同标题、不同角度报道。你的工作是: -## 核心原则 +1. 识别同一事件/主题并归并:把「真正相关」的新闻归并,但不要过度归并。以下情况归并为一个 topic: + - 同一件事的不同说法(如「某公司发布新产品」和「某公司新品亮相」); + - 同一个人物/主体的多条新闻; + - 紧密相关的同一细分话题(如「多家车企召回」「多家公司财报」可归并)。 + 注意:以下情况不能归并:「国际关系」「AI」「科技」这类宽泛领域不能作为一个 topic;不同国家的独立事件不能归并;不同公司的不同产品发布不能归并;不同领域的科技新闻不能归并。不要把不相关的新闻硬塞成「大杂烩」。 +2. 完整总结内容:对每个事件,整合所有平台透露的信息,写出一段完整、忠于原文的总结,讲清楚「发生了什么、关键信息是什么、各方怎么说」。输入数据中部分新闻标题下方带有「摘要:」的正文描述,总结时必须充分利用这些描述来提取细节和关键信息。 +3. 全局概述:把 topics 中每一条 title 依次串联,形成本期概述。 -1. 直击要害:拒绝"综上所述"、"众所周知"等废话。直接输出结论。 -2. 逻辑闭环:不仅描述"发生了什么",必须解释"为什么发生"以及"未来会怎样"。 -3. 去情绪化:可以分析舆论的情绪,但你自己的分析必须冷静、客观、冷血。 -4. 辩证思维:识别热点背后的"主要矛盾"(如技术变革vs既得利益),抓住事物发展的关键内因。 +## 核心原则(最重要) -## 数据字段深度解读指南 +1. 信息完整度优先:你的价值在于把散落在各平台的信息拼成完整图景,而不是评价它有多「火」。宁可多写事实,不要遗漏关键信息。 +2. 忠于原文:总结必须基于输入标题透露的事实,不要凭空编造细节。 +3. 去热度化:绝对不要分析、评价或比较任何「热度」信号。 +4. 聚合粒度:本期热点聚合为 10~20 个 topic。平均 2~4 条新闻归并为一个 topic。以「相关性」为准归并,但不要走两个极端:既不把不相关新闻硬塞成一个大杂烩,也不把每条新闻都单独列为一个 topic(那等于没做归并)。真正无法归并的独立重要新闻可以单独成 topic,但总数不应超过 25 个。 -### 1. 基础维度 -- 来源平台:每一行新闻开头的 [平台名称](如 [微博]、[知乎])明确指出了数据来源。请务必注意:后续的排名和轨迹数据仅针对该特定平台的榜单。 -- 排名:"1"为该平台榜首,数字越小越热。"3-8"表示在该平台排名在第3到第8之间波动。 -- 出现次数:次数越多,说明在热榜停留时间越长,热度越持久。 -- 时间范围:如"09:30~12:45",跨度越大说明话题生命力越强。 +## 明确禁止(务必遵守) -### 2. 轨迹量化分析(重要) -数据格式为 排名(时间)→排名(时间)...,例如 1(09:30)→0(10:00)→2(10:30)。 +- 禁止分析热度排名、排名变化、排名轨迹。 +- 禁止使用「全网霸屏」「破圈扩散」「圈层热点」等热度分级词汇。 +- 禁止比较哪个平台更热、哪个平台关注度更高。 +- 禁止输出「研判」「策略建议」「投资建议」「预测走向」等内容。 +- 禁止使用 Markdown 语法(**加粗**、# 标题、- 列表等)和 emoji。 -关键定义: -- 数值含义:数字代表排名(1为榜首,数字越小越靠前)。0 特指"未上榜"或"脱榜"(即该时间点不在榜单中)。 -- 符号含义:→ 代表时间推移。 +## 输出格式 -防幻觉警示(关键): -- 高位横盘 ≠ 急升:如果轨迹是 2(10:00)→2(10:30)→2(11:00),说明热度持续稳定,绝对不是"急升"或"爆发"。只有排名数值显著减小(如 10→5)才是急升。请务必区分"热度高"和"热度升"。 +以 JSON 格式输出,字段如下: -请重点分析以下模式: -- 急升/爆发:排名数值在短时间内大幅减小(如 20→3),代表热度飙升,往往意味着突发重大事件。 -- 衰退/僵尸:排名数值持续变大且无反弹(如 10→15→20),代表热度正在自然衰退。 -- 回榜/反转:序列中出现 0 后又变为高排名(如 5→0→2),代表话题曾脱榜但因新进展"复活",通常暗示有新爆料或剧情反转。 - -### 3. 跨平台特征(分级标准) -- 全网霸屏:5个及以上平台同时上榜。真正的"国民级"话题,无死角覆盖。 -- 破圈扩散:3-4个平台同时上榜。话题已突破单一社区壁垒,正在向外蔓延。 -- 圈层热点:仅在1-2个平台火爆。属于特定人群的狂欢。 - -平台调性参考 (Platform DNA): -- 舆论/情绪场:微博(情绪/吃瓜)、抖音/快手(视觉/传播快)、B站(年轻/玩梗) -- 理性/专业场:知乎(深度/批判)、雪球(投资/财经)、IT之家/36氪(科技/商业) -- 资讯/分发场:今日头条(社会/民生)、百度热搜(综合/搜索量) - -分析"平台温差"时,请结合平台调性。例如:某话题在微博火但在知乎冷,可能说明该话题"情绪价值大于逻辑价值"或"缺乏深度讨论点"。 +```json +{ + "overview": "本期热点总体概述(一段话)", + "topics": [ + { + "title": "事件/主题的简短标题", + "summary": "该事件的完整内容总结(多句话,讲清事件背景、关键事实、进展、各方说法)", + "platforms": ["报道了该事件的平台名称列表"], + "key_points": ["关键信息点1", "关键信息点2", "关键信息点3"] + } + ] +} +``` -## 输出格式规范(严格遵守) +字段说明: +- overview:本期概述,由 topics 中每一条 title 依次串联而成,一条条罗列(例如「1. xxx 2. yyy 3. zzz」),让读者一眼看清本期有哪些热点。 +- topics:按事件/主题聚合后的列表,总数 10~20 个,上限 25 个。每个 topic 是多条相关新闻归并后的结果,尽量让每个 topic 包含 2 条以上新闻。一个 topic 只包含一条新闻的情况应极少(仅限该新闻确实独立且重要、无法与其他新闻归并时)。 + - title:用一句简短的话概括该主题(一句话,尽量 30 字以内),例如「多家车企因门把手隐患召回」。不要照抄原标题,也不要把归并进去的每条新闻标题罗列出来或拼接在一起——summary 才是详细展开的地方。 + - summary:最核心的字段,务必完整总结该事件的所有已知信息(优先基于「摘要:」的正文描述),信息越完整越好。 + - platforms:列出报道了该事件的平台(用输入中出现的 [平台] 名称)。 + - key_points:用要点形式提炼该事件最关键的事实,便于快速阅读。 -你将以 JSON 格式输出分析结果。每个字段的值是纯文本字符串。 +排序规则(重要): +- topics 数组必须按「综合热度」从高到低排序,最热的事件排在第一位。 +- 综合热度由两个因素共同决定:覆盖平台数量(platforms 越多越热)+ 各平台出现次数(数据中的「出现:N次」越大越热)。platforms 只列出真正报道了该事件的平台,不要为了增加热度而把不相关新闻的平台也列进去。 +- 排序时以热度为准,但每个事件的 summary 仍只做内容总结,不要写「热度高」「排名靠前」等评价性文字。 换行规则: -- 用 \n 表示换行(JSON 字符串内标准换行符) -- 段落之间用 \n\n 分隔 - -结构标签规则(【】仅用于分段): -- 【】仅用于板块内的结构性分段标签,如【宏观主线】、【跨平台共振】 -- 标签后只跟冒号或直接换行(×【宏观主线】两大叙事交织:→ ○【宏观主线】:) -- 标签前用 \n 与前段分隔 -- 【】内只允许固定的分段名称,禁止放入话题名、新闻标题等动态内容 -- 同一标签下仅有1条内容时不加序号,2条及以上才使用序号 - -话题引用规则(「」用于行内引用): -- 提及具体话题、新闻标题、事件名称时,使用「」角引号(×【黄仁勋暴论】→ ○「黄仁勋暴论」) -- 「」是行内标记,不触发换行,不加冒号 - -序号规则: -- 列举时用 1. 2. 3. 数字序号 -- 每个序号独占一行(前面用 \n 换行) -- 序号行内禁止使用【】标签 - -绝对禁止: -- 禁止使用 Markdown(如 **加粗**、## 标题、- 列表) -- 禁止使用 emoji 或特殊装饰符号 - -## 分析板块说明(6个板块) - -### 1. core_trends — 核心热点态势(200字以内) -整合"趋势概述"、"热度走势"、"跨平台关联"。 -任务:提炼共性与定性。不仅要识别最火话题,更要尝试寻找不同新闻背后的底层逻辑或共性叙事(如:多条看似无关的新闻共同指向"经济复苏乏力"或"AI应用落地"的大趋势)。 -重点:判断热度性质(全网霸屏vs圈层自嗨)以及话题间的潜在关联。 -写法:拒绝流水账。用"宏观主线+微观佐证"的结构,将散点信息串联成逻辑链条。一句话开场定性(必须使用"全网霸屏"/"破圈扩散"/"圈层热点"等词汇),然后用【宏观主线】挖掘底层逻辑,【微观领域】用序号列举细分点。 - -### 2. sentiment_controversy — 舆论风向争议(100字以内) -任务:绘制情绪光谱。拒绝简单的"褒/贬"二元对立。要识别"舆论断层"(如:专家担忧风险而大众狂欢,或媒体冷处理而民间热议)。 -核心:寻找观点冲突点。哪里有争吵,哪里就有价值。识别是"利益之争"(钱包问题)还是"认知之争"(观念问题)。 -写法:【情绪光谱】识别"主流声音"与"潜流暗涌"的反差,【核心矛盾】用序号列举冲突点。 - -### 3. signals — 异动与弱信号(150字以内) -任务:捕捉时间轴(轨迹)和空间轴(跨平台)上的异常波动。拒绝平铺直叙的单点罗列。 -关注维度: -- 跨平台共振:某话题在A平台爆发后,是否迅速引发B平台关注?(对应"破圈扩散") -- 平台温差:某话题在微博霸榜但在知乎无人问津(对应"圈层热点") -- 轨迹突变:排名骤升(急升)、死而不僵(僵尸)、反转复活(回榜) -写法:必须结合跨平台特征分析,拒绝只列举单个平台的涨跌。用【标签】分段(不用序号),从【跨平台共振/温差】【轨迹突变】【弱信号捕捉】等维度至少覆盖2点。 - -### 4. rss_insights — RSS深度洞察(100字以内) -任务:寻找信息增量。RSS 源通常比大众热榜更垂直、更专业。 -策略: -- 去重:果断忽略与热榜大众新闻高度雷同的内容 -- 互补:挖掘热榜未覆盖的硬核细节(如技术参数、深度行研)或长尾话题 -- 前瞻:识别可能尚未引爆但极具价值的早期行业信号 -写法:【认知纠偏】专业视角如何修正大众热搜的误区或盲目,【硬核增量】补充热榜缺失的关键技术参数、行业内幕或深度数据。无RSS数据时填"暂无RSS数据"。 - -### 5. outlook_strategy — 研判策略建议 -任务:预测与推演。不仅总结过去,更要预测未来。 -核心: -- 后续推演:预测事件的下一阶段(如:是否会反转?监管是否介入?热度是否可持续?) -- 行动指南:给出具体、有针对性的建议。严禁使用"建议持续关注"等无意义的正确的废话。 -写法:格式为 1. 投资者:xxx 2. 品牌方:xxx 3. 公众:xxx,序号后直接跟角色名加冒号,不使用【】标签。 - -### 6. standalone_summaries — 独立展示区概括(每源100字以内) -仅当数据中包含独立展示区数据时返回。对象类型,key 为数据中每个源的 ### 标题方括号内的名称,value 为 100 字以内的概括。有几个源就写几个 key。 -核心原则:去重补盲 + 轨迹洞察。 -1. 去重:果断忽略前5板块已充分分析的话题,优先提取前5板块未覆盖的独有内容。若某话题虽在前5板块提及但在该平台有独特表现(如排名走势截然不同),可简要补充差异点。 -2. 轨迹洞察:若数据中包含轨迹信息,按上述"### 2. 轨迹量化分析"的规则解读排名走势,识别该平台的急升/衰退/回榜等趋势特征。若数据中无轨迹信息,则基于排名和出现次数做简要判断即可。 -写法:先用一句话点明该平台当前的整体趋势动向(基于轨迹数据判断),再列举前5板块未提及的重要话题(附带排名走势)。示例:"西藏感悟话题从第12急升至榜首,关注度爆发;此外白银交割战争预判(排名11稳定)、老君山45万年终奖(3→7缓降)值得留意"。禁止空泛总结。 +- JSON 字符串内的换行用 \n 表示。 +- 段落之间用 \n\n 分隔。 [user] -请分析以下热点新闻数据: +请总结以下各平台的热点新闻内容: ## 数据概览 - 报告模式:{report_mode} ({report_type}) @@ -162,25 +104,14 @@ {rss_content} ## 独立展示区 -以下为独立展示的完整热榜/RSS 数据(不受关键词过滤),请按板块说明中 standalone_summaries 的要求处理。 {standalone_content} --- -请基于上述数据撰写分析报告。以 JSON 格式返回,所有字段均为可选(缺少任何字段不会报错): - -```json -{ - "core_trends": "(按上述板块说明写法输出)", - "sentiment_controversy": "(按上述板块说明写法输出)", - "signals": "(按上述板块说明写法输出)", - "rss_insights": "(按上述板块说明写法输出)", - "outlook_strategy": "(按上述板块说明写法输出)", - "standalone_summaries": {"知乎": "100字概括,优先列前5板块未提及的话题及排名走势", "Hacker News": "100字概括..."} -} -``` +请基于上述数据,按 [system] 中的要求输出 JSON 总结。使用 {language} 输出。 要求: -- 使用 {language} 输出,语言简练专业 -- 6个板块内容不重叠不冗余 -- 若某板块无明显内容,可简写"暂无显著异常" +- 信息完整度优先,按事件/主题聚合各平台内容。 +- 同一事件只保留一个 topic,把各平台对该事件的信息整合进 summary。 +- topics 按综合热度(覆盖平台数 + 出现次数)从高到低排序。 +- summary 只做内容总结,不要写排名、热度等评价性文字。 diff --git a/config/config.yaml b/config/config.yaml index b743c7c76a447..51a5a19c3c01f 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -266,7 +266,7 @@ notification: # 推送渠道配置 channels: feishu: - webhook_url: "" # 飞书机器人 webhook URL + webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/f5029eea-0645-4673-b3a0-bdd02c7c491d" # 飞书机器人 webhook URL dingtalk: webhook_url: "" # 钉钉机器人 webhook URL @@ -363,11 +363,11 @@ ai: # 完整列表: https://docs.litellm.ai/docs/providers # 如果你对于看英文文档比较头疼,那么可以点击页面右下角的 【Ask AI】 ,用中文询问怎么配置 - model: "deepseek/deepseek-v4-flash" + model: "openai/LongCat-2.0" - api_key: "" # API Key(建议使用环境变量 AI_API_KEY) + api_key: "ak_2Wq4t81VB5yn91c7BR6SL0vE6Tg4G" # API Key(建议使用环境变量 AI_API_KEY) - api_base: "" # 自定义 API 地址(可选,绝大多数情况留空即可) + api_base: "https://api.longcat.chat/openai/v1" # 自定义 API 地址(可选,绝大多数情况留空即可) # # ❓ 什么时候需要填? # 当你使用的 AI 服务商不在上方支持列表中时。 @@ -388,13 +388,14 @@ ai: # 这样几乎任何服务商都能接入。 - timeout: 120 # 请求超时(秒) + timeout: 480 # 请求超时(秒,8 分钟) + # 推理模型(如 LongCat)生成推理过程较慢,适当调大超时 temperature: 1.0 # 采样温度 (0.0-2.0) # 注意:部分模型(如 gpt-5)可能要求必须为 1.0,否则会报错 - max_tokens: 5000 # 最大生成 token 数 - # 注意:如果 API 不支持此参数(报 HTTP 400),请设为 0 以禁用发送 + max_tokens: 0 # 最大生成 token 数(0=不限制,由模型/服务商决定上限) + # 推理模型(如 LongCat)会占用 token 输出推理过程,设为 0 避免截断 # 高级选项 num_retries: 1 # 失败重试次数 fallback_models: [] # 备用模型列表(可选) diff --git a/config/frequency_words.txt b/config/frequency_words.txt index a9af01820ee7c..81eedc94c43ad 100644 --- a/config/frequency_words.txt +++ b/config/frequency_words.txt @@ -193,7 +193,7 @@ /谷歌|皮查伊|安卓|油管|\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/ => 苹果 -/\bOpenAI\b|\bChatGPT\b|\bSora\b|\bDALL-E\b|\bSam Altman\b|\bGreg Brockman\b/ => OpenAI +/\bOpenAI\b|\bChatGPT\b|\bSora\b|\bDALL-E\b|\bSam Altman\b|\bGreg Brockman\b|\bGPT\b/ => OpenAI /\bAnthropic\b|\bClaude\b|\bDario Amodei\b/ => Claude @@ -230,11 +230,17 @@ [AI 相关] /(? 芯片 /水电|雅鲁藏布江/ => 水电 /光伏|太阳能/ => 光伏 @@ -243,11 +249,15 @@ /自动驾驶|无人驾驶|智驾/ => 自动驾驶 +[机器人] 机器人 -/机械狗|四足/ => 机器狗 +/机械狗|四足/ 具身智能 +/\bRobot\b|\bRobotics\b/ -/月球|登月|火星|宇宙|飞船|航天|空间站|卫星/ => 航天 +[航天] +/月球|登月|火星|宇宙|飞船|航天|空间站|卫星/ +/\bSpace\b|\bRocket\b|\bSpaceX\b|\bSatellite\b/ # 前沿科技 量子 diff --git a/index.html b/index.html index 90ab5b9d9fa37..dbb03cc6bec23 100644 --- a/index.html +++ b/index.html @@ -1,747 +1,3147 @@ - - - - - - 热点新闻分析 - - - - -
-
-
- - -
-
热点新闻分析
-
-
- 报告类型 - 当日汇总 -
-
- 新闻总数 - 387 条 -
-
- 热点新闻 - 5 条 -
-
- 生成时间 - 06-16 07:17 -
-
-
-
-
-
-
-
ai 人工智能
-
3 条
-
-
1/4
-
+ .standalone-section-count { + color: #6b7280; + font-size: 14px; + } -
-
1
-
-
- 财联社热门 - 7-8 - 00:23~07:17 - 15次 -
- + .standalone-group { + margin-bottom: 40px; + } + + .standalone-group:last-child { + margin-bottom: 0; + } + + .standalone-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 8px; + border-bottom: 1px solid #f0f0f0; + } + + .standalone-name { + font-size: 17px; + font-weight: 600; + color: #1a1a1a; + } + + .standalone-count { + color: #666; + font-size: 13px; + font-weight: 500; + } + + /* AI 分析区块样式 */ + .ai-section { + margin-top: 32px; + padding: 24px; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + border-radius: 12px; + border: 1px solid #bae6fd; + } + + .ai-section-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; + } + + .ai-section-title { + font-size: 18px; + font-weight: 600; + color: #0369a1; + } + + .ai-section-badge { + background: #0ea5e9; + color: white; + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + } + + .ai-block { + margin-bottom: 16px; + padding: 16px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + } + + .ai-block:last-child { + margin-bottom: 0; + } + + .ai-block-title { + font-size: 14px; + font-weight: 600; + color: #0369a1; + margin-bottom: 8px; + } + + .ai-block-content { + font-size: 14px; + line-height: 1.6; + color: #334155; + white-space: pre-wrap; + } + + .ai-error { + padding: 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #991b1b; + font-size: 14px; + } + + .ai-warning { + padding: 16px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 8px; + color: #92400e; + font-size: 14px; + } + + .ai-info { + padding: 16px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 8px; + color: #0369a1; + font-size: 14px; + } + + /* ===== 浏览器增强样式(渐进增强,邮件客户端无影响) ===== */ + + /* 宽屏模式 - 基础 */ + body.wide-mode .container { max-width: 1200px; } + body.wide-mode .header-info { grid-template-columns: repeat(4, 1fr); } + body.wide-mode .content { padding: 32px 40px; } + + /* 宽屏模式 - RSS feed-group 两列 */ + body.wide-mode .rss-feeds-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + } + body.wide-mode .feed-group { margin-bottom: 0; } + + /* 宽屏模式 - AI 分析区两列网格 */ + body.wide-mode .ai-section .ai-blocks-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + } + body.wide-mode .ai-block { margin-bottom: 0; } + + /* 宽屏模式 - 新增热点多列 */ + body.wide-mode .new-section .new-sources-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + } + body.wide-mode .new-source-group { margin-bottom: 0; } + + /* 宽屏模式 - 独立展示区多列 */ + body.wide-mode .standalone-section .standalone-groups-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + } + body.wide-mode .standalone-group { margin-bottom: 0; } + + /* Tab 栏 */ + .tab-bar-wrapper { + position: sticky; + top: 0; + z-index: 10; + background: white; + display: none; + margin-bottom: 20px; + align-items: stretch; + border-bottom: 2px solid #e5e7eb; + } + body.wide-mode .tab-bar-wrapper { display: flex; } + body.wide-mode .tab-bar-wrapper.tab-hidden { display: none; } + + .tab-bar { + flex: 1; + min-width: 0; + display: flex; + overflow-x: auto; + white-space: nowrap; + padding: 8px 0 12px 0; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + gap: 4px; + mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent); + -webkit-mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent); + } + .tab-bar::-webkit-scrollbar { display: none; } + .tab-bar.scroll-start { + mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent); + -webkit-mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent); + } + .tab-bar.scroll-end { + mask-image: linear-gradient(to right, transparent, black 24px, black); + -webkit-mask-image: linear-gradient(to right, transparent, black 24px, black); + } + .tab-bar.scroll-start.scroll-end, + .tab-bar.no-overflow { + mask-image: none; + -webkit-mask-image: none; + } + + .tab-arrow { + flex-shrink: 0; + width: 28px; + display: none; + align-items: center; + justify-content: center; + background: none; + border: none; + color: #9ca3af; + font-size: 20px; + font-weight: 300; + cursor: pointer; + padding: 0; + transition: color 0.15s ease; + } + .tab-arrow:hover { color: #4f46e5; } + .tab-arrow.visible { display: flex; } + + .tab-scroll-indicator { + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 2px; + background: #4f46e5; + border-radius: 0 1px 1px 0; + transition: width 0.1s linear; + } + + .tab-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + background: #f3f4f6; + color: #6b7280; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + transition: all 0.2s ease; + flex-shrink: 0; + } + .tab-btn:hover { background: #e5e7eb; color: #374151; } + .tab-btn.active { background: #4f46e5; color: white; } + .tab-count { + font-size: 11px; + background: rgba(0,0,0,0.1); + padding: 1px 6px; + border-radius: 10px; + } + .tab-btn.active .tab-count { background: rgba(255,255,255,0.3); } + + /* 搜索栏 */ + .search-bar { display: none; padding: 0 0 16px 0; } + .search-input { + width: 100%; + padding: 10px 16px; + border: 1px solid #e5e7eb; + border-radius: 8px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; + } + .search-input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); } + .search-input::placeholder { color: #9ca3af; } + + /* 右下角悬浮工具栏 */ + .fab-bar { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 100; + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s, transform 0.3s; + pointer-events: none; + } + .fab-bar.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + .fab-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: #4f46e5; + color: white; + border: none; + cursor: pointer; + font-size: 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + transition: transform 0.2s, background 0.2s; + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + .fab-btn:hover { transform: scale(1.1); background: #4338ca; } + body.dark-mode .fab-btn { background: #533483; } + body.dark-mode .fab-btn:hover { background: #6d28d9; } + + /* 快捷键 tooltip */ + .fab-tooltip { + position: absolute; + bottom: 0; + right: 52px; + background: rgba(30, 30, 50, 0.92); + backdrop-filter: blur(12px); + color: white; + border-radius: 10px; + padding: 12px 16px; + white-space: nowrap; + font-size: 12px; + line-height: 1.8; + box-shadow: 0 8px 24px rgba(0,0,0,0.25); + border: 1px solid rgba(255,255,255,0.1); + opacity: 0; + visibility: hidden; + transform: translateY(6px); + transition: all 0.2s ease; + pointer-events: none; + } + .fab-btn:hover .fab-tooltip, + .fab-btn.show-tip .fab-tooltip { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; + } + .fab-tooltip .tip-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + } + .fab-tooltip .tip-key { + background: rgba(255,255,255,0.15); + border-radius: 3px; + padding: 1px 6px; + font-family: monospace; + font-size: 11px; + margin-left: 8px; + } + + /* 折叠/展开 */ + .collapse-icon { + display: none; + margin-right: 6px; + font-size: 12px; + color: #9ca3af; + transition: transform 0.2s; + user-select: none; + } + .word-header.collapsible { cursor: pointer; } + .word-header.collapsible .collapse-icon { display: inline; } + .word-header.collapsible:hover { + background: #f9fafb; + border-radius: 6px; + margin: 0 -8px 20px -8px; + padding: 8px; + } + .word-group.collapsed .news-item { display: none; } + .word-group.collapsed .collapse-icon { transform: rotate(-90deg); } + + /* Tab 切换动画 */ + body.wide-mode .word-group[data-tab-index] { animation: tabFadeIn 0.2s ease; } + @keyframes tabFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + + /* 宽屏切换按钮 */ + .toggle-wide-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 10px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 15px; + transition: all 0.2s ease; + backdrop-filter: blur(10px); + line-height: 1; + min-height: 38px; + } + .toggle-wide-btn:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); + } + + /* ===== 暗色模式 ===== */ + body.dark-mode { + background: #0f172a; + color: #e2e8f0; + } + body.dark-mode .container { + background: #1e293b; + box-shadow: 0 4px 24px rgba(0,0,0,0.4); + } + body.dark-mode .header { + background: linear-gradient(135deg, #3730a3 0%, #7c3aed 100%); + } + body.dark-mode .content { + background: #1e293b; + } + + /* 文字颜色 */ + body.dark-mode .word-name, + body.dark-mode .new-section-title, + body.dark-mode .standalone-name, + body.dark-mode .new-item-title, + body.dark-mode .project-name { color: #f1f5f9; } + body.dark-mode .word-count, + body.dark-mode .word-index, + body.dark-mode .source-name, + body.dark-mode .time-info, + body.dark-mode .feed-count, + body.dark-mode .new-source-title, + body.dark-mode .standalone-count, + body.dark-mode .rss-section-count, + body.dark-mode .standalone-section-count, + body.dark-mode .news-meta, + body.dark-mode .rss-time, + body.dark-mode .rss-author, + body.dark-mode .rss-summary { color: #94a3b8; } + body.dark-mode .info-value { color: white; } + + /* 链接 */ + body.dark-mode .news-title a, + body.dark-mode .rss-title a, + body.dark-mode .new-item a, + body.dark-mode .standalone-item a, + body.dark-mode .rss-link { color: #93c5fd; } + body.dark-mode .news-title a:visited { color: #c4b5fd; } + body.dark-mode .rss-link:hover { color: #6ee7b7; } + + /* 强调色 */ + body.dark-mode .keyword-tag { + background: rgba(99,102,241,0.15); + color: #a5b4fc; + } + body.dark-mode .count-info { color: #6ee7b7; } + body.dark-mode .rss-source, + body.dark-mode .feed-name, + body.dark-mode .rss-section-title, + body.dark-mode .standalone-section-title { color: #6ee7b7; } + + /* 边框与分割线 */ + body.dark-mode .word-header, + body.dark-mode .news-item, + body.dark-mode .new-item, + body.dark-mode .standalone-item, + body.dark-mode .new-source-title, + body.dark-mode .standalone-header { border-bottom-color: #334155; } + body.dark-mode .section-divider { border-top-color: #334155; } + body.dark-mode .feed-header { border-bottom-color: #166534; } + body.dark-mode .tab-bar { border-bottom-color: #334155; } + + /* 序号圆圈 */ + body.dark-mode .news-number, + body.dark-mode .new-item-number { + background: #334155; + color: #94a3b8; + } + + /* 折叠 hover */ + body.dark-mode .word-header.collapsible:hover { background: #253347; } + + /* Tab 栏 */ + body.dark-mode .tab-bar-wrapper { + background: #1e293b; + border-bottom-color: #334155; + } + body.dark-mode .tab-arrow { color: #64748b; } + body.dark-mode .tab-arrow:hover { color: #c4b5fd; } + body.dark-mode .tab-scroll-indicator { background: #818cf8; } + body.dark-mode .tab-btn { + background: #334155; + color: #94a3b8; + } + body.dark-mode .tab-btn:hover { + background: #475569; + color: #e2e8f0; + } + body.dark-mode .tab-btn.active { + background: #6d28d9; + color: white; + } + body.dark-mode .tab-bar::-webkit-scrollbar-track { background: #1e293b; } + body.dark-mode .tab-bar::-webkit-scrollbar-thumb { background: #475569; } + + /* 搜索框 */ + body.dark-mode .search-input { + background: #1e293b; + border-color: #334155; + color: #e2e8f0; + } + body.dark-mode .search-input:focus { + border-color: #818cf8; + box-shadow: 0 0 0 3px rgba(129,140,248,0.15); + } + body.dark-mode .search-input::placeholder { color: #64748b; } + + /* RSS 卡片 */ + body.dark-mode .rss-item { + background: #1a2e25; + border-left-color: #059669; + } + + /* AI 分析区 */ + body.dark-mode .ai-section { + background: linear-gradient(135deg, #1e1b4b 0%, #1e293b 100%); + border-color: #334155; + } + body.dark-mode .ai-section-title { color: #a5b4fc; } + body.dark-mode .ai-section-badge { background: #4f46e5; } + body.dark-mode .ai-block { + background: #1e293b; + border-color: #334155; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + } + body.dark-mode .ai-block-title { color: #a5b4fc; } + body.dark-mode .ai-block-content { color: #cbd5e1; } + body.dark-mode .ai-warning { + background: #422006; + border-color: #854d0e; + color: #fbbf24; + } + body.dark-mode .ai-error { + background: #450a0a; + border-color: #991b1b; + color: #fca5a5; + } + body.dark-mode .ai-info { + background: #172554; + border-color: #1e40af; + color: #93c5fd; + } + + /* 错误区 */ + body.dark-mode .error-section { + background: #1c1917; + border-color: #78350f; + } + body.dark-mode .error-title { color: #fca5a5; } + body.dark-mode .error-item { color: #f87171; } + + /* Footer */ + body.dark-mode .footer { + background: #0f172a; + border-top-color: #334155; + color: #94a3b8; + } + body.dark-mode .footer-link { color: #93c5fd; } + body.dark-mode .footer-link:hover { color: #c4b5fd; } + + /* 悬浮按钮 */ + body.dark-mode .fab-btn { background: #6d28d9; } + body.dark-mode .fab-btn:hover { background: #7c3aed; } + + /* 下拉菜单 */ + body.dark-mode .save-dropdown-menu { + background: rgba(30,41,59,0.95); + border-color: #475569; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + } + body.dark-mode .save-dropdown-item { + color: #e2e8f0; + } + body.dark-mode .save-dropdown-item:hover { + background: #334155; + color: #c4b5fd; + } + + /* 暗色模式切换按钮 */ + .toggle-dark-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 10px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 15px; + transition: all 0.2s ease; + backdrop-filter: blur(10px); + line-height: 1; + min-height: 38px; + } + .toggle-dark-btn:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); + } + + /* 快捷键面板已集成到 fab-tooltip */ + + /* 阅读进度条 */ + .reading-progress { + position: fixed; + top: 0; left: 0; + width: 0; + height: 3px; + background: linear-gradient(90deg, #4f46e5, #7c3aed); + z-index: 9999; + transition: width 0.1s linear; + } + body.dark-mode .reading-progress { + background: linear-gradient(90deg, #8ab4f8, #c58af9); + } + + /* 复制按钮样式已集成到 .news-number */ + + + + /* 新上榜标记 */ + .badge-new { + display: inline-block; + background: linear-gradient(135deg, #f43f5e, #ec4899); + color: white; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + margin-left: 6px; + vertical-align: middle; + letter-spacing: 0.5px; + } + body.dark-mode .badge-new { + background: linear-gradient(135deg, #be185d, #9333ea); + } + + + +
+
+
+
TrendRadar
+
+ + +
+ + +
+ + + +
+
+
+
热点新闻分析
+
+
+ 报告类型 + 当前榜单 +
+
+ 生成时间 + 08-22 00:17 +
+
+ 热榜命中 + 42 / 255 +
+
+ RSS 命中 + 7 / 48 +
+
+ 热榜平台 + 11/11 +
+
+ RSS 源 + 2/2 +
+
+ 新增热点 + 2 + 3 +
+
+ AI 分析 + 42 + 30 +
+
+
+ +
+ +
+
+
RSS 新增更新
+
3 条
+
+
+
+
+
AI 相关
+
2 条
+
+ + +
+
+
+
英伟达 / AMD
+
1 条
+
+ +
+
+
+
+
+
+
+
中国
+
8 条
+
+
1/15
+
+ + + +
+
4
+
+
抖音500:04~00:152次 +
+ +
+
+
+
5
+ +
+ + +
+
8
+
+
贴吧2800:04~00:152次 +
+ +
+
+
+
+
+
+
东亚
+
7 条
+
+
2/15
+
+ +
+
2
+ +
+ +
+
4
+
+
凤凰网1000:04~00:152次 +
+ +
+
+ +
+
6
+
+
今日头条14-15📉00:04~00:152次 +
+ +
+
+
+
7
+
+
贴吧21-22📉00:04~00:152次 +
+ +
+
+
+
+
+
+
AI 相关
+
5 条
+
+
3/15
+
+ + +
+
3
+
+
贴吧11-12📉00:04~00:152次 +
+ +
+
+
+
4
+
+
bilibili 热搜18-20📈00:04~00:152次 +
+ +
+
+
+
5
+
+
贴吧22-23📉00:04~00:152次 +
+ +
+
+
+
+
+
+
黑神话悟空
+
4 条
+
+
4/15
+
+
+
1
+
+
bilibili 热搜19-30📉00:04~00:152次 +
+ +
+
+
+
2
+
+
bilibili 热搜19-26📈00:04~00:152次 +
+ +
+
+
+
3
+
+
抖音23-24📉00:04~00:152次 +
+ +
+
+
+
4
+
+
bilibili 热搜2700:15 +
+ +
+
+
+
+
+
+
腾讯
+
4 条
+
+
5/15
+
+
+
1
+
+
微博7-8📈00:04~00:152次 +
+ +
+
+
+
2
+
+
百度热搜800:04~00:152次 +
+ +
+
+
+
3
+
+
微博18-19📈00:04~00:152次 +
+ +
+
+
+
4
+
+
百度热搜2100:04~00:152次 +
+ +
+
+
+ +
+
+
+
北美
+
2 条
+
+
7/15
+
+ +
+
2
+
+
今日头条20-24📈00:04~00:152次 +
+ +
+
+
+
+
+
+
水电 / 光伏 / 核能 / 能源
+
2 条
+
+
8/15
+
+
+
1
+
+
今日头条500:04~00:152次 +
+ +
+
+ +
+
+
+
+
机器人 / 机器狗 / 具身智能
+
2 条
+
+
9/15
+
+ +
+
2
+
+
今日头条14-18📈00:04~00:152次 +
+ +
+
+
+
+
+
+
胖东来
+
1 条
+
+
10/15
+
+
+
1
+
+
今日头条700:04~00:152次 +
+ +
+
+
+
+
+
+
DeepSeek
+
1 条
+
+
11/15
+
+ +
+
+
+
+
华为
+
1 条
+
+
12/15
+
+ +
+
+
+
+
西欧
+
1 条
+
+
13/15
+
+
+
1
+
+
抖音800:15 +
+ +
+
+
+
+
+
+
印度
+
1 条
+
+
14/15
+
+
+
1
+
+
贴吧2-3📉00:04~00:152次 +
+ +
+
+
+ +
+
+
+
RSS 订阅更新
+
7 条
+
+ +
+
+
+
✨ AI 热点内容总结
+ AI +
+
+
+
本期概述
+
1. 特斯拉、小米等9家车企因门把手安全隐患启动召回

2. 微信“按住转文字”新功能上线引发用户吐槽

3. 《黑神话:钟馗》新作实机演示与考据细节受关注

4. 多家AI企业财报亮眼,DeepSeek发布多模态新模型

5. 华为发布全球首款阔直板手机Pura X View

6. 菲律宾飞机侵入黄岩岛领空,南部战区回应

7. 多名日本议员计划访华推动改善中日关系

8. 柯洁声称找到破解围棋AI的方法

9. 多所名校新校区搬至县城引发争议

10. Moderna个性化mRNA疫苗首度成功

11. 特朗普称将打击伊朗贸易伙伴引发对华制裁讨论

12. 澳大使鼓吹日韩印组团对付中国

13. 美韩军演遭朝鲜猛烈抨击

14. 薛之谦演唱会现人形机器人替身

15. 胖东来因房东原因关店引热议

16. 网红“桃黑黑”宣布将离开互联网

17. 《诡秘之主》手游开服一小时同时在线破70万

18. 曝iPhone17系列国内销量超4000万
+
+
+
1. 特斯拉、小米等9家车企因门把手安全隐患召回
+
8月21日,市场监管总局公告显示,9家车企因车门紧急拉手不醒目问题启动召回。召回品牌包括中国一汽、北汽蓝谷、东风汽车、奇瑞、吉利、小鹏、零跑、特斯拉、小米等。召回原因为车内应急机械拉手与内饰颜色接近,不易识别和操作。当发生严重碰撞导致整车低压系统失效等极端情形时,可能影响驾乘人员快速开启车门逃生及车外人员救援,存在安全隐患。各企业已按《缺陷汽车产品召回管理条例》要求,向国家市场监督管理总局备案了召回计划。
来源:知乎、财联社热门
关键信息:
- 9家车企因门把手安全隐患召回
- 涉及特斯拉、小米、小鹏等品牌
- 应急拉手与内饰颜色接近影响逃生救援
+
+
+
2. 微信“按住转文字”新功能上线引发用户吐槽
+
近日微信新版本上线“按住转文字”功能,用户长按消息气泡即可将语音转换成文字,聊天界面会出现“可按住 转文字”提示。该全新交互方式引发大量网友热议,不少网友吐槽该功能“难看”“反人类设计”“强迫症好痛苦”,且不支持手动关闭。同时有用户反映更新后复制粘贴内容变得不方便,纷纷在社交平台呼吁关闭此功能。部分微博用户称“第一批用上微信按住转文字的人”纷纷发声表达使用体验。
来源:微博、百度热搜
关键信息:
- 微信新增“按住转文字”功能
- 界面提示被吐槽“难看”“反人类设计”
- 用户呼吁支持手动关闭
+
+
+
3. 《黑神话:钟馗》新作实机演示与考据细节受关注
+
游戏《黑神话:钟馗》的15分钟实机演示引发玩家社区热议,多个平台的用户对该作进行硬核考据,分析实机演示中的细节元素。视频和讨论内容围绕主角身份、美术风格、战斗系统等展开。B站相关搜索词如“黑神话咕嘎”“黑神话钟馗15分钟实机演示”“硬核考据黑神话钟馗实机细节”登上热搜,抖音平台也有用户对主角身份进行猜测讨论。
来源:bilibili热搜、抖音
关键信息:
- 《黑神话:钟馗》新作发布15分钟实机演示
- 玩家社区进行硬核考据分析
- 主角身份成为讨论焦点
+
+
+
4. 多家AI企业财报亮眼,DeepSeek发布多模态新模型
+
AI算力企业中际旭创发布半年报显示,上半年净利润暴增241.7%,AI算力红利持续释放,阿里巴巴创始人章建平新进成为第九大股东。此外,深度求索(DeepSeek)发布DeepSeek-V4-Flash-Vision-Exp模型,社区截图显示API列表中已出现该模型,用户开始测试,其多模态能力表现受到技术社区关注与评价。
来源:华尔街见闻、知乎
关键信息:
- 中际旭创上半年净利暴增241.7%
- 章建平新进成为第九大股东
- DeepSeek发布多模态新模型V4-Flash-Vision-Exp
+
+
+
5. 华为发布全球首款阔直板手机Pura X View
+
华为于8月20日正式发布全球首款阔直板手机Pura X View。该机型在直板机身中实现了横向视野的实质性拓展,定位“为内容而生”,在影音、阅读、游戏等场景下提供更沉浸的观看体验和更完整的信息呈现。据此前爆料,该手机采用6.39英寸阔直板屏幕,屏幕比例接近16:10,分辨率为“21xx*13xx”,工程机搭载9系旗舰级芯片,电池容量设定最大约7000mAh,配备5000万像素旗舰级三摄影像系统加潜望长焦。此前市场传闻其定价在6000-7000元区间。
来源:知乎
关键信息:
- 华为发布全球首款阔直板手机Pura X View
- 6.39英寸屏幕,比例接近16:10
- 搭载9系旗舰芯片,电池约7000mAh
- 此前传闻定价6000-7000元
+
+
+
6. 菲律宾飞机侵入黄岩岛领空,南部战区回应
+
菲律宾3架小型飞机非法侵入中国黄岩岛领空,中国南部战区空军新闻发言人就此事件作出回应。该事件引发多个平台关注,是中国与菲律宾在南海地区主权争议的又一具体体现。
来源:澎湃新闻
关键信息:
- 菲律宾3架小型飞机非法侵入黄岩岛领空
- 南部战区空军新闻发言人回应
+
+
+
7. 多名日本议员计划访华推动改善中日关系
+
日本多名议员计划访华以推动改善中日关系。中国外交部对此回应称,敦促日本当政者倾听并重视这些声音。凤凰网等媒体以“这些日本议员想访华,各方回应来了”为题进行报道,引发各方对中日关系走向的讨论。
来源:澎湃新闻、凤凰网
关键信息:
- 日本议员计划访华推动改善中日关系
- 外交部敦促日当政者倾听重视这些声音
+
+
+
8. 柯洁声称找到破解围棋AI的方法
+
围棋世界冠军柯洁称找到了破解围棋AI的方法,表示只需要“装弱智”哪怕让AI九子也能赢。这一说法在知乎引发热议,用户纷纷讨论该方法的可行性及背后原理。有用户以视频形式分享相关内容,引发技术分析与辩论。
来源:知乎
关键信息:
- 柯洁称找到破解围棋AI的方法
- 声称“装弱智”即可让AI九子获胜
- 用户讨论其可行性与原理
+
+
+
9. 多所名校新校区搬至县城引发争议
+
北京理工大学、南京航空航天大学等多所知名高校将新校区搬迁至县城,引发社会广泛讨论。支持者认为这可以解决原有校区资源紧张问题,对接区域产业需求进行产教融合;反对者则担忧县城缺乏城市所提供的交通、实习、娱乐等配套设施,影响学生社会化进程。舆论围绕“去县城读名校是捡漏还是踩坑”展开激烈辩论,有学生分享“避雷帖”反映不适应情况。
来源:知乎
关键信息:
- 北京理工、南航等多所名校新校区搬至县城
- 支持者称解决资源紧张、促进产教融合
- 反对者担忧缺乏城市配套设施影响学生发展
+
+
+
10. Moderna个性化mRNA疫苗首度成功
+
美国生物科技公司Moderna宣布其“mRNA个性化疫苗”首度取得成功。与此同时,中国创新药企业也在相关领域积极布局,“排队中”推进类似技术研发。这一突破被多家财经媒体关注,被视为mRNA技术领域的重要里程碑。
来源:华尔街见闻
关键信息:
- Moderna“mRNA个性化疫苗”首度成功
- 中国创新药企业也在推进类似技术研发
+
+
+
11. 特朗普称打击伊朗贸易伙伴,美财长回应涉华制裁提问
+
美国总统特朗普声称将打击伊朗的贸易伙伴,美国媒体在记者会上问及“如何制裁中国”时,美国财政部长贝森特答非所问。这一表态引发国际社会对美中关系及美国对伊朗制裁政策走向的关注。贝森特同时表示,美国将对伊朗实施“史上最严厉制裁”,可能无需重启大规模军事行动。
来源:凤凰网、财联社热门、今日头条
关键信息:
- 特朗普称将打击伊朗贸易伙伴
- 美媒问如何制裁中国,美财长答非所问
- 贝森特称将对伊实施史上最严厉制裁
+
+
+
12. 澳大使鼓吹日韩印组团对付中国
+
澳大利亚大使公开鼓吹日本、韩国、印度组团对付中国。该言论引发日本网民热议,部分日本网民明确表示“不要韩印”,对澳方提议持拒绝态度。该事件反映出地区内部分国家在涉华议题上的不同立场与态度。
来源:凤凰网
关键信息:
- 澳大使鼓吹日韩印组团对付中国
- 日本网民表示“不要韩印”
-
- -
-
2
-
-
- tieba - 18-19 - 00:23~07:17 - 15次 -
- +
+
13. 美韩军演遭朝鲜猛烈抨击
+
美韩联合军演遭到朝鲜猛烈抨击,朝鲜方面要求韩国停止“诋毁”行为。与此同时,韩国国内有民众担心被卷入“不义的侵略战争”,反对支援中东美军。朝鲜半岛局势持续紧张。
来源:凤凰网
关键信息:
- 美韩联合军演遭朝鲜猛烈抨击
- 朝鲜要求韩方停止“诋毁”
- 韩国民众反对支援中东美军
-
- -
-
3
-
-
- zhihu - 5-13 - 00:23~07:17 - 15次 -
- +
+
14. 薛之谦演唱会现人形机器人替身
+
在薛之谦演唱会上出现人形机器人作为“替身”进行表演,引发观众和网友对机器人技术与娱乐产业结合的关注。该事件登上今日头条热搜,同时澎湃新闻发表观察文章讨论人形机器人技术的发展,标题为“当‘像人’不再是难题,人形机器人真正比拼的是什么?”
来源:澎湃新闻、今日头条
关键信息:
- 薛之谦演唱会出现人形机器人替身
- 人形机器人技术与娱乐产业结合引发关注
-
-
- -
-
-
-
DeepSeek 梁文锋
-
1 条
+
+
15. 胖东来因房东原因关店引热议
+
胖东来创始人于东来因房东问题狠心关店的事件引发讨论。舆论围绕“让于东来狠心关店的房东是否为‘恶人’”展开辩论,涉及商业租赁、民营企业经营环境等话题。
来源:今日头条
关键信息:
- 胖东来因房东问题关店
- 舆论讨论房东是否为“恶人”
-
2/4
-
- -
-
1
-
-
- 华尔街见闻 - 8-9 - 00:23~07:17 - 15次 -
- +
+
16. 网红“桃黑黑”宣布将离开互联网
+
拥有千万粉丝的网红“桃黑黑”发布长文,宣布将离开互联网一段时间。该消息在知乎等平台引发用户讨论,关注其离开原因及对网红生态的影响。
来源:知乎
关键信息:
- 千万粉丝网红“桃黑黑”宣布将离开互联网一段时间
-
-
- -
-
-
-
哪吒 饺子
-
1 条
+
+
17. 《诡秘之主》手游开服一小时同时在线破70万
+
MMORPG游戏《诡秘之主》正式开服后一小时内,同时在线人数突破70万,显示出该IP强大的玩家号召力。知乎用户就游戏体验、玩法特色等展开讨论。
来源:知乎
关键信息:
- MMORPG游戏《诡秘之主》开服
- 一小时同时在线人数突破70万
-
3/4
-
- -
-
1
-
-
- 百度热搜 - 24-30 - 00:57~06:55 - 7次 -
- +
+
18. 曝iPhone17系列国内销量超4000万
+
有消息曝出iPhone17系列在中国国内销量已超过4400万部。知乎用户就此展开讨论,分析消费者仍青睐苹果的原因,涉及品牌忠诚度、生态系统、产品竞争力等话题。
来源:知乎
关键信息:
- iPhone17系列国内销量超4000万部
- 用户讨论消费者仍青睐苹果的原因
-
-
- -
-
-
-
米哈游 原神 星穹铁道
-
1 条
-
4/4
+
-
-
1
-
-
- zhihu - 5 - 06:55~07:17 - 2次 -
- -
+
- - - - - \ No newline at end of file + + // 页脚 + lines.push('---'); + lines.push('*Generated by TrendRadar*'); + + // 下载 + var md = lines.join('\n'); + var blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); + var link = document.createElement('a'); + var filename = 'TrendRadar_' + dateStr + '_' + timeStr.replace(':', '') + '.md'; + link.download = filename; + link.href = URL.createObjectURL(blob); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + } + + document.addEventListener('DOMContentLoaded', function() { + window.scrollTo(0, 0); + + // 自动检测宽屏模式 + var savedMode = null; + try { savedMode = localStorage.getItem('trendradar-wide-mode'); } catch(e) {} + if (savedMode === '1' || (savedMode === null && window.innerWidth > 768)) { + document.body.classList.add('wide-mode'); + var btn = document.querySelector('.toggle-wide-btn'); + if (btn) btn.textContent = '⊡'; + } + + // 暗色模式恢复 + var savedDark = null; + try { savedDark = localStorage.getItem('trendradar-dark-mode'); } catch(e) {} + if (savedDark === '1') { + document.body.classList.add('dark-mode'); + var darkBtn = document.querySelector('.toggle-dark-btn'); + if (darkBtn) darkBtn.textContent = '☀'; + } + + // 启用搜索栏 + var searchBar = document.querySelector('.search-bar'); + if (searchBar) searchBar.style.display = 'block'; + + // 初始化增强功能 + initTabs(); + initBackToTop(); + initCollapse(); + initStandaloneTabs(); + + // 键盘快捷键 + document.addEventListener('keydown', function(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + var helpBtn = document.querySelector('.fab-help'); + switch(e.key) { + case '?': + if (helpBtn) { + helpBtn.classList.toggle('show-tip'); + var fabBar = document.querySelector('.fab-bar'); + if (fabBar) fabBar.classList.add('visible'); + } + break; + case 'Escape': + if (helpBtn) helpBtn.classList.remove('show-tip'); + break; + case 'w': case 'W': toggleWideMode(); break; + case 'd': case 'D': toggleDarkMode(); break; + case '/': e.preventDefault(); var si = document.querySelector('.search-input'); if (si) si.focus(); break; + } + }); + + // 阅读进度条 + var progressBar = document.querySelector('.reading-progress'); + if (progressBar) { + var progressTicking = false; + window.addEventListener('scroll', function() { + if (!progressTicking) { + requestAnimationFrame(function() { + var h = document.documentElement.scrollHeight - window.innerHeight; + progressBar.style.width = (h > 0 ? (window.scrollY / h * 100) : 0) + '%'; + progressTicking = false; + }); + progressTicking = true; + } + }); + } + + // 一键复制:hover 时数字变复制图标 + var copySvg = ''; + var checkSvg = ''; + document.querySelectorAll('.news-item .news-number').forEach(function(numEl) { + var item = numEl.closest('.news-item'); + var titleEl = item ? item.querySelector('.news-title a') : null; + if (!titleEl) return; + var numText = numEl.textContent.trim(); + numEl.innerHTML = '' + numText + '' + copySvg + ''; + numEl.title = '点击复制标题和链接'; + numEl.addEventListener('click', function(e) { + e.stopPropagation(); + var text = titleEl.textContent.trim() + ' ' + titleEl.href; + function onCopySuccess() { + numEl.classList.add('copied'); + numEl.querySelector('.copy-icon').innerHTML = checkSvg; + setTimeout(function() { + numEl.classList.remove('copied'); + numEl.querySelector('.copy-icon').innerHTML = copySvg; + }, 1500); + } + function fallbackCopy(str, cb) { + var ta = document.createElement('textarea'); + ta.value = str; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); cb(); } catch(ex) {} + document.body.removeChild(ta); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(onCopySuccess).catch(function() { + fallbackCopy(text, onCopySuccess); + }); + } else { + fallbackCopy(text, onCopySuccess); + } + }); + }); + + + + // Header watermark 鼠标跟随揭示 + (function() { + var header = document.querySelector('.header'); + var watermark = document.querySelector('.header-watermark'); + if (!header || !watermark) return; + + var radius = 100; + + header.addEventListener('mousemove', function(e) { + var rect = watermark.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + var maskVal = 'radial-gradient(circle ' + radius + 'px at ' + x + 'px ' + y + 'px, rgba(0,0,0,1) 0%, rgba(0,0,0,0.3) 50%, rgba(0,0,0,0) 100%)'; + watermark.style.webkitMaskImage = maskVal; + watermark.style.maskImage = maskVal; + watermark.style.color = 'rgba(255, 255, 255, 0.25)'; + }); + + header.addEventListener('mouseleave', function() { + watermark.style.webkitMaskImage = 'radial-gradient(circle 0px at 50% 50%, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%)'; + watermark.style.maskImage = 'radial-gradient(circle 0px at 50% 50%, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%)'; + watermark.style.color = 'rgba(255, 255, 255, 0.15)'; + }); + })(); + }); + + + + \ No newline at end of file diff --git a/trendradar/__init__.py b/trendradar/__init__.py index cab573c3a629b..57e850be750de 100644 --- a/trendradar/__init__.py +++ b/trendradar/__init__.py @@ -7,6 +7,31 @@ trendradar # 安装后执行 """ +import sys as _sys + + +def _configure_utf8_stdio() -> None: + """强制 stdout/stderr 使用 UTF-8 输出。 + + Windows 控制台默认编码可能是 GBK,直接用 print 输出 emoji(如 ✅/❌) + 会抛 UnicodeEncodeError 导致程序崩溃。这里在包初始化时统一将标准输出 + 改为 UTF-8,Windows 与 Linux(Ubuntu 上默认即 UTF-8)均适用,且无副作用。 + errors=backslashreplace 保证极端情况下不丢信息(用 \\uXXXX 转义代替报错)。 + line_buffering 使日志在重定向到文件时也能实时落盘。 + """ + for _name in ("stdout", "stderr"): + _stream = getattr(_sys, _name, None) + _reconfigure = getattr(_stream, "reconfigure", None) + if _reconfigure is None: + continue + try: + _reconfigure(encoding="utf-8", errors="backslashreplace", line_buffering=True) + except (ValueError, OSError): + pass + + +_configure_utf8_stdio() + from trendradar.context import AppContext __version__ = "6.10.0" diff --git a/trendradar/ai/analyzer.py b/trendradar/ai/analyzer.py index 7408afd1b3d48..d192f4029560d 100644 --- a/trendradar/ai/analyzer.py +++ b/trendradar/ai/analyzer.py @@ -17,13 +17,9 @@ @dataclass class AIAnalysisResult: """AI 分析结果""" - # 新版 5 核心板块 - core_trends: str = "" # 核心热点与舆情态势 - sentiment_controversy: str = "" # 舆论风向与争议 - signals: str = "" # 异动与弱信号 - rss_insights: str = "" # RSS 深度洞察 - outlook_strategy: str = "" # 研判与策略建议 - standalone_summaries: Dict[str, str] = field(default_factory=dict) # 独立展示区概括 {源ID: 概括} + # 内容汇总结构(按事件/主题聚合,信息完整度优先) + overview: str = "" # 本期热点总体概述 + topics: List[Dict] = field(default_factory=list) # 事件列表 [{title, summary, platforms, key_points}] # 基础元数据 raw_response: str = "" # 原始响应 @@ -90,7 +86,6 @@ def __init__( # 从分析配置获取功能参数 self.max_news = analysis_config.get("MAX_NEWS_FOR_ANALYSIS", 50) self.include_rss = analysis_config.get("INCLUDE_RSS", True) - self.include_rank_timeline = analysis_config.get("INCLUDE_RANK_TIMELINE", False) self.include_standalone = analysis_config.get("INCLUDE_STANDALONE", False) self.language = analysis_config.get("LANGUAGE", "Chinese") @@ -218,14 +213,6 @@ def analyze( else: print("[AI] JSON 修复失败,使用原始文本兜底") - # 如果配置未启用 RSS 分析,强制清空 AI 返回的 RSS 洞察 - if not self.include_rss: - result.rss_insights = "" - - # 如果配置未启用 standalone 分析,强制清空 - if not self.include_standalone: - result.standalone_summaries = {} - # 填充统计数据 result.total_news = total_news result.hotlist_count = prepared.hotlist_total @@ -283,37 +270,27 @@ def _prepare_news_content( # 来源 source = t.get("source_name", t.get("source", "")) - # 构建行 + # 构建行:保留标题 + 来源 + 出现次数。 + # 出现次数作为「综合热度」排序的参考信号,不输出排名/时间/轨迹等分析性信息。 if source: line = f"- [{source}] {title}" else: line = f"- {title}" - # 始终显示简化格式:排名范围 + 时间范围 + 出现次数 - ranks = t.get("ranks", []) - if ranks: - min_rank = min(ranks) - max_rank = max(ranks) - rank_str = f"{min_rank}" if min_rank == max_rank else f"{min_rank}-{max_rank}" - else: - rank_str = "-" - - first_time = t.get("first_time", "") - last_time = t.get("last_time", "") - time_str = self._format_time_range(first_time, last_time) - appear_count = t.get("count", 1) - - line += f" | 排名:{rank_str} | 时间:{time_str} | 出现:{appear_count}次" - - # 开启完整时间线时,额外添加轨迹 - if self.include_rank_timeline: - rank_timeline = t.get("rank_timeline", []) - timeline_str = self._format_rank_timeline(rank_timeline) - line += f" | 轨迹:{timeline_str}" + if appear_count and appear_count > 1: + line += f" | 出现:{appear_count}次" news_lines.append(line) + # 追加摘要/正文描述(来自 extra.hover,部分平台如知乎/百度提供) + summary = t.get("summary", "") + if summary: + # 截断过长摘要,控制 token 消耗 + if len(summary) > 800: + summary = summary[:800] + "..." + news_lines.append(f" 摘要:{summary}") + news_count += 1 if news_count >= self.max_news: break @@ -422,51 +399,6 @@ def _retry_fix_json(self, original_response: str, error_msg: str) -> Optional[AI print(f"[AI] 重试修复 JSON 异常: {type(e).__name__}: {e}") return None - def _format_time_range(self, first_time: str, last_time: str) -> str: - """格式化时间范围(简化显示,只保留时分)""" - def extract_time(time_str: str) -> str: - if not time_str: - return "-" - # 尝试提取 HH:MM 部分 - if " " in time_str: - parts = time_str.split(" ") - if len(parts) >= 2: - time_part = parts[1] - if ":" in time_part: - return time_part[:5] # HH:MM - elif ":" in time_str: - return time_str[:5] - # 处理 HH-MM 格式 - result = time_str[:5] if len(time_str) >= 5 else time_str - if len(result) == 5 and result[2] == '-': - result = result.replace('-', ':') - return result - - first = extract_time(first_time) - last = extract_time(last_time) - - if first == last or last == "-": - return first - return f"{first}~{last}" - - def _format_rank_timeline(self, rank_timeline: List[Dict]) -> str: - """格式化排名时间线""" - if not rank_timeline: - return "-" - - parts = [] - for item in rank_timeline: - time_str = item.get("time", "") - if len(time_str) == 5 and time_str[2] == '-': - time_str = time_str.replace('-', ':') - rank = item.get("rank") - if rank is None: - parts.append(f"0({time_str})") - else: - parts.append(f"{rank}({time_str})") - - return "→".join(parts) - def _prepare_standalone_content(self, standalone_data: Dict) -> tuple: """ 将独立展示区数据转为文本,注入 AI 分析 prompt @@ -493,36 +425,7 @@ def _prepare_standalone_content(self, standalone_data: Dict) -> tuple: if not title: continue - line = f"- {title}" - - # 排名信息 - ranks = item.get("ranks", []) - if ranks: - min_rank = min(ranks) - max_rank = max(ranks) - rank_str = f"{min_rank}" if min_rank == max_rank else f"{min_rank}-{max_rank}" - line += f" | 排名:{rank_str}" - - # 时间范围 - first_time = item.get("first_time", "") - last_time = item.get("last_time", "") - if first_time: - time_str = self._format_time_range(first_time, last_time) - line += f" | 时间:{time_str}" - - # 出现次数 - count = item.get("count", 1) - if count > 1: - line += f" | 出现:{count}次" - - # 排名轨迹(如果启用) - if self.include_rank_timeline: - rank_timeline = item.get("rank_timeline", []) - if rank_timeline: - timeline_str = self._format_rank_timeline(rank_timeline) - line += f" | 轨迹:{timeline_str}" - - lines.append(line) + lines.append(f"- {title}") lines.append("") # RSS 源 @@ -582,7 +485,7 @@ def _parse_response(self, response: str) -> AIAnalysisResult: json_str = json_str.strip() if not json_str: result.error = "提取的 JSON 内容为空" - result.core_trends = response[:500] + "..." if len(response) > 500 else response + result.overview = response[:500] + "..." if len(response) > 500 else response result.success = True return result @@ -616,29 +519,59 @@ def _parse_response(self, response: str) -> AIAnalysisResult: else: result.error = "JSON 解析失败" # 兜底:使用已提取的 json_str(不含 markdown 标记),避免推送中出现 ```json - result.core_trends = json_str[:500] + "..." if len(json_str) > 500 else json_str + result.overview = json_str[:500] + "..." if len(json_str) > 500 else json_str result.success = True return result # 解析成功,提取字段 try: - result.core_trends = data.get("core_trends", "") - result.sentiment_controversy = data.get("sentiment_controversy", "") - result.signals = data.get("signals", "") - result.rss_insights = data.get("rss_insights", "") - result.outlook_strategy = data.get("outlook_strategy", "") - - # 解析独立展示区概括 - summaries = data.get("standalone_summaries", {}) - if isinstance(summaries, dict): - result.standalone_summaries = { - str(k): str(v) for k, v in summaries.items() - } + result.overview = str(data.get("overview", "") or "").strip() + + topics = data.get("topics", []) + if isinstance(topics, list): + result.topics = self._normalize_topics(topics) result.success = True except (KeyError, TypeError, AttributeError) as e: result.error = f"字段提取错误: {type(e).__name__}: {e}" - result.core_trends = json_str[:500] + "..." if len(json_str) > 500 else json_str + result.overview = json_str[:500] + "..." if len(json_str) > 500 else json_str result.success = True return result + + def _normalize_topics(self, topics: List[Any]) -> List[Dict]: + """规范化 topics 列表,确保字段类型安全,过滤无效条目 + + Args: + topics: AI 返回的原始 topics 列表 + + Returns: + 规范化后的事件列表 [{title, summary, platforms, key_points}] + """ + normalized = [] + for t in topics: + if not isinstance(t, dict): + continue + + title = str(t.get("title", "") or "").strip() + summary = str(t.get("summary", "") or "").strip() + if not title and not summary: + continue + + platforms = t.get("platforms", []) + if not isinstance(platforms, list): + platforms = [] + platforms = [str(p).strip() for p in platforms if str(p).strip()] + + key_points = t.get("key_points", []) + if not isinstance(key_points, list): + key_points = [] + key_points = [str(k).strip() for k in key_points if str(k).strip()] + + normalized.append({ + "title": title, + "summary": summary, + "platforms": platforms, + "key_points": key_points, + }) + return normalized diff --git a/trendradar/ai/formatter.py b/trendradar/ai/formatter.py index 1b15927536748..4c88eadd2258d 100644 --- a/trendradar/ai/formatter.py +++ b/trendradar/ai/formatter.py @@ -2,7 +2,7 @@ """ AI 分析结果格式化模块 -将 AI 分析结果格式化为各推送渠道的样式 +将 AI 分析结果(按事件/主题聚合的内容总结)格式化为各推送渠道的样式 """ import html as html_lib @@ -24,7 +24,7 @@ def _format_list_content(text: str) -> str: """ if not text: return "" - + # 去除首尾空白,防止 AI 返回的内容开头就有换行导致显示空行 text = text.strip() @@ -38,7 +38,7 @@ def _format_list_content(text: str) -> str: # 2. 强制换行:匹配 "数字.",且前面不是换行符 # (?!\d) 排除版本号/小数(如 2.0、3.5),避免将其误判为列表序号 result = re.sub(r'(?<=[^\n])\s+(\d+\.)(?!\d)', r'\n\1', result) - + # 3. 处理 "1.**粗体**" 这种情况(虽然 Prompt 要求不输出 Markdown,但防御性处理) result = re.sub(r'(?<=[^\n])(\d+\.\*\*)', r'\n\1', result) @@ -47,7 +47,7 @@ def _format_list_content(text: str) -> str: # 5. 处理 "XX方面:"、"XX领域:" 等子标题换行 # 只有在中文标点(句号、逗号、分号等)后才触发换行,避免破坏 "1. XX领域:" 格式 - result = re.sub(r'([。!?;,、])\s*([a-zA-Z0-9\u4e00-\u9fa5]+(方面|领域)[::])', r'\1\n\2', result) + result = re.sub(r'([。!?;,、])\s*([a-zA-Z0-9一-龥]+(方面|领域)[::])', r'\1\n\2', result) # 6. 处理 【标签】 格式 # 6a. 标签前确保空行分隔(文本开头除外) @@ -65,89 +65,69 @@ def _format_list_content(text: str) -> str: return result -def _format_standalone_summaries( - summaries: dict, bracket_left: str = "[", bracket_right: str = "]" -) -> str: - """格式化独立展示区概括为纯文本行,每个源名称单独一行 +def _format_platforms(platforms: list) -> str: + """格式化平台来源列表为顿号分隔的字符串""" + if not platforms: + return "" + return "、".join(platforms) - Args: - summaries: 源名称 -> 概括文本 的字典 - bracket_left/bracket_right: 源名称两侧的方括号字符。飞书卡片 2.0 markdown - 基于 CommonMark,裸 ``[源名]:`` 会被当作「链接引用定义」整段吞掉, - 故飞书须传入 HTML 实体 ``[`` ``]``(其余渠道用默认裸方括号)。 - """ - if not summaries: + +def _format_key_points(key_points: list) -> str: + """格式化关键信息要点列表""" + if not key_points: return "" - lines = [] - for source_name, summary in summaries.items(): - if summary: - lines.append(f"{bracket_left}{source_name}{bracket_right}:\n{summary}") - return "\n\n".join(lines) - - -def _render_ai_analysis_markdown_like( - result: AIAnalysisResult, standalone_brackets=("[", "]") -) -> str: - """Markdown 系渠道的通用渲染骨架(飞书 / 企业微信 / ntfy / Slack 共用) - - Args: - standalone_brackets: 独立源点速览中源名两侧的括号字符。飞书卡片 markdown - 须用 HTML 实体 ``("[", "]")`` 避免源名被「链接引用定义」吞掉, - 其余渠道沿用默认裸方括号。 - """ + return "\n".join(f"- {p}" for p in key_points) + + +def _render_topic_title(topic: dict, index: int) -> str: + """提取事件标题,空标题时用序号兜底""" + title = topic.get("title", "") + return title if title else f"事件{index}" + + +# === 通用 Markdown 系渲染骨架(企业微信 / 飞书 / ntfy / Slack 共用) === + +def _render_topics_markdown_like(result: AIAnalysisResult) -> str: + """Markdown 系渠道的通用渲染骨架""" if not result.success: if result.skipped: return f"ℹ️ {result.error}" return f"⚠️ AI 分析失败: {result.error}" - lines = ["**✨ AI 热点分析**", ""] - - if result.core_trends: - lines.extend(["**核心热点态势**", _format_list_content(result.core_trends), ""]) - - if result.sentiment_controversy: - lines.extend( - ["**舆论风向争议**", _format_list_content(result.sentiment_controversy), ""] - ) + lines = ["**✨ AI 热点内容总结**", ""] - if result.signals: - lines.extend(["**异动与弱信号**", _format_list_content(result.signals), ""]) + if result.overview: + lines.extend(["**本期概述**", _format_list_content(result.overview), ""]) - if result.rss_insights: - lines.extend( - ["**RSS 深度洞察**", _format_list_content(result.rss_insights), ""] - ) + if result.topics: + lines.append("**热点事件**") + for i, topic in enumerate(result.topics, 1): + title = _render_topic_title(topic, i) + summary = topic.get("summary", "") + platforms = topic.get("platforms", []) + key_points = topic.get("key_points", []) - if result.outlook_strategy: - lines.extend( - ["**研判策略建议**", _format_list_content(result.outlook_strategy), ""] - ) + lines.append(f"**{i}. {title}**") + if platforms: + lines.append(f"来源:{_format_platforms(platforms)}") + if summary: + lines.append(_format_list_content(summary)) + if key_points: + lines.append("关键信息:") + lines.append(_format_key_points(key_points)) + lines.append("") - if result.standalone_summaries: - summaries_text = _format_standalone_summaries( - result.standalone_summaries, *standalone_brackets - ) - if summaries_text: - lines.extend(["**独立源点速览**", summaries_text]) - - return "\n".join(lines) + return "\n".join(lines).rstrip() def render_ai_analysis_markdown(result: AIAnalysisResult) -> str: """渲染为通用 Markdown 格式(企业微信、ntfy、Slack)""" - return _render_ai_analysis_markdown_like(result) + return _render_topics_markdown_like(result) def render_ai_analysis_feishu(result: AIAnalysisResult) -> str: - """渲染为飞书卡片 2.0 markdown 格式 - - 飞书卡片 markdown 基于 CommonMark,裸 ``[源名]:`` 会被解析为「链接引用定义」 - (link reference definition) 而整段不显示,故独立源点速览的源名改用 HTML 实体 - 方括号 ``[`` ``]``(与 report/formatter.py 标题来源标签的处理一致)。 - """ - return _render_ai_analysis_markdown_like( - result, standalone_brackets=("[", "]") - ) + """渲染为飞书卡片 2.0 markdown 格式""" + return _render_topics_markdown_like(result) def render_ai_analysis_dingtalk(result: AIAnalysisResult) -> str: @@ -157,75 +137,63 @@ def render_ai_analysis_dingtalk(result: AIAnalysisResult) -> str: return f"ℹ️ {result.error}" return f"⚠️ AI 分析失败: {result.error}" - lines = ["### ✨ AI 热点分析", ""] - - if result.core_trends: - lines.extend( - ["#### 核心热点态势", _format_list_content(result.core_trends), ""] - ) - - if result.sentiment_controversy: - lines.extend( - [ - "#### 舆论风向争议", - _format_list_content(result.sentiment_controversy), - "", - ] - ) - - if result.signals: - lines.extend(["#### 异动与弱信号", _format_list_content(result.signals), ""]) + lines = ["### ✨ AI 热点内容总结", ""] - if result.rss_insights: - lines.extend( - ["#### RSS 深度洞察", _format_list_content(result.rss_insights), ""] - ) + if result.overview: + lines.extend(["#### 本期概述", _format_list_content(result.overview), ""]) - if result.outlook_strategy: - lines.extend( - ["#### 研判策略建议", _format_list_content(result.outlook_strategy), ""] - ) + if result.topics: + lines.append("#### 热点事件") + for i, topic in enumerate(result.topics, 1): + title = _render_topic_title(topic, i) + summary = topic.get("summary", "") + platforms = topic.get("platforms", []) + key_points = topic.get("key_points", []) - if result.standalone_summaries: - summaries_text = _format_standalone_summaries(result.standalone_summaries) - if summaries_text: - lines.extend(["#### 独立源点速览", summaries_text]) + lines.append(f"**{i}. {title}**") + if platforms: + lines.append(f"来源:{_format_platforms(platforms)}") + if summary: + lines.append(_format_list_content(summary)) + if key_points: + lines.append("关键信息:") + lines.append(_format_key_points(key_points)) + lines.append("") - return "\n".join(lines) + return "\n".join(lines).rstrip() def render_ai_analysis_plain(result: AIAnalysisResult) -> str: - """渲染为纯文本格式""" + """渲染为纯文本格式(Bark)""" if not result.success: if result.skipped: return result.error return f"AI 分析失败: {result.error}" - lines = ["【✨ AI 热点分析】", ""] + lines = ["【✨ AI 热点内容总结】", ""] - if result.core_trends: - lines.extend(["[核心热点态势]", _format_list_content(result.core_trends), ""]) + if result.overview: + lines.extend(["[本期概述]", _format_list_content(result.overview), ""]) - if result.sentiment_controversy: - lines.extend( - ["[舆论风向争议]", _format_list_content(result.sentiment_controversy), ""] - ) + if result.topics: + lines.append("[热点事件]") + for i, topic in enumerate(result.topics, 1): + title = _render_topic_title(topic, i) + summary = topic.get("summary", "") + platforms = topic.get("platforms", []) + key_points = topic.get("key_points", []) - if result.signals: - lines.extend(["[异动与弱信号]", _format_list_content(result.signals), ""]) + lines.append(f"{i}. {title}") + if platforms: + lines.append(f"来源:{_format_platforms(platforms)}") + if summary: + lines.append(_format_list_content(summary)) + if key_points: + lines.append("关键信息:") + lines.append(_format_key_points(key_points)) + lines.append("") - if result.rss_insights: - lines.extend(["[RSS 深度洞察]", _format_list_content(result.rss_insights), ""]) - - if result.outlook_strategy: - lines.extend(["[研判策略建议]", _format_list_content(result.outlook_strategy), ""]) - - if result.standalone_summaries: - summaries_text = _format_standalone_summaries(result.standalone_summaries) - if summaries_text: - lines.extend(["[独立源点速览]", summaries_text]) - - return "\n".join(lines) + return "\n".join(lines).rstrip() def render_ai_analysis_telegram(result: AIAnalysisResult) -> str: @@ -240,29 +208,30 @@ def render_ai_analysis_telegram(result: AIAnalysisResult) -> str: return f"ℹ️ {_escape_html(result.error)}" return f"⚠️ AI 分析失败: {_escape_html(result.error)}" - lines = ["✨ AI 热点分析", ""] - - if result.core_trends: - lines.extend(["核心热点态势", _escape_html(_format_list_content(result.core_trends)), ""]) - - if result.sentiment_controversy: - lines.extend(["舆论风向争议", _escape_html(_format_list_content(result.sentiment_controversy)), ""]) - - if result.signals: - lines.extend(["异动与弱信号", _escape_html(_format_list_content(result.signals)), ""]) + lines = ["✨ AI 热点内容总结", ""] - if result.rss_insights: - lines.extend(["RSS 深度洞察", _escape_html(_format_list_content(result.rss_insights)), ""]) + if result.overview: + lines.extend(["本期概述", _escape_html(_format_list_content(result.overview)), ""]) - if result.outlook_strategy: - lines.extend(["研判策略建议", _escape_html(_format_list_content(result.outlook_strategy)), ""]) + if result.topics: + lines.append("热点事件") + for i, topic in enumerate(result.topics, 1): + title = _render_topic_title(topic, i) + summary = topic.get("summary", "") + platforms = topic.get("platforms", []) + key_points = topic.get("key_points", []) - if result.standalone_summaries: - summaries_text = _format_standalone_summaries(result.standalone_summaries) - if summaries_text: - lines.extend(["独立源点速览", _escape_html(summaries_text)]) + lines.append(f"{i}. {_escape_html(title)}") + if platforms: + lines.append(f"来源:{_escape_html(_format_platforms(platforms))}") + if summary: + lines.append(_escape_html(_format_list_content(summary))) + if key_points: + lines.append("关键信息:") + lines.append(_escape_html(_format_key_points(key_points))) + lines.append("") - return "\n".join(lines) + return "\n".join(lines).rstrip() def get_ai_analysis_renderer(channel: str): @@ -301,64 +270,43 @@ def render_ai_analysis_html_rich(result: AIAnalysisResult) -> str: ai_html = """
-
✨ AI 热点分析
+
✨ AI 热点内容总结
AI
""" - if result.core_trends: - content = _format_list_content(result.core_trends) + if result.overview: + content = _format_list_content(result.overview) content_html = _escape_html(content).replace("\n", "
") ai_html += f""" -
-
核心热点态势
+
+
本期概述
{content_html}
""" - if result.sentiment_controversy: - content = _format_list_content(result.sentiment_controversy) - content_html = _escape_html(content).replace("\n", "
") - ai_html += f""" -
-
舆论风向争议
-
{content_html}
-
""" + if result.topics: + for i, topic in enumerate(result.topics, 1): + title = _escape_html(_render_topic_title(topic, i)) + summary = topic.get("summary", "") + platforms = topic.get("platforms", []) + key_points = topic.get("key_points", []) + + parts = [] + if summary: + parts.append(_escape_html(_format_list_content(summary)).replace("\n", "
")) + if platforms: + parts.append(f"来源:{_escape_html(_format_platforms(platforms))}") + if key_points: + points = "
".join( + f"- {_escape_html(p)}" for p in key_points + ) + parts.append(f"关键信息:
{points}") + content_html = "
".join(parts) - if result.signals: - content = _format_list_content(result.signals) - content_html = _escape_html(content).replace("\n", "
") - ai_html += f""" -
-
异动与弱信号
-
{content_html}
-
""" - - if result.rss_insights: - content = _format_list_content(result.rss_insights) - content_html = _escape_html(content).replace("\n", "
") - ai_html += f""" -
-
RSS 深度洞察
-
{content_html}
-
""" - - if result.outlook_strategy: - content = _format_list_content(result.outlook_strategy) - content_html = _escape_html(content).replace("\n", "
") - ai_html += f""" -
-
研判策略建议
-
{content_html}
-
""" - - if result.standalone_summaries: - summaries_text = _format_standalone_summaries(result.standalone_summaries) - if summaries_text: - summaries_html = _escape_html(summaries_text).replace("\n", "
") ai_html += f"""
-
独立源点速览
-
{summaries_html}
+
{i}. {title}
+
{content_html}
""" ai_html += """ diff --git a/trendradar/core/analyzer.py b/trendradar/core/analyzer.py index 716a3b30efcdb..ea611889125e3 100644 --- a/trendradar/core/analyzer.py +++ b/trendradar/core/analyzer.py @@ -293,6 +293,7 @@ def count_word_frequency( ranks = source_ranks if source_ranks else [] url = source_url mobile_url = source_mobile_url + summary = "" rank_timeline = [] # 对于 current 模式,从历史统计信息中获取完整数据 @@ -306,6 +307,7 @@ def count_word_frequency( first_time = info.get("first_time", "") last_time = info.get("last_time", "") count_info = info.get("count", 1) + summary = info.get("summary", "") if "ranks" in info and info["ranks"]: ranks = info["ranks"] url = info.get("url", source_url) @@ -320,6 +322,7 @@ def count_word_frequency( first_time = info.get("first_time", "") last_time = info.get("last_time", "") count_info = info.get("count", 1) + summary = info.get("summary", "") if "ranks" in info and info["ranks"]: ranks = info["ranks"] url = info.get("url", source_url) @@ -357,6 +360,7 @@ def count_word_frequency( "mobileUrl": mobile_url, "is_new": is_new, "rank_timeline": rank_timeline, + "summary": summary, } ) diff --git a/trendradar/core/data.py b/trendradar/core/data.py index 291643220b7a0..3453191bb54d8 100644 --- a/trendradar/core/data.py +++ b/trendradar/core/data.py @@ -61,6 +61,7 @@ def read_all_today_titles_from_storage( "ranks": ranks, "url": item.url or "", "mobileUrl": item.mobile_url or "", + "summary": item.summary or "", } title_info[source_id][title] = { @@ -70,6 +71,7 @@ def read_all_today_titles_from_storage( "ranks": ranks, "url": item.url or "", "mobileUrl": item.mobile_url or "", + "summary": item.summary or "", "rank_timeline": rank_timeline, } diff --git a/trendradar/crawler/fetcher.py b/trendradar/crawler/fetcher.py index 7fbc6d745fa3a..df2c9b7440023 100644 --- a/trendradar/crawler/fetcher.py +++ b/trendradar/crawler/fetcher.py @@ -211,13 +211,23 @@ def crawl_websites( url = item.get("url", "") mobile_url = item.get("mobileUrl", "") + # 抓取摘要/描述(extra.hover,部分平台如知乎/百度提供正文描述) + extra = item.get("extra") + hover = "" + if isinstance(extra, dict): + hover = (extra.get("hover") or "").strip() + if title in results[id_value]: results[id_value][title]["ranks"].append(index) + # 已有记录若缺少 hover 则补充 + if hover and not results[id_value][title].get("hover"): + results[id_value][title]["hover"] = hover else: results[id_value][title] = { "ranks": [index], "url": url, "mobileUrl": mobile_url, + "hover": hover, } except json.JSONDecodeError: print(f"解析 {id_value} 响应失败") diff --git a/trendradar/notification/batch.py b/trendradar/notification/batch.py index d00672fdc911c..956ec2a462668 100644 --- a/trendradar/notification/batch.py +++ b/trendradar/notification/batch.py @@ -219,3 +219,37 @@ def add_batch_headers( result.append(header + content) return result + + +def split_ai_content_into_batches(ai_content: str, max_bytes: int) -> List[str]: + """将 AI 分析内容按行边界拆分为独立批次 + + AI 分析作为独立消息发送,不参与热点批次头部的编号。 + 按行边界拆分,避免在内容中间截断,确保每条消息不超过 max_bytes。 + + Args: + ai_content: 已渲染的 AI 分析内容(markdown/HTML/纯文本) + max_bytes: 单条消息的最大字节数 + + Returns: + 拆分后的批次列表(可能为空列表) + """ + if not ai_content: + return [] + + lines = ai_content.split("\n") + batches = [] + current = "" + + for line in lines: + candidate = current + line + "\n" + if len(candidate.encode("utf-8")) > max_bytes and current.strip(): + batches.append(current) + current = line + "\n" + else: + current = candidate + + if current.strip(): + batches.append(current) + + return batches diff --git a/trendradar/notification/senders.py b/trendradar/notification/senders.py index 7e72d102faf0a..19518d5d2f14d 100644 --- a/trendradar/notification/senders.py +++ b/trendradar/notification/senders.py @@ -29,7 +29,7 @@ import requests -from .batch import add_batch_headers, get_max_batch_header_size +from .batch import add_batch_headers, get_max_batch_header_size, split_ai_content_into_batches from .formatters import convert_markdown_to_mrkdwn, strip_markdown @@ -155,7 +155,7 @@ def send_to_feishu( mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -164,6 +164,10 @@ def send_to_feishu( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, "feishu", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]") # 逐批发送 @@ -287,7 +291,7 @@ def send_to_dingtalk( mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -296,6 +300,10 @@ def send_to_dingtalk( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, "dingtalk", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]") # 逐批发送 @@ -412,7 +420,7 @@ def send_to_wework( report_data, "wework", update_info, max_bytes=batch_size - header_reserve, mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -421,6 +429,10 @@ def send_to_wework( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, header_format_type, batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]") # 逐批发送 @@ -530,7 +542,7 @@ def send_to_telegram( report_data, "telegram", update_info, max_bytes=batch_size - header_reserve, mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -539,6 +551,10 @@ def send_to_telegram( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, "telegram", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]") # 逐批发送 @@ -827,7 +843,7 @@ def send_to_ntfy( report_data, "ntfy", update_info, max_bytes=batch_size - header_reserve, mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -836,6 +852,10 @@ def send_to_ntfy( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, "ntfy", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + total_batches = len(batches) print(f"{log_prefix}消息分为 {total_batches} 批次发送 [{report_type}]") @@ -1002,7 +1022,7 @@ def send_to_bark( report_data, "bark", update_info, max_bytes=batch_size - header_reserve, mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -1011,6 +1031,10 @@ def send_to_bark( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, "bark", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + total_batches = len(batches) print(f"{log_prefix}消息分为 {total_batches} 批次发送 [{report_type}]") @@ -1153,7 +1177,7 @@ def send_to_slack( report_data, "slack", update_info, max_bytes=batch_size - header_reserve, mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -1162,6 +1186,10 @@ def send_to_slack( # 统一添加批次头部(已预留空间,不会超限) batches = add_batch_headers(batches, "slack", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]") # 逐批发送 @@ -1266,7 +1294,7 @@ def send_to_generic_webhook( report_data, "wework", update_info, max_bytes=batch_size - template_overhead, mode=mode, rss_items=rss_items, rss_new_items=rss_new_items, - ai_content=ai_content, + ai_content=None, standalone_data=standalone_data, ai_stats=ai_stats, report_type=report_type, @@ -1275,6 +1303,10 @@ def send_to_generic_webhook( # 统一添加批次头部 batches = add_batch_headers(batches, "wework", batch_size) + # AI 分析作为独立消息发送(不拼进热点消息,也不参与批次头部编号) + if ai_content: + batches = batches + split_ai_content_into_batches(ai_content, batch_size) + print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]") # 逐批发送 diff --git a/trendradar/storage/base.py b/trendradar/storage/base.py index b9777fc29908f..1724b8a2f440f 100644 --- a/trendradar/storage/base.py +++ b/trendradar/storage/base.py @@ -27,6 +27,7 @@ class NewsItem: first_time: str = "" # 首次出现时间 last_time: str = "" # 最后出现时间 count: int = 1 # 出现次数 + summary: str = "" # 摘要/正文描述(来自 extra.hover,部分平台提供) rank_timeline: List[Dict[str, Any]] = field(default_factory=list) # 完整排名时间线 # 格式: [{"time": "09:30", "rank": 1}, {"time": "10:00", "rank": 2}, ...] # None 表示脱榜: [{"time": "11:00", "rank": None}] @@ -45,6 +46,7 @@ def to_dict(self) -> Dict[str, Any]: "first_time": self.first_time, "last_time": self.last_time, "count": self.count, + "summary": self.summary, "rank_timeline": self.rank_timeline, } @@ -63,6 +65,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "NewsItem": first_time=data.get("first_time", ""), last_time=data.get("last_time", ""), count=data.get("count", 1), + summary=data.get("summary", ""), rank_timeline=data.get("rank_timeline", []), ) @@ -557,6 +560,7 @@ def convert_crawl_results_to_news_data( ranks = data.get("ranks", []) url = data.get("url", "") mobile_url = data.get("mobileUrl", "") + summary = data.get("hover", "") or data.get("summary", "") rank = ranks[0] if ranks else 99 @@ -572,6 +576,7 @@ def convert_crawl_results_to_news_data( first_time=crawl_time, last_time=crawl_time, count=1, + summary=summary, ) news_list.append(news_item) diff --git a/trendradar/storage/schema.sql b/trendradar/storage/schema.sql index 38ee418f00132..83ada8b32685a 100644 --- a/trendradar/storage/schema.sql +++ b/trendradar/storage/schema.sql @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS news_items ( rank INTEGER NOT NULL, url TEXT DEFAULT '', mobile_url TEXT DEFAULT '', + summary TEXT DEFAULT '', -- 摘要/正文描述(来自 extra.hover) first_crawl_time TEXT NOT NULL, -- 首次抓取时间 last_crawl_time TEXT NOT NULL, -- 最后抓取时间 crawl_count INTEGER DEFAULT 1, -- 抓取次数 diff --git a/trendradar/storage/sqlite_mixin.py b/trendradar/storage/sqlite_mixin.py index ac9f40b9cc01f..7e7ee31aa0c00 100644 --- a/trendradar/storage/sqlite_mixin.py +++ b/trendradar/storage/sqlite_mixin.py @@ -95,12 +95,20 @@ def _init_tables(self, conn: sqlite3.Connection, db_type: str = "news") -> None: if ai_filter_schema.exists(): with open(ai_filter_schema, "r", encoding="utf-8") as f: conn.executescript(f.read()) + self._migrate_news_schema(conn) if db_type == "rss": self._migrate_rss_schema(conn) conn.commit() + def _migrate_news_schema(self, conn: sqlite3.Connection) -> None: + """迁移 news_items 表结构(为已有数据库添加 summary 列)""" + cursor = conn.execute("PRAGMA table_info(news_items)") + columns = {row[1] for row in cursor.fetchall()} + if "summary" not in columns: + conn.execute("ALTER TABLE news_items ADD COLUMN summary TEXT DEFAULT ''") + def _migrate_rss_schema(self, conn: sqlite3.Connection) -> None: """迁移 rss_items 表结构(为已有数据库添加 guid 列)""" cursor = conn.execute("PRAGMA table_info(rss_items)") @@ -198,23 +206,24 @@ def _save_news_data_impl(self, data: NewsData, log_prefix: str = "[存储]") -> title = ?, rank = ?, mobile_url = ?, + summary = ?, last_crawl_time = ?, crawl_count = crawl_count + 1, updated_at = ? WHERE id = ? """, (update_title, item.rank, item.mobile_url, - data.crawl_time, now_str, existing_id)) + item.summary, data.crawl_time, now_str, existing_id)) updated_count += 1 else: # 不存在,插入新记录(存储标准化后的 URL) cursor.execute(""" INSERT INTO news_items - (title, platform_id, rank, url, mobile_url, + (title, platform_id, rank, url, mobile_url, summary, first_crawl_time, last_crawl_time, crawl_count, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) """, (item.title, source_id, item.rank, normalized_url, - item.mobile_url, data.crawl_time, data.crawl_time, + item.mobile_url, item.summary, data.crawl_time, data.crawl_time, now_str, now_str)) new_id = cursor.lastrowid # 记录初始排名 @@ -228,12 +237,12 @@ def _save_news_data_impl(self, data: NewsData, log_prefix: str = "[存储]") -> # URL 为空的情况,直接插入(不做去重) cursor.execute(""" INSERT INTO news_items - (title, platform_id, rank, url, mobile_url, + (title, platform_id, rank, url, mobile_url, summary, first_crawl_time, last_crawl_time, crawl_count, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) """, (item.title, source_id, item.rank, "", - item.mobile_url, data.crawl_time, data.crawl_time, + item.mobile_url, item.summary, data.crawl_time, data.crawl_time, now_str, now_str)) new_id = cursor.lastrowid # 记录初始排名 @@ -357,7 +366,7 @@ def _get_today_all_data_impl(self, date: Optional[str] = None) -> Optional[NewsD # 获取所有新闻数据(包含 id 用于查询排名历史) cursor.execute(""" SELECT n.id, n.title, n.platform_id, p.name as platform_name, - n.rank, n.url, n.mobile_url, + n.rank, n.url, n.mobile_url, n.summary, n.first_crawl_time, n.last_crawl_time, n.crawl_count FROM news_items n LEFT JOIN platforms p ON n.platform_id = p.id @@ -438,11 +447,12 @@ def _get_today_all_data_impl(self, date: Optional[str] = None) -> Optional[NewsD rank=row[4], url=row[5] or "", mobile_url=row[6] or "", - crawl_time=row[8], # last_crawl_time + summary=row[7] or "", + crawl_time=row[9], # last_crawl_time ranks=ranks, - first_time=row[7], # first_crawl_time - last_time=row[8], # last_crawl_time - count=row[9], # crawl_count + first_time=row[8], # first_crawl_time + last_time=row[9], # last_crawl_time + count=row[10], # crawl_count rank_timeline=rank_timeline, )) @@ -509,7 +519,7 @@ def _get_latest_crawl_data_impl(self, date: Optional[str] = None) -> Optional[Ne # 获取该时间的新闻数据(包含 id 用于查询排名历史) cursor.execute(""" SELECT n.id, n.title, n.platform_id, p.name as platform_name, - n.rank, n.url, n.mobile_url, + n.rank, n.url, n.mobile_url, n.summary, n.first_crawl_time, n.last_crawl_time, n.crawl_count FROM news_items n LEFT JOIN platforms p ON n.platform_id = p.id @@ -587,11 +597,12 @@ def _get_latest_crawl_data_impl(self, date: Optional[str] = None) -> Optional[Ne rank=row[4], url=row[5] or "", mobile_url=row[6] or "", - crawl_time=row[8], # last_crawl_time + summary=row[7] or "", + crawl_time=row[9], # last_crawl_time ranks=ranks, - first_time=row[7], # first_crawl_time - last_time=row[8], # last_crawl_time - count=row[9], # crawl_count + first_time=row[8], # first_crawl_time + last_time=row[9], # last_crawl_time + count=row[10], # crawl_count rank_timeline=rank_timeline, )) diff --git "a/\350\257\264\346\230\216MCP.md" "b/\350\257\264\346\230\216MCP.md" new file mode 100644 index 0000000000000..9429a52e7db06 --- /dev/null +++ "b/\350\257\264\346\230\216MCP.md" @@ -0,0 +1,320 @@ +# TrendRadar MCP 与 AI 分析新闻 — 实现说明 + +> 本文档梳理 TrendRadar 项目中「AI 分析新闻」相关的代码,重点说明 MCP(Model Context Protocol)服务在其中的角色与实现机制。 + +--- + +## 目录 + +1. [核心结论](#1-核心结论) +2. [项目中的两套「AI 分析新闻」能力](#2-项目中的两套-ai-分析新闻能力) +3. [主动 AI 分析:`trendradar/ai/` 目录](#3-主动-ai-分析trendradarai-目录) +4. [MCP 服务中的 AI 分析实现](#4-mcp-服务中的-ai-分析实现) +5. [两者的关系与协作流程](#5-两者的关系与协作流程) +6. [配置项说明](#6-配置项说明) +7. [关键代码索引](#7-关键代码索引) + +--- + +## 1. 核心结论 + +TrendRadar 项目里存在**两套角色完全不同**的「AI 分析新闻」能力,二者代码位置不同、调用 LLM 的方式也不同: + +| 能力 | 代码位置 | 谁调用 LLM | 触发方式 | 产出去向 | +|------|----------|-----------|----------|----------| +| **主动 AI 分析** | [`trendradar/ai/`](trendradar/ai/) | TrendRadar **主程序自己**调用(LiteLLM) | 定时任务(scheduler)自动触发 | 分析报告推送到通知渠道 | +| **MCP 被动分析** | [`mcp_server/`](mcp_server/) | **MCP 客户端**(外部的 LLM,如 Claude、Cursor、Cherry Studio) | 用户/助手通过 MCP 工具调用 | 返回给调用方展示 | + +> 一句话总结: +> - **`trendradar/ai/` 是"内置 AI"** —— 程序自己调大模型,自动完成分析、筛选、翻译。 +> - **`mcp_server/` 是"外挂工具"** —— 它**不直接调用 LLM**,而是把新闻数据、提示词、文章正文「递」给外部 AI 助手,由助手的 LLM 完成最终分析。 + +--- + +## 2. 项目中的两套「AI 分析新闻」能力 + +### 2.1 主动 AI 分析(主程序) + +位于 [`trendradar/ai/`](trendradar/ai/),基于 **LiteLLM** 统一接口,是 TrendRadar 定时任务的一部分: + +- **AI 深度分析**(`AIAnalyzer`):把热榜 + RSS 新闻喂给大模型,产出 6 大板块的结构化分析报告。 +- **AI 智能筛选**(`AIFilter` / `AIFilterPipeline`):从兴趣描述中提取标签,再对新闻标题批量打标签分类。 +- **AI 翻译**(`AITranslator`):把推送标题翻译成目标语言。 + +### 2.2 MCP 服务 + +位于 [`mcp_server/`](mcp_server/),基于 **FastMCP 2.0**,是一个标准的 MCP 工具服务器(支持 stdio / HTTP 两种传输)。它对外暴露 26 个工具,其中与「AI 分析新闻」直接相关的有: + +- `analyze_sentiment` —— 情感分析(**生成 AI 提示词**,而非自己分析) +- `read_article` / `read_articles_batch` —— 读取文章正文(通过 **Jina AI Reader**) +- 以及一系列数据查询工具(`get_latest_news`、`search_news`、`aggregate_news` 等),为外部 AI 提供分析原料。 + +--- + +## 3. 主动 AI 分析:`trendradar/ai/` 目录 + +这是「真正调用 LLM 分析新闻」的核心代码。目录结构: + +``` +trendradar/ai/ +├── client.py # AIClient:LiteLLM 统一客户端 +├── analyzer.py # AIAnalyzer:AI 深度分析(核心) +├── filter.py # AIFilter:AI 智能筛选(标签提取/分类) +├── filter_pipeline.py # AIFilterPipeline:筛选流水线编排 +├── translator.py # AITranslator:AI 多语言翻译 +├── formatter.py # 分析结果渲染为各渠道样式 +└── prompt_loader.py # 提示词模板加载 +``` + +### 3.1 `AIClient` — 统一 AI 客户端 + +文件:[`client.py`](trendradar/ai/client.py) + +- 基于 `litellm.completion`,支持 100+ 提供商(DeepSeek、OpenAI、Gemini、Claude、Ollama 等)。 +- 核心方法 `chat(messages)`:构建请求参数(model、api_key、api_base、temperature、max_tokens、timeout、num_retries、fallbacks),调用 LiteLLM,统一提取响应内容(兼容 list 类型的 content)。 +- `validate_config()`:校验模型格式(必须是 `provider/model` 形式)与 API Key 是否存在。 + +关键代码位置: +- `AIClient` 类:[`client.py#L15`](trendradar/ai/client.py#L15) +- `chat()` 方法:[`client.py#L42`](trendradar/ai/client.py#L42) + +### 3.2 `AIAnalyzer` — AI 深度分析(核心) + +文件:[`analyzer.py`](trendradar/ai/analyzer.py) + +这是 AI 分析新闻的**主入口**,流程如下: + +``` +analyze(stats, rss_stats, ...) + ├─ 校验 API Key + ├─ _prepare_news_content() # 1. 把热榜/RSS 新闻拼成文本(受 max_news 上限控制) + ├─ 填充提示词模板 # 2. 替换 {keywords}/{platforms}/{news_content} 等占位符 + ├─ _call_ai() # 3. system + user 两条消息,调 LiteLLM + ├─ _parse_response() # 4. 解析 JSON(含 json_repair 本地修复) + ├─ _retry_fix_json() # 5. JSON 解析失败时,让 AI 修复一次 + └─ 填充统计字段 # 6. 返回 AIAnalysisResult +``` + +关键类与结构: + +- **`AIAnalysisResult`**(dataclass,[`analyzer.py#L17`](trendradar/ai/analyzer.py#L17))——分析结果,采用「按事件/主题聚合的内容总结」结构: + + | 字段 | 含义 | + |------|------| + | `overview` | 本期热点总体概述 | + | `topics` | 事件列表 `[{title, summary, platforms, key_points}]`,按综合热度降序排列 | + | 元数据字段 | `total_news` / `analyzed_news` / `hotlist_count` / `rss_count` 等统计信息 | + +- **`AIAnalyzer`**([`analyzer.py#L58`](trendradar/ai/analyzer.py#L58)): + - `analyze()`([`analyzer.py#L103`](trendradar/ai/analyzer.py#L103))——主入口。 + - `_prepare_news_content()`([`analyzer.py#L255`](trendradar/ai/analyzer.py#L255))——新闻内容拼接:保留标题 + 来源 + 出现次数(作为综合热度排序信号),去掉排名/时间/轨迹。 + - `_call_ai()`([`analyzer.py#L373`](trendradar/ai/analyzer.py#L373))——构造 messages 并调用 `client.chat()`。 + - `_parse_response()`([`analyzer.py#L557`](trendradar/ai/analyzer.py#L557))——三级兜底解析:标准 `json.loads` → `json_repair` 本地修复 → 提取纯文本兜底;解析成功后用 `_normalize_topics()` 规范化 topics。 + - `_retry_fix_json()`([`analyzer.py#L382`](trendradar/ai/analyzer.py#L382))——JSON 解析失败时,用一个轻量「JSON 修复」prompt 让 AI 重试一次。 + +### 3.3 `AIFilter` / `AIFilterPipeline` — AI 智能筛选 + +文件:[`filter.py`](trendradar/ai/filter.py)、[`filter_pipeline.py`](trendradar/ai/filter_pipeline.py) + +两阶段 + 一次增量更新: + +- **阶段 A:`extract_tags()`**([`filter.py#L119`](trendradar/ai/filter.py#L119))——从 `config/ai_interests.txt` 兴趣描述中提取结构化标签 `[{tag, description}]`。 +- **阶段 A':`update_tags()`**([`filter.py#L181`](trendradar/ai/filter.py#L181))——兴趣描述变更时,AI 对比旧标签给出「保留/新增/移除」方案(含 `change_ratio`)。 +- **阶段 B:`classify_batch()`**([`filter.py#L310`](trendradar/ai/filter.py#L310))——把新闻标题按标签批量分类,每条新闻只保留最高分的标签。 + +`AIFilterPipeline.run()`([`filter_pipeline.py#L64`](trendradar/ai/filter_pipeline.py#L64))编排完整流程: + +``` +读取兴趣描述 → 计算 hash → 对比数据库 hash + ├─ 首次/变更超过阈值 → 提取/更新标签 + ├─ 收集待分类新闻(去重 + RSS 新鲜度过滤) + ├─ 按 batch_size 分组调用 AI 分类 + ├─ 保存分类结果到存储 + └─ 转换为与关键词匹配一致的数据结构(convert_to_report_data) +``` + +### 3.4 `AITranslator` — AI 多语言翻译 + +文件:[`translator.py`](trendradar/ai/translator.py) + +- `translate()`([`translator.py#L65`](trendradar/ai/translator.py#L65))——单条翻译。 +- `translate_batch()`([`translator.py#L110`](trendradar/ai/translator.py#L110))——批量翻译(单次 API 调用,用 `[1] [2] [3]` 编号格式,按编号回填避免错位)。 + +### 3.5 格式化与提示词加载 + +- [`formatter.py`](trendradar/ai/formatter.py)——把 `AIAnalysisResult` 渲染为各推送渠道样式:通用 Markdown、飞书卡片、钉钉、Telegram HTML、纯文本、丰富 HTML。统一入口 `get_ai_analysis_renderer(channel)`([`formatter.py#L268`](trendradar/ai/formatter.py#L268))。 +- [`prompt_loader.py`](trendradar/ai/prompt_loader.py)——`load_prompt_template()`([`prompt_loader.py#L16`](trendradar/ai/prompt_loader.py#L16))解析 `[system]` / `[user]` 格式的提示词文件,供 analyzer / translator / filter 共用。 + +--- + +## 4. MCP 服务中的 AI 分析实现 + +### 4.1 关键事实:MCP 服务不直接调用 LLM + +经过代码审查,[`mcp_server/`](mcp_server/) 中**没有任何**对 `trendradar.ai`、`AIClient`、`litellm`、`AIAnalyzer` 的引用。 + +MCP 服务对 `trendradar` 的复用,全部是**非 AI** 的底层工具函数: + +| 复用点 | 用途 | 位置 | +|--------|------|------| +| `trendradar.core.analyzer.calculate_news_weight` | 新闻权重计算(用于排序,非 AI) | [`analytics.py#L16`](mcp_server/tools/analytics.py#L16) | +| `trendradar.core.frequency` | 关键词匹配 / 关注词加载 | `data_service.py`、`parser_service.py` | +| `trendradar.core.loader` | 配置 / webhook 配置加载 | `data_service.py`、`notification.py` | +| `trendradar.crawler.fetcher` | 手动触发爬取 | [`system.py#L214`](mcp_server/tools/system.py#L214) | +| `trendradar.notification.*` | 通知发送 / 格式化 | `notification.py` | +| `trendradar.storage.*` | 本地 / 远程存储 | `storage_sync.py`、`system.py` | + +因此,MCP 里的「AI 分析」是**间接**的:MCP 提供数据、提示词和正文,最终分析由**调用 MCP 的那个外部 LLM** 完成。 + +### 4.2 MCP 中的 AI 相关工具 + +#### 4.2.1 `analyze_sentiment` — 生成 AI 提示词(非自己分析) + +文件:[`mcp_server/tools/analytics.py`](mcp_server/tools/analytics.py) + +- 工具注册:`analyze_sentiment`([`server.py#L484`](mcp_server/server.py#L484)) +- 实现方法:`analyze_sentiment()`([`analytics.py#L657`](mcp_server/tools/analytics.py#L657)) + +它的工作方式: + +1. 收集新闻数据(可按 topic、platforms、date_range 过滤,支持跨天)。 +2. 去重(同一标题跨平台只保留一次)。 +3. 按权重排序(复用 `calculate_news_weight`)。 +4. 调用 `_create_sentiment_analysis_prompt()`([`analytics.py#L845`](mcp_server/tools/analytics.py#L845))**生成一段结构化的 AI 提示词**。 +5. 返回结果中带一个 `ai_prompt` 字段和 `usage_note`: + + ```json + { + "success": true, + "method": "ai_prompt_generation", + "ai_prompt": "请分析以下关于「特斯拉」的新闻标题的情感倾向...", + "data": [ ...新闻列表... ], + "usage_note": "请将 ai_prompt 字段的内容发送给 AI 进行情感分析" + } + ``` + + **MCP 服务到此为止,不实际调用任何 LLM。** 情感分析由调用方(外部 AI 助手)读取 `ai_prompt` 和 `data` 后自行完成。 + +#### 4.2.2 `read_article` / `read_articles_batch` — 通过 Jina AI Reader 读取正文 + +文件:[`mcp_server/tools/article_reader.py`](mcp_server/tools/article_reader.py) + +- `ArticleReaderTools` 类([`article_reader.py#L24`](mcp_server/tools/article_reader.py#L24)) +- 通过 **Jina AI Reader**(`https://r.jina.ai`)把网页 URL 转成干净的 Markdown,供 LLM 阅读正文后再做分析。 +- `read_article()`([`article_reader.py#L58`](mcp_server/tools/article_reader.py#L58)):单篇读取,内置 5 秒速率控制。 +- `read_articles_batch()`([`article_reader.py#L139`](mcp_server/tools/article_reader.py#L139)):批量读取,单次上限 5 篇。 + +典型流程(在工具 docstring 里也写明了): + +``` +1. search_news(include_url=True) → 搜索新闻拿链接 +2. read_article(url=链接) → 读取正文 Markdown +3. 外部 LLM 对正文做分析/摘要/翻译 +``` + +### 4.3 MCP 服务的完整工具清单 + +MCP 工具按功能分组的注册与启动日志见 [`server.py#L1117`](mcp_server/server.py#L1117)(`run_server`),共 26 个工具: + +- **日期解析**:`resolve_date_range` +- **基础查询**:`get_latest_news`、`get_news_by_date`、`get_trending_topics` +- **RSS 查询**:`get_latest_rss`、`search_rss`、`get_rss_feeds_status` +- **智能检索**:`search_news`、`find_related_news` +- **高级分析**:`analyze_topic_trend`、`analyze_data_insights`、`analyze_sentiment`、`aggregate_news`、`compare_periods`、`generate_summary_report` +- **配置与系统**:`get_current_config`、`get_system_status`、`check_version`、`trigger_crawl` +- **存储同步**:`sync_from_remote`、`get_storage_status`、`list_available_dates` +- **文章读取**:`read_article`、`read_articles_batch` +- **通知推送**:`get_channel_format_guide`、`get_notification_channels`、`send_notification` + +> 注意:`analyze_topic_trend`、`analyze_data_insights`、`aggregate_news`、`compare_periods`、`generate_summary_report` 等「分析」工具,实现的是**基于规则的统计/聚合**(词频、相似度、生命周期、增长率等),并**不涉及 LLM 调用**,只有 `analyze_sentiment` 是生成供 LLM 使用的提示词。 + +--- + +## 5. 两者的关系与协作流程 + +两套能力**互不依赖**,但可以配合使用: + +``` +┌─────────────────────────────────────────────────────────┐ +│ TrendRadar 主程序(定时任务) │ +│ crawler → analyzer/filter/translator (trendradar/ai/) │ +│ 自己调 LiteLLM → 生成报告 → 推送通知 │ +└─────────────────────────────────────────────────────────┘ + │ 共享数据(本地/远程存储) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ MCP Server(mcp_server/) │ +│ 不调 LLM,只提供: │ +│ - 数据查询工具(get_latest_news / search_news ...) │ +│ - 提示词生成(analyze_sentiment → ai_prompt) │ +│ - 正文读取(read_article → Jina Reader) │ +└─────────────────────────────────────────────────────────┘ + │ MCP 协议 + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 外部 AI 助手(Claude / Cursor / Cherry Studio ...) │ +│ 调用 MCP 工具 → 拿到数据/提示词/正文 → 用自己的 LLM 分析 │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. 配置项说明 + +所有 AI 相关配置集中在 [`config/config.yaml`](config/config.yaml): + +| 配置段 | 作用 | 位置 | +|--------|------|------| +| `ai` | 模型配置(LiteLLM):`model`、`api_key`、`api_base`、`timeout`、`temperature`、`max_tokens`、`num_retries`、`fallback_models` | [`config.yaml#L354`](config/config.yaml#L354) | +| `ai_analysis` | AI 深度分析开关:`enabled`、`language`、`prompt_file`、`mode`、`max_news_for_analysis`、`include_rss`、`include_standalone`、`include_rank_timeline` | [`config.yaml#L413`](config/config.yaml#L413) | +| `ai_translation` | AI 翻译开关:`enabled`、`language`、`prompt_file`、`batch_size`、`batch_interval`、`scope` | [`config.yaml#L442`](config/config.yaml#L442) | +| `ai_filter` | AI 智能筛选:`batch_size`、`batch_interval`、`min_score`、`reclassify_threshold` | [`config.yaml#L192`](config/config.yaml#L192) | +| `filter.method` | 筛选方式(`keyword` / `ai`),选 `ai` 时启用智能筛选 | [`config.yaml#L175`](config/config.yaml#L175) | + +提示词模板文件: + +| 文件 | 用途 | +|------|------| +| [`config/ai_analysis_prompt.txt`](config/ai_analysis_prompt.txt) | AI 深度分析(按事件/主题聚合的内容总结,`[system]`/`[user]` 格式) | +| [`config/ai_translation_prompt.txt`](config/ai_translation_prompt.txt) | AI 翻译 | +| [`config/ai_filter/prompt.txt`](config/ai_filter/prompt.txt) | 新闻分类 | +| [`config/ai_filter/extract_prompt.txt`](config/ai_filter/extract_prompt.txt) | 标签提取 | +| [`config/ai_filter/update_tags_prompt.txt`](config/ai_filter/update_tags_prompt.txt) | 标签增量更新 | +| [`config/ai_interests.txt`](config/ai_interests.txt) | 兴趣描述(智能筛选的输入) | + +--- + +## 7. 关键代码索引 + +### 主动 AI 分析(`trendradar/ai/`) + +| 组件 | 文件 | 关键位置 | +|------|------|----------| +| AI 客户端(LiteLLM) | [`client.py`](trendradar/ai/client.py) | `AIClient` [#L15](trendradar/ai/client.py#L15)、`chat()` [#L42](trendradar/ai/client.py#L42) | +| AI 深度分析 | [`analyzer.py`](trendradar/ai/analyzer.py) | `AIAnalysisResult` [#L17](trendradar/ai/analyzer.py#L17)、`AIAnalyzer` [#L58](trendradar/ai/analyzer.py#L58)、`analyze()` [#L103](trendradar/ai/analyzer.py#L103)、`_parse_response()` [#L557](trendradar/ai/analyzer.py#L557) | +| AI 智能筛选 | [`filter.py`](trendradar/ai/filter.py) | `AIFilter` [#L36](trendradar/ai/filter.py#L36)、`extract_tags()` [#L119](trendradar/ai/filter.py#L119)、`classify_batch()` [#L310](trendradar/ai/filter.py#L310) | +| 筛选流水线 | [`filter_pipeline.py`](trendradar/ai/filter_pipeline.py) | `AIFilterPipeline` [#L20](trendradar/ai/filter_pipeline.py#L20)、`run()` [#L64](trendradar/ai/filter_pipeline.py#L64) | +| AI 翻译 | [`translator.py`](trendradar/ai/translator.py) | `AITranslator` [#L37](trendradar/ai/translator.py#L37)、`translate_batch()` [#L110](trendradar/ai/translator.py#L110) | +| 结果渲染 | [`formatter.py`](trendradar/ai/formatter.py) | `get_ai_analysis_renderer()` [#L268](trendradar/ai/formatter.py#L268) | +| 提示词加载 | [`prompt_loader.py`](trendradar/ai/prompt_loader.py) | `load_prompt_template()` [#L16](trendradar/ai/prompt_loader.py#L16) | + +### MCP 服务(`mcp_server/`) + +| 组件 | 文件 | 关键位置 | +|------|------|----------| +| MCP 服务器入口 | [`server.py`](mcp_server/server.py) | `run_server()` [#L1117](mcp_server/server.py#L1117) | +| 情感分析(生成提示词) | [`analytics.py`](mcp_server/tools/analytics.py) | `analyze_sentiment()` [#L657](mcp_server/tools/analytics.py#L657)、`_create_sentiment_analysis_prompt()` [#L845](mcp_server/tools/analytics.py#L845) | +| 权重计算(复用,非 AI) | [`analytics.py`](mcp_server/tools/analytics.py) | `calculate_news_weight()` [#L82](mcp_server/tools/analytics.py#L82) | +| 文章正文读取(Jina) | [`article_reader.py`](mcp_server/tools/article_reader.py) | `read_article()` [#L58](mcp_server/tools/article_reader.py#L58)、`read_articles_batch()` [#L139](mcp_server/tools/article_reader.py#L139) | + +--- + +## 附:如何快速判断某段代码是否「真正调用 LLM」 + +在 `mcp_server/` 或 `trendradar/` 中检索以下任一符号即可判断: + +- `from trendradar.ai` / `import trendradar.ai` —— 是否引入主动 AI 模块 +- `AIClient` / `AIAnalyzer` / `AITranslator` / `AIFilter` —— 是否实例化 AI 类 +- `litellm` / `completion(` —— 是否调用 LiteLLM 底层 + +结论:这些符号**只出现在 `trendradar/ai/` 内部**,`mcp_server/` 完全不含。因此「AI 分析新闻」的真正实现集中在 `trendradar/ai/`,而 MCP 服务是面向外部 AI 助手的**数据与工具接口层**。 diff --git "a/\351\203\250\347\275\262.md" "b/\351\203\250\347\275\262.md" new file mode 100644 index 0000000000000..a0781bf2bf3d1 --- /dev/null +++ "b/\351\203\250\347\275\262.md" @@ -0,0 +1,601 @@ +# TrendRadar 服务器部署指南(宝塔面板) + +> 本文档指导你如何将 TrendRadar 部署到自己的云服务器上,使用宝塔面板进行管理。 + +--- + +## 目录 + +1. [部署方案概览](#1-部署方案概览) +2. [方案一:Docker 部署(推荐)](#2-方案一docker-部署推荐) +3. [方案二:本地 Python 部署](#3-方案二本地-python-部署) +4. [配置推送通知](#4-配置推送通知) +5. [配置 AI 功能(可选)](#5-配置-ai-功能可选) +6. [Web 报告访问](#6-web-报告访问) +7. [维护与管理](#7-维护与管理) +8. [常见问题](#8-常见问题) + +--- + +## 1. 部署方案概览 + +| 方案 | 难度 | 稳定性 | 推荐场景 | +|------|------|--------|---------| +| **Docker 部署** | ⭐ 低 | 高 | 推荐,宝塔面板自带 Docker 管理 | +| **本地 Python 部署** | ⭐⭐ 中 | 中 | 不想用 Docker 时 | + +**前置条件:** + +- 一台云服务器(Linux 系统,推荐 CentOS 7+ / Ubuntu 20.04+) +- 已安装宝塔面板([官方安装教程](https://www.bt.cn/new/download.html)) +- 服务器已开放所需端口(默认 8080,可自定义) + +--- + +## 2. 方案一:Docker 部署(推荐) + +### 2.1 在宝塔中安装 Docker + +**方法一:通过宝塔应用商店安装(推荐)** + +1. 登录宝塔面板 → **应用商店** +2. 搜索 **Docker** → 点击 **安装** +3. 安装完成后,左侧会出现 **Docker** 菜单 + +**方法二:通过命令行安装** + +如果应用商店安装失败,可通过 SSH 执行: + +```bash +curl -fsSL https://get.docker.com | bash +systemctl start docker +systemctl enable docker + +# 安装 docker-compose(如已安装可跳过) +curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +chmod +x /usr/local/bin/docker-compose +``` + +### 2.2 获取项目代码 + +通过 SSH 登录服务器,执行: + +```bash +# 安装 git(如已安装可跳过) +yum install -y git # CentOS +# 或 +apt install -y git # Ubuntu + +# 克隆项目 +cd /www +git clone https://github.com/sansan0/TrendRadar.git +cd TrendRadar +``` + +### 2.3 配置项目 + +#### 2.3.1 配置 config.yaml + +```bash +vim config/config.yaml +``` + +重点关注以下配置项: + +```yaml +# 时区(国内服务器保持默认即可) +app: + timezone: "Asia/Shanghai" + +# 调度系统(建议开启) +schedule: + enabled: true + preset: "morning_evening" # 推荐:全天增量 + 晚间汇总 + +# 存储(本地部署用 local 即可) +storage: + backend: "local" + formats: + sqlite: true + html: true +``` + +#### 2.3.2 配置关键词(frequency_words.txt) + +```bash +vim config/frequency_words.txt +``` + +根据你想关注的内容修改关键词,详见文件内的注释说明。 + +#### 2.3.3 配置环境变量(.env) + +```bash +cd docker +cp .env .env.local +vim .env.local +``` + +主要需要配置的项: + +```bash +# Web 服务器端口(如需公网访问,建议改 8080) +WEBSERVER_PORT=8080 + +# ===== 推送通知(至少配置一个,见第 4 节)===== +FEISHU_WEBHOOK_URL= +# ... 其他推送渠道 ... + +# ===== AI 配置(可选,见第 5 节)===== +AI_ANALYSIS_ENABLED=true +AI_API_KEY=sk-xxxxxx +AI_MODEL=deepseek/deepseek-chat +``` + +### 2.4 启动容器 + +#### 方式 A:使用 docker-compose.yml(推荐) + +##### ① 首次启动 + +```bash +cd /www/TrendRadar/docker + +# 复制环境变量模板 +cp .env .env.local + +# 编辑 .env.local 填写配置(见上一步) +vim .env.local + +# 启动容器(-d 表示后台运行) +docker-compose --env-file .env.local up -d trendradar + +# 查看启动日志,确认无报错 +docker logs trendradar +``` + +##### ② 查看运行状态 + +```bash +# 查看容器是否正常运行(STATUS 应为 Up) +docker ps | grep trendradar + +# 查看实时日志(按 Ctrl+C 退出日志跟踪) +docker logs -f trendradar + +# 查看容器资源占用 +docker stats trendradar + +# 进入容器内部操作 +docker exec -it trendradar bash +``` + +##### ③ 修改配置后重启 + +修改了 `config.yaml`、`.env.local` 或 `frequency_words.txt` 后,**不需要重建容器**,只需重启即可生效: + +```bash +# 重启容器(配置文件和环境变量会自动重新加载) +docker restart trendradar + +# 确认重启成功 +docker ps | grep trendradar + +# 查看重启后的日志 +docker logs -n 20 trendradar +``` + +##### ④ 代码更新(git pull) + +当项目有新版代码发布时,按以下步骤更新: + +```bash +cd /www/TrendRadar + +# 拉取最新代码(如有冲突,先备份本地修改再拉取) +git pull + +# 如果拉取后 config.yaml 有模板变更,需要手动合并更新 +# 可以查看模板变更内容: +git diff config/config.yaml + +# 进入 docker 目录重新构建并启动 +cd /www/TrendRadar/docker +docker-compose --env-file .env.local up -d --build trendradar + +# 验证新版本是否正常运行 +docker logs -n 30 trendradar +``` + +> **注意**:`git pull` 不会覆盖你的 `.env.local` 配置文件,因为该文件已被 `.gitignore` 排除。如果 `config.yaml` 有模板变更,需要手动对比合并。 + +##### ⑤ 停止与卸载 + +```bash +# 临时停止容器(不删除,可随时重启) +docker stop trendradar + +# 重启已停止的容器 +docker start trendradar + +# 彻底删除容器(不会删除 output 数据) +docker-compose --env-file .env.local down + +# 删除容器同时删除数据卷(会清空 output 数据,谨慎操作) +# docker-compose --env-file .env.local down -v +``` + +##### ⑥ 完整运维流程示例 + +日常维护的完整操作流程: + +```bash +# 1. 拉取最新代码 +cd /www/TrendRadar && git pull + +# 2. 重新构建并启动 +cd /www/TrendRadar/docker +docker-compose --env-file .env.local up -d --build trendradar + +# 3. 等待 10 秒让服务初始化 +sleep 10 + +# 4. 检查运行状态 +docker ps | grep trendradar && docker logs -n 10 trendradar + +# 5. 手动执行一次抓取测试 +docker exec -it trendradar python manage.py run + +# 6. 确认 Web 服务正常 +curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 +# 返回 200 即正常 +``` + +> 以上流程可写成脚本 `/www/TrendRadar/update.sh`,后续更新只需执行 `bash update.sh` 即可一键完成。 + +#### 方式 B:使用宝塔 Docker 管理器(图形化) + +1. 进入宝塔 → **Docker** → **容器** +2. 点击 **创建容器** +3. 填写以下参数: + +| 参数 | 值 | +|------|-----| +| 镜像 | `wantcat/trendradar:latest` | +| 容器名称 | `trendradar` | +| 端口映射 | `8080:8080`(宿主机:容器) | +| 重启策略 | `always` | + +4. **挂载目录**(存储卷): + +| 服务器路径 | 容器路径 | 说明 | +|-----------|---------|------| +| `/www/TrendRadar/config` | `/app/config` | 配置文件(只读) | +| `/www/TrendRadar/output` | `/app/output` | 输出数据 | + +5. **环境变量**:在创建容器时添加所需的环境变量 + +6. 点击 **创建** 即可启动 + +### 2.5 验证部署 + +```bash +# 查看容器状态 +docker ps | grep trendradar + +# 查看日志 +docker logs -f trendradar + +# 手动执行一次抓取测试 +docker exec -it trendradar python manage.py run +``` + +如果一切正常,第一次运行后会生成数据并推送到你配置的消息渠道。 + +--- + +## 3. 方案二:本地 Python 部署 + +如果不想用 Docker,也可以直接在服务器上运行 Python。 + +### 3.1 安装 Python 和 uv + +```bash +# 安装 uv(Python 包管理器) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 重新加载环境变量 +source ~/.bashrc +``` + +### 3.2 获取并配置项目 + +```bash +cd /www +git clone https://github.com/sansan0/TrendRadar.git +cd TrendRadar + +# 安装依赖 +uv sync + +# 编辑配置 +vim config/config.yaml +vim config/frequency_words.txt +``` + +### 3.3 使用宝塔的计划任务定时运行 + +1. 宝塔面板 → **计划任务** +2. 点击 **添加任务** +3. 配置如下: + +| 参数 | 值 | +|------|-----| +| 任务类型 | `Shell脚本` | +| 任务名称 | `TrendRadar 热点抓取` | +| 执行周期 | `N分钟` → `30`(每 30 分钟一次) | +| 脚本内容 | `cd /www/TrendRadar && /root/.local/bin/uv run python -m trendradar >> /www/TrendRadar/output/cron.log 2>&1` | + +### 3.4 首次运行测试 + +```bash +cd /www/TrendRadar +uv run python -m trendradar +``` + +--- + +## 4. 配置推送通知 + +TrendRadar 支持多种推送渠道,在 `.env.local`(Docker)或 `config.yaml`(本地)中配置。 + +### 4.1 飞书机器人(推荐,显示效果好) + +1. 打开飞书群 → 群设置 → **群机器人** → **添加机器人** → **自定义机器人** +2. 复制 Webhook 地址 +3. 配置到环境变量: + +```bash +FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/你的webhook地址 +``` + +### 4.2 企业微信机器人(最简单) + +1. 打开企业微信群 → 群设置 → **群机器人** → **添加** +2. 复制 Webhook 地址 +3. 配置: + +```bash +WEWORK_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key +``` + +### 4.3 其他渠道 + +详见项目 README 或 `.env` 文件中的注释,支持钉钉、Telegram、邮件、ntfy、Bark、Slack 等。 + +--- + +## 5. 配置 AI 功能(可选) + +### 5.1 AI 分析推送 + +在 `.env.local` 或 `config.yaml` 中配置: + +```bash +# 开启 AI 分析 +AI_ANALYSIS_ENABLED=true + +# API Key(以 DeepSeek 为例,性价比高) +AI_API_KEY=sk-your-deepseek-api-key + +# 模型名称(LiteLLM 格式) +AI_MODEL=deepseek/deepseek-chat + +# 可选:自定义 API 地址 +# AI_API_BASE=https://api.deepseek.com +``` + +支持的 AI 提供商: + +| 提供商 | AI_MODEL 填写 | 说明 | +|--------|-------------|------| +| DeepSeek | `deepseek/deepseek-chat` | 性价比极高,推荐 | +| OpenAI | `openai/gpt-4o-mini` | 稳定但较贵 | +| 通义千问 | `openai/qwen-turbo` | 需配合 `AI_API_BASE` | +| 豆包 | `openai/doubao-*` | 需配合 `AI_API_BASE` | + +### 5.2 AI 翻译功能 + +```yaml +# config.yaml +ai_translation: + enabled: true + language: "中文" # 翻译目标语言 +``` + +--- + +## 6. Web 报告访问 + +### 6.1 Docker 部署 + +启动容器后,Web 服务器已自动运行。访问方式: + +```bash +http://你的服务器IP:8080 +``` + +如果使用宝塔,建议配置反向代理+域名访问: + +1. 宝塔 → **网站** → **添加站点** +2. 填入你的域名 +3. 站点设置 → **反向代理** → **添加**: + +| 参数 | 值 | +|------|-----| +| 代理名称 | `TrendRadar` | +| 目标 URL | `http://127.0.0.1:8080` | + +然后就可以通过 `http://你的域名` 访问了。 + +### 6.2 本地部署 + +如需 Web 访问,可手动启动内置 Web 服务器: + +```bash +cd /www/TrendRadar +uv run python manage.py start_webserver +``` + +### 6.3 配置 SSL(HTTPS) + +在宝塔网站设置中,进入 **SSL** → 申请 **Let's Encrypt** 免费证书,即可开启 HTTPS。 + +--- + +## 7. 维护与管理 + +### 7.1 Docker 常用命令 + +```bash +# 查看日志 +docker logs -f trendradar + +# 手动运行一次 +docker exec -it trendradar python manage.py run + +# 查看状态 +docker exec -it trendradar python manage.py status + +# 查看输出文件 +docker exec -it trendradar python manage.py files + +# 重启容器 +docker restart trendradar + +# 更新镜像 +docker-compose pull trendradar +docker-compose up -d trendradar + +# 查看资源占用 +docker stats trendradar +``` + +### 7.2 数据管理 + +数据默认存储在 `/www/TrendRadar/output/` 目录下: + +``` +output/ +├── index.html # 最新报告 +├── news/ # SQLite 数据库(按日期) +│ ├── 2026-08-21.db +│ └── 2026-08-22.db +├── html/ # HTML 历史报告 +│ └── 2026-08-22/ +│ └── 当日汇总.html +└── rss/ # RSS 数据(按日期) +``` + +如需清理旧数据,修改 `config.yaml`: + +```yaml +storage: + local: + retention_days: 30 # 保留 30 天,0=永久保留 +``` + +### 7.3 更新项目 + +```bash +cd /www/TrendRadar + +# 拉取最新代码 +git pull + +# 拉取最新 Docker 镜像 +docker-compose pull trendradar + +# 重启容器 +docker-compose up -d trendradar +``` + +### 7.4 宝塔资源监控 + +在宝塔面板中可监控: +- **Docker** → 容器列表:查看 trendradar 运行状态 +- **监控**:查看 CPU、内存、磁盘使用情况 +- **安全** → **防火墙**:放行 8080 端口(如需公网访问) + +--- + +## 8. 常见问题 + +### Q1:容器启动后立刻退出 + +```bash +# 查看错误日志 +docker logs trendradar +``` + +常见原因:`config.yaml` 格式错误,可在线 YAML 校验工具检查。 + +### Q2:如何修改运行频率? + +Docker 方式修改 `.env.local` 中的 `CRON_SCHEDULE`: + +```bash +# 每 30 分钟(默认) +CRON_SCHEDULE=*/30 * * * * + +# 每小时 +CRON_SCHEDULE=0 * * * * + +# 每天早 8 点到晚 10 点,每半小时 +CRON_SCHEDULE=*/30 0-14 * * * +``` + +修改后重启容器: + +```bash +docker-compose down +docker-compose --env-file .env.local up -d trendradar +``` + +### Q3:如何查看生成的报告? + +- 直接访问 `http://你的IP:8080` +- 或查看文件:`/www/TrendRadar/output/index.html` +- 或用宝塔创建网站指向 output 目录 + +### Q4:部署后没有收到推送? + +1. 检查推送渠道配置是否正确 +2. 手动执行一次:`docker exec -it trendradar python manage.py run` +3. 查看日志是否有错误信息 +4. 检查 `frequency_words.txt` 关键词是否合理 + +### Q5:如何备份数据? + +直接备份 `/www/TrendRadar/output/` 目录即可: + +```bash +tar -czf trendradar-backup-$(date +%Y%m%d).tar.gz /www/TrendRadar/output/ +``` + +### Q6:宝塔 Docker 管理器无法拉取镜像? + +在 SSH 中手动拉取: + +```bash +docker pull wantcat/trendradar:latest +``` + +然后在宝塔 Docker 管理器中刷新即可看到已下载的镜像。 + +--- + +> **参考链接:** +> - [TrendRadar 官方文档](https://trendradar.sandev.cc/zh/) +> - [项目 GitHub 仓库](https://github.com/sansan0/TrendRadar) +> - [宝塔面板官方文档](https://www.bt.cn/new/document.html) \ No newline at end of file