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,392 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 本地收藏服务器 — 浏览器点击收藏实时同步到磁盘 收藏/ 文件夹。
4
+
5
+ 用法:
6
+ python scripts/fav_server.py --date 2026-05-19
7
+ python scripts/fav_server.py --date 2026-05-19 --port 8765
8
+
9
+ 打开 http://localhost:8765 即可使用,点击 ☆ 收藏按钮实时写入磁盘。
10
+ """
11
+
12
+ import sys, os, json
13
+ from pathlib import Path
14
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
15
+ import urllib.request
16
+
17
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
19
+ SOURCE_ROOT = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
20
+
21
+
22
+ class FavHandler(SimpleHTTPRequestHandler):
23
+ """处理静态文件 + /api/fav/* 端点。"""
24
+ date_str = None
25
+ date_dir = None
26
+
27
+ # 确保文本文件以 UTF-8 编码发送,避免浏览器乱码
28
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map,
29
+ '.md': 'text/markdown; charset=utf-8',
30
+ '.html': 'text/html; charset=utf-8',
31
+ '.css': 'text/css; charset=utf-8',
32
+ '.js': 'text/javascript; charset=utf-8',
33
+ '.json': 'application/json; charset=utf-8',
34
+ '.txt': 'text/plain; charset=utf-8',
35
+ }
36
+
37
+ def end_headers(self):
38
+ self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
39
+ self.send_header('Pragma', 'no-cache')
40
+ self.send_header('Expires', '0')
41
+ super().end_headers()
42
+
43
+ def do_GET(self):
44
+ try:
45
+ if self.path.startswith('/api/fav/list'):
46
+ self._handle_fav_list()
47
+ elif self.path == '/api/read/list':
48
+ self._handle_read_list()
49
+ elif self.path.startswith('/api/notes'):
50
+ self._handle_notes_get()
51
+ elif self.path.startswith('/proxy?url='):
52
+ self._handle_proxy()
53
+ else:
54
+ super().do_GET()
55
+ except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
56
+ pass
57
+ except Exception:
58
+ try: self.send_error(500)
59
+ except: pass
60
+
61
+ def do_POST(self):
62
+ try:
63
+ if self.path == '/api/fav/toggle':
64
+ self._handle_fav_toggle()
65
+ elif self.path == '/api/read/toggle':
66
+ self._handle_read_toggle()
67
+ elif self.path == '/api/explain':
68
+ self._handle_explain()
69
+ elif self.path.startswith('/api/notes'):
70
+ self._handle_notes_post()
71
+ else:
72
+ self.send_error(404)
73
+ except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
74
+ pass
75
+ except Exception:
76
+ try: self.send_error(500)
77
+ except: pass
78
+
79
+ def handle_one_request(self):
80
+ """Override to catch any unhandled exceptions at the lowest level."""
81
+ try:
82
+ super().handle_one_request()
83
+ except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
84
+ pass
85
+ except Exception:
86
+ pass
87
+
88
+ def _handle_proxy(self):
89
+ """代理微信CDN图片,绕过防盗链Referer检查。"""
90
+ from urllib.parse import unquote
91
+ qs = self.path.split('?', 1)[1] if '?' in self.path else ''
92
+ url = ''
93
+ for p in qs.split('&'):
94
+ if p.startswith('url='):
95
+ url = unquote(p[4:])
96
+ break
97
+ if not url or not url.startswith(('http://', 'https://')):
98
+ self.send_error(400, 'Missing url')
99
+ return
100
+ try:
101
+ req = urllib.request.Request(url, headers={
102
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
103
+ 'Referer': 'https://mp.weixin.qq.com/',
104
+ 'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
105
+ })
106
+ with urllib.request.urlopen(req, timeout=15) as resp:
107
+ data = resp.read(10 * 1024 * 1024) # max 10MB
108
+ content_type = resp.headers.get('Content-Type', 'image/jpeg')
109
+ self.send_response(200)
110
+ self.send_header('Content-Type', content_type)
111
+ self.send_header('Content-Length', len(data))
112
+ self.send_header('Cache-Control', 'public, max-age=86400')
113
+ self.send_header('Access-Control-Allow-Origin', '*')
114
+ self.end_headers()
115
+ self.wfile.write(data)
116
+ except Exception as e:
117
+ self.send_error(502, f'Proxy failed: {e}')
118
+
119
+ def _handle_fav_list(self):
120
+ favs = self._read_favs()
121
+ self._send_json(favs)
122
+
123
+ def _handle_fav_toggle(self):
124
+ content_len = int(self.headers.get('Content-Length', 0))
125
+ body = self.rfile.read(content_len)
126
+ try:
127
+ data = json.loads(body.decode('utf-8'))
128
+ except (json.JSONDecodeError, UnicodeDecodeError):
129
+ self.send_error(400, 'Invalid JSON')
130
+ return
131
+ article_id = data.get('id', '')
132
+ if not article_id:
133
+ self.send_error(400, 'Missing id')
134
+ return
135
+
136
+ favs = self._read_favs()
137
+ if article_id in favs:
138
+ favs.remove(article_id)
139
+ action = 'removed'
140
+ else:
141
+ favs.append(article_id)
142
+ action = 'added'
143
+
144
+ self._write_favs(favs)
145
+ self._sync_folder(favs)
146
+ self._send_json({'ok': True, 'action': action, 'id': article_id, 'count': len(favs)})
147
+
148
+ def _read_favs(self):
149
+ fav_file = Path(self.date_dir) / '.fav_state.json'
150
+ if fav_file.exists():
151
+ try:
152
+ with open(fav_file, 'r', encoding='utf-8') as f:
153
+ return json.load(f)
154
+ except Exception:
155
+ pass
156
+ return []
157
+
158
+ def _write_favs(self, favs):
159
+ fav_file = Path(self.date_dir) / '.fav_state.json'
160
+ with open(fav_file, 'w', encoding='utf-8') as f:
161
+ json.dump(favs, f, ensure_ascii=False, indent=2)
162
+
163
+ def _sync_folder(self, favs):
164
+ """实时同步 收藏/ 文件夹 — 添加/移除文章文件。"""
165
+ fav_dir = Path(self.date_dir) / '收藏'
166
+ fav_dir.mkdir(parents=True, exist_ok=True)
167
+
168
+ desired_names = set()
169
+ for rel_path in favs:
170
+ src = Path(self.date_dir) / rel_path
171
+ if src.exists():
172
+ desired_names.add(src.name)
173
+ link = fav_dir / src.name
174
+ if not link.exists():
175
+ try:
176
+ link.symlink_to(os.path.relpath(src, fav_dir))
177
+ except OSError:
178
+ import shutil
179
+ shutil.copy2(src, link)
180
+
181
+ # 移除不在收藏列表中的文件
182
+ for item in fav_dir.iterdir():
183
+ if item.name not in desired_names:
184
+ if item.is_symlink() or item.is_file():
185
+ item.unlink()
186
+
187
+ # ---- 已读状态 ----
188
+ def _read_run_state(self):
189
+ f = Path(self.date_dir) / '.read_state.json'
190
+ if f.exists():
191
+ try: return json.loads(f.read_text(encoding='utf-8'))
192
+ except: pass
193
+ return {}
194
+ def _write_run_state(self, state):
195
+ (Path(self.date_dir) / '.read_state.json').write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding='utf-8')
196
+
197
+ def _handle_read_list(self):
198
+ self._send_json(self._read_run_state())
199
+
200
+ def _handle_read_toggle(self):
201
+ content_len = int(self.headers.get('Content-Length', 0))
202
+ body = self.rfile.read(content_len)
203
+ try: data = json.loads(body.decode('utf-8'))
204
+ except: self.send_error(400); return
205
+ aid = data.get('id', '')
206
+ if not aid: self.send_error(400); return
207
+ state = self._read_run_state()
208
+ if aid in state: del state[aid]; action = 'unread'
209
+ else: state[aid] = True; action = 'read'
210
+ self._write_run_state(state)
211
+ self._send_json({'ok': True, 'action': action, 'id': aid})
212
+
213
+ def _handle_explain(self):
214
+ content_len = int(self.headers.get('Content-Length', 0))
215
+ body = self.rfile.read(content_len)
216
+ try:
217
+ data = json.loads(body.decode('utf-8'))
218
+ except (json.JSONDecodeError, UnicodeDecodeError):
219
+ self.send_error(400, 'Invalid JSON')
220
+ return
221
+ text = data.get('text', '').strip()
222
+ context = data.get('context', '').strip()
223
+ if not text:
224
+ self.send_error(400, 'Missing text')
225
+ return
226
+
227
+ # 读 DeepSeek API key
228
+ api_key = os.environ.get('ANTHROPIC_AUTH_TOKEN', '') or os.environ.get('DEEPSEEK_API_KEY', '')
229
+
230
+ prompt = f'''你是一个阅读助手。用户正在读一篇文章,有问题要问你。
231
+
232
+ 文章全文(供参考):
233
+ {context[:8000]}
234
+
235
+ 用户提问:"{text}"
236
+
237
+ 回答规则:
238
+ 1. 优先用文章内容回答;如果问题与文章相关但文章信息不足,先用文章已有的部分,再用你的知识补充
239
+ 2. 如果问题与文章完全无关,直接用自己的知识回答,不要拒绝
240
+ 3. 用大白话解释,假设用户是非技术背景,避免术语堆砌
241
+ 4. 简短直接,不要啰嗦'''
242
+
243
+ payload = json.dumps({
244
+ 'model': 'deepseek-chat',
245
+ 'messages': [
246
+ {'role': 'system', 'content': '你是一个简洁准确的知识助手,用中文回答。'},
247
+ {'role': 'user', 'content': prompt}
248
+ ],
249
+ 'max_tokens': 1000,
250
+ 'temperature': 0.3
251
+ }).encode('utf-8')
252
+
253
+ try:
254
+ req = urllib.request.Request('https://api.deepseek.com/v1/chat/completions', data=payload)
255
+ req.add_header('Content-Type', 'application/json; charset=utf-8')
256
+ req.add_header('Authorization', f'Bearer {api_key}')
257
+ resp = urllib.request.urlopen(req, timeout=15)
258
+ result = json.loads(resp.read().decode('utf-8'))
259
+ explanation = result['choices'][0]['message']['content']
260
+ self._send_json({'ok': True, 'text': text, 'explanation': explanation})
261
+ except Exception as e:
262
+ self._send_json({'ok': False, 'error': f'AI 调用失败: {str(e)}'})
263
+
264
+ # ---- 笔记功能 ----
265
+ def _notes_file(self):
266
+ return Path(self.date_dir) / 'notes.json'
267
+
268
+ def _read_notes(self):
269
+ f = self._notes_file()
270
+ if f.exists():
271
+ try: return json.loads(f.read_text(encoding='utf-8'))
272
+ except: pass
273
+ return {}
274
+
275
+ def _write_notes(self, notes):
276
+ self._notes_file().write_text(json.dumps(notes, ensure_ascii=False, indent=2), encoding='utf-8')
277
+
278
+ def _handle_notes_get(self):
279
+ """GET /api/notes?article=xxx → 返回该文章的所有笔记"""
280
+ qs = self.path.split('?', 1)[1] if '?' in self.path else ''
281
+ params = {}
282
+ for p in qs.split('&'):
283
+ if '=' in p:
284
+ k, v = p.split('=', 1)
285
+ params[k] = v
286
+ article_id = params.get('article', '')
287
+ notes = self._read_notes()
288
+ if article_id:
289
+ self._send_json(notes.get(article_id, []))
290
+ else:
291
+ self._send_json(notes)
292
+
293
+ def _handle_notes_post(self):
294
+ """POST /api/notes → 添加笔记, DELETE /api/notes → 删除笔记"""
295
+ content_len = int(self.headers.get('Content-Length', 0))
296
+ body = self.rfile.read(content_len)
297
+ try:
298
+ data = json.loads(body.decode('utf-8'))
299
+ except:
300
+ self.send_error(400); return
301
+
302
+ if self.path == '/api/notes/delete':
303
+ article_id = data.get('article', '')
304
+ highlight_id = data.get('id', '')
305
+ if not article_id or not highlight_id:
306
+ self.send_error(400); return
307
+ notes = self._read_notes()
308
+ if article_id in notes:
309
+ notes[article_id] = [h for h in notes[article_id] if h.get('id') != highlight_id]
310
+ if not notes[article_id]:
311
+ del notes[article_id]
312
+ self._write_notes(notes)
313
+ self._send_json({'ok': True})
314
+ else:
315
+ # POST /api/notes → add note
316
+ article_id = data.get('article', '')
317
+ text = data.get('text', '').strip()
318
+ note = data.get('note', '').strip()
319
+ if not article_id or not text:
320
+ self.send_error(400); return
321
+ import uuid, time
322
+ hid = uuid.uuid4().hex[:8]
323
+ notes = self._read_notes()
324
+ if article_id not in notes:
325
+ notes[article_id] = []
326
+ notes[article_id].append({
327
+ 'id': hid,
328
+ 'text': text,
329
+ 'note': note,
330
+ 'created': time.strftime('%Y-%m-%d %H:%M', time.localtime())
331
+ })
332
+ self._write_notes(notes)
333
+ self._send_json({'ok': True, 'id': hid})
334
+
335
+ def _send_json(self, data):
336
+ body = json.dumps(data, ensure_ascii=False).encode('utf-8')
337
+ self.send_response(200)
338
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
339
+ self.send_header('Content-Length', len(body))
340
+ self.send_header('Access-Control-Allow-Origin', '*')
341
+ self.end_headers()
342
+ self.wfile.write(body)
343
+
344
+ def do_OPTIONS(self):
345
+ self.send_response(200)
346
+ self.send_header('Access-Control-Allow-Origin', '*')
347
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
348
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
349
+ self.end_headers()
350
+
351
+ def log_message(self, format, *args):
352
+ # 简洁日志
353
+ if '/api/' in str(args[0]):
354
+ print(f' {args[0]}')
355
+
356
+
357
+ def main():
358
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
359
+ import argparse
360
+ parser = argparse.ArgumentParser(description='本地收藏服务器')
361
+ parser.add_argument('--date', required=True, help='日期 YYYY-MM-DD')
362
+ parser.add_argument('--port', type=int, default=8765, help='端口 (默认 8765)')
363
+ args = parser.parse_args()
364
+
365
+ date_dir = os.path.join(SOURCE_ROOT, args.date)
366
+ if not os.path.isdir(date_dir):
367
+ print(f'[ERROR] 目录不存在: {date_dir}')
368
+ sys.exit(1)
369
+
370
+ # 配置 Handler
371
+ FavHandler.date_str = args.date
372
+ FavHandler.date_dir = date_dir
373
+
374
+ # 切换到日报目录以提供静态文件服务
375
+ os.chdir(date_dir)
376
+
377
+ server = HTTPServer(('0.0.0.0', args.port), FavHandler)
378
+ print(f'⭐ 收藏服务器已启动')
379
+ print(f' 打开: http://localhost:{args.port}')
380
+ print(f' 日期: {args.date}')
381
+ print(f' 收藏文件夹: {date_dir}/收藏/')
382
+ print(f' Ctrl+C 停止')
383
+ print()
384
+ try:
385
+ server.serve_forever()
386
+ except KeyboardInterrupt:
387
+ print('\n已停止')
388
+ server.server_close()
389
+
390
+
391
+ if __name__ == '__main__':
392
+ main()
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """修复已生成文章的主题分类(关键词 fallback)。用法: python scripts/fix_topics.py 2026-05-19"""
3
+ import sys, os, re
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
+ from biz_daily import _guess_topic
8
+
9
+ TOPICS = ['AI', '学术', '新闻', '文学', '投资']
10
+
11
+ def fix_date(date_str: str):
12
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
13
+ base = Path('output/biz-daily') / date_str
14
+ if not base.is_dir():
15
+ print(f'[ERROR] 目录不存在: {base}')
16
+ sys.exit(1)
17
+
18
+ fixed = 0
19
+ for topic in TOPICS:
20
+ topic_dir = base / topic
21
+ if not topic_dir.is_dir():
22
+ continue
23
+ for md_file in topic_dir.glob('*.md'):
24
+ try:
25
+ content = md_file.read_text(encoding='utf-8')
26
+ except:
27
+ continue
28
+
29
+ # Extract current topic from frontmatter
30
+ m = re.search(r'^topic:\s*(.+)', content, re.MULTILINE)
31
+ old_topic = m.group(1).strip() if m else ''
32
+
33
+ # Extract title
34
+ title = md_file.stem
35
+ m2 = re.search(r'^title:\s*"?(.+?)"?\s*$', content, re.MULTILINE)
36
+ if m2:
37
+ title = m2.group(1).strip().strip('"').strip("'")
38
+
39
+ # Guess topic
40
+ article = {'title': title, 'account_name': ''}
41
+ m3 = re.search(r'^source:\s*"?(.+?)"?\s*$', content, re.MULTILINE)
42
+ if m3:
43
+ article['account_name'] = m3.group(1).strip().strip('"').strip("'")
44
+
45
+ guessed = _guess_topic(article)
46
+
47
+ if guessed != old_topic:
48
+ # Update frontmatter
49
+ new_content = re.sub(
50
+ r'^topic:\s*.+', f'topic: {guessed}',
51
+ content, flags=re.MULTILINE
52
+ )
53
+ # Update topic folder if needed - move file
54
+ if guessed != topic:
55
+ new_dir = base / guessed
56
+ new_dir.mkdir(exist_ok=True)
57
+ new_path = new_dir / md_file.name
58
+ md_file.rename(new_path)
59
+ # Also update the file at new location
60
+ new_path.write_text(new_content, encoding='utf-8')
61
+ print(f' [FIX] {title[:50]} -> {guessed} (was {topic})')
62
+ else:
63
+ md_file.write_text(new_content, encoding='utf-8')
64
+ fixed += 1
65
+
66
+ print(f'\n✓ 修复 {fixed} 篇,未变动 {sum(1 for _ in base.rglob("*.md") if _.name != "README.md") - fixed} 篇')
67
+
68
+ # Rebuild README
69
+ from biz_daily import sanitize_filename
70
+ all_articles = []
71
+ for topic in TOPICS:
72
+ topic_dir = base / topic
73
+ if not topic_dir.is_dir():
74
+ continue
75
+ for md_file in sorted(topic_dir.glob('*.md')):
76
+ content = md_file.read_text(encoding='utf-8')
77
+ title = md_file.stem
78
+ source = ''
79
+ article_time = ''
80
+ for line in content.split('\n'):
81
+ if line.startswith('title:'):
82
+ title = line.split(':', 1)[1].strip().strip('"').strip("'")
83
+ elif line.startswith('source:'):
84
+ source = line.split(':', 1)[1].strip().strip('"').strip("'")
85
+ mm = re.search(r'时间:\d{4}-\d{2}-\d{2} (\d{2}:\d{2})', content)
86
+ if mm: article_time = mm.group(1)
87
+ all_articles.append({'title': title, 'source': source, 'time': article_time, 'topic': topic, 'file': md_file.name})
88
+
89
+ index_lines = [f'# 公众号日报 — {date_str}', '', f'共 {len(all_articles)} 篇推送,按主题分类', '']
90
+ for topic in TOPICS:
91
+ group = [a for a in all_articles if a['topic'] == topic]
92
+ if not group: continue
93
+ index_lines.append(f'## {topic} ({len(group)}篇)')
94
+ index_lines.append('')
95
+ index_lines.append('| # | 时间 | 公众号 | 标题 |')
96
+ index_lines.append('|---|------|--------|------|')
97
+ for j, a in enumerate(group):
98
+ index_lines.append(f'| {j+1} | {a["time"]} | {a["source"]} | [{a["title"]}](./{topic}/{a["file"]}) |')
99
+ index_lines.append('')
100
+
101
+ with open(base / 'README.md', 'w', encoding='utf-8') as f:
102
+ f.write('\n'.join(index_lines))
103
+
104
+ print(f' README 已重建')
105
+
106
+
107
+ if __name__ == '__main__':
108
+ if len(sys.argv) < 2:
109
+ print('用法: python scripts/fix_topics.py 2026-05-19')
110
+ sys.exit(1)
111
+ fix_date(sys.argv[1])