Browse Source

feat:重试页面检测和重试点击的逻辑抽取为公共逻辑

QLHazyCoder 4 months ago
parent
commit
b387ebfa91

+ 5 - 2
README.md

@@ -477,6 +477,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 - 使用第 2 步已经确定好的邮箱
 - 使用自定义密码或自动生成密码
 - 在密码页填写密码并提交注册表单
+- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,会先自动点击 `重试` 恢复,再继续后续链路
 
 实际使用的密码会写入会话状态,并同步到侧边栏显示。
 
@@ -484,7 +485,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
 
-进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
+进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先通过共享恢复逻辑自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
 
 支持:
 
@@ -520,7 +521,8 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 - 直接删除 `chatgpt.com / openai.com` 相关 cookies
 - 再去 CPA / SUB2API 刷新最新 OAuth 链接
 - 认证页已经真正进入登录验证码页面
-- 如遇登录超时报错或登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 6
+- 如遇登录超时报错,会先尝试自动点击认证页上的 `重试` 恢复当前页面,再由后台按既有逻辑重跑整个 Step 6
+- 如遇登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 6
 
 支持:
 
@@ -554,6 +556,7 @@ Step 7 默认要求当前认证页已经处于登录验证码页。
 - 等待按钮可点击
 - 获取按钮坐标
 - 通过 Chrome `debugger` 的输入事件点击该按钮
+- 点击后会持续检查页面是否真正离开当前状态;如果出现认证页 `重试` 页面,会先自动点击 `重试` 恢复,再重新执行当前轮的“继续”点击
 - 同时监听 `chrome.webNavigation.onBeforeNavigate`
 - 一旦捕获本地回调地址,就把结果保存到 `Callback`
 

+ 72 - 1
background.js

