@zeppeli777/tools-doubao 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +105 -2
- package/config.example.json +18 -0
- package/index.mjs +204 -0
- package/package.json +38 -4
- package/scripts/login.mjs +34 -0
- package/scripts/test-offline.mjs +313 -0
- package/scripts/test-protocol.mjs +94 -0
- package/src/accounts.mjs +58 -0
- package/src/client.mjs +521 -0
- package/src/config.mjs +95 -0
- package/src/download.mjs +56 -0
- package/src/http.mjs +60 -0
- package/src/png.mjs +164 -0
- package/src/qlogin.mjs +240 -0
- package/src/upload.mjs +82 -0
- package/src/watermark.mjs +59 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// 离线单测:不联网,用合成 SSE 报文验证解析器、配置解析与插件结构。
|
|
2
|
+
// node scripts/test-offline.mjs
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { deflateSync, crc32 } from "node:zlib";
|
|
6
|
+
import { parseSseBlocks, parsePcImages, parseWebImages, normalizeRatio } from "../src/client.mjs";
|
|
7
|
+
import { DoubaoError, DoubaoAuthError, DoubaoRiskError } from "../src/http.mjs";
|
|
8
|
+
import { loadConfig, DEFAULTS } from "../src/config.mjs";
|
|
9
|
+
import { qrPngToAscii } from "../src/qlogin.mjs";
|
|
10
|
+
import { encodePng, decodePng, cropPngBottom } from "../src/png.mjs";
|
|
11
|
+
import { parseExtendFrac } from "../src/watermark.mjs";
|
|
12
|
+
import { DoubaoTools } from "../index.mjs";
|
|
13
|
+
|
|
14
|
+
let passed = 0;
|
|
15
|
+
function ok(name, fn) {
|
|
16
|
+
try {
|
|
17
|
+
fn();
|
|
18
|
+
passed++;
|
|
19
|
+
console.log(` ✅ ${name}`);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error(` ❌ ${name}\n${err.stack}`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ————— parseSseBlocks —————
|
|
27
|
+
console.log("parseSseBlocks");
|
|
28
|
+
ok("基本切块与 event 名", () => {
|
|
29
|
+
const blocks = parseSseBlocks('event: SSE_ACK\ndata: {"a":1}\n\ndata: {"b":2}\n\n');
|
|
30
|
+
assert.equal(blocks.length, 2);
|
|
31
|
+
assert.equal(blocks[0].event, "SSE_ACK");
|
|
32
|
+
assert.equal(blocks[0].json.a, 1);
|
|
33
|
+
assert.equal(blocks[1].event, "message");
|
|
34
|
+
assert.equal(blocks[1].json.b, 2);
|
|
35
|
+
});
|
|
36
|
+
ok("多行 data: 合并", () => {
|
|
37
|
+
const blocks = parseSseBlocks('data: {"x":\ndata: 1}\n');
|
|
38
|
+
assert.equal(blocks[0].json.x, 1);
|
|
39
|
+
});
|
|
40
|
+
ok("非 JSON 数据块 json 为 null", () => {
|
|
41
|
+
const blocks = parseSseBlocks("data: ping\n");
|
|
42
|
+
assert.equal(blocks[0].json, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ————— parsePcImages(samantha 2010 形态,doubao2api 真值) —————
|
|
46
|
+
console.log("parsePcImages");
|
|
47
|
+
// 真实响应是三层 JSON 嵌套(SSE data → event_data 字符串 → message.content 字符串),程序化构造
|
|
48
|
+
const pcImageData = {
|
|
49
|
+
data: [
|
|
50
|
+
{
|
|
51
|
+
key: "img1",
|
|
52
|
+
image_ori: { url: "https://cdn/ori.jpg", width: 1024, height: 1024, format: "jpeg" },
|
|
53
|
+
image_raw: { url: "https://cdn/raw.jpg" },
|
|
54
|
+
image_thumb: { url: "https://cdn/thumb.jpg" },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const pcBlockImage = `data: ${JSON.stringify({
|
|
59
|
+
event_type: 2001,
|
|
60
|
+
event_data: JSON.stringify({ message: { content_type: 2010, content: JSON.stringify(pcImageData), status: 1 } }),
|
|
61
|
+
})}`;
|
|
62
|
+
const pcBlockText = `data: ${JSON.stringify({
|
|
63
|
+
event_type: 2001,
|
|
64
|
+
event_data: JSON.stringify({ message: { content_type: 2001, content: JSON.stringify({ text: "已生成" }) } }),
|
|
65
|
+
})}`;
|
|
66
|
+
const pcFixture = [pcBlockImage, "", pcBlockText, ""].join("\n");
|
|
67
|
+
ok("提取 image_ori 为最优档(pc 顺序)", () => {
|
|
68
|
+
const { images, text } = parsePcImages(pcFixture);
|
|
69
|
+
assert.equal(images.length, 1);
|
|
70
|
+
assert.equal(images[0].url, "https://cdn/ori.jpg");
|
|
71
|
+
assert.equal(images[0].width, 1024);
|
|
72
|
+
assert.equal(text, "已生成");
|
|
73
|
+
});
|
|
74
|
+
ok("event_type 2005 抛错", () => {
|
|
75
|
+
assert.throws(() => parsePcImages('data: {"event_type":2005,"event_data":"boom"}'), /boom/);
|
|
76
|
+
});
|
|
77
|
+
ok("JSON 错误体分类:登录失效", () => {
|
|
78
|
+
assert.throws(() => parsePcImages('{"code":710012001,"msg":"登录已过期"}'), DoubaoAuthError);
|
|
79
|
+
});
|
|
80
|
+
ok("JSON 错误体分类:风控", () => {
|
|
81
|
+
try {
|
|
82
|
+
parsePcImages('{"code":710022004,"msg":"verify"}');
|
|
83
|
+
assert.fail("应当抛出");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
assert.ok(err instanceof DoubaoRiskError);
|
|
86
|
+
assert.match(err.hint, /风控|冷却/);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
ok("gateway-error 抛登录失效", () => {
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => parsePcImages('event: gateway-error\ndata: {"code":"710012001","message":"user invalid"}'),
|
|
92
|
+
DoubaoAuthError,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ————— parseWebImages(chat_ability 2074 形态,web-mcp 笔记) —————
|
|
97
|
+
console.log("parseWebImages");
|
|
98
|
+
const webBlock = {
|
|
99
|
+
block_type: 2074,
|
|
100
|
+
content: {
|
|
101
|
+
creation_block: {
|
|
102
|
+
creations: [
|
|
103
|
+
{
|
|
104
|
+
image: {
|
|
105
|
+
image_raw: { url: "https://cdn-raw/x.jpg", width: 1440, height: 810 },
|
|
106
|
+
lwm3_url: "https://cdn-lwm/x.jpg",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const webFixture = [
|
|
114
|
+
'event: SSE_ACK\ndata: {"ack_client_meta":{"conversation_id":"7421"}}',
|
|
115
|
+
"",
|
|
116
|
+
`event: STREAM_CHUNK\ndata: ${JSON.stringify({ patch_op: [{ patch_value: { content_block: [webBlock] } }] })}`,
|
|
117
|
+
"",
|
|
118
|
+
'event: STREAM_CHUNK\ndata: {"text":"生成中"}',
|
|
119
|
+
"",
|
|
120
|
+
'event: SSE_REPLY_END\ndata: {}',
|
|
121
|
+
"",
|
|
122
|
+
].join("\n");
|
|
123
|
+
ok("2074 creation 提取,image_raw 优先(web 顺序)", () => {
|
|
124
|
+
const { images, text, conversationId } = parseWebImages(webFixture);
|
|
125
|
+
assert.equal(images.length, 1);
|
|
126
|
+
assert.equal(images[0].url, "https://cdn-raw/x.jpg");
|
|
127
|
+
assert.equal(images[0].width, 1440);
|
|
128
|
+
assert.equal(conversationId, "7421");
|
|
129
|
+
assert.ok(text.includes("生成中"));
|
|
130
|
+
});
|
|
131
|
+
ok("无图但带订阅文案 → 付费墙提示", () => {
|
|
132
|
+
const fixture = [
|
|
133
|
+
'event: STREAM_CHUNK',
|
|
134
|
+
`data: ${JSON.stringify({ patch_op: [{ patch_value: { content_block: [{ block_type: 10000, content: { text_block: { text: "这是豆包订阅标准套餐专属能力" } } }] } }] })}`,
|
|
135
|
+
"",
|
|
136
|
+
].join("\n");
|
|
137
|
+
const err = (() => {
|
|
138
|
+
try {
|
|
139
|
+
parseWebImages(fixture);
|
|
140
|
+
return null;
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return e;
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
assert.ok(err instanceof DoubaoError);
|
|
146
|
+
assert.match(err.message, /订阅/);
|
|
147
|
+
});
|
|
148
|
+
ok("深度兜底扫描命中嵌套 2074", () => {
|
|
149
|
+
const fixture = `event: FULL_MSG_NOTIFY\ndata: ${JSON.stringify({ deep: { nested: [webBlock] } })}\n\n`;
|
|
150
|
+
const { images } = parseWebImages(fixture);
|
|
151
|
+
assert.equal(images.length, 1);
|
|
152
|
+
assert.equal(images[0].url, "https://cdn-raw/x.jpg");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ————— 二维码 PNG 解码 —————
|
|
156
|
+
console.log("qrPngToAscii");
|
|
157
|
+
function pngChunk(type, data) {
|
|
158
|
+
const len = Buffer.alloc(4);
|
|
159
|
+
len.writeUInt32BE(data.length);
|
|
160
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
161
|
+
const crc = Buffer.alloc(4);
|
|
162
|
+
crc.writeUInt32BE(crc32(body) >>> 0);
|
|
163
|
+
return Buffer.concat([len, body, crc]);
|
|
164
|
+
}
|
|
165
|
+
/** 自造伪二维码 PNG(8-bit 灰度、filter 0,四周 4 模块静区)。 */
|
|
166
|
+
function makeQrPng(modules, scale = 8) {
|
|
167
|
+
const w = (modules + 8) * scale;
|
|
168
|
+
const ihdr = Buffer.alloc(13);
|
|
169
|
+
ihdr.writeUInt32BE(w, 0);
|
|
170
|
+
ihdr.writeUInt32BE(w, 4);
|
|
171
|
+
ihdr[8] = 8; // bit depth
|
|
172
|
+
ihdr[9] = 0; // grayscale
|
|
173
|
+
const rows = [];
|
|
174
|
+
for (let y = 0; y < w; y++) {
|
|
175
|
+
const row = Buffer.alloc(1 + w);
|
|
176
|
+
row[0] = 0; // filter: none
|
|
177
|
+
for (let x = 0; x < w; x++) {
|
|
178
|
+
const mx = Math.floor(x / scale) - 4;
|
|
179
|
+
const my = Math.floor(y / scale) - 4;
|
|
180
|
+
const inGrid = mx >= 0 && my >= 0 && mx < modules && my < modules;
|
|
181
|
+
row[1 + x] = inGrid && (mx * 7 + my * 11 + mx * my) % 3 === 0 ? 0 : 255;
|
|
182
|
+
}
|
|
183
|
+
rows.push(row);
|
|
184
|
+
}
|
|
185
|
+
return Buffer.concat([
|
|
186
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
187
|
+
pngChunk("IHDR", ihdr),
|
|
188
|
+
pngChunk("IDAT", deflateSync(Buffer.concat(rows))),
|
|
189
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
ok("解码自造 PNG 为 ASCII 半块画", () => {
|
|
193
|
+
const ascii = qrPngToAscii(makeQrPng(25));
|
|
194
|
+
assert.ok(ascii, "应当解码成功");
|
|
195
|
+
const lines = ascii.split("\n");
|
|
196
|
+
assert.equal(lines.length, Math.ceil(25 / 2) + 2, "13 对行 + 上下白边");
|
|
197
|
+
// (0,0) 依图案公式为暗、(0,1) 为亮 → 首个内容行第 1 个内容字符应为 "▀"
|
|
198
|
+
const content = lines[1].trim();
|
|
199
|
+
assert.ok(content.startsWith("▀"), `首字符应为 ▀,实际: ${content.slice(0, 3)}`);
|
|
200
|
+
});
|
|
201
|
+
ok("非 PNG 输入返回 null", () => {
|
|
202
|
+
assert.equal(qrPngToAscii(Buffer.from("not a png")), null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ————— PNG 编解码与去水印裁剪 —————
|
|
206
|
+
console.log("png & watermark crop");
|
|
207
|
+
ok("encodePng/decodePng 往返无损", () => {
|
|
208
|
+
const w = 16;
|
|
209
|
+
const h = 10;
|
|
210
|
+
const rgb = Buffer.alloc(w * h * 3);
|
|
211
|
+
for (let y = 0; y < h; y++) {
|
|
212
|
+
for (let x = 0; x < w; x++) {
|
|
213
|
+
const i = (y * w + x) * 3;
|
|
214
|
+
rgb[i] = x * 16;
|
|
215
|
+
rgb[i + 1] = y * 25;
|
|
216
|
+
rgb[i + 2] = 128;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const back = decodePng(encodePng(w, h, rgb));
|
|
220
|
+
assert.equal(back.width, w);
|
|
221
|
+
assert.equal(back.height, h);
|
|
222
|
+
assert.ok(back.data.equals(rgb));
|
|
223
|
+
});
|
|
224
|
+
ok("cropPngBottom 保留顶部 80%", () => {
|
|
225
|
+
const w = 16;
|
|
226
|
+
const h = 10;
|
|
227
|
+
const rgb = Buffer.alloc(w * h * 3);
|
|
228
|
+
for (let y = 0; y < h; y++) {
|
|
229
|
+
for (let x = 0; x < w; x++) {
|
|
230
|
+
const i = (y * w + x) * 3;
|
|
231
|
+
rgb[i] = x * 16;
|
|
232
|
+
rgb[i + 1] = y * 25;
|
|
233
|
+
rgb[i + 2] = 7;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const png = encodePng(w, h, rgb);
|
|
237
|
+
const cropped = cropPngBottom(png, 0.8);
|
|
238
|
+
assert.equal(cropped.origHeight, 10);
|
|
239
|
+
assert.equal(cropped.height, 8); // 10 × 0.8
|
|
240
|
+
const img = decodePng(cropped.buffer);
|
|
241
|
+
assert.equal(img.width, 16);
|
|
242
|
+
assert.ok(img.data.equals(rgb.subarray(0, 8 * w * 3)), "顶部 8 行像素应与原图一致");
|
|
243
|
+
});
|
|
244
|
+
ok("cropPngBottom 对非 PNG 抛错", () => {
|
|
245
|
+
assert.throws(() => cropPngBottom(Buffer.from("\xff\xd8\xff jpeg-ish")), /PNG/);
|
|
246
|
+
});
|
|
247
|
+
ok("parseExtendFrac 解析", () => {
|
|
248
|
+
assert.equal(parseExtendFrac("1/4"), 0.25);
|
|
249
|
+
assert.equal(parseExtendFrac("1/5"), 0.2);
|
|
250
|
+
assert.equal(parseExtendFrac("0.2"), 0.2);
|
|
251
|
+
assert.equal(parseExtendFrac(0.5), 0.5);
|
|
252
|
+
assert.equal(parseExtendFrac(undefined), 0.25);
|
|
253
|
+
assert.equal(parseExtendFrac("垃圾"), 0.25);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ————— 杂项 —————
|
|
257
|
+
console.log("misc");
|
|
258
|
+
ok("normalizeRatio 白名单", () => {
|
|
259
|
+
assert.equal(normalizeRatio("16:9"), "16:9");
|
|
260
|
+
assert.equal(normalizeRatio("2:99"), "1:1");
|
|
261
|
+
assert.equal(normalizeRatio("", "auto"), "auto");
|
|
262
|
+
});
|
|
263
|
+
ok("loadConfig 解析 DOUBAO_ACCOUNTS", () => {
|
|
264
|
+
process.env.DOUBAO_ACCOUNTS = "主号:sessionid=abc123456789,ttwid=tt1;小号:sessionid=def123456789";
|
|
265
|
+
const cfg = loadConfig();
|
|
266
|
+
assert.equal(cfg.accounts.length, 2);
|
|
267
|
+
assert.equal(cfg.accounts[0].name, "主号");
|
|
268
|
+
assert.equal(cfg.accounts[0].ttwid, "tt1");
|
|
269
|
+
assert.equal(cfg.accounts[1].name, "小号");
|
|
270
|
+
assert.equal(cfg.model, DEFAULTS.model);
|
|
271
|
+
delete process.env.DOUBAO_ACCOUNTS;
|
|
272
|
+
});
|
|
273
|
+
ok("loadConfig 过滤无效账号", () => {
|
|
274
|
+
const cfg = loadConfig({ accounts: [{ name: "坏", sessionid: "x" }, { name: "好", sessionid: "valid-sessionid-123" }] });
|
|
275
|
+
assert.equal(cfg.accounts.length, 1);
|
|
276
|
+
assert.equal(cfg.accounts[0].name, "好");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ————— 插件结构 —————
|
|
280
|
+
console.log("plugin structure");
|
|
281
|
+
ok("导出 generate_image 与 check_login 两个工具", async () => {
|
|
282
|
+
const plugin = await DoubaoTools({}, {});
|
|
283
|
+
const tools = plugin?.tool || {};
|
|
284
|
+
assert.deepEqual(Object.keys(tools).sort(), ["check_login", "generate_image"]);
|
|
285
|
+
const gi = tools.generate_image;
|
|
286
|
+
assert.match(gi.description, /豆包/);
|
|
287
|
+
assert.equal(gi.args.type, "object");
|
|
288
|
+
assert.deepEqual(gi.args.required, ["prompt"]);
|
|
289
|
+
assert.equal(gi.args.properties.ratio.enum.length, 6);
|
|
290
|
+
assert.equal(typeof gi.execute, "function");
|
|
291
|
+
assert.equal(typeof tools.check_login.execute, "function");
|
|
292
|
+
});
|
|
293
|
+
ok("未配置账号时 execute 返回可读错误而非抛异常", async () => {
|
|
294
|
+
// 与真实 config.json 隔离:指向空配置文件
|
|
295
|
+
const { writeFileSync, rmSync, mkdtempSync } = await import("node:fs");
|
|
296
|
+
const { tmpdir } = await import("node:os");
|
|
297
|
+
const dir = mkdtempSync(path.join(tmpdir(), "doubao-test-"));
|
|
298
|
+
const emptyCfg = path.join(dir, "config.json");
|
|
299
|
+
writeFileSync(emptyCfg, "{}");
|
|
300
|
+
const prev = process.env.DOUBAO_IMAGE_CONFIG;
|
|
301
|
+
process.env.DOUBAO_IMAGE_CONFIG = emptyCfg;
|
|
302
|
+
try {
|
|
303
|
+
const plugin = await DoubaoTools({}, {});
|
|
304
|
+
const out = await plugin.tool.generate_image.execute({ prompt: "测试" }, { directory: dir });
|
|
305
|
+
assert.match(out, /未配置豆包账号/);
|
|
306
|
+
} finally {
|
|
307
|
+
if (prev === undefined) delete process.env.DOUBAO_IMAGE_CONFIG;
|
|
308
|
+
else process.env.DOUBAO_IMAGE_CONFIG = prev;
|
|
309
|
+
rmSync(dir, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
console.log(process.exitCode ? "\n有测试失败" : `\n全部通过(${passed} 项)`);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 独立协议测试脚本:不经 Magpie,直接调用豆包接口验证配置与生图链路。
|
|
3
|
+
// node scripts/test-protocol.mjs --check # 检查各账号登录态
|
|
4
|
+
// node scripts/test-protocol.mjs "一只柴犬在樱花树下" # 文生图
|
|
5
|
+
// node scripts/test-protocol.mjs "改成赛博朋克风格" --ref a.png --ref b.jpg
|
|
6
|
+
// 可选:--ratio 16:9 --model "Seedream 4.5" --channel web|pc --out 目录 --account 主号
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { loadConfig, configSource } from "../src/config.mjs";
|
|
9
|
+
import { generate, checkLogin } from "../src/client.mjs";
|
|
10
|
+
import { removeWatermarkByExtend } from "../src/watermark.mjs";
|
|
11
|
+
import { downloadImage, fileStamp } from "../src/download.mjs";
|
|
12
|
+
|
|
13
|
+
function parseArgv(argv) {
|
|
14
|
+
const opts = { refs: [], prompt: "" };
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i];
|
|
17
|
+
if (a === "--check") opts.check = true;
|
|
18
|
+
else if (a === "--ratio") opts.ratio = argv[++i];
|
|
19
|
+
else if (a === "--model") opts.model = argv[++i];
|
|
20
|
+
else if (a === "--channel") opts.channel = argv[++i];
|
|
21
|
+
else if (a === "--out") opts.out = argv[++i];
|
|
22
|
+
else if (a === "--account") opts.account = argv[++i];
|
|
23
|
+
else if (a === "--ref") opts.refs.push(argv[++i]);
|
|
24
|
+
else if (a === "--wm") opts.wm = true;
|
|
25
|
+
else if (a === "--no-hint") opts.noHint = true;
|
|
26
|
+
else if (!a.startsWith("--")) opts.prompt += (opts.prompt ? " " : "") + a;
|
|
27
|
+
}
|
|
28
|
+
return opts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const args = parseArgv(process.argv.slice(2));
|
|
32
|
+
const cfg = loadConfig(args.noHint ? { ratioHint: false } : {});
|
|
33
|
+
if (args.channel) cfg.channel = args.channel;
|
|
34
|
+
|
|
35
|
+
console.log(`配置来源: ${configSource()};账号 ${cfg.accounts.length} 个;通道 ${cfg.channel};默认模型 ${cfg.model}`);
|
|
36
|
+
|
|
37
|
+
if (!cfg.accounts.length) {
|
|
38
|
+
console.error(
|
|
39
|
+
"未配置账号。请先创建 config.json(复制 config.example.json 并填入 sessionid),\n" +
|
|
40
|
+
"或设置环境变量 DOUBAO_ACCOUNTS=\"主号:sessionid=xxx\"。",
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (args.check) {
|
|
46
|
+
const statuses = await checkLogin(cfg);
|
|
47
|
+
for (const s of statuses) {
|
|
48
|
+
console.log(
|
|
49
|
+
`${s.live ? "✅ 存活" : "❌ 失效"} ${s.name}${s.userName ? ` (${s.userName})` : ""}` +
|
|
50
|
+
`${s.userId ? ` user_id=${s.userId}` : ""}${s.error ? ` 错误: ${s.error}` : ""}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
process.exit(statuses.some((s) => s.live) ? 0 : 2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!args.prompt) {
|
|
57
|
+
console.error('用法: node scripts/test-protocol.mjs "提示词" [--ratio 16:9] [--model ...] [--ref 文件] [--check]');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`开始生成(prompt="${args.prompt}", ratio=${args.ratio || cfg.ratio}, model=${args.model || cfg.model}${args.refs.length ? `, 参考图 ${args.refs.length} 张` : ""})…`);
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
try {
|
|
64
|
+
const result = await generate(cfg, {
|
|
65
|
+
prompt: args.prompt,
|
|
66
|
+
ratio: args.ratio,
|
|
67
|
+
model: args.model,
|
|
68
|
+
refImages: args.refs,
|
|
69
|
+
account: args.account,
|
|
70
|
+
});
|
|
71
|
+
console.log(`生成完成(${((Date.now() - started) / 1000).toFixed(1)}s,通道 ${result.channel},账号 ${result.account})`);
|
|
72
|
+
if (result.conversationId) console.log(`会话: ${result.conversationId}`);
|
|
73
|
+
if (result.text) console.log(`附言: ${result.text.slice(0, 200)}`);
|
|
74
|
+
const outDir = args.out || path.resolve(import.meta.dirname, "..", "output");
|
|
75
|
+
for (let i = 0; i < result.images.length; i++) {
|
|
76
|
+
const img = result.images[i];
|
|
77
|
+
const { file, bytes } = await downloadImage(img.url, outDir, `doubao-${fileStamp()}-${i + 1}`);
|
|
78
|
+
console.log(`图片 ${i + 1}: ${file} (${(bytes / 1024).toFixed(0)} KB, ${img.width || "?"}x${img.height || "?"})`);
|
|
79
|
+
if (args.wm && !args.refs.length) {
|
|
80
|
+
console.log("去水印:扩图+裁剪中…");
|
|
81
|
+
const r = await removeWatermarkByExtend(cfg, {
|
|
82
|
+
file,
|
|
83
|
+
prompt: args.prompt,
|
|
84
|
+
ratio: args.ratio,
|
|
85
|
+
account: args.account,
|
|
86
|
+
});
|
|
87
|
+
console.log(` 完成:${r.width}x${r.height}(扩图前 ${r.width}x${r.origHeight},扩图比例 ${Math.round(r.extendFrac * 100)}%)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`生成失败: ${err.message}`);
|
|
92
|
+
if (err.hint) console.error(`建议: ${err.hint}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
package/src/accounts.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 多账号轮换与登录态检测。
|
|
2
|
+
import { BASE_URL, baseHeaders, DoubaoError } from "./http.mjs";
|
|
3
|
+
|
|
4
|
+
let cursor = 0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 生成尝试顺序:preferred(如指定)最优先,然后从轮换游标开始铺满全部账号。
|
|
8
|
+
* 同一进程内游标自增,多次调用自动分摊额度。
|
|
9
|
+
*/
|
|
10
|
+
export function accountOrder(accounts, preferred) {
|
|
11
|
+
if (!accounts.length) {
|
|
12
|
+
throw new DoubaoError(
|
|
13
|
+
"未配置豆包账号。",
|
|
14
|
+
"把 doubao.com 网页版 Cookie 里的 sessionid 写入插件目录 config.json(参考 config.example.json),或设置环境变量 DOUBAO_ACCOUNTS=\"主号:sessionid=xxx\"。",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const rest = accounts.slice();
|
|
18
|
+
const ordered = [];
|
|
19
|
+
if (preferred) {
|
|
20
|
+
const idx = rest.findIndex((a) => a.name === preferred || a.sessionid === preferred);
|
|
21
|
+
if (idx >= 0) ordered.push(...rest.splice(idx, 1));
|
|
22
|
+
}
|
|
23
|
+
for (let i = 0; i < rest.length; i++) {
|
|
24
|
+
ordered.push(rest[(cursor + i) % rest.length]);
|
|
25
|
+
}
|
|
26
|
+
cursor += 1;
|
|
27
|
+
return ordered;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 检测单个账号登录态:/passport/account/info/v2 返回 user_id 即存活。 */
|
|
31
|
+
export async function checkAccount(account) {
|
|
32
|
+
try {
|
|
33
|
+
const resp = await fetch(`${BASE_URL}/passport/account/info/v2`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: baseHeaders(account, { "Content-Type": "application/x-www-form-urlencoded" }),
|
|
36
|
+
body: "",
|
|
37
|
+
signal: AbortSignal.timeout(15_000),
|
|
38
|
+
});
|
|
39
|
+
const text = await resp.text();
|
|
40
|
+
let userId = "";
|
|
41
|
+
let userName = "";
|
|
42
|
+
try {
|
|
43
|
+
const body = JSON.parse(text);
|
|
44
|
+
const data = body.data || body;
|
|
45
|
+
userId = String(data.user_id ?? "");
|
|
46
|
+
userName = String(data.name ?? data.user_name ?? "");
|
|
47
|
+
} catch {
|
|
48
|
+
// 非 JSON 响应视为失效
|
|
49
|
+
}
|
|
50
|
+
return { name: account.name || "(未命名)", live: Boolean(userId), userId, userName };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { name: account.name || "(未命名)", live: false, error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function checkAllAccounts(accounts) {
|
|
57
|
+
return Promise.all(accounts.map(checkAccount));
|
|
58
|
+
}
|