weflow-cli 1.17.0 → 1.19.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 (71) hide show
  1. package/CHANGELOG.md +30 -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 +2 -1
  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_task.ps1 +213 -213
  43. package/scripts/extract_todos.py +3 -1
  44. package/scripts/fav_notes.py +321 -321
  45. package/scripts/health_check.py +473 -473
  46. package/scripts/imported_chats.py +68 -68
  47. package/scripts/install_tasks.ps1 +105 -105
  48. package/scripts/leads.py +179 -179
  49. package/scripts/mcp_bridge.py +3 -1
  50. package/scripts/media_index.py +150 -0
  51. package/scripts/network.py +206 -206
  52. package/scripts/nt_common.py +32 -0
  53. package/scripts/nt_decrypt.py +19 -15
  54. package/scripts/opinion_timeline.py +138 -138
  55. package/scripts/replay.py +803 -803
  56. package/scripts/run_hidden.vbs +24 -24
  57. package/scripts/semantic_search.py +5 -1
  58. package/scripts/user_notes.py +315 -315
  59. package/scripts/voice_transcribe.py +139 -139
  60. package/scripts/watch_issues.py +212 -212
  61. package/scripts/watch_mail.py +131 -131
  62. package/scripts/wechat_avatar.py +185 -0
  63. package/scripts/wechat_ledger.py +190 -0
  64. package/src/services/cursorProbe.ts +222 -222
  65. package/src/services/diagnosticReport.ts +22 -0
  66. package/src/services/qchatImport.ts +327 -327
  67. package/src/services/setupVenv.ts +238 -238
  68. package/src/services/syncService.ts +363 -363
  69. package/src/services/syncState.ts +275 -275
  70. package/src/services/wechatCompat.ts +67 -0
  71. package/src/services/wechatMedia.ts +166 -166
