dispatcher.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802
  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 not translated or not translated.strip():
  168. continue
  169. if loc_type == "stats":
  170. report_data["stats"][idx1]["titles"][idx2]["title"] = translated
  171. elif loc_type == "new_titles":
  172. report_data["new_titles"][idx1]["titles"][idx2]["title"] = translated
  173. elif loc_type == "rss_items" and rss_items:
  174. rss_items[idx1]["titles"][idx2]["title"] = translated
  175. elif loc_type == "rss_new_items" and rss_new_items:
  176. rss_new_items[idx1]["titles"][idx2]["title"] = translated
  177. elif loc_type == "standalone_platforms" and standalone_data:
  178. standalone_data["platforms"][idx1]["items"][idx2]["title"] = translated
  179. elif loc_type == "standalone_rss" and standalone_data:
  180. standalone_data["rss_feeds"][idx1]["items"][idx2]["title"] = translated
  181. return report_data, rss_items, rss_new_items, standalone_data
  182. def dispatch_all(
  183. self,
  184. report_data: Dict,
  185. report_type: str,
  186. update_info: Optional[Dict] = None,
  187. proxy_url: Optional[str] = None,
  188. mode: str = "daily",
  189. html_file_path: Optional[str] = None,
  190. rss_items: Optional[List[Dict]] = None,
  191. rss_new_items: Optional[List[Dict]] = None,
  192. ai_analysis: Optional[AIAnalysisResult] = None,
  193. standalone_data: Optional[Dict] = None,
  194. skip_translation: bool = False,
  195. ) -> Dict[str, bool]:
  196. """
  197. 分发通知到所有已配置的渠道(支持热榜+RSS合并推送+AI分析+独立展示区)
  198. Args:
  199. report_data: 报告数据(由 prepare_report_data 生成)
  200. report_type: 报告类型(如 "全天汇总"、"当前榜单"、"增量分析")
  201. update_info: 版本更新信息(可选)
  202. proxy_url: 代理 URL(可选)
  203. mode: 报告模式 (daily/current/incremental)
  204. html_file_path: HTML 报告文件路径(邮件使用)
  205. rss_items: RSS 统计条目列表(用于 RSS 统计区块)
  206. rss_new_items: RSS 新增条目列表(用于 RSS 新增区块)
  207. ai_analysis: AI 分析结果(可选)
  208. standalone_data: 独立展示区数据(可选)
  209. skip_translation: 跳过翻译(当数据已在上游翻译过时使用)
  210. Returns:
  211. Dict[str, bool]: 每个渠道的发送结果,key 为渠道名,value 为是否成功
  212. """
  213. results = {}
  214. # 获取区域显示配置
  215. display_regions = self.config.get("DISPLAY", {}).get("REGIONS", {})
  216. # 执行翻译(如果启用,根据 display_regions 跳过不展示的区域)
  217. # skip_translation=True 时,RSS 已在上游翻译过,跳过 RSS 重复翻译
  218. if not skip_translation:
  219. report_data, rss_items, rss_new_items, standalone_data = self.translate_content(
  220. report_data, rss_items, rss_new_items, standalone_data, display_regions
  221. )
  222. else:
  223. # RSS 已翻译,仅翻译热榜 report_data 和独立展示区热榜部分
  224. report_data, _, _, standalone_data = self.translate_content(
  225. report_data, standalone_data=standalone_data, display_regions=display_regions,
  226. skip_rss=True,
  227. )
  228. # 飞书
  229. if self.config.get("FEISHU_WEBHOOK_URL"):
  230. results["feishu"] = self._send_feishu(
  231. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  232. ai_analysis, display_regions, standalone_data
  233. )
  234. # 钉钉
  235. if self.config.get("DINGTALK_WEBHOOK_URL"):
  236. results["dingtalk"] = self._send_dingtalk(
  237. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  238. ai_analysis, display_regions, standalone_data
  239. )
  240. # 企业微信
  241. if self.config.get("WEWORK_WEBHOOK_URL"):
  242. results["wework"] = self._send_wework(
  243. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  244. ai_analysis, display_regions, standalone_data
  245. )
  246. # Telegram(需要配对验证)
  247. if self.config.get("TELEGRAM_BOT_TOKEN") and self.config.get("TELEGRAM_CHAT_ID"):
  248. results["telegram"] = self._send_telegram(
  249. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  250. ai_analysis, display_regions, standalone_data
  251. )
  252. # ntfy(需要配对验证)
  253. if self.config.get("NTFY_SERVER_URL") and self.config.get("NTFY_TOPIC"):
  254. results["ntfy"] = self._send_ntfy(
  255. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  256. ai_analysis, display_regions, standalone_data
  257. )
  258. # Bark
  259. if self.config.get("BARK_URL"):
  260. results["bark"] = self._send_bark(
  261. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  262. ai_analysis, display_regions, standalone_data
  263. )
  264. # Slack
  265. if self.config.get("SLACK_WEBHOOK_URL"):
  266. results["slack"] = self._send_slack(
  267. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  268. ai_analysis, display_regions, standalone_data
  269. )
  270. # 通用 Webhook
  271. if self.config.get("GENERIC_WEBHOOK_URL"):
  272. results["generic_webhook"] = self._send_generic_webhook(
  273. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  274. ai_analysis, display_regions, standalone_data
  275. )
  276. # 邮件(保持原有逻辑,已支持多收件人,AI 分析已嵌入 HTML)
  277. if (
  278. self.config.get("EMAIL_FROM")
  279. and self.config.get("EMAIL_PASSWORD")
  280. and self.config.get("EMAIL_TO")
  281. ):
  282. results["email"] = self._send_email(report_type, html_file_path)
  283. return results
  284. def _send_to_multi_accounts(
  285. self,
  286. channel_name: str,
  287. config_value: str,
  288. send_func: Callable[..., bool],
  289. **kwargs,
  290. ) -> bool:
  291. """
  292. 通用多账号发送逻辑
  293. Args:
  294. channel_name: 渠道名称(用于日志和账号数量限制提示)
  295. config_value: 配置值(可能包含多个账号,用 ; 分隔)
  296. send_func: 发送函数,签名为 (account, account_label=..., **kwargs) -> bool
  297. **kwargs: 传递给发送函数的其他参数
  298. Returns:
  299. bool: 任一账号发送成功则返回 True
  300. """
  301. accounts = parse_multi_account_config(config_value)
  302. if not accounts:
  303. return False
  304. accounts = limit_accounts(accounts, self.max_accounts, channel_name)
  305. results = []
  306. for i, account in enumerate(accounts):
  307. if account:
  308. account_label = f"账号{i+1}" if len(accounts) > 1 else ""
  309. result = send_func(account, account_label=account_label, **kwargs)
  310. results.append(result)
  311. return any(results) if results else False
  312. def _apply_display_regions(
  313. self,
  314. report_data: Dict,
  315. display_regions: Optional[Dict],
  316. rss_items: Optional[List[Dict]] = None,
  317. rss_new_items: Optional[List[Dict]] = None,
  318. ai_analysis: Optional[AIAnalysisResult] = None,
  319. standalone_data: Optional[Dict] = None,
  320. ) -> tuple:
  321. """根据 display_regions 过滤各区域数据,返回 (report_data, rss_items, rss_new_items, ai_analysis, standalone_data)"""
  322. display_regions = display_regions or {}
  323. if not display_regions.get("HOTLIST", True):
  324. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  325. show_rss = display_regions.get("RSS", True)
  326. return (
  327. report_data,
  328. rss_items if show_rss else None,
  329. rss_new_items if (show_rss and display_regions.get("NEW_ITEMS", True)) else None,
  330. ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  331. standalone_data if display_regions.get("STANDALONE", False) else None,
  332. )
  333. def _send_feishu(
  334. self,
  335. report_data: Dict,
  336. report_type: str,
  337. update_info: Optional[Dict],
  338. proxy_url: Optional[str],
  339. mode: str,
  340. rss_items: Optional[List[Dict]] = None,
  341. rss_new_items: Optional[List[Dict]] = None,
  342. ai_analysis: Optional[AIAnalysisResult] = None,
  343. display_regions: Optional[Dict] = None,
  344. standalone_data: Optional[Dict] = None,
  345. ) -> bool:
  346. """发送到飞书(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  347. rd, ri, rn, ai, sd = self._apply_display_regions(
  348. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  349. )
  350. return self._send_to_multi_accounts(
  351. channel_name="飞书",
  352. config_value=self.config["FEISHU_WEBHOOK_URL"],
  353. send_func=lambda url, account_label: send_to_feishu(
  354. webhook_url=url,
  355. report_data=rd,
  356. report_type=report_type,
  357. update_info=update_info,
  358. proxy_url=proxy_url,
  359. mode=mode,
  360. account_label=account_label,
  361. batch_size=self.config.get("FEISHU_BATCH_SIZE", 29000),
  362. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  363. split_content_func=self.split_content_func,
  364. get_time_func=self.get_time_func,
  365. rss_items=ri,
  366. rss_new_items=rn,
  367. ai_analysis=ai,
  368. display_regions=display_regions or {},
  369. standalone_data=sd,
  370. ),
  371. )
  372. def _send_dingtalk(
  373. self,
  374. report_data: Dict,
  375. report_type: str,
  376. update_info: Optional[Dict],
  377. proxy_url: Optional[str],
  378. mode: str,
  379. rss_items: Optional[List[Dict]] = None,
  380. rss_new_items: Optional[List[Dict]] = None,
  381. ai_analysis: Optional[AIAnalysisResult] = None,
  382. display_regions: Optional[Dict] = None,
  383. standalone_data: Optional[Dict] = None,
  384. ) -> bool:
  385. """发送到钉钉(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  386. rd, ri, rn, ai, sd = self._apply_display_regions(
  387. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  388. )
  389. return self._send_to_multi_accounts(
  390. channel_name="钉钉",
  391. config_value=self.config["DINGTALK_WEBHOOK_URL"],
  392. send_func=lambda url, account_label: send_to_dingtalk(
  393. webhook_url=url,
  394. report_data=rd,
  395. report_type=report_type,
  396. update_info=update_info,
  397. proxy_url=proxy_url,
  398. mode=mode,
  399. account_label=account_label,
  400. batch_size=self.config.get("DINGTALK_BATCH_SIZE", 20000),
  401. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  402. split_content_func=self.split_content_func,
  403. rss_items=ri,
  404. rss_new_items=rn,
  405. ai_analysis=ai,
  406. display_regions=display_regions or {},
  407. standalone_data=sd,
  408. ),
  409. )
  410. def _send_wework(
  411. self,
  412. report_data: Dict,
  413. report_type: str,
  414. update_info: Optional[Dict],
  415. proxy_url: Optional[str],
  416. mode: str,
  417. rss_items: Optional[List[Dict]] = None,
  418. rss_new_items: Optional[List[Dict]] = None,
  419. ai_analysis: Optional[AIAnalysisResult] = None,
  420. display_regions: Optional[Dict] = None,
  421. standalone_data: Optional[Dict] = None,
  422. ) -> bool:
  423. """发送到企业微信(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  424. rd, ri, rn, ai, sd = self._apply_display_regions(
  425. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  426. )
  427. return self._send_to_multi_accounts(
  428. channel_name="企业微信",
  429. config_value=self.config["WEWORK_WEBHOOK_URL"],
  430. send_func=lambda url, account_label: send_to_wework(
  431. webhook_url=url,
  432. report_data=rd,
  433. report_type=report_type,
  434. update_info=update_info,
  435. proxy_url=proxy_url,
  436. mode=mode,
  437. account_label=account_label,
  438. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  439. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  440. msg_type=self.config.get("WEWORK_MSG_TYPE", "markdown"),
  441. split_content_func=self.split_content_func,
  442. rss_items=ri,
  443. rss_new_items=rn,
  444. ai_analysis=ai,
  445. display_regions=display_regions or {},
  446. standalone_data=sd,
  447. ),
  448. )
  449. def _send_telegram(
  450. self,
  451. report_data: Dict,
  452. report_type: str,
  453. update_info: Optional[Dict],
  454. proxy_url: Optional[str],
  455. mode: str,
  456. rss_items: Optional[List[Dict]] = None,
  457. rss_new_items: Optional[List[Dict]] = None,
  458. ai_analysis: Optional[AIAnalysisResult] = None,
  459. display_regions: Optional[Dict] = None,
  460. standalone_data: Optional[Dict] = None,
  461. ) -> bool:
  462. """发送到 Telegram(多账号,需验证 token 和 chat_id 配对,支持热榜+RSS合并+AI分析+独立展示区)"""
  463. report_data, rss_items, rss_new_items, ai_analysis, standalone_data = self._apply_display_regions(
  464. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  465. )
  466. display_regions = display_regions or {}
  467. telegram_tokens = parse_multi_account_config(self.config["TELEGRAM_BOT_TOKEN"])
  468. telegram_chat_ids = parse_multi_account_config(self.config["TELEGRAM_CHAT_ID"])
  469. if not telegram_tokens or not telegram_chat_ids:
  470. return False
  471. valid, count = validate_paired_configs(
  472. {"bot_token": telegram_tokens, "chat_id": telegram_chat_ids},
  473. "Telegram",
  474. required_keys=["bot_token", "chat_id"],
  475. )
  476. if not valid or count == 0:
  477. return False
  478. telegram_tokens = limit_accounts(telegram_tokens, self.max_accounts, "Telegram")
  479. telegram_chat_ids = telegram_chat_ids[: len(telegram_tokens)]
  480. results = []
  481. for i in range(len(telegram_tokens)):
  482. token = telegram_tokens[i]
  483. chat_id = telegram_chat_ids[i]
  484. if token and chat_id:
  485. account_label = f"账号{i+1}" if len(telegram_tokens) > 1 else ""
  486. result = send_to_telegram(
  487. bot_token=token,
  488. chat_id=chat_id,
  489. report_data=report_data,
  490. report_type=report_type,
  491. update_info=update_info,
  492. proxy_url=proxy_url,
  493. mode=mode,
  494. account_label=account_label,
  495. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  496. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  497. split_content_func=self.split_content_func,
  498. rss_items=rss_items,
  499. rss_new_items=rss_new_items,
  500. ai_analysis=ai_analysis,
  501. display_regions=display_regions,
  502. standalone_data=standalone_data,
  503. )
  504. results.append(result)
  505. return any(results) if results else False
  506. def _send_ntfy(
  507. self,
  508. report_data: Dict,
  509. report_type: str,
  510. update_info: Optional[Dict],
  511. proxy_url: Optional[str],
  512. mode: str,
  513. rss_items: Optional[List[Dict]] = None,
  514. rss_new_items: Optional[List[Dict]] = None,
  515. ai_analysis: Optional[AIAnalysisResult] = None,
  516. display_regions: Optional[Dict] = None,
  517. standalone_data: Optional[Dict] = None,
  518. ) -> bool:
  519. """发送到 ntfy(多账号,需验证 topic 和 token 配对,支持热榜+RSS合并+AI分析+独立展示区)"""
  520. report_data, rss_items, rss_new_items, ai_analysis, standalone_data = self._apply_display_regions(
  521. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  522. )
  523. display_regions = display_regions or {}
  524. ntfy_server_url = self.config["NTFY_SERVER_URL"]
  525. ntfy_topics = parse_multi_account_config(self.config["NTFY_TOPIC"])
  526. ntfy_tokens = parse_multi_account_config(self.config.get("NTFY_TOKEN", ""))
  527. if not ntfy_server_url or not ntfy_topics:
  528. return False
  529. if ntfy_tokens and len(ntfy_tokens) != len(ntfy_topics):
  530. print(
  531. f"❌ ntfy 配置错误:topic 数量({len(ntfy_topics)})与 token 数量({len(ntfy_tokens)})不一致,跳过 ntfy 推送"
  532. )
  533. return False
  534. ntfy_topics = limit_accounts(ntfy_topics, self.max_accounts, "ntfy")
  535. if ntfy_tokens:
  536. ntfy_tokens = ntfy_tokens[: len(ntfy_topics)]
  537. results = []
  538. for i, topic in enumerate(ntfy_topics):
  539. if topic:
  540. token = get_account_at_index(ntfy_tokens, i, "") if ntfy_tokens else ""
  541. account_label = f"账号{i+1}" if len(ntfy_topics) > 1 else ""
  542. result = send_to_ntfy(
  543. server_url=ntfy_server_url,
  544. topic=topic,
  545. token=token,
  546. report_data=report_data,
  547. report_type=report_type,
  548. update_info=update_info,
  549. proxy_url=proxy_url,
  550. mode=mode,
  551. account_label=account_label,
  552. batch_size=3800,
  553. split_content_func=self.split_content_func,
  554. rss_items=rss_items,
  555. rss_new_items=rss_new_items,
  556. ai_analysis=ai_analysis,
  557. display_regions=display_regions,
  558. standalone_data=standalone_data,
  559. )
  560. results.append(result)
  561. return any(results) if results else False
  562. def _send_bark(
  563. self,
  564. report_data: Dict,
  565. report_type: str,
  566. update_info: Optional[Dict],
  567. proxy_url: Optional[str],
  568. mode: str,
  569. rss_items: Optional[List[Dict]] = None,
  570. rss_new_items: Optional[List[Dict]] = None,
  571. ai_analysis: Optional[AIAnalysisResult] = None,
  572. display_regions: Optional[Dict] = None,
  573. standalone_data: Optional[Dict] = None,
  574. ) -> bool:
  575. """发送到 Bark(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  576. rd, ri, rn, ai, sd = self._apply_display_regions(
  577. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  578. )
  579. return self._send_to_multi_accounts(
  580. channel_name="Bark",
  581. config_value=self.config["BARK_URL"],
  582. send_func=lambda url, account_label: send_to_bark(
  583. bark_url=url,
  584. report_data=rd,
  585. report_type=report_type,
  586. update_info=update_info,
  587. proxy_url=proxy_url,
  588. mode=mode,
  589. account_label=account_label,
  590. batch_size=self.config.get("BARK_BATCH_SIZE", 3600),
  591. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  592. split_content_func=self.split_content_func,
  593. rss_items=ri,
  594. rss_new_items=rn,
  595. ai_analysis=ai,
  596. display_regions=display_regions or {},
  597. standalone_data=sd,
  598. ),
  599. )
  600. def _send_slack(
  601. self,
  602. report_data: Dict,
  603. report_type: str,
  604. update_info: Optional[Dict],
  605. proxy_url: Optional[str],
  606. mode: str,
  607. rss_items: Optional[List[Dict]] = None,
  608. rss_new_items: Optional[List[Dict]] = None,
  609. ai_analysis: Optional[AIAnalysisResult] = None,
  610. display_regions: Optional[Dict] = None,
  611. standalone_data: Optional[Dict] = None,
  612. ) -> bool:
  613. """发送到 Slack(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  614. rd, ri, rn, ai, sd = self._apply_display_regions(
  615. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  616. )
  617. return self._send_to_multi_accounts(
  618. channel_name="Slack",
  619. config_value=self.config["SLACK_WEBHOOK_URL"],
  620. send_func=lambda url, account_label: send_to_slack(
  621. webhook_url=url,
  622. report_data=rd,
  623. report_type=report_type,
  624. update_info=update_info,
  625. proxy_url=proxy_url,
  626. mode=mode,
  627. account_label=account_label,
  628. batch_size=self.config.get("SLACK_BATCH_SIZE", 4000),
  629. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  630. split_content_func=self.split_content_func,
  631. rss_items=ri,
  632. rss_new_items=rn,
  633. ai_analysis=ai,
  634. display_regions=display_regions or {},
  635. standalone_data=sd,
  636. ),
  637. )
  638. def _send_generic_webhook(
  639. self,
  640. report_data: Dict,
  641. report_type: str,
  642. update_info: Optional[Dict],
  643. proxy_url: Optional[str],
  644. mode: str,
  645. rss_items: Optional[List[Dict]] = None,
  646. rss_new_items: Optional[List[Dict]] = None,
  647. ai_analysis: Optional[AIAnalysisResult] = None,
  648. display_regions: Optional[Dict] = None,
  649. standalone_data: Optional[Dict] = None,
  650. ) -> bool:
  651. """发送到通用 Webhook(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  652. report_data, rss_items, rss_new_items, ai_analysis, standalone_data = self._apply_display_regions(
  653. report_data, display_regions, rss_items, rss_new_items, ai_analysis, standalone_data
  654. )
  655. display_regions = display_regions or {}
  656. urls = parse_multi_account_config(self.config.get("GENERIC_WEBHOOK_URL", ""))
  657. templates = parse_multi_account_config(self.config.get("GENERIC_WEBHOOK_TEMPLATE", ""))
  658. if not urls:
  659. return False
  660. urls = limit_accounts(urls, self.max_accounts, "通用Webhook")
  661. results = []
  662. for i, url in enumerate(urls):
  663. if not url:
  664. continue
  665. template = ""
  666. if templates:
  667. if i < len(templates):
  668. template = templates[i]
  669. elif len(templates) == 1:
  670. template = templates[0]
  671. account_label = f"账号{i+1}" if len(urls) > 1 else ""
  672. result = send_to_generic_webhook(
  673. webhook_url=url,
  674. payload_template=template,
  675. report_data=report_data,
  676. report_type=report_type,
  677. update_info=update_info,
  678. proxy_url=proxy_url,
  679. mode=mode,
  680. account_label=account_label,
  681. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  682. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  683. split_content_func=self.split_content_func,
  684. rss_items=rss_items,
  685. rss_new_items=rss_new_items,
  686. ai_analysis=ai_analysis,
  687. display_regions=display_regions,
  688. standalone_data=standalone_data,
  689. )
  690. results.append(result)
  691. return any(results) if results else False
  692. def _send_email(
  693. self,
  694. report_type: str,
  695. html_file_path: Optional[str],
  696. ) -> bool:
  697. """发送邮件(保持原有逻辑,已支持多收件人)
  698. Note:
  699. AI 分析内容已在 HTML 生成时嵌入,无需在此传递
  700. """
  701. return send_to_email(
  702. from_email=self.config["EMAIL_FROM"],
  703. password=self.config["EMAIL_PASSWORD"],
  704. to_email=self.config["EMAIL_TO"],
  705. report_type=report_type,
  706. html_file_path=html_file_path,
  707. custom_smtp_server=self.config.get("EMAIL_SMTP_SERVER", ""),
  708. custom_smtp_port=self.config.get("EMAIL_SMTP_PORT", ""),
  709. get_time_func=self.get_time_func,
  710. )