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' // Doubao binding rules are deliberately isolated here. // Doubao can chat anonymously, while Geo Rankly requires a real logged-in Doubao // account before binding. Do not move these DOM selectors or cookie names into // generic AI auth helpers, and do not reuse them for Qwen/Kimi/DeepSeek. export const DOUBAO_AUTH_PROBE_URL = 'https://www.doubao.com/chat/' const DOUBAO_AUTH_COOKIE_NAMES = new Set([ 'flow_cur_user_sec_id', 'flow_multi_user_sec_info', 'multi_sids', 'n_mh', 'passport_auth_token', 'passport_auth_token_default', 'session_tlb_tag', 'sessionid', 'sessionid_ss', 'sid_guard', 'sid_tt', 'sid_ucp_sso_v1', 'sid_ucp_v1', 'sso_uid_tt', 'sso_uid_tt_ss', 'ssid_ucp_sso_v1', 'ssid_ucp_v1', 'uid_tt', 'uid_tt_ss', 'x-tt-multi-sids', ]) function normalizeText(value: unknown): string | null { if (typeof value !== 'string') { return null } const trimmed = value.trim().replace(/\s+/g, ' ') return trimmed ? trimmed : null } function hasCredentialValue(value: string | null | undefined): boolean { const normalized = normalizeText(value) if (!normalized) { return false } if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) { return false } return normalized.length >= 8 } function hashText(value: string): string { let hash = 0 for (let index = 0; index < value.length; index += 1) { hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 } return Math.abs(hash).toString(16).padStart(8, '0') } function boundedIdentity(prefix: string, raw: string): string { const normalizedPrefix = prefix.trim().replace(/:+$/, '') const normalizedRaw = raw.trim() if (!normalizedRaw) { return `${normalizedPrefix}:${hashText(prefix)}` } const candidate = `${normalizedPrefix}:${normalizedRaw}` if (candidate.length <= 120) { return candidate } return `${normalizedPrefix}:${hashText(normalizedRaw)}` } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function normalizeDoubaoPageStateSnapshot(state: unknown): GenericAIPageState | null { if (!state || typeof state !== 'object') { return null } const typed = state as Partial return { displayName: normalizeText(typed.displayName), avatarUrl: normalizeRemoteUrl(typed.avatarUrl), fingerprint: null, credentialFingerprint: null, currentPath: normalizeText(typed.currentPath), strongAuthSignalCount: typeof typed.strongAuthSignalCount === 'number' && Number.isFinite(typed.strongAuthSignalCount) ? typed.strongAuthSignalCount : 0, loginSignalCount: typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount) ? typed.loginSignalCount : 0, loggedOutSignalCount: typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount) ? typed.loggedOutSignalCount : 0, challengeSignalCount: typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount) ? typed.challengeSignalCount : 0, } } export function doubaoHasBoundAccountPageSignal( pageState: GenericAIPageState | null | undefined, ): boolean { return Boolean( pageState && pageState.strongAuthSignalCount >= 2 && (pageState.displayName || pageState.avatarUrl), ) } export function isDoubaoLoggedInCredentialCookieName(name: string): boolean { return DOUBAO_AUTH_COOKIE_NAMES.has(name.trim().toLowerCase()) } export function isDoubaoLoggedInCredentialCookie( cookie: Pick, pageState: GenericAIPageState | null | undefined, ): boolean { return ( doubaoHasBoundAccountPageSignal(pageState) && isDoubaoLoggedInCredentialCookieName(cookie.name) && hasCredentialValue(cookie.value) ) } export async function buildDoubaoSessionCredentialEvidence( session: Session, urls: Iterable, pageState: GenericAIPageState | null | undefined, ): Promise { if (!doubaoHasBoundAccountPageSignal(pageState)) { return null } const parts: string[] = [] for (const url of urls) { try { const cookies = await session.cookies.get({ url }) const authCookies = cookies.filter((cookie) => isDoubaoLoggedInCredentialCookie(cookie, pageState), ) if (authCookies.length === 0) { continue } const serialized = authCookies .map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`) .sort() .join(';') parts.push(`${url}:${serialized}`) } catch { continue } } return parts.length > 0 ? parts.join('||') : null } export async function readDoubaoAuthPageState( webContents: WebContents | null | undefined, ): Promise { if (!webContents || webContents.isDestroyed()) { return null } const state = await webContents .executeJavaScript( `(() => { try { const normalize = (value) => { if (typeof value !== "string") return null; const trimmed = value.trim().replace(/\\s+/g, " "); return trimmed || null; }; const isVisible = (node) => { if (!(node instanceof Element)) 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 isInsideAuthSurface = (node) => { let current = node instanceof Element ? node : null; for (let depth = 0; current && depth < 8; depth += 1) { const descriptor = [ current.getAttribute('role') || '', current.getAttribute('aria-modal') || '', current.getAttribute('data-testid') || '', current.id || '', current.className || '', ].join(' '); if (/(dialog|modal|popup|popover|login|signin|auth|scan|qrcode)/i.test(descriptor)) { return true; } current = current.parentElement; } return false; }; const isDoubaoAvatarUrl = (src) => { if (!src) return false; return /passport\\.byteacctimg\\.com\\/img\\/user-avatar\\//i.test(src); }; const isAccountAvatarUrl = (src) => { if (!src) return false; if (isDoubaoAvatarUrl(src)) return true; return !/(logo|icon|favicon|doubao_avatar|flow-doubao)/i.test(src); }; const accountTextStopWords = /^(豆包|Doubao|新对话|最近对话|历史对话|AI 创作|云盘|更多|设置|收藏夹|豆包官网|API 服务|下载电脑版|下载手机应用|退出登录|登录|注册)$/i; const pickAccountText = (trigger) => { const candidates = []; for (const node of Array.from(trigger.querySelectorAll('div, span, p'))) { if (!(node instanceof HTMLElement) || !isVisible(node)) continue; const text = normalize(node.textContent || ""); if (!text || text.length > 32 || accountTextStopWords.test(text)) continue; const rect = node.getBoundingClientRect(); candidates.push({ text, width: rect.width, left: rect.left }); } candidates.sort((left, right) => right.width - left.width || left.left - right.left); return candidates[0]?.text || null; }; const pickAccountTrigger = () => { const sidebar = document.querySelector('#flow_chat_sidebar'); if (!(sidebar instanceof HTMLElement) || !isVisible(sidebar)) return null; const triggers = Array.from( sidebar.querySelectorAll('button[data-slot="dropdown-menu-trigger"][aria-haspopup="menu"], button[aria-haspopup="menu"]'), ); const sidebarRect = sidebar.getBoundingClientRect(); const lowerBandTop = sidebarRect.top + sidebarRect.height * 0.6; for (const node of triggers) { if (!(node instanceof HTMLElement) || !isVisible(node) || isInsideAuthSurface(node)) { continue; } const rect = node.getBoundingClientRect(); if (rect.top < lowerBandTop) { continue; } const img = node.querySelector('img'); const avatarUrl = img instanceof HTMLImageElement ? normalize(img.currentSrc || img.src || "") : null; const displayName = pickAccountText(node); if (displayName || isAccountAvatarUrl(avatarUrl)) { return { trigger: node, displayName, avatarUrl }; } } return null; }; const bodyText = normalize(document.body?.innerText || "") || ""; const loginSignalCount = /(登录|注册|扫码登录|手机号登录|请先登录|立即登录|登录后)/.test(bodyText) ? 1 : 0; const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录)/.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) ? account.avatarUrl : null; const strongSignals = []; if (account) strongSignals.push('doubaoSidebarAccountMenu'); if (displayName) strongSignals.push('displayName'); if (avatarUrl) strongSignals.push('avatar'); if (isDoubaoAvatarUrl(avatarUrl)) strongSignals.push('doubaoPassportAvatar'); return { displayName, avatarUrl, fingerprint: null, credentialFingerprint: null, currentPath: normalize(window.location.pathname || ''), strongAuthSignalCount: strongSignals.length, loginSignalCount, loggedOutSignalCount, challengeSignalCount, }; } catch { return null; } })();`, true, ) .catch(() => null) return normalizeDoubaoPageStateSnapshot(state) } export async function probeDoubaoAccountSession(input: { session: Session webContents: WebContents | null | undefined urls: Iterable currentURL: string fallbackDisplayName: string expectedPlatformUid?: string | null }): Promise { const startedAt = Date.now() let pageState: GenericAIPageState | null = null let credentialEvidence: string | null = null while (Date.now() - startedAt < 8_000) { pageState = await readDoubaoAuthPageState(input.webContents).catch(() => null) if ((pageState?.challengeSignalCount ?? 0) > 0) { break } if ((pageState?.loggedOutSignalCount ?? 0) > 0) { break } if (doubaoHasBoundAccountPageSignal(pageState)) { credentialEvidence = await buildDoubaoSessionCredentialEvidence( input.session, input.urls, pageState, ) if (credentialEvidence) { break } } await delay(250) } if ((pageState?.challengeSignalCount ?? 0) > 0) { return { verdict: 'challenge_required', reason: 'captcha_gate', profile: null, sessionFingerprint: null, evidence: { url: input.currentURL, challengeSignalCount: pageState?.challengeSignalCount ?? 0, }, } } if ((pageState?.loggedOutSignalCount ?? 0) > 0) { return { verdict: 'expired', reason: 'login_redirect', profile: null, sessionFingerprint: null, evidence: { url: input.currentURL, loggedOutSignalCount: pageState?.loggedOutSignalCount ?? 0, }, } } if (!doubaoHasBoundAccountPageSignal(pageState)) { return { verdict: 'expired', reason: 'login_redirect', profile: null, sessionFingerprint: null, evidence: { url: input.currentURL, strongAuthSignalCount: pageState?.strongAuthSignalCount ?? 0, loginSignalCount: pageState?.loginSignalCount ?? 0, code: 'doubao_account_menu_missing', }, } } if (!credentialEvidence) { return { verdict: 'expired', reason: 'login_redirect', profile: null, sessionFingerprint: null, evidence: { url: input.currentURL, code: 'doubao_login_cookie_missing', }, } } const sessionFingerprint = boundedIdentity('doubao-session', credentialEvidence) const profile: AccountHealthProfile = { subjectUid: sessionFingerprint, displayName: pageState?.displayName ?? input.fallbackDisplayName, avatarUrl: pageState?.avatarUrl ?? null, } return { verdict: 'active', reason: 'probe_success', profile, sessionFingerprint, } }