html.py 55 KB

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