weflow-cli 1.16.0 → 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.
Files changed (97) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +16 -4
  3. package/bin/weflow-cli.ts +348 -6
  4. package/dist/bin/weflow-cli.js +381 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/qchatImport.d.ts +89 -0
  26. package/dist/src/services/qchatImport.d.ts.map +1 -0
  27. package/dist/src/services/qchatImport.js +273 -0
  28. package/dist/src/services/qchatImport.js.map +1 -0
  29. package/dist/src/services/setupVenv.d.ts +43 -0
  30. package/dist/src/services/setupVenv.d.ts.map +1 -0
  31. package/dist/src/services/setupVenv.js +198 -0
  32. package/dist/src/services/setupVenv.js.map +1 -0
  33. package/dist/src/utils/python.d.ts +10 -0
  34. package/dist/src/utils/python.d.ts.map +1 -1
  35. package/dist/src/utils/python.js +27 -4
  36. package/dist/src/utils/python.js.map +1 -1
  37. package/docs/DATA_CONTRACT.md +25 -0
  38. package/docs/DECISIONS.md +92 -30
  39. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  40. package/docs/HEALTH-CHECK.md +104 -104
  41. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  42. package/docs/NEXT_ACTIONS.md +236 -236
  43. package/docs/NPM-PUBLISH.md +130 -130
  44. package/docs/POSITIONING.md +68 -68
  45. package/docs/PROJECT_STATE.md +6 -3
  46. package/docs/REPLAY.md +169 -0
  47. package/docs/SYNC_CONTRACT.md +197 -180
  48. package/package.json +89 -88
  49. package/requirements-3x.txt +4 -4
  50. package/requirements-voice.txt +15 -15
  51. package/resources/js/graph3d/NOTICE.txt +35 -35
  52. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  53. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  54. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  55. package/resources/js/graph3d/d3-octree.min.js +2 -2
  56. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  57. package/resources/js/graph3d/d3-timer.min.js +2 -2
  58. package/resources/js/graph3d/three.min.js +6 -6
  59. package/resources/js/graph3d/viewer2d.js +550 -448
  60. package/resources/panel/quick-menu.cjs +197 -197
  61. package/scripts/_utils.py +1338 -1338
  62. package/scripts/article_notes.py +423 -423
  63. package/scripts/chat_notes.py +86 -3
  64. package/scripts/check_readme_commands.py +427 -426
  65. package/scripts/contact_schema.py +339 -339
  66. package/scripts/create_reading_notes.py +11 -11
  67. package/scripts/daily_task.ps1 +213 -213
  68. package/scripts/fav_notes.py +321 -321
  69. package/scripts/graph_2d.py +156 -151
  70. package/scripts/graph_3d.py +672 -646
  71. package/scripts/health_check.py +473 -473
  72. package/scripts/imported_chats.py +68 -0
  73. package/scripts/install_tasks.ps1 +105 -105
  74. package/scripts/leads.py +179 -179
  75. package/scripts/network.py +206 -206
  76. package/scripts/nt_decrypt.py +135 -1
  77. package/scripts/on_this_day.py +178 -178
  78. package/scripts/opinion_timeline.py +138 -138
  79. package/scripts/promises.py +185 -185
  80. package/scripts/replay.py +803 -0
  81. package/scripts/run_hidden.vbs +25 -0
  82. package/scripts/semantic_search.py +73 -5
  83. package/scripts/user_notes.py +315 -315
  84. package/scripts/voice_transcribe.py +139 -139
  85. package/scripts/watch_issues.py +212 -212
  86. package/scripts/watch_mail.py +131 -131
  87. package/src/core/ntCore.ts +59 -0
  88. package/src/services/assistantService.ts +48 -8
  89. package/src/services/assistantTools.ts +73 -10
  90. package/src/services/configService.ts +3 -2
  91. package/src/services/cursorProbe.ts +222 -0
  92. package/src/services/qchatImport.ts +327 -0
  93. package/src/services/setupVenv.ts +238 -0
  94. package/src/services/syncService.ts +363 -363
  95. package/src/services/syncState.ts +275 -275
  96. package/src/services/wechatMedia.ts +166 -166
  97. package/src/utils/python.ts +33 -4
@@ -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()