renderer.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. # coding=utf-8
  2. """
  3. 通知内容渲染模块
  4. 提供多平台通知内容渲染功能,生成格式化的推送消息
  5. """
  6. from datetime import datetime
  7. from typing import Dict, List, Optional, Callable
  8. from trendradar.report.formatter import format_title_for_platform
  9. # 默认区域顺序
  10. DEFAULT_REGION_ORDER = ["hotlist", "rss", "new_items", "standalone", "ai_analysis"]
  11. def render_feishu_content(
  12. report_data: Dict,
  13. update_info: Optional[Dict] = None,
  14. mode: str = "daily",
  15. separator: str = "---",
  16. region_order: Optional[List[str]] = None,
  17. get_time_func: Optional[Callable[[], datetime]] = None,
  18. rss_items: Optional[list] = None,
  19. show_new_section: bool = True,
  20. ) -> str:
  21. """渲染飞书通知内容(支持热榜+RSS合并)
  22. Args:
  23. report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
  24. update_info: 版本更新信息(可选)
  25. mode: 报告模式 ("daily", "incremental", "current")
  26. separator: 内容分隔符
  27. region_order: 区域显示顺序列表
  28. get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now())
  29. rss_items: RSS 条目列表(可选,用于合并推送)
  30. show_new_section: 是否显示新增热点区域
  31. Returns:
  32. 格式化的飞书消息内容
  33. """
  34. if region_order is None:
  35. region_order = DEFAULT_REGION_ORDER
  36. # 生成热点词汇统计部分
  37. stats_content = ""
  38. if report_data["stats"]:
  39. stats_content += "📊 **热点词汇统计**\n\n"
  40. total_count = len(report_data["stats"])
  41. for i, stat in enumerate(report_data["stats"]):
  42. word = stat["word"]
  43. count = stat["count"]
  44. sequence_display = f"<font color='grey'>[{i + 1}/{total_count}]</font>"
  45. if count >= 10:
  46. stats_content += f"🔥 {sequence_display} **{word}** : <font color='red'>{count}</font> 条\n\n"
  47. elif count >= 5:
  48. stats_content += f"📈 {sequence_display} **{word}** : <font color='orange'>{count}</font> 条\n\n"
  49. else:
  50. stats_content += f"📌 {sequence_display} **{word}** : {count} 条\n\n"
  51. for j, title_data in enumerate(stat["titles"], 1):
  52. formatted_title = format_title_for_platform(
  53. "feishu", title_data, show_source=True
  54. )
  55. stats_content += f" {j}. {formatted_title}\n"
  56. if j < len(stat["titles"]):
  57. stats_content += "\n"
  58. if i < len(report_data["stats"]) - 1:
  59. stats_content += f"\n{separator}\n\n"
  60. # 生成新增新闻部分
  61. new_titles_content = ""
  62. if show_new_section and report_data["new_titles"]:
  63. new_titles_content += (
  64. f"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
  65. )
  66. for source_data in report_data["new_titles"]:
  67. new_titles_content += (
  68. f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n"
  69. )
  70. for j, title_data in enumerate(source_data["titles"], 1):
  71. title_data_copy = title_data.copy()
  72. title_data_copy["is_new"] = False
  73. formatted_title = format_title_for_platform(
  74. "feishu", title_data_copy, show_source=False
  75. )
  76. new_titles_content += f" {j}. {formatted_title}\n"
  77. new_titles_content += "\n"
  78. # RSS 内容
  79. rss_content = ""
  80. if rss_items:
  81. rss_content = _render_rss_section_feishu(rss_items, separator)
  82. # 准备各区域内容映射
  83. region_contents = {
  84. "hotlist": stats_content,
  85. "new_items": new_titles_content,
  86. "rss": rss_content,
  87. }
  88. # 按 region_order 顺序组装内容
  89. text_content = ""
  90. for region in region_order:
  91. content = region_contents.get(region, "")
  92. if content:
  93. if text_content:
  94. text_content += f"\n{separator}\n\n"
  95. text_content += content
  96. if not text_content:
  97. if mode == "incremental":
  98. mode_text = "增量模式下暂无新增匹配的热点词汇"
  99. elif mode == "current":
  100. mode_text = "当前榜单模式下暂无匹配的热点词汇"
  101. else:
  102. mode_text = "暂无匹配的热点词汇"
  103. text_content = f"📭 {mode_text}\n\n"
  104. if report_data["failed_ids"]:
  105. if text_content and "暂无匹配" not in text_content:
  106. text_content += f"\n{separator}\n\n"
  107. text_content += "⚠️ **数据获取失败的平台:**\n\n"
  108. for i, id_value in enumerate(report_data["failed_ids"], 1):
  109. text_content += f" • <font color='red'>{id_value}</font>\n"
  110. # 获取当前时间
  111. now = get_time_func() if get_time_func else datetime.now()
  112. text_content += (
  113. f"\n\n<font color='grey'>更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}</font>"
  114. )
  115. if update_info:
  116. text_content += f"\n<font color='grey'>TrendRadar 发现新版本 {update_info['remote_version']},当前 {update_info['current_version']}</font>"
  117. return text_content
  118. def render_dingtalk_content(
  119. report_data: Dict,
  120. update_info: Optional[Dict] = None,
  121. mode: str = "daily",
  122. region_order: Optional[List[str]] = None,
  123. get_time_func: Optional[Callable[[], datetime]] = None,
  124. rss_items: Optional[list] = None,
  125. show_new_section: bool = True,
  126. ) -> str:
  127. """渲染钉钉通知内容(支持热榜+RSS合并)
  128. Args:
  129. report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
  130. update_info: 版本更新信息(可选)
  131. mode: 报告模式 ("daily", "incremental", "current")
  132. region_order: 区域显示顺序列表
  133. get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now())
  134. rss_items: RSS 条目列表(可选,用于合并推送)
  135. show_new_section: 是否显示新增热点区域
  136. Returns:
  137. 格式化的钉钉消息内容
  138. """
  139. if region_order is None:
  140. region_order = DEFAULT_REGION_ORDER
  141. total_titles = sum(
  142. len(stat["titles"]) for stat in report_data["stats"] if stat["count"] > 0
  143. )
  144. now = get_time_func() if get_time_func else datetime.now()
  145. # 头部信息
  146. header_content = f"**总新闻数:** {total_titles}\n\n"
  147. header_content += f"**时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
  148. header_content += "**类型:** 热点分析报告\n\n"
  149. header_content += "---\n\n"
  150. # 生成热点词汇统计部分
  151. stats_content = ""
  152. if report_data["stats"]:
  153. stats_content += "📊 **热点词汇统计**\n\n"
  154. total_count = len(report_data["stats"])
  155. for i, stat in enumerate(report_data["stats"]):
  156. word = stat["word"]
  157. count = stat["count"]
  158. sequence_display = f"[{i + 1}/{total_count}]"
  159. if count >= 10:
  160. stats_content += f"🔥 {sequence_display} **{word}** : **{count}** 条\n\n"
  161. elif count >= 5:
  162. stats_content += f"📈 {sequence_display} **{word}** : **{count}** 条\n\n"
  163. else:
  164. stats_content += f"📌 {sequence_display} **{word}** : {count} 条\n\n"
  165. for j, title_data in enumerate(stat["titles"], 1):
  166. formatted_title = format_title_for_platform(
  167. "dingtalk", title_data, show_source=True
  168. )
  169. stats_content += f" {j}. {formatted_title}\n"
  170. if j < len(stat["titles"]):
  171. stats_content += "\n"
  172. if i < len(report_data["stats"]) - 1:
  173. stats_content += "\n---\n\n"
  174. # 生成新增新闻部分
  175. new_titles_content = ""
  176. if show_new_section and report_data["new_titles"]:
  177. new_titles_content += (
  178. f"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
  179. )
  180. for source_data in report_data["new_titles"]:
  181. new_titles_content += f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
  182. for j, title_data in enumerate(source_data["titles"], 1):
  183. title_data_copy = title_data.copy()
  184. title_data_copy["is_new"] = False
  185. formatted_title = format_title_for_platform(
  186. "dingtalk", title_data_copy, show_source=False
  187. )
  188. new_titles_content += f" {j}. {formatted_title}\n"
  189. new_titles_content += "\n"
  190. # RSS 内容
  191. rss_content = ""
  192. if rss_items:
  193. rss_content = _render_rss_section_markdown(rss_items)
  194. # 准备各区域内容映射
  195. region_contents = {
  196. "hotlist": stats_content,
  197. "new_items": new_titles_content,
  198. "rss": rss_content,
  199. }
  200. # 按 region_order 顺序组装内容
  201. text_content = header_content
  202. has_content = False
  203. for region in region_order:
  204. content = region_contents.get(region, "")
  205. if content:
  206. if has_content:
  207. text_content += "\n---\n\n"
  208. text_content += content
  209. has_content = True
  210. if not has_content:
  211. if mode == "incremental":
  212. mode_text = "增量模式下暂无新增匹配的热点词汇"
  213. elif mode == "current":
  214. mode_text = "当前榜单模式下暂无匹配的热点词汇"
  215. else:
  216. mode_text = "暂无匹配的热点词汇"
  217. text_content += f"📭 {mode_text}\n\n"
  218. if report_data["failed_ids"]:
  219. if "暂无匹配" not in text_content:
  220. text_content += "\n---\n\n"
  221. text_content += "⚠️ **数据获取失败的平台:**\n\n"
  222. for i, id_value in enumerate(report_data["failed_ids"], 1):
  223. text_content += f" • **{id_value}**\n"
  224. text_content += f"\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
  225. if update_info:
  226. text_content += f"\n> TrendRadar 发现新版本 **{update_info['remote_version']}**,当前 **{update_info['current_version']}**"
  227. return text_content
  228. def render_rss_feishu_content(
  229. rss_items: list,
  230. feeds_info: Optional[Dict] = None,
  231. separator: str = "---",
  232. get_time_func: Optional[Callable[[], datetime]] = None,
  233. ) -> str:
  234. """渲染 RSS 飞书通知内容
  235. Args:
  236. rss_items: RSS 条目列表,每个条目包含:
  237. - title: 标题
  238. - feed_id: RSS 源 ID
  239. - feed_name: RSS 源名称
  240. - url: 链接
  241. - published_at: 发布时间
  242. - summary: 摘要(可选)
  243. - author: 作者(可选)
  244. feeds_info: RSS 源 ID 到名称的映射
  245. separator: 内容分隔符
  246. get_time_func: 获取当前时间的函数(可选)
  247. Returns:
  248. 格式化的飞书消息内容
  249. """
  250. if not rss_items:
  251. now = get_time_func() if get_time_func else datetime.now()
  252. return f"📭 暂无新的 RSS 订阅内容\n\n<font color='grey'>更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}</font>"
  253. # 按 feed_id 分组
  254. feeds_map: Dict[str, list] = {}
  255. for item in rss_items:
  256. feed_id = item.get("feed_id", "unknown")
  257. if feed_id not in feeds_map:
  258. feeds_map[feed_id] = []
  259. feeds_map[feed_id].append(item)
  260. text_content = f"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\n\n"
  261. text_content += f"{separator}\n\n"
  262. for feed_id, items in feeds_map.items():
  263. feed_name = items[0].get("feed_name", feed_id) if items else feed_id
  264. if feeds_info and feed_id in feeds_info:
  265. feed_name = feeds_info[feed_id]
  266. text_content += f"**{feed_name}** ({len(items)} 条)\n\n"
  267. for i, item in enumerate(items, 1):
  268. title = item.get("title", "")
  269. url = item.get("url", "")
  270. published_at = item.get("published_at", "")
  271. if url:
  272. text_content += f" {i}. [{title}]({url})"
  273. else:
  274. text_content += f" {i}. {title}"
  275. if published_at:
  276. text_content += f" <font color='grey'>- {published_at}</font>"
  277. text_content += "\n"
  278. if i < len(items):
  279. text_content += "\n"
  280. text_content += f"\n{separator}\n\n"
  281. now = get_time_func() if get_time_func else datetime.now()
  282. text_content += f"<font color='grey'>更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}</font>"
  283. return text_content
  284. def render_rss_dingtalk_content(
  285. rss_items: list,
  286. feeds_info: Optional[Dict] = None,
  287. get_time_func: Optional[Callable[[], datetime]] = None,
  288. ) -> str:
  289. """渲染 RSS 钉钉通知内容
  290. Args:
  291. rss_items: RSS 条目列表
  292. feeds_info: RSS 源 ID 到名称的映射
  293. get_time_func: 获取当前时间的函数(可选)
  294. Returns:
  295. 格式化的钉钉消息内容
  296. """
  297. now = get_time_func() if get_time_func else datetime.now()
  298. if not rss_items:
  299. return f"📭 暂无新的 RSS 订阅内容\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
  300. # 按 feed_id 分组
  301. feeds_map: Dict[str, list] = {}
  302. for item in rss_items:
  303. feed_id = item.get("feed_id", "unknown")
  304. if feed_id not in feeds_map:
  305. feeds_map[feed_id] = []
  306. feeds_map[feed_id].append(item)
  307. # 头部信息
  308. text_content = f"**总条目数:** {len(rss_items)}\n\n"
  309. text_content += f"**时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
  310. text_content += "**类型:** RSS 订阅更新\n\n"
  311. text_content += "---\n\n"
  312. for feed_id, items in feeds_map.items():
  313. feed_name = items[0].get("feed_name", feed_id) if items else feed_id
  314. if feeds_info and feed_id in feeds_info:
  315. feed_name = feeds_info[feed_id]
  316. text_content += f"📰 **{feed_name}** ({len(items)} 条)\n\n"
  317. for i, item in enumerate(items, 1):
  318. title = item.get("title", "")
  319. url = item.get("url", "")
  320. published_at = item.get("published_at", "")
  321. if url:
  322. text_content += f" {i}. [{title}]({url})"
  323. else:
  324. text_content += f" {i}. {title}"
  325. if published_at:
  326. text_content += f" - {published_at}"
  327. text_content += "\n"
  328. if i < len(items):
  329. text_content += "\n"
  330. text_content += "\n---\n\n"
  331. text_content += f"> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
  332. return text_content
  333. def render_rss_markdown_content(
  334. rss_items: list,
  335. feeds_info: Optional[Dict] = None,
  336. get_time_func: Optional[Callable[[], datetime]] = None,
  337. ) -> str:
  338. """渲染 RSS 通用 Markdown 格式内容(企业微信、Bark、ntfy、Slack)
  339. Args:
  340. rss_items: RSS 条目列表
  341. feeds_info: RSS 源 ID 到名称的映射
  342. get_time_func: 获取当前时间的函数(可选)
  343. Returns:
  344. 格式化的 Markdown 消息内容
  345. """
  346. now = get_time_func() if get_time_func else datetime.now()
  347. if not rss_items:
  348. return f"📭 暂无新的 RSS 订阅内容\n\n更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
  349. # 按 feed_id 分组
  350. feeds_map: Dict[str, list] = {}
  351. for item in rss_items:
  352. feed_id = item.get("feed_id", "unknown")
  353. if feed_id not in feeds_map:
  354. feeds_map[feed_id] = []
  355. feeds_map[feed_id].append(item)
  356. text_content = f"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\n\n"
  357. for feed_id, items in feeds_map.items():
  358. feed_name = items[0].get("feed_name", feed_id) if items else feed_id
  359. if feeds_info and feed_id in feeds_info:
  360. feed_name = feeds_info[feed_id]
  361. text_content += f"**{feed_name}** ({len(items)} 条)\n"
  362. for i, item in enumerate(items, 1):
  363. title = item.get("title", "")
  364. url = item.get("url", "")
  365. published_at = item.get("published_at", "")
  366. if url:
  367. text_content += f" {i}. [{title}]({url})"
  368. else:
  369. text_content += f" {i}. {title}"
  370. if published_at:
  371. text_content += f" `{published_at}`"
  372. text_content += "\n"
  373. text_content += "\n"
  374. text_content += f"更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
  375. return text_content
  376. # === RSS 内容渲染辅助函数(用于合并推送) ===
  377. def _render_rss_section_feishu(rss_items: list, separator: str = "---") -> str:
  378. """渲染 RSS 内容区块(飞书格式,用于合并推送)"""
  379. if not rss_items:
  380. return ""
  381. # 按 feed_id 分组
  382. feeds_map: Dict[str, list] = {}
  383. for item in rss_items:
  384. feed_id = item.get("feed_id", "unknown")
  385. if feed_id not in feeds_map:
  386. feeds_map[feed_id] = []
  387. feeds_map[feed_id].append(item)
  388. text_content = f"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\n\n"
  389. for feed_id, items in feeds_map.items():
  390. feed_name = items[0].get("feed_name", feed_id) if items else feed_id
  391. text_content += f"**{feed_name}** ({len(items)} 条)\n\n"
  392. for i, item in enumerate(items, 1):
  393. title = item.get("title", "")
  394. url = item.get("url", "")
  395. published_at = item.get("published_at", "")
  396. if url:
  397. text_content += f" {i}. [{title}]({url})"
  398. else:
  399. text_content += f" {i}. {title}"
  400. if published_at:
  401. text_content += f" <font color='grey'>- {published_at}</font>"
  402. text_content += "\n"
  403. if i < len(items):
  404. text_content += "\n"
  405. text_content += "\n"
  406. return text_content.rstrip("\n")
  407. def _render_rss_section_markdown(rss_items: list) -> str:
  408. """渲染 RSS 内容区块(通用 Markdown 格式,用于合并推送)"""
  409. if not rss_items:
  410. return ""
  411. # 按 feed_id 分组
  412. feeds_map: Dict[str, list] = {}
  413. for item in rss_items:
  414. feed_id = item.get("feed_id", "unknown")
  415. if feed_id not in feeds_map:
  416. feeds_map[feed_id] = []
  417. feeds_map[feed_id].append(item)
  418. text_content = f"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\n\n"
  419. for feed_id, items in feeds_map.items():
  420. feed_name = items[0].get("feed_name", feed_id) if items else feed_id
  421. text_content += f"**{feed_name}** ({len(items)} 条)\n"
  422. for i, item in enumerate(items, 1):
  423. title = item.get("title", "")
  424. url = item.get("url", "")
  425. published_at = item.get("published_at", "")
  426. if url:
  427. text_content += f" {i}. [{title}]({url})"
  428. else:
  429. text_content += f" {i}. {title}"
  430. if published_at:
  431. text_content += f" `{published_at}`"
  432. text_content += "\n"
  433. text_content += "\n"
  434. return text_content.rstrip("\n")