fix: harden signup verification recovery for Cloudflare and split OTP flows

- 合并 PR #130 的核心改动:修正 Cloudflare Temp Email 在 Step 4 的验证码轮询入口,并补上提交后认证重试页恢复逻辑
- 本地补充修复:正确识别分格 OTP 输入框,不再把单字符 numeric 输入误判为单一验证码框;连续重试页恢复失败时也不再误判成功
- 影响范围:background Step 4 验证码入口、signup-page 内容脚本、相关回归测试
This commit is contained in:
QLHazyCoder
2026-04-21 21:50:12 +08:00
committed by GitHub
5 changed files with 540 additions and 34 deletions
+6 -1
View File
@@ -122,11 +122,16 @@
await focusOrOpenMailTab(mail);
}
const shouldRequestFreshCodeFirst = ![
HOTMAIL_PROVIDER,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
].includes(mail.provider);
await resolveVerificationStep(4, state, mail, {
filterAfterTimestamp: verificationFilterAfterTimestamp,
sessionKey: verificationSessionKey,
disableTimeBudgetCap: mail.provider === '2925',
requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
requestFreshCodeFirst: shouldRequestFreshCodeFirst,
resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
? 0
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
+177 -33
View File
@@ -114,16 +114,24 @@ function isVisibleElement(el) {
&& rect.height > 0;
}
function getVisibleSplitVerificationInputs() {
return Array.from(document.querySelectorAll('input[maxlength="1"]'))
.filter(isVisibleElement);
}
function getVerificationCodeTarget() {
const splitInputs = getVisibleSplitVerificationInputs();
const codeInput = document.querySelector(VERIFICATION_CODE_INPUT_SELECTOR);
if (codeInput && isVisibleElement(codeInput)) {
const maxLength = Number(codeInput.getAttribute?.('maxlength') || codeInput.maxLength || 0);
if (maxLength === 1 && splitInputs.length >= 6) {
return { type: 'split', elements: splitInputs };
}
return { type: 'single', element: codeInput };
}
const singleInputs = Array.from(document.querySelectorAll('input[maxlength="1"]'))
.filter(isVisibleElement);
if (singleInputs.length >= 6) {
return { type: 'split', elements: singleInputs };
if (splitInputs.length >= 6) {
return { type: 'split', elements: splitInputs };
}
return null;
@@ -1702,15 +1710,30 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
async function waitForVerificationSubmitOutcome(step, timeout) {
const resolvedTimeout = timeout ?? (step === 8 ? 30000 : 12000);
const start = Date.now();
let recoveryCount = 0;
const maxRecoveryCount = 2;
while (Date.now() - start < resolvedTimeout) {
throwIfStopped();
if (step === 4) {
const signupRetryState = getCurrentAuthRetryPageState('signup');
if (signupRetryState?.userAlreadyExistsBlocked) {
throw createSignupUserAlreadyExistsError();
const retryFlow = step === 4 ? 'signup' : 'login';
const retryState = getCurrentAuthRetryPageState(retryFlow);
if (retryState?.userAlreadyExistsBlocked) {
throw createSignupUserAlreadyExistsError();
}
if (retryState) {
if (recoveryCount >= maxRecoveryCount) {
throw new Error(`步骤 ${step}:验证码提交后连续进入认证重试页 ${maxRecoveryCount} 次,页面仍未恢复。URL: ${location.href}`);
}
recoveryCount += 1;
log(`步骤 ${step}:验证码提交后进入认证重试页,正在自动恢复(${recoveryCount}/${maxRecoveryCount}...`, 'warn');
await recoverCurrentAuthRetryPage({
flow: retryFlow,
logLabel: `步骤 ${step}:验证码提交后检测到认证重试页,正在点击“重试”恢复`,
step,
timeoutMs: 12000,
});
continue;
}
const errorText = getVerificationErrorText();
@@ -1750,6 +1773,96 @@ async function waitForVerificationSubmitOutcome(step, timeout) {
return { success: true, assumed: true };
}
function getVerificationSubmitButtonForTarget(codeInput, options = {}) {
const { allowDisabled = false } = options;
const form = codeInput?.form || codeInput?.closest?.('form') || null;
const isUsableAction = (element) => {
if (!element || !isVisibleElement(element)) return false;
return allowDisabled || isActionEnabled(element);
};
const findSubmitInRoot = (root) => {
if (!root?.querySelectorAll) return null;
const directCandidates = root.querySelectorAll('button[type="submit"], input[type="submit"]');
for (const element of directCandidates) {
if (isUsableAction(element)) {
return element;
}
}
const textCandidates = root.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]');
return Array.from(textCandidates).find((element) => {
if (!isUsableAction(element)) return false;
const text = getActionText(element);
return /verify|confirm|submit|continue|确认|验证|继续/i.test(text);
}) || null;
};
return findSubmitInRoot(form) || findSubmitInRoot(document);
}
async function waitForVerificationSubmitButton(codeInput, timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
throw new Error('当前页面处于 405 错误恢复流程中,暂时无法定位验证码提交按钮。');
}
const button = getVerificationSubmitButtonForTarget(codeInput, { allowDisabled: false });
if (button) {
return button;
}
await sleep(150);
}
return null;
}
async function waitForVerificationCodeTarget(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (is405MethodNotAllowedPage()) {
throw new Error('当前页面处于 405 错误恢复流程中,暂时无法定位验证码输入框。');
}
const target = getVerificationCodeTarget();
if (target) {
return target;
}
await sleep(150);
}
throw new Error('未找到验证码输入框。URL: ' + location.href);
}
async function waitForSplitVerificationInputsFilled(inputs, code, timeout = 2500) {
const expected = String(code || '').slice(0, 6);
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const current = Array.from(inputs || [])
.slice(0, expected.length)
.map((input) => String(input?.value || '').trim())
.join('');
if (current === expected) {
return true;
}
await sleep(100);
}
return false;
}
async function fillVerificationCode(step, payload) {
const { code } = payload;
if (!code) throw new Error('未提供验证码。');
@@ -1764,6 +1877,7 @@ async function fillVerificationCode(step, payload) {
// Retry with 405 error recovery if needed
const maxRetries = 3;
let codeInput = null;
let splitInputs = null;
for (let retry = 0; retry <= maxRetries; retry++) {
throwIfStopped();
@@ -1776,28 +1890,14 @@ async function fillVerificationCode(step, payload) {
}
try {
codeInput = await waitForElement(VERIFICATION_CODE_INPUT_SELECTOR, 10000);
const verificationTarget = await waitForVerificationCodeTarget(10000);
if (verificationTarget.type === 'split') {
splitInputs = verificationTarget.elements;
} else {
codeInput = verificationTarget.element;
}
break; // Found it
} catch {
// Check for multiple single-digit inputs (common pattern)
const singleInputs = document.querySelectorAll('input[maxlength="1"]');
if (singleInputs.length >= 6) {
log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`);
for (let i = 0; i < 6 && i < singleInputs.length; i++) {
fillInput(singleInputs[i], code[i]);
await sleep(100);
}
const outcome = await waitForVerificationSubmitOutcome(step);
if (outcome.invalidCode) {
log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
} else if (outcome.addPhonePage) {
log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn');
} else {
log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}`, 'ok');
}
return outcome;
}
// No input found — check if it's a 405 error and can be recovered
if (is405MethodNotAllowedPage() && retry < maxRetries) {
log(`步骤 ${step}:未找到验证码输入框且页面出现 405 错误,正在恢复...`, 'warn');
@@ -1809,6 +1909,51 @@ async function fillVerificationCode(step, payload) {
}
}
if (splitInputs?.length >= 6) {
log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`);
for (let i = 0; i < 6 && i < splitInputs.length; i++) {
const targetInput = splitInputs[i];
try {
targetInput.focus?.();
} catch {}
fillInput(splitInputs[i], code[i]);
try {
targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: code[i], bubbles: true }));
} catch {}
await sleep(100);
}
const filled = await waitForSplitVerificationInputsFilled(splitInputs, code, 2500);
if (!filled) {
const current = Array.from(splitInputs)
.slice(0, 6)
.map((input) => String(input?.value || '').trim() || '_')
.join('');
log(`步骤 ${step}:分格验证码输入框未稳定呈现目标值,当前页面值为 ${current},准备继续观察提交流程。`, 'warn');
} else {
log(`步骤 ${step}:分格验证码输入框已稳定显示 ${code}`, 'info');
}
await sleep(800);
const splitSubmitBtn = await waitForVerificationSubmitButton(splitInputs[0], 2000).catch(() => null);
if (splitSubmitBtn) {
await humanPause(450, 1200);
simulateClick(splitSubmitBtn);
log(`步骤 ${step}:分格验证码已提交`);
} else {
log(`步骤 ${step}:分格验证码页面未找到可点击提交按钮,继续等待页面自动推进。`, 'info');
}
const outcome = await waitForVerificationSubmitOutcome(step);
if (outcome.invalidCode) {
log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
} else if (outcome.addPhonePage) {
log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn');
} else {
log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}`, 'ok');
}
return outcome;
}
if (!codeInput) {
throw new Error('未找到验证码输入框。URL: ' + location.href);
}
@@ -1816,17 +1961,16 @@ async function fillVerificationCode(step, payload) {
fillInput(codeInput, code);
log(`步骤 ${step}:验证码已填写`);
// Report complete BEFORE submit (page may navigate away)
// Submit
await sleep(500);
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
await sleep(800);
const submitBtn = await waitForVerificationSubmitButton(codeInput, 5000).catch(() => null);
if (submitBtn) {
await humanPause(450, 1200);
simulateClick(submitBtn);
log(`步骤 ${step}:验证码已提交`);
} else {
log(`步骤 ${step}:未找到可提交的验证码按钮,先等待页面自动推进或反馈结果。`, 'warn');
}
const outcome = await waitForVerificationSubmitOutcome(step);
@@ -69,3 +69,53 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
assert.equal(capturedOptions.filterAfterTimestamp, 100000);
assert.equal(capturedOptions.resendIntervalMs, 0);
});
test('step 4 does not request a fresh code first for Cloudflare temp mail', async () => {
let capturedOptions = null;
const realDateNow = Date.now;
Date.now = () => 700000;
const executor = api.createStep4Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => ({
provider: 'cloudflare-temp-email',
label: 'Cloudflare Temp Email',
source: 'cloudflare-temp-email',
url: 'https://temp.peekcart.com',
}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
LUCKMAIL_PROVIDER: 'luckmail-api',
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
resolveVerificationStep: async (_step, _state, _mail, options) => {
capturedOptions = options;
},
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async () => ({}),
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
});
try {
await executor.executeStep4({
email: 'user@example.com',
password: 'secret',
});
} finally {
Date.now = realDateNow;
}
assert.equal(capturedOptions.filterAfterTimestamp, 700000);
assert.equal(capturedOptions.requestFreshCodeFirst, false);
assert.equal(capturedOptions.resendIntervalMs, 25000);
});
+156
View File
@@ -0,0 +1,156 @@
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 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);
}
test('fillVerificationCode submits after split inputs are stably filled', async () => {
const api = new Function(`
const logs = [];
const clicks = [];
const filledValues = [];
let submitClicked = false;
const VERIFICATION_CODE_INPUT_SELECTOR = 'input[data-verification-code]';
const location = { href: 'https://auth.openai.com/email-verification' };
function KeyboardEvent(type, init = {}) {
this.type = type;
Object.assign(this, init);
}
const submitBtn = {
tagName: 'BUTTON',
textContent: 'Continue',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'submit';
if (name === 'aria-disabled') return 'false';
return '';
},
click() {
submitClicked = true;
},
};
const inputs = Array.from({ length: 6 }, () => ({
value: '',
maxLength: 1,
getAttribute(name) {
if (name === 'maxlength') return '1';
if (name === 'aria-disabled') return 'false';
return '';
},
focus() {},
dispatchEvent() {},
closest() { return null; },
}));
const document = {
querySelector(selector) {
if (selector === VERIFICATION_CODE_INPUT_SELECTOR) return inputs[0];
return null;
},
querySelectorAll(selector) {
if (selector === 'input[maxlength="1"]') return inputs;
if (selector === 'button[type="submit"], input[type="submit"]') return [submitBtn];
if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitBtn];
return [];
},
};
function throwIfStopped() {}
function log(message, level = 'info') { logs.push({ message, level }); }
async function waitForLoginVerificationPageReady() {}
function is405MethodNotAllowedPage() { return false; }
async function handle405ResendError() {}
function fillInput(el, value) {
el.value = value;
filledValues.push(value);
}
async function sleep() {}
function isVisibleElement() { return true; }
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
function getActionText(el) { return el.textContent || ''; }
async function humanPause() {}
function simulateClick(el) { el.click(); clicks.push(el.textContent); }
async function waitForVerificationSubmitOutcome() { return { success: true }; }
${extractFunction('getVisibleSplitVerificationInputs')}
${extractFunction('getVerificationCodeTarget')}
${extractFunction('getVerificationSubmitButtonForTarget')}
${extractFunction('waitForVerificationSubmitButton')}
${extractFunction('waitForVerificationCodeTarget')}
${extractFunction('waitForSplitVerificationInputsFilled')}
${extractFunction('fillVerificationCode')}
return {
run() {
return fillVerificationCode(4, { code: '123456' });
},
snapshot() {
return {
logs,
clicks,
filledValues,
submitClicked,
currentValue: inputs.map((input) => input.value).join(''),
};
},
};
`)();
const result = await api.run();
const snapshot = api.snapshot();
assert.deepStrictEqual(result, { success: true });
assert.equal(snapshot.currentValue, '123456');
assert.equal(snapshot.submitClicked, true);
assert.deepStrictEqual(snapshot.clicks, ['Continue']);
});
+151
View File
@@ -0,0 +1,151 @@
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 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);
}
test('waitForVerificationSubmitOutcome recovers signup retry page after submit', async () => {
const api = new Function(`
let retryVisible = true;
let step5Ready = false;
let recoverCalls = 0;
const location = { href: 'https://auth.openai.com/email-verification' };
function throwIfStopped() {}
function log() {}
function getVerificationErrorText() { return ''; }
function isStep5Ready() { return step5Ready; }
function isStep8Ready() { return false; }
function isAddPhonePageReady() { return false; }
function isVerificationPageStillVisible() { return false; }
function createSignupUserAlreadyExistsError() {
return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
}
function getCurrentAuthRetryPageState(flow) {
if (flow === 'signup' && retryVisible) {
return {
retryEnabled: true,
userAlreadyExistsBlocked: false,
};
}
return null;
}
async function recoverCurrentAuthRetryPage() {
recoverCalls += 1;
retryVisible = false;
step5Ready = true;
}
async function sleep() {}
${extractFunction('waitForVerificationSubmitOutcome')}
return {
run() {
return waitForVerificationSubmitOutcome(4, 1000);
},
snapshot() {
return { recoverCalls };
},
};
`)();
const result = await api.run();
assert.deepStrictEqual(result, { success: true });
assert.equal(api.snapshot().recoverCalls, 1);
});
test('waitForVerificationSubmitOutcome does not assume success after repeated signup retry pages', async () => {
const api = new Function(`
let recoverCalls = 0;
const location = { href: 'https://auth.openai.com/email-verification' };
function throwIfStopped() {}
function log() {}
function getVerificationErrorText() { return ''; }
function isStep5Ready() { return false; }
function isStep8Ready() { return false; }
function isAddPhonePageReady() { return false; }
function isVerificationPageStillVisible() { return false; }
function createSignupUserAlreadyExistsError() {
return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
}
function getCurrentAuthRetryPageState(flow) {
if (flow === 'signup') {
return {
retryEnabled: true,
userAlreadyExistsBlocked: false,
};
}
return null;
}
async function recoverCurrentAuthRetryPage() {
recoverCalls += 1;
}
async function sleep() {}
${extractFunction('waitForVerificationSubmitOutcome')}
return {
run() {
return waitForVerificationSubmitOutcome(4, 1000);
},
snapshot() {
return { recoverCalls };
},
};
`)();
await assert.rejects(
api.run(),
/连续进入认证重试页 2 次,页面仍未恢复/
);
assert.equal(api.snapshot().recoverCalls, 2);
});