Files
geo/apps/desktop-client/src/main/adapters/doubao.ts
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

2299 lines
65 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 = 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<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(/[\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<string, unknown>, 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<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 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<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,
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, 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('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<DoubaoPageQueryResult> => {
const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink } = parameters
const stablePollsRequired = 4
const pollIntervalMs = 1_200
const incompleteAnswerGraceMs = 15_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
}
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): 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<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)
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,
}