@@ -5465,7 +5465,7 @@ async function resumeAutoRun() {
 // ============================================================
 
 const SIGNUP_ENTRY_URL = 'https://chatgpt.com/';
-const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/signup-page.js'];
+const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/signup-page.js'];
 const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({
   chrome,
   addLog,
@@ -5684,6 +5684,15 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter
   executeStepViaCompletionSignal,
   exportSettingsBundle,
   fetchGeneratedEmail,
+  finalizeStep3Completion: async () => {
+    const currentState = await getState();
+    const signupTabId = await getTabId('signup-page');
+    return signupFlowHelpers.finalizeSignupPasswordSubmitInTab(
+      signupTabId,
+      currentState.password || currentState.customPassword || '',
+      3
+    );
+  },
   finalizeIcloudAliasAfterSuccessfulFlow,
   findHotmailAccount,
   flushCommand,
@@ -6308,6 +6317,7 @@ async function getStep8PageState(tabId, responseTimeoutMs = 1500) {
 async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS) {
   const start = Date.now();
   let recovered = false;
+  let retryRecovered = false;
 
   while (Date.now() - start < timeoutMs) {
     throwIfStopped();
@@ -6315,7 +6325,21 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS)
     if (pageState?.addPhonePage) {
       throw new Error('步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
     }
+    if (pageState?.retryPage) {
+      await recoverAuthRetryPageOnTab(tabId, {
+        flow: 'auth',
+        logLabel: '步骤 8:检测到认证页重试页,正在点击“重试”恢复',
+        step: 8,
+        timeoutMs: Math.max(5000, Math.min(12000, timeoutMs)),
+      });
+      retryRecovered = true;
+      await sleepWithStop(250);
+      continue;
+    }
     if (pageState?.consentReady) {
+      if (retryRecovered) {
+        await addLog('步骤 8:认证页重试页已恢复,准备重新定位“继续”按钮...', 'info');
+      }
       return pageState;
     }
     if (pageState === null && !recovered) {
@@ -6381,6 +6405,29 @@ async function triggerStep8ContentStrategy(tabId, strategy) {
   return result;
 }
 
+async function recoverAuthRetryPageOnTab(tabId, payload = {}, options = {}) {
+  await ensureStep8SignupPageReady(tabId, {
+    timeoutMs: options.readyTimeoutMs ?? 15000,
+    retryDelayMs: options.retryDelayMs ?? 600,
+    logMessage: options.readyLogMessage || '步骤 8:认证页内容脚本已失联,正在恢复后继续处理重试页...',
+  });
+  const result = await sendToContentScriptResilient('signup-page', {
+    type: 'RECOVER_AUTH_RETRY_PAGE',
+    source: 'background',
+    payload,
+  }, {
+    timeoutMs: options.timeoutMs ?? 15000,
+    retryDelayMs: options.retryDelayMs ?? 600,
+    logMessage: options.logMessage || '步骤 8:认证页正在切换,等待“重试”按钮重新就绪...',
+  });
+
+  if (result?.error) {
+    throw new Error(result.error);
+  }
+
+  return result;
+}
+
 async function reloadStep8ConsentPage(tabId, timeoutMs = 30000) {
   if (!Number.isInteger(tabId)) {
     throw new Error('步骤 8:缺少有效的认证页标签页,无法刷新后重试。');
@@ -6443,6 +6490,20 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI
     if (pageState?.addPhonePage) {
       throw new Error('步骤 8:点击“继续”后页面跳到了手机号页面,当前流程无法继续自动授权。');
     }
+    if (pageState?.retryPage) {
+      await recoverAuthRetryPageOnTab(tabId, {
+        flow: 'auth',
+        logLabel: '步骤 8:点击“继续”后进入重试页,正在点击“重试”恢复',
+        step: 8,
+        timeoutMs: Math.max(5000, Math.min(12000, timeoutMs)),
+      });
+      return {
+        progressed: false,
+        reason: 'retry_page_recovered',
+        restartCurrentStep: true,
+        url: pageState.url || baselineUrl || '',
+      };
+    }
     if (pageState === null) {
       if (!recovered) {
         recovered = true;
@@ -6457,6 +6518,14 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI
     }
     recovered = false;
 
+    if (pageState?.consentPage === false && !pageState?.verificationPage) {
+      return {
+        progressed: true,
+        reason: 'left_consent_page',
+        url: pageState.url || baselineUrl || '',
+      };
+    }
+
     await sleepWithStop(200);
   }
 
@@ -6467,6 +6536,8 @@ function getStep8EffectLabel(effect) {
   switch (effect?.reason) {
     case 'url_changed':
       return `URL 已变化:${effect.url}`;
+    case 'retry_page_recovered':
+      return '页面进入重试页并已恢复,需要重新执行当前步骤';
     case 'page_reloading':
       return '页面正在跳转或重载';
     case 'left_consent_page':

+ 14 - 0
background/message-router.js

@@ -28,6 +28,7 @@
       executeStepViaCompletionSignal,
       exportSettingsBundle,
       fetchGeneratedEmail,
+      finalizeStep3Completion,
       finalizeIcloudAliasAfterSuccessfulFlow,
       findHotmailAccount,
       flushCommand,
@@ -218,6 +219,19 @@
             notifyStepError(message.step, '流程已被用户停止。');
             return { ok: true };
           }
+          try {
+            if (message.step === 3 && typeof finalizeStep3Completion === 'function') {
+              await finalizeStep3Completion(message.payload || {});
+            }
+          } catch (error) {
+            const errorMessage = error?.message || String(error || '步骤 3 提交后确认失败');
+            await setStepStatus(message.step, 'failed');
+            await addLog(`步骤 ${message.step} 失败:${errorMessage}`, 'error');
+            await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, errorMessage);
+            notifyStepError(message.step, errorMessage);
+            return { ok: true, error: errorMessage };
+          }
+
           const completionState = message.step === 9 ? await getState() : null;
           await setStepStatus(message.step, 'completed');
           await addLog(`步骤 ${message.step} 已完成`, 'ok');

+ 32 - 0
background/signup-flow-helpers.js

@@ -150,6 +150,37 @@
       return result;
     }
 
+    async function finalizeSignupPasswordSubmitInTab(tabId, password = '', step = 3) {
+      if (!Number.isInteger(tabId)) {
+        throw new Error(`认证页面标签页已关闭,无法完成步骤 ${step} 的提交后确认。`);
+      }
+
+      await ensureContentScriptReadyOnTab('signup-page', tabId, {
+        inject: SIGNUP_PAGE_INJECT_FILES,
+        injectSource: 'signup-page',
+        timeoutMs: 45000,
+        retryDelayMs: 900,
+        logMessage: `步骤 ${step}:认证页仍在切换,正在等待页面恢复后继续确认提交流程...`,
+      });
+
+      const result = await sendToContentScriptResilient('signup-page', {
+        type: 'PREPARE_SIGNUP_VERIFICATION',
+        step,
+        source: 'background',
+        payload: { password: password || '' },
+      }, {
+        timeoutMs: 30000,
+        retryDelayMs: 700,
+        logMessage: `步骤 ${step}:密码已提交,正在确认是否进入下一页面,必要时自动恢复重试页...`,
+      });
+
+      if (result?.error) {
+        throw new Error(result.error);
+      }
+
+      return result || {};
+    }
+
     async function resolveSignupEmailForFlow(state) {
       let resolvedEmail = state.email;
       if (isHotmailProvider(state)) {
@@ -182,6 +213,7 @@
     return {
       ensureSignupEntryPageReady,
       ensureSignupPostEmailPageReadyInTab,
+      finalizeSignupPasswordSubmitInTab,
       ensureSignupPasswordPageReadyInTab,
       openSignupEntryTab,
       resolveSignupEmailForFlow,

+ 6 - 0
background/steps/confirm-oauth.js

@@ -152,6 +152,12 @@
                 break;
               }
 
+              if (effect.restartCurrentStep) {
+                await addLog(`步骤 8:${getStep8EffectLabel(effect)},准备重新定位“继续”按钮并重试...`, 'warn');
+                await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
+                continue;
+              }
+
               if (round >= STEP8_MAX_ROUNDS) {
                 throw new Error(`步骤 8:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
               }

+ 141 - 0
content/auth-page-recovery.js

@@ -0,0 +1,141 @@
+(function authPageRecoveryModule(root, factory) {
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = factory();
+    return;
+  }
+
+  root.MultiPageAuthPageRecovery = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createAuthPageRecoveryModule() {
+  function createAuthPageRecovery(deps = {}) {
+    const {
+      detailPattern = null,
+      getActionText,
+      getPageTextSnapshot,
+      humanPause,
+      isActionEnabled,
+      isVisibleElement,
+      log,
+      simulateClick,
+      sleep,
+      throwIfStopped,
+      titlePattern = null,
+    } = deps;
+
+    function matchesPathPatterns(pathname, pathPatterns = []) {
+      if (!Array.isArray(pathPatterns) || !pathPatterns.length) {
+        return true;
+      }
+      return pathPatterns.some((pattern) => pattern instanceof RegExp && pattern.test(pathname));
+    }
+
+    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"]');
+      return Array.from(candidates).find((element) => {
+        if (!isVisibleElement(element) || (!allowDisabled && !isActionEnabled(element))) {
+          return false;
+        }
+        const text = typeof getActionText === 'function' ? getActionText(element) : '';
+        return /重试|try\s+again/i.test(text);
+      }) || null;
+    }
+
+    function getAuthTimeoutErrorPageState(options = {}) {
+      const { pathPatterns = [] } = options;
+      const pathname = location.pathname || '';
+      if (!matchesPathPatterns(pathname, pathPatterns)) {
+        return null;
+      }
+
+      const retryButton = getAuthRetryButton({ allowDisabled: true });
+      if (!retryButton) {
+        return null;
+      }
+
+      const text = typeof getPageTextSnapshot === 'function' ? getPageTextSnapshot() : '';
+      const title = typeof document !== 'undefined' ? String(document.title || '') : '';
+      const titleMatched = titlePattern instanceof RegExp
+        ? titlePattern.test(text) || titlePattern.test(title)
+        : false;
+      const detailMatched = detailPattern instanceof RegExp
+        ? detailPattern.test(text)
+        : false;
+
+      if (!titleMatched && !detailMatched) {
+        return null;
+      }
+
+      return {
+        path: pathname,
+        url: location.href,
+        retryButton,
+        retryEnabled: isActionEnabled(retryButton),
+        titleMatched,
+        detailMatched,
+      };
+    }
+
+    async function recoverAuthRetryPage(options = {}) {
+      const {
+        logLabel = '',
+        pathPatterns = [],
+        pollIntervalMs = 250,
+        step = null,
+        timeoutMs = 12000,
+        waitAfterClickMs = 1200,
+      } = options;
+      const start = Date.now();
+      let clickCount = 0;
+
+      while (Date.now() - start < timeoutMs) {
+        if (typeof throwIfStopped === 'function') {
+          throwIfStopped();
+        }
+
+        const retryState = getAuthTimeoutErrorPageState({ pathPatterns });
+        if (!retryState) {
+          return {
+            recovered: clickCount > 0,
+            clickCount,
+            url: location.href,
+          };
+        }
+
+        if (retryState.retryButton && retryState.retryEnabled) {
+          clickCount += 1;
+          if (typeof log === 'function') {
+            const prefix = logLabel || `步骤 ${step || '?'}:检测到重试页,正在点击“重试”恢复`;
+            log(`${prefix}(第 ${clickCount} 次)...`, 'warn');
+          }
+          if (typeof humanPause === 'function') {
+            await humanPause(300, 800);
+          }
+          simulateClick(retryState.retryButton);
+          await sleep(waitAfterClickMs);
+          continue;
+        }
+
+        await sleep(pollIntervalMs);
+      }
+
+      throw new Error(
+        `${logLabel || `步骤 ${step || '?'}:重试页恢复`}超时。URL: ${location.href}`
+      );
+    }
+
+    return {
+      getAuthRetryButton,
+      getAuthTimeoutErrorPageState,
+      recoverAuthRetryPage,
+    };
+  }
+
+  return {
+    createAuthPageRecovery,
+  };
+});

+ 158 - 30
content/signup-page.js

@@ -19,6 +19,7 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
       || message.type === 'STEP8_TRIGGER_CONTINUE'
       || message.type === 'GET_LOGIN_AUTH_STATE'
       || message.type === 'PREPARE_SIGNUP_VERIFICATION'
+      || message.type === 'RECOVER_AUTH_RETRY_PAGE'
       || message.type === 'RESEND_VERIFICATION_CODE'
       || message.type === 'ENSURE_SIGNUP_ENTRY_READY'
       || message.type === 'ENSURE_SIGNUP_PASSWORD_PAGE_READY'
@@ -71,6 +72,8 @@ async function handleCommand(message) {
       return serializeLoginAuthState(inspectLoginAuthState());
     case 'PREPARE_SIGNUP_VERIFICATION':
       return await prepareSignupVerificationFlow(message.payload);
+    case 'RECOVER_AUTH_RETRY_PAGE':
+      return await recoverCurrentAuthRetryPage(message.payload);
     case 'RESEND_VERIFICATION_CODE':
       return await resendVerificationCode(message.step);
     case 'ENSURE_SIGNUP_ENTRY_READY':
@@ -626,6 +629,20 @@ const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+
 const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时/i;
 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;
 
+const authPageRecovery = self.MultiPageAuthPageRecovery?.createAuthPageRecovery?.({
+  detailPattern: AUTH_TIMEOUT_ERROR_DETAIL_PATTERN,
+  getActionText,
+  getPageTextSnapshot,
+  humanPause,
+  isActionEnabled,
+  isVisibleElement,
+  log,
+  simulateClick,
+  sleep,
+  throwIfStopped,
+  titlePattern: AUTH_TIMEOUT_ERROR_TITLE_PATTERN,
+}) || null;
+
 function getVerificationErrorText() {
   const messages = [];
   const selectors = [
@@ -863,6 +880,10 @@ function getSignupPasswordSubmitButton({ allowDisabled = false } = {}) {
 }
 
 function getAuthRetryButton({ allowDisabled = false } = {}) {
+  if (authPageRecovery?.getAuthRetryButton) {
+    return authPageRecovery.getAuthRetryButton({ allowDisabled });
+  }
+
   const direct = document.querySelector('button[data-dd-action-name="Try again"]');
   if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
     return direct;
@@ -877,6 +898,10 @@ function getAuthRetryButton({ allowDisabled = false } = {}) {
 }
 
 function getAuthTimeoutErrorPageState(options = {}) {
+  if (authPageRecovery?.getAuthTimeoutErrorPageState) {
+    return authPageRecovery.getAuthTimeoutErrorPageState(options);
+  }
+
   const { pathPatterns = [] } = options;
   const path = location.pathname || '';
   if (pathPatterns.length && !pathPatterns.some((pattern) => pattern.test(path))) {
@@ -907,6 +932,70 @@ function getAuthTimeoutErrorPageState(options = {}) {
   };
 }
 
+function getAuthRetryPathPatternsForFlow(flow = 'auth') {
+  switch (flow) {
+    case 'signup_password':
+      return [/\/create-account\/password(?:[/?#]|$)/i];
+    case 'login':
+      return [/\/log-in(?:[/?#]|$)/i];
+    default:
+      return [];
+  }
+}
+
+function getCurrentAuthRetryPageState(flow = 'auth') {
+  return getAuthTimeoutErrorPageState({
+    pathPatterns: getAuthRetryPathPatternsForFlow(flow),
+  });
+}
+
+async function recoverCurrentAuthRetryPage(payload = {}) {
+  const {
+    flow = 'auth',
+    logLabel = '',
+    step = null,
+    timeoutMs = 12000,
+    waitAfterClickMs = 1200,
+  } = payload;
+  const pathPatterns = getAuthRetryPathPatternsForFlow(flow);
+  if (authPageRecovery?.recoverAuthRetryPage) {
+    return authPageRecovery.recoverAuthRetryPage({
+      logLabel,
+      pathPatterns,
+      step,
+      timeoutMs,
+      waitAfterClickMs,
+    });
+  }
+
+  const start = Date.now();
+  let clickCount = 0;
+  while (Date.now() - start < timeoutMs) {
+    throwIfStopped();
+    const retryState = getCurrentAuthRetryPageState(flow);
+    if (!retryState) {
+      return {
+        recovered: clickCount > 0,
+        clickCount,
+        url: location.href,
+      };
+    }
+
+    if (retryState.retryButton && retryState.retryEnabled) {
+      clickCount += 1;
+      log(`${logLabel || `步骤 ${step || '?'}:检测到重试页,正在点击“重试”恢复`}(第 ${clickCount} 次)...`, 'warn');
+      await humanPause(300, 800);
+      simulateClick(retryState.retryButton);
+      await sleep(waitAfterClickMs);
+      continue;
+    }
+
+    await sleep(250);
+  }
+
+  throw new Error(`${logLabel || `步骤 ${step || '?'}:重试页恢复`}超时。URL: ${location.href}`);
+}
+
 function getSignupPasswordTimeoutErrorPageState() {
   return getAuthTimeoutErrorPageState({
     pathPatterns: [/\/create-account\/password(?:[/?#]|$)/i],
@@ -1121,6 +1210,29 @@ function createStep6RecoverableResult(reason, snapshot, options = {}) {
   };
 }
 
+async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, message) {
+  const resolvedSnapshot = normalizeStep6Snapshot(snapshot || inspectLoginAuthState());
+  if (resolvedSnapshot?.state === 'login_timeout_error_page') {
+    try {
+      const recoveryResult = await recoverCurrentAuthRetryPage({
+        flow: 'login',
+        logLabel: '步骤 6:检测到登录超时报错,正在点击“重试”恢复当前页面',
+        step: 6,
+        timeoutMs: 12000,
+      });
+      if (recoveryResult?.recovered) {
+        log('步骤 6:登录超时报错页已点击“重试”,准备重新执行当前步骤。', 'warn');
+      }
+    } catch (error) {
+      log(`步骤 6:登录超时报错页自动点击“重试”失败:${error.message}`, 'warn');
+    }
+  }
+
+  return createStep6RecoverableResult(reason, resolvedSnapshot, {
+    message,
+  });
+}
+
 function normalizeStep6Snapshot(snapshot) {
   if (snapshot?.state !== 'oauth_consent_page') {
     return snapshot;
@@ -1260,15 +1372,12 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
     recoveryRound += 1;
 
     if (snapshot.state === 'error') {
-      if (snapshot.retryButton && isActionEnabled(snapshot.retryButton)) {
-        log(`步骤 4:检测到密码页超时报错,正在点击“重试”(第 ${recoveryRound}/${maxRecoveryRounds} 次)...`, 'warn');
-        await humanPause(350, 900);
-        simulateClick(snapshot.retryButton);
-        await sleep(1200);
-        continue;
-      }
-
-      log(`步骤 4:检测到异常页,但“重试”按钮暂不可用,准备继续等待(${recoveryRound}/${maxRecoveryRounds})...`, 'warn');
+      await recoverCurrentAuthRetryPage({
+        flow: 'signup_password',
+        logLabel: `步骤 4:检测到密码页超时报错,正在点击“重试”恢复(第 ${recoveryRound}/${maxRecoveryRounds} 次)`,
+        step: 4,
+        timeoutMs: 12000,
+      });
       continue;
     }
 
@@ -1459,9 +1568,11 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120
     if (snapshot.state === 'login_timeout_error_page') {
       return {
         action: 'recoverable',
-        result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-          message: '提交邮箱后进入登录超时报错页。',
-        }),
+        result: await createStep6LoginTimeoutRecoverableResult(
+          'login_timeout_error_page',
+          snapshot,
+          '提交邮箱后进入登录超时报错页。'
+        ),
       };
     }
 
@@ -1492,9 +1603,11 @@ async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 120
   if (snapshot.state === 'login_timeout_error_page') {
     return {
       action: 'recoverable',
-      result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-        message: '提交邮箱后进入登录超时报错页。',
-      }),
+      result: await createStep6LoginTimeoutRecoverableResult(
+        'login_timeout_error_page',
+        snapshot,
+        '提交邮箱后进入登录超时报错页。'
+      ),
     };
   }
   if (snapshot.state === 'oauth_consent_page') {
@@ -1533,9 +1646,11 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout
     if (snapshot.state === 'login_timeout_error_page') {
       return {
         action: 'recoverable',
-        result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-          message: '提交密码后进入登录超时报错页。',
-        }),
+        result: await createStep6LoginTimeoutRecoverableResult(
+          'login_timeout_error_page',
+          snapshot,
+          '提交密码后进入登录超时报错页。'
+        ),
       };
     }
 
@@ -1563,9 +1678,11 @@ async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout
   if (snapshot.state === 'login_timeout_error_page') {
     return {
       action: 'recoverable',
-      result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-        message: '提交密码后进入登录超时报错页。',
-      }),
+      result: await createStep6LoginTimeoutRecoverableResult(
+        'login_timeout_error_page',
+        snapshot,
+        '提交密码后进入登录超时报错页。'
+      ),
     };
   }
   if (snapshot.state === 'oauth_consent_page') {
@@ -1602,9 +1719,11 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
     }
 
     if (snapshot.state === 'login_timeout_error_page') {
-      return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-        message: '切换到一次性验证码登录后进入登录超时报错页。',
-      });
+      return await createStep6LoginTimeoutRecoverableResult(
+        'login_timeout_error_page',
+        snapshot,
+        '切换到一次性验证码登录后进入登录超时报错页。'
+      );
     }
 
     if (snapshot.state === 'oauth_consent_page') {
@@ -1626,9 +1745,11 @@ async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeou
     });
   }
   if (snapshot.state === 'login_timeout_error_page') {
-    return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-      message: '切换到一次性验证码登录后进入登录超时报错页。',
-    });
+    return await createStep6LoginTimeoutRecoverableResult(
+      'login_timeout_error_page',
+      snapshot,
+      '切换到一次性验证码登录后进入登录超时报错页。'
+    );
   }
   if (snapshot.state === 'oauth_consent_page') {
     throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
@@ -1757,9 +1878,11 @@ async function step6_login(payload) {
 
   if (snapshot.state === 'login_timeout_error_page') {
     log('步骤 6:检测到登录超时报错,准备重新执行步骤 6。', 'warn');
-    return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
-      message: '当前页面处于登录超时报错页。',
-    });
+    return await createStep6LoginTimeoutRecoverableResult(
+      'login_timeout_error_page',
+      snapshot,
+      '当前页面处于登录超时报错页。'
+    );
   }
 
   if (snapshot.state === 'email_page') {
@@ -1797,12 +1920,17 @@ async function step8_findAndClick() {
 
 function getStep8State() {
   const continueBtn = getPrimaryContinueButton();
+  const retryState = getCurrentAuthRetryPageState('auth');
   const state = {
     url: location.href,
     consentPage: isOAuthConsentPage(),
     consentReady: isStep8Ready(),
     verificationPage: isVerificationPageStillVisible(),
     addPhonePage: isAddPhonePageReady(),
+    retryPage: Boolean(retryState),
+    retryEnabled: Boolean(retryState?.retryEnabled),
+    retryTitleMatched: Boolean(retryState?.titleMatched),
+    retryDetailMatched: Boolean(retryState?.detailMatched),
     buttonFound: Boolean(continueBtn),
     buttonEnabled: isButtonEnabled(continueBtn),
     buttonText: continueBtn ? getActionText(continueBtn) : '',

+ 1 - 0
manifest.json

@@ -46,6 +46,7 @@
       "js": [
         "content/activation-utils.js",
         "content/utils.js",
+        "content/auth-page-recovery.js",
         "content/signup-page.js"
       ],
       "run_at": "document_idle"

+ 96 - 0
tests/auth-page-recovery.test.js

@@ -0,0 +1,96 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { createAuthPageRecovery } = require('../content/auth-page-recovery.js');
+
+function createRetryButton() {
+  return {
+    disabled: false,
+    textContent: 'Try again',
+    getAttribute(name) {
+      if (name === 'data-dd-action-name') return 'Try again';
+      if (name === 'aria-disabled') return 'false';
+      return '';
+    },
+  };
+}
+
+function createRecoveryApi(state) {
+  const retryButton = createRetryButton();
+  global.location = {
+    pathname: '/log-in',
+    href: 'https://auth.openai.com/log-in',
+  };
+  global.document = {
+    title: 'Something went wrong',
+    querySelector(selector) {
+      if (selector === 'button[data-dd-action-name="Try again"]' && state.retryVisible) {
+        return retryButton;
+      }
+      return null;
+    },
+    querySelectorAll() {
+      return state.retryVisible ? [retryButton] : [];
+    },
+  };
+
+  return createAuthPageRecovery({
+    detailPattern: /timed out/i,
+    getActionText: (element) => element?.textContent || '',
+    getPageTextSnapshot: () => state.pageText,
+    humanPause: async () => {},
+    isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
+    isVisibleElement: () => true,
+    log: () => {},
+    simulateClick: () => {
+      state.clickCount += 1;
+      state.retryVisible = false;
+      state.pageText = 'Recovered login form';
+    },
+    sleep: async () => {},
+    throwIfStopped: () => {},
+    titlePattern: /something went wrong/i,
+  });
+}
+
+test('auth page recovery detects retry page state', () => {
+  const state = {
+    clickCount: 0,
+    pageText: 'Something went wrong. Operation timed out.',
+    retryVisible: true,
+  };
+  const api = createRecoveryApi(state);
+
+  const snapshot = api.getAuthTimeoutErrorPageState({
+    pathPatterns: [/\/log-in(?:[/?#]|$)/i],
+  });
+
+  assert.equal(Boolean(snapshot), true);
+  assert.equal(snapshot.retryEnabled, true);
+  assert.equal(snapshot.titleMatched, true);
+  assert.equal(snapshot.detailMatched, true);
+});
+
+test('auth page recovery clicks retry and waits until page recovers', async () => {
+  const state = {
+    clickCount: 0,
+    pageText: 'Something went wrong. Operation timed out.',
+    retryVisible: true,
+  };
+  const api = createRecoveryApi(state);
+
+  const result = await api.recoverAuthRetryPage({
+    logLabel: '步骤 8:检测到重试页,正在点击“重试”恢复',
+    pathPatterns: [/\/log-in(?:[/?#]|$)/i],
+    step: 8,
+    timeoutMs: 1000,
+  });
+
+  assert.deepStrictEqual(result, {
+    recovered: true,
+    clickCount: 1,
+    url: 'https://auth.openai.com/log-in',
+  });
+  assert.equal(state.clickCount, 1);
+  assert.equal(state.retryVisible, false);
+});

+ 72 - 2
tests/background-message-router-step2-skip.test.js

@@ -11,6 +11,9 @@ function createRouter(overrides = {}) {
     logs: [],
     stepStatuses: [],
     emailStates: [],
+    finalizePayloads: [],
+    notifyCompletions: [],
+    notifyErrors: [],
   };
 
   const router = api.createMessageRouter({
@@ -41,6 +44,9 @@ function createRouter(overrides = {}) {
     executeStepViaCompletionSignal: async () => {},
     exportSettingsBundle: async () => ({}),
     fetchGeneratedEmail: async () => '',
+    finalizeStep3Completion: overrides.finalizeStep3Completion || (async (payload) => {
+      events.finalizePayloads.push(payload);
+    }),
     finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
     findHotmailAccount: async () => null,
     flushCommand: async () => {},
@@ -63,8 +69,12 @@ function createRouter(overrides = {}) {
     normalizeHotmailAccounts: (items) => items,
     normalizeRunCount: (value) => value,
     AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled',
-    notifyStepComplete: () => {},
-    notifyStepError: () => {},
+    notifyStepComplete: (step, payload) => {
+      events.notifyCompletions.push({ step, payload });
+    },
+    notifyStepError: (step, error) => {
+      events.notifyErrors.push({ step, error });
+    },
     patchHotmailAccount: async () => {},
     registerTab: async () => {},
     requestStop: async () => {},
@@ -125,3 +135,63 @@ test('message router does not overwrite a completed step 3 when step 2 is replay
 
   assert.deepStrictEqual(events.stepStatuses, []);
 });
+
+test('message router finalizes step 3 before marking it completed', async () => {
+  const { router, events } = createRouter();
+
+  const response = await router.handleMessage({
+    type: 'STEP_COMPLETE',
+    step: 3,
+    source: 'signup-page',
+    payload: {
+      email: 'user@example.com',
+      signupVerificationRequestedAt: 123,
+    },
+  }, {});
+
+  assert.deepStrictEqual(events.finalizePayloads, [
+    {
+      email: 'user@example.com',
+      signupVerificationRequestedAt: 123,
+    },
+  ]);
+  assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'completed' }]);
+  assert.deepStrictEqual(events.emailStates, ['user@example.com']);
+  assert.deepStrictEqual(events.notifyCompletions, [
+    {
+      step: 3,
+      payload: {
+        email: 'user@example.com',
+        signupVerificationRequestedAt: 123,
+      },
+    },
+  ]);
+  assert.deepStrictEqual(response, { ok: true });
+});
+
+test('message router marks step 3 failed when post-submit finalize fails', async () => {
+  const { router, events } = createRouter({
+    finalizeStep3Completion: async () => {
+      throw new Error('步骤 3 提交后仍停留在密码页。');
+    },
+  });
+
+  const response = await router.handleMessage({
+    type: 'STEP_COMPLETE',
+    step: 3,
+    source: 'signup-page',
+    payload: {
+      email: 'user@example.com',
+    },
+  }, {});
+
+  assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'failed' }]);
+  assert.deepStrictEqual(events.notifyErrors, [
+    {
+      step: 3,
+      error: '步骤 3 提交后仍停留在密码页。',
+    },
+  ]);
+  assert.equal(events.logs.some(({ message }) => /步骤 3 失败:步骤 3 提交后仍停留在密码页。/.test(message)), true);
+  assert.deepStrictEqual(response, { ok: true, error: '步骤 3 提交后仍停留在密码页。' });
+});

+ 42 - 0
tests/background-signup-step2-branching.test.js

@@ -171,3 +171,45 @@ test('signup flow helper reuses existing managed alias email when it is still co
   assert.equal(buildCalls, 0);
   assert.equal(setEmailCalls, 0);
 });
+
+test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => {
+  let ensureCalls = 0;
+  const messages = [];
+
+  const helpers = signupFlowApi.createSignupFlowHelpers({
+    buildGeneratedAliasEmail: () => '',
+    chrome: { tabs: { get: async () => ({ id: 31, url: 'https://auth.openai.com/create-account/password' }) } },
+    ensureContentScriptReadyOnTab: async (...args) => {
+      ensureCalls += 1;
+      messages.push({ type: 'ensure', args });
+    },
+    ensureHotmailAccountForFlow: async () => ({}),
+    ensureLuckmailPurchaseForFlow: async () => ({}),
+    isGeneratedAliasProvider: () => false,
+    isReusableGeneratedAliasEmail: () => false,
+    isHotmailProvider: () => false,
+    isLuckmailProvider: () => false,
+    isSignupEmailVerificationPageUrl: () => false,
+    isSignupPasswordPageUrl: () => true,
+    reuseOrCreateTab: async () => 31,
+    sendToContentScriptResilient: async (_source, message) => {
+      messages.push({ type: 'send', message });
+      return { ready: true, retried: 1 };
+    },
+    setEmailState: async () => {},
+    SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
+    SIGNUP_PAGE_INJECT_FILES: ['content/utils.js', 'content/signup-page.js'],
+    waitForTabUrlMatch: async () => null,
+  });
+
+  const result = await helpers.finalizeSignupPasswordSubmitInTab(31, 'Secret123!', 3);
+
+  assert.deepStrictEqual(result, { ready: true, retried: 1 });
+  assert.equal(ensureCalls, 1);
+  assert.deepStrictEqual(messages.find((item) => item.type === 'send')?.message, {
+    type: 'PREPARE_SIGNUP_VERIFICATION',
+    step: 3,
+    source: 'background',
+    payload: { password: 'Secret123!' },
+  });
+});

+ 105 - 0
tests/step6-timeout-recovery.test.js

@@ -0,0 +1,105 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('content/signup-page.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let index = start; index < source.length; index += 1) {
+    const char = source[index];
+    if (char === '(') {
+      parenDepth += 1;
+    } else if (char === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (char === '{' && signatureEnded) {
+      braceStart = index;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const char = source[end];
+    if (char === '{') depth += 1;
+    if (char === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+test('step 6 timeout recoverable result clicks retry before asking background to rerun', async () => {
+  const api = new Function(`
+const logs = [];
+let recoverCalls = 0;
+
+const location = {
+  href: 'https://auth.openai.com/log-in',
+};
+
+function inspectLoginAuthState() {
+  return {
+    state: 'login_timeout_error_page',
+    url: location.href,
+  };
+}
+
+async function recoverCurrentAuthRetryPage() {
+  recoverCalls += 1;
+  return { recovered: true };
+}
+
+function log(message, level = 'info') {
+  logs.push({ message, level });
+}
+
+${extractFunction('createStep6RecoverableResult')}
+${extractFunction('normalizeStep6Snapshot')}
+${extractFunction('createStep6LoginTimeoutRecoverableResult')}
+
+return {
+  async run() {
+    return createStep6LoginTimeoutRecoverableResult(
+      'login_timeout_error_page',
+      { state: 'login_timeout_error_page', url: location.href },
+      '当前页面处于登录超时报错页。'
+    );
+  },
+  snapshot() {
+    return { logs, recoverCalls };
+  },
+};
+`)();
+
+  const result = await api.run();
+  const snapshot = api.snapshot();
+
+  assert.equal(snapshot.recoverCalls, 1);
+  assert.equal(result.step6Outcome, 'recoverable');
+  assert.equal(result.reason, 'login_timeout_error_page');
+  assert.equal(result.state, 'login_timeout_error_page');
+  assert.equal(result.message, '当前页面处于登录超时报错页。');
+});

+ 108 - 0
tests/step8-retry-page-recovery.test.js

@@ -0,0 +1,108 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let index = start; index < source.length; index += 1) {
+    const char = source[index];
+    if (char === '(') {
+      parenDepth += 1;
+    } else if (char === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (char === '{' && signatureEnded) {
+      braceStart = index;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const char = source[end];
+    if (char === '{') depth += 1;
+    if (char === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+test('step 8 click effect returns restart_current_step when retry page is recovered', async () => {
+  const api = new Function(`
+let recoverCalls = 0;
+
+const chrome = {
+  tabs: {
+    async get() {
+      return {
+        id: 88,
+        url: 'https://auth.openai.com/authorize',
+      };
+    },
+  },
+};
+
+function throwIfStopped() {}
+async function sleepWithStop() {}
+async function ensureStep8SignupPageReady() {}
+async function recoverAuthRetryPageOnTab() {
+  recoverCalls += 1;
+  return { recovered: true };
+}
+async function getStep8PageState() {
+  return {
+    url: 'https://auth.openai.com/authorize',
+    retryPage: true,
+    addPhonePage: false,
+    consentPage: false,
+    verificationPage: false,
+  };
+}
+
+${extractFunction('waitForStep8ClickEffect')}
+
+return {
+  async run() {
+    return waitForStep8ClickEffect(88, 'https://auth.openai.com/authorize', 1000);
+  },
+  snapshot() {
+    return { recoverCalls };
+  },
+};
+`)();
+
+  const result = await api.run();
+  const snapshot = api.snapshot();
+
+  assert.deepStrictEqual(result, {
+    progressed: false,
+    reason: 'retry_page_recovered',
+    restartCurrentStep: true,
+    url: 'https://auth.openai.com/authorize',
+  });
+  assert.equal(snapshot.recoverCalls, 1);
+});

+ 7 - 0
项目完整链路说明.md

@@ -224,6 +224,7 @@
 4. 让内容脚本填写密码
 5. 内容脚本先上报 Step 3 完成信号
 6. 上报完成后再异步点击提交,避免页面跳转打断响应通道
+7. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先自动点击 `重试` 恢复,再继续后续链路
 
 ### Step 4 / Step 7
 
@@ -463,3 +464,9 @@
   更新 [项目完整链路说明.md](c:/Users/projectf/Downloads/codex注册扩展/项目完整链路说明.md)
 - 规范、边界、步骤接入方式变更  
   更新 [项目开发规范(AI协作).md](c:/Users/projectf/Downloads/codex注册扩展/项目开发规范(AI协作).md)
+## 2026-04 链路补充:认证页共享恢复
+
+- 新增共享恢复层:`content/auth-page-recovery.js`。
+- Step 4 在等待注册验证码页时,如果命中认证页 `Try again / 重试` 页,会先自动点击 `重试` 恢复,再继续回到密码页重提和验证码页确认流程。
+- Step 6 在识别到登录超时报错页时,会先尝试自动点击 `重试` 恢复当前页面,再按原有有限重试逻辑重新执行整步。
+- Step 8 在点击 OAuth 同意页 `继续` 后,会额外检查是否进入认证页重试页;若命中则先自动点击 `重试` 恢复,再重新执行当前轮的 `继续` 点击。

+ 6 - 0
项目文件结构说明.md

@@ -208,3 +208,9 @@
 - `tests/step9-status-diagnostics.test.js`閿涙碍绁寸拠?Step 9 缁墽鈥橀幋鎰閸掋倕鐣鹃妴浣哄閼规煡鏁婄拠顖涒偓浣界箖濠娿倧绱濇禒銉ュ挤閹存劕濮涘鑺ョ垼娑撳骸銇戠拹銉﹀絹缁€鍝勮嫙鐎涙ɑ妞傞惃鍕喘閸忓牏楠囬妴?
 - `tests/verification-stop-propagation.test.js`閿涙碍绁寸拠鏇㈢崣鐠囦胶鐖滃ù浣衡柤閸?Stop 閸︾儤娅欐稉瀣畱闁挎瑨顕ゆ导鐘虫尡娑撳簼绗夋稉顓⑩偓鏃堟缁狙佲偓?
 - `tests/verification-flow-polling.test.js`閿涙碍绁寸拠?2925 闂€鑳枂鐠囥垹寮弫甯礉娴犮儱寮锋宀冪槈閻焦褰佹禍銈嗙ウ缁嬪鑵戦惃?`beforeSubmit` 闁解晛鐡欓幍褑顢戞い鍝勭碍閵?
+## 2026-04 结构补充
+
+- `content/auth-page-recovery.js`
+  - 认证页共享恢复模块。
+  - 统一封装 `Try again / 重试` 页识别、点击重试、等待退出重试页。
+  - 当前由 Step 4 / Step 6 / Step 8 复用。