feat:重试页面检测和重试点击的逻辑抽取为公共逻辑

This commit is contained in:
QLHazyCoder
2026-04-17 15:16:52 +08:00
parent 1a8cbc143b
commit b387ebfa91
15 changed files with 865 additions and 35 deletions
+5 -2
View File
@@ -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`
+72 -1
View File
@@ -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':
+14
View File
@@ -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');
+32
View File
@@ -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,
+6
View File
@@ -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} 轮点击“继续”后页面仍无反应。`);
}
+141
View File
@@ -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,
};
});
+158 -30
View File
@@ -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) : '',
+1
View File
@@ -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"
+96
View File
@@ -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);
});
@@ -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 提交后仍停留在密码页。' });
});
@@ -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!' },
});
});
+105
View File
@@ -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, '当前页面处于登录超时报错页。');
});
+108
View File
@@ -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);
});
+7
View File
@@ -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 同意页 `继续` 后,会额外检查是否进入认证页重试页;若命中则先自动点击 `重试` 恢复,再重新执行当前轮的 `继续` 点击。
+6
View File
@@ -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 复用。