html.py 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698
  1. # coding=utf-8
  2. """
  3. HTML 报告渲染模块
  4. 提供 HTML 格式的热点新闻报告生成功能
  5. """
  6. from datetime import datetime
  7. from typing import Any, Dict, List, Optional, Callable
  8. from trendradar.report.helpers import html_escape
  9. from trendradar.utils.time import convert_time_for_display
  10. from trendradar.ai.formatter import render_ai_analysis_html_rich
  11. def render_html_content(
  12. report_data: Dict,
  13. total_titles: int,
  14. mode: str = "daily",
  15. update_info: Optional[Dict] = None,
  16. *,
  17. region_order: Optional[List[str]] = None,
  18. get_time_func: Optional[Callable[[], datetime]] = None,
  19. rss_items: Optional[List[Dict]] = None,
  20. rss_new_items: Optional[List[Dict]] = None,
  21. display_mode: str = "keyword",
  22. standalone_data: Optional[Dict] = None,
  23. ai_analysis: Optional[Any] = None,
  24. show_new_section: bool = True,
  25. ) -> str:
  26. """渲染HTML内容
  27. Args:
  28. report_data: 报告数据字典,包含 stats, new_titles, failed_ids, total_new_count
  29. total_titles: 新闻总数
  30. mode: 报告模式 ("daily", "current", "incremental")
  31. update_info: 更新信息(可选)
  32. region_order: 区域显示顺序列表
  33. get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now)
  34. rss_items: RSS 统计条目列表(可选)
  35. rss_new_items: RSS 新增条目列表(可选)
  36. display_mode: 显示模式 ("keyword"=按关键词分组, "platform"=按平台分组)
  37. standalone_data: 独立展示区数据(可选),包含 platforms 和 rss_feeds
  38. ai_analysis: AI 分析结果对象(可选),AIAnalysisResult 实例
  39. show_new_section: 是否显示新增热点区域
  40. Returns:
  41. 渲染后的 HTML 字符串
  42. """
  43. # 默认区域顺序
  44. default_region_order = ["hotlist", "rss", "new_items", "standalone", "ai_analysis"]
  45. if region_order is None:
  46. region_order = default_region_order
  47. html = """
  48. <!DOCTYPE html>
  49. <html>
  50. <head>
  51. <meta charset="UTF-8">
  52. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  53. <title>热点新闻分析</title>
  54. <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>
  55. <style>
  56. * { box-sizing: border-box; }
  57. body {
  58. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  59. margin: 0;
  60. padding: 16px;
  61. background: #fafafa;
  62. color: #333;
  63. line-height: 1.5;
  64. }
  65. .container {
  66. max-width: 600px;
  67. margin: 0 auto;
  68. background: white;
  69. border-radius: 12px;
  70. overflow: hidden;
  71. box-shadow: 0 2px 16px rgba(0,0,0,0.06);
  72. }
  73. .header {
  74. background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
  75. color: white;
  76. padding: 32px 24px;
  77. text-align: center;
  78. position: relative;
  79. }
  80. .save-buttons {
  81. position: absolute;
  82. top: 16px;
  83. right: 16px;
  84. display: flex;
  85. gap: 8px;
  86. }
  87. .save-btn {
  88. background: rgba(255, 255, 255, 0.2);
  89. border: 1px solid rgba(255, 255, 255, 0.3);
  90. color: white;
  91. padding: 8px 16px;
  92. border-radius: 6px;
  93. cursor: pointer;
  94. font-size: 13px;
  95. font-weight: 500;
  96. transition: all 0.2s ease;
  97. backdrop-filter: blur(10px);
  98. white-space: nowrap;
  99. }
  100. .save-btn:hover {
  101. background: rgba(255, 255, 255, 0.3);
  102. border-color: rgba(255, 255, 255, 0.5);
  103. transform: translateY(-1px);
  104. }
  105. .save-btn:active {
  106. transform: translateY(0);
  107. }
  108. .save-btn:disabled {
  109. opacity: 0.6;
  110. cursor: not-allowed;
  111. }
  112. .header-title {
  113. font-size: 22px;
  114. font-weight: 700;
  115. margin: 0 0 20px 0;
  116. }
  117. .header-info {
  118. display: grid;
  119. grid-template-columns: 1fr 1fr;
  120. gap: 16px;
  121. font-size: 14px;
  122. opacity: 0.95;
  123. }
  124. .info-item {
  125. text-align: center;
  126. }
  127. .info-label {
  128. display: block;
  129. font-size: 12px;
  130. opacity: 0.8;
  131. margin-bottom: 4px;
  132. }
  133. .info-value {
  134. font-weight: 600;
  135. font-size: 16px;
  136. }
  137. .content {
  138. padding: 24px;
  139. }
  140. .word-group {
  141. margin-bottom: 40px;
  142. }
  143. .word-group:first-child {
  144. margin-top: 0;
  145. }
  146. .word-header {
  147. display: flex;
  148. align-items: center;
  149. justify-content: space-between;
  150. margin-bottom: 20px;
  151. padding-bottom: 8px;
  152. border-bottom: 1px solid #f0f0f0;
  153. }
  154. .word-info {
  155. display: flex;
  156. align-items: center;
  157. gap: 12px;
  158. }
  159. .word-name {
  160. font-size: 17px;
  161. font-weight: 600;
  162. color: #1a1a1a;
  163. }
  164. .word-count {
  165. color: #666;
  166. font-size: 13px;
  167. font-weight: 500;
  168. }
  169. .word-count.hot { color: #dc2626; font-weight: 600; }
  170. .word-count.warm { color: #ea580c; font-weight: 600; }
  171. .word-index {
  172. color: #999;
  173. font-size: 12px;
  174. }
  175. .news-item {
  176. margin-bottom: 20px;
  177. padding: 16px 0;
  178. border-bottom: 1px solid #f5f5f5;
  179. position: relative;
  180. display: flex;
  181. gap: 12px;
  182. align-items: center;
  183. }
  184. .news-item:last-child {
  185. border-bottom: none;
  186. }
  187. .news-item.new::after {
  188. content: "NEW";
  189. position: absolute;
  190. top: 12px;
  191. right: 0;
  192. background: #fbbf24;
  193. color: #92400e;
  194. font-size: 9px;
  195. font-weight: 700;
  196. padding: 3px 6px;
  197. border-radius: 4px;
  198. letter-spacing: 0.5px;
  199. }
  200. .news-number {
  201. color: #999;
  202. font-size: 13px;
  203. font-weight: 600;
  204. min-width: 20px;
  205. text-align: center;
  206. flex-shrink: 0;
  207. background: #f8f9fa;
  208. border-radius: 50%;
  209. width: 24px;
  210. height: 24px;
  211. display: flex;
  212. align-items: center;
  213. justify-content: center;
  214. align-self: flex-start;
  215. margin-top: 8px;
  216. }
  217. .news-content {
  218. flex: 1;
  219. min-width: 0;
  220. padding-right: 40px;
  221. }
  222. .news-item.new .news-content {
  223. padding-right: 50px;
  224. }
  225. .news-header {
  226. display: flex;
  227. align-items: center;
  228. gap: 8px;
  229. margin-bottom: 8px;
  230. flex-wrap: wrap;
  231. }
  232. .source-name {
  233. color: #666;
  234. font-size: 12px;
  235. font-weight: 500;
  236. }
  237. .keyword-tag {
  238. color: #2563eb;
  239. font-size: 12px;
  240. font-weight: 500;
  241. background: #eff6ff;
  242. padding: 2px 6px;
  243. border-radius: 4px;
  244. }
  245. .rank-num {
  246. color: #fff;
  247. background: #6b7280;
  248. font-size: 10px;
  249. font-weight: 700;
  250. padding: 2px 6px;
  251. border-radius: 10px;
  252. min-width: 18px;
  253. text-align: center;
  254. }
  255. .rank-num.top { background: #dc2626; }
  256. .rank-num.high { background: #ea580c; }
  257. .time-info {
  258. color: #999;
  259. font-size: 11px;
  260. }
  261. .count-info {
  262. color: #059669;
  263. font-size: 11px;
  264. font-weight: 500;
  265. }
  266. .news-title {
  267. font-size: 15px;
  268. line-height: 1.4;
  269. color: #1a1a1a;
  270. margin: 0;
  271. }
  272. .news-link {
  273. color: #2563eb;
  274. text-decoration: none;
  275. }
  276. .news-link:hover {
  277. text-decoration: underline;
  278. }
  279. .news-link:visited {
  280. color: #7c3aed;
  281. }
  282. /* 通用区域分割线样式 */
  283. .section-divider {
  284. margin-top: 32px;
  285. padding-top: 24px;
  286. border-top: 2px solid #e5e7eb;
  287. }
  288. /* 热榜统计区样式 */
  289. .hotlist-section {
  290. /* 默认无边框,由 section-divider 动态添加 */
  291. }
  292. .new-section {
  293. margin-top: 40px;
  294. padding-top: 24px;
  295. }
  296. .new-section-title {
  297. color: #1a1a1a;
  298. font-size: 16px;
  299. font-weight: 600;
  300. margin: 0 0 20px 0;
  301. }
  302. .new-source-group {
  303. margin-bottom: 24px;
  304. }
  305. .new-source-title {
  306. color: #666;
  307. font-size: 13px;
  308. font-weight: 500;
  309. margin: 0 0 12px 0;
  310. padding-bottom: 6px;
  311. border-bottom: 1px solid #f5f5f5;
  312. }
  313. .new-item {
  314. display: flex;
  315. align-items: center;
  316. gap: 12px;
  317. padding: 8px 0;
  318. border-bottom: 1px solid #f9f9f9;
  319. }
  320. .new-item:last-child {
  321. border-bottom: none;
  322. }
  323. .new-item-number {
  324. color: #999;
  325. font-size: 12px;
  326. font-weight: 600;
  327. min-width: 18px;
  328. text-align: center;
  329. flex-shrink: 0;
  330. background: #f8f9fa;
  331. border-radius: 50%;
  332. width: 20px;
  333. height: 20px;
  334. display: flex;
  335. align-items: center;
  336. justify-content: center;
  337. }
  338. .new-item-rank {
  339. color: #fff;
  340. background: #6b7280;
  341. font-size: 10px;
  342. font-weight: 700;
  343. padding: 3px 6px;
  344. border-radius: 8px;
  345. min-width: 20px;
  346. text-align: center;
  347. flex-shrink: 0;
  348. }
  349. .new-item-rank.top { background: #dc2626; }
  350. .new-item-rank.high { background: #ea580c; }
  351. .new-item-content {
  352. flex: 1;
  353. min-width: 0;
  354. }
  355. .new-item-title {
  356. font-size: 14px;
  357. line-height: 1.4;
  358. color: #1a1a1a;
  359. margin: 0;
  360. }
  361. .error-section {
  362. background: #fef2f2;
  363. border: 1px solid #fecaca;
  364. border-radius: 8px;
  365. padding: 16px;
  366. margin-bottom: 24px;
  367. }
  368. .error-title {
  369. color: #dc2626;
  370. font-size: 14px;
  371. font-weight: 600;
  372. margin: 0 0 8px 0;
  373. }
  374. .error-list {
  375. list-style: none;
  376. padding: 0;
  377. margin: 0;
  378. }
  379. .error-item {
  380. color: #991b1b;
  381. font-size: 13px;
  382. padding: 2px 0;
  383. font-family: 'SF Mono', Consolas, monospace;
  384. }
  385. .footer {
  386. margin-top: 32px;
  387. padding: 20px 24px;
  388. background: #f8f9fa;
  389. border-top: 1px solid #e5e7eb;
  390. text-align: center;
  391. }
  392. .footer-content {
  393. font-size: 13px;
  394. color: #6b7280;
  395. line-height: 1.6;
  396. }
  397. .footer-link {
  398. color: #4f46e5;
  399. text-decoration: none;
  400. font-weight: 500;
  401. transition: color 0.2s ease;
  402. }
  403. .footer-link:hover {
  404. color: #7c3aed;
  405. text-decoration: underline;
  406. }
  407. .project-name {
  408. font-weight: 600;
  409. color: #374151;
  410. }
  411. @media (max-width: 480px) {
  412. body { padding: 12px; }
  413. .header { padding: 24px 20px; }
  414. .content { padding: 20px; }
  415. .footer { padding: 16px 20px; }
  416. .header-info { grid-template-columns: 1fr; gap: 12px; }
  417. .news-header { gap: 6px; }
  418. .news-content { padding-right: 45px; }
  419. .news-item { gap: 8px; }
  420. .new-item { gap: 8px; }
  421. .news-number { width: 20px; height: 20px; font-size: 12px; }
  422. .save-buttons {
  423. position: static;
  424. margin-bottom: 16px;
  425. display: flex;
  426. gap: 8px;
  427. justify-content: center;
  428. flex-direction: column;
  429. width: 100%;
  430. }
  431. .save-btn {
  432. width: 100%;
  433. }
  434. }
  435. /* RSS 订阅内容样式 */
  436. .rss-section {
  437. margin-top: 32px;
  438. padding-top: 24px;
  439. }
  440. .rss-section-header {
  441. display: flex;
  442. align-items: center;
  443. justify-content: space-between;
  444. margin-bottom: 20px;
  445. }
  446. .rss-section-title {
  447. font-size: 18px;
  448. font-weight: 600;
  449. color: #059669;
  450. }
  451. .rss-section-count {
  452. color: #6b7280;
  453. font-size: 14px;
  454. }
  455. .feed-group {
  456. margin-bottom: 24px;
  457. }
  458. .feed-group:last-child {
  459. margin-bottom: 0;
  460. }
  461. .feed-header {
  462. display: flex;
  463. align-items: center;
  464. justify-content: space-between;
  465. margin-bottom: 12px;
  466. padding-bottom: 8px;
  467. border-bottom: 2px solid #10b981;
  468. }
  469. .feed-name {
  470. font-size: 15px;
  471. font-weight: 600;
  472. color: #059669;
  473. }
  474. .feed-count {
  475. color: #666;
  476. font-size: 13px;
  477. font-weight: 500;
  478. }
  479. .rss-item {
  480. margin-bottom: 12px;
  481. padding: 14px;
  482. background: #f0fdf4;
  483. border-radius: 8px;
  484. border-left: 3px solid #10b981;
  485. }
  486. .rss-item:last-child {
  487. margin-bottom: 0;
  488. }
  489. .rss-meta {
  490. display: flex;
  491. align-items: center;
  492. gap: 12px;
  493. margin-bottom: 6px;
  494. flex-wrap: wrap;
  495. }
  496. .rss-time {
  497. color: #6b7280;
  498. font-size: 12px;
  499. }
  500. .rss-author {
  501. color: #059669;
  502. font-size: 12px;
  503. font-weight: 500;
  504. }
  505. .rss-title {
  506. font-size: 14px;
  507. line-height: 1.5;
  508. margin-bottom: 6px;
  509. }
  510. .rss-link {
  511. color: #1f2937;
  512. text-decoration: none;
  513. font-weight: 500;
  514. }
  515. .rss-link:hover {
  516. color: #059669;
  517. text-decoration: underline;
  518. }
  519. .rss-summary {
  520. font-size: 13px;
  521. color: #6b7280;
  522. line-height: 1.5;
  523. margin: 0;
  524. display: -webkit-box;
  525. -webkit-line-clamp: 2;
  526. -webkit-box-orient: vertical;
  527. overflow: hidden;
  528. }
  529. /* 独立展示区样式 - 复用热点词汇统计区样式 */
  530. .standalone-section {
  531. margin-top: 32px;
  532. padding-top: 24px;
  533. }
  534. .standalone-section-header {
  535. display: flex;
  536. align-items: center;
  537. justify-content: space-between;
  538. margin-bottom: 20px;
  539. }
  540. .standalone-section-title {
  541. font-size: 18px;
  542. font-weight: 600;
  543. color: #059669;
  544. }
  545. .standalone-section-count {
  546. color: #6b7280;
  547. font-size: 14px;
  548. }
  549. .standalone-group {
  550. margin-bottom: 40px;
  551. }
  552. .standalone-group:last-child {
  553. margin-bottom: 0;
  554. }
  555. .standalone-header {
  556. display: flex;
  557. align-items: center;
  558. justify-content: space-between;
  559. margin-bottom: 20px;
  560. padding-bottom: 8px;
  561. border-bottom: 1px solid #f0f0f0;
  562. }
  563. .standalone-name {
  564. font-size: 17px;
  565. font-weight: 600;
  566. color: #1a1a1a;
  567. }
  568. .standalone-count {
  569. color: #666;
  570. font-size: 13px;
  571. font-weight: 500;
  572. }
  573. /* AI 分析区块样式 */
  574. .ai-section {
  575. margin-top: 32px;
  576. padding: 24px;
  577. background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
  578. border-radius: 12px;
  579. border: 1px solid #bae6fd;
  580. }
  581. .ai-section-header {
  582. display: flex;
  583. align-items: center;
  584. gap: 10px;
  585. margin-bottom: 20px;
  586. }
  587. .ai-section-title {
  588. font-size: 18px;
  589. font-weight: 600;
  590. color: #0369a1;
  591. }
  592. .ai-section-badge {
  593. background: #0ea5e9;
  594. color: white;
  595. font-size: 11px;
  596. font-weight: 600;
  597. padding: 3px 8px;
  598. border-radius: 4px;
  599. }
  600. .ai-block {
  601. margin-bottom: 16px;
  602. padding: 16px;
  603. background: white;
  604. border-radius: 8px;
  605. box-shadow: 0 1px 3px rgba(0,0,0,0.05);
  606. }
  607. .ai-block:last-child {
  608. margin-bottom: 0;
  609. }
  610. .ai-block-title {
  611. font-size: 14px;
  612. font-weight: 600;
  613. color: #0369a1;
  614. margin-bottom: 8px;
  615. }
  616. .ai-block-content {
  617. font-size: 14px;
  618. line-height: 1.6;
  619. color: #334155;
  620. white-space: pre-wrap;
  621. }
  622. .ai-error {
  623. padding: 16px;
  624. background: #fef2f2;
  625. border: 1px solid #fecaca;
  626. border-radius: 8px;
  627. color: #991b1b;
  628. font-size: 14px;
  629. }
  630. </style>
  631. </head>
  632. <body>
  633. <div class="container">
  634. <div class="header">
  635. <div class="save-buttons">
  636. <button class="save-btn" onclick="saveAsImage()">保存为图片</button>
  637. <button class="save-btn" onclick="saveAsMultipleImages()">分段保存</button>
  638. </div>
  639. <div class="header-title">热点新闻分析</div>
  640. <div class="header-info">
  641. <div class="info-item">
  642. <span class="info-label">报告类型</span>
  643. <span class="info-value">"""
  644. # 处理报告类型显示(根据 mode 直接显示)
  645. if mode == "current":
  646. html += "当前榜单"
  647. elif mode == "incremental":
  648. html += "增量分析"
  649. else:
  650. html += "全天汇总"
  651. html += """</span>
  652. </div>
  653. <div class="info-item">
  654. <span class="info-label">新闻总数</span>
  655. <span class="info-value">"""
  656. html += f"{total_titles} 条"
  657. # 计算筛选后的热点新闻数量
  658. hot_news_count = sum(len(stat["titles"]) for stat in report_data["stats"])
  659. html += """</span>
  660. </div>
  661. <div class="info-item">
  662. <span class="info-label">热点新闻</span>
  663. <span class="info-value">"""
  664. html += f"{hot_news_count} 条"
  665. html += """</span>
  666. </div>
  667. <div class="info-item">
  668. <span class="info-label">生成时间</span>
  669. <span class="info-value">"""
  670. # 使用提供的时间函数或默认 datetime.now
  671. if get_time_func:
  672. now = get_time_func()
  673. else:
  674. now = datetime.now()
  675. html += now.strftime("%m-%d %H:%M")
  676. html += """</span>
  677. </div>
  678. </div>
  679. </div>
  680. <div class="content">"""
  681. # 处理失败ID错误信息
  682. if report_data["failed_ids"]:
  683. html += """
  684. <div class="error-section">
  685. <div class="error-title">⚠️ 请求失败的平台</div>
  686. <ul class="error-list">"""
  687. for id_value in report_data["failed_ids"]:
  688. html += f'<li class="error-item">{html_escape(id_value)}</li>'
  689. html += """
  690. </ul>
  691. </div>"""
  692. # 生成热点词汇统计部分的HTML
  693. stats_html = ""
  694. if report_data["stats"]:
  695. total_count = len(report_data["stats"])
  696. for i, stat in enumerate(report_data["stats"], 1):
  697. count = stat["count"]
  698. # 确定热度等级
  699. if count >= 10:
  700. count_class = "hot"
  701. elif count >= 5:
  702. count_class = "warm"
  703. else:
  704. count_class = ""
  705. escaped_word = html_escape(stat["word"])
  706. stats_html += f"""
  707. <div class="word-group">
  708. <div class="word-header">
  709. <div class="word-info">
  710. <div class="word-name">{escaped_word}</div>
  711. <div class="word-count {count_class}">{count} 条</div>
  712. </div>
  713. <div class="word-index">{i}/{total_count}</div>
  714. </div>"""
  715. # 处理每个词组下的新闻标题,给每条新闻标上序号
  716. for j, title_data in enumerate(stat["titles"], 1):
  717. is_new = title_data.get("is_new", False)
  718. new_class = "new" if is_new else ""
  719. stats_html += f"""
  720. <div class="news-item {new_class}">
  721. <div class="news-number">{j}</div>
  722. <div class="news-content">
  723. <div class="news-header">"""
  724. # 根据 display_mode 决定显示来源还是关键词
  725. if display_mode == "keyword":
  726. # keyword 模式:显示来源
  727. stats_html += f'<span class="source-name">{html_escape(title_data["source_name"])}</span>'
  728. else:
  729. # platform 模式:显示关键词
  730. matched_keyword = title_data.get("matched_keyword", "")
  731. if matched_keyword:
  732. stats_html += f'<span class="keyword-tag">[{html_escape(matched_keyword)}]</span>'
  733. # 处理排名显示
  734. ranks = title_data.get("ranks", [])
  735. if ranks:
  736. min_rank = min(ranks)
  737. max_rank = max(ranks)
  738. rank_threshold = title_data.get("rank_threshold", 10)
  739. # 确定排名等级
  740. if min_rank <= 3:
  741. rank_class = "top"
  742. elif min_rank <= rank_threshold:
  743. rank_class = "high"
  744. else:
  745. rank_class = ""
  746. if min_rank == max_rank:
  747. rank_text = str(min_rank)
  748. else:
  749. rank_text = f"{min_rank}-{max_rank}"
  750. stats_html += f'<span class="rank-num {rank_class}">{rank_text}</span>'
  751. # 处理时间显示
  752. time_display = title_data.get("time_display", "")
  753. if time_display:
  754. # 简化时间显示格式,将波浪线替换为~
  755. simplified_time = (
  756. time_display.replace(" ~ ", "~")
  757. .replace("[", "")
  758. .replace("]", "")
  759. )
  760. stats_html += (
  761. f'<span class="time-info">{html_escape(simplified_time)}</span>'
  762. )
  763. # 处理出现次数
  764. count_info = title_data.get("count", 1)
  765. if count_info > 1:
  766. stats_html += f'<span class="count-info">{count_info}次</span>'
  767. stats_html += """
  768. </div>
  769. <div class="news-title">"""
  770. # 处理标题和链接
  771. escaped_title = html_escape(title_data["title"])
  772. link_url = title_data.get("mobile_url") or title_data.get("url", "")
  773. if link_url:
  774. escaped_url = html_escape(link_url)
  775. stats_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
  776. else:
  777. stats_html += escaped_title
  778. stats_html += """
  779. </div>
  780. </div>
  781. </div>"""
  782. stats_html += """
  783. </div>"""
  784. # 给热榜统计添加外层包装
  785. if stats_html:
  786. stats_html = f"""
  787. <div class="hotlist-section">{stats_html}
  788. </div>"""
  789. # 生成新增新闻区域的HTML
  790. new_titles_html = ""
  791. if show_new_section and report_data["new_titles"]:
  792. new_titles_html += f"""
  793. <div class="new-section">
  794. <div class="new-section-title">本次新增热点 (共 {report_data['total_new_count']} 条)</div>"""
  795. for source_data in report_data["new_titles"]:
  796. escaped_source = html_escape(source_data["source_name"])
  797. titles_count = len(source_data["titles"])
  798. new_titles_html += f"""
  799. <div class="new-source-group">
  800. <div class="new-source-title">{escaped_source} · {titles_count}条</div>"""
  801. # 为新增新闻也添加序号
  802. for idx, title_data in enumerate(source_data["titles"], 1):
  803. ranks = title_data.get("ranks", [])
  804. # 处理新增新闻的排名显示
  805. rank_class = ""
  806. if ranks:
  807. min_rank = min(ranks)
  808. if min_rank <= 3:
  809. rank_class = "top"
  810. elif min_rank <= title_data.get("rank_threshold", 10):
  811. rank_class = "high"
  812. if len(ranks) == 1:
  813. rank_text = str(ranks[0])
  814. else:
  815. rank_text = f"{min(ranks)}-{max(ranks)}"
  816. else:
  817. rank_text = "?"
  818. new_titles_html += f"""
  819. <div class="new-item">
  820. <div class="new-item-number">{idx}</div>
  821. <div class="new-item-rank {rank_class}">{rank_text}</div>
  822. <div class="new-item-content">
  823. <div class="new-item-title">"""
  824. # 处理新增新闻的链接
  825. escaped_title = html_escape(title_data["title"])
  826. link_url = title_data.get("mobile_url") or title_data.get("url", "")
  827. if link_url:
  828. escaped_url = html_escape(link_url)
  829. new_titles_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
  830. else:
  831. new_titles_html += escaped_title
  832. new_titles_html += """
  833. </div>
  834. </div>
  835. </div>"""
  836. new_titles_html += """
  837. </div>"""
  838. new_titles_html += """
  839. </div>"""
  840. # 生成 RSS 统计内容
  841. def render_rss_stats_html(stats: List[Dict], title: str = "RSS 订阅更新") -> str:
  842. """渲染 RSS 统计区块 HTML
  843. Args:
  844. stats: RSS 分组统计列表,格式与热榜一致:
  845. [
  846. {
  847. "word": "关键词",
  848. "count": 5,
  849. "titles": [
  850. {
  851. "title": "标题",
  852. "source_name": "Feed 名称",
  853. "time_display": "12-29 08:20",
  854. "url": "...",
  855. "is_new": True/False
  856. }
  857. ]
  858. }
  859. ]
  860. title: 区块标题
  861. Returns:
  862. 渲染后的 HTML 字符串
  863. """
  864. if not stats:
  865. return ""
  866. # 计算总条目数
  867. total_count = sum(stat.get("count", 0) for stat in stats)
  868. if total_count == 0:
  869. return ""
  870. rss_html = f"""
  871. <div class="rss-section">
  872. <div class="rss-section-header">
  873. <div class="rss-section-title">{title}</div>
  874. <div class="rss-section-count">{total_count} 条</div>
  875. </div>"""
  876. # 按关键词分组渲染(与热榜格式一致)
  877. for stat in stats:
  878. keyword = stat.get("word", "")
  879. titles = stat.get("titles", [])
  880. if not titles:
  881. continue
  882. keyword_count = len(titles)
  883. rss_html += f"""
  884. <div class="feed-group">
  885. <div class="feed-header">
  886. <div class="feed-name">{html_escape(keyword)}</div>
  887. <div class="feed-count">{keyword_count} 条</div>
  888. </div>"""
  889. for title_data in titles:
  890. item_title = title_data.get("title", "")
  891. url = title_data.get("url", "")
  892. time_display = title_data.get("time_display", "")
  893. source_name = title_data.get("source_name", "")
  894. is_new = title_data.get("is_new", False)
  895. rss_html += """
  896. <div class="rss-item">
  897. <div class="rss-meta">"""
  898. if time_display:
  899. rss_html += f'<span class="rss-time">{html_escape(time_display)}</span>'
  900. if source_name:
  901. rss_html += f'<span class="rss-author">{html_escape(source_name)}</span>'
  902. if is_new:
  903. rss_html += '<span class="rss-author" style="color: #dc2626;">NEW</span>'
  904. rss_html += """
  905. </div>
  906. <div class="rss-title">"""
  907. escaped_title = html_escape(item_title)
  908. if url:
  909. escaped_url = html_escape(url)
  910. rss_html += f'<a href="{escaped_url}" target="_blank" class="rss-link">{escaped_title}</a>'
  911. else:
  912. rss_html += escaped_title
  913. rss_html += """
  914. </div>
  915. </div>"""
  916. rss_html += """
  917. </div>"""
  918. rss_html += """
  919. </div>"""
  920. return rss_html
  921. # 生成独立展示区内容
  922. def render_standalone_html(data: Optional[Dict]) -> str:
  923. """渲染独立展示区 HTML(复用热点词汇统计区样式)
  924. Args:
  925. data: 独立展示数据,格式:
  926. {
  927. "platforms": [
  928. {
  929. "id": "zhihu",
  930. "name": "知乎热榜",
  931. "items": [
  932. {
  933. "title": "标题",
  934. "url": "链接",
  935. "rank": 1,
  936. "ranks": [1, 2, 1],
  937. "first_time": "08:00",
  938. "last_time": "12:30",
  939. "count": 3,
  940. }
  941. ]
  942. }
  943. ],
  944. "rss_feeds": [
  945. {
  946. "id": "hacker-news",
  947. "name": "Hacker News",
  948. "items": [
  949. {
  950. "title": "标题",
  951. "url": "链接",
  952. "published_at": "2025-01-07T08:00:00",
  953. "author": "作者",
  954. }
  955. ]
  956. }
  957. ]
  958. }
  959. Returns:
  960. 渲染后的 HTML 字符串
  961. """
  962. if not data:
  963. return ""
  964. platforms = data.get("platforms", [])
  965. rss_feeds = data.get("rss_feeds", [])
  966. if not platforms and not rss_feeds:
  967. return ""
  968. # 计算总条目数
  969. total_platform_items = sum(len(p.get("items", [])) for p in platforms)
  970. total_rss_items = sum(len(f.get("items", [])) for f in rss_feeds)
  971. total_count = total_platform_items + total_rss_items
  972. if total_count == 0:
  973. return ""
  974. standalone_html = f"""
  975. <div class="standalone-section">
  976. <div class="standalone-section-header">
  977. <div class="standalone-section-title">独立展示区</div>
  978. <div class="standalone-section-count">{total_count} 条</div>
  979. </div>"""
  980. # 渲染热榜平台(复用 word-group 结构)
  981. for platform in platforms:
  982. platform_name = platform.get("name", platform.get("id", ""))
  983. items = platform.get("items", [])
  984. if not items:
  985. continue
  986. standalone_html += f"""
  987. <div class="standalone-group">
  988. <div class="standalone-header">
  989. <div class="standalone-name">{html_escape(platform_name)}</div>
  990. <div class="standalone-count">{len(items)} 条</div>
  991. </div>"""
  992. # 渲染每个条目(复用 news-item 结构)
  993. for j, item in enumerate(items, 1):
  994. title = item.get("title", "")
  995. url = item.get("url", "") or item.get("mobileUrl", "")
  996. rank = item.get("rank", 0)
  997. ranks = item.get("ranks", [])
  998. first_time = item.get("first_time", "")
  999. last_time = item.get("last_time", "")
  1000. count = item.get("count", 1)
  1001. standalone_html += f"""
  1002. <div class="news-item">
  1003. <div class="news-number">{j}</div>
  1004. <div class="news-content">
  1005. <div class="news-header">"""
  1006. # 排名显示(复用 rank-num 样式,无 # 前缀)
  1007. if ranks:
  1008. min_rank = min(ranks)
  1009. max_rank = max(ranks)
  1010. # 确定排名等级
  1011. if min_rank <= 3:
  1012. rank_class = "top"
  1013. elif min_rank <= 10:
  1014. rank_class = "high"
  1015. else:
  1016. rank_class = ""
  1017. if min_rank == max_rank:
  1018. rank_text = str(min_rank)
  1019. else:
  1020. rank_text = f"{min_rank}-{max_rank}"
  1021. standalone_html += f'<span class="rank-num {rank_class}">{rank_text}</span>'
  1022. elif rank > 0:
  1023. if rank <= 3:
  1024. rank_class = "top"
  1025. elif rank <= 10:
  1026. rank_class = "high"
  1027. else:
  1028. rank_class = ""
  1029. standalone_html += f'<span class="rank-num {rank_class}">{rank}</span>'
  1030. # 时间显示(复用 time-info 样式,将 HH-MM 转换为 HH:MM)
  1031. if first_time and last_time and first_time != last_time:
  1032. first_time_display = convert_time_for_display(first_time)
  1033. last_time_display = convert_time_for_display(last_time)
  1034. standalone_html += f'<span class="time-info">{html_escape(first_time_display)}~{html_escape(last_time_display)}</span>'
  1035. elif first_time:
  1036. first_time_display = convert_time_for_display(first_time)
  1037. standalone_html += f'<span class="time-info">{html_escape(first_time_display)}</span>'
  1038. # 出现次数(复用 count-info 样式)
  1039. if count > 1:
  1040. standalone_html += f'<span class="count-info">{count}次</span>'
  1041. standalone_html += """
  1042. </div>
  1043. <div class="news-title">"""
  1044. # 标题和链接(复用 news-link 样式)
  1045. escaped_title = html_escape(title)
  1046. if url:
  1047. escaped_url = html_escape(url)
  1048. standalone_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
  1049. else:
  1050. standalone_html += escaped_title
  1051. standalone_html += """
  1052. </div>
  1053. </div>
  1054. </div>"""
  1055. standalone_html += """
  1056. </div>"""
  1057. # 渲染 RSS 源(复用相同结构)
  1058. for feed in rss_feeds:
  1059. feed_name = feed.get("name", feed.get("id", ""))
  1060. items = feed.get("items", [])
  1061. if not items:
  1062. continue
  1063. standalone_html += f"""
  1064. <div class="standalone-group">
  1065. <div class="standalone-header">
  1066. <div class="standalone-name">{html_escape(feed_name)}</div>
  1067. <div class="standalone-count">{len(items)} 条</div>
  1068. </div>"""
  1069. for j, item in enumerate(items, 1):
  1070. title = item.get("title", "")
  1071. url = item.get("url", "")
  1072. published_at = item.get("published_at", "")
  1073. author = item.get("author", "")
  1074. standalone_html += f"""
  1075. <div class="news-item">
  1076. <div class="news-number">{j}</div>
  1077. <div class="news-content">
  1078. <div class="news-header">"""
  1079. # 时间显示(格式化 ISO 时间)
  1080. if published_at:
  1081. try:
  1082. from datetime import datetime as dt
  1083. if "T" in published_at:
  1084. dt_obj = dt.fromisoformat(published_at.replace("Z", "+00:00"))
  1085. time_display = dt_obj.strftime("%m-%d %H:%M")
  1086. else:
  1087. time_display = published_at
  1088. except:
  1089. time_display = published_at
  1090. standalone_html += f'<span class="time-info">{html_escape(time_display)}</span>'
  1091. # 作者显示
  1092. if author:
  1093. standalone_html += f'<span class="source-name">{html_escape(author)}</span>'
  1094. standalone_html += """
  1095. </div>
  1096. <div class="news-title">"""
  1097. escaped_title = html_escape(title)
  1098. if url:
  1099. escaped_url = html_escape(url)
  1100. standalone_html += f'<a href="{escaped_url}" target="_blank" class="news-link">{escaped_title}</a>'
  1101. else:
  1102. standalone_html += escaped_title
  1103. standalone_html += """
  1104. </div>
  1105. </div>
  1106. </div>"""
  1107. standalone_html += """
  1108. </div>"""
  1109. standalone_html += """
  1110. </div>"""
  1111. return standalone_html
  1112. # 生成 RSS 统计和新增 HTML
  1113. rss_stats_html = render_rss_stats_html(rss_items, "RSS 订阅更新") if rss_items else ""
  1114. rss_new_html = render_rss_stats_html(rss_new_items, "RSS 新增更新") if rss_new_items else ""
  1115. # 生成独立展示区 HTML
  1116. standalone_html = render_standalone_html(standalone_data)
  1117. # 生成 AI 分析 HTML
  1118. ai_html = render_ai_analysis_html_rich(ai_analysis) if ai_analysis else ""
  1119. # 准备各区域内容映射
  1120. region_contents = {
  1121. "hotlist": stats_html,
  1122. "rss": rss_stats_html,
  1123. "new_items": (new_titles_html, rss_new_html), # 元组,分别处理
  1124. "standalone": standalone_html,
  1125. "ai_analysis": ai_html,
  1126. }
  1127. def add_section_divider(content: str) -> str:
  1128. """为内容的外层 div 添加 section-divider 类"""
  1129. if not content or 'class="' not in content:
  1130. return content
  1131. first_class_pos = content.find('class="')
  1132. if first_class_pos != -1:
  1133. insert_pos = first_class_pos + len('class="')
  1134. return content[:insert_pos] + "section-divider " + content[insert_pos:]
  1135. return content
  1136. # 按 region_order 顺序组装内容,动态添加分割线
  1137. has_previous_content = False
  1138. for region in region_order:
  1139. content = region_contents.get(region, "")
  1140. if region == "new_items":
  1141. # 特殊处理 new_items 区域(包含热榜新增和 RSS 新增两部分)
  1142. new_html, rss_new = content
  1143. if new_html:
  1144. if has_previous_content:
  1145. new_html = add_section_divider(new_html)
  1146. html += new_html
  1147. has_previous_content = True
  1148. if rss_new:
  1149. if has_previous_content:
  1150. rss_new = add_section_divider(rss_new)
  1151. html += rss_new
  1152. has_previous_content = True
  1153. elif content:
  1154. if has_previous_content:
  1155. content = add_section_divider(content)
  1156. html += content
  1157. has_previous_content = True
  1158. html += """
  1159. </div>
  1160. <div class="footer">
  1161. <div class="footer-content">
  1162. 由 <span class="project-name">TrendRadar</span> 生成 ·
  1163. <a href="https://github.com/sansan0/TrendRadar" target="_blank" class="footer-link">
  1164. GitHub 开源项目
  1165. </a>"""
  1166. if update_info:
  1167. html += f"""
  1168. <br>
  1169. <span style="color: #ea580c; font-weight: 500;">
  1170. 发现新版本 {update_info['remote_version']},当前版本 {update_info['current_version']}
  1171. </span>"""
  1172. html += """
  1173. </div>
  1174. </div>
  1175. </div>
  1176. <script>
  1177. async function saveAsImage() {
  1178. const button = event.target;
  1179. const originalText = button.textContent;
  1180. try {
  1181. button.textContent = '生成中...';
  1182. button.disabled = true;
  1183. window.scrollTo(0, 0);
  1184. // 等待页面稳定
  1185. await new Promise(resolve => setTimeout(resolve, 200));
  1186. // 截图前隐藏按钮
  1187. const buttons = document.querySelector('.save-buttons');
  1188. buttons.style.visibility = 'hidden';
  1189. // 再次等待确保按钮完全隐藏
  1190. await new Promise(resolve => setTimeout(resolve, 100));
  1191. const container = document.querySelector('.container');
  1192. const canvas = await html2canvas(container, {
  1193. backgroundColor: '#ffffff',
  1194. scale: 1.5,
  1195. useCORS: true,
  1196. allowTaint: false,
  1197. imageTimeout: 10000,
  1198. removeContainer: false,
  1199. foreignObjectRendering: false,
  1200. logging: false,
  1201. width: container.offsetWidth,
  1202. height: container.offsetHeight,
  1203. x: 0,
  1204. y: 0,
  1205. scrollX: 0,
  1206. scrollY: 0,
  1207. windowWidth: window.innerWidth,
  1208. windowHeight: window.innerHeight
  1209. });
  1210. buttons.style.visibility = 'visible';
  1211. const link = document.createElement('a');
  1212. const now = new Date();
  1213. 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`;
  1214. link.download = filename;
  1215. link.href = canvas.toDataURL('image/png', 1.0);
  1216. // 触发下载
  1217. document.body.appendChild(link);
  1218. link.click();
  1219. document.body.removeChild(link);
  1220. button.textContent = '保存成功!';
  1221. setTimeout(() => {
  1222. button.textContent = originalText;
  1223. button.disabled = false;
  1224. }, 2000);
  1225. } catch (error) {
  1226. const buttons = document.querySelector('.save-buttons');
  1227. buttons.style.visibility = 'visible';
  1228. button.textContent = '保存失败';
  1229. setTimeout(() => {
  1230. button.textContent = originalText;
  1231. button.disabled = false;
  1232. }, 2000);
  1233. }
  1234. }
  1235. async function saveAsMultipleImages() {
  1236. const button = event.target;
  1237. const originalText = button.textContent;
  1238. const container = document.querySelector('.container');
  1239. const scale = 1.5;
  1240. const maxHeight = 5000 / scale;
  1241. try {
  1242. button.textContent = '分析中...';
  1243. button.disabled = true;
  1244. // 获取所有可能的分割元素
  1245. const newsItems = Array.from(container.querySelectorAll('.news-item'));
  1246. const wordGroups = Array.from(container.querySelectorAll('.word-group'));
  1247. const newSection = container.querySelector('.new-section');
  1248. const errorSection = container.querySelector('.error-section');
  1249. const header = container.querySelector('.header');
  1250. const footer = container.querySelector('.footer');
  1251. // 计算元素位置和高度
  1252. const containerRect = container.getBoundingClientRect();
  1253. const elements = [];
  1254. // 添加header作为必须包含的元素
  1255. elements.push({
  1256. type: 'header',
  1257. element: header,
  1258. top: 0,
  1259. bottom: header.offsetHeight,
  1260. height: header.offsetHeight
  1261. });
  1262. // 添加错误信息(如果存在)
  1263. if (errorSection) {
  1264. const rect = errorSection.getBoundingClientRect();
  1265. elements.push({
  1266. type: 'error',
  1267. element: errorSection,
  1268. top: rect.top - containerRect.top,
  1269. bottom: rect.bottom - containerRect.top,
  1270. height: rect.height
  1271. });
  1272. }
  1273. // 按word-group分组处理news-item
  1274. wordGroups.forEach(group => {
  1275. const groupRect = group.getBoundingClientRect();
  1276. const groupNewsItems = group.querySelectorAll('.news-item');
  1277. // 添加word-group的header部分
  1278. const wordHeader = group.querySelector('.word-header');
  1279. if (wordHeader) {
  1280. const headerRect = wordHeader.getBoundingClientRect();
  1281. elements.push({
  1282. type: 'word-header',
  1283. element: wordHeader,
  1284. parent: group,
  1285. top: groupRect.top - containerRect.top,
  1286. bottom: headerRect.bottom - containerRect.top,
  1287. height: headerRect.height
  1288. });
  1289. }
  1290. // 添加每个news-item
  1291. groupNewsItems.forEach(item => {
  1292. const rect = item.getBoundingClientRect();
  1293. elements.push({
  1294. type: 'news-item',
  1295. element: item,
  1296. parent: group,
  1297. top: rect.top - containerRect.top,
  1298. bottom: rect.bottom - containerRect.top,
  1299. height: rect.height
  1300. });
  1301. });
  1302. });
  1303. // 添加新增新闻部分
  1304. if (newSection) {
  1305. const rect = newSection.getBoundingClientRect();
  1306. elements.push({
  1307. type: 'new-section',
  1308. element: newSection,
  1309. top: rect.top - containerRect.top,
  1310. bottom: rect.bottom - containerRect.top,
  1311. height: rect.height
  1312. });
  1313. }
  1314. // 添加footer
  1315. const footerRect = footer.getBoundingClientRect();
  1316. elements.push({
  1317. type: 'footer',
  1318. element: footer,
  1319. top: footerRect.top - containerRect.top,
  1320. bottom: footerRect.bottom - containerRect.top,
  1321. height: footer.offsetHeight
  1322. });
  1323. // 计算分割点
  1324. const segments = [];
  1325. let currentSegment = { start: 0, end: 0, height: 0, includeHeader: true };
  1326. let headerHeight = header.offsetHeight;
  1327. currentSegment.height = headerHeight;
  1328. for (let i = 1; i < elements.length; i++) {
  1329. const element = elements[i];
  1330. const potentialHeight = element.bottom - currentSegment.start;
  1331. // 检查是否需要创建新分段
  1332. if (potentialHeight > maxHeight && currentSegment.height > headerHeight) {
  1333. // 在前一个元素结束处分割
  1334. currentSegment.end = elements[i - 1].bottom;
  1335. segments.push(currentSegment);
  1336. // 开始新分段
  1337. currentSegment = {
  1338. start: currentSegment.end,
  1339. end: 0,
  1340. height: element.bottom - currentSegment.end,
  1341. includeHeader: false
  1342. };
  1343. } else {
  1344. currentSegment.height = potentialHeight;
  1345. currentSegment.end = element.bottom;
  1346. }
  1347. }
  1348. // 添加最后一个分段
  1349. if (currentSegment.height > 0) {
  1350. currentSegment.end = container.offsetHeight;
  1351. segments.push(currentSegment);
  1352. }
  1353. button.textContent = `生成中 (0/${segments.length})...`;
  1354. // 隐藏保存按钮
  1355. const buttons = document.querySelector('.save-buttons');
  1356. buttons.style.visibility = 'hidden';
  1357. // 为每个分段生成图片
  1358. const images = [];
  1359. for (let i = 0; i < segments.length; i++) {
  1360. const segment = segments[i];
  1361. button.textContent = `生成中 (${i + 1}/${segments.length})...`;
  1362. // 创建临时容器用于截图
  1363. const tempContainer = document.createElement('div');
  1364. tempContainer.style.cssText = `
  1365. position: absolute;
  1366. left: -9999px;
  1367. top: 0;
  1368. width: ${container.offsetWidth}px;
  1369. background: white;
  1370. `;
  1371. tempContainer.className = 'container';
  1372. // 克隆容器内容
  1373. const clonedContainer = container.cloneNode(true);
  1374. // 移除克隆内容中的保存按钮
  1375. const clonedButtons = clonedContainer.querySelector('.save-buttons');
  1376. if (clonedButtons) {
  1377. clonedButtons.style.display = 'none';
  1378. }
  1379. tempContainer.appendChild(clonedContainer);
  1380. document.body.appendChild(tempContainer);
  1381. // 等待DOM更新
  1382. await new Promise(resolve => setTimeout(resolve, 100));
  1383. // 使用html2canvas截取特定区域
  1384. const canvas = await html2canvas(clonedContainer, {
  1385. backgroundColor: '#ffffff',
  1386. scale: scale,
  1387. useCORS: true,
  1388. allowTaint: false,
  1389. imageTimeout: 10000,
  1390. logging: false,
  1391. width: container.offsetWidth,
  1392. height: segment.end - segment.start,
  1393. x: 0,
  1394. y: segment.start,
  1395. windowWidth: window.innerWidth,
  1396. windowHeight: window.innerHeight
  1397. });
  1398. images.push(canvas.toDataURL('image/png', 1.0));
  1399. // 清理临时容器
  1400. document.body.removeChild(tempContainer);
  1401. }
  1402. // 恢复按钮显示
  1403. buttons.style.visibility = 'visible';
  1404. // 下载所有图片
  1405. const now = new Date();
  1406. 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')}`;
  1407. for (let i = 0; i < images.length; i++) {
  1408. const link = document.createElement('a');
  1409. link.download = `${baseFilename}_part${i + 1}.png`;
  1410. link.href = images[i];
  1411. document.body.appendChild(link);
  1412. link.click();
  1413. document.body.removeChild(link);
  1414. // 延迟一下避免浏览器阻止多个下载
  1415. await new Promise(resolve => setTimeout(resolve, 100));
  1416. }
  1417. button.textContent = `已保存 ${segments.length} 张图片!`;
  1418. setTimeout(() => {
  1419. button.textContent = originalText;
  1420. button.disabled = false;
  1421. }, 2000);
  1422. } catch (error) {
  1423. console.error('分段保存失败:', error);
  1424. const buttons = document.querySelector('.save-buttons');
  1425. buttons.style.visibility = 'visible';
  1426. button.textContent = '保存失败';
  1427. setTimeout(() => {
  1428. button.textContent = originalText;
  1429. button.disabled = false;
  1430. }, 2000);
  1431. }
  1432. }
  1433. document.addEventListener('DOMContentLoaded', function() {
  1434. window.scrollTo(0, 0);
  1435. });
  1436. </script>
  1437. </body>
  1438. </html>
  1439. """
  1440. return html