weflow-cli 1.7.0 → 1.8.1

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 (200) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +2108 -2
  3. package/OPERATIONS.md +810 -1
  4. package/README.en.md +96 -10
  5. package/README.md +34 -9
  6. package/bin/weflow-cli.ts +937 -28
  7. package/dist/bin/weflow-cli.js +955 -28
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +25 -6
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +599 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +217 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1116 -59
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/services/wereadService.d.ts +33 -6
  100. package/dist/src/services/wereadService.d.ts.map +1 -1
  101. package/dist/src/services/wereadService.js +27 -2
  102. package/dist/src/services/wereadService.js.map +1 -1
  103. package/dist/src/utils/atomicWrite.d.ts +4 -0
  104. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  105. package/dist/src/utils/atomicWrite.js +60 -0
  106. package/dist/src/utils/atomicWrite.js.map +1 -0
  107. package/dist/src/utils/dateRange.d.ts +15 -0
  108. package/dist/src/utils/dateRange.d.ts.map +1 -1
  109. package/dist/src/utils/dateRange.js +47 -0
  110. package/dist/src/utils/dateRange.js.map +1 -1
  111. package/dist/src/utils/text.d.ts +18 -0
  112. package/dist/src/utils/text.d.ts.map +1 -0
  113. package/dist/src/utils/text.js +20 -0
  114. package/dist/src/utils/text.js.map +1 -0
  115. package/docs/AI_INTERFACE.md +2 -0
  116. package/docs/BRANCHES.md +13 -4
  117. package/docs/DECISIONS.md +1424 -0
  118. package/docs/EXTENDING.md +187 -0
  119. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  120. package/docs/MCP.md +23 -4
  121. package/docs/PROJECT_STATE.md +210 -9
  122. package/docs/SETUP.md +22 -0
  123. package/docs/SYNC_CONTRACT.md +31 -3
  124. package/mcp-server/index.ts +681 -657
  125. package/package.json +84 -83
  126. package/resources/panel/ball-position.cjs +153 -0
  127. package/resources/panel/index.html +46 -0
  128. package/resources/panel/main.cjs +483 -0
  129. package/resources/panel/mascot-happy.png +0 -0
  130. package/resources/panel/mascot-sorry.png +0 -0
  131. package/resources/panel/mascot-thinking.png +0 -0
  132. package/resources/panel/mascot-tired.png +0 -0
  133. package/resources/panel/mascot.png +0 -0
  134. package/resources/panel/package.json +7 -0
  135. package/resources/panel/panel.css +415 -0
  136. package/resources/panel/preload.cjs +79 -0
  137. package/resources/panel/quick-menu.cjs +197 -0
  138. package/resources/panel/renderer.js +470 -0
  139. package/resources/panel/tray-menu.cjs +40 -0
  140. package/resources/panel/tray.png +0 -0
  141. package/scripts/_utils.py +425 -54
  142. package/scripts/annual_report.py +4 -2
  143. package/scripts/article_notes.py +423 -0
  144. package/scripts/backfill_articles.py +438 -0
  145. package/scripts/biz_daily.py +590 -105
  146. package/scripts/chat_notes.py +470 -0
  147. package/scripts/classify_daily.py +10 -18
  148. package/scripts/compile_wiki.py +1060 -46
  149. package/scripts/create_reading_notes.py +199 -14
  150. package/scripts/decide.py +253 -0
  151. package/scripts/draft_reply.py +671 -0
  152. package/scripts/export_chat_html.py +35 -33
  153. package/scripts/extract_todos.py +31 -5
  154. package/scripts/fav_notes.py +318 -0
  155. package/scripts/fix_topics.py +2 -1
  156. package/scripts/generate_ai_report.py +214 -9
  157. package/scripts/generate_html.py +18 -3
  158. package/scripts/generate_review.py +2 -2
  159. package/scripts/jev_client.py +294 -0
  160. package/scripts/jev_probe.py +440 -0
  161. package/scripts/nt_common.py +82 -0
  162. package/scripts/nt_decrypt.py +1755 -1460
  163. package/scripts/pipeline.py +27 -2
  164. package/scripts/promote_ideas.py +19 -4
  165. package/scripts/quality_eval.py +707 -0
  166. package/scripts/rag_chat.py +4 -4
  167. package/scripts/read_image.py +187 -0
  168. package/scripts/reply_debt.py +488 -0
  169. package/scripts/route_cards.py +453 -0
  170. package/scripts/semantic_search.py +208 -49
  171. package/scripts/user_notes.py +315 -0
  172. package/scripts/vault_rag.py +13 -7
  173. package/scripts/vault_search.py +21 -4
  174. package/scripts/wiki_lint.py +429 -0
  175. package/src/core/appMsgFormat.ts +117 -0
  176. package/src/core/ntCore.ts +27 -2
  177. package/src/core/sqlcipherCore.ts +5 -61
  178. package/src/core/wcdbCore.ts +6 -2
  179. package/src/panel/client.ts +49 -0
  180. package/src/panel/endpoint.ts +144 -0
  181. package/src/panel/launch.ts +101 -0
  182. package/src/panel/server.ts +424 -0
  183. package/src/panel/userId.ts +72 -0
  184. package/src/services/assistantDaemon.ts +57 -9
  185. package/src/services/assistantEval.ts +611 -0
  186. package/src/services/assistantMemory.ts +413 -57
  187. package/src/services/assistantPrivacy.ts +25 -6
  188. package/src/services/assistantRouter.ts +191 -0
  189. package/src/services/assistantService.ts +681 -74
  190. package/src/services/assistantTools.ts +1149 -58
  191. package/src/services/assistantTrace.ts +175 -0
  192. package/src/services/chatService.ts +6 -2
  193. package/src/services/configService.ts +403 -385
  194. package/src/services/pythonBridge.ts +139 -0
  195. package/src/services/syncService.ts +37 -9
  196. package/src/services/wechatMessageService.ts +73 -7
  197. package/src/services/wereadService.ts +43 -7
  198. package/src/utils/atomicWrite.ts +56 -0
  199. package/src/utils/dateRange.ts +46 -0
  200. package/src/utils/text.ts +20 -0
