feat(network): integrate outbound route module on Ultra1.4 baseline

This commit is contained in:
daniellee2015
2026-04-27 13:25:46 +08:00
parent c98cfd7053
commit 2706d98225
16 changed files with 7979 additions and 26 deletions
+446 -20
View File
@@ -7,6 +7,8 @@ importScripts(
'background/account-run-history.js',
'background/contribution-oauth.js',
'background/mail-2925-session.js',
'background/ip-proxy-provider-711proxy.js',
'background/ip-proxy-core.js',
'background/panel-bridge.js',
'background/generated-email-helpers.js',
'background/signup-flow-helpers.js',
@@ -211,6 +213,43 @@ const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus';
const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback';
const DEFAULT_IP_PROXY_SERVICE = '711proxy';
const IP_PROXY_SERVICE_VALUES = ['711proxy', 'lumiproxy', 'iproyal', 'omegaproxy'];
const IP_PROXY_ENABLED_SERVICE_VALUES = ['711proxy'];
const DEFAULT_IP_PROXY_MODE = 'account';
const IP_PROXY_MODE_VALUES = ['api', 'account'];
const DEFAULT_IP_PROXY_PROTOCOL = 'http';
const IP_PROXY_PROTOCOL_VALUES = ['http', 'https', 'socks4', 'socks5'];
const IP_PROXY_FETCH_TIMEOUT_MS = 20000;
const IP_PROXY_SETTINGS_SCOPE = 'regular';
const IP_PROXY_BYPASS_LIST = ['<local>', 'localhost', '127.0.0.1'];
const IP_PROXY_ROUTE_ALL_TRAFFIC = true;
const IP_PROXY_ACCOUNT_LIST_ENABLED = false;
const IP_PROXY_INIT_ENABLE_EXIT_PROBE = false;
const IP_PROXY_INIT_SUPPRESS_AUTH_REBIND = true;
const IP_PROXY_INIT_AUTO_APPLY = false;
const IP_PROXY_TARGET_HOST_PATTERNS = [
'openai.com',
'*.openai.com',
'chatgpt.com',
'*.chatgpt.com',
'ipwho.is',
'*.ipwho.is',
'ipapi.co',
'*.ipapi.co',
'ipinfo.io',
'*.ipinfo.io',
'api.ipify.org',
'api64.ipify.org',
'api.ip.cc',
'ifconfig.me',
'checkip.amazonaws.com',
'ipv4.icanhazip.com',
'ident.me',
'httpbin.org',
'ip-api.com',
'myip.ipip.net',
];
const AUTO_RUN_TIMER_ALARM_NAME = 'auto-run-timer';
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds';
@@ -376,6 +415,21 @@ const PERSISTED_SETTING_DEFAULTS = {
sub2apiPassword: '',
sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
ipProxyEnabled: false,
ipProxyService: DEFAULT_IP_PROXY_SERVICE,
ipProxyMode: DEFAULT_IP_PROXY_MODE,
ipProxyApiUrl: '',
ipProxyServiceProfiles: {},
ipProxyAccountList: '',
ipProxyAccountSessionPrefix: '',
ipProxyAccountLifeMinutes: '',
ipProxyPoolTargetCount: '20',
ipProxyHost: '',
ipProxyPort: '',
ipProxyProtocol: DEFAULT_IP_PROXY_PROTOCOL,
ipProxyUsername: '',
ipProxyPassword: '',
ipProxyRegion: '',
codex2apiUrl: DEFAULT_CODEX2API_URL,
codex2apiAdminKey: '',
customPassword: '',
@@ -485,6 +539,15 @@ const DEFAULT_STATE = {
sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。
logs: [], // 侧边栏展示的运行日志。
...PERSISTED_SETTING_DEFAULTS, // 合并 chrome.storage.local 中持久化保存的用户配置。
ipProxyApiPool: [],
ipProxyApiCurrentIndex: 0,
ipProxyApiCurrent: null,
ipProxyAccountPool: [],
ipProxyAccountCurrentIndex: 0,
ipProxyAccountCurrent: null,
ipProxyPool: [],
ipProxyCurrentIndex: 0,
ipProxyCurrent: null,
luckmailApiKey: '',
luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL,
luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE,
@@ -515,6 +578,21 @@ const DEFAULT_STATE = {
currentHotmailAccountId: null,
currentMail2925AccountId: null,
preferredIcloudHost: '',
ipProxyApplied: false,
ipProxyAppliedReason: 'disabled',
ipProxyAppliedAt: 0,
ipProxyAppliedHost: '',
ipProxyAppliedPort: 0,
ipProxyAppliedRegion: '',
ipProxyAppliedHasAuth: false,
ipProxyAppliedProvider: DEFAULT_IP_PROXY_SERVICE,
ipProxyAppliedError: '',
ipProxyAppliedWarning: '',
ipProxyAppliedExitIp: '',
ipProxyAppliedExitRegion: '',
ipProxyAppliedExitDetecting: false,
ipProxyAppliedExitError: '',
ipProxyAppliedExitSource: '',
};
function normalizeAutoRunDelayMinutes(value) {
@@ -1097,6 +1175,63 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '').trim();
case 'sub2apiDefaultProxyName':
return String(value || '').trim();
case 'ipProxyEnabled':
return Boolean(value);
case 'ipProxyService':
return normalizeIpProxyProviderValue(value);
case 'ipProxyMode':
return normalizeIpProxyMode(value);
case 'ipProxyApiUrl':
return String(value || '').trim();
case 'ipProxyServiceProfiles':
return normalizeIpProxyServiceProfiles(value || {}, PERSISTED_SETTING_DEFAULTS);
case 'ipProxyAccountList':
return normalizeIpProxyAccountList(value || '');
case 'ipProxyAccountSessionPrefix':
return normalizeIpProxyAccountSessionPrefix(value || '');
case 'ipProxyAccountLifeMinutes':
return normalizeIpProxyAccountLifeMinutes(value || '');
case 'ipProxyPoolTargetCount':
return normalizeIpProxyPoolTargetCount(value || '', 20);
case 'ipProxyHost':
return String(value || '').trim();
case 'ipProxyPort':
return String(normalizeIpProxyPort(value || '') || '');
case 'ipProxyProtocol':
return normalizeIpProxyProtocol(value);
case 'ipProxyUsername':
return String(value || '').trim();
case 'ipProxyPassword':
return String(value || '');
case 'ipProxyRegion':
return String(value || '').trim();
case 'ipProxyApiPool':
return normalizeProxyPoolEntries(
value,
normalizeIpProxyProviderValue(value?.provider || DEFAULT_IP_PROXY_SERVICE)
);
case 'ipProxyApiCurrentIndex':
return normalizeIpProxyCurrentIndex(value, 0);
case 'ipProxyApiCurrent':
return normalizeProxyPoolEntries(value ? [value] : [], DEFAULT_IP_PROXY_SERVICE)[0] || null;
case 'ipProxyAccountPool':
return normalizeProxyPoolEntries(
value,
normalizeIpProxyProviderValue(value?.provider || DEFAULT_IP_PROXY_SERVICE)
);
case 'ipProxyAccountCurrentIndex':
return normalizeIpProxyCurrentIndex(value, 0);
case 'ipProxyAccountCurrent':
return normalizeProxyPoolEntries(value ? [value] : [], DEFAULT_IP_PROXY_SERVICE)[0] || null;
case 'ipProxyPool':
return normalizeProxyPoolEntries(
value,
normalizeIpProxyProviderValue(value?.provider || DEFAULT_IP_PROXY_SERVICE)
);
case 'ipProxyCurrentIndex':
return normalizeIpProxyCurrentIndex(value, 0);
case 'ipProxyCurrent':
return normalizeProxyPoolEntries(value ? [value] : [], DEFAULT_IP_PROXY_SERVICE)[0] || null;
case 'codex2apiUrl':
return normalizeCodex2ApiUrl(value);
case 'codex2apiAdminKey':
@@ -1241,6 +1376,34 @@ function buildPersistentSettingsPayload(input = {}, options = {}) {
}
payload.cloudflareTempEmailDomains = domains;
}
if (payload.ipProxyServiceProfiles) {
const selectedService = normalizeIpProxyProviderValue(
payload.ipProxyService || PERSISTED_SETTING_DEFAULTS.ipProxyService
);
const normalizedProfiles = normalizeIpProxyServiceProfiles(payload.ipProxyServiceProfiles, {
...PERSISTED_SETTING_DEFAULTS,
...payload,
});
payload.ipProxyServiceProfiles = normalizedProfiles;
const activeProfile = normalizedProfiles[selectedService]
|| buildIpProxyServiceProfileFromState({
...PERSISTED_SETTING_DEFAULTS,
...payload,
});
payload.ipProxyService = selectedService;
payload.ipProxyMode = normalizeIpProxyMode(activeProfile?.mode || payload.ipProxyMode);
payload.ipProxyApiUrl = String(activeProfile?.apiUrl || payload.ipProxyApiUrl || '').trim();
payload.ipProxyAccountList = normalizeIpProxyAccountList(activeProfile?.accountList || payload.ipProxyAccountList || '');
payload.ipProxyAccountSessionPrefix = normalizeIpProxyAccountSessionPrefix(activeProfile?.accountSessionPrefix || payload.ipProxyAccountSessionPrefix || '');
payload.ipProxyAccountLifeMinutes = normalizeIpProxyAccountLifeMinutes(activeProfile?.accountLifeMinutes || payload.ipProxyAccountLifeMinutes || '');
payload.ipProxyPoolTargetCount = normalizeIpProxyPoolTargetCount(activeProfile?.poolTargetCount || payload.ipProxyPoolTargetCount || '', 20);
payload.ipProxyHost = String(activeProfile?.host || payload.ipProxyHost || '').trim();
payload.ipProxyPort = String(normalizeIpProxyPort(activeProfile?.port || payload.ipProxyPort || '') || '');
payload.ipProxyProtocol = normalizeIpProxyProtocol(activeProfile?.protocol || payload.ipProxyProtocol);
payload.ipProxyUsername = String(activeProfile?.username || payload.ipProxyUsername || '').trim();
payload.ipProxyPassword = String(activeProfile?.password || payload.ipProxyPassword || '');
payload.ipProxyRegion = String(activeProfile?.region || payload.ipProxyRegion || '').trim();
}
return payload;
}
@@ -6557,23 +6720,48 @@ const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([
]);
function waitForStepComplete(step, timeoutMs = 120000) {
return new Promise((resolve, reject) => {
throwIfStopped();
if (stepWaiters.has(step)) {
console.warn(LOG_PREFIX, `[waitForStepComplete] replacing existing waiter for step ${step}`);
}
console.log(LOG_PREFIX, `[waitForStepComplete] register step ${step}, timeout=${timeoutMs}ms`);
throwIfStopped();
const normalizedStep = Number(step);
const existingWaiter = stepWaiters.get(normalizedStep);
if (existingWaiter?.promise) {
console.log(LOG_PREFIX, `[waitForStepComplete] reuse existing waiter for step ${normalizedStep}`);
return existingWaiter.promise;
}
console.log(LOG_PREFIX, `[waitForStepComplete] register step ${normalizedStep}, timeout=${timeoutMs}ms`);
const waiter = {
promise: null,
resolve: null,
reject: null,
};
waiter.promise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
stepWaiters.delete(step);
console.warn(LOG_PREFIX, `[waitForStepComplete] timeout for step ${step} after ${timeoutMs}ms`);
reject(new Error(`步骤 ${step} 等待超时(>${timeoutMs / 1000} 秒)`));
if (stepWaiters.get(normalizedStep) === waiter) {
stepWaiters.delete(normalizedStep);
}
console.warn(LOG_PREFIX, `[waitForStepComplete] timeout for step ${normalizedStep} after ${timeoutMs}ms`);
reject(new Error(`步骤 ${normalizedStep} 等待超时(>${timeoutMs / 1000} 秒)`));
}, timeoutMs);
stepWaiters.set(step, {
resolve: (data) => { clearTimeout(timer); stepWaiters.delete(step); resolve(data); },
reject: (err) => { clearTimeout(timer); stepWaiters.delete(step); reject(err); },
});
waiter.resolve = (data) => {
clearTimeout(timer);
if (stepWaiters.get(normalizedStep) === waiter) {
stepWaiters.delete(normalizedStep);
}
resolve(data);
};
waiter.reject = (err) => {
clearTimeout(timer);
if (stepWaiters.get(normalizedStep) === waiter) {
stepWaiters.delete(normalizedStep);
}
reject(err);
};
});
stepWaiters.set(normalizedStep, waiter);
return waiter.promise;
}
function getStepExecutionKeyForState(step, state = {}) {
@@ -7290,6 +7478,85 @@ async function deleteAndBroadcastAccountRunHistoryRecords(recordIds = [], stateO
return result;
}
function resolveIpProxyCandidateCountForAutoSwitch(state = {}, mode = 'account', provider = DEFAULT_IP_PROXY_SERVICE) {
const normalizedMode = typeof normalizeIpProxyMode === 'function'
? normalizeIpProxyMode(mode)
: String(mode || 'account').trim().toLowerCase();
const normalizedProvider = typeof normalizeIpProxyProviderValue === 'function'
? normalizeIpProxyProviderValue(provider)
: String(provider || DEFAULT_IP_PROXY_SERVICE).trim().toLowerCase();
if (normalizedMode === 'account' && typeof getAccountModeProxyPoolFromState === 'function') {
const pool = getAccountModeProxyPoolFromState(state, normalizedProvider);
return Array.isArray(pool) ? pool.length : 0;
}
if (typeof getIpProxyRuntimeSnapshot === 'function') {
const runtime = getIpProxyRuntimeSnapshot(state, normalizedMode, normalizedProvider);
return Array.isArray(runtime?.pool) ? runtime.pool.length : 0;
}
return 0;
}
async function maybeSwitchIpProxyAfterAutoRunRoundSuccess(payload = {}) {
if (typeof switchIpProxy !== 'function') {
return null;
}
const successfulRuns = Number(payload?.successfulRuns) || 0;
if (successfulRuns <= 0) {
return null;
}
const state = await getState();
if (!state?.ipProxyEnabled) {
return null;
}
const mode = typeof normalizeIpProxyMode === 'function'
? normalizeIpProxyMode(state?.ipProxyMode)
: String(state?.ipProxyMode || 'account').trim().toLowerCase();
const provider = typeof normalizeIpProxyProviderValue === 'function'
? normalizeIpProxyProviderValue(state?.ipProxyService)
: String(state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE).trim().toLowerCase();
const threshold = typeof resolveIpProxyAutoSwitchThreshold === 'function'
? resolveIpProxyAutoSwitchThreshold(state)
: Math.max(1, Math.min(500, Number(state?.ipProxyPoolTargetCount) || 20));
if (successfulRuns % threshold !== 0) {
return null;
}
const candidateCount = resolveIpProxyCandidateCountForAutoSwitch(state, mode, provider);
if (candidateCount <= 1) {
await addLog(
`任务切换阈值命中(成功 ${successfulRuns} 轮 / 阈值 ${threshold}),但当前仅 ${candidateCount} 条可切换代理,已跳过自动切换。`,
'info'
);
return {
skipped: true,
reason: 'insufficient_candidates',
candidateCount,
threshold,
successfulRuns,
};
}
const switchResult = await switchIpProxy('next', {
mode,
state,
forceRefresh: mode === 'api',
maxItems: typeof resolveIpProxyPoolTargetCountForMode === 'function'
? resolveIpProxyPoolTargetCountForMode(state, mode)
: undefined,
});
const display = String(switchResult?.display || '').trim();
const routingApplied = Boolean(switchResult?.proxyRouting?.applied);
await addLog(
routingApplied
? `任务切换阈值命中(成功 ${successfulRuns} 轮 / 阈值 ${threshold}),已自动切换代理:${display || '已切换到下一条'}`
: `任务切换阈值命中(成功 ${successfulRuns} 轮 / 阈值 ${threshold}),已尝试自动切换代理,但连通性仍异常。`,
routingApplied ? 'ok' : 'warn'
);
return switchResult;
}
const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({
addLog,
appendAccountRunRecord: (...args) => appendAndBroadcastAccountRunRecord(...args),
@@ -7316,6 +7583,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
isStopError,
launchAutoRunTimerPlan,
normalizeAutoRunFallbackThreadIntervalMinutes,
onAutoRunRoundSuccess: (payload = {}) => maybeSwitchIpProxyAfterAutoRunRoundSuccess(payload),
persistAutoRunTimerPlan,
resetState,
runAutoSequenceFromStep: (...args) => runAutoSequenceFromStep(...args),
@@ -8118,6 +8386,7 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({
addLog,
chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground,
confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass,
ensureMail2925MailboxSession,
ensureIcloudMailSession: ensureIcloudMailSessionForVerification,
@@ -8234,6 +8503,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
buildLuckmailSessionSettingsPayload,
buildPersistentSettingsPayload,
broadcastDataUpdate,
applyIpProxySettingsFromState,
cancelScheduledAutoRun,
checkIcloudSession,
clearAccountRunHistory: (...args) => clearAndBroadcastAccountRunHistory(...args),
@@ -8290,6 +8560,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
launchAutoRunTimerPlan,
listIcloudAliases,
listLuckmailPurchasesForManagement,
refreshIpProxyPool,
getCurrentMail2925Account,
normalizeHotmailAccounts,
normalizeMail2925Accounts,
@@ -8301,10 +8572,13 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
patchMail2925Account,
registerTab,
requestStop,
probeIpProxyExit,
resetState,
resumeAutoRun,
scheduleAutoRun,
selectLuckmailPurchase,
switchIpProxy,
changeIpProxyExit,
setCurrentHotmailAccount,
setCurrentMail2925Account,
setContributionMode,
@@ -8819,6 +9093,38 @@ function isAddPhoneAuthState(authState = {}) {
}
async function getPostStep6AutoRestartDecision(step, error) {
const resolveStepKey = (stepId, state) => {
if (typeof getStepExecutionKeyForState === 'function') {
return getStepExecutionKeyForState(stepId, state);
}
return String(
typeof getStepDefinitionForState === 'function'
? (getStepDefinitionForState(stepId, state)?.key || '')
: ''
).trim();
};
const findStepIdByKeyForState = (targetKey, state = {}) => {
const normalizedKey = String(targetKey || '').trim();
if (!normalizedKey) {
return null;
}
const stepIds = typeof getStepIdsForState === 'function'
? getStepIdsForState(state)
: [];
for (const stepId of stepIds) {
if (resolveStepKey(stepId, state) === normalizedKey) {
return Number(stepId);
}
}
return null;
};
const isPlatformVerifyTransientRetryError = (errorMessage = '') => {
const normalizedMessage = String(errorMessage || '');
const mentionsTokenExchange = /auth\.openai\.com\/oauth\/token/i.test(normalizedMessage);
const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage);
return mentionsTokenExchange && hasTransientNetworkSignal;
};
const normalizedStep = Number(step);
const errorMessage = getErrorMessage(error);
const shouldForceRestartFromStep7 = /restart step 7 with a new number/i.test(errorMessage);
@@ -8829,6 +9135,14 @@ async function getPostStep6AutoRestartDecision(step, error) {
const lastStepId = typeof getLastStepIdForState === 'function'
? getLastStepIdForState(latestState)
: (typeof LAST_STEP_ID === 'number' ? LAST_STEP_ID : 10);
const currentStepKey = resolveStepKey(normalizedStep, latestState);
const confirmOauthStep = findStepIdByKeyForState('confirm-oauth', latestState);
const shouldRetryFromConfirmStep = currentStepKey === 'platform-verify'
&& Number.isFinite(confirmOauthStep)
&& confirmOauthStep > 0
&& confirmOauthStep < normalizedStep
&& isPlatformVerifyTransientRetryError(errorMessage);
const restartAnchorStep = shouldRetryFromConfirmStep ? confirmOauthStep : authChainStartStep;
if (!Number.isFinite(normalizedStep) || normalizedStep < authChainStartStep || normalizedStep > lastStepId) {
return {
shouldRestart: false,
@@ -8865,7 +9179,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
let authState = null;
try {
authState = await getLoginAuthStateFromContent({
logMessage: `步骤 ${normalizedStep}:正在确认当前认证页状态,以决定是否回到步骤 ${authChainStartStep} 重开...`,
logMessage: `步骤 ${normalizedStep}:正在确认当前认证页状态,以决定是否回到步骤 ${restartAnchorStep} 重开...`,
});
} catch (inspectError) {
console.warn(LOG_PREFIX, '[AutoRun] failed to inspect login auth state after post-step6 error', {
@@ -8890,7 +9204,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
shouldRestart: true,
blockedByAddPhone: false,
forcedByPhoneVerificationTimeout: false,
restartStep: authChainStartStep,
restartStep: restartAnchorStep,
errorMessage,
authState,
};
@@ -8923,8 +9237,12 @@ async function getLoginAuthStateFromContent(options = {}) {
async function ensureStep8VerificationPageReady(options = {}) {
const visibleStep = Number(options.visibleStep) || 8;
const authLoginStep = Number(options.authLoginStep) || (visibleStep >= 11 ? 10 : 7);
const pageState = await getLoginAuthStateFromContent(options);
if (pageState.state === 'verification_page') {
const inspectState = async (overrides = {}) => getLoginAuthStateFromContent({
...options,
...overrides,
});
let pageState = await inspectState();
if (pageState.state === 'verification_page' || pageState.state === 'oauth_consent_page') {
return pageState;
}
@@ -8933,11 +9251,79 @@ async function ensureStep8VerificationPageReady(options = {}) {
}
if (pageState.state === 'login_timeout_error_page') {
let recovered = false;
try {
const recoverPayload = {
flow: 'login',
logLabel: `步骤 ${visibleStep}:检测到登录超时报错,正在点击“重试”恢复当前页面`,
step: visibleStep,
timeoutMs: 12000,
};
const recoverMessage = {
type: 'RECOVER_AUTH_RETRY_PAGE',
source: 'background',
payload: recoverPayload,
};
let recoverResult = null;
const recoverTimeoutMs = 15000;
if (typeof sendToContentScriptResilient === 'function') {
recoverResult = await sendToContentScriptResilient(
'signup-page',
recoverMessage,
{
timeoutMs: recoverTimeoutMs,
responseTimeoutMs: recoverTimeoutMs,
retryDelayMs: 700,
logMessage: `步骤 ${visibleStep}:认证页进入重试/超时报错状态,正在尝试点击“重试”恢复...`,
}
);
} else if (typeof sendToContentScript === 'function') {
recoverResult = await sendToContentScript('signup-page', recoverMessage, {
responseTimeoutMs: recoverTimeoutMs,
});
}
if (recoverResult?.error) {
throw new Error(recoverResult.error);
}
recovered = Boolean(recoverResult?.recovered || Number(recoverResult?.clickCount) > 0);
if (recovered && typeof addLog === 'function') {
await addLog(`步骤 ${visibleStep}:认证页已点击“重试”,正在重新确认验证码页状态...`, 'warn');
}
} catch (recoverError) {
const recoverMessage = getErrorMessage(recoverError);
if (/^CF_SECURITY_BLOCKED::/i.test(recoverMessage)) {
throw recoverError;
}
if (typeof addLog === 'function') {
await addLog(`步骤 ${visibleStep}:认证页“重试”恢复失败:${recoverMessage}`, 'warn');
}
}
if (recovered) {
pageState = await inspectState({
timeoutMs: 10000,
responseTimeoutMs: 10000,
retryDelayMs: 500,
logMessage: `步骤 ${visibleStep}:认证页恢复后,正在确认验证码页是否可继续...`,
});
if (pageState.state === 'verification_page' || pageState.state === 'oauth_consent_page') {
return pageState;
}
if (pageState.maxCheckAttemptsBlocked) {
throw new Error(`${CLOUDFLARE_SECURITY_BLOCK_ERROR_PREFIX}${CLOUDFLARE_SECURITY_BLOCK_USER_MESSAGE}`);
}
if (pageState.state === 'add_phone_page' || pageState.state === 'phone_verification_page') {
const urlPart = pageState.url ? ` URL: ${pageState.url}` : '';
throw new Error(`步骤 ${visibleStep}:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim());
}
}
const urlPart = pageState.url ? ` URL: ${pageState.url}` : '';
throw new Error(`STEP8_RESTART_STEP7::步骤 ${visibleStep}:当前认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim());
}
if (pageState.state === 'add_phone_page') {
if (pageState.state === 'add_phone_page' || pageState.state === 'phone_verification_page') {
const urlPart = pageState.url ? ` URL: ${pageState.url}` : '';
throw new Error(`步骤 ${visibleStep}:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim());
}
@@ -9144,7 +9530,15 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS)
throw new Error('步骤 9:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
}
if (pageState?.retryPage) {
throw new Error(`步骤 9:当前认证页已进入重试页,当前流程将直接报错。URL: ${pageState.url || 'unknown'}`);
const retryUrl = String(pageState?.url || '').trim();
const consentLikeRetry = Boolean(
pageState?.consentReady
|| pageState?.consentPage
|| /\/sign-in-with-chatgpt\/[^/?#]+\/consent(?:[/?#]|$)/i.test(retryUrl)
);
if (!consentLikeRetry) {
throw new Error(`步骤 9:当前认证页已进入重试页,当前流程将直接报错。URL: ${pageState.url || 'unknown'}`);
}
}
if (pageState?.consentReady) {
return pageState;
@@ -9311,7 +9705,15 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI
throw new Error('步骤 9:点击“继续”后页面跳到了手机号页面,当前流程无法继续自动授权。');
}
if (pageState?.retryPage) {
throw new Error(`步骤 9:点击“继续”后页面进入认证页重试页,当前流程将直接报错。URL: ${pageState.url || baselineUrl || 'unknown'}`);
const retryUrl = String(pageState?.url || baselineUrl || '').trim();
const consentLikeRetry = Boolean(
pageState?.consentReady
|| pageState?.consentPage
|| /\/sign-in-with-chatgpt\/[^/?#]+\/consent(?:[/?#]|$)/i.test(retryUrl)
);
if (!consentLikeRetry) {
throw new Error(`步骤 9:点击“继续”后页面进入认证页重试页,当前流程将直接报错。URL: ${pageState.url || baselineUrl || 'unknown'}`);
}
}
if (pageState === null) {
if (!recovered) {
@@ -9488,14 +9890,38 @@ chrome.runtime.onStartup.addListener(() => {
restoreAutoRunTimerIfNeeded().catch((err) => {
console.error(LOG_PREFIX, 'Failed to restore auto run timer on startup:', err);
});
if (IP_PROXY_INIT_AUTO_APPLY) {
ensureIpProxySettingsAppliedFromCurrentState({
skipExitProbe: !IP_PROXY_INIT_ENABLE_EXIT_PROBE,
suppressAuthRebind: IP_PROXY_INIT_SUPPRESS_AUTH_REBIND,
}).catch((err) => {
console.error(LOG_PREFIX, 'Failed to restore IP proxy settings on startup:', err);
});
}
});
chrome.runtime.onInstalled.addListener(() => {
restoreAutoRunTimerIfNeeded().catch((err) => {
console.error(LOG_PREFIX, 'Failed to restore auto run timer on install/update:', err);
});
if (IP_PROXY_INIT_AUTO_APPLY) {
ensureIpProxySettingsAppliedFromCurrentState({
skipExitProbe: !IP_PROXY_INIT_ENABLE_EXIT_PROBE,
suppressAuthRebind: IP_PROXY_INIT_SUPPRESS_AUTH_REBIND,
}).catch((err) => {
console.error(LOG_PREFIX, 'Failed to restore IP proxy settings on install/update:', err);
});
}
});
restoreAutoRunTimerIfNeeded().catch((err) => {
console.error(LOG_PREFIX, 'Failed to restore auto run timer:', err);
});
if (IP_PROXY_INIT_AUTO_APPLY) {
ensureIpProxySettingsAppliedFromCurrentState({
skipExitProbe: !IP_PROXY_INIT_ENABLE_EXIT_PROBE,
suppressAuthRebind: IP_PROXY_INIT_SUPPRESS_AUTH_REBIND,
}).catch((err) => {
console.error(LOG_PREFIX, 'Failed to restore IP proxy settings:', err);
});
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
// background/ip-proxy-provider-711proxy.js — 711Proxy 参数与账号规则
(function register711ProxyProvider(root) {
function normalizeCountryCode(value = '') {
const raw = String(value || '').trim().toUpperCase().replace(/[^A-Z]/g, '');
return /^[A-Z]{2}$/.test(raw) ? raw : '';
}
function normalize711SessionId(value = '') {
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
}
function normalize711SessionMinutes(value = '') {
const raw = String(value ?? '').trim();
if (!raw) return '';
const numeric = Number.parseInt(raw, 10);
if (!Number.isInteger(numeric)) return '';
return String(Math.max(1, Math.min(180, numeric)));
}
function apply711SessionToUsername(username = '', options = {}) {
const text = String(username || '').trim();
if (!text) {
return text;
}
const sessionId = normalize711SessionId(options?.sessionId || '');
const sessTime = normalize711SessionMinutes(options?.sessTime || '');
let next = text;
if (sessionId) {
if (/(?:^|[-_])session[-_:][A-Za-z0-9_-]+?(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i.test(next)) {
next = next.replace(
/((?:^|[-_])session[-_:])([A-Za-z0-9_-]+?)(?=(?:[-_](?:sessTime|sessAuto|region|life|zone|ptype|country|area)\b)|$)/i,
`$1${sessionId}`
);
} else {
next = `${next}-session-${sessionId}`;
}
}
if (sessTime) {
if (/(?:^|[-_])sessTime[-_:]?\d+\b/i.test(next)) {
next = next.replace(/((?:^|[-_])sessTime[-_:]?)(\d+)\b/i, `$1${sessTime}`);
} else {
next = `${next}-sessTime-${sessTime}`;
}
}
return next;
}
function apply711RegionToUsername(username = '', regionCode = '') {
const text = String(username || '').trim();
const normalizedRegion = normalizeCountryCode(regionCode);
if (!text || !normalizedRegion) {
return text;
}
if (/(?:^|[-_])region[-_:]?[A-Za-z]{2}\b/i.test(text)) {
return text.replace(/((?:^|[-_])region[-_:]?)([A-Za-z]{2})\b/i, `$1${normalizedRegion}`);
}
return `${text}-region-${normalizedRegion}`;
}
function transform711ProxyAccountEntry(entry = {}, context = {}) {
const state = context?.state || {};
const hasAccountList = Boolean(context?.hasAccountList);
const nextEntry = { ...entry };
const username = String(nextEntry.username || '').trim();
if (!username) {
return nextEntry;
}
const configuredRegion = normalizeCountryCode(state?.ipProxyRegion || '');
if (!hasAccountList && configuredRegion) {
nextEntry.username = apply711RegionToUsername(username, configuredRegion);
if (!String(nextEntry.region || '').trim()) {
nextEntry.region = configuredRegion;
}
}
// 账号列表模式按每行原样生效,不叠加固定账号区的 session/sessTime。
if (hasAccountList) {
return nextEntry;
}
const sessionId = normalize711SessionId(state?.ipProxyAccountSessionPrefix || '');
const sessTime = normalize711SessionMinutes(state?.ipProxyAccountLifeMinutes || '');
if (!sessionId && !sessTime) {
return nextEntry;
}
nextEntry.username = apply711SessionToUsername(nextEntry.username, {
sessionId,
sessTime,
});
return nextEntry;
}
root.transformIpProxyAccountEntryByProvider = function transformIpProxyAccountEntryByProvider(
provider = '',
entry = {},
context = {}
) {
const normalizedProvider = String(provider || '').trim().toLowerCase();
if (normalizedProvider === '711proxy') {
return transform711ProxyAccountEntry(entry, context);
}
return entry;
};
})(typeof self !== 'undefined' ? self : globalThis);
+108 -1
View File
@@ -10,6 +10,7 @@
buildLuckmailSessionSettingsPayload,
buildPersistentSettingsPayload,
broadcastDataUpdate,
applyIpProxySettingsFromState,
cancelScheduledAutoRun,
checkIcloudSession,
clearAccountRunHistory,
@@ -58,6 +59,7 @@
launchAutoRunTimerPlan,
listIcloudAliases,
listLuckmailPurchasesForManagement,
refreshIpProxyPool,
normalizeHotmailAccounts,
normalizeMail2925Accounts,
normalizeRunCount,
@@ -69,11 +71,14 @@
pollContributionStatus,
registerTab,
requestStop,
probeIpProxyExit,
handleCloudflareSecurityBlocked,
resetState,
resumeAutoRun,
scheduleAutoRun,
selectLuckmailPurchase,
switchIpProxy,
changeIpProxyExit,
setCurrentMail2925Account,
setCurrentHotmailAccount,
setContributionMode,
@@ -644,6 +649,34 @@
stateUpdates.currentStep = 0;
}
await setState(stateUpdates);
const mergedState = await getState();
const hasIpProxyUpdates = Object.keys(updates).some((key) => key.startsWith('ipProxy'));
const hasIpProxyEnabledUpdate = Object.prototype.hasOwnProperty.call(updates, 'ipProxyEnabled');
const previousIpProxyEnabled = Boolean(currentState?.ipProxyEnabled);
const nextIpProxyEnabled = hasIpProxyEnabledUpdate
? Boolean(updates.ipProxyEnabled)
: previousIpProxyEnabled;
// 仅在“手动开关代理”时自动应用。
// 其他字段改动(host/账号/地区/session 等)需由“同步/下一条/检测出口/Change”显式触发。
const shouldApplyIpProxyOnSave = hasIpProxyUpdates
&& hasIpProxyEnabledUpdate
&& previousIpProxyEnabled !== nextIpProxyEnabled;
let proxyRouting = null;
if (shouldApplyIpProxyOnSave && typeof applyIpProxySettingsFromState === 'function') {
const isEnablingProxy = !previousIpProxyEnabled && nextIpProxyEnabled;
proxyRouting = await applyIpProxySettingsFromState(mergedState, {
// 手动开启时自动应用一次代理,不做出口探测;
// 出口探测由“同步/检测出口”按钮显式触发,避免开启即误判为失败。
skipExitProbe: true,
resetNetworkState: false,
forceAuthRebind: false,
suppressAuthRebind: !isEnablingProxy,
}).catch((error) => ({
applied: false,
reason: 'apply_failed',
error: error?.message || String(error || '代理应用失败'),
}));
}
if (Boolean(currentState?.contributionMode) && typeof setContributionMode === 'function') {
await setContributionMode(true);
}
@@ -655,7 +688,81 @@
'info'
);
}
return { ok: true, state: await getState() };
return { ok: true, state: await getState(), proxyRouting };
}
case 'REFRESH_IP_PROXY_POOL': {
if (typeof refreshIpProxyPool !== 'function') {
throw new Error('IP 代理池能力尚未接入。');
}
const result = await refreshIpProxyPool({
maxItems: message.payload?.maxItems,
mode: message.payload?.mode,
});
return { ok: true, ...result };
}
case 'SWITCH_IP_PROXY': {
if (typeof switchIpProxy !== 'function') {
throw new Error('IP 代理切换能力尚未接入。');
}
const result = await switchIpProxy(message.payload?.direction || 'next', {
maxItems: message.payload?.maxItems,
mode: message.payload?.mode,
forceRefresh: message.payload?.forceRefresh,
});
return { ok: true, ...result };
}
case 'CHANGE_IP_PROXY_EXIT': {
if (typeof changeIpProxyExit !== 'function') {
throw new Error('IP 代理 Change 能力尚未接入。');
}
const result = await changeIpProxyExit({
mode: message.payload?.mode,
});
return { ok: true, ...result };
}
case 'PROBE_IP_PROXY_EXIT': {
if (typeof probeIpProxyExit !== 'function') {
throw new Error('IP 代理出口检测能力尚未接入。');
}
const probeState = await getState();
const mode = typeof normalizeIpProxyMode === 'function'
? normalizeIpProxyMode(probeState?.ipProxyMode)
: String(probeState?.ipProxyMode || 'account').trim().toLowerCase();
const provider = typeof normalizeIpProxyProviderValue === 'function'
? normalizeIpProxyProviderValue(probeState?.ipProxyService)
: String(probeState?.ipProxyService || '').trim().toLowerCase();
const is711AccountMode = mode === 'account' && provider === '711proxy';
const previousReason = String(probeState?.ipProxyAppliedReason || '').trim().toLowerCase();
const previousExitError = String(probeState?.ipProxyAppliedExitError || '').trim();
const hadMissingAuthChallenge = /challenge=0|provided=0|未触发代理鉴权挑战|未收到 407/i.test(previousExitError);
const shouldPreRebindBeforeProbe = Boolean(
probeState?.ipProxyEnabled
&& is711AccountMode
&& (hadMissingAuthChallenge || previousReason === 'connectivity_failed')
);
const timeoutMs = Number(message.payload?.timeoutMs) > 0
? Number(message.payload.timeoutMs)
: (is711AccountMode ? (shouldPreRebindBeforeProbe ? 8000 : 6000) : undefined);
// 手动“检测出口”前先轻量应用当前配置,避免读取到旧代理链路状态。
if (probeState?.ipProxyEnabled && typeof applyIpProxySettingsFromState === 'function') {
await applyIpProxySettingsFromState(probeState, {
skipExitProbe: true,
resetNetworkState: shouldPreRebindBeforeProbe,
forceAuthRebind: shouldPreRebindBeforeProbe,
suppressAuthRebind: !shouldPreRebindBeforeProbe,
}).catch(() => null);
}
const result = await probeIpProxyExit({
timeoutMs,
authRebindMaxAttempts: is711AccountMode ? 1 : undefined,
});
return { ok: true, ...result };
}
case 'EXPORT_SETTINGS': {
+95 -5
View File
@@ -8,6 +8,7 @@
addLog,
chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground,
confirmCustomVerificationStepBypass,
ensureMail2925MailboxSession,
ensureIcloudMailSession,
@@ -68,6 +69,68 @@
return String(value || '').trim().toLowerCase();
}
async function completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, options = {}) {
await setState({
step8VerificationTargetEmail: '',
loginVerificationRequestedAt: null,
});
const fromRecovery = Boolean(options.fromRecovery);
await addLog(
`步骤 ${visibleStep}:当前认证页已进入 OAuth 授权页${fromRecovery ? '(轮询失败后复核)' : ''},跳过登录验证码拉取并继续后续流程。`,
'warn'
);
if (typeof completeStepFromBackground === 'function') {
await completeStepFromBackground(visibleStep, {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
directOAuthConsentPage: true,
});
}
}
function isStep8AddPhoneStateError(error) {
const message = String(error?.message || error || '');
return /add-phone|手机号页面|手机号验证页|phone[\s-_]verification|phone\s+number/i.test(message);
}
async function recoverStep8PollingFailure(currentState, visibleStep) {
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
try {
const pageState = await ensureStep8VerificationPageReady({
visibleStep,
authLoginStep,
timeoutMs: await getStep8ReadyTimeoutMs(
'登录验证码轮询异常后复核认证页状态',
currentState?.oauthUrl || '',
visibleStep
),
});
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true });
return { outcome: 'completed' };
}
if (pageState?.state === 'verification_page') {
await addLog(
`步骤 ${visibleStep}:检测到邮箱轮询/页面通信异常,但认证页仍在验证码页,先在当前链路重试,不回到步骤 ${authLoginStep}`,
'warn'
);
return { outcome: 'retry_without_step7' };
}
} catch (inspectError) {
if (isStep8RestartStep7Error(inspectError)) {
return { outcome: 'restart_step7', error: inspectError };
}
if (isStep8AddPhoneStateError(inspectError)) {
throw inspectError;
}
await addLog(
`步骤 ${visibleStep}:轮询失败后复核认证页状态异常:${inspectError?.message || inspectError},将回到步骤 ${authLoginStep} 重试。`,
'warn'
);
}
return { outcome: 'restart_step7' };
}
function getExpectedMail2925MailboxEmail(state = {}) {
if (Boolean(state?.mail2925UseAccountPool)) {
const currentAccountId = String(state?.currentMail2925AccountId || '').trim();
@@ -131,6 +194,10 @@
authLoginStep: getAuthLoginStepForVisibleStep(visibleStep),
timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep),
});
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep);
return;
}
const shouldCompareVerificationEmail = mail.provider !== '2925';
const displayedVerificationEmail = shouldCompareVerificationEmail
? normalizeStep8VerificationTargetEmail(pageState?.displayedEmail)
@@ -224,24 +291,47 @@
} catch (err) {
const visibleStep = getVisibleStep(currentState, 8);
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
if (!isVerificationMailPollingError(err) && !isStep8RestartStep7Error(err)) {
throw err;
let currentError = err;
let retryWithoutStep7 = false;
const isMailPollingError = isVerificationMailPollingError(err);
if (isMailPollingError && !isStep8RestartStep7Error(err)) {
const recovery = await recoverStep8PollingFailure(currentState, visibleStep);
if (recovery?.outcome === 'completed') {
return;
}
if (recovery?.outcome === 'retry_without_step7') {
retryWithoutStep7 = true;
}
if (recovery?.error) {
currentError = recovery.error;
}
}
if (!isVerificationMailPollingError(currentError) && !isStep8RestartStep7Error(currentError)) {
throw currentError;
}
lastMailPollingError = err;
lastMailPollingError = currentError;
if (mailPollingAttempt >= STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS) {
break;
}
mailPollingAttempt += 1;
if (retryWithoutStep7) {
await addLog(
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}`,
'warn'
);
currentState = await getState();
continue;
}
await addLog(
isStep8RestartStep7Error(err)
isStep8RestartStep7Error(currentError)
? `步骤 ${visibleStep}:检测到认证页进入重试/超时报错状态,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}...`
: `步骤 ${visibleStep}:检测到邮箱轮询类失败,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}...`,
'warn'
);
await rerunStep7ForStep8Recovery({
logMessage: isStep8RestartStep7Error(err)
logMessage: isStep8RestartStep7Error(currentError)
? `步骤 ${visibleStep}:认证页进入重试/超时报错状态,正在回到步骤 ${authLoginStep} 重新发起登录流程...`
: `步骤 ${visibleStep}:正在回到步骤 ${authLoginStep},重新发起登录验证码流程...`,
});
+105
View File
@@ -129,6 +129,86 @@
};
}
async function detectStep8PostSubmitFallback(options = {}) {
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 9000);
const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || 300);
const step = Number(options.step) || 8;
const startedAt = Date.now();
let lastSnapshot = null;
while (Date.now() - startedAt < timeoutMs) {
throwIfStopped();
try {
const request = {
type: 'GET_LOGIN_AUTH_STATE',
source: 'background',
payload: {},
};
const requestTimeoutMs = Math.max(1200, Math.min(5000, timeoutMs));
const result = typeof sendToContentScriptResilient === 'function'
? await sendToContentScriptResilient(
'signup-page',
request,
{
timeoutMs: requestTimeoutMs,
responseTimeoutMs: requestTimeoutMs,
retryDelayMs: 400,
logMessage: `步骤 ${step}:验证码提交后页面正在切换,等待页面恢复并确认授权状态...`,
}
)
: await sendToContentScript('signup-page', request, {
responseTimeoutMs: requestTimeoutMs,
});
if (result?.error) {
throw new Error(result.error);
}
const authState = String(result?.state || '').trim();
const authUrl = String(result?.url || '').trim();
lastSnapshot = {
state: authState || 'unknown',
url: authUrl,
};
if (authState === 'oauth_consent_page') {
return {
success: true,
reason: 'oauth_consent_page',
addPhonePage: false,
url: authUrl,
};
}
if (authState === 'add_phone_page' || authState === 'phone_verification_page') {
return {
success: true,
reason: 'add_phone_page',
addPhonePage: true,
url: authUrl || 'https://auth.openai.com/add-phone',
};
}
if (authState === 'login_timeout_error_page') {
return {
success: false,
reason: 'login_timeout_error_page',
restartStep7: true,
url: authUrl,
};
}
} catch (_) {
// Ignore transient inspect failures and keep polling.
}
await sleepWithStop(pollIntervalMs);
}
return {
success: false,
reason: 'unknown',
snapshot: lastSnapshot,
};
}
function getVerificationResendStateKey() {
return 'verificationResendCount';
}
@@ -772,6 +852,31 @@
};
}
}
if (step === 8 && isRetryableVerificationTransportError(err)) {
const fallback = await detectStep8PostSubmitFallback({
step,
timeoutMs: 9000,
pollIntervalMs: 300,
});
if (fallback.success) {
if (fallback.addPhonePage) {
await addLog('步骤 8:验证码提交后通信中断,但页面已进入手机号验证页,按提交成功继续。', 'warn');
} else {
await addLog('步骤 8:验证码提交后通信中断,但页面已进入 OAuth 授权页,按提交成功继续。', 'warn');
}
return {
success: true,
assumed: true,
transportRecovered: true,
addPhonePage: Boolean(fallback.addPhonePage),
url: fallback.url || '',
};
}
if (fallback.restartStep7) {
const urlPart = fallback.url ? ` URL: ${fallback.url}` : '';
throw new Error(`STEP8_RESTART_STEP7::步骤 8:验证码提交后认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim());
}
}
throw err;
}
} else {
+131
View File
@@ -0,0 +1,131 @@
# IP Proxy 模块说明(Ultra1 集成版)
## 1. 模块定位
`IP Proxy` 是扩展内的网络出口接管模块,用于把自动化流程的页面访问链路切到指定代理节点,并在侧边栏内可视化展示当前代理与出口状态。
本模块目标是解决三件事:
1. 在扩展内统一配置和切换代理,而不是依赖浏览器外部全局代理。
2. 对“代理已配置但未真正接管/未真正出站”的情况进行显式诊断。
3. 在自动化流程中可控地切换出口,降低单节点波动影响。
## 2. 代码结构
### 2.1 Background(核心逻辑)
- `background/ip-proxy-core.js`
- 代理池解析与运行态管理
- 应用/清除代理配置
- 出口探测与状态诊断
- `SYNC/NEXT/CHANGE/PROBE` 主流程
- `background/ip-proxy-provider-711proxy.js`
- Provider 级别参数处理(当前重点是 711 账号串 token 规则)
- `background/message-router.js`
- 暴露消息接口:
- `REFRESH_IP_PROXY_POOL`
- `SWITCH_IP_PROXY`
- `CHANGE_IP_PROXY_EXIT`
- `PROBE_IP_PROXY_EXIT`
- `background.js`
- 持久化字段定义与默认值
- 启动恢复时的代理状态接管
- 自动运行成功后的代理切换钩子
### 2.2 Sidepanel(界面与交互)
- `sidepanel/ip-proxy-panel.js`
- 代理 UI 状态渲染
- 按钮行为(同步/下一条/Change/检测出口)
- 运行态文案和诊断详情展示
- 711 账号参数双向同步(`session/sessTime/region`
- `sidepanel/ip-proxy-provider-711proxy.js`
- Provider 级输入辅助(地区推断)
- `sidepanel/sidepanel.html`
- 代理区块 UI
- `sidepanel/sidepanel.css`
- 代理区块样式
- `sidepanel/sidepanel.js`
- 与设置保存流整合、事件绑定
## 3. 当前功能范围(本 PR
### 3.1 基础能力
1. 启用/禁用代理接管(PAC + 认证回填)。
2. 固定账号模式(Host/Port/Username/Password/Region)。
3. 出口探测与状态卡(当前代理、当前出口、诊断详情)。
4. 四个动作按钮:
- `同步`:应用当前配置并刷新运行态
- `下一条`:切到下一个可用节点
- `Change`:保持 session 的前提下重绑并换出口(711)
- `检测出口`:只做出口复测,不改节点
5. `检查IP`:打开 `https://ipinfo.io/what-is-my-ip`
### 3.2 711 账号串参数联动
支持从用户名中识别并回填:
- `session-xxxx`
- `sessTime-xx`(兼容 `life-xx`
- `region-XX`
支持从表单回写到用户名:
- 修改会话值 -> 更新 `session-*`
- 修改时长 -> 更新 `sessTime-*`
- 修改地区 -> 更新 `region-*`
### 3.3 同步后的自动复测
为避免“同步后还要手动点检测出口”:
- `同步/下一条/Change` 执行完成后,自动追加一次静默 `检测出口`
## 4. 当前发布策略(为了稳定)
本 PR 是“先可用,再扩展”的第一阶段:
1. 服务商主路径按 `711` 优先。
2. `API 模式`在 UI 中保留但禁用(暂未开放)。
3. `账号列表模式`目前关闭(防止多条目与鉴权缓存复用带来的不稳定)。
对应开关位于:
- `sidepanel/sidepanel.js``IP_PROXY_API_MODE_ENABLED = false`
- `sidepanel/sidepanel.js``IP_PROXY_ACCOUNT_LIST_ENABLED = false`
- `background.js``IP_PROXY_ACCOUNT_LIST_ENABLED = false`
## 5. 使用方式(操作步骤)
1. 打开侧边栏 `IP代理` 开关。
2. 选择账号模式,填写:
- Host
- Port
- Username
- Password
- Region(可选)
3. 点击 `同步`
4. 查看状态卡:
- 当前代理
- 当前出口
- 是否有校验提示
5. 需要换出口时:
- 711 + session 场景优先用 `Change`
- 或使用 `下一条`
6. 需要手动复核时点击 `检测出口``检查IP`
## 6. 已知限制
1. 某些代理链路不会返回标准 `407`,扩展无法触发认证回填,这类链路可能不稳定。
2. 地区“期望值”和“实际出口”不一致时,模块会保留接管并提示校验告警(不直接判定全失败)。
3. 不同探测源(页面探测/后台兜底)在网络波动时可能短时出现差异,状态卡会显示来源与诊断。
## 7. 回归建议
建议至少覆盖以下场景:
1. 固定账号:启用 -> 同步 -> 出口检测成功。
2. 固定账号:`session/sessTime/region` 双向同步正确。
3. 固定账号:`同步/下一条/Change` 后无需手动点检测即可刷新出口状态。
4. 非标准链路失败时,诊断信息可读且不误报“已接管成功”。
+3
View File
@@ -9,6 +9,9 @@
"alarms",
"tabs",
"webNavigation",
"webRequest",
"webRequestAuthProvider",
"proxy",
"declarativeNetRequest",
"debugger",
"browsingData",
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
// sidepanel/ip-proxy-provider-711proxy.js — 711Proxy 面板专属逻辑
function normalizeIpProxyCountryCode(value = '') {
const raw = String(value || '').trim().toUpperCase().replace(/[^A-Z]/g, '');
return /^[A-Z]{2}$/.test(raw) ? raw : '';
}
function infer711RegionFromHost(host = '') {
const text = String(host || '').trim().toLowerCase().replace(/\.$/, '');
if (!text || !text.includes('.')) {
return '';
}
const firstLabel = String(text.split('.')[0] || '').trim();
return /^[a-z]{2}$/.test(firstLabel) ? firstLabel.toUpperCase() : '';
}
function infer711RegionFromUsername(username = '') {
const text = String(username || '').trim();
if (!text) {
return '';
}
const match = text.match(/(?:^|[-_])region[-_:]?([A-Za-z]{2})\b/i);
return normalizeIpProxyCountryCode(match ? match[1] : '');
}
function resolve711ProxyRegionFromInputs({ host = '', username = '', region = '' } = {}) {
const fromUsername = infer711RegionFromUsername(username);
if (fromUsername) {
return fromUsername;
}
const fromHost = infer711RegionFromHost(host);
if (fromHost) {
return fromHost;
}
return normalizeIpProxyCountryCode(region);
}
+242
View File
@@ -781,6 +781,248 @@ header {
min-width: 0;
}
.ip-proxy-fold-row {
display: block;
}
.ip-proxy-fold {
width: 100%;
border: none;
border-radius: 0;
background: transparent;
padding: 0;
}
.ip-proxy-fold-body {
display: flex;
flex-direction: column;
gap: 8px;
border-top: none;
padding-top: 0;
}
.ip-proxy-layout-row {
display: block;
}
.ip-proxy-layout {
width: 100%;
display: block;
}
.ip-proxy-layout.is-account-only {
display: block;
}
.ip-proxy-column {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.ip-proxy-column-header {
display: none;
}
.ip-proxy-column-hint {
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
}
.ip-proxy-column-mode-btn {
width: 100%;
justify-content: center;
}
.ip-proxy-column-api.is-disabled {
opacity: 0.55;
}
.ip-proxy-column-api.is-disabled .data-input,
.ip-proxy-column-api.is-disabled .data-select,
.ip-proxy-column-api.is-disabled .data-textarea,
.ip-proxy-column-api.is-disabled .input-icon-btn,
.ip-proxy-column-api.is-disabled .btn,
.ip-proxy-column-api.is-disabled .choice-btn {
cursor: not-allowed;
}
@media (max-width: 900px) {
.ip-proxy-layout {
display: block;
}
}
.ip-proxy-enabled-inline {
justify-content: flex-end;
gap: 10px;
}
.ip-proxy-actions-inline {
flex-wrap: wrap;
align-items: center;
row-gap: 6px;
}
.ip-proxy-action-grid {
width: 100%;
display: flex;
flex-wrap: nowrap;
gap: 8px;
}
.ip-proxy-action-grid .data-inline-btn {
flex: 1 1 0;
min-width: 0;
}
.ip-proxy-action-hint {
flex: 1 1 100%;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
}
.ip-proxy-enabled-status {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 92px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-align: right;
white-space: nowrap;
}
.ip-proxy-enabled-status-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--text-muted);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 25%, transparent);
flex-shrink: 0;
}
.ip-proxy-enabled-status.is-on {
color: var(--green);
}
.ip-proxy-enabled-status.is-on .ip-proxy-enabled-status-dot {
background: var(--green);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 30%, transparent);
}
.ip-proxy-enabled-status.is-off {
color: var(--text-muted);
}
.ip-proxy-runtime-status {
display: inline-flex;
align-items: flex-start;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.ip-proxy-runtime-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ip-proxy-runtime-main {
min-width: 0;
}
.ip-proxy-runtime-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ip-proxy-check-ip-btn {
min-width: 64px;
padding-inline: 10px;
flex-shrink: 0;
}
.ip-proxy-runtime-current {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text-secondary);
}
.ip-proxy-runtime-current.has-value {
color: var(--text-primary);
}
.ip-proxy-runtime-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--text-muted);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 28%, transparent);
flex-shrink: 0;
margin-top: 6px;
}
.ip-proxy-runtime-status.state-applied .ip-proxy-runtime-dot {
background: var(--green);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 28%, transparent);
}
.ip-proxy-runtime-status.state-warning .ip-proxy-runtime-dot {
background: var(--orange);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 28%, transparent);
}
.ip-proxy-runtime-status.state-error .ip-proxy-runtime-dot {
background: var(--red);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 28%, transparent);
}
.ip-proxy-runtime-details {
margin: 0;
padding: 0;
}
.ip-proxy-runtime-details summary {
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.4;
}
.ip-proxy-runtime-details[open] summary {
color: var(--text-primary);
}
.ip-proxy-runtime-details-text {
margin-top: 4px;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
.mail2925-base-inline {
gap: 10px;
}
+159
View File
@@ -194,6 +194,163 @@
<span class="data-label">默认代理</span>
<input type="text" id="input-sub2api-default-proxy" class="data-input" placeholder="留空则不使用代理;或填写代理名称 / ID" />
</div>
<div class="data-row" id="row-ip-proxy-enabled">
<span class="data-label">IP代理</span>
<div class="data-inline ip-proxy-enabled-inline">
<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>
<span id="ip-proxy-enabled-status" class="ip-proxy-enabled-status" aria-live="polite">
<span id="ip-proxy-enabled-status-dot" class="ip-proxy-enabled-status-dot" aria-hidden="true"></span>
<span id="ip-proxy-enabled-status-text" class="ip-proxy-enabled-status-text">未开启</span>
</span>
</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" aria-live="polite">
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
<div class="ip-proxy-runtime-content">
<div class="ip-proxy-runtime-main">
<span id="ip-proxy-runtime-text" class="data-value data-value-fill">未启用,沿用浏览器默认/全局代理。</span>
</div>
<div class="ip-proxy-runtime-meta">
<span id="ip-proxy-current" class="ip-proxy-runtime-current mono truncate">未启用</span>
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs data-inline-btn ip-proxy-check-ip-btn" type="button">检查IP</button>
</div>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-codex2api-url" style="display:none;">
<span class="data-label">Codex2API</span>
<input type="text" id="input-codex2api-url" class="data-input"
@@ -867,6 +1024,8 @@
<script src="mail-2925-manager.js"></script>
<script src="icloud-manager.js"></script>
<script src="luckmail-manager.js"></script>
<script src="ip-proxy-provider-711proxy.js"></script>
<script src="ip-proxy-panel.js"></script>
<script src="contribution-mode.js"></script>
<script src="account-records-manager.js"></script>
<script src="sidepanel.js"></script>
File diff suppressed because it is too large Load Diff
+230
View File
@@ -159,6 +159,236 @@ test('Plus login-code step reuses step 8 verification logic but completes visibl
assert.deepStrictEqual(remainingStepCalls, [11, 11]);
});
test('step 8 completes directly when auth page is already on OAuth consent page', async () => {
const events = {
resolveCalls: 0,
completeCalls: [],
setStates: [],
rerunStep7: 0,
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
events.completeCalls.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({
state: 'oauth_consent_page',
}),
rerunStep7ForStep8Recovery: async () => {
events.rerunStep7 += 1;
},
getOAuthFlowRemainingMs: async () => 9000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async () => {
events.resolveCalls += 1;
},
reuseOrCreateTab: async () => {},
setState: async (payload) => {
events.setStates.push(payload);
},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(events.resolveCalls, 0);
assert.equal(events.rerunStep7, 0);
assert.deepStrictEqual(events.setStates, [
{
step8VerificationTargetEmail: '',
loginVerificationRequestedAt: null,
},
]);
assert.deepStrictEqual(events.completeCalls, [
{
step: 8,
payload: {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
directOAuthConsentPage: true,
},
},
]);
});
test('step 8 retries in-place when polling fails but auth page still stays on verification page', async () => {
const events = {
ensureCalls: 0,
resolveCalls: 0,
rerunStep7: 0,
};
const pageStates = [
{ state: 'verification_page', displayedEmail: 'user@example.com' },
{ state: 'verification_page', displayedEmail: 'user@example.com' },
{ state: 'verification_page', displayedEmail: 'user@example.com' },
];
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => {
events.ensureCalls += 1;
return pageStates[Math.min(events.ensureCalls - 1, pageStates.length - 1)];
},
rerunStep7ForStep8Recovery: async () => {
events.rerunStep7 += 1;
},
getOAuthFlowRemainingMs: async () => 9000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')),
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async () => {
events.resolveCalls += 1;
if (events.resolveCalls === 1) {
throw new Error('步骤 8:页面通信异常 did not respond in 30s');
}
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(events.resolveCalls, 2);
assert.equal(events.rerunStep7, 0);
assert.equal(events.ensureCalls >= 3, true);
});
test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => {
const events = {
ensureCalls: 0,
resolveCalls: 0,
rerunStep7: 0,
completeCalls: [],
};
const pageStates = [
{ state: 'verification_page', displayedEmail: 'user@example.com' },
{ state: 'oauth_consent_page' },
];
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
events.completeCalls.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => {
events.ensureCalls += 1;
return pageStates[Math.min(events.ensureCalls - 1, pageStates.length - 1)];
},
rerunStep7ForStep8Recovery: async () => {
events.rerunStep7 += 1;
},
getOAuthFlowRemainingMs: async () => 9000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')),
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async () => {
events.resolveCalls += 1;
throw new Error('步骤 8:页面通信异常 did not respond in 30s');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(events.resolveCalls, 1);
assert.equal(events.rerunStep7, 0);
assert.deepStrictEqual(events.completeCalls, [
{
step: 8,
payload: {
loginVerificationRequestedAt: null,
skipLoginVerificationStep: true,
directOAuthConsentPage: true,
},
},
]);
});
test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => {
let capturedOptions = null;
let ensureCalls = 0;
+109
View File
@@ -132,6 +132,115 @@ return {
);
});
test('step 8 ready check keeps waiting when retryPage is reported on consent URL', async () => {
const api = new Function(`
let pollCount = 0;
function throwIfStopped() {}
async function sleepWithStop() {}
async function ensureStep8SignupPageReady() {}
async function getState() {
return { phoneVerificationEnabled: true };
}
const phoneVerificationHelpers = {
async completePhoneVerificationFlow() {
throw new Error('should not trigger phone verification flow');
},
};
async function getStep8PageState() {
pollCount += 1;
if (pollCount === 1) {
return {
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
retryPage: true,
consentPage: true,
consentReady: false,
addPhonePage: false,
phoneVerificationPage: false,
};
}
return {
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
retryPage: false,
consentPage: true,
consentReady: true,
addPhonePage: false,
phoneVerificationPage: false,
};
}
${extractFunction('waitForStep8Ready')}
return {
async run() {
return waitForStep8Ready(88, 1000);
},
};
`)();
const result = await api.run();
assert.equal(result?.consentReady, true);
assert.equal(result?.retryPage, false);
});
test('step 8 click effect ignores consent-like retry snapshots and waits for real page progress', async () => {
const api = new Function(`
let pollCount = 0;
const chrome = {
tabs: {
async get() {
return {
id: 88,
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
};
},
},
};
function throwIfStopped() {}
async function sleepWithStop() {}
async function ensureStep8SignupPageReady() {}
async function getStep8PageState() {
pollCount += 1;
if (pollCount === 1) {
return {
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
retryPage: true,
consentPage: true,
consentReady: false,
addPhonePage: false,
verificationPage: false,
};
}
return {
url: 'https://chatgpt.com/',
retryPage: false,
consentPage: false,
consentReady: false,
addPhonePage: false,
verificationPage: false,
};
}
${extractFunction('waitForStep8ClickEffect')}
return {
async run() {
return waitForStep8ClickEffect(
88,
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
1000
);
},
};
`)();
const result = await api.run();
assert.equal(result?.progressed, true);
assert.equal(result?.reason, 'left_consent_page');
assert.match(String(result?.url || ''), /chatgpt\.com/);
});
test('step 8 ready check completes phone verification flow before waiting for OAuth consent', async () => {
const api = new Function(`
let pollCount = 0;
+73
View File
@@ -419,6 +419,79 @@ test('verification flow completes step 8 and flags phone verification when add-p
]);
});
test('verification flow keeps step 8 successful when code submit transport fails but auth page already reaches oauth consent', async () => {
const events = [];
const helpers = api.createVerificationFlowHelpers({
addLog: async (message) => {
events.push(['log', message]);
},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (_step, payload) => {
events.push(['complete', payload.code]);
},
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 () => ({}),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'FILL_CODE') {
throw new Error('message channel is closed before a response was received');
}
if (message.type === 'GET_LOGIN_AUTH_STATE') {
return {
state: 'oauth_consent_page',
url: 'https://auth.openai.com/authorize?client_id=test',
};
}
return {};
},
sendToMailContentScriptResilient: async () => ({
code: '654321',
emailTimestamp: 123,
}),
setState: async (payload) => {
events.push(['state', payload.lastLoginCode || payload.lastSignupCode]);
},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
const result = await helpers.resolveVerificationStep(
8,
{ email: 'user@example.com', lastLoginCode: null },
{ provider: 'qq', label: 'QQ Mail' },
{}
);
assert.deepStrictEqual(result, {
phoneVerificationRequired: false,
url: 'https://auth.openai.com/authorize?client_id=test',
});
assert.deepStrictEqual(events.filter((entry) => entry[0] !== 'log'), [
['state', '654321'],
['complete', '654321'],
]);
assert.ok(events.some((entry) => entry[0] === 'log' && /通信中断/.test(entry[1])));
});
test('verification flow treats manual step 8 add-phone confirmation as the same fatal add-phone error', async () => {
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},