senders.py 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404
  1. # coding=utf-8
  2. """
  3. 消息发送器模块
  4. 将报告数据发送到各种通知渠道:
  5. - 飞书 (Feishu/Lark)
  6. - 钉钉 (DingTalk)
  7. - 企业微信 (WeCom/WeWork)
  8. - Telegram
  9. - 邮件 (Email)
  10. - ntfy
  11. - Bark
  12. - Slack
  13. 每个发送函数都支持分批发送,并通过参数化配置实现与 CONFIG 的解耦。
  14. """
  15. import smtplib
  16. import time
  17. import json
  18. from datetime import datetime
  19. from email.header import Header
  20. from email.mime.multipart import MIMEMultipart
  21. from email.mime.text import MIMEText
  22. from email.utils import formataddr, formatdate, make_msgid
  23. from pathlib import Path
  24. from typing import Any, Callable, Dict, Optional
  25. from urllib.parse import urlparse
  26. import requests
  27. from .batch import add_batch_headers, get_max_batch_header_size
  28. from .formatters import convert_markdown_to_mrkdwn, strip_markdown
  29. def _render_ai_analysis(ai_analysis: Any, channel: str) -> str:
  30. """渲染 AI 分析内容为指定渠道格式"""
  31. if not ai_analysis:
  32. return ""
  33. try:
  34. from trendradar.ai.formatter import get_ai_analysis_renderer
  35. renderer = get_ai_analysis_renderer(channel)
  36. return renderer(ai_analysis)
  37. except ImportError:
  38. return ""
  39. # === SMTP 邮件配置 ===
  40. SMTP_CONFIGS = {
  41. # Gmail(使用 STARTTLS)
  42. "gmail.com": {"server": "smtp.gmail.com", "port": 587, "encryption": "TLS"},
  43. # QQ邮箱(使用 SSL,更稳定)
  44. "qq.com": {"server": "smtp.qq.com", "port": 465, "encryption": "SSL"},
  45. # Outlook(使用 STARTTLS)
  46. "outlook.com": {"server": "smtp-mail.outlook.com", "port": 587, "encryption": "TLS"},
  47. "hotmail.com": {"server": "smtp-mail.outlook.com", "port": 587, "encryption": "TLS"},
  48. "live.com": {"server": "smtp-mail.outlook.com", "port": 587, "encryption": "TLS"},
  49. # 网易邮箱(使用 SSL,更稳定)
  50. "163.com": {"server": "smtp.163.com", "port": 465, "encryption": "SSL"},
  51. "126.com": {"server": "smtp.126.com", "port": 465, "encryption": "SSL"},
  52. # 新浪邮箱(使用 SSL)
  53. "sina.com": {"server": "smtp.sina.com", "port": 465, "encryption": "SSL"},
  54. # 搜狐邮箱(使用 SSL)
  55. "sohu.com": {"server": "smtp.sohu.com", "port": 465, "encryption": "SSL"},
  56. # 天翼邮箱(使用 SSL)
  57. "189.cn": {"server": "smtp.189.cn", "port": 465, "encryption": "SSL"},
  58. # 阿里云邮箱(使用 TLS)
  59. "aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "encryption": "TLS"},
  60. # Yandex邮箱(使用 TLS)
  61. "yandex.com": {"server": "smtp.yandex.com", "port": 465, "encryption": "TLS"},
  62. # iCloud邮箱(使用 SSL)
  63. "icloud.com": {"server": "smtp.mail.me.com", "port": 587, "encryption": "SSL"},
  64. }
  65. def send_to_feishu(
  66. webhook_url: str,
  67. report_data: Dict,
  68. report_type: str,
  69. update_info: Optional[Dict] = None,
  70. proxy_url: Optional[str] = None,
  71. mode: str = "daily",
  72. account_label: str = "",
  73. *,
  74. batch_size: int = 29000,
  75. batch_interval: float = 1.0,
  76. split_content_func: Callable = None,
  77. get_time_func: Callable = None,
  78. rss_items: Optional[list] = None,
  79. rss_new_items: Optional[list] = None,
  80. ai_analysis: Any = None,
  81. display_regions: Optional[Dict] = None,
  82. standalone_data: Optional[Dict] = None,
  83. ) -> bool:
  84. """
  85. 发送到飞书(支持分批发送,支持热榜+RSS合并+独立展示区)
  86. Args:
  87. webhook_url: 飞书 Webhook URL
  88. report_data: 报告数据
  89. report_type: 报告类型
  90. update_info: 更新信息(可选)
  91. proxy_url: 代理 URL(可选)
  92. mode: 报告模式 (daily/current)
  93. account_label: 账号标签(多账号时显示)
  94. batch_size: 批次大小(字节)
  95. batch_interval: 批次发送间隔(秒)
  96. split_content_func: 内容分批函数
  97. get_time_func: 获取当前时间的函数
  98. rss_items: RSS 统计条目列表(可选,用于合并推送)
  99. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  100. Returns:
  101. bool: 发送是否成功
  102. """
  103. headers = {"Content-Type": "application/json"}
  104. proxies = None
  105. if proxy_url:
  106. proxies = {"http": proxy_url, "https": proxy_url}
  107. # 日志前缀
  108. log_prefix = f"飞书{account_label}" if account_label else "飞书"
  109. # 渲染 AI 分析内容(如果有)
  110. ai_content = None
  111. ai_stats = None
  112. if ai_analysis:
  113. ai_content = _render_ai_analysis(ai_analysis, "feishu")
  114. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  115. if getattr(ai_analysis, "success", False):
  116. ai_stats = {
  117. "total_news": getattr(ai_analysis, "total_news", 0),
  118. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  119. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  120. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  121. "rss_count": getattr(ai_analysis, "rss_count", 0),
  122. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  123. }
  124. # 预留批次头部空间,避免添加头部后超限
  125. header_reserve = get_max_batch_header_size("feishu")
  126. batches = split_content_func(
  127. report_data,
  128. "feishu",
  129. update_info,
  130. max_bytes=batch_size - header_reserve,
  131. mode=mode,
  132. rss_items=rss_items,
  133. rss_new_items=rss_new_items,
  134. ai_content=ai_content,
  135. standalone_data=standalone_data,
  136. ai_stats=ai_stats,
  137. report_type=report_type,
  138. )
  139. # 统一添加批次头部(已预留空间,不会超限)
  140. batches = add_batch_headers(batches, "feishu", batch_size)
  141. print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]")
  142. # 逐批发送
  143. for i, batch_content in enumerate(batches, 1):
  144. content_size = len(batch_content.encode("utf-8"))
  145. print(
  146. f"发送{log_prefix}第 {i}/{len(batches)} 批次,大小:{content_size} 字节 [{report_type}]"
  147. )
  148. # 根据 webhook 域名选择 payload 格式
  149. # www.feishu.cn 使用纯文本格式,其他域名(open.feishu.cn/open.larksuite.com)使用卡片 2.0
  150. if "www.feishu.cn" in webhook_url:
  151. payload = {
  152. "msg_type": "text",
  153. "content": {
  154. "text": batch_content,
  155. },
  156. }
  157. else:
  158. payload = {
  159. "msg_type": "interactive",
  160. "card": {
  161. "schema": "2.0",
  162. "body": {
  163. "elements": [
  164. {"tag": "markdown", "content": batch_content}
  165. ]
  166. },
  167. },
  168. }
  169. try:
  170. response = requests.post(
  171. webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30
  172. )
  173. if response.status_code == 200:
  174. result = response.json()
  175. # 检查飞书的响应状态
  176. if result.get("StatusCode") == 0 or result.get("code") == 0:
  177. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
  178. # 批次间间隔
  179. if i < len(batches):
  180. time.sleep(batch_interval)
  181. else:
  182. error_msg = result.get("msg") or result.get("StatusMessage", "未知错误")
  183. print(
  184. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],错误:{error_msg}"
  185. )
  186. return False
  187. else:
  188. print(
  189. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],状态码:{response.status_code}"
  190. )
  191. return False
  192. except Exception as e:
  193. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]:{e}")
  194. return False
  195. print(f"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]")
  196. return True
  197. def send_to_dingtalk(
  198. webhook_url: str,
  199. report_data: Dict,
  200. report_type: str,
  201. update_info: Optional[Dict] = None,
  202. proxy_url: Optional[str] = None,
  203. mode: str = "daily",
  204. account_label: str = "",
  205. *,
  206. batch_size: int = 20000,
  207. batch_interval: float = 1.0,
  208. split_content_func: Callable = None,
  209. rss_items: Optional[list] = None,
  210. rss_new_items: Optional[list] = None,
  211. ai_analysis: Any = None,
  212. display_regions: Optional[Dict] = None,
  213. standalone_data: Optional[Dict] = None,
  214. ) -> bool:
  215. """
  216. 发送到钉钉(支持分批发送,支持热榜+RSS合并+独立展示区)
  217. Args:
  218. webhook_url: 钉钉 Webhook URL
  219. report_data: 报告数据
  220. report_type: 报告类型
  221. update_info: 更新信息(可选)
  222. proxy_url: 代理 URL(可选)
  223. mode: 报告模式 (daily/current)
  224. account_label: 账号标签(多账号时显示)
  225. batch_size: 批次大小(字节)
  226. batch_interval: 批次发送间隔(秒)
  227. split_content_func: 内容分批函数
  228. rss_items: RSS 统计条目列表(可选,用于合并推送)
  229. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  230. Returns:
  231. bool: 发送是否成功
  232. """
  233. headers = {"Content-Type": "application/json"}
  234. proxies = None
  235. if proxy_url:
  236. proxies = {"http": proxy_url, "https": proxy_url}
  237. # 日志前缀
  238. log_prefix = f"钉钉{account_label}" if account_label else "钉钉"
  239. # 渲染 AI 分析内容(如果有)
  240. ai_content = None
  241. ai_stats = None
  242. if ai_analysis:
  243. ai_content = _render_ai_analysis(ai_analysis, "dingtalk")
  244. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  245. if getattr(ai_analysis, "success", False):
  246. ai_stats = {
  247. "total_news": getattr(ai_analysis, "total_news", 0),
  248. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  249. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  250. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  251. "rss_count": getattr(ai_analysis, "rss_count", 0),
  252. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  253. }
  254. # 预留批次头部空间,避免添加头部后超限
  255. header_reserve = get_max_batch_header_size("dingtalk")
  256. batches = split_content_func(
  257. report_data,
  258. "dingtalk",
  259. update_info,
  260. max_bytes=batch_size - header_reserve,
  261. mode=mode,
  262. rss_items=rss_items,
  263. rss_new_items=rss_new_items,
  264. ai_content=ai_content,
  265. standalone_data=standalone_data,
  266. ai_stats=ai_stats,
  267. report_type=report_type,
  268. )
  269. # 统一添加批次头部(已预留空间,不会超限)
  270. batches = add_batch_headers(batches, "dingtalk", batch_size)
  271. print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]")
  272. # 逐批发送
  273. for i, batch_content in enumerate(batches, 1):
  274. content_size = len(batch_content.encode("utf-8"))
  275. print(
  276. f"发送{log_prefix}第 {i}/{len(batches)} 批次,大小:{content_size} 字节 [{report_type}]"
  277. )
  278. payload = {
  279. "msgtype": "markdown",
  280. "markdown": {
  281. "title": f"TrendRadar 热点分析报告 - {report_type}",
  282. "text": batch_content,
  283. },
  284. }
  285. try:
  286. response = requests.post(
  287. webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30
  288. )
  289. if response.status_code == 200:
  290. result = response.json()
  291. if result.get("errcode") == 0:
  292. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
  293. # 批次间间隔
  294. if i < len(batches):
  295. time.sleep(batch_interval)
  296. else:
  297. print(
  298. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],错误:{result.get('errmsg')}"
  299. )
  300. return False
  301. else:
  302. print(
  303. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],状态码:{response.status_code}"
  304. )
  305. return False
  306. except Exception as e:
  307. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]:{e}")
  308. return False
  309. print(f"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]")
  310. return True
  311. def send_to_wework(
  312. webhook_url: str,
  313. report_data: Dict,
  314. report_type: str,
  315. update_info: Optional[Dict] = None,
  316. proxy_url: Optional[str] = None,
  317. mode: str = "daily",
  318. account_label: str = "",
  319. *,
  320. batch_size: int = 4000,
  321. batch_interval: float = 1.0,
  322. msg_type: str = "markdown",
  323. split_content_func: Callable = None,
  324. rss_items: Optional[list] = None,
  325. rss_new_items: Optional[list] = None,
  326. ai_analysis: Any = None,
  327. display_regions: Optional[Dict] = None,
  328. standalone_data: Optional[Dict] = None,
  329. ) -> bool:
  330. """
  331. 发送到企业微信(支持分批发送,支持 markdown 和 text 两种格式,支持热榜+RSS合并+独立展示区)
  332. Args:
  333. webhook_url: 企业微信 Webhook URL
  334. report_data: 报告数据
  335. report_type: 报告类型
  336. update_info: 更新信息(可选)
  337. proxy_url: 代理 URL(可选)
  338. mode: 报告模式 (daily/current)
  339. account_label: 账号标签(多账号时显示)
  340. batch_size: 批次大小(字节)
  341. batch_interval: 批次发送间隔(秒)
  342. msg_type: 消息类型 (markdown/text)
  343. split_content_func: 内容分批函数
  344. rss_items: RSS 统计条目列表(可选,用于合并推送)
  345. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  346. Returns:
  347. bool: 发送是否成功
  348. """
  349. headers = {"Content-Type": "application/json"}
  350. proxies = None
  351. if proxy_url:
  352. proxies = {"http": proxy_url, "https": proxy_url}
  353. # 日志前缀
  354. log_prefix = f"企业微信{account_label}" if account_label else "企业微信"
  355. # 获取消息类型配置(markdown 或 text)
  356. is_text_mode = msg_type.lower() == "text"
  357. if is_text_mode:
  358. print(f"{log_prefix}使用 text 格式(个人微信模式)[{report_type}]")
  359. else:
  360. print(f"{log_prefix}使用 markdown 格式(群机器人模式)[{report_type}]")
  361. # text 模式使用 wework_text,markdown 模式使用 wework
  362. header_format_type = "wework_text" if is_text_mode else "wework"
  363. # 渲染 AI 分析内容(如果有)
  364. ai_content = None
  365. ai_stats = None
  366. if ai_analysis:
  367. ai_content = _render_ai_analysis(ai_analysis, "wework")
  368. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  369. if getattr(ai_analysis, "success", False):
  370. ai_stats = {
  371. "total_news": getattr(ai_analysis, "total_news", 0),
  372. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  373. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  374. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  375. "rss_count": getattr(ai_analysis, "rss_count", 0),
  376. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  377. }
  378. # 获取分批内容,预留批次头部空间
  379. header_reserve = get_max_batch_header_size(header_format_type)
  380. batches = split_content_func(
  381. report_data, "wework", update_info, max_bytes=batch_size - header_reserve, mode=mode,
  382. rss_items=rss_items,
  383. rss_new_items=rss_new_items,
  384. ai_content=ai_content,
  385. standalone_data=standalone_data,
  386. ai_stats=ai_stats,
  387. report_type=report_type,
  388. )
  389. # 统一添加批次头部(已预留空间,不会超限)
  390. batches = add_batch_headers(batches, header_format_type, batch_size)
  391. print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]")
  392. # 逐批发送
  393. for i, batch_content in enumerate(batches, 1):
  394. # 根据消息类型构建 payload
  395. if is_text_mode:
  396. # text 格式:去除 markdown 语法
  397. plain_content = strip_markdown(batch_content)
  398. payload = {"msgtype": "text", "text": {"content": plain_content}}
  399. content_size = len(plain_content.encode("utf-8"))
  400. else:
  401. # markdown 格式:保持原样
  402. payload = {"msgtype": "markdown", "markdown": {"content": batch_content}}
  403. content_size = len(batch_content.encode("utf-8"))
  404. print(
  405. f"发送{log_prefix}第 {i}/{len(batches)} 批次,大小:{content_size} 字节 [{report_type}]"
  406. )
  407. try:
  408. response = requests.post(
  409. webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30
  410. )
  411. if response.status_code == 200:
  412. result = response.json()
  413. if result.get("errcode") == 0:
  414. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
  415. # 批次间间隔
  416. if i < len(batches):
  417. time.sleep(batch_interval)
  418. else:
  419. print(
  420. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],错误:{result.get('errmsg')}"
  421. )
  422. return False
  423. else:
  424. print(
  425. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],状态码:{response.status_code}"
  426. )
  427. return False
  428. except Exception as e:
  429. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]:{e}")
  430. return False
  431. print(f"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]")
  432. return True
  433. def send_to_telegram(
  434. bot_token: str,
  435. chat_id: str,
  436. report_data: Dict,
  437. report_type: str,
  438. update_info: Optional[Dict] = None,
  439. proxy_url: Optional[str] = None,
  440. mode: str = "daily",
  441. account_label: str = "",
  442. *,
  443. batch_size: int = 4000,
  444. batch_interval: float = 1.0,
  445. split_content_func: Callable = None,
  446. rss_items: Optional[list] = None,
  447. rss_new_items: Optional[list] = None,
  448. ai_analysis: Any = None,
  449. display_regions: Optional[Dict] = None,
  450. standalone_data: Optional[Dict] = None,
  451. ) -> bool:
  452. """
  453. 发送到 Telegram(支持分批发送,支持热榜+RSS合并+独立展示区)
  454. Args:
  455. bot_token: Telegram Bot Token
  456. chat_id: Telegram Chat ID
  457. report_data: 报告数据
  458. report_type: 报告类型
  459. update_info: 更新信息(可选)
  460. proxy_url: 代理 URL(可选)
  461. mode: 报告模式 (daily/current)
  462. account_label: 账号标签(多账号时显示)
  463. batch_size: 批次大小(字节)
  464. batch_interval: 批次发送间隔(秒)
  465. split_content_func: 内容分批函数
  466. rss_items: RSS 统计条目列表(可选,用于合并推送)
  467. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  468. Returns:
  469. bool: 发送是否成功
  470. """
  471. headers = {"Content-Type": "application/json"}
  472. url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
  473. proxies = None
  474. if proxy_url:
  475. proxies = {"http": proxy_url, "https": proxy_url}
  476. # 日志前缀
  477. log_prefix = f"Telegram{account_label}" if account_label else "Telegram"
  478. # 渲染 AI 分析内容(如果有)
  479. ai_content = None
  480. ai_stats = None
  481. if ai_analysis:
  482. ai_content = _render_ai_analysis(ai_analysis, "telegram")
  483. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  484. if getattr(ai_analysis, "success", False):
  485. ai_stats = {
  486. "total_news": getattr(ai_analysis, "total_news", 0),
  487. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  488. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  489. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  490. "rss_count": getattr(ai_analysis, "rss_count", 0),
  491. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  492. }
  493. # 获取分批内容,预留批次头部空间
  494. header_reserve = get_max_batch_header_size("telegram")
  495. batches = split_content_func(
  496. report_data, "telegram", update_info, max_bytes=batch_size - header_reserve, mode=mode,
  497. rss_items=rss_items,
  498. rss_new_items=rss_new_items,
  499. ai_content=ai_content,
  500. standalone_data=standalone_data,
  501. ai_stats=ai_stats,
  502. report_type=report_type,
  503. )
  504. # 统一添加批次头部(已预留空间,不会超限)
  505. batches = add_batch_headers(batches, "telegram", batch_size)
  506. print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]")
  507. # 逐批发送
  508. for i, batch_content in enumerate(batches, 1):
  509. content_size = len(batch_content.encode("utf-8"))
  510. print(
  511. f"发送{log_prefix}第 {i}/{len(batches)} 批次,大小:{content_size} 字节 [{report_type}]"
  512. )
  513. payload = {
  514. "chat_id": chat_id,
  515. "text": batch_content,
  516. "parse_mode": "HTML",
  517. "disable_web_page_preview": True,
  518. }
  519. try:
  520. response = requests.post(
  521. url, headers=headers, json=payload, proxies=proxies, timeout=30
  522. )
  523. if response.status_code == 200:
  524. result = response.json()
  525. if result.get("ok"):
  526. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
  527. # 批次间间隔
  528. if i < len(batches):
  529. time.sleep(batch_interval)
  530. else:
  531. print(
  532. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],错误:{result.get('description')}"
  533. )
  534. return False
  535. else:
  536. print(
  537. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],状态码:{response.status_code}"
  538. )
  539. return False
  540. except Exception as e:
  541. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]:{e}")
  542. return False
  543. print(f"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]")
  544. return True
  545. def send_to_email(
  546. from_email: str,
  547. password: str,
  548. to_email: str,
  549. report_type: str,
  550. html_file_path: str,
  551. custom_smtp_server: Optional[str] = None,
  552. custom_smtp_port: Optional[int] = None,
  553. *,
  554. get_time_func: Callable = None,
  555. ) -> bool:
  556. """
  557. 发送邮件通知
  558. Args:
  559. from_email: 发件人邮箱
  560. password: 邮箱密码/授权码
  561. to_email: 收件人邮箱(多个用逗号分隔)
  562. report_type: 报告类型
  563. html_file_path: HTML 报告文件路径
  564. custom_smtp_server: 自定义 SMTP 服务器(可选)
  565. custom_smtp_port: 自定义 SMTP 端口(可选)
  566. get_time_func: 获取当前时间的函数
  567. Returns:
  568. bool: 发送是否成功
  569. Note:
  570. AI 分析内容已在 HTML 生成时嵌入,无需再追加
  571. """
  572. try:
  573. if not html_file_path or not Path(html_file_path).exists():
  574. print(f"错误:HTML文件不存在或未提供: {html_file_path}")
  575. return False
  576. print(f"使用HTML文件: {html_file_path}")
  577. with open(html_file_path, "r", encoding="utf-8") as f:
  578. html_content = f.read()
  579. domain = from_email.split("@")[-1].lower()
  580. if custom_smtp_server and custom_smtp_port:
  581. # 使用自定义 SMTP 配置
  582. smtp_server = custom_smtp_server
  583. smtp_port = int(custom_smtp_port)
  584. # 根据端口判断加密方式:465=SSL, 587=TLS
  585. if smtp_port == 465:
  586. use_tls = False # SSL 模式(SMTP_SSL)
  587. elif smtp_port == 587:
  588. use_tls = True # TLS 模式(STARTTLS)
  589. else:
  590. # 其他端口优先尝试 TLS(更安全,更广泛支持)
  591. use_tls = True
  592. elif domain in SMTP_CONFIGS:
  593. # 使用预设配置
  594. config = SMTP_CONFIGS[domain]
  595. smtp_server = config["server"]
  596. smtp_port = config["port"]
  597. use_tls = config["encryption"] == "TLS"
  598. else:
  599. print(f"未识别的邮箱服务商: {domain},使用通用 SMTP 配置")
  600. smtp_server = f"smtp.{domain}"
  601. smtp_port = 587
  602. use_tls = True
  603. msg = MIMEMultipart("alternative")
  604. # 严格按照 RFC 标准设置 From header
  605. sender_name = "TrendRadar"
  606. msg["From"] = formataddr((sender_name, from_email))
  607. # 设置收件人
  608. recipients = [addr.strip() for addr in to_email.split(",")]
  609. if len(recipients) == 1:
  610. msg["To"] = recipients[0]
  611. else:
  612. msg["To"] = ", ".join(recipients)
  613. # 设置邮件主题
  614. now = get_time_func() if get_time_func else datetime.now()
  615. subject = f"TrendRadar 热点分析报告 - {report_type} - {now.strftime('%m月%d日 %H:%M')}"
  616. msg["Subject"] = Header(subject, "utf-8")
  617. # 设置其他标准 header
  618. msg["MIME-Version"] = "1.0"
  619. msg["Date"] = formatdate(localtime=True)
  620. msg["Message-ID"] = make_msgid()
  621. # 添加纯文本部分(作为备选)
  622. text_content = f"""
  623. TrendRadar 热点分析报告
  624. ========================
  625. 报告类型:{report_type}
  626. 生成时间:{now.strftime('%Y-%m-%d %H:%M:%S')}
  627. 请使用支持HTML的邮件客户端查看完整报告内容。
  628. """
  629. text_part = MIMEText(text_content, "plain", "utf-8")
  630. msg.attach(text_part)
  631. html_part = MIMEText(html_content, "html", "utf-8")
  632. msg.attach(html_part)
  633. print(f"正在发送邮件到 {to_email}...")
  634. print(f"SMTP 服务器: {smtp_server}:{smtp_port}")
  635. print(f"发件人: {from_email}")
  636. try:
  637. if use_tls:
  638. # TLS 模式
  639. server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
  640. server.set_debuglevel(0) # 设为1可以查看详细调试信息
  641. server.ehlo()
  642. server.starttls()
  643. server.ehlo()
  644. else:
  645. # SSL 模式
  646. server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
  647. server.set_debuglevel(0)
  648. server.ehlo()
  649. # 登录
  650. server.login(from_email, password)
  651. # 发送邮件
  652. server.send_message(msg)
  653. server.quit()
  654. print(f"邮件发送成功 [{report_type}] -> {to_email}")
  655. return True
  656. except smtplib.SMTPServerDisconnected:
  657. print("邮件发送失败:服务器意外断开连接,请检查网络或稍后重试")
  658. return False
  659. except smtplib.SMTPAuthenticationError as e:
  660. print("邮件发送失败:认证错误,请检查邮箱和密码/授权码")
  661. print(f"详细错误: {str(e)}")
  662. return False
  663. except smtplib.SMTPRecipientsRefused as e:
  664. print(f"邮件发送失败:收件人地址被拒绝 {e}")
  665. return False
  666. except smtplib.SMTPSenderRefused as e:
  667. print(f"邮件发送失败:发件人地址被拒绝 {e}")
  668. return False
  669. except smtplib.SMTPDataError as e:
  670. print(f"邮件发送失败:邮件数据错误 {e}")
  671. return False
  672. except smtplib.SMTPConnectError as e:
  673. print(f"邮件发送失败:无法连接到 SMTP 服务器 {smtp_server}:{smtp_port}")
  674. print(f"详细错误: {str(e)}")
  675. return False
  676. except Exception as e:
  677. print(f"邮件发送失败 [{report_type}]:{e}")
  678. import traceback
  679. traceback.print_exc()
  680. return False
  681. def send_to_ntfy(
  682. server_url: str,
  683. topic: str,
  684. token: Optional[str],
  685. report_data: Dict,
  686. report_type: str,
  687. update_info: Optional[Dict] = None,
  688. proxy_url: Optional[str] = None,
  689. mode: str = "daily",
  690. account_label: str = "",
  691. *,
  692. batch_size: int = 3800,
  693. split_content_func: Callable = None,
  694. rss_items: Optional[list] = None,
  695. rss_new_items: Optional[list] = None,
  696. ai_analysis: Any = None,
  697. display_regions: Optional[Dict] = None,
  698. standalone_data: Optional[Dict] = None,
  699. ) -> bool:
  700. """
  701. 发送到 ntfy(支持分批发送,严格遵守4KB限制,支持热榜+RSS合并+独立展示区)
  702. Args:
  703. server_url: ntfy 服务器 URL
  704. topic: ntfy 主题
  705. token: ntfy 访问令牌(可选)
  706. report_data: 报告数据
  707. report_type: 报告类型
  708. update_info: 更新信息(可选)
  709. proxy_url: 代理 URL(可选)
  710. mode: 报告模式 (daily/current)
  711. account_label: 账号标签(多账号时显示)
  712. batch_size: 批次大小(字节)
  713. split_content_func: 内容分批函数
  714. rss_items: RSS 统计条目列表(可选,用于合并推送)
  715. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  716. Returns:
  717. bool: 发送是否成功
  718. """
  719. # 日志前缀
  720. log_prefix = f"ntfy{account_label}" if account_label else "ntfy"
  721. # 避免 HTTP header 编码问题
  722. report_type_en_map = {
  723. "全天汇总": "Daily Summary",
  724. "当前榜单": "Current Ranking",
  725. "增量分析": "Incremental Update",
  726. "通知连通性测试": "Notification Test",
  727. }
  728. report_type_en = report_type_en_map.get(report_type, "News Report")
  729. headers = {
  730. "Content-Type": "text/plain; charset=utf-8",
  731. "Markdown": "yes",
  732. "Title": report_type_en,
  733. "Priority": "default",
  734. "Tags": "news",
  735. }
  736. if token:
  737. headers["Authorization"] = f"Bearer {token}"
  738. # 构建完整URL,确保格式正确
  739. base_url = server_url.rstrip("/")
  740. if not base_url.startswith(("http://", "https://")):
  741. base_url = f"https://{base_url}"
  742. url = f"{base_url}/{topic}"
  743. proxies = None
  744. if proxy_url:
  745. proxies = {"http": proxy_url, "https": proxy_url}
  746. # 渲染 AI 分析内容(如果有),合并到主内容中
  747. ai_content = None
  748. ai_stats = None
  749. if ai_analysis:
  750. ai_content = _render_ai_analysis(ai_analysis, "ntfy")
  751. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  752. if getattr(ai_analysis, "success", False):
  753. ai_stats = {
  754. "total_news": getattr(ai_analysis, "total_news", 0),
  755. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  756. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  757. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  758. "rss_count": getattr(ai_analysis, "rss_count", 0),
  759. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  760. }
  761. # 获取分批内容,预留批次头部空间
  762. header_reserve = get_max_batch_header_size("ntfy")
  763. batches = split_content_func(
  764. report_data, "ntfy", update_info, max_bytes=batch_size - header_reserve, mode=mode,
  765. rss_items=rss_items,
  766. rss_new_items=rss_new_items,
  767. ai_content=ai_content,
  768. standalone_data=standalone_data,
  769. ai_stats=ai_stats,
  770. report_type=report_type,
  771. )
  772. # 统一添加批次头部(已预留空间,不会超限)
  773. batches = add_batch_headers(batches, "ntfy", batch_size)
  774. total_batches = len(batches)
  775. print(f"{log_prefix}消息分为 {total_batches} 批次发送 [{report_type}]")
  776. # 反转批次顺序,使得在ntfy客户端显示时顺序正确
  777. # ntfy显示最新消息在上面,所以我们从最后一批开始推送
  778. reversed_batches = list(reversed(batches))
  779. print(f"{log_prefix}将按反向顺序推送(最后批次先推送),确保客户端显示顺序正确")
  780. # 逐批发送(反向顺序)
  781. success_count = 0
  782. for idx, batch_content in enumerate(reversed_batches, 1):
  783. # 计算正确的批次编号(用户视角的编号)
  784. actual_batch_num = total_batches - idx + 1
  785. content_size = len(batch_content.encode("utf-8"))
  786. print(
  787. f"发送{log_prefix}第 {actual_batch_num}/{total_batches} 批次(推送顺序: {idx}/{total_batches}),大小:{content_size} 字节 [{report_type}]"
  788. )
  789. # 检查消息大小,确保不超过4KB
  790. if content_size > 4096:
  791. print(f"警告:{log_prefix}第 {actual_batch_num} 批次消息过大({content_size} 字节),可能被拒绝")
  792. # 更新 headers 的批次标识
  793. current_headers = headers.copy()
  794. if total_batches > 1:
  795. current_headers["Title"] = f"{report_type_en} ({actual_batch_num}/{total_batches})"
  796. try:
  797. response = requests.post(
  798. url,
  799. headers=current_headers,
  800. data=batch_content.encode("utf-8"),
  801. proxies=proxies,
  802. timeout=30,
  803. )
  804. if response.status_code == 200:
  805. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送成功 [{report_type}]")
  806. success_count += 1
  807. if idx < total_batches:
  808. # 公共服务器建议 2-3 秒,自托管可以更短
  809. interval = 2 if "ntfy.sh" in server_url else 1
  810. time.sleep(interval)
  811. elif response.status_code == 429:
  812. print(
  813. f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次速率限制 [{report_type}],等待后重试"
  814. )
  815. time.sleep(10) # 等待10秒后重试
  816. # 重试一次
  817. retry_response = requests.post(
  818. url,
  819. headers=current_headers,
  820. data=batch_content.encode("utf-8"),
  821. proxies=proxies,
  822. timeout=30,
  823. )
  824. if retry_response.status_code == 200:
  825. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次重试成功 [{report_type}]")
  826. success_count += 1
  827. else:
  828. print(
  829. f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次重试失败,状态码:{retry_response.status_code}"
  830. )
  831. elif response.status_code == 413:
  832. print(
  833. f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次消息过大被拒绝 [{report_type}],消息大小:{content_size} 字节"
  834. )
  835. else:
  836. print(
  837. f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送失败 [{report_type}],状态码:{response.status_code}"
  838. )
  839. try:
  840. print(f"错误详情:{response.text}")
  841. except:
  842. pass
  843. except requests.exceptions.ConnectTimeout:
  844. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接超时 [{report_type}]")
  845. except requests.exceptions.ReadTimeout:
  846. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次读取超时 [{report_type}]")
  847. except requests.exceptions.ConnectionError as e:
  848. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接错误 [{report_type}]:{e}")
  849. except Exception as e:
  850. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送异常 [{report_type}]:{e}")
  851. # 判断整体发送是否成功
  852. if success_count == total_batches:
  853. print(f"{log_prefix}所有 {total_batches} 批次发送完成 [{report_type}]")
  854. elif success_count > 0:
  855. print(f"{log_prefix}部分发送成功:{success_count}/{total_batches} 批次 [{report_type}]")
  856. else:
  857. print(f"{log_prefix}发送完全失败 [{report_type}]")
  858. return False
  859. return True
  860. def send_to_bark(
  861. bark_url: str,
  862. report_data: Dict,
  863. report_type: str,
  864. update_info: Optional[Dict] = None,
  865. proxy_url: Optional[str] = None,
  866. mode: str = "daily",
  867. account_label: str = "",
  868. *,
  869. batch_size: int = 3600,
  870. batch_interval: float = 1.0,
  871. split_content_func: Callable = None,
  872. rss_items: Optional[list] = None,
  873. rss_new_items: Optional[list] = None,
  874. ai_analysis: Any = None,
  875. display_regions: Optional[Dict] = None,
  876. standalone_data: Optional[Dict] = None,
  877. ) -> bool:
  878. """
  879. 发送到 Bark(支持分批发送,使用 markdown 格式,支持热榜+RSS合并+独立展示区)
  880. Args:
  881. bark_url: Bark URL(包含 device_key)
  882. report_data: 报告数据
  883. report_type: 报告类型
  884. update_info: 更新信息(可选)
  885. proxy_url: 代理 URL(可选)
  886. mode: 报告模式 (daily/current)
  887. account_label: 账号标签(多账号时显示)
  888. batch_size: 批次大小(字节)
  889. batch_interval: 批次发送间隔(秒)
  890. split_content_func: 内容分批函数
  891. rss_items: RSS 统计条目列表(可选,用于合并推送)
  892. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  893. Returns:
  894. bool: 发送是否成功
  895. """
  896. # 日志前缀
  897. log_prefix = f"Bark{account_label}" if account_label else "Bark"
  898. proxies = None
  899. if proxy_url:
  900. proxies = {"http": proxy_url, "https": proxy_url}
  901. # 解析 Bark URL,提取 device_key 和 API 端点
  902. # Bark URL 格式: https://api.day.app/device_key 或 https://bark.day.app/device_key
  903. parsed_url = urlparse(bark_url)
  904. device_key = parsed_url.path.strip('/').split('/')[0] if parsed_url.path else None
  905. if not device_key:
  906. print(f"{log_prefix} URL 格式错误,无法提取 device_key: {bark_url}")
  907. return False
  908. # 构建正确的 API 端点
  909. api_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/push"
  910. # 渲染 AI 分析内容(如果有),合并到主内容中
  911. ai_content = None
  912. ai_stats = None
  913. if ai_analysis:
  914. ai_content = _render_ai_analysis(ai_analysis, "bark")
  915. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  916. if getattr(ai_analysis, "success", False):
  917. ai_stats = {
  918. "total_news": getattr(ai_analysis, "total_news", 0),
  919. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  920. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  921. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  922. "rss_count": getattr(ai_analysis, "rss_count", 0),
  923. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  924. }
  925. # 获取分批内容,预留批次头部空间
  926. header_reserve = get_max_batch_header_size("bark")
  927. batches = split_content_func(
  928. report_data, "bark", update_info, max_bytes=batch_size - header_reserve, mode=mode,
  929. rss_items=rss_items,
  930. rss_new_items=rss_new_items,
  931. ai_content=ai_content,
  932. standalone_data=standalone_data,
  933. ai_stats=ai_stats,
  934. report_type=report_type,
  935. )
  936. # 统一添加批次头部(已预留空间,不会超限)
  937. batches = add_batch_headers(batches, "bark", batch_size)
  938. total_batches = len(batches)
  939. print(f"{log_prefix}消息分为 {total_batches} 批次发送 [{report_type}]")
  940. # 反转批次顺序,使得在Bark客户端显示时顺序正确
  941. # Bark显示最新消息在上面,所以我们从最后一批开始推送
  942. reversed_batches = list(reversed(batches))
  943. print(f"{log_prefix}将按反向顺序推送(最后批次先推送),确保客户端显示顺序正确")
  944. # 逐批发送(反向顺序)
  945. success_count = 0
  946. for idx, batch_content in enumerate(reversed_batches, 1):
  947. # 计算正确的批次编号(用户视角的编号)
  948. actual_batch_num = total_batches - idx + 1
  949. content_size = len(batch_content.encode("utf-8"))
  950. print(
  951. f"发送{log_prefix}第 {actual_batch_num}/{total_batches} 批次(推送顺序: {idx}/{total_batches}),大小:{content_size} 字节 [{report_type}]"
  952. )
  953. # 检查消息大小(Bark使用APNs,限制4KB)
  954. if content_size > 4096:
  955. print(
  956. f"警告:{log_prefix}第 {actual_batch_num}/{total_batches} 批次消息过大({content_size} 字节),可能被拒绝"
  957. )
  958. # 构建JSON payload
  959. payload = {
  960. "title": report_type,
  961. "markdown": batch_content,
  962. "device_key": device_key,
  963. "sound": "default",
  964. "group": "TrendRadar",
  965. "action": "none", # 点击推送跳到 APP 不弹出弹框,方便阅读
  966. }
  967. try:
  968. response = requests.post(
  969. api_endpoint,
  970. json=payload,
  971. proxies=proxies,
  972. timeout=30,
  973. )
  974. if response.status_code == 200:
  975. result = response.json()
  976. if result.get("code") == 200:
  977. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送成功 [{report_type}]")
  978. success_count += 1
  979. # 批次间间隔
  980. if idx < total_batches:
  981. time.sleep(batch_interval)
  982. else:
  983. print(
  984. f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送失败 [{report_type}],错误:{result.get('message', '未知错误')}"
  985. )
  986. else:
  987. print(
  988. f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送失败 [{report_type}],状态码:{response.status_code}"
  989. )
  990. try:
  991. print(f"错误详情:{response.text}")
  992. except:
  993. pass
  994. except requests.exceptions.ConnectTimeout:
  995. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接超时 [{report_type}]")
  996. except requests.exceptions.ReadTimeout:
  997. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次读取超时 [{report_type}]")
  998. except requests.exceptions.ConnectionError as e:
  999. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接错误 [{report_type}]:{e}")
  1000. except Exception as e:
  1001. print(f"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送异常 [{report_type}]:{e}")
  1002. # 判断整体发送是否成功
  1003. if success_count == total_batches:
  1004. print(f"{log_prefix}所有 {total_batches} 批次发送完成 [{report_type}]")
  1005. elif success_count > 0:
  1006. print(f"{log_prefix}部分发送成功:{success_count}/{total_batches} 批次 [{report_type}]")
  1007. else:
  1008. print(f"{log_prefix}发送完全失败 [{report_type}]")
  1009. return False
  1010. return True
  1011. def send_to_slack(
  1012. webhook_url: str,
  1013. report_data: Dict,
  1014. report_type: str,
  1015. update_info: Optional[Dict] = None,
  1016. proxy_url: Optional[str] = None,
  1017. mode: str = "daily",
  1018. account_label: str = "",
  1019. *,
  1020. batch_size: int = 4000,
  1021. batch_interval: float = 1.0,
  1022. split_content_func: Callable = None,
  1023. rss_items: Optional[list] = None,
  1024. rss_new_items: Optional[list] = None,
  1025. ai_analysis: Any = None,
  1026. display_regions: Optional[Dict] = None,
  1027. standalone_data: Optional[Dict] = None,
  1028. ) -> bool:
  1029. """
  1030. 发送到 Slack(支持分批发送,使用 mrkdwn 格式,支持热榜+RSS合并+独立展示区)
  1031. Args:
  1032. webhook_url: Slack Webhook URL
  1033. report_data: 报告数据
  1034. report_type: 报告类型
  1035. update_info: 更新信息(可选)
  1036. proxy_url: 代理 URL(可选)
  1037. mode: 报告模式 (daily/current)
  1038. account_label: 账号标签(多账号时显示)
  1039. batch_size: 批次大小(字节)
  1040. batch_interval: 批次发送间隔(秒)
  1041. split_content_func: 内容分批函数
  1042. rss_items: RSS 统计条目列表(可选,用于合并推送)
  1043. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  1044. Returns:
  1045. bool: 发送是否成功
  1046. """
  1047. headers = {"Content-Type": "application/json"}
  1048. proxies = None
  1049. if proxy_url:
  1050. proxies = {"http": proxy_url, "https": proxy_url}
  1051. # 日志前缀
  1052. log_prefix = f"Slack{account_label}" if account_label else "Slack"
  1053. # 渲染 AI 分析内容(如果有),合并到主内容中
  1054. ai_content = None
  1055. ai_stats = None
  1056. if ai_analysis:
  1057. ai_content = _render_ai_analysis(ai_analysis, "slack")
  1058. # 提取 AI 分析统计数据(只要 AI 分析成功就显示)
  1059. if getattr(ai_analysis, "success", False):
  1060. ai_stats = {
  1061. "total_news": getattr(ai_analysis, "total_news", 0),
  1062. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  1063. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  1064. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  1065. "rss_count": getattr(ai_analysis, "rss_count", 0),
  1066. "ai_mode": getattr(ai_analysis, "ai_mode", ""),
  1067. }
  1068. # 获取分批内容,预留批次头部空间
  1069. header_reserve = get_max_batch_header_size("slack")
  1070. batches = split_content_func(
  1071. report_data, "slack", update_info, max_bytes=batch_size - header_reserve, mode=mode,
  1072. rss_items=rss_items,
  1073. rss_new_items=rss_new_items,
  1074. ai_content=ai_content,
  1075. standalone_data=standalone_data,
  1076. ai_stats=ai_stats,
  1077. report_type=report_type,
  1078. )
  1079. # 统一添加批次头部(已预留空间,不会超限)
  1080. batches = add_batch_headers(batches, "slack", batch_size)
  1081. print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]")
  1082. # 逐批发送
  1083. for i, batch_content in enumerate(batches, 1):
  1084. # 转换 Markdown 到 mrkdwn 格式
  1085. mrkdwn_content = convert_markdown_to_mrkdwn(batch_content)
  1086. content_size = len(mrkdwn_content.encode("utf-8"))
  1087. print(
  1088. f"发送{log_prefix}第 {i}/{len(batches)} 批次,大小:{content_size} 字节 [{report_type}]"
  1089. )
  1090. # 构建 Slack payload(使用简单的 text 字段,支持 mrkdwn)
  1091. payload = {"text": mrkdwn_content}
  1092. try:
  1093. response = requests.post(
  1094. webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30
  1095. )
  1096. # Slack Incoming Webhooks 成功时返回 "ok" 文本
  1097. if response.status_code == 200 and response.text == "ok":
  1098. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
  1099. # 批次间间隔
  1100. if i < len(batches):
  1101. time.sleep(batch_interval)
  1102. else:
  1103. error_msg = response.text if response.text else f"状态码:{response.status_code}"
  1104. print(
  1105. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],错误:{error_msg}"
  1106. )
  1107. return False
  1108. except Exception as e:
  1109. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]:{e}")
  1110. return False
  1111. print(f"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]")
  1112. return True
  1113. def send_to_generic_webhook(
  1114. webhook_url: str,
  1115. payload_template: Optional[str],
  1116. report_data: Dict,
  1117. report_type: str,
  1118. update_info: Optional[Dict] = None,
  1119. proxy_url: Optional[str] = None,
  1120. mode: str = "daily",
  1121. account_label: str = "",
  1122. *,
  1123. batch_size: int = 4000,
  1124. batch_interval: float = 1.0,
  1125. split_content_func: Optional[Callable] = None,
  1126. rss_items: Optional[list] = None,
  1127. rss_new_items: Optional[list] = None,
  1128. ai_analysis: Any = None,
  1129. display_regions: Optional[Dict] = None,
  1130. standalone_data: Optional[Dict] = None,
  1131. ) -> bool:
  1132. """
  1133. 发送到通用 Webhook(支持分批发送,支持自定义 JSON 模板,支持热榜+RSS合并+独立展示区)
  1134. Args:
  1135. webhook_url: Webhook URL
  1136. payload_template: JSON 模板字符串,支持 {title} 和 {content} 占位符
  1137. report_data: 报告数据
  1138. report_type: 报告类型
  1139. update_info: 更新信息(可选)
  1140. proxy_url: 代理 URL(可选)
  1141. mode: 报告模式 (daily/current)
  1142. account_label: 账号标签(多账号时显示)
  1143. batch_size: 批次大小(字节)
  1144. batch_interval: 批次发送间隔(秒)
  1145. split_content_func: 内容分批函数
  1146. rss_items: RSS 统计条目列表(可选,用于合并推送)
  1147. rss_new_items: RSS 新增条目列表(可选,用于新增区块)
  1148. Returns:
  1149. bool: 发送是否成功
  1150. """
  1151. if split_content_func is None:
  1152. raise ValueError("split_content_func is required")
  1153. headers = {"Content-Type": "application/json"}
  1154. proxies = None
  1155. if proxy_url:
  1156. proxies = {"http": proxy_url, "https": proxy_url}
  1157. # 日志前缀
  1158. log_prefix = f"通用Webhook{account_label}" if account_label else "通用Webhook"
  1159. # 渲染 AI 分析内容(如果有)
  1160. ai_content = None
  1161. ai_stats = None
  1162. if ai_analysis:
  1163. # 通用 Webhook 使用 markdown 格式渲染 AI 分析
  1164. ai_content = _render_ai_analysis(ai_analysis, "wework")
  1165. # 提取 AI 分析统计数据
  1166. if getattr(ai_analysis, "success", False):
  1167. ai_stats = {
  1168. "total_news": getattr(ai_analysis, "total_news", 0),
  1169. "analyzed_news": getattr(ai_analysis, "analyzed_news", 0),
  1170. "max_news_limit": getattr(ai_analysis, "max_news_limit", 0),
  1171. "hotlist_count": getattr(ai_analysis, "hotlist_count", 0),
  1172. "rss_count": getattr(ai_analysis, "rss_count", 0),
  1173. }
  1174. # 获取分批内容
  1175. # 使用 'wework' 作为 format_type 以获取 markdown 格式的通用输出
  1176. # 预留一定空间给模板外壳
  1177. template_overhead = 200
  1178. batches = split_content_func(
  1179. report_data, "wework", update_info, max_bytes=batch_size - template_overhead, mode=mode,
  1180. rss_items=rss_items,
  1181. rss_new_items=rss_new_items,
  1182. ai_content=ai_content,
  1183. standalone_data=standalone_data,
  1184. ai_stats=ai_stats,
  1185. report_type=report_type,
  1186. )
  1187. # 统一添加批次头部
  1188. batches = add_batch_headers(batches, "wework", batch_size)
  1189. print(f"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]")
  1190. # 逐批发送
  1191. for i, batch_content in enumerate(batches, 1):
  1192. content_size = len(batch_content.encode("utf-8"))
  1193. print(
  1194. f"发送{log_prefix}第 {i}/{len(batches)} 批次,大小:{content_size} 字节 [{report_type}]"
  1195. )
  1196. try:
  1197. # 构建 payload
  1198. if payload_template:
  1199. # 简单的字符串替换
  1200. # 注意:content 可能包含 JSON 特殊字符,需要先转义
  1201. json_content = json.dumps(batch_content)[1:-1] # 去掉首尾引号
  1202. json_title = json.dumps(report_type)[1:-1]
  1203. payload_str = payload_template.replace("{content}", json_content).replace("{title}", json_title)
  1204. # 尝试解析为 JSON 对象以验证有效性
  1205. try:
  1206. payload = json.loads(payload_str)
  1207. except json.JSONDecodeError as e:
  1208. print(f"{log_prefix} JSON 模板解析失败: {e}")
  1209. # 回退到默认格式
  1210. payload = {"title": report_type, "content": batch_content}
  1211. else:
  1212. # 默认格式
  1213. payload = {"title": report_type, "content": batch_content}
  1214. response = requests.post(
  1215. webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30
  1216. )
  1217. if response.status_code >= 200 and response.status_code < 300:
  1218. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
  1219. if i < len(batches):
  1220. time.sleep(batch_interval)
  1221. else:
  1222. print(
  1223. f"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}],状态码:{response.status_code}, 响应: {response.text}"
  1224. )
  1225. return False
  1226. except Exception as e:
  1227. print(f"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]:{e}")
  1228. return False
  1229. print(f"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]")
  1230. return True