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:
2026-06-10 11:06:51 +08:00
parent c7bad83496
commit abacc1f421
7 changed files with 287 additions and 77 deletions
+62 -55
View File
@@ -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',
+74 -1
View File
@@ -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 模板字符串使用的表达式片段,
* 求值结果为 number0 或 1)。
*/
export const PAGE_CHALLENGE_SIGNAL_COUNT_SNIPPET = `(${pageChallengeSignalCount.toString()})()`