shuadouyin 0.3.3 → 0.3.5

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
- 9dfa8e6e905ba375b63fc90c7da17e95a6be4be8
1
+ 74b35764d458acaeeff44eaef3fe9e9a99941ac8
package/dist/app.js CHANGED
@@ -4,6 +4,7 @@ import { COMMANDS } from "./catalog.js";
4
4
  import { CliError, normalizeError } from "./errors.js";
5
5
  import { Knowledge, knowledgeOperation, validateKnowledge } from "./knowledge.js";
6
6
  import { migrateLegacyLayout } from "./migrate.js";
7
+ import { askDouyin } from "./operations/ai-search.js";
7
8
  import { audio, capture, noteImage, noteSlide, observe } from "./operations/capture.js";
8
9
  import { captureChatImage, listChats, readChat } from "./operations/chat.js";
9
10
  import { compareChatWindows } from "./operations/chat-diff.js";
@@ -617,6 +618,8 @@ export class App {
617
618
  case "user.followers":
618
619
  case "user.following":
619
620
  return userRelations(session, { ...input, relation: operation === "user.following" ? "following" : "followers" }, signal);
621
+ case "ai.ask":
622
+ return askDouyin(session, input, signal);
620
623
  case "person.read":
621
624
  input.user_id = resourceID(input.resource ?? "", "user");
622
625
  return readPerson(session, input, signal);
package/dist/catalog.js CHANGED
@@ -727,6 +727,21 @@ export const COMMANDS = [
727
727
  description: "Move to the previous video and verify that its ID changed",
728
728
  effect: "navigation",
729
729
  },
730
+ {
731
+ id: "ai.ask",
732
+ description: "Ask Douyin's own assistant a question and read what it answers",
733
+ flags: [
734
+ {
735
+ key: "query",
736
+ name: "--query <question>",
737
+ description: "What to ask, up to 500 characters",
738
+ type: "string",
739
+ required: true,
740
+ },
741
+ ],
742
+ effect: "navigation",
743
+ guidance: "Douyin's 抖音 AI answers in prose and often lists works with links. That is a different kind of evidence from everything else here: the rest reports what the page shows, this reports what the platform's model says about its own content. Treat the answer as a lead to check by opening the work, and quote it as the platform's claim. The work ids it links to are real page links and can be opened with video open. Answers stream in; complete=false means the read gave up before the text stopped growing.",
744
+ },
730
745
  {
731
746
  id: "search",
732
747
  description: "Search Douyin and return loaded video results",
package/dist/errors.js CHANGED
@@ -74,6 +74,29 @@ export function errorExit(error) {
74
74
  return 1;
75
75
  }
76
76
  }
77
+ // Whether running the same command again could reasonably work.
78
+ //
79
+ // The field was hardcoded false, so it told a caller nothing. The distinction it
80
+ // should carry is the one that decides what to do next: a page that was mid-render
81
+ // is worth one more try, and a changed selector is not — repeating it just spends
82
+ // the same seconds for the same answer.
83
+ //
84
+ // `selector_changed` is deliberately in neither list. It is the one code that
85
+ // covers both a transition and a genuine structural change, and the page adapter
86
+ // already names which happened when it can ("no size", "covered", "not clickable").
87
+ // Marking it retryable would turn a broken page into a loop.
88
+ export function retryable(code) {
89
+ return [
90
+ // The browser or the network was not ready; nothing about the request was wrong.
91
+ "timeout",
92
+ "browser_not_running",
93
+ "browser_unavailable",
94
+ // Another command held the lease. It has since finished.
95
+ "busy",
96
+ // The page had not finished laying out.
97
+ "editor_not_ready",
98
+ ].includes(code);
99
+ }
77
100
  export function errorData(error) {
78
101
  const problem = normalizeError(error);
79
102
  return {
@@ -81,7 +104,7 @@ export function errorData(error) {
81
104
  code: problem.code,
82
105
  message: problem.message,
83
106
  ...(problem.hint ? { hint: problem.hint } : {}),
84
- retryable: false,
107
+ retryable: retryable(problem.code),
85
108
  ...(problem.details === undefined ? {} : { details: problem.details }),
86
109
  };
87
110
  }
@@ -0,0 +1,42 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { CliError } from "../errors.js";
3
+ import { navigate } from "./navigation.js";
4
+ const LIMITS = "This is Douyin's assistant answering about its own content, not an observation of any work. Treat the prose as a lead to check by opening the work, and quote it as the platform's claim rather than as something this tool saw. The ids are real page links and can be opened with dy video open.";
5
+ export async function askDouyin(session, input, signal) {
6
+ const question = (input.query ?? "").trim();
7
+ if (!question)
8
+ throw new CliError("invalid_argument", "Ask something: --query <question>");
9
+ if (question.length > 500)
10
+ throw new CliError("invalid_argument", "Keep the question under 500 characters");
11
+ await navigate(session, "https://www.douyin.com/aisearch?from_nav=1", "agent", signal);
12
+ const prepared = await session.call("ai.prepare", { question });
13
+ if (!prepared.ready)
14
+ throw new CliError("selector_changed", "The AI search box did not appear");
15
+ await session.tab.keyboard.insertText(question);
16
+ await session.tab.keyboard.press("Enter");
17
+ // The answer streams in. Waiting for the text to stop growing is what makes the
18
+ // difference between a partial answer and a complete one, and this reports which.
19
+ const deadline = Date.now() + 45_000;
20
+ let previous = "";
21
+ let stable = 0;
22
+ let answer = null;
23
+ while (Date.now() < deadline) {
24
+ signal.throwIfAborted();
25
+ answer = await session.call("ai.answer", { question });
26
+ if (answer.answer === previous && answer.answer.length > 0)
27
+ stable++;
28
+ else
29
+ stable = 0;
30
+ previous = answer.answer;
31
+ // Two reads in a row with the same text means the stream finished.
32
+ if (stable >= 2)
33
+ return { ...answer, complete: true, limits: LIMITS };
34
+ await delay(600, undefined, { signal });
35
+ }
36
+ if (!answer || !answer.answer)
37
+ throw new CliError("timeout", "The AI search page did not answer", {
38
+ hint: "The question may have been refused, or the page changed. Read it with dy page facts.",
39
+ });
40
+ return { ...answer, complete: false, limits: LIMITS };
41
+ }
42
+ //# sourceMappingURL=ai-search.js.map
@@ -2,25 +2,47 @@ 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 { captureSnapshot } from "./capture.js";
5
+ import { captureSnapshot, noteImage, noteSlide } from "./capture.js";
6
6
  import { listComments } from "./comments.js";
7
7
  // Long works earn more samples; a nine-second clip is its own summary. The
8
8
  // fractions avoid the opening (usually a title card) and the end (usually a
9
9
  // call to follow), which are the two least informative parts of a short video.
10
+ // How many samples a work deserves, from its length.
11
+ //
12
+ // Three frames is right for a few minutes and much too few for twenty: half an hour
13
+ // sampled at 25/50/75% is three stills of somebody talking, and the argument they
14
+ // made in between is gone. A long work gets more, capped so the capture does not
15
+ // take longer than watching it.
16
+ export function samplesFor(duration, ceiling = 8) {
17
+ if (!Number.isFinite(duration) || duration <= 0)
18
+ return 0;
19
+ if (duration < 15)
20
+ return 0;
21
+ if (duration < 45)
22
+ return 1;
23
+ // One sample per two minutes past the first, plus the three the base case needs:
24
+ // 3 minutes gets 3, 10 minutes gets 5, 20 minutes gets 8.
25
+ if (duration < 180)
26
+ return 3;
27
+ return Math.min(ceiling, 3 + Math.floor((duration - 180) / 120));
28
+ }
10
29
  export function samplePositions(duration, wanted) {
11
30
  if (!Number.isFinite(duration) || duration <= 0)
12
31
  return [];
13
- // A work under fifteen seconds is its own summary.
14
- if (duration < 15)
32
+ const automatic = samplesFor(duration);
33
+ if (automatic === 0)
15
34
  return [];
16
- // Under forty-five seconds one frame in the middle is enough, and it must be
17
- // the middle whatever count was asked for: the old code took the second
18
- // element of a one-element list when --frames 2 was passed, so a 15–45 second
19
- // work sampled nothing at all and said so only through an empty array.
20
- if (duration < 45)
35
+ // An explicit count is honoured: `--frames 1` means one frame, and a caller that
36
+ // wants the middle of a long talk has a reason. What is not honoured is asking
37
+ // for nothing, which the hasValue check upstream already refuses.
38
+ const count = Math.max(wanted ?? automatic, 1);
39
+ // Until the first millisecond and after the last are both wrong: the opening is a
40
+ // title card and the end is a request to follow.
41
+ if (count === 1)
21
42
  return [duration * 0.5];
22
- const fractions = wanted >= 3 ? [0.25, 0.5, 0.75] : [0.5];
23
- return fractions.slice(0, Math.max(1, wanted)).map((f) => duration * f);
43
+ // Spread across the middle eighty percent, so the first sample is past the
44
+ // opening and the last is before the sign-off.
45
+ return Array.from({ length: count }, (_, index) => duration * (0.1 + (0.8 * index) / (count - 1)));
24
46
  }
25
47
  export async function digest(session, store, input, signal) {
26
48
  const initial = await session.call("video.info", input);
@@ -32,7 +54,44 @@ export async function digest(session, store, input, signal) {
32
54
  const frames = [];
33
55
  const limitations = [];
34
56
  const directory = store.path(join("captures", `digest-${work.id}`));
35
- if (input.frames !== 0 && initial.video) {
57
+ // An image post is a set of pictures, and the set is the work. Sampling a video
58
+ // means picking moments inside one; sampling an image post means looking at every
59
+ // one of them, because the point of the format is that you swipe through them.
60
+ if (input.frames !== 0 && initial.note) {
61
+ const slides = [];
62
+ // Until the count is known there is nothing to loop over, so each step asks
63
+ // where it is and stops when the page says it is at the end.
64
+ for (let guard = 0; guard < (input.limit ?? 20); guard++) {
65
+ signal.throwIfAborted();
66
+ try {
67
+ const captured = await noteImage(session, store, { ...input, output: join(directory, `slide-${String(slides.length + 1).padStart(2, "0")}.png`) }, signal);
68
+ slides.push(slides.length + 1);
69
+ if (captured.slide.index !== null &&
70
+ captured.slide.count !== null &&
71
+ captured.slide.index + 1 >= captured.slide.count)
72
+ break;
73
+ }
74
+ catch (error) {
75
+ // The first page failing is worth reporting; one later page failing is
76
+ // the boundary, and the pages already read are still the answer.
77
+ if (!slides.length)
78
+ limitations.push(error instanceof CliError ? error.message : "Could not read the image post");
79
+ break;
80
+ }
81
+ try {
82
+ const moved = await noteSlide(session, input, "next", signal);
83
+ if (moved.boundary || !moved.moved)
84
+ break;
85
+ }
86
+ catch {
87
+ // No further page could be reached. What was read stands.
88
+ break;
89
+ }
90
+ }
91
+ if (slides.length)
92
+ limitations.push("Every page of the image post was captured in order.");
93
+ }
94
+ else if (input.frames !== 0 && initial.video) {
36
95
  const positions = samplePositions(duration ?? 0, input.frames ?? 3);
37
96
  if (!positions.length)
38
97
  limitations.push(duration ? `${Math.round(duration)}s is short enough to read in one pass` : "This work reports no duration");
@@ -38,11 +38,15 @@ export async function navigate(session, url, kind, signal) {
38
38
  return waitSnapshot(session, (state) => kind === "video"
39
39
  ? // Douyin redirects an image post from /video/ to /note/.
40
40
  !!(state.video?.id ?? state.note?.id)
41
- : kind === "search"
42
- ? state.page_kind === "search"
43
- : // The route commits before the profile renders, so the URL alone would let
44
- // the caller read a page whose header has not appeared yet.
45
- !!state.url?.includes("/user/") && state.page_kind === "profile", signal).catch((error) => {
41
+ : kind === "agent"
42
+ ? // The AI page reports itself as an ordinary page; what identifies it is
43
+ // the route and a composer being present.
44
+ !!state.url?.includes("/aisearch")
45
+ : kind === "search"
46
+ ? state.page_kind === "search"
47
+ : // The route commits before the profile renders, so the URL alone would let
48
+ // the caller read a page whose header has not appeared yet.
49
+ !!state.url?.includes("/user/") && state.page_kind === "profile", signal).catch((error) => {
46
50
  // A profile that redirects elsewhere is a real answer, not a changed selector:
47
51
  // Douyin sends restricted or unavailable accounts to the search page. Saying
48
52
  // "the page did not reach the expected state" hides which of the two happened
@@ -3066,6 +3066,30 @@ var DouyinPage = (() => {
3066
3066
  );
3067
3067
  return { present, comment_id: args.comment_id };
3068
3068
  }
3069
+ case "ai.prepare": {
3070
+ const editor = [...document.querySelectorAll(".douyin-chat-editor,[contenteditable='true']")].find(
3071
+ (element) => {
3072
+ const box = element.getBoundingClientRect();
3073
+ return box.width > 100 && box.height > 10;
3074
+ }
3075
+ );
3076
+ if (!editor) return { ready: false };
3077
+ editor.focus();
3078
+ return { ready: true };
3079
+ }
3080
+ case "ai.answer": {
3081
+ const asked = String(args.question ?? "").trim();
3082
+ const body = document.body.innerText ?? "";
3083
+ const at = asked ? body.indexOf(asked) : -1;
3084
+ const answer = at >= 0 ? body.slice(at + asked.length).trim() : body.trim();
3085
+ const links = [...document.querySelectorAll('a[href*="/video/"]')].map((anchor) => {
3086
+ const id = /\/video\/(\d+)/.exec(anchor.getAttribute("href") ?? "")?.[1];
3087
+ return id ? { id, text: clean(anchor.textContent).slice(0, 80) } : null;
3088
+ }).filter((entry) => entry !== null);
3089
+ const unique = /* @__PURE__ */ new Map();
3090
+ for (const link of links) if (!unique.has(link.id)) unique.set(link.id, link);
3091
+ return { question: asked, answer, links: [...unique.values()] };
3092
+ }
3069
3093
  case "comment.writable": {
3070
3094
  const composer = document.querySelector(".comment-input-container,.comment-input-inner-container") ?? document.querySelector(".public-DraftEditor-content");
3071
3095
  if (!composer) {
package/dist/tui.js CHANGED
@@ -153,6 +153,7 @@ export class TerminalUI {
153
153
  { value: "live", label: "直播", description: "读直播间、发弹幕、送礼" },
154
154
  { value: "social", label: "关系", description: "目标、候选、卡片和结果" },
155
155
  { value: "person", label: "看一个人", description: "对方发过什么、写过什么" },
156
+ { value: "ai", label: "问抖音 AI", description: "让平台自己回答,当线索用" },
156
157
  { value: "browse", label: "浏览轮次", description: "开一轮、记看过和划过的、收尾" },
157
158
  { value: "note", label: "图文作品", description: "读图文帖的每一页" },
158
159
  { value: "link", label: "解析链接", description: "把分享文案或链接解析成作品 ID" },
@@ -189,6 +190,8 @@ export class TerminalUI {
189
190
  commands = COMMANDS.filter((command) => /^social\./.test(command.id));
190
191
  if (selected === "person")
191
192
  commands = COMMANDS.filter((command) => /^person\./.test(command.id));
193
+ if (selected === "ai")
194
+ commands = COMMANDS.filter((command) => /^ai\./.test(command.id));
192
195
  if (selected === "browse")
193
196
  commands = COMMANDS.filter((command) => /^browse\./.test(command.id));
194
197
  if (selected === "note")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shuadouyin",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -8,6 +8,10 @@ allowed-tools: Bash(dy:*) Bash(npx dy:*)
8
8
 
9
9
  `dy` 是本地能力工具。当前 agent 负责理解、判断、对话、总结与调度。用 `dy schema` 查看可用命令;尊重用户当前授权和已有账号状态。
10
10
 
11
+ **这份文件是常驻的,所以只放每次都用的东西。** 完整的命令说明、平台事实、各场景流程在 `dy guide` 里,它的内容和你的 `dy` 版本永远一致——本文件可能落后于一次更新。拿不准某个命令怎么用,先跑 `dy guide`,别猜。
12
+
13
+ 下面的命令列表够日常使用。遇到没列到的、或者行为和这里写的不一样,以 `dy guide` 和实际返回为准。
14
+
11
15
  ## 第一次用:先问人设
12
16
 
13
17
  新工作区里人格还是模板。这时候每个页面命令的返回里都会带一个 `setup` 字段:
@@ -65,123 +69,37 @@ dy video info # 读当前作品
65
69
 
66
70
  ## 命令
67
71
 
68
- 所有命令返回一个 `{ok:true,data}` 信封;失败是 `{ok:false,error}` 加非零退出。`dy schema` 给全部命令和参数,`dy <命令> --help` 给单个。
69
-
70
- ### 刷与看
71
-
72
- ```bash
73
- dy feed open # 打开推荐流
74
- dy feed next # 下一条;核实 ID 变了才算成功
75
- dy feed previous
76
- dy feed sweep --count 10 # 一次走 10 条,返回 id/作者/标题/时长
77
- ```
78
-
79
- **`feed sweep` 是读一批的首选。** 一条命令、一次连接,`stopped` 说明为什么停下(`count_reached` / `steps_exhausted` / `feed_stuck`)。它不快多少(瓶颈是页面过渡,一次几秒),但一次拿全数据,不用逐条 `video info`。
80
-
81
- ### 判断一条作品在讲什么
82
-
83
- ```bash
84
- dy video digest # 标题 + 三个位置的画面 + 评论区摘要
85
- dy video digest --frames 0 # 只读评论,不采样
86
- dy video seek --at 120 # 跳到第 120 秒
87
- dy video observe --at 200 --seconds 4 --frames 2
88
- ```
89
-
90
- `digest` 约 3–4 秒。三个位置是 25%/50%/75%,避开开头封面语和结尾求关注。实测一条 4 分钟英语视频,三帧字幕拼起来就是完整观点,比标题信息量大得多。
91
-
92
- `--frames 0` 只对 `digest` 有效,`observe` 至少要 1 帧。15 秒以下的作品不采样:`frames` 为空并附一条限制说明,那是设计如此,不是失败。`comments.count` 只数页面已加载的,同一条作品连读两次可能不一样。
93
-
94
- ### 读作品与评论
72
+ 日常这一组就够。参数细节、每个命令的坑、各场景的完整流程都在 `dy guide`。
95
73
 
96
74
  ```bash
97
- dy video info # 当前作品的身份、播放状态、时长
75
+ # 刷与看
76
+ dy feed sweep --count 8 # 一次读一批:id/作者/标题/时长,首选
77
+ dy feed next / previous
78
+ dy video digest # 标题 + 采样画面 + 评论摘要;图集会逐页拍
98
79
  dy video open <id-or-url>
99
- dy comment list --limit 20 # 每条带 stance 和 likes
100
- dy comment replies --comment-id <id>
101
- dy comment delete --comment-id <id> --confirm # 删自己发的一条,不可撤销
102
- dy page scroll --target comments --pixels 600
103
- ```
104
-
105
- `comment list` 每条带 `stance`(`about_work` / `about_self` / `asking` / `spam` / `unknown`)和 `likes`。回之前看 stance,对着「在说自己」的评论夸「唱得好」是错位的。
106
-
107
- **它自己开的面板会自己关**,所以读完能直接 `feed next`。面板挡着翻页箭头时用 `dy comment close`。
108
-
109
- `comment delete` 只删自己的评论,别人的 id 会直接拒绝。**页面不会问你第二次**,点了就没,所以 `--confirm` 是唯一的闸。
110
-
111
- ### 学这个区的写法
112
-
113
- ```bash
114
- dy comment voice --limit 20 # 中位长度、感叹号习惯、称呼、人称、提问比例
115
- dy comment voice --save # 连同句式一起存下来
116
- dy comment voice --known # 已经学过的区,按形状归并
117
- dy comment phrases # 哪些句式还能用
118
- dy comment phrases --use "…" # 记一次使用
119
- ```
120
-
121
- **按作品算,不按全局算。** 同一个号上,不同区的评论中位长度可以差好几倍:一个区可能七八个字,另一个区要写满四五十个字。所以别把上个区学到的语气带到这个区,每换一条作品重新量。
122
80
 
123
- 具体数字每次都不一样,跑一次看 `profile.length.median`,不要信这里举例的数。
124
-
125
- `comment voice` 还给每条评论判 `has-wit` / `flat` / `generic-praise`,依据是接话、具体指认、反转三个维度,以及堆砌术语。实测一个区里 247 赞的是「下次一起唱」,150 字堆术语那条只有 2 赞,长度和赞数相关 -0.38。
126
-
127
- **句式用三次会自动标成 `spent`**,静默 14 天回来。别反复用同一条。
128
-
129
- ### 写
130
-
131
- ```bash
81
+ # 评论
82
+ dy comment list --limit 20 # 每条带 stance 和 likes
132
83
  dy comment post --video-id <id> --text "…" --request-id <唯一ID>
133
84
  dy comment reply --video-id <id> --comment-id <id> --text "…" --request-id <唯一ID>
134
- dy video like --video-id <id> --value true
135
- dy video favorite --video-id <id> --value true
136
- dy user follow --user-id <id> --value true
137
- ```
138
-
139
- 写入必须有 `--request-id`。加 `--progress` 看每一步走到哪,`dispatching` 是分水岭:之前失败什么都没发,之后不可逆。
140
-
141
- ```bash
142
- dy --progress --request-id <id> chat send --conversation-id <id> --text "…"
143
- ```
144
-
145
- ### 直播
146
-
147
- ```bash
148
- dy live info # 主播、开播状态、能否评论
149
- dy live chat --limit 20 # 当前加载的弹幕
150
- dy live say --text "…" --request-id <id>
151
- dy live gifts # 礼物和价格
152
- dy live gift --name 人气票 --price 1 --request-id <id>
153
- ```
154
-
155
- 礼物会花真钱,`--name` 和 `--price` 必须同时匹配。
156
-
157
- ### 记忆
158
-
159
- ```bash
160
- dy memory add --text "…" --source "user:…" --layer … --certainty …
161
- dy memory search <词>
162
- dy memory list
163
- dy memory candidates --text-file <文件>
164
- dy memory decide --text-file <文件> --apply --request-id <id>
85
+ dy comment delete --comment-id <id> --confirm
86
+ dy comment voice # 这个区的写法:中位长度、标点、称呼
87
+ dy voice check --text "…" # 发之前自查像不像机器写的
88
+
89
+ # 私信与关系
90
+ dy chat list / read / send
91
+ dy person read <id> # 这个人发过什么、写过什么
92
+ dy social response --conversation-id <id> # 我发的有没有人回
93
+ dy social advise <person> --conversation-id <id> # 继续、等、还是停
94
+
95
+ # 其他
96
+ dy live info / chat / say
97
+ dy memory add / search
165
98
  dy context show --query "…"
99
+ dy ai ask --query "…" # 问平台自己的 AI,当线索用
166
100
  ```
167
101
 
168
- ### 起步与检查
169
-
170
- ```bash
171
- dy init # 建工作区
172
- dy onboarding show # 读引导状态
173
- dy auth login # 扫码登录(有头 Chrome)
174
- dy auth status
175
- dy browser start / stop / status
176
- dy browser clean # 回收磁盘:缓存和截图,不碰登录
177
- dy diagnostics # 反复失败的命令
178
- dy page facts # 页面此刻真实的状态
179
- dy update --check # 有没有新版本
180
- dy skill install # 装 skill 到 agent 目录
181
- dy guide # 这份文档的完整内容
182
- ```
183
-
184
- `dy source` 只对开发 dy 的人有意义,正常使用用不到。
102
+ **写入必须有 `--request-id`。** 加 `--progress` 看每步走到哪,`dispatching` 之后不可逆。
185
103
 
186
104
  ## 按任务读参考
187
105
 
@@ -20,14 +20,22 @@ dy video digest
20
20
 
21
21
  **按长度分档:**
22
22
 
23
- | 时长 | 策略 |
24
- | --- | --- |
25
- | < 15s | `digest` 自动不采样,`frames` 为空且 `limitations` 说明原因 |
26
- | 15–45s | 只看中间一帧(`--frames 1`) |
27
- | 45s–3min | 三帧够用 |
28
- | > 3min | 三帧 + 评论区,必要时再补 `video observe` |
29
-
30
- **短作品没有帧不等于没看。** `frames: []` 配上「< 15s」的限制说明是设计如此——短内容本身就是全部。别当成采样失败去重试。
23
+ 采样数跟着时长走,不是固定三帧:
24
+
25
+ | 时长 | 帧数 | 说明 |
26
+ | --- | --- | --- |
27
+ | < 15s | 0 | 短内容本身就是全部,`frames: []` 配 `limitations` 说明原因 |
28
+ | 15–45s | 1 | 取正中 |
29
+ | 45s–3min | 3 | 分布在中间 80% |
30
+ | > 3min | 每 2 分钟 +1,上限 8 | 20 分钟的访谈取 8 帧 |
31
+
32
+ **不要用三帧衡量长视频。** 半小时的演讲取三点,等于只看开场、中间一瞥、结尾,论证全丢。既然命令已经按长度算好了,读 `frames` 的长度就知道有没有看够。
33
+
34
+ **`--frames N` 可以压过自动值**,但最低 1 帧。只想看一个位置就传 1。
35
+
36
+ **图集是另一回事。** `digest` 遇到图片帖会逐页截图,一页都不少,因为整条作品就是那组图。
37
+
38
+ **短作品没有帧不等于没看。** `frames: []` 配上「< 15s」的限制说明是设计如此。别当成采样失败去重试。
31
39
 
32
40
  **论坛区比标题更接近真相。** 一条手势舞标题是「我的世界你就是那中心点」,评论区里作者在问「这一版萌还是上一版萌」——那是连载作品,标题没说。看 `stance` 判断观众在说什么:`about_work` 才是在聊作品,`about_self` 是在聊自己。
33
41
 
@@ -69,6 +69,7 @@ dy page scroll --pixels 1200
69
69
  | 分享文本或链接转 ID | `dy link resolve <text>` |
70
70
  | 读当前作品 | `dy video info` |
71
71
  | 搜索 | `dy search <词>`、`dy user search <词>` |
72
+ | 问平台自己的 AI | `dy ai ask --query "…"` |
72
73
  | 当前标签页是什么 | `dy status` |
73
74
 
74
75
  `feed.next` 会核实作品 ID 变了才算成功。读到「当前是直播」不代表命令坏了——见 `platform-facts.md` 的滑片过渡说明。