html.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277
  1. # coding=utf-8
  2. """
  3. HTML 报告渲染模块
  4. 提供 HTML 格式的热点新闻报告生成功能
  5. """
  6. from datetime import datetime
  7. from typing import Dict, List, Optional, Callable
  8. from trendradar.report.helpers import html_escape
  9. def render_html_content(
  10. report_data: Dict,
  11. total_titles: int,
  12. is_daily_summary: bool = False,
  13. mode: str = "daily",
  14. update_info: Optional[Dict] = None,
  15. *,
  16. reverse_content_order: bool = False,
  17. get_time_func: Optional[Callable[[], datetime]] = None,
  18. rss_items: Optional[List[Dict]] = None,
  19. rss_new_items: Optional[List[Dict]] = None,
  20. display_mode: str = "keyword",
  21. ) -> str:
  22. """渲染HTML内容
  23. Args:
  24. report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
  25. total_titles: 新闻总数
  26. is_daily_summary: 是否为当日汇总
  27. mode: 报告模式 ("daily", "current", "incremental")
  28. update_info: 更新信息(可选)
  29. reverse_content_order: 是否反转内容顺序(新增热点在前)
  30. get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now)
  31. rss_items: RSS 统计条目列表(可选)
  32. rss_new_items: RSS 新增条目列表(可选)
  33. display_mode: 显示模式 ("keyword"=按关键词分组, "platform"=按平台分组)
  34. Returns:
  35. 渲染后的 HTML 字符串
  36. """
  37. html = """
  38. <!DOCTYPE html>
  39. <html>
  40. <head>
  41. <meta charset="UTF-8">
  42. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  43. <title>热点新闻分析</title>
  44. <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>
  45. <style>
  46. * { box-sizing: border-box; }
  47. body {
  48. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  49. margin: 0;
  50. padding: 16px;
  51. background: #fafafa;
  52. color: #333;
  53. line-height: 1.5;
  54. }
  55. .container {
  56. max-width: 600px;
  57. margin: 0 auto;
  58. background: white;
  59. border-radius: 12px;
  60. overflow: hidden;
  61. box-shadow: 0 2px 16px rgba(0,0,0,0.06);
  62. }
  63. .header {
  64. background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
  65. color: white;
  66. padding: 32px 24px;
  67. text-align: center;
  68. position: relative;
  69. }
  70. .save-buttons {
  71. position: absolute;
  72. top: 16px;
  73. right: 16px;
  74. display: flex;
  75. gap: 8px;
  76. }
  77. .save-btn {
  78. background: rgba(255, 255, 255, 0.2);
  79. border: 1px solid rgba(255, 255, 255, 0.3);
  80. color: white;
  81. padding: 8px 16px;
  82. border-radius: 6px;
  83. cursor: pointer;
  84. font-size: 13px;
  85. font-weight: 500;
  86. transition: all 0.2s ease;
  87. backdrop-filter: blur(10px);
  88. white-space: nowrap;
  89. }
  90. .save-btn:hover {
  91. background: rgba(255, 255, 255, 0.3);
  92. border-color: rgba(255, 255, 255, 0.5);
  93. transform: translateY(-1px);
  94. }
  95. .save-btn:active {
  96. transform: translateY(0);
  97. }
  98. .save-btn:disabled {
  99. opacity: 0.6;
  100. cursor: not-allowed;
  101. }
  102. .header-title {
  103. font-size: 22px;
  104. font-weight: 700;
  105. margin: 0 0 20px 0;
  106. }
  107. .header-info {
  108. display: grid;
  109. grid-template-columns: 1fr 1fr;
  110. gap: 16px;
  111. font-size: 14px;
  112. opacity: 0.95;
  113. }
  114. .info-item {
  115. text-align: center;
  116. }
  117. .info-label {
  118. display: block;
  119. font-size: 12px;
  120. opacity: 0.8;
  121. margin-bottom: 4px;
  122. }
  123. .info-value {
  124. font-weight: 600;
  125. font-size: 16px;
  126. }
  127. .content {
  128. padding: 24px;
  129. }
  130. .word-group {
  131. margin-bottom: 40px;
  132. }
  133. .word-group:first-child {
  134. margin-top: 0;
  135. }
  136. .word-header {
  137. display: flex;
  138. align-items: center;
  139. justify-content: space-between;
  140. margin-bottom: 20px;
  141. padding-bottom: 8px;
  142. border-bottom: 1px solid #f0f0f0;
  143. }
  144. .word-info {
  145. display: flex;
  146. align-items: center;
  147. gap: 12px;
  148. }
  149. .word-name {
  150. font-size: 17px;
  151. font-weight: 600;
  152. color: #1a1a1a;
  153. }
  154. .word-count {
  155. color: #666;
  156. font-size: 13px;
  157. font-weight: 500;
  158. }
  159. .word-count.hot { color: #dc2626; font-weight: 600; }
  160. .word-count.warm { color: #ea580c; font-weight: 600; }
  161. .word-index {
  162. color: #999;
  163. font-size: 12px;
  164. }
  165. .news-item {
  166. margin-bottom: 20px;
  167. padding: 16px 0;
  168. border-bottom: 1px solid #f5f5f5;
  169. position: relative;
  170. display: flex;
  171. gap: 12px;
  172. align-items: center;
  173. }
  174. .news-item:last-child {
  175. border-bottom: none;
  176. }
  177. .news-item.new::after {
  178. content: "NEW";
  179. position: absolute;
  180. top: 12px;
  181. right: 0;
  182. background: #fbbf24;
  183. color: #92400e;
  184. font-size: 9px;
  185. font-weight: 700;
  186. padding: 3px 6px;
  187. border-radius: 4px;
  188. letter-spacing: 0.5px;
  189. }
  190. .news-number {
  191. color: #999;
  192. font-size: 13px;
  193. font-weight: 600;
  194. min-width: 20px;
  195. text-align: center;
  196. flex-shrink: 0;
  197. background: #f8f9fa;
  198. border-radius: 50%;
  199. width: 24px;
  200. height: 24px;
  201. display: flex;
  202. align-items: center;
  203. justify-content: center;
  204. align-self: flex-start;
  205. margin-top: 8px;
  206. }
  207. .news-content {
  208. flex: 1;
  209. min-width: 0;
  210. padding-right: 40px;
  211. }
  212. .news-item.new .news-content {
  213. padding-right: 50px;
  214. }
  215. .news-header {
  216. display: flex;
  217. align-items: center;
  218. gap: 8px;
  219. margin-bottom: 8px;
  220. flex-wrap: wrap;
  221. }
  222. .source-name {
  223. color: #666;
  224. font-size: 12px;
  225. font-weight: 500;
  226. }
  227. .keyword-tag {
  228. color: #2563eb;
  229. font-size: 12px;
  230. font-weight: 500;
  231. background: #eff6ff;
  232. padding: 2px 6px;
  233. border-radius: 4px;
  234. }
  235. .rank-num {
  236. color: #fff;
  237. background: #6b7280;
  238. font-size: 10px;
  239. font-weight: 700;
  240. padding: 2px 6px;
  241. border-radius: 10px;
  242. min-width: 18px;
  243. text-align: center;
  244. }
  245. .rank-num.top { background: #dc2626; }
  246. .rank-num.high { background: #ea580c; }
  247. .time-info {
  248. color: #999;
  249. font-size: 11px;
  250. }
  251. .count-info {
  252. color: #059669;
  253. font-size: 11px;
  254. font-weight: 500;
  255. }
  256. .news-title {
  257. font-size: 15px;
  258. line-height: 1.4;
  259. color: #1a1a1a;
  260. margin: 0;
  261. }
  262. .news-link {
  263. color: #2563eb;
  264. text-decoration: none;
  265. }
  266. .news-link:hover {
  267. text-decoration: underline;
  268. }
  269. .news-link:visited {
  270. color: #7c3aed;
  271. }
  272. .new-section {
  273. margin-top: 40px;
  274. padding-top: 24px;
  275. border-top: 2px solid #f0f0f0;
  276. }
  277. .new-section-title {
  278. color: #1a1a1a;
  279. font-size: 16px;
  280. font-weight: 600;
  281. margin: 0 0 20px 0;
  282. }
  283. .new-source-group {
  284. margin-bottom: 24px;
  285. }
  286. .new-source-title {
  287. color: #666;
  288. font-size: 13px;
  289. font-weight: 500;
  290. margin: 0 0 12px 0;
  291. padding-bottom: 6px;
  292. border-bottom: 1px solid #f5f5f5;
  293. }
  294. .new-item {
  295. display: flex;
  296. align-items: center;
  297. gap: 12px;
  298. padding: 8px 0;
  299. border-bottom: 1px solid #f9f9f9;
  300. }
  301. .new-item:last-child {
  302. border-bottom: none;
  303. }
  304. .new-item-number {
  305. color: #999;
  306. font-size: 12px;
  307. font-weight: 600;
  308. min-width: 18px;
  309. text-align: center;
  310. flex-shrink: 0;
  311. background: #f8f9fa;
  312. border-radius: 50%;
  313. width: 20px;
  314. height: 20px;
  315. display: flex;
  316. align-items: center;
  317. justify-content: center;
  318. }
  319. .new-item-rank {
  320. color: #fff;
  321. background: #6b7280;
  322. font-size: 10px;
  323. font-weight: 700;
  324. padding: 3px 6px;
  325. border-radius: 8px;
  326. min-width: 20px;
  327. text-align: center;
  328. flex-shrink: 0;
  329. }
  330. .new-item-rank.top { background: #dc2626; }
  331. .new-item-rank.high { background: #ea580c; }
  332. .new-item-content {
  333. flex: 1;
  334. min-width: 0;
  335. }
  336. .new-item-title {
  337. font-size: 14px;
  338. line-height: 1.4;
  339. color: #1a1a1a;
  340. margin: 0;
  341. }
  342. .error-section {
  343. background: #fef2f2;
  344. border: 1px solid #fecaca;
  345. border-radius: 8px;
  346. padding: 16px;
  347. margin-bottom: 24px;
  348. }
  349. .error-title {
  350. color: #dc2626;
  351. font-size: 14px;
  352. font-weight: 600;
  353. margin: 0 0 8px 0;
  354. }
  355. .error-list {
  356. list-style: none;
  357. padding: 0;
  358. margin: 0;
  359. }
  360. .error-item {
  361. color: #991b1b;
  362. font-size: 13px;
  363. padding: 2px 0;
  364. font-family: 'SF Mono', Consolas, monospace;
  365. }
  366. .footer {
  367. margin-top: 32px;
  368. padding: 20px 24px;
  369. background: #f8f9fa;
  370. border-top: 1px solid #e5e7eb;
  371. text-align: center;
  372. }
  373. .footer-content {
  374. font-size: 13px;
  375. color: #6b7280;
  376. line-height: 1.6;
  377. }
  378. .footer-link {
  379. color: #4f46e5;
  380. text-decoration: none;
  381. font-weight: 500;
  382. transition: color 0.2s ease;
  383. }
  384. .footer-link:hover {
  385. color: #7c3aed;
  386. text-decoration: underline;
  387. }
  388. .project-name {
  389. font-weight: 600;
  390. color: #374151;
  391. }
  392. @media (max-width: 480px) {
  393. body { padding: 12px; }
  394. .header { padding: 24px 20px; }
  395. .content { padding: 20px; }
  396. .footer { padding: 16px 20px; }
  397. .header-info { grid-template-columns: 1fr; gap: 12px; }
  398. .news-header { gap: 6px; }
  399. .news-content { padding-right: 45px; }
  400. .news-item { gap: 8px; }
  401. .new-item { gap: 8px; }
  402. .news-number { width: 20px; height: 20px; font-size: 12px; }
  403. .save-buttons {
  404. position: static;
  405. margin-bottom: 16px;
  406. display: flex;
  407. gap: 8px;
  408. justify-content: center;
  409. flex-direction: column;
  410. width: 100%;
  411. }
  412. .save-btn {
  413. width: 100%;
  414. }
  415. }
  416. /* RSS 订阅内容样式 */
  417. .rss-section {
  418. margin-top: 32px;
  419. padding-top: 24px;
  420. border-top: 2px solid #e5e7eb;
  421. }
  422. .rss-section-header {
  423. display: flex;
  424. align-items: center;
  425. justify-content: space-between;
  426. margin-bottom: 20px;
  427. }
  428. .rss-section-title {
  429. font-size: 18px;
  430. font-weight: 600;
  431. color: #059669;
  432. }
  433. .rss-section-count {
  434. color: #6b7280;
  435. font-size: 14px;
  436. }
  437. .feed-group {
  438. margin-bottom: 24px;
  439. }
  440. .feed-group:last-child {
  441. margin-bottom: 0;
  442. }
  443. .feed-header {
  444. display: flex;
  445. align-items: center;
  446. justify-content: space-between;
  447. margin-bottom: 12px;
  448. padding-bottom: 8px;
  449. border-bottom: 2px solid #10b981;
  450. }
  451. .feed-name {
  452. font-size: 15px;
  453. font-weight: 600;
  454. color: #059669;
  455. }
  456. .feed-count {
  457. color: #666;
  458. font-size: 13px;
  459. font-weight: 500;
  460. }
  461. .rss-item {
  462. margin-bottom: 12px;
  463. padding: 14px;
  464. background: #f0fdf4;
  465. border-radius: 8px;
  466. border-left: 3px solid #10b981;
  467. }
  468. .rss-item:last-child {
  469. margin-bottom: 0;
  470. }
  471. .rss-meta {
  472. display: flex;
  473. align-items: center;
  474. gap: 12px;
  475. margin-bottom: 6px;
  476. flex-wrap: wrap;
  477. }
  478. .rss-time {
  479. color: #6b7280;
  480. font-size: 12px;
  481. }
  482. .rss-author {
  483. color: #059669;
  484. font-size: 12px;
  485. font-weight: 500;
  486. }
  487. .rss-title {
  488. font-size: 14px;
  489. line-height: 1.5;
  490. margin-bottom: 6px;
  491. }
  492. .rss-link {
  493. color: #1f2937;
  494. text-decoration: none;
  495. font-weight: 500;
  496. }
  497. .rss-link:hover {
  498. color: #059669;
  499. text-decoration: underline;
  500. }
  501. .rss-summary {
  502. font-size: 13px;
  503. color: #6b7280;
  504. line-height: 1.5;
  505. margin: 0;
  506. display: -webkit-box;
  507. -webkit-line-clamp: 2;
  508. -webkit-box-orient: vertical;
  509. overflow: hidden;
  510. }
  511. </style>
  512. </head>
  513. <body>
  514. <div class="container">
  515. <div class="header">
  516. <div class="save-buttons">
  517. <button class="save-btn" onclick="saveAsImage()">保存为图片</button>
  518. <button class="save-btn" onclick="saveAsMultipleImages()">分段保存</button>
  519. </div>
  520. <div class="header-title">热点新闻分析</div>
  521. <div class="header-info">
  522. <div class="info-item">
  523. <span class="info-label">报告类型</span>
  524. <span class="info-value">"""
  525. # 处理报告类型显示
  526. if is_daily_summary:
  527. if mode == "current":
  528. html += "当前榜单"
  529. elif mode == "incremental":
  530. html += "增量模式"
  531. else:
  532. html += "当日汇总"
  533. else:
  534. html += "实时分析"
  535. html += """</span>
  536. </div>
  537. <div class="info-item">
  538. <span class="info-label">新闻总数</span>
  539. <span class="info-value">"""
  540. html += f"{total_titles} 条"
  541. # 计算筛选后的热点新闻数量
  542. hot_news_count = sum(len(stat["titles"]) for stat in report_data["stats"])
  543. html += """</span>
  544. </div>
  545. <div class="info-item">
  546. <span class="info-label">热点新闻</span>
  547. <span class="info-value">"""
  548. html += f"{hot_news_count} 条"
  549. html += """</span>
  550. </div>
  551. <div class="info-item">
  552. <span class="info-label">生成时间</span>
  553. <span class="info-value">"""
  554. # 使用提供的时间函数或默认 datetime.now
  555. if get_time_func:
  556. now = get_time_func()
  557. else:
  558. now = datetime.now()
  559. html += now.strftime("%m-%d %H:%M")
  560. html += """</span>
  561. </div>
  562. </div>
  563. </div>
  564. <div class="content">"""
  565. # 处理失败ID错误信息
  566. if report_data["failed_ids"]:
  567. html += """
  568. <div class="error-section">
  569. <div class="error-title">⚠️ 请求失败的平台</div>
  570. <ul class="error-list">"""
  571. for id_value in report_data["failed_ids"]:
  572. html += f'<li class="error-item">{html_escape(id_value)}</li>'
  573. html += """
  574. </ul>
  575. </div>"""
  576. # 生成热点词汇统计部分的HTML
  577. stats_html = ""
  578. if report_data["stats"]:
  579. total_count = len(report_data["stats"])
  580. for i, stat in enumerate(report_data["stats"], 1):
  581. count = stat["count"]
  582. # 确定热度等级
  583. if count >= 10:
  584. count_class = "hot"
  585. elif count >= 5:
  586. count_class = "warm"
  587. else:
  588. count_class = ""
  589. escaped_word = html_escape(stat["word"])
  590. stats_html += f"""
  591. <div class="word-group">
  592. <div class="word-header">
  593. <div class="word-info">
  594. <div class="word-name">{escaped_word}</div>
  595. <div class="word-count {count_class}">{count} 条</div>
  596. </div>
  597. <div class="word-index">{i}/{total_count}</div>
  598. </div>"""
  599. # 处理每个词组下的新闻标题,给每条新闻标上序号
  600. for j, title_data in enumerate(stat["titles"], 1):
  601. is_new = title_data.get("is_new", False)
  602. new_class = "new" if is_new else ""
  603. stats_html += f"""
  604. <div class="news-item {new_class}">
  605. <div class="news-number">{j}</div>
  606. <div class="news-content">
  607. <div class="news-header">"""
  608. # 根据 display_mode 决定显示来源还是关键词
  609. if display_mode == "keyword":
  610. # keyword 模式:显示来源
  611. stats_html += f'<span class="source-name">{html_escape(title_data["source_name"])}</span>'
  612. else:
  613. # platform 模式:显示关键词
  614. matched_keyword = title_data.get("matched_keyword", "")
  615. if matched_keyword:
  616. stats_html += f'<span class="keyword-tag">[{html_escape(matched_keyword)}]</span>'
  617. # 处理排名显示
  618. ranks = title_data.get("ranks", [])
  619. if ranks:
  620. min_rank = min(ranks)
  621. max_rank = max(ranks)
  622. rank_threshold = title_data.get("rank_threshold", 10)
  623. # 确定排名等级
  624. if min_rank <= 3:
  625. rank_class = "top"
  626. elif min_rank <= rank_threshold:
  627. rank_class = "high"
  628. else:
  629. rank_class = ""
  630. if min_rank == max_rank:
  631. rank_text = str(min_rank)
  632. else:
  633. rank_text = f"{min_rank}-{max_rank}"
  634. stats_html += f'<span class="rank-num {rank_class}">{rank_text}</span>'
  635. # 处理时间显示
  636. time_display = title_data.get("time_display", "")
  637. if time_display:
  638. # 简化时间显示格式,将波浪线替换为~
  639. simplified_time = (
  640. time_display.replace(" ~ ", "~")
  641. .replace("[", "")
  642. .replace("]", "")
  643. )
  644. stats_html += (
  645. f'<span class="time-info">{html_escape(simplified_time)}</span>'
  646. )
  647. # 处理出现次数
  648. count_info = title_data.get("count", 1)
  649. if count_info > 1:
  650. stats_html += f'<span class="count-info">{count_info}次</span>'
  651. stats_html += """
  652. </div>
  653. <div class="news-title">"""
  654. # 处理标题和链接
  655. escaped_title = html_escape(title_data["title"])
  656. link_url = title_data.get("mobile_url") or title_data.get("url", "")
  657. if link_url:
  658. escaped_url = html_escape(link_url)
  659. stats_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
  660. else:
  661. stats_html += escaped_title
  662. stats_html += """
  663. </div>
  664. </div>
  665. </div>"""
  666. stats_html += """
  667. </div>"""
  668. # 生成新增新闻区域的HTML
  669. new_titles_html = ""
  670. if report_data["new_titles"]:
  671. new_titles_html += f"""
  672. <div class="new-section">
  673. <div class="new-section-title">本次新增热点 (共 {report_data['total_new_count']} 条)</div>"""
  674. for source_data in report_data["new_titles"]:
  675. escaped_source = html_escape(source_data["source_name"])
  676. titles_count = len(source_data["titles"])
  677. new_titles_html += f"""
  678. <div class="new-source-group">
  679. <div class="new-source-title">{escaped_source} · {titles_count}条</div>"""
  680. # 为新增新闻也添加序号
  681. for idx, title_data in enumerate(source_data["titles"], 1):
  682. ranks = title_data.get("ranks", [])
  683. # 处理新增新闻的排名显示
  684. rank_class = ""
  685. if ranks:
  686. min_rank = min(ranks)
  687. if min_rank <= 3:
  688. rank_class = "top"
  689. elif min_rank <= title_data.get("rank_threshold", 10):
  690. rank_class = "high"
  691. if len(ranks) == 1:
  692. rank_text = str(ranks[0])
  693. else:
  694. rank_text = f"{min(ranks)}-{max(ranks)}"
  695. else:
  696. rank_text = "?"
  697. new_titles_html += f"""
  698. <div class="new-item">
  699. <div class="new-item-number">{idx}</div>
  700. <div class="new-item-rank {rank_class}">{rank_text}</div>
  701. <div class="new-item-content">
  702. <div class="new-item-title">"""
  703. # 处理新增新闻的链接
  704. escaped_title = html_escape(title_data["title"])
  705. link_url = title_data.get("mobile_url") or title_data.get("url", "")
  706. if link_url:
  707. escaped_url = html_escape(link_url)
  708. new_titles_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
  709. else:
  710. new_titles_html += escaped_title
  711. new_titles_html += """
  712. </div>
  713. </div>
  714. </div>"""
  715. new_titles_html += """
  716. </div>"""
  717. new_titles_html += """
  718. </div>"""
  719. # 生成 RSS 统计内容
  720. def render_rss_stats_html(items: List[Dict], title: str = "RSS 订阅更新") -> str:
  721. if not items:
  722. return ""
  723. rss_html = ""
  724. rss_count = len(items)
  725. rss_html += f"""
  726. <div class="rss-section">
  727. <div class="rss-section-header">
  728. <div class="rss-section-title">{title}</div>
  729. <div class="rss-section-count">{rss_count} 条</div>
  730. </div>"""
  731. # 按 feed_id 分组
  732. feeds_grouped = {}
  733. for item in items:
  734. feed_id = item.get("feed_id", "unknown")
  735. if feed_id not in feeds_grouped:
  736. feeds_grouped[feed_id] = {
  737. "name": item.get("feed_name", feed_id),
  738. "items": []
  739. }
  740. feeds_grouped[feed_id]["items"].append(item)
  741. # 渲染每个 feed 分组
  742. for feed_id, feed_data in feeds_grouped.items():
  743. feed_name = feed_data["name"]
  744. feed_items = feed_data["items"]
  745. feed_item_count = len(feed_items)
  746. rss_html += f"""
  747. <div class="feed-group">
  748. <div class="feed-header">
  749. <div class="feed-name">{html_escape(feed_name)}</div>
  750. <div class="feed-count">{feed_item_count} 条</div>
  751. </div>"""
  752. for item in feed_items:
  753. item_title = item.get("title", "")
  754. url = item.get("url", "")
  755. published_at = item.get("published_at", "")
  756. author = item.get("author", "")
  757. # 格式化发布时间
  758. time_str = ""
  759. if published_at:
  760. if isinstance(published_at, datetime):
  761. time_str = published_at.strftime("%m-%d %H:%M")
  762. else:
  763. time_str = str(published_at)[:16] if len(str(published_at)) > 16 else str(published_at)
  764. rss_html += """
  765. <div class="rss-item">
  766. <div class="rss-meta">"""
  767. if time_str:
  768. rss_html += f'<span class="rss-time">{html_escape(time_str)}</span>'
  769. if author:
  770. rss_html += f'<span class="rss-author">by {html_escape(author)}</span>'
  771. rss_html += """
  772. </div>
  773. <div class="rss-title">"""
  774. escaped_title = html_escape(item_title)
  775. if url:
  776. escaped_url = html_escape(url)
  777. rss_html += f'<a href="{escaped_url}" target="_blank" class="rss-link">{escaped_title}</a>'
  778. else:
  779. rss_html += escaped_title
  780. rss_html += """
  781. </div>
  782. </div>"""
  783. rss_html += """
  784. </div>"""
  785. rss_html += """
  786. </div>"""
  787. return rss_html
  788. # 生成 RSS 统计和新增 HTML
  789. rss_stats_html = render_rss_stats_html(rss_items, "RSS 订阅更新") if rss_items else ""
  790. rss_new_html = render_rss_stats_html(rss_new_items, "RSS 新增更新") if rss_new_items else ""
  791. # 根据配置决定内容顺序(与推送逻辑一致)
  792. if reverse_content_order:
  793. # 新增在前,统计在后
  794. # 顺序:热榜新增 → RSS新增 → 热榜统计 → RSS统计
  795. html += new_titles_html + rss_new_html + stats_html + rss_stats_html
  796. else:
  797. # 默认:统计在前,新增在后
  798. # 顺序:热榜统计 → RSS统计 → 热榜新增 → RSS新增
  799. html += stats_html + rss_stats_html + new_titles_html + rss_new_html
  800. html += """
  801. </div>
  802. <div class="footer">
  803. <div class="footer-content">
  804. 由 <span class="project-name">TrendRadar</span> 生成 ·
  805. <a href="https://github.com/sansan0/TrendRadar" target="_blank" class="footer-link">
  806. GitHub 开源项目
  807. </a>"""
  808. if update_info:
  809. html += f"""
  810. <br>
  811. <span style="color: #ea580c; font-weight: 500;">
  812. 发现新版本 {update_info['remote_version']},当前版本 {update_info['current_version']}
  813. </span>"""
  814. html += """
  815. </div>
  816. </div>
  817. </div>
  818. <script>
  819. async function saveAsImage() {
  820. const button = event.target;
  821. const originalText = button.textContent;
  822. try {
  823. button.textContent = '生成中...';
  824. button.disabled = true;
  825. window.scrollTo(0, 0);
  826. // 等待页面稳定
  827. await new Promise(resolve => setTimeout(resolve, 200));
  828. // 截图前隐藏按钮
  829. const buttons = document.querySelector('.save-buttons');
  830. buttons.style.visibility = 'hidden';
  831. // 再次等待确保按钮完全隐藏
  832. await new Promise(resolve => setTimeout(resolve, 100));
  833. const container = document.querySelector('.container');
  834. const canvas = await html2canvas(container, {
  835. backgroundColor: '#ffffff',
  836. scale: 1.5,
  837. useCORS: true,
  838. allowTaint: false,
  839. imageTimeout: 10000,
  840. removeContainer: false,
  841. foreignObjectRendering: false,
  842. logging: false,
  843. width: container.offsetWidth,
  844. height: container.offsetHeight,
  845. x: 0,
  846. y: 0,
  847. scrollX: 0,
  848. scrollY: 0,
  849. windowWidth: window.innerWidth,
  850. windowHeight: window.innerHeight
  851. });
  852. buttons.style.visibility = 'visible';
  853. const link = document.createElement('a');
  854. const now = new Date();
  855. 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`;
  856. link.download = filename;
  857. link.href = canvas.toDataURL('image/png', 1.0);
  858. // 触发下载
  859. document.body.appendChild(link);
  860. link.click();
  861. document.body.removeChild(link);
  862. button.textContent = '保存成功!';
  863. setTimeout(() => {
  864. button.textContent = originalText;
  865. button.disabled = false;
  866. }, 2000);
  867. } catch (error) {
  868. const buttons = document.querySelector('.save-buttons');
  869. buttons.style.visibility = 'visible';
  870. button.textContent = '保存失败';
  871. setTimeout(() => {
  872. button.textContent = originalText;
  873. button.disabled = false;
  874. }, 2000);
  875. }
  876. }
  877. async function saveAsMultipleImages() {
  878. const button = event.target;
  879. const originalText = button.textContent;
  880. const container = document.querySelector('.container');
  881. const scale = 1.5;
  882. const maxHeight = 5000 / scale;
  883. try {
  884. button.textContent = '分析中...';
  885. button.disabled = true;
  886. // 获取所有可能的分割元素
  887. const newsItems = Array.from(container.querySelectorAll('.news-item'));
  888. const wordGroups = Array.from(container.querySelectorAll('.word-group'));
  889. const newSection = container.querySelector('.new-section');
  890. const errorSection = container.querySelector('.error-section');
  891. const header = container.querySelector('.header');
  892. const footer = container.querySelector('.footer');
  893. // 计算元素位置和高度
  894. const containerRect = container.getBoundingClientRect();
  895. const elements = [];
  896. // 添加header作为必须包含的元素
  897. elements.push({
  898. type: 'header',
  899. element: header,
  900. top: 0,
  901. bottom: header.offsetHeight,
  902. height: header.offsetHeight
  903. });
  904. // 添加错误信息(如果存在)
  905. if (errorSection) {
  906. const rect = errorSection.getBoundingClientRect();
  907. elements.push({
  908. type: 'error',
  909. element: errorSection,
  910. top: rect.top - containerRect.top,
  911. bottom: rect.bottom - containerRect.top,
  912. height: rect.height
  913. });
  914. }
  915. // 按word-group分组处理news-item
  916. wordGroups.forEach(group => {
  917. const groupRect = group.getBoundingClientRect();
  918. const groupNewsItems = group.querySelectorAll('.news-item');
  919. // 添加word-group的header部分
  920. const wordHeader = group.querySelector('.word-header');
  921. if (wordHeader) {
  922. const headerRect = wordHeader.getBoundingClientRect();
  923. elements.push({
  924. type: 'word-header',
  925. element: wordHeader,
  926. parent: group,
  927. top: groupRect.top - containerRect.top,
  928. bottom: headerRect.bottom - containerRect.top,
  929. height: headerRect.height
  930. });
  931. }
  932. // 添加每个news-item
  933. groupNewsItems.forEach(item => {
  934. const rect = item.getBoundingClientRect();
  935. elements.push({
  936. type: 'news-item',
  937. element: item,
  938. parent: group,
  939. top: rect.top - containerRect.top,
  940. bottom: rect.bottom - containerRect.top,
  941. height: rect.height
  942. });
  943. });
  944. });
  945. // 添加新增新闻部分
  946. if (newSection) {
  947. const rect = newSection.getBoundingClientRect();
  948. elements.push({
  949. type: 'new-section',
  950. element: newSection,
  951. top: rect.top - containerRect.top,
  952. bottom: rect.bottom - containerRect.top,
  953. height: rect.height
  954. });
  955. }
  956. // 添加footer
  957. const footerRect = footer.getBoundingClientRect();
  958. elements.push({
  959. type: 'footer',
  960. element: footer,
  961. top: footerRect.top - containerRect.top,
  962. bottom: footerRect.bottom - containerRect.top,
  963. height: footer.offsetHeight
  964. });
  965. // 计算分割点
  966. const segments = [];
  967. let currentSegment = { start: 0, end: 0, height: 0, includeHeader: true };
  968. let headerHeight = header.offsetHeight;
  969. currentSegment.height = headerHeight;
  970. for (let i = 1; i < elements.length; i++) {
  971. const element = elements[i];
  972. const potentialHeight = element.bottom - currentSegment.start;
  973. // 检查是否需要创建新分段
  974. if (potentialHeight > maxHeight && currentSegment.height > headerHeight) {
  975. // 在前一个元素结束处分割
  976. currentSegment.end = elements[i - 1].bottom;
  977. segments.push(currentSegment);
  978. // 开始新分段
  979. currentSegment = {
  980. start: currentSegment.end,
  981. end: 0,
  982. height: element.bottom - currentSegment.end,
  983. includeHeader: false
  984. };
  985. } else {
  986. currentSegment.height = potentialHeight;
  987. currentSegment.end = element.bottom;
  988. }
  989. }
  990. // 添加最后一个分段
  991. if (currentSegment.height > 0) {
  992. currentSegment.end = container.offsetHeight;
  993. segments.push(currentSegment);
  994. }
  995. button.textContent = `生成中 (0/${segments.length})...`;
  996. // 隐藏保存按钮
  997. const buttons = document.querySelector('.save-buttons');
  998. buttons.style.visibility = 'hidden';
  999. // 为每个分段生成图片
  1000. const images = [];
  1001. for (let i = 0; i < segments.length; i++) {
  1002. const segment = segments[i];
  1003. button.textContent = `生成中 (${i + 1}/${segments.length})...`;
  1004. // 创建临时容器用于截图
  1005. const tempContainer = document.createElement('div');
  1006. tempContainer.style.cssText = `
  1007. position: absolute;
  1008. left: -9999px;
  1009. top: 0;
  1010. width: ${container.offsetWidth}px;
  1011. background: white;
  1012. `;
  1013. tempContainer.className = 'container';
  1014. // 克隆容器内容
  1015. const clonedContainer = container.cloneNode(true);
  1016. // 移除克隆内容中的保存按钮
  1017. const clonedButtons = clonedContainer.querySelector('.save-buttons');
  1018. if (clonedButtons) {
  1019. clonedButtons.style.display = 'none';
  1020. }
  1021. tempContainer.appendChild(clonedContainer);
  1022. document.body.appendChild(tempContainer);
  1023. // 等待DOM更新
  1024. await new Promise(resolve => setTimeout(resolve, 100));
  1025. // 使用html2canvas截取特定区域
  1026. const canvas = await html2canvas(clonedContainer, {
  1027. backgroundColor: '#ffffff',
  1028. scale: scale,
  1029. useCORS: true,
  1030. allowTaint: false,
  1031. imageTimeout: 10000,
  1032. logging: false,
  1033. width: container.offsetWidth,
  1034. height: segment.end - segment.start,
  1035. x: 0,
  1036. y: segment.start,
  1037. windowWidth: window.innerWidth,
  1038. windowHeight: window.innerHeight
  1039. });
  1040. images.push(canvas.toDataURL('image/png', 1.0));
  1041. // 清理临时容器
  1042. document.body.removeChild(tempContainer);
  1043. }
  1044. // 恢复按钮显示
  1045. buttons.style.visibility = 'visible';
  1046. // 下载所有图片
  1047. const now = new Date();
  1048. 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')}`;
  1049. for (let i = 0; i < images.length; i++) {
  1050. const link = document.createElement('a');
  1051. link.download = `${baseFilename}_part${i + 1}.png`;
  1052. link.href = images[i];
  1053. document.body.appendChild(link);
  1054. link.click();
  1055. document.body.removeChild(link);
  1056. // 延迟一下避免浏览器阻止多个下载
  1057. await new Promise(resolve => setTimeout(resolve, 100));
  1058. }
  1059. button.textContent = `已保存 ${segments.length} 张图片!`;
  1060. setTimeout(() => {
  1061. button.textContent = originalText;
  1062. button.disabled = false;
  1063. }, 2000);
  1064. } catch (error) {
  1065. console.error('分段保存失败:', error);
  1066. const buttons = document.querySelector('.save-buttons');
  1067. buttons.style.visibility = 'visible';
  1068. button.textContent = '保存失败';
  1069. setTimeout(() => {
  1070. button.textContent = originalText;
  1071. button.disabled = false;
  1072. }, 2000);
  1073. }
  1074. }
  1075. document.addEventListener('DOMContentLoaded', function() {
  1076. window.scrollTo(0, 0);
  1077. });
  1078. </script>
  1079. </body>
  1080. </html>
  1081. """
  1082. return html