shuadouyin 0.4.0 → 0.5.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/.dy-build-commit CHANGED
@@ -1 +1 @@
1
- 7251333bbc6e84fd193536885101c3b6ebf46c86
1
+ e648c8d2d2d011a9aec54030287f9a8cf4947bf1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-10-31
4
+
5
+ ### 新增
6
+
7
+ - `video ai` — 读平台自己的 AI 对这条作品的描述。它能覆盖截图覆盖不到的:整条视频发生了什么、歌词唱什么、评论区在聊什么。**它是抖音在描述自己的内容,不是这个工具观察到的画面**,每条返回都带着这句话。
8
+ - `video digest --ai` — 一次调用同时拿到采样画面和平台描述。两者放在不同字段里,不混在一起。
9
+ - `video favorites` — 读自己收藏的作品,`--tab` 可换 喜欢 / 观看历史 / 稍后再看 / 我的预约。之前只能写收藏,看不到收藏了什么。
10
+
11
+ ### 改进
12
+
13
+ - `context show` 对人格文件改用按段投影,和 `persona show` 一致。低预算时身份与声音不会被静静切掉。自由格式(没有 `##` 段)回退到原文而不是返回空。
14
+ - 成功事件也记录耗时。之前只有失败记,所以「哪个命令慢」只能回答坏掉的那些。
15
+ - 轮次摘要分开报 `browser_ms`(页面调用时间)和 `elapsed_ms`(轮次开了多久),并单独报 `duplicate_observations`——一个作品读两次,预算算一次,成本付两次。
16
+
17
+ ### 修复
18
+
19
+ - `context show` 遇到只有标题没有小节的人格文件会返回空内容,等于说人格是空的。现在回退到原文。
20
+
21
+ ## 0.4.1 — 2026-10-31
22
+
23
+ ### 文档
24
+
25
+ - README 加了两张图:一张能力总览,一张六格流程示意。两种语言共用同一组。
26
+ - 图片不随 npm 包发布。它们是给 GitHub 页面看的,装包的人用不到,带上会把包从 343 kB 撑到 4.5 MB。
27
+
3
28
  ## 0.4.0 — 2026-10-31
4
29
 
5
30
  ### 新增
package/README.en.md CHANGED
@@ -5,196 +5,202 @@
5
5
 
6
6
  <div align="center">
7
7
 
8
- # ShuaDouyin
8
+ # ShuaDouyin · 刷抖音
9
9
 
10
- **An agent that browses Douyin with a persona and a memory. Open source.**
10
+ <img src="docs/assets/shuadouyin-hero.png" alt="ShuaDouyin: browsing and search, comments and reactions, chat and sharing, live rooms, relationships, persona and memory" width="100%" />
11
11
 
12
- Browsing, comments, direct messages, live rooms, memory — one local CLI that
13
- leaves the judgement to whatever agent is driving it.
12
+ **Let your AI Agent use Douyin with a persona and a memory.**
14
13
 
