weflow-cli 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,1372 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 生成公众号日报 HTML 页面 — 分类展示、已读/未读标记(localStorage 持久化)。
4
+
5
+ 用法:
6
+ python scripts/generate_html.py --date 2026-05-18
7
+ python scripts/generate_html.py --date 2026-05-18 --output custom.html
8
+ """
9
+ import sys, os, json, re
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+
13
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ SOURCE_ROOT = os.path.join(os.path.dirname(SCRIPTS_DIR), 'output', 'biz-daily')
15
+
16
+ TOPIC_LABELS = {
17
+ 'AI': ('AI', '#8b5cf6', '🤖'),
18
+ '学术': ('学术', '#3b82f6', '📚'),
19
+ '新闻': ('新闻', '#f59e0b', '📰'),
20
+ '文学': ('文学', '#ec4899', '📝'),
21
+ '投资': ('投资', '#10b981', '💰'),
22
+ }
23
+
24
+ TOPIC_ORDER = ['AI', '学术', '新闻', '文学', '投资']
25
+
26
+
27
+ def parse_frontmatter(text: str) -> dict:
28
+ """解析 YAML frontmatter(简易版,兼容现有格式)。"""
29
+ if not text.startswith('---'):
30
+ return {}
31
+ end = text.find('---', 3)
32
+ if end == -1:
33
+ return {}
34
+ fm_text = text[3:end].strip()
35
+ meta = {}
36
+ for line in fm_text.split('\n'):
37
+ line = line.strip()
38
+ if ':' in line:
39
+ key, _, val = line.partition(':')
40
+ key = key.strip()
41
+ val = val.strip().strip('"').strip("'")
42
+ if val.startswith('[') and val.endswith(']'):
43
+ val = [v.strip().strip('"').strip("'") for v in val[1:-1].split(',') if v.strip()]
44
+ meta[key] = val
45
+ return meta
46
+
47
+
48
+ def get_body_preview(text: str, max_chars=200) -> str:
49
+ """提取正文预览(跳过 frontmatter 和标题行)。"""
50
+ if text.startswith('---'):
51
+ end = text.find('---', 3)
52
+ text = text[end + 3:] if end != -1 else text
53
+ lines = text.strip().split('\n')
54
+ body_lines = []
55
+ for line in lines:
56
+ line = line.strip()
57
+ if line.startswith('# ') or line.startswith('> ') or line.startswith('---'):
58
+ continue
59
+ if line:
60
+ body_lines.append(line)
61
+ preview = ' '.join(body_lines)
62
+ if len(preview) > max_chars:
63
+ preview = preview[:max_chars] + '...'
64
+ return preview
65
+
66
+
67
+ def collect_articles(date_dir: str) -> dict:
68
+ """收集所有文章,按主题分组。"""
69
+ topics = {}
70
+ for topic in TOPIC_ORDER:
71
+ topic_dir = Path(date_dir) / topic
72
+ if not topic_dir.is_dir():
73
+ continue
74
+ articles = []
75
+ for md_file in sorted(topic_dir.glob('*.md')):
76
+ try:
77
+ content = md_file.read_text(encoding='utf-8')
78
+ except Exception:
79
+ continue
80
+ # Skip articles with empty body
81
+ body_part = content.split('## 正文')
82
+ if len(body_part) > 1:
83
+ body_text = body_part[1].strip()
84
+ body_text = ''.join(c for c in body_text if c not in ' \n\r\t')
85
+ if len(body_text) < 30:
86
+ continue
87
+ meta = parse_frontmatter(content)
88
+ preview = get_body_preview(content)
89
+ articles.append({
90
+ 'filename': md_file.name,
91
+ 'rel_path': f'{topic}/{md_file.name}',
92
+ 'title': meta.get('title', md_file.stem),
93
+ 'source': meta.get('source', ''),
94
+ 'topic': topic,
95
+ 'tags': meta.get('tags', []),
96
+ 'relevance': meta.get('relevance', ''),
97
+ 'url': meta.get('url', ''),
98
+ 'preview': preview,
99
+ })
100
+ if articles:
101
+ topics[topic] = articles
102
+ return topics
103
+
104
+
105
+ def escape_html(text: str) -> str:
106
+ return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
107
+
108
+
109
+ def generate_article_viewer(out_path: str):
110
+ """生成 article.html — Markdown 渲染阅读器。"""
111
+ html = r'''<!DOCTYPE html>
112
+ <html lang="zh-CN">
113
+ <head>
114
+ <meta charset="UTF-8">
115
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
116
+ <title>文章阅读</title>
117
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
118
+ <script>
119
+ // Simple MD5 implementation for image fallback
120
+ function md5(string) {
121
+ function md5cycle(x, k) {
122
+ var a = x[0], b = x[1], c = x[2], d = x[3];
123
+ a = ff(a, b, c, d, k[0], 7, -680876936);d = ff(d, a, b, c, k[1], 12, -389564586);c = ff(c, d, a, b, k[2], 17, 606105819);b = ff(b, c, d, a, k[3], 22, -1044525330);a = ff(a, b, c, d, k[4], 7, -176418897);d = ff(d, a, b, c, k[5], 12, 1200080426);c = ff(c, d, a, b, k[6], 17, -1473231341);b = ff(b, c, d, a, k[7], 22, -45705983);a = ff(a, b, c, d, k[8], 7, 1770035416);d = ff(d, a, b, c, k[9], 12, -1958414417);c = ff(c, d, a, b, k[10], 17, -42063);b = ff(b, c, d, a, k[11], 22, -1990404162);a = ff(a, b, c, d, k[12], 7, 1804603682);d = ff(d, a, b, c, k[13], 12, -40341101);c = ff(c, d, a, b, k[14], 17, -1502002290);b = ff(b, c, d, a, k[15], 22, 1236535329);a = gg(a, b, c, d, k[1], 5, -165796510);d = gg(d, a, b, c, k[6], 9, -1069501632);c = gg(c, d, a, b, k[11], 14, 643717713);b = gg(b, c, d, a, k[0], 20, -373897302);a = gg(a, b, c, d, k[5], 5, -701558691);d = gg(d, a, b, c, k[10], 9, 38016083);c = gg(c, d, a, b, k[15], 14, -660478335);b = gg(b, c, d, a, k[4], 20, -405537848);a = gg(a, b, c, d, k[9], 5, 568446438);d = gg(d, a, b, c, k[14], 9, -1019803690);c = gg(c, d, a, b, k[3], 14, -187363961);b = gg(b, c, d, a, k[8], 20, 1163531501);a = gg(a, b, c, d, k[13], 5, -1444681467);d = gg(d, a, b, c, k[2], 9, -51403784);c = gg(c, d, a, b, k[7], 14, 1735328473);b = gg(b, c, d, a, k[12], 20, -1926607734);a = hh(a, b, c, d, k[5], 4, -378558);d = hh(d, a, b, c, k[8], 11, -2022574463);c = hh(c, d, a, b, k[11], 16, 1839030562);b = hh(b, c, d, a, k[14], 23, -35309556);a = hh(a, b, c, d, k[1], 4, -1530992060);d = hh(d, a, b, c, k[4], 11, 1272893353);c = hh(c, d, a, b, k[7], 16, -155497632);b = hh(b, c, d, a, k[10], 23, -1094730640);a = hh(a, b, c, d, k[13], 4, 681279174);d = hh(d, a, b, c, k[0], 11, -358537222);c = hh(c, d, a, b, k[3], 16, -722521979);b = hh(b, c, d, a, k[6], 23, 76029189);a = hh(a, b, c, d, k[9], 4, -640364487);d = hh(d, a, b, c, k[12], 11, -421815835);c = hh(c, d, a, b, k[15], 16, 530742520);b = hh(b, c, d, a, k[2], 23, -995338651);a = ii(a, b, c, d, k[0], 6, -198630844);d = ii(d, a, b, c, k[7], 10, 1126891415);c = ii(c, d, a, b, k[14], 15, -1416354905);b = ii(b, c, d, a, k[5], 21, -57434055);a = ii(a, b, c, d, k[12], 6, 1700485571);d = ii(d, a, b, c, k[3], 10, -1894986606);c = ii(c, d, a, b, k[10], 15, -1051523);b = ii(b, c, d, a, k[1], 21, -2054922799);a = ii(a, b, c, d, k[8], 6, 1873313359);d = ii(d, a, b, c, k[15], 10, -30611744);c = ii(c, d, a, b, k[6], 15, -1560198380);b = ii(b, c, d, a, k[13], 21, 1309151649);a = ii(a, b, c, d, k[4], 6, -145523070);d = ii(d, a, b, c, k[11], 10, -1120210379);c = ii(c, d, a, b, k[2], 15, 718787259);b = ii(b, c, d, a, k[9], 21, -343485551);x[0] = add32(a, x[0]);x[1] = add32(b, x[1]);x[2] = add32(c, x[2]);x[3] = add32(d, x[3]);
124
+ }
125
+ function cmn(q, a, b, x, s, t) {a = add32(add32(a, q), add32(x, t));return add32((a << s) | (a >>> (32 - s)), b);}
126
+ function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t); }
127
+ function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t); }
128
+ function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); }
129
+ function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t); }
130
+ function md5blk(s) {var md5blks = [], i;for (i = 0; i < 64; i += 4) {md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24);}return md5blks;}
131
+ function add32(a, b) { return (a + b) & 0xFFFFFFFF; }
132
+ function rhex(n) {var s = '', j = 0;for (; j < 4; j++)s += '0123456789abcdef'.charAt((n >> (j * 8 + 4)) & 0x0F) + '0123456789abcdef'.charAt((n >> (j * 8)) & 0x0F);return s;}
133
+ function hex(x) {for (var i = 0; i < x.length; i++)x[i] = rhex(x[i]);return x.join('');}
134
+ var n = string.length, state = [1732584193, -271733879, -1732584194, 271733878], i;
135
+ for (i = 64; i <= n; i += 64) {md5cycle(state, md5blk(string.substring(i - 64, i)));}
136
+ string = string.substring(i - 64);
137
+ var tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];
138
+ for (i = 0; i < string.length; i++)tail[i >> 2] |= string.charCodeAt(i) << ((i % 4) << 3);
139
+ tail[i >> 2] |= 0x80 << ((i % 4) << 3);
140
+ if (i > 55) {md5cycle(state, tail);for (i = 0; i < 16; i++)tail[i] = 0;}
141
+ tail[14] = n * 8;
142
+ md5cycle(state, tail);
143
+ return hex(state);
144
+ }
145
+ </script>
146
+ <style>
147
+ * { margin: 0; padding: 0; box-sizing: border-box; }
148
+ body {
149
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
150
+ background: #0f172a;
151
+ color: #e2e8f0;
152
+ min-height: 100vh;
153
+ line-height: 1.8;
154
+ }
155
+ .container { max-width: 960px; margin: 0 auto; padding: 24px; }
156
+ .top-bar {
157
+ display: flex; align-items: center; gap: 12px; padding: 12px 0;
158
+ margin-bottom: 24px; border-bottom: 1px solid #1e293b;
159
+ position: sticky; top: 0; background: #0f172a; z-index: 10; flex-wrap: wrap;
160
+ }
161
+ .btn-back {
162
+ color: #94a3b8; text-decoration: none; font-size: 14px;
163
+ padding: 6px 14px; border: 1px solid #334155; border-radius: 8px;
164
+ transition: all .2s; white-space: nowrap;
165
+ }
166
+ .btn-back:hover { color: #e2e8f0; border-color: #475569; }
167
+ .article-source { color: #818cf8; font-size: 13px; font-weight: 600; }
168
+ .article-meta { color: #64748b; font-size: 12px; margin-left: auto; }
169
+ .mode-badge { font-size: 11px; padding: 2px 10px; border-radius: 10px; font-weight: 600; }
170
+ .mode-badge.html { background: #10b98120; color: #34d399; border: 1px solid #10b98140; }
171
+ .view-switch { display: inline-flex; background: #1e293b; border-radius: 8px; overflow: hidden; border: 1px solid #334155; }
172
+ .view-switch a { padding: 4px 12px; font-size: 12px; color: #64748b; text-decoration: none; transition: all .2s; }
173
+ .view-switch a.active { background: #334155; color: #e2e8f0; }
174
+ .view-switch a:hover:not(.active) { color: #94a3b8; }
175
+ .article-header { margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid #1e293b; }
176
+ .article-header h1 { font-size: 26px; font-weight: 700; line-height: 1.4; margin-bottom: 12px; color: #f1f5f9; }
177
+ .article-header .meta-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 13px; color: #64748b; }
178
+ .article-header .source-tag { background: #312e81; color: #a5b4fc; padding: 2px 10px; border-radius: 6px; font-size: 12px; }
179
+ .tags-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
180
+ .tag { font-size: 11px; padding: 2px 8px; background: #334155; border-radius: 6px; color: #94a3b8; }
181
+ .article-body { font-size: 16px; color: #cbd5e1; }
182
+ .article-body h2 { font-size: 22px; font-weight: 700; margin: 32px 0 16px; color: #f1f5f9; border-bottom: 1px solid #1e293b; padding-bottom: 8px; }
183
+ .article-body h3 { font-size: 18px; font-weight: 600; margin: 24px 0 12px; color: #e2e8f0; }
184
+ .article-body h4 { font-size: 16px; font-weight: 600; margin: 20px 0 8px; color: #cbd5e1; }
185
+ .article-body p { margin: 12px 0; }
186
+ .article-body strong { color: #f1f5f9; font-weight: 700; }
187
+ .article-body a { color: #818cf8; text-decoration: none; }
188
+ .article-body a:hover { text-decoration: underline; }
189
+ .article-body img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; border: 1px solid #334155; }
190
+ .article-body blockquote { border-left: 3px solid #818cf8; padding: 8px 16px; margin: 16px 0; background: #1a1040; border-radius: 0 8px 8px 0; color: #a5b4fc; }
191
+ .article-body blockquote p { margin: 4px 0; }
192
+ .article-body code { background: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 14px; color: #fbbf24; }
193
+ .article-body pre { background: #0c1222; border: 1px solid #1e293b; border-radius: 10px; padding: 16px; overflow-x: auto; margin: 16px 0; font-size: 13px; line-height: 1.7; }
194
+ .article-body pre code { background: none; padding: 0; color: #e2e8f0; }
195
+ .article-body ul, .article-body ol { margin: 12px 0; padding-left: 24px; }
196
+ .article-body li { margin: 6px 0; color: #cbd5e1; }
197
+ .article-body hr { border: none; border-top: 1px solid #1e293b; margin: 24px 0; }
198
+ .article-body table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
199
+ .article-body th { background: #1e293b; padding: 10px 14px; text-align: left; color: #e2e8f0; font-weight: 600; border-bottom: 2px solid #334155; }
200
+ .article-body td { padding: 8px 14px; border-bottom: 1px solid #1e293b; color: #94a3b8; }
201
+ .loading { text-align: center; padding: 80px 20px; color: #64748b; font-size: 16px; }
202
+ .error-box { background: #dc262610; border: 1px solid #dc262630; border-radius: 12px; padding: 20px; text-align: center; color: #f87171; margin: 40px 0; }
203
+ @media (max-width: 640px) {
204
+ .container { padding: 12px; }
205
+ .article-header h1 { font-size: 20px; }
206
+ .article-body { font-size: 15px; }
207
+ }
208
+ /* ---- Highlight & Notes ---- */
209
+ .article-body mark.hl { background: #fef08a; color: #1e1b4b; padding: 1px 0; border-radius: 2px; cursor: pointer; transition: all .2s; }
210
+ .article-body mark.hl:hover { background: #fde047; }
211
+ .article-body mark.hl.active { background: #fbbf24; box-shadow: 0 0 0 2px #fbbf2440; }
212
+ .selection-popup { position: absolute; z-index: 1000; background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 6px 10px; display: none; box-shadow: 0 8px 24px #00000040; }
213
+ .selection-popup button { background: #818cf8; color: #fff; border: none; padding: 6px 14px; border-radius: 7px; cursor: pointer; font-size: 13px; font-weight: 600; white-space: nowrap; }
214
+ .selection-popup button:hover { background: #6366f1; }
215
+ .note-modal-overlay { position: fixed; inset: 0; background: #00000060; z-index: 2000; display: flex; align-items: center; justify-content: center; }
216
+ .note-modal { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 24px; width: 90%; max-width: 480px; box-shadow: 0 16px 48px #00000060; }
217
+ .note-modal h3 { color: #e2e8f0; font-size: 16px; margin-bottom: 12px; }
218
+ .note-modal .hl-text { background: #1a1040; border: 1px solid #312e81; border-radius: 8px; padding: 10px 14px; color: #a5b4fc; font-size: 13px; margin-bottom: 14px; max-height: 100px; overflow-y: auto; line-height: 1.6; }
219
+ .note-modal textarea { width: 100%; height: 80px; background: #0f172a; border: 1px solid #334155; border-radius: 8px; color: #e2e8f0; padding: 10px 14px; font-size: 13px; resize: vertical; font-family: inherit; outline: none; }
220
+ .note-modal textarea:focus { border-color: #818cf8; }
221
+ .note-modal .btn-row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
222
+ .note-modal .btn-row button { padding: 7px 18px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
223
+ .note-modal .btn-save { background: #818cf8; color: #fff; }
224
+ .note-modal .btn-save:hover { background: #6366f1; }
225
+ .note-modal .btn-cancel { background: #334155; color: #94a3b8; }
226
+ .note-modal .btn-cancel:hover { background: #475569; }
227
+ .notes-sidebar { position: fixed; right: 0; top: 0; width: 340px; height: 100vh; background: #0f172a; border-left: 1px solid #1e293b; z-index: 500; overflow-y: auto; transform: translateX(100%); transition: transform .3s ease; }
228
+ .notes-sidebar.open { transform: translateX(0); }
229
+ .notes-sidebar-header { padding: 16px 20px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; background: #0f172a; z-index: 2; }
230
+ .notes-sidebar-header h3 { color: #e2e8f0; font-size: 15px; font-weight: 700; }
231
+ .notes-sidebar-header button { background: none; border: none; color: #64748b; font-size: 18px; cursor: pointer; }
232
+ .notes-sidebar-header button:hover { color: #e2e8f0; }
233
+ .note-item { padding: 14px 20px; border-bottom: 1px solid #1e293b; }
234
+ .note-item .hl-text { color: #fef08a; font-size: 13px; line-height: 1.6; margin-bottom: 8px; border-left: 3px solid #fbbf24; padding-left: 10px; }
235
+ .note-item .hl-note { color: #cbd5e1; font-size: 13px; line-height: 1.6; margin-bottom: 6px; }
236
+ .note-item .hl-meta { color: #475569; font-size: 11px; display: flex; align-items: center; gap: 10px; }
237
+ .note-item .hl-meta button { background: none; border: none; color: #dc5b5b; font-size: 11px; cursor: pointer; }
238
+ .note-item .hl-meta button:hover { color: #f87171; }
239
+ .notes-sidebar .empty { text-align: center; padding: 60px 20px; color: #475569; font-size: 14px; line-height: 2; }
240
+ .btn-notes-toggle { position: fixed; right: 16px; bottom: 24px; z-index: 501; background: #818cf8; color: #fff; border: none; width: 44px; height: 44px; border-radius: 12px; font-size: 18px; cursor: pointer; box-shadow: 0 4px 16px #818cf840; transition: all .2s; display: flex; align-items: center; justify-content: center; }
241
+ .btn-notes-toggle:hover { background: #6366f1; transform: scale(1.05); }
242
+ .btn-notes-toggle .badge { position: absolute; top: -4px; right: -4px; background: #ef4444; color: #fff; font-size: 10px; width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; font-weight: 700; }
243
+ @media (max-width: 640px) { .notes-sidebar { width: 100%; } }
244
+ </style>
245
+ </head>
246
+ <body>
247
+ <div class="container" id="app">
248
+ <div class="loading">&#x1f4d6; 加载中...</div>
249
+ </div>
250
+ <script>
251
+ (async function() {
252
+ const params = new URLSearchParams(location.search);
253
+ const file = params.get('file');
254
+ if (!file) {
255
+ document.getElementById('app').innerHTML = '<div class="error-box">\u274c \u7f3a\u5c11 file \u53c2\u6570</div>';
256
+ return;
257
+ }
258
+ let mdUrl = file;
259
+ if (!file.startsWith('http') && !file.startsWith('/')) { mdUrl = './' + file; }
260
+ try {
261
+ const res = await fetch(mdUrl);
262
+ if (!res.ok) throw new Error('HTTP ' + res.status);
263
+ const text = await res.text();
264
+ let title = '', source = '', tags = [], body = text;
265
+ if (text.startsWith('---')) {
266
+ const endIdx = text.indexOf('---', 3);
267
+ if (endIdx !== -1) {
268
+ const fm = text.slice(3, endIdx).trim();
269
+ body = text.slice(endIdx + 3).trim();
270
+ fm.split('\n').forEach(line => {
271
+ const ci = line.indexOf(':');
272
+ if (ci === -1) return;
273
+ const key = line.slice(0, ci).trim();
274
+ let val = line.slice(ci + 1).trim().replace(/^["']|["']$/g, '');
275
+ if (key === 'title') title = val;
276
+ if (key === 'source') source = val;
277
+ if (key === 'tags') {
278
+ try { tags = JSON.parse(val); } catch(e) {
279
+ tags = val.replace(/[\\[\\]]/g, '').split(',').map(s => s.trim().replace(/["']/g, ''));
280
+ }
281
+ }
282
+ });
283
+ }
284
+ }
285
+ if (!title) {
286
+ const h1m = body.match(/^#\s+(.+)/m);
287
+ if (h1m) { title = h1m[1]; body = body.replace(h1m[0], '').trim(); }
288
+ }
289
+ if (!title) title = file.split('/').pop().replace('.md', '');
290
+ let htmlContent = '';
291
+ if (typeof marked !== 'undefined') {
292
+ marked.setOptions({ breaks: true, gfm: true });
293
+ htmlContent = marked.parse(body);
294
+ } else {
295
+ htmlContent = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>');
296
+ htmlContent = '<p>' + htmlContent + '</p>';
297
+ }
298
+ // 本地模式:通过代理加载微信CDN图片,绕过防盗链
299
+ if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
300
+ htmlContent = htmlContent.replace(/src="(https?:\/\/[^"]*\.qpic\.cn\/[^"]+)"/g, function(match, url) {
301
+ // 还原 marked.js 的 HTML 转义
302
+ var realUrl = url.replace(/&amp;/g, '&');
303
+ // 计算本地图片路径(与 biz_daily.py 中的 hash 算法一致)
304
+ var hash = md5(realUrl).substring(0, 12);
305
+ var ext = '.jpg';
306
+ if (realUrl.indexOf('.png') !== -1 || realUrl.indexOf('wx_fmt=png') !== -1) ext = '.png';
307
+ else if (realUrl.indexOf('.gif') !== -1 || realUrl.indexOf('wx_fmt=gif') !== -1) ext = '.gif';
308
+ else if (realUrl.indexOf('.webp') !== -1 || realUrl.indexOf('wx_fmt=webp') !== -1) ext = '.webp';
309
+ var localPath = 'images/' + hash + ext;
310
+ // 优先用本地图片,不存在时走代理
311
+ if (window._IMG_MAP && window._IMG_MAP[realUrl]) {
312
+ return 'src="' + localPath + '"';
313
+ }
314
+ return 'src="/proxy?url=' + encodeURIComponent(realUrl) + '" onerror="this.onerror=null;this.src=\'' + localPath + '\'"';
315
+ });
316
+ }
317
+ const tagsHtml = tags.length ? '<div class="tags-row">' + tags.map(t => '<span class="tag">' + t + '</span>').join('') + '</div>' : '';
318
+ document.getElementById('app').innerHTML =
319
+ '<div class="top-bar">' +
320
+ '<a href="./" class="btn-back">\u2190 \u8fd4\u56de\u5217\u8868</a>' +
321
+ '<span class="article-source">' + (source || '') + '</span>' +
322
+ '<span class="article-meta">\u9605\u8bfb\u6a21\u5f0f</span>' +
323
+ '<span class="mode-badge html">\ud83c\udfa8 HTML</span>' +
324
+ '<div class="view-switch">' +
325
+ '<a href="' + file + '">\ud83d\udcdd MD</a>' +
326
+ '<a href="article.html?file=' + encodeURIComponent(file) + '" class="active">\ud83c\udfa8 HTML</a>' +
327
+ '</div>' +
328
+ '</div>' +
329
+ '<div class="article-header">' +
330
+ '<h1>' + title + '</h1>' +
331
+ '<div class="meta-row">' + (source ? '<span class="source-tag">' + source + '</span>' : '') + '</div>' +
332
+ tagsHtml +
333
+ '</div>' +
334
+ '<div class="article-body">' + htmlContent + '</div>' +
335
+ '<div style="text-align:center;padding:40px;color:#475569;font-size:12px;">\u2014 END \u2014</div>' +
336
+ '<div class="notes-sidebar" id="notes-sidebar">' +
337
+ '<div class="notes-sidebar-header"><h3>\ud83d\udcdd \u7b14\u8bb0</h3><button onclick="toggleNotes()">\u2715</button></div>' +
338
+ '<div id="notes-list"><div class="empty">\u9009\u4e2d\u6587\u5b57\u540e\u70b9\u51fb\u201c\u6807\u8bb0\u7b14\u8bb0\u201d\u5373\u53ef\u6dfb\u52a0</div></div>' +
339
+ '</div>' +
340
+ '<button class="btn-notes-toggle" id="btn-notes-toggle" onclick="toggleNotes()" title="\u7b14\u8bb0">\ud83d\udcdd<span class="badge" id="notes-badge" style="display:none">0</span></button>';
341
+ } catch(e) {
342
+ document.getElementById('app').innerHTML =
343
+ '<div class="error-box">\u274c \u52a0\u8f7d\u5931\u8d25: ' + e.message + '<br><br><a href="./" class="btn-back">\u2190 \u8fd4\u56de\u5217\u8868</a></div>';
344
+ }
345
+ })();
346
+
347
+ // ---- Notes / Highlight Feature ----
348
+ const NOTES_API = (location.hostname === 'localhost' || location.hostname === '127.0.0.1') ? '/api/notes' : null;
349
+ const ARTICLE_ID = file;
350
+ let allHighlights = [];
351
+ let hlPopup = null, hlModal = null;
352
+
353
+ function initNotesUI() {
354
+ hlPopup = document.createElement('div');
355
+ hlPopup.className = 'selection-popup';
356
+ hlPopup.innerHTML = '<button onclick="startNote()">\ud83d\udcdd \u6807\u8bb0\u7b14\u8bb0</button>';
357
+ document.body.appendChild(hlPopup);
358
+
359
+ document.addEventListener('mouseup', (e) => {
360
+ setTimeout(() => {
361
+ const sel = window.getSelection();
362
+ const text = sel ? sel.toString().trim() : '';
363
+ if (!text || text.length < 2 || text.length > 500) {
364
+ hlPopup.style.display = 'none';
365
+ return;
366
+ }
367
+ const range = sel.getRangeAt(0);
368
+ const rect = range.getBoundingClientRect();
369
+ hlPopup.style.display = 'block';
370
+ hlPopup.style.left = Math.min(rect.left + rect.width/2 - 50, window.innerWidth - 180) + 'px';
371
+ hlPopup.style.top = (rect.bottom + window.scrollY + 8) + 'px';
372
+ }, 10);
373
+ });
374
+
375
+ document.addEventListener('mousedown', (e) => {
376
+ if (!hlPopup.contains(e.target)) hlPopup.style.display = 'none';
377
+ });
378
+
379
+ if (NOTES_API) loadHighlights();
380
+ }
381
+
382
+ function startNote() {
383
+ const sel = window.getSelection();
384
+ const text = sel ? sel.toString().trim() : '';
385
+ if (!text) return;
386
+ hlPopup.style.display = 'none';
387
+ if (hlModal) hlModal.remove();
388
+
389
+ hlModal = document.createElement('div');
390
+ hlModal.className = 'note-modal-overlay';
391
+ hlModal.innerHTML =
392
+ '<div class="note-modal">' +
393
+ '<h3>\ud83d\udcdd \u6dfb\u52a0\u7b14\u8bb0</h3>' +
394
+ '<div class="hl-text">' + text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div>' +
395
+ '<textarea id="note-input" placeholder="\u5199\u4e0b\u4f60\u7684\u611f\u60f3\u6216\u5907\u6ce8\uff08\u53ef\u9009\uff09"></textarea>' +
396
+ '<div class="btn-row">' +
397
+ '<button class="btn-cancel" onclick="this.closest(\'.note-modal-overlay\').remove()">\u53d6\u6d88</button>' +
398
+ '<button class="btn-save" onclick="saveNote()">\u4fdd\u5b58</button>' +
399
+ '</div>' +
400
+ '</div>';
401
+ document.body.appendChild(hlModal);
402
+ setTimeout(() => document.getElementById('note-input').focus(), 100);
403
+ }
404
+
405
+ async function saveNote() {
406
+ const noteText = document.getElementById('note-input').value.trim();
407
+ const sel = window.getSelection();
408
+ const hlText = sel ? sel.toString().trim() : '';
409
+ if (!hlText) return;
410
+ if (!NOTES_API) {
411
+ const local = JSON.parse(localStorage.getItem('weflow_hl_' + ARTICLE_ID) || '[]');
412
+ local.push({id: Date.now().toString(36), text: hlText, note: noteText, created: new Date().toLocaleString()});
413
+ localStorage.setItem('weflow_hl_' + ARTICLE_ID, JSON.stringify(local));
414
+ hlModal.remove();
415
+ loadHighlights();
416
+ return;
417
+ }
418
+ try {
419
+ const res = await fetch(NOTES_API, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({article: ARTICLE_ID, text: hlText, note: noteText})});
420
+ await res.json();
421
+ hlModal.remove();
422
+ loadHighlights();
423
+ } catch(e) { alert('\u4fdd\u5b58\u5931\u8d25: ' + e.message); }
424
+ }
425
+
426
+ async function loadHighlights() {
427
+ if (NOTES_API) {
428
+ try {
429
+ const res = await fetch(NOTES_API + '?article=' + encodeURIComponent(ARTICLE_ID));
430
+ allHighlights = await res.json();
431
+ } catch(e) { allHighlights = []; }
432
+ } else {
433
+ allHighlights = JSON.parse(localStorage.getItem('weflow_hl_' + ARTICLE_ID) || '[]');
434
+ }
435
+ renderHighlights();
436
+ renderNotesList();
437
+ }
438
+
439
+ function renderHighlights() {
440
+ document.querySelectorAll('.article-body mark.hl').forEach(m => {
441
+ const parent = m.parentNode;
442
+ parent.replaceChild(document.createTextNode(m.textContent), m);
443
+ parent.normalize();
444
+ });
445
+ if (allHighlights.length === 0) return;
446
+
447
+ const body = document.querySelector('.article-body');
448
+ if (!body) return;
449
+
450
+ // 收集所有文本节点及其在拼接字符串中的偏移
451
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false);
452
+ const textNodes = [];
453
+ let node;
454
+ while (node = walker.nextNode()) {
455
+ if (node.textContent.length > 0) textNodes.push(node);
456
+ }
457
+
458
+ // 构建拼接字符串和偏移映射
459
+ let fullText = '';
460
+ const offsets = []; // [{node, start, end}]
461
+ textNodes.forEach(tn => {
462
+ offsets.push({node: tn, start: fullText.length, end: fullText.length + tn.textContent.length});
463
+ fullText += tn.textContent;
464
+ });
465
+
466
+ // 对每个高亮,在拼接字符串中查找并跨节点标记
467
+ allHighlights.forEach(hl => {
468
+ const searchText = hl.text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
469
+ // 尝试精确匹配,再尝试去除空白差异的模糊匹配
470
+ let idx = fullText.indexOf(searchText);
471
+ if (idx < 0) {
472
+ // 模糊匹配:将搜索文本和全文的连续空白归一化为单空格
473
+ const normFull = fullText.replace(/\s+/g, ' ');
474
+ const normSearch = searchText.replace(/\s+/g, ' ');
475
+ idx = normFull.indexOf(normSearch);
476
+ if (idx < 0) return; // 找不到,跳过
477
+ // 需要从 normFull 的 idx 映射回 fullText 的真实 idx
478
+ let fi = 0, ni = 0;
479
+ while (ni < idx) {
480
+ if (/\s/.test(fullText[fi])) {
481
+ while (fi < fullText.length && /\s/.test(fullText[fi])) fi++;
482
+ ni++;
483
+ } else {
484
+ fi++; ni++;
485
+ }
486
+ }
487
+ idx = fi;
488
+ }
489
+ const hlEnd = idx + searchText.length;
490
+
491
+ // 找出涉及的文本节点并逐段标记
492
+ for (const off of offsets) {
493
+ if (off.end <= idx || off.start >= hlEnd) continue; // 不重叠
494
+ const segStart = Math.max(idx, off.start) - off.start;
495
+ const segEnd = Math.min(hlEnd, off.end) - off.start;
496
+ try {
497
+ const range = document.createRange();
498
+ range.setStart(off.node, segStart);
499
+ range.setEnd(off.node, segEnd);
500
+ const mark = document.createElement('mark');
501
+ mark.className = 'hl';
502
+ mark.setAttribute('data-hid', hl.id);
503
+ mark.title = hl.note || hl.text;
504
+ mark.onclick = function(e) {
505
+ if (hl.note) alert('\u7b14\u8bb0: ' + hl.note);
506
+ };
507
+ range.surroundContents(mark);
508
+ } catch(e) {
509
+ // 跨元素选区可能失败,静默跳过
510
+ }
511
+ }
512
+ });
513
+ }
514
+
515
+ function renderNotesList() {
516
+ const container = document.getElementById('notes-list');
517
+ const badge = document.getElementById('notes-badge');
518
+ if (!container) return;
519
+ if (allHighlights.length === 0) {
520
+ container.innerHTML = '<div class="empty">\u9009\u4e2d\u6587\u5b57\u540e\u70b9\u51fb\u201c\u6807\u8bb0\u7b14\u8bb0\u201d\u5373\u53ef\u6dfb\u52a0</div>';
521
+ if (badge) badge.style.display = 'none';
522
+ return;
523
+ }
524
+ if (badge) { badge.style.display = ''; badge.textContent = allHighlights.length; }
525
+ container.innerHTML = allHighlights.map(hl =>
526
+ '<div class="note-item">' +
527
+ '<div class="hl-text">' + hl.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div>' +
528
+ (hl.note ? '<div class="hl-note">\ud83d\udcac ' + hl.note.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div>' : '') +
529
+ '<div class="hl-meta"><span>' + (hl.created || '') + '</span>' +
530
+ '<button onclick="deleteNote(\'' + hl.id + '\')">\u5220\u9664</button>' +
531
+ '</div>' +
532
+ '</div>'
533
+ ).join('');
534
+ }
535
+
536
+ async function deleteNote(hid) {
537
+ if (!confirm('\u5220\u9664\u8fd9\u6761\u7b14\u8bb0\uff1f')) return;
538
+ if (NOTES_API) {
539
+ try {
540
+ await fetch(NOTES_API + '/delete', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({article: ARTICLE_ID, id: hid})});
541
+ } catch(e) {}
542
+ } else {
543
+ allHighlights = allHighlights.filter(h => h.id !== hid);
544
+ localStorage.setItem('weflow_hl_' + ARTICLE_ID, JSON.stringify(allHighlights));
545
+ }
546
+ loadHighlights();
547
+ }
548
+
549
+ function toggleNotes() {
550
+ const sidebar = document.getElementById('notes-sidebar');
551
+ if (sidebar) sidebar.classList.toggle('open');
552
+ }
553
+
554
+ setTimeout(initNotesUI, 500);
555
+ </script>
556
+ </body>
557
+ </html>'''
558
+ with open(out_path, 'w', encoding='utf-8') as f:
559
+ f.write(html)
560
+
561
+
562
+ def generate_html(date_str: str, topics: dict, action_suggestions_exist: bool, briefing: str = '') -> str:
563
+ """生成完整 HTML。"""
564
+ total = sum(len(v) for v in topics.values())
565
+
566
+ # 构建文章卡片 JSON
567
+ articles_json = []
568
+ for topic in TOPIC_ORDER:
569
+ if topic not in topics:
570
+ continue
571
+ for art in topics[topic]:
572
+ articles_json.append({
573
+ 'id': art['rel_path'],
574
+ 'topic': topic,
575
+ })
576
+
577
+ # 构建分类导航
578
+ nav_items = []
579
+ for topic in TOPIC_ORDER:
580
+ if topic not in topics:
581
+ continue
582
+ count = len(topics[topic])
583
+ _, color, icon = TOPIC_LABELS[topic]
584
+ nav_items.append(f'''
585
+ <button class="tab-btn active" data-topic="{topic}"
586
+ style="--accent: {color}" onclick="switchTab('{topic}')">
587
+ {icon} {topic} <span class="count">{count}</span>
588
+ </button>''')
589
+
590
+ # 构建文章列表
591
+ topic_sections = []
592
+ for topic in TOPIC_ORDER:
593
+ if topic not in topics:
594
+ continue
595
+ _, color, icon = TOPIC_LABELS[topic]
596
+ cards = []
597
+ for art in topics[topic]:
598
+ all_tags = art['tags'] or []
599
+ shown_tags = all_tags[:4]
600
+ hidden_count = len(all_tags) - 4
601
+ tags_html = ''.join(f'<span class="tag">{escape_html(t)}</span>' for t in shown_tags)
602
+ if hidden_count > 0:
603
+ tags_html += f'<span class="tag tag-more">+{hidden_count}</span>'
604
+ relevance_badge = ''
605
+ if art.get('relevance') == '高':
606
+ relevance_badge = '<span class="relevance high">高相关</span>'
607
+ elif art.get('relevance') == '中':
608
+ relevance_badge = '<span class="relevance mid">中相关</span>'
609
+
610
+ cards.append(f'''
611
+ <article class="card" data-id="{escape_html(art['rel_path'])}" data-topic="{topic}">
612
+ <div class="card-header">
613
+ <div class="card-title-row">
614
+ <span class="unread-dot" id="dot-{escape_html(art['rel_path']).replace('/', '_').replace('.', '_')}"></span>
615
+ <h3 class="card-title">
616
+ <a href="{escape_html(art['rel_path'])}"
617
+ onclick="markRead('{escape_html(art['rel_path'])}')">
618
+ {escape_html(art['title'])}
619
+ </a>
620
+ </h3>
621
+ {relevance_badge}
622
+ </div>
623
+ <div class="card-meta">
624
+ <span class="source">{escape_html(art['source'])}</span>
625
+ </div>
626
+ </div>
627
+ <div class="card-tags">{tags_html}</div>
628
+ <p class="card-preview">{escape_html(art['preview'])}</p>
629
+ <div class="card-actions">
630
+ <a href="{escape_html(art['rel_path'])}"
631
+ class="btn-read" onclick="markRead('{escape_html(art['rel_path'])}')">
632
+ 📖 阅读
633
+ </a>
634
+ <button class="btn-toggle" onclick="toggleRead('{escape_html(art['rel_path'])}')">
635
+ <span class="toggle-label">✓ 标记已读</span>
636
+ </button>
637
+ <button class="btn-fav" onclick="toggleFav('{escape_html(art['rel_path'])}')" title="收藏">
638
+
639
+ </button>
640
+ </div>
641
+ </article>''')
642
+ sections_html = '\n'.join(cards)
643
+
644
+ topic_sections.append(f'''
645
+ <section class="topic-section" id="section-{topic}" data-topic="{topic}">
646
+ <h2 class="topic-heading" style="--accent: {color}">
647
+ {icon} {topic} <span class="count">{len(topics[topic])} 篇</span>
648
+ </h2>
649
+ <div class="cards-grid">
650
+ {sections_html}
651
+ </div>
652
+ </section>''')
653
+
654
+ action_link = ''
655
+ if action_suggestions_exist:
656
+ action_link = '''
657
+ <a href="./行动建议.md" target="_blank" class="action-link">📋 行动建议</a>
658
+ '''
659
+
660
+ briefing_html = ''
661
+ if briefing:
662
+ briefing_html = f'''
663
+ <div class="briefing">
664
+ <div class="briefing-title">📋 今日简报</div>
665
+ <div class="briefing-text">{escape_html(briefing)}</div>
666
+ </div>'''
667
+
668
+ html = f'''<!DOCTYPE html>
669
+ <html lang="zh-CN">
670
+ <head>
671
+ <meta charset="UTF-8">
672
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
673
+ <title>公众号日报 — {date_str}</title>
674
+ <style>
675
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
676
+ body {{
677
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
678
+ background: #f5f0e8;
679
+ color: #3c3a38;
680
+ min-height: 100vh;
681
+ -webkit-font-smoothing: antialiased;
682
+ }}
683
+ .container {{ max-width: 1400px; margin: 0 auto; padding: 28px 32px; }}
684
+
685
+ /* ---- Header ---- */
686
+ .header {{
687
+ text-align: center;
688
+ padding: 44px 20px 28px;
689
+ margin-bottom: 24px;
690
+ position: relative;
691
+ }}
692
+ .header::after {{
693
+ content: '';
694
+ position: absolute;
695
+ bottom: 0; left: 50%; transform: translateX(-50%);
696
+ width: 60px; height: 3px;
697
+ background: linear-gradient(90deg, #8b6914, #c8963e, #d4a853);
698
+ border-radius: 2px;
699
+ }}
700
+ .header h1 {{
701
+ font-size: 28px; font-weight: 800; letter-spacing: -.3px;
702
+ color: #4a3728;
703
+ }}
704
+ .header .meta {{
705
+ margin-top: 8px; color: #8b7355; font-size: 14px;
706
+ display: flex; align-items: center; justify-content: center; gap: 14px; flex-wrap: wrap;
707
+ }}
708
+ .action-link {{
709
+ color: #8b6914; text-decoration: none; font-weight: 600;
710
+ padding: 5px 14px; border: 1px solid #c8963e40; border-radius: 20px;
711
+ font-size: 13px; transition: all .25s; background: #faf6ef;
712
+ }}
713
+ .action-link:hover {{ background: #c8963e15; border-color: #c8963e70; }}
714
+
715
+ /* ---- Stats Bar ---- */
716
+ .stats-bar {{
717
+ display: flex; align-items: center; justify-content: center; gap: 10px;
718
+ flex-wrap: wrap; margin-bottom: 22px; padding: 10px 22px;
719
+ background: #faf6ef; border: 1px solid #d4c5a060; border-radius: 12px;
720
+ font-size: 13px; color: #8b7355;
721
+ }}
722
+ .stats-bar strong {{ color: #3c3a38; font-weight: 700; }}
723
+ .stats-group {{
724
+ display: flex; align-items: center; gap: 8px;
725
+ }}
726
+ .btn-clear {{
727
+ background: #f0e8d5; color: #5c4a32; border: 1px solid #d4c5a0;
728
+ padding: 5px 14px; border-radius: 8px; cursor: pointer;
729
+ font-size: 12px; font-weight: 500; transition: all .2s;
730
+ }}
731
+ .btn-clear:hover {{ background: #e8dcc0; border-color: #c8963e; color: #3c3a38; }}
732
+ .btn-clear.active-mode {{
733
+ background: #8b6914 !important; border-color: #8b6914 !important; color: #fff !important;
734
+ }}
735
+
736
+ /* ---- Tabs ---- */
737
+ .tabs {{
738
+ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 24px;
739
+ position: sticky; top: 12px; z-index: 10; padding: 6px;
740
+ background: #faf6eff2; backdrop-filter: blur(12px);
741
+ border: 1px solid #d4c5a060; border-radius: 12px;
742
+ }}
743
+ .tab-btn {{
744
+ background: transparent; color: #8b7355; border: none;
745
+ padding: 9px 18px; border-radius: 10px; cursor: pointer;
746
+ font-size: 14px; font-weight: 500; transition: all .2s;
747
+ }}
748
+ .tab-btn:hover {{ color: #3c3a38; background: #f0e8d5; }}
749
+ .tab-btn.active {{
750
+ color: #2a2018; background: #dcc89a;
751
+ box-shadow: 0 1px 4px #00000018, inset 0 1px 0 #fff8;
752
+ font-weight: 700;
753
+ border: 1px solid #c8963e30;
754
+ }}
755
+ .tab-btn .count {{
756
+ background: #d4c5a040; padding: 2px 8px; border-radius: 8px;
757
+ font-size: 12px; margin-left: 5px; font-weight: 600;
758
+ }}
759
+ .tab-btn.active .count {{ background: #c8963e20; color: #8b6914; }}
760
+
761
+ /* ---- Topic Sections ---- */
762
+ .topic-section {{ display: none; }}
763
+ .topic-section.active {{ display: block; }}
764
+ .topic-heading {{
765
+ font-size: 18px; font-weight: 700; margin-bottom: 16px;
766
+ display: flex; align-items: center; gap: 10px; color: #4a3728;
767
+ }}
768
+ .topic-heading::after {{
769
+ content: ''; flex: 1; height: 1px; background: #d4c5a0; margin-left: 8px;
770
+ }}
771
+ .topic-heading .count {{ font-size: 13px; color: #8b7355; font-weight: 400; }}
772
+
773
+ /* ---- Cards ---- */
774
+ .cards-grid {{ display: flex; flex-direction: column; gap: 10px; }}
775
+ .card {{
776
+ background: #fefcf8; border-radius: 12px; padding: 20px 24px;
777
+ border: 1px solid #d4c5a0; transition: all .25s ease;
778
+ position: relative; overflow: hidden;
779
+ }}
780
+ .card::before {{
781
+ content: ''; position: absolute; top: 0; left: 0; width: 3px; height: 100%;
782
+ background: transparent; transition: background .3s; border-radius: 12px 0 0 12px;
783
+ }}
784
+ .card:hover {{
785
+ border-color: #c8963e50; background: #fffdf7;
786
+ transform: translateY(-1px); box-shadow: 0 3px 16px #3c3a3810;
787
+ }}
788
+ .card:hover::before {{ background: #c8963e; }}
789
+ .card.read {{
790
+ opacity: 0.55; background: #f5f0e8; border-color: #d4c5a030;
791
+ }}
792
+ .card.read:hover {{ opacity: 0.7; border-color: #d4c5a060; transform: none; box-shadow: none; }}
793
+ .card.read:hover::before {{ background: #b8a080; }}
794
+ .card.faved {{ border-color: #c8963e40; background: #fffcf5; }}
795
+
796
+ .card-header {{ margin-bottom: 8px; }}
797
+ .card-title-row {{ display: flex; align-items: flex-start; gap: 10px; }}
798
+ .unread-dot {{
799
+ width: 9px; height: 9px; border-radius: 50%;
800
+ background: #c8963e; flex-shrink: 0; margin-top: 5px; transition: all .3s;
801
+ box-shadow: 0 0 6px #c8963e40;
802
+ }}
803
+ .card.read .unread-dot {{ background: #b8a080; box-shadow: none; }}
804
+ .card-title {{
805
+ font-size: 18px; font-weight: 700; flex: 1; line-height: 1.4; letter-spacing: -.3px;
806
+ }}
807
+ .card-title a {{ color: #2a2018; text-decoration: none; transition: color .2s; }}
808
+ .card-title a:hover {{ color: #8b6914; text-decoration: underline; text-underline-offset: 3px; }}
809
+ .card.read .card-title a {{ color: #8b7355; }}
810
+
811
+ .card-meta {{ display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 13px; color: #8b7355; font-weight: 500; }}
812
+ .card-meta .source {{
813
+ color: #6b4c1e; font-weight: 600;
814
+ }}
815
+
816
+ .relevance {{
817
+ font-size: 10px; padding: 3px 10px; border-radius: 12px; font-weight: 700;
818
+ flex-shrink: 0; letter-spacing: .3px;
819
+ }}
820
+ .relevance.high {{ background: #dc5b5b15; color: #b04444; border: 1px solid #dc5b5b30; }}
821
+ .relevance.mid {{ background: #c8963e15; color: #8b6914; border: 1px solid #c8963e40; }}
822
+
823
+ .card-tags {{ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }}
824
+ .tag {{
825
+ font-size: 11px; padding: 3px 10px; background: #f0e8d5; border-radius: 6px;
826
+ color: #8b7355; font-weight: 500; letter-spacing: .2px;
827
+ }}
828
+ .tag-more {{
829
+ background: #ede0c8; color: #96876a; font-weight: 600; cursor: default;
830
+ }}
831
+
832
+ .card-preview {{
833
+ font-size: 13px; color: #a0987a; line-height: 1.65; margin-bottom: 12px;
834
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
835
+ }}
836
+
837
+ /* ---- Card Actions ---- */
838
+ .card-actions {{ display: flex; gap: 8px; align-items: center; justify-content: flex-end; }}
839
+ .btn-read {{
840
+ font-size: 13px; color: #fff; text-decoration: none;
841
+ padding: 6px 18px; border: none; border-radius: 7px;
842
+ transition: all .2s; font-weight: 600;
843
+ background: linear-gradient(135deg, #8b6914, #a07828);
844
+ box-shadow: 0 1px 3px #8b691420;
845
+ }}
846
+ .btn-read:hover {{ background: linear-gradient(135deg, #6b4c1e, #8b6914); box-shadow: 0 2px 8px #8b691430; color: #fff; }}
847
+ .btn-toggle {{
848
+ font-size: 12px; color: #8b7355; background: none; border: 1px solid #d4c5a0;
849
+ padding: 5px 14px; border-radius: 7px; cursor: pointer;
850
+ transition: all .2s; font-weight: 500;
851
+ }}
852
+ .btn-toggle:hover {{ color: #3c3a38; border-color: #b8a080; }}
853
+ .card.read .btn-toggle {{ color: #6b8f5e; border-color: #6b8f5e30; }}
854
+ .card.read .toggle-label::before {{ content: '↩ 标记未读'; }}
855
+ .toggle-label::before {{ content: '✓ 标记已读'; }}
856
+
857
+ /* ---- Favorite ---- */
858
+ .btn-fav {{
859
+ font-size: 14px; background: none; border: 1px solid #d4c5a0;
860
+ padding: 4px 10px; border-radius: 7px; cursor: pointer;
861
+ transition: all .2s; color: #8b7355; line-height: 1;
862
+ }}
863
+ .btn-fav:hover {{ color: #c8963e; border-color: #c8963e60; }}
864
+ .btn-fav.faved {{ color: #c8963e; border-color: #c8963e50; background: #c8963e10; }}
865
+ .fav-empty {{
866
+ text-align: center; padding: 60px 20px; color: #96876a;
867
+ font-size: 15px; line-height: 2;
868
+ }}
869
+
870
+ /* ---- Search ---- */
871
+ .search-box {{
872
+ width: 100%; max-width: 480px; margin: 0 auto 20px; display: block;
873
+ padding: 11px 18px; border-radius: 12px; border: 1px solid #d4c5a0;
874
+ background: #fdfaf5; color: #3c3a38; font-size: 14px; outline: none;
875
+ transition: all .25s; font-family: inherit;
876
+ }}
877
+ .search-box:focus {{ border-color: #c8963e; box-shadow: 0 0 0 3px #c8963e15; }}
878
+ .search-box::placeholder {{ color: #b8a080; }}
879
+
880
+ /* ---- Footer ---- */
881
+ .footer {{
882
+ text-align: center; padding: 32px 20px; color: #b8a080;
883
+ font-size: 12px; margin-top: 24px; border-top: 1px solid #d4c5a050;
884
+ }}
885
+
886
+ /* ---- Responsive ---- */
887
+ @media (max-width: 640px) {{
888
+ .container {{ padding: 14px; }}
889
+ .header {{ padding: 28px 14px 22px; }}
890
+ .header h1 {{ font-size: 22px; }}
891
+ .card {{ padding: 14px 16px; }}
892
+ .tabs {{ gap: 2px; padding: 4px; }}
893
+ .tab-btn {{ padding: 7px 11px; font-size: 13px; }}
894
+ .card-title {{ font-size: 14px; }}
895
+ }}
896
+ </style>
897
+ </head>
898
+ <body>
899
+ <div class="container">
900
+
901
+ <div class="header">
902
+ <h1>📋 公众号日报 — {date_str}</h1>
903
+ <div class="meta">
904
+ <span>共 <strong>{total}</strong> 篇文章 {', '.join(f'{TOPIC_LABELS[t][2]} {len(topics[t])}' for t in TOPIC_ORDER if t in topics)}</span>
905
+ {action_link}
906
+ <span style="font-size:12px;color:#b8a080;display:flex;align-items:center;gap:6px">
907
+ <button class="btn-clear" id="btn-mode-md" onclick="setViewMode('md')" style="font-size:11px;padding:3px 10px">📝</button>
908
+ <button class="btn-clear" id="btn-mode-html" onclick="setViewMode('html')" style="font-size:11px;padding:3px 10px">🎨</button>
909
+ <button class="btn-clear" onclick="exportFav()" id="btn-export-fav" style="font-size:11px;padding:3px 10px">📥</button>
910
+ </span>
911
+ </div>
912
+ </div>
913
+
914
+ <div class="stats-bar">
915
+ <span class="stats-group">
916
+ <span>📊 <strong id="unread-count">-</strong> 篇未读 / {total} 篇</span>
917
+ <button class="btn-clear" id="btn-filter-unread" onclick="toggleUnreadFilter()" style="font-weight:600">👁 仅看未读</button>
918
+ </span>
919
+ <span class="stats-group">
920
+ <button class="btn-clear" onclick="markAllRead()">✅ 全部标为已读</button>
921
+ <button class="btn-clear" onclick="clearAll()">🔄 重置</button>
922
+ </span>
923
+ <span class="stats-group">
924
+ <span id="sync-indicator" style="display:none;font-size:12px;color:#6b8f5e;font-weight:600">🟢 已同步</span>
925
+ </span>
926
+
927
+ <input type="text" class="search-box" placeholder="🔍 搜索文章标题、来源、标签..." oninput="doSearch(this.value)">
928
+
929
+ <nav class="tabs">
930
+ {''.join(nav_items)}
931
+ <button class="tab-btn" data-topic="收藏"
932
+ style="--accent: #fbbf24" onclick="switchTab('收藏')">
933
+ ⭐ 收藏 <span class="count" id="fav-count">0</span>
934
+ </button>
935
+ </nav>
936
+
937
+ <section class="topic-section" id="section-收藏" data-topic="收藏">
938
+ <h2 class="topic-heading" style="--accent: #fbbf24">
939
+ ⭐ 收藏 <span class="count" id="fav-section-count">0 篇</span>
940
+ </h2>
941
+ <div class="cards-grid" id="fav-cards">
942
+ <div class="fav-empty">⭐ 点击文章卡片上的 ☆ 按钮即可收藏<br>收藏的文章会显示在这里</div>
943
+ </div>
944
+ </section>
945
+
946
+ {''.join(topic_sections)}
947
+
948
+ <div class="footer">
949
+ 生成于 {datetime.now().strftime('%Y-%m-%d %H:%M')} · 阅读状态保存在浏览器中
950
+ </div>
951
+
952
+ </div>
953
+
954
+ <script>
955
+ const STORAGE_KEY = 'weflow_read_{date_str}';
956
+ const FAV_STORAGE_KEY = 'weflow_fav_{date_str}';
957
+ const ALL_IDS = {json.dumps([a['id'] for a in articles_json], ensure_ascii=False)};
958
+
959
+ function getState() {{
960
+ try {{
961
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{{}}');
962
+ }} catch(e) {{ return {{}}; }}
963
+ }}
964
+ function saveState(state) {{
965
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
966
+ }}
967
+ function isRead(id) {{
968
+ return !!getState()[id];
969
+ }}
970
+ function markRead(id) {{
971
+ const state = getState();
972
+ state[id] = true;
973
+ saveState(state);
974
+ applyState();
975
+ if (IS_LOCALHOST) fetch('/api/read/toggle', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{id}})}}).catch(()=>{{}});
976
+ }}
977
+ function markUnread(id) {{
978
+ const state = getState();
979
+ delete state[id];
980
+ saveState(state);
981
+ applyState();
982
+ if (IS_LOCALHOST) fetch('/api/read/toggle', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{id}})}}).catch(()=>{{}});
983
+ }}
984
+ function toggleRead(id) {{
985
+ if (isRead(id)) markUnread(id);
986
+ else markRead(id);
987
+ }}
988
+ function markAllRead() {{
989
+ const state = {{}};
990
+ ALL_IDS.forEach(id => state[id] = true);
991
+ saveState(state);
992
+ applyState();
993
+ }}
994
+ function clearAll() {{
995
+ if (confirm('确定要重置所有阅读标记?')) {{
996
+ localStorage.removeItem(STORAGE_KEY);
997
+ applyState();
998
+ }}
999
+ }}
1000
+ function applyState() {{
1001
+ let unread = 0;
1002
+ ALL_IDS.forEach(id => {{
1003
+ const card = document.querySelector(`.card[data-id="${{id.replace(/"/g, '&quot;')}}"]`);
1004
+ if (!card) return;
1005
+ if (isRead(id)) {{
1006
+ card.classList.add('read');
1007
+ }} else {{
1008
+ card.classList.remove('read');
1009
+ unread++;
1010
+ }}
1011
+ }});
1012
+ const el = document.getElementById('unread-count');
1013
+ if (el) el.textContent = unread;
1014
+ }}
1015
+ function switchTab(topic) {{
1016
+ document.querySelectorAll('.topic-section').forEach(s => s.classList.remove('active'));
1017
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
1018
+ if (topic === '收藏') {{
1019
+ renderFavSection();
1020
+ }}
1021
+ const section = document.getElementById('section-' + topic);
1022
+ if (section) section.classList.add('active');
1023
+ const btn = document.querySelector(`.tab-btn[data-topic="${{topic}}"]`);
1024
+ if (btn) btn.classList.add('active');
1025
+ sessionStorage.setItem('weflow_tab_{date_str}', topic);
1026
+ window.location.hash = topic;
1027
+ }}
1028
+ function doSearch(query) {{
1029
+ const q = query.toLowerCase().trim();
1030
+ document.querySelectorAll('.card').forEach(card => {{
1031
+ if (!q) {{ card.style.display = ''; return; }}
1032
+ const text = (card.getAttribute('data-id') + ' ' + card.textContent).toLowerCase();
1033
+ card.style.display = text.includes(q) ? '' : 'none';
1034
+ }});
1035
+ if (q) {{
1036
+ document.querySelectorAll('.topic-section').forEach(s => s.classList.add('active'));
1037
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
1038
+ }} else {{
1039
+ switchTab(window.location.hash.slice(1) || 'AI');
1040
+ }}
1041
+ }}
1042
+
1043
+ // --- Favorites ---
1044
+ const IS_LOCALHOST = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
1045
+ const API_BASE = IS_LOCALHOST ? '/api/fav' : null;
1046
+
1047
+ function getFavState() {{
1048
+ try {{
1049
+ return JSON.parse(localStorage.getItem(FAV_STORAGE_KEY) || '[]');
1050
+ }} catch(e) {{ return []; }}
1051
+ }}
1052
+ function saveFavState(arr) {{
1053
+ localStorage.setItem(FAV_STORAGE_KEY, JSON.stringify(arr));
1054
+ }}
1055
+ function isFaved(id) {{
1056
+ return getFavState().includes(id);
1057
+ }}
1058
+ async function toggleFav(id) {{
1059
+ if (API_BASE) {{
1060
+ // 实时模式:调 API 写入磁盘
1061
+ try {{
1062
+ const res = await fetch(API_BASE + '/toggle', {{
1063
+ method: 'POST',
1064
+ headers: {{'Content-Type': 'application/json'}},
1065
+ body: JSON.stringify({{id: id}})
1066
+ }});
1067
+ const data = await res.json();
1068
+ if (data.ok) {{
1069
+ await loadFavFromServer();
1070
+ }}
1071
+ }} catch(e) {{
1072
+ console.error('API 请求失败,回退到本地模式', e);
1073
+ toggleFavLocal(id);
1074
+ }}
1075
+ }} else {{
1076
+ toggleFavLocal(id);
1077
+ }}
1078
+ }}
1079
+ function toggleFavLocal(id) {{
1080
+ const arr = getFavState();
1081
+ const idx = arr.indexOf(id);
1082
+ if (idx >= 0) {{ arr.splice(idx, 1); }}
1083
+ else {{ arr.push(id); }}
1084
+ saveFavState(arr);
1085
+ applyFavState();
1086
+ }}
1087
+ async function loadFavFromServer() {{
1088
+ try {{
1089
+ const res = await fetch(API_BASE + '/list');
1090
+ const data = await res.json();
1091
+ saveFavState(data);
1092
+ applyFavState();
1093
+ }} catch(e) {{
1094
+ console.error('加载收藏列表失败', e);
1095
+ }}
1096
+ }}
1097
+ function applyFavState() {{
1098
+ const favs = getFavState();
1099
+ // Update fav buttons
1100
+ document.querySelectorAll('.btn-fav').forEach(btn => {{
1101
+ const card = btn.closest('.card');
1102
+ if (!card) return;
1103
+ const id = card.getAttribute('data-id');
1104
+ if (favs.includes(id)) {{
1105
+ btn.classList.add('faved');
1106
+ btn.innerHTML = '★';
1107
+ card.classList.add('faved');
1108
+ }} else {{
1109
+ btn.classList.remove('faved');
1110
+ btn.innerHTML = '☆';
1111
+ card.classList.remove('faved');
1112
+ }}
1113
+ }});
1114
+ // Update fav count
1115
+ const countEl = document.getElementById('fav-count');
1116
+ if (countEl) countEl.textContent = favs.length;
1117
+ const sectionCountEl = document.getElementById('fav-section-count');
1118
+ if (sectionCountEl) sectionCountEl.textContent = favs.length + ' 篇';
1119
+ }}
1120
+ function renderFavSection() {{
1121
+ const favs = getFavState();
1122
+ const container = document.getElementById('fav-cards');
1123
+ if (!container) return;
1124
+ if (favs.length === 0) {{
1125
+ container.innerHTML = '<div class="fav-empty">⭐ 点击文章卡片上的 ☆ 按钮即可收藏<br>收藏的文章会显示在这里</div>';
1126
+ return;
1127
+ }}
1128
+ let html = '';
1129
+ favs.forEach(id => {{
1130
+ const card = Array.from(document.querySelectorAll('.card')).find(c => c.getAttribute('data-id') === id);
1131
+ if (card) {{
1132
+ html += card.outerHTML;
1133
+ }}
1134
+ }});
1135
+ container.innerHTML = html || '<div class="fav-empty">⭐ 暂无收藏文章</div>';
1136
+ }}
1137
+
1138
+ const VIEW_MODE_KEY = 'weflow_vmode_{date_str}';
1139
+ function getViewMode() {{
1140
+ return localStorage.getItem(VIEW_MODE_KEY) || 'html';
1141
+ }}
1142
+ function setViewMode(mode) {{
1143
+ localStorage.setItem(VIEW_MODE_KEY, mode);
1144
+ applyViewMode();
1145
+ }}
1146
+ const UNREAD_KEY = 'weflow_unread_{date_str}';
1147
+ let unreadFilterOn = sessionStorage.getItem(UNREAD_KEY) === '1';
1148
+ function toggleUnreadFilter() {{
1149
+ unreadFilterOn = !unreadFilterOn;
1150
+ sessionStorage.setItem(UNREAD_KEY, unreadFilterOn ? '1' : '0');
1151
+ const btn = document.getElementById('btn-filter-unread');
1152
+ if (btn) {{
1153
+ if (unreadFilterOn) {{
1154
+ btn.classList.add('active-mode');
1155
+ btn.textContent = '👁 仅看未读 ✓';
1156
+ }} else {{
1157
+ btn.classList.remove('active-mode');
1158
+ btn.textContent = '👁 仅看未读';
1159
+ }}
1160
+ }}
1161
+ applyUnreadFilter();
1162
+ }}
1163
+ function applyUnreadFilter() {{
1164
+ document.querySelectorAll('.card').forEach(card => {{
1165
+ if (!unreadFilterOn) {{ card.style.display = ''; return; }}
1166
+ card.style.display = card.classList.contains('read') ? 'none' : '';
1167
+ }});
1168
+ }}
1169
+
1170
+ function applyViewMode() {{
1171
+ const mode = getViewMode();
1172
+ const btnMd = document.getElementById('btn-mode-md');
1173
+ const btnHtml = document.getElementById('btn-mode-html');
1174
+ if (btnMd && btnHtml) {{
1175
+ btnMd.classList.toggle('active-mode', mode === 'md');
1176
+ btnHtml.classList.toggle('active-mode', mode === 'html');
1177
+ }}
1178
+ // Update all article title links and read buttons
1179
+ document.querySelectorAll('.card-title a, .btn-read').forEach(a => {{
1180
+ const card = a.closest('.card');
1181
+ if (!card) return;
1182
+ const id = card.getAttribute('data-id');
1183
+ if (!id) return;
1184
+ if (mode === 'html') {{
1185
+ const newHref = 'article.html?file=' + encodeURIComponent(id) + '&date={date_str}';
1186
+ if (a.getAttribute('data-md-href') === null) {{
1187
+ a.setAttribute('data-md-href', a.getAttribute('href') || '');
1188
+ }}
1189
+ a.setAttribute('href', newHref);
1190
+ }} else {{
1191
+ const mdHref = a.getAttribute('data-md-href');
1192
+ if (mdHref) a.setAttribute('href', mdHref);
1193
+ }}
1194
+ }});
1195
+ }}
1196
+
1197
+ function exportFav() {{
1198
+ const favs = getFavState();
1199
+ if (IS_LOCALHOST) {{
1200
+ alert('✅ 收藏已实时同步到磁盘!\\n\\n' + favs.length + ' 篇收藏在 收藏/ 文件夹中');
1201
+ return;
1202
+ }}
1203
+ const blob = new Blob([JSON.stringify(favs, null, 2)], {{type: 'application/json'}});
1204
+ const url = URL.createObjectURL(blob);
1205
+ const a = document.createElement('a');
1206
+ a.href = url;
1207
+ a.download = '.fav_state.json';
1208
+ a.click();
1209
+ URL.revokeObjectURL(url);
1210
+ alert('已导出 ' + favs.length + ' 篇收藏到 .fav_state.json\\n\\n运行: python scripts/sync_fav.py --date {date_str}');
1211
+ }}
1212
+
1213
+ // Init
1214
+ (async function() {{
1215
+ applyState();
1216
+ applyFavState();
1217
+ applyViewMode();
1218
+
1219
+ // 恢复"仅看未读"筛选状态
1220
+ if (unreadFilterOn) {{
1221
+ const btn = document.getElementById('btn-filter-unread');
1222
+ if (btn) {{ btn.classList.add('active-mode'); btn.textContent = '👁 仅看未读 ✓'; }}
1223
+ applyUnreadFilter();
1224
+ }}
1225
+
1226
+ // 恢复滚动位置(兼容 bfcache)
1227
+ const KEY = 'weflow_scroll_{date_str}';
1228
+ function restoreScroll() {{
1229
+ const y = sessionStorage.getItem(KEY);
1230
+ if (y) window.scrollTo(0, parseInt(y));
1231
+ }}
1232
+ restoreScroll();
1233
+ window.addEventListener('pageshow', (e) => {{ if (e.persisted) restoreScroll(); }});
1234
+
1235
+ // 滚动时保存
1236
+ let scrollTimer;
1237
+ window.addEventListener('scroll', () => {{
1238
+ clearTimeout(scrollTimer);
1239
+ scrollTimer = setTimeout(() => sessionStorage.setItem(KEY, String(window.scrollY)), 150);
1240
+ }}, {{passive: true}});
1241
+
1242
+ // 点击文章链接时立即保存
1243
+ document.addEventListener('click', (e) => {{
1244
+ const link = e.target.closest('.card-title a, .btn-read');
1245
+ if (link) sessionStorage.setItem(KEY, String(window.scrollY));
1246
+ }});
1247
+ if (IS_LOCALHOST) {{
1248
+ await loadFavFromServer();
1249
+ // 合并服务端已读状态(只补充,不覆盖本地)
1250
+ try {{
1251
+ const res = await fetch('/api/read/list');
1252
+ const serverState = await res.json();
1253
+ const local = getState();
1254
+ for (const [k,v] of Object.entries(serverState)) {{
1255
+ if (!local[k]) local[k] = true; // 只补充,不删除
1256
+ }}
1257
+ // 本地���也同步到服务端
1258
+ for (const [k,v] of Object.entries(local)) {{
1259
+ if (!(k in serverState)) {{
1260
+ fetch('/api/read/toggle', {{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{id:k}})}}).catch(()=>{{}});
1261
+ }}
1262
+ }}
1263
+ saveState(local);
1264
+ }} catch(e) {{}}
1265
+ const indicator = document.getElementById('sync-indicator');
1266
+ if (indicator) {{ indicator.style.display = ''; }}
1267
+ const exportBtn = document.getElementById('btn-export-fav');
1268
+ if (exportBtn) {{ exportBtn.textContent = '📥 导出'; exportBtn.title = '收藏已实时同步到磁盘'; }}
1269
+ }}
1270
+ // 无论是否 localhost,都应用已读状态到界面
1271
+ applyState();
1272
+ const savedTab = sessionStorage.getItem('weflow_tab_{date_str}');
1273
+ const hash = window.location.hash.slice(1);
1274
+ switchTab(hash || savedTab || 'AI');
1275
+ }})();
1276
+ </script>
1277
+ </body>
1278
+ </html>'''
1279
+ return html
1280
+
1281
+
1282
+ def main():
1283
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
1284
+ import argparse
1285
+ parser = argparse.ArgumentParser(description='生成公众号日报 HTML 页面')
1286
+ parser.add_argument('--date', help='日期 YYYY-MM-DD, 默认今天')
1287
+ parser.add_argument('--output', help='输出路径(默认在日报目录下的 index.html)')
1288
+ args = parser.parse_args()
1289
+
1290
+ if args.date:
1291
+ target_date = datetime.strptime(args.date, '%Y-%m-%d')
1292
+ else:
1293
+ from datetime import timezone, timedelta
1294
+ tz = timezone(timedelta(hours=8))
1295
+ target_date = datetime.now(tz)
1296
+ date_str = target_date.strftime('%Y-%m-%d')
1297
+ date_dir = os.path.join(SOURCE_ROOT, date_str)
1298
+
1299
+ if not os.path.isdir(date_dir):
1300
+ print(f'[ERROR] 目录不存在: {date_dir}')
1301
+ sys.exit(1)
1302
+
1303
+ topics = collect_articles(date_dir)
1304
+ if not topics:
1305
+ print(f'[ERROR] 未找到任何文章')
1306
+ sys.exit(1)
1307
+
1308
+ action_exist = os.path.exists(os.path.join(date_dir, '行动建议.md'))
1309
+
1310
+ # Extract briefing from README
1311
+ briefing = ''
1312
+ readme_path = os.path.join(date_dir, 'README.md')
1313
+ if os.path.exists(readme_path):
1314
+ try:
1315
+ with open(readme_path, 'r', encoding='utf-8') as f:
1316
+ readme = f.read()
1317
+ # Find briefing line, extract all subsequent > lines (blockquote)
1318
+ lines = readme.split('\n')
1319
+ in_briefing = False
1320
+ briefing_lines = []
1321
+ for line in lines:
1322
+ if '📋' in line and '简报' in line:
1323
+ in_briefing = True
1324
+ continue
1325
+ if in_briefing:
1326
+ if line.startswith('> '):
1327
+ text = line[2:].strip()
1328
+ if text:
1329
+ briefing_lines.append(text)
1330
+ elif not line.startswith('>') and briefing_lines:
1331
+ break # End of blockquote
1332
+ briefing = ' '.join(briefing_lines)
1333
+ except:
1334
+ pass
1335
+
1336
+ html = generate_html(date_str, topics, action_exist, briefing)
1337
+
1338
+ out_path = args.output or os.path.join(date_dir, 'index.html')
1339
+ with open(out_path, 'w', encoding='utf-8') as f:
1340
+ f.write(html)
1341
+
1342
+ # 同时生成 article.html(Markdown 渲染阅读器)
1343
+ article_html_path = os.path.join(date_dir, 'article.html')
1344
+ if not os.path.exists(article_html_path):
1345
+ import shutil
1346
+ template = os.path.join(SOURCE_ROOT, '2026-05-19', 'article.html')
1347
+ if os.path.exists(template):
1348
+ shutil.copy2(template, article_html_path)
1349
+ else:
1350
+ generate_article_viewer(article_html_path)
1351
+
1352
+ # 注入本地图片映射,优先用本地图片避免代理延迟
1353
+ image_map_path = os.path.join(date_dir, '.image_map.json')
1354
+ if os.path.exists(image_map_path):
1355
+ with open(image_map_path, 'r', encoding='utf-8') as f:
1356
+ img_map = json.load(f)
1357
+ if img_map and os.path.exists(article_html_path):
1358
+ with open(article_html_path, 'r', encoding='utf-8') as f:
1359
+ article_html = f.read()
1360
+ img_map_js = '<script>window._IMG_MAP=' + json.dumps(img_map, ensure_ascii=False) + ';</script>'
1361
+ # 注入到 </head> 之前
1362
+ article_html = article_html.replace('</head>', img_map_js + '\n</head>', 1)
1363
+ with open(article_html_path, 'w', encoding='utf-8') as f:
1364
+ f.write(article_html)
1365
+
1366
+ total = sum(len(v) for v in topics.values())
1367
+ print(f'✓ HTML 生成完成: {out_path}')
1368
+ print(f' 共 {total} 篇文章, {len(topics)} 个主题')
1369
+
1370
+
1371
+ if __name__ == '__main__':
1372
+ main()