feat(kimi): unwrap redirect urls and merge search panel into citations

Pull Kimi's "搜索网页" panel results into the regular citation source
flow, drop the dedicated-citation-panel branch on the server side, and
unwrap Kimi redirect URLs so citation source items resolve to the real
target host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 17:38:32 +08:00
parent 4a7427a7f1
commit e53319039e
4 changed files with 912 additions and 54 deletions
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { __kimiTestUtils } from "./kimi";
const {
buildSourceItem,
classifyKimiSources,
mergeKimiSourceLinksIntoContentSnapshot,
normalizeUrl,
} = __kimiTestUtils;
function buildSnapshot(overrides: Partial<Parameters<typeof classifyKimiSources>[0]>): Parameters<typeof classifyKimiSources>[0] {
return {
url: "https://www.kimi.com/chat/test",
title: "Kimi",
currentModelLabel: "K2.6 思考",
loginRequired: false,
loginReason: null,
busy: false,
busySignals: [],
sendDisabled: null,
answerText: "答案",
answerBlocks: ["答案"],
reasoningText: null,
reasoningBlocks: [],
questionMatched: true,
candidateAnswerCount: 1,
candidateReasoningCount: 0,
citationLinks: [],
panelCitationLinks: [],
inlineCitationCandidateLinks: [],
searchResultLinks: [],
signature: "sig",
...overrides,
};
}
describe("kimi adapter helpers", () => {
it("unwraps Kimi redirect URLs before storing source items", () => {
expect(normalizeUrl("https://www.kimi.com/redirect?url=https%3A%2F%2Fexample.com%2Fsource%23ref")).toBe(
"https://example.com/source",
);
expect(buildSourceItem({
url: "https://www.kimi.com/redirect?target=https%3A%2F%2Fsource.example.com%2Fnews%3Fid%3D1",
title: "搜索结果标题",
text: null,
siteName: "来源站点",
})).toMatchObject({
url: "https://source.example.com/news?id=1",
normalized_url: "https://source.example.com/news?id=1",
title: "搜索结果标题",
site_name: "来源站点",
host: "source.example.com",
});
});
it("uses search web results as citation sources and footer references as content citation candidates", () => {
const classified = classifyKimiSources(buildSnapshot({
searchResultLinks: [
{
url: "https://search.example.com/a",
title: "搜索网页 A",
text: "搜索网页 A",
siteName: "搜索站点",
},
],
panelCitationLinks: [
{
url: "https://content.example.com/cited",
title: "页尾引用文章",
text: "页尾引用文章",
siteName: "内容站点",
},
],
inlineCitationCandidateLinks: [
{
url: "https://content.example.com/site",
title: null,
text: "内容站点",
siteName: "内容站点",
},
],
}));
expect(classified.citations).toHaveLength(1);
expect(classified.citations[0]?.url).toBe("https://search.example.com/a");
expect(classified.searchResults[0]?.url).toBe("https://search.example.com/a");
expect(classified.panelCitations[0]?.url).toBe("https://content.example.com/cited");
expect(classified.inlineCitationCandidates.map((item) => item.url)).toEqual([
"https://content.example.com/cited",
"https://content.example.com/site",
]);
});
it("falls back to footer references as citation sources when no search web results are visible", () => {
const classified = classifyKimiSources(buildSnapshot({
panelCitationLinks: [
{
url: "https://content.example.com/cited",
title: "页尾引用文章",
text: "页尾引用文章",
siteName: "内容站点",
},
],
}));
expect(classified.searchResults).toHaveLength(0);
expect(classified.citations).toHaveLength(1);
expect(classified.citations[0]?.url).toBe("https://content.example.com/cited");
});
it("keeps the answer text from the content snapshot when merging side-panel sources", () => {
const merged = mergeKimiSourceLinksIntoContentSnapshot(
buildSnapshot({
answerText: "真实回答正文",
answerBlocks: ["真实回答正文"],
}),
buildSnapshot({
answerText: "搜索网页 48\nhttps://example.com/a 很长的侧栏文本",
answerBlocks: ["搜索网页 48\nhttps://example.com/a 很长的侧栏文本"],
searchResultLinks: [
{
url: "https://example.com/a",
title: "搜索结果 A",
text: "搜索结果 A",
siteName: "Example",
},
],
}),
);
expect(merged.answerText).toBe("真实回答正文");
expect(merged.answerBlocks).toEqual(["真实回答正文"]);
expect(merged.searchResultLinks).toHaveLength(1);
expect(merged.searchResultLinks[0]?.url).toBe("https://example.com/a");
});
});
+761 -41
View File
@@ -14,6 +14,26 @@ 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;
@@ -45,6 +65,13 @@ type KimiPageSnapshot = {
signature: string | null;
};
type KimiClassifiedSources = {
citations: MonitoringSourceItem[];
searchResults: MonitoringSourceItem[];
inlineCitationCandidates: MonitoringSourceItem[];
panelCitations: MonitoringSourceItem[];
};
type KimiModeSwitchResult =
| {
ok: true;
@@ -87,19 +114,85 @@ function normalizeOptionalString(value: unknown): string | null {
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;
}
try {
const url = new URL(input);
url.hash = "";
return url.toString();
} catch {
return input;
}
return extractRedirectTargetUrl(input) ?? normalizeAbsoluteUrl(input);
}
function buildSourceItem(source: KimiDomLink): MonitoringSourceItem | null {
@@ -152,6 +245,99 @@ function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[
return Array.from(keyed.values());
}
function dedupeKimiDomLinks(items: KimiDomLink[]): KimiDomLink[] {
const keyed = new Map<string, KimiDomLink>();
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;
@@ -405,6 +591,231 @@ async function ensureKimiCitationPanelOpen(page: PlaywrightPage, signal?: AbortS
}
}
async function ensureKimiSearchPanelOpen(page: PlaywrightPage, signal?: AbortSignal): Promise<void> {
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<HTMLElement>();
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<KimiDomLink[]> {
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;
@@ -979,7 +1390,7 @@ async function ensureKimiThinkingMode(page: PlaywrightPage): Promise<KimiModeSwi
async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): Promise<KimiPageSnapshot> {
return page.evaluate(
({ question }) => {
({ question, redirectQueryKeys }) => {
type CandidateKind = "answer" | "reasoning";
type Candidate = {
element: HTMLElement;
@@ -1062,6 +1473,27 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
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 = [
"微信扫码登录",
"手机号快捷登录",
@@ -1143,19 +1575,84 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
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;
}
try {
const url = new URL(href, window.location.href);
url.hash = "";
return url.toString();
} catch {
if (/^(?:javascript|data|blob|mailto|tel):/i.test(href)) {
return null;
}
return extractRedirectTargetHref(href) ?? normalizeAbsoluteHref(href);
};
const buildSyntheticSourceUrl = (siteName: string | null): string | null => {
@@ -1287,6 +1784,121 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
};
};
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;
@@ -1344,6 +1956,31 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
return results;
};
const collectUniqueRootLinks = (
roots: HTMLElement[],
selectors: string,
extractor: (element: Element) => KimiDomLink | null,
): KimiDomLink[] => {
const results: KimiDomLink[] = [];
const seen = new Set<string>();
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) {
@@ -1614,6 +2251,9 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue;
}
if (isAuxiliaryPanelElement(node)) {
continue;
}
const text = normalizeBlock(node.innerText);
if (!text) {
@@ -1726,6 +2366,9 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
if (!(element instanceof HTMLElement) || seen.has(element) || !isVisible(element)) {
continue;
}
if (isAuxiliaryPanelElement(element)) {
continue;
}
seen.add(element);
const order = getElementOrder(element);
@@ -1887,30 +2530,61 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
.filter((value): value is HTMLElement => value instanceof HTMLElement && isVisible(value))
.map((element) => {
const text = normalizeBlock(element.innerText) ?? "";
const externalAnchorCount = Array.from(element.querySelectorAll("a[href]"))
.filter((node) => extractExternalAnchorLink(node) !== null)
.length;
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,
externalAnchorCount,
searchLinkCount,
area,
className,
};
})
.filter((candidate) =>
candidate.externalAnchorCount >= 3
candidate.searchLinkCount >= 1
&& (
/^搜索网页(?:\s+\d+)?/m.test(candidate.text)
|| (
candidate.text.includes("搜索网页")
&& (candidate.className.includes("ole") || candidate.className.includes("search"))
&& (
candidate.text.includes("结果")
|| candidate.className.includes("ole")
|| candidate.className.includes("search")
|| candidate.className.includes("web")
)
)
))
.sort((left, right) => left.area - right.area || right.externalAnchorCount - left.externalAnchorCount);
.sort((left, right) => left.area - right.area || right.searchLinkCount - left.searchLinkCount);
const searchPanelRoots: HTMLElement[] = [];
for (const candidate of searchPanelCandidates) {
@@ -1922,8 +2596,32 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
const searchResultLinks = collectUniqueLinks(
searchPanelRoots,
[
"a[href]",
extractExternalAnchorLink,
"[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) ?? "";
@@ -1996,6 +2694,7 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string):
},
{
question: questionText,
redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS,
},
);
}
@@ -2065,8 +2764,18 @@ async function waitForKimiAnswer(
&& 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);
let finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText));
sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText));
let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot);
if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) {
finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot);
}
@@ -2082,8 +2791,18 @@ async function waitForKimiAnswer(
}
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);
let finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText));
sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText));
let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot);
if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) {
finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot);
}
@@ -2173,16 +2892,11 @@ export const kimiAdapter: MonitorAdapter = {
context.reportProgress("kimi.wait_answer");
const queryResult = await waitForKimiAnswer(page, questionText, context.signal);
const finalSnapshot = queryResult.finalSnapshot;
const citations = dedupeSourceItems(
finalSnapshot.citationLinks
.map((item) => buildSourceItem(item))
.filter((item): item is MonitoringSourceItem => item !== null),
);
const searchResults = dedupeSourceItems(
finalSnapshot.searchResultLinks
.map((item) => buildSourceItem(item))
.filter((item): item is MonitoringSourceItem => item !== null),
);
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);
@@ -2284,14 +2998,12 @@ export const kimiAdapter: MonitorAdapter = {
reasoning_blocks: finalSnapshot.reasoningBlocks,
citation_count: citations.length,
citations: toJsonSources(citations),
panel_citation_count: finalSnapshot.panelCitationLinks.length,
panel_citations: finalSnapshot.panelCitationLinks.map((item) => ({
url: item.url,
title: item.title ?? item.text ?? null,
site_name: item.siteName ?? null,
})),
inline_citation_candidate_count: finalSnapshot.inlineCitationCandidateLinks.length,
inline_citation_candidates: finalSnapshot.inlineCitationCandidateLinks.map((item) => ({
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,
@@ -2312,3 +3024,11 @@ export const kimiAdapter: MonitorAdapter = {
};
},
};
export const __kimiTestUtils = {
buildSourceItem,
classifyKimiSources,
dedupeSourceItems,
mergeKimiSourceLinksIntoContentSnapshot,
normalizeUrl,
};
@@ -2680,12 +2680,7 @@ func buildMonitoringCitationSourceInputs(platformID string, req MonitoringTaskRe
}
func monitoringPlatformUsesDedicatedCitationPanel(platformID string) bool {
switch normalizeMonitoringPlatformID(platformID) {
case "kimi":
return true
default:
return false
}
}
func monitoringPlatformResolvesContentCitations(platformID string) bool {
@@ -283,7 +283,7 @@ func TestMonitoringHeartbeatSnapshotMetrics(t *testing.T) {
}
}
func TestBuildMonitoringRawPayloadKimiExcludesSearchResultsFromCitationInputs(t *testing.T) {
func TestBuildMonitoringRawPayloadKimiIncludesSearchResultsInCitationInputs(t *testing.T) {
citationTitle := "真实引用文章"
searchTitle := "搜索网页结果"
@@ -304,13 +304,18 @@ func TestBuildMonitoringRawPayloadKimiExcludesSearchResultsFromCitationInputs(t
}
rawPayload, sourceInputs := buildMonitoringRawPayload("kimi", req)
if len(sourceInputs) != 1 {
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
if len(sourceInputs) != 2 {
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 2", len(sourceInputs))
}
seen := make(map[string]bool)
for _, item := range sourceInputs {
if item.URL != "https://example.com/citation" {
t.Fatalf("buildMonitoringRawPayload() source url = %q, want citation url", item.URL)
seen[item.URL] = true
}
if !seen["https://example.com/citation"] {
t.Fatalf("buildMonitoringRawPayload() missing citation url in source inputs: %#v", sourceInputs)
}
if !seen["https://example.com/search"] {
t.Fatalf("buildMonitoringRawPayload() missing search url in source inputs: %#v", sourceInputs)
}
var payload map[string]interface{}