From d054ff65de8e8fedc7290e018a1e16242d22e8b2 Mon Sep 17 00:00:00 2001 From: QLHazyCoder Date: Tue, 5 May 2026 01:47:58 +0800 Subject: [PATCH] feat: Enhance login flow with add-email and phone verification support - Added support for the add-email page in the logging status module. - Updated step 8 to handle phone-registered accounts during email verification. - Implemented logic to submit add-email before polling for email verification codes. - Enhanced tests for step 7 to detect username input as phone number and ignore hidden inputs. - Improved step 8 to allow add-email handoff only when requested. - Updated documentation to reflect changes in the login verification flow. --- background.js | 17 +- background/logging-status.js | 2 + background/steps/fetch-login-code.js | 246 ++++- content/signup-page.js | 837 +++++++++++++++--- .../background-logging-status-module.test.js | 1 + tests/background-step7-recovery.test.js | 186 +++- tests/step6-login-state.test.js | 23 + tests/step7-phone-login-entry.test.js | 117 ++- tests/step8-restart-step7-error.test.js | 31 + 项目完整链路说明.md | 29 +- 项目文件结构说明.md | 10 +- 11 files changed, 1323 insertions(+), 176 deletions(-) diff --git a/background.js b/background.js index d945375..12df754 100644 --- a/background.js +++ b/background.js @@ -7198,6 +7198,7 @@ function getLoginAuthStateLabel(state) { case 'login_timeout_error_page': return '登录超时报错页'; case 'oauth_consent_page': return 'OAuth 授权页'; case 'add_phone_page': return '手机号页'; + case 'add_email_page': return '添加邮箱页'; default: return '未知页面'; } } @@ -10345,10 +10346,12 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ isVerificationMailPollingError, LUCKMAIL_PROVIDER, resolveVerificationStep: verificationFlowHelpers.resolveVerificationStep, + resolveSignupEmailForFlow, phoneVerificationHelpers, rerunStep7ForStep8Recovery: (...args) => rerunStep7ForStep8Recovery(...args), resolveSignupMethod, reuseOrCreateTab, + sendToContentScriptResilient, setState, shouldUseCustomRegistrationEmail, sleepWithStop, @@ -11305,7 +11308,12 @@ async function ensureStep8VerificationPageReady(options = {}) { ...overrides, }); let pageState = await inspectState(); - if (pageState.state === 'verification_page' || pageState.state === 'oauth_consent_page') { + if ( + pageState.state === 'verification_page' + || pageState.state === 'oauth_consent_page' + || (options.allowPhoneVerificationPage && pageState.state === 'phone_verification_page') + || (options.allowAddEmailPage && pageState.state === 'add_email_page') + ) { return pageState; } @@ -11379,7 +11387,12 @@ async function ensureStep8VerificationPageReady(options = {}) { logMessage: '认证页恢复后,正在确认验证码页是否可继续...', logStepKey: 'fetch-login-code', }); - if (pageState.state === 'verification_page' || pageState.state === 'oauth_consent_page') { + if ( + pageState.state === 'verification_page' + || pageState.state === 'oauth_consent_page' + || (options.allowPhoneVerificationPage && pageState.state === 'phone_verification_page') + || (options.allowAddEmailPage && pageState.state === 'add_email_page') + ) { return pageState; } if (pageState.maxCheckAttemptsBlocked) { diff --git a/background/logging-status.js b/background/logging-status.js index 1a4578d..5ce50f0 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -101,6 +101,8 @@ return 'OAuth 授权页'; case 'add_phone_page': return '手机号页'; + case 'add_email_page': + return '添加邮箱页'; case 'phone_verification_page': return '手机验证码页'; default: diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index a5b3b43..41851d2 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -2,6 +2,8 @@ root.MultiPageBackgroundStep8 = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep8Module() { const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000; + const STEP8_ADD_EMAIL_URL = 'https://auth.openai.com/add-email'; + const STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS = 3; function createStep8Executor(deps = {}) { const { @@ -22,11 +24,12 @@ isTabAlive, isVerificationMailPollingError, LUCKMAIL_PROVIDER, + resolveSignupEmailForFlow, resolveVerificationStep, rerunStep7ForStep8Recovery, reuseOrCreateTab, + sendToContentScriptResilient, phoneVerificationHelpers = null, - resolveSignupMethod = () => 'email', setState, shouldUseCustomRegistrationEmail, sleepWithStop, @@ -99,11 +102,98 @@ return String(value || '').trim().toLowerCase(); } - function isPhoneLoginState(state = {}) { - return String(state?.accountIdentifierType || '').trim().toLowerCase() === 'phone' - || resolveSignupMethod(state) === 'phone' - || Boolean(state?.signupPhoneCompletedActivation) - || Boolean(state?.signupPhoneActivation); + async function getLoginAuthStateFromContent(visibleStep, options = {}) { + if (typeof sendToContentScriptResilient !== 'function') { + return {}; + } + const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 15000); + const result = await sendToContentScriptResilient( + 'signup-page', + { + type: 'GET_LOGIN_AUTH_STATE', + source: 'background', + payload: {}, + }, + { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: options.logMessage || `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪...`, + logStep: visibleStep, + logStepKey: 'fetch-login-code', + } + ); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function submitAddEmailIfNeeded(state, visibleStep, initialPageState = null) { + if (typeof resolveSignupEmailForFlow !== 'function' || typeof sendToContentScriptResilient !== 'function') { + return { state, pageState: initialPageState }; + } + + const pageState = initialPageState?.state + ? initialPageState + : await getLoginAuthStateFromContent(visibleStep, { + timeoutMs: 15000, + logMessage: `步骤 ${visibleStep}:正在确认是否已进入添加邮箱页...`, + }); + if (pageState?.state !== 'add_email_page') { + return { state, pageState }; + } + + const latestState = typeof getState === 'function' ? await getState() : state; + const resolvedEmail = await resolveSignupEmailForFlow(latestState); + await addLog(`步骤 ${visibleStep}:检测到添加邮箱页,正在添加邮箱 ${resolvedEmail} 并进入邮箱验证码页...`); + + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(60000, { + step: visibleStep, + actionLabel: '添加邮箱并进入验证码页', + oauthUrl: latestState?.oauthUrl || state?.oauthUrl || '', + }) + : 60000; + const result = await sendToContentScriptResilient( + 'signup-page', + { + type: 'SUBMIT_ADD_EMAIL', + source: 'background', + payload: { email: resolvedEmail }, + }, + { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 700, + logMessage: `步骤 ${visibleStep}:添加邮箱页面正在切换,等待邮箱验证码页就绪...`, + logStep: visibleStep, + logStepKey: 'fetch-login-code', + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + const displayedEmail = normalizeStep8VerificationTargetEmail(result?.displayedEmail || resolvedEmail); + await setState({ + email: resolvedEmail, + step8VerificationTargetEmail: displayedEmail, + }); + + return { + state: { + ...latestState, + email: resolvedEmail, + step8VerificationTargetEmail: displayedEmail, + }, + pageState: { + state: result?.directOAuthConsentPage ? 'oauth_consent_page' : 'verification_page', + displayedEmail, + url: result?.url || pageState?.url || '', + }, + }; } async function completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, options = {}) { @@ -130,12 +220,69 @@ return /add-phone|手机号页面|手机号验证页|phone[\s-_]verification|phone\s+number/i.test(message); } + function isStep8EmailInUseError(error) { + const message = String(error?.message || error || ''); + return /STEP8_EMAIL_IN_USE::|email_in_use|email\s+(?:address\s+)?already\s+exists|already\s+associated\s+with\s+this\s+email/i.test(message); + } + + function isStep8MaxCheckAttemptsError(error) { + const message = String(error?.message || error || ''); + return /AUTH_MAX_CHECK_ATTEMPTS::|max_check_attempts/i.test(message); + } + + async function openStep8AddEmailPage(state, visibleStep, reasonLabel = '') { + const tabId = typeof getTabId === 'function' ? await getTabId('signup-page') : 0; + const url = STEP8_ADD_EMAIL_URL; + if (tabId && chrome?.tabs?.update) { + await chrome.tabs.update(tabId, { url, active: true }); + } else if (typeof reuseOrCreateTab === 'function') { + await reuseOrCreateTab('signup-page', url); + } else { + throw new Error(`Step ${visibleStep}: cannot reopen add-email page for Step 8 recovery.`); + } + if (typeof sleepWithStop === 'function') { + await sleepWithStop(1000); + } + await addLog( + `步骤 ${visibleStep}:重新打开添加邮箱页面${reasonLabel ? `(${reasonLabel})` : ''}。`, + 'warn' + ); + return { + ...(state || {}), + oauthUrl: state?.oauthUrl || url, + }; + } + + async function resetStep8AfterEmailInUse(state, visibleStep) { + const currentEmail = String(state?.email || '').trim(); + await setState({ + email: null, + step8VerificationTargetEmail: '', + loginVerificationRequestedAt: null, + }); + if (currentEmail) { + await addLog(`步骤 ${visibleStep}:检测到邮箱 ${currentEmail} 已被占用,已清理运行态并准备重新获取新邮箱。`, 'warn'); + } else { + await addLog(`步骤 ${visibleStep}:检测到邮箱已被占用,已清理运行态并准备重新获取新邮箱。`, 'warn'); + } + } + + async function resetStep8AfterMaxCheckAttempts(visibleStep) { + await setState({ + step8VerificationTargetEmail: '', + loginVerificationRequestedAt: null, + }); + await addLog(`步骤 ${visibleStep}:检测到 max_check_attempts,将重新开始当前添加邮箱步骤,不继续点击重试。`, 'warn'); + } + async function recoverStep8PollingFailure(currentState, visibleStep) { const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); try { const pageState = await ensureStep8VerificationPageReady({ visibleStep, authLoginStep, + allowPhoneVerificationPage: true, + allowAddEmailPage: true, timeoutMs: await getStep8ReadyTimeoutMs( '登录验证码轮询异常后复核认证页状态', currentState?.oauthUrl || '', @@ -146,9 +293,9 @@ await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true }); return { outcome: 'completed' }; } - if (pageState?.state === 'verification_page') { + if (pageState?.state === 'verification_page' || pageState?.state === 'phone_verification_page' || pageState?.state === 'add_email_page') { await addLog( - `步骤 ${visibleStep}:检测到邮箱轮询/页面通信异常,但认证页仍在验证码页,先在当前链路重试,不回到步骤 ${authLoginStep}。`, + `步骤 ${visibleStep}:检测到邮箱轮询/页面通信异常,但认证页仍在当前登录后续页面,先在当前链路重试,不回到步骤 ${authLoginStep}。`, 'warn' ); return { outcome: 'retry_without_step7' }; @@ -250,40 +397,58 @@ await reuseOrCreateTab('signup-page', state.oauthUrl); } - if (isPhoneLoginState(state)) { - return executeLoginPhoneCodeStep(state, authTabId, visibleStep); - } - - const mail = getMailConfig(state); - if (mail.error) throw new Error(mail.error); const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt); const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function' ? runtime.onResendRequestedAt : null; - const stepStartedAt = Date.now(); - const verificationFilterAfterTimestamp = mail.provider === '2925' - ? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS) - : stepStartedAt; - const verificationSessionKey = `8:${stepStartedAt}`; - throwIfStopped(); - const pageState = await ensureStep8VerificationPageReady({ + let pageState = await ensureStep8VerificationPageReady({ visibleStep, authLoginStep: getAuthLoginStepForVisibleStep(visibleStep), + allowPhoneVerificationPage: true, + allowAddEmailPage: true, timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep), }); if (pageState?.state === 'oauth_consent_page') { await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); return; } + if (pageState?.state === 'phone_verification_page') { + return executeLoginPhoneCodeStep(state, authTabId, visibleStep); + } + + let preparedState = state; + const addEmailPreparation = await submitAddEmailIfNeeded(preparedState, visibleStep, pageState); + preparedState = addEmailPreparation?.state || preparedState; + pageState = addEmailPreparation?.pageState || pageState; + if (pageState?.state === 'oauth_consent_page') { + await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep); + return; + } + if (pageState?.state === 'phone_verification_page') { + return executeLoginPhoneCodeStep(preparedState, authTabId, visibleStep); + } + + const preparedStateLastResendAt = Number(preparedState?.loginVerificationRequestedAt) || 0; + if (preparedStateLastResendAt > 0) { + latestResendAt = Math.max(latestResendAt, preparedStateLastResendAt); + } + + const mail = getMailConfig(preparedState); + if (mail.error) throw new Error(mail.error); + const stepStartedAt = Date.now(); + const verificationFilterAfterTimestamp = mail.provider === '2925' + ? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS) + : stepStartedAt; + const verificationSessionKey = `8:${stepStartedAt}`; const shouldCompareVerificationEmail = mail.provider !== '2925'; const displayedVerificationEmail = shouldCompareVerificationEmail ? normalizeStep8VerificationTargetEmail(pageState?.displayedEmail) : ''; const fixedTargetEmail = shouldCompareVerificationEmail - ? (displayedVerificationEmail || normalizeStep8VerificationTargetEmail(state?.email)) + ? (displayedVerificationEmail || normalizeStep8VerificationTargetEmail(preparedState?.email)) : ''; await setState({ @@ -295,7 +460,7 @@ await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); } - if (shouldUseCustomRegistrationEmail(state)) { + if (shouldUseCustomRegistrationEmail(preparedState)) { await confirmCustomVerificationStepBypass(8, { completionStep: visibleStep, promptStep: visibleStep, @@ -306,7 +471,7 @@ if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { await addLog(`步骤 ${visibleStep}:正在确认 iCloud 邮箱登录态...`, 'info'); await ensureIcloudMailSession({ - state, + state: preparedState, step: 8, actionLabel: `步骤 ${visibleStep}:确认 iCloud 邮箱登录态`, }); @@ -323,10 +488,10 @@ await addLog(`步骤 ${visibleStep}:正在打开${mail.label}...`); if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') { await ensureMail2925MailboxSession({ - accountId: state.currentMail2925AccountId || null, + accountId: preparedState.currentMail2925AccountId || null, forceRelogin: false, - allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), - expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), + allowLoginWhenOnLoginPage: Boolean(preparedState?.mail2925UseAccountPool), + expectedMailboxEmail: getExpectedMail2925MailboxEmail(preparedState), actionLabel: `Step ${visibleStep}: ensure 2925 mailbox session`, }); } else { @@ -338,14 +503,14 @@ } await resolveVerificationStep(8, { - ...state, + ...preparedState, step8VerificationTargetEmail: displayedVerificationEmail || '', }, mail, { completionStep: visibleStep, filterAfterTimestamp: verificationFilterAfterTimestamp, sessionKey: verificationSessionKey, disableTimeBudgetCap: mail.provider === '2925', - getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep), + getRemainingTimeMs: getStep8RemainingTimeResolver(preparedState?.oauthUrl || '', visibleStep), requestFreshCodeFirst: false, lastResendAt: latestResendAt, onResendRequestedAt: async (requestedAt) => { @@ -381,6 +546,7 @@ let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; let retryWithoutStep7Streak = 0; const maxRetryWithoutStep7Streak = 3; + let currentStepRecoveryAttempt = 0; while (true) { try { @@ -403,6 +569,28 @@ const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); let currentError = err; let retryWithoutStep7 = false; + + if (isStep8EmailInUseError(currentError) || isStep8MaxCheckAttemptsError(currentError)) { + currentStepRecoveryAttempt += 1; + if (currentStepRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) { + throw currentError; + } + if (isStep8EmailInUseError(currentError)) { + await resetStep8AfterEmailInUse(currentState, visibleStep); + await openStep8AddEmailPage(currentState, visibleStep, 'email_in_use'); + } else { + await resetStep8AfterMaxCheckAttempts(visibleStep); + await openStep8AddEmailPage(currentState, visibleStep, 'max_check_attempts'); + } + const latestState = typeof getState === 'function' ? await getState() : currentState; + currentState = { + ...(currentState || {}), + ...(latestState || {}), + oauthUrl: currentState?.oauthUrl || latestState?.oauthUrl || STEP8_ADD_EMAIL_URL, + }; + continue; + } + const isMailPollingError = isVerificationMailPollingError(err); if (isMailPollingError && !isStep8RestartStep7Error(err)) { const recovery = await recoverStep8PollingFailure(currentState, visibleStep); diff --git a/content/signup-page.js b/content/signup-page.js index 8a08a0f..b1fe471 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -18,6 +18,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' || message.type === 'STEP8_GET_STATE' || message.type === 'STEP8_TRIGGER_CONTINUE' || message.type === 'GET_LOGIN_AUTH_STATE' + || message.type === 'SUBMIT_ADD_EMAIL' || message.type === 'PREPARE_SIGNUP_VERIFICATION' || message.type === 'RECOVER_AUTH_RETRY_PAGE' || message.type === 'RESEND_VERIFICATION_CODE' @@ -76,6 +77,8 @@ async function handleCommand(message) { return await fillVerificationCode(message.step, message.payload); case 'GET_LOGIN_AUTH_STATE': return serializeLoginAuthState(inspectLoginAuthState()); + case 'SUBMIT_ADD_EMAIL': + return await submitAddEmailAndContinue(message.payload); case 'PREPARE_SIGNUP_VERIFICATION': return await prepareSignupVerificationFlow(message.payload); case 'RECOVER_AUTH_RETRY_PAGE': @@ -135,6 +138,7 @@ const ONE_TIME_CODE_LOGIN_PATTERN = /使用一次性验证码登录|改用(?:一 const LOGIN_ENTRY_ACTION_PATTERN = /(?:^|\b)(?:log\s*in|sign\s*in|continue\s+(?:with|using)\s+(?:email|chatgpt)|use\s+(?:an?\s+)?email|email\s+address)(?:\b|$)|登录|登陆|邮箱|电子邮件/i; const LOGIN_SWITCH_TO_PHONE_PATTERN = /继续使用(?:手机|手机号|电话)(?:号码)?登录|改用(?:手机|手机号|电话)(?:号码)?登录|手机号登录|continue\s+(?:with|using)\s+(?:a\s+)?phone(?:\s+number)?|use\s+(?:a\s+)?phone(?:\s+number)?(?:\s+instead)?|sign\s*in\s+with\s+(?:a\s+)?phone/i; const LOGIN_PHONE_ACTION_PATTERN = /手机|电话|phone|telephone/i; +const LOGIN_PHONE_ENTRY_PAGE_PATTERN = /(?:\+\s*\(?\d{1,4}\)?\s*)?(?:手机号码|手机号|电话号码)|(?:phone|mobile)\s+number|telephone/i; const LOGIN_MORE_OPTIONS_PATTERN = /更多(?:选项|登录方式|方式)|其他(?:登录方式|选项|方式)|显示更多|more\s+(?:login\s+|sign[-\s]*in\s+)?options|other\s+(?:login\s+|sign[-\s]*in\s+)?(?:options|ways)|show\s+more/i; const LOGIN_EXTERNAL_IDP_PATTERN = /google|microsoft|apple|sso|single\s+sign[-\s]*on|企业|工作区|workspace/i; const LOGIN_CODE_ONLY_ACTION_PATTERN = /one[-\s]*time|passcode|use\s+(?:a\s+)?code|验证码|一次性/i; @@ -1826,33 +1830,186 @@ function getPhoneInputRenderedValue(phoneInput) { return String(phoneInput?.value ?? phoneInput?.getAttribute?.('value') ?? '').trim(); } -function isPhoneInputValueComplete(phoneInput, phoneNumber, dialCode, expectedLocalNumber = '') { - const renderedDigits = normalizePhoneDigits(getPhoneInputRenderedValue(phoneInput)); - const targetDigits = normalizePhoneDigits(phoneNumber); - const localDigits = normalizePhoneDigits(expectedLocalNumber || toNationalPhoneNumber(phoneNumber, dialCode)); - const normalizedDialCode = normalizePhoneDigits(dialCode); - if (!renderedDigits) { +function isPhoneInputValueVerified(actualValue, expectedValue, options = {}) { + const actualDigits = normalizePhoneDigits(actualValue); + const expectedDigits = normalizePhoneDigits(expectedValue); + if (!actualDigits || !expectedDigits) { return false; } - if (normalizedDialCode && renderedDigits === normalizedDialCode) { + if (actualDigits === expectedDigits) { + return true; + } + + const dialDigits = normalizePhoneDigits(options.dialCode); + const fullDigits = normalizePhoneDigits(options.phoneNumber); + if (fullDigits && actualDigits === fullDigits) { + return true; + } + if (!dialDigits) { return false; } - if (localDigits && renderedDigits === localDigits) { + if (actualDigits === `${dialDigits}${expectedDigits}`) { return true; } - if (localDigits && renderedDigits.endsWith(localDigits) && renderedDigits.length > localDigits.length) { - return true; - } - return Boolean(targetDigits && renderedDigits === targetDigits); + + const localDigits = fullDigits && fullDigits.startsWith(dialDigits) + ? fullDigits.slice(dialDigits.length) + : expectedDigits; + return dialDigits === '44' && actualDigits === `${dialDigits}0${localDigits}`; } -function getLoginPhoneFillCandidates(phoneNumber, dialCode) { - const candidates = [ - toNationalPhoneNumber(phoneNumber, dialCode), - toE164PhoneNumber(phoneNumber, dialCode), - normalizePhoneDigits(phoneNumber), - ]; - return candidates.filter((value, index, list) => value && list.indexOf(value) === index); +async function waitForPhoneInputValue(phoneInput, expectedValue, options = {}) { + const { + timeout = 1800, + pollInterval = 100, + resolvePhoneInput = null, + phoneNumber = '', + dialCode = '', + } = options; + const startedAt = Date.now(); + let currentInput = phoneInput; + + while (Date.now() - startedAt < timeout) { + throwIfStopped(); + currentInput = (typeof resolvePhoneInput === 'function' && resolvePhoneInput()) || currentInput; + if (isPhoneInputValueVerified(getPhoneInputRenderedValue(currentInput), expectedValue, { phoneNumber, dialCode })) { + return { + ok: true, + input: currentInput, + value: getPhoneInputRenderedValue(currentInput), + }; + } + await sleep(pollInterval); + } + + currentInput = (typeof resolvePhoneInput === 'function' && resolvePhoneInput()) || currentInput; + return { + ok: false, + input: currentInput, + value: getPhoneInputRenderedValue(currentInput), + }; +} + +function formatPhoneHiddenFormValue({ phoneNumber = '', dialCode = '', inputValue = '' } = {}) { + const fullDigits = normalizePhoneDigits(phoneNumber); + if (fullDigits) { + return `+${fullDigits}`; + } + + const localDigits = normalizePhoneDigits(inputValue); + if (!localDigits) { + return ''; + } + const dialDigits = normalizePhoneDigits(dialCode); + return dialDigits ? `+${dialDigits}${localDigits}` : localDigits; +} + +function getPhoneHiddenValueInput(phoneInput) { + if (typeof getLoginPhoneHiddenValueInput === 'function') { + const loginHiddenInput = getLoginPhoneHiddenValueInput(phoneInput); + if (loginHiddenInput) { + return loginHiddenInput; + } + } + const form = phoneInput?.form || phoneInput?.closest?.('form') || null; + const root = form || phoneInput?.closest?.('fieldset, form, [data-rac], div') || document; + const candidates = Array.from(root?.querySelectorAll?.('input[name="phone"], input[name="phoneNumber"], input[type="hidden"][id*="phone" i]') || []); + return candidates.find((input) => { + if (!input || input === phoneInput) return false; + const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase(); + return type === 'hidden' || !isVisibleElement(input); + }) || null; +} + +function setPhoneHiddenValue(input, value) { + const normalizedValue = String(value || ''); + try { + const nativeInputValueSetter = typeof window !== 'undefined' + ? Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + : null; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(input, normalizedValue); + } else { + input.value = normalizedValue; + } + } catch { + input.value = normalizedValue; + } + input.dispatchEvent?.(new Event('input', { bubbles: true })); + input.dispatchEvent?.(new Event('change', { bubbles: true })); +} + +function syncPhoneHiddenFormValue(phoneInput, options = {}) { + const hiddenInput = getPhoneHiddenValueInput(phoneInput); + const hiddenValue = formatPhoneHiddenFormValue(options); + if (!hiddenInput || !hiddenValue) { + return null; + } + + setPhoneHiddenValue(hiddenInput, hiddenValue); + return { + input: hiddenInput, + value: hiddenInput.value || '', + }; +} + +function isPhoneInputValueComplete(phoneInput, phoneNumber, dialCode, expectedLocalNumber = '') { + return isPhoneInputValueVerified(getPhoneInputRenderedValue(phoneInput), expectedLocalNumber || toNationalPhoneNumber(phoneNumber, dialCode), { + phoneNumber, + dialCode, + }); +} + +function getLoginPhoneFillCandidates(phoneNumber, dialCode, phoneInput = null) { + const inputValue = toNationalPhoneNumber(phoneNumber, dialCode); + const e164Value = toE164PhoneNumber(phoneNumber, dialCode); + const dialDigits = normalizePhoneDigits(dialCode); + const currentRenderedValue = getPhoneInputRenderedValue(phoneInput); + const currentDigits = normalizePhoneDigits(currentRenderedValue); + const shouldKeepDialPrefix = Boolean( + e164Value + && ( + String(currentRenderedValue || '').trim().startsWith('+') + || (dialDigits && currentDigits === dialDigits) + ) + ); + const candidates = []; + const addCandidate = (value) => { + const normalizedValue = String(value || '').trim(); + if (normalizedValue && !candidates.includes(normalizedValue)) { + candidates.push(normalizedValue); + } + }; + + if (shouldKeepDialPrefix) { + addCandidate(e164Value); + } + addCandidate(inputValue); + addCandidate(e164Value); + return candidates; +} + +function getLoginPhoneSubmitButtonDiagnostics(button) { + if (!button) { + return { + present: false, + }; + } + + return { + present: true, + tag: (button.tagName || '').toLowerCase(), + type: String(button.getAttribute?.('type') || button.type || '').trim().toLowerCase(), + text: getActionText(button).slice(0, 80), + visible: isVisibleElement(button), + enabled: isActionEnabled(button), + disabled: Boolean(button.disabled), + ariaDisabled: String(button.getAttribute?.('aria-disabled') || '').trim(), + }; +} + +function getLoginPhoneInputCandidateDiagnostics(limit = 12) { + return collectPhoneInputCandidates('input', { allowGenericText: true }).slice(0, limit); } async function fillLoginPhoneInputAndConfirm(phoneInput, options = {}) { @@ -1860,28 +2017,73 @@ async function fillLoginPhoneInputAndConfirm(phoneInput, options = {}) { phoneNumber = '', dialCode = '', visibleStep = 7, + resolvePhoneInput = null, + maxAttempts = 3, } = options; - const localNumber = toNationalPhoneNumber(phoneNumber, dialCode); - if (!localNumber) { - throw new Error(`步骤 ${visibleStep}:手机号为空,无法填写。`); + const inputValue = toNationalPhoneNumber(phoneNumber, dialCode); + if (!inputValue) { + throw new Error(`\u6b65\u9aa4 ${visibleStep}\uff1a\u624b\u673a\u53f7\u4e3a\u7a7a\uff0c\u65e0\u6cd5\u586b\u5199\u3002`); } - let lastRenderedValue = ''; - for (const candidate of getLoginPhoneFillCandidates(phoneNumber, dialCode)) { - fillInput(phoneInput, candidate); - await sleep(350); - lastRenderedValue = getPhoneInputRenderedValue(phoneInput); - if (isPhoneInputValueComplete(phoneInput, phoneNumber, dialCode, localNumber)) { - return { - inputValue: localNumber, - attemptedValue: candidate, - renderedValue: lastRenderedValue, - }; + let currentInput = phoneInput; + let lastVerification = { ok: false, input: currentInput, value: getPhoneInputRenderedValue(currentInput) }; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + throwIfStopped(); + currentInput = (typeof resolvePhoneInput === 'function' && resolvePhoneInput()) || currentInput; + if (!currentInput) { + break; } + + const fillCandidates = getLoginPhoneFillCandidates(phoneNumber, dialCode, currentInput); + for (const attemptedValue of fillCandidates) { + currentInput.focus?.(); + fillInput(currentInput, attemptedValue); + lastVerification = await waitForPhoneInputValue(currentInput, inputValue, { + resolvePhoneInput, + phoneNumber, + dialCode, + timeout: 1600, + pollInterval: 100, + }); + if (lastVerification.ok) { + const verifiedInput = lastVerification.input || currentInput; + const hiddenSync = syncPhoneHiddenFormValue(verifiedInput, { phoneNumber, dialCode, inputValue }); + const expectedHiddenDigits = normalizePhoneDigits(phoneNumber) || `${normalizePhoneDigits(dialCode)}${normalizePhoneDigits(inputValue)}`; + if (hiddenSync && expectedHiddenDigits && normalizePhoneDigits(hiddenSync.value) !== expectedHiddenDigits) { + throw new Error(`\u6b65\u9aa4 ${visibleStep}\uff1a\u624b\u673a\u53f7\u9690\u85cf\u63d0\u4ea4\u5b57\u6bb5\u540c\u6b65\u5931\u8d25\uff0c\u671f\u671b ${expectedHiddenDigits}\uff0c\u5b9e\u9645 ${normalizePhoneDigits(hiddenSync.value) || '\u7a7a'}\u3002`); + } + log( + `\u6b65\u9aa4 ${visibleStep}\uff1a\u624b\u673a\u53f7\u8f93\u5165\u6821\u9a8c\u901a\u8fc7 ${JSON.stringify({ + attemptedValue, + renderedValue: lastVerification.value, + input: getLoginPhoneInputDiagnostics(verifiedInput), + hidden: getLoginPhoneHiddenValueDiagnostics(hiddenSync?.input || getPhoneHiddenValueInput(verifiedInput)), + })}`, + 'info', + { step: visibleStep, stepKey: 'oauth-login' } + ); + return { + input: verifiedInput, + inputValue, + attemptedValue, + renderedValue: lastVerification.value, + hiddenInput: hiddenSync?.input || null, + hiddenValue: hiddenSync?.value || '', + }; + } + } + + const currentDigits = normalizePhoneDigits(lastVerification.value); + log( + `\u6b65\u9aa4 ${visibleStep}\uff1a\u624b\u673a\u53f7\u8f93\u5165\u6846\u672a\u7a33\u5b9a\u5199\u5165\uff08\u7b2c ${attempt}/${maxAttempts} \u6b21\uff09\uff0c\u671f\u671b\u672c\u5730\u53f7 ${inputValue}\uff0c\u5f53\u524d\u503c ${currentDigits || '\u7a7a'}\uff0c\u51c6\u5907\u91cd\u8bd5\u3002`, + 'warn', + { step: visibleStep, stepKey: 'oauth-login' } + ); + await sleep(200); } - const displayedValue = lastRenderedValue || '空'; - throw new Error(`步骤 ${visibleStep}:手机号填写后页面显示为 ${displayedValue},未包含本地号码 ${localNumber},已停止提交以避免空号提交。`); + const actualDigits = normalizePhoneDigits(lastVerification.value); + throw new Error(`\u6b65\u9aa4 ${visibleStep}\uff1a\u624b\u673a\u53f7\u586b\u5199\u540e\u6821\u9a8c\u5931\u8d25\uff0c\u5b8c\u6574\u53f7\u7801 ${phoneNumber}\uff0c\u533a\u53f7 +${dialCode || '\u672a\u8bc6\u522b'}\uff0c\u671f\u671b\u8f93\u5165\u672c\u5730\u53f7 ${inputValue}\uff0c\u5b9e\u9645\u8f93\u5165\u6846\u4e3a ${actualDigits || '\u7a7a'}\uff0c\u5df2\u505c\u6b62\u63d0\u4ea4\u3002`); } function resolveSignupPhoneDialCode(phoneInput, options = {}) { @@ -2180,6 +2382,7 @@ const OAUTH_CONSENT_PAGE_PATTERN = /使用\s*ChatGPT\s*登录到\s*Codex|sign\s+ const OAUTH_CONSENT_FORM_SELECTOR = 'form[action*="/sign-in-with-chatgpt/" i][action*="/consent" i]'; const CONTINUE_ACTION_PATTERN = /继续|continue/i; const ADD_PHONE_PAGE_PATTERN = /add[\s-]*(?:a\s+)?phone|添加(?:手机|手机号|电话号码)|绑定(?:手机|手机号|电话号码)|验证(?:你的|您)?(?:手机|手机号|电话号码)|需要(?:手机|手机号|电话号码)|提供(?:手机|手机号|电话号码)|provide\s+(?:a\s+)?phone\s+number|phone\s+number\s+(?:required|verification)|verify\s+(?:your\s+)?phone|confirm\s+(?:your\s+)?phone/i; +const ADD_EMAIL_PAGE_PATTERN = /add[\s-]*email|添加(?:电子邮件|邮箱)|要求提供(?:电子邮件|邮箱)地址|提供(?:电子邮件|邮箱)地址|provide\s+(?:an?\s+)?email\s+address|email\s+address\s+required/i; const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i; const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时|failed\s+to\s+fetch|network\s+error|fetch\s+failed/i; @@ -2187,6 +2390,8 @@ const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*40 const STEP4_405_RECOVERY_ERROR_PREFIX = 'STEP4_405_RECOVERY_LIMIT::'; const STEP4_405_RECOVERY_LIMIT = 3; const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::'; +const AUTH_MAX_CHECK_ATTEMPTS_ERROR_PREFIX = 'AUTH_MAX_CHECK_ATTEMPTS::'; +const STEP8_EMAIL_IN_USE_ERROR_PREFIX = 'STEP8_EMAIL_IN_USE::'; const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i; const authPageRecovery = self.MultiPageAuthPageRecovery?.createAuthPageRecovery?.({ @@ -2244,6 +2449,39 @@ function createSignupUserAlreadyExistsError() { ); } +function createAuthMaxCheckAttemptsError() { + return new Error(`${AUTH_MAX_CHECK_ATTEMPTS_ERROR_PREFIX}max_check_attempts on auth retry page; restart the current auth step without clicking Retry.`); +} + +function createStep8EmailInUseError() { + return new Error(`${STEP8_EMAIL_IN_USE_ERROR_PREFIX}email_in_use on add-email verification page; choose a different email.`); +} + +function getVisibleFieldErrorText() { + const selectors = [ + '.react-aria-FieldError', + '[slot="errorMessage"]', + '[id$="-error"]', + '[data-invalid="true"] + *', + '[aria-invalid="true"] + *', + '[class*="error"]', + '[role="alert"]', + ]; + + for (const selector of selectors) { + const match = Array.from(document.querySelectorAll(selector)).find((el) => { + if (!isVisibleElement(el)) return false; + const text = (el.textContent || '').replace(/\s+/g, ' ').trim(); + return Boolean(text); + }); + if (match) { + return (match.textContent || '').replace(/\s+/g, ' ').trim(); + } + } + + return ''; +} + function isStep5Ready() { return Boolean( document.querySelector('input[name="name"], input[autocomplete="name"], input[name="birthday"], input[name="age"], [role="spinbutton"][data-type="year"]') @@ -2468,6 +2706,28 @@ function isAddPhonePageReady() { return ADD_PHONE_PAGE_PATTERN.test(getPageTextSnapshot()); } +function isAddEmailPageReady() { + const path = `${location.pathname || ''} ${location.href || ''}`; + if (/\/add-email(?:[/?#]|$)/i.test(path)) { + return true; + } + + const emailInput = getLoginEmailInput(); + if (!emailInput) { + return false; + } + + const form = emailInput.form || emailInput.closest?.('form') || null; + const formAction = String(form?.getAttribute?.('action') || form?.action || ''); + if (/\/add-email(?:[/?#]|$)/i.test(formAction)) { + return true; + } + + const pageText = getPageTextSnapshot(); + return ADD_EMAIL_PAGE_PATTERN.test(pageText) + && !/继续使用(?:电子邮件地址|邮箱)登录|continue\s+using\s+(?:an?\s+)?email(?:\s+address)?\s+(?:to\s+)?(?:log\s*in|sign\s*in)|continue\s+with\s+email/i.test(pageText); +} + function isPhoneVerificationPageReady() { const path = `${location.pathname || ''} ${location.href || ''}`; if (/\/phone-verification(?:[/?#]|$)/i.test(path)) { @@ -2534,6 +2794,7 @@ function isStep8Ready() { if (isVerificationPageStillVisible()) return false; if (isPhoneVerificationPageReady()) return false; if (isAddPhonePageReady()) return false; + if (isAddEmailPageReady()) return false; return isOAuthConsentPage(); } @@ -2775,9 +3036,10 @@ function getAuthTimeoutErrorPageState(options = {}) { const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text); const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text); const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); + const emailInUseBlocked = /email_in_use/i.test(text); const userAlreadyExistsBlocked = /user_already_exists/i.test(text); - if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !emailInUseBlocked && !userAlreadyExistsBlocked) { return null; } @@ -2791,6 +3053,7 @@ function getAuthTimeoutErrorPageState(options = {}) { routeErrorMatched, fetchFailedMatched, maxCheckAttemptsBlocked, + emailInUseBlocked, userAlreadyExistsBlocked, }; } @@ -2932,22 +3195,155 @@ function getLoginTimeoutErrorPageState() { }); } +function isLoginPhoneUsernameKind(rawUrl = location.href) { + const url = String(rawUrl || '').trim(); + if (!url) { + return false; + } + + try { + const parsed = new URL(url); + return /\/log-in(?:[/?#]|$)/i.test(parsed.pathname || '') + && String(parsed.searchParams.get('usernameKind') || '').toLowerCase() === 'phone_number'; + } catch { + return /\/log-in(?:[/?#]|$)/i.test(url) && /[?&]usernameKind=phone_number(?:[&#]|$)/i.test(url); + } +} + +function isLoginPhoneEntryPageText(pageText = getPageTextSnapshot()) { + const normalizedText = String(pageText || '').replace(/\s+/g, ' ').trim(); + if (!normalizedText) { + return false; + } + + if (isAddPhonePageReady() || isPhoneVerificationPageReady()) { + return false; + } + + return LOGIN_PHONE_ENTRY_PAGE_PATTERN.test(normalizedText); +} + +function isInsideHiddenPhoneControl(element) { + if (!element) { + return true; + } + return Boolean(element.closest?.('[aria-hidden="true"], [hidden], [data-testid="hidden-select-container"], [data-react-aria-prevent-focus="true"]')); +} + +function summarizePhoneInputCandidate(element, options = {}) { + const summary = { + tag: (element?.tagName || '').toLowerCase(), + type: '', + name: '', + id: '', + autocomplete: '', + placeholder: '', + ariaLabel: '', + visible: false, + hiddenControl: true, + readOnly: false, + maxLength: 0, + usable: false, + skipReason: '', + }; + + if (!element) { + summary.skipReason = 'missing_element'; + return summary; + } + + summary.visible = isVisibleElement(element); + summary.hiddenControl = isInsideHiddenPhoneControl(element); + summary.type = String(element.getAttribute?.('type') || element.type || '').trim().toLowerCase(); + summary.name = String(element.getAttribute?.('name') || element.name || '').trim(); + summary.id = String(element.getAttribute?.('id') || element.id || '').trim(); + summary.autocomplete = String(element.getAttribute?.('autocomplete') || '').trim().toLowerCase(); + summary.placeholder = String(element.getAttribute?.('placeholder') || '').trim().slice(0, 80); + summary.ariaLabel = String(element.getAttribute?.('aria-label') || '').trim().slice(0, 80); + + const hasReadonlyAttribute = typeof element.hasAttribute === 'function' + ? element.hasAttribute('readonly') + : element.readOnly === true; + summary.readOnly = element.readOnly === true + || hasReadonlyAttribute + || String(element.getAttribute?.('aria-readonly') || '').trim().toLowerCase() === 'true'; + summary.maxLength = Number(element.getAttribute?.('maxlength') || element.maxLength || 0); + + if (summary.hiddenControl) { + summary.skipReason = 'inside_hidden_control'; + return summary; + } + if (!summary.visible) { + summary.skipReason = 'not_visible'; + return summary; + } + if (summary.type === 'hidden') { + summary.skipReason = 'hidden_type'; + return summary; + } + if (summary.readOnly) { + summary.skipReason = 'readonly'; + return summary; + } + if (summary.maxLength === 6) { + summary.skipReason = 'verification_code_input'; + return summary; + } + + const normalizedName = summary.name.toLowerCase(); + const normalizedId = summary.id.toLowerCase(); + const combinedText = `${normalizedName} ${normalizedId} ${summary.placeholder} ${summary.ariaLabel}`; + if ( + summary.type === 'tel' + || summary.autocomplete === 'tel' + || /phone|tel/i.test(`${normalizedName} ${normalizedId}`) + || /手机|电话|手机号|电话号码|国家号码|phone|mobile|telephone/i.test(combinedText) + ) { + summary.usable = true; + return summary; + } + + if (options.allowGenericText && (!summary.type || summary.type === 'text')) { + summary.usable = true; + return summary; + } + + summary.skipReason = 'not_phone_like'; + return summary; +} + +function isUsablePhoneInputElement(element, options = {}) { + return summarizePhoneInputCandidate(element, options).usable; +} + +function collectPhoneInputCandidates(selector, options = {}) { + return Array.from(document.querySelectorAll(selector)) + .map((element) => summarizePhoneInputCandidate(element, options)); +} + +function findUsablePhoneInput(selector, options = {}) { + return Array.from(document.querySelectorAll(selector)) + .find((element) => isUsablePhoneInputElement(element, options)) || null; +} + function getLoginEmailInput() { const input = document.querySelector( 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]' ); + if (isLoginPhoneUsernameKind() || isLoginPhoneEntryPageText()) { + return null; + } return input && isVisibleElement(input) ? input : null; } function getLoginPhoneInput() { - const input = document.querySelector([ + const phonePage = isLoginPhoneUsernameKind() || isLoginPhoneEntryPageText(); + const selector = [ 'input[type="tel"]:not([maxlength="6"])', + 'input[name*="phone" i]:not([type="hidden"])', + 'input[id*="phone" i]:not([type="hidden"])', 'input[autocomplete="tel"]', 'input[inputmode="tel"]', - 'input[name*="phone" i]', - 'input[id*="phone" i]', - 'input[name*="telephone" i]', - 'input[id*="telephone" i]', 'input[placeholder*="phone" i]', 'input[aria-label*="phone" i]', 'input[placeholder*="telephone" i]', @@ -2956,71 +3352,57 @@ function getLoginPhoneInput() { 'input[aria-label*="手机"]', 'input[placeholder*="电话"]', 'input[aria-label*="电话"]', - ].join(', ')); - if (input && isVisibleElement(input)) { - return input; - } + phonePage ? 'input[name="username"]:not([maxlength="6"])' : '', + phonePage ? 'input[id*="username" i]:not([maxlength="6"])' : '', + phonePage ? 'input[autocomplete="username"]:not([maxlength="6"])' : '', + phonePage ? 'input[type="text"]:not([maxlength="6"])' : '', + ].filter(Boolean).join(', '); + return findUsablePhoneInput(selector, { allowGenericText: phonePage }); +} - return Array.from(document.querySelectorAll('input')).find((el) => { - if (!isVisibleElement(el)) return false; - const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase(); - if (['email', 'password', 'hidden', 'submit', 'button', 'checkbox', 'radio'].includes(type)) return false; - const maxLength = Number(el.getAttribute?.('maxlength') || el.maxLength || 0); - if (maxLength > 0 && maxLength <= 6) return false; +function getLoginPhoneInputDiagnostics(phoneInput) { + return { + tag: (phoneInput?.tagName || '').toLowerCase(), + type: String(phoneInput?.getAttribute?.('type') || phoneInput?.type || '').trim().toLowerCase(), + name: String(phoneInput?.getAttribute?.('name') || phoneInput?.name || '').trim(), + id: String(phoneInput?.getAttribute?.('id') || phoneInput?.id || '').trim(), + autocomplete: String(phoneInput?.getAttribute?.('autocomplete') || '').trim().toLowerCase(), + placeholder: String(phoneInput?.getAttribute?.('placeholder') || '').trim().slice(0, 80), + ariaLabel: String(phoneInput?.getAttribute?.('aria-label') || '').trim().slice(0, 80), + value: getPhoneInputRenderedValue(phoneInput), + }; +} - const id = String(el.getAttribute?.('id') || '').trim(); - const ariaLabelledBy = String(el.getAttribute?.('aria-labelledby') || '').trim(); - const labelTexts = []; - if (id && typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { - document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((label) => { - labelTexts.push(label.textContent || ''); - }); - } - if (ariaLabelledBy) { - ariaLabelledBy.split(/\s+/).forEach((labelId) => { - if (labelId && typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { - labelTexts.push(document.getElementById?.(labelId)?.textContent || ''); - } - }); - } - const closestLabel = el.closest?.('label'); - if (closestLabel) { - labelTexts.push(closestLabel.textContent || ''); - } - - const attributeText = [ - el.getAttribute?.('name'), - el.getAttribute?.('id'), - el.getAttribute?.('placeholder'), - el.getAttribute?.('aria-label'), - el.getAttribute?.('autocomplete'), - el.getAttribute?.('inputmode'), - ...labelTexts, - ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); - if (/phone|telephone|tel|手机|手机号|电话|电话号码/i.test(attributeText)) { - return true; - } - if (/email|邮箱|电子邮件/i.test(attributeText)) { - return false; - } - - const fieldRoot = el.closest?.('fieldset, [role="group"], [data-rac]'); - const fieldText = String(fieldRoot?.textContent || '').replace(/\s+/g, ' ').trim(); - if (fieldText && /phone|telephone|手机|手机号|电话|电话号码/i.test(fieldText) && !/email|邮箱|电子邮件/i.test(fieldText)) { - return true; - } - - const compactRoot = el.closest?.('div'); - const compactText = String(compactRoot?.textContent || '').replace(/\s+/g, ' ').trim(); - return Boolean( - compactText - && compactText.length <= 220 - && /phone|telephone|手机|手机号|电话|电话号码/i.test(compactText) - && !/email|邮箱|电子邮件/i.test(compactText) - ); +function getLoginPhoneHiddenValueInput(phoneInput) { + const form = phoneInput?.form || phoneInput?.closest?.('form') || null; + const root = form || phoneInput?.closest?.('fieldset, form, [data-rac], div') || document; + const candidates = Array.from(root?.querySelectorAll?.([ + 'input[name="phone"]', + 'input[name*="phone" i]', + 'input[name="phoneNumber"]', + 'input[name*="telephone" i]', + 'input[type="hidden"][id*="phone" i]', + 'input[type="hidden"][id*="telephone" i]', + 'input[type="hidden"][name*="phone" i]', + 'input[type="hidden"][name*="telephone" i]', + ].join(', ')) || []); + return candidates.find((input) => { + if (!input || input === phoneInput) return false; + const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase(); + return type === 'hidden' || !isVisibleElement(input); }) || null; } +function getLoginPhoneHiddenValueDiagnostics(hiddenInput) { + return { + tag: (hiddenInput?.tagName || '').toLowerCase(), + type: String(hiddenInput?.getAttribute?.('type') || hiddenInput?.type || '').trim().toLowerCase(), + name: String(hiddenInput?.getAttribute?.('name') || hiddenInput?.name || '').trim(), + id: String(hiddenInput?.getAttribute?.('id') || hiddenInput?.id || '').trim(), + value: String(hiddenInput?.value || hiddenInput?.getAttribute?.('value') || '').trim(), + }; +} + function getLoginPasswordInput() { const input = document.querySelector('input[type="password"]'); return input && isVisibleElement(input) ? input : null; @@ -3258,6 +3640,7 @@ function inspectLoginAuthState() { const submitButton = getLoginSubmitButton({ allowDisabled: true }); const verificationVisible = isVerificationPageStillVisible(); const addPhonePage = isAddPhonePageReady(); + const addEmailPage = isAddEmailPageReady(); const phoneVerificationPage = isPhoneVerificationPageReady(); const consentReady = isStep8Ready(); const oauthConsentPage = isOAuthConsentPage(); @@ -3271,6 +3654,7 @@ function inspectLoginAuthState() { titleMatched: Boolean(retryState?.titleMatched), detailMatched: Boolean(retryState?.detailMatched), maxCheckAttemptsBlocked: Boolean(retryState?.maxCheckAttemptsBlocked), + emailInUseBlocked: Boolean(retryState?.emailInUseBlocked), verificationTarget, passwordInput, emailInput, @@ -3282,6 +3666,7 @@ function inspectLoginAuthState() { moreOptionsTrigger, verificationVisible, addPhonePage, + addEmailPage, phoneVerificationPage, oauthConsentPage, consentReady, @@ -3316,6 +3701,13 @@ function inspectLoginAuthState() { }; } + if (addEmailPage) { + return { + ...baseState, + state: 'add_email_page', + }; + } + if (passwordInput || switchTrigger) { return { ...baseState, @@ -3371,6 +3763,7 @@ function serializeLoginAuthState(snapshot) { titleMatched: Boolean(snapshot?.titleMatched), detailMatched: Boolean(snapshot?.detailMatched), maxCheckAttemptsBlocked: Boolean(snapshot?.maxCheckAttemptsBlocked), + emailInUseBlocked: Boolean(snapshot?.emailInUseBlocked), hasVerificationTarget: Boolean(snapshot?.verificationTarget), hasPasswordInput: Boolean(snapshot?.passwordInput), hasEmailInput: Boolean(snapshot?.emailInput), @@ -3382,6 +3775,7 @@ function serializeLoginAuthState(snapshot) { hasMoreOptionsTrigger: Boolean(snapshot?.moreOptionsTrigger), verificationVisible: Boolean(snapshot?.verificationVisible), addPhonePage: Boolean(snapshot?.addPhonePage), + addEmailPage: Boolean(snapshot?.addEmailPage), phoneVerificationPage: Boolean(snapshot?.phoneVerificationPage), oauthConsentPage: Boolean(snapshot?.oauthConsentPage), consentReady: Boolean(snapshot?.consentReady), @@ -3409,6 +3803,8 @@ function getLoginAuthStateLabel(snapshot) { return '登录入口页'; case 'add_phone_page': return '手机号页'; + case 'add_email_page': + return '添加邮箱页'; default: return '未知页面'; } @@ -3485,6 +3881,17 @@ function createStep6OAuthConsentSuccessResult(snapshot, options = {}) { }); } +function createStep6AddEmailSuccessResult(snapshot, options = {}) { + return { + ...createStep6SuccessResult(snapshot, { + ...options, + via: options.via || 'add_email_page', + loginVerificationRequestedAt: null, + }), + addEmailPage: true, + }; +} + function createStep6RecoverableResult(reason, snapshot, options = {}) { return { step6Outcome: 'recoverable', @@ -3548,6 +3955,15 @@ async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, messa }; } + if (resolvedSnapshot.state === 'add_email_page') { + return { + action: 'done', + result: createStep6AddEmailSuccessResult(resolvedSnapshot, { + via: `${via}_add_email`, + }), + }; + } + if (resolvedSnapshot.state === 'password_page') { log('登录超时报错页恢复后已进入密码页,继续当前登录流程。', 'warn', { step: visibleStep, stepKey: 'oauth-login' }); return { action: 'password', snapshot: resolvedSnapshot }; @@ -3625,6 +4041,13 @@ async function finalizeStep6VerificationReady(options = {}) { }); } + if (snapshot.state === 'add_email_page') { + log('认证页已进入添加邮箱页,登录阶段完成。', 'ok', { step: visibleStep, stepKey: 'oauth-login' }); + return createStep6AddEmailSuccessResult(snapshot, { + via: `${via}_add_email`, + }); + } + if (snapshot.state === 'login_timeout_error_page') { log(`页面进入登录超时报错页,准备自动恢复后重试步骤 ${visibleStep}。`, 'warn', { step: visibleStep, stepKey: 'oauth-login' }); return createStep6LoginTimeoutRecoverableResult( @@ -3666,6 +4089,12 @@ async function finalizeStep6VerificationReady(options = {}) { via: `${via}_oauth_consent`, }); } + if (snapshot.state === 'add_email_page') { + log('认证页已进入添加邮箱页,登录阶段完成。', 'ok', { step: visibleStep, stepKey: 'oauth-login' }); + return createStep6AddEmailSuccessResult(snapshot, { + via: `${via}_add_email`, + }); + } if (snapshot.state === 'login_timeout_error_page') { log(`页面进入登录超时报错页,准备自动恢复后重试步骤 ${visibleStep}。`, 'warn', { step: visibleStep, stepKey: 'oauth-login' }); return createStep6LoginTimeoutRecoverableResult( @@ -3924,6 +4353,12 @@ async function waitForVerificationSubmitOutcome(step, timeout) { if (retryState?.userAlreadyExistsBlocked) { throw createSignupUserAlreadyExistsError(); } + if (step === 8 && retryState?.emailInUseBlocked) { + throw createStep8EmailInUseError(); + } + if (step === 8 && retryState?.maxCheckAttemptsBlocked) { + throw createAuthMaxCheckAttemptsError(); + } if (retryState) { if (recoveryCount >= maxRecoveryCount) { throw new Error(`步骤 ${step}:验证码提交后连续进入认证重试页 ${maxRecoveryCount} 次,页面仍未恢复。URL: ${location.href}`); @@ -4316,6 +4751,15 @@ async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { }; } + if (normalizedSnapshot.state === 'add_email_page') { + return { + action: 'done', + result: createStep6AddEmailSuccessResult(normalizedSnapshot, { + via: `${via}_add_email`, + }), + }; + } + if (normalizedSnapshot.state === 'login_timeout_error_page') { const transition = await createStep6LoginTimeoutRecoveryTransition( timeoutRecoveryReason, @@ -4561,6 +5005,11 @@ async function step6OpenLoginEntry(payload, snapshot) { via: 'entry_open_oauth_consent_page', }); } + if (nextSnapshot.state === 'add_email_page') { + return createStep6AddEmailSuccessResult(nextSnapshot, { + via: 'entry_open_add_email_page', + }); + } if (nextSnapshot.state === 'login_timeout_error_page') { const transition = await createStep6LoginTimeoutRecoveryTransition( 'login_timeout_after_entry_open', @@ -4597,7 +5046,7 @@ async function step6SwitchToOneTimeCodeLogin(payload, snapshot) { await sleep(1200); const result = await waitForStep6SwitchTransition(loginVerificationRequestedAt, 10000, { visibleStep }); if (result?.step6Outcome === 'success') { - if (result.skipLoginVerificationStep) { + if (result.skipLoginVerificationStep || result.addEmailPage) { return result; } return finalizeStep6VerificationReady({ @@ -4648,23 +5097,56 @@ async function step6LoginFromPhonePage(payload, snapshot) { throw new Error(`步骤 ${visibleStep}:手机号为空,无法填写。`); } + log( + `步骤 ${visibleStep}:手机号登录填写前诊断 ${JSON.stringify({ + phoneNumber, + countryLabel, + countryId, + dialCode, + input: getLoginPhoneInputDiagnostics(phoneInput), + candidates: getLoginPhoneInputCandidateDiagnostics(), + })}`, + 'info', + { step: visibleStep, stepKey: 'oauth-login' } + ); log(`步骤 ${visibleStep}:正在填写手机号 ${phoneNumber}...`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); await humanPause(500, 1400); const fillResult = await fillLoginPhoneInputAndConfirm(phoneInput, { phoneNumber, dialCode, visibleStep, + resolvePhoneInput: () => getLoginPhoneInput() || phoneInput, }); - log(`步骤 ${visibleStep}:手机号已填写${dialCode ? `(区号 +${dialCode},本地号 ${fillResult.inputValue})` : ''}。`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); + log(`步骤 ${visibleStep}:手机号已填写${dialCode ? `(区号 +${dialCode},本地号 ${fillResult.inputValue},可见提交值 ${fillResult.attemptedValue})` : ''}。`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); await sleep(500); + const verifiedPhoneInput = fillResult.input || phoneInput; + const hiddenSync = syncPhoneHiddenFormValue(verifiedPhoneInput, { phoneNumber, dialCode, inputValue }); + const submitButton = getLoginSubmitButton({ allowDisabled: true }) || currentSnapshot.submitButton; + const preSubmitRenderedValue = getPhoneInputRenderedValue(verifiedPhoneInput); + const preSubmitHiddenInput = hiddenSync?.input || getPhoneHiddenValueInput(verifiedPhoneInput); + const preSubmitDiagnostics = { + renderedValue: preSubmitRenderedValue, + inputVerified: isPhoneInputValueComplete(verifiedPhoneInput, phoneNumber, dialCode, inputValue), + input: getLoginPhoneInputDiagnostics(verifiedPhoneInput), + hidden: getLoginPhoneHiddenValueDiagnostics(preSubmitHiddenInput), + submitButton: getLoginPhoneSubmitButtonDiagnostics(submitButton), + }; + log( + `步骤 ${visibleStep}:手机号提交前复查 ${JSON.stringify(preSubmitDiagnostics)}`, + 'info', + { step: visibleStep, stepKey: 'oauth-login' } + ); + if (!preSubmitDiagnostics.inputVerified) { + throw new Error(`步骤 ${visibleStep}:提交前手机号输入框复查失败,完整号码 ${phoneNumber},区号 +${dialCode || '未识别'},期望本地号 ${inputValue},当前输入框为 ${normalizePhoneDigits(preSubmitRenderedValue) || '空'},已停止提交。`); + } const phoneSubmittedAt = Date.now(); - await triggerLoginSubmitAction(currentSnapshot.submitButton, phoneInput); + await triggerLoginSubmitAction(submitButton, verifiedPhoneInput); log(`步骤 ${visibleStep}:手机号已提交。`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); const transition = await waitForStep6PhoneSubmitTransition(phoneSubmittedAt, 12000, { visibleStep }); if (transition.action === 'done') { - if (transition.result?.skipLoginVerificationStep) { + if (transition.result?.skipLoginVerificationStep || transition.result?.addEmailPage) { return transition.result; } return finalizeStep6VerificationReady({ @@ -4744,6 +5226,11 @@ async function switchFromEmailPageToPhoneLogin(payload, snapshot) { via: 'phone_entry_switch_oauth_consent_page', }); } + if (nextSnapshot.state === 'add_email_page') { + return createStep6AddEmailSuccessResult(nextSnapshot, { + via: 'phone_entry_switch_add_email_page', + }); + } if (nextSnapshot.state === 'login_timeout_error_page') { const transition = await createStep6LoginTimeoutRecoveryTransition( 'login_timeout_after_phone_entry_switch', @@ -4795,7 +5282,7 @@ async function step6LoginFromPasswordPage(payload, snapshot) { const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt, 10000, { visibleStep }); if (transition.action === 'done') { - if (transition.result?.skipLoginVerificationStep) { + if (transition.result?.skipLoginVerificationStep || transition.result?.addEmailPage) { return transition.result; } return finalizeStep6VerificationReady({ @@ -4861,7 +5348,7 @@ async function step6LoginFromEmailPage(payload, snapshot) { const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt, 12000, { visibleStep }); if (transition.action === 'done') { - if (transition.result?.skipLoginVerificationStep) { + if (transition.result?.skipLoginVerificationStep || transition.result?.addEmailPage) { return transition.result; } return finalizeStep6VerificationReady({ @@ -4916,6 +5403,13 @@ async function step6_login(payload) { }); } + if (snapshot.state === 'add_email_page') { + log('认证页已在添加邮箱页,登录阶段完成。', 'ok', { step: visibleStep, stepKey: 'oauth-login' }); + return createStep6AddEmailSuccessResult(snapshot, { + via: 'already_on_add_email_page', + }); + } + if (snapshot.state === 'login_timeout_error_page') { log('检测到登录超时报错页,先尝试恢复当前页面。', 'warn', { step: visibleStep, stepKey: 'oauth-login' }); const transition = await createStep6LoginTimeoutRecoveryTransition( @@ -4930,7 +5424,7 @@ async function step6_login(payload) { } ); if (transition.action === 'done') { - if (transition.result?.skipLoginVerificationStep) { + if (transition.result?.skipLoginVerificationStep || transition.result?.addEmailPage) { return transition.result; } return finalizeStep6VerificationReady({ @@ -4977,6 +5471,129 @@ async function step6_login(payload) { throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`); } +async function waitForAddEmailPageReady(timeout = 15000) { + const start = Date.now(); + let sawAddEmailPage = false; + while (Date.now() - start < timeout) { + throwIfStopped(); + if (isAddEmailPageReady()) { + sawAddEmailPage = true; + const snapshot = inspectLoginAuthState(); + if (snapshot.emailInput || getLoginEmailInput()) { + return snapshot; + } + } + await sleep(200); + } + if (sawAddEmailPage) { + throw new Error('等待添加邮箱页面输入框就绪超时。URL: ' + location.href); + } + throw new Error('等待添加邮箱页面就绪超时。URL: ' + location.href); +} + +async function waitForAddEmailSubmitOutcome(timeout = 45000) { + const start = Date.now(); + let lastState = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + lastState = inspectLoginAuthState(); + + if (lastState.state === 'verification_page') { + return { + success: true, + verificationPage: true, + displayedEmail: getLoginVerificationDisplayedEmail(), + url: location.href, + }; + } + if (lastState.state === 'oauth_consent_page') { + return { + success: true, + directOAuthConsentPage: true, + url: location.href, + }; + } + if (lastState.state === 'login_timeout_error_page') { + return { + retryPage: true, + maxCheckAttempts: Boolean(lastState.maxCheckAttemptsBlocked), + emailInUse: Boolean(lastState.emailInUseBlocked), + url: location.href, + }; + } + + const errorText = getVerificationErrorText(); + if (errorText) { + return { + errorText, + url: location.href, + }; + } + + const addEmailErrorText = isAddEmailPageReady() ? getVisibleFieldErrorText() : ''; + if (addEmailErrorText) { + return { + errorText: addEmailErrorText, + url: location.href, + }; + } + + await sleep(200); + } + + throw new Error(`提交邮箱后未进入验证码页。当前状态:${getLoginAuthStateLabel(lastState)}。URL: ${lastState?.url || location.href}`); +} + +async function submitAddEmailAndContinue(payload = {}) { + const email = String(payload.email || '').trim().toLowerCase(); + if (!email) { + throw new Error('未提供邮箱地址,无法添加邮箱。'); + } + + const snapshot = await waitForAddEmailPageReady(); + const emailInput = snapshot.emailInput || getLoginEmailInput(); + if (!emailInput) { + throw new Error('添加邮箱页未找到邮箱输入框。URL: ' + location.href); + } + + await humanPause(500, 1400); + fillInput(emailInput, email); + log(`步骤 8:已填写邮箱:${email}`); + + await sleep(500); + const submitButton = snapshot.submitButton || getLoginSubmitButton({ allowDisabled: true }); + if (!submitButton || !isActionEnabled(submitButton)) { + throw new Error('添加邮箱页未找到可点击的继续按钮。URL: ' + location.href); + } + + await triggerLoginSubmitAction(submitButton, emailInput); + log('步骤 8:已提交邮箱,正在等待邮箱验证码页...'); + + const outcome = await waitForAddEmailSubmitOutcome(); + if (outcome.errorText && (SIGNUP_EMAIL_EXISTS_PATTERN.test(outcome.errorText) || /email_in_use/i.test(outcome.errorText))) { + throw createStep8EmailInUseError(); + } + if (outcome.errorText) { + throw new Error(`添加邮箱失败:${outcome.errorText}`); + } + if (outcome.emailInUse) { + throw createStep8EmailInUseError(); + } + if (outcome.maxCheckAttempts) { + throw createAuthMaxCheckAttemptsError(); + } + if (outcome.retryPage) { + throw new Error(`添加邮箱后进入认证重试页,请重新执行步骤 8。URL: ${outcome.url}`); + } + + return { + submitted: true, + email, + ...outcome, + }; +} + // ============================================================ // Step 9: Find "继续" on OAuth consent page for debugger click // ============================================================ @@ -5008,6 +5625,7 @@ function getStep8State() { consentReady: isStep8Ready(), verificationPage: isVerificationPageStillVisible(), addPhonePage: isAddPhonePageReady(), + addEmailPage: isAddEmailPageReady(), phoneVerificationPage: isPhoneVerificationPageReady(), retryPage: Boolean(retryState), retryEnabled: Boolean(retryState?.retryEnabled), @@ -5086,6 +5704,9 @@ async function findContinueButton(timeout = 10000) { if (isAddPhonePageReady()) { throw new Error('当前页面已进入手机号页面,不是 OAuth 授权同意页。URL: ' + location.href); } + if (isAddEmailPageReady()) { + throw new Error('当前页面已进入添加邮箱页面,不是 OAuth 授权同意页。URL: ' + location.href); + } const button = getPrimaryContinueButton(); if (button && isStep8Ready()) { return button; diff --git a/tests/background-logging-status-module.test.js b/tests/background-logging-status-module.test.js index 5e66201..64f5e08 100644 --- a/tests/background-logging-status-module.test.js +++ b/tests/background-logging-status-module.test.js @@ -44,5 +44,6 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu true ); assert.equal(loggingStatus.getLoginAuthStateLabel('phone_verification_page'), '手机验证码页'); + assert.equal(loggingStatus.getLoginAuthStateLabel('add_email_page'), '添加邮箱页'); assert.equal(loggingStatus.getLoginAuthStateLabel('oauth_consent_page'), 'OAuth 授权页'); }); diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index 17ac5ba..3248750 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -85,16 +85,23 @@ test('step 8 submits login verification directly without replaying step 7', asyn { step8VerificationTargetEmail: 'display.user@example.com' }, ]); assert.deepStrictEqual(calls.ensureReadyOptions, [ - { visibleStep: 8, authLoginStep: 7, timeoutMs: 5000 }, + { + visibleStep: 8, + authLoginStep: 7, + allowPhoneVerificationPage: true, + allowAddEmailPage: true, + timeoutMs: 5000, + }, ]); assert.equal(calls.resolveOptions.completionStep, 8); }); -test('step 8 routes phone login verification through sms helper and skips mail provider setup', async () => { +test('step 8 keeps phone-registered accounts on email-code flow when page is email verification', async () => { const calls = { getMailConfigCalls: 0, helperCalls: [], completions: [], + resolveCalls: 0, }; const executor = api.createStep8Executor({ @@ -109,9 +116,7 @@ test('step 8 routes phone login verification through sms helper and skips mail p calls.completions.push({ step, payload }); }, confirmCustomVerificationStepBypass: async () => {}, - ensureStep8VerificationPageReady: async () => { - throw new Error('phone login branch should not probe email verification page readiness'); - }, + ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'phone-user@example.com' }), getOAuthFlowRemainingMs: async () => 5000, getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, getMailConfig: () => { @@ -141,7 +146,7 @@ test('step 8 routes phone login verification through sms helper and skips mail p }, resolveSignupMethod: () => 'phone', resolveVerificationStep: async () => { - throw new Error('phone login branch should not call email verification flow'); + calls.resolveCalls += 1; }, rerunStep7ForStep8Recovery: async () => { throw new Error('phone login branch should not rerun step 7 in this test'); @@ -164,6 +169,73 @@ test('step 8 routes phone login verification through sms helper and skips mail p oauthUrl: 'https://oauth.example/latest', }); + assert.equal(calls.getMailConfigCalls, 1); + assert.equal(calls.resolveCalls, 1); + assert.deepStrictEqual(calls.helperCalls, []); + assert.deepStrictEqual(calls.completions, []); +}); + +test('step 8 routes only a real phone verification page through sms helper', async () => { + const calls = { + getMailConfigCalls: 0, + helperCalls: [], + completions: [], + }; + + const executor = api.createStep8Executor({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async (step, payload) => { + calls.completions.push({ step, payload }); + }, + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async () => ({ state: 'phone_verification_page' }), + getOAuthFlowRemainingMs: async () => 5000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getMailConfig: () => { + calls.getMailConfigCalls += 1; + return { + provider: 'qq', + label: 'QQ 邮箱', + }; + }, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + phoneVerificationHelpers: { + completeLoginPhoneVerificationFlow: async (tabId, options) => { + calls.helperCalls.push({ tabId, visibleStep: options.visibleStep, state: options.state }); + return { code: '654321' }; + }, + }, + resolveVerificationStep: async () => { + throw new Error('real phone verification branch should not call email verification flow'); + }, + rerunStep7ForStep8Recovery: async () => { + throw new Error('real phone verification branch should not rerun step 7 in this test'); + }, + reuseOrCreateTab: async () => {}, + setState: async () => {}, + shouldUseCustomRegistrationEmail: () => false, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + await executor.executeStep8({ + visibleStep: 8, + accountIdentifierType: 'phone', + oauthUrl: 'https://oauth.example/latest', + }); + assert.equal(calls.getMailConfigCalls, 0); assert.deepStrictEqual(calls.helperCalls, [ { @@ -172,10 +244,6 @@ test('step 8 routes phone login verification through sms helper and skips mail p state: { visibleStep: 8, accountIdentifierType: 'phone', - signupPhoneCompletedActivation: { - activationId: 'signup-done', - phoneNumber: '66959916439', - }, oauthUrl: 'https://oauth.example/latest', }, }, @@ -192,6 +260,96 @@ test('step 8 routes phone login verification through sms helper and skips mail p ]); }); +test('step 8 submits add-email before polling the email verification code', async () => { + const calls = { + contentMessages: [], + resolvedStates: [], + setStates: [], + mailStates: [], + }; + + const executor = api.createStep8Executor({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async () => ({ state: 'add_email_page', url: 'https://auth.openai.com/add-email' }), + getOAuthFlowRemainingMs: async () => 5000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getMailConfig: (state) => { + calls.mailStates.push(state); + return { + provider: 'qq', + label: 'QQ 邮箱', + source: 'mail-qq', + url: 'https://mail.qq.com', + navigateOnReuse: false, + }; + }, + getState: async () => ({ + email: '', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2), + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + resolveSignupEmailForFlow: async (state) => { + calls.resolvedStates.push(state); + return 'new.user@example.com'; + }, + resolveVerificationStep: async (_step, state, _mail, options) => { + calls.resolvedVerification = { state, options }; + }, + rerunStep7ForStep8Recovery: async () => {}, + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async (_source, message) => { + calls.contentMessages.push(message); + assert.equal(message.type, 'SUBMIT_ADD_EMAIL'); + assert.equal(message.payload.email, 'new.user@example.com'); + return { + submitted: true, + displayedEmail: 'new.user@example.com', + url: 'https://auth.openai.com/email-verification', + }; + }, + setState: async (payload) => { + calls.setStates.push(payload); + }, + shouldUseCustomRegistrationEmail: () => false, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + await executor.executeStep8({ + visibleStep: 8, + accountIdentifierType: 'phone', + oauthUrl: 'https://oauth.example/latest', + }); + + assert.equal(calls.contentMessages.length, 1); + assert.equal(calls.resolvedStates.length, 1); + assert.equal(calls.mailStates[0].email, 'new.user@example.com'); + assert.equal(calls.resolvedVerification.state.email, 'new.user@example.com'); + assert.equal(calls.resolvedVerification.options.targetEmail, 'new.user@example.com'); + assert.deepStrictEqual(calls.setStates, [ + { + email: 'new.user@example.com', + step8VerificationTargetEmail: 'new.user@example.com', + }, + { + step8VerificationTargetEmail: 'new.user@example.com', + }, + ]); +}); + test('Plus login-code step reuses step 8 verification logic but completes visible step 11', async () => { let resolvedStep = null; let resolvedOptions = null; @@ -257,7 +415,13 @@ test('Plus login-code step reuses step 8 verification logic but completes visibl assert.equal(resolvedStep, 8); assert.equal(resolvedOptions.completionStep, 11); assert.equal(resolvedOptions.targetEmail, 'plus.user@example.com'); - assert.deepStrictEqual(readyOptions, { visibleStep: 11, authLoginStep: 10, timeoutMs: 9000 }); + assert.deepStrictEqual(readyOptions, { + visibleStep: 11, + authLoginStep: 10, + allowPhoneVerificationPage: true, + allowAddEmailPage: true, + timeoutMs: 9000, + }); assert.deepStrictEqual(remainingStepCalls, [11, 11]); }); diff --git a/tests/step6-login-state.test.js b/tests/step6-login-state.test.js index 3d4f992..da4d27d 100644 --- a/tests/step6-login-state.test.js +++ b/tests/step6-login-state.test.js @@ -124,6 +124,10 @@ function isAddPhonePageReady() { return ${JSON.stringify(Boolean(overrides.addPhonePage))}; } +function isAddEmailPageReady() { + return ${JSON.stringify(Boolean(overrides.addEmailPage))}; +} + function isVisibleElement() { return true; } @@ -261,6 +265,20 @@ return { assert.strictEqual(snapshot.state, 'phone_entry_page'); } +{ + const api = createApi({ + pathname: '/add-email', + href: 'https://auth.openai.com/add-email', + emailInput: { id: 'email' }, + submitButton: { id: 'submit' }, + addEmailPage: true, + }); + + const snapshot = api.inspectLoginAuthState(); + assert.strictEqual(snapshot.state, 'add_email_page'); + assert.strictEqual(snapshot.addEmailPage, true); +} + assert.ok( extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"), 'inspectLoginAuthState 应产出 oauth_consent_page 状态' @@ -271,4 +289,9 @@ assert.ok( 'inspectLoginAuthState 应产出 phone_entry_page 状态' ); +assert.ok( + extractFunction('inspectLoginAuthState').includes("state: 'add_email_page'"), + 'inspectLoginAuthState 应产出 add_email_page 状态' +); + console.log('step6 login state tests passed'); diff --git a/tests/step7-phone-login-entry.test.js b/tests/step7-phone-login-entry.test.js index b402b16..3eecd30 100644 --- a/tests/step7-phone-login-entry.test.js +++ b/tests/step7-phone-login-entry.test.js @@ -68,13 +68,15 @@ function createPhoneLoginEntryApi(options = {}) { inputRootText = '', pageText = '', addPhoneForm = false, + phoneUsernameKind = false, } = options; return new Function(` ${extractConst('ADD_PHONE_PAGE_PATTERN')} +${extractConst('LOGIN_PHONE_ENTRY_PAGE_PATTERN')} const location = { - href: ${JSON.stringify(href)}, + href: ${JSON.stringify(phoneUsernameKind ? `${href}${href.includes('?') ? '&' : '?'}usernameKind=phone_number` : href)}, pathname: ${JSON.stringify(pathname)}, }; @@ -107,7 +109,7 @@ const document = { return null; }, querySelectorAll(selector) { - if (selector === 'input') return [phoneInput]; + if (String(selector || '').includes('input')) return [phoneInput]; return []; }, getElementById() { @@ -125,7 +127,18 @@ function isVisibleElement(element) { return Boolean(element); } +function isPhoneVerificationPageReady() { + return false; +} + ${extractFunction('getPageTextSnapshot')} +${extractFunction('isLoginPhoneUsernameKind')} +${extractFunction('isLoginPhoneEntryPageText')} +${extractFunction('isInsideHiddenPhoneControl')} +${extractFunction('summarizePhoneInputCandidate')} +${extractFunction('isUsablePhoneInputElement')} +${extractFunction('collectPhoneInputCandidates')} +${extractFunction('findUsablePhoneInput')} ${extractFunction('getLoginPhoneInput')} ${extractFunction('isAddPhonePageReady')} @@ -155,6 +168,26 @@ test('step 7 does not mistake email entry with a phone switch action for phone i assert.equal(api.isAddPhonePageReady(), false); }); +test('step 7 detects username text input when usernameKind is phone_number', () => { + const api = createPhoneLoginEntryApi({ + phoneUsernameKind: true, + pageText: '\u6b22\u8fce\u56de\u6765', + inputAttributes: { type: 'text', name: 'username', autocomplete: 'username' }, + }); + + assert.ok(api.getLoginPhoneInput(), 'username text input should be treated as phone on phone login url'); +}); + +test('step 7 ignores hidden phone inputs while resolving login phone entry', () => { + const api = createPhoneLoginEntryApi({ + phoneUsernameKind: true, + pageText: '\u7535\u8bdd\u53f7\u7801', + inputAttributes: { type: 'hidden', name: 'phone' }, + }); + + assert.equal(api.getLoginPhoneInput(), null); +}); + test('add-phone detection stays true for real add-phone urls and forms', () => { assert.equal( createPhoneLoginEntryApi({ @@ -312,29 +345,73 @@ return { assert.deepEqual(api.getClicks(), ['\u6fb3\u5927\u5229\u4e9a (+61)', '\u82f1\u56fd +(44)']); }); -function createPhoneFillApi(fillBehavior) { +function createPhoneFillApi(fillBehavior, options = {}) { + const { + initialValue = '+44', + } = options; + return new Function('fillBehavior', ` const fills = []; const phoneInput = { - value: '+44', + value: ${JSON.stringify(initialValue)}, + form: null, getAttribute(name) { return name === 'value' ? this.value : ''; }, + focus() {}, + closest() { + return this.form; + }, }; +const hiddenPhoneInput = { + type: 'hidden', + value: '', + events: [], + getAttribute(name) { + if (name === 'type') return 'hidden'; + if (name === 'name') return 'phone'; + return ''; + }, + dispatchEvent(event) { + this.events.push(event.type); + }, +}; + +const root = { + querySelectorAll(selector) { + if (String(selector).includes('input[name="phone"]')) { + return [hiddenPhoneInput]; + } + return []; + }, +}; +phoneInput.form = root; + function fillInput(input, value) { fills.push(value); fillBehavior(input, value); } async function sleep() {} +function throwIfStopped() {} +function isVisibleElement() { return false; } +function log() {} ${extractFunction('normalizePhoneDigits')} ${extractFunction('toNationalPhoneNumber')} ${extractFunction('toE164PhoneNumber')} ${extractFunction('getPhoneInputRenderedValue')} +${extractFunction('isPhoneInputValueVerified')} +${extractFunction('waitForPhoneInputValue')} +${extractFunction('formatPhoneHiddenFormValue')} +${extractFunction('getPhoneHiddenValueInput')} +${extractFunction('setPhoneHiddenValue')} +${extractFunction('syncPhoneHiddenFormValue')} ${extractFunction('isPhoneInputValueComplete')} ${extractFunction('getLoginPhoneFillCandidates')} +${extractFunction('getLoginPhoneInputDiagnostics')} +${extractFunction('getLoginPhoneHiddenValueDiagnostics')} ${extractFunction('fillLoginPhoneInputAndConfirm')} return { @@ -343,6 +420,7 @@ return { phoneNumber: '447780579093', dialCode: '44', visibleStep: 7, + maxAttempts: 2, }); }, getFills() { @@ -351,13 +429,19 @@ return { getValue() { return phoneInput.value; }, + getHiddenValue() { + return hiddenPhoneInput.value; + }, + getHiddenEvents() { + return hiddenPhoneInput.events.slice(); + }, }; `)(fillBehavior); } -test('step 7 retries phone fill with e164 when the auth input keeps only the dial code', async () => { +test('step 7 keeps visible dial prefix when filling phone login and syncs the hidden value', async () => { const api = createPhoneFillApi((input, value) => { - input.value = value === '7780579093' ? '+44' : value; + input.value = value; }); const result = await api.run(); @@ -365,7 +449,24 @@ test('step 7 retries phone fill with e164 when the auth input keeps only the dia assert.equal(result.inputValue, '7780579093'); assert.equal(result.attemptedValue, '+447780579093'); assert.equal(api.getValue(), '+447780579093'); - assert.deepEqual(api.getFills(), ['7780579093', '+447780579093']); + assert.equal(api.getHiddenValue(), '+447780579093'); + assert.deepEqual(api.getHiddenEvents(), ['input', 'change']); + assert.deepEqual(api.getFills(), ['+447780579093']); +}); + +test('step 7 keeps national phone fill when visible login input has no dial prefix', async () => { + const api = createPhoneFillApi((input, value) => { + input.value = value; + }, { initialValue: '' }); + + const result = await api.run(); + + assert.equal(result.inputValue, '7780579093'); + assert.equal(result.attemptedValue, '7780579093'); + assert.equal(api.getValue(), '7780579093'); + assert.equal(api.getHiddenValue(), '+447780579093'); + assert.deepEqual(api.getHiddenEvents(), ['input', 'change']); + assert.deepEqual(api.getFills(), ['7780579093']); }); test('step 7 stops before submit when phone fill never includes the local number', async () => { @@ -375,5 +476,5 @@ test('step 7 stops before submit when phone fill never includes the local number await assert.rejects(api.run, /7780579093/); assert.equal(api.getValue(), '+44'); - assert.deepEqual(api.getFills(), ['7780579093', '+447780579093', '447780579093']); + assert.deepEqual(api.getFills(), ['+447780579093', '7780579093', '+447780579093', '7780579093']); }); diff --git a/tests/step8-restart-step7-error.test.js b/tests/step8-restart-step7-error.test.js index 8303e01..0f2c612 100644 --- a/tests/step8-restart-step7-error.test.js +++ b/tests/step8-restart-step7-error.test.js @@ -115,6 +115,37 @@ return { ); }); +test('ensureStep8VerificationPageReady allows add-email handoff only when requested', async () => { + const api = new Function(` +function getLoginAuthStateLabel(state) { + return state === 'add_email_page' ? '添加邮箱页' : 'unknown page'; +} + +async function getLoginAuthStateFromContent() { + return { + state: 'add_email_page', + url: 'https://auth.openai.com/add-email', + }; +} + +${extractFunction(backgroundSource, 'ensureStep8VerificationPageReady')} + +return { + run(options) { + return ensureStep8VerificationPageReady(options || {}); + }, +}; +`)(); + + await assert.rejects( + () => api.run({}), + /当前未进入登录验证码页面/ + ); + + const result = await api.run({ allowAddEmailPage: true }); + assert.equal(result.state, 'add_email_page'); +}); + test('step 8 reruns step 7 when auth page enters login timeout retry state', async () => { const calls = { rerunStep7: 0, diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 168c9bb..31ab652 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -366,9 +366,9 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 这两步共享验证码主流程: -1. 先按账号身份分流验证码通道: +1. 先按注册阶段或认证页真实状态分流验证码通道: Step 4:邮箱注册走邮箱验证码,手机号注册走短信验证码 - Step 8:邮箱登录走邮箱验证码,手机号登录走短信验证码 + Step 8:真实邮箱验证码页走邮箱验证码,真实手机验证码页才走短信验证码;如果先进入 `add-email`,先绑定邮箱再走邮箱验证码 2. 确定 provider 3. 必要时重发验证码 4. 轮询邮箱、短信 API 或对应 provider 通道 @@ -438,7 +438,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 - 邮箱账号:沿用现有邮箱登录链路 - 手机号账号:优先探测登录页是否存在手机号登录入口,必要时展开更多选项或从邮箱页切到手机号页;进入手机号输入页后会复用 Step 2 的可视国家下拉框同步逻辑,按本轮手机号最长区号切到目标国家,填写后还会读回输入框确认已包含本地号码,避免英国 `+44` 号码仍按默认澳大利亚 `+61` 提交或只提交区号 4. 登录;如果进入密码页且当前有密码,则填写并提交密码;如果当前没有密码但检测到一次性验证码入口,则直接切换到一次性验证码登录 -5. 确保真正进入验证码页;手机号登录允许以 `phone-verification` 作为 Step 7 的成功落点 +5. 确保真正进入验证码页或登录后续页;手机号登录允许以 `phone-verification` 作为 Step 7 的成功落点,手机号注册后的 OAuth 登录也允许以真实 `add-email` 作为 Step 7 完成并交给 Step 8 接管 6. 如果页面没有可用的手机号登录入口,则 Step 7 直接抛出明确业务错误,不再误进入 Step 8 邮箱轮询 7. 如果未进入验证码页,则按可恢复逻辑最多重试 3 次;但一旦当前失败已经明确进入 `https://auth.openai.com/add-phone`,则立即退出步骤 7 内部重试,不再继续第 2 / 3 次尝试 8. 自动运行一旦进入步骤 7 之后的链路,若后续步骤报错且认证页未进入 `https://auth.openai.com/add-phone`,则统一回到步骤 7 重新开始授权流程 @@ -457,19 +457,21 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 流程: -1. 先按账号身份判断当前登录验证码通道 -2. 邮箱账号: - - 确认当前认证页已经进入邮箱验证码页 +1. 先确认当前认证页真实状态,而不是只看 `accountIdentifierType`。 +2. 当前是 `add-email`: + - 调用 `resolveSignupEmailForFlow` 获取或生成本轮绑定邮箱 + - 内容脚本发送 `SUBMIT_ADD_EMAIL` 填写邮箱并提交 + - 页面进入邮箱验证码页后,用绑定邮箱作为 `step8VerificationTargetEmail` +3. 当前是邮箱验证码页: - 对非 2925 provider,固定当前验证码页显示邮箱为本次 Step 8 的目标邮箱 - 打开邮箱页或 API 轮询入口 - 轮询登录验证码并回填 -3. 手机号账号: - - 直接复用注册成功后的同一手机号订单,不重新申请新号码 - - 重新激活该号码,轮询短信验证码 +4. 当前是真实 `phone-verification` 页: + - 才复用手机号验证码共享流程,重新激活同一号码并轮询短信验证码 - 在当前 `phone-verification` 页回填短信验证码 -4. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则步骤 8 会保留“登录验证码已提交成功”的结果,并把后续手机号验证需求继续交给步骤 9 处理 -5. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试 -6. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9 +5. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则邮箱注册模式下交给后续 OAuth 后置手机号验证链路;手机号注册模式不把 `signupPhone*` 身份误当成官方 add-phone 订单 +6. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试;若仍停在验证码页、手机验证码页或 add-email 页,则优先在当前链路重试 +7. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9 补充: @@ -477,7 +479,8 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 - 对 `2925 receive` 而言,Step 8 会把当前目标注册邮箱一并传给 2925 内容脚本;只有当邮件里显式写出了其他邮箱时才会跳过,从而在不破坏历史兼容性的前提下,尽量降低误收验证码的概率。 - 对 `custom` provider 而言,Step 8 仍使用手动验证码确认弹窗;弹窗当前额外提供“出现手机号验证”按钮,点击后会直接抛出与真实 `add-phone` 页面一致的 fatal 错误,供 Auto 按既有 add-phone 分支继续下一邮箱。 - 当登录验证码提交后进入 `phone-verification` 页时,内容脚本会显式把该页面识别为“手机验证码页”,避免与邮箱验证码页混淆。 -- 手机号登录分支不会再读取邮箱 provider 配置,也不会误调用邮箱轮询;如果缺少可复用的注册手机号激活记录,会直接要求回到步骤 7 重新开始登录。 +- 手机号注册身份本身不会让 Step 8 自动走短信;只有真实页面状态是 `phone_verification_page` 时才调用短信 helper。手机号注册登录后出现 `add-email` 时,Step 8 走“绑定邮箱 -> 邮箱验证码”链路。 +- `email_in_use` 会在 Step 8 当前步骤内清理当前邮箱并重新打开 `add-email` 获取新邮箱;`max_check_attempts` 不继续点击认证页 `重试`,只恢复当前 Step 8。 ### Step 9 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index a3fb19d..05c3f07 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -54,7 +54,7 @@ - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;`LOG / STEP_COMPLETE / STEP_ERROR` 会把消息里的真实 `step` 继续传给结构化日志,跳过登录验证码这类派生日志也按当前步骤 key 写入;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息;当侧栏关闭 `oauthFlowTimeoutEnabled` 时,会立即清空已存在的 OAuth 总预算 deadline。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 - `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`。 -- `background/phone-verification-flow.js`:手机号验证码共享流程模块,负责复用 HeroSMS / 5sim / NexSMS 的取号、复用、轮询、重发、换号与收尾能力;既承接 OAuth 后置 `add-phone / phone-verification` 页面,也承接手机号注册 Step 4 和手机号 OAuth 登录 Step 8,并保持 `signupPhone*` 运行态与 `currentPhoneActivation` 隔离;该流程由调用方传入当前可见步骤号,手机号日志不会再固定显示普通模式 Step 9。 +- `background/phone-verification-flow.js`:手机号验证码共享流程模块,负责复用 HeroSMS / 5sim / NexSMS 的取号、复用、轮询、重发、换号与收尾能力;既承接 OAuth 后置 `add-phone / phone-verification` 页面,也承接手机号注册 Step 4 和 Step 8 中真实出现的 `phone-verification` 页面,并保持 `signupPhone*` 运行态与 `currentPhoneActivation` 隔离;该流程由调用方传入当前可见步骤号,手机号日志不会再固定显示普通模式 Step 9。 - `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;内容脚本恢复等待日志支持 `logStep / logStepKey`,用于保持 Plus 复用步骤的日志标签正确;当前等待标签完成、等待标签完成并短暂稳定、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 @@ -65,7 +65,7 @@ - `background/steps/clear-login-cookies.js`:步骤 6 实现,负责登录前 Cookie 清理。 - `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成;等待 callback 期间会动态检查 OAuth 总预算开关,关闭后仅保留本地回调等待超时。 - `background/steps/create-plus-checkout.js`:Plus 模式第 6 步实现,负责在已登录 ChatGPT 页面创建 Plus checkout session,打开 `chatgpt.com/checkout/openai_ie/{checkout_session_id}` 短链,并记录 checkout 运行态。 -- `background/steps/fetch-login-code.js`:步骤 8 实现,负责按账号身份分发登录验证码流程:邮箱账号继续走邮箱轮询、验证码回填与回退控制;手机号账号则直接复用注册成功后的同一手机号订单,轮询短信验证码并在当前认证页提交;邮箱分支对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱,当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。 +- `background/steps/fetch-login-code.js`:步骤 8 实现,负责按真实认证页状态分发登录后续流程:邮箱验证码页走邮箱轮询、验证码回填与回退控制;真实 `phone-verification` 页才复用手机号验证码共享流程;手机号注册登录后若进入 `add-email`,会先生成/解析邮箱并提交绑定,再进入邮箱验证码轮询;邮箱分支对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱,当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。 - `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口;邮箱注册继续走邮箱验证码,手机号注册改走短信验证码;当 provider 为 2925 时,会在邮箱轮询前先确保当前 2925 账号已自动登录。 - `background/steps/fill-plus-checkout.js`:Plus 模式第 7 步实现,负责驱动 checkout 页面选择 PayPal、生成账单姓名、读取本地地址 seed、触发地址推荐、提交订阅并等待跳转到 PayPal。 - `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交;当前已改为身份中立,手机号注册遇到无密码页时会按页面状态自动跳过。 @@ -93,7 +93,7 @@ - `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 - `content/plus-checkout.js`:ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 -- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 同时支持邮箱注册与手机号注册入口切换、手机号国家下拉框同步校验、手机号填写与提交;Step 7 登录链路会额外识别手机号输入页、手机号登录入口和 `phone-verification` 页面,避免把手机验证码页误判成邮箱验证码页或普通未知页。 +- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 同时支持邮箱注册与手机号注册入口切换、手机号国家下拉框同步校验、手机号填写与提交;Step 7 / 8 登录链路会额外识别手机号输入页、手机号登录入口、`phone-verification` 与真实 `add-email` 页面,避免把手机验证码页或添加邮箱页误判成普通邮箱登录入口。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;平台验证请求会读取 `visibleStep`,普通模式承接步骤 10,Plus 模式承接步骤 13。 - `content/utils.js`:内容脚本公共工具层,负责结构化日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击;内容脚本日志通过 `{ step, stepKey }` 上报,正文不再承担步骤号解析职责。 - `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做平台验证判定;当前支持普通步骤 10 与 Plus 可见步骤 13。 @@ -202,7 +202,7 @@ - `tests/background-step-modules.test.js`:测试步骤模块文件都已由后台入口加载。 - `tests/background-step5-submit-short-circuit.test.js`:测试步骤 5 会把生成好的资料直接转发给内容脚本,并依赖完成信号收尾。 - `tests/background-step6-retry-limit.test.js`:测试步骤 6 的 Cookie 清理,以及步骤 7 的有限重试上限与 `add-phone` 命中后的立即跳出行为。 -- `tests/background-step7-recovery.test.js`:测试步骤 8 获取登录验证码后直接提交(不再回放步骤 7),并为 2925 关闭重发间隔。 +- `tests/background-step7-recovery.test.js`:测试步骤 8 获取登录验证码后直接提交(不再回放步骤 7),覆盖邮箱验证码页、真实手机验证码页、`add-email` 绑定邮箱后收码、2925 固定回看窗口与关闭重发间隔。 - `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。 - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成、等待标签稳定完成,以及等待过程中的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 @@ -246,7 +246,7 @@ - `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。 - `tests/step5-age-consent.test.js`:测试步骤 5 在年龄页会先勾选顶部“我同意以下所有各项”复选框,再点击提交。 - `tests/step5-direct-complete.test.js`:测试步骤 5 在资料页点击提交后立即完成当前步骤。 -- `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路,并覆盖 `phone-verification` 页面不会被误判成邮箱验证码页。 +- `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路,并覆盖 `phone-verification` 页面不会被误判成邮箱验证码页、真实 `add-email` 页面不会被误判成普通邮箱输入页。 - `tests/step6-timeout-recovery.test.js`:测试 OAuth 登录超时报错页的可恢复结果构造,当前对应步骤 7 链路。 - `tests/step7-phone-login-entry.test.js`:测试 OAuth 手机号登录入口识别、真实 add-phone 与手机号登录页区分、慢切换等待窗口、手机号登录页可视国家下拉框会按本轮号码区号自动切换,以及输入框只剩区号时不会继续提交。 - `tests/step8-callback-handling.test.js`:测试 localhost 回调地址捕获逻辑,当前对应步骤 9。