From b387ebfa9118c4fb3f6f7301eda78a126a334c59 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Fri, 17 Apr 2026 15:16:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E9=87=8D=E8=AF=95=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=92=8C=E9=87=8D=E8=AF=95=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91=E6=8A=BD=E5=8F=96=E4=B8=BA=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- background.js | 73 ++++++- background/message-router.js | 14 ++ background/signup-flow-helpers.js | 32 +++ background/steps/confirm-oauth.js | 6 + content/auth-page-recovery.js | 141 +++++++++++++ content/signup-page.js | 188 +++++++++++++++--- manifest.json | 1 + tests/auth-page-recovery.test.js | 96 +++++++++ ...ckground-message-router-step2-skip.test.js | 74 ++++++- .../background-signup-step2-branching.test.js | 42 ++++ tests/step6-timeout-recovery.test.js | 105 ++++++++++ tests/step8-retry-page-recovery.test.js | 108 ++++++++++ 项目完整链路说明.md | 7 + 项目文件结构说明.md | 6 + 15 files changed, 865 insertions(+), 35 deletions(-) create mode 100644 content/auth-page-recovery.js create mode 100644 tests/auth-page-recovery.test.js create mode 100644 tests/step6-timeout-recovery.test.js create mode 100644 tests/step8-retry-page-recovery.test.js diff --git a/README.md b/README.md index ce8c49c..83a5d1d 100644 --- a/README.md +++ b/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` diff --git a/background.js b/background.js index 3dc801e..b7372a4 100644 --- a/background.js +++ b/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': diff --git a/background/message-router.js b/background/message-router.js index d2c4bb4..a340e05 100644 --- a/background/message-router.js +++ b/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'); diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index faa3ce3..4e45f0d 100644 --- a/background/signup-flow-helpers.js +++ b/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, diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index cd05537..12a0b90 100644 --- a/background/steps/confirm-oauth.js +++ b/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} 轮点击“继续”后页面仍无反应。`); } diff --git a/content/auth-page-recovery.js b/content/auth-page-recovery.js new file mode 100644 index 0000000..102d2f7 --- /dev/null +++ b/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, + }; +}); diff --git a/content/signup-page.js b/content/signup-page.js index 6fdf820..bc84a6b 100644 --- a/content/signup-page.js +++ b/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) : '', diff --git a/manifest.json b/manifest.json index 0e79e67..8d3246c 100644 --- a/manifest.json +++ b/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" diff --git a/tests/auth-page-recovery.test.js b/tests/auth-page-recovery.test.js new file mode 100644 index 0000000..ed92d1a --- /dev/null +++ b/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); +}); diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index a16d79e..31a3c97 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/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 提交后仍停留在密码页。' }); +}); diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js index 69b5040..24bc3bb 100644 --- a/tests/background-signup-step2-branching.test.js +++ b/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!' }, + }); +}); diff --git a/tests/step6-timeout-recovery.test.js b/tests/step6-timeout-recovery.test.js new file mode 100644 index 0000000..92db5d6 --- /dev/null +++ b/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, '当前页面处于登录超时报错页。'); +}); diff --git a/tests/step8-retry-page-recovery.test.js b/tests/step8-retry-page-recovery.test.js new file mode 100644 index 0000000..34d76aa --- /dev/null +++ b/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); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index c4c57a8..a0eb5ef 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.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 同意页 `继续` 后,会额外检查是否进入认证页重试页;若命中则先自动点击 `重试` 恢复,再重新执行当前轮的 `继续` 点击。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 9ff2aa5..3a25afd 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.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 复用。