import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types'
import type { Session } from 'electron/main'
import type { Page as PlaywrightPage } from 'playwright-core'
import type { MonitorAdapter } from './base'
import { normalizeText, sessionCookieFetchJson } from './common'
const WENXIN_BOOTSTRAP_URL = 'https://yiyan.baidu.com/'
const WENXIN_HISTORY_URL = 'https://yiyan.baidu.com/eb/chat/history'
const WENXIN_PAGE_READY_TIMEOUT_MS = 20_000
const WENXIN_QUERY_TIMEOUT_MS = 180_000
const WENXIN_QUERY_POLL_INTERVAL_MS = 1_250
const WENXIN_STABLE_POLLS_REQUIRED = 3
const WENXIN_STREAM_CAPTURE_LIMIT = 8
const WENXIN_STREAM_CAPTURE_BODY_LIMIT = 800_000
const WENXIN_EDITOR_SELECTOR =
'div[role="textbox"].editable__T7WAW4uW, div[role="textbox"], [contenteditable="true"]'
const WENXIN_LOGIN_SIGNALS = ['未登录', '请先登录', '登录', '扫码登录', '百度账号登录']
const WENXIN_RISK_CONTROL_PATTERN =
/(当前访问环境存在异常|访问环境存在异常|环境存在异常|更换浏览器再尝试提问|当前环境异常)/i
const WENXIN_LOGIN_REQUIRED_PATTERN = /(请先登录|未登录|登录后|登录态失效|重新登录)/i
const WENXIN_PLACEHOLDER_PATTERN =
/^(?:正在生成中(?:\.\.\.)?|思考中(?:\.\.\.)?|内容由AI生成,仅供参考,请仔细甄别|全选)$/
const WENXIN_TABLE_HTML_PATTERN = /
/gi
const WENXIN_REFERENCE_TRIGGER_PATTERN =
/(参考\s*\d+\s*(?:个网页|条网页信息源)|found\s*\d+\s*web pages)/i
const WENXIN_CITATION_FIELD_PATTERN =
/^(?:citations?|citationInfo|pluginCitations?|pluginCitation|reference(?:s|List|_list|Cards|_cards|Docs|_docs|Info|_info)?|source(?:s|List|_list|Cards|_cards|Info|_info)?|web(?:References?|Sources?|Pages?|Docs?|Results?)|page(?:References?|Sources?))$/i
const WENXIN_SEARCH_FIELD_PATTERN =
/^(?:searchCitations?|search_results?|searchResultList|search_result_list|webListData|webList|web(?:References?|Sources?|Pages?|Docs?|Results?)|browseReferences?|browse_results?|grounding(?:_results?)?)$/i
const WENXIN_REDIRECT_QUERY_KEYS = [
'url',
'u',
'target',
'targetUrl',
'target_url',
'link',
'href',
'sourceUrl',
'source_url',
'originUrl',
'origin_url',
'realUrl',
'real_url',
'redirect',
'redirect_url',
'jump',
'jump_url',
'to',
]
type WenxinPageSourceLink = {
url: string
title: string | null
text: string | null
siteName: string | null
}
type WenxinPageSnapshot = {
url: string
title: string | null
loginRequired: boolean
loginReason: string | null
isLogin: boolean | null
modelLabel: string | null
userName: string | null
editorText: string | null
bodySample: string | null
referenceTriggerText: string | null
referenceLinks: WenxinPageSourceLink[]
}
type WenxinCapturedStreamRecord = {
id: string
url: string
method: string
status: number | null
contentType: string | null
requestBody: string | null
body: string
done: boolean
error: string | null
startedAt: number
updatedAt: number
}
type WenxinStreamSummary = {
sessionId: string | null
userChatId: string | null
botChatId: string | null
providerModel: string | null
providerRequestID: string | null
answer: string | null
withdrawText: string | null
errorMessage: string | null
isBanned: boolean
sessionBan: boolean
sessionDel: boolean
isEnded: boolean
eventCount: number
captureDone: boolean
captureError: string | null
}
type WenxinHistorySummary = {
sessionId: string | null
loginRequired: boolean
providerModel: string | null
providerRequestID: string | null
latestChatId: string | null
latestChatMode: string | null
latestChatStop: number | boolean | null
state: number | string | null
answer: string | null
answerBlocks: string[]
citations: MonitoringSourceItem[]
searchResults: MonitoringSourceItem[]
withdrawText: string | null
errorMessage: string | null
generating: boolean
signature: string | null
}
type WenxinObservation = {
sessionId: string | null
providerModel: string | null
providerRequestID: string | null
answer: string | null
answerBlocks: string[]
citations: MonitoringSourceItem[]
searchResults: MonitoringSourceItem[]
withdrawText: string | null
errorMessage: string | null
historyGenerating: boolean
streamDone: boolean
signature: string | null
}
type WenxinWaitResult =
| {
ok: true
stream: WenxinStreamSummary
history: WenxinHistorySummary | null
observation: WenxinObservation
stablePollCount: number
elapsedMs: number
}
| {
ok: false
error: string
detail: string | null
stream: WenxinStreamSummary
history: WenxinHistorySummary | null
observation: WenxinObservation
stablePollCount: number
elapsedMs: number
}
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed ? trimmed : null
}
function normalizeBlockText(value: string | null): string | null {
const normalized = normalizeOptionalString(value)
if (!normalized) {
return null
}
return (
normalized
.replace(/\u00a0/g, ' ')
.replace(/\r/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim() || null
)
}
function decodeUrlCandidate(value: string): string {
let decoded = value.trim()
for (let index = 0; index < 3; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) {
break
}
decoded = next
} catch {
break
}
}
return decoded
}
function coercePotentialUrl(value: string): string {
const trimmed = decodeUrlCandidate(value)
if (/^\/\//.test(trimmed)) {
return `https:${trimmed}`
}
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(trimmed)) {
return `https://${trimmed}`
}
return trimmed
}
function normalizeAbsoluteUrl(value: string): string | null {
try {
const url = new URL(coercePotentialUrl(value))
url.hash = ''
return url.toString()
} catch {
return null
}
}
function extractRedirectTargetUrl(value: string): string | null {
const direct = normalizeAbsoluteUrl(value)
if (!direct) {
return null
}
try {
const url = new URL(direct)
for (const key of WENXIN_REDIRECT_QUERY_KEYS) {
const raw = url.searchParams.get(key)
if (!raw) {
continue
}
const normalized = normalizeAbsoluteUrl(raw)
if (normalized) {
return normalized
}
}
} catch {
return null
}
const encodedMatch = direct.match(/https?%3A%2F%2F[^&\s"'<>]+/i)
if (encodedMatch) {
return normalizeAbsoluteUrl(encodedMatch[0])
}
const plainMatch = direct.match(/https?:\/\/[^&\s"'<>]+/i)
if (plainMatch && plainMatch[0] !== direct) {
return normalizeAbsoluteUrl(plainMatch[0])
}
return null
}
function normalizeUrl(value: unknown): string | null {
const input = normalizeText(value)
if (!input) {
return null
}
return extractRedirectTargetUrl(input) ?? normalizeAbsoluteUrl(input)
}
function isLikelyWenxinSourceUrl(url: string | null): boolean {
if (!url) {
return false
}
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
if (!/^https?:$/i.test(parsed.protocol)) {
return false
}
if (
host === 'yiyan.baidu.com' ||
host.endsWith('.yiyan.baidu.com') ||
host === 'eb-static.cdn.bcebos.com' ||
host === 'ppui-static-wap.cdn.bcebos.com'
) {
return false
}
return true
} catch {
return false
}
}
function readStringByKeys(source: unknown, keys: string[]): string | null {
if (!isRecord(source)) {
return null
}
for (const key of keys) {
const value = normalizeOptionalString(source[key])
if (value) {
return value
}
}
return null
}
function readBooleanByKeys(source: unknown, keys: string[]): boolean | null {
if (!isRecord(source)) {
return null
}
for (const key of keys) {
const value = source[key]
if (typeof value === 'boolean') {
return value
}
}
return null
}
function readNumberByKeys(source: unknown, keys: string[]): number | null {
if (!isRecord(source)) {
return null
}
for (const key of keys) {
const value = source[key]
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
}
return null
}
function normalizeStopFlag(value: unknown): number | boolean | null {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase()
if (!trimmed) {
return null
}
if (trimmed === 'true') {
return true
}
if (trimmed === 'false') {
return false
}
const numericValue = Number(trimmed)
if (Number.isFinite(numericValue)) {
return numericValue
}
}
return null
}
function mergeText(current: string | null, next: string | null): string | null {
const left = normalizeBlockText(current)
const right = normalizeBlockText(next)
if (!left) {
return right
}
if (!right) {
return left
}
if (right.startsWith(left)) {
return right
}
if (left.startsWith(right)) {
return left
}
if (left.endsWith(right)) {
return left
}
if (right.endsWith(left)) {
return right
}
return `${left}${right}`
}
function safeParseJSON(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return null
}
}
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
const keyed = new Map()
for (const item of items) {
const key = normalizeUrl(item.normalized_url ?? item.url)
if (!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,
host: existing.host ?? item.host ?? null,
})
}
return Array.from(keyed.values())
}
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
if (!isRecord(input)) {
return null
}
const url = normalizeUrl(
input.url ??
input.href ??
input.link ??
input.pageUrl ??
input.page_url ??
input.webUrl ??
input.web_url ??
input.realUrl ??
input.real_url ??
input.displayUrl ??
input.display_url ??
input.originUrl ??
input.origin_url ??
input.targetUrl ??
input.target_url ??
input.sourceUrl ??
input.source_url,
)
if (!url || !isLikelyWenxinSourceUrl(url)) {
return null
}
let host: string | null = null
try {
host = new URL(url).hostname || null
} catch {
host = null
}
return {
url,
title: normalizeText(input.title ?? input.name ?? input.text ?? input.label ?? input.desc),
site_name: normalizeText(
input.site_name ?? input.siteName ?? input.site ?? input.domain ?? input.source,
),
normalized_url: url,
host,
}
}
function collectSourceBucketByFieldPattern(
input: unknown,
bucket: MonitoringSourceItem[],
pattern: RegExp,
depth = 0,
): void {
if (depth > 10 || input == null) {
return
}
if (Array.isArray(input)) {
for (const item of input) {
collectSourceBucketByFieldPattern(item, bucket, pattern, depth + 1)
}
return
}
if (!isRecord(input)) {
return
}
for (const [key, value] of Object.entries(input)) {
if (pattern.test(key)) {
collectSourceBucket(value, bucket, depth + 1)
}
if (Array.isArray(value) || isRecord(value)) {
collectSourceBucketByFieldPattern(value, bucket, pattern, depth + 1)
}
}
}
function collectSourceBucket(input: unknown, bucket: MonitoringSourceItem[], depth = 0): void {
if (depth > 10 || input == null) {
return
}
if (Array.isArray(input)) {
for (const item of input) {
collectSourceBucket(item, bucket, depth + 1)
}
return
}
const sourceItem = buildSourceItem(input)
if (sourceItem) {
bucket.push(sourceItem)
}
if (!isRecord(input)) {
return
}
for (const value of Object.values(input)) {
if (Array.isArray(value) || isRecord(value)) {
collectSourceBucket(value, bucket, depth + 1)
}
}
}
function decodeHtmlEntities(value: string): string {
const namedEntityMap: Record = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
mdash: '-',
ndash: '-',
}
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (matched, entity: string) => {
const lower = entity.toLowerCase()
if (lower in namedEntityMap) {
return namedEntityMap[lower]
}
if (lower.startsWith('#x')) {
const codePoint = Number.parseInt(lower.slice(2), 16)
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : matched
}
if (lower.startsWith('#')) {
const codePoint = Number.parseInt(lower.slice(1), 10)
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : matched
}
return matched
})
}
function stripHtml(value: string): string {
return decodeHtmlEntities(
value
.replace(/
/gi, '\n')
.replace(/<\/(?:p|div|section|article|li|ul|ol|h[1-6])>/gi, '\n')
.replace(/]*>/gi, '- ')
.replace(/<[^>]+>/g, ' '),
)
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/[ \t]{2,}/g, ' ')
}
function htmlTableToMarkdown(tableHtml: string): string | null {
const rowMatches = [...tableHtml.matchAll(/]*>([\s\S]*?)<\/tr>/gi)]
if (!rowMatches.length) {
return null
}
const rows = rowMatches
.map((match) => [...match[1].matchAll(/]*>([\s\S]*?)<\/t[hd]>/gi)])
.map((cells) =>
cells
.map((cell) => normalizeBlockText(stripHtml(cell[1] ?? '')) ?? '')
.map((cell) => cell.replace(/\|/g, '\\|').replace(/\n+/g, '
')),
)
.filter((cells) => cells.some((cell) => cell.length > 0))
if (!rows.length) {
return null
}
const header = rows[0]
const body = rows.slice(1)
const separator = header.map(() => '---')
const lines = [
`| ${header.join(' | ')} |`,
`| ${separator.join(' | ')} |`,
...body.map((row) => `| ${row.join(' | ')} |`),
]
return normalizeBlockText(lines.join('\n'))
}
function textSegmentsFromHtml(value: string): string[] {
const result: string[] = []
const push = (segment: string | null) => {
const normalized = normalizeBlockText(segment)
if (normalized) {
result.push(normalized)
}
}
const tables = [...value.matchAll(WENXIN_TABLE_HTML_PATTERN)]
for (const match of tables) {
push(htmlTableToMarkdown(match[0]))
}
const withoutTables = value.replace(WENXIN_TABLE_HTML_PATTERN, '\n')
push(stripHtml(withoutTables))
return result
}
function extractTextSegments(value: unknown, result: string[], seen: Set, depth = 0): void {
if (depth > 6 || value == null) {
return
}
const push = (segment: string | null) => {
const normalized = normalizeBlockText(segment)
if (!normalized || WENXIN_PLACEHOLDER_PATTERN.test(normalized)) {
return
}
const key = normalized.replace(/\s+/g, ' ')
if (seen.has(key)) {
return
}
seen.add(key)
result.push(normalized)
}
if (typeof value === 'string') {
if (/<[a-z][\s\S]*>/i.test(value)) {
for (const segment of textSegmentsFromHtml(value)) {
push(segment)
}
return
}
push(value)
return
}
if (Array.isArray(value)) {
for (const item of value) {
extractTextSegments(item, result, seen, depth + 1)
}
return
}
if (!isRecord(value)) {
return
}
for (const key of ['content', 'text', 'tips', 'quoteText', 'markdown', 'html']) {
if (key in value) {
extractTextSegments(value[key], result, seen, depth + 1)
}
}
for (const key of ['htmlMetaInfo', 'metaData', 'resInfo']) {
if (key in value) {
extractTextSegments(value[key], result, seen, depth + 1)
}
}
}
function extractWenxinMessageBlocks(messages: unknown): string[] {
const result: string[] = []
const seen = new Set()
if (Array.isArray(messages)) {
for (const message of messages) {
extractTextSegments(message, result, seen)
}
return result
}
extractTextSegments(messages, result, seen)
return result
}
function answerQualityScore(value: string | null): number {
const normalized = normalizeBlockText(value)
if (!normalized) {
return Number.NEGATIVE_INFINITY
}
let score = normalized.length
if (/\| .+ \|/.test(normalized)) {
score += 120
}
if (/(?:^|\n)(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(normalized)) {
score += 40
}
if (WENXIN_PLACEHOLDER_PATTERN.test(normalized)) {
score -= 500
}
return score
}
function selectBestAnswer(primary: string | null, secondary: string | null): string | null {
const first = normalizeBlockText(primary)
const second = normalizeBlockText(secondary)
if (!first) {
return second
}
if (!second) {
return first
}
return answerQualityScore(second) >= answerQualityScore(first) ? second : first
}
function extractQuestionText(payload: Record): string {
const candidates = [
payload.question_text,
payload.questionText,
payload.query,
payload.question,
payload.prompt,
payload.content,
payload.title,
]
for (const candidate of candidates) {
const text = normalizeOptionalString(candidate)
if (text) {
return text
}
}
throw new Error('wenxin_question_text_missing')
}
function toJsonValue(value: unknown, depth = 0): JsonValue {
if (value == null || depth > 12) {
return null
}
if (typeof value === 'string' || typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (Array.isArray(value)) {
return value.map((item) => toJsonValue(item, depth + 1))
}
if (!isRecord(value)) {
return null
}
const result: Record = {}
for (const [key, item] of Object.entries(value)) {
result[key] = toJsonValue(item, depth + 1)
}
return result
}
function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] {
return items.map((item) => ({
url: item.url,
title: item.title ?? null,
site_name: item.site_name ?? null,
site_key: item.site_key ?? null,
normalized_url: item.normalized_url ?? null,
host: item.host ?? null,
registrable_domain: item.registrable_domain ?? null,
subdomain: item.subdomain ?? null,
suffix: item.suffix ?? null,
article_id: item.article_id ?? null,
publish_record_id: item.publish_record_id ?? null,
resolution_status: item.resolution_status ?? null,
resolution_confidence: item.resolution_confidence ?? null,
}))
}
function buildAdapterError(
code: string,
message: string,
extras: Record = {},
): Record {
return {
code,
message,
...extras,
}
}
function emptyWenxinStreamSummary(): WenxinStreamSummary {
return {
sessionId: null,
userChatId: null,
botChatId: null,
providerModel: null,
providerRequestID: null,
answer: null,
withdrawText: null,
errorMessage: null,
isBanned: false,
sessionBan: false,
sessionDel: false,
isEnded: false,
eventCount: 0,
captureDone: false,
captureError: null,
}
}
function parseSSERecord(record: string): { event: string; payload: unknown } | null {
const lines = record.split(/\r?\n/)
let eventName = 'message'
const dataLines: string[] = []
for (const line of lines) {
if (!line || line.startsWith(':') || line.startsWith('ERR:')) {
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 parseWenxinSSEBody(
body: string,
captureDone = false,
captureError: string | null = null,
): WenxinStreamSummary {
const summary = emptyWenxinStreamSummary()
summary.captureDone = captureDone
summary.captureError = captureError
if (!body) {
return summary
}
const records = body
.split(/\r?\n\r?\n+/)
.map((record) => record.trim())
.filter(Boolean)
for (const record of records) {
const parsed = parseSSERecord(record)
if (!parsed || !isRecord(parsed.payload)) {
continue
}
summary.eventCount += 1
if (parsed.event === 'major') {
const payloadData = isRecord(parsed.payload.data) ? parsed.payload.data : null
const createChat = isRecord(payloadData?.createChatResponseVoCommonResult)
? payloadData.createChatResponseVoCommonResult
: null
const createChatData = isRecord(createChat?.data) ? createChat.data : null
const createSession = isRecord(payloadData?.createSessionResponseVoCommonResult)
? payloadData.createSessionResponseVoCommonResult
: null
const createSessionData = isRecord(createSession?.data) ? createSession.data : null
const botChat = isRecord(createChatData?.botChat) ? createChatData.botChat : null
const userChat = isRecord(createChatData?.chat) ? createChatData.chat : null
summary.sessionId = readStringByKeys(createSessionData, ['sessionId']) ?? summary.sessionId
summary.userChatId = readStringByKeys(userChat, ['id']) ?? summary.userChatId
summary.botChatId = readStringByKeys(botChat, ['id']) ?? summary.botChatId
summary.providerModel =
readStringByKeys(createSessionData, ['model']) ??
readStringByKeys(botChat, ['modelSign', 'model']) ??
summary.providerModel
summary.providerRequestID =
readStringByKeys(botChat, ['messageId']) ??
readStringByKeys(createChat, ['logId']) ??
readStringByKeys(parsed.payload, ['logId']) ??
summary.providerRequestID
summary.withdrawText =
readStringByKeys(createChatData, ['withdrawText']) ?? summary.withdrawText
summary.errorMessage =
readStringByKeys(createChatData, ['errMessage']) ??
readStringByKeys(createChat, ['msg']) ??
summary.errorMessage
summary.isBanned = readBooleanByKeys(createChatData, ['isBanned']) ?? summary.isBanned
summary.sessionBan = readBooleanByKeys(createChatData, ['sessionBan']) ?? summary.sessionBan
summary.sessionDel = readBooleanByKeys(createChatData, ['sessionDel']) ?? summary.sessionDel
const initialAnswer = extractWenxinMessageBlocks(botChat?.message).join('\n\n')
summary.answer = selectBestAnswer(summary.answer, initialAnswer)
continue
}
if (parsed.event === 'message') {
const payloadData = isRecord(parsed.payload.data) ? parsed.payload.data : null
const chatID = readStringByKeys(payloadData, ['chat_id', 'chatId'])
if (summary.botChatId && chatID && summary.botChatId !== chatID) {
continue
}
if (!summary.botChatId && chatID) {
summary.botChatId = chatID
}
const tokensAll = readStringByKeys(payloadData, ['tokens_all'])
const delta = readStringByKeys(payloadData, ['content', 'text'])
summary.answer = tokensAll
? selectBestAnswer(summary.answer, tokensAll)
: mergeText(summary.answer, delta)
summary.isEnded = readBooleanByKeys(payloadData, ['is_end']) ?? summary.isEnded
const messageText =
readStringByKeys(parsed.payload, ['msg']) ??
readStringByKeys(payloadData, ['msg', 'message', 'detail'])
const code = readNumberByKeys(parsed.payload, ['code'])
if (code !== null && code !== 0 && messageText) {
summary.errorMessage = messageText
}
}
}
if (!summary.errorMessage && captureError && !/AbortError/i.test(captureError)) {
summary.errorMessage = captureError
}
return summary
}
function findLatestRobotChat(chats: unknown[]): Record | null {
for (let index = chats.length - 1; index >= 0; index -= 1) {
const item = chats[index]
if (!isRecord(item)) {
continue
}
const role = readStringByKeys(item, ['role'])
if (role !== 'robot') {
continue
}
const parent = readStringByKeys(item, ['parent'])
if (parent === '0' && index < chats.length - 1) {
continue
}
return item
}
return null
}
function parseWenxinHistoryResponse(
payload: unknown,
sessionId: string | null = null,
): WenxinHistorySummary {
const code = readNumberByKeys(payload, ['code'])
const message = readStringByKeys(payload, ['msg'])
if (code === 1001 || WENXIN_LOGIN_REQUIRED_PATTERN.test(message ?? '')) {
return {
sessionId,
loginRequired: true,
providerModel: null,
providerRequestID: null,
latestChatId: null,
latestChatMode: null,
latestChatStop: null,
state: null,
answer: null,
answerBlocks: [],
citations: [],
searchResults: [],
withdrawText: null,
errorMessage: message ?? '请先登录',
generating: false,
signature: null,
}
}
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : null
const chats = Array.isArray(data?.chats) ? data.chats : []
const latestChat = findLatestRobotChat(chats)
const answerBlocks = extractWenxinMessageBlocks(latestChat?.message)
const answer = normalizeBlockText(answerBlocks.join('\n\n'))
const citationBucket: MonitoringSourceItem[] = []
collectSourceBucketByFieldPattern(latestChat, citationBucket, WENXIN_CITATION_FIELD_PATTERN)
if (!citationBucket.length) {
collectSourceBucketByFieldPattern(data, citationBucket, WENXIN_CITATION_FIELD_PATTERN)
}
const searchBucket: MonitoringSourceItem[] = []
collectSourceBucketByFieldPattern(latestChat, searchBucket, WENXIN_SEARCH_FIELD_PATTERN)
if (!searchBucket.length) {
collectSourceBucketByFieldPattern(data, searchBucket, WENXIN_SEARCH_FIELD_PATTERN)
}
const citations = dedupeSourceItems([...citationBucket, ...searchBucket])
const searchResults = dedupeSourceItems(searchBucket)
const withdrawText =
readStringByKeys(latestChat, ['withdrawText']) ?? readStringByKeys(data, ['withdrawText'])
const errorMessage =
readStringByKeys(latestChat, ['errMessage']) ?? (code !== null && code !== 0 ? message : null)
const latestChatStop = normalizeStopFlag(latestChat?.stop)
const latestChatMode = readStringByKeys(latestChat, ['mode'])
const state = data?.state ?? null
const generating =
latestChatStop === 0 ||
(answer !== null && WENXIN_PLACEHOLDER_PATTERN.test(answer)) ||
(!answer &&
!withdrawText &&
!errorMessage &&
!citations.length &&
!searchResults.length &&
Boolean(latestChat))
return {
sessionId,
loginRequired: false,
providerModel: readStringByKeys(latestChat, ['modelSign', 'model']),
providerRequestID:
readStringByKeys(latestChat, ['messageId']) ?? readStringByKeys(payload, ['logId']),
latestChatId: readStringByKeys(latestChat, ['id']),
latestChatMode,
latestChatStop,
state: typeof state === 'number' || typeof state === 'string' ? state : null,
answer,
answerBlocks,
citations,
searchResults,
withdrawText,
errorMessage,
generating,
signature: JSON.stringify({
latestChatId: readStringByKeys(latestChat, ['id']),
latestChatMode,
latestChatStop,
state: typeof state === 'number' || typeof state === 'string' ? state : null,
answer,
citationCount: citations.length,
searchCount: searchResults.length,
withdrawText,
errorMessage,
}),
}
}
function mergeWenxinObservation(
stream: WenxinStreamSummary,
history: WenxinHistorySummary | null,
): WenxinObservation {
const citations = dedupeSourceItems([...(history?.citations ?? [])])
const searchResults = dedupeSourceItems([...(history?.searchResults ?? [])])
const answer = selectBestAnswer(stream.answer, history?.answer ?? null)
const answerBlocks = history?.answerBlocks.length ? history.answerBlocks : answer ? [answer] : []
const withdrawText = history?.withdrawText ?? stream.withdrawText
const errorMessage = history?.errorMessage ?? stream.errorMessage ?? stream.captureError
return {
sessionId: history?.sessionId ?? stream.sessionId,
providerModel: history?.providerModel ?? stream.providerModel,
providerRequestID: history?.providerRequestID ?? stream.providerRequestID,
answer,
answerBlocks,
citations,
searchResults,
withdrawText,
errorMessage,
historyGenerating: history?.generating ?? false,
streamDone: stream.captureDone || stream.isEnded,
signature: JSON.stringify({
sessionId: history?.sessionId ?? stream.sessionId,
answer,
answerBlockCount: answerBlocks.length,
citationCount: citations.length,
searchCount: searchResults.length,
withdrawText,
errorMessage,
historyGenerating: history?.generating ?? false,
streamDone: stream.captureDone || stream.isEnded,
}),
}
}
function isWenxinRiskControlMessage(value: string | null | undefined): boolean {
return WENXIN_RISK_CONTROL_PATTERN.test(value ?? '')
}
function isWenxinLoginMessage(value: string | null | undefined): boolean {
return WENXIN_LOGIN_REQUIRED_PATTERN.test(value ?? '')
}
function isObservationComplete(observation: WenxinObservation): boolean {
if (observation.historyGenerating) {
return false
}
if (observation.answer && WENXIN_PLACEHOLDER_PATTERN.test(observation.answer)) {
return false
}
return Boolean(
observation.answer || observation.citations.length || observation.searchResults.length,
)
}
async function sleep(ms: number, signal?: AbortSignal): Promise {
await new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('adapter_aborted'))
return
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
reject(new Error('adapter_aborted'))
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
async function ensurePageOnWenxinChat(page: PlaywrightPage, signal: AbortSignal): Promise {
if (signal.aborted) {
throw new Error('adapter_aborted')
}
await page.goto(WENXIN_BOOTSTRAP_URL, {
waitUntil: 'domcontentloaded',
timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
})
await page
.waitForLoadState('domcontentloaded', {
timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
})
.catch(() => undefined)
await page
.waitForLoadState('networkidle', {
timeout: 3_000,
})
.catch(() => undefined)
}
async function readWenxinPageSnapshot(page: PlaywrightPage): Promise {
return await page.evaluate(
({
editorSelector,
loginSignals,
triggerPatternSource,
citationFieldPatternSource,
searchFieldPatternSource,
redirectQueryKeys,
}) => {
const isObjectRecord = (value: unknown): value is Record =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const normalize = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value
.replace(/\u00a0/g, ' ')
.replace(/\r/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
return trimmed || null
}
const decodeUrlCandidate = (value: string): string => {
let decoded = value.trim()
for (let index = 0; index < 3; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) {
break
}
decoded = next
} catch {
break
}
}
return decoded
}
const coercePotentialUrl = (value: string): string => {
const trimmed = decodeUrlCandidate(value)
if (/^\/\//.test(trimmed)) {
return `https:${trimmed}`
}
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(trimmed)) {
return `https://${trimmed}`
}
return trimmed
}
const normalizeAbsoluteHref = (value: string): string | null => {
try {
const url = new URL(coercePotentialUrl(value), window.location.href)
url.hash = ''
return url.toString()
} catch {
return null
}
}
const extractRedirectTargetHref = (value: string): string | null => {
const direct = normalizeAbsoluteHref(value)
if (!direct) {
return null
}
try {
const url = new URL(direct)
for (const key of redirectQueryKeys) {
const raw = url.searchParams.get(key)
if (!raw) {
continue
}
const normalized = normalizeAbsoluteHref(raw)
if (normalized) {
return normalized
}
}
} catch {
return null
}
const encodedMatch = direct.match(/https?%3A%2F%2F[^&\s"'<>]+/i)
if (encodedMatch) {
return normalizeAbsoluteHref(encodedMatch[0])
}
return null
}
const normalizeHref = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
if (!trimmed || /^(?:javascript|data|blob|mailto|tel):/i.test(trimmed)) {
return null
}
return extractRedirectTargetHref(trimmed) ?? normalizeAbsoluteHref(trimmed)
}
const isLikelySourceUrl = (value: string | null): value is string => {
if (!value) {
return false
}
try {
const url = new URL(value)
const host = url.hostname.toLowerCase()
if (!/^https?:$/i.test(url.protocol)) {
return false
}
if (
host === window.location.hostname.toLowerCase() ||
host.endsWith('.yiyan.baidu.com') ||
host === 'eb-static.cdn.bcebos.com' ||
host === 'ppui-static-wap.cdn.bcebos.com'
) {
return false
}
return true
} catch {
return false
}
}
const buildSourceLink = (input: unknown) => {
if (!isObjectRecord(input)) {
return null
}
const url = normalizeHref(
input.url ??
input.href ??
input.link ??
input.pageUrl ??
input.page_url ??
input.webUrl ??
input.web_url ??
input.realUrl ??
input.real_url ??
input.displayUrl ??
input.display_url ??
input.originUrl ??
input.origin_url ??
input.targetUrl ??
input.target_url ??
input.sourceUrl ??
input.source_url,
)
if (!isLikelySourceUrl(url)) {
return null
}
return {
url,
title: normalize(input.title ?? input.name ?? input.text ?? input.label ?? input.desc),
text: normalize(input.text ?? input.title ?? input.name ?? input.label),
siteName: normalize(
input.siteName ?? input.site_name ?? input.site ?? input.source ?? input.domain,
),
}
}
const dedupeLinks = (
items: Array<{
url: string
title: string | null
text: string | null
siteName: string | null
}>,
) => {
const seen = new Set()
const result: typeof items = []
for (const item of items) {
if (!item?.url || seen.has(item.url)) {
continue
}
seen.add(item.url)
result.push(item)
}
return result
}
const collectSourceLinks = (
input: unknown,
result: Array<{
url: string
title: string | null
text: string | null
siteName: string | null
}>,
depth = 0,
) => {
if (depth > 10 || input == null) {
return
}
if (Array.isArray(input)) {
for (const item of input) {
collectSourceLinks(item, result, depth + 1)
}
return
}
const sourceLink = buildSourceLink(input)
if (sourceLink) {
result.push(sourceLink)
}
if (!isObjectRecord(input)) {
return
}
for (const value of Object.values(input)) {
if (Array.isArray(value) || isObjectRecord(value)) {
collectSourceLinks(value, result, depth + 1)
}
}
}
const collectSourceLinksByFieldPattern = (
input: unknown,
pattern: RegExp,
result: Array<{
url: string
title: string | null
text: string | null
siteName: string | null
}>,
depth = 0,
) => {
if (depth > 10 || input == null) {
return
}
if (Array.isArray(input)) {
for (const item of input) {
collectSourceLinksByFieldPattern(item, pattern, result, depth + 1)
}
return
}
if (!isObjectRecord(input)) {
return
}
for (const [key, value] of Object.entries(input)) {
if (pattern.test(key)) {
collectSourceLinks(value, result, depth + 1)
}
if (Array.isArray(value) || isObjectRecord(value)) {
collectSourceLinksByFieldPattern(value, pattern, result, depth + 1)
}
}
}
const isVisible = (element: Element | null): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0' ||
element.hidden
) {
return false
}
const rect = element.getBoundingClientRect()
return rect.width >= 8 && rect.height >= 8
}
const extractAnchorLink = (element: Element) => {
if (!(element instanceof HTMLAnchorElement) || !isVisible(element)) {
return null
}
const url = normalizeHref(element.getAttribute('href') ?? element.href)
if (!isLikelySourceUrl(url)) {
return null
}
const title = normalize(
element.getAttribute('title') ??
element.querySelector('[class*="title"]')?.textContent ??
element.textContent,
)
const siteName = normalize(
element.getAttribute('data-site-name') ??
element.dataset.siteName ??
element.querySelector('[class*="site"]')?.textContent ??
element.getAttribute('aria-label'),
)
return {
url,
title: title ?? siteName,
text: title ?? siteName,
siteName,
}
}
const urlAttributeNames = [
'href',
'data-href',
'data-url',
'data-link',
'data-target-url',
'data-source-url',
'data-origin-url',
'data-real-url',
'data-page-url',
'data-web-url',
'data-redirect',
'data-jump-url',
'data-log',
'data-click',
'data-extra',
'data-info',
'data-value',
]
const firstUrlFromText = (value: string | null): string | null => {
if (!value) {
return null
}
const httpMatch = value.match(/https?:\/\/[^\s"'<>,。;、]+/i)
if (httpMatch) {
return normalizeHref(httpMatch[0])
}
const domainMatch = value.match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s"'<>,。;、]*)?/i)
return domainMatch ? normalizeHref(domainMatch[0]) : null
}
const readElementUrl = (element: Element): string | null => {
const candidates: string[] = []
for (const name of urlAttributeNames) {
const value = element.getAttribute(name)
if (value) {
candidates.push(value)
}
}
if (element instanceof HTMLElement) {
candidates.push(
...Object.values(element.dataset).filter((value): value is string => Boolean(value)),
)
}
for (const candidate of candidates) {
const url = normalizeHref(candidate) ?? firstUrlFromText(candidate)
if (isLikelySourceUrl(url)) {
return url
}
}
const text = normalize(element.textContent ?? '')
if (text && text.length <= 2_000) {
const url = firstUrlFromText(text)
if (isLikelySourceUrl(url)) {
return url
}
}
return null
}
const normalizeShortText = (value: unknown, maxLength = 220): string | null => {
const text = normalize(value)
if (!text) {
return null
}
return text.length <= maxLength ? text : null
}
const extractElementSourceLink = (element: Element) => {
if (!isVisible(element)) {
return null
}
const anchorLink = extractAnchorLink(element)
if (anchorLink) {
return anchorLink
}
const url = readElementUrl(element)
if (!isLikelySourceUrl(url)) {
return null
}
const card =
element.closest(
[
'a',
'li',
'article',
'section',
'[role="listitem"]',
'[class*="item"]',
'[class*="Item"]',
'[class*="card"]',
'[class*="Card"]',
'[class*="source"]',
'[class*="Source"]',
'[class*="reference"]',
'[class*="Reference"]',
'[class*="web"]',
'[class*="Web"]',
].join(','),
) ?? element
const title = normalizeShortText(
card.querySelector('[class*="title"], [class*="Title"], h1, h2, h3, h4')?.textContent ??
element.getAttribute('title') ??
element.getAttribute('aria-label') ??
card.textContent,
)
const siteName = normalizeShortText(
card.querySelector(
'[class*="site"], [class*="Site"], [class*="domain"], [class*="Domain"], [class*="source"], [class*="Source"]',
)?.textContent ??
element.getAttribute('data-site-name') ??
(element instanceof HTMLElement ? element.dataset.siteName : null),
80,
)
return {
url,
title,
text: title ?? siteName,
siteName,
}
}
const collectSourceLinksFromElement = (root: HTMLElement) => {
const candidates = [
root,
...Array.from(
root.querySelectorAll(
[
'a[href]',
'[data-href]',
'[data-url]',
'[data-link]',
'[data-target-url]',
'[data-source-url]',
'[data-origin-url]',
'[data-real-url]',
'[data-page-url]',
'[data-web-url]',
'[data-log]',
'[data-click]',
'[data-extra]',
'[data-info]',
'li',
'article',
'[role="listitem"]',
'[class*="item"]',
'[class*="Item"]',
'[class*="card"]',
'[class*="Card"]',
'[class*="source"]',
'[class*="Source"]',
'[class*="reference"]',
'[class*="Reference"]',
'[class*="web"]',
'[class*="Web"]',
].join(','),
),
),
]
return dedupeLinks(
candidates
.map((node) => extractElementSourceLink(node))
.filter((node): node is NonNullable => Boolean(node)),
)
}
const collectVisiblePanelLinks = (triggerText: string | null) => {
const panelSelectors = [
'[role="dialog"]',
'[role="list"]',
'[class*="modal"]',
'[class*="Modal"]',
'[class*="dialog"]',
'[class*="Dialog"]',
'[class*="drawer"]',
'[class*="Drawer"]',
'[class*="panel"]',
'[class*="Panel"]',
'[class*="popover"]',
'[class*="Popover"]',
'[class*="popup"]',
'[class*="Popup"]',
'[class*="webList"]',
'[class*="WebList"]',
'[class*="reference"]',
'[class*="Reference"]',
'[class*="material"]',
'[class*="cite"]',
'[class*="source"]',
'[class*="Source"]',
'[class*="web"]',
'[class*="Web"]',
'aside',
'section',
'article',
'body > div',
]
const rootLinks = new Map>()
const roots = Array.from(document.querySelectorAll(panelSelectors.join(',')))
.filter((element): element is HTMLElement => isVisible(element))
.filter((element) => {
const text = normalize(element.innerText) ?? ''
const className = String(element.className || '')
const links = collectSourceLinksFromElement(element)
rootLinks.set(element, links)
return (
links.length > 0 &&
(/参考|网页|信息源|引用|web pages|references/i.test(text) ||
Boolean(triggerText && text.includes(triggerText)) ||
element.getAttribute('role') === 'dialog' ||
/(drawer|panel|popover|popup|modal|dialog|web|source|reference|cite)/i.test(
className,
))
)
})
const links = roots.length
? roots.flatMap((root) => rootLinks.get(root) ?? collectSourceLinksFromElement(root))
: []
return dedupeLinks(links)
}
const store = (
window as typeof window & {
__NEXT_REDUX_STORE__?: {
getState?: () => Record
}
}
).__NEXT_REDUX_STORE__
const state = store?.getState?.() ?? {}
const userInfo = (state.updateReducer as Record | undefined)?.userInfo
const selectedModel = (state.model as Record | undefined)?.selectedModel
const triggerPattern = new RegExp(triggerPatternSource, 'i')
const citationFieldPattern = new RegExp(citationFieldPatternSource, 'i')
const searchFieldPattern = new RegExp(searchFieldPatternSource, 'i')
const bodyText = normalize(document.body?.innerText) ?? ''
const loginReason = loginSignals.find((signal) => bodyText.includes(signal)) ?? null
const reqDone =
isObjectRecord(userInfo) && typeof userInfo.reqDone === 'boolean' ? userInfo.reqDone : null
const storeIsLogin =
isObjectRecord(userInfo) && typeof userInfo.isLogin === 'boolean' ? userInfo.isLogin : null
const isLogin = storeIsLogin ?? !loginReason
const modelCandidates: Array = []
if (typeof selectedModel === 'string') {
modelCandidates.push(selectedModel)
} else if (isObjectRecord(selectedModel)) {
modelCandidates.push(
selectedModel.label,
selectedModel.name,
selectedModel.model,
selectedModel.displayName,
selectedModel.assistantName,
selectedModel.assistantKey,
)
}
for (const selector of [
'[class*="model"][class*="current"]',
'[class*="currentModel"]',
'[class*="modelLabel"]',
]) {
modelCandidates.push(document.querySelector(selector)?.textContent ?? null)
}
const modelLabel =
modelCandidates
.map((candidate) => normalize(candidate))
.find((candidate): candidate is string => Boolean(candidate)) ?? null
const editor = document.querySelector(editorSelector)
const editorText = normalize(
editor instanceof HTMLElement ? editor.innerText || editor.textContent || '' : '',
)
const referenceTriggerText =
Array.from(document.querySelectorAll('button, [role="button"], a, div, span, p'))
.filter((element): element is HTMLElement => isVisible(element))
.map((element) => normalize(element.innerText || element.textContent || ''))
.find((text): text is string => Boolean(text && triggerPattern.test(text))) ?? null
const storeReferenceLinks: Array<{
url: string
title: string | null
text: string | null
siteName: string | null
}> = []
collectSourceLinksByFieldPattern(state, citationFieldPattern, storeReferenceLinks)
collectSourceLinksByFieldPattern(state, searchFieldPattern, storeReferenceLinks)
const domReferenceLinks = collectVisiblePanelLinks(referenceTriggerText)
const referenceLinks = dedupeLinks([...storeReferenceLinks, ...domReferenceLinks])
return {
url: window.location.href,
title: normalize(document.title),
loginRequired: reqDone === true ? !isLogin : Boolean(loginReason && !isLogin),
loginReason,
isLogin,
modelLabel,
userName: isObjectRecord(userInfo)
? normalize(
(typeof userInfo.uname === 'string' ? userInfo.uname : null) ??
(typeof userInfo.fuzzyName === 'string' ? userInfo.fuzzyName : null),
)
: null,
editorText,
bodySample: normalize(bodyText.slice(0, 320)),
referenceTriggerText,
referenceLinks,
}
},
{
editorSelector: WENXIN_EDITOR_SELECTOR,
loginSignals: WENXIN_LOGIN_SIGNALS,
triggerPatternSource: WENXIN_REFERENCE_TRIGGER_PATTERN.source,
citationFieldPatternSource: WENXIN_CITATION_FIELD_PATTERN.source,
searchFieldPatternSource: WENXIN_SEARCH_FIELD_PATTERN.source,
redirectQueryKeys: WENXIN_REDIRECT_QUERY_KEYS,
},
)
}
async function maybeOpenWenxinReferencePanel(page: PlaywrightPage): Promise {
const opened = await page
.evaluate(
({ triggerPatternSource }) => {
const normalize = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value
.replace(/\u00a0/g, ' ')
.replace(/\r/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
return trimmed || null
}
const isVisible = (element: Element | null): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0' ||
element.hidden
) {
return false
}
const rect = element.getBoundingClientRect()
return rect.width >= 8 && rect.height >= 8
}
const triggerPattern = new RegExp(triggerPatternSource, 'i')
const rawCandidates = Array.from(
document.querySelectorAll('button, [role="button"], a, [tabindex], div, span, p'),
)
.filter((element): element is HTMLElement => isVisible(element))
.filter((element) => {
const text = normalize(element.innerText || element.textContent || '')
return Boolean(text && triggerPattern.test(text))
})
const candidates = rawCandidates
.map((element) => {
const target = element.closest('button, [role="button"], a, [tabindex]') ?? element
if (!(target instanceof HTMLElement) || !isVisible(target)) {
return null
}
const rect = target.getBoundingClientRect()
const text = normalize(target.innerText || target.textContent || '') ?? ''
const style = window.getComputedStyle(target)
const className = String(target.className || '')
let score = 0
if (target.matches('button, [role="button"], a, [tabindex]')) {
score += 80
}
if (style.cursor === 'pointer' || typeof target.onclick === 'function') {
score += 40
}
if (/(reference|cite|source|web)/i.test(className)) {
score += 30
}
if (text.length <= 80) {
score += 20
}
score -= Math.min((rect.width * rect.height) / 10_000, 50)
return { target, score }
})
.filter((item): item is { target: HTMLElement; score: number } => Boolean(item))
const seen = new Set()
const trigger = candidates
.sort((left, right) => right.score - left.score)
.map((item) => item.target)
.find((target) => {
if (seen.has(target)) {
return false
}
seen.add(target)
return true
})
if (!trigger) {
return false
}
trigger.scrollIntoView({ block: 'center', inline: 'center' })
trigger.click()
return true
},
{
triggerPatternSource: WENXIN_REFERENCE_TRIGGER_PATTERN.source,
},
)
.catch(() => false)
if (opened) {
await page.waitForTimeout(1_000).catch(() => undefined)
await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => undefined)
}
}
async function fillWenxinEditorText(page: PlaywrightPage, questionText: string): Promise {
const editor = page.locator(WENXIN_EDITOR_SELECTOR).first()
await editor.waitFor({
state: 'visible',
timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
})
await editor.scrollIntoViewIfNeeded().catch(() => undefined)
await editor
.evaluate((node) => {
if (node instanceof HTMLElement) {
node.focus()
}
})
.catch(() => undefined)
let filled = false
try {
await editor.fill(questionText)
filled = true
} catch {
filled = false
}
const normalizedExpected = questionText.trim().replace(/\s+/g, ' ')
const normalizedActual = (await editor.innerText().catch(() => '')).trim().replace(/\s+/g, ' ')
if (!filled || normalizedActual !== normalizedExpected) {
await page.evaluate(
({ selector, text }) => {
const editorElement = document.querySelector(selector)
if (!(editorElement instanceof HTMLElement)) {
throw new Error('wenxin_editor_missing')
}
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editorElement)
selection?.removeAllRanges()
selection?.addRange(range)
editorElement.focus()
try {
document.execCommand('delete')
document.execCommand('insertText', false, text)
} catch {
// Ignore and fall back to direct DOM mutation below.
}
const current = (editorElement.innerText || editorElement.textContent || '')
.trim()
.replace(/\s+/g, ' ')
if (current !== text.trim().replace(/\s+/g, ' ')) {
editorElement.innerHTML = ''
editorElement.textContent = text
editorElement.dispatchEvent(
new InputEvent('input', {
bubbles: true,
inputType: 'insertText',
data: text,
}),
)
editorElement.dispatchEvent(new Event('change', { bubbles: true }))
}
},
{
selector: WENXIN_EDITOR_SELECTOR,
text: questionText,
},
)
}
}
async function submitWenxinQuestion(
page: PlaywrightPage,
questionText: string,
signal: AbortSignal,
): Promise {
if (signal.aborted) {
throw new Error('adapter_aborted')
}
await fillWenxinEditorText(page, questionText)
await sleep(150, signal)
const editor = page.locator(WENXIN_EDITOR_SELECTOR).first()
await editor.press('Enter').catch(async () => {
await page.keyboard.press('Enter').catch(() => undefined)
})
}
async function installWenxinFetchCapture(page: PlaywrightPage): Promise {
await page.evaluate(
({ captureLimit, bodyLimit }) => {
type CaptureEntry = {
id: string
url: string
method: string
status: number | null
contentType: string | null
requestBody: string | null
body: string
done: boolean
error: string | null
startedAt: number
updatedAt: number
}
type CaptureState = {
installed: boolean
sequence: number
originalFetch: typeof window.fetch
entries: CaptureEntry[]
}
const globalWindow = window as typeof window & {
__geoWenxinCapture?: CaptureState
}
if (globalWindow.__geoWenxinCapture?.installed) {
return
}
const originalFetch = window.fetch.bind(window)
const decoder = new TextDecoder()
const state: CaptureState = {
installed: true,
sequence: 0,
originalFetch,
entries: [],
}
globalWindow.__geoWenxinCapture = state
window.fetch = (async (...args: [RequestInfo | URL, RequestInit?]) => {
const request = args[0]
const init = args[1]
const rawUrl =
typeof request === 'string'
? request
: request instanceof URL
? request.toString()
: request && typeof request.url === 'string'
? request.url
: ''
const url = rawUrl ? new URL(rawUrl, window.location.href).toString() : ''
const method = (
init?.method ?? (request instanceof Request ? request.method : 'GET')
).toUpperCase()
const requestBody = typeof init?.body === 'string' ? init.body : null
const response = await originalFetch(...args)
if (!url.includes('/eb/chat/conversation/v2')) {
return response
}
const entry: CaptureEntry = {
id: `wenxin-stream-${(state.sequence += 1)}`,
url,
method,
status: Number.isFinite(response.status) ? response.status : null,
contentType: response.headers.get('content-type'),
requestBody,
body: '',
done: false,
error: null,
startedAt: Date.now(),
updatedAt: Date.now(),
}
state.entries.push(entry)
if (state.entries.length > captureLimit) {
state.entries.splice(0, state.entries.length - captureLimit)
}
const clone = response.clone()
void Promise.resolve().then(async () => {
try {
if (clone.body) {
const reader = clone.body.getReader()
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
entry.body += decoder.decode(value, { stream: true })
if (entry.body.length > bodyLimit) {
entry.body = entry.body.slice(0, bodyLimit)
}
entry.updatedAt = Date.now()
}
entry.body += decoder.decode()
} else {
entry.body = await clone.text()
}
} catch (error) {
entry.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
} finally {
if (entry.body.length > bodyLimit) {
entry.body = entry.body.slice(0, bodyLimit)
}
entry.done = true
entry.updatedAt = Date.now()
}
})
return response
}) as typeof window.fetch
},
{
captureLimit: WENXIN_STREAM_CAPTURE_LIMIT,
bodyLimit: WENXIN_STREAM_CAPTURE_BODY_LIMIT,
},
)
}
async function resetWenxinFetchCapture(page: PlaywrightPage): Promise {
await page.evaluate(() => {
const globalWindow = window as typeof window & {
__geoWenxinCapture?: {
entries: WenxinCapturedStreamRecord[]
}
}
if (globalWindow.__geoWenxinCapture) {
globalWindow.__geoWenxinCapture.entries = []
}
})
}
async function readWenxinFetchCapture(page: PlaywrightPage): Promise {
return await page.evaluate(() => {
const globalWindow = window as typeof window & {
__geoWenxinCapture?: {
entries: WenxinCapturedStreamRecord[]
}
}
return (globalWindow.__geoWenxinCapture?.entries ?? []).map((entry) => ({
id: entry.id,
url: entry.url,
method: entry.method,
status: entry.status,
contentType: entry.contentType,
requestBody: entry.requestBody,
body: entry.body,
done: entry.done,
error: entry.error,
startedAt: entry.startedAt,
updatedAt: entry.updatedAt,
}))
})
}
function selectWenxinCapture(
entries: WenxinCapturedStreamRecord[],
questionText: string,
): WenxinCapturedStreamRecord | null {
const normalizedQuestion = questionText.trim()
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index]
if (entry.requestBody?.includes(normalizedQuestion)) {
return entry
}
}
return entries.at(-1) ?? null
}
async function fetchWenxinHistory(
session: Session,
sessionId: string,
): Promise {
const payload = await sessionCookieFetchJson>(
session,
WENXIN_HISTORY_URL,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
sessionId,
pageSize: 2000,
timestamp: Date.now(),
deviceType: 'pc',
}),
},
).catch(() => null)
if (!payload) {
return null
}
return parseWenxinHistoryResponse(payload, sessionId)
}
async function waitForWenxinAnswer(
page: PlaywrightPage,
session: Session,
questionText: string,
signal: AbortSignal,
): Promise {
const startedAt = Date.now()
let stablePollCount = 0
let lastSignature: string | null = null
let latestStream = emptyWenxinStreamSummary()
let latestHistory: WenxinHistorySummary | null = null
let latestObservation = mergeWenxinObservation(latestStream, latestHistory)
let seenCapture = false
let resolvedSessionId: string | null = null
while (Date.now() - startedAt <= WENXIN_QUERY_TIMEOUT_MS) {
if (signal.aborted) {
throw new Error('adapter_aborted')
}
const captures = await readWenxinFetchCapture(page)
const capture = selectWenxinCapture(captures, questionText)
if (capture) {
seenCapture = true
latestStream = parseWenxinSSEBody(capture.body, capture.done, capture.error)
resolvedSessionId = latestStream.sessionId ?? resolvedSessionId
}
if (!seenCapture && Date.now() - startedAt >= 12_000) {
latestObservation = mergeWenxinObservation(latestStream, latestHistory)
return {
ok: false,
error: 'wenxin_submit_failed',
detail: 'no Wenxin conversation stream observed after submission',
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
if (resolvedSessionId) {
latestHistory = await fetchWenxinHistory(session, resolvedSessionId)
}
latestObservation = mergeWenxinObservation(latestStream, latestHistory)
if (latestHistory?.loginRequired || isWenxinLoginMessage(latestObservation.errorMessage)) {
return {
ok: false,
error: 'wenxin_login_required',
detail: latestObservation.errorMessage ?? latestHistory?.errorMessage ?? 'login_required',
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
if (
isWenxinRiskControlMessage(latestObservation.withdrawText) ||
isWenxinRiskControlMessage(latestObservation.errorMessage)
) {
return {
ok: false,
error: 'wenxin_risk_control',
detail: latestObservation.withdrawText ?? latestObservation.errorMessage ?? 'risk_control',
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
if (
latestObservation.withdrawText &&
!latestObservation.answer &&
!latestObservation.citations.length &&
!latestObservation.searchResults.length &&
latestObservation.streamDone
) {
return {
ok: false,
error: 'wenxin_withdrawn',
detail: latestObservation.withdrawText,
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
if (
latestObservation.errorMessage &&
!latestObservation.answer &&
!latestObservation.citations.length &&
!latestObservation.searchResults.length &&
latestObservation.streamDone
) {
return {
ok: false,
error: 'wenxin_stream_error',
detail: latestObservation.errorMessage,
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
if (isObservationComplete(latestObservation)) {
if (latestObservation.signature === lastSignature) {
stablePollCount += 1
} else {
lastSignature = latestObservation.signature
stablePollCount = 1
}
if (stablePollCount >= WENXIN_STABLE_POLLS_REQUIRED) {
return {
ok: true,
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
} else {
stablePollCount = 0
lastSignature = null
}
await sleep(WENXIN_QUERY_POLL_INTERVAL_MS, signal)
}
latestObservation = mergeWenxinObservation(latestStream, latestHistory)
return {
ok: false,
error: 'wenxin_query_timeout',
detail: 'timed out while waiting for Wenxin answer',
stream: latestStream,
history: latestHistory,
observation: latestObservation,
stablePollCount,
elapsedMs: Date.now() - startedAt,
}
}
function buildWenxinCaptureDebug(entries: WenxinCapturedStreamRecord[]): JsonValue[] {
return entries.slice(-3).map((entry) => ({
id: entry.id,
url: entry.url,
method: entry.method,
status: entry.status,
content_type: entry.contentType,
request_body_preview: entry.requestBody?.slice(0, 240) ?? null,
body_length: entry.body.length,
body_preview: entry.body.slice(0, 360) || null,
done: entry.done,
error: entry.error ?? null,
}))
}
export const wenxinAdapter: MonitorAdapter = {
provider: 'wenxin',
executionMode: 'playwright',
async query(context, payload) {
if (!context.playwright?.page) {
return {
status: 'failed',
summary: '文心一言监测缺少 Playwright 页面上下文。',
error: buildAdapterError('wenxin_playwright_required', 'wenxin_playwright_required'),
}
}
const questionText = extractQuestionText(payload)
const page = context.playwright.page
context.reportProgress('wenxin.page_ready')
await ensurePageOnWenxinChat(page, context.signal)
await installWenxinFetchCapture(page)
await resetWenxinFetchCapture(page)
const initialSnapshot = await readWenxinPageSnapshot(page)
if (initialSnapshot.loginRequired) {
return {
status: 'failed',
summary: '文心一言账号未登录或登录态已失效,请先在 desktop client 中重新授权。',
error: buildAdapterError(
'wenxin_login_required',
initialSnapshot.loginReason ?? 'login_required',
{
page_url: initialSnapshot.url,
},
),
}
}
context.reportProgress('wenxin.query')
await submitWenxinQuestion(page, questionText, context.signal)
context.reportProgress('wenxin.wait_answer')
const waitResult = await waitForWenxinAnswer(
page,
context.session,
questionText,
context.signal,
)
const captures = await readWenxinFetchCapture(page)
await maybeOpenWenxinReferencePanel(page).catch(() => undefined)
const finalSnapshot = await readWenxinPageSnapshot(page).catch(() => initialSnapshot)
const observation = waitResult.observation
const answer = normalizeBlockText(observation.answer)
const pageReferenceLinks = dedupeSourceItems(
finalSnapshot.referenceLinks
.map((item) => buildSourceItem(item))
.filter((item): item is MonitoringSourceItem => Boolean(item)),
)
const citations = dedupeSourceItems([...observation.citations, ...pageReferenceLinks])
const searchResults = dedupeSourceItems([...observation.searchResults, ...pageReferenceLinks])
const allSources = dedupeSourceItems([...citations, ...searchResults])
const providerModel = observation.providerModel ?? finalSnapshot.modelLabel ?? 'EB45T'
const requestID = observation.sessionId
if (!waitResult.ok) {
if (waitResult.error === 'wenxin_login_required') {
return {
status: 'failed',
summary: '文心一言账号未登录或登录态已失效,请先在 desktop client 中重新授权。',
error: buildAdapterError(waitResult.error, waitResult.detail ?? 'login_required', {
page_url: finalSnapshot.url,
}),
}
}
if (waitResult.error === 'wenxin_risk_control') {
return {
status: 'failed',
summary: `文心一言请求失败:${waitResult.detail ?? '当前访问环境存在异常,请更换浏览器再尝试提问'}`,
error: buildAdapterError(waitResult.error, waitResult.detail ?? 'risk_control', {
page_url: finalSnapshot.url,
}),
}
}
if (waitResult.error === 'wenxin_query_timeout') {
return {
status: 'unknown',
summary: '文心一言超时未拿到完整答案,已回写 unknown 等待后续补采。',
error: buildAdapterError(waitResult.error, waitResult.detail ?? waitResult.error, {
page_url: finalSnapshot.url,
session_id: requestID,
stream_done: waitResult.stream.captureDone || waitResult.stream.isEnded,
history_generating: waitResult.history?.generating ?? null,
}),
}
}
return {
status: 'failed',
summary: `文心一言请求失败:${waitResult.detail ?? waitResult.error}`,
error: buildAdapterError(waitResult.error, waitResult.detail ?? waitResult.error, {
page_url: finalSnapshot.url,
session_id: requestID,
}),
}
}
if (!answer && !citations.length && !searchResults.length) {
return {
status: 'unknown',
summary: '文心一言返回为空,已回写 unknown 等待后续对账。',
error: buildAdapterError('wenxin_empty_response', 'wenxin returned no answer or sources'),
}
}
context.reportProgress('wenxin.parse_result')
return {
status: 'succeeded',
summary: '文心一言监控任务执行成功。',
payload: {
platform: 'wenxin',
provider_model: providerModel,
provider_request_id: observation.providerRequestID,
request_id: requestID,
conversation_id: requestID,
answer,
citation_count: citations.length,
search_result_count: searchResults.length,
source_count: allSources.length,
citations: toJsonSources(citations),
references: toJsonSources(allSources),
search_results: toJsonSources(searchResults),
sources: toJsonSources(allSources),
raw_response_json: {
platform: 'wenxin',
page_url: finalSnapshot.url,
page_title: finalSnapshot.title,
is_login: finalSnapshot.isLogin,
login_reason: finalSnapshot.loginReason,
model_label: finalSnapshot.modelLabel,
user_name: finalSnapshot.userName,
session_id: requestID,
answer: answer ?? null,
answer_block_count: observation.answerBlocks.length,
answer_blocks: observation.answerBlocks,
reference_trigger_text: finalSnapshot.referenceTriggerText,
page_reference_count: pageReferenceLinks.length,
page_references: toJsonSources(pageReferenceLinks),
citation_count: citations.length,
citations: toJsonSources(citations),
reference_count: allSources.length,
references: toJsonSources(allSources),
search_result_count: searchResults.length,
search_results: toJsonSources(searchResults),
source_count: allSources.length,
sources: toJsonSources(allSources),
stream_summary: toJsonValue(waitResult.stream),
history_summary: toJsonValue(waitResult.history),
stable_poll_count: waitResult.stablePollCount,
elapsed_ms: waitResult.elapsedMs,
stream_capture_count: captures.length,
stream_captures: buildWenxinCaptureDebug(captures),
},
},
}
},
}
export const __wenxinTestUtils = {
buildSourceItem,
htmlTableToMarkdown,
isObservationComplete,
normalizeUrl,
parseWenxinHistoryResponse,
parseWenxinSSEBody,
}