513e71f9f1
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>
3264 lines
94 KiB
TypeScript
3264 lines
94 KiB
TypeScript
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,
|
||
}
|