From 513e71f9f15ada21ea460719e82f2a221e281357 Mon Sep 17 00:00:00 2001 From: liangxu Date: Tue, 26 May 2026 10:18:53 +0800 Subject: [PATCH] fix(doubao): submit only verified SSE answers and harden source extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Doubao monitor adapter so success answers come exclusively from CHUNK_DELTA SSE text and require an SSE_REPLY_END finish event — DOM fallbacks and FULL_MSG_NOTIFY user echoes can no longer pose as the model reply. Also auto-switch to the 思考 answer mode, repair mixed-mojibake fragments per run, unwrap Doubao redirect URLs, scrub "unknown" source metadata, recover from mid-query navigation by returning unknown for retry, and surface stream/finish counters in diagnostics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/adapters/doubao.test.ts | 164 +++ .../src/main/adapters/doubao.ts | 1111 +++++++++++++++-- 2 files changed, 1202 insertions(+), 73 deletions(-) diff --git a/apps/desktop-client/src/main/adapters/doubao.test.ts b/apps/desktop-client/src/main/adapters/doubao.test.ts index ff9fa6e..b2edab1 100644 --- a/apps/desktop-client/src/main/adapters/doubao.test.ts +++ b/apps/desktop-client/src/main/adapters/doubao.test.ts @@ -8,6 +8,7 @@ const { isNonCitationAssetDomain, isNonCitationAssetUrl, parseDoubaoStreamBody, + resolveDoubaoSubmissionAnswer, } = __doubaoTestUtils describe('doubao adapter helpers', () => { @@ -59,4 +60,167 @@ describe('doubao adapter helpers', () => { ]), ).toHaveLength(1) }) + + it('uses thinking-chain reference metadata to fill unknown Doubao source titles', () => { + const body = [ + 'event: STREAM_CHUNK', + 'data: {"references":[{"url":"https://example.com/report","title":"unkown"}],"thinking":{"references":[{"url":"https://example.com/report#section","title":"思考链路里的真实标题","site_name":"示例站点"}]},"text_block":{"text":"回答"}}', + '', + '', + ].join('\n') + + const summary = parseDoubaoStreamBody(body) + + expect(summary.search_results).toHaveLength(1) + expect(summary.search_results[0]?.url).toBe('https://example.com/report') + expect(summary.search_results[0]?.title).toBe('思考链路里的真实标题') + expect(summary.search_results[0]?.site_name).toBe('示例站点') + }) + + it('unwraps Doubao redirect URLs before storing citation sources', () => { + const item = buildSourceItem({ + url: 'https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%23ref', + title: 'unknown', + }) + + expect(item).toMatchObject({ + url: 'https://example.com/source', + normalized_url: 'https://example.com/source', + host: 'example.com', + title: null, + }) + }) + + it('parses Doubao answer deltas and source cards from the completion SSE stream', () => { + const sourceChunkPayload = { + patch_op: [ + { + patch_value: { + content_block: [ + { + block_type: 10025, + content: { + search_query_result_block: { + results: [ + { + text_card: { + url: 'https://k.sina.cn/article_3244012052_c15bb21400101k0hg.html', + title: '2026年合肥全屋定制品牌综合实力排名出炉', + sitename: '新浪', + logo_url: 'https://p11-spider-image-sign.byteimg.com/logo.jpeg', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + } + const body = [ + 'event: SSE_ACK', + 'data: {"ack_client_meta":{"conversation_id":"38427686224603138","section_id":"38427686224603394"}}', + '', + 'event: FULL_MSG_NOTIFY', + 'data: {"message":{"content":"用户问题 echo,不是答案","conversation_id":"38427686224603138"}}', + '', + 'event: STREAM_CHUNK', + `data: ${JSON.stringify(sourceChunkPayload)}`, + '', + 'event: CHUNK_DELTA', + 'data: {"text":"直接给你"}', + '', + 'event: CHUNK_DELTA', + 'data: {"text":"一份清单。"}', + '', + 'event: SSE_REPLY_END', + 'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}', + '', + '', + ].join('\n') + + const summary = parseDoubaoStreamBody(body) + + expect(summary.answer).toBe('直接给你一份清单。') + expect(summary.conversation_id).toBe('38427686224603138') + expect(summary.full_message_event_count).toBe(1) + expect(summary.reply_end_event_count).toBe(1) + expect(summary.answer_finish_event_count).toBe(1) + expect(summary.search_results).toHaveLength(1) + expect(summary.search_results[0]).toMatchObject({ + url: 'https://k.sina.cn/article_3244012052_c15bb21400101k0hg.html', + title: '2026年合肥全屋定制品牌综合实力排名出炉', + site_name: '新浪', + host: 'k.sina.cn', + }) + }) + + it('repairs mixed Doubao mojibake fragments before returning the SSE answer', () => { + const body = [ + 'event: CHUNK_DELTA', + 'data: {"text":"需要合肥地区全屋定制推荐,"}', + '', + 'event: CHUNK_DELTA', + 'data: {"text":"综合考量性价比、口碑服务等因素。2000㎡展厅,核验批号。"}', + '', + 'event: SSE_REPLY_END', + 'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}', + '', + '', + ].join('\n') + + const summary = parseDoubaoStreamBody(body) + + expect(summary.answer).toBe( + '需要合肥地区全屋定制推荐,综合考量性价比、口碑服务等因素。2000㎡展厅,核验批号。', + ) + expect(summary.answer).not.toMatch(/[åæèéäãï][\u0080-\u00ff\u02c0-\u02ff\u2000-\u20ff]/) + expect(resolveDoubaoSubmissionAnswer({ answer: summary.answer })).toEqual({ + answer: + '需要合肥地区全屋定制推荐,综合考量性价比、口碑服务等因素。2000㎡展厅,核验批号。', + source: 'sse', + }) + }) + + it('does not treat FULL_MSG_NOTIFY user echo as the Doubao answer', () => { + const body = [ + 'event: FULL_MSG_NOTIFY', + 'data: {"message":{"content":"合肥全屋定制哪家好"}}', + '', + 'event: SSE_REPLY_END', + 'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}', + '', + '', + ].join('\n') + + const summary = parseDoubaoStreamBody(body) + + expect(summary.answer).toBeNull() + expect(summary.full_message_event_count).toBe(1) + expect(summary.answer_finish_event_count).toBe(1) + }) + + it('submits only SSE answer text, without reasoning fallback', () => { + expect( + resolveDoubaoSubmissionAnswer({ + answer: 'SSE 正文答案', + }), + ).toEqual({ + answer: 'SSE 正文答案', + source: 'sse', + }) + }) + + it('does not submit a success answer when the SSE stream has no answer text', () => { + expect( + resolveDoubaoSubmissionAnswer({ + answer: null, + }), + ).toEqual({ + answer: null, + source: null, + }) + }) }) diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts index dafdfbe..3df352c 100644 --- a/apps/desktop-client/src/main/adapters/doubao.ts +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -8,8 +8,8 @@ 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_CAPTURE_BODY_LIMIT = 8_000_000 +const DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS = DOUBAO_QUERY_TIMEOUT_MS const DOUBAO_STREAM_URL_FRAGMENT = '/chat/completion' const DOUBAO_NON_CITATION_ASSET_DOMAINS = new Set(['byteimg.com', 'bytednsdoc.com']) @@ -43,7 +43,9 @@ const SEARCH_RESULT_KEYS = new Set([ 'browseReferences', ]) -const ANSWER_EVENT_WHITELIST = new Set(['STREAM_CHUNK', 'STREAM_MSG_NOTIFY', 'FULL_MSG_NOTIFY']) +const ANSWER_EVENT_WHITELIST = new Set(['STREAM_CHUNK', 'STREAM_MSG_NOTIFY']) +const DOUBAO_CHUNK_DELTA_EVENT = 'CHUNK_DELTA' +const DOUBAO_REPLY_END_EVENT = 'SSE_REPLY_END' type DoubaoStreamEvent = { event: string @@ -58,9 +60,14 @@ type DoubaoStreamSummary = { conversation_id: string | null search_results: MonitoringSourceItem[] event_count: number + full_message_event_count: number + reply_end_event_count: number + answer_finish_event_count: number error_message: string | null } +type DoubaoAnswerModeKind = 'fast' | 'thinking' | 'expert' + type DoubaoCaptureRecord = { url: string status: number @@ -68,6 +75,14 @@ type DoubaoCaptureRecord = { body: string } +type DoubaoPageQueryParameters = { + questionText: string + timeoutMs: number + readyTimeoutMs: number + enableDeepThink: boolean + submitQuestion?: boolean +} + type DoubaoDomLink = { url: string title: string | null @@ -80,6 +95,8 @@ type DoubaoPageQuerySuccessResult = { url: string title: string | null modelLabel: string | null + modeLabel: string | null + thinkingModeApplied: boolean | null deepThinkEnabled: boolean | null domAnswer: string | null domReasoning: string | null @@ -93,6 +110,8 @@ type DoubaoPageQueryFailureResult = { url: string | null title: string | null modelLabel: string | null + modeLabel: string | null + thinkingModeApplied: boolean | null deepThinkEnabled: boolean | null domAnswer: string | null domReasoning: string | null @@ -140,13 +159,16 @@ function countDoubaoHanCharacters(value: string): number { } function countDoubaoSuspiciousCharacters(value: string): number { - return value.match(/[\u00c0-\u017f\u2000-\u20ff]/g)?.length ?? 0 + return value.match(/[\u0080-\u017f\u02c0-\u02ff\u2000-\u20ff]/g)?.length ?? 0 } function looksLikeDoubaoMojibake(value: string): boolean { if (!value) { return false } + if (containsDoubaoMojibakeSignal(value)) { + return true + } if (value.includes('Ã') || value.includes('â€') || value.includes('ï¼') || value.includes('ã€')) { return true } @@ -188,31 +210,83 @@ function doubaoRepairLooksBetter(original: string, repaired: string): boolean { if (!repaired.trim()) { return false } + if (repaired === original) { + return false + } const originalHan = countDoubaoHanCharacters(original) const repairedHan = countDoubaoHanCharacters(repaired) const originalSuspicious = countDoubaoSuspiciousCharacters(original) const repairedSuspicious = countDoubaoSuspiciousCharacters(repaired) - if (repairedHan > originalHan && repairedHan >= 2) { + if (repairedHan > originalHan && repairedHan >= 1) { return true } - return repairedHan > 0 && repairedSuspicious * 2 < originalSuspicious + return originalSuspicious > 0 && repairedSuspicious < originalSuspicious } -function repairDoubaoMojibake(value: string): string { - const trimmed = value.trim() - if (!trimmed || !looksLikeDoubaoMojibake(trimmed)) { +function isDoubaoMojibakeRepairableCharacter(value: string): boolean { + const codePoint = value.codePointAt(0) + return codePoint != null && (codePoint <= 0xff || DOUBAO_WINDOWS_1252_REVERSE_MAP.has(codePoint)) +} + +function containsDoubaoMojibakeSignal(value: string): boolean { + return /[\u0080-\u009f]/.test(value) || + /(?:Ã.|Â[\u0080-\u017f]|â[\u0080-\u017f\u2000-\u20ff]|[åæèéäãï][\u0080-\u017f\u02c0-\u02ff\u2000-\u20ff]|ã[\u0080-\u017f\u02c0-\u02ff\u2000-\u20ff])/.test( + value, + ) +} + +function repairDoubaoMojibakeSegment(value: string): string { + if (!containsDoubaoMojibakeSignal(value)) { return value } - const repaired = decodeDoubaoLatin1AsUtf8(trimmed) - if (!repaired || !doubaoRepairLooksBetter(trimmed, repaired)) { + const repaired = decodeDoubaoLatin1AsUtf8(value) + if (!repaired || !doubaoRepairLooksBetter(value, repaired)) { return value } return repaired } +function repairDoubaoMojibakeRuns(value: string): string { + let repaired = '' + let run = '' + + const flush = () => { + if (!run) { + return + } + repaired += repairDoubaoMojibakeSegment(run) + run = '' + } + + for (const char of value) { + if (isDoubaoMojibakeRepairableCharacter(char)) { + run += char + continue + } + flush() + repaired += char + } + flush() + + return repaired +} + +function repairDoubaoMojibake(value: string): string { + if (!value || !looksLikeDoubaoMojibake(value)) { + return value + } + + const wholeStringRepair = decodeDoubaoLatin1AsUtf8(value) + if (wholeStringRepair && doubaoRepairLooksBetter(value, wholeStringRepair)) { + return wholeStringRepair + } + + return repairDoubaoMojibakeRuns(value) +} + function normalizeOptionalString(value: unknown): string | null { if (typeof value !== 'string') { return null @@ -221,6 +295,24 @@ function normalizeOptionalString(value: unknown): string | null { return trimmed ? trimmed : null } +function normalizeSourceMetadataString(value: unknown): string | null { + const normalized = normalizeOptionalString(value) + if (!normalized) { + return null + } + + const compact = normalized.replace(/\s+/g, '').toLowerCase() + if ( + /^(?:unknown|unkown|undefined|null|none|n\/?a|--|-|未知|未知来源|未知站点|暂无|无)$/.test( + compact, + ) + ) { + return null + } + + return normalized +} + function safeParseJSON(raw: string): unknown { try { return JSON.parse(raw) @@ -239,6 +331,19 @@ function getDirectString(record: Record, keys: string[]): strin return null } +function getDirectSourceMetadataString( + record: Record, + keys: string[], +): string | null { + for (const key of keys) { + const value = normalizeSourceMetadataString(record[key]) + if (value) { + return value + } + } + return null +} + function findFirstStringByKey(source: unknown, keys: string[], depth = 0): string | null { if (depth > 8 || source == null) { return null @@ -362,6 +467,17 @@ function selectBestDoubaoText(primary: string | null, secondary: string | null): return secondaryScore > primaryScore ? secondaryText : primaryText } +function resolveDoubaoSubmissionAnswer(input: { + answer: string | null +}): { answer: string | null; source: 'sse' | null } { + const answer = normalizeOptionalString(input.answer) + if (answer && !containsDoubaoMojibakeSignal(answer)) { + return { answer, source: 'sse' } + } + + return { answer: null, source: null } +} + function looksLikeSourceUrl(value: string | null): boolean { if (!value) { return false @@ -379,19 +495,89 @@ function looksLikeSourceUrl(value: string | null): boolean { return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(normalized) } -function normalizeSourceUrl(value: string | null): string | null { +function extractDoubaoEmbeddedSourceUrl(url: URL, depth = 0): string | null { + if (depth > 3) { + return null + } + + const host = url.hostname.toLowerCase() + const likelyRedirectURL = + /(^|\.)doubao\.com$/i.test(host) || + /(^|\.)bytedance\.com$/i.test(host) || + /(redirect|jump|out|target|link|source|open)/i.test(url.pathname) + if (!likelyRedirectURL) { + return null + } + + for (const key of [ + 'url', + 'target', + 'target_url', + 'targetUrl', + 'u', + 'redirect', + 'redirect_url', + 'redirectUrl', + 'link', + 'href', + 'source', + 'source_url', + 'sourceUrl', + ]) { + const paramValue = url.searchParams.get(key) + if (!paramValue) { + continue + } + const normalized = normalizeSourceUrl(paramValue, depth + 1) + if (normalized) { + return normalized + } + } + + return null +} + +function normalizeSourceUrl(value: string | null, depth = 0): string | null { + if (depth > 3) { + return null + } + const input = normalizeText(value) if (!input || !looksLikeSourceUrl(input)) { return null } + const candidates = [input] try { - const url = new URL(/^(https?:)?\/\//i.test(input) ? input : `https://${input}`) - url.hash = '' - return url.toString() + const decoded = decodeURIComponent(input) + if (decoded && decoded !== input) { + candidates.push(decoded) + } } catch { - return null + // Keep the original candidate when the value is not URI-encoded. } + + for (const candidate of candidates) { + try { + const url = new URL(/^(https?:)?\/\//i.test(candidate) ? candidate : `https://${candidate}`) + const embeddedURL = extractDoubaoEmbeddedSourceUrl(url, depth + 1) + if (embeddedURL) { + return embeddedURL + } + url.hash = '' + return url.toString() + } catch { + const match = candidate.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0] + if (match && match !== candidate) { + const normalized = normalizeSourceUrl(match, depth + 1) + if (normalized) { + return normalized + } + } + } + } + + return null } function normalizeSourceHost(value: string | null | undefined): string | null { @@ -496,7 +682,7 @@ function buildSourceItem(input: unknown): MonitoringSourceItem | null { return { url, - title: getDirectString(input, [ + title: getDirectSourceMetadataString(input, [ 'title', 'name', 'cited_title', @@ -504,16 +690,21 @@ function buildSourceItem(input: unknown): MonitoringSourceItem | null { 'display_title', 'page_name', ]), - site_name: getDirectString(input, [ + site_name: getDirectSourceMetadataString(input, [ 'site_name', 'siteName', + 'sitename', 'source', + 'source_name', + 'sourceName', 'platform_name', + 'platformName', 'domain', 'site', 'host_name', + 'hostName', ]), - site_key: getDirectString(input, ['site_key', 'siteKey', 'domain_key']), + site_key: getDirectSourceMetadataString(input, ['site_key', 'siteKey', 'domain_key']), normalized_url: url, host, resolution_status: getDirectString(input, ['resolution_status', 'resolutionStatus']), @@ -676,6 +867,34 @@ function collectAnswerFragments( return result } +function appendDoubaoChunkDeltaAnswer( + summary: DoubaoStreamSummary, + payload: unknown, +): void { + const text = + isRecord(payload) && typeof payload.text === 'string' ? normalizeOptionalString(payload.text) : null + if (text) { + summary.answer = mergeAnswerText(summary.answer, text) + } +} + +function applyDoubaoReplyEnd(summary: DoubaoStreamSummary, payload: unknown): void { + summary.reply_end_event_count += 1 + if (!isRecord(payload)) { + return + } + + const endType = payload.end_type + if ( + endType === 2 || + endType === '2' || + isRecord(payload.answer_finish_attr) || + isRecord(payload.msg_finish_attr) + ) { + summary.answer_finish_event_count += 1 + } +} + function parseSSERecord(record: string): DoubaoStreamEvent | null { const lines = record.split(/\r?\n/) let eventName = 'message' @@ -746,6 +965,52 @@ function isDoubaoRetryableErrorMessage(message: string | null | undefined): bool ) } +function formatUnknownError(error: unknown): string { + if (error instanceof Error) { + return error.message || error.name + } + if (typeof error === 'string') { + return error + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function isDoubaoExecutionContextNavigationError(error: unknown): boolean { + const message = formatUnknownError(error).toLowerCase() + return ( + message.includes('execution context was destroyed') || + message.includes('most likely because of a navigation') || + message.includes('context was destroyed') || + message.includes('frame was detached') || + message.includes('navigation') + ) +} + +function isDoubaoRecoverablePageError(error: string): boolean { + return error === 'doubao_query_timeout' || error === 'doubao_page_navigation_interrupted' +} + +function buildDoubaoPageNavigationResult(page: PlaywrightPage, detail: string): DoubaoPageQueryFailureResult { + return { + ok: false, + error: 'doubao_page_navigation_interrupted', + detail, + url: safePageURL(page) || null, + title: null, + modelLabel: null, + modeLabel: null, + thinkingModeApplied: null, + deepThinkEnabled: null, + domAnswer: null, + domReasoning: null, + domLinks: [], + } +} + function buildDoubaoUnknownResult( code: string, summary: string, @@ -824,6 +1089,9 @@ function parseDoubaoStreamBody(body: string): DoubaoStreamSummary { conversation_id: null, search_results: [], event_count: 0, + full_message_event_count: 0, + reply_end_event_count: 0, + answer_finish_event_count: 0, error_message: null, } @@ -845,8 +1113,16 @@ function parseDoubaoStreamBody(body: string): DoubaoStreamSummary { continue } + const eventName = parsed.event.toUpperCase() summary.event_count += 1 - if (parsed.event.toUpperCase().includes('ERROR')) { + if (eventName === 'FULL_MSG_NOTIFY') { + summary.full_message_event_count += 1 + } + if (eventName === DOUBAO_REPLY_END_EVENT) { + applyDoubaoReplyEnd(summary, parsed.payload) + continue + } + if (eventName.includes('ERROR')) { summary.error_message = summary.error_message ?? formatDoubaoStreamError(parsed.payload) continue } @@ -872,7 +1148,12 @@ function parseDoubaoStreamBody(body: string): DoubaoStreamSummary { ]) } - if (ANSWER_EVENT_WHITELIST.has(parsed.event.toUpperCase())) { + if (eventName === DOUBAO_CHUNK_DELTA_EVENT) { + appendDoubaoChunkDeltaAnswer(summary, parsed.payload) + continue + } + + if (ANSWER_EVENT_WHITELIST.has(eventName)) { const fragments = collectAnswerFragments(parsed.payload) for (const fragment of fragments) { if (fragment.kind === 'answer') { @@ -899,6 +1180,9 @@ function mergeStreamSummaries(summaries: DoubaoStreamSummary[]): DoubaoStreamSum conversation_id: null, search_results: [], event_count: 0, + full_message_event_count: 0, + reply_end_event_count: 0, + answer_finish_event_count: 0, error_message: null, } @@ -914,6 +1198,9 @@ function mergeStreamSummaries(summaries: DoubaoStreamSummary[]): DoubaoStreamSum combined.conversation_id = combined.conversation_id ?? summary.conversation_id combined.search_results.push(...summary.search_results) combined.event_count += summary.event_count + combined.full_message_event_count += summary.full_message_event_count + combined.reply_end_event_count += summary.reply_end_event_count + combined.answer_finish_event_count += summary.answer_finish_event_count combined.error_message = combined.error_message ?? summary.error_message } @@ -1135,6 +1422,8 @@ function parseDoubaoCaptures(captures: DoubaoCaptureRecord[]): DoubaoStreamSumma (capture.url.includes(DOUBAO_STREAM_URL_FRAGMENT) || capture.body.includes('STREAM_CHUNK') || capture.body.includes('STREAM_MSG_NOTIFY') || + capture.body.includes(DOUBAO_CHUNK_DELTA_EVENT) || + capture.body.includes(DOUBAO_REPLY_END_EVENT) || capture.body.includes('FULL_MSG_NOTIFY') || /"block_type"\s*:\s*10000/.test(capture.body)), ) @@ -1156,6 +1445,9 @@ function parseDoubaoCaptures(captures: DoubaoCaptureRecord[]): DoubaoStreamSumma conversation_id: null, search_results: [], event_count: 0, + full_message_event_count: 0, + reply_end_event_count: 0, + answer_finish_event_count: 0, error_message: captureError, } satisfies DoubaoStreamSummary }) @@ -1173,24 +1465,61 @@ function summarizeCapturesForDebug(captures: DoubaoCaptureRecord[]): JsonValue[] })) } +function doubaoModeProviderModel(mode: DoubaoAnswerModeKind | null): string | null { + switch (mode) { + case 'fast': + return 'doubao-web-fast' + case 'thinking': + return 'doubao-web-thinking' + case 'expert': + return 'doubao-web-expert' + default: + return null + } +} + +function normalizeDoubaoModeLabel(value: string | null): DoubaoAnswerModeKind | null { + switch (normalizeText(value)) { + case '快速': + return 'fast' + case '思考': + return 'thinking' + case '专家': + return 'expert' + default: + return null + } +} + function resolveProviderModel(pageResult: DoubaoPageQueryResult): string { + const modeModel = doubaoModeProviderModel(normalizeDoubaoModeLabel(pageResult.modeLabel)) + if (modeModel) { + return modeModel + } const label = normalizeText(pageResult.modelLabel) if (label) { return label } + if (pageResult.thinkingModeApplied) { + return 'doubao-web-thinking' + } 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 doubaoQueryInPage = async ( + parameters: DoubaoPageQueryParameters, +): Promise => { + const { + questionText, + timeoutMs, + readyTimeoutMs, + enableDeepThink, + submitQuestion = true, + } = parameters + const stablePollsRequired = 8 + const pollIntervalMs = 1_500 + const incompleteAnswerGraceMs = 30_000 + const minimumAnswerSettleMs = 10_000 const wait = (timeMs: number) => new Promise((resolve) => { @@ -1317,7 +1646,9 @@ const doubaoQueryInPage = async (parameters: { const hasBusyIndicator = (): boolean => { const text = bodyText() ?? '' - return /停止生成|停止输出|正在思考|正在搜索|思考中|搜索中|生成中|回答中/.test(text) + return /停止生成|停止输出|停止回答|正在思考|正在搜索|思考中|搜索中|生成中|回答中|正在回答|继续生成/.test( + text, + ) } const summarizeFailureDetail = (raw: string | null): string | null => { @@ -1449,6 +1780,299 @@ const doubaoQueryInPage = async (parameters: { return candidates[0]?.element ?? null } + type AnswerModeKind = 'fast' | 'thinking' | 'expert' + + type AnswerModeSnapshot = { + kind: AnswerModeKind | null + label: string | null + } + + const ANSWER_MODE_LABELS: Record = { + fast: '快速', + thinking: '思考', + expert: '专家', + } + + const answerModeKindFromLabel = (label: string): AnswerModeKind | null => { + if (label === ANSWER_MODE_LABELS.fast) { + return 'fast' + } + if (label === ANSWER_MODE_LABELS.thinking) { + return 'thinking' + } + if (label === ANSWER_MODE_LABELS.expert) { + return 'expert' + } + return null + } + + const classifyAnswerModeText = (value: unknown): AnswerModeKind | null => { + const multiline = normalizeMultiline(typeof value === 'string' ? value : null) + const normalized = multiline ?? normalize(value) + if (!normalized || /深度思考|正在思考|思考中/.test(normalized)) { + return null + } + + const lines = normalized + .split(/\n+/) + .map((line) => normalize(line.replace(/[✓✔√›>⌄⌃⌵▾▽]/g, '')) ?? '') + .filter(Boolean) + for (const line of lines) { + const exact = answerModeKindFromLabel(line) + if (exact) { + return exact + } + } + + const compact = lines.join('').replace(/\s+/g, '') + if (!compact) { + return null + } + + const labelHits = Object.values(ANSWER_MODE_LABELS).filter((label) => compact.includes(label)) + if (labelHits.length !== 1) { + return null + } + + const label = labelHits[0] + if (!label) { + return null + } + if (compact.startsWith(label)) { + return answerModeKindFromLabel(label) + } + return null + } + + const readElementAnswerMode = (element: Element | null | undefined): AnswerModeSnapshot => { + if (!(element instanceof HTMLElement)) { + return { kind: null, label: null } + } + + const values = [ + normalizeMultiline(element.innerText), + normalize(element.textContent), + normalize(element.getAttribute('aria-label')), + normalize(element.getAttribute('title')), + normalize(element.getAttribute('data-testid')), + ].filter((value): value is string => Boolean(value)) + + for (const value of values) { + const kind = classifyAnswerModeText(value) + if (kind) { + return { kind, label: ANSWER_MODE_LABELS[kind] } + } + } + + return { kind: null, label: null } + } + + const resolveClickableModeElement = (element: HTMLElement): HTMLElement => { + const clickable = element.closest( + "button, [role='button'], [role='option'], [role='menuitem'], [aria-haspopup], [tabindex]", + ) + return clickable instanceof HTMLElement ? clickable : element + } + + const clickModeElement = (element: HTMLElement): void => { + const target = resolveClickableModeElement(element) + target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })) + target.click() + } + + const pressEscape = (): void => { + const init: KeyboardEventInit = { + bubbles: true, + cancelable: true, + key: 'Escape', + code: 'Escape', + keyCode: 27, + which: 27, + } + document.dispatchEvent(new KeyboardEvent('keydown', init)) + document.body?.dispatchEvent(new KeyboardEvent('keydown', init)) + } + + const findAnswerModeTrigger = ( + 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 candidates: Array<{ element: HTMLElement; score: number }> = [] + const seen = new Set() + + for (const scope of scopes) { + const nodes = Array.from( + scope.querySelectorAll( + "button, [role='button'], [aria-haspopup], [tabindex], label, div, span", + ), + ) + + for (const node of nodes) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { + continue + } + seen.add(node) + + const mode = readElementAnswerMode(node) + if (!mode.kind || isDisabled(resolveClickableModeElement(node))) { + continue + } + + const text = normalizeMultiline(node.innerText) ?? '' + const rect = node.getBoundingClientRect() + if (rect.width < 32 || rect.height < 20 || rect.width > 320 || rect.height > 96) { + continue + } + if (/擅长解决更难的问题|适用于大部分情况|研究级智能模型/.test(text)) { + continue + } + + let score = 500 + const hint = hintText(node) + if (/mode|model|类型|模式|模型/.test(hint)) { + score += 500 + } + if (text === mode.label) { + score += 700 + } + if (composerRect) { + if (rect.top < composerRect.top - 180 || rect.top > composerRect.bottom + 180) { + continue + } + const dx = Math.abs(rect.left - composerRect.left) + const dy = Math.abs(rect.top - composerRect.top) + score += Math.max(0, 1_800 - dx * 2 - dy * 4) + if (rect.left <= composerRect.left + 260) { + score += 360 + } + } + + candidates.push({ element: resolveClickableModeElement(node), score }) + } + } + + candidates.sort((left, right) => right.score - left.score) + return candidates[0]?.element ?? null + } + + const findAnswerModeOption = ( + targetKind: AnswerModeKind, + trigger: HTMLElement | null, + ): HTMLElement | null => { + const triggerRect = trigger?.getBoundingClientRect() ?? null + const nodes = Array.from( + document.querySelectorAll( + "button, [role='button'], [role='option'], [role='menuitem'], [tabindex], li, div, span", + ), + ) + const candidates: Array<{ element: HTMLElement; score: number }> = [] + const seen = new Set() + + for (const node of nodes) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { + continue + } + seen.add(node) + + const mode = readElementAnswerMode(node) + if (mode.kind !== targetKind) { + continue + } + const clickable = resolveClickableModeElement(node) + if (isDisabled(clickable)) { + continue + } + + const text = normalizeMultiline(node.innerText) ?? normalize(node.textContent) ?? '' + const rect = node.getBoundingClientRect() + if (rect.width < 48 || rect.height < 24 || rect.width > 560 || rect.height > 180) { + continue + } + if (text.length > 180) { + continue + } + + let score = 500 + if (/擅长解决更难的问题/.test(text)) { + score += 1_000 + } + if (text === ANSWER_MODE_LABELS[targetKind]) { + score += 500 + } + const role = normalize(node.getAttribute('role')) ?? '' + if (/option|menuitem|button/.test(role)) { + score += 240 + } + if (triggerRect) { + const dx = Math.abs(rect.left - triggerRect.left) + const dy = Math.abs(rect.top - triggerRect.top) + score += Math.max(0, 1_600 - dx * 2 - dy * 2) + } + + candidates.push({ element: clickable, score }) + } + + candidates.sort((left, right) => right.score - left.score) + return candidates[0]?.element ?? null + } + + const readCurrentAnswerMode = ( + shell: HTMLElement | null, + composer: HTMLElement | null, + ): AnswerModeSnapshot => { + const trigger = findAnswerModeTrigger(shell, composer) + return readElementAnswerMode(trigger) + } + + const ensureThinkingAnswerMode = async ( + shell: HTMLElement | null, + composer: HTMLElement | null, + ): Promise => { + const initialTrigger = findAnswerModeTrigger(shell, composer) + const initialMode = readElementAnswerMode(initialTrigger) + if (initialMode.kind === 'thinking') { + return { ...initialMode, applied: true } + } + if (!initialTrigger) { + return { ...initialMode, applied: null } + } + + clickModeElement(initialTrigger) + await wait(260) + + const thinkingOption = findAnswerModeOption('thinking', initialTrigger) + if (!thinkingOption) { + pressEscape() + await wait(120) + const current = readCurrentAnswerMode(shell, composer) + return { ...current, applied: current.kind === 'thinking' ? true : null } + } + + clickModeElement(thinkingOption) + await wait(360) + + const current = readCurrentAnswerMode(shell, composer) + return { + ...current, + applied: current.kind === 'thinking' ? true : null, + } + } + const setComposerText = (composer: HTMLElement, text: string) => { composer.focus() @@ -1591,18 +2215,87 @@ const doubaoQueryInPage = async (parameters: { composer.dispatchEvent(new KeyboardEvent('keyup', eventInit)) } - const normalizeHref = (value: string | null | undefined): string | null => { + const normalizeHref = (value: string | null | undefined, depth = 0): string | null => { + if (depth > 3) { + return null + } + const trimmed = normalize(value) if (!trimmed) { return null } + + const candidates = [trimmed] try { - const url = new URL(trimmed, window.location.href) - url.hash = '' - return url.toString() + const decoded = decodeURIComponent(trimmed) + if (decoded && decoded !== trimmed) { + candidates.push(decoded) + } } catch { + // Keep the original candidate when the value is not URI-encoded. + } + + const unwrapEmbeddedURL = (url: URL): string | null => { + const likelyRedirectURL = + url.hostname === window.location.hostname || + /(^|\.)doubao\.com$/i.test(url.hostname) || + /(^|\.)bytedance\.com$/i.test(url.hostname) || + /(redirect|jump|out|target|link|source|open)/i.test(url.pathname) + if (!likelyRedirectURL) { + return null + } + + for (const key of [ + 'url', + 'target', + 'target_url', + 'targetUrl', + 'u', + 'redirect', + 'redirect_url', + 'redirectUrl', + 'link', + 'href', + 'source', + 'source_url', + 'sourceUrl', + ]) { + const paramValue = url.searchParams.get(key) + if (!paramValue) { + continue + } + const normalized = normalizeHref(paramValue, depth + 1) + if (normalized) { + return normalized + } + } return null } + + for (const candidate of candidates) { + try { + const url = new URL(candidate, window.location.href) + const embeddedURL = unwrapEmbeddedURL(url) + if (embeddedURL) { + return embeddedURL + } + if (!/^https?:$/i.test(url.protocol)) { + continue + } + url.hash = '' + return url.toString() + } catch { + const match = candidate.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0] + if (match && match !== candidate) { + const normalized = normalizeHref(match, depth + 1) + if (normalized) { + return normalized + } + } + } + } + + return null } type ConversationSnapshot = { @@ -1694,13 +2387,18 @@ const doubaoQueryInPage = async (parameters: { const answer = normalizeMultiline(snapshot.answerText) const reasoning = normalizeMultiline(snapshot.reasoningText) + const changedContentAge = now - firstChangedContentAt if (!answer) { return ( Boolean(reasoning || snapshot.links.length > 0) && - now - firstChangedContentAt >= incompleteAnswerGraceMs + changedContentAge >= incompleteAnswerGraceMs ) } + if (changedContentAge < minimumAnswerSettleMs) { + return false + } + if ( answer.length >= 96 || countMatches(answer, /[。!?;:]/g) >= 2 || @@ -1712,7 +2410,7 @@ const doubaoQueryInPage = async (parameters: { return true } - return now - firstChangedContentAt >= incompleteAnswerGraceMs + return changedContentAge >= incompleteAnswerGraceMs } const isExternalHref = (value: string | null): boolean => { @@ -1749,10 +2447,11 @@ const doubaoQueryInPage = async (parameters: { continue } seen.add(href) + const visibleText = normalize(node.innerText) ?? normalize(node.textContent) links.push({ url: href, - title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')), - text: normalize(node.innerText) ?? normalize(node.textContent), + title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')) ?? visibleText, + text: visibleText, siteName: normalize(node.getAttribute('data-site-name')) ?? normalize(node.dataset.siteName ?? null), @@ -1761,6 +2460,75 @@ const doubaoQueryInPage = async (parameters: { return links } + const mergeLinks = (...groups: DoubaoDomLink[][]): DoubaoDomLink[] => { + const keyed = new Map() + for (const group of groups) { + for (const item of group) { + const href = normalizeHref(item.url) + if (!href || !isExternalHref(href)) { + continue + } + const existing = keyed.get(href) + if (!existing) { + keyed.set(href, { ...item, url: href }) + continue + } + keyed.set(href, { + ...existing, + title: existing.title ?? item.title ?? null, + text: existing.text ?? item.text ?? null, + siteName: existing.siteName ?? item.siteName ?? null, + }) + } + } + return Array.from(keyed.values()) + } + + const expandReferenceControls = (): void => { + for (const node of Array.from( + document.querySelectorAll("button, [role='button'], a, div, span"), + ).slice(0, 1600)) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + const text = normalize(node.innerText) ?? normalize(node.textContent) + if (!text || !/^(?:展开更多|查看更多|查看全部|更多|展开)$/i.test(text)) { + continue + } + + const nextParent = (element: HTMLElement): HTMLElement | null => + element.parentNode instanceof HTMLElement ? element.parentNode : null + let current: HTMLElement | null = node + let clicked = false + for (let depth = 0; current && depth < 5; depth += 1) { + const element: HTMLElement = current + if (!isVisible(element)) { + current = nextParent(element) + continue + } + const tagName = element.tagName.toLowerCase() + const role = normalize(element.getAttribute('role')) + const style = window.getComputedStyle(element) + const clickable = + tagName === 'button' || + tagName === 'a' || + role === 'button' || + element.tabIndex >= 0 || + style.cursor === 'pointer' || + typeof (element as { onclick?: unknown }).onclick === 'function' + if (clickable) { + element.click() + clicked = true + break + } + current = nextParent(element) + } + if (!clicked) { + node.click() + } + } + } + const normalizedQuestion = normalizeMultiline(questionText) // Tokens that only appear on Doubao's empty-chat surface (prompt templates, nav, feature chips). @@ -1818,6 +2586,9 @@ const doubaoQueryInPage = async (parameters: { if (/^(内容由AI生成|内容由 AI 生成)/.test(line)) { return false } + if (/^(已深度思考|思考完成|查看\d+篇资料|参考资料|资料来源|展开|收起)$/.test(line)) { + return false + } return true }) const joined = kept.join('\n').trim() @@ -1858,11 +2629,21 @@ const doubaoQueryInPage = async (parameters: { } const snapshotConversation = (composerTop: number | null): ConversationSnapshot => { + expandReferenceControls() + 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')) + const candidates: Array<{ + element: HTMLElement + score: number + text: string | null + top: number + left: number + }> = [] + const elements = Array.from( + document.querySelectorAll('article, section, div, main, li, p, h1, h2, h3, h4, h5, h6, pre'), + ) for (const node of elements) { if (!(node instanceof HTMLElement) || !isVisible(node)) { @@ -1928,11 +2709,60 @@ const doubaoQueryInPage = async (parameters: { continue } - candidates.push({ element: node, score }) + candidates.push({ + element: node, + score, + text, + top: rect.top, + left: rect.left, + }) } candidates.sort((left, right) => right.score - left.score) const best = candidates[0]?.element ?? null + const orderedCandidates = [...candidates].sort((left, right) => { + if (Math.abs(left.top - right.top) > 2) { + return left.top - right.top + } + return left.left - right.left + }) + + const leafTextBlocks = orderedCandidates.filter((candidate) => { + const candidateText = candidate.text + if (!candidateText) { + return false + } + return !orderedCandidates.some((other) => { + if (other === candidate || !other.text) { + return false + } + if (!candidate.element.contains(other.element)) { + return false + } + return candidateText === other.text || candidateText.includes(other.text) + }) + }) + + const joinedTextBlocks: string[] = [] + for (const block of leafTextBlocks) { + const text = block.text + if (!text) { + continue + } + const alreadyIncluded = joinedTextBlocks.some( + (existing) => existing === text || existing.includes(text), + ) + if (alreadyIncluded) { + continue + } + const containingIndex = joinedTextBlocks.findIndex((existing) => text.includes(existing)) + if (containingIndex >= 0) { + joinedTextBlocks.splice(containingIndex, 1, text) + continue + } + joinedTextBlocks.push(text) + } + const joinedAnswerText = stripKnownNoise(joinedTextBlocks.join('\n')) let answerText: string | null = null let reasoningText: string | null = null @@ -1953,15 +2783,32 @@ const doubaoQueryInPage = async (parameters: { reasoningText = stripKnownNoise(normalizeMultiline(reasoningNode.innerText)) } answerText = stripKnownNoise(normalizeMultiline(best.innerText)) + if (joinedAnswerText && joinedAnswerText.length > (answerText?.length ?? 0)) { + answerText = joinedAnswerText + } if (reasoningText && answerText && answerText.startsWith(reasoningText)) { const trimmedAnswer = answerText.slice(reasoningText.length).trim() if (trimmedAnswer) { answerText = trimmedAnswer } } + } else { + answerText = joinedAnswerText } - const links = best ? collectLinks(best) : [] + const scopedReferenceLinks = mergeLinks( + ...orderedCandidates + .filter((candidate) => { + if (!candidate.text) { + return true + } + return /参考|资料|来源|搜索|引用|网页|搜索\s*\d+\s*个关键词|查看\d+篇资料/i.test( + candidate.text, + ) + }) + .map((candidate) => collectLinks(candidate.element)), + ) + const links = mergeLinks(best ? collectLinks(best) : [], scopedReferenceLinks) const signature = `${answerText ?? ''}|${reasoningText ?? ''}|${links.map((item) => item.url).join('|')}`.slice( -2_000, @@ -1980,12 +2827,20 @@ const doubaoQueryInPage = async (parameters: { error: string, detail: string | null, composerTop: number | null, + modeSnapshot: AnswerModeSnapshot & { applied?: boolean | null } = { + kind: null, + label: null, + applied: null, + }, snapshotOverride: ConversationSnapshot | null = null, ): DoubaoPageQueryFailureResult => { const snapshot = snapshotOverride ?? snapshotConversation(composerTop) const composer = findComposer() const shell = findComposerShell(composer) const deepThinkButton = findInteractiveByText(/深度思考/, shell) + const currentMode = readCurrentAnswerMode(shell, composer) + const modeLabel = currentMode.label ?? modeSnapshot.label + const modeKind = currentMode.kind ?? modeSnapshot.kind return { ok: false, error, @@ -1996,6 +2851,9 @@ const doubaoQueryInPage = async (parameters: { url: window.location.href || null, title: normalize(document.title), modelLabel: resolveModelLabel(), + modeLabel, + thinkingModeApplied: + modeKind === 'thinking' ? true : (modeSnapshot.applied ?? null), deepThinkEnabled: toggleState(deepThinkButton), domAnswer: snapshot.answerText, domReasoning: snapshot.reasoningText, @@ -2011,7 +2869,11 @@ const doubaoQueryInPage = async (parameters: { } if (hasLoginGate()) { - return fail('doubao_login_required', bodyText(), composer?.getBoundingClientRect().top ?? null) + return fail( + 'doubao_login_required', + bodyText(), + composer?.getBoundingClientRect().top ?? null, + ) } if (!composer) { @@ -2020,6 +2882,9 @@ const doubaoQueryInPage = async (parameters: { let composerShell = findComposerShell(composer) const composerTop = composer.getBoundingClientRect().top + let answerMode = await ensureThinkingAnswerMode(composerShell, composer) + composer = findComposer() ?? composer + composerShell = findComposerShell(composer) ?? composerShell let deepThinkApplied: boolean | null = null if (enableDeepThink) { @@ -2034,29 +2899,33 @@ const doubaoQueryInPage = async (parameters: { } } - setComposerText(composer, questionText) - await wait(220) + if (submitQuestion) { + setComposerText(composer, questionText) + await wait(220) - composer = findComposer() ?? composer - composerShell = findComposerShell(composer) ?? composerShell - const sendButton = findSendButton(composerShell, composer) - if (sendButton) { - sendButton.click() + composer = findComposer() ?? composer + composerShell = findComposerShell(composer) ?? composerShell + const sendButton = findSendButton(composerShell, composer) + if (sendButton) { + sendButton.click() + } else { + dispatchEnter(composer) + } + + await wait(600) } else { - dispatchEnter(composer) + await wait(800) } - 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 + let stableCount = submitQuestion ? 0 : hasObservedContent(initialSnapshot) ? 1 : 0 + let sawChange = !submitQuestion && hasObservedContent(initialSnapshot) + let firstChangedContentAt: number | null = sawChange ? Date.now() : null const deadline = Date.now() + timeoutMs while (Date.now() <= deadline) { @@ -2086,6 +2955,7 @@ const doubaoQueryInPage = async (parameters: { 'doubao_login_expired', bodyText(), composerTop, + answerMode, bestContentSnapshot ?? bestSnapshot, ) } @@ -2100,11 +2970,19 @@ const doubaoQueryInPage = async (parameters: { shouldReturnStableAnswer(finalSnapshot, firstChangedContentAt, Date.now()) ) { const deepThinkButton = findInteractiveByText(/深度思考/, composerShell) + const currentMode = readCurrentAnswerMode(composerShell, composer) + answerMode = { + kind: currentMode.kind ?? answerMode.kind, + label: currentMode.label ?? answerMode.label, + applied: (currentMode.kind ?? answerMode.kind) === 'thinking' ? true : answerMode.applied, + } return { ok: true, url: window.location.href, title: normalize(document.title), modelLabel: resolveModelLabel(), + modeLabel: answerMode.label, + thinkingModeApplied: answerMode.applied, deepThinkEnabled: toggleState(deepThinkButton) ?? deepThinkApplied, domAnswer: finalSnapshot.answerText, domReasoning: finalSnapshot.reasoningText, @@ -2115,7 +2993,13 @@ const doubaoQueryInPage = async (parameters: { await wait(pollIntervalMs) } - return fail('doubao_query_timeout', bodyText(), composerTop, bestContentSnapshot ?? bestSnapshot) + return fail( + 'doubao_query_timeout', + bodyText(), + composerTop, + answerMode, + bestContentSnapshot ?? bestSnapshot, + ) } export const doubaoAdapter: MonitorAdapter = { @@ -2141,12 +3025,23 @@ export const doubaoAdapter: MonitorAdapter = { try { context.reportProgress('doubao.query') - pageResult = await page.evaluate(doubaoQueryInPage, { - questionText, - timeoutMs: DOUBAO_QUERY_TIMEOUT_MS, - readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS, - enableDeepThink: true, - }) + try { + pageResult = await page.evaluate(doubaoQueryInPage, { + questionText, + timeoutMs: DOUBAO_QUERY_TIMEOUT_MS, + readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS, + enableDeepThink: true, + }) + } catch (error) { + if (!isDoubaoExecutionContextNavigationError(error)) { + throw error + } + await page.waitForLoadState('domcontentloaded', { timeout: DOUBAO_PAGE_READY_TIMEOUT_MS }).catch( + () => undefined, + ) + await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined) + pageResult = buildDoubaoPageNavigationResult(page, formatUnknownError(error)) + } await sleep(600, context.signal).catch(() => undefined) await capture.flush(DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS) } finally { @@ -2168,12 +3063,19 @@ export const doubaoAdapter: MonitorAdapter = { .filter((item): item is MonitoringSourceItem => item !== null), ) const searchResults = dedupeSourceItems([...streamSummary.search_results, ...domSearchResults]) - const answer = selectBestDoubaoText(streamSummary.answer, pageResult.domAnswer) + const answer = normalizeOptionalString(streamSummary.answer) 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 streamSawAnswerFinish = streamSummary.answer_finish_event_count > 0 + const pageRecoverableError = + pageResult.ok === false && isDoubaoRecoverablePageError(pageResult.error) + const submission = resolveDoubaoSubmissionAnswer({ + answer, + }) + const hasRecoveredAnswer = Boolean(submission.answer) + const hasRecoveredContent = hasRecoveredAnswer || searchResults.length > 0 const retryableStreamError = isDoubaoRetryableErrorMessage(streamSummary.error_message) if (pageResult.ok === false) { @@ -2189,7 +3091,33 @@ export const doubaoAdapter: MonitorAdapter = { } } - if (failed.error !== 'doubao_query_timeout' || !hasRecoveredContent) { + if (isDoubaoRecoverablePageError(failed.error) && !hasRecoveredAnswer) { + return { + status: 'unknown', + summary: searchResults.length + ? '豆包未从 SSE 拿到完整答案,仅抓到参考资料,已回写 unknown 等待后续补采。' + : '豆包未从 SSE 拿到完整答案全文,且兜底未抓到思维链参考资料,已回写 unknown 等待后续补采。', + error: buildAdapterError(failed.error, detail, { + page_url: failed.url ?? safePageURL(page), + answer_present: Boolean(answer), + answer_length: answer?.length ?? 0, + reasoning_present: Boolean(reasoning), + reasoning_length: reasoning?.length ?? 0, + dom_answer_length: failed.domAnswer?.length ?? 0, + dom_reasoning_length: failed.domReasoning?.length ?? 0, + stream_answer_length: streamSummary.answer?.length ?? 0, + search_result_count: searchResults.length, + dom_search_result_count: domSearchResults.length, + stream_search_result_count: streamSummary.search_results.length, + full_message_event_count: streamSummary.full_message_event_count, + reply_end_event_count: streamSummary.reply_end_event_count, + answer_finish_event_count: streamSummary.answer_finish_event_count, + stream_event_count: streamSummary.event_count, + }), + } + } + + if (!isDoubaoRecoverablePageError(failed.error) || !hasRecoveredContent) { if (streamSummary.error_message) { if (retryableStreamError) { if (!hasRecoveredContent) { @@ -2240,11 +3168,38 @@ export const doubaoAdapter: MonitorAdapter = { } } - if (!hasRecoveredContent) { + if (!hasRecoveredAnswer) { return { status: 'unknown', - summary: '豆包返回为空,已回写 unknown 等待后续对账。', - error: buildAdapterError('doubao_empty_response', 'doubao returned no answer or sources'), + summary: searchResults.length + ? '豆包未从正文或思维链兜底抓到可提交答案,仅抓到参考资料,已回写 unknown 等待后续补采。' + : '豆包返回为空,且兜底未抓到思维链参考资料,已回写 unknown 等待后续对账。', + error: buildAdapterError('doubao_empty_response', 'doubao returned no answer', { + page_url: pageResult.url ?? safePageURL(page), + reasoning_present: Boolean(reasoning), + reasoning_length: reasoning?.length ?? 0, + search_result_count: searchResults.length, + dom_search_result_count: domSearchResults.length, + stream_search_result_count: streamSummary.search_results.length, + }), + } + } + + if (!streamSawAnswerFinish) { + return { + status: 'unknown', + summary: '豆包未通过 SSE 确认回答已生成全文,已回写 unknown 等待后续补采。', + error: buildAdapterError('doubao_incomplete_response', 'doubao response did not finish', { + page_url: pageResult.url, + answer_length: answer?.length ?? 0, + dom_answer_length: pageResult.domAnswer?.length ?? 0, + stream_answer_length: streamSummary.answer?.length ?? 0, + search_result_count: searchResults.length, + full_message_event_count: streamSummary.full_message_event_count, + reply_end_event_count: streamSummary.reply_end_event_count, + answer_finish_event_count: streamSummary.answer_finish_event_count, + stream_event_count: streamSummary.event_count, + }), } } @@ -2258,7 +3213,7 @@ export const doubaoAdapter: MonitorAdapter = { provider_request_id: streamSummary.provider_request_id, request_id: requestID, conversation_id: conversationID, - answer, + answer: submission.answer, reasoning, citation_count: 0, search_result_count: searchResults.length, @@ -2269,19 +3224,28 @@ export const doubaoAdapter: MonitorAdapter = { page_url: pageResult.url, page_title: pageResult.title, model_label: pageResult.modelLabel, + mode_label: pageResult.modeLabel, + thinking_mode_applied: pageResult.thinkingModeApplied, deep_think_enabled: pageResult.deepThinkEnabled, capture_count: capture.captures.length, capture_pending_after_flush: pendingCaptureCount, event_count: streamSummary.event_count, + full_message_event_count: streamSummary.full_message_event_count, + reply_end_event_count: streamSummary.reply_end_event_count, + answer_finish_event_count: streamSummary.answer_finish_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), + reasoning_fallback_used: false, + selected_answer_source: submission.source, stream_answer_length: streamSummary.answer?.length ?? 0, dom_answer_length: pageResult.domAnswer?.length ?? 0, - selected_answer_length: answer?.length ?? 0, + stream_reasoning_length: streamSummary.reasoning?.length ?? 0, + dom_reasoning_length: pageResult.domReasoning?.length ?? 0, + selected_answer_length: submission.answer?.length ?? 0, captures: captureDebug, }, }, @@ -2295,4 +3259,5 @@ export const __doubaoTestUtils = { isNonCitationAssetDomain, isNonCitationAssetUrl, parseDoubaoStreamBody, + resolveDoubaoSubmissionAnswer, }