feat: 实现步骤 8 的状态管理和点击策略,优化 OAuth 授权流程

This commit is contained in:
QLHazyCoder
2026-04-11 23:03:44 +08:00
parent 4050e3473f
commit 078a5457c5
2 changed files with 301 additions and 31 deletions
+181 -22
View File
@@ -2443,6 +2443,138 @@ async function executeStep7(state) {
// ============================================================
let webNavListener = null;
const STEP8_CLICK_EFFECT_TIMEOUT_MS = 3500;
const STEP8_CLICK_RETRY_DELAY_MS = 500;
const STEP8_READY_WAIT_TIMEOUT_MS = 30000;
const STEP8_STRATEGIES = [
{ mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' },
{ mode: 'debugger', label: 'debugger click' },
{ mode: 'content', strategy: 'nativeClick', label: 'element.click' },
{ mode: 'content', strategy: 'dispatchClick', label: 'dispatch click' },
{ mode: 'debugger', label: 'debugger click retry' },
];
async function getStep8PageState(tabId, responseTimeoutMs = 1500) {
try {
const result = await sendTabMessageWithTimeout(tabId, 'signup-page', {
type: 'STEP8_GET_STATE',
source: 'background',
payload: {},
}, responseTimeoutMs);
if (result?.error) {
throw new Error(result.error);
}
return result;
} catch (err) {
if (isRetryableContentScriptTransportError(err)) {
return null;
}
throw err;
}
}
async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
throwIfStopped();
const pageState = await getStep8PageState(tabId);
if (pageState?.addPhonePage) {
throw new Error('步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
}
if (pageState?.consentReady) {
return pageState;
}
await sleepWithStop(250);
}
throw new Error('步骤 8:长时间未进入 OAuth 同意页,无法定位“继续”按钮。');
}
async function prepareStep8DebuggerClick() {
const result = await sendToContentScriptResilient('signup-page', {
type: 'STEP8_FIND_AND_CLICK',
source: 'background',
payload: {},
}, {
timeoutMs: 15000,
retryDelayMs: 600,
logMessage: '步骤 8:认证页正在切换,等待 OAuth 同意页按钮重新就绪...',
});
if (result?.error) {
throw new Error(result.error);
}
return result;
}
async function triggerStep8ContentStrategy(strategy) {
const result = await sendToContentScriptResilient('signup-page', {
type: 'STEP8_TRIGGER_CONTINUE',
source: 'background',
payload: {
strategy,
findTimeoutMs: 4000,
enabledTimeoutMs: 3000,
},
}, {
timeoutMs: 15000,
retryDelayMs: 600,
logMessage: '步骤 8:认证页正在切换,等待“继续”按钮重新就绪...',
});
if (result?.error) {
throw new Error(result.error);
}
return result;
}
async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLICK_EFFECT_TIMEOUT_MS) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
throwIfStopped();
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab) {
throw new Error('步骤 8:认证页面标签页已关闭,无法继续自动授权。');
}
if (baselineUrl && typeof tab.url === 'string' && tab.url !== baselineUrl) {
return { progressed: true, reason: 'url_changed', url: tab.url };
}
const pageState = await getStep8PageState(tabId);
if (pageState?.addPhonePage) {
throw new Error('步骤 8:点击“继续”后页面跳到了手机号页面,当前流程无法继续自动授权。');
}
if (pageState === null) {
return { progressed: true, reason: 'page_reloading' };
}
if (pageState && !pageState.consentPage) {
return { progressed: true, reason: 'left_consent_page', url: pageState.url };
}
await sleepWithStop(200);
}
return { progressed: false, reason: 'no_effect' };
}
function getStep8EffectLabel(effect) {
switch (effect?.reason) {
case 'url_changed':
return `URL 已变化:${effect.url}`;
case 'page_reloading':
return '页面正在跳转或重载';
case 'left_consent_page':
return `页面已离开 OAuth 同意页:${effect.url || 'unknown'}`;
default:
return '页面仍停留在 OAuth 同意页';
}
}
async function executeStep8(state) {
if (!state.oauthUrl) {
@@ -2451,7 +2583,6 @@ async function executeStep8(state) {
await addLog('步骤 8:正在监听 localhost 回调地址...');
// 只在当前步骤内注册 webNavigation 监听
return new Promise((resolve, reject) => {
let resolved = false;
let signupTabId = null;
@@ -2463,9 +2594,16 @@ async function executeStep8(state) {
}
};
const timeout = setTimeout(() => {
const failStep8 = (error) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
cleanupListener();
reject(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 8 的点击可能被拦截了。'));
reject(error);
};
const timeout = setTimeout(() => {
failStep8(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 8 已持续重试点击“继续”但页面仍未完成授权。'));
}, 120000);
webNavListener = (details) => {
@@ -2487,40 +2625,61 @@ async function executeStep8(state) {
}
};
// 步骤 7 之后,认证页会进入 OAuth 同意页,出现“继续”按钮。
// 这里先在页面内定位按钮,再通过 debugger 输入事件发起点击。
(async () => {
try {
signupTabId = await getTabId('signup-page');
if (signupTabId) {
await chrome.tabs.update(signupTabId, { active: true });
await addLog('步骤 8:已切回认证页,正在准备调试器点击...');
await addLog('步骤 8:已切回认证页,准备循环确认“继续”按钮直到页面真正跳转...');
} else {
signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl);
await addLog('步骤 8:已重新打开认证页,正在准备调试器点击...');
await addLog('步骤 8:已重新打开认证页,准备循环确认“继续”按钮直到页面真正跳转...');
}
chrome.webNavigation.onBeforeNavigate.addListener(webNavListener);
const clickResult = await sendToContentScript('signup-page', {
type: 'STEP8_FIND_AND_CLICK',
source: 'background',
payload: {},
});
let attempt = 0;
while (!resolved) {
const pageState = await waitForStep8Ready(signupTabId);
if (!pageState?.consentReady) {
await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
continue;
}
if (clickResult?.error) {
throw new Error(clickResult.error);
}
const strategy = STEP8_STRATEGIES[attempt % STEP8_STRATEGIES.length];
const round = attempt + 1;
attempt += 1;
if (!resolved) {
await clickWithDebugger(signupTabId, clickResult?.rect);
await addLog('步骤 8:已发送调试器点击,正在等待跳转...');
await addLog(`步骤 8:第 ${round} 次尝试点击“继续”(${strategy.label}...`);
if (strategy.mode === 'debugger') {
const clickTarget = await prepareStep8DebuggerClick();
if (!resolved) {
await clickWithDebugger(signupTabId, clickTarget?.rect);
}
} else {
await triggerStep8ContentStrategy(strategy.strategy);
}
if (resolved) {
return;
}
const effect = await waitForStep8ClickEffect(signupTabId, pageState.url);
if (resolved) {
return;
}
if (effect.progressed) {
await addLog(`步骤 8:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
break;
}
await addLog(`步骤 8${strategy.label} 本次未触发页面离开同意页,准备继续重试。`, 'warn');
await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
}
} catch (err) {
clearTimeout(timeout);
cleanupListener();
reject(err);
failStep8(err);
}
})();
});
+120 -9
View File
@@ -9,6 +9,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
message.type === 'EXECUTE_STEP'
|| message.type === 'FILL_CODE'
|| message.type === 'STEP8_FIND_AND_CLICK'
|| message.type === 'STEP8_GET_STATE'
|| message.type === 'STEP8_TRIGGER_CONTINUE'
|| message.type === 'PREPARE_LOGIN_CODE'
|| message.type === 'PREPARE_SIGNUP_VERIFICATION'
|| message.type === 'RESEND_VERIFICATION_CODE'
@@ -58,6 +60,10 @@ async function handleCommand(message) {
return await resendVerificationCode(message.step);
case 'STEP8_FIND_AND_CLICK':
return await step8_findAndClick();
case 'STEP8_GET_STATE':
return getStep8State();
case 'STEP8_TRIGGER_CONTINUE':
return await step8_triggerContinue(message.payload);
}
}
@@ -928,13 +934,7 @@ async function step6_login(payload) {
async function step8_findAndClick() {
log('步骤 8:正在查找 OAuth 同意页的“继续”按钮...');
const continueBtn = await findContinueButton();
await waitForButtonEnabled(continueBtn);
await humanPause(350, 900);
continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
continueBtn.focus();
await sleep(250);
const continueBtn = await prepareStep8ContinueButton();
const rect = getSerializableRect(continueBtn);
log('步骤 8:已找到“继续”按钮并准备好调试器点击坐标。');
@@ -945,9 +945,82 @@ async function step8_findAndClick() {
};
}
async function findContinueButton() {
function getStep8State() {
const pageText = getPageTextSnapshot();
const continueBtn = getPrimaryContinueButton();
const state = {
url: location.href,
consentPage: OAUTH_CONSENT_PAGE_PATTERN.test(pageText),
consentReady: isStep8Ready(),
verificationPage: isVerificationPageStillVisible(),
addPhonePage: isAddPhonePageReady(),
buttonFound: Boolean(continueBtn),
buttonEnabled: isButtonEnabled(continueBtn),
buttonText: continueBtn ? getActionText(continueBtn) : '',
};
if (continueBtn) {
try {
state.rect = getSerializableRect(continueBtn);
} catch {
state.rect = null;
}
}
return state;
}
async function step8_triggerContinue(payload = {}) {
const strategy = payload?.strategy || 'requestSubmit';
const continueBtn = await prepareStep8ContinueButton({
findTimeoutMs: payload?.findTimeoutMs,
enabledTimeoutMs: payload?.enabledTimeoutMs,
});
const form = continueBtn.form || continueBtn.closest('form');
switch (strategy) {
case 'requestSubmit':
if (!form || typeof form.requestSubmit !== 'function') {
throw new Error('“继续”按钮当前不在可提交的 form 中,无法使用 requestSubmit。URL: ' + location.href);
}
form.requestSubmit(continueBtn);
break;
case 'nativeClick':
continueBtn.click();
break;
case 'dispatchClick':
simulateClick(continueBtn);
break;
default:
throw new Error(`未知的 Step 8 触发策略:${strategy}`);
}
log(`Step 8: continue button triggered via ${strategy}.`);
return {
strategy,
...getStep8State(),
};
}
async function prepareStep8ContinueButton(options = {}) {
const {
findTimeoutMs = 10000,
enabledTimeoutMs = 8000,
} = options;
const continueBtn = await findContinueButton(findTimeoutMs);
await waitForButtonEnabled(continueBtn, enabledTimeoutMs);
await humanPause(250, 700);
continueBtn.scrollIntoView({ behavior: 'auto', block: 'center' });
continueBtn.focus();
await waitForStableButtonRect(continueBtn);
return continueBtn;
}
async function findContinueButton(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < 10000) {
while (Date.now() - start < timeout) {
throwIfStopped();
if (isAddPhonePageReady()) {
throw new Error('当前页面已进入手机号页面,不是 OAuth 授权同意页。URL: ' + location.href);
@@ -978,6 +1051,44 @@ function isButtonEnabled(button) {
&& button.getAttribute('aria-disabled') !== 'true';
}
async function waitForStableButtonRect(button, timeout = 1500) {
let previous = null;
let stableSamples = 0;
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const rect = button?.getBoundingClientRect?.();
if (rect && rect.width > 0 && rect.height > 0) {
const snapshot = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
if (
previous
&& Math.abs(snapshot.left - previous.left) < 1
&& Math.abs(snapshot.top - previous.top) < 1
&& Math.abs(snapshot.width - previous.width) < 1
&& Math.abs(snapshot.height - previous.height) < 1
) {
stableSamples += 1;
if (stableSamples >= 2) {
return;
}
} else {
stableSamples = 0;
}
previous = snapshot;
}
await sleep(80);
}
}
function getSerializableRect(el) {
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height) {