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,411 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 后处理:广告清洗 → 兴趣主题深度摘要 → 分文件夹 → 重建README。
4
+
5
+ 前置: 已运行 biz_daily.py,md 文件中已含【主题】标签。
6
+
7
+ 用法: python scripts/classify_daily.py [date_dir] --api-key <key> [--interest AI]
8
+ """
9
+
10
+ import sys, os, json, re, time, urllib.request, shutil
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+ # 公共工具
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ from _utils import (
17
+ call_deepseek, parse_frontmatter, write_with_frontmatter,
18
+ DEFAULT_USER_PROFILE, generate_action_suggestion, load_config
19
+ )
20
+
21
+ OUTPUT_ROOT = 'output/biz-daily'
22
+ TOPICS = ['AI', '学术', '新闻', '文学', '投资']
23
+
24
+ AD_PATTERNS = [
25
+ re.compile(r'在小说阅读器读本章\s*'),
26
+ re.compile(r'在小说阅读器中沉浸阅读\s*'),
27
+ re.compile(r'去阅读\s*'),
28
+ re.compile(r'Scan to Follow\s*'),
29
+ re.compile(r'轻触阅读原文\s*'),
30
+ re.compile(r'预览时标签不可点\s*'),
31
+ re.compile(r'继续滑动看下一个\s*'),
32
+ re.compile(r'\[.*?\]\(javascript:void\(0\);\)'),
33
+ re.compile(r'\n{4,}'),
34
+ ]
35
+
36
+ INTEREST_PROMPT = """对以下AI领域文章生成深度解析:
37
+
38
+ 标题:{title}
39
+ 来源:{source}
40
+
41
+ 正文:
42
+ {content}
43
+
44
+ 请用markdown返回:
45
+ ### 核心观点
46
+ (1-2句)
47
+
48
+ ### 关键细节
49
+ - 点1
50
+ - 点2
51
+ - 点3
52
+
53
+ ### 启示
54
+ (1句话)"""
55
+
56
+
57
+ BATCH_ACTION_PROMPT = """你是读者的科研助手。基于以下文章列表,为读者筛选并生成可落地的行动建议。
58
+
59
+ 【读者定位】
60
+ {profile}
61
+
62
+ 【文章列表】
63
+ {articles}
64
+
65
+ 请完成以下任务:
66
+
67
+ 1. **筛选**:只保留对读者有实际落地价值的文章(排除纯新闻、娱乐、广告、无关内容)
68
+ 2. **评估**:对每篇保留的文章,判断相关度(高/中)并给出理由
69
+ 3. **生成建议**:为每篇保留的文章生成三级行动建议:
70
+ - **立即可做**:今天就能执行的具体动作
71
+ - **本周计划**:本周可以推进的中期动作
72
+ - **长期关注**:值得持续跟踪的方向(仅高相关度时输出)
73
+
74
+ 输出格式要求:
75
+
76
+ ```
77
+ ## 📋 今日行动清单(快速浏览)
78
+ - [ ] 动作1(来自:文章标题)
79
+ - [ ] 动作2(来自:文章标题)
80
+ ...
81
+
82
+ ---
83
+
84
+ ## 详细建议
85
+
86
+ ### 🔴 高相关度
87
+
88
+ #### 1. 《文章标题》
89
+ > 来源:公众号名 | 主题:AI/学术
90
+
91
+ **相关度**:高 — 一句话解释原因
92
+
93
+ **行动建议**
94
+ - **立即可做**:xxx
95
+ - **本周计划**:xxx
96
+ - **长期关注**:xxx
97
+
98
+ ### 🟡 中相关度
99
+
100
+ #### 1. 《文章标题》
101
+ ...
102
+ ```
103
+
104
+ 如果筛选后没有值得建议的文章,直接输出:「今日文章暂无直接可落地的行动建议,建议信息性阅读即可。」"""
105
+
106
+
107
+ def generate_batch_action_suggestions(articles_data: list[dict], api_key: str, profile: str = '') -> str:
108
+ """批量生成行动建议。
109
+
110
+ Args:
111
+ articles_data: 文章数据列表,每项包含 title, source, topic, summary, content
112
+ api_key: DeepSeek API key
113
+ profile: 用户定位描述
114
+
115
+ Returns:
116
+ 格式化的行动建议 markdown 文本
117
+ """
118
+ if not profile:
119
+ profile = DEFAULT_USER_PROFILE
120
+
121
+ # 构建文章列表文本
122
+ articles_text = []
123
+ for i, a in enumerate(articles_data, 1):
124
+ articles_text.append(
125
+ f"[{i}] 《{a['title']}》\n"
126
+ f" 来源:{a['source']} | 主题:{a['topic']} | 相关度:{a.get('relevance', '中')}\n"
127
+ f" 摘要:{a['summary'][:200]}...\n"
128
+ f" 正文节选:{a['content'][:500]}...\n"
129
+ )
130
+
131
+ prompt = BATCH_ACTION_PROMPT.format(
132
+ profile=profile,
133
+ articles='\n'.join(articles_text)
134
+ )
135
+
136
+ return call_deepseek(prompt, api_key, max_tokens=4000, timeout=120)
137
+
138
+
139
+ def clean_ads(text: str) -> str:
140
+ for pat in AD_PATTERNS:
141
+ text = pat.sub('', text)
142
+ text = re.sub(r'\n来源:[^\n]+\n编辑:[^\n]+\n校对:[^\n]+\n校审:[^\n]+', '', text)
143
+ text = re.sub(r'\n>/ [^\n]+', '', text)
144
+ text = re.sub(r'\n{4,}', '\n\n\n', text)
145
+ return text.strip()
146
+
147
+
148
+ def extract_topic_from_file(content: str) -> str:
149
+ """Extract topic from YAML frontmatter, fallback to text parsing."""
150
+ fm, _ = parse_frontmatter(content)
151
+ if 'topic' in fm and fm['topic'] in TOPICS:
152
+ return fm['topic']
153
+ # Fallback: legacy text-based parsing
154
+ m = re.search(r'> 主题:(\S+)', content)
155
+ if m: return m.group(1)
156
+ return '学术'
157
+
158
+
159
+ def extract_tags_from_file(content: str) -> list[str]:
160
+ """Extract tags from YAML frontmatter."""
161
+ fm, _ = parse_frontmatter(content)
162
+ return fm.get('tags', [])
163
+
164
+
165
+ def main():
166
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
167
+ import argparse
168
+ parser = argparse.ArgumentParser()
169
+ parser.add_argument('date_dir', nargs='?', help='日期目录')
170
+ parser.add_argument('--api-key', help='DeepSeek API key')
171
+ parser.add_argument('--interest', default='AI', help='兴趣主题,默认 AI')
172
+ parser.add_argument('--profile', default='', help='用户定位描述(默认使用环境科学研究生画像)')
173
+ parser.add_argument('--skip-action', action='store_true', help='跳过行动建议生成')
174
+ args = parser.parse_args()
175
+
176
+ config = load_config()
177
+ api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
178
+ if not api_key:
179
+ print('[ERROR] 需要 DeepSeek API key。请通过 --api-key、环境变量 DEEPSEEK_API_KEY 或 ~/.weflow-cli/config.json 中的 deepseekApiKey 提供')
180
+ sys.exit(1)
181
+
182
+ user_profile = args.profile if args.profile else DEFAULT_USER_PROFILE
183
+
184
+ if args.date_dir:
185
+ base = Path(OUTPUT_ROOT) / args.date_dir
186
+ else:
187
+ dirs = sorted(Path(OUTPUT_ROOT).glob('202*'), reverse=True)
188
+ if not dirs: print('[ERROR] 未找到日报目录'); sys.exit(1)
189
+ base = dirs[0]
190
+
191
+ md_files = sorted([f for f in base.rglob('*.md') if f.name != 'README.md'])
192
+ print(f'目录: {base}')
193
+ print(f'文章: {len(md_files)} 篇 | 兴趣: {args.interest}\n')
194
+
195
+ # ====== Step 1: Clean ads ======
196
+ print('=== Step 1: 广告清洗 ===')
197
+ cleaned = 0
198
+ for fpath in md_files:
199
+ with open(fpath, 'r', encoding='utf-8') as f:
200
+ content = f.read()
201
+ new_content = clean_ads(content)
202
+ if len(new_content) != len(content):
203
+ cleaned += 1
204
+ with open(fpath, 'w', encoding='utf-8') as f:
205
+ f.write(new_content)
206
+ print(f' 清洗 {cleaned} 篇\n')
207
+
208
+ # ====== Step 2: Read topics from md (already set by biz_daily) ======
209
+ print('=== Step 2: 读取主题 ===')
210
+ topic_map = {}
211
+ for fpath in md_files:
212
+ with open(fpath, 'r', encoding='utf-8') as f:
213
+ content = f.read()
214
+ topic = extract_topic_from_file(content)
215
+ if topic not in TOPICS:
216
+ for t in TOPICS:
217
+ if t in topic: topic = t; break
218
+ else: topic = '学术'
219
+ topic_map[fpath] = topic
220
+
221
+ dist = Counter(topic_map.values())
222
+ print(f' 分布: {dict(dist)}\n')
223
+
224
+ # ====== Step 3: Deep summary for interest ======
225
+ interest_files = [f for f, t in topic_map.items() if t == args.interest]
226
+ if interest_files:
227
+ print(f'=== Step 3: [{args.interest}] 深度摘要 ({len(interest_files)}篇) ===')
228
+ for i, fpath in enumerate(interest_files):
229
+ with open(fpath, 'r', encoding='utf-8') as f:
230
+ content = f.read()
231
+ title = re.search(r'^# (.+)', content, re.MULTILINE)
232
+ source = re.search(r'来源:(.+)', content)
233
+ title = title.group(1) if title else ''
234
+ source = source.group(1).strip() if source else ''
235
+ body_match = re.search(r'## 正文\n\n(.+)', content, re.DOTALL)
236
+ body = body_match.group(1)[:5000] if body_match else content[500:5500]
237
+
238
+ try:
239
+ prompt = INTEREST_PROMPT.format(title=title, source=source, content=body)
240
+ deep = call_deepseek(prompt, api_key, max_tokens=2000)
241
+ content = re.sub(
242
+ r'## AI 摘要\n\n.+?(?=\n\n---|\Z)',
243
+ f'## 深度解析\n\n{deep}',
244
+ content, flags=re.DOTALL,
245
+ )
246
+ # Update frontmatter: mark as enhanced
247
+ fm, body = parse_frontmatter(content)
248
+ if fm:
249
+ fm['enhanced'] = 'true'
250
+ write_with_frontmatter(str(fpath), fm, body)
251
+ else:
252
+ with open(fpath, 'w', encoding='utf-8') as f:
253
+ f.write(content)
254
+ print(f' [{i+1}/{len(interest_files)}] {title[:50]}')
255
+ time.sleep(0.3)
256
+ except Exception as e:
257
+ print(f' [{i+1}] ERR: {e}')
258
+
259
+ # ====== Step 4: Generate action suggestions (batch) ======
260
+ if not args.skip_action:
261
+ print(f'\n=== Step 4: 生成行动建议 ===')
262
+ # 只处理 AI 和 学术 主题的文章
263
+ candidate_files = [f for f, t in topic_map.items() if t in ['AI', '学术']]
264
+ if candidate_files:
265
+ articles_data = []
266
+ skipped_articles = []
267
+ for fpath in candidate_files:
268
+ with open(fpath, 'r', encoding='utf-8') as f:
269
+ content = f.read()
270
+
271
+ fm, body = parse_frontmatter(content)
272
+ title = fm.get('title', '') if fm else ''
273
+ source = fm.get('source', '') if fm else ''
274
+ topic = fm.get('topic', '') if fm else ''
275
+ relevance = fm.get('relevance', '中') if fm else '中'
276
+
277
+ # 提取摘要
278
+ summary_match = re.search(r'## AI 摘要\n\n(.+?)(?=\n\n---|\n\n## |\Z)', content, re.DOTALL)
279
+ summary = summary_match.group(1).strip() if summary_match else ''
280
+
281
+ # 提取正文
282
+ body_match = re.search(r'## 正文\n\n(.+)', content, re.DOTALL)
283
+ body_text = body_match.group(1)[:3000] if body_match else content[:3000]
284
+
285
+ articles_data.append({
286
+ 'title': title,
287
+ 'source': source,
288
+ 'topic': topic,
289
+ 'relevance': relevance,
290
+ 'summary': summary,
291
+ 'content': body_text,
292
+ })
293
+
294
+ # 收集被跳过的文章(新闻/文学/投资类)
295
+ for fpath, topic in topic_map.items():
296
+ if topic not in ['AI', '学术']:
297
+ with open(fpath, 'r', encoding='utf-8') as f:
298
+ content = f.read()
299
+ fm, _ = parse_frontmatter(content)
300
+ title = fm.get('title', '') if fm else ''
301
+ source = fm.get('source', '') if fm else ''
302
+ skipped_articles.append({'title': title, 'source': source, 'topic': topic})
303
+
304
+ try:
305
+ print(f' 候选文章: {len(articles_data)} 篇 (AI/学术)')
306
+ print(f' 跳过文章: {len(skipped_articles)} 篇 (新闻/文学/投资)')
307
+ action_content = generate_batch_action_suggestions(articles_data, api_key, user_profile)
308
+
309
+ # 添加被跳过的文章列表
310
+ if skipped_articles:
311
+ action_content += '\n\n---\n\n## 被跳过的文章(主题不符)\n\n'
312
+ for a in skipped_articles:
313
+ action_content += f"- 《{a['title']}》| 来源:{a['source']} | 主题:{a['topic']}\n"
314
+
315
+ # 写入行动建议文件
316
+ action_file = base / '行动建议.md'
317
+ with open(action_file, 'w', encoding='utf-8') as f:
318
+ f.write(f'# 行动建议 — {base.name}\n\n')
319
+ f.write(f'> 基于定位:{user_profile[:80]}...\n\n')
320
+ f.write(action_content)
321
+ print(f' ✓ 已生成: {action_file}')
322
+ except Exception as e:
323
+ print(f' [WARN] 行动建议生成失败: {e}')
324
+ else:
325
+ print(' 无 AI/学术 类文章,跳过行动建议')
326
+ else:
327
+ print(f'\n=== Step 4: 跳过行动建议 (--skip-action) ===')
328
+
329
+ # ====== Step 5: Move to topic folders ======
330
+ print(f'\n=== Step 5: 重建目录 ===')
331
+ # 保护收藏文件夹和状态文件
332
+ fav_dir = base / '收藏'
333
+ fav_state = base / '.fav_state.json'
334
+ fav_backup = None
335
+ if fav_dir.exists():
336
+ import tempfile as _tmp
337
+ fav_backup = Path(_tmp.mkdtemp()) / '收藏'
338
+ shutil.copytree(str(fav_dir), str(fav_backup))
339
+ shutil.rmtree(str(fav_dir))
340
+ for topic in TOPICS:
341
+ (base / topic).mkdir(exist_ok=True)
342
+ for fpath, topic in topic_map.items():
343
+ try:
344
+ dest = base / topic / fpath.name
345
+ if fpath.parent != dest.parent:
346
+ shutil.move(str(fpath), str(dest))
347
+ except:
348
+ pass
349
+ # 恢复收藏
350
+ if fav_backup and fav_backup.exists():
351
+ shutil.copytree(str(fav_backup), str(fav_dir))
352
+ shutil.rmtree(str(fav_backup.parent))
353
+
354
+ # ====== Step 6: README ======
355
+ # Preserve briefing block from biz_daily README
356
+ briefing_block = []
357
+ old_readme = base / 'README.md'
358
+ if old_readme.exists():
359
+ try:
360
+ with open(old_readme, 'r', encoding='utf-8') as f:
361
+ for line in f:
362
+ if '📋' in line and '简报' in line:
363
+ briefing_block.append(line.rstrip())
364
+ for line in f:
365
+ if line.startswith('>'):
366
+ stripped = line.rstrip()
367
+ if stripped != '>': # skip empty blockquote lines
368
+ briefing_block.append(stripped)
369
+ else:
370
+ break
371
+ break
372
+ except:
373
+ pass
374
+
375
+ action_badge = ' ✅' if (base / '行动建议.md').exists() else ''
376
+ lines = [
377
+ f'# 公众号日报 — {base.name}',
378
+ '',
379
+ ]
380
+ if briefing_block:
381
+ lines.extend(briefing_block)
382
+ lines.append('')
383
+ lines.extend([
384
+ f'共 {len(md_files)} 篇 | 兴趣: {args.interest}',
385
+ '',
386
+ f'**[📋 查看行动建议](./行动建议.md){action_badge}** — 基于你的定位生成的可落地建议',
387
+ ''
388
+ ])
389
+ for topic in TOPICS:
390
+ files = sorted((base / topic).glob('*.md'))
391
+ if not files: continue
392
+ fire = ' 🔥' if topic == args.interest else ''
393
+ lines.append(f'## {topic}{fire} ({len(files)}篇)')
394
+ lines.append('')
395
+ for j, f in enumerate(files):
396
+ with open(f, 'r', encoding='utf-8') as fh:
397
+ t = fh.readline().strip('#').strip()
398
+ lines.append(f'{j+1}. [{t}](./{topic}/{f.name})')
399
+ lines.append('')
400
+
401
+ with open(base / 'README.md', 'w', encoding='utf-8') as f:
402
+ f.write('\n'.join(lines))
403
+
404
+ print(f'\n✓ 完成!')
405
+ for topic in TOPICS:
406
+ n = len(list((base / topic).glob('*.md')))
407
+ if n: print(f' {topic}: {n} 篇')
408
+
409
+
410
+ if __name__ == '__main__':
411
+ main()
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 概念图谱编译 — 扫描文章的 [[Wikilinks]] → 聚合 → DeepSeek 生成概念页。
4
+
5
+ 用法:
6
+ python scripts/compile_wiki.py --api-key <key> [--limit 20] [--source output/biz-daily]
7
+ """
8
+ import sys, os, json, re, time, hashlib
9
+ from pathlib import Path
10
+ from collections import defaultdict, Counter
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+ from _utils import call_deepseek, parse_frontmatter, write_with_frontmatter
14
+
15
+ # Default paths
16
+ SOURCE_ROOT = 'output/biz-daily'
17
+ OUTPUT_ROOT = 'output/wechat-vault/Wiki/Concepts'
18
+
19
+ CONCEPT_PROMPT = """为概念生成 Wiki 知识页。
20
+
21
+ 概念名:{name}
22
+
23
+ 参考来源(来自公众号文章):
24
+ {references}
25
+
26
+ 请按格式返回:
27
+ 【定义】
28
+ (1-2句话定义这个概念)
29
+
30
+ 【关键要点】
31
+ - 要点1
32
+ - 要点2
33
+ - 要点3
34
+
35
+ 【标签】
36
+ tag1, tag2, tag3
37
+
38
+ 【相关概念】
39
+ 概念A, 概念B, 概念C
40
+
41
+ 要求:定义精准,要点简洁(每条≤30字),标签2-3个,相关概念2-4个。"""
42
+
43
+
44
+ def scan_articles(source_dir: str) -> list[dict]:
45
+ """Scan all .md files, extract frontmatter + wikilinks."""
46
+ articles = []
47
+ for md_file in Path(source_dir).rglob('*.md'):
48
+ if md_file.name == 'README.md':
49
+ continue
50
+ try:
51
+ with open(md_file, 'r', encoding='utf-8') as f:
52
+ content = f.read()
53
+ except:
54
+ continue
55
+
56
+ fm, body = parse_frontmatter(content)
57
+
58
+ # Extract [[wikilinks]] with optional descriptions
59
+ wiki_pattern = re.findall(r'\[\[([^\]]+)\]\](?:\s*—?\s*([^\n]+))?', body)
60
+ wikilinks = [(name.strip(), desc.strip()) for name, desc in wiki_pattern]
61
+
62
+ if not wikilinks:
63
+ continue
64
+
65
+ articles.append({
66
+ 'file': str(md_file.relative_to(source_dir)),
67
+ 'title': fm.get('title', md_file.stem),
68
+ 'source': fm.get('source', ''),
69
+ 'topic': fm.get('topic', ''),
70
+ 'tags': fm.get('tags', []),
71
+ 'summary': _extract_summary(body),
72
+ 'wikilinks': wikilinks,
73
+ })
74
+ return articles
75
+
76
+
77
+ def _extract_summary(body: str) -> str:
78
+ """Extract the AI summary section from article body."""
79
+ m = re.search(r'## (?:AI 摘要|深度解析)\n\n(.+?)(?=\n\n##|\n\n---|\Z)', body, re.DOTALL)
80
+ if m:
81
+ return m.group(1).strip()[:500]
82
+ # Fallback: first paragraph after metadata
83
+ lines = body.strip().split('\n')
84
+ for line in lines:
85
+ line = line.strip()
86
+ if line and not line.startswith('#') and not line.startswith('>') and not line.startswith('-'):
87
+ return line[:200]
88
+ return ''
89
+
90
+
91
+ def aggregate_concepts(articles: list[dict]) -> dict[str, list[dict]]:
92
+ """Aggregate wikilinks: concept_name -> [articles that reference it]."""
93
+ concept_map = defaultdict(list)
94
+ for art in articles:
95
+ seen = set()
96
+ for name, desc in art['wikilinks']:
97
+ if name in seen:
98
+ continue
99
+ seen.add(name)
100
+ concept_map[name].append({
101
+ 'title': art['title'],
102
+ 'source': art['source'],
103
+ 'summary': art['summary'],
104
+ 'desc': desc,
105
+ 'file': art['file'],
106
+ })
107
+ return dict(concept_map)
108
+
109
+
110
+ def generate_concept(name: str, refs: list[dict], api_key: str) -> str | None:
111
+ """Call DeepSeek to generate a concept Wiki page."""
112
+ # Build references section
113
+ ref_lines = []
114
+ for r in refs[:5]: # max 5 references
115
+ ref_lines.append(f'- [{r["title"]}]({r["source"]}):{r["summary"][:150]}')
116
+ ref_text = '\n'.join(ref_lines) if ref_lines else '(无详细信息)'
117
+
118
+ prompt = CONCEPT_PROMPT.format(name=name, references=ref_text)
119
+ try:
120
+ response = call_deepseek(prompt, api_key, max_tokens=800)
121
+ except Exception as e:
122
+ print(f' [ERR] {name}: {e}')
123
+ return None
124
+
125
+ # Parse response
126
+ definition_m = re.search(r'【定义】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
127
+ points_m = re.search(r'【关键要点】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
128
+ tags_m = re.search(r'【标签】\s*(.+)', response)
129
+ related_m = re.search(r'【相关概念】\s*(.+)', response)
130
+
131
+ definition = definition_m.group(1).strip() if definition_m else ''
132
+ points = points_m.group(1).strip() if points_m else ''
133
+ tags = [t.strip() for t in tags_m.group(1).split(',')] if tags_m else []
134
+ related = [r.strip() for r in related_m.group(1).split(',')] if related_m else []
135
+
136
+ # Build markdown body
137
+ body_parts = [f'# {name}\n']
138
+ if definition:
139
+ body_parts.append(f'{definition}\n\n')
140
+ if points:
141
+ body_parts.append('## 关键要点\n\n')
142
+ body_parts.append(points + '\n\n')
143
+ if related:
144
+ body_parts.append('## 相关概念\n\n')
145
+ for rc in related:
146
+ body_parts.append(f'- [[{rc}]]\n')
147
+ body_parts.append('\n')
148
+ body_parts.append('## 来源\n\n')
149
+ for r in refs[:5]:
150
+ body_parts.append(f'- [[{r["file"]}]] — {r["title"]}\n')
151
+
152
+ # Frontmatter
153
+ source_files = [r['file'] for r in refs[:5]]
154
+ today = time.strftime('%Y-%m-%d')
155
+ fm = {
156
+ 'title': f'"{name}"',
157
+ 'type': 'concept',
158
+ 'tags': tags,
159
+ 'created': today,
160
+ 'sources': source_files,
161
+ }
162
+
163
+ return fm, ''.join(body_parts)
164
+
165
+
166
+ def main():
167
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
168
+ import argparse
169
+ parser = argparse.ArgumentParser(description='概念图谱编译')
170
+ parser.add_argument('--api-key', help='DeepSeek API key (或环境变量 DEEPSEEK_API_KEY)')
171
+ parser.add_argument('--limit', type=int, default=20, help='最多生成概念数 (默认20)')
172
+ parser.add_argument('--source', default=SOURCE_ROOT, help='文章目录')
173
+ parser.add_argument('--output', default=OUTPUT_ROOT, help='概念页输出目录')
174
+ args = parser.parse_args()
175
+
176
+ api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
177
+ if not api_key:
178
+ print('[ERROR] 需要 DeepSeek API key')
179
+ sys.exit(1)
180
+
181
+ source_dir = args.source
182
+ out_dir = Path(args.output)
183
+ out_dir.mkdir(parents=True, exist_ok=True)
184
+
185
+ # Step 1: Scan
186
+ print(f'=== Step 1: 扫描文章 ===')
187
+ articles = scan_articles(source_dir)
188
+ print(f' 找到 {len(articles)} 篇带 wikilinks 的文章')
189
+
190
+ if not articles:
191
+ print('No articles with wikilinks found. Exiting.')
192
+ return
193
+
194
+ # Step 2: Aggregate
195
+ print(f'\n=== Step 2: 聚合概念 ===')
196
+ concept_map = aggregate_concepts(articles)
197
+ ranked = sorted(concept_map.items(), key=lambda x: len(x[1]), reverse=True)
198
+ print(f' 共 {len(ranked)} 个概念(限制 TOP {args.limit})')
199
+ for i, (name, refs) in enumerate(ranked[:10]):
200
+ print(f' {i+1}. [[{name}]] — {len(refs)} 篇文章引用')
201
+
202
+ # Step 3: Generate
203
+ print(f'\n=== Step 3: AI 生成概念页 ===')
204
+ top_concepts = ranked[:args.limit]
205
+ generated = 0
206
+
207
+ # Load existing concepts to skip
208
+ skipped = 0
209
+ for name, refs in top_concepts:
210
+ safe_name = re.sub(r'[\\/:*?"<>|]', '_', name)[:60]
211
+ out_file = out_dir / f'{safe_name}.md'
212
+
213
+ if out_file.exists():
214
+ skipped += 1
215
+ print(f' [SKIP] {name} (已存在)')
216
+ continue
217
+
218
+ print(f' [{generated+1}/{args.limit}] {name} ({len(refs)} 引用)...')
219
+ result = generate_concept(name, refs, api_key)
220
+ if result:
221
+ fm, body = result
222
+ write_with_frontmatter(str(out_file), fm, body)
223
+ generated += 1
224
+ time.sleep(0.5)
225
+
226
+ if skipped:
227
+ print(f' 跳过 {skipped} 个已有概念')
228
+ print(f' 生成 {generated} 个新概念')
229
+
230
+ # Step 4: Index
231
+ print(f'\n=== Step 4: 生成索引 ===')
232
+ concept_files = sorted(out_dir.glob('*.md'))
233
+ index_lines = [
234
+ '# 概念索引',
235
+ '',
236
+ f'共 {len(concept_files)} 个概念 | 生成时间:{time.strftime("%Y-%m-%d %H:%M")}',
237
+ '',
238
+ '| # | 概念 | 引用数 |',
239
+ '|---|------|--------|',
240
+ ]
241
+ for i, cf in enumerate(concept_files):
242
+ with open(cf, 'r', encoding='utf-8') as f:
243
+ content = f.read()
244
+ fm, _ = parse_frontmatter(content)
245
+ title = fm.get('title', cf.stem)
246
+ count = len(concept_map.get(title, []))
247
+ index_lines.append(f'| {i+1} | [[{title}]] | {count} |')
248
+
249
+ index_path = out_dir.parent / '00-Overview.md'
250
+ with open(index_path, 'w', encoding='utf-8') as f:
251
+ f.write('\n'.join(index_lines) + '\n')
252
+
253
+ print(f' 索引: {index_path}')
254
+ print(f'\n✓ 完成!共 {len(concept_files)} 个概念页')
255
+
256
+
257
+ if __name__ == '__main__':
258
+ main()