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,192 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 微信读书同步到 Vault — 将书架、笔记、划线写入 Obsidian。
4
+
5
+ 用法:
6
+ python scripts/sync_weread.py # 同步所有
7
+ python scripts/sync_weread.py --type notes # 仅笔记
8
+ python scripts/sync_weread.py --type shelf # 仅书架概览
9
+ """
10
+
11
+ import sys, os, json, urllib.request
12
+ from pathlib import Path
13
+ from datetime import datetime
14
+
15
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
17
+ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
18
+ GATEWAY = 'https://i.weread.qq.com/api/agent/gateway'
19
+ SKILL_VERSION = '1.0.3'
20
+
21
+
22
+ def weread_call(api_name: str, api_key: str, **params) -> dict:
23
+ """调用微信读书 API。"""
24
+ body = json.dumps({'api_name': api_name, 'skill_version': SKILL_VERSION, **params}).encode('utf-8')
25
+ req = urllib.request.Request(GATEWAY, data=body, headers={
26
+ 'Authorization': f'Bearer {api_key}',
27
+ 'Content-Type': 'application/json; charset=utf-8',
28
+ })
29
+ try:
30
+ resp = urllib.request.urlopen(req, timeout=15)
31
+ return json.loads(resp.read().decode('utf-8'))
32
+ except Exception as e:
33
+ print(f' [WARN] API 调用失败: {e}')
34
+ return {}
35
+
36
+
37
+ def sync_shelf(api_key, vault_dir):
38
+ """同步书架概览到 Vault。"""
39
+ data = weread_call('/shelf/sync', api_key)
40
+ books = data.get('books', [])
41
+ if not books:
42
+ print(' 书架为空')
43
+ return
44
+
45
+ shelf_dir = Path(vault_dir) / 'Sources' / 'WeRead'
46
+ shelf_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ # 生成书架索引
49
+ lines = [
50
+ '---',
51
+ 'type: weread-shelf',
52
+ f'updated: {datetime.now().strftime("%Y-%m-%d %H:%M")}',
53
+ '---',
54
+ '',
55
+ '# 微信读书书架',
56
+ '',
57
+ f'共 {len(books)} 本书',
58
+ '',
59
+ ]
60
+ for b in sorted(books, key=lambda x: x.get('readUpdateTime', 0), reverse=True):
61
+ done = '✓' if b.get('finishReading') == 1 else ''
62
+ lines.append(f'- {done} **{b.get("title", "")}** — {b.get("author", "")}')
63
+
64
+ (shelf_dir / 'README.md').write_text('\n'.join(lines), encoding='utf-8')
65
+ print(f' 书架索引: {shelf_dir / "README.md"} ({len(books)} 本)')
66
+
67
+ # 为每本在读的书创建笔记页
68
+ reading_dir = Path(vault_dir) / 'Notes' / 'Reading'
69
+ reading_dir.mkdir(parents=True, exist_ok=True)
70
+ created = 0
71
+ for b in books:
72
+ if b.get('finishReading') == 1:
73
+ continue # 跳过已读完的
74
+ safe_name = b.get('title', 'unknown').replace('/', '_').replace('\\', '_')
75
+ for ch in '<>:"/\\|?*':
76
+ safe_name = safe_name.replace(ch, '')
77
+ safe_name = safe_name[:40].rstrip('. ')
78
+ note_path = reading_dir / f'Weread-{safe_name}.md'
79
+ if note_path.exists():
80
+ continue
81
+ note_path.write_text(f'''---
82
+ title: "{b.get('title', '')}"
83
+ author: "{b.get('author', '')}"
84
+ bookId: "{b.get('bookId', '')}"
85
+ type: weread-reading
86
+ tags: [weread, {b.get('category', '').lower()}]
87
+ ---
88
+
89
+ # {b.get('title', '')}
90
+
91
+ > 作者: {b.get('author', '')}
92
+
93
+ ## 阅读笔记
94
+
95
+ ## 划线摘录
96
+
97
+ ## 读后感
98
+
99
+ ''', encoding='utf-8')
100
+ created += 1
101
+ if created:
102
+ print(f' 阅读笔记: {created} 本新书')
103
+
104
+
105
+ def sync_notes(api_key, vault_dir):
106
+ """同步笔记划线到 Vault。"""
107
+ data = weread_call('/user/notebooks', api_key, count=50)
108
+ books = data.get('books', [])
109
+ if not books:
110
+ print(' 无法获取笔记列表')
111
+ return
112
+
113
+ highlights_dir = Path(vault_dir) / 'Notes' / 'WereadHighlights'
114
+ highlights_dir.mkdir(parents=True, exist_ok=True)
115
+ synced = 0
116
+
117
+ for nb in books[:10]: # 最多处理 10 本有笔记的书
118
+ book = nb.get('book', {})
119
+ book_id = book.get('bookId', '')
120
+ title = book.get('title', '')
121
+ if not book_id:
122
+ continue
123
+
124
+ marks = weread_call('/book/bookmarklist', api_key, bookId=book_id, count=50)
125
+ updated = marks.get('updated', [])
126
+ if not updated:
127
+ continue
128
+
129
+ safe_name = title.replace('/', '_')
130
+ for ch in '<>:"/\\|?*':
131
+ safe_name = safe_name.replace(ch, '')
132
+ safe_name = safe_name[:40].rstrip('. ')
133
+ note_path = highlights_dir / f'{safe_name}-划线.md'
134
+
135
+ lines = [
136
+ '---',
137
+ f'title: "{title}"',
138
+ f'bookId: "{book_id}"',
139
+ 'type: weread-highlights',
140
+ 'tags: [weread, highlights]',
141
+ '---',
142
+ '',
143
+ f'# {title} — 划线笔记',
144
+ '',
145
+ ]
146
+ for m in updated:
147
+ text = m.get('markText', '')
148
+ note = m.get('content', '')
149
+ chapter = m.get('chapterTitle', '')
150
+ if text:
151
+ lines.append(f'> {text}')
152
+ if chapter:
153
+ lines.append(f' — {chapter}')
154
+ if note:
155
+ lines.append(f' 💭 {note}')
156
+ lines.append('')
157
+
158
+ note_path.write_text('\n'.join(lines), encoding='utf-8')
159
+ synced += 1
160
+
161
+ print(f' 划线笔记: {synced} 本书')
162
+
163
+
164
+ def main():
165
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
166
+ import argparse
167
+ parser = argparse.ArgumentParser(description='微信读书 → Vault 同步')
168
+ parser.add_argument('--type', default='all', choices=['all', 'shelf', 'notes'])
169
+ parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
170
+ args = parser.parse_args()
171
+
172
+ api_key = os.environ.get('WEREAD_API_KEY', '')
173
+ if not api_key:
174
+ print('[ERROR] 未设置 WEREAD_API_KEY')
175
+ sys.exit(1)
176
+
177
+ vault_dir = Path(args.vault)
178
+ (vault_dir / 'Sources' / 'WeRead').mkdir(parents=True, exist_ok=True)
179
+
180
+ if args.type in ('all', 'shelf'):
181
+ print('📚 同步书架...')
182
+ sync_shelf(api_key, vault_dir)
183
+
184
+ if args.type in ('all', 'notes'):
185
+ print('📝 同步笔记...')
186
+ sync_notes(api_key, vault_dir)
187
+
188
+ print('✓ WeRead 同步完成')
189
+
190
+
191
+ if __name__ == '__main__':
192
+ main()
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Vault RAG 对话 — 基于 Vault 知识库(文章+概念+笔记)的问答。
4
+
5
+ 用法:
6
+ python scripts/vault_rag.py "深度学习在遥感中的应用"
7
+ python scripts/vault_rag.py "最近读了什么文章" --json
8
+ """
9
+
10
+ import sys, os, json, argparse, re
11
+ from pathlib import Path
12
+
13
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
15
+ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
16
+ DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
17
+
18
+ sys.path.insert(0, SCRIPTS_DIR)
19
+ from _utils import call_deepseek
20
+
21
+
22
+ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> list[dict]:
23
+ """从 Vault + biz-daily 检索相关上下文。"""
24
+ results = []
25
+ search_terms = question.lower().split()
26
+
27
+ # 1. 概念页(权重最高)
28
+ concepts_dir = Path(vault) / 'Wiki' / 'Concepts'
29
+ if concepts_dir.is_dir():
30
+ for md in concepts_dir.glob('*.md'):
31
+ try:
32
+ text = md.read_text(encoding='utf-8')[:2000]
33
+ except Exception:
34
+ continue
35
+ score = sum(text.lower().count(t) for t in search_terms)
36
+ if score > 0:
37
+ results.append({'source': 'concept', 'title': md.stem, 'content': text, 'score': score})
38
+
39
+ # 2. 阅读笔记
40
+ notes_dir = Path(vault) / 'Notes'
41
+ if notes_dir.is_dir():
42
+ for md in notes_dir.rglob('*.md'):
43
+ try:
44
+ text = md.read_text(encoding='utf-8')[:1500]
45
+ except Exception:
46
+ continue
47
+ score = sum(text.lower().count(t) for t in search_terms)
48
+ # 标题匹配加分
49
+ if any(t in md.stem.lower() for t in search_terms):
50
+ score += 15
51
+ if score > 0:
52
+ results.append({'source': 'note', 'title': md.stem[:60], 'content': text, 'score': score})
53
+
54
+ # 3. 最新文章
55
+ biz_path = Path(biz_daily)
56
+ for date_dir in sorted(biz_path.glob('20*'), reverse=True)[:7]: # 最近 7 天
57
+ for md in date_dir.rglob('*.md'):
58
+ if md.name == 'README.md' or 'README' in md.name:
59
+ continue
60
+ try:
61
+ text = md.read_text(encoding='utf-8')[:1000]
62
+ except Exception:
63
+ continue
64
+ score = sum(text.lower().count(t) for t in search_terms)
65
+ if score > 0:
66
+ results.append({'source': 'article', 'title': md.stem[:60], 'content': text, 'score': score})
67
+
68
+ results.sort(key=lambda x: x['score'], reverse=True)
69
+ return results[:top_k]
70
+
71
+
72
+ def build_prompt(question: str, context: list[dict]) -> str:
73
+ sources_text = ''
74
+ for i, ctx in enumerate(context):
75
+ label = {'concept': '概念', 'note': '笔记', 'article': '文章'}.get(ctx['source'], ctx['source'])
76
+ sources_text += f'\n[{i+1}] ({label}) {ctx["title"]}\n{ctx["content"][:600]}\n'
77
+
78
+ return f'''你是个人知识助手,基于用户笔记库回答问题。
79
+
80
+ 知识库内容:
81
+ {sources_text}
82
+
83
+ 用户问题:{question}
84
+
85
+ 请综合知识库内容回答。如果知识库中没有相关信息,如实说明。'''
86
+
87
+
88
+ def main():
89
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
90
+ parser = argparse.ArgumentParser(description='Vault RAG 对话')
91
+ parser.add_argument('question', nargs='?', default='', help='问题')
92
+ parser.add_argument('--top-k', default='8', help='检索条数')
93
+ parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
94
+ parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
95
+ parser.add_argument('--api-key', default='', help='DeepSeek API key')
96
+ parser.add_argument('--json', action='store_true', help='JSON 输出')
97
+ args = parser.parse_args()
98
+
99
+ if not args.question:
100
+ print('请提供问题')
101
+ sys.exit(1)
102
+
103
+ api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
104
+ if not api_key:
105
+ print('[ERROR] 缺少 API Key')
106
+ sys.exit(1)
107
+
108
+ top_k = int(args.top_k)
109
+ context = collect_context(args.vault, args.biz_daily, args.question, top_k)
110
+
111
+ if args.json:
112
+ output = {'question': args.question, 'sources': [{'source': c['source'], 'title': c['title'], 'score': c['score']} for c in context]}
113
+ if context:
114
+ prompt = build_prompt(args.question, context)
115
+ output['answer'] = call_deepseek(prompt, api_key, max_tokens=800)
116
+ else:
117
+ output['answer'] = '知识库中没有相关信息'
118
+ print(json.dumps(output, ensure_ascii=False, indent=2))
119
+ return
120
+
121
+ print(f'🔍 "{args.question}"\n')
122
+ if not context:
123
+ print('未找到相关内容')
124
+ return
125
+
126
+ print(f'检索到 {len(context)} 条相关内容:\n')
127
+ for i, c in enumerate(context):
128
+ icon = {'concept': '🧠', 'note': '📝', 'article': '📄'}.get(c['source'], '📎')
129
+ print(f' {i+1}. {icon} [{c["source"]}] {c["title"]}')
130
+
131
+ prompt = build_prompt(args.question, context)
132
+ print(f'\n{"="*50}')
133
+ answer = call_deepseek(prompt, api_key, max_tokens=800)
134
+ print(answer)
135
+ print(f'{"="*50}')
136
+
137
+
138
+ if __name__ == '__main__':
139
+ main()
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Vault 全局搜索 — 关键词 + AI 排序,覆盖文章/概念/笔记。
4
+
5
+ 用法:
6
+ python scripts/vault_search.py "遥感反演" --top-k 10
7
+ python scripts/vault_search.py "空气污染" --type article --days 30
8
+ """
9
+
10
+ import sys, os, json, argparse, re
11
+ from pathlib import Path
12
+ from datetime import datetime, timedelta
13
+
14
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
15
+ PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
16
+ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
17
+ DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
18
+
19
+
20
+ def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
21
+ """关键词搜索 + 简单排序。"""
22
+ terms = query.lower().split()
23
+ scored = []
24
+ for filepath, category, meta in files:
25
+ try:
26
+ text = filepath.read_text(encoding='utf-8')[:2000]
27
+ except Exception:
28
+ continue
29
+ text_lower = text.lower()
30
+ # 计分:标题匹配 > 关键词频次
31
+ score = 0
32
+ title = meta.get('title', '')
33
+ if any(t in title.lower() for t in terms):
34
+ score += 10
35
+ for t in terms:
36
+ score += text_lower.count(t)
37
+ if score > 0:
38
+ scored.append({'file': str(filepath), 'category': category, 'title': title,
39
+ 'score': score, 'snippet': text[:300]})
40
+ scored.sort(key=lambda x: x['score'], reverse=True)
41
+ return scored[:top_k]
42
+
43
+
44
+ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> list[tuple]:
45
+ """收集可搜索文件。"""
46
+ files = []
47
+ cutoff = datetime.now() - timedelta(days=days)
48
+
49
+ def parse_fm(text):
50
+ if not text.startswith('---'):
51
+ return {}
52
+ end = text.find('---', 3)
53
+ if end == -1:
54
+ return {}
55
+ fm = {}
56
+ for line in text[3:end].strip().split('\n'):
57
+ if ':' in line:
58
+ k, _, v = line.partition(':')
59
+ fm[k.strip()] = v.strip().strip('"').strip("'")
60
+ return fm
61
+
62
+ if search_type in ('all', 'article'):
63
+ # biz-daily 文章
64
+ biz_dir = Path(biz_daily)
65
+ for date_dir in sorted(biz_dir.glob('20*'), reverse=True):
66
+ try:
67
+ dir_date = datetime.strptime(date_dir.name, '%Y-%m-%d')
68
+ if dir_date < cutoff:
69
+ continue
70
+ except ValueError:
71
+ continue
72
+ for md in date_dir.rglob('*.md'):
73
+ if md.name == 'README.md':
74
+ continue
75
+ try:
76
+ fm = parse_fm(md.read_text(encoding='utf-8'))
77
+ except Exception:
78
+ fm = {}
79
+ files.append((md, 'article', fm))
80
+
81
+ if search_type in ('all', 'concept'):
82
+ vault_path = Path(vault)
83
+ concepts_dir = vault_path / 'Wiki' / 'Concepts'
84
+ if concepts_dir.is_dir():
85
+ for md in concepts_dir.glob('*.md'):
86
+ try:
87
+ fm = parse_fm(md.read_text(encoding='utf-8'))
88
+ except Exception:
89
+ fm = {}
90
+ files.append((md, 'concept', fm))
91
+
92
+ if search_type in ('all', 'note'):
93
+ vault_path = Path(vault)
94
+ notes_dir = vault_path / 'Notes'
95
+ if notes_dir.is_dir():
96
+ for md in notes_dir.rglob('*.md'):
97
+ try:
98
+ fm = parse_fm(md.read_text(encoding='utf-8'))
99
+ except Exception:
100
+ fm = {}
101
+ files.append((md, 'note', fm))
102
+
103
+ return files
104
+
105
+
106
+ def main():
107
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
108
+ parser = argparse.ArgumentParser(description='Vault 全局搜索')
109
+ parser.add_argument('query', nargs='?', default='', help='搜索关键词')
110
+ parser.add_argument('--top-k', default='10', help='返回数量')
111
+ parser.add_argument('--type', default='all', choices=['all', 'article', 'concept', 'note'])
112
+ parser.add_argument('--days', type=int, default=90, help='搜索天数范围')
113
+ parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
114
+ parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
115
+ parser.add_argument('--json', action='store_true', help='JSON 输出')
116
+ args = parser.parse_args()
117
+
118
+ if not args.query:
119
+ print('请提供搜索关键词')
120
+ sys.exit(1)
121
+
122
+ files = collect_files(args.vault, args.biz_daily, args.type, args.days)
123
+ results = search_text(args.query, files, int(args.top_k))
124
+
125
+ if args.json:
126
+ print(json.dumps(results, ensure_ascii=False, indent=2))
127
+ return
128
+
129
+ print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条 (类型: {args.type}, {args.days}天内)\n')
130
+ for i, r in enumerate(results):
131
+ icon = {'article': '📄', 'concept': '🧠', 'note': '📝'}.get(r['category'], '📎')
132
+ num = str(i + 1).rjust(2)
133
+ print(f'{num}. {icon} {r["title"] or r["file"].split("/")[-1]}')
134
+ print(f' {r["category"]} | 相关度: {r["score"]}')
135
+ snippet = re.sub(r'\s+', ' ', r['snippet'][:120])
136
+ print(f' {snippet}')
137
+ print()
138
+
139
+
140
+ if __name__ == '__main__':
141
+ main()