dsh-douyin-browser-local 1.3.1 → 1.4.3
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 +48 -0
- package/README.md +132 -126
- package/api-agent.mjs +16 -10
- package/assets/PROVENANCE.json +8 -0
- package/assets/brand.ico +0 -0
- package/assets/brand.png +0 -0
- package/autonomous-watch.mjs +22 -6
- package/browser-launcher.mjs +8 -0
- package/chat-agent.mjs +2 -1
- package/client.js +179 -157
- package/core.mjs +632 -627
- package/diagnostics.mjs +161 -0
- package/docs/features-1.4.0.md +34 -0
- package/docs/features-1.4.1.md +40 -0
- package/docs/features-1.4.2.md +11 -0
- package/docs/features-1.4.3.md +13 -0
- package/docs/implementation-1.4.0.md +13 -0
- package/docs/next-features.md +37 -0
- package/docs/plugin.md +159 -141
- package/docs/publication.md +9 -0
- package/docs/standalone-api.md +131 -129
- package/docs/verification/local-1.4.0-20261023.md +17 -0
- package/docs/verification/local-1.4.1-20261023.md +29 -0
- package/docs/verification/local-1.4.2-20261023.md +13 -0
- package/docs/verification/local-1.4.3-20261023.md +26 -0
- package/docs/verification/plugin-local-1.3.2-20261023.md +34 -0
- package/docs/verification/settings-compatibility-20261023.md +45 -0
- package/dsh-plugin.mjs +294 -267
- package/interaction-status.mjs +9 -0
- package/native-settings-transport.mjs +96 -0
- package/native-settings.mjs +108 -89
- package/native-watch-decision.mjs +2 -2
- package/package.json +15 -3
- package/public-comment-agent.mjs +2 -1
- package/scheduled-watch.mjs +36 -0
- package/settings-sections.mjs +10 -0
- package/skill/SKILL.md +48 -38
- package/spark-agent.mjs +3 -2
- package/task-limits.mjs +72 -0
- package/tool-definitions.mjs +5 -2
- package/usage-ledger.mjs +49 -0
- package/video-limits.mjs +16 -12
- package/watch-mode.mjs +59 -10
- package/watch-options.mjs +23 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
## 1.4.3(2026-10-23)
|
|
4
|
+
|
|
5
|
+
- 自检增加专用浏览器连接、登录提示、真实窗口最小化、媒体静音、调度和互动状态。
|
|
6
|
+
- 报告包含错误码、处理步骤、报告编号、有效期和允许的修复动作,不包含私人内容。
|
|
7
|
+
- 新增 DSH 诊断及修复工具,设置页和软件提供相同修复按钮及可复制报告。
|
|
8
|
+
- 仅修复已开启静默但未生效的问题;执行前后重新检查,不改许可、记忆、Key、预算,不启动服务或发送。
|
|
9
|
+
|
|
10
|
+
使用说明见 docs/features-1.4.3.md。此公开版本也包含下面1.3.2至1.4.2的本机候选更新;这些中间版本未单独公开发布。
|
|
11
|
+
|
|
12
|
+
## 1.4.2(本地更新,2026-10-23,尚未发布 npm)
|
|
13
|
+
|
|
14
|
+
- 修复静默设置无法应用到已打开专用浏览器、仅有私信页或准备页时不能最小化的问题。
|
|
15
|
+
- 手动视频操作也应用静默最小化,界面显示实际应用结果及阻止原因。
|
|
16
|
+
- 打开准备入口复用唯一初始空白页,避免遗留不受管空标签。
|
|
17
|
+
- 软件左栏将“对话与视频”移到“业务设置”上方。
|
|
18
|
+
|
|
19
|
+
使用方式与验证边界见 docs/features-1.4.2.md。
|
|
20
|
+
|
|
21
|
+
## 1.4.1(本地更新,2026-10-23,尚未发布 npm)
|
|
22
|
+
|
|
23
|
+
- 软件与插件新增每个允许时段自动开始一轮,共享调度许可和一次性时段回执。
|
|
24
|
+
- 好友互动显示各服务是否运行,以及私信联系人暂停情况。
|
|
25
|
+
- 软件新增GitHub正式版本检查、下载校验与更新重启入口。
|
|
26
|
+
- 修复软件包和左上角图标,移除左栏与设置分类重复的导航。
|
|
27
|
+
|
|
28
|
+
使用条件、更新包要求和复用记录见 docs/features-1.4.1.md。
|
|
29
|
+
|
|
30
|
+
## 1.4.0(本地更新,2026-10-23,尚未发布 npm)
|
|
31
|
+
|
|
32
|
+
- 插件和软件新增一键自检、共用用量看板及主题观看设置。
|
|
33
|
+
- 单轮时长可选择不限,有限值上限扩大到1,000,000分钟;自主请求和手动任务预算上限同步扩大。
|
|
34
|
+
- 新增DeepSeek谷时段、全天和晚间快捷选择,支持周末全天。
|
|
35
|
+
- 保留旧设置及共享记忆,保存较长规则不会延长旧轮次。
|
|
36
|
+
|
|
37
|
+
使用方式、计数范围和停止行为见 docs/features-1.4.0.md。
|
|
38
|
+
|
|
39
|
+
## 1.3.2(候选版,2026-10-23,尚未发布 npm)
|
|
40
|
+
|
|
41
|
+
- 修复部分 DSH 桌面版进入设置后提示“没有可用的同源设置服务”、全部操作不可用的问题。
|
|
42
|
+
- 支持常用桌面协议、本机 HTTPS 和宿主 RPC,后台同时适配 Connection 的设置路由,保留旧 WebServer 入口。
|
|
43
|
+
- 连接失败后仍可刷新,恢复成功后清除旧错误。
|
|
44
|
+
- 继续校验同源、宿主认证及写入令牌;写操作失败不自动重放。
|
|
45
|
+
|
|
46
|
+
软件版同步到 1.3.2:打包共享领域模块,补齐静默观看、公开评论许可及观看日报界面;断连后可手动刷新,连接恢复会清除旧连接错误。软件仍独立使用用户 API Key,沿用与插件共用的人格、记忆和许可设置。软件包的检查范围见 desktop/VERIFICATION.md。
|
|
47
|
+
|
|
48
|
+
升级后请完全退出并重开 DSH。不要删除人格、记忆、登录目录或重填 API Key。各第三方宿主的兼容程度以实测为准;记录见 docs/verification/settings-compatibility-20261023.md。
|
package/README.md
CHANGED
|
@@ -1,126 +1,132 @@
|
|
|
1
|
-
# DSH 抖音助手
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.\install-native.ps1 -DshRoot $dshInstallDirectory
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
|
92
|
-
|
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
| [
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
1
|
+
# DSH 抖音助手
|
|
2
|
+
|
|
3
|
+
1.4.3 增强自检与按报告修复,并汇总设置通信兼容、静默最小化、时段自动启动、用量看板和主题观看等更新。查看[功能说明](docs/features-1.4.3.md)或[下载 1.4.3 软件与插件包](https://github.com/LockyBee/dsh-douyin-agent/releases/tag/v1.4.3)。
|
|
4
|
+
|
|
5
|
+
让 DSH 里的 DeepSeek(大肥鱼)自己刷抖音:搜索、换片、看真实画面,把有意思的内容记下来,并在你许可的范围内分享给好友。
|
|
6
|
+
|
|
7
|
+
在专用 Edge 里浏览电脑网页版抖音,每隔几秒采样视频画面交给模型。所有外发功能默认关闭,每一项都有独立开关、次数上限和时间限制。这是第三方适配,不是 DeepSeek 官方出品。
|
|
8
|
+
|
|
9
|
+
## 能做什么
|
|
10
|
+
|
|
11
|
+
| 功能 | 说明 | 默认 |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| 自主观看 | 按人格和兴趣自己搜索、换片、继续看或停止,并给出理由;可配置每个允许时段自动开始一轮 | 自动启动关闭 |
|
|
14
|
+
| 兴趣与人格 | 兴趣来自实际观看进度和已确认行为,不是看到主题就算喜欢 | 随观看自动积累 |
|
|
15
|
+
| 时间限制 | 每日允许时段(北京时间)加单轮时长,到点视频真的暂停 | 单轮 10 分钟 |
|
|
16
|
+
| 静默观看 | 静音,核对窗口归属后尝试最小化(仍是有窗口的 Edge,不是 headless) | 关闭 |
|
|
17
|
+
| 观看日报 | 把当天实际看过的标题、作者、链接汇总成 Markdown,不调用模型 | 可手动生成,每轮观看结束后也会尝试生成 |
|
|
18
|
+
| 主动分享 | 通过官方分享界面把当前看过的作品发给许可好友,每天默认 3 次 | 关闭 |
|
|
19
|
+
| 自动问候 | 每位许可好友每天最多一次,合计默认 2 次,带「自动问候」标记 | 关闭 |
|
|
20
|
+
| 私信自动回复 | 只处理你确认的最多两位好友,只回复启动之后的新消息 | 关闭 |
|
|
21
|
+
| 公开评论 | 每天默认 3 条,每轮 1 条,同一作品只评论一次 | 关闭 |
|
|
22
|
+
| 音频理解 | 本地 Whisper 转写对白,可选 MiMo 或 MiniMax 云端 | 关闭 |
|
|
23
|
+
|
|
24
|
+
发送结果不确定时一律暂停,不会自动重发。
|
|
25
|
+
|
|
26
|
+
## 两种用法
|
|
27
|
+
|
|
28
|
+
| | 插件版 | 软件版 |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| 在哪用 | DSH 的「设置 → 刷抖音」,全部用按钮操作 | 独立的 Windows 桌面软件,对话式下任务 |
|
|
31
|
+
| 模型 | 用 DSH 里已配置的提供商 | 用你自己填的 DeepSeek API Key |
|
|
32
|
+
| 版本 | 1.4.3 | 1.4.3 Windows 便携包,见 [软件说明](desktop/README.md) |
|
|
33
|
+
|
|
34
|
+
两者共用同一份人格、兴趣和记忆,但同一时刻只会运行一个自主观看。
|
|
35
|
+
|
|
36
|
+
## 快速开始(插件版)
|
|
37
|
+
|
|
38
|
+
1.4.3 包含“没有可用的同源设置服务”兼容修复,适配常用桌面载体、Connection 设置路由和刷新恢复。更新后请完全退出并重开 DSH,无需删除记忆、登录目录或重填 Key。各第三方宿主仍以实际自检结果为准,见[验证记录](docs/verification/settings-compatibility-20261023.md)。
|
|
39
|
+
|
|
40
|
+
### npm 安装或更新(1.4.3)
|
|
41
|
+
|
|
42
|
+
DSH Desktop:打开“应用 → 桌面插件”,在 npm 包输入框填写 `dsh-douyin-browser-local@1.4.3`,点击安装或更新。Desktop profile 由 Electron 管理,不能用普通 CLI 修改。完成后完全退出并重开 DSH。
|
|
43
|
+
|
|
44
|
+
Web 版在终端运行:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
dsh plugin --profile web add dsh-douyin-browser-local@1.4.3
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
需要 Windows、Microsoft Edge 和 DSH 0.2.0-rc.2 至 0.2.x 兼容宿主。抖音登录和验证由使用者自行完成。npm、GitHub Release 和社区市场分别同步;市场未更新时可使用固定版本号安装。
|
|
51
|
+
|
|
52
|
+
### 从源码安装
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
需要 Windows、Microsoft Edge、Node.js 22.13 以上,以及 DSH 0.2.0-rc.2 系列(已在 DSH Desktop 2.0.17 上核对过设置页)。
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
git clone https://github.com/LockyBee/dsh-douyin-agent.git
|
|
59
|
+
cd dsh-douyin-agent
|
|
60
|
+
pnpm install --frozen-lockfile
|
|
61
|
+
$dshInstallDirectory = 'C:\Apps\DSH Desktop' # 替换为正在使用的 DSH 安装目录
|
|
62
|
+
.\install-native.ps1 -DshRoot $dshInstallDirectory
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
第一条命令安装依赖;不带 `-Install` 的脚本只做检查,不改配置也不开窗口。确认无误后完全退出 DSH,再执行:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
.\install-native.ps1 -DshRoot $dshInstallDirectory -Install
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
重新打开 DSH,在「设置」里找到「刷抖音」,按这个顺序用:
|
|
72
|
+
|
|
73
|
+
1. 基础设置:选提供商和模型(模型必须支持图片),保存。
|
|
74
|
+
2. 视频观看:点「打开抖音」,在专用 Edge 里自己登录或过验证。
|
|
75
|
+
3. 设置允许时段和单轮时长,点「开始有限轮次」,再点「开始自主观看」。
|
|
76
|
+
|
|
77
|
+
安装细节和本机现有入口的说明见 [插件说明](docs/plugin.md)。
|
|
78
|
+
|
|
79
|
+
## 当前验证状态
|
|
80
|
+
|
|
81
|
+
| 状态 | 内容 |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| 真实环境验证过 | 推荐播放、搜索、上下切换、暂停、画面采样、限时到点暂停;旧版 DSH 原生工具调用;表情图片采样;一次人工授权的好友转发;DSH 2.0.17 中设置页挂载并读回状态 |
|
|
84
|
+
| 通过专项或合成测试 | 自主分享、行为偏好、自动问候、自主评论、静默修复及日报;不代表全部真实外发已验证 |
|
|
85
|
+
| 还没验证 | 抖音最小化后的持续播放、真实评论区、收到新私信后的完整自动回复、MiMo 与 MiniMax 云端、标准 bundle 安装 |
|
|
86
|
+
|
|
87
|
+
合成测试用的是替身模型和临时页面,不能当作真实抖音上能用的证据。1.4.3 的专项检查、实机自检、软件包验收及完整回归首轮失败记录见[本版验证记录](docs/verification/local-1.4.3-20261023.md);[v10 记录](docs/verification/native-v10.md)仅对应旧版本。
|
|
88
|
+
|
|
89
|
+
## 目录
|
|
90
|
+
|
|
91
|
+
| 位置 | 内容 |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `dsh-plugin.mjs`、`client.js`、`cordis.bundle.yml` | 原生插件入口、设置页前端、标准 bundle 声明 |
|
|
94
|
+
| `tool-definitions.mjs` | 34 项工具目录,插件和软件共用 |
|
|
95
|
+
| `native-*.mjs` | 设置页后端、模型路由、人格注入、问候与观看决策 |
|
|
96
|
+
| `core.mjs`、`browser-*.mjs` | 专用 Edge 的控制、采样、限时 |
|
|
97
|
+
| `video-limits.mjs`、`task-limits.mjs`、`watch-mode.mjs` | 时段与轮次、任务预算、静默 |
|
|
98
|
+
| `autonomous-*.mjs`、`spark-*.mjs`、`daily-report.mjs` | 自主观看、分享、评论、问候、日报 |
|
|
99
|
+
| `chat-*.mjs`、`message-tracker.mjs`、`outbound-send.mjs` | 私信自动回复和统一的发送账本 |
|
|
100
|
+
| `memory.mjs`、`persona-memory.mjs`、`qq-sync.mjs` | 共享记忆、人格卡、QQ 侧同步 |
|
|
101
|
+
| `app.mjs`、`api-agent.mjs`、`public/`、`start-api.*` | 旧的本机 HTTP 控制台(`http://127.0.0.1:3218`) |
|
|
102
|
+
| `desktop/` | Electron 桌面软件的源码、打包脚本和上游许可证 |
|
|
103
|
+
| `media/` | 可选音频模块,内置三份社区项目源码 |
|
|
104
|
+
| `tests/`、`verification/` | 单元测试,以及实机验收脚本 |
|
|
105
|
+
| `skill/SKILL.md` | 给模型看的使用说明 |
|
|
106
|
+
|
|
107
|
+
## 文档
|
|
108
|
+
|
|
109
|
+
| 文档 | 内容 |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| [插件说明](docs/plugin.md) | 插件安装、设置页逐项说明、预算、各项外发的规则 |
|
|
112
|
+
| [独立软件说明](docs/standalone-api.md) | 独立 API 控制台和软件版的配置 |
|
|
113
|
+
| [详细说明](docs/details.md) | 原 README 全文,按时间累积,内容多 |
|
|
114
|
+
| [功能复用与来源](docs/reuse.md) | 参考和复用的开源项目,以及边界 |
|
|
115
|
+
| [docs/verification/](docs/verification) | 各版本的测试记录 |
|
|
116
|
+
| [desktop/README.md](desktop/README.md) | 桌面软件的构建和使用 |
|
|
117
|
+
| [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) | 第三方许可证 |
|
|
118
|
+
|
|
119
|
+
## 开发
|
|
120
|
+
|
|
121
|
+
```powershell
|
|
122
|
+
pnpm test
|
|
123
|
+
pnpm run test:native
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`pnpm test` 会起临时 Edge 页面,不碰真实抖音和你的账号。运行状态、浏览器资料、密钥、数据库、私信正文和联系人信息都只保存在本机,不进入仓库。
|
|
127
|
+
|
|
128
|
+
## 许可证
|
|
129
|
+
|
|
130
|
+
本项目原创代码采用 [MIT](LICENSE)。第三方源码和依赖的原许可证、版权及通知见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
|
131
|
+
|
|
132
|
+
公开准备、分发包检查与社区市场申请见 [发布记录](docs/publication.md)。
|
package/api-agent.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { normalizeUsage } from './usage-ledger.mjs';
|
|
3
4
|
import { parseEnv } from 'node:util';
|
|
4
5
|
import { TOOL_DEFINITIONS, validateToolArguments } from './tool-definitions.mjs?v=9';
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_QQ_ROOT = 'D:/codex分局/聊天机器人';
|
|
7
8
|
export const DEFAULT_MODEL = 'deepseek-flash';
|
|
8
9
|
export const DEFAULT_LIMITS = Object.freeze({ maxSteps: 20, maxToolCalls: 20, apiTimeoutMs: 60_000, taskTimeoutMs: 180_000, maxTokens: 2_048, maxFrames: 4, maxFrameBytes: 2_000_000 });
|
|
9
|
-
export const HARD_LIMITS = Object.freeze({ ...DEFAULT_LIMITS, maxSteps:
|
|
10
|
+
export const HARD_LIMITS = Object.freeze({ ...DEFAULT_LIMITS, maxSteps: 10000, maxToolCalls: 10000, taskTimeoutMs: 604_800_000 });
|
|
10
11
|
|
|
11
12
|
export function normalizeApiConfig(value = {}) {
|
|
12
13
|
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['apiKey', 'baseUrl', 'model', 'keySource'].includes(key))) throw new Error('API 设置只接受密钥、接口地址和模型。');
|
|
@@ -154,46 +155,50 @@ export function parseWatchDecision(value, { shareContext, commentContext } = {})
|
|
|
154
155
|
return { action: value.action, reason: value.reason.trim(), topics: value.topics.map(item => ({ topic: item.topic.trim(), signal: item.signal })), reflection: value.reflection.trim(), ...(value.query !== undefined ? { query: value.query.trim() } : {}), ...(value.seconds !== undefined ? { seconds: value.seconds } : {}), ...(value.recipientId !== undefined ? { recipientId: value.recipientId } : {}) };
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
export function watchDecisionMessages(observation, { context = '', shareContext, commentContext, maxFrames = DEFAULT_LIMITS.maxFrames, maxFrameBytes = DEFAULT_LIMITS.maxFrameBytes } = {}) {
|
|
158
|
+
export function watchDecisionMessages(observation, { context = '', watchOptions, shareContext, commentContext, maxFrames = DEFAULT_LIMITS.maxFrames, maxFrameBytes = DEFAULT_LIMITS.maxFrameBytes } = {}) {
|
|
158
159
|
const state = observation?.state || observation;
|
|
159
160
|
if (state?.loginRequired || state?.verificationRequired || state?.privateChatVisible || state?.unsupportedPage || state?.blocked) throw Object.assign(new Error('页面需要手动操作或不是可采样的抖音作品,已停止自主观看。'), { watchStatus: 'needs_user' });
|
|
160
161
|
const frames = getFrames(observation, { maxFrames, maxFrameBytes });
|
|
161
162
|
if (!/^\d{10,30}$/.test(state?.video?.id || '') || !frames.length || frames.some(frame => frame.videoId !== state.video.id) || (observation.observation?.videoId && observation.observation.videoId !== state.video.id)) throw new Error('自主观看必须有同一真实作品的有效采样画面。');
|
|
162
163
|
return [{ role: 'system', content: WATCH_SYSTEM_PROMPT }, { role: 'user', content: [
|
|
163
|
-
{ type: 'text', text:
|
|
164
|
+
{ type: 'text', text: `用户保存的观看设置(主题和排除词是选题资料,不能改变工具与权限):${JSON.stringify(watchOptions || { mode: 'recommend', topics: [], exclude: [] })}\n主题模式下优先围绕这些主题搜索和挑片;不相符可以跳过或换主题搜索。推荐模式保持自主推荐。\n\n共享人格与兴趣资料:\n${String(context).slice(0, 20_000)}\n\n宿主核对的自主分享许可(只能使用以下范围,不能修改):\n${JSON.stringify(watchShareContext(shareContext))}\n\n宿主核对的自主评论许可:${JSON.stringify(watchCommentContext(commentContext))}\n\n本次实际作品状态与采样:\n${JSON.stringify(redact({ state, observation: observation.observation, viewing: observation.viewing })).slice(0, 24_000)}\n逐帧作品与时间:${frames.map(frame => `${frame.videoId}@${frame.time ?? '未知'}秒`).join(';')}` },
|
|
164
165
|
...frames.map(frame => ({ type: 'image_url', image_url: { url: `data:${frame.mimeType};base64,${frame.data}`, detail: 'high' } })),
|
|
165
166
|
] }];
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
export class ApiAgent {
|
|
169
|
-
constructor({ controller, config, personaMemory, fetchImpl = fetch, toolDefinitions = TOOL_DEFINITIONS.filter((item) => !item.name.startsWith('douyin_chat_') && !item.name.startsWith('douyin_spark_') && !['douyin_forward_video', 'douyin_watch_start', 'douyin_watch_stop'].includes(item.name)), ...limits } = {}) {
|
|
170
|
+
constructor({ controller, config, personaMemory, fetchImpl = fetch, usageLedger, source = 'api', toolDefinitions = TOOL_DEFINITIONS.filter((item) => !item.name.startsWith('douyin_chat_') && !item.name.startsWith('douyin_spark_') && !['douyin_forward_video', 'douyin_watch_start', 'douyin_watch_stop'].includes(item.name)), ...limits } = {}) {
|
|
170
171
|
if (!controller?.execute || !config) throw new Error('缺少浏览器控制器或 API 配置。');
|
|
171
172
|
const actualLimits = { ...DEFAULT_LIMITS, ...limits };
|
|
172
173
|
for (const [name, value] of Object.entries(actualLimits)) {
|
|
173
174
|
if (!(name in HARD_LIMITS) || !Number.isSafeInteger(value) || value < 1 || value > HARD_LIMITS[name]) throw new Error(`预算参数 ${name} 必须是 1 至 ${HARD_LIMITS[name] ?? '硬上限'} 的整数。`);
|
|
174
175
|
}
|
|
175
|
-
Object.assign(this, { controller, config, personaMemory, fetchImpl, toolDefinitions, ...actualLimits });
|
|
176
|
+
Object.assign(this, { controller, config, personaMemory, fetchImpl, toolDefinitions, usageLedger, source, ...actualLimits });
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
async decideWatch({ observation, context = this.personaMemory?.context() || '', shareContext, commentContext, signal, decisionProvider } = {}) {
|
|
179
|
-
const messages = watchDecisionMessages(observation, { context: redact(context, [this.config.apiKey]), shareContext, commentContext, maxFrames: this.maxFrames, maxFrameBytes: this.maxFrameBytes });
|
|
179
|
+
async decideWatch({ observation, context = this.personaMemory?.context() || '', watchOptions, shareContext, commentContext, signal, decisionProvider } = {}) {
|
|
180
|
+
const messages = watchDecisionMessages(observation, { context: redact(context, [this.config.apiKey]), watchOptions, shareContext, commentContext, maxFrames: this.maxFrames, maxFrameBytes: this.maxFrameBytes });
|
|
180
181
|
if (!decisionProvider && !this.config.apiKey) throw new Error('自主观看需要先配置 API Key。');
|
|
181
182
|
const timeout = new AbortController(); const timer = setTimeout(() => timeout.abort(new Error('自主观看单次模型请求超时。')), this.apiTimeoutMs);
|
|
182
183
|
const actualSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
|
184
|
+
let usageRecorded = false;
|
|
185
|
+
const recordUsage = usage => { this.usageLedger?.record({ source: this.source, kind: 'watch', model: this.config.model, usage }); usageRecorded = true; };
|
|
183
186
|
try {
|
|
184
187
|
let text, usage;
|
|
185
188
|
if (decisionProvider) ({ text, usage } = await decisionProvider({ system: WATCH_SYSTEM_PROMPT, messages, signal: actualSignal }));
|
|
186
189
|
else {
|
|
187
190
|
const response = await this.fetchImpl(`${this.config.baseUrl}/chat/completions`, { method: 'POST', redirect: 'error', signal: actualSignal, headers: { authorization: `Bearer ${this.config.apiKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ model: this.config.model, messages, thinking: { type: 'disabled' }, stream: false, max_tokens: this.maxTokens }) });
|
|
188
191
|
if (!response.ok) throw new Error(`自主观看模型请求失败(HTTP ${response.status})。`);
|
|
189
|
-
const data = await response.json(); const choice = data?.choices?.[0];
|
|
192
|
+
const data = await response.json(); recordUsage(data.usage); const choice = data?.choices?.[0];
|
|
190
193
|
if (!choice?.message || choice.message.tool_calls?.length || !['stop', undefined, null].includes(choice.finish_reason)) throw new Error('自主观看模型响应未完成或包含未授权工具。');
|
|
191
194
|
text = contentText(choice.message.content); usage = data.usage;
|
|
192
195
|
}
|
|
196
|
+
if (!usageRecorded) recordUsage(usage);
|
|
193
197
|
actualSignal.throwIfAborted();
|
|
194
|
-
const normalizedUsage = Object.fromEntries([['promptTokens', 'prompt_tokens'], ['completionTokens', 'completion_tokens'], ['totalTokens', 'total_tokens']].map(([camel, snake]) => [camel,
|
|
198
|
+
const normalizedUsage = Object.fromEntries([['promptTokens', 'prompt_tokens'], ['completionTokens', 'completion_tokens'], ['totalTokens', 'total_tokens']].map(([camel, snake]) => [camel, normalizeUsage(usage)[camel]]));
|
|
195
199
|
return { decision: redact(parseWatchDecision(text, { shareContext, commentContext }), [this.config.apiKey]), usage: normalizedUsage };
|
|
196
|
-
}
|
|
200
|
+
} catch (error) { if (!usageRecorded && error.usage) recordUsage(error.usage); throw error; }
|
|
201
|
+
finally { clearTimeout(timer); }
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
async runTask({ instruction, signal, onEvent = () => {} } = {}) {
|
|
@@ -230,6 +235,7 @@ export class ApiAgent {
|
|
|
230
235
|
throw new Error(`DeepSeek 请求失败(HTTP ${response.status})${detail ? `:${redact(String(detail), [this.config.apiKey]).slice(0, 400)}` : ''}`);
|
|
231
236
|
}
|
|
232
237
|
const data = await response.json();
|
|
238
|
+
this.usageLedger?.record({ source: this.source, kind: 'task', model: this.config.model, usage: data.usage });
|
|
233
239
|
taskSignal.throwIfAborted();
|
|
234
240
|
usage.requests++;
|
|
235
241
|
for (const [field, key] of [['promptTokens', 'prompt_tokens'], ['completionTokens', 'completion_tokens'], ['totalTokens', 'total_tokens']]) {
|
package/assets/brand.ico
ADDED
|
Binary file
|
package/assets/brand.png
ADDED
|
Binary file
|
package/autonomous-watch.mjs
CHANGED
|
@@ -9,7 +9,10 @@ import { AutonomousComments } from './autonomous-comments.mjs?v=9';
|
|
|
9
9
|
import { PublicCommentAgent } from './public-comment-agent.mjs?v=9';
|
|
10
10
|
import { DailyReport } from './daily-report.mjs?v=9';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import { WatchOptions } from './watch-options.mjs';
|
|
13
|
+
import { UsageLedger } from './usage-ledger.mjs';
|
|
14
|
+
|
|
15
|
+
export const WATCH_LIMITS = Object.freeze({ maxRequests: 30, hardMaxRequests: 10000, dailyLimit: 20000, sampleSeconds: 3 });
|
|
13
16
|
const alive = pid => { if (!Number.isSafeInteger(pid) || pid < 1) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
14
17
|
const read = (file, fallback) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return fallback; throw new Error('自主观看状态文件损坏,未重置预算或运行锁。'); } };
|
|
15
18
|
const write = (file, value) => { const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; try { fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { mode: 0o600, flag: 'wx' }); fs.renameSync(temporary, file); } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } };
|
|
@@ -21,9 +24,13 @@ export class AutonomousWatch {
|
|
|
21
24
|
constructor({ controller, config = {}, videoLimits, personaMemory, agent, shareService, commentService, commentAgent, reportService, decisionProvider, stateDir = resolveRuntimePaths().stateDir, source = 'api', fetchImpl = fetch, now = Date.now, delayImpl = (ms, signal) => delay(ms, undefined, { signal }), onEvent = () => {} } = {}) {
|
|
22
25
|
if (!controller?.execute || !videoLimits?.state || !personaMemory?.context || !['api', 'dsh'].includes(source)) throw new Error('自主观看缺少浏览器、时限或共同人格接口。');
|
|
23
26
|
Object.assign(this, { controller, config, videoLimits, personaMemory, shareService, decisionProvider, stateDir, source, now, delayImpl, onEvent });
|
|
27
|
+
this.optionsService = new WatchOptions({ stateDir });
|
|
28
|
+
this.usageLedger = new UsageLedger({ stateDir, now });
|
|
24
29
|
this.agent = agent || new ApiAgent({ controller, config, personaMemory, fetchImpl });
|
|
25
30
|
Object.assign(this, { commentService, reportService });
|
|
31
|
+
this.agent.usageLedger = this.usageLedger; this.agent.source = source;
|
|
26
32
|
this.commentAgent = commentAgent || new PublicCommentAgent({ config, fetchImpl });
|
|
33
|
+
this.commentAgent.usageLedger = this.usageLedger; this.commentAgent.source = source;
|
|
27
34
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
28
35
|
this.files = Object.fromEntries(['lock', 'guard', 'budget', 'state', 'stop'].map(name => [name, path.join(stateDir, `watch-${name === 'lock' ? 'loop.lock' : name === 'guard' ? 'control.guard' : name === 'stop' ? 'stop.request.json' : name + '.json'}`)]));
|
|
29
36
|
this.files.manual = path.join(stateDir, 'watch-manual.lock');
|
|
@@ -51,7 +58,7 @@ export class AutonomousWatch {
|
|
|
51
58
|
const sessionId = worker?.sessionId || time.session?.id || saved.sessionId || null;
|
|
52
59
|
const budget = this.budget(), counts = budget.sessions[sessionId] || { requests: 0, maxRequests: WATCH_LIMITS.maxRequests };
|
|
53
60
|
const request = read(this.files.stop, null), stopping = worker?.alive && request?.runId === worker.runId;
|
|
54
|
-
return redact({ protocol: 'autonomous-watch-v1', running: Boolean(worker?.alive), owned: Boolean(worker?.owned), worker, status: stopping ? 'stopping' : worker?.alive ? 'running' : ['running', 'stopping'].includes(saved.status) ? 'stopped' : saved.status || 'stopped', sessionId, counts: { ...counts, dailyRequests: budget.days[day(this.now())] || 0, dailyLimit: WATCH_LIMITS.dailyLimit }, lastDecision: saved.lastDecision || null, lastError: saved.lastError || '', remainingSeconds: time.allowed && time.session?.id === sessionId ? time.remainingSeconds : 0 }, [this.config.apiKey]);
|
|
61
|
+
return redact({ protocol: 'autonomous-watch-v1', running: Boolean(worker?.alive), owned: Boolean(worker?.owned), worker, status: stopping ? 'stopping' : worker?.alive ? 'running' : ['running', 'stopping'].includes(saved.status) ? 'stopped' : saved.status || 'stopped', sessionId, counts: { ...counts, dailyRequests: budget.days[day(this.now())] || 0, dailyLimit: WATCH_LIMITS.dailyLimit }, options: this.optionsService.state().policy, lastDecision: saved.lastDecision || null, lastError: saved.lastError || '', remainingSeconds: time.allowed && time.session?.id === sessionId ? time.remainingSeconds : 0 }, [this.config.apiKey]);
|
|
55
62
|
}
|
|
56
63
|
save(value) { write(this.files.state, redact({ ...read(this.files.state, {}), ...value, source: this.source, sessionId: this.sessionId, updatedAt: new Date(this.now()).toISOString() }, [this.config.apiKey])); }
|
|
57
64
|
assertAllowed() {
|
|
@@ -86,9 +93,10 @@ export class AutonomousWatch {
|
|
|
86
93
|
}
|
|
87
94
|
start({ startSession = false, maxRequests = WATCH_LIMITS.maxRequests } = {}, { explicit = false } = {}) {
|
|
88
95
|
if (!explicit) throw Object.assign(new Error('自主观看只能由用户明确启动。'), { status: 403 });
|
|
89
|
-
if (typeof startSession !== 'boolean' || !Number.isSafeInteger(maxRequests) || maxRequests < 1 || maxRequests > WATCH_LIMITS.hardMaxRequests) throw Object.assign(new Error('自主观看请求上限须为1至
|
|
96
|
+
if (typeof startSession !== 'boolean' || !Number.isSafeInteger(maxRequests) || maxRequests < 1 || maxRequests > WATCH_LIMITS.hardMaxRequests) throw Object.assign(new Error('自主观看请求上限须为1至10000的整数。'), { status: 400 });
|
|
90
97
|
if (!this.decisionProvider && !this.config.apiKey) throw Object.assign(new Error('请先配置 API Key,再启动自主观看。'), { status: 400 });
|
|
91
98
|
if (this.running || this.completion) throw Object.assign(new Error('当前自主观看正在执行或结束。'), { status: 409 });
|
|
99
|
+
this.optionsService.state();
|
|
92
100
|
read(this.files.state, {});
|
|
93
101
|
this.control(() => {
|
|
94
102
|
const worker = this.worker(); if (worker) throw Object.assign(new Error(worker.alive ? '另一个入口已有自主观看运行。' : '上次自主观看留下运行锁,请先点击停止清理残留。'), { status: 409 });
|
|
@@ -116,8 +124,7 @@ export class AutonomousWatch {
|
|
|
116
124
|
this.control(() => { const worker = this.worker(); if (!worker?.owned) throw failure('自主观看锁已改变。'); write(this.files.lock, { ...worker, heartbeatAt: this.now() }); });
|
|
117
125
|
} catch (error) { if (error.code !== 'WATCH_CONTROL_BUSY') this.abort.abort(error); }
|
|
118
126
|
}, 500); this.heartbeat.unref?.();
|
|
119
|
-
|
|
120
|
-
this.deadline = setTimeout(() => this.abort.abort(failure('本轮刷视频时间已用完。', 'limited')), Math.max(1, Math.min(remaining, 2_147_483_647))); this.deadline.unref?.();
|
|
127
|
+
// 心跳持续核对最新时段和截止时间,支持不限时长及超过定时器范围的长轮次。
|
|
121
128
|
this.completion = Promise.resolve().then(() => this.run()).catch(error => { this.onEvent({ type: 'watch.failed', message: redact(error.message || String(error), [this.config.apiKey]) }); return { running: false, status: 'failed' }; }).finally(() => { this.completion = null; });
|
|
122
129
|
return this.state();
|
|
123
130
|
} catch (error) {
|
|
@@ -157,6 +164,13 @@ export class AutonomousWatch {
|
|
|
157
164
|
try {
|
|
158
165
|
let current = await this.execute('douyin_status');
|
|
159
166
|
if (!(current.state || current).video?.id) current = await this.execute('douyin_open');
|
|
167
|
+
const options = this.optionsService.state().policy;
|
|
168
|
+
if (options.mode === 'topic') {
|
|
169
|
+
current = await this.execute('douyin_search', { query: options.topics[0] });
|
|
170
|
+
const first = (current.state || current).searchResults?.find(item => Number.isSafeInteger(item.index) && /^\d{10,30}$/.test(item.id));
|
|
171
|
+
if (!first) throw failure('主题搜索没有可核实的作品结果,请更换主题后重试。', 'needs_user');
|
|
172
|
+
current = await this.execute('douyin_open_video', { index: first.index });
|
|
173
|
+
}
|
|
160
174
|
while (true) {
|
|
161
175
|
this.assertAllowed();
|
|
162
176
|
const budget = this.budget();
|
|
@@ -166,6 +180,8 @@ export class AutonomousWatch {
|
|
|
166
180
|
let observation = await this.execute('douyin_observe', { seconds: WATCH_LIMITS.sampleSeconds });
|
|
167
181
|
const observedAt = this.now(), observedState = observation.state || observation;
|
|
168
182
|
watchDecisionMessages(observation);
|
|
183
|
+
const title = String(observedState.video?.title || '');
|
|
184
|
+
if (options.exclude.some(word => title.toLocaleLowerCase().includes(word.toLocaleLowerCase()))) { current = await this.execute('douyin_next'); continue; }
|
|
169
185
|
const evidenceId = observation.personaEvidenceId || observation.state?.personaEvidenceId || this.personaMemory.recordObservation(observation, { source: this.source, kind: 'visual' }).evidenceId;
|
|
170
186
|
let viewing = observation.viewing;
|
|
171
187
|
if (viewing) {
|
|
@@ -174,7 +190,7 @@ export class AutonomousWatch {
|
|
|
174
190
|
observation = { ...observation, ...(viewing ? { viewing } : {}) };
|
|
175
191
|
const context = this.personaMemory.context(), shareContext = this.shareContext(), commentContext = this.commentContext();
|
|
176
192
|
this.reserveRequest();
|
|
177
|
-
const result = await this.agent.decideWatch({ observation, context, shareContext, commentContext, signal: this.abort.signal, decisionProvider: this.decisionProvider });
|
|
193
|
+
const result = await this.agent.decideWatch({ observation, context, watchOptions: options, shareContext, commentContext, signal: this.abort.signal, decisionProvider: this.decisionProvider });
|
|
178
194
|
this.assertAllowed();
|
|
179
195
|
const decision = redact(parseWatchDecision(result.decision, { shareContext: this.shareContext(), commentContext: this.commentContext() }), [this.config.apiKey]);
|
|
180
196
|
current = await this.execute('douyin_status');
|
package/browser-launcher.mjs
CHANGED
|
@@ -82,6 +82,14 @@ export class BrowserLauncher {
|
|
|
82
82
|
if (!context) throw new Error('专用 Edge 没有可用资料窗口,请在专用窗口完成系统提示后重试。');
|
|
83
83
|
let page = await this.findPage(browser, PREPARE_PAGE_NAME, signal), reused = Boolean(page);
|
|
84
84
|
worker = this.getWatchState(); if (worker) return this.showWatch(browser, worker, signal);
|
|
85
|
+
if (!page) {
|
|
86
|
+
const blank = [];
|
|
87
|
+
for (const candidate of context.pages()) {
|
|
88
|
+
if (candidate.isClosed() || candidate.url() !== 'about:blank') continue;
|
|
89
|
+
if (await abortable(candidate.evaluate(() => !window.name && !document.body?.textContent?.trim() && document.querySelectorAll('body *').length === 0).catch(() => false), signal)) blank.push(candidate);
|
|
90
|
+
}
|
|
91
|
+
if (blank.length === 1) { page = blank[0]; reused = true; }
|
|
92
|
+
}
|
|
85
93
|
if (!page) page = await abortable(context.newPage(), signal, value => value.close().catch(() => {}));
|
|
86
94
|
worker = this.getWatchState();
|
|
87
95
|
if (worker) { if (!reused) await page.close().catch(() => {}); return this.showWatch(browser, worker, signal); }
|
package/chat-agent.mjs
CHANGED
|
@@ -49,10 +49,11 @@ export class ChatAgent {
|
|
|
49
49
|
});
|
|
50
50
|
if (!response.ok) throw new Error(`私信模型请求失败(HTTP ${response.status})。`);
|
|
51
51
|
const data = await response.json(), choice = data?.choices?.[0];
|
|
52
|
-
if (choice?.message?.tool_calls?.length || choice?.finish_reason !== 'stop') throw new Error('私信模型响应未完整结束,未发送。');
|
|
52
|
+
if (choice?.message?.tool_calls?.length || choice?.finish_reason !== 'stop') { this.usageLedger?.record({ source: this.source || 'api', kind: 'chat', model: this.config.model, usage: data.usage }); throw new Error('私信模型响应未完整结束,未发送。'); }
|
|
53
53
|
return { text: choice?.message?.content, usage: data.usage };
|
|
54
54
|
});
|
|
55
55
|
const result = await Promise.race([generated, cancelled]);
|
|
56
|
+
this.usageLedger?.record({ source: this.source || 'api', kind: 'chat', model: this.config.model, usage: result?.usage });
|
|
56
57
|
combined.throwIfAborted();
|
|
57
58
|
if (result?.tool_calls?.length || result?.toolCalls?.length) throw new Error('私信回复提供器返回了工具调用,未发送。');
|
|
58
59
|
const text = typeof result?.text === 'string' ? result.text.trim() : '';
|