@@ -1,206 +1,206 @@
1
- """人脉图谱:谁和你在同一个群、谁适合做引荐人。
2
-
3
- 数据全在 `contact.db`(联系人 + 群 + 群成员),不碰消息库,也就没有"读聊天内容"这件事:
4
- contact(id, username, nick_name, remark) 4192 人
5
- chat_room(id, username, owner) 80 个群
6
- chatroom_member(room_id, member_id) 4305 条关系(member_id 是 contact.id)
7
-
8
- 用法:
9
- python scripts/network.py --groups 群概览(成员数,按大小排)
10
- python scripts/network.py --with 张三 我和他共同在哪些群
11
- python scripts/network.py --intro 张三 谁最适合把我介绍给他
12
- python scripts/network.py --intro 张三 --json
13
- """
14
- from __future__ import annotations
15
-
16
- import argparse
17
- import json
18
- import pathlib
19
- import sys
20
- from collections import defaultdict
21
-
22
- SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
23
- sys.path.insert(0, str(SCRIPT_DIR))
24
-
25
- from _utils import load_config, decrypt_lock # noqa: E402
26
- from nt_common import derive_database_key # noqa: E402
27
- from export_chat_html import connect # noqa: E402
28
-
29
-
30
- def _open_contact(cfg):
31
- path = cfg.get('contactDbPath') or ''
32
- if not path:
33
- raise SystemExit('配置里没有 contactDbPath,先跑 weflow-cli init')
34
- key = decrypt_lock(cfg.get('contactKey', '') or cfg.get('ntKey', '') or '')
35
- salt = decrypt_lock(cfg.get('contactSalt', '') or cfg.get('ntSalt', '') or '')
36
- pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
- k, s = derive_database_key(path, key, salt, pw)
38
- return connect(path, k, s)
39
-
40
-
41
- def load_graph(cfg):
42
- """(people, rooms, members) —— people: id->名字; rooms: id->群名; members: room->{id}"""
43
- conn, cur = _open_contact(cfg)
44
- try:
45
- people = {}
46
- for pid, username, nick, remark in cur.execute(
47
- 'SELECT id, username, COALESCE(nick_name,""), COALESCE(remark,"") FROM contact'):
48
- name = remark or nick or username
49
- people[int(pid)] = {'name': name, 'username': username, 'nick': nick, 'remark': remark}
50
-
51
- rooms = {}
52
- # 群昵称在 `contact` 里(`@chatroom` 那条的 remark/nick_name),`chat_room.username`
53
- # 只是 id —— 不查这一步,输出里 80 个群全叫 `47966561144@chatroom`。
54
- by_username = {p['username']: p for p in people.values()}
55
- for rid, username, owner in cur.execute('SELECT id, username, COALESCE(owner,"") FROM chat_room'):
56
- info = by_username.get(username) or {}
57
- name = info.get('remark') or info.get('nick') or username
58
- rooms[int(rid)] = {'name': name, 'username': username, 'owner': owner}
59
-
60
- members = defaultdict(set)
61
- for rid, mid in cur.execute('SELECT room_id, member_id FROM chatroom_member'):
62
- members[int(rid)].add(int(mid))
63
- return people, rooms, members
64
- finally:
65
- conn.close()
66
-
67
-
68
- def resolve(people, needle):
69
- """按 wxid / 昵称 / 备注找一个人;返回 (id, 记录) 或 None。
70
-
71
- **排除群**:`contact` 表里 `@chatroom` 也在,用 `--with 老表` 会先命中「老表亲戚群」
72
- 这个群而不是人(第一版就这么错),所以匹配人时跳过它们。
73
- """
74
- needle = (needle or '').strip()
75
- if not needle:
76
- return None
77
- exact, fuzzy = [], []
78
- for pid, p in people.items():
79
- if p['username'].endswith('@chatroom'):
80
- continue
81
- fields = (p['name'], p['username'], p['nick'], p['remark'])
82
- if needle == p['username']:
83
- return pid, p
84
- if any(needle == f for f in fields if f):
85
- exact.append((pid, p))
86
- elif any(needle in f for f in fields if f):
87
- fuzzy.append((pid, p))
88
- pool = exact or fuzzy
89
- return pool[0] if pool else None
90
-
91
-
92
- def find_me(people, members, cfg):
93
- """自己的 contact.id。
94
-
95
- 先按配置里的 wxid 找;**找不到就用"出现在最多群里的人"** —— 你在自己所有群里,
96
- 而 `contact` 表不一定收录自己(实测这里就没有),少了这个兜底,
97
- 「共同群」这类功能会直接退化成一列群名。
98
- """
99
- wxid = cfg.get('wxid') or ''
100
- if wxid:
101
- for pid, p in people.items():
102
- if p['username'] == wxid:
103
- return pid
104
- counts = defaultdict(int)
105
- for ids in members.values():
106
- for pid in ids:
107
- counts[pid] += 1
108
- if counts:
109
- pid, n = max(counts.items(), key=lambda kv: kv[1])
110
- if n >= 3:
111
- return pid
112
- return None
113
-
114
-
115
- def rooms_of(members, pid):
116
- return {rid for rid, ids in members.items() if pid in ids}
117
-
118
-
119
- def cmd_groups(people, rooms, members):
120
- rows = []
121
- for rid, info in rooms.items():
122
- ids = members.get(rid, set())
123
- rows.append({'room': info['name'], 'members': len(ids)})
124
- rows.sort(key=lambda r: -r['members'])
125
- print(f'共 {len(rows)} 个群\n')
126
- for r in rows[:30]:
127
- print(f" {r['members']:>4} 人 {r['room']}")
128
- if len(rows) > 30:
129
- print(f' …还有 {len(rows)-30} 个')
130
-
131
-
132
- def cmd_with(people, rooms, members, needle, cfg):
133
- me_id = find_me(people, members, cfg)
134
- me = (me_id, people.get(me_id, {})) if me_id else None
135
- target = resolve(people, needle)
136
- if not target:
137
- raise SystemExit(f'没找到「{needle}」')
138
- tid, tinfo = target
139
- t_rooms = rooms_of(members, tid)
140
- print(f"目标:{tinfo['name']}({tinfo['username']})在 {len(t_rooms)} 个群")
141
- if not me:
142
- print('(配置里没有自己的 wxid,跳过共同群计算)')
143
- for rid in sorted(t_rooms):
144
- print(f" · {rooms[rid]['name']}")
145
- return
146
- my_rooms = rooms_of(members, me[0])
147
- common = t_rooms & my_rooms
148
- print(f"你们共同在 {len(common)} 个群:")
149
- for rid in sorted(common):
150
- print(f" · {rooms[rid]['name']}({len(members.get(rid, []))} 人)")
151
- if not common:
152
- print(' (没有共同群)')
153
-
154
-
155
- def cmd_intro(people, rooms, members, needle, cfg, top=8):
156
- me_id = find_me(people, members, cfg)
157
- me = (me_id, people.get(me_id, {})) if me_id else None
158
- target = resolve(people, needle)
159
- if not target:
160
- raise SystemExit(f'没找到「{needle}」')
161
- tid, tinfo = target
162
- if not me:
163
- raise SystemExit('配置里没有自己的 wxid,算不了引荐路径')
164
- my_rooms = rooms_of(members, me[0])
165
- t_rooms = rooms_of(members, tid)
166
-
167
- # 候选人 = 同时在我和目标的群里的人;分数 = 交集群数,其次是他和目标最近的共同群大小
168
- scores = defaultdict(lambda: {'rooms': set()})
169
- for rid in my_rooms & t_rooms:
170
- for pid in members.get(rid, set()):
171
- if pid in (me[0], tid):
172
- continue
173
- scores[pid]['rooms'].add(rid)
174
- if not scores:
175
- print(f'你和「{tinfo["name"]}」不在同一个群,也没有共同的联系人可引荐')
176
- return
177
- ranked = sorted(scores.items(), key=lambda kv: -len(kv[1]['rooms']))
178
- print(f"想认识「{tinfo['name']}」,这些人同时和你们在一个群:\n")
179
- for pid, info in ranked[:top]:
180
- p = people.get(pid, {})
181
- shared = '、'.join(rooms[r]['name'] for r in list(info['rooms'])[:3])
182
- print(f" {p.get('name') or p.get('username')} 共同群 {len(info['rooms'])} 个:{shared}")
183
-
184
-
185
- def main():
186
- ap = argparse.ArgumentParser(description='人脉图谱:共同群与引荐路径')
187
- ap.add_argument('--groups', action='store_true', help='群概览')
188
- ap.add_argument('--with', dest='with_who', default='', help='我和他共同在哪些群')
189
- ap.add_argument('--intro', default='', help='谁能把我介绍给他')
190
- ap.add_argument('--json', action='store_true')
191
- args = ap.parse_args()
192
-
193
- cfg = load_config()
194
- people, rooms, members = load_graph(cfg)
195
- if args.groups:
196
- cmd_groups(people, rooms, members)
197
- elif args.with_who:
198
- cmd_with(people, rooms, members, args.with_who, cfg)
199
- elif args.intro:
200
- cmd_intro(people, rooms, members, args.intro, cfg)
201
- else:
202
- ap.print_help()
203
-
204
-
205
- if __name__ == '__main__':
206
- main()
1
+ """人脉图谱:谁和你在同一个群、谁适合做引荐人。
2
+
3
+ 数据全在 `contact.db`(联系人 + 群 + 群成员),不碰消息库,也就没有"读聊天内容"这件事:
4
+ contact(id, username, nick_name, remark) 4192 人
5
+ chat_room(id, username, owner) 80 个群
6
+ chatroom_member(room_id, member_id) 4305 条关系(member_id 是 contact.id)
7
+
8
+ 用法:
9
+ python scripts/network.py --groups 群概览(成员数,按大小排)
10
+ python scripts/network.py --with 张三 我和他共同在哪些群
11
+ python scripts/network.py --intro 张三 谁最适合把我介绍给他
12
+ python scripts/network.py --intro 张三 --json
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import pathlib
19
+ import sys
20
+ from collections import defaultdict
21
+
22
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
23
+ sys.path.insert(0, str(SCRIPT_DIR))
24
+
25
+ from _utils import load_config, decrypt_lock # noqa: E402
26
+ from nt_common import derive_database_key # noqa: E402
27
+ from export_chat_html import connect # noqa: E402
28
+
29
+
30
+ def _open_contact(cfg):
31
+ path = cfg.get('contactDbPath') or ''
32
+ if not path:
33
+ raise SystemExit('配置里没有 contactDbPath,先跑 weflow-cli init')
34
+ key = decrypt_lock(cfg.get('contactKey', '') or cfg.get('ntKey', '') or '')
35
+ salt = decrypt_lock(cfg.get('contactSalt', '') or cfg.get('ntSalt', '') or '')
36
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
+ k, s = derive_database_key(path, key, salt, pw)
38
+ return connect(path, k, s)
39
+
40
+
41
+ def load_graph(cfg):
42
+ """(people, rooms, members) —— people: id->名字; rooms: id->群名; members: room->{id}"""
43
+ conn, cur = _open_contact(cfg)
44
+ try:
45
+ people = {}
46
+ for pid, username, nick, remark in cur.execute(
47
+ 'SELECT id, username, COALESCE(nick_name,""), COALESCE(remark,"") FROM contact'):
48
+ name = remark or nick or username
49
+ people[int(pid)] = {'name': name, 'username': username, 'nick': nick, 'remark': remark}
50
+
51
+ rooms = {}
52
+ # 群昵称在 `contact` 里(`@chatroom` 那条的 remark/nick_name),`chat_room.username`
53
+ # 只是 id —— 不查这一步,输出里 80 个群全叫 `47966561144@chatroom`。
54
+ by_username = {p['username']: p for p in people.values()}
55
+ for rid, username, owner in cur.execute('SELECT id, username, COALESCE(owner,"") FROM chat_room'):
56
+ info = by_username.get(username) or {}
57
+ name = info.get('remark') or info.get('nick') or username
58
+ rooms[int(rid)] = {'name': name, 'username': username, 'owner': owner}
59
+
60
+ members = defaultdict(set)
61
+ for rid, mid in cur.execute('SELECT room_id, member_id FROM chatroom_member'):
62
+ members[int(rid)].add(int(mid))
63
+ return people, rooms, members
64
+ finally:
65
+ conn.close()
66
+
67
+
68
+ def resolve(people, needle):
69
+ """按 wxid / 昵称 / 备注找一个人;返回 (id, 记录) 或 None。
70
+
71
+ **排除群**:`contact` 表里 `@chatroom` 也在,用 `--with 老表` 会先命中「老表亲戚群」
72
+ 这个群而不是人(第一版就这么错),所以匹配人时跳过它们。
73
+ """
74
+ needle = (needle or '').strip()
75
+ if not needle:
76
+ return None
77
+ exact, fuzzy = [], []
78
+ for pid, p in people.items():
79
+ if p['username'].endswith('@chatroom'):
80
+ continue
81
+ fields = (p['name'], p['username'], p['nick'], p['remark'])
82
+ if needle == p['username']:
83
+ return pid, p
84
+ if any(needle == f for f in fields if f):
85
+ exact.append((pid, p))
86
+ elif any(needle in f for f in fields if f):
87
+ fuzzy.append((pid, p))
88
+ pool = exact or fuzzy
89
+ return pool[0] if pool else None
90
+
91
+
92
+ def find_me(people, members, cfg):
93
+ """自己的 contact.id。
94
+
95
+ 先按配置里的 wxid 找;**找不到就用"出现在最多群里的人"** —— 你在自己所有群里,
96
+ 而 `contact` 表不一定收录自己(实测这里就没有),少了这个兜底,
97
+ 「共同群」这类功能会直接退化成一列群名。
98
+ """
99
+ wxid = cfg.get('wxid') or ''
100
+ if wxid:
101
+ for pid, p in people.items():
102
+ if p['username'] == wxid:
103
+ return pid
104
+ counts = defaultdict(int)
105
+ for ids in members.values():
106
+ for pid in ids:
107
+ counts[pid] += 1
108
+ if counts:
109
+ pid, n = max(counts.items(), key=lambda kv: kv[1])
110
+ if n >= 3:
111
+ return pid
112
+ return None
113
+
114
+
115
+ def rooms_of(members, pid):
116
+ return {rid for rid, ids in members.items() if pid in ids}
117
+
118
+
119
+ def cmd_groups(people, rooms, members):
120
+ rows = []
121
+ for rid, info in rooms.items():
122
+ ids = members.get(rid, set())
123
+ rows.append({'room': info['name'], 'members': len(ids)})
124
+ rows.sort(key=lambda r: -r['members'])
125
+ print(f'共 {len(rows)} 个群\n')
126
+ for r in rows[:30]:
127
+ print(f" {r['members']:>4} 人 {r['room']}")
128
+ if len(rows) > 30:
129
+ print(f' …还有 {len(rows)-30} 个')
130
+
131
+
132
+ def cmd_with(people, rooms, members, needle, cfg):
133
+ me_id = find_me(people, members, cfg)
134
+ me = (me_id, people.get(me_id, {})) if me_id else None
135
+ target = resolve(people, needle)
136
+ if not target:
137
+ raise SystemExit(f'没找到「{needle}」')
138
+ tid, tinfo = target
139
+ t_rooms = rooms_of(members, tid)
140
+ print(f"目标:{tinfo['name']}({tinfo['username']})在 {len(t_rooms)} 个群")
141
+ if not me:
142
+ print('(配置里没有自己的 wxid,跳过共同群计算)')
143
+ for rid in sorted(t_rooms):
144
+ print(f" · {rooms[rid]['name']}")
145
+ return
146
+ my_rooms = rooms_of(members, me[0])
147
+ common = t_rooms & my_rooms
148
+ print(f"你们共同在 {len(common)} 个群:")
149
+ for rid in sorted(common):
150
+ print(f" · {rooms[rid]['name']}({len(members.get(rid, []))} 人)")
151
+ if not common:
152
+ print(' (没有共同群)')
153
+
154
+
155
+ def cmd_intro(people, rooms, members, needle, cfg, top=8):
156
+ me_id = find_me(people, members, cfg)
157
+ me = (me_id, people.get(me_id, {})) if me_id else None
158
+ target = resolve(people, needle)
159
+ if not target:
160
+ raise SystemExit(f'没找到「{needle}」')
161
+ tid, tinfo = target
162
+ if not me:
163
+ raise SystemExit('配置里没有自己的 wxid,算不了引荐路径')
164
+ my_rooms = rooms_of(members, me[0])
165
+ t_rooms = rooms_of(members, tid)
166
+
167
+ # 候选人 = 同时在我和目标的群里的人;分数 = 交集群数,其次是他和目标最近的共同群大小
168
+ scores = defaultdict(lambda: {'rooms': set()})
169
+ for rid in my_rooms & t_rooms:
170
+ for pid in members.get(rid, set()):
171
+ if pid in (me[0], tid):
172
+ continue
173
+ scores[pid]['rooms'].add(rid)
174
+ if not scores:
175
+ print(f'你和「{tinfo["name"]}」不在同一个群,也没有共同的联系人可引荐')
176
+ return
177
+ ranked = sorted(scores.items(), key=lambda kv: -len(kv[1]['rooms']))
178
+ print(f"想认识「{tinfo['name']}」,这些人同时和你们在一个群:\n")
179
+ for pid, info in ranked[:top]:
180
+ p = people.get(pid, {})
181
+ shared = '、'.join(rooms[r]['name'] for r in list(info['rooms'])[:3])
182
+ print(f" {p.get('name') or p.get('username')} 共同群 {len(info['rooms'])} 个:{shared}")
183
+
184
+
185
+ def main():
186
+ ap = argparse.ArgumentParser(description='人脉图谱:共同群与引荐路径')
187
+ ap.add_argument('--groups', action='store_true', help='群概览')
188
+ ap.add_argument('--with', dest='with_who', default='', help='我和他共同在哪些群')
189
+ ap.add_argument('--intro', default='', help='谁能把我介绍给他')
190
+ ap.add_argument('--json', action='store_true')
191
+ args = ap.parse_args()
192
+
193
+ cfg = load_config()
194
+ people, rooms, members = load_graph(cfg)
195
+ if args.groups:
196
+ cmd_groups(people, rooms, members)
197
+ elif args.with_who:
198
+ cmd_with(people, rooms, members, args.with_who, cfg)
199
+ elif args.intro:
200
+ cmd_intro(people, rooms, members, args.intro, cfg)
201
+ else:
202
+ ap.print_help()
203
+
204
+
205
+ if __name__ == '__main__':
206
+ main()
@@ -80,3 +80,35 @@ def table_columns(cursor, table):
80
80
  except Exception:
81
81
  return set()
82
82
  return {row[1] for row in rows}
83
+
84
+
85
+ #: zstd frame magic。`message_content` 在 4.x 里**可能被压缩**(issue #12)。
86
+ ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
87
+
88
+
89
+ def decode_content(value):
90
+ """`message_content` → 文本。**所有读消息的脚本都该走这里。**
91
+
92
+ 三种形态都要接:`str`(老格式,直接返回)、zstd frame(4.x 压缩后存的)、
93
+ 以及普通 bytes。解不出就回空串——调用方拿到的永远是字符串,不需要自己判断类型。
94
+
95
+ 为什么要一个公共函数:这条规则以前散在五个脚本里,其中四个写的是
96
+ `if not isinstance(content, str): continue`,于是**压缩正文被整条跳过**——
97
+ 消息不进索引、不进待办、不进年度统计,而且不报错(issue #12 同源)。
98
+ """
99
+ if isinstance(value, str):
100
+ return value
101
+ if not value:
102
+ return ''
103
+ try:
104
+ data = bytes(value)
105
+ except Exception:
106
+ return ''
107
+ if data[:4] == ZSTD_MAGIC:
108
+ try:
109
+ import zstandard
110
+ return zstandard.ZstdDecompressor().decompress(data).decode('utf-8', 'ignore')
111
+ except Exception:
112
+ # 声称是 zstd 但内容坏了:回退成按字节解码,不抛也不丢整条消息。
113
+ return data.decode('utf-8', 'ignore')
114
+ return data.decode('utf-8', 'ignore')
@@ -989,19 +989,13 @@ QUOTE_CLIP = 120
989
989
 
990
990
  ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
991
991
  def _decode_content(raw):
992
- """消息内容 → 文本。BLOB 先按 zstd 解压(公众号消息同一套机制)。解不出回空串。"""
993
- if isinstance(raw, str):
994
- return raw
995
- if not raw:
996
- return ''
997
- data = bytes(raw)
998
- if data[:4] == ZSTD_MAGIC: # zstd frame header
999
- try:
1000
- import zstandard
1001
- return zstandard.ZstdDecompressor().decompress(data).decode('utf-8', 'ignore')
1002
- except Exception:
1003
- return ''
1004
- return data.decode('utf-8', 'ignore')
992
+ """消息内容 → 文本。实现在 `nt_common.decode_content`(各脚本共用一份)。
993
+
994
+ 保留这个名字是为了不动 `non_text_display` 等既有调用点;行为与公共函数一致:
995
+ str 原样、zstd frame 解压、其它 bytes 按 UTF-8 解码,解不出回空串。
996
+ """
997
+ from nt_common import decode_content
998
+ return decode_content(raw)
1005
999
 
1006
1000
 
1007
1001
  def _xml_block(xml, tag):
@@ -1117,7 +1111,15 @@ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
1117
1111
 
1118
1112
  # Parse message_content - TEXT column
1119
1113
  raw_content = row.get('message_content')
1120
- content = raw_content if isinstance(raw_content, str) else ""
1114
+ # **普通文本也可能是 zstd 压缩的 BLOB**(issue #12)。这里原来只认 `str`,
1115
+ # 于是 `local_type == 1` 的压缩正文被置空、再交给 `non_text_display()`,
1116
+ # 最终输出 `[未识别的消息类型 1]` —— 消息还在,正文没了,下游按
1117
+ # `parsedContent` 做的关键词 / @所有人 筛选就漏掉这些通知。
1118
+ # `_decode_content()` 已经处理三种形态(str / zstd frame / 普通 bytes),复用它即可。
1119
+ if local_type == 1:
1120
+ content = _decode_content(raw_content)
1121
+ else:
1122
+ content = raw_content if isinstance(raw_content, str) else ""
1121
1123
 
1122
1124
  # `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
1123
1125
  # field every consumer reads first - gets the display-ready form.
@@ -1140,7 +1142,9 @@ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
1140
1142
  "senderDisplay": sender_display,
1141
1143
  "content": content,
1142
1144
  "rawContent": content,
1143
- "parsedContent": display[:200] if local_type == 1 else display[:200],
1145
+ # 文本不截断:末尾的 `@所有人` 一类关键词正是下游筛选要用的,截掉等于静默漏消息
1146
+ # (issue #12)。非文本仍保留 200 字的展示上限——它们是标签和标题,不是正文。
1147
+ "parsedContent": display if local_type == 1 else display[:200],
1144
1148
  }
1145
1149
 
1146
1150