@@ -25,6 +25,11 @@ except ImportError:
25
25
  print("Install sqlcipher3: pip install sqlcipher3")
26
26
  sys.exit(1)
27
27
 
28
+ # 与 nt_decrypt 共用同一份实现(原先两边各有一份,契约还不一样)。
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
+ from nt_common import (discover_message_shards, derive_database_key,
31
+ table_columns, MESSAGE_ANCHOR_COLUMNS)
32
+
28
33
  PAGE_SIZE = 4096
29
34
  MSG_TYPES = {
30
35
  1: 'text', 3: 'image', 34: 'voice', 42: 'card',
@@ -135,6 +140,19 @@ PREFETCH_WORKERS = 24
135
140
  PREFETCH_MAX_URLS = 800
136
141
 
137
142
 
143
+ # The columns the row-consuming code below reads by position. Order is part
144
+ # of the contract: `row[4]` is `real_sender_id` and nothing else.
145
+ EXPORT_MESSAGE_COLUMNS = ('local_id', 'server_id', 'local_type', 'sort_seq',
146
+ 'real_sender_id', 'create_time', 'status', 'source',
147
+ 'message_content', 'compress_content')
148
+
149
+ # A column this shard lacks is selected as NULL rather than dropped. Dropping
150
+ # it would shift every later field by one and put, say, `create_time` where
151
+ # `real_sender_id` is expected - wrong output with no error anywhere, which is
152
+ # worse than the empty export a hard failure would produce.
153
+ MISSING_COLUMN_WARNING = ' WARNING: shard has no column %s; reading it as NULL'
154
+
155
+
138
156
  def connect(db_path, key_hex, salt_hex):
139
157
  raw_key = f"x'{key_hex}{salt_hex}'"
140
158
  conn = sqlcipher.connect(db_path)
@@ -166,12 +184,21 @@ def fetch_messages(conn, talker, date=''):
166
184
  date_filter = ' WHERE create_time >= ? AND create_time < ?'
167
185
  date_params = [int(day.timestamp()), int((day + datetime.timedelta(days=1)).timestamp())]
168
186
 
169
- c.execute(f'''
170
- SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
171
- create_time, status, source, message_content, compress_content
172
- FROM "{tbl}"{date_filter}
173
- ORDER BY create_time ASC
174
- ''', date_params)
187
+ available = table_columns(c, tbl)
188
+ for col in EXPORT_MESSAGE_COLUMNS:
189
+ if col not in available:
190
+ print(MISSING_COLUMN_WARNING % col)
191
+ if date and 'create_time' not in available:
192
+ # A requested day cannot be expressed here. Exporting everything
193
+ # instead would silently widen a bounded export into an unbounded one.
194
+ raise ValueError('shard has no create_time column, so a date-bounded export cannot be produced')
195
+ projection = ', '.join('"%s"' % col if col in available else 'NULL AS "%s"' % col
196
+ for col in EXPORT_MESSAGE_COLUMNS)
197
+ order = [col for col in MESSAGE_ANCHOR_COLUMNS
198
+ if col in available]
199
+ order_sql = ' ORDER BY ' + ', '.join('"%s" ASC' % col for col in order) if order else ''
200
+
201
+ c.execute(f'SELECT {projection} FROM "{tbl}"{date_filter}{order_sql}', date_params)
175
202
 
176
203
  messages = []
177
204
  batch = 0
@@ -187,36 +214,11 @@ def fetch_messages(conn, talker, date=''):
187
214
  return messages
188
215
 
189
216
 
190
- def discover_message_shards(db_path):
191
- """Return all NT message shards alongside the configured database."""
192
- db = Path(db_path)
193
- candidates = sorted(db.parent.glob('message_*.db'))
194
- return [str(path) for path in candidates if path.name.lower() not in {
195
- 'message_fts.db', 'message_resource.db'
196
- }]
197
-
198
-
199
- def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
200
- """Derive a shard-specific SQLCipher key from the shared NT passphrase."""
201
- if not passphrase:
202
- return fallback_key, fallback_salt
203
- try:
204
- with open(path, 'rb') as fh:
205
- salt = fh.read(16)
206
- if len(salt) != 16:
207
- return fallback_key, fallback_salt
208
- raw_passphrase = bytes.fromhex(passphrase)
209
- key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32).hex()
210
- return key, salt.hex()
211
- except (OSError, ValueError):
212
- return fallback_key, fallback_salt
213
-
214
-
215
217
  def fetch_messages_from_shards(db_path, key_hex, salt_hex, talker, date='', passphrase=''):
