Ver código fonte

feat: 增加邮箱重试逻辑,优化步骤4失败后的处理方式

QLHazyCoder 4 meses atrás
pai
commit
f2a1a4d7bf

+ 2 - 0
README.md

@@ -488,6 +488,8 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先通过共享恢复逻辑自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。
 
+在 `Auto` 模式下,如果 Step 4 当前轮失败,后台不会立刻丢弃这轮邮箱;而是沿用当前邮箱回到 Step 1 重新开始当前轮,避免刚拿到的邮箱被直接换掉。
+
 支持:
 
 - `Hotmail`(远程服务 / 本地助手)

+ 70 - 5
background.js

@@ -151,6 +151,10 @@ const AUTO_RUN_RETRY_DELAY_MS = 3000;
 const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
 const AUTO_STEP_DELAY_MIN_ALLOWED_SECONDS = 0;
 const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600;
+const VERIFICATION_RESEND_COUNT_MIN = 0;
+const VERIFICATION_RESEND_COUNT_MAX = 20;
+const DEFAULT_SIGNUP_VERIFICATION_RESEND_COUNT = 5;
+const DEFAULT_LOGIN_VERIFICATION_RESEND_COUNT = 4;
 const LEGACY_AUTO_STEP_DELAY_KEYS = ['autoStepRandomDelayMinSeconds', 'autoStepRandomDelayMaxSeconds'];
 const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
 const DEFAULT_CPA_CALLBACK_MODE = 'step9';
@@ -218,6 +222,8 @@ const PERSISTED_SETTING_DEFAULTS = {
   autoRunDelayEnabled: false,
   autoRunDelayMinutes: 30,
   autoStepDelaySeconds: null,
+  signupVerificationResendCount: DEFAULT_SIGNUP_VERIFICATION_RESEND_COUNT,
+  loginVerificationResendCount: DEFAULT_LOGIN_VERIFICATION_RESEND_COUNT,
   mailProvider: '163',
   mail2925Mode: DEFAULT_MAIL_2925_MODE,
   emailGenerator: 'duck',
@@ -359,6 +365,23 @@ function normalizeAutoStepDelaySeconds(value, fallback = null) {
   );
 }
 
