Merge branch 'dev' into icloud-forward-mailbox-config
This commit is contained in:
+561
-105
File diff suppressed because it is too large
Load Diff
@@ -180,6 +180,8 @@
|
||||
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null,
|
||||
source,
|
||||
autoRunContext: source === 'auto' ? autoRunContext : null,
|
||||
plusModeEnabled: Boolean(record.plusModeEnabled),
|
||||
contributionMode: Boolean(record.contributionMode),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -242,6 +244,8 @@
|
||||
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null,
|
||||
source,
|
||||
autoRunContext,
|
||||
plusModeEnabled: Boolean(state.plusModeEnabled),
|
||||
contributionMode: Boolean(state.contributionMode),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() {
|
||||
const API_BASE_URL = 'https://apikey.qzz.io/oauth/api';
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'expired', 'error']);
|
||||
const CALLBACK_FINAL_STATUSES = new Set(['submitted']);
|
||||
const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']);
|
||||
const CONTRIBUTION_SOURCE_CPA = 'cpa';
|
||||
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
|
||||
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
|
||||
const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus';
|
||||
|
||||
const RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSource: CONTRIBUTION_SOURCE_SUB2API,
|
||||
contributionTargetGroupName: CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -64,9 +70,6 @@
|
||||
case 'auto_rejected':
|
||||
case 'rejected':
|
||||
return 'auto_rejected';
|
||||
case 'manual_review_required':
|
||||
case 'manual_review':
|
||||
return 'manual_review_required';
|
||||
case 'expired':
|
||||
case 'timeout':
|
||||
return 'expired';
|
||||
@@ -107,6 +110,40 @@
|
||||
return FINAL_STATUSES.has(normalizeContributionStatus(status));
|
||||
}
|
||||
|
||||
function normalizeContributionSource(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
return normalized === CONTRIBUTION_SOURCE_SUB2API
|
||||
? CONTRIBUTION_SOURCE_SUB2API
|
||||
: CONTRIBUTION_SOURCE_CPA;
|
||||
}
|
||||
|
||||
function resolveContributionRouting(state = {}) {
|
||||
const currentStatus = normalizeContributionStatus(state.contributionStatus);
|
||||
const currentSource = normalizeContributionSource(state.contributionSource);
|
||||
const hasActiveSession = Boolean(
|
||||
normalizeString(state.contributionSessionId)
|
||||
&& currentStatus
|
||||
&& !FINAL_STATUSES.has(currentStatus)
|
||||
);
|
||||
|
||||
if (hasActiveSession) {
|
||||
return {
|
||||
source: currentSource,
|
||||
targetGroupName: currentSource === CONTRIBUTION_SOURCE_SUB2API
|
||||
? (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME)
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
const source = CONTRIBUTION_SOURCE_SUB2API;
|
||||
return {
|
||||
source,
|
||||
targetGroupName: Boolean(state.plusModeEnabled)
|
||||
? CONTRIBUTION_SUB2API_PLUS_GROUP_NAME
|
||||
: (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusLabel(status = '') {
|
||||
switch (normalizeContributionStatus(status)) {
|
||||
case 'started':
|
||||
@@ -114,13 +151,11 @@
|
||||
case 'waiting':
|
||||
return '等待提交回调';
|
||||
case 'processing':
|
||||
return '已提交回调,等待 CPA 确认';
|
||||
return '已提交回调,等待服务端确认';
|
||||
case 'auto_approved':
|
||||
return '贡献成功,CPA 已确认';
|
||||
return '贡献成功,服务端已确认';
|
||||
case 'auto_rejected':
|
||||
return '贡献未通过确认';
|
||||
case 'manual_review_required':
|
||||
return '已提交,等待人工处理';
|
||||
case 'expired':
|
||||
return '贡献会话已超时';
|
||||
case 'error':
|
||||
@@ -536,6 +571,16 @@
|
||||
const callbackState = deriveCallbackState(mergedPayload, currentState);
|
||||
const updates = {
|
||||
contributionLastPollAt: Date.now(),
|
||||
contributionSource: normalizeContributionSource(
|
||||
mergedPayload.source
|
||||
|| mergedPayload.source_kind
|
||||
|| currentState.contributionSource
|
||||
),
|
||||
contributionTargetGroupName: normalizeString(
|
||||
mergedPayload.target_group_name
|
||||
|| mergedPayload.group_name
|
||||
|| currentState.contributionTargetGroupName
|
||||
),
|
||||
contributionStatus: normalizedStatus,
|
||||
contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload),
|
||||
contributionCallbackUrl: callbackState.callbackUrl,
|
||||
@@ -577,6 +622,7 @@
|
||||
async function startContributionFlow(options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const shouldOpenAuthTab = options.openAuthTab !== false;
|
||||
const routing = resolveContributionRouting(currentState);
|
||||
if (!currentState.contributionMode) {
|
||||
throw new Error('请先进入贡献模式。');
|
||||
}
|
||||
@@ -600,7 +646,8 @@
|
||||
nickname: buildNickname(currentState, options.nickname),
|
||||
qq: buildContributionQq(currentState, options.qq),
|
||||
email: normalizeString(currentState.email),
|
||||
source: 'cpa',
|
||||
source: routing.source,
|
||||
target_group_name: routing.targetGroupName,
|
||||
channel: 'codex-extension',
|
||||
},
|
||||
});
|
||||
@@ -613,6 +660,12 @@
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionSource: normalizeContributionSource(payload.source || routing.source),
|
||||
contributionTargetGroupName: normalizeString(
|
||||
payload.target_group_name
|
||||
|| payload.group_name
|
||||
|| routing.targetGroupName
|
||||
),
|
||||
contributionSessionId: sessionId,
|
||||
contributionAuthUrl: authUrl,
|
||||
contributionAuthState: authState,
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
}
|
||||
|
||||
function getLoginAuthStateLabel(state) {
|
||||
state = state === 'oauth_consent_page' ? 'unknown' : state;
|
||||
switch (state) {
|
||||
case 'verification_page':
|
||||
return '登录验证码页';
|
||||
|
||||
+150
-63
@@ -40,6 +40,9 @@
|
||||
getPendingAutoRunTimerPlan,
|
||||
getSourceLabel,
|
||||
getState,
|
||||
getStepDefinitionForState,
|
||||
getStepIdsForState,
|
||||
getLastStepIdForState,
|
||||
getTabId,
|
||||
getStopRequested,
|
||||
handleAutoRunLoopUnhandledError,
|
||||
@@ -127,7 +130,122 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getStepKeyForState(step, state = {}) {
|
||||
if (typeof getStepDefinitionForState === 'function') {
|
||||
return String(getStepDefinitionForState(step, state)?.key || '').trim();
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.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;
|
||||
}
|
||||
|
||||
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 +299,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 +313,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 +363,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);
|
||||
@@ -456,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);
|
||||
@@ -561,13 +626,35 @@
|
||||
}
|
||||
|
||||
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 (Boolean(currentState?.contributionMode) && typeof setContributionMode === 'function') {
|
||||
await setContributionMode(true);
|
||||
}
|
||||
if (modeChanged) {
|
||||
await addLog(
|
||||
Boolean(updates.plusModeEnabled)
|
||||
? 'Plus 模式已开启,已切换为 Plus Checkout + PayPal 步骤。'
|
||||
: 'Plus 模式已关闭,已恢复普通注册授权步骤。',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
return { ok: true, state: await getState() };
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,27 @@
|
||||
setStep8TabUpdatedListener,
|
||||
} = deps;
|
||||
|
||||
function getVisibleStep(state, fallback = 9) {
|
||||
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
|
||||
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) {
|
||||
throw new Error('缺少登录用 OAuth 链接,请先完成步骤 7。');
|
||||
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
|
||||
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 +82,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 +92,7 @@
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectStep9(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 9 的点击可能被拦截了。'));
|
||||
rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`));
|
||||
}, callbackTimeoutMs);
|
||||
|
||||
setStep8PendingReject((error) => {
|
||||
@@ -111,10 +122,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 +135,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 +148,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 +160,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 +178,7 @@
|
||||
} else {
|
||||
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: 9,
|
||||
step: visibleStep,
|
||||
actionLabel: '点击 OAuth 同意页继续按钮',
|
||||
})
|
||||
: 15000;
|
||||
@@ -186,7 +197,7 @@
|
||||
pageState.url,
|
||||
typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: 9,
|
||||
step: visibleStep,
|
||||
actionLabel: '等待 OAuth 同意页点击生效',
|
||||
})
|
||||
: 15000
|
||||
@@ -196,20 +207,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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
(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 addLog('步骤 6:Plus Checkout 页面已就绪,准备继续下一步。', 'info');
|
||||
|
||||
await completeStepFromBackground(6, {
|
||||
plusCheckoutCountry: result.country || 'DE',
|
||||
plusCheckoutCurrency: result.currency || 'EUR',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
executePlusCheckoutCreate,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createPlusCheckoutCreateExecutor,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
(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'];
|
||||
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 {
|
||||
addLog,
|
||||
chrome,
|
||||
completeStepFromBackground,
|
||||
ensureContentScriptReadyOnTabUntilStopped,
|
||||
fetch: fetchImpl = null,
|
||||
generateRandomName,
|
||||
getAddressSeedForCountry,
|
||||
getTabId,
|
||||
isTabAlive,
|
||||
setState,
|
||||
sleepWithStop,
|
||||
waitForTabCompleteUntilStopped,
|
||||
waitForTabUrlMatchUntilStopped,
|
||||
} = deps;
|
||||
|
||||
function isPlusCheckoutUrl(url = '') {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
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: { ...frame, ready: true }, result: result || {} };
|
||||
} catch (error) {
|
||||
const readyError = frame.ready === false ? 'content-script-not-ready' : '';
|
||||
const message = error?.message || String(error || '');
|
||||
return { frame, error: readyError ? `${readyError}: ${message}` : message };
|
||||
}
|
||||
}
|
||||
|
||||
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 || '',
|
||||
countryText: picked.result?.countryText || '',
|
||||
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)) {
|
||||
const aliveRegisteredTabId = await getAlivePlusCheckoutTabId(registeredTabId);
|
||||
if (aliveRegisteredTabId) {
|
||||
return aliveRegisteredTabId;
|
||||
}
|
||||
}
|
||||
const storedTabId = Number(state.plusCheckoutTabId) || 0;
|
||||
if (storedTabId) {
|
||||
const aliveStoredTabId = await getAlivePlusCheckoutTabId(storedTabId);
|
||||
if (aliveStoredTabId) {
|
||||
return aliveStoredTabId;
|
||||
}
|
||||
}
|
||||
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 = {}) {
|
||||
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 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(' ');
|
||||
|
||||
await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info');
|
||||
const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, {
|
||||
type: 'PLUS_CHECKOUT_SELECT_PAYPAL',
|
||||
source: 'background',
|
||||
payload: {},
|
||||
});
|
||||
if (paymentResult?.error) {
|
||||
throw new Error(paymentResult.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');
|
||||
}
|
||||
|
||||
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 (!addressSeed.skipAutocomplete && 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({
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -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。');
|
||||
}
|
||||
@@ -54,22 +60,22 @@
|
||||
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: 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,9 +105,16 @@
|
||||
}
|
||||
|
||||
if (isStep6SuccessResult(result)) {
|
||||
await completeStepFromBackground(7, {
|
||||
const completionPayload = {
|
||||
loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
|
||||
});
|
||||
};
|
||||
if (result.skipLoginVerificationStep) {
|
||||
completionPayload.skipLoginVerificationStep = true;
|
||||
}
|
||||
if (result.directOAuthConsentPage) {
|
||||
completionPayload.directOAuthConsentPage = true;
|
||||
}
|
||||
await completeStepFromBackground(visibleStep, completionPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,7 +132,7 @@
|
||||
}
|
||||
if (isManagementSecretConfigError(err)) {
|
||||
await addLog(
|
||||
`步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
|
||||
`步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
|
||||
'error'
|
||||
);
|
||||
throw err;
|
||||
@@ -128,11 +142,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 };
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
(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'];
|
||||
const PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS = 30000;
|
||||
const PAYPAL_LOGIN_TRANSITION_POLL_MS = 500;
|
||||
|
||||
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 discoveredPayPalTabId = await findOpenPayPalTabId();
|
||||
if (discoveredPayPalTabId) {
|
||||
await addLog('步骤 8:已从当前浏览器标签中发现 PayPal 页面,正在接管继续执行。', 'info');
|
||||
return discoveredPayPalTabId;
|
||||
}
|
||||
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 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);
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
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 && !isPayPalUrl(currentUrl)) {
|
||||
await addLog('步骤 8:PayPal 已跳转离开授权页,准备进入回跳确认。', 'ok');
|
||||
break;
|
||||
}
|
||||
|
||||
await ensurePayPalReady(tabId, '步骤 8:PayPal 页面正在切换,等待脚本重新就绪...');
|
||||
const pageState = await getPayPalState(tabId);
|
||||
|
||||
if (pageState.needsLogin) {
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -26,18 +26,31 @@
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function parseLocalhostCallback(rawUrl) {
|
||||
function getVisibleStep(state, fallback = 10) {
|
||||
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
|
||||
return visibleStep > 0 ? visibleStep : fallback;
|
||||
}
|
||||
|
||||
function getConfirmStepForVisibleStep(visibleStep) {
|
||||
return visibleStep >= 13 ? 12 : 9;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -109,26 +122,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 +164,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,29 +186,31 @@
|
||||
}
|
||||
|
||||
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 会话信息,请重新执行步骤 ${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);
|
||||
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 +222,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 +251,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 +273,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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
// 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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDocumentComplete() {
|
||||
await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 });
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
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'
|
||||
&& 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 isEnabledControl(input) && !['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 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,
|
||||
/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;
|
||||
}
|
||||
|
||||
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';
|
||||
return '';
|
||||
}
|
||||
|
||||
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();
|
||||
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 = findEmailNextButton() || 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 && normalizeText(emailInput.value || '') !== email) {
|
||||
fillInput(emailInput, email);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
timeoutMs: 8000,
|
||||
timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.',
|
||||
});
|
||||
|
||||
simulateClick(loginButton);
|
||||
return {
|
||||
submitted: true,
|
||||
phase: 'password_submitted',
|
||||
awaiting: 'redirect_or_approval',
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
const loginPhase = getPayPalLoginPhase(emailInput, passwordInput);
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
needsLogin: Boolean(loginPhase),
|
||||
loginPhase,
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
const PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS = 5000;
|
||||
|
||||
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_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();
|
||||
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_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:
|
||||
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 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 = [];
|
||||
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 getCombinedSearchText(el) {
|
||||
return normalizeText([
|
||||
getSearchText(el),
|
||||
getFieldText(el),
|
||||
].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;
|
||||
}
|
||||
|
||||
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
|
||||
|| 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 登录会话...');
|
||||
|
||||
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() {
|
||||
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);
|
||||
log('Plus Checkout:已点击 PayPal 付款方式,正在确认选中状态。');
|
||||
|
||||
if (!await waitForPayPalPaymentMethodActive()) {
|
||||
const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error');
|
||||
throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`);
|
||||
}
|
||||
|
||||
log('Plus Checkout:已确认 PayPal 付款方式生效。');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function selectPlusPayPalPaymentMethod() {
|
||||
await waitForDocumentComplete();
|
||||
await selectPayPalPaymentMethod();
|
||||
return {
|
||||
paymentSelected: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 (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;
|
||||
}
|
||||
if (/address|street|billing|search|line\s*1|地址|街道|账单/i.test(text)) {
|
||||
return true;
|
||||
}
|
||||
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() {
|
||||
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 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 && !isNonAddressSearchInput(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) {
|
||||
await fillAddressQuery(seed);
|
||||
return clickAddressSuggestion(seed);
|
||||
}
|
||||
|
||||
async function clickAddressSuggestion(seed = {}) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function fillAddressQuery(seed = {}) {
|
||||
const addressInput = await findAddressSearchInput();
|
||||
fillInput(addressInput, seed.query || 'Berlin Mitte');
|
||||
await sleep(800);
|
||||
return {
|
||||
filled: true,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
/地址\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);
|
||||
await selectRegionDropdown(findRegionDropdown(), 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: getRegionDropdownValue(findRegionDropdown()) || 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();
|
||||
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();
|
||||
const seed = payload.addressSeed || {
|
||||
query: 'Berlin Mitte',
|
||||
suggestionIndex: 1,
|
||||
fallback: {
|
||||
address1: 'Unter den Linden',
|
||||
city: 'Berlin',
|
||||
region: 'Berlin',
|
||||
postalCode: '10117',
|
||||
},
|
||||
};
|
||||
let selected = { selectedText: '' };
|
||||
if (!seed.skipAutocomplete) {
|
||||
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;
|
||||
}, {
|
||||
label: '订阅按钮',
|
||||
intervalMs: 250,
|
||||
});
|
||||
|
||||
simulateClick(subscribeButton);
|
||||
return {
|
||||
clicked: true,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPlusCheckoutState() {
|
||||
const structuredAddress = getStructuredAddressFields();
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
countryText: readCountryText(),
|
||||
hasPayPal: Boolean(findPayPalPaymentMethodTarget()),
|
||||
paypalCandidates: getPayPalCandidateSummaries(),
|
||||
paymentTextPreview: getPaymentTextPreview(),
|
||||
cardFieldsVisible: hasCreditCardFields(),
|
||||
billingFieldsVisible: hasBillingAddressFields(),
|
||||
hasSubscribeButton: Boolean(findSubscribeButton()),
|
||||
addressFieldValues: {
|
||||
address1: structuredAddress.address1?.value || '',
|
||||
city: structuredAddress.city?.value || '',
|
||||
region: getRegionDropdownValue(findRegionDropdown()) || structuredAddress.region?.value || '',
|
||||
postalCode: structuredAddress.postalCode?.value || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
+297
-263
@@ -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 });
|
||||
});
|
||||
@@ -113,6 +114,9 @@ const VERIFICATION_CODE_INPUT_SELECTOR = [
|
||||
].join(', ');
|
||||
|
||||
const ONE_TIME_CODE_LOGIN_PATTERN = /使用一次性验证码登录|改用(?:一次性)?验证码(?:登录)?|使用验证码登录|一次性验证码|验证码登录|one[-\s]*time\s*(?:passcode|password|code)|use\s+(?:a\s+)?one[-\s]*time\s*(?:passcode|password|code)(?:\s+instead)?|use\s+(?:a\s+)?code(?:\s+instead)?|sign\s+in\s+with\s+(?:email|code)|email\s+(?:me\s+)?(?:a\s+)?code/i;
|
||||
const LOGIN_ENTRY_ACTION_PATTERN = /(?:^|\b)(?:log\s*in|sign\s*in|continue\s+(?:with|using)\s+(?:email|chatgpt)|use\s+(?:an?\s+)?email|email\s+address)(?:\b|$)|登录|登陆|邮箱|电子邮件/i;
|
||||
const LOGIN_EXTERNAL_IDP_PATTERN = /google|microsoft|apple|sso|single\s+sign[-\s]*on|企业|工作区|workspace/i;
|
||||
const LOGIN_CODE_ONLY_ACTION_PATTERN = /one[-\s]*time|passcode|use\s+(?:a\s+)?code|验证码|一次性/i;
|
||||
|
||||
const RESEND_VERIFICATION_CODE_PATTERN = /重新发送(?:验证码)?|再次发送(?:验证码)?|重发(?:验证码)?|未收到(?:验证码|邮件)|resend(?:\s+code)?|send\s+(?:a\s+)?new\s+code|send\s+(?:it\s+)?again|request\s+(?:a\s+)?new\s+code|didn'?t\s+receive/i;
|
||||
|
||||
@@ -1718,12 +1722,32 @@ function getLoginSubmitButton({ allowDisabled = false } = {}) {
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findLoginEntryTrigger() {
|
||||
const candidates = Array.from(document.querySelectorAll(
|
||||
'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]'
|
||||
)).filter((el) => isVisibleElement(el) && isActionEnabled(el));
|
||||
|
||||
const preferred = candidates.find((el) => {
|
||||
const text = getActionText(el);
|
||||
if (!text || LOGIN_CODE_ONLY_ACTION_PATTERN.test(text) || LOGIN_EXTERNAL_IDP_PATTERN.test(text)) return false;
|
||||
return /continue\s+(?:with|using)\s+email|use\s+(?:an?\s+)?email|email\s+address|邮箱|电子邮件/i.test(text);
|
||||
});
|
||||
if (preferred) return preferred;
|
||||
|
||||
return candidates.find((el) => {
|
||||
const text = getActionText(el);
|
||||
if (!text || LOGIN_CODE_ONLY_ACTION_PATTERN.test(text) || LOGIN_EXTERNAL_IDP_PATTERN.test(text)) return false;
|
||||
return LOGIN_ENTRY_ACTION_PATTERN.test(text);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
const retryState = getLoginTimeoutErrorPageState();
|
||||
const verificationTarget = getVerificationCodeTarget();
|
||||
const passwordInput = getLoginPasswordInput();
|
||||
const emailInput = getLoginEmailInput();
|
||||
const switchTrigger = findOneTimeCodeLoginTrigger();
|
||||
const loginEntryTrigger = findLoginEntryTrigger();
|
||||
const submitButton = getLoginSubmitButton({ allowDisabled: true });
|
||||
const verificationVisible = isVerificationPageStillVisible();
|
||||
const addPhonePage = isAddPhonePageReady();
|
||||
@@ -1745,6 +1769,7 @@ function inspectLoginAuthState() {
|
||||
emailInput,
|
||||
submitButton,
|
||||
switchTrigger,
|
||||
loginEntryTrigger,
|
||||
verificationVisible,
|
||||
addPhonePage,
|
||||
phoneVerificationPage,
|
||||
@@ -1802,6 +1827,20 @@ function inspectLoginAuthState() {
|
||||
};
|
||||
}
|
||||
|
||||
if (consentReady) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'oauth_consent_page',
|
||||
};
|
||||
}
|
||||
|
||||
if (loginEntryTrigger) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'entry_page',
|
||||
};
|
||||
}
|
||||
|
||||
return baseState;
|
||||
}
|
||||
|
||||
@@ -1820,6 +1859,7 @@ function serializeLoginAuthState(snapshot) {
|
||||
hasEmailInput: Boolean(snapshot?.emailInput),
|
||||
hasSubmitButton: Boolean(snapshot?.submitButton),
|
||||
hasSwitchTrigger: Boolean(snapshot?.switchTrigger),
|
||||
hasLoginEntryTrigger: Boolean(snapshot?.loginEntryTrigger),
|
||||
verificationVisible: Boolean(snapshot?.verificationVisible),
|
||||
addPhonePage: Boolean(snapshot?.addPhonePage),
|
||||
phoneVerificationPage: Boolean(snapshot?.phoneVerificationPage),
|
||||
@@ -1829,7 +1869,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 '登录验证码页';
|
||||
@@ -1841,6 +1881,8 @@ function getLoginAuthStateLabel(snapshot) {
|
||||
return '登录超时报错页';
|
||||
case 'oauth_consent_page':
|
||||
return 'OAuth 授权页';
|
||||
case 'entry_page':
|
||||
return '登录入口页';
|
||||
case 'add_phone_page':
|
||||
return '手机号页';
|
||||
default:
|
||||
@@ -1886,13 +1928,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 = {}) {
|
||||
@@ -1947,6 +2008,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 };
|
||||
@@ -2006,6 +2076,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(
|
||||
@@ -2036,6 +2113,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(
|
||||
@@ -2058,21 +2141,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':
|
||||
@@ -2594,88 +2670,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') {
|
||||
@@ -2695,212 +2738,180 @@ 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 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}`,
|
||||
});
|
||||
|
||||
if (transition.action === 'done' || transition.action === 'recoverable') {
|
||||
return transition.result;
|
||||
}
|
||||
return transition;
|
||||
}
|
||||
|
||||
async function waitForLoginEntryOpenTransition(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 createStep6SuccessResult(snapshot, {
|
||||
via: 'switch_to_one_time_code_login',
|
||||
loginVerificationRequestedAt,
|
||||
});
|
||||
if (snapshot.state !== 'unknown' && snapshot.state !== 'entry_page') {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
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,
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function step6OpenLoginEntry(payload, snapshot) {
|
||||
const currentSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
|
||||
const trigger = currentSnapshot.loginEntryTrigger || findLoginEntryTrigger();
|
||||
if (!trigger || !isActionEnabled(trigger)) {
|
||||
return createStep6RecoverableResult('missing_login_entry_trigger', currentSnapshot, {
|
||||
message: '当前登录入口页没有可点击的邮箱登录入口。',
|
||||
});
|
||||
}
|
||||
if (snapshot.state === 'login_timeout_error_page') {
|
||||
|
||||
log(`步骤 7:检测到登录入口页,正在点击 "${getActionText(trigger).slice(0, 80)}"...`);
|
||||
await humanPause(350, 900);
|
||||
simulateClick(trigger);
|
||||
const nextSnapshot = await waitForLoginEntryOpenTransition();
|
||||
|
||||
if (nextSnapshot.state === 'email_page') {
|
||||
return step6LoginFromEmailPage(payload, nextSnapshot);
|
||||
}
|
||||
if (nextSnapshot.state === 'password_page') {
|
||||
return step6LoginFromPasswordPage(payload, nextSnapshot);
|
||||
}
|
||||
if (nextSnapshot.state === 'verification_page') {
|
||||
return finalizeStep6VerificationReady({
|
||||
logLabel: '步骤 7 收尾',
|
||||
loginVerificationRequestedAt: null,
|
||||
via: 'entry_open_verification_page',
|
||||
});
|
||||
}
|
||||
if (nextSnapshot.state === 'oauth_consent_page') {
|
||||
return createStep6OAuthConsentSuccessResult(nextSnapshot, {
|
||||
via: 'entry_open_oauth_consent_page',
|
||||
});
|
||||
}
|
||||
if (nextSnapshot.state === 'login_timeout_error_page') {
|
||||
const transition = await createStep6LoginTimeoutRecoveryTransition(
|
||||
'login_timeout_error_page',
|
||||
snapshot,
|
||||
'切换到一次性验证码登录后进入登录超时报错页。',
|
||||
{
|
||||
loginVerificationRequestedAt,
|
||||
via: 'switch_to_one_time_code_timeout_recovered',
|
||||
}
|
||||
'login_timeout_after_entry_open',
|
||||
nextSnapshot,
|
||||
'点击登录入口后进入登录超时报错页。'
|
||||
);
|
||||
if (transition.action === 'done') {
|
||||
return transition.result;
|
||||
}
|
||||
if (transition.action === 'password' || transition.action === 'email') {
|
||||
return transition;
|
||||
}
|
||||
if (transition.action === 'done') return transition.result;
|
||||
if (transition.action === 'email') return step6LoginFromEmailPage(payload, transition.snapshot);
|
||||
if (transition.action === 'password') return step6LoginFromPasswordPage(payload, transition.snapshot);
|
||||
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 createStep6RecoverableResult('login_entry_open_stalled', nextSnapshot, {
|
||||
message: '点击登录入口后仍未进入邮箱/密码/验证码页。',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2920,6 +2931,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,
|
||||
@@ -2963,6 +2977,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,
|
||||
@@ -3019,6 +3036,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,
|
||||
@@ -3056,6 +3076,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(
|
||||
@@ -3068,6 +3095,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,
|
||||
@@ -3093,6 +3123,10 @@ async function step6_login(payload) {
|
||||
return step6LoginFromPasswordPage(payload, snapshot);
|
||||
}
|
||||
|
||||
if (snapshot.state === 'entry_page') {
|
||||
return step6OpenLoginEntry(payload, snapshot);
|
||||
}
|
||||
|
||||
throwForStep6FatalState(snapshot);
|
||||
throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`);
|
||||
}
|
||||
|
||||
+16
-11
@@ -1,4 +1,4 @@
|
||||
// content/sub2api-panel.js — 页内脚本:SUB2API 后台(步骤 1、9)
|
||||
// content/sub2api-panel.js — 页内脚本:SUB2API 后台(OAuth 生成与回调提交)
|
||||
|
||||
console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href);
|
||||
|
||||
@@ -68,7 +68,9 @@ async function handleStep(step, payload = {}) {
|
||||
case 1:
|
||||
return step1_generateOpenAiAuthUrl(payload);
|
||||
case 10:
|
||||
return step9_submitOpenAiCallback(payload);
|
||||
case 12:
|
||||
case 13:
|
||||
return step9_submitOpenAiCallback({ ...(payload || {}), visibleStep: step });
|
||||
default:
|
||||
throw new Error(`sub2api-panel.js 不处理步骤 ${step}`);
|
||||
}
|
||||
@@ -501,15 +503,13 @@ 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();
|
||||
|
||||
const sessionId = String(payload.sub2apiSessionId || backgroundState.sub2apiSessionId || '').trim();
|
||||
const expectedState = String(payload.sub2apiOAuthState || backgroundState.sub2apiOAuthState || '').trim();
|
||||
const accountName = flowEmail
|
||||
|| String(payload.sub2apiDraftName || backgroundState.sub2apiDraftName || '').trim()
|
||||
|| buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
|
||||
const { origin, token } = await loginSub2Api(payload);
|
||||
const proxyPreference = resolveSub2ApiProxyPreference(payload, backgroundState);
|
||||
@@ -528,11 +528,11 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
|
||||
}
|
||||
|
||||
log('步骤 10:正在向 SUB2API 交换 OpenAI 授权码...');
|
||||
log(`步骤 ${visibleStep}:正在向 SUB2API 交换 OpenAI 授权码...`);
|
||||
if (proxy) {
|
||||
log(`步骤 10:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
log(`步骤 ${visibleStep}:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
} else {
|
||||
log('步骤 10:未配置 SUB2API 默认代理,本次将不使用代理。');
|
||||
log(`步骤 ${visibleStep}:未配置 SUB2API 默认代理,本次将不使用代理。`);
|
||||
}
|
||||
const exchangeRequestBody = {
|
||||
session_id: sessionId,
|
||||
@@ -550,10 +550,15 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
|
||||
const credentials = buildOpenAiCredentials(exchangeData);
|
||||
const extra = buildOpenAiExtra(exchangeData);
|
||||
const resolvedEmail = String(exchangeData?.email || credentials?.email || '').trim();
|
||||
const groupId = Number(group.id);
|
||||
if (!Number.isFinite(groupId) || groupId <= 0) {
|
||||
throw new Error('SUB2API 返回的目标分组 ID 无效。');
|
||||
}
|
||||
const accountName = resolvedEmail
|
||||
|| flowEmail
|
||||
|| String(payload.sub2apiDraftName || backgroundState.sub2apiDraftName || '').trim()
|
||||
|| buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
const createPayload = {
|
||||
name: accountName,
|
||||
notes: '',
|
||||
@@ -574,7 +579,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 +587,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,
|
||||
});
|
||||
|
||||
+15
-4
@@ -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();
|
||||
}
|
||||
|
||||
+16
-12
@@ -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 >= 13 ? 12 : 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: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,78 @@
|
||||
{ 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: 'fetch-login-code', title: '获取登录验证码' },
|
||||
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' },
|
||||
{ id: 13, order: 130, 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,
|
||||
};
|
||||
});
|
||||
|
||||
+235
-8
@@ -1,4 +1,6 @@
|
||||
本教程用于说明相关项目地址、扩展更新方式、`QQ 邮箱`切换邮箱的使用方法,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。
|
||||
# Codex 注册扩展相关项目、更新、邮箱切换、PayPal 与 Clash Verge 配置教程
|
||||
|
||||
本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。
|
||||
|
||||
## 适用场景
|
||||
|
||||
@@ -6,6 +8,7 @@
|
||||
- 已经安装本扩展,想更新到最新版本
|
||||
- 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务`
|
||||
- 需要临时切换 `QQ 邮箱` 地址继续使用
|
||||
- 需要注册并使用 `PayPal` 个人账户
|
||||
- 需要在 `Clash Verge` 中启用 `🔁 非港轮询`
|
||||
|
||||
## 准备内容
|
||||
@@ -15,16 +18,18 @@
|
||||
- 可以打开浏览器的 `扩展程序管理` 页面
|
||||
- 已准备好 `Cloudflare Temp Email` 后端地址;如需随机子域,域名解析也已配置完成
|
||||
- 一个可正常登录的 `QQ 邮箱`
|
||||
- 一个可正常接收短信的手机号
|
||||
- 一张可在线支付的借记卡或信用卡
|
||||
- 如需部署 `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. 拉取项目到本地
|
||||
先将你需要的项目拉取到本地目录。
|
||||
@@ -67,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`
|
||||
@@ -101,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. 进入 `账号管理`
|
||||
在 `账号与安全` 页面中找到 `账号管理`。
|
||||
@@ -114,11 +119,218 @@
|
||||
这两个邮箱地址使用完成后,可以直接删除。
|
||||
删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。
|
||||
|
||||
### 第五部分:配置 `Clash Verge` 的 `🔁 非港轮询`
|
||||
### 第五部分:`PayPal` 注册与绑卡使用教程
|
||||
|
||||
1. 打开注册页面
|
||||
打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。
|
||||
然后点击 `注册`。
|
||||
|
||||
2. 选择账户类型
|
||||
选择 `个人账户`。
|
||||
如果页面显示英文,通常对应 `Individual Account`。
|
||||
|
||||
3. 选择国家或地区
|
||||
在 `国家/地区` 中直接选择 `中国`。
|
||||
后续会绑定手机号,按中国手机号流程继续即可。
|
||||
|
||||
4. 输入手机号并继续
|
||||
按页面提示输入手机号。
|
||||
如果页面要求短信验证,就先完成短信验证再继续下一步。
|
||||
|
||||
5. 填写登录信息和个人信息
|
||||
按页面提示继续填写邮箱、密码,以及姓名、出生日期、地址和联系方式。
|
||||
`PayPal` 官方说明,中国大陆居民注册时必须使用身份证上的中文姓名,不能使用拼音。
|
||||
填完后勾选协议并完成注册。
|
||||
|
||||
6. 完成邮箱确认
|
||||
注册成功后,按页面或邮箱提示确认邮箱地址。
|
||||
如页面继续提示验证手机号,也一并完成。
|
||||
|
||||
7. 进入 `钱包` 或主页绑卡
|
||||
注册成功后,进入主页。
|
||||
然后在页面中找到 `关联卡或银行账户`,也可以直接进入 `钱包` 页面操作。
|
||||
|
||||
8. 选择关联借记卡或信用卡
|
||||
点击 `关联借记卡或信用卡`。
|
||||
如果你要先完成最基础的付款配置,优先把卡先绑上。
|
||||
|
||||
9. 填写银行卡信息
|
||||
按页面提示输入银行卡号、有效期、安全代码和账单地址。
|
||||
有效期一般在卡正面,通常写成 `10/45` 这种格式,表示 `2045 年 10 月` 到期。
|
||||
安全代码一般在卡背面,输入三位数的那个。
|
||||
如果卡背面分成两段数字,一段四位、一段三位,选择三位数的那段。
|
||||
账单地址如果页面默认信息正确,保持默认即可。
|
||||
|
||||
10. 完成绑卡并检查结果
|
||||
点击 `关联卡`。
|
||||
有时候页面会报错,但你仍然可以去 `钱包` 页面重新看一下,因为卡有可能已经绑定成功。
|
||||
|
||||
11. 检查右上角通知并完成认证
|
||||
绑卡成功后,记得查看右上角的通知图标。
|
||||
如果通知图标标红,或者页面提示账户需要认证,请按提示补交资料。
|
||||
常见情况是上传身份证件。
|
||||
`PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。
|
||||
|
||||
### 第六部分: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) 的 `🔁 非港轮询`
|
||||
|
||||
#### 第一步:添加扩展脚本
|
||||
|
||||
1. 打开 `Clash Verge`,进入左侧的 `订阅`(`Profiles`)界面。
|
||||
1. 打开 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),进入左侧的 `订阅`(`Profiles`)界面。
|
||||
2. 在右上角或对应位置找到并双击打开 `全局扩展脚本`。
|
||||

|
||||
3. 将里面的内容全部清空,替换为下方脚本代码。
|
||||
@@ -236,6 +448,18 @@ function main(config, profileName) {
|
||||
|
||||
可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。
|
||||
|
||||
### `PayPal` 绑卡时报错怎么办?
|
||||
|
||||
先去 `钱包` 页面再看一眼,有时虽然页面提示报错,但实际上已经绑定成功。
|
||||
如果还没有成功,优先检查账单地址是否与银行卡账单地址一致。
|
||||
`PayPal` 官方还说明,绑定新卡时会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,如果这一步被银行拒绝,绑卡也会失败。
|
||||
|
||||
### `PayPal` 右上角通知标红怎么办?
|
||||
|
||||
先点开通知查看具体要求。
|
||||
如果页面要求 `Confirm your Identity` 或账户认证,就按提示上传身份证件或其他所需材料。
|
||||
`PayPal` 官方说明,资料通常会在 `2 个工作日` 内审核,但某些情况可能更久。
|
||||
|
||||
### 为什么没有看到 `🔁 非港轮询`?
|
||||
|
||||
请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。
|
||||
@@ -247,6 +471,9 @@ function main(config, profileName) {
|
||||
- 开启 `随机子域` 前,请先确认后端已经配置 `RANDOM_SUBDOMAIN_DOMAINS`,并且 Cloudflare DNS 已完成 `MX *` 设置。
|
||||
- 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。
|
||||
- `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。
|
||||
- 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。
|
||||
- `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。
|
||||
- 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。
|
||||
- 使用 `git pull` 更新扩展是最方便、最推荐的方式。
|
||||
- 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
(function attachSidepanelContributionMode(globalScope) {
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,并继续等待 CPA 最终确认。';
|
||||
(function attachSidepanelContributionMode(globalScope) {
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'expired', 'error']);
|
||||
const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,并继续等待服务端确认。';
|
||||
const CONTRIBUTION_SOURCE_CPA = 'cpa';
|
||||
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
|
||||
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
|
||||
|
||||
function createContributionModeManager(context = {}) {
|
||||
const {
|
||||
@@ -66,6 +69,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContributionSource(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
return normalized === CONTRIBUTION_SOURCE_SUB2API
|
||||
? CONTRIBUTION_SOURCE_SUB2API
|
||||
: CONTRIBUTION_SOURCE_CPA;
|
||||
}
|
||||
|
||||
function getContributionSource(currentState = getLatestState()) {
|
||||
return normalizeContributionSource(currentState.contributionSource || currentState.panelMode);
|
||||
}
|
||||
|
||||
function getContributionSourceLabel(currentState = getLatestState()) {
|
||||
return getContributionSource(currentState) === CONTRIBUTION_SOURCE_SUB2API ? 'SUB2API' : 'CPA';
|
||||
}
|
||||
|
||||
function isContributionModeEnabled(currentState = getLatestState()) {
|
||||
return Boolean(currentState.contributionMode);
|
||||
}
|
||||
@@ -140,7 +158,7 @@
|
||||
if (status === 'waiting') {
|
||||
return '等待提交回调';
|
||||
}
|
||||
if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') {
|
||||
if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected') {
|
||||
return status === 'processing' ? '已提交回调' : '授权已结束';
|
||||
}
|
||||
if (status === 'expired' || status === 'error') {
|
||||
@@ -173,7 +191,15 @@
|
||||
}
|
||||
|
||||
function getSummaryText(currentState = getLatestState()) {
|
||||
return normalizeString(currentState.contributionStatusMessage) || DEFAULT_COPY;
|
||||
const statusMessage = normalizeString(currentState.contributionStatusMessage);
|
||||
if (statusMessage) {
|
||||
return statusMessage;
|
||||
}
|
||||
if (getContributionSource(currentState) === CONTRIBUTION_SOURCE_SUB2API) {
|
||||
const groupName = normalizeString(currentState.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME;
|
||||
return `当前账号将用于支持项目维护。贡献会通过 SUB2API 完成,并固定写入 ${groupName} 分组;如检测到回调地址,扩展会自动提交并等待服务端确认。`;
|
||||
}
|
||||
return DEFAULT_COPY;
|
||||
}
|
||||
|
||||
function getContributionPortalPageUrl() {
|
||||
@@ -310,9 +336,10 @@
|
||||
const enabled = isContributionModeEnabled(currentState);
|
||||
const blocked = isModeSwitchBlocked();
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
const sourceLabel = getContributionSourceLabel(currentState);
|
||||
|
||||
if (enabled && dom.selectPanelMode) {
|
||||
dom.selectPanelMode.value = 'cpa';
|
||||
dom.selectPanelMode.value = getContributionSource(currentState);
|
||||
}
|
||||
|
||||
helpers.updatePanelModeUI?.();
|
||||
@@ -322,7 +349,13 @@
|
||||
dom.contributionModePanel.hidden = !enabled;
|
||||
}
|
||||
if (dom.contributionModeText) {
|
||||
dom.contributionModeText.textContent = DEFAULT_COPY;
|
||||
dom.contributionModeText.textContent = getSummaryText({
|
||||
contributionSource: currentState.contributionSource,
|
||||
contributionTargetGroupName: currentState.contributionTargetGroupName,
|
||||
});
|
||||
}
|
||||
if (dom.contributionModeBadge) {
|
||||
dom.contributionModeBadge.textContent = enabled ? sourceLabel : '';
|
||||
}
|
||||
if (dom.inputContributionNickname && activeElement !== dom.inputContributionNickname) {
|
||||
const nextNickname = normalizeString(currentState.contributionNickname);
|
||||
|
||||
@@ -2419,6 +2419,22 @@ header {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.plus-contribution-prompt-copy {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plus-contribution-prompt-image {
|
||||
display: block;
|
||||
width: min(220px, 100%);
|
||||
max-height: 280px;
|
||||
object-fit: contain;
|
||||
margin: 12px auto 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.modal-message a,
|
||||
.modal-alert a {
|
||||
color: var(--blue);
|
||||
|
||||
@@ -115,11 +115,11 @@
|
||||
<option value="codex2api">Codex2API</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="contribution-mode-panel" class="contribution-mode-panel" hidden>
|
||||
<div class="contribution-mode-panel-header">
|
||||
<span class="section-label">贡献模式</span>
|
||||
<span class="contribution-mode-badge">CPA</span>
|
||||
</div>
|
||||
<div id="contribution-mode-panel" class="contribution-mode-panel" hidden>
|
||||
<div class="contribution-mode-panel-header">
|
||||
<span class="section-label">贡献模式</span>
|
||||
<span id="contribution-mode-badge" class="contribution-mode-badge">CPA</span>
|
||||
</div>
|
||||
<p id="contribution-mode-text" class="contribution-mode-text">
|
||||
当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。</p>
|
||||
<div class="data-row contribution-mode-field">
|
||||
@@ -212,6 +212,28 @@
|
||||
<button id="btn-toggle-password" class="input-icon-btn" type="button" aria-label="显示密码" title="显示密码"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-plus-mode">
|
||||
<span class="data-label">Plus 模式</span>
|
||||
<div class="data-inline setting-pair">
|
||||
<div class="setting-group setting-group-primary">
|
||||
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout + PayPal 授权流程">
|
||||
<input type="checkbox" id="input-plus-mode-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="setting-caption">PayPal 订阅链路</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-paypal-email" style="display:none;">
|
||||
<span class="data-label">PayPal 账号</span>
|
||||
<input type="text" id="input-paypal-email" class="data-input" placeholder="请输入 PayPal 登录邮箱" />
|
||||
</div>
|
||||
<div class="data-row" id="row-paypal-password" style="display:none;">
|
||||
<span class="data-label">PayPal 密码</span>
|
||||
<input type="password" id="input-paypal-password" class="data-input" placeholder="请输入 PayPal 登录密码" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">邮箱服务</span>
|
||||
<div class="data-inline">
|
||||
|
||||
+326
-16
@@ -34,6 +34,7 @@ const updateReleaseList = document.getElementById('update-release-list');
|
||||
const btnOpenRelease = document.getElementById('btn-open-release');
|
||||
const settingsCard = document.getElementById('settings-card');
|
||||
const contributionModePanel = document.getElementById('contribution-mode-panel');
|
||||
const contributionModeBadge = document.getElementById('contribution-mode-badge');
|
||||
const contributionModeText = document.getElementById('contribution-mode-text');
|
||||
const inputContributionNickname = document.getElementById('input-contribution-nickname');
|
||||
const inputContributionQq = document.getElementById('input-contribution-qq');
|
||||
@@ -99,6 +100,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');
|
||||
@@ -248,11 +255,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;
|
||||
@@ -273,8 +281,32 @@ 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';
|
||||
const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger';
|
||||
|
||||
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 AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS = 3;
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
const PLUS_CONTRIBUTION_DONATION_CREDIT = 20;
|
||||
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
|
||||
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
|
||||
const ICLOUD_PROVIDER = 'icloud';
|
||||
@@ -1021,10 +1053,164 @@ 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;
|
||||
}
|
||||
|
||||
function normalizePlusContributionPromptNumber(value) {
|
||||
const number = Math.floor(Number(value) || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function normalizePlusContributionPromptLedger(value = {}) {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
return {
|
||||
promptBaseline: normalizePlusContributionPromptNumber(source.promptBaseline),
|
||||
donationCredit: Math.max(0, normalizePlusContributionPromptNumber(source.donationCredit)),
|
||||
};
|
||||
}
|
||||
|
||||
function getPlusContributionPromptLedger() {
|
||||
try {
|
||||
return normalizePlusContributionPromptLedger(
|
||||
JSON.parse(localStorage.getItem(PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY) || '{}')
|
||||
);
|
||||
} catch {
|
||||
return normalizePlusContributionPromptLedger();
|
||||
}
|
||||
}
|
||||
|
||||
function setPlusContributionPromptLedger(ledger) {
|
||||
localStorage.setItem(
|
||||
PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY,
|
||||
JSON.stringify(normalizePlusContributionPromptLedger(ledger))
|
||||
);
|
||||
}
|
||||
|
||||
function isSuccessfulPlusAccountRecord(record = {}) {
|
||||
return record?.finalStatus === 'success' && Boolean(record.plusModeEnabled);
|
||||
}
|
||||
|
||||
function getPlusContributionPromptTotals(records = []) {
|
||||
return (Array.isArray(records) ? records : []).reduce((totals, record) => {
|
||||
if (!isSuccessfulPlusAccountRecord(record)) {
|
||||
return totals;
|
||||
}
|
||||
if (record.contributionMode) {
|
||||
totals.contributionSuccess += 1;
|
||||
} else {
|
||||
totals.plusSuccess += 1;
|
||||
}
|
||||
return totals;
|
||||
}, {
|
||||
plusSuccess: 0,
|
||||
contributionSuccess: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function getPlusContributionPromptProgress(records = [], ledger = getPlusContributionPromptLedger()) {
|
||||
const totals = getPlusContributionPromptTotals(records);
|
||||
const normalizedLedger = normalizePlusContributionPromptLedger(ledger);
|
||||
const credit = (totals.contributionSuccess * PLUS_CONTRIBUTION_ACCOUNT_CREDIT)
|
||||
+ normalizedLedger.donationCredit;
|
||||
const netCount = totals.plusSuccess - credit;
|
||||
const sinceLastPrompt = netCount - normalizedLedger.promptBaseline;
|
||||
return {
|
||||
...totals,
|
||||
credit,
|
||||
netCount,
|
||||
sinceLastPrompt,
|
||||
shouldPrompt: sinceLastPrompt >= PLUS_CONTRIBUTION_PROMPT_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldShowPlusContributionPrompt(records = [], plusModeEnabled = false, ledger = getPlusContributionPromptLedger()) {
|
||||
return Boolean(plusModeEnabled)
|
||||
&& getPlusContributionPromptProgress(records, ledger).shouldPrompt;
|
||||
}
|
||||
|
||||
function markPlusContributionPromptShown(records = [], ledger = getPlusContributionPromptLedger()) {
|
||||
const progress = getPlusContributionPromptProgress(records, ledger);
|
||||
const nextLedger = {
|
||||
...normalizePlusContributionPromptLedger(ledger),
|
||||
promptBaseline: progress.netCount,
|
||||
};
|
||||
setPlusContributionPromptLedger(nextLedger);
|
||||
return nextLedger;
|
||||
}
|
||||
|
||||
function addPlusContributionPromptCredit(credit, ledger = getPlusContributionPromptLedger()) {
|
||||
const normalizedLedger = normalizePlusContributionPromptLedger(ledger);
|
||||
const nextLedger = {
|
||||
...normalizedLedger,
|
||||
donationCredit: normalizedLedger.donationCredit + Math.max(0, normalizePlusContributionPromptNumber(credit)),
|
||||
};
|
||||
setPlusContributionPromptLedger(nextLedger);
|
||||
return nextLedger;
|
||||
}
|
||||
|
||||
function getPlusContributionSupportImageUrl() {
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) {
|
||||
return chrome.runtime.getURL('docs/images/微信.png');
|
||||
}
|
||||
return '../docs/images/微信.png';
|
||||
}
|
||||
|
||||
function buildPlusContributionSupportPromptHtml() {
|
||||
const imageUrl = getPlusContributionSupportImageUrl();
|
||||
return [
|
||||
'<span class="plus-contribution-prompt-copy">您觉得这个 Plus 功能怎么样?您的账户数量应该已经够个人使用啦。</span>',
|
||||
'<span class="plus-contribution-prompt-copy">可以打开贡献给作者贡献几个账号,以便于让作者开发更好的功能出来吗?或者打赏一下作者?</span>',
|
||||
`<img class="plus-contribution-prompt-image" src="${escapeHtml(imageUrl)}" alt="微信打赏二维码" />`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
function openPlusContributionSupportModal() {
|
||||
return openActionModal({
|
||||
title: 'Plus 功能使用反馈',
|
||||
messageHtml: buildPlusContributionSupportPromptHtml(),
|
||||
actions: [
|
||||
{ id: null, label: '取消', variant: 'btn-ghost' },
|
||||
{ id: 'contribute', label: '去贡献账号', variant: 'btn-outline' },
|
||||
{ id: 'donated', label: '已打赏', variant: 'btn-primary' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeShowPlusContributionPromptBeforeAutoRun(plusModeEnabled) {
|
||||
const records = Array.isArray(latestState?.accountRunHistory) ? latestState.accountRunHistory : [];
|
||||
if (!shouldShowPlusContributionPrompt(records, plusModeEnabled)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const choice = await openPlusContributionSupportModal();
|
||||
const ledger = markPlusContributionPromptShown(records);
|
||||
if (choice === 'donated') {
|
||||
addPlusContributionPromptCredit(PLUS_CONTRIBUTION_DONATION_CREDIT, ledger);
|
||||
showToast('感谢打赏支持,已延后下一次 Plus 提醒。', 'success', 2200);
|
||||
return true;
|
||||
}
|
||||
if (choice === 'contribute') {
|
||||
openExternalUrl(getContributionPortalUrl());
|
||||
showToast('已打开贡献页面,可以按页面提示贡献 Plus 账号。', 'info', 2200);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openAutoSkipFailuresConfirmModal() {
|
||||
const result = await openConfirmModalWithOption({
|
||||
title: '自动重试说明',
|
||||
@@ -1051,6 +1237,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);
|
||||
@@ -1126,7 +1325,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) {
|
||||
@@ -1713,7 +1913,9 @@ function collectSettingsPayload() {
|
||||
label: typeof DEFAULT_HERO_SMS_COUNTRY_LABEL !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_LABEL : 'Thailand',
|
||||
};
|
||||
return {
|
||||
panelMode: selectPanelMode.value,
|
||||
...(contributionModeEnabled ? {} : {
|
||||
panelMode: selectPanelMode.value,
|
||||
}),
|
||||
vpsUrl: inputVpsUrl.value.trim(),
|
||||
vpsPassword: inputVpsPassword.value,
|
||||
localCpaStep9Mode: getSelectedLocalCpaStep9Mode(),
|
||||
@@ -1724,6 +1926,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,
|
||||
}),
|
||||
@@ -1960,6 +2171,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;
|
||||
@@ -2140,6 +2366,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;
|
||||
@@ -2183,6 +2412,21 @@ function renderStepsList() {
|
||||
if (stepsProgress) {
|
||||
stepsProgress.textContent = `0 / ${STEP_IDS.length}`;
|
||||
}
|
||||
|
||||
initializeManualStepActions();
|
||||
renderStepStatuses();
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
function syncStepDefinitionsForMode(plusModeEnabled = false, options = {}) {
|
||||
const nextPlusModeEnabled = Boolean(plusModeEnabled);
|
||||
const shouldRender = Boolean(options.render) || nextPlusModeEnabled !== currentPlusModeEnabled;
|
||||
if (!shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
rebuildStepDefinitionState(nextPlusModeEnabled);
|
||||
renderStepsList();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2190,11 +2434,24 @@ function renderStepsList() {
|
||||
// ============================================================
|
||||
|
||||
function applySettingsState(state) {
|
||||
if (typeof syncStepDefinitionsForMode === 'function') {
|
||||
syncStepDefinitionsForMode(Boolean(state?.plusModeEnabled));
|
||||
}
|
||||
syncLatestState(state);
|
||||
syncAutoRunState(state);
|
||||
renderStepStatuses(latestState);
|
||||
|
||||
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);
|
||||
@@ -2319,6 +2576,9 @@ function applySettingsState(state) {
|
||||
updateFallbackThreadIntervalInputState();
|
||||
updateAccountRunHistorySettingsUI();
|
||||
updatePhoneVerificationSettingsUI();
|
||||
if (typeof updatePlusModeUI === 'function') {
|
||||
updatePlusModeUI();
|
||||
}
|
||||
updatePanelModeUI();
|
||||
updateMailProviderUI();
|
||||
if (isLuckmailProvider(state?.mailProvider)) {
|
||||
@@ -2924,6 +3184,7 @@ function getCustomMailProviderUiCopy() {
|
||||
|
||||
function getCustomVerificationPromptCopy(step) {
|
||||
const verificationLabel = step === 4 ? '注册验证码' : '登录验证码';
|
||||
const isLoginVerificationStep = step === 8 || step === 11;
|
||||
return {
|
||||
title: `手动处理${verificationLabel}`,
|
||||
message: `当前邮箱服务为“自定义邮箱”。请先在页面中手动输入${verificationLabel},并确认已经进入下一页面后,再点击确认。`,
|
||||
@@ -2931,7 +3192,7 @@ function getCustomVerificationPromptCopy(step) {
|
||||
text: `点击确认后会跳过步骤 ${step}。`,
|
||||
tone: 'danger',
|
||||
},
|
||||
...(step === 8 ? {
|
||||
...(isLoginVerificationStep ? {
|
||||
phoneActionLabel: '出现手机号验证',
|
||||
phoneActionAlert: {
|
||||
text: '如果当前页面已经进入手机号验证,可直接标记为失败并继续下一个邮箱。',
|
||||
@@ -2943,7 +3204,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,
|
||||
@@ -3500,9 +3761,6 @@ function updatePanelModeUI() {
|
||||
// ============================================================
|
||||
|
||||
function updateStepUI(step, status) {
|
||||
const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
|
||||
const row = document.querySelector(`.step-row[data-step="${step}"]`);
|
||||
|
||||
syncLatestState({
|
||||
stepStatuses: {
|
||||
...getStepStatuses(),
|
||||
@@ -3510,16 +3768,31 @@ function updateStepUI(step, status) {
|
||||
},
|
||||
});
|
||||
|
||||
if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
|
||||
if (row) {
|
||||
row.className = `step-row ${status}`;
|
||||
}
|
||||
|
||||
renderSingleStepStatus(step, status);
|
||||
updateButtonStates();
|
||||
updateProgressCounter();
|
||||
updateConfigMenuControls();
|
||||
}
|
||||
|
||||
function renderSingleStepStatus(step, status) {
|
||||
const normalizedStatus = status || 'pending';
|
||||
const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
|
||||
const row = document.querySelector(`.step-row[data-step="${step}"]`);
|
||||
|
||||
if (statusEl) statusEl.textContent = STATUS_ICONS[normalizedStatus] || '';
|
||||
if (row) {
|
||||
row.className = `step-row ${normalizedStatus}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStepStatuses(state = latestState) {
|
||||
const statuses = getStepStatuses(state);
|
||||
for (const step of STEP_IDS) {
|
||||
renderSingleStepStatus(step, statuses[step]);
|
||||
}
|
||||
updateProgressCounter();
|
||||
}
|
||||
|
||||
function updateProgressCounter() {
|
||||
const completed = Object.values(getStepStatuses()).filter(isDoneStatus).length;
|
||||
stepsProgress.textContent = `${completed} / ${STEP_IDS.length}`;
|
||||
@@ -4049,6 +4322,7 @@ const contributionModeManager = window.SidepanelContributionMode?.createContribu
|
||||
btnOpenAccountRecords,
|
||||
btnOpenContributionUpload,
|
||||
btnStartContribution,
|
||||
contributionModeBadge,
|
||||
contributionModePanel,
|
||||
contributionModeSummary,
|
||||
contributionModeText,
|
||||
@@ -4496,6 +4770,9 @@ async function startAutoRunFromCurrentSettings() {
|
||||
if (lockedRunCount > 0) {
|
||||
inputRunCount.value = String(lockedRunCount);
|
||||
}
|
||||
const plusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled
|
||||
? Boolean(inputPlusModeEnabled.checked)
|
||||
: Boolean(currentPlusModeEnabled || latestState?.plusModeEnabled);
|
||||
let mode = 'restart';
|
||||
const autoRunSkipFailures = inputAutoSkipFailures.checked;
|
||||
const contributionNickname = String(inputContributionNickname?.value || '').trim();
|
||||
@@ -4515,6 +4792,11 @@ async function startAutoRunFromCurrentSettings() {
|
||||
mode = choice;
|
||||
}
|
||||
|
||||
const confirmedPlusContributionPrompt = await maybeShowPlusContributionPromptBeforeAutoRun(plusModeEnabled);
|
||||
if (!confirmedPlusContributionPrompt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldWarnAutoRunFallbackRisk(totalRuns, autoRunSkipFailures)
|
||||
&& !isAutoRunFallbackRiskPromptDismissed()) {
|
||||
const result = await openAutoRunFallbackRiskConfirmModal(totalRuns);
|
||||
@@ -4526,6 +4808,17 @@ async function startAutoRunFromCurrentSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -4742,6 +5035,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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -45,11 +45,15 @@ const bundle = [
|
||||
'const AUTO_STEP_DELAY_MIN_ALLOWED_SECONDS = 0;',
|
||||
'const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600;',
|
||||
'const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null };',
|
||||
"const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([['plus-checkout-create', 20000]]);",
|
||||
'function getStepDefinitionForState(step, state = {}) { return state.definitions?.[step] || null; }',
|
||||
extractFunction('normalizeAutoStepDelaySeconds'),
|
||||
extractFunction('resolveLegacyAutoStepDelaySeconds'),
|
||||
extractFunction('getStepExecutionKeyForState'),
|
||||
extractFunction('getAutoRunPreExecutionDelayMs'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`${bundle}; return { normalizeAutoStepDelaySeconds, resolveLegacyAutoStepDelaySeconds };`)();
|
||||
const api = new Function(`${bundle}; return { normalizeAutoStepDelaySeconds, resolveLegacyAutoStepDelaySeconds, getAutoRunPreExecutionDelayMs };`)();
|
||||
|
||||
assert.strictEqual(
|
||||
api.normalizeAutoStepDelaySeconds(''),
|
||||
@@ -123,4 +127,24 @@ assert.strictEqual(
|
||||
'empty legacy settings should migrate to no delay'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
api.getAutoRunPreExecutionDelayMs(6, {
|
||||
definitions: {
|
||||
6: { key: 'plus-checkout-create' },
|
||||
},
|
||||
}),
|
||||
20000,
|
||||
'Plus checkout create should wait before step execution'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
api.getAutoRunPreExecutionDelayMs(6, {
|
||||
definitions: {
|
||||
6: { key: 'clear-login-cookies' },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
'normal step 6 should not inherit the Plus checkout pre-wait'
|
||||
);
|
||||
|
||||
console.log('auto step delay tests passed');
|
||||
|
||||
@@ -87,6 +87,8 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
totalRuns: 10,
|
||||
attemptRun: 3,
|
||||
},
|
||||
plusModeEnabled: false,
|
||||
contributionMode: false,
|
||||
});
|
||||
|
||||
const appended = await helpers.appendAccountRunRecord('step8_failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
|
||||
@@ -138,6 +140,39 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
assert.equal(normalizedStoppedRecord.failedStep, 7);
|
||||
});
|
||||
|
||||
test('account run history records preserve Plus and contribution mode flags', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
|
||||
|
||||
const helpers = api.createAccountRunHistoryHelpers({
|
||||
chrome: { storage: { local: { get: async () => ({}), set: async () => {} } } },
|
||||
getState: async () => ({}),
|
||||
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
|
||||
});
|
||||
|
||||
const record = helpers.buildAccountRunHistoryRecord({
|
||||
email: 'plus@example.com',
|
||||
password: 'secret',
|
||||
plusModeEnabled: true,
|
||||
contributionMode: true,
|
||||
}, 'success');
|
||||
|
||||
assert.equal(record.plusModeEnabled, true);
|
||||
assert.equal(record.contributionMode, true);
|
||||
|
||||
const normalized = helpers.normalizeAccountRunHistoryRecord({
|
||||
email: 'plus@example.com',
|
||||
password: 'secret',
|
||||
finalStatus: 'success',
|
||||
plusModeEnabled: true,
|
||||
contributionMode: true,
|
||||
});
|
||||
|
||||
assert.equal(normalized.plusModeEnabled, true);
|
||||
assert.equal(normalized.contributionMode, true);
|
||||
});
|
||||
|
||||
test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -84,14 +84,16 @@ test('contribution oauth module exposes a factory', () => {
|
||||
assert.equal(Array.isArray(api?.RUNTIME_KEYS), true);
|
||||
});
|
||||
|
||||
test('buildContributionModeState preserves active contribution runtime while forcing CPA mode', () => {
|
||||
test('buildContributionModeState preserves active contribution runtime while keeping contribution on sub2api', () => {
|
||||
const bundle = extractFunction(backgroundSource, 'buildContributionModeState');
|
||||
|
||||
const api = new Function(`
|
||||
const api = new Function(`
|
||||
const DEFAULT_STATE = { panelMode: 'cpa' };
|
||||
const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -107,6 +109,35 @@ const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
|
||||
function isPlusModeState(state = {}) { return Boolean(state?.plusModeEnabled); }
|
||||
function normalizeContributionModeSource(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'sub2api' ? 'sub2api' : 'cpa';
|
||||
}
|
||||
function resolveContributionModeRoutingState(state = {}) {
|
||||
const currentStatus = String(state?.contributionStatus || '').trim().toLowerCase();
|
||||
const currentSource = normalizeContributionModeSource(state?.contributionSource);
|
||||
const hasActiveSession = Boolean(
|
||||
String(state?.contributionSessionId || '').trim()
|
||||
&& currentStatus
|
||||
&& !['auto_approved', 'auto_rejected', 'expired', 'error'].includes(currentStatus)
|
||||
);
|
||||
if (hasActiveSession) {
|
||||
return {
|
||||
source: currentSource,
|
||||
targetGroupName: currentSource === 'sub2api'
|
||||
? (String(state?.contributionTargetGroupName || '').trim() || 'codex号池')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
const source = 'sub2api';
|
||||
return {
|
||||
source,
|
||||
targetGroupName: isPlusModeState(state)
|
||||
? 'openai-plus'
|
||||
: (String(state?.contributionTargetGroupName || '').trim() || 'codex号池'),
|
||||
};
|
||||
}
|
||||
${bundle}
|
||||
return { buildContributionModeState };
|
||||
`)();
|
||||
@@ -125,6 +156,8 @@ return { buildContributionModeState };
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: 'session-001',
|
||||
@@ -138,7 +171,7 @@ return { buildContributionModeState };
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'cpa',
|
||||
panelMode: 'sub2api',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
@@ -157,6 +190,8 @@ return { buildContributionModeState };
|
||||
{
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -175,6 +210,37 @@ return { buildContributionModeState };
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(true, {
|
||||
panelMode: 'cpa',
|
||||
plusModeEnabled: true,
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {}),
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'openai-plus',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'sub2api',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('resetState preserves contribution runtime across reset', () => {
|
||||
@@ -334,6 +400,8 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
||||
let statusPollCount = 0;
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
email: 'user@example.com',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
@@ -424,6 +492,8 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"nickname":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"qq":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"email":"user@example\.com"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"source":"sub2api"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"target_group_name":"codex号池"/);
|
||||
assert.match(fetchCalls[1].url, /\/status\?/);
|
||||
|
||||
const callbackState = await manager.handleCapturedCallback(
|
||||
@@ -471,6 +541,78 @@ test('contribution oauth manager accepts localhost callback urls that contain er
|
||||
);
|
||||
});
|
||||
|
||||
test('contribution oauth manager switches Plus contribution traffic to sub2api openai-plus', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
plusModeEnabled: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'openai-plus',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (String(url).endsWith('/start')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-plus-001',
|
||||
state: 'oauth-state-plus-001',
|
||||
source: 'sub2api',
|
||||
target_group_name: 'openai-plus',
|
||||
auth_url: 'https://auth.example.com/oauth?state=oauth-state-plus-001',
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/status?')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-plus-001',
|
||||
status: 'waiting',
|
||||
source: 'sub2api',
|
||||
target_group_name: 'openai-plus',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, { ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
chrome: {
|
||||
tabs: {
|
||||
async create(payload) {
|
||||
return { id: 91, url: payload.url };
|
||||
},
|
||||
async update() {
|
||||
return null;
|
||||
},
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
broadcastDataUpdate: (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
await manager.startContributionFlow();
|
||||
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"source":"sub2api"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"target_group_name":"openai-plus"/);
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
@@ -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 授权页');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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 = [],
|
||||
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];
|
||||
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 (tabId = 1) => {
|
||||
if (urlQueue.length) {
|
||||
lastUrl = urlQueue.shift();
|
||||
}
|
||||
return {
|
||||
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,
|
||||
isTabAlive,
|
||||
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 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: [
|
||||
{ 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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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]]);
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
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 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',
|
||||
structuredAddress: {
|
||||
address1: 'Unter den Linden',
|
||||
city: 'Berlin',
|
||||
postalCode: '10117',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutorHarness({
|
||||
frames,
|
||||
stateByFrame,
|
||||
readyByFrame = {},
|
||||
fetchImpl = null,
|
||||
getAddressSeedForCountry = () => createAddressSeed(),
|
||||
}) {
|
||||
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 });
|
||||
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: [] };
|
||||
}
|
||||
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 }),
|
||||
fetch: fetchImpl,
|
||||
generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }),
|
||||
getAddressSeedForCountry,
|
||||
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 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: [
|
||||
{ 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 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: [
|
||||
{ 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/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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 does not wait 20 seconds after opening checkout page', 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]);
|
||||
|
||||
const completeIndex = events.findIndex((event) => event.type === 'complete');
|
||||
const readyLogIndex = events.findIndex((event) => event.type === 'log' && /Plus Checkout 页面已就绪/.test(event.message));
|
||||
assert.ok(readyLogIndex > -1);
|
||||
assert.ok(completeIndex > readyLogIndex);
|
||||
assert.equal(events.some((event) => event.type === 'sleep' && event.ms === 20000), false);
|
||||
assert.equal(events.some((event) => event.type === 'log' && /固定等待 20 秒后继续下一步/.test(event.message)), false);
|
||||
});
|
||||
@@ -48,12 +48,24 @@ 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,
|
||||
plusContributionImpl,
|
||||
} = {}) {
|
||||
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 +87,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 +97,23 @@ 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 });
|
||||
}
|
||||
async function maybeShowPlusContributionPromptBeforeAutoRun(plusModeEnabled) {
|
||||
${plusContributionImpl ? 'return (' + plusContributionImpl + ')(plusModeEnabled, events);' : 'return true;'}
|
||||
}
|
||||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
|
||||
async function refreshContributionContentHint() {
|
||||
events.push({ type: 'refresh' });
|
||||
@@ -155,3 +184,58 @@ 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']
|
||||
);
|
||||
});
|
||||
|
||||
test('startAutoRunFromCurrentSettings aborts when Plus contribution prompt opens contribution page', async () => {
|
||||
const api = createApi({
|
||||
plusModeEnabled: true,
|
||||
plusContributionImpl: `async (plusModeEnabled, events) => {
|
||||
events.push({ type: 'plus-contribution-modal', plusModeEnabled });
|
||||
return false;
|
||||
}`,
|
||||
});
|
||||
|
||||
const result = await api.startAutoRunFromCurrentSettings();
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(
|
||||
api.getEvents().map((entry) => entry.type),
|
||||
['refresh', 'confirm-check', 'plus-contribution-modal']
|
||||
);
|
||||
assert.equal(api.getEvents()[2].plusModeEnabled, true);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -122,6 +122,59 @@ test('sidepanel html contains contribution mode runtime UI and loads the module
|
||||
assert.ok(moduleIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('sidepanel settings refresh preserves rendered step progress', () => {
|
||||
const applySettingsStateSource = extractFunction('applySettingsState');
|
||||
assert.doesNotMatch(
|
||||
applySettingsStateSource,
|
||||
/syncStepDefinitionsForMode\(Boolean\(state\?\.plusModeEnabled\),\s*\{\s*render:\s*true\s*\}\)/
|
||||
);
|
||||
assert.match(applySettingsStateSource, /renderStepStatuses\(latestState\)/);
|
||||
|
||||
const bundle = [
|
||||
extractFunction('isDoneStatus'),
|
||||
extractFunction('getStepStatuses'),
|
||||
extractFunction('renderSingleStepStatus'),
|
||||
extractFunction('renderStepStatuses'),
|
||||
extractFunction('updateProgressCounter'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const STATUS_ICONS = {
|
||||
pending: '',
|
||||
running: '',
|
||||
completed: 'C',
|
||||
failed: 'F',
|
||||
stopped: 'S',
|
||||
manual_completed: 'M',
|
||||
skipped: 'K',
|
||||
};
|
||||
let latestState = { stepStatuses: { 1: 'completed', 2: 'running', 3: 'pending' } };
|
||||
let STEP_IDS = [1, 2, 3];
|
||||
let STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', 3: 'pending' };
|
||||
const rows = new Map(STEP_IDS.map((step) => [step, { className: 'step-row' }]));
|
||||
const statusEls = new Map(STEP_IDS.map((step) => [step, { textContent: '' }]));
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
const match = selector.match(/data-step="(\\d+)"/);
|
||||
const step = match ? Number(match[1]) : 0;
|
||||
return selector.includes('step-status') ? statusEls.get(step) : rows.get(step);
|
||||
},
|
||||
};
|
||||
const stepsProgress = { textContent: '' };
|
||||
${bundle}
|
||||
return { renderStepStatuses, rows, statusEls, stepsProgress };
|
||||
`)();
|
||||
|
||||
api.renderStepStatuses();
|
||||
|
||||
assert.equal(api.rows.get(1).className, 'step-row completed');
|
||||
assert.equal(api.rows.get(2).className, 'step-row running');
|
||||
assert.equal(api.rows.get(3).className, 'step-row pending');
|
||||
assert.equal(api.statusEls.get(1).textContent, 'C');
|
||||
assert.equal(api.statusEls.get(2).textContent, '');
|
||||
assert.equal(api.stepsProgress.textContent, '1 / 3');
|
||||
});
|
||||
|
||||
test('collectSettingsPayload omits custom password and local sync settings in contribution mode', () => {
|
||||
const bundle = extractFunction('collectSettingsPayload');
|
||||
|
||||
@@ -195,6 +248,7 @@ return {
|
||||
`)(normalizeIcloudTargetMailboxType, normalizeIcloudForwardMailProvider);
|
||||
|
||||
const contributionPayload = api.collectSettingsPayload();
|
||||
assert.equal('panelMode' in contributionPayload, false);
|
||||
assert.equal('customPassword' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false);
|
||||
@@ -203,6 +257,7 @@ return {
|
||||
|
||||
api.setLatestState({ contributionMode: false });
|
||||
const normalPayload = api.collectSettingsPayload();
|
||||
assert.equal(normalPayload.panelMode, 'cpa');
|
||||
assert.equal(normalPayload.customPassword, 'Secret123!');
|
||||
assert.equal(normalPayload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
@@ -235,6 +290,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
@@ -262,6 +319,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
btnOpenAccountRecords: createElement(),
|
||||
btnOpenContributionUpload: createElement(),
|
||||
btnStartContribution: createElement(),
|
||||
contributionModeBadge: createElement(),
|
||||
inputContributionNickname: createElement({ value: '贡献者昵称' }),
|
||||
inputContributionQq: createElement({ value: '123456' }),
|
||||
contributionCallbackStatus: createElement(),
|
||||
@@ -357,7 +415,9 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
state: message.payload.enabled
|
||||
? {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
panelMode: 'sub2api',
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -372,6 +432,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
: {
|
||||
contributionMode: false,
|
||||
panelMode: 'cpa',
|
||||
contributionSource: 'cpa',
|
||||
contributionTargetGroupName: '',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -391,7 +453,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
state: {
|
||||
...latestState,
|
||||
contributionStatus: 'processing',
|
||||
contributionStatusMessage: '已提交回调,等待 CPA 确认',
|
||||
contributionStatusMessage: '已提交回调,等待服务端确认',
|
||||
contributionCallbackStatus: 'submitted',
|
||||
contributionCallbackMessage: '已提交回调',
|
||||
},
|
||||
@@ -418,13 +480,15 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.disabled, false);
|
||||
assert.equal(dom.contributionModeBadge.textContent, '');
|
||||
|
||||
manager.bindEvents();
|
||||
await dom.btnContributionMode.listeners.click();
|
||||
|
||||
assert.equal(dom.contributionModePanel.hidden, false);
|
||||
assert.equal(dom.selectPanelMode.value, 'cpa');
|
||||
assert.equal(dom.selectPanelMode.value, 'sub2api');
|
||||
assert.equal(dom.selectPanelMode.disabled, true);
|
||||
assert.equal(dom.contributionModeBadge.textContent, 'SUB2API');
|
||||
assert.equal(dom.btnOpenAccountRecords.disabled, true);
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03');
|
||||
@@ -457,7 +521,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
assert.equal(statusState.contributionStatus, 'processing');
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85 CPA \u786e\u8ba4');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85\u670d\u52a1\u7aef\u786e\u8ba4');
|
||||
|
||||
dom.btnOpenContributionUpload.listeners.click();
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io', 'https://apikey.qzz.io/upload']);
|
||||
@@ -480,7 +544,9 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
blocked = true;
|
||||
latestState = {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
panelMode: 'sub2api',
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '贡献者昵称',
|
||||
contributionQq: '123456',
|
||||
contributionSessionId: 'session-002',
|
||||
@@ -491,6 +557,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
manager.render();
|
||||
assert.equal(dom.selectPanelMode.value, 'sub2api');
|
||||
assert.equal(dom.contributionModeBadge.textContent, 'SUB2API');
|
||||
assert.equal(dom.btnExitContributionMode.disabled, true);
|
||||
manager.stopPolling();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.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 i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
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 extractBundle(names) {
|
||||
return names.map((name) => extractFunction(name)).join('\n');
|
||||
}
|
||||
|
||||
function createPlusRecords(count, contributionMode = false) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
recordId: `${contributionMode ? 'contribution' : 'plus'}-${index}@example.com`,
|
||||
email: `${contributionMode ? 'contribution' : 'plus'}-${index}@example.com`,
|
||||
finalStatus: 'success',
|
||||
plusModeEnabled: true,
|
||||
contributionMode,
|
||||
}));
|
||||
}
|
||||
|
||||
test('Plus contribution prompt starts every five normal Plus successes', () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
]);
|
||||
|
||||
const api = new Function(`
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
${bundle}
|
||||
return {
|
||||
getPlusContributionPromptProgress,
|
||||
shouldShowPlusContributionPrompt,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(createPlusRecords(4), true, { promptBaseline: 0, donationCredit: 0 }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(createPlusRecords(5), true, { promptBaseline: 0, donationCredit: 0 }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(createPlusRecords(8), false, { promptBaseline: 0, donationCredit: 0 }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('Plus contribution success and donated credit delay the next prompt', () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
]);
|
||||
|
||||
const api = new Function(`
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
${bundle}
|
||||
return { shouldShowPlusContributionPrompt };
|
||||
`)();
|
||||
|
||||
const afterPromptLedger = { promptBaseline: 5, donationCredit: 0 };
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(
|
||||
[...createPlusRecords(14), ...createPlusRecords(1, true)],
|
||||
true,
|
||||
afterPromptLedger
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(
|
||||
[...createPlusRecords(15), ...createPlusRecords(1, true)],
|
||||
true,
|
||||
afterPromptLedger
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
const donatedLedger = { promptBaseline: 5, donationCredit: 20 };
|
||||
assert.equal(api.shouldShowPlusContributionPrompt(createPlusRecords(29), true, donatedLedger), false);
|
||||
assert.equal(api.shouldShowPlusContributionPrompt(createPlusRecords(30), true, donatedLedger), true);
|
||||
});
|
||||
|
||||
test('Plus contribution support modal includes WeChat image and expected actions', async () => {
|
||||
const bundle = extractBundle([
|
||||
'getPlusContributionSupportImageUrl',
|
||||
'buildPlusContributionSupportPromptHtml',
|
||||
'openPlusContributionSupportModal',
|
||||
]);
|
||||
|
||||
const api = new Function(`
|
||||
let capturedOptions = null;
|
||||
const chrome = { runtime: { getURL: (path) => 'chrome-extension://test/' + path } };
|
||||
function escapeHtml(value) { return String(value || ''); }
|
||||
async function openActionModal(options) {
|
||||
capturedOptions = options;
|
||||
return 'donated';
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
openPlusContributionSupportModal,
|
||||
getCapturedOptions() {
|
||||
return capturedOptions;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const choice = await api.openPlusContributionSupportModal();
|
||||
const options = api.getCapturedOptions();
|
||||
|
||||
assert.equal(choice, 'donated');
|
||||
assert.equal(options.title, 'Plus 功能使用反馈');
|
||||
assert.match(options.messageHtml, /docs\/images\/微信\.png/);
|
||||
assert.deepEqual(options.actions.map((action) => action.label), ['取消', '去贡献账号', '已打赏']);
|
||||
});
|
||||
|
||||
test('Plus contribution prompt marks shown and donated choice adds twenty credits', async () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'getPlusContributionPromptLedger',
|
||||
'setPlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
'markPlusContributionPromptShown',
|
||||
'addPlusContributionPromptCredit',
|
||||
'maybeShowPlusContributionPromptBeforeAutoRun',
|
||||
]);
|
||||
|
||||
const api = new Function('records', `
|
||||
const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger';
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
const PLUS_CONTRIBUTION_DONATION_CREDIT = 20;
|
||||
const latestState = { accountRunHistory: records };
|
||||
const storage = {};
|
||||
const events = [];
|
||||
const localStorage = {
|
||||
getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; },
|
||||
setItem(key, value) { storage[key] = String(value); },
|
||||
};
|
||||
async function openPlusContributionSupportModal() {
|
||||
events.push({ type: 'modal' });
|
||||
return 'donated';
|
||||
}
|
||||
function showToast(message, type) {
|
||||
events.push({ type: 'toast', message, toastType: type });
|
||||
}
|
||||
function openExternalUrl(url) {
|
||||
events.push({ type: 'open', url });
|
||||
}
|
||||
function getContributionPortalUrl() {
|
||||
return 'https://apikey.qzz.io';
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
maybeShowPlusContributionPromptBeforeAutoRun,
|
||||
getLedger() {
|
||||
return JSON.parse(storage[PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY] || '{}');
|
||||
},
|
||||
getEvents() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)(createPlusRecords(5));
|
||||
|
||||
const result = await api.maybeShowPlusContributionPromptBeforeAutoRun(true);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(api.getEvents().map((event) => event.type), ['modal', 'toast']);
|
||||
assert.deepEqual(api.getLedger(), {
|
||||
promptBaseline: 5,
|
||||
donationCredit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('Plus contribution prompt opens portal and aborts normal auto run when contribute is chosen', async () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'getPlusContributionPromptLedger',
|
||||
'setPlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
'markPlusContributionPromptShown',
|
||||
'addPlusContributionPromptCredit',
|
||||
'maybeShowPlusContributionPromptBeforeAutoRun',
|
||||
]);
|
||||
|
||||
const api = new Function('records', `
|
||||
const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger';
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
const PLUS_CONTRIBUTION_DONATION_CREDIT = 20;
|
||||
const latestState = { accountRunHistory: records };
|
||||
const storage = {};
|
||||
const events = [];
|
||||
const localStorage = {
|
||||
getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; },
|
||||
setItem(key, value) { storage[key] = String(value); },
|
||||
};
|
||||
async function openPlusContributionSupportModal() {
|
||||
events.push({ type: 'modal' });
|
||||
return 'contribute';
|
||||
}
|
||||
function showToast(message, type) {
|
||||
events.push({ type: 'toast', message, toastType: type });
|
||||
}
|
||||
function openExternalUrl(url) {
|
||||
events.push({ type: 'open', url });
|
||||
}
|
||||
function getContributionPortalUrl() {
|
||||
return 'https://apikey.qzz.io';
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
maybeShowPlusContributionPromptBeforeAutoRun,
|
||||
getEvents() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)(createPlusRecords(5));
|
||||
|
||||
const result = await api.maybeShowPlusContributionPromptBeforeAutoRun(true);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(api.getEvents().map((event) => event.type), ['modal', 'open', 'toast']);
|
||||
assert.equal(api.getEvents()[1].url, 'https://apikey.qzz.io');
|
||||
});
|
||||
@@ -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,28 @@ 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',
|
||||
'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'), 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', () => {
|
||||
@@ -41,3 +64,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"/);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,10 @@ function findOneTimeCodeLoginTrigger() {
|
||||
return ${JSON.stringify(overrides.switchTrigger || null)};
|
||||
}
|
||||
|
||||
function findLoginEntryTrigger() {
|
||||
return ${JSON.stringify(overrides.loginEntryTrigger || null)};
|
||||
}
|
||||
|
||||
function getLoginSubmitButton() {
|
||||
return ${JSON.stringify(overrides.submitButton || null)};
|
||||
}
|
||||
@@ -215,17 +219,29 @@ 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 状态');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createApi({
|
||||
loginEntryTrigger: { id: 'continue-email' },
|
||||
});
|
||||
|
||||
const snapshot = api.inspectLoginAuthState();
|
||||
assert.strictEqual(snapshot.state, 'entry_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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -177,6 +177,31 @@ test('SUB2API step 10 uses the same proxy for code exchange and account creation
|
||||
assert.equal(context.completed[0].step, 10);
|
||||
});
|
||||
|
||||
test('SUB2API panel accepts Plus platform verify step 13', async () => {
|
||||
const fetchCalls = [];
|
||||
const context = createSub2ApiPanelContext(fetchCalls);
|
||||
|
||||
await vm.runInContext(`
|
||||
handleStep(13, {
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
sub2apiUrl: 'https://sub.example/admin/accounts',
|
||||
sub2apiEmail: 'admin@example.com',
|
||||
sub2apiPassword: 'secret',
|
||||
sub2apiGroupName: 'codex',
|
||||
sub2apiSessionId: 'session-1',
|
||||
sub2apiOAuthState: 'oauth-state',
|
||||
sub2apiGroupId: 5
|
||||
})
|
||||
`, context);
|
||||
|
||||
const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code');
|
||||
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
|
||||
|
||||
assert.equal(exchangeCall.body.code, 'callback-code');
|
||||
assert.equal(createCall.body.group_ids[0], 5);
|
||||
assert.equal(context.completed[0].step, 13);
|
||||
});
|
||||
|
||||
test('SUB2API step 1 omits proxy_id when default proxy is empty', async () => {
|
||||
const fetchCalls = [];
|
||||
const context = createSub2ApiPanelContext(fetchCalls);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+37
-1
@@ -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 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 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 步记录。
|
||||
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 统一别名邮箱链路
|
||||
@@ -731,9 +765,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. 如果失败,根据设置决定:
|
||||
|
||||
+34
-4
@@ -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,35 @@
|
||||
- `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` 与 `mail-provider-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;当 provider 为 iCloud 时,会保存并回显目标邮箱类型与转发邮箱 provider,相关 provider 选项和 label 复用 `mail-provider-utils.js`;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `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` 与 `mail-provider-utils.js`,并把旧的“邮箱前缀”字段语义改为“别
|
||||
名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免
|
||||
receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时
|
||||
额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收
|
||||
码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `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 两种模式;当 provider 为 iCloud 时,会保存并回显目标邮箱类型与转发邮箱 provider,相关 provider 选
|
||||
项和 label 复用 `mail-provider-utils.js`;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保
|
||||
存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `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 重开,而是直接把错误抛给自动重试控制器。
|
||||
@@ -167,6 +196,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` 等网页邮箱来源的识别逻辑。
|
||||
|
||||
Reference in New Issue
Block a user