216
218
  """Read and merge messages from every NT message shard."""
219
+ # `discover_message_shards` 保证不返回空表,所以这里不需要再兜底——
220
+ # 原先的 `if not shards: shards = [db_path]` 是给"可能返回空"的那一份实现擦屁股的。
217
221
  shards = discover_message_shards(db_path)
218
- if not shards:
219
- shards = [db_path]
220
222
  messages = []
221
223
  for shard in shards:
222
224
  shard_conn = None
@@ -10,6 +10,10 @@
10
10
  python scripts/extract_todos.py rm <id>
11
11
  python scripts/extract_todos.py remind
12
12
 
13
+ `list --json` 输出裸数组(老契约,有其他调用方);加 `--meta` 改成
14
+ `{items, extracted, count}`——`extracted=False` 表示**从没跑过 extract**,
15
+ 与"跑过了但没有待办"是两件事,别把后者说成前者。
16
+
13
17
  数据: ~/.weflow-cli/todos.json
14
18
  """
15
19
 
@@ -18,7 +22,7 @@ from datetime import datetime, timezone, timedelta
18
22
  from pathlib import Path
19
23
 
20
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
- from _utils import load_config, decrypt_lock, call_deepseek
25
+ from _utils import load_config, decrypt_lock, call_deepseek, get_api_key
22
26
 
23
27
  TODOS_FILE = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'todos.json')
24
28
  TZ = timezone(timedelta(hours=8))
@@ -309,6 +313,10 @@ def main():
309
313
  p.add_argument('--status', choices=['pending', 'done'], help='按状态筛选')
310
314
  p.add_argument('--urgency', choices=['高', '中', '低'], help='按紧急度筛选')
311
315
  p.add_argument('--json', action='store_true', help='JSON 输出')
316
+ # `--json` 保持"裸数组"这个老形状(有调用方按数组消费),所以区分"没提取过"的
317
+ # 那条信息走一个新开关,而不是改老形状。见下面的输出分支。
318
+ p.add_argument('--meta', action='store_true',
319
+ help='与 --json 连用:输出 {items, extracted, count},extracted=False 表示从没跑过 extract')
312
320
 
313
321
  # done
314
322
  p = subparsers.add_parser('done', help='标记待办为已完成')
@@ -341,7 +349,8 @@ def main():
341
349
 
342
350
  if args.command == 'extract':
343
351
  config = load_config()
344
- api_key = getattr(args, 'api_key', None) or os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
352
+ api_key = (getattr(args, 'api_key', None)
353
+ or os.environ.get('DEEPSEEK_API_KEY', '') or get_api_key(config))
345
354
  if not api_key:
346
355
  print('[ERROR] 缺少 DeepSeek API key')
347
356
  sys.exit(1)
@@ -351,10 +360,23 @@ def main():
351
360
 
352
361
  elif args.command == 'list':
353
362
  result = list_todos(args.status, args.urgency)
354
- if getattr(args, 'json', False):
363
+ if getattr(args, 'meta', False):
364
+ # "没有待办"与"从没提取过"必须分得开:文件不存在时 `list` 也给空数组,
365
+ # 调用方拿到空数组会说"你没有待办"——而真相是这件事从没跑过。
366
+ # MCP 侧(mcp_bridge.py 的 get_todos)早就在分这两件事,助手工具没分。
367
+ print(json.dumps({
368
+ 'items': result,
369
+ 'extracted': os.path.exists(TODOS_FILE),
370
+ 'count': len(result),
371
+ }, ensure_ascii=False, indent=2))
372
+ elif getattr(args, 'json', False):
355
373
  print(json.dumps(result, ensure_ascii=False, indent=2))
356
374
  elif not result:
357
- print('✅ 暂无待办')
375
+ # 文件不存在时 `list` 也给空——直接说"暂无待办",用户会以为自己真的没事要做。
376
+ if not os.path.exists(TODOS_FILE):
377
+ print('还没提取过待办(先运行:weflow-cli todos extract --days 7)')
378
+ else:
379
+ print('✅ 暂无待办')
358
380
  else:
359
381
  status_labels = {'pending': '⬜', 'done': '✅'}
360
382
  urg_labels = {'高': '🔴', '中': '🟡', '低': '🟢'}
@@ -438,7 +460,11 @@ def main():
438
460
  print(f' - {t["task"]}')
439
461
  print()
440
462
  if result['total_pending'] == 0:
441
- print('✅ 暂无待办,干得好!')
463
+ # 同上:空清单与"从没提取过"是两件事,别把后者夸成前者。
464
+ if not os.path.exists(TODOS_FILE):
465
+ print('还没提取过待办(先运行:weflow-cli todos extract --days 7)')
466
+ else:
467
+ print('✅ 暂无待办,干得好!')
442
468
 
443
469
 
444
470
  if __name__ == '__main__':
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 收藏知识卡 — 把微信收藏里的文章变成知识卡,喂给 wiki compile。
4
+
5
+ 用法:
6
+ python scripts/fav_notes.py --dry-run --json # 看代价(只读本地;有哪些要联网抓也报出来)
7
+ python scripts/fav_notes.py --limit 10 --yes # 先试水 10 条
8
+ python scripts/compile_wiki.py --source output/fav-notes
9
+
10
+ ## 为什么收藏值得单独一条来源
11
+
12
+ 日报那条线吃的是"你今天读了什么",而**收藏是你明确留下的**——两者不是一回事:
13
+ 收藏里常有日报窗口之外的文章,而且"我主动存过"本身就是兴趣的最强信号。
14
+ (另外这条线自带一个好处:收藏的文章多半日报已经抓过,`fetch_article_cached` 会命中缓存,
15
+ 所以联网这一步常常是零成本。)
16
+
17
+ ## 与另外两条线的关系
18
+
19
+ 同一形状、同一消费者(`wiki_compile` 的 `--source`):文章线 `article_notes.py`、
20
+ 对话线 `chat_notes.py`、收藏线就是本脚本。共用的件在 `_utils.py`。
21
+
22
+ **一处不同,必须写清**:文章的卡里"摘要"是**照抄**原笔记的,而收藏记录里**没有摘要**
23
+ (只有标题/来源/链接)——所以这条线的摘要**是模型写的**。卡片里会标出来
24
+ (frontmatter 的 `summary_by: model`),免得它看起来像抄来的原文。
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone, timedelta
31
+ from pathlib import Path
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from _utils import (SEPARATOR, call_deepseek, decrypt_lock, get_api_key, load_config, # noqa: E402
35
+ note_path, plain, strip_wx_ads, write_with_frontmatter)
36
+
37
+ TZ = timezone(timedelta(hours=8))
38
+ OUTPUT_ROOT = 'output/fav-notes'
39
+ DEFAULT_LIMIT = 10
40
+ MAX_ARTICLE_CHARS = 3000
41
+ MIN_ARTICLE_CHARS = 200
42
+ LINK_CHARS = 6 * 1024 * 1024 # 单篇正文上限(缓存来的 markdown 一般远小于它)
43
+
44
+ FAV_PROMPT = """你是知识库的**编译者,不是作者**。下面是一篇用户**收藏**的公众号文章。
45
+
46
+ 请只依据它,返回一个 JSON 对象(不要解释、不要代码块围栏):
47
+ {{"summary": "这篇文章在说什么(2-3 句,只写文章里有的)",
48
+ "concepts": [{{"name": "概念名(2-12 字,不要带书名号引号方括号)", "desc": "这篇文章关于它说了什么(一句话,具体)"}}]}}
49
+
50
+ 要求:
51
+ 1. **只写文章里有的**。没讲到的不许补;拿不准的宁可不写。
52
+ 2. `concepts` 2-6 条、按重要度排序;**概念要具体**("MCP 协议"可以,"技术"不行),
53
+ 也不是分类词("AI"、"新闻"这种主题标签不算概念)。
54
+ 3. 每条 desc 是"这篇文章关于它的说法",不是百科定义。
55
+ 4. 全部中文,不要 markdown 语法(不要 #、*、-)。
56
+
57
+ 文章:{title}(来源:{source})
58
+
59
+ 正文:
60
+ {body}
61
+ """
62
+
63
+
64
+ def read_favorites(limit: int):
65
+ """读收藏列表(本地、只读)。返回 (条目列表, 错误原因)。"""
66
+ import nt_decrypt
67
+ config = load_config()
68
+ fav_db = config.get('favDbPath', '')
69
+ if not fav_db:
70
+ return [], '配置里没有 favDbPath(收藏库还没连上)'
71
+ got = nt_decrypt.get_favorites(fav_db, decrypt_lock(config.get('favKey', '')), limit=limit)
72
+ if isinstance(got, dict) and got.get('error'):
73
+ return [], str(got['error'])
74
+ items = got.get('favorites') if isinstance(got, dict) else got
75
+ return list(items or []), None
76
+
77
+
78
+ def local_text(item: dict) -> str:
79
+ """收藏**自带**的正文(笔记/文本类有,文章类没有)。**不碰网络。**
80
+
81
+ 与 `favorite_text` 分开,是为了让 `--dry-run` 能说清"哪几条要联网抓"而**不去抓**:
82
+ 预览是只读的(它的说明里写着"只读本地"),自己去打网络就名不副实了——我第一版
83
+ 就是这么写的,跑 dry-run 时看见它在抓文章才发现。
84
+ """
85
+ for key in ('desc', 'content', 'summary'):
86
+ text = strip_wx_ads(str(item.get(key) or '')).strip()
87
+ if len(text) >= MIN_ARTICLE_CHARS:
88
+ return text
89
+ return ''
90
+
91
+
92
+ def classify_source(item: dict) -> str:
93
+ """这条收藏的正文从哪来(**只看本地信息**):local / fetch / none。"""
94
+ if local_text(item):
95
+ return 'local'
96
+ return 'fetch' if str(item.get('link') or '').strip() else 'none'
97
+
98
+
99
+ def favorite_text(item: dict, fetch=None):
100
+ """一条收藏的正文:本地文本优先,没有(或太短)就抓链接。返回 (正文, 来源说明)。
101
+
102
+ 收藏分两种:笔记/文本类**自带内容**(`desc`/`content`),文章类只有 `link`。
103
+ 抓取复用日报那条线的 `fetch_article_cached`——不重写第二份(那套微信 UA 绕 WAF、
104
+ gzip、重试是踩出来的),而且**带缓存**:日报抓过的文章这里零网络。
105
+
106
+ `fetch` 是**可注入的口子**:默认走 `biz_daily.fetch_article_cached`,测试传桩进来——
107
+ 否则单元测试会真去联网(我第一版就是这么写的,跑测试时才发现)。
108
+ """
109
+ for key in ('desc', 'content', 'summary'):
110
+ text = strip_wx_ads(str(item.get(key) or '')).strip()
111
+ if len(text) >= MIN_ARTICLE_CHARS:
112
+ return text, 'local'
113
+ link = str(item.get('link') or '').strip()
114
+ if not link:
115
+ return '', 'no-text'
116
+ if fetch is None:
117
+ try:
118
+ import biz_daily
119
+ fetch = biz_daily.fetch_article_cached
120
+ except Exception as error:
121
+ return '', 'fetch-failed: %s' % type(error).__name__
122
+ try:
123
+ got = fetch(link)
124
+ except Exception as error:
125
+ return '', 'fetch-failed: %s' % type(error).__name__
126
+ if not got:
127
+ return '', 'fetch-failed'
128
+ text, cached = got
129
+ if not text:
130
+ return '', 'fetch-failed'
131
+ return strip_wx_ads(str(text))[:LINK_CHARS], ('cache' if cached else 'network')
132
+
133
+
134
+ def parse_note(raw: str):
135
+ """解出 `{summary, concepts}`。解不出来返回 None(**不猜**)。"""
136
+ if not isinstance(raw, str) or not raw.strip():
137
+ return None
138
+ text = raw.strip()
139
+ start, end = text.find('{'), text.rfind('}')
140
+ if start < 0 or end <= start:
141
+ return None
142
+ try:
143
+ data = json.loads(text[start:end + 1])
144
+ except Exception:
145
+ return None
146
+ if not isinstance(data, dict) or not isinstance(data.get('concepts'), list):
147
+ return None
148
+ summary = plain(data.get('summary')).strip()
149
+ if not summary:
150
+ return None
151
+ concepts = []
152
+ for item in data['concepts']:
153
+ if not isinstance(item, dict):
154
+ continue
155
+ name = plain(item.get('name')).strip().strip('[]')
156
+ desc = plain(item.get('desc')).strip()
157
+ if name and desc:
158
+ concepts.append({'name': name, 'desc': desc})
159
+ if len(concepts) >= 6:
160
+ break
161
+ return {'summary': summary, 'concepts': concepts}
162
+
163
+
164
+ def build_card(item: dict, note: dict, body_len: int, saved: str, updated: str):
165
+ """卡片 → (frontmatter, body)。摘要这次**是模型写的**,所以标出来。"""
166
+ title = str(item.get('title') or '无标题').strip()
167
+ source = str(item.get('source_name') or '').strip()
168
+ frontmatter = {
169
+ 'title': title,
170
+ 'type': 'fav-card',
171
+ 'source': source or '收藏',
172
+ 'topic': '收藏',
173
+ 'tags': ['收藏'],
174
+ # **收藏时间**不是发布时间——别把它叫 published,那是另一回事
175
+ 'saved': saved,
176
+ 'url': str(item.get('link') or ''),
177
+ 'summary_by': 'model', # 这条线的摘要不是抄的,标出来
178
+ 'bodyChars': body_len,
179
+ 'updated': updated,
180
+ }
181
+ parts = ['# %s\n' % title, '## AI 摘要\n', note['summary'] + '\n']
182
+ if note['concepts']:
183
+ parts.append('## 概念\n')
184
+ for concept in note['concepts']:
185
+ parts.append('- [[%s]] %s %s' % (concept['name'], SEPARATOR, concept['desc']))
186
+ parts.append('')
187
+ return frontmatter, '\n'.join(parts)
188
+
189
+
190
+ def already_carded(item: dict, out_dir: str) -> bool:
191
+ """这条收藏已经有卡了吗(按标题落成的文件名)。"""
192
+ title = str(item.get('title') or '').strip()
193
+ return bool(title) and note_path(out_dir, title).exists()
194
+
195
+
196
+ def exit_code(written: int) -> int:
197
+ """部分成功算成功——与另外两条线同一个约定。"""
198
+ return 0 if written else 1
199
+
200
+
201
+ def main():
202
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
203
+ parser = argparse.ArgumentParser(description='收藏知识卡(把收藏变成喂给 wiki compile 的卡)')
204
+ parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
205
+ help='最多处理几条收藏(默认 %d)' % DEFAULT_LIMIT)
206
+ parser.add_argument('--out', default=OUTPUT_ROOT, help='卡片输出目录')
207
+ parser.add_argument('--refresh', action='store_true', help='已有卡的也重做')
208
+ parser.add_argument('--dry-run', action='store_true', help='只报要发多少给模型,不调用')
209
+ parser.add_argument('--yes', action='store_true', help='确认调用云端模型')
210
+ parser.add_argument('--json', action='store_true')
211
+ args = parser.parse_args()
212
+
213
+ if args.limit < 1:
214
+ print(json.dumps({'success': False, 'error': '--limit 要 ≥ 1'}))
215
+ return 1
216
+
217
+ favorites, error = read_favorites(args.limit)
218
+ if error:
219
+ message = '读收藏失败:%s' % error
220
+ print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
221
+ if args.json else message)
222
+ return 1
223
+
224
+ runnable, thin, existing = [], [], []
225
+ for item in favorites:
226
+ if args.refresh or not already_carded(item, args.out):
227
+ runnable.append(item)
228
+ else:
229
+ existing.append(item)
230
+
231
+ if args.dry_run:
232
+ # **只看本地信息**:预览是只读的,不替用户去打网络(抓取留给真跑那一步)
233
+ counts = {'local': 0, 'fetch': 0, 'none': 0}
234
+ for item in runnable:
235
+ counts[classify_source(item)] += 1
236
+ preview = {
237
+ 'success': True, 'dryRun': True, 'action': 'fav-notes',
238
+ 'favorites': len(favorites), 'calls': len(runnable),
239
+ 'alreadyCarded': len(existing), 'noText': counts['none'],
240
+ 'textSource': {'local': counts['local'], 'needsFetch': counts['fetch']},
241
+ 'model': 'DeepSeek(生成)', 'readsLocalData': True, 'fetchArticleUrls': False,
242
+ 'invokesAI': False,
243
+ 'note': '每条一次调用;正文优先用收藏自带的文本,文章类走日报那条线的抓取(带缓存)'
244
+ '——抓取发生在真跑时,预览不联网',
245
+ }
246
+ if args.json:
247
+ print(json.dumps(preview, ensure_ascii=False, indent=2))
248
+ else:
249
+ print('收藏 %d 条,会调用模型 %d 次(已有卡 %d 条跳过)'
250
+ % (len(favorites), len(runnable), len(existing)))
251
+ print('正文:自带 %d 条、要联网抓 %d 条、取不到 %d 条(预览不联网,抓取在 --yes 之后)'
252
+ % (counts['local'], counts['fetch'], counts['none']))
253
+ return 0
254
+
255
+ if not runnable:
256
+ message = '没有要处理的收藏(%d 条都已有卡)' % len(favorites)
257
+ print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
258
+ if args.json else message, file=sys.stdout if args.json else sys.stderr)
259
+ return 1
260
+
261
+ if not args.yes:
262
+ print('会把 %d 条收藏的正文发给 DeepSeek(每条一次调用)。加 --yes 确认。' % len(runnable),
263
+ file=sys.stderr)
264
+ return 1
265
+
266
+ config = load_config()
267
+ api_key = get_api_key(config)
268
+ if not api_key:
269
+ print('没有配置 deepseekApiKey(weflow-cli config set deepseekApiKey "...")', file=sys.stderr)
270
+ return 1
271
+
272
+ updated = datetime.now(TZ).strftime('%Y-%m-%d %H:%M')
273
+ written, failed = [], []
274
+ for item in runnable:
275
+ text, how = favorite_text(item)
276
+ title = str(item.get('title') or '无标题').strip()
277
+ if not text:
278
+ failed.append({'title': title[:30], 'reason': '取不到正文(%s)' % how})
279
+ continue
280
+ saved = ''
281
+ if item.get('update_time'):
282
+ saved = datetime.fromtimestamp(int(item['update_time']), tz=TZ).strftime('%Y-%m-%d')
283
+ prompt = FAV_PROMPT.format(title=title, source=item.get('source_name') or '未知',
284
+ body=text[:MAX_ARTICLE_CHARS])
285
+ try:
286
+ raw = call_deepseek(prompt, api_key, max_tokens=2000, timeout=120)
287
+ except Exception as error:
288
+ failed.append({'title': title[:30], 'reason': '调用失败:%s' % error})
289
+ continue
290
+ note = parse_note(raw)
291
+ if note is None:
292
+ failed.append({'title': title[:30], 'reason': '模型没返回可用 JSON',
293
+ 'rawTail': str(raw)[-200:] if raw else '(空)'})
294
+ continue
295
+ target = note_path(args.out, title)
296
+ target.parent.mkdir(parents=True, exist_ok=True)
297
+ frontmatter, body = build_card(item, note, len(text), saved, updated)
298
+ write_with_frontmatter(str(target), frontmatter, body)
299
+ written.append({'title': title[:30], 'file': str(target),
300
+ 'concepts': len(note['concepts']), 'textFrom': how})
301
+
302
+ result = {'success': True, 'action': 'fav-notes', 'model': 'DeepSeek(生成)',
303
+ 'sendsNothing': True, 'written': written, 'failed': failed,
304
+ 'alreadyCarded': len(existing), 'favorites': len(favorites), 'calls': len(runnable)}
305
+ if args.json:
306
+ print(json.dumps(result, ensure_ascii=False, indent=2))
307
+ else:
308
+ for item in written:
309
+ print('✓ %s(%d 个概念,正文来自 %s)' % (item['title'], item['concepts'], item['textFrom']))
310
+ for item in failed:
311
+ print('✗ %s:%s' % (item['title'], item['reason']), file=sys.stderr)
312
+ print('\n共 %d 张卡;接着跑 python scripts/compile_wiki.py --source %s'
313
+ % (len(written), args.out))
314
+ return exit_code(len(written))
315
+
316
+
317
+ if __name__ == '__main__':
318
+ sys.exit(main())
@@ -5,8 +5,9 @@ from pathlib import Path
5
5
 
6
6
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
7
  from biz_daily import _guess_topic
8
+ from _utils import TOPICS
9
+
8
10
 
9
- TOPICS = ['AI', '学术', '新闻', '文学', '投资', '政治']
10
11
 
11
12
  def fix_date(date_str: str):
12
13
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')