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.
- package/CHANGELOG.md +46 -0
- package/README.md +3 -0
- package/bin/weflow-cli.ts +167 -0
- package/dist/bin/weflow-cli.js +199 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +6 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -1
- package/dist/src/services/diagnosticReport.js +16 -0
- package/dist/src/services/diagnosticReport.js.map +1 -1
- package/dist/src/services/wechatCompat.d.ts +29 -0
- package/dist/src/services/wechatCompat.d.ts.map +1 -0
- package/dist/src/services/wechatCompat.js +65 -0
- package/dist/src/services/wechatCompat.js.map +1 -0
- package/docs/DATA_CONTRACT.md +25 -25
- package/docs/DECISIONS.md +26 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
- package/docs/HEALTH-CHECK.md +104 -104
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
- package/docs/NEXT_ACTIONS.md +236 -236
- package/docs/NPM-PUBLISH.md +130 -130
- package/docs/NT_DATABASES.md +47 -0
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +3 -2
- package/docs/RELEASING.md +4 -0
- package/package.json +1 -1
- package/requirements-3x.txt +4 -4
- package/requirements-voice.txt +15 -15
- package/resources/js/graph3d/NOTICE.txt +35 -35
- package/resources/js/graph3d/d3-binarytree.min.js +2 -2
- package/resources/js/graph3d/d3-dispatch.min.js +2 -2
- package/resources/js/graph3d/d3-force-3d.min.js +2 -2
- package/resources/js/graph3d/d3-octree.min.js +2 -2
- package/resources/js/graph3d/d3-quadtree.min.js +2 -2
- package/resources/js/graph3d/d3-timer.min.js +2 -2
- package/resources/js/graph3d/three.min.js +6 -6
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/annual_report.py +3 -1
- package/scripts/article_notes.py +423 -423
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_mail.py +51 -5
- package/scripts/daily_task.ps1 +216 -213
- package/scripts/extract_todos.py +3 -1
- package/scripts/fav_notes.py +321 -321
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -68
- package/scripts/install_tasks.ps1 +105 -105
- package/scripts/key_cache.py +232 -0
- package/scripts/leads.py +179 -179
- package/scripts/mcp_bridge.py +3 -1
- package/scripts/media_index.py +150 -0
- package/scripts/network.py +206 -206
- package/scripts/nt_common.py +32 -0
- package/scripts/nt_decrypt.py +2023 -1954
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/replay.py +803 -803
- package/scripts/run_hidden.vbs +24 -24
- package/scripts/semantic_search.py +5 -1
- package/scripts/user_notes.py +315 -315
- package/scripts/voice_transcribe.py +139 -139
- package/scripts/watch_issues.py +212 -212
- package/scripts/watch_mail.py +131 -131
- package/scripts/wechat_avatar.py +185 -0
- package/scripts/wechat_ledger.py +190 -0
- package/src/services/cursorProbe.ts +222 -222
- package/src/services/diagnosticReport.ts +22 -0
- package/src/services/qchatImport.ts +327 -327
- package/src/services/setupVenv.ts +238 -238
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatCompat.ts +67 -0
- package/src/services/wechatMedia.ts +166 -166
package/scripts/network.py
CHANGED
|
@@ -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()
|
package/scripts/nt_common.py
CHANGED
|
@@ -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')
|