weflow-cli 1.15.1 → 1.16.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 +17 -1
- package/OPERATIONS.md +16 -1
- package/README.en.md +21 -2
- package/README.md +18 -2
- package/bin/weflow-cli.ts +41 -0
- package/dist/bin/weflow-cli.js +41 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/package.json +1 -1
- package/scripts/bonds.py +11 -0
- package/scripts/daily_task.ps1 +36 -0
- package/scripts/install_tasks.ps1 +18 -4
- package/scripts/leads.py +179 -0
package/package.json
CHANGED
package/scripts/bonds.py
CHANGED
|
@@ -137,6 +137,14 @@ def shape_of(messages, now):
|
|
|
137
137
|
span_days = max(1, ((last - min(stamps)) / 86400)) if len(stamps) > 1 else 1
|
|
138
138
|
per_day = len(stamps) / span_days # 平时日均
|
|
139
139
|
recent = sum(1 for t in stamps if (now - t) / 86400 <= 7)
|
|
140
|
+
# 最后一句原文。冷却名单里只写"已静默 45 天"没法决定要不要回 —— 补上这句话,
|
|
141
|
+
# 读者一眼就知道该说什么(2026-10-23:这是"复联提醒"缺的那一半)。
|
|
142
|
+
last_text = ''
|
|
143
|
+
for m in sorted(messages, key=lambda x: x.get('createTime') or 0, reverse=True):
|
|
144
|
+
text = ' '.join(str(m.get('content') or '').split())
|
|
145
|
+
if text:
|
|
146
|
+
last_text = text[:60]
|
|
147
|
+
break
|
|
140
148
|
return {
|
|
141
149
|
'messages': len(stamps),
|
|
142
150
|
'activeDays': len(days),
|
|
@@ -145,6 +153,7 @@ def shape_of(messages, now):
|
|
|
145
153
|
'recent7': recent,
|
|
146
154
|
'mine': round(100.0 * sum(1 for m in messages if m.get('isSend')) / max(1, len(stamps))),
|
|
147
155
|
'last': datetime.fromtimestamp(last, TZ).strftime('%Y-%m-%d') if last else '',
|
|
156
|
+
'lastText': last_text,
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
|
|
@@ -253,6 +262,8 @@ def render(payload, limit=12):
|
|
|
253
262
|
lines.append(' %-16s %5d 条 / %3d 个活跃天 / 已静默 %6.0f 天(最后一次 %s)%s'
|
|
254
263
|
% (p['name'][:16], p['messages'], p['activeDays'], p['silentDays'], p['last'],
|
|
255
264
|
' ⚠只有微信这一条线' if 'only-wechat' in p['tags'] else ''))
|
|
265
|
+
if p.get('lastText'):
|
|
266
|
+
lines.append(' 最后一句:%s' % p['lastText'])
|
|
256
267
|
if view['heating']:
|
|
257
268
|
lines.append('')
|
|
258
269
|
lines.append('最近变热(近 7 天 ≥ 平时日均的 %.1f 倍):' % HOT_RATIO)
|
package/scripts/daily_task.ps1
CHANGED
|
@@ -119,6 +119,42 @@ if (Test-Path $out) {
|
|
|
119
119
|
$proc = Start-Process -FilePath $python -ArgumentList $allArgs -NoNewWindow -Wait -PassThru `
|
|
120
120
|
-RedirectStandardOutput $out -RedirectStandardError $err
|
|
121
121
|
|
|
122
|
+
# Fallback for the HTML stage.
|
|
123
|
+
#
|
|
124
|
+
# `pipeline.py` fetches, summarises, writes the day, then renders `index.html`. That last step
|
|
125
|
+
# has hung twice now (2026-10-18: 11 hours with no output; 2026-10-23: the run died after the
|
|
126
|
+
# README and never produced the page). Losing the page also loses the mail, because mailing
|
|
127
|
+
# happens only on success - so a single slow step silently costs the whole morning report.
|
|
128
|
+
#
|
|
129
|
+
# The fetch and summaries are already on disk at that point (`.articles.json`), so rendering is
|
|
130
|
+
# retried on its own. `$proc` is replaced by the retry's exit code, which is what lets the mail
|
|
131
|
+
# and index steps below run on a day where only the renderer failed.
|
|
132
|
+
if ($proc.ExitCode -ne 0) {
|
|
133
|
+
$dayDir = Join-Path $root ('output\biz-daily\' + $targetDay)
|
|
134
|
+
$art = Join-Path $dayDir '.articles.json'
|
|
135
|
+
$idx = Join-Path $dayDir 'index.html'
|
|
136
|
+
$htmlScript = Join-Path $PSScriptRoot 'generate_html.py'
|
|
137
|
+
# Only retry when the data is there and the page is missing or older than the data:
|
|
138
|
+
# a page newer than `.articles.json` means rendering already happened this run.
|
|
139
|
+
$needsRender = (Test-Path $art) -and (Test-Path $htmlScript)
|
|
140
|
+
if ($needsRender -and (Test-Path $idx)) {
|
|
141
|
+
$needsRender = (Get-Item $art).LastWriteTime -gt (Get-Item $idx).LastWriteTime
|
|
142
|
+
}
|
|
143
|
+
if ($needsRender) {
|
|
144
|
+
Add-Content -Path (Join-Path $logDir 'daily-task.log') -Value `
|
|
145
|
+
('===== {0} [{1}] {2} pipeline exit {3}, retrying HTML only =====' -f `
|
|
146
|
+
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $mode, $targetDay, $proc.ExitCode) -Encoding UTF8
|
|
147
|
+
$retryProc = Start-Process -FilePath $python `
|
|
148
|
+
-ArgumentList @($htmlScript, '--date', $targetDay) `
|
|
149
|
+
-NoNewWindow -Wait -PassThru `
|
|
150
|
+
-RedirectStandardOutput (Join-Path $logDir ('html-retry-{0}{1}.log' -f $stamp, $suffix)) `
|
|
151
|
+
-RedirectStandardError (Join-Path $logDir ('html-retry-{0}{1}.err.log' -f $stamp, $suffix))
|
|
152
|
+
if ($retryProc.ExitCode -eq 0) {
|
|
153
|
+
$proc = $retryProc
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
|
|
123
159
|
# Marker for the noon pass only; the day pass is judged by generated_at above.
|
|
124
160
|
if ($mode -eq 'noon') {
|
|
@@ -60,24 +60,38 @@ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances
|
|
|
60
60
|
$psExe = 'powershell.exe'
|
|
61
61
|
$common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
|
|
62
62
|
|
|
63
|
+
# Launch through wscript.exe, not powershell.exe directly.
|
|
64
|
+
#
|
|
65
|
+
# A scheduled action that runs powershell.exe is a *console* program: Windows creates the console
|
|
66
|
+
# window before PowerShell can hide it, so every run flashes a black box - and while a game is in
|
|
67
|
+
# the foreground that is a real interruption. wscript.exe is a GUI process with no console at all,
|
|
68
|
+
# and run_hidden.vbs starts PowerShell with window style 0, so nothing appears.
|
|
69
|
+
$wscript = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
|
70
|
+
$vbs = Join-Path $PSScriptRoot 'run_hidden.vbs'
|
|
71
|
+
if (-not (Test-Path $vbs)) {
|
|
72
|
+
Write-Host "Missing launcher: $vbs" -ForegroundColor Red
|
|
73
|
+
exit 1
|
|
74
|
+
}
|
|
75
|
+
$action = '//B //Nologo "' + $vbs + '"'
|
|
76
|
+
|
|
63
77
|
# End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
|
|
64
78
|
# 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
|
|
65
79
|
# no run record), so "it will catch up by itself" is not something to rely on.
|
|
66
80
|
Register-ScheduledTask -TaskName 'WeFlow Daily' `
|
|
67
|
-
-Action (New-ScheduledTaskAction -Execute $
|
|
81
|
+
-Action (New-ScheduledTaskAction -Execute $wscript -Argument $action) `
|
|
68
82
|
-Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
|
|
69
83
|
(New-ScheduledTaskTrigger -AtLogOn)) `
|
|
70
84
|
-Settings $settings `
|
|
71
|
-
-Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent)' -Force | Out-Null
|
|
85
|
+
-Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent, no window)' -Force | Out-Null
|
|
72
86
|
|
|
73
87
|
# Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
|
|
74
88
|
# very thin "noon" report and write the .noon_done marker, which would then make the real
|
|
75
89
|
# 13:00 run skip itself (the guard keys on that marker).
|
|
76
90
|
Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
|
|
77
|
-
-Action (New-ScheduledTaskAction -Execute $
|
|
91
|
+
-Action (New-ScheduledTaskAction -Execute $wscript -Argument ($action + ' noon')) `
|
|
78
92
|
-Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
|
|
79
93
|
-Settings $settings `
|
|
80
|
-
-Description 'WeChat daily report for today so far (13:00)' -Force | Out-Null
|
|
94
|
+
-Description 'WeChat daily report for today so far (13:00, no window)' -Force | Out-Null
|
|
81
95
|
|
|
82
96
|
foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
|
|
83
97
|
$t = Get-ScheduledTask -TaskName $name
|
package/scripts/leads.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""线索雷达:把聊天里"可能是生意"的对话挑出来,并指回原话。
|
|
2
|
+
|
|
3
|
+
为什么它值得存在:微信里最容易丢的不是重要消息,是**混在闲聊里的机会** —— 有人问价、
|
|
4
|
+
有人问能不能做、有人留了联系方式,然后被下一条消息压过去。它和 `promises` 用同一个骨架:
|
|
5
|
+
规则先捞候选、再到同会话的后续对话里找"有没有跟上",**每条结论都带原话**,不做黑箱判断。
|
|
6
|
+
|
|
7
|
+
默认不调模型。信号分五类(联系方式 / 报价 / 采购 / 合作 / 需求),按命中类型加权排序,
|
|
8
|
+
高分的往往是"问了价 + 留了联系方式"这种最该马上回的组合。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python scripts/leads.py --days 30
|
|
12
|
+
python scripts/leads.py --days 90 --min-score 3
|
|
13
|
+
python scripts/leads.py --days 30 --json
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from datetime import datetime, timezone, timedelta
|
|
24
|
+
|
|
25
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
26
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
27
|
+
|
|
28
|
+
from _utils import load_config, decrypt_lock # noqa: E402
|
|
29
|
+
from promises import load_messages, same_account # noqa: E402
|
|
30
|
+
import nt_decrypt as nd # noqa: E402
|
|
31
|
+
|
|
32
|
+
TZ = timezone(timedelta(hours=8))
|
|
33
|
+
|
|
34
|
+
#: 信号类型 → (正则, 权重)。权重表示"这条信号有多像生意",不是"多重要"。
|
|
35
|
+
SIGNALS = {
|
|
36
|
+
'联系方式': (re.compile(r'(微信|vx|wx|威信|手机号|电话|联系我|加我)|1[3-9]\d{9}'
|
|
37
|
+
r'|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', re.I), 3),
|
|
38
|
+
'报价': (re.compile(r'(多少钱|报价|预算|费用|价格|单价|收费|怎么算钱|贵不贵)'), 3),
|
|
39
|
+
'采购': (re.compile(r'(采购|下单|合同|发票|订购|样品|打款|付款)'), 2),
|
|
40
|
+
'合作': (re.compile(r'(合作|对接|渠道|代理|分销|外包|接单|项目合作)'), 2),
|
|
41
|
+
'需求': (re.compile(r'(需要.{0,8}(做|开发|方案|系统|服务|设计)|能不能做|可以做吗'
|
|
42
|
+
r'|有没有.{0,4}(做|卖|提供)|求推荐|想找.{0,6}(做|人))'), 2),
|
|
43
|
+
}
|
|
44
|
+
#: 我跟上了的迹象(在同一条线索之后的同会话消息里找)
|
|
45
|
+
FOLLOWED = re.compile(r'(已加|加了|私聊|发你了|发您了|发过去|安排|稍后|好的.{0,4}(我|马上)|收到)')
|
|
46
|
+
#: 已经明确黄掉的迹象
|
|
47
|
+
DEAD = re.compile(r'(太贵|不考虑|算了|暂时不需要|下次吧|再看看)')
|
|
48
|
+
|
|
49
|
+
MERGE_WINDOW = 3600 # 同一会话里这一秒数内的信号合成一条线索
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: 广告 / 招聘 / 促销:这些恰好塞满"合作、采购、报价、联系方式"这些词,但**不是线索**。
|
|
53
|
+
#: 2026-10-23 第一版没滤它们,30 天里 8 条"线索"全是校招宣讲和电商促销,没有一条真生意。
|
|
54
|
+
NOISE = re.compile(
|
|
55
|
+
r'(招聘|校招|秋招|春招|宣讲会|双选会|简历|offer|求职|面试|直播|预告'
|
|
56
|
+
r'|优惠|券|折扣|秒杀|包邮|抽奖|福利|返现|下单链接|u\.jd\.com|拼团|预售|凑单)', re.I)
|
|
57
|
+
#: 真线索几乎都带**请求或提问**。纯陈述句(广告、通知)不进来。
|
|
58
|
+
ASK = re.compile(r'(吗|呢|?|\?|能否|可以吗|可不可以|麻烦|请问|想找|有没有|多少钱|怎么收费|求推荐)')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _score(text):
|
|
62
|
+
"""(信号类型, 分数)。**先过噪点与提问两道闸,再算信号分。**
|
|
63
|
+
|
|
64
|
+
匹配前先剥掉 `@昵称`:群昵称里常带手机号(`@某人13800000000`),不剥的话每一句
|
|
65
|
+
亲戚群闲聊都会被判成"留了联系方式"(2026-10-23 实测,误报全来自这里)。
|
|
66
|
+
"""
|
|
67
|
+
bare = re.sub(r'@[^\s]{1,40}\s*', '', text)
|
|
68
|
+
# URL 里的长数字串会命中手机号规则(`?id=4713032079053994316` 这类),先剥链接。
|
|
69
|
+
bare = re.sub(r'https?://\S+', '', bare)
|
|
70
|
+
if len(bare) > 300 or NOISE.search(bare) or not ASK.search(bare):
|
|
71
|
+
return [], 0
|
|
72
|
+
hit = []
|
|
73
|
+
score = 0
|
|
74
|
+
for name, (rx, weight) in SIGNALS.items():
|
|
75
|
+
if rx.search(bare):
|
|
76
|
+
hit.append(name)
|
|
77
|
+
score += weight
|
|
78
|
+
return hit, score
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
ap = argparse.ArgumentParser(description='线索雷达:聊天里的报价/合作/需求信号')
|
|
83
|
+
ap.add_argument('--days', type=int, default=30)
|
|
84
|
+
ap.add_argument('--min-score', type=int, default=2, help='最低分(默认 2)')
|
|
85
|
+
ap.add_argument('--limit', type=int, default=20)
|
|
86
|
+
ap.add_argument('--json', action='store_true')
|
|
87
|
+
args = ap.parse_args()
|
|
88
|
+
|
|
89
|
+
cfg = load_config()
|
|
90
|
+
db = cfg.get('ntDbPath') or ''
|
|
91
|
+
my_wxid = cfg.get('wxid') or ''
|
|
92
|
+
if not db:
|
|
93
|
+
raise SystemExit('配置里没有 ntDbPath')
|
|
94
|
+
key = decrypt_lock(cfg.get('ntKey', '') or '')
|
|
95
|
+
salt = decrypt_lock(cfg.get('ntSalt', '') or '')
|
|
96
|
+
pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
|
|
97
|
+
conns = nd.connect_message_shards(db, key, salt, pw)
|
|
98
|
+
if not conns:
|
|
99
|
+
raise SystemExit('打不开消息分片')
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
by_talker = load_messages(conns, args.days, my_wxid)
|
|
103
|
+
name_map = {}
|
|
104
|
+
try:
|
|
105
|
+
from network import load_graph
|
|
106
|
+
people, _rooms, _members = load_graph(cfg)
|
|
107
|
+
name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
|
|
108
|
+
for p in people.values()}
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
print(f'[WARN] 读联系人失败({type(exc).__name__}),显示 id', file=sys.stderr)
|
|
111
|
+
finally:
|
|
112
|
+
for conn in conns:
|
|
113
|
+
try:
|
|
114
|
+
conn.close()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
leads = []
|
|
119
|
+
for talker, msgs in by_talker.items():
|
|
120
|
+
hits = []
|
|
121
|
+
for i, (ts, is_me, who, text) in enumerate(msgs):
|
|
122
|
+
if is_me:
|
|
123
|
+
continue # 线索来自别人说的,不是我自己发的
|
|
124
|
+
kinds, score = _score(text)
|
|
125
|
+
if score < args.min_score:
|
|
126
|
+
continue
|
|
127
|
+
hits.append({'i': i, 'ts': ts, 'text': text, 'kinds': kinds, 'score': score})
|
|
128
|
+
|
|
129
|
+
# 同一会话里时间挨近的信号合成一条线索,避免"问价"和"留手机号"被拆成两条。
|
|
130
|
+
merged = []
|
|
131
|
+
for h in hits:
|
|
132
|
+
if merged and h['ts'] - merged[-1]['last'] <= MERGE_WINDOW:
|
|
133
|
+
cur = merged[-1]
|
|
134
|
+
cur['last'] = h['ts']
|
|
135
|
+
cur['score'] = max(cur['score'], h['score']) + 1
|
|
136
|
+
cur['kinds'] = sorted(set(cur['kinds']) | set(h['kinds']))
|
|
137
|
+
if h['text'] not in cur['parts']:
|
|
138
|
+
cur['parts'].append(h['text'])
|
|
139
|
+
cur['idx'].append(h['i'])
|
|
140
|
+
else:
|
|
141
|
+
merged.append({'first': h['ts'], 'last': h['ts'], 'score': h['score'],
|
|
142
|
+
'kinds': list(h['kinds']), 'parts': [h['text']],
|
|
143
|
+
'idx': [h['i']]})
|
|
144
|
+
|
|
145
|
+
for m in merged:
|
|
146
|
+
after = msgs[m['idx'][-1] + 1: m['idx'][-1] + 25]
|
|
147
|
+
replied = any(is_me for _, is_me, _w, _t in after)
|
|
148
|
+
followed = any(is_me and FOLLOWED.search(t) for _, is_me, _w, t in after)
|
|
149
|
+
dead = any(DEAD.search(t) for _, _me, _w, t in after)
|
|
150
|
+
state = ('已结束' if dead else '已跟进' if followed else '我回过' if replied else '没回')
|
|
151
|
+
leads.append({
|
|
152
|
+
'time': datetime.fromtimestamp(m['first'], tz=TZ).strftime('%Y-%m-%d %H:%M'),
|
|
153
|
+
'talker': name_map.get(talker, talker),
|
|
154
|
+
'kinds': m['kinds'],
|
|
155
|
+
'score': m['score'],
|
|
156
|
+
'said': ' / '.join(p[:80] for p in m['parts'][:3]),
|
|
157
|
+
'state': state,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
leads.sort(key=lambda x: (-x['score'], x['time']), reverse=False)
|
|
161
|
+
leads = leads[:args.limit]
|
|
162
|
+
|
|
163
|
+
if args.json:
|
|
164
|
+
print(json.dumps({'success': True, 'days': args.days, 'leads': leads},
|
|
165
|
+
ensure_ascii=False, indent=2))
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
if not leads:
|
|
169
|
+
print(f'最近 {args.days} 天里没有达到分数 {args.min_score} 的线索信号')
|
|
170
|
+
return
|
|
171
|
+
print(f'最近 {args.days} 天的线索信号 {len(leads)} 条(分数越高越像生意):\n')
|
|
172
|
+
for it in leads:
|
|
173
|
+
print(f"[{it['score']}分 · {'/'.join(it['kinds'])} · {it['state']}] {it['time']} {it['talker']}")
|
|
174
|
+
print(f" {it['said']}")
|
|
175
|
+
print()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == '__main__':
|
|
179
|
+
main()
|