shuadouyin 0.3.2 → 0.3.4
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 +1 -1
- package/dist/app.js +3 -0
- package/dist/catalog.js +15 -0
- package/dist/operations/ai-search.js +42 -0
- package/dist/operations/digest.js +70 -11
- package/dist/operations/navigation.js +26 -5
- package/dist/page-adapter.js +24 -0
- package/dist/tui.js +3 -0
- package/package.json +1 -1
- package/skills/dy/SKILL.md +33 -4
- package/skills/dy/references/commands.md +1 -0
package/.dy-build-commit
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
acefdb95ebb80b24b61632d5874193c29517801a
|
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",
|
|
@@ -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
|
-
|
|
14
|
-
if (
|
|
32
|
+
const automatic = samplesFor(duration);
|
|
33
|
+
if (automatic === 0)
|
|
15
34
|
return [];
|
|
16
|
-
//
|
|
17
|
-
// the middle
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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,32 @@ 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 === "
|
|
42
|
-
?
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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) => {
|
|
50
|
+
// A profile that redirects elsewhere is a real answer, not a changed selector:
|
|
51
|
+
// Douyin sends restricted or unavailable accounts to the search page. Saying
|
|
52
|
+
// "the page did not reach the expected state" hides which of the two happened
|
|
53
|
+
// and sends the caller looking for a broken selector.
|
|
54
|
+
// Only when the page actually went somewhere else. A profile that simply took
|
|
55
|
+
// too long is a different answer, and calling it "not found" would send the
|
|
56
|
+
// caller looking for a restricted account that is fine.
|
|
57
|
+
if (kind === "profile" && error instanceof CliError && error.code === "selector_changed") {
|
|
58
|
+
const landed = String(error.details?.url ?? "");
|
|
59
|
+
if (landed && !landed.includes("/user/"))
|
|
60
|
+
throw new CliError("not_found", "This profile redirected elsewhere", {
|
|
61
|
+
hint: "Douyin sent this account somewhere other than its profile: it may be restricted, deleted, or require a fresh login. Run dy auth status, or open one of their works with dy video open.",
|
|
62
|
+
details: { requested: url, landed },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
});
|
|
46
67
|
}
|
|
47
68
|
// The recommended feed lives at https://www.douyin.com/?recommend=1, but a
|
|
48
69
|
// plain load of that URL can land on the 精选 page instead, and the feed there
|
package/dist/page-adapter.js
CHANGED
|
@@ -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
package/skills/dy/SKILL.md
CHANGED
|
@@ -168,8 +168,6 @@ dy context show --query "…"
|
|
|
168
168
|
### 起步与检查
|
|
169
169
|
|
|
170
170
|
```bash
|
|
171
|
-
dy init # 建工作区
|
|
172
|
-
dy onboarding show # 读引导状态
|
|
173
171
|
dy auth login # 扫码登录(有头 Chrome)
|
|
174
172
|
dy auth status
|
|
175
173
|
dy browser start / stop / status
|
|
@@ -178,10 +176,9 @@ dy diagnostics # 反复失败的命令
|
|
|
178
176
|
dy page facts # 页面此刻真实的状态
|
|
179
177
|
dy update --check # 有没有新版本
|
|
180
178
|
dy skill install # 装 skill 到 agent 目录
|
|
181
|
-
dy guide # 这份文档的完整内容
|
|
182
179
|
```
|
|
183
180
|
|
|
184
|
-
`dy source` 只对开发 dy
|
|
181
|
+
其余的就:`dy init` 建工作区,`dy guide` 输出完整文档。`dy source` 只对开发 dy 的人有意义。
|
|
185
182
|
|
|
186
183
|
## 按任务读参考
|
|
187
184
|
|
|
@@ -199,6 +196,38 @@ dy guide # 这份文档的完整内容
|
|
|
199
196
|
|
|
200
197
|
`references/commands.md` 是按意图索引的全部命令地图。其余文件讲的是流程和判断,冲突时以命令行为为准。
|
|
201
198
|
|
|
199
|
+
## 做事的时候一直在说话
|
|
200
|
+
|
|
201
|
+
刷十条、看五条、评三条,这种活要跑几分钟。跑的时候**一条一条说**,别憋到最后一起报。
|
|
202
|
+
|
|
203
|
+
原因很简单:用户在等。他不知道你在干什么,就只能看着屏幕不动。中途说一句「第 3 条是拆蟹教程,评论区在吵该不该用工具」,他就知道这事在推进,也能及时叫停——你要是刷到第 8 条才发现方向不对,已经浪费了七条。
|
|
204
|
+
|
|
205
|
+
**每做完一个可描述的动作就报一句:**
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
看第 1 条:南浔弹唱《暧昧》,评论区在接歌词,中位 8 字
|
|
209
|
+
看第 2 条:拆蟹教程 4 分钟,三帧是完整流程,14.6 万赞
|
|
210
|
+
第 2 条评了:「大家争的是快慢,我看的是拆的过程本身好看」
|
|
211
|
+
第 3 条划过了,内容是纯广告
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**不要这样:**
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
✗ 好的,我来刷一下(然后 5 分钟没声,最后一次性贴 12 条结果)
|
|
218
|
+
✗ 正在处理第 1/12 条……正在处理第 2/12 条……(这是在刷进度条,不是在说话)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**判断一条值不值得说**:这条读完你有没有新的判断?有就说,没有就跳到下一条。流水账和沉默一样没用。
|
|
222
|
+
|
|
223
|
+
**长任务开始前先说一句要做什么、大概几步**,用户好在跑偏时叫停:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
我去翻 8 条,挑 2 条有意思的看,可能评 1 条。
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
写操作(评论、私信)尤其要边说边做,因为 `dispatching` 之后不可逆——发之前那句「我要评这条:……」是用户最后的干预机会。
|
|
230
|
+
|
|
202
231
|
## 硬规则
|
|
203
232
|
|
|
204
233
|
这几条在哪个参考文件里都成立:
|
|
@@ -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` 的滑片过渡说明。
|