abacc1f421
Challenge detection previously regex-matched the entire page innerText, so chat transcripts or console copy containing words like 验证码/安全验证/verify flagged healthy accounts as challenge_required. Detection is now scoped to real evidence only (visible captcha vendor widgets, short modal copy, sparse interstitial pages) via a shared challenge-signals module used by the generic AI probe, doubao/kimi/qwen auth rules, and the deepseek runtime snapshot. Platform API error codes (e.g. baijiahao_challenge_required) keep working. Account health also could not recover after lid-close sleep or a network drop: offline probes misread cached console pages as expired, and expired records null out nextProbeAt so they were never re-probed. Probing is now skipped while offline (network_error backoff instead), and powerMonitor resume/unlock plus an offline-to-online transition watch schedule jittered recovery probes for all tracked accounts, including expired ones. Challenge rechecks wait 8s so transient widgets during page load no longer stick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2969 lines
93 KiB
TypeScript
2969 lines
93 KiB
TypeScript
import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types'
|
|
import type { Page as PlaywrightPage } from 'playwright-core'
|
|
|
|
import type { MonitorAdapter } from './base'
|
|
import { normalizeText } from './common'
|
|
|
|
const DEEPSEEK_BOOTSTRAP_URL = 'https://chat.deepseek.com/'
|
|
const DEEPSEEK_PAGE_READY_TIMEOUT_MS = 20_000
|
|
const DEEPSEEK_QUERY_TIMEOUT_MS = 120_000
|
|
const DEEPSEEK_QUERY_POLL_INTERVAL_MS = 1_200
|
|
const DEEPSEEK_STABLE_POLLS_REQUIRED = 4
|
|
const DEEPSEEK_MAX_SEARCH_RESULT_LINKS = 96
|
|
const DEEPSEEK_MAX_CITATION_LINKS = 96
|
|
const DEEPSEEK_MAX_INLINE_CITATION_LINKS = 32
|
|
const DEEPSEEK_SEARCH_RESULTS_BUTTON_PATTERN =
|
|
/view search results|search results|搜索结果|查看搜索结果/i
|
|
const DEEPSEEK_SEARCH_RESULTS_HEADING_PATTERN = /^(?:搜索结果|search results)$/i
|
|
const DEEPSEEK_WEBPAGES_TRIGGER_ZH_PATTERN = /^(?:已阅读\s*)?\d+\s*个网页$/i
|
|
const DEEPSEEK_WEBPAGES_TRIGGER_EN_PATTERN =
|
|
/^(?:(?:read|viewed)\s*)?\d+\s*(?:web\s*pages?|webpages?)$/i
|
|
const DEEPSEEK_REASONING_SEARCH_TRIGGER_ZH_PATTERN = /^搜索到\s*\d+\s*个网页$/i
|
|
const DEEPSEEK_REASONING_SEARCH_TRIGGER_EN_PATTERN =
|
|
/^(?:found|searched)\s*\d+\s*(?:web\s*pages?|webpages?)$/i
|
|
const DEEPSEEK_BUSY_TEXT_PATTERN =
|
|
/(searching|thinking|generating|please wait until the message is generated|搜索中|思考中|生成中|停止生成|stop generating)/i
|
|
const DEEPSEEK_BUSY_DESCRIPTOR_PATTERN =
|
|
/(searching|thinking|generating|stop generating|loading|spinner|搜索中|思考中|生成中|停止生成)/i
|
|
const DEEPSEEK_BUSY_PLACEHOLDER_PATTERN = /(site_logo_loading|site_logo_fallback)/i
|
|
const DEEPSEEK_TOGGLE_SELECTED_PATTERN =
|
|
/(ds-toggle-button--selected|(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$))/i
|
|
const DEEPSEEK_DEEP_THINK_PATTERN = /深度思考|deep\s*think|reasoner/i
|
|
|
|
type DeepSeekRawSourceLink = {
|
|
url: string
|
|
title: string | null
|
|
text: string | null
|
|
siteName: string | null
|
|
kind: 'inline' | 'source' | 'search'
|
|
}
|
|
|
|
type DeepSeekSourceInput = DeepSeekRawSourceLink | MonitoringSourceItem
|
|
|
|
type DeepSeekLinkKind = DeepSeekRawSourceLink['kind']
|
|
|
|
type DeepSeekWindowState = Window &
|
|
typeof globalThis & {
|
|
__NEXT_DATA__?: unknown
|
|
__INITIAL_STATE__?: unknown
|
|
__APP_STATE__?: unknown
|
|
__NUXT__?: unknown
|
|
__STORE__?: {
|
|
getState?: () => unknown
|
|
}
|
|
}
|
|
|
|
type DeepSeekPageSnapshot = {
|
|
url: string
|
|
title: string | null
|
|
loginRequired: boolean
|
|
loginReason: string | null
|
|
challengeRequired: boolean
|
|
challengeReason: string | null
|
|
busy: boolean
|
|
busySignals: string[]
|
|
composerValue: string | null
|
|
sendDisabled: boolean | null
|
|
answer: string | null
|
|
answerText: string | null
|
|
reasoning: string | null
|
|
signature: string | null
|
|
currentModelLabel: string | null
|
|
providerRequestID: string | null
|
|
requestID: string | null
|
|
inlineLinks: DeepSeekRawSourceLink[]
|
|
sourcePanelLinks: DeepSeekRawSourceLink[]
|
|
searchPanelLinks: DeepSeekRawSourceLink[]
|
|
}
|
|
|
|
type DeepSeekPageSnapshotDraft = Omit<
|
|
DeepSeekPageSnapshot,
|
|
'busy' | 'busySignals' | 'signature'
|
|
> & {
|
|
bodyText: string | null
|
|
busyDescriptors: string[]
|
|
signatureParts: string[]
|
|
}
|
|
|
|
type DeepSeekClassifiedSources = {
|
|
inlineCitationCandidates: MonitoringSourceItem[]
|
|
citations: MonitoringSourceItem[]
|
|
searchResults: MonitoringSourceItem[]
|
|
}
|
|
|
|
type DeepSeekWaitResult =
|
|
| {
|
|
ok: true
|
|
finalSnapshot: DeepSeekPageSnapshot
|
|
stablePollCount: number
|
|
elapsedMs: number
|
|
}
|
|
| {
|
|
ok: false
|
|
error: string
|
|
detail: string | null
|
|
finalSnapshot: DeepSeekPageSnapshot
|
|
stablePollCount: number
|
|
elapsedMs: number
|
|
}
|
|
|
|
function isDeepSeekWaitFailure(
|
|
result: DeepSeekWaitResult,
|
|
): result is Extract<DeepSeekWaitResult, { ok: false }> {
|
|
return !result.ok
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function normalizeOptionalString(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null
|
|
}
|
|
|
|
const trimmed = value.trim()
|
|
return trimmed ? trimmed : null
|
|
}
|
|
|
|
function 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'
|
|
}
|
|
|
|
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 decoded = decodeURIComponent(input)
|
|
if (decoded && decoded !== input) {
|
|
candidates.push(decoded)
|
|
}
|
|
} 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 {
|
|
const title = normalizeText(value)
|
|
if (!title) {
|
|
return null
|
|
}
|
|
|
|
const markerCandidate = title
|
|
.replace(/\s+/g, '')
|
|
.replace(/^[\[({(【]+/, '')
|
|
.replace(/[\])})】]+$/, '')
|
|
if (/^[-–—]?\d+$/.test(markerCandidate)) {
|
|
return null
|
|
}
|
|
|
|
return title
|
|
}
|
|
|
|
function getSourceNormalizedUrl(source: DeepSeekSourceInput): string | null | undefined {
|
|
return 'normalized_url' in source ? source.normalized_url : null
|
|
}
|
|
|
|
function getSourceText(source: DeepSeekSourceInput): string | null | undefined {
|
|
return 'text' in source ? source.text : null
|
|
}
|
|
|
|
function getSourceSiteName(source: DeepSeekSourceInput): string | null | undefined {
|
|
if ('site_name' in source) {
|
|
return source.site_name
|
|
}
|
|
if ('siteName' in source) {
|
|
return source.siteName
|
|
}
|
|
return null
|
|
}
|
|
|
|
function mergeSourceItemMetadata(
|
|
item: MonitoringSourceItem,
|
|
metadata: MonitoringSourceItem | null | undefined,
|
|
): MonitoringSourceItem {
|
|
if (!metadata) {
|
|
return item
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
title: item.title ?? metadata.title ?? null,
|
|
site_name:
|
|
(item.site_name && item.site_name !== item.host ? item.site_name : null) ??
|
|
metadata.site_name ??
|
|
item.site_name ??
|
|
null,
|
|
host: item.host ?? metadata.host ?? null,
|
|
}
|
|
}
|
|
|
|
function enrichSourceItemsWithMetadata(
|
|
items: MonitoringSourceItem[],
|
|
metadataItems: MonitoringSourceItem[],
|
|
): MonitoringSourceItem[] {
|
|
const metadataByUrl = new Map(
|
|
metadataItems
|
|
.map((item) => [item.normalized_url ?? item.url, item] as const)
|
|
.filter(([key]) => Boolean(key)),
|
|
)
|
|
|
|
return items.map((item) =>
|
|
mergeSourceItemMetadata(item, metadataByUrl.get(item.normalized_url ?? item.url)),
|
|
)
|
|
}
|
|
|
|
function detectDeepSeekBusySignals(input: {
|
|
bodyText: string | null | undefined
|
|
descriptors: Array<string | null | undefined>
|
|
}): string[] {
|
|
const busySignals: string[] = []
|
|
|
|
if (input.bodyText && DEEPSEEK_BUSY_TEXT_PATTERN.test(input.bodyText)) {
|
|
busySignals.push('body_text')
|
|
}
|
|
|
|
for (const descriptor of input.descriptors) {
|
|
const normalized = normalizeText(descriptor)
|
|
if (!normalized || DEEPSEEK_BUSY_PLACEHOLDER_PATTERN.test(normalized)) {
|
|
continue
|
|
}
|
|
if (!DEEPSEEK_BUSY_DESCRIPTOR_PATTERN.test(normalized)) {
|
|
continue
|
|
}
|
|
busySignals.push(normalized)
|
|
if (busySignals.length >= 6) {
|
|
break
|
|
}
|
|
}
|
|
|
|
return busySignals
|
|
}
|
|
|
|
function isDeepSeekToggleSelected(value: string | null | undefined): boolean {
|
|
const normalized = normalizeText(value)
|
|
if (!normalized) {
|
|
return false
|
|
}
|
|
|
|
return DEEPSEEK_TOGGLE_SELECTED_PATTERN.test(normalized)
|
|
}
|
|
|
|
function isDeepSeekWebpagesTriggerText(value: string | null | undefined): boolean {
|
|
const normalized = normalizeText(value)
|
|
if (!normalized) {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
DEEPSEEK_WEBPAGES_TRIGGER_ZH_PATTERN.test(normalized) ||
|
|
DEEPSEEK_WEBPAGES_TRIGGER_EN_PATTERN.test(normalized)
|
|
)
|
|
}
|
|
|
|
function isDeepSeekReasoningSearchTriggerText(value: string | null | undefined): boolean {
|
|
const normalized = normalizeText(value)
|
|
if (!normalized) {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
DEEPSEEK_REASONING_SEARCH_TRIGGER_ZH_PATTERN.test(normalized) ||
|
|
DEEPSEEK_REASONING_SEARCH_TRIGGER_EN_PATTERN.test(normalized)
|
|
)
|
|
}
|
|
|
|
function buildSourceItem(source: DeepSeekSourceInput): MonitoringSourceItem | null {
|
|
const url = normalizeUrl(getSourceNormalizedUrl(source) ?? source.url)
|
|
if (!url) {
|
|
return null
|
|
}
|
|
|
|
let host: string | null = null
|
|
try {
|
|
host = new URL(url).hostname || null
|
|
} catch {
|
|
host = null
|
|
}
|
|
|
|
const title = normalizeSourceTitle(source.title) ?? normalizeSourceTitle(getSourceText(source))
|
|
const siteName = normalizeText(getSourceSiteName(source) ?? host)
|
|
return {
|
|
url,
|
|
title,
|
|
site_name: siteName,
|
|
normalized_url: url,
|
|
host,
|
|
}
|
|
}
|
|
|
|
function dedupeSourceItems(
|
|
items: Array<DeepSeekRawSourceLink | MonitoringSourceItem>,
|
|
): MonitoringSourceItem[] {
|
|
const keyed = new Map<string, MonitoringSourceItem>()
|
|
|
|
for (const item of items) {
|
|
const built = buildSourceItem(item)
|
|
if (!built) {
|
|
continue
|
|
}
|
|
|
|
const key = built.normalized_url ?? built.url
|
|
if (!key) {
|
|
continue
|
|
}
|
|
|
|
const existing = keyed.get(key)
|
|
if (!existing) {
|
|
keyed.set(key, built)
|
|
continue
|
|
}
|
|
|
|
keyed.set(key, {
|
|
...existing,
|
|
title: existing.title ?? built.title ?? null,
|
|
site_name: existing.site_name ?? built.site_name ?? null,
|
|
host: existing.host ?? built.host ?? null,
|
|
})
|
|
}
|
|
|
|
return Array.from(keyed.values())
|
|
}
|
|
|
|
function mergeDeepSeekRawSourceLinks(
|
|
...groups: DeepSeekRawSourceLink[][]
|
|
): DeepSeekRawSourceLink[] {
|
|
const keyed = new Map<string, DeepSeekRawSourceLink>()
|
|
|
|
for (const group of groups) {
|
|
for (const item of group) {
|
|
const url = normalizeUrl(item.url)
|
|
if (!url) {
|
|
continue
|
|
}
|
|
|
|
const key = `${item.kind}:${url}`
|
|
const existing = keyed.get(key)
|
|
const normalizedItem: DeepSeekRawSourceLink = {
|
|
...item,
|
|
url,
|
|
}
|
|
if (!existing) {
|
|
keyed.set(key, normalizedItem)
|
|
continue
|
|
}
|
|
|
|
keyed.set(key, {
|
|
...existing,
|
|
title: existing.title ?? normalizedItem.title ?? null,
|
|
text: existing.text ?? normalizedItem.text ?? null,
|
|
siteName: existing.siteName ?? normalizedItem.siteName ?? null,
|
|
})
|
|
}
|
|
}
|
|
|
|
return Array.from(keyed.values())
|
|
}
|
|
|
|
function classifyDeepseekSources(input: {
|
|
inlineLinks: Array<DeepSeekRawSourceLink | MonitoringSourceItem>
|
|
sourcePanelLinks: Array<DeepSeekRawSourceLink | MonitoringSourceItem>
|
|
searchPanelLinks: Array<DeepSeekRawSourceLink | MonitoringSourceItem>
|
|
}): DeepSeekClassifiedSources {
|
|
const searchResultsWithMetadata = dedupeSourceItems(input.searchPanelLinks)
|
|
const inlineCitationCandidates = enrichSourceItemsWithMetadata(
|
|
dedupeSourceItems([...input.inlineLinks, ...input.sourcePanelLinks]),
|
|
searchResultsWithMetadata,
|
|
).slice(0, DEEPSEEK_MAX_INLINE_CITATION_LINKS)
|
|
const citations = searchResultsWithMetadata.slice(0, DEEPSEEK_MAX_CITATION_LINKS)
|
|
const searchResults = searchResultsWithMetadata.slice(0, DEEPSEEK_MAX_SEARCH_RESULT_LINKS)
|
|
|
|
return {
|
|
inlineCitationCandidates,
|
|
citations,
|
|
searchResults,
|
|
}
|
|
}
|
|
|
|
function resolveDeepSeekCitationPanelLinks(
|
|
snapshotSearchPanelLinks: DeepSeekRawSourceLink[],
|
|
revealedSearchPanelLinks: DeepSeekRawSourceLink[],
|
|
): DeepSeekRawSourceLink[] {
|
|
if (revealedSearchPanelLinks.length > 0) {
|
|
return mergeDeepSeekRawSourceLinks(revealedSearchPanelLinks)
|
|
}
|
|
return mergeDeepSeekRawSourceLinks(snapshotSearchPanelLinks)
|
|
}
|
|
|
|
function extractQuestionText(payload: Record<string, unknown>): string {
|
|
const candidates = [
|
|
payload.question_text,
|
|
payload.questionText,
|
|
payload.query,
|
|
payload.question,
|
|
payload.prompt,
|
|
payload.content,
|
|
payload.title,
|
|
]
|
|
|
|
for (const candidate of candidates) {
|
|
const text = normalizeOptionalString(candidate)
|
|
if (text) {
|
|
return text
|
|
}
|
|
}
|
|
|
|
throw new Error('deepseek_question_text_missing')
|
|
}
|
|
|
|
function buildAdapterError(
|
|
code: string,
|
|
message: string,
|
|
extras: Record<string, JsonValue> = {},
|
|
): Record<string, JsonValue> {
|
|
return {
|
|
code,
|
|
message,
|
|
...extras,
|
|
}
|
|
}
|
|
|
|
function toJsonValue(value: unknown, depth = 0): JsonValue {
|
|
if (value == null || depth > 12) {
|
|
return null
|
|
}
|
|
|
|
if (typeof value === 'string' || typeof value === 'boolean') {
|
|
return value
|
|
}
|
|
|
|
if (typeof value === 'number') {
|
|
return Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => toJsonValue(item, depth + 1))
|
|
}
|
|
|
|
if (!isRecord(value)) {
|
|
return null
|
|
}
|
|
|
|
const result: Record<string, JsonValue> = {}
|
|
for (const [key, item] of Object.entries(value)) {
|
|
result[key] = toJsonValue(item, depth + 1)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] {
|
|
return items.map((item) => ({
|
|
url: item.url,
|
|
title: item.title ?? null,
|
|
site_name: item.site_name ?? null,
|
|
site_key: item.site_key ?? null,
|
|
normalized_url: item.normalized_url ?? null,
|
|
host: item.host ?? null,
|
|
registrable_domain: item.registrable_domain ?? null,
|
|
subdomain: item.subdomain ?? null,
|
|
suffix: item.suffix ?? null,
|
|
article_id: item.article_id ?? null,
|
|
publish_record_id: item.publish_record_id ?? null,
|
|
resolution_status: item.resolution_status ?? null,
|
|
resolution_confidence: item.resolution_confidence ?? null,
|
|
}))
|
|
}
|
|
|
|
function buildDeepSeekRawResponse(
|
|
snapshot: DeepSeekPageSnapshot,
|
|
classifiedSources: DeepSeekClassifiedSources,
|
|
answer: string | null,
|
|
): Record<string, JsonValue> {
|
|
const providerModel = normalizeDeepSeekProviderModelLabel(snapshot.currentModelLabel)
|
|
return {
|
|
platform: 'deepseek',
|
|
page_url: snapshot.url,
|
|
page_title: snapshot.title ?? null,
|
|
provider_model: providerModel,
|
|
provider_request_id: snapshot.providerRequestID ?? null,
|
|
request_id: snapshot.requestID ?? snapshot.providerRequestID ?? null,
|
|
answer,
|
|
answer_text: snapshot.answerText ?? null,
|
|
reasoning: snapshot.reasoning ?? null,
|
|
busy: snapshot.busy,
|
|
busy_signals: snapshot.busySignals,
|
|
send_disabled: snapshot.sendDisabled,
|
|
signature: snapshot.signature ?? null,
|
|
citation_count: classifiedSources.citations.length,
|
|
inline_citation_candidate_count: classifiedSources.inlineCitationCandidates.length,
|
|
search_result_count: classifiedSources.searchResults.length,
|
|
inline_citation_candidates: toJsonSources(classifiedSources.inlineCitationCandidates),
|
|
citations: toJsonSources(classifiedSources.citations),
|
|
search_results: toJsonSources(classifiedSources.searchResults),
|
|
inline_links: toJsonValue(snapshot.inlineLinks),
|
|
source_panel_links: toJsonValue(snapshot.sourcePanelLinks),
|
|
search_panel_links: toJsonValue(snapshot.searchPanelLinks),
|
|
}
|
|
}
|
|
|
|
function buildDeepSeekPayload(
|
|
snapshot: DeepSeekPageSnapshot,
|
|
classifiedSources: DeepSeekClassifiedSources,
|
|
answer: string | null,
|
|
): Record<string, JsonValue> {
|
|
const providerModel = normalizeDeepSeekProviderModelLabel(snapshot.currentModelLabel)
|
|
return {
|
|
platform: 'deepseek',
|
|
provider_model: providerModel,
|
|
provider_request_id: snapshot.providerRequestID ?? null,
|
|
request_id: snapshot.requestID ?? snapshot.providerRequestID ?? null,
|
|
answer,
|
|
citations: toJsonSources(classifiedSources.citations),
|
|
search_results: toJsonSources(classifiedSources.searchResults),
|
|
raw_response_json: buildDeepSeekRawResponse(snapshot, classifiedSources, answer),
|
|
}
|
|
}
|
|
|
|
function answerScore(snapshot: DeepSeekPageSnapshot): number {
|
|
const answerLength = snapshot.answerText?.length ?? snapshot.answer?.length ?? 0
|
|
const linkCount =
|
|
snapshot.inlineLinks.length +
|
|
snapshot.sourcePanelLinks.length +
|
|
snapshot.searchPanelLinks.length
|
|
return answerLength * 8 + linkCount * 13 + (snapshot.reasoning?.length ?? 0)
|
|
}
|
|
|
|
function isObservationComplete(snapshot: {
|
|
answer: string | null
|
|
busy: boolean
|
|
loginRequired: boolean
|
|
challengeRequired: boolean
|
|
signature: string | null
|
|
}): boolean {
|
|
return Boolean(
|
|
snapshot.answer &&
|
|
snapshot.signature &&
|
|
!snapshot.busy &&
|
|
!snapshot.loginRequired &&
|
|
!snapshot.challengeRequired,
|
|
)
|
|
}
|
|
|
|
function safePageURL(page: PlaywrightPage): string {
|
|
try {
|
|
return page.url()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
if (signal?.aborted) {
|
|
throw new Error('adapter_aborted')
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
resolve()
|
|
}, ms)
|
|
|
|
const onAbort = () => {
|
|
clearTimeout(timer)
|
|
reject(new Error('adapter_aborted'))
|
|
}
|
|
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
async function ensurePageOnDeepSeekChat(page: PlaywrightPage, signal: AbortSignal): Promise<void> {
|
|
if (signal.aborted) {
|
|
throw new Error('adapter_aborted')
|
|
}
|
|
|
|
const currentURL = safePageURL(page)
|
|
if (!currentURL || !currentURL.startsWith('https://chat.deepseek.com/')) {
|
|
await page.goto(DEEPSEEK_BOOTSTRAP_URL, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: DEEPSEEK_PAGE_READY_TIMEOUT_MS,
|
|
})
|
|
}
|
|
|
|
await page
|
|
.waitForLoadState('domcontentloaded', {
|
|
timeout: DEEPSEEK_PAGE_READY_TIMEOUT_MS,
|
|
})
|
|
.catch(() => undefined)
|
|
await page
|
|
.waitForLoadState('networkidle', {
|
|
timeout: 3_000,
|
|
})
|
|
.catch(() => undefined)
|
|
|
|
if (signal.aborted) {
|
|
throw new Error('adapter_aborted')
|
|
}
|
|
}
|
|
|
|
async function resolveDeepSeekEditor(page: PlaywrightPage): Promise<{
|
|
locator: ReturnType<PlaywrightPage['locator']>
|
|
mode: 'textarea' | 'contenteditable'
|
|
}> {
|
|
const candidates: Array<{ selector: string; mode: 'textarea' | 'contenteditable' }> = [
|
|
{ selector: 'textarea.ds-textarea', mode: 'textarea' },
|
|
{ selector: '.ds-textarea textarea', mode: 'textarea' },
|
|
{ selector: "textarea[placeholder*='DeepSeek']", mode: 'textarea' },
|
|
{ selector: "textarea[placeholder*='消息']", mode: 'textarea' },
|
|
{ selector: 'textarea', mode: 'textarea' },
|
|
{ selector: "[role='textbox'][contenteditable='true']", mode: 'contenteditable' },
|
|
{ selector: "[contenteditable='true'][data-lexical-editor='true']", mode: 'contenteditable' },
|
|
{ selector: "div[contenteditable='true']", mode: 'contenteditable' },
|
|
]
|
|
|
|
for (const candidate of candidates) {
|
|
const locator = page.locator(candidate.selector).first()
|
|
const count = await locator.count().catch(() => 0)
|
|
if (!count) {
|
|
continue
|
|
}
|
|
const visible = await locator.isVisible().catch(() => false)
|
|
if (!visible) {
|
|
continue
|
|
}
|
|
|
|
return {
|
|
locator,
|
|
mode: candidate.mode,
|
|
}
|
|
}
|
|
|
|
throw new Error('deepseek_editor_not_found')
|
|
}
|
|
|
|
async function tryClickDeepSeekSendButton(
|
|
_page: PlaywrightPage,
|
|
editor: ReturnType<PlaywrightPage['locator']>,
|
|
): Promise<boolean> {
|
|
return await editor
|
|
.evaluate((node) => {
|
|
const isVisible = (value: Element | null): value is HTMLElement => {
|
|
if (!(value instanceof HTMLElement)) {
|
|
return false
|
|
}
|
|
const style = window.getComputedStyle(value)
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
return false
|
|
}
|
|
const rect = value.getBoundingClientRect()
|
|
return rect.width > 0 && rect.height > 0
|
|
}
|
|
|
|
const root =
|
|
node.closest(
|
|
"form, [class*='textarea'], [class*='input'], [class*='editor'], [class*='compose']",
|
|
) ??
|
|
node.parentElement ??
|
|
document.body
|
|
|
|
const buttons = Array.from(root.querySelectorAll("button, [role='button']"))
|
|
for (const button of buttons.reverse()) {
|
|
if (!isVisible(button)) {
|
|
continue
|
|
}
|
|
|
|
const htmlButton = button as HTMLButtonElement
|
|
if ('disabled' in htmlButton && htmlButton.disabled) {
|
|
continue
|
|
}
|
|
|
|
htmlButton.click()
|
|
return true
|
|
}
|
|
|
|
return false
|
|
})
|
|
.catch(() => false)
|
|
}
|
|
|
|
async function ensureDeepSeekToggleEnabled(
|
|
page: PlaywrightPage,
|
|
labelPattern: RegExp,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
if (signal.aborted) {
|
|
throw new Error('adapter_aborted')
|
|
}
|
|
|
|
const clicked = await page
|
|
.evaluate(
|
|
({ source, flags }) => {
|
|
const pattern = new RegExp(source, flags)
|
|
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 isSelected = (element: HTMLElement | null): boolean => {
|
|
if (!element) {
|
|
return false
|
|
}
|
|
|
|
const attrs = [
|
|
element.getAttribute('aria-pressed'),
|
|
element.getAttribute('aria-checked'),
|
|
element.getAttribute('data-state'),
|
|
element.getAttribute('data-selected'),
|
|
element.getAttribute('data-active'),
|
|
]
|
|
.map((value) => normalize(value)?.toLowerCase() ?? null)
|
|
.filter((value): value is string => value !== null)
|
|
if (
|
|
attrs.some((value) => ['true', 'checked', 'selected', 'active', 'on'].includes(value))
|
|
) {
|
|
return true
|
|
}
|
|
if (attrs.some((value) => ['false', 'unchecked', 'off'].includes(value))) {
|
|
return false
|
|
}
|
|
|
|
const className = normalize(String(element.className || '')) ?? ''
|
|
return /(ds-toggle-button--selected|(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$))/i.test(
|
|
className,
|
|
)
|
|
}
|
|
|
|
const candidates = Array.from(
|
|
document.querySelectorAll(
|
|
"button, [role='button'], [class*='toggle-button'], [class*='toggle']",
|
|
),
|
|
).filter(isVisible)
|
|
const target = candidates.find((node) => {
|
|
const text = normalize(
|
|
node.innerText || node.textContent || node.getAttribute('aria-label') || '',
|
|
)
|
|
return Boolean(text && pattern.test(text))
|
|
})
|
|
if (!target || isSelected(target)) {
|
|
return false
|
|
}
|
|
|
|
target.click()
|
|
return true
|
|
},
|
|
{
|
|
source: labelPattern.source,
|
|
flags: labelPattern.flags,
|
|
},
|
|
)
|
|
.catch(() => false)
|
|
|
|
if (clicked) {
|
|
await page.waitForTimeout(180)
|
|
}
|
|
}
|
|
|
|
async function submitDeepSeekQuestion(
|
|
page: PlaywrightPage,
|
|
questionText: string,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
if (signal.aborted) {
|
|
throw new Error('adapter_aborted')
|
|
}
|
|
|
|
const { locator, mode } = await resolveDeepSeekEditor(page)
|
|
await ensureDeepSeekToggleEnabled(page, DEEPSEEK_DEEP_THINK_PATTERN, signal).catch(
|
|
() => undefined,
|
|
)
|
|
await locator.click({ timeout: 5_000 })
|
|
|
|
if (mode === 'textarea') {
|
|
await locator.fill(questionText)
|
|
} else {
|
|
await page.keyboard.press('Meta+A').catch(() => undefined)
|
|
await page.keyboard.press('Control+A').catch(() => undefined)
|
|
await page.keyboard.press('Backspace').catch(() => undefined)
|
|
await page.keyboard.insertText(questionText)
|
|
}
|
|
|
|
await page.keyboard.press('Enter').catch(() => undefined)
|
|
await sleep(300, signal)
|
|
|
|
const snapshot = await readDeepSeekPageSnapshot(page, questionText).catch(() => null)
|
|
const composerStillFilled = normalizeText(snapshot?.composerValue)
|
|
if (composerStillFilled && composerStillFilled.includes(questionText)) {
|
|
const clicked = await tryClickDeepSeekSendButton(page, locator)
|
|
if (!clicked) {
|
|
throw new Error('deepseek_send_button_unavailable')
|
|
}
|
|
}
|
|
}
|
|
|
|
async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise<void> {
|
|
if (await hasDeepSeekSearchResultsPanel(page)) {
|
|
return
|
|
}
|
|
|
|
const webpagesTrigger = 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)
|
|
if (!text) {
|
|
return false
|
|
}
|
|
return (
|
|
/^(?:已阅读\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 findClickTarget = (node: HTMLElement): HTMLElement | null => {
|
|
let fallback: HTMLElement | null = null
|
|
let current: HTMLElement | null = node
|
|
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 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, [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
|
|
}
|
|
|
|
const target = findClickTarget(node)
|
|
if (!(target instanceof HTMLElement)) {
|
|
continue
|
|
}
|
|
|
|
const rect = target.getBoundingClientRect()
|
|
let score = 0
|
|
if (isClickable(target)) {
|
|
score += 32
|
|
}
|
|
if (normalize(target.textContent || target.getAttribute('aria-label') || '') === text) {
|
|
score += 12
|
|
}
|
|
if (rect.top >= window.innerHeight * 0.35) {
|
|
score += 20
|
|
}
|
|
if (rect.width <= 320) {
|
|
score += 8
|
|
}
|
|
if (rect.left >= window.innerWidth * 0.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
|
|
}
|
|
}
|
|
|
|
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 (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
|
|
}
|
|
}
|
|
|
|
const searchResultsButton = page
|
|
.getByRole('button', {
|
|
name: DEEPSEEK_SEARCH_RESULTS_BUTTON_PATTERN,
|
|
})
|
|
.first()
|
|
|
|
if (await searchResultsButton.isVisible().catch(() => false)) {
|
|
await searchResultsButton.click().catch(() => undefined)
|
|
await page.waitForTimeout(300)
|
|
await waitForDeepSeekSearchResultsPanel(page).catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
async function maybeRevealDeepSeekReasoningSearchSources(page: PlaywrightPage): Promise<void> {
|
|
if (await hasDeepSeekSearchResultsPanel(page)) {
|
|
return
|
|
}
|
|
|
|
const reasoningSearchTrigger = 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 isTriggerText = (value: unknown): boolean => {
|
|
const text = normalize(value)
|
|
return Boolean(
|
|
text &&
|
|
(/^搜索到\s*\d+\s*个网页$/i.test(text) ||
|
|
/^(?:found|searched)\s*\d+\s*(?:web\s*pages?|webpages?)$/i.test(text)),
|
|
)
|
|
}
|
|
const isReasoningNode = (node: HTMLElement): boolean => {
|
|
if (
|
|
node.closest(".ds-think-content, [class*='think-content'], [class*='reasoning-content']")
|
|
) {
|
|
return true
|
|
}
|
|
|
|
let current: HTMLElement | null = node
|
|
for (let depth = 0; current && depth < 8; depth += 1) {
|
|
const text = normalize(current.textContent || '')
|
|
const className = String(current.className || '')
|
|
if (
|
|
/think|reason|analysis|思考/i.test(className) ||
|
|
Boolean(text && /已思考|思考过程|搜索到\s*\d+\s*个网页/.test(text))
|
|
) {
|
|
return true
|
|
}
|
|
current = current.parentElement
|
|
}
|
|
|
|
return false
|
|
}
|
|
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 findClickTarget = (node: HTMLElement): HTMLElement | null => {
|
|
let fallback: HTMLElement | null = null
|
|
const nextParent = (element: HTMLElement): HTMLElement | null =>
|
|
element.parentNode instanceof HTMLElement ? element.parentNode : null
|
|
let current: HTMLElement | null = node
|
|
for (let depth = 0; current !== null && depth < 10; depth += 1) {
|
|
const element: HTMLElement = current
|
|
if (!isVisible(element)) {
|
|
current = nextParent(element)
|
|
continue
|
|
}
|
|
const currentText = normalize(
|
|
element.textContent || element.getAttribute('aria-label') || '',
|
|
)
|
|
if (!fallback && currentText && isTriggerText(currentText)) {
|
|
fallback = element
|
|
}
|
|
if (isClickable(element)) {
|
|
return element
|
|
}
|
|
current = nextParent(element)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
let bestTarget: HTMLElement | null = null
|
|
let bestScore = -1
|
|
for (const node of Array.from(
|
|
document.querySelectorAll("span, div, button, a, [role='button']"),
|
|
).slice(0, 1600)) {
|
|
if (!(node instanceof HTMLElement) || !isVisible(node)) {
|
|
continue
|
|
}
|
|
const text = normalize(node.textContent || node.getAttribute('aria-label') || '')
|
|
if (!isTriggerText(text) || !isReasoningNode(node)) {
|
|
continue
|
|
}
|
|
|
|
const target = findClickTarget(node)
|
|
if (!(target instanceof HTMLElement)) {
|
|
continue
|
|
}
|
|
|
|
const rect = target.getBoundingClientRect()
|
|
let score = 0
|
|
if (isClickable(target)) {
|
|
score += 32
|
|
}
|
|
if (rect.left >= window.innerWidth * 0.18 && rect.left <= window.innerWidth * 0.75) {
|
|
score += 18
|
|
}
|
|
if (rect.top >= window.innerHeight * 0.2) {
|
|
score += 12
|
|
}
|
|
if (rect.width <= 360) {
|
|
score += 8
|
|
}
|
|
if (target.closest(".ds-think-content, [class*='think-content']")) {
|
|
score += 16
|
|
}
|
|
if (score > bestScore) {
|
|
bestScore = score
|
|
bestTarget = target
|
|
}
|
|
}
|
|
|
|
if (!bestTarget) {
|
|
return null
|
|
}
|
|
|
|
bestTarget.scrollIntoView({ block: 'center', inline: 'center' })
|
|
const rect = bestTarget.getBoundingClientRect()
|
|
return {
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2,
|
|
}
|
|
})
|
|
.catch(() => null)
|
|
|
|
if (!reasoningSearchTrigger || typeof reasoningSearchTrigger !== 'object') {
|
|
return
|
|
}
|
|
|
|
await page.mouse.click(reasoningSearchTrigger.x, reasoningSearchTrigger.y).catch(() => undefined)
|
|
await page.waitForTimeout(500).catch(() => undefined)
|
|
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): 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 isTriggerText = (value: unknown): boolean => {
|
|
const text = normalize(value)
|
|
return Boolean(text && /^搜索到\s*\d+\s*个网页$/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, 1600)) {
|
|
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 < 10; 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).catch(() => undefined)
|
|
await waitForDeepSeekSearchResultsPanel(page).catch(() => undefined)
|
|
}
|
|
|
|
async function maybeExpandDeepSeekReasoning(page: PlaywrightPage): Promise<void> {
|
|
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<boolean> {
|
|
const heading = page
|
|
.getByRole('heading', {
|
|
name: DEEPSEEK_SEARCH_RESULTS_HEADING_PATTERN,
|
|
})
|
|
.first()
|
|
if (await heading.isVisible().catch(() => false)) {
|
|
return true
|
|
}
|
|
|
|
const cardTitle = page
|
|
.locator("[class*='search-view-card__title'], .search-view-card__title")
|
|
.first()
|
|
return await cardTitle.isVisible().catch(() => false)
|
|
}
|
|
|
|
async function waitForDeepSeekSearchResultsPanel(
|
|
page: PlaywrightPage,
|
|
timeoutMs = 3_000,
|
|
): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (await hasDeepSeekSearchResultsPanel(page)) {
|
|
return true
|
|
}
|
|
await page.waitForTimeout(150)
|
|
}
|
|
return await hasDeepSeekSearchResultsPanel(page)
|
|
}
|
|
|
|
async function collectDeepSeekSearchResultsPanelLinks(
|
|
page: PlaywrightPage,
|
|
): Promise<DeepSeekRawSourceLink[]> {
|
|
return await page
|
|
.evaluate(async () => {
|
|
type EvalSourceLink = {
|
|
url: string
|
|
title: string | null
|
|
text: string | null
|
|
siteName: string | null
|
|
kind: 'search'
|
|
}
|
|
|
|
const sleep = (ms: number) =>
|
|
new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, ms)
|
|
})
|
|
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 normalizeUrl = (value: unknown, depth = 0): string | null => {
|
|
if (depth > 3) {
|
|
return null
|
|
}
|
|
|
|
const text = normalize(value)
|
|
if (!text) {
|
|
return null
|
|
}
|
|
|
|
const candidates = [text]
|
|
try {
|
|
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
|
|
}
|
|
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 => {
|
|
for (const node of Array.from(root.querySelectorAll(selectors))) {
|
|
const text = normalize(node.textContent || '')
|
|
if (text) {
|
|
return text
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
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<EvalSourceLink, 'title' | 'text' | 'siteName'> => ({
|
|
title:
|
|
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(
|
|
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(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
|
|
const seen = new Set<HTMLElement>()
|
|
const candidates: Array<{ node: HTMLElement; score: number }> = []
|
|
const addCandidate = (node: Element | null, baseScore: number): void => {
|
|
let current: HTMLElement | null = node instanceof HTMLElement ? node : null
|
|
for (let depth = 0; current && depth < 7; depth += 1) {
|
|
if (seen.has(current) || !isVisible(current)) {
|
|
current = current.parentElement
|
|
continue
|
|
}
|
|
const rect = current.getBoundingClientRect()
|
|
if (rect.width < 220 || rect.height < 220 || rect.left < window.innerWidth * 0.45) {
|
|
current = current.parentElement
|
|
continue
|
|
}
|
|
const style = window.getComputedStyle(current)
|
|
const headingText = queryText(current, "[role='heading'], h1, h2, h3, h4")
|
|
const hasHeading = Boolean(headingText && headingPattern.test(headingText))
|
|
const hasSearchCards = Boolean(
|
|
current.querySelector("[class*='search-view-card__title'], .search-view-card__title"),
|
|
)
|
|
if (!hasHeading && !hasSearchCards) {
|
|
current = current.parentElement
|
|
continue
|
|
}
|
|
|
|
let score = baseScore
|
|
if (hasHeading) {
|
|
score += 24
|
|
}
|
|
if (hasSearchCards) {
|
|
score += 24
|
|
}
|
|
if (
|
|
['auto', 'scroll', 'overlay'].includes(style.overflowY) &&
|
|
current.scrollHeight > current.clientHeight + 80
|
|
) {
|
|
score += 18
|
|
}
|
|
if (rect.left >= window.innerWidth * 0.55) {
|
|
score += 18
|
|
}
|
|
if (rect.width <= 540) {
|
|
score += 8
|
|
}
|
|
if (rect.height >= 360) {
|
|
score += 8
|
|
}
|
|
score += Math.min(current.querySelectorAll('a[href]').length, 12)
|
|
seen.add(current)
|
|
candidates.push({ node: current, score })
|
|
current = current.parentElement
|
|
}
|
|
}
|
|
|
|
for (const heading of Array.from(
|
|
document.querySelectorAll("[role='heading'], h1, h2, h3, h4"),
|
|
)) {
|
|
const text = normalize(heading.textContent || '')
|
|
if (text && headingPattern.test(text)) {
|
|
addCandidate(heading, 40)
|
|
}
|
|
}
|
|
for (const titleNode of Array.from(
|
|
document.querySelectorAll("[class*='search-view-card__title'], .search-view-card__title"),
|
|
)) {
|
|
addCandidate(titleNode, 30)
|
|
}
|
|
|
|
return candidates.sort((left, right) => right.score - left.score).at(0)?.node ?? null
|
|
}
|
|
|
|
const searchPanel = findSearchResultsPanel()
|
|
|
|
if (!(searchPanel instanceof HTMLElement)) {
|
|
return []
|
|
}
|
|
|
|
const originalScrollTop = searchPanel.scrollTop
|
|
const seen = new Set<string>()
|
|
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<HTMLAnchorElement>('a[href]'),
|
|
)) {
|
|
const href = normalizeUrl(anchor.getAttribute('href') || anchor.href || '')
|
|
if (!href) {
|
|
continue
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
collect()
|
|
let previousScrollTop = -1
|
|
let stableScrollCount = 0
|
|
for (let attempt = 0; attempt < 48; attempt += 1) {
|
|
const nextTop = Math.min(
|
|
searchPanel.scrollTop + Math.max(searchPanel.clientHeight - 96, 420),
|
|
Math.max(searchPanel.scrollHeight - searchPanel.clientHeight, 0),
|
|
)
|
|
searchPanel.scrollTop = nextTop
|
|
await sleep(140)
|
|
collect()
|
|
|
|
if (searchPanel.scrollTop === previousScrollTop) {
|
|
stableScrollCount += 1
|
|
} else {
|
|
previousScrollTop = searchPanel.scrollTop
|
|
stableScrollCount = 0
|
|
}
|
|
|
|
if (
|
|
searchPanel.scrollTop + searchPanel.clientHeight >= searchPanel.scrollHeight - 2 &&
|
|
stableScrollCount >= 1
|
|
) {
|
|
break
|
|
}
|
|
if (stableScrollCount >= 2) {
|
|
break
|
|
}
|
|
}
|
|
|
|
searchPanel.scrollTop = originalScrollTop
|
|
return results
|
|
})
|
|
.catch(() => [])
|
|
}
|
|
|
|
async function readDeepSeekPageSnapshot(
|
|
page: PlaywrightPage,
|
|
questionText?: string,
|
|
): Promise<DeepSeekPageSnapshot> {
|
|
const draft = (await page.evaluate((input) => {
|
|
type EvalRecord = Record<string, unknown>
|
|
type EvalSourceLink = {
|
|
url: string
|
|
title: string | null
|
|
text: string | null
|
|
siteName: string | null
|
|
kind: DeepSeekLinkKind
|
|
}
|
|
|
|
const globalWindow = window as DeepSeekWindowState
|
|
const questionText = typeof input === 'string' ? input.trim().replace(/\s+/g, ' ') : ''
|
|
const normalize = (value: unknown): string | null => {
|
|
if (typeof value !== 'string') {
|
|
return null
|
|
}
|
|
const trimmed = value.trim().replace(/\s+/g, ' ')
|
|
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'
|
|
}
|
|
|
|
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
|
|
}
|
|
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 normalizeUrl = (value: unknown, depth = 0): string | null => {
|
|
if (depth > 3) {
|
|
return null
|
|
}
|
|
|
|
const text = normalize(value)
|
|
if (!text) {
|
|
return null
|
|
}
|
|
|
|
const candidates = [text]
|
|
try {
|
|
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
|
|
}
|
|
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}`
|
|
const queryText = (root: ParentNode, selectors: string): string | null => {
|
|
for (const node of Array.from(root.querySelectorAll(selectors))) {
|
|
const text = normalize(node.textContent || '')
|
|
if (text) {
|
|
return text
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
const renderStructuredHtml = (root: Element | null): string | null => {
|
|
if (!(root instanceof Element)) {
|
|
return null
|
|
}
|
|
const clone = root.cloneNode(true)
|
|
if (!(clone instanceof HTMLElement)) {
|
|
return null
|
|
}
|
|
|
|
for (const anchor of Array.from(clone.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
|
|
const href = normalizeUrl(anchor.getAttribute('href') || anchor.href || '')
|
|
if (!href || !isExternalUrl(href)) {
|
|
continue
|
|
}
|
|
anchor.setAttribute('href', href)
|
|
anchor.setAttribute('target', '_blank')
|
|
anchor.setAttribute('rel', 'noreferrer')
|
|
}
|
|
|
|
const html = clone.innerHTML.trim()
|
|
return html || null
|
|
}
|
|
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<EvalSourceLink, 'title' | 'text' | 'siteName'> => ({
|
|
title:
|
|
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(
|
|
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(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,
|
|
bucket: EvalSourceLink[],
|
|
seen: Set<string>,
|
|
): void => {
|
|
if (!(root instanceof Element)) {
|
|
return
|
|
}
|
|
const anchors: HTMLAnchorElement[] =
|
|
root instanceof HTMLAnchorElement && root.matches('a[href]')
|
|
? [root]
|
|
: Array.from(root.querySelectorAll('a[href]'))
|
|
for (const anchor of anchors) {
|
|
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
|
|
}
|
|
const key = linkKey(href, kind)
|
|
if (seen.has(key)) {
|
|
continue
|
|
}
|
|
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,
|
|
title: metadata.title,
|
|
text: metadata.text,
|
|
siteName: metadata.siteName,
|
|
kind,
|
|
})
|
|
}
|
|
}
|
|
const collectWindowStateLinks = (): EvalSourceLink[] => {
|
|
const results: EvalSourceLink[] = []
|
|
const seen = new Set<string>()
|
|
const roots: unknown[] = []
|
|
const candidates = [
|
|
globalWindow.__NEXT_DATA__,
|
|
globalWindow.__INITIAL_STATE__,
|
|
globalWindow.__APP_STATE__,
|
|
globalWindow.__NUXT__,
|
|
typeof globalWindow.__STORE__?.getState === 'function'
|
|
? globalWindow.__STORE__.getState()
|
|
: null,
|
|
]
|
|
for (const candidate of candidates) {
|
|
if (candidate && typeof candidate === 'object') {
|
|
roots.push(candidate)
|
|
}
|
|
}
|
|
const queue: Array<{ value: unknown; path: string }> = roots.map((root) => ({
|
|
value: root,
|
|
path: '',
|
|
}))
|
|
const visited = new WeakSet<object>()
|
|
while (queue.length && results.length < 96) {
|
|
const current = queue.shift()
|
|
if (!current) {
|
|
continue
|
|
}
|
|
const { value, path } = current
|
|
if (!value || typeof value !== 'object') {
|
|
continue
|
|
}
|
|
if (visited.has(value)) {
|
|
continue
|
|
}
|
|
visited.add(value)
|
|
if (Array.isArray(value)) {
|
|
for (const item of value.slice(0, 32)) {
|
|
queue.push({ value: item, path })
|
|
}
|
|
continue
|
|
}
|
|
const record = value as EvalRecord
|
|
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|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 ??
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
for (const [key, item] of Object.entries(record).slice(0, 64)) {
|
|
if (item && typeof item === 'object') {
|
|
queue.push({
|
|
value: item,
|
|
path: path ? `${path}.${key}` : key,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
const findSearchResultsPanel = (): HTMLElement | null => {
|
|
const headingPattern = /^(?:搜索结果|search results)$/i
|
|
const seen = new Set<HTMLElement>()
|
|
const candidates: Array<{ node: HTMLElement; score: number }> = []
|
|
const addCandidate = (node: Element | null, baseScore: number): void => {
|
|
let current: HTMLElement | null = node instanceof HTMLElement ? node : null
|
|
for (let depth = 0; current && depth < 7; depth += 1) {
|
|
if (seen.has(current) || !isVisible(current)) {
|
|
current = current.parentElement
|
|
continue
|
|
}
|
|
const rect = current.getBoundingClientRect()
|
|
if (rect.width < 220 || rect.height < 220 || rect.left < window.innerWidth * 0.45) {
|
|
current = current.parentElement
|
|
continue
|
|
}
|
|
const style = window.getComputedStyle(current)
|
|
const headingText = queryText(current, "[role='heading'], h1, h2, h3, h4")
|
|
const hasHeading = Boolean(headingText && headingPattern.test(headingText))
|
|
const hasSearchCards = Boolean(
|
|
current.querySelector("[class*='search-view-card__title'], .search-view-card__title"),
|
|
)
|
|
if (!hasHeading && !hasSearchCards) {
|
|
current = current.parentElement
|
|
continue
|
|
}
|
|
|
|
let score = baseScore
|
|
if (hasHeading) {
|
|
score += 24
|
|
}
|
|
if (hasSearchCards) {
|
|
score += 24
|
|
}
|
|
if (
|
|
['auto', 'scroll', 'overlay'].includes(style.overflowY) &&
|
|
current.scrollHeight > current.clientHeight + 80
|
|
) {
|
|
score += 18
|
|
}
|
|
if (rect.left >= window.innerWidth * 0.55) {
|
|
score += 18
|
|
}
|
|
if (rect.width <= 540) {
|
|
score += 8
|
|
}
|
|
if (rect.height >= 360) {
|
|
score += 8
|
|
}
|
|
score += Math.min(current.querySelectorAll('a[href]').length, 12)
|
|
seen.add(current)
|
|
candidates.push({ node: current, score })
|
|
current = current.parentElement
|
|
}
|
|
}
|
|
|
|
for (const heading of Array.from(
|
|
document.querySelectorAll("[role='heading'], h1, h2, h3, h4"),
|
|
)) {
|
|
const text = normalize(heading.textContent || '')
|
|
if (text && headingPattern.test(text)) {
|
|
addCandidate(heading, 40)
|
|
}
|
|
}
|
|
for (const titleNode of Array.from(
|
|
document.querySelectorAll("[class*='search-view-card__title'], .search-view-card__title"),
|
|
)) {
|
|
addCandidate(titleNode, 30)
|
|
}
|
|
|
|
return candidates.sort((left, right) => right.score - left.score).at(0)?.node ?? null
|
|
}
|
|
|
|
const bodyText = normalize(document.body?.innerText || '')
|
|
// 登录/验证判定不能直接对整页 innerText 匹配:聊天问题与回答里出现
|
|
// "登录/验证码/安全验证" 这类词会把正常会话误判成需要人工处理。
|
|
// 只认三类证据:sign_in 路由、可见弹层内的短文案、稀疏拦截页。
|
|
const loginPhrasePattern =
|
|
/(log in|send code|scan with wechat to login|phone number|continue with deepseek|和 deepseek 继续聊|登录|发送验证码|手机号|验证码)/i
|
|
const challengePhrasePattern =
|
|
/(人机验证|安全验证|请完成验证|完成下方验证|拖动滑块|滑动验证|向右滑动|安全检测|访问异常|环境异常|verification required|security check|unusual traffic|not a robot|请输入(?:图形)?验证码)/i
|
|
const findVisibleSurfaceText = (
|
|
selectors: string,
|
|
pattern: RegExp,
|
|
maxTextLength: number,
|
|
): boolean => {
|
|
for (const node of Array.from(document.querySelectorAll(selectors)).slice(0, 50)) {
|
|
if (!isVisible(node)) {
|
|
continue
|
|
}
|
|
const text = normalize(node.innerText || node.textContent)
|
|
if (text && text.length <= maxTextLength && pattern.test(text)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
const hasChallengeVendorWidget = (() => {
|
|
const vendorSelectors = [
|
|
'[class*="geetest_"]',
|
|
'[id^="nc_"][id$="_wrapper"]',
|
|
'.nc-container',
|
|
'[class*="nocaptcha"]',
|
|
'[class*="yidun_modal"]',
|
|
'[class*="yidun_popup"]',
|
|
'[class*="vaptcha"]',
|
|
'[class*="tcaptcha"]',
|
|
'#tcaptcha_iframe',
|
|
'[class*="captcha_verify"]',
|
|
'[class*="secsdk-captcha"]',
|
|
'iframe[src*="captcha"]',
|
|
'iframe[src*="geetest"]',
|
|
'iframe[src*="turnstile"]',
|
|
'iframe[src*="hcaptcha"]',
|
|
'iframe[src*="recaptcha"]',
|
|
'iframe[title*="验证"]',
|
|
].join(',')
|
|
for (const node of Array.from(document.querySelectorAll(vendorSelectors)).slice(0, 50)) {
|
|
if (isVisible(node)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})()
|
|
const loginRequired =
|
|
window.location.pathname.includes('/sign_in') ||
|
|
findVisibleSurfaceText(
|
|
'[role="dialog"], [aria-modal="true"], [class*="modal"], [class*="login"], [class*="sign"], [class*="auth"]',
|
|
loginPhrasePattern,
|
|
300,
|
|
) ||
|
|
Boolean(bodyText && bodyText.length <= 600 && loginPhrasePattern.test(bodyText))
|
|
const challengeRequired =
|
|
hasChallengeVendorWidget ||
|
|
findVisibleSurfaceText(
|
|
'[role="dialog"], [aria-modal="true"], [class*="captcha"], [id*="captcha"], [class*="verify"], [class*="Verify"]',
|
|
challengePhrasePattern,
|
|
240,
|
|
) ||
|
|
Boolean(bodyText && bodyText.length <= 400 && challengePhrasePattern.test(bodyText))
|
|
|
|
const busyDescriptors: string[] = []
|
|
for (const node of Array.from(
|
|
document.querySelectorAll(
|
|
"button, [role='button'], [class*='loading'], [class*='spinner'], [class*='think']",
|
|
),
|
|
).slice(0, 60)) {
|
|
if (!isVisible(node)) {
|
|
continue
|
|
}
|
|
const text = normalize(node.textContent || node.getAttribute('aria-label') || '')
|
|
const descriptor = `${node.className || ''} ${text || ''}`
|
|
if (descriptor) {
|
|
busyDescriptors.push(descriptor)
|
|
}
|
|
if (busyDescriptors.length >= 24) {
|
|
break
|
|
}
|
|
}
|
|
|
|
const editor =
|
|
document.querySelector('textarea.ds-textarea') ||
|
|
document.querySelector('.ds-textarea textarea') ||
|
|
document.querySelector('textarea') ||
|
|
document.querySelector("[role='textbox'][contenteditable='true']") ||
|
|
document.querySelector("[contenteditable='true']")
|
|
const composerValue =
|
|
editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement
|
|
? normalize(editor.value)
|
|
: normalize(editor?.textContent || '')
|
|
|
|
const sendButton =
|
|
Array.from(document.querySelectorAll("button, [role='button']")).find((node) => {
|
|
if (!isVisible(node)) {
|
|
return false
|
|
}
|
|
const text = normalize(node.textContent || node.getAttribute('aria-label') || '')
|
|
if (text && /(send|发送|提交)/i.test(text)) {
|
|
return true
|
|
}
|
|
return /sendButton/i.test(String(node.className || ''))
|
|
}) || null
|
|
const sendDisabled =
|
|
sendButton && 'disabled' in sendButton ? Boolean(sendButton.disabled) : null
|
|
|
|
const markdownNodes = Array.from(document.querySelectorAll('.ds-markdown'))
|
|
.filter((node) => isVisible(node))
|
|
.map((node) => {
|
|
const text = normalize(node.textContent || '')
|
|
const comparableText = normalizeComparable(text)
|
|
const html = renderStructuredHtml(node)
|
|
const rect = node.getBoundingClientRect()
|
|
const inThink = Boolean(node.closest('.ds-think-content'))
|
|
const container =
|
|
node.closest("[data-message-id], [class*='message'], article, li, section") ||
|
|
node.parentElement
|
|
const links: EvalSourceLink[] = []
|
|
const seen = new Set<string>()
|
|
addLinksFromRoot(node, 'inline', links, seen)
|
|
const containerLinks: EvalSourceLink[] = []
|
|
if (container) {
|
|
addLinksFromRoot(container, 'source', containerLinks, seen)
|
|
}
|
|
return {
|
|
node,
|
|
html,
|
|
text,
|
|
comparableText,
|
|
top: Math.round(rect.top),
|
|
inThink,
|
|
links,
|
|
containerLinks,
|
|
container,
|
|
}
|
|
})
|
|
|
|
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<string>()
|
|
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
|
|
}
|
|
if (!candidate.text && !candidate.links.length && !candidate.containerLinks.length) {
|
|
return false
|
|
}
|
|
if (questionText && candidate.comparableText === questionText.toLowerCase()) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
const answerCandidate = answerCandidates.at(-1) || null
|
|
|
|
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 searchSeen = new Set<string>()
|
|
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<string>()
|
|
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)
|
|
}
|
|
|
|
for (const item of collectWindowStateLinks()) {
|
|
if (item.kind === 'search') {
|
|
if (!searchResultsPanel && searchPanelLinks.length === 0) {
|
|
continue
|
|
}
|
|
addSearchLink(item)
|
|
continue
|
|
}
|
|
|
|
const key = linkKey(item.url, 'source')
|
|
if (!searchSeen.has(key)) {
|
|
searchSeen.add(key)
|
|
sourcePanelLinks.push(item)
|
|
}
|
|
}
|
|
|
|
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')) ||
|
|
normalize(document.body?.getAttribute('data-request-id')) ||
|
|
null
|
|
const requestID =
|
|
normalize(document.documentElement.getAttribute('data-message-id')) ||
|
|
normalize(document.body?.getAttribute('data-message-id')) ||
|
|
providerRequestID ||
|
|
null
|
|
|
|
const signatureParts = [
|
|
normalize(answerCandidate?.text),
|
|
normalize(thinkCandidate?.text ?? reasoningRoot?.text),
|
|
...inlineLinks.map((item) => item.url),
|
|
...sourcePanelLinks.map((item) => item.url),
|
|
...searchPanelLinks.map((item) => item.url),
|
|
].filter(Boolean)
|
|
|
|
return {
|
|
url: window.location.href,
|
|
title: normalize(document.title),
|
|
loginRequired,
|
|
loginReason: loginRequired ? normalize(bodyText) || 'login_required' : null,
|
|
challengeRequired,
|
|
challengeReason: challengeRequired ? normalize(bodyText) || 'challenge_required' : null,
|
|
bodyText,
|
|
busyDescriptors,
|
|
composerValue,
|
|
sendDisabled,
|
|
answer: answerCandidate?.html ?? answerCandidate?.text ?? null,
|
|
answerText: answerCandidate?.text ?? null,
|
|
reasoning: thinkCandidate?.text ?? reasoningRoot?.text ?? null,
|
|
signatureParts,
|
|
currentModelLabel,
|
|
providerRequestID,
|
|
requestID,
|
|
inlineLinks,
|
|
sourcePanelLinks,
|
|
searchPanelLinks,
|
|
}
|
|
}, questionText ?? '')) as DeepSeekPageSnapshotDraft
|
|
|
|
const busySignals = detectDeepSeekBusySignals({
|
|
bodyText: draft.bodyText,
|
|
descriptors: draft.busyDescriptors,
|
|
})
|
|
const { bodyText: _bodyText, busyDescriptors: _busyDescriptors, signatureParts, ...rest } = draft
|
|
|
|
return {
|
|
...rest,
|
|
busy: busySignals.length > 0,
|
|
busySignals,
|
|
signature: [...signatureParts, String(busySignals.length)].join('|') || null,
|
|
}
|
|
}
|
|
|
|
function selectBetterSnapshot(
|
|
current: DeepSeekPageSnapshot,
|
|
candidate: DeepSeekPageSnapshot,
|
|
): DeepSeekPageSnapshot {
|
|
if (answerScore(candidate) > answerScore(current)) {
|
|
return candidate
|
|
}
|
|
|
|
if (isObservationComplete(candidate) && !isObservationComplete(current)) {
|
|
return candidate
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
async function waitForDeepSeekAnswer(
|
|
page: PlaywrightPage,
|
|
questionText: string,
|
|
signal: AbortSignal,
|
|
): Promise<DeepSeekWaitResult> {
|
|
const startedAt = Date.now()
|
|
let stablePollCount = 0
|
|
let previousSignature: string | null = null
|
|
let bestSnapshot = await readDeepSeekPageSnapshot(page, questionText)
|
|
|
|
while (Date.now() - startedAt < DEEPSEEK_QUERY_TIMEOUT_MS) {
|
|
if (signal.aborted) {
|
|
throw new Error('adapter_aborted')
|
|
}
|
|
|
|
const snapshot = await readDeepSeekPageSnapshot(page, questionText)
|
|
bestSnapshot = selectBetterSnapshot(bestSnapshot, snapshot)
|
|
|
|
if (snapshot.loginRequired) {
|
|
return {
|
|
ok: false,
|
|
error: 'deepseek_login_required',
|
|
detail: snapshot.loginReason ?? 'login_required',
|
|
finalSnapshot: bestSnapshot,
|
|
stablePollCount,
|
|
elapsedMs: Date.now() - startedAt,
|
|
}
|
|
}
|
|
|
|
if (snapshot.challengeRequired && !snapshot.answer) {
|
|
return {
|
|
ok: false,
|
|
error: 'deepseek_challenge_required',
|
|
detail: snapshot.challengeReason ?? 'challenge_required',
|
|
finalSnapshot: bestSnapshot,
|
|
stablePollCount,
|
|
elapsedMs: Date.now() - startedAt,
|
|
}
|
|
}
|
|
|
|
if (isObservationComplete(snapshot)) {
|
|
if (snapshot.signature && snapshot.signature === previousSignature) {
|
|
stablePollCount += 1
|
|
} else {
|
|
stablePollCount = 1
|
|
}
|
|
|
|
previousSignature = snapshot.signature
|
|
if (stablePollCount >= DEEPSEEK_STABLE_POLLS_REQUIRED) {
|
|
return {
|
|
ok: true,
|
|
finalSnapshot: snapshot,
|
|
stablePollCount,
|
|
elapsedMs: Date.now() - startedAt,
|
|
}
|
|
}
|
|
} else {
|
|
stablePollCount = 0
|
|
previousSignature = snapshot.signature
|
|
}
|
|
|
|
await sleep(DEEPSEEK_QUERY_POLL_INTERVAL_MS, signal)
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
error: 'deepseek_query_timeout',
|
|
detail: 'timed out while waiting for DeepSeek answer',
|
|
finalSnapshot: bestSnapshot,
|
|
stablePollCount,
|
|
elapsedMs: Date.now() - startedAt,
|
|
}
|
|
}
|
|
|
|
export const deepseekAdapter: MonitorAdapter = {
|
|
provider: 'deepseek',
|
|
executionMode: 'playwright',
|
|
async query(context, payload) {
|
|
if (!context.playwright?.page) {
|
|
return {
|
|
status: 'failed',
|
|
summary: 'DeepSeek 监测缺少 Playwright 页面上下文。',
|
|
error: buildAdapterError('deepseek_playwright_required', 'deepseek_playwright_required'),
|
|
}
|
|
}
|
|
|
|
let questionText: string
|
|
try {
|
|
questionText = extractQuestionText(payload)
|
|
} catch (error) {
|
|
return {
|
|
status: 'failed',
|
|
summary: 'DeepSeek 监测缺少问题文本,无法执行。',
|
|
error: buildAdapterError(
|
|
'deepseek_question_text_missing',
|
|
error instanceof Error ? error.message : 'deepseek_question_text_missing',
|
|
),
|
|
}
|
|
}
|
|
|
|
const page = context.playwright.page
|
|
|
|
context.reportProgress('deepseek.page_ready')
|
|
await ensurePageOnDeepSeekChat(page, context.signal)
|
|
|
|
const initialSnapshot = await readDeepSeekPageSnapshot(page, questionText)
|
|
if (initialSnapshot.loginRequired) {
|
|
return {
|
|
status: 'failed',
|
|
summary: 'DeepSeek 账号未登录或登录态已失效,请先在 desktop client 中重新授权。',
|
|
error: buildAdapterError(
|
|
'deepseek_login_required',
|
|
initialSnapshot.loginReason ?? 'login_required',
|
|
{
|
|
page_url: initialSnapshot.url,
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
if (initialSnapshot.challengeRequired) {
|
|
return {
|
|
status: 'failed',
|
|
summary: 'DeepSeek 当前需要人工验证,相关监控任务已暂停。',
|
|
error: buildAdapterError(
|
|
'deepseek_challenge_required',
|
|
initialSnapshot.challengeReason ?? 'challenge_required',
|
|
{
|
|
page_url: initialSnapshot.url,
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
context.reportProgress('deepseek.query')
|
|
try {
|
|
await submitDeepSeekQuestion(page, questionText, context.signal)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return {
|
|
status: 'failed',
|
|
summary: `DeepSeek 提问失败:${message}`,
|
|
error: buildAdapterError('deepseek_submit_failed', message, {
|
|
page_url: safePageURL(page),
|
|
}),
|
|
}
|
|
}
|
|
|
|
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)
|
|
let revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(
|
|
() => [],
|
|
)
|
|
if (revealedSearchPanelLinks.length === 0) {
|
|
await maybeRevealDeepSeekReasoningSearchSources(page).catch(() => undefined)
|
|
revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(() => [])
|
|
}
|
|
const finalSnapshot = await readDeepSeekPageSnapshot(page, questionText).catch(
|
|
() => waitResult.finalSnapshot,
|
|
)
|
|
const bestSnapshot = selectBetterSnapshot(waitResult.finalSnapshot, finalSnapshot)
|
|
const citationPanelLinks = resolveDeepSeekCitationPanelLinks(
|
|
bestSnapshot.searchPanelLinks,
|
|
revealedSearchPanelLinks,
|
|
)
|
|
const mergedSnapshot: DeepSeekPageSnapshot = {
|
|
...bestSnapshot,
|
|
searchPanelLinks: citationPanelLinks,
|
|
}
|
|
const classifiedSources = classifyDeepseekSources({
|
|
inlineLinks: mergedSnapshot.inlineLinks,
|
|
sourcePanelLinks: mergedSnapshot.sourcePanelLinks,
|
|
searchPanelLinks: mergedSnapshot.searchPanelLinks,
|
|
})
|
|
|
|
if (isDeepSeekWaitFailure(waitResult)) {
|
|
const detail = waitResult.detail ?? waitResult.error
|
|
if (waitResult.error === 'deepseek_login_required') {
|
|
return {
|
|
status: 'failed',
|
|
summary: 'DeepSeek 账号未登录或登录态已失效,请先在 desktop client 中重新授权。',
|
|
error: buildAdapterError('deepseek_login_required', detail, {
|
|
page_url: mergedSnapshot.url,
|
|
}),
|
|
}
|
|
}
|
|
|
|
if (waitResult.error === 'deepseek_challenge_required') {
|
|
return {
|
|
status: 'failed',
|
|
summary: 'DeepSeek 当前需要人工验证,相关监控任务已暂停。',
|
|
error: buildAdapterError('deepseek_challenge_required', detail, {
|
|
page_url: mergedSnapshot.url,
|
|
}),
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 'unknown',
|
|
summary: 'DeepSeek 回答等待超时,已回写 unknown 等待后续对账。',
|
|
error: buildAdapterError(waitResult.error, detail, {
|
|
page_url: mergedSnapshot.url,
|
|
}),
|
|
payload: buildDeepSeekPayload(mergedSnapshot, classifiedSources, mergedSnapshot.answer),
|
|
}
|
|
}
|
|
|
|
const answer = normalizeText(mergedSnapshot.answer)
|
|
if (!answer) {
|
|
return {
|
|
status: 'unknown',
|
|
summary: 'DeepSeek 未返回可用答案,已回写 unknown 等待后续对账。',
|
|
error: buildAdapterError('deepseek_empty_answer', 'deepseek returned no final answer'),
|
|
payload: buildDeepSeekPayload(mergedSnapshot, classifiedSources, mergedSnapshot.answer),
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 'succeeded',
|
|
summary: 'DeepSeek 监控任务执行成功。',
|
|
payload: buildDeepSeekPayload(mergedSnapshot, classifiedSources, answer),
|
|
}
|
|
},
|
|
}
|
|
|
|
export const __deepseekTestUtils = {
|
|
extractQuestionText,
|
|
buildSourceItem,
|
|
buildDeepSeekPayload,
|
|
detectDeepSeekBusySignals,
|
|
dedupeSourceItems,
|
|
classifyDeepseekSources,
|
|
mergeDeepSeekRawSourceLinks,
|
|
resolveDeepSeekCitationPanelLinks,
|
|
isObservationComplete,
|
|
isDeepSeekToggleSelected,
|
|
isDeepSeekWebpagesTriggerText,
|
|
isDeepSeekReasoningSearchTriggerText,
|
|
}
|