From 3271dc2f83b4616ecc46a88fc9f9d5b5acd00efd Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 05:47:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20PayPal=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=B5=81=E7=A8=8B=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=A3=80=E6=B5=8B=E9=82=AE=E7=AE=B1=E5=92=8C=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E8=BE=93=E5=85=A5=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E5=A4=84=E7=90=86=E5=92=8C=E7=9B=B8=E5=BA=94=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/paypal-approve.js | 106 +++++++++++++++++- content/paypal-flow.js | 57 ++++++++-- tests/paypal-approve-detection.test.js | 146 +++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 tests/paypal-approve-detection.test.js diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index b638654..3dee859 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -4,6 +4,8 @@ const PAYPAL_SOURCE = 'paypal-flow'; const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; const PAYPAL_INJECT_FILES = ['content/utils.js', 'content/paypal-flow.js']; + const PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS = 30000; + const PAYPAL_LOGIN_TRANSITION_POLL_MS = 500; function createPayPalApproveExecutor(deps = {}) { const { @@ -87,6 +89,89 @@ if (result?.error) { throw new Error(result.error); } + return result || {}; + } + + function isPayPalUrl(url = '') { + return /paypal\./i.test(String(url || '')); + } + + function isPayPalPasswordState(pageState = {}) { + return Boolean(pageState.hasPasswordInput) + || pageState.loginPhase === 'password' + || pageState.loginPhase === 'login_combined'; + } + + async function waitForPayPalPostLoginDecision(tabId, actionResult = {}) { + const phase = String(actionResult?.phase || '').trim(); + const startedAt = Date.now(); + + while (Date.now() - startedAt < PAYPAL_LOGIN_TRANSITION_TIMEOUT_MS) { + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('步骤 8:PayPal 标签页已关闭,无法继续识别登录后的页面。'); + } + + const currentUrl = tab.url || ''; + if (!currentUrl) { + await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS); + continue; + } + if (currentUrl && !isPayPalUrl(currentUrl)) { + return { + outcome: 'left_paypal', + url: currentUrl, + }; + } + + if (tab.status !== 'complete') { + await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS); + continue; + } + + await ensurePayPalReady( + tabId, + phase === 'email_submitted' + ? '步骤 8:PayPal 账号已提交,正在识别下一页...' + : '步骤 8:PayPal 密码已提交,正在识别跳转结果...' + ); + const pageState = await getPayPalState(tabId); + + if (pageState.hasPasskeyPrompt) { + return { + outcome: 'prompt', + pageState, + }; + } + + if (pageState.approveReady) { + return { + outcome: 'approve_ready', + pageState, + }; + } + + if (phase === 'email_submitted' && isPayPalPasswordState(pageState)) { + return { + outcome: 'password_ready', + pageState, + }; + } + + if (phase === 'password_submitted' && !pageState.needsLogin) { + return { + outcome: 'post_login_state', + pageState, + }; + } + + await sleepWithStop(PAYPAL_LOGIN_TRANSITION_POLL_MS); + } + + return { + outcome: 'timeout', + phase, + }; } async function clickApprove(tabId) { @@ -109,7 +194,7 @@ let loggedWaiting = false; while (true) { const currentUrl = (await chrome.tabs.get(tabId).catch(() => null))?.url || ''; - if (currentUrl && !/paypal\./i.test(currentUrl)) { + if (currentUrl && !isPayPalUrl(currentUrl)) { await addLog('步骤 8:PayPal 已跳转离开授权页,准备进入回跳确认。', 'ok'); break; } @@ -118,9 +203,22 @@ const pageState = await getPayPalState(tabId); if (pageState.needsLogin) { - await submitLogin(tabId, state); - await waitForTabCompleteUntilStopped(tabId); - await sleepWithStop(1000); + const submitResult = await submitLogin(tabId, state); + const decision = await waitForPayPalPostLoginDecision(tabId, submitResult); + if (decision.outcome === 'left_paypal') { + await addLog('步骤 8:PayPal 登录后已跳转离开登录/授权页,继续进入回跳确认。', 'ok'); + break; + } + if (decision.outcome === 'password_ready') { + await addLog('步骤 8:PayPal 账号页提交后已识别到密码页,继续填写密码。', 'info'); + } else if (decision.outcome === 'approve_ready') { + await addLog('步骤 8:PayPal 登录后已识别到授权确认页,继续点击授权。', 'info'); + } else if (decision.outcome === 'prompt') { + await addLog('步骤 8:PayPal 登录后已识别到提示弹窗,继续处理弹窗。', 'info'); + } else if (decision.outcome === 'timeout') { + await addLog('步骤 8:PayPal 登录动作后暂未识别到新页面,重新检查当前页面状态。', 'warn'); + } + loggedWaiting = false; continue; } diff --git a/content/paypal-flow.js b/content/paypal-flow.js index f003609..7cc4217 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -48,12 +48,17 @@ async function handlePayPalCommand(message) { async function waitUntil(predicate, options = {}) { const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250)); + const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0)); + const startedAt = Date.now(); while (true) { throwIfStopped(); const value = await predicate(); if (value) { return value; } + if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) { + throw new Error(options.timeoutMessage || 'PayPal page timed out waiting for target state.'); + } await sleep(intervalMs); } } @@ -179,6 +184,13 @@ function hasPasskeyPrompt() { return findPasskeyPromptButtons().length > 0; } +function getPayPalLoginPhase(emailInput, passwordInput) { + if (emailInput && passwordInput) return 'login_combined'; + if (passwordInput) return 'password'; + if (emailInput) return 'email'; + return ''; +} + async function submitPayPalLogin(payload = {}) { await waitForDocumentComplete(); @@ -192,19 +204,34 @@ async function submitPayPalLogin(payload = {}) { const emailInput = findEmailInput(); if (!passwordInput && emailInput && email) { - fillInput(emailInput, email); - const nextButton = findLoginNextButton(); - if (nextButton && isEnabledControl(nextButton)) { - simulateClick(nextButton); + if (normalizeText(emailInput.value || '') !== email) { + fillInput(emailInput, email); } - passwordInput = await waitUntil(() => findPasswordInput(), { intervalMs: 250 }); + const nextButton = await waitUntil(() => { + const button = findLoginNextButton(); + return button && isEnabledControl(button) ? button : null; + }, { + intervalMs: 250, + timeoutMs: 8000, + timeoutMessage: 'PayPal email page did not expose a clickable next/continue button.', + }); + simulateClick(nextButton); + return { + submitted: false, + phase: 'email_submitted', + awaiting: 'password_page', + }; } else if (!passwordInput && emailInput && !email) { throw new Error('PayPal 账号为空,请先在侧边栏配置。'); - } else if (emailInput && email && !String(emailInput.value || '').trim()) { + } else if (emailInput && email && normalizeText(emailInput.value || '') !== email) { fillInput(emailInput, email); } - passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { intervalMs: 250 }); + passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { + intervalMs: 250, + timeoutMs: 8000, + timeoutMessage: 'PayPal password page did not expose a password input.', + }); fillInput(passwordInput, password); await sleep(1000); @@ -214,10 +241,18 @@ async function submitPayPalLogin(payload = {}) { /登录|登入|继续/i, ]); return button && isEnabledControl(button) ? button : null; - }, { intervalMs: 250 }); + }, { + intervalMs: 250, + timeoutMs: 8000, + timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.', + }); simulateClick(loginButton); - return { submitted: true }; + return { + submitted: true, + phase: 'password_submitted', + awaiting: 'redirect_or_approval', + }; } async function dismissPayPalPrompts() { @@ -261,10 +296,12 @@ function inspectPayPalState() { const emailInput = findEmailInput(); const passwordInput = findPasswordInput(); const approveButton = findApproveButton(); + const loginPhase = getPayPalLoginPhase(emailInput, passwordInput); return { url: location.href, readyState: document.readyState, - needsLogin: Boolean(emailInput || passwordInput), + needsLogin: Boolean(loginPhase), + loginPhase, hasEmailInput: Boolean(emailInput), hasPasswordInput: Boolean(passwordInput), approveReady: Boolean(approveButton && isEnabledControl(approveButton)), diff --git a/tests/paypal-approve-detection.test.js b/tests/paypal-approve-detection.test.js new file mode 100644 index 0000000..3adf6a1 --- /dev/null +++ b/tests/paypal-approve-detection.test.js @@ -0,0 +1,146 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/steps/paypal-approve.js', 'utf8'); + +function loadModule() { + const self = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundPayPalApprove;`)(self); +} + +function createExecutor({ pageStates, submitResults, tabUrls = [] }) { + const api = loadModule(); + const events = { + completed: [], + logs: [], + messages: [], + submittedPayloads: [], + }; + const stateQueue = [...pageStates]; + const submitQueue = [...submitResults]; + const urlQueue = [...tabUrls]; + let lastUrl = urlQueue.shift() || 'https://www.paypal.com/signin'; + + const executor = api.createPayPalApproveExecutor({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + chrome: { + tabs: { + get: async () => { + if (urlQueue.length) { + lastUrl = urlQueue.shift(); + } + return { + id: 1, + status: 'complete', + url: lastUrl, + }; + }, + }, + }, + completeStepFromBackground: async (step, payload) => { + events.completed.push({ step, payload }); + }, + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + getTabId: async (source) => (source === 'paypal-flow' ? 1 : null), + isTabAlive: async () => true, + sendTabMessageUntilStopped: async (_tabId, _source, message) => { + events.messages.push(message.type); + if (message.type === 'PAYPAL_GET_STATE') { + return stateQueue.shift() || pageStates[pageStates.length - 1] || {}; + } + if (message.type === 'PAYPAL_SUBMIT_LOGIN') { + events.submittedPayloads.push(message.payload); + return submitQueue.shift() || { submitted: true, phase: 'password_submitted' }; + } + if (message.type === 'PAYPAL_DISMISS_PROMPTS') { + return { clicked: 0 }; + } + if (message.type === 'PAYPAL_CLICK_APPROVE') { + return { clicked: true }; + } + return {}; + }, + setState: async () => {}, + sleepWithStop: async () => {}, + waitForTabCompleteUntilStopped: async () => {}, + waitForTabUrlMatchUntilStopped: async () => {}, + }); + + return { executor, events }; +} + +test('PayPal approve keeps original combined email and password login path', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' }, + { needsLogin: false, approveReady: true }, + { needsLogin: false, approveReady: true }, + ], + submitResults: [ + { submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' }, + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.equal(events.submittedPayloads.length, 1); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); +}); + +test('PayPal approve auto-detects split email then password pages', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: true, hasEmailInput: true, hasPasswordInput: false, loginPhase: 'email' }, + { needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' }, + { needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' }, + { needsLogin: false, approveReady: true }, + { needsLogin: false, approveReady: true }, + ], + submitResults: [ + { submitted: false, phase: 'email_submitted', awaiting: 'password_page' }, + { submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' }, + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.equal(events.submittedPayloads.length, 2); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.logs.some(({ message }) => /识别到密码页/.test(message)), true); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true); +}); + +test('PayPal approve finishes when login redirects away from PayPal', async () => { + const { executor, events } = createExecutor({ + pageStates: [ + { needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' }, + ], + submitResults: [ + { submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' }, + ], + tabUrls: [ + 'https://www.paypal.com/signin', + 'https://www.paypal.com/signin', + 'https://checkout.openai.com/return', + ], + }); + + await executor.executePayPalApprove({ + paypalEmail: 'user@example.com', + paypalPassword: 'secret', + }); + + assert.equal(events.submittedPayloads.length, 1); + assert.deepEqual(events.completed.map((item) => item.step), [8]); + assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), false); +});