162abdc97c
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2564 lines
74 KiB
TypeScript
2564 lines
74 KiB
TypeScript
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 = /<table\b[\s\S]*?<\/table>/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<string, unknown> {
|
|
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<string, MonitoringSourceItem>()
|
|
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<string, string> = {
|
|
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(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<\/(?:p|div|section|article|li|ul|ol|h[1-6])>/gi, '\n')
|
|
.replace(/<li\b[^>]*>/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(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)]
|
|
if (!rowMatches.length) {
|
|
return null
|
|
}
|
|
|
|
const rows = rowMatches
|
|
.map((match) => [...match[1].matchAll(/<t[hd]\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)])
|
|
.map((cells) =>
|
|
cells
|
|
.map((cell) => normalizeBlockText(stripHtml(cell[1] ?? '')) ?? '')
|
|
.map((cell) => cell.replace(/\|/g, '\\|').replace(/\n+/g, '<br/>')),
|
|
)
|
|
.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<string>, 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<string>()
|
|
|
|
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, unknown>): string {
|
|
const candidates = [
|
|
payload.question_text,
|
|
payload.questionText,
|
|
payload.query,
|
|
payload.question,
|
|
payload.prompt,
|
|
payload.content,
|
|
payload.title,
|
|
]
|
|
|
|
for (const candidate of candidates) {
|
|
const text = normalizeOptionalString(candidate)
|
|
if (text) {
|
|
return text
|
|
}
|
|
}
|
|
|
|
throw new Error('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<string, JsonValue> = {}
|
|
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<string, JsonValue> = {},
|
|
): Record<string, JsonValue> {
|
|
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<string, unknown> | 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<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (signal?.aborted) {
|
|
reject(new Error('adapter_aborted'))
|
|
return
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
resolve()
|
|
}, ms)
|
|
|
|
const onAbort = () => {
|
|
clearTimeout(timer)
|
|
signal?.removeEventListener('abort', onAbort)
|
|
reject(new Error('adapter_aborted'))
|
|
}
|
|
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
async function ensurePageOnWenxinChat(page: PlaywrightPage, signal: AbortSignal): Promise<void> {
|
|
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<WenxinPageSnapshot> {
|
|
return await page.evaluate(
|
|
({
|
|
editorSelector,
|
|
loginSignals,
|
|
triggerPatternSource,
|
|
citationFieldPatternSource,
|
|
searchFieldPatternSource,
|
|
redirectQueryKeys,
|
|
}) => {
|
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
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<string>()
|
|
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<typeof node> => 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<HTMLElement, ReturnType<typeof collectSourceLinksFromElement>>()
|
|
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<string, unknown>
|
|
}
|
|
}
|
|
).__NEXT_REDUX_STORE__
|
|
const state = store?.getState?.() ?? {}
|
|
const userInfo = (state.updateReducer as Record<string, unknown> | undefined)?.userInfo
|
|
const selectedModel = (state.model as Record<string, unknown> | 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<unknown> = []
|
|
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<void> {
|
|
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<HTMLElement>()
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await page.evaluate(() => {
|
|
const globalWindow = window as typeof window & {
|
|
__geoWenxinCapture?: {
|
|
entries: WenxinCapturedStreamRecord[]
|
|
}
|
|
}
|
|
if (globalWindow.__geoWenxinCapture) {
|
|
globalWindow.__geoWenxinCapture.entries = []
|
|
}
|
|
})
|
|
}
|
|
|
|
async function readWenxinFetchCapture(page: PlaywrightPage): Promise<WenxinCapturedStreamRecord[]> {
|
|
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<WenxinHistorySummary | null> {
|
|
const payload = await sessionCookieFetchJson<Record<string, unknown>>(
|
|
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<WenxinWaitResult> {
|
|
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,
|
|
}
|