weflow-cli 1.15.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/OPERATIONS.md +16 -1
  3. package/README.en.md +21 -2
  4. package/README.md +34 -6
  5. package/bin/weflow-cli.ts +389 -6
  6. package/dist/bin/weflow-cli.js +422 -8
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +47 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +24 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/assistantService.d.ts +8 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +47 -8
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +8 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +67 -11
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/configService.d.ts.map +1 -1
  21. package/dist/src/services/configService.js +3 -2
  22. package/dist/src/services/configService.js.map +1 -1
  23. package/dist/src/services/cursorProbe.d.ts +44 -0
  24. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  25. package/dist/src/services/cursorProbe.js +183 -0
  26. package/dist/src/services/cursorProbe.js.map +1 -0
  27. package/dist/src/services/qchatImport.d.ts +89 -0
  28. package/dist/src/services/qchatImport.d.ts.map +1 -0
  29. package/dist/src/services/qchatImport.js +273 -0
  30. package/dist/src/services/qchatImport.js.map +1 -0
  31. package/dist/src/services/setupVenv.d.ts +43 -0
  32. package/dist/src/services/setupVenv.d.ts.map +1 -0
  33. package/dist/src/services/setupVenv.js +198 -0
  34. package/dist/src/services/setupVenv.js.map +1 -0
  35. package/dist/src/utils/python.d.ts +10 -0
  36. package/dist/src/utils/python.d.ts.map +1 -1
  37. package/dist/src/utils/python.js +27 -4
  38. package/dist/src/utils/python.js.map +1 -1
  39. package/docs/DATA_CONTRACT.md +25 -0
  40. package/docs/DECISIONS.md +92 -30
  41. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  42. package/docs/HEALTH-CHECK.md +104 -104
  43. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  44. package/docs/NEXT_ACTIONS.md +236 -236
  45. package/docs/NPM-PUBLISH.md +130 -130
  46. package/docs/POSITIONING.md +68 -68
  47. package/docs/PROJECT_STATE.md +6 -3
  48. package/docs/REPLAY.md +169 -0
  49. package/docs/SYNC_CONTRACT.md +197 -180
  50. package/package.json +89 -88
  51. package/requirements-3x.txt +4 -4
  52. package/requirements-voice.txt +15 -15
  53. package/resources/js/graph3d/NOTICE.txt +35 -35
  54. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  55. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  56. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  57. package/resources/js/graph3d/d3-octree.min.js +2 -2
  58. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  59. package/resources/js/graph3d/d3-timer.min.js +2 -2
  60. package/resources/js/graph3d/three.min.js +6 -6
  61. package/resources/js/graph3d/viewer2d.js +550 -448
  62. package/resources/panel/quick-menu.cjs +197 -197
  63. package/scripts/_utils.py +1338 -1338
  64. package/scripts/article_notes.py +423 -423
  65. package/scripts/bonds.py +11 -0
  66. package/scripts/chat_notes.py +86 -3
  67. package/scripts/check_readme_commands.py +427 -426
  68. package/scripts/contact_schema.py +339 -339
  69. package/scripts/create_reading_notes.py +11 -11
  70. package/scripts/daily_task.ps1 +213 -177
  71. package/scripts/fav_notes.py +321 -321
  72. package/scripts/graph_2d.py +156 -151
  73. package/scripts/graph_3d.py +672 -646
  74. package/scripts/health_check.py +473 -473
  75. package/scripts/imported_chats.py +68 -0
  76. package/scripts/install_tasks.ps1 +105 -91
  77. package/scripts/leads.py +179 -0
  78. package/scripts/network.py +206 -206
  79. package/scripts/nt_decrypt.py +135 -1
  80. package/scripts/on_this_day.py +178 -178
  81. package/scripts/opinion_timeline.py +138 -138
  82. package/scripts/promises.py +185 -185
  83. package/scripts/replay.py +803 -0
  84. package/scripts/run_hidden.vbs +25 -0
  85. package/scripts/semantic_search.py +73 -5
  86. package/scripts/user_notes.py +315 -315
  87. package/scripts/voice_transcribe.py +139 -139
  88. package/scripts/watch_issues.py +212 -212
  89. package/scripts/watch_mail.py +131 -131
  90. package/src/core/ntCore.ts +59 -0
  91. package/src/services/assistantService.ts +48 -8
  92. package/src/services/assistantTools.ts +73 -10
  93. package/src/services/configService.ts +3 -2
  94. package/src/services/cursorProbe.ts +222 -0
  95. package/src/services/qchatImport.ts +327 -0
  96. package/src/services/setupVenv.ts +238 -0
  97. package/src/services/syncService.ts +363 -363
  98. package/src/services/syncState.ts +275 -275
  99. package/src/services/wechatMedia.ts +166 -166
  100. package/src/utils/python.ts +33 -4