15
- <img src="https://img.shields.io/badge/node-%E2%89%A522.19-339933?logo=node.js&logoColor=white" alt="Node" />
16
- <img src="https://img.shields.io/badge/license-MIT-blue" alt="License" />
17
- <img src="https://img.shields.io/github/stars/9Ashwin/ShuaDouyin?style=social" alt="Stars" />
14
+ A local command-line tool connecting your Agent, the Douyin page, and context that lasts.
15
+
16
+ <img src="https://img.shields.io/badge/node-%E2%89%A522.19-339933?logo=node.js&logoColor=white" alt="Node.js 22.19+" />
17
+ <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" />
18
+ <img src="https://img.shields.io/github/stars/9Ashwin/ShuaDouyin?style=social" alt="GitHub Stars" />
19
+
20
+ [Quick start](#quick-start) · [Capabilities](#capabilities) · [Agent integration](#agent-integration) · [Development](#development-and-contributing)
18
21
 
19
22
  </div>
20
23
 
21
- ---
24
+ ## What is this?
25
+
26
+ ShuaDouyin's executable is `dy`. Give it to an AI Agent that can run shell commands to search content, inspect works and comments, interact with your authorization, handle messages and shares, visit live rooms, and keep sourced memories and relationship records.
27
+
28
+ **You define the name, voice and boundaries. The Agent interprets and decides; `dy` supplies operations and evidence.** It bundles no model and schedules no tasks. It uses your existing Agent and local Chrome, with persona and memory stored locally across sessions.
22
29
 
23
- ## What is this
30
+ ## Quick start
24
31
 
25
- `dy` is a local command-line tool. It bundles no model, schedules nothing, and
26
- decides nothing about content — it provides the state, context and primitives
27
- one browsing round needs, and leaves the judgement to the agent calling it.
32
+ ### Copy an installation prompt (recommended)
28
33
 
29
- What it remembers for you: what this account likes, who it has talked to, which
30
- lines got an answer. What it will not decide for you: whether to send, what to
31
- say, or whether a person is worth knowing.
34
+ Copy the entire block to an Agent that can run shell commands. Install the skill first, then let its instructions guide CLI installation and setup:
35
+
36
+ ```text
37
+ Please install the dy skill:
38
+ npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
39
+
40
+ Read the installed dy skill and follow it to check and install the CLI,
41
+ guide me through persona setup and Douyin login, and verify that it works.
42
+ Preserve existing settings and memories. Only install, configure, and run read-only checks.
43
+ ```
32
44
 
33
- ## Install
45
+ The skill includes CLI installation instructions. The Agent checks `dy --version` first and follows `references/setup.md` if the CLI is missing. The skill installer fetches its content from GitHub, so you do not need to open the repository and read the docs yourself. `dy` uses your existing Agent and needs no separate model API key. You log into Douyin yourself in local Chrome.
46
+
47
+ ### Manual installation
48
+
49
+ You need **Node.js 22.19+, Chrome, and an Agent that can execute shell commands**.
50
+
51
+ #### 1. Install the CLI and skill
34
52
 
35
53
  ```bash
36
54
  npm install -g shuadouyin
55
+ dy --version
56
+ dy skill install
37
57
  ```
38
58
 
39
- `dy` is then on your PATH. The package is `shuadouyin` and the command is `dy`;
40
- the published tarball ships a built `dist/`, so nothing compiles on install. The
41
- only requirement is a local Chrome — **no browser is downloaded**.
59
+ The npm package is `shuadouyin`; the command is `dy`. The published package includes the build output and uses your local Chrome, without downloading another browser. The skill installer detects supported Agent directories and preserves existing copies it does not manage.
42
60
 
43
- Installing from source also works, and builds once:
61
+ #### 2. Create the workspace and log in
44
62
 
45
63
  ```bash
46
- npm install -g github:9Ashwin/ShuaDouyin
64
+ dy init
65
+ dy auth login
47
66
  ```
48
67
 
49
- This package is distributed from GitHub, not the npm registry.
68
+ Scan the login QR code in the dedicated Chrome window. The default workspace is `~/.dy`; the browser login persists in `~/.dy/chrome`.
50
69
 
51
- <details>
52
- <summary><strong>Other options</strong></summary>
70
+ ### Start using it
53
71
 
54
- Into a private directory, without a global npm:
72
+ After setup, send this to your Agent:
55
73
 
56
- ```bash
57
- curl -fsSL https://raw.githubusercontent.com/9Ashwin/ShuaDouyin/main/scripts/install.sh | sh
58
- ```
74
+ > Use dy to browse five works, pick one to inspect its frames and comments, and tell me what you observed. Only read this round; do not interact. If no persona has been configured yet, guide me through setup first.
59
75
 
60
- Uninstall:
76
+ You can also start with a specific task:
61
77
 
62
- ```bash
63
- npm uninstall -g shuadouyin
64
- ```
78
+ - **Research content:** “Search for AI coding, inspect two works, and separate what their titles, frames and comments tell us.”
79
+ - **Continue a conversation:** “Check new messages in the conversation I specify and draft a reply using the earlier context.”
80
+ - **Follow up:** “Review the people I have recorded and list follow-ups due today.”
81
+ - **Review the day:** “Summarize today's local activity and receipts, clearly separating uncertain outcomes.”
65
82
 
66
- Uninstalling keeps the bot, memories and account login in `~/.dy`.
83
+ For manual terminal use, run `dy tui`. It needs an interactive terminal and shares the CLI's operations and state.
67
84
 
68
- </details>
85
+ ## Capabilities
86
+
87
+ ![Six-panel comic: define a persona, search and browse, inspect works and comments, interact and share, visit live rooms, keep memory and relationships](docs/assets/shuadouyin-workflow.png)
88
+
89
+ The artwork uses Chinese labels. The character represents a configurable Agent, not a fixed persona. Four sample positions and five comments came from an actual browsing run; the scenes are redrawn illustrations. The other panels show capabilities, not messages or gifts sent during that run.
90
+
91
+ | Capability | Common entry points |
92
+ | --- | --- |
93
+ | Search works and people; browse a batch of feed items | `dy search` · `dy user search` · `dy feed sweep` |
94
+ | Read works, sample frames, inspect comments and replies | `dy video digest` · `dy comment list` · `dy comment replies` |
95
+ | Like, favorite, follow, post text or image comments | `dy video like` · `dy video favorite` · `dy user follow` · `dy comment post` · `dy comment image` |
96
+ | Read messages, send text and images, quote a reply | `dy chat read` · `dy chat send` · `dy chat image` · `dy chat reply` |
97
+ | Share a work as a link or native card | `dy chat share` · `dy chat forward` |
98
+ | Read live-room info and chat, speak, send a named and priced gift | `dy live info` · `dy live chat` · `dy live say` · `dy live gift` |
99
+ | Read persona, recall context, save sourced facts | `dy persona show` · `dy context show` · `dy memory add` |
100
+ | Derive voice from actual writing, retain lessons, check wording | `dy persona evolve` · `dy learn keep` · `dy voice check` |
101
+ | Track browsing rounds, goals, relationships and follow-ups | `dy browse start` · `dy social goal` · `dy social due` |
102
+ | Inspect write receipts; prepare and save daily reports | `dy receipt get` · `dy report prepare` · `dy report save` |
103
+
104
+ These are command entry points. Use `dy <command> --help` for required arguments, or `dy schema` for the complete command and field catalog.
105
+
106
+ ## Persona and memory across sessions
69
107
 
70
- Install the skill for an agent (optional):
108
+ The default `~/.dy` workspace separates persona, people records, memory and the browser login:
109
+
110
+ - **`AGENTS.md`** defines the Agent's identity, voice and boundaries. You can revise the settings.
111
+ - **`PROFILES.md` and `memory/`** hold people records and sourced memories. Owner statements, page observations and inferences stay distinct. Markdown is the source of truth for memory bodies.
112
+ - **`growth review`, `learn keep` and `persona evolve`** support reviewing activity, retaining lessons and deriving changes in voice. The caller decides what is worth keeping; a viewed work does not automatically become a lasting preference.
113
+
114
+ Updating or reinstalling the CLI continues to use the existing workspace. Use the reset commands below when you want to start over.
115
+
116
+ ## Agent integration
117
+
118
+ For a first installation, use the [installation prompt above](#copy-an-installation-prompt-recommended). The following covers skill entry points and the usual calling sequence.
119
+
120
+ The [dy skill](skills/dy/SKILL.md) teaches Agents to install and use the tool, with topic-specific workflows in [references](skills/dy/references/). If the CLI is already installed, you can also run `dy skill install` to connect the skill.
121
+
122
+ Start each task by reading persona and relevant context:
71
123
 
72
124
  ```bash
73
- dy skill install
125
+ dy persona show
126
+ dy context show --query "The question for this task"
127
+ dy feed sweep --count 5
74
128
  ```
75
129
 
76
- ## Getting started
130
+ Choose a work ID from the results, open its detail page, then sample it:
77
131
 
78
132
  ```bash
79
- dy init # create the workspace, start onboarding
80
- dy onboarding show # four questions: name, voice, facts about you, limits
81
- dy auth login # open Chrome and scan the QR code
82
- dy feed next # next work in the recommended feed
83
- dy video info # read the current work
133
+ dy video open <video-id>
134
+ dy video digest --video-id <video-id> --limit 5
84
135
  ```
85
136
 
86
- The first login opens a dedicated Chrome window whose profile lives in
87
- `~/.dy/chrome`. That login is kept across restarts.
137
+ `dy guide` returns instructions matching the installed version; `dy schema` describes commands, arguments and effects. Run one workspace operation at a time to avoid account-lock conflicts.
88
138
 
89
- Run `dy tui` when you want to browse and like by hand — a terminal interface
90
- over the same operations. It shares the CLI's implementation, so both see the
91
- same state. It needs a real terminal and refuses `--dry-run`.
139
+ Success emits `{ok:true,data}` to stdout. Failure emits `{ok:false,error}` to stderr and exits nonzero. Writes use a unique `--request-id`. The `--dry-run` option validates input and the plan without connecting to Chrome, filling inputs or sending. See [docs/contracts.md](docs/contracts.md) for the full contract.
92
140
 
93
- ## What it does
141
+ ## Actions and evidence
94
142
 
95
- | Task | Commands |
96
- | --- | --- |
97
- | Browse the feed | `dy feed open` / `dy feed next` / `dy video info` |
98
- | Read works and comments | `dy video info` / `dy comment list` / `dy comment replies` |
99
- | Interact | `dy video like` / `dy video favorite` / `dy comment post` / `dy comment reply` |
100
- | Direct messages | `dy chat list` / `dy chat read` / `dy chat send` / `dy chat image` |
101
- | People | `dy user get` / `dy user search` / `dy user followers` / `dy user follow` |
102
- | Live rooms | `dy live info` / `dy live chat` / `dy live say` / `dy live gift` |
103
- | Search | `dy search` / `dy user search` |
104
- | Memory and persona | `dy memory add` / `dy memory search` / `dy context show` |
105
- | Browsing rounds | `dy browse start` / `dy browse watch` / `dy browse end` |
106
- | Relationships | `dy social goal` / `dy social list` / `dy social due` / `dy social log` |
107
- | Teach an agent to use it | `dy guide` |
143
+ - **A receipt before dispatch.** Replaying a confirmed request ID returns the saved receipt. Inspect `delivery_uncertain` outcomes instead of automatically resending. Native-card sharing currently cannot verify delivery and keeps dispatched shares uncertain.
144
+ - **Stable identities.** Use the work, user and conversation IDs returned by the CLI. Display names are only labels.
145
+ - **Samples are not full viewing.** Lists contain loaded content and generally report `complete: false`. The live picture is not returned as readable content. The Agent draws conclusions from the evidence it actually has.
146
+ - **External actions need authorization.** Comments, messages, follows and live chat affect a real account. Gifts cost money and require explicit agreement plus a name-and-price check. Deletion and recall depend on platform conditions and cannot guarantee that content was unseen.
147
+ - **Local login and data.** Automation may trigger verification, rate limits or account penalties. A person must complete verification; the project offers no bypass. `~/.dy` contains login state, messages and memories and should not be committed or shared. See [SECURITY.md](SECURITY.md).
108
148
 
109
- See `dy schema` for every command, or `dy <command> --help`.
149
+ ## Installation options and workspace management
110
150
 
111
- ## For agents
151
+ <details>
152
+ <summary><strong>Other installation methods, uninstall and reset</strong></summary>
112
153
 
113
- `dy` is built for an agent to drive. Hand it to anything that can run a shell:
154
+ Install from GitHub source, which runs a build:
114
155
 
115
156
  ```bash
116
- dy guide # the full usage guide (~9000 characters); this is what the agent reads
157
+ npm install -g github:9Ashwin/ShuaDouyin
117
158
  ```
118
159
 
119
- Or install `skills/dy` into your agent's skill directory. It carries five
120
- references: browsing and growth, memory and persona, reports, what to do when
121
- something fails, and **platform facts** — the last one is the record of what
122
- this project got wrong on the real page, and reading it saves repeating that.
123
-
124
- ## Principles
125
-
126
- - **No judgement.** The CLI returns facts and candidates; the caller decides.
127
- It acts on a real account, so the code's job is to make every action auditable
128
- and reversible — not to decide whether the action is appropriate.
129
- - **A receipt before anything leaves.** Every write lands on disk before it can
130
- dispatch, and replaying a request ID returns the original receipt. A
131
- `delivery_uncertain` write is never retried automatically: it may already be
132
- out, and sending again makes two.
133
- - **Identity is a stable id.** Display names collide and change; `sec_uid` does
134
- not.
135
- - **Report what is readable.** The live picture is a canvas with no text layer,
136
- so nothing is claimed about it. Comment lists are sampled and say so.
137
- - **Memory belongs to the account.** A new install is a new bot; it does not
138
- inherit anyone else's conclusions.
139
-
140
- ## Reset
160
+ On macOS / Linux, an installer can use a private installation directory:
141
161
 
142
162
  ```bash
143
- dy bot reset # show what would be cleared
144
- dy bot reset --write # back up to ~/.dy-backup-<time>, then clear
145
- dy init # create a fresh bot
163
+ curl -fsSL https://raw.githubusercontent.com/9Ashwin/ShuaDouyin/main/scripts/install.sh | sh
146
164
  ```
147
165
 
148
- The Chrome login is kept, so no new scan is needed.
166
+ Check installed skill copies against the current version:
149
167
 
150
- ## Development
168
+ ```bash
169
+ dy skill status
170
+ ```
151
171
 
152
- Requires Node.js 22.19+, pnpm, and a local Chrome.
172
+ Remove an npm global installation while retaining `~/.dy`:
153
173
 
154
174
  ```bash
155
- pnpm install --frozen-lockfile --ignore-scripts
156
- pnpm build
157
- pnpm test # unit tests
158
- pnpm test:browser # isolated Chrome tests
159
- pnpm check # Biome + tsc
175
+ npm uninstall -g shuadouyin
160
176
  ```
161
177
 
162
- Output contracts, receipt states and storage semantics are in
163
- [docs/contracts.md](docs/contracts.md). Source layout, module ownership and the
164
- check sequence are in [AGENTS.md](AGENTS.md).
178
+ Reset persona, memories and activity after a backup; keep the Chrome login:
165
179
 
166
- ## Risks and limits
180
+ ```bash
181
+ dy bot reset # preview the scope
182
+ dy bot reset --write # back up and reset; use the returned backup path
183
+ dy init # create the workspace again
184
+ ```
167
185
 
168
- This tool drives a real Douyin account. Using it means accepting the following:
186
+ </details>
169
187
 
170
- - **The account risk is yours.** Automated use of Douyin may trigger human
171
- verification, rate limits or penalties. The project offers no bypass and will
172
- not help you evade them. When a challenge appears, a person has to solve it in
173
- the headed Chrome window.
174
- - **What is sent cannot be taken back.** Comments, direct messages, danmaku and
175
- gifts are public and irreversible, and gifts cost real money. Check the target
176
- and the text first; `--dry-run` shows the plan without acting.
177
- - **Local state is sensitive.** `~/.dy` holds the login, everything that was read
178
- and the long-term memory. Do not commit it, screenshot it or put it somewhere
179
- shared. See [SECURITY.md](SECURITY.md).
180
- - **What you read is not everything.** Lists are sampled and say
181
- `complete: false`; the live picture is a canvas with no text. Concluding from a
182
- sample is your judgement, not the tool's finding.
183
- - **It does not decide.** Whether to send, to whom, or whether a person is worth
184
- knowing is not a decision the commands make, and being driven by an agent does
185
- not lower that bar.
188
+ ## Development and contributing
186
189
 
187
- ## Status
190
+ Requires Node.js 22.19+, pnpm, and local Chrome.
188
191
 
189
- Early. Commands, error codes and response fields are public contracts and are
190
- kept stable; the database schema migrates with each release. CI runs the unit
191
- and isolated-Chrome tests on Ubuntu, macOS and Windows.
192
+ ```bash
193
+ pnpm install --frozen-lockfile --ignore-scripts
194
+ pnpm build
195
+ pnpm check # Biome + TypeScript
196
+ pnpm test # unit tests
197
+ pnpm test:browser # temporary Chrome profile and fixture pages
198
+ ```
192
199
 
193
- ## Contributing
200
+ Browser tests use no account credentials. Passing them does not prove behavior on the real Douyin page. The project is early, and page changes can affect operations; error codes, arguments and response fields are maintained as public contracts.
194
201
 
195
- See [CONTRIBUTING.md](CONTRIBUTING.md). Report security problems privately per
196
- [SECURITY.md](SECURITY.md).
202
+ See [AGENTS.md](AGENTS.md) for development rules, [docs/architecture.md](docs/architecture.md) for the architecture, [CONTRIBUTING.md](CONTRIBUTING.md) to contribute, and [CHANGELOG.md](CHANGELOG.md) for release changes.
197
203
 
198
204
  ## License
199
205
 
200
- MIT
206
+ [MIT](LICENSE)
package/README.md CHANGED
@@ -5,177 +5,202 @@
5
5
 
6
6
  <div align="center">
7
7
 
8
- # 刷抖音 · ShuaDouyin
8
+ # ShuaDouyin · 刷抖音
9
9
 
10
- **让 agent 带着人格和记忆使用抖音。开源。**
10
+ <img src="docs/assets/shuadouyin-hero.png" alt="ShuaDouyin 刷抖音:浏览与搜索、评论与互动、私信与分享、直播与弹幕、关系与跟进、人格与记忆" width="100%" />
11
11
 
12
- 浏览、评论、私信、直播、记忆——一个本地 CLI,判断权留给调用它的 agent。
12
+ **让你的 AI Agent,带着人格和记忆使用抖音。**
13
13
 
14
- <img src="https://img.shields.io/badge/node-%E2%89%A522.19-339933?logo=node.js&logoColor=white" alt="Node" />
15
- <img src="https://img.shields.io/badge/license-MIT-blue" alt="License" />
16
- <img src="https://img.shields.io/github/stars/9Ashwin/ShuaDouyin?style=social" alt="Stars" />
14
+ 一个本地命令行工具,连接 Agent、抖音页面和持续积累的上下文。
17
15
 
18
- </div>
16
+ <img src="https://img.shields.io/badge/node-%E2%89%A522.19-339933?logo=node.js&logoColor=white" alt="Node.js 22.19+" />
17
+ <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" />
18
+ <img src="https://img.shields.io/github/stars/9Ashwin/ShuaDouyin?style=social" alt="GitHub Stars" />
19
+
20
+ [快速开始](#快速开始) · [功能一览](#功能一览) · [Agent 接入](#给-agent-接入) · [开发与参与](#开发与参与)
19
21
 
20
- ---
22
+ </div>
21
23
 
22
24
  ## 这是什么
23
25
 
24
- `dy` 是一个本地命令行工具。它不内建模型、不调度、不做内容判断——它提供**一轮浏览所需的状态、上下文和操作原语**,判断由调用它的 agent 做。
26
+ ShuaDouyin 的命令叫 `dy`。把它交给能执行 shell 的 AI Agent,就能搜索内容、浏览作品与评论、按授权互动、处理私信与分享、进入直播间,并保存有来源的记忆和关系记录。
25
27
 
26
- 它替你记住的:这个账号喜欢什么、跟谁聊过、哪句话有回应。它不替你决定的:该不该发、发什么、这个人值不值得认识。
28
+ **你定义名字、语气和边界,Agent 负责理解与判断,`dy` 提供操作和证据。** 项目不内建模型,也不运行定时任务;使用你已有的 Agent 和本机 Chrome。人设与记忆保存在本地,可以跨会话继续使用。
27
29
 
28
- ## 安装
30
+ ## 快速开始
29
31
 
30
- ```bash
31
- npm install -g shuadouyin
32
+ ### 复制给 Agent 安装(推荐)
33
+
34
+ 把下面整段复制给能执行 shell 的 Agent,先装 skill,再由技能引导完成 CLI 安装与设置:
35
+
36
+ ```text
37
+ 请帮我安装 dy 技能:
38
+ npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
39
+
40
+ 安装后读取 dy skill,按技能指引检查并安装 CLI,
41
+ 带我完成人格设置和抖音登录,并验证是否可用。
42
+ 已有配置和记忆请保留,这次只做安装、配置和只读验证。
32
43
  ```
33
44
 
34
- `dy` 随即可用。**不下载浏览器**——用你本机已有的 Chrome。
45
+ skill 已包含 CLI 的安装指引,Agent 会先检查 `dy --version`,缺少 CLI 时按 `references/setup.md` 继续安装。技能安装器会从 GitHub 获取内容,你无需手动打开仓库查文档。`dy` 使用你现有的 Agent,不需要单独配置模型 API Key;抖音登录由你在本机 Chrome 中完成。
35
46
 
36
- 包叫 `shuadouyin`,命令叫 `dy`。发布包里带的是构建好的 `dist/`,装上就能跑,不需要本地再编译一次。
47
+ ### 自己安装
37
48
 
38
- <details>
39
- <summary><strong>其他</strong></summary>
49
+ 准备好 **Node.js 22.19+、Chrome,以及一个能执行 shell 的 Agent**。
40
50
 
41
- 从源码装(会构建一次,几秒):
51
+ #### 1. 安装工具与 skill
42
52
 
43
53
  ```bash
44
- npm install -g github:9Ashwin/ShuaDouyin
54
+ npm install -g shuadouyin
55
+ dy --version
56
+ dy skill install
45
57
  ```
46
58
 
47
- 装到独立目录,不需要全局 npm:
59
+ npm 包叫 `shuadouyin`,可执行命令叫 `dy`。发布包已包含构建产物;使用本机 Chrome,不额外下载浏览器。`dy skill install` 会为已检测到的 Agent 安装技能,已有的非托管副本会保留。
60
+
61
+ #### 2. 建立工作区并登录
48
62
 
49
63
  ```bash
50
- curl -fsSL https://raw.githubusercontent.com/9Ashwin/ShuaDouyin/main/scripts/install.sh | sh
64
+ dy init
65
+ dy auth login
51
66
  ```
52
67
 
53
- 卸载:
68
+ 在打开的专用 Chrome 窗口里扫码登录。工作区默认位于 `~/.dy`,浏览器登录保存在 `~/.dy/chrome`,后续继续使用。
54
69
 
55
- ```bash
56
- npm uninstall -g shuadouyin
57
- ```
70
+ ### 开始使用
58
71
 
59
- 卸载保留 `~/.dy` 里的 bot、记忆和账号登录状态。
72
+ 设置好后,把这句话发给你的 Agent:
60
73
 
61
- </details>
74
+ > 用 dy 刷 5 条,挑 1 条读画面和评论,告诉我看到了什么。这轮只看,不互动。如果还没设置人格,先带我完成设置。
62
75
 
63
- 装 skill 给 agent 用(可选):
76
+ 也可以从具体任务开始:
64
77
 
65
- ```bash
66
- npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
67
- ```
78
+ - **带着问题找内容:**“搜一下 AI 编程,挑两条展开读,区分标题、画面和评论提供的信息。”
79
+ - **继续已有对话:**“看看我指定会话的新消息,结合之前的上下文拟一条回复。”
80
+ - **跟进关系:**“整理我已记录的人,看看今天有哪些到期的跟进事项。”
81
+ - **回顾一天:**“根据今天的本地记录和收据写个摘要,结果不明的操作单独说明。”
82
+
83
+ 想自己操作终端界面,可以运行 `dy tui`;它需要真实终端,和 CLI 共用同一套操作与状态。
84
+
85
+ ## 功能一览
86
+
87
+ ![ShuaDouyin 六格漫画:设定人格、搜索与浏览、读作品与评论、互动与分享、直播与弹幕、记忆与关系](docs/assets/shuadouyin-workflow.png)
88
+
89
+ 图中角色代表可配置的 Agent,名字、语气和边界由你定义。四个采样点和五条评论来自一次实际浏览,画面为重绘;其他场景是功能示意,不代表本次执行了发送或送礼。
90
+
91
+ | 能力 | 常用入口 |
92
+ | --- | --- |
93
+ | 搜索作品、找人、批量浏览推荐流 | `dy search` · `dy user search` · `dy feed sweep` |
94
+ | 读取作品、采样画面、查看评论与回复 | `dy video digest` · `dy comment list` · `dy comment replies` |
95
+ | 点赞、收藏、关注、发布文字或图片评论 | `dy video like` · `dy video favorite` · `dy user follow` · `dy comment post` · `dy comment image` |
96
+ | 读取私信、发送文字和图片、引用回复 | `dy chat read` · `dy chat send` · `dy chat image` · `dy chat reply` |
97
+ | 分享作品链接或原生卡片 | `dy chat share` · `dy chat forward` |
98
+ | 读取直播信息与弹幕、发言、按名称和价格送礼 | `dy live info` · `dy live chat` · `dy live say` · `dy live gift` |
99
+ | 读取人格、召回记忆、保存带来源的事实 | `dy persona show` · `dy context show` · `dy memory add` |
100
+ | 从实际表达中更新声音、积累经验、自查文风 | `dy persona evolve` · `dy learn keep` · `dy voice check` |
101
+ | 管理浏览轮次、目标、关系与跟进事项 | `dy browse start` · `dy social goal` · `dy social due` |
102
+ | 查询操作收据、准备和保存日报 | `dy receipt get` · `dy report prepare` · `dy report save` |
103
+
104
+ 表中是命令入口,必填参数见 `dy <命令> --help`;完整命令与字段见 `dy schema`。
68
105
 
69
- 或者用 CLI 自己装到 agent 目录:
106
+ ## 人格和记忆如何延续
107
+
108
+ 默认工作区 `~/.dy` 将人格、人物资料、记忆和账号登录分开保存:
109
+
110
+ - **`AGENTS.md`** 定义这个 Agent 的身份、语气和边界,设定可以修改。
111
+ - **`PROFILES.md` 与 `memory/`** 保存人物资料和带来源的记忆。用户明确表达的事实、页面观察和推测分开记录;Markdown 是记忆正文的来源。
112
+ - **`growth review`、`learn keep` 与 `persona evolve`** 为回顾、经验和表达变化提供依据。是否值得保存由调用方判断,浏览不等于自动形成长期偏好。
113
+
114
+ 更新或重新安装 CLI 会继续使用原工作区。需要重新开始时,使用下方的重置命令。
115
+
116
+ ## 给 Agent 接入
117
+
118
+ 首次安装可以直接使用上面的[安装提示词](#复制给-agent-安装推荐)。下面是技能入口和日常调用顺序。
119
+
120
+ [dy skill](skills/dy/SKILL.md) 负责教 Agent 安装和使用工具,主题流程放在 [references](skills/dy/references/) 中。如果已经安装 CLI,也可以运行 `dy skill install` 接入技能。
121
+
122
+ 每次任务先读人格和相关上下文,再读页面:
70
123
 
71
124
  ```bash
72
- dy skill install
73
- dy skill status # 已装的副本和仓库是否一致
125
+ dy persona show
126
+ dy context show --query "这次要解决的问题"
127
+ dy feed sweep --count 5
74
128
  ```
75
129
 
76
- ## 开始使用
130
+ 从结果中选出作品 ID 后,先打开对应详情页,再采样:
77
131
 
78
132
  ```bash
79
- dy init # 建工作区,开始引导
80
- dy onboarding show # 回答四个问题:名字、性格、关于你的事实、界限
81
- dy auth login # 打开 Chrome 扫码登录抖音
82
- dy feed next # 刷下一条
83
- dy video info # 读当前作品
133
+ dy video open <video-id>
134
+ dy video digest --video-id <video-id> --limit 5
84
135
  ```
85
136
 
86
- 第一次登录会打开一个专用的 Chrome 窗口(profile 在 `~/.dy/chrome`),扫码后这个登录会一直保留。
137
+ `dy guide` 提供与当前安装版本一致的使用说明;`dy schema` 提供命令、参数和副作用类型。一次只运行一个工作区操作,避免账号锁冲突。
87
138
 
88
- 想手动刷、手动点赞时用 `dy tui`——一个覆盖同样操作的终端界面。它和 CLI 走同一套实现,所以两边看到的状态一致。需要在真实终端里运行(不接受管道输入),也不能和 `--dry-run` 一起用。
139
+ 成功输出 `{ok:true,data}` 到 stdout;失败输出 `{ok:false,error}` 到 stderr,并返回非零退出码。写入使用唯一的 `--request-id`;`--dry-run` 只验证参数和计划,不连接 Chrome、不填表、不发送。完整契约见 [docs/contracts.md](docs/contracts.md)。
89
140
 
90
- ## 它能做什么
141
+ ## 操作与证据边界
91
142
 
92
- | 做什么 | 命令 |
93
- | --- | --- |
94
- | 刷推荐流 | `dy feed open` / `dy feed next` / `dy video info` |
95
- | 读作品与评论 | `dy video info` / `dy comment list` / `dy comment replies` |
96
- | 互动 | `dy video like` / `dy video favorite` / `dy comment post` / `dy comment reply` |
97
- | 私信 | `dy chat list` / `dy chat read` / `dy chat send` / `dy chat image` |
98
- | 人 | `dy user get` / `dy user search` / `dy user followers` / `dy user follow` |
99
- | 直播 | `dy live info` / `dy live chat` / `dy live say` / `dy live gift` |
100
- | 搜索 | `dy search` / `dy user search` |
101
- | 记忆与人格 | `dy memory add` / `dy memory search` / `dy context show` |
102
- | 人格自己长 | `dy persona evolve` / `dy persona history` / `dy persona rollback` |
103
- | 沉淀经验 | `dy learn keep` / `dy learn findings` |
104
- | 文风自查 | `dy voice check --text "…"` |
105
- | 浏览轮次 | `dy browse start` / `dy browse watch` / `dy browse end` |
106
- | 社交关系 | `dy social goal` / `dy social list` / `dy social due` / `dy social log` |
107
- | 让 agent 会用 | `dy guide` |
108
-
109
- 完整命令见 `dy schema`,或 `dy <命令> --help`。
110
-
111
- ## 给 agent 用
112
-
113
- `dy` 是为 agent 设计的。把它交给任何能跑 shell 的 agent:
143
+ - **写之前先留收据。** 已确认的 request ID 重放时返回原收据;遇到 `delivery_uncertain` 先核对,不自动重发。原生卡片分享目前无法验证送达,发出后会保留不确定状态。
144
+ - **用稳定 ID 定位。** 作品、用户和会话以 CLI 返回的稳定 ID 为准;昵称只用于展示。
145
+ - **采样不等于完整观看。** 列表只包含已加载内容,通常标记 `complete: false`;直播画面不作为可读内容返回。判断由 Agent 根据实际证据作出。
146
+ - **对外动作需要授权。** 评论、私信、关注和直播发言作用于真实账号;礼物会花真钱,必须明确同意并核对名称和价格。删除或撤回受平台条件限制,不能保证收回已被看到的内容。
147
+ - **登录和数据留在本机。** 自动化可能触发验证、限流或账号处罚;验证需要真人完成,项目不提供绕过手段。`~/.dy` 含登录态、消息和记忆,不应提交或共享。详见 [SECURITY.md](SECURITY.md)。
148
+
149
+ ## 安装方式与工作区管理
150
+
151
+ <details>
152
+ <summary><strong>其他安装方式、卸载与重置</strong></summary>
153
+
154
+ 从 GitHub 源码安装,会执行构建:
114
155
 
115
156
  ```bash
116
- dy guide # 返回完整的使用说明(约 3 万字),agent 读这个
157
+ npm install -g github:9Ashwin/ShuaDouyin
117
158
  ```
118
159
 
119
- 或者把 skill 装进你的 agent 技能目录,这样它知道什么时候该用 `dy`、怎么用:
160
+ macOS / Linux 也可以使用独立目录安装器:
120
161
 
121
162
  ```bash
122
- npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
163
+ curl -fsSL https://raw.githubusercontent.com/9Ashwin/ShuaDouyin/main/scripts/install.sh | sh
123
164
  ```
124
165
 
125
- `skills/dy` 里按主题分成八个参考文件:浏览与成长、记忆与人格、社交、直播、日报、自我改进、命令全表,以及**平台事实**——最后一个是踩过的坑,读它能省掉一次重来。
166
+ 检查 skill 副本与当前版本是否一致:
126
167
 
127
- `SKILL.md` 本身只有两百来行,是每次对话都会加载的那部分;参考文件按需读。
168
+ ```bash
169
+ dy skill status
170
+ ```
128
171
 
129
- ## 设计原则
172
+ 卸载 npm 全局安装的命令,保留 `~/.dy`:
130
173
 
131
- - **不做判断。** `dy` 不内建模型。它返回事实和候选,判断留给调用方。它对着一个真实账号操作,所以代码的职责是让每个动作可审计、可回滚,而不是替你决定这个动作该不该做。
132
- - **写之前先落收据。** 每条写入在可能发出之前就已落盘,重放同一个 request ID 返回原收据。`delivery_uncertain` 永远不自动重试——那条可能已经发出去了,再发就是两条。
133
- - **身份用稳定 ID。** 昵称会重名会改,`sec_uid` 不会。
134
- - **读到什么说什么。** 直播画面是 canvas,读不到就不报内容;评论是抽样,标 `complete: false`。
135
- - **记忆跟账号走。** 换一个安装就是新的 bot,不继承别人的判断。
174
+ ```bash
175
+ npm uninstall -g shuadouyin
176
+ ```
136
177
 
137
- ## 重置
178
+ 重置人格、记忆及活动状态,会先备份;Chrome 登录保留:
138
179
 
139
180
  ```bash
140
- dy bot reset # 看会清掉什么(不改动)
141
- dy bot reset --write # 备份到 ~/.dy-backup-<时间> 后清空
142
- dy init # 建新的 bot
181
+ dy bot reset # 只预览范围
182
+ dy bot reset --write # 备份后重置,以返回的备份路径为准
183
+ dy init # 重新建立工作区
143
184
  ```
144
185
 
145
- Chrome 登录会保留,不用重新扫码。
186
+ </details>
146
187
 
147
- ## 开发
188
+ ## 开发与参与
148
189
 
149
190
  需要 Node.js 22.19+、pnpm 和本机 Chrome。
150
191
 
151
192
  ```bash
152
193
  pnpm install --frozen-lockfile --ignore-scripts
153
194
  pnpm build
195
+ pnpm check # Biome + TypeScript
154
196
  pnpm test # 单元测试
155
- pnpm test:browser # 隔离 Chrome 的浏览器测试
156
- pnpm check # Biome + tsc
197
+ pnpm test:browser # 临时 Chrome profile + fixture 页面
157
198
  ```
158
199
 
159
- 输出契约、收据状态和存储语义见 [docs/contracts.md](docs/contracts.md)。源码结构、模块归属和检查流程见 [AGENTS.md](AGENTS.md)。
160
-
161
- ## 风险与边界
162
-
163
- 这是一个操作真实抖音账号的工具。用它意味着接受下面几件事:
164
-
165
- - **账号风险由你承担。** 自动化使用抖音可能触发平台的人机验证、限流或处罚。项目不提供任何绕过手段,也不会帮你规避风控。验证弹出来时,需要真人在有头的 Chrome 里完成。
166
- - **发出去的东西撤不回来。** 评论、私信、弹幕、礼物都是对外且不可逆的。送礼花的是真钱。发之前先确认对象和内容,`--dry-run` 可以先看计划。
167
- - **本地状态含敏感信息。** `~/.dy` 里有登录态、读过的消息和长期记忆。不要提交、不要截图、不要放进共享目录。详见 [SECURITY.md](SECURITY.md)。
168
- - **读到的不是全部。** 列表是抽样并标 `complete: false`,直播画面是 canvas 读不到字。基于抽样下结论是你自己的判断,不是工具的结论。
169
- - **它不做判断。** 该不该发、发给谁、这个人值不值得认识,命令不会替你决定,也不会因为调用方是 agent 就降低门槛。
170
-
171
- ## 状态
172
-
173
- 早期阶段。命令、错误码和返回字段是公开契约,会尽量保持稳定;数据库结构随版本迁移。CI 在 Ubuntu、macOS 和 Windows 上跑单元测试与隔离 Chrome 测试。
174
-
175
- ## 参与
200
+ 浏览器测试不使用账号凭据,通过测试不等于通过真实抖音页面验证。项目仍处于早期阶段,页面变化可能影响操作;错误码、参数和返回字段按公开契约维护。
176
201
 
177
- 见 [CONTRIBUTING.md](CONTRIBUTING.md)。安全问题走私密报告渠道,见 [SECURITY.md](SECURITY.md)。
202
+ 开发规则见 [AGENTS.md](AGENTS.md),架构见 [docs/architecture.md](docs/architecture.md),贡献方式见 [CONTRIBUTING.md](CONTRIBUTING.md),版本变化见 [CHANGELOG.md](CHANGELOG.md)。
178
203
 
179
204
  ## 许可
180
205
 
181
- MIT
206
+ [MIT](LICENSE)
package/dist/app.js CHANGED
@@ -5,6 +5,7 @@ import { CliError, normalizeError } from "./errors.js";
5
5
  import { Knowledge, knowledgeOperation, validateKnowledge } from "./knowledge.js";
6
6
  import { migrateLegacyLayout } from "./migrate.js";
7
7
  import { askDouyin } from "./operations/ai-search.js";
8
+ import { readAiSummary } from "./operations/ai-summary.js";
8
9
  import { rounds } from "./operations/browsing.js";
9
10
  import { audio, capture, noteImage, noteSlide, observe } from "./operations/capture.js";
10
11
  import { captureChatImage, listChats, readChat } from "./operations/chat.js";
@@ -18,6 +19,7 @@ import { closeComments, commentVoice, knownVoices, listComments, listReplies, ph
18
19
  import { inspectCreator } from "./operations/creator.js";
19
20
  import { digest } from "./operations/digest.js";
20
21
  import { pageFactsRead } from "./operations/facts.js";
22
+ import { readFavorites } from "./operations/favorites.js";
21
23
  import { readImage } from "./operations/image-file.js";
22
24
  import { readInbox } from "./operations/inbox.js";
23
25
  import { resolveLink } from "./operations/links.js";
@@ -252,9 +254,16 @@ export class App {
252
254
  // starting. Reading "the open round" after the fact would attach a
253
255
  // long sweep to whichever round happened to be open when it ended.
254
256
  const open = rounds(await this.store.events()).find((round) => round.status === "open");
255
- const data = open && result && typeof result === "object"
256
- ? { ...result, _browse: { round_id: open.id, started_at: open.started_at } }
257
- : result;
257
+ // How long the page call took is measured here, where the clock is
258
+ // already running. Only failures carried a duration before, so
259
+ // "which command is slow" could be answered for the ones that broke
260
+ // and never for the ones that worked — which is the question a
261
+ // caller actually has.
262
+ const elapsed = Math.max(0, Math.round(performance.now() - started));
263
+ const browse = open ? { round_id: open.id, started_at: open.started_at, elapsed_ms: elapsed } : null;
264
+ const data = !open && !(result && typeof result === "object")
265
+ ? result
266
+ : { ...result, _elapsed_ms: elapsed, ...(browse ? { _browse: browse } : {}) };
258
267
  await this.store.append(operation, data);
259
268
  }
260
269
  const notices = {
@@ -656,6 +665,10 @@ export class App {
656
665
  return userRelations(session, { ...input, relation: operation === "user.following" ? "following" : "followers" }, signal);
657
666
  case "ai.ask":
658
667
  return askDouyin(session, input, signal);
668
+ case "video.ai":
669
+ return readAiSummary(session, input, signal);
670
+ case "video.favorites":
671
+ return readFavorites(session, input, signal);
659
672
  case "person.read":
660
673
  input.user_id = resourceID(input.resource ?? "", "user");
661
674
  return readPerson(session, input, signal);
package/dist/catalog.js CHANGED
@@ -900,6 +900,20 @@ export const COMMANDS = [
900
900
  effect: "account_write",
901
901
  guidance: "Read video info first. --value false removes the like; an already matching state does not click the button. Image posts use their own controls and never fall back to the video button.",
902
902
  },
903
+ {
904
+ id: "video.favorites",
905
+ description: "List the works this account has saved, from its own profile tab",
906
+ flags: [
907
+ {
908
+ key: "target",
909
+ name: "--tab <name>",
910
+ description: "收藏 (default), 喜欢, 观看历史, 稍后再看, 我的预约",
911
+ type: "string",
912
+ },
913
+ ],
914
+ effect: "read",
915
+ guidance: "Opens this account's own profile and reads the work grid on the named tab. Writing a favorite already existed; this is the read side, without which a caller adds to a collection it cannot see. Only works loaded in the window are listed and the page states no total, so complete is always false. A tab that comes back empty is empty, not still loading: the reader waits for the grid to change and then reports what is there.",
916
+ },
903
917
  {
904
918
  id: "video.favorite",
905
919
  description: "Set the desired favorite state of the current video or image post",
@@ -912,6 +926,13 @@ export const COMMANDS = [
912
926
  flags: [{ key: "output", name: "--output <path>", description: "PNG output path", type: "string" }],
913
927
  effect: "local",
914
928
  },
929
+ {
930
+ id: "video.ai",
931
+ description: "Read the platform's own summary of the current work",
932
+ flags: [video],
933
+ effect: "read",
934
+ guidance: "Opens the work's AI panel and returns what Douyin's model wrote about it. This covers what frames cannot — the whole video, the lyrics, what the comments are about — and it is the platform describing its own content rather than an observation: an answer here can be wrong in ways a screenshot cannot. Every response carries that distinction in its limits, and complete:false means the panel had not finished writing, so the text is partial. Use video digest for what the page shows and this for what the platform says about it; a claim worth repeating should be checked against the work.",
935
+ },
915
936
  {
916
937
  id: "video.digest",
917
938
  description: "Gather what a work is about: identity, sampled positions and loaded comments",
@@ -929,6 +950,13 @@ export const COMMANDS = [
929
950
  },
930
951
  { key: "limit", name: "--limit <count>", description: "Comments to read (default 8)", type: "number" },
931
952
  { key: "output", name: "--output <directory>", description: "Frame output directory", type: "string" },
953
+ {
954
+ key: "ai",
955
+ name: "--ai",
956
+ description: "Also read the platform's own summary of the work",
957
+ type: "boolean",
958
+ default: false,
959
+ },
932
960
  ],
933
961
  effect: "local",
934
962
  guidance: "A title states the form, not the point. Three positions inside a work carry the argument, and the comment section says who is watching and what they took from it. Sampling avoids the opening and the end, which are the least informative parts. Frames are viewport screenshots and are labelled as samples, never as a claim about the whole work. A work under 15 seconds is not sampled: it is its own summary. Use --frames 0 to skip seeking entirely.",
package/dist/knowledge.js CHANGED
@@ -481,7 +481,46 @@ export class Knowledge {
481
481
  remaining -= kept.length;
482
482
  return { content: kept.join(""), truncated: kept.length < chars.length };
483
483
  };
484
- const documentViews = documents.map((document) => ({ ...document, ...excerpt(document.content, 8000) }));
484
+ // The persona is projected by section, not sliced from the front.
485
+ //
486
+ // Slicing is right for memories and wrong for a persona: identity and
487
+ // voice sit after the role introduction, so a budget that runs out
488
+ // silently removes the part the agent is supposed to sound like, and
489
+ // nothing in the output says it was cut. The projection returns whole
490
+ // sections and marks each one, and persona show uses the same function
491
+ // so two callers cannot disagree about what the persona is.
492
+ const documentViews = documents.map((document) => {
493
+ if (document.file !== "AGENTS.md")
494
+ return { ...document, ...excerpt(document.content, 8000) };
495
+ const sectionBudget = Math.min(4000, remaining);
496
+ const surface = personaSurface(BOT_ID, document.file, document.revision, document.content, sectionBudget);
497
+ // A file with no sections is not a persona with nothing in it. The
498
+ // projection cannot read it, so the document is passed through as
499
+ // written and the flags say the shape was not understood — an empty
500
+ // content field would say the persona itself is empty.
501
+ if (surface.needs_interpretation)
502
+ return { ...document, ...excerpt(document.content, 8000) };
503
+ const parts = surface.parts.filter((part) => part.complete);
504
+ // The remaining sections are reported, not dropped: a caller that sees
505
+ // complete:false knows to raise the budget or read the document.
506
+ remaining -= surface.characters;
507
+ return {
508
+ ...document,
509
+ content: parts
510
+ .map((part) => `${part.name === "identity" ? "## 身份与声音" : part.name}\n\n${part.text}`)
511
+ .join("\n\n"),
512
+ truncated: !surface.complete,
513
+ persona: {
514
+ name: surface.name,
515
+ sections: surface.parts.map((part) => ({
516
+ name: part.name,
517
+ complete: part.complete,
518
+ })),
519
+ missing: surface.missing,
520
+ needs_interpretation: true,
521
+ },
522
+ };
523
+ });
485
524
  const memoryViews = packed.items.map(({ hit, body, truncated }) => {
486
525
  const row = hit.entry.row;
487
526
  const short = (value, max) => Array.from(value).slice(0, max).join("");
@@ -0,0 +1,52 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { CliError } from "../errors.js";
3
+ import { control } from "./navigation.js";
4
+ export const AI_SUMMARY_LIMITS = [
5
+ "Written by Douyin's model about its own content, not observed by this tool. Quote it as the platform's description, never as something seen here.",
6
+ "It can describe what the frames cannot: the whole video, lyrics, and what the comments are about.",
7
+ "Read the ending marker as the signal it finished. Without it the answer was still streaming, and this returns complete:false rather than the partial text.",
8
+ ];
9
+ export async function readAiSummary(session, input, signal) {
10
+ const open = await session.call("video.info", input);
11
+ const workID = open.video?.id ?? null;
12
+ const point = await session.call("ai.entry.point", {});
13
+ await session.tab.mouse.click(point.x, point.y);
14
+ // The panel streams. Two reads in a row with the same text means it stopped;
15
+ // waiting for a non-empty read alone would return the first sentence of an
16
+ // answer that is still being written.
17
+ const deadline = Date.now() + 30_000;
18
+ let previous = "";
19
+ let stable = 0;
20
+ let last = { complete: false, summary: "", why: "" };
21
+ while (Date.now() < deadline) {
22
+ signal.throwIfAborted();
23
+ await delay(600, undefined, { signal });
24
+ last = await session.call("ai.summary", {});
25
+ // The disclaimer is the page stating the answer is done, which is stronger
26
+ // evidence than a stable read.
27
+ if (last.complete) {
28
+ if (last.summary && last.summary === previous)
29
+ stable++;
30
+ else
31
+ stable = 0;
32
+ previous = last.summary;
33
+ if (stable >= 1)
34
+ break;
35
+ }
36
+ }
37
+ if (!last.summary)
38
+ throw new CliError("timeout", "The AI panel did not produce a summary", {
39
+ hint: last.why || "The entry may have opened a different panel, or the page changed.",
40
+ });
41
+ // A panel stays open across feed moves and would be read as this work's
42
+ // summary. Closing is best-effort: a panel that is already gone has nothing
43
+ // to close, and failing here would throw away a summary already read.
44
+ await control(session, "chat.close").catch(() => undefined);
45
+ return {
46
+ work_id: workID,
47
+ summary: last.summary,
48
+ complete: last.complete && stable >= 1,
49
+ limits: AI_SUMMARY_LIMITS,
50
+ };
51
+ }
52
+ //# sourceMappingURL=ai-summary.js.map
@@ -203,6 +203,25 @@ export function roundSummary(events, id) {
203
203
  inspected,
204
204
  interacted: [...interacted].length,
205
205
  elapsed_ms: elapsed,
206
+ // Time spent inside page calls, apart from how long the round was open. A
207
+ // round can be open for an hour with four minutes of browser work in it,
208
+ // and the difference is the caller thinking, not the tool being slow.
209
+ browser_ms: (() => {
210
+ let total = 0;
211
+ for (const event of inside) {
212
+ const data = record(event);
213
+ if (typeof data._elapsed_ms === "number" && data._elapsed_ms >= 0)
214
+ total += data._elapsed_ms;
215
+ }
216
+ return total;
217
+ })(),
218
+ // The same work returned by more than one command. Counting it once is
219
+ // right for the budget and misleading for cost: reading a work twice is
220
+ // paid for twice.
221
+ duplicate_observations: (() => {
222
+ const all = observations(inside);
223
+ return all.length - uniqueObservations(all).length;
224
+ })(),
206
225
  budget: round.budget,
207
226
  // What the round is delivering against, and how far along it is. The
208
227
  // qualified count comes from the checkpoints, so it says what the caller
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { classifyComment } from "../comment-stance.js";
4
4
  import { CliError } from "../errors.js";
5
+ import { AI_SUMMARY_LIMITS, readAiSummary } from "./ai-summary.js";
5
6
  import { captureSnapshot, noteImage, noteSlide } from "./capture.js";
6
7
  import { listComments } from "./comments.js";
7
8
  // Long works earn more samples; a nine-second clip is its own summary. The
@@ -53,6 +54,11 @@ export async function digest(session, store, input, signal) {
53
54
  const duration = initial.video?.duration ?? null;
54
55
  const frames = [];
55
56
  const limitations = [];
57
+ // Requested before the frames so a failure here is the caller's error rather
58
+ // than a digest that quietly comes back with pictures and no summary.
59
+ const ai = input.ai
60
+ ? await readAiSummary(session, { ...input, video_id: initial.video?.id ?? initial.note?.id ?? undefined }, signal)
61
+ : null;
56
62
  const directory = store.path(join("captures", `digest-${work.id}`));
57
63
  // An image post is a set of pictures, and the set is the work. Sampling a video
58
64
  // means picking moments inside one; sampling an image post means looking at every
@@ -164,9 +170,15 @@ export async function digest(session, store, input, signal) {
164
170
  },
165
171
  frames,
166
172
  comments,
173
+ // The platform's own description, when asked for. It goes in a field of its
174
+ // own and never into `frames`: one is what was seen, the other is what the
175
+ // platform says, and merging them would make the difference invisible at the
176
+ // point a caller reads it.
177
+ ...(ai ? { platform_summary: ai } : {}),
167
178
  scope: frames.length ? "sampled_positions_and_loaded_comments" : "loaded_comments",
168
179
  limitations: [
169
180
  "Frames are screenshots of the viewport: they show what was on screen, not a claim about the whole work.",
181
+ ...(ai ? [AI_SUMMARY_LIMITS[0]] : []),
170
182
  ...limitations,
171
183
  ],
172
184
  };
@@ -0,0 +1,67 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { CliError } from "../errors.js";
3
+ import { navigate } from "./navigation.js";
4
+ // The tab labels the profile offers. Reading a different tab is the same code
5
+ // path with a different label, so this is not only about favorites.
6
+ const TABS = ["收藏", "喜欢", "观看历史", "稍后再看", "我的预约"];
7
+ export async function readFavorites(session, input, signal) {
8
+ const requested = (input.target ?? "收藏").trim();
9
+ if (!TABS.includes(requested))
10
+ throw new CliError("invalid_argument", `Unknown profile tab "${requested}"`, {
11
+ hint: `Accepted: ${TABS.join(", ")}`,
12
+ });
13
+ const limits = [
14
+ "Screenshots and the private area are not read; this is the work grid as the page renders it.",
15
+ "Only works loaded in the current window are listed. Scroll the page for more.",
16
+ "A work being listed here says it is on this tab, not why the account saved it.",
17
+ ];
18
+ await navigate(session, "https://www.douyin.com/user/self", "profile", signal);
19
+ // The tab strip renders with the header, a moment after the route settles. A
20
+ // read taken on the navigation's word found no tabs at all and reported the
21
+ // page as changed.
22
+ const tabDeadline = Date.now() + 6000;
23
+ while (Date.now() < tabDeadline) {
24
+ signal.throwIfAborted();
25
+ try {
26
+ await session.call("profile.tab.point", { target: requested });
27
+ break;
28
+ }
29
+ catch (error) {
30
+ if (error instanceof CliError && error.code === "selector_changed") {
31
+ await delay(300, undefined, { signal });
32
+ continue;
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ const before = await session.call("profile.tab.works", {});
38
+ const point = await session.call("profile.tab.point", { target: requested });
39
+ await session.tab.mouse.click(point.x, point.y);
40
+ await delay(400, undefined, { signal });
41
+ // The grid replaces the previous tab's list rather than appending to it, so
42
+ // waiting for the first work to differ is what tells the two apart. Reading
43
+ // before that returns the previous tab and calls it this one.
44
+ const deadline = Date.now() + 6000;
45
+ let works = before.works;
46
+ while (Date.now() < deadline) {
47
+ signal.throwIfAborted();
48
+ await delay(300, undefined, { signal });
49
+ const read = await session.call("profile.tab.works", {});
50
+ if (read.works.length && JSON.stringify(read.works) !== JSON.stringify(before.works)) {
51
+ works = read.works;
52
+ break;
53
+ }
54
+ // An empty tab is a real answer: the list is empty rather than still
55
+ // loading. It is taken once the wait has run out, never on the first read.
56
+ works = read.works;
57
+ }
58
+ return {
59
+ tab: requested,
60
+ works,
61
+ // The page does not state a total, so "complete" would be a claim about a
62
+ // number nothing here knows.
63
+ complete: false,
64
+ limits,
65
+ };
66
+ }
67
+ //# sourceMappingURL=favorites.js.map
@@ -90,6 +90,13 @@ export function validate(operation, raw) {
90
90
  throw new CliError("invalid_argument", `--${key} must be an integer from ${minimum} to ${maximum}`);
91
91
  }
92
92
  switch (operation) {
93
+ case "video.favorites":
94
+ // The tab name is checked before Chrome starts. A page command that
95
+ // validated after connecting reported browser_not_running to a caller
96
+ // whose actual mistake was a typo in the tab name.
97
+ if (input.target && !["收藏", "喜欢", "观看历史", "稍后再看", "我的预约"].includes(input.target))
98
+ throw new CliError("invalid_argument", "--tab must be 收藏, 喜欢, 观看历史, 稍后再看 or 我的预约");
99
+ break;
93
100
  case "search":
94
101
  case "user.search":
95
102
  if (!input.query?.trim() || input.query.length > 300)
@@ -765,6 +765,29 @@ var DouyinPage = (() => {
765
765
  const label = clean(container.getAttribute("aria-label") || container.getAttribute("title")) || images.map((image) => image.label).filter(Boolean).join(" ");
766
766
  return { name: label || null, images, requires_visual: !label };
767
767
  }
768
+ function aiEntryPoint() {
769
+ const entries2 = findRendered(document, '[class*="ai-douyin-entry"]').filter((element) => {
770
+ const box2 = element.getBoundingClientRect();
771
+ return box2.width > 0 && box2.height > 0 && box2.bottom > 0 && box2.top < innerHeight;
772
+ });
773
+ const entry = one(entries2, "AI summary entry on screen");
774
+ const box = entry.getBoundingClientRect();
775
+ return { x: Math.round(box.left + box.width / 2), y: Math.round(box.top + box.height / 2) };
776
+ }
777
+ function aiSummary() {
778
+ const text = document.body.innerText;
779
+ const marker = "\u4EE5\u4E0A\u5185\u5BB9\u7531AI\u751F\u6210";
780
+ const end = text.indexOf(marker);
781
+ if (end < 0) return { complete: false, summary: "", why: "The AI panel is not showing a finished answer" };
782
+ const head = text.slice(0, end);
783
+ const tabs = /(?:TA的作品|相关推荐)\s*\n?\s*评论?\s*\n?\s*相关推荐\s*\n*/;
784
+ const after = tabs.test(head) ? head.slice(head.search(tabs) + (head.match(tabs)?.[0].length ?? 0)).trim() : head.trim();
785
+ return {
786
+ complete: true,
787
+ summary: clean(after),
788
+ why: ""
789
+ };
790
+ }
768
791
  function readShare(container) {
769
792
  let videoID2 = null;
770
793
  let kind = null;
@@ -2650,10 +2673,22 @@ var DouyinPage = (() => {
2650
2673
  for (const anchor of anchors) {
2651
2674
  const id = /\/video\/(\d+)/.exec(anchor.getAttribute("href") ?? "")?.[1];
2652
2675
  if (!id || works.has(id)) continue;
2653
- works.set(id, { url: anchor.href, title: clean(anchor.textContent) });
2676
+ const text = clean(anchor.textContent);
2677
+ const half = text.slice(0, Math.floor(text.length / 2));
2678
+ works.set(id, { url: anchor.href, title: half && text === half + half ? half : text });
2654
2679
  }
2655
2680
  return [...works.values()];
2656
2681
  }
2682
+ function profileTabPoint(label) {
2683
+ const entries2 = [...document.querySelectorAll('[data-e2e="user-tab-icon"]')].map((icon) => ({ icon, label: clean(icon.parentElement?.textContent) })).filter((entry2) => entry2.label === label);
2684
+ const entry = one(entries2, `profile tab "${label}"`);
2685
+ const box = entry.icon.getBoundingClientRect();
2686
+ if (box.width === 0 || box.height === 0) fail("selector_changed", `The "${label}" tab is not laid out`);
2687
+ return { x: Math.round(box.left + box.width / 2), y: Math.round(box.top + box.height / 2) };
2688
+ }
2689
+ function tabWorks() {
2690
+ return readProfileWorks();
2691
+ }
2657
2692
  var hoverPoint = null;
2658
2693
  async function execute(operation, args) {
2659
2694
  switch (operation) {
@@ -2934,6 +2969,10 @@ var DouyinPage = (() => {
2934
2969
  index
2935
2970
  };
2936
2971
  }
2972
+ case "ai.entry.point":
2973
+ return aiEntryPoint();
2974
+ case "ai.summary":
2975
+ return aiSummary();
2937
2976
  case "chat.detail.open":
2938
2977
  return findRendered(document, '[class*="messageMessageListlist"],[data-e2e="message-list"]').length > 0;
2939
2978
  case "chat.recall.target":
@@ -3208,6 +3247,10 @@ var DouyinPage = (() => {
3208
3247
  }
3209
3248
  case "profile.works":
3210
3249
  return { works: readProfileWorks().length };
3250
+ case "profile.tab.point":
3251
+ return profileTabPoint(args.target ?? "\u6536\u85CF");
3252
+ case "profile.tab.works":
3253
+ return { works: tabWorks() };
3211
3254
  case "user.get": {
3212
3255
  const id = userIdentity(args.user_id);
3213
3256
  const matches = find(document, '[data-e2e="user-info"],[data-e2e="user-detail"]');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shuadouyin",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "packageManager": "pnpm@10.34.7",
5
5
  "description": "Browse Douyin with a persistent bot persona and a memory of its own",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "skills",
22
22
  ".dy-build-commit",
23
23
  "!dist/**/*.map",
24
+ "!docs/assets",
24
25
  "!dist/**/*.d.ts",
25
26
  ".dy-build-origin"
26
27
  ],
@@ -104,6 +104,8 @@ dy note capture --index 0 --output x.png
104
104
 
105
105
  ```bash
106
106
  dy video digest [--frames N --limit 8] # 标题 + 采样画面 + 评论;不写 --frames 就按时长自动选
107
+ dy video ai # 平台自己的 AI 对这条作品的描述
108
+ dy video digest --ai # 一次拿到截图和平台描述
107
109
  dy video seek --at 120 # 跳到第 120 秒
108
110
  dy video observe --at 200 --seconds 4 --frames 2 # 在指定位置采样
109
111
  dy video play [--video-id <id>] # 播放
@@ -116,6 +118,10 @@ dy video audio --seconds 10 --output a.webm
116
118
 
117
119
  `--frames 0` 只读身份和评论,不采样——筛候选时用这个,比每条都截图快得多。
118
120
 
121
+ **`video ai` 看的是平台模型写的描述,不是画面。** 它能覆盖截图覆盖不到的:整条视频发生了什么、歌词唱什么、评论区在聊什么。但它是抖音在描述自己的内容,可能错、可能空泛、可能顺着你说——所以它返回的每条都带 `limits` 说明这一点。
122
+
123
+ 用截图能确认的事实用 `digest`;想知道「这条到底讲了什么」用 `ai`,但引述时要说「平台的说法」,不能说成「我看到的」。`complete: false` 表示它还没写完,不要把那半段当全文。
124
+
119
125
  `--frames 0` 跳过所有采样只读评论(最快);短片自动不采样并说明原因。
120
126
 
121
127
  `video.seek` 越界会**拒绝**而不是截断——传了超出时长的位置说明你猜错了时长,需要知道。
@@ -126,7 +132,8 @@ dy video audio --seconds 10 --output a.webm
126
132
 
127
133
  ```bash
128
134
  dy video like --value true|false
129
- dy video favorite --value true|false
135
+ dy video favorite --video-id <id> --value true|false # 收藏/取消
136
+ dy video favorites # 读自己的收藏列表(--tab 可换 喜欢/观看历史/稍后再看)
130
137
  dy user follow --value true|false
131
138
  ```
132
139