weflow-cli 1.15.1 → 1.17.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.
- package/CHANGELOG.md +59 -1
- package/OPERATIONS.md +16 -1
- package/README.en.md +21 -2
- package/README.md +34 -6
- package/bin/weflow-cli.ts +389 -6
- package/dist/bin/weflow-cli.js +422 -8
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +47 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +24 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +8 -1
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +47 -8
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +8 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +67 -11
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/cursorProbe.d.ts +44 -0
- package/dist/src/services/cursorProbe.d.ts.map +1 -0
- package/dist/src/services/cursorProbe.js +183 -0
- package/dist/src/services/cursorProbe.js.map +1 -0
- package/dist/src/services/qchatImport.d.ts +89 -0
- package/dist/src/services/qchatImport.d.ts.map +1 -0
- package/dist/src/services/qchatImport.js +273 -0
- package/dist/src/services/qchatImport.js.map +1 -0
- package/dist/src/services/setupVenv.d.ts +43 -0
- package/dist/src/services/setupVenv.d.ts.map +1 -0
- package/dist/src/services/setupVenv.js +198 -0
- package/dist/src/services/setupVenv.js.map +1 -0
- package/dist/src/utils/python.d.ts +10 -0
- package/dist/src/utils/python.d.ts.map +1 -1
- package/dist/src/utils/python.js +27 -4
- package/dist/src/utils/python.js.map +1 -1
- package/docs/DATA_CONTRACT.md +25 -0
- package/docs/DECISIONS.md +92 -30
- 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/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +6 -3
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- 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/js/graph3d/viewer2d.js +550 -448
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/article_notes.py +423 -423
- package/scripts/bonds.py +11 -0
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_task.ps1 +213 -177
- package/scripts/fav_notes.py +321 -321
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -0
- package/scripts/install_tasks.ps1 +105 -91
- package/scripts/leads.py +179 -0
- package/scripts/network.py +206 -206
- package/scripts/nt_decrypt.py +135 -1
- package/scripts/on_this_day.py +178 -178
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +73 -5
- 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/src/core/ntCore.ts +59 -0
- package/src/services/assistantService.ts +48 -8
- package/src/services/assistantTools.ts +73 -10
- package/src/services/configService.ts +3 -2
- package/src/services/cursorProbe.ts +222 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatMedia.ts +166 -166
- package/src/utils/python.ts +33 -4
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
"""关系网络时间重放:按**天**推进,生成可播放的单文件 HTML。
|
|
2
|
+
|
|
3
|
+
设计见 `docs/REPLAY.md`。与第一版(按月)的区别全部来自 2026-10-25 的实际反馈:
|
|
4
|
+
· **尺度改成天**(按月看不出"哪天断的");边用**滚动 30 天窗口**聚合,
|
|
5
|
+
否则大部分天只有零星几条、边会一天出现一天消失,看起来像数据在跳;
|
|
6
|
+
· **节点带名字**(群全部标,人只标前 12 个,避免标签糊成一片),鼠标悬停看全部;
|
|
7
|
+
· **图例**写明颜色与大小含义;
|
|
8
|
+
· **播放速度可调**(970 天:慢档约 4 分钟,极快约 12 秒);
|
|
9
|
+
· 右侧写出**断掉的关系叫什么**,不只给 id。
|
|
10
|
+
|
|
11
|
+
纯本地:不联网、不调模型。
|
|
12
|
+
|
|
13
|
+
用法:
|
|
14
|
+
python scripts/replay.py # output/replay.html
|
|
15
|
+
python scripts/replay.py --window 30 --json # 只输出聚合结果(验收用)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import pathlib
|
|
23
|
+
import sys
|
|
24
|
+
from collections import Counter, defaultdict, deque
|
|
25
|
+
from datetime import datetime, timezone, timedelta
|
|
26
|
+
|
|
27
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
28
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
29
|
+
|
|
30
|
+
from _utils import load_config, decrypt_lock # noqa: E402
|
|
31
|
+
import nt_decrypt as nd # noqa: E402
|
|
32
|
+
|
|
33
|
+
ROOT = SCRIPT_DIR.parent
|
|
34
|
+
OUT_HTML = ROOT / 'output' / 'replay.html'
|
|
35
|
+
TZ = timezone(timedelta(hours=8))
|
|
36
|
+
|
|
37
|
+
#: 边权门槛:窗口内少于这么多条就不画。
|
|
38
|
+
DEFAULT_MIN_WEIGHT = 3
|
|
39
|
+
#: 边按"过去 N 天"聚合。按天推进时必须窗口化。
|
|
40
|
+
DEFAULT_WINDOW = 30
|
|
41
|
+
#: `gone` 只报重要边:窗口内曾 ≥ 这个权重、现在掉出门槛,才算"断了"。
|
|
42
|
+
GONE_MIN_WEIGHT = 8
|
|
43
|
+
#: 会话数低于这个值的那天判为 sparse(早期只有一两个会话)。
|
|
44
|
+
SPARSE_TALKERS = 20
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _keys(cfg):
|
|
48
|
+
return (decrypt_lock(cfg.get('ntKey', '') or ''),
|
|
49
|
+
decrypt_lock(cfg.get('ntSalt', '') or ''),
|
|
50
|
+
decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or ''))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _names(cfg):
|
|
54
|
+
try:
|
|
55
|
+
from network import load_graph
|
|
56
|
+
people, _rooms, _members = load_graph(cfg)
|
|
57
|
+
return {p['username']: (p['remark'] or p['nick'] or p['username'])
|
|
58
|
+
for p in people.values()}
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
print(f'[WARN] 读联系人失败({type(exc).__name__}),节点显示 id', file=sys.stderr)
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def scan_days(cfg):
|
|
65
|
+
"""按天聚合。返回 (per_day, total, failed)。"""
|
|
66
|
+
db = cfg.get('ntDbPath') or ''
|
|
67
|
+
if not db:
|
|
68
|
+
raise SystemExit('配置里没有 ntDbPath')
|
|
69
|
+
key, salt, pw = _keys(cfg)
|
|
70
|
+
conns = nd.connect_message_shards(db, key, salt, pw)
|
|
71
|
+
if not conns:
|
|
72
|
+
raise SystemExit('打不开消息分片')
|
|
73
|
+
|
|
74
|
+
per_day = defaultdict(lambda: {
|
|
75
|
+
'messages': 0, 'talkers': set(), 'people': set(),
|
|
76
|
+
'dm': Counter(), 'groups': Counter(), 'person_group': defaultdict(Counter),
|
|
77
|
+
})
|
|
78
|
+
total = 0
|
|
79
|
+
failed = []
|
|
80
|
+
|
|
81
|
+
for conn in conns:
|
|
82
|
+
c = conn.cursor()
|
|
83
|
+
try:
|
|
84
|
+
c.execute('SELECT rowid, user_name FROM Name2Id')
|
|
85
|
+
id2name = {r[0]: r[1] for r in c.fetchall()}
|
|
86
|
+
except Exception:
|
|
87
|
+
id2name = {}
|
|
88
|
+
try:
|
|
89
|
+
c.execute('SELECT user_name FROM Name2Id WHERE is_session = 1')
|
|
90
|
+
sessions = [r[0] for r in c.fetchall() if r[0]]
|
|
91
|
+
except Exception:
|
|
92
|
+
sessions = []
|
|
93
|
+
for talker in sessions:
|
|
94
|
+
tbl = 'Msg_' + hashlib.md5(talker.encode()).hexdigest()
|
|
95
|
+
try:
|
|
96
|
+
c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
|
|
97
|
+
if c.fetchone()[0] == 0:
|
|
98
|
+
continue
|
|
99
|
+
c.execute('SELECT create_time, real_sender_id FROM "%s"' % tbl)
|
|
100
|
+
rows = c.fetchall()
|
|
101
|
+
except Exception:
|
|
102
|
+
failed.append(talker)
|
|
103
|
+
continue
|
|
104
|
+
is_group = talker.endswith('@chatroom')
|
|
105
|
+
for ts, sender in rows:
|
|
106
|
+
if not ts:
|
|
107
|
+
continue
|
|
108
|
+
day = datetime.fromtimestamp(ts, tz=TZ).strftime('%Y-%m-%d')
|
|
109
|
+
d = per_day[day]
|
|
110
|
+
d['messages'] += 1
|
|
111
|
+
total += 1
|
|
112
|
+
d['talkers'].add(talker)
|
|
113
|
+
who = id2name.get(sender)
|
|
114
|
+
if who:
|
|
115
|
+
d['people'].add(who)
|
|
116
|
+
if is_group:
|
|
117
|
+
d['groups'][talker] += 1
|
|
118
|
+
if who:
|
|
119
|
+
d['person_group'][who][talker] += 1
|
|
120
|
+
elif who:
|
|
121
|
+
d['dm'][who] += 1
|
|
122
|
+
|
|
123
|
+
for conn in conns:
|
|
124
|
+
try:
|
|
125
|
+
conn.close()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
return per_day, total, failed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_frames(per_day, names, min_weight, window):
|
|
132
|
+
"""每天一帧,边取过去 `window` 天的累积(滑动窗口)。"""
|
|
133
|
+
days = sorted(per_day)
|
|
134
|
+
if not days:
|
|
135
|
+
return [], 0
|
|
136
|
+
|
|
137
|
+
win = deque()
|
|
138
|
+
dm_sum = Counter()
|
|
139
|
+
grp_sum = Counter()
|
|
140
|
+
pg_sum = defaultdict(Counter)
|
|
141
|
+
win_talkers = Counter() # 窗口内出现过的会话 -> 天数
|
|
142
|
+
prev_edges = {}
|
|
143
|
+
prev2_edges = {}
|
|
144
|
+
frames = []
|
|
145
|
+
filtered = 0
|
|
146
|
+
|
|
147
|
+
for day in days:
|
|
148
|
+
data = per_day[day]
|
|
149
|
+
win.append((day, data))
|
|
150
|
+
dm_sum.update(data['dm'])
|
|
151
|
+
grp_sum.update(data['groups'])
|
|
152
|
+
win_talkers.update(data['talkers'])
|
|
153
|
+
for who, rooms in data['person_group'].items():
|
|
154
|
+
pg_sum[who].update(rooms)
|
|
155
|
+
while win and (datetime.strptime(day, '%Y-%m-%d') -
|
|
156
|
+
datetime.strptime(win[0][0], '%Y-%m-%d')).days > window:
|
|
157
|
+
_old_day, old = win.popleft()
|
|
158
|
+
for t in old['talkers']:
|
|
159
|
+
win_talkers[t] -= 1
|
|
160
|
+
if win_talkers[t] <= 0:
|
|
161
|
+
win_talkers.pop(t, None)
|
|
162
|
+
for k, v in old['dm'].items():
|
|
163
|
+
dm_sum[k] -= v
|
|
164
|
+
if dm_sum[k] <= 0:
|
|
165
|
+
dm_sum.pop(k, None)
|
|
166
|
+
for k, v in old['groups'].items():
|
|
167
|
+
grp_sum[k] -= v
|
|
168
|
+
if grp_sum[k] <= 0:
|
|
169
|
+
grp_sum.pop(k, None)
|
|
170
|
+
for who, rooms in old['person_group'].items():
|
|
171
|
+
for room, v in rooms.items():
|
|
172
|
+
pg_sum[who][room] -= v
|
|
173
|
+
if pg_sum[who][room] <= 0:
|
|
174
|
+
pg_sum[who].pop(room, None)
|
|
175
|
+
if not pg_sum[who]:
|
|
176
|
+
pg_sum.pop(who, None)
|
|
177
|
+
|
|
178
|
+
edges = {}
|
|
179
|
+
for who, weight in dm_sum.items():
|
|
180
|
+
if weight < min_weight:
|
|
181
|
+
filtered += 1
|
|
182
|
+
continue
|
|
183
|
+
edges['dm:' + who] = {'src': 'me', 'dst': 'p:' + who, 'kind': 'dm',
|
|
184
|
+
'label': names.get(who, who), 'weight': weight}
|
|
185
|
+
for room, weight in grp_sum.items():
|
|
186
|
+
if weight < min_weight:
|
|
187
|
+
filtered += 1
|
|
188
|
+
continue
|
|
189
|
+
edges['grp:' + room] = {'src': 'me', 'dst': 'g:' + room, 'kind': 'group',
|
|
190
|
+
'label': names.get(room, room), 'weight': weight}
|
|
191
|
+
for who, rooms in pg_sum.items():
|
|
192
|
+
best = rooms.most_common(1)
|
|
193
|
+
if not best:
|
|
194
|
+
continue
|
|
195
|
+
room, weight = best[0]
|
|
196
|
+
if weight < min_weight:
|
|
197
|
+
filtered += 1
|
|
198
|
+
continue
|
|
199
|
+
edges['pg:' + who + ':' + room] = {'src': 'p:' + who, 'dst': 'g:' + room,
|
|
200
|
+
'kind': 'member',
|
|
201
|
+
'label': names.get(who, who), 'weight': weight}
|
|
202
|
+
|
|
203
|
+
for eid, e in edges.items():
|
|
204
|
+
if eid not in prev_edges:
|
|
205
|
+
e['state'] = 'new'
|
|
206
|
+
elif e['weight'] <= (prev2_edges.get(eid) or 0) * 0.5:
|
|
207
|
+
e['state'] = 'fading'
|
|
208
|
+
else:
|
|
209
|
+
e['state'] = 'stable'
|
|
210
|
+
|
|
211
|
+
gone = []
|
|
212
|
+
for eid, w in prev_edges.items():
|
|
213
|
+
if eid in edges or w < GONE_MIN_WEIGHT:
|
|
214
|
+
continue
|
|
215
|
+
kind, _, rest = eid.partition(':')
|
|
216
|
+
if kind == 'dm':
|
|
217
|
+
gone.append({'label': names.get(rest, rest), 'kind': 'dm', 'weight': w})
|
|
218
|
+
elif kind == 'grp':
|
|
219
|
+
gone.append({'label': names.get(rest, rest), 'kind': 'group', 'weight': w})
|
|
220
|
+
else:
|
|
221
|
+
who = rest.split(':')[0]
|
|
222
|
+
gone.append({'label': names.get(who, who), 'kind': 'member', 'weight': w})
|
|
223
|
+
|
|
224
|
+
nodes = {}
|
|
225
|
+
for who, weight in dm_sum.items():
|
|
226
|
+
nodes['p:' + who] = {'id': 'p:' + who, 'kind': 'person',
|
|
227
|
+
'label': names.get(who, who), 'weight': weight}
|
|
228
|
+
for room, weight in grp_sum.items():
|
|
229
|
+
nodes['g:' + room] = {'id': 'g:' + room, 'kind': 'group',
|
|
230
|
+
'label': names.get(room, room), 'weight': weight}
|
|
231
|
+
for who, rooms in pg_sum.items():
|
|
232
|
+
if 'p:' + who not in nodes:
|
|
233
|
+
w = sum(rooms.values())
|
|
234
|
+
if w >= min_weight:
|
|
235
|
+
nodes['p:' + who] = {'id': 'p:' + who, 'kind': 'person',
|
|
236
|
+
'label': names.get(who, who), 'weight': w}
|
|
237
|
+
|
|
238
|
+
top = [{'label': names.get(r, r), 'weight': w} for r, w in grp_sum.most_common(5)]
|
|
239
|
+
|
|
240
|
+
frames.append({
|
|
241
|
+
'day': day,
|
|
242
|
+
'messages': data['messages'],
|
|
243
|
+
'window': sum(dm_sum.values()) + sum(grp_sum.values()),
|
|
244
|
+
'talkers': len(data['talkers']),
|
|
245
|
+
'people': len(data['people']),
|
|
246
|
+
'completeness': 'full' if len(win_talkers) >= SPARSE_TALKERS else 'sparse',
|
|
247
|
+
'nodes': list(nodes.values()),
|
|
248
|
+
'edges': list(edges.values()),
|
|
249
|
+
'gone': gone,
|
|
250
|
+
'top': top,
|
|
251
|
+
})
|
|
252
|
+
prev2_edges, prev_edges = prev_edges, {eid: e['weight'] for eid, e in edges.items()}
|
|
253
|
+
|
|
254
|
+
return frames, filtered
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def layout(frames):
|
|
258
|
+
"""确定性的"星系"布局:我在中心,群在环上,人贴在自己最常出现的群旁边。
|
|
259
|
+
|
|
260
|
+
不跑力导向迭代:坐标每帧都动的话,播放时整个画面在抖,读者会以为数据在变。
|
|
261
|
+
"""
|
|
262
|
+
import math
|
|
263
|
+
import random
|
|
264
|
+
|
|
265
|
+
random.seed(42)
|
|
266
|
+
groups = sorted({n['id'] for f in frames for n in f['nodes'] if n['kind'] == 'group'})
|
|
267
|
+
pos = {'me': (0.5, 0.5)}
|
|
268
|
+
for i, g in enumerate(groups):
|
|
269
|
+
a = 2 * math.pi * i / max(1, len(groups))
|
|
270
|
+
pos[g] = (0.5 + 0.42 * math.cos(a), 0.5 + 0.42 * math.sin(a))
|
|
271
|
+
|
|
272
|
+
home = {}
|
|
273
|
+
for f in frames:
|
|
274
|
+
for e in f['edges']:
|
|
275
|
+
if e['kind'] == 'member' and e['src'].startswith('p:'):
|
|
276
|
+
cur = home.get(e['src'])
|
|
277
|
+
if cur is None or e['weight'] > cur[1]:
|
|
278
|
+
home[e['src']] = (e['dst'], e['weight'])
|
|
279
|
+
|
|
280
|
+
people = sorted({n['id'] for f in frames for n in f['nodes'] if n['kind'] == 'person'})
|
|
281
|
+
for pid in people:
|
|
282
|
+
host = home.get(pid)
|
|
283
|
+
if host and host[0] in pos:
|
|
284
|
+
gx, gy = pos[host[0]]
|
|
285
|
+
a = random.uniform(0, 2 * math.pi)
|
|
286
|
+
r = random.uniform(0.05, 0.16)
|
|
287
|
+
else:
|
|
288
|
+
gx, gy = 0.5, 0.5
|
|
289
|
+
a = random.uniform(0, 2 * math.pi)
|
|
290
|
+
r = random.uniform(0.14, 0.33)
|
|
291
|
+
pos[pid] = (min(0.97, max(0.03, gx + r * math.cos(a))),
|
|
292
|
+
min(0.97, max(0.03, gy + r * math.sin(a))))
|
|
293
|
+
return pos
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
HTML_TEMPLATE = r"""<!doctype html>
|
|
297
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
298
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
299
|
+
<title>关系网络时间重放</title>
|
|
300
|
+
<style>
|
|
301
|
+
:root { --bg:#0d1014; --card:#141a22; --fg:#e8ebf2; --dim:#8b93a3; --line:#232b38;
|
|
302
|
+
--new:#4ade80; --stable:#65708a; --fading:#f59e0b; --gone:#ef4444; --group:#7c6cff; }
|
|
303
|
+
*{box-sizing:border-box}
|
|
304
|
+
body{margin:0;background:var(--bg);color:var(--fg);
|
|
305
|
+
font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;overflow:hidden}
|
|
306
|
+
#top{height:52px;display:flex;align-items:center;gap:18px;padding:0 20px;
|
|
307
|
+
border-bottom:1px solid var(--line);background:var(--card)}
|
|
308
|
+
#day{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.4px}
|
|
309
|
+
#legend{display:flex;gap:14px;font-size:12px;color:var(--dim);margin-left:auto;flex-wrap:wrap}
|
|
310
|
+
#legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;
|
|
311
|
+
vertical-align:middle}
|
|
312
|
+
#wrap{display:flex;height:calc(100vh - 52px - 78px)}
|
|
313
|
+
#cv{flex:1;display:block;cursor:crosshair}
|
|
314
|
+
#side{width:300px;padding:16px 18px;border-left:1px solid var(--line);
|
|
315
|
+
background:var(--card);overflow:auto}
|
|
316
|
+
.k{display:flex;justify-content:space-between;padding:5px 0;
|
|
317
|
+
border-bottom:1px solid var(--line);font-size:13px}
|
|
318
|
+
.k span:last-child{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
319
|
+
.sec{margin-top:14px;font-size:11px;color:var(--dim);text-transform:uppercase;
|
|
320
|
+
letter-spacing:.08em}
|
|
321
|
+
.item{font-size:13px;padding:3px 0;display:flex;justify-content:space-between;gap:8px}
|
|
322
|
+
.item em{font-style:normal;color:var(--dim);font-variant-numeric:tabular-nums}
|
|
323
|
+
.gone{color:var(--gone)}
|
|
324
|
+
#warn{margin-top:10px;padding:8px 10px;border-radius:6px;background:#3a2a12;
|
|
325
|
+
color:#f5c26b;font-size:12px;display:none;line-height:1.5}
|
|
326
|
+
#ctrl{height:78px;display:flex;align-items:center;gap:16px;padding:0 20px;
|
|
327
|
+
border-top:1px solid var(--line);background:var(--card)}
|
|
328
|
+
button{background:#1d2532;color:var(--fg);border:1px solid var(--line);
|
|
329
|
+
border-radius:7px;padding:8px 16px;font-size:14px;cursor:pointer}
|
|
330
|
+
button:hover{background:#26303f}
|
|
331
|
+
#slider{flex:1;accent-color:#4f46e5}
|
|
332
|
+
select{background:#1d2532;color:var(--fg);border:1px solid var(--line);
|
|
333
|
+
border-radius:7px;padding:7px 10px;font-size:13px}
|
|
334
|
+
#tip{position:fixed;pointer-events:none;background:#000c;color:#fff;font-size:12px;
|
|
335
|
+
padding:3px 7px;border-radius:4px;display:none;white-space:nowrap}
|
|
336
|
+
</style></head>
|
|
337
|
+
<body>
|
|
338
|
+
<div id="top">
|
|
339
|
+
<div id="day"></div>
|
|
340
|
+
<div id="legend">
|
|
341
|
+
<span><i style="background:var(--new)"></i>新出现</span>
|
|
342
|
+
<span><i style="background:var(--stable)"></i>持续</span>
|
|
343
|
+
<span><i style="background:var(--fading)"></i>变淡</span>
|
|
344
|
+
<span><i style="background:var(--gone)"></i>断掉</span>
|
|
345
|
+
<span><i style="background:var(--group)"></i>群</span>
|
|
346
|
+
<span><i style="background:#d7dce6"></i>人(越大越活跃)</span>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
<div id="wrap">
|
|
350
|
+
<canvas id="cv"></canvas>
|
|
351
|
+
<aside id="side">
|
|
352
|
+
<div class="k"><b>当天消息</b><span id="m"></span></div>
|
|
353
|
+
<div class="k"><b>30 天窗口互动</b><span id="w"></span></div>
|
|
354
|
+
<div class="k"><b>当天会话 / 发言者</b><span id="tp"></span></div>
|
|
355
|
+
<div class="k"><b>图上节点 / 边</b><span id="ne"></span></div>
|
|
356
|
+
<div class="k"><b>图上门槛</b><span>≥ __MINW__ 条</span></div>
|
|
357
|
+
<div id="warn"></div>
|
|
358
|
+
<div class="sec">断掉的关系(窗口内曾 ≥ __GONEMIN__ 条)</div>
|
|
359
|
+
<div id="goneList"></div>
|
|
360
|
+
<div class="sec">窗口内最活跃的群</div>
|
|
361
|
+
<div id="topList"></div>
|
|
362
|
+
</aside>
|
|
363
|
+
</div>
|
|
364
|
+
<div id="ctrl">
|
|
365
|
+
<button id="play">▶ 播放</button>
|
|
366
|
+
<input type="range" id="slider" min="0" max="0" value="0">
|
|
367
|
+
<select id="speed">
|
|
368
|
+
<option value="240">慢</option>
|
|
369
|
+
<option value="90" selected>中</option>
|
|
370
|
+
<option value="35">快</option>
|
|
371
|
+
<option value="12">极快</option>
|
|
372
|
+
</select>
|
|
373
|
+
<span id="label" style="color:var(--dim);min-width:110px;text-align:right"></span>
|
|
374
|
+
</div>
|
|
375
|
+
<div id="tip"></div>
|
|
376
|
+
<script>
|
|
377
|
+
const DATA = __DATA__;
|
|
378
|
+
const POS = __POS__;
|
|
379
|
+
const F = DATA.frames;
|
|
380
|
+
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
|
|
381
|
+
let idx = F.length - 1, timer = null;
|
|
382
|
+
|
|
383
|
+
function resize(){
|
|
384
|
+
const r = cv.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
|
|
385
|
+
cv.width = r.width * dpr; cv.height = r.height * dpr;
|
|
386
|
+
ctx.setTransform(dpr,0,0,dpr,0,0);
|
|
387
|
+
draw();
|
|
388
|
+
}
|
|
389
|
+
addEventListener('resize', resize);
|
|
390
|
+
|
|
391
|
+
function project(){
|
|
392
|
+
const r = cv.getBoundingClientRect();
|
|
393
|
+
const W = r.width, H = r.height, R = Math.min(W, H) * 0.44;
|
|
394
|
+
return {W:W, H:H, cx:W/2, cy:H/2, R:R};
|
|
395
|
+
}
|
|
396
|
+
function at(id, P){
|
|
397
|
+
const p = POS[id]; if (!p) return null;
|
|
398
|
+
return [P.cx + (p[0]-0.5)*2*P.R, P.cy + (p[1]-0.5)*2*P.R];
|
|
399
|
+
}
|
|
400
|
+
const COL = {new:'74,222,128', stable:'101,112,138', fading:'245,158,11'};
|
|
401
|
+
|
|
402
|
+
function draw(){
|
|
403
|
+
const f = F[idx], P = project();
|
|
404
|
+
ctx.clearRect(0,0,P.W,P.H);
|
|
405
|
+
|
|
406
|
+
const maxW = Math.max.apply(null, f.edges.map(e=>e.weight).concat([1]));
|
|
407
|
+
for (let i=0;i<f.edges.length;i++){
|
|
408
|
+
const e = f.edges[i];
|
|
409
|
+
const a = at(e.src,P), b = at(e.dst,P);
|
|
410
|
+
if (!a||!b) continue;
|
|
411
|
+
const al = 0.14 + 0.55*(e.weight/maxW);
|
|
412
|
+
ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0],b[1]);
|
|
413
|
+
ctx.strokeStyle = 'rgba(' + (COL[e.state]||COL.stable) + ',' + al + ')';
|
|
414
|
+
ctx.lineWidth = 0.5 + 2.6*(e.weight/maxW);
|
|
415
|
+
ctx.stroke();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const maxN = Math.max.apply(null, f.nodes.map(n=>n.weight).concat([1]));
|
|
419
|
+
const ranked = f.nodes.slice().sort((a,b)=>b.weight-a.weight);
|
|
420
|
+
|
|
421
|
+
// 标签避让:按权重降序尝试放置,与已放标签的矩形重叠就跳过。
|
|
422
|
+
// 不这样做的话,图上 30 多个群名会互相盖住(2026-10-25 实测反馈:"太拥挤了")。
|
|
423
|
+
const boxes = [];
|
|
424
|
+
function place(label, x, y, color, font){
|
|
425
|
+
ctx.font = font;
|
|
426
|
+
const w = ctx.measureText(label).width, h = 15;
|
|
427
|
+
const cands = [[x, y - h/2], [x - w, y - h/2], [x - w/2, y - h - 4], [x - w/2, y + 4]];
|
|
428
|
+
for (const c of cands){
|
|
429
|
+
const box = {x:c[0], y:c[1], w:w, h:h};
|
|
430
|
+
let hit = false;
|
|
431
|
+
for (const b of boxes){
|
|
432
|
+
if (box.x < b.x + b.w && box.x + box.w > b.x &&
|
|
433
|
+
box.y < b.y + b.h && box.y + box.h > b.y){ hit = true; break; }
|
|
434
|
+
}
|
|
435
|
+
if (hit) continue;
|
|
436
|
+
boxes.push(box);
|
|
437
|
+
ctx.textAlign='left'; ctx.textBaseline='top';
|
|
438
|
+
ctx.lineWidth = 3; ctx.strokeStyle='rgba(13,16,20,.9)';
|
|
439
|
+
ctx.strokeText(label, box.x, box.y);
|
|
440
|
+
ctx.fillStyle = color; ctx.fillText(label, box.x, box.y);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 群名太长会把整张图盖住:超过 14 字截断;没有真名的群(`xxx@chatroom`)不标,靠悬停。
|
|
447
|
+
const shortName = s => {
|
|
448
|
+
if (/@chatroom$/i.test(s) || /^\d+$/.test(s)) return '';
|
|
449
|
+
return s.length > 14 ? s.slice(0, 14) + '…' : s;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const groupFont = '600 12px -apple-system,"Microsoft YaHei",sans-serif';
|
|
453
|
+
const personFont = '11px -apple-system,"Microsoft YaHei",sans-serif';
|
|
454
|
+
let personLabels = 0;
|
|
455
|
+
for (const n of ranked){
|
|
456
|
+
if (n.kind === 'group'){
|
|
457
|
+
const nm = shortName(n.label);
|
|
458
|
+
if (!nm) continue;
|
|
459
|
+
const a = at(n.id,P); if (!a) continue;
|
|
460
|
+
const rad = 6+11*(n.weight/maxN);
|
|
461
|
+
place(nm, a[0]+rad+4, a[1], '#c9c2ff', groupFont);
|
|
462
|
+
} else if (personLabels < 8){
|
|
463
|
+
const nm = shortName(n.label);
|
|
464
|
+
if (!nm) continue;
|
|
465
|
+
const a = at(n.id,P); if (!a) continue;
|
|
466
|
+
const rad = 2.5+6*(n.weight/maxN);
|
|
467
|
+
if (place(nm, a[0]+rad+4, a[1], '#9aa3b5', personFont)) personLabels++;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const n of f.nodes){
|
|
472
|
+
const a = at(n.id,P); if (!a) continue;
|
|
473
|
+
const rad = n.kind==='group' ? 6+11*(n.weight/maxN) : 2.5+6*(n.weight/maxN);
|
|
474
|
+
ctx.beginPath(); ctx.arc(a[0],a[1],rad,0,7);
|
|
475
|
+
ctx.fillStyle = n.kind==='group' ? '#7c6cff' : '#d7dce6';
|
|
476
|
+
ctx.globalAlpha = n.kind==='group' ? 0.95 : 0.7;
|
|
477
|
+
ctx.fill(); ctx.globalAlpha = 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const me = at('me',P);
|
|
481
|
+
if (me){
|
|
482
|
+
ctx.beginPath(); ctx.arc(me[0],me[1],7,0,7); ctx.fillStyle='#fff'; ctx.fill();
|
|
483
|
+
ctx.beginPath(); ctx.arc(me[0],me[1],14,0,7);
|
|
484
|
+
ctx.strokeStyle='rgba(255,255,255,.3)'; ctx.lineWidth=1.5; ctx.stroke();
|
|
485
|
+
ctx.font='600 12px sans-serif'; ctx.textAlign='left'; ctx.textBaseline='middle';
|
|
486
|
+
ctx.lineWidth=3; ctx.strokeStyle='rgba(13,16,20,.85)';
|
|
487
|
+
ctx.strokeText('我', me[0]+11, me[1]); ctx.fillStyle='#fff';
|
|
488
|
+
ctx.fillText('我', me[0]+11, me[1]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
document.getElementById('day').textContent = f.day;
|
|
492
|
+
document.getElementById('m').textContent = f.messages.toLocaleString();
|
|
493
|
+
document.getElementById('w').textContent = f.window.toLocaleString();
|
|
494
|
+
document.getElementById('tp').textContent = f.talkers + ' / ' + f.people;
|
|
495
|
+
document.getElementById('ne').textContent = f.nodes.length + ' / ' + f.edges.length;
|
|
496
|
+
document.getElementById('label').textContent = (idx+1) + ' / ' + F.length;
|
|
497
|
+
document.getElementById('slider').value = idx;
|
|
498
|
+
|
|
499
|
+
const warn = document.getElementById('warn');
|
|
500
|
+
if (f.completeness === 'sparse'){
|
|
501
|
+
warn.style.display = 'block';
|
|
502
|
+
warn.textContent = '⚠ 这天只有 ' + f.talkers + ' 个会话被记录。图上稀疏是数据缺失,不是那天没聊天。';
|
|
503
|
+
} else { warn.style.display = 'none'; }
|
|
504
|
+
|
|
505
|
+
const gl = document.getElementById('goneList');
|
|
506
|
+
if (f.gone.length){
|
|
507
|
+
gl.innerHTML = f.gone.slice(0,10).map(g=>'<div class="item gone"><span>'+g.label+'</span><em>'+g.weight+'</em></div>').join('');
|
|
508
|
+
} else {
|
|
509
|
+
gl.innerHTML = '<div class="item"><span style="color:var(--dim)">无</span></div>';
|
|
510
|
+
}
|
|
511
|
+
document.getElementById('topList').innerHTML =
|
|
512
|
+
f.top.map(t=>'<div class="item"><span>'+t.label+'</span><em>'+t.weight+'</em></div>').join('');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
addEventListener('mousemove', function(ev){
|
|
516
|
+
if (!F.length) return;
|
|
517
|
+
const r = cv.getBoundingClientRect();
|
|
518
|
+
const x = ev.clientX - r.left, y = ev.clientY - r.top, P = project();
|
|
519
|
+
let best = null, bd = 196;
|
|
520
|
+
for (const n of F[idx].nodes){
|
|
521
|
+
const a = at(n.id,P); if (!a) continue;
|
|
522
|
+
const d = (a[0]-x)*(a[0]-x) + (a[1]-y)*(a[1]-y);
|
|
523
|
+
if (d < bd){ bd = d; best = n; }
|
|
524
|
+
}
|
|
525
|
+
const tip = document.getElementById('tip');
|
|
526
|
+
if (best){
|
|
527
|
+
tip.style.display = 'block';
|
|
528
|
+
tip.style.left = (ev.clientX + 12) + 'px';
|
|
529
|
+
tip.style.top = (ev.clientY + 12) + 'px';
|
|
530
|
+
tip.textContent = best.label + ' · ' + best.weight + ' 条';
|
|
531
|
+
} else { tip.style.display = 'none'; }
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
document.getElementById('slider').max = F.length - 1;
|
|
535
|
+
document.getElementById('slider').addEventListener('input', function(e){
|
|
536
|
+
stop(); idx = +e.target.value; draw();
|
|
537
|
+
});
|
|
538
|
+
function speed(){ return +document.getElementById('speed').value; }
|
|
539
|
+
function play(){
|
|
540
|
+
if (timer){ stop(); return; }
|
|
541
|
+
if (idx >= F.length - 1) idx = 0;
|
|
542
|
+
document.getElementById('play').textContent = '⏸ 暂停';
|
|
543
|
+
timer = setInterval(function(){
|
|
544
|
+
if (idx >= F.length - 1){ stop(); return; }
|
|
545
|
+
idx++; draw();
|
|
546
|
+
}, speed());
|
|
547
|
+
}
|
|
548
|
+
function stop(){
|
|
549
|
+
if (timer){ clearInterval(timer); timer = null; }
|
|
550
|
+
document.getElementById('play').textContent = '▶ 播放';
|
|
551
|
+
}
|
|
552
|
+
document.getElementById('play').addEventListener('click', play);
|
|
553
|
+
document.getElementById('speed').addEventListener('change', function(){
|
|
554
|
+
if (timer){ stop(); play(); }
|
|
555
|
+
});
|
|
556
|
+
addEventListener('keydown', function(e){
|
|
557
|
+
if (e.code === 'Space'){ e.preventDefault(); play(); }
|
|
558
|
+
if (e.code === 'ArrowRight'){ stop(); idx = Math.min(F.length-1, idx+1); draw(); }
|
|
559
|
+
if (e.code === 'ArrowLeft'){ stop(); idx = Math.max(0, idx-1); draw(); }
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
resize();
|
|
563
|
+
</script></body></html>
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
SUMMARY_TEMPLATE = r"""<!doctype html>
|
|
568
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
569
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
570
|
+
<title>关系网络:这些年到底变了什么</title>
|
|
571
|
+
<style>
|
|
572
|
+
:root{--bg:#0d1014;--card:#141a22;--fg:#e8ebf2;--dim:#8b93a3;--line:#232b38;
|
|
573
|
+
--gone:#ef4444;--new:#4ade80;--group:#a99bff;--accent:#4f46e5}
|
|
574
|
+
*{box-sizing:border-box}
|
|
575
|
+
body{margin:0;background:var(--bg);color:var(--fg);
|
|
576
|
+
font:15px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
|
577
|
+
.wrap{max-width:1000px;margin:0 auto;padding:40px 24px 80px}
|
|
578
|
+
h1{font-size:28px;margin:0 0 6px;letter-spacing:.3px}
|
|
579
|
+
.sub{color:var(--dim);font-size:14px;margin-bottom:28px}
|
|
580
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:32px}
|
|
581
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
|
582
|
+
.card b{display:block;font-size:24px;font-variant-numeric:tabular-nums}
|
|
583
|
+
.card span{color:var(--dim);font-size:12px}
|
|
584
|
+
h2{font-size:17px;margin:34px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
|
|
585
|
+
.note{background:#3a2a12;color:#f5c26b;padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:20px}
|
|
586
|
+
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
587
|
+
th{text-align:left;color:var(--dim);font-weight:500;font-size:12px;padding:6px 8px;
|
|
588
|
+
border-bottom:1px solid var(--line)}
|
|
589
|
+
td{padding:6px 8px;border-bottom:1px solid #1b2230;vertical-align:top}
|
|
590
|
+
td.num{text-align:right;font-variant-numeric:tabular-nums;color:var(--dim);white-space:nowrap}
|
|
591
|
+
.gone{color:var(--gone)}
|
|
592
|
+
.tag{display:inline-block;font-size:11px;padding:1px 7px;border-radius:4px;
|
|
593
|
+
background:#1e2635;color:var(--dim);margin-left:6px}
|
|
594
|
+
.bar{display:flex;align-items:flex-end;gap:2px;height:120px;margin:10px 0 4px;
|
|
595
|
+
background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px}
|
|
596
|
+
.bar div{flex:1;background:var(--accent);border-radius:2px 2px 0 0;min-height:2px;position:relative}
|
|
597
|
+
.bar div:hover{background:#7c6cff}
|
|
598
|
+
.axis{display:flex;justify-content:space-between;color:var(--dim);font-size:11px}
|
|
599
|
+
.empty{color:var(--dim);font-size:14px;padding:8px 0}
|
|
600
|
+
code{background:#1e2635;padding:1px 6px;border-radius:4px;font-size:13px}
|
|
601
|
+
</style></head><body><div class="wrap">
|
|
602
|
+
|
|
603
|
+
<h1>这些年,关系到底变了什么</h1>
|
|
604
|
+
<div class="sub">__SPAN__ · 全部数字来自本地聊天记录,不联网、不调模型</div>
|
|
605
|
+
|
|
606
|
+
<div class="cards">
|
|
607
|
+
<div class="card"><b>__TOTAL__</b><span>条消息</span></div>
|
|
608
|
+
<div class="card"><b>__DAYS__</b><span>天有对话</span></div>
|
|
609
|
+
<div class="card"><b>__PEOPLE__</b><span>个发言者</span></div>
|
|
610
|
+
<div class="card"><b>__RELATIONS__</b><span>段关系(单聊 + 群)</span></div>
|
|
611
|
+
<div class="card"><b>__GONE_N__</b><span>段已经断了</span></div>
|
|
612
|
+
</div>
|
|
613
|
+
|
|
614
|
+
__SPARSE_NOTE__
|
|
615
|
+
|
|
616
|
+
<h2>消息量:每月的对话密度</h2>
|
|
617
|
+
<div class="bar">__BARS__</div>
|
|
618
|
+
<div class="axis"><span>__FIRST_MONTH__</span><span>__LAST_MONTH__</span></div>
|
|
619
|
+
|
|
620
|
+
<h2>已经断掉的关系 <span class="tag">最后出现距今超过 30 天,且曾经很活跃</span></h2>
|
|
621
|
+
__GONE_TABLE__
|
|
622
|
+
|
|
623
|
+
<h2>最持久的关系 <span class="tag">按"第一次到最后一次"的跨度排</span></h2>
|
|
624
|
+
__LONG_TABLE__
|
|
625
|
+
|
|
626
|
+
<h2>最近三个月才出现的关系 <span class="tag">新的连接</span></h2>
|
|
627
|
+
__NEW_TABLE__
|
|
628
|
+
|
|
629
|
+
<h2>怎么读这份总结</h2>
|
|
630
|
+
<p style="color:var(--dim);font-size:14px">
|
|
631
|
+
· <b>断掉</b>的意思是"过去 30 天窗口里,这条关系的互动量掉到门槛以下",不等于绝交 ——
|
|
632
|
+
可能是事情做完了、可能转到了线下、也可能只是这个月没说话。<br>
|
|
633
|
+
· 权重是<b>互动量</b>(消息条数),不是亲密度:和同事一天 50 条是工作,和家人一周 3 条是亲近。<br>
|
|
634
|
+
· 早期(2024-03 ~ 2025-09)只有一两个会话被记录,那段时间的"关系少"是<b>数据缺失</b>,不是没聊。<br>
|
|
635
|
+
· 想看它是怎么一天天变成这样的,打开 <code>replay.html</code>,按播放键。
|
|
636
|
+
</p>
|
|
637
|
+
|
|
638
|
+
</div></body></html>
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def build_summary(frames, total):
|
|
643
|
+
"""把帧序列压成"人读的结论":断了哪些、哪段最久、哪些是新的。"""
|
|
644
|
+
life = {}
|
|
645
|
+
import re as _re
|
|
646
|
+
for f in frames:
|
|
647
|
+
day = f['day']
|
|
648
|
+
for e in f['edges']:
|
|
649
|
+
# **只看"我和谁/我和哪个群"**。`member`(某人在某群发言)是第三层关系,
|
|
650
|
+
# 放进总结会把"断掉的关系"淹成几百行 id(2026-10-25 实测)。
|
|
651
|
+
if e['kind'] not in ('dm', 'group'):
|
|
652
|
+
continue
|
|
653
|
+
label = _re.sub(r'[\u3000\s]+', ' ', e['label'] or '').strip()
|
|
654
|
+
# 没解析到名字的(不在 contact.db 的已删好友/陌生群)不占位置:
|
|
655
|
+
# `p:wxid_x` 和 `12345@chatroom` 对读者没有意义。
|
|
656
|
+
if not label or label.startswith('p:') or label.endswith('@chatroom'):
|
|
657
|
+
continue
|
|
658
|
+
key = e['kind'] + '|' + label
|
|
659
|
+
cur = life.get(key)
|
|
660
|
+
if cur is None:
|
|
661
|
+
life[key] = {'label': label, 'kind': e['kind'],
|
|
662
|
+
'first': day, 'last': day, 'peak': e['weight'],
|
|
663
|
+
'samples': 1}
|
|
664
|
+
else:
|
|
665
|
+
cur['last'] = day
|
|
666
|
+
cur['peak'] = max(cur['peak'], e['weight'])
|
|
667
|
+
cur['samples'] += 1
|
|
668
|
+
|
|
669
|
+
last_day = frames[-1]['day']
|
|
670
|
+
from datetime import date as _date
|
|
671
|
+
end = _date.fromisoformat(last_day)
|
|
672
|
+
cut30 = (end - timedelta(days=30)).isoformat()
|
|
673
|
+
cut90 = (end - timedelta(days=90)).isoformat()
|
|
674
|
+
|
|
675
|
+
gone = [v for v in life.values() if v['last'] < cut30 and v['peak'] >= GONE_MIN_WEIGHT]
|
|
676
|
+
gone.sort(key=lambda v: v['last'], reverse=True)
|
|
677
|
+
|
|
678
|
+
living = [v for v in life.values() if v['last'] >= cut30]
|
|
679
|
+
long_lived = sorted(living, key=lambda v: (v['last'], v['first']), reverse=True)
|
|
680
|
+
long_lived = sorted(living, key=lambda v: _date.fromisoformat(v['last']).toordinal()
|
|
681
|
+
- _date.fromisoformat(v['first']).toordinal(), reverse=True)
|
|
682
|
+
|
|
683
|
+
fresh = [v for v in life.values() if v['first'] >= cut90]
|
|
684
|
+
fresh.sort(key=lambda v: v['first'], reverse=True)
|
|
685
|
+
|
|
686
|
+
months = {}
|
|
687
|
+
for f in frames:
|
|
688
|
+
m = f['day'][:7]
|
|
689
|
+
months[m] = months.get(m, 0) + f['messages']
|
|
690
|
+
peak = max(months.values()) if months else 1
|
|
691
|
+
bars = ''.join(
|
|
692
|
+
f'<div style="height:{max(2, round(v * 100 / peak))}%" title="{m}:{v} 条"></div>'
|
|
693
|
+
for m, v in sorted(months.items()))
|
|
694
|
+
|
|
695
|
+
sparse_days = sum(1 for f in frames if f['completeness'] == 'sparse')
|
|
696
|
+
sparse_note = ('<div class="note">⚠ 有 %d 天(约占 %.0f%%)处于<b>数据稀薄</b>状态,'
|
|
697
|
+
'主要落在 2024-03 到 2025-09 之间。那段时间只有一两个会话被记录,'
|
|
698
|
+
'所以下面的"关系少"是数据缺失,不是你那时没聊天。</div>'
|
|
699
|
+
% (sparse_days, 100.0 * sparse_days / max(1, len(frames)))
|
|
700
|
+
) if sparse_days else ''
|
|
701
|
+
|
|
702
|
+
def table(rows, cols, empty):
|
|
703
|
+
if not rows:
|
|
704
|
+
return f'<div class="empty">{empty}</div>'
|
|
705
|
+
head = ''.join(f'<th>{c[0]}</th>' for c in cols)
|
|
706
|
+
body = ''
|
|
707
|
+
for r in rows:
|
|
708
|
+
tds = ''.join(f'<td class="{c[2]}">{r[c[1]]}</td>' for c in cols)
|
|
709
|
+
body += f'<tr>{tds}</tr>'
|
|
710
|
+
return f'<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>'
|
|
711
|
+
|
|
712
|
+
kind_cn = {'dm': '单聊', 'group': '群', 'member': '群成员'}
|
|
713
|
+
gone_rows = [{'name': v['label'], 'kind': kind_cn.get(v['kind'], v['kind']),
|
|
714
|
+
'last': v['last'], 'peak': v['peak']} for v in gone[:25]]
|
|
715
|
+
long_rows = [{'name': v['label'], 'kind': kind_cn.get(v['kind'], v['kind']),
|
|
716
|
+
'first': v['first'], 'last': v['last'], 'peak': v['peak']}
|
|
717
|
+
for v in long_lived[:20] if v['first'] != v['last']]
|
|
718
|
+
new_rows = [{'name': v['label'], 'kind': kind_cn.get(v['kind'], v['kind']),
|
|
719
|
+
'first': v['first'], 'peak': v['peak']} for v in fresh[:20]]
|
|
720
|
+
|
|
721
|
+
html = SUMMARY_TEMPLATE
|
|
722
|
+
repl = {
|
|
723
|
+
'__SPAN__': f'{frames[0]["day"]} ~ {last_day}',
|
|
724
|
+
'__TOTAL__': f'{total:,}',
|
|
725
|
+
'__DAYS__': str(len(frames)),
|
|
726
|
+
'__PEOPLE__': str(len({n['label'] for f in frames for n in f['nodes']
|
|
727
|
+
if n['kind'] == 'person'})),
|
|
728
|
+
'__RELATIONS__': str(len(life)),
|
|
729
|
+
'__GONE_N__': str(len(gone)),
|
|
730
|
+
'__SPARSE_NOTE__': sparse_note,
|
|
731
|
+
'__BARS__': bars,
|
|
732
|
+
'__FIRST_MONTH__': frames[0]['day'][:7],
|
|
733
|
+
'__LAST_MONTH__': last_day[:7],
|
|
734
|
+
'__GONE_TABLE__': table(
|
|
735
|
+
gone_rows,
|
|
736
|
+
[('对象', 'name', ''), ('类型', 'kind', 'num'),
|
|
737
|
+
('最后出现', 'last', 'num'), ('窗口峰值', 'peak', 'num')],
|
|
738
|
+
'没有断掉的关系 —— 过去 30 天里所有活跃关系都还在。'),
|
|
739
|
+
'__LONG_TABLE__': table(
|
|
740
|
+
long_rows,
|
|
741
|
+
[('对象', 'name', ''), ('类型', 'kind', 'num'),
|
|
742
|
+
('第一次', 'first', 'num'), ('最近', 'last', 'num'), ('峰值', 'peak', 'num')],
|
|
743
|
+
'数据太少,还算不出"最持久"。'),
|
|
744
|
+
'__NEW_TABLE__': table(
|
|
745
|
+
new_rows,
|
|
746
|
+
[('对象', 'name', ''), ('类型', 'kind', 'num'),
|
|
747
|
+
('第一次出现', 'first', 'num'), ('峰值', 'peak', 'num')],
|
|
748
|
+
'最近三个月没有新出现的关系。'),
|
|
749
|
+
}
|
|
750
|
+
for k, v in repl.items():
|
|
751
|
+
html = html.replace(k, v)
|
|
752
|
+
return html
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def render_html(frames, pos, conservation, filtered, min_weight):
|
|
756
|
+
data = {'frames': frames, 'conservation': conservation, 'filtered': filtered}
|
|
757
|
+
return (HTML_TEMPLATE
|
|
758
|
+
.replace('__DATA__', json.dumps(data, ensure_ascii=False))
|
|
759
|
+
.replace('__POS__', json.dumps(pos, ensure_ascii=False))
|
|
760
|
+
.replace('__MINW__', str(min_weight))
|
|
761
|
+
.replace('__GONEMIN__', str(GONE_MIN_WEIGHT)))
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def main():
|
|
765
|
+
ap = argparse.ArgumentParser(description='关系网络时间重放(按天,单文件 HTML)')
|
|
766
|
+
ap.add_argument('--json', action='store_true', help='只输出聚合结果')
|
|
767
|
+
ap.add_argument('--min-weight', type=int, default=DEFAULT_MIN_WEIGHT)
|
|
768
|
+
ap.add_argument('--window', type=int, default=DEFAULT_WINDOW, help='边的滚动窗口天数')
|
|
769
|
+
ap.add_argument('--out', default=str(OUT_HTML))
|
|
770
|
+
args = ap.parse_args()
|
|
771
|
+
|
|
772
|
+
cfg = load_config()
|
|
773
|
+
names = _names(cfg)
|
|
774
|
+
per_day, total, failed = scan_days(cfg)
|
|
775
|
+
frames, filtered = build_frames(per_day, names, args.min_weight, args.window)
|
|
776
|
+
|
|
777
|
+
summed = sum(f['messages'] for f in frames)
|
|
778
|
+
conservation = {'total': total, 'summed': summed, 'equal': total == summed,
|
|
779
|
+
'failedShards': len(failed), 'days': len(frames)}
|
|
780
|
+
|
|
781
|
+
if args.json:
|
|
782
|
+
print(json.dumps({'days': len(frames), 'frames': frames,
|
|
783
|
+
'conservation': conservation, 'filteredEdges': filtered},
|
|
784
|
+
ensure_ascii=False))
|
|
785
|
+
return
|
|
786
|
+
|
|
787
|
+
out = pathlib.Path(args.out)
|
|
788
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
789
|
+
out.write_text(render_html(frames, layout(frames), conservation, filtered, args.min_weight),
|
|
790
|
+
encoding='utf-8')
|
|
791
|
+
summary = out.with_name(out.stem + '-summary.html')
|
|
792
|
+
summary.write_text(build_summary(frames, total), encoding='utf-8')
|
|
793
|
+
print(f'天数 {len(frames)},消息 {total},守恒 {conservation["equal"]},'
|
|
794
|
+
f'过滤边 {filtered},分片失败 {len(failed)}')
|
|
795
|
+
print(f'已生成 {out}({out.stat().st_size/1024/1024:.1f} MB)')
|
|
796
|
+
print(f'已生成 {summary}({summary.stat().st_size/1024:.0f} KB,静态总结)')
|
|
797
|
+
if not conservation['equal']:
|
|
798
|
+
print(f'[WARN] 守恒不成立:各帧之和 {summed} ≠ 全库 {total},'
|
|
799
|
+
f'画面里"断掉的边"可能是假象', file=sys.stderr)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
if __name__ == '__main__':
|
|
803
|
+
main()
|