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 @@
+
邮箱服务
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. 在右上角或对应位置找到并双击打开 `全局扩展脚本`。

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');