f91d2645d3
- implement kimi/yuanbao monitor adapters with dedicated citation panel detection - detect Kimi session via kimi-auth cookie during account binding - harden hidden Playwright and bound windows with skipTaskbar/focusable/hiddenInMissionControl to stop stealing focus - tag hidden bootstrap windows with a unique token so CDP retention resolves the correct page - enable yuanbao/kimi/wenxin platforms in dev-seed and default monitoring quota - update kimi loginUrl to www.kimi.com Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1727 lines
50 KiB
TypeScript
1727 lines
50 KiB
TypeScript
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<number, number>([
|
||
[0x20ac, 0x80],
|
||
[0x201a, 0x82],
|
||
[0x0192, 0x83],
|
||
[0x201e, 0x84],
|
||
[0x2026, 0x85],
|
||
[0x2020, 0x86],
|
||
[0x2021, 0x87],
|
||
[0x02c6, 0x88],
|
||
[0x2030, 0x89],
|
||
[0x0160, 0x8a],
|
||
[0x2039, 0x8b],
|
||
[0x0152, 0x8c],
|
||
[0x017d, 0x8e],
|
||
[0x2018, 0x91],
|
||
[0x2019, 0x92],
|
||
[0x201c, 0x93],
|
||
[0x201d, 0x94],
|
||
[0x2022, 0x95],
|
||
[0x2013, 0x96],
|
||
[0x2014, 0x97],
|
||
[0x02dc, 0x98],
|
||
[0x2122, 0x99],
|
||
[0x0161, 0x9a],
|
||
[0x203a, 0x9b],
|
||
[0x0153, 0x9c],
|
||
[0x017e, 0x9e],
|
||
[0x0178, 0x9f],
|
||
]);
|
||
|
||
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<string>;
|
||
};
|
||
|
||
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 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<number>();
|
||
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<string, unknown>, 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<string, MonitoringSourceItem>();
|
||
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<string, JsonValue> = {},
|
||
): Record<string, JsonValue> {
|
||
return {
|
||
code,
|
||
message,
|
||
...extras,
|
||
};
|
||
}
|
||
|
||
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("yuanbao_question_text_missing");
|
||
}
|
||
|
||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||
await new Promise<void>((resolve, reject) => {
|
||
if (signal?.aborted) {
|
||
reject(new Error("adapter_aborted"));
|
||
return;
|
||
}
|
||
|
||
const timer = setTimeout(() => {
|
||
signal?.removeEventListener("abort", onAbort);
|
||
resolve();
|
||
}, ms);
|
||
|
||
const onAbort = () => {
|
||
clearTimeout(timer);
|
||
reject(new Error("adapter_aborted"));
|
||
};
|
||
|
||
signal?.addEventListener("abort", onAbort, { once: true });
|
||
});
|
||
}
|
||
|
||
async function ensurePageOnYuanbaoChat(page: PlaywrightPage, signal: AbortSignal): Promise<void> {
|
||
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<string>();
|
||
|
||
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<string>(),
|
||
};
|
||
|
||
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<YuanbaoPageQueryResult> => {
|
||
const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink, enableWebSearch } = parameters;
|
||
|
||
const wait = (timeMs: number) =>
|
||
new Promise<void>((resolve) => {
|
||
window.setTimeout(resolve, timeMs);
|
||
});
|
||
|
||
const normalize = (value: unknown): string | null => {
|
||
if (typeof value !== "string") {
|
||
return null;
|
||
}
|
||
const trimmed = value.trim().replace(/\s+/g, " ");
|
||
return trimmed ? trimmed : null;
|
||
};
|
||
|
||
const 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<HTMLElement>();
|
||
const candidates: Array<{ element: HTMLElement; score: number }> = [];
|
||
|
||
for (const scope of scopes) {
|
||
const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]"));
|
||
for (const node of nodes) {
|
||
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
||
continue;
|
||
}
|
||
seen.add(node);
|
||
|
||
const text = 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<HTMLElement>();
|
||
|
||
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<string>();
|
||
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,
|
||
},
|
||
},
|
||
};
|
||
},
|
||
};
|