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
@@ -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')