@zeppeli777/tools-doubao 0.0.0-stage → 0.1.1

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/http.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // 共享 HTTP 常量与请求头构造。零依赖:只用全局 fetch / node 内置模块。
2
+
3
+ // 与 doubao2api 抓包真值对齐的桌面客户端指纹常量(公开逆向项目的通用值)。
4
+ export const DEVICE_ID = "714003710229497";
5
+ export const WEB_ID = "7604137868021548590";
6
+ export const FP = "verify_mlcfw5f7_TPq0YmFD_NrsC_4RuQ_BJPg_M5W7i58I7wV0";
7
+ export const CHROMIUM_VERSION = "148.0.7816.0";
8
+
9
+ export const USER_AGENT =
10
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " +
11
+ "Chrome/148.0.0.0 Safari/537.36";
12
+
13
+ export const BASE_URL = "https://www.doubao.com";
14
+
15
+ /** 把一个账号的凭证合成为 Cookie 头。sessionid 必需;msToken 留空最安全(假值触发 710022002)。 */
16
+ export function cookieHeader(account) {
17
+ const parts = [`sessionid=${account.sessionid}`];
18
+ if (account.ttwid) parts.push(`ttwid=${account.ttwid}`);
19
+ if (account.passport_csrf_token) parts.push(`passport_csrf_token=${account.passport_csrf_token}`);
20
+ return parts.join("; ");
21
+ }
22
+
23
+ /** 基础请求头。extra 里的同名键会覆盖默认值。 */
24
+ export function baseHeaders(account, extra = {}) {
25
+ return {
26
+ "User-Agent": USER_AGENT,
27
+ Cookie: cookieHeader(account),
28
+ "x-tt-passport-csrf-token": account.passport_csrf_token || "",
29
+ Origin: BASE_URL,
30
+ Referer: `${BASE_URL}/chat`,
31
+ ...extra,
32
+ };
33
+ }
34
+
35
+ export const SSE_HEADERS = {
36
+ Accept: "text/event-stream",
37
+ "Content-Type": "application/json",
38
+ "Agw-Js-Conv": "str",
39
+ };
40
+
41
+ /** 错误类型:带给 Agent 的可读提示。 */
42
+ export class DoubaoError extends Error {
43
+ constructor(message, hint = "") {
44
+ super(message);
45
+ this.name = "DoubaoError";
46
+ this.hint = hint;
47
+ }
48
+ }
49
+
50
+ /** 登录态失效 —— 调用方应当轮换下一个账号重试。 */
51
+ export class DoubaoAuthError extends DoubaoError {}
52
+ /** IP/账号风控 —— 换账号没用,直接报给用户。 */
53
+ export class DoubaoRiskError extends DoubaoError {}
54
+
55
+ export const RISK_CODES = new Set([710022002, 710022004]);
56
+ export const AUTH_CODES = new Set([710012001, 710012002, 710012003]);
57
+
58
+ export function riskHint() {
59
+ return "触发了豆包风控。可等待约 30 分钟冷却、切换网络(手机热点最快)、或在浏览器里打开 doubao.com 手动过一次人机验证。";
60
+ }
package/src/png.mjs ADDED
@@ -0,0 +1,164 @@
1
+ // 零依赖 PNG 解码 / 编码 / 裁剪(8-bit,bit depth 固定 8)。
2
+ // 解码支持 colorType 0/2/3/4/6,统一输出 RGB;编码输出 colorType 2(RGB)。
3
+ // 用途:扫码登录二维码转 ASCII、去水印的扩图裁剪。
4
+ import { inflateSync, deflateSync } from "node:zlib";
5
+
6
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+
8
+ // 自带 CRC32(zlib.crc32 在部分 Bun 版本缺失,为兼容 Bun 宿主手动实现)
9
+ const CRC_TABLE = (() => {
10
+ const table = new Int32Array(256);
11
+ for (let n = 0; n < 256; n++) {
12
+ let c = n;
13
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
14
+ table[n] = c;
15
+ }
16
+ return table;
17
+ })();
18
+
19
+ function crc32(buf) {
20
+ let c = 0xffffffff;
21
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
22
+ return (c ^ 0xffffffff) >>> 0;
23
+ }
24
+
25
+ function chunk(type, data) {
26
+ const len = Buffer.alloc(4);
27
+ len.writeUInt32BE(data.length);
28
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
29
+ const crc = Buffer.alloc(4);
30
+ crc.writeUInt32BE(crc32(body));
31
+ return Buffer.concat([len, body, crc]);
32
+ }
33
+
34
+ /**
35
+ * 解码 PNG 为 RGB。返回 { width, height, data: Buffer(RGB interleaved) }。
36
+ * 仅支持 8-bit、非隔行;不满足时抛错。
37
+ */
38
+ export function decodePng(buf) {
39
+ if (!buf || buf.length < 57 || buf.readUInt32BE(0) !== 0x89504e47) {
40
+ throw new Error("不是 PNG 文件");
41
+ }
42
+ let width = 0;
43
+ let height = 0;
44
+ let bitDepth = 0;
45
+ let colorType = 0;
46
+ let interlace = 0;
47
+ const idat = [];
48
+ let palette = null;
49
+ let off = 8;
50
+ while (off + 8 <= buf.length) {
51
+ const len = buf.readUInt32BE(off);
52
+ const type = buf.toString("ascii", off + 4, off + 8);
53
+ const data = buf.subarray(off + 8, off + 8 + len);
54
+ if (type === "IHDR") {
55
+ width = data.readUInt32BE(0);
56
+ height = data.readUInt32BE(4);
57
+ bitDepth = data[8];
58
+ colorType = data[9];
59
+ interlace = data[12];
60
+ } else if (type === "PLTE") {
61
+ palette = data;
62
+ } else if (type === "IDAT") {
63
+ idat.push(data);
64
+ } else if (type === "IEND") {
65
+ break;
66
+ }
67
+ off += 12 + len;
68
+ }
69
+ if (bitDepth !== 8) throw new Error(`不支持的 PNG 位深 ${bitDepth}(仅支持 8)`);
70
+ if (interlace) throw new Error("不支持隔行扫描(Adam7)的 PNG");
71
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
72
+ if (!channels) throw new Error(`不支持的 PNG 颜色类型 ${colorType}`);
73
+
74
+ const raw = inflateSync(Buffer.concat(idat));
75
+ const stride = width * channels;
76
+ const pixels = Buffer.alloc(height * stride);
77
+ for (let y = 0; y < height; y++) {
78
+ const filter = raw[y * (stride + 1)];
79
+ const rowIn = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
80
+ const row = pixels.subarray(y * stride, (y + 1) * stride);
81
+ const prev = y > 0 ? pixels.subarray((y - 1) * stride, y * stride) : null;
82
+ for (let x = 0; x < stride; x++) {
83
+ const left = x >= channels ? row[x - channels] : 0;
84
+ const up = prev ? prev[x] : 0;
85
+ const upLeft = prev && x >= channels ? prev[x - channels] : 0;
86
+ let val = rowIn[x];
87
+ if (filter === 1) val += left;
88
+ else if (filter === 2) val += up;
89
+ else if (filter === 3) val += (left + up) >> 1;
90
+ else if (filter === 4) {
91
+ const p = left + up - upLeft;
92
+ const pa = Math.abs(p - left);
93
+ const pb = Math.abs(p - up);
94
+ const pc = Math.abs(p - upLeft);
95
+ val += pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft;
96
+ }
97
+ row[x] = val & 0xff;
98
+ }
99
+ }
100
+
101
+ // 统一转 RGB
102
+ const rgb = Buffer.alloc(width * height * 3);
103
+ for (let i = 0, j = 0; i < width * height; i++, j += 3) {
104
+ const p = i * channels;
105
+ let r = 0;
106
+ let g = 0;
107
+ let b = 0;
108
+ if (colorType === 0) {
109
+ r = g = b = pixels[p];
110
+ } else if (colorType === 2 || colorType === 6) {
111
+ r = pixels[p];
112
+ g = pixels[p + 1];
113
+ b = pixels[p + 2];
114
+ } else if (colorType === 4) {
115
+ r = g = b = pixels[p];
116
+ } else if (colorType === 3) {
117
+ const idx = pixels[p] * 3;
118
+ r = palette[idx];
119
+ g = palette[idx + 1];
120
+ b = palette[idx + 2];
121
+ }
122
+ rgb[j] = r;
123
+ rgb[j + 1] = g;
124
+ rgb[j + 2] = b;
125
+ }
126
+ return { width, height, data: rgb };
127
+ }
128
+
129
+ /** 把 RGB 像素编码为 PNG(colorType 2,filter 0)。 */
130
+ export function encodePng(width, height, rgb) {
131
+ const stride = width * 3;
132
+ const raw = Buffer.alloc((stride + 1) * height);
133
+ for (let y = 0; y < height; y++) {
134
+ raw[y * (stride + 1)] = 0; // filter: none
135
+ rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
136
+ }
137
+ const ihdr = Buffer.alloc(13);
138
+ ihdr.writeUInt32BE(width, 0);
139
+ ihdr.writeUInt32BE(height, 4);
140
+ ihdr[8] = 8; // bit depth
141
+ ihdr[9] = 2; // truecolor RGB
142
+ return Buffer.concat([
143
+ SIGNATURE,
144
+ chunk("IHDR", ihdr),
145
+ chunk("IDAT", deflateSync(raw, { level: 6 })),
146
+ chunk("IEND", Buffer.alloc(0)),
147
+ ]);
148
+ }
149
+
150
+ /**
151
+ * 裁掉 PNG 底部:保留顶部 keepTopFrac 比例(默认 0.8,对应"往下扩图四分之一")。
152
+ * 返回 { buffer, width, height, origHeight };输入非 PNG 时抛错。
153
+ */
154
+ export function cropPngBottom(pngBuf, keepTopFrac = 0.8) {
155
+ const img = decodePng(pngBuf);
156
+ const newHeight = Math.max(1, Math.round(img.height * keepTopFrac));
157
+ const data = img.data.subarray(0, newHeight * img.width * 3);
158
+ return {
159
+ buffer: encodePng(img.width, newHeight, data),
160
+ width: img.width,
161
+ height: newHeight,
162
+ origHeight: img.height,
163
+ };
164
+ }
package/src/qlogin.mjs ADDED
@@ -0,0 +1,240 @@
1
+ // 豆包扫码登录(移植自 doubao2api qr_login.py 的抓包真值):
2
+ // 1. GET 首页 → ttwid 等基础 Cookie
3
+ // 2. GET /passport/safe/csrf_token → passport_csrf_token
4
+ // 3. GET /passport/web/get_qrcode → token + base64 PNG 二维码
5
+ // 4. 轮询 /passport/web/check_qrconnect(1.5s)→ new / scanned / confirmed / expired
6
+ // 5. confirmed 后跟随 redirect_url 链,收集完整 Cookie(含 sessionid)
7
+ // 零依赖:fetch + node:zlib(二维码 PNG 解码出 ASCII)。
8
+ import { tmpdir } from "node:os";
9
+ import path from "node:path";
10
+ import { spawn } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+ import { readFileSync, writeFileSync } from "node:fs";
13
+ import { decodePng } from "./png.mjs";
14
+
15
+ const BASE_URL = "https://www.doubao.com";
16
+ const AID = "497858";
17
+ const UA =
18
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " +
19
+ "Chrome/148.0.0.0 Safari/537.36";
20
+
21
+ const BASE_HEADERS = {
22
+ "User-Agent": UA,
23
+ Accept: "application/json, text/plain, */*",
24
+ Referer: `${BASE_URL}/chat/login`,
25
+ Origin: BASE_URL,
26
+ };
27
+
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+
30
+ /** GET 一次请求;把响应的 Set-Cookie 合并进 cookies。返回 {status, text}。 */
31
+ async function httpGet(url, headers, cookies, { follow = false, timeoutMs = 15_000 } = {}) {
32
+ let current = url;
33
+ for (let hop = 0; hop < 10; hop++) {
34
+ const resp = await fetch(current, {
35
+ method: "GET",
36
+ headers: { ...headers, ...(Object.keys(cookies).length ? { Cookie: serialize(cookies) } : {}) },
37
+ redirect: "manual",
38
+ signal: AbortSignal.timeout(timeoutMs),
39
+ });
40
+ mergeSetCookies(resp, cookies);
41
+ if (follow && [301, 302, 303, 307, 308].includes(resp.status)) {
42
+ const location = resp.headers.get("location");
43
+ if (location) {
44
+ current = new URL(location, current).href;
45
+ continue;
46
+ }
47
+ }
48
+ return { status: resp.status, text: await resp.text() };
49
+ }
50
+ return { status: 0, text: "" };
51
+ }
52
+
53
+ function serialize(cookies) {
54
+ return Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
55
+ }
56
+
57
+ function mergeSetCookies(resp, cookies) {
58
+ const list = typeof resp.headers.getSetCookie === "function" ? resp.headers.getSetCookie() : [];
59
+ for (const raw of list) {
60
+ const pair = raw.split(";", 1)[0].trim();
61
+ const eq = pair.indexOf("=");
62
+ if (eq > 0) cookies[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
63
+ }
64
+ }
65
+
66
+ async function getJson(url, headers, cookies) {
67
+ const { text } = await httpGet(url, headers, cookies);
68
+ try {
69
+ return JSON.parse(text);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** 把二维码 PNG 解码成终端半块字符画(尽力而为,失败返回 null)。 */
76
+ export function qrPngToAscii(png) {
77
+ try {
78
+ const { width, height, data } = decodePng(png);
79
+ if (!width || !height) return null;
80
+ const lum = (x, y) => {
81
+ const i = (y * width + x) * 3;
82
+ return 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
83
+ };
84
+ const dark = (x, y) => lum(x, y) < 128;
85
+ // 左上静区宽度 ≈ 4 个模块 → 每模块像素数
86
+ let quiet = 0;
87
+ while (quiet < width && !dark(quiet, Math.floor(height / 2))) quiet++;
88
+ // 若从最左列采样撞上黑模块(静区异常),退化为固定 37 模块猜测
89
+ const moduleCount = quiet >= 6 ? Math.round(width / (quiet / 4)) - 8 : 37;
90
+ const scale = width / (moduleCount + 8);
91
+ if (moduleCount < 21 || moduleCount > 177 || scale < 2) return null;
92
+ const sample = (row, col) =>
93
+ dark(
94
+ Math.min(width - 1, Math.floor((col + 4 + 0.5) * scale)),
95
+ Math.min(height - 1, Math.floor((row + 4 + 0.5) * scale)),
96
+ );
97
+ const grid = [];
98
+ for (let r = 0; r < moduleCount; r++) {
99
+ const line = [];
100
+ for (let c = 0; c < moduleCount; c++) line.push(sample(r, c) ? 1 : 0);
101
+ grid.push(line);
102
+ }
103
+ // 半块字符(▀▄█ 空格)保持方形纵横比,外留一圈白边做静区
104
+ const lines = [" ".repeat(moduleCount + 2)];
105
+ for (let r = 0; r < moduleCount; r += 2) {
106
+ let line = " ";
107
+ for (let c = 0; c < moduleCount; c++) {
108
+ const top = grid[r][c];
109
+ const bottom = r + 1 < moduleCount ? grid[r + 1][c] : 0;
110
+ line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
111
+ }
112
+ lines.push(line + " ");
113
+ }
114
+ lines.push(" ".repeat(moduleCount + 2));
115
+ return lines.join("\n");
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /** 保存二维码 PNG 到临时目录并返回路径。 */
122
+ export function saveQrPng(png) {
123
+ const file = path.join(tmpdir(), `doubao-login-qr-${Date.now()}.png`);
124
+ writeFileSync(file, png);
125
+ return file;
126
+ }
127
+
128
+ /** 用系统默认看图程序打开文件(Windows explorer / 其他平台 open/xdg-open)。 */
129
+ export function openInViewer(file) {
130
+ try {
131
+ if (process.platform === "win32") spawn("explorer.exe", [file], { detached: true, stdio: "ignore" }).unref();
132
+ else if (process.platform === "darwin") spawn("open", [file], { detached: true, stdio: "ignore" }).unref();
133
+ else spawn("xdg-open", [file], { detached: true, stdio: "ignore" }).unref();
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 完整扫码登录流程。
142
+ * onStatus(stage, detail):stage ∈ fetching | ready | waiting | scanned | confirmed
143
+ * 返回 { sessionid, ttwid, passport_csrf_token, cookies, qrPng, qrPngPath }。
144
+ */
145
+ export async function qrLogin({ onStatus = () => {}, timeoutMs = 180_000, pollMs = 1_500 } = {}) {
146
+ const cookies = {};
147
+ const log = (stage, detail) => onStatus(stage, detail);
148
+
149
+ log("fetching", "正在获取二维码…");
150
+ await httpGet(`${BASE_URL}/`, BASE_HEADERS, cookies); // 基础 Cookie(ttwid 等)
151
+ let csrf = cookies.passport_csrf_token || cookies.passport_csrf_token_default || "";
152
+ if (!csrf) {
153
+ const body = await getJson(`${BASE_URL}/passport/safe/csrf_token/?aid=${AID}`, BASE_HEADERS, cookies);
154
+ csrf = body?.data?.passport_csrf_token || cookies.passport_csrf_token || "";
155
+ }
156
+
157
+ const next = encodeURIComponent(BASE_URL);
158
+ const qrBody = await getJson(
159
+ `${BASE_URL}/passport/web/get_qrcode/?next=${next}&aid=${AID}`,
160
+ { ...BASE_HEADERS, "x-tt-passport-csrf-token": csrf },
161
+ cookies,
162
+ );
163
+ const qr = qrBody?.data || {};
164
+ const qrErr = qr.error_code ?? qrBody?.error_code ?? -1;
165
+ if (qrErr !== 0 || qrBody?.message !== "success" || !qr.token) {
166
+ throw new Error(`获取二维码失败: ${JSON.stringify(qrBody).slice(0, 200)}`);
167
+ }
168
+
169
+ let qrPng = Buffer.alloc(0);
170
+ const b64 = qr.qrcode || qr.qrcode_url || "";
171
+ if (b64.startsWith("data:")) qrPng = Buffer.from(b64.slice(b64.indexOf(",") + 1), "base64");
172
+ else if (/^https?:/.test(b64)) {
173
+ const resp = await fetch(b64, { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(15_000) });
174
+ qrPng = Buffer.from(await resp.arrayBuffer());
175
+ } else if (b64) qrPng = Buffer.from(b64, "base64");
176
+
177
+ log("ready", qrPng.length ? qrPng : null);
178
+ const csrfHeaders = { ...BASE_HEADERS, "x-tt-passport-csrf-token": csrf };
179
+
180
+ const deadline = Date.now() + timeoutMs;
181
+ let lastStatus = "";
182
+ while (Date.now() < deadline) {
183
+ const body = await getJson(
184
+ `${BASE_URL}/passport/web/check_qrconnect/?next=${next}&token=${encodeURIComponent(qr.token)}&aid=${AID}`,
185
+ csrfHeaders,
186
+ cookies,
187
+ );
188
+ const d = body?.data || {};
189
+ const errCode = d.error_code ?? body?.error_code ?? 0;
190
+ const desc = String(d.description ?? "").toLowerCase();
191
+ if (errCode !== 0 && (desc.includes("expired") || desc.includes("过期"))) {
192
+ throw new Error("二维码已过期,请重新发起登录。");
193
+ }
194
+ const status = d.status || "";
195
+ if (status !== lastStatus) {
196
+ lastStatus = status;
197
+ if (status === "new") log("waiting", "等待扫码");
198
+ if (status === "scanned") log("scanned", "已扫码,请在手机上确认");
199
+ }
200
+ if (status === "confirmed") {
201
+ log("confirmed", "登录成功");
202
+ if (d.redirect_url) await httpGet(d.redirect_url, BASE_HEADERS, cookies, { follow: true, timeoutMs: 20_000 });
203
+ const sessionid = cookies.sessionid || "";
204
+ if (!sessionid) throw new Error("手机已确认登录,但未取到 sessionid,请重试。");
205
+ return {
206
+ sessionid,
207
+ ttwid: cookies.ttwid || "",
208
+ passport_csrf_token: cookies.passport_csrf_token || "",
209
+ cookies: { ...cookies },
210
+ qrPng,
211
+ };
212
+ }
213
+ if (status === "expired") throw new Error("二维码已过期,请重新发起登录。");
214
+ await sleep(pollMs);
215
+ }
216
+ throw new Error("扫码超时(3 分钟),请重新发起登录。");
217
+ }
218
+
219
+ /** 把扫码得到的账号写进插件目录 config.json(同名覆盖,其余字段保留)。 */
220
+ function writeAccountToConfig(account, name = "扫码登录") {
221
+ const file = fileURLToPath(new URL("../config.json", import.meta.url));
222
+ let cfg = {};
223
+ try {
224
+ cfg = JSON.parse(readFileSync(file, "utf8"));
225
+ } catch {
226
+ /* 尚无配置文件 */
227
+ }
228
+ const accounts = Array.isArray(cfg.accounts) ? cfg.accounts.filter(Boolean) : [];
229
+ const entry = { name, sessionid: account.sessionid };
230
+ if (account.ttwid) entry.ttwid = account.ttwid;
231
+ if (account.passport_csrf_token) entry.passport_csrf_token = account.passport_csrf_token;
232
+ const idx = accounts.findIndex((a) => a && a.name === name);
233
+ if (idx >= 0) accounts[idx] = entry;
234
+ else accounts.push(entry);
235
+ cfg.accounts = accounts;
236
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
237
+ return file;
238
+ }
239
+
240
+ export { writeAccountToConfig };
package/src/upload.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // 图生图的参考图上传(两步流,移植自 doubao2api 抓包真值):
2
+ // 1. POST /samantha/pages/upload_image (multipart) -> data.uri
3
+ // 2. POST /alice/message/get_file_url -> file_urls[0] {uri, main_url}
4
+ import { readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { BASE_URL, baseHeaders, DoubaoError } from "./http.mjs";
7
+ import { securityParams } from "./client.mjs";
8
+
9
+ /** 读取参考图:本地路径或 http(s) URL 都支持。 */
10
+ export async function loadRefImage(source) {
11
+ if (/^https?:\/\//i.test(source)) {
12
+ const resp = await fetch(source, { signal: AbortSignal.timeout(60_000) });
13
+ if (!resp.ok) throw new DoubaoError(`参考图下载失败 (${resp.status}): ${source}`);
14
+ const buf = Buffer.from(await resp.arrayBuffer());
15
+ let name = "";
16
+ try {
17
+ name = decodeURIComponent(new URL(source).pathname.split("/").pop() || "");
18
+ } catch {
19
+ /* ignore */
20
+ }
21
+ return { bytes: buf, name: name || "reference.png" };
22
+ }
23
+ const bytes = await readFile(source);
24
+ return { bytes, name: path.basename(source) };
25
+ }
26
+
27
+ /** 上传一张参考图,返回 samantha attachments 需要的 key(uri)等信息。 */
28
+ export async function uploadRefImage(cfg, account, source) {
29
+ const { bytes, name } = await loadRefImage(source);
30
+ const ext = (name.includes(".") ? name.split(".").pop() : "png").toLowerCase();
31
+ const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
32
+
33
+ // 第一步:上传
34
+ const uploadUrl = `${BASE_URL}/samantha/pages/upload_image?${new URLSearchParams(securityParams("pc"))}`;
35
+ const form = new FormData();
36
+ form.append("data", new Blob([bytes], { type: mime }), name);
37
+ form.append("file_type", ext);
38
+ const uploadResp = await fetch(uploadUrl, {
39
+ method: "POST",
40
+ headers: baseHeaders(account), // 不手动设 Content-Type,fetch 自动补 multipart boundary
41
+ body: form,
42
+ signal: AbortSignal.timeout(60_000),
43
+ });
44
+ const uploadBody = await uploadResp.json().catch(() => null);
45
+ if (!uploadResp.ok || !uploadBody || uploadBody.code !== 0) {
46
+ throw new DoubaoError(
47
+ `参考图上传失败 (${uploadResp.status}): ${JSON.stringify(uploadBody).slice(0, 300)}`,
48
+ );
49
+ }
50
+ const uriShort = uploadBody.data.uri;
51
+
52
+ // 第二步:换 CDN 链接 + 完整 uri
53
+ const fileUrlEndpoint = `${BASE_URL}/alice/message/get_file_url?${new URLSearchParams(securityParams("pc"))}`;
54
+ const fileResp = await fetch(fileUrlEndpoint, {
55
+ method: "POST",
56
+ headers: baseHeaders(account, { "Content-Type": "application/json" }),
57
+ body: JSON.stringify({ uris: [uriShort], type: "image", format: ext, expire_second: 3600 }),
58
+ signal: AbortSignal.timeout(60_000),
59
+ });
60
+ const fileBody = await fileResp.json().catch(() => null);
61
+ const fileInfo = fileBody?.data?.file_urls?.[0];
62
+ if (!fileResp.ok || !fileBody || fileBody.code !== 0 || !fileInfo) {
63
+ throw new DoubaoError(
64
+ `参考图换取 CDN 链接失败: ${JSON.stringify(fileBody).slice(0, 300)}`,
65
+ );
66
+ }
67
+ return {
68
+ uri: fileInfo.uri,
69
+ cdnUrl: fileInfo.main_url,
70
+ name,
71
+ format: ext,
72
+ };
73
+ }
74
+
75
+ /** 批量上传参考图(同名失败会带上下文抛出)。 */
76
+ export async function uploadRefImages(cfg, account, sources) {
77
+ const uploaded = [];
78
+ for (const source of sources || []) {
79
+ uploaded.push(await uploadRefImage(cfg, account, String(source)));
80
+ }
81
+ return uploaded;
82
+ }
@@ -0,0 +1,59 @@
1
+ // 去水印(扩图裁剪法,用户提供的思路):
2
+ // 1) 把已生成的带水印原图作为参考图走图生图,提示词追加"往下扩图四分之一"
3
+ // —— 模型重新演绎画面,原水印随之被清除,而新一轮水印落在底部新增区域
4
+ // 2) 下载扩图结果,把底部扩出来的部分裁掉(保留 1/(1+扩图比例),默认 80%)
5
+ // 3) 用裁剪结果覆盖原文件
6
+ // 代价:多花一次生成额度;画面边缘会被模型重新演绎。
7
+ import { readFile, writeFile, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import path from "node:path";
10
+ import { generate } from "./client.mjs";
11
+ import { downloadImage } from "./download.mjs";
12
+ import { cropPngBottom } from "./png.mjs";
13
+
14
+ /** 解析扩图比例配置("1/4" / 0.25 / "0.25"),默认 0.25(四分之一)。 */
15
+ export function parseExtendFrac(v) {
16
+ if (v === undefined || v === null || v === "") return 0.25;
17
+ const s = String(v).trim();
18
+ if (s.includes("/")) {
19
+ const [a, b] = s.split("/").map(Number);
20
+ if (Number.isFinite(a) && Number.isFinite(b) && a > 0 && b > 0) return a / b;
21
+ }
22
+ const n = Number(s);
23
+ if (Number.isFinite(n) && n > 0 && n < 4) return n;
24
+ return 0.25;
25
+ }
26
+
27
+ function describeFrac(frac) {
28
+ const map = { 0.125: "八分之一", 0.2: "五分之一", 0.25: "四分之一", 0.5: "二分之一" };
29
+ const key = Math.round(frac * 1e6) / 1e6;
30
+ return map[key] || `百分之${Math.round(frac * 100)}`;
31
+ }
32
+
33
+ /**
34
+ * 对一张已落盘的生成图执行扩图裁剪去水印,成功后覆盖 file。
35
+ * 返回 { width, height, origHeight, extendFrac };失败抛错(原文件不被破坏)。
36
+ */
37
+ export async function removeWatermarkByExtend(cfg, { file, prompt, ratio, account }) {
38
+ const extendFrac = parseExtendFrac(cfg.watermarkExtend);
39
+ const keepFrac = 1 / (1 + extendFrac);
40
+ // 扩图这步让模型自己决定输出几何(ratio=auto),几何由"往下扩图…"文本指令驱动
41
+ const ext = await generate(cfg, {
42
+ prompt: `${prompt},往下扩图${describeFrac(extendFrac)}`,
43
+ ratio: "auto",
44
+ refImages: [file],
45
+ account,
46
+ });
47
+ if (!ext.images?.length) throw new Error("扩图未返回图片");
48
+
49
+ let dl = null;
50
+ try {
51
+ dl = await downloadImage(ext.images[0].url, tmpdir(), `doubao-extend-${Date.now()}`);
52
+ const png = await readFile(dl.file);
53
+ const cropped = cropPngBottom(png, keepFrac); // 非 PNG 会在此抛错,原文件不受影响
54
+ await writeFile(file, cropped.buffer);
55
+ return { width: cropped.width, height: cropped.height, origHeight: cropped.origHeight, extendFrac };
56
+ } finally {
57
+ if (dl) await rm(dl.file, { force: true });
58
+ }
59
+ }