dispatcher.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211
  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. from .renderer import (
  30. render_rss_feishu_content,
  31. render_rss_dingtalk_content,
  32. render_rss_markdown_content,
  33. )
  34. # 类型检查时导入,运行时不导入(避免循环导入)
  35. if TYPE_CHECKING:
  36. from trendradar.ai import AIAnalysisResult, AITranslator
  37. class NotificationDispatcher:
  38. """
  39. 统一的多账号通知调度器
  40. 将多账号发送逻辑封装,提供简洁的 dispatch_all 接口。
  41. 内部处理账号解析、数量限制、配对验证等逻辑。
  42. """
  43. def __init__(
  44. self,
  45. config: Dict[str, Any],
  46. get_time_func: Callable,
  47. split_content_func: Callable,
  48. translator: Optional["AITranslator"] = None,
  49. ):
  50. """
  51. 初始化通知调度器
  52. Args:
  53. config: 完整的配置字典,包含所有通知渠道的配置
  54. get_time_func: 获取当前时间的函数
  55. split_content_func: 内容分批函数
  56. translator: AI 翻译器实例(可选)
  57. """
  58. self.config = config
  59. self.get_time_func = get_time_func
  60. self.split_content_func = split_content_func
  61. self.max_accounts = config.get("MAX_ACCOUNTS_PER_CHANNEL", 3)
  62. self.translator = translator
  63. def translate_content(
  64. self,
  65. report_data: Dict,
  66. rss_items: Optional[List[Dict]] = None,
  67. rss_new_items: Optional[List[Dict]] = None,
  68. standalone_data: Optional[Dict] = None,
  69. display_regions: Optional[Dict] = None,
  70. skip_rss: bool = False,
  71. ) -> tuple:
  72. """
  73. 翻译推送内容
  74. Args:
  75. report_data: 报告数据
  76. rss_items: RSS 统计条目
  77. rss_new_items: RSS 新增条目
  78. standalone_data: 独立展示区数据
  79. display_regions: 区域显示配置(不展示的区域跳过翻译)
  80. skip_rss: 跳过 RSS 和独立展示区翻译(当数据已在上游翻译过时使用)
  81. Returns:
  82. tuple: (翻译后的 report_data, rss_items, rss_new_items, standalone_data)
  83. """
  84. if not self.translator or not self.translator.enabled:
  85. return report_data, rss_items, rss_new_items, standalone_data
  86. import copy
  87. print(f"[翻译] 开始翻译内容到 {self.translator.target_language}...")
  88. scope = self.translator.scope
  89. display_regions = display_regions or {}
  90. # 深拷贝避免修改原始数据
  91. report_data = copy.deepcopy(report_data)
  92. rss_items = copy.deepcopy(rss_items) if rss_items else None
  93. rss_new_items = copy.deepcopy(rss_new_items) if rss_new_items else None
  94. standalone_data = copy.deepcopy(standalone_data) if standalone_data else None
  95. # 收集所有需要翻译的标题
  96. titles_to_translate = []
  97. title_locations = [] # 记录标题位置,用于回填
  98. # 1. 热榜标题(scope 开启 且 区域展示)
  99. if scope.get("HOTLIST", True) and display_regions.get("HOTLIST", True):
  100. for stat_idx, stat in enumerate(report_data.get("stats", [])):
  101. for title_idx, title_data in enumerate(stat.get("titles", [])):
  102. titles_to_translate.append(title_data.get("title", ""))
  103. title_locations.append(("stats", stat_idx, title_idx))
  104. # 2. 新增热点标题
  105. for source_idx, source in enumerate(report_data.get("new_titles", [])):
  106. for title_idx, title_data in enumerate(source.get("titles", [])):
  107. titles_to_translate.append(title_data.get("title", ""))
  108. title_locations.append(("new_titles", source_idx, title_idx))
  109. # 3. RSS 统计标题(结构与 stats 一致:[{word, count, titles: [{title, ...}]}])
  110. if not skip_rss and rss_items and scope.get("RSS", True) and display_regions.get("RSS", True):
  111. for stat_idx, stat in enumerate(rss_items):
  112. for title_idx, title_data in enumerate(stat.get("titles", [])):
  113. titles_to_translate.append(title_data.get("title", ""))
  114. title_locations.append(("rss_items", stat_idx, title_idx))
  115. # 4. RSS 新增标题(结构与 stats 一致)
  116. 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):
  117. for stat_idx, stat in enumerate(rss_new_items):
  118. for title_idx, title_data in enumerate(stat.get("titles", [])):
  119. titles_to_translate.append(title_data.get("title", ""))
  120. title_locations.append(("rss_new_items", stat_idx, title_idx))
  121. # 5. 独立展示区 - 热榜平台
  122. if standalone_data and scope.get("STANDALONE", True) and display_regions.get("STANDALONE", False):
  123. for plat_idx, platform in enumerate(standalone_data.get("platforms", [])):
  124. for item_idx, item in enumerate(platform.get("items", [])):
  125. titles_to_translate.append(item.get("title", ""))
  126. title_locations.append(("standalone_platforms", plat_idx, item_idx))
  127. # 6. 独立展示区 - RSS 源(跳过已翻译的)
  128. if not skip_rss:
  129. for feed_idx, feed in enumerate(standalone_data.get("rss_feeds", [])):
  130. for item_idx, item in enumerate(feed.get("items", [])):
  131. titles_to_translate.append(item.get("title", ""))
  132. title_locations.append(("standalone_rss", feed_idx, item_idx))
  133. if not titles_to_translate:
  134. print("[翻译] 没有需要翻译的内容")
  135. return report_data, rss_items, rss_new_items, standalone_data
  136. print(f"[翻译] 共 {len(titles_to_translate)} 条标题待翻译")
  137. # 批量翻译
  138. result = self.translator.translate_batch(titles_to_translate)
  139. if result.success_count == 0:
  140. print(f"[翻译] 翻译失败: {result.results[0].error if result.results else '未知错误'}")
  141. return report_data, rss_items, rss_new_items, standalone_data
  142. print(f"[翻译] 翻译完成: {result.success_count}/{result.total_count} 成功")
  143. # debug 模式:输出完整 prompt、AI 原始响应、逐条对照
  144. if self.config.get("DEBUG", False):
  145. if result.prompt:
  146. print(f"[翻译][DEBUG] === 发送给 AI 的 Prompt ===")
  147. print(result.prompt)
  148. print(f"[翻译][DEBUG] === Prompt 结束 ===")
  149. if result.raw_response:
  150. print(f"[翻译][DEBUG] === AI 原始响应 ===")
  151. print(result.raw_response)
  152. print(f"[翻译][DEBUG] === 响应结束 ===")
  153. # 行数不匹配警告
  154. expected = len(titles_to_translate)
  155. if result.parsed_count != expected:
  156. print(f"[翻译][DEBUG] ⚠️ 行数不匹配:期望 {expected} 条,AI 返回 {result.parsed_count} 条")
  157. # 逐条对照
  158. unchanged_count = 0
  159. for i, res in enumerate(result.results):
  160. if not res.success and res.error:
  161. print(f"[翻译][DEBUG] [{i+1}] !! 失败: {res.error}")
  162. elif res.original_text == res.translated_text:
  163. unchanged_count += 1
  164. else:
  165. print(f"[翻译][DEBUG] [{i+1}] {res.original_text} => {res.translated_text}")
  166. if unchanged_count > 0:
  167. print(f"[翻译][DEBUG] (另有 {unchanged_count} 条未变化,已省略)")
  168. # 回填翻译结果
  169. for i, (loc_type, idx1, idx2) in enumerate(title_locations):
  170. if i < len(result.results) and result.results[i].success:
  171. translated = result.results[i].translated_text
  172. if loc_type == "stats":
  173. report_data["stats"][idx1]["titles"][idx2]["title"] = translated
  174. elif loc_type == "new_titles":
  175. report_data["new_titles"][idx1]["titles"][idx2]["title"] = translated
  176. elif loc_type == "rss_items" and rss_items:
  177. rss_items[idx1]["titles"][idx2]["title"] = translated
  178. elif loc_type == "rss_new_items" and rss_new_items:
  179. rss_new_items[idx1]["titles"][idx2]["title"] = translated
  180. elif loc_type == "standalone_platforms" and standalone_data:
  181. standalone_data["platforms"][idx1]["items"][idx2]["title"] = translated
  182. elif loc_type == "standalone_rss" and standalone_data:
  183. standalone_data["rss_feeds"][idx1]["items"][idx2]["title"] = translated
  184. return report_data, rss_items, rss_new_items, standalone_data
  185. def dispatch_all(
  186. self,
  187. report_data: Dict,
  188. report_type: str,
  189. update_info: Optional[Dict] = None,
  190. proxy_url: Optional[str] = None,
  191. mode: str = "daily",
  192. html_file_path: Optional[str] = None,
  193. rss_items: Optional[List[Dict]] = None,
  194. rss_new_items: Optional[List[Dict]] = None,
  195. ai_analysis: Optional[AIAnalysisResult] = None,
  196. standalone_data: Optional[Dict] = None,
  197. skip_translation: bool = False,
  198. ) -> Dict[str, bool]:
  199. """
  200. 分发通知到所有已配置的渠道(支持热榜+RSS合并推送+AI分析+独立展示区)
  201. Args:
  202. report_data: 报告数据(由 prepare_report_data 生成)
  203. report_type: 报告类型(如 "全天汇总"、"当前榜单"、"增量分析")
  204. update_info: 版本更新信息(可选)
  205. proxy_url: 代理 URL(可选)
  206. mode: 报告模式 (daily/current/incremental)
  207. html_file_path: HTML 报告文件路径(邮件使用)
  208. rss_items: RSS 统计条目列表(用于 RSS 统计区块)
  209. rss_new_items: RSS 新增条目列表(用于 RSS 新增区块)
  210. ai_analysis: AI 分析结果(可选)
  211. standalone_data: 独立展示区数据(可选)
  212. skip_translation: 跳过翻译(当数据已在上游翻译过时使用)
  213. Returns:
  214. Dict[str, bool]: 每个渠道的发送结果,key 为渠道名,value 为是否成功
  215. """
  216. results = {}
  217. # 获取区域显示配置
  218. display_regions = self.config.get("DISPLAY", {}).get("REGIONS", {})
  219. # 执行翻译(如果启用,根据 display_regions 跳过不展示的区域)
  220. # skip_translation=True 时,RSS 已在上游翻译过,跳过 RSS 重复翻译
  221. if not skip_translation:
  222. report_data, rss_items, rss_new_items, standalone_data = self.translate_content(
  223. report_data, rss_items, rss_new_items, standalone_data, display_regions
  224. )
  225. else:
  226. # RSS 已翻译,仅翻译热榜 report_data 和独立展示区热榜部分
  227. report_data, _, _, standalone_data = self.translate_content(
  228. report_data, standalone_data=standalone_data, display_regions=display_regions,
  229. skip_rss=True,
  230. )
  231. # 飞书
  232. if self.config.get("FEISHU_WEBHOOK_URL"):
  233. results["feishu"] = self._send_feishu(
  234. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  235. ai_analysis, display_regions, standalone_data
  236. )
  237. # 钉钉
  238. if self.config.get("DINGTALK_WEBHOOK_URL"):
  239. results["dingtalk"] = self._send_dingtalk(
  240. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  241. ai_analysis, display_regions, standalone_data
  242. )
  243. # 企业微信
  244. if self.config.get("WEWORK_WEBHOOK_URL"):
  245. results["wework"] = self._send_wework(
  246. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  247. ai_analysis, display_regions, standalone_data
  248. )
  249. # Telegram(需要配对验证)
  250. if self.config.get("TELEGRAM_BOT_TOKEN") and self.config.get("TELEGRAM_CHAT_ID"):
  251. results["telegram"] = self._send_telegram(
  252. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  253. ai_analysis, display_regions, standalone_data
  254. )
  255. # ntfy(需要配对验证)
  256. if self.config.get("NTFY_SERVER_URL") and self.config.get("NTFY_TOPIC"):
  257. results["ntfy"] = self._send_ntfy(
  258. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  259. ai_analysis, display_regions, standalone_data
  260. )
  261. # Bark
  262. if self.config.get("BARK_URL"):
  263. results["bark"] = self._send_bark(
  264. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  265. ai_analysis, display_regions, standalone_data
  266. )
  267. # Slack
  268. if self.config.get("SLACK_WEBHOOK_URL"):
  269. results["slack"] = self._send_slack(
  270. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  271. ai_analysis, display_regions, standalone_data
  272. )
  273. # 通用 Webhook
  274. if self.config.get("GENERIC_WEBHOOK_URL"):
  275. results["generic_webhook"] = self._send_generic_webhook(
  276. report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,
  277. ai_analysis, display_regions, standalone_data
  278. )
  279. # 邮件(保持原有逻辑,已支持多收件人,AI 分析已嵌入 HTML)
  280. if (
  281. self.config.get("EMAIL_FROM")
  282. and self.config.get("EMAIL_PASSWORD")
  283. and self.config.get("EMAIL_TO")
  284. ):
  285. results["email"] = self._send_email(report_type, html_file_path)
  286. return results
  287. def _send_to_multi_accounts(
  288. self,
  289. channel_name: str,
  290. config_value: str,
  291. send_func: Callable[..., bool],
  292. **kwargs,
  293. ) -> bool:
  294. """
  295. 通用多账号发送逻辑
  296. Args:
  297. channel_name: 渠道名称(用于日志和账号数量限制提示)
  298. config_value: 配置值(可能包含多个账号,用 ; 分隔)
  299. send_func: 发送函数,签名为 (account, account_label=..., **kwargs) -> bool
  300. **kwargs: 传递给发送函数的其他参数
  301. Returns:
  302. bool: 任一账号发送成功则返回 True
  303. """
  304. accounts = parse_multi_account_config(config_value)
  305. if not accounts:
  306. return False
  307. accounts = limit_accounts(accounts, self.max_accounts, channel_name)
  308. results = []
  309. for i, account in enumerate(accounts):
  310. if account:
  311. account_label = f"账号{i+1}" if len(accounts) > 1 else ""
  312. result = send_func(account, account_label=account_label, **kwargs)
  313. results.append(result)
  314. return any(results) if results else False
  315. def _send_feishu(
  316. self,
  317. report_data: Dict,
  318. report_type: str,
  319. update_info: Optional[Dict],
  320. proxy_url: Optional[str],
  321. mode: str,
  322. rss_items: Optional[List[Dict]] = None,
  323. rss_new_items: Optional[List[Dict]] = None,
  324. ai_analysis: Optional[AIAnalysisResult] = None,
  325. display_regions: Optional[Dict] = None,
  326. standalone_data: Optional[Dict] = None,
  327. ) -> bool:
  328. """发送到飞书(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  329. display_regions = display_regions or {}
  330. if not display_regions.get("HOTLIST", True):
  331. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  332. return self._send_to_multi_accounts(
  333. channel_name="飞书",
  334. config_value=self.config["FEISHU_WEBHOOK_URL"],
  335. send_func=lambda url, account_label: send_to_feishu(
  336. webhook_url=url,
  337. report_data=report_data,
  338. report_type=report_type,
  339. update_info=update_info,
  340. proxy_url=proxy_url,
  341. mode=mode,
  342. account_label=account_label,
  343. batch_size=self.config.get("FEISHU_BATCH_SIZE", 29000),
  344. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  345. split_content_func=self.split_content_func,
  346. get_time_func=self.get_time_func,
  347. rss_items=rss_items if display_regions.get("RSS", True) else None,
  348. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  349. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  350. display_regions=display_regions,
  351. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  352. ),
  353. )
  354. def _send_dingtalk(
  355. self,
  356. report_data: Dict,
  357. report_type: str,
  358. update_info: Optional[Dict],
  359. proxy_url: Optional[str],
  360. mode: str,
  361. rss_items: Optional[List[Dict]] = None,
  362. rss_new_items: Optional[List[Dict]] = None,
  363. ai_analysis: Optional[AIAnalysisResult] = None,
  364. display_regions: Optional[Dict] = None,
  365. standalone_data: Optional[Dict] = None,
  366. ) -> bool:
  367. """发送到钉钉(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  368. display_regions = display_regions or {}
  369. if not display_regions.get("HOTLIST", True):
  370. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  371. return self._send_to_multi_accounts(
  372. channel_name="钉钉",
  373. config_value=self.config["DINGTALK_WEBHOOK_URL"],
  374. send_func=lambda url, account_label: send_to_dingtalk(
  375. webhook_url=url,
  376. report_data=report_data,
  377. report_type=report_type,
  378. update_info=update_info,
  379. proxy_url=proxy_url,
  380. mode=mode,
  381. account_label=account_label,
  382. batch_size=self.config.get("DINGTALK_BATCH_SIZE", 20000),
  383. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  384. split_content_func=self.split_content_func,
  385. rss_items=rss_items if display_regions.get("RSS", True) else None,
  386. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  387. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  388. display_regions=display_regions,
  389. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  390. ),
  391. )
  392. def _send_wework(
  393. self,
  394. report_data: Dict,
  395. report_type: str,
  396. update_info: Optional[Dict],
  397. proxy_url: Optional[str],
  398. mode: str,
  399. rss_items: Optional[List[Dict]] = None,
  400. rss_new_items: Optional[List[Dict]] = None,
  401. ai_analysis: Optional[AIAnalysisResult] = None,
  402. display_regions: Optional[Dict] = None,
  403. standalone_data: Optional[Dict] = None,
  404. ) -> bool:
  405. """发送到企业微信(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  406. display_regions = display_regions or {}
  407. if not display_regions.get("HOTLIST", True):
  408. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  409. return self._send_to_multi_accounts(
  410. channel_name="企业微信",
  411. config_value=self.config["WEWORK_WEBHOOK_URL"],
  412. send_func=lambda url, account_label: send_to_wework(
  413. webhook_url=url,
  414. report_data=report_data,
  415. report_type=report_type,
  416. update_info=update_info,
  417. proxy_url=proxy_url,
  418. mode=mode,
  419. account_label=account_label,
  420. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  421. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  422. msg_type=self.config.get("WEWORK_MSG_TYPE", "markdown"),
  423. split_content_func=self.split_content_func,
  424. rss_items=rss_items if display_regions.get("RSS", True) else None,
  425. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  426. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  427. display_regions=display_regions,
  428. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  429. ),
  430. )
  431. def _send_telegram(
  432. self,
  433. report_data: Dict,
  434. report_type: str,
  435. update_info: Optional[Dict],
  436. proxy_url: Optional[str],
  437. mode: str,
  438. rss_items: Optional[List[Dict]] = None,
  439. rss_new_items: Optional[List[Dict]] = None,
  440. ai_analysis: Optional[AIAnalysisResult] = None,
  441. display_regions: Optional[Dict] = None,
  442. standalone_data: Optional[Dict] = None,
  443. ) -> bool:
  444. """发送到 Telegram(多账号,需验证 token 和 chat_id 配对,支持热榜+RSS合并+AI分析+独立展示区)"""
  445. display_regions = display_regions or {}
  446. if not display_regions.get("HOTLIST", True):
  447. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  448. telegram_tokens = parse_multi_account_config(self.config["TELEGRAM_BOT_TOKEN"])
  449. telegram_chat_ids = parse_multi_account_config(self.config["TELEGRAM_CHAT_ID"])
  450. if not telegram_tokens or not telegram_chat_ids:
  451. return False
  452. valid, count = validate_paired_configs(
  453. {"bot_token": telegram_tokens, "chat_id": telegram_chat_ids},
  454. "Telegram",
  455. required_keys=["bot_token", "chat_id"],
  456. )
  457. if not valid or count == 0:
  458. return False
  459. telegram_tokens = limit_accounts(telegram_tokens, self.max_accounts, "Telegram")
  460. telegram_chat_ids = telegram_chat_ids[: len(telegram_tokens)]
  461. results = []
  462. for i in range(len(telegram_tokens)):
  463. token = telegram_tokens[i]
  464. chat_id = telegram_chat_ids[i]
  465. if token and chat_id:
  466. account_label = f"账号{i+1}" if len(telegram_tokens) > 1 else ""
  467. result = send_to_telegram(
  468. bot_token=token,
  469. chat_id=chat_id,
  470. report_data=report_data,
  471. report_type=report_type,
  472. update_info=update_info,
  473. proxy_url=proxy_url,
  474. mode=mode,
  475. account_label=account_label,
  476. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  477. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  478. split_content_func=self.split_content_func,
  479. rss_items=rss_items if display_regions.get("RSS", True) else None,
  480. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  481. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  482. display_regions=display_regions,
  483. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  484. )
  485. results.append(result)
  486. return any(results) if results else False
  487. def _send_ntfy(
  488. self,
  489. report_data: Dict,
  490. report_type: str,
  491. update_info: Optional[Dict],
  492. proxy_url: Optional[str],
  493. mode: str,
  494. rss_items: Optional[List[Dict]] = None,
  495. rss_new_items: Optional[List[Dict]] = None,
  496. ai_analysis: Optional[AIAnalysisResult] = None,
  497. display_regions: Optional[Dict] = None,
  498. standalone_data: Optional[Dict] = None,
  499. ) -> bool:
  500. """发送到 ntfy(多账号,需验证 topic 和 token 配对,支持热榜+RSS合并+AI分析+独立展示区)"""
  501. display_regions = display_regions or {}
  502. if not display_regions.get("HOTLIST", True):
  503. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  504. ntfy_server_url = self.config["NTFY_SERVER_URL"]
  505. ntfy_topics = parse_multi_account_config(self.config["NTFY_TOPIC"])
  506. ntfy_tokens = parse_multi_account_config(self.config.get("NTFY_TOKEN", ""))
  507. if not ntfy_server_url or not ntfy_topics:
  508. return False
  509. if ntfy_tokens and len(ntfy_tokens) != len(ntfy_topics):
  510. print(
  511. f"❌ ntfy 配置错误:topic 数量({len(ntfy_topics)})与 token 数量({len(ntfy_tokens)})不一致,跳过 ntfy 推送"
  512. )
  513. return False
  514. ntfy_topics = limit_accounts(ntfy_topics, self.max_accounts, "ntfy")
  515. if ntfy_tokens:
  516. ntfy_tokens = ntfy_tokens[: len(ntfy_topics)]
  517. results = []
  518. for i, topic in enumerate(ntfy_topics):
  519. if topic:
  520. token = get_account_at_index(ntfy_tokens, i, "") if ntfy_tokens else ""
  521. account_label = f"账号{i+1}" if len(ntfy_topics) > 1 else ""
  522. result = send_to_ntfy(
  523. server_url=ntfy_server_url,
  524. topic=topic,
  525. token=token,
  526. report_data=report_data,
  527. report_type=report_type,
  528. update_info=update_info,
  529. proxy_url=proxy_url,
  530. mode=mode,
  531. account_label=account_label,
  532. batch_size=3800,
  533. split_content_func=self.split_content_func,
  534. rss_items=rss_items if display_regions.get("RSS", True) else None,
  535. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  536. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  537. display_regions=display_regions,
  538. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  539. )
  540. results.append(result)
  541. return any(results) if results else False
  542. def _send_bark(
  543. self,
  544. report_data: Dict,
  545. report_type: str,
  546. update_info: Optional[Dict],
  547. proxy_url: Optional[str],
  548. mode: str,
  549. rss_items: Optional[List[Dict]] = None,
  550. rss_new_items: Optional[List[Dict]] = None,
  551. ai_analysis: Optional[AIAnalysisResult] = None,
  552. display_regions: Optional[Dict] = None,
  553. standalone_data: Optional[Dict] = None,
  554. ) -> bool:
  555. """发送到 Bark(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  556. display_regions = display_regions or {}
  557. if not display_regions.get("HOTLIST", True):
  558. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  559. return self._send_to_multi_accounts(
  560. channel_name="Bark",
  561. config_value=self.config["BARK_URL"],
  562. send_func=lambda url, account_label: send_to_bark(
  563. bark_url=url,
  564. report_data=report_data,
  565. report_type=report_type,
  566. update_info=update_info,
  567. proxy_url=proxy_url,
  568. mode=mode,
  569. account_label=account_label,
  570. batch_size=self.config.get("BARK_BATCH_SIZE", 3600),
  571. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  572. split_content_func=self.split_content_func,
  573. rss_items=rss_items if display_regions.get("RSS", True) else None,
  574. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  575. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  576. display_regions=display_regions,
  577. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  578. ),
  579. )
  580. def _send_slack(
  581. self,
  582. report_data: Dict,
  583. report_type: str,
  584. update_info: Optional[Dict],
  585. proxy_url: Optional[str],
  586. mode: str,
  587. rss_items: Optional[List[Dict]] = None,
  588. rss_new_items: Optional[List[Dict]] = None,
  589. ai_analysis: Optional[AIAnalysisResult] = None,
  590. display_regions: Optional[Dict] = None,
  591. standalone_data: Optional[Dict] = None,
  592. ) -> bool:
  593. """发送到 Slack(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  594. display_regions = display_regions or {}
  595. if not display_regions.get("HOTLIST", True):
  596. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  597. return self._send_to_multi_accounts(
  598. channel_name="Slack",
  599. config_value=self.config["SLACK_WEBHOOK_URL"],
  600. send_func=lambda url, account_label: send_to_slack(
  601. webhook_url=url,
  602. report_data=report_data,
  603. report_type=report_type,
  604. update_info=update_info,
  605. proxy_url=proxy_url,
  606. mode=mode,
  607. account_label=account_label,
  608. batch_size=self.config.get("SLACK_BATCH_SIZE", 4000),
  609. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  610. split_content_func=self.split_content_func,
  611. rss_items=rss_items if display_regions.get("RSS", True) else None,
  612. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  613. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  614. display_regions=display_regions,
  615. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  616. ),
  617. )
  618. def _send_generic_webhook(
  619. self,
  620. report_data: Dict,
  621. report_type: str,
  622. update_info: Optional[Dict],
  623. proxy_url: Optional[str],
  624. mode: str,
  625. rss_items: Optional[List[Dict]] = None,
  626. rss_new_items: Optional[List[Dict]] = None,
  627. ai_analysis: Optional[AIAnalysisResult] = None,
  628. display_regions: Optional[Dict] = None,
  629. standalone_data: Optional[Dict] = None,
  630. ) -> bool:
  631. """发送到通用 Webhook(多账号,支持热榜+RSS合并+AI分析+独立展示区)"""
  632. display_regions = display_regions or {}
  633. if not display_regions.get("HOTLIST", True):
  634. report_data = {"stats": [], "failed_ids": [], "new_titles": [], "id_to_name": {}}
  635. urls = parse_multi_account_config(self.config.get("GENERIC_WEBHOOK_URL", ""))
  636. templates = parse_multi_account_config(self.config.get("GENERIC_WEBHOOK_TEMPLATE", ""))
  637. if not urls:
  638. return False
  639. urls = limit_accounts(urls, self.max_accounts, "通用Webhook")
  640. results = []
  641. for i, url in enumerate(urls):
  642. if not url:
  643. continue
  644. template = ""
  645. if templates:
  646. if i < len(templates):
  647. template = templates[i]
  648. elif len(templates) == 1:
  649. template = templates[0]
  650. account_label = f"账号{i+1}" if len(urls) > 1 else ""
  651. result = send_to_generic_webhook(
  652. webhook_url=url,
  653. payload_template=template,
  654. report_data=report_data,
  655. report_type=report_type,
  656. update_info=update_info,
  657. proxy_url=proxy_url,
  658. mode=mode,
  659. account_label=account_label,
  660. batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
  661. batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
  662. split_content_func=self.split_content_func,
  663. rss_items=rss_items if display_regions.get("RSS", True) else None,
  664. rss_new_items=rss_new_items if (display_regions.get("RSS", True) and display_regions.get("NEW_ITEMS", True)) else None,
  665. ai_analysis=ai_analysis if display_regions.get("AI_ANALYSIS", True) else None,
  666. display_regions=display_regions,
  667. standalone_data=standalone_data if display_regions.get("STANDALONE", False) else None,
  668. )
  669. results.append(result)
  670. return any(results) if results else False
  671. def _send_email(
  672. self,
  673. report_type: str,
  674. html_file_path: Optional[str],
  675. ) -> bool:
  676. """发送邮件(保持原有逻辑,已支持多收件人)
  677. Note:
  678. AI 分析内容已在 HTML 生成时嵌入,无需在此传递
  679. """
  680. return send_to_email(
  681. from_email=self.config["EMAIL_FROM"],
  682. password=self.config["EMAIL_PASSWORD"],
  683. to_email=self.config["EMAIL_TO"],
  684. report_type=report_type,
  685. html_file_path=html_file_path,
  686. custom_smtp_server=self.config.get("EMAIL_SMTP_SERVER", ""),
  687. custom_smtp_port=self.config.get("EMAIL_SMTP_PORT", ""),
  688. get_time_func=self.get_time_func,
  689. )
  690. # === RSS 通知方法 ===
  691. def dispatch_rss(
  692. self,
  693. rss_items: List[Dict],
  694. feeds_info: Optional[Dict[str, str]] = None,
  695. proxy_url: Optional[str] = None,
  696. html_file_path: Optional[str] = None,
  697. ) -> Dict[str, bool]:
  698. """
  699. 分发 RSS 通知到所有已配置的渠道
  700. Args:
  701. rss_items: RSS 条目列表,每个条目包含:
  702. - title: 标题
  703. - feed_id: RSS 源 ID
  704. - feed_name: RSS 源名称
  705. - url: 链接
  706. - published_at: 发布时间
  707. - summary: 摘要(可选)
  708. - author: 作者(可选)
  709. feeds_info: RSS 源 ID 到名称的映射
  710. proxy_url: 代理 URL(可选)
  711. html_file_path: HTML 报告文件路径(邮件使用)
  712. Returns:
  713. Dict[str, bool]: 每个渠道的发送结果
  714. """
  715. if not rss_items:
  716. print("[RSS通知] 没有 RSS 内容,跳过通知")
  717. return {}
  718. results = {}
  719. report_type = "RSS 订阅更新"
  720. # 飞书
  721. if self.config.get("FEISHU_WEBHOOK_URL"):
  722. results["feishu"] = self._send_rss_feishu(
  723. rss_items, feeds_info, proxy_url
  724. )
  725. # 钉钉
  726. if self.config.get("DINGTALK_WEBHOOK_URL"):
  727. results["dingtalk"] = self._send_rss_dingtalk(
  728. rss_items, feeds_info, proxy_url
  729. )
  730. # 企业微信
  731. if self.config.get("WEWORK_WEBHOOK_URL"):
  732. results["wework"] = self._send_rss_markdown(
  733. rss_items, feeds_info, proxy_url, "wework"
  734. )
  735. # Telegram
  736. if self.config.get("TELEGRAM_BOT_TOKEN") and self.config.get("TELEGRAM_CHAT_ID"):
  737. results["telegram"] = self._send_rss_markdown(
  738. rss_items, feeds_info, proxy_url, "telegram"
  739. )
  740. # ntfy
  741. if self.config.get("NTFY_SERVER_URL") and self.config.get("NTFY_TOPIC"):
  742. results["ntfy"] = self._send_rss_markdown(
  743. rss_items, feeds_info, proxy_url, "ntfy"
  744. )
  745. # Bark
  746. if self.config.get("BARK_URL"):
  747. results["bark"] = self._send_rss_markdown(
  748. rss_items, feeds_info, proxy_url, "bark"
  749. )
  750. # Slack
  751. if self.config.get("SLACK_WEBHOOK_URL"):
  752. results["slack"] = self._send_rss_markdown(
  753. rss_items, feeds_info, proxy_url, "slack"
  754. )
  755. # 邮件
  756. if (
  757. self.config.get("EMAIL_FROM")
  758. and self.config.get("EMAIL_PASSWORD")
  759. and self.config.get("EMAIL_TO")
  760. ):
  761. results["email"] = self._send_email(report_type, html_file_path)
  762. return results
  763. def _send_rss_feishu(
  764. self,
  765. rss_items: List[Dict],
  766. feeds_info: Optional[Dict[str, str]],
  767. proxy_url: Optional[str],
  768. ) -> bool:
  769. """发送 RSS 到飞书"""
  770. import requests
  771. content = render_rss_feishu_content(
  772. rss_items=rss_items,
  773. feeds_info=feeds_info,
  774. get_time_func=self.get_time_func,
  775. )
  776. webhooks = parse_multi_account_config(self.config["FEISHU_WEBHOOK_URL"])
  777. webhooks = limit_accounts(webhooks, self.max_accounts, "飞书")
  778. results = []
  779. for i, webhook_url in enumerate(webhooks):
  780. if not webhook_url:
  781. continue
  782. account_label = f"账号{i+1}" if len(webhooks) > 1 else ""
  783. try:
  784. # 分批发送
  785. batches = self.split_content_func(
  786. content, self.config.get("FEISHU_BATCH_SIZE", 29000)
  787. )
  788. for batch_idx, batch_content in enumerate(batches):
  789. payload = {
  790. "msg_type": "interactive",
  791. "card": {
  792. "header": {
  793. "title": {
  794. "tag": "plain_text",
  795. "content": f"📰 RSS 订阅更新 {f'({batch_idx + 1}/{len(batches)})' if len(batches) > 1 else ''}",
  796. },
  797. "template": "green",
  798. },
  799. "elements": [
  800. {"tag": "markdown", "content": batch_content}
  801. ],
  802. },
  803. }
  804. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  805. resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)
  806. resp.raise_for_status()
  807. print(f"✅ 飞书{account_label} RSS 通知发送成功")
  808. results.append(True)
  809. except Exception as e:
  810. print(f"❌ 飞书{account_label} RSS 通知发送失败: {e}")
  811. results.append(False)
  812. return any(results) if results else False
  813. def _send_rss_dingtalk(
  814. self,
  815. rss_items: List[Dict],
  816. feeds_info: Optional[Dict[str, str]],
  817. proxy_url: Optional[str],
  818. ) -> bool:
  819. """发送 RSS 到钉钉"""
  820. import requests
  821. content = render_rss_dingtalk_content(
  822. rss_items=rss_items,
  823. feeds_info=feeds_info,
  824. get_time_func=self.get_time_func,
  825. )
  826. webhooks = parse_multi_account_config(self.config["DINGTALK_WEBHOOK_URL"])
  827. webhooks = limit_accounts(webhooks, self.max_accounts, "钉钉")
  828. results = []
  829. for i, webhook_url in enumerate(webhooks):
  830. if not webhook_url:
  831. continue
  832. account_label = f"账号{i+1}" if len(webhooks) > 1 else ""
  833. try:
  834. batches = self.split_content_func(
  835. content, self.config.get("DINGTALK_BATCH_SIZE", 20000)
  836. )
  837. for batch_idx, batch_content in enumerate(batches):
  838. title = f"📰 RSS 订阅更新 {f'({batch_idx + 1}/{len(batches)})' if len(batches) > 1 else ''}"
  839. payload = {
  840. "msgtype": "markdown",
  841. "markdown": {
  842. "title": title,
  843. "text": batch_content,
  844. },
  845. }
  846. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  847. resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)
  848. resp.raise_for_status()
  849. print(f"✅ 钉钉{account_label} RSS 通知发送成功")
  850. results.append(True)
  851. except Exception as e:
  852. print(f"❌ 钉钉{account_label} RSS 通知发送失败: {e}")
  853. results.append(False)
  854. return any(results) if results else False
  855. def _send_rss_markdown(
  856. self,
  857. rss_items: List[Dict],
  858. feeds_info: Optional[Dict[str, str]],
  859. proxy_url: Optional[str],
  860. channel: str,
  861. ) -> bool:
  862. """发送 RSS 到 Markdown 兼容渠道(企业微信、Telegram、ntfy、Bark、Slack)"""
  863. content = render_rss_markdown_content(
  864. rss_items=rss_items,
  865. feeds_info=feeds_info,
  866. get_time_func=self.get_time_func,
  867. )
  868. try:
  869. if channel == "wework":
  870. return self._send_rss_wework(content, proxy_url)
  871. elif channel == "telegram":
  872. return self._send_rss_telegram(content, proxy_url)
  873. elif channel == "ntfy":
  874. return self._send_rss_ntfy(content, proxy_url)
  875. elif channel == "bark":
  876. return self._send_rss_bark(content, proxy_url)
  877. elif channel == "slack":
  878. return self._send_rss_slack(content, proxy_url)
  879. except Exception as e:
  880. print(f"❌ {channel} RSS 通知发送失败: {e}")
  881. return False
  882. return False
  883. def _send_rss_wework(self, content: str, proxy_url: Optional[str]) -> bool:
  884. """发送 RSS 到企业微信"""
  885. import requests
  886. webhooks = parse_multi_account_config(self.config["WEWORK_WEBHOOK_URL"])
  887. webhooks = limit_accounts(webhooks, self.max_accounts, "企业微信")
  888. results = []
  889. for i, webhook_url in enumerate(webhooks):
  890. if not webhook_url:
  891. continue
  892. account_label = f"账号{i+1}" if len(webhooks) > 1 else ""
  893. try:
  894. batches = self.split_content_func(
  895. content, self.config.get("MESSAGE_BATCH_SIZE", 4000)
  896. )
  897. for batch_content in batches:
  898. payload = {
  899. "msgtype": "markdown",
  900. "markdown": {"content": batch_content},
  901. }
  902. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  903. resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)
  904. resp.raise_for_status()
  905. print(f"✅ 企业微信{account_label} RSS 通知发送成功")
  906. results.append(True)
  907. except Exception as e:
  908. print(f"❌ 企业微信{account_label} RSS 通知发送失败: {e}")
  909. results.append(False)
  910. return any(results) if results else False
  911. def _send_rss_telegram(self, content: str, proxy_url: Optional[str]) -> bool:
  912. """发送 RSS 到 Telegram"""
  913. import requests
  914. tokens = parse_multi_account_config(self.config["TELEGRAM_BOT_TOKEN"])
  915. chat_ids = parse_multi_account_config(self.config["TELEGRAM_CHAT_ID"])
  916. if not tokens or not chat_ids:
  917. return False
  918. results = []
  919. for i in range(min(len(tokens), len(chat_ids), self.max_accounts)):
  920. token = tokens[i]
  921. chat_id = chat_ids[i]
  922. if not token or not chat_id:
  923. continue
  924. account_label = f"账号{i+1}" if len(tokens) > 1 else ""
  925. try:
  926. batches = self.split_content_func(
  927. content, self.config.get("MESSAGE_BATCH_SIZE", 4000)
  928. )
  929. for batch_content in batches:
  930. url = f"https://api.telegram.org/bot{token}/sendMessage"
  931. payload = {
  932. "chat_id": chat_id,
  933. "text": batch_content,
  934. "parse_mode": "Markdown",
  935. }
  936. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  937. resp = requests.post(url, json=payload, proxies=proxies, timeout=30)
  938. resp.raise_for_status()
  939. print(f"✅ Telegram{account_label} RSS 通知发送成功")
  940. results.append(True)
  941. except Exception as e:
  942. print(f"❌ Telegram{account_label} RSS 通知发送失败: {e}")
  943. results.append(False)
  944. return any(results) if results else False
  945. def _send_rss_ntfy(self, content: str, proxy_url: Optional[str]) -> bool:
  946. """发送 RSS 到 ntfy"""
  947. import requests
  948. server_url = self.config["NTFY_SERVER_URL"]
  949. topics = parse_multi_account_config(self.config["NTFY_TOPIC"])
  950. tokens = parse_multi_account_config(self.config.get("NTFY_TOKEN", ""))
  951. if not server_url or not topics:
  952. return False
  953. topics = limit_accounts(topics, self.max_accounts, "ntfy")
  954. results = []
  955. for i, topic in enumerate(topics):
  956. if not topic:
  957. continue
  958. token = tokens[i] if tokens and i < len(tokens) else ""
  959. account_label = f"账号{i+1}" if len(topics) > 1 else ""
  960. try:
  961. batches = self.split_content_func(content, 3800)
  962. for batch_content in batches:
  963. url = f"{server_url.rstrip('/')}/{topic}"
  964. headers = {"Title": "RSS 订阅更新", "Markdown": "yes"}
  965. if token:
  966. headers["Authorization"] = f"Bearer {token}"
  967. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  968. resp = requests.post(
  969. url, data=batch_content.encode("utf-8"),
  970. headers=headers, proxies=proxies, timeout=30
  971. )
  972. resp.raise_for_status()
  973. print(f"✅ ntfy{account_label} RSS 通知发送成功")
  974. results.append(True)
  975. except Exception as e:
  976. print(f"❌ ntfy{account_label} RSS 通知发送失败: {e}")
  977. results.append(False)
  978. return any(results) if results else False
  979. def _send_rss_bark(self, content: str, proxy_url: Optional[str]) -> bool:
  980. """发送 RSS 到 Bark"""
  981. import requests
  982. import urllib.parse
  983. urls = parse_multi_account_config(self.config["BARK_URL"])
  984. urls = limit_accounts(urls, self.max_accounts, "Bark")
  985. results = []
  986. for i, bark_url in enumerate(urls):
  987. if not bark_url:
  988. continue
  989. account_label = f"账号{i+1}" if len(urls) > 1 else ""
  990. try:
  991. batches = self.split_content_func(
  992. content, self.config.get("BARK_BATCH_SIZE", 3600)
  993. )
  994. for batch_content in batches:
  995. title = urllib.parse.quote("📰 RSS 订阅更新")
  996. body = urllib.parse.quote(batch_content)
  997. url = f"{bark_url.rstrip('/')}/{title}/{body}"
  998. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  999. resp = requests.get(url, proxies=proxies, timeout=30)
  1000. resp.raise_for_status()
  1001. print(f"✅ Bark{account_label} RSS 通知发送成功")
  1002. results.append(True)
  1003. except Exception as e:
  1004. print(f"❌ Bark{account_label} RSS 通知发送失败: {e}")
  1005. results.append(False)
  1006. return any(results) if results else False
  1007. def _send_rss_slack(self, content: str, proxy_url: Optional[str]) -> bool:
  1008. """发送 RSS 到 Slack"""
  1009. import requests
  1010. webhooks = parse_multi_account_config(self.config["SLACK_WEBHOOK_URL"])
  1011. webhooks = limit_accounts(webhooks, self.max_accounts, "Slack")
  1012. results = []
  1013. for i, webhook_url in enumerate(webhooks):
  1014. if not webhook_url:
  1015. continue
  1016. account_label = f"账号{i+1}" if len(webhooks) > 1 else ""
  1017. try:
  1018. batches = self.split_content_func(
  1019. content, self.config.get("SLACK_BATCH_SIZE", 4000)
  1020. )
  1021. for batch_content in batches:
  1022. payload = {
  1023. "blocks": [
  1024. {
  1025. "type": "section",
  1026. "text": {
  1027. "type": "mrkdwn",
  1028. "text": batch_content,
  1029. },
  1030. }
  1031. ]
  1032. }
  1033. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  1034. resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)
  1035. resp.raise_for_status()
  1036. print(f"✅ Slack{account_label} RSS 通知发送成功")
  1037. results.append(True)
  1038. except Exception as e:
  1039. print(f"❌ Slack{account_label} RSS 通知发送失败: {e}")
  1040. results.append(False)
  1041. return any(results) if results else False