Merge remote-tracking branch 'origin/dev' into fix/herosms-max-price-input

This commit is contained in:
QLHazycoder
2026-05-03 03:17:21 +08:00
38 changed files with 12425 additions and 951 deletions
+1
View File
@@ -9,3 +9,4 @@
.npm-test.log
.omx/
/node_modules
/.runtime
+635 -33
View File
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -97,6 +97,26 @@
.join('');
}
function isPhoneNumberSupplyExhaustedFailure(errorLike) {
const message = String(
typeof errorLike === 'string'
? errorLike
: (errorLike?.message || errorLike || '')
).trim();
if (!message) {
return false;
}
const hasGlobalNoSupplySignal = /Step\s*9:\s*all\s+provider\s+candidates\s+failed\s+to\s+acquire\s+number|(?:HeroSMS|5sim|NexSMS)\s+no\s+numbers\s+available\s+across|no\s+numbers\s+within\s+maxPrice|no\s+free\s+phones|numbers?\s+not\s+found/i.test(message);
if (!hasGlobalNoSupplySignal) {
return false;
}
const hasRecoverableStep9RotationSignal = /phone\s+verification\s+did\s+not\s+succeed\s+after\s+\d+\s+number\s+replacements|sms_timeout_after_|route_405_retry_loop|resend_throttled|activation_not_found|order\s+not\s+found/i.test(message);
if (hasRecoverableStep9RotationSignal) {
return false;
}
return true;
}
function shouldKeepCustomMailProviderPoolEmail(state = {}) {
return String(state?.mailProvider || '').trim().toLowerCase() === 'custom'
&& Array.isArray(state?.customMailProviderPool)
@@ -482,10 +502,15 @@
const reason = getErrorMessage(err);
roundSummary.failureReasons.push(reason);
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err);
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
&& !keepSameEmailUntilAddPhone
&& isSignupUserAlreadyExistsFailure(err);
const canRetry = !blockedByAddPhone && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
const canRetry = !blockedByAddPhone
&& !blockedByPhoneSupplyExhausted
&& !blockedBySignupUserAlreadyExists
&& autoRunSkipFailures
&& attemptRun < maxAttemptsForRound;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
@@ -561,6 +586,41 @@
break;
}
if (blockedByPhoneSupplyExhausted) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await appendRoundRecordIfNeeded('failed', reason);
cancelPendingCommands('当前轮因接码号池不可用已终止。');
await broadcastStopToContentScripts();
if (!autoRunSkipFailures) {
await addLog(
`${targetRun}/${totalRuns} 轮触发接码号池不可用,自动重试未开启,当前自动运行将停止。`,
'warn'
);
stoppedEarly = true;
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
sessionId: 0,
});
break;
}
await addLog(`${targetRun}/${totalRuns} 轮接码号池暂不可用,本轮将直接失败并跳过剩余重试。`, 'warn');
await addLog(
targetRun < totalRuns
? `${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,自动流程将继续下一轮。`
: `${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,已无后续轮次,本次自动运行结束。`,
'warn'
);
forceFreshTabsNextRun = true;
break;
}
if (canRetry) {
const retryIndex = attemptRun;
if (isRestartCurrentAttemptError(err)) {
+9 -2
View File
@@ -284,9 +284,16 @@
}
if (generator === 'icloud') {
const stateFetchMode = String(mergedState.icloudFetchMode || '').trim().toLowerCase();
return fetchIcloudHideMyEmail({
const icloudOptions = {
generateNew: Boolean(options.generateNew) || stateFetchMode === 'always_new',
});
};
if (mergedState.icloudHostPreference !== undefined) {
icloudOptions.hostPreference = mergedState.icloudHostPreference;
}
if (mergedState.preferredIcloudHost !== undefined) {
icloudOptions.preferredHost = mergedState.preferredIcloudHost;
}
return fetchIcloudHideMyEmail(icloudOptions);
}
if (generator === 'cloudflare') {
return fetchCloudflareEmail(mergedState, options);
+42 -15
View File
@@ -3,6 +3,7 @@ let ipProxyAuthListenerInstalled = false;
let ipProxyErrorListenerInstalled = false;
let currentIpProxyAuthEntry = null;
let ipProxyExitDetectionToken = 0;
let ipProxyProbeInFlightPromise = null;
let lastAppliedIpProxyEntrySignature = '';
let ipProxyAuthHostVariantToggle = false;
const ipProxyHostResolveCache = new Map();
@@ -573,6 +574,17 @@ function normalizeIpProxyServiceProfile(rawValue = {}) {
const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue))
? rawValue
: {};
const normalizeAutoSyncInterval = (value = '', fallback = 15) => {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return Math.max(1, Math.min(1440, Number(fallback) || 15));
}
const numeric = Number.parseInt(rawValue, 10);
if (!Number.isFinite(numeric)) {
return Math.max(1, Math.min(1440, Number(fallback) || 15));
}
return Math.max(1, Math.min(1440, numeric));
};
return {
mode: normalizeIpProxyMode(raw.mode),
apiUrl: String(raw.apiUrl || '').trim(),
@@ -580,6 +592,8 @@ function normalizeIpProxyServiceProfile(rawValue = {}) {
accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''),
accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''),
poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20),
autoSyncEnabled: Boolean(raw.autoSyncEnabled),
autoSyncIntervalMinutes: normalizeAutoSyncInterval(raw.autoSyncIntervalMinutes, 15),
host: String(raw.host || '').trim(),
port: String(normalizeIpProxyPort(raw.port || '') || ''),
protocol: normalizeIpProxyProtocol(raw.protocol),
@@ -597,6 +611,8 @@ function buildIpProxyServiceProfileFromState(state = {}) {
accountSessionPrefix: state?.ipProxyAccountSessionPrefix,
accountLifeMinutes: state?.ipProxyAccountLifeMinutes,
poolTargetCount: state?.ipProxyPoolTargetCount,
autoSyncEnabled: state?.ipProxyAutoSyncEnabled,
autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes,
host: state?.ipProxyHost,
port: state?.ipProxyPort,
protocol: state?.ipProxyProtocol,
@@ -1595,20 +1611,14 @@ function applyExitRegionExpectation(status = {}, expectedRegion = '') {
const authHint = authSummary ? ` 诊断:probe:${authSummary}` : '';
const mismatchMessage = `代理出口地区与预期不一致:期望 ${expected},实际 ${detected || 'unknown'}${sourceHint}${usernameHint}${missingAuthChallengeHint}${authHint}`.trim();
if (normalizedProvider === '711proxy') {
if (missingAuthChallenge) {
return {
...status,
applied: false,
reason: 'connectivity_failed',
error: mismatchMessage,
warning: '',
};
}
const warningPrefix = missingAuthChallenge
? '地区校验未通过且未触发代理鉴权挑战,疑似匿名链路;先保留代理接管并给出强告警:'
: '地区校验未通过,但已保留代理接管:';
return {
...status,
applied: true,
reason: 'applied_with_warning',
warning: `地区校验未通过,但已保留代理接管:${mismatchMessage}`,
warning: `${warningPrefix}${mismatchMessage}`,
error: '',
};
}
@@ -3172,9 +3182,11 @@ async function refreshIpProxyPool(options = {}) {
forceAuthRebind: true,
suppressAuthRebind: false,
resetNetworkState: true,
skipExitProbe: false,
skipExitProbe: Boolean(options?.skipExitProbe),
}
: {
skipExitProbe: Boolean(options?.skipExitProbe),
}
: undefined
);
}
@@ -3249,9 +3261,11 @@ async function switchIpProxy(direction = 'next', options = {}) {
forceAuthRebind: true,
suppressAuthRebind: false,
resetNetworkState: true,
skipExitProbe: false,
skipExitProbe: Boolean(options?.skipExitProbe),
}
: {
skipExitProbe: Boolean(options?.skipExitProbe),
}
: undefined
);
}
const summary = buildProxyPoolSummary(pool, nextIndex);
@@ -3299,7 +3313,7 @@ async function changeIpProxyExit(options = {}) {
forceAuthRebind: true,
suppressAuthRebind: false,
resetNetworkState: true,
skipExitProbe: false,
skipExitProbe: Boolean(options?.skipExitProbe),
});
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
@@ -3405,6 +3419,10 @@ async function tryRecoverApiProxyByRotation(options = {}) {
}
async function probeIpProxyExit(options = {}) {
if (ipProxyProbeInFlightPromise) {
return ipProxyProbeInFlightPromise;
}
const probePromise = (async () => {
const state = options.state || await getState();
if (!state?.ipProxyEnabled) {
const status = {
@@ -3599,6 +3617,15 @@ async function probeIpProxyExit(options = {}) {
}
await updateIpProxyRuntimeStatus(normalizedFinalStatus);
return { proxyRouting: normalizedFinalStatus };
})();
ipProxyProbeInFlightPromise = probePromise;
try {
return await probePromise;
} finally {
if (ipProxyProbeInFlightPromise === probePromise) {
ipProxyProbeInFlightPromise = null;
}
}
}
async function ensureIpProxySettingsAppliedFromCurrentState(options = {}) {
+1 -1
View File
@@ -58,7 +58,7 @@
function isVerificationMailPollingError(error) {
const message = getErrorMessage(error);
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s|405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/(?:email|phone)-verification/i.test(message);
}
function isAddPhoneAuthFailure(error) {
+34 -1
View File
@@ -61,6 +61,9 @@
isStopError,
isTabAlive,
launchAutoRunTimerPlan,
ensureIpProxyAutoSyncAlarm,
clearIpProxyAutoSyncAlarm,
runIpProxyAutoSync,
listIcloudAliases,
listLuckmailPurchasesForManagement,
refreshIpProxyPool,
@@ -716,6 +719,12 @@
oauthFlowDeadlineSourceUrl: null,
} : {}),
};
if (Object.prototype.hasOwnProperty.call(updates, 'icloudHostPreference')) {
const nextHostPreference = String(updates.icloudHostPreference || '').trim().toLowerCase();
stateUpdates.preferredIcloudHost = nextHostPreference === 'icloud.com' || nextHostPreference === 'icloud.com.cn'
? nextHostPreference
: '';
}
if (stepModeChanged && typeof getStepIdsForState === 'function') {
const nextStateForSteps = { ...currentState, ...stateUpdates };
stateUpdates.stepStatuses = Object.fromEntries(
@@ -725,6 +734,19 @@
}
await setState(stateUpdates);
const mergedState = await getState();
const hasIpProxyAutoSyncSettingChanged = (
Object.prototype.hasOwnProperty.call(updates, 'ipProxyAutoSyncEnabled')
|| Object.prototype.hasOwnProperty.call(updates, 'ipProxyAutoSyncIntervalMinutes')
);
if (hasIpProxyAutoSyncSettingChanged) {
if (Boolean(mergedState?.ipProxyAutoSyncEnabled)) {
if (typeof ensureIpProxyAutoSyncAlarm === 'function') {
await ensureIpProxyAutoSyncAlarm(mergedState);
}
} else if (typeof clearIpProxyAutoSyncAlarm === 'function') {
await clearIpProxyAutoSyncAlarm();
}
}
const hasIpProxyUpdates = Object.keys(updates).some((key) => key.startsWith('ipProxy'));
const hasIpProxyEnabledUpdate = Object.prototype.hasOwnProperty.call(updates, 'ipProxyEnabled');
const previousIpProxyEnabled = Boolean(currentState?.ipProxyEnabled);
@@ -778,6 +800,14 @@
return { ok: true, state: await getState(), proxyRouting };
}
case 'RUN_IP_PROXY_AUTO_SYNC_NOW': {
if (typeof runIpProxyAutoSync !== 'function') {
throw new Error('IP 代理自动同步能力尚未接入。');
}
const result = await runIpProxyAutoSync('manual');
return { ok: true, ...result };
}
case 'REFRESH_IP_PROXY_POOL': {
if (typeof refreshIpProxyPool !== 'function') {
throw new Error('IP 代理池能力尚未接入。');
@@ -785,6 +815,7 @@
const result = await refreshIpProxyPool({
maxItems: message.payload?.maxItems,
mode: message.payload?.mode,
skipExitProbe: message.payload?.skipExitProbe,
});
return { ok: true, ...result };
}
@@ -797,6 +828,7 @@
maxItems: message.payload?.maxItems,
mode: message.payload?.mode,
forceRefresh: message.payload?.forceRefresh,
skipExitProbe: message.payload?.skipExitProbe,
});
return { ok: true, ...result };
}
@@ -807,6 +839,7 @@
}
const result = await changeIpProxyExit({
mode: message.payload?.mode,
skipExitProbe: message.payload?.skipExitProbe,
});
return { ok: true, ...result };
}
@@ -833,7 +866,7 @@
);
const timeoutMs = Number(message.payload?.timeoutMs) > 0
? Number(message.payload.timeoutMs)
: (is711AccountMode ? (shouldPreRebindBeforeProbe ? 8000 : 6000) : undefined);
: (is711AccountMode ? (shouldPreRebindBeforeProbe ? 15000 : 12000) : undefined);
// 手动“检测出口”前先轻量应用当前配置,避免读取到旧代理链路状态。
if (probeState?.ipProxyEnabled && typeof applyIpProxySettingsFromState === 'function') {
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -316,6 +316,8 @@
let mailPollingAttempt = 1;
let lastMailPollingError = null;
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let retryWithoutStep7Streak = 0;
const maxRetryWithoutStep7Streak = 3;
while (true) {
try {
@@ -331,6 +333,7 @@
if (Number(result?.lastResendAt) > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0);
}
retryWithoutStep7Streak = 0;
return;
} catch (err) {
const visibleStep = getVisibleStep(currentState, 8);
@@ -361,8 +364,21 @@
mailPollingAttempt += 1;
if (retryWithoutStep7) {
retryWithoutStep7Streak += 1;
if (retryWithoutStep7Streak > maxRetryWithoutStep7Streak) {
await addLog(
`步骤 ${visibleStep}认证页仍保持在验证码页,将在当前链路直接重试${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}`,
`步骤 ${visibleStep}邮箱通信异常在当前链路已连续重试 ${retryWithoutStep7Streak} 次,改为回到步骤 ${authLoginStep} 重新发起授权链路,避免空轮询循环`,
'warn'
);
await rerunStep7ForStep8Recovery({
logMessage: `步骤 ${visibleStep}:邮箱通信异常持续未恢复,正在回到步骤 ${authLoginStep} 重新发起登录流程...`,
});
currentState = await getState();
retryWithoutStep7Streak = 0;
continue;
}
await addLog(
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}(连续同链路重试 ${retryWithoutStep7Streak}/${maxRetryWithoutStep7Streak})。`,
'warn'
);
const latestState = await getState();
@@ -390,6 +406,7 @@
}
continue;
}
retryWithoutStep7Streak = 0;
await addLog(
isStep8RestartStep7Error(currentError)
? `步骤 ${visibleStep}:检测到认证页进入重试/超时报错状态,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}...`
+58 -7
View File
@@ -19,7 +19,9 @@
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
resolveVerificationStep,
reuseOrCreateTab,
sendToContentScript,
sendToContentScriptResilient,
isRetryableContentScriptTransportError = () => false,
shouldUseCustomRegistrationEmail,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
throwIfStopped,
@@ -80,9 +82,7 @@
throwIfStopped();
await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...');
const prepareResult = await sendToContentScriptResilient(
'signup-page',
{
const prepareRequest = {
type: 'PREPARE_SIGNUP_VERIFICATION',
step: 4,
source: 'background',
@@ -91,13 +91,64 @@
prepareSource: 'step4_execute',
prepareLogLabel: '步骤 4 执行',
},
},
{
timeoutMs: 30000,
};
const prepareTimeoutMs = 30000;
const prepareResponseTimeoutMs = 30000;
const prepareStartAt = Date.now();
let prepareResult = null;
while (Date.now() - prepareStartAt < prepareTimeoutMs) {
throwIfStopped();
try {
prepareResult = typeof sendToContentScript === 'function'
? await sendToContentScript('signup-page', prepareRequest, {
responseTimeoutMs: prepareResponseTimeoutMs,
})
: await sendToContentScriptResilient('signup-page', prepareRequest, {
timeoutMs: Math.max(1000, prepareTimeoutMs - (Date.now() - prepareStartAt)),
responseTimeoutMs: prepareResponseTimeoutMs,
retryDelayMs: 700,
logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...',
});
break;
} catch (error) {
if (!isRetryableContentScriptTransportError(error)) {
throw error;
}
const remainingMs = Math.max(0, prepareTimeoutMs - (Date.now() - prepareStartAt));
if (remainingMs <= 0) {
throw error;
}
const recoverResult = await sendToContentScriptResilient('signup-page', {
type: 'RECOVER_AUTH_RETRY_PAGE',
step: 4,
source: 'background',
payload: {
flow: 'signup',
step: 4,
timeoutMs: Math.min(12000, remainingMs),
maxClickAttempts: 2,
logLabel: '步骤 4:检测到注册认证重试页,正在点击“重试”恢复',
},
}, {
timeoutMs: Math.min(12000, remainingMs),
responseTimeoutMs: Math.min(12000, remainingMs),
retryDelayMs: 700,
logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...',
});
if (recoverResult?.error) {
throw new Error(recoverResult.error);
}
}
}
if (!prepareResult) {
throw new Error('步骤 4:等待注册验证码页面就绪超时,请刷新认证页后重试。');
}
);
if (prepareResult && prepareResult.error) {
throw new Error(prepareResult.error);
+45 -4
View File
@@ -135,6 +135,26 @@
}
}
function isSub2ApiTransientExchangeError(error) {
const message = normalizeString(error?.message || error);
if (!message) {
return false;
}
const tokenExchangeFailure = /auth\.openai\.com\/oauth\/token/i.test(message);
const transientNetworkSignal = /unexpected\s+eof|eof|connection\s+refused|i\/o\s+timeout|context\s+deadline\s+exceeded|connection\s+reset|broken\s+pipe|failed\s+to\s+fetch|temporarily\s+unavailable|timeout/i.test(message);
const transientExchangeUserSignal = /token_exchange_user_error|invalid\s+request\.\s+please\s+try\s+again\s+later/i.test(message);
if (transientExchangeUserSignal) {
return true;
}
return tokenExchangeFailure && transientNetworkSignal;
}
async function sleep(ms = 0) {
const timeout = Math.max(0, Number(ms) || 0);
if (!timeout) return;
await new Promise((resolve) => setTimeout(resolve, timeout));
}
async function fetchCodex2ApiJson(origin, path, options = {}) {
const controller = new AbortController();
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
@@ -286,6 +306,7 @@
}
async function executeSub2ApiStep10(state) {
const visibleStep = resolvePlatformVerifyStep(state);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
}
@@ -327,8 +348,8 @@
injectSource: 'sub2api-panel',
});
await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...');
const result = await sendToContentScript('sub2api-panel', {
await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`);
const requestMessage = {
type: 'EXECUTE_STEP',
step: 10,
source: 'background',
@@ -345,13 +366,33 @@
sub2apiGroupId: state.sub2apiGroupId,
sub2apiDraftName: state.sub2apiDraftName,
},
}, {
};
const maxExchangeAttempts = 3;
let lastError = null;
for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) {
try {
const result = await sendToContentScript('sub2api-panel', requestMessage, {
responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
});
if (result?.error) {
throw new Error(result.error);
}
return;
} catch (error) {
lastError = error;
if (!isSub2ApiTransientExchangeError(error) || attempt >= maxExchangeAttempts) {
throw error;
}
await addLog(
`步骤 ${visibleStep}:SUB2API 回调交换出现临时网络波动(${error.message}),正在重试 ${attempt + 1}/${maxExchangeAttempts}...`,
'warn'
);
await sleep(1200 * attempt);
}
}
if (lastError) {
throw lastError;
}
}
return {
+115 -8
View File
@@ -1,6 +1,9 @@
(function attachBackgroundVerificationFlow(root, factory) {
root.MultiPageBackgroundVerificationFlow = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundVerificationFlowModule() {
const ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS = 18000;
const ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS = 22000;
function createVerificationFlowHelpers(deps = {}) {
const {
addLog,
@@ -46,6 +49,33 @@
return step === 4 ? '注册' : '登录';
}
function capIcloudMailPollingTimeouts(mail, timedPoll) {
const defaultResponseTimeoutMs = Math.max(1000, Number(timedPoll?.responseTimeoutMs) || 30000);
const defaultTimeoutMs = Math.max(defaultResponseTimeoutMs, Number(timedPoll?.timeoutMs) || defaultResponseTimeoutMs);
if (mail?.source !== 'icloud-mail') {
return {
responseTimeoutMs: defaultResponseTimeoutMs,
timeoutMs: defaultTimeoutMs,
capped: false,
};
}
const cappedResponseTimeoutMs = Math.max(
5000,
Math.min(defaultResponseTimeoutMs, ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS)
);
const cappedTimeoutMs = Math.max(
cappedResponseTimeoutMs,
Math.min(defaultTimeoutMs, ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS)
);
return {
responseTimeoutMs: cappedResponseTimeoutMs,
timeoutMs: cappedTimeoutMs,
capped: cappedResponseTimeoutMs < defaultResponseTimeoutMs || cappedTimeoutMs < defaultTimeoutMs,
};
}
function isLikelyLoggedInChatgptHomeUrl(rawUrl) {
const url = String(rawUrl || '').trim();
if (!url) return false;
@@ -375,6 +405,10 @@
const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1);
const disableTimeBudgetCap = Boolean(options.disableTimeBudgetCap);
const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel);
const minPollingResponseTimeoutMs = Math.max(
3000,
Number(options.minPollingResponseTimeoutMs) || 5000
);
if (!disableTimeBudgetCap && remainingMs !== null) {
nextPayload.maxAttempts = Math.max(
@@ -386,7 +420,10 @@
const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000);
const responseTimeoutMs = disableTimeBudgetCap || remainingMs === null
? defaultResponseTimeoutMs
: Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs));
: Math.max(
minPollingResponseTimeoutMs,
Math.min(defaultResponseTimeoutMs, remainingMs)
);
return {
payload: nextPayload,
@@ -583,9 +620,27 @@
const resendIntervalMs = Math.max(0, Number(pollOverrides.resendIntervalMs) || 0);
let lastResendAt = Number(pollOverrides.lastResendAt) || 0;
let usedResendRequests = 0;
let pollOnlyNoResendRounds = 0;
let transportErrorStreak = 0;
const maxTransportErrorStreak = mail?.source === 'icloud-mail' ? 6 : 4;
const maxIcloudNoResendRounds = mail?.source === 'icloud-mail' ? 4 : 0;
const hasExistingResendTimestamp = Number(lastResendAt) > 0;
const initialRoundNoResendWindowMs = resendIntervalMs > 0
? Math.max(10000, Math.min(45000, resendIntervalMs))
: 0;
const initialRoundNoResendUntil = hasExistingResendTimestamp
? 0
: (initialRoundNoResendWindowMs > 0 ? (Date.now() + initialRoundNoResendWindowMs) : 0);
for (let round = 1; round <= totalRounds; round++) {
throwIfStopped();
if (round === 1 && initialRoundNoResendUntil > 0) {
const waitSeconds = Math.max(1, Math.ceil((initialRoundNoResendUntil - Date.now()) / 1000));
await addLog(
`步骤 ${step}:首次进入验证码轮询,先等待 ${waitSeconds} 秒观察新邮件,避免过早重复重发。`,
'info'
);
}
if (round > 1) {
lastResendAt = await requestVerificationCodeResend(step, pollOverrides);
usedResendRequests += 1;
@@ -619,6 +674,13 @@
pollOverrides,
`轮询${getVerificationCodeLabel(step)}验证码邮箱`
);
const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll);
if (timeoutWindow.capped) {
await addLog(
`步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`,
'info'
);
}
const result = await sendToMailContentScriptResilient(
mail,
{
@@ -628,9 +690,9 @@
payload: timedPoll.payload,
},
{
timeoutMs: timedPoll.timeoutMs,
timeoutMs: timeoutWindow.timeoutMs,
maxRecoveryAttempts: 2,
responseTimeoutMs: timedPoll.responseTimeoutMs,
responseTimeoutMs: timeoutWindow.responseTimeoutMs,
}
);
@@ -646,6 +708,8 @@
throw new Error(`步骤 ${step}:再次收到了相同的${getVerificationCodeLabel(step)}验证码:${result.code}`);
}
transportErrorStreak = 0;
return {
...result,
lastResendAt,
@@ -661,16 +725,52 @@
}
throw err;
}
const isTransportError = isRetryableVerificationTransportError(err);
if (isTransportError) {
transportErrorStreak += 1;
lastError = err;
await addLog(`步骤 ${step}${err.message}`, 'warn');
if (transportErrorStreak >= maxTransportErrorStreak) {
throw new Error(
`步骤 ${step}${mail?.label || '邮箱'}页面通信异常连续 ${transportErrorStreak} 次,已停止当前轮询以避免重复重发验证码。最后错误:${err.message}`
);
}
const fallbackIntervalMs = Math.max(
800,
Math.min(
3000,
Number(payloadOverrides.intervalMs)
|| Number(pollOverrides.intervalMs)
|| 2000
)
);
await sleepWithStop(fallbackIntervalMs);
continue;
}
transportErrorStreak = 0;
lastError = err;
await addLog(`步骤 ${step}${err.message}`, 'warn');
}
if (mail?.source === 'icloud-mail' && maxIcloudNoResendRounds > 0) {
pollOnlyNoResendRounds += 1;
if (pollOnlyNoResendRounds >= maxIcloudNoResendRounds) {
throw new Error(
`步骤 ${step}iCloud 邮箱连续 ${pollOnlyNoResendRounds} 轮轮询均未拿到验证码且未触发重发,已停止当前链路以避免空轮询循环,请刷新邮箱页后重试。`
);
}
}
const remainingBeforeResendMs = lastResendAt > 0
? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt))
: 0;
if (remainingBeforeResendMs > 0) {
const initialCooldownMs = (round === 1 && initialRoundNoResendUntil > 0)
? Math.max(0, initialRoundNoResendUntil - Date.now())
: 0;
const effectiveCooldownMs = Math.max(remainingBeforeResendMs, initialCooldownMs);
if (effectiveCooldownMs > 0) {
await addLog(
`步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`,
`步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(effectiveCooldownMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`,
'info'
);
const configuredIntervalMs = Math.max(
@@ -680,7 +780,7 @@
|| 3000
);
const cooldownSleepMs = Math.min(
remainingBeforeResendMs,
effectiveCooldownMs,
Math.max(1000, Math.min(configuredIntervalMs, 3000))
);
await sleepWithStop(cooldownSleepMs);
@@ -840,6 +940,13 @@
pollOverrides,
`轮询${getVerificationCodeLabel(step)}验证码邮箱`
);
const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll);
if (timeoutWindow.capped) {
await addLog(
`步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`,
'info'
);
}
const result = await sendToMailContentScriptResilient(
mail,
{
@@ -849,9 +956,9 @@
payload: timedPoll.payload,
},
{
timeoutMs: timedPoll.timeoutMs,
timeoutMs: timeoutWindow.timeoutMs,
maxRecoveryAttempts: 2,
responseTimeoutMs: timedPoll.responseTimeoutMs,
responseTimeoutMs: timeoutWindow.responseTimeoutMs,
}
);
+3 -1
View File
@@ -71,8 +71,9 @@
: false;
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
return null;
}
@@ -84,6 +85,7 @@
titleMatched,
detailMatched,
routeErrorMatched,
fetchFailedMatched,
maxCheckAttemptsBlocked,
userAlreadyExistsBlocked,
};
+77 -4
View File
@@ -1,5 +1,6 @@
const ICLOUD_MAIL_PREFIX = '[MultiPage:icloud-mail]';
const isTopFrame = window === window.top;
const ICLOUD_POLL_SESSION_CACHE = new Map();
console.log(ICLOUD_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
@@ -12,7 +13,10 @@ function isMailApplicationFrame() {
if (isTopFrame) {
console.log(ICLOUD_MAIL_PREFIX, 'Top frame detected; waiting for mail iframe.');
} else {
}
const shouldHandlePollEmailInCurrentFrame = !isTopFrame || isMailApplicationFrame();
if (shouldHandlePollEmailInCurrentFrame) {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
if (!isMailApplicationFrame()) {
@@ -221,19 +225,76 @@ if (isTopFrame) {
}
}
function normalizePollSessionKey(payload = {}) {
const raw = String(payload?.sessionKey || '').trim();
if (raw) {
return raw;
}
return '';
}
function getOrCreatePollSessionBaseline(sessionKey, currentItems = []) {
if (!sessionKey) {
return {
signatures: new Set(currentItems.map(buildItemSignature)),
fallbackCarry: 0,
fromCache: false,
};
}
const cached = ICLOUD_POLL_SESSION_CACHE.get(sessionKey);
if (cached && cached.signatures instanceof Set) {
return {
signatures: new Set(cached.signatures),
fallbackCarry: Math.max(0, Number(cached.fallbackCarry) || 0),
fromCache: true,
};
}
return {
signatures: new Set(currentItems.map(buildItemSignature)),
fallbackCarry: 0,
fromCache: false,
};
}
function persistPollSessionBaseline(sessionKey, signatures, fallbackCarry = 0) {
if (!sessionKey) {
return;
}
ICLOUD_POLL_SESSION_CACHE.set(sessionKey, {
signatures: new Set(signatures || []),
fallbackCarry: Math.max(0, Number(fallbackCarry) || 0),
updatedAt: Date.now(),
});
if (ICLOUD_POLL_SESSION_CACHE.size > 12) {
const oldest = Array.from(ICLOUD_POLL_SESSION_CACHE.entries())
.sort((left, right) => Number(left?.[1]?.updatedAt || 0) - Number(right?.[1]?.updatedAt || 0))
.slice(0, ICLOUD_POLL_SESSION_CACHE.size - 12);
oldest.forEach(([key]) => ICLOUD_POLL_SESSION_CACHE.delete(key));
}
}
async function handlePollEmail(step, payload) {
const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload;
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
const FALLBACK_AFTER = 3;
const pollSessionKey = normalizePollSessionKey(payload);
const normalizedSenderFilters = senderFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
const normalizedSubjectFilters = subjectFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean);
log(`步骤 ${step}:开始轮询 iCloud 邮箱(最多 ${maxAttempts} 次)`);
await waitForElement('.content-container', 10000);
await sleep(1500);
const existingSignatures = new Set(collectThreadItems().map(buildItemSignature));
const currentItems = collectThreadItems();
const sessionBaseline = getOrCreatePollSessionBaseline(pollSessionKey, currentItems);
const existingSignatures = sessionBaseline.signatures;
let fallbackCarry = sessionBaseline.fallbackCarry;
if (sessionBaseline.fromCache) {
log(`步骤 ${step}:已复用当前会话旧邮件快照(${existingSignatures.size} 封)。`);
} else {
log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`);
}
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
log(`步骤 ${step}:正在轮询 iCloud 邮箱,第 ${attempt}/${maxAttempts}`);
@@ -244,7 +305,7 @@ if (isTopFrame) {
}
const items = collectThreadItems();
const useFallback = attempt > FALLBACK_AFTER;
const useFallback = (fallbackCarry + attempt) > FALLBACK_AFTER;
for (const item of items) {
const signature = buildItemSignature(item);
@@ -287,6 +348,11 @@ if (isTopFrame) {
const source = useFallback && existingSignatures.has(signature) ? '回退匹配邮件' : '新邮件';
log(`步骤 ${step}:已找到验证码:${code}(来源:${source}`, 'ok');
persistPollSessionBaseline(
pollSessionKey,
new Set(collectThreadItems().map(buildItemSignature)),
0
);
return {
ok: true,
code,
@@ -304,6 +370,13 @@ if (isTopFrame) {
}
}
fallbackCarry += maxAttempts;
persistPollSessionBaseline(
pollSessionKey,
new Set(collectThreadItems().map(buildItemSignature)),
fallbackCarry
);
throw new Error(
`${Math.round((maxAttempts * intervalMs) / 1000)} 秒后仍未在 iCloud 邮箱中找到新的匹配邮件。请手动检查收件箱。`
);
+200 -22
View File
@@ -19,7 +19,15 @@
waitForElement,
} = deps;
const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::';
const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::';
const PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS = 6000;
const PHONE_RESEND_ROUTE_405_MAX_RECOVERIES = 2;
const PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS = 12000;
const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i;
const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i;
const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3;
let lastPhoneRoute405RecoveryFailedAt = 0;
let activePhoneResendPromise = null;
function dispatchInputEvents(element) {
if (!element) return;
@@ -35,6 +43,10 @@
return digits;
}
function isExplicitInternationalPhoneInput(value) {
return /^\s*(?:\+|00)\s*\d/.test(String(value || '').trim());
}
function normalizeCountryLabel(value) {
return String(value || '')
.normalize('NFKD')
@@ -151,23 +163,31 @@
function toNationalPhoneNumber(value, dialCode) {
const digits = normalizePhoneDigits(value);
const normalizedDialCode = normalizePhoneDigits(dialCode);
const isExplicitInternational = isExplicitInternationalPhoneInput(value);
if (!digits) {
return '';
}
if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) {
return digits.slice(normalizedDialCode.length);
}
if (isExplicitInternational) {
return digits;
}
return digits;
}
function toE164PhoneNumber(value, dialCode) {
const digits = normalizePhoneDigits(value);
const normalizedDialCode = normalizePhoneDigits(dialCode);
const isExplicitInternational = isExplicitInternationalPhoneInput(value);
if (!digits) {
return '';
}
if (isExplicitInternational) {
return `+${digits}`;
}
if (!normalizedDialCode) {
return digits.startsWith('+') ? digits : `+${digits}`;
return `+${digits}`;
}
if (digits.startsWith(normalizedDialCode)) {
return `+${digits}`;
@@ -234,34 +254,71 @@
.map((label) => normalizeCountryLabel(label))
.filter(Boolean);
return normalizedLabels.some((optionLabel) => (
optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)
optionLabel.length > 2
&& normalizedTarget.length > 2
&& (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel))
));
})
|| null;
}
async function ensureCountrySelected(countryLabel) {
function findCountryOptionByPhoneNumber(phoneNumber) {
const select = getCountrySelect();
if (!select) {
return null;
}
const digits = normalizePhoneDigits(phoneNumber);
if (!digits) {
return null;
}
let bestMatch = null;
let bestDialCodeLength = 0;
for (const option of Array.from(select.options || [])) {
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getOptionLabel(option)));
if (!dialCode || !digits.startsWith(dialCode)) {
continue;
}
if (dialCode.length > bestDialCodeLength) {
bestMatch = option;
bestDialCodeLength = dialCode.length;
}
}
return bestMatch;
}
async function trySelectCountryOption(select, targetOption) {
if (!select || !targetOption) {
return false;
}
const selectedOption = getSelectedCountryOption();
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
return true;
}
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
await sleep(250);
const nextSelectedOption = getSelectedCountryOption();
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
}
async function ensureCountrySelected(countryLabel, phoneNumber = '') {
const select = getCountrySelect();
if (!select) {
return false;
}
const targetOption = findCountryOptionByLabel(countryLabel);
if (!targetOption) {
throw new Error(`Add-phone page is missing the country option for "${countryLabel}".`);
}
const selectedOption = getSelectedCountryOption();
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
const byLabel = findCountryOptionByLabel(countryLabel);
if (await trySelectCountryOption(select, byLabel)) {
return true;
}
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
await sleep(250);
const byPhoneNumber = findCountryOptionByPhoneNumber(phoneNumber);
if (await trySelectCountryOption(select, byPhoneNumber)) {
return true;
}
const nextSelectedOption = getSelectedCountryOption();
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
return Boolean(getSelectedCountryOption());
}
function getAddPhoneSubmitButton() {
@@ -398,6 +455,81 @@
return '';
}
function getAuthRetryButton(options = {}) {
const { allowDisabled = false } = options;
const direct = document.querySelector('button[data-dd-action-name="Try again"]');
if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
return direct;
}
const candidates = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]');
return Array.from(candidates).find((element) => {
if (!isVisibleElement(element) || (!allowDisabled && !isActionEnabled(element))) {
return false;
}
const text = String(getActionText?.(element) || '').trim();
return /重试|try\s+again/i.test(text);
}) || null;
}
function is405MethodNotAllowedPage() {
const path = String(location?.pathname || '');
if (!/\/phone-verification(?:[/?#]|$)/i.test(path) && !/\/add-phone(?:[/?#]|$)/i.test(path)) {
return false;
}
const text = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim();
const title = String(document?.title || '');
const matched = PHONE_ROUTE_405_PATTERN.test(text) || PHONE_ROUTE_405_PATTERN.test(title);
if (!matched) {
return false;
}
return Boolean(getAuthRetryButton({ allowDisabled: true }));
}
async function recoverPhoneRoute405(timeout = 12000, options = {}) {
const now = Date.now();
if (
lastPhoneRoute405RecoveryFailedAt > 0
&& now - lastPhoneRoute405RecoveryFailedAt < PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS
) {
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route is still in 405 recovery cooldown (${Math.ceil((PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS - (now - lastPhoneRoute405RecoveryFailedAt)) / 1000)}s left). URL: ${location.href}`
);
}
const startedAt = Date.now();
let clicked = 0;
const maxRetryClicks = Math.max(
1,
Math.floor(Number(options?.maxRetryClicks) || PHONE_ROUTE_405_MAX_RECOVERY_CLICKS)
);
while (Date.now() - startedAt < timeout) {
throwIfStopped();
if (!is405MethodNotAllowedPage()) {
return;
}
const retryButton = getAuthRetryButton({ allowDisabled: true });
if (retryButton && isActionEnabled(retryButton)) {
if (clicked >= maxRetryClicks) {
lastPhoneRoute405RecoveryFailedAt = Date.now();
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route stayed on 405 after ${clicked} retry click(s). URL: ${location.href}`
);
}
clicked += 1;
await humanPause(200, 500);
simulateClick(retryButton);
await sleep(1000);
continue;
}
await sleep(250);
}
lastPhoneRoute405RecoveryFailedAt = Date.now();
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route 405 recovery timed out after ${clicked} retry click(s). URL: ${location.href}`
);
}
async function waitForAddPhoneReady(timeout = 20000) {
const start = Date.now();
while (Date.now() - start < timeout) {
@@ -414,6 +546,10 @@
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start))));
continue;
}
if (isPhoneVerificationPageReady()) {
return {
phoneVerificationPage: true,
@@ -448,18 +584,15 @@
async function submitPhoneNumber(payload = {}) {
const countryLabel = String(payload.countryLabel || '').trim();
if (!countryLabel) {
throw new Error('Missing country label for add-phone submission.');
}
const isExplicitInternational = isExplicitInternationalPhoneInput(payload.phoneNumber);
await waitForAddPhoneReady();
const countrySelected = await ensureCountrySelected(countryLabel);
const countrySelected = await ensureCountrySelected(countryLabel, payload.phoneNumber);
if (!countrySelected) {
throw new Error(`Failed to select "${countryLabel}" on the add-phone page.`);
throw new Error(`Failed to select "${countryLabel || 'target country'}" on the add-phone page.`);
}
const dialCode = getDisplayedDialCode();
if (!dialCode) {
if (!dialCode && !isExplicitInternational) {
throw new Error(`Could not determine the dial code for "${countryLabel}" on the add-phone page.`);
}
@@ -498,6 +631,10 @@
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start))));
continue;
}
const errorText = getVerificationErrorText();
if (errorText) {
@@ -565,13 +702,45 @@
fillInput(codeInput, code);
await sleep(250);
simulateClick(submitButton);
if (is405MethodNotAllowedPage()) {
await recoverPhoneRoute405(12000);
}
return waitForPhoneVerificationOutcome();
}
async function resendPhoneVerificationCode(timeout = 45000) {
if (activePhoneResendPromise) {
return activePhoneResendPromise;
}
activePhoneResendPromise = (async () => {
const start = Date.now();
const route405RecoveryStart = Date.now();
let route405RecoveryCount = 0;
const recoverRoute405WithinResend = async () => {
route405RecoveryCount += 1;
if (route405RecoveryCount > PHONE_RESEND_ROUTE_405_MAX_RECOVERIES) {
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend stayed on route-405 page after ${PHONE_RESEND_ROUTE_405_MAX_RECOVERIES} recovery round(s). URL: ${location.href}`
);
}
const recoveryBudgetLeft = PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS - (Date.now() - route405RecoveryStart);
if (recoveryBudgetLeft <= 0) {
throw new Error(
`${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend exceeded route-405 recovery budget (${PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS}ms). URL: ${location.href}`
);
}
const remainingTimeout = Math.max(1000, timeout - (Date.now() - start));
const recoveryTimeout = Math.max(1000, Math.min(12000, recoveryBudgetLeft, remainingTimeout));
await recoverPhoneRoute405(recoveryTimeout);
};
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
await recoverRoute405WithinResend();
continue;
}
const throttledText = getPhoneResendThrottleText();
if (throttledText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`);
@@ -581,6 +750,10 @@
await humanPause(250, 700);
simulateClick(resendButton);
await sleep(1000);
if (is405MethodNotAllowedPage()) {
await recoverRoute405WithinResend();
continue;
}
const afterClickThrottleText = getPhoneResendThrottleText();
if (afterClickThrottleText) {
throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`);
@@ -599,6 +772,11 @@
}
throw new Error('Timed out waiting for the phone verification resend button.');
})().finally(() => {
activePhoneResendPromise = null;
});
return activePhoneResendPromise;
}
async function returnToAddPhone(timeout = 20000) {
+5 -3
View File
@@ -1036,8 +1036,8 @@ const CONTINUE_ACTION_PATTERN = /继续|continue/i;
const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/i;
const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i;
const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i;
const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时/i;
const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405/i;
const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时|failed\s+to\s+fetch|network\s+error|fetch\s+failed/i;
const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i;
const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::';
const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i;
@@ -1536,10 +1536,11 @@ function getAuthTimeoutErrorPageState(options = {}) {
|| AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(document.title || '');
const detailMatched = AUTH_TIMEOUT_ERROR_DETAIL_PATTERN.test(text);
const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text);
const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text);
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
return null;
}
@@ -1551,6 +1552,7 @@ function getAuthTimeoutErrorPageState(options = {}) {
titleMatched,
detailMatched,
routeErrorMatched,
fetchFailedMatched,
maxCheckAttemptsBlocked,
userAlreadyExistsBlocked,
};
+72
View File
@@ -0,0 +1,72 @@
# 错误重试分层策略(步骤 4/8/9/自动运行)
本文档说明当前实现中的三层重试语义,避免把“当步重试 / 小循环重试 / 大循环重试”混在一起理解。
## 1. 第一层:当步重试(Step 内部)
定义:不离开当前步骤,在当前页面链路内恢复或替换资源。
典型位置:
- `background/steps/fetch-signup-code.js`(步骤 4 页面恢复)
- `background/steps/fetch-login-code.js` + `background/verification-flow.js`(步骤 8 邮箱轮询与重发)
- `background/phone-verification-flow.js`(步骤 9 号码轮换、重发、回到 add-phone)
常见动作:
- 点击 `Try again` 恢复 405/重试页;
- 同一步内重新拉验证码;
- 步骤 9 内直接换号(不立刻回步骤 7);
- 同提供商/同国家内换价格档位或换号码。
## 2. 第二层:小循环重试(授权链路重开)
定义:当前轮次内回到授权链路起点(通常步骤 7),再走 7→8→9。
触发来源:
- `background.js``getPostStep6AutoRestartDecision(...)`
- 步骤 8 检测到邮箱通信异常且当前链路连续重试超限;
- 步骤 9 触发 `PHONE_RESTART_STEP7` 类错误(例如回调窗口过期/链路不可恢复)。
特点:
- 还是同一“轮次”;
- 不进入线程间隔(20 分钟);
- 优先用于“流程状态错位/页面链路失效”类问题。
## 3. 第三层:大循环重试(自动运行 attempt/round
定义:由自动运行控制器决定是否进入下一次 attempt,或等待线程间隔后继续。
位置:
- `background/auto-run-controller.js`
关键点:
- `autoRunSkipFailures=true` 时,允许同轮多次 attempt
- 某些错误会被视为“本轮直接失败并跳过剩余重试”;
- 若配置了线程间隔,会出现“等待 N 分钟后第 x 次尝试”日志。
## 4. 错误分类优先级(当前实现)
1) 先做当步恢复(能在步骤内闭环就不升级)
2) 当步失败后,再判断是否走小循环(回步骤 7)
3) 小循环仍失败,才交给大循环(attempt / round / 线程间隔)
## 5. 已落实的关键规则(与近期问题对应)
- `Failed to fetch / network error / fetch failed`
步骤 4/8/9 进入有上限的网络重试(当前默认 3 次,冷却 12 秒)。
- `Step 9: 5sim check activation failed: order not found`
归类为“号码失效”,在步骤 9 内立即换号(当步重试),不应直接触发线程间隔。
- `Route Error 405`(手机号验证码页):
优先当步恢复;若落入 route-405 循环,步骤 9 内换号。
- `add-phone / phone-verification` 页面状态:
表示流程已进入手机链路,自动运行层默认不再盲目回步骤 7,避免破坏当前链路。
## 6. 关于“第几轮尝试 + 暂停”含义
- “第 x/y 轮第 n 次尝试”是自动运行控制器的外层 attempt 计数;
- “线程间隔:等待 20 分钟...”表示已经升级到第三层(大循环);
- 用户手动停止会中断当前计划;恢复时是否回到步骤 7,取决于当前页面状态与回调是否过期:
- 回调未过期且仍在可恢复链路:优先当前链路继续;
- 回调已过期或链路断裂:回步骤 7 重拉授权。
+85 -4
View File
@@ -13,7 +13,10 @@ function normalizeIpProxyService(value = '') {
const ipProxyActionState = {
busy: false,
action: '',
startedAt: 0,
};
const IP_PROXY_ACTION_LOCK_TIMEOUT_MS = 25000;
let ipProxyDeferredProbeTimer = 0;
const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded';
let ipProxySectionExpanded = false;
@@ -78,12 +81,14 @@ function getIpProxyActionState() {
return {
busy: Boolean(ipProxyActionState.busy),
action: normalizeIpProxyActionType(ipProxyActionState.action),
startedAt: Number(ipProxyActionState.startedAt) || 0,
};
}
function setIpProxyActionBusy(action = '', busy = false) {
ipProxyActionState.busy = Boolean(busy);
ipProxyActionState.action = ipProxyActionState.busy ? normalizeIpProxyActionType(action) : '';
ipProxyActionState.startedAt = ipProxyActionState.busy ? Date.now() : 0;
}
async function runIpProxyActionWithLock(action = '', runner) {
@@ -104,10 +109,23 @@ async function runIpProxyActionWithLock(action = '', runner) {
updateIpProxyUI(latestState);
}
const actionLabel = getIpProxyActionLabel(nextAction);
const timeoutMs = Math.max(5000, Number(IP_PROXY_ACTION_LOCK_TIMEOUT_MS) || 25000);
let timeoutId = 0;
try {
const value = await runner();
const value = await Promise.race([
Promise.resolve().then(() => runner()),
new Promise((_, reject) => {
timeoutId = globalThis.setTimeout(() => {
reject(new Error(`${actionLabel}超时(${Math.round(timeoutMs / 1000)} 秒),已自动解锁,请重试。`));
}, timeoutMs);
}),
]);
return { skipped: false, value };
} finally {
if (timeoutId) {
globalThis.clearTimeout(timeoutId);
}
setIpProxyActionBusy(nextAction, false);
if (typeof updateIpProxyUI === 'function') {
updateIpProxyUI(latestState);
@@ -115,6 +133,24 @@ async function runIpProxyActionWithLock(action = '', runner) {
}
}
function scheduleIpProxyExitProbe(options = {}) {
const {
silent = true,
delayMs = 80,
} = options;
const delay = Math.max(0, Number(delayMs) || 0);
if (ipProxyDeferredProbeTimer) {
globalThis.clearTimeout(ipProxyDeferredProbeTimer);
ipProxyDeferredProbeTimer = 0;
}
ipProxyDeferredProbeTimer = globalThis.setTimeout(() => {
ipProxyDeferredProbeTimer = 0;
Promise.resolve()
.then(() => probeIpProxyExit({ silent }))
.catch(() => {});
}, delay);
}
function normalizeIpProxyMode(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return SUPPORTED_IP_PROXY_MODES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_MODE;
@@ -210,6 +246,8 @@ function normalizeIpProxyServiceProfile(rawValue = {}) {
accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''),
accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''),
poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20),
autoSyncEnabled: Boolean(raw.autoSyncEnabled),
autoSyncIntervalMinutes: String(Math.max(1, Math.min(1440, Number.parseInt(String(raw.autoSyncIntervalMinutes ?? '').trim(), 10) || 15))),
host: String(raw.host || '').trim(),
port: String(normalizeIpProxyPort(raw.port || '') || ''),
protocol: normalizeIpProxyProtocol(raw.protocol),
@@ -227,6 +265,8 @@ function buildIpProxyServiceProfileFromFlatState(state = {}) {
accountSessionPrefix: state?.ipProxyAccountSessionPrefix,
accountLifeMinutes: state?.ipProxyAccountLifeMinutes,
poolTargetCount: state?.ipProxyPoolTargetCount,
autoSyncEnabled: state?.ipProxyAutoSyncEnabled,
autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes,
host: state?.ipProxyHost,
port: state?.ipProxyPort,
protocol: state?.ipProxyProtocol,
@@ -387,6 +427,8 @@ function buildCurrentIpProxyServiceProfileFromInputs() {
accountSessionPrefix: inputIpProxyAccountSessionPrefix?.value || '',
accountLifeMinutes: inputIpProxyAccountLifeMinutes?.value || '',
poolTargetCount: inputIpProxyPoolTargetCount?.value || '',
autoSyncEnabled: Boolean(inputIpProxyAutoSyncEnabled?.checked),
autoSyncIntervalMinutes: inputIpProxyAutoSyncIntervalMinutes?.value || '',
host: inputIpProxyHost?.value || '',
port: inputIpProxyPort?.value || '',
protocol: selectIpProxyProtocol?.value || '',
@@ -419,6 +461,8 @@ function buildIpProxyStatePatchFromServiceProfile(service = '', profile = {}) {
ipProxyAccountSessionPrefix: normalizedProfile.accountSessionPrefix,
ipProxyAccountLifeMinutes: normalizedProfile.accountLifeMinutes,
ipProxyPoolTargetCount: normalizedProfile.poolTargetCount,
ipProxyAutoSyncEnabled: normalizedProfile.autoSyncEnabled,
ipProxyAutoSyncIntervalMinutes: Number.parseInt(String(normalizedProfile.autoSyncIntervalMinutes || '15').trim(), 10) || 15,
ipProxyHost: normalizedProfile.host,
ipProxyPort: normalizedProfile.port,
ipProxyProtocol: normalizedProfile.protocol,
@@ -449,6 +493,12 @@ function applyIpProxyServiceProfileToInputs(profile = {}, options = {}) {
if (inputIpProxyPoolTargetCount) {
inputIpProxyPoolTargetCount.value = normalizedProfile.poolTargetCount;
}
if (inputIpProxyAutoSyncEnabled) {
inputIpProxyAutoSyncEnabled.checked = Boolean(normalizedProfile.autoSyncEnabled);
}
if (inputIpProxyAutoSyncIntervalMinutes) {
inputIpProxyAutoSyncIntervalMinutes.value = String(normalizedProfile.autoSyncIntervalMinutes || '15');
}
if (inputIpProxyHost) {
inputIpProxyHost.value = normalizedProfile.host;
}
@@ -1058,6 +1108,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
stateClass: 'state-idle',
text: '未启用,沿用浏览器默认/全局代理。',
details: '',
hideCurrentDisplay: false,
};
}
@@ -1073,6 +1124,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
stateClass: warningText ? 'state-warning' : 'state-applied',
text: `${statusText}${briefWarning}`,
details,
hideCurrentDisplay: true,
};
}
@@ -1082,6 +1134,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
stateClass: 'state-warning',
text: '已启用,但当前没有可用代理(已阻断直连)。',
details: errorText,
hideCurrentDisplay: false,
};
}
return {
@@ -1090,6 +1143,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
? '已启用,但账号模式没有可用代理。请先填写代理列表,或填写 Host/Port。已阻断所有网站直连。'
: '已启用,但当前没有可用代理。请先点击“拉取”获取 IP 列表。已阻断所有网站直连。',
details: '',
hideCurrentDisplay: false,
};
}
if (reason === 'proxy_api_unavailable') {
@@ -1097,6 +1151,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
stateClass: 'state-error',
text: '已启用,但当前浏览器不支持扩展代理 API,无法应用。',
details,
hideCurrentDisplay: false,
};
}
if (reason === 'apply_failed') {
@@ -1104,6 +1159,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
stateClass: 'state-error',
text: '已启用,但代理应用失败(已回退默认代理)。',
details: errorText || details,
hideCurrentDisplay: false,
};
}
if (reason === 'connectivity_failed') {
@@ -1112,6 +1168,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
stateClass: 'state-error',
text: `${prefix};连通性失败,请切换节点或重试。`,
details: errorText || details,
hideCurrentDisplay: true,
};
}
@@ -1121,6 +1178,7 @@ function formatIpProxyRuntimeStatus(state = latestState) {
? `已启用,等待生效:${endpointWithRegion}${authSuffix}`
: '已启用,等待拉取并应用代理。',
details,
hideCurrentDisplay: false,
};
}
@@ -1137,6 +1195,10 @@ function setIpProxyRuntimeStatusDisplay(status = {}) {
if (ipProxyRuntimeDot) {
ipProxyRuntimeDot.title = text;
}
const runtimeMeta = ipProxyCurrent?.closest?.('.ip-proxy-runtime-meta') || null;
if (runtimeMeta) {
runtimeMeta.style.display = status?.hideCurrentDisplay ? 'none' : '';
}
const detailsText = String(status?.details || '').trim();
if (ipProxyRuntimeDetails && ipProxyRuntimeDetailsText) {
if (detailsText) {
@@ -1241,6 +1303,12 @@ function updateIpProxyUI(state = latestState) {
if (rowIpProxyPoolTargetCount) {
rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyAutoSyncEnabled) {
rowIpProxyAutoSyncEnabled.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyAutoSyncInterval) {
rowIpProxyAutoSyncInterval.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyHost) {
rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none';
}
@@ -1334,6 +1402,16 @@ function updateIpProxyUI(state = latestState) {
if (inputIpProxyAccountList) {
inputIpProxyAccountList.disabled = !enabled || !isAccountMode || !accountListAvailable;
}
if (inputIpProxyAutoSyncEnabled) {
inputIpProxyAutoSyncEnabled.disabled = !enabled;
}
if (inputIpProxyAutoSyncIntervalMinutes) {
const autoSyncEnabled = Boolean(inputIpProxyAutoSyncEnabled?.checked);
if (!Number.isFinite(Number.parseInt(String(inputIpProxyAutoSyncIntervalMinutes.value || '').trim(), 10))) {
inputIpProxyAutoSyncIntervalMinutes.value = '15';
}
inputIpProxyAutoSyncIntervalMinutes.disabled = !enabled || !autoSyncEnabled;
}
const runtimeStatus = formatIpProxyRuntimeStatus(runtimeState);
setIpProxyRuntimeStatusDisplay(runtimeStatus);
@@ -1395,6 +1473,7 @@ async function refreshIpProxyPoolByApi(options = {}) {
source: 'sidepanel',
payload: {
mode,
skipExitProbe: true,
},
});
if (response?.error) {
@@ -1425,7 +1504,7 @@ async function refreshIpProxyPoolByApi(options = {}) {
syncLatestState(patch);
}
updateIpProxyUI(latestState);
await probeIpProxyExit({ silent: true }).catch(() => {});
scheduleIpProxyExitProbe({ silent: true });
if (!silent) {
if (mode === 'account') {
@@ -1447,6 +1526,7 @@ async function switchIpProxyToNext(options = {}) {
direction: 'next',
mode,
forceRefresh: false,
skipExitProbe: true,
},
});
if (response?.error) {
@@ -1476,7 +1556,7 @@ async function switchIpProxyToNext(options = {}) {
syncLatestState(patch);
}
updateIpProxyUI(latestState);
await probeIpProxyExit({ silent: true }).catch(() => {});
scheduleIpProxyExitProbe({ silent: true });
if (!silent) {
showToast(`已切换代理:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800);
}
@@ -1491,6 +1571,7 @@ async function changeIpProxyExitBySession(options = {}) {
source: 'sidepanel',
payload: {
mode,
skipExitProbe: true,
},
});
if (response?.error) {
@@ -1520,7 +1601,7 @@ async function changeIpProxyExitBySession(options = {}) {
syncLatestState(patch);
}
updateIpProxyUI(latestState);
await probeIpProxyExit({ silent: true }).catch(() => {});
scheduleIpProxyExitProbe({ silent: true });
if (!silent) {
showToast(`已执行 Change${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800);
}
+61
View File
@@ -1161,6 +1161,59 @@ header {
flex: 1;
}
.country-order-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-base);
color: var(--text-primary);
font-size: 11px;
line-height: 1.2;
}
.country-order-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.country-order-chip-label {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.country-order-remove {
border: none;
background: transparent;
color: var(--text-muted);
width: 16px;
height: 16px;
border-radius: 999px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.country-order-remove:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.country-order-separator {
color: var(--text-muted);
font-size: 11px;
}
.data-value.has-value { color: var(--text-primary); }
.mono {
@@ -2114,6 +2167,14 @@ header {
min-width: 0;
}
.hero-sms-runtime-select {
flex: 1 1 auto;
min-width: 0;
width: 100%;
height: 33px;
min-height: 33px;
}
.hero-sms-price-preview-stack {
width: 100%;
display: flex;
+479 -325
View File
@@ -420,331 +420,6 @@
</div>
</div>
</div>
<div id="phone-verification-section" class="data-card phone-verification-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">接码设置</span>
<span class="data-value">手机号验证与 HeroSMS 获取策略</span>
</div>
<div id="row-phone-verification-enabled" class="section-mini-actions phone-verification-header-actions">
<button id="btn-toggle-phone-verification-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-phone-verification-fold">展开设置</button>
<label class="toggle-switch" for="input-phone-verification-enabled" title="启用或禁用手机号接码流程">
<input type="checkbox" id="input-phone-verification-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
<div class="data-row phone-verification-fold-row" id="row-phone-verification-fold" style="display:none;">
<div id="phone-verification-fold" class="phone-verification-fold">
<div class="phone-verification-fold-body">
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
<span class="data-label">同步服务</span>
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
placeholder="http://127.0.0.1:17373" />
</div>
<div class="data-row" id="row-hero-sms-platform" style="display:none;">
<span class="data-label">接码平台</span>
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
</div>
<div class="data-row" id="row-hero-sms-country" style="display:none;">
<span class="data-label">国家优先级</span>
<div class="data-inline hero-sms-country-stack">
<select id="select-hero-sms-country" class="data-input mono" multiple size="6" style="display:none;">
<option value="52" selected>Thailand</option>
</select>
<div class="hero-sms-country-mainline">
<div id="hero-sms-country-menu-shell" class="hero-sms-country-menu">
<button id="btn-hero-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
Thailand (1/3)
</button>
<div id="hero-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
</div>
</div>
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
</div>
</div>
<div class="data-row" id="row-hero-sms-country-fallback" style="display:none;">
<span class="data-label">生效顺序</span>
<div class="data-inline data-value-actions">
<span id="display-hero-sms-country-fallback-order" class="data-value data-value-fill mono">Thailand(52)</span>
<button id="btn-hero-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
</div>
</div>
<div class="data-row" id="row-hero-sms-acquire-priority" style="display:none;">
<span class="data-label">拿号优先级</span>
<select id="select-hero-sms-acquire-priority" class="data-input mono">
<option value="country">国家优先(默认)</option>
<option value="price">价格优先(同价按国家顺序)</option>
</select>
</div>
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
<span class="data-label">接码 API</span>
<div class="input-with-icon">
<input type="password" id="input-hero-sms-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 HeroSMS API Key" />
<button id="btn-toggle-hero-sms-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-hero-sms-api-key" data-show-label="显示 HeroSMS API Key"
data-hide-label="隐藏 HeroSMS API Key" aria-label="显示 HeroSMS API Key" title="显示 HeroSMS API Key"></button>
</div>
</div>
<div class="data-row" id="row-hero-sms-max-price" style="display:none;">
<span class="data-label">价格</span>
<div class="data-inline hero-sms-price-preview-stack">
<div class="hero-sms-price-preview-head">
<button id="btn-hero-sms-price-preview" class="btn btn-outline btn-xs data-inline-btn" type="button">查询价格</button>
</div>
<div id="row-hero-sms-price-tiers" class="hero-sms-price-preview-result" style="display:none;">
<span id="display-hero-sms-price-tiers" class="data-value mono hero-sms-price-preview-text">未获取</span>
</div>
<div class="hero-sms-price-controls-grid">
<div class="hero-sms-price-control">
<span class="hero-sms-settings-caption">价格上限</span>
<div class="setting-controls">
<input type="number" id="input-hero-sms-max-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="0.12" min="0" step="0.0001" title="接码价格上限;可空(空=自动价格)" />
</div>
</div>
<div class="hero-sms-price-control hero-sms-price-control-reuse">
<span class="hero-sms-settings-caption">号码复用</span>
<div class="setting-controls hero-sms-toggle-controls">
<label class="toggle-switch hero-sms-price-reuse-toggle" for="input-hero-sms-reuse-enabled" title="开启后会优先复用未超次数的可用号码">
<input type="checkbox" id="input-hero-sms-reuse-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-phone-code-settings-group" style="display:none;">
<span class="data-label">接码参数</span>
<div class="data-inline hero-sms-settings-grid">
<div id="row-phone-verification-resend-count" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">验证码重发</span>
<div class="setting-controls">
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-replacement-limit" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">换号上限</span>
<div class="setting-controls">
<input type="number" id="input-phone-replacement-limit" class="data-input auto-delay-input" value="3" min="1" max="20" step="1" title="步骤 9 内部允许更换号码的最大次数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-wait-seconds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">验证码限时</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-wait-seconds" class="data-input auto-delay-input" value="60" min="15" max="300" step="1" title="每轮等待验证码的秒数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-timeout-windows" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">超时次数</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-timeout-windows" class="data-input auto-delay-input" value="2" min="1" max="10" step="1" title="验证码超时后,最多继续等待几轮再换号" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-poll-interval-seconds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">轮询间隔</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-poll-interval-seconds" class="data-input auto-delay-input" value="5" min="1" max="30" step="1" title="向 HeroSMS 查询验证码状态的间隔秒数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-poll-max-rounds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">轮询次数</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-poll-max-rounds" class="data-input auto-delay-input" value="4" min="1" max="120" step="1" title="每轮验证码等待窗口最多轮询次数" />
<span class="data-unit"></span>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-hero-sms-runtime-pair" style="display:none;">
<span class="data-label">运行状态</span>
<div class="data-inline hero-sms-runtime-grid">
<div id="row-hero-sms-current-number" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">当前分配</span>
<span id="display-hero-sms-current-number" class="data-value mono hero-sms-runtime-value">未分配</span>
</div>
<div id="row-hero-sms-current-code" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">验证码</span>
<span id="display-hero-sms-current-code" class="data-value mono hero-sms-runtime-value">未获取</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="ip-proxy-section" class="data-card ip-proxy-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">IP 代理</span>
<span class="data-value">用于浏览器代理接管与出口切换</span>
</div>
<div id="row-ip-proxy-enabled" class="section-mini-actions ip-proxy-header-actions">
<button id="btn-toggle-ip-proxy-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-ip-proxy-fold">展开设置</button>
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
<input type="checkbox" id="input-ip-proxy-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
<div id="ip-proxy-fold" class="ip-proxy-fold">
<div class="ip-proxy-fold-body">
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
<span class="data-label">代理服务</span>
<div class="data-inline">
<select id="select-ip-proxy-service" class="data-select" disabled>
<option value="711proxy">711Proxy(首版)</option>
</select>
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
注册
</button>
</div>
</div>
<div class="data-row" id="row-ip-proxy-mode" style="display:none;">
<span class="data-label">代理模式</span>
<div id="ip-proxy-mode-group" class="choice-group" role="group" aria-label="IP代理模式">
<button type="button" class="choice-btn" data-ip-proxy-mode="account">账号密码</button>
<button type="button" class="choice-btn" data-ip-proxy-mode="api" disabled title="首版暂未开放">API 拉取(暂未开放)</button>
</div>
</div>
<div class="data-row ip-proxy-layout-row" id="row-ip-proxy-layout" style="display:none;">
<div class="ip-proxy-layout" id="ip-proxy-layout">
<div class="ip-proxy-column ip-proxy-column-account" id="ip-proxy-account-panel">
<div class="ip-proxy-column-header">账号密码模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-list" style="display:none;">
<span class="data-label">账号代理列表</span>
<textarea id="input-ip-proxy-account-list" class="data-textarea mono"
placeholder="每行一条:host:port 或 host:port:username:password&#10;例如 global.rotgb.711proxy.com:10000:username:password"></textarea>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-session-prefix" style="display:none;">
<span class="data-label">会话(session)</span>
<input type="text" id="input-ip-proxy-account-session-prefix" class="data-input mono"
placeholder="会话前缀,例如 ZC28qZ0KQL" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-life-minutes" style="display:none;">
<span class="data-label">时长(life)</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-account-life-minutes" class="data-input" min="1" max="1440" step="1"
placeholder="5-180(分钟)" title="711Proxy 会话时长范围:5-180 分钟" />
<span class="data-unit">分钟</span>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-host" style="display:none;">
<span class="data-label">代理 Host</span>
<input type="text" id="input-ip-proxy-host" class="data-input" placeholder="例如 global.rotgb.711proxy.com" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-port" style="display:none;">
<span class="data-label">代理 Port</span>
<input type="number" id="input-ip-proxy-port" class="data-input" min="1" max="65535" step="1"
placeholder="例如 10000" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-protocol" style="display:none;">
<span class="data-label">代理协议</span>
<select id="select-ip-proxy-protocol" class="data-select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-username" style="display:none;">
<span class="data-label">代理账号</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-username" class="data-input data-input-with-icon"
placeholder="例如 USER047152-zone-custom-region-US-asn-ASN81" />
<button id="btn-toggle-ip-proxy-username" class="input-icon-btn" type="button" aria-label="显示代理账号"
title="显示代理账号"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-password" style="display:none;">
<span class="data-label">代理密码</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-password" class="data-input data-input-with-icon"
placeholder="账号密码代理的密码" />
<button id="btn-toggle-ip-proxy-password" class="input-icon-btn" type="button" aria-label="显示代理密码"
title="显示代理密码"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-region" style="display:none;">
<span class="data-label">地区参数</span>
<input type="text" id="input-ip-proxy-region" class="data-input" placeholder="可选,例如 US / DE / HK" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-pool-target-count" style="display:none;">
<span class="data-label">任务切换阈值</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-pool-target-count" class="data-input" min="1" max="500" step="1"
placeholder="20(成功轮次)" title="每成功多少轮任务后自动切换一次代理;仅 1 条节点时会跳过自动切换" />
<span class="data-unit"></span>
</div>
</div>
</div>
<div class="ip-proxy-column ip-proxy-column-api" id="ip-proxy-api-panel">
<div class="ip-proxy-column-header">API 模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-api-url" style="display:none;">
<span class="data-label">代理API</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-api-url" class="data-input data-input-with-icon"
placeholder="粘贴完整 API 链接" />
<button id="btn-toggle-ip-proxy-api-url" class="input-icon-btn" type="button" aria-label="显示代理 API"
title="显示代理 API"></button>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-ip-proxy-actions" style="display:none;">
<span class="data-label">切换代理</span>
<div class="data-inline ip-proxy-actions-inline">
<span id="ip-proxy-action-buttons" class="ip-proxy-action-grid">
<button id="btn-ip-proxy-refresh" class="btn btn-outline btn-sm data-inline-btn" type="button">拉取</button>
<button id="btn-ip-proxy-next" class="btn btn-outline btn-sm data-inline-btn" type="button">下一条</button>
<button id="btn-ip-proxy-change" class="btn btn-outline btn-sm data-inline-btn" type="button" title="保持当前会话并刷新出口(仅 711 + session">Change</button>
<button id="btn-ip-proxy-probe" class="btn btn-outline btn-sm data-inline-btn" type="button">检测出口</button>
</span>
<span id="ip-proxy-action-hint" class="ip-proxy-action-hint">
下一条:切到代理池下一条。Change:保持当前 session 重绑链路(仅 711 + session)。
</span>
</div>
</div>
<div class="data-row" id="row-ip-proxy-runtime-status" style="display:none;">
<span class="data-label">代理状态</span>
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status state-idle">
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
<div class="ip-proxy-runtime-content">
<div id="ip-proxy-runtime-text" class="ip-proxy-runtime-main">未启用,沿用浏览器默认/全局代理。</div>
<div class="ip-proxy-runtime-meta">
<span id="ip-proxy-current" class="ip-proxy-runtime-current">暂无可用代理</span>
</div>
<div class="ip-proxy-runtime-details-row">
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
<summary>详情</summary>
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
</details>
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs ip-proxy-check-ip-btn" type="button">检查IP</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="cloudflare-temp-email-section" class="data-card hotmail-card" style="display:none;">
<div class="section-mini-header">
<div class="section-mini-copy">
@@ -1103,6 +778,485 @@
<div id="icloud-list" class="icloud-list"></div>
</div>
</div>
<div id="ip-proxy-section" class="data-card ip-proxy-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">IP 代理</span>
<span class="data-value">用于浏览器代理接管与出口切换</span>
</div>
<div id="row-ip-proxy-enabled" class="section-mini-actions ip-proxy-header-actions">
<button id="btn-toggle-ip-proxy-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-ip-proxy-fold">展开设置</button>
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
<input type="checkbox" id="input-ip-proxy-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
<div id="ip-proxy-fold" class="ip-proxy-fold">
<div class="ip-proxy-fold-body">
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
<span class="data-label">代理服务</span>
<div class="data-inline">
<select id="select-ip-proxy-service" class="data-select" disabled>
<option value="711proxy">711Proxy(首版)</option>
</select>
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
注册
</button>
</div>
</div>
<div class="data-row" id="row-ip-proxy-mode" style="display:none;">
<span class="data-label">代理模式</span>
<div id="ip-proxy-mode-group" class="choice-group" role="group" aria-label="IP代理模式">
<button type="button" class="choice-btn" data-ip-proxy-mode="account">账号密码</button>
<button type="button" class="choice-btn" data-ip-proxy-mode="api" disabled title="首版暂未开放">API 拉取(暂未开放)</button>
</div>
</div>
<div class="data-row ip-proxy-layout-row" id="row-ip-proxy-layout" style="display:none;">
<div class="ip-proxy-layout" id="ip-proxy-layout">
<div class="ip-proxy-column ip-proxy-column-account" id="ip-proxy-account-panel">
<div class="ip-proxy-column-header">账号密码模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-list" style="display:none;">
<span class="data-label">账号代理列表</span>
<textarea id="input-ip-proxy-account-list" class="data-textarea mono"
placeholder="每行一条:host:port 或 host:port:username:password&#10;例如 global.rotgb.711proxy.com:10000:username:password"></textarea>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-session-prefix" style="display:none;">
<span class="data-label">会话(session)</span>
<input type="text" id="input-ip-proxy-account-session-prefix" class="data-input mono"
placeholder="会话前缀,例如 ZC28qZ0KQL" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-life-minutes" style="display:none;">
<span class="data-label">时长(life)</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-account-life-minutes" class="data-input" min="1" max="1440" step="1"
placeholder="5-180(分钟)" title="711Proxy 会话时长范围:5-180 分钟" />
<span class="data-unit">分钟</span>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-host" style="display:none;">
<span class="data-label">代理 Host</span>
<input type="text" id="input-ip-proxy-host" class="data-input" placeholder="例如 global.rotgb.711proxy.com" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-port" style="display:none;">
<span class="data-label">代理 Port</span>
<input type="number" id="input-ip-proxy-port" class="data-input" min="1" max="65535" step="1"
placeholder="例如 10000" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-protocol" style="display:none;">
<span class="data-label">代理协议</span>
<select id="select-ip-proxy-protocol" class="data-select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-username" style="display:none;">
<span class="data-label">代理账号</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-username" class="data-input data-input-with-icon"
placeholder="例如 USER047152-zone-custom-region-US-asn-ASN81" />
<button id="btn-toggle-ip-proxy-username" class="input-icon-btn" type="button" aria-label="显示代理账号"
title="显示代理账号"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-password" style="display:none;">
<span class="data-label">代理密码</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-password" class="data-input data-input-with-icon"
placeholder="账号密码代理的密码" />
<button id="btn-toggle-ip-proxy-password" class="input-icon-btn" type="button" aria-label="显示代理密码"
title="显示代理密码"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-region" style="display:none;">
<span class="data-label">地区参数</span>
<input type="text" id="input-ip-proxy-region" class="data-input" placeholder="可选,例如 US / DE / HK" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-pool-target-count" style="display:none;">
<span class="data-label">任务切换阈值</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-pool-target-count" class="data-input" min="1" max="500" step="1"
placeholder="20(成功轮次)" title="每成功多少轮任务后自动切换一次代理;仅 1 条节点时会跳过自动切换" />
<span class="data-unit"></span>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-auto-sync-enabled" style="display:none;">
<span class="data-label">自动同步</span>
<div class="data-inline">
<label class="toggle-switch" for="input-ip-proxy-auto-sync-enabled" title="启用后会按间隔自动执行一次同步代理">
<input type="checkbox" id="input-ip-proxy-auto-sync-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
<span class="data-value">定时刷新代理,降低账号凭据过期导致的中断概率</span>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-auto-sync-interval" style="display:none;">
<span class="data-label">同步间隔</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-auto-sync-interval-minutes" class="data-input" min="1" max="1440" step="1"
placeholder="15" title="自动同步间隔(分钟)" />
<span class="data-unit">分钟</span>
</div>
</div>
</div>
<div class="ip-proxy-column ip-proxy-column-api" id="ip-proxy-api-panel">
<div class="ip-proxy-column-header">API 模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-api-url" style="display:none;">
<span class="data-label">代理API</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-api-url" class="data-input data-input-with-icon"
placeholder="粘贴完整 API 链接" />
<button id="btn-toggle-ip-proxy-api-url" class="input-icon-btn" type="button" aria-label="显示代理 API"
title="显示代理 API"></button>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-ip-proxy-actions" style="display:none;">
<span class="data-label">切换代理</span>
<div class="data-inline ip-proxy-actions-inline">
<span id="ip-proxy-action-buttons" class="ip-proxy-action-grid">
<button id="btn-ip-proxy-refresh" class="btn btn-outline btn-sm data-inline-btn" type="button">拉取</button>
<button id="btn-ip-proxy-next" class="btn btn-outline btn-sm data-inline-btn" type="button">下一条</button>
<button id="btn-ip-proxy-change" class="btn btn-outline btn-sm data-inline-btn" type="button" title="保持当前会话并刷新出口(仅 711 + session">Change</button>
<button id="btn-ip-proxy-probe" class="btn btn-outline btn-sm data-inline-btn" type="button">检测出口</button>
</span>
<span id="ip-proxy-action-hint" class="ip-proxy-action-hint">
下一条:切到代理池下一条。Change:保持当前 session 重绑链路(仅 711 + session)。
</span>
</div>
</div>
<div class="data-row" id="row-ip-proxy-runtime-status" style="display:none;">
<span class="data-label">代理状态</span>
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status state-idle">
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
<div class="ip-proxy-runtime-content">
<div id="ip-proxy-runtime-text" class="ip-proxy-runtime-main">未启用,沿用浏览器默认/全局代理。</div>
<div class="ip-proxy-runtime-meta">
<span id="ip-proxy-current" class="ip-proxy-runtime-current">暂无可用代理</span>
</div>
<div class="ip-proxy-runtime-details-row">
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
<summary>详情</summary>
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
</details>
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs ip-proxy-check-ip-btn" type="button">检查IP</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="phone-verification-section" class="data-card phone-verification-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">接码设置</span>
<span class="data-value">手机号验证与接码服务商获取策略</span>
</div>
<div id="row-phone-verification-enabled" class="section-mini-actions phone-verification-header-actions">
<button id="btn-toggle-phone-verification-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-phone-verification-fold">展开设置</button>
<label class="toggle-switch" for="input-phone-verification-enabled" title="启用或禁用手机号接码流程">
<input type="checkbox" id="input-phone-verification-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
<div class="data-row phone-verification-fold-row" id="row-phone-verification-fold" style="display:none;">
<div id="phone-verification-fold" class="phone-verification-fold">
<div class="phone-verification-fold-body">
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
<span class="data-label">同步服务</span>
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
placeholder="http://127.0.0.1:17373" />
</div>
<div class="data-row" id="row-phone-sms-provider" style="display:none;">
<span class="data-label">接码服务商</span>
<select id="select-phone-sms-provider" class="data-select mono">
<option value="hero-sms">HeroSMS(原有)</option>
<option value="5sim">5sim(新增)</option>
<option value="nexsms">NexSMS(新增)</option>
</select>
</div>
<div class="data-row" id="row-phone-sms-provider-order" style="display:none;">
<span class="data-label">服务商顺序</span>
<div class="data-inline hero-sms-country-stack">
<select id="select-phone-sms-provider-order" class="data-input mono" multiple size="3" style="display:none;">
<option value="hero-sms" selected>HeroSMS</option>
<option value="5sim" selected>5sim</option>
<option value="nexsms" selected>NexSMS</option>
</select>
<div class="hero-sms-country-mainline">
<div id="phone-sms-provider-order-menu-shell" class="hero-sms-country-menu">
<button id="btn-phone-sms-provider-order-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
HeroSMS / 5sim / NexSMS (3/3)
</button>
<div id="phone-sms-provider-order-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
</div>
</div>
<span class="data-value hero-sms-country-note">第一位为主服务商,后续按顺序自动回退。</span>
</div>
</div>
<div class="data-row" id="row-phone-sms-provider-order-actions" style="display:none;">
<span class="data-label">顺序管理</span>
<div class="data-inline data-value-actions">
<span id="display-phone-sms-provider-order" class="data-value data-value-fill mono country-order-list">未设置</span>
<button id="btn-phone-sms-provider-order-reset" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
</div>
</div>
<div class="data-row" id="row-hero-sms-platform" style="display:none;">
<span class="data-label">接码平台</span>
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
</div>
<div class="data-row" id="row-hero-sms-country" style="display:none;">
<span class="data-label">国家优先级</span>
<div class="data-inline hero-sms-country-stack">
<select id="select-hero-sms-country" class="data-input mono" multiple size="6" style="display:none;">
<option value="52" selected>Thailand</option>
</select>
<div class="hero-sms-country-mainline">
<div id="hero-sms-country-menu-shell" class="hero-sms-country-menu">
<button id="btn-hero-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
Thailand (1/3)
</button>
<div id="hero-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
</div>
</div>
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
</div>
</div>
<div class="data-row" id="row-hero-sms-country-fallback" style="display:none;">
<span class="data-label">生效顺序</span>
<div class="data-inline data-value-actions">
<span id="display-hero-sms-country-fallback-order" class="data-value data-value-fill mono country-order-list">未设置</span>
<button id="btn-hero-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
</div>
</div>
<div class="data-row" id="row-hero-sms-acquire-priority" style="display:none;">
<span class="data-label">拿号优先级</span>
<select id="select-hero-sms-acquire-priority" class="data-input mono">
<option value="country">国家优先(默认)</option>
<option value="price">低价优先(同价按国家顺序)</option>
<option value="price_high">高价优先(同价按国家顺序)</option>
</select>
</div>
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
<span class="data-label">接码 API</span>
<div class="input-with-icon">
<input type="password" id="input-hero-sms-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 HeroSMS API Key" />
<button id="btn-toggle-hero-sms-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-hero-sms-api-key" data-show-label="显示 HeroSMS API Key"
data-hide-label="隐藏 HeroSMS API Key" aria-label="显示 HeroSMS API Key" title="显示 HeroSMS API Key"></button>
</div>
</div>
<div class="data-row" id="row-five-sim-api-key" style="display:none;">
<span class="data-label">5sim API</span>
<div class="input-with-icon">
<input type="password" id="input-five-sim-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 5sim API KeyBearer Token" />
<button id="btn-toggle-five-sim-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-five-sim-api-key" data-show-label="显示 5sim API Key"
data-hide-label="隐藏 5sim API Key" aria-label="显示 5sim API Key" title="显示 5sim API Key"></button>
</div>
</div>
<div class="data-row" id="row-five-sim-country" style="display:none;">
<span class="data-label">国家优先级</span>
<div class="data-inline hero-sms-country-stack">
<select id="select-five-sim-country" class="data-input mono" multiple size="6" style="display:none;">
<option value="thailand" selected>泰国 (Thailand) [TH]</option>
</select>
<div class="hero-sms-country-mainline">
<div id="five-sim-country-menu-shell" class="hero-sms-country-menu">
<button id="btn-five-sim-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
未选择 (0/3)
</button>
<div id="five-sim-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
</div>
</div>
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
</div>
</div>
<div class="data-row" id="row-five-sim-country-fallback" style="display:none;">
<span class="data-label">生效顺序</span>
<div class="data-inline data-value-actions">
<span id="display-five-sim-country-fallback-order" class="data-value data-value-fill mono country-order-list">未设置</span>
<button id="btn-five-sim-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
</div>
</div>
<div class="data-row" id="row-five-sim-operator" style="display:none;">
<span class="data-label">运营商</span>
<input type="text" id="input-five-sim-operator" class="data-input mono"
placeholder="any" />
</div>
<div class="data-row" id="row-five-sim-product" style="display:none;">
<span class="data-label">产品代码</span>
<input type="text" id="input-five-sim-product" class="data-input mono"
placeholder="openai" />
</div>
<div class="data-row" id="row-nex-sms-api-key" style="display:none;">
<span class="data-label">NexSMS API</span>
<div class="input-with-icon">
<input type="password" id="input-nex-sms-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 NexSMS API Key" />
<button id="btn-toggle-nex-sms-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-nex-sms-api-key" data-show-label="显示 NexSMS API Key"
data-hide-label="隐藏 NexSMS API Key" aria-label="显示 NexSMS API Key" title="显示 NexSMS API Key"></button>
</div>
</div>
<div class="data-row" id="row-nex-sms-country" style="display:none;">
<span class="data-label">国家优先级</span>
<div class="data-inline hero-sms-country-stack">
<select id="select-nex-sms-country" class="data-input mono" multiple size="6" style="display:none;">
<option value="1" selected>Country #1</option>
</select>
<div class="hero-sms-country-mainline">
<div id="nex-sms-country-menu-shell" class="hero-sms-country-menu">
<button id="btn-nex-sms-country-menu" class="btn btn-outline btn-sm hero-sms-country-menu-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
Country #1 (1/3)
</button>
<div id="nex-sms-country-menu" class="hero-sms-country-menu-dropdown" role="listbox" aria-multiselectable="true" hidden></div>
</div>
</div>
<span class="data-value hero-sms-country-note">多选最多 3 个,按点击顺序生效。</span>
</div>
</div>
<div class="data-row" id="row-nex-sms-country-fallback" style="display:none;">
<span class="data-label">生效顺序</span>
<div class="data-inline data-value-actions">
<span id="display-nex-sms-country-fallback-order" class="data-value data-value-fill mono country-order-list">未设置</span>
<button id="btn-nex-sms-country-clear" class="btn btn-ghost btn-xs data-inline-btn" type="button">清空</button>
</div>
</div>
<div class="data-row" id="row-nex-sms-service-code" style="display:none;">
<span class="data-label">服务代码</span>
<input type="text" id="input-nex-sms-service-code" class="data-input mono"
placeholder="ot" />
</div>
<div class="data-row" id="row-hero-sms-max-price" style="display:none;">
<span class="data-label">价格</span>
<div class="data-inline hero-sms-price-preview-stack">
<div class="hero-sms-price-preview-head">
<button id="btn-hero-sms-price-preview" class="btn btn-outline btn-xs data-inline-btn" type="button">查询价格</button>
</div>
<div id="row-hero-sms-price-tiers" class="hero-sms-price-preview-result" style="display:none;">
<span id="display-hero-sms-price-tiers" class="data-value mono hero-sms-price-preview-text">未获取</span>
</div>
<div class="hero-sms-price-controls-grid">
<div class="hero-sms-price-control">
<span class="hero-sms-settings-caption">价格上限</span>
<div class="setting-controls">
<input type="number" id="input-hero-sms-max-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="0.12" min="0" step="0.0001" title="接码价格上限;可空(空=自动价格)" />
</div>
</div>
<div class="hero-sms-price-control">
<span class="hero-sms-settings-caption">指定档位</span>
<div class="setting-controls">
<input type="number" id="input-hero-sms-preferred-price" class="data-input auto-delay-input mono hero-sms-max-price-input" placeholder="例如 0.0512(可空)" min="0" step="0.0001" title="优先尝试该价格档位;可空(空=按优先级策略)" />
</div>
</div>
<div class="hero-sms-price-control hero-sms-price-control-reuse">
<span class="hero-sms-settings-caption">号码复用</span>
<div class="setting-controls hero-sms-toggle-controls">
<label class="toggle-switch hero-sms-price-reuse-toggle" for="input-hero-sms-reuse-enabled" title="开启后会优先复用未超次数的可用号码">
<input type="checkbox" id="input-hero-sms-reuse-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-phone-code-settings-group" style="display:none;">
<span class="data-label">接码参数</span>
<div class="data-inline hero-sms-settings-grid">
<div id="row-phone-verification-resend-count" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">验证码重发</span>
<div class="setting-controls">
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-replacement-limit" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">换号上限</span>
<div class="setting-controls">
<input type="number" id="input-phone-replacement-limit" class="data-input auto-delay-input" value="3" min="1" max="20" step="1" title="步骤 9 内部允许更换号码的最大次数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-wait-seconds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">验证码限时</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-wait-seconds" class="data-input auto-delay-input" value="60" min="15" max="300" step="1" title="每轮等待验证码的秒数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-timeout-windows" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">超时次数</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-timeout-windows" class="data-input auto-delay-input" value="2" min="1" max="10" step="1" title="验证码超时后,最多继续等待几轮再换号" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-poll-interval-seconds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">轮询间隔</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-poll-interval-seconds" class="data-input auto-delay-input" value="5" min="1" max="30" step="1" title="向 HeroSMS 查询验证码状态的间隔秒数" />
<span class="data-unit"></span>
</div>
</div>
<div id="row-phone-code-poll-max-rounds" class="hero-sms-settings-cell" style="display:none;">
<span class="hero-sms-settings-caption">轮询次数</span>
<div class="setting-controls">
<input type="number" id="input-phone-code-poll-max-rounds" class="data-input auto-delay-input" value="4" min="1" max="120" step="1" title="每轮验证码等待窗口最多轮询次数" />
<span class="data-unit"></span>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-hero-sms-runtime-pair" style="display:none;">
<span class="data-label">运行状态</span>
<div class="data-inline hero-sms-runtime-grid">
<div id="row-hero-sms-current-number" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">当前分配</span>
<span id="display-hero-sms-current-number" class="data-value mono hero-sms-runtime-value">未分配</span>
</div>
<div id="row-hero-sms-current-countdown" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">倒计时</span>
<span id="display-hero-sms-current-countdown" class="data-value mono hero-sms-runtime-value">未启动</span>
</div>
<div id="row-hero-sms-current-code" class="hero-sms-runtime-cell" style="display:none;">
<span class="hero-sms-runtime-key">验证码</span>
<span id="display-hero-sms-current-code" class="data-value mono hero-sms-runtime-value">未获取</span>
</div>
<div id="row-hero-sms-preferred-activation" class="hero-sms-runtime-cell hero-sms-runtime-cell-span2" style="display:none;">
<span class="hero-sms-runtime-key">优先号码</span>
<select id="select-hero-sms-preferred-activation" class="data-input mono hero-sms-runtime-select">
<option value="">自动(先复用已有可用号,再创建新号)</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="status-bar" class="status-bar">
<div class="status-dot"></div>
<span id="display-status">就绪</span>
+3400 -145
View File
File diff suppressed because it is too large Load Diff
+37 -2
View File
@@ -42,7 +42,7 @@ function createRecoveryApi(state) {
isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
isVisibleElement: () => true,
log: () => {},
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405/i,
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i,
simulateClick: () => {
state.clickCount += 1;
if (typeof state.onClick === 'function') {
@@ -103,6 +103,42 @@ test('auth page recovery detects route error retry page on email verification ro
assert.equal(snapshot.routeErrorMatched, true);
});
test('auth page recovery detects email route missing-action retry page text', () => {
const state = {
clickCount: 0,
pageText: "Error: You made a POST request to \"/email-verification\" but did not provide an `action` for route \"EMAIL_VERIFICATION\"",
pathname: '/email-verification',
retryVisible: true,
title: '',
};
const api = createRecoveryApi(state);
const snapshot = api.getAuthTimeoutErrorPageState({
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
});
assert.equal(Boolean(snapshot), true);
assert.equal(snapshot.routeErrorMatched, true);
});
test('auth page recovery detects failed-to-fetch retry page on email verification route', () => {
const state = {
clickCount: 0,
pageText: 'Oops, an error occurred! Failed to fetch',
pathname: '/email-verification',
retryVisible: true,
title: 'Oops, an error occurred!',
};
const api = createRecoveryApi(state);
const snapshot = api.getAuthTimeoutErrorPageState({
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
});
assert.equal(Boolean(snapshot), true);
assert.equal(snapshot.fetchFailedMatched, true);
});
test('auth page recovery clicks retry and waits until page recovers', async () => {
const state = {
clickCount: 0,
@@ -225,4 +261,3 @@ test('auth page recovery throws signup user already exists error without clickin
assert.equal(state.clickCount, 0);
});
+321
View File
@@ -169,6 +169,327 @@ test('auto-run controller skips add-phone failures to the next round instead of
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller treats phone-number supply exhaustion as round-fatal and skips same-round retries', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isRestartCurrentAttemptError: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('HeroSMS no numbers available across 1 country candidate(s): Thailand: NO_NUMBERS.');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(2, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, 'number supply failure should fail current round and continue next round');
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'number supply failure should not enter same-round retrying phase');
assert.equal(events.accountRecords.length, 1, 'number supply failure should persist one failed round record');
assert.equal(events.accountRecords[0].status, 'failed');
assert.match(events.accountRecords[0].reason, /NO_NUMBERS/i);
assert.ok(events.logs.some(({ message }) => /接码号池/.test(message)));
assert.equal(events.broadcasts.some(({ phase }) => phase === 'stopped'), false);
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller keeps same-round retrying for step9 local replacement exhaustion errors', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isRestartCurrentAttemptError: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('Step 9: phone verification did not succeed after 3 number replacements. Last reason: sms_timeout_after_2_windows.');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, 'step9 local replacement exhaustion should retry in same round before success');
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), true, 'should enter retrying phase for same-round retry');
assert.equal(events.logs.some(({ message }) => /接码号池/.test(message)), false, 'should not be misclassified as global phone supply exhaustion');
assert.equal(events.accountRecords.length, 0, 'eventual same-round success should not persist failed round record');
});
test('auto-run controller skips user_already_exists failures to the next round instead of retrying the same round', async () => {
const events = {
logs: [],
+51
View File
@@ -67,6 +67,7 @@ function createHarness(options = {}) {
failureBudget = 1,
failureMessage = '认证失败: Request failed with status code 502',
authState = { state: 'password_page', url: 'https://auth.openai.com/log-in' },
customState = {},
} = options;
return new Function(`
@@ -97,8 +98,30 @@ async function getState() {
return {
stepStatuses: { 3: 'completed' },
mailProvider: '163',
...${JSON.stringify(customState)},
};
}
function getStepIdsForState() {
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
}
function getStepDefinitionForState(step) {
const map = {
1: { key: 'open-signup' },
2: { key: 'prepare-email' },
3: { key: 'fill-password' },
4: { key: 'verify-email' },
5: { key: 'profile-basic' },
6: { key: 'profile-finish' },
7: { key: 'auth-login' },
8: { key: 'auth-email-code' },
9: { key: 'confirm-oauth' },
10: { key: 'platform-verify' },
};
return map[Number(step)] || null;
}
function getStepExecutionKeyForState(step, state = {}) {
return String(getStepDefinitionForState(step, state)?.key || '').trim();
}
function isStopError(error) {
return (error?.message || String(error || '')) === '流程已被用户停止。';
}
@@ -246,3 +269,31 @@ test('auto-run stop errors after step 7 are rethrown immediately instead of rest
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run restarts from confirm-oauth step after transient step10 token_exchange_user_error', async () => {
const harness = createHarness({
failureStep: 10,
failureBudget: 1,
failureMessage: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }',
authState: { state: 'oauth_consent_page', url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent' },
customState: {
panelMode: 'sub2api',
stepStatuses: { 3: 'completed' },
stepsVersion: 'ultra2.0',
visibleStep: 10,
contributionMode: false,
},
});
const events = await harness.run();
assert.deepStrictEqual(events.steps, [7, 8, 9, 10, 9, 10]);
assert.equal(events.invalidations.length, 1);
assert.deepStrictEqual(events.invalidations[0], {
step: 8,
options: {
logLabel: '步骤 10 报错后准备回到步骤 9 重试(第 1 次重开)',
},
});
assert.ok(events.logs.some(({ message }) => /回到步骤 9 重新开始授权流程/.test(message)));
});
@@ -54,6 +54,12 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeHotmailLocalBaseUrl'),
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
extractFunction('normalizeVerificationResendCount'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizePhoneSmsProviderOrder'),
extractFunction('normalizeFiveSimCountryCode'),
extractFunction('normalizeFiveSimCountryOrder'),
extractFunction('normalizeNexSmsCountryId'),
extractFunction('normalizeNexSmsCountryOrder'),
extractFunction('normalizePhoneVerificationReplacementLimit'),
extractFunction('normalizePhoneCodeWaitSeconds'),
extractFunction('normalizePhoneCodeTimeoutWindows'),
@@ -136,6 +142,7 @@ return {
assert.equal(api.normalizePersistentSettingValue('phoneCodePollMaxRounds', '18'), 18);
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0.123456'), '0.1235');
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0'), '');
assert.equal(api.normalizePersistentSettingValue('heroSmsCountryId', 0), 0);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]),
[{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }]
@@ -172,4 +179,16 @@ return {
api.normalizePersistentSettingValue('codex2apiAdminKey', ' secret-key '),
'secret-key'
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('phoneSmsProviderOrder', []),
[]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('fiveSimCountryOrder', []),
[]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('nexSmsCountryOrder', []),
[]
);
});
+94
View File
@@ -392,3 +392,97 @@ return {
}]);
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
});
test('executeStep retries fetch-network errors for step 4 with cooldown and bounded attempts', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
const STEP_FETCH_NETWORK_RETRY_POLICIES = new Map([[4, { maxAttempts: 3, cooldownMs: 1 }]]);
let activeTopLevelAuthChainExecution = null;
let stopRequested = false;
const events = {
logs: [],
statusCalls: [],
registryCalls: [],
sleepCalls: [],
};
let runCount = 0;
async function addLog(message, level = 'info') {
events.logs.push({ message, level });
}
async function setStepStatus(step, status) {
events.statusCalls.push({ step, status });
}
async function humanStepDelay() {}
async function getState() {
return {
flowStartTime: null,
stepStatuses: {},
};
}
function getErrorMessage(error) {
return error?.message || String(error || '');
}
async function appendManualAccountRunRecordIfNeeded() {}
function isTerminalSecurityBlockedError() {
return false;
}
async function handleCloudflareSecurityBlocked() {}
function doesStepUseCompletionSignal() {
return false;
}
async function sleepWithStop(ms) {
events.sleepCalls.push(ms);
}
const stepRegistry = {
getStepDefinition(step) {
return { id: step, key: 'fetch-signup-code' };
},
async executeStep(step) {
events.registryCalls.push(step);
runCount += 1;
if (runCount < 3) {
throw new TypeError('Failed to fetch');
}
},
};
function getStepRegistryForState() {
return stepRegistry;
}
function getStepDefinitionForState(step) {
return { id: step, key: 'fetch-signup-code' };
}
${extractFunction('isStopError')}
${extractFunction('isRetryableContentScriptTransportError')}
${extractFunction('isStepFetchNetworkRetryableError')}
${extractFunction('getStepFetchNetworkRetryPolicy')}
${extractFunction('throwIfStopped')}
${extractFunction('isAuthChainStep')}
${extractFunction('acquireTopLevelAuthChainExecution')}
${extractFunction('isBrowserSwitchRequiredError')}
${extractFunction('getBrowserSwitchRequiredMessage')}
${extractFunction('handleBrowserSwitchRequired')}
${extractFunction('executeStep')}
return {
executeStep,
snapshot() {
return events;
},
};
`)();
await api.executeStep(4);
const events = api.snapshot();
assert.deepStrictEqual(events.registryCalls, [4, 4, 4]);
assert.deepStrictEqual(events.sleepCalls, [1, 1]);
assert.equal(
events.logs.filter(({ message }) => message.includes('[NETWORK_FETCH_RETRY]')).length >= 3,
true
);
});
+2 -2
View File
@@ -19,8 +19,8 @@ test('icloud login helper distinguishes auth-required errors from transient cont
assert.match(
source,
/if \(isIcloudTransientContextError\(err\)\) \{[\s\S]*iCloud 别名加载受网络\/上下文波动影响,请稍后重试。/m,
'withIcloudLoginHelp should surface transient-context copy instead of forcing login prompt'
/if \(isIcloudTransientContextError\(err\)\) \{[\s\S]*safeActionLabel[\s\S]*iCloud\$\{safeActionLabel\}受网络\/上下文波动影响,请稍后重试。/m,
'withIcloudLoginHelp should surface action-scoped transient-context copy instead of forcing login prompt'
);
assert.match(
+28
View File
@@ -30,6 +30,7 @@ ${providerSource}
const transformIpProxyAccountEntryByProvider = self.transformIpProxyAccountEntryByProvider;
${coreSource}
return {
applyExitRegionExpectation,
buildIpProxyPacScript,
getAccountModeProxyPoolFromState,
normalizeIpProxyAccountList,
@@ -133,3 +134,30 @@ test('IP proxy auto-switch threshold is clamped to the supported range', () => {
assert.equal(api.resolveIpProxyAutoSwitchThreshold({ ipProxyPoolTargetCount: '25' }), 25);
assert.equal(api.resolveIpProxyAutoSwitchThreshold({ ipProxyPoolTargetCount: '9999' }), 500);
});
test('711 proxy region mismatch with missing auth challenge keeps routing as warning instead of hard failure', () => {
const api = loadIpProxyCore();
const status = api.applyExitRegionExpectation({
applied: true,
reason: 'applied',
provider: '711proxy',
hasAuth: true,
username: 'USER047152-zone-custom-region-US',
entrySource: 'fixed_account',
exitIp: '1.2.3.4',
exitRegion: 'BR',
authDiagnostics: 'auth(challenge=0,provided=0,isProxy=n/a,status=0,host=unknown)',
error: '',
warning: '',
}, 'US');
assert.equal(status.applied, true);
assert.equal(status.reason, 'applied_with_warning');
assert.equal(status.error, '');
assert.match(
String(status.warning || ''),
/地区校验未通过且未触发代理鉴权挑战,疑似匿名链路;先保留代理接管并给出强告警/
);
assert.match(String(status.warning || ''), /期望 US,实际 BR/);
});
@@ -79,3 +79,120 @@ test('platform verify module supports codex2api protocol callback exchange', asy
globalThis.fetch = originalFetch;
}
});
test('platform verify retries transient SUB2API oauth/token exchange errors before failing', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const logs = [];
const attempts = [];
let callCount = 0;
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
getTabId: async () => 12,
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
isTabAlive: async () => true,
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
rememberSourceLastUrl: async () => {},
reuseOrCreateTab: async () => 12,
sendToContentScript: async (_source, message) => {
attempts.push(message.type);
callCount += 1;
if (callCount === 1) {
return {
error: 'request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF',
};
}
return { ok: true };
},
sendToContentScriptResilient: async () => ({}),
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
});
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
sub2apiEmail: 'flow@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
});
assert.equal(callCount, 2);
assert.deepStrictEqual(attempts, ['EXECUTE_STEP', 'EXECUTE_STEP']);
assert.equal(
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
true
);
});
test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const logs = [];
let callCount = 0;
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
getTabId: async () => 12,
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
isTabAlive: async () => true,
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
rememberSourceLastUrl: async () => {},
reuseOrCreateTab: async () => 12,
sendToContentScript: async () => {
callCount += 1;
if (callCount === 1) {
return {
error: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }',
};
}
return { ok: true };
},
sendToContentScriptResilient: async () => ({}),
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
});
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
sub2apiEmail: 'flow@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
});
assert.equal(callCount, 2);
assert.equal(
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
true
);
});
@@ -45,7 +45,9 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
reuseOrCreateTab: async (source, url) => {
tabReuses.push({ source, url });
},
sendToContentScript: async () => ({}),
sendToContentScriptResilient: async () => ({}),
isRetryableContentScriptTransportError: () => false,
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
@@ -100,7 +102,9 @@ test('step 4 does not request a fresh code first for Cloudflare temp mail', asyn
capturedOptions = options;
},
reuseOrCreateTab: async () => {},
sendToContentScript: async () => ({}),
sendToContentScriptResilient: async () => ({}),
isRetryableContentScriptTransportError: () => false,
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
@@ -151,7 +155,9 @@ test('step 4 checks iCloud session before polling iCloud mailbox', async () => {
resolved = true;
},
reuseOrCreateTab: async () => {},
sendToContentScript: async () => ({}),
sendToContentScriptResilient: async () => ({}),
isRetryableContentScriptTransportError: () => false,
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
@@ -197,10 +203,15 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
resolveCalls += 1;
},
reuseOrCreateTab: async () => {},
sendToContentScript: async () => ({
alreadyVerified: true,
skipProfileStep: true,
}),
sendToContentScriptResilient: async () => ({
alreadyVerified: true,
skipProfileStep: true,
}),
isRetryableContentScriptTransportError: () => false,
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
@@ -219,3 +230,73 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
]);
assert.equal(resolveCalls, 0);
});
test('step 4 prepare retries transport by recovering retry page without replaying full prepare loop', async () => {
let sendToContentScriptCalls = 0;
let recoverCalls = 0;
let resolveCalls = 0;
const logs = [];
const executor = api.createStep4Executor({
addLog: async (message, level) => {
logs.push({ message, level: level || 'info' });
},
chrome: {
tabs: {
update: async () => {},
},
},
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({
provider: '163',
label: '163 邮箱',
source: 'mail-163',
url: 'https://mail.163.com',
}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
LUCKMAIL_PROVIDER: 'luckmail-api',
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
resolveVerificationStep: async () => {
resolveCalls += 1;
},
reuseOrCreateTab: async () => {},
sendToContentScript: async (_source, message) => {
if (message.type !== 'PREPARE_SIGNUP_VERIFICATION') {
return {};
}
sendToContentScriptCalls += 1;
if (sendToContentScriptCalls === 1) {
throw new Error('Content script on signup-page did not respond in 30s. Try refreshing the tab and retry.');
}
return { ready: true };
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'RECOVER_AUTH_RETRY_PAGE') {
recoverCalls += 1;
return { recovered: true };
}
return {};
},
isRetryableContentScriptTransportError: (error) => /did not respond in \d+s/i.test(String(error?.message || error)),
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
});
await executor.executeStep4({
email: 'user@example.com',
password: 'secret',
});
assert.equal(sendToContentScriptCalls, 2);
assert.equal(recoverCalls, 1);
assert.equal(resolveCalls, 1);
assert.equal(
logs.some((entry) => /正在确认注册验证码页面是否就绪/.test(entry.message)),
true
);
});
+101
View File
@@ -193,3 +193,104 @@ return {
assert.equal(api.isThreadItemSelected(staleItem, api.buildItemSignature(selectedItem)), true);
assert.equal(api.isThreadItemSelected(staleItem, api.buildItemSignature(staleItem)), false);
});
test('icloud poll session baseline is reused across calls and enables fallback after carry-over attempts', async () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getThreadItemMetadata'),
extractFunction('buildItemSignature'),
extractFunction('extractVerificationCode'),
extractFunction('normalizePollSessionKey'),
extractFunction('getOrCreatePollSessionBaseline'),
extractFunction('persistPollSessionBaseline'),
extractFunction('handlePollEmail'),
].join('\n');
const api = new Function(`
const ICLOUD_POLL_SESSION_CACHE = new Map();
function log() {}
function throwIfStopped() {}
async function sleep() {}
async function waitForElement() { return true; }
async function refreshInbox() { return true; }
function normalizeThreadEntry(entry) {
return {
signature: String(entry.signature || ''),
sender: String(entry.sender || ''),
subject: String(entry.subject || ''),
preview: String(entry.preview || ''),
timestamp: String(entry.timestamp || ''),
ariaLabel: String(entry.ariaLabel || ''),
};
}
let currentThreadData = [];
function setThreadData(next) {
currentThreadData = Array.isArray(next) ? next.map(normalizeThreadEntry) : [];
}
function collectThreadItems() {
return currentThreadData.map((entry) => ({
getAttribute(name) {
if (name === 'aria-label') return entry.ariaLabel || entry.signature;
return '';
},
querySelector(selector) {
if (selector === '.thread-participants') return { textContent: entry.sender };
if (selector === '.thread-subject') return { textContent: entry.subject };
if (selector === '.thread-preview') return { textContent: entry.preview };
if (selector === '.thread-timestamp') return { textContent: entry.timestamp };
return null;
},
}));
}
async function openMailItemAndRead(item) {
const meta = getThreadItemMetadata(item);
return {
sender: meta.sender,
recipients: '',
timestamp: meta.timestamp,
bodyText: meta.preview,
combinedText: meta.combinedText,
};
}
${bundle}
return {
handlePollEmail,
setThreadData,
};
`)();
api.setThreadData([
{ signature: 'old-1', sender: 'OpenAI', subject: '旧邮件', preview: 'no code', timestamp: '10:00', ariaLabel: 'old-1' },
]);
await assert.rejects(
() => api.handlePollEmail(8, {
senderFilters: ['openai'],
subjectFilters: ['code'],
maxAttempts: 1,
intervalMs: 10,
excludeCodes: [],
sessionKey: 's-1',
}),
/仍未在 iCloud 邮箱中找到新的匹配邮件/
);
api.setThreadData([
{ signature: 'old-1', sender: 'OpenAI', subject: '旧邮件', preview: 'no code', timestamp: '10:00', ariaLabel: 'old-1' },
{ signature: 'old-2', sender: 'OpenAI', subject: '旧邮件2', preview: 'no code', timestamp: '10:01', ariaLabel: 'old-2' },
{ signature: 'old-3', sender: 'OpenAI', subject: '旧邮件3', preview: 'no code', timestamp: '10:02', ariaLabel: 'old-3' },
{ signature: 'old-4', sender: 'OpenAI', subject: '旧邮件4', preview: 'no code', timestamp: '10:03', ariaLabel: 'old-4' },
{ signature: 'old-code', sender: 'OpenAI', subject: 'ChatGPT Log-in Code', preview: 'enter this code 556677', timestamp: '10:04', ariaLabel: 'old-code' },
]);
const result = await api.handlePollEmail(8, {
senderFilters: ['openai'],
subjectFilters: ['code', 'login'],
maxAttempts: 1,
intervalMs: 10,
excludeCodes: [],
sessionKey: 's-1',
});
assert.equal(result.code, '556677');
});
+256 -7
View File
@@ -6,19 +6,27 @@ const source = fs.readFileSync('content/phone-auth.js', 'utf8');
const globalScope = { navigator: { language: 'zh-CN' } };
const api = new Function('self', `${source}; return self.MultiPagePhoneAuth;`)(globalScope);
function createFakeAddPhoneDom() {
function createFakeAddPhoneDom(config = {}) {
const selectEvents = [];
const hiddenInputEvents = [];
let submitClicked = false;
const options = [
{ value: 'US', textContent: '美国' },
{ value: 'TH', textContent: '泰国' },
];
const options = (
Array.isArray(config.options) && config.options.length
? config.options
: [
{ value: 'US', textContent: '美国', buttonText: '美国 (+1)' },
{ value: 'TH', textContent: '泰国', buttonText: '泰国 (+66)' },
]
).map((entry) => ({
value: String(entry?.value || '').trim(),
textContent: String(entry?.textContent || '').trim(),
buttonText: String(entry?.buttonText || entry?.textContent || '').trim(),
}));
const select = {
options,
selectedIndex: 0,
selectedIndex: Math.max(0, Math.min(options.length - 1, Number(config.selectedIndex) || 0)),
dispatchEvent(event) {
selectEvents.push(event?.type || '');
return true;
@@ -58,7 +66,7 @@ function createFakeAddPhoneDom() {
const selectValueNode = {
get textContent() {
return select.value === 'TH' ? '泰国 (+66)' : '美国 (+1)';
return options[select.selectedIndex]?.buttonText || '';
},
};
@@ -199,3 +207,244 @@ test('phone auth matches english HeroSMS country labels against localized add-ph
Intl.DisplayNames = OriginalDisplayNames;
}
});
test('phone auth keeps explicit international number and auto-selects country by dial code when label lookup fails', async () => {
const originalDocument = global.document;
const originalEvent = global.Event;
const originalLocation = global.location;
const OriginalDisplayNames = Intl.DisplayNames;
const dom = createFakeAddPhoneDom({
options: [
{ value: 'CO', textContent: 'Colombia (+57)', buttonText: 'Colombia (+57)' },
{ value: 'GB', textContent: 'United Kingdom (+44)', buttonText: 'United Kingdom (+44)' },
],
selectedIndex: 0,
});
let phoneVerificationReady = false;
global.document = dom.document;
global.Event = class Event {
constructor(type) {
this.type = type;
}
};
global.location = { href: 'https://auth.openai.com/add-phone' };
Intl.DisplayNames = class DisplayNames {
of(regionCode) {
return regionCode;
}
};
try {
const helpers = api.createPhoneAuthHelpers({
fillInput: (element, value) => {
element.value = value;
},
getActionText: () => '',
getPageTextSnapshot: () => '',
getVerificationErrorText: () => '',
humanPause: async () => {},
isActionEnabled: () => true,
isAddPhonePageReady: () => true,
isConsentReady: () => false,
isPhoneVerificationPageReady: () => phoneVerificationReady,
isVisibleElement: () => true,
simulateClick: (element) => {
element.click?.();
phoneVerificationReady = true;
global.location.href = 'https://auth.openai.com/phone-verification';
},
sleep: async () => {},
throwIfStopped: () => {},
waitForElement: async () => null,
});
const result = await helpers.submitPhoneNumber({
countryLabel: 'Country #16',
phoneNumber: '+447999221823',
});
assert.equal(dom.select.value, 'GB');
assert.equal(dom.phoneInput.value, '7999221823');
assert.equal(dom.hiddenPhoneInput.value, '+447999221823');
assert.equal(dom.wasSubmitClicked(), true);
assert.deepStrictEqual(result, {
phoneVerificationPage: true,
displayedPhone: '',
url: 'https://auth.openai.com/phone-verification',
});
} finally {
global.document = originalDocument;
global.Event = originalEvent;
global.location = originalLocation;
Intl.DisplayNames = OriginalDisplayNames;
}
});
test('phone auth can auto-select country by dial code even when number has no plus prefix', async () => {
const originalDocument = global.document;
const originalEvent = global.Event;
const originalLocation = global.location;
const OriginalDisplayNames = Intl.DisplayNames;
const dom = createFakeAddPhoneDom({
options: [
{ value: 'CO', textContent: 'Colombia (+57)', buttonText: 'Colombia (+57)' },
{ value: 'GB', textContent: 'United Kingdom (+44)', buttonText: 'United Kingdom (+44)' },
],
selectedIndex: 0,
});
let phoneVerificationReady = false;
global.document = dom.document;
global.Event = class Event {
constructor(type) {
this.type = type;
}
};
global.location = { href: 'https://auth.openai.com/add-phone' };
Intl.DisplayNames = class DisplayNames {
of(regionCode) {
return regionCode;
}
};
try {
const helpers = api.createPhoneAuthHelpers({
fillInput: (element, value) => {
element.value = value;
},
getActionText: () => '',
getPageTextSnapshot: () => '',
getVerificationErrorText: () => '',
humanPause: async () => {},
isActionEnabled: () => true,
isAddPhonePageReady: () => true,
isConsentReady: () => false,
isPhoneVerificationPageReady: () => phoneVerificationReady,
isVisibleElement: () => true,
simulateClick: (element) => {
element.click?.();
phoneVerificationReady = true;
global.location.href = 'https://auth.openai.com/phone-verification';
},
sleep: async () => {},
throwIfStopped: () => {},
waitForElement: async () => null,
});
const result = await helpers.submitPhoneNumber({
countryLabel: 'Country #16',
phoneNumber: '447999221823',
});
assert.equal(dom.select.value, 'GB');
assert.equal(dom.phoneInput.value, '7999221823');
assert.equal(dom.hiddenPhoneInput.value, '+447999221823');
assert.equal(dom.wasSubmitClicked(), true);
assert.deepStrictEqual(result, {
phoneVerificationPage: true,
displayedPhone: '',
url: 'https://auth.openai.com/phone-verification',
});
} finally {
global.document = originalDocument;
global.Event = originalEvent;
global.location = originalLocation;
Intl.DisplayNames = OriginalDisplayNames;
}
});
test('phone auth resend stops with PHONE_ROUTE_405_RECOVERY_FAILED instead of endless Try-again loop', async () => {
const originalDocument = global.document;
const originalLocation = global.location;
const originalWindow = global.window;
let retryClicks = 0;
const fakeRetryButton = {
getAttribute(name) {
if (name === 'data-dd-action-name') return 'Try again';
return '';
},
click() {
retryClicks += 1;
},
textContent: 'Try again',
};
const fakeResendButton = {
getAttribute(name) {
if (name === 'value') return 'resend';
return '';
},
textContent: 'Resend text message',
};
const fakePhoneForm = {
querySelectorAll(selector) {
if (selector === 'button, input[type="submit"], input[type="button"]') {
return [fakeResendButton, fakeRetryButton];
}
return [];
},
};
global.document = {
title: 'Route Error',
querySelector(selector) {
if (selector === 'button[data-dd-action-name="Try again"]') {
return fakeRetryButton;
}
if (selector === 'form[action*="/phone-verification" i]') {
return fakePhoneForm;
}
return null;
},
querySelectorAll(selector) {
if (selector === 'button, [role="button"], input[type="submit"], input[type="button"]') {
return [fakeRetryButton, fakeResendButton];
}
return [];
},
};
global.location = {
href: 'https://auth.openai.com/phone-verification',
pathname: '/phone-verification',
};
global.window = global;
const helpers = api.createPhoneAuthHelpers({
fillInput: () => {},
getActionText: (element) => String(element?.textContent || ''),
getPageTextSnapshot: () => (
'Route Error (405 Method Not Allowed): You made a POST request to "/phone-verification" but did not provide an action.'
),
getVerificationErrorText: () => '',
humanPause: async () => {},
isActionEnabled: () => true,
isAddPhonePageReady: () => false,
isConsentReady: () => false,
isPhoneVerificationPageReady: () => true,
isVisibleElement: () => true,
simulateClick: (element) => {
element?.click?.();
},
sleep: async () => {},
throwIfStopped: () => {},
waitForElement: async () => null,
});
try {
await assert.rejects(
() => helpers.resendPhoneVerificationCode(4000),
/PHONE_ROUTE_405_RECOVERY_FAILED::/i
);
assert.ok(
retryClicks > 0 && retryClicks <= 6,
`expected bounded retry clicks (1..6), got ${retryClicks}`
);
} finally {
global.document = originalDocument;
global.location = originalLocation;
global.window = originalWindow;
}
});
File diff suppressed because it is too large Load Diff
@@ -59,6 +59,13 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="btn-toggle-phone-verification-section"/);
assert.match(html, /id="row-phone-verification-fold"/);
assert.match(html, /id="input-phone-verification-enabled"/);
assert.match(html, /id="row-phone-sms-provider"/);
assert.match(html, /id="select-phone-sms-provider"/);
assert.match(html, /id="row-phone-sms-provider-order"/);
assert.match(html, /id="select-phone-sms-provider-order"[^>]*multiple/);
assert.match(html, /id="btn-phone-sms-provider-order-menu"/);
assert.match(html, /id="row-phone-sms-provider-order-actions"/);
assert.match(html, /id="btn-phone-sms-provider-order-reset"/);
assert.match(html, /id="row-hero-sms-platform"/);
assert.match(html, /id="row-hero-sms-country"/);
assert.match(html, /id="row-hero-sms-country-fallback"/);
@@ -69,32 +76,83 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="row-hero-sms-api-key"/);
assert.match(html, /id="row-hero-sms-max-price"/);
assert.match(html, /id="row-hero-sms-current-number"/);
assert.match(html, /id="row-hero-sms-current-countdown"/);
assert.match(html, /id="row-hero-sms-price-tiers"/);
assert.match(html, /id="row-hero-sms-current-code"/);
assert.match(html, /id="row-hero-sms-preferred-activation"/);
assert.match(html, /id="select-hero-sms-preferred-activation"/);
assert.match(html, /id="row-phone-replacement-limit"/);
assert.match(html, /id="row-phone-verification-resend-count"/);
assert.match(html, /id="row-phone-code-wait-seconds"/);
assert.match(html, /id="row-phone-code-timeout-windows"/);
assert.match(html, /id="row-phone-code-poll-interval-seconds"/);
assert.match(html, /id="row-phone-code-poll-max-rounds"/);
assert.match(html, /id="row-oauth-flow-timeout"/);
assert.match(html, /id="input-oauth-flow-timeout-enabled"/);
assert.match(html, /只取消 Step 7 后链总预算/);
assert.match(html, /id="row-five-sim-api-key"/);
assert.match(html, /id="input-five-sim-api-key"/);
assert.match(html, /id="row-five-sim-country"/);
assert.match(html, /id="select-five-sim-country"[^>]*multiple/);
assert.match(html, /id="row-five-sim-country-fallback"/);
assert.match(html, /id="row-five-sim-operator"/);
assert.match(html, /id="input-five-sim-operator"/);
assert.match(html, /id="row-five-sim-product"/);
assert.match(html, /id="input-five-sim-product"/);
assert.match(html, /<option value="nexsms">/);
assert.match(html, /id="row-nex-sms-api-key"/);
assert.match(html, /id="input-nex-sms-api-key"/);
assert.match(html, /id="row-nex-sms-country"/);
assert.match(html, /id="select-nex-sms-country"[^>]*multiple/);
assert.match(html, /id="row-nex-sms-country-fallback"/);
assert.match(html, /id="row-nex-sms-service-code"/);
assert.match(html, /id="input-nex-sms-service-code"/);
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
test('updatePhoneVerificationSettingsUI toggles HeroSMS rows from the sms switch', () => {
const api = new Function(`
const phoneVerificationSectionExpanded = true;
let latestState = {};
const inputPhoneVerificationEnabled = { checked: false };
const rowPhoneVerificationEnabled = { style: { display: 'none' } };
const rowPhoneVerificationFold = { style: { display: 'none' } };
const rowPhoneSmsProvider = { style: { display: 'none' } };
const rowPhoneSmsProviderOrder = { style: { display: 'none' } };
const rowPhoneSmsProviderOrderActions = { style: { display: 'none' } };
const selectPhoneSmsProvider = { value: 'hero-sms' };
const btnTogglePhoneVerificationSection = {
disabled: false,
textContent: '',
title: '',
setAttribute: () => {},
};
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms'];
const phoneSmsProviderOrderSelection = [];
function normalizePhoneSmsProviderOrderValue(value = [], fallbackOrder = DEFAULT_PHONE_SMS_PROVIDER_ORDER) {
const source = Array.isArray(value) ? value : [];
const normalized = [...source];
if (normalized.length) {
return normalized.slice(0, 3);
}
if (!Array.isArray(fallbackOrder) || !fallbackOrder.length) {
return [];
}
const fallbackNormalized = [];
for (const provider of fallbackOrder) {
if (!fallbackNormalized.includes(provider)) {
fallbackNormalized.push(provider);
}
}
return fallbackNormalized.slice(0, 3);
}
function resolveNormalizedProviderOrderForRuntime(state = {}) {
const rawOrder = Array.isArray(state?.phoneSmsProviderOrder) ? state.phoneSmsProviderOrder : [];
const normalizedOrder = normalizePhoneSmsProviderOrderValue(rawOrder, []);
if (normalizedOrder.length) {
return normalizedOrder;
}
const fallbackProvider = String(state?.phoneSmsProvider || selectPhoneSmsProvider?.value || 'hero-sms').trim().toLowerCase() || 'hero-sms';
return [fallbackProvider];
}
function updatePhoneSmsProviderOrderSummary() {}
const rowHeroSmsPlatform = { style: { display: 'none' } };
const rowHeroSmsCountry = { style: { display: 'none' } };
const rowHeroSmsCountryFallback = { style: { display: 'none' } };
@@ -102,20 +160,36 @@ const rowHeroSmsAcquirePriority = { style: { display: 'none' } };
const rowHeroSmsApiKey = { style: { display: 'none' } };
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
const rowHeroSmsCurrentNumber = { style: { display: 'none' } };
const rowHeroSmsCurrentCountdown = { style: { display: 'none' } };
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
const rowHeroSmsCurrentCode = { style: { display: 'none' } };
const rowHeroSmsPreferredActivation = { style: { display: 'none' } };
const rowPhoneVerificationResendCount = { style: { display: 'none' } };
const rowPhoneReplacementLimit = { style: { display: 'none' } };
const rowPhoneCodeWaitSeconds = { style: { display: 'none' } };
const rowPhoneCodeTimeoutWindows = { style: { display: 'none' } };
const rowPhoneCodePollIntervalSeconds = { style: { display: 'none' } };
const rowPhoneCodePollMaxRounds = { style: { display: 'none' } };
const rowFiveSimApiKey = { style: { display: 'none' } };
const rowFiveSimCountry = { style: { display: 'none' } };
const rowFiveSimCountryFallback = { style: { display: 'none' } };
const rowFiveSimOperator = { style: { display: 'none' } };
const rowFiveSimProduct = { style: { display: 'none' } };
const rowNexSmsApiKey = { style: { display: 'none' } };
const rowNexSmsCountry = { style: { display: 'none' } };
const rowNexSmsCountryFallback = { style: { display: 'none' } };
const rowNexSmsServiceCode = { style: { display: 'none' } };
${extractFunction('updatePhoneVerificationSettingsUI')}
return {
setLatestState: (state) => { latestState = state || {}; },
rowPhoneVerificationEnabled,
rowPhoneVerificationFold,
rowPhoneSmsProvider,
rowPhoneSmsProviderOrder,
rowPhoneSmsProviderOrderActions,
selectPhoneSmsProvider,
btnTogglePhoneVerificationSection,
inputPhoneVerificationEnabled,
rowHeroSmsPlatform,
@@ -125,14 +199,25 @@ return {
rowHeroSmsApiKey,
rowHeroSmsMaxPrice,
rowHeroSmsCurrentNumber,
rowHeroSmsCurrentCountdown,
rowHeroSmsPriceTiers,
rowHeroSmsCurrentCode,
rowHeroSmsPreferredActivation,
rowPhoneVerificationResendCount,
rowPhoneReplacementLimit,
rowPhoneCodeWaitSeconds,
rowPhoneCodeTimeoutWindows,
rowPhoneCodePollIntervalSeconds,
rowPhoneCodePollMaxRounds,
rowFiveSimApiKey,
rowFiveSimCountry,
rowFiveSimCountryFallback,
rowFiveSimOperator,
rowFiveSimProduct,
rowNexSmsApiKey,
rowNexSmsCountry,
rowNexSmsCountryFallback,
rowNexSmsServiceCode,
updatePhoneVerificationSettingsUI,
};
`)();
@@ -140,6 +225,9 @@ return {
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationEnabled.style.display, '');
assert.equal(api.rowPhoneVerificationFold.style.display, 'none');
assert.equal(api.rowPhoneSmsProvider.style.display, 'none');
assert.equal(api.rowPhoneSmsProviderOrder.style.display, 'none');
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, 'none');
assert.equal(api.btnTogglePhoneVerificationSection.disabled, true);
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '展开设置');
assert.equal(api.rowHeroSmsPlatform.style.display, 'none');
@@ -149,18 +237,32 @@ return {
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
assert.equal(api.rowHeroSmsMaxPrice.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, 'none');
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentCode.style.display, 'none');
assert.equal(api.rowHeroSmsPreferredActivation.style.display, 'none');
assert.equal(api.rowPhoneVerificationResendCount.style.display, 'none');
assert.equal(api.rowPhoneReplacementLimit.style.display, 'none');
assert.equal(api.rowPhoneCodeWaitSeconds.style.display, 'none');
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, 'none');
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, 'none');
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, 'none');
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
assert.equal(api.rowFiveSimCountry.style.display, 'none');
assert.equal(api.rowFiveSimCountryFallback.style.display, 'none');
assert.equal(api.rowFiveSimOperator.style.display, 'none');
assert.equal(api.rowFiveSimProduct.style.display, 'none');
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
assert.equal(api.rowNexSmsCountry.style.display, 'none');
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
api.inputPhoneVerificationEnabled.checked = true;
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationFold.style.display, '');
assert.equal(api.rowPhoneSmsProvider.style.display, '');
assert.equal(api.rowPhoneSmsProviderOrder.style.display, '');
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, '');
assert.equal(api.btnTogglePhoneVerificationSection.disabled, false);
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '收起设置');
assert.equal(api.rowHeroSmsPlatform.style.display, '');
@@ -170,14 +272,51 @@ return {
assert.equal(api.rowHeroSmsApiKey.style.display, '');
assert.equal(api.rowHeroSmsMaxPrice.style.display, '');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, '');
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, '');
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentCode.style.display, '');
assert.equal(api.rowHeroSmsPreferredActivation.style.display, '');
assert.equal(api.rowPhoneVerificationResendCount.style.display, '');
assert.equal(api.rowPhoneReplacementLimit.style.display, '');
assert.equal(api.rowPhoneCodeWaitSeconds.style.display, '');
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, '');
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, '');
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, '');
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
assert.equal(api.rowFiveSimCountry.style.display, 'none');
assert.equal(api.rowFiveSimCountryFallback.style.display, 'none');
assert.equal(api.rowFiveSimOperator.style.display, 'none');
assert.equal(api.rowFiveSimProduct.style.display, 'none');
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
assert.equal(api.rowNexSmsCountry.style.display, 'none');
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
api.selectPhoneSmsProvider.value = '5sim';
api.setLatestState({ phoneSmsProviderOrder: ['5sim'] });
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
assert.equal(api.rowFiveSimApiKey.style.display, '');
assert.equal(api.rowFiveSimCountry.style.display, '');
assert.equal(api.rowFiveSimCountryFallback.style.display, '');
assert.equal(api.rowFiveSimOperator.style.display, '');
assert.equal(api.rowFiveSimProduct.style.display, '');
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
assert.equal(api.rowNexSmsCountry.style.display, 'none');
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
api.selectPhoneSmsProvider.value = 'nexsms';
api.setLatestState({ phoneSmsProviderOrder: ['nexsms'] });
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
assert.equal(api.rowFiveSimCountry.style.display, 'none');
assert.equal(api.rowNexSmsApiKey.style.display, '');
assert.equal(api.rowNexSmsCountry.style.display, '');
assert.equal(api.rowNexSmsCountryFallback.style.display, '');
assert.equal(api.rowNexSmsServiceCode.style.display, '');
});
test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => {
@@ -186,6 +325,7 @@ let latestState = {
contributionMode: false,
mail2925UseAccountPool: false,
currentMail2925AccountId: '',
fiveSimCountryOrder: ['thailand', 'england'],
};
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
@@ -225,12 +365,26 @@ const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: false };
const inputPhoneVerificationEnabled = { checked: true };
const selectPhoneSmsProvider = { value: 'hero-sms' };
const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
const inputFiveSimApiKey = { value: 'five-sim-key' };
const inputFiveSimOperator = { value: 'any' };
const inputFiveSimProduct = { value: 'openai' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'price' };
function getSelectedPhonePreferredActivation() {
return {
provider: 'hero-sms',
activationId: 'demo-activation',
phoneNumber: '66958889999',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 0,
maxUses: 3,
};
}
const inputHeroSmsMaxPrice = { value: '0.12' };
const inputPhoneReplacementLimit = { value: '5' };
const inputPhoneCodeWaitSeconds = { value: '75' };
@@ -303,13 +457,26 @@ return { collectSettingsPayload };
const payload = api.collectSettingsPayload();
assert.equal(payload.phoneVerificationEnabled, true);
assert.equal(payload.oauthFlowTimeoutEnabled, false);
assert.equal(payload.phoneSmsProvider, 'hero-sms');
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
assert.equal(payload.fiveSimApiKey, 'five-sim-key');
assert.deepStrictEqual(payload.fiveSimCountryOrder, ['thailand', 'england']);
assert.equal(payload.fiveSimOperator, 'any');
assert.equal(payload.fiveSimProduct, 'openai');
assert.equal(payload.heroSmsReuseEnabled, true);
assert.equal(payload.heroSmsAcquirePriority, 'price');
assert.equal(payload.heroSmsMaxPrice, '0.12');
assert.deepStrictEqual(payload.phonePreferredActivation, {
provider: 'hero-sms',
activationId: 'demo-activation',
phoneNumber: '66958889999',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 0,
maxUses: 3,
});
assert.equal(payload.phoneVerificationReplacementLimit, 5);
assert.equal(payload.phoneCodeWaitSeconds, 75);
assert.equal(payload.phoneCodeTimeoutWindows, 3);
+168
View File
@@ -0,0 +1,168 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function extractFunction(name) {
const asyncStart = sidepanelSource.indexOf(`async function ${name}`);
const normalStart = sidepanelSource.indexOf(`function ${name}`);
const start = asyncStart !== -1
? asyncStart
: normalStart;
if (start === -1) {
throw new Error(`Function ${name} not found`);
}
const signatureEnd = sidepanelSource.indexOf(')', start);
const bodyStart = sidepanelSource.indexOf('{', signatureEnd);
let depth = 0;
let end = bodyStart;
for (; end < sidepanelSource.length; end += 1) {
const char = sidepanelSource[end];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return sidepanelSource.slice(start, end);
}
test('sidepanel step definitions keep the selected Plus payment method', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getStepDefinitionsForMode'),
extractFunction('rebuildStepDefinitionState'),
extractFunction('syncStepDefinitionsForMode'),
].join('\n');
const api = new Function(`
const calls = [];
const window = {
MultiPageStepDefinitions: {
getSteps(options) {
calls.push({ type: 'getSteps', options });
return [{ id: options.plusPaymentMethod === 'gopay' ? 7 : 6, order: 1 }];
},
},
};
let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
let stepDefinitions = [];
let STEP_IDS = [];
let STEP_DEFAULT_STATUSES = {};
let SKIPPABLE_STEPS = new Set();
function renderStepsList() {
calls.push({ type: 'render', stepIds: [...STEP_IDS] });
}
${bundle}
return {
calls,
syncStepDefinitionsForMode,
getCurrentPlusPaymentMethod: () => currentPlusPaymentMethod,
getStepIds: () => [...STEP_IDS],
};
`)();
api.syncStepDefinitionsForMode(true, 'gopay', { render: true });
assert.equal(api.getCurrentPlusPaymentMethod(), 'gopay');
assert.deepEqual(api.getStepIds(), [7]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { plusModeEnabled: true, plusPaymentMethod: 'gopay' },
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] });
});
test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('updatePlusModeUI'),
].join('\n');
const api = new Function(`
let latestState = { plusPaymentMethod: 'gopay' };
let currentPlusPaymentMethod = 'paypal';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gopay', style: { display: 'none' } };
const rowPayPalAccount = { style: { display: '' } };
${bundle}
return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
`)();
api.updatePlusModeUI();
assert.equal(api.selectPlusPaymentMethod.style.display, '');
assert.equal(api.rowPayPalAccount.style.display, 'none');
api.selectPlusPaymentMethod.value = 'paypal';
api.updatePlusModeUI();
assert.equal(api.rowPayPalAccount.style.display, '');
});
test('sidepanel resolves pending GoPay manual confirmation from DATA_UPDATED state', async () => {
const bundle = [
extractFunction('openPlusManualConfirmationDialog'),
extractFunction('syncPlusManualConfirmationDialog'),
].join('\n');
const api = new Function(`
const events = [];
let latestState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'gopay-request-1',
plusManualConfirmationStep: 7,
plusManualConfirmationMethod: 'gopay',
plusManualConfirmationTitle: 'GoPay 订阅确认',
plusManualConfirmationMessage: '请确认订阅。',
};
let activePlusManualConfirmationRequestId = '';
let plusManualConfirmationDialogInFlight = false;
function openActionModal(options) {
events.push({ type: 'modal', options });
return Promise.resolve('confirm');
}
function showToast(message, tone) {
events.push({ type: 'toast', message, tone });
}
const chrome = {
runtime: {
async sendMessage(message) {
events.push({ type: 'send', message });
latestState = {
...latestState,
plusManualConfirmationPending: false,
};
return { ok: true };
},
},
};
${bundle}
return { events, syncPlusManualConfirmationDialog };
`)();
await api.syncPlusManualConfirmationDialog();
assert.equal(api.events[0].type, 'modal');
assert.equal(api.events[0].options.title, 'GoPay 订阅确认');
assert.deepEqual(api.events[1], {
type: 'send',
message: {
type: 'RESOLVE_PLUS_MANUAL_CONFIRMATION',
source: 'sidepanel',
payload: {
step: 7,
requestId: 'gopay-request-1',
confirmed: true,
},
},
});
assert.match(api.events[2].message, /GoPay/);
assert.equal(api.events[2].tone, 'info');
});
+71
View File
@@ -189,3 +189,74 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
},
]);
});
test('step 8 escalates to rerun step 7 after too many local retry_without_step7 recoveries', async () => {
const calls = {
rerunStep7: 0,
ensureReady: 0,
logs: [],
};
const executor = step8Api.createStep8Executor({
addLog: async (message, level) => {
calls.logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => {
calls.ensureReady += 1;
return { state: 'verification_page' };
},
rerunStep7ForStep8Recovery: async () => {
calls.rerunStep7 += 1;
throw new Error('RERUN_MARKER');
},
getOAuthFlowRemainingMs: async () => 8000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ mail',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret', oauthUrl: 'https://oauth.example/latest' }),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => true,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async () => {
throw new Error('Content script on icloud-mail did not respond in 1s. Try refreshing the tab and retry.');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async () => {},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await assert.rejects(
() => executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
}),
/RERUN_MARKER/
);
assert.equal(calls.rerunStep7, 1);
assert.equal(calls.ensureReady >= 4, true);
assert.equal(
calls.logs.some(({ message }) => /连续重试 \d+ 改为回到步骤 7/.test(message)),
true
);
});
+6
View File
@@ -51,4 +51,10 @@ assert.strictEqual(
'真实业务错误不应被误判为可重试传输错误'
);
assert.strictEqual(
api.isRetryableContentScriptTransportError(new Error('TypeError: Failed to fetch')),
true,
'Failed to fetch 应进入可重试传输错误分支'
);
console.log('step8 state timeout retry tests passed');
+257 -1
View File
@@ -601,6 +601,69 @@ test('verification flow caps mail polling timeout to the remaining oauth budget'
assert.equal(mailPollCalls[0].payload.maxAttempts, 2);
});
test('verification flow keeps mail polling response timeout above minimum floor', async () => {
const mailPollCalls = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
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') {
return {};
}
return {};
},
sendToMailContentScriptResilient: async (_mail, message, options) => {
mailPollCalls.push({
payload: message.payload,
options,
});
return { code: '654321', emailTimestamp: 123 };
},
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 邮箱' },
{
getRemainingTimeMs: async () => 1200,
resendIntervalMs: 0,
}
);
assert.ok(mailPollCalls.length >= 1);
assert.equal(mailPollCalls[0].options.timeoutMs, 5000);
assert.equal(mailPollCalls[0].options.responseTimeoutMs, 5000);
});
test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even when oauth budget is smaller', async () => {
const mailPollCalls = [];
@@ -1155,7 +1218,7 @@ test('verification flow notifies onResendRequestedAt when resend is triggered',
{ provider: 'qq', label: 'QQ 邮箱' },
{
maxResendRequests: 1,
resendIntervalMs: 25000,
resendIntervalMs: 0,
onResendRequestedAt: async (requestedAt) => {
resendRequestedAtCalls.push(Number(requestedAt) || 0);
},
@@ -1270,3 +1333,196 @@ test('verification flow treats retryable submit transport failure as success whe
assert.equal(result.transportRecovered, true);
assert.equal(logs.some(({ message }) => /验证码提交后页面已切换到ChatGPT 已登录首页/.test(message)), true);
});
test('verification flow avoids resend storms when iCloud polling keeps hitting transport errors', async () => {
let resendRequests = 0;
let pollAttempts = 0;
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
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 === 'RESEND_VERIFICATION_CODE') {
resendRequests += 1;
return {};
}
return {};
},
sendToMailContentScriptResilient: async () => {
pollAttempts += 1;
throw new Error('Content script on icloud-mail did not respond in 1s. Try refreshing the tab and retry.');
},
setState: async () => {},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await assert.rejects(
helpers.pollFreshVerificationCodeWithResendInterval(
8,
{ email: 'user@example.com', lastLoginCode: null },
{ source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' },
{
intervalMs: 1000,
maxAttempts: 1,
resendIntervalMs: 25000,
maxResendRequests: 2,
}
),
/页面通信异常连续/
);
assert.equal(pollAttempts >= 6, true);
assert.equal(resendRequests, 0);
});
test('verification flow stops iCloud poll-only loop after repeated no-code rounds before resend', async () => {
let resendRequests = 0;
let pollCalls = 0;
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
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 === 'RESEND_VERIFICATION_CODE') {
resendRequests += 1;
}
return {};
},
sendToMailContentScriptResilient: async () => {
pollCalls += 1;
return {};
},
setState: async () => {},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await assert.rejects(
helpers.pollFreshVerificationCodeWithResendInterval(
8,
{ email: 'user@example.com', lastLoginCode: null },
{ source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' },
{
intervalMs: 1000,
maxAttempts: 1,
resendIntervalMs: 25000,
maxResendRequests: 2,
}
),
/空轮询循环|停止当前链路/
);
assert.equal(pollCalls >= 4, true);
assert.equal(resendRequests, 0);
});
test('verification flow caps iCloud polling response timeout to avoid long silent stalls', async () => {
const pollTimeouts = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
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 === 'RESEND_VERIFICATION_CODE') {
return {};
}
return {};
},
sendToMailContentScriptResilient: async (_mail, _message, options = {}) => {
pollTimeouts.push({
timeoutMs: Number(options.timeoutMs) || 0,
responseTimeoutMs: Number(options.responseTimeoutMs) || 0,
});
return {};
},
setState: async () => {},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await assert.rejects(
helpers.pollFreshVerificationCodeWithResendInterval(
4,
{ email: 'user@example.com', lastSignupCode: null },
{ source: 'icloud-mail', provider: 'icloud', label: 'iCloud 邮箱' },
{
intervalMs: 3000,
maxAttempts: 5,
resendIntervalMs: 25000,
maxResendRequests: 1,
}
),
/空轮询循环|停止当前链路/
);
assert.equal(pollTimeouts.length > 0, true);
assert.equal(pollTimeouts.every(({ timeoutMs }) => timeoutMs > 0 && timeoutMs <= 22000), true);
assert.equal(
pollTimeouts.every(({ responseTimeoutMs }) => responseTimeoutMs > 0 && responseTimeoutMs <= 18000),
true
);
});