big update

This commit is contained in:
stardrophere
2026-03-11 20:52:58 +08:00
parent 8ed819a580
commit 966bcfbba4
44 changed files with 7124 additions and 650 deletions
@@ -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,
)