285 lines
12 KiB
Python
285 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
新闻智能跟踪系统 - 邮件通知
|
|
支持 plain / starttls / ssl 三种 SMTP 模式,发送实时重要资讯与每日汇总。
|
|
"""
|
|
import smtplib
|
|
from datetime import datetime
|
|
from email.header import Header
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.utils import formataddr, formatdate
|
|
|
|
import config
|
|
import db
|
|
|
|
|
|
def get_mail_cfg():
|
|
cfg = dict(config.MAIL_DEFAULTS)
|
|
cfg.update(db.get_all_settings().get("mail", {}))
|
|
return cfg
|
|
|
|
|
|
def send_email(subject, html, to=None):
|
|
cfg = get_mail_cfg()
|
|
to = to or cfg["email_to"]
|
|
msg = MIMEMultipart("alternative")
|
|
msg["From"] = formataddr((str(Header(cfg.get("sender_name", "新闻智能跟踪"), "utf-8")), cfg["smtp_user"]))
|
|
msg["To"] = to
|
|
msg["Subject"] = Header(subject, "utf-8")
|
|
# mail.tphai.com 的 amavisd 强制要求 Date 头,缺失会退信(554 5.6.0 BAD HEADER)
|
|
msg["Date"] = formatdate(localtime=True)
|
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
|
|
|
mode = cfg.get("smtp_mode", "plain")
|
|
if mode == "ssl":
|
|
server = smtplib.SMTP_SSL(cfg["smtp_host"], int(cfg["smtp_port"]), timeout=20)
|
|
else:
|
|
server = smtplib.SMTP(cfg["smtp_host"], int(cfg["smtp_port"]), timeout=20)
|
|
if mode == "starttls":
|
|
server.starttls()
|
|
try:
|
|
server.login(cfg["smtp_user"], cfg["smtp_pass"])
|
|
server.sendmail(cfg["smtp_user"], [to], msg.as_string())
|
|
finally:
|
|
server.quit()
|
|
return True
|
|
|
|
|
|
# ============ 系统错误邮件通知(频率 + 静默时段) ============
|
|
|
|
def _esc(s):
|
|
return str(s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
def _errnotify_cfg():
|
|
cfg = dict(config.ERRNOTIFY_DEFAULTS)
|
|
cfg.update(db.get_all_settings().get("errnotify", {}))
|
|
return cfg
|
|
|
|
|
|
def _in_quiet_period(now=None):
|
|
"""是否处于静默时段。支持多段、跨午夜(如 23:00-07:30)。"""
|
|
cfg = _errnotify_cfg()
|
|
if not cfg.get("quiet_enabled"):
|
|
return False
|
|
now = now or datetime.now()
|
|
cur_min = now.hour * 60 + now.minute
|
|
for p in cfg.get("quiet_periods") or []:
|
|
p = (p or "").strip()
|
|
if not p or "-" not in p:
|
|
continue
|
|
try:
|
|
s, e = p.split("-")
|
|
sh, sm = map(int, s.strip().split(":"))
|
|
eh, em = map(int, e.strip().split(":"))
|
|
except Exception:
|
|
continue
|
|
s_min, e_min = sh * 60 + sm, eh * 60 + em
|
|
if s_min <= e_min:
|
|
if s_min <= cur_min < e_min:
|
|
return True
|
|
else: # 跨午夜
|
|
if cur_min >= s_min or cur_min < e_min:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _send_error_digest(errors):
|
|
"""发送一封错误通知邮件(受调用方冷却/静默控制),并记录通知日志"""
|
|
rows = "".join(
|
|
f"<div style='border:1px solid #fecaca;border-left:4px solid #dc2626;border-radius:6px;"
|
|
f"padding:10px 14px;margin:8px 0;'>"
|
|
f"<div style='font-weight:bold;color:#b91c1c;'>{_esc(e.get('source', ''))} · 发生 {e.get('count', 1)} 次</div>"
|
|
f"<div style='font-size:13px;color:#374151;margin-top:2px;'>{_esc(e.get('message', ''))}</div>"
|
|
f"<div style='font-size:12px;color:#6b7280;'>{_esc(e.get('detail', ''))}</div>"
|
|
f"<div style='font-size:11px;color:#9ca3af;'>最近: {_esc(e.get('last_seen', ''))} · "
|
|
f"首次: {_esc(e.get('first_seen', ''))}</div></div>"
|
|
for e in errors
|
|
)
|
|
subject = f"⚠️ 系统异常通知 · {len(errors)} 类错误"
|
|
html = _html_wrap(
|
|
"系统运行异常提醒",
|
|
f"<p style='color:#374151;font-size:13px;'>新闻智能跟踪系统检测到以下异常,请及时处理:</p>{rows}",
|
|
)
|
|
send_email(subject, html)
|
|
db.add_log("error", subject, len(errors), [], status="ok", detail="系统错误邮件通知", body=html)
|
|
|
|
|
|
def _try_send_pending():
|
|
"""按通知策略发送待通知错误(受静默时段 + 冷却频率控制)。返回发送条数。"""
|
|
cfg = _errnotify_cfg()
|
|
if not cfg.get("enabled") or cfg.get("mode") == "off":
|
|
return 0
|
|
if _in_quiet_period():
|
|
return 0 # 静默时段不发,等待调度器在静默结束后 flush
|
|
# 冷却频率:两次错误邮件最小间隔
|
|
last_sent = db.get_err_last_send()
|
|
if last_sent:
|
|
try:
|
|
lt = datetime.strptime(last_sent, "%Y-%m-%d %H:%M:%S")
|
|
cooldown = max(0, int(cfg.get("cooldown_min", 60) or 0))
|
|
if cooldown > 0 and (datetime.now() - lt).total_seconds() < cooldown * 60:
|
|
return 0 # 冷却中
|
|
except Exception:
|
|
pass
|
|
errors = db.pending_system_errors(limit=int(cfg.get("max_items", 10) or 10))
|
|
if not errors:
|
|
return 0
|
|
try:
|
|
_send_error_digest(errors)
|
|
except Exception:
|
|
# 邮件发送失败:保留 pending,下次重试
|
|
return 0
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
db.mark_errors_notified([e["id"] for e in errors], now)
|
|
db.set_err_last_send(now)
|
|
return len(errors)
|
|
|
|
|
|
def report_error(source, message, detail=""):
|
|
"""记录一条系统错误;immediate 模式下尝试立即发送(受冷却/静默控制)。"""
|
|
try:
|
|
db.add_system_error(source, message, detail)
|
|
except Exception:
|
|
return None
|
|
cfg = _errnotify_cfg()
|
|
if not cfg.get("enabled") or cfg.get("mode") not in ("immediate", "cooldown"):
|
|
return None
|
|
if cfg.get("mode") == "immediate":
|
|
try:
|
|
_try_send_pending()
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
|
|
def flush_pending_errors():
|
|
"""后台调度器定时调用:把待通知错误按策略发送(冷却 + 静默控制)"""
|
|
return _try_send_pending()
|
|
|
|
|
|
def test_error_notify():
|
|
"""发送一封测试错误邮件(无视冷却/静默,用于设置页测试按钮)"""
|
|
_send_error_digest([{
|
|
"source": "测试", "message": "这是一封测试错误通知邮件",
|
|
"detail": "如果你收到了这封邮件,说明系统错误邮件通知链路正常。",
|
|
"count": 1, "first_seen": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"last_seen": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
}])
|
|
return True
|
|
|
|
|
|
def _score_color(score):
|
|
if score >= 80:
|
|
return "#e74c3c"
|
|
if score >= 60:
|
|
return "#e67e22"
|
|
return "#7f8c8d"
|
|
|
|
|
|
def _card(art):
|
|
ents = "、".join(art.get("entities") or [])
|
|
src = db.get_source(art.get("source_id") or 0) if art.get("source_id") else None
|
|
custom = bool(src and src.get("kind") == "custom")
|
|
badge = ('<span style="background:#ede9fe;color:#6d28d9;padding:2px 8px;'
|
|
'border-radius:6px;font-size:11px;margin-right:6px;">🎯 定制监控命中</span>') if custom else ""
|
|
return f"""
|
|
<div style="border:1px solid #e5e7eb;border-left:4px solid {_score_color(art.get('total_score',0))};
|
|
border-radius:6px;padding:10px 14px;margin:8px 0;">
|
|
<div style="font-size:14px;font-weight:bold;color:#1f2937;">
|
|
{badge}<a href="{art.get('url','#')}" style="color:#1f2937;text-decoration:none;">{art['title']}</a>
|
|
</div>
|
|
<div style="font-size:12px;color:#6b7280;margin:4px 0;">
|
|
综合分 {art.get('total_score',0)} · 相关度 {art.get('relevance',0)} ·
|
|
{art.get('source_name','')} · {art.get('published_at','')}
|
|
</div>
|
|
<div style="font-size:13px;color:#374151;">{art.get('summary') or art.get('content','')[:120]}</div>
|
|
<div style="font-size:12px;color:#9ca3af;">涉及:{ents if ents else '—'}</div>
|
|
{('<div style="font-size:12px;color:#b45309;background:#fef3c7;padding:4px 8px;border-radius:4px;margin-top:4px;">💡 ' + art.get('analysis','') + '</div>') if art.get('analysis') else ''}
|
|
</div>"""
|
|
|
|
|
|
def _html_wrap(title, body):
|
|
return f"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"></head>
|
|
<body style="font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f9fafb;padding:20px;">
|
|
<div style="max-width:720px;margin:0 auto;background:#ffffff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.08);">
|
|
<h2 style="margin:0 0 4px;color:#111827;">{title}</h2>
|
|
{body}
|
|
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;">
|
|
由 新闻智能跟踪系统 自动生成 · {config.SERVICE_NAME}
|
|
</div>
|
|
</div>
|
|
</body></html>"""
|
|
|
|
|
|
def send_realtime(articles):
|
|
"""实时重要资讯通知(一次一批)"""
|
|
if not articles:
|
|
return 0
|
|
cards = "".join(_card(a) for a in articles)
|
|
subject = f"🔥 重要AI资讯 {len(articles)}条 · {articles[0]['published_at'][:16]}"
|
|
html = _html_wrap(
|
|
"重要资讯实时提醒",
|
|
f"<p style='color:#374151;font-size:13px;'>以下 {len(articles)} 条资讯对您重点关注领域很重要,已自动甄别:</p>{cards}",
|
|
)
|
|
send_email(subject, html)
|
|
ids = [a["id"] for a in articles]
|
|
for aid in ids:
|
|
db.update_article(aid, notified=1)
|
|
db.add_log("realtime", subject, len(articles), ids, status="ok", detail="实时通知", body=html)
|
|
return len(articles)
|
|
|
|
|
|
def send_daily_summary(articles, window_label):
|
|
"""新闻机制:每日汇总(默认每天10点)——普通源的重要/相关资讯"""
|
|
if not articles:
|
|
return 0
|
|
top = articles[: int(db.get_setting("max_summary_items", config.AUTO_DEFAULTS["max_summary_items"]))]
|
|
cards = "".join(_card(a) for a in top)
|
|
# 分领域统计
|
|
from collections import Counter
|
|
dom_cnt = Counter(a.get("domain") or "未分类" for a in articles)
|
|
stats = " · ".join(f"{k} {v}条" for k, v in dom_cnt.most_common(6))
|
|
subject = f"📰 AI资讯日报 {window_label} · 共{len(articles)}条 重点{len(top)}条"
|
|
html = _html_wrap(
|
|
"AI 重要资讯日报",
|
|
f"""
|
|
<p style="color:#374151;font-size:13px;">汇总时段:{window_label}</p>
|
|
<p style="color:#6b7280;font-size:12px;">领域分布:{stats}</p>
|
|
<p style="color:#374151;font-size:13px;margin-top:16px;">重点资讯(按综合分排序):</p>
|
|
{cards}
|
|
""",
|
|
)
|
|
send_email(subject, html)
|
|
ids = [a["id"] for a in articles if a["id"]]
|
|
db.add_log("summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}条", body=html)
|
|
for aid in ids:
|
|
art = db.get_article(aid)
|
|
if art and art.get("status") != "summarized":
|
|
db.update_article(aid, status="summarized")
|
|
return len(top)
|
|
|
|
|
|
def send_custom_summary(articles, window_label):
|
|
"""定制监控机制:独立汇总邮件——定制源在窗口内命中推送标准的资讯(单独配置/单独时间)"""
|
|
if not articles:
|
|
return 0
|
|
top = articles[: int(db.get_setting("custom_max_summary_items",
|
|
config.CUSTOM_DEFAULTS["custom_max_summary_items"]))]
|
|
cards = "".join(_card(a) for a in top)
|
|
subject = f"🎯 定制监控汇总 {window_label} · 命中{len(articles)}条"
|
|
html = _html_wrap(
|
|
"定制监控命中汇总",
|
|
f"""
|
|
<p style="color:#374151;font-size:13px;">汇总时段:{window_label}</p>
|
|
<p style="color:#6b7280;font-size:12px;">以下为定制监控源中<b>达到推送标准</b>的资讯(由大模型按各源推送标准判定):</p>
|
|
{cards}
|
|
""",
|
|
)
|
|
send_email(subject, html)
|
|
ids = [a["id"] for a in articles if a["id"]]
|
|
db.add_log("custom_summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}条", body=html)
|
|
return len(top)
|