Files
OmniRoute/open-sse/services/volcengineConsoleAutoLogin.ts
deploy 8f15b79a84 feat(volcengine): phone/SMS auto-login for console with MFA + identity selection
- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
2026-08-24 12:22:00 -03:00

980 lines
37 KiB
TypeScript

/**
* VolcengineConsoleAutoLogin — session-based phone/SMS-code login for the
* Volcano Engine console.
*
* Unlike InAppLoginService (which opens a headful browser and requires the
* operator to complete login inside a browser on the server machine), this
* service drives a headless Chromium through the console's 手机号登录 (phone +
* SMS verification code) flow:
*
* 1. startLogin(phone) — navigate to the login page, switch to the phone
* tab, fill the phone number, click 获取验证码. If the console demands an
* image captcha, a screenshot is captured for the dashboard to render.
* 2. submitCode(code, captcha?) — fill the SMS code (and image captcha when
* requested), click 登录 / 注册, then poll the browser context for the
* console session cookies (digest / AccountID / csrfToken / userInfo).
* 3. cancel() / resendCode() — lifecycle helpers.
*
* The service only extracts credentials; persisting/binding them to provider
* connections stays in the dashboard API layer (volcenginePlanBinding.ts).
*
* Selector strategy: the console login page is built with Arco Design and
* exposes stable element ids (#Tel_input, #Code_input, #VerificatonCodeInput).
* Every interaction goes through multi-candidate selector lists so a single
* frontend rename does not break the flow. When a candidate list misses or
* risk-control (slider) is detected, the session degrades to
* `fallback_manual` and the caller can fall back to the pre-existing
* headful-browser flow.
*/
import { randomUUID } from "crypto";
// ─── Public types ───────────────────────────────────────────────────────────
export type VolcLoginPhase =
| "starting"
| "sending_code"
| "waiting_code"
| "captcha_required"
| "submitting"
| "mfa_waiting"
| "identity_required"
| "success"
| "error"
| "timeout"
| "cancelled"
| "fallback_manual";
export interface VolcLoginSessionView {
sessionId: string;
phase: VolcLoginPhase;
phoneMasked: string;
error: string | null;
/** data:image/png;base64 screenshot of the image captcha, when required */
captchaImage: string | null;
/** epoch ms — earliest time a resend should be offered */
resendAvailableAt: number;
createdAt: number;
updatedAt: number;
/** True while the console demands an MFA step-up code (second SMS code) */
mfaRequired?: boolean;
/** Identity options scraped from /auth/login/select_identity, when required */
identityOptions?: Array<{ index: number; label: string }>;
/** Credentials (console cookies) — only present after success */
credentials?: Record<string, string>;
/** Set by the API layer after binding plans (not part of this service) */
binding?: unknown;
}
export interface StartOptions {
/** Total session timeout in ms (default 300_000) */
timeout?: number;
}
export interface SubmitCodeOptions {
/** Extra wait for cookie polling after submit (default 90_000) */
timeout?: number;
}
/** Injectable delays — tests shrink these to keep the suite fast. */
export interface ServiceDelays {
pageSettleMs?: number;
tabSwitchMs?: number;
sendCodeSettleMs?: number;
pollIntervalMs?: number;
resendCooldownMs?: number;
}
// ─── Config ─────────────────────────────────────────────────────────────────
const LOGIN_URL = "https://console.volcengine.com/auth/login";
/** Landing page the manual headful flow uses — the console app issues the
* remaining session cookies (AccountID/userInfo) once it runs. */
const ARK_CONSOLE_URL =
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
const DEFAULT_SESSION_TIMEOUT = 300_000;
const SUBMIT_COOKIE_TIMEOUT = 90_000;
const CAPTURE_POLL_INTERVAL = 1_000;
const RESEND_COOLDOWN_MS = 60_000;
const MAX_ACTIVE_SESSIONS = 2;
/** Multi-candidate selectors — first visible candidate wins. */
const SELECTORS = {
phoneTab: ['.arco-tabs-header-title:has-text("手机号登录")', "text=手机号登录"],
phoneInput: ["#Tel_input", 'input[name="Tel"]', 'input[placeholder*="手机号"]'],
smsCodeInput: ["#Code_input", 'input[placeholder*="请输入验证码"]'],
sendCodeButton: ['button:has-text("获取验证码")', "text=获取验证码"],
loginButton: ['button:has-text("登录 / 注册")', 'button:has-text("登录")'],
imageCaptchaInput: ["#VerificatonCodeInput", "input.verify-input"],
captchaShot: [".arco-modal", '[class*="captcha"]', '[class*="verify"]'],
/** Risk-control slider / popup heuristics */
riskControl: [
'[class*="secsdk-captcha"]',
"#captcha_popup",
'[class*="captcha-slider"]',
'[class*="drag"] [class*="slider"]',
],
/** MFA step-up modal (需要额外认证): a SECOND 6-digit SMS code is required */
mfaModal: ['.arco-modal:has-text("需要额外认证")', "text=需要额外认证"],
mfaInput: ["#VerificatonCodeInput", ".arco-modal input.verify-input", ".arco-modal input"],
mfaConfirmButton: ['button:has-text("好的")', '.arco-modal button:has-text("确定")'],
mfaResendButton: ['button:has-text("重发校验码")'],
/** TOTP binding modal (绑定MFA设备) — needs interactive Google Authenticator setup */
mfaBindModal: ['.arco-modal:has-text("绑定MFA设备")'],
/** Identity selection page (/auth/login/select_identity) — the phone maps to
* multiple accounts; the user must pick which identity to log in as.
* Structure verified against the real auth bundle (vconsole-auth 1.0.0.2837,
* module 12173 + chunk 202): ul[class*=accountUl] > li[class*=accountLi] >
* div[class*=item] (click target) with the identity text in [class*=identity];
* submit is button[type=submit] ("登录") inside [class*=selectPlatformIdentity].
* .arco-list-item is kept as a fallback for future Arco-based redesigns. */
identityList: ['ul[class*="accountUl"] li[class*="accountLi"]', ".arco-list-item"],
identityItem: ['li[class*="accountLi"] > [class*="item"]', ".arco-list-item"],
identitySubmitButton: [
'[class*="selectPlatformIdentity"] button[type="submit"]',
'button[type="submit"]:has-text("登录")',
'button:has-text("登录")',
],
} as const;
/** URL marker for the console's identity-selection page */
const IDENTITY_URL_PATTERN = /\/auth\/login\/select_identity/i;
const BROWSER_CONTEXT_OPTIONS = {
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
viewport: { width: 1280, height: 800 },
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
};
// ─── Minimal playwright structural types ──────────────────────────────────
// Playwright is an optional runtime dep (dynamically imported), so we model
// only the API surface this service drives instead of importing its types.
interface PwLocator {
first(): PwLocator;
isVisible(options?: { timeout?: number }): Promise<boolean>;
click(options?: unknown): Promise<void>;
fill(value: string): Promise<void>;
isDisabled(): Promise<boolean>;
screenshot(options?: { type?: string }): Promise<Buffer>;
textContent(options?: { timeout?: number }): Promise<string | null>;
count(): Promise<number>;
nth(index: number): PwLocator;
}
interface PwPage {
setDefaultTimeout(timeout: number): void;
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>;
locator(selector: string): PwLocator;
screenshot(options?: { type?: string }): Promise<Buffer>;
url(): string;
content(): Promise<string>;
}
interface PwContext {
newPage(): Promise<PwPage>;
cookies(): Promise<Array<{ name: string; domain: string; value: string }>>;
}
interface PwBrowser {
newContext(options?: Record<string, unknown>): Promise<PwContext>;
close(): Promise<void>;
}
interface PwModule {
chromium: {
launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise<PwBrowser>;
};
}
// ─── Session record (internal) ──────────────────────────────────────────────
interface ActiveSession {
sessionId: string;
phone: string;
phase: VolcLoginPhase;
error: string | null;
captchaImage: string | null;
resendAvailableAt: number;
createdAt: number;
updatedAt: number;
timeoutMs: number;
credentials: Record<string, string> | null;
/** Binding outcome set by the API layer via withBinding() */
binding?: unknown;
cancelled: boolean;
/** Identity options scraped from the select_identity page */
identityOptions: Array<{ index: number; label: string }> | null;
// Playwright handles — never serialized
browser: PwBrowser | null;
context: PwContext | null;
page: PwPage | null;
}
export function maskPhone(phone: string): string {
if (phone.length < 7) return "***";
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
/** Normalize a CN mobile number: strip +86/86 prefix, spaces, dashes. */
export function normalizePhone(raw: string): string | null {
const trimmed = String(raw || "")
.trim()
.replace(/[\s-]/g, "");
const bare = trimmed.replace(/^\+?86/, "");
return /^1\d{10}$/.test(bare) ? bare : null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Service ────────────────────────────────────────────────────────────────
export class VolcengineConsoleAutoLoginService {
private sessions = new Map<string, ActiveSession>();
/** sessionId → bind promise set by the API layer to dedupe lazy binding */
private bindInFlight = new Map<string, Promise<unknown>>();
/** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */
private readonly loadPlaywright: () => Promise<PwModule>;
private readonly delays: Required<ServiceDelays>;
constructor(
loadPlaywright: () => Promise<PwModule> = async () => import("playwright"),
delays: ServiceDelays = {}
) {
this.loadPlaywright = loadPlaywright;
this.delays = {
pageSettleMs: delays.pageSettleMs ?? 2_500,
tabSwitchMs: delays.tabSwitchMs ?? 1_000,
sendCodeSettleMs: delays.sendCodeSettleMs ?? 2_000,
pollIntervalMs: delays.pollIntervalMs ?? CAPTURE_POLL_INTERVAL,
resendCooldownMs: delays.resendCooldownMs ?? RESEND_COOLDOWN_MS,
};
}
// ─── Queries ─────────────────────────────────────────────────────────────
getActiveSessionCount(): number {
let count = 0;
for (const session of this.sessions.values()) {
if (!isTerminal(session.phase)) count++;
}
return count;
}
getStatus(sessionId: string): VolcLoginSessionView | null {
const session = this.sessions.get(sessionId);
if (!session) return null;
return this.toView(session);
}
/**
* Lazy binding hook used by the API layer: the route stores a promise here
* so concurrent status polls do not double-bind the same credentials.
*/
async withBinding<T>(
sessionId: string,
bind: (credentials: Record<string, string>) => Promise<T>
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.phase !== "success" || !session.credentials) {
return this.toView(session);
}
if (session.binding !== undefined) return this.toView(session);
let inFlight = this.bindInFlight.get(sessionId);
if (!inFlight) {
inFlight = bind(session.credentials)
.then((binding: unknown) => {
session.binding = binding;
return binding;
})
.catch((error: unknown) => {
// Persist the failure so status polls do not retry forever.
session.binding = { error: errorMessage(error) };
return session.binding;
})
.finally(() => {
this.bindInFlight.delete(sessionId);
});
this.bindInFlight.set(sessionId, inFlight);
}
await inFlight;
return this.toView(session);
}
// ─── Lifecycle ───────────────────────────────────────────────────────────
async startLogin(
phone: string,
options?: StartOptions
): Promise<{ ok: true; session: VolcLoginSessionView } | { ok: false; error: string }> {
const normalized = normalizePhone(phone);
if (!normalized) {
return { ok: false, error: "Invalid phone number (expected an 11-digit CN mobile number)" };
}
this.expireSessions();
for (const session of this.sessions.values()) {
if (session.phone === normalized && !isTerminal(session.phase)) {
await this.cancel(session.sessionId);
}
}
if (this.getActiveSessionCount() >= MAX_ACTIVE_SESSIONS) {
return { ok: false, error: "Too many concurrent Volcano login sessions" };
}
let playwright: PwModule;
try {
playwright = await this.loadPlaywright();
} catch {
return {
ok: false,
error: "Playwright is not installed. Use manual browser login instead.",
};
}
const session: ActiveSession = {
sessionId: randomUUID(),
phone: normalized,
phase: "starting",
error: null,
captchaImage: null,
resendAvailableAt: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
timeoutMs: options?.timeout || DEFAULT_SESSION_TIMEOUT,
credentials: null,
cancelled: false,
identityOptions: null,
browser: null,
context: null,
page: null,
};
this.sessions.set(session.sessionId, session);
try {
// Prefer the playwright-managed Chromium; fall back to the system Chrome
// channel on machines without `npx playwright install` browsers (dev laptops).
try {
session.browser = await playwright.chromium.launch({
headless: true,
args: ["--disable-blink-features=AutomationControlled"],
});
} catch (launchError) {
if (!/Executable doesn't exist/.test(String(launchError))) throw launchError;
session.browser = await playwright.chromium.launch({
headless: true,
channel: "chrome",
args: ["--disable-blink-features=AutomationControlled"],
});
}
session.context = await session.browser.newContext(BROWSER_CONTEXT_OPTIONS);
session.page = await session.context.newPage();
session.page.setDefaultTimeout(15_000);
await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await sleep(this.delays.pageSettleMs);
// Switch to the phone-code login tab
const tab = await this.firstVisible(session.page, SELECTORS.phoneTab);
if (!tab) throw new SelectorMissError("phone tab");
await tab.click();
await sleep(this.delays.tabSwitchMs);
// Fill the phone number
const phoneInput = await this.firstVisible(session.page, SELECTORS.phoneInput);
if (!phoneInput) throw new SelectorMissError("phone input");
await phoneInput.fill(normalized);
// Send the SMS code
const sendBtn = await this.firstVisible(session.page, SELECTORS.sendCodeButton);
if (!sendBtn) throw new SelectorMissError("send-code button");
await sendBtn.click();
session.phase = "sending_code";
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
await sleep(this.delays.sendCodeSettleMs);
// Risk-control slider → degrade to the manual headful flow
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
if (risk) {
session.captchaImage = await this.shot(session.page);
session.phase = "fallback_manual";
session.error =
"Volcano risk control (slider captcha) was triggered in headless mode. Use manual browser login.";
await this.closeBrowser(session);
return { ok: true, session: this.toView(session) };
}
// Image captcha may be required before the SMS is sent
const captchaInput = await this.firstVisible(session.page, SELECTORS.imageCaptchaInput);
if (captchaInput) {
session.captchaImage = await this.shot(session.page);
session.phase = "captcha_required";
} else {
session.phase = "waiting_code";
}
return { ok: true, session: this.toView(session) };
} catch (error) {
await this.closeBrowser(session);
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
if (session.phase === "fallback_manual") {
session.error = `${session.error}. The login page layout may have changed — use manual browser login.`;
}
return { ok: true, session: this.toView(session) };
}
}
async submitCode(
sessionId: string,
code: string,
captcha?: string,
options?: SubmitCodeOptions
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
const fromMfa = session.phase === "mfa_waiting";
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
return this.toView(session);
}
const smsCode = String(code || "").trim();
if (!/^\d{4,6}$/.test(smsCode)) {
session.error = "Invalid SMS code";
return this.toView(session);
}
if (session.phase === "captcha_required" && !String(captcha || "").trim()) {
session.error = "Image captcha is required";
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
if (fromMfa) {
// MFA step-up (需要额外认证): fill the SECOND code into the modal
// input and confirm with 好的.
const mfaInput = await this.firstVisible(page, SELECTORS.mfaInput);
if (!mfaInput) throw new SelectorMissError("mfa code input");
await mfaInput.fill(smsCode);
const confirmBtn = await this.firstVisible(page, SELECTORS.mfaConfirmButton);
if (!confirmBtn) throw new SelectorMissError("mfa confirm button");
await confirmBtn.click();
} else {
const codeInput = await this.firstVisible(page, SELECTORS.smsCodeInput);
if (!codeInput) throw new SelectorMissError("sms code input");
await codeInput.fill(smsCode);
if (captcha) {
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
if (captchaInput) await captchaInput.fill(String(captcha).trim());
}
const loginBtn = await this.firstVisible(page, SELECTORS.loginButton);
if (!loginBtn) throw new SelectorMissError("login button");
await loginBtn.click();
}
session.phase = "submitting";
session.error = null;
session.captchaImage = null;
return await this.pollUntilResolved(session, {
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
fromMfa,
detectIdentity: true,
});
} catch (error) {
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
/**
* Pick an identity on the console's /auth/login/select_identity page and
* finish the login. `index` maps to the identityOptions list previously
* returned in the session view.
*/
async selectIdentity(
sessionId: string,
index: number,
options?: SubmitCodeOptions
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.phase !== "identity_required") {
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
// Click the requested identity card (the page pre-selects the first one,
// so only non-zero indexes need an explicit click).
if (index > 0) {
const itemSelector = await this.identityItemSelector(page);
if (!itemSelector) throw new SelectorMissError("identity item");
const items = page.locator(itemSelector);
const count = await items.count();
if (index < 0 || index >= count) {
session.error = `Identity index ${index} is out of range (${count} options)`;
return this.toView(session);
}
await items.nth(index).click();
await sleep(this.delays.tabSwitchMs);
}
// Submit the selection (button[type=submit] “登录” on the identity card)
const submitBtn = await this.firstVisible(page, SELECTORS.identitySubmitButton);
if (!submitBtn) throw new SelectorMissError("identity submit button");
await submitBtn.click();
session.phase = "submitting";
session.error = null;
session.identityOptions = null;
return await this.pollUntilResolved(session, {
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
fromMfa: false,
detectIdentity: false,
});
} catch (error) {
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
/** First clickable identity-item selector that matches at least one element. */
private async identityItemSelector(page: PwPage): Promise<string | null> {
for (const selector of SELECTORS.identityItem) {
try {
const count = await page.locator(selector).count();
if (count > 0) return selector;
} catch {
// try next candidate
}
}
return null;
}
/**
* Shared post-submit loop: waits for console cookies, watching for MFA
* step-up, identity selection, TOTP binding, and console error toasts.
*/
private async pollUntilResolved(
session: ActiveSession,
opts: { timeoutMs: number; fromMfa: boolean; detectIdentity: boolean }
): Promise<VolcLoginSessionView> {
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
const deadline = Date.now() + opts.timeoutMs;
let pollCount = 0;
let navigatedAfterLogin = false;
while (Date.now() < deadline) {
if (session.cancelled) {
session.phase = "cancelled";
await this.closeBrowser(session);
return this.toView(session);
}
if (Date.now() - session.createdAt > session.timeoutMs) {
session.phase = "timeout";
session.error = "Login timed out";
await this.closeBrowser(session);
return this.toView(session);
}
const cookies = await session.context.cookies();
const credentials: Record<string, string> = {};
for (const cookie of cookies as Array<{ name: string; domain: string; value: string }>) {
if (
REQUIRED_COOKIES.includes(cookie.name as (typeof REQUIRED_COOKIES)[number]) &&
cookie.domain.includes("volcengine.com")
) {
credentials[cookie.name] = cookie.value;
}
}
if (REQUIRED_COOKIES.every((name) => credentials[name])) {
session.credentials = credentials;
session.phase = "success";
await this.closeBrowser(session);
return this.toView(session);
}
// TOTP binding modal (绑定MFA设备) — needs interactive Google
// Authenticator setup that cannot be driven headlessly.
const bindModal = await this.firstVisible(page, SELECTORS.mfaBindModal);
if (bindModal) {
session.phase = "fallback_manual";
session.error =
"The console requires binding an MFA device (Google Authenticator). Use manual browser login to complete the one-time setup.";
await this.closeBrowser(session);
return this.toView(session);
}
// MFA step-up modal (需要额外认证) — a second SMS code is required;
// hand control back to the user instead of timing out.
if (!opts.fromMfa) {
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
if (mfaModal) {
session.phase = "mfa_waiting";
session.error = null;
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
return this.toView(session);
}
} else if (pollCount >= 5) {
// Wrong MFA code → the modal stays up; after a grace window hand
// control back so the user can enter the latest code.
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
if (mfaModal) {
session.phase = "mfa_waiting";
session.error = "The MFA code was not accepted — enter the latest code";
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
return this.toView(session);
}
}
// Identity selection page (/auth/login/select_identity) — the phone
// maps to multiple accounts; scrape the options and let the user pick.
if (opts.detectIdentity && IDENTITY_URL_PATTERN.test(page.url())) {
const options = await this.scrapeIdentityOptions(page);
if (options.length > 0) {
session.phase = "identity_required";
session.error = null;
session.identityOptions = options;
return this.toView(session);
}
}
// Login redirected away from /auth/login but cookies are incomplete →
// the console app may need to run once to issue AccountID/userInfo.
// Give it the same landing page the manual flow uses.
if (!navigatedAfterLogin && pollCount >= 2 && !page.url().includes("/auth/login")) {
navigatedAfterLogin = true;
try {
await page.goto(ARK_CONSOLE_URL, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
} catch {
// navigation is best-effort; keep polling cookies
}
}
// Console error toast (e.g. wrong SMS code) → surface it early
const toast = await page
.locator('.arco-message-error, [class*="message-error"]')
.first()
.textContent({ timeout: 250 })
.catch(() => null);
if (toast && /验证码|密码|错误|失败|频繁/.test(toast)) {
session.phase = "error";
session.error = toast.trim().slice(0, 120);
await this.closeBrowser(session);
return this.toView(session);
}
await sleep(this.delays.pollIntervalMs);
pollCount++;
}
session.phase = "timeout";
session.error = await this.timeoutDiagnostics(session);
await this.closeBrowser(session);
return this.toView(session);
}
/** First identity-list selector that matches at least one element. */
private async identityListSelector(page: PwPage): Promise<string | null> {
for (const selector of SELECTORS.identityList) {
try {
const count = await page.locator(selector).count();
if (count > 0) return selector;
} catch {
// try next candidate
}
}
return null;
}
/** Scrape identity options from the select_identity page, in document order. */
private async scrapeIdentityOptions(
page: PwPage
): Promise<Array<{ index: number; label: string }>> {
const selector = await this.identityListSelector(page);
if (!selector) return [];
const items = page.locator(selector);
const count = await items.count();
const options: Array<{ index: number; label: string }> = [];
for (let i = 0; i < count; i++) {
const text =
(await items
.nth(i)
.textContent()
.catch(() => "")) || "";
const label = text.replace(/\s+/g, " ").trim();
if (label) options.push({ index: i, label: label.slice(0, 100) });
}
return options;
}
/**
* Build a diagnostic message for the cookie-poll timeout: page URL, cookies
* collected so far, and any blocking modal. Keeps future debugging cheap.
* When stuck on the identity-selection page, also dumps the page HTML to
* /tmp so a selector miss can be fixed from ground truth in one shot.
*/
private async timeoutDiagnostics(session: ActiveSession): Promise<string> {
const parts = ["Timed out waiting for the console session cookies"];
try {
if (session.page) {
parts.push(`url=${session.page.url()}`);
const cookies = (await session.context.cookies()) as Array<{
name: string;
domain: string;
}>;
const present = REQUIRED_COOKIES.filter((name) =>
cookies.some((c) => c.name === name && c.domain.includes("volcengine.com"))
);
parts.push(
`cookies=[${present.join(",") || "none of digest/AccountID/csrfToken/userInfo"}]`
);
const bindModal = await this.firstVisible(session.page, SELECTORS.mfaBindModal);
if (bindModal) parts.push("blocked by 绑定MFA设备 modal");
const mfaModal = await this.firstVisible(session.page, SELECTORS.mfaModal);
if (mfaModal) parts.push("blocked by 需要额外认证 modal");
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
if (risk) parts.push("blocked by risk-control slider");
if (IDENTITY_URL_PATTERN.test(session.page.url())) {
const dump = await this.dumpPageHtml(session);
if (dump) parts.push(`identityPageHtml=${dump}`);
}
}
} catch {
// diagnostics are best-effort
}
return parts.join(" · ");
}
/** Best-effort page HTML dump for debugging selector misses. */
private async dumpPageHtml(session: ActiveSession): Promise<string | null> {
try {
const { writeFile } = await import("fs/promises");
const path = `/tmp/omniroute-volc-select-identity-${session.sessionId.slice(0, 8)}.html`;
await writeFile(path, await session.page.content(), "utf8");
return path;
} catch {
return null;
}
}
async resendCode(sessionId: string): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
const fromMfa = session.phase === "mfa_waiting";
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
return this.toView(session);
}
if (Date.now() < session.resendAvailableAt) {
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
// In the MFA step-up modal the button is 重发校验码; on the login form
// it counts down ("60s后重发" etc.) — try the fresh label first, then
// any 重发/重新获取 variant.
const resendSelectors = fromMfa
? [...SELECTORS.mfaResendButton]
: [
'button:has-text("获取验证码")',
'button:has-text("重发")',
'button:has-text("重新获取")',
'button:has-text("重新发送")',
];
const btn = await this.firstVisible(page, resendSelectors);
if (!btn) throw new SelectorMissError("resend button");
const disabled = await btn.isDisabled().catch(() => false);
if (disabled) {
session.error = "Resend is still cooling down on the login page";
return this.toView(session);
}
await btn.click();
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
await sleep(this.delays.sendCodeSettleMs);
if (fromMfa) {
// Stay in mfa_waiting — the modal persists until a valid code lands.
session.phase = "mfa_waiting";
session.error = null;
return this.toView(session);
}
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
if (captchaInput) {
session.captchaImage = await this.shot(page);
session.phase = "captcha_required";
} else {
session.captchaImage = null;
session.phase = "waiting_code";
}
session.error = null;
return this.toView(session);
} catch (error) {
session.phase = "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
async cancel(sessionId: string): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (isTerminal(session.phase)) return this.toView(session);
session.cancelled = true;
session.phase = "cancelled";
await this.closeBrowser(session);
return this.toView(session);
}
// ─── Internals ───────────────────────────────────────────────────────────
private toView(session: ActiveSession): VolcLoginSessionView {
const view: VolcLoginSessionView = {
sessionId: session.sessionId,
phase: session.phase,
phoneMasked: maskPhone(session.phone),
error: session.error,
captchaImage: session.phase === "captcha_required" ? session.captchaImage : null,
resendAvailableAt: session.resendAvailableAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
if (session.phase === "mfa_waiting") view.mfaRequired = true;
if (session.phase === "identity_required" && session.identityOptions) {
view.identityOptions = session.identityOptions;
}
if (session.phase === "success" && session.credentials) view.credentials = session.credentials;
if (session.binding !== undefined) view.binding = session.binding;
return view;
}
private async closeBrowser(session: ActiveSession): Promise<void> {
try {
await session.browser?.close?.();
} catch {
// browser may already be gone
} finally {
session.browser = null;
session.context = null;
session.page = null;
}
}
/** Screenshot for captcha rendering; null when capture fails. */
private async shot(page: PwPage): Promise<string | null> {
try {
const target = await this.firstVisible(page, SELECTORS.captchaShot);
const buffer: Buffer | null = target
? await target.screenshot({ type: "png" })
: await page.screenshot({ type: "png" });
return buffer ? `data:image/png;base64,${buffer.toString("base64")}` : null;
} catch {
return null;
}
}
private async firstVisible(
page: PwPage,
selectors: readonly string[]
): Promise<PwLocator | null> {
for (const selector of selectors) {
try {
const locator = page.locator(selector).first();
if (await locator.isVisible({ timeout: 2_000 })) return locator;
} catch {
// try next candidate
}
}
return null;
}
/** Close and drop sessions past their TTL; keep terminal ones briefly for status reads. */
private expireSessions(): void {
const now = Date.now();
for (const [id, session] of this.sessions) {
const age = now - session.createdAt;
const terminal = isTerminal(session.phase);
if (terminal && age > 10 * 60_000) {
this.sessions.delete(id);
} else if (!terminal && age > session.timeoutMs + 60_000) {
session.phase = "timeout";
session.error = "Session expired";
void this.closeBrowser(session);
this.sessions.delete(id);
}
}
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
class SelectorMissError extends Error {
constructor(element: string) {
super(`Login page element not found: ${element}`);
}
}
function isTerminal(phase: VolcLoginPhase): boolean {
return (
phase === "success" ||
phase === "error" ||
phase === "timeout" ||
phase === "cancelled" ||
phase === "fallback_manual"
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// ─── Singleton ──────────────────────────────────────────────────────────────
export const volcengineConsoleAutoLoginService = new VolcengineConsoleAutoLoginService();