shuadouyin 0.4.1 → 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
- d9528376f4f68e7e7414bc7968f619267fd656b4
1
+ e648c8d2d2d011a9aec54030287f9a8cf4947bf1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
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
+
3
21
  ## 0.4.1 — 2026-10-31
4
22
 
5
23
  ### 文档
package/README.en.md CHANGED
@@ -29,9 +29,26 @@ ShuaDouyin's executable is `dy`. Give it to an AI Agent that can run shell comma
29
29
 
30
30
  ## Quick start
31
31
 
32
+ ### Copy an installation prompt (recommended)
33
+
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
+ ```
44
+
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
+
32
49
  You need **Node.js 22.19+, Chrome, and an Agent that can execute shell commands**.
33
50
 
34
- ### 1. Install the CLI and skill
51
+ #### 1. Install the CLI and skill
35
52
 
36
53
  ```bash
37
54
  npm install -g shuadouyin
@@ -41,7 +58,7 @@ dy skill install
41
58
 
42
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.
43
60
 
44
- ### 2. Create the workspace and log in
61
+ #### 2. Create the workspace and log in
45
62
 
46
63
  ```bash
47
64
  dy init
@@ -50,9 +67,11 @@ dy auth login
50
67
 
51
68
  Scan the login QR code in the dedicated Chrome window. The default workspace is `~/.dy`; the browser login persists in `~/.dy/chrome`.
52
69
 
53
- ### 3. Start a conversation with your Agent
70
+ ### Start using it
71
+
72
+ After setup, send this to your Agent:
54
73
 
55
- > Use dy to help me set up a name, voice and boundaries. Then browse five works, pick one to inspect its frames and comments, and tell me what you observed. Only read this round; do not interact.
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.
56
75
 
57
76
  You can also start with a specific task:
58
77
 
@@ -96,11 +115,9 @@ Updating or reinstalling the CLI continues to use the existing workspace. Use th
96
115
 
97
116
  ## Agent integration
98
117
 
99
- The [dy skill](skills/dy/SKILL.md) teaches Agents to use the tool, with topic-specific workflows in [references](skills/dy/references/). As an alternative to `dy skill install`, use the skills installer:
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.
100
119
 
101
- ```bash
102
- npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
103
- ```
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.
104
121
 
105
122
  Start each task by reading persona and relevant context:
106
123
 
package/README.md CHANGED
@@ -29,9 +29,26 @@ ShuaDouyin 的命令叫 `dy`。把它交给能执行 shell 的 AI Agent,就能
29
29
 
30
30
  ## 快速开始
31
31
 
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
+ 已有配置和记忆请保留,这次只做安装、配置和只读验证。
43
+ ```
44
+
45
+ skill 已包含 CLI 的安装指引,Agent 会先检查 `dy --version`,缺少 CLI 时按 `references/setup.md` 继续安装。技能安装器会从 GitHub 获取内容,你无需手动打开仓库查文档。`dy` 使用你现有的 Agent,不需要单独配置模型 API Key;抖音登录由你在本机 Chrome 中完成。
46
+
47
+ ### 自己安装
48
+
32
49
  准备好 **Node.js 22.19+、Chrome,以及一个能执行 shell 的 Agent**。
33
50
 
34
- ### 1. 安装工具与 skill
51
+ #### 1. 安装工具与 skill
35
52
 
36
53
  ```bash
37
54
  npm install -g shuadouyin
@@ -41,7 +58,7 @@ dy skill install
41
58
 
42
59
  npm 包叫 `shuadouyin`,可执行命令叫 `dy`。发布包已包含构建产物;使用本机 Chrome,不额外下载浏览器。`dy skill install` 会为已检测到的 Agent 安装技能,已有的非托管副本会保留。
43
60
 
44
- ### 2. 建立工作区并登录
61
+ #### 2. 建立工作区并登录
45
62
 
46
63
  ```bash
47
64
  dy init
@@ -50,9 +67,11 @@ dy auth login
50
67
 
51
68
  在打开的专用 Chrome 窗口里扫码登录。工作区默认位于 `~/.dy`,浏览器登录保存在 `~/.dy/chrome`,后续继续使用。
52
69
 
53
- ### 3. 在你的 Agent 里开始聊
70
+ ### 开始使用
71
+
72
+ 设置好后,把这句话发给你的 Agent:
54
73
 
55
- > 用 dy 带我完成设置,先问我名字、语气和边界。然后刷 5 条,挑 1 条读画面和评论,告诉我看到了什么。这轮只看,不互动。
74
+ > 用 dy 刷 5 条,挑 1 条读画面和评论,告诉我看到了什么。这轮只看,不互动。如果还没设置人格,先带我完成设置。
56
75
 
57
76
  也可以从具体任务开始:
58
77
 
@@ -96,11 +115,9 @@ dy auth login
96
115
 
97
116
  ## 给 Agent 接入
98
117
 
99
- [dy skill](skills/dy/SKILL.md) 负责教 Agent 如何使用工具,主题流程放在 [references](skills/dy/references/) 中。除了 `dy skill install`,也可以使用技能安装器:
118
+ 首次安装可以直接使用上面的[安装提示词](#复制给-agent-安装推荐)。下面是技能入口和日常调用顺序。
100
119
 
101
- ```bash
102
- npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
103
- ```
120
+ [dy skill](skills/dy/SKILL.md) 负责教 Agent 安装和使用工具,主题流程放在 [references](skills/dy/references/) 中。如果已经安装 CLI,也可以运行 `dy skill install` 接入技能。
104
121
 
105
122
  每次任务先读人格和相关上下文,再读页面:
106
123
 
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.1",
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",
@@ -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