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,370 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 阅读笔记系统 V2 — 参考 obsidian-research-vault-template 改造。
4
+
5
+ 改进:
6
+ - 数字前缀目录 (000_Inbox ~ 999_Archive)
7
+ - Frontmatter: aliases, rating, reading-progress, typed links
8
+ - Obsidian callout 语法 ([!info], [!tip], [!quote])
9
+ - 内嵌 Dataview 查询
10
+
11
+ 用法:
12
+ python scripts/create_reading_notes.py --date 2026-05-22
13
+ python scripts/create_reading_notes.py --date 2026-05-22 --vault output/wechat-vault
14
+ """
15
+
16
+ import sys, os, re
17
+ from pathlib import Path
18
+ from datetime import datetime
19
+
20
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
22
+ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
23
+ DEFAULT_SOURCE = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
24
+
25
+ TOPIC_ORDER = ['AI', '学术', '新闻', '文学', '投资']
26
+
27
+ # ====== V2 模板 ======
28
+
29
+ NOTE_TEMPLATE = '''---
30
+ title: "{title}"
31
+ aliases: [{aliases}]
32
+ created: {created}
33
+ last-updated: {created}
34
+
35
+ # 来源
36
+ source: "{source}"
37
+ source_url: "{url}"
38
+ source_type: wechat-article
39
+ local_source: "{local_source}"
40
+ published: {published}
41
+
42
+ # 分类
43
+ hasTopic: [[{topic}]]
44
+ tags: [source/wechat, {topic_tag}, status/unread]
45
+
46
+ # 评价
47
+ rating:
48
+ importance:
49
+ reading-progress: 0
50
+ ---
51
+
52
+ # {title}
53
+
54
+ > [!info] 文献信息
55
+ > - **来源**: {source} | {published}
56
+ > - **主题**: [[{topic}]]
57
+ > - **网页**: [微信原文]({url})
58
+ > - **本地**: [📂 打开源文件]({local_source})
59
+
60
+ ---
61
+
62
+ ## 📋 摘要
63
+
64
+ {summary}
65
+
66
+ ---
67
+
68
+ ## 💡 核心观点
69
+
70
+ > [!tip] 作者主张
71
+ > -
72
+
73
+ ---
74
+
75
+ ## 📝 阅读笔记
76
+
77
+ ### 初读印象
78
+
79
+
80
+ ### 关键发现
81
+ 1.
82
+
83
+ ### 方法亮点
84
+ -
85
+
86
+ ### 局限与质疑
87
+ -
88
+
89
+ ---
90
+
91
+ ## ✨ 高亮与摘录
92
+
93
+ > [!quote]
94
+ >
95
+
96
+ ---
97
+
98
+ ## 🧠 个人思考
99
+
100
+ ### 与我的研究关联
101
+
102
+
103
+ ### 可借鉴之处
104
+ -
105
+
106
+ ### 待深入问题
107
+ - [ ]
108
+
109
+ ---
110
+
111
+ ## 🔗 关联网络
112
+
113
+ ### 相关概念
114
+ {concepts}
115
+
116
+ ### 相关文献
117
+ -
118
+
119
+ ---
120
+
121
+ ## 📊 相关文章
122
+
123
+ ```dataview
124
+ TABLE rating, importance, reading-progress
125
+ FROM "002_Literature"
126
+ WHERE contains(hasTopic, "{topic}")
127
+ SORT date DESC
128
+ LIMIT 10
129
+ ```
130
+
131
+ ---
132
+
133
+ #review/pending #source/wechat
134
+ '''
135
+
136
+ DAILY_TEMPLATE = '''---
137
+ date: {date}
138
+ type: daily-review
139
+ tags: [daily]
140
+ mood:
141
+ energy:
142
+ ---
143
+
144
+ # {date} 阅读回顾
145
+
146
+ > [!abstract] 今日概览
147
+ > - **阅读**: {total} 篇文章
148
+ > - **笔记**: {created} 篇新建
149
+
150
+ ---
151
+
152
+ ## 📥 今日捕获
153
+
154
+ ### 阅读清单
155
+ {reading_list}
156
+
157
+ ### 新想法
158
+ -
159
+
160
+ ### 待跟进
161
+ - [ ]
162
+
163
+ ---
164
+
165
+ ## 💡 今日收获
166
+
167
+ ### 关键洞察
168
+ -
169
+
170
+ ### 方法启发
171
+ -
172
+
173
+ ### 疑问待解
174
+ -
175
+
176
+ ---
177
+
178
+ ## 🔗 概念连接
179
+
180
+ ```dataview
181
+ LIST
182
+ FROM "002_Literature"
183
+ WHERE created = date({date})
184
+ ```
185
+
186
+ ---
187
+
188
+ ## 🎯 明日计划
189
+
190
+ - [ ]
191
+
192
+ ---
193
+
194
+ #daily #review
195
+ '''
196
+
197
+ VAULT_DIRS = [
198
+ '000_Inbox',
199
+ '001_Daily',
200
+ '002_Literature/WeChat',
201
+ '002_Literature/WeRead',
202
+ '003_Ideas',
203
+ '004_Permanent',
204
+ '005_Reference/Tools',
205
+ '005_Reference/Methods',
206
+ '006_Projects',
207
+ '007_Wiki/Concepts',
208
+ '008_MOC',
209
+ '999_Archive',
210
+ '_attachments',
211
+ ]
212
+
213
+
214
+ def parse_frontmatter(text: str) -> tuple:
215
+ if not text.startswith('---'):
216
+ return {}, text
217
+ end = text.find('---', 3)
218
+ if end == -1:
219
+ return {}, text
220
+ fm_text = text[3:end].strip()
221
+ body = text[end + 3:].strip()
222
+ meta = {}
223
+ for line in fm_text.split('\n'):
224
+ line = line.strip()
225
+ if ':' in line:
226
+ key, _, val = line.partition(':')
227
+ key = key.strip()
228
+ val = val.strip().strip('"').strip("'")
229
+ if val.startswith('[') and val.endswith(']'):
230
+ val = [v.strip().strip('"').strip("'") for v in val[1:-1].split(',') if v.strip()]
231
+ meta[key] = val
232
+ return meta, body
233
+
234
+
235
+ def extract_summary(body: str, max_len=300) -> str:
236
+ lines = []
237
+ for line in body.split('\n'):
238
+ line = line.strip()
239
+ if line.startswith('#') or line.startswith('---') or line.startswith('## 相关阅读'):
240
+ continue
241
+ if line.startswith('>') or line.startswith('!['):
242
+ continue
243
+ line = line.replace('[[', '').replace(']]', '')
244
+ if line:
245
+ lines.append(line)
246
+ summary = ' '.join(lines[:10])
247
+ if len(summary) > max_len:
248
+ summary = summary[:max_len] + '...'
249
+ return summary or '(无摘要)'
250
+
251
+
252
+ def extract_concepts(body: str) -> str:
253
+ import re
254
+ concepts = re.findall(r'\[\[([^\]]+)\]\]', body)
255
+ concepts = [c.split('|')[0].strip() for c in concepts if not c.startswith('20')]
256
+ unique = list(set(concepts))[:10]
257
+ if not unique:
258
+ return '-\n-'
259
+ return '\n'.join(f'- [[{c}]]' for c in unique)
260
+
261
+
262
+ def generate_aliases(title: str) -> str:
263
+ """从标题提取有意义的别名(中文词≥2字,英文词≥4字符)。"""
264
+ words = re.findall(r'[\u4e00-\u9fa5]{2,}|[a-zA-Z]{4,}', title)
265
+ # 去重、排除纯数字
266
+ seen = set()
267
+ aliases = []
268
+ for w in words:
269
+ wl = w.lower()
270
+ if wl not in seen and not w.isdigit():
271
+ aliases.append(w)
272
+ seen.add(wl)
273
+ return ', '.join(f'"{a}"' for a in aliases[:5])
274
+
275
+
276
+ def safe_filename(title: str, max_len=50) -> str:
277
+ safe = title.replace('/', '_').replace('\\', '_').replace(':', ' -')
278
+ for ch in '<>:"/\\|?*':
279
+ safe = safe.replace(ch, '')
280
+ return safe[:max_len].rstrip('. ')
281
+
282
+
283
+ def create_reading_note(article_path, vault_path, date_str):
284
+ try:
285
+ content = article_path.read_text(encoding='utf-8')
286
+ except Exception:
287
+ return None, None
288
+
289
+ fm, body = parse_frontmatter(content)
290
+ title = fm.get('title', article_path.stem)
291
+ source = fm.get('source', '')
292
+ url = fm.get('url', '')
293
+ topic = fm.get('topic', '')
294
+ topic_tag = topic.lower() if topic else 'general'
295
+ published = fm.get('date', date_str)
296
+ summary = extract_summary(body)
297
+ concepts = extract_concepts(body)
298
+ aliases = generate_aliases(title)
299
+ created = datetime.now().strftime('%Y-%m-%d')
300
+ # 源文件绝对路径 (file:// Obsidian 可点击)
301
+ local_source = 'file:///' + str(article_path.resolve()).replace('\\', '/')
302
+
303
+ # 路径: 002_Literature/WeChat/2026-05-22/文章.md
304
+ note_path = Path(vault_path) / '002_Literature' / 'WeChat' / date_str / f'{date_str}-{safe_filename(title)}.md'
305
+ if note_path.exists():
306
+ return 'skip', title
307
+
308
+ note_path.parent.mkdir(parents=True, exist_ok=True)
309
+ note_content = NOTE_TEMPLATE.format(
310
+ title=title, aliases=aliases, created=created,
311
+ source=source, url=url, local_source=local_source, published=published,
312
+ topic=topic, topic_tag=topic_tag,
313
+ summary=summary, concepts=concepts,
314
+ )
315
+ note_path.write_text(note_content, encoding='utf-8')
316
+ return 'created', title
317
+
318
+
319
+ def main():
320
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
321
+ import argparse
322
+ parser = argparse.ArgumentParser(description='创建 Obsidian 阅读笔记 V2')
323
+ parser.add_argument('--date', required=True, help='日期 YYYY-MM-DD')
324
+ parser.add_argument('--source', default=DEFAULT_SOURCE, help='文章目录')
325
+ parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 目录')
326
+ args = parser.parse_args()
327
+
328
+ date_dir = os.path.join(args.source, args.date)
329
+ if not os.path.isdir(date_dir):
330
+ print(f'[ERROR] 目录不存在: {date_dir}')
331
+ sys.exit(1)
332
+
333
+ vault = Path(args.vault)
334
+ # 创建编号目录结构
335
+ for d in VAULT_DIRS:
336
+ (vault / d).mkdir(parents=True, exist_ok=True)
337
+
338
+ created, skipped = 0, 0
339
+ titles = []
340
+ for topic in TOPIC_ORDER:
341
+ topic_dir = Path(date_dir) / topic
342
+ if not topic_dir.is_dir():
343
+ continue
344
+ for md_file in sorted(topic_dir.glob('*.md')):
345
+ result, title = create_reading_note(md_file, vault, args.date)
346
+ if not result:
347
+ continue
348
+ if result == 'created':
349
+ created += 1
350
+ titles.append(f'- [[{args.date}-{safe_filename(title)}|{title}]]')
351
+ elif result == 'skip':
352
+ skipped += 1
353
+
354
+ total = created + skipped
355
+ print(f'✓ 阅读笔记 V2: {created} 篇新建, {skipped} 篇已存在')
356
+ print(f' 位置: {vault / "002_Literature" / "WeChat" / args.date}')
357
+
358
+ # 每日日记
359
+ daily_path = vault / '001_Daily' / f'{args.date}.md'
360
+ if not daily_path.exists():
361
+ daily_content = DAILY_TEMPLATE.format(
362
+ date=args.date, total=total, created=created,
363
+ reading_list='\n'.join(titles[:20]) if titles else '(无)',
364
+ )
365
+ daily_path.write_text(daily_content, encoding='utf-8')
366
+ print(f' 日记: {daily_path}')
367
+
368
+
369
+ if __name__ == '__main__':
370
+ main()
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 双向链接增强 — 为文章添加同主题、共享概念的相关文章链接。
4
+
5
+ 用法:
6
+ python scripts/enrich_backlinks.py --date 2026-05-22
7
+ python scripts/enrich_backlinks.py --date 2026-05-22 --dry-run
8
+ """
9
+
10
+ import sys, os, re
11
+ from pathlib import Path
12
+ from collections import defaultdict
13
+
14
+ from _utils import parse_frontmatter
15
+
16
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
18
+ DEFAULT_SOURCE = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
19
+
20
+ TOPIC_ORDER = ['AI', '学术', '新闻', '文学', '投资']
21
+
22
+
23
+ def collect_articles(date_dir: str) -> list[dict]:
24
+ """收集目录下所有文章,提取元数据。"""
25
+ articles = []
26
+ for topic in TOPIC_ORDER:
27
+ topic_dir = Path(date_dir) / topic
28
+ if not topic_dir.is_dir():
29
+ continue
30
+ for md_file in sorted(topic_dir.glob('*.md')):
31
+ try:
32
+ content = md_file.read_text(encoding='utf-8')
33
+ except Exception:
34
+ continue
35
+ fm, body = parse_frontmatter(content)
36
+ # 检查是否已经有相关阅读段落
37
+ if '## 相关阅读' in body:
38
+ continue # 已处理,跳过
39
+
40
+ articles.append({
41
+ 'path': md_file,
42
+ 'topic': topic,
43
+ 'filename': f'{topic}/{md_file.name}',
44
+ 'title': fm.get('title', md_file.stem),
45
+ 'source': fm.get('source', ''),
46
+ 'tags': fm.get('tags', []),
47
+ 'concepts': fm.get('concepts', fm.get('wikilinks', [])),
48
+ 'relevance': fm.get('relevance', ''),
49
+ })
50
+ return articles
51
+
52
+
53
+ def find_related(article: dict, all_articles: list[dict]) -> list[dict]:
54
+ """查找与给定文章相关的文章。"""
55
+ # 同主题文章(排除自身)
56
+ same_topic = [a for a in all_articles
57
+ if a['topic'] == article['topic']
58
+ and a['filename'] != article['filename']]
59
+
60
+ # 共享概念的文章
61
+ my_concepts = set(article['concepts']) if isinstance(article['concepts'], list) else set()
62
+ shared_concept = []
63
+ if my_concepts:
64
+ shared_concept = [a for a in all_articles
65
+ if a['filename'] != article['filename']
66
+ and a['topic'] != article['topic']
67
+ and set(a['concepts'] if isinstance(a['concepts'], list) else []) & my_concepts]
68
+
69
+ # 优先:同主题 + 高相关 + 共享概念
70
+ related = []
71
+ seen = set()
72
+
73
+ # 先加同主题高相关
74
+ for a in same_topic:
75
+ if a['relevance'] == '高':
76
+ related.append(a)
77
+ seen.add(a['filename'])
78
+
79
+ # 共享概念
80
+ for a in shared_concept:
81
+ if a['filename'] not in seen:
82
+ related.append(a)
83
+ seen.add(a['filename'])
84
+
85
+ # 补充同主题中相关
86
+ for a in same_topic:
87
+ if a['filename'] not in seen and a['relevance'] == '中':
88
+ related.append(a)
89
+ seen.add(a['filename'])
90
+
91
+ # 最后补任意来源
92
+ for a in same_topic:
93
+ if a['filename'] not in seen:
94
+ related.append(a)
95
+ seen.add(a['filename'])
96
+
97
+ return related[:5]
98
+
99
+
100
+ def enrich_article(article: dict, related: list[dict], dry_run=False):
101
+ """为文章末尾追加相关阅读段落。"""
102
+ if not related:
103
+ return 0
104
+
105
+ lines = ['', '---', '', '## 相关阅读', '']
106
+ for a in related:
107
+ label = f"📎 {a['title']}"
108
+ if a.get('source'):
109
+ label += f" — {a['source']}"
110
+ # 使用相对路径 Wikilink
111
+ link_path = a['filename'].replace('/', '/')
112
+ lines.append(f'- [[{link_path}|{label}]]')
113
+
114
+ appendix = '\n'.join(lines) + '\n'
115
+
116
+ if dry_run:
117
+ print(f' → {article["title"][:40]}')
118
+ print(f' 相关: {", ".join(a["title"][:30] for a in related)}')
119
+ return len(related)
120
+
121
+ filepath = article['path']
122
+ current = filepath.read_text(encoding='utf-8')
123
+ filepath.write_text(current.rstrip() + '\n' + appendix, encoding='utf-8')
124
+ return len(related)
125
+
126
+
127
+ def main():
128
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
129
+ import argparse
130
+ parser = argparse.ArgumentParser(description='为文章添加双向链接(相关阅读)')
131
+ parser.add_argument('--date', required=True, help='日期 YYYY-MM-DD')
132
+ parser.add_argument('--dry-run', action='store_true', help='仅预览不写入')
133
+ parser.add_argument('--source', default=DEFAULT_SOURCE, help='源目录')
134
+ args = parser.parse_args()
135
+
136
+ date_dir = os.path.join(args.source, args.date)
137
+ if not os.path.isdir(date_dir):
138
+ print(f'[ERROR] 目录不存在: {date_dir}')
139
+ sys.exit(1)
140
+
141
+ articles = collect_articles(date_dir)
142
+ print(f'找到 {len(articles)} 篇文章')
143
+ print(f'主题分布: {dict((t, len([a for a in articles if a["topic"]==t])) for t in TOPIC_ORDER if any(a["topic"]==t for a in articles))}')
144
+ print()
145
+
146
+ enriched = 0
147
+ skipped = 0
148
+ for article in articles:
149
+ related = find_related(article, articles)
150
+ if not related:
151
+ skipped += 1
152
+ continue
153
+ n = enrich_article(article, related, args.dry_run)
154
+ if n > 0:
155
+ enriched += 1
156
+
157
+ print(f'\n✓ 完成: {enriched} 篇已增强, {skipped} 篇无关联文章')
158
+ if args.dry_run:
159
+ print(' (--dry-run 模式,未实际写入)')
160
+
161
+
162
+ if __name__ == '__main__':
163
+ main()