fix(desktop-client): stop false manual-verification states and auto-revive probes after sleep/offline
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>
This commit is contained in:
@@ -12,7 +12,7 @@ import { dirname, join } from 'node:path'
|
||||
import type { DesktopAccountInfo } from '@geo/shared-types'
|
||||
import { aiPlatformCatalog } from '@geo/shared-types'
|
||||
import type { Cookie, Session, WebContents } from 'electron/main'
|
||||
import { app, BrowserWindow, session as electronSession, nativeTheme } from 'electron/main'
|
||||
import { app, BrowserWindow, net, session as electronSession, nativeTheme } from 'electron/main'
|
||||
|
||||
import { normalizeAccountPlatformUid, sameAccountPlatformUid } from '../shared/account-identity'
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { readWenxinAccountIdentity } from './adapters/wenxin/account-identity'
|
||||
import { readYuanbaoAccountIdentity } from './adapters/yuanbao/account-identity'
|
||||
import type { AccountHealthProfile, AuthProbeResult } from './auth-types'
|
||||
import { PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET } from './challenge-signals'
|
||||
import {
|
||||
attachWindowDiagnostics,
|
||||
isWindowReadyForDetection,
|
||||
@@ -1131,7 +1132,6 @@ async function readGenericAIPageState(
|
||||
const stopWords = /^(登录|注册|设置|帮助|历史|聊天|新建|菜单|账户|账号|个人中心|联网搜索|深度思考|工具|更多|安装电脑版|下载电脑版|DeepSeek|元宝|混元|豆包|Kimi|Qwen|通义千问)$/i;
|
||||
const loginPattern = /(登录|注册|立即登录|扫码登录|微信登录|QQ登录|手机号登录|请先登录)/i;
|
||||
const loggedOutPattern = /(未登录|扫码登录|快捷登录|使用其他头像、昵称或账号|登录后继续|请先登录|立即登录|微信快捷登录)/i;
|
||||
const challengePattern = /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|手机验证码|扫码验证|verify|verification|captcha|security check)/i;
|
||||
const credentialKeyPattern = /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i;
|
||||
const isInsideAuthSurface = (node) => {
|
||||
if (!(node instanceof Element)) {
|
||||
@@ -1261,26 +1261,6 @@ async function readGenericAIPageState(
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const collectChallengeSignals = () => {
|
||||
const matches = [];
|
||||
if (bodyText && challengePattern.test(bodyText)) {
|
||||
matches.push('body-text');
|
||||
}
|
||||
const selectors = ['button', 'a', '[role="button"]', '[role="dialog"]', 'input', 'label'];
|
||||
for (const selector of selectors) {
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
if (!isVisible(node)) continue;
|
||||
const text = normalize(node.textContent || node.getAttribute?.('aria-label') || "");
|
||||
if (!text || !challengePattern.test(text)) continue;
|
||||
matches.push(text);
|
||||
if (matches.length >= 4) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
const collectCredentialStorage = (storage, prefix) => {
|
||||
const items = [];
|
||||
try {
|
||||
@@ -1333,7 +1313,7 @@ async function readGenericAIPageState(
|
||||
}
|
||||
const loginSignals = collectLoginSignals();
|
||||
const loggedOutSignals = collectLoggedOutSignals();
|
||||
const challengeSignals = collectChallengeSignals();
|
||||
const challengeSignalCount = ${PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET};
|
||||
const credentialFingerprintParts = [
|
||||
...collectCredentialStorage(window.localStorage, 'local'),
|
||||
...collectCredentialStorage(window.sessionStorage, 'session'),
|
||||
@@ -1356,7 +1336,7 @@ async function readGenericAIPageState(
|
||||
strongAuthSignalCount: strongSignals.length,
|
||||
loginSignalCount: loginSignals.length,
|
||||
loggedOutSignalCount: loggedOutSignals.length,
|
||||
challengeSignalCount: challengeSignals.length,
|
||||
challengeSignalCount,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -2378,6 +2358,27 @@ async function resolvePublishAccountProfileFromHandle(
|
||||
return toPublishAccountProfile(detected)
|
||||
}
|
||||
|
||||
// 断网时控制台页面可能命中本地缓存:页面渲染成功但账号接口全部失败,
|
||||
// 探测会走到"未检出账号 → expired"的兜底分支,把好账号误判成已失效。
|
||||
// expired 会停掉自动重探(nextProbeAt 置空),必须在兜底前先排除离线场景。
|
||||
function offlineNetworkProbeResult(): AuthProbeResult | null {
|
||||
try {
|
||||
if (net.isOnline()) {
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: 'network_error',
|
||||
reason: 'network_error',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: { code: 'offline' },
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeAIAccountSession(
|
||||
account: PublishAccountIdentity,
|
||||
partition?: string,
|
||||
@@ -2601,18 +2602,20 @@ export async function probeAIAccountSession(
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: 'expired',
|
||||
reason: 'login_redirect',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
strongAuthSignalCount: pageState.strongAuthSignalCount,
|
||||
loginSignalCount: pageState.loginSignalCount,
|
||||
loggedOutSignalCount: pageState.loggedOutSignalCount,
|
||||
},
|
||||
}
|
||||
return (
|
||||
offlineNetworkProbeResult() ?? {
|
||||
verdict: 'expired',
|
||||
reason: 'login_redirect',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
strongAuthSignalCount: pageState.strongAuthSignalCount,
|
||||
loginSignalCount: pageState.loginSignalCount,
|
||||
loggedOutSignalCount: pageState.loggedOutSignalCount,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
return {
|
||||
verdict: 'network_error',
|
||||
@@ -2869,16 +2872,18 @@ export async function probePublishAccountSession(
|
||||
}
|
||||
|
||||
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||||
return {
|
||||
verdict: 'expired',
|
||||
reason: 'login_redirect',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
code: 'account_api_empty',
|
||||
},
|
||||
}
|
||||
return (
|
||||
offlineNetworkProbeResult() ?? {
|
||||
verdict: 'expired',
|
||||
reason: 'login_redirect',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
code: 'account_api_empty',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const consoleReady =
|
||||
@@ -2894,15 +2899,17 @@ export async function probePublishAccountSession(
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: 'expired',
|
||||
reason: 'login_redirect',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
},
|
||||
}
|
||||
return (
|
||||
offlineNetworkProbeResult() ?? {
|
||||
verdict: 'expired',
|
||||
reason: 'login_redirect',
|
||||
profile: null,
|
||||
sessionFingerprint: null,
|
||||
evidence: {
|
||||
url: currentURL,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
return {
|
||||
verdict: 'network_error',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import type { DesktopAccountInfo } from '@geo/shared-types'
|
||||
import { app } from 'electron/main'
|
||||
import { app, net, powerMonitor } from 'electron/main'
|
||||
|
||||
import type { PublishAccountIdentity } from './account-binder'
|
||||
import type {
|
||||
@@ -35,6 +35,11 @@ const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000
|
||||
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000
|
||||
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const
|
||||
const CHALLENGE_RECHECK_ATTEMPTS = 1
|
||||
// 复核前留出缓冲:页面加载中的瞬态验证组件(无感验证/加载占位)几秒内会自行消失,
|
||||
// 立即复核会把瞬态误报固化成"需人工验证"。
|
||||
const CHALLENGE_RECHECK_DELAY_MS = process.env.VITEST ? 25 : 8_000
|
||||
const RECOVERY_PROBE_MIN_MS = 2_000
|
||||
const RECOVERY_PROBE_MAX_MS = 15_000
|
||||
|
||||
type ProbeTrigger = 'scheduled' | 'manual' | 'preflight' | 'confirm'
|
||||
|
||||
@@ -85,6 +90,20 @@ const probeQueue: string[] = []
|
||||
let initialized = false
|
||||
let probeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let activeProbeCount = 0
|
||||
let recoveryWatchersInstalled = false
|
||||
let lastKnownOnline = true
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isNetworkOnline(): boolean {
|
||||
try {
|
||||
return net.isOnline()
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
if (max <= min) {
|
||||
@@ -176,13 +195,60 @@ function ensureInitialized(): void {
|
||||
loadPersistedRecords()
|
||||
}
|
||||
|
||||
// 休眠唤醒/断网恢复后,给所有被跟踪账号(包括已标记 expired、nextProbeAt 已被
|
||||
// 置空的账号)重新排一次短延迟探测,让会话状态自动回到真实值,而不是停留在
|
||||
// 断网期间产生的离线/失效快照上。
|
||||
function scheduleRecoveryProbes(reason: string): void {
|
||||
ensureInitialized()
|
||||
const now = Date.now()
|
||||
let touched = false
|
||||
|
||||
for (const accountId of trackedAccounts.keys()) {
|
||||
const record = records.get(accountId)
|
||||
if (!record || isProbeInFlight(record)) {
|
||||
continue
|
||||
}
|
||||
record.nextProbeAt = now + randomInt(RECOVERY_PROBE_MIN_MS, RECOVERY_PROBE_MAX_MS)
|
||||
touched = true
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
persistRecords()
|
||||
console.info('[desktop-account-health] recovery probes scheduled', { reason })
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRecoveryWatchers(): void {
|
||||
if (recoveryWatchersInstalled) {
|
||||
return
|
||||
}
|
||||
recoveryWatchersInstalled = true
|
||||
lastKnownOnline = isNetworkOnline()
|
||||
|
||||
try {
|
||||
powerMonitor.on('resume', () => scheduleRecoveryProbes('power_resume'))
|
||||
powerMonitor.on('unlock-screen', () => scheduleRecoveryProbes('unlock_screen'))
|
||||
} catch {
|
||||
// powerMonitor 在 app ready 之前或测试环境不可用;此时仅依赖下方的网络轮询恢复
|
||||
}
|
||||
}
|
||||
|
||||
function ensureProbeTimer(): void {
|
||||
ensureInitialized()
|
||||
ensureRecoveryWatchers()
|
||||
if (probeTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
probeTimer = setInterval(() => {
|
||||
const onlineNow = isNetworkOnline()
|
||||
if (onlineNow && !lastKnownOnline) {
|
||||
scheduleRecoveryProbes('network_restored')
|
||||
}
|
||||
lastKnownOnline = onlineNow
|
||||
if (!onlineNow) {
|
||||
return
|
||||
}
|
||||
void runDueProbes()
|
||||
}, PROBE_TICK_MS)
|
||||
}
|
||||
@@ -400,6 +466,7 @@ async function recheckChallengeResult(
|
||||
|
||||
let confirmed = result
|
||||
for (let attempt = 0; attempt < CHALLENGE_RECHECK_ATTEMPTS; attempt += 1) {
|
||||
await sleep(CHALLENGE_RECHECK_DELAY_MS)
|
||||
confirmed = await probeAdapter(adapter, account, partition)
|
||||
if (confirmed.verdict !== 'challenge_required') {
|
||||
return confirmed
|
||||
@@ -442,6 +509,12 @@ async function performProbeLocked(
|
||||
record.probeState = 'probing'
|
||||
commitRecord(record, previousSignature)
|
||||
|
||||
// 离线时页面探测只能得到缓存渲染或加载失败,任何"过期/需验证"的结论都不可信,
|
||||
// 直接按网络错误退避,等网络恢复后由 recovery 探测纠正。
|
||||
if (!isNetworkOnline()) {
|
||||
return applyNetworkResult(record, networkProbeResult(), Date.now(), options.trigger)
|
||||
}
|
||||
|
||||
const partition = resolveKnownPartition(account.id)
|
||||
if (!partition) {
|
||||
return applyExpiredResult(
|
||||
|
||||
@@ -2379,20 +2379,72 @@ async function readDeepSeekPageSnapshot(
|
||||
}
|
||||
|
||||
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') ||
|
||||
Boolean(
|
||||
bodyText &&
|
||||
/(log in|send code|scan with wechat to login|phone number|continue with deepseek|和 deepseek 继续聊|登录|发送验证码|手机号|验证码)/i.test(
|
||||
bodyText,
|
||||
),
|
||||
)
|
||||
const challengeRequired = Boolean(
|
||||
bodyText &&
|
||||
/(verification required|captcha|security check|verify|challenge|人机验证|安全验证|请完成验证|验证码|风控|环境异常)/i.test(
|
||||
bodyText,
|
||||
),
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Cookie, Session, WebContents } from 'electron/main'
|
||||
|
||||
import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types'
|
||||
import { PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET } from '../../challenge-signals'
|
||||
import type { GenericAIPageState } from '../../generic-ai-auth'
|
||||
import { normalizeRemoteUrl } from '../common'
|
||||
|
||||
@@ -269,9 +270,7 @@ export async function readDoubaoAuthPageState(
|
||||
const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录)/.test(bodyText)
|
||||
? 1
|
||||
: 0;
|
||||
const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText)
|
||||
? 1
|
||||
: 0;
|
||||
const challengeSignalCount = ${PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET};
|
||||
const account = pickAccountTrigger();
|
||||
const displayName = account?.displayName || null;
|
||||
const avatarUrl = isAccountAvatarUrl(account?.avatarUrl || null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Cookie, Session, WebContents } from 'electron/main'
|
||||
|
||||
import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types'
|
||||
import { PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET } from '../../challenge-signals'
|
||||
import type { GenericAIPageState } from '../../generic-ai-auth'
|
||||
import { normalizeRemoteUrl } from '../common'
|
||||
|
||||
@@ -195,9 +196,7 @@ export async function readKimiAuthPageState(
|
||||
const loggedOutSignalCount = /(登录以同步历史会话|微信扫码登录|手机号快捷登录|请输入手机号|请输入验证码|扫码登录|未登录|登录后继续|请先登录)/.test(bodyText)
|
||||
? 1
|
||||
: 0;
|
||||
const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification|NECAPTCHA)/i.test(bodyText)
|
||||
? 1
|
||||
: 0;
|
||||
const challengeSignalCount = ${PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET};
|
||||
const stopWords = /^(Kimi|Kimi Code|Kimi Claw|历史会话|新建会话|查看全部|设置|会员计划|关于我们|Language|用户反馈|获取应用程序|升级|升级套餐|登录|注册|虚拟用户)$/i;
|
||||
const isKimiAvatarUrl = (src) => {
|
||||
if (!src) return false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Cookie, Session, WebContents } from 'electron/main'
|
||||
|
||||
import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types'
|
||||
import { PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET } from '../../challenge-signals'
|
||||
import type { GenericAIPageState } from '../../generic-ai-auth'
|
||||
import { normalizeRemoteUrl } from '../common'
|
||||
|
||||
@@ -370,9 +371,7 @@ export async function readQwenAuthPageState(
|
||||
const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录|登录可同步历史对话)/.test(bodyText)
|
||||
? 1
|
||||
: 0;
|
||||
const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText)
|
||||
? 1
|
||||
: 0;
|
||||
const challengeSignalCount = ${PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET};
|
||||
return {
|
||||
displayName: sidebarAccount?.displayName || user?.displayName || null,
|
||||
avatarUrl: sidebarAccount?.avatarUrl || user?.avatarUrl || null,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 页面内"人工验证"信号判定。
|
||||
*
|
||||
* 旧实现对整页 innerText 做宽泛关键词匹配(验证码/安全验证/captcha/verification…),
|
||||
* 聊天正文、会话标题、帮助文案里出现这些词就会被误判为需要人工验证。
|
||||
* 这里收紧为三类真实挑战证据,三者满足其一才计数:
|
||||
* 1. 已知验证码厂商组件可见(极验/阿里云盾/网易易盾/腾讯防水墙/hCaptcha/reCAPTCHA/Turnstile…);
|
||||
* 2. 可见弹层(dialog/modal/captcha 容器)自身文案命中挑战短语,且文案很短(排除聊天内容容器);
|
||||
* 3. 整页是稀疏拦截页(正文极短且命中挑战短语,典型如风控拦截/盾页面)。
|
||||
*/
|
||||
export function pageChallengeSignalCount(): number {
|
||||
try {
|
||||
const phrasePattern =
|
||||
/(人机验证|安全验证|请完成验证|完成下方验证|拖动滑块|滑动验证|向右滑动|安全检测|访问异常|环境异常|verification required|security check|unusual traffic|verify (?:you are|to continue)|not a robot|请输入(?:图形)?验证码)/i
|
||||
const isVisible = (node: Element): boolean => {
|
||||
const style = window.getComputedStyle(node)
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||
return false
|
||||
}
|
||||
const rect = node.getBoundingClientRect()
|
||||
return rect.width > 4 && rect.height > 4
|
||||
}
|
||||
const normalize = (value: string | null | undefined): string =>
|
||||
(value || '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
const vendorSelectors = [
|
||||
'[class*="geetest_"]',
|
||||
'[id^="nc_"][id$="_wrapper"]',
|
||||
'.nc-container',
|
||||
'[class*="nocaptcha"]',
|
||||
'[class*="yidun_modal"]',
|
||||
'[class*="yidun_popup"]',
|
||||
'[class*="yidun_panel"]',
|
||||
'[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 1
|
||||
}
|
||||
}
|
||||
|
||||
const surfaceSelectors =
|
||||
'[role="dialog"], [aria-modal="true"], [class*="captcha"], [id*="captcha"], [class*="verify"], [class*="Verify"]'
|
||||
for (const node of Array.from(document.querySelectorAll(surfaceSelectors)).slice(0, 50)) {
|
||||
if (!isVisible(node)) {
|
||||
continue
|
||||
}
|
||||
const text = normalize(
|
||||
node instanceof HTMLElement ? node.innerText : node.textContent || '',
|
||||
)
|
||||
if (text && text.length <= 240 && phrasePattern.test(text)) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = normalize(document.body ? document.body.innerText : '')
|
||||
if (bodyText && bodyText.length <= 400 && phrasePattern.test(bodyText)) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 webContents.executeJavaScript 模板字符串使用的表达式片段,
|
||||
* 求值结果为 number(0 或 1)。
|
||||
*/
|
||||
export const PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET = `(${pageChallengeSignalCount.toString()})()`
|
||||
Reference in New Issue
Block a user