162abdc97c
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2299 lines
65 KiB
TypeScript
2299 lines
65 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 = 1_200_000
|
||
const DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000
|
||
const DOUBAO_STREAM_URL_FRAGMENT = '/chat/completion'
|
||
const DOUBAO_NON_CITATION_ASSET_DOMAINS = new Set(['byteimg.com', 'bytednsdoc.com'])
|
||
|
||
const SEARCH_RESULT_KEYS = new Set([
|
||
'references',
|
||
'reference_list',
|
||
'referenceList',
|
||
'reference_cards',
|
||
'referenceCards',
|
||
'reference_docs',
|
||
'referenceDocs',
|
||
'sources',
|
||
'source_list',
|
||
'sourceList',
|
||
'source_cards',
|
||
'sourceCards',
|
||
'search_results',
|
||
'searchResults',
|
||
'search_result_list',
|
||
'searchResultList',
|
||
'search_refs',
|
||
'searchRefs',
|
||
'web_results',
|
||
'webResults',
|
||
'grounding',
|
||
'grounding_results',
|
||
'groundingResults',
|
||
'browse_results',
|
||
'browseResults',
|
||
'browse_references',
|
||
'browseReferences',
|
||
])
|
||
|
||
const ANSWER_EVENT_WHITELIST = new Set(['STREAM_CHUNK', 'STREAM_MSG_NOTIFY', 'FULL_MSG_NOTIFY'])
|
||
|
||
type DoubaoStreamEvent = {
|
||
event: string
|
||
payload: unknown
|
||
}
|
||
|
||
type DoubaoStreamSummary = {
|
||
answer: string | null
|
||
reasoning: string | null
|
||
provider_request_id: string | null
|
||
request_id: string | null
|
||
conversation_id: string | null
|
||
search_results: MonitoringSourceItem[]
|
||
event_count: number
|
||
error_message: string | null
|
||
}
|
||
|
||
type DoubaoCaptureRecord = {
|
||
url: string
|
||
status: number
|
||
contentType: string | null
|
||
body: string
|
||
}
|
||
|
||
type DoubaoDomLink = {
|
||
url: string
|
||
title: string | null
|
||
text: string | null
|
||
siteName: string | null
|
||
}
|
||
|
||
type DoubaoPageQuerySuccessResult = {
|
||
ok: true
|
||
url: string
|
||
title: string | null
|
||
modelLabel: string | null
|
||
deepThinkEnabled: boolean | null
|
||
domAnswer: string | null
|
||
domReasoning: string | null
|
||
domLinks: DoubaoDomLink[]
|
||
}
|
||
|
||
type DoubaoPageQueryFailureResult = {
|
||
ok: false
|
||
error: string
|
||
detail: string | null
|
||
url: string | null
|
||
title: string | null
|
||
modelLabel: string | null
|
||
deepThinkEnabled: boolean | null
|
||
domAnswer: string | null
|
||
domReasoning: string | null
|
||
domLinks: DoubaoDomLink[]
|
||
}
|
||
|
||
type DoubaoPageQueryResult = DoubaoPageQuerySuccessResult | DoubaoPageQueryFailureResult
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
const DOUBAO_WINDOWS_1252_REVERSE_MAP = new Map<number, number>([
|
||
[0x20ac, 0x80],
|
||
[0x201a, 0x82],
|
||
[0x0192, 0x83],
|
||
[0x201e, 0x84],
|
||
[0x2026, 0x85],
|
||
[0x2020, 0x86],
|
||
[0x2021, 0x87],
|
||
[0x02c6, 0x88],
|
||
[0x2030, 0x89],
|
||
[0x0160, 0x8a],
|
||
[0x2039, 0x8b],
|
||
[0x0152, 0x8c],
|
||
[0x017d, 0x8e],
|
||
[0x2018, 0x91],
|
||
[0x2019, 0x92],
|
||
[0x201c, 0x93],
|
||
[0x201d, 0x94],
|
||
[0x2022, 0x95],
|
||
[0x2013, 0x96],
|
||
[0x2014, 0x97],
|
||
[0x02dc, 0x98],
|
||
[0x2122, 0x99],
|
||
[0x0161, 0x9a],
|
||
[0x203a, 0x9b],
|
||
[0x0153, 0x9c],
|
||
[0x017e, 0x9e],
|
||
[0x0178, 0x9f],
|
||
])
|
||
|
||
function countDoubaoHanCharacters(value: string): number {
|
||
return value.match(/[\u3400-\u9fff]/g)?.length ?? 0
|
||
}
|
||
|
||
function countDoubaoSuspiciousCharacters(value: string): number {
|
||
return value.match(/[\u00c0-\u017f\u2000-\u20ff]/g)?.length ?? 0
|
||
}
|
||
|
||
function looksLikeDoubaoMojibake(value: string): boolean {
|
||
if (!value) {
|
||
return false
|
||
}
|
||
if (value.includes('Ã') || value.includes('â€') || value.includes('ï¼') || value.includes('ã€')) {
|
||
return true
|
||
}
|
||
|
||
const hanCount = countDoubaoHanCharacters(value)
|
||
const suspiciousCount = countDoubaoSuspiciousCharacters(value)
|
||
if (hanCount > 0) {
|
||
return suspiciousCount >= 8 && suspiciousCount > hanCount * 3
|
||
}
|
||
return suspiciousCount >= 4
|
||
}
|
||
|
||
function decodeDoubaoLatin1AsUtf8(value: string): string | null {
|
||
const bytes: number[] = []
|
||
for (const char of value) {
|
||
const codePoint = char.codePointAt(0)
|
||
if (codePoint == null) {
|
||
return null
|
||
}
|
||
const mapped = DOUBAO_WINDOWS_1252_REVERSE_MAP.get(codePoint)
|
||
if (mapped != null) {
|
||
bytes.push(mapped)
|
||
continue
|
||
}
|
||
if (codePoint > 0xff) {
|
||
return null
|
||
}
|
||
bytes.push(codePoint)
|
||
}
|
||
|
||
try {
|
||
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes))
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function doubaoRepairLooksBetter(original: string, repaired: string): boolean {
|
||
if (!repaired.trim()) {
|
||
return false
|
||
}
|
||
|
||
const originalHan = countDoubaoHanCharacters(original)
|
||
const repairedHan = countDoubaoHanCharacters(repaired)
|
||
const originalSuspicious = countDoubaoSuspiciousCharacters(original)
|
||
const repairedSuspicious = countDoubaoSuspiciousCharacters(repaired)
|
||
|
||
if (repairedHan > originalHan && repairedHan >= 2) {
|
||
return true
|
||
}
|
||
return repairedHan > 0 && repairedSuspicious * 2 < originalSuspicious
|
||
}
|
||
|
||
function repairDoubaoMojibake(value: string): string {
|
||
const trimmed = value.trim()
|
||
if (!trimmed || !looksLikeDoubaoMojibake(trimmed)) {
|
||
return value
|
||
}
|
||
|
||
const repaired = decodeDoubaoLatin1AsUtf8(trimmed)
|
||
if (!repaired || !doubaoRepairLooksBetter(trimmed, repaired)) {
|
||
return value
|
||
}
|
||
return repaired
|
||
}
|
||
|
||
function normalizeOptionalString(value: unknown): string | null {
|
||
if (typeof value !== 'string') {
|
||
return null
|
||
}
|
||
const trimmed = repairDoubaoMojibake(value).trim()
|
||
return trimmed ? trimmed : null
|
||
}
|
||
|
||
function safeParseJSON(raw: string): unknown {
|
||
try {
|
||
return JSON.parse(raw)
|
||
} catch {
|
||
return raw
|
||
}
|
||
}
|
||
|
||
function getDirectString(record: Record<string, unknown>, keys: string[]): string | null {
|
||
for (const key of keys) {
|
||
const value = normalizeOptionalString(record[key])
|
||
if (value) {
|
||
return value
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findFirstStringByKey(source: unknown, keys: string[], depth = 0): string | null {
|
||
if (depth > 8 || source == null) {
|
||
return null
|
||
}
|
||
if (Array.isArray(source)) {
|
||
for (const item of source) {
|
||
const found = findFirstStringByKey(item, keys, depth + 1)
|
||
if (found) {
|
||
return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
if (!isRecord(source)) {
|
||
return null
|
||
}
|
||
|
||
for (const key of keys) {
|
||
const direct = normalizeOptionalString(source[key])
|
||
if (direct) {
|
||
return direct
|
||
}
|
||
}
|
||
|
||
for (const value of Object.values(source)) {
|
||
const found = findFirstStringByKey(value, keys, depth + 1)
|
||
if (found) {
|
||
return found
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function readNestedString(source: unknown, path: string[]): string | null {
|
||
let current: unknown = source
|
||
for (const key of path) {
|
||
if (!isRecord(current) || !(key in current)) {
|
||
return null
|
||
}
|
||
current = current[key]
|
||
}
|
||
return normalizeOptionalString(current)
|
||
}
|
||
|
||
function mergeAnswerText(current: string | null, nextFragment: string | null): string | null {
|
||
if (!nextFragment) {
|
||
return current
|
||
}
|
||
if (!current) {
|
||
return nextFragment
|
||
}
|
||
if (current === nextFragment || current.includes(nextFragment)) {
|
||
return current
|
||
}
|
||
if (nextFragment.startsWith(current)) {
|
||
return nextFragment
|
||
}
|
||
return `${current}${nextFragment}`
|
||
}
|
||
|
||
function countDoubaoMatches(input: string, pattern: RegExp): number {
|
||
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`
|
||
return input.match(new RegExp(pattern.source, flags))?.length ?? 0
|
||
}
|
||
|
||
function doubaoHasStructuredAnswerMarkers(text: string): boolean {
|
||
return (
|
||
/(^|\n)\s*(?:[-*+]\s+|\d+[.、]\s+|#{1,6}\s+)/m.test(text) ||
|
||
/(?:^|\n)\|.+\|(?:\n|$)/m.test(text) ||
|
||
/(?:^|\n)\s*[^:\n]{1,24}[::]\s+\S+/m.test(text)
|
||
)
|
||
}
|
||
|
||
function doubaoTextQualityScore(value: string | null): number {
|
||
const normalized = normalizeOptionalString(value)
|
||
if (!normalized) {
|
||
return Number.NEGATIVE_INFINITY
|
||
}
|
||
|
||
const hanCount = countDoubaoHanCharacters(normalized)
|
||
const suspiciousCount = countDoubaoSuspiciousCharacters(normalized)
|
||
const punctuationCount = countDoubaoMatches(normalized, /[。!?;:,、,.!?;:]/g)
|
||
const lineCount = normalized
|
||
.split(/\r?\n+/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean).length
|
||
let score =
|
||
Math.min(normalized.length, 4_000) + hanCount * 6 - suspiciousCount * 8 + punctuationCount * 24
|
||
score += Math.max(0, lineCount - 1) * 120
|
||
if (doubaoHasStructuredAnswerMarkers(normalized)) {
|
||
score += 320
|
||
}
|
||
if (/[。!?.!?]$/.test(normalized)) {
|
||
score += 80
|
||
}
|
||
if (lineCount <= 1 && normalized.length < 24 && punctuationCount === 0) {
|
||
score -= 160
|
||
}
|
||
return score
|
||
}
|
||
|
||
function selectBestDoubaoText(primary: string | null, secondary: string | null): string | null {
|
||
const primaryText = normalizeOptionalString(primary)
|
||
const secondaryText = normalizeOptionalString(secondary)
|
||
if (!primaryText) {
|
||
return secondaryText
|
||
}
|
||
if (!secondaryText) {
|
||
return primaryText
|
||
}
|
||
|
||
const primaryScore = doubaoTextQualityScore(primaryText)
|
||
const secondaryScore = doubaoTextQualityScore(secondaryText)
|
||
if (secondaryText.includes(primaryText) && secondaryScore >= primaryScore - 64) {
|
||
return secondaryText
|
||
}
|
||
if (primaryText.includes(secondaryText) && primaryScore >= secondaryScore - 64) {
|
||
return primaryText
|
||
}
|
||
return secondaryScore > primaryScore ? secondaryText : primaryText
|
||
}
|
||
|
||
function looksLikeSourceUrl(value: string | null): boolean {
|
||
if (!value) {
|
||
return false
|
||
}
|
||
|
||
const normalized = value.trim()
|
||
if (!normalized) {
|
||
return false
|
||
}
|
||
|
||
if (/^https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) {
|
||
return true
|
||
}
|
||
|
||
return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(normalized)
|
||
}
|
||
|
||
function normalizeSourceUrl(value: string | null): string | null {
|
||
const input = normalizeText(value)
|
||
if (!input || !looksLikeSourceUrl(input)) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const url = new URL(/^(https?:)?\/\//i.test(input) ? input : `https://${input}`)
|
||
url.hash = ''
|
||
return url.toString()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function normalizeSourceHost(value: string | null | undefined): string | null {
|
||
const input = normalizeText(value)
|
||
if (!input) {
|
||
return null
|
||
}
|
||
try {
|
||
return new URL(
|
||
/^(https?:)?\/\//i.test(input) ? input : `https://${input}`,
|
||
).hostname.toLowerCase()
|
||
} catch {
|
||
return (
|
||
input
|
||
.replace(/^https?:\/\//i, '')
|
||
.replace(/^\/\//, '')
|
||
.split(/[/?#:]/)[0]
|
||
?.trim()
|
||
.toLowerCase() || null
|
||
)
|
||
}
|
||
}
|
||
|
||
function isNonCitationAssetDomain(value: string | null | undefined): boolean {
|
||
const host = normalizeSourceHost(value)
|
||
if (!host) {
|
||
return false
|
||
}
|
||
if (DOUBAO_NON_CITATION_ASSET_DOMAINS.has(host)) {
|
||
return true
|
||
}
|
||
return Array.from(DOUBAO_NON_CITATION_ASSET_DOMAINS).some((domain) => host.endsWith(`.${domain}`))
|
||
}
|
||
|
||
function isNonCitationAssetUrl(value: string | null | undefined): boolean {
|
||
const url = normalizeSourceUrl(value ?? null) ?? normalizeText(value)
|
||
if (!url) {
|
||
return false
|
||
}
|
||
try {
|
||
return isNonCitationAssetDomain(new URL(url).hostname)
|
||
} catch {
|
||
return isNonCitationAssetDomain(url)
|
||
}
|
||
}
|
||
|
||
function resolveSourceBucket(key: string): 'search' | null {
|
||
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
if (
|
||
SEARCH_RESULT_KEYS.has(key) ||
|
||
normalized.includes('reference') ||
|
||
normalized.includes('source') ||
|
||
normalized.includes('search')
|
||
) {
|
||
return 'search'
|
||
}
|
||
return null
|
||
}
|
||
|
||
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
|
||
if (typeof input === 'string') {
|
||
const url = normalizeSourceUrl(input)
|
||
return url ? { url, normalized_url: url } : null
|
||
}
|
||
if (!isRecord(input)) {
|
||
return null
|
||
}
|
||
|
||
const url = normalizeSourceUrl(
|
||
getDirectString(input, [
|
||
'url',
|
||
'link',
|
||
'uri',
|
||
'href',
|
||
'cited_url',
|
||
'target_url',
|
||
'targetUrl',
|
||
'source_url',
|
||
'sourceUrl',
|
||
'page_url',
|
||
'pageUrl',
|
||
'jump_url',
|
||
'jumpUrl',
|
||
]),
|
||
)
|
||
if (!url) {
|
||
return null
|
||
}
|
||
|
||
let host: string | null = null
|
||
try {
|
||
host = new URL(url).hostname || null
|
||
} catch {
|
||
host = null
|
||
}
|
||
if (isNonCitationAssetDomain(host) || isNonCitationAssetUrl(url)) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
url,
|
||
title: getDirectString(input, [
|
||
'title',
|
||
'name',
|
||
'cited_title',
|
||
'page_title',
|
||
'display_title',
|
||
'page_name',
|
||
]),
|
||
site_name: getDirectString(input, [
|
||
'site_name',
|
||
'siteName',
|
||
'source',
|
||
'platform_name',
|
||
'domain',
|
||
'site',
|
||
'host_name',
|
||
]),
|
||
site_key: getDirectString(input, ['site_key', 'siteKey', 'domain_key']),
|
||
normalized_url: url,
|
||
host,
|
||
resolution_status: getDirectString(input, ['resolution_status', 'resolutionStatus']),
|
||
resolution_confidence: getDirectString(input, [
|
||
'resolution_confidence',
|
||
'resolutionConfidence',
|
||
]),
|
||
}
|
||
}
|
||
|
||
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
|
||
const keyed = new Map<string, MonitoringSourceItem>()
|
||
for (const item of items) {
|
||
const key = normalizeSourceUrl(item.normalized_url ?? item.url)
|
||
if (!key || isNonCitationAssetDomain(item.host) || isNonCitationAssetUrl(key)) {
|
||
continue
|
||
}
|
||
|
||
const existing = keyed.get(key)
|
||
if (!existing) {
|
||
keyed.set(key, {
|
||
...item,
|
||
normalized_url: key,
|
||
})
|
||
continue
|
||
}
|
||
|
||
keyed.set(key, {
|
||
...existing,
|
||
title: existing.title ?? item.title ?? null,
|
||
site_name: existing.site_name ?? item.site_name ?? null,
|
||
site_key: existing.site_key ?? item.site_key ?? null,
|
||
host: existing.host ?? item.host ?? null,
|
||
resolution_status: existing.resolution_status ?? item.resolution_status ?? null,
|
||
resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null,
|
||
})
|
||
}
|
||
|
||
return Array.from(keyed.values())
|
||
}
|
||
|
||
function collectSearchSources(
|
||
payload: unknown,
|
||
results: MonitoringSourceItem[],
|
||
bucket: 'search' | null = null,
|
||
depth = 0,
|
||
): void {
|
||
if (depth > 12 || payload == null) {
|
||
return
|
||
}
|
||
|
||
if (typeof payload === 'string') {
|
||
if (!bucket) {
|
||
return
|
||
}
|
||
const item = buildSourceItem(payload)
|
||
if (item) {
|
||
results.push(item)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (Array.isArray(payload)) {
|
||
for (const item of payload) {
|
||
collectSearchSources(item, results, bucket, depth + 1)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!isRecord(payload)) {
|
||
return
|
||
}
|
||
|
||
const directSourceItem = buildSourceItem(payload)
|
||
if (directSourceItem && bucket) {
|
||
results.push(directSourceItem)
|
||
}
|
||
|
||
for (const [key, value] of Object.entries(payload)) {
|
||
const nextBucket = resolveSourceBucket(key) ?? bucket
|
||
if (Array.isArray(value) || isRecord(value) || typeof value === 'string') {
|
||
collectSearchSources(value, results, nextBucket, depth + 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
type AnswerFragment = {
|
||
text: string
|
||
kind: 'answer' | 'reasoning'
|
||
}
|
||
|
||
type AnswerWalkContext = {
|
||
inReasoning: boolean
|
||
}
|
||
|
||
function isReasoningIcon(value: unknown): boolean {
|
||
return typeof value === 'string' && value.includes('Deep_Think')
|
||
}
|
||
|
||
function collectAnswerFragments(
|
||
payload: unknown,
|
||
result: AnswerFragment[] = [],
|
||
context: AnswerWalkContext = { inReasoning: false },
|
||
): AnswerFragment[] {
|
||
if (payload == null) {
|
||
return result
|
||
}
|
||
if (Array.isArray(payload)) {
|
||
for (const item of payload) {
|
||
collectAnswerFragments(item, result, context)
|
||
}
|
||
return result
|
||
}
|
||
if (!isRecord(payload)) {
|
||
return result
|
||
}
|
||
|
||
let nextContext = context
|
||
if (typeof payload.block_type === 'number' && payload.block_type !== 10000) {
|
||
return result
|
||
}
|
||
if (isReasoningIcon((payload as { icon_url?: unknown }).icon_url)) {
|
||
nextContext = { inReasoning: true }
|
||
}
|
||
|
||
const textBlock = payload.text_block
|
||
if (isRecord(textBlock)) {
|
||
const text = normalizeOptionalString(textBlock.text)
|
||
if (text) {
|
||
const reasoning = context.inReasoning || isReasoningIcon(textBlock.icon_url)
|
||
result.push({ text, kind: reasoning ? 'reasoning' : 'answer' })
|
||
}
|
||
}
|
||
|
||
const deltaText = normalizeOptionalString(payload.delta)
|
||
if (deltaText) {
|
||
result.push({ text: deltaText, kind: nextContext.inReasoning ? 'reasoning' : 'answer' })
|
||
}
|
||
|
||
const nestedDeltaText = readNestedString(payload, ['delta', 'text'])
|
||
if (nestedDeltaText) {
|
||
result.push({ text: nestedDeltaText, kind: nextContext.inReasoning ? 'reasoning' : 'answer' })
|
||
}
|
||
|
||
const assistantText =
|
||
(normalizeOptionalString(payload.role) === 'assistant' ||
|
||
normalizeOptionalString(payload.message_type) === 'assistant') &&
|
||
normalizeOptionalString(payload.text)
|
||
if (assistantText) {
|
||
result.push({ text: assistantText, kind: nextContext.inReasoning ? 'reasoning' : 'answer' })
|
||
}
|
||
|
||
for (const [key, value] of Object.entries(payload)) {
|
||
if (key === 'text_block') continue
|
||
if (Array.isArray(value) || isRecord(value)) {
|
||
collectAnswerFragments(value, result, nextContext)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
function parseSSERecord(record: string): DoubaoStreamEvent | null {
|
||
const lines = record.split(/\r?\n/)
|
||
let eventName = 'message'
|
||
const dataLines: string[] = []
|
||
|
||
for (const line of lines) {
|
||
if (!line || line.startsWith(':')) {
|
||
continue
|
||
}
|
||
if (line.startsWith('event:')) {
|
||
eventName = line.slice(6).trim() || eventName
|
||
continue
|
||
}
|
||
if (line.startsWith('data:')) {
|
||
dataLines.push(line.slice(5).trimStart())
|
||
}
|
||
}
|
||
|
||
if (!dataLines.length) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
event: eventName,
|
||
payload: safeParseJSON(dataLines.join('\n')),
|
||
}
|
||
}
|
||
|
||
function formatDoubaoStreamError(payload: unknown): string {
|
||
const errorCode = findFirstStringByKey(payload, ['error_code', 'code'])
|
||
const errorMessage = findFirstStringByKey(payload, ['error_msg', 'message', 'detail'])
|
||
const verifyScene = findFirstStringByKey(payload, ['verify_scene'])
|
||
|
||
if (verifyScene) {
|
||
return `doubao verification required (${verifyScene})`
|
||
}
|
||
if (errorCode && errorMessage) {
|
||
return `doubao stream error ${errorCode}: ${errorMessage}`
|
||
}
|
||
if (errorCode) {
|
||
return `doubao stream error ${errorCode}`
|
||
}
|
||
if (errorMessage) {
|
||
return `doubao stream error: ${errorMessage}`
|
||
}
|
||
return 'doubao stream error'
|
||
}
|
||
|
||
function isDoubaoRetryableErrorMessage(message: string | null | undefined): boolean {
|
||
const normalized = normalizeText(message)?.toLowerCase()
|
||
if (!normalized) {
|
||
return false
|
||
}
|
||
|
||
return (
|
||
normalized.includes('rate limited') ||
|
||
normalized.includes('rate limit') ||
|
||
normalized.includes('too many requests') ||
|
||
normalized.includes('request limit') ||
|
||
normalized.includes('frequency limit') ||
|
||
normalized.includes('请求过于频繁') ||
|
||
normalized.includes('频率过快') ||
|
||
normalized.includes('访问过于频繁') ||
|
||
normalized.includes('限流') ||
|
||
normalized.includes('稍后再试') ||
|
||
normalized.includes('服务繁忙') ||
|
||
normalized.includes('请稍后')
|
||
)
|
||
}
|
||
|
||
function buildDoubaoUnknownResult(
|
||
code: string,
|
||
summary: string,
|
||
message: string,
|
||
extras: Record<string, JsonValue> = {},
|
||
): {
|
||
status: 'unknown'
|
||
summary: string
|
||
error: Record<string, JsonValue>
|
||
} {
|
||
return {
|
||
status: 'unknown',
|
||
summary,
|
||
error: buildAdapterError(code, message, extras),
|
||
}
|
||
}
|
||
|
||
function buildDoubaoRetryableUnknownResult(
|
||
message: string,
|
||
extras: Record<string, JsonValue> = {},
|
||
): {
|
||
status: 'unknown'
|
||
summary: string
|
||
error: Record<string, JsonValue>
|
||
} {
|
||
return buildDoubaoUnknownResult(
|
||
'doubao_retryable_error',
|
||
'豆包触发限流或临时错误,已回写 unknown 等待后续补采。',
|
||
message,
|
||
extras,
|
||
)
|
||
}
|
||
|
||
function parseDoubaoCaptureError(capture: DoubaoCaptureRecord): string | null {
|
||
if (!capture.body) {
|
||
return capture.status >= 400 ? `doubao request failed with status ${capture.status}` : null
|
||
}
|
||
|
||
const parsed = safeParseJSON(capture.body)
|
||
const structured = formatDoubaoStreamError(parsed)
|
||
if (structured !== 'doubao stream error') {
|
||
return structured
|
||
}
|
||
|
||
const rawMessage = findFirstStringByKey(parsed, [
|
||
'error_msg',
|
||
'message',
|
||
'detail',
|
||
'errmsg',
|
||
'err_msg',
|
||
'error_message',
|
||
])
|
||
if (rawMessage) {
|
||
return `doubao stream error: ${rawMessage}`
|
||
}
|
||
|
||
const bodyText = normalizeText(capture.body)
|
||
if (bodyText) {
|
||
if (isDoubaoRetryableErrorMessage(bodyText)) {
|
||
return `doubao stream error: ${bodyText}`
|
||
}
|
||
if (capture.status >= 400) {
|
||
return `doubao request failed ${capture.status}: ${bodyText}`
|
||
}
|
||
}
|
||
|
||
return capture.status >= 400 ? `doubao request failed with status ${capture.status}` : null
|
||
}
|
||
|
||
function parseDoubaoStreamBody(body: string): DoubaoStreamSummary {
|
||
const summary: DoubaoStreamSummary = {
|
||
answer: null,
|
||
reasoning: null,
|
||
provider_request_id: null,
|
||
request_id: null,
|
||
conversation_id: null,
|
||
search_results: [],
|
||
event_count: 0,
|
||
error_message: null,
|
||
}
|
||
|
||
if (!body) {
|
||
return summary
|
||
}
|
||
|
||
const normalized = body.replace(/\r\n/g, '\n')
|
||
const records = normalized.split(/\n\n+/)
|
||
|
||
for (const rawRecord of records) {
|
||
const trimmed = rawRecord.trim()
|
||
if (!trimmed) {
|
||
continue
|
||
}
|
||
|
||
const parsed = parseSSERecord(trimmed)
|
||
if (!parsed) {
|
||
continue
|
||
}
|
||
|
||
summary.event_count += 1
|
||
if (parsed.event.toUpperCase().includes('ERROR')) {
|
||
summary.error_message = summary.error_message ?? formatDoubaoStreamError(parsed.payload)
|
||
continue
|
||
}
|
||
|
||
if (!summary.conversation_id) {
|
||
summary.conversation_id =
|
||
readNestedString(parsed.payload, ['ack_client_meta', 'conversation_id']) ??
|
||
readNestedString(parsed.payload, ['client_meta', 'conversation_id']) ??
|
||
findFirstStringByKey(parsed.payload, ['conversation_id'])
|
||
}
|
||
if (!summary.provider_request_id) {
|
||
summary.provider_request_id = findFirstStringByKey(parsed.payload, [
|
||
'log_id',
|
||
'request_id',
|
||
'req_id',
|
||
])
|
||
}
|
||
if (!summary.request_id) {
|
||
summary.request_id = findFirstStringByKey(parsed.payload, [
|
||
'message_id',
|
||
'local_message_id',
|
||
'reply_id',
|
||
])
|
||
}
|
||
|
||
if (ANSWER_EVENT_WHITELIST.has(parsed.event.toUpperCase())) {
|
||
const fragments = collectAnswerFragments(parsed.payload)
|
||
for (const fragment of fragments) {
|
||
if (fragment.kind === 'answer') {
|
||
summary.answer = mergeAnswerText(summary.answer, fragment.text)
|
||
} else {
|
||
summary.reasoning = mergeAnswerText(summary.reasoning, fragment.text)
|
||
}
|
||
}
|
||
}
|
||
|
||
collectSearchSources(parsed.payload, summary.search_results)
|
||
}
|
||
|
||
summary.search_results = dedupeSourceItems(summary.search_results)
|
||
return summary
|
||
}
|
||
|
||
function mergeStreamSummaries(summaries: DoubaoStreamSummary[]): DoubaoStreamSummary {
|
||
const combined: DoubaoStreamSummary = {
|
||
answer: null,
|
||
reasoning: null,
|
||
provider_request_id: null,
|
||
request_id: null,
|
||
conversation_id: null,
|
||
search_results: [],
|
||
event_count: 0,
|
||
error_message: null,
|
||
}
|
||
|
||
for (const summary of summaries) {
|
||
if (summary.answer) {
|
||
combined.answer = mergeAnswerText(combined.answer, summary.answer)
|
||
}
|
||
if (summary.reasoning) {
|
||
combined.reasoning = mergeAnswerText(combined.reasoning, summary.reasoning)
|
||
}
|
||
combined.provider_request_id = combined.provider_request_id ?? summary.provider_request_id
|
||
combined.request_id = combined.request_id ?? summary.request_id
|
||
combined.conversation_id = combined.conversation_id ?? summary.conversation_id
|
||
combined.search_results.push(...summary.search_results)
|
||
combined.event_count += summary.event_count
|
||
combined.error_message = combined.error_message ?? summary.error_message
|
||
}
|
||
|
||
combined.search_results = dedupeSourceItems(combined.search_results)
|
||
return combined
|
||
}
|
||
|
||
function extractQuestionText(payload: Record<string, unknown>): string {
|
||
const candidates = [
|
||
payload.question_text,
|
||
payload.questionText,
|
||
payload.query,
|
||
payload.question,
|
||
payload.prompt,
|
||
payload.content,
|
||
payload.title,
|
||
]
|
||
|
||
for (const candidate of candidates) {
|
||
const text = normalizeOptionalString(candidate)
|
||
if (text) {
|
||
return text
|
||
}
|
||
}
|
||
|
||
throw new Error('doubao_question_text_missing')
|
||
}
|
||
|
||
function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] {
|
||
return items.map((item) => ({
|
||
url: item.url,
|
||
title: item.title ?? null,
|
||
site_name: item.site_name ?? null,
|
||
site_key: item.site_key ?? null,
|
||
normalized_url: item.normalized_url ?? null,
|
||
host: item.host ?? null,
|
||
registrable_domain: item.registrable_domain ?? null,
|
||
subdomain: item.subdomain ?? null,
|
||
suffix: item.suffix ?? null,
|
||
article_id: item.article_id ?? null,
|
||
publish_record_id: item.publish_record_id ?? null,
|
||
resolution_status: item.resolution_status ?? null,
|
||
resolution_confidence: item.resolution_confidence ?? null,
|
||
}))
|
||
}
|
||
|
||
function buildAdapterError(
|
||
code: string,
|
||
message: string,
|
||
extras: Record<string, JsonValue> = {},
|
||
): Record<string, JsonValue> {
|
||
return {
|
||
code,
|
||
message,
|
||
...extras,
|
||
}
|
||
}
|
||
|
||
function extractConversationID(url: string | null): string | null {
|
||
const input = normalizeText(url)
|
||
if (!input) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(input)
|
||
const matched = parsed.pathname.match(/\/chat\/([^/?#]+)/i)
|
||
return matched?.[1]?.trim() || null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function safePageURL(page: PlaywrightPage): string {
|
||
try {
|
||
return page.url()
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||
await new Promise<void>((resolve, reject) => {
|
||
if (signal?.aborted) {
|
||
reject(new Error('adapter_aborted'))
|
||
return
|
||
}
|
||
|
||
const timer = setTimeout(() => {
|
||
signal?.removeEventListener('abort', onAbort)
|
||
resolve()
|
||
}, ms)
|
||
|
||
const onAbort = () => {
|
||
clearTimeout(timer)
|
||
reject(new Error('adapter_aborted'))
|
||
}
|
||
|
||
signal?.addEventListener('abort', onAbort, { once: true })
|
||
})
|
||
}
|
||
|
||
async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal): Promise<void> {
|
||
if (signal.aborted) {
|
||
throw new Error('adapter_aborted')
|
||
}
|
||
|
||
const currentURL = safePageURL(page)
|
||
if (!currentURL.startsWith('https://www.doubao.com/')) {
|
||
await page.goto(DOUBAO_BOOTSTRAP_URL, {
|
||
waitUntil: 'domcontentloaded',
|
||
timeout: DOUBAO_PAGE_READY_TIMEOUT_MS,
|
||
})
|
||
} else if (!currentURL.startsWith('https://www.doubao.com/chat')) {
|
||
await page.goto(DOUBAO_BOOTSTRAP_URL, {
|
||
waitUntil: 'domcontentloaded',
|
||
timeout: DOUBAO_PAGE_READY_TIMEOUT_MS,
|
||
})
|
||
}
|
||
|
||
await page
|
||
.waitForLoadState('domcontentloaded', {
|
||
timeout: DOUBAO_PAGE_READY_TIMEOUT_MS,
|
||
})
|
||
.catch(() => undefined)
|
||
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined)
|
||
|
||
if (signal.aborted) {
|
||
throw new Error('adapter_aborted')
|
||
}
|
||
}
|
||
|
||
function attachDoubaoResponseCapture(page: PlaywrightPage): {
|
||
captures: DoubaoCaptureRecord[]
|
||
flush: (timeoutMs: number) => Promise<void>
|
||
pendingCount: () => number
|
||
dispose: () => void
|
||
} {
|
||
const captures: DoubaoCaptureRecord[] = []
|
||
const pending = new Set<Promise<void>>()
|
||
|
||
const pushCapture = (record: DoubaoCaptureRecord) => {
|
||
captures.push({
|
||
...record,
|
||
body:
|
||
record.body.length > DOUBAO_CAPTURE_BODY_LIMIT
|
||
? record.body.slice(0, DOUBAO_CAPTURE_BODY_LIMIT)
|
||
: record.body,
|
||
})
|
||
if (captures.length > DOUBAO_CAPTURE_LIMIT) {
|
||
captures.splice(0, captures.length - DOUBAO_CAPTURE_LIMIT)
|
||
}
|
||
}
|
||
|
||
const responseHandler = (response: PlaywrightResponse) => {
|
||
const url = normalizeOptionalString(response.url())
|
||
if (!url || !url.startsWith('https://www.doubao.com/')) {
|
||
return
|
||
}
|
||
|
||
const headers = response.headers()
|
||
const contentType = normalizeOptionalString(headers['content-type']) ?? null
|
||
const isStreamEndpoint = url.includes(DOUBAO_STREAM_URL_FRAGMENT)
|
||
const looksStreamy =
|
||
contentType !== null &&
|
||
/(event-stream|text\/plain|application\/(?:json|x-ndjson))/i.test(contentType)
|
||
const isApiPath = /\/(?:chat|api|samantha|assistant|aweme|bot)\//i.test(url)
|
||
if (!isStreamEndpoint && !(looksStreamy && isApiPath) && response.status() < 400) {
|
||
return
|
||
}
|
||
|
||
const task = response
|
||
.body()
|
||
.then((body: Buffer) => {
|
||
const decoded = body.toString('utf8')
|
||
pushCapture({
|
||
url,
|
||
status: response.status(),
|
||
contentType,
|
||
body: decoded,
|
||
})
|
||
})
|
||
.catch(() => undefined)
|
||
pending.add(task)
|
||
void task.finally(() => {
|
||
pending.delete(task)
|
||
})
|
||
}
|
||
|
||
page.on('response', responseHandler)
|
||
|
||
return {
|
||
captures,
|
||
async flush(timeoutMs: number) {
|
||
const deadline = Date.now() + timeoutMs
|
||
while (pending.size > 0 && Date.now() < deadline) {
|
||
const snapshot = Array.from(pending)
|
||
const remaining = Math.max(0, deadline - Date.now())
|
||
await Promise.race([
|
||
Promise.allSettled(snapshot),
|
||
new Promise<void>((resolve) => setTimeout(resolve, remaining)),
|
||
])
|
||
}
|
||
},
|
||
pendingCount() {
|
||
return pending.size
|
||
},
|
||
dispose() {
|
||
page.off('response', responseHandler)
|
||
},
|
||
}
|
||
}
|
||
|
||
function parseDoubaoCaptures(captures: DoubaoCaptureRecord[]): DoubaoStreamSummary {
|
||
const summaries = captures
|
||
.map((capture) => {
|
||
const streamLike = Boolean(
|
||
capture.body &&
|
||
(capture.url.includes(DOUBAO_STREAM_URL_FRAGMENT) ||
|
||
capture.body.includes('STREAM_CHUNK') ||
|
||
capture.body.includes('STREAM_MSG_NOTIFY') ||
|
||
capture.body.includes('FULL_MSG_NOTIFY') ||
|
||
/"block_type"\s*:\s*10000/.test(capture.body)),
|
||
)
|
||
|
||
if (streamLike) {
|
||
return parseDoubaoStreamBody(capture.body)
|
||
}
|
||
|
||
const captureError = parseDoubaoCaptureError(capture)
|
||
if (!captureError) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
answer: null,
|
||
reasoning: null,
|
||
provider_request_id: null,
|
||
request_id: null,
|
||
conversation_id: null,
|
||
search_results: [],
|
||
event_count: 0,
|
||
error_message: captureError,
|
||
} satisfies DoubaoStreamSummary
|
||
})
|
||
.filter((summary): summary is DoubaoStreamSummary => summary !== null)
|
||
return mergeStreamSummaries(summaries)
|
||
}
|
||
|
||
function summarizeCapturesForDebug(captures: DoubaoCaptureRecord[]): JsonValue[] {
|
||
return captures.slice(-16).map((capture) => ({
|
||
url: capture.url,
|
||
status: capture.status,
|
||
content_type: capture.contentType,
|
||
body_length: capture.body.length,
|
||
body_preview: capture.body.slice(0, 240) || null,
|
||
}))
|
||
}
|
||
|
||
function resolveProviderModel(pageResult: DoubaoPageQueryResult): string {
|
||
const label = normalizeText(pageResult.modelLabel)
|
||
if (label) {
|
||
return label
|
||
}
|
||
return pageResult.deepThinkEnabled ? 'doubao-web-thinking' : 'doubao-web'
|
||
}
|
||
|
||
const doubaoQueryInPage = async (parameters: {
|
||
questionText: string
|
||
timeoutMs: number
|
||
readyTimeoutMs: number
|
||
enableDeepThink: boolean
|
||
}): Promise<DoubaoPageQueryResult> => {
|
||
const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink } = parameters
|
||
const stablePollsRequired = 4
|
||
const pollIntervalMs = 1_200
|
||
const incompleteAnswerGraceMs = 15_000
|
||
|
||
const wait = (timeMs: number) =>
|
||
new Promise<void>((resolve) => {
|
||
window.setTimeout(resolve, timeMs)
|
||
})
|
||
|
||
const normalize = (value: unknown): string | null => {
|
||
if (typeof value !== 'string') {
|
||
return null
|
||
}
|
||
const trimmed = value.trim().replace(/\s+/g, ' ')
|
||
return trimmed ? trimmed : null
|
||
}
|
||
|
||
const normalizeMultiline = (value: unknown): string | null => {
|
||
if (typeof value !== 'string') {
|
||
return null
|
||
}
|
||
const trimmed = value
|
||
.replace(/\u00A0/g, ' ')
|
||
.split(/\n+/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.join('\n')
|
||
return trimmed ? trimmed : null
|
||
}
|
||
|
||
const isVisible = (element: Element | null | undefined): element is HTMLElement => {
|
||
if (!(element instanceof HTMLElement)) {
|
||
return false
|
||
}
|
||
const style = window.getComputedStyle(element)
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||
return false
|
||
}
|
||
const rect = element.getBoundingClientRect()
|
||
return rect.width > 0 && rect.height > 0
|
||
}
|
||
|
||
const isDisabled = (element: HTMLElement | null): boolean => {
|
||
if (!element) {
|
||
return false
|
||
}
|
||
if ('disabled' in element && typeof (element as HTMLButtonElement).disabled === 'boolean') {
|
||
return Boolean((element as HTMLButtonElement).disabled)
|
||
}
|
||
return element.getAttribute('aria-disabled') === 'true'
|
||
}
|
||
|
||
const toggleState = (element: HTMLElement | null): boolean | null => {
|
||
if (!element) {
|
||
return null
|
||
}
|
||
const attrs = [
|
||
element.getAttribute('aria-pressed'),
|
||
element.getAttribute('aria-checked'),
|
||
element.getAttribute('data-state'),
|
||
element.getAttribute('data-selected'),
|
||
element.getAttribute('data-active'),
|
||
]
|
||
.map((value: string | null) => normalize(value)?.toLowerCase() ?? null)
|
||
.filter((value: string | null): value is string => value !== null)
|
||
|
||
if (
|
||
attrs.some((value: string) => ['true', 'checked', 'selected', 'active', 'on'].includes(value))
|
||
) {
|
||
return true
|
||
}
|
||
if (attrs.some((value: string) => ['false', 'unchecked', 'off'].includes(value))) {
|
||
return false
|
||
}
|
||
|
||
const className = normalize(element.className)?.toLowerCase() ?? ''
|
||
if (/(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$)/.test(className)) {
|
||
return true
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const textOf = (element: Element | null | undefined): string | null => {
|
||
if (!(element instanceof HTMLElement)) {
|
||
return null
|
||
}
|
||
return normalize(element.innerText) ?? normalize(element.textContent)
|
||
}
|
||
|
||
const hintText = (element: Element | null | undefined): string => {
|
||
if (!(element instanceof HTMLElement)) {
|
||
return ''
|
||
}
|
||
return [
|
||
element.getAttribute('aria-label'),
|
||
element.getAttribute('placeholder'),
|
||
element.getAttribute('title'),
|
||
element.getAttribute('data-testid'),
|
||
element.getAttribute('name'),
|
||
element.getAttribute('id'),
|
||
typeof element.className === 'string' ? element.className : '',
|
||
textOf(element),
|
||
]
|
||
.map((value: string | null) => normalize(value)?.toLowerCase() ?? '')
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.slice(0, 600)
|
||
}
|
||
|
||
const bodyText = () => normalizeMultiline(document.body?.innerText ?? '')
|
||
|
||
const hasLoginGate = (): boolean => {
|
||
const text = bodyText() ?? ''
|
||
if (
|
||
/扫码登录|微信登录|手机号登录|请先登录|立即登录|未登录|登录后/.test(text) &&
|
||
!/登录后可同步|登录体验更多|登录保存|已登录/.test(text)
|
||
) {
|
||
return true
|
||
}
|
||
return Boolean(
|
||
document.querySelector(
|
||
"[class*='login-dialog'], [class*='LoginDialog'], [data-testid*='login']",
|
||
),
|
||
)
|
||
}
|
||
|
||
const hasBusyIndicator = (): boolean => {
|
||
const text = bodyText() ?? ''
|
||
return /停止生成|停止输出|正在思考|正在搜索|思考中|搜索中|生成中|回答中/.test(text)
|
||
}
|
||
|
||
const summarizeFailureDetail = (raw: string | null): string | null => {
|
||
const trimmed = normalize(raw)
|
||
if (!trimmed) {
|
||
return null
|
||
}
|
||
return trimmed.length <= 280 ? trimmed : `${trimmed.slice(0, 280)}...`
|
||
}
|
||
|
||
const findComposer = (): HTMLElement | null => {
|
||
const selectors = [
|
||
"textarea[data-testid*='chat']",
|
||
'textarea[placeholder]',
|
||
"[contenteditable='true'][role='textbox']",
|
||
"[contenteditable='true']",
|
||
'textarea',
|
||
]
|
||
|
||
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
||
const seen = new Set<HTMLElement>()
|
||
|
||
for (const selector of selectors) {
|
||
for (const node of Array.from(document.querySelectorAll(selector))) {
|
||
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
||
continue
|
||
}
|
||
if ((node as HTMLInputElement).readOnly || (node as HTMLInputElement).disabled) {
|
||
continue
|
||
}
|
||
seen.add(node)
|
||
|
||
const rect = node.getBoundingClientRect()
|
||
// Doubao composer sits near the bottom of the viewport; search bar sits near top.
|
||
const isNearBottom = rect.top > window.innerHeight * 0.45
|
||
const hint = hintText(node)
|
||
const placeholderLooksLikeSearch = /搜索|search/i.test(hint)
|
||
const placeholderLooksLikeComposer =
|
||
/(问|提问|给我发|doubao|豆包|说点什么|开始聊天|发消息|发送)/i.test(hint)
|
||
|
||
if (placeholderLooksLikeSearch && !placeholderLooksLikeComposer) {
|
||
continue
|
||
}
|
||
|
||
let score = Math.min(rect.width, 1_400) + rect.height
|
||
if (isNearBottom) {
|
||
score += 1_600
|
||
} else {
|
||
score -= 1_200
|
||
}
|
||
if (node.isContentEditable) {
|
||
score += 1_400
|
||
} else if (node instanceof HTMLTextAreaElement) {
|
||
score += 800
|
||
}
|
||
if (placeholderLooksLikeComposer) {
|
||
score += 1_800
|
||
}
|
||
|
||
candidates.push({ element: node, score })
|
||
}
|
||
}
|
||
|
||
candidates.sort((left, right) => right.score - left.score)
|
||
return candidates[0]?.element ?? null
|
||
}
|
||
|
||
const findComposerShell = (composer: HTMLElement | null): HTMLElement | null => {
|
||
if (!composer) {
|
||
return null
|
||
}
|
||
|
||
let current: HTMLElement | null = composer
|
||
let best: HTMLElement = composer
|
||
let bestArea = composer.getBoundingClientRect().width * composer.getBoundingClientRect().height
|
||
|
||
for (let depth = 0; current && depth < 5; depth += 1) {
|
||
if (isVisible(current)) {
|
||
const rect = current.getBoundingClientRect()
|
||
const area = rect.width * rect.height
|
||
if (area >= bestArea) {
|
||
best = current
|
||
bestArea = area
|
||
}
|
||
}
|
||
current = current.parentElement
|
||
}
|
||
|
||
return best
|
||
}
|
||
|
||
const findInteractiveByText = (
|
||
pattern: RegExp,
|
||
shell: HTMLElement | null,
|
||
): HTMLElement | null => {
|
||
const scopes: ParentNode[] = []
|
||
if (shell) {
|
||
scopes.push(shell)
|
||
if (shell.parentElement) {
|
||
scopes.push(shell.parentElement)
|
||
}
|
||
if (shell.parentElement?.parentElement) {
|
||
scopes.push(shell.parentElement.parentElement)
|
||
}
|
||
}
|
||
scopes.push(document)
|
||
|
||
const seen = new Set<HTMLElement>()
|
||
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
||
|
||
for (const scope of scopes) {
|
||
const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]"))
|
||
for (const node of nodes) {
|
||
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
||
continue
|
||
}
|
||
seen.add(node)
|
||
|
||
const text = textOf(node)
|
||
if (!text || !pattern.test(text)) {
|
||
continue
|
||
}
|
||
const rect = node.getBoundingClientRect()
|
||
candidates.push({ element: node, score: rect.top + rect.left })
|
||
}
|
||
}
|
||
|
||
candidates.sort((left, right) => right.score - left.score)
|
||
return candidates[0]?.element ?? null
|
||
}
|
||
|
||
const setComposerText = (composer: HTMLElement, text: string) => {
|
||
composer.focus()
|
||
|
||
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
|
||
const prototype = Object.getPrototypeOf(composer) as {
|
||
value?: PropertyDescriptor
|
||
}
|
||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||
if (setter) {
|
||
setter.call(composer, text)
|
||
} else {
|
||
composer.value = text
|
||
}
|
||
composer.dispatchEvent(new Event('input', { bubbles: true }))
|
||
composer.dispatchEvent(new Event('change', { bubbles: true }))
|
||
return
|
||
}
|
||
|
||
if (composer.isContentEditable) {
|
||
const selection = window.getSelection()
|
||
const range = document.createRange()
|
||
range.selectNodeContents(composer)
|
||
selection?.removeAllRanges()
|
||
selection?.addRange(range)
|
||
|
||
const inserted =
|
||
typeof document.execCommand === 'function'
|
||
? document.execCommand('insertText', false, text)
|
||
: false
|
||
if (!inserted) {
|
||
composer.textContent = text
|
||
}
|
||
|
||
composer.dispatchEvent(
|
||
new InputEvent('input', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
data: text,
|
||
inputType: 'insertText',
|
||
}),
|
||
)
|
||
composer.dispatchEvent(new Event('change', { bubbles: true }))
|
||
}
|
||
}
|
||
|
||
const findSendButton = (
|
||
shell: HTMLElement | null,
|
||
composer: HTMLElement | null,
|
||
): HTMLElement | null => {
|
||
const scopes: ParentNode[] = []
|
||
if (shell) {
|
||
scopes.push(shell)
|
||
if (shell.parentElement) {
|
||
scopes.push(shell.parentElement)
|
||
}
|
||
if (shell.parentElement?.parentElement) {
|
||
scopes.push(shell.parentElement.parentElement)
|
||
}
|
||
}
|
||
scopes.push(document)
|
||
|
||
const composerRect = composer?.getBoundingClientRect() ?? null
|
||
const blacklist =
|
||
/^(深度思考|联网搜索|工具|插件|图像|语音|附件|上传|语音输入|停止输出|停止生成|登录|下载)$/
|
||
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
||
const seen = new Set<HTMLElement>()
|
||
|
||
for (const scope of scopes) {
|
||
const nodes = Array.from(
|
||
scope.querySelectorAll(
|
||
"button, [role='button'], [data-testid*='send'], [aria-label*='发送'], [aria-label*='Send']",
|
||
),
|
||
)
|
||
for (const node of nodes) {
|
||
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
||
continue
|
||
}
|
||
seen.add(node)
|
||
|
||
const text = textOf(node) ?? ''
|
||
const hint = hintText(node)
|
||
if (blacklist.test(text)) {
|
||
continue
|
||
}
|
||
if (/(登录|下载|插件|历史|设置|菜单)/.test(hint) && !/send|发送|submit/.test(hint)) {
|
||
continue
|
||
}
|
||
if (isDisabled(node)) {
|
||
continue
|
||
}
|
||
|
||
const rect = node.getBoundingClientRect()
|
||
if (rect.width < 18 || rect.height < 18) {
|
||
continue
|
||
}
|
||
if (composerRect) {
|
||
if (rect.top < composerRect.top - 120 || rect.top > composerRect.bottom + 160) {
|
||
continue
|
||
}
|
||
if (rect.left < composerRect.left - 80 || rect.left > composerRect.right + 240) {
|
||
continue
|
||
}
|
||
}
|
||
|
||
let score = rect.left + rect.top
|
||
if (composerRect) {
|
||
const dx = Math.abs(rect.left - composerRect.right)
|
||
const dy = Math.abs(rect.top - composerRect.top)
|
||
score += Math.max(0, 2_000 - dx * 3 - dy * 3)
|
||
if (rect.left >= composerRect.right - 32) {
|
||
score += 500
|
||
}
|
||
}
|
||
if (/(send|发送|submit|paper|arrow|上箭头|up)/i.test(hint)) {
|
||
score += 1_800
|
||
}
|
||
if (!text && rect.width <= 72 && rect.height <= 72) {
|
||
score += 360
|
||
}
|
||
|
||
candidates.push({ element: node, score })
|
||
}
|
||
}
|
||
|
||
candidates.sort((left, right) => right.score - left.score)
|
||
return candidates[0]?.element ?? null
|
||
}
|
||
|
||
const dispatchEnter = (composer: HTMLElement) => {
|
||
const eventInit: KeyboardEventInit = {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
key: 'Enter',
|
||
code: 'Enter',
|
||
keyCode: 13,
|
||
which: 13,
|
||
}
|
||
composer.dispatchEvent(new KeyboardEvent('keydown', eventInit))
|
||
composer.dispatchEvent(new KeyboardEvent('keypress', eventInit))
|
||
composer.dispatchEvent(new KeyboardEvent('keyup', eventInit))
|
||
}
|
||
|
||
const normalizeHref = (value: string | null | undefined): string | null => {
|
||
const trimmed = normalize(value)
|
||
if (!trimmed) {
|
||
return null
|
||
}
|
||
try {
|
||
const url = new URL(trimmed, window.location.href)
|
||
url.hash = ''
|
||
return url.toString()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
type ConversationSnapshot = {
|
||
answerText: string | null
|
||
reasoningText: string | null
|
||
links: DoubaoDomLink[]
|
||
signature: string
|
||
}
|
||
|
||
const countMatches = (input: string, pattern: RegExp): number => {
|
||
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`
|
||
return input.match(new RegExp(pattern.source, flags))?.length ?? 0
|
||
}
|
||
|
||
const hasStructuredAnswerMarkers = (text: string): boolean => {
|
||
return (
|
||
/(^|\n)\s*(?:[-*+]\s+|\d+[.、]\s+|#{1,6}\s+)/m.test(text) ||
|
||
/(?:^|\n)\|.+\|(?:\n|$)/m.test(text) ||
|
||
/(?:^|\n)\s*[^:\n]{1,24}[::]\s+\S+/m.test(text)
|
||
)
|
||
}
|
||
|
||
const hasObservedContent = (snapshot: ConversationSnapshot | null): boolean => {
|
||
if (!snapshot) {
|
||
return false
|
||
}
|
||
return Boolean(
|
||
normalize(snapshot.answerText) ||
|
||
normalize(snapshot.reasoningText) ||
|
||
snapshot.links.length > 0,
|
||
)
|
||
}
|
||
|
||
const snapshotQualityScore = (snapshot: ConversationSnapshot | null): number => {
|
||
if (!snapshot) {
|
||
return Number.NEGATIVE_INFINITY
|
||
}
|
||
|
||
const answer = normalizeMultiline(snapshot.answerText)
|
||
const reasoning = normalizeMultiline(snapshot.reasoningText)
|
||
let score = 0
|
||
if (answer) {
|
||
score += Math.min(answer.length, 4_000)
|
||
score += countMatches(answer, /[。!?;:,、,.!?;:]/g) * 18
|
||
if (hasStructuredAnswerMarkers(answer)) {
|
||
score += 260
|
||
}
|
||
if (/[。!?.!?]$/.test(answer)) {
|
||
score += 60
|
||
}
|
||
if (!/\n/.test(answer) && answer.length < 24 && !/[。!?.!?]/.test(answer)) {
|
||
score -= 120
|
||
}
|
||
}
|
||
if (reasoning) {
|
||
score += Math.min(reasoning.length, 2_000) / 2
|
||
}
|
||
score += snapshot.links.length * 120
|
||
return score
|
||
}
|
||
|
||
const pickBetterSnapshot = (
|
||
current: ConversationSnapshot | null,
|
||
candidate: ConversationSnapshot,
|
||
): ConversationSnapshot => {
|
||
if (!current) {
|
||
return candidate
|
||
}
|
||
|
||
const currentHasContent = hasObservedContent(current)
|
||
const candidateHasContent = hasObservedContent(candidate)
|
||
if (candidateHasContent && !currentHasContent) {
|
||
return candidate
|
||
}
|
||
if (currentHasContent && !candidateHasContent) {
|
||
return current
|
||
}
|
||
return snapshotQualityScore(candidate) >= snapshotQualityScore(current) ? candidate : current
|
||
}
|
||
|
||
const shouldReturnStableAnswer = (
|
||
snapshot: ConversationSnapshot | null,
|
||
firstChangedContentAt: number | null,
|
||
now: number,
|
||
): boolean => {
|
||
if (!snapshot || firstChangedContentAt === null || !hasObservedContent(snapshot)) {
|
||
return false
|
||
}
|
||
|
||
const answer = normalizeMultiline(snapshot.answerText)
|
||
const reasoning = normalizeMultiline(snapshot.reasoningText)
|
||
if (!answer) {
|
||
return (
|
||
Boolean(reasoning || snapshot.links.length > 0) &&
|
||
now - firstChangedContentAt >= incompleteAnswerGraceMs
|
||
)
|
||
}
|
||
|
||
if (
|
||
answer.length >= 96 ||
|
||
countMatches(answer, /[。!?;:]/g) >= 2 ||
|
||
countMatches(answer, /\n/g) >= 2 ||
|
||
hasStructuredAnswerMarkers(answer) ||
|
||
(snapshot.links.length >= 2 && answer.length >= 48) ||
|
||
(reasoning && answer.length >= 48)
|
||
) {
|
||
return true
|
||
}
|
||
|
||
return now - firstChangedContentAt >= incompleteAnswerGraceMs
|
||
}
|
||
|
||
const isExternalHref = (value: string | null): boolean => {
|
||
if (!value) {
|
||
return false
|
||
}
|
||
try {
|
||
const hostname = new URL(value).hostname.toLowerCase()
|
||
if (!hostname) {
|
||
return false
|
||
}
|
||
return (
|
||
!hostname.endsWith('doubao.com') &&
|
||
!hostname.endsWith('bytedance.com') &&
|
||
!hostname.endsWith('volces.com')
|
||
)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
const collectLinks = (scope: HTMLElement | null): DoubaoDomLink[] => {
|
||
if (!scope) {
|
||
return []
|
||
}
|
||
const links: DoubaoDomLink[] = []
|
||
const seen = new Set<string>()
|
||
for (const node of Array.from(scope.querySelectorAll('a[href]'))) {
|
||
if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) {
|
||
continue
|
||
}
|
||
const href = normalizeHref(node.getAttribute('href') ?? node.href)
|
||
if (!href || !isExternalHref(href) || seen.has(href)) {
|
||
continue
|
||
}
|
||
seen.add(href)
|
||
links.push({
|
||
url: href,
|
||
title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')),
|
||
text: normalize(node.innerText) ?? normalize(node.textContent),
|
||
siteName:
|
||
normalize(node.getAttribute('data-site-name')) ??
|
||
normalize(node.dataset.siteName ?? null),
|
||
})
|
||
}
|
||
return links
|
||
}
|
||
|
||
const normalizedQuestion = normalizeMultiline(questionText)
|
||
|
||
// Tokens that only appear on Doubao's empty-chat surface (prompt templates, nav, feature chips).
|
||
const EMPTY_CHAT_TOKENS = [
|
||
'快速',
|
||
'帮我写作',
|
||
'图像生成',
|
||
'AI 播客',
|
||
'AI播客',
|
||
'编程',
|
||
'翻译',
|
||
'更多',
|
||
'更多功能',
|
||
'新对话',
|
||
'新建对话',
|
||
'扫码下载',
|
||
'下载 App',
|
||
'下载App',
|
||
'登录体验更多',
|
||
]
|
||
|
||
const looksLikeEmptyChatSurface = (text: string | null): boolean => {
|
||
if (!text) {
|
||
return false
|
||
}
|
||
const lines = text
|
||
.split(/\n+/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
if (lines.length === 0) {
|
||
return false
|
||
}
|
||
const hits = lines.filter((line) =>
|
||
EMPTY_CHAT_TOKENS.some((token) => line === token || line === `${token}`),
|
||
).length
|
||
// If half-or-more of the short lines are prompt-template chips, this is the empty-chat surface.
|
||
return hits >= 3 || (hits >= 2 && lines.length <= 6)
|
||
}
|
||
|
||
const stripKnownNoise = (text: string | null): string | null => {
|
||
if (!text) {
|
||
return null
|
||
}
|
||
const lines = text
|
||
.split(/\n+/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
const kept = lines.filter((line) => {
|
||
if (EMPTY_CHAT_TOKENS.includes(line)) {
|
||
return false
|
||
}
|
||
if (normalizedQuestion && line === normalizedQuestion) {
|
||
return false
|
||
}
|
||
if (/^(内容由AI生成|内容由 AI 生成)/.test(line)) {
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
const joined = kept.join('\n').trim()
|
||
return joined || null
|
||
}
|
||
|
||
const findQuestionAnchor = (): HTMLElement | null => {
|
||
if (!normalizedQuestion) {
|
||
return null
|
||
}
|
||
let best: { element: HTMLElement; order: number } | null = null
|
||
let order = 0
|
||
const candidates = Array.from(document.querySelectorAll('p, div, span, li, article, section'))
|
||
for (const node of candidates) {
|
||
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||
continue
|
||
}
|
||
const text = normalizeMultiline(node.innerText)
|
||
if (!text) {
|
||
continue
|
||
}
|
||
if (
|
||
text !== normalizedQuestion &&
|
||
!(text.includes(normalizedQuestion) && text.length <= normalizedQuestion.length + 80)
|
||
) {
|
||
continue
|
||
}
|
||
order += 1
|
||
const container =
|
||
(node.closest(
|
||
"[class*='message'], [class*='Message'], [class*='bubble'], [class*='Bubble'], [class*='user'], [class*='User'], li, article, section",
|
||
) as HTMLElement | null) ?? node
|
||
if (!best || order >= best.order) {
|
||
best = { element: container, order }
|
||
}
|
||
}
|
||
return best?.element ?? null
|
||
}
|
||
|
||
const snapshotConversation = (composerTop: number | null): ConversationSnapshot => {
|
||
const questionAnchor = findQuestionAnchor()
|
||
const anchorRect = questionAnchor?.getBoundingClientRect() ?? null
|
||
|
||
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
||
const elements = Array.from(document.querySelectorAll('article, section, div, main, li'))
|
||
|
||
for (const node of elements) {
|
||
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
||
continue
|
||
}
|
||
if (
|
||
questionAnchor &&
|
||
(node === questionAnchor || questionAnchor.contains(node) || node.contains(questionAnchor))
|
||
) {
|
||
continue
|
||
}
|
||
|
||
const rect = node.getBoundingClientRect()
|
||
if (composerTop !== null && rect.top >= composerTop - 12) {
|
||
continue
|
||
}
|
||
if (anchorRect && rect.bottom <= anchorRect.top + 4) {
|
||
// Skip elements that come strictly before the user question.
|
||
continue
|
||
}
|
||
if (rect.left < window.innerWidth * 0.08 || rect.width < 200) {
|
||
continue
|
||
}
|
||
|
||
const rawText = normalizeMultiline(node.innerText)
|
||
const text = stripKnownNoise(rawText)
|
||
const links = collectLinks(node)
|
||
if (!text && links.length === 0) {
|
||
continue
|
||
}
|
||
if (rawText && looksLikeEmptyChatSurface(rawText)) {
|
||
continue
|
||
}
|
||
if (text && text.length < 12 && links.length === 0) {
|
||
continue
|
||
}
|
||
if (text && /^(Hi[,,\s].*|请登录|请先登录|扫码登录)$/.test(text)) {
|
||
continue
|
||
}
|
||
|
||
const className = normalize(node.className)?.toLowerCase() ?? ''
|
||
const datasetKeys = Object.keys(node.dataset ?? {})
|
||
.join(' ')
|
||
.toLowerCase()
|
||
const blob = `${className} ${datasetKeys}`
|
||
|
||
if (/menu|navbar|sidebar|prompt-?template|quick-?action|home-?page|entry/.test(blob)) {
|
||
continue
|
||
}
|
||
|
||
let score = Math.min(text?.length ?? 0, 3_000) + links.length * 80 + rect.height
|
||
if (/markdown|message-content|chat-content|answer|response|assistant|bot|reply/.test(blob)) {
|
||
score += 1_400
|
||
}
|
||
if (/user|question|human/.test(blob)) {
|
||
score -= 1_200
|
||
}
|
||
if (questionAnchor && rect.top > (anchorRect?.bottom ?? 0)) {
|
||
score += 1_000
|
||
}
|
||
|
||
if (score <= 0) {
|
||
continue
|
||
}
|
||
|
||
candidates.push({ element: node, score })
|
||
}
|
||
|
||
candidates.sort((left, right) => right.score - left.score)
|
||
const best = candidates[0]?.element ?? null
|
||
|
||
let answerText: string | null = null
|
||
let reasoningText: string | null = null
|
||
|
||
if (best) {
|
||
const reasoningNode =
|
||
Array.from(
|
||
best.querySelectorAll(
|
||
"[class*='think'], [class*='Think'], [class*='reason'], [class*='Reason'], [class*='analysis']",
|
||
),
|
||
)
|
||
.filter((node): node is HTMLElement => node instanceof HTMLElement && isVisible(node))
|
||
.sort(
|
||
(left, right) =>
|
||
right.getBoundingClientRect().height - left.getBoundingClientRect().height,
|
||
)[0] ?? null
|
||
if (reasoningNode) {
|
||
reasoningText = stripKnownNoise(normalizeMultiline(reasoningNode.innerText))
|
||
}
|
||
answerText = stripKnownNoise(normalizeMultiline(best.innerText))
|
||
if (reasoningText && answerText && answerText.startsWith(reasoningText)) {
|
||
const trimmedAnswer = answerText.slice(reasoningText.length).trim()
|
||
if (trimmedAnswer) {
|
||
answerText = trimmedAnswer
|
||
}
|
||
}
|
||
}
|
||
|
||
const links = best ? collectLinks(best) : []
|
||
const signature =
|
||
`${answerText ?? ''}|${reasoningText ?? ''}|${links.map((item) => item.url).join('|')}`.slice(
|
||
-2_000,
|
||
)
|
||
|
||
return { answerText, reasoningText, links, signature }
|
||
}
|
||
|
||
const resolveModelLabel = (): string | null => {
|
||
const title = normalize(document.title)
|
||
const matched = title?.match(/豆包[^-|]*/)
|
||
return normalize(matched?.[0] ?? null)
|
||
}
|
||
|
||
const fail = (
|
||
error: string,
|
||
detail: string | null,
|
||
composerTop: number | null,
|
||
snapshotOverride: ConversationSnapshot | null = null,
|
||
): DoubaoPageQueryFailureResult => {
|
||
const snapshot = snapshotOverride ?? snapshotConversation(composerTop)
|
||
const composer = findComposer()
|
||
const shell = findComposerShell(composer)
|
||
const deepThinkButton = findInteractiveByText(/深度思考/, shell)
|
||
return {
|
||
ok: false,
|
||
error,
|
||
detail:
|
||
summarizeFailureDetail(detail) ??
|
||
summarizeFailureDetail(snapshot.answerText) ??
|
||
summarizeFailureDetail(bodyText()),
|
||
url: window.location.href || null,
|
||
title: normalize(document.title),
|
||
modelLabel: resolveModelLabel(),
|
||
deepThinkEnabled: toggleState(deepThinkButton),
|
||
domAnswer: snapshot.answerText,
|
||
domReasoning: snapshot.reasoningText,
|
||
domLinks: snapshot.links,
|
||
}
|
||
}
|
||
|
||
const readyDeadline = Date.now() + readyTimeoutMs
|
||
let composer = findComposer()
|
||
while (!composer && Date.now() <= readyDeadline) {
|
||
await wait(250)
|
||
composer = findComposer()
|
||
}
|
||
|
||
if (hasLoginGate()) {
|
||
return fail('doubao_login_required', bodyText(), composer?.getBoundingClientRect().top ?? null)
|
||
}
|
||
|
||
if (!composer) {
|
||
return fail('doubao_composer_missing', bodyText(), null)
|
||
}
|
||
|
||
let composerShell = findComposerShell(composer)
|
||
const composerTop = composer.getBoundingClientRect().top
|
||
|
||
let deepThinkApplied: boolean | null = null
|
||
if (enableDeepThink) {
|
||
const deepThinkButton = findInteractiveByText(/深度思考/, composerShell)
|
||
if (deepThinkButton) {
|
||
const currentState = toggleState(deepThinkButton)
|
||
if (currentState !== true) {
|
||
deepThinkButton.click()
|
||
await wait(180)
|
||
}
|
||
deepThinkApplied = toggleState(deepThinkButton)
|
||
}
|
||
}
|
||
|
||
setComposerText(composer, questionText)
|
||
await wait(220)
|
||
|
||
composer = findComposer() ?? composer
|
||
composerShell = findComposerShell(composer) ?? composerShell
|
||
const sendButton = findSendButton(composerShell, composer)
|
||
if (sendButton) {
|
||
sendButton.click()
|
||
} else {
|
||
dispatchEnter(composer)
|
||
}
|
||
|
||
await wait(600)
|
||
|
||
const initialSnapshot = snapshotConversation(composerTop)
|
||
let bestSnapshot = initialSnapshot
|
||
let bestContentSnapshot: ConversationSnapshot | null = hasObservedContent(initialSnapshot)
|
||
? initialSnapshot
|
||
: null
|
||
let lastSignature = initialSnapshot.signature
|
||
let stableCount = 0
|
||
let sawChange = false
|
||
let firstChangedContentAt: number | null = null
|
||
const deadline = Date.now() + timeoutMs
|
||
|
||
while (Date.now() <= deadline) {
|
||
const snapshot = snapshotConversation(composerTop)
|
||
bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot)
|
||
if (hasObservedContent(snapshot)) {
|
||
bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, snapshot)
|
||
}
|
||
|
||
const busy = hasBusyIndicator()
|
||
const hasContent = hasObservedContent(snapshot)
|
||
if (snapshot.signature && hasContent && snapshot.signature !== initialSnapshot.signature) {
|
||
sawChange = true
|
||
if (firstChangedContentAt === null) {
|
||
firstChangedContentAt = Date.now()
|
||
}
|
||
if (snapshot.signature === lastSignature) {
|
||
stableCount = busy ? 0 : stableCount + 1
|
||
} else {
|
||
lastSignature = snapshot.signature
|
||
stableCount = 0
|
||
}
|
||
}
|
||
|
||
if (hasLoginGate()) {
|
||
return fail(
|
||
'doubao_login_expired',
|
||
bodyText(),
|
||
composerTop,
|
||
bestContentSnapshot ?? bestSnapshot,
|
||
)
|
||
}
|
||
|
||
const finalSnapshot = bestContentSnapshot
|
||
? pickBetterSnapshot(bestSnapshot, bestContentSnapshot)
|
||
: bestSnapshot
|
||
if (
|
||
sawChange &&
|
||
!busy &&
|
||
stableCount >= stablePollsRequired &&
|
||
shouldReturnStableAnswer(finalSnapshot, firstChangedContentAt, Date.now())
|
||
) {
|
||
const deepThinkButton = findInteractiveByText(/深度思考/, composerShell)
|
||
return {
|
||
ok: true,
|
||
url: window.location.href,
|
||
title: normalize(document.title),
|
||
modelLabel: resolveModelLabel(),
|
||
deepThinkEnabled: toggleState(deepThinkButton) ?? deepThinkApplied,
|
||
domAnswer: finalSnapshot.answerText,
|
||
domReasoning: finalSnapshot.reasoningText,
|
||
domLinks: finalSnapshot.links,
|
||
}
|
||
}
|
||
|
||
await wait(pollIntervalMs)
|
||
}
|
||
|
||
return fail('doubao_query_timeout', bodyText(), composerTop, bestContentSnapshot ?? bestSnapshot)
|
||
}
|
||
|
||
export const doubaoAdapter: MonitorAdapter = {
|
||
provider: 'doubao',
|
||
executionMode: 'playwright',
|
||
async query(context, payload) {
|
||
if (!context.playwright?.page) {
|
||
return {
|
||
status: 'failed',
|
||
summary: '豆包监测缺少 Playwright 页面上下文。',
|
||
error: buildAdapterError('doubao_playwright_required', 'doubao_playwright_required'),
|
||
}
|
||
}
|
||
|
||
const questionText = extractQuestionText(payload)
|
||
const page = context.playwright.page
|
||
|
||
context.reportProgress('doubao.page_ready')
|
||
await ensurePageOnDoubaoChat(page, context.signal)
|
||
|
||
const capture = attachDoubaoResponseCapture(page)
|
||
let pageResult: DoubaoPageQueryResult
|
||
|
||
try {
|
||
context.reportProgress('doubao.query')
|
||
pageResult = await page.evaluate(doubaoQueryInPage, {
|
||
questionText,
|
||
timeoutMs: DOUBAO_QUERY_TIMEOUT_MS,
|
||
readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS,
|
||
enableDeepThink: true,
|
||
})
|
||
await sleep(600, context.signal).catch(() => undefined)
|
||
await capture.flush(DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS)
|
||
} finally {
|
||
capture.dispose()
|
||
}
|
||
|
||
const streamSummary = parseDoubaoCaptures(capture.captures)
|
||
const captureDebug = summarizeCapturesForDebug(capture.captures)
|
||
const pendingCaptureCount = capture.pendingCount()
|
||
const domSearchResults = dedupeSourceItems(
|
||
pageResult.domLinks
|
||
.map((item) =>
|
||
buildSourceItem({
|
||
url: item.url,
|
||
title: item.title ?? item.text ?? null,
|
||
site_name: item.siteName,
|
||
}),
|
||
)
|
||
.filter((item): item is MonitoringSourceItem => item !== null),
|
||
)
|
||
const searchResults = dedupeSourceItems([...streamSummary.search_results, ...domSearchResults])
|
||
const answer = selectBestDoubaoText(streamSummary.answer, pageResult.domAnswer)
|
||
const reasoning = selectBestDoubaoText(streamSummary.reasoning, pageResult.domReasoning)
|
||
const providerModel = resolveProviderModel(pageResult)
|
||
const conversationID = streamSummary.conversation_id ?? extractConversationID(pageResult.url)
|
||
const requestID = streamSummary.request_id ?? conversationID
|
||
const hasRecoveredContent = Boolean(answer) || searchResults.length > 0
|
||
const retryableStreamError = isDoubaoRetryableErrorMessage(streamSummary.error_message)
|
||
|
||
if (pageResult.ok === false) {
|
||
const failed = pageResult
|
||
const detail = normalizeText(failed.detail) ?? 'unknown'
|
||
if (failed.error === 'doubao_login_required' || failed.error === 'doubao_login_expired') {
|
||
return {
|
||
status: 'failed',
|
||
summary: '豆包账号未登录或登录态已失效,请先在桌面客户端中重新授权。',
|
||
error: buildAdapterError(failed.error, detail, {
|
||
page_url: failed.url ?? safePageURL(page),
|
||
}),
|
||
}
|
||
}
|
||
|
||
if (failed.error !== 'doubao_query_timeout' || !hasRecoveredContent) {
|
||
if (streamSummary.error_message) {
|
||
if (retryableStreamError) {
|
||
if (!hasRecoveredContent) {
|
||
return buildDoubaoRetryableUnknownResult(streamSummary.error_message, {
|
||
page_url: failed.url ?? safePageURL(page),
|
||
page_error: failed.error,
|
||
})
|
||
}
|
||
} else {
|
||
return {
|
||
status: 'failed',
|
||
summary: `豆包请求失败:${streamSummary.error_message}`,
|
||
error: buildAdapterError('doubao_stream_error', streamSummary.error_message, {
|
||
page_url: failed.url ?? safePageURL(page),
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!retryableStreamError || !hasRecoveredContent) {
|
||
if (retryableStreamError) {
|
||
return buildDoubaoRetryableUnknownResult(detail, {
|
||
page_url: failed.url ?? safePageURL(page),
|
||
page_error: failed.error,
|
||
})
|
||
}
|
||
return {
|
||
status: 'failed',
|
||
summary: `豆包请求失败:${detail}`,
|
||
error: buildAdapterError(failed.error, detail, {
|
||
page_url: failed.url ?? safePageURL(page),
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
} else if (streamSummary.error_message && !hasRecoveredContent) {
|
||
if (retryableStreamError) {
|
||
return buildDoubaoRetryableUnknownResult(streamSummary.error_message, {
|
||
page_url: pageResult.url,
|
||
})
|
||
}
|
||
return {
|
||
status: 'failed',
|
||
summary: `豆包请求失败:${streamSummary.error_message}`,
|
||
error: buildAdapterError('doubao_stream_error', streamSummary.error_message, {
|
||
page_url: pageResult.url,
|
||
}),
|
||
}
|
||
}
|
||
|
||
if (!hasRecoveredContent) {
|
||
return {
|
||
status: 'unknown',
|
||
summary: '豆包返回为空,已回写 unknown 等待后续对账。',
|
||
error: buildAdapterError('doubao_empty_response', 'doubao returned no answer or sources'),
|
||
}
|
||
}
|
||
|
||
context.reportProgress('doubao.parse_result')
|
||
return {
|
||
status: 'succeeded',
|
||
summary: '豆包监控任务执行成功。',
|
||
payload: {
|
||
platform: 'doubao',
|
||
provider_model: providerModel,
|
||
provider_request_id: streamSummary.provider_request_id,
|
||
request_id: requestID,
|
||
conversation_id: conversationID,
|
||
answer,
|
||
reasoning,
|
||
citation_count: 0,
|
||
search_result_count: searchResults.length,
|
||
citations: [],
|
||
search_results: toJsonSources(searchResults),
|
||
raw_response_json: {
|
||
platform: 'doubao',
|
||
page_url: pageResult.url,
|
||
page_title: pageResult.title,
|
||
model_label: pageResult.modelLabel,
|
||
deep_think_enabled: pageResult.deepThinkEnabled,
|
||
capture_count: capture.captures.length,
|
||
capture_pending_after_flush: pendingCaptureCount,
|
||
event_count: streamSummary.event_count,
|
||
stream_search_result_count: streamSummary.search_results.length,
|
||
dom_search_result_count: domSearchResults.length,
|
||
stream_error_message: streamSummary.error_message,
|
||
stream_error_retryable: retryableStreamError,
|
||
stream_answer_present: Boolean(streamSummary.answer),
|
||
dom_answer_present: Boolean(pageResult.domAnswer),
|
||
stream_answer_length: streamSummary.answer?.length ?? 0,
|
||
dom_answer_length: pageResult.domAnswer?.length ?? 0,
|
||
selected_answer_length: answer?.length ?? 0,
|
||
captures: captureDebug,
|
||
},
|
||
},
|
||
}
|
||
},
|
||
}
|
||
|
||
export const __doubaoTestUtils = {
|
||
buildSourceItem,
|
||
dedupeSourceItems,
|
||
isNonCitationAssetDomain,
|
||
isNonCitationAssetUrl,
|
||
parseDoubaoStreamBody,
|
||
}
|