weflow-cli 1.14.1 → 1.15.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.
@@ -514,6 +514,41 @@ because nobody built it" look identical to a reader.
514
514
  - **`tags` is the configured category for any source that has one, by design.** The prompt for a categorised source is summary-only and explicitly says *不要输出主题、标签、相关度或概念字段*, and the code sets `tags = [category]` itself. Measured over the 1584 articles of the 11 stored days: 1396 carry exactly `[topic]`, 182 carry none, and only **6** carry real extracted tags - and those 6 are the corpus built without configured categories. The checkable signature is that **on every one of those days each source maps to exactly one topic** (36/40/15/43/41/27/35/36/3/36/6 sources, no exceptions), which is what topic-from-config produces and per-article classification does not. A live call through the production prompt returns real tags (`AI与数学, 人类数学家, 数学共同体, 人机关系, 学术人文`), so extraction itself is not broken - it is simply not asked for on that path. Practical consequence: to get meaningful tags for a source, leave its category unconfigured (and accept that its topic then comes from classification rather than from you).
515
515
  - **No real artifact on disk carries `includeScore`.** The six files in the 2026-09-05 output directory were written before that field existed, so the field has never been produced in a real run - which is why calibration has to re-score articles rather than read stored values.
516
516
  - The report's admission threshold (`INCLUDE_THRESHOLD = 0.5` on `includeScore`) is provisional in the same way and, unlike the old rule, it is actually applied: both loaders call one predicate. Turning it on **reduced** what the report collects, because the primary loader previously collected everything unfiltered. On a 6-article trial the scores straddled the cut point (0.48 and 0.63 on two engineering posts), so the cut point is doing real work rather than being decorative.
