dispatcher.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  1. # coding=utf-8
  2. """
  3. 通知调度器模块
  4. 提供统一的通知分发接口。
  5. 支持所有通知渠道的多账号配置,使用 `;` 分隔多个账号。
  6. 使用示例:
  7. dispatcher = NotificationDispatcher(config, get_time_func, split_content_func)
  8. results = dispatcher.dispatch_all(report_data, report_type, ...)
  9. """
  10. from __future__ import annotations
  11. from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
  12. from trendradar.core.config import (
  13. get_account_at_index,
  14. limit_accounts,
  15. parse_multi_account_config,
  16. validate_paired_configs,
  17. )
  18. from .senders import (
  19. send_to_bark,
  20. send_to_dingtalk,
  21. send_to_email,
  22. send_to_feishu,
  23. send_to_ntfy,
  24. send_to_slack,
  25. send_to_telegram,
  26. send_to_wework,
  27. send_to_generic_webhook,
  28. )
  29. # 类型检查时导入,运行时不导入(避免循环导入)
  30. if TYPE_CHECKING:
  31. from trendradar.ai import AIAnalysisResult, AITranslator
  32. class NotificationDispatcher:
  33. """
  34. 统一的多账号通知调度器
  35. 将多账号发送逻辑封装,提供简洁的 dispatch_all 接口。
  36. 内部处理账号解析、数量限制、配对验证等逻辑。
  37. """
  38. def __init__(
  39. self,
  40. config: Dict[str, Any],
  41. get_time_func: Callable,
  42. split_content_func: Callable,
  43. translator: Optional["AITranslator"] = None,
  44. ):
  45. """
  46. 初始化通知调度器
  47. Args:
  48. config: 完整的配置字典,包含所有通知渠道的配置
  49. get_time_func: 获取当前时间的函数
  50. split_content_func: 内容分批函数
  51. translator: AI 翻译器实例(可选)
  52. """
  53. self.config = config
  54. self.get_time_func = get_time_func
  55. self.split_content_func = split_content_func
  56. self.max_accounts = config.get("MAX_ACCOUNTS_PER_CHANNEL", 3)
  57. self.translator = translator
  58. def translate_content(
  59. self,
  60. report_data: Dict,
  61. rss_items: Optional[List[Dict]] = None,
  62. rss_new_items: Optional[List[Dict]] = None,
  63. standalone_data: Optional[Dict] = None,
  64. display_regions: Optional[Dict] = None,
  65. skip_rss: bool = False,
  66. ) -> tuple:
  67. """
  68. 翻译推送内容
  69. Args:
  70. report_data: 报告数据
  71. rss_items: RSS 统计条目
  72. rss_new_items: RSS 新增条目
  73. standalone_data: 独立展示区数据
  74. display_regions: 区域显示配置(不展示的区域跳过翻译)
  75. skip_rss: 跳过 RSS 和独立展示区翻译(当数据已在上游翻译过时使用)
  76. Returns:
  77. tuple: (翻译后的 report_data, rss_items, rss_new_items, standalone_data)
  78. """
  79. if not self.translator or not self.translator.enabled:
  80. return report_data, rss_items, rss_new_items, standalone_data
  81. import copy
  82. print(f"[翻译] 开始翻译内容到 {self.translator.target_language}...")
  83. scope = self.translator.scope
  84. display_regions = display_regions or {}
  85. # 深拷贝避免修改原始数据
  86. report_data = copy.deepcopy(report_data)
  87. rss_items = copy.deepcopy(rss_items) if rss_items else None
  88. rss_new_items = copy.deepcopy(rss_new_items) if rss_new_items else None
  89. standalone_data = copy.deepcopy(standalone_data) if standalone_data else None
  90. # 收集所有需要翻译的标题
  91. titles_to_translate = []
  92. title_locations = [] # 记录标题位置,用于回填
  93. # 1. 热榜标题(scope 开启 且 区域展示)
  94. if scope.get("HOTLIST", True) and display_regions.get("HOTLIST", True):
  95. for stat_idx, stat in enumerate(report_data.get("stats", [])):
  96. for title_idx, title_data in enumerate(stat.get("titles", [])):
  97. titles_to_translate.append(title_data.get("title", ""))
  98. title_locations.append(("stats", stat_idx, title_idx))
  99. # 2. 新增热点标题
  100. for source_idx, source in enumerate(report_data.get("new_titles", [])):
  101. for title_idx, title_data in enumerate(source.get("titles", [])):
  102. titles_to_translate.append(title_data.get("title", ""))
  103. title_locations.append(("new_titles", source_idx, title_idx))
  104. # 3. RSS 统计标题(结构与 stats 一致:[{word, count, titles: [{title, ...}]}])
  105. if not skip_rss and rss_items and scope.get("RSS", True) and display_regions.get("RSS", True):
  106. for stat_idx, stat in enumerate(rss_items):
  107. for title_idx, title_data in enumerate(stat.get("titles", [])):
  108. titles_to_translate.append(title_data.get("title", ""))
  109. title_locations.append(("rss_items", stat_idx, title_idx))
  110. # 4. RSS 新增标题(结构与 stats 一致)
  111. if not skip_rss and rss_new_items and scope.get("RSS", True) and display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True):
  112. for stat_idx, stat in enumerate(rss_new_items):
  113. for title_idx, title_data in enumerate(stat.get("titles", [])):
  114. titles_to_translate.append(title_data.get("title", ""))
  115. title_locations.append(("rss_new_items", stat_idx, title_idx))
  116. # 5. 独立展示区 - 热榜平台
  117. if standalone_data and scope.get("STANDALONE", True) and display_regions.get("STANDALONE", False):
  118. for plat_idx, platform in enumerate(standalone_data.get("platforms", [])):
  119. for item_idx, item in enumerate(platform.get("items", [])):
  120. titles_to_translate.append(item.get("title", ""))
  121. title_locations.append(("standalone_platforms", plat_idx, item_idx))
  122. # 6. 独立展示区 - RSS 源(跳过已翻译的)
  123. if not skip_rss:
  124. for feed_idx, feed in enumerate(standalone_data.get("rss_feeds", [])):
  125. for item_idx, item in enumerate(feed.get("items", [])):
  126. titles_to_translate.append(item.get("title", ""))
  127. title_locations.append(("standalone_rss", feed_idx, item_idx))
  128. if not titles_to_translate:
  129. print("[翻译] 没有需要翻译的内容")
  130. return report_data, rss_items, rss_new_items, standalone_data
  131. print(f"[翻译] 共 {len(titles_to_translate)} 条标题待翻译")
  132. # 批量翻译
  133. result = self.translator.translate_batch(titles_to_translate)
  134. if result.success_count == 0:
  135. print(f"[翻译] 翻译失败: {result.results[0].error if result.results else '未知错误'}")
  136. return report_data, rss_items, rss_new_items, standalone_data
  137. print(f"[翻译] 翻译完成: {result.success_count}/{result.total_count} 成功")
  138. # debug 模式:输出完整 prompt、AI 原始响应、逐条对照
  139. if self.config.get("DEBUG", False):
  140. if result.prompt:
  141. print(f"[翻译][DEBUG] === 发送给 AI 的 Prompt ===")
  142. print(result.prompt)
  143. print(f"[翻译][DEBUG] === Prompt 结束 ===")
  144. if result.raw_response:
  145. print(f"[翻译][DEBUG] === AI 原始响应 ===")
  146. print(result.raw_response)
  147. print(f"[翻译][DEBUG] === 响应结束 ===")
  148. # 行数不匹配警告
  149. expected = len(titles_to_translate)
  150. if result.parsed_count != expected:
  151. print(f"[翻译][DEBUG] ⚠️ 行数不匹配:期望 {expected} 条,AI 返回 {result.parsed_count} 条")
  152. # 逐条对照
  153. unchanged_count = 0
  154. for i, res in enumerate(result.results):
  155. if not res.success and res.error:
  156. print(f"[翻译][DEBUG] [{i+1}] !! 失败: {res.error}")
  157. elif res.original_text == res.translated_text:
  158. unchanged_count += 1
  159. else:
  160. print(f"[翻译][DEBUG] [{i+1}] {res.original_text} => {res.translated_text}")
  161. if unchanged_count > 0:
  162. print(f"[翻译][DEBUG] (另有 {unchanged_count} 条未变化,已省略)")
  163. # 回填翻译结果
  164. for i, (loc_type, idx1, idx2) in enumerate(title_locations):
  165. if i < len(result.results) and result.results[i].success:
  166. translated = result.results[i].translated_text
  167. if loc_type == "stats":
  168. report_data["stats"][idx1]["titles"][idx2]["title"] = translated
  169. elif loc_type == "new_titles":
  170. report_data["new_titles"][idx1]["titles"][idx2]["title"] = translated
  171. elif loc_type == "rss_items" and rss_items:
  172. rss_items[idx1]["titles"][idx2]["title"] = translated
  173. elif loc_type == "rss_new_items" and rss_new_items:
  174. rss_new_items[idx1]["titles"][idx2]["title"] = translated
  175. elif loc_type == "standalone_platforms" and standalone_data:
  176. standalone_data["platforms"][idx1]["items"][idx2]["title"] = translated
  177. elif loc_type == "standalone_rss" and standalone_data:
  178. standalone_data["rss_feeds"][idx1]["items"][idx2]["title"] = translated
  179. return report_data, rss_items, rss_new_items, standalone_data
  180. def dispatch_all(
  181. self,
  182. report_data: Dict,
  183. report_type: str,
  184. update_info: Optional[Dict] = None,
  185. proxy_url: Optional[str] = None,
  186. mode: str = "daily",
  187. html_file_path: Optional[str] = None,
  188. rss_items: Optional[List[Dict]] = None,
  189. rss_new_items: Optional[List[Dict]] = None,
  190. ai_analysis: Optional[AIAnalysisResult] = None,
  191. standalone_data: Optional[Dict] = None,
  192. skip_translation: bool = False,
  193. ) -> Dict[str, bool]:
  194. """
  195. 分发通知到所有已配置的渠道(支持热榜+RSS合并推送+AI分析+独立展示区)
  196. Args:
  197. report_data: 报告数据(由 prepare_report_data 生成)
  198. report_type: 报告类型(如 "全天汇总"、"当前榜单"、"增量分析")
  199. update_info: 版本更新信息(可选)
  200. proxy_url: 代理 URL(可选)
  201. mode: 报告模式 (daily/current/incremental)
  202. html_file_path: HTML 报告文件路径(邮件使用)
  203. rss_items: RSS 统计条目列表(用于 RSS 统计区块)
  204. rss_new_items: RSS 新增条目列表(用于 RSS 新增区块)
  205. ai_analysis: AI 分析结果(可选)
  206. standalone_data: 独立展示区数据(可选)
  207. skip_translation: 跳过翻译(当数据已在上游翻译过时使用)
  208. Returns:
  209. Dict[str, bool]: 每个渠道的发送结果,key 为渠道名,value 为是否成功
  210. """
  211. results = {}
  212. # 获取区域显示配置
  213. display_regions = self.config.get("DISPLAY", {}).get("REGIONS", {})
  214. # 执行翻译(如果启用,根据 display_regions 跳过不展示的区域)
  215. # skip_translation=True 时,RSS 已在上游翻译过,跳过 RSS 重复翻译
  216. if not skip_translation:
  217. report_data, rss_items, rss_new_items, standalone_data = self.translate_content(
  218. report_data, rss_items, rss_new_items, standalone_data, display_regions
  219. )
  220. else:
  221. # RSS 已翻译,仅翻译热榜 report_data 和独立展示区热榜部分
  222. report_data, _, _, standalone_data = self.translate_content(
  223. report_data, standalone_data=standalone_data, display_regions=display_regions,
  224. skip_rss=True,
  225. )
  226. # 飞书
  227. if self.config.get("FEISHU_WEBHOOK_URL"):
  228. results["feishu"] = self._send_feishu(
  229. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  230. ai_analysis, display_regions, standalone_data
  231. )
  232. # 钉钉
  233. if self.config.get("DINGTALK_WEBHOOK_URL"):
  234. results["dingtalk"] = self._send_dingtalk(
  235. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  236. ai_analysis, display_regions, standalone_data
  237. )
  238. # 企业微信
  239. if self.config.get("WEWORK_WEBHOOK_URL"):
  240. results["wework"] = self._send_wework(
  241. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  242. ai_analysis, display_regions, standalone_data
  243. )
  244. # Telegram(需要配对验证)
  245. if self.config.get("TELEGRAM_BOT_TOKEN") and self.config.get("TELEGRAM_CHAT_ID"):
  246. results["telegram"] = self._send_telegram(
  247. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  248. ai_analysis, display_regions, standalone_data
  249. )
  250. # ntfy(需要配对验证)
  251. if self.config.get("NTFY_SERVER_URL") and self.config.get("NTFY_TOPIC"):
  252. results["ntfy"] = self._send_ntfy(
  253. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  254. ai_analysis, display_regions, standalone_data
  255. )
  256. # Bark
  257. if self.config.get("BARK_URL"):
  258. results["bark"] = self._send_bark(
  259. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  260. ai_analysis, display_regions, standalone_data
  261. )
  262. # Slack
  263. if self.config.get("SLACK_WEBHOOK_URL"):
  264. results["slack"] = self._send_slack(
  265. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  266. ai_analysis, display_regions, standalone_data
  267. )
  268. # 通用 Webhook
  269. if self.config.get("GENERIC_WEBHOOK_URL"):
  270. results["generic_webhook"] = self._send_generic_webhook(
  271. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  272. ai_analysis, display_regions, standalone_data
  273. )
  274. # 邮件(保持原有逻辑,已支持多收件人,AI 分析已嵌入 HTML)
  275. if (
  276. self.config.get("EMAIL_FROM")
  277. and self.config.get("EMAIL_PASSWORD")
  278. and self.config.get("EMAIL_TO")
  279. ):
  280. results["email"] = self._send_email(report_type, html_file_path)
  281. return results
  282. def _send_to_multi_accounts(
  283. self,
  284. channel_name: str,
  285. config_value: str,
  286. send_func: Callable[..., bool],
  287. **kwargs,
  288. ) -> bool:
  289. """
  290. 通用多账号发送逻辑
  291. Args:
  292. channel_name: 渠道名称(用于日志和账号数量限制提示)
  293. config_value: 配置值(可能包含多个账号,用 ; 分隔)
  294. send_func: 发送函数,签名为 (account, account_label=..., **kwargs) -> bool
  295. **kwargs: 传递给发送函数的其他参数
  296. Returns:
  297. bool: 任一账号发送成功则返回 True
  298. """
  299. accounts = parse_multi_account_config(config_value)
  300. if not accounts:
  301. return False
  302. accounts = limit_accounts(accounts, self.max_accounts, channel_name)
  303. results = []
  304. for i, account in enumerate(accounts):
  305. if account:
  306. account_label = f"账号{i+1}" if len(accounts) > 1 else ""
  307. result = send_func(account, account_label=account_label, **kwargs)
  308. results.append(result)
  309. return any(results) if results else False
  310. def _apply_display_regions(
  311. self,
  312. report_data: Dict,
  313. display_regions: Optional[Dict],
  314. rss_items: Optional[List[Dict]] = None,
  315. rss_new_items: Optional[List[Dict]] = None,
  316. ai_analysis: Optional[AIAnalysisResult] = None,
  317. standalone_data: Optional[Dict] = None,
  318. ) -> tuple:
  319. """根据 display_regions 过滤各区域数据,返回 (report_data, rss_items, rss_new_items, ai_analysis, standalone_data)"""
  320. display_regions = display_regions or {}
  321. if not display_regions.get("HOTLIST", True):
  322. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  323. show_rss = display_regions.get("RSS", True)
  324. return (
  325. report_data,
  326. rss_items if show_rss else None,
  327. rss_new_items if (show_rss and display_regions.get("NEW_ITEMS", True)) else None,
  328. ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  329. standalone_data if display_regions.get("STANDALONE", False) else None,
  330. )
  331. def _send_feishu(
  332. self,
  333. report_data: Dict,
  334. report_type: str,
  335. update_info: Optional[Dict],
  336. proxy_url: Optional[str],
  337. mode: str,
  338. rss_items: Optional[List[Dict]] = None,
  339. rss_new_items: Optional[List[Dict]] = None,
  340. ai_analysis: Optional[AIAnalysisResult] = None,
  341. display_regions: Optional[Dict] = None,
  342. standalone_data: Optional[Dict] = None,
  343. ) -> bool:
  344. """发送到飞书(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  345. rd, ri, rn, ai, sd = self._apply_display_regions(
  346. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  347. )
  348. return self._send_to_multi_accounts(
  349. channel_name="飞书",
  350. config_value=self.config["FEISHU_WEBHOOK_URL"],
  351. send_func=lambda url, account_label: send_to_feishu(
  352. webhook_url=url,
  353. report_data=rd,
  354. report_type=report_type,
  355. update_info=update_info,
  356. proxy_url=proxy_url,
  357. mode=mode,
  358. account_label=account_label,
  359. batch_size=self.config.get("FEISHU_BATCH_SIZE", 29000),
  360. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  361. split_content_func=self.split_content_func,
  362. get_time_func=self.get_time_func,
  363. rss_items=ri,
  364. rss_new_items=rn,
  365. ai_analysis=ai,
  366. display_regions=display_regions or {},
  367. standalone_data=sd,
  368. ),
  369. )
  370. def _send_dingtalk(
  371. self,
  372. report_data: Dict,
  373. report_type: str,
  374. update_info: Optional[Dict],
  375. proxy_url: Optional[str],
  376. mode: str,
  377. rss_items: Optional[List[Dict]] = None,
  378. rss_new_items: Optional[List[Dict]] = None,
  379. ai_analysis: Optional[AIAnalysisResult] = None,
  380. display_regions: Optional[Dict] = None,
  381. standalone_data: Optional[Dict] = None,
  382. ) -> bool:
  383. """发送到钉钉(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  384. rd, ri, rn, ai, sd = self._apply_display_regions(
  385. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  386. )
  387. return self._send_to_multi_accounts(
  388. channel_name="钉钉",
  389. config_value=self.config["DINGTALK_WEBHOOK_URL"],
  390. send_func=lambda url, account_label: send_to_dingtalk(
  391. webhook_url=url,
  392. report_data=rd,
  393. report_type=report_type,
  394. update_info=update_info,
  395. proxy_url=proxy_url,
  396. mode=mode,
  397. account_label=account_label,
  398. batch_size=self.config.get("DINGTALK_BATCH_SIZE", 20000),
  399. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  400. split_content_func=self.split_content_func,
  401. rss_items=ri,
  402. rss_new_items=rn,
  403. ai_analysis=ai,
  404. display_regions=display_regions or {},
  405. standalone_data=sd,
  406. ),
  407. )
  408. def _send_wework(
  409. self,
  410. report_data: Dict,
  411. report_type: str,
  412. update_info: Optional[Dict],
  413. proxy_url: Optional[str],
  414. mode: str,
  415. rss_items: Optional[List[Dict]] = None,
  416. rss_new_items: Optional[List[Dict]] = None,
  417. ai_analysis: Optional[AIAnalysisResult] = None,
  418. display_regions: Optional[Dict] = None,
  419. standalone_data: Optional[Dict] = None,
  420. ) -> bool:
  421. """发送到企业微信(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  422. rd, ri, rn, ai, sd = self._apply_display_regions(
  423. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  424. )
  425. return self._send_to_multi_accounts(
  426. channel_name="企业微信",
  427. config_value=self.config["WEWORK_WEBHOOK_URL"],
  428. send_func=lambda url, account_label: send_to_wework(
  429. webhook_url=url,
  430. report_data=rd,
  431. report_type=report_type,
  432. update_info=update_info,
  433. proxy_url=proxy_url,
  434. mode=mode,
  435. account_label=account_label,
  436. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  437. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  438. msg_type=self.config.get("WEWORK_MSG_TYPE", "markdown"),
  439. split_content_func=self.split_content_func,
  440. rss_items=ri,
  441. rss_new_items=rn,
  442. ai_analysis=ai,
  443. display_regions=display_regions or {},
  444. standalone_data=sd,
  445. ),
  446. )
  447. def _send_telegram(
  448. self,
  449. report_data: Dict,
  450. report_type: str,
  451. update_info: Optional[Dict],
  452. proxy_url: Optional[str],
  453. mode: str,
  454. rss_items: Optional[List[Dict]] = None,
  455. rss_new_items: Optional[List[Dict]] = None,
  456. ai_analysis: Optional[AIAnalysisResult] = None,
  457. display_regions: Optional[Dict] = None,
  458. standalone_data: Optional[Dict] = None,
  459. ) -> bool:
  460. """发送到 Telegram(多账号,需验证 token 和 chat_id 配对,支持热榜+RSS合并+AI分析+独立展示区)"""
  461. report_data, rss_items, rss_new_items, ai_analysis, standalone_data = self._apply_display_regions(
  462. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  463. )
  464. display_regions = display_regions or {}
  465. telegram_tokens = parse_multi_account_config(self.config["TELEGRAM_BOT_TOKEN"])
  466. telegram_chat_ids = parse_multi_account_config(self.config["TELEGRAM_CHAT_ID"])
  467. if not telegram_tokens or not telegram_chat_ids:
  468. return False
  469. valid, count = validate_paired_configs(
  470. {"bot_token": telegram_tokens, "chat_id": telegram_chat_ids},
  471. "Telegram",
  472. required_keys=["bot_token", "chat_id"],
  473. )
  474. if not valid or count == 0:
  475. return False
  476. telegram_tokens = limit_accounts(telegram_tokens, self.max_accounts, "Telegram")
  477. telegram_chat_ids = telegram_chat_ids[: len(telegram_tokens)]
  478. results = []
  479. for i in range(len(telegram_tokens)):
  480. token = telegram_tokens[i]
  481. chat_id = telegram_chat_ids[i]
  482. if token and chat_id:
  483. account_label = f"账号{i+1}" if len(telegram_tokens) > 1 else ""
  484. result = send_to_telegram(
  485. bot_token=token,
  486. chat_id=chat_id,
  487. report_data=report_data,
  488. report_type=report_type,
  489. update_info=update_info,
  490. proxy_url=proxy_url,
  491. mode=mode,
  492. account_label=account_label,
  493. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  494. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  495. split_content_func=self.split_content_func,
  496. rss_items=rss_items,
  497. rss_new_items=rss_new_items,
  498. ai_analysis=ai_analysis,
  499. display_regions=display_regions,
  500. standalone_data=standalone_data,
  501. )
  502. results.append(result)
  503. return any(results) if results else False
  504. def _send_ntfy(
  505. self,
  506. report_data: Dict,
  507. report_type: str,
  508. update_info: Optional[Dict],
  509. proxy_url: Optional[str],
  510. mode: str,
  511. rss_items: Optional[List[Dict]] = None,
  512. rss_new_items: Optional[List[Dict]] = None,
  513. ai_analysis: Optional[AIAnalysisResult] = None,
  514. display_regions: Optional[Dict] = None,
  515. standalone_data: Optional[Dict] = None,
  516. ) -> bool:
  517. """发送到 ntfy(多账号,需验证 topic 和 token 配对,支持热榜+RSS合并+AI分析+独立展示区)"""
  518. report_data, rss_items, rss_new_items, ai_analysis, standalone_data = self._apply_display_regions(
  519. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  520. )
  521. display_regions = display_regions or {}
  522. ntfy_server_url = self.config["NTFY_SERVER_URL"]
  523. ntfy_topics = parse_multi_account_config(self.config["NTFY_TOPIC"])
  524. ntfy_tokens = parse_multi_account_config(self.config.get("NTFY_TOKEN", ""))
  525. if not ntfy_server_url or not ntfy_topics:
  526. return False
  527. if ntfy_tokens and len(ntfy_tokens) != len(ntfy_topics):
  528. print(
  529. f"❌ ntfy 配置错误:topic 数量({len(ntfy_topics)})与 token 数量({len(ntfy_tokens)})不一致,跳过 ntfy 推送"
  530. )
  531. return False
  532. ntfy_topics = limit_accounts(ntfy_topics, self.max_accounts, "ntfy")
  533. if ntfy_tokens:
  534. ntfy_tokens = ntfy_tokens[: len(ntfy_topics)]
  535. results = []
  536. for i, topic in enumerate(ntfy_topics):
  537. if topic:
  538. token = get_account_at_index(ntfy_tokens, i, "") if ntfy_tokens else ""
  539. account_label = f"账号{i+1}" if len(ntfy_topics) > 1 else ""
  540. result = send_to_ntfy(
  541. server_url=ntfy_server_url,
  542. topic=topic,
  543. token=token,
  544. report_data=report_data,
  545. report_type=report_type,
  546. update_info=update_info,
  547. proxy_url=proxy_url,
  548. mode=mode,
  549. account_label=account_label,
  550. batch_size=3800,
  551. split_content_func=self.split_content_func,
  552. rss_items=rss_items,
  553. rss_new_items=rss_new_items,
  554. ai_analysis=ai_analysis,
  555. display_regions=display_regions,
  556. standalone_data=standalone_data,
  557. )
  558. results.append(result)
  559. return any(results) if results else False
  560. def _send_bark(
  561. self,
  562. report_data: Dict,
  563. report_type: str,
  564. update_info: Optional[Dict],
  565. proxy_url: Optional[str],
  566. mode: str,
  567. rss_items: Optional[List[Dict]] = None,
  568. rss_new_items: Optional[List[Dict]] = None,
  569. ai_analysis: Optional[AIAnalysisResult] = None,
  570. display_regions: Optional[Dict] = None,
  571. standalone_data: Optional[Dict] = None,
  572. ) -> bool:
  573. """发送到 Bark(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  574. rd, ri, rn, ai, sd = self._apply_display_regions(
  575. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  576. )
  577. return self._send_to_multi_accounts(
  578. channel_name="Bark",
  579. config_value=self.config["BARK_URL"],
  580. send_func=lambda url, account_label: send_to_bark(
  581. bark_url=url,
  582. report_data=rd,
  583. report_type=report_type,
  584. update_info=update_info,
  585. proxy_url=proxy_url,
  586. mode=mode,
  587. account_label=account_label,
  588. batch_size=self.config.get("BARK_BATCH_SIZE", 3600),
  589. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  590. split_content_func=self.split_content_func,
  591. rss_items=ri,
  592. rss_new_items=rn,
  593. ai_analysis=ai,
  594. display_regions=display_regions or {},
  595. standalone_data=sd,
  596. ),
  597. )
  598. def _send_slack(
  599. self,
  600. report_data: Dict,
  601. report_type: str,
  602. update_info: Optional[Dict],
  603. proxy_url: Optional[str],
  604. mode: str,
  605. rss_items: Optional[List[Dict]] = None,
  606. rss_new_items: Optional[List[Dict]] = None,
  607. ai_analysis: Optional[AIAnalysisResult] = None,
  608. display_regions: Optional[Dict] = None,
  609. standalone_data: Optional[Dict] = None,
  610. ) -> bool:
  611. """发送到 Slack(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  612. rd, ri, rn, ai, sd = self._apply_display_regions(
  613. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  614. )
  615. return self._send_to_multi_accounts(
  616. channel_name="Slack",
  617. config_value=self.config["SLACK_WEBHOOK_URL"],
  618. send_func=lambda url, account_label: send_to_slack(
  619. webhook_url=url,
  620. report_data=rd,
  621. report_type=report_type,
  622. update_info=update_info,
  623. proxy_url=proxy_url,
  624. mode=mode,
  625. account_label=account_label,
  626. batch_size=self.config.get("SLACK_BATCH_SIZE", 4000),
  627. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  628. split_content_func=self.split_content_func,
  629. rss_items=ri,
  630. rss_new_items=rn,
  631. ai_analysis=ai,
  632. display_regions=display_regions or {},
  633. standalone_data=sd,
  634. ),
  635. )
  636. def _send_generic_webhook(
  637. self,
  638. report_data: Dict,
  639. report_type: str,
  640. update_info: Optional[Dict],
  641. proxy_url: Optional[str],
  642. mode: str,
  643. rss_items: Optional[List[Dict]] = None,
  644. rss_new_items: Optional[List[Dict]] = None,
  645. ai_analysis: Optional[AIAnalysisResult] = None,
  646. display_regions: Optional[Dict] = None,
  647. standalone_data: Optional[Dict] = None,
  648. ) -> bool:
  649. """发送到通用 Webhook(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  650. report_data, rss_items, rss_new_items, ai_analysis, standalone_data = self._apply_display_regions(
  651. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  652. )
  653. display_regions = display_regions or {}
  654. urls = parse_multi_account_config(self.config.get("GENERIC_WEBHOOK_URL", ""))
  655. templates = parse_multi_account_config(self.config.get("GENERIC_WEBHOOK_TEMPLATE", ""))
  656. if not urls:
  657. return False
  658. urls = limit_accounts(urls, self.max_accounts, "通用Webhook")
  659. results = []
  660. for i, url in enumerate(urls):
  661. if not url:
  662. continue
  663. template = ""
  664. if templates:
  665. if i < len(templates):
  666. template = templates[i]
  667. elif len(templates) == 1:
  668. template = templates[0]
  669. account_label = f"账号{i+1}" if len(urls) > 1 else ""
  670. result = send_to_generic_webhook(
  671. webhook_url=url,
  672. payload_template=template,
  673. report_data=report_data,
  674. report_type=report_type,
  675. update_info=update_info,
  676. proxy_url=proxy_url,
  677. mode=mode,
  678. account_label=account_label,
  679. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  680. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  681. split_content_func=self.split_content_func,
  682. rss_items=rss_items,
  683. rss_new_items=rss_new_items,
  684. ai_analysis=ai_analysis,
  685. display_regions=display_regions,
  686. standalone_data=standalone_data,
  687. )
  688. results.append(result)
  689. return any(results) if results else False
  690. def _send_email(
  691. self,
  692. report_type: str,
  693. html_file_path: Optional[str],
  694. ) -> bool:
  695. """发送邮件(保持原有逻辑,已支持多收件人)
  696. Note:
  697. AI 分析内容已在 HTML 生成时嵌入,无需在此传递
  698. """
  699. return send_to_email(
  700. from_email=self.config["EMAIL_FROM"],
  701. password=self.config["EMAIL_PASSWORD"],
  702. to_email=self.config["EMAIL_TO"],
  703. report_type=report_type,
  704. html_file_path=html_file_path,
  705. custom_smtp_server=self.config.get("EMAIL_SMTP_SERVER", ""),
  706. custom_smtp_port=self.config.get("EMAIL_SMTP_PORT", ""),
  707. get_time_func=self.get_time_func,
  708. )