From 6ad866b9624da7746e6f7c9da11e633da3b42e5b Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 01:41:40 +0800 Subject: [PATCH 1/9] =?UTF-8?q?Plua=E6=A8=A1=E5=BC=8F=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 418 +++++++++++++++++--- background/auto-run-controller.js | 13 +- background/message-router.js | 171 +++++--- background/steps/confirm-oauth.js | 45 ++- background/steps/create-plus-checkout.js | 81 ++++ background/steps/fill-plus-checkout.js | 94 +++++ background/steps/oauth-login.js | 25 +- background/steps/paypal-approve.js | 169 ++++++++ background/steps/platform-verify.js | 58 ++- background/steps/plus-return-confirm.js | 64 +++ content/paypal-flow.js | 275 +++++++++++++ content/plus-checkout.js | 454 ++++++++++++++++++++++ content/signup-page.js | 9 +- content/sub2api-panel.js | 10 +- content/vps-panel.js | 28 +- data/address-sources.js | 129 ++++++ data/step-definitions.js | 70 +++- sidepanel/sidepanel.html | 22 ++ sidepanel/sidepanel.js | 110 +++++- tests/address-sources.test.js | 23 ++ tests/background-auth-chain-guard.test.js | 18 + tests/background-step-registry.test.js | 7 +- tests/step-definitions-module.test.js | 33 +- 项目完整链路说明.md | 38 +- 项目文件结构说明.md | 16 +- 25 files changed, 2173 insertions(+), 207 deletions(-) create mode 100644 background/steps/create-plus-checkout.js create mode 100644 background/steps/fill-plus-checkout.js create mode 100644 background/steps/paypal-approve.js create mode 100644 background/steps/plus-return-confirm.js create mode 100644 content/paypal-flow.js create mode 100644 content/plus-checkout.js create mode 100644 data/address-sources.js create mode 100644 tests/address-sources.test.js 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 @@ +
+ Plus 模式 +
+
+ +
+ PayPal 订阅链路 +
+
+ +
邮箱服务
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 9a73739..7792b30 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -99,6 +99,12 @@ const inputCodex2ApiUrl = document.getElementById('input-codex2api-url'); const rowCodex2ApiAdminKey = document.getElementById('row-codex2api-admin-key'); const inputCodex2ApiAdminKey = document.getElementById('input-codex2api-admin-key'); const rowCustomPassword = document.getElementById('row-custom-password'); +const rowPlusMode = document.getElementById('row-plus-mode'); +const inputPlusModeEnabled = document.getElementById('input-plus-mode-enabled'); +const rowPaypalEmail = document.getElementById('row-paypal-email'); +const inputPaypalEmail = document.getElementById('input-paypal-email'); +const rowPaypalPassword = document.getElementById('row-paypal-password'); +const inputPaypalPassword = document.getElementById('input-paypal-password'); const selectMailProvider = document.getElementById('select-mail-provider'); const btnMailLogin = document.getElementById('btn-mail-login'); const rowCustomMailProviderPool = document.getElementById('row-custom-mail-provider-pool'); @@ -244,11 +250,12 @@ const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel'); const btnAutoStartRestart = document.getElementById('btn-auto-start-restart'); const btnAutoStartContinue = document.getElementById('btn-auto-start-continue'); const autoHintText = document.querySelector('.auto-hint'); -const stepDefinitions = (window.MultiPageStepDefinitions?.getSteps?.() || []).sort((left, right) => left.order - right.order); -const STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); -const STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); -const SKIPPABLE_STEPS = new Set(STEP_IDS); const stepsList = document.querySelector('.steps-list'); +let currentPlusModeEnabled = false; +let stepDefinitions = getStepDefinitionsForMode(false); +let STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); +let STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); +let SKIPPABLE_STEPS = new Set(STEP_IDS); const AUTO_DELAY_MIN_MINUTES = 1; const AUTO_DELAY_MAX_MINUTES = 1440; const AUTO_DELAY_DEFAULT_MINUTES = 30; @@ -269,6 +276,24 @@ const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed'; const AUTO_SKIP_FAILURES_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-skip-failures-prompt-dismissed'; const AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-fallback-risk-prompt-dismissed'; + +function getStepDefinitionsForMode(plusModeEnabled = false) { + return (window.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled }) || []) + .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 rebuildStepDefinitionState(plusModeEnabled = false) { + currentPlusModeEnabled = Boolean(plusModeEnabled); + stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled); + STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); + STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); + SKIPPABLE_STEPS = new Set(STEP_IDS); +} const CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version'; const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; @@ -1100,7 +1125,8 @@ function isDoneStatus(status) { } function getStepStatuses(state = latestState) { - return { ...STEP_DEFAULT_STATUSES, ...(state?.stepStatuses || {}) }; + const merged = { ...STEP_DEFAULT_STATUSES, ...(state?.stepStatuses || {}) }; + return Object.fromEntries(STEP_IDS.map((stepId) => [stepId, merged[stepId] || 'pending'])); } function getFirstUnfinishedStep(state = latestState) { @@ -1690,6 +1716,15 @@ function collectSettingsPayload() { sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(), codex2apiUrl: inputCodex2ApiUrl.value.trim(), codex2apiAdminKey: inputCodex2ApiAdminKey.value.trim(), + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), + paypalEmail: typeof inputPaypalEmail !== 'undefined' && inputPaypalEmail + ? inputPaypalEmail.value.trim() + : String(latestState?.paypalEmail || ''), + paypalPassword: typeof inputPaypalPassword !== 'undefined' && inputPaypalPassword + ? inputPaypalPassword.value + : String(latestState?.paypalPassword || ''), ...(contributionModeEnabled ? {} : { customPassword: inputPassword.value, }), @@ -1924,6 +1959,21 @@ function updatePhoneVerificationSettingsUI() { }); } +function updatePlusModeUI() { + const enabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : false; + [ + typeof rowPaypalEmail !== 'undefined' ? rowPaypalEmail : null, + typeof rowPaypalPassword !== 'undefined' ? rowPaypalPassword : null, + ].forEach((row) => { + if (!row) { + return; + } + row.style.display = enabled ? '' : 'none'; + }); +} + function setSettingsCardLocked(locked) { if (!settingsCard) { return; @@ -2104,6 +2154,9 @@ function applyAutoRunStatus(payload = currentAutoRun) { function initializeManualStepActions() { document.querySelectorAll('.step-row').forEach((row) => { + if (row.querySelector('.step-actions')) { + return; + } const step = Number(row.dataset.step); const statusEl = row.querySelector('.step-status'); if (!statusEl) return; @@ -2147,6 +2200,21 @@ function renderStepsList() { if (stepsProgress) { stepsProgress.textContent = `0 / ${STEP_IDS.length}`; } + + initializeManualStepActions(); + updateProgressCounter(); + updateButtonStates(); +} + +function syncStepDefinitionsForMode(plusModeEnabled = false, options = {}) { + const nextPlusModeEnabled = Boolean(plusModeEnabled); + const shouldRender = Boolean(options.render) || nextPlusModeEnabled !== currentPlusModeEnabled; + if (!shouldRender) { + return; + } + + rebuildStepDefinitionState(nextPlusModeEnabled); + renderStepsList(); } // ============================================================ @@ -2154,11 +2222,23 @@ function renderStepsList() { // ============================================================ function applySettingsState(state) { + if (typeof syncStepDefinitionsForMode === 'function') { + syncStepDefinitionsForMode(Boolean(state?.plusModeEnabled), { render: true }); + } syncLatestState(state); syncAutoRunState(state); inputEmail.value = state?.email || ''; syncPasswordField(state || {}); + if (typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled) { + inputPlusModeEnabled.checked = Boolean(state?.plusModeEnabled); + } + if (typeof inputPaypalEmail !== 'undefined' && inputPaypalEmail) { + inputPaypalEmail.value = state?.paypalEmail || ''; + } + if (typeof inputPaypalPassword !== 'undefined' && inputPaypalPassword) { + inputPaypalPassword.value = state?.paypalPassword || ''; + } inputVpsUrl.value = state?.vpsUrl || ''; inputVpsPassword.value = state?.vpsPassword || ''; setLocalCpaStep9Mode(state?.localCpaStep9Mode); @@ -2277,6 +2357,9 @@ function applySettingsState(state) { updateFallbackThreadIntervalInputState(); updateAccountRunHistorySettingsUI(); updatePhoneVerificationSettingsUI(); + if (typeof updatePlusModeUI === 'function') { + updatePlusModeUI(); + } updatePanelModeUI(); updateMailProviderUI(); if (isLuckmailProvider(state?.mailProvider)) { @@ -4651,6 +4734,23 @@ inputPassword.addEventListener('blur', () => { saveSettings({ silent: true }).catch(() => { }); }); +inputPlusModeEnabled?.addEventListener('change', () => { + updatePlusModeUI(); + syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled.checked), { render: true }); + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + +[inputPaypalEmail, inputPaypalPassword].forEach((input) => { + input?.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); + }); + input?.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); + }); +}); + selectMailProvider.addEventListener('change', async () => { const previousProvider = latestState?.mailProvider || ''; const previousMail2925Mode = latestState?.mail2925Mode; diff --git a/tests/address-sources.test.js b/tests/address-sources.test.js new file mode 100644 index 0000000..98dd917 --- /dev/null +++ b/tests/address-sources.test.js @@ -0,0 +1,23 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('address sources normalize supported countries and return local seeds', () => { + const source = fs.readFileSync('data/address-sources.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageAddressSources;`)(globalScope); + + assert.equal(api.normalizeCountryCode('Deutschland'), 'DE'); + assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU'); + assert.equal(api.normalizeCountryCode('unknown'), ''); + + const deSeed = api.getAddressSeedForCountry('DE'); + assert.equal(deSeed.countryCode, 'DE'); + assert.equal(deSeed.suggestionIndex, 1); + assert.equal(Boolean(deSeed.query), true); + assert.equal(Boolean(deSeed.fallback.city), true); + + const fallbackSeed = api.getAddressSeedForCountry('unknown', { fallbackCountry: 'AU' }); + assert.equal(fallbackSeed.countryCode, 'AU'); + assert.equal(fallbackSeed.fallback.region, 'New South Wales'); +}); diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js index b781570..c0a6356 100644 --- a/tests/background-auth-chain-guard.test.js +++ b/tests/background-auth-chain-guard.test.js @@ -118,6 +118,9 @@ function isRetryableContentScriptTransportError() { return false; } const stepRegistry = { + getStepDefinition(step) { + return { id: step, key: 'test-step' }; + }, async executeStep(step) { events.registryCalls.push(step); if (step === 8) { @@ -127,6 +130,12 @@ const stepRegistry = { } }, }; +function getStepRegistryForState() { + return stepRegistry; +} +function getStepDefinitionForState(step) { + return { id: step, key: 'test-step' }; +} ${extractFunction('isStopError')} ${extractFunction('throwIfStopped')} @@ -212,10 +221,19 @@ function isRetryableContentScriptTransportError() { return false; } const stepRegistry = { + getStepDefinition(step) { + return { id: step, key: 'test-step' }; + }, async executeStep() { throw new Error('BROWSER_SWITCH_REQUIRED::请更换浏览器进行注册登录。'); }, }; +function getStepRegistryForState() { + return stepRegistry; +} +function getStepDefinitionForState(step) { + return { id: step, key: 'test-step' }; +} ${extractFunction('isStopError')} ${extractFunction('throwIfStopped')} diff --git a/tests/background-step-registry.test.js b/tests/background-step-registry.test.js index 13972f3..d4afc3e 100644 --- a/tests/background-step-registry.test.js +++ b/tests/background-step-registry.test.js @@ -7,5 +7,10 @@ test('background imports step registry and shared step definitions', () => { assert.match(source, /background\/steps\/registry\.js/); assert.match(source, /data\/step-definitions\.js/); assert.match(source, /MultiPageStepDefinitions\?\.getSteps/); - assert.match(source, /stepRegistry\.executeStep\(step,\s*state\)/); + assert.match(source, /getStepRegistryForState\(state\)/); + assert.match(source, /activeStepRegistry\.executeStep\(step,\s*\{/); + assert.match(source, /background\/steps\/create-plus-checkout\.js/); + assert.match(source, /background\/steps\/fill-plus-checkout\.js/); + assert.match(source, /background\/steps\/paypal-approve\.js/); + assert.match(source, /background\/steps\/plus-return-confirm\.js/); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index 859bffb..9ac6f7b 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -2,15 +2,16 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -test('step definitions module exposes ordered shared step metadata', () => { +test('step definitions module exposes ordered normal and Plus step metadata', () => { const source = fs.readFileSync('data/step-definitions.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope); const steps = api.getSteps(); + const plusSteps = api.getSteps({ plusModeEnabled: true }); assert.equal(Array.isArray(steps), true); - assert.equal(steps.length >= 10, true); + assert.equal(steps.length, 10); assert.deepStrictEqual( steps.map((step) => step.order), steps.map((step) => step.order).slice().sort((left, right) => left - right) @@ -30,6 +31,27 @@ test('step definitions module exposes ordered shared step metadata', () => { 'platform-verify', ] ); + assert.deepStrictEqual( + plusSteps.map((step) => step.key), + [ + 'open-chatgpt', + 'submit-signup-email', + 'fill-password', + 'fetch-signup-code', + 'fill-profile', + 'plus-checkout-create', + 'plus-checkout-billing', + 'paypal-approve', + 'plus-checkout-return', + 'oauth-login', + 'confirm-oauth', + 'platform-verify', + ] + ); + assert.equal(plusSteps.some((step) => step.key === 'clear-login-cookies'), false); + assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), false); + assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + assert.equal(api.getLastStepId({ plusModeEnabled: true }), 12); }); test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => { @@ -41,3 +63,10 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap', assert.notEqual(sidepanelIndex, -1); assert.ok(definitionsIndex < sidepanelIndex); }); + +test('sidepanel html exposes Plus mode and PayPal settings', () => { + const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8'); + assert.match(html, /id="input-plus-mode-enabled"/); + assert.match(html, /id="input-paypal-email"/); + assert.match(html, /id="input-paypal-password"/); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 15daf08..b4a5308 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -23,6 +23,7 @@ - 轮询登录验证码 - 自动确认 OAuth 同意页 - 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API +- 可选开启 Plus 模式:先完成 Plus Checkout、账单地址、PayPal 登录授权与订阅回跳确认,再复用 OAuth 后半段链路 ## 2. 核心运行参与者 @@ -44,6 +45,7 @@ - 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新 - 展示一个单独的“接码”开关;开启后才展示 HeroSMS 的接码国家与 API Key 设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证 +- 展示 `Plus 模式` 开关与 PayPal 账号/密码配置;开启后步骤列表切换为 Plus 模式 12 步定义,普通模式的 Cookie 清理与登录验证码步骤不再显示或执行 - 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作 ### 2.2 Background Service Worker @@ -116,6 +118,7 @@ - 步骤顺序靠 `order` - 步骤文件名靠语义 - 新增步骤时不需要重命名后续文件 +- 普通模式使用 10 步定义;Plus 模式使用 12 步定义,其中 Plus 可见步骤 10/11/12 复用原 OAuth 登录、OAuth 同意页与平台回调验证执行器,但按 Plus 可见步骤编号记录状态 ## 4. 状态与存储链路 @@ -138,6 +141,7 @@ - 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail` - 当前手机号验证激活记录 `currentPhoneActivation` - 可复用的手机号验证激活记录 `reusablePhoneActivation` +- Plus checkout / PayPal 运行态:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusCheckoutCountry`、`plusCheckoutCurrency`、`plusBillingCountryText`、`plusBillingAddress`、`plusPaypalApprovedAt`、`plusReturnUrl` - localhost 回调地址 - 自动运行轮次信息 - 当前自动运行 session 标识 `autoRunSessionId` @@ -156,6 +160,8 @@ - CPA / SUB2API 配置 - Codex2API 配置 +- Plus 模式开关 `plusModeEnabled` +- PayPal 登录配置 `paypalEmail / paypalPassword` - 邮箱 provider 配置 - Hotmail 账号池 - 2925 账号池 @@ -479,6 +485,34 @@ Codex2API 补充: - 步骤 10 会先尝试提交已捕获的 callback URL,随后轮询公开贡献状态,直到进入最终态 - `auto_approved` 与 `manual_review_required` 视为主流程完成;`auto_rejected / expired / error` 视为当前轮失败 +## 6.1 Plus 模式链路 + +Plus 模式通过 `plusModeEnabled` 开启,目标是在普通注册资料完成后,不执行原 Step 6 Cookie 清理,也不执行原 Step 8 登录验证码步骤,而是先完成 Plus Checkout 与 PayPal 授权,再复用现有 OAuth 后半段。 + +Plus 模式可见步骤: + +1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。 +2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`。 +3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。 +4. 第 8 步 `PayPal 登录与授权`:在 PayPal 页面填写 `paypalEmail / paypalPassword`,登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。 +5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。 +6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。 +7. 第 11 步:复用原 Step 9 OAuth 同意页点击和 localhost callback 捕获执行器,但状态和日志按 Plus 可见第 11 步记录。 +8. 第 12 步:复用原 Step 10 CPA / SUB2API / Codex2API 平台回调验证执行器,但状态和日志按 Plus 可见第 12 步记录。 + +隐藏与跳过规则: + +- 原 Step 6 `清理登录 Cookies`:Plus 模式下隐藏且不执行,因为 Plus Checkout 创建依赖当前 ChatGPT 登录态。 +- 原 Step 8 `获取登录验证码`:Plus 模式下隐藏且默认跳过,Plus 后半段直接从可见第 10 步进入第 11 步。 +- 原 Step 9 不能跳过;它负责点击 OAuth 同意页并捕获 `localhostUrl`。 +- 原 Step 10 不能跳过;它依赖 `localhostUrl` 完成平台侧账号创建或回调验证。 + +等待模型: + +- Plus Checkout 和 PayPal 专用步骤使用“无限等待但可停止”的后台等待 helper。 +- 每次页面加载完成后固定等待 1 秒,再继续下一次输入、点击或状态判断。 +- 第一版不实时抓取外部地址网站;地址自动化只依赖本地 seed query 和 checkout 页面内置 Google 地址推荐。 + ## 7. 邮箱与 provider 链路 ### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路 @@ -716,9 +750,11 @@ Codex2API 补充: - 如果当前 `Mail = 自定义邮箱` 且配置了 `customMailProviderPool`,会先按当前目标轮次把号池中的对应邮箱写回运行态 - 如果当前生成方式是 `custom-pool`,会先按当前目标轮次把邮箱池中的对应邮箱写回运行态 5. 执行 `runAutoSequenceFromStep` + - 自动运行会按当前 `plusModeEnabled` 选择普通 10 步或 Plus 12 步可见步骤;Plus 模式下第 6~9 步走 checkout / PayPal,第 10~12 步复用 OAuth 后半段 - 步骤 7 内部仍保留登录态恢复的有限重试,但 `add-phone / 手机号页` 属于立即跳出的不可重试错误 - 步骤 8 若在验证码提交后进入 `add-phone / 手机号页`,会直接抛出 fatal 错误,不再先标记步骤成功 - - 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开 + - 普通模式一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开 + - Plus 模式在 checkout / PayPal 结束后,如果 OAuth 后半段遇到普通报错且认证流程未进入 `add-phone`,则自动回到 Plus 可见步骤 10 重新开始授权链路 - 如果命中 `add-phone / 手机号页` 这类 fatal 错误,则不会再做当前轮的内部重试;当开启自动重试/跳过失败时,会直接结束当前轮并继续下一轮,而不是把整条自动流程暂停 - 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开” 6. 如果失败,根据设置决定: diff --git a/项目文件结构说明.md b/项目文件结构说明.md index db47d43..68e1dd3 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -43,7 +43,7 @@ ## `background/` - `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并按默认本地 helper 地址自动尝试把完整快照同步到本地 helper;若 helper 未启动,则静默跳过。 -- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail`、`mail2925BaseEmail` 与当前 2925 账号选择,避免自动流程重置后丢失别名基邮箱与 2925 切号上下文。 +- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 Plus 模式与 PayPal 配置、`gmailBaseEmail`、`mail2925BaseEmail` 与当前 2925 账号选择,避免自动流程重置后丢失关键持久配置。 - `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口与自定义邮箱池读取;当 provider 为 2925 且 `mail2925Mode = provide` 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱;若 `mail2925Mode = receive`,则会回退到普通邮箱生成链路;当生成方式为 iCloud 且 `icloudFetchMode = always_new` 时,会强制跳过未用别名复用并新建别名;当生成方式为 `custom-pool` 时,会按当前目标轮次读取邮箱池中的对应邮箱。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 @@ -60,13 +60,17 @@ - `background/steps/clear-login-cookies.js`:步骤 6 实现,负责登录前 Cookie 清理。 - `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成。 +- `background/steps/create-plus-checkout.js`:Plus 模式第 6 步实现,负责在已登录 ChatGPT 页面创建 Plus checkout session,打开 `chatgpt.com/checkout/openai_ie/{checkout_session_id}` 短链,并记录 checkout 运行态。 - `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱;当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。 - `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口;当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。 +- `background/steps/fill-plus-checkout.js`:Plus 模式第 7 步实现,负责驱动 checkout 页面选择 PayPal、生成账单姓名、读取本地地址 seed、触发地址推荐、提交订阅并等待跳转到 PayPal。 - `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交。 - `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。 - `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试。 - `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。 +- `background/steps/paypal-approve.js`:Plus 模式第 8 步实现,负责驱动 PayPal 登录、关闭可见通行密钥提示、点击“同意并继续”,并等待授权流程离开 PayPal 页面。 - `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证,以及 Codex2API 的协议式 callback code/state 交换。 +- `background/steps/plus-return-confirm.js`:Plus 模式第 9 步实现,负责等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒再完成。 - `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。 - `background/steps/submit-signup-email.js`:步骤 2 实现,负责注册入口点击、邮箱提交与提交后落地页分支判断。 @@ -80,7 +84,9 @@ - `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。 - `content/mail-163.js`:163 / 163 VIP / 126 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。 +- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。 - `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 +- `content/plus-checkout.js`:ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 - `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 会在注册弹窗默认处于手机号输入模式时自动切回邮箱输入模式,并兼容本地化邮箱占位与 `aria-label`;登录链路还会显式识别 `phone-verification` 页面,避免把手机验证码页误判成邮箱验证码页或普通未知页。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。 @@ -90,7 +96,8 @@ ## `data/` - `data/names.js`:随机姓名、生日等测试数据源。 -- `data/step-definitions.js`:共享步骤元数据,前后台共同使用,用于动态渲染和步骤注册。 +- `data/address-sources.js`:Plus 模式本地地址 seed 表,负责按国家选择用于触发 checkout 内置 Google 地址推荐的查询词和结构化地址 fallback;第一版不实时抓取外部地址网站。 +- `data/step-definitions.js`:共享步骤元数据,前后台共同使用,用于动态渲染和步骤注册;当前同时提供普通 10 步定义与 Plus 模式 12 步定义。 ## `docs/` @@ -125,13 +132,14 @@ - `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。 - `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。 - `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。 -- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“接码”开关与“验证码重发”拆成同一行展示;只有开启接码后,下面的 HeroSMS 平台、国家与 API Key 行才会显示;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 -- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置,接码开关关闭时会直接隐藏相关配置;账号记录快照同步改为默认自动模式,只要本地 helper 可用就会自动落盘;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示。 +- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“接码”开关与“验证码重发”拆成同一行展示;只有开启接码后,下面的 HeroSMS 平台、国家与 API Key 行才会显示;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;设置卡片新增 `Plus 模式` 开关与 PayPal 账号/密码输入行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 +- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Plus 模式开启后,步骤列表会切换为 12 步,并显示 PayPal 凭据配置;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置,接码开关关闭时会直接隐藏相关配置;账号记录快照同步改为默认自动模式,只要本地 helper 可用就会自动落盘;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。 ## `tests/` - `tests/activation-utils.test.js`:测试内容脚本激活策略与平台回调可恢复错误判断。 +- `tests/address-sources.test.js`:测试 Plus 模式本地地址 seed 表的国家归一化、fallback 国家选择与基础地址字段。 - `tests/auth-page-recovery.test.js`:测试认证页共享恢复层的重试页识别与恢复行为。 - `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文,并补充 `gmailBaseEmail` / `mail2925BaseEmail` 在 fresh reset 后仍被保留的回归验证。 - `tests/auto-run-step4-mail2925-thread-terminate.test.js`:测试步骤 4 命中 2925“结束当前尝试”错误时,不会再沿用当前邮箱回到步骤 1 重开,而是直接把错误抛给自动重试控制器。 From 72697ffc83ca593b45ccc3eef8f62922b7282947 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 01:42:09 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E4=B8=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 1 + background/steps/fill-plus-checkout.js | 374 ++++++++++++++++- content/plus-checkout.js | 390 +++++++++++++++++- docs/使用教程.md | 76 +++- ...us-checkout-billing-tab-resolution.test.js | 210 ++++++++++ 项目完整链路说明.md | 2 +- 项目文件结构说明.md | 1 + 7 files changed, 1016 insertions(+), 38 deletions(-) create mode 100644 tests/plus-checkout-billing-tab-resolution.test.js diff --git a/background.js b/background.js index 7cd6e21..9f5e5ab 100644 --- a/background.js +++ b/background.js @@ -6915,6 +6915,7 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c }); const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?.createPlusCheckoutBillingExecutor({ addLog, + chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, generateRandomName, diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 6fa2dd1..64a92dc 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -3,33 +3,287 @@ })(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']; + const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i; + const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500; function createPlusCheckoutBillingExecutor(deps = {}) { const { addLog, + chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, generateRandomName, getAddressSeedForCountry, getTabId, isTabAlive, - sendTabMessageUntilStopped, setState, sleepWithStop, waitForTabCompleteUntilStopped, waitForTabUrlMatchUntilStopped, } = deps; + function isPlusCheckoutUrl(url = '') { + return PLUS_CHECKOUT_URL_PATTERN.test(String(url || '')); + } + + async function getAlivePlusCheckoutTabId(tabId) { + if (!Number.isInteger(tabId) || tabId <= 0) { + return null; + } + if (!chrome?.tabs?.get) { + return tabId; + } + const tab = await chrome.tabs.get(tabId).catch(() => null); + return tab && isPlusCheckoutUrl(tab.url) ? tabId : null; + } + + async function getCurrentPlusCheckoutTabId() { + if (!chrome?.tabs?.query) { + return null; + } + + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []); + const activeCheckoutTab = activeTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url)); + if (activeCheckoutTab) { + return activeCheckoutTab.id; + } + + const checkoutTabs = await chrome.tabs.query({ url: 'https://chatgpt.com/checkout/*' }).catch(() => []); + const checkoutTab = checkoutTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url)); + return checkoutTab?.id || null; + } + + async function getCheckoutFrames(tabId) { + if (!chrome?.webNavigation?.getAllFrames) { + return [{ frameId: 0, url: '' }]; + } + const frames = await chrome.webNavigation.getAllFrames({ tabId }).catch(() => null); + if (!Array.isArray(frames) || !frames.length) { + return [{ frameId: 0, url: '' }]; + } + return frames + .filter((frame) => Number.isInteger(frame?.frameId)) + .sort((left, right) => Number(left.frameId) - Number(right.frameId)); + } + + async function pingCheckoutFrame(tabId, frameId) { + try { + const pong = await chrome.tabs.sendMessage(tabId, { + type: 'PING', + source: 'background', + payload: {}, + }, { + frameId: Number.isInteger(frameId) ? frameId : 0, + }); + return Boolean(pong?.ok && (!pong.source || pong.source === PLUS_CHECKOUT_SOURCE)); + } catch { + return false; + } + } + + async function ensurePlusCheckoutFrameReady(tabId, frameId) { + if (await pingCheckoutFrame(tabId, frameId)) { + return true; + } + if (!chrome?.scripting?.executeScript) { + return false; + } + + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [frameId] }, + func: (injectedSource) => { + window.__MULTIPAGE_SOURCE = injectedSource; + }, + args: [PLUS_CHECKOUT_SOURCE], + }); + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [frameId] }, + files: PLUS_CHECKOUT_INJECT_FILES, + }); + } catch { + // If the frame was already injected or navigated mid-injection, ping once more below. + } + + await sleepWithStop(PLUS_CHECKOUT_FRAME_READY_DELAY_MS); + return await pingCheckoutFrame(tabId, frameId); + } + + async function ensurePlusCheckoutFramesReady(tabId, frames) { + const checkedFrames = []; + for (const frame of frames) { + const ready = await ensurePlusCheckoutFrameReady(tabId, frame.frameId); + checkedFrames.push({ ...frame, ready }); + } + return checkedFrames; + } + + async function sendFrameMessage(tabId, frameId, message) { + return chrome.tabs.sendMessage(tabId, message, { + frameId: Number.isInteger(frameId) ? frameId : 0, + }); + } + + async function inspectCheckoutFrame(tabId, frame) { + if (frame.ready === false) { + return { frame, error: 'content-script-not-ready' }; + } + try { + const result = await sendFrameMessage(tabId, frame.frameId, { + type: 'PLUS_CHECKOUT_GET_STATE', + source: 'background', + payload: {}, + }); + if (result?.error) { + return { frame, error: result.error }; + } + return { frame, result: result || {} }; + } catch (error) { + return { frame, error: error?.message || String(error || '') }; + } + } + + function isPaymentFrameUrl(url = '') { + return /elements-inner-payment|componentName=payment/i.test(String(url || '')); + } + + function isAddressFrameUrl(url = '') { + return /elements-inner-address|componentName=address/i.test(String(url || '')); + } + + function isAutocompleteFrameUrl(url = '') { + return /elements-inner-autocompl/i.test(String(url || '')); + } + + function buildFrameSummary(inspections) { + return inspections + .map((item) => { + const flags = []; + if (item.result?.hasPayPal) flags.push('paypal'); + if (item.result?.billingFieldsVisible) flags.push('billing'); + if (item.result?.hasSubscribeButton) flags.push('subscribe'); + if (!flags.length && item.error) flags.push(item.error); + if (!flags.length) flags.push('no-match'); + return `${item.frame.frameId}:${item.frame.url || 'about:blank'}:${flags.join(',')}`; + }) + .slice(0, 8) + .join(' | '); + } + + async function inspectCheckoutFrames(tabId, frames) { + const inspections = []; + for (const frame of frames) { + const inspection = await inspectCheckoutFrame(tabId, frame); + inspections.push(inspection); + } + return inspections; + } + + function pickPaymentFrame(inspections) { + return inspections.find((item) => item.result?.hasPayPal || item.result?.paypalCandidates?.length) + || inspections.find((item) => isPaymentFrameUrl(item.frame.url)) + || null; + } + + function pickBillingFrame(inspections) { + return inspections.find((item) => item.result?.billingFieldsVisible) + || inspections.find((item) => isAddressFrameUrl(item.frame.url)) + || null; + } + + function pickSubscribeFrame(inspections) { + return inspections.find((item) => item.result?.hasSubscribeButton) + || inspections.find((item) => item.frame.frameId === 0) + || null; + } + + async function getReadyCheckoutFrames(tabId) { + return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId)); + } + + async function resolveOptionalFrameByUrl(tabId, predicate) { + const frames = await getCheckoutFrames(tabId); + const frame = frames.find((item) => predicate(item.url)); + if (!frame) { + return null; + } + const ready = await ensurePlusCheckoutFrameReady(tabId, frame.frameId); + return { + frame, + ready, + }; + } + + async function resolvePaymentFrame(tabId, frames) { + const inspections = await inspectCheckoutFrames(tabId, frames); + const picked = pickPaymentFrame(inspections); + if (picked) { + return { + frameId: picked.frame.frameId, + frameUrl: picked.frame.url || '', + ready: picked.frame.ready !== false, + inspections, + }; + } + + return { + frameId: null, + frameUrl: '', + inspections, + }; + } + + async function waitForBillingFrame(tabId) { + while (true) { + const frames = await getReadyCheckoutFrames(tabId); + const inspections = await inspectCheckoutFrames(tabId, frames); + const picked = pickBillingFrame(inspections); + if (picked) { + return { + frameId: picked.frame.frameId, + frameUrl: picked.frame.url || '', + ready: picked.frame.ready !== false, + inspections, + }; + } + await sleepWithStop(250); + } + } + + async function waitForSubscribeFrame(tabId, candidateFrames) { + const frames = candidateFrames.length ? candidateFrames : [{ frameId: 0, url: '' }]; + while (true) { + const readyFrames = await ensurePlusCheckoutFramesReady(tabId, frames); + const inspections = await inspectCheckoutFrames(tabId, readyFrames); + const picked = pickSubscribeFrame(inspections); + if (picked) { + return picked.frame; + } + await sleepWithStop(250); + } + } + async function getCheckoutTabId(state = {}) { const registeredTabId = await getTabId(PLUS_CHECKOUT_SOURCE); if (registeredTabId && await isTabAlive(PLUS_CHECKOUT_SOURCE)) { - return registeredTabId; + const aliveRegisteredTabId = await getAlivePlusCheckoutTabId(registeredTabId); + if (aliveRegisteredTabId) { + return aliveRegisteredTabId; + } } const storedTabId = Number(state.plusCheckoutTabId) || 0; if (storedTabId) { - return storedTabId; + const aliveStoredTabId = await getAlivePlusCheckoutTabId(storedTabId); + if (aliveStoredTabId) { + return aliveStoredTabId; + } } - throw new Error('步骤 7:未找到 Plus Checkout 标签页,请先完成步骤 6。'); + const currentCheckoutTabId = await getCurrentPlusCheckoutTabId(); + if (currentCheckoutTabId) { + await addLog('步骤 7:检测到当前已在 Plus Checkout 页面,直接接管当前标签页。', 'info'); + return currentCheckoutTabId; + } + throw new Error('步骤 7:未找到 Plus Checkout 标签页。请先打开 Plus Checkout 页面,或完成步骤 6。'); } async function executePlusCheckoutBilling(state = {}) { @@ -43,6 +297,19 @@ injectSource: PLUS_CHECKOUT_SOURCE, logMessage: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...', }); + const readyFrames = await getReadyCheckoutFrames(tabId); + const paymentFrame = await resolvePaymentFrame(tabId, readyFrames); + if (paymentFrame.frameId === null) { + const frameSummary = buildFrameSummary(paymentFrame.inspections); + throw new Error(`步骤 7:未在主页面或 iframe 中发现 PayPal DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`); + } + if (!paymentFrame.ready) { + throw new Error(`步骤 7:已定位到 PayPal 所在 iframe(frameId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`); + } + + if (paymentFrame.frameId !== 0) { + await addLog(`步骤 7:PayPal 位于 checkout iframe(frameId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info'); + } const randomName = generateRandomName(); const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' '); @@ -53,18 +320,99 @@ 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', + await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info'); + const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, { + type: 'PLUS_CHECKOUT_SELECT_PAYPAL', source: 'background', - payload: { - fullName, - addressSeed, - }, + payload: {}, }); + if (paymentResult?.error) { + throw new Error(paymentResult.error); + } - if (result?.error) { - throw new Error(result.error); + const billingFrame = await waitForBillingFrame(tabId); + if (!billingFrame.ready) { + throw new Error(`步骤 7:已定位到账单地址 iframe(frameId=${billingFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`); + } + if (billingFrame.frameId !== paymentFrame.frameId) { + await addLog(`步骤 7:账单地址位于 checkout iframe(frameId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info'); + } + + await addLog(`步骤 7:正在填写账单地址(${addressSeed.countryCode} / ${addressSeed.query})...`, 'info'); + const autocompleteFrame = await resolveOptionalFrameByUrl(tabId, isAutocompleteFrameUrl); + let result = null; + if (autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) { + if (!autocompleteFrame.ready) { + throw new Error('步骤 7:发现 Google 地址推荐 iframe,但无法注入账单脚本。请提供该 iframe 的控制台结构。'); + } + await addLog(`步骤 7:Google 地址推荐位于独立 iframe(frameId=${autocompleteFrame.frame.frameId}),将拆分输入与选择动作。`, 'info'); + + const queryResult = await sendFrameMessage(tabId, billingFrame.frameId, { + type: 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY', + source: 'background', + payload: { + fullName, + addressSeed, + }, + }); + if (queryResult?.error) { + throw new Error(queryResult.error); + } + + const suggestionResult = await sendFrameMessage(tabId, autocompleteFrame.frame.frameId, { + type: 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION', + source: 'background', + payload: { + addressSeed, + }, + }); + if (suggestionResult?.error) { + throw new Error(suggestionResult.error); + } + + const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, { + type: 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS', + source: 'background', + payload: { + addressSeed, + }, + }); + if (structuredResult?.error) { + throw new Error(structuredResult.error); + } + + result = { + ...structuredResult, + selectedAddressText: suggestionResult?.selectedAddressText || '', + }; + } else { + result = await sendFrameMessage(tabId, billingFrame.frameId, { + type: 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS', + source: 'background', + payload: { + fullName, + addressSeed, + }, + }); + + if (result?.error) { + throw new Error(result.error); + } + } + + await addLog('步骤 7:账单地址已填写完成,正在定位订阅按钮...', 'info'); + const subscribeFrame = await waitForSubscribeFrame(tabId, [ + { frameId: 0, url: '' }, + { frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' }, + { frameId: billingFrame.frameId, url: billingFrame.frameUrl || '' }, + ]); + const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, { + type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE', + source: 'background', + payload: {}, + }); + if (subscribeResult?.error) { + throw new Error(subscribeResult.error); } await setState({ diff --git a/content/plus-checkout.js b/content/plus-checkout.js index a4c18f1..598f5a7 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -16,6 +16,7 @@ const PLUS_CHECKOUT_PAYLOAD = { is_coupon_from_query_param: false, }, }; +const PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS = 5000; if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') { document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1'); @@ -24,6 +25,12 @@ if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== ' if ( message.type === 'CREATE_PLUS_CHECKOUT' || message.type === 'FILL_PLUS_BILLING_AND_SUBMIT' + || message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' + || message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' + || message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY' + || message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION' + || message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS' + || message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' || message.type === 'PLUS_CHECKOUT_GET_STATE' ) { resetStopState(); @@ -49,6 +56,18 @@ async function handlePlusCheckoutCommand(message) { return createPlusCheckoutSession(); case 'FILL_PLUS_BILLING_AND_SUBMIT': return fillPlusBillingAndSubmit(message.payload || {}); + case 'PLUS_CHECKOUT_SELECT_PAYPAL': + return selectPlusPayPalPaymentMethod(); + case 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS': + return fillPlusBillingAddress(message.payload || {}); + case 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY': + return fillPlusAddressQuery(message.payload || {}); + case 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION': + return selectPlusAddressSuggestion(message.payload || {}); + case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS': + return ensurePlusStructuredBillingAddress(message.payload || {}); + case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE': + return clickPlusSubscribe(); case 'PLUS_CHECKOUT_GET_STATE': return inspectPlusCheckoutState(); default: @@ -103,6 +122,21 @@ function getActionText(el) { ].filter(Boolean).join(' ')); } +function getSearchText(el) { + const datasetValues = el?.dataset ? Object.values(el.dataset) : []; + return normalizeText([ + getActionText(el), + el?.getAttribute?.('alt'), + el?.getAttribute?.('role'), + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('src'), + el?.getAttribute?.('href'), + el?.getAttribute?.('xlink:href'), + typeof el?.className === 'string' ? el.className : el?.getAttribute?.('class'), + ...datasetValues, + ].filter(Boolean).join(' ')); +} + function getFieldText(el) { const id = el?.id || ''; const labels = []; @@ -123,6 +157,13 @@ function getFieldText(el) { ].filter(Boolean).join(' ')); } +function getCombinedSearchText(el) { + return normalizeText([ + getSearchText(el), + getFieldText(el), + ].filter(Boolean).join(' ')); +} + function getVisibleControls(selector) { return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement); } @@ -161,6 +202,179 @@ function findInputByFieldText(patterns, options = {}) { }) || null; } +function isDocumentLevelContainer(el) { + return !el + || el === document.documentElement + || el === document.body + || ['HTML', 'BODY', 'MAIN'].includes(el.tagName); +} + +function isPaymentCardSized(el) { + if (!isVisibleElement(el) || isDocumentLevelContainer(el)) return false; + const rect = el.getBoundingClientRect(); + const maxWidth = Math.max(320, Math.min(window.innerWidth * 0.95, 900)); + const maxHeight = Math.max(140, Math.min(window.innerHeight * 0.45, 320)); + return rect.width >= 64 + && rect.height >= 28 + && rect.width <= maxWidth + && rect.height <= maxHeight; +} + +function findInteractiveAncestor(el) { + let current = el; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + if (!isVisibleElement(current) || isDocumentLevelContainer(current)) continue; + if (current.matches?.('button, a, label, [role="button"], [role="radio"], input[type="radio"], [tabindex]')) { + return current; + } + } + return null; +} + +function findPaymentCardAncestor(el, pattern) { + let current = el; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + if (!isVisibleElement(current)) continue; + if (isDocumentLevelContainer(current)) break; + const text = getSearchText(current); + if (pattern.test(text) && isPaymentCardSized(current)) { + return current; + } + } + return null; +} + +function getAncestorChainSummary(el, limit = 6) { + const chain = []; + let current = el; + for (let depth = 0; current && depth < limit; depth += 1, current = current.parentElement) { + if (isDocumentLevelContainer(current)) break; + const rect = current.getBoundingClientRect(); + chain.push({ + tag: String(current.tagName || '').toLowerCase(), + role: current.getAttribute?.('role') || '', + id: current.id || '', + className: typeof current.className === 'string' ? current.className.slice(0, 120) : '', + testId: current.getAttribute?.('data-testid') || '', + ariaLabel: current.getAttribute?.('aria-label') || '', + ariaChecked: current.getAttribute?.('aria-checked') || '', + ariaSelected: current.getAttribute?.('aria-selected') || '', + rect: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + text: getCombinedSearchText(current).slice(0, 180), + }); + } + return chain; +} + +function getPayPalSearchCandidates() { + const selector = [ + 'button', + 'a', + 'label', + '[role="button"]', + '[role="radio"]', + 'input[type="radio"]', + '[tabindex]', + '[data-testid]', + '[aria-label]', + '[title]', + 'img', + 'svg', + 'span', + 'div', + ].join(', '); + + return getVisibleControls(selector) + .filter((el) => /paypal/i.test(getCombinedSearchText(el))) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height); + }); +} + +function findPayPalPaymentMethodTarget() { + const paypalPattern = /paypal/i; + const directClickable = findClickableByText([paypalPattern]); + if (directClickable) { + return directClickable; + } + + const radios = getVisibleControls('input[type="radio"], [role="radio"]'); + const paypalRadio = radios.find((el) => paypalPattern.test(getCombinedSearchText(el))); + if (paypalRadio) { + return paypalRadio; + } + + const candidates = getPayPalSearchCandidates(); + for (const candidate of candidates) { + const interactive = findInteractiveAncestor(candidate); + if (interactive && paypalPattern.test(getCombinedSearchText(interactive))) { + return interactive; + } + const card = findPaymentCardAncestor(candidate, paypalPattern); + if (card) { + return card; + } + } + + return null; +} + +function summarizeElementForDebug(el) { + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { + tag: String(el.tagName || '').toLowerCase(), + role: el.getAttribute?.('role') || '', + text: getSearchText(el).slice(0, 160), + rect: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + chain: getAncestorChainSummary(el, 3), + }; +} + +function getPayPalCandidateSummaries(limit = 6) { + return getPayPalSearchCandidates() + .map(summarizeElementForDebug) + .filter(Boolean) + .slice(0, limit); +} + +function getPaymentTextPreview(limit = 10) { + const seen = new Set(); + const pattern = /paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i; + return getVisibleControls('button, a, label, [role="button"], [role="radio"], input[type="radio"], input[type="button"], input[type="submit"], [data-testid]') + .map((el) => getCombinedSearchText(el)) + .filter((text) => text && pattern.test(text)) + .map((text) => text.slice(0, 180)) + .filter((text) => { + if (seen.has(text)) return false; + seen.add(text); + return true; + }) + .slice(0, limit); +} + +function getPayPalDiagnostics(reason = '') { + return { + reason, + url: location.href, + readyState: document.readyState, + paypalCandidates: getPayPalCandidateSummaries(), + paymentTextPreview: getPaymentTextPreview(), + cardFieldsVisible: hasCreditCardFields(), + billingFieldsVisible: hasBillingAddressFields(), + }; +} + +function writePayPalDiagnostics(reason, level = 'info') { + const diagnostics = getPayPalDiagnostics(reason); + const writer = typeof console[level] === 'function' ? console[level] : console.info; + writer.call(console, '[MultiPage:plus-checkout] PayPal diagnostics', diagnostics); + log(`Plus Checkout:${reason}。PayPal 候选 ${diagnostics.paypalCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}。`, level === 'error' ? 'error' : 'warn'); + return diagnostics; +} + async function createPlusCheckoutSession() { await waitForDocumentComplete(); log('Plus:正在读取 ChatGPT 登录会话...'); @@ -199,23 +413,42 @@ async function createPlusCheckoutSession() { } async function selectPayPalPaymentMethod() { - const paypalPattern = /paypal/i; - const existingSelected = findClickableByText([/paypal/i]); - if (existingSelected) { - simulateClick(existingSelected); - await sleep(600); - return true; + let lastDiagnosticsAt = 0; + const target = await waitUntil(() => { + const currentTarget = findPayPalPaymentMethodTarget(); + if (currentTarget) { + return currentTarget; + } + + const now = Date.now(); + if (!lastDiagnosticsAt || now - lastDiagnosticsAt >= PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS) { + lastDiagnosticsAt = now; + writePayPalDiagnostics('正在等待可点击的 PayPal 付款方式', 'warn'); + } + return null; + }, { + label: 'PayPal 付款方式', + intervalMs: 250, + }); + console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target)); + simulateClick(target); + await sleep(1000); + + if (!isPayPalPaymentMethodActive()) { + const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error'); + throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`); } - 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; - } + log('Plus Checkout:已确认 PayPal 付款方式生效。'); + return true; +} - throw new Error('Plus Checkout:未找到 PayPal 付款方式。'); +async function selectPlusPayPalPaymentMethod() { + await waitForDocumentComplete(); + await selectPayPalPaymentMethod(); + return { + paymentSelected: true, + }; } async function fillFullName(fullName) { @@ -251,7 +484,7 @@ function readCountryText() { 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)) { + if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|card|card\s*number|expiry|expiration|security|cvc|cvv|cc-|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州|银行卡|卡号|有效期|安全码/i.test(text)) { return false; } if (/address|street|billing|search|line\s*1|地址|街道|账单/i.test(text)) { @@ -260,6 +493,52 @@ function isLikelyAddressSearchInput(input) { return false; } +function hasCreditCardFields() { + return getVisibleTextInputs().some((input) => { + const text = getFieldText(input); + return /card\s*number|card|expiry|expiration|security\s*code|cvc|cvv|银行卡|卡号|有效期|安全码/i.test(text); + }); +} + +function hasBillingAddressFields() { + return getVisibleTextInputs().some((input) => { + const text = getFieldText(input); + return /address|street|billing|line\s*1|地址|街道|账单/i.test(text) + && !/card\s*number|card|expiry|expiration|security|cvc|cvv|银行卡|卡号|有效期|安全码/i.test(text); + }); +} + +function hasSelectedPayPalControl() { + const paypalPattern = /paypal/i; + const candidates = getPayPalSearchCandidates(); + return candidates.some((candidate) => { + let current = candidate; + for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) { + if (isDocumentLevelContainer(current)) break; + if (!paypalPattern.test(getCombinedSearchText(current))) continue; + const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || ''; + if ( + current.checked === true + || current.getAttribute?.('aria-checked') === 'true' + || current.getAttribute?.('aria-selected') === 'true' + || current.getAttribute?.('data-state') === 'checked' + || current.getAttribute?.('data-selected') === 'true' + || /\b(selected|checked|active)\b/i.test(className) + ) { + return true; + } + } + return false; + }); +} + +function isPayPalPaymentMethodActive() { + if (hasSelectedPayPalControl()) { + return true; + } + return getPayPalSearchCandidates().length > 0 && !hasCreditCardFields(); +} + async function findAddressSearchInput() { return waitUntil(() => { const direct = findInputByFieldText([ @@ -302,10 +581,11 @@ function getAddressSuggestions() { } async function selectAddressSuggestion(seed) { - const addressInput = await findAddressSearchInput(); - fillInput(addressInput, seed.query || 'Berlin Mitte'); - await sleep(800); + await fillAddressQuery(seed); + return clickAddressSuggestion(seed); +} +async function clickAddressSuggestion(seed = {}) { const suggestions = await waitUntil(() => { const options = getAddressSuggestions(); return options.length ? options : null; @@ -327,6 +607,15 @@ async function selectAddressSuggestion(seed) { }; } +async function fillAddressQuery(seed = {}) { + const addressInput = await findAddressSearchInput(); + fillInput(addressInput, seed.query || 'Berlin Mitte'); + await sleep(800); + return { + filled: true, + }; +} + function getStructuredAddressFields() { const address1 = findInputByFieldText([ /address\s*(?:line)?\s*1|street/i, @@ -404,6 +693,24 @@ function findSubscribeButton() { async function fillPlusBillingAndSubmit(payload = {}) { await waitForDocumentComplete(); await selectPayPalPaymentMethod(); + const billingResult = await fillPlusBillingAddress(payload); + + if (payload.skipSubmit) { + return { + ...billingResult, + submitted: false, + }; + } + + await clickPlusSubscribe(); + return { + ...billingResult, + submitted: true, + }; +} + +async function fillPlusBillingAddress(payload = {}) { + await waitForDocumentComplete(); await fillFullName(payload.fullName || ''); const countryText = readCountryText(); @@ -420,6 +727,43 @@ async function fillPlusBillingAndSubmit(payload = {}) { const selected = await selectAddressSuggestion(seed); const structuredAddress = await ensureStructuredAddress(seed); + return { + countryText, + selectedAddressText: selected.selectedText, + structuredAddress, + }; +} + +async function fillPlusAddressQuery(payload = {}) { + await waitForDocumentComplete(); + await fillFullName(payload.fullName || ''); + const seed = payload.addressSeed || {}; + await fillAddressQuery(seed); + return { + countryText: readCountryText(), + queryFilled: true, + }; +} + +async function selectPlusAddressSuggestion(payload = {}) { + await waitForDocumentComplete(); + const selected = await clickAddressSuggestion(payload.addressSeed || {}); + return { + selectedAddressText: selected.selectedText, + suggestionIndex: selected.suggestionIndex, + }; +} + +async function ensurePlusStructuredBillingAddress(payload = {}) { + await waitForDocumentComplete(); + const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}); + return { + countryText: readCountryText(), + structuredAddress, + }; +} + +async function clickPlusSubscribe() { const subscribeButton = await waitUntil(() => { const button = findSubscribeButton(); return button && isEnabledControl(button) ? button : null; @@ -430,9 +774,7 @@ async function fillPlusBillingAndSubmit(payload = {}) { simulateClick(subscribeButton); return { - countryText, - selectedAddressText: selected.selectedText, - structuredAddress, + clicked: true, }; } @@ -442,7 +784,11 @@ function inspectPlusCheckoutState() { url: location.href, readyState: document.readyState, countryText: readCountryText(), - hasPayPal: Boolean(findClickableByText([/paypal/i])), + hasPayPal: Boolean(findPayPalPaymentMethodTarget()), + paypalCandidates: getPayPalCandidateSummaries(), + paymentTextPreview: getPaymentTextPreview(), + cardFieldsVisible: hasCreditCardFields(), + billingFieldsVisible: hasBillingAddressFields(), hasSubscribeButton: Boolean(findSubscribeButton()), addressFieldValues: { address1: structuredAddress.address1?.value || '', diff --git a/docs/使用教程.md b/docs/使用教程.md index d419c53..54b2598 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,4 +1,6 @@ -本教程用于说明相关项目地址、扩展更新方式、`QQ 邮箱`切换邮箱的使用方法,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。 +# Codex 注册扩展相关项目、更新、邮箱切换、PayPal 与 Clash Verge 配置教程 + +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -6,6 +8,7 @@ - 已经安装本扩展,想更新到最新版本 - 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务` - 需要临时切换 `QQ 邮箱` 地址继续使用 +- 需要注册并使用 `PayPal` 个人账户 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` ## 准备内容 @@ -15,6 +18,8 @@ - 可以打开浏览器的 `扩展程序管理` 页面 - 已准备好 `Cloudflare Temp Email` 后端地址;如需随机子域,域名解析也已配置完成 - 一个可正常登录的 `QQ 邮箱` +- 一个可正常接收短信的手机号 +- 一张可在线支付的借记卡或信用卡 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` - 已安装 `Clash Verge`,并已导入可用订阅 @@ -114,7 +119,59 @@ 这两个邮箱地址使用完成后,可以直接删除。 删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。 -### 第五部分:配置 `Clash Verge` 的 `🔁 非港轮询` +### 第五部分:`PayPal` 注册与绑卡使用教程 + +1. 打开注册页面 + 打开 `https://www.paypal.com/signin`。 + 然后点击 `注册`。 + +2. 选择账户类型 + 选择 `个人账户`。 + 如果页面显示英文,通常对应 `Individual Account`。 + +3. 选择国家或地区 + 在 `国家/地区` 中直接选择 `中国`。 + 后续会绑定手机号,按中国手机号流程继续即可。 + +4. 输入手机号并继续 + 按页面提示输入手机号。 + 如果页面要求短信验证,就先完成短信验证再继续下一步。 + +5. 填写登录信息和个人信息 + 按页面提示继续填写邮箱、密码,以及姓名、出生日期、地址和联系方式。 + `PayPal` 官方说明,中国大陆居民注册时必须使用身份证上的中文姓名,不能使用拼音。 + 填完后勾选协议并完成注册。 + +6. 完成邮箱确认 + 注册成功后,按页面或邮箱提示确认邮箱地址。 + 如页面继续提示验证手机号,也一并完成。 + +7. 进入 `钱包` 或主页绑卡 + 注册成功后,进入主页。 + 然后在页面中找到 `关联卡或银行账户`,也可以直接进入 `钱包` 页面操作。 + +8. 选择关联借记卡或信用卡 + 点击 `关联借记卡或信用卡`。 + 如果你要先完成最基础的付款配置,优先把卡先绑上。 + +9. 填写银行卡信息 + 按页面提示输入银行卡号、有效期、安全代码和账单地址。 + 有效期一般在卡正面,通常写成 `10/45` 这种格式,表示 `2045 年 10 月` 到期。 + 安全代码一般在卡背面,输入三位数的那个。 + 如果卡背面分成两段数字,一段四位、一段三位,选择三位数的那段。 + 账单地址如果页面默认信息正确,保持默认即可。 + +10. 完成绑卡并检查结果 + 点击 `关联卡`。 + 有时候页面会报错,但你仍然可以去 `钱包` 页面重新看一下,因为卡有可能已经绑定成功。 + +11. 检查右上角通知并完成认证 + 绑卡成功后,记得查看右上角的通知图标。 + 如果通知图标标红,或者页面提示账户需要认证,请按提示补交资料。 + 常见情况是上传身份证件。 + `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 + +### 第六部分:配置 `Clash Verge` 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -236,6 +293,18 @@ function main(config, profileName) { 可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。 +### `PayPal` 绑卡时报错怎么办? + +先去 `钱包` 页面再看一眼,有时虽然页面提示报错,但实际上已经绑定成功。 +如果还没有成功,优先检查账单地址是否与银行卡账单地址一致。 +`PayPal` 官方还说明,绑定新卡时会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,如果这一步被银行拒绝,绑卡也会失败。 + +### `PayPal` 右上角通知标红怎么办? + +先点开通知查看具体要求。 +如果页面要求 `Confirm your Identity` 或账户认证,就按提示上传身份证件或其他所需材料。 +`PayPal` 官方说明,资料通常会在 `2 个工作日` 内审核,但某些情况可能更久。 + ### 为什么没有看到 `🔁 非港轮询`? 请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。 @@ -247,6 +316,9 @@ function main(config, profileName) { - 开启 `随机子域` 前,请先确认后端已经配置 `RANDOM_SUBDOMAIN_DOMAINS`,并且 Cloudflare DNS 已完成 `MX *` 设置。 - 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。 - `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。 +- 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 +- `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。 +- 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。 - 使用 `git pull` 更新扩展是最方便、最推荐的方式。 - 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。 diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js new file mode 100644 index 0000000..c69c45c --- /dev/null +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -0,0 +1,210 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadPlusCheckoutBillingModule() { + const source = fs.readFileSync('background/steps/fill-plus-checkout.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutBilling;`)(globalScope); +} + +function createAddressSeed() { + return { + countryCode: 'DE', + query: 'Berlin Mitte', + suggestionIndex: 1, + fallback: { + address1: 'Unter den Linden', + city: 'Berlin', + region: 'Berlin', + postalCode: '10117', + }, + }; +} + +function createSuccessfulBillingResult() { + return { + countryText: 'Germany', + structuredAddress: { + address1: 'Unter den Linden', + city: 'Berlin', + postalCode: '10117', + }, + }; +} + +function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { + const api = loadPlusCheckoutBillingModule(); + const events = { + completed: [], + ensuredTabs: [], + injectedAllFrames: false, + logs: [], + messages: [], + states: [], + waitedUrls: [], + }; + const checkoutTab = { + id: 42, + url: 'https://chatgpt.com/checkout/openai_ie/cs_test', + status: 'complete', + }; + + const executor = api.createPlusCheckoutBillingExecutor({ + addLog: async (message, level = 'info') => events.logs.push({ message, level }), + chrome: { + tabs: { + get: async (tabId) => (tabId === checkoutTab.id ? checkoutTab : null), + query: async (queryInfo) => { + if (queryInfo.active && queryInfo.currentWindow) { + return [checkoutTab]; + } + if (queryInfo.url === 'https://chatgpt.com/checkout/*') { + return [checkoutTab]; + } + return []; + }, + sendMessage: async (tabId, message, options = {}) => { + const frameId = Number.isInteger(options.frameId) ? options.frameId : 0; + events.messages.push({ tabId, message, frameId }); + if (message.type === 'PING') { + if (readyByFrame[frameId] === false) { + throw new Error('No receiving end'); + } + return { ok: true, source: 'plus-checkout' }; + } + if (message.type === 'PLUS_CHECKOUT_GET_STATE') { + return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] }; + } + return createSuccessfulBillingResult(); + }, + }, + scripting: { + executeScript: async (details) => { + if (details.target?.allFrames) { + events.injectedAllFrames = true; + } + }, + }, + webNavigation: { + getAllFrames: async () => frames, + }, + }, + completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }), + ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }), + generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }), + getAddressSeedForCountry: () => createAddressSeed(), + getTabId: async () => null, + isTabAlive: async () => false, + setState: async (updates) => events.states.push(updates), + sleepWithStop: async () => {}, + waitForTabCompleteUntilStopped: async () => checkoutTab, + waitForTabUrlMatchUntilStopped: async (tabId, matcher) => { + events.waitedUrls.push({ tabId }); + assert.equal(matcher('https://www.paypal.com/checkoutnow'), true); + return { id: tabId, url: 'https://www.paypal.com/checkoutnow' }; + }, + }); + + return { checkoutTab, events, executor }; +} + +test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => { + const { checkoutTab, events, executor } = createExecutorHarness({ + frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }], + stateByFrame: { + 0: { + hasPayPal: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + billingFieldsVisible: true, + hasSubscribeButton: true, + }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + assert.deepEqual(events.ensuredTabs[0], { source: 'plus-checkout', tabId: checkoutTab.id }); + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' && entry.frameId === 0), true); + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' && entry.frameId === 0), true); + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' && entry.frameId === 0), true); + assert.equal(events.completed[0].step, 7); + assert.equal(events.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true); + assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true); +}); + +test('Plus checkout billing sends the billing command to the iframe that contains PayPal', async () => { + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'); + const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'); + assert.equal(selectMessage.frameId, 7); + assert.equal(fillMessage.frameId, 8); + assert.equal(subscribeMessage.frameId, 0); + assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true); + assert.equal(events.completed[0].step, 7); +}); + +test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => { + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + { frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + 9: { hasPayPal: false, paypalCandidates: [] }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'); + const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'); + const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS'); + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(fillQueryMessage.frameId, 8); + assert.equal(suggestionMessage.frameId, 9); + assert.equal(ensureAddressMessage.frameId, 8); + assert.equal(combinedFillMessage, undefined); + assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true); + assert.equal(events.completed[0].step, 7); +}); + +test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => { + const { executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + }, + readyByFrame: { + 7: false, + }, + }); + + await assert.rejects( + executor.executePlusCheckoutBilling({}), + /已定位到 PayPal 所在 iframe(frameId=7),但账单脚本无法注入该 iframe/ + ); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index b4a5308..ba779b1 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -493,7 +493,7 @@ Plus 模式可见步骤: 1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。 2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`。 -3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。 +3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frame,Google 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。 4. 第 8 步 `PayPal 登录与授权`:在 PayPal 页面填写 `paypalEmail / paypalPassword`,登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。 5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。 6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 68e1dd3..36fa027 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -175,6 +175,7 @@ - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成时的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 +- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 - `tests/content-utils.test.js`:测试内容脚本公共工具层对 `mail.126.com` 等网页邮箱来源的识别逻辑。 From f454105ff5d83c0e157630645820a4d19f1e3e1f Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 03:08:12 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20Plus=20Checkou?= =?UTF-8?q?t=20=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AD=90?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E7=9A=84=E5=B0=B1=E7=BB=AA=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=92=8C=20PayPal=20=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=9A=84=E6=A3=80=E6=B5=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/fill-plus-checkout.js | 9 +- content/plus-checkout.js | 47 +++- content/utils.js | 19 +- docs/使用教程.md | 18 +- tests/content-utils.test.js | 12 ++ tests/plus-checkout-address-input.test.js | 201 ++++++++++++++++++ ...us-checkout-billing-tab-resolution.test.js | 32 +++ 7 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 tests/plus-checkout-address-input.test.js diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 64a92dc..7e58f65 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -125,9 +125,6 @@ } async function inspectCheckoutFrame(tabId, frame) { - if (frame.ready === false) { - return { frame, error: 'content-script-not-ready' }; - } try { const result = await sendFrameMessage(tabId, frame.frameId, { type: 'PLUS_CHECKOUT_GET_STATE', @@ -137,9 +134,11 @@ if (result?.error) { return { frame, error: result.error }; } - return { frame, result: result || {} }; + return { frame: { ...frame, ready: true }, result: result || {} }; } catch (error) { - return { frame, error: error?.message || String(error || '') }; + const readyError = frame.ready === false ? 'content-script-not-ready' : ''; + const message = error?.message || String(error || ''); + return { frame, error: readyError ? `${readyError}: ${message}` : message }; } } diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 598f5a7..40bfcc4 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -202,6 +202,29 @@ function findInputByFieldText(patterns, options = {}) { }) || null; } +function getDirectFieldHintText(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); + } + return normalizeText([ + getActionText(el), + ...labels, + ].filter(Boolean).join(' ')); +} + +function isNonAddressSearchInput(input) { + const directText = getDirectFieldHintText(input); + const type = String(input?.getAttribute?.('type') || input?.type || '').trim().toLowerCase(); + return /name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|card|card\s*number|expiry|expiration|security|cvc|cvv|cc-/i.test(directText) + || ['email', 'tel', 'password'].includes(type); +} + function isDocumentLevelContainer(el) { return !el || el === document.documentElement @@ -432,9 +455,9 @@ async function selectPayPalPaymentMethod() { }); console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target)); simulateClick(target); - await sleep(1000); + log('Plus Checkout:已点击 PayPal 付款方式,正在确认选中状态。'); - if (!isPayPalPaymentMethodActive()) { + if (!await waitForPayPalPaymentMethodActive()) { const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error'); throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`); } @@ -484,6 +507,9 @@ function readCountryText() { function isLikelyAddressSearchInput(input) { const text = getFieldText(input); + if (isNonAddressSearchInput(input)) { + return false; + } if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|card|card\s*number|expiry|expiration|security|cvc|cvv|cc-|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州|银行卡|卡号|有效期|安全码/i.test(text)) { return false; } @@ -533,10 +559,19 @@ function hasSelectedPayPalControl() { } function isPayPalPaymentMethodActive() { - if (hasSelectedPayPalControl()) { - return true; + return hasSelectedPayPalControl(); +} + +async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + throwIfStopped(); + if (isPayPalPaymentMethodActive()) { + return true; + } + await sleep(250); } - return getPayPalSearchCandidates().length > 0 && !hasCreditCardFields(); + return false; } async function findAddressSearchInput() { @@ -547,7 +582,7 @@ async function findAddressSearchInput() { ], { exclude: (input) => /city|state|province|postal|zip|country|城市|省|州|邮编|国家|地区/i.test(getFieldText(input)), }); - if (direct) return direct; + if (direct && !isNonAddressSearchInput(direct)) return direct; const candidates = getVisibleTextInputs().filter(isLikelyAddressSearchInput); return candidates[0] || null; }, { diff --git a/content/utils.js b/content/utils.js index 7eab2e4..a38143d 100644 --- a/content/utils.js +++ b/content/utils.js @@ -421,9 +421,20 @@ async function humanPause(min = 250, max = 850) { await sleep(duration); } -// Auto-report ready on load -// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration -const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'gmail-mail' || SCRIPT_SOURCE === 'mail-2925' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top; -if (!_isMailChildFrame) { +function shouldReportReadyForFrame(source, isChildFrame) { + if (!isChildFrame) return true; + return ![ + 'qq-mail', + 'mail-163', + 'gmail-mail', + 'mail-2925', + 'inbucket-mail', + 'plus-checkout', + ].includes(source); +} + +// Auto-report ready on load. Child frames are probed explicitly by frameId, so +// they should not overwrite the tab-level registration or spam the side panel. +if (shouldReportReadyForFrame(SCRIPT_SOURCE, window !== window.top)) { reportReady(); } diff --git a/docs/使用教程.md b/docs/使用教程.md index 54b2598..c58dda8 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱切换、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -21,15 +21,15 @@ - 一个可正常接收短信的手机号 - 一张可在线支付的借记卡或信用卡 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` -- 已安装 `Clash Verge`,并已导入可用订阅 +- 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),并已导入可用订阅 ## 操作步骤 ### 第一部分:相关项目地址与部署说明 1. 查看项目地址 - `cpa` 项目地址:`https://github.com/router-for-me/CLIProxyAPI` - `sub2api` 项目地址:`https://github.com/Wei-Shaw/sub2api` + `cpa` 项目地址:[https://github.com/router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) + `sub2api` 项目地址:[https://github.com/Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 2. 拉取项目到本地 先将你需要的项目拉取到本地目录。 @@ -72,7 +72,7 @@ 如果两边都选择了它,就需要把两套配置都填完整。 2. 填写 `Temp API` - 这里填写 `Cloudflare Temp Email` 后端地址,例如 `https://your-worker-domain`。 + 这里填写 `Cloudflare Temp Email` 后端地址,例如 [https://your-worker-domain](https://your-worker-domain)。 不论你是拿它来生成邮箱,还是接收转发邮件,这一项都需要先配好。 3. 作为 `邮箱生成` 使用时填写 `Admin Auth` @@ -106,7 +106,7 @@ 先登录你当前正在使用的 `QQ 邮箱` 账号。 2. 进入 `账号与安全` 页面 - 打开 `账号与安全` 页面:`https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/` + 打开 `账号与安全` 页面:[https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/](https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/) 3. 进入 `账号管理` 在 `账号与安全` 页面中找到 `账号管理`。 @@ -122,7 +122,7 @@ ### 第五部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 - 打开 `https://www.paypal.com/signin`。 + 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 然后点击 `注册`。 2. 选择账户类型 @@ -171,11 +171,11 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第六部分:配置 `Clash Verge` 的 `🔁 非港轮询` +### 第六部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 -1. 打开 `Clash Verge`,进入左侧的 `订阅`(`Profiles`)界面。 +1. 打开 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),进入左侧的 `订阅`(`Profiles`)界面。 2. 在右上角或对应位置找到并双击打开 `全局扩展脚本`。 ![05d15cd026ec21d0cffde04ad0d1a6c9](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163252--05d15cd026ec21d0cffde04ad0d1a6c9--0a59ba73acff.png) 3. 将里面的内容全部清空,替换为下方脚本代码。 diff --git a/tests/content-utils.test.js b/tests/content-utils.test.js index e3a11d0..01380a5 100644 --- a/tests/content-utils.test.js +++ b/tests/content-utils.test.js @@ -80,3 +80,15 @@ return { detectScriptSource }; 'mail-163' ); }); + +test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => { + const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n'); + const api = new Function(` +${bundle} +return { shouldReportReadyForFrame }; +`)(); + + assert.equal(api.shouldReportReadyForFrame('plus-checkout', true), false); + assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true); + assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true); +}); diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js new file mode 100644 index 0000000..99bf2d6 --- /dev/null +++ b/tests/plus-checkout-address-input.test.js @@ -0,0 +1,201 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/plus-checkout.js', 'utf8'); + +function extractFunction(name) { + const plainStart = source.indexOf(`function ${name}(`); + const asyncStart = source.indexOf(`async function ${name}(`); + const start = asyncStart >= 0 + ? asyncStart + : plainStart; + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = index; + break; + } + } + + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function createInput({ id = '', name = '', placeholder = '', containerText = '' }) { + const attrs = { + id, + name, + placeholder, + type: 'text', + }; + const container = { + textContent: containerText, + }; + return { + id, + name, + type: 'text', + value: '', + textContent: '', + getAttribute: (key) => attrs[key] || '', + closest: (selector) => { + if (selector === 'label') return null; + if (String(selector || '').includes('[data-testid]')) return container; + return null; + }, + getBoundingClientRect: () => ({ width: 240, height: 40 }), + }; +} + +function createElement({ tagName = 'BUTTON', text = '', attrs = {}, className = '' }) { + return { + tagName, + textContent: text, + value: '', + className, + dataset: {}, + id: attrs.id || '', + checked: false, + getAttribute: (key) => attrs[key] || '', + closest: () => null, + getBoundingClientRect: () => ({ width: 180, height: 64 }), + }; +} + +test('findAddressSearchInput skips the name field when its container says billing address', async () => { + const bundle = [ + 'function throwIfStopped() {}', + 'function sleep() { return Promise.resolve(); }', + extractFunction('waitUntil'), + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getFieldText'), + extractFunction('getVisibleControls'), + extractFunction('getVisibleTextInputs'), + extractFunction('findInputByFieldText'), + extractFunction('getDirectFieldHintText'), + extractFunction('isNonAddressSearchInput'), + extractFunction('isLikelyAddressSearchInput'), + extractFunction('findAddressSearchInput'), + ].join('\n'); + + const nameInput = createInput({ + name: 'name', + placeholder: 'Name', + containerText: 'Billing address', + }); + const addressInput = createInput({ + name: 'addressLine1', + placeholder: 'Address', + containerText: 'Billing address', + }); + const inputs = [nameInput, addressInput]; + const documentMock = { + readyState: 'complete', + querySelectorAll: (selector) => { + if (selector === 'input, textarea') return inputs; + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { findAddressSearchInput, isNonAddressSearchInput }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.isNonAddressSearchInput(nameInput), true); + assert.equal(await api.findAddressSearchInput(), addressInput); +}); + +test('isPayPalPaymentMethodActive requires a selected PayPal control', () => { + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getSearchText'), + extractFunction('getFieldText'), + extractFunction('getCombinedSearchText'), + extractFunction('getVisibleControls'), + extractFunction('getVisibleTextInputs'), + extractFunction('isDocumentLevelContainer'), + extractFunction('getPayPalSearchCandidates'), + extractFunction('hasCreditCardFields'), + extractFunction('hasSelectedPayPalControl'), + extractFunction('isPayPalPaymentMethodActive'), + ].join('\n'); + + const paypalButton = createElement({ + text: 'PayPal', + attrs: { + id: 'paypal-tab', + role: 'tab', + 'aria-selected': '', + }, + }); + const elements = [paypalButton]; + const documentMock = { + documentElement: {}, + body: {}, + querySelectorAll: (selector) => { + if (selector === 'input, textarea') return []; + if (String(selector || '').includes('label[for=')) return []; + return elements; + }, + }; + const windowMock = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { isPayPalPaymentMethodActive }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.isPayPalPaymentMethodActive(), false); + paypalButton.getAttribute = (key) => (key === 'aria-selected' ? 'true' : (paypalButton.id && key === 'id' ? paypalButton.id : '')); + assert.equal(api.isPayPalPaymentMethodActive(), true); +}); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index c69c45c..679e055 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -67,12 +67,16 @@ function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { sendMessage: async (tabId, message, options = {}) => { const frameId = Number.isInteger(options.frameId) ? options.frameId : 0; events.messages.push({ tabId, message, frameId }); + const hasConfiguredState = Object.prototype.hasOwnProperty.call(stateByFrame, frameId); if (message.type === 'PING') { if (readyByFrame[frameId] === false) { throw new Error('No receiving end'); } return { ok: true, source: 'plus-checkout' }; } + if (readyByFrame[frameId] === false && !hasConfiguredState) { + throw new Error('No receiving end'); + } if (message.type === 'PLUS_CHECKOUT_GET_STATE') { return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] }; } @@ -159,6 +163,34 @@ test('Plus checkout billing sends the billing command to the iframe that contain assert.equal(events.completed[0].step, 7); }); +test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => { + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { + hasPayPal: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + hasSubscribeButton: true, + }, + 7: { hasPayPal: false, paypalCandidates: [] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + }, + readyByFrame: { + 0: false, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'); + assert.equal(selectMessage.frameId, 0); + assert.equal(events.completed[0].step, 7); +}); + test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => { const { events, executor } = createExecutorHarness({ frames: [ From ef52ff806b9251458996d0282fb6aaf9d3ff4d11 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 04:31:51 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20meiguodizhi=20?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B4=A6=E5=8D=95=E5=9C=B0=E5=9D=80=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B7=B3=E8=BF=87=20Google?= =?UTF-8?q?=20=E5=9C=B0=E5=9D=80=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 1 + background/steps/fill-plus-checkout.js | 113 ++++++++++++- content/plus-checkout.js | 145 +++++++++++++++- docs/使用教程.md | 157 +++++++++++++++++- tests/plus-checkout-address-input.test.js | 54 ++++++ ...us-checkout-billing-tab-resolution.test.js | 137 ++++++++++++++- 6 files changed, 593 insertions(+), 14 deletions(-) diff --git a/background.js b/background.js index da53818..3ec9e4a 100644 --- a/background.js +++ b/background.js @@ -6924,6 +6924,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling? chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, generateRandomName, getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry, getTabId, diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 7e58f65..cec29ba 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -5,6 +5,13 @@ const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i; const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500; + const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz'; + const MEIGUODIZHI_PATH_BY_COUNTRY = { + AU: '/au-address', + DE: '/de-address', + FR: '/fr-address', + US: '/', + }; function createPlusCheckoutBillingExecutor(deps = {}) { const { @@ -12,6 +19,7 @@ chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + fetch: fetchImpl = null, generateRandomName, getAddressSeedForCountry, getTabId, @@ -26,6 +34,97 @@ return PLUS_CHECKOUT_URL_PATTERN.test(String(url || '')); } + function normalizeText(value = '') { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function hasCompleteAddressFallback(seed) { + const fallback = seed?.fallback || {}; + return Boolean( + normalizeText(fallback.address1) + && normalizeText(fallback.city) + && normalizeText(fallback.postalCode) + ); + } + + function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) { + const address1 = normalizeText(apiAddress?.Address); + const city = normalizeText(apiAddress?.City); + const region = normalizeText(apiAddress?.State || apiAddress?.State_Full); + const postalCode = normalizeText(apiAddress?.Zip_Code); + if (!address1 || !city || !postalCode) { + return null; + } + return { + ...(fallbackSeed || {}), + countryCode, + query: [address1, city].filter(Boolean).join(', '), + source: 'meiguodizhi', + skipAutocomplete: true, + fallback: { + ...(fallbackSeed?.fallback || {}), + address1, + city, + region, + postalCode, + }, + }; + } + + async function fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed) { + if (typeof fetchImpl !== 'function') { + return null; + } + const path = MEIGUODIZHI_PATH_BY_COUNTRY[countryCode] || MEIGUODIZHI_PATH_BY_COUNTRY.DE; + const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || ''); + const response = await fetchImpl(MEIGUODIZHI_ADDRESS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + city, + path, + method: 'refresh', + }), + }); + if (!response?.ok) { + throw new Error(`HTTP ${response?.status || 0}`); + } + const data = await response.json(); + if (data?.status !== 'ok') { + throw new Error(data?.message || data?.status || 'unknown response'); + } + return buildDirectAddressSeed(countryCode, data.address || {}, fallbackSeed); + } + + async function resolveBillingAddressSeed(state = {}, countryOverride = '') { + const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE'); + const fallbackSeed = getAddressSeedForCountry(requestedCountry, { + fallbackCountry: 'DE', + }); + if (!fallbackSeed) { + throw new Error('步骤 7:未找到可用的本地账单地址种子。'); + } + + const countryCode = fallbackSeed.countryCode || 'DE'; + try { + const remoteSeed = await fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed); + if (hasCompleteAddressFallback(remoteSeed)) { + await addLog( + `步骤 7:已从 meiguodizhi 接口获取账单地址(${remoteSeed.fallback.city} / ${remoteSeed.fallback.postalCode}),将跳过 Google 地址推荐。`, + 'info' + ); + return remoteSeed; + } + await addLog('步骤 7:meiguodizhi 接口返回的地址字段不完整,回退到本地地址种子。', 'warn'); + } catch (error) { + await addLog(`步骤 7:meiguodizhi 地址接口不可用,回退到本地地址种子:${error?.message || String(error || '')}`, 'warn'); + } + + return fallbackSeed; + } + async function getAlivePlusCheckoutTabId(tabId) { if (!Number.isInteger(tabId) || tabId <= 0) { return null; @@ -241,6 +340,7 @@ return { frameId: picked.frame.frameId, frameUrl: picked.frame.url || '', + countryText: picked.result?.countryText || '', ready: picked.frame.ready !== false, inspections, }; @@ -312,12 +412,6 @@ 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 付款方式...', 'info'); const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, { @@ -337,10 +431,15 @@ await addLog(`步骤 7:账单地址位于 checkout iframe(frameId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info'); } + const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText); + if (!addressSeed) { + throw new Error('步骤 7:未找到可用的本地账单地址种子。'); + } + await addLog(`步骤 7:正在填写账单地址(${addressSeed.countryCode} / ${addressSeed.query})...`, 'info'); const autocompleteFrame = await resolveOptionalFrameByUrl(tabId, isAutocompleteFrameUrl); let result = null; - if (autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) { + if (!addressSeed.skipAutocomplete && autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) { if (!autocompleteFrame.ready) { throw new Error('步骤 7:发现 Google 地址推荐 iframe,但无法注入账单脚本。请提供该 iframe 的控制台结构。'); } diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 40bfcc4..1beca2d 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -651,6 +651,140 @@ async function fillAddressQuery(seed = {}) { }; } +function getRegionCandidates(value) { + const raw = normalizeText(value); + if (!raw) return []; + const aliases = { + act: 'Australian Capital Territory', + nsw: 'New South Wales', + nt: 'Northern Territory', + qld: 'Queensland', + sa: 'South Australia', + tas: 'Tasmania', + vic: 'Victoria', + wa: 'Western Australia', + }; + const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, ''); + const candidates = [raw]; + if (aliases[compact]) { + candidates.push(aliases[compact]); + } + for (const [abbr, name] of Object.entries(aliases)) { + const compactName = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (compact === compactName) { + candidates.push(abbr.toUpperCase()); + } + } + return Array.from(new Set(candidates.filter(Boolean))); +} + +function matchesRegionOption(text, desiredValue) { + const normalizedText = normalizeText(text).toLowerCase(); + const compactText = normalizedText.replace(/[^a-z0-9]/g, ''); + if (!compactText) return false; + return getRegionCandidates(desiredValue).some((candidate) => { + const normalizedCandidate = normalizeText(candidate).toLowerCase(); + const compactCandidate = normalizedCandidate.replace(/[^a-z0-9]/g, ''); + if (!compactCandidate) return false; + return normalizedText === normalizedCandidate + || compactText === compactCandidate + || (compactCandidate.length > 3 && compactText.includes(compactCandidate)); + }); +} + +function findRegionDropdown() { + const controls = getVisibleControls('select, button, [role="button"], [role="combobox"], [aria-haspopup="listbox"]'); + return controls.find((control) => { + if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false; + const text = getFieldText(control); + if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false; + return /state|province|county/i.test(text) + || /(?:^|\s)region(?:\s|$)/i.test(text) + || /\u5dde|\u7701/.test(text); + }) || null; +} + +function getRegionDropdownValue(control) { + if (!control) return ''; + if (String(control.tagName || '').toUpperCase() === 'SELECT') { + const selected = control.selectedOptions?.[0]; + return normalizeText(selected?.textContent || control.value || ''); + } + return normalizeText( + control.getAttribute?.('aria-valuetext') + || control.getAttribute?.('aria-label') + || control.getAttribute?.('data-value') + || control.textContent + || '' + ); +} + +function getVisibleRegionOptions() { + const selectors = [ + '[role="listbox"] [role="option"]', + '[role="option"]', + 'li', + ]; + const seen = new Set(); + const options = []; + for (const selector of selectors) { + for (const option of Array.from(document.querySelectorAll(selector))) { + if (!isVisibleElement(option)) continue; + const text = normalizeText(getActionText(option) || option.textContent || ''); + if (!text || seen.has(text)) continue; + seen.add(text); + options.push(option); + } + } + return options; +} + +async function selectRegionDropdown(regionDropdown, value) { + if (!regionDropdown || !value) return false; + if (matchesRegionOption(getRegionDropdownValue(regionDropdown), value)) { + return false; + } + + if (String(regionDropdown.tagName || '').toUpperCase() === 'SELECT') { + const option = Array.from(regionDropdown.options || []).find((item) => ( + matchesRegionOption(item.textContent || '', value) + || matchesRegionOption(item.value || '', value) + )); + if (!option) { + throw new Error(`Plus Checkout: state dropdown option "${value}" was not found.`); + } + regionDropdown.value = option.value; + option.selected = true; + regionDropdown.dispatchEvent(new Event('input', { bubbles: true })); + regionDropdown.dispatchEvent(new Event('change', { bubbles: true })); + return true; + } + + simulateClick(regionDropdown); + await sleep(250); + const startedAt = Date.now(); + let option = null; + while (Date.now() - startedAt < 2500) { + throwIfStopped(); + option = getVisibleRegionOptions().find((item) => ( + matchesRegionOption(getActionText(item) || item.textContent || '', value) + )); + if (option) break; + await sleep(100); + } + if (!option) { + const visibleOptions = getVisibleRegionOptions() + .map((item) => normalizeText(getActionText(item) || item.textContent || '')) + .filter(Boolean) + .slice(0, 12) + .join(' | '); + throw new Error(`Plus Checkout: state dropdown option "${value}" was not found. Visible options: ${visibleOptions || 'none'}.`); + } + simulateClick(option); + await sleep(500); + return true; +} + function getStructuredAddressFields() { const address1 = findInputByFieldText([ /address\s*(?:line)?\s*1|street/i, @@ -697,7 +831,7 @@ async function ensureStructuredAddress(seed) { fillIfEmpty(fields.address1, fallback.address1); fillIfEmpty(fields.city, fallback.city); - fillIfEmpty(fields.region, fallback.region); + await selectRegionDropdown(findRegionDropdown(), fallback.region); fillIfEmpty(fields.postalCode, fallback.postalCode); await sleep(500); @@ -713,7 +847,7 @@ async function ensureStructuredAddress(seed) { return { address1: latest.address1?.value || '', city: latest.city?.value || '', - region: latest.region?.value || '', + region: getRegionDropdownValue(findRegionDropdown()) || latest.region?.value || '', postalCode: latest.postalCode?.value || '', }; } @@ -759,7 +893,10 @@ async function fillPlusBillingAddress(payload = {}) { postalCode: '10117', }, }; - const selected = await selectAddressSuggestion(seed); + let selected = { selectedText: '' }; + if (!seed.skipAutocomplete) { + selected = await selectAddressSuggestion(seed); + } const structuredAddress = await ensureStructuredAddress(seed); return { @@ -828,7 +965,7 @@ function inspectPlusCheckoutState() { addressFieldValues: { address1: structuredAddress.address1?.value || '', city: structuredAddress.city?.value || '', - region: structuredAddress.region?.value || '', + region: getRegionDropdownValue(findRegionDropdown()) || structuredAddress.region?.value || '', postalCode: structuredAddress.postalCode?.value || '', }, }; diff --git a/docs/使用教程.md b/docs/使用教程.md index c58dda8..2b2e1c1 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -171,7 +171,162 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第六部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第六部分:0元试用 ChatGPT Plus 教程 + +本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 + +#### 准备工作 + +1. 已有一个登录状态的 ChatGPT 账户。 +2. 一个可用的 PayPal 账户(参考第五部分进行注册和绑卡)。 +3. 能够接收生成的账单地址的真实地址或虚拟地址。 +4. Chrome 浏览器(推荐使用地址补全功能)。 + +#### 执行步骤 + +1. **进入 ChatGPT 并打开开发者工具** + 在已登录 ChatGPT 的页面上,按 `F12` 打开浏览器开发者工具。 + 点击 `Console`(控制台)标签进入命令行界面。 + +2. **允许粘贴脚本** + 在控制台输入 `allow pasting` 并回车。 + 浏览器将允许你粘贴多行脚本代码。 + +3. **粘贴并执行脚本** + 复制下方脚本代码,粘贴到控制台,然后回车执行。 + +```javascript +(async function(){ + try { + const t = await (await fetch("/api/auth/session")).json(); + if(!t.accessToken){ + alert("请先登录 ChatGPT!"); + return; + } + + // 核心1:强制使用触发 PayPal 的欧洲区参数 (DE 德国 / EUR 欧元) + const payload = { + entry_point: "all_plans_pricing_modal", + plan_name: "chatgptplusplan", // Plus 的套餐名 + billing_details: { + country: "DE", // 必须是 DE 或 FR 才能在后续页面使用 PayPal + currency: "EUR" + }, + checkout_ui_mode: "custom", + promo_campaign: { + promo_campaign_id: "plus-1-month-free", // Plus 对应优惠码 + is_coupon_from_query_param: false + } + }; + + const response = await fetch("https://chatgpt.com/backend-api/payments/checkout", { + method: "POST", + headers: { + "Authorization": "Bearer " + t.accessToken, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + if(data.checkout_session_id) { + // 核心2:拼接 Plus 的专属支付短链 + // 如果 openai_ie 报错,可以直接把 /openai_ie 删掉,变成 "https://chatgpt.com/checkout/" + data.checkout_session_id + const shortLink = "https://chatgpt.com/checkout/openai_ie/" + data.checkout_session_id; + + // 弹窗让你复制这个带有 PayPal 的短链 + prompt("提取成功!这是你的 Plus 支付短链(复制保留):", shortLink); + + // 自动跳转到该短链 + window.location.href = shortLink; + } else { + console.error(data); + alert("提取失败:" + (data.detail || JSON.stringify(data))); + } + } catch(e) { + alert("发生异常:" + e); + } +})(); +``` + +4. **复制支付短链** + 脚本执行后会弹出一个对话框,显示你的 Plus 支付短链。 + 点击"确定"按钮,页面会自动跳转到支付页面。 + (可以复制这个链接备用,以防需要重新进入。) + +5. **选择 PayPal 支付** + 页面加载完成后,你会看到 ChatGPT Plus 的 checkout 页面,标题通常为"开始免费试用 Plus"。 + 在左侧付款方式中选择 `PayPal`。 + +6. **填写账单信息 - 选择国家** + 页面右侧会显示账单地址表单。 + "国家或地区" 字段会根据你当前的地址预填(通常是德国或法国)。 + 如果页面显示的不是你期望的国家,可以点击下拉框更改(建议保持德国或法国,以确保支付流程顺利)。 + +7. **填写账单信息 - 输入完整名字** + 在 "全名" 字段中输入完整的英文名字(例如 `John Smith`)。 + +8. **生成和填写地址** + 在 "地址第 1 行" 字段中开始输入。 + 输入城市名、州名或街道名(例如输入 `Berlin` 或 `New York`)。 + 页面会弹出 Google 地址推荐列表。 + 从推荐列表中选择一个地址项(建议选择第二项)。 + 页面会自动回填"城市"、"州"、"邮编"等字段。 + +9. **使用虚拟地址生成工具(可选)** + 如果地址推荐没有出现或推荐的地址不满足需求,可以使用 [https://www.meiguodizhi.com/](https://www.meiguodizhi.com/) 生成虚拟地址。 + 在该网站输入城市名或随机字母获取推荐地址,复制完整地址后粘贴到表单对应字段。 + +10. **完成地址填写并点击订阅** + 确认所有必填字段已填完(全名、地址、城市、州、邮编)。 + 在右侧点击 "订阅" 按钮。 + 页面会跳转到 PayPal 登录界面。 + +11. **PayPal 登录** + 在 PayPal 登录页输入 PayPal 账户邮箱。 + 输入 PayPal 账户密码。 + 点击 "登录" 按钮。 + +12. **处理浏览器通行密钥提示(如果出现)** + 如果出现 "要在无痕模式以外保存此通行密钥吗?" 的弹窗,点击 "取消"。 + +13. **关闭 PayPal 引导弹窗(如果出现)** + 如果出现 "下次登录更快捷" 或其他 PayPal 通行密钥引导弹窗,点击右上角的关闭图标。 + +14. **同意并继续** + 页面显示 "只需一次设置,结账更快捷。" 以及向 `OpenAI Ireland Limited` 付款的摘要。 + 点击 "同意并继续" 按钮。 + 等待页面跳转并加载完成。 + +15. **订阅成功** + 页面会回跳到 ChatGPT 或 OpenAI 的订阅确认页面。 + 此时 Plus 的0元试用订阅已成功完成。 + 你现在可以使用 ChatGPT Plus 的所有功能,试用期结束后会自动按月续订。 + +#### 常见问题处理 + +- **脚本执行报错 "请先登录 ChatGPT!"** + 请确认你当前已登录 ChatGPT 账户。退出重新登录后再尝试。 + +- **脚本执行返回错误信息** + 检查网络连接是否正常。如果多次尝试仍然失败,可能是当前账户不符合0元试用条件,请稍后再试。 + +- **/openai_ie 部分出现404错误** + 如果短链中的 `/openai_ie/` 无法访问,手动修改短链为: + `https://chatgpt.com/checkout/{checkout_session_id}` (删除 `/openai_ie/`) + 然后在浏览器地址栏中重新访问。 + +- **支付页面没有显示 PayPal 选项** + 这可能是因为脚本中的 `country` 参数不是 `DE` 或 `FR`。重新运行脚本,确保国家参数为德国 (DE) 或法国 (FR)。 + +- **地址推荐没有出现** + 稍等 1-2 秒,输入框下方应该会出现地址推荐列表。如果仍未出现,尝试清空后重新输入,或直接使用虚拟地址生成网站的地址。 + +- **PayPal 登录后页面无法继续跳转** + 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 + +### 第七部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index 99bf2d6..fd95e91 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -199,3 +199,57 @@ return { isPayPalPaymentMethodActive }; paypalButton.getAttribute = (key) => (key === 'aria-selected' ? 'true' : (paypalButton.id && key === 'id' ? paypalButton.id : '')); assert.equal(api.isPayPalPaymentMethodActive(), true); }); + +test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => { + const bundle = [ + 'function throwIfStopped() {}', + 'function sleep() { return Promise.resolve(); }', + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getRegionCandidates'), + extractFunction('matchesRegionOption'), + extractFunction('getRegionDropdownValue'), + extractFunction('getVisibleRegionOptions'), + extractFunction('selectRegionDropdown'), + ].join('\n'); + + const state = { opened: false }; + const clicks = []; + const stateDropdown = createElement({ + tagName: 'DIV', + text: 'State New South Wales', + attrs: { + role: 'combobox', + 'aria-haspopup': 'listbox', + }, + }); + const options = [ + createElement({ tagName: 'DIV', text: 'New South Wales', attrs: { role: 'option' } }), + createElement({ tagName: 'DIV', text: 'Western Australia', attrs: { role: 'option' } }), + ]; + const documentMock = { + querySelectorAll: (selector) => { + if (!state.opened) return []; + if (selector === '[role="listbox"] [role="option"]' || selector === '[role="option"]') return options; + if (selector === 'li') return []; + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + + const api = new Function('window', 'document', 'Event', 'clicks', 'stateDropdown', 'state', ` +function simulateClick(el) { + clicks.push(el); + if (el === stateDropdown) state.opened = true; +} +${bundle} +return { selectRegionDropdown }; +`)(windowMock, documentMock, Event, clicks, stateDropdown, state); + + await api.selectRegionDropdown(stateDropdown, 'Western Australia'); + + assert.deepEqual(clicks, [stateDropdown, options[1]]); +}); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 679e055..350d6f5 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -22,6 +22,20 @@ function createAddressSeed() { }; } +function createAuAddressSeed() { + return { + countryCode: 'AU', + query: 'Sydney NSW', + suggestionIndex: 1, + fallback: { + address1: 'George Street', + city: 'Sydney', + region: 'New South Wales', + postalCode: '2000', + }, + }; +} + function createSuccessfulBillingResult() { return { countryText: 'Germany', @@ -33,7 +47,13 @@ function createSuccessfulBillingResult() { }; } -function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { +function createExecutorHarness({ + frames, + stateByFrame, + readyByFrame = {}, + fetchImpl = null, + getAddressSeedForCountry = () => createAddressSeed(), +}) { const api = loadPlusCheckoutBillingModule(); const events = { completed: [], @@ -96,8 +116,9 @@ function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { }, completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }), ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }), + fetch: fetchImpl, generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }), - getAddressSeedForCountry: () => createAddressSeed(), + getAddressSeedForCountry, getTabId: async () => null, isTabAlive: async () => false, setState: async (updates) => events.states.push(updates), @@ -221,6 +242,118 @@ test('Plus checkout billing uses the autocomplete iframe for address suggestions assert.equal(events.completed[0].step, 7); }); +test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a complete address', async () => { + const fetchRequests = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + { frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + 9: { hasPayPal: false, paypalCandidates: [] }, + }, + fetchImpl: async (url, init) => { + fetchRequests.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ + status: 'ok', + address: { + Address: 'Rosa-Luxemburg-Strasse 40', + City: 'Berlin', + State: 'Berlin', + Zip_Code: '69081', + }, + }), + }; + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'); + const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'); + const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS'); + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(fillQueryMessage, undefined); + assert.equal(suggestionMessage, undefined); + assert.equal(ensureAddressMessage, undefined); + assert.equal(combinedFillMessage.frameId, 8); + assert.equal(combinedFillMessage.message.payload.addressSeed.skipAutocomplete, true); + assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, 'Rosa-Luxemburg-Strasse 40'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.city, 'Berlin'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.postalCode, '69081'); + assert.equal(fetchRequests.length, 1); + assert.equal(fetchRequests[0].url, 'https://www.meiguodizhi.com/api/v1/dz'); + assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { + city: 'Berlin', + path: '/de-address', + method: 'refresh', + }); + assert.equal(events.completed[0].step, 7); +}); + +test('Plus checkout billing uses the detected checkout country before choosing an address seed', async () => { + const requestedCountries = []; + const fetchRequests = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { + hasPayPal: false, + paypalCandidates: [], + billingFieldsVisible: true, + countryText: 'Australia', + }, + }, + getAddressSeedForCountry: (countryValue) => { + requestedCountries.push(countryValue); + return /australia|au/i.test(String(countryValue || '')) ? createAuAddressSeed() : createAddressSeed(); + }, + fetchImpl: async (url, init) => { + fetchRequests.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ + status: 'ok', + address: { + Address: '98 Ocean Street', + City: 'Sydney South', + State: 'New South Wales', + Zip_Code: '2000', + }, + }), + }; + }, + }); + + await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' }); + + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(requestedCountries[0], 'Australia'); + assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'AU'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.region, 'New South Wales'); + assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { + city: 'Sydney', + path: '/au-address', + method: 'refresh', + }); +}); + test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => { const { executor } = createExecutorHarness({ frames: [ From 88ceb79af69f44622502cf74f0c07df1d89d87a8 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 05:22:36 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=99=BB=E9=99=86?= =?UTF-8?q?=E5=8F=8A=E5=90=8E=E7=BB=AD=E9=80=BB=E8=BE=91=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=BD=BF=E7=94=A8=E4=B8=80=E4=B8=AA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 160 ++++-- background/logging-status.js | 1 - background/message-router.js | 41 +- background/steps/confirm-oauth.js | 6 +- background/steps/fetch-login-code.js | 63 ++- background/steps/oauth-login.js | 13 +- background/steps/platform-verify.js | 18 +- background/verification-flow.js | 26 +- content/signup-page.js | 478 ++++++++---------- content/vps-panel.js | 2 +- data/step-definitions.js | 5 +- sidepanel/sidepanel.js | 5 +- .../background-logging-status-module.test.js | 1 + ...ckground-message-router-step2-skip.test.js | 45 ++ tests/background-step6-retry-limit.test.js | 49 ++ tests/background-step7-recovery.test.js | 72 ++- tests/sidepanel-custom-email-pool.test.js | 40 ++ tests/step-definitions-module.test.js | 7 +- tests/step6-login-state.test.js | 9 +- tests/step6-oauth-consent-skip.test.js | 134 +++++ tests/verification-flow-polling.test.js | 63 +++ 21 files changed, 868 insertions(+), 370 deletions(-) create mode 100644 tests/step6-oauth-consent-skip.test.js diff --git a/background.js b/background.js index 3ec9e4a..ca6482b 100644 --- a/background.js +++ b/background.js @@ -4420,7 +4420,6 @@ function getLoginAuthStateLabel(state) { if (typeof loggingStatus !== 'undefined' && loggingStatus?.getLoginAuthStateLabel) { return loggingStatus.getLoginAuthStateLabel(state); } - state = state === 'oauth_consent_page' ? 'unknown' : state; switch (state) { case 'verification_page': return '登录验证码页'; case 'password_page': return '密码页'; @@ -4487,7 +4486,8 @@ function hasSavedProgress(statuses = {}, stateOverride = null) { return activeStepIds.some((step) => (merged[step] || 'pending') !== 'pending'); } -function getDownstreamStateResets(step) { +function getDownstreamStateResets(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); const plusRuntimeResets = { plusCheckoutTabId: null, plusCheckoutUrl: null, @@ -4574,6 +4574,20 @@ function getDownstreamStateResets(step) { localhostUrl: null, }; } + if (stepKey === 'oauth-login' || stepKey === 'fetch-login-code') { + return { + lastLoginCode: null, + loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, + localhostUrl: null, + }; + } + if (stepKey === 'confirm-oauth') { + return { + localhostUrl: null, + }; + } return {}; } @@ -4609,7 +4623,7 @@ async function invalidateDownstreamAfterStepRestart(step, options = {}) { await addLog(`${logLabel},已重置后续步骤状态:${changedSteps.join(', ')}`, 'warn'); } - const resets = getDownstreamStateResets(step); + const resets = getDownstreamStateResets(step, state); if (Object.keys(resets).length) { await setState(resets); broadcastDataUpdate(resets); @@ -5436,6 +5450,24 @@ 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, 12]); +const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ + 'open-chatgpt', + 'submit-signup-email', + 'fetch-signup-code', + 'clear-login-cookies', + 'plus-checkout-create', + 'plus-checkout-billing', + 'paypal-approve', + 'plus-checkout-return', + 'oauth-login', + 'fetch-login-code', + 'confirm-oauth', +]); +const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([ + 'fill-password', + 'fill-profile', + 'platform-verify', +]); function waitForStepComplete(step, timeoutMs = 120000) { return new Promise((resolve, reject) => { @@ -5457,7 +5489,26 @@ function waitForStepComplete(step, timeoutMs = 120000) { }); } -function doesStepUseCompletionSignal(step) { +function getStepExecutionKeyForState(step, state = {}) { + if (typeof getStepDefinitionForState !== 'function') { + return ''; + } + return String(getStepDefinitionForState(step, state)?.key || '').trim(); +} + +function doesStepUseBackgroundCompletion(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); + if (stepKey) { + return AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS.has(stepKey); + } + return AUTO_RUN_BACKGROUND_COMPLETED_STEPS.has(step); +} + +function doesStepUseCompletionSignal(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); + if (stepKey) { + return STEP_COMPLETION_SIGNAL_STEP_KEYS.has(stepKey); + } return STEP_COMPLETION_SIGNAL_STEPS.has(step); } @@ -5586,16 +5637,28 @@ async function waitForRunningStepsToFinish(payload = {}) { return currentState; } -const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10, 11, 12]); +const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10, 11, 12, 13]); +const AUTH_CHAIN_STEP_KEYS = new Set([ + 'oauth-login', + 'fetch-login-code', + 'confirm-oauth', + 'platform-verify', +]); let activeTopLevelAuthChainExecution = null; -function isAuthChainStep(step) { +function isAuthChainStep(step, state = {}) { + const stepKey = typeof getStepDefinitionForState === 'function' + ? String(getStepDefinitionForState(step, state)?.key || '').trim() + : ''; + if (stepKey && typeof AUTH_CHAIN_STEP_KEYS !== 'undefined') { + return AUTH_CHAIN_STEP_KEYS.has(stepKey); + } return AUTH_CHAIN_STEP_IDS.has(Number(step)); } -async function acquireTopLevelAuthChainExecution(step) { +async function acquireTopLevelAuthChainExecution(step, state = {}) { const normalizedStep = Number(step); - if (!isAuthChainStep(normalizedStep)) { + if (!isAuthChainStep(normalizedStep, state)) { return { joined: false, release() {}, @@ -5735,7 +5798,8 @@ async function requestStop(options = {}) { async function executeStep(step, options = {}) { const { deferRetryableTransportError = false } = options; console.log(LOG_PREFIX, `Executing step ${step}`); - const authChainClaim = await acquireTopLevelAuthChainExecution(step); + let state = await getState(); + const authChainClaim = await acquireTopLevelAuthChainExecution(step, state); if (authChainClaim.joined) { return; } @@ -5747,7 +5811,7 @@ async function executeStep(step, options = {}) { await addLog(`步骤 ${step} 开始执行`); await humanStepDelay(); - const state = await getState(); + state = await getState(); // Set flow start time on first step if (step === 1 && !state.flowStartTime) { @@ -5765,11 +5829,11 @@ async function executeStep(step, options = {}) { }); } catch (err) { executionError = err; - const state = await getState(); + const errorState = await getState(); if (isStopError(err)) { await setStepStatus(step, 'stopped'); await addLog(`步骤 ${step} 已被用户停止`, 'warn'); - await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, state, getErrorMessage(err)); + await appendManualAccountRunRecordIfNeeded(`step${step}_stopped`, errorState, getErrorMessage(err)); throw err; } if (isTerminalSecurityBlockedError(err)) { @@ -5780,10 +5844,10 @@ async function executeStep(step, options = {}) { await handleBrowserSwitchRequired(err); throw new Error(STOP_ERROR_MESSAGE); } - if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step) && isRetryableContentScriptTransportError(err))) { + if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step, errorState) && isRetryableContentScriptTransportError(err))) { await setStepStatus(step, 'failed'); await addLog(`步骤 ${step} 失败:${err.message}`, 'error'); - await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, state, getErrorMessage(err)); + await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, errorState, getErrorMessage(err)); } else { console.warn( LOG_PREFIX, @@ -5813,12 +5877,13 @@ async function executeStepAndWait(step, delayAfter = 2000) { await sleepWithStop(delaySeconds * 1000); } - if (AUTO_RUN_BACKGROUND_COMPLETED_STEPS.has(step)) { + const executionState = await getState(); + if (doesStepUseBackgroundCompletion(step, executionState)) { await addLog(`自动运行:步骤 ${step} 由后台流程负责收尾,执行函数返回后将直接进入下一步。`, 'info'); await executeStep(step); const latestState = await getState(); await addLog(`自动运行:步骤 ${step} 已执行返回,当前状态为 ${latestState.stepStatuses?.[step] || 'pending'},准备继续后续步骤。`, 'info'); - } else if (doesStepUseCompletionSignal(step)) { + } else if (doesStepUseCompletionSignal(step, executionState)) { await addLog(`自动运行:步骤 ${step} 已发起,正在等待完成信号(超时 ${AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS / 1000} 秒)。`, 'info'); await executeStepViaCompletionSignal(step, AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS); await addLog(`自动运行:步骤 ${step} 已收到完成信号,准备继续后续步骤。`, 'info'); @@ -6581,7 +6646,10 @@ async function runAutoSequenceFromStep(startStep, context = {}) { if (restartDecision.blockedByAddPhone) { const addPhoneUrl = restartDecision.authState?.url || 'https://auth.openai.com/add-phone'; - await addLog(`步骤 ${step}:检测到认证流程进入 add-phone(${addPhoneUrl}),停止自动回到步骤 7 重开。`, 'warn'); + const authChainStartStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(await getState()) + : FINAL_OAUTH_CHAIN_START_STEP; + await addLog(`步骤 ${step}:检测到认证流程进入 add-phone(${addPhoneUrl}),停止自动回到步骤 ${authChainStartStep} 重开。`, 'warn'); } throw err; } @@ -7440,12 +7508,13 @@ async function runPreStep6CookieCleanup() { // Step 7: Login and ensure the auth page reaches the login verification page // ============================================================ -async function refreshOAuthUrlBeforeStep6(state) { +async function refreshOAuthUrlBeforeStep6(state, options = {}) { + const visibleStep = Number(options.visibleStep) || Number(state?.visibleStep) || 7; if (state?.contributionModeExpected && !state?.contributionMode) { - throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。'); + throw new Error(`步骤 ${visibleStep}:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。`); } if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) { - await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info'); + await addLog(`步骤 ${visibleStep}:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...`, 'info'); const contributionState = await contributionOAuthManager.startContributionFlow({ nickname: state.contributionNickname || '', openAuthTab: false, @@ -7458,9 +7527,9 @@ async function refreshOAuthUrlBeforeStep6(state) { await handleStepData(1, { oauthUrl }); return oauthUrl; } - await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); + await addLog(`步骤 ${visibleStep}:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info'); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel'); - const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' }); + const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: `步骤 ${visibleStep}` }); await handleStepData(1, refreshResult); if (!refreshResult?.oauthUrl) { @@ -7470,9 +7539,12 @@ async function refreshOAuthUrlBeforeStep6(state) { return refreshResult.oauthUrl; } -function buildOAuthFlowTimeoutError(step, actionLabel = '后续授权流程') { +function buildOAuthFlowTimeoutError(step, actionLabel = '后续授权流程', state = {}) { + const restartStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(state) + : FINAL_OAUTH_CHAIN_START_STEP; return new Error( - `步骤 ${step}:从拿到 OAuth 登录地址开始,${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟内未完成${actionLabel},结束当前链路,准备从步骤 7 重新开始。` + `步骤 ${step}:从拿到 OAuth 登录地址开始,${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟内未完成${actionLabel},结束当前链路,准备从步骤 ${restartStep} 重新开始。` ); } @@ -7523,7 +7595,7 @@ async function getOAuthFlowRemainingMs(options = {}) { const remainingMs = deadlineAt - Date.now(); if (remainingMs <= 0) { - throw buildOAuthFlowTimeoutError(step, actionLabel); + throw buildOAuthFlowTimeoutError(step, actionLabel, state); } return remainingMs; @@ -7539,9 +7611,11 @@ async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) { const budgetMs = remainingMs - reserveMs; if (budgetMs <= 0) { + const stateForError = options.state || await getState(); throw buildOAuthFlowTimeoutError( Number(options.step) || 7, - String(options.actionLabel || '后续授权流程').trim() || '后续授权流程' + String(options.actionLabel || '后续授权流程').trim() || '后续授权流程', + stateForError ); } @@ -7615,7 +7689,7 @@ async function getPostStep6AutoRestartDecision(step, error) { let authState = null; try { authState = await getLoginAuthStateFromContent({ - logMessage: `步骤 ${normalizedStep}:正在确认当前认证页状态,以决定是否回到步骤 7 重开...`, + logMessage: `步骤 ${normalizedStep}:正在确认当前认证页状态,以决定是否回到步骤 ${authChainStartStep} 重开...`, }); } catch (inspectError) { console.warn(LOG_PREFIX, '[AutoRun] failed to inspect login auth state after post-step6 error', { @@ -7671,6 +7745,8 @@ async function getLoginAuthStateFromContent(options = {}) { } async function ensureStep8VerificationPageReady(options = {}) { + const visibleStep = Number(options.visibleStep) || 8; + const authLoginStep = Number(options.authLoginStep) || (visibleStep >= 11 ? 10 : 7); const pageState = await getLoginAuthStateFromContent(options); if (pageState.state === 'verification_page') { return pageState; @@ -7682,17 +7758,17 @@ async function ensureStep8VerificationPageReady(options = {}) { if (pageState.state === 'login_timeout_error_page') { const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; - throw new Error(`STEP8_RESTART_STEP7::步骤 8:当前认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim()); + throw new Error(`STEP8_RESTART_STEP7::步骤 ${visibleStep}:当前认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim()); } if (pageState.state === 'add_phone_page') { const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; - throw new Error(`步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + throw new Error(`步骤 ${visibleStep}:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); } const stateLabel = getLoginAuthStateLabel(pageState.state); const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; - throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim()); + throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 ${authLoginStep}。当前状态:${stateLabel}.${urlPart}`.trim()); } async function rerunStep7ForStep8Recovery(options = {}) { @@ -7703,27 +7779,33 @@ async function rerunStep7ForStep8Recovery(options = {}) { throwIfStopped(); const initialState = await getState(); + const authLoginStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(initialState) + : FINAL_OAUTH_CHAIN_START_STEP; await addLog(logMessage, 'warn'); - await setStepStatus(7, 'running'); - await addLog('步骤 7 开始执行'); + await setStepStatus(authLoginStep, 'running'); + await addLog(`步骤 ${authLoginStep} 开始执行`); try { - await step7Executor.executeStep7(initialState); + await step7Executor.executeStep7({ + ...initialState, + visibleStep: authLoginStep, + }); } catch (err) { const latestState = await getState(); if (isStopError(err)) { - await setStepStatus(7, 'stopped'); - await addLog('步骤 7 已被用户停止', 'warn'); - await appendManualAccountRunRecordIfNeeded('step7_stopped', latestState, getErrorMessage(err)); + await setStepStatus(authLoginStep, 'stopped'); + await addLog(`步骤 ${authLoginStep} 已被用户停止`, 'warn'); + await appendManualAccountRunRecordIfNeeded(`step${authLoginStep}_stopped`, latestState, getErrorMessage(err)); throw err; } if (isTerminalSecurityBlockedError(err)) { await handleCloudflareSecurityBlocked(err); throw new Error(STOP_ERROR_MESSAGE); } - await setStepStatus(7, 'failed'); - await addLog(`步骤 7 失败:${getErrorMessage(err)}`, 'error'); - await appendManualAccountRunRecordIfNeeded('step7_failed', latestState, getErrorMessage(err)); + await setStepStatus(authLoginStep, 'failed'); + await addLog(`步骤 ${authLoginStep} 失败:${getErrorMessage(err)}`, 'error'); + await appendManualAccountRunRecordIfNeeded(`step${authLoginStep}_failed`, latestState, getErrorMessage(err)); throw err; } diff --git a/background/logging-status.js b/background/logging-status.js index 47d3c2c..15aeff7 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -70,7 +70,6 @@ } function getLoginAuthStateLabel(state) { - state = state === 'oauth_consent_page' ? 'unknown' : state; switch (state) { case 'verification_page': return '登录验证码页'; diff --git a/background/message-router.js b/background/message-router.js index 8261b53..a95bf59 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -137,6 +137,31 @@ return ''; } + function isStepProtectedFromAutoSkip(status) { + return status === 'running' + || status === 'completed' + || status === 'manual_completed' + || status === 'skipped'; + } + + function findStepByKeyAfter(currentStep, targetKey, state = {}) { + const activeStepIds = typeof getStepIdsForState === 'function' + ? getStepIdsForState(state) + : []; + const candidates = activeStepIds.length ? activeStepIds : [Number(currentStep) + 1, 8]; + return candidates.find((stepId) => { + const numericStep = Number(stepId); + if (!Number.isFinite(numericStep) || numericStep <= Number(currentStep)) { + return false; + } + const stepKey = getStepKeyForState(numericStep, state); + if (stepKey) { + return stepKey === targetKey; + } + return targetKey === 'fetch-login-code' && Number(currentStep) === 7 && numericStep === 8; + }) || null; + } + async function handlePlatformVerifyStepData(payload) { if (payload.localhostUrl) { await closeLocalhostCallbackTabs(payload.localhostUrl); @@ -180,7 +205,18 @@ const stepKey = getStepKeyForState(step, stateForStep); if (stepKey === 'oauth-login') { - if (payload.loginVerificationRequestedAt) { + if (payload.skipLoginVerificationStep) { + await setState({ loginVerificationRequestedAt: null }); + const latestState = await getState(); + const loginCodeStep = findStepByKeyAfter(step, 'fetch-login-code', latestState); + if (loginCodeStep) { + const currentStatus = latestState.stepStatuses?.[loginCodeStep]; + if (!isStepProtectedFromAutoSkip(currentStatus)) { + await setStepStatus(loginCodeStep, 'skipped'); + await addLog(`步骤 ${step}:认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn'); + } + } + } else if (payload.loginVerificationRequestedAt) { await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); } return; @@ -484,7 +520,8 @@ await setPersistentSettings({ emailPrefix: message.payload.emailPrefix }); await setState({ emailPrefix: message.payload.emailPrefix }); } - if (doesStepUseCompletionSignal(step)) { + const executionState = await getState(); + if (doesStepUseCompletionSignal(step, executionState)) { await executeStepViaCompletionSignal(step); } else { await executeStep(step); diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index 88153a7..88607c9 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -38,10 +38,14 @@ return visibleStep > 0 ? visibleStep : fallback; } + function getAuthLoginStepForVisibleStep(visibleStep) { + return visibleStep >= 12 ? 10 : 7; + } + async function executeStep9(state) { const visibleStep = getVisibleStep(state, 9); if (!state.oauthUrl) { - const authLoginStep = visibleStep === 11 ? 10 : 7; + const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}。`); } diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index a379a77..c25b2f4 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -31,25 +31,34 @@ throwIfStopped, } = deps; - async function getStep8ReadyTimeoutMs(actionLabel, expectedOauthUrl = '') { + function getVisibleStep(state, fallback = 8) { + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + return visibleStep > 0 ? visibleStep : fallback; + } + + function getAuthLoginStepForVisibleStep(visibleStep) { + return visibleStep >= 11 ? 10 : 7; + } + + async function getStep8ReadyTimeoutMs(actionLabel, expectedOauthUrl = '', visibleStep = 8) { if (typeof getOAuthFlowStepTimeoutMs !== 'function') { return 15000; } return getOAuthFlowStepTimeoutMs(15000, { - step: 8, + step: visibleStep, actionLabel, oauthUrl: expectedOauthUrl, }); } - function getStep8RemainingTimeResolver(expectedOauthUrl = '') { + function getStep8RemainingTimeResolver(expectedOauthUrl = '', visibleStep = 8) { if (typeof getOAuthFlowRemainingMs !== 'function') { return undefined; } return async (details = {}) => getOAuthFlowRemainingMs({ - step: 8, + step: visibleStep, actionLabel: details.actionLabel || '登录验证码流程', oauthUrl: expectedOauthUrl, }); @@ -96,6 +105,7 @@ } async function runStep8Attempt(state) { + const visibleStep = getVisibleStep(state, 8); const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); @@ -110,14 +120,16 @@ await chrome.tabs.update(authTabId, { active: true }); } else { if (!state.oauthUrl) { - throw new Error('缺少登录用 OAuth 链接,请先完成步骤 7。'); + throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); } await reuseOrCreateTab('signup-page', state.oauthUrl); } throwIfStopped(); const pageState = await ensureStep8VerificationPageReady({ - timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || ''), + visibleStep, + authLoginStep: getAuthLoginStepForVisibleStep(visibleStep), + timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep), }); const shouldCompareVerificationEmail = mail.provider !== '2925'; const displayedVerificationEmail = shouldCompareVerificationEmail @@ -131,22 +143,25 @@ step8VerificationTargetEmail: displayedVerificationEmail || '', }); - await addLog('步骤 8:登录验证码页面已就绪,开始获取验证码。', 'info'); + await addLog(`步骤 ${visibleStep}:登录验证码页面已就绪,开始获取验证码。`, 'info'); if (shouldCompareVerificationEmail && displayedVerificationEmail) { - await addLog(`步骤 8:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); + await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); } if (shouldUseCustomRegistrationEmail(state)) { - await confirmCustomVerificationStepBypass(8); + await confirmCustomVerificationStepBypass(8, { + completionStep: visibleStep, + promptStep: visibleStep, + }); return; } if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { - await addLog('步骤 8:正在确认 iCloud 邮箱登录态...', 'info'); + await addLog(`步骤 ${visibleStep}:正在确认 iCloud 邮箱登录态...`, 'info'); await ensureIcloudMailSession({ state, step: 8, - actionLabel: '步骤 8:确认 iCloud 邮箱登录态', + actionLabel: `步骤 ${visibleStep}:确认 iCloud 邮箱登录态`, }); } @@ -156,22 +171,22 @@ || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER ) { - await addLog(`步骤 8:正在通过 ${mail.label} 轮询验证码...`); + await addLog(`步骤 ${visibleStep}:正在通过 ${mail.label} 轮询验证码...`); } else { - await addLog(`步骤 8:正在打开${mail.label}...`); + await addLog(`步骤 ${visibleStep}:正在打开${mail.label}...`); if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') { await ensureMail2925MailboxSession({ accountId: state.currentMail2925AccountId || null, forceRelogin: false, allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), - actionLabel: 'Step 8: ensure 2925 mailbox session', + actionLabel: `Step ${visibleStep}: ensure 2925 mailbox session`, }); } else { await focusOrOpenMailTab(mail); } if (mail.provider === '2925') { - await addLog(`步骤 8:将直接使用当前已登录的 ${mail.label} 轮询验证码。`, 'info'); + await addLog(`步骤 ${visibleStep}:将直接使用当前已登录的 ${mail.label} 轮询验证码。`, 'info'); } } @@ -179,10 +194,11 @@ ...state, step8VerificationTargetEmail: displayedVerificationEmail || '', }, mail, { + completionStep: visibleStep, filterAfterTimestamp: verificationFilterAfterTimestamp, sessionKey: verificationSessionKey, disableTimeBudgetCap: mail.provider === '2925', - getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || ''), + getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep), requestFreshCodeFirst: false, targetEmail: fixedTargetEmail, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') @@ -206,6 +222,8 @@ await runStep8Attempt(currentState); return; } catch (err) { + const visibleStep = getVisibleStep(currentState, 8); + const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); if (!isVerificationMailPollingError(err) && !isStep8RestartStep7Error(err)) { throw err; } @@ -218,26 +236,27 @@ mailPollingAttempt += 1; await addLog( isStep8RestartStep7Error(err) - ? `步骤 8:检测到认证页进入重试/超时报错状态,准备从步骤 7 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...` - : `步骤 8:检测到邮箱轮询类失败,准备从步骤 7 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...`, + ? `步骤 ${visibleStep}:检测到认证页进入重试/超时报错状态,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...` + : `步骤 ${visibleStep}:检测到邮箱轮询类失败,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...`, 'warn' ); await rerunStep7ForStep8Recovery({ logMessage: isStep8RestartStep7Error(err) - ? '步骤 8:认证页进入重试/超时报错状态,正在回到步骤 7 重新发起登录流程...' - : '步骤 8:正在回到步骤 7,重新发起登录验证码流程...', + ? `步骤 ${visibleStep}:认证页进入重试/超时报错状态,正在回到步骤 ${authLoginStep} 重新发起登录流程...` + : `步骤 ${visibleStep}:正在回到步骤 ${authLoginStep},重新发起登录验证码流程...`, }); currentState = await getState(); } } + const visibleStep = getVisibleStep(currentState, 8); if (lastMailPollingError) { throw new Error( - `步骤 8:登录验证码流程在 ${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS} 轮邮箱轮询恢复后仍未成功。最后一次原因:${lastMailPollingError.message}` + `步骤 ${visibleStep}:登录验证码流程在 ${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS} 轮邮箱轮询恢复后仍未成功。最后一次原因:${lastMailPollingError.message}` ); } - throw new Error('步骤 8:登录验证码流程未成功完成。'); + throw new Error(`步骤 ${visibleStep}:登录验证码流程未成功完成。`); } return { executeStep8 }; diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index d57a261..d12ca49 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -60,7 +60,7 @@ try { const currentState = attempt === 1 ? state : await getState(); const password = currentState.password || currentState.customPassword || ''; - const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); + const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState, { visibleStep }); if (typeof startOAuthFlowTimeoutWindow === 'function') { await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl }); } @@ -105,9 +105,16 @@ } if (isStep6SuccessResult(result)) { - await completeStepFromBackground(visibleStep, { + const completionPayload = { loginVerificationRequestedAt: result.loginVerificationRequestedAt || null, - }); + }; + if (result.skipLoginVerificationStep) { + completionPayload.skipLoginVerificationStep = true; + } + if (result.directOAuthConsentPage) { + completionPayload.directOAuthConsentPage = true; + } + await completeStepFromBackground(visibleStep, completionPayload); return; } diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index fefaac5..b88f295 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -32,21 +32,25 @@ } function getConfirmStepForVisibleStep(visibleStep) { - return visibleStep === 12 ? 11 : 9; + return visibleStep >= 13 ? 12 : 9; } - function parseLocalhostCallback(rawUrl) { + function getAuthLoginStepForVisibleStep(visibleStep) { + return visibleStep >= 13 ? 10 : 7; + } + + function parseLocalhostCallback(rawUrl, visibleStep = 10, confirmStep = 9) { let parsed; try { parsed = new URL(rawUrl); } catch { - throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。'); + throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmStep}。`); } const code = normalizeString(parsed.searchParams.get('code')); const state = normalizeString(parsed.searchParams.get('state')); if (!code || !state) { - throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。'); + throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmStep}。`); } return { @@ -191,16 +195,16 @@ throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); } if (!state.codex2apiSessionId) { - throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${visibleStep === 12 ? 10 : 7}。`); + throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); } if (!normalizeString(state.codex2apiAdminKey)) { throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); } - const callback = parseLocalhostCallback(state.localhostUrl); + const callback = parseLocalhostCallback(state.localhostUrl, visibleStep, confirmStep); const expectedState = normalizeString(state.codex2apiOAuthState); if (expectedState && expectedState !== callback.state) { - throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); + throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); } const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); diff --git a/background/verification-flow.js b/background/verification-flow.js index dbc9594..229bed7 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -175,25 +175,32 @@ return Math.max(0, Math.floor(Number(VERIFICATION_POLL_MAX_ROUNDS) || 1) - 1); } - async function confirmCustomVerificationStepBypass(step) { + function getCompletionStep(step, options = {}) { + const completionStep = Number(options.completionStep); + return Number.isFinite(completionStep) && completionStep > 0 ? completionStep : step; + } + + async function confirmCustomVerificationStepBypass(step, options = {}) { + const completionStep = getCompletionStep(step, options); + const promptStep = getCompletionStep(step, { completionStep: options.promptStep ?? completionStep }); const verificationLabel = getVerificationCodeLabel(step); - await addLog(`步骤 ${step}:当前为自定义邮箱模式,请手动在页面中输入${verificationLabel}验证码并进入下一页面。`, 'warn'); + await addLog(`步骤 ${completionStep}:当前为自定义邮箱模式,请手动在页面中输入${verificationLabel}验证码并进入下一页面。`, 'warn'); let response = null; try { - response = await confirmCustomVerificationStepBypassRequest(step); + response = await confirmCustomVerificationStepBypassRequest(promptStep); } catch { - throw new Error(`步骤 ${step}:无法打开确认弹窗,请先保持侧边栏打开后重试。`); + throw new Error(`步骤 ${completionStep}:无法打开确认弹窗,请先保持侧边栏打开后重试。`); } if (response?.error) { throw new Error(response.error); } if (step === 8 && response?.addPhoneDetected) { - throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone'); + throw new Error(`步骤 ${completionStep}:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone`); } if (!response?.confirmed) { - throw new Error(`步骤 ${step}:已取消手动${verificationLabel}验证码确认。`); + throw new Error(`步骤 ${completionStep}:已取消手动${verificationLabel}验证码确认。`); } await setState({ @@ -201,8 +208,8 @@ signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, }); - await deps.setStepStatus(step, 'skipped'); - await addLog(`步骤 ${step}:已确认手动完成${verificationLabel}验证码输入,当前步骤已跳过。`, 'warn'); + await deps.setStepStatus(completionStep, 'skipped'); + await addLog(`步骤 ${completionStep}:已确认手动完成${verificationLabel}验证码输入,当前步骤已跳过。`, 'warn'); } function getVerificationPollPayload(step, state, overrides = {}) { @@ -781,6 +788,7 @@ } async function resolveVerificationStep(step, state, mail, options = {}) { + const completionStep = getCompletionStep(step, options); const stateKey = getVerificationCodeStateKey(step); const rejectedCodes = new Set(); const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER @@ -918,7 +926,7 @@ [stateKey]: result.code, }); - await completeStepFromBackground(step, { + await completeStepFromBackground(completionStep, { emailTimestamp: result.emailTimestamp, code: result.code, phoneVerificationRequired: Boolean(submitResult.addPhonePage), diff --git a/content/signup-page.js b/content/signup-page.js index 0c85113..9c6caf6 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1803,6 +1803,13 @@ function inspectLoginAuthState() { }; } + if (consentReady) { + return { + ...baseState, + state: 'oauth_consent_page', + }; + } + return baseState; } @@ -1830,7 +1837,7 @@ function serializeLoginAuthState(snapshot) { } function getLoginAuthStateLabel(snapshot) { - const state = snapshot?.state === 'oauth_consent_page' ? 'unknown' : snapshot?.state; + const state = snapshot?.state; switch (state) { case 'verification_page': return '登录验证码页'; @@ -1887,13 +1894,32 @@ async function waitForLoginVerificationPageReady(timeout = 10000) { } function createStep6SuccessResult(snapshot, options = {}) { - return { + const result = { step6Outcome: 'success', state: snapshot?.state || 'verification_page', url: snapshot?.url || location.href, via: options.via || '', loginVerificationRequestedAt: options.loginVerificationRequestedAt || null, }; + + if (options.skipLoginVerificationStep) { + result.skipLoginVerificationStep = true; + } + if (options.directOAuthConsentPage) { + result.directOAuthConsentPage = true; + } + + return result; +} + +function createStep6OAuthConsentSuccessResult(snapshot, options = {}) { + return createStep6SuccessResult(snapshot, { + ...options, + via: options.via || 'oauth_consent_page', + loginVerificationRequestedAt: null, + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + }); } function createStep6RecoverableResult(reason, snapshot, options = {}) { @@ -1948,6 +1974,15 @@ async function createStep6LoginTimeoutRecoveryTransition(reason, snapshot, messa }; } + if (resolvedSnapshot.state === 'oauth_consent_page') { + return { + action: 'done', + result: createStep6OAuthConsentSuccessResult(resolvedSnapshot, { + via, + }), + }; + } + if (resolvedSnapshot.state === 'password_page') { log('步骤 7:登录超时报错页恢复后已进入密码页,继续当前登录流程。', 'warn'); return { action: 'password', snapshot: resolvedSnapshot }; @@ -2007,6 +2042,13 @@ async function finalizeStep6VerificationReady(options = {}) { }); } + if (snapshot.state === 'oauth_consent_page') { + log(`${logLabel}:认证页已直接进入 OAuth 授权页,跳过登录验证码步骤。`, 'ok'); + return createStep6OAuthConsentSuccessResult(snapshot, { + via: `${via}_oauth_consent`, + }); + } + if (snapshot.state === 'login_timeout_error_page') { log(`${logLabel}:页面进入登录超时报错页,准备自动恢复后重试步骤 7。`, 'warn'); return createStep6LoginTimeoutRecoverableResult( @@ -2037,6 +2079,12 @@ async function finalizeStep6VerificationReady(options = {}) { loginVerificationRequestedAt, }); } + if (snapshot.state === 'oauth_consent_page') { + log(`${logLabel}:认证页已直接进入 OAuth 授权页,跳过登录验证码步骤。`, 'ok'); + return createStep6OAuthConsentSuccessResult(snapshot, { + via: `${via}_oauth_consent`, + }); + } if (snapshot.state === 'login_timeout_error_page') { log(`${logLabel}:页面进入登录超时报错页,准备自动恢复后重试步骤 7。`, 'warn'); return createStep6LoginTimeoutRecoverableResult( @@ -2059,21 +2107,14 @@ async function finalizeStep6VerificationReady(options = {}) { } function normalizeStep6Snapshot(snapshot) { - if (snapshot?.state !== 'oauth_consent_page') { - return snapshot; - } - - return { - ...snapshot, - state: 'unknown', - }; + return snapshot; } function throwForStep6FatalState(snapshot) { snapshot = normalizeStep6Snapshot(snapshot); switch (snapshot?.state) { case 'oauth_consent_page': - throw new Error(`当前页面已进入 OAuth 授权页,未经过登录验证码页,无法完成步骤 7。URL: ${snapshot.url}`); + return; case 'add_phone_page': throw new Error(`当前页面已进入手机号页面,未经过登录验证码页,无法完成步骤 7。URL: ${snapshot.url}`); case 'unknown': @@ -2595,88 +2636,55 @@ async function fillVerificationCode(step, payload) { // Step 7: Login with registered account (on OAuth auth page) // ============================================================ -async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) { - const start = Date.now(); - let snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); +function getStep6OptionMessage(value, snapshot) { + return typeof value === 'function' ? value(snapshot) : String(value || ''); +} - while (Date.now() - start < timeout) { - throwIfStopped(); - snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); +async function resolveStep6PostSubmitSnapshot(snapshot, options = {}) { + const normalizedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState()); + const { + via = 'post_submit', + loginVerificationRequestedAt = null, + oauthConsentVia = `${via}_oauth_consent`, + timeoutRecoveryReason = 'login_timeout_error_page', + timeoutRecoveryMessage = '登录提交后进入登录超时报错页。', + timeoutRecoveryVia = `${via}_timeout_recovered`, + allowPasswordAction = false, + allowEmailAction = false, + allowFinalPasswordAction = false, + allowFinalEmailAction = false, + allowFinalSwitchAction = false, + final = false, + addPhoneMessage, + } = options; - if (snapshot.state === 'verification_page') { - return { - action: 'done', - result: createStep6SuccessResult(snapshot, { - via: 'email_submit', - loginVerificationRequestedAt: emailSubmittedAt, - }), - }; - } - - if (snapshot.state === 'password_page') { - return { action: 'password', snapshot }; - } - - if (snapshot.state === 'login_timeout_error_page') { - const transition = await createStep6LoginTimeoutRecoveryTransition( - 'login_timeout_error_page', - snapshot, - '提交邮箱后进入登录超时报错页。', - { - loginVerificationRequestedAt: emailSubmittedAt, - via: 'email_submit_timeout_recovered', - } - ); - if (transition.action === 'done') { - return { - action: 'done', - result: transition.result, - }; - } - if (transition.action === 'password') { - return { action: 'password', snapshot: transition.snapshot }; - } - if (transition.action === 'email') { - return { action: 'email', snapshot: transition.snapshot }; - } - return { - action: 'recoverable', - result: transition.result, - }; - } - - if (snapshot.state === 'oauth_consent_page') { - throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); - } - - if (snapshot.state === 'add_phone_page') { - throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); - } - - await sleep(250); - } - - snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); - if (snapshot.state === 'verification_page') { + if (normalizedSnapshot.state === 'verification_page') { return { action: 'done', - result: createStep6SuccessResult(snapshot, { - via: 'email_submit', - loginVerificationRequestedAt: emailSubmittedAt, + result: createStep6SuccessResult(normalizedSnapshot, { + via, + loginVerificationRequestedAt, }), }; } - if (snapshot.state === 'password_page') { - return { action: 'password', snapshot }; + + if (normalizedSnapshot.state === 'oauth_consent_page') { + return { + action: 'done', + result: createStep6OAuthConsentSuccessResult(normalizedSnapshot, { + via: oauthConsentVia, + }), + }; } - if (snapshot.state === 'login_timeout_error_page') { + + if (normalizedSnapshot.state === 'login_timeout_error_page') { const transition = await createStep6LoginTimeoutRecoveryTransition( - 'login_timeout_error_page', - snapshot, - '提交邮箱后进入登录超时报错页。', + timeoutRecoveryReason, + normalizedSnapshot, + timeoutRecoveryMessage, { - loginVerificationRequestedAt: emailSubmittedAt, - via: 'email_submit_timeout_recovered', + loginVerificationRequestedAt, + via: timeoutRecoveryVia, } ); if (transition.action === 'done') { @@ -2696,213 +2704,116 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120 result: transition.result, }; } - if (snapshot.state === 'oauth_consent_page') { - throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + + if (normalizedSnapshot.state === 'password_page') { + if (allowPasswordAction || (final && allowFinalPasswordAction)) { + return { action: 'password', snapshot: normalizedSnapshot }; + } + if (final && allowFinalSwitchAction && normalizedSnapshot.switchTrigger) { + return { action: 'switch', snapshot: normalizedSnapshot }; + } } - if (snapshot.state === 'add_phone_page') { - throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + + if (normalizedSnapshot.state === 'email_page' && (allowEmailAction || (final && allowFinalEmailAction))) { + return { action: 'email', snapshot: normalizedSnapshot }; + } + + if (normalizedSnapshot.state === 'add_phone_page') { + const message = getStep6OptionMessage(addPhoneMessage, normalizedSnapshot) + || `登录提交后页面进入手机号页面。URL: ${normalizedSnapshot.url || location.href}`; + throw new Error(message); + } + + return null; +} + +async function waitForStep6PostSubmitTransition(options = {}) { + const { + timeout = 10000, + stalledReason = 'post_submit_stalled', + stalledMessage = '登录提交后未进入可识别的下一页。', + } = options; + const start = Date.now(); + let snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + const transition = await resolveStep6PostSubmitSnapshot(snapshot, { + ...options, + final: false, + }); + if (transition) { + return transition; + } + await sleep(250); + } + + snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + const transition = await resolveStep6PostSubmitSnapshot(snapshot, { + ...options, + final: true, + }); + if (transition) { + return transition; } return { action: 'recoverable', - result: createStep6RecoverableResult('email_submit_stalled', snapshot, { - message: '提交邮箱后长时间未进入密码页或登录验证码页。', + result: createStep6RecoverableResult(stalledReason, snapshot, { + message: stalledMessage, + loginVerificationRequestedAt: options.loginVerificationRequestedAt || null, }), }; } +async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) { + return waitForStep6PostSubmitTransition({ + timeout, + via: 'email_submit', + oauthConsentVia: 'email_submit_oauth_consent', + loginVerificationRequestedAt: emailSubmittedAt, + timeoutRecoveryMessage: '提交邮箱后进入登录超时报错页。', + timeoutRecoveryVia: 'email_submit_timeout_recovered', + allowPasswordAction: true, + stalledReason: 'email_submit_stalled', + stalledMessage: '提交邮箱后长时间未进入密码页或登录验证码页。', + addPhoneMessage: (snapshot) => `提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`, + }); +} + async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000) { - const start = Date.now(); - let snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); - - while (Date.now() - start < timeout) { - throwIfStopped(); - snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); - - if (snapshot.state === 'verification_page') { - return { - action: 'done', - result: createStep6SuccessResult(snapshot, { - via: 'password_submit', - loginVerificationRequestedAt: passwordSubmittedAt, - }), - }; - } - - if (snapshot.state === 'login_timeout_error_page') { - const transition = await createStep6LoginTimeoutRecoveryTransition( - 'login_timeout_error_page', - snapshot, - '提交密码后进入登录超时报错页。', - { - loginVerificationRequestedAt: passwordSubmittedAt, - via: 'password_submit_timeout_recovered', - } - ); - if (transition.action === 'done') { - return { - action: 'done', - result: transition.result, - }; - } - if (transition.action === 'password') { - return { action: 'password', snapshot: transition.snapshot }; - } - if (transition.action === 'email') { - return { action: 'email', snapshot: transition.snapshot }; - } - return { - action: 'recoverable', - result: transition.result, - }; - } - - if (snapshot.state === 'oauth_consent_page') { - throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); - } - - if (snapshot.state === 'add_phone_page') { - throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); - } - - await sleep(250); - } - - snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); - if (snapshot.state === 'verification_page') { - return { - action: 'done', - result: createStep6SuccessResult(snapshot, { - via: 'password_submit', - loginVerificationRequestedAt: passwordSubmittedAt, - }), - }; - } - if (snapshot.state === 'login_timeout_error_page') { - const transition = await createStep6LoginTimeoutRecoveryTransition( - 'login_timeout_error_page', - snapshot, - '提交密码后进入登录超时报错页。', - { - loginVerificationRequestedAt: passwordSubmittedAt, - via: 'password_submit_timeout_recovered', - } - ); - if (transition.action === 'done') { - return { - action: 'done', - result: transition.result, - }; - } - if (transition.action === 'password') { - return { action: 'password', snapshot: transition.snapshot }; - } - if (transition.action === 'email') { - return { action: 'email', snapshot: transition.snapshot }; - } - return { - action: 'recoverable', - result: transition.result, - }; - } - if (snapshot.state === 'oauth_consent_page') { - throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); - } - if (snapshot.state === 'add_phone_page') { - throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); - } - if (snapshot.state === 'password_page' && snapshot.switchTrigger) { - return { action: 'switch', snapshot }; - } - - return { - action: 'recoverable', - result: createStep6RecoverableResult('password_submit_stalled', snapshot, { - message: '提交密码后仍未进入登录验证码页。', - }), - }; + return waitForStep6PostSubmitTransition({ + timeout, + via: 'password_submit', + oauthConsentVia: 'password_submit_oauth_consent', + loginVerificationRequestedAt: passwordSubmittedAt, + timeoutRecoveryMessage: '提交密码后进入登录超时报错页。', + timeoutRecoveryVia: 'password_submit_timeout_recovered', + allowFinalSwitchAction: true, + stalledReason: 'password_submit_stalled', + stalledMessage: '提交密码后仍未进入登录验证码页。', + addPhoneMessage: (snapshot) => `提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`, + }); } async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeout = 10000) { - const start = Date.now(); - let snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); + const transition = await waitForStep6PostSubmitTransition({ + timeout, + via: 'switch_to_one_time_code_login', + oauthConsentVia: 'switch_to_one_time_code_oauth_consent', + loginVerificationRequestedAt, + timeoutRecoveryMessage: '切换到一次性验证码登录后进入登录超时报错页。', + timeoutRecoveryVia: 'switch_to_one_time_code_timeout_recovered', + stalledReason: 'one_time_code_switch_stalled', + stalledMessage: '点击一次性验证码登录后仍未进入登录验证码页。', + addPhoneMessage: (snapshot) => `切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`, + }); - while (Date.now() - start < timeout) { - throwIfStopped(); - snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); - - if (snapshot.state === 'verification_page') { - return createStep6SuccessResult(snapshot, { - via: 'switch_to_one_time_code_login', - loginVerificationRequestedAt, - }); - } - - if (snapshot.state === 'login_timeout_error_page') { - const transition = await createStep6LoginTimeoutRecoveryTransition( - 'login_timeout_error_page', - snapshot, - '切换到一次性验证码登录后进入登录超时报错页。', - { - loginVerificationRequestedAt, - via: 'switch_to_one_time_code_timeout_recovered', - } - ); - if (transition.action === 'done') { - return transition.result; - } - if (transition.action === 'password' || transition.action === 'email') { - return transition; - } - return transition.result; - } - - if (snapshot.state === 'oauth_consent_page') { - throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); - } - - if (snapshot.state === 'add_phone_page') { - throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); - } - - await sleep(250); - } - - snapshot = normalizeStep6Snapshot(inspectLoginAuthState()); - if (snapshot.state === 'verification_page') { - return createStep6SuccessResult(snapshot, { - via: 'switch_to_one_time_code_login', - loginVerificationRequestedAt, - }); - } - if (snapshot.state === 'login_timeout_error_page') { - const transition = await createStep6LoginTimeoutRecoveryTransition( - 'login_timeout_error_page', - snapshot, - '切换到一次性验证码登录后进入登录超时报错页。', - { - loginVerificationRequestedAt, - via: 'switch_to_one_time_code_timeout_recovered', - } - ); - if (transition.action === 'done') { - return transition.result; - } - if (transition.action === 'password' || transition.action === 'email') { - return transition; - } + if (transition.action === 'done' || transition.action === 'recoverable') { return transition.result; } - if (snapshot.state === 'oauth_consent_page') { - throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); - } - if (snapshot.state === 'add_phone_page') { - throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); - } - - return createStep6RecoverableResult('one_time_code_switch_stalled', snapshot, { - message: '点击一次性验证码登录后仍未进入登录验证码页。', - }); + return transition; } async function step6SwitchToOneTimeCodeLogin(payload, snapshot) { @@ -2921,6 +2832,9 @@ async function step6SwitchToOneTimeCodeLogin(payload, snapshot) { await sleep(1200); const result = await waitForStep6SwitchTransition(loginVerificationRequestedAt); if (result?.step6Outcome === 'success') { + if (result.skipLoginVerificationStep) { + return result; + } return finalizeStep6VerificationReady({ logLabel: '步骤 7 收尾', loginVerificationRequestedAt: result.loginVerificationRequestedAt || loginVerificationRequestedAt, @@ -2964,6 +2878,9 @@ async function step6LoginFromPasswordPage(payload, snapshot) { const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt); if (transition.action === 'done') { + if (transition.result?.skipLoginVerificationStep) { + return transition.result; + } return finalizeStep6VerificationReady({ logLabel: '步骤 7 收尾', loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || passwordSubmittedAt, @@ -3020,6 +2937,9 @@ async function step6LoginFromEmailPage(payload, snapshot) { const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt); if (transition.action === 'done') { + if (transition.result?.skipLoginVerificationStep) { + return transition.result; + } return finalizeStep6VerificationReady({ logLabel: '步骤 7 收尾', loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || emailSubmittedAt, @@ -3057,6 +2977,13 @@ async function step6_login(payload) { }); } + if (snapshot.state === 'oauth_consent_page') { + log('步骤 7:认证页已直接进入 OAuth 授权页,跳过登录验证码步骤。', 'ok'); + return createStep6OAuthConsentSuccessResult(snapshot, { + via: 'already_on_oauth_consent_page', + }); + } + if (snapshot.state === 'login_timeout_error_page') { log('步骤 7:检测到登录超时报错页,先尝试恢复当前页面。', 'warn'); const transition = await createStep6LoginTimeoutRecoveryTransition( @@ -3069,6 +2996,9 @@ async function step6_login(payload) { } ); if (transition.action === 'done') { + if (transition.result?.skipLoginVerificationStep) { + return transition.result; + } return finalizeStep6VerificationReady({ logLabel: '步骤 7 收尾', loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || null, diff --git a/content/vps-panel.js b/content/vps-panel.js index 41094f2..0a590ee 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -1012,7 +1012,7 @@ async function step1_getOAuthLink(payload, options = {}) { async function step9_vpsVerify(payload) { const visibleStep = Number(payload?.visibleStep) || 10; - const confirmStep = visibleStep === 12 ? 11 : 9; + const confirmStep = visibleStep >= 13 ? 12 : 9; await ensureOAuthManagementPage(payload?.vpsPassword, confirmStep); // 优先从 payload 读取 localhostUrl;没有时再回退到全局状态 diff --git a/data/step-definitions.js b/data/step-definitions.js index 0fec026..3852332 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -25,8 +25,9 @@ { 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: '平台回调验证' }, + { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, + { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, + { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' }, ]; function isPlusModeEnabled(options = {}) { diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 7792b30..156bd46 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -2965,6 +2965,7 @@ function getCustomMailProviderUiCopy() { function getCustomVerificationPromptCopy(step) { const verificationLabel = step === 4 ? '注册验证码' : '登录验证码'; + const isLoginVerificationStep = step === 8 || step === 11; return { title: `手动处理${verificationLabel}`, message: `当前邮箱服务为“自定义邮箱”。请先在页面中手动输入${verificationLabel},并确认已经进入下一页面后,再点击确认。`, @@ -2972,7 +2973,7 @@ function getCustomVerificationPromptCopy(step) { text: `点击确认后会跳过步骤 ${step}。`, tone: 'danger', }, - ...(step === 8 ? { + ...(isLoginVerificationStep ? { phoneActionLabel: '出现手机号验证', phoneActionAlert: { text: '如果当前页面已经进入手机号验证,可直接标记为失败并继续下一个邮箱。', @@ -2984,7 +2985,7 @@ function getCustomVerificationPromptCopy(step) { async function openCustomVerificationConfirmDialog(step) { const promptCopy = getCustomVerificationPromptCopy(step); - if (step === 8) { + if (step === 8 || step === 11) { return openActionModal({ title: promptCopy.title, message: promptCopy.message, diff --git a/tests/background-logging-status-module.test.js b/tests/background-logging-status-module.test.js index b5f94f6..5e66201 100644 --- a/tests/background-logging-status-module.test.js +++ b/tests/background-logging-status-module.test.js @@ -44,4 +44,5 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu true ); assert.equal(loggingStatus.getLoginAuthStateLabel('phone_verification_page'), '手机验证码页'); + assert.equal(loggingStatus.getLoginAuthStateLabel('oauth_consent_page'), 'OAuth 授权页'); }); diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index a90bf63..9ca45f6 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/tests/background-message-router-step2-skip.test.js @@ -59,6 +59,8 @@ function createRouter(overrides = {}) { getPendingAutoRunTimerPlan: () => null, getSourceLabel: () => '', getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } }, + getStepDefinitionForState: overrides.getStepDefinitionForState, + getStepIdsForState: overrides.getStepIdsForState, getTabId: overrides.getTabId || (async () => null), getStopRequested: () => false, handleAutoRunLoopUnhandledError: async () => {}, @@ -184,6 +186,49 @@ test('message router skips step 5 when step 4 reports already logged-in transiti assert.equal(events.logs[0]?.message, '步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。'); }); +test('message router skips login-code step when oauth login lands on consent page', async () => { + const stepKeys = { + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'confirm-oauth', + }; + const { router, events } = createRouter({ + state: { stepStatuses: { 7: 'completed', 8: 'pending', 9: 'pending' } }, + getStepDefinitionForState: (step) => ({ id: step, key: stepKeys[step] || '' }), + getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }); + + await router.handleStepData(7, { + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + }); + + assert.deepStrictEqual(events.stepStatuses, [{ step: 8, status: 'skipped' }]); + assert.equal(events.logs.some(({ message }) => /OAuth 授权页.*步骤 8/.test(message)), true); +}); + +test('message router skips Plus login-code step when oauth login lands on consent page', async () => { + const stepKeys = { + 10: 'oauth-login', + 11: 'fetch-login-code', + 12: 'confirm-oauth', + 13: 'platform-verify', + }; + const { router, events } = createRouter({ + state: { plusModeEnabled: true, stepStatuses: { 10: 'completed', 11: 'pending', 12: 'pending', 13: 'pending' } }, + getStepDefinitionForState: (step) => ({ id: step, key: stepKeys[step] || '' }), + getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }); + + await router.handleStepData(10, { + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + }); + + assert.deepStrictEqual(events.stepStatuses, [{ step: 11, status: 'skipped' }]); + assert.equal(events.logs.some(({ message }) => /OAuth 授权页.*步骤 11/.test(message)), true); +}); + test('message router finalizes step 3 before marking it completed', async () => { const { router, events } = createRouter(); diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index 091a579..39c0ca9 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -178,6 +178,55 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as ]); }); +test('step 7 forwards direct OAuth consent skip metadata when completing', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + completions: [], + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeStepFromBackground: async (step, payload) => { + events.completions.push({ step, payload }); + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async () => ({ + step6Outcome: 'success', + state: 'oauth_consent_page', + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + }), + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await executor.executeStep7({ + email: 'user@example.com', + password: 'secret', + visibleStep: 10, + }); + + assert.deepStrictEqual(events.completions, [ + { + step: 10, + payload: { + loginVerificationRequestedAt: null, + skipLoginVerificationStep: true, + directOAuthConsentPage: true, + }, + }, + ]); +}); + test('step 7 stops immediately when management secret is missing', async () => { const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); const globalScope = {}; diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index d1a2613..6b28446 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -85,8 +85,78 @@ test('step 8 submits login verification directly without replaying step 7', asyn { step8VerificationTargetEmail: 'display.user@example.com' }, ]); assert.deepStrictEqual(calls.ensureReadyOptions, [ - { timeoutMs: 5000 }, + { visibleStep: 8, authLoginStep: 7, timeoutMs: 5000 }, ]); + assert.equal(calls.resolveOptions.completionStep, 8); +}); + +test('Plus login-code step reuses step 8 verification logic but completes visible step 11', async () => { + let resolvedStep = null; + let resolvedOptions = null; + let readyOptions = null; + const remainingStepCalls = []; + + const executor = api.createStep8Executor({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async (options) => { + readyOptions = options; + return { state: 'verification_page', displayedEmail: 'plus.user@example.com' }; + }, + rerunStep7ForStep8Recovery: async () => {}, + getOAuthFlowRemainingMs: async (details) => { + remainingStepCalls.push(details.step); + return 9000; + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, details) => { + remainingStepCalls.push(details.step); + return Math.min(defaultTimeoutMs, 9000); + }, + getMailConfig: () => ({ + provider: 'qq', + label: 'QQ 邮箱', + source: 'mail-qq', + url: 'https://mail.qq.com', + navigateOnReuse: false, + }), + getState: async () => ({ email: 'user@example.com', password: 'secret', plusModeEnabled: true }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2), + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + resolveVerificationStep: async (step, _state, _mail, options) => { + resolvedStep = step; + resolvedOptions = options; + await options.getRemainingTimeMs({ actionLabel: '登录验证码流程' }); + }, + reuseOrCreateTab: async () => {}, + setState: async () => {}, + shouldUseCustomRegistrationEmail: () => false, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + await executor.executeStep8({ + visibleStep: 11, + plusModeEnabled: true, + email: 'user@example.com', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }); + + assert.equal(resolvedStep, 8); + assert.equal(resolvedOptions.completionStep, 11); + assert.equal(resolvedOptions.targetEmail, 'plus.user@example.com'); + assert.deepStrictEqual(readyOptions, { visibleStep: 11, authLoginStep: 10, timeoutMs: 9000 }); + assert.deepStrictEqual(remainingStepCalls, [11, 11]); }); test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => { diff --git a/tests/sidepanel-custom-email-pool.test.js b/tests/sidepanel-custom-email-pool.test.js index a8e7b6e..75e5c89 100644 --- a/tests/sidepanel-custom-email-pool.test.js +++ b/tests/sidepanel-custom-email-pool.test.js @@ -216,3 +216,43 @@ return { assert.equal(modalPayload.actions[1].id, 'add_phone'); assert.equal(modalPayload.actions[1].label, '出现手机号验证'); }); + +test('sidepanel custom verification dialog exposes add-phone action for Plus login code step', async () => { + const bundle = [ + extractFunction('getCustomVerificationPromptCopy'), + extractFunction('openCustomVerificationConfirmDialog'), + ].join('\n'); + + const api = new Function(` +let openActionModalPayload = null; + +async function openActionModal(options) { + openActionModalPayload = options; + return options.buildResult('add_phone'); +} + +async function openConfirmModal() { + throw new Error('Plus login code step should use action modal'); +} + +${bundle} + +return { + getCustomVerificationPromptCopy, + openCustomVerificationConfirmDialog, + getOpenActionModalPayload: () => openActionModalPayload, +}; +`)(); + + const prompt = api.getCustomVerificationPromptCopy(11); + assert.equal(prompt.phoneActionLabel, '出现手机号验证'); + + const result = await api.openCustomVerificationConfirmDialog(11); + assert.deepEqual(result, { + confirmed: false, + addPhoneDetected: true, + }); + + const modalPayload = api.getOpenActionModalPayload(); + assert.equal(modalPayload.actions[1].id, 'add_phone'); +}); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index 9ac6f7b..ea6390b 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -44,14 +44,15 @@ test('step definitions module exposes ordered normal and Plus step metadata', () 'paypal-approve', 'plus-checkout-return', 'oauth-login', + 'fetch-login-code', 'confirm-oauth', 'platform-verify', ] ); assert.equal(plusSteps.some((step) => step.key === 'clear-login-cookies'), false); - assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), false); - assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); - assert.equal(api.getLastStepId({ plusModeEnabled: true }), 12); + assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), true); + assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); + assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13); }); test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => { diff --git a/tests/step6-login-state.test.js b/tests/step6-login-state.test.js index c274f60..f9f2f95 100644 --- a/tests/step6-login-state.test.js +++ b/tests/step6-login-state.test.js @@ -215,17 +215,20 @@ return { consentReady: true, }); + const inspected = api.inspectLoginAuthState(); + assert.strictEqual(inspected.state, 'oauth_consent_page'); + const snapshot = api.normalizeStep6Snapshot({ state: 'oauth_consent_page', url: 'https://auth.openai.com/authorize', }); - assert.strictEqual(snapshot.state, 'unknown', '第六步应忽略 oauth_consent_page 状态'); + assert.strictEqual(snapshot.state, 'oauth_consent_page', '第六步应保留 oauth_consent_page 状态'); } assert.ok( - !extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"), - 'inspectLoginAuthState 不应再产出 oauth_consent_page 状态' + extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"), + 'inspectLoginAuthState 应产出 oauth_consent_page 状态' ); console.log('step6 login state tests passed'); diff --git a/tests/step6-oauth-consent-skip.test.js b/tests/step6-oauth-consent-skip.test.js new file mode 100644 index 0000000..d8ad6bb --- /dev/null +++ b/tests/step6-oauth-consent-skip.test.js @@ -0,0 +1,134 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === '(') { + parenDepth += 1; + } else if (char === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (char === '{' && signatureEnded) { + braceStart = index; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const char = source[end]; + if (char === '{') depth += 1; + if (char === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('password submit treats direct OAuth consent as a login-code skip', async () => { + const api = new Function(` +const location = { href: 'https://auth.openai.com/authorize' }; + +function inspectLoginAuthState() { + return { + state: 'oauth_consent_page', + url: location.href, + }; +} + +function throwIfStopped() {} +async function sleep() { + throw new Error('should not wait once oauth consent is detected'); +} + +${extractFunction('createStep6SuccessResult')} +${extractFunction('createStep6OAuthConsentSuccessResult')} +${extractFunction('createStep6RecoverableResult')} +${extractFunction('normalizeStep6Snapshot')} +${extractFunction('getStep6OptionMessage')} +${extractFunction('resolveStep6PostSubmitSnapshot')} +${extractFunction('waitForStep6PostSubmitTransition')} +${extractFunction('waitForStep6PasswordSubmitTransition')} + +return { + run() { + return waitForStep6PasswordSubmitTransition(123, 1000); + }, +}; +`)(); + + const transition = await api.run(); + + assert.equal(transition.action, 'done'); + assert.equal(transition.result.state, 'oauth_consent_page'); + assert.equal(transition.result.skipLoginVerificationStep, true); + assert.equal(transition.result.directOAuthConsentPage, true); + assert.equal(transition.result.loginVerificationRequestedAt, null); +}); + +test('step 7 entry succeeds when the auth page is already on OAuth consent', async () => { + const logs = []; + const api = new Function(` +const location = { href: 'https://auth.openai.com/authorize' }; +const logs = arguments[0]; + +function inspectLoginAuthState() { + return { + state: 'oauth_consent_page', + url: location.href, + }; +} + +function throwIfStopped() {} +async function sleep() {} +function log(message, level = 'info') { + logs.push({ message, level }); +} + +${extractFunction('createStep6SuccessResult')} +${extractFunction('createStep6OAuthConsentSuccessResult')} +${extractFunction('normalizeStep6Snapshot')} +${extractFunction('waitForKnownLoginAuthState')} +${extractFunction('step6_login')} + +return { + run() { + return step6_login({ email: 'user@example.com' }); + }, +}; +`)(logs); + + const result = await api.run(); + + assert.equal(result.step6Outcome, 'success'); + assert.equal(result.state, 'oauth_consent_page'); + assert.equal(result.skipLoginVerificationStep, true); + assert.equal(result.directOAuthConsentPage, true); + assert.equal(logs.some(({ level }) => level === 'ok'), true); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index df4f6d8..426b828 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -833,6 +833,69 @@ test('verification flow uses configured login resend count for step 8', async () assert.equal(pollCalls, 3); }); +test('verification flow can complete Plus visible login-code step with shared step 8 semantics', async () => { + const completed = []; + const fillMessages = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async (step, payload) => { + completed.push({ step, payload }); + }, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'FILL_CODE') { + fillMessages.push(message); + } + return {}; + }, + sendToMailContentScriptResilient: async () => ({ code: '654321', emailTimestamp: 456 }), + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 8, + { email: 'user@example.com', lastLoginCode: null }, + { provider: 'qq', label: 'QQ 邮箱' }, + { + completionStep: 11, + requestFreshCodeFirst: false, + maxResendRequests: 0, + resendIntervalMs: 0, + } + ); + + assert.deepStrictEqual(fillMessages.map((message) => message.step), [8]); + assert.deepStrictEqual(completed, [ + { + step: 11, + payload: { + emailTimestamp: 456, + code: '654321', + phoneVerificationRequired: false, + }, + }, + ]); +}); + test('verification flow waits during resend cooldown instead of tight-looping', async () => { const sleepCalls = []; let pollCalls = 0; From b86114fa3ad4e1876387e784bbda214907306bad Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 05:25:17 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Plus=20Checkou?= =?UTF-8?q?t=20=E5=88=9B=E5=BB=BA=E5=90=8E=E7=AD=89=E5=BE=85=2020=20?= =?UTF-8?q?=E7=A7=92=E7=9A=84=E9=80=BB=E8=BE=91=EF=BC=8C=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=9B=B8=E5=BA=94=E7=9A=84=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/create-plus-checkout.js | 4 ++ tests/plus-checkout-create-wait.test.js | 55 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/plus-checkout-create-wait.test.js diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index f00bee4..fb995ab 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -4,6 +4,7 @@ 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']; + const PLUS_CHECKOUT_POST_CREATE_WAIT_MS = 20000; function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -64,6 +65,9 @@ plusCheckoutCurrency: result.currency || 'EUR', }); + await addLog('步骤 6:Plus Checkout 页面已就绪,固定等待 20 秒后继续下一步。', 'info'); + await sleepWithStop(PLUS_CHECKOUT_POST_CREATE_WAIT_MS); + await completeStepFromBackground(6, { plusCheckoutCountry: result.country || 'DE', plusCheckoutCurrency: result.currency || 'EUR', diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js new file mode 100644 index 0000000..8253d6c --- /dev/null +++ b/tests/plus-checkout-create-wait.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/steps/create-plus-checkout.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutCreate;`)(globalScope); + +test('Plus checkout create waits 20 seconds before completing step 6', async () => { + const events = []; + const executor = api.createPlusCheckoutCreateExecutor({ + addLog: async (message, level = 'info') => { + events.push({ type: 'log', message, level }); + }, + chrome: { + tabs: { + update: async (tabId, payload) => { + events.push({ type: 'tab-update', tabId, payload }); + }, + }, + }, + completeStepFromBackground: async (step, payload) => { + events.push({ type: 'complete', step, payload }); + }, + ensureContentScriptReadyOnTabUntilStopped: async () => { + events.push({ type: 'ready' }); + }, + reuseOrCreateTab: async () => 42, + sendTabMessageUntilStopped: async () => ({ + checkoutUrl: 'https://checkout.stripe.com/c/pay/session', + country: 'US', + currency: 'USD', + }), + setState: async (payload) => { + events.push({ type: 'set-state', payload }); + }, + sleepWithStop: async (ms) => { + events.push({ type: 'sleep', ms }); + }, + waitForTabCompleteUntilStopped: async () => { + events.push({ type: 'tab-complete' }); + }, + }); + + await executor.executePlusCheckoutCreate(); + + const sleepEvents = events.filter((event) => event.type === 'sleep'); + assert.deepStrictEqual(sleepEvents.map((event) => event.ms), [1000, 1000, 20000]); + + const fixedWaitIndex = events.findIndex((event) => event.type === 'sleep' && event.ms === 20000); + const completeIndex = events.findIndex((event) => event.type === 'complete'); + assert.ok(fixedWaitIndex > -1); + assert.ok(completeIndex > fixedWaitIndex); + assert.equal(events.some((event) => event.type === 'log' && /固定等待 20 秒/.test(event.message)), true); +}); From 3271dc2f83b4616ecc46a88fc9f9d5b5acd00efd Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 05:47:44 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20PayPal=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A3=80=E6=B5=8B=E9=82=AE=E7=AE=B1=E5=92=8C?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E8=BE=93=E5=85=A5=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E5=A4=84=E7=90=86=E5=92=8C=E7=9B=B8=E5=BA=94?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/paypal-approve.js | 106 +++++++++++++++++- content/paypal-flow.js | 57 ++++++++-- tests/paypal-approve-detection.test.js | 146 +++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 tests/paypal-approve-detection.test.js diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index b638654..3dee859 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -4,6 +4,8 @@ const PAYPAL_SOURCE = 'paypal-flow'; const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; const PAYPAL_INJECT_FILES = ['content/utils.js', 'content/paypal-flow.js']; + const PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS = 30000; + const PAYPAL_LOGIN_TRANSITION_POLL_MS = 500; function createPayPalApproveExecutor(deps = {}) { const { @@ -87,6 +89,89 @@ if (result?.error) { throw new Error(result.error); } + return result || {}; + } + + function isPayPalUrl(url = '') { + return /paypal\./i.test(String(url || '')); + } + + function isPayPalPasswordState(pageState = {}) { + return Boolean(pageState.hasPasswordInput) + || pageState.loginPhase === 'password' + || pageState.loginPhase === 'login_combined'; + } + + async function waitForPayPalPostLoginDecision(tabId, actionResult = {}) { + const phase = String(actionResult?.phase || '').trim(); + const startedAt = Date.now(); + + while (Date.now() - startedAt < PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS) { + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('步骤 8:PayPal 标签页已关闭,无法继续识别登录后的页面。'); + } + + const currentUrl = tab.url || ''; + if (!currentUrl) { + await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS); + continue; + } + if (currentUrl && !isPayPalUrl(currentUrl)) { + return { + outcome: 'left_paypal', + url: currentUrl, + }; + } + + if (tab.status !== 'complete') { + await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS); + continue; + } + + await ensurePayPalReady( + tabId, + phase === 'email_submitted' + ? '步骤 8:PayPal 账号已提交,正在识别下一页...' + : '步骤 8:PayPal 密码已提交,正在识别跳转结果...' + ); + const pageState = await getPayPalState(tabId); + + if (pageState.hasPasskeyPrompt) { + return { + outcome: 'prompt', + pageState, + }; + } + + if (pageState.approveReady) { + return { + outcome: 'approve_ready', + pageState, + }; + } + + if (phase === 'email_submitted' && isPayPalPasswordState(pageState)) { + return { + outcome: 'password_ready', + pageState, + }; + } + + if (phase === 'password_submitted' && !pageState.needsLogin) { + return { + outcome: 'post_login_state', + pageState, + }; + } + + await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS); + } + + return { + outcome: 'timeout', + phase, + }; } async function clickApprove(tabId) { @@ -109,7 +194,7 @@ let loggedWaiting = false; while (true) { const currentUrl = (await chrome.tabs.get(tabId).catch(() => null))?.url || ''; - if (currentUrl && !/paypal\./i.test(currentUrl)) { + if (currentUrl && !isPayPalUrl(currentUrl)) { await addLog('步骤 8:PayPal 已跳转离开授权页,准备进入回跳确认。', 'ok'); break; } @@ -118,9 +203,22 @@ const pageState = await getPayPalState(tabId); if (pageState.needsLogin) { - await submitLogin(tabId, state); - await waitForTabCompleteUntilStopped(tabId); - await sleepWithStop(1000); + const submitResult = await submitLogin(tabId, state); + const decision = await waitForPayPalPostLoginDecision(tabId, submitResult); + if (decision.outcome === 'left_paypal') { + await addLog('步骤 8:PayPal 登录后已跳转离开登录/授权页,继续进入回跳确认。', 'ok'); + break; + } + if (decision.outcome === 'password_ready') { + await addLog('步骤 8:PayPal 账号页提交后已识别到密码页,继续填写密码。', 'info'); + } else if (decision.outcome === 'approve_ready') { + await addLog('步骤 8:PayPal 登录后已识别到授权确认页,继续点击授权。', 'info'); + } else if (decision.outcome === 'prompt') { + await addLog('步骤 8:PayPal 登录后已识别到提示弹窗,继续处理弹窗。', 'info'); + } else if (decision.outcome === 'timeout') { + await addLog('步骤 8:PayPal 登录动作后暂未识别到新页面,重新检查当前页面状态。', 'warn'); + } + loggedWaiting = false; continue; } diff --git a/content/paypal-flow.js b/content/paypal-flow.js index f003609..7cc4217 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -48,12 +48,17 @@ async function handlePayPalCommand(message) { async function waitUntil(predicate, options = {}) { const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250)); + const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0)); + const startedAt = Date.now(); while (true) { throwIfStopped(); const value = await predicate(); if (value) { return value; } + if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) { + throw new Error(options.timeoutMessage || 'PayPal page timed out waiting for target state.'); + } await sleep(intervalMs); } } @@ -179,6 +184,13 @@ function hasPasskeyPrompt() { return findPasskeyPromptButtons().length > 0; } +function getPayPalLoginPhase(emailInput, passwordInput) { + if (emailInput && passwordInput) return 'login_combined'; + if (passwordInput) return 'password'; + if (emailInput) return 'email'; + return ''; +} + async function submitPayPalLogin(payload = {}) { await waitForDocumentComplete(); @@ -192,19 +204,34 @@ async function submitPayPalLogin(payload = {}) { const emailInput = findEmailInput(); if (!passwordInput && emailInput && email) { - fillInput(emailInput, email); - const nextButton = findLoginNextButton(); - if (nextButton && isEnabledControl(nextButton)) { - simulateClick(nextButton); + if (normalizeText(emailInput.value || '') !== email) { + fillInput(emailInput, email); } - passwordInput = await waitUntil(() => findPasswordInput(), { intervalMs: 250 }); + const nextButton = await waitUntil(() => { + const button = findLoginNextButton(); + return button && isEnabledControl(button) ? button : null; + }, { + intervalMs: 250, + timeoutMs: 8000, + timeoutMessage: 'PayPal email page did not expose a clickable next/continue button.', + }); + simulateClick(nextButton); + return { + submitted: false, + phase: 'email_submitted', + awaiting: 'password_page', + }; } else if (!passwordInput && emailInput && !email) { throw new Error('PayPal 账号为空,请先在侧边栏配置。'); - } else if (emailInput && email && !String(emailInput.value || '').trim()) { + } else if (emailInput && email && normalizeText(emailInput.value || '') !== email) { fillInput(emailInput, email); } - passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { intervalMs: 250 }); + passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { + intervalMs: 250, + timeoutMs: 8000, + timeoutMessage: 'PayPal password page did not expose a password input.', + }); fillInput(passwordInput, password); await sleep(1000); @@ -214,10 +241,18 @@ async function submitPayPalLogin(payload = {}) { /登录|登入|继续/i, ]); return button && isEnabledControl(button) ? button : null; - }, { intervalMs: 250 }); + }, { + intervalMs: 250, + timeoutMs: 8000, + timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.', + }); simulateClick(loginButton); - return { submitted: true }; + return { + submitted: true, + phase: 'password_submitted', + awaiting: 'redirect_or_approval', + }; } async function dismissPayPalPrompts() { @@ -261,10 +296,12 @@ function inspectPayPalState() { const emailInput = findEmailInput(); const passwordInput = findPasswordInput(); const approveButton = findApproveButton(); + const loginPhase = getPayPalLoginPhase(emailInput, passwordInput); return { url: location.href, readyState: document.readyState, - needsLogin: Boolean(emailInput || passwordInput), + needsLogin: Boolean(loginPhase), + loginPhase, hasEmailInput: Boolean(emailInput), hasPasswordInput: Boolean(passwordInput), approveReady: Boolean(approveButton && isEnabledControl(approveButton)), diff --git a/tests/paypal-approve-detection.test.js b/tests/paypal-approve-detection.test.js new file mode 100644 index 0000000..3adf6a1 --- /dev/null +++ b/tests/paypal-approve-detection.test.js @@ -0,0 +1,146 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/steps/paypal-approve.js', 'utf8'); + +function loadModule() { + const self = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundPayPalApprove;`)(self); +} + +function createExecutor({ pageStates, submitResults, tabUrls = [] }) { + const api = loadModule(); + const events = { + completed: [], + logs: [], + messages: [], + submittedPayloads: [], + }; + const stateQueue = [...pageStates]; + const submitQueue = [...submitResults]; + const urlQueue = [...tabUrls]; + let lastUrl = urlQueue.shift() || 'https://www.paypal.com/signin'; + + const executor = api.createPayPalApproveExecutor({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + chrome: { + tabs: { + get: async () => { + if (urlQueue.length) { + lastUrl = urlQueue.shift(); + } + return { + id: 1, + status: 'complete', + url: lastUrl, + }; + }, + }, + }, + completeStepFromBackground: async (step, payload) => { + events.completed.push({ step, payload }); + }, + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + getTabId: async (source) => (source === 'paypal-flow' ? 1 : null), + isTabAlive: async () => true, + sendTabMessageUntilStopped: async (_tabId, _source, message) => { + events.messages.push(message.type); + if (message.type === 'PAYPAL_GET_STATE') { + return stateQueue.shift() || pageStates[pageStates.length - 1] || {}; + } + if (message.type === 'PAYPAL_SUBMIT_LOGIN') { + events.submittedPayloads.push(message.payload); + return submitQueue.shift() || { submitted: true, phase: 'password_submitted' }; + } + if (message.type === 'PAYPAL_DISMISS_PROMPTS') { + return { clicked: 0 }; + } + if (message.type === 'PAYPAL_CLICK_APPROVE') { + return { clicked: true }; + } + return {}; + }, + setState: async () => {}, + sleepWithStop: async () => {}, + waitForTabCompleteUntilStopped: async () => {}, + waitForTabUrlMatchUntilStopped: async () => {}, + }); + + return { executor, events }; +} + +test('PayPal approve keeps original combined email and password login path', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' }, + { needsLogin: false, approveReady: true }, + { needsLogin: false, approveReady: true }, + ], + submitResults: [ + { submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' }, + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.equal(events.submittedPayloads.length, 1); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); +}); + +test('PayPal approve auto-detects split email then password pages', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: true, hasEmailInput: true, hasPasswordInput: false, loginPhase: 'email' }, + { needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' }, + { needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' }, + { needsLogin: false, approveReady: true }, + { needsLogin: false, approveReady: true }, + ], + submitResults: [ + { submitted: false, phase: 'email_submitted', awaiting: 'password_page' }, + { submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' }, + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.equal(events.submittedPayloads.length, 2); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.logs.some(({ message }) => /识别到密码页/.test(message)), true); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); +}); + +test('PayPal approve finishes when login redirects away from PayPal', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' }, + ], + submitResults: [ + { submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' }, + ], + tabUrls: [ + 'https://www.paypal.com/signin', + 'https://www.paypal.com/signin', + 'https://checkout.openai.com/return', + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.equal(events.submittedPayloads.length, 1); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), false); +}); From 9a63cba994efc1b70840e3c0511afe714c8ee641 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 06:03:53 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20PayPal=20?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E9=A1=B5=E6=A3=80=E6=B5=8B=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E5=B7=B2=E6=89=93=E5=BC=80=E7=9A=84=20PayPal=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E7=9A=84=E5=85=83=E7=B4=A0=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/paypal-approve.js | 26 ++++ content/paypal-flow.js | 53 ++++++- tests/paypal-approve-detection.test.js | 55 ++++++- tests/paypal-flow-content.test.js | 205 +++++++++++++++++++++++++ 4 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 tests/paypal-flow-content.test.js diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index 3dee859..d69540d 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -27,6 +27,11 @@ if (paypalTabId && await isTabAlive(PAYPAL_SOURCE)) { return paypalTabId; } + const discoveredPayPalTabId = await findOpenPayPalTabId(); + if (discoveredPayPalTabId) { + await addLog('步骤 8:已从当前浏览器标签中发现 PayPal 页面,正在接管继续执行。', 'info'); + return discoveredPayPalTabId; + } const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE); if (checkoutTabId) { return checkoutTabId; @@ -38,6 +43,27 @@ throw new Error('步骤 8:未找到 PayPal 标签页,请先完成步骤 7。'); } + async function findOpenPayPalTabId() { + if (!chrome?.tabs?.query) { + return 0; + } + + const tabs = await chrome.tabs.query({}).catch(() => []); + const candidates = (Array.isArray(tabs) ? tabs : []) + .filter((tab) => Number.isInteger(tab?.id) && isPayPalUrl(tab.url || '')); + if (!candidates.length) { + return 0; + } + + const match = candidates.find((tab) => tab.active && tab.currentWindow) + || candidates.find((tab) => tab.active) + || candidates[0]; + if (match?.id && chrome?.tabs?.update) { + await chrome.tabs.update(match.id, { active: true }).catch(() => {}); + } + return match?.id || 0; + } + async function ensurePayPalReady(tabId, logMessage = '') { await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url)); await waitForTabCompleteUntilStopped(tabId); diff --git a/content/paypal-flow.js b/content/paypal-flow.js index 7cc4217..9f34e51 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -70,6 +70,22 @@ async function waitForDocumentComplete() { function isVisibleElement(el) { if (!el) return false; + let node = el; + while (node && node.nodeType === 1) { + if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) { + return false; + } + const nodeStyle = window.getComputedStyle(node); + if ( + nodeStyle.display === 'none' + || nodeStyle.visibility === 'hidden' + || nodeStyle.visibility === 'collapse' + || Number(nodeStyle.opacity) === 0 + ) { + return false; + } + node = node.parentElement; + } const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style.display !== 'none' @@ -117,7 +133,7 @@ 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 isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type); }); return inputs.find((input) => { const text = getActionText(input); @@ -144,6 +160,21 @@ function findLoginNextButton() { ]); } +function findEmailNextButton() { + return findClickableByText([ + /next|btn\s*next|btnnext/i, + /下一页|下一步/i, + ]); +} + +function findPasswordLoginButton() { + const button = findClickableByText([ + /login|log\s*in|sign\s*in/i, + /登录|登入/i, + ]); + return button && button !== findEmailNextButton() ? button : null; +} + function findApproveButton() { return findClickableByText([ /同意并继续|同意|继续|授权|确认并继续/i, @@ -185,6 +216,11 @@ function hasPasskeyPrompt() { } function getPayPalLoginPhase(emailInput, passwordInput) { + const emailNextButton = findEmailNextButton(); + const passwordLoginButton = findPasswordLoginButton(); + if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !passwordLoginButton)) { + return 'email'; + } if (emailInput && passwordInput) return 'login_combined'; if (passwordInput) return 'password'; if (emailInput) return 'email'; @@ -202,13 +238,26 @@ async function submitPayPalLogin(payload = {}) { let passwordInput = findPasswordInput(); const emailInput = findEmailInput(); + const emailNextButton = findEmailNextButton(); + + if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) { + if (normalizeText(emailInput.value || '') !== email) { + fillInput(emailInput, email); + } + simulateClick(emailNextButton); + return { + submitted: false, + phase: 'email_submitted', + awaiting: 'password_page', + }; + } if (!passwordInput && emailInput && email) { if (normalizeText(emailInput.value || '') !== email) { fillInput(emailInput, email); } const nextButton = await waitUntil(() => { - const button = findLoginNextButton(); + const button = findEmailNextButton() || findLoginNextButton(); return button && isEnabledControl(button) ? button : null; }, { intervalMs: 250, diff --git a/tests/paypal-approve-detection.test.js b/tests/paypal-approve-detection.test.js index 3adf6a1..14644e5 100644 --- a/tests/paypal-approve-detection.test.js +++ b/tests/paypal-approve-detection.test.js @@ -9,13 +9,21 @@ function loadModule() { return new Function('self', `${source}; return self.MultiPageBackgroundPayPalApprove;`)(self); } -function createExecutor({ pageStates, submitResults, tabUrls = [] }) { +function createExecutor({ + pageStates, + submitResults, + tabUrls = [], + getTabId = async (source) => (source === 'paypal-flow' ? 1 : null), + isTabAlive = async () => true, + queryTabs = [], +}) { const api = loadModule(); const events = { completed: [], logs: [], messages: [], submittedPayloads: [], + updatedTabs: [], }; const stateQueue = [...pageStates]; const submitQueue = [...submitResults]; @@ -28,24 +36,29 @@ function createExecutor({ pageStates, submitResults, tabUrls = [] }) { }, chrome: { tabs: { - get: async () => { + get: async (tabId = 1) => { if (urlQueue.length) { lastUrl = urlQueue.shift(); } return { - id: 1, + id: tabId, status: 'complete', url: lastUrl, }; }, + query: async () => queryTabs, + update: async (tabId, updateInfo) => { + events.updatedTabs.push({ tabId, updateInfo }); + return {}; + }, }, }, completeStepFromBackground: async (step, payload) => { events.completed.push({ step, payload }); }, ensureContentScriptReadyOnTabUntilStopped: async () => {}, - getTabId: async (source) => (source === 'paypal-flow' ? 1 : null), - isTabAlive: async () => true, + getTabId, + isTabAlive, sendTabMessageUntilStopped: async (_tabId, _source, message) => { events.messages.push(message.type); if (message.type === 'PAYPAL_GET_STATE') { @@ -94,6 +107,38 @@ test('PayPal approve keeps original combined email and password login path', asy assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); }); +test('PayPal approve discovers an already open unregistered PayPal tab', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: false, approveReady: true }, + ], + submitResults: [], + getTabId: async () => null, + isTabAlive: async () => false, + queryTabs: [ + { + id: 7, + active: true, + currentWindow: true, + url: 'https://www.paypal.com/pay/?token=BA-demo', + }, + ], + tabUrls: [ + 'https://www.paypal.com/pay/?token=BA-demo', + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.deepEqual(events.updatedTabs, [{ tabId: 7, updateInfo: { active: true } }]); + assert.equal(events.logs.some(({ message }) => /发现 PayPal 页面/.test(message)), true); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); +}); + test('PayPal approve auto-detects split email then password pages', async () => { const { executor, events } = createExecutor({ pageStates: [ diff --git a/tests/paypal-flow-content.test.js b/tests/paypal-flow-content.test.js new file mode 100644 index 0000000..d2f0ffd --- /dev/null +++ b/tests/paypal-flow-content.test.js @@ -0,0 +1,205 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/paypal-flow.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === '(') { + parenDepth += 1; + } else if (char === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (char === '{' && signatureEnded) { + braceStart = index; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const char = source[end]; + if (char === '{') depth += 1; + if (char === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function createElement({ + tag = 'div', + type = '', + id = '', + name = '', + text = '', + value = '', + placeholder = '', + attrs = {}, + style = {}, + rect = { width: 160, height: 40 }, + parentElement = null, +} = {}) { + return { + nodeType: 1, + tag, + type, + id, + name, + textContent: text, + value, + placeholder, + disabled: false, + hidden: Boolean(attrs.hidden), + style: { + display: 'block', + visibility: 'visible', + opacity: '1', + ...style, + }, + parentElement, + getAttribute(key) { + if (key === 'type') return type; + if (key === 'id') return id; + if (key === 'name') return name; + if (key === 'placeholder') return placeholder; + if (key === 'value') return value; + return Object.prototype.hasOwnProperty.call(attrs, key) ? attrs[key] : null; + }, + getBoundingClientRect() { + return rect; + }, + }; +} + +function loadApi(elements) { + const document = { + documentElement: {}, + querySelectorAll(selector) { + if (selector === 'input') { + return elements.filter((el) => el.tag === 'input'); + } + if (selector === 'input[type="email"]') { + return elements.filter((el) => el.tag === 'input' && el.type === 'email'); + } + if (selector === 'input[type="password"]') { + return elements.filter((el) => el.tag === 'input' && el.type === 'password'); + } + if (selector.includes('button') || selector.includes('[role="button"]')) { + return elements.filter((el) => el.tag === 'button' || el.attrs?.role === 'button'); + } + return []; + }, + }; + const window = { + getComputedStyle(el) { + return el?.style || { display: 'block', visibility: 'visible', opacity: '1' }; + }, + }; + + return new Function('document', 'window', ` +${extractFunction('isVisibleElement')} +${extractFunction('normalizeText')} +${extractFunction('getActionText')} +${extractFunction('getVisibleControls')} +${extractFunction('isEnabledControl')} +${extractFunction('findClickableByText')} +${extractFunction('findInputByPatterns')} +${extractFunction('findEmailInput')} +${extractFunction('findPasswordInput')} +${extractFunction('findLoginNextButton')} +${extractFunction('findEmailNextButton')} +${extractFunction('findPasswordLoginButton')} +${extractFunction('getPayPalLoginPhase')} +return { + findEmailInput, + findPasswordInput, + findEmailNextButton, + findPasswordLoginButton, + getPayPalLoginPhase, +}; +`)(document, window); +} + +test('PayPal email page ignores hidden pre-rendered password input', () => { + const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } }); + const emailInput = createElement({ + tag: 'input', + type: 'text', + id: 'login_email', + name: 'login_email', + value: 'user@example.com', + placeholder: 'Email', + }); + const hiddenPasswordInput = createElement({ + tag: 'input', + type: 'password', + id: 'login_password', + name: 'login_password', + parentElement: hiddenPanel, + }); + const nextButton = createElement({ + tag: 'button', + id: 'btnNext', + text: 'Next', + }); + + const api = loadApi([emailInput, hiddenPasswordInput, nextButton]); + + assert.equal(api.findEmailInput(), emailInput); + assert.equal(api.findPasswordInput(), null); + assert.equal(api.findEmailNextButton(), nextButton); + assert.equal(api.findPasswordLoginButton(), null); + assert.equal(api.getPayPalLoginPhase(emailInput, api.findPasswordInput()), 'email'); +}); + +test('PayPal combined login page still sees visible password input', () => { + const emailInput = createElement({ + tag: 'input', + type: 'text', + id: 'login_email', + name: 'login_email', + }); + const passwordInput = createElement({ + tag: 'input', + type: 'password', + id: 'login_password', + name: 'login_password', + }); + const loginButton = createElement({ + tag: 'button', + id: 'btnLogin', + text: 'Log In', + }); + + const api = loadApi([emailInput, passwordInput, loginButton]); + + assert.equal(api.findEmailInput(), emailInput); + assert.equal(api.findPasswordInput(), passwordInput); + assert.equal(api.findPasswordLoginButton(), loginButton); + assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined'); +}); From 6329407b95e5097db5f14e8678518e9797ae3edb Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 06:25:11 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Plus=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=9A=84=E8=87=AA=E5=8A=A8=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=A3=8E=E9=99=A9=E6=8F=90=E7=A4=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=B6=85=E8=BF=87=203=20=E8=BD=AE?= =?UTF-8?q?=E7=9A=84=E8=AD=A6=E5=91=8A=E5=92=8C=E7=A1=AE=E8=AE=A4=E6=A8=A1?= =?UTF-8?q?=E6=80=81=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sidepanel/sidepanel.js | 42 ++++++++++++ ...sidepanel-auto-run-content-refresh.test.js | 65 ++++++++++++++++++- tests/sidepanel-auto-run-risk-warning.test.js | 42 ++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 156bd46..9c36e1f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -276,6 +276,7 @@ const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed'; const AUTO_SKIP_FAILURES_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-skip-failures-prompt-dismissed'; const AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-fallback-risk-prompt-dismissed'; +const AUTO_RUN_PLUS_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-plus-risk-prompt-dismissed'; function getStepDefinitionsForMode(plusModeEnabled = false) { return (window.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled }) || []) @@ -296,6 +297,7 @@ function rebuildStepDefinitionState(plusModeEnabled = false) { } const CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version'; const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3; +const AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS = 3; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; const ICLOUD_PROVIDER = 'icloud'; @@ -1020,10 +1022,23 @@ function setAutoRunFallbackRiskPromptDismissed(dismissed) { setPromptDismissed(AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY, dismissed); } +function isAutoRunPlusRiskPromptDismissed() { + return isPromptDismissed(AUTO_RUN_PLUS_RISK_PROMPT_DISMISSED_STORAGE_KEY); +} + +function setAutoRunPlusRiskPromptDismissed(dismissed) { + setPromptDismissed(AUTO_RUN_PLUS_RISK_PROMPT_DISMISSED_STORAGE_KEY, dismissed); +} + function shouldWarnAutoRunFallbackRisk(totalRuns, autoRunSkipFailures) { return totalRuns >= AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS; } +function shouldWarnPlusAutoRunRisk(totalRuns, plusModeEnabled) { + return Boolean(plusModeEnabled) + && Math.floor(Number(totalRuns) || 0) > AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS; +} + async function openAutoSkipFailuresConfirmModal() { const result = await openConfirmModalWithOption({ title: '自动重试说明', @@ -1050,6 +1065,19 @@ async function openAutoRunFallbackRiskConfirmModal(totalRuns) { }; } +async function openPlusAutoRunRiskConfirmModal(totalRuns) { + const result = await openConfirmModalWithOption({ + title: 'Plus 自动轮数提醒', + message: `Plus 模式下当前设置为 ${totalRuns} 轮。轮数过多可能造成 PayPal 或账号快速封号。建议够用就好:我注册了几个使用,没多注册,完全足够使用,并且没有封号。这个模式下只要可以注册成功就能使用,所以不要贪杯哦。`, + confirmLabel: '我知道了,继续', + }); + + return { + confirmed: result.confirmed, + dismissPrompt: result.optionChecked, + }; +} + function updateConfigMenuControls() { const disabled = configActionInFlight || settingsSaveInFlight; const contributionModeEnabled = Boolean(latestState?.contributionMode); @@ -4519,6 +4547,20 @@ async function startAutoRunFromCurrentSettings() { } } + const plusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(currentPlusModeEnabled || latestState?.plusModeEnabled); + if (shouldWarnPlusAutoRunRisk(totalRuns, plusModeEnabled) + && !isAutoRunPlusRiskPromptDismissed()) { + const result = await openPlusAutoRunRiskConfirmModal(totalRuns); + if (!result.confirmed) { + return false; + } + if (result.dismissPrompt) { + setAutoRunPlusRiskPromptDismissed(true); + } + } + btnAutoRun.disabled = true; inputRunCount.disabled = true; const delayEnabled = inputAutoDelayEnabled.checked; diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index c91cb51..dc6ed2f 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -48,12 +48,23 @@ function extractFunction(name) { return sidepanelSource.slice(start, end); } -function createApi({ refreshImpl, confirmImpl, dismissImpl } = {}) { +function createApi({ + refreshImpl, + confirmImpl, + dismissImpl, + runCount = 3, + plusModeEnabled = false, + plusRiskEnabled = false, + plusRiskConfirmed = true, + plusRiskDismissPrompt = false, +} = {}) { const bundle = extractFunction('startAutoRunFromCurrentSettings'); return new Function(` const events = []; const latestState = { contributionMode: false }; +const currentPlusModeEnabled = ${JSON.stringify(Boolean(plusModeEnabled))}; +const inputPlusModeEnabled = { checked: ${JSON.stringify(Boolean(plusModeEnabled))} }; const inputAutoSkipFailures = { checked: false }; const inputContributionNickname = { value: 'tester' }; const inputContributionQq = { value: '123456' }; @@ -75,7 +86,7 @@ const console = { events.push({ type: 'warn', args }); }, }; -function getRunCountValue() { return 3; } +function getRunCountValue() { return ${Math.max(1, Number(runCount) || 1)}; } function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; } function shouldOfferAutoModeChoice() { return false; } async function openAutoStartChoiceDialog() { throw new Error('should not be called'); } @@ -85,6 +96,20 @@ function shouldWarnAutoRunFallbackRisk() { return false; } function isAutoRunFallbackRiskPromptDismissed() { return false; } async function openAutoRunFallbackRiskConfirmModal() { throw new Error('should not be called'); } function setAutoRunFallbackRiskPromptDismissed() {} +function shouldWarnPlusAutoRunRisk(totalRuns, plusModeEnabled) { + return ${JSON.stringify(Boolean(plusRiskEnabled))} && Boolean(plusModeEnabled) && Number(totalRuns) > 3; +} +function isAutoRunPlusRiskPromptDismissed() { return false; } +async function openPlusAutoRunRiskConfirmModal(totalRuns) { + events.push({ type: 'plus-risk-modal', totalRuns }); + return { + confirmed: ${JSON.stringify(Boolean(plusRiskConfirmed))}, + dismissPrompt: ${JSON.stringify(Boolean(plusRiskDismissPrompt))}, + }; +} +function setAutoRunPlusRiskPromptDismissed(dismissed) { + events.push({ type: 'plus-risk-dismiss', dismissed }); +} function normalizeAutoDelayMinutes(value) { return Number(value) || 30; } async function refreshContributionContentHint() { events.push({ type: 'refresh' }); @@ -155,3 +180,39 @@ test('startAutoRunFromCurrentSettings aborts when contribution update confirmati ['refresh', 'confirm-check'] ); }); + +test('startAutoRunFromCurrentSettings shows Plus risk warning before starting more than 3 runs', async () => { + const api = createApi({ + runCount: 4, + plusModeEnabled: true, + plusRiskEnabled: true, + }); + + const result = await api.startAutoRunFromCurrentSettings(); + const events = api.getEvents(); + + assert.equal(result, true); + assert.deepEqual( + events.map((entry) => entry.type), + ['refresh', 'confirm-check', 'plus-risk-modal', 'send'] + ); + assert.equal(events[2].totalRuns, 4); + assert.equal(events[3].message.payload.totalRuns, 4); +}); + +test('startAutoRunFromCurrentSettings aborts when Plus risk warning is declined', async () => { + const api = createApi({ + runCount: 4, + plusModeEnabled: true, + plusRiskEnabled: true, + plusRiskConfirmed: false, + }); + + const result = await api.startAutoRunFromCurrentSettings(); + + assert.equal(result, false); + assert.deepEqual( + api.getEvents().map((entry) => entry.type), + ['refresh', 'confirm-check', 'plus-risk-modal'] + ); +}); diff --git a/tests/sidepanel-auto-run-risk-warning.test.js b/tests/sidepanel-auto-run-risk-warning.test.js index 5a7ba4a..aa059d3 100644 --- a/tests/sidepanel-auto-run-risk-warning.test.js +++ b/tests/sidepanel-auto-run-risk-warning.test.js @@ -65,6 +65,48 @@ return { shouldWarnAutoRunFallbackRisk }; assert.equal(api.shouldWarnAutoRunFallbackRisk(10, true), true); }); +test('Plus auto-run risk warning only starts above 3 runs in Plus mode', () => { + const bundle = extractFunction('shouldWarnPlusAutoRunRisk'); + + const api = new Function(` +const AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS = 3; +${bundle} +return { shouldWarnPlusAutoRunRisk }; +`)(); + + assert.equal(api.shouldWarnPlusAutoRunRisk(3, true), false); + assert.equal(api.shouldWarnPlusAutoRunRisk(4, true), true); + assert.equal(api.shouldWarnPlusAutoRunRisk(10, false), false); +}); + +test('Plus auto-run risk modal uses PayPal account warning copy', async () => { + const bundle = extractFunction('openPlusAutoRunRiskConfirmModal'); + + const api = new Function(` +let capturedOptions = null; +async function openConfirmModalWithOption(options) { + capturedOptions = options; + return { confirmed: true, optionChecked: true }; +} +${bundle} +return { + openPlusAutoRunRiskConfirmModal, + getCapturedOptions() { + return capturedOptions; + }, +}; +`)(); + + const result = await api.openPlusAutoRunRiskConfirmModal(4); + const options = api.getCapturedOptions(); + + assert.deepStrictEqual(result, { confirmed: true, dismissPrompt: true }); + assert.equal(options.title, 'Plus 自动轮数提醒'); + assert.match(options.message, /轮数过多可能造成 PayPal 或账号快速封号/); + assert.match(options.message, /不要贪杯哦/); + assert.equal(options.confirmLabel, '我知道了,继续'); +}); + test('auto-run fallback risk modal uses the single-node warning copy', async () => { const bundle = extractFunction('openAutoRunFallbackRiskConfirmModal');