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,431 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 全局语义搜索 — 基于阿里云百炼 Embedding API (text-embedding-v4) + NumPy。
4
+
5
+ 用法:
6
+ # 构建索引(首次或增量)
7
+ python scripts/semantic_search.py build
8
+
9
+ # 搜索(有索引用向量,否则关键词 fallback)
10
+ python scripts/semantic_search.py search "有人推荐过遥感的工具吗" --top-k 10
11
+
12
+ # 增量更新(只处理新数据)
13
+ python scripts/semantic_search.py update
14
+
15
+ 输出: JSON 格式
16
+ """
17
+
18
+ import sys, os, json, hashlib, argparse
19
+ from datetime import datetime, timezone, timedelta
20
+ from pathlib import Path
21
+
22
+ try:
23
+ import numpy as np
24
+ except ImportError:
25
+ print(json.dumps({"error": "请安装: pip install numpy"}))
26
+ sys.exit(1)
27
+
28
+ try:
29
+ from sqlcipher3 import dbapi2 as sqlcipher
30
+ except ImportError:
31
+ print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
32
+ sys.exit(1)
33
+
34
+ try:
35
+ import urllib.request
36
+ import urllib.parse
37
+ except:
38
+ pass
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+ from _utils import load_config, decrypt_lock
42
+
43
+ OUTPUT_ROOT = 'output'
44
+ INDEX_DIR = Path(OUTPUT_ROOT) / '.semantic_index'
45
+ VECTORS_FILE = INDEX_DIR / 'vectors.npy'
46
+ META_FILE = INDEX_DIR / 'meta.json'
47
+ EMBEDDING_DIM = 1024 # 阿里云 text-embedding-v4
48
+ BATCH_SIZE = 10 # 阿里云 text-embedding-v4 单次最多 10 条
49
+ TZ = timezone(timedelta(hours=8))
50
+
51
+
52
+ def json_output(data):
53
+ print(json.dumps(data, ensure_ascii=False, indent=2))
54
+
55
+
56
+ # ====== Embedding API ======
57
+
58
+ def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
59
+ """Call 阿里云百炼 embedding API (OpenAI-compatible)."""
60
+ if not texts:
61
+ return []
62
+
63
+ url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
64
+ headers = {
65
+ "Authorization": f"Bearer {api_key}",
66
+ "Content-Type": "application/json",
67
+ }
68
+
69
+ all_embeddings = []
70
+ for i in range(0, len(texts), BATCH_SIZE):
71
+ batch = texts[i:i + BATCH_SIZE]
72
+ data = {
73
+ "model": "text-embedding-v4",
74
+ "input": batch,
75
+ }
76
+
77
+ req = urllib.request.Request(
78
+ url,
79
+ data=json.dumps(data).encode('utf-8'),
80
+ headers=headers,
81
+ )
82
+
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=120) as resp:
85
+ result = json.loads(resp.read().decode('utf-8'))
86
+ embeddings = [item['embedding'] for item in result['data']]
87
+ all_embeddings.extend(embeddings)
88
+ except Exception as e:
89
+ err_body = ''
90
+ if hasattr(e, 'read'):
91
+ try: err_body = e.read().decode()[:300]
92
+ except: pass
93
+ print(f"[WARN] Embedding API batch {i//BATCH_SIZE + 1} 失败: {e} {err_body}", file=sys.stderr)
94
+ all_embeddings.extend([[0.0] * EMBEDDING_DIM for _ in batch])
95
+
96
+ return all_embeddings
97
+
98
+
99
+ # ====== Data Collection ======
100
+
101
+ def open_db(db_path, key_hex, salt_hex):
102
+ raw_key = f"x'{key_hex}{salt_hex}'"
103
+ conn = sqlcipher.connect(db_path)
104
+ c = conn.cursor()
105
+ c.execute(f'PRAGMA key = "{raw_key}";')
106
+ c.execute("SELECT count(*) FROM sqlite_master")
107
+ return conn
108
+
109
+
110
+ def get_name_map(contact_db, contact_key, contact_salt):
111
+ name_map = {}
112
+ if not contact_db or not contact_key or not os.path.exists(contact_db):
113
+ return name_map
114
+ try:
115
+ conn = open_db(contact_db, contact_key, contact_salt)
116
+ c = conn.cursor()
117
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
118
+ for r in c.fetchall():
119
+ name_map[r[0]] = r[1]
120
+ conn.close()
121
+ except:
122
+ pass
123
+ return name_map
124
+
125
+
126
+ def collect_chat_messages(conn, name_map, days=90):
127
+ """Collect chat messages for indexing."""
128
+ c = conn.cursor()
129
+ now = datetime.now(TZ)
130
+ start_ts = int((now - timedelta(days=days)).timestamp())
131
+
132
+ try:
133
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
134
+ sessions = [r[0] for r in c.fetchall()]
135
+ except:
136
+ c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
137
+ sessions = [r[0] for r in c.fetchall()]
138
+
139
+ items = []
140
+ for talker in sessions[:100]: # Limit to avoid too many
141
+ tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
142
+ try:
143
+ c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
144
+ if c.fetchone()[0] == 0:
145
+ continue
146
+
147
+ c.execute(f'''
148
+ SELECT create_time, real_sender_id, message_content
149
+ FROM "{tbl}"
150
+ WHERE create_time >= ?
151
+ ORDER BY create_time DESC
152
+ LIMIT 500
153
+ ''', (start_ts,))
154
+
155
+ for ts, sender, content in c.fetchall():
156
+ if not content or not isinstance(content, str) or len(content) < 10:
157
+ continue
158
+ sender_name = name_map.get(sender, sender) if sender else name_map.get(talker, talker)
159
+ dt = datetime.fromtimestamp(ts, tz=TZ)
160
+ items.append({
161
+ "id": f"chat:{talker}:{ts}",
162
+ "type": "chat",
163
+ "talker": name_map.get(talker, talker),
164
+ "sender": sender_name,
165
+ "time": dt.strftime('%Y-%m-%d %H:%M'),
166
+ "text": content[:500],
167
+ })
168
+ except:
169
+ pass
170
+
171
+ return items
172
+
173
+
174
+ def collect_articles():
175
+ """Collect articles from biz-daily."""
176
+ items = []
177
+ daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
178
+ if not daily_dir.exists():
179
+ return items
180
+
181
+ for date_dir in sorted(daily_dir.iterdir(), reverse=True)[:30]: # Last 30 days
182
+ if not date_dir.is_dir():
183
+ continue
184
+ for topic_dir in date_dir.iterdir():
185
+ if not topic_dir.is_dir():
186
+ continue
187
+ for f in topic_dir.glob(' marriage*.md'):
188
+ if f.name == 'README.md':
189
+ continue
190
+ try:
191
+ content = f.read_text(encoding='utf-8')
192
+ # Extract title and body
193
+ lines = content.split('\n')
194
+ title = ''
195
+ body_start = 0
196
+ for i, line in enumerate(lines):
197
+ if line.startswith('title:'):
198
+ title = line.split(':', 1)[1].strip().strip('"')
199
+ if line.startswith('## 正文'):
200
+ body_start = i + 1
201
+ break
202
+ body = '\n'.join(lines[body_start:body_start + 50]) if body_start else content[:1000]
203
+ if title:
204
+ items.append({
205
+ "id": f"article:{f.relative_to(daily_dir)}",
206
+ "type": "article",
207
+ "title": title,
208
+ "date": date_dir.name,
209
+ "topic": topic_dir.name,
210
+ "text": f"{title}\n{body[:500]}",
211
+ })
212
+ except:
213
+ pass
214
+
215
+ return items
216
+
217
+
218
+ # ====== Index Operations ======
219
+
220
+ def build_index(api_key: str, full: bool = False):
221
+ """Build or update the semantic index."""
222
+ INDEX_DIR.mkdir(parents=True, exist_ok=True)
223
+
224
+ # Load existing index
225
+ existing_ids = set()
226
+ if not full and META_FILE.exists():
227
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
228
+ existing_ids = {item['id'] for item in meta}
229
+ print(f"现有索引: {len(existing_ids)} 条", file=sys.stderr)
230
+
231
+ # Collect data
232
+ print("收集数据...", file=sys.stderr)
233
+
234
+ # Load DB
235
+ config = load_config()
236
+ nt_db = config.get('ntDbPath', '')
237
+ if not nt_db:
238
+ return {"error": "未初始化,请先运行 weflow-cli init"}
239
+
240
+ nt_key = decrypt_lock(config.get('ntKey', ''))
241
+ nt_salt = config.get('ntSalt', '')
242
+
243
+ # Get name map
244
+ msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
245
+ wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
246
+ contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
247
+ contact_key_enc = config.get('contactKey', '')
248
+ contact_salt = config.get('contactSalt', '')
249
+ contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
250
+ name_map = get_name_map(contact_db, contact_key, contact_salt)
251
+
252
+ # Collect items
253
+ items = []
254
+ try:
255
+ conn = open_db(nt_db, nt_key, nt_salt)
256
+ chat_items = collect_chat_messages(conn, name_map, days=90)
257
+ items.extend(chat_items)
258
+ conn.close()
259
+ except Exception as e:
260
+ print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
261
+
262
+ article_items = collect_articles()
263
+ items.extend(article_items)
264
+
265
+ # Filter new items
266
+ new_items = [item for item in items if item['id'] not in existing_ids]
267
+ print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
268
+
269
+ if not new_items:
270
+ return {"status": "up_to_date", "total": len(items)}
271
+
272
+ # Generate embeddings
273
+ print("生成 embeddings...", file=sys.stderr)
274
+ texts = [item['text'] for item in new_items]
275
+ embeddings = get_embeddings(texts, api_key)
276
+
277
+ if not embeddings or all(e == [0.0] * EMBEDDING_DIM for e in embeddings):
278
+ return {"error": "Embedding 生成失败,请检查 API key"}
279
+
280
+ # Load existing vectors
281
+ if VECTORS_FILE.exists() and not full:
282
+ vectors = np.load(VECTORS_FILE)
283
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
284
+ else:
285
+ vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
286
+ meta = []
287
+
288
+ # Append new vectors
289
+ new_vectors = np.array(embeddings, dtype=np.float32)
290
+ vectors = np.vstack([vectors, new_vectors]) if vectors.size else new_vectors
291
+ meta.extend(new_items)
292
+
293
+ # Normalize vectors for cosine similarity
294
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
295
+ norms[norms == 0] = 1 # Avoid division by zero
296
+ vectors = vectors / norms
297
+
298
+ # Save
299
+ np.save(VECTORS_FILE, vectors)
300
+ META_FILE.write_text(json.dumps(meta, ensure_ascii=False), encoding='utf-8')
301
+
302
+ return {
303
+ "status": "success",
304
+ "total": len(meta),
305
+ "new": len(new_items),
306
+ }
307
+
308
+
309
+ def keyword_search(query: str, top_k: int = 10):
310
+ """Simple keyword fallback: scan articles + messages for keyword matches."""
311
+ results = []
312
+ keywords = query.lower().split()
313
+
314
+ # Scan biz-daily articles
315
+ biz_dir = Path('output/biz-daily')
316
+ if biz_dir.exists():
317
+ for md_file in sorted(biz_dir.rglob('*.md'), reverse=True):
318
+ if md_file.name == 'README.md' or md_file.name.startswith('.'):
319
+ continue
320
+ try:
321
+ content = md_file.read_text(encoding='utf-8')[:5000]
322
+ except:
323
+ continue
324
+ score = sum(content.lower().count(kw) for kw in keywords)
325
+ if score > 0:
326
+ title = content.split('\n')[0].lstrip('# ').strip() if content.startswith('#') else md_file.stem
327
+ results.append({
328
+ 'title': title,
329
+ 'source': str(md_file.relative_to(biz_dir)),
330
+ 'score': score,
331
+ 'text': content[:300].strip(),
332
+ })
333
+
334
+ # Scan chat messages from mcp_bridge
335
+ try:
336
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
337
+ from mcp_bridge import search_messages as _search_msg
338
+ msg_results = _search_msg(query, 50)
339
+ for r in msg_results.get('results', [])[:top_k]:
340
+ results.append({
341
+ 'title': f"[{r.get('time','')}] {r.get('talker','')} > {r.get('sender','')}",
342
+ 'source': '微信聊天',
343
+ 'score': len(query),
344
+ 'text': r.get('content', '')[:200],
345
+ })
346
+ except:
347
+ pass
348
+
349
+ results.sort(key=lambda x: -x['score'])
350
+ return results[:top_k]
351
+
352
+
353
+ def search(query: str, api_key: str, top_k: int = 10):
354
+ """Semantic search with keyword fallback."""
355
+ # Try embedding-based search first
356
+ if VECTORS_FILE.exists() and META_FILE.exists():
357
+ try:
358
+ vectors = np.load(VECTORS_FILE)
359
+ meta = json.loads(META_FILE.read_text(encoding='utf-8'))
360
+ query_embeddings = get_embeddings([query], api_key)
361
+ if query_embeddings and not all(v == 0 for v in query_embeddings[0]):
362
+ query_vec = np.array(query_embeddings[0], dtype=np.float32)
363
+ query_vec = query_vec / np.linalg.norm(query_vec)
364
+ similarities = np.dot(vectors, query_vec)
365
+ top_indices = np.argsort(similarities)[::-1][:top_k]
366
+ results = []
367
+ for idx in top_indices:
368
+ item = meta[idx]
369
+ results.append({**item, 'score': float(similarities[idx])})
370
+ return results
371
+ except:
372
+ pass
373
+
374
+ # Fallback: keyword search
375
+ return keyword_search(query, top_k)
376
+
377
+ results = []
378
+ for idx in top_indices:
379
+ item = meta[idx]
380
+ results.append({
381
+ **item,
382
+ "score": float(similarities[idx]),
383
+ })
384
+
385
+ return {
386
+ "query": query,
387
+ "total": len(meta),
388
+ "results": results,
389
+ }
390
+
391
+
392
+ # ====== Main ======
393
+
394
+ def main():
395
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
396
+ parser = argparse.ArgumentParser()
397
+ subparsers = parser.add_subparsers(dest='command')
398
+
399
+ # build
400
+ p = subparsers.add_parser('build')
401
+ p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
402
+ p.add_argument('--full', action='store_true', help='全量重建')
403
+
404
+ # update
405
+ p = subparsers.add_parser('update')
406
+ p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
407
+
408
+ # search
409
+ p = subparsers.add_parser('search')
410
+ p.add_argument('query')
411
+ p.add_argument('--api-key', help='DeepSeek API key(向量搜索时需要,关键词 fallback 不需要)')
412
+ p.add_argument('--top-k', type=int, default=10)
413
+
414
+ args = parser.parse_args()
415
+ config = load_config()
416
+ api_key = args.api_key or os.environ.get('DASHSCOPE_API_KEY', '') or config.get('dashscopeApiKey', '')
417
+
418
+ if args.command == 'build':
419
+ result = build_index(api_key, full=args.full)
420
+ elif args.command == 'update':
421
+ result = build_index(api_key, full=False)
422
+ elif args.command == 'search':
423
+ result = search(args.query, api_key, top_k=args.top_k)
424
+ else:
425
+ result = {"error": f"未知命令: {args.command}"}
426
+
427
+ json_output(result)
428
+
429
+
430
+ if __name__ == '__main__':
431
+ main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 同步收藏夹 — 根据 .fav_state.json 将收藏文章 symlink 到 收藏/ 文件夹。
4
+
5
+ 用法:
6
+ python scripts/sync_fav.py --date 2026-05-19
7
+ python scripts/sync_fav.py --date 2026-05-19 --add "AI/某文章.md" --remove "学术/某文章.md"
8
+
9
+ .fav_state.json 格式: ["AI/文章1.md", "学术/文章2.md", ...]
10
+ 与 HTML localStorage 的 weflow_fav_{date} 格式一致。
11
+ """
12
+
13
+ import sys, os, json
14
+ from pathlib import Path
15
+ from datetime import datetime
16
+
17
+ SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ SOURCE_ROOT = os.path.join(os.path.dirname(SCRIPTS_DIR), 'output', 'biz-daily')
19
+
20
+
21
+ def sync_favorites(date_str: str):
22
+ """读取 .fav_state.json 并同步 收藏/ 文件夹中的 symlink。"""
23
+ date_dir = Path(SOURCE_ROOT) / date_str
24
+ if not date_dir.is_dir():
25
+ print(f'[ERROR] 目录不存在: {date_dir}')
26
+ return
27
+
28
+ fav_dir = date_dir / '收藏'
29
+ fav_state_file = date_dir / '.fav_state.json'
30
+
31
+ # 读取收藏列表
32
+ if fav_state_file.exists():
33
+ try:
34
+ with open(fav_state_file, 'r', encoding='utf-8') as f:
35
+ fav_list = json.load(f)
36
+ except Exception:
37
+ print(f'[ERROR] 无法解析 .fav_state.json')
38
+ return
39
+ else:
40
+ fav_list = []
41
+
42
+ if not fav_list:
43
+ print('收藏列表为空,清理 收藏/ 文件夹...')
44
+ if fav_dir.is_dir():
45
+ for link in fav_dir.iterdir():
46
+ if link.is_symlink() or link.is_file():
47
+ link.unlink()
48
+ print(f' 移除: {link.name}')
49
+ return
50
+
51
+ # 创建收藏文件夹
52
+ fav_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+ # 构建期望的链接集合
55
+ desired = set()
56
+ for rel_path in fav_list:
57
+ src = date_dir / rel_path
58
+ if src.exists():
59
+ desired.add(rel_path)
60
+ else:
61
+ print(f'[WARN] 源文件不存在: {rel_path}')
62
+
63
+ # 清理不在列表中的旧链接
64
+ existing = set()
65
+ for item in fav_dir.iterdir():
66
+ if item.is_symlink():
67
+ existing.add(item.name)
68
+ if item.name not in {Path(p).name for p in desired}:
69
+ item.unlink()
70
+ print(f' 移除: {item.name}')
71
+ elif item.is_file():
72
+ # 非 symlink 的文件也清理
73
+ existing.add(item.name)
74
+ if item.name not in {Path(p).name for p in desired}:
75
+ item.unlink()
76
+ print(f' 移除: {item.name}')
77
+
78
+ # 创建缺失的 symlink
79
+ for rel_path in desired:
80
+ src = date_dir / rel_path
81
+ link = fav_dir / src.name
82
+ if not link.exists():
83
+ try:
84
+ link.symlink_to(os.path.relpath(src, fav_dir))
85
+ print(f' 添加: {src.name}')
86
+ except OSError:
87
+ # Windows 可能不支持 symlink,尝试复制
88
+ import shutil
89
+ shutil.copy2(src, link)
90
+ print(f' 复制: {src.name}')
91
+
92
+
93
+ def manage_fav(date_str: str, add: list = None, remove: list = None):
94
+ """手动添加/移除收藏项。"""
95
+ date_dir = Path(SOURCE_ROOT) / date_str
96
+ fav_state_file = date_dir / '.fav_state.json'
97
+
98
+ fav_list = []
99
+ if fav_state_file.exists():
100
+ try:
101
+ with open(fav_state_file, 'r', encoding='utf-8') as f:
102
+ fav_list = json.load(f)
103
+ except Exception:
104
+ pass
105
+
106
+ changed = False
107
+ if add:
108
+ for item in add:
109
+ if item not in fav_list:
110
+ fav_list.append(item)
111
+ print(f' 收藏: {item}')
112
+ changed = True
113
+
114
+ if remove:
115
+ for item in remove:
116
+ if item in fav_list:
117
+ fav_list.remove(item)
118
+ print(f' 取消收藏: {item}')
119
+ changed = True
120
+
121
+ if changed:
122
+ with open(fav_state_file, 'w', encoding='utf-8') as f:
123
+ json.dump(fav_list, f, ensure_ascii=False, indent=2)
124
+ print(f'已更新 .fav_state.json ({len(fav_list)} 篇收藏)')
125
+
126
+ sync_favorites(date_str)
127
+
128
+
129
+ def main():
130
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
131
+ import argparse
132
+ parser = argparse.ArgumentParser(description='同步收藏夹到 收藏/ 文件夹')
133
+ parser.add_argument('--date', required=True, help='日期 YYYY-MM-DD')
134
+ parser.add_argument('--add', nargs='*', help='添加收藏 (相对路径)')
135
+ parser.add_argument('--remove', nargs='*', help='取消收藏 (相对路径)')
136
+ args = parser.parse_args()
137
+
138
+ if args.add or args.remove:
139
+ manage_fav(args.date, args.add, args.remove)
140
+ else:
141
+ sync_favorites(args.date)
142
+ print(f'✓ 收藏同步完成')
143
+
144
+
145
+ if __name__ == '__main__':
146
+ main()