Files
FlowPilot/content/paypal-flow.js
T
2026-04-28 15:31:59 +08:00

370 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// content/paypal-flow.js — PayPal login and approval helper.
console.log('[MultiPage:paypal-flow] Content script loaded on', location.href);
const PAYPAL_FLOW_LISTENER_SENTINEL = 'data-multipage-paypal-flow-listener';
if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'PAYPAL_GET_STATE'
|| message.type === 'PAYPAL_SUBMIT_LOGIN'
|| message.type === 'PAYPAL_DISMISS_PROMPTS'
|| message.type === 'PAYPAL_CLICK_APPROVE'
) {
resetStopState();
handlePayPalCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch((err) => {
if (isStopError(err)) {
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
}
});
} else {
console.log('[MultiPage:paypal-flow] 消息监听已存在,跳过重复注册');
}
async function handlePayPalCommand(message) {
switch (message.type) {
case 'PAYPAL_GET_STATE':
return inspectPayPalState();
case 'PAYPAL_SUBMIT_LOGIN':
return submitPayPalLogin(message.payload || {});
case 'PAYPAL_DISMISS_PROMPTS':
return dismissPayPalPrompts();
case 'PAYPAL_CLICK_APPROVE':
return clickPayPalApprove();
default:
throw new Error(`paypal-flow.js 不处理消息:${message.type}`);
}
}
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);
}
}
async function waitForDocumentComplete() {
await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 });
await sleep(1000);
}
function isVisibleElement(el) {
if (!el) return false;
let node = el;
while (node && node.nodeType === 1) {
if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) {
return false;
}
const nodeStyle = window.getComputedStyle(node);
if (
nodeStyle.display === 'none'
|| nodeStyle.visibility === 'hidden'
|| nodeStyle.visibility === 'collapse'
|| Number(nodeStyle.opacity) === 0
) {
return false;
}
node = node.parentElement;
}
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(rect.width) > 0
&& Number(rect.height) > 0;
}
function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function getActionText(el) {
return normalizeText([
el?.textContent,
el?.value,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.getAttribute?.('placeholder'),
el?.getAttribute?.('name'),
el?.id,
].filter(Boolean).join(' '));
}
function getVisibleControls(selector) {
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
}
function isEnabledControl(el) {
return Boolean(el)
&& !el.disabled
&& el.getAttribute?.('aria-disabled') !== 'true';
}
function findClickableByText(patterns) {
const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"]');
return candidates.find((el) => {
const text = getActionText(el);
return normalizedPatterns.some((pattern) => pattern.test(text));
}) || null;
}
function findInputByPatterns(patterns) {
const inputs = getVisibleControls('input')
.filter((input) => {
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
});
return inputs.find((input) => {
const text = getActionText(input);
return patterns.some((pattern) => pattern.test(text));
}) || null;
}
function findEmailInput() {
return findInputByPatterns([
/email|login|user|账号|邮箱/i,
]) || getVisibleControls('input[type="email"]').find(isVisibleElement) || null;
}
function findPasswordInput() {
return findInputByPatterns([
/password|pass|密码/i,
]) || getVisibleControls('input[type="password"]').find(isVisibleElement) || null;
}
function findLoginNextButton() {
return findClickableByText([
/next|continue|login|log\s*in|sign\s*in/i,
/下一步|继续|登录|登入/i,
]);
}
function findEmailNextButton() {
return findClickableByText([
/next|btn\s*next|btnnext/i,
/下一页|下一步/i,
]);
}
function findPasswordLoginButton() {
const button = findClickableByText([
/login|log\s*in|sign\s*in/i,
/登录|登入/i,
]);
return button && button !== findEmailNextButton() ? button : null;
}
function findApproveButton() {
return findClickableByText([
/同意并继续|同意|继续|授权|确认并继续/i,
/agree\s*(?:and)?\s*continue|continue|accept|authorize|agree|pay\s*now/i,
]);
}
function findPasskeyPromptButtons() {
const promptPatterns = [
/passkey|通行密钥|安全密钥|下次登录|faster|save/i,
];
const bodyText = normalizeText(document.body?.innerText || '');
const likelyPrompt = promptPatterns.some((pattern) => pattern.test(bodyText));
if (!likelyPrompt) {
return [];
}
const cancelOrClose = getVisibleControls('button, a, [role="button"]')
.filter((el) => {
const text = getActionText(el);
return /取消|稍后|不保存|不用|关闭|cancel|not now|maybe later|skip|close|x/i.test(text)
|| el.getAttribute?.('aria-label')?.match(/close|关闭/i);
});
const iconCloseButtons = getVisibleControls('button, [role="button"]')
.filter((el) => {
const text = getActionText(el);
const rect = el.getBoundingClientRect();
return (/^×$|^x$/i.test(text) || /close|关闭/i.test(text))
&& rect.width <= 64
&& rect.height <= 64;
});
return [...cancelOrClose, ...iconCloseButtons];
}
function hasPasskeyPrompt() {
return findPasskeyPromptButtons().length > 0;
}
function getPayPalLoginPhase(emailInput, passwordInput) {
const emailNextButton = findEmailNextButton();
const passwordLoginButton = findPasswordLoginButton();
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !passwordLoginButton)) {
return 'email';
}
if (emailInput && passwordInput) return 'login_combined';
if (passwordInput) return 'password';
if (emailInput) return 'email';
return '';
}
function refillPayPalEmailInput(emailInput, email) {
if (!emailInput) return;
if (typeof emailInput.focus === 'function') {
emailInput.focus();
}
fillInput(emailInput, '');
fillInput(emailInput, email);
if (typeof emailInput.blur === 'function') {
emailInput.blur();
}
}
async function submitPayPalLogin(payload = {}) {
await waitForDocumentComplete();
const email = normalizeText(payload.email || '');
const password = String(payload.password || '');
if (!password) {
throw new Error('PayPal 密码为空,请先在侧边栏配置。');
}
let passwordInput = findPasswordInput();
const emailInput = findEmailInput();
const emailNextButton = findEmailNextButton();
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) {
refillPayPalEmailInput(emailInput, email);
simulateClick(emailNextButton);
return {
submitted: false,
phase: 'email_submitted',
awaiting: 'password_page',
};
}
if (!passwordInput && emailInput && email) {
refillPayPalEmailInput(emailInput, email);
const nextButton = await waitUntil(() => {
const button = findEmailNextButton() || 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) {
refillPayPalEmailInput(emailInput, email);
}
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);
const loginButton = await waitUntil(() => {
const button = findClickableByText([
/login|log\s*in|sign\s*in|continue/i,
/登录|登入|继续/i,
]);
return button && isEnabledControl(button) ? button : null;
}, {
intervalMs: 250,
timeoutMs: 8000,
timeoutMessage: 'PayPal password page did not expose a clickable login/continue button.',
});
simulateClick(loginButton);
return {
submitted: true,
phase: 'password_submitted',
awaiting: 'redirect_or_approval',
};
}
async function dismissPayPalPrompts() {
await waitForDocumentComplete();
const buttons = findPasskeyPromptButtons();
let clicked = 0;
for (const button of buttons) {
if (!isVisibleElement(button) || !isEnabledControl(button)) {
continue;
}
simulateClick(button);
clicked += 1;
await sleep(500);
}
return {
clicked,
hasPromptAfterClick: hasPasskeyPrompt(),
};
}
async function clickPayPalApprove() {
await waitForDocumentComplete();
await dismissPayPalPrompts().catch(() => ({ clicked: 0 }));
const button = findApproveButton();
if (!button || !isEnabledControl(button)) {
return {
clicked: false,
state: inspectPayPalState(),
};
}
simulateClick(button);
return {
clicked: true,
buttonText: getActionText(button),
};
}
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(loginPhase),
loginPhase,
hasEmailInput: Boolean(emailInput),
hasPasswordInput: Boolean(passwordInput),
approveReady: Boolean(approveButton && isEnabledControl(approveButton)),
approveButtonText: approveButton ? getActionText(approveButton) : '',
hasPasskeyPrompt: hasPasskeyPrompt(),
bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240),
};
}