diff --git a/apps/admin-web/src/views/TrackingQuestionDetailView.vue b/apps/admin-web/src/views/TrackingQuestionDetailView.vue index 5e00f4e..69f9d58 100644 --- a/apps/admin-web/src/views/TrackingQuestionDetailView.vue +++ b/apps/admin-web/src/views/TrackingQuestionDetailView.vue @@ -220,6 +220,39 @@ function statusLabel(status: string): string { return t(`tracking.status.${status}`); } +function resolveDeepSeekProviderModelLabel(value: string | null | undefined): string { + const normalized = String(value ?? "").trim(); + if (!normalized || normalized === "--") { + return "DeepSeek"; + } + + const compact = normalized.replace(/\s+/g, "").toLowerCase(); + if (/r1|reasoner|deepthink|深度思考/.test(compact)) { + return "DeepSeek"; + } + + const versionMatch = + normalized.match(/deepseek[-_\s]*(r1|v\d+(?:\.\d+)?)/i) + ?? normalized.match(/\b(r1|v\d+(?:\.\d+)?)\b/i); + if (versionMatch?.[1]) { + return `DeepSeek-${versionMatch[1].toUpperCase()}`; + } + + if (/search|web|联网|搜索/.test(compact)) { + return "DeepSeek"; + } + + return normalized; +} + +function resolveAnswerProviderModel(platform: MonitoringQuestionDetailPlatform | null): string { + if (isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) { + return resolveDeepSeekProviderModelLabel(platform?.provider_model); + } + + return String(platform?.provider_model ?? "").trim() || "--"; +} + function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): string { return citation.cited_title || citation.article_title || citation.cited_url; } @@ -374,6 +407,78 @@ function buildCitationSourceLookup(platform: MonitoringQuestionDetailPlatform | return lookup; } +function buildDeepSeekAnswerLinkSourceLookup( + platform: MonitoringQuestionDetailPlatform | null, +): Map { + const lookup = new Map(); + + for (const item of platform?.inline_citations ?? []) { + const normalizedURL = normalizeInlineCitationUrl(item.normalized_url ?? item.url); + if (!normalizedURL || lookup.has(normalizedURL)) { + continue; + } + lookup.set(normalizedURL, { + title: item.title?.trim() || null, + siteName: item.site_name?.trim() || null, + }); + } + + for (const citation of platform?.citations ?? []) { + const normalizedURL = normalizeInlineCitationUrl(citation.cited_url); + if (!normalizedURL || lookup.has(normalizedURL)) { + continue; + } + lookup.set(normalizedURL, { + title: resolveCitationTitle(citation), + siteName: citation.site_name?.trim() || null, + }); + } + + return lookup; +} + +function isHiddenDeepSeekAnswerNode(element: Element): boolean { + let current: Element | null = element; + for (let depth = 0; current && depth < 8; depth += 1) { + if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") { + return true; + } + const style = current.getAttribute("style") ?? ""; + if (/(display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0)/i.test(style)) { + return true; + } + current = current.parentElement; + } + return false; +} + +function isInsideDeepSeekAuxiliaryLinkContainer(anchor: HTMLAnchorElement): boolean { + let current = anchor.parentElement; + for (let depth = 0; current && depth < 8; depth += 1) { + const descriptor = `${current.className || ""} ${current.getAttribute("data-testid") || ""} ${current.getAttribute("role") || ""}`; + if (/(search-view-card|search-result|result-card|source-card|reference-card|citation-card|popover|tooltip|dropdown|floating|hover-card|preview-card)/i.test(descriptor)) { + return true; + } + current = current.parentElement; + } + return false; +} + +function shouldCountDeepSeekAnswerLink(anchor: HTMLAnchorElement): boolean { + if (isHiddenDeepSeekAnswerNode(anchor) || isInsideDeepSeekAuxiliaryLinkContainer(anchor)) { + return false; + } + + const text = String(anchor.textContent ?? "").trim(); + if (normalizeDeepSeekCitationLabel(text)) { + return true; + } + if (shouldStripDeepSeekOrphanAnchor(anchor)) { + return false; + } + return /[A-Za-z0-9\u3400-\u9fff]/.test(text); +} + function normalizeDeepSeekCitationLabel(value: string | null | undefined): string | null { const normalized = String(value ?? "").trim().replace(/\s+/g, " "); if (!normalized) { @@ -416,13 +521,16 @@ function pushUniqueDeepSeekCitationLabel(labels: string[], label: string): void function shouldStripDeepSeekOrphanAnchor(anchor: HTMLAnchorElement): boolean { const text = String(anchor.textContent ?? "").trim().replace(/\s+/g, ""); + if (/^(link|source|citation|reference|open|原文|链接|来源|引用)$/i.test(text)) { + return true; + } if (/[A-Za-z0-9\u3400-\u9fff]/.test(text)) { return false; } if (anchor.querySelector("img, svg")) { return true; } - return text.length === 0 || /^[\[\](){}<>#.,:;!?+\-_/\\|~`'"“”‘’·•::,。!?;、🔗]+$/.test(text); + return text.length === 0 || /^[\[\](){}<>#.,:;!?+\-_/\\|~`'"“”‘’·•::,。!?;、🔗↗↘↙↖↪⤴]+$/.test(text); } function collectDeepSeekCitationReferenceLabels( @@ -489,6 +597,17 @@ function resolveCitationIndexDisplayLabels( return labels; } +function shouldShowCitationSourceListIndex( + citationIndex: number, + platform: MonitoringQuestionDetailPlatform | null, +): boolean { + if (!isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) { + return true; + } + + return resolveCitationIndexDisplayLabels(citationIndex, platform).length === 0; +} + function countHanCharacters(value: string): number { return value.match(/[\u3400-\u9fff]/g)?.length ?? 0; } @@ -575,6 +694,8 @@ function renderDeepSeekAnswerContent( if (!source && !citationSource) { if (shouldStripDeepSeekOrphanAnchor(anchor)) { anchor.remove(); + } else { + anchor.replaceWith(document.createTextNode(anchor.textContent ?? "")); } continue; } @@ -657,28 +778,26 @@ function collectDeepSeekInlineCitationOccurrences( return []; } - const inlineCitationLookup = buildInlineCitationSourceLookup(platform); - if (!inlineCitationLookup.size) { - return []; - } + const sourceLookup = buildDeepSeekAnswerLinkSourceLookup(platform); const counts = new Map(); for (const anchor of Array.from(root.querySelectorAll("a[href]"))) { const normalizedURL = normalizeInlineCitationUrl(anchor.getAttribute("href") || anchor.href || ""); - if (!normalizedURL || !inlineCitationLookup.has(normalizedURL)) { + if (!normalizedURL || !shouldCountDeepSeekAnswerLink(anchor)) { continue; } - const source = inlineCitationLookup.get(normalizedURL) ?? null; + const source = sourceLookup.get(normalizedURL) ?? null; const existing = counts.get(normalizedURL); if (existing) { + // One row per URL, but every visible answer occurrence contributes to the count. existing.citationCount += 1; continue; } counts.set(normalizedURL, { - label: source?.title?.trim() || null, - siteName: source?.site_name?.trim() || null, + label: source?.title || anchor.getAttribute("title")?.trim() || null, + siteName: source?.siteName || null, citationCount: 1, }); } @@ -996,7 +1115,7 @@ const contentCitationColumns = computed(() => [
{{ activePlatform?.sampled_at ? formatDateTime(activePlatform.sampled_at) : "--" }} - {{ activePlatform?.provider_model ?? "--" }} + {{ resolveAnswerProviderModel(activePlatform) }}
@@ -1031,6 +1150,7 @@ const contentCitationColumns = computed(() => [ {{ label }} diff --git a/apps/desktop-client/src/main/adapters/deepseek.test.ts b/apps/desktop-client/src/main/adapters/deepseek.test.ts index b7bce49..1ff115f 100644 --- a/apps/desktop-client/src/main/adapters/deepseek.test.ts +++ b/apps/desktop-client/src/main/adapters/deepseek.test.ts @@ -97,6 +97,40 @@ describe("deepseek adapter helpers", () => { expect(classified.searchResults).toHaveLength(1); }); + it("keeps reasoning search pages as citation sources separate from answer content citations", () => { + const classified = classifyDeepseekSources({ + inlineLinks: [ + { + url: "https://example.com/body-citation", + title: "Answer body citation", + text: "[15]", + siteName: "Body Site", + kind: "inline", + }, + ], + sourcePanelLinks: [], + searchPanelLinks: [ + { + url: "https://example.com/reasoning-search", + title: "Reasoning searched page", + text: "浏览页面", + siteName: "Search Site", + kind: "search", + }, + ], + }); + + expect(classified.inlineCitationCandidates.map((item) => item.url)).toEqual([ + "https://example.com/body-citation", + ]); + expect(classified.citations.map((item) => item.url)).toEqual([ + "https://example.com/reasoning-search", + ]); + expect(classified.searchResults.map((item) => item.url)).toEqual([ + "https://example.com/reasoning-search", + ]); + }); + it("retains all revealed DeepSeek search results instead of truncating after 32 items", () => { const searchPanelLinks = Array.from({ length: 78 }, (_, index) => ({ url: `https://example.com/search-${index + 1}`, @@ -173,6 +207,22 @@ describe("deepseek adapter helpers", () => { })); }); + it("unwraps DeepSeek redirect URLs before storing source items", () => { + const item = buildSourceItem({ + url: "https://chat.deepseek.com/api/redirect?url=https%3A%2F%2Fexample.com%2Fsource%23ref", + title: "Redirected source", + text: null, + siteName: null, + kind: "search", + }); + + expect(item).toEqual(expect.objectContaining({ + url: "https://example.com/source", + normalized_url: "https://example.com/source", + host: "example.com", + })); + }); + it("does not use numeric citation markers as source titles", () => { const item = buildSourceItem({ url: "https://example.com/report#cite-1", @@ -217,6 +267,45 @@ describe("deepseek adapter helpers", () => { expect(isDeepSeekWebpagesTriggerText("搜索到 39 个网页")).toBe(false); }); + it("normalizes DeepSeek model labels without mistaking search toggles for models", () => { + const snapshot = { + url: "https://chat.deepseek.com/", + title: "DeepSeek", + loginRequired: false, + loginReason: null, + challengeRequired: false, + challengeReason: null, + busy: false, + busySignals: [], + composerValue: null, + sendDisabled: false, + answer: "Final answer", + answerText: "Final answer", + reasoning: null, + signature: "sig-1", + currentModelLabel: "深度思考 (R1)", + providerRequestID: null, + requestID: null, + inlineLinks: [], + sourcePanelLinks: [], + searchPanelLinks: [], + }; + const sources = { + citations: [], + inlineCitationCandidates: [], + searchResults: [], + }; + + const r1Payload = buildDeepSeekPayload(snapshot, sources, "Final answer"); + const searchPayload = buildDeepSeekPayload({ + ...snapshot, + currentModelLabel: "联网搜索", + }, sources, "Final answer"); + + expect(r1Payload.provider_model).toBe("DeepSeek-R1"); + expect(searchPayload.provider_model).toBeNull(); + }); + it("builds json-safe payloads with null placeholders instead of undefined", () => { const payload = buildDeepSeekPayload({ url: "https://chat.deepseek.com/", diff --git a/apps/desktop-client/src/main/adapters/deepseek.ts b/apps/desktop-client/src/main/adapters/deepseek.ts index bfda460..b285334 100644 --- a/apps/desktop-client/src/main/adapters/deepseek.ts +++ b/apps/desktop-client/src/main/adapters/deepseek.ts @@ -98,6 +98,12 @@ type DeepSeekWaitResult = elapsedMs: number; }; +function isDeepSeekWaitFailure( + result: DeepSeekWaitResult, +): result is Extract { + return !result.ok; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -111,22 +117,117 @@ function normalizeOptionalString(value: unknown): string | null { return trimmed ? trimmed : null; } -function normalizeUrl(value: unknown): string | null { +function normalizeDeepSeekProviderModelLabel(value: unknown): string | null { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return null; + } + + const compact = normalized.replace(/\s+/g, "").toLowerCase(); + if (/^(search|websearch|联网搜索|搜索|网页搜索)$/.test(compact)) { + return null; + } + if (/r1|reasoner|deepthink|深度思考/.test(compact)) { + return "DeepSeek-R1"; + } + + const versionMatch = + normalized.match(/deepseek[-_\s]*(r1|v\d+(?:\.\d+)?)/i) + ?? normalized.match(/\b(r1|v\d+(?:\.\d+)?)\b/i); + if (versionMatch?.[1]) { + return `DeepSeek-${versionMatch[1].toUpperCase()}`; + } + + if (/deepseek/i.test(normalized)) { + return normalized; + } + + return null; +} + +function normalizeUrl(value: unknown, depth = 0): string | null { + if (depth > 3) { + return null; + } + const input = normalizeText(value); if (!input) { return null; } + const candidates = [input]; try { - const url = new URL(input); - if (!/^https?:$/i.test(url.protocol)) { - return null; + const decoded = decodeURIComponent(input); + if (decoded && decoded !== input) { + candidates.push(decoded); } - url.hash = ""; - return url.toString(); } catch { + // Keep the original candidate when the value is not URI-encoded. + } + + for (const candidate of candidates) { + try { + const url = new URL(candidate); + const embeddedURL = extractDeepSeekEmbeddedURL(url, depth + 1); + if (embeddedURL) { + return embeddedURL; + } + if (!/^https?:$/i.test(url.protocol)) { + continue; + } + url.hash = ""; + return url.toString(); + } catch { + const match = candidate.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0]; + if (match && match !== candidate) { + const normalized = normalizeUrl(match, depth + 1); + if (normalized) { + return normalized; + } + } + } + } + + return null; +} + +function extractDeepSeekEmbeddedURL(url: URL, depth = 0): string | null { + if (depth > 3) { return null; } + + const host = url.hostname.toLowerCase(); + const likelyRedirectURL = + /(^|\.)deepseek\.com$/i.test(host) + || /(redirect|jump|out|target|link|source)/i.test(url.pathname); + if (!likelyRedirectURL) { + return null; + } + + const candidateKeys = [ + "url", + "target", + "target_url", + "u", + "redirect", + "redirect_url", + "link", + "source", + "source_url", + ]; + + for (const key of candidateKeys) { + const value = url.searchParams.get(key); + if (!value) { + continue; + } + const normalized = normalizeUrl(value, depth + 1); + if (normalized) { + return normalized; + } + } + + return null; } function normalizeSourceTitle(value: unknown): string | null { @@ -435,11 +536,12 @@ function buildDeepSeekRawResponse( classifiedSources: DeepSeekClassifiedSources, answer: string | null, ): Record { + const providerModel = normalizeDeepSeekProviderModelLabel(snapshot.currentModelLabel); return { platform: "deepseek", page_url: snapshot.url, page_title: snapshot.title ?? null, - provider_model: snapshot.currentModelLabel ?? null, + provider_model: providerModel, provider_request_id: snapshot.providerRequestID ?? null, request_id: snapshot.requestID ?? snapshot.providerRequestID ?? null, answer, @@ -466,9 +568,10 @@ function buildDeepSeekPayload( classifiedSources: DeepSeekClassifiedSources, answer: string | null, ): Record { + const providerModel = normalizeDeepSeekProviderModelLabel(snapshot.currentModelLabel); return { platform: "deepseek", - provider_model: snapshot.currentModelLabel ?? null, + provider_model: providerModel, provider_request_id: snapshot.providerRequestID ?? null, request_id: snapshot.requestID ?? snapshot.providerRequestID ?? null, answer, @@ -748,7 +851,7 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { return; } - const clickedWebpagesTrigger = await page.evaluate(() => { + const webpagesTrigger = await page.evaluate(() => { const normalize = (value: unknown): string | null => { if (typeof value !== "string") { return null; @@ -756,7 +859,7 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { const trimmed = value.trim().replace(/\s+/g, " "); return trimmed || null; }; - const isVisible = (node: Element | null): node is HTMLElement => { + const isVisible = (node: Element | null): boolean => { if (!(node instanceof HTMLElement)) { return false; } @@ -787,19 +890,29 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { || typeof (node as { onclick?: unknown }).onclick === "function"; }; const findClickTarget = (node: HTMLElement): HTMLElement | null => { + let fallback: HTMLElement | null = null; let current: HTMLElement | null = node; - for (let depth = 0; current && depth < 6; depth += 1) { - if (isVisible(current) && (isClickable(current) || isTriggerText(current.textContent || current.getAttribute("aria-label") || ""))) { + for (let depth = 0; current && depth < 9; depth += 1) { + if (!isVisible(current)) { + current = current.parentElement; + continue; + } + const currentText = normalize(current.textContent || current.getAttribute("aria-label") || ""); + if (!fallback && currentText && isTriggerText(currentText)) { + fallback = current; + } + if (isClickable(current)) { return current; } current = current.parentElement; } - return null; + return fallback; }; let bestTarget: HTMLElement | null = null; + let bestText: string | null = null; let bestScore = -1; - for (const node of Array.from(document.querySelectorAll("span, div, button, a")).slice(0, 800)) { + for (const node of Array.from(document.querySelectorAll("span, div, button, a, [role='button']")).slice(0, 1200)) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue; } @@ -816,32 +929,128 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { const rect = target.getBoundingClientRect(); let score = 0; if (isClickable(target)) { - score += 16; + score += 32; } if (normalize(target.textContent || target.getAttribute("aria-label") || "") === text) { score += 12; } if (rect.top >= window.innerHeight * 0.35) { - score += 8; + score += 20; } if (rect.width <= 320) { - score += 4; + score += 8; } if (rect.left >= window.innerWidth * 0.2) { - score += 2; + score += 4; + } + const className = String(target.className || ""); + if (/web|page|source|search|reference|ds-/i.test(className)) { + score += 4; } if (score > bestScore) { bestScore = score; bestTarget = target; + bestText = text; } } - bestTarget?.click(); - return bestTarget !== null; + if (!bestTarget) { + return null; + } + + bestTarget.scrollIntoView({ block: "center", inline: "center" }); + const rect = bestTarget.getBoundingClientRect(); + return { + text: bestText, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + width: rect.width, + height: rect.height, + tagName: bestTarget.tagName, + className: String(bestTarget.className || ""), + }; }).catch(() => false); - if (clickedWebpagesTrigger) { - await page.waitForTimeout(300); + if (webpagesTrigger && typeof webpagesTrigger === "object") { + await page.mouse.click(webpagesTrigger.x, webpagesTrigger.y).catch(() => undefined); + await page.waitForTimeout(500); + if (await waitForDeepSeekSearchResultsPanel(page, 5_000)) { + return; + } + + await page.evaluate(() => { + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed || null; + }; + const isVisible = (node: Element | null): boolean => { + if (!(node instanceof HTMLElement)) { + return false; + } + const style = window.getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isTriggerText = (value: unknown): boolean => { + const text = normalize(value); + return Boolean( + text + && (/^(?:已阅读\s*)?\d+\s*个网页$/i.test(text) + || /^(?:(?:read|viewed)\s*)?\d+\s*(?:web\s*pages?|webpages?)$/i.test(text)), + ); + }; + const isClickable = (node: HTMLElement): boolean => { + const tagName = node.tagName.toLowerCase(); + const role = normalize(node.getAttribute("role")); + const style = window.getComputedStyle(node); + return tagName === "button" + || tagName === "a" + || role === "button" + || node.tabIndex >= 0 + || style.cursor === "pointer" + || typeof (node as { onclick?: unknown }).onclick === "function"; + }; + const dispatchClick = (target: HTMLElement): void => { + target.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true, pointerId: 1 })); + target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + target.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true, pointerId: 1 })); + target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })); + target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }; + + for (const node of Array.from(document.querySelectorAll("span, div, button, a, [role='button']")).slice(0, 1200)) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + const text = normalize(node.textContent || node.getAttribute("aria-label") || ""); + if (!isTriggerText(text)) { + continue; + } + let target: HTMLElement | null = node; + let fallback: HTMLElement | null = node; + for (let depth = 0; target && depth < 9; depth += 1) { + if (isVisible(target) && isClickable(target)) { + dispatchClick(target); + return; + } + if (isVisible(target)) { + fallback = target; + } + target = target.parentElement; + } + if (fallback) { + dispatchClick(fallback); + return; + } + } + }).catch(() => undefined); + await page.waitForTimeout(500); if (await waitForDeepSeekSearchResultsPanel(page)) { return; } @@ -858,6 +1067,66 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { } } +async function maybeExpandDeepSeekReasoning(page: PlaywrightPage): Promise { + await page.evaluate(() => { + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed || null; + }; + const isVisible = (node: Element | null): node is HTMLElement => { + if (!(node instanceof HTMLElement)) { + return false; + } + const style = window.getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isCollapsed = (node: HTMLElement): boolean => { + const expanded = normalize(node.getAttribute("aria-expanded")); + if (expanded === "true") { + return false; + } + if (expanded === "false") { + return true; + } + const className = String(node.className || ""); + return /(collapsed|folded|close|closed)/i.test(className); + }; + const hasVisibleReasoningContent = (node: HTMLElement): boolean => { + const root = + node.closest("[data-message-id], [class*='message'], article, li, section") + ?? node.parentElement; + if (!(root instanceof HTMLElement)) { + return false; + } + return Array.from(root.querySelectorAll(".ds-think-content, [class*='think-content'], [class*='reasoning-content']")) + .some((candidate) => isVisible(candidate) && Boolean(normalize(candidate.textContent || ""))); + }; + + for (const node of Array.from(document.querySelectorAll("button, [role='button'], [class*='think']"))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + const text = normalize(node.textContent || node.getAttribute("aria-label") || ""); + if (!text || !/(已思考|思考过程|思维链|thought|thinking|reasoning)/i.test(text)) { + continue; + } + if (!isCollapsed(node) && hasVisibleReasoningContent(node)) { + continue; + } + node.click(); + break; + } + }).catch(() => undefined); + await page.waitForTimeout(180).catch(() => undefined); +} + async function hasDeepSeekSearchResultsPanel(page: PlaywrightPage): Promise { const heading = page.getByRole("heading", { name: DEEPSEEK_SEARCH_RESULTS_HEADING_PATTERN, @@ -915,35 +1184,84 @@ async function collectDeepSeekSearchResultsPanelLinks(page: PlaywrightPage): Pro const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; - const isExternalUrl = (value: unknown): boolean => { - const text = normalize(value); - if (!text) { - return false; + const normalizeUrl = (value: unknown, depth = 0): string | null => { + if (depth > 3) { + return null; } - try { - const url = new URL(text, window.location.href); - if (!/^https?:$/i.test(url.protocol)) { - return false; - } - return url.hostname !== window.location.hostname; - } catch { - return false; - } - }; - const normalizeUrl = (value: unknown): string | null => { + const text = normalize(value); if (!text) { return null; } + + const candidates = [text]; try { - const url = new URL(text, window.location.href); - if (!/^https?:$/i.test(url.protocol)) { + const decoded = decodeURIComponent(text); + if (decoded && decoded !== text) { + candidates.push(decoded); + } + } catch { + // Keep the original candidate when the value is not URI-encoded. + } + + const unwrapEmbeddedURL = (url: URL, depthValue: number): string | null => { + if (depthValue > 3) { return null; } - url.hash = ""; - return url.toString(); - } catch { + const likelyRedirectURL = + url.hostname === window.location.hostname + || /(^|\.)deepseek\.com$/i.test(url.hostname) + || /(redirect|jump|out|target|link|source)/i.test(url.pathname); + if (!likelyRedirectURL) { + return null; + } + + for (const key of ["url", "target", "target_url", "u", "redirect", "redirect_url", "link", "source", "source_url"]) { + const paramValue = url.searchParams.get(key); + if (!paramValue) { + continue; + } + const normalized = normalizeUrl(paramValue, depthValue + 1); + if (normalized) { + return normalized; + } + } return null; + }; + + for (const candidate of candidates) { + try { + const url = new URL(candidate, window.location.href); + const embeddedURL = unwrapEmbeddedURL(url, depth + 1); + if (embeddedURL) { + return embeddedURL; + } + if (!/^https?:$/i.test(url.protocol)) { + continue; + } + url.hash = ""; + return url.toString(); + } catch { + const match = candidate.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0]; + if (match && match !== candidate) { + const normalized = normalizeUrl(match, depth + 1); + if (normalized) { + return normalized; + } + } + } + } + return null; + }; + const isExternalUrl = (value: unknown): boolean => { + const href = normalizeUrl(value); + if (!href) { + return false; + } + try { + return new URL(href).hostname !== window.location.hostname; + } catch { + return false; } }; const queryText = (root: ParentNode, selectors: string): string | null => { @@ -955,23 +1273,64 @@ async function collectDeepSeekSearchResultsPanelLinks(page: PlaywrightPage): Pro } return null; }; - const extractAnchorMetadata = ( - anchor: HTMLAnchorElement, + const findURLInAttributeValue = (value: unknown): string | null => { + const normalized = normalizeUrl(value); + if (normalized && isExternalUrl(normalized)) { + return normalized; + } + const text = normalize(value); + if (!text) { + return null; + } + const match = text.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0] ?? null; + return match ? normalizeUrl(match) : null; + }; + const findURLInElement = (root: Element): string | null => { + const nodes = [ + root, + ...Array.from(root.querySelectorAll("[href], [data-url], [data-href], [data-link], [data-source-url], [data-target-url], [title], [aria-label]")).slice(0, 40), + ]; + for (const node of nodes) { + for (const attribute of Array.from(node.attributes)) { + const url = findURLInAttributeValue(attribute.value); + if (url) { + return url; + } + } + } + return null; + }; + const extractElementMetadata = ( + element: Element, ): Pick => ({ title: - normalize(anchor.getAttribute("title")) - || queryText(anchor, "[class*='search-view-card__title'], [class*='source-card__title'], [class*='reference-card__title'], [class*='card__title'], h1, h2, h3, h4") + normalize(element.getAttribute("title")) + || queryText(element, "[class*='search-view-card__title'], [class*='source-card__title'], [class*='reference-card__title'], [class*='result-title'], [class*='card__title'], h1, h2, h3, h4") + || normalize(element instanceof HTMLAnchorElement ? element.textContent || "" : "") || null, text: - queryText(anchor, "[class*='search-view-card__snippet'], [class*='source-card__snippet'], [class*='reference-card__snippet'], [class*='card__snippet'], [class*='snippet'], [class*='summary'], p") - || normalize(anchor.textContent || "") + queryText(element, "[class*='search-view-card__snippet'], [class*='source-card__snippet'], [class*='reference-card__snippet'], [class*='result-snippet'], [class*='card__snippet'], [class*='snippet'], [class*='summary'], p") + || normalize(element.textContent || "") || null, siteName: - normalize(anchor.getAttribute("data-site-name")) - || queryText(anchor, "[class*='site'], [class*='domain'], [class*='host'], [class*='origin'], [class*='source-card__source']") - || normalize(anchor.getAttribute("aria-label")) + normalize(element.getAttribute("data-site-name")) + || normalize(element.getAttribute("data-domain")) + || normalize(element.getAttribute("data-host")) + || queryText(element, "[class*='site'], [class*='domain'], [class*='host'], [class*='origin'], [class*='source-card__source']") + || normalize(element.getAttribute("aria-label")) || null, }); + const findResultRoot = (node: Element): Element => { + let current: Element | null = node; + for (let depth = 0; current && depth < 5; depth += 1) { + const className = String((current as HTMLElement).className || ""); + if (/(search|result|source|reference).*card|card.*(search|result|source|reference)/i.test(className)) { + return current; + } + current = current.parentElement; + } + return node; + }; const findSearchResultsPanel = (): HTMLElement | null => { const headingPattern = /^(?:搜索结果|search results)$/i; @@ -1046,22 +1405,39 @@ async function collectDeepSeekSearchResultsPanelLinks(page: PlaywrightPage): Pro const originalScrollTop = searchPanel.scrollTop; const seen = new Set(); const results: EvalSourceLink[] = []; + const pushResult = (url: string | null, metadataRoot: Element): void => { + if (!url || !isExternalUrl(url) || seen.has(url)) { + return; + } + + const metadata = extractElementMetadata(metadataRoot); + seen.add(url); + results.push({ + url, + title: metadata.title, + text: metadata.text, + siteName: metadata.siteName, + kind: "search", + }); + }; const collect = () => { for (const anchor of Array.from(searchPanel.querySelectorAll("a[href]"))) { const href = normalizeUrl(anchor.getAttribute("href") || anchor.href || ""); - if (!href || !isExternalUrl(href) || seen.has(href)) { + if (!href) { continue; } - const metadata = extractAnchorMetadata(anchor); - seen.add(href); - results.push({ - url: href, - title: metadata.title, - text: metadata.text, - siteName: metadata.siteName, - kind: "search", - }); + pushResult(href, findResultRoot(anchor)); + } + + const candidates = Array.from(searchPanel.querySelectorAll( + "[class*='search-view-card'], [class*='search-result'], [class*='result-card'], [class*='source-card'], [class*='reference-card'], [data-url], [data-href], [data-link], [data-source-url], [data-target-url]", + )); + for (const candidate of candidates) { + if (!(candidate instanceof HTMLElement) || !isVisible(candidate)) { + continue; + } + pushResult(findURLInElement(candidate), findResultRoot(candidate)); } }; @@ -1124,6 +1500,33 @@ async function readDeepSeekPageSnapshot( return trimmed || null; }; const normalizeComparable = (value: unknown): string => (normalize(value) || "").toLowerCase(); + const normalizeModelLabel = (value: unknown): string | null => { + const text = normalize(value); + if (!text) { + return null; + } + + const compact = text.replace(/\s+/g, "").toLowerCase(); + if (/^(search|websearch|联网搜索|搜索|网页搜索)$/.test(compact)) { + return null; + } + if (/r1|reasoner|deepthink|深度思考/.test(compact)) { + return "DeepSeek-R1"; + } + + const versionMatch = + text.match(/deepseek[-_\s]*(r1|v\d+(?:\.\d+)?)/i) + ?? text.match(/\b(r1|v\d+(?:\.\d+)?)\b/i); + if (versionMatch?.[1]) { + return `DeepSeek-${versionMatch[1].toUpperCase()}`; + } + + if (/deepseek/i.test(text) && text.length <= 60) { + return text; + } + + return null; + }; const isVisible = (node: Element | null): node is HTMLElement => { if (!(node instanceof HTMLElement)) { return false; @@ -1135,35 +1538,84 @@ async function readDeepSeekPageSnapshot( const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; - const isExternalUrl = (value: unknown): boolean => { - const text = normalize(value); - if (!text) { - return false; + const normalizeUrl = (value: unknown, depth = 0): string | null => { + if (depth > 3) { + return null; } - try { - const url = new URL(text, window.location.href); - if (!/^https?:$/i.test(url.protocol)) { - return false; - } - return url.hostname !== window.location.hostname; - } catch { - return false; - } - }; - const normalizeUrl = (value: unknown): string | null => { + const text = normalize(value); if (!text) { return null; } + + const candidates = [text]; try { - const url = new URL(text, window.location.href); - if (!/^https?:$/i.test(url.protocol)) { + const decoded = decodeURIComponent(text); + if (decoded && decoded !== text) { + candidates.push(decoded); + } + } catch { + // Keep the original candidate when the value is not URI-encoded. + } + + const unwrapEmbeddedURL = (url: URL, depthValue: number): string | null => { + if (depthValue > 3) { return null; } - url.hash = ""; - return url.toString(); - } catch { + const likelyRedirectURL = + url.hostname === window.location.hostname + || /(^|\.)deepseek\.com$/i.test(url.hostname) + || /(redirect|jump|out|target|link|source)/i.test(url.pathname); + if (!likelyRedirectURL) { + return null; + } + + for (const key of ["url", "target", "target_url", "u", "redirect", "redirect_url", "link", "source", "source_url"]) { + const paramValue = url.searchParams.get(key); + if (!paramValue) { + continue; + } + const normalized = normalizeUrl(paramValue, depthValue + 1); + if (normalized) { + return normalized; + } + } return null; + }; + + for (const candidate of candidates) { + try { + const url = new URL(candidate, window.location.href); + const embeddedURL = unwrapEmbeddedURL(url, depth + 1); + if (embeddedURL) { + return embeddedURL; + } + if (!/^https?:$/i.test(url.protocol)) { + continue; + } + url.hash = ""; + return url.toString(); + } catch { + const match = candidate.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0]; + if (match && match !== candidate) { + const normalized = normalizeUrl(match, depth + 1); + if (normalized) { + return normalized; + } + } + } + } + return null; + }; + const isExternalUrl = (value: unknown): boolean => { + const href = normalizeUrl(value); + if (!href) { + return false; + } + try { + return new URL(href).hostname !== window.location.hostname; + } catch { + return false; } }; const linkKey = (url: string, kind: DeepSeekLinkKind): string => `${kind}:${url}`; @@ -1198,23 +1650,64 @@ async function readDeepSeekPageSnapshot( const html = clone.innerHTML.trim(); return html || null; }; - const extractAnchorMetadata = ( - anchor: HTMLAnchorElement, + const findURLInAttributeValue = (value: unknown): string | null => { + const normalized = normalizeUrl(value); + if (normalized && isExternalUrl(normalized)) { + return normalized; + } + const text = normalize(value); + if (!text) { + return null; + } + const match = text.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0] ?? null; + return match ? normalizeUrl(match) : null; + }; + const findURLInElement = (root: Element): string | null => { + const nodes = [ + root, + ...Array.from(root.querySelectorAll("[href], [data-url], [data-href], [data-link], [data-source-url], [data-target-url], [title], [aria-label]")).slice(0, 40), + ]; + for (const node of nodes) { + for (const attribute of Array.from(node.attributes)) { + const url = findURLInAttributeValue(attribute.value); + if (url) { + return url; + } + } + } + return null; + }; + const extractElementMetadata = ( + element: Element, ): Pick => ({ title: - normalize(anchor.getAttribute("title")) - || queryText(anchor, "[class*='search-view-card__title'], [class*='source-card__title'], [class*='reference-card__title'], [class*='card__title'], h1, h2, h3, h4") + normalize(element.getAttribute("title")) + || queryText(element, "[class*='search-view-card__title'], [class*='source-card__title'], [class*='reference-card__title'], [class*='result-title'], [class*='card__title'], h1, h2, h3, h4") + || normalize(element instanceof HTMLAnchorElement ? element.textContent || "" : "") || null, text: - queryText(anchor, "[class*='search-view-card__snippet'], [class*='source-card__snippet'], [class*='reference-card__snippet'], [class*='card__snippet'], [class*='snippet'], [class*='summary'], p") - || normalize(anchor.textContent || "") + queryText(element, "[class*='search-view-card__snippet'], [class*='source-card__snippet'], [class*='reference-card__snippet'], [class*='result-snippet'], [class*='card__snippet'], [class*='snippet'], [class*='summary'], p") + || normalize(element.textContent || "") || null, siteName: - normalize(anchor.getAttribute("data-site-name")) - || queryText(anchor, "[class*='site'], [class*='domain'], [class*='host'], [class*='origin'], [class*='source-card__source']") - || normalize(anchor.getAttribute("aria-label")) + normalize(element.getAttribute("data-site-name")) + || normalize(element.getAttribute("data-domain")) + || normalize(element.getAttribute("data-host")) + || queryText(element, "[class*='site'], [class*='domain'], [class*='host'], [class*='origin'], [class*='source-card__source']") + || normalize(element.getAttribute("aria-label")) || null, }); + const findResultRoot = (node: Element): Element => { + let current: Element | null = node; + for (let depth = 0; current && depth < 5; depth += 1) { + const className = String((current as HTMLElement).className || ""); + if (/(search|result|source|reference).*card|card.*(search|result|source|reference)/i.test(className)) { + return current; + } + current = current.parentElement; + } + return node; + }; const addLinksFromRoot = ( root: Element | null, kind: DeepSeekLinkKind, @@ -1231,6 +1724,9 @@ async function readDeepSeekPageSnapshot( if (!isVisible(anchor)) { continue; } + if (kind !== "search" && anchor.closest(".ds-think-content, [class*='think-content'], [class*='reasoning-content']")) { + continue; + } const href = normalizeUrl(anchor.getAttribute("href") || anchor.href || ""); if (!href || !isExternalUrl(href)) { continue; @@ -1239,7 +1735,40 @@ async function readDeepSeekPageSnapshot( if (seen.has(key)) { continue; } - const metadata = extractAnchorMetadata(anchor); + const metadata = extractElementMetadata(kind === "search" ? findResultRoot(anchor) : anchor); + seen.add(key); + bucket.push({ + url: href, + title: metadata.title, + text: metadata.text, + siteName: metadata.siteName, + kind, + }); + } + + if (kind !== "search") { + return; + } + + const candidates = Array.from(root.querySelectorAll( + "[class*='search-view-card'], [class*='search-result'], [class*='result-card'], [class*='source-card'], [class*='reference-card'], [data-url], [data-href], [data-link], [data-source-url], [data-target-url]", + )); + for (const candidate of candidates) { + if (!(candidate instanceof HTMLElement) || !isVisible(candidate)) { + continue; + } + if (kind !== "search" && candidate.closest(".ds-think-content, [class*='think-content'], [class*='reasoning-content']")) { + continue; + } + const href = findURLInElement(candidate); + if (!href || !isExternalUrl(href)) { + continue; + } + const key = linkKey(href, kind); + if (seen.has(key)) { + continue; + } + const metadata = extractElementMetadata(findResultRoot(candidate)); seen.add(key); bucket.push({ url: href, @@ -1288,18 +1817,32 @@ async function readDeepSeekPageSnapshot( continue; } const record = value as EvalRecord; - const rawUrl = record.url ?? record.href ?? record.link ?? record.uri; + const rawUrl = + record.url + ?? record.href + ?? record.link + ?? record.uri + ?? record.webUrl + ?? record.web_url + ?? record.sourceUrl + ?? record.source_url + ?? record.targetUrl + ?? record.target_url + ?? record.pcUrl + ?? record.pc_url + ?? record.mobileUrl + ?? record.mobile_url; const url = normalizeUrl(rawUrl); if (url && isExternalUrl(url)) { - const kind: DeepSeekLinkKind = /search|result/i.test(path) ? "search" : "source"; + const kind: DeepSeekLinkKind = /search|result|reference|citation|source|web|page|card/i.test(path) ? "search" : "source"; const key = linkKey(url, kind); if (!seen.has(key)) { seen.add(key); results.push({ url, - title: normalize(record.title ?? record.name ?? record.label), - text: normalize(record.text ?? record.snippet ?? record.description), - siteName: normalize(record.site_name ?? record.siteName ?? record.domain ?? record.host), + title: normalize(record.title ?? record.name ?? record.label ?? record.summaryTitle ?? record.summary_title), + text: normalize(record.text ?? record.snippet ?? record.description ?? record.summary ?? record.content), + siteName: normalize(record.site_name ?? record.siteName ?? record.source ?? record.domain ?? record.host), kind, }); } @@ -1458,6 +2001,25 @@ async function readDeepSeekPageSnapshot( }; }); + const reasoningRoots = Array.from(document.querySelectorAll( + ".ds-think-content, [class*='think-content'], [class*='reasoning-content']", + )) + .filter((node) => isVisible(node)) + .map((node) => { + const text = normalize(node.textContent || ""); + const links: EvalSourceLink[] = []; + const seen = new Set(); + addLinksFromRoot(node, "search", links, seen); + const rect = node.getBoundingClientRect(); + return { + node, + text, + links, + top: Math.round(rect.top), + }; + }) + .filter((candidate) => candidate.text || candidate.links.length); + const answerCandidates = markdownNodes.filter((candidate) => { if (candidate.inThink) { return false; @@ -1474,11 +2036,38 @@ async function readDeepSeekPageSnapshot( const thinkCandidates = markdownNodes.filter((candidate) => candidate.inThink && candidate.text); const thinkCandidate = thinkCandidates.at(-1) || null; + const reasoningRoot = reasoningRoots.at(-1) || null; const inlineLinks = answerCandidate?.links ?? []; const sourcePanelLinks = answerCandidate?.containerLinks ?? []; - const searchPanelLinks: EvalSourceLink[] = []; const searchSeen = new Set(); + const searchPanelLinks: EvalSourceLink[] = []; + const addSearchLink = (item: EvalSourceLink): void => { + const key = linkKey(item.url, "search"); + if (searchSeen.has(key)) { + return; + } + searchSeen.add(key); + searchPanelLinks.push({ + ...item, + kind: "search", + }); + }; + const reasoningSearchLinks: EvalSourceLink[] = []; + const reasoningSearchSeen = new Set(); + if (reasoningRoot?.node) { + addLinksFromRoot(reasoningRoot.node, "search", reasoningSearchLinks, reasoningSearchSeen); + } + if (thinkCandidate?.node) { + addLinksFromRoot(thinkCandidate.node, "search", reasoningSearchLinks, reasoningSearchSeen); + } + if (thinkCandidate?.container) { + addLinksFromRoot(thinkCandidate.container, "search", reasoningSearchLinks, reasoningSearchSeen); + } + for (const item of reasoningSearchLinks) { + addSearchLink(item); + } + const searchResultsPanel = findSearchResultsPanel(); if (searchResultsPanel) { addLinksFromRoot(searchResultsPanel, "search", searchPanelLinks, searchSeen); @@ -1486,14 +2075,10 @@ async function readDeepSeekPageSnapshot( for (const item of collectWindowStateLinks()) { if (item.kind === "search") { - if (!searchResultsPanel) { + if (!searchResultsPanel && searchPanelLinks.length === 0) { continue; } - const key = linkKey(item.url, "search"); - if (!searchSeen.has(key)) { - searchSeen.add(key); - searchPanelLinks.push(item); - } + addSearchLink(item); continue; } @@ -1504,13 +2089,35 @@ async function readDeepSeekPageSnapshot( } } - const currentModelLabel = - Array.from(document.querySelectorAll("button, [role='button'], [class*='model']")).map((node) => normalize(node.textContent || "")).find((text) => { - if (!text) { - return false; - } - return /deepseek|think|search/i.test(text) && text.length <= 40; - }) || null; + const modelCandidates = Array.from(document.querySelectorAll("button, [role='button'], [class*='model']")).flatMap((node) => { + if (!isVisible(node)) { + return []; + } + const text = normalize(node.textContent || node.getAttribute("aria-label") || ""); + const label = normalizeModelLabel(text); + if (!label) { + return []; + } + + const className = String((node as HTMLElement).className || ""); + const selected = + node.getAttribute("aria-pressed") === "true" + || node.getAttribute("aria-selected") === "true" + || node.getAttribute("data-state") === "on" + || /selected|active|checked|current|on/.test(className); + let score = selected ? 30 : 0; + if (/r1|reasoner|deepthink|深度思考/i.test(text || "")) { + score += 12; + } + if (/deepseek/i.test(text || "")) { + score += 8; + } + if (String((node as HTMLElement).className || "").toLowerCase().includes("model")) { + score += 6; + } + return [{ label, score }]; + }); + const currentModelLabel = modelCandidates.sort((left, right) => right.score - left.score).at(0)?.label ?? null; const providerRequestID = normalize(document.documentElement.getAttribute("data-request-id")) @@ -1524,7 +2131,7 @@ async function readDeepSeekPageSnapshot( const signatureParts = [ normalize(answerCandidate?.text), - normalize(thinkCandidate?.text), + normalize(thinkCandidate?.text ?? reasoningRoot?.text), ...inlineLinks.map((item) => item.url), ...sourcePanelLinks.map((item) => item.url), ...searchPanelLinks.map((item) => item.url), @@ -1543,7 +2150,7 @@ async function readDeepSeekPageSnapshot( sendDisabled, answer: answerCandidate?.html ?? answerCandidate?.text ?? null, answerText: answerCandidate?.text ?? null, - reasoning: thinkCandidate?.text ?? null, + reasoning: thinkCandidate?.text ?? reasoningRoot?.text ?? null, signatureParts, currentModelLabel, providerRequestID, @@ -1739,6 +2346,7 @@ export const deepseekAdapter: MonitorAdapter = { context.reportProgress("deepseek.wait_answer"); const waitResult = await waitForDeepSeekAnswer(page, questionText, context.signal); context.reportProgress("deepseek.reveal_sources"); + await maybeExpandDeepSeekReasoning(page).catch(() => undefined); await maybeRevealDeepSeekSources(page).catch(() => undefined); const revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(() => []); const finalSnapshot = await readDeepSeekPageSnapshot(page, questionText).catch(() => waitResult.finalSnapshot); @@ -1753,7 +2361,7 @@ export const deepseekAdapter: MonitorAdapter = { searchPanelLinks: mergedSnapshot.searchPanelLinks, }); - if (!waitResult.ok) { + if (isDeepSeekWaitFailure(waitResult)) { const detail = waitResult.detail ?? waitResult.error; if (waitResult.error === "deepseek_login_required") { return {