weflow-cli 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -0
- package/bin/weflow-cli.ts +169 -0
- package/dist/bin/weflow-cli.js +177 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/docs/PROJECT_STATE.md +35 -0
- package/package.json +1 -1
- package/scripts/bonds.py +1 -1
- package/scripts/daily_mail.py +6 -1
- package/scripts/daily_task.ps1 +34 -9
- package/scripts/network.py +206 -0
- package/scripts/on_this_day.py +178 -0
- package/scripts/opinion_timeline.py +138 -0
- package/scripts/promises.py +185 -0
- package/scripts/semantic_search.py +41 -1
- package/scripts/voice_transcribe.py +139 -0
- package/scripts/wechat_emoticon.py +1 -1
- package/scripts/wechat_voice.py +27 -2
package/docs/PROJECT_STATE.md
CHANGED
|
@@ -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
package/scripts/bonds.py
CHANGED
|
@@ -58,7 +58,7 @@ OTHER_CHANNEL = ('打电话', '手机号', '电话号', '邮箱', '邮件', '短
|
|
|
58
58
|
|
|
59
59
|
# 名字里带这些的,**只标出来、不自动排除**。
|
|
60
60
|
#
|
|
61
|
-
# 为什么只标不排:实测有个「普华口腔门诊部」的 id 是 `
|
|
61
|
+
# 为什么只标不排:实测有个「普华口腔门诊部」的 id 是 `wxid_示例号`、`local_type=1`、
|
|
62
62
|
# 还有自定义微信号 —— 它**和真人长得一模一样**,结构上没有任何标记能区分(`@openim` 那种是另一类)。
|
|
63
63
|
# 所以"谁算人"这一手,工具摆出可疑的、由人决定;名单在 `output/bonds-skip.txt` 或 `--skip` 里。
|
|
64
64
|
SERVICE_NAME_HINTS = ('门诊', '口腔', '医院', '客服', '小助手', '福利', '旗舰店', '专营店',
|
package/scripts/daily_mail.py
CHANGED
|
@@ -423,6 +423,7 @@ def main():
|
|
|
423
423
|
ap.add_argument('--date', required=True, help='YYYY-MM-DD')
|
|
424
424
|
ap.add_argument('--to', default='', help='收件人;缺省用配置 dailyMailTo,再缺省发给自己')
|
|
425
425
|
ap.add_argument('--topics', default=','.join(DEFAULT_TOPICS), help='逗号分隔的主题')
|
|
426
|
+
ap.add_argument('--label', default='', help='标题后缀,如「截至中午」——区分半日速览与次日完整版')
|
|
426
427
|
ap.add_argument('--no-digest', action='store_true', help='不生成每主题的 AI 综述(省钱、更快)')
|
|
427
428
|
ap.add_argument('--dry-run', action='store_true', help='只渲染,不发送')
|
|
428
429
|
args = ap.parse_args()
|
|
@@ -449,7 +450,11 @@ def main():
|
|
|
449
450
|
print(f'正文含 GitHub 链接: {with_repo} 篇')
|
|
450
451
|
|
|
451
452
|
text_body, html_body, total = render(args.date, groups, digests, bodies, clusters)
|
|
452
|
-
|
|
453
|
+
label = (args.label or '').strip()
|
|
454
|
+
suffix = f'({label})' if label else ''
|
|
455
|
+
subject = f'公众号日报 {args.date}{suffix}:{" / ".join(t for t in topics if groups.get(t))} {total} 篇'
|
|
456
|
+
if label:
|
|
457
|
+
text_body = f'【{label}】以下是到此刻为止收到的 {total} 篇。完整版会在次日凌晨按整天重发。\n\n' + text_body
|
|
453
458
|
|
|
454
459
|
user, auth = smtp_settings()
|
|
455
460
|
to = args.to or str(cfg.get('dailyMailTo') or '') or user
|
package/scripts/daily_task.ps1
CHANGED
|
@@ -128,18 +128,43 @@ if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
# Retrieval indexes lag behind unless something refreshes them: on 2026-10-22 the
|
|
132
|
+
# semantic index was still at 10-15 (7 days stale) and the phrase index at 10-17, so
|
|
133
|
+
# `search` simply could not find anything newer. Both run here, after the day is on
|
|
134
|
+
# disk, in their own logs -- a failure is recorded and never fails the day.
|
|
135
|
+
$searchScript = Join-Path $PSScriptRoot 'semantic_search.py'
|
|
136
|
+
if ($mode -eq 'day' -and (Test-Path $searchScript)) {
|
|
137
|
+
$idxOut = Join-Path $logDir ('index-{0}.log' -f $stamp)
|
|
138
|
+
$idxErr = Join-Path $logDir ('index-{0}.err.log' -f $stamp)
|
|
139
|
+
# `update` only embeds what is not in the index yet (4,061 new records on the first
|
|
140
|
+
# run after a week). `phrase-build` is local FTS, no API, so it rebuilds in full.
|
|
141
|
+
$idxProc = Start-Process -FilePath $python `
|
|
142
|
+
-ArgumentList @($searchScript, 'update', '--article-days', '30') `
|
|
143
|
+
-NoNewWindow -Wait -PassThru -RedirectStandardOutput $idxOut -RedirectStandardError $idxErr
|
|
144
|
+
$phraseProc = Start-Process -FilePath $python `
|
|
145
|
+
-ArgumentList @($searchScript, 'phrase-build') `
|
|
146
|
+
-NoNewWindow -Wait -PassThru `
|
|
147
|
+
-RedirectStandardOutput (Join-Path $logDir ('phrase-{0}.log' -f $stamp)) `
|
|
148
|
+
-RedirectStandardError (Join-Path $logDir ('phrase-{0}.err.log' -f $stamp))
|
|
149
|
+
if ($idxProc.ExitCode -ne 0 -or $phraseProc.ExitCode -ne 0) {
|
|
150
|
+
Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
|
|
151
|
+
('===== {0} index update failed (update={1}, phrase={2}), see {3} =====' -f `
|
|
152
|
+
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $idxProc.ExitCode, $phraseProc.ExitCode, $idxErr) -Encoding UTF8
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# **Both passes mail now.** The noon pass used to be silent, which read as "it never
|
|
157
|
+
# ran" - the only way to tell it had done anything was to open the log. The two emails
|
|
158
|
+
# are clearly different: the noon one is labelled 截至中午 and says the complete version
|
|
159
|
+
# follows next morning. Mail is still its own step with its own log pair, so a mail
|
|
160
|
+
# failure never fails the day nor blocks the noon marker.
|
|
138
161
|
$mailScript = Join-Path $PSScriptRoot 'daily_mail.py'
|
|
139
162
|
$mailOut = Join-Path $logDir ('daily-mail-{0}{1}.log' -f $stamp, $suffix)
|
|
140
163
|
$mailErr = Join-Path $logDir ('daily-mail-{0}{1}.err.log' -f $stamp, $suffix)
|
|
141
|
-
if (
|
|
142
|
-
$
|
|
164
|
+
if (Test-Path $mailScript) {
|
|
165
|
+
$mailArgs = @($mailScript, '--date', $targetDay)
|
|
166
|
+
if ($mode -eq 'noon') { $mailArgs += @('--label', '截至中午') }
|
|
167
|
+
$mailProc = Start-Process -FilePath $python -ArgumentList $mailArgs `
|
|
143
168
|
-NoNewWindow -Wait -PassThru -RedirectStandardOutput $mailOut -RedirectStandardError $mailErr
|
|
144
169
|
if ($mailProc.ExitCode -ne 0) {
|
|
145
170
|
Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
|
|
@@ -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()
|