feat(wenxin): unwrap reference redirects and capture web page sources
Decode Wenxin reference redirect targets so citation URLs land on the real source host, and broaden the reference/search field patterns to pick up webPages-style payloads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { __wenxinTestUtils } from "./wenxin";
|
import { __wenxinTestUtils } from "./wenxin";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
buildSourceItem,
|
||||||
htmlTableToMarkdown,
|
htmlTableToMarkdown,
|
||||||
isObservationComplete,
|
isObservationComplete,
|
||||||
|
normalizeUrl,
|
||||||
parseWenxinHistoryResponse,
|
parseWenxinHistoryResponse,
|
||||||
parseWenxinSSEBody,
|
parseWenxinSSEBody,
|
||||||
} = __wenxinTestUtils;
|
} = __wenxinTestUtils;
|
||||||
@@ -163,6 +165,62 @@ describe("wenxin adapter helpers", () => {
|
|||||||
expect(history.citations[0]?.url).toBe("https://fallback.example.com/article");
|
expect(history.citations[0]?.url).toBe("https://fallback.example.com/article");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("unwraps Wenxin reference redirect urls", () => {
|
||||||
|
expect(normalizeUrl("https://yiyan.baidu.com/eb/link?target=https%3A%2F%2Fwww.example.com%2Farticle%23ref")).toBe(
|
||||||
|
"https://www.example.com/article",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildSourceItem({
|
||||||
|
pageUrl: "https://yiyan.baidu.com/eb/link?url=https%3A%2F%2Fsource.example.com%2Fnews%3Fid%3D1",
|
||||||
|
title: "引用来源标题",
|
||||||
|
source: "来源站点",
|
||||||
|
})).toMatchObject({
|
||||||
|
url: "https://source.example.com/news?id=1",
|
||||||
|
title: "引用来源标题",
|
||||||
|
site_name: "来源站点",
|
||||||
|
host: "source.example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts web page references from Wenxin reference list fields", () => {
|
||||||
|
const history = parseWenxinHistoryResponse({
|
||||||
|
code: 0,
|
||||||
|
msg: "success",
|
||||||
|
data: {
|
||||||
|
state: 1,
|
||||||
|
chats: [
|
||||||
|
{
|
||||||
|
id: "bot-1",
|
||||||
|
role: "robot",
|
||||||
|
stop: 1,
|
||||||
|
modelSign: "EB45T",
|
||||||
|
message: [
|
||||||
|
{
|
||||||
|
contentType: "text",
|
||||||
|
content: "这是答案",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webPages: [
|
||||||
|
{
|
||||||
|
targetUrl: "https://yiyan.baidu.com/eb/link?target=https%3A%2F%2Fwww.reference-source.com%2Fa%23section",
|
||||||
|
title: "参考网页 A",
|
||||||
|
siteName: "reference-source.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, "session-3");
|
||||||
|
|
||||||
|
expect(history.citations).toHaveLength(1);
|
||||||
|
expect(history.searchResults).toHaveLength(1);
|
||||||
|
expect(history.citations[0]).toMatchObject({
|
||||||
|
url: "https://www.reference-source.com/a",
|
||||||
|
title: "参考网页 A",
|
||||||
|
site_name: "reference-source.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not treat error-only observations as complete", () => {
|
it("does not treat error-only observations as complete", () => {
|
||||||
expect(isObservationComplete({
|
expect(isObservationComplete({
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
|
|||||||
@@ -29,8 +29,28 @@ const WENXIN_LOGIN_REQUIRED_PATTERN = /(请先登录|未登录|登录后|登录
|
|||||||
const WENXIN_PLACEHOLDER_PATTERN = /^(?:正在生成中(?:\.\.\.)?|思考中(?:\.\.\.)?|内容由AI生成,仅供参考,请仔细甄别|全选)$/;
|
const WENXIN_PLACEHOLDER_PATTERN = /^(?:正在生成中(?:\.\.\.)?|思考中(?:\.\.\.)?|内容由AI生成,仅供参考,请仔细甄别|全选)$/;
|
||||||
const WENXIN_TABLE_HTML_PATTERN = /<table\b[\s\S]*?<\/table>/gi;
|
const WENXIN_TABLE_HTML_PATTERN = /<table\b[\s\S]*?<\/table>/gi;
|
||||||
const WENXIN_REFERENCE_TRIGGER_PATTERN = /(参考\s*\d+\s*(?:个网页|条网页信息源)|found\s*\d+\s*web pages)/i;
|
const WENXIN_REFERENCE_TRIGGER_PATTERN = /(参考\s*\d+\s*(?:个网页|条网页信息源)|found\s*\d+\s*web pages)/i;
|
||||||
const WENXIN_CITATION_FIELD_PATTERN = /^(?:citations?|citationInfo|pluginCitations?|pluginCitation|reference(?:s|List|_list|Cards|_cards|Docs|_docs)?|source(?:s|List|_list|Cards|_cards)?)$/i;
|
const WENXIN_CITATION_FIELD_PATTERN = /^(?:citations?|citationInfo|pluginCitations?|pluginCitation|reference(?:s|List|_list|Cards|_cards|Docs|_docs|Info|_info)?|source(?:s|List|_list|Cards|_cards|Info|_info)?|web(?:References?|Sources?|Pages?|Docs?|Results?)|page(?:References?|Sources?))$/i;
|
||||||
const WENXIN_SEARCH_FIELD_PATTERN = /^(?:searchCitations?|search_results?|searchResultList|search_result_list|webListData|webList|browseReferences?|browse_results?|grounding(?:_results?)?)$/i;
|
const WENXIN_SEARCH_FIELD_PATTERN = /^(?:searchCitations?|search_results?|searchResultList|search_result_list|webListData|webList|web(?:References?|Sources?|Pages?|Docs?|Results?)|browseReferences?|browse_results?|grounding(?:_results?)?)$/i;
|
||||||
|
const WENXIN_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 WenxinPageSourceLink = {
|
type WenxinPageSourceLink = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -165,19 +185,85 @@ function normalizeBlockText(value: string | null): string | null {
|
|||||||
.trim() || null;
|
.trim() || 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 WENXIN_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 {
|
function normalizeUrl(value: unknown): string | null {
|
||||||
const input = normalizeText(value);
|
const input = normalizeText(value);
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return extractRedirectTargetUrl(input) ?? normalizeAbsoluteUrl(input);
|
||||||
const url = new URL(input);
|
|
||||||
url.hash = "";
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyWenxinSourceUrl(url: string | null): boolean {
|
function isLikelyWenxinSourceUrl(url: string | null): boolean {
|
||||||
@@ -351,6 +437,14 @@ function buildSourceItem(input: unknown): MonitoringSourceItem | null {
|
|||||||
input.url
|
input.url
|
||||||
?? input.href
|
?? input.href
|
||||||
?? input.link
|
?? input.link
|
||||||
|
?? input.pageUrl
|
||||||
|
?? input.page_url
|
||||||
|
?? input.webUrl
|
||||||
|
?? input.web_url
|
||||||
|
?? input.realUrl
|
||||||
|
?? input.real_url
|
||||||
|
?? input.displayUrl
|
||||||
|
?? input.display_url
|
||||||
?? input.originUrl
|
?? input.originUrl
|
||||||
?? input.origin_url
|
?? input.origin_url
|
||||||
?? input.targetUrl
|
?? input.targetUrl
|
||||||
@@ -1100,7 +1194,7 @@ async function ensurePageOnWenxinChat(page: PlaywrightPage, signal: AbortSignal)
|
|||||||
|
|
||||||
async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageSnapshot> {
|
async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageSnapshot> {
|
||||||
return await page.evaluate(
|
return await page.evaluate(
|
||||||
({ editorSelector, loginSignals, triggerPatternSource, citationFieldPatternSource, searchFieldPatternSource }) => {
|
({ editorSelector, loginSignals, triggerPatternSource, citationFieldPatternSource, searchFieldPatternSource, redirectQueryKeys }) => {
|
||||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
@@ -1116,6 +1210,73 @@ async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageS
|
|||||||
return trimmed || null;
|
return trimmed || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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: unknown): string | null => {
|
const normalizeHref = (value: unknown): string | null => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -1126,13 +1287,7 @@ async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageS
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return extractRedirectTargetHref(trimmed) ?? normalizeAbsoluteHref(trimmed);
|
||||||
const url = new URL(trimmed, window.location.href);
|
|
||||||
url.hash = "";
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLikelySourceUrl = (value: string | null): value is string => {
|
const isLikelySourceUrl = (value: string | null): value is string => {
|
||||||
@@ -1169,6 +1324,14 @@ async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageS
|
|||||||
input.url
|
input.url
|
||||||
?? input.href
|
?? input.href
|
||||||
?? input.link
|
?? input.link
|
||||||
|
?? input.pageUrl
|
||||||
|
?? input.page_url
|
||||||
|
?? input.webUrl
|
||||||
|
?? input.web_url
|
||||||
|
?? input.realUrl
|
||||||
|
?? input.real_url
|
||||||
|
?? input.displayUrl
|
||||||
|
?? input.display_url
|
||||||
?? input.originUrl
|
?? input.originUrl
|
||||||
?? input.origin_url
|
?? input.origin_url
|
||||||
?? input.targetUrl
|
?? input.targetUrl
|
||||||
@@ -1331,39 +1494,225 @@ async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageS
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const urlAttributeNames = [
|
||||||
|
"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-redirect",
|
||||||
|
"data-jump-url",
|
||||||
|
"data-log",
|
||||||
|
"data-click",
|
||||||
|
"data-extra",
|
||||||
|
"data-info",
|
||||||
|
"data-value",
|
||||||
|
];
|
||||||
|
|
||||||
|
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 candidates: string[] = [];
|
||||||
|
for (const name of urlAttributeNames) {
|
||||||
|
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 url = normalizeHref(candidate) ?? firstUrlFromText(candidate);
|
||||||
|
if (isLikelySourceUrl(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = normalize(element.textContent ?? "");
|
||||||
|
if (text && text.length <= 2_000) {
|
||||||
|
const url = firstUrlFromText(text);
|
||||||
|
if (isLikelySourceUrl(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeShortText = (value: unknown, maxLength = 220): string | null => {
|
||||||
|
const text = normalize(value);
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text.length <= maxLength ? text : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractElementSourceLink = (element: Element) => {
|
||||||
|
if (!isVisible(element)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorLink = extractAnchorLink(element);
|
||||||
|
if (anchorLink) {
|
||||||
|
return anchorLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = readElementUrl(element);
|
||||||
|
if (!isLikelySourceUrl(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = element.closest([
|
||||||
|
"a",
|
||||||
|
"li",
|
||||||
|
"article",
|
||||||
|
"section",
|
||||||
|
"[role=\"listitem\"]",
|
||||||
|
"[class*=\"item\"]",
|
||||||
|
"[class*=\"Item\"]",
|
||||||
|
"[class*=\"card\"]",
|
||||||
|
"[class*=\"Card\"]",
|
||||||
|
"[class*=\"source\"]",
|
||||||
|
"[class*=\"Source\"]",
|
||||||
|
"[class*=\"reference\"]",
|
||||||
|
"[class*=\"Reference\"]",
|
||||||
|
"[class*=\"web\"]",
|
||||||
|
"[class*=\"Web\"]",
|
||||||
|
].join(",")) ?? element;
|
||||||
|
|
||||||
|
const title = normalizeShortText(
|
||||||
|
card.querySelector("[class*=\"title\"], [class*=\"Title\"], h1, h2, h3, h4")?.textContent
|
||||||
|
?? element.getAttribute("title")
|
||||||
|
?? element.getAttribute("aria-label")
|
||||||
|
?? card.textContent,
|
||||||
|
);
|
||||||
|
const siteName = normalizeShortText(
|
||||||
|
card.querySelector("[class*=\"site\"], [class*=\"Site\"], [class*=\"domain\"], [class*=\"Domain\"], [class*=\"source\"], [class*=\"Source\"]")?.textContent
|
||||||
|
?? element.getAttribute("data-site-name")
|
||||||
|
?? (element instanceof HTMLElement ? element.dataset.siteName : null),
|
||||||
|
80,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
text: title ?? siteName,
|
||||||
|
siteName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectSourceLinksFromElement = (root: HTMLElement) => {
|
||||||
|
const candidates = [
|
||||||
|
root,
|
||||||
|
...Array.from(root.querySelectorAll([
|
||||||
|
"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*=\"source\"]",
|
||||||
|
"[class*=\"Source\"]",
|
||||||
|
"[class*=\"reference\"]",
|
||||||
|
"[class*=\"Reference\"]",
|
||||||
|
"[class*=\"web\"]",
|
||||||
|
"[class*=\"Web\"]",
|
||||||
|
].join(","))),
|
||||||
|
];
|
||||||
|
|
||||||
|
return dedupeLinks(
|
||||||
|
candidates
|
||||||
|
.map((node) => extractElementSourceLink(node))
|
||||||
|
.filter((node): node is NonNullable<typeof node> => Boolean(node)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const collectVisiblePanelLinks = (triggerText: string | null) => {
|
const collectVisiblePanelLinks = (triggerText: string | null) => {
|
||||||
const panelSelectors = [
|
const panelSelectors = [
|
||||||
|
"[role=\"dialog\"]",
|
||||||
|
"[role=\"list\"]",
|
||||||
|
"[class*=\"modal\"]",
|
||||||
|
"[class*=\"Modal\"]",
|
||||||
|
"[class*=\"dialog\"]",
|
||||||
|
"[class*=\"Dialog\"]",
|
||||||
"[class*=\"drawer\"]",
|
"[class*=\"drawer\"]",
|
||||||
|
"[class*=\"Drawer\"]",
|
||||||
"[class*=\"panel\"]",
|
"[class*=\"panel\"]",
|
||||||
|
"[class*=\"Panel\"]",
|
||||||
"[class*=\"popover\"]",
|
"[class*=\"popover\"]",
|
||||||
|
"[class*=\"Popover\"]",
|
||||||
|
"[class*=\"popup\"]",
|
||||||
|
"[class*=\"Popup\"]",
|
||||||
"[class*=\"webList\"]",
|
"[class*=\"webList\"]",
|
||||||
|
"[class*=\"WebList\"]",
|
||||||
"[class*=\"reference\"]",
|
"[class*=\"reference\"]",
|
||||||
|
"[class*=\"Reference\"]",
|
||||||
"[class*=\"material\"]",
|
"[class*=\"material\"]",
|
||||||
"[class*=\"cite\"]",
|
"[class*=\"cite\"]",
|
||||||
|
"[class*=\"source\"]",
|
||||||
|
"[class*=\"Source\"]",
|
||||||
|
"[class*=\"web\"]",
|
||||||
|
"[class*=\"Web\"]",
|
||||||
"aside",
|
"aside",
|
||||||
"section",
|
"section",
|
||||||
"article",
|
"article",
|
||||||
|
"body > div",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const rootLinks = new Map<HTMLElement, ReturnType<typeof collectSourceLinksFromElement>>();
|
||||||
const roots = Array.from(document.querySelectorAll(panelSelectors.join(",")))
|
const roots = Array.from(document.querySelectorAll(panelSelectors.join(",")))
|
||||||
.filter((element): element is HTMLElement => isVisible(element))
|
.filter((element): element is HTMLElement => isVisible(element))
|
||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
const text = normalize(element.innerText) ?? "";
|
const text = normalize(element.innerText) ?? "";
|
||||||
const externalAnchorCount = Array.from(element.querySelectorAll("a[href]"))
|
const className = String(element.className || "");
|
||||||
.filter((node) => extractAnchorLink(node) !== null)
|
const links = collectSourceLinksFromElement(element);
|
||||||
.length;
|
rootLinks.set(element, links);
|
||||||
return externalAnchorCount > 0
|
return links.length > 0
|
||||||
&& (
|
&& (
|
||||||
/参考|网页|信息源|引用|web pages|references/i.test(text)
|
/参考|网页|信息源|引用|web pages|references/i.test(text)
|
||||||
|| Boolean(triggerText && text.includes(triggerText))
|
|| Boolean(triggerText && text.includes(triggerText))
|
||||||
|
|| element.getAttribute("role") === "dialog"
|
||||||
|
|| /(drawer|panel|popover|popup|modal|dialog|web|source|reference|cite)/i.test(className)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const links = roots.length
|
const links = roots.length
|
||||||
? roots.flatMap((root) =>
|
? roots.flatMap((root) => rootLinks.get(root) ?? collectSourceLinksFromElement(root))
|
||||||
Array.from(root.querySelectorAll("a[href]"))
|
|
||||||
.map((node) => extractAnchorLink(node))
|
|
||||||
.filter((node): node is NonNullable<typeof node> => Boolean(node)))
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return dedupeLinks(links);
|
return dedupeLinks(links);
|
||||||
@@ -1464,6 +1813,7 @@ async function readWenxinPageSnapshot(page: PlaywrightPage): Promise<WenxinPageS
|
|||||||
triggerPatternSource: WENXIN_REFERENCE_TRIGGER_PATTERN.source,
|
triggerPatternSource: WENXIN_REFERENCE_TRIGGER_PATTERN.source,
|
||||||
citationFieldPatternSource: WENXIN_CITATION_FIELD_PATTERN.source,
|
citationFieldPatternSource: WENXIN_CITATION_FIELD_PATTERN.source,
|
||||||
searchFieldPatternSource: WENXIN_SEARCH_FIELD_PATTERN.source,
|
searchFieldPatternSource: WENXIN_SEARCH_FIELD_PATTERN.source,
|
||||||
|
redirectQueryKeys: WENXIN_REDIRECT_QUERY_KEYS,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1500,17 +1850,58 @@ async function maybeOpenWenxinReferencePanel(page: PlaywrightPage): Promise<void
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerPattern = new RegExp(triggerPatternSource, "i");
|
const triggerPattern = new RegExp(triggerPatternSource, "i");
|
||||||
const trigger = Array.from(document.querySelectorAll("button, [role=\"button\"], a, div, span, p"))
|
const rawCandidates = Array.from(document.querySelectorAll("button, [role=\"button\"], a, [tabindex], div, span, p"))
|
||||||
.filter((element): element is HTMLElement => isVisible(element))
|
.filter((element): element is HTMLElement => isVisible(element))
|
||||||
.find((element) => {
|
.filter((element) => {
|
||||||
const text = normalize(element.innerText || element.textContent || "");
|
const text = normalize(element.innerText || element.textContent || "");
|
||||||
return Boolean(text && triggerPattern.test(text));
|
return Boolean(text && triggerPattern.test(text));
|
||||||
});
|
});
|
||||||
|
const candidates = rawCandidates
|
||||||
|
.map((element) => {
|
||||||
|
const target = element.closest("button, [role=\"button\"], a, [tabindex]") ?? element;
|
||||||
|
if (!(target instanceof HTMLElement) || !isVisible(target)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const text = normalize(target.innerText || target.textContent || "") ?? "";
|
||||||
|
const style = window.getComputedStyle(target);
|
||||||
|
const className = String(target.className || "");
|
||||||
|
let score = 0;
|
||||||
|
if (target.matches("button, [role=\"button\"], a, [tabindex]")) {
|
||||||
|
score += 80;
|
||||||
|
}
|
||||||
|
if (style.cursor === "pointer" || typeof target.onclick === "function") {
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
if (/(reference|cite|source|web)/i.test(className)) {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
if (text.length <= 80) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
score -= Math.min(rect.width * rect.height / 10_000, 50);
|
||||||
|
return { target, score };
|
||||||
|
})
|
||||||
|
.filter((item): item is { target: HTMLElement; score: number } => Boolean(item));
|
||||||
|
|
||||||
|
const seen = new Set<HTMLElement>();
|
||||||
|
const trigger = candidates
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.map((item) => item.target)
|
||||||
|
.find((target) => {
|
||||||
|
if (seen.has(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(target);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (!trigger) {
|
if (!trigger) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trigger.scrollIntoView({ block: "center", inline: "center" });
|
||||||
trigger.click();
|
trigger.click();
|
||||||
return true;
|
return true;
|
||||||
}, {
|
}, {
|
||||||
@@ -1518,7 +1909,8 @@ async function maybeOpenWenxinReferencePanel(page: PlaywrightPage): Promise<void
|
|||||||
}).catch(() => false);
|
}).catch(() => false);
|
||||||
|
|
||||||
if (opened) {
|
if (opened) {
|
||||||
await page.waitForTimeout(350).catch(() => undefined);
|
await page.waitForTimeout(1_000).catch(() => undefined);
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 1_500 }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2153,8 +2545,10 @@ export const wenxinAdapter: MonitorAdapter = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const __wenxinTestUtils = {
|
export const __wenxinTestUtils = {
|
||||||
|
buildSourceItem,
|
||||||
htmlTableToMarkdown,
|
htmlTableToMarkdown,
|
||||||
isObservationComplete,
|
isObservationComplete,
|
||||||
|
normalizeUrl,
|
||||||
parseWenxinHistoryResponse,
|
parseWenxinHistoryResponse,
|
||||||
parseWenxinSSEBody,
|
parseWenxinSSEBody,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user