Merge remote-tracking branch 'origin/master'

# Conflicts:
#	content/signup-page.js
#	tests/step6-login-state.test.js
This commit is contained in:
sh2001sh
2026-04-22 10:06:10 +08:00
42 changed files with 3209 additions and 478 deletions
+49 -1
View File
@@ -629,6 +629,39 @@ function extractVerificationCode(text, strictChatGPTCodeOnly = false) {
return null;
}
function extractEmails(text = '') {
const matches = String(text || '').match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) || [];
return [...new Set(matches.map((item) => item.toLowerCase()))];
}
function emailMatchesTarget(candidate, targetEmail) {
const normalizedCandidate = String(candidate || '').trim().toLowerCase();
const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
return Boolean(normalizedCandidate && normalizedTarget && normalizedCandidate === normalizedTarget);
}
function getTargetEmailMatchState(text, targetEmail) {
const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
if (!normalizedTarget) {
return { matches: true, hasExplicitEmail: false };
}
const normalizedText = String(text || '').toLowerCase();
if (normalizedText.includes(normalizedTarget)) {
return { matches: true, hasExplicitEmail: true };
}
const extractedEmails = extractEmails(normalizedText);
if (!extractedEmails.length) {
return { matches: true, hasExplicitEmail: false };
}
return {
matches: extractedEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)),
hasExplicitEmail: true,
};
}
function normalizeMinuteTimestamp(timestamp) {
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0;
const date = new Date(timestamp);
@@ -902,11 +935,12 @@ async function ensureMail2925Session(payload = {}) {
await sleep(150);
fillInput(passwordInput, password);
await sleep(200);
await sleep(1000);
log(`步骤 0:2925 已定位到登录表单,准备点击“登录”,当前地址 ${location.href}`, 'info');
simulateClick(loginButton);
log(`步骤 0:2925 已点击“登录”,点击后地址 ${location.href}`, 'info');
const finalState = await waitForMail2925View('mailbox', 20000);
const finalState = await waitForMail2925View('mailbox', 40000);
log(`步骤 0:2925 登录等待结束,状态=${finalState.view},地址=${location.href}`, 'info');
if (finalState.view !== 'mailbox') {
throw new Error('2925:提交账号密码后未进入收件箱。');
@@ -930,6 +964,8 @@ async function handlePollEmail(step, payload) {
filterAfterTimestamp = 0,
excludeCodes = [],
strictChatGPTCodeOnly = false,
targetEmail = '',
mail2925MatchTargetEmail = false,
} = payload || {};
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
@@ -994,9 +1030,21 @@ async function handlePollEmail(step, payload) {
if (!matchesMailFilters(previewText, senderFilters, subjectFilters)) {
continue;
}
const previewTargetState = mail2925MatchTargetEmail
? getTargetEmailMatchState(previewText, targetEmail)
: { matches: true, hasExplicitEmail: false };
if (mail2925MatchTargetEmail && previewTargetState.hasExplicitEmail && !previewTargetState.matches) {
continue;
}
const previewCode = extractVerificationCode(previewText, strictChatGPTCodeOnly);
const openedText = await openMailAndDeleteAfterRead(item, step);
const openedTargetState = mail2925MatchTargetEmail
? getTargetEmailMatchState(openedText, targetEmail)
: { matches: true, hasExplicitEmail: false };
if (mail2925MatchTargetEmail && openedTargetState.hasExplicitEmail && !openedTargetState.matches) {
continue;
}
const bodyCode = extractVerificationCode(openedText, strictChatGPTCodeOnly);
const candidateCode = bodyCode || previewCode;
+289 -48
View File
@@ -126,16 +126,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;
@@ -1272,7 +1280,7 @@ function getSignupPasswordTimeoutErrorPageState() {
function getLoginTimeoutErrorPageState() {
return getAuthTimeoutErrorPageState({
pathPatterns: [/\/log-in(?:[/?#]|$)/i],
pathPatterns: getLoginAuthRetryPathPatterns(),
});
}
@@ -1339,6 +1347,13 @@ function inspectLoginAuthState() {
consentReady,
};
if (retryState) {
return {
...baseState,
state: 'login_timeout_error_page',
};
}
if (phoneVerificationPage) {
return {
...baseState,
@@ -1354,13 +1369,6 @@ function inspectLoginAuthState() {
};
}
if (retryState) {
return {
...baseState,
state: 'login_timeout_error_page',
};
}
if (addPhonePage) {
return {
...baseState,
@@ -1519,6 +1527,86 @@ async function createStep6LoginTimeoutRecoverableResult(reason, snapshot, messag
});
}
async function finalizeStep6VerificationReady(options = {}) {
const {
logLabel = '步骤 7 收尾',
loginVerificationRequestedAt = null,
timeout = 12000,
via = 'verification_page_ready',
} = options;
const start = Date.now();
const maxRounds = 3;
const settleDelayMs = 3000;
let round = 0;
while (Date.now() - start < timeout && round < maxRounds) {
throwIfStopped();
round += 1;
log(`${logLabel}:确认页面是否稳定停留在登录验证码阶段(第 ${round}/${maxRounds} 轮,先等待 3 秒)...`, 'info');
await sleep(settleDelayMs);
const rawSnapshot = inspectLoginAuthState();
const snapshot = normalizeStep6Snapshot(rawSnapshot);
if (snapshot.state === 'verification_page') {
log(`${logLabel}:登录验证码页面已稳定就绪。`, 'ok');
return createStep6SuccessResult(snapshot, {
via,
loginVerificationRequestedAt,
});
}
if (snapshot.state === 'login_timeout_error_page') {
log(`${logLabel}:页面进入登录超时报错页,准备自动恢复后重试步骤 7。`, 'warn');
return createStep6LoginTimeoutRecoverableResult(
'login_timeout_error_page',
snapshot,
'登录验证码页面准备就绪前进入登录超时报错页。'
);
}
if (snapshot.state === 'password_page' || snapshot.state === 'email_page') {
return createStep6RecoverableResult('verification_page_unstable', snapshot, {
message: `页面曾进入登录验证码阶段,但又回到了${getLoginAuthStateLabel(snapshot)},准备重新执行步骤 7。`,
loginVerificationRequestedAt,
});
}
if (snapshot.state === 'add_phone_page') {
throw new Error(`登录验证码页面准备过程中页面进入手机号页面。URL: ${snapshot.url}`);
}
}
const rawSnapshot = inspectLoginAuthState();
const snapshot = normalizeStep6Snapshot(rawSnapshot);
if (snapshot.state === 'verification_page') {
log(`${logLabel}:登录验证码页面已稳定就绪。`, 'ok');
return createStep6SuccessResult(snapshot, {
via,
loginVerificationRequestedAt,
});
}
if (snapshot.state === 'login_timeout_error_page') {
log(`${logLabel}:页面进入登录超时报错页,准备自动恢复后重试步骤 7。`, 'warn');
return createStep6LoginTimeoutRecoverableResult(
'login_timeout_error_page',
snapshot,
'登录验证码页面准备就绪前进入登录超时报错页。'
);
}
if (snapshot.state === 'password_page' || snapshot.state === 'email_page') {
return createStep6RecoverableResult('verification_page_unstable', snapshot, {
message: `页面曾进入登录验证码阶段,但又回到了${getLoginAuthStateLabel(snapshot)},准备重新执行步骤 7。`,
loginVerificationRequestedAt,
});
}
return createStep6RecoverableResult('verification_page_finalize_unknown', snapshot, {
message: '登录验证码页面状态在收尾确认阶段未稳定,准备重新执行步骤 7。',
loginVerificationRequestedAt,
});
}
function normalizeStep6Snapshot(snapshot) {
if (snapshot?.state !== 'oauth_consent_page') {
return snapshot;
@@ -1707,15 +1795,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();
@@ -1755,6 +1858,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('未提供验证码。');
@@ -1769,6 +1962,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();
@@ -1781,28 +1975,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');
@@ -1814,6 +1994,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);
}
@@ -1821,17 +2046,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);
@@ -2084,7 +2308,15 @@ async function step6SwitchToOneTimeCodeLogin(snapshot) {
simulateClick(switchTrigger);
log('步骤 7:已点击一次性验证码登录');
await sleep(1200);
return waitForStep6SwitchTransition(loginVerificationRequestedAt);
const result = await waitForStep6SwitchTransition(loginVerificationRequestedAt);
if (result?.step6Outcome === 'success') {
return finalizeStep6VerificationReady({
logLabel: '步骤 7 收尾',
loginVerificationRequestedAt: result.loginVerificationRequestedAt || loginVerificationRequestedAt,
via: result.via || 'switch_to_one_time_code_login',
});
}
return result;
}
async function step6LoginFromPasswordPage(payload, snapshot) {
@@ -2115,8 +2347,11 @@ async function step6LoginFromPasswordPage(payload, snapshot) {
const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt);
if (transition.action === 'done') {
log('步骤 7:已进入登录验证码页面。', 'ok');
return transition.result;
return finalizeStep6VerificationReady({
logLabel: '步骤 7 收尾',
loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || passwordSubmittedAt,
via: transition.result.via || 'password_submit',
});
}
if (transition.action === 'recoverable') {
log(`步骤 7${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 7。'}`, 'warn');
@@ -2162,8 +2397,11 @@ async function step6LoginFromEmailPage(payload, snapshot) {
const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt);
if (transition.action === 'done') {
log('步骤 7:已进入登录验证码页面。', 'ok');
return transition.result;
return finalizeStep6VerificationReady({
logLabel: '步骤 7 收尾',
loginVerificationRequestedAt: transition.result.loginVerificationRequestedAt || emailSubmittedAt,
via: transition.result.via || 'email_submit',
});
}
if (transition.action === 'recoverable') {
log(`步骤 7${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 7。'}`, 'warn');
@@ -2187,8 +2425,11 @@ async function step6_login(payload) {
const snapshot = normalizeStep6Snapshot(await waitForKnownLoginAuthState(15000));
if (snapshot.state === 'verification_page') {
log('步骤 7:登录验证码页面已就绪。', 'ok');
return createStep6SuccessResult(snapshot, { via: 'already_on_verification_page' });
return finalizeStep6VerificationReady({
logLabel: '步骤 7 收尾',
loginVerificationRequestedAt: null,
via: 'already_on_verification_page',
});
}
if (snapshot.state === 'login_timeout_error_page') {