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,531 @@
1
+ """
2
+ weflow-cli 公共工具函数 — 供 biz_daily / classify_daily / chat_report 等共用。
3
+ """
4
+ import os, json, base64, socket, urllib.request, hashlib, time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from functools import wraps
7
+
8
+
9
+ # ======================================================================
10
+ # 工具库(静态知识,供报告「行动指南」自动匹配)
11
+ # 按关键词 → GitHub 项目/工具,匹配越靠前优先级越高
12
+ # ======================================================================
13
+
14
+ TOOL_LIBRARY = [
15
+ # --- AI 编程 / Agent ---
16
+ {'keywords': ['cursor', 'agent', 'ide', '编程助手', '代码生成'],
17
+ 'name': 'Cursor', 'url': 'https://github.com/getcursor/cursor',
18
+ 'desc': 'AI 原生 IDE,支持多文件编辑与 Agent 模式'},
19
+ {'keywords': ['windsurf', 'agent', 'ide'],
20
+ 'name': 'Windsurf', 'url': 'https://codeium.com/windsurf',
21
+ 'desc': 'AI 原生 IDE,类 Cursor,支持任务级自动编程'},
22
+ {'keywords': ['continue', 'agent', 'ide', 'vscode'],
23
+ 'name': 'Continue', 'url': 'https://github.com/continuedev/continue',
24
+ 'desc': '开源 VS Code 插件,接入本地/云端大模型'},
25
+
26
+ # --- 大模型 / 推理 ---
27
+ {'keywords': ['deepseek', 'r1', '开源模型'],
28
+ 'name': 'DeepSeek R1', 'url': 'https://github.com/deepseek-ai/DeepSeek-R1',
29
+ 'desc': 'DeepSeek 开源推理模型,中文表现强'},
30
+ {'keywords': ['llama', 'meta', '开源模型'],
31
+ 'name': 'Llama 3', 'url': 'https://github.com/meta-llama/llama3',
32
+ 'desc': 'Meta 开源大模型,社区生态最成熟'},
33
+ {'keywords': ['ollama', '本地部署', '本地推理'],
34
+ 'name': 'Ollama', 'url': 'https://github.com/ollama/ollama',
35
+ 'desc': '一行命令运行本地大模型(macOS/Linux/Windows)'},
36
+ {'keywords': ['lmstudio', '本地部署', '本地推理'],
37
+ 'name': 'LM Studio', 'url': 'https://lmstudio.ai',
38
+ 'desc': '桌面端本地大模型运行器,有 GUI'},
39
+
40
+ # --- 信息聚合 / 日报 / RAG ---
41
+ {'keywords': ['rag', '检索增强', '知识库', '论文'],
42
+ 'name': 'AnythingLLM', 'url': 'https://github.com/Mintplex-Labs/anything-llm',
43
+ 'desc': '开箱即用的 RAG 知识库 + 聊天桌面应用'},
44
+ {'keywords': ['rss', '信息源', '订阅'],
45
+ 'name': 'Fluent Reader', 'url': 'https://github.com/yang991178/fluent-reader',
46
+ 'desc': '现代化 RSS 阅读器,聚合公众号/博客/新闻'},
47
+
48
+ # --- 环境科学 / 交叉方向 ---
49
+ {'keywords': ['遥感', '卫星', '反演', 'landsat', 'sentinel'],
50
+ 'name': 'Google Earth Engine', 'url': 'https://earthengine.google.com',
51
+ 'desc': '大规模遥感影像在线分析平台'},
52
+ {'keywords': ['遥感', '卫星', 'python'],
53
+ 'name': 'xarray', 'url': 'https://github.com/pydata/xarray',
54
+ 'desc': 'N 维数组处理,气象/遥感数据标配'},
55
+ {'keywords': ['大气', '排放清单', '排放'],
56
+ 'name': 'MEIC', 'url': 'https://meicmodel.org',
57
+ 'desc': '中国多尺度排放清单模型'},
58
+ {'keywords': ['lca', '生命周期', '生命周期评估'],
59
+ 'name': 'brightway', 'url': 'https://github.com/brightway-lca/brightway2',
60
+ 'desc': '开源生命周期评估(LCA)框架'},
61
+ {'keywords': ['数值模拟', '大气', '模型', '模拟'],
62
+ 'name': 'WRF', 'url': 'https://github.com/wrf-model/WRF',
63
+ 'desc': '中尺度数值天气预报模型'},
64
+
65
+ # --- 效率工具 ---
66
+ {'keywords': ['obsidian', '笔记', '知识管理'],
67
+ 'name': 'Obsidian', 'url': 'https://obsidian.md',
68
+ 'desc': '本地优先的 Markdown 笔记,双链/图谱'},
69
+ {'keywords': ['anki', '记忆', '复习'],
70
+ 'name': 'Anki', 'url': 'https://apps.ankiweb.net',
71
+ 'desc': '基于间隔重复的闪卡记忆工具'},
72
+
73
+ # --- 一般关键词兜底 ---
74
+ {'keywords': ['投资', '财报', '股价', '股票'],
75
+ 'name': 'Tushare', 'url': 'https://tushare.pro',
76
+ 'desc': '免费金融数据接口(Python)'},
77
+ ]
78
+
79
+
80
+ def match_tools(articles: list[dict], top_n: int = 8) -> list[dict]:
81
+ """从文章的标题/摘要/标签中抽取关键词,匹配 TOOL_LIBRARY。"""
82
+ collected_text = ''
83
+ for a in articles:
84
+ tags = a.get('tags') or []
85
+ text_parts = [
86
+ a.get('title', ''),
87
+ a.get('summary', ''),
88
+ ' '.join(tags) if isinstance(tags, list) else str(tags),
89
+ ]
90
+ collected_text += ' ' + ' '.join(text_parts)
91
+ collected_text = collected_text.lower()
92
+
93
+ ranked = []
94
+ for tool in TOOL_LIBRARY:
95
+ score = sum(1 for kw in tool['keywords'] if kw.lower() in collected_text)
96
+ if score > 0:
97
+ ranked.append((score, tool))
98
+ ranked.sort(key=lambda x: x[0], reverse=True)
99
+ return [t for _, t in ranked[:top_n]]
100
+
101
+
102
+ # ======================================================================
103
+ # Config
104
+ # ======================================================================
105
+
106
+ CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'config.json')
107
+
108
+
109
+ # ======================================================================
110
+ # AI Engine 抽象
111
+ # ======================================================================
112
+
113
+ class AIEngine:
114
+ """OpenAI-compatible API 引擎基类。"""
115
+ def __init__(self, api_key: str, base_url: str, model: str, timeout=60):
116
+ self.api_key = api_key
117
+ self.base_url = base_url.rstrip('/')
118
+ self.model = model
119
+ self.timeout = timeout
120
+
121
+ def chat(self, prompt: str, max_tokens=2000) -> str:
122
+ payload = json.dumps({
123
+ 'model': self.model,
124
+ 'messages': [{'role': 'user', 'content': prompt}],
125
+ 'max_tokens': max_tokens,
126
+ 'temperature': 0.2,
127
+ }).encode('utf-8')
128
+ req = urllib.request.Request(
129
+ f'{self.base_url}/chat/completions',
130
+ data=payload,
131
+ headers={
132
+ 'Content-Type': 'application/json',
133
+ 'Authorization': f'Bearer {self.api_key}',
134
+ },
135
+ method='POST',
136
+ )
137
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
138
+ data = json.loads(resp.read())
139
+ return data['choices'][0]['message']['content']
140
+
141
+
142
+ class AnthropicEngine(AIEngine):
143
+ """Anthropic Messages API 引擎(/v1/messages)。"""
144
+ def __init__(self, api_key: str, base_url: str, model: str, timeout=120):
145
+ super().__init__(api_key, base_url, model, timeout=timeout)
146
+
147
+ def chat(self, prompt: str, max_tokens=2000) -> str:
148
+ payload = json.dumps({
149
+ 'model': self.model,
150
+ 'messages': [{'role': 'user', 'content': prompt}],
151
+ 'max_tokens': max_tokens,
152
+ }).encode('utf-8')
153
+ req = urllib.request.Request(
154
+ f'{self.base_url}/messages',
155
+ data=payload,
156
+ headers={
157
+ 'Content-Type': 'application/json',
158
+ 'x-api-key': self.api_key,
159
+ 'anthropic-version': '2023-06-01',
160
+ 'Authorization': f'Bearer {self.api_key}',
161
+ },
162
+ method='POST',
163
+ )
164
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
165
+ data = json.loads(resp.read())
166
+ return data['content'][0]['text']
167
+
168
+
169
+ class DeepSeekEngine(AIEngine):
170
+ def __init__(self, api_key: str, model='deepseek-chat', timeout=180):
171
+ super().__init__(api_key, 'https://api.deepseek.com/v1', model, timeout=timeout)
172
+
173
+
174
+ class ClaudeEngine(AIEngine):
175
+ def __init__(self, api_key: str, model='claude-sonnet-4-6', timeout=90):
176
+ super().__init__(api_key, 'https://api.anthropic.com/v1', model, timeout=timeout)
177
+
178
+
179
+ class OllamaEngine(AIEngine):
180
+ def __init__(self, model='llama3', timeout=120):
181
+ super().__init__('ollama', 'http://localhost:11434/v1', model, timeout=timeout)
182
+
183
+
184
+ # ======================================================================
185
+ # 本地引擎自动检测
186
+ # ======================================================================
187
+
188
+ _LOCAL_ENDPOINTS = [
189
+ # (name, base_url, api_style)
190
+ ('ollama', 'http://localhost:11434', 'openai'),
191
+ ('lmstudio', 'http://localhost:1234', 'openai'),
192
+ ('claude', 'http://localhost:8080', 'anthropic'),
193
+ ('lmstudio', 'http://localhost:8000', 'openai'),
194
+ ('lmstudio', 'http://localhost:3000', 'openai'),
195
+ ]
196
+
197
+
198
+ def _try_local_endpoint(name: str, base_url: str, api_style: str,
199
+ timeout: int = 5) -> tuple[bool, str, str, str]:
200
+ health_path = f'{base_url}/v1/models'
201
+ try:
202
+ req = urllib.request.Request(health_path, headers={'Accept': 'application/json'})
203
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
204
+ data = json.loads(resp.read())
205
+ models = data.get('data', data.get('models', []))
206
+ if not models:
207
+ return True, name, base_url, 'local'
208
+ first = models[0]
209
+ model_name = first.get('id', first.get('model', 'local'))
210
+ return True, name, base_url, model_name
211
+ except Exception as e:
212
+ return False, name, base_url, str(e)
213
+
214
+
215
+ def detect_local_engine(timeout: int = 5) -> tuple[bool, AIEngine, str]:
216
+ """自动检测本地推理服务。返回 (detected, engine, description)。"""
217
+ checked = set()
218
+ for name, base_url, api_style in _LOCAL_ENDPOINTS:
219
+ key = (name, base_url)
220
+ if key in checked:
221
+ continue
222
+ checked.add(key)
223
+ ok, dname, durl, model = _try_local_endpoint(name, base_url, api_style, timeout)
224
+ if not ok:
225
+ continue
226
+ if api_style == 'anthropic':
227
+ engine = AnthropicEngine('local', durl, model, timeout=180)
228
+ else:
229
+ engine = OllamaEngine(model=model, timeout=180)
230
+ engine.base_url = durl.rstrip('/') + '/v1'
231
+ desc = f'{dname} ({model}@{durl})'
232
+ return True, engine, desc
233
+ return False, None, '无可用本地推理服务'
234
+
235
+
236
+ def create_engine(engine_type: str, api_key: str = '') -> AIEngine:
237
+ """工厂函数:根据类型创建 AI 引擎。
238
+
239
+ 引擎类型: local(自动检测) / deepseek / claude / ollama
240
+ """
241
+ t = engine_type.lower()
242
+ if t == 'local':
243
+ ok, engine, desc = detect_local_engine(timeout=8)
244
+ if not ok:
245
+ raise RuntimeError(
246
+ '未检测到本地推理服务。请启动以下任一服务后重试:\n'
247
+ ' • Ollama: ollama serve (11434)\n'
248
+ ' • LM Studio: lmstudio server start (1234)\n'
249
+ ' • Claude Code 本地服务 (8080)\n'
250
+ '或使用 --engine deepseek/claude 指定云端引擎。'
251
+ )
252
+ return engine
253
+ elif t == 'deepseek':
254
+ return DeepSeekEngine(api_key)
255
+ elif t == 'claude':
256
+ return ClaudeEngine(api_key)
257
+ elif t == 'ollama':
258
+ return OllamaEngine()
259
+ raise ValueError(
260
+ f'未知引擎: {engine_type},可选: local / deepseek / claude / ollama'
261
+ )
262
+
263
+
264
+ def call_deepseek(prompt: str, api_key: str, max_tokens=2000, timeout=60) -> str:
265
+ """向后兼容:调用 DeepSeek API。"""
266
+ return DeepSeekEngine(api_key, timeout=timeout).chat(prompt, max_tokens=max_tokens)
267
+
268
+
269
+ # ======================================================================
270
+ # 缓存机制 — 避免重跑 pipeline 时重复调用 API
271
+ # ======================================================================
272
+
273
+ _CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
274
+ 'output', '.cache')
275
+
276
+
277
+ def _cache_key(*args, **kwargs) -> str:
278
+ """根据参数生成 MD5 缓存 key。"""
279
+ raw = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, ensure_ascii=False)
280
+ return hashlib.md5(raw.encode('utf-8')).hexdigest()
281
+
282
+
283
+ def cached_call(func, *args, cache_subdir: str = '', **kwargs):
284
+ """带缓存的函数调用。命中缓存则直接返回,否则调用 func 并存储结果。
285
+
286
+ Args:
287
+ func: 要调用的函数(需返回可 JSON 序列化的结果)
288
+ cache_subdir: 缓存子目录名(如 'classify'、'wiki')
289
+ """
290
+ cache_dir = os.path.join(_CACHE_DIR, cache_subdir) if cache_subdir else _CACHE_DIR
291
+ os.makedirs(cache_dir, exist_ok=True)
292
+ key = _cache_key(func.__name__, *args, **kwargs)
293
+ cache_file = os.path.join(cache_dir, f'{key}.json')
294
+
295
+ if os.path.exists(cache_file):
296
+ try:
297
+ with open(cache_file, 'r', encoding='utf-8') as f:
298
+ return json.load(f)
299
+ except (json.JSONDecodeError, OSError):
300
+ pass # 缓存损坏,重新调用
301
+
302
+ result = func(*args, **kwargs)
303
+ try:
304
+ with open(cache_file, 'w', encoding='utf-8') as f:
305
+ json.dump(result, f, ensure_ascii=False)
306
+ except (TypeError, OSError):
307
+ pass # 结果不可序列化或写入失败,跳过缓存
308
+
309
+ return result
310
+
311
+
312
+ def clear_cache(cache_subdir: str = ''):
313
+ """清空指定子目录的缓存,或全部缓存。"""
314
+ target = os.path.join(_CACHE_DIR, cache_subdir) if cache_subdir else _CACHE_DIR
315
+ if not os.path.isdir(target):
316
+ return
317
+ for fname in os.listdir(target):
318
+ fpath = os.path.join(target, fname)
319
+ if os.path.isfile(fpath):
320
+ os.unlink(fpath)
321
+
322
+
323
+ # ======================================================================
324
+ # 并发 API 调用 — 替代串行 + time.sleep() 模式
325
+ # ======================================================================
326
+
327
+ def parallel_map(func, items, max_workers: int = 3, delay: float = 0.1,
328
+ desc: str = '') -> list:
329
+ """并发执行函数,自动控制并发数和速率。
330
+
331
+ Args:
332
+ func: 接受单个 item 的函数,返回结果
333
+ items: 待处理的列表
334
+ max_workers: 最大并发数(默认 3,避免 API 限流)
335
+ delay: 每个任务提交后的延迟(秒),用于速率控制
336
+ desc: 进度描述(用于日志输出)
337
+
338
+ Returns:
339
+ 与 items 顺序对应的结果列表
340
+ """
341
+ results = [None] * len(items)
342
+ errors = [None] * len(items)
343
+
344
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
345
+ future_to_idx = {}
346
+ for idx, item in enumerate(items):
347
+ future = executor.submit(func, item)
348
+ future_to_idx[future] = idx
349
+ if delay > 0:
350
+ time.sleep(delay)
351
+
352
+ done_count = 0
353
+ total = len(items)
354
+ for future in as_completed(future_to_idx):
355
+ idx = future_to_idx[future]
356
+ try:
357
+ results[idx] = future.result()
358
+ except Exception as e:
359
+ errors[idx] = e
360
+ if desc:
361
+ print(f' [{desc}] 第 {idx+1}/{total} 项失败: {e}')
362
+ done_count += 1
363
+ if desc and done_count % 10 == 0:
364
+ print(f' [{desc}] 进度: {done_count}/{total}')
365
+
366
+ # 报告错误统计
367
+ fail_count = sum(1 for e in errors if e is not None)
368
+ if fail_count > 0 and desc:
369
+ print(f' [{desc}] 完成: {total - fail_count} 成功, {fail_count} 失败')
370
+
371
+ return results
372
+
373
+
374
+ # ======================================================================
375
+ # Config Decrypt
376
+ # ======================================================================
377
+
378
+ def load_config():
379
+ with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
380
+ return json.load(f)
381
+
382
+
383
+ def decrypt_lock(locked_str: str) -> str:
384
+ if not locked_str or not locked_str.startswith('lock:'):
385
+ return locked_str
386
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
387
+ from cryptography.hazmat.primitives import hashes
388
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
389
+ from cryptography.hazmat.backends import default_backend
390
+
391
+ raw = base64.b64decode(locked_str[5:])
392
+ salt, iv, auth_tag, ciphertext = raw[:16], raw[16:28], raw[28:44], raw[44:]
393
+ machine_id = f'{socket.gethostname()}-{os.environ.get("USERNAME", "")}-weflow-cli'
394
+ kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000,
395
+ backend=default_backend())
396
+ key = kdf.derive(machine_id.encode('utf-8'))
397
+ aesgcm = AESGCM(key)
398
+ return aesgcm.decrypt(iv, ciphertext + auth_tag, None).decode()
399
+
400
+
401
+ def get_db_config(config=None):
402
+ if config is None:
403
+ config = load_config()
404
+ return {
405
+ 'nt_db': config.get('ntDbPath', ''),
406
+ 'nt_key': decrypt_lock(config.get('ntKey', '')),
407
+ 'nt_salt': config.get('ntSalt', ''),
408
+ 'contact_db': config.get('contactDbPath', ''),
409
+ 'contact_key': decrypt_lock(config.get('contactKey', '')),
410
+ 'contact_salt': config.get('contactSalt', ''),
411
+ }
412
+
413
+
414
+ # ======================================================================
415
+ # Markdown / Frontmatter
416
+ # ======================================================================
417
+
418
+ def write_with_frontmatter(filepath: str, frontmatter: dict, body: str):
419
+ import tempfile as _tmp
420
+ fm_lines = ['---']
421
+ for k, v in frontmatter.items():
422
+ if isinstance(v, list):
423
+ fm_lines.append(f'{k}: [{", ".join(v)}]')
424
+ elif isinstance(v, str) and ('"' in v or ':' in v or '#' in v):
425
+ vs = v.strip()
426
+ fm_lines.append(f'{k}: "{vs}"' if not (vs.startswith('"') and vs.endswith('"'))
427
+ else f'{k}: {v}')
428
+ else:
429
+ fm_lines.append(f'{k}: {v}')
430
+ fm_lines.append('---')
431
+ fm_block = '\n'.join(fm_lines) + '\n\n'
432
+ dir_name = os.path.dirname(filepath) or '.'
433
+ fd, tmp_path = _tmp.mkstemp(suffix='.md', dir=dir_name)
434
+ try:
435
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
436
+ f.write(fm_block)
437
+ f.write(body)
438
+ os.replace(tmp_path, filepath)
439
+ except Exception:
440
+ if os.path.exists(tmp_path):
441
+ os.unlink(tmp_path)
442
+ raise
443
+
444
+
445
+ def parse_frontmatter(content: str) -> tuple[dict, str]:
446
+ """解析 Markdown 文件中的 YAML frontmatter。"""
447
+ if not content.startswith('---'):
448
+ return {}, content
449
+ end = content.find('---', 3)
450
+ if end == -1:
451
+ return {}, content
452
+ fm_text = content[3:end].strip()
453
+ body = content[end + 3:].lstrip('\n')
454
+ result = {}
455
+ for line in fm_text.split('\n'):
456
+ line = line.strip()
457
+ if not line or ':' not in line:
458
+ continue
459
+ key, _, val = line.partition(':')
460
+ key = key.strip()
461
+ val = val.strip()
462
+ if val.startswith('[') and val.endswith(']'):
463
+ inner = val[1:-1]
464
+ items = [v.strip().strip('"\'') for v in inner.split(',')] if inner.strip() else []
465
+ result[key] = items
466
+ elif val.startswith('"') and val.endswith('"'):
467
+ result[key] = val[1:-1]
468
+ elif val.startswith("'") and val.endswith("'"):
469
+ result[key] = val[1:-1]
470
+ else:
471
+ result[key] = val
472
+ return result, body
473
+
474
+
475
+ def format_wikilinks(concepts: list[tuple[str, str]]) -> str:
476
+ lines = ['## 相关概念', '']
477
+ for name, desc in concepts:
478
+ if desc:
479
+ lines.append(f'- [[{name}]] — {desc}')
480
+ else:
481
+ lines.append(f'- [[{name}]]')
482
+ return '\n'.join(lines) + '\n'
483
+
484
+
485
+ # ======================================================================
486
+ # 用户定位 & 行动建议(向后兼容)
487
+ # ======================================================================
488
+
489
+ DEFAULT_USER_PROFILE = (
490
+ '你是一名对AI感兴趣的环境科学研究生,研究方向是计算机与环境的交叉领域'
491
+ '(如环境模型、大气污染模拟、遥感反演、环境大数据分析、LCA生命周期评估等)。'
492
+ '你关注AI工具如何提升科研效率、环境数据处理新技术、以及交叉领域的学术机会。'
493
+ )
494
+
495
+ ACTION_PROMPT = """基于以下文章,为读者生成可落地的行动建议。
496
+
497
+ 【读者定位】
498
+ {profile}
499
+
500
+ 【文章信息】
501
+ 标题:{title}
502
+ 来源:{source}
503
+ 主题:{topic}
504
+ 摘要:{summary}
505
+
506
+ 【正文节选】
507
+ {content}
508
+
509
+ 【输出格式】
510
+ ### 相关度
511
+ (高/中/低 + 一句话解释)
512
+
513
+ ### 行动建议
514
+ - **立即可做**:1-2个今天就能执行的具体动作
515
+ - **本周计划**:1个本周可以推进的中期动作
516
+ - **长期关注**:1个值得持续跟踪的方向(相关度为低时省略)"""
517
+
518
+
519
+ def generate_action_suggestion(title: str, source: str, topic: str,
520
+ summary: str, content: str,
521
+ api_key: str, profile: str = '',
522
+ engine_type: str = 'deepseek',
523
+ max_tokens: int = 1500) -> str:
524
+ if not profile:
525
+ profile = DEFAULT_USER_PROFILE
526
+ prompt = ACTION_PROMPT.format(
527
+ profile=profile, title=title, source=source, topic=topic,
528
+ summary=summary[:500], content=content[:3000],
529
+ )
530
+ engine = create_engine(engine_type, api_key)
531
+ return engine.chat(prompt, max_tokens=max_tokens)