| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698 |
- # coding=utf-8
- """
- HTML 报告渲染模块
- 提供 HTML 格式的热点新闻报告生成功能
- """
- from datetime import datetime
- from typing import Any, Dict, List, Optional, Callable
- from trendradar.report.helpers import html_escape
- from trendradar.utils.time import convert_time_for_display
- from trendradar.ai.formatter import render_ai_analysis_html_rich
- def render_html_content(
- report_data: Dict,
- total_titles: int,
- mode: str = "daily",
- update_info: Optional[Dict] = None,
- *,
- region_order: Optional[List[str]] = None,
- get_time_func: Optional[Callable[[], datetime]] = None,
- rss_items: Optional[List[Dict]] = None,
- rss_new_items: Optional[List[Dict]] = None,
- display_mode: str = "keyword",
- standalone_data: Optional[Dict] = None,
- ai_analysis: Optional[Any] = None,
- show_new_section: bool = True,
- ) -> str:
- """渲染HTML内容
- Args:
- report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
- total_titles: 新闻总数
- mode: 报告模式 ("daily", "current", "incremental")
- update_info: 更新信息(可选)
- region_order: 区域显示顺序列表
- get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now)
- rss_items: RSS 统计条目列表(可选)
- rss_new_items: RSS 新增条目列表(可选)
- display_mode: 显示模式 ("keyword"=按关键词分组, "platform"=按平台分组)
- standalone_data: 独立展示区数据(可选),包含 platforms 和 rss_feeds
- ai_analysis: AI 分析结果对象(可选),AIAnalysisResult 实例
- show_new_section: 是否显示新增热点区域
- Returns:
- 渲染后的 HTML 字符串
- """
- # 默认区域顺序
- default_region_order = ["hotlist", "rss", "new_items", "standalone", "ai_analysis"]
- if region_order is None:
- region_order = default_region_order
- html = """
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>热点新闻分析</title>
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js" integrity="sha512-BNaRQnYJYiPSqHHDb58B0yaPfCu+Wgds8Gp/gU33kqBtgNS4tSPHuGibyoeqMV/TJlSKda6FXzoEyYGjTe+vXA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
- <style>
- * { box-sizing: border-box; }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
- margin: 0;
- padding: 16px;
- background: #fafafa;
- color: #333;
- line-height: 1.5;
- }
- .container {
- max-width: 600px;
- margin: 0 auto;
- background: white;
- border-radius: 12px;
- overflow: hidden;
- box-shadow: 0 2px 16px rgba(0,0,0,0.06);
- }
- .header {
- background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
- color: white;
- padding: 32px 24px;
- text-align: center;
- position: relative;
- }
- .save-buttons {
- position: absolute;
- top: 16px;
- right: 16px;
- display: flex;
- gap: 8px;
- }
- .save-btn {
- background: rgba(255, 255, 255, 0.2);
- border: 1px solid rgba(255, 255, 255, 0.3);
- color: white;
- padding: 8px 16px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 13px;
- font-weight: 500;
- transition: all 0.2s ease;
- backdrop-filter: blur(10px);
- white-space: nowrap;
- }
- .save-btn:hover {
- background: rgba(255, 255, 255, 0.3);
- border-color: rgba(255, 255, 255, 0.5);
- transform: translateY(-1px);
- }
- .save-btn:active {
- transform: translateY(0);
- }
- .save-btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
- .header-title {
- font-size: 22px;
- font-weight: 700;
- margin: 0 0 20px 0;
- }
- .header-info {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px;
- font-size: 14px;
- opacity: 0.95;
- }
- .info-item {
- text-align: center;
- }
- .info-label {
- display: block;
- font-size: 12px;
- opacity: 0.8;
- margin-bottom: 4px;
- }
- .info-value {
- font-weight: 600;
- font-size: 16px;
- }
- .content {
- padding: 24px;
- }
- .word-group {
- margin-bottom: 40px;
- }
- .word-group:first-child {
- margin-top: 0;
- }
- .word-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- padding-bottom: 8px;
- border-bottom: 1px solid #f0f0f0;
- }
- .word-info {
- display: flex;
- align-items: center;
- gap: 12px;
- }
- .word-name {
- font-size: 17px;
- font-weight: 600;
- color: #1a1a1a;
- }
- .word-count {
- color: #666;
- font-size: 13px;
- font-weight: 500;
- }
- .word-count.hot { color: #dc2626; font-weight: 600; }
- .word-count.warm { color: #ea580c; font-weight: 600; }
- .word-index {
- color: #999;
- font-size: 12px;
- }
- .news-item {
- margin-bottom: 20px;
- padding: 16px 0;
- border-bottom: 1px solid #f5f5f5;
- position: relative;
- display: flex;
- gap: 12px;
- align-items: center;
- }
- .news-item:last-child {
- border-bottom: none;
- }
- .news-item.new::after {
- content: "NEW";
- position: absolute;
- top: 12px;
- right: 0;
- background: #fbbf24;
- color: #92400e;
- font-size: 9px;
- font-weight: 700;
- padding: 3px 6px;
- border-radius: 4px;
- letter-spacing: 0.5px;
- }
- .news-number {
- color: #999;
- font-size: 13px;
- font-weight: 600;
- min-width: 20px;
- text-align: center;
- flex-shrink: 0;
- background: #f8f9fa;
- border-radius: 50%;
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- align-self: flex-start;
- margin-top: 8px;
- }
- .news-content {
- flex: 1;
- min-width: 0;
- padding-right: 40px;
- }
- .news-item.new .news-content {
- padding-right: 50px;
- }
- .news-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 8px;
- flex-wrap: wrap;
- }
- .source-name {
- color: #666;
- font-size: 12px;
- font-weight: 500;
- }
- .keyword-tag {
- color: #2563eb;
- font-size: 12px;
- font-weight: 500;
- background: #eff6ff;
- padding: 2px 6px;
- border-radius: 4px;
- }
- .rank-num {
- color: #fff;
- background: #6b7280;
- font-size: 10px;
- font-weight: 700;
- padding: 2px 6px;
- border-radius: 10px;
- min-width: 18px;
- text-align: center;
- }
- .rank-num.top { background: #dc2626; }
- .rank-num.high { background: #ea580c; }
- .time-info {
- color: #999;
- font-size: 11px;
- }
- .count-info {
- color: #059669;
- font-size: 11px;
- font-weight: 500;
- }
- .news-title {
- font-size: 15px;
- line-height: 1.4;
- color: #1a1a1a;
- margin: 0;
- }
- .news-link {
- color: #2563eb;
- text-decoration: none;
- }
- .news-link:hover {
- text-decoration: underline;
- }
- .news-link:visited {
- color: #7c3aed;
- }
- /* 通用区域分割线样式 */
- .section-divider {
- margin-top: 32px;
- padding-top: 24px;
- border-top: 2px solid #e5e7eb;
- }
- /* 热榜统计区样式 */
- .hotlist-section {
- /* 默认无边框,由 section-divider 动态添加 */
- }
- .new-section {
- margin-top: 40px;
- padding-top: 24px;
- }
- .new-section-title {
- color: #1a1a1a;
- font-size: 16px;
- font-weight: 600;
- margin: 0 0 20px 0;
- }
- .new-source-group {
- margin-bottom: 24px;
- }
- .new-source-title {
- color: #666;
- font-size: 13px;
- font-weight: 500;
- margin: 0 0 12px 0;
- padding-bottom: 6px;
- border-bottom: 1px solid #f5f5f5;
- }
- .new-item {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 8px 0;
- border-bottom: 1px solid #f9f9f9;
- }
- .new-item:last-child {
- border-bottom: none;
- }
- .new-item-number {
- color: #999;
- font-size: 12px;
- font-weight: 600;
- min-width: 18px;
- text-align: center;
- flex-shrink: 0;
- background: #f8f9fa;
- border-radius: 50%;
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .new-item-rank {
- color: #fff;
- background: #6b7280;
- font-size: 10px;
- font-weight: 700;
- padding: 3px 6px;
- border-radius: 8px;
- min-width: 20px;
- text-align: center;
- flex-shrink: 0;
- }
- .new-item-rank.top { background: #dc2626; }
- .new-item-rank.high { background: #ea580c; }
- .new-item-content {
- flex: 1;
- min-width: 0;
- }
- .new-item-title {
- font-size: 14px;
- line-height: 1.4;
- color: #1a1a1a;
- margin: 0;
- }
- .error-section {
- background: #fef2f2;
- border: 1px solid #fecaca;
- border-radius: 8px;
- padding: 16px;
- margin-bottom: 24px;
- }
- .error-title {
- color: #dc2626;
- font-size: 14px;
- font-weight: 600;
- margin: 0 0 8px 0;
- }
- .error-list {
- list-style: none;
- padding: 0;
- margin: 0;
- }
- .error-item {
- color: #991b1b;
- font-size: 13px;
- padding: 2px 0;
- font-family: 'SF Mono', Consolas, monospace;
- }
- .footer {
- margin-top: 32px;
- padding: 20px 24px;
- background: #f8f9fa;
- border-top: 1px solid #e5e7eb;
- text-align: center;
- }
- .footer-content {
- font-size: 13px;
- color: #6b7280;
- line-height: 1.6;
- }
- .footer-link {
- color: #4f46e5;
- text-decoration: none;
- font-weight: 500;
- transition: color 0.2s ease;
- }
- .footer-link:hover {
- color: #7c3aed;
- text-decoration: underline;
- }
- .project-name {
- font-weight: 600;
- color: #374151;
- }
- @media (max-width: 480px) {
- body { padding: 12px; }
- .header { padding: 24px 20px; }
- .content { padding: 20px; }
- .footer { padding: 16px 20px; }
- .header-info { grid-template-columns: 1fr; gap: 12px; }
- .news-header { gap: 6px; }
- .news-content { padding-right: 45px; }
- .news-item { gap: 8px; }
- .new-item { gap: 8px; }
- .news-number { width: 20px; height: 20px; font-size: 12px; }
- .save-buttons {
- position: static;
- margin-bottom: 16px;
- display: flex;
- gap: 8px;
- justify-content: center;
- flex-direction: column;
- width: 100%;
- }
- .save-btn {
- width: 100%;
- }
- }
- /* RSS 订阅内容样式 */
- .rss-section {
- margin-top: 32px;
- padding-top: 24px;
- }
- .rss-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- }
- .rss-section-title {
- font-size: 18px;
- font-weight: 600;
- color: #059669;
- }
- .rss-section-count {
- color: #6b7280;
- font-size: 14px;
- }
- .feed-group {
- margin-bottom: 24px;
- }
- .feed-group:last-child {
- margin-bottom: 0;
- }
- .feed-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
- padding-bottom: 8px;
- border-bottom: 2px solid #10b981;
- }
- .feed-name {
- font-size: 15px;
- font-weight: 600;
- color: #059669;
- }
- .feed-count {
- color: #666;
- font-size: 13px;
- font-weight: 500;
- }
- .rss-item {
- margin-bottom: 12px;
- padding: 14px;
- background: #f0fdf4;
- border-radius: 8px;
- border-left: 3px solid #10b981;
- }
- .rss-item:last-child {
- margin-bottom: 0;
- }
- .rss-meta {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 6px;
- flex-wrap: wrap;
- }
- .rss-time {
- color: #6b7280;
- font-size: 12px;
- }
- .rss-author {
- color: #059669;
- font-size: 12px;
- font-weight: 500;
- }
- .rss-title {
- font-size: 14px;
- line-height: 1.5;
- margin-bottom: 6px;
- }
- .rss-link {
- color: #1f2937;
- text-decoration: none;
- font-weight: 500;
- }
- .rss-link:hover {
- color: #059669;
- text-decoration: underline;
- }
- .rss-summary {
- font-size: 13px;
- color: #6b7280;
- line-height: 1.5;
- margin: 0;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- /* 独立展示区样式 - 复用热点词汇统计区样式 */
- .standalone-section {
- margin-top: 32px;
- padding-top: 24px;
- }
- .standalone-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- }
- .standalone-section-title {
- font-size: 18px;
- font-weight: 600;
- color: #059669;
- }
- .standalone-section-count {
- color: #6b7280;
- font-size: 14px;
- }
- .standalone-group {
- margin-bottom: 40px;
- }
- .standalone-group:last-child {
- margin-bottom: 0;
- }
- .standalone-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- padding-bottom: 8px;
- border-bottom: 1px solid #f0f0f0;
- }
- .standalone-name {
- font-size: 17px;
- font-weight: 600;
- color: #1a1a1a;
- }
- .standalone-count {
- color: #666;
- font-size: 13px;
- font-weight: 500;
- }
- /* AI 分析区块样式 */
- .ai-section {
- margin-top: 32px;
- padding: 24px;
- background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
- border-radius: 12px;
- border: 1px solid #bae6fd;
- }
- .ai-section-header {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 20px;
- }
- .ai-section-title {
- font-size: 18px;
- font-weight: 600;
- color: #0369a1;
- }
- .ai-section-badge {
- background: #0ea5e9;
- color: white;
- font-size: 11px;
- font-weight: 600;
- padding: 3px 8px;
- border-radius: 4px;
- }
- .ai-block {
- margin-bottom: 16px;
- padding: 16px;
- background: white;
- border-radius: 8px;
- box-shadow: 0 1px 3px rgba(0,0,0,0.05);
- }
- .ai-block:last-child {
- margin-bottom: 0;
- }
- .ai-block-title {
- font-size: 14px;
- font-weight: 600;
- color: #0369a1;
- margin-bottom: 8px;
- }
- .ai-block-content {
- font-size: 14px;
- line-height: 1.6;
- color: #334155;
- white-space: pre-wrap;
- }
- .ai-error {
- padding: 16px;
- background: #fef2f2;
- border: 1px solid #fecaca;
- border-radius: 8px;
- color: #991b1b;
- font-size: 14px;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <div class="save-buttons">
- <button class="save-btn" onclick="saveAsImage()">保存为图片</button>
- <button class="save-btn" onclick="saveAsMultipleImages()">分段保存</button>
- </div>
- <div class="header-title">热点新闻分析</div>
- <div class="header-info">
- <div class="info-item">
- <span class="info-label">报告类型</span>
- <span class="info-value">"""
- # 处理报告类型显示(根据 mode 直接显示)
- if mode == "current":
- html += "当前榜单"
- elif mode == "incremental":
- html += "增量分析"
- else:
- html += "全天汇总"
- html += """</span>
- </div>
- <div class="info-item">
- <span class="info-label">新闻总数</span>
- <span class="info-value">"""
- html += f"{total_titles} 条"
- # 计算筛选后的热点新闻数量
- hot_news_count = sum(len(stat["titles"]) for stat in report_data["stats"])
- html += """</span>
- </div>
- <div class="info-item">
- <span class="info-label">热点新闻</span>
- <span class="info-value">"""
- html += f"{hot_news_count} 条"
- html += """</span>
- </div>
- <div class="info-item">
- <span class="info-label">生成时间</span>
- <span class="info-value">"""
- # 使用提供的时间函数或默认 datetime.now
- if get_time_func:
- now = get_time_func()
- else:
- now = datetime.now()
- html += now.strftime("%m-%d %H:%M")
- html += """</span>
- </div>
- </div>
- </div>
- <div class="content">"""
- # 处理失败ID错误信息
- if report_data["failed_ids"]:
- html += """
- <div class="error-section">
- <div class="error-title">⚠️ 请求失败的平台</div>
- <ul class="error-list">"""
- for id_value in report_data["failed_ids"]:
- html += f'<li class="error-item">{html_escape(id_value)}</li>'
- html += """
- </ul>
- </div>"""
- # 生成热点词汇统计部分的HTML
- stats_html = ""
- if report_data["stats"]:
- total_count = len(report_data["stats"])
- for i, stat in enumerate(report_data["stats"], 1):
- count = stat["count"]
- # 确定热度等级
- if count >= 10:
- count_class = "hot"
- elif count >= 5:
- count_class = "warm"
- else:
- count_class = ""
- escaped_word = html_escape(stat["word"])
- stats_html += f"""
- <div class="word-group">
- <div class="word-header">
- <div class="word-info">
- <div class="word-name">{escaped_word}</div>
- <div class="word-count {count_class}">{count} 条</div>
- </div>
- <div class="word-index">{i}/{total_count}</div>
- </div>"""
- # 处理每个词组下的新闻标题,给每条新闻标上序号
- for j, title_data in enumerate(stat["titles"], 1):
- is_new = title_data.get("is_new", False)
- new_class = "new" if is_new else ""
- stats_html += f"""
- <div class="news-item {new_class}">
- <div class="news-number">{j}</div>
- <div class="news-content">
- <div class="news-header">"""
- # 根据 display_mode 决定显示来源还是关键词
- if display_mode == "keyword":
- # keyword 模式:显示来源
- stats_html += f'<span class="source-name">{html_escape(title_data["source_name"])}</span>'
- else:
- # platform 模式:显示关键词
- matched_keyword = title_data.get("matched_keyword", "")
- if matched_keyword:
- stats_html += f'<span class="keyword-tag">[{html_escape(matched_keyword)}]</span>'
- # 处理排名显示
- ranks = title_data.get("ranks", [])
- if ranks:
- min_rank = min(ranks)
- max_rank = max(ranks)
- rank_threshold = title_data.get("rank_threshold", 10)
- # 确定排名等级
- if min_rank <= 3:
- rank_class = "top"
- elif min_rank <= rank_threshold:
- rank_class = "high"
- else:
- rank_class = ""
- if min_rank == max_rank:
- rank_text = str(min_rank)
- else:
- rank_text = f"{min_rank}-{max_rank}"
- stats_html += f'<span class="rank-num {rank_class}">{rank_text}</span>'
- # 处理时间显示
- time_display = title_data.get("time_display", "")
- if time_display:
- # 简化时间显示格式,将波浪线替换为~
- simplified_time = (
- time_display.replace(" ~ ", "~")
- .replace("[", "")
- .replace("]", "")
- )
- stats_html += (
- f'<span class="time-info">{html_escape(simplified_time)}</span>'
- )
- # 处理出现次数
- count_info = title_data.get("count", 1)
- if count_info > 1:
- stats_html += f'<span class="count-info">{count_info}次</span>'
- stats_html += """
- </div>
- <div class="news-title">"""
- # 处理标题和链接
- escaped_title = html_escape(title_data["title"])
- link_url = title_data.get("mobile_url") or title_data.get("url", "")
- if link_url:
- escaped_url = html_escape(link_url)
- stats_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
- else:
- stats_html += escaped_title
- stats_html += """
- </div>
- </div>
- </div>"""
- stats_html += """
- </div>"""
- # 给热榜统计添加外层包装
- if stats_html:
- stats_html = f"""
- <div class="hotlist-section">{stats_html}
- </div>"""
- # 生成新增新闻区域的HTML
- new_titles_html = ""
- if show_new_section and report_data["new_titles"]:
- new_titles_html += f"""
- <div class="new-section">
- <div class="new-section-title">本次新增热点 (共 {report_data['total_new_count']} 条)</div>"""
- for source_data in report_data["new_titles"]:
- escaped_source = html_escape(source_data["source_name"])
- titles_count = len(source_data["titles"])
- new_titles_html += f"""
- <div class="new-source-group">
- <div class="new-source-title">{escaped_source} · {titles_count}条</div>"""
- # 为新增新闻也添加序号
- for idx, title_data in enumerate(source_data["titles"], 1):
- ranks = title_data.get("ranks", [])
- # 处理新增新闻的排名显示
- rank_class = ""
- if ranks:
- min_rank = min(ranks)
- if min_rank <= 3:
- rank_class = "top"
- elif min_rank <= title_data.get("rank_threshold", 10):
- rank_class = "high"
- if len(ranks) == 1:
- rank_text = str(ranks[0])
- else:
- rank_text = f"{min(ranks)}-{max(ranks)}"
- else:
- rank_text = "?"
- new_titles_html += f"""
- <div class="new-item">
- <div class="new-item-number">{idx}</div>
- <div class="new-item-rank {rank_class}">{rank_text}</div>
- <div class="new-item-content">
- <div class="new-item-title">"""
- # 处理新增新闻的链接
- escaped_title = html_escape(title_data["title"])
- link_url = title_data.get("mobile_url") or title_data.get("url", "")
- if link_url:
- escaped_url = html_escape(link_url)
- new_titles_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
- else:
- new_titles_html += escaped_title
- new_titles_html += """
- </div>
- </div>
- </div>"""
- new_titles_html += """
- </div>"""
- new_titles_html += """
- </div>"""
- # 生成 RSS 统计内容
- def render_rss_stats_html(stats: List[Dict], title: str = "RSS 订阅更新") -> str:
- """渲染 RSS 统计区块 HTML
- Args:
- stats: RSS 分组统计列表,格式与热榜一致:
- [
- {
- "word": "关键词",
- "count": 5,
- "titles": [
- {
- "title": "标题",
- "source_name": "Feed 名称",
- "time_display": "12-29 08:20",
- "url": "...",
- "is_new": True/False
- }
- ]
- }
- ]
- title: 区块标题
- Returns:
- 渲染后的 HTML 字符串
- """
- if not stats:
- return ""
- # 计算总条目数
- total_count = sum(stat.get("count", 0) for stat in stats)
- if total_count == 0:
- return ""
- rss_html = f"""
- <div class="rss-section">
- <div class="rss-section-header">
- <div class="rss-section-title">{title}</div>
- <div class="rss-section-count">{total_count} 条</div>
- </div>"""
- # 按关键词分组渲染(与热榜格式一致)
- for stat in stats:
- keyword = stat.get("word", "")
- titles = stat.get("titles", [])
- if not titles:
- continue
- keyword_count = len(titles)
- rss_html += f"""
- <div class="feed-group">
- <div class="feed-header">
- <div class="feed-name">{html_escape(keyword)}</div>
- <div class="feed-count">{keyword_count} 条</div>
- </div>"""
- for title_data in titles:
- item_title = title_data.get("title", "")
- url = title_data.get("url", "")
- time_display = title_data.get("time_display", "")
- source_name = title_data.get("source_name", "")
- is_new = title_data.get("is_new", False)
- rss_html += """
- <div class="rss-item">
- <div class="rss-meta">"""
- if time_display:
- rss_html += f'<span class="rss-time">{html_escape(time_display)}</span>'
- if source_name:
- rss_html += f'<span class="rss-author">{html_escape(source_name)}</span>'
- if is_new:
- rss_html += '<span class="rss-author" style="color: #dc2626;">NEW</span>'
- rss_html += """
- </div>
- <div class="rss-title">"""
- escaped_title = html_escape(item_title)
- if url:
- escaped_url = html_escape(url)
- rss_html += f'<a href="{escaped_url}" target="_blank" class="rss-link">{escaped_title}</a>'
- else:
- rss_html += escaped_title
- rss_html += """
- </div>
- </div>"""
- rss_html += """
- </div>"""
- rss_html += """
- </div>"""
- return rss_html
- # 生成独立展示区内容
- def render_standalone_html(data: Optional[Dict]) -> str:
- """渲染独立展示区 HTML(复用热点词汇统计区样式)
- Args:
- data: 独立展示数据,格式:
- {
- "platforms": [
- {
- "id": "zhihu",
- "name": "知乎热榜",
- "items": [
- {
- "title": "标题",
- "url": "链接",
- "rank": 1,
- "ranks": [1, 2, 1],
- "first_time": "08:00",
- "last_time": "12:30",
- "count": 3,
- }
- ]
- }
- ],
- "rss_feeds": [
- {
- "id": "hacker-news",
- "name": "Hacker News",
- "items": [
- {
- "title": "标题",
- "url": "链接",
- "published_at": "2025-01-07T08:00:00",
- "author": "作者",
- }
- ]
- }
- ]
- }
- Returns:
- 渲染后的 HTML 字符串
- """
- if not data:
- return ""
- platforms = data.get("platforms", [])
- rss_feeds = data.get("rss_feeds", [])
- if not platforms and not rss_feeds:
- return ""
- # 计算总条目数
- total_platform_items = sum(len(p.get("items", [])) for p in platforms)
- total_rss_items = sum(len(f.get("items", [])) for f in rss_feeds)
- total_count = total_platform_items + total_rss_items
- if total_count == 0:
- return ""
- standalone_html = f"""
- <div class="standalone-section">
- <div class="standalone-section-header">
- <div class="standalone-section-title">独立展示区</div>
- <div class="standalone-section-count">{total_count} 条</div>
- </div>"""
- # 渲染热榜平台(复用 word-group 结构)
- for platform in platforms:
- platform_name = platform.get("name", platform.get("id", ""))
- items = platform.get("items", [])
- if not items:
- continue
- standalone_html += f"""
- <div class="standalone-group">
- <div class="standalone-header">
- <div class="standalone-name">{html_escape(platform_name)}</div>
- <div class="standalone-count">{len(items)} 条</div>
- </div>"""
- # 渲染每个条目(复用 news-item 结构)
- for j, item in enumerate(items, 1):
- title = item.get("title", "")
- url = item.get("url", "") or item.get("mobileUrl", "")
- rank = item.get("rank", 0)
- ranks = item.get("ranks", [])
- first_time = item.get("first_time", "")
- last_time = item.get("last_time", "")
- count = item.get("count", 1)
- standalone_html += f"""
- <div class="news-item">
- <div class="news-number">{j}</div>
- <div class="news-content">
- <div class="news-header">"""
- # 排名显示(复用 rank-num 样式,无 # 前缀)
- if ranks:
- min_rank = min(ranks)
- max_rank = max(ranks)
- # 确定排名等级
- if min_rank <= 3:
- rank_class = "top"
- elif min_rank <= 10:
- rank_class = "high"
- else:
- rank_class = ""
- if min_rank == max_rank:
- rank_text = str(min_rank)
- else:
- rank_text = f"{min_rank}-{max_rank}"
- standalone_html += f'<span class="rank-num {rank_class}">{rank_text}</span>'
- elif rank > 0:
- if rank <= 3:
- rank_class = "top"
- elif rank <= 10:
- rank_class = "high"
- else:
- rank_class = ""
- standalone_html += f'<span class="rank-num {rank_class}">{rank}</span>'
- # 时间显示(复用 time-info 样式,将 HH-MM 转换为 HH:MM)
- if first_time and last_time and first_time != last_time:
- first_time_display = convert_time_for_display(first_time)
- last_time_display = convert_time_for_display(last_time)
- standalone_html += f'<span class="time-info">{html_escape(first_time_display)}~{html_escape(last_time_display)}</span>'
- elif first_time:
- first_time_display = convert_time_for_display(first_time)
- standalone_html += f'<span class="time-info">{html_escape(first_time_display)}</span>'
- # 出现次数(复用 count-info 样式)
- if count > 1:
- standalone_html += f'<span class="count-info">{count}次</span>'
- standalone_html += """
- </div>
- <div class="news-title">"""
- # 标题和链接(复用 news-link 样式)
- escaped_title = html_escape(title)
- if url:
- escaped_url = html_escape(url)
- standalone_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
- else:
- standalone_html += escaped_title
- standalone_html += """
- </div>
- </div>
- </div>"""
- standalone_html += """
- </div>"""
- # 渲染 RSS 源(复用相同结构)
- for feed in rss_feeds:
- feed_name = feed.get("name", feed.get("id", ""))
- items = feed.get("items", [])
- if not items:
- continue
- standalone_html += f"""
- <div class="standalone-group">
- <div class="standalone-header">
- <div class="standalone-name">{html_escape(feed_name)}</div>
- <div class="standalone-count">{len(items)} 条</div>
- </div>"""
- for j, item in enumerate(items, 1):
- title = item.get("title", "")
- url = item.get("url", "")
- published_at = item.get("published_at", "")
- author = item.get("author", "")
- standalone_html += f"""
- <div class="news-item">
- <div class="news-number">{j}</div>
- <div class="news-content">
- <div class="news-header">"""
- # 时间显示(格式化 ISO 时间)
- if published_at:
- try:
- from datetime import datetime as dt
- if "T" in published_at:
- dt_obj = dt.fromisoformat(published_at.replace("Z", "+00:00"))
- time_display = dt_obj.strftime("%m-%d %H:%M")
- else:
- time_display = published_at
- except:
- time_display = published_at
- standalone_html += f'<span class="time-info">{html_escape(time_display)}</span>'
- # 作者显示
- if author:
- standalone_html += f'<span class="source-name">{html_escape(author)}</span>'
- standalone_html += """
- </div>
- <div class="news-title">"""
- escaped_title = html_escape(title)
- if url:
- escaped_url = html_escape(url)
- standalone_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
- else:
- standalone_html += escaped_title
- standalone_html += """
- </div>
- </div>
- </div>"""
- standalone_html += """
- </div>"""
- standalone_html += """
- </div>"""
- return standalone_html
- # 生成 RSS 统计和新增 HTML
- rss_stats_html = render_rss_stats_html(rss_items, "RSS 订阅更新") if rss_items else ""
- rss_new_html = render_rss_stats_html(rss_new_items, "RSS 新增更新") if rss_new_items else ""
- # 生成独立展示区 HTML
- standalone_html = render_standalone_html(standalone_data)
- # 生成 AI 分析 HTML
- ai_html = render_ai_analysis_html_rich(ai_analysis) if ai_analysis else ""
- # 准备各区域内容映射
- region_contents = {
- "hotlist": stats_html,
- "rss": rss_stats_html,
- "new_items": (new_titles_html, rss_new_html), # 元组,分别处理
- "standalone": standalone_html,
- "ai_analysis": ai_html,
- }
- def add_section_divider(content: str) -> str:
- """为内容的外层 div 添加 section-divider 类"""
- if not content or 'class="' not in content:
- return content
- first_class_pos = content.find('class="')
- if first_class_pos != -1:
- insert_pos = first_class_pos + len('class="')
- return content[:insert_pos] + "section-divider " + content[insert_pos:]
- return content
- # 按 region_order 顺序组装内容,动态添加分割线
- has_previous_content = False
- for region in region_order:
- content = region_contents.get(region, "")
- if region == "new_items":
- # 特殊处理 new_items 区域(包含热榜新增和 RSS 新增两部分)
- new_html, rss_new = content
- if new_html:
- if has_previous_content:
- new_html = add_section_divider(new_html)
- html += new_html
- has_previous_content = True
- if rss_new:
- if has_previous_content:
- rss_new = add_section_divider(rss_new)
- html += rss_new
- has_previous_content = True
- elif content:
- if has_previous_content:
- content = add_section_divider(content)
- html += content
- has_previous_content = True
- html += """
- </div>
- <div class="footer">
- <div class="footer-content">
- 由 <span class="project-name">TrendRadar</span> 生成 ·
- <a href="https://github.com/sansan0/TrendRadar" target="_blank" class="footer-link">
- GitHub 开源项目
- </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>
- <script>
- async function saveAsImage() {
- const button = event.target;
- const originalText = button.textContent;
- try {
- button.textContent = '生成中...';
- button.disabled = true;
- window.scrollTo(0, 0);
- // 等待页面稳定
- await new Promise(resolve => setTimeout(resolve, 200));
- // 截图前隐藏按钮
- const buttons = document.querySelector('.save-buttons');
- buttons.style.visibility = 'hidden';
- // 再次等待确保按钮完全隐藏
- await new Promise(resolve => setTimeout(resolve, 100));
- const container = document.querySelector('.container');
- const canvas = await html2canvas(container, {
- backgroundColor: '#ffffff',
- scale: 1.5,
- useCORS: true,
- allowTaint: false,
- imageTimeout: 10000,
- removeContainer: false,
- foreignObjectRendering: false,
- logging: false,
- width: container.offsetWidth,
- height: container.offsetHeight,
- x: 0,
- y: 0,
- scrollX: 0,
- scrollY: 0,
- windowWidth: window.innerWidth,
- windowHeight: window.innerHeight
- });
- buttons.style.visibility = 'visible';
- const link = document.createElement('a');
- const now = new Date();
- const filename = `TrendRadar_热点新闻分析_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}.png`;
- link.download = filename;
- link.href = canvas.toDataURL('image/png', 1.0);
- // 触发下载
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- button.textContent = '保存成功!';
- setTimeout(() => {
- button.textContent = originalText;
- button.disabled = false;
- }, 2000);
- } catch (error) {
- const buttons = document.querySelector('.save-buttons');
- buttons.style.visibility = 'visible';
- button.textContent = '保存失败';
- setTimeout(() => {
- button.textContent = originalText;
- button.disabled = false;
- }, 2000);
- }
- }
- async function saveAsMultipleImages() {
- const button = event.target;
- const originalText = button.textContent;
- const container = document.querySelector('.container');
- const scale = 1.5;
- const maxHeight = 5000 / scale;
- try {
- button.textContent = '分析中...';
- button.disabled = true;
- // 获取所有可能的分割元素
- const newsItems = Array.from(container.querySelectorAll('.news-item'));
- const wordGroups = Array.from(container.querySelectorAll('.word-group'));
- const newSection = container.querySelector('.new-section');
- const errorSection = container.querySelector('.error-section');
- const header = container.querySelector('.header');
- const footer = container.querySelector('.footer');
- // 计算元素位置和高度
- const containerRect = container.getBoundingClientRect();
- const elements = [];
- // 添加header作为必须包含的元素
- elements.push({
- type: 'header',
- element: header,
- top: 0,
- bottom: header.offsetHeight,
- height: header.offsetHeight
- });
- // 添加错误信息(如果存在)
- if (errorSection) {
- const rect = errorSection.getBoundingClientRect();
- elements.push({
- type: 'error',
- element: errorSection,
- top: rect.top - containerRect.top,
- bottom: rect.bottom - containerRect.top,
- height: rect.height
- });
- }
- // 按word-group分组处理news-item
- wordGroups.forEach(group => {
- const groupRect = group.getBoundingClientRect();
- const groupNewsItems = group.querySelectorAll('.news-item');
- // 添加word-group的header部分
- const wordHeader = group.querySelector('.word-header');
- if (wordHeader) {
- const headerRect = wordHeader.getBoundingClientRect();
- elements.push({
- type: 'word-header',
- element: wordHeader,
- parent: group,
- top: groupRect.top - containerRect.top,
- bottom: headerRect.bottom - containerRect.top,
- height: headerRect.height
- });
- }
- // 添加每个news-item
- groupNewsItems.forEach(item => {
- const rect = item.getBoundingClientRect();
- elements.push({
- type: 'news-item',
- element: item,
- parent: group,
- top: rect.top - containerRect.top,
- bottom: rect.bottom - containerRect.top,
- height: rect.height
- });
- });
- });
- // 添加新增新闻部分
- if (newSection) {
- const rect = newSection.getBoundingClientRect();
- elements.push({
- type: 'new-section',
- element: newSection,
- top: rect.top - containerRect.top,
- bottom: rect.bottom - containerRect.top,
- height: rect.height
- });
- }
- // 添加footer
- const footerRect = footer.getBoundingClientRect();
- elements.push({
- type: 'footer',
- element: footer,
- top: footerRect.top - containerRect.top,
- bottom: footerRect.bottom - containerRect.top,
- height: footer.offsetHeight
- });
- // 计算分割点
- const segments = [];
- let currentSegment = { start: 0, end: 0, height: 0, includeHeader: true };
- let headerHeight = header.offsetHeight;
- currentSegment.height = headerHeight;
- for (let i = 1; i < elements.length; i++) {
- const element = elements[i];
- const potentialHeight = element.bottom - currentSegment.start;
- // 检查是否需要创建新分段
- if (potentialHeight > maxHeight && currentSegment.height > headerHeight) {
- // 在前一个元素结束处分割
- currentSegment.end = elements[i - 1].bottom;
- segments.push(currentSegment);
- // 开始新分段
- currentSegment = {
- start: currentSegment.end,
- end: 0,
- height: element.bottom - currentSegment.end,
- includeHeader: false
- };
- } else {
- currentSegment.height = potentialHeight;
- currentSegment.end = element.bottom;
- }
- }
- // 添加最后一个分段
- if (currentSegment.height > 0) {
- currentSegment.end = container.offsetHeight;
- segments.push(currentSegment);
- }
- button.textContent = `生成中 (0/${segments.length})...`;
- // 隐藏保存按钮
- const buttons = document.querySelector('.save-buttons');
- buttons.style.visibility = 'hidden';
- // 为每个分段生成图片
- const images = [];
- for (let i = 0; i < segments.length; i++) {
- const segment = segments[i];
- button.textContent = `生成中 (${i + 1}/${segments.length})...`;
- // 创建临时容器用于截图
- const tempContainer = document.createElement('div');
- tempContainer.style.cssText = `
- position: absolute;
- left: -9999px;
- top: 0;
- width: ${container.offsetWidth}px;
- background: white;
- `;
- tempContainer.className = 'container';
- // 克隆容器内容
- const clonedContainer = container.cloneNode(true);
- // 移除克隆内容中的保存按钮
- const clonedButtons = clonedContainer.querySelector('.save-buttons');
- if (clonedButtons) {
- clonedButtons.style.display = 'none';
- }
- tempContainer.appendChild(clonedContainer);
- document.body.appendChild(tempContainer);
- // 等待DOM更新
- await new Promise(resolve => setTimeout(resolve, 100));
- // 使用html2canvas截取特定区域
- const canvas = await html2canvas(clonedContainer, {
- backgroundColor: '#ffffff',
- scale: scale,
- useCORS: true,
- allowTaint: false,
- imageTimeout: 10000,
- logging: false,
- width: container.offsetWidth,
- height: segment.end - segment.start,
- x: 0,
- y: segment.start,
- windowWidth: window.innerWidth,
- windowHeight: window.innerHeight
- });
- images.push(canvas.toDataURL('image/png', 1.0));
- // 清理临时容器
- document.body.removeChild(tempContainer);
- }
- // 恢复按钮显示
- buttons.style.visibility = 'visible';
- // 下载所有图片
- const now = new Date();
- const baseFilename = `TrendRadar_热点新闻分析_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
- for (let i = 0; i < images.length; i++) {
- const link = document.createElement('a');
- link.download = `${baseFilename}_part${i + 1}.png`;
- link.href = images[i];
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- // 延迟一下避免浏览器阻止多个下载
- await new Promise(resolve => setTimeout(resolve, 100));
- }
- button.textContent = `已保存 ${segments.length} 张图片!`;
- setTimeout(() => {
- button.textContent = originalText;
- button.disabled = false;
- }, 2000);
- } catch (error) {
- console.error('分段保存失败:', error);
- const buttons = document.querySelector('.save-buttons');
- buttons.style.visibility = 'visible';
- button.textContent = '保存失败';
- setTimeout(() => {
- button.textContent = originalText;
- button.disabled = false;
- }, 2000);
- }
- }
- document.addEventListener('DOMContentLoaded', function() {
- window.scrollTo(0, 0);
- });
- </script>
- </body>
- </html>
- """
- return html
|