mirror of
https://gitee.com/houhuan/TrendRadar.git
synced 2026-05-01 01:22:42 +08:00
v4.0.0 大大大更新
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
通知推送模块
|
||||
|
||||
提供多渠道通知推送功能,包括:
|
||||
- 飞书、钉钉、企业微信
|
||||
- Telegram、Slack
|
||||
- Email、ntfy、Bark
|
||||
|
||||
模块结构:
|
||||
- push_manager: 推送记录管理
|
||||
- formatters: 内容格式转换
|
||||
- batch: 批次处理工具
|
||||
- renderer: 通知内容渲染
|
||||
- splitter: 消息分批拆分
|
||||
- senders: 消息发送器(各渠道发送函数)
|
||||
- dispatcher: 多账号通知调度器
|
||||
"""
|
||||
|
||||
from trendradar.notification.push_manager import PushRecordManager
|
||||
from trendradar.notification.formatters import (
|
||||
strip_markdown,
|
||||
convert_markdown_to_mrkdwn,
|
||||
)
|
||||
from trendradar.notification.batch import (
|
||||
get_batch_header,
|
||||
get_max_batch_header_size,
|
||||
truncate_to_bytes,
|
||||
add_batch_headers,
|
||||
)
|
||||
from trendradar.notification.renderer import (
|
||||
render_feishu_content,
|
||||
render_dingtalk_content,
|
||||
)
|
||||
from trendradar.notification.splitter import (
|
||||
split_content_into_batches,
|
||||
DEFAULT_BATCH_SIZES,
|
||||
)
|
||||
from trendradar.notification.senders import (
|
||||
send_to_feishu,
|
||||
send_to_dingtalk,
|
||||
send_to_wework,
|
||||
send_to_telegram,
|
||||
send_to_email,
|
||||
send_to_ntfy,
|
||||
send_to_bark,
|
||||
send_to_slack,
|
||||
SMTP_CONFIGS,
|
||||
)
|
||||
from trendradar.notification.dispatcher import NotificationDispatcher
|
||||
|
||||
__all__ = [
|
||||
# 推送记录管理
|
||||
"PushRecordManager",
|
||||
# 格式转换
|
||||
"strip_markdown",
|
||||
"convert_markdown_to_mrkdwn",
|
||||
# 批次处理
|
||||
"get_batch_header",
|
||||
"get_max_batch_header_size",
|
||||
"truncate_to_bytes",
|
||||
"add_batch_headers",
|
||||
# 内容渲染
|
||||
"render_feishu_content",
|
||||
"render_dingtalk_content",
|
||||
# 消息分批
|
||||
"split_content_into_batches",
|
||||
"DEFAULT_BATCH_SIZES",
|
||||
# 消息发送器
|
||||
"send_to_feishu",
|
||||
"send_to_dingtalk",
|
||||
"send_to_wework",
|
||||
"send_to_telegram",
|
||||
"send_to_email",
|
||||
"send_to_ntfy",
|
||||
"send_to_bark",
|
||||
"send_to_slack",
|
||||
"SMTP_CONFIGS",
|
||||
# 通知调度器
|
||||
"NotificationDispatcher",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
批次处理模块
|
||||
|
||||
提供消息分批发送的辅助函数
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
|
||||
def get_batch_header(format_type: str, batch_num: int, total_batches: int) -> str:
|
||||
"""根据 format_type 生成对应格式的批次头部
|
||||
|
||||
Args:
|
||||
format_type: 推送类型(telegram, slack, wework_text, bark, feishu, dingtalk, ntfy, wework)
|
||||
batch_num: 当前批次编号
|
||||
total_batches: 总批次数
|
||||
|
||||
Returns:
|
||||
格式化的批次头部字符串
|
||||
"""
|
||||
if format_type == "telegram":
|
||||
return f"<b>[第 {batch_num}/{total_batches} 批次]</b>\n\n"
|
||||
elif format_type == "slack":
|
||||
return f"*[第 {batch_num}/{total_batches} 批次]*\n\n"
|
||||
elif format_type in ("wework_text", "bark"):
|
||||
# 企业微信文本模式和 Bark 使用纯文本格式
|
||||
return f"[第 {batch_num}/{total_batches} 批次]\n\n"
|
||||
else:
|
||||
# 飞书、钉钉、ntfy、企业微信 markdown 模式
|
||||
return f"**[第 {batch_num}/{total_batches} 批次]**\n\n"
|
||||
|
||||
|
||||
def get_max_batch_header_size(format_type: str) -> int:
|
||||
"""估算批次头部的最大字节数(假设最多 99 批次)
|
||||
|
||||
用于在分批时预留空间,避免事后截断破坏内容完整性。
|
||||
|
||||
Args:
|
||||
format_type: 推送类型
|
||||
|
||||
Returns:
|
||||
最大头部字节数
|
||||
"""
|
||||
# 生成最坏情况的头部(99/99 批次)
|
||||
max_header = get_batch_header(format_type, 99, 99)
|
||||
return len(max_header.encode("utf-8"))
|
||||
|
||||
|
||||
def truncate_to_bytes(text: str, max_bytes: int) -> str:
|
||||
"""安全截断字符串到指定字节数,避免截断多字节字符
|
||||
|
||||
Args:
|
||||
text: 要截断的文本
|
||||
max_bytes: 最大字节数
|
||||
|
||||
Returns:
|
||||
截断后的文本
|
||||
"""
|
||||
text_bytes = text.encode("utf-8")
|
||||
if len(text_bytes) <= max_bytes:
|
||||
return text
|
||||
|
||||
# 截断到指定字节数
|
||||
truncated = text_bytes[:max_bytes]
|
||||
|
||||
# 处理可能的不完整 UTF-8 字符
|
||||
for i in range(min(4, len(truncated))):
|
||||
try:
|
||||
return truncated[: len(truncated) - i].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
# 极端情况:返回空字符串
|
||||
return ""
|
||||
|
||||
|
||||
def add_batch_headers(
|
||||
batches: List[str], format_type: str, max_bytes: int
|
||||
) -> List[str]:
|
||||
"""为批次添加头部,动态计算确保总大小不超过限制
|
||||
|
||||
Args:
|
||||
batches: 原始批次列表
|
||||
format_type: 推送类型(bark, telegram, feishu 等)
|
||||
max_bytes: 该推送类型的最大字节限制
|
||||
|
||||
Returns:
|
||||
添加头部后的批次列表
|
||||
"""
|
||||
if len(batches) <= 1:
|
||||
return batches
|
||||
|
||||
total = len(batches)
|
||||
result = []
|
||||
|
||||
for i, content in enumerate(batches, 1):
|
||||
# 生成批次头部
|
||||
header = get_batch_header(format_type, i, total)
|
||||
header_size = len(header.encode("utf-8"))
|
||||
|
||||
# 动态计算允许的最大内容大小
|
||||
max_content_size = max_bytes - header_size
|
||||
content_size = len(content.encode("utf-8"))
|
||||
|
||||
# 如果超出,截断到安全大小
|
||||
if content_size > max_content_size:
|
||||
print(
|
||||
f"警告:{format_type} 第 {i}/{total} 批次内容({content_size}字节) + 头部({header_size}字节) 超出限制({max_bytes}字节),截断到 {max_content_size} 字节"
|
||||
)
|
||||
content = truncate_to_bytes(content, max_content_size)
|
||||
|
||||
result.append(header + content)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,420 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
通知调度器模块
|
||||
|
||||
提供统一的通知分发接口。
|
||||
支持所有通知渠道的多账号配置,使用 `;` 分隔多个账号。
|
||||
|
||||
使用示例:
|
||||
dispatcher = NotificationDispatcher(config, get_time_func, split_content_func)
|
||||
results = dispatcher.dispatch_all(report_data, report_type, ...)
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from trendradar.core.config import (
|
||||
get_account_at_index,
|
||||
limit_accounts,
|
||||
parse_multi_account_config,
|
||||
validate_paired_configs,
|
||||
)
|
||||
|
||||
from .senders import (
|
||||
send_to_bark,
|
||||
send_to_dingtalk,
|
||||
send_to_email,
|
||||
send_to_feishu,
|
||||
send_to_ntfy,
|
||||
send_to_slack,
|
||||
send_to_telegram,
|
||||
send_to_wework,
|
||||
)
|
||||
|
||||
|
||||
class NotificationDispatcher:
|
||||
"""
|
||||
统一的多账号通知调度器
|
||||
|
||||
将多账号发送逻辑封装,提供简洁的 dispatch_all 接口。
|
||||
内部处理账号解析、数量限制、配对验证等逻辑。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
get_time_func: Callable,
|
||||
split_content_func: Callable,
|
||||
):
|
||||
"""
|
||||
初始化通知调度器
|
||||
|
||||
Args:
|
||||
config: 完整的配置字典,包含所有通知渠道的配置
|
||||
get_time_func: 获取当前时间的函数
|
||||
split_content_func: 内容分批函数
|
||||
"""
|
||||
self.config = config
|
||||
self.get_time_func = get_time_func
|
||||
self.split_content_func = split_content_func
|
||||
self.max_accounts = config.get("MAX_ACCOUNTS_PER_CHANNEL", 3)
|
||||
|
||||
def dispatch_all(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
mode: str = "daily",
|
||||
html_file_path: Optional[str] = None,
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
分发通知到所有已配置的渠道
|
||||
|
||||
Args:
|
||||
report_data: 报告数据(由 prepare_report_data 生成)
|
||||
report_type: 报告类型(如 "当日汇总"、"实时增量")
|
||||
update_info: 版本更新信息(可选)
|
||||
proxy_url: 代理 URL(可选)
|
||||
mode: 报告模式 (daily/current/incremental)
|
||||
html_file_path: HTML 报告文件路径(邮件使用)
|
||||
|
||||
Returns:
|
||||
Dict[str, bool]: 每个渠道的发送结果,key 为渠道名,value 为是否成功
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# 飞书
|
||||
if self.config.get("FEISHU_WEBHOOK_URL"):
|
||||
results["feishu"] = self._send_feishu(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# 钉钉
|
||||
if self.config.get("DINGTALK_WEBHOOK_URL"):
|
||||
results["dingtalk"] = self._send_dingtalk(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# 企业微信
|
||||
if self.config.get("WEWORK_WEBHOOK_URL"):
|
||||
results["wework"] = self._send_wework(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# Telegram(需要配对验证)
|
||||
if self.config.get("TELEGRAM_BOT_TOKEN") and self.config.get("TELEGRAM_CHAT_ID"):
|
||||
results["telegram"] = self._send_telegram(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# ntfy(需要配对验证)
|
||||
if self.config.get("NTFY_SERVER_URL") and self.config.get("NTFY_TOPIC"):
|
||||
results["ntfy"] = self._send_ntfy(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# Bark
|
||||
if self.config.get("BARK_URL"):
|
||||
results["bark"] = self._send_bark(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# Slack
|
||||
if self.config.get("SLACK_WEBHOOK_URL"):
|
||||
results["slack"] = self._send_slack(
|
||||
report_data, report_type, update_info, proxy_url, mode
|
||||
)
|
||||
|
||||
# 邮件(保持原有逻辑,已支持多收件人)
|
||||
if (
|
||||
self.config.get("EMAIL_FROM")
|
||||
and self.config.get("EMAIL_PASSWORD")
|
||||
and self.config.get("EMAIL_TO")
|
||||
):
|
||||
results["email"] = self._send_email(report_type, html_file_path)
|
||||
|
||||
return results
|
||||
|
||||
def _send_to_multi_accounts(
|
||||
self,
|
||||
channel_name: str,
|
||||
config_value: str,
|
||||
send_func: Callable[..., bool],
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
"""
|
||||
通用多账号发送逻辑
|
||||
|
||||
Args:
|
||||
channel_name: 渠道名称(用于日志和账号数量限制提示)
|
||||
config_value: 配置值(可能包含多个账号,用 ; 分隔)
|
||||
send_func: 发送函数,签名为 (account, account_label=..., **kwargs) -> bool
|
||||
**kwargs: 传递给发送函数的其他参数
|
||||
|
||||
Returns:
|
||||
bool: 任一账号发送成功则返回 True
|
||||
"""
|
||||
accounts = parse_multi_account_config(config_value)
|
||||
if not accounts:
|
||||
return False
|
||||
|
||||
accounts = limit_accounts(accounts, self.max_accounts, channel_name)
|
||||
results = []
|
||||
|
||||
for i, account in enumerate(accounts):
|
||||
if account:
|
||||
account_label = f"账号{i+1}" if len(accounts) > 1 else ""
|
||||
result = send_func(account, account_label=account_label, **kwargs)
|
||||
results.append(result)
|
||||
|
||||
return any(results) if results else False
|
||||
|
||||
def _send_feishu(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到飞书(多账号)"""
|
||||
return self._send_to_multi_accounts(
|
||||
channel_name="飞书",
|
||||
config_value=self.config["FEISHU_WEBHOOK_URL"],
|
||||
send_func=lambda url, account_label: send_to_feishu(
|
||||
webhook_url=url,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=self.config.get("FEISHU_BATCH_SIZE", 29000),
|
||||
batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
|
||||
split_content_func=self.split_content_func,
|
||||
get_time_func=self.get_time_func,
|
||||
),
|
||||
)
|
||||
|
||||
def _send_dingtalk(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到钉钉(多账号)"""
|
||||
return self._send_to_multi_accounts(
|
||||
channel_name="钉钉",
|
||||
config_value=self.config["DINGTALK_WEBHOOK_URL"],
|
||||
send_func=lambda url, account_label: send_to_dingtalk(
|
||||
webhook_url=url,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=self.config.get("DINGTALK_BATCH_SIZE", 20000),
|
||||
batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
|
||||
split_content_func=self.split_content_func,
|
||||
),
|
||||
)
|
||||
|
||||
def _send_wework(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到企业微信(多账号)"""
|
||||
return self._send_to_multi_accounts(
|
||||
channel_name="企业微信",
|
||||
config_value=self.config["WEWORK_WEBHOOK_URL"],
|
||||
send_func=lambda url, account_label: send_to_wework(
|
||||
webhook_url=url,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
|
||||
batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
|
||||
msg_type=self.config.get("WEWORK_MSG_TYPE", "markdown"),
|
||||
split_content_func=self.split_content_func,
|
||||
),
|
||||
)
|
||||
|
||||
def _send_telegram(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到 Telegram(多账号,需验证 token 和 chat_id 配对)"""
|
||||
telegram_tokens = parse_multi_account_config(self.config["TELEGRAM_BOT_TOKEN"])
|
||||
telegram_chat_ids = parse_multi_account_config(self.config["TELEGRAM_CHAT_ID"])
|
||||
|
||||
if not telegram_tokens or not telegram_chat_ids:
|
||||
return False
|
||||
|
||||
# 验证配对
|
||||
valid, count = validate_paired_configs(
|
||||
{"bot_token": telegram_tokens, "chat_id": telegram_chat_ids},
|
||||
"Telegram",
|
||||
required_keys=["bot_token", "chat_id"],
|
||||
)
|
||||
if not valid or count == 0:
|
||||
return False
|
||||
|
||||
# 限制账号数量
|
||||
telegram_tokens = limit_accounts(telegram_tokens, self.max_accounts, "Telegram")
|
||||
telegram_chat_ids = telegram_chat_ids[: len(telegram_tokens)]
|
||||
|
||||
results = []
|
||||
for i in range(len(telegram_tokens)):
|
||||
token = telegram_tokens[i]
|
||||
chat_id = telegram_chat_ids[i]
|
||||
if token and chat_id:
|
||||
account_label = f"账号{i+1}" if len(telegram_tokens) > 1 else ""
|
||||
result = send_to_telegram(
|
||||
bot_token=token,
|
||||
chat_id=chat_id,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=self.config.get("MESSAGE_BATCH_SIZE", 4000),
|
||||
batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
|
||||
split_content_func=self.split_content_func,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
return any(results) if results else False
|
||||
|
||||
def _send_ntfy(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到 ntfy(多账号,需验证 topic 和 token 配对)"""
|
||||
ntfy_server_url = self.config["NTFY_SERVER_URL"]
|
||||
ntfy_topics = parse_multi_account_config(self.config["NTFY_TOPIC"])
|
||||
ntfy_tokens = parse_multi_account_config(self.config.get("NTFY_TOKEN", ""))
|
||||
|
||||
if not ntfy_server_url or not ntfy_topics:
|
||||
return False
|
||||
|
||||
# 验证 token 和 topic 数量一致(如果配置了 token)
|
||||
if ntfy_tokens and len(ntfy_tokens) != len(ntfy_topics):
|
||||
print(
|
||||
f"❌ ntfy 配置错误:topic 数量({len(ntfy_topics)})与 token 数量({len(ntfy_tokens)})不一致,跳过 ntfy 推送"
|
||||
)
|
||||
return False
|
||||
|
||||
# 限制账号数量
|
||||
ntfy_topics = limit_accounts(ntfy_topics, self.max_accounts, "ntfy")
|
||||
if ntfy_tokens:
|
||||
ntfy_tokens = ntfy_tokens[: len(ntfy_topics)]
|
||||
|
||||
results = []
|
||||
for i, topic in enumerate(ntfy_topics):
|
||||
if topic:
|
||||
token = get_account_at_index(ntfy_tokens, i, "") if ntfy_tokens else ""
|
||||
account_label = f"账号{i+1}" if len(ntfy_topics) > 1 else ""
|
||||
result = send_to_ntfy(
|
||||
server_url=ntfy_server_url,
|
||||
topic=topic,
|
||||
token=token,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=3800,
|
||||
split_content_func=self.split_content_func,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
return any(results) if results else False
|
||||
|
||||
def _send_bark(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到 Bark(多账号)"""
|
||||
return self._send_to_multi_accounts(
|
||||
channel_name="Bark",
|
||||
config_value=self.config["BARK_URL"],
|
||||
send_func=lambda url, account_label: send_to_bark(
|
||||
bark_url=url,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=self.config.get("BARK_BATCH_SIZE", 3600),
|
||||
batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
|
||||
split_content_func=self.split_content_func,
|
||||
),
|
||||
)
|
||||
|
||||
def _send_slack(
|
||||
self,
|
||||
report_data: Dict,
|
||||
report_type: str,
|
||||
update_info: Optional[Dict],
|
||||
proxy_url: Optional[str],
|
||||
mode: str,
|
||||
) -> bool:
|
||||
"""发送到 Slack(多账号)"""
|
||||
return self._send_to_multi_accounts(
|
||||
channel_name="Slack",
|
||||
config_value=self.config["SLACK_WEBHOOK_URL"],
|
||||
send_func=lambda url, account_label: send_to_slack(
|
||||
webhook_url=url,
|
||||
report_data=report_data,
|
||||
report_type=report_type,
|
||||
update_info=update_info,
|
||||
proxy_url=proxy_url,
|
||||
mode=mode,
|
||||
account_label=account_label,
|
||||
batch_size=self.config.get("SLACK_BATCH_SIZE", 4000),
|
||||
batch_interval=self.config.get("BATCH_SEND_INTERVAL", 1.0),
|
||||
split_content_func=self.split_content_func,
|
||||
),
|
||||
)
|
||||
|
||||
def _send_email(
|
||||
self,
|
||||
report_type: str,
|
||||
html_file_path: Optional[str],
|
||||
) -> bool:
|
||||
"""发送邮件(保持原有逻辑,已支持多收件人)"""
|
||||
return send_to_email(
|
||||
from_email=self.config["EMAIL_FROM"],
|
||||
password=self.config["EMAIL_PASSWORD"],
|
||||
to_email=self.config["EMAIL_TO"],
|
||||
report_type=report_type,
|
||||
html_file_path=html_file_path,
|
||||
custom_smtp_server=self.config.get("EMAIL_SMTP_SERVER", ""),
|
||||
custom_smtp_port=self.config.get("EMAIL_SMTP_PORT", ""),
|
||||
get_time_func=self.get_time_func,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
通知内容格式转换模块
|
||||
|
||||
提供不同推送平台间的格式转换功能
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""去除文本中的 markdown 语法格式,用于个人微信推送
|
||||
|
||||
Args:
|
||||
text: 包含 markdown 格式的文本
|
||||
|
||||
Returns:
|
||||
纯文本内容
|
||||
"""
|
||||
# 去除粗体 **text** 或 __text__
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'__(.+?)__', r'\1', text)
|
||||
|
||||
# 去除斜体 *text* 或 _text_
|
||||
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
||||
text = re.sub(r'_(.+?)_', r'\1', text)
|
||||
|
||||
# 去除删除线 ~~text~~
|
||||
text = re.sub(r'~~(.+?)~~', r'\1', text)
|
||||
|
||||
# 转换链接 [text](url) -> text url(保留 URL)
|
||||
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 \2', text)
|
||||
|
||||
# 去除图片  -> alt
|
||||
text = re.sub(r'!\[(.+?)\]\(.+?\)', r'\1', text)
|
||||
|
||||
# 去除行内代码 `code`
|
||||
text = re.sub(r'`(.+?)`', r'\1', text)
|
||||
|
||||
# 去除引用符号 >
|
||||
text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE)
|
||||
|
||||
# 去除标题符号 # ## ### 等
|
||||
text = re.sub(r'^#+\s*', '', text, flags=re.MULTILINE)
|
||||
|
||||
# 去除水平分割线 --- 或 ***
|
||||
text = re.sub(r'^[\-\*]{3,}\s*$', '', text, flags=re.MULTILINE)
|
||||
|
||||
# 去除 HTML 标签 <font color='xxx'>text</font> -> text
|
||||
text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
# 清理多余的空行(保留最多两个连续空行)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def convert_markdown_to_mrkdwn(content: str) -> str:
|
||||
"""
|
||||
将标准 Markdown 转换为 Slack 的 mrkdwn 格式
|
||||
|
||||
转换规则:
|
||||
- **粗体** → *粗体*
|
||||
- [文本](url) → <url|文本>
|
||||
- 保留其他格式(代码块、列表等)
|
||||
|
||||
Args:
|
||||
content: Markdown 格式的内容
|
||||
|
||||
Returns:
|
||||
Slack mrkdwn 格式的内容
|
||||
"""
|
||||
# 1. 转换链接格式: [文本](url) → <url|文本>
|
||||
content = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<\2|\1>', content)
|
||||
|
||||
# 2. 转换粗体: **文本** → *文本*
|
||||
content = re.sub(r'\*\*([^*]+)\*\*', r'*\1*', content)
|
||||
|
||||
return content
|
||||
@@ -0,0 +1,109 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
推送记录管理模块
|
||||
|
||||
管理推送记录,支持每日只推送一次和时间窗口控制
|
||||
通过 storage_backend 统一存储,支持本地 SQLite 和远程云存储
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional, Any
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
class PushRecordManager:
|
||||
"""
|
||||
推送记录管理器
|
||||
|
||||
通过 storage_backend 统一管理推送记录:
|
||||
- 本地环境:使用 LocalStorageBackend,数据存储在本地 SQLite
|
||||
- GitHub Actions:使用 RemoteStorageBackend,数据存储在云端
|
||||
|
||||
这样 once_per_day 功能在 GitHub Actions 上也能正常工作。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage_backend: Any,
|
||||
get_time_func: Optional[Callable[[], datetime]] = None,
|
||||
):
|
||||
"""
|
||||
初始化推送记录管理器
|
||||
|
||||
Args:
|
||||
storage_backend: 存储后端实例(LocalStorageBackend 或 RemoteStorageBackend)
|
||||
get_time_func: 获取当前时间的函数(应使用配置的时区)
|
||||
"""
|
||||
self.storage_backend = storage_backend
|
||||
self.get_time = get_time_func or self._default_get_time
|
||||
|
||||
print(f"[推送记录] 使用 {storage_backend.backend_name} 存储后端")
|
||||
|
||||
def _default_get_time(self) -> datetime:
|
||||
"""默认时间获取函数(UTC+8)"""
|
||||
return datetime.now(pytz.timezone("Asia/Shanghai"))
|
||||
|
||||
def has_pushed_today(self) -> bool:
|
||||
"""
|
||||
检查今天是否已经推送过
|
||||
|
||||
Returns:
|
||||
是否已推送
|
||||
"""
|
||||
return self.storage_backend.has_pushed_today()
|
||||
|
||||
def record_push(self, report_type: str) -> bool:
|
||||
"""
|
||||
记录推送
|
||||
|
||||
Args:
|
||||
report_type: 报告类型
|
||||
|
||||
Returns:
|
||||
是否记录成功
|
||||
"""
|
||||
return self.storage_backend.record_push(report_type)
|
||||
|
||||
def is_in_time_range(self, start_time: str, end_time: str) -> bool:
|
||||
"""
|
||||
检查当前时间是否在指定时间范围内
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(格式:HH:MM)
|
||||
end_time: 结束时间(格式:HH:MM)
|
||||
|
||||
Returns:
|
||||
是否在时间范围内
|
||||
"""
|
||||
now = self.get_time()
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
def normalize_time(time_str: str) -> str:
|
||||
"""将时间字符串标准化为 HH:MM 格式"""
|
||||
try:
|
||||
parts = time_str.strip().split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"时间格式错误: {time_str}")
|
||||
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
raise ValueError(f"时间范围错误: {time_str}")
|
||||
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
except Exception as e:
|
||||
print(f"时间格式化错误 '{time_str}': {e}")
|
||||
return time_str
|
||||
|
||||
normalized_start = normalize_time(start_time)
|
||||
normalized_end = normalize_time(end_time)
|
||||
normalized_current = normalize_time(current_time)
|
||||
|
||||
result = normalized_start <= normalized_current <= normalized_end
|
||||
|
||||
if not result:
|
||||
print(f"时间窗口判断:当前 {normalized_current},窗口 {normalized_start}-{normalized_end}")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,260 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
通知内容渲染模块
|
||||
|
||||
提供多平台通知内容渲染功能,生成格式化的推送消息
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Callable
|
||||
|
||||
from trendradar.report.formatter import format_title_for_platform
|
||||
|
||||
|
||||
def render_feishu_content(
|
||||
report_data: Dict,
|
||||
update_info: Optional[Dict] = None,
|
||||
mode: str = "daily",
|
||||
separator: str = "---",
|
||||
reverse_content_order: bool = False,
|
||||
get_time_func: Optional[Callable[[], datetime]] = None,
|
||||
) -> str:
|
||||
"""渲染飞书通知内容
|
||||
|
||||
Args:
|
||||
report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
|
||||
update_info: 版本更新信息(可选)
|
||||
mode: 报告模式 ("daily", "incremental", "current")
|
||||
separator: 内容分隔符
|
||||
reverse_content_order: 是否反转内容顺序(新增在前)
|
||||
get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now())
|
||||
|
||||
Returns:
|
||||
格式化的飞书消息内容
|
||||
"""
|
||||
# 生成热点词汇统计部分
|
||||
stats_content = ""
|
||||
if report_data["stats"]:
|
||||
stats_content += "📊 **热点词汇统计**\n\n"
|
||||
|
||||
total_count = len(report_data["stats"])
|
||||
|
||||
for i, stat in enumerate(report_data["stats"]):
|
||||
word = stat["word"]
|
||||
count = stat["count"]
|
||||
|
||||
sequence_display = f"<font color='grey'>[{i + 1}/{total_count}]</font>"
|
||||
|
||||
if count >= 10:
|
||||
stats_content += f"🔥 {sequence_display} **{word}** : <font color='red'>{count}</font> 条\n\n"
|
||||
elif count >= 5:
|
||||
stats_content += f"📈 {sequence_display} **{word}** : <font color='orange'>{count}</font> 条\n\n"
|
||||
else:
|
||||
stats_content += f"📌 {sequence_display} **{word}** : {count} 条\n\n"
|
||||
|
||||
for j, title_data in enumerate(stat["titles"], 1):
|
||||
formatted_title = format_title_for_platform(
|
||||
"feishu", title_data, show_source=True
|
||||
)
|
||||
stats_content += f" {j}. {formatted_title}\n"
|
||||
|
||||
if j < len(stat["titles"]):
|
||||
stats_content += "\n"
|
||||
|
||||
if i < len(report_data["stats"]) - 1:
|
||||
stats_content += f"\n{separator}\n\n"
|
||||
|
||||
# 生成新增新闻部分
|
||||
new_titles_content = ""
|
||||
if report_data["new_titles"]:
|
||||
new_titles_content += (
|
||||
f"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
|
||||
)
|
||||
|
||||
for source_data in report_data["new_titles"]:
|
||||
new_titles_content += (
|
||||
f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n"
|
||||
)
|
||||
|
||||
for j, title_data in enumerate(source_data["titles"], 1):
|
||||
title_data_copy = title_data.copy()
|
||||
title_data_copy["is_new"] = False
|
||||
formatted_title = format_title_for_platform(
|
||||
"feishu", title_data_copy, show_source=False
|
||||
)
|
||||
new_titles_content += f" {j}. {formatted_title}\n"
|
||||
|
||||
new_titles_content += "\n"
|
||||
|
||||
# 根据配置决定内容顺序
|
||||
text_content = ""
|
||||
if reverse_content_order:
|
||||
# 新增热点在前,热点词汇统计在后
|
||||
if new_titles_content:
|
||||
text_content += new_titles_content
|
||||
if stats_content:
|
||||
text_content += f"\n{separator}\n\n"
|
||||
if stats_content:
|
||||
text_content += stats_content
|
||||
else:
|
||||
# 默认:热点词汇统计在前,新增热点在后
|
||||
if stats_content:
|
||||
text_content += stats_content
|
||||
if new_titles_content:
|
||||
text_content += f"\n{separator}\n\n"
|
||||
if new_titles_content:
|
||||
text_content += new_titles_content
|
||||
|
||||
if not text_content:
|
||||
if mode == "incremental":
|
||||
mode_text = "增量模式下暂无新增匹配的热点词汇"
|
||||
elif mode == "current":
|
||||
mode_text = "当前榜单模式下暂无匹配的热点词汇"
|
||||
else:
|
||||
mode_text = "暂无匹配的热点词汇"
|
||||
text_content = f"📭 {mode_text}\n\n"
|
||||
|
||||
if report_data["failed_ids"]:
|
||||
if text_content and "暂无匹配" not in text_content:
|
||||
text_content += f"\n{separator}\n\n"
|
||||
|
||||
text_content += "⚠️ **数据获取失败的平台:**\n\n"
|
||||
for i, id_value in enumerate(report_data["failed_ids"], 1):
|
||||
text_content += f" • <font color='red'>{id_value}</font>\n"
|
||||
|
||||
# 获取当前时间
|
||||
now = get_time_func() if get_time_func else datetime.now()
|
||||
text_content += (
|
||||
f"\n\n<font color='grey'>更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}</font>"
|
||||
)
|
||||
|
||||
if update_info:
|
||||
text_content += f"\n<font color='grey'>TrendRadar 发现新版本 {update_info['remote_version']},当前 {update_info['current_version']}</font>"
|
||||
|
||||
return text_content
|
||||
|
||||
|
||||
def render_dingtalk_content(
|
||||
report_data: Dict,
|
||||
update_info: Optional[Dict] = None,
|
||||
mode: str = "daily",
|
||||
reverse_content_order: bool = False,
|
||||
get_time_func: Optional[Callable[[], datetime]] = None,
|
||||
) -> str:
|
||||
"""渲染钉钉通知内容
|
||||
|
||||
Args:
|
||||
report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
|
||||
update_info: 版本更新信息(可选)
|
||||
mode: 报告模式 ("daily", "incremental", "current")
|
||||
reverse_content_order: 是否反转内容顺序(新增在前)
|
||||
get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now())
|
||||
|
||||
Returns:
|
||||
格式化的钉钉消息内容
|
||||
"""
|
||||
total_titles = sum(
|
||||
len(stat["titles"]) for stat in report_data["stats"] if stat["count"] > 0
|
||||
)
|
||||
now = get_time_func() if get_time_func else datetime.now()
|
||||
|
||||
# 头部信息
|
||||
header_content = f"**总新闻数:** {total_titles}\n\n"
|
||||
header_content += f"**时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
header_content += "**类型:** 热点分析报告\n\n"
|
||||
header_content += "---\n\n"
|
||||
|
||||
# 生成热点词汇统计部分
|
||||
stats_content = ""
|
||||
if report_data["stats"]:
|
||||
stats_content += "📊 **热点词汇统计**\n\n"
|
||||
|
||||
total_count = len(report_data["stats"])
|
||||
|
||||
for i, stat in enumerate(report_data["stats"]):
|
||||
word = stat["word"]
|
||||
count = stat["count"]
|
||||
|
||||
sequence_display = f"[{i + 1}/{total_count}]"
|
||||
|
||||
if count >= 10:
|
||||
stats_content += f"🔥 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
elif count >= 5:
|
||||
stats_content += f"📈 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
else:
|
||||
stats_content += f"📌 {sequence_display} **{word}** : {count} 条\n\n"
|
||||
|
||||
for j, title_data in enumerate(stat["titles"], 1):
|
||||
formatted_title = format_title_for_platform(
|
||||
"dingtalk", title_data, show_source=True
|
||||
)
|
||||
stats_content += f" {j}. {formatted_title}\n"
|
||||
|
||||
if j < len(stat["titles"]):
|
||||
stats_content += "\n"
|
||||
|
||||
if i < len(report_data["stats"]) - 1:
|
||||
stats_content += "\n---\n\n"
|
||||
|
||||
# 生成新增新闻部分
|
||||
new_titles_content = ""
|
||||
if report_data["new_titles"]:
|
||||
new_titles_content += (
|
||||
f"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
|
||||
)
|
||||
|
||||
for source_data in report_data["new_titles"]:
|
||||
new_titles_content += f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
|
||||
|
||||
for j, title_data in enumerate(source_data["titles"], 1):
|
||||
title_data_copy = title_data.copy()
|
||||
title_data_copy["is_new"] = False
|
||||
formatted_title = format_title_for_platform(
|
||||
"dingtalk", title_data_copy, show_source=False
|
||||
)
|
||||
new_titles_content += f" {j}. {formatted_title}\n"
|
||||
|
||||
new_titles_content += "\n"
|
||||
|
||||
# 根据配置决定内容顺序
|
||||
text_content = header_content
|
||||
if reverse_content_order:
|
||||
# 新增热点在前,热点词汇统计在后
|
||||
if new_titles_content:
|
||||
text_content += new_titles_content
|
||||
if stats_content:
|
||||
text_content += "\n---\n\n"
|
||||
if stats_content:
|
||||
text_content += stats_content
|
||||
else:
|
||||
# 默认:热点词汇统计在前,新增热点在后
|
||||
if stats_content:
|
||||
text_content += stats_content
|
||||
if new_titles_content:
|
||||
text_content += "\n---\n\n"
|
||||
if new_titles_content:
|
||||
text_content += new_titles_content
|
||||
|
||||
if not stats_content and not new_titles_content:
|
||||
if mode == "incremental":
|
||||
mode_text = "增量模式下暂无新增匹配的热点词汇"
|
||||
elif mode == "current":
|
||||
mode_text = "当前榜单模式下暂无匹配的热点词汇"
|
||||
else:
|
||||
mode_text = "暂无匹配的热点词汇"
|
||||
text_content += f"📭 {mode_text}\n\n"
|
||||
|
||||
if report_data["failed_ids"]:
|
||||
if "暂无匹配" not in text_content:
|
||||
text_content += "\n---\n\n"
|
||||
|
||||
text_content += "⚠️ **数据获取失败的平台:**\n\n"
|
||||
for i, id_value in enumerate(report_data["failed_ids"], 1):
|
||||
text_content += f" • **{id_value}**\n"
|
||||
|
||||
text_content += f"\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
if update_info:
|
||||
text_content += f"\n> TrendRadar 发现新版本 **{update_info['remote_version']}**,当前 **{update_info['current_version']}**"
|
||||
|
||||
return text_content
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,580 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
消息分批处理模块
|
||||
|
||||
提供消息内容分批拆分功能,确保消息大小不超过各平台限制
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Callable
|
||||
|
||||
from trendradar.report.formatter import format_title_for_platform
|
||||
|
||||
|
||||
# 默认批次大小配置
|
||||
DEFAULT_BATCH_SIZES = {
|
||||
"dingtalk": 20000,
|
||||
"feishu": 29000,
|
||||
"ntfy": 3800,
|
||||
"default": 4000,
|
||||
}
|
||||
|
||||
|
||||
def split_content_into_batches(
|
||||
report_data: Dict,
|
||||
format_type: str,
|
||||
update_info: Optional[Dict] = None,
|
||||
max_bytes: Optional[int] = None,
|
||||
mode: str = "daily",
|
||||
batch_sizes: Optional[Dict[str, int]] = None,
|
||||
feishu_separator: str = "---",
|
||||
reverse_content_order: bool = False,
|
||||
get_time_func: Optional[Callable[[], datetime]] = None,
|
||||
) -> List[str]:
|
||||
"""分批处理消息内容,确保词组标题+至少第一条新闻的完整性
|
||||
|
||||
Args:
|
||||
report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
|
||||
format_type: 格式类型 (feishu, dingtalk, wework, telegram, ntfy, bark, slack)
|
||||
update_info: 版本更新信息(可选)
|
||||
max_bytes: 最大字节数(可选,如果不指定则使用默认配置)
|
||||
mode: 报告模式 (daily, incremental, current)
|
||||
batch_sizes: 批次大小配置字典(可选)
|
||||
feishu_separator: 飞书消息分隔符
|
||||
reverse_content_order: 是否反转内容顺序(新增在前)
|
||||
get_time_func: 获取当前时间的函数(可选)
|
||||
|
||||
Returns:
|
||||
分批后的消息内容列表
|
||||
"""
|
||||
# 合并批次大小配置
|
||||
sizes = {**DEFAULT_BATCH_SIZES, **(batch_sizes or {})}
|
||||
|
||||
if max_bytes is None:
|
||||
if format_type == "dingtalk":
|
||||
max_bytes = sizes.get("dingtalk", 20000)
|
||||
elif format_type == "feishu":
|
||||
max_bytes = sizes.get("feishu", 29000)
|
||||
elif format_type == "ntfy":
|
||||
max_bytes = sizes.get("ntfy", 3800)
|
||||
else:
|
||||
max_bytes = sizes.get("default", 4000)
|
||||
|
||||
batches = []
|
||||
|
||||
total_titles = sum(
|
||||
len(stat["titles"]) for stat in report_data["stats"] if stat["count"] > 0
|
||||
)
|
||||
now = get_time_func() if get_time_func else datetime.now()
|
||||
|
||||
base_header = ""
|
||||
if format_type in ("wework", "bark"):
|
||||
base_header = f"**总新闻数:** {total_titles}\n\n\n\n"
|
||||
elif format_type == "telegram":
|
||||
base_header = f"总新闻数: {total_titles}\n\n"
|
||||
elif format_type == "ntfy":
|
||||
base_header = f"**总新闻数:** {total_titles}\n\n"
|
||||
elif format_type == "feishu":
|
||||
base_header = ""
|
||||
elif format_type == "dingtalk":
|
||||
base_header = f"**总新闻数:** {total_titles}\n\n"
|
||||
base_header += f"**时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
base_header += f"**类型:** 热点分析报告\n\n"
|
||||
base_header += "---\n\n"
|
||||
elif format_type == "slack":
|
||||
base_header = f"*总新闻数:* {total_titles}\n\n"
|
||||
|
||||
base_footer = ""
|
||||
if format_type in ("wework", "bark"):
|
||||
base_footer = f"\n\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if update_info:
|
||||
base_footer += f"\n> TrendRadar 发现新版本 **{update_info['remote_version']}**,当前 **{update_info['current_version']}**"
|
||||
elif format_type == "telegram":
|
||||
base_footer = f"\n\n更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if update_info:
|
||||
base_footer += f"\nTrendRadar 发现新版本 {update_info['remote_version']},当前 {update_info['current_version']}"
|
||||
elif format_type == "ntfy":
|
||||
base_footer = f"\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if update_info:
|
||||
base_footer += f"\n> TrendRadar 发现新版本 **{update_info['remote_version']}**,当前 **{update_info['current_version']}**"
|
||||
elif format_type == "feishu":
|
||||
base_footer = f"\n\n<font color='grey'>更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}</font>"
|
||||
if update_info:
|
||||
base_footer += f"\n<font color='grey'>TrendRadar 发现新版本 {update_info['remote_version']},当前 {update_info['current_version']}</font>"
|
||||
elif format_type == "dingtalk":
|
||||
base_footer = f"\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if update_info:
|
||||
base_footer += f"\n> TrendRadar 发现新版本 **{update_info['remote_version']}**,当前 **{update_info['current_version']}**"
|
||||
elif format_type == "slack":
|
||||
base_footer = f"\n\n_更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}_"
|
||||
if update_info:
|
||||
base_footer += f"\n_TrendRadar 发现新版本 *{update_info['remote_version']}*,当前 *{update_info['current_version']}_"
|
||||
|
||||
stats_header = ""
|
||||
if report_data["stats"]:
|
||||
if format_type in ("wework", "bark"):
|
||||
stats_header = f"📊 **热点词汇统计**\n\n"
|
||||
elif format_type == "telegram":
|
||||
stats_header = f"📊 热点词汇统计\n\n"
|
||||
elif format_type == "ntfy":
|
||||
stats_header = f"📊 **热点词汇统计**\n\n"
|
||||
elif format_type == "feishu":
|
||||
stats_header = f"📊 **热点词汇统计**\n\n"
|
||||
elif format_type == "dingtalk":
|
||||
stats_header = f"📊 **热点词汇统计**\n\n"
|
||||
elif format_type == "slack":
|
||||
stats_header = f"📊 *热点词汇统计*\n\n"
|
||||
|
||||
current_batch = base_header
|
||||
current_batch_has_content = False
|
||||
|
||||
if (
|
||||
not report_data["stats"]
|
||||
and not report_data["new_titles"]
|
||||
and not report_data["failed_ids"]
|
||||
):
|
||||
if mode == "incremental":
|
||||
mode_text = "增量模式下暂无新增匹配的热点词汇"
|
||||
elif mode == "current":
|
||||
mode_text = "当前榜单模式下暂无匹配的热点词汇"
|
||||
else:
|
||||
mode_text = "暂无匹配的热点词汇"
|
||||
simple_content = f"📭 {mode_text}\n\n"
|
||||
final_content = base_header + simple_content + base_footer
|
||||
batches.append(final_content)
|
||||
return batches
|
||||
|
||||
# 定义处理热点词汇统计的函数
|
||||
def process_stats_section(current_batch, current_batch_has_content, batches):
|
||||
"""处理热点词汇统计"""
|
||||
if not report_data["stats"]:
|
||||
return current_batch, current_batch_has_content, batches
|
||||
|
||||
total_count = len(report_data["stats"])
|
||||
|
||||
# 添加统计标题
|
||||
test_content = current_batch + stats_header
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
< max_bytes
|
||||
):
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
else:
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + stats_header
|
||||
current_batch_has_content = True
|
||||
|
||||
# 逐个处理词组(确保词组标题+第一条新闻的原子性)
|
||||
for i, stat in enumerate(report_data["stats"]):
|
||||
word = stat["word"]
|
||||
count = stat["count"]
|
||||
sequence_display = f"[{i + 1}/{total_count}]"
|
||||
|
||||
# 构建词组标题
|
||||
word_header = ""
|
||||
if format_type in ("wework", "bark"):
|
||||
if count >= 10:
|
||||
word_header = (
|
||||
f"🔥 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
)
|
||||
elif count >= 5:
|
||||
word_header = (
|
||||
f"📈 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
)
|
||||
else:
|
||||
word_header = f"📌 {sequence_display} **{word}** : {count} 条\n\n"
|
||||
elif format_type == "telegram":
|
||||
if count >= 10:
|
||||
word_header = f"🔥 {sequence_display} {word} : {count} 条\n\n"
|
||||
elif count >= 5:
|
||||
word_header = f"📈 {sequence_display} {word} : {count} 条\n\n"
|
||||
else:
|
||||
word_header = f"📌 {sequence_display} {word} : {count} 条\n\n"
|
||||
elif format_type == "ntfy":
|
||||
if count >= 10:
|
||||
word_header = (
|
||||
f"🔥 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
)
|
||||
elif count >= 5:
|
||||
word_header = (
|
||||
f"📈 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
)
|
||||
else:
|
||||
word_header = f"📌 {sequence_display} **{word}** : {count} 条\n\n"
|
||||
elif format_type == "feishu":
|
||||
if count >= 10:
|
||||
word_header = f"🔥 <font color='grey'>{sequence_display}</font> **{word}** : <font color='red'>{count}</font> 条\n\n"
|
||||
elif count >= 5:
|
||||
word_header = f"📈 <font color='grey'>{sequence_display}</font> **{word}** : <font color='orange'>{count}</font> 条\n\n"
|
||||
else:
|
||||
word_header = f"📌 <font color='grey'>{sequence_display}</font> **{word}** : {count} 条\n\n"
|
||||
elif format_type == "dingtalk":
|
||||
if count >= 10:
|
||||
word_header = (
|
||||
f"🔥 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
)
|
||||
elif count >= 5:
|
||||
word_header = (
|
||||
f"📈 {sequence_display} **{word}** : **{count}** 条\n\n"
|
||||
)
|
||||
else:
|
||||
word_header = f"📌 {sequence_display} **{word}** : {count} 条\n\n"
|
||||
elif format_type == "slack":
|
||||
if count >= 10:
|
||||
word_header = (
|
||||
f"🔥 {sequence_display} *{word}* : *{count}* 条\n\n"
|
||||
)
|
||||
elif count >= 5:
|
||||
word_header = (
|
||||
f"📈 {sequence_display} *{word}* : *{count}* 条\n\n"
|
||||
)
|
||||
else:
|
||||
word_header = f"📌 {sequence_display} *{word}* : {count} 条\n\n"
|
||||
|
||||
# 构建第一条新闻
|
||||
first_news_line = ""
|
||||
if stat["titles"]:
|
||||
first_title_data = stat["titles"][0]
|
||||
if format_type in ("wework", "bark"):
|
||||
formatted_title = format_title_for_platform(
|
||||
"wework", first_title_data, show_source=True
|
||||
)
|
||||
elif format_type == "telegram":
|
||||
formatted_title = format_title_for_platform(
|
||||
"telegram", first_title_data, show_source=True
|
||||
)
|
||||
elif format_type == "ntfy":
|
||||
formatted_title = format_title_for_platform(
|
||||
"ntfy", first_title_data, show_source=True
|
||||
)
|
||||
elif format_type == "feishu":
|
||||
formatted_title = format_title_for_platform(
|
||||
"feishu", first_title_data, show_source=True
|
||||
)
|
||||
elif format_type == "dingtalk":
|
||||
formatted_title = format_title_for_platform(
|
||||
"dingtalk", first_title_data, show_source=True
|
||||
)
|
||||
elif format_type == "slack":
|
||||
formatted_title = format_title_for_platform(
|
||||
"slack", first_title_data, show_source=True
|
||||
)
|
||||
else:
|
||||
formatted_title = f"{first_title_data['title']}"
|
||||
|
||||
first_news_line = f" 1. {formatted_title}\n"
|
||||
if len(stat["titles"]) > 1:
|
||||
first_news_line += "\n"
|
||||
|
||||
# 原子性检查:词组标题+第一条新闻必须一起处理
|
||||
word_with_first_news = word_header + first_news_line
|
||||
test_content = current_batch + word_with_first_news
|
||||
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
# 当前批次容纳不下,开启新批次
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + stats_header + word_with_first_news
|
||||
current_batch_has_content = True
|
||||
start_index = 1
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
start_index = 1
|
||||
|
||||
# 处理剩余新闻条目
|
||||
for j in range(start_index, len(stat["titles"])):
|
||||
title_data = stat["titles"][j]
|
||||
if format_type in ("wework", "bark"):
|
||||
formatted_title = format_title_for_platform(
|
||||
"wework", title_data, show_source=True
|
||||
)
|
||||
elif format_type == "telegram":
|
||||
formatted_title = format_title_for_platform(
|
||||
"telegram", title_data, show_source=True
|
||||
)
|
||||
elif format_type == "ntfy":
|
||||
formatted_title = format_title_for_platform(
|
||||
"ntfy", title_data, show_source=True
|
||||
)
|
||||
elif format_type == "feishu":
|
||||
formatted_title = format_title_for_platform(
|
||||
"feishu", title_data, show_source=True
|
||||
)
|
||||
elif format_type == "dingtalk":
|
||||
formatted_title = format_title_for_platform(
|
||||
"dingtalk", title_data, show_source=True
|
||||
)
|
||||
elif format_type == "slack":
|
||||
formatted_title = format_title_for_platform(
|
||||
"slack", title_data, show_source=True
|
||||
)
|
||||
else:
|
||||
formatted_title = f"{title_data['title']}"
|
||||
|
||||
news_line = f" {j + 1}. {formatted_title}\n"
|
||||
if j < len(stat["titles"]) - 1:
|
||||
news_line += "\n"
|
||||
|
||||
test_content = current_batch + news_line
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + stats_header + word_header + news_line
|
||||
current_batch_has_content = True
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
|
||||
# 词组间分隔符
|
||||
if i < len(report_data["stats"]) - 1:
|
||||
separator = ""
|
||||
if format_type in ("wework", "bark"):
|
||||
separator = f"\n\n\n\n"
|
||||
elif format_type == "telegram":
|
||||
separator = f"\n\n"
|
||||
elif format_type == "ntfy":
|
||||
separator = f"\n\n"
|
||||
elif format_type == "feishu":
|
||||
separator = f"\n{feishu_separator}\n\n"
|
||||
elif format_type == "dingtalk":
|
||||
separator = f"\n---\n\n"
|
||||
elif format_type == "slack":
|
||||
separator = f"\n\n"
|
||||
|
||||
test_content = current_batch + separator
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
< max_bytes
|
||||
):
|
||||
current_batch = test_content
|
||||
|
||||
return current_batch, current_batch_has_content, batches
|
||||
|
||||
# 定义处理新增新闻的函数
|
||||
def process_new_titles_section(current_batch, current_batch_has_content, batches):
|
||||
"""处理新增新闻"""
|
||||
if not report_data["new_titles"]:
|
||||
return current_batch, current_batch_has_content, batches
|
||||
|
||||
new_header = ""
|
||||
if format_type in ("wework", "bark"):
|
||||
new_header = f"\n\n\n\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
|
||||
elif format_type == "telegram":
|
||||
new_header = (
|
||||
f"\n\n🆕 本次新增热点新闻 (共 {report_data['total_new_count']} 条)\n\n"
|
||||
)
|
||||
elif format_type == "ntfy":
|
||||
new_header = f"\n\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
|
||||
elif format_type == "feishu":
|
||||
new_header = f"\n{feishu_separator}\n\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
|
||||
elif format_type == "dingtalk":
|
||||
new_header = f"\n---\n\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
|
||||
elif format_type == "slack":
|
||||
new_header = f"\n\n🆕 *本次新增热点新闻* (共 {report_data['total_new_count']} 条)\n\n"
|
||||
|
||||
test_content = current_batch + new_header
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + new_header
|
||||
current_batch_has_content = True
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
|
||||
# 逐个处理新增新闻来源
|
||||
for source_data in report_data["new_titles"]:
|
||||
source_header = ""
|
||||
if format_type in ("wework", "bark"):
|
||||
source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
|
||||
elif format_type == "telegram":
|
||||
source_header = f"{source_data['source_name']} ({len(source_data['titles'])} 条):\n\n"
|
||||
elif format_type == "ntfy":
|
||||
source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
|
||||
elif format_type == "feishu":
|
||||
source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
|
||||
elif format_type == "dingtalk":
|
||||
source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
|
||||
elif format_type == "slack":
|
||||
source_header = f"*{source_data['source_name']}* ({len(source_data['titles'])} 条):\n\n"
|
||||
|
||||
# 构建第一条新增新闻
|
||||
first_news_line = ""
|
||||
if source_data["titles"]:
|
||||
first_title_data = source_data["titles"][0]
|
||||
title_data_copy = first_title_data.copy()
|
||||
title_data_copy["is_new"] = False
|
||||
|
||||
if format_type in ("wework", "bark"):
|
||||
formatted_title = format_title_for_platform(
|
||||
"wework", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "telegram":
|
||||
formatted_title = format_title_for_platform(
|
||||
"telegram", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "feishu":
|
||||
formatted_title = format_title_for_platform(
|
||||
"feishu", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "dingtalk":
|
||||
formatted_title = format_title_for_platform(
|
||||
"dingtalk", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "slack":
|
||||
formatted_title = format_title_for_platform(
|
||||
"slack", title_data_copy, show_source=False
|
||||
)
|
||||
else:
|
||||
formatted_title = f"{title_data_copy['title']}"
|
||||
|
||||
first_news_line = f" 1. {formatted_title}\n"
|
||||
|
||||
# 原子性检查:来源标题+第一条新闻
|
||||
source_with_first_news = source_header + first_news_line
|
||||
test_content = current_batch + source_with_first_news
|
||||
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + new_header + source_with_first_news
|
||||
current_batch_has_content = True
|
||||
start_index = 1
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
start_index = 1
|
||||
|
||||
# 处理剩余新增新闻
|
||||
for j in range(start_index, len(source_data["titles"])):
|
||||
title_data = source_data["titles"][j]
|
||||
title_data_copy = title_data.copy()
|
||||
title_data_copy["is_new"] = False
|
||||
|
||||
if format_type == "wework":
|
||||
formatted_title = format_title_for_platform(
|
||||
"wework", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "telegram":
|
||||
formatted_title = format_title_for_platform(
|
||||
"telegram", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "feishu":
|
||||
formatted_title = format_title_for_platform(
|
||||
"feishu", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "dingtalk":
|
||||
formatted_title = format_title_for_platform(
|
||||
"dingtalk", title_data_copy, show_source=False
|
||||
)
|
||||
elif format_type == "slack":
|
||||
formatted_title = format_title_for_platform(
|
||||
"slack", title_data_copy, show_source=False
|
||||
)
|
||||
else:
|
||||
formatted_title = f"{title_data_copy['title']}"
|
||||
|
||||
news_line = f" {j + 1}. {formatted_title}\n"
|
||||
|
||||
test_content = current_batch + news_line
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + new_header + source_header + news_line
|
||||
current_batch_has_content = True
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
|
||||
current_batch += "\n"
|
||||
|
||||
return current_batch, current_batch_has_content, batches
|
||||
|
||||
# 根据配置决定处理顺序
|
||||
if reverse_content_order:
|
||||
# 新增热点在前,热点词汇统计在后
|
||||
current_batch, current_batch_has_content, batches = process_new_titles_section(
|
||||
current_batch, current_batch_has_content, batches
|
||||
)
|
||||
current_batch, current_batch_has_content, batches = process_stats_section(
|
||||
current_batch, current_batch_has_content, batches
|
||||
)
|
||||
else:
|
||||
# 默认:热点词汇统计在前,新增热点在后
|
||||
current_batch, current_batch_has_content, batches = process_stats_section(
|
||||
current_batch, current_batch_has_content, batches
|
||||
)
|
||||
current_batch, current_batch_has_content, batches = process_new_titles_section(
|
||||
current_batch, current_batch_has_content, batches
|
||||
)
|
||||
|
||||
if report_data["failed_ids"]:
|
||||
failed_header = ""
|
||||
if format_type == "wework":
|
||||
failed_header = f"\n\n\n\n⚠️ **数据获取失败的平台:**\n\n"
|
||||
elif format_type == "telegram":
|
||||
failed_header = f"\n\n⚠️ 数据获取失败的平台:\n\n"
|
||||
elif format_type == "ntfy":
|
||||
failed_header = f"\n\n⚠️ **数据获取失败的平台:**\n\n"
|
||||
elif format_type == "feishu":
|
||||
failed_header = f"\n{feishu_separator}\n\n⚠️ **数据获取失败的平台:**\n\n"
|
||||
elif format_type == "dingtalk":
|
||||
failed_header = f"\n---\n\n⚠️ **数据获取失败的平台:**\n\n"
|
||||
|
||||
test_content = current_batch + failed_header
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + failed_header
|
||||
current_batch_has_content = True
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
|
||||
for i, id_value in enumerate(report_data["failed_ids"], 1):
|
||||
if format_type == "feishu":
|
||||
failed_line = f" • <font color='red'>{id_value}</font>\n"
|
||||
elif format_type == "dingtalk":
|
||||
failed_line = f" • **{id_value}**\n"
|
||||
else:
|
||||
failed_line = f" • {id_value}\n"
|
||||
|
||||
test_content = current_batch + failed_line
|
||||
if (
|
||||
len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
|
||||
>= max_bytes
|
||||
):
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
current_batch = base_header + failed_header + failed_line
|
||||
current_batch_has_content = True
|
||||
else:
|
||||
current_batch = test_content
|
||||
current_batch_has_content = True
|
||||
|
||||
# 完成最后批次
|
||||
if current_batch_has_content:
|
||||
batches.append(current_batch + base_footer)
|
||||
|
||||
return batches
|
||||
Reference in New Issue
Block a user