From abacc1f421d304a784e49b30a169ddae1b091669 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 10 Jun 2026 11:06:51 +0800 Subject: [PATCH] fix(desktop-client): stop false manual-verification states and auto-revive probes after sleep/offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../desktop-client/src/main/account-binder.ts | 117 ++++++++++-------- .../desktop-client/src/main/account-health.ts | 75 ++++++++++- .../src/main/adapters/deepseek.ts | 76 ++++++++++-- .../src/main/adapters/doubao/auth-rules.ts | 5 +- .../src/main/adapters/kimi/auth-rules.ts | 5 +- .../src/main/adapters/qwen/auth-rules.ts | 5 +- .../src/main/challenge-signals.ts | 81 ++++++++++++ 7 files changed, 287 insertions(+), 77 deletions(-) create mode 100644 apps/desktop-client/src/main/challenge-signals.ts diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index f9d29d8..29def9e 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -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', diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 5e63088..c32673f 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -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 | null = null let activeProbeCount = 0 +let recoveryWatchersInstalled = false +let lastKnownOnline = true + +function sleep(ms: number): Promise { + 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( diff --git a/apps/desktop-client/src/main/adapters/deepseek.ts b/apps/desktop-client/src/main/adapters/deepseek.ts index e7fc3bc..e2e6f2b 100644 --- a/apps/desktop-client/src/main/adapters/deepseek.ts +++ b/apps/desktop-client/src/main/adapters/deepseek.ts @@ -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( diff --git a/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts b/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts index 6044a5d..9a5c60c 100644 --- a/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts +++ b/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts @@ -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) diff --git a/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts b/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts index 4df9864..5797e07 100644 --- a/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts +++ b/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts @@ -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; diff --git a/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts b/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts index eb56ac6..5c37455 100644 --- a/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts +++ b/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts @@ -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, diff --git a/apps/desktop-client/src/main/challenge-signals.ts b/apps/desktop-client/src/main/challenge-signals.ts new file mode 100644 index 0000000..962cc54 --- /dev/null +++ b/apps/desktop-client/src/main/challenge-signals.ts @@ -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()})()`