ddce8b3d8d
Move the Qwen page-state probe, session detection, and silent probe helpers out of account-binder into apps/desktop-client/src/main/adapters/qwen, mirroring the Kimi adapter layout. Generic AI auth no longer special-cases qwen since the platform now owns its own credential rules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4278 lines
122 KiB
TypeScript
4278 lines
122 KiB
TypeScript
import { createHash } from 'node:crypto'
|
||
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||
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 { BrowserWindow, app, session as electronSession, nativeTheme } from 'electron/main'
|
||
|
||
import { normalizeAccountPlatformUid, sameAccountPlatformUid } from '../shared/account-identity'
|
||
import {
|
||
normalizeRemoteUrl,
|
||
pageFetchJson,
|
||
sessionCookieFetchJson,
|
||
sessionCookieHeader,
|
||
sessionCookieValue,
|
||
sessionFetchJson,
|
||
sessionFetchText,
|
||
} from './adapters/common'
|
||
import {
|
||
buildDoubaoSessionCredentialEvidence,
|
||
DOUBAO_AUTH_PROBE_URL,
|
||
doubaoHasBoundAccountPageSignal,
|
||
probeDoubaoAccountSession,
|
||
readDoubaoAuthPageState,
|
||
} from './adapters/doubao/auth-rules'
|
||
import {
|
||
detectKimiAccountFromSession,
|
||
KIMI_AUTH_PROBE_URL,
|
||
probeKimiAccountSession,
|
||
readKimiAuthPageState,
|
||
} from './adapters/kimi/auth-rules'
|
||
import {
|
||
detectQwenAccountFromSession,
|
||
probeQwenAccountSession,
|
||
QWEN_AUTH_PROBE_URL,
|
||
readQwenAuthPageState,
|
||
} from './adapters/qwen/auth-rules'
|
||
import type { AccountHealthProfile, AuthProbeResult } from './auth-types'
|
||
import {
|
||
attachWindowDiagnostics,
|
||
isWindowReadyForDetection,
|
||
loadWindowURLSafely,
|
||
} from './external-window'
|
||
import {
|
||
genericAIHasVisibleAccountSignal,
|
||
genericAIPlatformRequiresVisibleCredentialSignal,
|
||
isLikelyPlatformAICredentialCookie,
|
||
type GenericAIPageState,
|
||
} from './generic-ai-auth'
|
||
import {
|
||
attachSessionHandle,
|
||
createPendingSessionHandle,
|
||
createSessionHandle,
|
||
createSessionHandleForPartition,
|
||
forgetSessionHandle,
|
||
getPersistedPartition,
|
||
getSessionHandle,
|
||
type SessionHandle,
|
||
} from './session-registry'
|
||
import { upsertDesktopAccount } from './transport/api-client'
|
||
import { STANDARD_USER_AGENT } from './user-agent'
|
||
|
||
interface DetectedAccount {
|
||
platformUid: string
|
||
displayName: string
|
||
avatarUrl: string | null
|
||
}
|
||
|
||
interface DetectContext {
|
||
session: Session
|
||
webContents: WebContents
|
||
}
|
||
|
||
interface PublishPlatformBindingDefinition {
|
||
id: string
|
||
label: string
|
||
loginUrl: string
|
||
consoleUrl: string
|
||
loginRedirectUrls?: string[]
|
||
detect(context: DetectContext): Promise<DetectedAccount | null>
|
||
}
|
||
|
||
export interface PublishAccountIdentity {
|
||
id: string
|
||
platform: string
|
||
platformUid: string
|
||
displayName: string
|
||
}
|
||
|
||
export interface PublishAccountProfile {
|
||
platformUid: string
|
||
displayName: string
|
||
avatarUrl: string | null
|
||
}
|
||
|
||
export interface PublishAccountLocalInspection {
|
||
availability: 'missing' | 'ready' | 'expired'
|
||
profile: PublishAccountProfile | null
|
||
}
|
||
|
||
interface ActiveBindOperation {
|
||
platformId: string
|
||
window: BrowserWindow
|
||
promise: Promise<DesktopAccountInfo>
|
||
}
|
||
|
||
interface WeixinGzhConsoleState {
|
||
loggedOut: boolean
|
||
title: string
|
||
textSnippet: string
|
||
}
|
||
|
||
type ToutiaoMediaInfoResponse = {
|
||
data?: {
|
||
user?: {
|
||
id_str?: string | number
|
||
screen_name?: string
|
||
https_avatar_url?: string
|
||
}
|
||
user_info?: {
|
||
id_str?: string | number
|
||
user_id_str?: string | number
|
||
screen_name?: string
|
||
name?: string
|
||
https_avatar_url?: string
|
||
avatar_url?: string
|
||
}
|
||
media?: {
|
||
id?: string | number
|
||
media_id?: string | number
|
||
media_name?: string
|
||
screen_name?: string
|
||
https_avatar_url?: string
|
||
avatar_url?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
type BaijiahaoAppInfoResponse = {
|
||
data?: {
|
||
user?: {
|
||
userid?: string | number
|
||
name?: string
|
||
uname?: string
|
||
username?: string
|
||
avatar?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
type SohuRegisterInfoResponse = {
|
||
data?: {
|
||
account?: {
|
||
id?: string | number
|
||
nickName?: string
|
||
avatar?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
type QiehaoBasicInfoResponse = {
|
||
data?: {
|
||
cpInfo?: {
|
||
mediaId?: string | number
|
||
mediaName?: string
|
||
header?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
type ZhihuMeResponse = {
|
||
uid?: string
|
||
id?: string
|
||
name?: string
|
||
avatar_url?: string
|
||
}
|
||
|
||
type WangyiInfoResponse = {
|
||
code?: number | string
|
||
message?: string
|
||
msg?: string
|
||
data?: {
|
||
mediaInfo?: {
|
||
userId?: string | number
|
||
wemediaId?: string | number
|
||
tname?: string
|
||
icon?: string
|
||
}
|
||
}
|
||
}
|
||
type WangyiInfoFetchOptions = {
|
||
allowCookieWrite?: boolean
|
||
}
|
||
type StoredWangyihaoCookie = Pick<
|
||
Cookie,
|
||
| 'expirationDate'
|
||
| 'hostOnly'
|
||
| 'httpOnly'
|
||
| 'name'
|
||
| 'path'
|
||
| 'sameSite'
|
||
| 'secure'
|
||
| 'session'
|
||
| 'value'
|
||
> & {
|
||
domain: string
|
||
}
|
||
type WangyihaoCookieVault = Record<
|
||
string,
|
||
{
|
||
updatedAt: number
|
||
cookies: StoredWangyihaoCookie[]
|
||
}
|
||
>
|
||
|
||
type JianshuUser = {
|
||
id?: string | number
|
||
nickname?: string
|
||
avatar?: string
|
||
}
|
||
|
||
type BilibiliNavResponse = {
|
||
code?: number
|
||
data?: {
|
||
isLogin?: boolean
|
||
mid?: string | number
|
||
uname?: string
|
||
face?: string
|
||
wbi_img?: {
|
||
img_url?: string
|
||
sub_url?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
type BilibiliSpaceInfoResponse = {
|
||
code?: number
|
||
data?: {
|
||
face?: string
|
||
}
|
||
}
|
||
|
||
type JuejinProfileResponse = {
|
||
err_no?: number
|
||
err_msg?: string
|
||
data?: {
|
||
bui_user?: {
|
||
user_id?: string
|
||
screen_name?: string
|
||
avatar_large?: string
|
||
avatar_url?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
type SmzdmCurrentUser = {
|
||
smzdm_id?: string | number
|
||
nickname?: string
|
||
audit_nickname?: string
|
||
avatar?: string
|
||
data?: {
|
||
smzdm_id?: string | number
|
||
nickname?: string
|
||
audit_nickname?: string
|
||
avatar?: string
|
||
}
|
||
}
|
||
|
||
type SmzdmPageState = {
|
||
displayName: string | null
|
||
avatarUrl: string | null
|
||
loggedInPage: boolean
|
||
}
|
||
|
||
type ZolUserInfoResponse = {
|
||
data?: {
|
||
userId?: string | number
|
||
nickName?: string
|
||
photo?: string
|
||
}
|
||
}
|
||
|
||
type DongchediAccountInfoResponse = {
|
||
data?: {
|
||
user_id_str?: string
|
||
name?: string
|
||
avatar_url?: string
|
||
}
|
||
}
|
||
|
||
const activeBindOperations = new Map<string, ActiveBindOperation>()
|
||
const MAX_CONCURRENT_BIND_WINDOWS = 2
|
||
|
||
const TOUTIAO_LOGIN_URL = 'https://mp.toutiao.com/auth/page/login'
|
||
const TOUTIAO_CONSOLE_HOME_URL = 'https://mp.toutiao.com/profile_v4/index'
|
||
const TOUTIAO_WORKBENCH_URL_PATTERN = /^https:\/\/mp\.toutiao\.com\/profile_v4\//i
|
||
const WEIXIN_GZH_HOME_URL = 'https://mp.weixin.qq.com/'
|
||
const WEIXIN_GZH_LOGIN_URL = 'https://mp.weixin.qq.com/cgi-bin/loginpage'
|
||
const WEIXIN_GZH_DRAFT_LIST_URL = 'https://mp.weixin.qq.com/cgi-bin/appmsg'
|
||
const BILIBILI_API_ORIGIN = 'https://api.bilibili.com'
|
||
const ZOL_API_ORIGIN = 'https://open-api.zol.com.cn'
|
||
const ZOL_REFERER = 'https://post.zol.com.cn/'
|
||
const ZOL_CONSOLE_URL = 'https://post.zol.com.cn/v2/manage/works/all'
|
||
const ZOL_LOGIN_REDIRECT_URLS = [
|
||
'https://passport.zol.com.cn/',
|
||
'https://service.zol.com.cn/user/login',
|
||
]
|
||
const WANGYI_API_ORIGIN = 'https://mp.163.com'
|
||
const WANGYI_CONSOLE_ORIGIN = 'https://mp.163.com'
|
||
const WANGYI_LEGACY_CONSOLE_ORIGIN = 'http://mp.163.com'
|
||
const WANGYI_API_REFERER = `${WANGYI_API_ORIGIN}/subscribe_v4/index.html`
|
||
const WANGYI_INFO_URL = `${WANGYI_API_ORIGIN}/wemedia/info.do`
|
||
const WANGYI_CONSOLE_URL = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html`
|
||
const WANGYI_LOGIN_REDIRECT_URLS = [
|
||
`${WANGYI_API_ORIGIN}/login.html`,
|
||
`${WANGYI_CONSOLE_ORIGIN}/login.html`,
|
||
`${WANGYI_LEGACY_CONSOLE_ORIGIN}/login.html`,
|
||
'https://reg.163.com/',
|
||
'https://dl.reg.163.com/',
|
||
]
|
||
const WANGYI_COOKIE_URLS = [
|
||
WANGYI_API_ORIGIN,
|
||
WANGYI_CONSOLE_ORIGIN,
|
||
WANGYI_LEGACY_CONSOLE_ORIGIN,
|
||
'https://reg.163.com/',
|
||
'https://dl.reg.163.com/',
|
||
]
|
||
const BILIBILI_WBI_MIXIN_KEY_ENC_TAB = [
|
||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28,
|
||
14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54,
|
||
21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
||
]
|
||
|
||
function sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
setTimeout(resolve, ms)
|
||
})
|
||
}
|
||
|
||
function normalizeComparableText(value: string): string {
|
||
return value.trim().replace(/\s+/g, '').toLowerCase()
|
||
}
|
||
|
||
function partitionsRootPath(): string {
|
||
return join(app.getPath('userData'), 'Partitions')
|
||
}
|
||
|
||
function listPendingPartitions(platformId: string): string[] {
|
||
try {
|
||
return readdirSync(partitionsRootPath(), { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`pending-${platformId}-`))
|
||
.map((entry) => {
|
||
const absolutePath = join(partitionsRootPath(), entry.name)
|
||
let mtimeMs = 0
|
||
try {
|
||
mtimeMs = statSync(absolutePath).mtimeMs
|
||
} catch {
|
||
mtimeMs = 0
|
||
}
|
||
return {
|
||
partition: `persist:${entry.name}`,
|
||
mtimeMs,
|
||
}
|
||
})
|
||
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
||
.map((item) => item.partition)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function normalizeText(value: unknown): string | null {
|
||
if (typeof value !== 'string') {
|
||
return null
|
||
}
|
||
const trimmed = value.trim()
|
||
return trimmed ? trimmed : null
|
||
}
|
||
|
||
function stringId(value: unknown): string {
|
||
if (typeof value === 'number') {
|
||
return Number.isFinite(value) ? String(value) : ''
|
||
}
|
||
return normalizeText(value) ?? ''
|
||
}
|
||
|
||
function decodeCookieText(value: string): string {
|
||
const source = value.replace(/\+/g, ' ')
|
||
try {
|
||
return decodeURIComponent(source)
|
||
} catch {
|
||
return source
|
||
}
|
||
}
|
||
|
||
function hashText(value: string): string {
|
||
return createHash('sha1').update(value).digest('hex').slice(0, 20)
|
||
}
|
||
|
||
function buildBilibiliMixinKey(imgKey: string, subKey: string): string {
|
||
const raw = imgKey + subKey
|
||
return BILIBILI_WBI_MIXIN_KEY_ENC_TAB.map((index) => raw.charAt(index))
|
||
.filter(Boolean)
|
||
.join('')
|
||
.slice(0, 32)
|
||
}
|
||
|
||
function bilibiliKeyFromWbiURL(value: string | undefined): string {
|
||
const source = value?.trim() || ''
|
||
if (!source) {
|
||
return ''
|
||
}
|
||
const slashIndex = source.lastIndexOf('/')
|
||
const dotIndex = source.lastIndexOf('.')
|
||
if (slashIndex < 0 || dotIndex <= slashIndex) {
|
||
return ''
|
||
}
|
||
return source.slice(slashIndex + 1, dotIndex)
|
||
}
|
||
|
||
function buildBilibiliWbiQuery(
|
||
nav: BilibiliNavResponse | null | undefined,
|
||
params: Record<string, string | number>,
|
||
): string | null {
|
||
const imgKey = bilibiliKeyFromWbiURL(nav?.data?.wbi_img?.img_url)
|
||
const subKey = bilibiliKeyFromWbiURL(nav?.data?.wbi_img?.sub_url)
|
||
if (!imgKey || !subKey) {
|
||
return null
|
||
}
|
||
|
||
const mixinKey = buildBilibiliMixinKey(imgKey, subKey)
|
||
const next: Record<string, string | number> = {
|
||
...params,
|
||
wts: Math.round(Date.now() / 1000),
|
||
}
|
||
const illegalChars = /[!'()*]/g
|
||
const query = Object.keys(next)
|
||
.sort()
|
||
.map((key) => {
|
||
const value = String(next[key]).replace(illegalChars, '')
|
||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
||
})
|
||
.join('&')
|
||
const wRid = createHash('md5').update(`${query}${mixinKey}`).digest('hex')
|
||
return `${query}&w_rid=${wRid}`
|
||
}
|
||
|
||
function normalizeBilibiliAvatarUrl(value?: string | null): string | null {
|
||
return normalizeRemoteUrl(value)?.replace(/^http:\/\//i, 'https://') ?? null
|
||
}
|
||
|
||
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 detectedAccountFromFields(input: {
|
||
uid?: unknown
|
||
name?: unknown
|
||
avatar?: unknown
|
||
}): DetectedAccount | null {
|
||
const platformUid = stringId(input.uid)
|
||
const displayName = normalizeText(input.name)
|
||
if (!platformUid || !displayName) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid,
|
||
displayName,
|
||
avatarUrl: normalizeRemoteUrl(stringId(input.avatar) || normalizeText(input.avatar)),
|
||
}
|
||
}
|
||
|
||
function extractToutiaoAccount(
|
||
response: ToutiaoMediaInfoResponse | null | undefined,
|
||
): DetectedAccount | null {
|
||
const candidates = [
|
||
detectedAccountFromFields({
|
||
uid: response?.data?.user?.id_str,
|
||
name: response?.data?.user?.screen_name,
|
||
avatar: response?.data?.user?.https_avatar_url,
|
||
}),
|
||
detectedAccountFromFields({
|
||
uid: response?.data?.user_info?.id_str ?? response?.data?.user_info?.user_id_str,
|
||
name: response?.data?.user_info?.screen_name ?? response?.data?.user_info?.name,
|
||
avatar: response?.data?.user_info?.https_avatar_url ?? response?.data?.user_info?.avatar_url,
|
||
}),
|
||
detectedAccountFromFields({
|
||
uid: response?.data?.media?.media_id ?? response?.data?.media?.id,
|
||
name: response?.data?.media?.screen_name ?? response?.data?.media?.media_name,
|
||
avatar: response?.data?.media?.https_avatar_url ?? response?.data?.media?.avatar_url,
|
||
}),
|
||
]
|
||
|
||
return candidates.find((item) => item !== null) ?? null
|
||
}
|
||
|
||
function extractWeixinGzhToken(html: string): string {
|
||
const patterns = [
|
||
/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/,
|
||
/(?:\?|&|&)token=(\d+)(?:&|&|$)/,
|
||
/["']?token["']?\s*[:=]\s*["']?(\d+)["']?/,
|
||
]
|
||
|
||
for (const pattern of patterns) {
|
||
const token = html.match(pattern)?.[1]?.trim()
|
||
if (token) {
|
||
return token
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function buildWeixinGzhDraftListURL(token?: string): string {
|
||
const url = new URL(WEIXIN_GZH_DRAFT_LIST_URL)
|
||
url.searchParams.set('begin', '0')
|
||
url.searchParams.set('count', '10')
|
||
url.searchParams.set('type', '77')
|
||
url.searchParams.set('action', 'list_card')
|
||
if (token) {
|
||
url.searchParams.set('token', token)
|
||
}
|
||
url.searchParams.set('lang', 'zh_CN')
|
||
return url.toString()
|
||
}
|
||
|
||
function isWeixinGzhLoggedOutText(text: string): boolean {
|
||
return /(登录超时|登录态超时|登录已过期|会话已过期|请重新登录|未登录)/.test(text)
|
||
}
|
||
|
||
async function readWeixinGzhConsoleState(
|
||
webContents: WebContents | null | undefined,
|
||
): Promise<WeixinGzhConsoleState | null> {
|
||
if (!webContents || webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const state = await webContents
|
||
.executeJavaScript(
|
||
`(() => {
|
||
try {
|
||
const text = String(document.body?.innerText || "");
|
||
const title = String(document.title || "");
|
||
return {
|
||
title,
|
||
textSnippet: text.slice(0, 500),
|
||
loggedOut: /(登录超时|登录态超时|登录已过期|会话已过期|请重新登录|未登录)/.test(text + "\\n" + title),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();`,
|
||
true,
|
||
)
|
||
.catch(() => null)
|
||
|
||
if (!state || typeof state !== 'object') {
|
||
return null
|
||
}
|
||
|
||
const typed = state as Partial<WeixinGzhConsoleState>
|
||
return {
|
||
loggedOut:
|
||
typed.loggedOut === true ||
|
||
isWeixinGzhLoggedOutText(`${typed.title ?? ''}\n${typed.textSnippet ?? ''}`),
|
||
title: typeof typed.title === 'string' ? typed.title : '',
|
||
textSnippet: typeof typed.textSnippet === 'string' ? typed.textSnippet : '',
|
||
}
|
||
}
|
||
|
||
async function resolveWeixinGzhConsoleURL(session: Session): Promise<string> {
|
||
const html = await sessionFetchText(session, WEIXIN_GZH_HOME_URL, {
|
||
credentials: 'include',
|
||
headers: {
|
||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||
referer: WEIXIN_GZH_HOME_URL,
|
||
},
|
||
}).catch(() => '')
|
||
|
||
const token = extractWeixinGzhToken(html)
|
||
if (!token) {
|
||
return WEIXIN_GZH_LOGIN_URL
|
||
}
|
||
|
||
return buildWeixinGzhDraftListURL(token)
|
||
}
|
||
|
||
async function resolvePublishAccountConsoleURL(
|
||
definition: PublishPlatformBindingDefinition,
|
||
session: Session,
|
||
): Promise<string> {
|
||
if (definition.id === 'weixin_gzh') {
|
||
return await resolveWeixinGzhConsoleURL(session)
|
||
}
|
||
return definition.consoleUrl
|
||
}
|
||
|
||
async function detectToutiaoFromPageState(
|
||
webContents: WebContents,
|
||
): Promise<Pick<DetectedAccount, 'platformUid' | 'displayName' | 'avatarUrl'> | null> {
|
||
if (!webContents || webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const serialized = await webContents
|
||
.executeJavaScript(
|
||
`(() => {
|
||
try {
|
||
const normalizeText = (value) => typeof value === "string" ? value.trim() : "";
|
||
const stringId = (value) => typeof value === "number"
|
||
? Number.isFinite(value) ? String(value) : ""
|
||
: normalizeText(value);
|
||
const pickAccount = (value) => {
|
||
if (!value || typeof value !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const uid = stringId(
|
||
value.id_str
|
||
?? value.user_id_str
|
||
?? value.media_id
|
||
?? value.mediaId
|
||
?? value.uid
|
||
?? value.user_id
|
||
?? value.id,
|
||
);
|
||
const displayName = normalizeText(
|
||
value.screen_name
|
||
?? value.nick_name
|
||
?? value.nickname
|
||
?? value.user_name
|
||
?? value.media_name
|
||
?? value.mediaName
|
||
?? value.name,
|
||
);
|
||
const avatarUrl = normalizeText(
|
||
value.https_avatar_url
|
||
?? value.avatar_url
|
||
?? value.avatar
|
||
?? value.head_img,
|
||
);
|
||
|
||
if (!uid || !displayName) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName,
|
||
avatarUrl: avatarUrl || null,
|
||
};
|
||
};
|
||
const extractUidFromText = (value) => {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const source = String(value);
|
||
const patterns = [
|
||
/"id_str":"([^"]+)"/,
|
||
/"user_id_str":"([^"]+)"/,
|
||
/"media_id":"?([^",}]+)"?/,
|
||
/"mediaId":"?([^",}]+)"?/,
|
||
/\b(?:id_str|user_id_str|media_id|mediaId|uid|user_id)\b[^0-9A-Za-z_-]*([0-9A-Za-z_-]{4,})/i,
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
const match = source.match(pattern);
|
||
if (match?.[1]) {
|
||
return match[1];
|
||
}
|
||
}
|
||
|
||
return "";
|
||
};
|
||
const collectStorageHints = (storage) => {
|
||
const hints = [];
|
||
if (!storage) {
|
||
return hints;
|
||
}
|
||
|
||
try {
|
||
for (let index = 0; index < storage.length && index < 80; index += 1) {
|
||
const key = storage.key(index);
|
||
if (!key) {
|
||
continue;
|
||
}
|
||
|
||
const value = storage.getItem(key) || "";
|
||
if (
|
||
/(uid|user|media|creator|account|id)/i.test(key)
|
||
|| /(id_str|user_id_str|media_id|mediaId|uid|user_id)/i.test(value)
|
||
) {
|
||
hints.push(key + "=" + value);
|
||
}
|
||
}
|
||
} catch {
|
||
return hints;
|
||
}
|
||
|
||
return hints;
|
||
};
|
||
const detectHeaderName = () => {
|
||
const ignoredPatterns = [
|
||
/^消息$/,
|
||
/^更多$/,
|
||
/^立即完善$/,
|
||
/^主页$/,
|
||
/^创作$/,
|
||
/^管理$/,
|
||
/^数据$/,
|
||
/^第\d+天$/,
|
||
/^在头条创作/,
|
||
/^扫码登录$/,
|
||
/^验证码登录$/,
|
||
/^密码登录$/,
|
||
/^手机号$/,
|
||
/^获取验证码$/,
|
||
/^登录$/,
|
||
];
|
||
|
||
const candidates = Array.from(document.querySelectorAll("body *"))
|
||
.map((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = (element.textContent || "").trim().replace(/\s+/g, "");
|
||
return { text, rect };
|
||
})
|
||
.filter(({ text, rect }) =>
|
||
text
|
||
&& text.length >= 2
|
||
&& text.length <= 40
|
||
&& rect.top >= 0
|
||
&& rect.top <= 220
|
||
&& rect.left >= window.innerWidth * 0.55
|
||
&& rect.width <= 320
|
||
&& rect.height <= 120,
|
||
)
|
||
.map(({ text, rect }) => ({ text, rect }))
|
||
.sort((left, right) => {
|
||
if (right.rect.left !== left.rect.left) {
|
||
return right.rect.left - left.rect.left;
|
||
}
|
||
return left.rect.top - right.rect.top;
|
||
})
|
||
.map(({ text }) => text);
|
||
|
||
return candidates.find((text) => !ignoredPatterns.some((pattern) => pattern.test(text))) || "";
|
||
};
|
||
const detectHeaderAvatar = () => {
|
||
const images = Array.from(document.images)
|
||
.map((image) => {
|
||
const rect = image.getBoundingClientRect();
|
||
return {
|
||
src: image.currentSrc || image.src || "",
|
||
rect,
|
||
};
|
||
})
|
||
.filter(({ src, rect }) =>
|
||
src
|
||
&& rect.top >= 0
|
||
&& rect.top <= 220
|
||
&& rect.left >= window.innerWidth * 0.55
|
||
&& rect.width >= 24
|
||
&& rect.width <= 120
|
||
&& rect.height >= 24
|
||
&& rect.height <= 120,
|
||
)
|
||
.sort((left, right) => {
|
||
if (right.rect.left !== left.rect.left) {
|
||
return right.rect.left - left.rect.left;
|
||
}
|
||
return left.rect.top - right.rect.top;
|
||
});
|
||
|
||
return images[0]?.src || "";
|
||
};
|
||
const isLoggedInWorkbench = () => {
|
||
const text = document.body.innerText || "";
|
||
return (
|
||
location.hostname.includes("mp.toutiao.com")
|
||
&& !/\/auth\/page\/login/.test(location.pathname)
|
||
&& (
|
||
/在头条创作的第\s*\d+\s*天/.test(text)
|
||
|| (text.includes("主页") && text.includes("创作") && text.includes("管理"))
|
||
)
|
||
);
|
||
};
|
||
|
||
const roots = [
|
||
window,
|
||
window.__INITIAL_STATE__,
|
||
window.__INITIAL_DATA__,
|
||
window.__REDUX_STATE__,
|
||
window.__NEXT_DATA__,
|
||
window.__STORE__ && typeof window.__STORE__.getState === "function"
|
||
? window.__STORE__.getState()
|
||
: null,
|
||
];
|
||
const queue = [];
|
||
const seen = new WeakSet();
|
||
const enqueue = (value) => {
|
||
if (!value || typeof value !== "object" || seen.has(value)) {
|
||
return;
|
||
}
|
||
seen.add(value);
|
||
queue.push(value);
|
||
};
|
||
|
||
roots.forEach(enqueue);
|
||
|
||
let account = null;
|
||
while (queue.length && !account) {
|
||
const current = queue.shift();
|
||
account = pickAccount(current);
|
||
if (account) {
|
||
break;
|
||
}
|
||
|
||
const children = Array.isArray(current) ? current : Object.values(current);
|
||
for (const child of children.slice(0, 120)) {
|
||
enqueue(child);
|
||
}
|
||
}
|
||
|
||
if (!account) {
|
||
const html = document.documentElement.outerHTML;
|
||
const patternGroups = [
|
||
{
|
||
uid: /"id_str":"([^"]+)"/,
|
||
name: /"screen_name":"([^"]+)"/,
|
||
avatar: /"https_avatar_url":"([^"]+)"/,
|
||
},
|
||
{
|
||
uid: /"user_id_str":"([^"]+)"/,
|
||
name: /"name":"([^"]+)"/,
|
||
avatar: /"avatar_url":"([^"]+)"/,
|
||
},
|
||
{
|
||
uid: /"media_id":"?([^",}]+)"?/,
|
||
name: /"media_name":"([^"]+)"/,
|
||
avatar: /"avatar_url":"([^"]+)"/,
|
||
},
|
||
];
|
||
|
||
for (const group of patternGroups) {
|
||
const uid = html.match(group.uid)?.[1] ?? "";
|
||
const name = html.match(group.name)?.[1] ?? "";
|
||
if (uid && name) {
|
||
account = {
|
||
platformUid: uid,
|
||
displayName: name,
|
||
avatarUrl: html.match(group.avatar)?.[1] ?? null,
|
||
};
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const isLoginPage = /\/auth\/page\/login/.test(location.pathname);
|
||
|
||
if (!account && !isLoginPage) {
|
||
const displayName = detectHeaderName();
|
||
if (displayName) {
|
||
const storageHints = [
|
||
...collectStorageHints(window.localStorage),
|
||
...collectStorageHints(window.sessionStorage),
|
||
document.cookie,
|
||
document.documentElement.outerHTML,
|
||
];
|
||
const fallbackUid = storageHints.map(extractUidFromText).find(Boolean) || ("toutiao-name:" + displayName);
|
||
account = {
|
||
platformUid: fallbackUid,
|
||
displayName,
|
||
avatarUrl: detectHeaderAvatar() || null,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (!account && isLoggedInWorkbench()) {
|
||
account = {
|
||
platformUid: "",
|
||
displayName: detectHeaderName() || "头条号账号",
|
||
avatarUrl: detectHeaderAvatar() || null,
|
||
};
|
||
}
|
||
|
||
return JSON.stringify(account);
|
||
} catch {
|
||
return JSON.stringify(null);
|
||
}
|
||
})()`,
|
||
true,
|
||
)
|
||
.catch(() => null)
|
||
|
||
if (typeof serialized !== 'string' || !serialized) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(serialized) as Partial<DetectedAccount> | null
|
||
if (!parsed?.displayName) {
|
||
return null
|
||
}
|
||
return {
|
||
platformUid: stringId(parsed.platformUid),
|
||
displayName: parsed.displayName,
|
||
avatarUrl: normalizeRemoteUrl(parsed.avatarUrl),
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function deriveToutiaoFallbackUid(session: Session, displayName: string): Promise<string> {
|
||
const cookieCandidates = [
|
||
['mp.toutiao.com', 'uid_tt'],
|
||
['mp.toutiao.com', 'uid_tt_ss'],
|
||
['mp.toutiao.com', 'sessionid'],
|
||
['mp.toutiao.com', 'sessionid_ss'],
|
||
['mp.toutiao.com', 'ttwid'],
|
||
['toutiao.com', 'uid_tt'],
|
||
['toutiao.com', 'uid_tt_ss'],
|
||
['toutiao.com', 'ttwid'],
|
||
] as const
|
||
|
||
for (const [domain, name] of cookieCandidates) {
|
||
const value = await sessionCookieValue(session, domain, name).catch(() => '')
|
||
const normalized = normalizeText(value)
|
||
if (normalized) {
|
||
return boundedIdentity(`toutiao-cookie-${name}`, normalized)
|
||
}
|
||
}
|
||
|
||
const cookieHeader = await sessionCookieHeader(session, 'mp.toutiao.com').catch(() => '')
|
||
if (cookieHeader.trim()) {
|
||
return boundedIdentity('toutiao-cookie-hash', cookieHeader)
|
||
}
|
||
|
||
return boundedIdentity('toutiao-name', displayName)
|
||
}
|
||
|
||
function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount {
|
||
const displayName = normalizeText(account.displayName) ?? '未命名账号'
|
||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platformUid)
|
||
const platformUid =
|
||
normalizedPlatformUid ||
|
||
normalizeAccountPlatformUid(boundedIdentity('platform', displayName)) ||
|
||
displayName
|
||
|
||
return {
|
||
platformUid,
|
||
displayName,
|
||
avatarUrl: normalizeRemoteUrl(account.avatarUrl),
|
||
}
|
||
}
|
||
|
||
function normalizeGenericAIPageStateSnapshot(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: normalizeText(typed.fingerprint),
|
||
credentialFingerprint: normalizeText(typed.credentialFingerprint),
|
||
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,
|
||
}
|
||
}
|
||
|
||
async function readGenericAIPageState(
|
||
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 bodyText = normalize(document.body?.innerText || "");
|
||
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)) {
|
||
return false;
|
||
}
|
||
let current = node;
|
||
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|sheet|drawer|login|signin|auth|scan|qrcode|qrlogin)/i.test(descriptor)) {
|
||
return true;
|
||
}
|
||
|
||
const text = normalize(current.textContent || '');
|
||
if (text && text.length <= 120 && loggedOutPattern.test(text)) {
|
||
return true;
|
||
}
|
||
|
||
current = current.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
const pickGreetingName = () => {
|
||
if (!bodyText) {
|
||
return null;
|
||
}
|
||
const patterns = [
|
||
/(?:^|\\n)\\s*(?:hi|hello|你好|嗨)[,,\\s]+([^\\n,,]{1,20})/i,
|
||
/(?:^|\\n)\\s*欢迎回来[,,\\s]*([^\\n]{1,20})/i,
|
||
];
|
||
for (const pattern of patterns) {
|
||
const matched = bodyText.match(pattern);
|
||
const candidate = normalize(matched?.[1] || "");
|
||
if (candidate && !stopWords.test(candidate)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const pickDisplayName = () => {
|
||
const selectors = [
|
||
'[data-testid*="user"]',
|
||
'[data-testid*="profile"]',
|
||
'[class*="user-name"]',
|
||
'[class*="username"]',
|
||
'[class*="nickname"]',
|
||
'[class*="profile"] [class*="name"]',
|
||
'[class*="avatar"] + *',
|
||
'[class*="account"] [class*="name"]',
|
||
'aside [class*="name"]',
|
||
'nav [class*="name"]',
|
||
];
|
||
for (const selector of selectors) {
|
||
const nodes = document.querySelectorAll(selector);
|
||
for (const node of nodes) {
|
||
if (!isVisible(node)) continue;
|
||
if (isInsideAuthSurface(node)) continue;
|
||
const text = normalize(node.textContent || "");
|
||
if (!text) continue;
|
||
if (text.length < 2 || text.length > 48) continue;
|
||
if (stopWords.test(text)) continue;
|
||
return text;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const pickAvatar = () => {
|
||
const selectors = [
|
||
'[class*="avatar"] img',
|
||
'[data-testid*="avatar"] img',
|
||
'[class*="profile"] img',
|
||
'[class*="account"] img',
|
||
'img[alt*="头像"]',
|
||
];
|
||
for (const selector of selectors) {
|
||
const node = document.querySelector(selector);
|
||
if (!node || !isVisible(node)) continue;
|
||
if (isInsideAuthSurface(node)) continue;
|
||
const src = normalize(node && 'src' in node ? node.src : null);
|
||
if (src && /(logo|icon|favicon)/i.test(src)) continue;
|
||
if (src) return src;
|
||
}
|
||
return null;
|
||
};
|
||
const collectLoginSignals = () => {
|
||
const matches = [];
|
||
if (bodyText && loginPattern.test(bodyText)) {
|
||
matches.push('body-text');
|
||
}
|
||
const selectors = ['button', 'a', '[role="button"]', '[aria-haspopup="dialog"]'];
|
||
for (const selector of selectors) {
|
||
const nodes = document.querySelectorAll(selector);
|
||
for (const node of nodes) {
|
||
if (!isVisible(node)) continue;
|
||
const text = normalize(node.textContent || "");
|
||
if (!text || !loginPattern.test(text)) continue;
|
||
matches.push(text);
|
||
if (matches.length >= 4) {
|
||
return matches;
|
||
}
|
||
}
|
||
}
|
||
return matches;
|
||
};
|
||
const collectLoggedOutSignals = () => {
|
||
const matches = [];
|
||
if (bodyText && loggedOutPattern.test(bodyText)) {
|
||
matches.push('body-text');
|
||
}
|
||
const selectors = ['button', 'a', '[role="button"]', '[aria-haspopup="dialog"]', '[role="dialog"]'];
|
||
for (const selector of selectors) {
|
||
const nodes = document.querySelectorAll(selector);
|
||
for (const node of nodes) {
|
||
if (!isVisible(node)) continue;
|
||
const text = normalize(node.textContent || "");
|
||
if (!text || !loggedOutPattern.test(text)) continue;
|
||
matches.push(text);
|
||
if (matches.length >= 4) {
|
||
return matches;
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
const limit = Math.min(storage.length, 40);
|
||
for (let index = 0; index < limit; index += 1) {
|
||
const key = storage.key(index);
|
||
if (!key || !credentialKeyPattern.test(key)) {
|
||
continue;
|
||
}
|
||
const value = storage.getItem(key);
|
||
const text = String(value || '').slice(0, 240).trim();
|
||
if (!text) {
|
||
continue;
|
||
}
|
||
items.push(prefix + ':' + key + '=' + text);
|
||
}
|
||
} catch {
|
||
return items;
|
||
}
|
||
return items;
|
||
};
|
||
const collectStorage = (storage, prefix) => {
|
||
const items = [];
|
||
try {
|
||
const limit = Math.min(storage.length, 40);
|
||
for (let index = 0; index < limit; index += 1) {
|
||
const key = storage.key(index);
|
||
if (!key || !/(user|uid|token|session|auth|account|profile|login|member|nick|name)/i.test(key)) {
|
||
continue;
|
||
}
|
||
const value = storage.getItem(key);
|
||
items.push(prefix + ':' + key + '=' + String(value || '').slice(0, 160));
|
||
}
|
||
} catch {
|
||
return items;
|
||
}
|
||
return items;
|
||
};
|
||
const displayName = pickGreetingName() || pickDisplayName();
|
||
const avatarUrl = pickAvatar();
|
||
const strongSignals = [];
|
||
if (displayName) {
|
||
strongSignals.push('displayName');
|
||
}
|
||
if (avatarUrl) {
|
||
strongSignals.push('avatar');
|
||
}
|
||
if (bodyText && /(?:^|\\n)\\s*(?:hi|hello|你好|嗨)[,,\\s]+/i.test(bodyText)) {
|
||
strongSignals.push('greeting');
|
||
}
|
||
const loginSignals = collectLoginSignals();
|
||
const loggedOutSignals = collectLoggedOutSignals();
|
||
const challengeSignals = collectChallengeSignals();
|
||
const credentialFingerprintParts = [
|
||
...collectCredentialStorage(window.localStorage, 'local'),
|
||
...collectCredentialStorage(window.sessionStorage, 'session'),
|
||
];
|
||
const fingerprintParts = [
|
||
document.cookie || '',
|
||
window.location.origin || '',
|
||
document.title || '',
|
||
...collectStorage(window.localStorage, 'local'),
|
||
...collectStorage(window.sessionStorage, 'session'),
|
||
].filter(Boolean);
|
||
return {
|
||
displayName,
|
||
avatarUrl,
|
||
fingerprint: fingerprintParts.length ? fingerprintParts.join('||') : null,
|
||
credentialFingerprint: credentialFingerprintParts.length
|
||
? credentialFingerprintParts.join('||')
|
||
: null,
|
||
currentPath: normalize(window.location.pathname || ''),
|
||
strongAuthSignalCount: strongSignals.length,
|
||
loginSignalCount: loginSignals.length,
|
||
loggedOutSignalCount: loggedOutSignals.length,
|
||
challengeSignalCount: challengeSignals.length,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();`,
|
||
true,
|
||
)
|
||
.catch(() => null)
|
||
|
||
return normalizeGenericAIPageStateSnapshot(state)
|
||
}
|
||
|
||
function isAIPlatformBinding(platformId: string): boolean {
|
||
return aiPlatformCatalog.some((item) => item.id === platformId)
|
||
}
|
||
|
||
function normalizeURLPath(pathname: string): string {
|
||
const normalized = pathname.replace(/\/+$/, '')
|
||
return normalized === '/' ? '' : normalized
|
||
}
|
||
|
||
async function buildGenericAISessionFingerprint(
|
||
platformId: string,
|
||
session: Session,
|
||
pageState?: GenericAIPageState | null,
|
||
): Promise<string | null> {
|
||
const parts: string[] = []
|
||
|
||
if (pageState?.credentialFingerprint) {
|
||
if (
|
||
genericAIPlatformRequiresVisibleCredentialSignal(platformId) &&
|
||
!genericAIHasVisibleAccountSignal(pageState)
|
||
) {
|
||
return null
|
||
}
|
||
parts.push(pageState.credentialFingerprint)
|
||
}
|
||
|
||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
||
if (platformMeta) {
|
||
const urls = new Set([platformMeta.loginUrl, platformMeta.consoleUrl])
|
||
for (const url of urls) {
|
||
try {
|
||
const cookies = await session.cookies.get({ url })
|
||
const authCookies = cookies.filter((cookie) => {
|
||
return isLikelyPlatformAICredentialCookie(
|
||
platformId,
|
||
cookie.name,
|
||
cookie.value,
|
||
pageState,
|
||
)
|
||
})
|
||
if (authCookies.length > 0) {
|
||
const serialized = authCookies
|
||
.map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`)
|
||
.sort()
|
||
.join(';')
|
||
parts.push(`${url}:${serialized}`)
|
||
}
|
||
} catch {
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
if (parts.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return boundedIdentity(`${platformId}-session`, parts.join('||'))
|
||
}
|
||
|
||
async function detectCredentialBackedAIPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
if (!context.webContents || context.webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const currentURL =
|
||
context.webContents && !context.webContents.isDestroyed() ? context.webContents.getURL() : ''
|
||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
||
if (!platformMeta) {
|
||
return null
|
||
}
|
||
|
||
if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) {
|
||
return null
|
||
}
|
||
|
||
const pageState = await readGenericAIPageState(context.webContents).catch(() => null)
|
||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
if ((pageState?.loginSignalCount ?? 0) > 0 && !genericAIHasVisibleAccountSignal(pageState)) {
|
||
return null
|
||
}
|
||
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState)
|
||
if (!fingerprint) {
|
||
return null
|
||
}
|
||
|
||
return sanitizeDetectedAccount({
|
||
platformUid: fingerprint,
|
||
displayName: pageState?.displayName ?? `${label} 会话`,
|
||
avatarUrl: pageState?.avatarUrl ?? null,
|
||
})
|
||
}
|
||
|
||
async function detectDeepSeekPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
return await detectCredentialBackedAIPlatform(platformId, label, context)
|
||
}
|
||
|
||
async function detectYuanbaoPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
return await detectCredentialBackedAIPlatform(platformId, label, context)
|
||
}
|
||
|
||
async function detectWenxinPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
return await detectCredentialBackedAIPlatform(platformId, label, context)
|
||
}
|
||
|
||
async function detectQwenFromSession(
|
||
session: Session,
|
||
pageState: GenericAIPageState | null,
|
||
hints: {
|
||
displayName?: string | null
|
||
avatarUrl?: string | null
|
||
} = {},
|
||
): Promise<DetectedAccount | null> {
|
||
const detected = await detectQwenAccountFromSession(session, pageState, hints)
|
||
return detected ? sanitizeDetectedAccount(detected) : null
|
||
}
|
||
|
||
async function detectKimiFromSession(
|
||
session: Session,
|
||
pageState: GenericAIPageState | null,
|
||
hints: {
|
||
displayName?: string | null
|
||
avatarUrl?: string | null
|
||
} = {},
|
||
): Promise<DetectedAccount | null> {
|
||
const detected = await detectKimiAccountFromSession(session, pageState, hints)
|
||
return detected ? sanitizeDetectedAccount(detected) : null
|
||
}
|
||
|
||
async function detectQwenPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
if (!context.webContents || context.webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const currentURL = context.webContents.getURL()
|
||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
||
if (!platformMeta) {
|
||
return null
|
||
}
|
||
|
||
if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) {
|
||
return null
|
||
}
|
||
|
||
const pageState = await readQwenAuthPageState(context.webContents).catch(() => null)
|
||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
|
||
const fromSession = await detectQwenFromSession(context.session, pageState, {
|
||
displayName: pageState?.displayName ?? null,
|
||
avatarUrl: pageState?.avatarUrl ?? null,
|
||
})
|
||
if (fromSession) {
|
||
return fromSession
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function detectDoubaoPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
if (!context.webContents || context.webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const currentURL = context.webContents.getURL()
|
||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
||
if (!platformMeta) {
|
||
return null
|
||
}
|
||
|
||
if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) {
|
||
return null
|
||
}
|
||
|
||
const pageState = await readDoubaoAuthPageState(context.webContents).catch(() => null)
|
||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
|
||
// Doubao is intentionally platform-specific. It allows anonymous chat, but
|
||
// our desktop binding requires the real sidebar account menu plus login cookies.
|
||
if (!doubaoHasBoundAccountPageSignal(pageState)) {
|
||
return null
|
||
}
|
||
|
||
const urls = new Set([platformMeta.loginUrl, platformMeta.consoleUrl])
|
||
const credentialEvidence = await buildDoubaoSessionCredentialEvidence(
|
||
context.session,
|
||
urls,
|
||
pageState,
|
||
)
|
||
if (!credentialEvidence) {
|
||
return null
|
||
}
|
||
|
||
return sanitizeDetectedAccount({
|
||
platformUid: boundedIdentity('doubao-session', credentialEvidence),
|
||
displayName: pageState?.displayName ?? `${label} 账号`,
|
||
avatarUrl: pageState?.avatarUrl ?? null,
|
||
})
|
||
}
|
||
|
||
async function detectKimiPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
if (!context.webContents || context.webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const currentURL = context.webContents.getURL()
|
||
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null
|
||
if (!platformMeta) {
|
||
return null
|
||
}
|
||
|
||
if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) {
|
||
return null
|
||
}
|
||
|
||
const pageState = await readKimiAuthPageState(context.webContents).catch(() => null)
|
||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||
return null
|
||
}
|
||
|
||
const fromSession = await detectKimiFromSession(context.session, pageState, {
|
||
displayName: pageState?.displayName ?? `${label} 会话`,
|
||
avatarUrl: pageState?.avatarUrl ?? null,
|
||
})
|
||
if (fromSession) {
|
||
return fromSession
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function detectAIPlatformAccount(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
if (platformId === 'qwen') {
|
||
return await detectQwenPlatform(platformId, label, context)
|
||
}
|
||
|
||
if (platformId === 'doubao') {
|
||
return await detectDoubaoPlatform(platformId, label, context)
|
||
}
|
||
|
||
if (platformId === 'kimi') {
|
||
return await detectKimiPlatform(platformId, label, context)
|
||
}
|
||
|
||
if (platformId === 'deepseek') {
|
||
return await detectDeepSeekPlatform(platformId, label, context)
|
||
}
|
||
|
||
if (platformId === 'yuanbao') {
|
||
return await detectYuanbaoPlatform(platformId, label, context)
|
||
}
|
||
|
||
if (platformId === 'wenxin') {
|
||
return await detectWenxinPlatform(platformId, label, context)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function detectToutiao({
|
||
session,
|
||
webContents,
|
||
}: DetectContext): Promise<DetectedAccount | null> {
|
||
const pageResponse = await pageFetchJson<ToutiaoMediaInfoResponse>(
|
||
webContents,
|
||
'https://mp.toutiao.com/mp/agw/media/get_media_info',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
},
|
||
)
|
||
const accountFromPage = extractToutiaoAccount(pageResponse)
|
||
if (accountFromPage) {
|
||
return accountFromPage
|
||
}
|
||
|
||
const sessionResponse = await sessionCookieFetchJson<ToutiaoMediaInfoResponse>(
|
||
session,
|
||
'https://mp.toutiao.com/mp/agw/media/get_media_info',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
},
|
||
)
|
||
const accountFromSession = extractToutiaoAccount(sessionResponse)
|
||
if (accountFromSession) {
|
||
return accountFromSession
|
||
}
|
||
|
||
const accountFromPageState = await detectToutiaoFromPageState(webContents)
|
||
if (!accountFromPageState?.displayName) {
|
||
const currentURL = webContents.isDestroyed() ? '' : webContents.getURL()
|
||
if (
|
||
/^https:\/\/mp\.toutiao\.com\//i.test(currentURL) &&
|
||
!/\/auth\/page\/login/i.test(currentURL)
|
||
) {
|
||
const fallbackDisplayName = normalizeText(webContents.getTitle()) || '头条号账号'
|
||
return sanitizeDetectedAccount({
|
||
platformUid: await deriveToutiaoFallbackUid(session, fallbackDisplayName),
|
||
displayName: fallbackDisplayName,
|
||
avatarUrl: null,
|
||
})
|
||
}
|
||
return null
|
||
}
|
||
|
||
return sanitizeDetectedAccount({
|
||
platformUid:
|
||
accountFromPageState.platformUid ||
|
||
(await deriveToutiaoFallbackUid(session, accountFromPageState.displayName)),
|
||
displayName: accountFromPageState.displayName,
|
||
avatarUrl: accountFromPageState.avatarUrl,
|
||
})
|
||
}
|
||
|
||
async function detectToutiaoFromSessionOnly(session: Session): Promise<DetectedAccount | null> {
|
||
const response = await sessionCookieFetchJson<ToutiaoMediaInfoResponse>(
|
||
session,
|
||
'https://mp.toutiao.com/mp/agw/media/get_media_info',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
},
|
||
)
|
||
return extractToutiaoAccount(response)
|
||
}
|
||
|
||
async function detectToutiaoFromSession(session: Session): Promise<DetectedAccount | null> {
|
||
const detected = await detectToutiaoFromSessionOnly(session)
|
||
if (detected) {
|
||
return sanitizeDetectedAccount(detected)
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function hasToutiaoAuthCookies(session: Session): Promise<boolean> {
|
||
const candidates = [
|
||
['toutiao.com', 'sessionid'],
|
||
['toutiao.com', 'sessionid_ss'],
|
||
['toutiao.com', 'sid_tt'],
|
||
['toutiao.com', 'sid_ucp_v1'],
|
||
['mp.toutiao.com', 'sessionid'],
|
||
['mp.toutiao.com', 'sessionid_ss'],
|
||
] as const
|
||
|
||
for (const [domain, name] of candidates) {
|
||
const value = await sessionCookieValue(session, domain, name).catch(() => '')
|
||
if (normalizeText(value)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
function extractWangyihaoAccount(
|
||
response: WangyiInfoResponse | null | undefined,
|
||
): DetectedAccount | null {
|
||
const media = response?.data?.mediaInfo
|
||
const uid = stringId(media?.userId) || stringId(media?.wemediaId)
|
||
const displayName = normalizeText(media?.tname)
|
||
if (!uid || !displayName) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName,
|
||
avatarUrl: normalizeRemoteUrl(media?.icon),
|
||
}
|
||
}
|
||
|
||
async function fetchWangyihaoInfo(
|
||
session: Session,
|
||
options: WangyiInfoFetchOptions = {},
|
||
): Promise<WangyiInfoResponse | null> {
|
||
const headers = {
|
||
accept: 'application/json, text/plain, */*',
|
||
referer: WANGYI_API_REFERER,
|
||
origin: WANGYI_API_ORIGIN,
|
||
}
|
||
|
||
if (options.allowCookieWrite) {
|
||
return await sessionFetchJson<WangyiInfoResponse>(session, WANGYI_INFO_URL, {
|
||
credentials: 'include',
|
||
headers,
|
||
}).catch(() => null)
|
||
}
|
||
|
||
return await sessionCookieFetchJson<WangyiInfoResponse>(session, WANGYI_INFO_URL, {
|
||
headers,
|
||
}).catch(() => null)
|
||
}
|
||
|
||
async function detectWangyihaoFromSession(session: Session): Promise<DetectedAccount | null> {
|
||
return extractWangyihaoAccount(await fetchWangyihaoInfo(session))
|
||
}
|
||
|
||
function wangyihaoCookieVaultPath(): string {
|
||
return join(app.getPath('userData'), 'desktop-wangyihao-cookies.json')
|
||
}
|
||
|
||
function readWangyihaoCookieVault(): WangyihaoCookieVault {
|
||
try {
|
||
return JSON.parse(readFileSync(wangyihaoCookieVaultPath(), 'utf8')) as WangyihaoCookieVault
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
function writeWangyihaoCookieVault(vault: WangyihaoCookieVault): void {
|
||
const target = wangyihaoCookieVaultPath()
|
||
mkdirSync(dirname(target), { recursive: true })
|
||
writeFileSync(target, JSON.stringify(vault, null, 2), 'utf8')
|
||
}
|
||
|
||
function isWangyihaoCookie(cookie: Cookie): boolean {
|
||
const domain = cookie.domain?.toLowerCase() ?? ''
|
||
return domain.endsWith('163.com') || domain.endsWith('126.net')
|
||
}
|
||
|
||
function dedupeWangyihaoCookies(cookies: Cookie[]): StoredWangyihaoCookie[] {
|
||
const seen = new Set<string>()
|
||
const result: StoredWangyihaoCookie[] = []
|
||
for (const cookie of cookies) {
|
||
const domain = cookie.domain
|
||
if (!domain || !isWangyihaoCookie(cookie) || !cookie.value) {
|
||
continue
|
||
}
|
||
|
||
const key = [domain, cookie.path, cookie.name, cookie.secure ? 'secure' : 'plain'].join('\n')
|
||
if (seen.has(key)) {
|
||
continue
|
||
}
|
||
seen.add(key)
|
||
result.push({
|
||
domain,
|
||
expirationDate: cookie.expirationDate,
|
||
hostOnly: cookie.hostOnly,
|
||
httpOnly: cookie.httpOnly,
|
||
name: cookie.name,
|
||
path: cookie.path,
|
||
sameSite: cookie.sameSite,
|
||
secure: cookie.secure,
|
||
session: cookie.session,
|
||
value: cookie.value,
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
function urlForStoredWangyihaoCookie(cookie: StoredWangyihaoCookie): string {
|
||
const domain = cookie.domain.replace(/^\./, '')
|
||
const protocol = cookie.secure ? 'https' : 'http'
|
||
return `${protocol}://${domain}${cookie.path || '/'}`
|
||
}
|
||
|
||
async function saveWangyihaoSessionCookies(accountId: string, session: Session): Promise<void> {
|
||
const collected: Cookie[] = []
|
||
for (const url of WANGYI_COOKIE_URLS) {
|
||
collected.push(...(await session.cookies.get({ url }).catch(() => [])))
|
||
}
|
||
|
||
const cookies = dedupeWangyihaoCookies(collected)
|
||
if (!cookies.length) {
|
||
return
|
||
}
|
||
|
||
const vault = readWangyihaoCookieVault()
|
||
vault[accountId] = {
|
||
updatedAt: Date.now(),
|
||
cookies,
|
||
}
|
||
writeWangyihaoCookieVault(vault)
|
||
}
|
||
|
||
async function restoreWangyihaoSessionCookies(accountId: string, session: Session): Promise<void> {
|
||
const snapshot = readWangyihaoCookieVault()[accountId]
|
||
if (!snapshot?.cookies?.length) {
|
||
return
|
||
}
|
||
|
||
for (const cookie of snapshot.cookies) {
|
||
await session.cookies
|
||
.set({
|
||
url: urlForStoredWangyihaoCookie(cookie),
|
||
name: cookie.name,
|
||
value: cookie.value,
|
||
domain: cookie.domain,
|
||
path: cookie.path || '/',
|
||
secure: cookie.secure,
|
||
httpOnly: cookie.httpOnly,
|
||
sameSite: cookie.sameSite,
|
||
...(!cookie.session && cookie.expirationDate
|
||
? { expirationDate: cookie.expirationDate }
|
||
: {}),
|
||
})
|
||
.catch((error) => {
|
||
console.warn('[desktop-session] restore wangyihao cookie failed', {
|
||
accountId,
|
||
domain: cookie.domain,
|
||
name: cookie.name,
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
async function probeWangyihaoSession(
|
||
account: PublishAccountIdentity,
|
||
session: Session,
|
||
options: WangyiInfoFetchOptions = {},
|
||
): Promise<AuthProbeResult> {
|
||
const response = await fetchWangyihaoInfo(session, options).catch(() => null)
|
||
const detected = extractWangyihaoAccount(response)
|
||
if (detected) {
|
||
if (!detectedAccountMatchesIdentity(detected, account)) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
evidence: {
|
||
detectedPlatformUid: detected.platformUid,
|
||
expectedPlatformUid: account.platformUid,
|
||
},
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
}
|
||
}
|
||
|
||
if (!response) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
code: response.code ?? null,
|
||
message: response.message ?? response.msg ?? null,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function hasWangyihaoAuthCookies(session: Session): Promise<boolean> {
|
||
const authCookieNames = new Set(['P_INFO', 'S_INFO', 'NTES_SESS', 'l_yd_sign'])
|
||
const isAuthCookie = (name: string) =>
|
||
authCookieNames.has(name) || name.startsWith('l_s_subscribe')
|
||
const urls = [
|
||
WANGYI_API_ORIGIN,
|
||
WANGYI_CONSOLE_ORIGIN,
|
||
'https://reg.163.com/',
|
||
'https://dl.reg.163.com/',
|
||
]
|
||
|
||
for (const url of urls) {
|
||
const cookies = await session.cookies.get({ url }).catch(() => [])
|
||
if (cookies.some((cookie) => isAuthCookie(cookie.name) && normalizeText(cookie.value))) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
async function recoverWangyihaoSessionHandle(
|
||
account: PublishAccountIdentity,
|
||
): Promise<SessionHandle | null> {
|
||
const pendingPartitions = listPendingPartitions('wangyihao')
|
||
if (!pendingPartitions.length) {
|
||
return null
|
||
}
|
||
|
||
const expectedUid = normalizeText(account.platformUid) ?? ''
|
||
const expectedName = normalizeComparableText(account.displayName)
|
||
for (const partition of pendingPartitions) {
|
||
const candidateSession = electronSession.fromPartition(partition)
|
||
if (!(await hasWangyihaoAuthCookies(candidateSession))) {
|
||
continue
|
||
}
|
||
|
||
const detected = await detectWangyihaoFromSession(candidateSession).catch(() => null)
|
||
if (!detected) {
|
||
continue
|
||
}
|
||
|
||
if (expectedUid && sameAccountPlatformUid(detected.platformUid, expectedUid)) {
|
||
console.info('[desktop-session] recovered wangyihao partition by uid', {
|
||
accountId: account.id,
|
||
partition,
|
||
platformUid: detected.platformUid,
|
||
})
|
||
return createSessionHandleForPartition(account.id, partition)
|
||
}
|
||
|
||
if (expectedName && normalizeComparableText(detected.displayName) === expectedName) {
|
||
console.info('[desktop-session] recovered wangyihao partition by name', {
|
||
accountId: account.id,
|
||
partition,
|
||
displayName: detected.displayName,
|
||
})
|
||
return createSessionHandleForPartition(account.id, partition)
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function recoverToutiaoSessionHandle(
|
||
account: PublishAccountIdentity,
|
||
): Promise<SessionHandle | null> {
|
||
const pendingPartitions = listPendingPartitions('toutiaohao')
|
||
if (!pendingPartitions.length) {
|
||
return null
|
||
}
|
||
|
||
const expectedUid = normalizeText(account.platformUid) ?? ''
|
||
const expectedName = normalizeComparableText(account.displayName)
|
||
let fallbackPartition: string | null = null
|
||
|
||
for (const partition of pendingPartitions) {
|
||
const candidateSession = electronSession.fromPartition(partition)
|
||
if (!(await hasToutiaoAuthCookies(candidateSession))) {
|
||
continue
|
||
}
|
||
|
||
fallbackPartition ??= partition
|
||
|
||
const detected = await detectToutiaoFromSessionOnly(candidateSession).catch(() => null)
|
||
if (!detected) {
|
||
continue
|
||
}
|
||
|
||
if (expectedUid && sameAccountPlatformUid(detected.platformUid, expectedUid)) {
|
||
console.info('[desktop-session] recovered toutiao partition by uid', {
|
||
accountId: account.id,
|
||
partition,
|
||
platformUid: detected.platformUid,
|
||
})
|
||
return createSessionHandleForPartition(account.id, partition)
|
||
}
|
||
|
||
if (expectedName && normalizeComparableText(detected.displayName) === expectedName) {
|
||
console.info('[desktop-session] recovered toutiao partition by name', {
|
||
accountId: account.id,
|
||
partition,
|
||
displayName: detected.displayName,
|
||
})
|
||
return createSessionHandleForPartition(account.id, partition)
|
||
}
|
||
}
|
||
|
||
if (!fallbackPartition) {
|
||
return null
|
||
}
|
||
|
||
console.info('[desktop-session] recovered toutiao partition by auth cookie fallback', {
|
||
accountId: account.id,
|
||
partition: fallbackPartition,
|
||
})
|
||
return createSessionHandleForPartition(account.id, fallbackPartition)
|
||
}
|
||
|
||
export async function ensurePublishAccountSessionHandle(
|
||
account: PublishAccountIdentity,
|
||
): Promise<SessionHandle> {
|
||
const existing = await findLocalPublishAccountSessionHandle(account)
|
||
return existing ?? createSessionHandle(account.id)
|
||
}
|
||
|
||
async function findLocalPublishAccountSessionHandle(
|
||
account: PublishAccountIdentity,
|
||
): Promise<SessionHandle | null> {
|
||
const existing = getSessionHandle(account.id)
|
||
if (existing) {
|
||
if (account.platform === 'wangyihao') {
|
||
await restoreWangyihaoSessionCookies(account.id, existing.session)
|
||
}
|
||
return existing
|
||
}
|
||
|
||
const persisted = getPersistedPartition(account.id)
|
||
if (persisted) {
|
||
const handle = createSessionHandle(account.id)
|
||
if (account.platform === 'wangyihao') {
|
||
await restoreWangyihaoSessionCookies(account.id, handle.session)
|
||
}
|
||
return handle
|
||
}
|
||
|
||
if (account.platform === 'toutiaohao') {
|
||
const recovered = await recoverToutiaoSessionHandle(account)
|
||
if (recovered) {
|
||
return recovered
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'wangyihao') {
|
||
const recovered = await recoverWangyihaoSessionHandle(account)
|
||
if (recovered) {
|
||
return recovered
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function toPublishAccountProfile(detected: DetectedAccount | null): PublishAccountProfile | null {
|
||
if (!detected) {
|
||
return null
|
||
}
|
||
|
||
const normalized = sanitizeDetectedAccount(detected)
|
||
return {
|
||
platformUid: normalized.platformUid,
|
||
displayName: normalized.displayName,
|
||
avatarUrl: normalized.avatarUrl,
|
||
}
|
||
}
|
||
|
||
function toAccountHealthProfile(
|
||
profile: PublishAccountProfile | null,
|
||
): AccountHealthProfile | null {
|
||
if (!profile) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
subjectUid: profile.platformUid,
|
||
displayName: profile.displayName,
|
||
avatarUrl: profile.avatarUrl,
|
||
}
|
||
}
|
||
|
||
function fallbackAccountHealthProfile(
|
||
account: PublishAccountIdentity,
|
||
overrides: {
|
||
displayName?: string | null
|
||
avatarUrl?: string | null
|
||
} = {},
|
||
): AccountHealthProfile {
|
||
return {
|
||
subjectUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||
displayName: overrides.displayName ?? account.displayName,
|
||
avatarUrl: overrides.avatarUrl ?? null,
|
||
}
|
||
}
|
||
|
||
async function resolvePublishAccountProfileFromHandle(
|
||
account: PublishAccountIdentity,
|
||
handle: SessionHandle,
|
||
): Promise<PublishAccountProfile | null> {
|
||
if (account.platform === 'toutiaohao') {
|
||
return await detectToutiaoFromSession(handle.session)
|
||
}
|
||
|
||
const definition = platformBindingDefinitions[account.platform]
|
||
if (!definition) {
|
||
return null
|
||
}
|
||
|
||
if (isAIPlatformBinding(account.platform)) {
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默识别`,
|
||
await resolvePublishAccountConsoleURL(definition, handle.session),
|
||
handle.session,
|
||
{ show: false },
|
||
)
|
||
|
||
try {
|
||
await waitForWindowNavigationSettled(window)
|
||
if (window.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
.catch(() => null)
|
||
return toPublishAccountProfile(detected)
|
||
} finally {
|
||
if (!window.isDestroyed()) {
|
||
window.destroy()
|
||
}
|
||
}
|
||
}
|
||
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: undefined as unknown as WebContents,
|
||
})
|
||
.catch(() => null)
|
||
|
||
return toPublishAccountProfile(detected)
|
||
}
|
||
|
||
export async function probeAIAccountSession(
|
||
account: PublishAccountIdentity,
|
||
partition?: string,
|
||
): Promise<AuthProbeResult> {
|
||
const handle = partition
|
||
? createSessionHandleForPartition(account.id, partition)
|
||
: await findLocalPublishAccountSessionHandle(account)
|
||
|
||
if (!handle) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'missing_partition',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
}
|
||
}
|
||
|
||
const platformMeta = aiPlatformCatalog.find((item) => item.id === account.platform) ?? null
|
||
if (!platformMeta) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
platform: account.platform,
|
||
code: 'desktop_ai_platform_not_supported',
|
||
},
|
||
}
|
||
}
|
||
|
||
const aiProbeUrl =
|
||
account.platform === 'doubao'
|
||
? DOUBAO_AUTH_PROBE_URL
|
||
: account.platform === 'kimi'
|
||
? KIMI_AUTH_PROBE_URL
|
||
: account.platform === 'qwen'
|
||
? QWEN_AUTH_PROBE_URL
|
||
: platformMeta.consoleUrl
|
||
|
||
const window = createBoundWindow(
|
||
`${platformMeta.label} AI 静默探针`,
|
||
aiProbeUrl,
|
||
handle.session,
|
||
{ show: false },
|
||
)
|
||
|
||
let mainFrameLoadError:
|
||
| {
|
||
code: number
|
||
description: string
|
||
url: string
|
||
}
|
||
| undefined
|
||
window.webContents.on(
|
||
'did-fail-load',
|
||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||
if (isMainFrame && errorCode !== -3) {
|
||
mainFrameLoadError = {
|
||
code: errorCode,
|
||
description: errorDescription,
|
||
url: validatedURL,
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
try {
|
||
await waitForWindowNavigationSettled(window)
|
||
if (window.isDestroyed()) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
}
|
||
}
|
||
|
||
const loadError = mainFrameLoadError
|
||
if (loadError) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
errorCode: loadError.code,
|
||
errorDescription: loadError.description,
|
||
url: loadError.url,
|
||
},
|
||
}
|
||
}
|
||
|
||
const currentURL = window.webContents.getURL()
|
||
if (!currentURL || currentURL.startsWith('data:text/html')) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'doubao') {
|
||
return await probeDoubaoAccountSession({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
urls: new Set([platformMeta.loginUrl, platformMeta.consoleUrl]),
|
||
currentURL,
|
||
fallbackDisplayName: account.displayName,
|
||
expectedPlatformUid: account.platformUid,
|
||
})
|
||
}
|
||
|
||
if (account.platform === 'kimi') {
|
||
return await probeKimiAccountSession({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
currentURL,
|
||
fallbackDisplayName: account.displayName,
|
||
expectedPlatformUid: account.platformUid,
|
||
})
|
||
}
|
||
|
||
if (account.platform === 'qwen') {
|
||
return await probeQwenAccountSession({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
currentURL,
|
||
fallbackDisplayName: account.displayName,
|
||
expectedPlatformUid: account.platformUid,
|
||
})
|
||
}
|
||
|
||
if (definitionLooksLikeLoginRedirect(currentURL, {
|
||
id: platformMeta.id,
|
||
label: platformMeta.label,
|
||
loginUrl: platformMeta.loginUrl,
|
||
consoleUrl: platformMeta.consoleUrl,
|
||
detect: (context: DetectContext) =>
|
||
detectAIPlatformAccount(platformMeta.id, platformMeta.label, context),
|
||
})) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
}
|
||
}
|
||
|
||
const pageState = await readGenericAIPageState(window.webContents)
|
||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||
return {
|
||
verdict: 'challenge_required',
|
||
reason: 'captcha_gate',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
challengeSignalCount: pageState?.challengeSignalCount ?? 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
const detected = await detectAIPlatformAccount(account.platform, platformMeta.label, {
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
if (detected) {
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||
sessionFingerprint: detected.platformUid,
|
||
}
|
||
}
|
||
|
||
if (!pageState) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
}
|
||
}
|
||
|
||
if (pageState.loggedOutSignalCount > 0) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
loggedOutSignalCount: pageState.loggedOutSignalCount,
|
||
},
|
||
}
|
||
}
|
||
|
||
if ((pageState.loginSignalCount ?? 0) > 0 && (pageState.strongAuthSignalCount ?? 0) === 0) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
loginSignalCount: pageState.loginSignalCount,
|
||
},
|
||
}
|
||
}
|
||
|
||
return {
|
||
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',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
},
|
||
}
|
||
} finally {
|
||
if (!window.isDestroyed()) {
|
||
window.destroy()
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function probePublishAccountSession(
|
||
account: PublishAccountIdentity,
|
||
partition?: string,
|
||
): Promise<AuthProbeResult> {
|
||
if (isAIPlatformBinding(account.platform)) {
|
||
return await probeAIAccountSession(account, partition)
|
||
}
|
||
|
||
const handle = partition
|
||
? createSessionHandleForPartition(account.id, partition)
|
||
: await findLocalPublishAccountSessionHandle(account)
|
||
|
||
if (!handle) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'missing_partition',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'wangyihao') {
|
||
await restoreWangyihaoSessionCookies(account.id, handle.session)
|
||
}
|
||
|
||
const definition = platformBindingDefinitions[account.platform]
|
||
if (!definition) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
platform: account.platform,
|
||
code: 'desktop_platform_not_supported',
|
||
},
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'wangyihao') {
|
||
return await probeWangyihaoSession(account, handle.session)
|
||
}
|
||
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默探针`,
|
||
await resolvePublishAccountConsoleURL(definition, handle.session),
|
||
handle.session,
|
||
{ show: false },
|
||
)
|
||
|
||
let mainFrameLoadError:
|
||
| {
|
||
code: number
|
||
description: string
|
||
url: string
|
||
}
|
||
| undefined
|
||
window.webContents.on(
|
||
'did-fail-load',
|
||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||
if (isMainFrame && errorCode !== -3) {
|
||
mainFrameLoadError = {
|
||
code: errorCode,
|
||
description: errorDescription,
|
||
url: validatedURL,
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
try {
|
||
await waitForWindowNavigationSettled(window)
|
||
if (window.isDestroyed()) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
}
|
||
}
|
||
|
||
const loadError = mainFrameLoadError
|
||
if (loadError) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
errorCode: loadError.code,
|
||
errorDescription: loadError.description,
|
||
url: loadError.url,
|
||
},
|
||
}
|
||
}
|
||
|
||
const currentURL = window.webContents.getURL()
|
||
if (!currentURL || currentURL.startsWith('data:text/html')) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
}
|
||
}
|
||
|
||
const isLoginRedirect = definitionLooksLikeLoginRedirect(currentURL, definition)
|
||
|
||
if (isLoginRedirect) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'weixin_gzh') {
|
||
const pageState = await readWeixinGzhConsoleState(window.webContents)
|
||
if (pageState?.loggedOut) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
title: pageState.title,
|
||
text: pageState.textSnippet,
|
||
},
|
||
}
|
||
}
|
||
|
||
const detected = await detectWeixinGzh({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
}).catch(() => null)
|
||
if (!detected) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
title: pageState?.title,
|
||
text: pageState?.textSnippet,
|
||
},
|
||
}
|
||
}
|
||
|
||
if (!detectedAccountMatchesIdentity(detected, account)) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
evidence: {
|
||
url: currentURL,
|
||
detectedPlatformUid: detected.platformUid,
|
||
expectedPlatformUid: account.platformUid,
|
||
},
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'toutiaohao') {
|
||
const detected = await detectToutiaoFromSession(handle.session)
|
||
if (detected) {
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||
sessionFingerprint: detected.platformUid,
|
||
}
|
||
}
|
||
|
||
const consoleReady = await verifyToutiaoConsoleAccess(window, handle.session)
|
||
if (consoleReady) {
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: fallbackAccountHealthProfile(account),
|
||
sessionFingerprint:
|
||
normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
}
|
||
}
|
||
|
||
const detected = await definition.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
if (detected) {
|
||
if (
|
||
platformRequiresDetectedAccountIdentityMatch(account.platform) &&
|
||
!detectedAccountMatchesIdentity(detected, account)
|
||
) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
evidence: {
|
||
detectedPlatformUid: detected.platformUid,
|
||
expectedPlatformUid: account.platformUid,
|
||
},
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
}
|
||
}
|
||
|
||
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
code: 'account_api_empty',
|
||
},
|
||
}
|
||
}
|
||
|
||
const consoleReady =
|
||
account.platform === 'zol'
|
||
? await verifyZolConsoleAccess(window, handle.session, account)
|
||
: await verifyPublishAccountConsoleAccess(account, handle)
|
||
if (consoleReady) {
|
||
return {
|
||
verdict: 'active',
|
||
reason: 'probe_success',
|
||
profile: fallbackAccountHealthProfile(account),
|
||
sessionFingerprint: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||
}
|
||
}
|
||
|
||
return {
|
||
verdict: 'expired',
|
||
reason: 'login_redirect',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
verdict: 'network_error',
|
||
reason: 'network_error',
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
},
|
||
}
|
||
} finally {
|
||
if (!window.isDestroyed()) {
|
||
window.destroy()
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function silentRefreshAIAccountSession(
|
||
account: PublishAccountIdentity,
|
||
partition?: string,
|
||
): Promise<boolean> {
|
||
const result = await probeAIAccountSession(account, partition).catch(() => null)
|
||
return result?.verdict === 'active'
|
||
}
|
||
|
||
export async function silentRefreshPublishAccountSession(
|
||
account: PublishAccountIdentity,
|
||
partition?: string,
|
||
): Promise<boolean> {
|
||
if (isAIPlatformBinding(account.platform)) {
|
||
return await silentRefreshAIAccountSession(account, partition)
|
||
}
|
||
|
||
const handle = partition
|
||
? createSessionHandleForPartition(account.id, partition)
|
||
: await findLocalPublishAccountSessionHandle(account)
|
||
|
||
if (!handle) {
|
||
return false
|
||
}
|
||
|
||
if (account.platform === 'wangyihao') {
|
||
await restoreWangyihaoSessionCookies(account.id, handle.session)
|
||
}
|
||
|
||
const definition = platformBindingDefinitions[account.platform]
|
||
if (!definition) {
|
||
return false
|
||
}
|
||
|
||
if (account.platform === 'wangyihao') {
|
||
const result = await probeWangyihaoSession(account, handle.session, {
|
||
allowCookieWrite: true,
|
||
}).catch(() => null)
|
||
if (result?.verdict === 'active') {
|
||
await saveWangyihaoSessionCookies(account.id, handle.session)
|
||
}
|
||
return result?.verdict === 'active'
|
||
}
|
||
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默续期`,
|
||
await resolvePublishAccountConsoleURL(definition, handle.session),
|
||
handle.session,
|
||
{ show: false },
|
||
)
|
||
|
||
try {
|
||
await waitForWindowNavigationSettled(window, 10_000)
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
await sleep(1_200)
|
||
await flushSessionPersistence(handle.session)
|
||
if (account.platform === 'weixin_gzh') {
|
||
const pageState = await readWeixinGzhConsoleState(window.webContents)
|
||
if (pageState?.loggedOut) {
|
||
return false
|
||
}
|
||
|
||
const detected = await detectWeixinGzh({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
}).catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
if (account.platform === 'zol') {
|
||
return await verifyZolConsoleAccess(window, handle.session, account)
|
||
}
|
||
|
||
if (platformRequiresDetectedAccountIdentityMatch(account.platform)) {
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
.catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
.catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
return !definitionLooksLikeLoginRedirect(window.webContents.getURL(), definition)
|
||
} catch {
|
||
return false
|
||
} finally {
|
||
if (!window.isDestroyed()) {
|
||
window.destroy()
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function inspectPublishAccountLocalSession(
|
||
account: PublishAccountIdentity,
|
||
): Promise<PublishAccountLocalInspection> {
|
||
const handle = await findLocalPublishAccountSessionHandle(account)
|
||
if (!handle) {
|
||
return {
|
||
availability: 'missing',
|
||
profile: null,
|
||
}
|
||
}
|
||
|
||
const profile = await resolvePublishAccountProfileFromHandle(account, handle)
|
||
if (!profile) {
|
||
const consoleReady = await verifyPublishAccountConsoleAccess(account, handle).catch(() => false)
|
||
if (consoleReady) {
|
||
return {
|
||
availability: 'ready',
|
||
profile: {
|
||
platformUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||
displayName: account.displayName,
|
||
avatarUrl: null,
|
||
},
|
||
}
|
||
}
|
||
|
||
if (account.platform === 'toutiaohao' && (await hasToutiaoAuthCookies(handle.session))) {
|
||
return {
|
||
availability: 'ready',
|
||
profile: {
|
||
platformUid: normalizeAccountPlatformUid(account.platformUid) || account.platformUid,
|
||
displayName: account.displayName,
|
||
avatarUrl: null,
|
||
},
|
||
}
|
||
}
|
||
|
||
return {
|
||
availability: 'expired',
|
||
profile: null,
|
||
}
|
||
}
|
||
|
||
return {
|
||
availability: 'ready',
|
||
profile,
|
||
}
|
||
}
|
||
|
||
export async function resolvePublishAccountProfile(
|
||
account: PublishAccountIdentity,
|
||
): Promise<PublishAccountProfile | null> {
|
||
const handle = await ensurePublishAccountSessionHandle(account)
|
||
return await resolvePublishAccountProfileFromHandle(account, handle)
|
||
}
|
||
|
||
async function finalizeDetectedAccountForBind(
|
||
_definition: PublishPlatformBindingDefinition,
|
||
_context: DetectContext,
|
||
detected: DetectedAccount,
|
||
): Promise<DetectedAccount> {
|
||
return detected
|
||
}
|
||
|
||
function isToutiaoLoginURL(url: string): boolean {
|
||
return url.startsWith(TOUTIAO_LOGIN_URL)
|
||
}
|
||
|
||
async function waitForWindowNavigationSettled(
|
||
window: BrowserWindow,
|
||
timeoutMs = 8_000,
|
||
): Promise<void> {
|
||
const startedAt = Date.now()
|
||
let lastURL = ''
|
||
let stableSince = Date.now()
|
||
|
||
while (!window.isDestroyed() && Date.now() - startedAt < timeoutMs) {
|
||
const currentURL = window.webContents.getURL()
|
||
if (currentURL !== lastURL) {
|
||
lastURL = currentURL
|
||
stableSince = Date.now()
|
||
}
|
||
|
||
if (!window.webContents.isLoading() && Date.now() - stableSince >= 450) {
|
||
return
|
||
}
|
||
|
||
await sleep(150)
|
||
}
|
||
}
|
||
|
||
async function flushSessionPersistence(target: Session): Promise<void> {
|
||
const maybeFlushStorageData = (
|
||
target as Session & {
|
||
flushStorageData?: () => Promise<void>
|
||
}
|
||
).flushStorageData
|
||
|
||
if (typeof maybeFlushStorageData === 'function') {
|
||
try {
|
||
await maybeFlushStorageData.call(target)
|
||
} catch (error) {
|
||
console.warn('[desktop-session] flushStorageData failed', {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
}
|
||
}
|
||
|
||
try {
|
||
await target.cookies.flushStore()
|
||
} catch (error) {
|
||
console.warn('[desktop-session] flushStore failed', {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
}
|
||
}
|
||
|
||
async function verifyToutiaoConsoleAccess(
|
||
window: BrowserWindow,
|
||
session: Session,
|
||
): Promise<boolean> {
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
await loadWindowURLSafely(window, TOUTIAO_CONSOLE_HOME_URL, '头条号创作台校验')
|
||
await waitForWindowNavigationSettled(window)
|
||
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
const currentURL = window.webContents.getURL()
|
||
if (isToutiaoLoginURL(currentURL)) {
|
||
return false
|
||
}
|
||
|
||
if (TOUTIAO_WORKBENCH_URL_PATTERN.test(currentURL)) {
|
||
return true
|
||
}
|
||
|
||
const pageResponse = await pageFetchJson<ToutiaoMediaInfoResponse>(
|
||
window.webContents,
|
||
'https://mp.toutiao.com/mp/agw/media/get_media_info',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
},
|
||
)
|
||
if (extractToutiaoAccount(pageResponse)) {
|
||
return true
|
||
}
|
||
|
||
const sessionResponse = await sessionCookieFetchJson<ToutiaoMediaInfoResponse>(
|
||
session,
|
||
'https://mp.toutiao.com/mp/agw/media/get_media_info',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
},
|
||
)
|
||
|
||
return extractToutiaoAccount(sessionResponse) !== null
|
||
}
|
||
|
||
function isWangyihaoLoggedOutText(text: string): boolean {
|
||
return /(登录超时|登录态失效|登录状态失效|登录已过期|会话已过期|请重新登录|请先登录|未登录|网易邮箱账号登录|网易邮箱帐号登录|手机号登录|扫码登录)/i.test(
|
||
text,
|
||
)
|
||
}
|
||
|
||
async function readWangyihaoConsoleState(
|
||
webContents: WebContents | null | undefined,
|
||
): Promise<{ loggedOut: boolean; title: string; textSnippet: string } | null> {
|
||
if (!webContents || webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const state = await webContents
|
||
.executeJavaScript(
|
||
`(() => {
|
||
try {
|
||
const text = String(document.body?.innerText || "");
|
||
const title = String(document.title || "");
|
||
const source = title + "\\n" + text.slice(0, 1200);
|
||
return {
|
||
title,
|
||
textSnippet: text.slice(0, 500),
|
||
loggedOut: /(登录超时|登录态失效|登录状态失效|登录已过期|会话已过期|请重新登录|请先登录|未登录|网易邮箱账号登录|网易邮箱帐号登录|手机号登录|扫码登录)/i.test(source),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();`,
|
||
true,
|
||
)
|
||
.catch(() => null)
|
||
|
||
if (!state || typeof state !== 'object') {
|
||
return null
|
||
}
|
||
|
||
const typed = state as { loggedOut?: unknown; title?: unknown; textSnippet?: unknown }
|
||
const title = typeof typed.title === 'string' ? typed.title : ''
|
||
const textSnippet = typeof typed.textSnippet === 'string' ? typed.textSnippet : ''
|
||
return {
|
||
loggedOut: typed.loggedOut === true || isWangyihaoLoggedOutText(`${title}\n${textSnippet}`),
|
||
title,
|
||
textSnippet,
|
||
}
|
||
}
|
||
|
||
async function verifyWangyihaoConsoleAccess(
|
||
window: BrowserWindow,
|
||
session: Session,
|
||
account: PublishAccountIdentity,
|
||
): Promise<boolean> {
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
const currentURL = window.webContents.getURL()
|
||
if (!currentURL || currentURL.startsWith('data:text/html')) {
|
||
return false
|
||
}
|
||
|
||
const definition = platformBindingDefinitions.wangyihao
|
||
if (definition && definitionLooksLikeLoginRedirect(currentURL, definition)) {
|
||
return false
|
||
}
|
||
|
||
const pageState = await readWangyihaoConsoleState(window.webContents)
|
||
if (pageState?.loggedOut) {
|
||
return false
|
||
}
|
||
|
||
const detected = await detectWangyihaoFromSession(session).catch(() => null)
|
||
if (detected) {
|
||
return detectedAccountMatchesIdentity(detected, account)
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function detectedAccountsLikelyMatch(left: DetectedAccount, right: DetectedAccount): boolean {
|
||
const leftUid = normalizeAccountPlatformUid(left.platformUid) || left.platformUid
|
||
const rightUid = normalizeAccountPlatformUid(right.platformUid) || right.platformUid
|
||
if (leftUid && rightUid) {
|
||
return sameAccountPlatformUid(leftUid, rightUid)
|
||
}
|
||
|
||
const leftName = normalizeComparableText(left.displayName)
|
||
const rightName = normalizeComparableText(right.displayName)
|
||
return Boolean(leftName && rightName && leftName === rightName)
|
||
}
|
||
|
||
async function verifyWangyihaoBindWindowReady(
|
||
window: BrowserWindow,
|
||
session: Session,
|
||
expected: DetectedAccount,
|
||
): Promise<boolean> {
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
const currentURL = window.webContents.getURL()
|
||
const definition = platformBindingDefinitions.wangyihao
|
||
if (!currentURL || currentURL.startsWith('data:text/html')) {
|
||
return false
|
||
}
|
||
if (definition && definitionLooksLikeLoginRedirect(currentURL, definition)) {
|
||
return false
|
||
}
|
||
|
||
const pageState = await readWangyihaoConsoleState(window.webContents)
|
||
if (pageState?.loggedOut) {
|
||
return false
|
||
}
|
||
|
||
const detected = await detectWangyihaoFromSession(session).catch(() => null)
|
||
return detected ? detectedAccountsLikelyMatch(detected, expected) : false
|
||
}
|
||
|
||
function detectedAccountMatchesIdentity(
|
||
detected: DetectedAccount,
|
||
account: PublishAccountIdentity,
|
||
): boolean {
|
||
const expected = normalizeAccountPlatformUid(account.platformUid) || account.platformUid
|
||
if (!expected) {
|
||
return true
|
||
}
|
||
return sameAccountPlatformUid(detected.platformUid, expected)
|
||
}
|
||
|
||
function platformRequiresDetectedAccountForConsoleAccess(platformId: string): boolean {
|
||
return platformId === 'qiehao'
|
||
}
|
||
|
||
function platformRequiresDetectedAccountIdentityMatch(platformId: string): boolean {
|
||
return (
|
||
platformId === 'qiehao' ||
|
||
platformId === 'wangyihao' ||
|
||
platformId === 'zol' ||
|
||
platformId === 'bilibili' ||
|
||
platformId === 'juejin' ||
|
||
platformId === 'smzdm'
|
||
)
|
||
}
|
||
|
||
async function verifyZolConsoleAccess(
|
||
window: BrowserWindow,
|
||
session: Session,
|
||
account: PublishAccountIdentity,
|
||
): Promise<boolean> {
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
const detected = await detectZol({
|
||
session,
|
||
webContents: window.webContents,
|
||
}).catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
const LOGIN_REDIRECT_TOKEN_RE =
|
||
/(^|[/?#=&._-])(login|signin|sign-in|sign_in|signup|sign-up|register|auth|oauth|passport|scan|qrcode|qrlogin|wxlogin|sso)(?=$|[/?#=&._-])/i
|
||
const LOGIN_REDIRECT_HOST_RE = /(^|[.-])(login|passport|auth|sso|reg)(?=$|[.-])/i
|
||
|
||
function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean {
|
||
if (!currentURL || !loginURL) {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const current = new URL(currentURL)
|
||
const login = new URL(loginURL)
|
||
if (current.origin !== login.origin) {
|
||
return false
|
||
}
|
||
|
||
const currentPath = normalizeURLPath(current.pathname)
|
||
const loginPath = normalizeURLPath(login.pathname)
|
||
if (!loginPath) {
|
||
return (
|
||
LOGIN_REDIRECT_TOKEN_RE.test(`${current.pathname}${current.search}${current.hash}`) ||
|
||
LOGIN_REDIRECT_HOST_RE.test(current.hostname)
|
||
)
|
||
}
|
||
|
||
return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function definitionLooksLikeLoginRedirect(
|
||
currentURL: string,
|
||
definition: PublishPlatformBindingDefinition,
|
||
): boolean {
|
||
const loginRedirectUrls = definition.loginRedirectUrls ?? [definition.loginUrl]
|
||
return loginRedirectUrls.some((loginURL) => looksLikeLoginRedirect(currentURL, loginURL))
|
||
}
|
||
|
||
async function verifyPublishAccountConsoleAccess(
|
||
account: PublishAccountIdentity,
|
||
handle: SessionHandle,
|
||
): Promise<boolean> {
|
||
const definition = platformBindingDefinitions[account.platform]
|
||
if (!definition) {
|
||
return false
|
||
}
|
||
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默校验`,
|
||
await resolvePublishAccountConsoleURL(definition, handle.session),
|
||
handle.session,
|
||
{ show: false },
|
||
)
|
||
|
||
try {
|
||
if (account.platform === 'toutiaohao') {
|
||
return await verifyToutiaoConsoleAccess(window, handle.session)
|
||
}
|
||
|
||
await waitForWindowNavigationSettled(window)
|
||
if (window.isDestroyed()) {
|
||
return false
|
||
}
|
||
|
||
const currentURL = window.webContents.getURL()
|
||
if (!currentURL || currentURL.startsWith('data:text/html')) {
|
||
return false
|
||
}
|
||
|
||
if (isAIPlatformBinding(account.platform)) {
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
.catch(() => null)
|
||
return detected !== null
|
||
}
|
||
|
||
if (account.platform === 'weixin_gzh') {
|
||
const pageState = await readWeixinGzhConsoleState(window.webContents)
|
||
if (pageState?.loggedOut) {
|
||
return false
|
||
}
|
||
|
||
const detected = await detectWeixinGzh({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
}).catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
if (account.platform === 'wangyihao') {
|
||
return await verifyWangyihaoConsoleAccess(window, handle.session, account)
|
||
}
|
||
|
||
if (account.platform === 'zol') {
|
||
return await verifyZolConsoleAccess(window, handle.session, account)
|
||
}
|
||
|
||
if (platformRequiresDetectedAccountIdentityMatch(account.platform)) {
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
.catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
})
|
||
.catch(() => null)
|
||
return detected ? detectedAccountMatchesIdentity(detected, account) : false
|
||
}
|
||
|
||
return !definitionLooksLikeLoginRedirect(currentURL, definition)
|
||
} finally {
|
||
if (!window.isDestroyed()) {
|
||
window.destroy()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function detectBaijiahao({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<BaijiahaoAppInfoResponse>(
|
||
session,
|
||
'https://baijiahao.baidu.com/builder/app/appinfo',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
},
|
||
).catch(() => null)
|
||
|
||
const user = response?.data?.user
|
||
const uid = stringId(user?.userid)
|
||
const nickname = user?.name || user?.uname || user?.username || ''
|
||
if (!uid || !nickname) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: nickname,
|
||
avatarUrl: normalizeRemoteUrl(user?.avatar),
|
||
}
|
||
}
|
||
|
||
async function detectSohu({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<SohuRegisterInfoResponse>(
|
||
session,
|
||
'https://mp.sohu.com/mpbp/bp/account/register-info',
|
||
).catch(() => null)
|
||
|
||
const account = response?.data?.account
|
||
const uid = stringId(account?.id)
|
||
if (!uid || !account?.nickName) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: account.nickName,
|
||
avatarUrl: normalizeRemoteUrl(account.avatar),
|
||
}
|
||
}
|
||
|
||
async function detectQiehao({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const cookie = await sessionCookieHeader(session, 'om.qq.com')
|
||
const response = await sessionFetchJson<QiehaoBasicInfoResponse>(
|
||
session,
|
||
'https://om.qq.com/maccountsetting/basicinfo?relogin=1',
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
...(cookie ? { cookie } : {}),
|
||
},
|
||
},
|
||
).catch(() => null)
|
||
|
||
const cpInfo = response?.data?.cpInfo
|
||
const uid = stringId(cpInfo?.mediaId)
|
||
if (!uid || !cpInfo?.mediaName) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: cpInfo.mediaName,
|
||
avatarUrl: normalizeRemoteUrl(cpInfo.header),
|
||
}
|
||
}
|
||
|
||
async function detectZhihu({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<ZhihuMeResponse>(
|
||
session,
|
||
'https://www.zhihu.com/api/v4/me',
|
||
{
|
||
headers: {
|
||
'x-requested-with': 'fetch',
|
||
},
|
||
},
|
||
).catch(() => null)
|
||
|
||
const uid = stringId(response?.uid || response?.id)
|
||
if (!uid || !response?.name) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: response.name,
|
||
avatarUrl: normalizeRemoteUrl(response.avatar_url),
|
||
}
|
||
}
|
||
|
||
async function detectWangyihao({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
return await detectWangyihaoFromSession(session)
|
||
}
|
||
|
||
async function detectJianshu({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<JianshuUser>(
|
||
session,
|
||
'https://www.jianshu.com/author/current_user',
|
||
{
|
||
headers: {
|
||
accept: 'application/json',
|
||
},
|
||
},
|
||
).catch(() => null)
|
||
|
||
const uid = stringId(response?.id)
|
||
if (!uid || !response?.nickname) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: response.nickname,
|
||
avatarUrl: normalizeRemoteUrl(response.avatar),
|
||
}
|
||
}
|
||
|
||
async function fetchBilibiliSpaceAvatar(
|
||
session: Session,
|
||
uid: string,
|
||
nav: BilibiliNavResponse | null,
|
||
cookie: string,
|
||
): Promise<string | null> {
|
||
const query = buildBilibiliWbiQuery(nav, {
|
||
mid: uid,
|
||
platform: 'web',
|
||
token: '',
|
||
web_location: 1550101,
|
||
})
|
||
if (!query) {
|
||
return null
|
||
}
|
||
|
||
const controller = new AbortController()
|
||
const timeout = setTimeout(() => {
|
||
controller.abort()
|
||
}, 1800)
|
||
|
||
try {
|
||
const response = await sessionFetchJson<BilibiliSpaceInfoResponse>(
|
||
session,
|
||
`${BILIBILI_API_ORIGIN}/x/space/wbi/acc/info?${query}`,
|
||
{
|
||
signal: controller.signal,
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
referer: `https://space.bilibili.com/${encodeURIComponent(uid)}`,
|
||
...(cookie ? { cookie } : {}),
|
||
},
|
||
},
|
||
).catch(() => null)
|
||
|
||
if (response?.code !== 0) {
|
||
return null
|
||
}
|
||
|
||
return normalizeBilibiliAvatarUrl(response.data?.face)
|
||
} finally {
|
||
clearTimeout(timeout)
|
||
}
|
||
}
|
||
|
||
async function detectBilibili({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const cookie = await sessionCookieHeader(session, 'bilibili.com')
|
||
const response = await sessionFetchJson<BilibiliNavResponse>(
|
||
session,
|
||
`${BILIBILI_API_ORIGIN}/x/web-interface/nav?build=0&mobi_app=web`,
|
||
{
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
...(cookie ? { cookie } : {}),
|
||
},
|
||
},
|
||
).catch(() => null)
|
||
|
||
const user = response?.data
|
||
const uid = stringId(user?.mid)
|
||
if (response?.code !== 0 || !user?.isLogin || !uid || !user.uname) {
|
||
return null
|
||
}
|
||
|
||
const avatarUrl =
|
||
normalizeBilibiliAvatarUrl(user.face) ??
|
||
(await fetchBilibiliSpaceAvatar(session, uid, response, cookie))
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: user.uname,
|
||
avatarUrl,
|
||
}
|
||
}
|
||
|
||
async function detectJuejin({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<JuejinProfileResponse>(
|
||
session,
|
||
'https://api.juejin.cn/user_api/v1/user/get_profile',
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
accept: 'application/json',
|
||
},
|
||
body: '{}',
|
||
},
|
||
).catch(() => null)
|
||
|
||
if (!response || (typeof response.err_no === 'number' && response.err_no !== 0)) {
|
||
return null
|
||
}
|
||
|
||
const user = response?.data?.bui_user
|
||
if (!user?.user_id || !user.screen_name) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: user.user_id,
|
||
displayName: user.screen_name,
|
||
avatarUrl: normalizeRemoteUrl(user.avatar_url ?? user.avatar_large),
|
||
}
|
||
}
|
||
|
||
function normalizeSmzdmUid(value: unknown): string {
|
||
const uid = stringId(value)
|
||
return uid && uid !== '0' ? uid : ''
|
||
}
|
||
|
||
function smzdmAccountFromCurrentUser(
|
||
response: SmzdmCurrentUser | null | undefined,
|
||
fallback?: {
|
||
displayName?: string | null
|
||
avatarUrl?: string | null
|
||
} | null,
|
||
): DetectedAccount | null {
|
||
const user = response?.data ?? response
|
||
const uid = normalizeSmzdmUid(user?.smzdm_id)
|
||
if (!uid) {
|
||
return null
|
||
}
|
||
|
||
const nickname =
|
||
normalizeText(user?.nickname) ??
|
||
normalizeText(user?.audit_nickname) ??
|
||
normalizeText(fallback?.displayName) ??
|
||
`值友${uid}`
|
||
return {
|
||
platformUid: uid,
|
||
displayName: nickname,
|
||
avatarUrl: normalizeRemoteUrl(user?.avatar) ?? normalizeRemoteUrl(fallback?.avatarUrl),
|
||
}
|
||
}
|
||
|
||
async function readSmzdmPageState(
|
||
webContents: WebContents | null | undefined,
|
||
): Promise<SmzdmPageState | null> {
|
||
if (!webContents || webContents.isDestroyed()) {
|
||
return null
|
||
}
|
||
|
||
const serialized = await webContents
|
||
.executeJavaScript(
|
||
`(() => {
|
||
try {
|
||
const normalize = (value) => {
|
||
if (typeof value !== "string") return null;
|
||
const trimmed = value.trim().replace(/\\s+/g, " ");
|
||
return trimmed || null;
|
||
};
|
||
const compact = (value) => normalize(value)?.replace(/\\s+/g, "") || 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 ignored = /^(首页|好价|社区|百科|集团官网|商业合作|爆料投稿|个人中心|我的消息|兑换记录|账户设置|关注|粉丝|社区达人榜|查看更多|搜索|文章|笔记|视频|商品|众测|评论|收藏|品牌认领|金币|碎银子|经验|登录|注册|扫码登录|手机号登录)$/;
|
||
const scoreName = (text, rect) => {
|
||
let score = 0;
|
||
if (/^值友[0-9A-Za-z_-]{4,}$/.test(text)) score += 80;
|
||
if (rect.top >= 120 && rect.top <= 360 && rect.left >= 160 && rect.left <= 820) score += 40;
|
||
if (rect.top >= 40 && rect.top <= 140 && rect.left >= window.innerWidth * 0.72) score += 20;
|
||
return score;
|
||
};
|
||
const pickDisplayName = () => {
|
||
const candidates = [];
|
||
const selectors = [
|
||
"[class*=nickname]",
|
||
"[class*=user] [class*=name]",
|
||
"[class*=account] [class*=name]",
|
||
"[class*=profile] [class*=name]",
|
||
".name",
|
||
];
|
||
for (const selector of selectors) {
|
||
for (const node of document.querySelectorAll(selector)) {
|
||
if (!isVisible(node)) continue;
|
||
const text = compact(node.textContent || "");
|
||
if (!text || text.length < 2 || text.length > 40 || ignored.test(text)) continue;
|
||
const rect = node.getBoundingClientRect();
|
||
candidates.push({ text, score: scoreName(text, rect) + 30 });
|
||
}
|
||
}
|
||
for (const node of document.querySelectorAll("body *")) {
|
||
if (!isVisible(node)) continue;
|
||
const text = compact(node.textContent || "");
|
||
if (!text || text.length < 2 || text.length > 40 || ignored.test(text)) continue;
|
||
const rect = node.getBoundingClientRect();
|
||
if (rect.top < 0 || rect.top > 420 || rect.left < 80) continue;
|
||
const score = scoreName(text, rect);
|
||
if (score > 0) {
|
||
candidates.push({ text, score });
|
||
}
|
||
}
|
||
candidates.sort((left, right) => right.score - left.score);
|
||
if (candidates[0]?.text) {
|
||
return candidates[0].text;
|
||
}
|
||
const bodyText = document.body?.innerText || "";
|
||
return compact(bodyText.match(/值友[0-9A-Za-z_-]{4,}/)?.[0] || "");
|
||
};
|
||
const pickAvatar = () => {
|
||
const images = Array.from(document.images)
|
||
.map((image) => ({ src: image.currentSrc || image.src || "", rect: image.getBoundingClientRect() }))
|
||
.filter(({ src, rect }) =>
|
||
src
|
||
&& !/(logo|favicon|sprite|blank)/i.test(src)
|
||
&& rect.top >= 100
|
||
&& rect.top <= 420
|
||
&& rect.left >= 80
|
||
&& rect.left <= 520
|
||
&& rect.width >= 40
|
||
&& rect.height >= 40
|
||
)
|
||
.sort((left, right) => {
|
||
const leftArea = left.rect.width * left.rect.height;
|
||
const rightArea = right.rect.width * right.rect.height;
|
||
return rightArea - leftArea;
|
||
});
|
||
return images[0]?.src || null;
|
||
};
|
||
const displayName = pickDisplayName();
|
||
const bodyText = document.body?.innerText || "";
|
||
const path = window.location.pathname || "";
|
||
const loggedInPage =
|
||
/(?:^|\\.)smzdm\\.com$/i.test(window.location.hostname)
|
||
&& !/\\/user\\/login/i.test(path)
|
||
&& Boolean(displayName)
|
||
&& (
|
||
/个人中心/.test(bodyText)
|
||
|| /账户设置/.test(bodyText)
|
||
|| /我的消息/.test(bodyText)
|
||
|| /^值友[0-9A-Za-z_-]{4,}$/.test(displayName)
|
||
);
|
||
|
||
return JSON.stringify({
|
||
displayName,
|
||
avatarUrl: pickAvatar(),
|
||
loggedInPage,
|
||
});
|
||
} catch {
|
||
return JSON.stringify(null);
|
||
}
|
||
})()`,
|
||
true,
|
||
)
|
||
.catch(() => null)
|
||
|
||
if (typeof serialized !== 'string' || !serialized) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(serialized) as Partial<SmzdmPageState> | null
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
return null
|
||
}
|
||
return {
|
||
displayName: normalizeText(parsed.displayName),
|
||
avatarUrl: normalizeRemoteUrl(parsed.avatarUrl),
|
||
loggedInPage: parsed.loggedInPage === true,
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function deriveSmzdmCookieAccount(
|
||
session: Session,
|
||
pageState?: SmzdmPageState | null,
|
||
): Promise<DetectedAccount | null> {
|
||
const uid = normalizeSmzdmUid(
|
||
await sessionCookieValue(session, 'smzdm.com', 'smzdm_id').catch(() => ''),
|
||
)
|
||
const userCookie = await sessionCookieValue(session, 'smzdm.com', 'user').catch(() => '')
|
||
const sessCookie = await sessionCookieValue(session, 'smzdm.com', 'sess').catch(() => '')
|
||
const decodedUser = normalizeText(decodeCookieText(userCookie))
|
||
const displayName =
|
||
decodedUser ?? normalizeText(pageState?.displayName) ?? (uid ? `值友${uid}` : null)
|
||
|
||
if (uid && displayName) {
|
||
return {
|
||
platformUid: uid,
|
||
displayName,
|
||
avatarUrl: normalizeRemoteUrl(pageState?.avatarUrl),
|
||
}
|
||
}
|
||
|
||
const authFingerprint = normalizeText(sessCookie)
|
||
if (authFingerprint && displayName) {
|
||
return {
|
||
platformUid: boundedIdentity('smzdm-sess', authFingerprint),
|
||
displayName,
|
||
avatarUrl: normalizeRemoteUrl(pageState?.avatarUrl),
|
||
}
|
||
}
|
||
|
||
if (pageState?.loggedInPage && pageState.displayName) {
|
||
const cookieHeader = await sessionCookieHeader(session, 'smzdm.com').catch(() => '')
|
||
return {
|
||
platformUid: boundedIdentity('smzdm-page', cookieHeader || pageState.displayName),
|
||
displayName: pageState.displayName,
|
||
avatarUrl: normalizeRemoteUrl(pageState.avatarUrl),
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function detectSmzdm({
|
||
session,
|
||
webContents,
|
||
}: DetectContext): Promise<DetectedAccount | null> {
|
||
const currentUserUrl = 'https://zhiyou.smzdm.com/user/info/jsonp_get_current'
|
||
const pageState = await readSmzdmPageState(webContents).catch(() => null)
|
||
const pageResponse = await pageFetchJson<SmzdmCurrentUser>(webContents, currentUserUrl, {
|
||
credentials: 'include',
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
}).catch(() => null)
|
||
const fromPage = smzdmAccountFromCurrentUser(pageResponse, pageState)
|
||
if (fromPage) {
|
||
return fromPage
|
||
}
|
||
|
||
const sessionResponse = await sessionFetchJson<SmzdmCurrentUser>(session, currentUserUrl, {
|
||
credentials: 'include',
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
referer: 'https://zhiyou.smzdm.com/user',
|
||
origin: 'https://zhiyou.smzdm.com',
|
||
},
|
||
}).catch(() => null)
|
||
const fromSession = smzdmAccountFromCurrentUser(sessionResponse, pageState)
|
||
if (fromSession) {
|
||
return fromSession
|
||
}
|
||
|
||
const cookieResponse = await sessionCookieFetchJson<SmzdmCurrentUser>(session, currentUserUrl, {
|
||
headers: {
|
||
accept: 'application/json, text/plain, */*',
|
||
},
|
||
}).catch(() => null)
|
||
const fromCookieRequest = smzdmAccountFromCurrentUser(cookieResponse, pageState)
|
||
if (fromCookieRequest) {
|
||
return fromCookieRequest
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function detectWeixinGzh({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const html = await sessionFetchText(session, WEIXIN_GZH_HOME_URL).catch(() => '')
|
||
if (!html) {
|
||
return null
|
||
}
|
||
|
||
const token = extractWeixinGzhToken(html)
|
||
const userName = html.match(/user_name:\s*["']([^"']+)["']/)?.[1] ?? ''
|
||
const nickName = html.match(/nick_name:\s*["']([^"']+)["']/)?.[1] ?? ''
|
||
const avatarFromHead =
|
||
html.match(/class="weui-desktop-account__thumb"[^>]*src="([^"]+)"/)?.[1] ?? ''
|
||
const avatarFromMeta = html.match(/head_img:\s*['"]([^'"]+)['"]/)?.[1] ?? ''
|
||
const avatarUrl = (avatarFromHead || avatarFromMeta || '').replace(/^http:\/\//, 'https://')
|
||
|
||
if (!token || !userName || !nickName) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: userName,
|
||
displayName: nickName,
|
||
avatarUrl: normalizeRemoteUrl(avatarUrl),
|
||
}
|
||
}
|
||
|
||
async function zolRequestHeaders(session: Session): Promise<Record<string, string>> {
|
||
const cookie = await sessionCookieHeader(session, 'zol.com.cn').catch(() => '')
|
||
return {
|
||
accept: 'application/json, text/plain, */*',
|
||
origin: 'https://post.zol.com.cn',
|
||
referer: ZOL_REFERER,
|
||
...(cookie ? { cookie } : {}),
|
||
}
|
||
}
|
||
|
||
async function detectZol({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<ZolUserInfoResponse>(
|
||
session,
|
||
`${ZOL_API_ORIGIN}/api/v1/creator.user.getinfo`,
|
||
{
|
||
credentials: 'include',
|
||
headers: await zolRequestHeaders(session),
|
||
},
|
||
).catch(() => null)
|
||
|
||
const user = response?.data
|
||
const uid = stringId(user?.userId)
|
||
if (!uid || !user?.nickName) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: user.nickName,
|
||
avatarUrl: normalizeRemoteUrl(user.photo),
|
||
}
|
||
}
|
||
|
||
async function detectDongchedi({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<DongchediAccountInfoResponse>(
|
||
session,
|
||
'https://mp.dcdapp.com/passport/account/info/v2/?aid=2302&account_sdk_source=web',
|
||
).catch(() => null)
|
||
|
||
const user = response?.data
|
||
if (!user?.user_id_str || !user.name) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
platformUid: user.user_id_str,
|
||
displayName: user.name,
|
||
avatarUrl: normalizeRemoteUrl(user.avatar_url),
|
||
}
|
||
}
|
||
|
||
const publishBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = {
|
||
toutiaohao: {
|
||
id: 'toutiaohao',
|
||
label: '头条号',
|
||
loginUrl: TOUTIAO_LOGIN_URL,
|
||
consoleUrl: TOUTIAO_CONSOLE_HOME_URL,
|
||
detect: detectToutiao,
|
||
},
|
||
baijiahao: {
|
||
id: 'baijiahao',
|
||
label: '百家号',
|
||
loginUrl: 'https://baijiahao.baidu.com/builder/theme/bjh/login',
|
||
consoleUrl: 'https://baijiahao.baidu.com/builder/rc/edit?type=news',
|
||
detect: detectBaijiahao,
|
||
},
|
||
sohuhao: {
|
||
id: 'sohuhao',
|
||
label: '搜狐号',
|
||
loginUrl: 'https://mp.sohu.com/mpfe/v4/login',
|
||
consoleUrl: 'https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle',
|
||
detect: detectSohu,
|
||
},
|
||
qiehao: {
|
||
id: 'qiehao',
|
||
label: '企鹅号',
|
||
loginUrl: 'https://om.qq.com',
|
||
consoleUrl: 'https://om.qq.com/main.html#/article/add',
|
||
detect: detectQiehao,
|
||
},
|
||
zhihu: {
|
||
id: 'zhihu',
|
||
label: '知乎',
|
||
loginUrl: 'https://www.zhihu.com/signin',
|
||
consoleUrl: 'https://zhuanlan.zhihu.com/write',
|
||
detect: detectZhihu,
|
||
},
|
||
wangyihao: {
|
||
id: 'wangyihao',
|
||
label: '网易号',
|
||
loginUrl: 'https://mp.163.com/login.html',
|
||
consoleUrl: WANGYI_CONSOLE_URL,
|
||
loginRedirectUrls: WANGYI_LOGIN_REDIRECT_URLS,
|
||
detect: detectWangyihao,
|
||
},
|
||
jianshu: {
|
||
id: 'jianshu',
|
||
label: '简书',
|
||
loginUrl: 'https://www.jianshu.com/sign_in',
|
||
consoleUrl: 'https://www.jianshu.com/writer',
|
||
detect: detectJianshu,
|
||
},
|
||
bilibili: {
|
||
id: 'bilibili',
|
||
label: 'bilibili',
|
||
loginUrl: 'https://www.bilibili.com',
|
||
consoleUrl: 'https://member.bilibili.com/platform/upload/text/edit',
|
||
detect: detectBilibili,
|
||
},
|
||
juejin: {
|
||
id: 'juejin',
|
||
label: '稀土掘金',
|
||
loginUrl: 'https://juejin.cn/login',
|
||
consoleUrl: 'https://juejin.cn/editor/drafts/new/v3',
|
||
detect: detectJuejin,
|
||
},
|
||
smzdm: {
|
||
id: 'smzdm',
|
||
label: '什么值得买',
|
||
loginUrl: 'https://zhiyou.smzdm.com/user/login',
|
||
consoleUrl: 'https://post.smzdm.com/',
|
||
detect: detectSmzdm,
|
||
},
|
||
weixin_gzh: {
|
||
id: 'weixin_gzh',
|
||
label: '微信公众号',
|
||
loginUrl: WEIXIN_GZH_LOGIN_URL,
|
||
consoleUrl: buildWeixinGzhDraftListURL(),
|
||
detect: detectWeixinGzh,
|
||
},
|
||
zol: {
|
||
id: 'zol',
|
||
label: '中关村在线',
|
||
loginUrl: ZOL_CONSOLE_URL,
|
||
consoleUrl: ZOL_CONSOLE_URL,
|
||
loginRedirectUrls: ZOL_LOGIN_REDIRECT_URLS,
|
||
detect: detectZol,
|
||
},
|
||
dongchedi: {
|
||
id: 'dongchedi',
|
||
label: '懂车帝',
|
||
loginUrl: 'https://mp.dcdapp.com/login',
|
||
consoleUrl: 'https://mp.dcdapp.com/profile_v2/publish/article',
|
||
detect: detectDongchedi,
|
||
},
|
||
}
|
||
|
||
const aiBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = Object.fromEntries(
|
||
aiPlatformCatalog.map((platform) => [
|
||
platform.id,
|
||
{
|
||
id: platform.id,
|
||
label: platform.label,
|
||
loginUrl: platform.loginUrl,
|
||
consoleUrl: platform.consoleUrl,
|
||
detect: (context: DetectContext) =>
|
||
detectAIPlatformAccount(platform.id, platform.label, context),
|
||
},
|
||
]),
|
||
) as Record<string, PublishPlatformBindingDefinition>
|
||
|
||
const platformBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = {
|
||
...publishBindingDefinitions,
|
||
...aiBindingDefinitions,
|
||
}
|
||
|
||
function createBoundWindow(
|
||
title: string,
|
||
targetURL: string,
|
||
sessionHandle: Session,
|
||
options: { show?: boolean } = {},
|
||
): BrowserWindow {
|
||
const show = options.show ?? true
|
||
const window = new BrowserWindow({
|
||
show,
|
||
width: 1320,
|
||
height: 900,
|
||
minWidth: 1100,
|
||
minHeight: 760,
|
||
title,
|
||
autoHideMenuBar: true,
|
||
skipTaskbar: !show,
|
||
focusable: show,
|
||
hiddenInMissionControl: !show,
|
||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#15191a' : '#f4f1ea',
|
||
webPreferences: {
|
||
session: sessionHandle,
|
||
sandbox: true,
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
})
|
||
|
||
window.webContents.setUserAgent(STANDARD_USER_AGENT)
|
||
attachWindowDiagnostics(window, title)
|
||
|
||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||
if (/^https?:\/\//i.test(url)) {
|
||
setImmediate(() => {
|
||
if (!window.isDestroyed()) {
|
||
void loadWindowURLSafely(window, url, `${title} popup`)
|
||
}
|
||
})
|
||
}
|
||
return { action: 'deny' }
|
||
})
|
||
|
||
void loadWindowURLSafely(window, targetURL, title)
|
||
return window
|
||
}
|
||
|
||
function focusBindWindow(window: BrowserWindow): void {
|
||
if (window.isDestroyed()) {
|
||
return
|
||
}
|
||
|
||
if (window.isMinimized()) {
|
||
window.restore()
|
||
}
|
||
if (!window.isVisible()) {
|
||
window.show()
|
||
}
|
||
|
||
window.focus()
|
||
}
|
||
|
||
function cleanupInactiveBindOperations(): void {
|
||
for (const [platformId, operation] of activeBindOperations.entries()) {
|
||
if (operation.window.isDestroyed()) {
|
||
activeBindOperations.delete(platformId)
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearActiveBindOperation(platformId: string, window: BrowserWindow): void {
|
||
const operation = activeBindOperations.get(platformId)
|
||
if (operation?.window === window) {
|
||
activeBindOperations.delete(platformId)
|
||
}
|
||
}
|
||
|
||
export async function bindPublishAccount(platformId: string): Promise<DesktopAccountInfo> {
|
||
const definition = platformBindingDefinitions[platformId]
|
||
if (!definition) {
|
||
throw new Error(`desktop_platform_not_supported:${platformId}`)
|
||
}
|
||
|
||
cleanupInactiveBindOperations()
|
||
|
||
const existingOperation = activeBindOperations.get(platformId)
|
||
if (existingOperation && !existingOperation.window.isDestroyed()) {
|
||
focusBindWindow(existingOperation.window)
|
||
return existingOperation.promise
|
||
}
|
||
|
||
if (activeBindOperations.size >= MAX_CONCURRENT_BIND_WINDOWS) {
|
||
throw new Error('desktop_account_bind_limit_reached')
|
||
}
|
||
|
||
const handle = createPendingSessionHandle(platformId)
|
||
let window: BrowserWindow
|
||
|
||
try {
|
||
window = createBoundWindow(`绑定 ${definition.label}`, definition.loginUrl, handle.session)
|
||
} catch (error) {
|
||
forgetSessionHandle(handle.accountId)
|
||
throw error instanceof Error ? error : new Error('desktop_account_bind_failed')
|
||
}
|
||
|
||
const bindPromise = new Promise<DesktopAccountInfo>((resolve, reject) => {
|
||
let finished = false
|
||
let checking = false
|
||
let detectReady = false
|
||
let detectDebounceHandle: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const finalizeOperation = () => {
|
||
clearInterval(intervalHandle)
|
||
if (detectDebounceHandle) {
|
||
clearTimeout(detectDebounceHandle)
|
||
detectDebounceHandle = null
|
||
}
|
||
clearActiveBindOperation(platformId, window)
|
||
}
|
||
|
||
const settleSuccess = (account: DesktopAccountInfo) => {
|
||
if (finished) {
|
||
return
|
||
}
|
||
finished = true
|
||
finalizeOperation()
|
||
attachSessionHandle(account.id, handle)
|
||
resolve(account)
|
||
if (!window.isDestroyed()) {
|
||
window.setTitle(`${definition.label}(${account.display_name}) 已绑定`)
|
||
setTimeout(() => {
|
||
if (!window.isDestroyed()) {
|
||
window.close()
|
||
}
|
||
}, 800)
|
||
}
|
||
}
|
||
|
||
const settleFailure = (error: Error) => {
|
||
if (finished) {
|
||
return
|
||
}
|
||
finished = true
|
||
finalizeOperation()
|
||
forgetSessionHandle(handle.accountId)
|
||
if (!window.isDestroyed()) {
|
||
window.close()
|
||
}
|
||
reject(error)
|
||
}
|
||
|
||
const syncDetectionState = () => {
|
||
if (window.isDestroyed()) {
|
||
detectReady = false
|
||
return
|
||
}
|
||
const currentURL = window.webContents.getURL()
|
||
const fallbackReady =
|
||
/^https?:\/\//i.test(currentURL) &&
|
||
!currentURL.startsWith('data:text/html') &&
|
||
!window.webContents.isLoading()
|
||
detectReady = isWindowReadyForDetection(window) || fallbackReady
|
||
}
|
||
|
||
const scheduleDetectAndBind = (delayMs = 120) => {
|
||
if (finished || window.isDestroyed()) {
|
||
return
|
||
}
|
||
if (detectDebounceHandle) {
|
||
clearTimeout(detectDebounceHandle)
|
||
}
|
||
detectDebounceHandle = setTimeout(() => {
|
||
detectDebounceHandle = null
|
||
void detectAndBind()
|
||
}, delayMs)
|
||
}
|
||
|
||
const handleNavigationReady = () => {
|
||
syncDetectionState()
|
||
scheduleDetectAndBind()
|
||
}
|
||
|
||
window.webContents.on('did-finish-load', handleNavigationReady)
|
||
window.webContents.on('did-navigate', handleNavigationReady)
|
||
window.webContents.on('did-navigate-in-page', handleNavigationReady)
|
||
window.webContents.on('page-title-updated', () => scheduleDetectAndBind(250))
|
||
window.webContents.on(
|
||
'did-fail-load',
|
||
(_event, errorCode, _errorDescription, _validatedURL, isMainFrame) => {
|
||
if (isMainFrame && errorCode !== -3) {
|
||
detectReady = false
|
||
}
|
||
},
|
||
)
|
||
|
||
const detectAndBind = async () => {
|
||
syncDetectionState()
|
||
const currentURL = window.isDestroyed() ? '' : window.webContents.getURL()
|
||
const canProbe = detectReady || /^https?:\/\//i.test(currentURL)
|
||
if (finished || checking || !canProbe || window.isDestroyed()) {
|
||
return
|
||
}
|
||
checking = true
|
||
try {
|
||
console.info('[desktop-bind] detect tick', {
|
||
platform: definition.id,
|
||
detectReady,
|
||
canProbe,
|
||
loading: window.webContents.isLoading(),
|
||
url: currentURL,
|
||
})
|
||
const detected = await definition
|
||
.detect({ session: handle.session, webContents: window.webContents })
|
||
.catch((error) => {
|
||
console.warn(`[desktop-bind] ${definition.id} detect failed`, {
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
return null
|
||
})
|
||
if (!detected) {
|
||
return
|
||
}
|
||
let normalizedDetected = sanitizeDetectedAccount(detected)
|
||
console.info('[desktop-bind] detect success', {
|
||
platform: definition.id,
|
||
displayName: normalizedDetected.displayName,
|
||
platformUid: normalizedDetected.platformUid,
|
||
url: currentURL,
|
||
})
|
||
|
||
if (definition.id === 'toutiaohao') {
|
||
const consoleReady = await verifyToutiaoConsoleAccess(window, handle.session)
|
||
console.info('[desktop-bind] toutiao console verification', {
|
||
platform: definition.id,
|
||
ready: consoleReady,
|
||
url: window.isDestroyed() ? '' : window.webContents.getURL(),
|
||
})
|
||
if (!consoleReady) {
|
||
return
|
||
}
|
||
|
||
const detectedFromWorkbench = await definition
|
||
.detect({ session: handle.session, webContents: window.webContents })
|
||
.catch(() => null)
|
||
if (detectedFromWorkbench) {
|
||
normalizedDetected = sanitizeDetectedAccount(detectedFromWorkbench)
|
||
}
|
||
}
|
||
|
||
if (definition.id === 'wangyihao') {
|
||
const consoleReady = await verifyWangyihaoBindWindowReady(
|
||
window,
|
||
handle.session,
|
||
normalizedDetected,
|
||
)
|
||
console.info('[desktop-bind] wangyihao console verification', {
|
||
platform: definition.id,
|
||
ready: consoleReady,
|
||
url: window.isDestroyed() ? '' : window.webContents.getURL(),
|
||
})
|
||
if (!consoleReady) {
|
||
return
|
||
}
|
||
|
||
const detectedFromWorkbench = await definition
|
||
.detect({ session: handle.session, webContents: window.webContents })
|
||
.catch(() => null)
|
||
if (detectedFromWorkbench) {
|
||
normalizedDetected = sanitizeDetectedAccount(detectedFromWorkbench)
|
||
}
|
||
}
|
||
|
||
normalizedDetected = await finalizeDetectedAccountForBind(
|
||
definition,
|
||
{ session: handle.session, webContents: window.webContents },
|
||
normalizedDetected,
|
||
)
|
||
|
||
void flushSessionPersistence(handle.session)
|
||
|
||
try {
|
||
const account = await upsertDesktopAccount({
|
||
platform: definition.id,
|
||
platform_uid: normalizedDetected.platformUid,
|
||
display_name: normalizedDetected.displayName,
|
||
avatar_url: normalizedDetected.avatarUrl,
|
||
account_fingerprint: normalizedDetected.platformUid,
|
||
health: 'live',
|
||
verified_at: new Date().toISOString(),
|
||
tags: [],
|
||
})
|
||
|
||
if (definition.id === 'wangyihao') {
|
||
await saveWangyihaoSessionCookies(account.id, handle.session)
|
||
}
|
||
|
||
settleSuccess(account)
|
||
} catch (error) {
|
||
console.error('[desktop-bind] upsert failed', {
|
||
platform: definition.id,
|
||
url: currentURL,
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
if (!window.isDestroyed()) {
|
||
window.setTitle(`${definition.label} 绑定保存失败,保留窗口重试`)
|
||
}
|
||
return
|
||
}
|
||
} catch (error) {
|
||
console.error('[desktop-bind] bind failed', {
|
||
platform: definition.id,
|
||
url: currentURL,
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
settleFailure(error instanceof Error ? error : new Error('desktop_account_bind_failed'))
|
||
} finally {
|
||
checking = false
|
||
}
|
||
}
|
||
|
||
const intervalHandle = setInterval(() => {
|
||
void detectAndBind()
|
||
}, 650)
|
||
|
||
window.on('closed', () => {
|
||
clearActiveBindOperation(platformId, window)
|
||
if (!finished) {
|
||
clearInterval(intervalHandle)
|
||
forgetSessionHandle(handle.accountId)
|
||
reject(new Error('desktop_account_bind_window_closed'))
|
||
}
|
||
})
|
||
|
||
syncDetectionState()
|
||
void detectAndBind()
|
||
})
|
||
|
||
activeBindOperations.set(platformId, {
|
||
platformId,
|
||
window,
|
||
promise: bindPromise,
|
||
})
|
||
focusBindWindow(window)
|
||
|
||
return await bindPromise
|
||
}
|
||
|
||
export async function openPublishAccountConsole(account: PublishAccountIdentity): Promise<void> {
|
||
const definition = platformBindingDefinitions[account.platform]
|
||
if (!definition) {
|
||
throw new Error(`desktop_platform_not_supported:${account.platform}`)
|
||
}
|
||
|
||
const handle = await findLocalPublishAccountSessionHandle(account)
|
||
if (!handle) {
|
||
throw new Error(`desktop_account_session_expired:${account.platform}`)
|
||
}
|
||
const consoleURL =
|
||
account.platform === 'doubao'
|
||
? DOUBAO_AUTH_PROBE_URL
|
||
: await resolvePublishAccountConsoleURL(definition, handle.session)
|
||
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||
const detected = await definition
|
||
.detect({
|
||
session: handle.session,
|
||
webContents: undefined as unknown as WebContents,
|
||
})
|
||
.catch(() => null)
|
||
if (!detected || !detectedAccountMatchesIdentity(detected, account)) {
|
||
throw new Error(`desktop_account_session_expired:${account.platform}`)
|
||
}
|
||
}
|
||
const window = createBoundWindow(`${definition.label} 创作台`, consoleURL, handle.session)
|
||
window.show()
|
||
window.focus()
|
||
}
|