From 4e9acfd2ee229fae7de47eb6aed45032e242b84c Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 20 Apr 2026 10:59:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E9=A1=B5=E6=81=A2=E5=A4=8D=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A4=84?= =?UTF-8?q?=E7=90=86=20405=20=E9=94=99=E8=AF=AF=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- content/auth-page-recovery.js | 7 +- content/signup-page.js | 82 +++++++++---------- tests/auth-page-recovery.test.js | 28 ++++++- tests/signup-verification-state-guard.test.js | 50 +++++++++++ 项目完整链路说明.md | 4 +- 6 files changed, 126 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d52dff2..7204332 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 - 使用第 2 步已经确定好的邮箱 - 使用自定义密码或自动生成密码 - 在密码页填写密码并提交注册表单 -- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 +- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 实际使用的密码会写入会话状态,并同步到侧边栏显示。 @@ -499,7 +499,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。 -进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,回到密码页重新提交,再继续等待验证码页面。 +进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果注册认证流程出现 `糟糕,出错了 / 操作超时(Operation timed out)`,或 `/email-verification` 上的 `405 / Route Error` 且带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,必要时回到密码页重新提交,再继续等待验证码页面。 在 `Auto` 模式下,如果 Step 4 当前轮失败,后台不会立刻丢弃这轮邮箱;而是沿用当前邮箱回到 Step 1 重新开始当前轮,避免刚拿到的邮箱被直接换掉。 diff --git a/content/auth-page-recovery.js b/content/auth-page-recovery.js index e046396..70b192e 100644 --- a/content/auth-page-recovery.js +++ b/content/auth-page-recovery.js @@ -15,6 +15,7 @@ isActionEnabled, isVisibleElement, log, + routeErrorPattern = null, simulateClick, sleep, throwIfStopped, @@ -65,9 +66,12 @@ const detailMatched = detailPattern instanceof RegExp ? detailPattern.test(text) : false; + const routeErrorMatched = routeErrorPattern instanceof RegExp + ? routeErrorPattern.test(text) + : false; const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); - if (!titleMatched && !detailMatched && !maxCheckAttemptsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked) { return null; } @@ -78,6 +82,7 @@ retryEnabled: isActionEnabled(retryButton), titleMatched, detailMatched, + routeErrorMatched, maxCheckAttemptsBlocked, }; } diff --git a/content/signup-page.js b/content/signup-page.js index 104138f..5cb30a0 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -253,39 +253,17 @@ async function resendVerificationCode(step, timeout = 45000) { function is405MethodNotAllowedPage() { const pageText = document.body?.textContent || ''; - return /405\s+Method\s+Not\s+Allowed/i.test(pageText) - || /Route\s+Error.*405/i.test(pageText); + return AUTH_ROUTE_ERROR_PATTERN.test(pageText); } async function handle405ResendError(step, remainingTimeout = 30000) { - const start = Date.now(); - let retryCount = 0; - - while (Date.now() - start < remainingTimeout) { - throwIfStopped(); - - if (!is405MethodNotAllowedPage()) { - // Page recovered — back to verification page - log(`步骤 ${step}:405 错误已恢复,页面已返回验证码页面。`); - return; - } - - const retryBtn = getAuthRetryButton(); - if (retryBtn) { - retryCount++; - log(`步骤 ${step}:检测到 405 错误页面,正在点击"Try again"(第 ${retryCount} 次)...`, 'warn'); - await humanPause(300, 800); - simulateClick(retryBtn); - - // Wait 3 seconds before checking again - await sleep(3000); - continue; - } - - await sleep(500); - } - - throw new Error(`步骤 ${step}:405 错误恢复超时,无法返回验证码页面。URL: ${location.href}`); + await recoverCurrentAuthRetryPage({ + logLabel: `步骤 ${step}:检测到 405 错误页面,正在点击“重试”恢复`, + pathPatterns: [], + step, + timeoutMs: Math.max(1000, remainingTimeout), + }); + log(`步骤 ${step}:405 错误已恢复,页面已返回验证码页面。`); } // ============================================================ @@ -633,6 +611,7 @@ const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手 const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i; const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时/i; +const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405/i; const 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?.({ @@ -643,6 +622,7 @@ const authPageRecovery = self.MultiPageAuthPageRecovery?.createAuthPageRecovery? isActionEnabled, isVisibleElement, log, + routeErrorPattern: AUTH_ROUTE_ERROR_PATTERN, simulateClick, sleep, throwIfStopped, @@ -999,9 +979,10 @@ function getAuthTimeoutErrorPageState(options = {}) { const titleMatched = AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(text) || AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(document.title || ''); const detailMatched = AUTH_TIMEOUT_ERROR_DETAIL_PATTERN.test(text); + const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text); const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); - if (!titleMatched && !detailMatched && !maxCheckAttemptsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked) { return null; } @@ -1012,16 +993,32 @@ function getAuthTimeoutErrorPageState(options = {}) { retryEnabled: isActionEnabled(retryButton), titleMatched, detailMatched, + routeErrorMatched, maxCheckAttemptsBlocked, }; } +function getSignupAuthRetryPathPatterns() { + return [ + /\/create-account\/password(?:[/?#]|$)/i, + /\/email-verification(?:[/?#]|$)/i, + ]; +} + +function getLoginAuthRetryPathPatterns() { + return [ + /\/log-in(?:[/?#]|$)/i, + /\/email-verification(?:[/?#]|$)/i, + ]; +} + function getAuthRetryPathPatternsForFlow(flow = 'auth') { switch (flow) { + case 'signup': case 'signup_password': - return [/\/create-account\/password(?:[/?#]|$)/i]; + return getSignupAuthRetryPathPatterns(); case 'login': - return [/\/log-in(?:[/?#]|$)/i]; + return getLoginAuthRetryPathPatterns(); default: return []; } @@ -1038,16 +1035,19 @@ async function recoverCurrentAuthRetryPage(payload = {}) { flow = 'auth', logLabel = '', maxClickAttempts = 5, + pathPatterns = null, step = null, timeoutMs = 12000, waitAfterClickMs = 3000, } = payload; - const pathPatterns = getAuthRetryPathPatternsForFlow(flow); + const resolvedPathPatterns = Array.isArray(pathPatterns) + ? pathPatterns + : getAuthRetryPathPatternsForFlow(flow); if (authPageRecovery?.recoverAuthRetryPage) { return authPageRecovery.recoverAuthRetryPage({ logLabel, maxClickAttempts, - pathPatterns, + pathPatterns: resolvedPathPatterns, step, timeoutMs, waitAfterClickMs, @@ -1061,7 +1061,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { let idlePollCount = 0; while (clickCount < maxClickAttempts) { throwIfStopped(); - const retryState = getCurrentAuthRetryPageState(flow); + const retryState = getAuthTimeoutErrorPageState({ pathPatterns: resolvedPathPatterns }); if (!retryState) { return { recovered: clickCount > 0, @@ -1082,7 +1082,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { const settleStart = Date.now(); while (Date.now() - settleStart < waitAfterClickMs) { throwIfStopped(); - if (!getCurrentAuthRetryPageState(flow)) { + if (!getAuthTimeoutErrorPageState({ pathPatterns: resolvedPathPatterns })) { return { recovered: true, clickCount, @@ -1102,7 +1102,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { await sleep(250); } - const finalRetryState = getCurrentAuthRetryPageState(flow); + const finalRetryState = getAuthTimeoutErrorPageState({ pathPatterns: resolvedPathPatterns }); if (!finalRetryState) { return { recovered: clickCount > 0, @@ -1119,7 +1119,7 @@ async function recoverCurrentAuthRetryPage(payload = {}) { function getSignupPasswordTimeoutErrorPageState() { return getAuthTimeoutErrorPageState({ - pathPatterns: [/\/create-account\/password(?:[/?#]|$)/i], + pathPatterns: getSignupAuthRetryPathPatterns(), }); } @@ -1504,8 +1504,8 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) { if (snapshot.state === 'error') { await recoverCurrentAuthRetryPage({ - flow: 'signup_password', - logLabel: `${prepareLogLabel}:检测到密码页超时报错,正在点击“重试”恢复(第 ${recoveryRound}/${maxRecoveryRounds} 次)`, + flow: 'signup', + logLabel: `${prepareLogLabel}:检测到注册认证重试页,正在点击“重试”恢复(第 ${recoveryRound}/${maxRecoveryRounds} 次)`, step: 4, timeoutMs: 12000, }); diff --git a/tests/auth-page-recovery.test.js b/tests/auth-page-recovery.test.js index b807e43..9271eab 100644 --- a/tests/auth-page-recovery.test.js +++ b/tests/auth-page-recovery.test.js @@ -18,11 +18,11 @@ function createRetryButton() { function createRecoveryApi(state) { const retryButton = createRetryButton(); global.location = { - pathname: '/log-in', - href: 'https://auth.openai.com/log-in', + pathname: state.pathname || '/log-in', + href: state.href || `https://auth.openai.com${state.pathname || '/log-in'}`, }; global.document = { - title: 'Something went wrong', + title: state.title ?? 'Something went wrong', querySelector(selector) { if (selector === 'button[data-dd-action-name="Try again"]' && state.retryVisible) { return retryButton; @@ -42,6 +42,7 @@ function createRecoveryApi(state) { isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true', isVisibleElement: () => true, log: () => {}, + routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405/i, simulateClick: () => { state.clickCount += 1; if (typeof state.onClick === 'function') { @@ -78,9 +79,30 @@ test('auth page recovery detects retry page state', () => { assert.equal(snapshot.retryEnabled, true); assert.equal(snapshot.titleMatched, true); assert.equal(snapshot.detailMatched, false); + assert.equal(snapshot.routeErrorMatched, false); assert.equal(snapshot.maxCheckAttemptsBlocked, false); }); +test('auth page recovery detects route error retry page on email verification route', () => { + const state = { + clickCount: 0, + pageText: 'Route Error (405 Method Not Allowed): email-verification action missing.', + pathname: '/email-verification', + retryVisible: true, + title: '', + }; + const api = createRecoveryApi(state); + + const snapshot = api.getAuthTimeoutErrorPageState({ + pathPatterns: [/\/email-verification(?:[/?#]|$)/i], + }); + + assert.equal(Boolean(snapshot), true); + assert.equal(snapshot.titleMatched, false); + assert.equal(snapshot.detailMatched, false); + assert.equal(snapshot.routeErrorMatched, true); +}); + test('auth page recovery clicks retry and waits until page recovers', async () => { const state = { clickCount: 0, diff --git a/tests/signup-verification-state-guard.test.js b/tests/signup-verification-state-guard.test.js index 3cb8b23..f36efe2 100644 --- a/tests/signup-verification-state-guard.test.js +++ b/tests/signup-verification-state-guard.test.js @@ -140,3 +140,53 @@ return { retryButton: { textContent: 'Try again' }, }); }); + +test('signup verification state treats email-verification retry page as error instead of verification', () => { + const api = new Function(` +const location = { + pathname: '/email-verification', +}; + +function getAuthTimeoutErrorPageState(options) { + return options.pathPatterns.some((pattern) => pattern.test(location.pathname)) + ? { retryButton: { textContent: 'Try again' } } + : null; +} + +function isStep5Ready() { + return false; +} + +function isVerificationPageStillVisible() { + return true; +} + +function isSignupEmailAlreadyExistsPage() { + return false; +} + +function getSignupPasswordInput() { + return null; +} + +function getSignupPasswordSubmitButton() { + return null; +} + +${extractFunction('getSignupAuthRetryPathPatterns')} +${extractFunction('getSignupPasswordTimeoutErrorPageState')} +${extractFunction('isSignupPasswordErrorPage')} +${extractFunction('inspectSignupVerificationState')} + +return { + run() { + return inspectSignupVerificationState(); + }, +}; +`)(); + + assert.deepStrictEqual(api.run(), { + state: 'error', + retryButton: { textContent: 'Try again' }, + }); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index e94f4f1..86b6c13 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -283,7 +283,7 @@ 5. 内容脚本先上报 Step 3 完成信号 6. 上报完成后再异步点击提交,避免页面跳转打断响应通道 7. 延迟提交真正触发前会再次检查 Stop 状态,避免用户已停止时页面仍继续自动提交 -8. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 +8. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路 ### Step 4 / Step 8 @@ -584,7 +584,7 @@ ## 2026-04 链路补充:认证页共享恢复 - 新增共享恢复层:`content/auth-page-recovery.js`。 -- Step 4 在等待注册验证码页时,如果命中认证页 `Try again / 重试` 页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续回到密码页重提和验证码页确认流程。 +- Step 4 在等待注册验证码页时,如果命中认证页 `Try again / 重试` 页,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续回到密码页重提和验证码页确认流程。 - Step 7 在识别到登录超时报错页时,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复当前页面;若仍未恢复,则按原有可恢复失败逻辑重跑 Step 7。 - Step 8 如果发现认证页已经进入登录超时报错/重试页,会直接报错并回到 Step 7 重新开始,而不是在 Step 8 内部点击 `重试`。 - 任意认证页重试页如果正文中出现 `max_check_attempts`,会被视为 Cloudflare 风控触发:后台立刻完全停止流程,侧边栏会复用现有确认弹窗提示等待 15~30 分钟后再试,避免继续刷新或反复重试加重风控,确认按钮显示为“我知道了”。