From c04b64c966c981a6e155dc3797415a3752507ad2 Mon Sep 17 00:00:00 2001 From: QLHazyCoder Date: Mon, 4 May 2026 12:56:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 325 ++++++- background/account-run-history.js | 114 ++- background/auto-run-controller.js | 1 + background/message-router.js | 55 +- background/phone-verification-flow.js | 620 +++++++++++++- background/signup-flow-helpers.js | 62 +- background/steps/create-plus-checkout.js | 1 + background/steps/fetch-login-code.js | 55 +- background/steps/fetch-signup-code.js | 45 +- background/steps/fill-password.js | 49 +- background/steps/oauth-login.js | 61 +- background/steps/submit-signup-email.js | 112 ++- content/plus-checkout.js | 1 - content/signup-page.js | 750 ++++++++++++++++- sidepanel/account-records-manager.js | 57 +- sidepanel/sidepanel.html | 354 +------- sidepanel/sidepanel.js | 793 +++++++++++++++++- tests/auto-run-fresh-attempt-reset.test.js | 27 + ...un-step4-mail2925-thread-terminate.test.js | 2 + tests/auto-run-step4-restart.test.js | 6 + tests/auto-run-step6-restart.test.js | 2 + ...ackground-account-history-settings.test.js | 46 + ...kground-account-run-history-module.test.js | 58 ++ ...ckground-message-router-step2-skip.test.js | 2 +- .../background-signup-method-settings.test.js | 97 +++ .../background-signup-step2-branching.test.js | 78 ++ tests/background-step3-password-reuse.test.js | 73 ++ tests/background-step4-filter-window.test.js | 76 ++ tests/background-step6-retry-limit.test.js | 69 ++ tests/background-step7-recovery.test.js | 102 +++ tests/phone-verification-flow.test.js | 408 +++++++++ .../sidepanel-account-records-manager.test.js | 62 +- ...epanel-phone-verification-settings.test.js | 100 ++- tests/signup-step2-email-switch.test.js | 229 +++++ tests/step3-direct-complete.test.js | 20 + tests/step6-login-state.test.js | 27 + 项目完整链路说明.md | 92 +- 项目文件结构说明.md | 18 +- 38 files changed, 4509 insertions(+), 540 deletions(-) create mode 100644 tests/background-signup-method-settings.test.js diff --git a/background.js b/background.js index 4e15300..e4d51e5 100644 --- a/background.js +++ b/background.js @@ -377,6 +377,9 @@ const PERSISTENT_ALIAS_STATE_KEYS = [ 'icloudAliasCacheAt', ]; const ACCOUNT_RUN_HISTORY_STORAGE_KEY = 'accountRunHistory'; +const SIGNUP_METHOD_EMAIL = 'email'; +const SIGNUP_METHOD_PHONE = 'phone'; +const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL; const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_DEFAULTS || { contributionMode: false, contributionModeExpected: false, @@ -564,6 +567,7 @@ const PERSISTED_SETTING_DEFAULTS = { autoRunDelayMinutes: 30, autoStepDelaySeconds: null, phoneVerificationEnabled: false, + signupMethod: DEFAULT_SIGNUP_METHOD, phoneSmsProvider: DEFAULT_PHONE_SMS_PROVIDER, phoneSmsProviderOrder: [], verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT, @@ -624,12 +628,17 @@ const PERSISTED_SETTING_DEFAULTS = { heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, heroSmsCountryFallback: [], fiveSimApiKey: '', + fiveSimProduct: DEFAULT_FIVE_SIM_PRODUCT, fiveSimCountryId: FIVE_SIM_COUNTRY_ID, fiveSimCountryLabel: FIVE_SIM_COUNTRY_LABEL, fiveSimCountryFallback: [], - fiveSimCountryOrder: [FIVE_SIM_COUNTRY_ID], + fiveSimCountryOrder: [...DEFAULT_FIVE_SIM_COUNTRY_ORDER], fiveSimMaxPrice: '', fiveSimOperator: FIVE_SIM_OPERATOR, + nexSmsApiKey: '', + nexSmsCountryOrder: [...DEFAULT_NEX_SMS_COUNTRY_ORDER], + nexSmsServiceCode: DEFAULT_NEX_SMS_SERVICE_CODE, + phonePreferredActivation: null, }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -658,6 +667,9 @@ const DEFAULT_STATE = { stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 + resolvedSignupMethod: null, // 当前自动轮次冻结后的实际注册方式。 + accountIdentifierType: null, + accountIdentifier: '', email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 accounts: [], // 已生成账号记录:{ email, password, createdAt }。 @@ -724,6 +736,11 @@ const DEFAULT_STATE = { currentPhoneVerificationCountdownWindowTotal: 0, reusablePhoneActivation: null, phoneReusableActivationPool: [], + signupPhoneNumber: '', + signupPhoneActivation: null, + signupPhoneCompletedActivation: null, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', heroSmsLastPriceTiers: [], heroSmsLastPriceCountryId: 0, heroSmsLastPriceCountryLabel: '', @@ -999,9 +1016,100 @@ function normalizePhoneSmsProvider(value = '') { return rootScope.PhoneSmsProviderRegistry.normalizeProviderId(value); } const normalized = String(value || '').trim().toLowerCase(); - return normalized === PHONE_SMS_PROVIDER_FIVE_SIM - ? PHONE_SMS_PROVIDER_FIVE_SIM - : PHONE_SMS_PROVIDER_HERO_SMS; + if (normalized === PHONE_SMS_PROVIDER_FIVE_SIM) { + return PHONE_SMS_PROVIDER_FIVE_SIM; + } + if (normalized === PHONE_SMS_PROVIDER_NEXSMS) { + return PHONE_SMS_PROVIDER_NEXSMS; + } + return PHONE_SMS_PROVIDER_HERO_SMS; +} + +function normalizePhoneSmsProviderOrder(value = [], fallbackOrder = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const normalized = []; + const seen = new Set(); + + source.forEach((entry) => { + const provider = normalizePhoneSmsProvider( + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry.provider || entry.id || entry.value || '') + : entry + ); + if (!provider || seen.has(provider)) { + return; + } + seen.add(provider); + normalized.push(provider); + }); + + if (normalized.length) { + return normalized.slice(0, DEFAULT_PHONE_SMS_PROVIDER_ORDER.length); + } + + const fallback = Array.isArray(fallbackOrder) ? fallbackOrder : []; + fallback.forEach((entry) => { + const provider = normalizePhoneSmsProvider( + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry.provider || entry.id || entry.value || '') + : entry + ); + if (!provider || seen.has(provider)) { + return; + } + seen.add(provider); + normalized.push(provider); + }); + + return normalized.slice(0, DEFAULT_PHONE_SMS_PROVIDER_ORDER.length); +} + +function normalizeSignupMethod(value = '') { + return String(value || '').trim().toLowerCase() === SIGNUP_METHOD_PHONE + ? SIGNUP_METHOD_PHONE + : SIGNUP_METHOD_EMAIL; +} + +function canUsePhoneSignup(state = {}) { + return Boolean(state?.phoneVerificationEnabled) + && !Boolean(state?.plusModeEnabled) + && !Boolean(state?.contributionMode); +} + +function resolveSignupMethod(state = {}) { + const frozenMethod = String(state?.resolvedSignupMethod || '').trim().toLowerCase(); + if (frozenMethod === SIGNUP_METHOD_EMAIL || frozenMethod === SIGNUP_METHOD_PHONE) { + return normalizeSignupMethod(frozenMethod); + } + const method = normalizeSignupMethod(state?.signupMethod); + return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) + ? SIGNUP_METHOD_PHONE + : SIGNUP_METHOD_EMAIL; +} + +async function ensureResolvedSignupMethodForRun(options = {}) { + const state = await getState(); + const force = Boolean(options.force); + const existing = String(state?.resolvedSignupMethod || '').trim().toLowerCase(); + if (!force && (existing === SIGNUP_METHOD_EMAIL || existing === SIGNUP_METHOD_PHONE)) { + return normalizeSignupMethod(existing); + } + + const configuredMethod = normalizeSignupMethod(state?.signupMethod); + const resolvedMethod = resolveSignupMethod({ + ...state, + resolvedSignupMethod: null, + }); + await setState({ resolvedSignupMethod: resolvedMethod }); + if (configuredMethod === SIGNUP_METHOD_PHONE && resolvedMethod !== SIGNUP_METHOD_PHONE) { + await addLog('当前模式暂不支持手机号注册,本轮已固定为邮箱注册。', 'warn'); + } + return resolvedMethod; } function normalizePlusPaymentMethod(value = '') { @@ -1031,6 +1139,115 @@ function normalizeFiveSimCountryId(value, fallback = FIVE_SIM_COUNTRY_ID) { return FIVE_SIM_SUPPORTED_COUNTRY_ID_SET.has(normalizedFallback) ? normalizedFallback : FIVE_SIM_COUNTRY_ID; } +function normalizeFiveSimCountryCode(value = '', fallback = 'thailand') { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + return normalized || fallback; +} + +function normalizeFiveSimCountryOrder(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const normalized = []; + const seen = new Set(); + + source.forEach((entry) => { + const code = normalizeFiveSimCountryCode( + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry.code || entry.country || entry.id || '') + : entry, + '' + ); + if (!code || seen.has(code)) { + return; + } + seen.add(code); + normalized.push(code); + }); + + return normalized.slice(0, 10); +} + +function normalizeNexSmsCountryId(value, fallback = 0) { + const parsed = Math.floor(Number(value)); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + const fallbackParsed = Math.floor(Number(fallback)); + if (Number.isFinite(fallbackParsed) && fallbackParsed >= 0) { + return fallbackParsed; + } + return 0; +} + +function normalizeNexSmsCountryOrder(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const normalized = []; + const seen = new Set(); + source.forEach((entry) => { + const id = normalizeNexSmsCountryId( + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry.id || entry.countryId || entry.country || '') + : entry, + -1 + ); + if (id < 0 || seen.has(id)) { + return; + } + seen.add(id); + normalized.push(id); + }); + return normalized.slice(0, 10); +} + +function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_NEX_SMS_SERVICE_CODE) { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ''); + if (normalized) { + return normalized; + } + const fallbackNormalized = String(fallback || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ''); + return fallbackNormalized || DEFAULT_NEX_SMS_SERVICE_CODE; +} + +function normalizePhonePreferredActivation(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const activationId = String(value.activationId ?? value.id ?? value.activation ?? '').trim(); + const phoneNumber = String(value.phoneNumber ?? value.number ?? value.phone ?? '').trim(); + if (!activationId || !phoneNumber) { + return null; + } + const provider = normalizePhoneSmsProvider(value.provider || value.smsProvider || DEFAULT_PHONE_SMS_PROVIDER); + return { + ...value, + provider, + activationId, + phoneNumber, + countryId: value.countryId ?? value.country ?? value.countryCode ?? null, + countryLabel: String(value.countryLabel || value.label || '').trim(), + successfulUses: Math.max(0, Math.floor(Number(value.successfulUses) || 0)), + maxUses: Math.max(1, Math.floor(Number(value.maxUses) || 1)), + }; +} + function normalizeFiveSimCountryLabel(value = '', fallback = FIVE_SIM_COUNTRY_LABEL) { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryLabel) { @@ -1863,6 +2080,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'customPassword': return String(value || ''); + case 'signupMethod': + return normalizeSignupMethod(value); case 'plusPaymentMethod': return normalizePlusPaymentMethod(value); case 'paypalEmail': @@ -2002,6 +2221,8 @@ function normalizePersistentSettingValue(key, value) { return normalizeHeroSmsAcquirePriority(value); case 'heroSmsMaxPrice': return normalizeHeroSmsMaxPrice(value); + case 'heroSmsPreferredPrice': + return normalizeHeroSmsMaxPrice(value); case 'heroSmsCountryId': { const parsed = Math.floor(Number(value)); if (Number.isFinite(parsed) && HERO_SMS_SUPPORTED_COUNTRY_ID_SET.has(String(parsed))) { @@ -2015,6 +2236,8 @@ function normalizePersistentSettingValue(key, value) { return normalizeHeroSmsCountryFallback(value); case 'fiveSimApiKey': return String(value || ''); + case 'fiveSimProduct': + return normalizeFiveSimCountryCode(value, DEFAULT_FIVE_SIM_PRODUCT); case 'fiveSimCountryId': return normalizeFiveSimCountryId(value); case 'fiveSimCountryLabel': @@ -2022,13 +2245,19 @@ function normalizePersistentSettingValue(key, value) { case 'fiveSimCountryFallback': return normalizeFiveSimCountryFallback(value); case 'fiveSimCountryOrder': - return normalizeFiveSimCountryFallback(value) - .map((entry) => entry.id) - .filter(Boolean); + return normalizeFiveSimCountryOrder(value); case 'fiveSimMaxPrice': return normalizeFiveSimMaxPrice(value); case 'fiveSimOperator': return normalizeFiveSimOperator(value); + case 'nexSmsApiKey': + return String(value || ''); + case 'nexSmsCountryOrder': + return normalizeNexSmsCountryOrder(value); + case 'nexSmsServiceCode': + return normalizeNexSmsServiceCode(value); + case 'phonePreferredActivation': + return normalizePhonePreferredActivation(value); default: return value; } @@ -2085,6 +2314,16 @@ function buildPersistentSettingsPayload(input = {}, options = {}) { } payload.cloudflareTempEmailDomains = domains; } + const nextSignupConstraintState = { + ...PERSISTED_SETTING_DEFAULTS, + ...payload, + resolvedSignupMethod: null, + }; + if (Object.prototype.hasOwnProperty.call(payload, 'phoneVerificationEnabled') + || Object.prototype.hasOwnProperty.call(payload, 'plusModeEnabled') + || Object.prototype.hasOwnProperty.call(payload, 'signupMethod')) { + payload.signupMethod = resolveSignupMethod(nextSignupConstraintState); + } if (payload.ipProxyServiceProfiles) { const selectedService = normalizeIpProxyProviderValue( payload.ipProxyService || PERSISTED_SETTING_DEFAULTS.ipProxyService @@ -2133,11 +2372,11 @@ async function getPersistedAliasState() { const preservedAliases = normalizeBooleanMap(stored.preservedAliases); return { manualAliasUsage, - preservedAliases, - icloudAliasCache: normalizeIcloudAliasCacheList(stored.icloudAliasCache, { - usedEmails: toNormalizedEmailSet(manualAliasUsage), - preservedEmails: toNormalizedEmailSet(preservedAliases), - }), + preservedAliases, + icloudAliasCache: normalizeIcloudAliasCacheList(stored.icloudAliasCache, { + usedEmails: toNormalizedEmailSet(manualAliasUsage), + preservedEmails: toNormalizedEmailSet(preservedAliases), + }), icloudAliasCacheAt: Math.max(0, Number(stored.icloudAliasCacheAt) || 0), }; } catch (err) { @@ -6766,8 +7005,10 @@ function getLoginAuthStateLabel(state) { } switch (state) { case 'verification_page': return '登录验证码页'; + case 'phone_verification_page': return '手机验证码页'; case 'password_page': return '密码页'; case 'email_page': return '邮箱输入页'; + case 'phone_entry_page': return '手机号输入页'; case 'login_timeout_error_page': return '登录超时报错页'; case 'oauth_consent_page': return 'OAuth 授权页'; case 'add_phone_page': return '手机号页'; @@ -7768,12 +8009,21 @@ async function handleStepData(step, payload) { } case 2: if (payload.email) await setEmailState(payload.email); + if (payload.accountIdentifierType || payload.accountIdentifier || payload.signupPhoneNumber || payload.signupPhoneActivation) { + await setState({ + accountIdentifierType: payload.accountIdentifierType || null, + accountIdentifier: String(payload.accountIdentifier || '').trim(), + signupPhoneNumber: String(payload.signupPhoneNumber || '').trim(), + signupPhoneActivation: payload.signupPhoneActivation || null, + }); + } if (payload.skippedPasswordStep) { const latestState = await getState(); const step3Status = latestState.stepStatuses?.[3]; if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') { await setStepStatus(3, 'skipped'); - await addLog('步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。', 'warn'); + const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱'; + await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn'); } } break; @@ -7782,6 +8032,14 @@ async function handleStepData(step, payload) { if (payload.signupVerificationRequestedAt) { await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt }); } + if (payload.skipProfileStep) { + const latestState = await getState(); + const step5Status = latestState.stepStatuses?.[5]; + if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { + await setStepStatus(5, 'skipped'); + await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn'); + } + } if (payload.loginVerificationRequestedAt) { await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); } @@ -7793,13 +8051,25 @@ async function handleStepData(step, payload) { break; case 4: await setState({ - lastEmailTimestamp: payload.emailTimestamp || null, + ...(payload.phoneVerification ? { + currentPhoneVerificationCode: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + } : { + lastEmailTimestamp: payload.emailTimestamp || null, + }), signupVerificationRequestedAt: null, }); break; case 8: await setState({ - lastEmailTimestamp: payload.emailTimestamp || null, + ...(payload.phoneVerification || payload.loginPhoneVerification ? { + currentPhoneVerificationCode: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + } : { + lastEmailTimestamp: payload.emailTimestamp || null, + }), loginVerificationRequestedAt: null, }); break; @@ -9276,6 +9546,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) { let step4RestartCount = 0; let currentStartStep = startStep; let continueCurrentAttempt = continued; + const resolvedSignupMethod = await ensureResolvedSignupMethodForRun(); while (true) { @@ -9290,7 +9561,11 @@ async function runAutoSequenceFromStep(startStep, context = {}) { } if (currentStartStep <= 2) { - await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns); + if (resolvedSignupMethod === SIGNUP_METHOD_PHONE) { + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:本轮注册方式为手机号注册,将跳过邮箱预获取 ===`, 'info'); + } else { + await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns); + } await executeStepAndWait(2, AUTO_STEP_DELAYS[2]); } @@ -9570,6 +9845,14 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe isGeneratedAliasProvider, isReusableGeneratedAliasEmail, isSignupEmailVerificationPageUrl, + isSignupPhoneVerificationPageUrl: (rawUrl) => { + const parsed = parseUrlSafely(rawUrl); + return Boolean(parsed && isSignupPageHost(parsed.hostname) && /\/phone-verification(?:[/?#]|$)/i.test(parsed.pathname || '')); + }, + isSignupProfilePageUrl: (rawUrl) => { + const parsed = parseUrlSafely(rawUrl); + return Boolean(parsed && isSignupPageHost(parsed.hostname) && /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || '')); + }, isRetryableContentScriptTransportError, isHotmailProvider, isLuckmailProvider, @@ -9660,8 +9943,11 @@ const step2Executor = self.MultiPageBackgroundStep2?.createStep2Executor({ ensureSignupAuthEntryPageReady, ensureSignupEntryPageReady, ensureSignupPostEmailPageReadyInTab, + ensureSignupPostIdentityPageReadyInTab: signupFlowHelpers.ensureSignupPostIdentityPageReadyInTab, getTabId, isTabAlive, + phoneVerificationHelpers, + resolveSignupMethod, resolveSignupEmailForFlow, sendToContentScriptResilient, SIGNUP_PAGE_INJECT_FILES, @@ -9712,6 +9998,8 @@ const step4Executor = self.MultiPageBackgroundStep4?.createStep4Executor({ shouldUseCustomRegistrationEmail, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, throwIfStopped, + phoneVerificationHelpers, + resolveSignupMethod, }); const step5Executor = self.MultiPageBackgroundStep5?.createStep5Executor({ addLog, @@ -9760,7 +10048,9 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ isVerificationMailPollingError, LUCKMAIL_PROVIDER, resolveVerificationStep: verificationFlowHelpers.resolveVerificationStep, + phoneVerificationHelpers, rerunStep7ForStep8Recovery: (...args) => rerunStep7ForStep8Recovery(...args), + resolveSignupMethod, reuseOrCreateTab, setState, shouldUseCustomRegistrationEmail, @@ -9938,6 +10228,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter getStepDefinitionForState, getStepIdsForState, getLastStepIdForState, + normalizeSignupMethod, + canUsePhoneSignup, + resolveSignupMethod, getTabId, getStopRequested: () => stopRequested, handleCloudflareSecurityBlocked, diff --git a/background/account-run-history.js b/background/account-run-history.js index ca1f2dd..7fb20be 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -90,8 +90,61 @@ return '流程失败'; } - function buildRecordId(email = '') { - return String(email || '').trim().toLowerCase(); + function normalizeAccountIdentifierType(value = '') { + return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; + } + + function normalizeAccountIdentifierValue(value = '', identifierType = 'email') { + const normalizedValue = String(value || '').trim(); + if (!normalizedValue) { + return ''; + } + return normalizeAccountIdentifierType(identifierType) === 'phone' + ? normalizedValue + : normalizedValue.toLowerCase(); + } + + function resolveRecordIdentity(record = {}) { + const email = String(record.email || '').trim().toLowerCase(); + const phoneNumber = String(record.phoneNumber ?? record.phone ?? record.number ?? '').trim(); + const rawIdentifierType = String(record.accountIdentifierType || '').trim().toLowerCase(); + const inferredIdentifierType = rawIdentifierType === 'phone' + || (!email && phoneNumber) + ? 'phone' + : 'email'; + const rawAccountIdentifier = String( + record.accountIdentifier + || (inferredIdentifierType === 'phone' ? phoneNumber : email) + || '' + ).trim(); + const accountIdentifierType = rawAccountIdentifier + ? normalizeAccountIdentifierType(inferredIdentifierType) + : (email ? 'email' : (phoneNumber ? 'phone' : '')); + const accountIdentifier = normalizeAccountIdentifierValue( + rawAccountIdentifier || (accountIdentifierType === 'phone' ? phoneNumber : email), + accountIdentifierType || inferredIdentifierType + ); + + return { + email, + phoneNumber, + accountIdentifierType, + accountIdentifier, + }; + } + + function buildRecordId(identifier = '', identifierType = 'email') { + const normalizedIdentifierType = normalizeAccountIdentifierType(identifierType); + const normalizedIdentifier = normalizeAccountIdentifierValue(identifier, normalizedIdentifierType); + if (!normalizedIdentifier) { + return ''; + } + if (normalizedIdentifierType === 'phone' && /^phone:/i.test(normalizedIdentifier)) { + return normalizedIdentifier.toLowerCase(); + } + return normalizedIdentifierType === 'phone' + ? `phone:${normalizedIdentifier.toLowerCase()}` + : normalizedIdentifier; } function normalizeSource(value = '') { @@ -140,11 +193,15 @@ return null; } - const email = String(record.email || '').trim(); - const password = String(record.password || '').trim(); + const identity = resolveRecordIdentity(record); + const email = identity.email; + const phoneNumber = identity.phoneNumber; + const accountIdentifierType = identity.accountIdentifierType; + const accountIdentifier = identity.accountIdentifier; + const password = String(record.password ?? '').trim(); const finalStatus = normalizeFinalStatus(record.finalStatus || record.status || ''); - if (!email || !password || !finalStatus) { + if (!accountIdentifier || !finalStatus) { return null; } @@ -167,8 +224,11 @@ const rawFailureLabel = String(record.failureLabel || '').trim(); return { - recordId: String(record.recordId || '').trim() || buildRecordId(email), + recordId: String(record.recordId || '').trim() || buildRecordId(accountIdentifier, accountIdentifierType), + accountIdentifierType, + accountIdentifier, email, + phoneNumber, password, finalStatus, finishedAt, @@ -215,11 +275,20 @@ } function buildAccountRunHistoryRecord(state = {}, status = '', reason = '') { - const email = String(state.email || '').trim(); - const password = String(state.password || state.customPassword || '').trim() || '无'; + const identity = resolveRecordIdentity({ + accountIdentifierType: state.accountIdentifierType, + accountIdentifier: state.accountIdentifier, + email: state.email, + phoneNumber: state.phoneNumber || state.signupPhoneNumber, + }); + const email = identity.email; + const phoneNumber = identity.phoneNumber; + const accountIdentifierType = identity.accountIdentifierType; + const accountIdentifier = identity.accountIdentifier; + const password = String(state.password || state.customPassword || '').trim(); const finalStatus = normalizeFinalStatus(status); - if (!email || !finalStatus) { + if (!accountIdentifier || !finalStatus) { return null; } @@ -233,8 +302,11 @@ const finishedAt = new Date().toISOString(); return { - recordId: buildRecordId(email), + recordId: buildRecordId(accountIdentifier, accountIdentifierType), + accountIdentifierType, + accountIdentifier, email, + phoneNumber, password, finalStatus, finishedAt, @@ -257,10 +329,23 @@ const recordId = String(record.recordId || '').trim(); const emailKey = String(record.email || '').trim().toLowerCase(); + const phoneKey = String(record.phoneNumber || '').trim().toLowerCase(); + const identifierKey = buildRecordId( + record.accountIdentifier || record.email || record.phoneNumber, + record.accountIdentifierType || (phoneKey && !emailKey ? 'phone' : 'email') + ); const nextHistory = normalizedHistory.filter((item) => { const itemRecordId = String(item.recordId || '').trim(); const itemEmailKey = String(item.email || '').trim().toLowerCase(); - return itemRecordId !== recordId && itemEmailKey !== emailKey; + const itemPhoneKey = String(item.phoneNumber || '').trim().toLowerCase(); + const itemIdentifierKey = buildRecordId( + item.accountIdentifier || item.email || item.phoneNumber, + item.accountIdentifierType || (itemPhoneKey && !itemEmailKey ? 'phone' : 'email') + ); + return itemRecordId !== recordId + && itemIdentifierKey !== identifierKey + && (!emailKey || itemEmailKey !== emailKey) + && (!phoneKey || itemPhoneKey !== phoneKey); }); nextHistory.unshift(record); @@ -283,7 +368,12 @@ } const selectedIds = new Set(normalizedIds); - const nextHistory = normalizedHistory.filter((record) => !selectedIds.has(buildRecordId(record.recordId || record.email))); + const nextHistory = normalizedHistory.filter((record) => !selectedIds.has(buildRecordId( + record.recordId || record.accountIdentifier || record.email || record.phoneNumber, + String(record.recordId || '').startsWith('phone:') || String(record.accountIdentifierType || '').trim().toLowerCase() === 'phone' + ? 'phone' + : 'email' + ))); return { deletedCount: normalizedHistory.length - nextHistory.length, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 6f4058f..f80608b 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -417,6 +417,7 @@ autoRunDelayEnabled: prevState.autoRunDelayEnabled, autoRunDelayMinutes: prevState.autoRunDelayMinutes, autoStepDelaySeconds: prevState.autoStepDelaySeconds, + signupMethod: prevState.signupMethod, mailProvider: prevState.mailProvider, emailGenerator: prevState.emailGenerator, gmailBaseEmail: prevState.gmailBaseEmail, diff --git a/background/message-router.js b/background/message-router.js index b829f90..9f5772e 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -48,6 +48,14 @@ getStepDefinitionForState, getStepIdsForState, getLastStepIdForState, + normalizeSignupMethod = (value = '') => String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email', + canUsePhoneSignup = (state = {}) => Boolean(state?.phoneVerificationEnabled) + && !Boolean(state?.plusModeEnabled) + && !Boolean(state?.contributionMode), + resolveSignupMethod = (state = {}) => { + const method = normalizeSignupMethod(state?.signupMethod); + return method === 'phone' && canUsePhoneSignup(state) ? 'phone' : 'email'; + }, getTabId, getStopRequested, handleAutoRunLoopUnhandledError, @@ -275,7 +283,13 @@ if (stepKey === 'fetch-login-code') { await setState({ - lastEmailTimestamp: payload.emailTimestamp || null, + ...(payload.phoneVerification || payload.loginPhoneVerification ? { + currentPhoneVerificationCode: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + } : { + lastEmailTimestamp: payload.emailTimestamp || null, + }), loginVerificationRequestedAt: null, }); return; @@ -340,7 +354,8 @@ const step3Status = latestState.stepStatuses?.[3]; if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') { await setStepStatus(3, 'skipped'); - await addLog('步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。', 'warn'); + const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱'; + await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn'); } } break; @@ -349,13 +364,27 @@ if (payload.signupVerificationRequestedAt) { await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt }); } + if (payload.skipProfileStep) { + const latestState = await getState(); + const step5Status = latestState.stepStatuses?.[5]; + if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { + await setStepStatus(5, 'skipped'); + await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn'); + } + } if (payload.loginVerificationRequestedAt) { await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); } break; case 4: await setState({ - lastEmailTimestamp: payload.emailTimestamp || null, + ...(payload.phoneVerification ? { + currentPhoneVerificationCode: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + } : { + lastEmailTimestamp: payload.emailTimestamp || null, + }), signupVerificationRequestedAt: null, }); if (payload.skipProfileStep) { @@ -373,7 +402,13 @@ break; case 8: await setState({ - lastEmailTimestamp: payload.emailTimestamp || null, + ...(payload.phoneVerification || payload.loginPhoneVerification ? { + currentPhoneVerificationCode: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + } : { + lastEmailTimestamp: payload.emailTimestamp || null, + }), loginVerificationRequestedAt: null, }); break; @@ -758,6 +793,18 @@ const currentState = await getState(); const updates = buildPersistentSettingsPayload(message.payload || {}); const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {}); + const nextSignupState = { + ...currentState, + ...updates, + resolvedSignupMethod: null, + }; + if ( + Object.prototype.hasOwnProperty.call(updates, 'phoneVerificationEnabled') + || Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled') + || Object.prototype.hasOwnProperty.call(updates, 'signupMethod') + ) { + updates.signupMethod = resolveSignupMethod(nextSignupState); + } const modeChanged = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled') && Boolean(currentState?.plusModeEnabled) !== Boolean(updates.plusModeEnabled); const plusPaymentChanged = Object.prototype.hasOwnProperty.call(updates, 'plusPaymentMethod') diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index 84c572a..a41506f 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -92,6 +92,7 @@ const MAX_ACTIVATION_PRICE_HINTS = 256; const activationPriceHintsByKey = new Map(); let activePhoneVerificationLogStep = null; + let activePhoneVerificationLogStepKey = null; function normalizeLogStep(value) { const step = Math.floor(Number(value) || 0); @@ -114,13 +115,26 @@ if (step) { normalizedOptions.step = step; if (!normalizedOptions.stepKey) { - normalizedOptions.stepKey = 'phone-verification'; + normalizedOptions.stepKey = activePhoneVerificationLogStepKey || 'phone-verification'; } } delete normalizedOptions.visibleStep; return rawAddLog(normalizePhoneVerificationLogMessage(message), level, normalizedOptions); } + async function withPhoneVerificationLogContext(options = {}, action) { + const previousStep = activePhoneVerificationLogStep; + const previousStepKey = activePhoneVerificationLogStepKey; + activePhoneVerificationLogStep = normalizeLogStep(options.step || options.visibleStep) || previousStep; + activePhoneVerificationLogStepKey = String(options.stepKey || '').trim() || previousStepKey; + try { + return await action(); + } finally { + activePhoneVerificationLogStep = previousStep; + activePhoneVerificationLogStepKey = previousStepKey; + } + } + function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) { const trimmed = String(value || '').trim(); if (!trimmed) { @@ -3661,6 +3675,60 @@ return result || {}; } + async function submitSignupPhoneVerificationCode(tabId, code, options = {}) { + const visibleStep = 4; + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(45000, { step: visibleStep, actionLabel: '提交注册手机验证码' }) + : 45000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'SUBMIT_PHONE_VERIFICATION_CODE', + step: visibleStep, + source: 'background', + payload: { + code, + purpose: 'signup', + signupProfile: options.signupProfile || null, + }, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: '步骤 4:等待注册手机验证码页面就绪后填写短信验证码...', + logStep: visibleStep, + logStepKey: 'fetch-signup-code', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function resendSignupPhoneVerificationCode(tabId) { + const visibleStep = 4; + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(65000, { step: visibleStep, actionLabel: '重新发送注册手机验证码' }) + : 65000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'RESEND_VERIFICATION_CODE', + step: visibleStep, + source: 'background', + payload: {}, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: '步骤 4:等待注册手机验证码重发按钮出现...', + logStep: visibleStep, + logStepKey: 'fetch-signup-code', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + async function returnToAddPhone(tabId) { const visibleStep = normalizeLogStep(activePhoneVerificationLogStep) || 9; const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' @@ -3773,6 +3841,25 @@ }); } + async function persistSignupPhoneRuntimeState(updates = {}) { + await setPhoneRuntimeState({ + signupPhoneNumber: '', + signupPhoneActivation: null, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + accountIdentifierType: null, + accountIdentifier: '', + ...updates, + }); + } + + async function clearSignupPhoneRuntimeState(extraUpdates = {}) { + await persistSignupPhoneRuntimeState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + ...extraUpdates, + }); + } + async function acquirePhoneActivation(state = {}, options = {}) { const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER); const providerOrder = resolvePhoneProviderOrder(state, provider); @@ -3982,6 +4069,28 @@ throw lastProviderError || new Error('Step 9: failed to acquire phone activation.'); } + async function prepareSignupPhoneActivation(state = {}, options = {}) { + return withPhoneVerificationLogContext({ step: 2, stepKey: 'submit-signup-email' }, async () => { + const activation = await acquirePhoneActivation(state, { + ...options, + logLabel: options?.logLabel || '步骤 2', + }); + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + throw new Error('步骤 2:接码平台返回的手机号订单无效。'); + } + await persistSignupPhoneRuntimeState({ + signupPhoneNumber: normalizedActivation.phoneNumber, + signupPhoneActivation: normalizedActivation, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: 'signup', + accountIdentifierType: 'phone', + accountIdentifier: normalizedActivation.phoneNumber, + }); + return normalizedActivation; + }); + } + async function markActivationReusableAfterSuccess(state, activation) { const normalizedActivation = normalizeActivation(activation); if (!isPhoneSmsReuseEnabled(state)) { @@ -4151,8 +4260,505 @@ throw new Error('手机号验证未完成。'); } + function buildCompletedActivationSnapshot(activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + return null; + } + return { + ...normalizedActivation, + successfulUses: normalizedActivation.successfulUses + 1, + }; + } + + async function waitForScopedPhoneCode(state = {}, activation, options = {}) { + const normalizedActivation = normalizeActivation(activation); + const visibleStep = normalizeLogStep(options?.step) || 4; + const stepKey = String(options?.stepKey || 'fetch-signup-code').trim() || 'fetch-signup-code'; + const purpose = String(options?.purpose || 'signup').trim() || 'signup'; + const actionLabelPrefix = String(options?.actionLabelPrefix || 'signup phone verification').trim() || 'phone verification'; + if (!normalizedActivation) { + throw new Error(options?.missingActivationMessage || `步骤 ${visibleStep}:手机号激活记录缺失,请重新执行前置步骤。`); + } + + return withPhoneVerificationLogContext({ step: visibleStep, stepKey }, async () => { + const providerLabel = getPhoneSmsProviderLabel(normalizedActivation.provider); + const waitSeconds = normalizePhoneCodeWaitSeconds(state?.phoneCodeWaitSeconds); + const timeoutWindows = normalizePhoneCodeTimeoutWindows(state?.phoneCodeTimeoutWindows); + const pollIntervalSeconds = normalizePhoneCodePollIntervalSeconds(state?.phoneCodePollIntervalSeconds); + const pollMaxRounds = normalizePhoneCodePollMaxRounds(state?.phoneCodePollMaxRounds); + let lastLoggedStatus = ''; + let lastLoggedPollCount = 0; + + for (let windowIndex = 1; windowIndex <= timeoutWindows; windowIndex += 1) { + await setPhoneRuntimeState({ + signupPhoneActivation: normalizedActivation, + signupPhoneNumber: normalizedActivation.phoneNumber, + signupPhoneVerificationPurpose: purpose, + signupPhoneVerificationRequestedAt: Date.now(), + [PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY]: Date.now() + waitSeconds * 1000, + [PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY]: windowIndex, + [PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY]: timeoutWindows, + }); + await addLog( + `步骤 ${visibleStep}:正在等待 ${normalizedActivation.phoneNumber} 的短信验证码(${windowIndex}/${timeoutWindows},最长 ${waitSeconds} 秒)。`, + 'info', + { step: visibleStep, stepKey } + ); + try { + const code = await pollPhoneActivationCode(state, normalizedActivation, { + actionLabel: windowIndex === 1 + ? `poll ${actionLabelPrefix} code from ${providerLabel}` + : `poll resent ${actionLabelPrefix} code from ${providerLabel}`, + timeoutMs: waitSeconds * 1000, + intervalMs: pollIntervalSeconds * 1000, + maxRounds: pollMaxRounds, + onStatus: async ({ elapsedMs, pollCount, statusText }) => { + const shouldLog = ( + pollCount === 1 + || statusText !== lastLoggedStatus + || pollCount - lastLoggedPollCount >= 3 + ); + if (!shouldLog) { + return; + } + lastLoggedStatus = statusText; + lastLoggedPollCount = pollCount; + await addLog( + `步骤 ${visibleStep}:${providerLabel} 状态 ${normalizedActivation.phoneNumber}: ${statusText}(已等待 ${Math.ceil(elapsedMs / 1000)} 秒,第 ${pollCount}/${pollMaxRounds} 轮)。`, + 'info', + { step: visibleStep, stepKey } + ); + }, + }); + await clearPhoneRuntimeCountdown(); + await setPhoneRuntimeState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: String(code || '').trim(), + signupPhoneVerificationRequestedAt: Date.now(), + }); + return code; + } catch (error) { + if (!isPhoneCodeTimeoutError(error)) { + if (isPhoneActivationOrderMissingError(error, normalizedActivation.provider)) { + throw new Error(`步骤 ${visibleStep}:当前手机号激活已失效,请重新执行前置步骤获取新短信。${error.message || error}`); + } + throw error; + } + + if (windowIndex < timeoutWindows) { + await addLog( + `步骤 ${visibleStep}:${normalizedActivation.phoneNumber} 在 ${waitSeconds} 秒内未收到短信,准备请求重发。`, + 'warn', + { step: visibleStep, stepKey } + ); + await requestAdditionalPhoneSms(state, normalizedActivation); + if (typeof options.onTimeoutWindow === 'function') { + await options.onTimeoutWindow({ + activation: normalizedActivation, + windowIndex, + timeoutWindows, + }); + } + continue; + } + + await clearPhoneRuntimeCountdown(); + throw error; + } + } + + throw new Error(`步骤 ${visibleStep}:手机验证码未能成功获取。`); + }); + } + + async function waitForSignupPhoneCode(state = {}, activation, options = {}) { + return waitForScopedPhoneCode(state, activation, { + ...options, + step: 4, + stepKey: 'fetch-signup-code', + purpose: 'signup', + actionLabelPrefix: 'signup phone verification', + missingActivationMessage: '步骤 4:注册手机号激活记录缺失,请重新执行步骤 2。', + }); + } + + async function waitForLoginPhoneCode(state = {}, activation, options = {}) { + const visibleStep = normalizeLogStep(options?.visibleStep || options?.step) || 8; + return waitForScopedPhoneCode(state, activation, { + ...options, + step: visibleStep, + stepKey: 'fetch-login-code', + purpose: 'login', + actionLabelPrefix: 'login phone verification', + missingActivationMessage: `步骤 ${visibleStep}:登录手机号激活记录缺失,请重新执行步骤 ${visibleStep >= 11 ? 10 : 7}。`, + }); + } + + async function finalizeSignupPhoneActivationAfterSuccess(state = {}, activation = null) { + const normalizedActivation = normalizeActivation(activation || state?.signupPhoneActivation); + if (!normalizedActivation) { + await clearSignupPhoneRuntimeState(); + return null; + } + await completePhoneActivation(state, normalizedActivation); + await markActivationReusableAfterSuccess(state, normalizedActivation); + await clearSignupPhoneRuntimeState({ + signupPhoneCompletedActivation: buildCompletedActivationSnapshot(normalizedActivation), + signupPhoneNumber: normalizedActivation.phoneNumber, + accountIdentifierType: 'phone', + accountIdentifier: normalizedActivation.phoneNumber, + }); + return normalizedActivation; + } + + async function cancelSignupPhoneActivation(state = {}, activation = null) { + const normalizedActivation = normalizeActivation(activation || state?.signupPhoneActivation); + if (normalizedActivation) { + await cancelPhoneActivation(state, normalizedActivation); + } + await clearSignupPhoneRuntimeState(); + } + + async function completeSignupPhoneVerificationFlow(tabId, options = {}) { + return withPhoneVerificationLogContext({ step: 4, stepKey: 'fetch-signup-code' }, async () => { + let state = options?.state || await getState(); + const activation = normalizeActivation(options?.activation || state?.signupPhoneActivation); + if (!activation) { + throw new Error('步骤 4:未找到当前注册手机号激活记录,请重新执行步骤 2。'); + } + + let shouldCancelActivation = true; + try { + for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) { + throwIfStopped(); + state = await getState(); + const code = await waitForSignupPhoneCode(state, activation, { + onTimeoutWindow: async () => { + try { + await resendSignupPhoneVerificationCode(tabId); + await addLog('步骤 4:已点击注册手机验证码页面的“重新发送”。', 'info', { + step: 4, + stepKey: 'fetch-signup-code', + }); + } catch (resendError) { + if (isStopRequestedError(resendError)) { + throw resendError; + } + await addLog(`步骤 4:注册手机验证码页面重发失败,将继续轮询短信。${resendError.message}`, 'warn', { + step: 4, + stepKey: 'fetch-signup-code', + }); + } + }, + }); + + await setPhoneRuntimeState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: String(code || '').trim(), + signupPhoneVerificationRequestedAt: Date.now(), + signupPhoneVerificationPurpose: 'signup', + }); + await addLog(`步骤 4:已获取手机验证码 ${code}。`, 'info', { + step: 4, + stepKey: 'fetch-signup-code', + }); + + const submitResult = await submitSignupPhoneVerificationCode(tabId, code, { + signupProfile: options.signupProfile || null, + }); + + if (submitResult.invalidCode) { + const invalidErrorText = String(submitResult.errorText || submitResult.url || '未知错误').trim(); + if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { + throw new Error(`步骤 4:手机验证码连续 ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} 次被拒绝:${invalidErrorText}`); + } + + await requestAdditionalPhoneSms(state, activation); + try { + await resendSignupPhoneVerificationCode(tabId); + } catch (resendError) { + if (isStopRequestedError(resendError)) { + throw resendError; + } + await addLog(`步骤 4:验证码被拒后点击重发失败。${resendError.message}`, 'warn', { + step: 4, + stepKey: 'fetch-signup-code', + }); + } + await addLog( + `步骤 4:手机验证码被拒绝,已请求新短信(${attempt + 1}/${DEFAULT_PHONE_SUBMIT_ATTEMPTS})。`, + 'warn', + { step: 4, stepKey: 'fetch-signup-code' } + ); + continue; + } + + await finalizeSignupPhoneActivationAfterSuccess(state, activation); + shouldCancelActivation = false; + await setPhoneRuntimeState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + }); + await addLog('步骤 4:手机验证码已通过,继续进入资料填写。', 'ok', { + step: 4, + stepKey: 'fetch-signup-code', + }); + return submitResult || {}; + } + + throw new Error('步骤 4:手机验证码未能成功提交。'); + } catch (error) { + if (shouldCancelActivation && activation) { + await cancelSignupPhoneActivation(state, activation).catch(() => {}); + } + await setPhoneRuntimeState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + }); + throw sanitizePhoneCodeTimeoutError(error); + } + }); + } + + async function submitLoginPhoneVerificationCode(tabId, code, options = {}) { + const visibleStep = normalizeLogStep(options?.visibleStep || options?.step) || 8; + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(45000, { step: visibleStep, actionLabel: '提交登录手机验证码' }) + : 45000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'SUBMIT_PHONE_VERIFICATION_CODE', + step: visibleStep, + source: 'background', + payload: { + code, + purpose: 'login', + visibleStep, + }, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: `步骤 ${visibleStep}:等待登录手机验证码页面就绪后填写短信验证码...`, + logStep: visibleStep, + logStepKey: 'fetch-login-code', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function resendLoginPhoneVerificationCode(tabId, options = {}) { + const visibleStep = normalizeLogStep(options?.visibleStep || options?.step) || 8; + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(65000, { step: visibleStep, actionLabel: '重新发送登录手机验证码' }) + : 65000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'RESEND_VERIFICATION_CODE', + step: visibleStep, + source: 'background', + payload: {}, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: `步骤 ${visibleStep}:等待登录手机验证码重发按钮出现...`, + logStep: visibleStep, + logStepKey: 'fetch-login-code', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function prepareLoginPhoneActivation(state = {}, options = {}) { + const visibleStep = normalizeLogStep(options?.visibleStep || options?.step) || 8; + return withPhoneVerificationLogContext({ step: visibleStep, stepKey: 'fetch-login-code' }, async () => { + const preferredActivation = normalizeActivation( + options?.activation + || state?.signupPhoneCompletedActivation + || state?.signupPhoneActivation + ); + if (!preferredActivation) { + throw new Error(`步骤 ${visibleStep}:缺少已注册手机号激活记录,无法继续手机号登录验证码流程。`); + } + + const activeActivation = normalizeActivation(state?.signupPhoneActivation); + if (activeActivation && isSameActivation(activeActivation, preferredActivation)) { + await setPhoneRuntimeState({ + signupPhoneNumber: activeActivation.phoneNumber, + signupPhoneVerificationPurpose: 'login', + }); + return activeActivation; + } + + const reactivated = await reactivatePhoneActivation(state, preferredActivation); + const normalizedActivation = normalizeActivation(reactivated); + if (!normalizedActivation) { + throw new Error(`步骤 ${visibleStep}:无法复用当前注册手机号,请重新执行步骤 ${visibleStep >= 11 ? 10 : 7}。`); + } + + await setPhoneRuntimeState({ + signupPhoneActivation: normalizedActivation, + signupPhoneCompletedActivation: preferredActivation, + signupPhoneNumber: normalizedActivation.phoneNumber, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: 'login', + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + accountIdentifierType: 'phone', + accountIdentifier: normalizedActivation.phoneNumber, + }); + return normalizedActivation; + }); + } + + async function finalizeLoginPhoneActivationAfterSuccess(state = {}, activation = null, options = {}) { + const normalizedActivation = normalizeActivation(activation || state?.signupPhoneActivation); + const visibleStep = normalizeLogStep(options?.visibleStep || options?.step) || 8; + if (!normalizedActivation) { + await setPhoneRuntimeState({ + signupPhoneActivation: null, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + }); + return null; + } + + return withPhoneVerificationLogContext({ step: visibleStep, stepKey: 'fetch-login-code' }, async () => { + await completePhoneActivation(state, normalizedActivation); + await setPhoneRuntimeState({ + signupPhoneActivation: null, + signupPhoneCompletedActivation: buildCompletedActivationSnapshot(normalizedActivation), + signupPhoneNumber: normalizedActivation.phoneNumber, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + accountIdentifierType: 'phone', + accountIdentifier: normalizedActivation.phoneNumber, + }); + return normalizedActivation; + }); + } + + async function completeLoginPhoneVerificationFlow(tabId, options = {}) { + const visibleStep = normalizeLogStep(options?.visibleStep || options?.step) || 8; + return withPhoneVerificationLogContext({ step: visibleStep, stepKey: 'fetch-login-code' }, async () => { + let state = options?.state || await getState(); + const baseActivation = normalizeActivation( + options?.activation + || state?.signupPhoneCompletedActivation + || state?.signupPhoneActivation + ); + if (!baseActivation) { + throw new Error(`步骤 ${visibleStep}:未找到当前登录手机号激活记录,请重新执行步骤 ${visibleStep >= 11 ? 10 : 7}。`); + } + + let activation = await prepareLoginPhoneActivation(state, { + activation: baseActivation, + visibleStep, + }); + let shouldCancelActivation = true; + + try { + for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) { + throwIfStopped(); + state = await getState(); + const code = await waitForLoginPhoneCode(state, activation, { + visibleStep, + onTimeoutWindow: async () => { + try { + await resendLoginPhoneVerificationCode(tabId, { visibleStep }); + await addLog(`步骤 ${visibleStep}:已点击登录手机验证码页面的“重新发送”。`, 'info', { + step: visibleStep, + stepKey: 'fetch-login-code', + }); + } catch (resendError) { + if (isStopRequestedError(resendError)) { + throw resendError; + } + await addLog(`步骤 ${visibleStep}:登录手机验证码页面重发失败,将继续轮询短信。${resendError.message}`, 'warn', { + step: visibleStep, + stepKey: 'fetch-login-code', + }); + } + }, + }); + + await setPhoneRuntimeState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: String(code || '').trim(), + signupPhoneVerificationRequestedAt: Date.now(), + signupPhoneVerificationPurpose: 'login', + }); + await addLog(`步骤 ${visibleStep}:已获取登录手机验证码 ${code}。`, 'info', { + step: visibleStep, + stepKey: 'fetch-login-code', + }); + + const submitResult = await submitLoginPhoneVerificationCode(tabId, code, { + visibleStep, + }); + + if (submitResult.invalidCode) { + const invalidErrorText = String(submitResult.errorText || submitResult.url || '未知错误').trim(); + if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { + throw new Error(`步骤 ${visibleStep}:登录手机验证码连续 ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} 次被拒绝:${invalidErrorText}`); + } + + await requestAdditionalPhoneSms(state, activation); + try { + await resendLoginPhoneVerificationCode(tabId, { visibleStep }); + } catch (resendError) { + if (isStopRequestedError(resendError)) { + throw resendError; + } + await addLog(`步骤 ${visibleStep}:登录手机验证码被拒后点击重发失败。${resendError.message}`, 'warn', { + step: visibleStep, + stepKey: 'fetch-login-code', + }); + } + await addLog( + `步骤 ${visibleStep}:登录手机验证码被拒绝,已请求新短信(${attempt + 1}/${DEFAULT_PHONE_SUBMIT_ATTEMPTS})。`, + 'warn', + { step: visibleStep, stepKey: 'fetch-login-code' } + ); + continue; + } + + await finalizeLoginPhoneActivationAfterSuccess(state, activation, { visibleStep }); + shouldCancelActivation = false; + await addLog(`步骤 ${visibleStep}:登录手机验证码已通过,继续进入后续授权流程。`, 'ok', { + step: visibleStep, + stepKey: 'fetch-login-code', + }); + return submitResult || {}; + } + + throw new Error(`步骤 ${visibleStep}:登录手机验证码未能成功提交。`); + } catch (error) { + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation).catch(() => {}); + } + await setPhoneRuntimeState({ + signupPhoneActivation: null, + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + }); + throw sanitizePhoneCodeTimeoutError(error); + } + }); + } + async function completePhoneVerificationFlow(tabId, initialPageState = null, options = {}) { + const previousLogStep = activePhoneVerificationLogStep; + const previousLogStepKey = activePhoneVerificationLogStepKey; activePhoneVerificationLogStep = normalizeLogStep(options.visibleStep || options.step) || 9; + activePhoneVerificationLogStepKey = 'phone-verification'; let state = await getState(); let activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]); let pageState = initialPageState || await readPhonePageState(tabId); @@ -4796,15 +5402,27 @@ } await clearCurrentActivation(); throw sanitizePhoneRestartStep7Error(sanitizePhoneCodeTimeoutError(error)); + } finally { + activePhoneVerificationLogStep = previousLogStep; + activePhoneVerificationLogStepKey = previousLogStepKey; } } return { + cancelSignupPhoneActivation, + completeLoginPhoneVerificationFlow, completePhoneVerificationFlow, + completeSignupPhoneVerificationFlow, + finalizeLoginPhoneActivationAfterSuccess, + finalizeSignupPhoneActivationAfterSuccess, normalizeActivation, pollPhoneActivationCode, + prepareLoginPhoneActivation, + prepareSignupPhoneActivation, reactivatePhoneActivation, requestPhoneActivation, + waitForLoginPhoneCode, + waitForSignupPhoneCode, }; } diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index dc64c9b..c8172a1 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -17,6 +17,8 @@ isLuckmailProvider, isSignupEmailVerificationPageUrl, isSignupPasswordPageUrl, + isSignupPhoneVerificationPageUrl = null, + isSignupProfilePageUrl = null, reuseOrCreateTab, sendToContentScriptResilient, setEmailState, @@ -62,46 +64,79 @@ return { tabId, result: result || {} }; } - function resolveSignupPostEmailState(rawUrl) { + function parseUrlSafely(rawUrl) { + if (!rawUrl) return null; + try { + return new URL(rawUrl); + } catch { + return null; + } + } + + function fallbackSignupPhoneVerificationPageUrl(rawUrl) { + const parsed = parseUrlSafely(rawUrl); + if (!parsed) return false; + return /\/phone-verification(?:[/?#]|$)/i.test(parsed.pathname || ''); + } + + function fallbackSignupProfilePageUrl(rawUrl) { + const parsed = parseUrlSafely(rawUrl); + if (!parsed) return false; + return /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || ''); + } + + function resolveSignupPostIdentityState(rawUrl) { if (isSignupPasswordPageUrl(rawUrl)) { return 'password_page'; } if (isSignupEmailVerificationPageUrl(rawUrl)) { return 'verification_page'; } + const isPhoneVerificationUrl = typeof isSignupPhoneVerificationPageUrl === 'function' + ? isSignupPhoneVerificationPageUrl(rawUrl) + : fallbackSignupPhoneVerificationPageUrl(rawUrl); + if (isPhoneVerificationUrl) { + return 'phone_verification_page'; + } + const isProfileUrl = typeof isSignupProfilePageUrl === 'function' + ? isSignupProfilePageUrl(rawUrl) + : fallbackSignupProfilePageUrl(rawUrl); + if (isProfileUrl) { + return 'profile_page'; + } return ''; } - async function ensureSignupPostEmailPageReadyInTab(tabId, step = 2, options = {}) { + async function ensureSignupPostIdentityPageReadyInTab(tabId, step = 2, options = {}) { const { skipUrlWait = false } = options; let landingUrl = ''; let landingState = ''; if (!skipUrlWait) { - const matchedTab = await waitForTabUrlMatch(tabId, (url) => Boolean(resolveSignupPostEmailState(url)), { + const matchedTab = await waitForTabUrlMatch(tabId, (url) => Boolean(resolveSignupPostIdentityState(url)), { timeoutMs: 45000, retryDelayMs: 300, }); if (!matchedTab) { - throw new Error('等待邮箱提交后的页面跳转超时,请检查页面是否仍停留在邮箱输入页。'); + throw new Error('等待注册身份提交后的页面跳转超时,请检查页面是否仍停留在输入页。'); } landingUrl = matchedTab.url || ''; - landingState = resolveSignupPostEmailState(landingUrl); + landingState = resolveSignupPostIdentityState(landingUrl); } if (!landingState) { try { const currentTab = await chrome.tabs.get(tabId); landingUrl = landingUrl || currentTab?.url || ''; - landingState = resolveSignupPostEmailState(landingUrl); + landingState = resolveSignupPostIdentityState(landingUrl); } catch { landingUrl = landingUrl || ''; } } if (!landingState) { - throw new Error(`邮箱提交后未能识别当前页面,既不是密码页也不是邮箱验证码页。URL: ${landingUrl || 'unknown'}`); + throw new Error(`注册身份提交后未能识别当前页面,既不是密码页、验证码页,也不是资料页。URL: ${landingUrl || 'unknown'}`); } await ensureContentScriptReadyOnTab('signup-page', tabId, { @@ -109,12 +144,12 @@ injectSource: 'signup-page', timeoutMs: 45000, retryDelayMs: 900, - logMessage: landingState === 'verification_page' - ? `步骤 ${step}:邮箱验证码页仍在加载,正在等待页面恢复...` - : `步骤 ${step}:密码页仍在加载,正在重试连接内容脚本...`, + logMessage: landingState === 'password_page' + ? `步骤 ${step}:密码页仍在加载,正在重试连接内容脚本...` + : `步骤 ${step}:注册后续页面仍在加载,正在等待页面恢复...`, }); - if (landingState === 'verification_page') { + if (landingState !== 'password_page') { return { ready: true, state: landingState, @@ -145,6 +180,10 @@ }; } + async function ensureSignupPostEmailPageReadyInTab(tabId, step = 2, options = {}) { + return ensureSignupPostIdentityPageReadyInTab(tabId, step, options); + } + async function ensureSignupPasswordPageReadyInTab(tabId, step = 2, options = {}) { const result = await ensureSignupPostEmailPageReadyInTab(tabId, step, options); if (result.state !== 'password_page') { @@ -240,6 +279,7 @@ return { ensureSignupEntryPageReady, + ensureSignupPostIdentityPageReadyInTab, ensureSignupPostEmailPageReadyInTab, finalizeSignupPasswordSubmitInTab, ensureSignupPasswordPageReadyInTab, diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 88e8cad..b4538fd 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -52,6 +52,7 @@ async function executePlusCheckoutCreate(state = {}) { const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod); const paymentMethodLabel = getPlusPaymentMethodLabel(paymentMethod); + const checkoutModeLabel = getCheckoutModeLabel(state); await addLog('步骤 6:正在新打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info'); const tabId = await openFreshChatGptTabForCheckoutCreate(); diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index d40cf2e..a5b3b43 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -25,6 +25,8 @@ resolveVerificationStep, rerunStep7ForStep8Recovery, reuseOrCreateTab, + phoneVerificationHelpers = null, + resolveSignupMethod = () => 'email', setState, shouldUseCustomRegistrationEmail, sleepWithStop, @@ -97,6 +99,13 @@ 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 completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, options = {}) { await setState({ step8VerificationTargetEmail: '', @@ -206,9 +215,45 @@ return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0); } + async function executeLoginPhoneCodeStep(state, signupTabId, visibleStep) { + if (!Number.isInteger(signupTabId)) { + throw new Error(`步骤 ${visibleStep}:认证页面标签页已关闭,无法继续手机号登录验证码流程。`); + } + if (typeof phoneVerificationHelpers?.completeLoginPhoneVerificationFlow !== 'function') { + throw new Error(`步骤 ${visibleStep}:手机号登录验证码流程不可用,接码模块尚未初始化。`); + } + + const result = await phoneVerificationHelpers.completeLoginPhoneVerificationFlow(signupTabId, { + state, + visibleStep, + }); + + await completeStepFromBackground(visibleStep, { + phoneVerification: true, + loginPhoneVerification: true, + code: result?.code || '', + }); + return result || {}; + } + async function runStep8Attempt(state, runtime = {}) { const visibleStep = getVisibleStep(state, 8); activeFetchLoginCodeStep = visibleStep; + const authTabId = await getTabId('signup-page'); + + if (authTabId) { + await chrome.tabs.update(authTabId, { active: true }); + } else { + if (!state.oauthUrl) { + throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); + } + 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; @@ -222,16 +267,6 @@ ? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS) : stepStartedAt; const verificationSessionKey = `8:${stepStartedAt}`; - const authTabId = await getTabId('signup-page'); - - if (authTabId) { - await chrome.tabs.update(authTabId, { active: true }); - } else { - if (!state.oauthUrl) { - throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); - } - await reuseOrCreateTab('signup-page', state.oauthUrl); - } throwIfStopped(); const pageState = await ensureStep8VerificationPageReady({ diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index 4fe13b2..4c986c7 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -27,6 +27,8 @@ shouldUseCustomRegistrationEmail, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, throwIfStopped, + phoneVerificationHelpers = null, + resolveSignupMethod = () => 'email', } = deps; function buildSignupProfileForVerificationStep() { @@ -58,6 +60,32 @@ return String(state?.mail2925BaseEmail || '').trim().toLowerCase(); } + function isPhoneSignupState(state = {}) { + return resolveSignupMethod(state) === 'phone' + || state?.accountIdentifierType === 'phone' + || Boolean(state?.signupPhoneActivation); + } + + async function executeSignupPhoneCodeStep(state, signupTabId) { + if (typeof phoneVerificationHelpers?.completeSignupPhoneVerificationFlow !== 'function') { + throw new Error('步骤 4:手机号注册验证码流程不可用,接码模块尚未初始化。'); + } + + const signupProfile = buildSignupProfileForVerificationStep(); + const result = await phoneVerificationHelpers.completeSignupPhoneVerificationFlow(signupTabId, { + state, + signupProfile, + }); + + await completeStepFromBackground(4, { + phoneVerification: true, + code: result?.code || '', + ...(result?.skipProfileStep ? { skipProfileStep: true } : {}), + ...(result?.skipProfileStepReason ? { skipProfileStepReason: result.skipProfileStepReason } : {}), + }); + return result || {}; + } + async function focusOrOpenMailTab(mail) { const alive = await isTabAlive(mail.source); if (alive) { @@ -81,13 +109,7 @@ } async function executeStep4(state) { - const mail = getMailConfig(state); - 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 = `4:${stepStartedAt}`; const signupTabId = await getTabId('signup-page'); @@ -175,11 +197,22 @@ return; } + if (isPhoneSignupState(state)) { + return executeSignupPhoneCodeStep(state, signupTabId); + } + if (shouldUseCustomRegistrationEmail(state)) { await confirmCustomVerificationStepBypass(4); return; } + const mail = getMailConfig(state); + if (mail.error) throw new Error(mail.error); + + const verificationFilterAfterTimestamp = mail.provider === '2925' + ? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS) + : stepStartedAt; + if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { await addLog('步骤 4:正在确认 iCloud 邮箱登录态...', 'info'); await ensureIcloudMailSession({ diff --git a/background/steps/fill-password.js b/background/steps/fill-password.js index f8f3318..1f1dc55 100644 --- a/background/steps/fill-password.js +++ b/background/steps/fill-password.js @@ -15,10 +15,32 @@ SIGNUP_PAGE_INJECT_FILES, } = deps; + function resolveStep3AccountIdentity(state = {}) { + const resolvedEmail = String(state?.email || '').trim(); + const signupPhoneNumber = String( + state?.signupPhoneNumber + || (state?.accountIdentifierType === 'phone' ? state?.accountIdentifier : '') + || '' + ).trim(); + const accountIdentifierType = signupPhoneNumber && state?.accountIdentifierType === 'phone' + ? 'phone' + : (resolvedEmail ? 'email' : (signupPhoneNumber ? 'phone' : 'email')); + const accountIdentifier = accountIdentifierType === 'phone' + ? signupPhoneNumber + : resolvedEmail; + + return { + accountIdentifierType, + accountIdentifier, + email: resolvedEmail, + phoneNumber: signupPhoneNumber, + }; + } + async function executeStep3(state) { - const resolvedEmail = state.email; - if (!resolvedEmail) { - throw new Error('缺少邮箱地址,请先完成步骤 2。'); + const identity = resolveStep3AccountIdentity(state); + if (!identity.accountIdentifier) { + throw new Error('缺少注册账号,请先完成步骤 2。'); } const signupTabId = await getTabId('signup-page'); @@ -30,7 +52,13 @@ await setPasswordState(password); const accounts = state.accounts || []; - accounts.push({ email: resolvedEmail, createdAt: new Date().toISOString() }); + accounts.push({ + email: identity.email, + phoneNumber: identity.phoneNumber, + accountIdentifierType: identity.accountIdentifierType, + accountIdentifier: identity.accountIdentifier, + createdAt: new Date().toISOString(), + }); await setState({ accounts }); await chrome.tabs.update(signupTabId, { active: true }); @@ -42,14 +70,23 @@ logMessage: '步骤 3:密码页内容脚本未就绪,正在等待页面恢复...', }); + const identityLabel = identity.accountIdentifierType === 'phone' + ? `注册手机号为 ${identity.accountIdentifier}` + : `邮箱为 ${identity.accountIdentifier}`; await addLog( - `步骤 3:正在填写密码,邮箱为 ${resolvedEmail},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)` + `步骤 3:正在填写密码,${identityLabel},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)` ); await sendToContentScript('signup-page', { type: 'EXECUTE_STEP', step: 3, source: 'background', - payload: { email: resolvedEmail, password }, + payload: { + email: identity.email, + phoneNumber: identity.phoneNumber, + accountIdentifierType: identity.accountIdentifierType, + accountIdentifier: identity.accountIdentifier, + password, + }, }); } diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 90e98bf..2c08462 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -41,12 +41,30 @@ } async function executeStep7(state) { - if (!state.email) { - throw new Error('缺少邮箱地址,请先完成步骤 3。'); - } - const visibleStep = Math.floor(Number(state?.visibleStep) || 0); const completionStep = visibleStep > 0 ? visibleStep : 7; + const resolvedIdentifierType = String( + state?.accountIdentifierType + || (state?.signupPhoneNumber ? 'phone' : '') + || '' + ).trim().toLowerCase() === 'phone' + ? 'phone' + : 'email'; + const phoneNumber = String( + state?.signupPhoneNumber + || (resolvedIdentifierType === 'phone' ? state?.accountIdentifier : '') + || state?.signupPhoneCompletedActivation?.phoneNumber + || state?.signupPhoneActivation?.phoneNumber + || '' + ).trim(); + const email = String( + state?.email + || (resolvedIdentifierType === 'email' ? state?.accountIdentifier : '') + || '' + ).trim(); + if (!email && !phoneNumber) { + throw new Error('缺少登录账号,请先完成步骤 2 和步骤 3。'); + } let attempt = 0; let lastError = null; @@ -57,6 +75,28 @@ try { const currentState = attempt === 1 ? state : await getState(); const password = currentState.password || currentState.customPassword || ''; + const currentIdentifierType = String( + currentState?.accountIdentifierType + || (currentState?.signupPhoneNumber ? 'phone' : '') + || resolvedIdentifierType + ).trim().toLowerCase() === 'phone' + ? 'phone' + : 'email'; + const currentPhoneNumber = String( + currentState?.signupPhoneNumber + || (currentIdentifierType === 'phone' ? currentState?.accountIdentifier : '') + || currentState?.signupPhoneCompletedActivation?.phoneNumber + || currentState?.signupPhoneActivation?.phoneNumber + || phoneNumber + ).trim(); + const currentEmail = String( + currentState?.email + || (currentIdentifierType === 'email' ? currentState?.accountIdentifier : '') + || email + ).trim(); + const accountIdentifier = currentIdentifierType === 'phone' + ? currentPhoneNumber + : currentEmail; const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); if (typeof startOAuthFlowTimeoutWindow === 'function') { await startOAuthFlowTimeoutWindow({ step: completionStep, oauthUrl }); @@ -90,7 +130,18 @@ step: 7, source: 'background', payload: { - email: currentState.email, + email: currentEmail, + phoneNumber: currentPhoneNumber, + countryId: currentState?.signupPhoneCompletedActivation?.countryId + ?? currentState?.signupPhoneActivation?.countryId + ?? null, + countryLabel: String( + currentState?.signupPhoneCompletedActivation?.countryLabel + || currentState?.signupPhoneActivation?.countryLabel + || '' + ).trim(), + accountIdentifier, + loginIdentifierType: currentIdentifierType, password, visibleStep: completionStep, }, diff --git a/background/steps/submit-signup-email.js b/background/steps/submit-signup-email.js index 3b6be68..431b864 100644 --- a/background/steps/submit-signup-email.js +++ b/background/steps/submit-signup-email.js @@ -10,8 +10,11 @@ ensureSignupAuthEntryPageReady, ensureSignupEntryPageReady, ensureSignupPostEmailPageReadyInTab, + ensureSignupPostIdentityPageReadyInTab = ensureSignupPostEmailPageReadyInTab, getTabId, isTabAlive, + phoneVerificationHelpers = null, + resolveSignupMethod = () => 'email', resolveSignupEmailForFlow, sendToContentScriptResilient, SIGNUP_PAGE_INJECT_FILES, @@ -94,7 +97,7 @@ throw new Error(message); } - async function submitSignupEmail(resolvedEmail, options = {}) { + async function sendSignupIdentity(payload = {}, options = {}) { const { timeoutMs = 35000, retryDelayMs = 700, @@ -106,7 +109,7 @@ type: 'EXECUTE_STEP', step: 2, source: 'background', - payload: { email: resolvedEmail }, + payload, }, { timeoutMs, retryDelayMs, @@ -117,9 +120,23 @@ } } - async function executeStep2(state) { - const resolvedEmail = await resolveSignupEmailForFlow(state); + async function submitSignupEmail(resolvedEmail, options = {}) { + return sendSignupIdentity({ email: resolvedEmail }, options); + } + async function submitSignupPhone(phoneNumber, activation, options = {}) { + return sendSignupIdentity({ + signupMethod: 'phone', + phoneNumber, + countryId: activation?.countryId ?? null, + countryLabel: String(activation?.countryLabel || '').trim(), + }, { + logMessage: '步骤 2:官网注册入口正在切换,等待手机号注册入口恢复...', + ...options, + }); + } + + async function ensureSignupTabForStep2() { let signupTabId = await getTabId('signup-page'); if (!signupTabId || !(await isTabAlive('signup-page'))) { await addLog('步骤 2:未发现可用的注册页标签,正在重新打开 ChatGPT 官网...', 'warn'); @@ -134,6 +151,84 @@ logMessage: '步骤 2:注册入口页内容脚本未就绪,正在等待页面恢复...', }); } + return signupTabId; + } + + async function executeSignupPhoneEntry(state) { + if (typeof phoneVerificationHelpers?.prepareSignupPhoneActivation !== 'function') { + throw new Error('手机号注册流程不可用:接码模块尚未初始化。'); + } + + let signupTabId = await ensureSignupTabForStep2(); + if (await shouldForceAuthEntryRetry(signupTabId)) { + await addLog('步骤 2:检测到当前位于已登录 ChatGPT 首页,先切换认证入口页再提交手机号。', 'warn'); + try { + signupTabId = (await ensureSignupAuthEntryPageReady(2)).tabId; + } catch (entryError) { + const entryErrorMessage = getErrorMessage(entryError); + if (await failStep2OnLoggedInSession(signupTabId, entryErrorMessage)) { + return; + } + await addLog('步骤 2:切换认证入口失败,正在重新打开官网入口并重试提交手机号...', 'warn'); + signupTabId = (await ensureSignupEntryPageReady(2)).tabId; + } + } + + const activation = await phoneVerificationHelpers.prepareSignupPhoneActivation(state); + let step2Result = await submitSignupPhone(activation.phoneNumber, activation, { + timeoutMs: 45000, + retryDelayMs: 700, + logMessage: '步骤 2:官网注册入口正在切换,等待手机号注册入口恢复...', + }); + + if (step2Result?.error) { + const errorMessage = getErrorMessage(step2Result.error); + if (isSignupEntryUnavailableErrorMessage(errorMessage) || isRetryableStep2TransportErrorMessage(errorMessage)) { + await addLog('步骤 2:手机号注册入口不可用或通信超时,正在切换认证入口页后重试一次...', 'warn'); + signupTabId = (await ensureSignupAuthEntryPageReady(2)).tabId; + step2Result = await submitSignupPhone(activation.phoneNumber, activation, { + timeoutMs: 45000, + retryDelayMs: 700, + logMessage: '步骤 2:认证入口页已打开,正在重新提交手机号...', + }); + } + } + + if (step2Result?.error) { + const finalErrorMessage = getErrorMessage(step2Result.error); + if ( + (isSignupEntryUnavailableErrorMessage(finalErrorMessage) + || isRetryableStep2TransportErrorMessage(finalErrorMessage)) + && await failStep2OnLoggedInSession(signupTabId, finalErrorMessage) + ) { + return; + } + if (typeof phoneVerificationHelpers?.cancelSignupPhoneActivation === 'function') { + await phoneVerificationHelpers.cancelSignupPhoneActivation(state, activation).catch(() => {}); + } + throw new Error(finalErrorMessage); + } + + await addLog(`步骤 2:手机号 ${activation.phoneNumber} 已提交,正在等待页面加载并确认下一步入口...`); + const landingResult = await ensureSignupPostIdentityPageReadyInTab(signupTabId, 2, { + skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage), + }); + + await completeStepFromBackground(2, { + accountIdentifierType: 'phone', + accountIdentifier: activation.phoneNumber, + signupPhoneNumber: activation.phoneNumber, + signupPhoneActivation: activation, + nextSignupState: landingResult?.state || step2Result?.state || 'password_page', + nextSignupUrl: landingResult?.url || step2Result?.url || '', + skippedPasswordStep: landingResult?.state === 'phone_verification_page' || landingResult?.state === 'profile_page', + }); + } + + async function executeSignupEmailEntry(state) { + const resolvedEmail = await resolveSignupEmailForFlow(state); + + let signupTabId = await ensureSignupTabForStep2(); if (await shouldForceAuthEntryRetry(signupTabId)) { await addLog('步骤 2:检测到当前位于已登录 ChatGPT 首页,先切换认证入口页再提交邮箱。', 'warn'); @@ -214,12 +309,21 @@ await completeStepFromBackground(2, { email: resolvedEmail, + accountIdentifierType: 'email', + accountIdentifier: resolvedEmail, nextSignupState: landingResult?.state || 'password_page', nextSignupUrl: landingResult?.url || step2Result?.url || '', skippedPasswordStep: landingResult?.state === 'verification_page', }); } + async function executeStep2(state) { + if (resolveSignupMethod(state) === 'phone') { + return executeSignupPhoneEntry(state); + } + return executeSignupEmailEntry(state); + } + return { executeStep2 }; } diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 10dc218..bba15b6 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -631,7 +631,6 @@ function buildPlusCheckoutUrl(checkoutSessionId, paymentMethod = PLUS_PAYMENT_ME async function createPlusCheckoutSession(options = {}) { await waitForDocumentComplete(); - const checkoutConfig = buildPlusCheckoutConfig(payload); log('Plus:正在读取 ChatGPT 登录会话...'); const sessionResponse = await fetch('/api/auth/session', { diff --git a/content/signup-page.js b/content/signup-page.js index 1923af0..bb5fd2a 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -84,6 +84,12 @@ async function handleCommand(message) { case 'SUBMIT_PHONE_NUMBER': return await phoneAuthHelpers.submitPhoneNumber(message.payload); case 'SUBMIT_PHONE_VERIFICATION_CODE': + if (message.payload?.purpose === 'signup') { + return await fillVerificationCode(message.step || 4, message.payload); + } + if (message.payload?.purpose === 'login') { + return await fillVerificationCode(message.step || 8, message.payload); + } return await phoneAuthHelpers.submitPhoneVerificationCode(message.payload); case 'RESEND_PHONE_VERIFICATION_CODE': return await phoneAuthHelpers.resendPhoneVerificationCode(); @@ -124,6 +130,9 @@ const VERIFICATION_CODE_INPUT_SELECTOR = [ const ONE_TIME_CODE_LOGIN_PATTERN = /使用一次性验证码登录|改用(?:一次性)?验证码(?:登录)?|使用验证码登录|一次性验证码|验证码登录|one[-\s]*time\s*(?:passcode|password|code)|use\s+(?:a\s+)?one[-\s]*time\s*(?:passcode|password|code)(?:\s+instead)?|use\s+(?:a\s+)?code(?:\s+instead)?|sign\s+in\s+with\s+(?:email|code)|email\s+(?:me\s+)?(?:a\s+)?code/i; 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_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; @@ -427,6 +436,15 @@ const SIGNUP_SWITCH_TO_EMAIL_PATTERN = new RegExp([ ].join('|'), 'i'); const SIGNUP_SWITCH_ACTION_PATTERN = /\u7ee7\u7eed\u4f7f\u7528|\u6539\u7528|continue|use|sign\s*(?:in|up)/i; const SIGNUP_EMAIL_ACTION_PATTERN = /\u7535\u5b50\u90ae\u4ef6|\u90ae\u7bb1|email/i; +const SIGNUP_PHONE_ACTION_PATTERN = /手机|手机号|电话号码|phone|telephone|mobile/i; +const SIGNUP_SWITCH_TO_PHONE_PATTERN = new RegExp([ + String.raw`\u7ee7\u7eed\u4f7f\u7528(?:\u624b\u673a|\u624b\u673a\u53f7|\u7535\u8bdd\u53f7\u7801)(?:\u53f7\u7801)?\u767b\u5f55`, + String.raw`\u6539\u7528(?:\u624b\u673a|\u624b\u673a\u53f7|\u7535\u8bdd\u53f7\u7801)(?:\u53f7\u7801)?\u767b\u5f55`, + String.raw`continue\s+(?:with|using)\s+(?:a\s+)?phone(?:\s+number)?`, + String.raw`use\s+(?:a\s+)?phone(?:\s+number)?(?:\s+instead)?`, + String.raw`sign\s*(?:in|up)\s+with\s+(?:a\s+)?phone`, +].join('|'), 'i'); +const SIGNUP_MORE_OPTIONS_PATTERN = /更多选项|其它方式|其他方式|more\s+options|show\s+more|other\s+(?:options|ways)/i; const SIGNUP_WORK_EMAIL_PATTERN = /\u5de5\u4f5c|business|work\s+email/i; function getSignupEmailInput() { @@ -490,6 +508,29 @@ function findSignupUseEmailTrigger() { }) || null; } +function findSignupUsePhoneTrigger() { + const candidates = document.querySelectorAll('button, a, [role="button"], [role="link"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el) || !isActionEnabled(el)) return false; + const text = getActionText(el); + if (!text) return false; + return SIGNUP_SWITCH_TO_PHONE_PATTERN.test(text) + || (SIGNUP_SWITCH_ACTION_PATTERN.test(text) && SIGNUP_PHONE_ACTION_PATTERN.test(text)); + }) || null; +} + +function findSignupMoreOptionsTrigger() { + const candidates = document.querySelectorAll('button, a, [role="button"], [role="link"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el) || !isActionEnabled(el)) return false; + const text = getActionText(el); + if (!text || !SIGNUP_MORE_OPTIONS_PATTERN.test(text)) return false; + const expanded = String(el.getAttribute?.('aria-expanded') || '').trim().toLowerCase(); + const state = String(el.getAttribute?.('data-state') || '').trim().toLowerCase(); + return expanded !== 'true' && state !== 'open'; + }) || null; +} + function getSignupEmailContinueButton({ allowDisabled = false } = {}) { const direct = document.querySelector('button[type="submit"], input[type="submit"]'); if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) { @@ -522,6 +563,41 @@ function getSignupPasswordDisplayedEmail() { } function inspectSignupEntryState() { + if (typeof isPhoneVerificationPageReady === 'function' && isPhoneVerificationPageReady()) { + return { + state: 'phone_verification_page', + verificationTarget: typeof getVerificationCodeTarget === 'function' ? getVerificationCodeTarget() : null, + displayedPhone: typeof getPhoneVerificationDisplayedPhone === 'function' ? getPhoneVerificationDisplayedPhone() : '', + url: location.href, + }; + } + + const postVerificationState = typeof getStep4PostVerificationState === 'function' + ? getStep4PostVerificationState() + : null; + if (postVerificationState?.state === 'step5') { + return { + state: 'profile_page', + url: postVerificationState.url || location.href, + }; + } + + if (postVerificationState?.state === 'logged_in_home') { + return { + state: 'logged_in_home', + skipProfileStep: true, + url: postVerificationState.url || location.href, + }; + } + + if (typeof isVerificationPageStillVisible === 'function' && isVerificationPageStillVisible()) { + return { + state: 'verification_page', + verificationTarget: typeof getVerificationCodeTarget === 'function' ? getVerificationCodeTarget() : null, + url: location.href, + }; + } + const passwordInput = getSignupPasswordInput(); if (isSignupPasswordPage() && passwordInput) { return { @@ -539,6 +615,7 @@ function inspectSignupEntryState() { state: 'email_entry', emailInput, continueButton: getSignupEmailContinueButton({ allowDisabled: true }), + switchToPhoneTrigger: findSignupUsePhoneTrigger(), url: location.href, }; } @@ -604,6 +681,14 @@ function getSignupEntryStateSummary(snapshot = inspectSignupEntryState()) { }; } + if (snapshot?.switchToPhoneTrigger) { + summary.switchToPhoneTrigger = { + tag: (snapshot.switchToPhoneTrigger.tagName || '').toLowerCase(), + text: getActionText(snapshot.switchToPhoneTrigger).slice(0, 80), + enabled: isActionEnabled(snapshot.switchToPhoneTrigger), + }; + } + return summary; } @@ -1033,11 +1118,195 @@ async function fillSignupEmailAndContinue(email, step) { }; } +function normalizePhoneDigits(value) { + let digits = String(value || '').replace(/\D+/g, ''); + if (digits.startsWith('00')) { + digits = digits.slice(2); + } + return digits; +} + +function extractDialCodeFromText(value) { + const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/); + return String(match?.[1] || match?.[2] || '').trim(); +} + +function toNationalPhoneNumber(value, dialCode) { + const digits = normalizePhoneDigits(value); + const normalizedDialCode = normalizePhoneDigits(dialCode); + const isExplicitInternational = /^\s*(?:\+|00)\s*\d/.test(String(value || '').trim()); + if (!digits) { + return ''; + } + if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) { + return digits.slice(normalizedDialCode.length); + } + if (isExplicitInternational) { + return digits; + } + return digits; +} + +function resolveSignupPhoneDialCode(phoneInput, options = {}) { + const { phoneNumber = '', countryLabel = '' } = options; + const inputRoot = phoneInput?.closest?.('fieldset, form, [data-rac], div') || document; + const visibleText = String(inputRoot?.textContent || '').replace(/\s+/g, ' ').trim(); + const rootDialCode = extractDialCodeFromText(visibleText); + if (rootDialCode) { + return rootDialCode; + } + const pageDialCode = extractDialCodeFromText(getPageTextSnapshot()); + if (pageDialCode) { + return pageDialCode; + } + const countryText = String(countryLabel || '').trim(); + if (/thailand|泰国/i.test(countryText)) return '66'; + if (/vietnam|越南/i.test(countryText)) return '84'; + if (/england|united kingdom|英国|uk/i.test(countryText)) return '44'; + const digits = normalizePhoneDigits(phoneNumber); + const knownDialCodes = ['66', '84', '44', '1', '81', '82', '86', '852', '855', '856', '60', '62', '63', '65']; + return knownDialCodes.find((code) => digits.startsWith(code) && digits.length > code.length) || ''; +} + +async function waitForSignupPhoneEntryState(options = {}) { + const { + timeout = 20000, + step = 2, + } = options; + const start = Date.now(); + let lastTriggerClickAt = 0; + let lastSwitchToPhoneAt = 0; + let lastMoreOptionsClickAt = 0; + let slowSnapshotLogged = false; + + while (Date.now() - start < timeout) { + throwIfStopped(); + const snapshot = inspectSignupEntryState(); + + if (snapshot.state === 'password_page') { + return snapshot; + } + + if (snapshot.state === 'phone_entry' && snapshot.phoneInput) { + return snapshot; + } + + if (snapshot.state === 'email_entry') { + const switchToPhone = snapshot.switchToPhoneTrigger || findSignupUsePhoneTrigger(); + if (switchToPhone && Date.now() - lastSwitchToPhoneAt >= 1500) { + lastSwitchToPhoneAt = Date.now(); + log(`步骤 ${step}:检测到邮箱输入模式,正在切换到手机号注册入口...`); + await humanPause(350, 900); + simulateClick(switchToPhone); + } else { + const moreOptionsTrigger = findSignupMoreOptionsTrigger(); + if (moreOptionsTrigger && Date.now() - lastMoreOptionsClickAt >= 1500) { + lastMoreOptionsClickAt = Date.now(); + log(`步骤 ${step}:手机号入口可能隐藏在更多选项中,正在展开更多选项...`); + await humanPause(350, 900); + simulateClick(moreOptionsTrigger); + } else if (!switchToPhone && !slowSnapshotLogged && Date.now() - start >= 5000) { + slowSnapshotLogged = true; + log(`步骤 ${step}:尚未找到手机号入口,页面诊断快照:${JSON.stringify(getSignupEntryDiagnostics())}`, 'warn'); + } + } + await sleep(250); + continue; + } + + if (snapshot.state === 'entry_home' && snapshot.signupTrigger) { + if (Date.now() - lastTriggerClickAt >= 1500) { + lastTriggerClickAt = Date.now(); + log(`步骤 ${step}:正在点击官网注册入口...`); + await humanPause(350, 900); + simulateClick(snapshot.signupTrigger); + } + await sleep(250); + continue; + } + + if (!slowSnapshotLogged && Date.now() - start >= 5000) { + slowSnapshotLogged = true; + log(`步骤 ${step}:等待手机号注册入口超过 5 秒,页面诊断快照:${JSON.stringify(getSignupEntryDiagnostics())}`, 'warn'); + } + + await sleep(250); + } + + const finalSnapshot = inspectSignupEntryState(); + log(`步骤 ${step}:等待手机号注册入口超时,最终状态快照:${JSON.stringify(getSignupEntryStateSummary(finalSnapshot))}`, 'warn'); + return finalSnapshot; +} + +async function submitSignupPhoneNumberAndContinue(payload = {}) { + const phoneNumber = String(payload.phoneNumber || '').trim(); + const countryLabel = String(payload.countryLabel || '').trim(); + if (!phoneNumber) { + throw new Error('未提供手机号,步骤 2 无法继续。'); + } + + const snapshot = await waitForSignupPhoneEntryState({ timeout: 25000, step: 2 }); + if (snapshot.state === 'password_page') { + log('步骤 2:当前已在密码页,无需重复提交手机号。'); + return { + alreadyOnPasswordPage: true, + url: snapshot.url || location.href, + }; + } + + if (snapshot.state !== 'phone_entry' || !snapshot.phoneInput) { + throw new Error(`步骤 2:未找到可用的手机号输入入口。URL: ${location.href}`); + } + + const dialCode = resolveSignupPhoneDialCode(snapshot.phoneInput, { + phoneNumber, + countryId: payload.countryId, + countryLabel, + }); + const inputValue = toNationalPhoneNumber(phoneNumber, dialCode); + if (!inputValue) { + throw new Error('步骤 2:手机号为空,无法填写。'); + } + + log(`步骤 2:正在填写手机号:${phoneNumber}`); + await humanPause(500, 1400); + fillInput(snapshot.phoneInput, inputValue); + log(`步骤 2:手机号已填写:${phoneNumber}${dialCode ? `(区号 +${dialCode},本地号 ${inputValue})` : ''}`); + + const continueButton = getSignupEmailContinueButton({ allowDisabled: true }); + if (!continueButton || !isActionEnabled(continueButton)) { + throw new Error(`步骤 2:未找到可点击的“继续”按钮。URL: ${location.href}`); + } + + log('步骤 2:手机号已准备提交,正在前往下一页...'); + window.setTimeout(() => { + try { + throwIfStopped(); + simulateClick(continueButton); + } catch (error) { + if (!isStopError(error)) { + console.error('[MultiPage:signup-page] deferred signup phone submit failed:', error?.message || error); + } + } + }, 120); + + return { + submitted: true, + deferredSubmit: true, + phoneNumber, + phoneInputValue: snapshot.phoneInput?.value || inputValue, + url: location.href, + }; +} + // ============================================================ // Step 2: Click Register, fill email, then continue to password page // ============================================================ async function step2_clickRegister(payload = {}) { + if (payload?.signupMethod === 'phone' || payload?.phoneNumber) { + return submitSignupPhoneNumberAndContinue(payload); + } const { email } = payload; return fillSignupEmailAndContinue(email, 2); } @@ -1050,12 +1319,40 @@ async function step3_fillEmailPassword(payload) { const { email, password } = payload; if (!password) throw new Error('未提供密码,步骤 3 需要可用密码。'); const normalizedEmail = String(email || '').trim().toLowerCase(); + const accountIdentifierType = String(payload?.accountIdentifierType || '').trim().toLowerCase() === 'phone' + ? 'phone' + : 'email'; + const accountIdentifier = String(payload?.accountIdentifier || email || payload?.phoneNumber || '').trim(); let snapshot = inspectSignupEntryState(); if (snapshot.state === 'entry_home') { throw new Error('当前仍停留在 ChatGPT 官网首页,请先完成步骤 2。'); } + if ( + snapshot.state === 'phone_verification_page' + || snapshot.state === 'verification_page' + || snapshot.state === 'profile_page' + || snapshot.state === 'logged_in_home' + ) { + const completionPayload = { + email: email || '', + phoneNumber: String(payload?.phoneNumber || '').trim(), + accountIdentifierType, + accountIdentifier, + signupVerificationRequestedAt: ( + snapshot.state === 'phone_verification_page' + || snapshot.state === 'verification_page' + ) ? Date.now() : null, + skippedPasswordPage: true, + deferredSubmit: false, + ...(snapshot.skipProfileStep ? { skipProfileStep: true } : {}), + }; + log('步骤 3:当前页面已进入验证码或后续阶段,密码页按已跳过处理。', 'warn'); + reportComplete(3, completionPayload); + return completionPayload; + } + if (snapshot.state === 'email_entry') { const transition = await fillSignupEmailAndContinue(email, 3); if (!transition.alreadyOnPasswordPage) { @@ -1100,6 +1397,9 @@ async function step3_fillEmailPassword(payload) { const signupVerificationRequestedAt = submitBtn ? Date.now() : null; const completionPayload = { email, + phoneNumber: String(payload?.phoneNumber || '').trim(), + accountIdentifierType, + accountIdentifier, signupVerificationRequestedAt, deferredSubmit: Boolean(submitBtn), }; @@ -1811,6 +2111,13 @@ function getLoginEmailInput() { return input && isVisibleElement(input) ? input : null; } +function getLoginPhoneInput() { + const input = document.querySelector( + 'input[type="tel"], input[autocomplete="tel"], input[name*="phone" i], input[id*="phone" i], input[placeholder*="phone" i], input[aria-label*="phone" i], input[placeholder*="telephone" i], input[aria-label*="telephone" i]' + ); + return input && isVisibleElement(input) ? input : null; +} + function getLoginPasswordInput() { const input = document.querySelector('input[type="password"]'); return input && isVisibleElement(input) ? input : null; @@ -1833,6 +2140,112 @@ function getLoginSubmitButton({ allowDisabled = false } = {}) { }) || null; } +function normalizeCountryLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&/g, ' and ') + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function getLoginPhoneCountrySelect(phoneInput) { + const scope = phoneInput?.closest?.('fieldset, form, [data-rac], div') || document; + const select = scope.querySelector?.('select'); + return select && isVisibleElement(select) ? select : null; +} + +function getLoginPhoneCountryOptionLabel(option) { + return String(option?.textContent || option?.label || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function getLoginPhoneCountryOptionMatchLabels(option) { + const labels = new Set(); + const pushLabel = (value) => { + const label = String(value || '').replace(/\s+/g, ' ').trim(); + if (label) { + labels.add(label); + } + }; + + pushLabel(getLoginPhoneCountryOptionLabel(option)); + pushLabel(option?.value); + return Array.from(labels); +} + +function findLoginPhoneCountryOptionByLabel(select, countryLabel) { + const normalizedTarget = normalizeCountryLabel(countryLabel); + if (!select || !normalizedTarget) { + return null; + } + + const options = Array.from(select.options || []); + return options.find((option) => ( + getLoginPhoneCountryOptionMatchLabels(option) + .some((label) => normalizeCountryLabel(label) === normalizedTarget) + )) || options.find((option) => { + const normalizedLabels = getLoginPhoneCountryOptionMatchLabels(option) + .map((label) => normalizeCountryLabel(label)) + .filter(Boolean); + return normalizedLabels.some((optionLabel) => ( + optionLabel.length > 2 + && normalizedTarget.length > 2 + && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)) + )); + }) || null; +} + +function findLoginPhoneCountryOptionByNumber(select, phoneNumber) { + if (!select) { + return null; + } + const digits = normalizePhoneDigits(phoneNumber); + if (!digits) { + return null; + } + + let bestMatch = null; + let bestDialCodeLength = 0; + for (const option of Array.from(select.options || [])) { + const dialCode = normalizePhoneDigits(extractDialCodeFromText(getLoginPhoneCountryOptionLabel(option))); + if (!dialCode || !digits.startsWith(dialCode)) { + continue; + } + if (dialCode.length > bestDialCodeLength) { + bestMatch = option; + bestDialCodeLength = dialCode.length; + } + } + return bestMatch; +} + +async function selectCountryForPhoneInput(phoneInput, phoneNumber = '', countryLabel = '') { + const select = getLoginPhoneCountrySelect(phoneInput); + if (!select) { + return ''; + } + + const targetOption = findLoginPhoneCountryOptionByLabel(select, countryLabel) + || findLoginPhoneCountryOptionByNumber(select, phoneNumber); + if (targetOption && String(select.value || '') !== String(targetOption.value || '')) { + select.value = String(targetOption.value || ''); + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + await sleep(250); + } + + const selectedOption = select.options?.[select.selectedIndex] || targetOption || null; + return extractDialCodeFromText(getLoginPhoneCountryOptionLabel(selectedOption)); +} + +function resolveLoginPhoneDialCode(phoneInput, options = {}) { + return resolveSignupPhoneDialCode(phoneInput, options); +} + function findLoginEntryTrigger() { const candidates = Array.from(document.querySelectorAll( 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]' @@ -1852,13 +2265,44 @@ function findLoginEntryTrigger() { }) || null; } +function findLoginPhoneEntryTrigger() { + const candidates = Array.from(document.querySelectorAll( + 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]' + )).filter((el) => isVisibleElement(el) && isActionEnabled(el)); + + return candidates.find((el) => { + const text = getActionText(el); + if (!text || LOGIN_CODE_ONLY_ACTION_PATTERN.test(text) || LOGIN_EXTERNAL_IDP_PATTERN.test(text)) return false; + return LOGIN_SWITCH_TO_PHONE_PATTERN.test(text) + || ( + LOGIN_PHONE_ACTION_PATTERN.test(text) + && !/email|邮箱|电子邮件/i.test(text) + ); + }) || null; +} + +function findLoginMoreOptionsTrigger() { + const candidates = Array.from(document.querySelectorAll( + 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]' + )).filter((el) => isVisibleElement(el) && isActionEnabled(el)); + + return candidates.find((el) => { + const text = getActionText(el); + if (!text || LOGIN_EXTERNAL_IDP_PATTERN.test(text)) return false; + return LOGIN_MORE_OPTIONS_PATTERN.test(text); + }) || null; +} + function inspectLoginAuthState() { const retryState = getLoginTimeoutErrorPageState(); const verificationTarget = getVerificationCodeTarget(); const passwordInput = getLoginPasswordInput(); const emailInput = getLoginEmailInput(); + const phoneInput = getLoginPhoneInput(); const switchTrigger = findOneTimeCodeLoginTrigger(); const loginEntryTrigger = findLoginEntryTrigger(); + const phoneEntryTrigger = findLoginPhoneEntryTrigger(); + const moreOptionsTrigger = findLoginMoreOptionsTrigger(); const submitButton = getLoginSubmitButton({ allowDisabled: true }); const verificationVisible = isVerificationPageStillVisible(); const addPhonePage = isAddPhonePageReady(); @@ -1878,9 +2322,12 @@ function inspectLoginAuthState() { verificationTarget, passwordInput, emailInput, + phoneInput, submitButton, switchTrigger, loginEntryTrigger, + phoneEntryTrigger, + moreOptionsTrigger, verificationVisible, addPhonePage, phoneVerificationPage, @@ -1924,6 +2371,13 @@ function inspectLoginAuthState() { }; } + if (phoneInput) { + return { + ...baseState, + state: 'phone_entry_page', + }; + } + if (emailInput) { return { ...baseState, @@ -1968,9 +2422,12 @@ function serializeLoginAuthState(snapshot) { hasVerificationTarget: Boolean(snapshot?.verificationTarget), hasPasswordInput: Boolean(snapshot?.passwordInput), hasEmailInput: Boolean(snapshot?.emailInput), + hasPhoneInput: Boolean(snapshot?.phoneInput), hasSubmitButton: Boolean(snapshot?.submitButton), hasSwitchTrigger: Boolean(snapshot?.switchTrigger), hasLoginEntryTrigger: Boolean(snapshot?.loginEntryTrigger), + hasPhoneEntryTrigger: Boolean(snapshot?.phoneEntryTrigger), + hasMoreOptionsTrigger: Boolean(snapshot?.moreOptionsTrigger), verificationVisible: Boolean(snapshot?.verificationVisible), addPhonePage: Boolean(snapshot?.addPhonePage), phoneVerificationPage: Boolean(snapshot?.phoneVerificationPage), @@ -1988,6 +2445,10 @@ function getLoginAuthStateLabel(snapshot) { return '密码页'; case 'email_page': return '邮箱输入页'; + case 'phone_entry_page': + return '手机号输入页'; + case 'phone_verification_page': + return '手机验证码页'; case 'login_timeout_error_page': return '登录超时报错页'; case 'oauth_consent_page': @@ -2021,14 +2482,15 @@ function getAuthLoginStepForLoginCodeStep(step = 8) { return Number(step) >= 11 ? 10 : 7; } -async function waitForLoginVerificationPageReady(timeout = 10000, visibleStep = 8) { +async function waitForLoginVerificationPageReady(timeout = 10000, visibleStep = 8, options = {}) { const start = Date.now(); let snapshot = inspectLoginAuthState(); + const allowPhoneVerificationPage = Boolean(options?.allowPhoneVerificationPage); while (Date.now() - start < timeout) { throwIfStopped(); snapshot = inspectLoginAuthState(); - if (snapshot.state === 'verification_page') { + if (snapshot.state === 'verification_page' || (allowPhoneVerificationPage && snapshot.state === 'phone_verification_page')) { return snapshot; } if (snapshot.state !== 'unknown') { @@ -2087,6 +2549,7 @@ async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, messa loginVerificationRequestedAt = null, visibleStep = 7, via = 'login_timeout_recovered', + allowPhoneVerificationPage = false, } = options; let resolvedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); let recovered = false; @@ -2114,7 +2577,7 @@ async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, messa ? normalizeStep6Snapshot(await waitForKnownLoginAuthState(4000)) : normalizeStep6Snapshot(inspectLoginAuthState()); - if (resolvedSnapshot.state === 'verification_page') { + if (resolvedSnapshot.state === 'verification_page' || (allowPhoneVerificationPage && resolvedSnapshot.state === 'phone_verification_page')) { return { action: 'done', result: createStep6SuccessResult(resolvedSnapshot, { @@ -2138,6 +2601,11 @@ async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, messa return { action: 'password', snapshot: resolvedSnapshot }; } + if (resolvedSnapshot.state === 'phone_entry_page') { + log('登录超时报错页恢复后已进入手机号输入页,继续当前登录流程。', 'warn', { step: visibleStep, stepKey: 'oauth-login' }); + return { action: 'phone', snapshot: resolvedSnapshot }; + } + if (resolvedSnapshot.state === 'email_page') { log('登录超时报错页恢复后已回到邮箱输入页,继续当前登录流程。', 'warn', { step: visibleStep, stepKey: 'oauth-login' }); return { action: 'email', snapshot: resolvedSnapshot }; @@ -2170,6 +2638,7 @@ async function finalizeStep6VerificationReady(options = {}) { loginVerificationRequestedAt = null, timeout = 12000, via = 'verification_page_ready', + allowPhoneVerificationPage = false, } = options; const start = Date.now(); const maxRounds = 3; @@ -2185,8 +2654,12 @@ async function finalizeStep6VerificationReady(options = {}) { const rawSnapshot = inspectLoginAuthState(); const snapshot = normalizeStep6Snapshot(rawSnapshot); - if (snapshot.state === 'verification_page') { - log('登录验证码页面已稳定就绪。', 'ok', { step: visibleStep, stepKey: 'oauth-login' }); + if (snapshot.state === 'verification_page' || (allowPhoneVerificationPage && snapshot.state === 'phone_verification_page')) { + log( + snapshot.state === 'phone_verification_page' ? '登录手机验证码页面已稳定就绪。' : '登录验证码页面已稳定就绪。', + 'ok', + { step: visibleStep, stepKey: 'oauth-login' } + ); return createStep6SuccessResult(snapshot, { via, loginVerificationRequestedAt, @@ -2224,8 +2697,12 @@ async function finalizeStep6VerificationReady(options = {}) { const rawSnapshot = inspectLoginAuthState(); const snapshot = normalizeStep6Snapshot(rawSnapshot); - if (snapshot.state === 'verification_page') { - log('登录验证码页面已稳定就绪。', 'ok', { step: visibleStep, stepKey: 'oauth-login' }); + if (snapshot.state === 'verification_page' || (allowPhoneVerificationPage && snapshot.state === 'phone_verification_page')) { + log( + snapshot.state === 'phone_verification_page' ? '登录手机验证码页面已稳定就绪。' : '登录验证码页面已稳定就绪。', + 'ok', + { step: visibleStep, stepKey: 'oauth-login' } + ); return createStep6SuccessResult(snapshot, { via, loginVerificationRequestedAt, @@ -2334,6 +2811,13 @@ function inspectSignupVerificationState() { }; } + if (typeof isPhoneVerificationPageReady === 'function' && isPhoneVerificationPageReady()) { + return { + state: 'verification', + phoneVerificationPage: true, + }; + } + if (isVerificationPageStillVisible()) { return { state: 'verification' }; } @@ -2400,9 +2884,6 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { } if (snapshot.state === 'logged_in_home') { - /* - log(`${prepareLogLabel}锛氶〉闈㈠凡鐩存帴杩涘叆 ChatGPT 宸茬櫥褰曟€侊紝鏈楠?鎸夊凡瀹屾垚澶勭悊锛屽苟灏嗚烦杩囨楠?5銆俙, 'ok'); - */ log(`${prepareLogLabel}:页面已直接进入 ChatGPT 已登录态,本步骤按已完成处理,并将跳过步骤 5。`, 'ok'); return { ready: true, @@ -2685,7 +3166,9 @@ async function fillVerificationCode(step, payload) { log(`步骤 ${step}:正在填写验证码:${code}`); if (step === 8) { - await waitForLoginVerificationPageReady(10000, step); + await waitForLoginVerificationPageReady(10000, step, { + allowPhoneVerificationPage: payload?.purpose === 'login' || payload?.loginIdentifierType === 'phone', + }); } const combinedSignupProfilePage = step === 4 @@ -2840,8 +3323,11 @@ async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { timeoutRecoveryReason = 'login_timeout_error_page', timeoutRecoveryMessage = '登录提交后进入登录超时报错页。', timeoutRecoveryVia = `${via}_timeout_recovered`, + allowPhoneVerificationPage = false, + allowPhoneAction = false, allowPasswordAction = false, allowEmailAction = false, + allowFinalPhoneAction = false, allowFinalPasswordAction = false, allowFinalEmailAction = false, allowFinalSwitchAction = false, @@ -2850,7 +3336,7 @@ async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { addPhoneMessage, } = options; - if (normalizedSnapshot.state === 'verification_page') { + if (normalizedSnapshot.state === 'verification_page' || (allowPhoneVerificationPage && normalizedSnapshot.state === 'phone_verification_page')) { return { action: 'done', result: createStep6SuccessResult(normalizedSnapshot, { @@ -2878,6 +3364,7 @@ async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { visibleStep, loginVerificationRequestedAt, via: timeoutRecoveryVia, + allowPhoneVerificationPage, } ); if (transition.action === 'done') { @@ -2886,6 +3373,9 @@ async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { result: transition.result, }; } + if (transition.action === 'phone') { + return { action: 'phone', snapshot: transition.snapshot }; + } if (transition.action === 'password') { return { action: 'password', snapshot: transition.snapshot }; } @@ -2898,6 +3388,10 @@ async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { }; } + if (normalizedSnapshot.state === 'phone_entry_page' && (allowPhoneAction || (final && allowFinalPhoneAction))) { + return { action: 'phone', snapshot: normalizedSnapshot }; + } + if (normalizedSnapshot.state === 'password_page') { if (allowPasswordAction || (final && allowFinalPasswordAction)) { return { action: 'password', snapshot: normalizedSnapshot }; @@ -2976,6 +3470,24 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120 }); } +async function waitForStep6PhoneSubmitTransition(phoneSubmittedAt, timeout = 12000, options = {}) { + return waitForStep6PostSubmitTransition({ + timeout, + visibleStep: Math.floor(Number(options?.visibleStep) || 0) || 7, + via: 'phone_submit', + oauthConsentVia: 'phone_submit_oauth_consent', + loginVerificationRequestedAt: phoneSubmittedAt, + timeoutRecoveryMessage: '提交手机号后进入登录超时报错页。', + timeoutRecoveryVia: 'phone_submit_timeout_recovered', + allowPhoneVerificationPage: true, + allowPasswordAction: true, + allowFinalPhoneAction: true, + stalledReason: 'phone_submit_stalled', + stalledMessage: '提交手机号后长时间未进入密码页或手机验证码页。', + addPhoneMessage: (snapshot) => `提交手机号后页面直接进入手机号补全页面,未经过登录验证码页。URL: ${snapshot.url}`, + }); +} + async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000, options = {}) { return waitForStep6PostSubmitTransition({ timeout, @@ -3028,13 +3540,34 @@ async function waitForLoginEntryOpenTransition(timeout = 10000) { return snapshot; } +async function waitForPhoneLoginEntrySwitchTransition(timeout = 10000) { + const start = Date.now(); + let snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + if (snapshot.state !== 'unknown' && snapshot.state !== 'email_page' && snapshot.state !== 'entry_page') { + return snapshot; + } + await sleep(250); + } + + return snapshot; +} + async function step6OpenLoginEntry(payload, snapshot) { const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7; const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); - const trigger = currentSnapshot.loginEntryTrigger || findLoginEntryTrigger(); + const preferPhoneLogin = String(payload?.loginIdentifierType || '').trim() === 'phone' || (!payload?.email && payload?.phoneNumber); + const trigger = preferPhoneLogin + ? (currentSnapshot.phoneEntryTrigger || findLoginPhoneEntryTrigger()) + : (currentSnapshot.loginEntryTrigger || findLoginEntryTrigger()); if (!trigger || !isActionEnabled(trigger)) { return createStep6RecoverableResult('missing_login_entry_trigger', currentSnapshot, { - message: '当前登录入口页没有可点击的邮箱登录入口。', + message: preferPhoneLogin + ? '当前登录入口页没有可点击的手机号登录入口。' + : '当前登录入口页没有可点击的邮箱登录入口。', }); } @@ -3044,11 +3577,17 @@ async function step6OpenLoginEntry(payload, snapshot) { const nextSnapshot = await waitForLoginEntryOpenTransition(); if (nextSnapshot.state === 'email_page') { + if (preferPhoneLogin) { + return switchFromEmailPageToPhoneLogin(payload, nextSnapshot); + } return step6LoginFromEmailPage(payload, nextSnapshot); } if (nextSnapshot.state === 'password_page') { return step6LoginFromPasswordPage(payload, nextSnapshot); } + if (nextSnapshot.state === 'phone_entry_page') { + return step6LoginFromPhonePage(payload, nextSnapshot); + } if (nextSnapshot.state === 'verification_page') { return finalizeStep6VerificationReady({ visibleStep, @@ -3069,13 +3608,14 @@ async function step6OpenLoginEntry(payload, snapshot) { { visibleStep } ); if (transition.action === 'done') return transition.result; + if (transition.action === 'phone') return step6LoginFromPhonePage(payload, transition.snapshot); if (transition.action === 'email') return step6LoginFromEmailPage(payload, transition.snapshot); if (transition.action === 'password') return step6LoginFromPasswordPage(payload, transition.snapshot); return transition.result; } return createStep6RecoverableResult('login_entry_open_stalled', nextSnapshot, { - message: '点击登录入口后仍未进入邮箱/密码/验证码页。', + message: '点击登录入口后仍未进入手机号/邮箱/密码/验证码页。', }); } @@ -3108,12 +3648,159 @@ async function step6SwitchToOneTimeCodeLogin(payload, snapshot) { if (result?.action === 'password') { return step6LoginFromPasswordPage(payload, result.snapshot); } + if (result?.action === 'phone') { + return step6LoginFromPhonePage(payload, result.snapshot); + } if (result?.action === 'email') { return step6LoginFromEmailPage(payload, result.snapshot); } return result; } +async function step6LoginFromPhonePage(payload, snapshot) { + const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7; + const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + const phoneInput = currentSnapshot.phoneInput || getLoginPhoneInput(); + const phoneNumber = String(payload?.phoneNumber || payload?.accountIdentifier || '').trim(); + const countryLabel = String(payload?.countryLabel || '').trim(); + const countryId = payload?.countryId; + + if (!phoneNumber) { + return createStep6RecoverableResult('missing_phone_number', currentSnapshot, { + message: '手机号登录时缺少手机号,请重新执行步骤 2 获取号码。', + }); + } + if (!phoneInput) { + return createStep6RecoverableResult('missing_phone_input', currentSnapshot, { + message: '当前登录页没有可用的手机号输入框。', + }); + } + + const dialCodeFromSelection = await selectCountryForPhoneInput(phoneInput, phoneNumber, countryLabel); + const dialCode = dialCodeFromSelection || resolveLoginPhoneDialCode(phoneInput, { + phoneNumber, + countryId, + countryLabel, + }); + const inputValue = toNationalPhoneNumber(phoneNumber, dialCode); + if (!inputValue) { + throw new Error(`步骤 ${visibleStep}:手机号为空,无法填写。`); + } + + log(`步骤 ${visibleStep}:正在填写手机号 ${phoneNumber}...`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); + await humanPause(500, 1400); + fillInput(phoneInput, inputValue); + log(`步骤 ${visibleStep}:手机号已填写${dialCode ? `(区号 +${dialCode})` : ''}。`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); + + await sleep(500); + const phoneSubmittedAt = Date.now(); + await triggerLoginSubmitAction(currentSnapshot.submitButton, phoneInput); + log(`步骤 ${visibleStep}:手机号已提交。`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); + + const transition = await waitForStep6PhoneSubmitTransition(phoneSubmittedAt, 12000, { visibleStep }); + if (transition.action === 'done') { + if (transition.result?.skipLoginVerificationStep) { + return transition.result; + } + return finalizeStep6VerificationReady({ + visibleStep, + loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || phoneSubmittedAt, + via: transition.result.via || 'phone_submit', + allowPhoneVerificationPage: true, + }); + } + if (transition.action === 'recoverable') { + log(transition.result.message || `提交手机号后仍未进入目标页面,准备重新执行步骤 ${visibleStep}。`, 'warn', { + step: visibleStep, + stepKey: 'oauth-login', + }); + return transition.result; + } + if (transition.action === 'phone') { + return step6LoginFromPhonePage(payload, transition.snapshot); + } + if (transition.action === 'password') { + return step6LoginFromPasswordPage(payload, transition.snapshot); + } + if (transition.action === 'email') { + return step6LoginFromEmailPage(payload, transition.snapshot); + } + + return createStep6RecoverableResult('phone_submit_unknown', normalizeStep6Snapshot(inspectLoginAuthState()), { + message: '提交手机号后未得到可用的下一步状态。', + }); +} + +async function switchFromEmailPageToPhoneLogin(payload, snapshot) { + const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7; + let currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + let phoneEntryTrigger = currentSnapshot.phoneEntryTrigger || findLoginPhoneEntryTrigger(); + if (!phoneEntryTrigger || !isActionEnabled(phoneEntryTrigger)) { + const moreOptionsTrigger = currentSnapshot.moreOptionsTrigger || findLoginMoreOptionsTrigger(); + if (moreOptionsTrigger && isActionEnabled(moreOptionsTrigger)) { + log(`步骤 ${visibleStep}:手机号入口可能隐藏在更多选项中,正在展开更多选项...`, 'info', { + step: visibleStep, + stepKey: 'oauth-login', + }); + await humanPause(350, 900); + simulateClick(moreOptionsTrigger); + await sleep(800); + currentSnapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + phoneEntryTrigger = currentSnapshot.phoneEntryTrigger || findLoginPhoneEntryTrigger(); + } + } + + if (!phoneEntryTrigger || !isActionEnabled(phoneEntryTrigger)) { + return createStep6RecoverableResult('missing_phone_login_entry_trigger', currentSnapshot, { + message: '本轮要求使用手机号登录,但当前邮箱登录页没有可用的手机号登录入口。', + }); + } + + log(`步骤 ${visibleStep}:当前在邮箱入口,正在切换到手机号登录...`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); + await humanPause(350, 900); + simulateClick(phoneEntryTrigger); + const nextSnapshot = normalizeStep6Snapshot(await waitForPhoneLoginEntrySwitchTransition()); + if (nextSnapshot.state === 'phone_entry_page') { + return step6LoginFromPhonePage(payload, nextSnapshot); + } + if (nextSnapshot.state === 'password_page') { + return step6LoginFromPasswordPage(payload, nextSnapshot); + } + if (nextSnapshot.state === 'verification_page' || nextSnapshot.state === 'phone_verification_page') { + return finalizeStep6VerificationReady({ + visibleStep, + loginVerificationRequestedAt: null, + via: 'phone_entry_switch_verification_page', + allowPhoneVerificationPage: true, + }); + } + if (nextSnapshot.state === 'oauth_consent_page') { + return createStep6OAuthConsentSuccessResult(nextSnapshot, { + via: 'phone_entry_switch_oauth_consent_page', + }); + } + if (nextSnapshot.state === 'login_timeout_error_page') { + const transition = await createStep6LoginTimeoutRecoveryTransition( + 'login_timeout_after_phone_entry_switch', + nextSnapshot, + '点击手机号登录入口后进入登录超时报错页。', + { + visibleStep, + allowPhoneVerificationPage: true, + } + ); + if (transition.action === 'done') return transition.result; + if (transition.action === 'phone') return step6LoginFromPhonePage(payload, transition.snapshot); + if (transition.action === 'password') return step6LoginFromPasswordPage(payload, transition.snapshot); + if (transition.action === 'email') return step6LoginFromEmailPage(payload, transition.snapshot); + return transition.result; + } + + return createStep6RecoverableResult('phone_login_entry_switch_stalled', nextSnapshot, { + message: `点击手机号登录入口后仍未进入手机号或密码页,当前停留在${getLoginAuthStateLabel(nextSnapshot)}。`, + }); +} + async function step6LoginFromPasswordPage(payload, snapshot) { const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7; const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); @@ -3159,6 +3846,9 @@ async function step6LoginFromPasswordPage(payload, snapshot) { if (transition.action === 'password') { return step6LoginFromPasswordPage(payload, transition.snapshot); } + if (transition.action === 'phone') { + return step6LoginFromPhonePage(payload, transition.snapshot); + } if (transition.action === 'email') { return step6LoginFromEmailPage(payload, transition.snapshot); } @@ -3183,6 +3873,9 @@ async function step6LoginFromPasswordPage(payload, snapshot) { async function step6LoginFromEmailPage(payload, snapshot) { const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7; const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + if (String(payload?.loginIdentifierType || '').trim() === 'phone' && payload?.phoneNumber) { + return switchFromEmailPageToPhoneLogin(payload, currentSnapshot); + } const emailInput = currentSnapshot.emailInput || getLoginEmailInput(); if (!emailInput) { throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href); @@ -3222,6 +3915,9 @@ async function step6LoginFromEmailPage(payload, snapshot) { if (transition.action === 'password') { return step6LoginFromPasswordPage(payload, transition.snapshot); } + if (transition.action === 'phone') { + return step6LoginFromPhonePage(payload, transition.snapshot); + } return createStep6RecoverableResult('email_submit_unknown', normalizeStep6Snapshot(inspectLoginAuthState()), { message: '提交邮箱后未得到可用的下一步状态。', @@ -3230,17 +3926,21 @@ async function step6LoginFromEmailPage(payload, snapshot) { async function step6_login(payload) { const visibleStep = Math.floor(Number(payload?.visibleStep) || 0) || 7; - const { email } = payload; - if (!email) throw new Error('登录时缺少邮箱地址。'); + const { email, phoneNumber } = payload; + const loginIdentifierType = String(payload?.loginIdentifierType || '').trim(); + if (!email && !phoneNumber) throw new Error('登录时缺少邮箱地址或手机号。'); const snapshot = normalizeStep6Snapshot(await waitForKnownLoginAuthState(15000)); - if (snapshot.state === 'verification_page') { + if (snapshot.state === 'verification_page' || snapshot.state === 'phone_verification_page') { log('认证页已在登录验证码页,开始确认页面是否稳定。', 'info', { step: visibleStep, stepKey: 'oauth-login' }); return finalizeStep6VerificationReady({ visibleStep, loginVerificationRequestedAt: null, - via: 'already_on_verification_page', + via: snapshot.state === 'phone_verification_page' + ? 'already_on_phone_verification_page' + : 'already_on_verification_page', + allowPhoneVerificationPage: snapshot.state === 'phone_verification_page', }); } @@ -3261,6 +3961,7 @@ async function step6_login(payload) { visibleStep, loginVerificationRequestedAt: null, via: 'login_timeout_initial_recovered', + allowPhoneVerificationPage: loginIdentifierType === 'phone' || Boolean(phoneNumber), } ); if (transition.action === 'done') { @@ -3273,6 +3974,9 @@ async function step6_login(payload) { via: transition.result.via || 'login_timeout_initial_recovered', }); } + if (transition.action === 'phone') { + return step6LoginFromPhonePage(payload, transition.snapshot); + } if (transition.action === 'email') { return step6LoginFromEmailPage(payload, transition.snapshot); } @@ -3283,10 +3987,18 @@ async function step6_login(payload) { } if (snapshot.state === 'email_page') { + if (loginIdentifierType === 'phone' && phoneNumber) { + return switchFromEmailPageToPhoneLogin(payload, snapshot); + } log(`正在使用 ${email} 登录...`, 'info', { step: visibleStep, stepKey: 'oauth-login' }); return step6LoginFromEmailPage(payload, snapshot); } + if (snapshot.state === 'phone_entry_page') { + log('正在使用手机号登录...', 'info', { step: visibleStep, stepKey: 'oauth-login' }); + return step6LoginFromPhonePage(payload, snapshot); + } + if (snapshot.state === 'password_page') { log('认证页已在密码页,继续当前登录流程。', 'info', { step: visibleStep, stepKey: 'oauth-login' }); return step6LoginFromPasswordPage(payload, snapshot); diff --git a/sidepanel/account-records-manager.js b/sidepanel/account-records-manager.js index 9621523..fe04a65 100644 --- a/sidepanel/account-records-manager.js +++ b/sidepanel/account-records-manager.js @@ -68,9 +68,33 @@ } function buildRecordId(record = {}) { - return String(record.recordId || record.email || '') - .trim() - .toLowerCase(); + const rawRecordId = String(record.recordId || '').trim(); + if (rawRecordId) { + return rawRecordId.toLowerCase(); + } + const identifierType = String(record.accountIdentifierType || '').trim().toLowerCase() === 'phone' + || (!record.email && (record.accountIdentifier || record.phoneNumber)) + ? 'phone' + : 'email'; + const identifier = String( + record.accountIdentifier + || (identifierType === 'phone' ? (record.phoneNumber || record.phone || record.number || '') : (record.email || '')) + || '' + ).trim(); + if (!identifier) { + return ''; + } + return identifierType === 'phone' + ? `phone:${identifier.toLowerCase()}` + : identifier.toLowerCase(); + } + + function getRecordPrimaryIdentifier(record = {}) { + const email = String(record.email || '').trim(); + if (email) { + return email; + } + return String(record.accountIdentifier || record.phoneNumber || record.phone || record.number || '').trim(); } function getAccountRunRecords(currentState = state.getLatestState()) { @@ -262,7 +286,7 @@ } if (!allRecords.length) { - dom.accountRecordsMeta.textContent = '暂无邮箱记录'; + dom.accountRecordsMeta.textContent = '暂无账号记录'; return; } @@ -337,7 +361,7 @@ const message = allRecords.length ? `当前筛选“${getFilterConfig(activeFilter).metaLabel}”下暂无记录` - : '暂无邮箱记录'; + : '暂无账号记录'; dom.accountRecordsList.innerHTML = `
${escapeHtml(message)}
`; } @@ -357,6 +381,7 @@ dom.accountRecordsList.innerHTML = visibleRecords.map((record) => { const recordId = buildRecordId(record); + const primaryIdentifier = getRecordPrimaryIdentifier(record) || '(空账号)'; const statusMeta = getStatusMeta(record); const summaryText = getRecordSummaryText(record); const retryCount = normalizeRetryCount(record.retryCount); @@ -383,12 +408,12 @@
-
-
-
- - 手机号验证与接码平台获取策略 -
-
- - -
-
-
- 接码平台 -
- - HeroSMS / OpenAI / Thailand -
-
- -
-
-
-
- - 用于浏览器代理接管与出口切换 -
-
- - -
-
- -