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, }