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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
[
|
||||
"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) ?? "";
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user