diff --git a/background.js b/background.js index b5767be..7cd6e21 100644 --- a/background.js +++ b/background.js @@ -18,12 +18,17 @@ importScripts( 'background/logging-status.js', 'background/steps/registry.js', 'data/step-definitions.js', + 'data/address-sources.js', 'background/steps/open-chatgpt.js', 'background/steps/submit-signup-email.js', 'background/steps/fill-password.js', 'background/steps/fetch-signup-code.js', 'background/steps/fill-profile.js', 'background/steps/clear-login-cookies.js', + 'background/steps/create-plus-checkout.js', + 'background/steps/fill-plus-checkout.js', + 'background/steps/paypal-approve.js', + 'background/steps/plus-return-confirm.js', 'background/steps/oauth-login.js', 'background/steps/fetch-login-code.js', 'background/steps/confirm-oauth.js', @@ -37,12 +42,28 @@ importScripts( 'content/activation-utils.js' ); -const SHARED_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.() || []; -const STEP_IDS = SHARED_STEP_DEFINITIONS +const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: false }) || []; +const PLUS_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: true }) || NORMAL_STEP_DEFINITIONS; +const ALL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.() || [ + ...NORMAL_STEP_DEFINITIONS, + ...PLUS_STEP_DEFINITIONS, +]; +const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS + .map((definition) => Number(definition?.id)) + .filter(Number.isFinite))) + .sort((left, right) => left - right); +const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite) .sort((left, right) => left - right); -const LAST_STEP_ID = STEP_IDS[STEP_IDS.length - 1] || 10; +const PLUS_STEP_IDS = PLUS_STEP_DEFINITIONS + .map((definition) => Number(definition?.id)) + .filter(Number.isFinite) + .sort((left, right) => left - right); +const LAST_STEP_ID = Math.max( + NORMAL_STEP_IDS[NORMAL_STEP_IDS.length - 1] || 10, + PLUS_STEP_IDS[PLUS_STEP_IDS.length - 1] || 10 +); const FINAL_OAUTH_CHAIN_START_STEP = 7; const { @@ -218,6 +239,32 @@ const CONTRIBUTION_RUNTIME_DEFAULTS = self.MultiPageBackgroundContributionOAuth? const CONTRIBUTION_RUNTIME_KEYS = self.MultiPageBackgroundContributionOAuth?.RUNTIME_KEYS || Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS); +function isPlusModeState(state = {}) { + return Boolean(state?.plusModeEnabled); +} + +function getStepDefinitionsForState(state = {}) { + return isPlusModeState(state) ? PLUS_STEP_DEFINITIONS : NORMAL_STEP_DEFINITIONS; +} + +function getStepIdsForState(state = {}) { + return isPlusModeState(state) ? PLUS_STEP_IDS : NORMAL_STEP_IDS; +} + +function getLastStepIdForState(state = {}) { + const ids = getStepIdsForState(state); + return ids[ids.length - 1] || 10; +} + +function getAuthChainStartStepId(state = {}) { + return isPlusModeState(state) ? 10 : FINAL_OAUTH_CHAIN_START_STEP; +} + +function getStepDefinitionForState(step, state = {}) { + const numericStep = Number(step); + return getStepDefinitionsForState(state).find((definition) => Number(definition.id) === numericStep) || null; +} + initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -264,6 +311,9 @@ const PERSISTED_SETTING_DEFAULTS = { codex2apiUrl: DEFAULT_CODEX2API_URL, codex2apiAdminKey: '', customPassword: '', + plusModeEnabled: false, + paypalEmail: '', + paypalPassword: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, autoRunDelayEnabled: false, @@ -350,6 +400,14 @@ const DEFAULT_STATE = { sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 codex2apiOAuthState: null, // Codex2API OAuth state。 + plusCheckoutTabId: null, // Plus checkout / PayPal 标签页 ID。 + plusCheckoutUrl: null, // Plus checkout 运行时短链,不写入持久配置。 + plusCheckoutCountry: 'DE', + plusCheckoutCurrency: 'EUR', + plusBillingCountryText: '', + plusBillingAddress: null, + plusPaypalApprovedAt: null, + plusReturnUrl: '', flowStartTime: null, // 当前流程开始时间。 tabRegistry: {}, // 程序维护的标签页注册表。 sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 @@ -973,9 +1031,14 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'customPassword': return String(value || ''); + case 'paypalEmail': + return String(value || '').trim(); + case 'paypalPassword': + return String(value || ''); case 'autoRunSkipFailures': case 'autoRunDelayEnabled': case 'phoneVerificationEnabled': + case 'plusModeEnabled': return Boolean(value); case 'autoRunFallbackThreadIntervalMinutes': return normalizeAutoRunFallbackThreadIntervalMinutes(value); @@ -1138,7 +1201,7 @@ async function getState() { getPersistedAliasState(), accountRunHistoryHelpers?.getPersistedAccountRunHistory?.() || [], ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, accountRunHistory, ...state }; + return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state, accountRunHistory }; } async function initializeSessionStorageAccess() { @@ -3968,14 +4031,97 @@ async function waitForTabUrlMatch(tabId, matcher, options = {}) { return tabRuntime.waitForTabUrlMatch(tabId, matcher, options); } +async function waitForTabUrlMatchUntilStopped(tabId, matcher, options = {}) { + const retryDelayMs = Math.max(100, Math.floor(Number(options.retryDelayMs) || 300)); + while (true) { + throwIfStopped(); + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('目标标签页已关闭,无法继续等待页面跳转。'); + } + if (typeof matcher === 'function' && matcher(tab.url || '', tab)) { + return tab; + } + await sleepWithStop(retryDelayMs); + } +} + async function waitForTabComplete(tabId, options = {}) { return tabRuntime.waitForTabComplete(tabId, options); } +async function waitForTabCompleteUntilStopped(tabId, options = {}) { + const retryDelayMs = Math.max(100, Math.floor(Number(options.retryDelayMs) || 300)); + while (true) { + throwIfStopped(); + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('目标标签页已关闭,无法继续等待页面加载完成。'); + } + if (tab.status === 'complete') { + return tab; + } + await sleepWithStop(retryDelayMs); + } +} + async function ensureContentScriptReadyOnTab(source, tabId, options = {}) { return tabRuntime.ensureContentScriptReadyOnTab(source, tabId, options); } +async function ensureContentScriptReadyOnTabUntilStopped(source, tabId, options = {}) { + const { + inject = null, + injectSource = null, + retryDelayMs = 700, + logMessage = '', + } = options; + let logged = false; + + while (true) { + throwIfStopped(); + const pong = await pingContentScriptOnTab(tabId); + if (pong?.ok && (!pong.source || pong.source === source)) { + await registerTab(source, tabId); + return; + } + + if (!inject || !inject.length) { + throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`); + } + + try { + if (injectSource) { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (injectedSource) => { + window.__MULTIPAGE_SOURCE = injectedSource; + }, + args: [injectSource], + }); + } + await chrome.scripting.executeScript({ + target: { tabId }, + files: inject, + }); + } catch (error) { + console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTabUntilStopped] inject failed for ${source}:`, error?.message || error); + } + + const pongAfterInject = await pingContentScriptOnTab(tabId); + if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) { + await registerTab(source, tabId); + return; + } + + if (logMessage && !logged) { + logged = true; + await addLog(logMessage, 'warn'); + } + await sleepWithStop(retryDelayMs); + } +} + // ============================================================ // Command Queue (for content scripts not yet ready) // ============================================================ @@ -3998,6 +4144,21 @@ function sendTabMessageWithTimeout(tabId, source, message, responseTimeoutMs = g return tabRuntime.sendTabMessageWithTimeout(tabId, source, message, responseTimeoutMs); } +async function sendTabMessageUntilStopped(tabId, source, message, options = {}) { + const retryDelayMs = Math.max(100, Math.floor(Number(options.retryDelayMs) || 300)); + while (true) { + throwIfStopped(); + try { + return await chrome.tabs.sendMessage(tabId, message); + } catch (error) { + if (!isRetryableContentScriptTransportError(error)) { + throw error; + } + await sleepWithStop(retryDelayMs); + } + } +} + function queueCommand(source, message, timeout = 15000) { return tabRuntime.queueCommand(source, message, timeout); } @@ -4091,6 +4252,8 @@ function getSourceLabel(source) { 'hotmail-api': 'Hotmail(API对接/本地助手)', 'luckmail-api': 'LuckMail(API 购邮)', 'cloudflare-temp-email': 'Cloudflare Temp Email', + 'plus-checkout': 'Plus Checkout', + 'paypal-flow': 'PayPal 授权页', }; return labels[source] || source || '未知来源'; } @@ -4296,26 +4459,45 @@ function isStepDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } -function getFirstUnfinishedStep(statuses = {}) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.getFirstUnfinishedStep) { - return loggingStatus.getFirstUnfinishedStep(statuses); - } - for (const step of STEP_IDS) { +function getFirstUnfinishedStep(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + for (const step of activeStepIds) { if (!isStepDoneStatus(statuses[step] || 'pending')) return step; } return null; } -function hasSavedProgress(statuses = {}) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.hasSavedProgress) { - return loggingStatus.hasSavedProgress(statuses); - } - return Object.values({ ...DEFAULT_STATE.stepStatuses, ...statuses }).some((status) => status !== 'pending'); +function hasSavedProgress(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const merged = { ...DEFAULT_STATE.stepStatuses, ...statuses }; + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + return activeStepIds.some((step) => (merged[step] || 'pending') !== 'pending'); } function getDownstreamStateResets(step) { + const plusRuntimeResets = { + plusCheckoutTabId: null, + plusCheckoutUrl: null, + plusCheckoutCountry: 'DE', + plusCheckoutCurrency: 'EUR', + plusBillingCountryText: '', + plusBillingAddress: null, + plusPaypalApprovedAt: null, + plusReturnUrl: '', + }; + if (step <= 1) { return { + ...plusRuntimeResets, oauthUrl: null, sub2apiSessionId: null, sub2apiOAuthState: null, @@ -4337,6 +4519,7 @@ function getDownstreamStateResets(step) { } if (step === 2) { return { + ...plusRuntimeResets, password: null, lastEmailTimestamp: null, signupVerificationRequestedAt: null, @@ -4350,6 +4533,7 @@ function getDownstreamStateResets(step) { } if (step === 3 || step === 4) { return { + ...plusRuntimeResets, lastEmailTimestamp: null, signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, @@ -4362,6 +4546,17 @@ function getDownstreamStateResets(step) { } if (step === 5 || step === 6 || step === 7 || step === 8) { return { + ...(step <= 6 ? plusRuntimeResets : {}), + ...(step === 7 ? { + plusBillingCountryText: '', + plusBillingAddress: null, + plusPaypalApprovedAt: null, + plusReturnUrl: '', + } : {}), + ...(step === 8 ? { + plusPaypalApprovedAt: null, + plusReturnUrl: '', + } : {}), lastLoginCode: null, loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, @@ -4371,6 +4566,7 @@ function getDownstreamStateResets(step) { } if (step === 9) { return { + plusReturnUrl: '', localhostUrl: null, }; } @@ -4383,7 +4579,15 @@ async function invalidateDownstreamAfterStepRestart(step, options = {}) { const statuses = { ...(state.stepStatuses || {}) }; const changedSteps = []; - for (let downstream = step + 1; downstream <= LAST_STEP_ID; downstream++) { + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + for (const downstream of activeStepIds) { + if (downstream <= step) { + continue; + } if (statuses[downstream] !== 'pending') { statuses[downstream] = 'pending'; changedSteps.push(downstream); @@ -4412,22 +4616,26 @@ function clearStopRequest() { stopRequested = false; } -function getRunningSteps(statuses = {}) { - if (typeof loggingStatus !== 'undefined' && loggingStatus?.getRunningSteps) { - return loggingStatus.getRunningSteps(statuses); - } - return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses }) - .filter(([, status]) => status === 'running') - .map(([step]) => Number(step)) +function getRunningSteps(statuses = {}, stateOverride = null) { + const state = stateOverride || {}; + const merged = { ...DEFAULT_STATE.stepStatuses, ...statuses }; + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); + return activeStepIds + .filter((step) => merged[step] === 'running') .sort((a, b) => a - b); } function inferStoppedRecordStep(state = {}) { const statuses = { ...DEFAULT_STATE.stepStatuses, ...(state?.stepStatuses || {}) }; - const stepIds = Object.keys(statuses) - .map((step) => Number(step)) - .filter(Number.isFinite) - .sort((left, right) => left - right); + const stepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); const runningSteps = stepIds.filter((step) => statuses[step] === 'running'); if (runningSteps.length) { @@ -4928,8 +5136,13 @@ async function ensureManualInteractionAllowed(actionLabel) { async function skipStep(step) { const state = await ensureManualInteractionAllowed('跳过步骤'); + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : (typeof STEP_IDS !== 'undefined' && Array.isArray(STEP_IDS) && STEP_IDS.length + ? STEP_IDS + : Array.from({ length: typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10 }, (_, index) => index + 1)); - if (!Number.isInteger(step) || !STEP_IDS.includes(step)) { + if (!Number.isInteger(step) || !activeStepIds.includes(step)) { throw new Error(`无效步骤:${step}`); } @@ -4942,10 +5155,12 @@ async function skipStep(step) { throw new Error(`步骤 ${step} 已完成,无需再跳过。`); } - if (step > 1) { - const prevStatus = statuses[step - 1]; + const currentIndex = activeStepIds.indexOf(step); + if (currentIndex > 0) { + const prevStep = activeStepIds[currentIndex - 1]; + const prevStatus = statuses[prevStep]; if (!isStepDoneStatus(prevStatus)) { - throw new Error(`请先完成步骤 ${step - 1},再跳过步骤 ${step}。`); + throw new Error(`请先完成步骤 ${prevStep},再跳过步骤 ${step}。`); } } @@ -5216,7 +5431,7 @@ const stepWaiters = new Map(); let resumeWaiter = null; const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000; const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([1, 2, 4, 6, 7, 8, 9]); -const STEP_COMPLETION_SIGNAL_STEPS = new Set([3, 5, 10]); +const STEP_COMPLETION_SIGNAL_STEPS = new Set([3, 5, 10, 12]); function waitForStepComplete(step, timeoutMs = 120000) { return new Promise((resolve, reject) => { @@ -5262,11 +5477,15 @@ async function completeStepFromBackground(step, payload = {}) { return; } - const completionState = step === LAST_STEP_ID ? await getState() : null; + const latestState = await getState(); + const lastStepId = typeof getLastStepIdForState === 'function' + ? getLastStepIdForState(latestState) + : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10); + const completionState = step === lastStepId ? latestState : null; await setStepStatus(step, 'completed'); await addLog(`步骤 ${step} 已完成`, 'ok'); await handleStepData(step, payload); - if (step === LAST_STEP_ID) { + if (step === lastStepId) { await appendAndBroadcastAccountRunRecord('success', completionState); } notifyStepComplete(step, payload); @@ -5345,7 +5564,7 @@ async function executeStepViaCompletionSignal(step, timeoutMs = AUTO_RUN_SIGNAL_ async function waitForRunningStepsToFinish(payload = {}) { let currentState = await getState(); - let runningSteps = getRunningSteps(currentState.stepStatuses); + let runningSteps = getRunningSteps(currentState.stepStatuses, currentState); if (!runningSteps.length) { return currentState; } @@ -5356,14 +5575,14 @@ async function waitForRunningStepsToFinish(payload = {}) { while (runningSteps.length) { await sleepWithStop(250); currentState = await getState(); - runningSteps = getRunningSteps(currentState.stepStatuses); + runningSteps = getRunningSteps(currentState.stepStatuses, currentState); } await addLog('自动继续:当前运行步骤已结束,准备按最新进度继续自动流程...', 'info'); return currentState; } -const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]); +const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10, 11, 12]); let activeTopLevelAuthChainExecution = null; function isAuthChainStep(step) { @@ -5418,7 +5637,7 @@ async function acquireTopLevelAuthChainExecution(step) { async function markRunningStepsStopped() { const state = await getState(); - const runningSteps = getRunningSteps(state.stepStatuses); + const runningSteps = getRunningSteps(state.stepStatuses, state); for (const step of runningSteps) { await setStepStatus(step, 'stopped'); @@ -5428,7 +5647,7 @@ async function markRunningStepsStopped() { async function requestStop(options = {}) { const { logMessage = '已收到停止请求,正在取消当前操作...' } = options; const state = await getState(); - const runningSteps = getRunningSteps(state.stepStatuses); + const runningSteps = getRunningSteps(state.stepStatuses, state); const inferredStopStep = inferStoppedRecordStep(state); const timerPlan = getPendingAutoRunTimerPlan(state); @@ -5531,7 +5750,15 @@ async function executeStep(step, options = {}) { await setState({ flowStartTime: Date.now() }); } - await stepRegistry.executeStep(step, state); + const activeStepRegistry = getStepRegistryForState(state); + if (!activeStepRegistry?.getStepDefinition?.(step)) { + throw new Error(`当前模式下不存在步骤:${step}`); + } + await activeStepRegistry.executeStep(step, { + ...state, + visibleStep: Number(step), + stepDefinition: getStepDefinitionForState(step, state), + }); } catch (err) { executionError = err; const state = await getState(); @@ -6266,8 +6493,14 @@ async function runAutoSequenceFromStep(startStep, context = {}) { let restartFromStep1WithCurrentEmail = false; let step = Math.max(currentStartStep, 4); - while (step <= LAST_STEP_ID) { + while (step <= (typeof getLastStepIdForState === 'function' + ? getLastStepIdForState(await getState()) + : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10))) { const latestState = await getState(); + if (typeof getStepDefinitionForState === 'function' && !getStepDefinitionForState(step, latestState)) { + step += 1; + continue; + } const currentStatus = latestState.stepStatuses?.[step] || 'pending'; if (isStepDoneStatus(currentStatus)) { await addLog(`自动运行:步骤 ${step} 当前状态为 ${currentStatus},将直接继续后续流程。`, 'info'); @@ -6317,6 +6550,11 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const restartDecision = await getPostStep6AutoRestartDecision(step, err); if (restartDecision.shouldRestart) { postStep7RestartCount += 1; + const restartStep = restartDecision.restartStep + || (typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(await getState()) + : FINAL_OAUTH_CHAIN_START_STEP); + const resetAfterStep = Math.max(1, restartStep - 1); const authState = restartDecision.authState; const authStateLabel = authState?.state ? getLoginAuthStateLabel(authState.state) : '未知页面'; const authStateSuffix = authState?.url @@ -6325,13 +6563,13 @@ async function runAutoSequenceFromStep(startStep, context = {}) { ? `当前认证页:${authStateLabel}` : '未获取到认证页状态'; await addLog( - `步骤 ${step}:检测到报错且当前未进入 add-phone,正在回到步骤 7 重新开始授权流程(第 ${postStep7RestartCount} 次重开)。${authStateSuffix};原因:${restartDecision.errorMessage || '未知错误'}`, + `步骤 ${step}:检测到报错且当前未进入 add-phone,正在回到步骤 ${restartStep} 重新开始授权流程(第 ${postStep7RestartCount} 次重开)。${authStateSuffix};原因:${restartDecision.errorMessage || '未知错误'}`, 'warn' ); - await invalidateDownstreamAfterStepRestart(6, { - logLabel: `步骤 ${step} 报错后准备回到步骤 7 重试(第 ${postStep7RestartCount} 次重开)`, + await invalidateDownstreamAfterStepRestart(resetAfterStep, { + logLabel: `步骤 ${step} 报错后准备回到步骤 ${restartStep} 重试(第 ${postStep7RestartCount} 次重开)`, }); - step = 7; + step = restartStep; continue; } @@ -6664,6 +6902,54 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({ STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, }); +const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.createPlusCheckoutCreateExecutor({ + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + reuseOrCreateTab, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, +}); +const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?.createPlusCheckoutBillingExecutor({ + addLog, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + generateRandomName, + getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry, + getTabId, + isTabAlive, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, +}); +const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPalApproveExecutor({ + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, +}); +const plusReturnConfirmExecutor = self.MultiPageBackgroundPlusReturnConfirm?.createPlusReturnConfirmExecutor({ + addLog, + completeStepFromBackground, + getTabId, + isTabAlive, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, +}); const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ addLog, chrome, @@ -6683,7 +6969,6 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ shouldBypassStep9ForLocalCpa, SUB2API_STEP9_RESPONSE_TIMEOUT_MS, }); -const stepDefinitions = SHARED_STEP_DEFINITIONS; const stepExecutorsByKey = { 'open-chatgpt': () => step1Executor.executeStep1(), 'submit-signup-email': (state) => step2Executor.executeStep2(state), @@ -6691,6 +6976,10 @@ const stepExecutorsByKey = { 'fetch-signup-code': (state) => step4Executor.executeStep4(state), 'fill-profile': (state) => step5Executor.executeStep5(state), 'clear-login-cookies': () => step6Executor.executeStep6(), + 'plus-checkout-create': (state) => plusCheckoutCreateExecutor.executePlusCheckoutCreate(state), + 'plus-checkout-billing': (state) => plusCheckoutBillingExecutor.executePlusCheckoutBilling(state), + 'paypal-approve': (state) => payPalApproveExecutor.executePayPalApprove(state), + 'plus-checkout-return': (state) => plusReturnConfirmExecutor.executePlusReturnConfirm(state), 'oauth-login': (state) => step7Executor.executeStep7(state), 'fetch-login-code': (state) => step8Executor.executeStep8(state), 'confirm-oauth': (state) => step9Executor.executeStep9(state), @@ -6741,6 +7030,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter getPendingAutoRunTimerPlan, getSourceLabel, getState, + getStepDefinitionForState, + getStepIdsForState, + getLastStepIdForState, getTabId, getStopRequested: () => stopRequested, handleCloudflareSecurityBlocked, @@ -6798,12 +7090,22 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter upsertHotmailAccount, verifyHotmailAccount, }); -const stepRegistry = self.MultiPageBackgroundStepRegistry?.createStepRegistry( - stepDefinitions.map((definition) => ({ - ...definition, - execute: stepExecutorsByKey[definition.key], - })) -); + +function buildStepRegistry(definitions = []) { + return self.MultiPageBackgroundStepRegistry?.createStepRegistry( + definitions.map((definition) => ({ + ...definition, + execute: stepExecutorsByKey[definition.key], + })) + ); +} + +const normalStepRegistry = buildStepRegistry(NORMAL_STEP_DEFINITIONS); +const plusStepRegistry = buildStepRegistry(PLUS_STEP_DEFINITIONS); + +function getStepRegistryForState(state = {}) { + return isPlusModeState(state) ? plusStepRegistry : normalStepRegistry; +} async function requestOAuthUrlFromPanel(state, options = {}) { return panelBridge.requestOAuthUrlFromPanel(state, options); @@ -7262,11 +7564,19 @@ async function getPostStep6AutoRestartDecision(step, error) { const normalizedStep = Number(step); const errorMessage = getErrorMessage(error); const shouldForceRestartFromStep7 = /restart step 7 with a new number/i.test(errorMessage); - if (!Number.isFinite(normalizedStep) || normalizedStep < 7 || normalizedStep > LAST_STEP_ID) { + const latestState = await getState(); + const authChainStartStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(latestState) + : FINAL_OAUTH_CHAIN_START_STEP; + const lastStepId = typeof getLastStepIdForState === 'function' + ? getLastStepIdForState(latestState) + : (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10); + if (!Number.isFinite(normalizedStep) || normalizedStep < authChainStartStep || normalizedStep > lastStepId) { return { shouldRestart: false, blockedByAddPhone: false, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState: null, }; @@ -7277,6 +7587,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: true, blockedByAddPhone: false, forcedByPhoneVerificationTimeout: true, + restartStep: authChainStartStep, errorMessage, authState: null, }; @@ -7287,6 +7598,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: false, blockedByAddPhone: true, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState: null, }; @@ -7310,6 +7622,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: false, blockedByAddPhone: true, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState, }; @@ -7319,6 +7632,7 @@ async function getPostStep6AutoRestartDecision(step, error) { shouldRestart: true, blockedByAddPhone: false, forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, errorMessage, authState, }; diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 761a3e4..37705a9 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -311,7 +311,7 @@ let successfulRuns = roundSummaries.filter((item) => item.status === 'success').length; const initialState = await getState(); - const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses).length + const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses, initialState).length ? 'waiting_step' : 'running'; const showResumePosition = continueCurrentOnFirstAttempt || resumeCurrentRun > 1 || resumeAttemptRun > 1; @@ -351,18 +351,18 @@ if (reuseExistingProgress) { let currentState = await getState(); - if (getRunningSteps(currentState.stepStatuses).length) { + if (getRunningSteps(currentState.stepStatuses, currentState).length) { currentState = await waitForRunningStepsToFinish({ currentRun: targetRun, totalRuns, attemptRun, }); } - const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses); - if (resumeStep && hasSavedProgress(currentState.stepStatuses)) { + const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses, currentState); + if (resumeStep && hasSavedProgress(currentState.stepStatuses, currentState)) { startStep = resumeStep; useExistingProgress = true; - } else if (hasSavedProgress(currentState.stepStatuses)) { + } else if (hasSavedProgress(currentState.stepStatuses, currentState)) { await addLog('检测到当前流程已处理完成,本轮将改为从步骤 1 重新开始。', 'info'); } } @@ -373,6 +373,9 @@ vpsUrl: prevState.vpsUrl, vpsPassword: prevState.vpsPassword, customPassword: prevState.customPassword, + plusModeEnabled: prevState.plusModeEnabled, + paypalEmail: prevState.paypalEmail, + paypalPassword: prevState.paypalPassword, autoRunSkipFailures: prevState.autoRunSkipFailures, autoRunFallbackThreadIntervalMinutes: prevState.autoRunFallbackThreadIntervalMinutes, autoRunDelayEnabled: prevState.autoRunDelayEnabled, diff --git a/background/message-router.js b/background/message-router.js index 15c503d..8261b53 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -40,6 +40,9 @@ getPendingAutoRunTimerPlan, getSourceLabel, getState, + getStepDefinitionForState, + getStepIdsForState, + getLastStepIdForState, getTabId, getStopRequested, handleAutoRunLoopUnhandledError, @@ -127,7 +130,86 @@ } } + function getStepKeyForState(step, state = {}) { + if (typeof getStepDefinitionForState === 'function') { + return String(getStepDefinitionForState(step, state)?.key || '').trim(); + } + return ''; + } + + async function handlePlatformVerifyStepData(payload) { + if (payload.localhostUrl) { + await closeLocalhostCallbackTabs(payload.localhostUrl); + } + const latestState = await getState(); + if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) { + await patchHotmailAccount(latestState.currentHotmailAccountId, { + used: true, + lastUsedAt: Date.now(), + }); + await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok'); + } + if (String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) { + await patchMail2925Account(latestState.currentMail2925AccountId, { + lastUsedAt: Date.now(), + lastError: '', + }); + await addLog('当前 2925 账号已记录最近使用时间。', 'ok'); + } + if (isLuckmailProvider(latestState)) { + const currentPurchase = getCurrentLuckmailPurchase(latestState); + if (currentPurchase?.id) { + await setLuckmailPurchaseUsedState(currentPurchase.id, true); + await addLog(`当前 LuckMail 邮箱 ${currentPurchase.email_address} 已在本地标记为已用。`, 'ok'); + } + await clearLuckmailRuntimeState({ clearEmail: true }); + await addLog('当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。', 'ok'); + } + const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl); + if (localhostPrefix) { + await closeTabsByUrlPrefix(localhostPrefix, { + excludeUrls: [payload.localhostUrl], + excludeLocalhostCallbacks: true, + }); + } + await finalizeIcloudAliasAfterSuccessfulFlow(latestState); + } + async function handleStepData(step, payload) { + const stateForStep = await getState(); + const stepKey = getStepKeyForState(step, stateForStep); + + if (stepKey === 'oauth-login') { + if (payload.loginVerificationRequestedAt) { + await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); + } + return; + } + + if (stepKey === 'fetch-login-code') { + await setState({ + lastEmailTimestamp: payload.emailTimestamp || null, + loginVerificationRequestedAt: null, + }); + return; + } + + if (stepKey === 'confirm-oauth') { + if (payload.localhostUrl) { + if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) { + throw new Error(`步骤 ${step} 返回了无效的 localhost OAuth 回调地址。`); + } + await setState({ localhostUrl: payload.localhostUrl }); + broadcastDataUpdate({ localhostUrl: payload.localhostUrl }); + } + return; + } + + if (stepKey === 'platform-verify') { + await handlePlatformVerifyStepData(payload); + return; + } + switch (step) { case 1: { const updates = {}; @@ -181,11 +263,6 @@ await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); } break; - case 7: - if (payload.loginVerificationRequestedAt) { - await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); - } - break; case 4: await setState({ lastEmailTimestamp: payload.emailTimestamp || null, @@ -200,59 +277,6 @@ } } break; - case 8: - await setState({ - lastEmailTimestamp: payload.emailTimestamp || null, - loginVerificationRequestedAt: null, - }); - break; - case 9: - if (payload.localhostUrl) { - if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) { - throw new Error('步骤 9 返回了无效的 localhost OAuth 回调地址。'); - } - await setState({ localhostUrl: payload.localhostUrl }); - broadcastDataUpdate({ localhostUrl: payload.localhostUrl }); - } - break; - case 10: { - if (payload.localhostUrl) { - await closeLocalhostCallbackTabs(payload.localhostUrl); - } - const latestState = await getState(); - if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) { - await patchHotmailAccount(latestState.currentHotmailAccountId, { - used: true, - lastUsedAt: Date.now(), - }); - await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok'); - } - if (String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) { - await patchMail2925Account(latestState.currentMail2925AccountId, { - lastUsedAt: Date.now(), - lastError: '', - }); - await addLog('当前 2925 账号已记录最近使用时间。', 'ok'); - } - if (isLuckmailProvider(latestState)) { - const currentPurchase = getCurrentLuckmailPurchase(latestState); - if (currentPurchase?.id) { - await setLuckmailPurchaseUsedState(currentPurchase.id, true); - await addLog(`当前 LuckMail 邮箱 ${currentPurchase.email_address} 已在本地标记为已用。`, 'ok'); - } - await clearLuckmailRuntimeState({ clearEmail: true }); - await addLog('当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。', 'ok'); - } - const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl); - if (localhostPrefix) { - await closeTabsByUrlPrefix(localhostPrefix, { - excludeUrls: [payload.localhostUrl], - excludeLocalhostCallbacks: true, - }); - } - await finalizeIcloudAliasAfterSuccessfulFlow(latestState); - break; - } default: break; } @@ -303,11 +327,15 @@ return { ok: true, error: errorMessage }; } - const completionState = message.step === 10 ? await getState() : null; + const completionStateCandidate = await getState(); + const lastStepId = typeof getLastStepIdForState === 'function' + ? getLastStepIdForState(completionStateCandidate) + : 10; + const completionState = message.step === lastStepId ? completionStateCandidate : null; await setStepStatus(message.step, 'completed'); await addLog(`步骤 ${message.step} 已完成`, 'ok'); await handleStepData(message.step, message.payload); - if (message.step === 10 && typeof appendAccountRunRecord === 'function') { + if (message.step === lastStepId && typeof appendAccountRunRecord === 'function') { await appendAccountRunRecord('success', completionState); } notifyStepComplete(message.step, message.payload); @@ -561,13 +589,32 @@ } case 'SAVE_SETTING': { + const currentState = await getState(); const updates = buildPersistentSettingsPayload(message.payload || {}); const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {}); + const modeChanged = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled') + && Boolean(currentState?.plusModeEnabled) !== Boolean(updates.plusModeEnabled); await setPersistentSettings(updates); - await setState({ + const stateUpdates = { ...updates, ...sessionUpdates, - }); + }; + if (modeChanged && typeof getStepIdsForState === 'function') { + const nextStateForSteps = { ...currentState, ...stateUpdates }; + stateUpdates.stepStatuses = Object.fromEntries( + getStepIdsForState(nextStateForSteps).map((stepId) => [stepId, 'pending']) + ); + stateUpdates.currentStep = 0; + } + await setState(stateUpdates); + if (modeChanged) { + await addLog( + Boolean(updates.plusModeEnabled) + ? 'Plus 模式已开启,已切换为 Plus Checkout + PayPal 步骤。' + : 'Plus 模式已关闭,已恢复普通注册授权步骤。', + 'info' + ); + } return { ok: true, state: await getState() }; } diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index 59deab9..88153a7 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -33,16 +33,23 @@ setStep8TabUpdatedListener, } = deps; + function getVisibleStep(state, fallback = 9) { + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + return visibleStep > 0 ? visibleStep : fallback; + } + async function executeStep9(state) { + const visibleStep = getVisibleStep(state, 9); if (!state.oauthUrl) { - throw new Error('缺少登录用 OAuth 链接,请先完成步骤 7。'); + const authLoginStep = visibleStep === 11 ? 10 : 7; + throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}。`); } - await addLog('步骤 9:正在监听 localhost 回调地址...'); + await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`); const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(240000, { - step: 9, + step: visibleStep, actionLabel: 'OAuth localhost 回调', }) : 240000; @@ -71,8 +78,8 @@ cleanupListener(); clearTimeout(timeout); - addLog(`步骤 9:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => { - return completeStepFromBackground(9, { localhostUrl: callbackUrl }); + addLog(`步骤 ${visibleStep}:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => { + return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl }); }).then(() => { resolve(); }).catch((err) => { @@ -81,7 +88,7 @@ }; const timeout = setTimeout(() => { - rejectStep9(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 9 的点击可能被拦截了。')); + rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); }, callbackTimeoutMs); setStep8PendingReject((error) => { @@ -111,10 +118,10 @@ if (signupTabId && await isTabAlive('signup-page')) { await chrome.tabs.update(signupTabId, { active: true }); - await addLog('步骤 9:已切回认证页,正在准备调试器点击...'); + await addLog(`步骤 ${visibleStep}:已切回认证页,正在准备调试器点击...`); } else { signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl); - await addLog('步骤 9:已重新打开认证页,正在准备调试器点击...'); + await addLog(`步骤 ${visibleStep}:已重新打开认证页,正在准备调试器点击...`); } throwIfStep8SettledOrStopped(resolved); @@ -124,11 +131,11 @@ await ensureStep8SignupPageReady(signupTabId, { timeoutMs: typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(15000, { - step: 9, + step: visibleStep, actionLabel: '等待 OAuth 同意页内容脚本就绪', }) : 15000, - logMessage: '步骤 9:认证页内容脚本尚未就绪,正在等待页面恢复...', + logMessage: `步骤 ${visibleStep}:认证页内容脚本尚未就绪,正在等待页面恢复...`, }); for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) { @@ -137,7 +144,7 @@ signupTabId, typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(STEP8_READY_WAIT_TIMEOUT_MS, { - step: 9, + step: visibleStep, actionLabel: '等待 OAuth 同意页出现', }) : STEP8_READY_WAIT_TIMEOUT_MS @@ -149,12 +156,12 @@ const strategy = STEP8_STRATEGIES[Math.min(round - 1, STEP8_STRATEGIES.length - 1)]; - await addLog(`步骤 9:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`); + await addLog(`步骤 ${visibleStep}:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`); if (strategy.mode === 'debugger') { const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(15000, { - step: 9, + step: visibleStep, actionLabel: '定位 OAuth 同意页继续按钮', }) : 15000; @@ -167,7 +174,7 @@ } else { const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(15000, { - step: 9, + step: visibleStep, actionLabel: '点击 OAuth 同意页继续按钮', }) : 15000; @@ -186,7 +193,7 @@ pageState.url, typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(15000, { - step: 9, + step: visibleStep, actionLabel: '等待 OAuth 同意页点击生效', }) : 15000 @@ -196,20 +203,20 @@ } if (effect.progressed) { - await addLog(`步骤 9:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info'); + await addLog(`步骤 ${visibleStep}:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info'); break; } if (round >= STEP8_MAX_ROUNDS) { - throw new Error(`步骤 9:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`); + throw new Error(`步骤 ${visibleStep}:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`); } - await addLog(`步骤 9:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn'); + await addLog(`步骤 ${visibleStep}:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn'); await reloadStep8ConsentPage( signupTabId, typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(30000, { - step: 9, + step: visibleStep, actionLabel: '刷新 OAuth 同意页', }) : 30000 diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js new file mode 100644 index 0000000..f00bee4 --- /dev/null +++ b/background/steps/create-plus-checkout.js @@ -0,0 +1,81 @@ +(function attachBackgroundPlusCheckoutCreate(root, factory) { + root.MultiPageBackgroundPlusCheckoutCreate = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutCreateModule() { + const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/'; + const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; + + function createPlusCheckoutCreateExecutor(deps = {}) { + const { + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + reuseOrCreateTab, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + } = deps; + + async function executePlusCheckoutCreate() { + await addLog('步骤 6:正在打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info'); + const tabId = await reuseOrCreateTab(PLUS_CHECKOUT_SOURCE, PLUS_CHECKOUT_ENTRY_URL, { + inject: PLUS_CHECKOUT_INJECT_FILES, + injectSource: PLUS_CHECKOUT_SOURCE, + reloadIfSameUrl: false, + }); + + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, { + inject: PLUS_CHECKOUT_INJECT_FILES, + injectSource: PLUS_CHECKOUT_SOURCE, + logMessage: '步骤 6:ChatGPT 页面仍在加载,等待 Plus Checkout 脚本就绪...', + }); + + const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, { + type: 'CREATE_PLUS_CHECKOUT', + source: 'background', + payload: {}, + }); + + if (result?.error) { + throw new Error(result.error); + } + if (!result?.checkoutUrl) { + throw new Error('步骤 6:Plus Checkout 创建后未返回支付链接。'); + } + + await addLog('步骤 6:Plus Checkout 已创建,正在打开支付页面...', 'ok'); + await chrome.tabs.update(tabId, { url: result.checkoutUrl, active: true }); + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, { + inject: PLUS_CHECKOUT_INJECT_FILES, + injectSource: PLUS_CHECKOUT_SOURCE, + logMessage: '步骤 6:Checkout 页面仍在加载,等待页面脚本就绪...', + }); + + await setState({ + plusCheckoutTabId: tabId, + plusCheckoutUrl: result.checkoutUrl, + plusCheckoutCountry: result.country || 'DE', + plusCheckoutCurrency: result.currency || 'EUR', + }); + + await completeStepFromBackground(6, { + plusCheckoutCountry: result.country || 'DE', + plusCheckoutCurrency: result.currency || 'EUR', + }); + } + + return { + executePlusCheckoutCreate, + }; + } + + return { + createPlusCheckoutCreateExecutor, + }; +}); diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js new file mode 100644 index 0000000..6fa2dd1 --- /dev/null +++ b/background/steps/fill-plus-checkout.js @@ -0,0 +1,94 @@ +(function attachBackgroundPlusCheckoutBilling(root, factory) { + root.MultiPageBackgroundPlusCheckoutBilling = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutBillingModule() { + const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; + + function createPlusCheckoutBillingExecutor(deps = {}) { + const { + addLog, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + generateRandomName, + getAddressSeedForCountry, + getTabId, + isTabAlive, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, + } = deps; + + async function getCheckoutTabId(state = {}) { + const registeredTabId = await getTabId(PLUS_CHECKOUT_SOURCE); + if (registeredTabId && await isTabAlive(PLUS_CHECKOUT_SOURCE)) { + return registeredTabId; + } + const storedTabId = Number(state.plusCheckoutTabId) || 0; + if (storedTabId) { + return storedTabId; + } + throw new Error('步骤 7:未找到 Plus Checkout 标签页,请先完成步骤 6。'); + } + + async function executePlusCheckoutBilling(state = {}) { + const tabId = await getCheckoutTabId(state); + await addLog('步骤 7:正在等待 Plus Checkout 页面加载完成...', 'info'); + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + + await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, { + inject: PLUS_CHECKOUT_INJECT_FILES, + injectSource: PLUS_CHECKOUT_SOURCE, + logMessage: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...', + }); + + const randomName = generateRandomName(); + const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' '); + const addressSeed = getAddressSeedForCountry(state.plusCheckoutCountry || 'DE', { + fallbackCountry: 'DE', + }); + if (!addressSeed) { + throw new Error('步骤 7:未找到可用的本地账单地址种子。'); + } + + await addLog(`步骤 7:正在选择 PayPal 并填写账单地址(${addressSeed.countryCode} / ${addressSeed.query})...`, 'info'); + const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, { + type: 'FILL_PLUS_BILLING_AND_SUBMIT', + source: 'background', + payload: { + fullName, + addressSeed, + }, + }); + + if (result?.error) { + throw new Error(result.error); + } + + await setState({ + plusCheckoutTabId: tabId, + plusBillingCountryText: result?.countryText || '', + plusBillingAddress: result?.structuredAddress || null, + }); + + await addLog('步骤 7:账单地址已提交,正在等待跳转到 PayPal...', 'info'); + await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url)); + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + + await completeStepFromBackground(7, { + plusBillingCountryText: result?.countryText || '', + }); + } + + return { + executePlusCheckoutBilling, + }; + } + + return { + createPlusCheckoutBillingExecutor, + }; +}); diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index f3209ea..d57a261 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -40,7 +40,13 @@ return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message); } + function getVisibleStep(state, fallback = 7) { + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + return visibleStep > 0 ? visibleStep : fallback; + } + async function executeStep7(state) { + const visibleStep = getVisibleStep(state, 7); if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); } @@ -56,20 +62,20 @@ const password = currentState.password || currentState.customPassword || ''; const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); if (typeof startOAuthFlowTimeoutWindow === 'function') { - await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl }); + await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl }); } const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(180000, { - step: 7, + step: visibleStep, actionLabel: 'OAuth 登录并进入验证码页', oauthUrl, }) : 180000; if (attempt === 1) { - await addLog('步骤 7:正在打开最新 OAuth 链接并登录...'); + await addLog(`步骤 ${visibleStep}:正在打开最新 OAuth 链接并登录...`); } else { - await addLog(`步骤 7:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn'); + await addLog(`步骤 ${visibleStep}:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn'); } await reuseOrCreateTab('signup-page', oauthUrl); @@ -83,13 +89,14 @@ payload: { email: currentState.email, password, + visibleStep, }, }, { timeoutMs: loginTimeoutMs, responseTimeoutMs: loginTimeoutMs, retryDelayMs: 700, - logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...', + logMessage: `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪后继续登录...`, } ); @@ -98,7 +105,7 @@ } if (isStep6SuccessResult(result)) { - await completeStepFromBackground(7, { + await completeStepFromBackground(visibleStep, { loginVerificationRequestedAt: result.loginVerificationRequestedAt || null, }); return; @@ -118,7 +125,7 @@ } if (isManagementSecretConfigError(err)) { await addLog( - `步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, + `步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, 'error' ); throw err; @@ -128,11 +135,11 @@ break; } - await addLog(`步骤 7:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn'); + await addLog(`步骤 ${visibleStep}:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn'); } } - throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`); + throw new Error(`步骤 ${visibleStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`); } return { executeStep7 }; diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js new file mode 100644 index 0000000..b638654 --- /dev/null +++ b/background/steps/paypal-approve.js @@ -0,0 +1,169 @@ +(function attachBackgroundPayPalApprove(root, factory) { + root.MultiPageBackgroundPayPalApprove = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalApproveModule() { + const PAYPAL_SOURCE = 'paypal-flow'; + const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + const PAYPAL_INJECT_FILES = ['content/utils.js', 'content/paypal-flow.js']; + + function createPayPalApproveExecutor(deps = {}) { + const { + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, + } = deps; + + async function resolvePayPalTabId(state = {}) { + const paypalTabId = await getTabId(PAYPAL_SOURCE); + if (paypalTabId && await isTabAlive(PAYPAL_SOURCE)) { + return paypalTabId; + } + const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE); + if (checkoutTabId) { + return checkoutTabId; + } + const storedTabId = Number(state.plusCheckoutTabId) || 0; + if (storedTabId) { + return storedTabId; + } + throw new Error('步骤 8:未找到 PayPal 标签页,请先完成步骤 7。'); + } + + async function ensurePayPalReady(tabId, logMessage = '') { + await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url)); + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + await ensureContentScriptReadyOnTabUntilStopped(PAYPAL_SOURCE, tabId, { + inject: PAYPAL_INJECT_FILES, + injectSource: PAYPAL_SOURCE, + logMessage: logMessage || '步骤 8:PayPal 页面仍在加载,等待脚本就绪...', + }); + } + + async function getPayPalState(tabId) { + const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { + type: 'PAYPAL_GET_STATE', + source: 'background', + payload: {}, + }); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function dismissPrompts(tabId) { + const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { + type: 'PAYPAL_DISMISS_PROMPTS', + source: 'background', + payload: {}, + }); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function submitLogin(tabId, state = {}) { + if (!state.paypalPassword) { + throw new Error('步骤 8:未配置 PayPal 密码,请先在侧边栏填写。'); + } + await addLog('步骤 8:正在填写 PayPal 登录信息并提交...', 'info'); + const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { + type: 'PAYPAL_SUBMIT_LOGIN', + source: 'background', + payload: { + email: state.paypalEmail || '', + password: state.paypalPassword || '', + }, + }); + if (result?.error) { + throw new Error(result.error); + } + } + + async function clickApprove(tabId) { + const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { + type: 'PAYPAL_CLICK_APPROVE', + source: 'background', + payload: {}, + }); + if (result?.error) { + throw new Error(result.error); + } + return Boolean(result?.clicked); + } + + async function executePayPalApprove(state = {}) { + const tabId = await resolvePayPalTabId(state); + await ensurePayPalReady(tabId); + await setState({ plusCheckoutTabId: tabId }); + + let loggedWaiting = false; + while (true) { + const currentUrl = (await chrome.tabs.get(tabId).catch(() => null))?.url || ''; + if (currentUrl && !/paypal\./i.test(currentUrl)) { + await addLog('步骤 8:PayPal 已跳转离开授权页,准备进入回跳确认。', 'ok'); + break; + } + + await ensurePayPalReady(tabId, '步骤 8:PayPal 页面正在切换,等待脚本重新就绪...'); + const pageState = await getPayPalState(tabId); + + if (pageState.needsLogin) { + await submitLogin(tabId, state); + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + continue; + } + + if (pageState.hasPasskeyPrompt) { + await addLog('步骤 8:检测到 PayPal 通行密钥提示,正在关闭...', 'info'); + await dismissPrompts(tabId); + await sleepWithStop(1000); + continue; + } + + const dismissed = await dismissPrompts(tabId).catch(() => ({ clicked: 0 })); + if (dismissed.clicked) { + await sleepWithStop(1000); + continue; + } + + if (pageState.approveReady) { + await addLog('步骤 8:正在点击 PayPal“同意并继续”...', 'info'); + const clicked = await clickApprove(tabId); + if (clicked) { + await setState({ plusPaypalApprovedAt: Date.now() }); + break; + } + } + + if (!loggedWaiting) { + loggedWaiting = true; + await addLog('步骤 8:等待 PayPal 授权按钮或下一步页面出现...', 'info'); + } + await sleepWithStop(500); + } + + await completeStepFromBackground(8, { + plusPaypalApprovedAt: Date.now(), + }); + } + + return { + executePayPalApprove, + }; + } + + return { + createPayPalApproveExecutor, + }; +}); diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index d4e01b4..fefaac5 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -26,6 +26,15 @@ return String(value || '').trim(); } + function getVisibleStep(state, fallback = 10) { + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + return visibleStep > 0 ? visibleStep : fallback; + } + + function getConfirmStepForVisibleStep(visibleStep) { + return visibleStep === 12 ? 11 : 9; + } + function parseLocalhostCallback(rawUrl) { let parsed; try { @@ -109,26 +118,28 @@ } async function executeCpaStep10(state) { + const visibleStep = getVisibleStep(state, 10); + const confirmStep = getConfirmStepForVisibleStep(visibleStep); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); } if (!state.localhostUrl) { - throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); } if (!state.vpsUrl) { throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。'); } if (shouldBypassStep9ForLocalCpa(state)) { - await addLog('步骤 10:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info'); - await completeStepFromBackground(10, { + await addLog(`步骤 ${visibleStep}:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。`, 'info'); + await completeStepFromBackground(visibleStep, { localhostUrl: state.localhostUrl, verifiedStatus: 'local-auto', }); return; } - await addLog('步骤 10:正在打开 CPA 面板...'); + await addLog(`步骤 ${visibleStep}:正在打开 CPA 面板...`); const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js']; let tabId = await getTabId('vps-panel'); @@ -149,20 +160,20 @@ inject: injectFiles, timeoutMs: 45000, retryDelayMs: 900, - logMessage: '步骤 10:CPA 面板仍在加载,正在重试连接...', + logMessage: `步骤 ${visibleStep}:CPA 面板仍在加载,正在重试连接...`, }); - await addLog('步骤 10:正在填写回调地址...'); + await addLog(`步骤 ${visibleStep}:正在填写回调地址...`); const result = await sendToContentScriptResilient('vps-panel', { type: 'EXECUTE_STEP', - step: 10, + step: visibleStep, source: 'background', - payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword }, + payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword, visibleStep }, }, { timeoutMs: 125000, responseTimeoutMs: 125000, retryDelayMs: 700, - logMessage: '步骤 10:CPA 面板通信未就绪,正在等待页面恢复...', + logMessage: `步骤 ${visibleStep}:CPA 面板通信未就绪,正在等待页面恢复...`, }); if (result?.error) { @@ -171,14 +182,16 @@ } async function executeCodex2ApiStep10(state) { + const visibleStep = getVisibleStep(state, 10); + const confirmStep = getConfirmStepForVisibleStep(visibleStep); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); } if (!state.localhostUrl) { - throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); } if (!state.codex2apiSessionId) { - throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。'); + throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${visibleStep === 12 ? 10 : 7}。`); } if (!normalizeString(state.codex2apiAdminKey)) { throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); @@ -193,7 +206,7 @@ const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); const origin = new URL(codex2apiUrl).origin; - await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...'); + await addLog(`步骤 ${visibleStep}:正在向 Codex2API 提交回调并创建账号...`); const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', { adminKey: state.codex2apiAdminKey, method: 'POST', @@ -205,19 +218,21 @@ }); const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; - await addLog(`步骤 10:${verifiedStatus}`, 'ok'); - await completeStepFromBackground(10, { + await addLog(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(visibleStep, { localhostUrl: callback.url, verifiedStatus, }); } async function executeSub2ApiStep10(state) { + const visibleStep = getVisibleStep(state, 10); + const confirmStep = getConfirmStepForVisibleStep(visibleStep); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); + throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); } if (!state.localhostUrl) { - throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); + throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); } if (!state.sub2apiSessionId) { throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。'); @@ -232,7 +247,7 @@ const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - await addLog('步骤 10:正在打开 SUB2API 后台...'); + await addLog(`步骤 ${visibleStep}:正在打开 SUB2API 后台...`); let tabId = await getTabId('sub2api-panel'); const alive = tabId && await isTabAlive('sub2api-panel'); @@ -254,12 +269,13 @@ injectSource: 'sub2api-panel', }); - await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...'); + await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`); const result = await sendToContentScript('sub2api-panel', { type: 'EXECUTE_STEP', - step: 10, + step: visibleStep, source: 'background', payload: { + visibleStep, localhostUrl: state.localhostUrl, sub2apiUrl, sub2apiEmail: state.sub2apiEmail, diff --git a/background/steps/plus-return-confirm.js b/background/steps/plus-return-confirm.js new file mode 100644 index 0000000..c233dc6 --- /dev/null +++ b/background/steps/plus-return-confirm.js @@ -0,0 +1,64 @@ +(function attachBackgroundPlusReturnConfirm(root, factory) { + root.MultiPageBackgroundPlusReturnConfirm = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusReturnConfirmModule() { + const PAYPAL_SOURCE = 'paypal-flow'; + const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + + function createPlusReturnConfirmExecutor(deps = {}) { + const { + addLog, + completeStepFromBackground, + getTabId, + isTabAlive, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + waitForTabUrlMatchUntilStopped, + } = deps; + + async function resolveReturnTabId(state = {}) { + const paypalTabId = await getTabId(PAYPAL_SOURCE); + if (paypalTabId && await isTabAlive(PAYPAL_SOURCE)) { + return paypalTabId; + } + const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE); + if (checkoutTabId) { + return checkoutTabId; + } + const storedTabId = Number(state.plusCheckoutTabId) || 0; + if (storedTabId) { + return storedTabId; + } + throw new Error('步骤 9:未找到 Plus / PayPal 标签页,无法确认订阅回跳。'); + } + + function isReturnUrl(url = '') { + return /https:\/\/(?:chatgpt\.com|chat\.openai\.com|openai\.com)\//i.test(String(url || '')) + && !/paypal\./i.test(String(url || '')); + } + + async function executePlusReturnConfirm(state = {}) { + const tabId = await resolveReturnTabId(state); + await addLog('步骤 9:正在等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面...', 'info'); + const tab = await waitForTabUrlMatchUntilStopped(tabId, isReturnUrl); + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + + await setState({ + plusCheckoutTabId: tabId, + plusReturnUrl: tab?.url || '', + }); + await completeStepFromBackground(9, { + plusReturnUrl: tab?.url || '', + }); + } + + return { + executePlusReturnConfirm, + }; + } + + return { + createPlusReturnConfirmExecutor, + }; +}); diff --git a/content/paypal-flow.js b/content/paypal-flow.js new file mode 100644 index 0000000..f003609 --- /dev/null +++ b/content/paypal-flow.js @@ -0,0 +1,275 @@ +// content/paypal-flow.js — PayPal login and approval helper. + +console.log('[MultiPage:paypal-flow] Content script loaded on', location.href); + +const PAYPAL_FLOW_LISTENER_SENTINEL = 'data-multipage-paypal-flow-listener'; + +if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') { + document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1'); + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if ( + message.type === 'PAYPAL_GET_STATE' + || message.type === 'PAYPAL_SUBMIT_LOGIN' + || message.type === 'PAYPAL_DISMISS_PROMPTS' + || message.type === 'PAYPAL_CLICK_APPROVE' + ) { + resetStopState(); + handlePayPalCommand(message).then((result) => { + sendResponse({ ok: true, ...(result || {}) }); + }).catch((err) => { + if (isStopError(err)) { + sendResponse({ stopped: true, error: err.message }); + return; + } + sendResponse({ error: err.message }); + }); + return true; + } + }); +} else { + console.log('[MultiPage:paypal-flow] 消息监听已存在,跳过重复注册'); +} + +async function handlePayPalCommand(message) { + switch (message.type) { + case 'PAYPAL_GET_STATE': + return inspectPayPalState(); + case 'PAYPAL_SUBMIT_LOGIN': + return submitPayPalLogin(message.payload || {}); + case 'PAYPAL_DISMISS_PROMPTS': + return dismissPayPalPrompts(); + case 'PAYPAL_CLICK_APPROVE': + return clickPayPalApprove(); + default: + throw new Error(`paypal-flow.js 不处理消息:${message.type}`); + } +} + +async function waitUntil(predicate, options = {}) { + const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250)); + while (true) { + throwIfStopped(); + const value = await predicate(); + if (value) { + return value; + } + await sleep(intervalMs); + } +} + +async function waitForDocumentComplete() { + await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 }); + await sleep(1000); +} + +function isVisibleElement(el) { + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && Number(rect.width) > 0 + && Number(rect.height) > 0; +} + +function normalizeText(text = '') { + return String(text || '').replace(/\s+/g, ' ').trim(); +} + +function getActionText(el) { + return normalizeText([ + el?.textContent, + el?.value, + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.getAttribute?.('placeholder'), + el?.getAttribute?.('name'), + el?.id, + ].filter(Boolean).join(' ')); +} + +function getVisibleControls(selector) { + return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement); +} + +function isEnabledControl(el) { + return Boolean(el) + && !el.disabled + && el.getAttribute?.('aria-disabled') !== 'true'; +} + +function findClickableByText(patterns) { + const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean); + const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"]'); + return candidates.find((el) => { + const text = getActionText(el); + return normalizedPatterns.some((pattern) => pattern.test(text)); + }) || null; +} + +function findInputByPatterns(patterns) { + const inputs = getVisibleControls('input') + .filter((input) => { + const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase(); + return !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type); + }); + return inputs.find((input) => { + const text = getActionText(input); + return patterns.some((pattern) => pattern.test(text)); + }) || null; +} + +function findEmailInput() { + return findInputByPatterns([ + /email|login|user|账号|邮箱/i, + ]) || getVisibleControls('input[type="email"]').find(isVisibleElement) || null; +} + +function findPasswordInput() { + return findInputByPatterns([ + /password|pass|密码/i, + ]) || getVisibleControls('input[type="password"]').find(isVisibleElement) || null; +} + +function findLoginNextButton() { + return findClickableByText([ + /next|continue|login|log\s*in|sign\s*in/i, + /下一步|继续|登录|登入/i, + ]); +} + +function findApproveButton() { + return findClickableByText([ + /同意并继续|同意|继续|授权|确认并继续/i, + /agree\s*(?:and)?\s*continue|continue|accept|authorize|agree|pay\s*now/i, + ]); +} + +function findPasskeyPromptButtons() { + const promptPatterns = [ + /passkey|通行密钥|安全密钥|下次登录|faster|save/i, + ]; + const bodyText = normalizeText(document.body?.innerText || ''); + const likelyPrompt = promptPatterns.some((pattern) => pattern.test(bodyText)); + if (!likelyPrompt) { + return []; + } + + const cancelOrClose = getVisibleControls('button, a, [role="button"]') + .filter((el) => { + const text = getActionText(el); + return /取消|稍后|不保存|不用|关闭|cancel|not now|maybe later|skip|close|x/i.test(text) + || el.getAttribute?.('aria-label')?.match(/close|关闭/i); + }); + + const iconCloseButtons = getVisibleControls('button, [role="button"]') + .filter((el) => { + const text = getActionText(el); + const rect = el.getBoundingClientRect(); + return (/^×$|^x$/i.test(text) || /close|关闭/i.test(text)) + && rect.width <= 64 + && rect.height <= 64; + }); + + return [...cancelOrClose, ...iconCloseButtons]; +} + +function hasPasskeyPrompt() { + return findPasskeyPromptButtons().length > 0; +} + +async function submitPayPalLogin(payload = {}) { + await waitForDocumentComplete(); + + const email = normalizeText(payload.email || ''); + const password = String(payload.password || ''); + if (!password) { + throw new Error('PayPal 密码为空,请先在侧边栏配置。'); + } + + let passwordInput = findPasswordInput(); + const emailInput = findEmailInput(); + + if (!passwordInput && emailInput && email) { + fillInput(emailInput, email); + const nextButton = findLoginNextButton(); + if (nextButton && isEnabledControl(nextButton)) { + simulateClick(nextButton); + } + passwordInput = await waitUntil(() => findPasswordInput(), { intervalMs: 250 }); + } else if (!passwordInput && emailInput && !email) { + throw new Error('PayPal 账号为空,请先在侧边栏配置。'); + } else if (emailInput && email && !String(emailInput.value || '').trim()) { + fillInput(emailInput, email); + } + + passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { intervalMs: 250 }); + fillInput(passwordInput, password); + await sleep(1000); + + const loginButton = await waitUntil(() => { + const button = findClickableByText([ + /login|log\s*in|sign\s*in|continue/i, + /登录|登入|继续/i, + ]); + return button && isEnabledControl(button) ? button : null; + }, { intervalMs: 250 }); + + simulateClick(loginButton); + return { submitted: true }; +} + +async function dismissPayPalPrompts() { + await waitForDocumentComplete(); + const buttons = findPasskeyPromptButtons(); + let clicked = 0; + for (const button of buttons) { + if (!isVisibleElement(button) || !isEnabledControl(button)) { + continue; + } + simulateClick(button); + clicked += 1; + await sleep(500); + } + return { + clicked, + hasPromptAfterClick: hasPasskeyPrompt(), + }; +} + +async function clickPayPalApprove() { + await waitForDocumentComplete(); + await dismissPayPalPrompts().catch(() => ({ clicked: 0 })); + + const button = findApproveButton(); + if (!button || !isEnabledControl(button)) { + return { + clicked: false, + state: inspectPayPalState(), + }; + } + + simulateClick(button); + return { + clicked: true, + buttonText: getActionText(button), + }; +} + +function inspectPayPalState() { + const emailInput = findEmailInput(); + const passwordInput = findPasswordInput(); + const approveButton = findApproveButton(); + return { + url: location.href, + readyState: document.readyState, + needsLogin: Boolean(emailInput || passwordInput), + hasEmailInput: Boolean(emailInput), + hasPasswordInput: Boolean(passwordInput), + approveReady: Boolean(approveButton && isEnabledControl(approveButton)), + approveButtonText: approveButton ? getActionText(approveButton) : '', + hasPasskeyPrompt: hasPasskeyPrompt(), + bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240), + }; +} diff --git a/content/plus-checkout.js b/content/plus-checkout.js new file mode 100644 index 0000000..a4c18f1 --- /dev/null +++ b/content/plus-checkout.js @@ -0,0 +1,454 @@ +// content/plus-checkout.js — ChatGPT Plus checkout helper. + +console.log('[MultiPage:plus-checkout] Content script loaded on', location.href); + +const PLUS_CHECKOUT_LISTENER_SENTINEL = 'data-multipage-plus-checkout-listener'; +const PLUS_CHECKOUT_PAYLOAD = { + entry_point: 'all_plans_pricing_modal', + plan_name: 'chatgptplusplan', + billing_details: { + country: 'DE', + currency: 'EUR', + }, + checkout_ui_mode: 'custom', + promo_campaign: { + promo_campaign_id: 'plus-1-month-free', + is_coupon_from_query_param: false, + }, +}; + +if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') { + document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1'); + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if ( + message.type === 'CREATE_PLUS_CHECKOUT' + || message.type === 'FILL_PLUS_BILLING_AND_SUBMIT' + || message.type === 'PLUS_CHECKOUT_GET_STATE' + ) { + resetStopState(); + handlePlusCheckoutCommand(message).then((result) => { + sendResponse({ ok: true, ...(result || {}) }); + }).catch((err) => { + if (isStopError(err)) { + sendResponse({ stopped: true, error: err.message }); + return; + } + sendResponse({ error: err.message }); + }); + return true; + } + }); +} else { + console.log('[MultiPage:plus-checkout] 消息监听已存在,跳过重复注册'); +} + +async function handlePlusCheckoutCommand(message) { + switch (message.type) { + case 'CREATE_PLUS_CHECKOUT': + return createPlusCheckoutSession(); + case 'FILL_PLUS_BILLING_AND_SUBMIT': + return fillPlusBillingAndSubmit(message.payload || {}); + case 'PLUS_CHECKOUT_GET_STATE': + return inspectPlusCheckoutState(); + default: + throw new Error(`plus-checkout.js 不处理消息:${message.type}`); + } +} + +async function waitUntil(predicate, options = {}) { + const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250)); + const label = String(options.label || '条件').trim() || '条件'; + while (true) { + throwIfStopped(); + const value = await predicate(); + if (value) { + return value; + } + await sleep(intervalMs); + } +} + +async function waitForDocumentComplete() { + await waitUntil(() => document.readyState === 'complete', { + label: '页面加载完成', + intervalMs: 200, + }); + await sleep(1000); +} + +function isVisibleElement(el) { + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && Number(rect.width) > 0 + && Number(rect.height) > 0; +} + +function normalizeText(text = '') { + return String(text || '').replace(/\s+/g, ' ').trim(); +} + +function getActionText(el) { + return normalizeText([ + el?.textContent, + el?.value, + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.getAttribute?.('placeholder'), + el?.getAttribute?.('name'), + el?.id, + ].filter(Boolean).join(' ')); +} + +function getFieldText(el) { + const id = el?.id || ''; + const labels = []; + if (id) { + labels.push(...Array.from(document.querySelectorAll(`label[for="${CSS.escape(id)}"]`)).map((label) => label.textContent)); + } + const wrappingLabel = el?.closest?.('label'); + if (wrappingLabel) { + labels.push(wrappingLabel.textContent); + } + const container = el?.closest?.('[data-testid], [class], div, section, fieldset'); + if (container) { + labels.push(container.textContent); + } + return normalizeText([ + getActionText(el), + ...labels, + ].filter(Boolean).join(' ')); +} + +function getVisibleControls(selector) { + return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement); +} + +function findClickableByText(patterns) { + const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns]) + .filter(Boolean); + const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]'); + return candidates.find((el) => { + const text = getActionText(el); + return normalizedPatterns.some((pattern) => pattern.test(text)); + }) || null; +} + +function isEnabledControl(el) { + return Boolean(el) + && !el.disabled + && el.getAttribute?.('aria-disabled') !== 'true'; +} + +function getVisibleTextInputs() { + return getVisibleControls('input, textarea') + .filter((el) => { + const type = String(el.getAttribute('type') || el.type || '').trim().toLowerCase(); + return !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type); + }); +} + +function findInputByFieldText(patterns, options = {}) { + const inputs = getVisibleTextInputs(); + const excluded = options.exclude || (() => false); + return inputs.find((input) => { + if (excluded(input)) return false; + const text = getFieldText(input); + return patterns.some((pattern) => pattern.test(text)); + }) || null; +} + +async function createPlusCheckoutSession() { + await waitForDocumentComplete(); + log('Plus:正在读取 ChatGPT 登录会话...'); + + const sessionResponse = await fetch('/api/auth/session', { + credentials: 'include', + }); + const session = await sessionResponse.json().catch(() => ({})); + const accessToken = session?.accessToken; + if (!accessToken) { + throw new Error('请先登录 ChatGPT,当前页面未返回可用 accessToken。'); + } + + log('Plus:正在创建 checkout 会话...'); + const response = await fetch('https://chatgpt.com/backend-api/payments/checkout', { + method: 'POST', + credentials: 'include', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(PLUS_CHECKOUT_PAYLOAD), + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.checkout_session_id) { + const detail = data?.detail || data?.message || `HTTP ${response.status}`; + throw new Error(`创建 Plus Checkout 失败:${detail}`); + } + + return { + checkoutUrl: `https://chatgpt.com/checkout/openai_ie/${data.checkout_session_id}`, + country: PLUS_CHECKOUT_PAYLOAD.billing_details.country, + currency: PLUS_CHECKOUT_PAYLOAD.billing_details.currency, + }; +} + +async function selectPayPalPaymentMethod() { + const paypalPattern = /paypal/i; + const existingSelected = findClickableByText([/paypal/i]); + if (existingSelected) { + simulateClick(existingSelected); + await sleep(600); + return true; + } + + const radios = getVisibleControls('input[type="radio"], [role="radio"]'); + const paypalRadio = radios.find((el) => paypalPattern.test(getFieldText(el))); + if (paypalRadio) { + simulateClick(paypalRadio); + await sleep(600); + return true; + } + + throw new Error('Plus Checkout:未找到 PayPal 付款方式。'); +} + +async function fillFullName(fullName) { + const value = normalizeText(fullName); + if (!value) return false; + const input = findInputByFieldText([ + /full\s*name|name\s*on|cardholder|billing\s*name/i, + /姓名|全名|持卡人/i, + ]); + if (!input) { + return false; + } + fillInput(input, value); + await sleep(300); + return true; +} + +function readCountryText() { + const countryInput = findInputByFieldText([ + /country|region/i, + /国家|地区/i, + ]); + if (countryInput?.value) { + return countryInput.value; + } + const countrySelect = getVisibleControls('select').find((select) => /country|region|国家|地区/i.test(getFieldText(select))); + if (countrySelect) { + const option = countrySelect.selectedOptions?.[0]; + return option?.textContent || countrySelect.value || ''; + } + return ''; +} + +function isLikelyAddressSearchInput(input) { + const text = getFieldText(input); + if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州/i.test(text)) { + return false; + } + if (/address|street|billing|search|line\s*1|地址|街道|账单/i.test(text)) { + return true; + } + return false; +} + +async function findAddressSearchInput() { + return waitUntil(() => { + const direct = findInputByFieldText([ + /address|street|billing|search|line\s*1/i, + /地址|街道|账单/i, + ], { + exclude: (input) => /city|state|province|postal|zip|country|城市|省|州|邮编|国家|地区/i.test(getFieldText(input)), + }); + if (direct) return direct; + const candidates = getVisibleTextInputs().filter(isLikelyAddressSearchInput); + return candidates[0] || null; + }, { + label: '地址搜索输入框', + intervalMs: 250, + }); +} + +function getAddressSuggestions() { + const selectors = [ + '[role="listbox"] [role="option"]', + '[role="option"]', + '.pac-container .pac-item', + '[data-testid*="address" i] [role="option"]', + 'li', + ]; + const seen = new Set(); + const results = []; + for (const selector of selectors) { + for (const el of Array.from(document.querySelectorAll(selector))) { + if (!isVisibleElement(el)) continue; + const text = normalizeText(el.textContent || el.getAttribute?.('aria-label') || ''); + if (!text || text.length < 3) continue; + const key = `${selector}:${text}`; + if (seen.has(key)) continue; + seen.add(key); + results.push(el); + } + } + return results; +} + +async function selectAddressSuggestion(seed) { + const addressInput = await findAddressSearchInput(); + fillInput(addressInput, seed.query || 'Berlin Mitte'); + await sleep(800); + + const suggestions = await waitUntil(() => { + const options = getAddressSuggestions(); + return options.length ? options : null; + }, { + label: '地址推荐列表', + intervalMs: 250, + }); + + const suggestionIndex = Math.max(0, Math.min( + suggestions.length - 1, + Math.floor(Number(seed.suggestionIndex) || 0) + )); + const target = suggestions[suggestionIndex] || suggestions[0]; + simulateClick(target); + await sleep(1200); + return { + selectedText: normalizeText(target.textContent || ''), + suggestionIndex, + }; +} + +function getStructuredAddressFields() { + const address1 = findInputByFieldText([ + /address\s*(?:line)?\s*1|street/i, + /地址\s*1|街道|详细地址/i, + ]); + const address2 = findInputByFieldText([ + /address\s*(?:line)?\s*2|apt|suite|unit/i, + /地址\s*2|公寓|单元|门牌/i, + ]); + const city = findInputByFieldText([ + /city|town|suburb/i, + /城市|市区/i, + ]); + const region = findInputByFieldText([ + /state|province|region|county/i, + /省|州|地区/i, + ]); + const postalCode = findInputByFieldText([ + /postal|zip|postcode/i, + /邮编|邮政/i, + ]); + return { address1, address2, city, region, postalCode }; +} + +function fillIfEmpty(input, value) { + if (!input || !value) return false; + if (String(input.value || '').trim()) return false; + fillInput(input, value); + return true; +} + +async function ensureStructuredAddress(seed) { + const fallback = seed?.fallback || {}; + const fields = await waitUntil(() => { + const currentFields = getStructuredAddressFields(); + if (currentFields.address1 || currentFields.city || currentFields.postalCode) { + return currentFields; + } + return null; + }, { + label: '结构化账单地址字段', + intervalMs: 250, + }); + + fillIfEmpty(fields.address1, fallback.address1); + fillIfEmpty(fields.city, fallback.city); + fillIfEmpty(fields.region, fallback.region); + fillIfEmpty(fields.postalCode, fallback.postalCode); + await sleep(500); + + const latest = getStructuredAddressFields(); + const missing = []; + if (!String(latest.address1?.value || '').trim()) missing.push('地址1'); + if (!String(latest.city?.value || '').trim()) missing.push('城市'); + if (!String(latest.postalCode?.value || '').trim()) missing.push('邮编'); + if (missing.length) { + throw new Error(`Plus Checkout:账单地址字段未填写完整:${missing.join('、')}。`); + } + + return { + address1: latest.address1?.value || '', + city: latest.city?.value || '', + region: latest.region?.value || '', + postalCode: latest.postalCode?.value || '', + }; +} + +function findSubscribeButton() { + return findClickableByText([ + /订阅|继续|确认|支付/i, + /subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i, + ]); +} + +async function fillPlusBillingAndSubmit(payload = {}) { + await waitForDocumentComplete(); + await selectPayPalPaymentMethod(); + await fillFullName(payload.fullName || ''); + + const countryText = readCountryText(); + const seed = payload.addressSeed || { + query: 'Berlin Mitte', + suggestionIndex: 1, + fallback: { + address1: 'Unter den Linden', + city: 'Berlin', + region: 'Berlin', + postalCode: '10117', + }, + }; + const selected = await selectAddressSuggestion(seed); + const structuredAddress = await ensureStructuredAddress(seed); + + const subscribeButton = await waitUntil(() => { + const button = findSubscribeButton(); + return button && isEnabledControl(button) ? button : null; + }, { + label: '订阅按钮', + intervalMs: 250, + }); + + simulateClick(subscribeButton); + return { + countryText, + selectedAddressText: selected.selectedText, + structuredAddress, + }; +} + +function inspectPlusCheckoutState() { + const structuredAddress = getStructuredAddressFields(); + return { + url: location.href, + readyState: document.readyState, + countryText: readCountryText(), + hasPayPal: Boolean(findClickableByText([/paypal/i])), + hasSubscribeButton: Boolean(findSubscribeButton()), + addressFieldValues: { + address1: structuredAddress.address1?.value || '', + city: structuredAddress.city?.value || '', + region: structuredAddress.region?.value || '', + postalCode: structuredAddress.postalCode?.value || '', + }, + }; +} diff --git a/content/signup-page.js b/content/signup-page.js index 74311df..0c85113 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -32,9 +32,10 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' handleCommand(message).then((result) => { sendResponse({ ok: true, ...(result || {}) }); }).catch(err => { + const reportedStep = Number(message.payload?.visibleStep) || message.step; if (isStopError(err)) { - if (message.step) { - log(`步骤 ${message.step || 8}:已被用户停止。`, 'warn'); + if (reportedStep) { + log(`步骤 ${reportedStep || 8}:已被用户停止。`, 'warn'); } sendResponse({ stopped: true, error: err.message }); return; @@ -46,8 +47,8 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' return; } - if (message.step) { - reportError(message.step, err.message); + if (reportedStep) { + reportError(reportedStep, err.message); } sendResponse({ error: err.message }); }); diff --git a/content/sub2api-panel.js b/content/sub2api-panel.js index 0cdd8c7..c078202 100644 --- a/content/sub2api-panel.js +++ b/content/sub2api-panel.js @@ -68,7 +68,8 @@ async function handleStep(step, payload = {}) { case 1: return step1_generateOpenAiAuthUrl(payload); case 10: - return step9_submitOpenAiCallback(payload); + case 12: + return step9_submitOpenAiCallback({ ...(payload || {}), visibleStep: step }); default: throw new Error(`sub2api-panel.js 不处理步骤 ${step}`); } @@ -501,6 +502,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) { } async function step9_submitOpenAiCallback(payload = {}) { + const visibleStep = Number(payload?.visibleStep) || 10; const callback = parseLocalhostCallback(payload.localhostUrl || ''); const backgroundState = await getBackgroundState(); const flowEmail = String(backgroundState.email || '').trim(); @@ -574,7 +576,7 @@ async function step9_submitOpenAiCallback(payload = {}) { createPayload.extra = extra; } - log(`步骤 10:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`); + log(`步骤 ${visibleStep}:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`); const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', { method: 'POST', token, @@ -582,8 +584,8 @@ async function step9_submitOpenAiCallback(payload = {}) { }); const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`; - log(`步骤 10:${verifiedStatus}`, 'ok'); - reportComplete(10, { + log(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok'); + reportComplete(visibleStep, { localhostUrl: callback.url, verifiedStatus, }); diff --git a/content/vps-panel.js b/content/vps-panel.js index 63af31c..41094f2 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -86,7 +86,9 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1') async function handleStep(step, payload) { switch (step) { case 1: return await step1_getOAuthLink(payload); - case 10: return await step9_vpsVerify(payload); + case 10: + case 12: + return await step9_vpsVerify({ ...(payload || {}), visibleStep: step }); default: throw new Error(`vps-panel.js 不处理步骤 ${step}`); } @@ -1009,27 +1011,29 @@ async function step1_getOAuthLink(payload, options = {}) { // ============================================================ async function step9_vpsVerify(payload) { - await ensureOAuthManagementPage(payload?.vpsPassword, 9); + const visibleStep = Number(payload?.visibleStep) || 10; + const confirmStep = visibleStep === 12 ? 11 : 9; + await ensureOAuthManagementPage(payload?.vpsPassword, confirmStep); // 优先从 payload 读取 localhostUrl;没有时再回退到全局状态 let localhostUrl = payload?.localhostUrl; if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) { - throw new Error('步骤 10 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 9。'); + throw new Error(`步骤 ${visibleStep} 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 ${confirmStep}。`); } if (!localhostUrl) { - log('步骤 10:payload 中没有 localhostUrl,正在从状态中读取...'); + log(`步骤 ${visibleStep}:payload 中没有 localhostUrl,正在从状态中读取...`); const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' }); localhostUrl = state.localhostUrl; if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) { - throw new Error('步骤 10 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 9。'); + throw new Error(`步骤 ${visibleStep} 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 ${confirmStep}。`); } } if (!localhostUrl) { - throw new Error('未找到 localhost 回调地址,请先完成步骤 8。'); + throw new Error(`未找到 localhost 回调地址,请先完成步骤 ${confirmStep}。`); } - log(`步骤 10:已获取 localhostUrl:${localhostUrl.slice(0, 60)}...`); + log(`步骤 ${visibleStep}:已获取 localhostUrl:${localhostUrl.slice(0, 60)}...`); - log('步骤 10:正在查找回调地址输入框...'); + log(`步骤 ${visibleStep}:正在查找回调地址输入框...`); // Find the callback URL input // Actual DOM: @@ -1046,7 +1050,7 @@ async function step9_vpsVerify(payload) { await humanPause(600, 1500); fillInput(urlInput, localhostUrl); - log(`步骤 10:已填写回调地址:${localhostUrl.slice(0, 80)}...`); + log(`步骤 ${visibleStep}:已填写回调地址:${localhostUrl.slice(0, 80)}...`); // Find and click the callback submit button in supported UI languages. const callbackSubmitPattern = /提交回调\s*URL|Submit\s+Callback\s+URL|Отправить\s+Callback\s+URL/i; @@ -1067,9 +1071,9 @@ async function step9_vpsVerify(payload) { await humanPause(450, 1200); simulateClick(submitBtn); - log('步骤 10:已点击回调提交按钮,正在等待认证结果...'); + log(`步骤 ${visibleStep}:已点击回调提交按钮,正在等待认证结果...`); const verifiedStatus = await waitForExactSuccessBadge(); - log(`步骤 10:${verifiedStatus}`, 'ok'); - reportComplete(10, { localhostUrl, verifiedStatus }); + log(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok'); + reportComplete(visibleStep, { localhostUrl, verifiedStatus }); } diff --git a/data/address-sources.js b/data/address-sources.js new file mode 100644 index 0000000..df56c85 --- /dev/null +++ b/data/address-sources.js @@ -0,0 +1,129 @@ +(function attachAddressSources(root, factory) { + root.MultiPageAddressSources = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createAddressSourcesModule() { + const COUNTRY_ALIASES = { + AU: ['au', 'aus', 'australia', '澳大利亚'], + DE: ['de', 'deu', 'germany', 'deutschland', '德国'], + FR: ['fr', 'fra', 'france', '法国'], + US: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'], + }; + + const ADDRESS_SEEDS = { + AU: [ + { + query: 'New South Wales', + suggestionIndex: 1, + fallback: { + address1: 'Thyne Reid Drive', + city: 'Thredbo', + region: 'New South Wales', + postalCode: '2625', + }, + }, + { + query: 'Sydney NSW', + suggestionIndex: 1, + fallback: { + address1: 'George Street', + city: 'Sydney', + region: 'New South Wales', + postalCode: '2000', + }, + }, + ], + DE: [ + { + query: 'Berlin Mitte', + suggestionIndex: 1, + fallback: { + address1: 'Unter den Linden', + city: 'Berlin', + region: 'Berlin', + postalCode: '10117', + }, + }, + { + query: 'Munich Altstadt', + suggestionIndex: 1, + fallback: { + address1: 'Marienplatz', + city: 'Munich', + region: 'Bavaria', + postalCode: '80331', + }, + }, + ], + FR: [ + { + query: 'Paris France', + suggestionIndex: 1, + fallback: { + address1: 'Rue de Rivoli', + city: 'Paris', + region: 'Ile-de-France', + postalCode: '75001', + }, + }, + { + query: 'Lyon France', + suggestionIndex: 1, + fallback: { + address1: 'Rue de la Republique', + city: 'Lyon', + region: 'Auvergne-Rhone-Alpes', + postalCode: '69002', + }, + }, + ], + US: [ + { + query: 'New York NY', + suggestionIndex: 1, + fallback: { + address1: 'Broadway', + city: 'New York', + region: 'New York', + postalCode: '10007', + }, + }, + ], + }; + + function normalizeCountryCode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return ''; + } + + for (const [code, aliases] of Object.entries(COUNTRY_ALIASES)) { + if (aliases.some((alias) => normalized === alias || normalized.includes(alias))) { + return code; + } + } + + const compact = normalized.replace(/[^a-z]/g, '').toUpperCase(); + return ADDRESS_SEEDS[compact] ? compact : ''; + } + + function getAddressSeedForCountry(countryValue = '', options = {}) { + const fallbackCountry = normalizeCountryCode(options.fallbackCountry || 'DE') || 'DE'; + const countryCode = normalizeCountryCode(countryValue) || fallbackCountry; + const candidates = ADDRESS_SEEDS[countryCode] || ADDRESS_SEEDS[fallbackCountry] || []; + const seed = candidates[0] || null; + if (!seed) { + return null; + } + return { + countryCode, + query: seed.query, + suggestionIndex: Math.max(0, Math.floor(Number(seed.suggestionIndex) || 0)), + fallback: { ...(seed.fallback || {}) }, + }; + } + + return { + ADDRESS_SEEDS, + getAddressSeedForCountry, + normalizeCountryCode, + }; +}); diff --git a/data/step-definitions.js b/data/step-definitions.js index 5dd6b3a..0fec026 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -1,7 +1,7 @@ (function attachStepDefinitions(root, factory) { root.MultiPageStepDefinitions = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createStepDefinitionsModule() { - const STEP_DEFINITIONS = [ + const NORMAL_STEP_DEFINITIONS = [ { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, @@ -14,19 +14,77 @@ { id: 10, order: 100, key: 'platform-verify', title: '平台回调验证' }, ]; - function getSteps() { - return STEP_DEFINITIONS.map((step) => ({ ...step })); + const PLUS_STEP_DEFINITIONS = [ + { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, + { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, + { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, + { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, + { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, + { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 Plus Checkout' }, + { id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订阅' }, + { id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权' }, + { id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认' }, + { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, + { id: 11, order: 110, key: 'confirm-oauth', title: '自动确认 OAuth' }, + { id: 12, order: 120, key: 'platform-verify', title: '平台回调验证' }, + ]; + + function isPlusModeEnabled(options = {}) { + return Boolean(options?.plusModeEnabled || options?.plusMode); } - function getStepById(id) { + function getModeStepDefinitions(options = {}) { + return isPlusModeEnabled(options) ? PLUS_STEP_DEFINITIONS : NORMAL_STEP_DEFINITIONS; + } + + function cloneSteps(steps = []) { + return steps.map((step) => ({ ...step })); + } + + function getSteps(options = {}) { + return cloneSteps(getModeStepDefinitions(options)); + } + + function getAllSteps() { + const keyed = new Map(); + for (const step of [...NORMAL_STEP_DEFINITIONS, ...PLUS_STEP_DEFINITIONS]) { + keyed.set(`${step.id}:${step.key}`, step); + } + return cloneSteps(Array.from(keyed.values()).sort((left, right) => { + const leftOrder = Number.isFinite(left.order) ? left.order : left.id; + const rightOrder = Number.isFinite(right.order) ? right.order : right.id; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return left.id - right.id; + })); + } + + function getStepIds(options = {}) { + return getModeStepDefinitions(options) + .map((step) => Number(step.id)) + .filter(Number.isFinite) + .sort((left, right) => left - right); + } + + function getLastStepId(options = {}) { + const ids = getStepIds(options); + return ids[ids.length - 1] || 0; + } + + function getStepById(id, options = {}) { const numericId = Number(id); - const match = STEP_DEFINITIONS.find((step) => step.id === numericId); + const match = getModeStepDefinitions(options).find((step) => step.id === numericId); return match ? { ...match } : null; } return { - STEP_DEFINITIONS, + STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS, + NORMAL_STEP_DEFINITIONS, + PLUS_STEP_DEFINITIONS, + getAllSteps, + getLastStepId, getStepById, + getStepIds, getSteps, + isPlusModeEnabled, }; }); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index ff8faa3..1cf6c96 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -212,6 +212,28 @@ +