7abac1e9c4
- Added a new Qwen adapter to facilitate monitoring through Playwright, leveraging internal text/chat managers. - Updated account detection logic to recognize Qwen sessions based on persisted cookies, improving binding reliability. - Relaxed authentication requirements for monitor tasks, allowing anonymous execution for certain AI platforms. - Enhanced the generic AI platform detection to include Qwen-specific logic, ensuring accurate session identification. - Modified the monitoring dashboard to include runtime state indicating if the current user's desktop client is online. - Updated various files including `account-binder.ts`, `runtime-controller.ts`, and `monitoring_service.go` to support new features and improvements.
2743 lines
78 KiB
TypeScript
2743 lines
78 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import { readdirSync, statSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
|
||
import { BrowserWindow, app, nativeTheme, session as electronSession } from "electron/main";
|
||
import type { Session, WebContents } from "electron/main";
|
||
import { aiPlatformCatalog } from "@geo/shared-types";
|
||
import type { DesktopAccountInfo } from "@geo/shared-types";
|
||
|
||
import {
|
||
normalizeRemoteUrl,
|
||
pageFetchJson,
|
||
sessionCookieFetchJson,
|
||
sessionCookieHeader,
|
||
sessionCookieValue,
|
||
sessionFetchJson,
|
||
sessionFetchText,
|
||
} from "./adapters/common";
|
||
import {
|
||
attachSessionHandle,
|
||
createPendingSessionHandle,
|
||
createSessionHandle,
|
||
createSessionHandleForPartition,
|
||
forgetSessionHandle,
|
||
getPersistedPartition,
|
||
getSessionHandle,
|
||
type SessionHandle,
|
||
} from "./session-registry";
|
||
import {
|
||
attachWindowDiagnostics,
|
||
isWindowReadyForDetection,
|
||
loadWindowURLSafely,
|
||
} from "./external-window";
|
||
import { upsertDesktopAccount } from "./transport/api-client";
|
||
import { STANDARD_USER_AGENT } from "./user-agent";
|
||
import {
|
||
normalizeAccountPlatformUid,
|
||
sameAccountPlatformUid,
|
||
} from "../shared/account-identity";
|
||
import type { AccountHealthProfile, AuthProbeResult } from "./auth-types";
|
||
|
||
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;
|
||
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>;
|
||
}
|
||
|
||
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 = {
|
||
data?: {
|
||
mediaInfo?: {
|
||
userId?: string | number;
|
||
tname?: string;
|
||
icon?: string;
|
||
};
|
||
};
|
||
};
|
||
|
||
type JianshuUser = {
|
||
id?: string | number;
|
||
nickname?: string;
|
||
avatar?: string;
|
||
};
|
||
|
||
type BilibiliNavResponse = {
|
||
code?: number;
|
||
data?: {
|
||
isLogin?: boolean;
|
||
mid?: string | number;
|
||
uname?: string;
|
||
face?: string;
|
||
};
|
||
};
|
||
|
||
type JuejinProfileResponse = {
|
||
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;
|
||
};
|
||
|
||
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;
|
||
|
||
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 hashText(value: string): string {
|
||
return createHash("sha1").update(value).digest("hex").slice(0, 20);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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),
|
||
};
|
||
}
|
||
|
||
interface GenericAIPageState {
|
||
displayName: string | null;
|
||
avatarUrl: string | null;
|
||
fingerprint: string | null;
|
||
credentialFingerprint: string | null;
|
||
currentPath: string | null;
|
||
strongAuthSignalCount: number;
|
||
loginSignalCount: number;
|
||
loggedOutSignalCount: number;
|
||
challengeSignalCount: number;
|
||
}
|
||
|
||
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);
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
function isAIPlatformBinding(platformId: string): boolean {
|
||
return aiPlatformCatalog.some((item) => item.id === platformId);
|
||
}
|
||
|
||
function normalizeURLPath(pathname: string): string {
|
||
const normalized = pathname.replace(/\/+$/, "");
|
||
return normalized === "/" ? "" : normalized;
|
||
}
|
||
|
||
function safeParseURL(input: string): URL | null {
|
||
try {
|
||
return new URL(input);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function genericAIConversationPath(pathname: string): boolean {
|
||
return /^\/(?:chat|c|conversation)(?:\/|$)/i.test(pathname);
|
||
}
|
||
|
||
function isLikelyGenericAICredentialName(name: string): boolean {
|
||
const normalized = name.trim().toLowerCase();
|
||
if (/^(x[-_]?csrf[-_]?token|xsrf[-_]?token|csrf[-_]?token|csrf|_csrf)$/i.test(normalized)) {
|
||
return false;
|
||
}
|
||
return /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i.test(name);
|
||
}
|
||
|
||
function isLikelyGenericAICredentialValue(value: string | null | undefined): boolean {
|
||
const normalized = normalizeText(value);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) {
|
||
return false;
|
||
}
|
||
|
||
return normalized.length >= 8;
|
||
}
|
||
|
||
function genericAIPageLooksAuthenticated(currentURL: string, pageState: GenericAIPageState | null): boolean {
|
||
const current = safeParseURL(currentURL);
|
||
const currentPath = normalizeURLPath(current?.pathname ?? pageState?.currentPath ?? "");
|
||
const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0;
|
||
const loginSignalCount = pageState?.loginSignalCount ?? 0;
|
||
const loggedOutSignalCount = pageState?.loggedOutSignalCount ?? 0;
|
||
const onConversationPath = genericAIConversationPath(currentPath);
|
||
|
||
if (loggedOutSignalCount > 0) {
|
||
return false;
|
||
}
|
||
|
||
if (loginSignalCount > 0 && strongAuthSignalCount === 0 && !onConversationPath) {
|
||
return false;
|
||
}
|
||
|
||
if (loginSignalCount > 0) {
|
||
return strongAuthSignalCount >= 2 && !onConversationPath;
|
||
}
|
||
|
||
return strongAuthSignalCount > 0 || onConversationPath;
|
||
}
|
||
|
||
async function buildGenericAISessionFingerprint(
|
||
platformId: string,
|
||
session: Session,
|
||
pageState?: GenericAIPageState | null,
|
||
): Promise<string | null> {
|
||
const parts: string[] = [];
|
||
|
||
if (pageState?.credentialFingerprint) {
|
||
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 isLikelyGenericAICredentialName(cookie.name)
|
||
&& isLikelyGenericAICredentialValue(cookie.value);
|
||
});
|
||
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 detectGenericAIPlatform(
|
||
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 (!genericAIPageLooksAuthenticated(currentURL, 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 detectQwenFromSession(
|
||
session: Session,
|
||
hints: {
|
||
displayName?: string | null;
|
||
avatarUrl?: string | null;
|
||
} = {},
|
||
): Promise<DetectedAccount | null> {
|
||
const cookies = await session.cookies.get({ url: "https://www.qianwen.com/" }).catch(() => []);
|
||
if (!cookies.length) {
|
||
return null;
|
||
}
|
||
|
||
const cookieMap = new Map<string, string>();
|
||
for (const cookie of cookies) {
|
||
if (!cookieMap.has(cookie.name)) {
|
||
cookieMap.set(cookie.name, cookie.value);
|
||
}
|
||
}
|
||
|
||
const ticketHash = normalizeText(cookieMap.get("tongyi_sso_ticket_hash"));
|
||
const ticket = normalizeText(cookieMap.get("tongyi_sso_ticket"));
|
||
const platformUid = ticketHash ?? ticket;
|
||
if (!platformUid) {
|
||
return null;
|
||
}
|
||
|
||
return sanitizeDetectedAccount({
|
||
platformUid,
|
||
displayName: hints.displayName ?? "通义千问账号",
|
||
avatarUrl: hints.avatarUrl ?? null,
|
||
});
|
||
}
|
||
|
||
async function detectQwenPlatform(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
const genericDetected = await detectGenericAIPlatform(platformId, label, context);
|
||
if (genericDetected) {
|
||
return genericDetected;
|
||
}
|
||
|
||
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 readGenericAIPageState(context.webContents).catch(() => null);
|
||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||
return null;
|
||
}
|
||
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||
return null;
|
||
}
|
||
|
||
const fromSession = await detectQwenFromSession(context.session, {
|
||
displayName: pageState?.displayName ?? `${label} 会话`,
|
||
avatarUrl: pageState?.avatarUrl ?? null,
|
||
});
|
||
if (fromSession) {
|
||
return fromSession;
|
||
}
|
||
|
||
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 detectAIPlatformAccount(
|
||
platformId: string,
|
||
label: string,
|
||
context: DetectContext,
|
||
): Promise<DetectedAccount | null> {
|
||
if (platformId === "qwen") {
|
||
return await detectQwenPlatform(platformId, label, context);
|
||
}
|
||
|
||
return await detectGenericAIPlatform(platformId, label, context);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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) {
|
||
return existing;
|
||
}
|
||
|
||
const persisted = getPersistedPartition(account.id);
|
||
if (persisted) {
|
||
return createSessionHandle(account.id);
|
||
}
|
||
|
||
if (account.platform === "toutiaohao") {
|
||
const recovered = await recoverToutiaoSessionHandle(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} 静默识别`,
|
||
definition.consoleUrl,
|
||
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 probePublishAccountSession(
|
||
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 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",
|
||
},
|
||
};
|
||
}
|
||
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默探针`,
|
||
definition.consoleUrl,
|
||
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 (looksLikeLoginRedirect(currentURL, definition.loginUrl)) {
|
||
return {
|
||
verdict: "expired",
|
||
reason: "login_redirect",
|
||
profile: null,
|
||
sessionFingerprint: null,
|
||
evidence: {
|
||
url: currentURL,
|
||
},
|
||
};
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
if (isAIPlatformBinding(account.platform)) {
|
||
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, definition.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 || looksLikeLoginRedirect(currentURL, definition.loginUrl)) {
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
const fingerprint = await buildGenericAISessionFingerprint(account.platform, handle.session, pageState);
|
||
if (genericAIPageLooksAuthenticated(currentURL, pageState)) {
|
||
return {
|
||
verdict: "active",
|
||
reason: "probe_success",
|
||
profile: fallbackAccountHealthProfile(account, {
|
||
displayName: pageState.displayName ?? account.displayName,
|
||
avatarUrl: pageState.avatarUrl ?? null,
|
||
}),
|
||
sessionFingerprint: fingerprint,
|
||
};
|
||
}
|
||
|
||
return {
|
||
verdict: "network_error",
|
||
reason: "network_error",
|
||
profile: null,
|
||
sessionFingerprint: fingerprint,
|
||
evidence: {
|
||
url: currentURL,
|
||
strongAuthSignalCount: pageState.strongAuthSignalCount,
|
||
loginSignalCount: pageState.loginSignalCount,
|
||
loggedOutSignalCount: pageState.loggedOutSignalCount,
|
||
},
|
||
};
|
||
}
|
||
|
||
const detected = await definition.detect({
|
||
session: handle.session,
|
||
webContents: window.webContents,
|
||
});
|
||
if (detected) {
|
||
return {
|
||
verdict: "active",
|
||
reason: "probe_success",
|
||
profile: toAccountHealthProfile(toPublishAccountProfile(detected)),
|
||
sessionFingerprint: sanitizeDetectedAccount(detected).platformUid,
|
||
};
|
||
}
|
||
|
||
const consoleReady = 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 silentRefreshPublishAccountSession(
|
||
account: PublishAccountIdentity,
|
||
partition?: string,
|
||
): Promise<boolean> {
|
||
const handle = partition
|
||
? createSessionHandleForPartition(account.id, partition)
|
||
: await findLocalPublishAccountSessionHandle(account);
|
||
|
||
if (!handle) {
|
||
return false;
|
||
}
|
||
|
||
const definition = platformBindingDefinitions[account.platform];
|
||
if (!definition) {
|
||
return false;
|
||
}
|
||
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默续期`,
|
||
definition.consoleUrl,
|
||
handle.session,
|
||
{ show: false },
|
||
);
|
||
|
||
try {
|
||
await waitForWindowNavigationSettled(window, 10_000);
|
||
if (window.isDestroyed()) {
|
||
return false;
|
||
}
|
||
|
||
await sleep(1_200);
|
||
await flushSessionPersistence(handle.session);
|
||
return !looksLikeLoginRedirect(window.webContents.getURL(), definition.loginUrl);
|
||
} 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> {
|
||
if (detected.avatarUrl) {
|
||
return detected;
|
||
}
|
||
|
||
let resolved = detected;
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
await sleep(400 + attempt * 250);
|
||
const retried = await definition.detect(context).catch(() => null);
|
||
if (!retried) {
|
||
continue;
|
||
}
|
||
|
||
const sanitized = sanitizeDetectedAccount(retried);
|
||
resolved = sanitized;
|
||
if (sanitized.avatarUrl) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return resolved;
|
||
}
|
||
|
||
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 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|signin|sign-in|signup|sign-up|register|auth|oauth|passport|scan|qrcode|qrlogin|wxlogin|sso)(?=$|[/?#=&._-])/i
|
||
.test(`${current.pathname}${current.search}${current.hash}`);
|
||
}
|
||
|
||
return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function verifyPublishAccountConsoleAccess(
|
||
account: PublishAccountIdentity,
|
||
handle: SessionHandle,
|
||
): Promise<boolean> {
|
||
const definition = platformBindingDefinitions[account.platform];
|
||
if (!definition) {
|
||
return false;
|
||
}
|
||
|
||
const window = createBoundWindow(
|
||
`${definition.label} 静默校验`,
|
||
definition.consoleUrl,
|
||
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;
|
||
}
|
||
|
||
return !looksLikeLoginRedirect(currentURL, definition.loginUrl);
|
||
} 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> {
|
||
const response = await sessionFetchJson<WangyiInfoResponse>(
|
||
session,
|
||
"https://mp.163.com/wemedia/info.do",
|
||
).catch(() => null);
|
||
|
||
const media = response?.data?.mediaInfo;
|
||
const uid = stringId(media?.userId);
|
||
if (!uid || !media?.tname) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: media.tname,
|
||
avatarUrl: normalizeRemoteUrl(media.icon),
|
||
};
|
||
}
|
||
|
||
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 detectBilibili({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const cookie = await sessionCookieHeader(session, "bilibili.com");
|
||
const response = await sessionFetchJson<BilibiliNavResponse>(
|
||
session,
|
||
"https://api.bilibili.com/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;
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: user.uname,
|
||
avatarUrl: normalizeRemoteUrl(user.face),
|
||
};
|
||
}
|
||
|
||
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);
|
||
|
||
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),
|
||
};
|
||
}
|
||
|
||
async function detectSmzdm({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<SmzdmCurrentUser>(
|
||
session,
|
||
"https://zhiyou.smzdm.com/user/info/jsonp_get_current",
|
||
).catch(() => null);
|
||
|
||
const uid = stringId(response?.smzdm_id);
|
||
const nickname = response?.nickname || response?.audit_nickname || "";
|
||
if (!uid || !nickname) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
platformUid: uid,
|
||
displayName: nickname,
|
||
avatarUrl: normalizeRemoteUrl(response?.avatar),
|
||
};
|
||
}
|
||
|
||
async function detectWeixinGzh({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const html = await sessionFetchText(session, "https://mp.weixin.qq.com/").catch(() => "");
|
||
if (!html) {
|
||
return null;
|
||
}
|
||
|
||
const token = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/)?.[1] ?? "";
|
||
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 detectZol({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||
const response = await sessionFetchJson<ZolUserInfoResponse>(
|
||
session,
|
||
"https://open-api.zol.com.cn/api/v1/creator.user.getinfo",
|
||
{
|
||
headers: {
|
||
accept: "application/json, text/plain, */*",
|
||
},
|
||
},
|
||
).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/news/create",
|
||
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: "https://mp.163.com/index.html#/article/create",
|
||
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: "https://mp.weixin.qq.com/cgi-bin/loginpage",
|
||
consoleUrl: "https://mp.weixin.qq.com/cgi-bin/home",
|
||
detect: detectWeixinGzh,
|
||
},
|
||
zol: {
|
||
id: "zol",
|
||
label: "中关村在线",
|
||
loginUrl: "https://post.zol.com.cn/v2/manage/works/all",
|
||
consoleUrl: "https://post.zol.com.cn/v2/manage/works/all",
|
||
detect: detectZol,
|
||
},
|
||
dongchedi: {
|
||
id: "dongchedi",
|
||
label: "懂车帝",
|
||
loginUrl: "https://mp.dcdapp.com/login",
|
||
consoleUrl: "https://mp.dcdapp.com/content/article/create",
|
||
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 window = new BrowserWindow({
|
||
show: options.show ?? true,
|
||
width: 1320,
|
||
height: 900,
|
||
minWidth: 1100,
|
||
minHeight: 760,
|
||
title,
|
||
autoHideMenuBar: true,
|
||
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;
|
||
|
||
const finalizeOperation = () => {
|
||
clearInterval(intervalHandle);
|
||
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;
|
||
};
|
||
|
||
window.webContents.on("did-finish-load", syncDetectionState);
|
||
window.webContents.on("did-navigate", syncDetectionState);
|
||
window.webContents.on("did-navigate-in-page", syncDetectionState);
|
||
window.webContents.on(
|
||
"did-fail-load",
|
||
(_event, errorCode, _errorDescription, _validatedURL, isMainFrame) => {
|
||
if (isMainFrame && errorCode !== -3) {
|
||
detectReady = false;
|
||
}
|
||
},
|
||
);
|
||
|
||
const detectAndBind = async () => {
|
||
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);
|
||
}
|
||
}
|
||
|
||
normalizedDetected = await finalizeDetectedAccountForBind(
|
||
definition,
|
||
{ session: handle.session, webContents: window.webContents },
|
||
normalizedDetected,
|
||
);
|
||
|
||
await 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: [],
|
||
});
|
||
|
||
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();
|
||
}, 1800);
|
||
|
||
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 ensurePublishAccountSessionHandle(account);
|
||
const window = createBoundWindow(`${definition.label} 创作台`, definition.consoleUrl, handle.session);
|
||
window.show();
|
||
window.focus();
|
||
}
|