feat(desktop): add monitor scheduler, Playwright CDP layer, and account health
Move monitor-task scheduling authority onto the client with a durable file-backed queue that survives restart, drops stale cross-day tasks, enforces per-platform serialism, and adapts global concurrency from Electron process metrics. Publish tasks keep their existing FIFO. Add a hidden Playwright CDP manager that attaches to Electron Chromium on account session partitions, lets adapters opt into `executionMode: "playwright"`, and leaves the existing hidden WebContentsView path in place for current adapters. Introduce an account-health subsystem with silent probes, projected health/auth states, and IPC invalidation events so the renderer can show accurate auth/probe status and verification timestamps. Server-side, derive and forward title/business_date/scheduler_group_key/ question_text metadata on desktop task events so the local scheduler can defer same-question fan-out before leasing.
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
normalizeAccountPlatformUid,
|
||||
sameAccountPlatformUid,
|
||||
} from "../shared/account-identity";
|
||||
import type { AccountHealthProfile, AuthProbeResult } from "./auth-types";
|
||||
|
||||
interface DetectedAccount {
|
||||
platformUid: string;
|
||||
@@ -57,7 +58,7 @@ interface PublishPlatformBindingDefinition {
|
||||
detect(context: DetectContext): Promise<DetectedAccount | null>;
|
||||
}
|
||||
|
||||
interface PublishAccountIdentity {
|
||||
export interface PublishAccountIdentity {
|
||||
id: string;
|
||||
platform: string;
|
||||
platformUid: string;
|
||||
@@ -689,6 +690,7 @@ interface GenericAIPageState {
|
||||
strongAuthSignalCount: number;
|
||||
loginSignalCount: number;
|
||||
loggedOutSignalCount: number;
|
||||
challengeSignalCount: number;
|
||||
}
|
||||
|
||||
async function readGenericAIPageState(
|
||||
@@ -721,6 +723,7 @@ async function readGenericAIPageState(
|
||||
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)) {
|
||||
@@ -850,6 +853,26 @@ async function readGenericAIPageState(
|
||||
}
|
||||
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 {
|
||||
@@ -902,6 +925,7 @@ async function readGenericAIPageState(
|
||||
}
|
||||
const loginSignals = collectLoginSignals();
|
||||
const loggedOutSignals = collectLoggedOutSignals();
|
||||
const challengeSignals = collectChallengeSignals();
|
||||
const credentialFingerprintParts = [
|
||||
...collectCredentialStorage(window.localStorage, 'local'),
|
||||
...collectCredentialStorage(window.sessionStorage, 'session'),
|
||||
@@ -924,6 +948,7 @@ async function readGenericAIPageState(
|
||||
strongAuthSignalCount: strongSignals.length,
|
||||
loginSignalCount: loginSignals.length,
|
||||
loggedOutSignalCount: loggedOutSignals.length,
|
||||
challengeSignalCount: challengeSignals.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -955,6 +980,10 @@ async function readGenericAIPageState(
|
||||
typeof typed.loggedOutSignalCount === "number" && Number.isFinite(typed.loggedOutSignalCount)
|
||||
? typed.loggedOutSignalCount
|
||||
: 0,
|
||||
challengeSignalCount:
|
||||
typeof typed.challengeSignalCount === "number" && Number.isFinite(typed.challengeSignalCount)
|
||||
? typed.challengeSignalCount
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1285,6 +1314,29 @@ function toPublishAccountProfile(detected: DetectedAccount | null): PublishAccou
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1332,6 +1384,328 @@ async function resolvePublishAccountProfileFromHandle(
|
||||
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 detectGenericAIPlatform(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> {
|
||||
|
||||
Reference in New Issue
Block a user