mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 00:00:05 +08:00
big update
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
# 推送邮件 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 >= 50:
|
||||
return "全网沸腾", "badge-hot", " is-hot"
|
||||
if score >= 20:
|
||||
return "高度关注", "badge-warm", ""
|
||||
if score >= 10:
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user