From f2a1a4d7bf7f05114629f99fd4cdc8d510504b6b Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Fri, 17 Apr 2026 16:24:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=AD=A5=E9=AA=A44=E5=A4=B1=E8=B4=A5=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + background.js | 75 ++++++- background/steps/fill-password.js | 2 +- tests/auto-run-step4-restart.test.js | 212 ++++++++++++++++++ tests/background-step3-password-reuse.test.js | 51 +++++ 项目完整链路说明.md | 2 + 6 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 tests/auto-run-step4-restart.test.js create mode 100644 tests/background-step3-password-reuse.test.js diff --git a/README.md b/README.md index 487eb31..45ddb2c 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,8 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先通过共享恢复逻辑自动点击 `重试`、回到密码页重新提交,再继续等待验证码页面。 +在 `Auto` 模式下,如果 Step 4 当前轮失败,后台不会立刻丢弃这轮邮箱;而是沿用当前邮箱回到 Step 1 重新开始当前轮,避免刚拿到的邮箱被直接换掉。 + 支持: - `Hotmail`(远程服务 / 本地助手) diff --git a/background.js b/background.js index e83791d..0d071da 100644 --- a/background.js +++ b/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() { diff --git a/background/steps/fill-password.js b/background/steps/fill-password.js index d3937a2..88f0a52 100644 --- a/background/steps/fill-password.js +++ b/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 || []; diff --git a/tests/auto-run-step4-restart.test.js b/tests/auto-run-step4-restart.test.js new file mode 100644 index 0000000..dd57c7b --- /dev/null +++ b/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); +}); diff --git a/tests/background-step3-password-reuse.test.js b/tests/background-step3-password-reuse.test.js new file mode 100644 index 0000000..2867cf5 --- /dev/null +++ b/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!', + }, + }, + ]); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 06ddcd3..812f0de 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -249,6 +249,8 @@ - Hotmail 在 Step 4 / 8 首轮轮询时会优先使用最近一次验证码请求时间作为时间窗口;如果中途触发重发,轮询窗口也会同步前移,避免旧邮件反复命中。 - CPA 模式下,Step 8 在真正回填登录验证码前,会先刷新最新 OAuth 地址并快速重走一次 Step 7,降低验证码等待过久导致 OAuth 过期的概率。 +- Auto 模式下,如果 Step 4 当前轮失败,后台会沿用当前邮箱回到 Step 1 重新开始当前轮,而不是立刻换邮箱开新尝试。 + ### Step 5 文件: