rss_html.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. # coding=utf-8
  2. """
  3. RSS HTML 报告渲染模块
  4. 提供 RSS 订阅内容的 HTML 格式报告生成功能
  5. """
  6. from datetime import datetime
  7. from typing import Dict, List, Optional, Callable
  8. from trendradar.report.helpers import html_escape
  9. def render_rss_html_content(
  10. rss_items: List[Dict],
  11. total_count: int,
  12. feeds_info: Optional[Dict[str, str]] = None,
  13. *,
  14. get_time_func: Optional[Callable[[], datetime]] = None,
  15. ) -> str:
  16. """渲染 RSS HTML 内容
  17. Args:
  18. rss_items: RSS 条目列表,每个条目包含:
  19. - title: 标题
  20. - feed_id: RSS 源 ID
  21. - feed_name: RSS 源名称
  22. - url: 链接
  23. - published_at: 发布时间
  24. - summary: 摘要(可选)
  25. - author: 作者(可选)
  26. total_count: 条目总数
  27. feeds_info: RSS 源 ID 到名称的映射
  28. get_time_func: 获取当前时间的函数(可选,默认使用 datetime.now)
  29. Returns:
  30. 渲染后的 HTML 字符串
  31. """
  32. html = """
  33. <!DOCTYPE html>
  34. <html>
  35. <head>
  36. <meta charset="UTF-8">
  37. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  38. <title>RSS 订阅内容</title>
  39. <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>
  40. <style>
  41. * { box-sizing: border-box; }
  42. body {
  43. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  44. margin: 0;
  45. padding: 16px;
  46. background: #fafafa;
  47. color: #333;
  48. line-height: 1.5;
  49. }
  50. .container {
  51. max-width: 700px;
  52. margin: 0 auto;
  53. background: white;
  54. border-radius: 12px;
  55. overflow: hidden;
  56. box-shadow: 0 2px 16px rgba(0,0,0,0.06);
  57. }
  58. .header {
  59. background: linear-gradient(135deg, #059669 0%, #10b981 100%);
  60. color: white;
  61. padding: 32px 24px;
  62. text-align: center;
  63. position: relative;
  64. }
  65. .save-buttons {
  66. position: absolute;
  67. top: 16px;
  68. right: 16px;
  69. display: flex;
  70. gap: 8px;
  71. }
  72. .save-btn {
  73. background: rgba(255, 255, 255, 0.2);
  74. border: 1px solid rgba(255, 255, 255, 0.3);
  75. color: white;
  76. padding: 8px 16px;
  77. border-radius: 6px;
  78. cursor: pointer;
  79. font-size: 13px;
  80. font-weight: 500;
  81. transition: all 0.2s ease;
  82. backdrop-filter: blur(10px);
  83. white-space: nowrap;
  84. }
  85. .save-btn:hover {
  86. background: rgba(255, 255, 255, 0.3);
  87. border-color: rgba(255, 255, 255, 0.5);
  88. transform: translateY(-1px);
  89. }
  90. .save-btn:active {
  91. transform: translateY(0);
  92. }
  93. .save-btn:disabled {
  94. opacity: 0.6;
  95. cursor: not-allowed;
  96. }
  97. .header-title {
  98. font-size: 22px;
  99. font-weight: 700;
  100. margin: 0 0 20px 0;
  101. }
  102. .header-info {
  103. display: grid;
  104. grid-template-columns: 1fr 1fr;
  105. gap: 16px;
  106. font-size: 14px;
  107. opacity: 0.95;
  108. }
  109. .info-item {
  110. text-align: center;
  111. }
  112. .info-label {
  113. display: block;
  114. font-size: 12px;
  115. opacity: 0.8;
  116. margin-bottom: 4px;
  117. }
  118. .info-value {
  119. font-weight: 600;
  120. font-size: 16px;
  121. }
  122. .content {
  123. padding: 24px;
  124. }
  125. .feed-group {
  126. margin-bottom: 32px;
  127. }
  128. .feed-group:last-child {
  129. margin-bottom: 0;
  130. }
  131. .feed-header {
  132. display: flex;
  133. align-items: center;
  134. justify-content: space-between;
  135. margin-bottom: 16px;
  136. padding-bottom: 8px;
  137. border-bottom: 2px solid #10b981;
  138. }
  139. .feed-name {
  140. font-size: 16px;
  141. font-weight: 600;
  142. color: #059669;
  143. }
  144. .feed-count {
  145. color: #666;
  146. font-size: 13px;
  147. font-weight: 500;
  148. }
  149. .rss-item {
  150. margin-bottom: 16px;
  151. padding: 16px;
  152. background: #f9fafb;
  153. border-radius: 8px;
  154. border-left: 3px solid #10b981;
  155. }
  156. .rss-item:last-child {
  157. margin-bottom: 0;
  158. }
  159. .rss-meta {
  160. display: flex;
  161. align-items: center;
  162. gap: 12px;
  163. margin-bottom: 8px;
  164. flex-wrap: wrap;
  165. }
  166. .rss-time {
  167. color: #6b7280;
  168. font-size: 12px;
  169. }
  170. .rss-author {
  171. color: #059669;
  172. font-size: 12px;
  173. font-weight: 500;
  174. }
  175. .rss-title {
  176. font-size: 15px;
  177. line-height: 1.5;
  178. color: #1a1a1a;
  179. margin: 0 0 8px 0;
  180. font-weight: 500;
  181. }
  182. .rss-link {
  183. color: #2563eb;
  184. text-decoration: none;
  185. }
  186. .rss-link:hover {
  187. text-decoration: underline;
  188. }
  189. .rss-link:visited {
  190. color: #7c3aed;
  191. }
  192. .rss-summary {
  193. font-size: 13px;
  194. color: #6b7280;
  195. line-height: 1.6;
  196. margin: 0;
  197. display: -webkit-box;
  198. -webkit-line-clamp: 3;
  199. -webkit-box-orient: vertical;
  200. overflow: hidden;
  201. }
  202. .footer {
  203. margin-top: 32px;
  204. padding: 20px 24px;
  205. background: #f8f9fa;
  206. border-top: 1px solid #e5e7eb;
  207. text-align: center;
  208. }
  209. .footer-content {
  210. font-size: 13px;
  211. color: #6b7280;
  212. line-height: 1.6;
  213. }
  214. .footer-link {
  215. color: #059669;
  216. text-decoration: none;
  217. font-weight: 500;
  218. transition: color 0.2s ease;
  219. }
  220. .footer-link:hover {
  221. color: #10b981;
  222. text-decoration: underline;
  223. }
  224. .project-name {
  225. font-weight: 600;
  226. color: #374151;
  227. }
  228. @media (max-width: 480px) {
  229. body { padding: 12px; }
  230. .header { padding: 24px 20px; }
  231. .content { padding: 20px; }
  232. .footer { padding: 16px 20px; }
  233. .header-info { grid-template-columns: 1fr; gap: 12px; }
  234. .rss-meta { gap: 8px; }
  235. .rss-item { padding: 12px; }
  236. .save-buttons {
  237. position: static;
  238. margin-bottom: 16px;
  239. display: flex;
  240. gap: 8px;
  241. justify-content: center;
  242. flex-direction: column;
  243. width: 100%;
  244. }
  245. .save-btn {
  246. width: 100%;
  247. }
  248. }
  249. </style>
  250. </head>
  251. <body>
  252. <div class="container">
  253. <div class="header">
  254. <div class="save-buttons">
  255. <button class="save-btn" onclick="saveAsImage()">保存为图片</button>
  256. </div>
  257. <div class="header-title">RSS 订阅内容</div>
  258. <div class="header-info">
  259. <div class="info-item">
  260. <span class="info-label">订阅条目</span>
  261. <span class="info-value">"""
  262. html += f"{total_count} 条"
  263. html += """</span>
  264. </div>
  265. <div class="info-item">
  266. <span class="info-label">生成时间</span>
  267. <span class="info-value">"""
  268. # 使用提供的时间函数或默认 datetime.now
  269. if get_time_func:
  270. now = get_time_func()
  271. else:
  272. now = datetime.now()
  273. html += now.strftime("%m-%d %H:%M")
  274. html += """</span>
  275. </div>
  276. </div>
  277. </div>
  278. <div class="content">"""
  279. # 按 feed_id 分组
  280. feeds_map: Dict[str, List[Dict]] = {}
  281. for item in rss_items:
  282. feed_id = item.get("feed_id", "unknown")
  283. if feed_id not in feeds_map:
  284. feeds_map[feed_id] = []
  285. feeds_map[feed_id].append(item)
  286. # 渲染每个 RSS 源的内容
  287. for feed_id, items in feeds_map.items():
  288. feed_name = items[0].get("feed_name", feed_id) if items else feed_id
  289. if feeds_info and feed_id in feeds_info:
  290. feed_name = feeds_info[feed_id]
  291. escaped_feed_name = html_escape(feed_name)
  292. html += f"""
  293. <div class="feed-group">
  294. <div class="feed-header">
  295. <div class="feed-name">{escaped_feed_name}</div>
  296. <div class="feed-count">{len(items)} 条</div>
  297. </div>"""
  298. for item in items:
  299. raw_title = item.get("title", "")
  300. if not raw_title or not raw_title.strip():
  301. raw_title = item.get("url", "") or item.get("feed_name", "")
  302. escaped_title = html_escape(raw_title)
  303. url = item.get("url", "")
  304. published_at = item.get("published_at", "")
  305. author = item.get("author", "")
  306. summary = item.get("summary", "")
  307. html += """
  308. <div class="rss-item">
  309. <div class="rss-meta">"""
  310. if published_at:
  311. html += f'<span class="rss-time">{html_escape(published_at)}</span>'
  312. if author:
  313. html += f'<span class="rss-author">by {html_escape(author)}</span>'
  314. html += """
  315. </div>
  316. <div class="rss-title">"""
  317. if url:
  318. escaped_url = html_escape(url)
  319. html += f'<a href="{escaped_url}" target="_blank" class="rss-link">{escaped_title}</a>'
  320. else:
  321. html += escaped_title
  322. html += """
  323. </div>"""
  324. if summary:
  325. escaped_summary = html_escape(summary)
  326. html += f"""
  327. <p class="rss-summary">{escaped_summary}</p>"""
  328. html += """
  329. </div>"""
  330. html += """
  331. </div>"""
  332. html += """
  333. </div>
  334. <div class="footer">
  335. <div class="footer-content">
  336. 由 <span class="project-name">TrendRadar</span> 生成 ·
  337. <a href="https://github.com/sansan0/TrendRadar" target="_blank" class="footer-link">
  338. GitHub 开源项目
  339. </a>
  340. </div>
  341. </div>
  342. </div>
  343. <script>
  344. async function saveAsImage() {
  345. const button = event.target;
  346. const originalText = button.textContent;
  347. try {
  348. button.textContent = '生成中...';
  349. button.disabled = true;
  350. window.scrollTo(0, 0);
  351. await new Promise(resolve => setTimeout(resolve, 200));
  352. const buttons = document.querySelector('.save-buttons');
  353. buttons.style.visibility = 'hidden';
  354. await new Promise(resolve => setTimeout(resolve, 100));
  355. const container = document.querySelector('.container');
  356. const canvas = await html2canvas(container, {
  357. backgroundColor: '#ffffff',
  358. scale: 1.5,
  359. useCORS: true,
  360. allowTaint: false,
  361. imageTimeout: 10000,
  362. removeContainer: false,
  363. foreignObjectRendering: false,
  364. logging: false,
  365. width: container.offsetWidth,
  366. height: container.offsetHeight,
  367. x: 0,
  368. y: 0,
  369. scrollX: 0,
  370. scrollY: 0,
  371. windowWidth: window.innerWidth,
  372. windowHeight: window.innerHeight
  373. });
  374. buttons.style.visibility = 'visible';
  375. const link = document.createElement('a');
  376. const now = new Date();
  377. const filename = `TrendRadar_RSS订阅_${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`;
  378. link.download = filename;
  379. link.href = canvas.toDataURL('image/png', 1.0);
  380. document.body.appendChild(link);
  381. link.click();
  382. document.body.removeChild(link);
  383. button.textContent = '保存成功!';
  384. setTimeout(() => {
  385. button.textContent = originalText;
  386. button.disabled = false;
  387. }, 2000);
  388. } catch (error) {
  389. const buttons = document.querySelector('.save-buttons');
  390. buttons.style.visibility = 'visible';
  391. button.textContent = '保存失败';
  392. setTimeout(() => {
  393. button.textContent = originalText;
  394. button.disabled = false;
  395. }, 2000);
  396. }
  397. }
  398. document.addEventListener('DOMContentLoaded', function() {
  399. window.scrollTo(0, 0);
  400. });
  401. </script>
  402. </body>
  403. </html>
  404. """
  405. return html