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