import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; import type { Page as PlaywrightPage, Response as PlaywrightResponse } from "playwright-core"; import { normalizeText } from "./common"; import type { MonitorAdapter } from "./base"; const YUANBAO_BOOTSTRAP_URL = "https://yuanbao.tencent.com/"; const YUANBAO_PAGE_READY_TIMEOUT_MS = 20_000; const YUANBAO_QUERY_TIMEOUT_MS = 120_000; const YUANBAO_CAPTURE_LIMIT = 96; const YUANBAO_CAPTURE_BODY_LIMIT = 160_000; const YUANBAO_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi; const WINDOWS_1252_REVERSE_MAP = new Map([ [0x20ac, 0x80], [0x201a, 0x82], [0x0192, 0x83], [0x201e, 0x84], [0x2026, 0x85], [0x2020, 0x86], [0x2021, 0x87], [0x02c6, 0x88], [0x2030, 0x89], [0x0160, 0x8a], [0x2039, 0x8b], [0x0152, 0x8c], [0x017d, 0x8e], [0x2018, 0x91], [0x2019, 0x92], [0x201c, 0x93], [0x201d, 0x94], [0x2022, 0x95], [0x2013, 0x96], [0x2014, 0x97], [0x02dc, 0x98], [0x2122, 0x99], [0x0161, 0x9a], [0x203a, 0x9b], [0x0153, 0x9c], [0x017e, 0x9e], [0x0178, 0x9f], ]); type YuanbaoCaptureRecord = { url: string; status: number; contentType: string | null; body: string; }; type YuanbaoDomLink = { url: string; title: string | null; text: string | null; }; type YuanbaoPageQuerySuccessResult = { ok: true; url: string; title: string | null; modelLabel: string | null; deepThinkEnabled: boolean | null; webSearchEnabled: boolean | null; domAnswer: string | null; domLinks: YuanbaoDomLink[]; }; type YuanbaoPageQueryFailureResult = { ok: false; error: string; detail: string | null; url: string | null; title: string | null; modelLabel: string | null; deepThinkEnabled: boolean | null; webSearchEnabled: boolean | null; domAnswer: string | null; domLinks: YuanbaoDomLink[]; }; type YuanbaoPageQueryResult = YuanbaoPageQuerySuccessResult | YuanbaoPageQueryFailureResult; type YuanbaoSummary = { answer: string | null; reasoning: string | null; providerModel: string | null; providerRequestID: string | null; requestID: string | null; conversationID: string | null; citations: MonitoringSourceItem[]; searchResults: MonitoringSourceItem[]; candidateCount: number; contentTypes: Set; }; 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 countHanCharacters(value: string): number { const matched = value.match(/[\u3400-\u9fff]/g); return matched?.length ?? 0; } function countSuspiciousMojibakeCharacters(value: string): number { const matched = value.match(/[\u00a0-\u017f\u02c0-\u02ff\u2000-\u20ff]/g); return matched?.length ?? 0; } function looksLikeMojibake(value: string): boolean { const normalized = value.trim(); if (!normalized) { return false; } if ( normalized.includes("Ã") || normalized.includes("â€") || normalized.includes("ï¼") || normalized.includes("ã€") ) { return true; } const hanCount = countHanCharacters(normalized); const suspiciousCount = countSuspiciousMojibakeCharacters(normalized); if (hanCount > 0) { return suspiciousCount >= 8 && suspiciousCount > hanCount * 3; } return suspiciousCount >= 4; } function repairPotentialMojibake(value: string | null): string | null { const normalized = normalizeOptionalString(value); if (!normalized || !looksLikeMojibake(normalized)) { return value; } try { const bytes = Uint8Array.from(Array.from(normalized, (char) => { const codePoint = char.codePointAt(0) ?? 0; const mapped = WINDOWS_1252_REVERSE_MAP.get(codePoint); if (mapped !== undefined) { return mapped; } if (codePoint <= 0xff) { return codePoint; } throw new Error("non_windows_1252_codepoint"); })); const repaired = new TextDecoder("utf-8").decode(bytes); const originalHanCount = countHanCharacters(normalized); const repairedHanCount = countHanCharacters(repaired); const originalSuspiciousCount = countSuspiciousMojibakeCharacters(normalized); const repairedSuspiciousCount = countSuspiciousMojibakeCharacters(repaired); if ( repairedHanCount > originalHanCount || (repairedHanCount > 0 && repairedSuspiciousCount * 2 < originalSuspiciousCount) ) { return repaired; } } catch { return value; } return value; } function decodeResponseBody(buffer: Buffer): string { const decoded = new TextDecoder("utf-8").decode(buffer); return repairPotentialMojibake(decoded) ?? decoded; } function extractCitationMarkerIndexes(value: string | null): number[] { const normalized = normalizeOptionalString(value); if (!normalized) { return []; } const indexes: number[] = []; const seen = new Set(); for (const matched of normalized.matchAll(YUANBAO_CITATION_MARKER_PATTERN)) { const index = Number.parseInt(matched[1] ?? "", 10); if (!Number.isFinite(index) || index < 1 || seen.has(index)) { continue; } seen.add(index); indexes.push(index); } return indexes; } function looksLikeNonAnswerNoise(value: string): boolean { const trimmed = value.trim(); if (!trimmed) { return true; } const hanCount = countHanCharacters(trimmed); const suspiciousCount = countSuspiciousMojibakeCharacters(trimmed); if (hanCount === 0) { if (suspiciousCount >= 4) { return true; } const codePointLength = [...trimmed].length; return suspiciousCount >= 2 && suspiciousCount * 2 >= codePointLength; } return suspiciousCount >= 8 && suspiciousCount > hanCount * 3; } function answerQualityScore(value: string | null): number { const normalized = normalizeOptionalString(value); if (!normalized) { return Number.NEGATIVE_INFINITY; } const hanCount = countHanCharacters(normalized); const suspiciousCount = countSuspiciousMojibakeCharacters(normalized); return hanCount * 8 - suspiciousCount * 6 + Math.min(normalized.length, 400) + extractCitationMarkerIndexes(normalized).length * 12; } const STREAMING_FRAGMENT_MAX_LENGTH = 6; const STREAMING_FRAGMENT_RUN_THRESHOLD = 10; const STREAMING_FALLBACK_MIN_CONTENT_CHARS = 120; const STREAMING_MARKDOWN_STRUCTURE_PATTERN = /^(?:#{1,6}\s|[*\-+•]\s|\d+[.、]\s?|>\s?|\|\s?|```|---|===|\*\*)/; function isLikelyStreamingFragmentLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed) { return false; } if ([...trimmed].length > STREAMING_FRAGMENT_MAX_LENGTH) { return false; } if (STREAMING_MARKDOWN_STRUCTURE_PATTERN.test(trimmed)) { return false; } if (/^[\-=_*]{2,}$/.test(trimmed)) { return false; } return true; } function collapseTokenFragmentRuns(value: string): string { if (!value.includes("\n")) { return value; } const rawLines = value.split("\n"); type Segment = { type: "content" | "fragments"; lines: string[] }; const segments: Segment[] = []; let fragmentBuffer: string[] = []; const pushContent = (line: string) => { const last = segments[segments.length - 1]; if (last && last.type === "content") { last.lines.push(line); } else { segments.push({ type: "content", lines: [line] }); } }; const flushFragments = () => { if (!fragmentBuffer.length) { return; } if (fragmentBuffer.length >= STREAMING_FRAGMENT_RUN_THRESHOLD) { segments.push({ type: "fragments", lines: fragmentBuffer }); } else { for (const buffered of fragmentBuffer) { pushContent(buffered); } } fragmentBuffer = []; }; for (const line of rawLines) { if (isLikelyStreamingFragmentLine(line)) { fragmentBuffer.push(line); } else { flushFragments(); pushContent(line); } } flushFragments(); const contentCharCount = segments .filter((segment) => segment.type === "content") .reduce((total, segment) => total + segment.lines.reduce((acc, line) => acc + line.trim().length, 0), 0); const keepFragmentsAsFallback = contentCharCount < STREAMING_FALLBACK_MIN_CONTENT_CHARS; const output: string[] = []; for (const segment of segments) { if (segment.type === "content") { output.push(...segment.lines); } else if (keepFragmentsAsFallback) { output.push(segment.lines.map((line) => line.trim()).join("")); } } return output.join("\n"); } function sanitizeAnswerCandidate(value: string | null): string | null { const normalized = normalizeOptionalString(value); if (!normalized) { return null; } const repaired = repairPotentialMojibake(normalized) ?? normalized; const collapsed = collapseTokenFragmentRuns(repaired); const lines = collapsed.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean); if (lines.length <= 1) { return collapsed; } const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line)); if (!keptLines.length) { return collapsed; } const cleaned = keptLines.join("\n"); return answerQualityScore(cleaned) >= answerQualityScore(collapsed) ? cleaned : collapsed; } function selectBestAnswerText(parsedAnswer: string | null, domAnswer: string | null): string | null { const parsed = sanitizeAnswerCandidate(parsedAnswer); const dom = sanitizeAnswerCandidate(domAnswer); if (!parsed) { return dom; } if (!dom) { return parsed; } if (parsed.includes(dom) && answerQualityScore(dom) >= answerQualityScore(parsed) - 24) { return dom; } return answerQualityScore(dom) > answerQualityScore(parsed) ? dom : parsed; } function resolveCitationsFromMarkers( answerText: string | null, pools: MonitoringSourceItem[][], ): MonitoringSourceItem[] { const indexes = extractCitationMarkerIndexes(answerText); if (!indexes.length) { return []; } const maxIndex = Math.max(...indexes); const candidates = pools.filter((pool) => pool.length > 0); const resolvedPool = candidates.find((pool) => pool.length >= maxIndex) ?? candidates[0] ?? []; return indexes .map((index) => resolvedPool[index - 1] ?? null) .filter((item): item is MonitoringSourceItem => item !== null); } function mergeText(current: string | null, nextFragment: string | null): string | null { if (!nextFragment) { return current; } if (!current) { return nextFragment; } if (current === nextFragment || current.includes(nextFragment)) { return current; } if (nextFragment.startsWith(current)) { return nextFragment; } return `${current}${nextFragment}`; } function getDirectString(record: Record, keys: string[]): string | null { for (const key of keys) { const value = normalizeOptionalString(record[key]); if (value) { return value; } } return null; } function normalizeSourceUrl(value: string | null): string | null { const input = normalizeText(value); if (!input) { return null; } try { const url = new URL(input); url.hash = ""; return url.toString(); } catch { return input; } } function buildSourceItem(input: unknown): MonitoringSourceItem | null { if (typeof input === "string") { const url = normalizeSourceUrl(input); return url ? { url, normalized_url: url } : null; } if (!isRecord(input)) { return null; } const url = normalizeSourceUrl( getDirectString(input, [ "url", "link", "href", "uri", "source_url", "sourceUrl", "page_url", "pageUrl", "jump_url", "jumpUrl", "target_url", "targetUrl", ]), ); if (!url) { return null; } let host: string | null = null; try { host = new URL(url).hostname || null; } catch { host = null; } return { url, title: repairPotentialMojibake(getDirectString(input, ["title", "name", "page_title", "display_title", "text"])), site_name: repairPotentialMojibake(getDirectString(input, ["site_name", "siteName", "source", "platform_name", "site", "domain"])), site_key: getDirectString(input, ["site_key", "siteKey", "domain_key"]), normalized_url: url, host, resolution_status: repairPotentialMojibake(getDirectString(input, ["resolution_status", "resolutionStatus"])), resolution_confidence: repairPotentialMojibake(getDirectString(input, ["resolution_confidence", "resolutionConfidence"])), }; } function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { const keyed = new Map(); for (const item of items) { const key = normalizeSourceUrl(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, site_key: existing.site_key ?? item.site_key ?? null, host: existing.host ?? item.host ?? null, resolution_status: existing.resolution_status ?? item.resolution_status ?? null, resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null, }); } return Array.from(keyed.values()); } function 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 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("yuanbao_question_text_missing"); } 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); reject(new Error("adapter_aborted")); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } async function ensurePageOnYuanbaoChat(page: PlaywrightPage, signal: AbortSignal): Promise { if (signal.aborted) { throw new Error("adapter_aborted"); } const currentURL = safePageURL(page); if (!currentURL.startsWith("https://yuanbao.tencent.com/")) { await page.goto(YUANBAO_BOOTSTRAP_URL, { waitUntil: "domcontentloaded", timeout: YUANBAO_PAGE_READY_TIMEOUT_MS, }); } await page.waitForLoadState("domcontentloaded", { timeout: YUANBAO_PAGE_READY_TIMEOUT_MS, }).catch(() => undefined); await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); if (signal.aborted) { throw new Error("adapter_aborted"); } } function safePageURL(page: PlaywrightPage): string { try { return page.url(); } catch { return ""; } } function attachYuanbaoResponseCapture(page: PlaywrightPage): { captures: YuanbaoCaptureRecord[]; dispose: () => void; } { const captures: YuanbaoCaptureRecord[] = []; const pushCapture = (record: YuanbaoCaptureRecord) => { captures.push({ ...record, body: record.body.length > YUANBAO_CAPTURE_BODY_LIMIT ? record.body.slice(0, YUANBAO_CAPTURE_BODY_LIMIT) : record.body, }); if (captures.length > YUANBAO_CAPTURE_LIMIT) { captures.splice(0, captures.length - YUANBAO_CAPTURE_LIMIT); } }; const responseHandler = (response: PlaywrightResponse) => { const url = normalizeOptionalString(response.url()); if (!url || !url.startsWith("https://yuanbao.tencent.com/")) { return; } const headers = response.headers(); const contentType = normalizeOptionalString(headers["content-type"]) ?? null; const shouldRead = url.includes("/api/") || response.status() >= 400 || (contentType !== null && /(json|event-stream|text\/plain)/i.test(contentType)); if (!shouldRead) { return; } void response.body() .then((body) => { const normalizedBody = normalizeOptionalString(decodeResponseBody(body)) ?? ""; pushCapture({ url, status: response.status(), contentType, body: normalizedBody, }); }) .catch(() => undefined); }; page.on("response", responseHandler); return { captures, dispose() { page.off("response", responseHandler); }, }; } function extractJSONCandidates(body: string): unknown[] { const values: unknown[] = []; const seen = new Set(); const pushParsed = (raw: string) => { const normalized = raw.trim(); if (!normalized || seen.has(normalized)) { return; } try { values.push(JSON.parse(normalized)); seen.add(normalized); } catch { // Ignore malformed frames and continue with the remaining candidates. } }; pushParsed(body); for (const rawLine of body.split(/\r?\n/)) { const line = rawLine.trim(); if (!line) { continue; } if (line.startsWith("data:")) { const value = line.slice(5).trim(); if (value && value !== "[DONE]") { pushParsed(value); } continue; } if (line.startsWith("{") || line.startsWith("[")) { pushParsed(line); } } return values; } function collectSourceBucket(input: unknown, bucket: MonitoringSourceItem[]): void { if (input == null) { return; } if (Array.isArray(input)) { for (const item of input) { collectSourceBucket(item, bucket); } 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); } } } function walkYuanbaoPayload( value: unknown, summary: YuanbaoSummary, depth = 0, ): void { if (depth > 12 || value == null) { return; } if (Array.isArray(value)) { for (const item of value) { walkYuanbaoPayload(item, summary, depth + 1); } return; } if (!isRecord(value)) { return; } summary.providerModel = summary.providerModel ?? getDirectString(value, ["chatModelId", "chat_model_id", "model", "modelName", "model_name"]); summary.providerRequestID = summary.providerRequestID ?? getDirectString(value, ["traceID", "traceId", "request_id", "requestId", "msgId", "msg_id"]); summary.requestID = summary.requestID ?? getDirectString(value, ["request_id", "requestId", "traceID", "traceId", "msgId", "msg_id"]); summary.conversationID = summary.conversationID ?? getDirectString(value, ["conversationId", "conversation_id", "chatId", "chat_id"]); const type = getDirectString(value, ["type"]); if (type) { summary.contentTypes.add(type); switch (type) { case "text": summary.answer = mergeText(summary.answer, getDirectString(value, ["msg", "text", "content"])); break; case "think": summary.reasoning = mergeText(summary.reasoning, getDirectString(value, ["content", "msg", "text"])); break; case "searchGuid": collectSourceBucket(value.docs, summary.searchResults); collectSourceBucket(value.citations, summary.citations); break; case "toolCall": collectSourceBucket(value.docs, summary.searchResults); collectSourceBucket(value.ref_docs, summary.citations); break; case "thinkDeep": summary.reasoning = mergeText(summary.reasoning, getDirectString(value, ["content", "msg", "text"])); collectSourceBucket(value.thinkDeepSections, summary.citations); break; case "deepSearch": walkYuanbaoPayload(value.contents, summary, depth + 1); break; case "step": collectSourceBucket(value.thinkDeepSections, summary.citations); break; default: break; } } if (Array.isArray(value.content)) { walkYuanbaoPayload(value.content, summary, depth + 1); } if (Array.isArray(value.contents)) { walkYuanbaoPayload(value.contents, summary, depth + 1); } if (Array.isArray(value.speechesV2)) { walkYuanbaoPayload(value.speechesV2, summary, depth + 1); } if (Array.isArray(value.convs)) { walkYuanbaoPayload(value.convs, summary, depth + 1); } if (Array.isArray(value.docs)) { walkYuanbaoPayload(value.docs, summary, depth + 1); } if (Array.isArray(value.citations)) { walkYuanbaoPayload(value.citations, summary, depth + 1); } if (Array.isArray(value.ref_docs)) { walkYuanbaoPayload(value.ref_docs, summary, depth + 1); } if (Array.isArray(value.thinkDeepSections)) { walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1); } if (Array.isArray(value.list)) { walkYuanbaoPayload(value.list, summary, depth + 1); } for (const nested of Object.values(value)) { if (Array.isArray(nested) || isRecord(nested)) { walkYuanbaoPayload(nested, summary, depth + 1); } } } function parseYuanbaoCaptures(captures: YuanbaoCaptureRecord[]): YuanbaoSummary { const summary: YuanbaoSummary = { answer: null, reasoning: null, providerModel: null, providerRequestID: null, requestID: null, conversationID: null, citations: [], searchResults: [], candidateCount: 0, contentTypes: new Set(), }; for (const capture of captures) { if (!capture.body) { continue; } const candidates = extractJSONCandidates(capture.body); summary.candidateCount += candidates.length; for (const candidate of candidates) { walkYuanbaoPayload(candidate, summary); } } summary.citations = dedupeSourceItems(summary.citations); summary.searchResults = dedupeSourceItems(summary.searchResults); return summary; } function extractConversationID(url: string | null): string | null { const input = normalizeText(url); if (!input) { return null; } try { const parsed = new URL(input); const matched = parsed.pathname.match(/\/chat\/([^/?#]+)/i); return matched?.[1]?.trim() || null; } catch { return null; } } function resolveProviderModel(pageResult: YuanbaoPageQueryResult, summary: YuanbaoSummary): string { if (summary.providerModel) { return summary.providerModel; } if (pageResult.modelLabel) { return pageResult.modelLabel; } const title = normalizeText(pageResult.title); const matched = title?.match(/体验\s*([^-]+?)(?:全新版|高效AI助手|AI助手)/); const fromTitle = normalizeText(matched?.[1] ?? null); if (fromTitle) { return fromTitle; } return pageResult.deepThinkEnabled ? "yuanbao-web-deep-think" : "yuanbao-web"; } const yuanbaoQueryInPage = async ( parameters: { questionText: string; timeoutMs: number; readyTimeoutMs: number; enableDeepThink: boolean; enableWebSearch: boolean; }, ): Promise => { const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink, enableWebSearch } = parameters; const wait = (timeMs: number) => new Promise((resolve) => { window.setTimeout(resolve, timeMs); }); const normalize = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value.trim().replace(/\s+/g, " "); return trimmed ? trimmed : null; }; const isVisible = (element: Element | null | undefined): element is HTMLElement => { if (!(element instanceof HTMLElement)) { return false; } const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { return false; } const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const isDisabled = (element: HTMLElement | null): boolean => { if (!element) { return false; } if ("disabled" in element && typeof (element as HTMLButtonElement).disabled === "boolean") { return Boolean((element as HTMLButtonElement).disabled); } const ariaDisabled = element.getAttribute("aria-disabled"); return ariaDisabled === "true"; }; const parseColor = (value: string | null): { r: number; g: number; b: number; a: number } | null => { if (!value) { return null; } const matched = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); if (!matched) { return null; } return { r: Number(matched[1]), g: Number(matched[2]), b: Number(matched[3]), a: matched[4] ? Number(matched[4]) : 1, }; }; const isGreenish = (value: string | null): boolean => { const color = parseColor(value); if (!color) { return false; } return color.g >= color.r + 12 && color.g >= color.b + 12; }; const toggleState = (element: HTMLElement | null): boolean | null => { if (!element) { return null; } const attrs = [ element.getAttribute("aria-pressed"), element.getAttribute("aria-checked"), element.getAttribute("data-state"), element.getAttribute("data-selected"), element.getAttribute("data-active"), ] .map((value) => normalize(value)?.toLowerCase() ?? null) .filter((value): value is string => value !== null); if (attrs.some((value) => ["true", "checked", "selected", "active", "on"].includes(value))) { return true; } if (attrs.some((value) => ["false", "unchecked", "off"].includes(value))) { return false; } const className = normalize(element.className)?.toLowerCase() ?? ""; if (/(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$)/.test(className)) { return true; } if (/(^|[\s_-])(inactive|unselected|unchecked|off)([\s_-]|$)/.test(className)) { return false; } const style = window.getComputedStyle(element); if (isGreenish(style.color) || isGreenish(style.borderColor) || isGreenish(style.backgroundColor)) { return true; } const parent = element.closest("button, label, [role='button']") as HTMLElement | null; if (parent && parent !== element) { const parentStyle = window.getComputedStyle(parent); if ( isGreenish(parentStyle.color) || isGreenish(parentStyle.borderColor) || isGreenish(parentStyle.backgroundColor) ) { return true; } } return null; }; const textOf = (element: Element | null | undefined): string | null => { if (!(element instanceof HTMLElement)) { return null; } return normalize(element.innerText) ?? normalize(element.textContent); }; const hintText = (element: Element | null | undefined): string => { if (!(element instanceof HTMLElement)) { return ""; } return [ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("type"), element.getAttribute("name"), element.getAttribute("id"), typeof element.className === "string" ? element.className : "", textOf(element), ] .map((value) => normalize(value)?.toLowerCase() ?? "") .filter(Boolean) .join(" ") .slice(0, 600); }; const contextText = (element: HTMLElement | null): string => { if (!element) { return ""; } const scopes = [ element, element.parentElement, element.parentElement?.parentElement, element.closest("form, main, article, section, [role='dialog']"), ]; return scopes .map((scope) => textOf(scope)?.toLowerCase() ?? "") .filter(Boolean) .join(" ") .slice(0, 900); }; const looksLikeSearchSurface = (element: HTMLElement | null): boolean => { if (!element) { return false; } const searchHint = [ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("type"), element.getAttribute("name"), element.getAttribute("id"), typeof element.className === "string" ? element.className : "", ] .map((value) => normalize(value)?.toLowerCase() ?? "") .filter(Boolean) .join(" "); const context = contextText(element); return /(搜索|search)/i.test(searchHint) || /(搜索 元宝|全部应用|全部收藏|灵感图库|前往下载中心|下载中心)/i.test(context); }; const looksLikeComposerSurface = ( element: HTMLElement | null, shell: HTMLElement | null, ): boolean => { if (!element) { return false; } const hint = `${hintText(element)} ${hintText(shell)} ${contextText(shell ?? element)}`; return /(ql-editor|chat-input|text-area|dialogue|composer|editor|提问|问题|输入|深度思考|联网搜索|工具|内容由ai生成,仅供参考)/i.test(hint); }; const summarizeFailureDetail = (raw: string | null): string | null => { const normalized = normalize(raw); if (!normalized) { return null; } const cleaned = normalized .replace( /The resource http:\/\/localhost:[^\s]+ was preloaded using link preload but not used within a few seconds from the window's load event\.?/gi, "", ) .replace(/\s+/g, " ") .trim(); if (!cleaned) { return null; } if (cleaned.length <= 280) { return cleaned; } return `${cleaned.slice(0, 280)}...`; }; const findComposer = (): HTMLElement | null => { const selectors = [ "[contenteditable='true'][role='textbox']", "[contenteditable='true'][aria-multiline='true']", "[contenteditable='true']", "textarea", "input[type='text']", ]; const candidates: Array<{ element: HTMLElement; score: number }> = []; for (const selector of selectors) { const matched = Array.from(document.querySelectorAll(selector)); for (const node of matched) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue; } if ((node as HTMLInputElement).readOnly || (node as HTMLInputElement).disabled) { continue; } const shell = findComposerShell(node); const rect = node.getBoundingClientRect(); const isPlainInput = node instanceof HTMLInputElement; const composerLike = looksLikeComposerSurface(node, shell); const searchLike = looksLikeSearchSurface(node) || looksLikeSearchSurface(shell); if (searchLike && !composerLike) { continue; } if (isPlainInput && !composerLike && rect.top < window.innerHeight * 0.75) { continue; } let score = rect.top * 3 + Math.min(rect.width, 1200) + rect.height; if (node.isContentEditable) { score += 2_200; } else if (node instanceof HTMLTextAreaElement) { score += 1_400; } else if (isPlainInput) { score -= 1_000; } if (composerLike) { score += 1_800; } if (searchLike) { score -= 1_800; } if (rect.top < window.innerHeight * 0.45) { score -= 1_200; } if (rect.width >= window.innerWidth * 0.45) { score += 400; } if (score <= 0) { continue; } candidates.push({ element: node, score, }); } } candidates.sort((left, right) => right.score - left.score); return candidates[0]?.element ?? null; }; const findComposerShell = (composer: HTMLElement | null): HTMLElement | null => { if (!composer) { return null; } let current: HTMLElement | null = composer; let best: HTMLElement | null = composer; let bestArea = composer.getBoundingClientRect().width * composer.getBoundingClientRect().height; for (let depth = 0; current && depth < 5; depth += 1) { if (isVisible(current)) { const rect = current.getBoundingClientRect(); const area = rect.width * rect.height; if (area >= bestArea) { best = current; bestArea = area; } } current = current.parentElement; } return best; }; const findInteractiveByText = (pattern: RegExp, shell: HTMLElement | null): HTMLElement | null => { const scopes: ParentNode[] = []; if (shell) { scopes.push(shell); if (shell.parentElement) { scopes.push(shell.parentElement); } if (shell.parentElement?.parentElement) { scopes.push(shell.parentElement.parentElement); } } scopes.push(document); const seen = new Set(); const candidates: Array<{ element: HTMLElement; score: number }> = []; for (const scope of scopes) { const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]")); for (const node of nodes) { if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { continue; } seen.add(node); const text = normalize(node.innerText) ?? normalize(node.textContent); if (!text || !pattern.test(text)) { continue; } const rect = node.getBoundingClientRect(); const score = rect.top + rect.left + rect.width; candidates.push({ element: node, score }); } } candidates.sort((left, right) => right.score - left.score); return candidates[0]?.element ?? null; }; const setComposerText = (composer: HTMLElement, text: string) => { composer.focus(); if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { const prototype = Object.getPrototypeOf(composer) as { value?: PropertyDescriptor; }; const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; if (setter) { setter.call(composer, text); } else { composer.value = text; } composer.dispatchEvent(new Event("input", { bubbles: true })); composer.dispatchEvent(new Event("change", { bubbles: true })); return; } if (composer.isContentEditable) { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(composer); selection?.removeAllRanges(); selection?.addRange(range); const inserted = typeof document.execCommand === "function" ? document.execCommand("insertText", false, text) : false; if (!inserted) { composer.textContent = text; } composer.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, data: text, inputType: "insertText", })); composer.dispatchEvent(new Event("change", { bubbles: true })); } }; const findSendButton = ( shell: HTMLElement | null, composer: HTMLElement | null, ): HTMLElement | null => { const scopes: ParentNode[] = []; if (shell) { scopes.push(shell); if (shell.parentElement) { scopes.push(shell.parentElement); } if (shell.parentElement?.parentElement) { scopes.push(shell.parentElement.parentElement); } } scopes.push(document); const composerRect = composer?.getBoundingClientRect() ?? null; const blacklist = /^(深度思考|联网搜索|工具|登录|安装电脑版|下载电脑版)$/; const candidates: Array<{ element: HTMLElement; score: number }> = []; const seen = new Set(); for (const scope of scopes) { const nodes = Array.from(scope.querySelectorAll("button, [role='button']")); for (const node of nodes) { if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { continue; } seen.add(node); const text = normalize(node.innerText) ?? normalize(node.textContent) ?? ""; const hint = `${hintText(node)} ${contextText(node.parentElement)}`; if (blacklist.test(text)) { continue; } if (/(登录|下载电脑版|安装电脑版|前往下载中心|全部应用|全部收藏|搜索)/i.test(hint)) { continue; } if (isDisabled(node)) { continue; } const rect = node.getBoundingClientRect(); if (rect.width < 20 || rect.height < 20) { continue; } if (composerRect) { if (rect.top < composerRect.top - 120 || rect.top > composerRect.bottom + 140) { continue; } if (rect.left < composerRect.left - 80 || rect.left > composerRect.right + 240) { continue; } } let score = rect.left * 2 + rect.top + rect.width + rect.height; if (composerRect) { const dx = Math.abs(rect.left - composerRect.right); const dy = Math.abs(rect.top - composerRect.top); score += Math.max(0, 1_800 - dx * 3 - dy * 3); if (rect.left >= composerRect.right - 24) { score += 400; } } if (/(发送|submit|send|paper-plane|arrow|up)/i.test(hint)) { score += 1_200; } if (!text && rect.width <= 64 && rect.height <= 64) { score += 280; } candidates.push({ element: node, score }); } } candidates.sort((left, right) => right.score - left.score); return candidates[0]?.element ?? null; }; const dispatchEnter = (composer: HTMLElement) => { const eventInit: KeyboardEventInit = { bubbles: true, cancelable: true, key: "Enter", code: "Enter", }; composer.dispatchEvent(new KeyboardEvent("keydown", eventInit)); composer.dispatchEvent(new KeyboardEvent("keypress", eventInit)); composer.dispatchEvent(new KeyboardEvent("keyup", eventInit)); }; const collectLinks = (scope: HTMLElement | null): YuanbaoDomLink[] => { if (!scope) { return []; } const links: YuanbaoDomLink[] = []; const seen = new Set(); for (const node of Array.from(scope.querySelectorAll("a[href]"))) { if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) { continue; } const href = normalize(node.href); if (!href || !/^https?:\/\//i.test(href) || seen.has(href)) { continue; } seen.add(href); links.push({ url: href, title: normalize(node.title) ?? normalize(node.getAttribute("aria-label")), text: normalize(node.innerText) ?? normalize(node.textContent) ?? normalize(node.getAttribute("aria-label")), }); } return links; }; const snapshotConversation = (composerTop: number | null): { text: string | null; links: YuanbaoDomLink[]; signature: string; } => { const candidates: Array<{ element: HTMLElement; score: number }> = []; const elements = Array.from(document.querySelectorAll("article, section, div, main")); for (const node of elements) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue; } const rect = node.getBoundingClientRect(); if (composerTop !== null && rect.top >= composerTop - 12) { continue; } if (rect.left < window.innerWidth * 0.12 || rect.width < 180) { continue; } const text = normalize(node.innerText); const links = collectLinks(node); if ((!text || text.length < 24) && links.length === 0) { continue; } if ( text && ( /^(Hi[,,\s].*|内容由AI生成,仅供参考|请登录后输入内容)$/.test(text) || /(前往下载中心|全部应用|全部收藏|搜索 元宝|安装电脑版|下载电脑版)/.test(text) ) ) { continue; } if (looksLikeSearchSurface(node)) { continue; } const score = rect.height * 2 + Math.min(text?.length ?? 0, 800) + links.length * 80 - Math.max(0, Math.abs((composerTop ?? window.innerHeight) - rect.bottom)); candidates.push({ element: node, score }); } candidates.sort((left, right) => right.score - left.score); const best = candidates[0]?.element ?? null; const text = best ? normalize(best.innerText) : null; const links = best ? collectLinks(best) : []; const signature = `${text ?? ""}|${links.map((item) => item.url).join("|")}`.slice(-1_500); return { text, links, signature }; }; const resolveModelLabel = (): string | null => { const title = normalize(document.title); const matched = title?.match(/体验\s*([^-]+?)(?:全新版|高效AI助手|AI助手)/); return normalize(matched?.[1] ?? null); }; const bodyText = () => normalize(document.body?.innerText ?? ""); const hasLoginGate = (): boolean => { if (document.querySelector(".hyc-login__dialog, [class*='login__dialog'], [class*='login-dialog']")) { return true; } return /请登录后输入内容|请先登录|立即登录|未登录|扫码登录|微信扫码登录/.test(bodyText() ?? ""); }; const hasBusyIndicator = (): boolean => { const text = bodyText() ?? ""; return /停止生成|思考中|搜索中|回答中|生成中/.test(text); }; const fail = ( error: string, detail: string | null, composerTop: number | null, ): YuanbaoPageQueryFailureResult => { const snapshot = snapshotConversation(composerTop); const deepThinkButton = findInteractiveByText(/深度思考/, findComposerShell(findComposer())); const webSearchButton = findInteractiveByText(/联网搜索/, findComposerShell(findComposer())); return { ok: false, error, detail: summarizeFailureDetail(detail) ?? summarizeFailureDetail(snapshot.text) ?? summarizeFailureDetail(bodyText()), url: window.location.href || null, title: normalize(document.title), modelLabel: resolveModelLabel(), deepThinkEnabled: toggleState(deepThinkButton), webSearchEnabled: toggleState(webSearchButton), domAnswer: snapshot.text, domLinks: snapshot.links, }; }; const readyDeadline = Date.now() + readyTimeoutMs; let composer = findComposer(); while (!composer && Date.now() <= readyDeadline) { await wait(250); composer = findComposer(); } if (hasLoginGate()) { return fail("yuanbao_login_required", bodyText(), composer?.getBoundingClientRect().top ?? null); } if (!composer) { return fail("yuanbao_composer_missing", bodyText(), null); } let composerShell = findComposerShell(composer); const composerTop = composer.getBoundingClientRect().top; if (enableDeepThink) { const deepThinkButton = findInteractiveByText(/深度思考/, composerShell); if (deepThinkButton && toggleState(deepThinkButton) !== true) { deepThinkButton.click(); await wait(160); } } if (enableWebSearch) { const webSearchButton = findInteractiveByText(/联网搜索/, composerShell); if (webSearchButton && toggleState(webSearchButton) !== true) { webSearchButton.click(); await wait(160); } } setComposerText(composer, questionText); await wait(220); composer = findComposer() ?? composer; composerShell = findComposerShell(composer) ?? composerShell; const sendButton = findSendButton(composerShell, composer); if (sendButton) { sendButton.click(); } else { dispatchEnter(composer); } await wait(600); const initialSnapshot = snapshotConversation(composerTop); let lastSignature = initialSnapshot.signature; let stableCount = 0; let sawChange = false; const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const snapshot = snapshotConversation(composerTop); if (snapshot.signature && snapshot.signature !== initialSnapshot.signature) { sawChange = true; if (snapshot.signature === lastSignature) { stableCount += 1; } else { lastSignature = snapshot.signature; stableCount = 0; } } if (hasLoginGate()) { return fail("yuanbao_login_expired", bodyText(), composerTop); } if (sawChange && !hasBusyIndicator() && stableCount >= 2) { const deepThinkButton = findInteractiveByText(/深度思考/, composerShell); const webSearchButton = findInteractiveByText(/联网搜索/, composerShell); return { ok: true, url: window.location.href, title: normalize(document.title), modelLabel: resolveModelLabel(), deepThinkEnabled: toggleState(deepThinkButton), webSearchEnabled: toggleState(webSearchButton), domAnswer: snapshot.text, domLinks: snapshot.links, }; } await wait(1_000); } return fail("yuanbao_query_timeout", bodyText(), composerTop); }; export const yuanbaoAdapter: MonitorAdapter = { provider: "yuanbao", executionMode: "playwright", async query(context, payload) { if (!context.playwright?.page) { return { status: "failed", summary: "元宝监测缺少 Playwright 页面上下文。", error: buildAdapterError("yuanbao_playwright_required", "yuanbao_playwright_required"), }; } const questionText = extractQuestionText(payload); const page = context.playwright.page; context.reportProgress("yuanbao.page_ready"); await ensurePageOnYuanbaoChat(page, context.signal); const capture = attachYuanbaoResponseCapture(page); let pageResult: YuanbaoPageQueryResult; try { context.reportProgress("yuanbao.query"); pageResult = await page.evaluate(yuanbaoQueryInPage, { questionText, timeoutMs: YUANBAO_QUERY_TIMEOUT_MS, readyTimeoutMs: YUANBAO_PAGE_READY_TIMEOUT_MS, enableDeepThink: true, enableWebSearch: true, }); await sleep(600, context.signal).catch(() => undefined); } finally { capture.dispose(); } const parsed = parseYuanbaoCaptures(capture.captures); const domCitations = dedupeSourceItems( pageResult.domLinks .map((item) => buildSourceItem(item)) .filter((item): item is MonitoringSourceItem => item !== null), ); const searchResults = dedupeSourceItems(parsed.searchResults); const answerWithMarkers = selectBestAnswerText(parsed.answer, pageResult.domAnswer); const citationMarkerIndexes = extractCitationMarkerIndexes(answerWithMarkers ?? pageResult.domAnswer); const markerCitations = dedupeSourceItems(resolveCitationsFromMarkers( answerWithMarkers ?? pageResult.domAnswer, [ parsed.citations, parsed.searchResults, domCitations, dedupeSourceItems([...parsed.citations, ...parsed.searchResults, ...domCitations]), ], )); const answer = answerWithMarkers; const citations = dedupeSourceItems([...parsed.citations, ...markerCitations, ...domCitations]); const reasoning = repairPotentialMojibake(normalizeText(parsed.reasoning)); const providerModel = resolveProviderModel(pageResult, parsed); const conversationID = parsed.conversationID ?? extractConversationID(pageResult.url); const requestID = parsed.requestID ?? conversationID; if (pageResult.ok === false) { const failedPageResult = pageResult; const detail = normalizeText(failedPageResult.detail) ?? "unknown"; if ( failedPageResult.error === "yuanbao_login_required" || failedPageResult.error === "yuanbao_login_expired" ) { return { status: "failed", summary: "元宝账号未登录或登录态已失效,请先在 desktop client 中重新授权。", error: buildAdapterError( failedPageResult.error, detail, { page_url: failedPageResult.url ?? safePageURL(page), }, ), }; } if ( failedPageResult.error !== "yuanbao_query_timeout" || (!answer && !searchResults.length && !citations.length) ) { return { status: "failed", summary: `元宝请求失败:${detail}`, error: buildAdapterError( failedPageResult.error, detail, { page_url: failedPageResult.url ?? safePageURL(page), }, ), }; } } if (!answer && !searchResults.length && !citations.length) { return { status: "unknown", summary: "元宝返回为空,已回写 unknown 等待后续对账。", error: buildAdapterError("yuanbao_empty_response", "yuanbao returned no answer or sources"), }; } context.reportProgress("yuanbao.parse_result"); return { status: "succeeded", summary: "元宝监控任务执行成功。", payload: { platform: "yuanbao", provider_model: providerModel, provider_request_id: parsed.providerRequestID, request_id: requestID, conversation_id: conversationID, answer, reasoning, citation_count: citations.length, search_result_count: searchResults.length, citations: toJsonSources(citations), search_results: toJsonSources(searchResults), raw_response_json: { platform: "yuanbao", page_url: pageResult.url, page_title: pageResult.title, model_label: pageResult.modelLabel, deep_think_enabled: pageResult.deepThinkEnabled, web_search_enabled: pageResult.webSearchEnabled, candidate_count: parsed.candidateCount, capture_count: capture.captures.length, content_types: Array.from(parsed.contentTypes.values()), citation_marker_count: citationMarkerIndexes.length, citation_marker_indexes: citationMarkerIndexes, }, }, }; }, };