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 = 1_200_000 const DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000 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', 'FULL_MSG_NOTIFY']) 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 error_message: string | null } type DoubaoCaptureRecord = { url: string status: number contentType: string | null body: string } type DoubaoDomLink = { url: string title: string | null text: string | null siteName: string | null } type DoubaoPageQuerySuccessResult = { ok: true url: string title: string | null modelLabel: string | null deepThinkEnabled: boolean | null domAnswer: string | null domReasoning: string | null domLinks: DoubaoDomLink[] } type DoubaoPageQueryFailureResult = { ok: false error: string detail: string | null url: string | null title: string | null modelLabel: string | null deepThinkEnabled: boolean | null domAnswer: string | null domReasoning: string | null domLinks: DoubaoDomLink[] } type DoubaoPageQueryResult = DoubaoPageQuerySuccessResult | DoubaoPageQueryFailureResult function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } const DOUBAO_WINDOWS_1252_REVERSE_MAP = new Map([ [0x20ac, 0x80], [0x201a, 0x82], [0x0192, 0x83], [0x201e, 0x84], [0x2026, 0x85], [0x2020, 0x86], [0x2021, 0x87], [0x02c6, 0x88], [0x2030, 0x89], [0x0160, 0x8a], [0x2039, 0x8b], [0x0152, 0x8c], [0x017d, 0x8e], [0x2018, 0x91], [0x2019, 0x92], [0x201c, 0x93], [0x201d, 0x94], [0x2022, 0x95], [0x2013, 0x96], [0x2014, 0x97], [0x02dc, 0x98], [0x2122, 0x99], [0x0161, 0x9a], [0x203a, 0x9b], [0x0153, 0x9c], [0x017e, 0x9e], [0x0178, 0x9f], ]) function countDoubaoHanCharacters(value: string): number { return value.match(/[\u3400-\u9fff]/g)?.length ?? 0 } function countDoubaoSuspiciousCharacters(value: string): number { return value.match(/[\u00c0-\u017f\u2000-\u20ff]/g)?.length ?? 0 } function looksLikeDoubaoMojibake(value: string): boolean { if (!value) { return false } if (value.includes('Ã') || value.includes('â€') || value.includes('ï¼') || value.includes('ã€')) { return true } const hanCount = countDoubaoHanCharacters(value) const suspiciousCount = countDoubaoSuspiciousCharacters(value) if (hanCount > 0) { return suspiciousCount >= 8 && suspiciousCount > hanCount * 3 } return suspiciousCount >= 4 } function decodeDoubaoLatin1AsUtf8(value: string): string | null { const bytes: number[] = [] for (const char of value) { const codePoint = char.codePointAt(0) if (codePoint == null) { return null } const mapped = DOUBAO_WINDOWS_1252_REVERSE_MAP.get(codePoint) if (mapped != null) { bytes.push(mapped) continue } if (codePoint > 0xff) { return null } bytes.push(codePoint) } try { return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes)) } catch { return null } } function doubaoRepairLooksBetter(original: string, repaired: string): boolean { if (!repaired.trim()) { return false } const originalHan = countDoubaoHanCharacters(original) const repairedHan = countDoubaoHanCharacters(repaired) const originalSuspicious = countDoubaoSuspiciousCharacters(original) const repairedSuspicious = countDoubaoSuspiciousCharacters(repaired) if (repairedHan > originalHan && repairedHan >= 2) { return true } return repairedHan > 0 && repairedSuspicious * 2 < originalSuspicious } function repairDoubaoMojibake(value: string): string { const trimmed = value.trim() if (!trimmed || !looksLikeDoubaoMojibake(trimmed)) { return value } const repaired = decodeDoubaoLatin1AsUtf8(trimmed) if (!repaired || !doubaoRepairLooksBetter(trimmed, repaired)) { return value } return repaired } function normalizeOptionalString(value: unknown): string | null { if (typeof value !== 'string') { return null } const trimmed = repairDoubaoMojibake(value).trim() return trimmed ? trimmed : null } 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 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 looksLikeSourceUrl(value: string | null): boolean { if (!value) { return false } const normalized = value.trim() if (!normalized) { return false } if (/^https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) { return true } return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(normalized) } function normalizeSourceUrl(value: string | null): string | null { const input = normalizeText(value) if (!input || !looksLikeSourceUrl(input)) { return null } try { const url = new URL(/^(https?:)?\/\//i.test(input) ? input : `https://${input}`) url.hash = '' return url.toString() } catch { 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: getDirectString(input, [ 'title', 'name', 'cited_title', 'page_title', 'display_title', 'page_name', ]), site_name: getDirectString(input, [ 'site_name', 'siteName', 'source', 'platform_name', 'domain', 'site', 'host_name', ]), site_key: getDirectString(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 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 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, 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 } summary.event_count += 1 if (parsed.event.toUpperCase().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 (ANSWER_EVENT_WHITELIST.has(parsed.event.toUpperCase())) { 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, 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.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('FULL_MSG_NOTIFY') || /"block_type"\s*:\s*10000/.test(capture.body)), ) if (streamLike) { return parseDoubaoStreamBody(capture.body) } const captureError = parseDoubaoCaptureError(capture) if (!captureError) { return null } return { answer: null, reasoning: null, provider_request_id: null, request_id: null, conversation_id: null, search_results: [], event_count: 0, error_message: captureError, } satisfies DoubaoStreamSummary }) .filter((summary): summary is DoubaoStreamSummary => summary !== null) return mergeStreamSummaries(summaries) } function summarizeCapturesForDebug(captures: DoubaoCaptureRecord[]): JsonValue[] { return captures.slice(-16).map((capture) => ({ url: capture.url, status: capture.status, content_type: capture.contentType, body_length: capture.body.length, body_preview: capture.body.slice(0, 240) || null, })) } function resolveProviderModel(pageResult: DoubaoPageQueryResult): string { const label = normalizeText(pageResult.modelLabel) if (label) { return label } return pageResult.deepThinkEnabled ? 'doubao-web-thinking' : 'doubao-web' } const doubaoQueryInPage = async (parameters: { questionText: string timeoutMs: number readyTimeoutMs: number enableDeepThink: boolean }): Promise => { const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink } = parameters const stablePollsRequired = 4 const pollIntervalMs = 1_200 const incompleteAnswerGraceMs = 15_000 const wait = (timeMs: number) => new Promise((resolve) => { window.setTimeout(resolve, timeMs) }) const normalize = (value: unknown): string | null => { if (typeof value !== 'string') { return null } const trimmed = value.trim().replace(/\s+/g, ' ') return trimmed ? trimmed : null } const normalizeMultiline = (value: unknown): string | null => { if (typeof value !== 'string') { return null } const trimmed = value .replace(/\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 } const setComposerText = (composer: HTMLElement, text: string) => { composer.focus() if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { const prototype = Object.getPrototypeOf(composer) as { value?: PropertyDescriptor } const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set if (setter) { setter.call(composer, text) } else { composer.value = text } composer.dispatchEvent(new Event('input', { bubbles: true })) composer.dispatchEvent(new Event('change', { bubbles: true })) return } if (composer.isContentEditable) { const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(composer) selection?.removeAllRanges() selection?.addRange(range) const inserted = typeof document.execCommand === 'function' ? document.execCommand('insertText', false, text) : false if (!inserted) { composer.textContent = text } composer.dispatchEvent( new InputEvent('input', { bubbles: true, cancelable: true, data: text, inputType: 'insertText', }), ) composer.dispatchEvent(new Event('change', { bubbles: true })) } } const findSendButton = ( shell: HTMLElement | null, composer: HTMLElement | null, ): HTMLElement | null => { const scopes: ParentNode[] = [] if (shell) { scopes.push(shell) if (shell.parentElement) { scopes.push(shell.parentElement) } if (shell.parentElement?.parentElement) { scopes.push(shell.parentElement.parentElement) } } scopes.push(document) const composerRect = composer?.getBoundingClientRect() ?? null const blacklist = /^(深度思考|联网搜索|工具|插件|图像|语音|附件|上传|语音输入|停止输出|停止生成|登录|下载)$/ const candidates: Array<{ element: HTMLElement; score: number }> = [] const seen = new Set() for (const scope of scopes) { const nodes = Array.from( scope.querySelectorAll( "button, [role='button'], [data-testid*='send'], [aria-label*='发送'], [aria-label*='Send']", ), ) for (const node of nodes) { if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { continue } seen.add(node) const text = textOf(node) ?? '' const hint = hintText(node) if (blacklist.test(text)) { continue } if (/(登录|下载|插件|历史|设置|菜单)/.test(hint) && !/send|发送|submit/.test(hint)) { continue } if (isDisabled(node)) { continue } const rect = node.getBoundingClientRect() if (rect.width < 18 || rect.height < 18) { continue } if (composerRect) { if (rect.top < composerRect.top - 120 || rect.top > composerRect.bottom + 160) { continue } if (rect.left < composerRect.left - 80 || rect.left > composerRect.right + 240) { continue } } let score = rect.left + rect.top if (composerRect) { const dx = Math.abs(rect.left - composerRect.right) const dy = Math.abs(rect.top - composerRect.top) score += Math.max(0, 2_000 - dx * 3 - dy * 3) if (rect.left >= composerRect.right - 32) { score += 500 } } if (/(send|发送|submit|paper|arrow|上箭头|up)/i.test(hint)) { score += 1_800 } if (!text && rect.width <= 72 && rect.height <= 72) { score += 360 } candidates.push({ element: node, score }) } } candidates.sort((left, right) => right.score - left.score) return candidates[0]?.element ?? null } const dispatchEnter = (composer: HTMLElement) => { const eventInit: KeyboardEventInit = { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13, which: 13, } composer.dispatchEvent(new KeyboardEvent('keydown', eventInit)) composer.dispatchEvent(new KeyboardEvent('keypress', eventInit)) composer.dispatchEvent(new KeyboardEvent('keyup', eventInit)) } const normalizeHref = (value: string | null | undefined): string | null => { const trimmed = normalize(value) if (!trimmed) { return null } try { const url = new URL(trimmed, window.location.href) url.hash = '' return url.toString() } catch { return null } } type ConversationSnapshot = { answerText: string | null reasoningText: string | null links: DoubaoDomLink[] signature: string } const countMatches = (input: string, pattern: RegExp): number => { const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g` return input.match(new RegExp(pattern.source, flags))?.length ?? 0 } const hasStructuredAnswerMarkers = (text: string): boolean => { return ( /(^|\n)\s*(?:[-*+]\s+|\d+[.、]\s+|#{1,6}\s+)/m.test(text) || /(?:^|\n)\|.+\|(?:\n|$)/m.test(text) || /(?:^|\n)\s*[^:\n]{1,24}[::]\s+\S+/m.test(text) ) } const hasObservedContent = (snapshot: ConversationSnapshot | null): boolean => { if (!snapshot) { return false } return Boolean( normalize(snapshot.answerText) || normalize(snapshot.reasoningText) || snapshot.links.length > 0, ) } const snapshotQualityScore = (snapshot: ConversationSnapshot | null): number => { if (!snapshot) { return Number.NEGATIVE_INFINITY } const answer = normalizeMultiline(snapshot.answerText) const reasoning = normalizeMultiline(snapshot.reasoningText) let score = 0 if (answer) { score += Math.min(answer.length, 4_000) score += countMatches(answer, /[。!?;:,、,.!?;:]/g) * 18 if (hasStructuredAnswerMarkers(answer)) { score += 260 } if (/[。!?.!?]$/.test(answer)) { score += 60 } if (!/\n/.test(answer) && answer.length < 24 && !/[。!?.!?]/.test(answer)) { score -= 120 } } if (reasoning) { score += Math.min(reasoning.length, 2_000) / 2 } score += snapshot.links.length * 120 return score } const pickBetterSnapshot = ( current: ConversationSnapshot | null, candidate: ConversationSnapshot, ): ConversationSnapshot => { if (!current) { return candidate } const currentHasContent = hasObservedContent(current) const candidateHasContent = hasObservedContent(candidate) if (candidateHasContent && !currentHasContent) { return candidate } if (currentHasContent && !candidateHasContent) { return current } return snapshotQualityScore(candidate) >= snapshotQualityScore(current) ? candidate : current } const shouldReturnStableAnswer = ( snapshot: ConversationSnapshot | null, firstChangedContentAt: number | null, now: number, ): boolean => { if (!snapshot || firstChangedContentAt === null || !hasObservedContent(snapshot)) { return false } const answer = normalizeMultiline(snapshot.answerText) const reasoning = normalizeMultiline(snapshot.reasoningText) if (!answer) { return ( Boolean(reasoning || snapshot.links.length > 0) && now - firstChangedContentAt >= incompleteAnswerGraceMs ) } if ( answer.length >= 96 || countMatches(answer, /[。!?;:]/g) >= 2 || countMatches(answer, /\n/g) >= 2 || hasStructuredAnswerMarkers(answer) || (snapshot.links.length >= 2 && answer.length >= 48) || (reasoning && answer.length >= 48) ) { return true } return now - firstChangedContentAt >= incompleteAnswerGraceMs } const isExternalHref = (value: string | null): boolean => { if (!value) { return false } try { const hostname = new URL(value).hostname.toLowerCase() if (!hostname) { return false } return ( !hostname.endsWith('doubao.com') && !hostname.endsWith('bytedance.com') && !hostname.endsWith('volces.com') ) } catch { return false } } const collectLinks = (scope: HTMLElement | null): DoubaoDomLink[] => { if (!scope) { return [] } const links: DoubaoDomLink[] = [] const seen = new Set() for (const node of Array.from(scope.querySelectorAll('a[href]'))) { if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) { continue } const href = normalizeHref(node.getAttribute('href') ?? node.href) if (!href || !isExternalHref(href) || seen.has(href)) { continue } seen.add(href) links.push({ url: href, title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')), text: normalize(node.innerText) ?? normalize(node.textContent), siteName: normalize(node.getAttribute('data-site-name')) ?? normalize(node.dataset.siteName ?? null), }) } return links } const normalizedQuestion = normalizeMultiline(questionText) // Tokens that only appear on Doubao's empty-chat surface (prompt templates, nav, feature chips). const EMPTY_CHAT_TOKENS = [ '快速', '帮我写作', '图像生成', 'AI 播客', 'AI播客', '编程', '翻译', '更多', '更多功能', '新对话', '新建对话', '扫码下载', '下载 App', '下载App', '登录体验更多', ] const looksLikeEmptyChatSurface = (text: string | null): boolean => { if (!text) { return false } const lines = text .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) if (lines.length === 0) { return false } const hits = lines.filter((line) => EMPTY_CHAT_TOKENS.some((token) => line === token || line === `${token}`), ).length // If half-or-more of the short lines are prompt-template chips, this is the empty-chat surface. return hits >= 3 || (hits >= 2 && lines.length <= 6) } const stripKnownNoise = (text: string | null): string | null => { if (!text) { return null } const lines = text .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) const kept = lines.filter((line) => { if (EMPTY_CHAT_TOKENS.includes(line)) { return false } if (normalizedQuestion && line === normalizedQuestion) { return false } if (/^(内容由AI生成|内容由 AI 生成)/.test(line)) { return false } return true }) const joined = kept.join('\n').trim() return joined || null } const findQuestionAnchor = (): HTMLElement | null => { if (!normalizedQuestion) { return null } let best: { element: HTMLElement; order: number } | null = null let order = 0 const candidates = Array.from(document.querySelectorAll('p, div, span, li, article, section')) for (const node of candidates) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue } const text = normalizeMultiline(node.innerText) if (!text) { continue } if ( text !== normalizedQuestion && !(text.includes(normalizedQuestion) && text.length <= normalizedQuestion.length + 80) ) { continue } order += 1 const container = (node.closest( "[class*='message'], [class*='Message'], [class*='bubble'], [class*='Bubble'], [class*='user'], [class*='User'], li, article, section", ) as HTMLElement | null) ?? node if (!best || order >= best.order) { best = { element: container, order } } } return best?.element ?? null } const snapshotConversation = (composerTop: number | null): ConversationSnapshot => { const questionAnchor = findQuestionAnchor() const anchorRect = questionAnchor?.getBoundingClientRect() ?? null const candidates: Array<{ element: HTMLElement; score: number }> = [] const elements = Array.from(document.querySelectorAll('article, section, div, main, li')) for (const node of elements) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue } if ( questionAnchor && (node === questionAnchor || questionAnchor.contains(node) || node.contains(questionAnchor)) ) { continue } const rect = node.getBoundingClientRect() if (composerTop !== null && rect.top >= composerTop - 12) { continue } if (anchorRect && rect.bottom <= anchorRect.top + 4) { // Skip elements that come strictly before the user question. continue } if (rect.left < window.innerWidth * 0.08 || rect.width < 200) { continue } const rawText = normalizeMultiline(node.innerText) const text = stripKnownNoise(rawText) const links = collectLinks(node) if (!text && links.length === 0) { continue } if (rawText && looksLikeEmptyChatSurface(rawText)) { continue } if (text && text.length < 12 && links.length === 0) { continue } if (text && /^(Hi[,,\s].*|请登录|请先登录|扫码登录)$/.test(text)) { continue } const className = normalize(node.className)?.toLowerCase() ?? '' const datasetKeys = Object.keys(node.dataset ?? {}) .join(' ') .toLowerCase() const blob = `${className} ${datasetKeys}` if (/menu|navbar|sidebar|prompt-?template|quick-?action|home-?page|entry/.test(blob)) { continue } let score = Math.min(text?.length ?? 0, 3_000) + links.length * 80 + rect.height if (/markdown|message-content|chat-content|answer|response|assistant|bot|reply/.test(blob)) { score += 1_400 } if (/user|question|human/.test(blob)) { score -= 1_200 } if (questionAnchor && rect.top > (anchorRect?.bottom ?? 0)) { score += 1_000 } if (score <= 0) { continue } candidates.push({ element: node, score }) } candidates.sort((left, right) => right.score - left.score) const best = candidates[0]?.element ?? null let answerText: string | null = null let reasoningText: string | null = null if (best) { const reasoningNode = Array.from( best.querySelectorAll( "[class*='think'], [class*='Think'], [class*='reason'], [class*='Reason'], [class*='analysis']", ), ) .filter((node): node is HTMLElement => node instanceof HTMLElement && isVisible(node)) .sort( (left, right) => right.getBoundingClientRect().height - left.getBoundingClientRect().height, )[0] ?? null if (reasoningNode) { reasoningText = stripKnownNoise(normalizeMultiline(reasoningNode.innerText)) } answerText = stripKnownNoise(normalizeMultiline(best.innerText)) if (reasoningText && answerText && answerText.startsWith(reasoningText)) { const trimmedAnswer = answerText.slice(reasoningText.length).trim() if (trimmedAnswer) { answerText = trimmedAnswer } } } const links = best ? collectLinks(best) : [] const signature = `${answerText ?? ''}|${reasoningText ?? ''}|${links.map((item) => item.url).join('|')}`.slice( -2_000, ) return { answerText, reasoningText, links, signature } } const resolveModelLabel = (): string | null => { const title = normalize(document.title) const matched = title?.match(/豆包[^-|]*/) return normalize(matched?.[0] ?? null) } const fail = ( error: string, detail: string | null, composerTop: number | null, snapshotOverride: ConversationSnapshot | null = null, ): DoubaoPageQueryFailureResult => { const snapshot = snapshotOverride ?? snapshotConversation(composerTop) const composer = findComposer() const shell = findComposerShell(composer) const deepThinkButton = findInteractiveByText(/深度思考/, shell) return { ok: false, error, detail: summarizeFailureDetail(detail) ?? summarizeFailureDetail(snapshot.answerText) ?? summarizeFailureDetail(bodyText()), url: window.location.href || null, title: normalize(document.title), modelLabel: resolveModelLabel(), deepThinkEnabled: toggleState(deepThinkButton), domAnswer: snapshot.answerText, domReasoning: snapshot.reasoningText, domLinks: snapshot.links, } } const readyDeadline = Date.now() + readyTimeoutMs let composer = findComposer() while (!composer && Date.now() <= readyDeadline) { await wait(250) composer = findComposer() } if (hasLoginGate()) { return fail('doubao_login_required', bodyText(), composer?.getBoundingClientRect().top ?? null) } if (!composer) { return fail('doubao_composer_missing', bodyText(), null) } let composerShell = findComposerShell(composer) const composerTop = composer.getBoundingClientRect().top let deepThinkApplied: boolean | null = null if (enableDeepThink) { const deepThinkButton = findInteractiveByText(/深度思考/, composerShell) if (deepThinkButton) { const currentState = toggleState(deepThinkButton) if (currentState !== true) { deepThinkButton.click() await wait(180) } deepThinkApplied = toggleState(deepThinkButton) } } setComposerText(composer, questionText) await wait(220) composer = findComposer() ?? composer composerShell = findComposerShell(composer) ?? composerShell const sendButton = findSendButton(composerShell, composer) if (sendButton) { sendButton.click() } else { dispatchEnter(composer) } await wait(600) const initialSnapshot = snapshotConversation(composerTop) let bestSnapshot = initialSnapshot let bestContentSnapshot: ConversationSnapshot | null = hasObservedContent(initialSnapshot) ? initialSnapshot : null let lastSignature = initialSnapshot.signature let stableCount = 0 let sawChange = false let firstChangedContentAt: number | null = null const deadline = Date.now() + timeoutMs while (Date.now() <= deadline) { const snapshot = snapshotConversation(composerTop) bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot) if (hasObservedContent(snapshot)) { bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, snapshot) } const busy = hasBusyIndicator() const hasContent = hasObservedContent(snapshot) if (snapshot.signature && hasContent && snapshot.signature !== initialSnapshot.signature) { sawChange = true if (firstChangedContentAt === null) { firstChangedContentAt = Date.now() } if (snapshot.signature === lastSignature) { stableCount = busy ? 0 : stableCount + 1 } else { lastSignature = snapshot.signature stableCount = 0 } } if (hasLoginGate()) { return fail( 'doubao_login_expired', bodyText(), composerTop, bestContentSnapshot ?? bestSnapshot, ) } const finalSnapshot = bestContentSnapshot ? pickBetterSnapshot(bestSnapshot, bestContentSnapshot) : bestSnapshot if ( sawChange && !busy && stableCount >= stablePollsRequired && shouldReturnStableAnswer(finalSnapshot, firstChangedContentAt, Date.now()) ) { const deepThinkButton = findInteractiveByText(/深度思考/, composerShell) return { ok: true, url: window.location.href, title: normalize(document.title), modelLabel: resolveModelLabel(), deepThinkEnabled: toggleState(deepThinkButton) ?? deepThinkApplied, domAnswer: finalSnapshot.answerText, domReasoning: finalSnapshot.reasoningText, domLinks: finalSnapshot.links, } } await wait(pollIntervalMs) } return fail('doubao_query_timeout', bodyText(), composerTop, bestContentSnapshot ?? bestSnapshot) } export const doubaoAdapter: MonitorAdapter = { provider: 'doubao', executionMode: 'playwright', async query(context, payload) { if (!context.playwright?.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') pageResult = await page.evaluate(doubaoQueryInPage, { questionText, timeoutMs: DOUBAO_QUERY_TIMEOUT_MS, readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS, enableDeepThink: true, }) await sleep(600, context.signal).catch(() => undefined) await capture.flush(DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS) } finally { capture.dispose() } const streamSummary = parseDoubaoCaptures(capture.captures) const captureDebug = summarizeCapturesForDebug(capture.captures) const pendingCaptureCount = capture.pendingCount() const domSearchResults = dedupeSourceItems( pageResult.domLinks .map((item) => buildSourceItem({ url: item.url, title: item.title ?? item.text ?? null, site_name: item.siteName, }), ) .filter((item): item is MonitoringSourceItem => item !== null), ) const searchResults = dedupeSourceItems([...streamSummary.search_results, ...domSearchResults]) const answer = selectBestDoubaoText(streamSummary.answer, pageResult.domAnswer) const reasoning = selectBestDoubaoText(streamSummary.reasoning, pageResult.domReasoning) const providerModel = resolveProviderModel(pageResult) const conversationID = streamSummary.conversation_id ?? extractConversationID(pageResult.url) const requestID = streamSummary.request_id ?? conversationID const hasRecoveredContent = Boolean(answer) || searchResults.length > 0 const retryableStreamError = isDoubaoRetryableErrorMessage(streamSummary.error_message) if (pageResult.ok === false) { const failed = pageResult const detail = normalizeText(failed.detail) ?? 'unknown' if (failed.error === 'doubao_login_required' || failed.error === 'doubao_login_expired') { return { status: 'failed', summary: '豆包账号未登录或登录态已失效,请先在桌面客户端中重新授权。', error: buildAdapterError(failed.error, detail, { page_url: failed.url ?? safePageURL(page), }), } } if (failed.error !== 'doubao_query_timeout' || !hasRecoveredContent) { if (streamSummary.error_message) { if (retryableStreamError) { if (!hasRecoveredContent) { return buildDoubaoRetryableUnknownResult(streamSummary.error_message, { page_url: failed.url ?? safePageURL(page), page_error: failed.error, }) } } else { return { status: 'failed', summary: `豆包请求失败:${streamSummary.error_message}`, error: buildAdapterError('doubao_stream_error', streamSummary.error_message, { page_url: failed.url ?? safePageURL(page), }), } } } if (!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 (!hasRecoveredContent) { return { status: 'unknown', summary: '豆包返回为空,已回写 unknown 等待后续对账。', error: buildAdapterError('doubao_empty_response', 'doubao returned no answer or sources'), } } 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, reasoning, citation_count: 0, search_result_count: searchResults.length, citations: [], search_results: toJsonSources(searchResults), raw_response_json: { platform: 'doubao', page_url: pageResult.url, page_title: pageResult.title, model_label: pageResult.modelLabel, deep_think_enabled: pageResult.deepThinkEnabled, capture_count: capture.captures.length, capture_pending_after_flush: pendingCaptureCount, event_count: streamSummary.event_count, stream_search_result_count: streamSummary.search_results.length, dom_search_result_count: domSearchResults.length, stream_error_message: streamSummary.error_message, stream_error_retryable: retryableStreamError, stream_answer_present: Boolean(streamSummary.answer), dom_answer_present: Boolean(pageResult.domAnswer), stream_answer_length: streamSummary.answer?.length ?? 0, dom_answer_length: pageResult.domAnswer?.length ?? 0, selected_answer_length: answer?.length ?? 0, captures: captureDebug, }, }, } }, } export const __doubaoTestUtils = { buildSourceItem, dedupeSourceItems, isNonCitationAssetDomain, isNonCitationAssetUrl, parseDoubaoStreamBody, }