From b89892be6e7fbbd6f251317020ca4461812e381f Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 30 Apr 2026 19:18:03 +0800 Subject: [PATCH] feat(desktop-client/wangyihao): persist session cookies and recover bound partitions Snapshot 163.com/126.net cookies on bind/refresh to a userData vault and restore them when reattaching a session, recover orphan partitions by matching the detected mediaInfo, verify the bind window via the console state instead of a generic profile probe, and route token/draft fetches through the https API origin while opening console pages over http to match the live editor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop-client/src/main/account-binder.ts | 525 +++++++++++++++++- .../src/main/adapters/wangyihao.ts | 45 +- 2 files changed, 530 insertions(+), 40 deletions(-) diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 44a81a7..3f63711 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { BrowserWindow, app, nativeTheme, session as electronSession } from "electron/main"; -import type { Session, WebContents } from "electron/main"; +import type { Cookie, Session, WebContents } from "electron/main"; import { aiPlatformCatalog } from "@geo/shared-types"; import type { DesktopAccountInfo } from "@geo/shared-types"; @@ -160,14 +160,31 @@ type ZhihuMeResponse = { }; 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; type JianshuUser = { id?: string | number; @@ -259,6 +276,23 @@ 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 = "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`, + "https://reg.163.com/", + "https://dl.reg.163.com/", +]; +const WANGYI_COOKIE_URLS = [ + WANGYI_API_ORIGIN, + WANGYI_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, @@ -1586,6 +1620,281 @@ async function hasToutiaoAuthCookies(session: Session): Promise { 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 { + const headers = { + accept: "application/json, text/plain, */*", + referer: WANGYI_API_REFERER, + origin: WANGYI_API_ORIGIN, + }; + + if (options.allowCookieWrite) { + return await sessionFetchJson( + session, + WANGYI_INFO_URL, + { + credentials: "include", + headers, + }, + ).catch(() => null); + } + + return await sessionCookieFetchJson( + session, + WANGYI_INFO_URL, + { headers }, + ).catch(() => null); +} + +async function detectWangyihaoFromSession(session: Session): Promise { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const pendingPartitions = listPendingPartitions("toutiaohao"); if (!pendingPartitions.length) { @@ -1651,12 +1960,19 @@ async function findLocalPublishAccountSessionHandle( ): Promise { 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) { - return createSessionHandle(account.id); + const handle = createSessionHandle(account.id); + if (account.platform === "wangyihao") { + await restoreWangyihaoSessionCookies(account.id, handle.session); + } + return handle; } if (account.platform === "toutiaohao") { @@ -1666,6 +1982,13 @@ async function findLocalPublishAccountSessionHandle( } } + if (account.platform === "wangyihao") { + const recovered = await recoverWangyihaoSessionHandle(account); + if (recovered) { + return recovered; + } + } + return null; } @@ -1783,6 +2106,10 @@ export async function probePublishAccountSession( }; } + if (account.platform === "wangyihao") { + return await probeWangyihaoSession(account, handle.session); + } + const window = createBoundWindow( `${definition.label} 静默探针`, await resolvePublishAccountConsoleURL(definition, handle.session), @@ -1849,7 +2176,9 @@ export async function probePublishAccountSession( }; } - if (definitionLooksLikeLoginRedirect(currentURL, definition)) { + const isLoginRedirect = definitionLooksLikeLoginRedirect(currentURL, definition); + + if (isLoginRedirect) { return { verdict: "expired", reason: "login_redirect", @@ -2136,6 +2465,15 @@ export async function silentRefreshPublishAccountSession( 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), @@ -2199,6 +2537,13 @@ export async function inspectPublishAccountLocalSession( const profile = await resolvePublishAccountProfileFromHandle(account, handle); if (!profile) { + if (account.platform === "wangyihao") { + return { + availability: "expired", + profile: null, + }; + } + const consoleReady = await verifyPublishAccountConsoleAccess(account, handle).catch(() => false); if (consoleReady) { return { @@ -2344,6 +2689,121 @@ async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Sessio 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 { + 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 { + 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, @@ -2360,7 +2820,7 @@ function platformRequiresDetectedAccountForConsoleAccess(platformId: string): bo } function platformRequiresDetectedAccountIdentityMatch(platformId: string): boolean { - return platformId === "qiehao" || platformId === "zol"; + return platformId === "qiehao" || platformId === "wangyihao" || platformId === "zol"; } async function verifyZolConsoleAccess( @@ -2381,7 +2841,7 @@ async function verifyZolConsoleAccess( 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)(?=$|[.-])/i; +const LOGIN_REDIRECT_HOST_RE = /(^|[.-])(login|passport|auth|sso|reg)(?=$|[.-])/i; function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { if (!currentURL || !loginURL) { @@ -2468,6 +2928,10 @@ async function verifyPublishAccountConsoleAccess( 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); } @@ -2582,22 +3046,7 @@ async function detectZhihu({ session }: DetectContext): Promise { - const response = await sessionFetchJson( - 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), - }; + return await detectWangyihaoFromSession(session); } async function detectJianshu({ session }: DetectContext): Promise { @@ -3094,7 +3543,8 @@ const publishBindingDefinitions: Record null); + if (detectedFromWorkbench) { + normalizedDetected = sanitizeDetectedAccount(detectedFromWorkbench); + } + } + normalizedDetected = await finalizeDetectedAccountForBind( definition, { session: handle.session, webContents: window.webContents }, @@ -3434,6 +3907,10 @@ export async function bindPublishAccount(platformId: string): Promise "")); return { accept: "application/json, text/plain, */*", - origin: WANGYI_ORIGIN, - referer: WANGYI_HOME_URL, + origin: WANGYI_API_ORIGIN, + referer: WANGYI_API_REFERER, ...(cookie ? { cookie } : {}), ...extra, }; @@ -165,7 +167,7 @@ async function wangyiHeaders( async function fetchAccount(context: PublishAdapterContext): Promise { const response = await sessionFetchJson( context.session, - `${WANGYI_ORIGIN}/wemedia/info.do`, + `${WANGYI_API_ORIGIN}/wemedia/info.do`, { credentials: "include", headers: await wangyiHeaders(context), @@ -191,7 +193,7 @@ async function fetchPublishDetail( newProminentFlag: "", }; - const url = new URL(`${WANGYI_ORIGIN}/wemedia/article/postpage.do`); + const url = new URL(`${WANGYI_API_ORIGIN}/wemedia/article/postpage.do`); url.searchParams.set("_", String(Date.now())); url.searchParams.set("wemediaId", account.wemediaId); url.searchParams.set("mediaId", account.wemediaId); @@ -224,13 +226,24 @@ async function getPublishToken(context: PublishAdapterContext): Promise throw new Error("wangyihao_playwright_missing"); } - await page.goto(WANGYI_HOME_URL, { - waitUntil: "domcontentloaded", - }); + for (let attempt = 0; attempt < 2; attempt += 1) { + await page.goto(WANGYI_HOME_URL, { + waitUntil: attempt === 0 ? "domcontentloaded" : "load", + timeout: 20_000, + }).catch(() => null); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => null); + if (attempt > 0) { + await sleep(800, context.signal); + } - // 网易号的 neg 有时是顶层绑定,不一定挂在 window 上;这里保持和旧版插件一致的读取方式。 - const token = await readPublishTokenFromPage(page); - return token.trim(); + // 网易号的 neg 有时是顶层绑定,不一定挂在 window 上;这里保持和旧版插件一致的读取方式。 + const token = (await readPublishTokenFromPage(page)).trim(); + if (token) { + return token; + } + } + + return ""; } async function readPublishTokenFromPage(page: Page): Promise { @@ -415,7 +428,7 @@ async function uploadImage( form.append("from", "neteasecode_mp"); form.append("logotext", account.name); - const url = new URL(`${WANGYI_ORIGIN}/api/v3/upload/picupload`); + const url = new URL(`${WANGYI_API_ORIGIN}/api/v3/upload/picupload`); url.searchParams.set("_", String(Date.now())); url.searchParams.set("wemediaId", account.wemediaId); url.searchParams.set("realUserId", account.realUserId); @@ -444,7 +457,7 @@ async function saveDraft( html: string, token: string, ): Promise { - const url = new URL(`${WANGYI_ORIGIN}/wemedia/article/status/api/publishV2.do`); + const url = new URL(`${WANGYI_API_ORIGIN}/wemedia/article/status/api/publishV2.do`); url.searchParams.set("_", String(Date.now())); const body = buildDraftBody(context, account, detail, html, token); @@ -772,7 +785,7 @@ async function publishDraft(context: PublishAdapterContext, draftId: string): Pr throw new Error("wangyihao_playwright_missing"); } - const draftUrl = `https://mp.163.com/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`; + const draftUrl = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`; await page.goto(draftUrl, { waitUntil: "domcontentloaded", });