weflow-cli 1.17.0 → 1.19.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 (73) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +3 -2
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_mail.py +51 -5
  43. package/scripts/daily_task.ps1 +216 -213
  44. package/scripts/extract_todos.py +3 -1
  45. package/scripts/fav_notes.py +321 -321
  46. package/scripts/health_check.py +473 -473
  47. package/scripts/imported_chats.py +68 -68
  48. package/scripts/install_tasks.ps1 +105 -105
  49. package/scripts/key_cache.py +232 -0
  50. package/scripts/leads.py +179 -179
  51. package/scripts/mcp_bridge.py +3 -1
  52. package/scripts/media_index.py +150 -0
  53. package/scripts/network.py +206 -206
  54. package/scripts/nt_common.py +32 -0
  55. package/scripts/nt_decrypt.py +2023 -1954
  56. package/scripts/opinion_timeline.py +138 -138
  57. package/scripts/replay.py +803 -803
  58. package/scripts/run_hidden.vbs +24 -24
  59. package/scripts/semantic_search.py +5 -1
  60. package/scripts/user_notes.py +315 -315
  61. package/scripts/voice_transcribe.py +139 -139
  62. package/scripts/watch_issues.py +212 -212
  63. package/scripts/watch_mail.py +131 -131
  64. package/scripts/wechat_avatar.py +185 -0
  65. package/scripts/wechat_ledger.py +190 -0
  66. package/src/services/cursorProbe.ts +222 -222
  67. package/src/services/diagnosticReport.ts +22 -0
  68. package/src/services/qchatImport.ts +327 -327
  69. package/src/services/setupVenv.ts +238 -238
  70. package/src/services/syncService.ts +363 -363
  71. package/src/services/syncState.ts +275 -275
  72. package/src/services/wechatCompat.ts +67 -0
  73. package/src/services/wechatMedia.ts +166 -166
