Files
geo/apps/desktop-client/src/main/adapters/doubao.ts
T
root 513e71f9f1 fix(doubao): submit only verified SSE answers and harden source extraction
Rework the Doubao monitor adapter so success answers come exclusively from
CHUNK_DELTA SSE text and require an SSE_REPLY_END finish event — DOM fallbacks
and FULL_MSG_NOTIFY user echoes can no longer pose as the model reply. Also
auto-switch to the 思考 answer mode, repair mixed-mojibake fragments per run,
unwrap Doubao redirect URLs, scrub "unknown" source metadata, recover from
mid-query navigation by returning unknown for retry, and surface stream/finish
counters in diagnostics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:18:53 +08:00

3264 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const DOUBAO_WINDOWS_1252_REVERSE_MAP = new Map<number, number>([
[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<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = normalizeOptionalString(record[key])
if (value) {
return value
}
}
return null
}
function getDirectSourceMetadataString(
record: Record<string, unknown>,
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<string, MonitoringSourceItem>()
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<string, JsonValue> = {},
): {
status: 'unknown'
summary: string
error: Record<string, JsonValue>
} {
return {
status: 'unknown',
summary,
error: buildAdapterError(code, message, extras),
}
}
function buildDoubaoRetryableUnknownResult(
message: string,
extras: Record<string, JsonValue> = {},
): {
status: 'unknown'
summary: string
error: Record<string, JsonValue>
} {
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, unknown>): 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<string, JsonValue> = {},
): Record<string, JsonValue> {
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<void> {
await new Promise<void>((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<void> {
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<void>
pendingCount: () => number
dispose: () => void
} {
const captures: DoubaoCaptureRecord[] = []
const pending = new Set<Promise<void>>()
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<void>((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<DoubaoPageQueryResult> => {
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<void>((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<HTMLElement>()
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<HTMLElement>()
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<AnswerModeKind, string> = {
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<HTMLElement>()
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<HTMLElement>()
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<AnswerModeSnapshot & { applied: boolean | null }> => {
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<HTMLElement>()
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<string>()
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<string, DoubaoDomLink>()
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,
}