|
|
@@ -6,6 +6,11 @@ import random
|
|
|
import re
|
|
|
import time
|
|
|
import webbrowser
|
|
|
+import smtplib
|
|
|
+from email.mime.text import MIMEText
|
|
|
+from email.mime.multipart import MIMEMultipart
|
|
|
+from email.header import Header
|
|
|
+from email.utils import formataddr, formatdate, make_msgid
|
|
|
from datetime import datetime
|
|
|
from pathlib import Path
|
|
|
from typing import Dict, List, Tuple, Optional, Union
|
|
|
@@ -15,9 +20,69 @@ import requests
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
-VERSION = "2.2.0"
|
|
|
+VERSION = "2.3.0"
|
|
|
|
|
|
|
|
|
+# === SMTP邮件配置 ===
|
|
|
+SMTP_CONFIGS = {
|
|
|
+ # Gmail
|
|
|
+ 'gmail.com': {
|
|
|
+ 'server': 'smtp.gmail.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+
|
|
|
+ # QQ邮箱
|
|
|
+ 'qq.com': {
|
|
|
+ 'server': 'smtp.qq.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+
|
|
|
+ # Outlook
|
|
|
+ 'outlook.com': {
|
|
|
+ 'server': 'smtp-mail.outlook.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+ 'hotmail.com': {
|
|
|
+ 'server': 'smtp-mail.outlook.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+ 'live.com': {
|
|
|
+ 'server': 'smtp-mail.outlook.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+
|
|
|
+ # 网易邮箱
|
|
|
+ '163.com': {
|
|
|
+ 'server': 'smtp.163.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+ '126.com': {
|
|
|
+ 'server': 'smtp.126.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+
|
|
|
+ # 新浪邮箱
|
|
|
+ 'sina.com': {
|
|
|
+ 'server': 'smtp.sina.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ },
|
|
|
+
|
|
|
+ # 搜狐邮箱
|
|
|
+ 'sohu.com': {
|
|
|
+ 'server': 'smtp.sohu.com',
|
|
|
+ 'port': 587,
|
|
|
+ 'encryption': 'TLS'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
# === 配置管理 ===
|
|
|
def load_config():
|
|
|
"""加载配置文件"""
|
|
|
@@ -96,6 +161,23 @@ def load_config():
|
|
|
config["TELEGRAM_CHAT_ID"] = os.environ.get(
|
|
|
"TELEGRAM_CHAT_ID", ""
|
|
|
).strip() or webhooks.get("telegram_chat_id", "")
|
|
|
+
|
|
|
+ # 邮件配置
|
|
|
+ config["EMAIL_FROM"] = os.environ.get(
|
|
|
+ "EMAIL_FROM", ""
|
|
|
+ ).strip() or webhooks.get("email_from", "")
|
|
|
+ config["EMAIL_PASSWORD"] = os.environ.get(
|
|
|
+ "EMAIL_PASSWORD", ""
|
|
|
+ ).strip() or webhooks.get("email_password", "")
|
|
|
+ config["EMAIL_TO"] = os.environ.get(
|
|
|
+ "EMAIL_TO", ""
|
|
|
+ ).strip() or webhooks.get("email_to", "")
|
|
|
+ config["EMAIL_SMTP_SERVER"] = os.environ.get(
|
|
|
+ "EMAIL_SMTP_SERVER", ""
|
|
|
+ ).strip() or webhooks.get("email_smtp_server", "")
|
|
|
+ config["EMAIL_SMTP_PORT"] = os.environ.get(
|
|
|
+ "EMAIL_SMTP_PORT", ""
|
|
|
+ ).strip() or webhooks.get("email_smtp_port", "")
|
|
|
|
|
|
# 输出配置来源信息
|
|
|
webhook_sources = []
|
|
|
@@ -114,7 +196,10 @@ def load_config():
|
|
|
)
|
|
|
chat_source = "环境变量" if os.environ.get("TELEGRAM_CHAT_ID") else "配置文件"
|
|
|
webhook_sources.append(f"Telegram({token_source}/{chat_source})")
|
|
|
-
|
|
|
+ if config["EMAIL_FROM"] and config["EMAIL_PASSWORD"] and config["EMAIL_TO"]:
|
|
|
+ from_source = "环境变量" if os.environ.get("EMAIL_FROM") else "配置文件"
|
|
|
+ webhook_sources.append(f"邮件({from_source})")
|
|
|
+
|
|
|
if webhook_sources:
|
|
|
print(f"Webhook 配置来源: {', '.join(webhook_sources)}")
|
|
|
else:
|
|
|
@@ -1464,6 +1549,7 @@ def generate_html_report(
|
|
|
id_to_name: Optional[Dict] = None,
|
|
|
mode: str = "daily",
|
|
|
is_daily_summary: bool = False,
|
|
|
+ update_info: Optional[Dict] = None,
|
|
|
) -> str:
|
|
|
"""生成HTML报告"""
|
|
|
if is_daily_summary:
|
|
|
@@ -1481,7 +1567,7 @@ def generate_html_report(
|
|
|
report_data = prepare_report_data(stats, failed_ids, new_titles, id_to_name, mode)
|
|
|
|
|
|
html_content = render_html_content(
|
|
|
- report_data, total_titles, is_daily_summary, mode
|
|
|
+ report_data, total_titles, is_daily_summary, mode, update_info
|
|
|
)
|
|
|
|
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
|
@@ -1500,6 +1586,7 @@ def render_html_content(
|
|
|
total_titles: int,
|
|
|
is_daily_summary: bool = False,
|
|
|
mode: str = "daily",
|
|
|
+ update_info: Optional[Dict] = None,
|
|
|
) -> str:
|
|
|
"""渲染HTML内容"""
|
|
|
html = """
|
|
|
@@ -1876,7 +1963,7 @@ def render_html_content(
|
|
|
.footer-content {
|
|
|
font-size: 13px;
|
|
|
color: #6b7280;
|
|
|
- line-height: 1.4;
|
|
|
+ line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.footer-link {
|
|
|
@@ -2157,7 +2244,16 @@ def render_html_content(
|
|
|
由 <span class="project-name">TrendRadar</span> 生成 ·
|
|
|
<a href="https://github.com/sansan0/TrendRadar" target="_blank" class="footer-link">
|
|
|
GitHub 开源项目
|
|
|
- </a>
|
|
|
+ </a>"""
|
|
|
+
|
|
|
+ if update_info:
|
|
|
+ html += f"""
|
|
|
+ <br>
|
|
|
+ <span style="color: #ea580c; font-weight: 500;">
|
|
|
+ 发现新版本 {update_info['remote_version']},当前版本 {update_info['current_version']}
|
|
|
+ </span>"""
|
|
|
+
|
|
|
+ html += """
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -2823,6 +2919,7 @@ def send_to_webhooks(
|
|
|
update_info: Optional[Dict] = None,
|
|
|
proxy_url: Optional[str] = None,
|
|
|
mode: str = "daily",
|
|
|
+ html_file_path: Optional[str] = None,
|
|
|
) -> Dict[str, bool]:
|
|
|
"""发送数据到多个webhook平台"""
|
|
|
results = {}
|
|
|
@@ -2851,6 +2948,11 @@ def send_to_webhooks(
|
|
|
wework_url = CONFIG["WEWORK_WEBHOOK_URL"]
|
|
|
telegram_token = CONFIG["TELEGRAM_BOT_TOKEN"]
|
|
|
telegram_chat_id = CONFIG["TELEGRAM_CHAT_ID"]
|
|
|
+ email_from = CONFIG["EMAIL_FROM"]
|
|
|
+ email_password = CONFIG["EMAIL_PASSWORD"]
|
|
|
+ email_to = CONFIG["EMAIL_TO"]
|
|
|
+ email_smtp_server = CONFIG.get("EMAIL_SMTP_SERVER", "")
|
|
|
+ email_smtp_port = CONFIG.get("EMAIL_SMTP_PORT", "")
|
|
|
|
|
|
update_info_to_send = update_info if CONFIG["SHOW_VERSION_UPDATE"] else None
|
|
|
|
|
|
@@ -2884,6 +2986,18 @@ def send_to_webhooks(
|
|
|
mode,
|
|
|
)
|
|
|
|
|
|
+ # 发送邮件
|
|
|
+ if email_from and email_password and email_to:
|
|
|
+ results["email"] = send_to_email(
|
|
|
+ email_from,
|
|
|
+ email_password,
|
|
|
+ email_to,
|
|
|
+ report_type,
|
|
|
+ html_file_path,
|
|
|
+ email_smtp_server,
|
|
|
+ email_smtp_port,
|
|
|
+ )
|
|
|
+
|
|
|
if not results:
|
|
|
print("未配置任何webhook URL,跳过通知发送")
|
|
|
|
|
|
@@ -3156,6 +3270,137 @@ def send_to_telegram(
|
|
|
print(f"Telegram所有 {len(batches)} 批次发送完成 [{report_type}]")
|
|
|
return True
|
|
|
|
|
|
+def send_to_email(
|
|
|
+ from_email: str,
|
|
|
+ password: str,
|
|
|
+ to_email: str,
|
|
|
+ report_type: str,
|
|
|
+ html_file_path: str,
|
|
|
+ custom_smtp_server: Optional[str] = None,
|
|
|
+ custom_smtp_port: Optional[int] = None,
|
|
|
+) -> bool:
|
|
|
+ """发送邮件通知"""
|
|
|
+ try:
|
|
|
+ if not html_file_path or not Path(html_file_path).exists():
|
|
|
+ print(f"错误:HTML文件不存在或未提供: {html_file_path}")
|
|
|
+ return False
|
|
|
+
|
|
|
+ print(f"使用HTML文件: {html_file_path}")
|
|
|
+ with open(html_file_path, "r", encoding="utf-8") as f:
|
|
|
+ html_content = f.read()
|
|
|
+
|
|
|
+ domain = from_email.split('@')[-1].lower()
|
|
|
+
|
|
|
+ if custom_smtp_server and custom_smtp_port:
|
|
|
+ # 使用自定义 SMTP 配置
|
|
|
+ smtp_server = custom_smtp_server
|
|
|
+ smtp_port = int(custom_smtp_port)
|
|
|
+ use_tls = smtp_port == 587
|
|
|
+ elif domain in SMTP_CONFIGS:
|
|
|
+ # 使用预设配置
|
|
|
+ config = SMTP_CONFIGS[domain]
|
|
|
+ smtp_server = config['server']
|
|
|
+ smtp_port = config['port']
|
|
|
+ use_tls = config['encryption'] == 'TLS'
|
|
|
+ else:
|
|
|
+ print(f"未识别的邮箱服务商: {domain},使用通用 SMTP 配置")
|
|
|
+ smtp_server = f"smtp.{domain}"
|
|
|
+ smtp_port = 587
|
|
|
+ use_tls = True
|
|
|
+
|
|
|
+ msg = MIMEMultipart('alternative')
|
|
|
+
|
|
|
+ # 严格按照 RFC 标准设置 From header
|
|
|
+ sender_name = "TrendRadar"
|
|
|
+ msg['From'] = formataddr((sender_name, from_email))
|
|
|
+
|
|
|
+ # 设置收件人
|
|
|
+ recipients = [addr.strip() for addr in to_email.split(',')]
|
|
|
+ if len(recipients) == 1:
|
|
|
+ msg['To'] = recipients[0]
|
|
|
+ else:
|
|
|
+ msg['To'] = ', '.join(recipients)
|
|
|
+
|
|
|
+ # 设置邮件主题
|
|
|
+ now = get_beijing_time()
|
|
|
+ subject = f"TrendRadar 热点分析报告 - {report_type} - {now.strftime('%m月%d日 %H:%M')}"
|
|
|
+ msg['Subject'] = Header(subject, 'utf-8')
|
|
|
+
|
|
|
+ # 设置其他标准 header
|
|
|
+ msg['MIME-Version'] = '1.0'
|
|
|
+ msg['Date'] = formatdate(localtime=True)
|
|
|
+ msg['Message-ID'] = make_msgid()
|
|
|
+
|
|
|
+ # 添加纯文本部分(作为备选)
|
|
|
+ text_content = f"""
|
|
|
+TrendRadar 热点分析报告
|
|
|
+========================
|
|
|
+报告类型:{report_type}
|
|
|
+生成时间:{now.strftime('%Y-%m-%d %H:%M:%S')}
|
|
|
+
|
|
|
+请使用支持HTML的邮件客户端查看完整报告内容。
|
|
|
+ """
|
|
|
+ text_part = MIMEText(text_content, 'plain', 'utf-8')
|
|
|
+ msg.attach(text_part)
|
|
|
+
|
|
|
+ html_part = MIMEText(html_content, 'html', 'utf-8')
|
|
|
+ msg.attach(html_part)
|
|
|
+
|
|
|
+ print(f"正在发送邮件到 {to_email}...")
|
|
|
+ print(f"SMTP 服务器: {smtp_server}:{smtp_port}")
|
|
|
+ print(f"发件人: {from_email}")
|
|
|
+
|
|
|
+ try:
|
|
|
+ if use_tls:
|
|
|
+ # TLS 模式
|
|
|
+ server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
|
|
|
+ server.set_debuglevel(0) # 设为1可以查看详细调试信息
|
|
|
+ server.ehlo()
|
|
|
+ server.starttls()
|
|
|
+ server.ehlo()
|
|
|
+ else:
|
|
|
+ # SSL 模式
|
|
|
+ server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
|
|
|
+ server.set_debuglevel(0)
|
|
|
+ server.ehlo()
|
|
|
+
|
|
|
+ # 登录
|
|
|
+ server.login(from_email, password)
|
|
|
+
|
|
|
+ # 发送邮件
|
|
|
+ server.send_message(msg)
|
|
|
+ server.quit()
|
|
|
+
|
|
|
+ print(f"邮件发送成功 [{report_type}] -> {to_email}")
|
|
|
+ return True
|
|
|
+
|
|
|
+ except smtplib.SMTPServerDisconnected:
|
|
|
+ print(f"邮件发送失败:服务器意外断开连接,请检查网络或稍后重试")
|
|
|
+ return False
|
|
|
+
|
|
|
+ except smtplib.SMTPAuthenticationError as e:
|
|
|
+ print(f"邮件发送失败:认证错误,请检查邮箱和密码/授权码")
|
|
|
+ print(f"详细错误: {str(e)}")
|
|
|
+ return False
|
|
|
+ except smtplib.SMTPRecipientsRefused as e:
|
|
|
+ print(f"邮件发送失败:收件人地址被拒绝 {e}")
|
|
|
+ return False
|
|
|
+ except smtplib.SMTPSenderRefused as e:
|
|
|
+ print(f"邮件发送失败:发件人地址被拒绝 {e}")
|
|
|
+ return False
|
|
|
+ except smtplib.SMTPDataError as e:
|
|
|
+ print(f"邮件发送失败:邮件数据错误 {e}")
|
|
|
+ return False
|
|
|
+ except smtplib.SMTPConnectError as e:
|
|
|
+ print(f"邮件发送失败:无法连接到 SMTP 服务器 {smtp_server}:{smtp_port}")
|
|
|
+ print(f"详细错误: {str(e)}")
|
|
|
+ return False
|
|
|
+ except Exception as e:
|
|
|
+ print(f"邮件发送失败 [{report_type}]:{e}")
|
|
|
+ import traceback
|
|
|
+ traceback.print_exc()
|
|
|
+ return False
|
|
|
+
|
|
|
|
|
|
# === 主分析器 ===
|
|
|
class NewsAnalyzer:
|
|
|
@@ -3374,6 +3619,7 @@ class NewsAnalyzer:
|
|
|
id_to_name=id_to_name,
|
|
|
mode=mode,
|
|
|
is_daily_summary=is_daily_summary,
|
|
|
+ update_info=self.update_info if CONFIG["SHOW_VERSION_UPDATE"] else None,
|
|
|
)
|
|
|
|
|
|
return stats, html_file
|
|
|
@@ -3386,6 +3632,7 @@ class NewsAnalyzer:
|
|
|
failed_ids: Optional[List] = None,
|
|
|
new_titles: Optional[Dict] = None,
|
|
|
id_to_name: Optional[Dict] = None,
|
|
|
+ html_file_path: Optional[str] = None,
|
|
|
) -> bool:
|
|
|
"""统一的通知发送逻辑,包含所有判断条件"""
|
|
|
has_webhook = self._has_webhook_configured()
|
|
|
@@ -3404,6 +3651,7 @@ class NewsAnalyzer:
|
|
|
self.update_info,
|
|
|
self.proxy_url,
|
|
|
mode=mode,
|
|
|
+ html_file_path=html_file_path,
|
|
|
)
|
|
|
return True
|
|
|
elif CONFIG["ENABLE_NOTIFICATION"] and not has_webhook:
|
|
|
@@ -3456,14 +3704,16 @@ class NewsAnalyzer:
|
|
|
)
|
|
|
|
|
|
print(f"{summary_type}报告已生成: {html_file}")
|
|
|
-
|
|
|
+
|
|
|
# 发送通知
|
|
|
self._send_notification_if_needed(
|
|
|
stats,
|
|
|
mode_strategy["summary_report_type"],
|
|
|
mode_strategy["summary_mode"],
|
|
|
+ failed_ids=[],
|
|
|
new_titles=new_titles,
|
|
|
id_to_name=id_to_name,
|
|
|
+ html_file_path=html_file,
|
|
|
)
|
|
|
|
|
|
return html_file
|
|
|
@@ -3596,6 +3846,7 @@ class NewsAnalyzer:
|
|
|
failed_ids=failed_ids,
|
|
|
new_titles=historical_new_titles,
|
|
|
id_to_name=combined_id_to_name,
|
|
|
+ html_file_path=html_file,
|
|
|
)
|
|
|
else:
|
|
|
print("❌ 严重错误:无法读取刚保存的数据文件")
|
|
|
@@ -3624,6 +3875,7 @@ class NewsAnalyzer:
|
|
|
failed_ids=failed_ids,
|
|
|
new_titles=new_titles,
|
|
|
id_to_name=id_to_name,
|
|
|
+ html_file_path=html_file,
|
|
|
)
|
|
|
|
|
|
# 生成汇总报告(如果需要)
|