517
+ - **Four read-only "questions WeChat cannot answer" commands ship as CLI, not just scripts.**
518
+ `today` (cross-year same-day recap), `network` (shared groups / introduction paths from `contact.db`),
519
+ `promises` (what I said I would do, with the follow-up evidence), `timeline` (one account's articles
520
+ over time). All four are `--json`-capable and read only; none sends or calls the assistant layer.
521
+ Two shared traps were fixed while wiring them: **contact names live in `contact.db`, never in the
522
+ message shards** (looking for a `contact` table there silently yields ids, so every name printed as
523
+ `47966561144@chatroom`), and **the configured `wxid` may carry a suffix the `Name2Id` table does not**
524
+ (`wxid_账号A_后缀` vs `wxid_账号A`), which made "messages I sent" match nothing until the comparison
525
+ became prefix-tolerant.
526
+ - **Voice transcription works once four decode settings are on - measured 2026-10-22.** Default
527
+ `model.transcribe(...)` returned verbatim repetition (`我做的事情都没有做过` x3) on dialect-heavy clips,
528
+ which is Whisper's self-conditioning failure mode rather than an audio problem: the decode side was
529
+ verified good (15878-byte SILK → 8.4 s of 16 kHz mono, peak 27613, 46% loud samples, and decoding at
530
+ 8000 vs 16000 Hz yields the same 8.38 s so PILK resamples rather than shifting pitch). The fix is the
531
+ combination in `transcribe_wav`: `condition_on_previous_text=False` (breaks the repetition chain),
532
+ `vad_filter=True` (silence is what invites hallucination), `compression_ratio_threshold=2.0` with a
533
+ `temperature` ladder (so a repeating output really escalates instead of stalling), and
534
+ `repetition_penalty=1.15`. Measured after the change: **4 of 5 clips readable**
535
+ (`你填一下交通补贴20元 兄弟`), where before it was 0 of 5. Residual errors remain on heavy dialect and
536
+ are reported as-is; there is no claim of accuracy here.
537
+ - **Voice enters the *phrase* index, not the semantic one.** `build_phrase_index` appends
538
+ `output/.voice_index/voices.jsonl` as `type='voice'`, so `search --phrase` finds what was said out
539
+ loud - the one content class WeChat cannot search at all. It is deliberately kept out of the semantic
540
+ index (a second embedding bill for a dialect-approximate transcript is a bad trade) and out of the
541
+ `wiki`/`chat` line filters (those two are user-chosen scopes; voice is a third kind of evidence).
542
+ `voice_transcribe.py` owns decode + cache + resume, and the cache is content-addressed so a re-run
543
+ after ids shift still hits.
544
+ - **Message shards are split by *time*, not by account or by size - measured 2026-10-22.** The four
545
+ `message_*.db` files on this machine each cover a consecutive span, oldest to newest:
546
+ `message_2.db` 2024-03-29 ~ 2024-08-28 (1 conversation table), `message_1.db` 2024-08-30 ~ 2025-08-28 (2),
547
+ `message_0.db` 2025-08-31 ~ 2026-08-30 (122), `message_3.db` 2026-08-30 ~ 2026-10-22 (84). Two consequences
548
+ worth knowing before reading anything into "missing" history: the **earliest record is 2024-03-29** (that is
549
+ where this account's data starts, not a reading failure), and a *year* can look empty while the shard holding
550
+ it holds almost nothing - the whole of 2024 lives in 3 conversation tables across two shards. `discover_message_shards`
551
+ reads all of them, so `on_this_day.py` correctly reports "no chat that day" rather than failing to look.
517
552
  - Message reads select only the columns the reader consumes, resolved per shard from what that shard actually has. `nt_decrypt` reads six (`local_id`, `server_id`, `local_type`, `real_sender_id`, `create_time`, `message_content`) and a WeChat version that renames or drops any other column of the message table therefore cannot make a shard unreadable. What is lost is reported, not swallowed: a shard lacking one of those six is read anyway and named in `missingColumns`, while a shard where none of them is present is reported as `SCHEMA_MISMATCH` and makes `coverage` partial. `export_chat_html` reads rows positionally, so there a missing column keeps its slot as `NULL` instead of shifting the fields after it. Both are verified against synthetic shards only - no future WeChat schema has been tested.
518
553
 
519
554
  - **Topic exclusion is a display-layer switch, and that is a measurement result rather than a preference.** `dailyExcludeTopics` (or `--exclude-topics`) removes a topic from the daily report and the reader page; the article is still fetched, judged and archived. Filtering *before* the fetch was the original ask and was rejected on evidence: from a source name plus a title and the platform digest - everything that exists pre-fetch - the type judgement agreed with the source-level configuration on only **60%** of 217 articles, with 48 false positives, and there is no gold standard either way. A dropped fetch is irreversible (nothing is archived, and the page is not the same weeks later) while an unwanted article costs only attention, so the decision is deliberately placed where it can be undone by regenerating. The exclusion is orthogonal to `--include-all` and to the inclusion score, and **all three lists in 我拿不准的 are filtered** - passing the exclusion set to `admits()` in the "not admitted" list had the opposite effect and surfaced the excluded articles (that list is populated by exactly the articles being excluded). One predicate (`is_excluded_topic`) now serves both directions. Excluding the focus topic is refused with a WARN, because the result is not an empty report but a misleading one: it exits with "未找到文章,请先运行 biz_daily.py". The key is registered in all four `configService` literals plus `bin`'s `configurableKeys`, which `test/config-keys.test.ts` now enforces - a key missing from defaults or `clear()` survives `config set` and then quietly disappears.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weflow-cli",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/bonds.py CHANGED
@@ -58,7 +58,7 @@ OTHER_CHANNEL = ('打电话', '手机号', '电话号', '邮箱', '邮件', '短
58
58
 
59
59
  # 名字里带这些的,**只标出来、不自动排除**。
60
60
  #
61
- # 为什么只标不排:实测有个「普华口腔门诊部」的 id 是 `wxid_v5q0oc8eb04712`、`local_type=1`、
61
+ # 为什么只标不排:实测有个「普华口腔门诊部」的 id 是 `wxid_示例号`、`local_type=1`、
62
62
  # 还有自定义微信号 —— 它**和真人长得一模一样**,结构上没有任何标记能区分(`@openim` 那种是另一类)。
63
63
  # 所以"谁算人"这一手,工具摆出可疑的、由人决定;名单在 `output/bonds-skip.txt` 或 `--skip` 里。
64
64
  SERVICE_NAME_HINTS = ('门诊', '口腔', '医院', '客服', '小助手', '福利', '旗舰店', '专营店',
@@ -0,0 +1,206 @@
1
+ """人脉图谱:谁和你在同一个群、谁适合做引荐人。
2
+
3
+ 数据全在 `contact.db`(联系人 + 群 + 群成员),不碰消息库,也就没有"读聊天内容"这件事:
4
+ contact(id, username, nick_name, remark) 4192 人
5
+ chat_room(id, username, owner) 80 个群
6
+ chatroom_member(room_id, member_id) 4305 条关系(member_id 是 contact.id)
7
+
8
+ 用法:
9
+ python scripts/network.py --groups 群概览(成员数,按大小排)
10
+ python scripts/network.py --with 张三 我和他共同在哪些群
11
+ python scripts/network.py --intro 张三 谁最适合把我介绍给他
12
+ python scripts/network.py --intro 张三 --json
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import pathlib
19
+ import sys
20
+ from collections import defaultdict
21
+
22
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
23
+ sys.path.insert(0, str(SCRIPT_DIR))
24
+
25
+ from _utils import load_config, decrypt_lock # noqa: E402
26
+ from nt_common import derive_database_key # noqa: E402
27
+ from export_chat_html import connect # noqa: E402
28
+
29
+
30
+ def _open_contact(cfg):
31
+ path = cfg.get('contactDbPath') or ''
32
+ if not path:
33
+ raise SystemExit('配置里没有 contactDbPath,先跑 weflow-cli init')
34
+ key = decrypt_lock(cfg.get('contactKey', '') or cfg.get('ntKey', '') or '')
35
+ salt = decrypt_lock(cfg.get('contactSalt', '') or cfg.get('ntSalt', '') or '')
36
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
+ k, s = derive_database_key(path, key, salt, pw)
38
+ return connect(path, k, s)
39
+
40
+
41
+ def load_graph(cfg):
42
+ """(people, rooms, members) —— people: id->名字; rooms: id->群名; members: room->{id}"""
43
+ conn, cur = _open_contact(cfg)
44
+ try:
45
+ people = {}
46
+ for pid, username, nick, remark in cur.execute(
47
+ 'SELECT id, username, COALESCE(nick_name,""), COALESCE(remark,"") FROM contact'):
48
+ name = remark or nick or username
49
+ people[int(pid)] = {'name': name, 'username': username, 'nick': nick, 'remark': remark}
50
+
51
+ rooms = {}
52
+ # 群昵称在 `contact` 里(`@chatroom` 那条的 remark/nick_name),`chat_room.username`
53
+ # 只是 id —— 不查这一步,输出里 80 个群全叫 `47966561144@chatroom`。
54
+ by_username = {p['username']: p for p in people.values()}
55
+ for rid, username, owner in cur.execute('SELECT id, username, COALESCE(owner,"") FROM chat_room'):
56
+ info = by_username.get(username) or {}
57
+ name = info.get('remark') or info.get('nick') or username
58
+ rooms[int(rid)] = {'name': name, 'username': username, 'owner': owner}
59
+
60
+ members = defaultdict(set)
61
+ for rid, mid in cur.execute('SELECT room_id, member_id FROM chatroom_member'):
62
+ members[int(rid)].add(int(mid))
63
+ return people, rooms, members
64
+ finally:
65
+ conn.close()
66
+
67
+
68
+ def resolve(people, needle):
69
+ """按 wxid / 昵称 / 备注找一个人;返回 (id, 记录) 或 None。
70
+
71
+ **排除群**:`contact` 表里 `@chatroom` 也在,用 `--with 老表` 会先命中「老表亲戚群」
72
+ 这个群而不是人(第一版就这么错),所以匹配人时跳过它们。
73
+ """
74
+ needle = (needle or '').strip()
75
+ if not needle:
76
+ return None
77
+ exact, fuzzy = [], []
78
+ for pid, p in people.items():
79
+ if p['username'].endswith('@chatroom'):
80
+ continue
81
+ fields = (p['name'], p['username'], p['nick'], p['remark'])
82
+ if needle == p['username']:
83
+ return pid, p
84
+ if any(needle == f for f in fields if f):
85
+ exact.append((pid, p))
86
+ elif any(needle in f for f in fields if f):
87
+ fuzzy.append((pid, p))
88
+ pool = exact or fuzzy
89
+ return pool[0] if pool else None
90
+
91
+
92
+ def find_me(people, members, cfg):
93
+ """自己的 contact.id。
94
+
95
+ 先按配置里的 wxid 找;**找不到就用"出现在最多群里的人"** —— 你在自己所有群里,
96
+ 而 `contact` 表不一定收录自己(实测这里就没有),少了这个兜底,
97
+ 「共同群」这类功能会直接退化成一列群名。
98
+ """
99
+ wxid = cfg.get('wxid') or ''
100
+ if wxid:
101
+ for pid, p in people.items():
102
+ if p['username'] == wxid:
103
+ return pid
104
+ counts = defaultdict(int)
105
+ for ids in members.values():
106
+ for pid in ids:
107
+ counts[pid] += 1
108
+ if counts:
109
+ pid, n = max(counts.items(), key=lambda kv: kv[1])
110
+ if n >= 3:
111
+ return pid
112
+ return None
113
+
114
+
115
+ def rooms_of(members, pid):
116
+ return {rid for rid, ids in members.items() if pid in ids}
117
+
118
+
119
+ def cmd_groups(people, rooms, members):
120
+ rows = []
121
+ for rid, info in rooms.items():
122
+ ids = members.get(rid, set())
123
+ rows.append({'room': info['name'], 'members': len(ids)})
124
+ rows.sort(key=lambda r: -r['members'])
125
+ print(f'共 {len(rows)} 个群\n')
126
+ for r in rows[:30]:
127
+ print(f" {r['members']:>4} 人 {r['room']}")
128
+ if len(rows) > 30:
129
+ print(f' …还有 {len(rows)-30} 个')
130
+
131
+
132
+ def cmd_with(people, rooms, members, needle, cfg):
133
+ me_id = find_me(people, members, cfg)
134
+ me = (me_id, people.get(me_id, {})) if me_id else None
135
+ target = resolve(people, needle)
136
+ if not target:
137
+ raise SystemExit(f'没找到「{needle}」')
138
+ tid, tinfo = target
139
+ t_rooms = rooms_of(members, tid)
140
+ print(f"目标:{tinfo['name']}({tinfo['username']})在 {len(t_rooms)} 个群")
141
+ if not me:
142
+ print('(配置里没有自己的 wxid,跳过共同群计算)')
143
+ for rid in sorted(t_rooms):
144
+ print(f" · {rooms[rid]['name']}")
145
+ return
146
+ my_rooms = rooms_of(members, me[0])
147
+ common = t_rooms & my_rooms
148
+ print(f"你们共同在 {len(common)} 个群:")
149
+ for rid in sorted(common):
150
+ print(f" · {rooms[rid]['name']}({len(members.get(rid, []))} 人)")
151
+ if not common:
152
+ print(' (没有共同群)')
153
+
154
+
155
+ def cmd_intro(people, rooms, members, needle, cfg, top=8):
156
+ me_id = find_me(people, members, cfg)
157
+ me = (me_id, people.get(me_id, {})) if me_id else None
158
+ target = resolve(people, needle)
159
+ if not target:
160
+ raise SystemExit(f'没找到「{needle}」')
161
+ tid, tinfo = target
162
+ if not me:
163
+ raise SystemExit('配置里没有自己的 wxid,算不了引荐路径')
164
+ my_rooms = rooms_of(members, me[0])
165
+ t_rooms = rooms_of(members, tid)
166
+
167
+ # 候选人 = 同时在我和目标的群里的人;分数 = 交集群数,其次是他和目标最近的共同群大小
168
+ scores = defaultdict(lambda: {'rooms': set()})
169
+ for rid in my_rooms & t_rooms:
170
+ for pid in members.get(rid, set()):
171
+ if pid in (me[0], tid):
172
+ continue
173
+ scores[pid]['rooms'].add(rid)
174
+ if not scores:
175
+ print(f'你和「{tinfo["name"]}」不在同一个群,也没有共同的联系人可引荐')
176
+ return
177
+ ranked = sorted(scores.items(), key=lambda kv: -len(kv[1]['rooms']))
178
+ print(f"想认识「{tinfo['name']}」,这些人同时和你们在一个群:\n")
179
+ for pid, info in ranked[:top]:
180
+ p = people.get(pid, {})
181
+ shared = '、'.join(rooms[r]['name'] for r in list(info['rooms'])[:3])
182
+ print(f" {p.get('name') or p.get('username')} 共同群 {len(info['rooms'])} 个:{shared}")
183
+
184
+
185
+ def main():
186
+ ap = argparse.ArgumentParser(description='人脉图谱:共同群与引荐路径')
187
+ ap.add_argument('--groups', action='store_true', help='群概览')
188
+ ap.add_argument('--with', dest='with_who', default='', help='我和他共同在哪些群')
189
+ ap.add_argument('--intro', default='', help='谁能把我介绍给他')
190
+ ap.add_argument('--json', action='store_true')
191
+ args = ap.parse_args()
192
+
193
+ cfg = load_config()
194
+ people, rooms, members = load_graph(cfg)
195
+ if args.groups:
196
+ cmd_groups(people, rooms, members)
197
+ elif args.with_who:
198
+ cmd_with(people, rooms, members, args.with_who, cfg)
199
+ elif args.intro:
200
+ cmd_intro(people, rooms, members, args.intro, cfg)
201
+ else:
202
+ ap.print_help()
203
+
204
+
205
+ if __name__ == '__main__':
206
+ main()
@@ -0,0 +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()
@@ -0,0 +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()