Files
2026-07-13 13:39:12 +08:00

666 lines
20 KiB
TypeScript

/**
* DoubaoWebExecutor — Dola Global web chat via dola.com.
*
* The provider id remains `doubao-web` for compatibility with existing saved
* provider connections, but the global consumer service now runs through Dola.
*
* Endpoint: POST https://www.dola.com/chat/completion
* Auth: Session cookies from www.dola.com
*/
import { randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
const BASE_URL = "https://www.dola.com";
const CHAT_URL = `${BASE_URL}/chat/completion`;
const DEFAULT_MODEL = "dola-speed";
const DOLA_BOT_ID = "7339470689562525703";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
type JsonRecord = Record<string, unknown>;
export interface DolaTextExtractionState {
deferUntilAnswer: boolean;
answerStarted: boolean;
bufferedDeltas: string[];
}
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function toContentText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJsonRecord(raw: string): JsonRecord | null {
if (!raw.startsWith("{")) return null;
try {
return asRecord(JSON.parse(raw));
} catch {
return null;
}
}
function randomNumericId(length = 19): string {
// crypto-backed, UNBIASED digits (CodeQL js/insecure-randomness +
// js/biased-cryptographic-random): a synthetic device/web id, not a secret.
// A raw `cryptoByte % 10` is biased (256 is not a multiple of 10), so each
// digit is drawn by rejection sampling — discard bytes in the biased tail so
// the remaining range divides evenly, then reduce.
const digit = (max: number): number => {
const limit = 256 - (256 % max); // largest multiple of `max` that fits in a byte
const buf = new Uint8Array(1);
let b: number;
do {
globalThis.crypto.getRandomValues(buf);
b = buf[0];
} while (b >= limit);
return b % max;
};
let id = String(digit(9) + 1);
for (let i = 1; i < length; i += 1) id += String(digit(10));
return id;
}
function contentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => {
const item = asRecord(part);
if (item.type === "text") return toContentText(item.text);
if (typeof item.text === "string") return item.text;
return "";
})
.filter(Boolean)
.join("\n");
}
return "";
}
function isDolaReasoningModel(modelId: string): boolean {
return modelId === "dola-pro" || modelId === "dola-deep-think";
}
function createDolaTextExtractionState(modelId: string): DolaTextExtractionState {
const deferUntilAnswer = isDolaReasoningModel(modelId);
return {
deferUntilAnswer,
answerStarted: !deferUntilAnswer,
bufferedDeltas: [],
};
}
function isDolaAnswerBoundary(block: JsonRecord): boolean {
return block.block_type === 10040 && block.is_finish === true;
}
export function foldMessages(messages: unknown): string {
if (!Array.isArray(messages)) return "";
return messages
.map((message) => {
const item = asRecord(message);
const role = toString(item.role) || "user";
const text = contentToText(item.content);
return text ? `${role}: ${text}` : "";
})
.filter(Boolean)
.join("\n\n");
}
export function extractCookieValue(cookieHeader: string, name: string): string {
const pattern = new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`);
const value = pattern.exec(cookieHeader)?.[1] ?? "";
try {
return decodeURIComponent(value).trim();
} catch {
return value.trim();
}
}
function extractQueryValue(raw: string, name: string): string {
if (!raw.includes("?") && !raw.includes("&")) return "";
try {
const url = raw.startsWith("http") ? new URL(raw) : new URL(`https://www.dola.com/?${raw}`);
return toString(url.searchParams.get(name));
} catch {
return "";
}
}
export function resolveDolaFingerprint(
cookieHeader: string,
providerSpecificData?: unknown,
rawCredential = ""
): string {
const data = asRecord(providerSpecificData);
return (
toString(data.s_v_web_id) ||
toString(data.sVWebId) ||
extractCookieValue(cookieHeader, "s_v_web_id") ||
toString(data.fp) ||
extractCookieValue(cookieHeader, "fp") ||
extractQueryValue(rawCredential, "fp")
);
}
export function buildDolaCookieHeader(
rawCredential: string,
providerSpecificData?: unknown
): string {
const providerData = asRecord(providerSpecificData);
const raw = normalizeCookie(rawCredential.trim());
const parsed = parseJsonRecord(raw);
const data = { ...providerData, ...(parsed ?? {}) };
const explicitCookie = normalizeCookie(toString(data.cookie));
const directCookie = raw && !parsed ? raw : "";
const cookieSource = explicitCookie || directCookie;
if (cookieSource.includes("=")) return cookieSource;
const cookieNames = [
"sessionid",
"ttwid",
"s_v_web_id",
"fp",
"sessionid_ss",
"sid_guard",
"sid_tt",
"uid_tt",
"uid_tt_ss",
"passport_auth_status",
"passport_auth_status_ss",
"odin_tt",
];
const parts = cookieNames
.map((name) => {
const value = toString(data[name]);
return value ? `${name}=${value}` : "";
})
.filter(Boolean);
if (parts.length > 0) return parts.join("; ");
return raw ? `sessionid=${raw}` : "";
}
export function buildDolaQueryParams(
cookieHeader: string,
providerSpecificData?: unknown,
rawCredential = ""
): URLSearchParams {
const data = asRecord(providerSpecificData);
const generatedId = randomNumericId();
const deviceId = toString(data.device_id) || toString(data.deviceId) || generatedId;
const fp = resolveDolaFingerprint(cookieHeader, providerSpecificData, rawCredential);
return new URLSearchParams({
aid: "495671",
real_aid: "495671",
device_platform: "web",
device_id: deviceId,
web_id: toString(data.web_id) || toString(data.webId) || deviceId,
tea_uuid: toString(data.tea_uuid) || toString(data.teaUuid) || deviceId,
web_tab_id: randomUUID(),
pc_version: toString(data.pc_version) || toString(data.pcVersion) || "3.25.3",
pkg_type: "release_version",
version_code: "20800",
samantha_web: "1",
web_platform: "browser",
"use-olympus-account": "1",
language: toString(data.language) || "en",
region: toString(data.region) || "US",
sys_region: toString(data.sys_region) || toString(data.sysRegion) || "US",
fp,
});
}
export function resolveDolaDeepThinkValue(modelId: string, providerSpecificData?: unknown): 0 | 3 {
const data = asRecord(providerSpecificData);
const configured = toString(data.use_deep_think) || toString(data.useDeepThink);
if (configured === "3") return 3;
if (configured === "0") return 0;
if (data.deepThink === true || modelId === "dola-pro" || modelId === "dola-deep-think") return 3;
return 0;
}
export function buildDolaPayload(
prompt: string,
modelId = DEFAULT_MODEL,
cookieHeader = "",
providerSpecificData?: unknown,
rawCredential = ""
): JsonRecord {
const data = asRecord(providerSpecificData);
const localConversationId =
toString(data.local_conversation_id) ||
toString(data.localConversationId) ||
`local_${randomNumericId(16)}`;
const blockId = randomUUID();
const messageId = randomUUID();
const uniqueKey = randomUUID();
const now = Date.now();
const deepThinkValue = resolveDolaDeepThinkValue(modelId, providerSpecificData);
const fp = resolveDolaFingerprint(cookieHeader, providerSpecificData, rawCredential);
return {
client_meta: {
local_conversation_id: localConversationId,
conversation_id: "",
bot_id: toString(data.bot_id) || toString(data.botId) || DOLA_BOT_ID,
last_section_id: "",
last_message_index: null,
},
messages: [
{
local_message_id: messageId,
content_block: [
{
block_type: 10000,
content: {
text_block: {
text: prompt,
icon_url: "",
icon_url_dark: "",
summary: "",
},
pc_event_block: "",
},
block_id: blockId,
parent_id: "",
meta_info: [],
append_fields: [],
},
],
message_status: 0,
},
],
option: {
send_message_scene: "",
create_time_ms: now,
collect_id: "",
is_audio: false,
answer_with_suggest: false,
tts_switch: false,
need_deep_think: deepThinkValue,
click_clear_context: false,
from_suggest: false,
is_regen: false,
is_replace: false,
is_from_click_option: false,
is_from_click_softlink: false,
disable_sse_cache: false,
select_text_action: "",
is_select_text: false,
resend_for_regen: false,
scene_type: 0,
unique_key: uniqueKey,
start_seq: 0,
need_create_conversation: true,
conversation_init_option: { need_ack_conversation: true },
regen_query_id: [],
edit_query_id: [],
regen_instruction: "",
no_replace_for_regen: false,
message_from: 0,
shared_app_name: "",
shared_app_id: "",
sse_recv_event_options: { support_chunk_delta: true },
is_ai_playground: false,
is_old_user: false,
recovery_option: {
is_recovery: false,
req_create_time_sec: Math.floor(now / 1000),
append_sse_event_scene: 0,
},
message_storage_type: 0,
},
user_context: [],
ext: {
use_deep_think: String(deepThinkValue),
fp,
sub_conv_firstmet_type: "1",
collection_id: "",
conversation_init_option: JSON.stringify({ need_ack_conversation: true }),
commerce_credit_config_enable: "0",
},
};
}
function parseSseBlock(block: string): { event: string; data: unknown } | null {
const lines = block.split(/\r?\n/);
const event = lines
.find((line) => line.startsWith("event:"))
?.slice(6)
.trim();
const dataLines = lines
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
if (dataLines.length === 0) return null;
const rawData = dataLines.join("\n");
if (rawData === "[DONE]") return { event: event || "done", data: "[DONE]" };
try {
return { event: event || "", data: JSON.parse(rawData) };
} catch {
return null;
}
}
function extractDolaBlockDeltas(blocks: unknown[], state?: DolaTextExtractionState): string[] {
const deltas: string[] = [];
for (const block of blocks) {
const blockRecord = asRecord(block);
if (state && isDolaAnswerBoundary(blockRecord)) {
state.answerStarted = true;
state.bufferedDeltas = [];
continue;
}
const text = toContentText(asRecord(asRecord(blockRecord.content).text_block).text);
if (!text) continue;
if (!state || state.answerStarted) {
deltas.push(text);
} else {
state.bufferedDeltas.push(text);
}
}
return deltas;
}
function flushDolaTextExtractionState(state: DolaTextExtractionState): string[] {
if (state.answerStarted) return [];
const fallback = state.bufferedDeltas;
state.bufferedDeltas = [];
state.answerStarted = true;
return fallback;
}
export function extractDolaTextDeltas(data: unknown, state?: DolaTextExtractionState): string[] {
const root = asRecord(data);
const payload = asRecord(root.data);
const content = asRecord(root.content);
const payloadContent = asRecord(payload.content);
const initialBlocks = Array.isArray(content.content_block)
? content.content_block
: Array.isArray(payloadContent.content_block)
? payloadContent.content_block
: [];
const patchOps = Array.isArray(root.patch_op)
? root.patch_op
: Array.isArray(payload.patch_op)
? payload.patch_op
: [];
const deltas = extractDolaBlockDeltas(initialBlocks, state);
for (const op of patchOps) {
const patchValue = asRecord(asRecord(op).patch_value);
const blocks = Array.isArray(patchValue.content_block) ? patchValue.content_block : [];
deltas.push(...extractDolaBlockDeltas(blocks, state));
}
return deltas;
}
function extractDolaError(data: unknown): string {
const root = asRecord(data);
const payload = asRecord(root.data);
return (
toString(root.message) ||
toString(payload.message) ||
toString(payload.error_msg) ||
toString(payload.errorMessage)
);
}
export function isDolaBusyMessage(content: string): boolean {
const normalized = content.trim().toLowerCase();
return (
normalized.includes("a lot of people are using the app right now") &&
normalized.includes("try again later")
);
}
function openAiChunk(modelId: string, content: string): JsonRecord {
return {
id: `chatcmpl-dola-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{ index: 0, delta: { content }, finish_reason: null }],
};
}
function openAiCompletion(modelId: string, content: string): JsonRecord {
return {
id: `chatcmpl-dola-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
};
}
export class DoubaoWebExecutor extends BaseExecutor {
constructor() {
super("doubao-web", { id: "doubao-web", baseUrl: BASE_URL });
}
private createHeaders(cookieHeader: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
Accept: "text/event-stream",
Referer: `${BASE_URL}/chat/`,
Origin: BASE_URL,
"Agw-Js-Conv": "str",
};
if (cookieHeader) headers.Cookie = cookieHeader;
return headers;
}
private async collectText(upstream: Response, modelId: string): Promise<string> {
const raw = await upstream.text();
const state = createDolaTextExtractionState(modelId);
const deltas: string[] = [];
for (const block of raw.split(/\r?\n\r?\n/)) {
const event = parseSseBlock(block);
if (event) deltas.push(...extractDolaTextDeltas(event.data, state));
}
deltas.push(...flushDolaTextExtractionState(state));
return deltas.join("");
}
private createStream(upstream: Response, modelId: string, signal?: AbortSignal | null) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const state = createDolaTextExtractionState(modelId);
let sentDone = false;
return new ReadableStream({
async start(controller) {
const reader = upstream.body?.getReader();
if (!reader) {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
return;
}
let buffer = "";
let errored = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() || "";
for (const block of blocks) {
const event = parseSseBlock(block);
if (!event) continue;
if (event.event === "STREAM_ERROR") {
const message = extractDolaError(event.data) || "Dola stream error";
errored = true;
controller.error(new Error(message));
return;
}
for (const text of extractDolaTextDeltas(event.data, state)) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(openAiChunk(modelId, text))}\n\n`)
);
}
if (event.event === "SSE_REPLY_END") {
sentDone = true;
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
}
}
}
} catch (err) {
if (!signal?.aborted) {
errored = true;
controller.error(err);
}
return;
} finally {
if (errored) return;
for (const text of flushDolaTextExtractionState(state)) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(openAiChunk(modelId, text))}\n\n`)
);
}
if (!sentDone) controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
}
},
});
}
async execute(input: ExecuteInput) {
const { body, credentials, signal, stream: wantStream } = input;
const bodyObj = asRecord(body);
const providerSpecificData = credentials?.providerSpecificData;
const rawCredential = toString(credentials?.apiKey);
const cookieHeader = buildDolaCookieHeader(rawCredential, providerSpecificData);
const requestedModel = toString(bodyObj.model) || input.model || DEFAULT_MODEL;
const modelId = requestedModel.split("/").pop() || DEFAULT_MODEL;
const prompt = foldMessages(bodyObj.messages);
const fingerprint = resolveDolaFingerprint(cookieHeader, providerSpecificData, rawCredential);
const transformedBody = buildDolaPayload(
prompt,
modelId,
cookieHeader,
providerSpecificData,
rawCredential
);
const query = buildDolaQueryParams(cookieHeader, providerSpecificData, rawCredential);
const url = `${CHAT_URL}?${query.toString()}`;
const reqHeaders = this.createHeaders(cookieHeader);
if (!extractCookieValue(cookieHeader, "sessionid")) {
return {
...makeErrorResult(
401,
"Dola Web requires a www.dola.com Cookie header containing at least sessionid, ttwid, and s_v_web_id.",
body,
url
),
headers: reqHeaders,
transformedBody,
};
}
if (!fingerprint) {
return {
...makeErrorResult(
401,
"Dola Web requires the browser fingerprint value from www.dola.com. Add s_v_web_id=... from Cookies or fp=verify_... from a Network chat/completion request URL.",
body,
url
),
headers: reqHeaders,
transformedBody,
};
}
let upstream: Response;
try {
upstream = await fetch(url, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(transformedBody),
signal,
});
} catch (err) {
return {
...makeErrorResult(
502,
`Dola fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
body,
url
),
headers: reqHeaders,
transformedBody,
};
}
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "");
return {
...makeErrorResult(upstream.status, `Dola error: ${errText}`, body, url),
headers: reqHeaders,
transformedBody,
};
}
const contentType = upstream.headers.get("Content-Type") || "";
if (!contentType.toLowerCase().includes("text/event-stream")) {
const text = await upstream.text().catch(() => "");
return {
...makeErrorResult(502, `Dola returned non-SSE response: ${text}`, body, url),
headers: reqHeaders,
transformedBody,
};
}
if (!wantStream) {
const content = await this.collectText(upstream, modelId);
if (isDolaBusyMessage(content)) {
return {
...makeErrorResult(429, "Dola is temporarily busy. Please try again later.", body, url),
headers: reqHeaders,
transformedBody,
};
}
return {
response: new Response(JSON.stringify(openAiCompletion(modelId, content)), {
headers: { "Content-Type": "application/json" },
}),
url,
headers: reqHeaders,
transformedBody,
};
}
return {
response: new Response(this.createStream(upstream, modelId, signal), {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url,
headers: reqHeaders,
transformedBody,
};
}
}