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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- 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()
|