408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
|
|
import type { Cookie, Session, WebContents } from 'electron/main'
|
||
|
|
|
||
|
|
import { normalizeRemoteUrl } from '../common'
|
||
|
|
import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types'
|
||
|
|
import type { GenericAIPageState } from '../../generic-ai-auth'
|
||
|
|
|
||
|
|
// 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<void> {
|
||
|
|
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<GenericAIPageState>
|
||
|
|
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<Cookie, 'name' | 'value'>,
|
||
|
|
pageState: GenericAIPageState | null | undefined,
|
||
|
|
): boolean {
|
||
|
|
return (
|
||
|
|
doubaoHasBoundAccountPageSignal(pageState) &&
|
||
|
|
isDoubaoLoggedInCredentialCookieName(cookie.name) &&
|
||
|
|
hasCredentialValue(cookie.value)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function buildDoubaoSessionCredentialEvidence(
|
||
|
|
session: Session,
|
||
|
|
urls: Iterable<string>,
|
||
|
|
pageState: GenericAIPageState | null | undefined,
|
||
|
|
): Promise<string | null> {
|
||
|
|
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<GenericAIPageState | null> {
|
||
|
|
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 = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText)
|
||
|
|
? 1
|
||
|
|
: 0;
|
||
|
|
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<string>
|
||
|
|
currentURL: string
|
||
|
|
fallbackDisplayName: string
|
||
|
|
expectedPlatformUid?: string | null
|
||
|
|
}): Promise<AuthProbeResult> {
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
}
|