From bd4ee8feef76c6537e95f306aa69e4a61fdb7ee9 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 23 Apr 2026 15:26:08 +0800 Subject: [PATCH] refactor(desktop): rewrite Doubao adapter around page-based capture Replaces the stale API-client flow with a CDP-driven page session that bootstraps the chat UI, captures streaming responses, and extracts the answer from both the network log and the DOM. Adds mojibake repair for UTF-8-over-latin1 encoding drift returned by the site, picks the higher quality source/DOM answer, and tightens source/citation extraction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/adapters/doubao.ts | 2314 ++++++++++++----- 1 file changed, 1598 insertions(+), 716 deletions(-) diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts index 2408ffe..bb5b989 100644 --- a/apps/desktop-client/src/main/adapters/doubao.ts +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -1,18 +1,16 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; - import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; -import type { Session } from "electron/main"; -import type { Page as PlaywrightPage } from "playwright-core"; +import type { Page as PlaywrightPage, Response as PlaywrightResponse } from "playwright-core"; import { normalizeText } from "./common"; import type { MonitorAdapter } from "./base"; -const DOUBAO_APP_ID = "497858"; -const DOUBAO_BOT_ID = "7338286299411103781"; -const DOUBAO_HOME_URL = "https://www.doubao.com/"; -const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion"; -const DOUBAO_REFERER = "https://www.doubao.com/chat/"; +const DOUBAO_BOOTSTRAP_URL = "https://www.doubao.com/chat/"; +const DOUBAO_PAGE_READY_TIMEOUT_MS = 20_000; +const DOUBAO_QUERY_TIMEOUT_MS = 120_000; +const DOUBAO_CAPTURE_LIMIT = 64; +const DOUBAO_CAPTURE_BODY_LIMIT = 1_200_000; +const DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000; +const DOUBAO_STREAM_URL_FRAGMENT = "/chat/completion"; const SEARCH_RESULT_KEYS = new Set([ "references", @@ -44,24 +42,12 @@ const SEARCH_RESULT_KEYS = new Set([ "browseReferences", ]); -const CONTENT_CITATION_KEYS = new Set([ - "citations", - "citation_list", - "citationList", - "content_citations", - "contentCitations", - "answer_citations", - "answerCitations", +const ANSWER_EVENT_WHITELIST = new Set([ + "STREAM_CHUNK", + "STREAM_MSG_NOTIFY", + "FULL_MSG_NOTIFY", ]); -type DoubaoRuntimeState = { - fp: string; - ms_token: string; - device_id: string; - web_id: string; - tea_uuid: string; -}; - type DoubaoStreamEvent = { event: string; payload: unknown; @@ -73,27 +59,168 @@ type DoubaoStreamSummary = { provider_request_id: string | null; request_id: string | null; conversation_id: string | null; - citations: MonitoringSourceItem[]; search_results: MonitoringSourceItem[]; event_count: number; error_message: string | null; }; -const ANSWER_EVENT_WHITELIST = new Set([ - "STREAM_CHUNK", - "STREAM_MSG_NOTIFY", - "FULL_MSG_NOTIFY", -]); +type DoubaoCaptureRecord = { + url: string; + status: number; + contentType: string | null; + body: string; +}; + +type DoubaoDomLink = { + url: string; + title: string | null; + text: string | null; + siteName: string | null; +}; + +type DoubaoPageQuerySuccessResult = { + ok: true; + url: string; + title: string | null; + modelLabel: string | null; + deepThinkEnabled: boolean | null; + domAnswer: string | null; + domReasoning: string | null; + domLinks: DoubaoDomLink[]; +}; + +type DoubaoPageQueryFailureResult = { + ok: false; + error: string; + detail: string | null; + url: string | null; + title: string | null; + modelLabel: string | null; + deepThinkEnabled: boolean | null; + domAnswer: string | null; + domReasoning: string | null; + domLinks: DoubaoDomLink[]; +}; + +type DoubaoPageQueryResult = DoubaoPageQuerySuccessResult | DoubaoPageQueryFailureResult; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +const DOUBAO_WINDOWS_1252_REVERSE_MAP = new Map([ + [0x20AC, 0x80], + [0x201A, 0x82], + [0x0192, 0x83], + [0x201E, 0x84], + [0x2026, 0x85], + [0x2020, 0x86], + [0x2021, 0x87], + [0x02C6, 0x88], + [0x2030, 0x89], + [0x0160, 0x8A], + [0x2039, 0x8B], + [0x0152, 0x8C], + [0x017D, 0x8E], + [0x2018, 0x91], + [0x2019, 0x92], + [0x201C, 0x93], + [0x201D, 0x94], + [0x2022, 0x95], + [0x2013, 0x96], + [0x2014, 0x97], + [0x02DC, 0x98], + [0x2122, 0x99], + [0x0161, 0x9A], + [0x203A, 0x9B], + [0x0153, 0x9C], + [0x017E, 0x9E], + [0x0178, 0x9F], +]); + +function countDoubaoHanCharacters(value: string): number { + return value.match(/[\u3400-\u9fff]/g)?.length ?? 0; +} + +function countDoubaoSuspiciousCharacters(value: string): number { + return value.match(/[\u00c0-\u017f\u2000-\u20ff]/g)?.length ?? 0; +} + +function looksLikeDoubaoMojibake(value: string): boolean { + if (!value) { + return false; + } + if (value.includes("Ã") || value.includes("â€") || value.includes("ï¼") || value.includes("ã€")) { + return true; + } + + const hanCount = countDoubaoHanCharacters(value); + const suspiciousCount = countDoubaoSuspiciousCharacters(value); + if (hanCount > 0) { + return suspiciousCount >= 8 && suspiciousCount > hanCount * 3; + } + return suspiciousCount >= 4; +} + +function decodeDoubaoLatin1AsUtf8(value: string): string | null { + const bytes: number[] = []; + for (const char of value) { + const codePoint = char.codePointAt(0); + if (codePoint == null) { + return null; + } + const mapped = DOUBAO_WINDOWS_1252_REVERSE_MAP.get(codePoint); + if (mapped != null) { + bytes.push(mapped); + continue; + } + if (codePoint > 0xff) { + return null; + } + bytes.push(codePoint); + } + + try { + return new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes)); + } catch { + return null; + } +} + +function doubaoRepairLooksBetter(original: string, repaired: string): boolean { + if (!repaired.trim()) { + return false; + } + + const originalHan = countDoubaoHanCharacters(original); + const repairedHan = countDoubaoHanCharacters(repaired); + const originalSuspicious = countDoubaoSuspiciousCharacters(original); + const repairedSuspicious = countDoubaoSuspiciousCharacters(repaired); + + if (repairedHan > originalHan && repairedHan >= 2) { + return true; + } + return repairedHan > 0 && repairedSuspicious * 2 < originalSuspicious; +} + +function repairDoubaoMojibake(value: string): string { + const trimmed = value.trim(); + if (!trimmed || !looksLikeDoubaoMojibake(trimmed)) { + return value; + } + + const repaired = decodeDoubaoLatin1AsUtf8(trimmed); + if (!repaired || !doubaoRepairLooksBetter(trimmed, repaired)) { + return value; + } + return repaired; +} + function normalizeOptionalString(value: unknown): string | null { if (typeof value !== "string") { return null; } - const trimmed = value.trim(); + const trimmed = repairDoubaoMojibake(value).trim(); return trimmed ? trimmed : null; } @@ -176,35 +303,112 @@ function mergeAnswerText(current: string | null, nextFragment: string | null): s return `${current}${nextFragment}`; } -function resolveSourceBucket(key: string): "search" | "citation" | null { - const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); +function countDoubaoMatches(input: string, pattern: RegExp): number { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return input.match(new RegExp(pattern.source, flags))?.length ?? 0; +} + +function doubaoHasStructuredAnswerMarkers(text: string): boolean { + return ( + /(^|\n)\s*(?:[-*+]\s+|\d+[.、]\s+|#{1,6}\s+)/m.test(text) + || /(?:^|\n)\|.+\|(?:\n|$)/m.test(text) + || /(?:^|\n)\s*[^:\n]{1,24}[::]\s+\S+/m.test(text) + ); +} + +function doubaoTextQualityScore(value: string | null): number { + const normalized = normalizeOptionalString(value); if (!normalized) { - return null; + return Number.NEGATIVE_INFINITY; } - if (CONTENT_CITATION_KEYS.has(key) || normalized.includes("citation")) { - return "citation"; + + const hanCount = countDoubaoHanCharacters(normalized); + const suspiciousCount = countDoubaoSuspiciousCharacters(normalized); + const punctuationCount = countDoubaoMatches(normalized, /[。!?;:,、,.!?;:]/g); + const lineCount = normalized.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean).length; + let score = Math.min(normalized.length, 4_000) + hanCount * 6 - suspiciousCount * 8 + punctuationCount * 24; + score += Math.max(0, lineCount - 1) * 120; + if (doubaoHasStructuredAnswerMarkers(normalized)) { + score += 320; } - if (SEARCH_RESULT_KEYS.has(key) || normalized.includes("reference") || normalized.includes("source") || normalized.includes("search")) { - return "search"; + if (/[。!?.!?]$/.test(normalized)) { + score += 80; } - return null; + if (lineCount <= 1 && normalized.length < 24 && punctuationCount === 0) { + score -= 160; + } + return score; +} + +function selectBestDoubaoText(primary: string | null, secondary: string | null): string | null { + const primaryText = normalizeOptionalString(primary); + const secondaryText = normalizeOptionalString(secondary); + if (!primaryText) { + return secondaryText; + } + if (!secondaryText) { + return primaryText; + } + + const primaryScore = doubaoTextQualityScore(primaryText); + const secondaryScore = doubaoTextQualityScore(secondaryText); + if (secondaryText.includes(primaryText) && secondaryScore >= primaryScore - 64) { + return secondaryText; + } + if (primaryText.includes(secondaryText) && primaryScore >= secondaryScore - 64) { + return primaryText; + } + return secondaryScore > primaryScore ? secondaryText : primaryText; +} + +function looksLikeSourceUrl(value: string | null): boolean { + if (!value) { + return false; + } + + const normalized = value.trim(); + if (!normalized) { + return false; + } + + if (/^https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) { + return true; + } + + return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(normalized); } function normalizeSourceUrl(value: string | null): string | null { const input = normalizeText(value); - if (!input) { + if (!input || !looksLikeSourceUrl(input)) { return null; } try { - const url = new URL(input); + const url = new URL(/^(https?:)?\/\//i.test(input) ? input : `https://${input}`); url.hash = ""; return url.toString(); } catch { - return input; + return null; } } +function resolveSourceBucket(key: string): "search" | null { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + if (!normalized) { + return null; + } + if ( + SEARCH_RESULT_KEYS.has(key) + || normalized.includes("reference") + || normalized.includes("source") + || normalized.includes("search") + ) { + return "search"; + } + return null; +} + function buildSourceItem(input: unknown): MonitoringSourceItem | null { if (typeof input === "string") { const url = normalizeSourceUrl(input); @@ -285,39 +489,30 @@ function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[ return Array.from(keyed.values()); } -function collectSources( +function collectSearchSources( payload: unknown, - searchResults: MonitoringSourceItem[], - citations: MonitoringSourceItem[], - bucket: "search" | "citation" | null = null, + results: MonitoringSourceItem[], + bucket: "search" | null = null, depth = 0, ): void { if (depth > 12 || payload == null) { return; } - const push = (item: MonitoringSourceItem, targetBucket: "search" | "citation" | null) => { - if (targetBucket === "citation") { - citations.push(item); - return; - } - searchResults.push(item); - }; - if (typeof payload === "string") { if (!bucket) { return; } const item = buildSourceItem(payload); if (item) { - push(item, bucket); + results.push(item); } return; } if (Array.isArray(payload)) { for (const item of payload) { - collectSources(item, searchResults, citations, bucket, depth + 1); + collectSearchSources(item, results, bucket, depth + 1); } return; } @@ -328,13 +523,13 @@ function collectSources( const directSourceItem = buildSourceItem(payload); if (directSourceItem && bucket) { - push(directSourceItem, bucket); + results.push(directSourceItem); } for (const [key, value] of Object.entries(payload)) { const nextBucket = resolveSourceBucket(key) ?? bucket; if (Array.isArray(value) || isRecord(value) || typeof value === "string") { - collectSources(value, searchResults, citations, nextBucket, depth + 1); + collectSearchSources(value, results, nextBucket, depth + 1); } } } @@ -372,7 +567,6 @@ function collectAnswerFragments( let nextContext = context; if (typeof payload.block_type === "number" && payload.block_type !== 10000) { - // Non-answer blocks (search queries, status cards, etc.) — skip entirely. return result; } if (isReasoningIcon((payload as { icon_url?: unknown }).icon_url)) { @@ -464,38 +658,131 @@ function formatDoubaoStreamError(payload: unknown): string { return "doubao stream error"; } -async function parseDoubaoStream(response: Response): Promise { +function isDoubaoRetryableErrorMessage(message: string | null | undefined): boolean { + const normalized = normalizeText(message)?.toLowerCase(); + if (!normalized) { + return false; + } + + return ( + normalized.includes("rate limited") + || normalized.includes("rate limit") + || normalized.includes("too many requests") + || normalized.includes("request limit") + || normalized.includes("frequency limit") + || normalized.includes("请求过于频繁") + || normalized.includes("频率过快") + || normalized.includes("访问过于频繁") + || normalized.includes("限流") + || normalized.includes("稍后再试") + || normalized.includes("服务繁忙") + || normalized.includes("请稍后") + ); +} + +function buildDoubaoUnknownResult( + code: string, + summary: string, + message: string, + extras: Record = {}, +): { + status: "unknown"; + summary: string; + error: Record; +} { + return { + status: "unknown", + summary, + error: buildAdapterError(code, message, extras), + }; +} + +function buildDoubaoRetryableUnknownResult( + message: string, + extras: Record = {}, +): { + status: "unknown"; + summary: string; + error: Record; +} { + return buildDoubaoUnknownResult( + "doubao_retryable_error", + "豆包触发限流或临时错误,已回写 unknown 等待后续补采。", + message, + extras, + ); +} + +function parseDoubaoCaptureError(capture: DoubaoCaptureRecord): string | null { + if (!capture.body) { + return capture.status >= 400 ? `doubao request failed with status ${capture.status}` : null; + } + + const parsed = safeParseJSON(capture.body); + const structured = formatDoubaoStreamError(parsed); + if (structured !== "doubao stream error") { + return structured; + } + + const rawMessage = findFirstStringByKey(parsed, [ + "error_msg", + "message", + "detail", + "errmsg", + "err_msg", + "error_message", + ]); + if (rawMessage) { + return `doubao stream error: ${rawMessage}`; + } + + const bodyText = normalizeText(capture.body); + if (bodyText) { + if (isDoubaoRetryableErrorMessage(bodyText)) { + return `doubao stream error: ${bodyText}`; + } + if (capture.status >= 400) { + return `doubao request failed ${capture.status}: ${bodyText}`; + } + } + + return capture.status >= 400 ? `doubao request failed with status ${capture.status}` : null; +} + +function parseDoubaoStreamBody(body: string): DoubaoStreamSummary { const summary: DoubaoStreamSummary = { answer: null, reasoning: null, provider_request_id: null, request_id: null, conversation_id: null, - citations: [], search_results: [], event_count: 0, error_message: null, }; - const body = response.body; if (!body) { - throw new Error("doubao response stream missing"); + return summary; } - const decoder = new TextDecoder(); - const reader = body.getReader(); - let buffer = ""; + const normalized = body.replace(/\r\n/g, "\n"); + const records = normalized.split(/\n\n+/); - const consumeRecord = (rawRecord: string) => { - const parsed = parseSSERecord(rawRecord); + for (const rawRecord of records) { + const trimmed = rawRecord.trim(); + if (!trimmed) { + continue; + } + + const parsed = parseSSERecord(trimmed); if (!parsed) { - return; + continue; } summary.event_count += 1; if (parsed.event.toUpperCase().includes("ERROR")) { - summary.error_message = formatDoubaoStreamError(parsed.payload); - return; + summary.error_message = summary.error_message ?? formatDoubaoStreamError(parsed.payload); + continue; } if (!summary.conversation_id) { @@ -522,607 +809,53 @@ async function parseDoubaoStream(response: Response): Promise { - return { - client_meta: { - local_conversation_id: `local_${Date.now()}${Math.floor(Math.random() * 1_000_000)}`, - conversation_id: "", - bot_id: DOUBAO_BOT_ID, - last_section_id: "", - last_message_index: null, - }, - messages: [ - { - local_message_id: crypto.randomUUID(), - content_block: [ - { - block_type: 10000, - content: { - text_block: { - text: questionText, - icon_url: "", - icon_url_dark: "", - summary: "", - }, - pc_event_block: "", - }, - block_id: crypto.randomUUID(), - parent_id: "", - meta_info: [], - append_fields: [], - }, - ], - message_status: 0, - }, - ], - option: { - need_deep_think: 1, - need_create_conversation: true, - conversation_init_option: { - need_ack_conversation: true, - }, - sse_recv_event_options: { - support_chunk_delta: true, - }, - }, - ext: { - use_deep_think: "1", - fp: runtimeState.fp, - conversation_init_option: JSON.stringify({ - need_ack_conversation: true, - }), - commerce_credit_config_enable: "0", - sub_conv_firstmet_type: "1", - }, +function mergeStreamSummaries(summaries: DoubaoStreamSummary[]): DoubaoStreamSummary { + const combined: DoubaoStreamSummary = { + answer: null, + reasoning: null, + provider_request_id: null, + request_id: null, + conversation_id: null, + search_results: [], + event_count: 0, + error_message: null, }; -} -const DOUBAO_RUNTIME_STATE_FIELDS: Array = [ - "fp", - "ms_token", - "device_id", - "web_id", - "tea_uuid", -]; -const DOUBAO_RUNTIME_STATE_TIMEOUT_MS = 15_000; -const DOUBAO_RUNTIME_STATE_POLL_INTERVAL_MS = 500; -const DOUBAO_RUNTIME_STATE_DISK_SCAN_FILE_LIMIT = 8; -const DOUBAO_RUNTIME_STATE_DISK_SCAN_MAX_BYTES = 512_000; -const DOUBAO_XMST_PATTERN = /xmst[\x00-\x20\x7f-\xff]{0,8}([A-Za-z0-9_-]{20,}={0,2})/g; -const DOUBAO_TEA_TOKENS_PATTERN = /__tea_cache_tokens_497858(?:u)?[\x00-\x20\x7f-\xff]{0,8}(\{[^{}]{0,500}\})/g; -const DOUBAO_SAMANTHA_WEB_ID_PATTERN = /samantha_web_web_id[\x00-\x20\x7f-\xff]{0,8}(\{[^{}]{0,500}\})/g; -const DOUBAO_SLARDAR_FLOW_PATTERN = /SLARDARflow_web[\x00-\x20\x7f-\xff]{0,16}(JTdC[A-Za-z0-9+/=_-]{40,}JTdE)/g; - -function parseRuntimeState(value: unknown): { - state: DoubaoRuntimeState | null; - missing: Array; - readError: string | null; -} { - if (!isRecord(value)) { - return { state: null, missing: [...DOUBAO_RUNTIME_STATE_FIELDS], readError: null }; - } - - const readError = normalizeOptionalString(value.error); - const fp = normalizeOptionalString(value.fp); - const msToken = normalizeOptionalString(value.ms_token); - const deviceID = normalizeOptionalString(value.device_id); - const webID = normalizeOptionalString(value.web_id); - const teaUUID = normalizeOptionalString(value.tea_uuid); - - const missing: Array = []; - if (!fp) missing.push("fp"); - if (!msToken) missing.push("ms_token"); - if (!deviceID) missing.push("device_id"); - if (!webID) missing.push("web_id"); - if (!teaUUID) missing.push("tea_uuid"); - - if (missing.length > 0 || !fp || !msToken || !deviceID || !webID || !teaUUID) { - return { state: null, missing, readError }; - } - - return { - state: { - fp, - ms_token: msToken, - device_id: deviceID, - web_id: webID, - tea_uuid: teaUUID, - }, - missing, - readError, - }; -} - -async function sleep(ms: number, signal?: AbortSignal): Promise { - await new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("adapter_aborted")); - return; + for (const summary of summaries) { + if (summary.answer) { + combined.answer = mergeAnswerText(combined.answer, summary.answer); } - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(new Error("adapter_aborted")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -async function ensurePageOnDoubaoChat( - page: PlaywrightPage, - signal: AbortSignal, -): Promise { - if (signal.aborted) { - throw new Error("adapter_aborted"); - } - const currentURL = safePlaywrightPageURL(page); - if (!currentURL.startsWith("https://www.doubao.com/")) { - await page.goto(DOUBAO_HOME_URL, { waitUntil: "domcontentloaded" }); - if (signal.aborted) { - throw new Error("adapter_aborted"); + if (summary.reasoning) { + combined.reasoning = mergeAnswerText(combined.reasoning, summary.reasoning); } - } - if (!safePlaywrightPageURL(page).startsWith("https://www.doubao.com/chat")) { - await page.goto(DOUBAO_REFERER, { waitUntil: "domcontentloaded" }); - } - await page.waitForLoadState("domcontentloaded"); - await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); - if (signal.aborted) { - throw new Error("adapter_aborted"); - } -} - -function safePlaywrightPageURL(page: PlaywrightPage): string { - try { - return page.url(); - } catch { - return ""; - } -} - -const DOUBAO_READ_RUNTIME_STATE_SCRIPT = () => { - try { - const safeParse = (value) => { - if (!value) return null; - try { - return JSON.parse(value); - } catch { - return value; - } - }; - const readStorage = (name) => { - try { - return window.localStorage.getItem(name) || window.sessionStorage.getItem(name); - } catch { - return null; - } - }; - const decodeFlowState = (value) => { - if (typeof value !== "string" || !value.trim()) { - return null; - } - let current = value.trim(); - try { - current = window.atob(current); - } catch { - // Keep original text when it is not base64-encoded. - } - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - const next = decodeURIComponent(current); - if (next === current) { - break; - } - current = next; - } catch { - break; - } - } - return safeParse(current); - }; - const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id")); - const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858")); - const teaSDKState = safeParse(window.localStorage.getItem("__tea_sdk_ab_version_497858")); - const flowState = decodeFlowState(readStorage("SLARDARflow_web")); - const pick = (source, paths) => { - for (const path of paths) { - let current = source; - let ok = true; - for (const key of path) { - if (!current || typeof current !== "object" || !(key in current)) { - ok = false; - break; - } - current = current[key]; - } - if (ok && typeof current === "string" && current.trim()) { - return current.trim(); - } - } - return null; - }; - const readCookie = (name) => { - const segments = document.cookie.split(";"); - for (const segment of segments) { - const [rawName, ...rest] = segment.trim().split("="); - if (rawName === name) { - const joined = rest.join("=").trim(); - return joined || null; - } - } - return null; - }; - return { - fp: readCookie("s_v_web_id"), - ms_token: readStorage("xmst") || readStorage("msToken"), - device_id: - pick(flowState, [["deviceId"], ["device_id"], ["id"]]) || - pick(samanthaState, [["device_id"], ["deviceId"], ["web_id"], ["webId"], ["id"]]), - web_id: - pick(teaState, [["web_id"], ["webId"], ["id"]]) || - pick(samanthaState, [["web_id"], ["webId"], ["device_id"], ["deviceId"], ["id"]]), - tea_uuid: - pick(teaState, [["user_unique_id"], ["userUniqueId"], ["tea_uuid"], ["teaUuid"], ["uuid"], ["web_id"]]) || - pick(teaSDKState, [["uuid"], ["user_unique_id"], ["userUniqueId"]]), - }; - } catch (error) { - return { - error: error instanceof Error ? error.message : "runtime_state_read_failed", - }; - } -}; - -function lastPatternMatch(text: string, pattern: RegExp): string | null { - pattern.lastIndex = 0; - let last: string | null = null; - let matched: RegExpExecArray | null = null; - while ((matched = pattern.exec(text)) !== null) { - const candidate = normalizeOptionalString(matched[1]); - if (candidate) { - last = candidate; - } - } - pattern.lastIndex = 0; - return last; -} - -function readFileTailAsLatin1(filePath: string, maxBytes = DOUBAO_RUNTIME_STATE_DISK_SCAN_MAX_BYTES): string { - const buffer = readFileSync(filePath); - if (buffer.length <= maxBytes) { - return buffer.toString("latin1"); - } - return buffer.subarray(buffer.length - maxBytes).toString("latin1"); -} - -function parseDoubaoRecord(value: string | null): Record | null { - if (!value) { - return null; - } - const parsed = safeParseJSON(value); - return isRecord(parsed) ? parsed : null; -} - -function decodeDoubaoFlowState(value: string | null): Record | null { - const raw = normalizeOptionalString(value); - if (!raw) { - return null; - } - try { - let decoded = Buffer.from(raw, "base64").toString("utf8"); - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - const next = decodeURIComponent(decoded); - if (next === decoded) { - break; - } - decoded = next; - } catch { - break; - } - } - const parsed = safeParseJSON(decoded); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -} - -async function flushDoubaoSessionPersistence(session: Session): Promise { - const maybeFlush = (session as Session & { - flushStorageData?: () => Promise | void; - }).flushStorageData; - if (typeof maybeFlush === "function") { - try { - await Promise.resolve(maybeFlush.call(session)); - } catch { - // Ignore persistence flush errors and continue with the best available state. - } - } - try { - await Promise.resolve(session.cookies.flushStore()); - } catch { - // Ignore cookie flush errors and continue with the best available state. - } -} - -async function readDoubaoCookieValue( - session: Session, - name: string, -): Promise { - let cookies: Awaited> = []; - try { - cookies = await Promise.resolve(session.cookies.get({ url: DOUBAO_HOME_URL })); - } catch { - cookies = []; - } - for (const cookie of cookies) { - if (cookie.name !== name) { - continue; - } - const value = normalizeOptionalString(cookie.value); - if (value) { - return value; - } - } - return null; -} - -function readDoubaoPersistedStorageState(storagePath: string | null): Partial { - if (!storagePath) { - return {}; + combined.provider_request_id = combined.provider_request_id ?? summary.provider_request_id; + combined.request_id = combined.request_id ?? summary.request_id; + combined.conversation_id = combined.conversation_id ?? summary.conversation_id; + combined.search_results.push(...summary.search_results); + combined.event_count += summary.event_count; + combined.error_message = combined.error_message ?? summary.error_message; } - const leveldbPath = join(storagePath, "Local Storage", "leveldb"); - let candidates: string[] = []; - try { - candidates = readdirSync(leveldbPath) - .map((entry) => join(leveldbPath, entry)) - .filter((entry) => /\.(?:log|ldb)$/i.test(entry)) - .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs) - .slice(0, DOUBAO_RUNTIME_STATE_DISK_SCAN_FILE_LIMIT); - } catch { - return {}; - } - - let xmst: string | null = null; - let teaState: Record | null = null; - let samanthaState: Record | null = null; - let flowState: Record | null = null; - - for (const filePath of candidates) { - let text = ""; - try { - text = readFileTailAsLatin1(filePath); - } catch { - continue; - } - if (!text) { - continue; - } - xmst ||= lastPatternMatch(text, DOUBAO_XMST_PATTERN); - teaState ||= parseDoubaoRecord(lastPatternMatch(text, DOUBAO_TEA_TOKENS_PATTERN)); - samanthaState ||= parseDoubaoRecord(lastPatternMatch(text, DOUBAO_SAMANTHA_WEB_ID_PATTERN)); - flowState ||= decodeDoubaoFlowState(lastPatternMatch(text, DOUBAO_SLARDAR_FLOW_PATTERN)); - if (xmst && teaState && samanthaState && flowState) { - break; - } - } - - const deviceID = - getDirectString(flowState ?? {}, ["deviceId", "device_id", "id"]) ?? - getDirectString(samanthaState ?? {}, ["device_id", "deviceId", "web_id", "webId", "id"]); - const webID = - getDirectString(teaState ?? {}, ["web_id", "webId", "id"]) ?? - getDirectString(samanthaState ?? {}, ["web_id", "webId", "device_id", "deviceId", "id"]); - const teaUUID = - getDirectString(teaState ?? {}, ["user_unique_id", "userUniqueId", "tea_uuid", "teaUuid", "uuid", "web_id", "webId"]) ?? - webID; - - return { - ms_token: xmst ?? undefined, - device_id: deviceID ?? undefined, - web_id: webID ?? undefined, - tea_uuid: teaUUID ?? undefined, - }; -} - -function mergeDoubaoRuntimeStateSources(...sources: Array | null | undefined>): Record { - const merged: Record = {}; - for (const field of DOUBAO_RUNTIME_STATE_FIELDS) { - for (const source of sources) { - const value = normalizeOptionalString(source?.[field]); - if (value) { - merged[field] = value; - break; - } - } - } - for (const source of sources) { - const error = normalizeOptionalString(source?.error); - if (error) { - merged.error = error; - break; - } - } - return merged; -} - -async function readDoubaoRuntimeStateFallback(session: Session): Promise> { - const [fp, persisted] = await Promise.all([ - readDoubaoCookieValue(session, "s_v_web_id"), - Promise.resolve(readDoubaoPersistedStorageState(session.storagePath)), - ]); - return { - ...(fp ? { fp } : {}), - ...persisted, - }; -} - -async function loadDoubaoRuntimeState(context: Parameters[0]): Promise { - if (!context.playwright) { - throw new Error("doubao_playwright_required"); - } - const page = context.playwright.page; - - context.reportProgress("doubao.bootstrap_view"); - await ensurePageOnDoubaoChat(page, context.signal); - await flushDoubaoSessionPersistence(context.session); - - context.reportProgress("doubao.read_runtime_state"); - - const deadline = Date.now() + DOUBAO_RUNTIME_STATE_TIMEOUT_MS; - let lastMissing: Array = [...DOUBAO_RUNTIME_STATE_FIELDS]; - let lastReadError: string | null = null; - const persistedFallback = await readDoubaoRuntimeStateFallback(context.session).catch(() => ({})); - - while (true) { - if (context.signal?.aborted) { - throw new Error("adapter_aborted"); - } - - const raw = await page.evaluate(DOUBAO_READ_RUNTIME_STATE_SCRIPT); - const merged = mergeDoubaoRuntimeStateSources(raw, persistedFallback); - const { state, missing, readError } = parseRuntimeState(merged); - if (state) { - return state; - } - lastMissing = missing; - lastReadError = readError; - - if (Date.now() >= deadline) { - break; - } - await sleep(DOUBAO_RUNTIME_STATE_POLL_INTERVAL_MS, context.signal); - } - - const detail = lastReadError - ? `read_error=${lastReadError}` - : `missing=${lastMissing.join(",") || "unknown"}`; - throw new Error(`doubao_runtime_state_missing:${detail}`); -} - -const DOUBAO_COMPLETION_FETCH_SCRIPT = async ( - { url, body, referer }: { url: string; body: Record; referer: string }, -) => { - try { - const res = await fetch(url, { - method: "POST", - credentials: "include", - referrer: referer, - headers: { - Accept: "text/event-stream", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - const text = await res.text(); - return { ok: res.ok, status: res.status, body: text }; - } catch (error) { - return { - ok: false, - status: 0, - body: "", - error: error instanceof Error ? error.message : "doubao_completion_fetch_failed", - }; - } -}; - -type DoubaoCompletionResult = { - ok: boolean; - status: number; - body: string; - error?: string; -}; - -async function fetchDoubaoCompletion( - page: PlaywrightPage, - url: string, - body: Record, -): Promise { - let result: unknown; - try { - result = await page.evaluate(DOUBAO_COMPLETION_FETCH_SCRIPT, { - url, - body, - referer: DOUBAO_REFERER, - }); - } catch (error) { - return { - ok: false, - status: 0, - body: "", - error: error instanceof Error ? error.message : "doubao_completion_fetch_eval_failed", - }; - } - - if (!isRecord(result)) { - return { ok: false, status: 0, body: "", error: "doubao_completion_fetch_malformed" }; - } - return { - ok: Boolean(result.ok), - status: typeof result.status === "number" ? result.status : 0, - body: typeof result.body === "string" ? result.body : "", - error: normalizeOptionalString(result.error) ?? undefined, - }; + combined.search_results = dedupeSourceItems(combined.search_results); + return combined; } function extractQuestionText(payload: Record): string { const candidates = [ payload.question_text, + payload.questionText, payload.query, payload.question, payload.prompt, payload.content, + payload.title, ]; for (const candidate of candidates) { @@ -1143,6 +876,11 @@ function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { site_key: item.site_key ?? null, normalized_url: item.normalized_url ?? null, host: item.host ?? null, + registrable_domain: item.registrable_domain ?? null, + subdomain: item.subdomain ?? null, + suffix: item.suffix ?? null, + article_id: item.article_id ?? null, + publish_record_id: item.publish_record_id ?? null, resolution_status: item.resolution_status ?? null, resolution_confidence: item.resolution_confidence ?? null, })); @@ -1160,81 +898,1205 @@ function buildAdapterError( }; } +function extractConversationID(url: string | null): string | null { + const input = normalizeText(url); + if (!input) { + return null; + } + + try { + const parsed = new URL(input); + const matched = parsed.pathname.match(/\/chat\/([^/?#]+)/i); + return matched?.[1]?.trim() || null; + } catch { + return null; + } +} + +function safePageURL(page: PlaywrightPage): string { + try { + return page.url(); + } catch { + return ""; + } +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + await new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("adapter_aborted")); + return; + } + + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timer); + reject(new Error("adapter_aborted")); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const currentURL = safePageURL(page); + if (!currentURL.startsWith("https://www.doubao.com/")) { + await page.goto(DOUBAO_BOOTSTRAP_URL, { + waitUntil: "domcontentloaded", + timeout: DOUBAO_PAGE_READY_TIMEOUT_MS, + }); + } else if (!currentURL.startsWith("https://www.doubao.com/chat")) { + await page.goto(DOUBAO_BOOTSTRAP_URL, { + waitUntil: "domcontentloaded", + timeout: DOUBAO_PAGE_READY_TIMEOUT_MS, + }); + } + + await page.waitForLoadState("domcontentloaded", { + timeout: DOUBAO_PAGE_READY_TIMEOUT_MS, + }).catch(() => undefined); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); + + if (signal.aborted) { + throw new Error("adapter_aborted"); + } +} + +function attachDoubaoResponseCapture(page: PlaywrightPage): { + captures: DoubaoCaptureRecord[]; + flush: (timeoutMs: number) => Promise; + pendingCount: () => number; + dispose: () => void; +} { + const captures: DoubaoCaptureRecord[] = []; + const pending = new Set>(); + + const pushCapture = (record: DoubaoCaptureRecord) => { + captures.push({ + ...record, + body: record.body.length > DOUBAO_CAPTURE_BODY_LIMIT + ? record.body.slice(0, DOUBAO_CAPTURE_BODY_LIMIT) + : record.body, + }); + if (captures.length > DOUBAO_CAPTURE_LIMIT) { + captures.splice(0, captures.length - DOUBAO_CAPTURE_LIMIT); + } + }; + + const responseHandler = (response: PlaywrightResponse) => { + const url = normalizeOptionalString(response.url()); + if (!url || !url.startsWith("https://www.doubao.com/")) { + return; + } + + const headers = response.headers(); + const contentType = normalizeOptionalString(headers["content-type"]) ?? null; + const isStreamEndpoint = url.includes(DOUBAO_STREAM_URL_FRAGMENT); + const looksStreamy = contentType !== null + && /(event-stream|text\/plain|application\/(?:json|x-ndjson))/i.test(contentType); + const isApiPath = /\/(?:chat|api|samantha|assistant|aweme|bot)\//i.test(url); + if (!isStreamEndpoint && !(looksStreamy && isApiPath) && response.status() < 400) { + return; + } + + const task = response.body() + .then((body: Buffer) => { + const decoded = body.toString("utf8"); + pushCapture({ + url, + status: response.status(), + contentType, + body: decoded, + }); + }) + .catch(() => undefined); + pending.add(task); + void task.finally(() => { + pending.delete(task); + }); + }; + + page.on("response", responseHandler); + + return { + captures, + async flush(timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + while (pending.size > 0 && Date.now() < deadline) { + const snapshot = Array.from(pending); + const remaining = Math.max(0, deadline - Date.now()); + await Promise.race([ + Promise.allSettled(snapshot), + new Promise((resolve) => setTimeout(resolve, remaining)), + ]); + } + }, + pendingCount() { + return pending.size; + }, + dispose() { + page.off("response", responseHandler); + }, + }; +} + +function parseDoubaoCaptures(captures: DoubaoCaptureRecord[]): DoubaoStreamSummary { + const summaries = captures + .map((capture) => { + const streamLike = Boolean( + capture.body + && ( + capture.url.includes(DOUBAO_STREAM_URL_FRAGMENT) + || capture.body.includes("STREAM_CHUNK") + || capture.body.includes("STREAM_MSG_NOTIFY") + || capture.body.includes("FULL_MSG_NOTIFY") + || /"block_type"\s*:\s*10000/.test(capture.body) + ), + ); + + if (streamLike) { + return parseDoubaoStreamBody(capture.body); + } + + const captureError = parseDoubaoCaptureError(capture); + if (!captureError) { + return null; + } + + return { + answer: null, + reasoning: null, + provider_request_id: null, + request_id: null, + conversation_id: null, + search_results: [], + event_count: 0, + error_message: captureError, + } satisfies DoubaoStreamSummary; + }) + .filter((summary): summary is DoubaoStreamSummary => summary !== null); + return mergeStreamSummaries(summaries); +} + +function summarizeCapturesForDebug(captures: DoubaoCaptureRecord[]): JsonValue[] { + return captures.slice(-16).map((capture) => ({ + url: capture.url, + status: capture.status, + content_type: capture.contentType, + body_length: capture.body.length, + body_preview: capture.body.slice(0, 240) || null, + })); +} + +function resolveProviderModel(pageResult: DoubaoPageQueryResult): string { + const label = normalizeText(pageResult.modelLabel); + if (label) { + return label; + } + return pageResult.deepThinkEnabled ? "doubao-web-thinking" : "doubao-web"; +} + +const doubaoQueryInPage = async ( + parameters: { + questionText: string; + timeoutMs: number; + readyTimeoutMs: number; + enableDeepThink: boolean; + }, +): Promise => { + const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink } = parameters; + const stablePollsRequired = 4; + const pollIntervalMs = 1_200; + const incompleteAnswerGraceMs = 15_000; + + const wait = (timeMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, timeMs); + }); + + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed ? trimmed : null; + }; + + const normalizeMultiline = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/ /g, " ") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + return trimmed ? trimmed : null; + }; + + const isVisible = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const isDisabled = (element: HTMLElement | null): boolean => { + if (!element) { + return false; + } + if ("disabled" in element && typeof (element as HTMLButtonElement).disabled === "boolean") { + return Boolean((element as HTMLButtonElement).disabled); + } + return element.getAttribute("aria-disabled") === "true"; + }; + + const toggleState = (element: HTMLElement | null): boolean | null => { + if (!element) { + return null; + } + const attrs = [ + element.getAttribute("aria-pressed"), + element.getAttribute("aria-checked"), + element.getAttribute("data-state"), + element.getAttribute("data-selected"), + element.getAttribute("data-active"), + ] + .map((value: string | null) => normalize(value)?.toLowerCase() ?? null) + .filter((value: string | null): value is string => value !== null); + + if (attrs.some((value: string) => ["true", "checked", "selected", "active", "on"].includes(value))) { + return true; + } + if (attrs.some((value: string) => ["false", "unchecked", "off"].includes(value))) { + return false; + } + + const className = normalize(element.className)?.toLowerCase() ?? ""; + if (/(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$)/.test(className)) { + return true; + } + + return null; + }; + + const textOf = (element: Element | null | undefined): string | null => { + if (!(element instanceof HTMLElement)) { + return null; + } + return normalize(element.innerText) ?? normalize(element.textContent); + }; + + const hintText = (element: Element | null | undefined): string => { + if (!(element instanceof HTMLElement)) { + return ""; + } + return [ + element.getAttribute("aria-label"), + element.getAttribute("placeholder"), + element.getAttribute("title"), + element.getAttribute("data-testid"), + element.getAttribute("name"), + element.getAttribute("id"), + typeof element.className === "string" ? element.className : "", + textOf(element), + ] + .map((value: string | null) => normalize(value)?.toLowerCase() ?? "") + .filter(Boolean) + .join(" ") + .slice(0, 600); + }; + + const bodyText = () => normalizeMultiline(document.body?.innerText ?? ""); + + const hasLoginGate = (): boolean => { + const text = bodyText() ?? ""; + if ( + /扫码登录|微信登录|手机号登录|请先登录|立即登录|未登录|登录后/.test(text) + && !/登录后可同步|登录体验更多|登录保存|已登录/.test(text) + ) { + return true; + } + return Boolean(document.querySelector("[class*='login-dialog'], [class*='LoginDialog'], [data-testid*='login']")); + }; + + const hasBusyIndicator = (): boolean => { + const text = bodyText() ?? ""; + return /停止生成|停止输出|正在思考|正在搜索|思考中|搜索中|生成中|回答中/.test(text); + }; + + const summarizeFailureDetail = (raw: string | null): string | null => { + const trimmed = normalize(raw); + if (!trimmed) { + return null; + } + return trimmed.length <= 280 ? trimmed : `${trimmed.slice(0, 280)}...`; + }; + + const findComposer = (): HTMLElement | null => { + const selectors = [ + "textarea[data-testid*='chat']", + "textarea[placeholder]", + "[contenteditable='true'][role='textbox']", + "[contenteditable='true']", + "textarea", + ]; + + const candidates: Array<{ element: HTMLElement; score: number }> = []; + const seen = new Set(); + + for (const selector of selectors) { + for (const node of Array.from(document.querySelectorAll(selector))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { + continue; + } + if ((node as HTMLInputElement).readOnly || (node as HTMLInputElement).disabled) { + continue; + } + seen.add(node); + + const rect = node.getBoundingClientRect(); + // Doubao composer sits near the bottom of the viewport; search bar sits near top. + const isNearBottom = rect.top > window.innerHeight * 0.45; + const hint = hintText(node); + const placeholderLooksLikeSearch = /搜索|search/i.test(hint); + const placeholderLooksLikeComposer = /(问|提问|给我发|doubao|豆包|说点什么|开始聊天|发消息|发送)/i.test(hint); + + if (placeholderLooksLikeSearch && !placeholderLooksLikeComposer) { + continue; + } + + let score = Math.min(rect.width, 1_400) + rect.height; + if (isNearBottom) { + score += 1_600; + } else { + score -= 1_200; + } + if (node.isContentEditable) { + score += 1_400; + } else if (node instanceof HTMLTextAreaElement) { + score += 800; + } + if (placeholderLooksLikeComposer) { + score += 1_800; + } + + candidates.push({ element: node, score }); + } + } + + candidates.sort((left, right) => right.score - left.score); + return candidates[0]?.element ?? null; + }; + + const findComposerShell = (composer: HTMLElement | null): HTMLElement | null => { + if (!composer) { + return null; + } + + let current: HTMLElement | null = composer; + let best: HTMLElement = composer; + let bestArea = composer.getBoundingClientRect().width * composer.getBoundingClientRect().height; + + for (let depth = 0; current && depth < 5; depth += 1) { + if (isVisible(current)) { + const rect = current.getBoundingClientRect(); + const area = rect.width * rect.height; + if (area >= bestArea) { + best = current; + bestArea = area; + } + } + current = current.parentElement; + } + + return best; + }; + + const findInteractiveByText = (pattern: RegExp, shell: HTMLElement | null): HTMLElement | null => { + const scopes: ParentNode[] = []; + if (shell) { + scopes.push(shell); + if (shell.parentElement) { + scopes.push(shell.parentElement); + } + if (shell.parentElement?.parentElement) { + scopes.push(shell.parentElement.parentElement); + } + } + scopes.push(document); + + const seen = new Set(); + const candidates: Array<{ element: HTMLElement; score: number }> = []; + + for (const scope of scopes) { + const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]")); + for (const node of nodes) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { + continue; + } + seen.add(node); + + const text = textOf(node); + if (!text || !pattern.test(text)) { + continue; + } + const rect = node.getBoundingClientRect(); + candidates.push({ element: node, score: rect.top + rect.left }); + } + } + + candidates.sort((left, right) => right.score - left.score); + return candidates[0]?.element ?? null; + }; + + const setComposerText = (composer: HTMLElement, text: string) => { + composer.focus(); + + if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { + const prototype = Object.getPrototypeOf(composer) as { + value?: PropertyDescriptor; + }; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (setter) { + setter.call(composer, text); + } else { + composer.value = text; + } + composer.dispatchEvent(new Event("input", { bubbles: true })); + composer.dispatchEvent(new Event("change", { bubbles: true })); + return; + } + + if (composer.isContentEditable) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(composer); + selection?.removeAllRanges(); + selection?.addRange(range); + + const inserted = typeof document.execCommand === "function" + ? document.execCommand("insertText", false, text) + : false; + if (!inserted) { + composer.textContent = text; + } + + composer.dispatchEvent(new InputEvent("input", { + bubbles: true, + cancelable: true, + data: text, + inputType: "insertText", + })); + composer.dispatchEvent(new Event("change", { bubbles: true })); + } + }; + + const findSendButton = ( + shell: HTMLElement | null, + composer: HTMLElement | null, + ): HTMLElement | null => { + const scopes: ParentNode[] = []; + if (shell) { + scopes.push(shell); + if (shell.parentElement) { + scopes.push(shell.parentElement); + } + if (shell.parentElement?.parentElement) { + scopes.push(shell.parentElement.parentElement); + } + } + scopes.push(document); + + const composerRect = composer?.getBoundingClientRect() ?? null; + const blacklist = /^(深度思考|联网搜索|工具|插件|图像|语音|附件|上传|语音输入|停止输出|停止生成|登录|下载)$/; + const candidates: Array<{ element: HTMLElement; score: number }> = []; + const seen = new Set(); + + for (const scope of scopes) { + const nodes = Array.from(scope.querySelectorAll("button, [role='button'], [data-testid*='send'], [aria-label*='发送'], [aria-label*='Send']")); + for (const node of nodes) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { + continue; + } + seen.add(node); + + const text = textOf(node) ?? ""; + const hint = hintText(node); + if (blacklist.test(text)) { + continue; + } + if (/(登录|下载|插件|历史|设置|菜单)/.test(hint) && !/send|发送|submit/.test(hint)) { + continue; + } + if (isDisabled(node)) { + continue; + } + + const rect = node.getBoundingClientRect(); + if (rect.width < 18 || rect.height < 18) { + continue; + } + if (composerRect) { + if (rect.top < composerRect.top - 120 || rect.top > composerRect.bottom + 160) { + continue; + } + if (rect.left < composerRect.left - 80 || rect.left > composerRect.right + 240) { + continue; + } + } + + let score = rect.left + rect.top; + if (composerRect) { + const dx = Math.abs(rect.left - composerRect.right); + const dy = Math.abs(rect.top - composerRect.top); + score += Math.max(0, 2_000 - dx * 3 - dy * 3); + if (rect.left >= composerRect.right - 32) { + score += 500; + } + } + if (/(send|发送|submit|paper|arrow|上箭头|up)/i.test(hint)) { + score += 1_800; + } + if (!text && rect.width <= 72 && rect.height <= 72) { + score += 360; + } + + candidates.push({ element: node, score }); + } + } + + candidates.sort((left, right) => right.score - left.score); + return candidates[0]?.element ?? null; + }; + + const dispatchEnter = (composer: HTMLElement) => { + const eventInit: KeyboardEventInit = { + bubbles: true, + cancelable: true, + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + }; + composer.dispatchEvent(new KeyboardEvent("keydown", eventInit)); + composer.dispatchEvent(new KeyboardEvent("keypress", eventInit)); + composer.dispatchEvent(new KeyboardEvent("keyup", eventInit)); + }; + + const normalizeHref = (value: string | null | undefined): string | null => { + const trimmed = normalize(value); + if (!trimmed) { + return null; + } + try { + const url = new URL(trimmed, window.location.href); + url.hash = ""; + return url.toString(); + } catch { + return null; + } + }; + + type ConversationSnapshot = { + answerText: string | null; + reasoningText: string | null; + links: DoubaoDomLink[]; + signature: string; + }; + + const countMatches = (input: string, pattern: RegExp): number => { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return input.match(new RegExp(pattern.source, flags))?.length ?? 0; + }; + + const hasStructuredAnswerMarkers = (text: string): boolean => { + return ( + /(^|\n)\s*(?:[-*+]\s+|\d+[.、]\s+|#{1,6}\s+)/m.test(text) + || /(?:^|\n)\|.+\|(?:\n|$)/m.test(text) + || /(?:^|\n)\s*[^:\n]{1,24}[::]\s+\S+/m.test(text) + ); + }; + + const hasObservedContent = (snapshot: ConversationSnapshot | null): boolean => { + if (!snapshot) { + return false; + } + return Boolean(normalize(snapshot.answerText) || normalize(snapshot.reasoningText) || snapshot.links.length > 0); + }; + + const snapshotQualityScore = (snapshot: ConversationSnapshot | null): number => { + if (!snapshot) { + return Number.NEGATIVE_INFINITY; + } + + const answer = normalizeMultiline(snapshot.answerText); + const reasoning = normalizeMultiline(snapshot.reasoningText); + let score = 0; + if (answer) { + score += Math.min(answer.length, 4_000); + score += countMatches(answer, /[。!?;:,、,.!?;:]/g) * 18; + if (hasStructuredAnswerMarkers(answer)) { + score += 260; + } + if (/[。!?.!?]$/.test(answer)) { + score += 60; + } + if (!/\n/.test(answer) && answer.length < 24 && !/[。!?.!?]/.test(answer)) { + score -= 120; + } + } + if (reasoning) { + score += Math.min(reasoning.length, 2_000) / 2; + } + score += snapshot.links.length * 120; + return score; + }; + + const pickBetterSnapshot = ( + current: ConversationSnapshot | null, + candidate: ConversationSnapshot, + ): ConversationSnapshot => { + if (!current) { + return candidate; + } + + const currentHasContent = hasObservedContent(current); + const candidateHasContent = hasObservedContent(candidate); + if (candidateHasContent && !currentHasContent) { + return candidate; + } + if (currentHasContent && !candidateHasContent) { + return current; + } + return snapshotQualityScore(candidate) >= snapshotQualityScore(current) ? candidate : current; + }; + + const shouldReturnStableAnswer = ( + snapshot: ConversationSnapshot | null, + firstChangedContentAt: number | null, + now: number, + ): boolean => { + if (!snapshot || firstChangedContentAt === null || !hasObservedContent(snapshot)) { + return false; + } + + const answer = normalizeMultiline(snapshot.answerText); + const reasoning = normalizeMultiline(snapshot.reasoningText); + if (!answer) { + return Boolean(reasoning || snapshot.links.length > 0) && now - firstChangedContentAt >= incompleteAnswerGraceMs; + } + + if ( + answer.length >= 96 + || countMatches(answer, /[。!?;:]/g) >= 2 + || countMatches(answer, /\n/g) >= 2 + || hasStructuredAnswerMarkers(answer) + || (snapshot.links.length >= 2 && answer.length >= 48) + || (reasoning && answer.length >= 48) + ) { + return true; + } + + return now - firstChangedContentAt >= incompleteAnswerGraceMs; + }; + + const isExternalHref = (value: string | null): boolean => { + if (!value) { + return false; + } + try { + const hostname = new URL(value).hostname.toLowerCase(); + if (!hostname) { + return false; + } + return !hostname.endsWith("doubao.com") && !hostname.endsWith("bytedance.com") && !hostname.endsWith("volces.com"); + } catch { + return false; + } + }; + + const collectLinks = (scope: HTMLElement | null): DoubaoDomLink[] => { + if (!scope) { + return []; + } + const links: DoubaoDomLink[] = []; + const seen = new Set(); + for (const node of Array.from(scope.querySelectorAll("a[href]"))) { + if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) { + continue; + } + const href = normalizeHref(node.getAttribute("href") ?? node.href); + if (!href || !isExternalHref(href) || seen.has(href)) { + continue; + } + seen.add(href); + links.push({ + url: href, + title: normalize(node.title) ?? normalize(node.getAttribute("aria-label")), + text: normalize(node.innerText) ?? normalize(node.textContent), + siteName: normalize(node.getAttribute("data-site-name")) ?? normalize(node.dataset.siteName ?? null), + }); + } + return links; + }; + + const normalizedQuestion = normalizeMultiline(questionText); + + // Tokens that only appear on Doubao's empty-chat surface (prompt templates, nav, feature chips). + const EMPTY_CHAT_TOKENS = [ + "快速", + "帮我写作", + "图像生成", + "AI 播客", + "AI播客", + "编程", + "翻译", + "更多", + "更多功能", + "新对话", + "新建对话", + "扫码下载", + "下载 App", + "下载App", + "登录体验更多", + ]; + + const looksLikeEmptyChatSurface = (text: string | null): boolean => { + if (!text) { + return false; + } + const lines = text.split(/\n+/).map((line) => line.trim()).filter(Boolean); + if (lines.length === 0) { + return false; + } + const hits = lines.filter((line) => EMPTY_CHAT_TOKENS.some((token) => line === token || line === `${token}`)).length; + // If half-or-more of the short lines are prompt-template chips, this is the empty-chat surface. + return hits >= 3 || (hits >= 2 && lines.length <= 6); + }; + + const stripKnownNoise = (text: string | null): string | null => { + if (!text) { + return null; + } + const lines = text.split(/\n+/).map((line) => line.trim()).filter(Boolean); + const kept = lines.filter((line) => { + if (EMPTY_CHAT_TOKENS.includes(line)) { + return false; + } + if (normalizedQuestion && line === normalizedQuestion) { + return false; + } + if (/^(内容由AI生成|内容由 AI 生成)/.test(line)) { + return false; + } + return true; + }); + const joined = kept.join("\n").trim(); + return joined || null; + }; + + const findQuestionAnchor = (): HTMLElement | null => { + if (!normalizedQuestion) { + return null; + } + let best: { element: HTMLElement; order: number } | null = null; + let order = 0; + const candidates = Array.from(document.querySelectorAll("p, div, span, li, article, section")); + for (const node of candidates) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + const text = normalizeMultiline(node.innerText); + if (!text) { + continue; + } + if (text !== normalizedQuestion && !(text.includes(normalizedQuestion) && text.length <= normalizedQuestion.length + 80)) { + continue; + } + order += 1; + const container = + (node.closest("[class*='message'], [class*='Message'], [class*='bubble'], [class*='Bubble'], [class*='user'], [class*='User'], li, article, section") as HTMLElement | null) + ?? node; + if (!best || order >= best.order) { + best = { element: container, order }; + } + } + return best?.element ?? null; + }; + + const snapshotConversation = (composerTop: number | null): ConversationSnapshot => { + const questionAnchor = findQuestionAnchor(); + const anchorRect = questionAnchor?.getBoundingClientRect() ?? null; + + const candidates: Array<{ element: HTMLElement; score: number }> = []; + const elements = Array.from(document.querySelectorAll("article, section, div, main, li")); + + for (const node of elements) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + if (questionAnchor && (node === questionAnchor || questionAnchor.contains(node) || node.contains(questionAnchor))) { + continue; + } + + const rect = node.getBoundingClientRect(); + if (composerTop !== null && rect.top >= composerTop - 12) { + continue; + } + if (anchorRect && rect.bottom <= anchorRect.top + 4) { + // Skip elements that come strictly before the user question. + continue; + } + if (rect.left < window.innerWidth * 0.08 || rect.width < 200) { + continue; + } + + const rawText = normalizeMultiline(node.innerText); + const text = stripKnownNoise(rawText); + const links = collectLinks(node); + if (!text && links.length === 0) { + continue; + } + if (rawText && looksLikeEmptyChatSurface(rawText)) { + continue; + } + if (text && text.length < 12 && links.length === 0) { + continue; + } + if (text && /^(Hi[,,\s].*|请登录|请先登录|扫码登录)$/.test(text)) { + continue; + } + + const className = normalize(node.className)?.toLowerCase() ?? ""; + const datasetKeys = Object.keys(node.dataset ?? {}).join(" ").toLowerCase(); + const blob = `${className} ${datasetKeys}`; + + if (/menu|navbar|sidebar|prompt-?template|quick-?action|home-?page|entry/.test(blob)) { + continue; + } + + let score = Math.min(text?.length ?? 0, 3_000) + links.length * 80 + rect.height; + if (/markdown|message-content|chat-content|answer|response|assistant|bot|reply/.test(blob)) { + score += 1_400; + } + if (/user|question|human/.test(blob)) { + score -= 1_200; + } + if (questionAnchor && rect.top > (anchorRect?.bottom ?? 0)) { + score += 1_000; + } + + if (score <= 0) { + continue; + } + + candidates.push({ element: node, score }); + } + + candidates.sort((left, right) => right.score - left.score); + const best = candidates[0]?.element ?? null; + + let answerText: string | null = null; + let reasoningText: string | null = null; + + if (best) { + const reasoningNode = Array.from(best.querySelectorAll("[class*='think'], [class*='Think'], [class*='reason'], [class*='Reason'], [class*='analysis']")) + .filter((node): node is HTMLElement => node instanceof HTMLElement && isVisible(node)) + .sort((left, right) => right.getBoundingClientRect().height - left.getBoundingClientRect().height)[0] ?? null; + if (reasoningNode) { + reasoningText = stripKnownNoise(normalizeMultiline(reasoningNode.innerText)); + } + answerText = stripKnownNoise(normalizeMultiline(best.innerText)); + if (reasoningText && answerText && answerText.startsWith(reasoningText)) { + const trimmedAnswer = answerText.slice(reasoningText.length).trim(); + if (trimmedAnswer) { + answerText = trimmedAnswer; + } + } + } + + const links = best ? collectLinks(best) : []; + const signature = `${answerText ?? ""}|${reasoningText ?? ""}|${links.map((item) => item.url).join("|")}`.slice(-2_000); + + return { answerText, reasoningText, links, signature }; + }; + + const resolveModelLabel = (): string | null => { + const title = normalize(document.title); + const matched = title?.match(/豆包[^-|]*/); + return normalize(matched?.[0] ?? null); + }; + + const fail = ( + error: string, + detail: string | null, + composerTop: number | null, + snapshotOverride: ConversationSnapshot | null = null, + ): DoubaoPageQueryFailureResult => { + const snapshot = snapshotOverride ?? snapshotConversation(composerTop); + const composer = findComposer(); + const shell = findComposerShell(composer); + const deepThinkButton = findInteractiveByText(/深度思考/, shell); + return { + ok: false, + error, + detail: summarizeFailureDetail(detail) ?? summarizeFailureDetail(snapshot.answerText) ?? summarizeFailureDetail(bodyText()), + url: window.location.href || null, + title: normalize(document.title), + modelLabel: resolveModelLabel(), + deepThinkEnabled: toggleState(deepThinkButton), + domAnswer: snapshot.answerText, + domReasoning: snapshot.reasoningText, + domLinks: snapshot.links, + }; + }; + + const readyDeadline = Date.now() + readyTimeoutMs; + let composer = findComposer(); + while (!composer && Date.now() <= readyDeadline) { + await wait(250); + composer = findComposer(); + } + + if (hasLoginGate()) { + return fail("doubao_login_required", bodyText(), composer?.getBoundingClientRect().top ?? null); + } + + if (!composer) { + return fail("doubao_composer_missing", bodyText(), null); + } + + let composerShell = findComposerShell(composer); + const composerTop = composer.getBoundingClientRect().top; + + let deepThinkApplied: boolean | null = null; + if (enableDeepThink) { + const deepThinkButton = findInteractiveByText(/深度思考/, composerShell); + if (deepThinkButton) { + const currentState = toggleState(deepThinkButton); + if (currentState !== true) { + deepThinkButton.click(); + await wait(180); + } + deepThinkApplied = toggleState(deepThinkButton); + } + } + + setComposerText(composer, questionText); + await wait(220); + + composer = findComposer() ?? composer; + composerShell = findComposerShell(composer) ?? composerShell; + const sendButton = findSendButton(composerShell, composer); + if (sendButton) { + sendButton.click(); + } else { + dispatchEnter(composer); + } + + await wait(600); + + const initialSnapshot = snapshotConversation(composerTop); + let bestSnapshot = initialSnapshot; + let bestContentSnapshot: ConversationSnapshot | null = hasObservedContent(initialSnapshot) ? initialSnapshot : null; + let lastSignature = initialSnapshot.signature; + let stableCount = 0; + let sawChange = false; + let firstChangedContentAt: number | null = null; + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + const snapshot = snapshotConversation(composerTop); + bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot); + if (hasObservedContent(snapshot)) { + bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, snapshot); + } + + const busy = hasBusyIndicator(); + const hasContent = hasObservedContent(snapshot); + if (snapshot.signature && hasContent && snapshot.signature !== initialSnapshot.signature) { + sawChange = true; + if (firstChangedContentAt === null) { + firstChangedContentAt = Date.now(); + } + if (snapshot.signature === lastSignature) { + stableCount = busy ? 0 : stableCount + 1; + } else { + lastSignature = snapshot.signature; + stableCount = 0; + } + } + + if (hasLoginGate()) { + return fail("doubao_login_expired", bodyText(), composerTop, bestContentSnapshot ?? bestSnapshot); + } + + const finalSnapshot = bestContentSnapshot ? pickBetterSnapshot(bestSnapshot, bestContentSnapshot) : bestSnapshot; + if ( + sawChange + && !busy + && stableCount >= stablePollsRequired + && shouldReturnStableAnswer(finalSnapshot, firstChangedContentAt, Date.now()) + ) { + const deepThinkButton = findInteractiveByText(/深度思考/, composerShell); + return { + ok: true, + url: window.location.href, + title: normalize(document.title), + modelLabel: resolveModelLabel(), + deepThinkEnabled: toggleState(deepThinkButton) ?? deepThinkApplied, + domAnswer: finalSnapshot.answerText, + domReasoning: finalSnapshot.reasoningText, + domLinks: finalSnapshot.links, + }; + } + + await wait(pollIntervalMs); + } + + return fail("doubao_query_timeout", bodyText(), composerTop, bestContentSnapshot ?? bestSnapshot); +}; + export const doubaoAdapter: MonitorAdapter = { provider: "doubao", executionMode: "playwright", async query(context, payload) { - if (!context.playwright) { + if (!context.playwright?.page) { return { status: "failed", - summary: "豆包监测缺少 Playwright 执行上下文。", - error: buildAdapterError( - "doubao_playwright_required", - "doubao_playwright_required", - ), + summary: "豆包监测缺少 Playwright 页面上下文。", + error: buildAdapterError("doubao_playwright_required", "doubao_playwright_required"), }; } const questionText = extractQuestionText(payload); - let runtimeState: DoubaoRuntimeState; + const page = context.playwright.page; + + context.reportProgress("doubao.page_ready"); + await ensurePageOnDoubaoChat(page, context.signal); + + const capture = attachDoubaoResponseCapture(page); + let pageResult: DoubaoPageQueryResult; + try { - runtimeState = await loadDoubaoRuntimeState(context); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.startsWith("doubao_runtime_state_missing")) { - const detail = message.slice("doubao_runtime_state_missing:".length) || "unknown"; + context.reportProgress("doubao.query"); + pageResult = await page.evaluate(doubaoQueryInPage, { + questionText, + timeoutMs: DOUBAO_QUERY_TIMEOUT_MS, + readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS, + enableDeepThink: true, + }); + await sleep(600, context.signal).catch(() => undefined); + await capture.flush(DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS); + } finally { + capture.dispose(); + } + + const streamSummary = parseDoubaoCaptures(capture.captures); + const captureDebug = summarizeCapturesForDebug(capture.captures); + const pendingCaptureCount = capture.pendingCount(); + const domSearchResults = dedupeSourceItems( + pageResult.domLinks + .map((item) => buildSourceItem({ + url: item.url, + title: item.title ?? item.text ?? null, + site_name: item.siteName, + })) + .filter((item): item is MonitoringSourceItem => item !== null), + ); + const searchResults = dedupeSourceItems([ + ...streamSummary.search_results, + ...domSearchResults, + ]); + const answer = selectBestDoubaoText(streamSummary.answer, pageResult.domAnswer); + const reasoning = selectBestDoubaoText(streamSummary.reasoning, pageResult.domReasoning); + const providerModel = resolveProviderModel(pageResult); + const conversationID = streamSummary.conversation_id ?? extractConversationID(pageResult.url); + const requestID = streamSummary.request_id ?? conversationID; + const hasRecoveredContent = Boolean(answer) || searchResults.length > 0; + const retryableStreamError = isDoubaoRetryableErrorMessage(streamSummary.error_message); + + if (pageResult.ok === false) { + const failed = pageResult; + const detail = normalizeText(failed.detail) ?? "unknown"; + if (failed.error === "doubao_login_required" || failed.error === "doubao_login_expired") { return { status: "failed", - summary: `豆包运行时上下文未就绪(${detail}),请稍后重试或在桌面端打开豆包页面完成初始化。`, - error: buildAdapterError( - "doubao_runtime_state_missing", - `doubao_runtime_state_missing:${detail}`, - ), + summary: "豆包账号未登录或登录态已失效,请先在桌面客户端中重新授权。", + error: buildAdapterError(failed.error, detail, { page_url: failed.url ?? safePageURL(page) }), }; } - throw error; - } - const requestBody = buildDoubaoRequestBody(questionText, runtimeState); - context.reportProgress("doubao.query"); - const fetchResult = await fetchDoubaoCompletion( - context.playwright.page, - buildDoubaoRequestUrl(runtimeState), - requestBody, - ); + if ( + failed.error !== "doubao_query_timeout" + || !hasRecoveredContent + ) { + if (streamSummary.error_message) { + if (retryableStreamError) { + if (!hasRecoveredContent) { + return buildDoubaoRetryableUnknownResult(streamSummary.error_message, { + page_url: failed.url ?? safePageURL(page), + page_error: failed.error, + }); + } + } else { + return { + status: "failed", + summary: `豆包请求失败:${streamSummary.error_message}`, + error: buildAdapterError("doubao_stream_error", streamSummary.error_message, { + page_url: failed.url ?? safePageURL(page), + }), + }; + } + } - if (fetchResult.error) { + if (!retryableStreamError || !hasRecoveredContent) { + if (retryableStreamError) { + return buildDoubaoRetryableUnknownResult(detail, { + page_url: failed.url ?? safePageURL(page), + page_error: failed.error, + }); + } + return { + status: "failed", + summary: `豆包请求失败:${detail}`, + error: buildAdapterError(failed.error, detail, { + page_url: failed.url ?? safePageURL(page), + }), + }; + } + } + } else if (streamSummary.error_message && !hasRecoveredContent) { + if (retryableStreamError) { + return buildDoubaoRetryableUnknownResult(streamSummary.error_message, { + page_url: pageResult.url, + }); + } return { status: "failed", - summary: `豆包请求发送失败:${fetchResult.error}`, - error: buildAdapterError("doubao_request_failed", fetchResult.error), + summary: `豆包请求失败:${streamSummary.error_message}`, + error: buildAdapterError("doubao_stream_error", streamSummary.error_message, { + page_url: pageResult.url, + }), }; } - if (!fetchResult.ok) { - const detail = normalizeText(fetchResult.body)?.slice(0, 240); - return { - status: "failed", - summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${fetchResult.status})。`, - error: buildAdapterError( - "doubao_request_failed", - detail || `doubao_request_failed_${fetchResult.status}`, - { http_status: fetchResult.status }, - ), - }; - } - - context.reportProgress("doubao.parse_stream"); - const streamSummary = await parseDoubaoStream(new Response(fetchResult.body)); - if (streamSummary.error_message) { - return { - status: "failed", - summary: streamSummary.error_message, - error: buildAdapterError("doubao_stream_error", streamSummary.error_message), - }; - } - - if (!streamSummary.answer && !streamSummary.citations.length && !streamSummary.search_results.length) { + if (!hasRecoveredContent) { return { status: "unknown", summary: "豆包返回为空,已回写 unknown 等待后续对账。", @@ -1242,22 +2104,42 @@ export const doubaoAdapter: MonitorAdapter = { }; } + context.reportProgress("doubao.parse_result"); return { status: "succeeded", summary: "豆包监控任务执行成功。", payload: { platform: "doubao", - provider_model: "doubao-web-thinking", + provider_model: providerModel, provider_request_id: streamSummary.provider_request_id, - request_id: streamSummary.request_id ?? streamSummary.conversation_id, - conversation_id: streamSummary.conversation_id, - answer: streamSummary.answer, - reasoning: streamSummary.reasoning, - citation_count: streamSummary.citations.length, - search_result_count: streamSummary.search_results.length, - event_count: streamSummary.event_count, - citations: toJsonSources(streamSummary.citations), - search_results: toJsonSources(streamSummary.search_results), + request_id: requestID, + conversation_id: conversationID, + answer, + reasoning, + citation_count: 0, + search_result_count: searchResults.length, + citations: [], + search_results: toJsonSources(searchResults), + raw_response_json: { + platform: "doubao", + page_url: pageResult.url, + page_title: pageResult.title, + model_label: pageResult.modelLabel, + deep_think_enabled: pageResult.deepThinkEnabled, + capture_count: capture.captures.length, + capture_pending_after_flush: pendingCaptureCount, + event_count: streamSummary.event_count, + stream_search_result_count: streamSummary.search_results.length, + dom_search_result_count: domSearchResults.length, + stream_error_message: streamSummary.error_message, + stream_error_retryable: retryableStreamError, + stream_answer_present: Boolean(streamSummary.answer), + dom_answer_present: Boolean(pageResult.domAnswer), + stream_answer_length: streamSummary.answer?.length ?? 0, + dom_answer_length: pageResult.domAnswer?.length ?? 0, + selected_answer_length: answer?.length ?? 0, + captures: captureDebug, + }, }, }; },