import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; import type { Page as PlaywrightPage } from "playwright-core"; import { normalizeText } from "./common"; import type { MonitorAdapter } from "./base"; const KIMI_BOOTSTRAP_URL = "https://www.kimi.com/"; const KIMI_PAGE_READY_TIMEOUT_MS = 20_000; const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000; const KIMI_QUERY_TIMEOUT_MS = 120_000; const KIMI_QUERY_POLL_INTERVAL_MS = 1_200; const KIMI_STABLE_POLLS_REQUIRED = 5; const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000; const KIMI_EDITOR_SELECTOR = "div[role=\"textbox\"].chat-input-editor, .chat-input-editor[role=\"textbox\"], [role=\"textbox\"][class*=\"chat-input-editor\"]"; const KIMI_SEND_BUTTON_SELECTOR = ".send-button-container"; const KIMI_THINKING_MODE_LABEL = "K2.6 思考"; const KIMI_REDIRECT_QUERY_KEYS = [ "url", "u", "target", "targetUrl", "target_url", "link", "href", "sourceUrl", "source_url", "originUrl", "origin_url", "realUrl", "real_url", "redirect", "redirect_url", "jump", "jump_url", "to", ]; type KimiDomLink = { url: string; title: string | null; text: string | null; siteName: string | null; }; type KimiPageSnapshot = { url: string; title: string | null; currentModelLabel: string | null; loginRequired: boolean; loginReason: string | null; busy: boolean; busySignals: string[]; sendDisabled: boolean | null; answerText: string | null; answerBlocks: string[]; reasoningText: string | null; reasoningBlocks: string[]; questionMatched: boolean; candidateAnswerCount: number; candidateReasoningCount: number; citationLinks: KimiDomLink[]; panelCitationLinks: KimiDomLink[]; inlineCitationCandidateLinks: KimiDomLink[]; searchResultLinks: KimiDomLink[]; signature: string | null; }; type KimiClassifiedSources = { citations: MonitoringSourceItem[]; searchResults: MonitoringSourceItem[]; inlineCitationCandidates: MonitoringSourceItem[]; panelCitations: MonitoringSourceItem[]; }; type KimiModeSwitchResult = | { ok: true; currentModelLabel: string | null; } | { ok: false; error: string; detail: string | null; currentModelLabel: string | null; url: string | null; }; type KimiQueryWaitResult = | { ok: true; finalSnapshot: KimiPageSnapshot; stablePollCount: number; elapsedMs: number; } | { ok: false; error: string; detail: string | null; finalSnapshot: KimiPageSnapshot; stablePollCount: number; elapsedMs: number; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizeOptionalString(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; } function decodeUrlCandidate(value: string): string { let decoded = value.trim(); for (let index = 0; index < 3; index += 1) { try { const next = decodeURIComponent(decoded); if (next === decoded) { break; } decoded = next; } catch { break; } } return decoded; } function coercePotentialUrl(value: string): string { const trimmed = decodeUrlCandidate(value); if (/^\/\//.test(trimmed)) { return `https:${trimmed}`; } if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(trimmed)) { return `https://${trimmed}`; } return trimmed; } function normalizeAbsoluteUrl(value: string): string | null { try { const url = new URL(coercePotentialUrl(value)); url.hash = ""; return url.toString(); } catch { return null; } } function extractRedirectTargetUrl(value: string): string | null { const direct = normalizeAbsoluteUrl(value); if (!direct) { return null; } try { const url = new URL(direct); for (const key of KIMI_REDIRECT_QUERY_KEYS) { const raw = url.searchParams.get(key); if (!raw) { continue; } const normalized = normalizeAbsoluteUrl(raw); if (normalized) { return normalized; } } } catch { return null; } const encodedMatch = direct.match(/https?%3A%2F%2F[^&\s"'<>]+/i); if (encodedMatch) { return normalizeAbsoluteUrl(encodedMatch[0]); } const plainMatch = direct.match(/https?:\/\/[^&\s"'<>]+/i); if (plainMatch && plainMatch[0] !== direct) { return normalizeAbsoluteUrl(plainMatch[0]); } return null; } function normalizeUrl(value: unknown): string | null { const input = normalizeText(value); if (!input) { return null; } return extractRedirectTargetUrl(input) ?? normalizeAbsoluteUrl(input); } function buildSourceItem(source: KimiDomLink): MonitoringSourceItem | null { const url = normalizeUrl(source.url); if (!url) { return null; } let host: string | null = null; try { host = new URL(url).hostname || null; } catch { host = null; } return { url, title: normalizeText(source.title ?? source.text ?? source.siteName), site_name: normalizeText(source.siteName), normalized_url: url, host, }; } function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { const keyed = new Map(); for (const item of items) { const key = normalizeUrl(item.normalized_url ?? item.url); if (!key) { continue; } const existing = keyed.get(key); if (!existing) { keyed.set(key, { ...item, normalized_url: key, }); continue; } keyed.set(key, { ...existing, title: existing.title ?? item.title ?? null, site_name: existing.site_name ?? item.site_name ?? null, host: existing.host ?? item.host ?? null, }); } return Array.from(keyed.values()); } function dedupeKimiDomLinks(items: KimiDomLink[]): KimiDomLink[] { const keyed = new Map(); for (const item of items) { const key = normalizeUrl(item.url); if (!key) { continue; } const existing = keyed.get(key); if (!existing) { keyed.set(key, { ...item, url: key, }); continue; } keyed.set(key, { url: key, title: existing.title ?? item.title ?? null, text: existing.text ?? item.text ?? null, siteName: existing.siteName ?? item.siteName ?? null, }); } return Array.from(keyed.values()); } function mergeKimiDomLinks(left: KimiDomLink[], right: KimiDomLink[]): KimiDomLink[] { return dedupeKimiDomLinks([...left, ...right]); } function mergeKimiSnapshots(base: KimiPageSnapshot, extra: KimiPageSnapshot): KimiPageSnapshot { const citationLinks = mergeKimiDomLinks(base.citationLinks, extra.citationLinks); const panelCitationLinks = mergeKimiDomLinks(base.panelCitationLinks, extra.panelCitationLinks); const inlineCitationCandidateLinks = mergeKimiDomLinks( base.inlineCitationCandidateLinks, extra.inlineCitationCandidateLinks, ); const searchResultLinks = mergeKimiDomLinks(base.searchResultLinks, extra.searchResultLinks); return { ...base, citationLinks, panelCitationLinks, inlineCitationCandidateLinks, searchResultLinks, signature: [ base.signature, extra.signature, ...citationLinks.map((item) => item.url), ...searchResultLinks.map((item) => item.url), ].filter(Boolean).join("\n").slice(-6_000) || base.signature || extra.signature, }; } function mergeKimiSourceLinksIntoContentSnapshot( contentSnapshot: KimiPageSnapshot, sourceSnapshot: KimiPageSnapshot, ): KimiPageSnapshot { return mergeKimiSnapshots(contentSnapshot, sourceSnapshot); } function classifyKimiSources(snapshot: KimiPageSnapshot): KimiClassifiedSources { const searchResults = dedupeSourceItems( snapshot.searchResultLinks .map((item) => buildSourceItem(item)) .filter((item): item is MonitoringSourceItem => item !== null), ); const panelCitations = dedupeSourceItems( snapshot.panelCitationLinks .map((item) => buildSourceItem(item)) .filter((item): item is MonitoringSourceItem => item !== null), ); const inlineAnswerCitations = dedupeSourceItems( snapshot.inlineCitationCandidateLinks .map((item) => buildSourceItem(item)) .filter((item): item is MonitoringSourceItem => item !== null), ); const inlineCitationCandidates = dedupeSourceItems([ ...panelCitations, ...inlineAnswerCitations, ]); return { // Kimi's citation-source list is the "搜索网页" result set. The footer "引用" panel // is kept as inline/content citation metadata instead. citations: searchResults.length ? searchResults : panelCitations, searchResults, inlineCitationCandidates, panelCitations, }; } function toJsonValue(value: unknown, depth = 0): JsonValue { if (value == null || depth > 12) { return null; } if (typeof value === "string" || typeof value === "boolean") { return value; } if (typeof value === "number") { return Number.isFinite(value) ? value : null; } if (Array.isArray(value)) { return value.map((item) => toJsonValue(item, depth + 1)); } if (!isRecord(value)) { return null; } const result: Record = {}; for (const [key, item] of Object.entries(value)) { result[key] = toJsonValue(item, depth + 1); } return result; } function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { return items.map((item) => ({ url: item.url, title: item.title ?? null, site_name: item.site_name ?? null, site_key: item.site_key ?? null, normalized_url: item.normalized_url ?? null, host: item.host ?? null, registrable_domain: item.registrable_domain ?? null, subdomain: item.subdomain ?? null, suffix: item.suffix ?? null, article_id: item.article_id ?? null, publish_record_id: item.publish_record_id ?? null, resolution_status: item.resolution_status ?? null, resolution_confidence: item.resolution_confidence ?? null, })); } function buildAdapterError( code: string, message: string, extras: Record = {}, ): Record { return { code, message, ...extras, }; } function extractQuestionText(payload: Record): string { const candidates = [ payload.question_text, payload.questionText, payload.query, payload.question, payload.prompt, payload.content, payload.title, ]; for (const candidate of candidates) { const text = normalizeOptionalString(candidate); if (text) { return text; } } throw new Error("kimi_question_text_missing"); } function safePageURL(page: PlaywrightPage): string { try { return page.url(); } catch { return ""; } } async function sleep(ms: number, signal?: AbortSignal): Promise { if (!signal) { await new Promise((resolve) => { setTimeout(resolve, ms); }); return; } if (signal.aborted) { throw new Error("adapter_aborted"); } await new Promise((resolve, reject) => { const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timer); signal.removeEventListener("abort", onAbort); reject(new Error("adapter_aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); }); } async function ensurePageOnKimiChat(page: PlaywrightPage, signal: AbortSignal): Promise { if (signal.aborted) { throw new Error("adapter_aborted"); } const currentURL = safePageURL(page); if (!currentURL.startsWith("https://www.kimi.com/")) { await page.goto(KIMI_BOOTSTRAP_URL, { waitUntil: "domcontentloaded", timeout: KIMI_PAGE_READY_TIMEOUT_MS, }); } await page.waitForLoadState("domcontentloaded", { timeout: KIMI_PAGE_READY_TIMEOUT_MS, }).catch(() => undefined); await page.waitForLoadState("networkidle", { timeout: 3_000, }).catch(() => undefined); if (signal.aborted) { throw new Error("adapter_aborted"); } } async function pinKimiConversationToBottom(page: PlaywrightPage): Promise { await page.evaluate(() => { const scrollers = new Set(); const maybeTrack = (value: Element | null) => { if (!(value instanceof HTMLElement)) { return; } if (value.scrollHeight - value.clientHeight < 48) { return; } scrollers.add(value); }; maybeTrack(document.scrollingElement as HTMLElement | null); maybeTrack(document.documentElement); maybeTrack(document.body); for (const selector of [ "main", "section", "article", "[class*=\"scroll\"]", "[class*=\"conversation\"]", "[class*=\"message\"]", "[class*=\"chat\"]", "[class*=\"list\"]", "[class*=\"content\"]", ]) { for (const element of Array.from(document.querySelectorAll(selector))) { maybeTrack(element); } } for (const scroller of scrollers) { scroller.scrollTop = scroller.scrollHeight; } window.scrollTo(0, Math.max( document.body?.scrollHeight ?? 0, document.documentElement?.scrollHeight ?? 0, )); }).catch(() => undefined); } async function ensureKimiCitationPanelOpen(page: PlaywrightPage, signal?: AbortSignal): Promise { for (let attempt = 0; attempt < 4; attempt += 1) { await pinKimiConversationToBottom(page); const locatorVisibleCount = await page.locator( ".side-console-container .ref .sites a.site-item[href], .side-console .ref .sites a.site-item[href]", ).count().catch(() => 0); if (locatorVisibleCount > 0) { return; } const refAction = page.locator(".chat-content-item-assistant .ref-action, .segment-assistant-actions .ref-action").last(); if (await refAction.isVisible().catch(() => false)) { const clicked = await refAction.click({ force: true }).then(() => true).catch(() => false); if (clicked) { await sleep(250, signal).catch(() => undefined); } } const state = await page.evaluate(() => { 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 visibleCitationCount = Array.from( document.querySelectorAll(".side-console-container .ref .sites a.site-item[href], .side-console .ref .sites a.site-item[href]"), ).filter((node) => isVisible(node)).length; if (visibleCitationCount > 0) { return { ready: true, visibleCitationCount, }; } const refActions = Array.from(document.querySelectorAll(".chat-content-item-assistant .ref-action, .segment-assistant-actions .ref-action")); const target = refActions.at(-1); if (target instanceof HTMLElement) { target.click(); return { ready: false, visibleCitationCount: 0, }; } return { ready: false, visibleCitationCount: 0, }; }).catch(() => ({ ready: false, visibleCitationCount: 0, })); if (state.ready) { return; } await sleep(250, signal).catch(() => undefined); } } async function ensureKimiSearchPanelOpen(page: PlaywrightPage, signal?: AbortSignal): Promise { for (let attempt = 0; attempt < 4; attempt += 1) { const state = await page.evaluate(() => { const normalize = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join(" "); 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 panelReady = Array.from(document.querySelectorAll("div, section, aside")) .some((element) => { if (!(element instanceof HTMLElement) || !isVisible(element)) { return false; } const text = normalize(element.innerText) ?? ""; return /^搜索网页\s+\d+/m.test(text) && element.querySelectorAll("a[href], [data-url], [data-href]").length > 0; }); if (panelReady) { return { ready: true, clicked: false, }; } const candidates = Array.from(document.querySelectorAll("button, [role=\"button\"], a, [tabindex], div, li, span")) .filter((element): element is HTMLElement => isVisible(element)) .map((element) => { const text = normalize(element.innerText || element.textContent || "") ?? ""; if (!/搜索网页/.test(text) || !/结果/.test(text)) { return null; } const target = element.closest("button, [role=\"button\"], a, [tabindex], li, [class*=\"search\"], [class*=\"Search\"], [class*=\"web\"], [class*=\"Web\"]") ?? element; if (!(target instanceof HTMLElement) || !isVisible(target)) { return null; } const rect = target.getBoundingClientRect(); const className = String(target.className || ""); const inSideConsole = Boolean(target.closest(".side-console, .side-console-container")); let score = 0; if (/^\s*搜索网页/.test(text)) { score += 120; } if (/\d+\s*个?结果/.test(text)) { score += 100; } if (target.matches("button, [role=\"button\"], a, [tabindex]")) { score += 60; } if (/(search|web|result|思考|research|ole)/i.test(className)) { score += 30; } if (inSideConsole) { score -= 200; } score -= Math.min(rect.width * rect.height / 20_000, 60); return { target, score, }; }) .filter((item): item is { target: HTMLElement; score: number } => Boolean(item)) .sort((left, right) => right.score - left.score); const seen = new Set(); const trigger = candidates .map((item) => item.target) .find((target) => { if (seen.has(target)) { return false; } seen.add(target); return true; }); if (!trigger) { return { ready: false, clicked: false, }; } trigger.scrollIntoView({ block: "center", inline: "center" }); trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); trigger.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })); trigger.click(); return { ready: false, clicked: true, }; }).catch(() => ({ ready: false, clicked: false, })); if (state.ready) { return; } if (!state.clicked && attempt > 0) { await pinKimiConversationToBottom(page); } await sleep(450, signal).catch(() => undefined); } } async function scrollKimiSearchPanel(page: PlaywrightPage): Promise<{ atBottom: boolean; moved: boolean }> { return page.evaluate(() => { const normalize = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join("\n"); 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 candidates = Array.from(document.querySelectorAll("div, section, aside")) .filter((element): element is HTMLElement => { if (!(element instanceof HTMLElement) || !isVisible(element)) { return false; } const text = normalize(element.innerText) ?? ""; return text.includes("搜索网页") && element.scrollHeight - element.clientHeight > 32; }) .sort((left, right) => { const leftScrollable = left.scrollHeight - left.clientHeight; const rightScrollable = right.scrollHeight - right.clientHeight; return rightScrollable - leftScrollable; }); const panel = candidates[0] ?? null; if (!panel) { return { atBottom: true, moved: false, }; } const before = panel.scrollTop; const maxScrollTop = Math.max(panel.scrollHeight - panel.clientHeight, 0); panel.scrollTop = Math.min(maxScrollTop, panel.scrollTop + Math.max(panel.clientHeight - 96, 420)); return { atBottom: panel.scrollTop >= maxScrollTop - 2, moved: panel.scrollTop !== before, }; }).catch(() => ({ atBottom: true, moved: false, })); } async function collectKimiSearchPanelLinks( page: PlaywrightPage, questionText: string, signal: AbortSignal, ): Promise { await ensureKimiSearchPanelOpen(page, signal); let collected: KimiDomLink[] = []; let stalledPolls = 0; for (let index = 0; index < 80; index += 1) { if (signal.aborted) { throw new Error("adapter_aborted"); } const snapshot = await readKimiPageSnapshot(page, questionText); const beforeCount = collected.length; collected = mergeKimiDomLinks(collected, snapshot.searchResultLinks); const state = await scrollKimiSearchPanel(page); if (collected.length === beforeCount) { stalledPolls += 1; } else { stalledPolls = 0; } if (state.atBottom && stalledPolls >= 2) { break; } if (!state.moved && stalledPolls >= 3) { break; } await sleep(180, signal).catch(() => undefined); } return collected; } function countMatches(input: string, pattern: RegExp): number { const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; return input.match(new RegExp(pattern.source, flags))?.length ?? 0; } function hasStructuredAnswerMarkers(text: string): boolean { return ( /(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text) || /(?:^|\n)\|.+\|(?:\n|$)/m.test(text) || /(?:^|\n)\s*[^:\n]{1,24}[::]\s+\S+/m.test(text) ); } function isKimiTerminalShortAnswer(text: string): boolean { const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized || normalized.length > 48) { return false; } return /(未找到|暂无|没有|无法|不能|抱歉|不确定|未检索到|未发现|无明显结果)/.test(normalized); } function isKimiKeywordBlob(text: string): boolean { const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized) { return false; } const punctuationCount = countMatches(normalized, /[。!?;:,、,.!?;:]/g); const lineCount = normalized.split(/\n+/).filter(Boolean).length; const tokenCount = normalized.split(/\s+/).filter(Boolean).length; return ( !isKimiTerminalShortAnswer(normalized) && !hasStructuredAnswerMarkers(normalized) && lineCount <= 1 && punctuationCount === 0 && tokenCount >= 3 && normalized.length <= 140 ); } function isKimiSubstantiveAnswer(answer: string, answerBlocks: string[]): boolean { const normalized = answer.trim(); if (!normalized) { return false; } const punctuationCount = countMatches(normalized, /[。!?;:,、,.!?;:]/g); const lineCount = normalized.split(/\n+/).filter(Boolean).length; if (hasStructuredAnswerMarkers(normalized)) { return true; } if (answerBlocks.length >= 2) { return true; } if (normalized.length >= 140) { return true; } if (normalized.length >= 80 && punctuationCount >= 2) { return true; } if (normalized.length >= 60 && lineCount >= 3) { return true; } return false; } function isKimiAnswerComplete(snapshot: KimiPageSnapshot): boolean { const answer = normalizeOptionalString(snapshot.answerText); if (!answer) { return false; } if (isKimiKeywordBlob(answer)) { return false; } if (isKimiTerminalShortAnswer(answer)) { return true; } if (isKimiSubstantiveAnswer(answer, snapshot.answerBlocks)) { return true; } const punctuationCount = countMatches(answer, /[。!?;:,、,.!?;:]/g); if (snapshot.citationLinks.length >= 1 && answer.length >= 50 && punctuationCount >= 1) { return true; } if (snapshot.searchResultLinks.length >= 3 && answer.length >= 70 && punctuationCount >= 1) { return true; } if (snapshot.reasoningBlocks.length >= 2 && answer.length >= 70 && punctuationCount >= 1) { return true; } return false; } function shouldReturnStableKimiAnswer( snapshot: KimiPageSnapshot, firstChangedContentAt: number | null, now: number, ): boolean { if (isKimiAnswerComplete(snapshot)) { return true; } const answer = normalizeOptionalString(snapshot.answerText); if (!answer || isKimiKeywordBlob(answer) || firstChangedContentAt === null) { return false; } const changedContentAge = now - firstChangedContentAt; if (changedContentAge < KIMI_INCOMPLETE_ANSWER_GRACE_MS) { return false; } const punctuationCount = countMatches(answer, /[。!?;:,、,.!?;:]/g); return ( answer.length >= 48 || punctuationCount >= 1 || snapshot.answerBlocks.length >= 2 || isKimiTerminalShortAnswer(answer) ); } function hasKimiObservedContent(snapshot: KimiPageSnapshot | null): boolean { if (!snapshot) { return false; } return Boolean( normalizeOptionalString(snapshot.answerText) || normalizeOptionalString(snapshot.reasoningText) || snapshot.answerBlocks.length > 0 || snapshot.reasoningBlocks.length > 0 || snapshot.citationLinks.length > 0 || snapshot.searchResultLinks.length > 0, ); } function answerQualityScore(snapshot: KimiPageSnapshot | null): number { if (!snapshot) { return Number.NEGATIVE_INFINITY; } const answer = normalizeOptionalString(snapshot.answerText); const reasoning = normalizeOptionalString(snapshot.reasoningText); let score = 0; if (answer) { score += Math.min(answer.length, 4_000); } if (reasoning) { score += Math.min(reasoning.length, 2_000) / 2; } score += snapshot.answerBlocks.length * 240; score += snapshot.reasoningBlocks.length * 80; score += snapshot.citationLinks.length * 180; score += Math.min(snapshot.searchResultLinks.length, 80) * 18; if (snapshot.currentModelLabel?.includes("思考")) { score += 120; } if (snapshot.questionMatched) { score += 600; } if (!hasKimiObservedContent(snapshot)) { score -= 2_000; } if (answer && isKimiKeywordBlob(answer)) { score -= 1_000; } if (isKimiAnswerComplete(snapshot)) { score += 1_500; } if (snapshot.busy) { score -= 120; } if (snapshot.loginRequired) { score -= 10_000; } return score; } function pickBetterSnapshot(current: KimiPageSnapshot | null, candidate: KimiPageSnapshot): KimiPageSnapshot { if (!current) { return candidate; } const currentHasContent = hasKimiObservedContent(current); const candidateHasContent = hasKimiObservedContent(candidate); if (candidateHasContent && !currentHasContent) { return candidate; } if (currentHasContent && !candidateHasContent) { return current; } return answerQualityScore(candidate) >= answerQualityScore(current) ? candidate : current; } function extractConversationID(url: string | null): string | null { const input = normalizeText(url); if (!input) { return null; } try { const parsed = new URL(input); const chatMatch = parsed.pathname.match(/\/chat\/([^/?#]+)/i); if (chatMatch?.[1]?.trim()) { return chatMatch[1].trim(); } return normalizeText(parsed.searchParams.get("conversation_id") ?? parsed.searchParams.get("session_id")); } catch { return null; } } async function fillKimiEditorText(page: PlaywrightPage, questionText: string): Promise { const editor = page.locator(KIMI_EDITOR_SELECTOR).first(); await editor.waitFor({ state: "visible", timeout: KIMI_PAGE_READY_TIMEOUT_MS, }); await editor.scrollIntoViewIfNeeded().catch(() => undefined); await editor.evaluate((node) => { if (node instanceof HTMLElement) { node.focus(); } }).catch(() => undefined); let filled = false; try { await editor.fill(questionText); filled = true; } catch { filled = false; } const normalizedExpected = questionText.trim().replace(/\s+/g, " "); const normalizedActual = (await editor.innerText().catch(() => "")).trim().replace(/\s+/g, " "); if (!filled || normalizedActual !== normalizedExpected) { await page.evaluate( ({ selector, text }) => { const editorElement = document.querySelector(selector); if (!(editorElement instanceof HTMLElement)) { throw new Error("kimi_editor_missing"); } const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(editorElement); selection?.removeAllRanges(); selection?.addRange(range); editorElement.focus(); try { document.execCommand("delete"); document.execCommand("insertText", false, text); } catch { // Ignore and fall back to direct DOM mutation below. } const current = editorElement.innerText.trim().replace(/\s+/g, " "); if (current !== text.trim().replace(/\s+/g, " ")) { editorElement.innerHTML = ""; editorElement.textContent = text; editorElement.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text, })); editorElement.dispatchEvent(new Event("change", { bubbles: true })); } }, { selector: KIMI_EDITOR_SELECTOR, text: questionText, }, ); } } async function submitKimiQuestion(page: PlaywrightPage, questionText: string, signal: AbortSignal): Promise { if (signal.aborted) { throw new Error("adapter_aborted"); } await fillKimiEditorText(page, questionText); await sleep(200, signal); const enabledSendButton = page.locator(`${KIMI_SEND_BUTTON_SELECTOR}:not(.disabled)`).first(); if (await enabledSendButton.isVisible().catch(() => false)) { const clicked = await enabledSendButton.evaluate((node) => { if (!(node instanceof HTMLElement)) { return false; } node.focus?.(); node.click(); return true; }).catch(() => false); if (clicked) { return; } const forceClicked = await enabledSendButton.click({ force: true }).then(() => true).catch(() => false); if (forceClicked) { return; } } const editor = page.locator(KIMI_EDITOR_SELECTOR).first(); await editor.press("Enter").catch(() => undefined); } async function ensureKimiThinkingMode(page: PlaywrightPage): Promise { let result = await page.evaluate( async ({ preferredModelLabel, timeoutMs }) => { const wait = (timeMs: number) => new Promise((resolve) => { window.setTimeout(resolve, timeMs); }); const normalize = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join(" "); 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 clickElement = (element: HTMLElement | null): boolean => { if (!element) { return false; } element.focus?.(); element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })); element.click(); return true; }; const loginSignals = [ "微信扫码登录", "手机号快捷登录", "发送验证码", "登录后", "有问题,免费聊", ]; const buildDetail = () => { const bodyText = normalize(document.body?.innerText)?.slice(0, 200) ?? null; return [normalize(document.title), normalize(window.location.href), bodyText].filter(Boolean).join(" | ") || null; }; const readModelLabel = () => normalize( document.querySelector(".current-model .name") ?.textContent ?? document.querySelector(".current-model .model-name")?.textContent ?? document.querySelector(".current-model")?.textContent, ); const loginReason = () => { const bodyText = normalize(document.body?.innerText) ?? ""; for (const signal of loginSignals) { if (bodyText.includes(signal)) { return signal; } } return null; }; const findTrigger = () => { const selectors = [".current-model", ".model-name", ".current-model .name"]; for (const selector of selectors) { const element = document.querySelector(selector); if (isVisible(element)) { return element; } } return null; }; const findThinkingOption = () => { const candidates = Array.from(document.querySelectorAll("button, [role=\"button\"], [role=\"menuitem\"], li, div, span")); let best: { element: HTMLElement; score: number } | null = null; for (const candidate of candidates) { if (!isVisible(candidate)) { continue; } const text = normalize(candidate.textContent)?.replace(/\s+/g, " ") ?? ""; if (!text) { continue; } let score = Number.NEGATIVE_INFINITY; if (text === preferredModelLabel) { score = 10_000; } else if (text.includes(preferredModelLabel)) { score = 8_000; } else if (text.includes("思考")) { score = 3_000; } if (!Number.isFinite(score)) { continue; } const className = normalize(candidate.getAttribute("class"))?.toLowerCase() ?? ""; if (className.includes("menu")) { score += 100; } if (!best || score > best.score) { best = { element: candidate, score, }; } } return best?.element ?? null; }; const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const currentModelLabel = readModelLabel(); if (currentModelLabel?.includes("思考")) { return { ok: true as const, currentModelLabel, }; } const loginSignal = loginReason(); if (loginSignal) { return { ok: false as const, error: "kimi_login_required", detail: loginSignal, currentModelLabel, url: window.location.href || null, }; } const trigger = findTrigger(); if (trigger) { clickElement(trigger); } await wait(200); const nextLabel = readModelLabel(); if (nextLabel?.includes("思考")) { return { ok: true as const, currentModelLabel: nextLabel, }; } const option = findThinkingOption(); if (option && clickElement(option)) { await wait(250); } const resolvedLabel = readModelLabel(); if (resolvedLabel?.includes("思考")) { return { ok: true as const, currentModelLabel: resolvedLabel, }; } } return { ok: false as const, error: readModelLabel() ? "kimi_thinking_mode_not_selected" : "kimi_model_switch_trigger_missing", detail: buildDetail(), currentModelLabel: readModelLabel(), url: window.location.href || null, }; }, { preferredModelLabel: KIMI_THINKING_MODE_LABEL, timeoutMs: KIMI_MODE_SWITCH_TIMEOUT_MS, }, ); if (result.ok) { return result; } const trigger = page.locator(".current-model").first(); if (!(await trigger.isVisible().catch(() => false))) { return result; } await trigger.click().catch(() => undefined); await sleep(250).catch(() => undefined); for (const text of [KIMI_THINKING_MODE_LABEL, "思考"]) { const option = page.getByText(text, { exact: text === KIMI_THINKING_MODE_LABEL, }).first(); if (await option.isVisible().catch(() => false)) { await option.click().catch(() => undefined); await sleep(300).catch(() => undefined); break; } } result = await page.evaluate( () => { const normalize = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join(" "); return trimmed ? trimmed : null; }; const currentModelLabel = normalize( document.querySelector(".current-model .name") ?.textContent ?? document.querySelector(".current-model .model-name")?.textContent ?? document.querySelector(".current-model")?.textContent, ); if (currentModelLabel?.includes("思考")) { return { ok: true as const, currentModelLabel, }; } return { ok: false as const, error: "kimi_thinking_mode_not_selected", detail: normalize(document.body?.innerText)?.slice(0, 200) ?? null, currentModelLabel, url: window.location.href || null, }; }, ); return result; } async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): Promise { return page.evaluate( ({ question, redirectQueryKeys }) => { type CandidateKind = "answer" | "reasoning"; type Candidate = { element: HTMLElement; text: string; serializedText: string; score: number; order: number; kind: CandidateKind; }; const normalizeInline = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join(" "); return trimmed ? trimmed : null; }; const tidyInlineText = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .replace(/[ \t\f\v]+/g, " ") .replace(/ *\n */g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); return trimmed ? trimmed : null; }; const normalizeBlock = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value .replace(/\u00a0/g, " ") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join("\n"); return trimmed ? trimmed : null; }; const sanitizeSiteName = (value: unknown): string | null => { const normalized = normalizeInline(value); if (!normalized) { return null; } const cleaned = normalized.replace(/^来源[::]\s*/i, "").trim(); return cleaned ? cleaned : null; }; const escapeMarkdownText = (value: string): string => value .replace(/\\/g, "\\\\") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]"); const escapeTableCell = (value: string): string => value .replace(/\|/g, "\\|") .replace(/\r?\n+/g, "
") .trim(); 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 isAuxiliaryPanelElement = (element: Element | null | undefined): boolean => { let current: Element | null = element ?? null; for (let depth = 0; current && depth < 10; depth += 1) { const descriptor = [ current.className || "", current.getAttribute("role") || "", current.getAttribute("aria-label") || "", current.getAttribute("data-testid") || "", ].join(" "); const text = current instanceof HTMLElement ? normalizeInline(current.innerText)?.slice(0, 160) ?? "" : ""; if ( /(side-console|console|drawer|modal|popover|popup|tooltip|floating|hover-card|dropdown|search-result|search-panel|reference-panel|ref-panel|citation-panel)/i.test(descriptor) || /^(引用来源|搜索网页)\s*\d*/.test(text) ) { return true; } current = current.parentElement; } return false; }; const loginSignals = [ "微信扫码登录", "手机号快捷登录", "发送验证码", "登录后", "有问题,免费聊", ]; const busySignalsPattern = /思考中|搜索中|生成中|回答中|整理中|正在搜索|正在思考|正在生成|停止生成|停止回答|停止思考/i; const tableWrapperPattern = /markdown-table|table-container/i; const structuredClassPattern = /paragraph|markdown|rich-text|content|answer|response|message|reason|thought|analysis/i; const blockTagNames = new Set([ "ARTICLE", "ASIDE", "BLOCKQUOTE", "DIV", "FIGCAPTION", "FIGURE", "FOOTER", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TH", "THEAD", "TR", "UL", ]); const ignoredTagNames = new Set([ "CANVAS", "IFRAME", "NOSCRIPT", "SCRIPT", "STYLE", ]); const exactNoiseLines = new Set([ "复制", "重试", "重新生成", "继续", "发送", "上传", "搜索", "分享", ]); const partialNoiseSnippets = [ "微信扫码登录", "手机号快捷登录", "发送验证码", "登录后", "有问题,免费聊", ]; const elementOrders = new Map(); let nextOrder = 1; const normalizedQuestion = normalizeBlock(question); const getElementOrder = (element: Element): number => { const existing = elementOrders.get(element); if (existing !== undefined) { return existing; } const order = nextOrder; nextOrder += 1; elementOrders.set(element, order); return order; }; const decodeUrlCandidate = (value: string): string => { let decoded = value.trim(); for (let index = 0; index < 3; index += 1) { try { const next = decodeURIComponent(decoded); if (next === decoded) { break; } decoded = next; } catch { break; } } return decoded; }; const coercePotentialUrl = (value: string): string => { const trimmed = decodeUrlCandidate(value); if (/^\/\//.test(trimmed)) { return `https:${trimmed}`; } if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(trimmed)) { return `https://${trimmed}`; } return trimmed; }; const normalizeAbsoluteHref = (value: string): string | null => { try { const url = new URL(coercePotentialUrl(value), window.location.href); url.hash = ""; return url.toString(); } catch { return null; } }; const extractRedirectTargetHref = (value: string): string | null => { const direct = normalizeAbsoluteHref(value); if (!direct) { return null; } try { const url = new URL(direct); for (const key of redirectQueryKeys) { const raw = url.searchParams.get(key); if (!raw) { continue; } const normalized = normalizeAbsoluteHref(raw); if (normalized) { return normalized; } } } catch { return null; } const encodedMatch = direct.match(/https?%3A%2F%2F[^&\s"'<>]+/i); if (encodedMatch) { return normalizeAbsoluteHref(encodedMatch[0]); } return null; }; const normalizeHref = (value: string | null): string | null => { const href = normalizeInline(value); if (!href) { return null; } if (/^(?:javascript|data|blob|mailto|tel):/i.test(href)) { return null; } return extractRedirectTargetHref(href) ?? normalizeAbsoluteHref(href); }; const buildSyntheticSourceUrl = (siteName: string | null): string | null => { const cleaned = sanitizeSiteName(siteName); if (!cleaned) { return null; } if (/^https?:\/\//i.test(cleaned)) { return normalizeHref(cleaned); } const domainMatch = cleaned.match(/([a-z0-9-]+(?:\.[a-z0-9-]+)+)(?:\/.*)?$/i); if (domainMatch?.[1]) { return normalizeHref(`https://${domainMatch[1]}/`); } const siteAliases: Array<[RegExp, string]> = [ [/搜狐焦点/i, "https://house.focus.cn/"], [/(今日头条|toutiao)/i, "https://www.toutiao.com/"], [/(搜狐|sohu)/i, "https://www.sohu.com/"], [/(新浪|sina)/i, "https://www.sina.com.cn/"], [/(网易|163)/i, "https://www.163.com/"], [/(腾讯|qq)/i, "https://www.qq.com/"], [/(凤凰|ifeng)/i, "https://www.ifeng.com/"], [/(澎湃|thepaper)/i, "https://www.thepaper.cn/"], [/(人民网|people)/i, "https://www.people.com.cn/"], [/(新华|news\\.cn|xinhuanet)/i, "https://www.news.cn/"], [/(中国新闻网|chinanews)/i, "https://www.chinanews.com.cn/"], [/(百度|baidu)/i, "https://www.baidu.com/"], [/(知乎|zhihu)/i, "https://www.zhihu.com/"], [/(小红书|xiaohongshu|xhslink)/i, "https://www.xiaohongshu.com/"], [/(抖音|douyin)/i, "https://www.douyin.com/"], [/(快手|kuaishou)/i, "https://www.kuaishou.com/"], ]; for (const [pattern, target] of siteAliases) { if (pattern.test(cleaned)) { return target; } } return null; }; const isExternalSourceUrl = (href: string | null): boolean => { if (!href) { return false; } try { const hostname = new URL(href).hostname.toLowerCase(); return Boolean( hostname && !hostname.endsWith("kimi.com") && !hostname.endsWith("moonshot.cn"), ); } catch { return false; } }; const isCitationElement = (element: Element | null | undefined): element is HTMLElement => { if (!(element instanceof HTMLElement)) { return false; } const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; return className.includes("rag-tag") || Boolean(sanitizeSiteName(element.getAttribute("data-site-name"))); }; const extractDomLink = (element: Element): KimiDomLink | null => { if (!(element instanceof HTMLElement)) { return null; } const siteName = sanitizeSiteName( element.getAttribute("data-site-name") ?? element.dataset.siteName ?? element.getAttribute("title") ?? element.getAttribute("aria-label"), ); const href = element instanceof HTMLAnchorElement ? normalizeHref(element.getAttribute("href") ?? element.href) : normalizeHref(element.getAttribute("href")); const url = isExternalSourceUrl(href) ? href : buildSyntheticSourceUrl(siteName); if (!url) { return null; } let text = normalizeInline(element.textContent) ?? siteName; if (!text) { try { text = new URL(url).hostname; } catch { text = siteName; } } return { url, title: sanitizeSiteName(element.getAttribute("title")) ?? siteName, text, siteName, }; }; const extractExternalAnchorLink = (element: Element): KimiDomLink | null => { if (!(element instanceof HTMLAnchorElement)) { return null; } const href = normalizeHref(element.getAttribute("href") ?? element.href); if (!isExternalSourceUrl(href)) { return null; } const siteName = sanitizeSiteName( element.getAttribute("data-site-name") ?? element.dataset.siteName ?? element.getAttribute("aria-label"), ); const text = normalizeInline(element.textContent); return { url: href as string, title: sanitizeSiteName(element.getAttribute("title")) ?? text ?? siteName, text, siteName, }; }; const firstUrlFromText = (value: string | null): string | null => { if (!value) { return null; } const httpMatch = value.match(/https?:\/\/[^\s"'<>,。;、]+/i); if (httpMatch) { return normalizeHref(httpMatch[0]); } const domainMatch = value.match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s"'<>,。;、]*)?/i); return domainMatch ? normalizeHref(domainMatch[0]) : null; }; const readElementUrl = (element: Element): string | null => { const candidateAttributes = [ "href", "data-href", "data-url", "data-link", "data-target-url", "data-source-url", "data-origin-url", "data-real-url", "data-page-url", "data-web-url", "data-log", "data-click", "data-extra", "data-info", "data-value", ]; const candidates: string[] = []; for (const name of candidateAttributes) { const value = element.getAttribute(name); if (value) { candidates.push(value); } } if (element instanceof HTMLElement) { candidates.push(...Object.values(element.dataset).filter((value): value is string => Boolean(value))); } for (const candidate of candidates) { const href = normalizeHref(candidate) ?? firstUrlFromText(candidate); if (isExternalSourceUrl(href)) { return href; } } const text = normalizeBlock(element.textContent ?? ""); if (text && text.length <= 2_000) { const href = firstUrlFromText(text); if (isExternalSourceUrl(href)) { return href; } } return null; }; const extractSearchResultLink = (element: Element): KimiDomLink | null => { const anchorLink = extractExternalAnchorLink(element); if (anchorLink) { return anchorLink; } if (!(element instanceof HTMLElement) || !isVisible(element)) { return null; } const href = readElementUrl(element); if (!isExternalSourceUrl(href)) { return null; } const card = element.closest([ "a", "li", "article", "section", "[role=\"listitem\"]", "[class*=\"item\"]", "[class*=\"Item\"]", "[class*=\"card\"]", "[class*=\"Card\"]", "[class*=\"result\"]", "[class*=\"Result\"]", "[class*=\"source\"]", "[class*=\"Source\"]", "[class*=\"web\"]", "[class*=\"Web\"]", ].join(",")) ?? element; const title = normalizeInline( card.querySelector("[class*=\"title\"], [class*=\"Title\"], h1, h2, h3, h4")?.textContent ?? element.getAttribute("title") ?? element.getAttribute("aria-label") ?? card.textContent, ); const siteName = sanitizeSiteName( card.querySelector("[class*=\"site\"], [class*=\"Site\"], [class*=\"domain\"], [class*=\"Domain\"], [class*=\"source\"], [class*=\"Source\"]")?.textContent ?? element.getAttribute("data-site-name") ?? element.dataset.siteName ?? element.getAttribute("aria-label"), ); return { url: href as string, title: title ?? siteName, text: title ?? siteName, siteName, }; }; const extractCitationPanelLink = (element: Element): KimiDomLink | null => { if (!(element instanceof HTMLAnchorElement)) { return null; } const href = normalizeHref(element.getAttribute("href") ?? element.href); if (!isExternalSourceUrl(href)) { return null; } const title = normalizeInline( element.querySelector(".site-title")?.textContent ?? element.getAttribute("title") ?? element.textContent, ); const siteName = sanitizeSiteName( element.querySelector(".site-name-text")?.textContent ?? element.getAttribute("data-site-name") ?? element.dataset.siteName ?? element.getAttribute("aria-label"), ); return { url: href as string, title: title ?? siteName, text: title ?? siteName, siteName, }; }; const shouldIgnoreLink = (link: KimiDomLink | null): boolean => { const linkText = normalizeInline(link?.text); return Boolean(linkText && ["用户协议", "隐私政策", "联系我们", "下载 App"].includes(linkText)); }; const collectUniqueLinks = ( roots: HTMLElement[], selectors: string, extractor: (element: Element) => KimiDomLink | null, ): KimiDomLink[] => { const results: KimiDomLink[] = []; const seen = new Set(); for (const root of roots) { const sourceNodes = Array.from(root.querySelectorAll(selectors)); for (const sourceNode of sourceNodes) { const link = extractor(sourceNode); if (!link || shouldIgnoreLink(link) || seen.has(link.url)) { continue; } seen.add(link.url); results.push(link); } } return results; }; const collectUniqueRootLinks = ( roots: HTMLElement[], selectors: string, extractor: (element: Element) => KimiDomLink | null, ): KimiDomLink[] => { const results: KimiDomLink[] = []; const seen = new Set(); for (const root of roots) { const sourceNodes = [ root, ...Array.from(root.querySelectorAll(selectors)), ]; for (const sourceNode of sourceNodes) { const link = extractor(sourceNode); if (!link || shouldIgnoreLink(link) || seen.has(link.url)) { continue; } seen.add(link.url); results.push(link); } } return results; }; const formatMarkdownLink = (label: string | null, url: string | null): string => { const normalizedLabel = normalizeInline(label); if (!url) { return normalizedLabel ? escapeMarkdownText(normalizedLabel) : ""; } const safeLabel = escapeMarkdownText(normalizedLabel || url); return `[${safeLabel}](${url})`; }; const resolveStandaloneTable = (element: HTMLElement): HTMLTableElement | null => { if (element instanceof HTMLTableElement) { return element; } const className = normalizeInline(element.getAttribute("class")) ?? ""; if (!tableWrapperPattern.test(className)) { return null; } const nestedTable = element.querySelector("table"); return nestedTable instanceof HTMLTableElement ? nestedTable : null; }; const hasStructuredChildren = (element: HTMLElement): boolean => Array.from(element.children).some((child) => { if (!(child instanceof HTMLElement)) { return false; } const childClassName = normalizeInline(child.getAttribute("class")) ?? ""; return Boolean( resolveStandaloneTable(child) || blockTagNames.has(child.tagName) || structuredClassPattern.test(childClassName), ); }); const serializeInlineNode = (node: any): string => { if (node.nodeType === Node.TEXT_NODE) { return node.textContent?.replace(/\u00a0/g, " ").replace(/\s+/g, " ") ?? ""; } if (!(node instanceof HTMLElement)) { return ""; } const element = node as any; if (isCitationElement(element as Element)) { const citation = extractDomLink(element); if (!citation) { return sanitizeSiteName(element.getAttribute("data-site-name")) ?? ""; } const label = citation.siteName ?? citation.text ?? citation.title ?? "来源"; return ` ${formatMarkdownLink(label, citation.url)}`; } if (!isVisible(element as Element) || ignoredTagNames.has(element.tagName)) { return ""; } if (element.tagName === "BR") { return "\n"; } const standaloneTable = resolveStandaloneTable(element as HTMLElement); if (standaloneTable) { return `\n\n${serializeTable(standaloneTable)}\n\n`; } if (element.tagName === "A") { const anchor = element as HTMLAnchorElement; const href = normalizeHref(anchor.getAttribute("href") ?? anchor.href); const inlineText = tidyInlineText(Array.from(element.childNodes).map((child) => serializeInlineNode(child)).join("")) ?? normalizeInline(element.textContent); if (!href || !isExternalSourceUrl(href)) { return inlineText ?? ""; } return formatMarkdownLink(inlineText ?? href, href); } const childrenText = tidyInlineText( Array.from(element.childNodes).map((child) => serializeInlineNode(child)).join(""), ); switch (element.tagName) { case "STRONG": case "B": return childrenText ? `**${childrenText}**` : ""; case "EM": case "I": return childrenText ? `*${childrenText}*` : ""; case "CODE": return childrenText ? `\`${childrenText}\`` : ""; default: return childrenText ?? ""; } }; function serializeInlineChildren(element: ParentNode): string { return tidyInlineText( Array.from(element.childNodes).map((child) => serializeInlineNode(child)).join(""), ) ?? ""; } const serializeTable = (table: HTMLTableElement): string => { const rows = Array.from(table.rows) .map((row) => Array.from(row.cells) .map((cell) => { const serialized = tidyInlineText(serializeInlineChildren(cell) || normalizeBlock(cell.innerText) || ""); return serialized ? escapeTableCell(serialized) : ""; }), ) .filter((row) => row.some((cell) => Boolean(cell))); if (!rows.length) { return ""; } const firstDomRow = table.rows.item(0); const firstRowLooksLikeHeader = Boolean( table.tHead?.rows.length || ( firstDomRow && Array.from(firstDomRow.cells).every((cell) => cell.tagName === "TH") ), ); const rowWidth = rows.reduce((max, row) => Math.max(max, row.length), 0); if (!rowWidth) { return ""; } const padRow = (row: string[]) => Array.from({ length: rowWidth }, (_, index) => row[index] ?? ""); let header = firstRowLooksLikeHeader ? padRow(rows[0]) : Array.from({ length: rowWidth }, (_, index) => `列${index + 1}`); const body = (firstRowLooksLikeHeader ? rows.slice(1) : rows).map(padRow); if (!header.some((cell) => Boolean(cell))) { header = Array.from({ length: rowWidth }, (_, index) => `列${index + 1}`); } const divider = Array.from({ length: rowWidth }, () => "---"); return [ `| ${header.join(" | ")} |`, `| ${divider.join(" | ")} |`, ...body.map((row) => `| ${row.join(" | ")} |`), ].join("\n"); }; const serializeList = (element: HTMLElement, ordered: boolean): string => { const items = Array.from(element.children) .filter((child): child is HTMLElement => child instanceof HTMLElement && child.tagName === "LI") .map((item, index) => { const label = ordered ? `${index + 1}.` : "-"; const text = serializeInlineChildren(item); return text ? `${label} ${text}` : ""; }) .filter(Boolean); return items.join("\n"); }; const serializeBlockNode = (node: any): string => { if (node.nodeType === Node.TEXT_NODE) { return normalizeInline(node.textContent) ?? ""; } if (!(node instanceof HTMLElement)) { return ""; } const element = node as any; if (isCitationElement(element as Element)) { return serializeInlineNode(element); } if (!isVisible(element as Element) || ignoredTagNames.has(element.tagName)) { return ""; } if (element.tagName === "BR") { return "\n"; } const standaloneTable = resolveStandaloneTable(element as HTMLElement); if (standaloneTable) { return serializeTable(standaloneTable); } if (element.tagName === "UL") { return serializeList(element as HTMLElement, false); } if (element.tagName === "OL") { return serializeList(element as HTMLElement, true); } if (element.tagName === "LI") { const text = serializeInlineChildren(element as ParentNode); return text ? `- ${text}` : ""; } if (element.tagName === "BLOCKQUOTE") { const text = serializeInlineChildren(element as ParentNode); return text ? text .split(/\n+/) .map((line) => `> ${line}`) .join("\n") : ""; } if (/^H[1-6]$/.test(element.tagName)) { const depth = Number(element.tagName.slice(1)); const text = serializeInlineChildren(element as ParentNode); return text ? `${"#".repeat(Math.max(1, Math.min(depth, 6)))} ${text}` : ""; } const className = normalizeInline(element.getAttribute("class")) ?? ""; if (element.tagName === "P" || /paragraph/i.test(className)) { return serializeInlineChildren(element as ParentNode); } if (hasStructuredChildren(element as HTMLElement)) { const parts = Array.from(element.childNodes) .map((child) => normalizeBlock(serializeBlockNode(child))) .filter((value): value is string => Boolean(value)); return parts.join("\n\n"); } return serializeInlineChildren(element as ParentNode); }; const serializeRichElement = (element: HTMLElement): string | null => normalizeBlock(serializeBlockNode(element)); const cleanCandidateText = (value: string | null) => { if (!value) { return null; } const lines = value .split(/\n+/) .map((line) => line.trim()) .filter(Boolean); const filtered = lines.filter((line) => { if (exactNoiseLines.has(line)) { return false; } if (normalizedQuestion && line === normalizedQuestion) { return false; } return !partialNoiseSnippets.some((snippet) => line.includes(snippet)); }); return filtered.length > 0 ? filtered.join("\n") : null; }; const findQuestionAnchor = (): HTMLElement | null => { if (!normalizedQuestion) { return null; } let best: { element: HTMLElement; order: number } | null = null; const nodes = Array.from(document.querySelectorAll("article, section, div, p, span")); for (const node of nodes) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue; } if (isAuxiliaryPanelElement(node)) { continue; } const text = normalizeBlock(node.innerText); if (!text) { continue; } if ( text !== normalizedQuestion && !(text.includes(normalizedQuestion) && text.length <= normalizedQuestion.length + 80) ) { continue; } const container = (node.closest("article, section, [class*=\"message\"], [class*=\"question\"], [class*=\"user\"], li, div") as HTMLElement | null) ?? node; const order = getElementOrder(container); if (!best || order >= best.order) { best = { element: container, order, }; } } return best?.element ?? null; }; const questionAnchor = findQuestionAnchor(); const isAfterQuestionAnchor = (element: HTMLElement): boolean => { if (!questionAnchor) { return true; } if (element === questionAnchor || questionAnchor.contains(element) || element.contains(questionAnchor)) { return false; } const relation = questionAnchor.compareDocumentPosition(element); if (relation & Node.DOCUMENT_POSITION_FOLLOWING) { return true; } const elementRect = element.getBoundingClientRect(); const anchorRect = questionAnchor.getBoundingClientRect(); return elementRect.top >= anchorRect.top - 12; }; const resolveCandidateKind = (element: HTMLElement, text: string): CandidateKind => { const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; const merged = `${className}\n${text}`; if ( /think|reason|analysis|thought/.test(className) || /^思考已完成/.test(text) || /(搜索网页|搜索结果|检索关键词|思考过程)/.test(merged) ) { return "reasoning"; } return "answer"; }; const candidateScore = (element: HTMLElement, text: string, kind: CandidateKind) => { const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; const rect = element.getBoundingClientRect(); let score = Math.min(text.length, 3_500); if (className.includes("markdown")) { score += 1_100; } if (className.includes("assistant")) { score += 700; } if (className.includes("answer") || className.includes("response")) { score += 500; } if (className.includes("message")) { score += 160; } if (className.includes("content")) { score += 140; } if (kind === "reasoning") { score += 220; } score += Math.min(rect.height, 1_400) / 8; const linkCount = element.querySelectorAll("a[href], .rag-tag[data-site-name], [data-site-name]").length; score += Math.min(linkCount, 12) * 30; score += Math.min(element.querySelectorAll("table").length, 4) * 260; score += Math.min(text.split("\n").length, 24) * 20; if (normalizedQuestion && text === normalizedQuestion) { score -= 8_000; } if (loginSignals.some((signal) => text.includes(signal))) { score -= 5_000; } if (questionAnchor) { const anchorRect = questionAnchor.getBoundingClientRect(); score += Math.max(0, 800 - Math.abs(rect.top - anchorRect.top)); } return score; }; const collectCandidates = (selectors: string[]) => { const seen = new Set(); const candidates: Candidate[] = []; for (const selector of selectors) { const elements = Array.from(document.querySelectorAll(selector)); for (const element of elements) { if (!(element instanceof HTMLElement) || seen.has(element) || !isVisible(element)) { continue; } if (isAuxiliaryPanelElement(element)) { continue; } seen.add(element); const order = getElementOrder(element); if (!isAfterQuestionAnchor(element)) { continue; } const rawText = normalizeBlock(element.innerText); const text = cleanCandidateText(rawText); const hasStructuredContent = Boolean(element.querySelector("table, .table.markdown-table, .table-container")); if (!text || (text.length < 24 && !hasStructuredContent)) { continue; } const serializedText = cleanCandidateText(serializeRichElement(element)) ?? text; if (element.childElementCount > 36 && text.length < 220) { continue; } const kind = resolveCandidateKind(element, text); candidates.push({ element, text, serializedText, score: candidateScore(element, text, kind), order, kind, }); } } candidates.sort((left, right) => right.score - left.score || left.order - right.order); return candidates; }; const selectDistinctCandidates = (candidates: Candidate[], kind: CandidateKind, maxBlocks: number) => { const selected: Candidate[] = []; for (const candidate of candidates) { if (candidate.kind !== kind) { continue; } let handled = false; for (let index = 0; index < selected.length; index += 1) { const existing = selected[index]; if (existing.text === candidate.text || existing.text.includes(candidate.text)) { handled = true; break; } if (candidate.text.includes(existing.text)) { if (candidate.score >= existing.score) { selected[index] = candidate; } handled = true; break; } } if (!handled) { selected.push(candidate); } if (selected.length >= maxBlocks && selected.some((item) => item.text.length >= 1_200)) { break; } } selected.sort((left, right) => left.order - right.order); return selected; }; const mergeTextBlocks = (blocks: Candidate[]) => { const merged: Candidate[] = []; for (const block of blocks) { let duplicate = false; for (let index = 0; index < merged.length; index += 1) { const existing = merged[index]; if (existing.text === block.text || existing.text.includes(block.text)) { duplicate = true; break; } if (block.text.includes(existing.text)) { merged[index] = block; duplicate = true; break; } } if (!duplicate) { merged.push(block); } } return merged.map((item) => item.serializedText || item.text); }; const candidates = collectCandidates([ ".chat-content-item.chat-content-item-assistant", ".chat-content-item-assistant", ".paragraph", ".table.markdown-table", ".table-container", ".markdown", "[class*=\"markdown\"]", "[class*=\"rich-text\"]", "[class*=\"assistant\"]", "[data-role*=\"assistant\"]", "[class*=\"answer\"]", "[class*=\"response\"]", "[class*=\"reason\"]", "[class*=\"thought\"]", "[class*=\"analysis\"]", "[class*=\"content\"]", "article", "section", "[class*=\"message\"]", "main article", "main section", ]); const answerCandidates = selectDistinctCandidates(candidates, "answer", 6); const reasoningCandidates = selectDistinctCandidates(candidates, "reasoning", 5); const answerBlocks = mergeTextBlocks(answerCandidates); const reasoningBlocks = mergeTextBlocks( reasoningCandidates.filter((candidate) => !answerCandidates.some((answerCandidate) => answerCandidate.text.includes(candidate.text) || candidate.text.includes(answerCandidate.text)) ), ); const answerText = answerBlocks.join("\n\n") || null; const reasoningText = reasoningBlocks.join("\n\n") || null; const citationRoots = [ ...answerCandidates.map((candidate) => candidate.element), ...reasoningCandidates.map((candidate) => candidate.element), ].filter((value): value is HTMLElement => value instanceof HTMLElement); const inlineCitationCandidateLinks = collectUniqueLinks( citationRoots, ".rag-tag[data-site-name], [data-site-name].rag-tag", extractDomLink, ); const citationPanelRoots = Array.from( document.querySelectorAll(".side-console-container .ref, .side-console .ref"), ).filter((value): value is HTMLElement => value instanceof HTMLElement && isVisible(value) && (normalizeBlock(value.innerText)?.includes("引用来源") ?? false)); const panelCitationLinks = collectUniqueLinks( citationPanelRoots, "a.site-item[href]", extractCitationPanelLink, ); const citationLinks = panelCitationLinks; const searchPanelCandidates = Array.from(document.querySelectorAll("div, section, aside")) .filter((value): value is HTMLElement => value instanceof HTMLElement && isVisible(value)) .map((element) => { const text = normalizeBlock(element.innerText) ?? ""; const searchLinkCount = collectUniqueRootLinks( [element], [ "a[href]", "[data-href]", "[data-url]", "[data-link]", "[data-target-url]", "[data-source-url]", "[data-origin-url]", "[data-real-url]", "[data-page-url]", "[data-web-url]", "[data-log]", "[data-click]", "[data-extra]", "[data-info]", "li", "article", "[role=\"listitem\"]", "[class*=\"item\"]", "[class*=\"Item\"]", "[class*=\"card\"]", "[class*=\"Card\"]", "[class*=\"result\"]", "[class*=\"Result\"]", ].join(","), extractSearchResultLink, ).length; const rect = element.getBoundingClientRect(); const area = Math.round(Math.max(rect.width, 0) * Math.max(rect.height, 0)); const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; return { element, text, searchLinkCount, area, className, }; }) .filter((candidate) => candidate.searchLinkCount >= 1 && ( /^搜索网页(?:\s+\d+)?/m.test(candidate.text) || ( candidate.text.includes("搜索网页") && ( candidate.text.includes("结果") || candidate.className.includes("ole") || candidate.className.includes("search") || candidate.className.includes("web") ) ) )) .sort((left, right) => left.area - right.area || right.searchLinkCount - left.searchLinkCount); const searchPanelRoots: HTMLElement[] = []; for (const candidate of searchPanelCandidates) { if (searchPanelRoots.some((root) => root === candidate.element || root.contains(candidate.element) || candidate.element.contains(root))) { continue; } searchPanelRoots.push(candidate.element); } const searchResultLinks = collectUniqueLinks( searchPanelRoots, [ "a[href]", "[data-href]", "[data-url]", "[data-link]", "[data-target-url]", "[data-source-url]", "[data-origin-url]", "[data-real-url]", "[data-page-url]", "[data-web-url]", "[data-log]", "[data-click]", "[data-extra]", "[data-info]", "li", "article", "[role=\"listitem\"]", "[class*=\"item\"]", "[class*=\"Item\"]", "[class*=\"card\"]", "[class*=\"Card\"]", "[class*=\"result\"]", "[class*=\"Result\"]", ].join(","), extractSearchResultLink, ); const bodyText = normalizeBlock(document.body?.innerText) ?? ""; const loginReason = loginSignals.find((signal) => bodyText.includes(signal)) ?? null; const busySignals: string[] = []; if (!bodyText.includes("思考已完成") && busySignalsPattern.test(bodyText)) { busySignals.push("body_status_text"); } const visibleBusyTextExists = Array.from( document.querySelectorAll("button, [role=\"button\"], [class*=\"status\"], [class*=\"loading\"], [class*=\"state\"]"), ).some((node) => { if (!(node instanceof HTMLElement) || !isVisible(node)) { return false; } const text = normalizeInline(node.innerText); return Boolean(text && text !== "思考已完成" && busySignalsPattern.test(text)); }); if (visibleBusyTextExists) { busySignals.push("visible_busy_text"); } const loadingIndicators = Array.from( document.querySelectorAll("[class*=\"loading\"], [class*=\"typing\"], [class*=\"spinner\"], [class*=\"stream\"]"), ).filter((node) => isVisible(node)); if (loadingIndicators.length > 0) { busySignals.push("loading_indicator"); } const sendButton = document.querySelector(".send-button-container"); const sendDisabled = sendButton instanceof HTMLElement ? sendButton.classList.contains("disabled") || sendButton.getAttribute("aria-disabled") === "true" : null; const currentModelLabel = normalizeInline( document.querySelector(".current-model .name") ?.textContent ?? document.querySelector(".current-model .model-name")?.textContent ?? document.querySelector(".current-model")?.textContent, ); return { url: window.location.href, title: normalizeInline(document.title), currentModelLabel, loginRequired: Boolean(loginReason), loginReason, busy: busySignals.length > 0, busySignals, sendDisabled, answerText, answerBlocks, reasoningText, reasoningBlocks, questionMatched: Boolean(questionAnchor), candidateAnswerCount: answerCandidates.length, candidateReasoningCount: reasoningCandidates.length, citationLinks, panelCitationLinks, inlineCitationCandidateLinks, searchResultLinks, signature: [ currentModelLabel, answerText, reasoningText, answerBlocks.join("\n"), reasoningBlocks.join("\n"), ...citationLinks.map((item) => `${item.siteName ?? item.text ?? item.url}:${item.url}`), ...searchResultLinks.map((item) => item.url), ].filter(Boolean).join("\n").slice(-6_000) || null, }; }, { question: questionText, redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS, }, ); } async function waitForKimiAnswer( page: PlaywrightPage, questionText: string, signal: AbortSignal, ): Promise { const startedAt = Date.now(); await pinKimiConversationToBottom(page); let stablePollCount = 0; let bestSnapshot = await readKimiPageSnapshot(page, questionText); let bestContentSnapshot = hasKimiObservedContent(bestSnapshot) ? bestSnapshot : null; const initialSignature = bestSnapshot.signature; let lastSignature: string | null = initialSignature; let sawChange = false; let firstChangedContentAt: number | null = null; while (Date.now() - startedAt <= KIMI_QUERY_TIMEOUT_MS) { if (signal.aborted) { throw new Error("adapter_aborted"); } await pinKimiConversationToBottom(page); const snapshot = await readKimiPageSnapshot(page, questionText); bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot); if (hasKimiObservedContent(snapshot)) { bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, snapshot); } if (snapshot.loginRequired) { return { ok: false, error: "kimi_login_expired", detail: snapshot.loginReason ?? "login_required", finalSnapshot: pickBetterSnapshot(bestSnapshot, snapshot), stablePollCount, elapsedMs: Date.now() - startedAt, }; } const hasAnswer = Boolean(normalizeOptionalString(snapshot.answerText)); const hasLinks = snapshot.citationLinks.length > 0 || snapshot.searchResultLinks.length > 0; const hasReasoning = snapshot.reasoningBlocks.length > 0; const hasContent = hasAnswer || hasLinks || hasReasoning; if (snapshot.signature && hasContent) { if (snapshot.signature !== initialSignature) { sawChange = true; if (firstChangedContentAt === null) { firstChangedContentAt = Date.now(); } } if (snapshot.signature === lastSignature) { stablePollCount = snapshot.busy ? 0 : stablePollCount + 1; } else { lastSignature = snapshot.signature; stablePollCount = 0; } } const now = Date.now(); if ( sawChange && hasContent && !snapshot.busy && stablePollCount >= KIMI_STABLE_POLLS_REQUIRED && shouldReturnStableKimiAnswer(snapshot, firstChangedContentAt, now) ) { const contentSnapshot = pickBetterSnapshot(bestSnapshot, snapshot); const revealedSearchLinks = await collectKimiSearchPanelLinks(page, questionText, signal).catch(() => []); let sourceSnapshot = await readKimiPageSnapshot(page, questionText); if (revealedSearchLinks.length) { sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, { ...sourceSnapshot, searchResultLinks: revealedSearchLinks, }); } await ensureKimiCitationPanelOpen(page, signal); sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText)); let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot); if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) { finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot); } return { ok: true, finalSnapshot, stablePollCount, elapsedMs: Date.now() - startedAt, }; } await sleep(KIMI_QUERY_POLL_INTERVAL_MS, signal); } await pinKimiConversationToBottom(page); const contentSnapshot = bestContentSnapshot ?? bestSnapshot; const revealedSearchLinks = await collectKimiSearchPanelLinks(page, questionText, signal).catch(() => []); let sourceSnapshot = await readKimiPageSnapshot(page, questionText); if (revealedSearchLinks.length) { sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, { ...sourceSnapshot, searchResultLinks: revealedSearchLinks, }); } await ensureKimiCitationPanelOpen(page, signal); sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText)); let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot); if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) { finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot); } return { ok: false, error: "kimi_query_timeout", detail: "timed out while waiting for Kimi answer", finalSnapshot, stablePollCount, elapsedMs: Date.now() - startedAt, }; } export const kimiAdapter: MonitorAdapter = { provider: "kimi", executionMode: "playwright", async query(context, payload) { if (!context.playwright?.page) { return { status: "failed", summary: "Kimi 监测缺少 Playwright 页面上下文。", error: buildAdapterError("kimi_playwright_required", "kimi_playwright_required"), }; } const questionText = extractQuestionText(payload); const page = context.playwright.page; context.reportProgress("kimi.page_ready"); await ensurePageOnKimiChat(page, context.signal); const initialSnapshot = await readKimiPageSnapshot(page, questionText); if (initialSnapshot.loginRequired) { return { status: "failed", summary: "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", error: buildAdapterError( "kimi_login_required", initialSnapshot.loginReason ?? "login_required", { page_url: initialSnapshot.url, }, ), }; } context.reportProgress("kimi.mode_switch"); const modeSwitchResult = await ensureKimiThinkingMode(page); if (!modeSwitchResult.ok) { const isAuthFailure = modeSwitchResult.error === "kimi_login_required"; return { status: "failed", summary: isAuthFailure ? "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。" : "Kimi 未能切换到 K2.6 思考,已中止本次监控任务。", error: buildAdapterError( modeSwitchResult.error, modeSwitchResult.detail ?? modeSwitchResult.error, { page_url: modeSwitchResult.url ?? safePageURL(page), current_model_label: modeSwitchResult.currentModelLabel, }, ), }; } context.reportProgress("kimi.query"); await submitKimiQuestion(page, questionText, context.signal); await sleep(400, context.signal).catch(() => undefined); await pinKimiConversationToBottom(page); const postSubmitSnapshot = await readKimiPageSnapshot(page, questionText); if (postSubmitSnapshot.loginRequired) { return { status: "failed", summary: "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", error: buildAdapterError( "kimi_login_expired", postSubmitSnapshot.loginReason ?? "login_required", { page_url: postSubmitSnapshot.url, }, ), }; } context.reportProgress("kimi.wait_answer"); const queryResult = await waitForKimiAnswer(page, questionText, context.signal); const finalSnapshot = queryResult.finalSnapshot; const classifiedSources = classifyKimiSources(finalSnapshot); const citations = classifiedSources.citations; const searchResults = classifiedSources.searchResults; const inlineCitationCandidates = classifiedSources.inlineCitationCandidates; const panelCitations = classifiedSources.panelCitations; const answer = normalizeText(finalSnapshot.answerText); const reasoning = normalizeText(finalSnapshot.reasoningText); const answerComplete = isKimiAnswerComplete(finalSnapshot); const keywordBlobDetected = answer ? isKimiKeywordBlob(answer) : false; const providerModel = normalizeText(finalSnapshot.currentModelLabel) ?? KIMI_THINKING_MODE_LABEL; const conversationID = extractConversationID(finalSnapshot.url); if (!queryResult.ok && queryResult.error === "kimi_login_expired") { return { status: "failed", summary: "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", error: buildAdapterError( queryResult.error, queryResult.detail ?? "login_required", { page_url: finalSnapshot.url, }, ), }; } if (!answer) { const timedOut = !queryResult.ok && queryResult.error === "kimi_query_timeout"; const hasReasoningOnly = Boolean(reasoning); if (timedOut || hasReasoningOnly || searchResults.length > 0 || citations.length > 0) { const incompleteDetail = !queryResult.ok ? queryResult.detail ?? queryResult.error : "kimi returned sources without a final answer"; return { status: "unknown", summary: "Kimi 未拿到完整答案,已回写 unknown 等待后续补采。", error: buildAdapterError( timedOut ? queryResult.error : "kimi_incomplete_response", incompleteDetail, { page_url: finalSnapshot.url, current_model_label: finalSnapshot.currentModelLabel, reasoning_present: Boolean(reasoning), citation_count: citations.length, search_result_count: searchResults.length, busy: finalSnapshot.busy, }, ), }; } return queryResult.ok ? { status: "unknown", summary: "Kimi 返回为空,已回写 unknown 等待后续对账。", error: buildAdapterError("kimi_empty_response", "kimi returned no answer, citations, or search results"), } : { status: "failed", summary: `Kimi 请求失败:${queryResult.detail ?? queryResult.error}`, error: buildAdapterError( queryResult.error, queryResult.detail ?? queryResult.error, { page_url: finalSnapshot.url, current_model_label: finalSnapshot.currentModelLabel, }, ), }; } context.reportProgress("kimi.parse_result"); return { status: "succeeded", summary: queryResult.ok ? "Kimi 监控任务执行成功。" : "Kimi 监控任务执行成功(超时前已收集到页面答案)。", payload: { platform: "kimi", provider_model: providerModel, provider_request_id: null, request_id: conversationID, 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: "kimi", status: queryResult.ok ? "succeeded" : queryResult.error, page_url: finalSnapshot.url, page_title: finalSnapshot.title, model_label: finalSnapshot.currentModelLabel, thinking_mode_requested: KIMI_THINKING_MODE_LABEL, login_required: finalSnapshot.loginRequired, busy: finalSnapshot.busy, busy_signals: finalSnapshot.busySignals, send_disabled: finalSnapshot.sendDisabled, answer: answer ?? null, answer_complete: answerComplete, answer_keyword_blob_detected: keywordBlobDetected, answer_block_count: finalSnapshot.answerBlocks.length, answer_blocks: finalSnapshot.answerBlocks, reasoning: reasoning ?? null, reasoning_block_count: finalSnapshot.reasoningBlocks.length, reasoning_blocks: finalSnapshot.reasoningBlocks, citation_count: citations.length, citations: toJsonSources(citations), panel_citation_count: panelCitations.length, panel_citations: toJsonSources(panelCitations), inline_citation_candidate_count: inlineCitationCandidates.length, inline_citation_candidates: toJsonSources(inlineCitationCandidates), inline_answer_citation_candidate_count: finalSnapshot.inlineCitationCandidateLinks.length, inline_answer_citation_candidates: finalSnapshot.inlineCitationCandidateLinks.map((item) => ({ url: item.url, title: item.title ?? item.text ?? null, site_name: item.siteName ?? null, })), search_result_count: searchResults.length, search_results: toJsonSources(searchResults), source_count: citations.length, sources: toJsonSources(citations), question_matched: finalSnapshot.questionMatched, candidate_answer_count: finalSnapshot.candidateAnswerCount, candidate_reasoning_count: finalSnapshot.candidateReasoningCount, stable_poll_count: queryResult.stablePollCount, elapsed_ms: queryResult.elapsedMs, signature: finalSnapshot.signature, snapshot: toJsonValue(finalSnapshot), }, }, }; }, }; export const __kimiTestUtils = { buildSourceItem, classifyKimiSources, dedupeSourceItems, mergeKimiSourceLinksIntoContentSnapshot, normalizeUrl, };