@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/src/client.mjs ADDED
@@ -0,0 +1,521 @@
1
+ // 豆包生图协议核心。
2
+ //
3
+ // 两条通道(协议真值来源见 README 的“参考实现”一节,均为 2026 年抓包结论):
4
+ // web: POST /chat/completion chat_ability 新协议(aid=497858,免签名),
5
+ // 模型/比例精确选择;SSE 事件 SSE_ACK / STREAM_CHUNK / SSE_REPLY_END,
6
+ // 图片在 block_type=2074 creation_block 里。
7
+ // pc : POST /samantha/chat/completion 旧 agent 通道(aid=582478,pc_client),
8
+ // content_type=2009 + skill_type=3,响应 content_type=2010。
9
+ // 图生图(参考图)只走此通道。
10
+ // channel=auto 时先 web 后 pc(风控除外);参考图存在时只走 pc。
11
+ import { randomUUID } from "node:crypto";
12
+ import {
13
+ BASE_URL,
14
+ baseHeaders,
15
+ SSE_HEADERS,
16
+ DoubaoError,
17
+ DoubaoAuthError,
18
+ DoubaoRiskError,
19
+ RISK_CODES,
20
+ AUTH_CODES,
21
+ riskHint,
22
+ FP,
23
+ CHROMIUM_VERSION,
24
+ } from "./http.mjs";
25
+ import { uploadRefImages } from "./upload.mjs";
26
+
27
+ export const EXTENSION_BOT_ID = "7338286299411103781"; // 多模态扩展 bot(doubao2api 真值)
28
+ export const FREE_MODELS = ["Seedream 5.0 Flash", "Seedream 4.5", "Seedream 4.0"];
29
+ export const DEFAULT_MODEL = "Seedream 5.0 Flash";
30
+ export const RATIOS = ["auto", "1:1", "16:9", "9:16", "4:3", "3:4"];
31
+
32
+ // —— 各端点共用的 query 参数(抓包真值;多为埋点字段,服务端主要看 cookie/aid/头) ——
33
+ export function securityParams(profile) {
34
+ const common = {
35
+ device_id: "714003710229497",
36
+ tea_uuid: "714003710229497",
37
+ web_id: "7604137868021548590",
38
+ device_platform: "web",
39
+ language: "zh",
40
+ region: "CN",
41
+ sys_region: "CN",
42
+ pkg_type: "release_version",
43
+ runtime: "web",
44
+ runtime_version: "3.5.4",
45
+ samantha_web: "1",
46
+ "use-olympus-account": "1",
47
+ fp: FP,
48
+ web_tab_id: randomUUID(),
49
+ };
50
+ const byProfile =
51
+ profile === "web"
52
+ ? {
53
+ aid: "497858",
54
+ real_aid: "497858",
55
+ doubao_pc_version: "3.41.4",
56
+ pc_version: "3.41.4",
57
+ version_code: "341400",
58
+ client_platform: "web",
59
+ }
60
+ : {
61
+ aid: "582478",
62
+ real_aid: "582478",
63
+ version_code: "20800",
64
+ pc_version: "2.1.7",
65
+ chromium_version: CHROMIUM_VERSION,
66
+ client_platform: "pc_client",
67
+ };
68
+ return { ...common, ...byProfile };
69
+ }
70
+
71
+ export function normalizeRatio(ratio, fallback = "1:1") {
72
+ const r = String(ratio || "").trim();
73
+ return RATIOS.includes(r) ? r : fallback;
74
+ }
75
+
76
+ // —————————————————————————— SSE 解析 ——————————————————————————
77
+
78
+ /** 把原始 SSE 文本切成 {event, data, json} 块(兼容多行 data:)。 */
79
+ export function parseSseBlocks(raw) {
80
+ const blocks = [];
81
+ for (const block of String(raw).split("\n\n")) {
82
+ if (!block.trim()) continue;
83
+ let event = "message";
84
+ const dataLines = [];
85
+ for (const line of block.replace(/\r/g, "").split("\n")) {
86
+ if (line.startsWith("event:")) event = line.slice(6).trim() || "message";
87
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
88
+ }
89
+ if (!dataLines.length) continue;
90
+ const data = dataLines.join("\n");
91
+ let json = null;
92
+ try {
93
+ json = JSON.parse(data);
94
+ } catch {
95
+ try {
96
+ json = JSON.parse(dataLines[dataLines.length - 1]);
97
+ } catch {
98
+ /* 非 JSON 数据块,忽略 */
99
+ }
100
+ }
101
+ blocks.push({ event, data, json });
102
+ }
103
+ return blocks;
104
+ }
105
+
106
+ /** 响应若是 JSON 错误体(非 SSE),按错误码分类抛出。 */
107
+ function assertNotJsonError(raw) {
108
+ const trimmed = String(raw || "").trimStart();
109
+ if (!trimmed.startsWith("{")) return;
110
+ let obj = null;
111
+ try {
112
+ obj = JSON.parse(trimmed);
113
+ } catch {
114
+ return;
115
+ }
116
+ if (!obj || typeof obj !== "object" || !("code" in obj)) return;
117
+ throwCodeError(Number(obj.code) || 0, String(obj.msg || obj.message || ""));
118
+ }
119
+
120
+ function throwCodeError(code, msg) {
121
+ const short = msg.slice(0, 300);
122
+ if (RISK_CODES.has(code)) throw new DoubaoRiskError(`豆包风控拦截 (code=${code}) ${short}`, riskHint());
123
+ if (AUTH_CODES.has(code)) throw new DoubaoAuthError(`豆包登录态失效 (code=${code}) ${short}`);
124
+ if (code) throw new DoubaoError(`豆包接口错误 (code=${code}) ${short}`);
125
+ throw new DoubaoError(`豆包接口错误 ${short}`);
126
+ }
127
+
128
+ function throwGatewayError(json) {
129
+ const code = Number(json?.code) || 0;
130
+ const msg = String(json?.message ?? json?.msg ?? "").slice(0, 300);
131
+ if (AUTH_CODES.has(code) || /登录|过期|invalid user/i.test(msg)) {
132
+ throw new DoubaoAuthError(`豆包登录态失效 (gateway-error code=${code}) ${msg}`);
133
+ }
134
+ if (RISK_CODES.has(code)) throw new DoubaoRiskError(`豆包风控拦截 (code=${code})`, riskHint());
135
+ throw new DoubaoError(`豆包网关错误 (gateway-error code=${code}) ${msg}`);
136
+ }
137
+
138
+ const PC_IMAGE_ORDER = ["image_ori", "image_raw", "image_thumb"]; // pc 通道 ori 为最高清下载档
139
+ const WEB_IMAGE_ORDER = ["image_raw", "image_ori", "image_thumb"]; // web 通道 image_raw 为无水印档
140
+
141
+ /** 从一个图片资源对象里挑出最优 URL(按通道优先级)。 */
142
+ function pickImageVariants(item, order) {
143
+ if (!item || typeof item !== "object") return null;
144
+ const cands = [];
145
+ const push = (o) => {
146
+ if (o && typeof o === "object" && typeof o.url === "string" && o.url.startsWith("http")) cands.push(o);
147
+ };
148
+ for (const key of order) push(item[key]);
149
+ for (const key of ["ori_url", "raw_url", "thumb_url", "url"]) {
150
+ if (typeof item[key] === "string" && item[key].startsWith("http")) cands.push({ url: item[key], width: item.width, height: item.height, format: item.format });
151
+ }
152
+ const best = cands[0];
153
+ if (!best) return null;
154
+ return {
155
+ url: best.url,
156
+ width: Number(best.width || 0) || 0,
157
+ height: Number(best.height || 0) || 0,
158
+ format: best.format || item.format || "",
159
+ key: item.key || "",
160
+ };
161
+ }
162
+
163
+ /** 深度兜底:全树找 block_type=2074 的 creation 块。 */
164
+ function deepFindCreations(node, out) {
165
+ if (Array.isArray(node)) {
166
+ for (const n of node) deepFindCreations(n, out);
167
+ return;
168
+ }
169
+ if (!node || typeof node !== "object") return;
170
+ if (node.block_type === 2074 && Array.isArray(node.content?.creation_block?.creations)) {
171
+ out.push(...node.content.creation_block.creations);
172
+ return;
173
+ }
174
+ for (const v of Object.values(node)) deepFindCreations(v, out);
175
+ }
176
+
177
+ function noImagesMessage(texts) {
178
+ const joined = texts.join(" ").replace(/\s+/g, " ").trim();
179
+ if (/订阅|专属|标准套餐/.test(joined)) {
180
+ return `所选模型需要豆包订阅,当前账号不可用。${joined.slice(0, 200)}`;
181
+ }
182
+ if (joined) return `豆包未返回图片。${joined.slice(0, 300)}`;
183
+ return "豆包未返回图片。";
184
+ }
185
+
186
+ // —————————————————————————— pc 通道(samantha-2009,抓包真值) ——————————————————————————
187
+
188
+ export function parsePcImages(raw) {
189
+ assertNotJsonError(raw);
190
+ const images = [];
191
+ const texts = [];
192
+ for (const { event, json } of parseSseBlocks(raw)) {
193
+ if (event === "gateway-error") throwGatewayError(json);
194
+ if (!json || typeof json !== "object") continue;
195
+ const eventType = json.event_type;
196
+ if (eventType === 2005) {
197
+ const detail = String(json.event_data ?? "").slice(0, 300);
198
+ if (RISK_CODES.has(Number(json.code) || 0)) throw new DoubaoRiskError(`豆包风控拦截 ${detail}`, riskHint());
199
+ throw new DoubaoError(`豆包生图失败: ${detail}`);
200
+ }
201
+ if (eventType !== 2001) continue;
202
+ let ed = json.event_data;
203
+ if (typeof ed === "string") {
204
+ try {
205
+ ed = JSON.parse(ed);
206
+ } catch {
207
+ continue;
208
+ }
209
+ }
210
+ const msg = ed?.message || {};
211
+ let content = msg.content;
212
+ if (typeof content === "string") {
213
+ try {
214
+ content = JSON.parse(content);
215
+ } catch {
216
+ continue;
217
+ }
218
+ }
219
+ if (msg.content_type === 2010 && Array.isArray(content?.data)) {
220
+ for (const item of content.data) {
221
+ const img = pickImageVariants(item, PC_IMAGE_ORDER);
222
+ if (img) images.push(img);
223
+ }
224
+ } else if (content?.text) {
225
+ texts.push(String(content.text));
226
+ }
227
+ }
228
+ if (!images.length) throw new DoubaoError(noImagesMessage(texts));
229
+ return { images, text: texts.join("") };
230
+ }
231
+
232
+ export async function generateViaPc(cfg, account, { prompt, ratio }, refs = []) {
233
+ const content = { text: prompt };
234
+ if (ratio && ratio !== "auto") content.ratio = ratio;
235
+ const message = {
236
+ content: JSON.stringify(content),
237
+ content_type: 2009, // SamanthaImageInput
238
+ attachments: refs.map((r) => ({ type: "image", key: r.uri, extra: { refer_types: "overall" } })),
239
+ references: [],
240
+ skill: { skill_type: 3, skill_type_no_default: 3, skill_id: "3", skill_id_no_default: "3" },
241
+ };
242
+ const body = {
243
+ messages: [message],
244
+ completion_option: {
245
+ is_regen: false,
246
+ with_suggest: true,
247
+ need_create_conversation: true,
248
+ launch_stage: 1,
249
+ is_replace: false,
250
+ is_delete: false,
251
+ is_ai_playground: false,
252
+ memory_type: 2,
253
+ message_from: 0,
254
+ use_deep_think: false,
255
+ use_auto_cot: false,
256
+ resend_for_regen: false,
257
+ enable_commerce_credit: false,
258
+ action_bar_skill_id: 3,
259
+ },
260
+ evaluate_option: { web_ab_params: "" },
261
+ local_conversation_id: randomUUID(),
262
+ local_message_id: randomUUID(),
263
+ };
264
+ const url = `${BASE_URL}/samantha/chat/completion?${new URLSearchParams(securityParams("pc"))}`;
265
+ const resp = await fetch(url, {
266
+ method: "POST",
267
+ headers: baseHeaders(account, SSE_HEADERS),
268
+ body: JSON.stringify(body),
269
+ signal: AbortSignal.timeout((cfg.timeoutSeconds || 180) * 1000),
270
+ });
271
+ const raw = await resp.text();
272
+ if (!resp.ok) {
273
+ assertNotJsonError(raw);
274
+ throw new DoubaoError(`samantha 接口请求失败 (${resp.status}): ${raw.slice(0, 200)}`);
275
+ }
276
+ const parsed = parsePcImages(raw);
277
+ return { channel: "pc", ...parsed };
278
+ }
279
+
280
+ // —————————————————————————— web 通道(chat_ability,免签名) ——————————————————————————
281
+
282
+ export function parseWebImages(raw) {
283
+ assertNotJsonError(raw);
284
+ const images = [];
285
+ const texts = [];
286
+ let conversationId = "";
287
+ const scanCreations = (creations) => {
288
+ for (const c of creations || []) {
289
+ if (!c || typeof c !== "object") continue;
290
+ const img = pickImageVariants(c.image || c, WEB_IMAGE_ORDER);
291
+ if (img) images.push(img);
292
+ }
293
+ };
294
+ for (const { event, json } of parseSseBlocks(raw)) {
295
+ if (event === "gateway-error") throwGatewayError(json);
296
+ if (!json || typeof json !== "object") continue;
297
+
298
+ if (event === "SSE_ACK") {
299
+ const cid = String(json.ack_client_meta?.conversation_id ?? "");
300
+ if (cid && cid !== "0") conversationId = cid;
301
+ continue;
302
+ }
303
+ if (event === "STREAM_ERROR" || ("error_code" in json && Number(json.error_code) !== 0)) {
304
+ throwCodeError(Number(json.error_code) || 0, String(json.error_msg ?? json.message ?? ""));
305
+ }
306
+ if (typeof json.text === "string" && json.text && !("error_code" in json)) {
307
+ texts.push(json.text); // CHUNK_DELTA
308
+ }
309
+
310
+ const blocks = [];
311
+ for (const patch of json.patch_op || []) {
312
+ const pv = patch?.patch_value;
313
+ if (pv && typeof pv === "object") {
314
+ if (Array.isArray(pv.content_block)) blocks.push(...pv.content_block);
315
+ if (typeof pv.content === "string") {
316
+ try {
317
+ const c = JSON.parse(pv.content);
318
+ if (c?.text) texts.push(String(c.text));
319
+ } catch {
320
+ /* ignore */
321
+ }
322
+ }
323
+ }
324
+ }
325
+ if (json.content && typeof json.content === "object" && Array.isArray(json.content.content_block)) {
326
+ blocks.push(...json.content.content_block);
327
+ }
328
+ for (const cb of blocks) {
329
+ if (!cb || typeof cb !== "object") continue;
330
+ if (cb.block_type === 10000) {
331
+ const t = cb.content?.text_block?.text;
332
+ if (t) texts.push(String(t));
333
+ } else if (cb.block_type === 2074) {
334
+ scanCreations(cb.content?.creation_block?.creations);
335
+ }
336
+ }
337
+ if (event === "SSE_REPLY_END") break;
338
+ }
339
+
340
+ if (!images.length) {
341
+ // 已知块结构没命中时,整棵 SSE 事件树兜底扫一遍 2074 creation 块
342
+ const creations = [];
343
+ deepFindCreations(parseSseBlocks(raw).map((b) => b.json), creations);
344
+ scanCreations(creations);
345
+ }
346
+ if (!images.length) throw new DoubaoError(noImagesMessage(texts));
347
+ return { images, text: texts.join(""), conversationId };
348
+ }
349
+
350
+ export async function generateViaWeb(cfg, account, { prompt, ratio, model }) {
351
+ const r = normalizeRatio(ratio, "auto");
352
+ let text = `生成图片:${prompt}`;
353
+ if (cfg.ratioHint !== false && r !== "auto") text += `(画幅比例 ${r})`;
354
+ const abilityParam = JSON.stringify({
355
+ ability_param: {
356
+ model: model || cfg.model || DEFAULT_MODEL,
357
+ ratio: r,
358
+ input_box_content: { user_input_content: prompt, reply_message_format: "生成图片:%s" },
359
+ },
360
+ ability_type: 1,
361
+ });
362
+ const body = {
363
+ client_meta: {
364
+ local_conversation_id: `local_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
365
+ conversation_id: "",
366
+ bot_id: EXTENSION_BOT_ID,
367
+ last_section_id: "",
368
+ last_message_index: 0,
369
+ },
370
+ messages: [
371
+ {
372
+ local_message_id: randomUUID(),
373
+ content_block: [
374
+ {
375
+ block_type: 10000,
376
+ content: { text_block: { text, icon_url: "", icon_url_dark: "", summary: "" }, pc_event_block: "" },
377
+ block_id: randomUUID(),
378
+ parent_id: "",
379
+ meta_info: [],
380
+ append_fields: [],
381
+ is_finish: true,
382
+ patch_type: 2,
383
+ },
384
+ ],
385
+ message_status: 0,
386
+ },
387
+ ],
388
+ option: {
389
+ send_message_scene: "",
390
+ create_time_ms: 0,
391
+ collect_id: "",
392
+ is_audio: false,
393
+ answer_with_suggest: true,
394
+ tts_switch: false,
395
+ need_deep_think: 0,
396
+ click_clear_context: false,
397
+ from_suggest: false,
398
+ is_regen: false,
399
+ is_replace: false,
400
+ disable_sse_cache: false,
401
+ select_text_action: "",
402
+ resend_for_regen: false,
403
+ scene_type: 0,
404
+ unique_key: randomUUID(),
405
+ start_seq: 0,
406
+ need_create_conversation: true,
407
+ conversation_init_option: { need_ack_conversation: true },
408
+ conversation_mode: 1,
409
+ message_storage_type: 0,
410
+ memory_type: 0,
411
+ regen_query_id: [],
412
+ edit_query_id: [],
413
+ regen_instruction: "",
414
+ no_replace_for_regen: false,
415
+ message_from: 0,
416
+ shared_app_name: "",
417
+ action_bar_skill_id: 0,
418
+ sse_recv_event_options: { support_chunk_delta: true },
419
+ is_ai_playground: false,
420
+ },
421
+ chat_ability: { ability_type: 3, ability_param: abilityParam },
422
+ ext: {
423
+ use_deep_think: "0",
424
+ fp: FP,
425
+ use_submit_pipeline: "1",
426
+ commerce_credit_config_enable: "0",
427
+ sub_conv_firstmet_type: "1",
428
+ },
429
+ };
430
+ const url = `${BASE_URL}/chat/completion?${new URLSearchParams(securityParams("web"))}`;
431
+ const resp = await fetch(url, {
432
+ method: "POST",
433
+ headers: baseHeaders(account, SSE_HEADERS),
434
+ body: JSON.stringify(body),
435
+ signal: AbortSignal.timeout((cfg.timeoutSeconds || 180) * 1000),
436
+ });
437
+ const raw = await resp.text();
438
+ if (!resp.ok) {
439
+ assertNotJsonError(raw);
440
+ throw new DoubaoError(`chat/completion 接口请求失败 (${resp.status}): ${raw.slice(0, 200)}`);
441
+ }
442
+ const parsed = parseWebImages(raw);
443
+ return { channel: "web", ...parsed };
444
+ }
445
+
446
+ // —————————————————————————— 顶层编排:账号轮换 + 通道回退 ——————————————————————————
447
+
448
+ const PAID_WALL_RE = /订阅|专属|标准套餐/;
449
+
450
+ /**
451
+ * 生成图片。opts: { prompt, ratio?, model?, refImages?, account? }
452
+ * 返回 { images:[{url,width,height,format}], text, channel, account, conversationId, notes[] }
453
+ */
454
+ export async function generate(cfg, opts) {
455
+ const prompt = String(opts.prompt || "").trim();
456
+ if (!prompt) throw new DoubaoError("prompt 不能为空。");
457
+ const ratio = normalizeRatio(opts.ratio, normalizeRatio(cfg.ratio, "1:1"));
458
+ const model = (opts.model || cfg.model || DEFAULT_MODEL).trim();
459
+ const channels = cfg.channel === "auto" ? ["web", "pc"] : [cfg.channel];
460
+ const effectiveChannels = opts.refImages?.length ? ["pc"] : channels; // 图生图只走 pc 通道
461
+ const errors = [];
462
+
463
+ const { accountOrder } = await import("./accounts.mjs");
464
+ const order = accountOrder(cfg.accounts, opts.account);
465
+
466
+ for (const account of order) {
467
+ try {
468
+ // 参考图要用当前账号的 cookie 上传
469
+ const accountRefs = opts.refImages?.length ? await uploadRefImages(cfg, account, opts.refImages) : [];
470
+ let lastErr = null;
471
+ for (const channel of effectiveChannels) {
472
+ try {
473
+ const run = (m) =>
474
+ channel === "web"
475
+ ? generateViaWeb(cfg, account, { prompt, ratio, model: m })
476
+ : generateViaPc(cfg, account, { prompt, ratio }, accountRefs);
477
+ let result;
478
+ try {
479
+ result = await run(model);
480
+ } catch (err) {
481
+ // 付费墙 + 用户点名了非默认模型 → 自动回退默认(免费档)模型再试一次
482
+ if (
483
+ err instanceof DoubaoError &&
484
+ PAID_WALL_RE.test(err.message) &&
485
+ model !== (cfg.model || DEFAULT_MODEL) &&
486
+ model !== DEFAULT_MODEL
487
+ ) {
488
+ result = await run(cfg.model || DEFAULT_MODEL);
489
+ result.text = `[模型回退] 所选 ${model} 需要订阅,已改用 ${cfg.model || DEFAULT_MODEL}。${result.text}`;
490
+ } else {
491
+ throw err;
492
+ }
493
+ }
494
+ return { ...result, account: account.name || "(未命名)", notes: [] };
495
+ } catch (err) {
496
+ lastErr = err;
497
+ if (err instanceof DoubaoRiskError) throw err; // IP 级风控,换通道无意义
498
+ errors.push(`${channel}: ${err.message}`);
499
+ }
500
+ }
501
+ if (lastErr) throw lastErr;
502
+ } catch (err) {
503
+ if (err instanceof DoubaoRiskError) throw err;
504
+ if (err instanceof DoubaoAuthError) {
505
+ errors.push(`${account.name || "(未命名)"}: ${err.message}`);
506
+ continue; // 换下一个账号
507
+ }
508
+ throw err;
509
+ }
510
+ }
511
+ throw new DoubaoError(
512
+ `所有账号均不可用。${errors.join(";")}`,
513
+ "运行 check_login 查看各账号登录态;sessionid 过期后请到 doubao.com 重新复制。",
514
+ );
515
+ }
516
+
517
+ /** 登录态自检(供 check_login 工具与测试脚本使用)。 */
518
+ export async function checkLogin(cfg) {
519
+ const { checkAllAccounts } = await import("./accounts.mjs");
520
+ return checkAllAccounts(cfg.accounts);
521
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // 配置解析。优先级:
2
+ // env DOUBAO_IMAGE_CONFIG(指定配置文件路径)
3
+ // > 插件目录 config.json
4
+ // > env DOUBAO_ACCOUNTS("主号:sessionid=xxx,ttwid=yyy;小号:sessionid=zzz")
5
+ // > 内置默认
6
+ // Magpie 插件 options(插件函数第二参)可覆盖任意字段。
7
+ import { readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const PLUGIN_DIR = fileURLToPath(new URL("..", import.meta.url));
11
+
12
+ export const DEFAULTS = {
13
+ accounts: [],
14
+ model: "Seedream 5.0 Flash",
15
+ ratio: "1:1",
16
+ channel: "auto", // auto | web | pc
17
+ outDir: "", // 空 = 跟随调用方工作目录的 generated-images/
18
+ timeoutSeconds: 180,
19
+ ratioHint: true, // web 通道把比例拼进自然语言(web 端 ratio 字段实测不生效)
20
+ removeWatermark: false, // 扩图裁剪法去右下角"豆包AI生成"水印(多花一次生成额度)
21
+ watermarkExtend: "1/4", // 扩图比例,裁剪保留 1/(1+该值)
22
+ };
23
+
24
+ function parseEnvAccounts(raw) {
25
+ const accounts = [];
26
+ for (const chunk of String(raw).split(";")) {
27
+ const seg = chunk.trim();
28
+ if (!seg) continue;
29
+ const colon = seg.indexOf(":");
30
+ let name = "";
31
+ let rest = seg;
32
+ if (colon > 0) {
33
+ name = seg.slice(0, colon).trim();
34
+ rest = seg.slice(colon + 1);
35
+ }
36
+ const account = { name: name || `账号${accounts.length + 1}` };
37
+ for (const kv of rest.split(",")) {
38
+ const eq = kv.indexOf("=");
39
+ if (eq <= 0) continue;
40
+ account[kv.slice(0, eq).trim()] = kv.slice(eq + 1).trim();
41
+ }
42
+ accounts.push(account);
43
+ }
44
+ return accounts;
45
+ }
46
+
47
+ function loadConfigFile() {
48
+ const explicit = process.env.DOUBAO_IMAGE_CONFIG;
49
+ const candidates = explicit
50
+ ? [explicit]
51
+ : [fileURLToPath(new URL("../config.json", import.meta.url))];
52
+ for (const candidate of candidates) {
53
+ try {
54
+ return JSON.parse(readFileSync(candidate, "utf8"));
55
+ } catch (err) {
56
+ if (explicit && err.code !== "ENOENT") {
57
+ console.warn(`[tools-doubao] 读取配置 ${candidate} 失败: ${err.message}`);
58
+ }
59
+ }
60
+ }
61
+ return {};
62
+ }
63
+
64
+ export function loadConfig(overrides = {}) {
65
+ const file = loadConfigFile();
66
+ const envAccounts = process.env.DOUBAO_ACCOUNTS
67
+ ? parseEnvAccounts(process.env.DOUBAO_ACCOUNTS)
68
+ : null;
69
+ const merged = { ...DEFAULTS, ...file, ...overrides };
70
+ // 优先级:env DOUBAO_ACCOUNTS > overrides(Magpie options)> config.json
71
+ const accounts =
72
+ envAccounts ||
73
+ (Array.isArray(overrides.accounts) && overrides.accounts.length ? overrides.accounts : null) ||
74
+ (Array.isArray(file.accounts) ? file.accounts : []) ||
75
+ [];
76
+ merged.accounts = (Array.isArray(accounts) ? accounts : []).filter(
77
+ (a) => a && typeof a.sessionid === "string" && a.sessionid.length > 8,
78
+ );
79
+ return merged;
80
+ }
81
+
82
+ /** 给测试脚本/排障用:配置是从哪里来的。 */
83
+ export function configSource() {
84
+ if (process.env.DOUBAO_IMAGE_CONFIG) return `env:DOUBAO_IMAGE_CONFIG`;
85
+ try {
86
+ readFileSync(fileURLToPath(new URL("../config.json", import.meta.url)), "utf8");
87
+ return "config.json";
88
+ } catch {
89
+ /* ignore */
90
+ }
91
+ if (process.env.DOUBAO_ACCOUNTS) return "env:DOUBAO_ACCOUNTS";
92
+ return "none";
93
+ }
94
+
95
+ export { PLUGIN_DIR };
@@ -0,0 +1,56 @@
1
+ // 图片下载落盘。按魔数识别真实格式,content-type 兜底。
2
+ import { mkdir, writeFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { USER_AGENT, DoubaoError } from "./http.mjs";
5
+
6
+ /** 文件大小(字节),失败返回 0。 */
7
+ export async function fileSize(file) {
8
+ try {
9
+ return (await stat(file)).size;
10
+ } catch {
11
+ return 0;
12
+ }
13
+ }
14
+
15
+ const EXT_BY_CONTENT_TYPE = {
16
+ "image/png": "png",
17
+ "image/jpeg": "jpg",
18
+ "image/webp": "webp",
19
+ "image/gif": "gif",
20
+ };
21
+
22
+ function sniffExt(buf, contentType) {
23
+ if (buf.length > 8) {
24
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "png";
25
+ if (buf[0] === 0xff && buf[1] === 0xd8) return "jpg";
26
+ if (buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "webp";
27
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return "gif";
28
+ }
29
+ const ct = (contentType || "").split(";")[0].trim().toLowerCase();
30
+ return EXT_BY_CONTENT_TYPE[ct] || "png";
31
+ }
32
+
33
+ /** 下载一张图到 outDir,返回 { file, bytes }。 */
34
+ export async function downloadImage(url, outDir, baseName) {
35
+ const resp = await fetch(url, {
36
+ headers: { "User-Agent": USER_AGENT, Referer: "https://www.doubao.com/" },
37
+ signal: AbortSignal.timeout(120_000),
38
+ });
39
+ if (!resp.ok) throw new DoubaoError(`图片下载失败 (${resp.status}): ${url.slice(0, 120)}`);
40
+ const buf = Buffer.from(await resp.arrayBuffer());
41
+ if (!buf.length) throw new DoubaoError(`图片下载为空: ${url.slice(0, 120)}`);
42
+ const ext = sniffExt(buf, resp.headers.get("content-type"));
43
+ await mkdir(outDir, { recursive: true });
44
+ const file = path.join(outDir, `${baseName}.${ext}`);
45
+ await writeFile(file, buf);
46
+ return { file, bytes: buf.length };
47
+ }
48
+
49
+ /** yyyymmddHHMMSS 时间戳,用于文件名。 */
50
+ export function fileStamp(date = new Date()) {
51
+ const p = (n, w = 2) => String(n).padStart(w, "0");
52
+ return (
53
+ `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
54
+ `${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
55
+ );
56
+ }