@@ -0,0 +1,68 @@
1
+ # -*- coding: utf-8 -*-
2
+ """读 `weflow-cli import qchat` 落下的存储(output/imports/qq/sessions/)。
3
+
4
+ **单一读者**:chat_notes 与 semantic_search 都从这里取导入会话。两边各解析一遍格式,
5
+ 漂了就是"知识卡看得见、搜索看不见"这类静默分叉——`nt_common` 存在的同款理由。
6
+
7
+ 存储形状(写入方是 `src/services/qchatImport.ts`,字段语义对齐 weflow-message/v1):
8
+ 每个会话一个 JSON:schema/session{id,name,type}/importedAt/sourceFiles/messages[],
9
+ 消息字段 serverId(=qchat messageId,全局唯一)/createTime(unix 秒)/isSend(可缺省=
10
+ 未知)/senderUsername(QQ 号,敏感)/senderName/messageType(13 稳定名)/parsedContent/
11
+ rawContent。
12
+
13
+ 路径:默认 `output/imports/qq/sessions`(cwd 相对,与 chat_notes 的 OUTPUT_ROOT 同一
14
+ 约定);`WEFLOW_IMPORT_ROOT` 指到别处时用 `<root>/sessions`(测试用,TS 写入方读同
15
+ 一个变量)。
16
+ """
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+
21
+ SCHEMA = 'weflow-import/qq-v1'
22
+
23
+
24
+ def sessions_dir(root=None):
25
+ if root is None:
26
+ root = os.environ.get('WEFLOW_IMPORT_ROOT')
27
+ if root:
28
+ return Path(root) / 'sessions'
29
+ return Path('output/imports/qq/sessions')
30
+
31
+
32
+ def sessions(root=None):
33
+ """全部已导入会话(按导入时间新→旧)。坏文件/非本 schema 的文件跳过不挡路。
34
+
35
+ 返回的是存储原文(dict),消息已按 createTime 升序。
36
+ """
37
+ d = sessions_dir(root)
38
+ if not d.is_dir():
39
+ return []
40
+ out = []
41
+ for p in sorted(d.glob('*.json')):
42
+ try:
43
+ data = json.loads(p.read_text(encoding='utf-8'))
44
+ except Exception:
45
+ continue
46
+ if not isinstance(data, dict):
47
+ continue
48
+ if data.get('schema') != SCHEMA or not isinstance(data.get('messages'), list):
49
+ continue
50
+ out.append(data)
51
+ out.sort(key=lambda s: s.get('importedAt') or '', reverse=True)
52
+ return out
53
+
54
+
55
+ def session_messages(sess, days=None, now=None):
56
+ """一个会话的消息,可选按"最近 days 天"过滤。
57
+
58
+ 时间语义与 chat_notes/semantic_search 的窗口一致:unix 秒、含当天。
59
+ """
60
+ from datetime import datetime, timedelta, timezone
61
+
62
+ msgs = [m for m in sess.get('messages', []) if isinstance(m, dict)]
63
+ if days is None:
64
+ return msgs
65
+ tz = timezone(timedelta(hours=8))
66
+ now = now or datetime.now(tz)
67
+ cutoff = int((now - timedelta(days=days)).timestamp())
68
+ return [m for m in msgs if (m.get('createTime') or 0) >= cutoff]
@@ -1,91 +1,105 @@
1
- # Register / update the two scheduled tasks for the official-account daily report.
2
- # Idempotent: running it again overwrites the definitions with the same values.
3
- #
4
- # Why this cannot always be done for you: writing to Task Scheduler is refused in a
5
- # restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
6
- # an elevated Administrator token, all three write paths said "Access is denied" while
7
- # reads kept working:
8
- # schtasks /create ... -> Access is denied
9
- # (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
10
- # New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
11
- # So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
12
- # by "run as administrator". The one reliable route is a terminal the sandbox does not
13
- # supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
14
- # this file from your own PowerShell window.
15
- #
16
- # Usage:
17
- # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
18
- # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
19
- #
20
- # The two tasks:
21
- # WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
22
- # WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
23
- #
24
- # Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
25
- # then lines up with the date directory the run writes into, and it does not collide with
26
- # other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
27
- # moment that has the complete day.
28
- #
29
- # This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
30
- # the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
31
- param(
32
- [switch]$Uninstall
33
- )
34
-
35
- $ErrorActionPreference = 'Stop'
36
-
37
- $repo = Split-Path -Parent $PSScriptRoot
38
- $script = Join-Path $repo 'scripts\daily_task.ps1'
39
- if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
40
-
41
- $isAdmin = (New-Object Security.Principal.WindowsPrincipal(
42
- [Security.Principal.WindowsIdentity]::GetCurrent())
43
- ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
44
- if (-not $isAdmin) {
45
- Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
46
- exit 1
47
- }
48
-
49
- if ($Uninstall) {
50
- foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
51
- if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
52
- Unregister-ScheduledTask -TaskName $name -Confirm:$false
53
- Write-Host "removed: $name"
54
- }
55
- }
56
- exit 0
57
- }
58
-
59
- $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
60
- $psExe = 'powershell.exe'
61
- $common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
62
-
63
- # End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
64
- # 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
65
- # no run record), so "it will catch up by itself" is not something to rely on.
66
- Register-ScheduledTask -TaskName 'WeFlow Daily' `
67
- -Action (New-ScheduledTaskAction -Execute $psExe -Argument $common) `
68
- -Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
69
- (New-ScheduledTaskTrigger -AtLogOn)) `
70
- -Settings $settings `
71
- -Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent)' -Force | Out-Null
72
-
73
- # Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
74
- # very thin "noon" report and write the .noon_done marker, which would then make the real
75
- # 13:00 run skip itself (the guard keys on that marker).
76
- Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
77
- -Action (New-ScheduledTaskAction -Execute $psExe -Argument ($common + ' noon')) `
78
- -Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
79
- -Settings $settings `
80
- -Description 'WeChat daily report for today so far (13:00)' -Force | Out-Null
81
-
82
- foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
83
- $t = Get-ScheduledTask -TaskName $name
84
- $i = Get-ScheduledTaskInfo -TaskName $name
85
- Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
86
- }
87
- Write-Host ''
88
- Write-Host 'Run one now instead of waiting:'
89
- Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
90
- Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
91
- Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'
1
+ # Register / update the two scheduled tasks for the official-account daily report.
2
+ # Idempotent: running it again overwrites the definitions with the same values.
3
+ #
4
+ # Why this cannot always be done for you: writing to Task Scheduler is refused in a
5
+ # restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
6
+ # an elevated Administrator token, all three write paths said "Access is denied" while
7
+ # reads kept working:
8
+ # schtasks /create ... -> Access is denied
9
+ # (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
10
+ # New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
11
+ # So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
12
+ # by "run as administrator". The one reliable route is a terminal the sandbox does not
13
+ # supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
14
+ # this file from your own PowerShell window.
15
+ #
16
+ # Usage:
17
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
18
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
19
+ #
20
+ # The two tasks:
21
+ # WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
22
+ # WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
23
+ #
24
+ # Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
25
+ # then lines up with the date directory the run writes into, and it does not collide with
26
+ # other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
27
+ # moment that has the complete day.
28
+ #
29
+ # This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
30
+ # the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
31
+ param(
32
+ [switch]$Uninstall
33
+ )
34
+
35
+ $ErrorActionPreference = 'Stop'
36
+
37
+ $repo = Split-Path -Parent $PSScriptRoot
38
+ $script = Join-Path $repo 'scripts\daily_task.ps1'
39
+ if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
40
+
41
+ $isAdmin = (New-Object Security.Principal.WindowsPrincipal(
42
+ [Security.Principal.WindowsIdentity]::GetCurrent())
43
+ ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
44
+ if (-not $isAdmin) {
45
+ Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
46
+ exit 1
47
+ }
48
+
49
+ if ($Uninstall) {
50
+ foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
51
+ if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
52
+ Unregister-ScheduledTask -TaskName $name -Confirm:$false
53
+ Write-Host "removed: $name"
54
+ }
55
+ }
56
+ exit 0
57
+ }
58
+
59
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
60
+ $psExe = 'powershell.exe'
61
+ $common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
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
+
77
+ # End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
78
+ # 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
79
+ # no run record), so "it will catch up by itself" is not something to rely on.
80
+ Register-ScheduledTask -TaskName 'WeFlow Daily' `
81
+ -Action (New-ScheduledTaskAction -Execute $wscript -Argument $action) `
82
+ -Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
83
+ (New-ScheduledTaskTrigger -AtLogOn)) `
84
+ -Settings $settings `
85
+ -Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent, no window)' -Force | Out-Null
86
+
87
+ # Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
88
+ # very thin "noon" report and write the .noon_done marker, which would then make the real
89
+ # 13:00 run skip itself (the guard keys on that marker).
90
+ Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
91
+ -Action (New-ScheduledTaskAction -Execute $wscript -Argument ($action + ' noon')) `
92
+ -Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
93
+ -Settings $settings `
94
+ -Description 'WeChat daily report for today so far (13:00, no window)' -Force | Out-Null
95
+
96
+ foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
97
+ $t = Get-ScheduledTask -TaskName $name
98
+ $i = Get-ScheduledTaskInfo -TaskName $name
99
+ Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
100
+ }
101
+ Write-Host ''
102
+ Write-Host 'Run one now instead of waiting:'
103
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
104
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
105
+ Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'
@@ -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()