package/scripts/leads.py CHANGED
@@ -1,179 +1,179 @@
1
- """线索雷达:把聊天里"可能是生意"的对话挑出来,并指回原话。
2
-
3
- 为什么它值得存在:微信里最容易丢的不是重要消息,是**混在闲聊里的机会** —— 有人问价、
4
- 有人问能不能做、有人留了联系方式,然后被下一条消息压过去。它和 `promises` 用同一个骨架:
5
- 规则先捞候选、再到同会话的后续对话里找"有没有跟上",**每条结论都带原话**,不做黑箱判断。
6
-
7
- 默认不调模型。信号分五类(联系方式 / 报价 / 采购 / 合作 / 需求),按命中类型加权排序,
8
- 高分的往往是"问了价 + 留了联系方式"这种最该马上回的组合。
9
-
10
- 用法:
11
- python scripts/leads.py --days 30
12
- python scripts/leads.py --days 90 --min-score 3
13
- python scripts/leads.py --days 30 --json
14
- """
15
- from __future__ import annotations
16
-
17
- import argparse
18
- import json
19
- import pathlib
20
- import re
21
- import sys
22
- from collections import defaultdict
23
- from datetime import datetime, timezone, timedelta
24
-
25
- SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
26
- sys.path.insert(0, str(SCRIPT_DIR))
27
-
28
- from _utils import load_config, decrypt_lock # noqa: E402
29
- from promises import load_messages, same_account # noqa: E402
30
- import nt_decrypt as nd # noqa: E402
31
-
32
- TZ = timezone(timedelta(hours=8))
33
-
34
- #: 信号类型 → (正则, 权重)。权重表示"这条信号有多像生意",不是"多重要"。
35
- SIGNALS = {
36
- '联系方式': (re.compile(r'(微信|vx|wx|威信|手机号|电话|联系我|加我)|1[3-9]\d{9}'
37
- r'|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', re.I), 3),
38
- '报价': (re.compile(r'(多少钱|报价|预算|费用|价格|单价|收费|怎么算钱|贵不贵)'), 3),
39
- '采购': (re.compile(r'(采购|下单|合同|发票|订购|样品|打款|付款)'), 2),
40
- '合作': (re.compile(r'(合作|对接|渠道|代理|分销|外包|接单|项目合作)'), 2),
41
- '需求': (re.compile(r'(需要.{0,8}(做|开发|方案|系统|服务|设计)|能不能做|可以做吗'
42
- r'|有没有.{0,4}(做|卖|提供)|求推荐|想找.{0,6}(做|人))'), 2),
43
- }
44
- #: 我跟上了的迹象(在同一条线索之后的同会话消息里找)
45
- FOLLOWED = re.compile(r'(已加|加了|私聊|发你了|发您了|发过去|安排|稍后|好的.{0,4}(我|马上)|收到)')
46
- #: 已经明确黄掉的迹象
47
- DEAD = re.compile(r'(太贵|不考虑|算了|暂时不需要|下次吧|再看看)')
48
-
49
- MERGE_WINDOW = 3600 # 同一会话里这一秒数内的信号合成一条线索
50
-
51
-
52
- #: 广告 / 招聘 / 促销:这些恰好塞满"合作、采购、报价、联系方式"这些词,但**不是线索**。
53
- #: 2026-10-23 第一版没滤它们,30 天里 8 条"线索"全是校招宣讲和电商促销,没有一条真生意。
54
- NOISE = re.compile(
55
- r'(招聘|校招|秋招|春招|宣讲会|双选会|简历|offer|求职|面试|直播|预告'
56
- r'|优惠|券|折扣|秒杀|包邮|抽奖|福利|返现|下单链接|u\.jd\.com|拼团|预售|凑单)', re.I)
57
- #: 真线索几乎都带**请求或提问**。纯陈述句(广告、通知)不进来。
58
- ASK = re.compile(r'(吗|呢|?|\?|能否|可以吗|可不可以|麻烦|请问|想找|有没有|多少钱|怎么收费|求推荐)')
59
-
60
-
61
- def _score(text):
62
- """(信号类型, 分数)。**先过噪点与提问两道闸,再算信号分。**
63
-
64
- 匹配前先剥掉 `@昵称`:群昵称里常带手机号(`@某人13800000000`),不剥的话每一句
65
- 亲戚群闲聊都会被判成"留了联系方式"(2026-10-23 实测,误报全来自这里)。
66
- """
67
- bare = re.sub(r'@[^\s]{1,40}\s*', '', text)
68
- # URL 里的长数字串会命中手机号规则(`?id=4713032079053994316` 这类),先剥链接。
69
- bare = re.sub(r'https?://\S+', '', bare)
70
- if len(bare) > 300 or NOISE.search(bare) or not ASK.search(bare):
71
- return [], 0
72
- hit = []
73
- score = 0
74
- for name, (rx, weight) in SIGNALS.items():
75
- if rx.search(bare):
76
- hit.append(name)
77
- score += weight
78
- return hit, score
79
-
80
-
81
- def main():
82
- ap = argparse.ArgumentParser(description='线索雷达:聊天里的报价/合作/需求信号')
83
- ap.add_argument('--days', type=int, default=30)
84
- ap.add_argument('--min-score', type=int, default=2, help='最低分(默认 2)')
85
- ap.add_argument('--limit', type=int, default=20)
86
- ap.add_argument('--json', action='store_true')
87
- args = ap.parse_args()
88
-
89
- cfg = load_config()
90
- db = cfg.get('ntDbPath') or ''
91
- my_wxid = cfg.get('wxid') or ''
92
- if not db:
93
- raise SystemExit('配置里没有 ntDbPath')
94
- key = decrypt_lock(cfg.get('ntKey', '') or '')
95
- salt = decrypt_lock(cfg.get('ntSalt', '') or '')
96
- pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
97
- conns = nd.connect_message_shards(db, key, salt, pw)
98
- if not conns:
99
- raise SystemExit('打不开消息分片')
100
-
101
- try:
102
- by_talker = load_messages(conns, args.days, my_wxid)
103
- name_map = {}
104
- try:
105
- from network import load_graph
106
- people, _rooms, _members = load_graph(cfg)
107
- name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
108
- for p in people.values()}
109
- except Exception as exc:
110
- print(f'[WARN] 读联系人失败({type(exc).__name__}),显示 id', file=sys.stderr)
111
- finally:
112
- for conn in conns:
113
- try:
114
- conn.close()
115
- except Exception:
116
- pass
117
-
118
- leads = []
119
- for talker, msgs in by_talker.items():
120
- hits = []
121
- for i, (ts, is_me, who, text) in enumerate(msgs):
122
- if is_me:
123
- continue # 线索来自别人说的,不是我自己发的
124
- kinds, score = _score(text)
125
- if score < args.min_score:
126
- continue
127
- hits.append({'i': i, 'ts': ts, 'text': text, 'kinds': kinds, 'score': score})
128
-
129
- # 同一会话里时间挨近的信号合成一条线索,避免"问价"和"留手机号"被拆成两条。
130
- merged = []
131
- for h in hits:
132
- if merged and h['ts'] - merged[-1]['last'] <= MERGE_WINDOW:
133
- cur = merged[-1]
134
- cur['last'] = h['ts']
135
- cur['score'] = max(cur['score'], h['score']) + 1
136
- cur['kinds'] = sorted(set(cur['kinds']) | set(h['kinds']))
137
- if h['text'] not in cur['parts']:
138
- cur['parts'].append(h['text'])
139
- cur['idx'].append(h['i'])
140
- else:
141
- merged.append({'first': h['ts'], 'last': h['ts'], 'score': h['score'],
142
- 'kinds': list(h['kinds']), 'parts': [h['text']],
143
- 'idx': [h['i']]})
144
-
145
- for m in merged:
146
- after = msgs[m['idx'][-1] + 1: m['idx'][-1] + 25]
147
- replied = any(is_me for _, is_me, _w, _t in after)
148
- followed = any(is_me and FOLLOWED.search(t) for _, is_me, _w, t in after)
149
- dead = any(DEAD.search(t) for _, _me, _w, t in after)
150
- state = ('已结束' if dead else '已跟进' if followed else '我回过' if replied else '没回')
151
- leads.append({
152
- 'time': datetime.fromtimestamp(m['first'], tz=TZ).strftime('%Y-%m-%d %H:%M'),
153
- 'talker': name_map.get(talker, talker),
154
- 'kinds': m['kinds'],
155
- 'score': m['score'],
156
- 'said': ' / '.join(p[:80] for p in m['parts'][:3]),
157
- 'state': state,
158
- })
159
-
160
- leads.sort(key=lambda x: (-x['score'], x['time']), reverse=False)
161
- leads = leads[:args.limit]
162
-
163
- if args.json:
164
- print(json.dumps({'success': True, 'days': args.days, 'leads': leads},
165
- ensure_ascii=False, indent=2))
166
- return
167
-
168
- if not leads:
169
- print(f'最近 {args.days} 天里没有达到分数 {args.min_score} 的线索信号')
170
- return
171
- print(f'最近 {args.days} 天的线索信号 {len(leads)} 条(分数越高越像生意):\n')
172
- for it in leads:
173
- print(f"[{it['score']}分 · {'/'.join(it['kinds'])} · {it['state']}] {it['time']} {it['talker']}")
174
- print(f" {it['said']}")
175
- print()
176
-
177
-
178
- if __name__ == '__main__':
179
- main()
1
+ """线索雷达:把聊天里"可能是生意"的对话挑出来,并指回原话。
2
+
3
+ 为什么它值得存在:微信里最容易丢的不是重要消息,是**混在闲聊里的机会** —— 有人问价、
4
+ 有人问能不能做、有人留了联系方式,然后被下一条消息压过去。它和 `promises` 用同一个骨架:
5
+ 规则先捞候选、再到同会话的后续对话里找"有没有跟上",**每条结论都带原话**,不做黑箱判断。
6
+
7
+ 默认不调模型。信号分五类(联系方式 / 报价 / 采购 / 合作 / 需求),按命中类型加权排序,
8
+ 高分的往往是"问了价 + 留了联系方式"这种最该马上回的组合。
9
+
10
+ 用法:
11
+ python scripts/leads.py --days 30
12
+ python scripts/leads.py --days 90 --min-score 3
13
+ python scripts/leads.py --days 30 --json
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import pathlib
20
+ import re
21
+ import sys
22
+ from collections import defaultdict
23
+ from datetime import datetime, timezone, timedelta
24
+
25
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
26
+ sys.path.insert(0, str(SCRIPT_DIR))
27
+
28
+ from _utils import load_config, decrypt_lock # noqa: E402
29
+ from promises import load_messages, same_account # noqa: E402
30
+ import nt_decrypt as nd # noqa: E402
31
+
32
+ TZ = timezone(timedelta(hours=8))
33
+
34
+ #: 信号类型 → (正则, 权重)。权重表示"这条信号有多像生意",不是"多重要"。
35
+ SIGNALS = {
36
+ '联系方式': (re.compile(r'(微信|vx|wx|威信|手机号|电话|联系我|加我)|1[3-9]\d{9}'
37
+ r'|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', re.I), 3),
38
+ '报价': (re.compile(r'(多少钱|报价|预算|费用|价格|单价|收费|怎么算钱|贵不贵)'), 3),
39
+ '采购': (re.compile(r'(采购|下单|合同|发票|订购|样品|打款|付款)'), 2),
40
+ '合作': (re.compile(r'(合作|对接|渠道|代理|分销|外包|接单|项目合作)'), 2),
41
+ '需求': (re.compile(r'(需要.{0,8}(做|开发|方案|系统|服务|设计)|能不能做|可以做吗'
42
+ r'|有没有.{0,4}(做|卖|提供)|求推荐|想找.{0,6}(做|人))'), 2),
43
+ }
44
+ #: 我跟上了的迹象(在同一条线索之后的同会话消息里找)
45
+ FOLLOWED = re.compile(r'(已加|加了|私聊|发你了|发您了|发过去|安排|稍后|好的.{0,4}(我|马上)|收到)')
46
+ #: 已经明确黄掉的迹象
47
+ DEAD = re.compile(r'(太贵|不考虑|算了|暂时不需要|下次吧|再看看)')
48
+
49
+ MERGE_WINDOW = 3600 # 同一会话里这一秒数内的信号合成一条线索
50
+
51
+
52
+ #: 广告 / 招聘 / 促销:这些恰好塞满"合作、采购、报价、联系方式"这些词,但**不是线索**。
53
+ #: 2026-10-23 第一版没滤它们,30 天里 8 条"线索"全是校招宣讲和电商促销,没有一条真生意。
54
+ NOISE = re.compile(
55
+ r'(招聘|校招|秋招|春招|宣讲会|双选会|简历|offer|求职|面试|直播|预告'
56
+ r'|优惠|券|折扣|秒杀|包邮|抽奖|福利|返现|下单链接|u\.jd\.com|拼团|预售|凑单)', re.I)
57
+ #: 真线索几乎都带**请求或提问**。纯陈述句(广告、通知)不进来。
58
+ ASK = re.compile(r'(吗|呢|?|\?|能否|可以吗|可不可以|麻烦|请问|想找|有没有|多少钱|怎么收费|求推荐)')
59
+
60
+
61
+ def _score(text):
62
+ """(信号类型, 分数)。**先过噪点与提问两道闸,再算信号分。**
63
+
64
+ 匹配前先剥掉 `@昵称`:群昵称里常带手机号(`@某人13800000000`),不剥的话每一句
65
+ 亲戚群闲聊都会被判成"留了联系方式"(2026-10-23 实测,误报全来自这里)。
66
+ """
67
+ bare = re.sub(r'@[^\s]{1,40}\s*', '', text)
68
+ # URL 里的长数字串会命中手机号规则(`?id=4713032079053994316` 这类),先剥链接。
69
+ bare = re.sub(r'https?://\S+', '', bare)
70
+ if len(bare) > 300 or NOISE.search(bare) or not ASK.search(bare):
71
+ return [], 0
72
+ hit = []
73
+ score = 0
74
+ for name, (rx, weight) in SIGNALS.items():
75
+ if rx.search(bare):
76
+ hit.append(name)
77
+ score += weight
78
+ return hit, score
79
+
80
+
81
+ def main():
82
+ ap = argparse.ArgumentParser(description='线索雷达:聊天里的报价/合作/需求信号')
83
+ ap.add_argument('--days', type=int, default=30)
84
+ ap.add_argument('--min-score', type=int, default=2, help='最低分(默认 2)')
85
+ ap.add_argument('--limit', type=int, default=20)
86
+ ap.add_argument('--json', action='store_true')
87
+ args = ap.parse_args()
88
+
89
+ cfg = load_config()
90
+ db = cfg.get('ntDbPath') or ''
91
+ my_wxid = cfg.get('wxid') or ''
92
+ if not db:
93
+ raise SystemExit('配置里没有 ntDbPath')
94
+ key = decrypt_lock(cfg.get('ntKey', '') or '')
95
+ salt = decrypt_lock(cfg.get('ntSalt', '') or '')
96
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
97
+ conns = nd.connect_message_shards(db, key, salt, pw)
98
+ if not conns:
99
+ raise SystemExit('打不开消息分片')
100
+
101
+ try:
102
+ by_talker = load_messages(conns, args.days, my_wxid)
103
+ name_map = {}
104
+ try:
105
+ from network import load_graph
106
+ people, _rooms, _members = load_graph(cfg)
107
+ name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
108
+ for p in people.values()}
109
+ except Exception as exc:
110
+ print(f'[WARN] 读联系人失败({type(exc).__name__}),显示 id', file=sys.stderr)
111
+ finally:
112
+ for conn in conns:
113
+ try:
114
+ conn.close()
115
+ except Exception:
116
+ pass
117
+
118
+ leads = []
119
+ for talker, msgs in by_talker.items():
120
+ hits = []
121
+ for i, (ts, is_me, who, text) in enumerate(msgs):
122
+ if is_me:
123
+ continue # 线索来自别人说的,不是我自己发的
124
+ kinds, score = _score(text)
125
+ if score < args.min_score:
126
+ continue
127
+ hits.append({'i': i, 'ts': ts, 'text': text, 'kinds': kinds, 'score': score})
128
+
129
+ # 同一会话里时间挨近的信号合成一条线索,避免"问价"和"留手机号"被拆成两条。
130
+ merged = []
131
+ for h in hits:
132
+ if merged and h['ts'] - merged[-1]['last'] <= MERGE_WINDOW:
133
+ cur = merged[-1]
134
+ cur['last'] = h['ts']
135
+ cur['score'] = max(cur['score'], h['score']) + 1
136
+ cur['kinds'] = sorted(set(cur['kinds']) | set(h['kinds']))
137
+ if h['text'] not in cur['parts']:
138
+ cur['parts'].append(h['text'])
139
+ cur['idx'].append(h['i'])
140
+ else:
141
+ merged.append({'first': h['ts'], 'last': h['ts'], 'score': h['score'],
142
+ 'kinds': list(h['kinds']), 'parts': [h['text']],
143
+ 'idx': [h['i']]})
144
+
145
+ for m in merged:
146
+ after = msgs[m['idx'][-1] + 1: m['idx'][-1] + 25]
147
+ replied = any(is_me for _, is_me, _w, _t in after)
148
+ followed = any(is_me and FOLLOWED.search(t) for _, is_me, _w, t in after)
149
+ dead = any(DEAD.search(t) for _, _me, _w, t in after)
150
+ state = ('已结束' if dead else '已跟进' if followed else '我回过' if replied else '没回')
151
+ leads.append({
152
+ 'time': datetime.fromtimestamp(m['first'], tz=TZ).strftime('%Y-%m-%d %H:%M'),
153
+ 'talker': name_map.get(talker, talker),
154
+ 'kinds': m['kinds'],
155
+ 'score': m['score'],
156
+ 'said': ' / '.join(p[:80] for p in m['parts'][:3]),
157
+ 'state': state,
158
+ })
159
+
160
+ leads.sort(key=lambda x: (-x['score'], x['time']), reverse=False)
161
+ leads = leads[:args.limit]
162
+
163
+ if args.json:
164
+ print(json.dumps({'success': True, 'days': args.days, 'leads': leads},
165
+ ensure_ascii=False, indent=2))
166
+ return
167
+
168
+ if not leads:
169
+ print(f'最近 {args.days} 天里没有达到分数 {args.min_score} 的线索信号')
170
+ return
171
+ print(f'最近 {args.days} 天的线索信号 {len(leads)} 条(分数越高越像生意):\n')
172
+ for it in leads:
173
+ print(f"[{it['score']}分 · {'/'.join(it['kinds'])} · {it['state']}] {it['time']} {it['talker']}")
174
+ print(f" {it['said']}")
175
+ print()
176
+
177
+
178
+ if __name__ == '__main__':
179
+ main()
@@ -24,6 +24,7 @@ except ImportError:
24
24
 
25
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
26
  from _utils import load_config, decrypt_lock, parse_frontmatter
27
+ from nt_common import decode_content
27
28
 
28
29
  OUTPUT_ROOT = 'output'
29
30
  TZ = timezone(timedelta(hours=8))
@@ -196,7 +197,8 @@ def cmd_search_messages(args):
196
197
  ''', (start_ts, end_ts))
197
198
 
198
199
  for ts, sender, content in c.fetchall():
199
- if not content or not isinstance(content, str):
200
+ content = decode_content(content) # 压缩正文(issue #12)
201
+ if not content:
200
202
  continue
201
203
  if keyword and keyword.lower() not in content.lower():
202
204
  continue
@@ -0,0 +1,150 @@
1
+ """媒体精确匹配:用微信自己的 `hardlink.db` 把 md5 直接对到本地文件,不猜目录。
2
+
3
+ 在此之前,找一张图/一个文件只能**扫描整个 msg 目录**再比对,慢且会漏(同 md5 的
4
+ 转发件、被改名的副本都不认)。`hardlink.db` 是微信自己的索引:三张表分别记
5
+ image / file / video 的 `md5 → (dir1, dir2, file_name)`,`dir2id` 把这两个 id 映射成
6
+ 目录名,路径因此是**确定的**。
7
+
8
+ 实测出来的路径规律(本机 2026-10-26,逐条对过真实文件):
9
+
10
+ msg/attach/<dir2id[dir2]>/<dir2id[dir1]>/Img/<file_name> 图片
11
+ msg/file/<dir2id[dir1]>/<file_name> 文件
12
+ msg/video/<dir2id[dir1]>/<file_name> 视频
13
+
14
+ 用法:
15
+ python scripts/media_index.py --md5 <md5前若干位>
16
+ python scripts/media_index.py --list --type file --limit 20
17
+ python scripts/media_index.py --json
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import pathlib
24
+ import sys
25
+
26
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(SCRIPT_DIR))
28
+
29
+ from _utils import load_config, decrypt_lock # noqa: E402
30
+ from nt_common import derive_database_key # noqa: E402
31
+ from export_chat_html import connect # noqa: E402
32
+
33
+ #: (表名, 相对 `msg/` 的路径模板)。
34
+ #:
35
+ #: **图片的 dir1/dir2 顺序与另两张表相反**:实测 image 行是 `dir1=会话hash, dir2=月份`
36
+ #: (落在 `attach/<会话>/<月份>/Img/`),而 file/video 行是 `dir1=月份, dir2=0`。
37
+ #: 按统一顺序拼会把图片全指到 `attach/<月份>/<会话>/` 下一个也不存在(2026-10-26 实测)。
38
+ TABLES = {
39
+ 'image': ('image_hardlink_info_v4', 'attach/{d1}/{d2}/Img/{name}'),
40
+ 'file': ('file_hardlink_info_v4', 'file/{d1}/{name}'),
41
+ 'video': ('video_hardlink_info_v4', 'video/{d1}/{name}'),
42
+ }
43
+
44
+
45
+ def hardlink_path(cfg):
46
+ """hardlink.db 与消息库同属 db_storage,用 `ntDbPath` 反推。"""
47
+ msg = pathlib.Path(cfg.get('ntDbPath') or '')
48
+ if not msg.name:
49
+ raise SystemExit('配置里没有 ntDbPath')
50
+ return msg.parent.parent / 'hardlink' / 'hardlink.db'
51
+
52
+
53
+ def account_root(cfg):
54
+ """`msg/` 的父目录(媒体按这种结构摆放)。"""
55
+ return pathlib.Path(cfg.get('ntDbPath') or '').parent.parent.parent
56
+
57
+
58
+ def open_db(cfg):
59
+ path = hardlink_path(cfg)
60
+ if not path.is_file():
61
+ raise SystemExit(f'找不到 {path}')
62
+ key = decrypt_lock(cfg.get('ntKey', '') or '')
63
+ salt = decrypt_lock(cfg.get('ntSalt', '') or '')
64
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
65
+ k, s = derive_database_key(str(path), key, salt, pw)
66
+ return connect(str(path), k, s)
67
+
68
+
69
+ def load_rows(conn, kind):
70
+ table = TABLES[kind][0]
71
+ cur = conn.cursor()
72
+ dirs = {rid: name for rid, name in cur.execute('SELECT rowid, username FROM dir2id')}
73
+ rows = []
74
+ for md5, typ, name, size, d1, d2 in cur.execute(
75
+ 'SELECT md5, type, file_name, file_size, dir1, dir2 FROM "%s"' % table):
76
+ rows.append({'md5': md5 or '', 'type': typ, 'file': name or '',
77
+ 'size': size or 0, 'dir1': dirs.get(d1, ''), 'dir2': dirs.get(d2, ''),
78
+ 'kind': kind})
79
+ return rows
80
+
81
+
82
+ def resolve(cfg, row):
83
+ """一行 → 本地绝对路径(不保证文件还在:微信会清理)。"""
84
+ tmpl = TABLES[row['kind']][1]
85
+ rel = tmpl.format(d1=row['dir1'], d2=row['dir2'], name=row['file'])
86
+ return account_root(cfg) / 'msg' / rel
87
+
88
+
89
+ def main():
90
+ # 手跑时 stdout 是本地代码页(本机 GBK),下面的 ✓/✗ 在 GBK 里编不出来会直接抛
91
+ # UnicodeEncodeError。走 bridge 时父进程已设 PYTHONIOENCODING,两条路表现才会一致。
92
+ try:
93
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
94
+ except Exception:
95
+ pass
96
+ ap = argparse.ArgumentParser(description='媒体精确匹配(读 hardlink.db,本地只读)')
97
+ ap.add_argument('--md5', default='', help='按 md5 前缀查(不区分大小写)')
98
+ ap.add_argument('--type', default='all', choices=['all', 'image', 'file', 'video'])
99
+ ap.add_argument('--list', action='store_true', help='列出行(不给 --md5 时等价于列出)')
100
+ ap.add_argument('--limit', type=int, default=20)
101
+ ap.add_argument('--missing-only', action='store_true', help='只列文件已不在本地的')
102
+ ap.add_argument('--json', action='store_true')
103
+ args = ap.parse_args()
104
+
105
+ cfg = load_config()
106
+ conn, _cur = open_db(cfg)
107
+ try:
108
+ kinds = list(TABLES) if args.type == 'all' else [args.type]
109
+ rows = []
110
+ for kind in kinds:
111
+ rows.extend(load_rows(conn, kind))
112
+ finally:
113
+ try:
114
+ conn.close()
115
+ except Exception:
116
+ pass
117
+
118
+ if args.md5:
119
+ needle = args.md5.lower()
120
+ rows = [r for r in rows if r['md5'].lower().startswith(needle)]
121
+
122
+ for r in rows:
123
+ p = resolve(cfg, r)
124
+ r['path'] = str(p)
125
+ r['exists'] = p.is_file()
126
+ if args.missing_only:
127
+ rows = [r for r in rows if not r['exists']]
128
+
129
+ total = len(rows)
130
+ rows = rows[:args.limit]
131
+
132
+ if args.json:
133
+ print(json.dumps({'success': True, 'total': total, 'rows': rows},
134
+ ensure_ascii=False, indent=2))
135
+ return
136
+ if not rows:
137
+ print('没有匹配的记录')
138
+ return
139
+ present = sum(1 for r in rows if r['exists'])
140
+ print(f'匹配 {total} 条(显示 {len(rows)} 条,其中文件仍在本地的 {present} 条)\n')
141
+ for r in rows:
142
+ mark = '✓' if r['exists'] else '✗'
143
+ print(f"{mark} {r['kind']:<5} {r['md5'][:12]} {r['file'][:44]:<44} {r['size']:>9} 字节")
144
+ print(f" {r['path']}")
145
+ if present < len(rows):
146
+ print(f'\n(✗ 表示 hardlink.db 有记录但文件已被微信清理,不是路径算错)')
147
+
148
+
149
+ if __name__ == '__main__':
150
+ main()