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
package/scripts/on_this_day.py
CHANGED
|
@@ -1,178 +1,178 @@
|
|
|
1
|
-
"""「那年今日」:把几年前的今天你在聊什么捞出来。
|
|
2
|
-
|
|
3
|
-
为什么只有本地全量数据能做到:微信的搜索只覆盖当前会话、而且历史一长就翻不动;
|
|
4
|
-
这里直接按 `create_time` 扫所有分片里的每一张会话表,所以"五年前的今天"是能查的。
|
|
5
|
-
|
|
6
|
-
用法:
|
|
7
|
-
python scripts/on_this_day.py # 1 / 3 / 5 年前的今天
|
|
8
|
-
python scripts/on_this_day.py --years 2,4,6
|
|
9
|
-
python scripts/on_this_day.py --talker 老表亲戚群 # 只看某个会话
|
|
10
|
-
python scripts/on_this_day.py --json
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import argparse
|
|
15
|
-
import re
|
|
16
|
-
import json
|
|
17
|
-
import pathlib
|
|
18
|
-
import sys
|
|
19
|
-
import time
|
|
20
|
-
from collections import Counter, defaultdict
|
|
21
|
-
from datetime import datetime, timezone, timedelta
|
|
22
|
-
|
|
23
|
-
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
24
|
-
sys.path.insert(0, str(SCRIPT_DIR))
|
|
25
|
-
|
|
26
|
-
from _utils import load_config, decrypt_lock # noqa: E402
|
|
27
|
-
import nt_decrypt as nd # noqa: E402
|
|
28
|
-
|
|
29
|
-
TZ = timezone(timedelta(hours=8))
|
|
30
|
-
DEFAULT_YEARS = (1, 3, 5)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _keys(cfg):
|
|
34
|
-
key = decrypt_lock(cfg.get('ntKey', '') or '')
|
|
35
|
-
salt = decrypt_lock(cfg.get('ntSalt', '') or '')
|
|
36
|
-
pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
|
|
37
|
-
return key, salt, pw
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _text_of(content, local_type=0):
|
|
41
|
-
"""消息正文 → 一行字。
|
|
42
|
-
|
|
43
|
-
**不能只看 `message_content` 是不是以 `<` 开头**:appmsg 那一族(链接/文件/小程序)
|
|
44
|
-
的内容是 zstd 压缩的二进制,原样打出来就是 `(/`$` 这种碎片(第一版就是这么翻车的)。
|
|
45
|
-
这里直接复用 `nt_decrypt` 里那套已经处理过压缩、类型标签与引用消息的显示层。
|
|
46
|
-
"""
|
|
47
|
-
lt = int(local_type or 0)
|
|
48
|
-
base = lt & 0xFFFFFFFF
|
|
49
|
-
if base == 1: # 纯文本
|
|
50
|
-
text = nd._decode_content(content)
|
|
51
|
-
text = ' '.join(str(text).split())[:120]
|
|
52
|
-
# 群消息的 `message_content` 自带 `wxid_xxx: ` 前缀,而发言人已经在左边显示了一次
|
|
53
|
-
return re.sub(r'^[A-Za-z0-9_\-]+@?(chatroom|openim)?\s*:\s*', '', text)[:120]
|
|
54
|
-
try:
|
|
55
|
-
out = nd.non_text_display(lt, content)[:120]
|
|
56
|
-
return re.sub(r'^[A-Za-z0-9_\-]+:\s*', '', out)[:120]
|
|
57
|
-
except Exception:
|
|
58
|
-
return ''
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def day_bounds(dt):
|
|
62
|
-
start = datetime(dt.year, dt.month, dt.day, tzinfo=TZ)
|
|
63
|
-
return int(start.timestamp()), int((start + timedelta(days=1)).timestamp())
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def collect_for_day(conns, name_map, target_day, talker_filter=''):
|
|
67
|
-
"""{talker: [(ts, sender, text), ...]} for one calendar day."""
|
|
68
|
-
start, end = day_bounds(target_day)
|
|
69
|
-
out = defaultdict(list)
|
|
70
|
-
for conn in conns:
|
|
71
|
-
c = conn.cursor()
|
|
72
|
-
try:
|
|
73
|
-
c.execute('SELECT user_name FROM Name2Id WHERE is_session = 1')
|
|
74
|
-
sessions = [r[0] for r in c.fetchall() if r[0]]
|
|
75
|
-
except Exception:
|
|
76
|
-
continue
|
|
77
|
-
# `real_sender_id` 是 **Name2Id 的 rowid**,不是 wxid —— 少这一步映射,
|
|
78
|
-
# 输出里发言人就全是 `65`、`4`、`8` 这样的数字(第一版就是这样)。
|
|
79
|
-
try:
|
|
80
|
-
c.execute('SELECT rowid, user_name FROM Name2Id')
|
|
81
|
-
sender_map = {r[0]: r[1] for r in c.fetchall()}
|
|
82
|
-
except Exception:
|
|
83
|
-
sender_map = {}
|
|
84
|
-
for talker in sessions:
|
|
85
|
-
if talker_filter and talker_filter not in (talker, name_map.get(talker, talker)):
|
|
86
|
-
continue
|
|
87
|
-
tbl = f'Msg_{__import__("hashlib").md5(talker.encode()).hexdigest()}'
|
|
88
|
-
try:
|
|
89
|
-
c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
|
|
90
|
-
if c.fetchone()[0] == 0:
|
|
91
|
-
continue
|
|
92
|
-
c.execute(f'SELECT create_time, real_sender_id, local_type, message_content '
|
|
93
|
-
'FROM "{0}" WHERE create_time >= ? AND create_time < ? ORDER BY create_time'.format(tbl),
|
|
94
|
-
(start, end))
|
|
95
|
-
for ts, sender, ltype, content in c.fetchall():
|
|
96
|
-
text = _text_of(content, ltype)
|
|
97
|
-
if text:
|
|
98
|
-
who = sender_map.get(sender, '')
|
|
99
|
-
out[name_map.get(talker, talker)].append(
|
|
100
|
-
(int(ts), name_map.get(who, who), text))
|
|
101
|
-
except Exception:
|
|
102
|
-
continue
|
|
103
|
-
return out
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def main():
|
|
107
|
-
ap = argparse.ArgumentParser(description='那几年的今天你在聊什么')
|
|
108
|
-
ap.add_argument('--years', default=','.join(str(y) for y in DEFAULT_YEARS),
|
|
109
|
-
help='往前看几年,逗号分隔(默认 1,3,5)')
|
|
110
|
-
ap.add_argument('--talker', default='', help='只看某个会话(wxid 或昵称)')
|
|
111
|
-
ap.add_argument('--limit', type=int, default=6, help='每天最多列几条(默认 6)')
|
|
112
|
-
ap.add_argument('--json', action='store_true')
|
|
113
|
-
args = ap.parse_args()
|
|
114
|
-
|
|
115
|
-
cfg = load_config()
|
|
116
|
-
db = cfg.get('ntDbPath') or ''
|
|
117
|
-
if not db:
|
|
118
|
-
raise SystemExit('配置里没有 ntDbPath,先跑 weflow-cli init')
|
|
119
|
-
key, salt, pw = _keys(cfg)
|
|
120
|
-
conns = nd.connect_message_shards(db, key, salt, pw)
|
|
121
|
-
if not conns:
|
|
122
|
-
raise SystemExit('打不开任何消息分片')
|
|
123
|
-
|
|
124
|
-
try:
|
|
125
|
-
# 联系人在 **contact.db**,不在消息库里 —— 从消息库找 `contact` 表一定落空,
|
|
126
|
-
# 输出里就只剩 `47966561144@chatroom` 和 `wxid_xxx`(第一版就是这样)。
|
|
127
|
-
name_map = {}
|
|
128
|
-
try:
|
|
129
|
-
from network import load_graph
|
|
130
|
-
people, _rooms, _members = load_graph(cfg)
|
|
131
|
-
name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
|
|
132
|
-
for p in people.values()}
|
|
133
|
-
except Exception as exc:
|
|
134
|
-
print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),下面显示 id',
|
|
135
|
-
file=sys.stderr)
|
|
136
|
-
|
|
137
|
-
today = datetime.now(TZ)
|
|
138
|
-
years = [int(y) for y in args.years.split(',') if y.strip().isdigit()]
|
|
139
|
-
result = []
|
|
140
|
-
for y in years:
|
|
141
|
-
try:
|
|
142
|
-
day = today.replace(year=today.year - y)
|
|
143
|
-
except ValueError: # 2 月 29 日
|
|
144
|
-
day = today.replace(year=today.year - y, day=28)
|
|
145
|
-
by_talker = collect_for_day(conns, name_map, day, args.talker)
|
|
146
|
-
total = sum(len(v) for v in by_talker.values())
|
|
147
|
-
top = Counter({k: len(v) for k, v in by_talker.items()}).most_common(3)
|
|
148
|
-
picks = []
|
|
149
|
-
for talker, msgs in sorted(by_talker.items(), key=lambda kv: -len(kv[1]))[:3]:
|
|
150
|
-
for ts, sender, text in msgs[:max(1, args.limit // 3)]:
|
|
151
|
-
picks.append({'talker': talker, 'time': datetime.fromtimestamp(ts, tz=TZ).strftime('%H:%M'),
|
|
152
|
-
'sender': sender, 'text': text})
|
|
153
|
-
result.append({'date': day.strftime('%Y-%m-%d'), 'yearsAgo': y,
|
|
154
|
-
'total': total, 'threads': top, 'samples': picks[:args.limit]})
|
|
155
|
-
finally:
|
|
156
|
-
for conn in conns:
|
|
157
|
-
try:
|
|
158
|
-
conn.close()
|
|
159
|
-
except Exception:
|
|
160
|
-
pass
|
|
161
|
-
|
|
162
|
-
if args.json:
|
|
163
|
-
print(json.dumps({'success': True, 'days': result}, ensure_ascii=False, indent=2))
|
|
164
|
-
return
|
|
165
|
-
|
|
166
|
-
for item in result:
|
|
167
|
-
head = f"{item['date']}({item['yearsAgo']} 年前的今天)"
|
|
168
|
-
if not item['total']:
|
|
169
|
-
print(f'\n{head}\n 那天没有聊天记录')
|
|
170
|
-
continue
|
|
171
|
-
threads = '、'.join(f'{n}({c})' for n, c in item['threads'])
|
|
172
|
-
print(f"\n{head} {item['total']} 条 {threads}")
|
|
173
|
-
for s in item['samples']:
|
|
174
|
-
print(f" {s['time']} {s['talker']} · {s['sender'] or '?'}:{s['text']}")
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if __name__ == '__main__':
|
|
178
|
-
main()
|
|
1
|
+
"""「那年今日」:把几年前的今天你在聊什么捞出来。
|
|
2
|
+
|
|
3
|
+
为什么只有本地全量数据能做到:微信的搜索只覆盖当前会话、而且历史一长就翻不动;
|
|
4
|
+
这里直接按 `create_time` 扫所有分片里的每一张会话表,所以"五年前的今天"是能查的。
|
|
5
|
+
|
|
6
|
+
用法:
|
|
7
|
+
python scripts/on_this_day.py # 1 / 3 / 5 年前的今天
|
|
8
|
+
python scripts/on_this_day.py --years 2,4,6
|
|
9
|
+
python scripts/on_this_day.py --talker 老表亲戚群 # 只看某个会话
|
|
10
|
+
python scripts/on_this_day.py --json
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import json
|
|
17
|
+
import pathlib
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from collections import Counter, defaultdict
|
|
21
|
+
from datetime import datetime, timezone, timedelta
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
24
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
25
|
+
|
|
26
|
+
from _utils import load_config, decrypt_lock # noqa: E402
|
|
27
|
+
import nt_decrypt as nd # noqa: E402
|
|
28
|
+
|
|
29
|
+
TZ = timezone(timedelta(hours=8))
|
|
30
|
+
DEFAULT_YEARS = (1, 3, 5)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _keys(cfg):
|
|
34
|
+
key = decrypt_lock(cfg.get('ntKey', '') or '')
|
|
35
|
+
salt = decrypt_lock(cfg.get('ntSalt', '') or '')
|
|
36
|
+
pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
|
|
37
|
+
return key, salt, pw
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _text_of(content, local_type=0):
|
|
41
|
+
"""消息正文 → 一行字。
|
|
42
|
+
|
|
43
|
+
**不能只看 `message_content` 是不是以 `<` 开头**:appmsg 那一族(链接/文件/小程序)
|
|
44
|
+
的内容是 zstd 压缩的二进制,原样打出来就是 `(/`$` 这种碎片(第一版就是这么翻车的)。
|
|
45
|
+
这里直接复用 `nt_decrypt` 里那套已经处理过压缩、类型标签与引用消息的显示层。
|
|
46
|
+
"""
|
|
47
|
+
lt = int(local_type or 0)
|
|
48
|
+
base = lt & 0xFFFFFFFF
|
|
49
|
+
if base == 1: # 纯文本
|
|
50
|
+
text = nd._decode_content(content)
|
|
51
|
+
text = ' '.join(str(text).split())[:120]
|
|
52
|
+
# 群消息的 `message_content` 自带 `wxid_xxx: ` 前缀,而发言人已经在左边显示了一次
|
|
53
|
+
return re.sub(r'^[A-Za-z0-9_\-]+@?(chatroom|openim)?\s*:\s*', '', text)[:120]
|
|
54
|
+
try:
|
|
55
|
+
out = nd.non_text_display(lt, content)[:120]
|
|
56
|
+
return re.sub(r'^[A-Za-z0-9_\-]+:\s*', '', out)[:120]
|
|
57
|
+
except Exception:
|
|
58
|
+
return ''
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def day_bounds(dt):
|
|
62
|
+
start = datetime(dt.year, dt.month, dt.day, tzinfo=TZ)
|
|
63
|
+
return int(start.timestamp()), int((start + timedelta(days=1)).timestamp())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def collect_for_day(conns, name_map, target_day, talker_filter=''):
|
|
67
|
+
"""{talker: [(ts, sender, text), ...]} for one calendar day."""
|
|
68
|
+
start, end = day_bounds(target_day)
|
|
69
|
+
out = defaultdict(list)
|
|
70
|
+
for conn in conns:
|
|
71
|
+
c = conn.cursor()
|
|
72
|
+
try:
|
|
73
|
+
c.execute('SELECT user_name FROM Name2Id WHERE is_session = 1')
|
|
74
|
+
sessions = [r[0] for r in c.fetchall() if r[0]]
|
|
75
|
+
except Exception:
|
|
76
|
+
continue
|
|
77
|
+
# `real_sender_id` 是 **Name2Id 的 rowid**,不是 wxid —— 少这一步映射,
|
|
78
|
+
# 输出里发言人就全是 `65`、`4`、`8` 这样的数字(第一版就是这样)。
|
|
79
|
+
try:
|
|
80
|
+
c.execute('SELECT rowid, user_name FROM Name2Id')
|
|
81
|
+
sender_map = {r[0]: r[1] for r in c.fetchall()}
|
|
82
|
+
except Exception:
|
|
83
|
+
sender_map = {}
|
|
84
|
+
for talker in sessions:
|
|
85
|
+
if talker_filter and talker_filter not in (talker, name_map.get(talker, talker)):
|
|
86
|
+
continue
|
|
87
|
+
tbl = f'Msg_{__import__("hashlib").md5(talker.encode()).hexdigest()}'
|
|
88
|
+
try:
|
|
89
|
+
c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
|
|
90
|
+
if c.fetchone()[0] == 0:
|
|
91
|
+
continue
|
|
92
|
+
c.execute(f'SELECT create_time, real_sender_id, local_type, message_content '
|
|
93
|
+
'FROM "{0}" WHERE create_time >= ? AND create_time < ? ORDER BY create_time'.format(tbl),
|
|
94
|
+
(start, end))
|
|
95
|
+
for ts, sender, ltype, content in c.fetchall():
|
|
96
|
+
text = _text_of(content, ltype)
|
|
97
|
+
if text:
|
|
98
|
+
who = sender_map.get(sender, '')
|
|
99
|
+
out[name_map.get(talker, talker)].append(
|
|
100
|
+
(int(ts), name_map.get(who, who), text))
|
|
101
|
+
except Exception:
|
|
102
|
+
continue
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
ap = argparse.ArgumentParser(description='那几年的今天你在聊什么')
|
|
108
|
+
ap.add_argument('--years', default=','.join(str(y) for y in DEFAULT_YEARS),
|
|
109
|
+
help='往前看几年,逗号分隔(默认 1,3,5)')
|
|
110
|
+
ap.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER', ''), help='只看某个会话(wxid 或昵称)')
|
|
111
|
+
ap.add_argument('--limit', type=int, default=6, help='每天最多列几条(默认 6)')
|
|
112
|
+
ap.add_argument('--json', action='store_true')
|
|
113
|
+
args = ap.parse_args()
|
|
114
|
+
|
|
115
|
+
cfg = load_config()
|
|
116
|
+
db = cfg.get('ntDbPath') or ''
|
|
117
|
+
if not db:
|
|
118
|
+
raise SystemExit('配置里没有 ntDbPath,先跑 weflow-cli init')
|
|
119
|
+
key, salt, pw = _keys(cfg)
|
|
120
|
+
conns = nd.connect_message_shards(db, key, salt, pw)
|
|
121
|
+
if not conns:
|
|
122
|
+
raise SystemExit('打不开任何消息分片')
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# 联系人在 **contact.db**,不在消息库里 —— 从消息库找 `contact` 表一定落空,
|
|
126
|
+
# 输出里就只剩 `47966561144@chatroom` 和 `wxid_xxx`(第一版就是这样)。
|
|
127
|
+
name_map = {}
|
|
128
|
+
try:
|
|
129
|
+
from network import load_graph
|
|
130
|
+
people, _rooms, _members = load_graph(cfg)
|
|
131
|
+
name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
|
|
132
|
+
for p in people.values()}
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),下面显示 id',
|
|
135
|
+
file=sys.stderr)
|
|
136
|
+
|
|
137
|
+
today = datetime.now(TZ)
|
|
138
|
+
years = [int(y) for y in args.years.split(',') if y.strip().isdigit()]
|
|
139
|
+
result = []
|
|
140
|
+
for y in years:
|
|
141
|
+
try:
|
|
142
|
+
day = today.replace(year=today.year - y)
|
|
143
|
+
except ValueError: # 2 月 29 日
|
|
144
|
+
day = today.replace(year=today.year - y, day=28)
|
|
145
|
+
by_talker = collect_for_day(conns, name_map, day, args.talker)
|
|
146
|
+
total = sum(len(v) for v in by_talker.values())
|
|
147
|
+
top = Counter({k: len(v) for k, v in by_talker.items()}).most_common(3)
|
|
148
|
+
picks = []
|
|
149
|
+
for talker, msgs in sorted(by_talker.items(), key=lambda kv: -len(kv[1]))[:3]:
|
|
150
|
+
for ts, sender, text in msgs[:max(1, args.limit // 3)]:
|
|
151
|
+
picks.append({'talker': talker, 'time': datetime.fromtimestamp(ts, tz=TZ).strftime('%H:%M'),
|
|
152
|
+
'sender': sender, 'text': text})
|
|
153
|
+
result.append({'date': day.strftime('%Y-%m-%d'), 'yearsAgo': y,
|
|
154
|
+
'total': total, 'threads': top, 'samples': picks[:args.limit]})
|
|
155
|
+
finally:
|
|
156
|
+
for conn in conns:
|
|
157
|
+
try:
|
|
158
|
+
conn.close()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
if args.json:
|
|
163
|
+
print(json.dumps({'success': True, 'days': result}, ensure_ascii=False, indent=2))
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
for item in result:
|
|
167
|
+
head = f"{item['date']}({item['yearsAgo']} 年前的今天)"
|
|
168
|
+
if not item['total']:
|
|
169
|
+
print(f'\n{head}\n 那天没有聊天记录')
|
|
170
|
+
continue
|
|
171
|
+
threads = '、'.join(f'{n}({c})' for n, c in item['threads'])
|
|
172
|
+
print(f"\n{head} {item['total']} 条 {threads}")
|
|
173
|
+
for s in item['samples']:
|
|
174
|
+
print(f" {s['time']} {s['talker']} · {s['sender'] or '?'}:{s['text']}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == '__main__':
|
|
178
|
+
main()
|
|
@@ -1,138 +1,138 @@
|
|
|
1
|
-
"""某个号的观点时间线:它这一年都说过什么,什么时候开始变了。
|
|
2
|
-
|
|
3
|
-
日报每天把文章存成 `output/biz-daily/<date>/<topic>/*.md`(frontmatter 带 `source`、
|
|
4
|
-
`date`,正文有 `## AI 摘要`),所以"这个号的观点怎么变"是可以按时间排出来的 ——
|
|
5
|
-
而微信里你只能一篇篇点开、还没有横轴。
|
|
6
|
-
|
|
7
|
-
用法:
|
|
8
|
-
python scripts/opinion_timeline.py --list # 哪些号存了多少篇
|
|
9
|
-
python scripts/opinion_timeline.py --source 某号 # 时间线
|
|
10
|
-
python scripts/opinion_timeline.py --source 某号 --months 3
|
|
11
|
-
python scripts/opinion_timeline.py --source 某号 --ai # 让模型总结观点变化
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import pathlib
|
|
18
|
-
import re
|
|
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
|
-
# 摘要复用日报那套清洗:原始文本是「【核心观点】…【关键细节】1.…」,直接打出来是噪音。
|
|
26
|
-
from daily_mail import clean_summary as _clean # noqa: E402
|
|
27
|
-
|
|
28
|
-
ROOT = SCRIPT_DIR.parent
|
|
29
|
-
DAILY_DIR = ROOT / 'output' / 'biz-daily'
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def parse_md(path):
|
|
33
|
-
"""frontmatter + `## AI 摘要` 那一段。解析不出来就跳过,不让一篇坏文件毁掉整条时间线。"""
|
|
34
|
-
try:
|
|
35
|
-
text = path.read_text(encoding='utf-8')
|
|
36
|
-
except Exception:
|
|
37
|
-
return None
|
|
38
|
-
meta = {}
|
|
39
|
-
body = text
|
|
40
|
-
if text.startswith('---'):
|
|
41
|
-
end = text.find('\n---', 3)
|
|
42
|
-
if end > 0:
|
|
43
|
-
for line in text[3:end].splitlines():
|
|
44
|
-
if ':' in line:
|
|
45
|
-
k, v = line.split(':', 1)
|
|
46
|
-
meta[k.strip()] = v.strip().strip('"')
|
|
47
|
-
body = text[end + 4:]
|
|
48
|
-
summary = ''
|
|
49
|
-
m = re.search(r'##\s*AI 摘要\s*\n+(.+?)(?:\n#|\n---|$)', body, re.S)
|
|
50
|
-
if m:
|
|
51
|
-
summary = ' '.join(m.group(1).split())[:200]
|
|
52
|
-
return {
|
|
53
|
-
'title': meta.get('title', ''),
|
|
54
|
-
'source': meta.get('source', ''),
|
|
55
|
-
'date': meta.get('date', ''),
|
|
56
|
-
'relevance': meta.get('relevance', ''),
|
|
57
|
-
'topic': meta.get('topic', ''),
|
|
58
|
-
'url': meta.get('url', ''),
|
|
59
|
-
'summary': summary,
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def load_all():
|
|
64
|
-
"""所有日期目录里的文章。"""
|
|
65
|
-
items = []
|
|
66
|
-
if not DAILY_DIR.is_dir():
|
|
67
|
-
return items
|
|
68
|
-
for day in sorted(DAILY_DIR.iterdir()):
|
|
69
|
-
if not day.is_dir():
|
|
70
|
-
continue
|
|
71
|
-
for md in day.rglob('*.md'):
|
|
72
|
-
rec = parse_md(md)
|
|
73
|
-
if rec and rec['source']:
|
|
74
|
-
items.append(rec)
|
|
75
|
-
return items
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def main():
|
|
79
|
-
ap = argparse.ArgumentParser(description='某个号的观点时间线')
|
|
80
|
-
ap.add_argument('--source', default='', help='公众号名(子串匹配)')
|
|
81
|
-
ap.add_argument('--months', type=int, default=0, help='只看最近 N 个月(0 = 全部)')
|
|
82
|
-
ap.add_argument('--limit', type=int, default=30, help='最多列几篇(默认 30,最近的在前)')
|
|
83
|
-
ap.add_argument('--list', action='store_true', help='列出存了哪些号、各多少篇')
|
|
84
|
-
ap.add_argument('--ai', action='store_true', help='让模型总结观点变化(要 API key)')
|
|
85
|
-
ap.add_argument('--json', action='store_true')
|
|
86
|
-
args = ap.parse_args()
|
|
87
|
-
|
|
88
|
-
items = load_all()
|
|
89
|
-
if not items:
|
|
90
|
-
raise SystemExit(f'{DAILY_DIR} 下没有可解析的文章')
|
|
91
|
-
|
|
92
|
-
if args.list:
|
|
93
|
-
counter = defaultdict(int)
|
|
94
|
-
for it in items:
|
|
95
|
-
counter[it['source']] += 1
|
|
96
|
-
rows = sorted(counter.items(), key=lambda kv: -kv[1])
|
|
97
|
-
print(f'共 {len(rows)} 个号,{len(items)} 篇\n')
|
|
98
|
-
for name, n in rows[:40]:
|
|
99
|
-
print(f' {n:>4} 篇 {name}')
|
|
100
|
-
return
|
|
101
|
-
|
|
102
|
-
if not args.source:
|
|
103
|
-
ap.print_help()
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
picked = [it for it in items if args.source in it['source']]
|
|
107
|
-
if not picked:
|
|
108
|
-
raise SystemExit(f'没有匹配「{args.source}」的文章;用 --list 看有哪些号')
|
|
109
|
-
picked.sort(key=lambda x: x['date'], reverse=True)
|
|
110
|
-
if args.limit and args.limit > 0:
|
|
111
|
-
picked = picked[:args.limit]
|
|
112
|
-
|
|
113
|
-
dates = sorted({it['date'] for it in picked})
|
|
114
|
-
print(f"{picked[0]['source']}:{len(picked)} 篇,{dates[0]} ~ {dates[-1]}\n")
|
|
115
|
-
for it in picked:
|
|
116
|
-
rel = f"({it['relevance']})" if it['relevance'] else ''
|
|
117
|
-
print(f"{it['date']} {it['title'][:60]}{rel}")
|
|
118
|
-
if it['summary']:
|
|
119
|
-
print(f" {_clean(it['summary'], 160)}")
|
|
120
|
-
|
|
121
|
-
if args.ai:
|
|
122
|
-
from _utils import load_config, get_api_key, call_ai
|
|
123
|
-
api_key = get_api_key(load_config())
|
|
124
|
-
if not api_key:
|
|
125
|
-
print('\n[WARN] 没有可用的 API key,跳过观点总结', file=sys.stderr)
|
|
126
|
-
return
|
|
127
|
-
lines = [f"{it['date']} {it['title']}:{it['summary']}" for it in picked][-40:]
|
|
128
|
-
prompt = (
|
|
129
|
-
f"下面是同一个公众号「{picked[0]['source']}」按时间排列的文章与要点。\n"
|
|
130
|
-
'请用中文回答:它的关注点或立场有没有随时间变化?如果有,指出从什么变成什么,'
|
|
131
|
-
'并给出对应的日期作为证据;如果没有明显变化,就说明它一直关注什么。'
|
|
132
|
-
'不要罗列所有篇目,不要空话。\n\n' + '\n'.join(lines))
|
|
133
|
-
print('\n--- 观点变化(AI 总结)---')
|
|
134
|
-
print(call_ai(prompt, 'deepseek', api_key, max_tokens=800))
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if __name__ == '__main__':
|
|
138
|
-
main()
|
|
1
|
+
"""某个号的观点时间线:它这一年都说过什么,什么时候开始变了。
|
|
2
|
+
|
|
3
|
+
日报每天把文章存成 `output/biz-daily/<date>/<topic>/*.md`(frontmatter 带 `source`、
|
|
4
|
+
`date`,正文有 `## AI 摘要`),所以"这个号的观点怎么变"是可以按时间排出来的 ——
|
|
5
|
+
而微信里你只能一篇篇点开、还没有横轴。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python scripts/opinion_timeline.py --list # 哪些号存了多少篇
|
|
9
|
+
python scripts/opinion_timeline.py --source 某号 # 时间线
|
|
10
|
+
python scripts/opinion_timeline.py --source 某号 --months 3
|
|
11
|
+
python scripts/opinion_timeline.py --source 某号 --ai # 让模型总结观点变化
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import pathlib
|
|
18
|
+
import re
|
|
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
|
+
# 摘要复用日报那套清洗:原始文本是「【核心观点】…【关键细节】1.…」,直接打出来是噪音。
|
|
26
|
+
from daily_mail import clean_summary as _clean # noqa: E402
|
|
27
|
+
|
|
28
|
+
ROOT = SCRIPT_DIR.parent
|
|
29
|
+
DAILY_DIR = ROOT / 'output' / 'biz-daily'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_md(path):
|
|
33
|
+
"""frontmatter + `## AI 摘要` 那一段。解析不出来就跳过,不让一篇坏文件毁掉整条时间线。"""
|
|
34
|
+
try:
|
|
35
|
+
text = path.read_text(encoding='utf-8')
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
meta = {}
|
|
39
|
+
body = text
|
|
40
|
+
if text.startswith('---'):
|
|
41
|
+
end = text.find('\n---', 3)
|
|
42
|
+
if end > 0:
|
|
43
|
+
for line in text[3:end].splitlines():
|
|
44
|
+
if ':' in line:
|
|
45
|
+
k, v = line.split(':', 1)
|
|
46
|
+
meta[k.strip()] = v.strip().strip('"')
|
|
47
|
+
body = text[end + 4:]
|
|
48
|
+
summary = ''
|
|
49
|
+
m = re.search(r'##\s*AI 摘要\s*\n+(.+?)(?:\n#|\n---|$)', body, re.S)
|
|
50
|
+
if m:
|
|
51
|
+
summary = ' '.join(m.group(1).split())[:200]
|
|
52
|
+
return {
|
|
53
|
+
'title': meta.get('title', ''),
|
|
54
|
+
'source': meta.get('source', ''),
|
|
55
|
+
'date': meta.get('date', ''),
|
|
56
|
+
'relevance': meta.get('relevance', ''),
|
|
57
|
+
'topic': meta.get('topic', ''),
|
|
58
|
+
'url': meta.get('url', ''),
|
|
59
|
+
'summary': summary,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_all():
|
|
64
|
+
"""所有日期目录里的文章。"""
|
|
65
|
+
items = []
|
|
66
|
+
if not DAILY_DIR.is_dir():
|
|
67
|
+
return items
|
|
68
|
+
for day in sorted(DAILY_DIR.iterdir()):
|
|
69
|
+
if not day.is_dir():
|
|
70
|
+
continue
|
|
71
|
+
for md in day.rglob('*.md'):
|
|
72
|
+
rec = parse_md(md)
|
|
73
|
+
if rec and rec['source']:
|
|
74
|
+
items.append(rec)
|
|
75
|
+
return items
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
ap = argparse.ArgumentParser(description='某个号的观点时间线')
|
|
80
|
+
ap.add_argument('--source', default='', help='公众号名(子串匹配)')
|
|
81
|
+
ap.add_argument('--months', type=int, default=0, help='只看最近 N 个月(0 = 全部)')
|
|
82
|
+
ap.add_argument('--limit', type=int, default=30, help='最多列几篇(默认 30,最近的在前)')
|
|
83
|
+
ap.add_argument('--list', action='store_true', help='列出存了哪些号、各多少篇')
|
|
84
|
+
ap.add_argument('--ai', action='store_true', help='让模型总结观点变化(要 API key)')
|
|
85
|
+
ap.add_argument('--json', action='store_true')
|
|
86
|
+
args = ap.parse_args()
|
|
87
|
+
|
|
88
|
+
items = load_all()
|
|
89
|
+
if not items:
|
|
90
|
+
raise SystemExit(f'{DAILY_DIR} 下没有可解析的文章')
|
|
91
|
+
|
|
92
|
+
if args.list:
|
|
93
|
+
counter = defaultdict(int)
|
|
94
|
+
for it in items:
|
|
95
|
+
counter[it['source']] += 1
|
|
96
|
+
rows = sorted(counter.items(), key=lambda kv: -kv[1])
|
|
97
|
+
print(f'共 {len(rows)} 个号,{len(items)} 篇\n')
|
|
98
|
+
for name, n in rows[:40]:
|
|
99
|
+
print(f' {n:>4} 篇 {name}')
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if not args.source:
|
|
103
|
+
ap.print_help()
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
picked = [it for it in items if args.source in it['source']]
|
|
107
|
+
if not picked:
|
|
108
|
+
raise SystemExit(f'没有匹配「{args.source}」的文章;用 --list 看有哪些号')
|
|
109
|
+
picked.sort(key=lambda x: x['date'], reverse=True)
|
|
110
|
+
if args.limit and args.limit > 0:
|
|
111
|
+
picked = picked[:args.limit]
|
|
112
|
+
|
|
113
|
+
dates = sorted({it['date'] for it in picked})
|
|
114
|
+
print(f"{picked[0]['source']}:{len(picked)} 篇,{dates[0]} ~ {dates[-1]}\n")
|
|
115
|
+
for it in picked:
|
|
116
|
+
rel = f"({it['relevance']})" if it['relevance'] else ''
|
|
117
|
+
print(f"{it['date']} {it['title'][:60]}{rel}")
|
|
118
|
+
if it['summary']:
|
|
119
|
+
print(f" {_clean(it['summary'], 160)}")
|
|
120
|
+
|
|
121
|
+
if args.ai:
|
|
122
|
+
from _utils import load_config, get_api_key, call_ai
|
|
123
|
+
api_key = get_api_key(load_config())
|
|
124
|
+
if not api_key:
|
|
125
|
+
print('\n[WARN] 没有可用的 API key,跳过观点总结', file=sys.stderr)
|
|
126
|
+
return
|
|
127
|
+
lines = [f"{it['date']} {it['title']}:{it['summary']}" for it in picked][-40:]
|
|
128
|
+
prompt = (
|
|
129
|
+
f"下面是同一个公众号「{picked[0]['source']}」按时间排列的文章与要点。\n"
|
|
130
|
+
'请用中文回答:它的关注点或立场有没有随时间变化?如果有,指出从什么变成什么,'
|
|
131
|
+
'并给出对应的日期作为证据;如果没有明显变化,就说明它一直关注什么。'
|
|
132
|
+
'不要罗列所有篇目,不要空话。\n\n' + '\n'.join(lines))
|
|
133
|
+
print('\n--- 观点变化(AI 总结)---')
|
|
134
|
+
print(call_ai(prompt, 'deepseek', api_key, max_tokens=800))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == '__main__':
|
|
138
|
+
main()
|