html.py 36 KB

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