diff --git a/background.js b/background.js index c601312..574c2f4 100644 --- a/background.js +++ b/background.js @@ -10906,6 +10906,7 @@ const step2Executor = self.MultiPageBackgroundStep2?.createStep2Executor({ resolveSignupEmailForFlow, sendToContentScriptResilient, SIGNUP_PAGE_INJECT_FILES, + waitForTabStableComplete, }); const step3Executor = self.MultiPageBackgroundStep3?.createStep3Executor({ addLog, diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index e6dbbc8..d3c890e 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -30,12 +30,35 @@ waitForTabUrlMatch, } = deps; + async function waitForSignupEntryTabToSettle(tabId, step = 1) { + if (step !== 2 || !Number.isInteger(tabId) || typeof waitForTabStableComplete !== 'function') { + return null; + } + + if (typeof addLog === 'function') { + await addLog( + `步骤 ${step}:注册页已打开,正在等待页面加载完成并额外稳定 3 秒...`, + 'info', + { step, stepKey: 'signup-entry' } + ); + } + + return waitForTabStableComplete(tabId, { + timeoutMs: 45000, + retryDelayMs: 300, + stableMs: 3000, + initialDelayMs: 300, + }); + } + async function openSignupEntryTab(step = 1) { const tabId = await reuseOrCreateTab('signup-page', SIGNUP_ENTRY_URL, { inject: SIGNUP_PAGE_INJECT_FILES, injectSource: 'signup-page', }); + await waitForSignupEntryTabToSettle(tabId, step); + await ensureContentScriptReadyOnTab('signup-page', tabId, { inject: SIGNUP_PAGE_INJECT_FILES, injectSource: 'signup-page', diff --git a/background/steps/submit-signup-email.js b/background/steps/submit-signup-email.js index 5a917f8..44b0a1c 100644 --- a/background/steps/submit-signup-email.js +++ b/background/steps/submit-signup-email.js @@ -18,6 +18,7 @@ resolveSignupEmailForFlow, sendToContentScriptResilient, SIGNUP_PAGE_INJECT_FILES, + waitForTabStableComplete = null, } = deps; function getErrorMessage(error) { @@ -165,6 +166,25 @@ } } + async function waitForStep2SignupTabToSettle(tabId, logMessage) { + if (!Number.isInteger(tabId) || typeof waitForTabStableComplete !== 'function') { + return null; + } + + await addLog( + logMessage || '步骤 2:注册页标签已切换,正在等待页面加载完成并额外稳定 3 秒...', + 'info', + { step: 2, stepKey: 'signup-entry' } + ); + + return waitForTabStableComplete(tabId, { + timeoutMs: 45000, + retryDelayMs: 300, + stableMs: 3000, + initialDelayMs: 300, + }); + } + async function ensureSignupPhoneEntryReady(tabId) { if (!Number.isInteger(tabId)) { throw new Error('步骤 2:未找到可用的注册页标签,无法切换到手机号注册入口。'); @@ -210,6 +230,10 @@ signupTabId = (await ensureSignupEntryPageReady(2)).tabId; } else { await chrome.tabs.update(signupTabId, { active: true }); + await waitForStep2SignupTabToSettle( + signupTabId, + '步骤 2:已切换到注册页标签,正在等待页面加载完成并额外稳定 3 秒...' + ); await ensureContentScriptReadyOnTab('signup-page', signupTabId, { inject: SIGNUP_PAGE_INJECT_FILES, injectSource: 'signup-page', diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js index b5d8de4..9ee99c2 100644 --- a/tests/background-signup-step2-branching.test.js +++ b/tests/background-signup-step2-branching.test.js @@ -428,6 +428,85 @@ test('step 2 does not force auth-entry retry on logged-out chatgpt home when con assert.equal(logs.some((item) => /已登录 ChatGPT 首页/.test(item.message)), false); }); +test('step 2 waits for the existing signup tab to settle before probing the entry state', async () => { + const completedPayloads = []; + const logs = []; + const events = []; + + const executor = step2Api.createStep2Executor({ + addLog: async (message, level = 'info', meta = {}) => { + logs.push({ message, level, meta }); + }, + chrome: { + tabs: { + update: async () => { + events.push('tab-update'); + }, + get: async () => ({ url: 'https://chatgpt.com/' }), + }, + }, + completeStepFromBackground: async (step, payload) => { + completedPayloads.push({ step, payload }); + }, + ensureContentScriptReadyOnTab: async () => { + events.push('content-ready'); + }, + ensureSignupAuthEntryPageReady: async () => ({ tabId: 17 }), + ensureSignupEntryPageReady: async () => ({ tabId: 17 }), + ensureSignupPostEmailPageReadyInTab: async () => ({ + state: 'password_page', + url: 'https://auth.openai.com/create-account/password', + }), + getTabId: async () => 17, + isTabAlive: async () => true, + resolveSignupEmailForFlow: async () => 'user@example.com', + sendToContentScriptResilient: async (_source, message) => { + events.push(message.type); + if (message.type === 'ENSURE_SIGNUP_ENTRY_READY') { + return { ready: true, state: 'entry_home', url: 'https://chatgpt.com/' }; + } + return { submitted: true }; + }, + SIGNUP_PAGE_INJECT_FILES: [], + waitForTabStableComplete: async (_tabId, options) => { + events.push({ type: 'wait-stable', options }); + return { id: 17, url: 'https://chatgpt.com/', status: 'complete' }; + }, + }); + + await executor.executeStep2({ email: 'user@example.com' }); + + assert.deepStrictEqual(events.slice(0, 4), [ + 'tab-update', + { + type: 'wait-stable', + options: { + timeoutMs: 45000, + retryDelayMs: 300, + stableMs: 3000, + initialDelayMs: 300, + }, + }, + 'content-ready', + 'ENSURE_SIGNUP_ENTRY_READY', + ]); + assert.equal(logs.some((item) => /额外稳定 3 秒/.test(item.message)), true); + assert.equal(logs.some((item) => item.meta.step === 2 && item.meta.stepKey === 'signup-entry'), true); + assert.deepStrictEqual(completedPayloads, [ + { + step: 2, + payload: { + email: 'user@example.com', + accountIdentifierType: 'email', + accountIdentifier: 'user@example.com', + nextSignupState: 'password_page', + nextSignupUrl: 'https://auth.openai.com/create-account/password', + skippedPasswordStep: false, + }, + }, + ]); +}); + test('signup flow helper recognizes email verification page as post-email landing page', async () => { let ensureCalls = 0; let passwordReadyChecks = 0; @@ -477,6 +556,71 @@ test('signup flow helper recognizes email verification page as post-email landin assert.equal(passwordReadyChecks, 0); }); +test('signup flow helper waits for the signup entry tab to settle for step 2 before probing the entry page', async () => { + const logs = []; + const events = []; + + const helpers = signupFlowApi.createSignupFlowHelpers({ + addLog: async (message, level = 'info', meta = {}) => { + logs.push({ message, level, meta }); + }, + buildGeneratedAliasEmail: () => '', + ensureContentScriptReadyOnTab: async () => { + events.push('content-ready'); + }, + ensureHotmailAccountForFlow: async () => ({}), + ensureLuckmailPurchaseForFlow: async () => ({}), + isGeneratedAliasProvider: () => false, + isHotmailProvider: () => false, + isLuckmailProvider: () => false, + isSignupEmailVerificationPageUrl: () => false, + isSignupPasswordPageUrl: () => false, + reuseOrCreateTab: async () => { + events.push('reuse-or-create'); + return 23; + }, + sendToContentScriptResilient: async () => { + events.push('probe-entry'); + return { ready: true, state: 'entry_home', url: 'https://chatgpt.com/' }; + }, + setEmailState: async () => {}, + SIGNUP_ENTRY_URL: 'https://chatgpt.com/', + SIGNUP_PAGE_INJECT_FILES: [], + waitForTabStableComplete: async (_tabId, options) => { + events.push({ type: 'wait-stable', options }); + return { id: 23, url: 'https://chatgpt.com/', status: 'complete' }; + }, + waitForTabUrlMatch: async () => null, + }); + + const result = await helpers.ensureSignupEntryPageReady(2); + + assert.deepStrictEqual(events, [ + 'reuse-or-create', + { + type: 'wait-stable', + options: { + timeoutMs: 45000, + retryDelayMs: 300, + stableMs: 3000, + initialDelayMs: 300, + }, + }, + 'content-ready', + 'probe-entry', + ]); + assert.equal(logs.some((item) => /额外稳定 3 秒/.test(item.message)), true); + assert.equal(logs.some((item) => item.meta.step === 2 && item.meta.stepKey === 'signup-entry'), true); + assert.deepStrictEqual(result, { + tabId: 23, + result: { + ready: true, + state: 'entry_home', + url: 'https://chatgpt.com/', + }, + }); +}); + test('signup flow helper accepts phone signup landing on login password page', async () => { let ensureCalls = 0; let passwordReadyChecks = 0; diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 2ef245c..30d0d68 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -328,14 +328,15 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 1. 解析本轮应使用的注册方式:默认邮箱注册,开启接码且普通模式下可切到手机号注册 2. 打开或复用注册页 -3. 内容脚本识别到官网“免费注册 / Sign up / Register”入口后,会先等待 3 秒再点击;如果点击后仍停留在官网入口页、没有进入邮箱或手机号输入页,会再次等待 3 秒后重试,最多额外重试 5 次,且每次真正点击前都会检查 Stop 状态 -4. 邮箱注册时,如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式 -5. 邮箱注册分支:提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label` -6. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,按“已有 signup activation > 手动填写的运行态手机号 > 接码平台新申请号码”的顺序决定手机号来源,避免重复买号。内容脚本会通过 `content/phone-country-utils.js` 共享工具按号码最长区号和平台国家名切换手机号国家下拉框,兼容 `+(44)` / `(+44)` / `+44` 等页面文案;必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交 -7. 以当前统一账号标识先写入一条“停止(流程尚未完成)”的记录占位;邮箱账号占位邮箱,phone-only 账号占位手机号 -8. 等待身份提交后的真实落地页 -9. 如果进入密码页,则继续执行 Step 3 -10. 如果直接进入邮箱验证码页或手机验证码页,则自动跳过 Step 3 并进入 Step 4 +3. 后台打开或切回注册页后,会先等待标签页完成加载并额外稳定 3 秒,再连接内容脚本探测入口;这用于避开官网按钮已在 DOM 中但仍处于隐藏态的异步窗口期 +4. 内容脚本识别到官网“免费注册 / Sign up / Register”入口后,会先等待 3 秒再点击;如果点击后仍停留在官网入口页、没有进入邮箱或手机号输入页,会再次等待 3 秒后重试,最多额外重试 5 次,且每次真正点击前都会检查 Stop 状态 +5. 邮箱注册时,如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式 +6. 邮箱注册分支:提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label` +7. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,按“已有 signup activation > 手动填写的运行态手机号 > 接码平台新申请号码”的顺序决定手机号来源,避免重复买号。内容脚本会通过 `content/phone-country-utils.js` 共享工具按号码最长区号和平台国家名切换手机号国家下拉框,兼容 `+(44)` / `(+44)` / `+44` 等页面文案;必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交 +8. 以当前统一账号标识先写入一条“停止(流程尚未完成)”的记录占位;邮箱账号占位邮箱,phone-only 账号占位手机号 +9. 等待身份提交后的真实落地页 +10. 如果进入密码页,则继续执行 Step 3 +11. 如果直接进入邮箱验证码页或手机验证码页,则自动跳过 Step 3 并进入 Step 4 ### Step 3 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 06ad991..5b1a28f 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -59,7 +59,7 @@ - `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`。 - `background/phone-verification-flow.js`:手机号验证码共享流程模块,负责复用 HeroSMS / 5sim / NexSMS 的取号、复用、轮询、重发、换号与收尾能力;既承接 OAuth 后置 `add-phone / phone-verification` 页面,也承接手机号注册 Step 4 和 Step 8 中真实出现的 `phone-verification` 页面,并保持 `signupPhone*` 运行态与 `currentPhoneActivation` 隔离;后置 add-phone 成功后会把已绑定手机号写入运行态 `phoneNumber`,供账号记录并入同一轮;提交后置手机号验证码时会一并透传可复用的资料页 payload,便于认证页偶发跳回资料页时继续补齐;该流程由调用方传入当前可见步骤号,手机号日志不会再固定显示普通模式 Step 9。 - `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。 -- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。 +- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、Step 2 打开入口页后的加载完成与额外 3 秒稳定等待、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;内容脚本恢复等待日志支持 `logStep / logStepKey`,用于保持 Plus 复用步骤的日志标签正确;当前等待标签完成、等待标签完成并短暂稳定、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 - `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置、Cloud Mail API 轮询以及 2925 长轮询参数;日志步骤号使用 `completionStep`,因此 Plus 登录验证码会显示可见第 11 步而不是内部复用的 Step 8;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若登录验证码提交后页面转入 `add-phone / 手机号页`,则会把“后续需要手机号验证”的结果继续传给 OAuth 确认步骤,而不是误判为普通失败;若登录验证码提交后通信中断但认证页已进入 OAuth 同意页或手机号验证页,会按真实页面状态继续后续流程;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。 @@ -79,7 +79,7 @@ - `background/steps/platform-verify.js`:平台回调验证实现,普通模式为步骤 10,Plus 模式为可见步骤 13;负责 CPA / SUB2API 回调验证,以及 Codex2API 的协议式 callback code/state 交换,所有平台验证日志和完成信号都按当前可见步骤上报。 - `background/steps/plus-return-confirm.js`:Plus 模式第 9 步实现,负责等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒再完成。 - `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。 -- `background/steps/submit-signup-email.js`:步骤 2 实现,负责按 `signupMethod` 在邮箱注册与手机号注册之间分发;邮箱分支会先点击官网“免费注册”再提交邮箱并清理旧手机号注册运行态,手机号分支会先点击官网“免费注册”并切到手机号注册入口,确认手机号输入页就绪后优先复用已有 signup activation,其次使用手动运行态手机号,最后才申请接码平台号码并提交,最后根据落地页判断是否跳过后续密码步骤。 +- `background/steps/submit-signup-email.js`:步骤 2 实现,负责按 `signupMethod` 在邮箱注册与手机号注册之间分发;切回已有注册页标签时会先等待加载完成并额外稳定 3 秒,再检查注册入口状态;邮箱分支会先点击官网“免费注册”再提交邮箱并清理旧手机号注册运行态,手机号分支会先点击官网“免费注册”并切到手机号注册入口,确认手机号输入页就绪后优先复用已有 signup activation,其次使用手动运行态手机号,最后才申请接码平台号码并提交,最后根据落地页判断是否跳过后续密码步骤。 ## `content/`