+function normalizeVerificationResendCount(value, fallback) {
+  const rawValue = String(value ?? '').trim();
+  if (!rawValue) {
+    return fallback;
+  }
+
+  const numeric = Number(rawValue);
+  if (!Number.isFinite(numeric)) {
+    return fallback;
+  }
+
+  return Math.min(
+    VERIFICATION_RESEND_COUNT_MAX,
+    Math.max(VERIFICATION_RESEND_COUNT_MIN, Math.floor(numeric))
+  );
+}
+
 function resolveLegacyAutoStepDelaySeconds(input = {}) {
   const hasLegacyMin = input.autoStepRandomDelayMinSeconds !== undefined;
   const hasLegacyMax = input.autoStepRandomDelayMaxSeconds !== undefined;
@@ -781,6 +804,10 @@ function normalizePersistentSettingValue(key, value) {
       return normalizeAutoRunDelayMinutes(value);
     case 'autoStepDelaySeconds':
       return normalizeAutoStepDelaySeconds(value, PERSISTED_SETTING_DEFAULTS.autoStepDelaySeconds);
+    case 'signupVerificationResendCount':
+      return normalizeVerificationResendCount(value, DEFAULT_SIGNUP_VERIFICATION_RESEND_COUNT);
+    case 'loginVerificationResendCount':
+      return normalizeVerificationResendCount(value, DEFAULT_LOGIN_VERIFICATION_RESEND_COUNT);
     case 'mailProvider':
       return normalizeMailProvider(value);
     case 'mail2925Mode':
@@ -5286,23 +5313,28 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
 async function runAutoSequenceFromStep(startStep, context = {}) {
   const { targetRun, totalRuns, attemptRuns, continued = false } = context;
   let postStep7RestartCount = 0;
+  let step4RestartCount = 0;
+  let currentStartStep = startStep;
+  let continueCurrentAttempt = continued;
 
-  if (continued) {
+  while (true) {
+
+  if (continueCurrentAttempt) {
     await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:继续当前进度,从步骤 ${startStep} 开始(第 ${attemptRuns} 次尝试)===`, 'info');
   } else {
     await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:第 ${attemptRuns} 次尝试,阶段 1,打开官网并进入密码页 ===`, 'info');
   }
 
-  if (startStep <= 1) {
+  if (currentStartStep <= 1) {
     await executeStepAndWait(1, AUTO_STEP_DELAYS[1]);
   }
 
-  if (startStep <= 2) {
+  if (currentStartStep <= 2) {
     await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns);
     await executeStepAndWait(2, AUTO_STEP_DELAYS[2]);
   }
 
-  if (startStep <= 3) {
+  if (currentStartStep <= 3) {
     const latestState = await getState();
     const step3Status = latestState.stepStatuses?.[3] || 'pending';
     await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:阶段 2,填写密码、验证、登录并完成授权(第 ${attemptRuns} 次尝试)===`, 'info');
@@ -5325,7 +5357,8 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
     await chrome.tabs.update(signupTabId, { active: true });
   }
 
-  let step = Math.max(startStep, 4);
+  let restartFromStep1WithCurrentEmail = false;
+  let step = Math.max(currentStartStep, 4);
   while (step <= LAST_STEP_ID) {
     try {
       await executeStepAndWait(step, AUTO_STEP_DELAYS[step]);
@@ -5340,6 +5373,31 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
         throw err;
       }
 
+      if (step === 4) {
+        step4RestartCount += 1;
+        const preservedState = await getState();
+        const preservedEmail = String(preservedState.email || '').trim();
+        const preservedPassword = String(preservedState.password || '').trim();
+        const emailSuffix = preservedEmail ? `当前邮箱:${preservedEmail};` : '';
+        await addLog(
+          `步骤 4:执行失败,准备沿用当前邮箱回到步骤 1 重新开始(第 ${step4RestartCount} 次重开)。${emailSuffix}原因:${getErrorMessage(err)}`,
+          'warn'
+        );
+        await invalidateDownstreamAfterStepRestart(1, {
+          logLabel: `步骤 4 报错后准备回到步骤 1 沿用当前邮箱重试(第 ${step4RestartCount} 次重开)`,
+        });
+        const restorePayload = {};
+        if (preservedEmail) restorePayload.email = preservedEmail;
+        if (preservedPassword) restorePayload.password = preservedPassword;
+        if (Object.keys(restorePayload).length) {
+          await setState(restorePayload);
+        }
+        currentStartStep = 1;
+        continueCurrentAttempt = true;
+        restartFromStep1WithCurrentEmail = true;
+        break;
+      }
+
       const restartDecision = await getPostStep6AutoRestartDecision(step, err);
       if (restartDecision.shouldRestart) {
         postStep7RestartCount += 1;
@@ -5368,6 +5426,13 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
       throw err;
     }
   }
+
+  if (restartFromStep1WithCurrentEmail) {
+    continue;
+  }
+
+  break;
+}
 }
 
 async function waitForResume() {

+ 1 - 1
background/steps/fill-password.js

@@ -26,7 +26,7 @@
         throw new Error('认证页面标签页已关闭,请先重新完成步骤 2。');
       }
 
-      const password = state.customPassword || generatePassword();
+      const password = state.customPassword || state.password || generatePassword();
       await setPasswordState(password);
 
       const accounts = state.accounts || [];

+ 212 - 0
tests/auto-run-step4-restart.test.js

@@ -0,0 +1,212 @@
+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 i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+const bundle = [
+  extractFunction('isAddPhoneAuthUrl'),
+  extractFunction('isAddPhoneAuthState'),
+  extractFunction('getPostStep6AutoRestartDecision'),
+  extractFunction('runAutoSequenceFromStep'),
+].join('\n');
+
+test('auto-run restarts from step 1 with the same email after step 4 failure', async () => {
+  const api = new Function(`
+const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
+const LAST_STEP_ID = 10;
+const FINAL_OAUTH_CHAIN_START_STEP = 7;
+const chrome = {
+  tabs: {
+    update: async () => {},
+  },
+  runtime: {
+    sendMessage: async () => {},
+  },
+};
+
+let remainingFailures = 1;
+  let currentState = {
+    email: 'keep@example.com',
+    password: 'Secret123!',
+    mailProvider: '163',
+    stepStatuses: {
+      1: 'pending',
+      2: 'pending',
+      3: 'pending',
+      4: 'pending',
+      5: 'pending',
+      6: 'pending',
+    7: 'pending',
+    8: 'pending',
+    9: 'pending',
+    10: 'pending',
+  },
+};
+const events = {
+  steps: [],
+  emails: [],
+  invalidations: [],
+  logs: [],
+  setStateCalls: [],
+};
+
+async function addLog(message, level = 'info') {
+  events.logs.push({ message, level });
+}
+
+async function ensureAutoEmailReady() {
+  events.emails.push(currentState.email);
+  return currentState.email;
+}
+
+async function broadcastAutoRunStatus() {}
+
+async function getState() {
+  return currentState;
+}
+
+async function setState(updates) {
+  currentState = {
+    ...currentState,
+    ...updates,
+    stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
+  };
+  events.setStateCalls.push(updates);
+}
+
+function isStopError(error) {
+  return (error?.message || String(error || '')) === '流程已被用户停止。';
+}
+
+function isStepDoneStatus(status) {
+  return status === 'completed' || status === 'manual_completed' || status === 'skipped';
+}
+
+async function executeStepAndWait(step) {
+  events.steps.push(step);
+  if (step === 4 && remainingFailures > 0) {
+    remainingFailures -= 1;
+    throw new Error('步骤 4 提交验证码前页面异常。');
+  }
+}
+
+async function getTabId() {
+  return 1;
+}
+
+function shouldSkipLoginVerificationForCpaCallback() {
+  return false;
+}
+
+async function invalidateDownstreamAfterStepRestart(step, options = {}) {
+  events.invalidations.push({ step, options });
+  currentState = {
+    ...currentState,
+    password: null,
+    stepStatuses: {
+      1: currentState.stepStatuses[1] || 'completed',
+      2: 'pending',
+      3: 'pending',
+      4: 'pending',
+      5: 'pending',
+      6: 'pending',
+      7: 'pending',
+      8: 'pending',
+      9: 'pending',
+      10: 'pending',
+    },
+  };
+}
+
+function getLoginAuthStateLabel(state) {
+  return state || 'unknown';
+}
+
+function getErrorMessage(error) {
+  return error?.message || String(error || '');
+}
+
+async function getLoginAuthStateFromContent() {
+  return { state: 'password_page', url: 'https://auth.openai.com/log-in' };
+}
+
+${bundle}
+
+return {
+  async run() {
+    await runAutoSequenceFromStep(1, {
+      targetRun: 1,
+      totalRuns: 1,
+      attemptRuns: 1,
+      continued: false,
+    });
+    return { events, currentState };
+  },
+};
+`)();
+
+  const { events, currentState } = await api.run();
+
+  assert.deepStrictEqual(events.invalidations, [
+    {
+      step: 1,
+      options: {
+        logLabel: '步骤 4 报错后准备回到步骤 1 沿用当前邮箱重试(第 1 次重开)',
+      },
+    },
+  ]);
+  assert.deepStrictEqual(events.emails, ['keep@example.com', 'keep@example.com']);
+  assert.deepStrictEqual(events.steps, [1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+  assert.equal(currentState.email, 'keep@example.com');
+  assert.equal(currentState.password, 'Secret123!');
+  assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), true);
+});

+ 51 - 0
tests/background-step3-password-reuse.test.js

@@ -0,0 +1,51 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('background/steps/fill-password.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundStep3;`)(globalScope);
+
+test('step 3 reuses existing generated password when rerunning the same email flow', async () => {
+  const events = {
+    passwordStates: [],
+    messages: [],
+  };
+
+  const executor = api.createStep3Executor({
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    ensureContentScriptReadyOnTab: async () => {},
+    generatePassword: () => 'Generated-Should-Not-Be-Used',
+    getTabId: async () => 88,
+    isTabAlive: async () => true,
+    sendToContentScript: async (_source, message) => {
+      events.messages.push(message);
+    },
+    setPasswordState: async (password) => {
+      events.passwordStates.push(password);
+    },
+    setState: async () => {},
+    SIGNUP_PAGE_INJECT_FILES: [],
+  });
+
+  await executor.executeStep3({
+    email: 'keep@example.com',
+    password: 'Secret123!',
+    customPassword: '',
+    accounts: [],
+  });
+
+  assert.deepStrictEqual(events.passwordStates, ['Secret123!']);
+  assert.deepStrictEqual(events.messages, [
+    {
+      type: 'EXECUTE_STEP',
+      step: 3,
+      source: 'background',
+      payload: {
+        email: 'keep@example.com',
+        password: 'Secret123!',
+      },
+    },
+  ]);
+});

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

@@ -249,6 +249,8 @@
 - Hotmail 在 Step 4 / 8 首轮轮询时会优先使用最近一次验证码请求时间作为时间窗口;如果中途触发重发,轮询窗口也会同步前移,避免旧邮件反复命中。
 - CPA 模式下,Step 8 在真正回填登录验证码前,会先刷新最新 OAuth 地址并快速重走一次 Step 7,降低验证码等待过久导致 OAuth 过期的概率。
 
+- Auto 模式下,如果 Step 4 当前轮失败,后台会沿用当前邮箱回到 Step 1 重新开始当前轮,而不是立刻换邮箱开新尝试。
+
 ### Step 5
 
 文件: