mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 03:07:50 +08:00
265 lines
9.7 KiB
Python
265 lines
9.7 KiB
Python
# 推送邮件 HTML 模板
|
||
# 用于生成定时推送给用户的热点摘要邮件
|
||
|
||
# 邮件客户端不支持 Font Awesome,改用 Emoji 代替平台图标
|
||
PLATFORM_EMOJI: dict[str, str] = {
|
||
"微博热搜": "🔴",
|
||
"微博": "🔴",
|
||
"知乎热榜": "🔵",
|
||
"知乎": "🔵",
|
||
"百度热搜": "🔍",
|
||
"今日头条": "📰",
|
||
"抖音热榜": "🎵",
|
||
"抖音": "🎵",
|
||
"bilibili 热搜": "📺",
|
||
"B站热搜": "📺",
|
||
"华尔街见闻": "📈",
|
||
"澎湃新闻": "🌊",
|
||
"财联社热门": "💰",
|
||
"凤凰网": "🦅",
|
||
"贴吧": "💬",
|
||
}
|
||
|
||
DIGEST_HTML_TEMPLATE = """\
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
|
||
body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;}}
|
||
.container{{max-width:640px;margin:0 auto;padding:32px 16px;}}
|
||
|
||
|
||
.header{{text-align:center;padding:10px 0 30px;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,0.06);}}
|
||
.header h1{{font-size:26px;font-weight:800;margin:0 0 10px;color:#ffffff;text-shadow:0 0 16px rgba(139,92,246,0.5);letter-spacing:0.5px;}}
|
||
.header p{{font-size:14px;color:#8b949e;margin:0;}}
|
||
|
||
|
||
.mode-badge{{display:inline-block;margin-top:12px;padding:4px 14px;border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.5px;}}
|
||
.mode-default{{background:rgba(59,130,246,0.15);color:#7dd3fc;border:1px solid rgba(59,130,246,0.3);}}
|
||
.mode-keyword{{background:rgba(168,85,247,0.15);color:#e879f9;border:1px solid rgba(168,85,247,0.3);}}
|
||
|
||
|
||
.event-card{{background:#161b22;border:1px solid #30363d;border-radius:16px;padding:20px;margin-bottom:20px;box-shadow:0 4px 12px rgba(0,0,0,0.2);}}
|
||
.event-card.is-hot{{border-left:4px solid #f85149;background:linear-gradient(90deg, rgba(248,81,73,0.03) 0%, transparent 100%), #161b22;}}
|
||
|
||
|
||
.event-title{{font-size:18px;font-weight:700;margin:0 0 14px;color:#ffffff;line-height:1.5;}}
|
||
|
||
|
||
.event-meta{{margin-bottom:12px;}}
|
||
.badge{{display:inline-block;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;margin-right:6px;margin-bottom:6px;}}
|
||
.badge-hot{{background:rgba(248,81,73,0.15);color:#ff7b72;border:1px solid rgba(248,81,73,0.3);}}
|
||
.badge-warm{{background:rgba(210,153,34,0.15);color:#d29922;border:1px solid rgba(210,153,34,0.3);}}
|
||
.badge-normal{{background:rgba(56,139,253,0.15);color:#58a6ff;border:1px solid rgba(56,139,253,0.3);}}
|
||
.badge-tag{{background:rgba(139,148,158,0.15);color:#8b949e;border:1px solid rgba(139,148,158,0.2);}}
|
||
|
||
|
||
.summary{{font-size:14px;line-height:1.6;color:#c9d1d9;padding:12px 16px;background:rgba(139,92,246,0.06);border-radius:0 8px 8px 0;border-left:3px solid #a78bfa;margin-bottom:16px;}}
|
||
.summary strong{{color:#a78bfa;font-weight:600;}}
|
||
|
||
|
||
.platforms-list{{margin:0;padding:0;list-style:none;background:rgba(255,255,255,0.02);border-radius:10px;padding:12px;}}
|
||
.platform-item{{padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);}}
|
||
.platform-item:last-child{{border-bottom:none;padding-bottom:0;}}
|
||
.platform-item:first-child{{padding-top:0;}}
|
||
.platform-source{{font-size:12px;color:#8b949e;margin-bottom:4px;display:flex;align-items:center;}}
|
||
.platform-rank{{display:inline-block;padding:2px 6px;border-radius:4px;background:rgba(210,153,34,0.15);color:#d29922;font-size:10px;font-weight:700;margin-left:6px;}}
|
||
.platform-link{{font-size:14px;color:#79c0ff;text-decoration:none;line-height:1.5;display:block;transition:color 0.2s;}}
|
||
.platform-link:hover{{text-decoration:underline;color:#a5d6ff;}}
|
||
.platform-text{{font-size:14px;color:#e6edf3;line-height:1.5;}}
|
||
|
||
/* 匹配信息底部栏 */
|
||
.match-info{{font-size:12px;color:#8b949e;margin-top:16px;padding-top:12px;border-top:1px dashed #30363d;}}
|
||
.hit{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;margin-right:4px;margin-top:4px;}}
|
||
.hit-exact{{background:rgba(46,160,67,0.15);color:#3fb950;}}
|
||
.hit-semantic{{background:rgba(163,113,247,0.15);color:#d2a8ff;}}
|
||
|
||
/* 页脚 */
|
||
.footer{{text-align:center;padding:30px 0 10px;margin-top:20px;font-size:12px;color:#484f58;}}
|
||
.footer a{{color:#79c0ff;text-decoration:none;}}
|
||
.footer a:hover{{text-decoration:underline;}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>InsightRadar · 热点快报</h1>
|
||
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
|
||
<span class="mode-badge {mode_badge_class}">{mode_label}</span>
|
||
</div>
|
||
|
||
{event_cards_html}
|
||
|
||
<div class="footer">
|
||
<p>此邮件由 InsightRadar 自动推送。</p>
|
||
<p>如需调整推送设置,请登录 <a href="{app_url}">InsightRadar 控制台</a></p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
EVENT_CARD_TEMPLATE = """\
|
||
<div class="event-card{hot_class}">
|
||
<div class="event-meta">
|
||
<span class="badge {badge_class}">{hot_label} {hot_score}</span>
|
||
{tags_html}
|
||
</div>
|
||
<div class="event-title">{title}</div>
|
||
{summary_html}
|
||
{platforms_html}
|
||
{match_html}
|
||
</div>
|
||
"""
|
||
|
||
|
||
def _hot_level(score: int) -> tuple[str, str, str]:
|
||
"""返回 (label, badge_class, hot_class)"""
|
||
if score >= 10:
|
||
return "全网沸腾", "badge-hot", " is-hot"
|
||
if score >= 5:
|
||
return "高度关注", "badge-warm", ""
|
||
if score >= 3:
|
||
return "上升中", "badge-normal", ""
|
||
return "一般关注", "badge-tag", ""
|
||
|
||
|
||
def _get_event_summary(ev) -> str:
|
||
"""
|
||
兼容 ORM 字段名(ai_comprehensive_summary)和 schema 字段名(summary)。
|
||
"""
|
||
return (
|
||
getattr(ev, "summary", None)
|
||
or getattr(ev, "ai_comprehensive_summary", None)
|
||
or ""
|
||
)
|
||
|
||
|
||
def _build_platforms_html(platform_list: list[dict]) -> str:
|
||
"""
|
||
将平台数据列表渲染为 HTML。
|
||
每条包含:emoji 图标 + 来源名 + 排名徽章 + 可点击标题链接。
|
||
"""
|
||
if not platform_list:
|
||
return ""
|
||
|
||
rows = []
|
||
seen_sources: set[str] = set()
|
||
for p in platform_list[:8]:
|
||
source_name = p.get("source_name", "未知")
|
||
# 同一来源只显示第一条(通常是排名最靠前的那条)
|
||
if source_name in seen_sources:
|
||
continue
|
||
seen_sources.add(source_name)
|
||
|
||
headline = p.get("headline", "")
|
||
url = p.get("url", "")
|
||
ranking = p.get("ranking")
|
||
emoji = PLATFORM_EMOJI.get(source_name, "🔗")
|
||
|
||
rank_html = ""
|
||
if ranking:
|
||
rank_html = f'<span class="platform-rank">TOP {ranking}</span>'
|
||
|
||
if url:
|
||
title_html = (
|
||
f'<a href="{url}" class="platform-link">{headline}</a>'
|
||
)
|
||
else:
|
||
title_html = f'<span class="platform-text">{headline}</span>'
|
||
|
||
rows.append(
|
||
f'<li class="platform-item">'
|
||
f'<div class="platform-source">{emoji} {source_name}{rank_html}</div>'
|
||
f'{title_html}'
|
||
f'</li>'
|
||
)
|
||
|
||
if not rows:
|
||
return ""
|
||
return '<div class="platforms-list-wrapper"><ul class="platforms-list">' + "".join(rows) + "</ul></div>"
|
||
|
||
|
||
def build_digest_html(
|
||
items: list,
|
||
delivery_time_str: str,
|
||
platforms_map: dict[int, list[dict]] | None = None,
|
||
app_url: str = "http://localhost:5173",
|
||
is_default_push: bool = False,
|
||
) -> str:
|
||
"""
|
||
根据事件列表生成推送邮件 HTML 正文。
|
||
items 元素可以是 MatchedEventResult 或 _DefaultEventItem,
|
||
二者均有 .event / .tags / .exact_hits / .semantic_hits / .match_score 属性。
|
||
platforms_map: event_id → [{source_name, headline, url, ranking}]
|
||
"""
|
||
if platforms_map is None:
|
||
platforms_map = {}
|
||
|
||
mode_label = "全网热点推送" if is_default_push else "个性化关键词匹配"
|
||
mode_badge_class = "mode-default" if is_default_push else "mode-keyword"
|
||
|
||
cards = []
|
||
for item in items:
|
||
ev = item.event
|
||
hot_label, badge_class, hot_class = _hot_level(ev.hot_score)
|
||
|
||
tags_html = "".join(
|
||
f'<span class="badge badge-tag">{t}</span>'
|
||
for t in item.tags[:4]
|
||
)
|
||
|
||
summary_text = _get_event_summary(ev)
|
||
summary_html = ""
|
||
if summary_text:
|
||
summary_html = (
|
||
f'<div class="summary"><strong>AI 洞察:</strong>{summary_text}</div>'
|
||
)
|
||
|
||
platform_list = platforms_map.get(ev.id, [])
|
||
platforms_html = _build_platforms_html(platform_list)
|
||
|
||
match_parts = []
|
||
# 仅个性化模式才显示匹配信息
|
||
if not getattr(item, "is_default", False):
|
||
for h in item.exact_hits[:3]:
|
||
match_parts.append(f'<span class="hit hit-exact">精确 {h}</span>')
|
||
for s in item.semantic_hits[:2]:
|
||
sim_pct = int(s.get("similarity", 0) * 100)
|
||
match_parts.append(
|
||
f'<span class="hit hit-semantic">语义 {s.get("topic_keyword", "")} {sim_pct}%</span>'
|
||
)
|
||
match_html = ""
|
||
if match_parts:
|
||
match_html = (
|
||
f'<div class="match-info">匹配度 {item.match_score:.0f} · '
|
||
+ " ".join(match_parts)
|
||
+ "</div>"
|
||
)
|
||
|
||
cards.append(
|
||
EVENT_CARD_TEMPLATE.format(
|
||
hot_class=hot_class,
|
||
badge_class=badge_class,
|
||
hot_label=hot_label,
|
||
hot_score=ev.hot_score,
|
||
tags_html=tags_html,
|
||
title=ev.unified_title,
|
||
summary_html=summary_html,
|
||
platforms_html=platforms_html,
|
||
match_html=match_html,
|
||
)
|
||
)
|
||
|
||
return DIGEST_HTML_TEMPLATE.format(
|
||
delivery_time=delivery_time_str,
|
||
event_count=len(items),
|
||
event_cards_html="\n".join(cards),
|
||
app_url=app_url,
|
||
mode_label=mode_label,
|
||
mode_badge_class=mode_badge_class,
|
||
)
|