import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types' import type { Page as PlaywrightPage, Response as PlaywrightResponse } from 'playwright-core' import type { MonitorAdapter } from './base' import { normalizeText } from './common' 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 = 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']) const SEARCH_RESULT_KEYS = new Set([ 'references', 'reference_list', 'referenceList', 'reference_cards', 'referenceCards', 'reference_docs', 'referenceDocs', 'sources', 'source_list', 'sourceList', 'source_cards', 'sourceCards', 'search_results', 'searchResults', 'search_result_list', 'searchResultList', 'search_refs', 'searchRefs', 'web_results', 'webResults', 'grounding', 'grounding_results', 'groundingResults', 'browse_results', 'browseResults', 'browse_references', 'browseReferences', ]) 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 payload: unknown } type DoubaoStreamSummary = { answer: string | null reasoning: string | null provider_request_id: string | null request_id: string | null 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 contentType: string | null body: string } type DoubaoPageQueryParameters = { questionText: string timeoutMs: number readyTimeoutMs: number enableDeepThink: boolean submitQuestion?: boolean } 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 modeLabel: string | null thinkingModeApplied: boolean | 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 modeLabel: string | null thinkingModeApplied: boolean | 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(/[\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 } 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 } 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 >= 1) { return true } return originalSuspicious > 0 && repairedSuspicious < originalSuspicious } 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(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 } const trimmed = repairDoubaoMojibake(value).trim() 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) } catch { return raw } } function getDirectString(record: Record, keys: string[]): string | null { for (const key of keys) { const value = normalizeOptionalString(record[key]) if (value) { return value } } 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 } if (Array.isArray(source)) { for (const item of source) { const found = findFirstStringByKey(item, keys, depth + 1) if (found) { return found } } return null } if (!isRecord(source)) { return null } for (const key of keys) { const direct = normalizeOptionalString(source[key]) if (direct) { return direct } } for (const value of Object.values(source)) { const found = findFirstStringByKey(value, keys, depth + 1) if (found) { return found } } return null } function readNestedString(source: unknown, path: string[]): string | null { let current: unknown = source for (const key of path) { if (!isRecord(current) || !(key in current)) { return null } current = current[key] } return normalizeOptionalString(current) } function mergeAnswerText(current: string | null, nextFragment: string | null): string | null { if (!nextFragment) { return current } if (!current) { return nextFragment } if (current === nextFragment || current.includes(nextFragment)) { return current } if (nextFragment.startsWith(current)) { return nextFragment } return `${current}${nextFragment}` } 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 Number.NEGATIVE_INFINITY } 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 (/[。!?.!?]$/.test(normalized)) { score += 80 } 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 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 } 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 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 decoded = decodeURIComponent(input) if (decoded && decoded !== input) { candidates.push(decoded) } } catch { // 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 { const input = normalizeText(value) if (!input) { return null } try { return new URL( /^(https?:)?\/\//i.test(input) ? input : `https://${input}`, ).hostname.toLowerCase() } catch { return ( input .replace(/^https?:\/\//i, '') .replace(/^\/\//, '') .split(/[/?#:]/)[0] ?.trim() .toLowerCase() || null ) } } function isNonCitationAssetDomain(value: string | null | undefined): boolean { const host = normalizeSourceHost(value) if (!host) { return false } if (DOUBAO_NON_CITATION_ASSET_DOMAINS.has(host)) { return true } return Array.from(DOUBAO_NON_CITATION_ASSET_DOMAINS).some((domain) => host.endsWith(`.${domain}`)) } function isNonCitationAssetUrl(value: string | null | undefined): boolean { const url = normalizeSourceUrl(value ?? null) ?? normalizeText(value) if (!url) { return false } try { return isNonCitationAssetDomain(new URL(url).hostname) } catch { return isNonCitationAssetDomain(url) } } 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) return url ? { url, normalized_url: url } : null } if (!isRecord(input)) { return null } const url = normalizeSourceUrl( getDirectString(input, [ 'url', 'link', 'uri', 'href', 'cited_url', 'target_url', 'targetUrl', 'source_url', 'sourceUrl', 'page_url', 'pageUrl', 'jump_url', 'jumpUrl', ]), ) if (!url) { return null } let host: string | null = null try { host = new URL(url).hostname || null } catch { host = null } if (isNonCitationAssetDomain(host) || isNonCitationAssetUrl(url)) { return null } return { url, title: getDirectSourceMetadataString(input, [ 'title', 'name', 'cited_title', 'page_title', 'display_title', 'page_name', ]), site_name: getDirectSourceMetadataString(input, [ 'site_name', 'siteName', 'sitename', 'source', 'source_name', 'sourceName', 'platform_name', 'platformName', 'domain', 'site', 'host_name', 'hostName', ]), site_key: getDirectSourceMetadataString(input, ['site_key', 'siteKey', 'domain_key']), normalized_url: url, host, resolution_status: getDirectString(input, ['resolution_status', 'resolutionStatus']), resolution_confidence: getDirectString(input, [ 'resolution_confidence', 'resolutionConfidence', ]), } } function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { const keyed = new Map() for (const item of items) { const key = normalizeSourceUrl(item.normalized_url ?? item.url) if (!key || isNonCitationAssetDomain(item.host) || isNonCitationAssetUrl(key)) { continue } const existing = keyed.get(key) if (!existing) { keyed.set(key, { ...item, normalized_url: key, }) continue } keyed.set(key, { ...existing, title: existing.title ?? item.title ?? null, site_name: existing.site_name ?? item.site_name ?? null, site_key: existing.site_key ?? item.site_key ?? null, host: existing.host ?? item.host ?? null, resolution_status: existing.resolution_status ?? item.resolution_status ?? null, resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null, }) } return Array.from(keyed.values()) } function collectSearchSources( payload: unknown, results: MonitoringSourceItem[], bucket: 'search' | null = null, depth = 0, ): void { if (depth > 12 || payload == null) { return } if (typeof payload === 'string') { if (!bucket) { return } const item = buildSourceItem(payload) if (item) { results.push(item) } return } if (Array.isArray(payload)) { for (const item of payload) { collectSearchSources(item, results, bucket, depth + 1) } return } if (!isRecord(payload)) { return } const directSourceItem = buildSourceItem(payload) if (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') { collectSearchSources(value, results, nextBucket, depth + 1) } } } type AnswerFragment = { text: string kind: 'answer' | 'reasoning' } type AnswerWalkContext = { inReasoning: boolean } function isReasoningIcon(value: unknown): boolean { return typeof value === 'string' && value.includes('Deep_Think') } function collectAnswerFragments( payload: unknown, result: AnswerFragment[] = [], context: AnswerWalkContext = { inReasoning: false }, ): AnswerFragment[] { if (payload == null) { return result } if (Array.isArray(payload)) { for (const item of payload) { collectAnswerFragments(item, result, context) } return result } if (!isRecord(payload)) { return result } let nextContext = context if (typeof payload.block_type === 'number' && payload.block_type !== 10000) { return result } if (isReasoningIcon((payload as { icon_url?: unknown }).icon_url)) { nextContext = { inReasoning: true } } const textBlock = payload.text_block if (isRecord(textBlock)) { const text = normalizeOptionalString(textBlock.text) if (text) { const reasoning = context.inReasoning || isReasoningIcon(textBlock.icon_url) result.push({ text, kind: reasoning ? 'reasoning' : 'answer' }) } } const deltaText = normalizeOptionalString(payload.delta) if (deltaText) { result.push({ text: deltaText, kind: nextContext.inReasoning ? 'reasoning' : 'answer' }) } const nestedDeltaText = readNestedString(payload, ['delta', 'text']) if (nestedDeltaText) { result.push({ text: nestedDeltaText, kind: nextContext.inReasoning ? 'reasoning' : 'answer' }) } const assistantText = (normalizeOptionalString(payload.role) === 'assistant' || normalizeOptionalString(payload.message_type) === 'assistant') && normalizeOptionalString(payload.text) if (assistantText) { result.push({ text: assistantText, kind: nextContext.inReasoning ? 'reasoning' : 'answer' }) } for (const [key, value] of Object.entries(payload)) { if (key === 'text_block') continue if (Array.isArray(value) || isRecord(value)) { collectAnswerFragments(value, result, nextContext) } } 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' const dataLines: string[] = [] for (const line of lines) { if (!line || line.startsWith(':')) { continue } if (line.startsWith('event:')) { eventName = line.slice(6).trim() || eventName continue } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()) } } if (!dataLines.length) { return null } return { event: eventName, payload: safeParseJSON(dataLines.join('\n')), } } function formatDoubaoStreamError(payload: unknown): string { const errorCode = findFirstStringByKey(payload, ['error_code', 'code']) const errorMessage = findFirstStringByKey(payload, ['error_msg', 'message', 'detail']) const verifyScene = findFirstStringByKey(payload, ['verify_scene']) if (verifyScene) { return `doubao verification required (${verifyScene})` } if (errorCode && errorMessage) { return `doubao stream error ${errorCode}: ${errorMessage}` } if (errorCode) { return `doubao stream error ${errorCode}` } if (errorMessage) { return `doubao stream error: ${errorMessage}` } return 'doubao stream error' } 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 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, 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, search_results: [], event_count: 0, full_message_event_count: 0, reply_end_event_count: 0, answer_finish_event_count: 0, error_message: null, } if (!body) { return summary } const normalized = body.replace(/\r\n/g, '\n') const records = normalized.split(/\n\n+/) for (const rawRecord of records) { const trimmed = rawRecord.trim() if (!trimmed) { continue } const parsed = parseSSERecord(trimmed) if (!parsed) { continue } const eventName = parsed.event.toUpperCase() summary.event_count += 1 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 } if (!summary.conversation_id) { summary.conversation_id = readNestedString(parsed.payload, ['ack_client_meta', 'conversation_id']) ?? readNestedString(parsed.payload, ['client_meta', 'conversation_id']) ?? findFirstStringByKey(parsed.payload, ['conversation_id']) } if (!summary.provider_request_id) { summary.provider_request_id = findFirstStringByKey(parsed.payload, [ 'log_id', 'request_id', 'req_id', ]) } if (!summary.request_id) { summary.request_id = findFirstStringByKey(parsed.payload, [ 'message_id', 'local_message_id', 'reply_id', ]) } 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') { summary.answer = mergeAnswerText(summary.answer, fragment.text) } else { summary.reasoning = mergeAnswerText(summary.reasoning, fragment.text) } } } collectSearchSources(parsed.payload, summary.search_results) } summary.search_results = dedupeSourceItems(summary.search_results) return summary } 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, full_message_event_count: 0, reply_end_event_count: 0, answer_finish_event_count: 0, error_message: null, } for (const summary of summaries) { if (summary.answer) { combined.answer = mergeAnswerText(combined.answer, summary.answer) } if (summary.reasoning) { combined.reasoning = mergeAnswerText(combined.reasoning, summary.reasoning) } 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.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 } 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) { const text = normalizeOptionalString(candidate) if (text) { return text } } throw new Error('doubao_question_text_missing') } function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { return items.map((item) => ({ url: item.url, title: item.title ?? null, site_name: item.site_name ?? null, 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, })) } function buildAdapterError( code: string, message: string, extras: Record = {}, ): Record { return { code, message, ...extras, } } 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(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)), ) 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, full_message_event_count: 0, reply_end_event_count: 0, answer_finish_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 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: 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) => { 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(/\u00A0/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 } 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() 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, depth = 0): string | null => { if (depth > 3) { return null } const trimmed = normalize(value) if (!trimmed) { return null } const candidates = [trimmed] try { 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 = { 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) const changedContentAge = now - firstChangedContentAt if (!answer) { return ( Boolean(reasoning || snapshot.links.length > 0) && changedContentAge >= incompleteAnswerGraceMs ) } if (changedContentAge < minimumAnswerSettleMs) { return false } 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 changedContentAge >= 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) const visibleText = normalize(node.innerText) ?? normalize(node.textContent) links.push({ url: href, title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')) ?? visibleText, text: visibleText, siteName: normalize(node.getAttribute('data-site-name')) ?? normalize(node.dataset.siteName ?? null), }) } 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). 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 } if (/^(已深度思考|思考完成|查看\d+篇资料|参考资料|资料来源|展开|收起)$/.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 => { expandReferenceControls() const questionAnchor = findQuestionAnchor() const anchorRect = questionAnchor?.getBoundingClientRect() ?? null 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)) { 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, 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 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 (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 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, ) 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, 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, detail: summarizeFailureDetail(detail) ?? summarizeFailureDetail(snapshot.answerText) ?? summarizeFailureDetail(bodyText()), 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, 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 answerMode = await ensureThinkingAnswerMode(composerShell, composer) composer = findComposer() ?? composer composerShell = findComposerShell(composer) ?? composerShell 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) } } if (submitQuestion) { 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) } else { await wait(800) } const initialSnapshot = snapshotConversation(composerTop) let bestSnapshot = initialSnapshot let bestContentSnapshot: ConversationSnapshot | null = hasObservedContent(initialSnapshot) ? initialSnapshot : null let lastSignature = initialSnapshot.signature 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) { 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, answerMode, bestContentSnapshot ?? bestSnapshot, ) } const finalSnapshot = bestContentSnapshot ? pickBetterSnapshot(bestSnapshot, bestContentSnapshot) : bestSnapshot if ( sawChange && !busy && stableCount >= stablePollsRequired && 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, domLinks: finalSnapshot.links, } } await wait(pollIntervalMs) } return fail( 'doubao_query_timeout', bodyText(), composerTop, answerMode, bestContentSnapshot ?? bestSnapshot, ) } export const doubaoAdapter: MonitorAdapter = { provider: 'doubao', executionMode: 'playwright', async query(context, payload) { if (!context.playwright?.page) { return { status: 'failed', summary: '豆包监测缺少 Playwright 页面上下文。', error: buildAdapterError('doubao_playwright_required', 'doubao_playwright_required'), } } const questionText = extractQuestionText(payload) const page = context.playwright.page context.reportProgress('doubao.page_ready') await ensurePageOnDoubaoChat(page, context.signal) const capture = attachDoubaoResponseCapture(page) let pageResult: DoubaoPageQueryResult try { context.reportProgress('doubao.query') 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 { 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 = 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 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) { const failed = pageResult const detail = normalizeText(failed.detail) ?? 'unknown' if (failed.error === 'doubao_login_required' || failed.error === 'doubao_login_expired') { return { status: 'failed', summary: '豆包账号未登录或登录态已失效,请先在桌面客户端中重新授权。', error: buildAdapterError(failed.error, detail, { page_url: failed.url ?? safePageURL(page), }), } } 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) { 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 (!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: `豆包请求失败:${streamSummary.error_message}`, error: buildAdapterError('doubao_stream_error', streamSummary.error_message, { page_url: pageResult.url, }), } } if (!hasRecoveredAnswer) { return { status: 'unknown', 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, }), } } context.reportProgress('doubao.parse_result') return { status: 'succeeded', summary: '豆包监控任务执行成功。', payload: { platform: 'doubao', provider_model: providerModel, provider_request_id: streamSummary.provider_request_id, request_id: requestID, conversation_id: conversationID, answer: submission.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, 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, stream_reasoning_length: streamSummary.reasoning?.length ?? 0, dom_reasoning_length: pageResult.domReasoning?.length ?? 0, selected_answer_length: submission.answer?.length ?? 0, captures: captureDebug, }, }, } }, } export const __doubaoTestUtils = { buildSourceItem, dedupeSourceItems, isNonCitationAssetDomain, isNonCitationAssetUrl, parseDoubaoStreamBody, resolveDoubaoSubmissionAnswer, }