diff --git a/background.js b/background.js index a16dbb6..c601312 100644 --- a/background.js +++ b/background.js @@ -7743,6 +7743,17 @@ function isGpcTaskEndedFailure(error) { return /GPC_TASK_ENDED::/i.test(message); } +function isGpcCheckoutRestartRequiredFailure(error) { + const message = getErrorMessage(error); + if (/PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message)) { + return false; + } + if (/GPC_TASK_ENDED::/i.test(message)) { + return true; + } + return /GPC\s*API\s*请求超时|步骤\s*[67][\s\S]*GPC[\s\S]*(?:任务轮询超时|请求超时|超时|timeout|timed\s*out|卡死|无响应)|account\s+already\s+linked|GOPAY已经绑了订阅|(?:账号|账户|GoPay|GOPAY)[\s\S]*(?:已绑定|已经绑定|已绑|绑了订阅|绑定了订阅)|创建\s*GPC\s*订单失败[\s\S]*(?:任务已结束|任务结束|failed|expired|discarded|请求超时|timeout|timed\s*out)/i.test(message); +} + function isGoPayCheckoutRestartRequiredFailure(error) { const message = getErrorMessage(error); return /GOPAY_RESTART_FROM_STEP6::|GOPAY_RETRY_REQUIRED::/i.test(message); @@ -10432,6 +10443,7 @@ async function runAutoSequenceFromStep(startStep, context = {}) { const { targetRun, totalRuns, attemptRuns, continued = false } = context; let postStep7RestartCount = 0; let goPayCheckoutRestartCount = 0; + let gpcCheckoutRestartCount = 0; let step4RestartCount = 0; let currentStartStep = startStep; let continueCurrentAttempt = continued; @@ -10525,6 +10537,26 @@ async function runAutoSequenceFromStep(startStep, context = {}) { throw err; } + const stepExecutionKey = typeof getStepExecutionKeyForState === 'function' + ? getStepExecutionKeyForState(step, latestState) + : ''; + const isGpcCheckoutStep = normalizePlusPaymentMethod(latestState?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GPC_HELPER + || String(latestState?.plusCheckoutSource || '').trim() === PLUS_PAYMENT_METHOD_GPC_HELPER; + if (isGpcCheckoutStep + && (stepExecutionKey === 'plus-checkout-create' || stepExecutionKey === 'plus-checkout-billing') + && isGpcCheckoutRestartRequiredFailure(err)) { + gpcCheckoutRestartCount += 1; + await addLog( + `步骤 ${step}:检测到 GPC 任务失败/卡住,准备回到步骤 6 重新创建 GPC 任务(第 ${gpcCheckoutRestartCount} 次)。原因:${getErrorMessage(err)}`, + 'warn' + ); + await invalidateDownstreamAfterStepRestart(5, { + logLabel: `步骤 ${step} GPC 任务失败后准备回到步骤 6 重试(第 ${gpcCheckoutRestartCount} 次)`, + }); + step = 6; + continue; + } + if (step === 8 && isGoPayCheckoutRestartRequiredFailure(err)) { goPayCheckoutRestartCount += 1; if (goPayCheckoutRestartCount > 3) { diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 0651049..b07c970 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -257,11 +257,34 @@ throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。'); } const controller = typeof AbortController === 'function' ? new AbortController() : null; - const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null; + const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000); + let didTimeout = false; + let timer = null; + const buildTimeoutError = () => new Error(`GPC API 请求超时(>${Math.round(effectiveTimeoutMs / 1000)} 秒):${url}`); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + didTimeout = true; + reject(buildTimeoutError()); + if (controller) { + controller.abort(); + } + }, effectiveTimeoutMs); + }); try { - const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }); - const data = await response.json().catch(() => ({})); + const response = await Promise.race([ + fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }), + timeoutPromise, + ]); + const data = await Promise.race([ + response.json().catch(() => ({})), + timeoutPromise, + ]); return { response, data }; + } catch (error) { + if (didTimeout || error?.name === 'AbortError') { + throw buildTimeoutError(); + } + throw error; } finally { if (timer) clearTimeout(timer); } diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index d14f137..bdfea34 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -209,11 +209,34 @@ throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。'); } const controller = typeof AbortController === 'function' ? new AbortController() : null; - const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null; + const effectiveTimeoutMs = Math.max(1000, Number(timeoutMs) || 30000); + let didTimeout = false; + let timer = null; + const buildTimeoutError = () => new Error(`GPC API 请求超时(>${Math.round(effectiveTimeoutMs / 1000)} 秒):${url}`); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + didTimeout = true; + reject(buildTimeoutError()); + if (controller) { + controller.abort(); + } + }, effectiveTimeoutMs); + }); try { - const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }); - const data = await response.json().catch(() => ({})); + const response = await Promise.race([ + fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }), + timeoutPromise, + ]); + const data = await Promise.race([ + response.json().catch(() => ({})), + timeoutPromise, + ]); return { response, data }; + } catch (error) { + if (didTimeout || error?.name === 'AbortError') { + throw buildTimeoutError(); + } + throw error; } finally { if (timer) clearTimeout(timer); } diff --git a/scripts/__pycache__/gpc_sms_helper_macos.cpython-313.pyc b/scripts/__pycache__/gpc_sms_helper_macos.cpython-313.pyc new file mode 100644 index 0000000..7f85f82 Binary files /dev/null and b/scripts/__pycache__/gpc_sms_helper_macos.cpython-313.pyc differ diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index 4cf598f..1210f2e 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -56,10 +56,24 @@ const bundle = [ extractFunction('isAddPhoneAuthFailure'), extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), + extractFunction('isGpcCheckoutRestartRequiredFailure'), extractFunction('getPostStep6AutoRestartDecision'), extractFunction('runAutoSequenceFromStep'), ].join('\n'); +const defaultStepDefinitions = { + 1: { key: 'open-signup' }, + 2: { key: 'prepare-email' }, + 3: { key: 'fill-password' }, + 4: { key: 'verify-email' }, + 5: { key: 'profile-basic' }, + 6: { key: 'profile-finish' }, + 7: { key: 'auth-login' }, + 8: { key: 'auth-email-code' }, + 9: { key: 'confirm-oauth' }, + 10: { key: 'platform-verify' }, +}; + function createHarness(options = {}) { const { startStep = 7, @@ -68,13 +82,18 @@ function createHarness(options = {}) { failureMessage = '认证失败: Request failed with status code 502', authState = { state: 'password_page', url: 'https://auth.openai.com/log-in' }, customState = {}, + stepDefinitions = defaultStepDefinitions, + stepIds = Object.keys(stepDefinitions).map(Number).sort((a, b) => a - b), + lastStepId = Math.max(...stepIds), + finalOAuthChainStartStep = 7, } = options; return 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 AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, 13: 0 }; +const LAST_STEP_ID = ${JSON.stringify(lastStepId)}; +const FINAL_OAUTH_CHAIN_START_STEP = ${JSON.stringify(finalOAuthChainStartStep)}; const SIGNUP_METHOD_PHONE = 'phone'; +const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const LOG_PREFIX = '[test]'; const chrome = { tabs: { @@ -104,21 +123,10 @@ async function getState() { }; } function getStepIdsForState() { - return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + return ${JSON.stringify(stepIds)}; } function getStepDefinitionForState(step) { - const map = { - 1: { key: 'open-signup' }, - 2: { key: 'prepare-email' }, - 3: { key: 'fill-password' }, - 4: { key: 'verify-email' }, - 5: { key: 'profile-basic' }, - 6: { key: 'profile-finish' }, - 7: { key: 'auth-login' }, - 8: { key: 'auth-email-code' }, - 9: { key: 'confirm-oauth' }, - 10: { key: 'platform-verify' }, - }; + const map = ${JSON.stringify(stepDefinitions)}; return map[Number(step)] || null; } function getStepExecutionKeyForState(step, state = {}) { @@ -149,6 +157,10 @@ function getLoginAuthStateLabel(state) { function getErrorMessage(error) { return error?.message || String(error || ''); } +function normalizePlusPaymentMethod(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === PLUS_PAYMENT_METHOD_GPC_HELPER ? PLUS_PAYMENT_METHOD_GPC_HELPER : normalized; +} function isPhoneSmsPlatformRateLimitFailure(error) { const message = getErrorMessage(error); return /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(message); @@ -321,3 +333,95 @@ test('auto-run restarts from confirm-oauth step after transient step10 token_exc }); assert.ok(events.logs.some(({ message }) => /回到步骤 9 重新开始授权流程/.test(message))); }); + +test('auto-run restarts GPC checkout from step 6 when step 7 task polling stalls', async () => { + const plusGpcSteps = { + 6: { key: 'plus-checkout-create' }, + 7: { key: 'plus-checkout-billing' }, + 10: { key: 'oauth-login' }, + 11: { key: 'fetch-login-code' }, + 12: { key: 'confirm-oauth' }, + 13: { key: 'platform-verify' }, + }; + const harness = createHarness({ + startStep: 6, + failureStep: 7, + failureBudget: 2, + failureMessage: 'GPC API 请求超时(>30 秒):https://gpc.qlhazycoder.top/api/gp/tasks/task_stalled', + stepDefinitions: plusGpcSteps, + finalOAuthChainStartStep: 10, + customState: { + stepStatuses: { 3: 'completed' }, + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + }, + }); + + const events = await harness.run(); + + assert.deepStrictEqual( + events.steps, + [6, 7, 6, 7, 6, 7, 10, 11, 12, 13] + ); + assert.deepStrictEqual( + events.invalidations.map((entry) => entry.step), + [5, 5] + ); + assert.ok(events.logs.some(({ message }) => /回到步骤 6 重新创建 GPC 任务/.test(message))); +}); + +test('auto-run treats GPC account binding as recoverable step 6 restart', async () => { + const plusGpcSteps = { + 6: { key: 'plus-checkout-create' }, + 7: { key: 'plus-checkout-billing' }, + 10: { key: 'oauth-login' }, + 11: { key: 'fetch-login-code' }, + 12: { key: 'confirm-oauth' }, + 13: { key: 'platform-verify' }, + }; + const harness = createHarness({ + startStep: 6, + failureStep: 7, + failureBudget: 1, + failureMessage: 'GPC_TASK_ENDED::GOPAY已经绑了订阅,需要手动解绑', + stepDefinitions: plusGpcSteps, + finalOAuthChainStartStep: 10, + customState: { + stepStatuses: { 3: 'completed' }, + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + }, + }); + + const events = await harness.run(); + + assert.deepStrictEqual(events.steps, [6, 7, 6, 7, 10, 11, 12, 13]); + assert.deepStrictEqual(events.invalidations.map((entry) => entry.step), [5]); +}); + +test('auto-run does not restart GPC checkout when Plus account has no free-trial eligibility', async () => { + const plusGpcSteps = { + 6: { key: 'plus-checkout-create' }, + 7: { key: 'plus-checkout-billing' }, + 10: { key: 'oauth-login' }, + }; + const harness = createHarness({ + startStep: 6, + failureStep: 7, + failureBudget: 1, + failureMessage: 'PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(IDR 299000),当前账号没有免费试用资格,已跳过支付提交。', + stepDefinitions: plusGpcSteps, + finalOAuthChainStartStep: 10, + customState: { + stepStatuses: { 3: 'completed' }, + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + }, + }); + + const result = await harness.runAndCaptureError(); + + assert.ok(result?.error); + assert.deepStrictEqual(result.events.steps, [6, 7]); + assert.equal(result.events.invalidations.length, 0); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index a14ae57..2ef245c 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -328,13 +328,14 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 1. 解析本轮应使用的注册方式:默认邮箱注册,开启接码且普通模式下可切到手机号注册 2. 打开或复用注册页 -3. 邮箱注册时,如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式 -4. 邮箱注册分支:提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label` -5. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,按“已有 signup activation > 手动填写的运行态手机号 > 接码平台新申请号码”的顺序决定手机号来源,避免重复买号。内容脚本会通过 `content/phone-country-utils.js` 共享工具按号码最长区号和平台国家名切换手机号国家下拉框,兼容 `+(44)` / `(+44)` / `+44` 等页面文案;必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交 -6. 以当前统一账号标识先写入一条“停止(流程尚未完成)”的记录占位;邮箱账号占位邮箱,phone-only 账号占位手机号 -7. 等待身份提交后的真实落地页 -8. 如果进入密码页,则继续执行 Step 3 -9. 如果直接进入邮箱验证码页或手机验证码页,则自动跳过 Step 3 并进入 Step 4 +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 ### Step 3 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index cee6d2a..06ad991 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -96,7 +96,7 @@ - `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 - `content/plus-checkout.js`:ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 -- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 同时支持邮箱注册与手机号注册入口切换、手机号国家下拉框同步校验、手机号填写与提交;Step 3 收尾会识别手机号注册密码页的 `Incorrect phone number or password` 并上报当前轮重开信号;Step 7 / 8 登录链路会额外识别手机号输入页、手机号登录入口、`phone-verification` 与真实 `add-email` 页面,避免把手机验证码页或添加邮箱页误判成普通邮箱登录入口;Step 9 后置手机号验证完成后若偶发进入资料页,会复用 Step 5 的姓名生日填写逻辑再继续等待 OAuth 同意页。 +- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前 Step 2 同时支持邮箱注册与手机号注册入口切换、官网注册入口点击前固定等待 3 秒、入口点击失败后最多 5 次重试、手机号国家下拉框同步校验、手机号填写与提交;Step 3 收尾会识别手机号注册密码页的 `Incorrect phone number or password` 并上报当前轮重开信号;Step 7 / 8 登录链路会额外识别手机号输入页、手机号登录入口、`phone-verification` 与真实 `add-email` 页面,避免把手机验证码页或添加邮箱页误判成普通邮箱登录入口;Step 9 后置手机号验证完成后若偶发进入资料页,会复用 Step 5 的姓名生日填写逻辑再继续等待 OAuth 同意页。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;平台验证请求会读取 `visibleStep`,普通模式承接步骤 10,Plus 模式承接步骤 13。 - `content/utils.js`:内容脚本公共工具层,负责结构化日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击;内容脚本日志通过 `{ step, stepKey }` 上报,正文不再承担步骤号解析职责。 - `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做平台验证判定;当前支持普通步骤 10 与 Plus 可见步骤 13。 @@ -249,7 +249,7 @@ - `tests/sidepanel-mail2925-mode.test.js`:测试侧边栏保留 `2925 provide / receive` 模式行与独立的号池配置行,并验证 sidepanel 只在 provide 模式下把 2925 视为别名邮箱 provider。 - `tests/sidepanel-paypal-manager.test.js`:测试侧边栏公共表单弹窗脚本与 PayPal manager 的加载顺序,以及 PayPal 账号保存后会立即选中当前账号。 - `tests/signup-entry-diagnostics.test.js`:测试注册入口与密码页诊断快照输出,包括密码页错误文案字段。 -- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式、本地化邮箱输入框识别,以及手机号注册时国家下拉框可视区号同步。 +- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式、本地化邮箱输入框识别、官网注册入口点击等待与最多 5 次重试,以及手机号注册时国家下拉框可视区号同步。 - `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。 - `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。 - `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。