Split PayPal hosted checkout flow

This commit is contained in:
QLHazyCoder
2026-05-20 10:03:56 +08:00
parent 886d4c1297
commit aa8025cb76
8 changed files with 1214 additions and 146 deletions
+168 -13
View File
@@ -8,9 +8,17 @@ const PAYPAL_HOSTED_STAGE_OUTSIDE = 'outside_paypal';
const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login';
const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout';
const PAYPAL_HOSTED_STAGE_VERIFICATION = 'verification';
const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account';
const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent';
const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval';
const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown';
const PAYPAL_HOSTED_STEP_KEYS = {
[PAYPAL_HOSTED_STAGE_LOGIN]: 'paypal-hosted-email',
[PAYPAL_HOSTED_STAGE_VERIFICATION]: 'paypal-hosted-verification',
[PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT]: 'paypal-hosted-card',
[PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT]: 'paypal-hosted-create-account',
[PAYPAL_HOSTED_STAGE_REVIEW]: 'paypal-hosted-review',
};
if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1');
@@ -255,15 +263,47 @@ function isHostedLoginPage() {
}
function isHostedGuestCheckoutPage() {
if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) {
return true;
}
const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
if (/create\s*(?:paypal\s*)?account|agree\s*(?:&|and)?\s*create|创建.*(?:账户|账号)/i.test(pageText)
&& findHostedCreateAccountButton()) {
return false;
}
return /\/checkoutweb\//i.test(getPayPalPathname())
|| Boolean(document.getElementById('cardNumber'))
|| Boolean(document.getElementById('billingLine1'));
&& Boolean(document.getElementById('phone') || document.getElementById('email'));
}
function isHostedReviewPage() {
return /\/webapps\/hermes/i.test(getPayPalPathname());
}
function findHostedCreateAccountButton() {
return document.getElementById('createAccount')
|| document.getElementById('createAccountButton')
|| document.querySelector('button[data-testid="createAccountButton"]')
|| document.querySelector('button[data-testid="create-account-button"]')
|| findClickableByText([
/agree\s*(?:&|and)?\s*create\s*(?:paypal\s*)?account/i,
/create\s*(?:paypal\s*)?account/i,
/同意.*创建|创建.*账户|创建.*账号/i,
]);
}
function isHostedCreateAccountPage() {
if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) {
return false;
}
const button = findHostedCreateAccountButton();
if (!button || !isVisibleElement(button) || !isEnabledControl(button)) {
return false;
}
const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
return /create\s*(?:paypal\s*)?account|agree\s*(?:&|and)?\s*create|创建.*(?:账户|账号)/i.test(pageText)
|| /create/i.test(getActionText(button));
}
function findHostedReviewConsentButton() {
const direct = document.getElementById('consentButton')
|| document.querySelector('button[data-testid="consentButton"]');
@@ -289,6 +329,9 @@ function detectPayPalHostedStage() {
if (isHostedReviewPage() && findHostedReviewConsentButton()) {
return PAYPAL_HOSTED_STAGE_REVIEW;
}
if (isHostedCreateAccountPage()) {
return PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT;
}
if (isHostedLoginPage()) {
return PAYPAL_HOSTED_STAGE_LOGIN;
}
@@ -337,23 +380,102 @@ function findHostedSubmitButton() {
]);
}
async function clickHostedSubmitButton() {
function getHostedStepKey(stage = '', fallback = 'plus-checkout-create') {
return PAYPAL_HOSTED_STEP_KEYS[stage] || fallback;
}
async function clickHostedSubmitButton(options = {}) {
const stepKey = String(options.stepKey || getHostedStepKey(options.stage)).trim();
const label = String(options.label || 'hosted-paypal-submit').trim();
const maxAttempts = Math.max(1, Math.floor(Number(options.maxAttempts) || 3));
let lastButtonText = '';
let lastDisabled = false;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const button = await waitUntil(() => {
const candidate = findHostedSubmitButton();
return candidate && isVisibleElement(candidate) ? candidate : null;
}, {
intervalMs: 500,
timeoutMs: 15000,
timeoutMessage: 'PayPal hosted checkout 未找到可点击的继续/提交按钮。',
});
lastButtonText = getActionText(button);
lastDisabled = !isEnabledControl(button);
if (lastDisabled) {
if (attempt >= maxAttempts) {
throw new Error('PayPal hosted checkout 继续/提交按钮长时间不可用。');
}
await sleep(1000);
continue;
}
await performPayPalOperationWithDelay({ stepKey, kind: 'click', label }, async () => {
simulateClick(button);
});
await sleep(1000);
return {
clicked: true,
buttonText: lastButtonText,
verificationRequired: hasHostedVerificationInputs(),
attempt,
};
}
return {
clicked: false,
buttonText: lastButtonText,
disabled: lastDisabled,
};
}
function normalizeHostedPhoneDigits(value = '') {
return String(value || '').replace(/\D/g, '');
}
function verifyHostedPhoneBeforeSubmit(expectedPhone = '') {
const phoneInput = document.getElementById('phone');
if (!phoneInput || !isVisibleElement(phoneInput)) {
throw new Error('PayPal hosted checkout 未找到电话输入框。');
}
const expectedDigits = normalizeHostedPhoneDigits(expectedPhone || PAYPAL_HOSTED_DEFAULT_PHONE);
const renderedDigits = normalizeHostedPhoneDigits(phoneInput.value || '');
if (!expectedDigits) {
throw new Error('PayPal hosted checkout 电话配置为空。');
}
const comparableRenderedDigits = renderedDigits.length > expectedDigits.length
? renderedDigits.slice(-expectedDigits.length)
: renderedDigits;
if (comparableRenderedDigits !== expectedDigits) {
throw new Error(`PayPal hosted checkout 电话不一致:配置 ${expectedDigits},页面 ${renderedDigits || '(空)'}`);
}
return {
payloadPhoneDigits: expectedDigits,
renderedPhoneDigits: renderedDigits,
phoneMatched: true,
};
}
async function clickHostedCreateAccount(payload = {}) {
await waitForDocumentComplete();
const button = await waitUntil(() => {
const candidate = findHostedSubmitButton();
const candidate = findHostedCreateAccountButton();
return candidate && isVisibleElement(candidate) && isEnabledControl(candidate) ? candidate : null;
}, {
intervalMs: 500,
timeoutMs: 15000,
timeoutMessage: 'PayPal hosted checkout 未找到可点击的继续/提交按钮。',
timeoutMs: 30000,
timeoutMessage: 'PayPal hosted checkout 未找到创建账号确认按钮。',
});
await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'click', label: 'hosted-paypal-submit' }, async () => {
await performPayPalOperationWithDelay({
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT),
kind: 'click',
label: 'hosted-paypal-create-account',
}, async () => {
simulateClick(button);
});
await sleep(1000);
return {
stage: PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT,
clicked: true,
submitted: true,
buttonText: getActionText(button),
verificationRequired: hasHostedVerificationInputs(),
};
}
@@ -408,7 +530,10 @@ async function submitHostedLogin(payload = {}) {
throw new Error('PayPal hosted checkout 未找到邮箱输入框。');
}
refillPayPalEmailInput(emailInput, email);
const clickResult = await clickHostedSubmitButton();
const clickResult = await clickHostedSubmitButton({
stage: PAYPAL_HOSTED_STAGE_LOGIN,
label: 'hosted-paypal-email-submit',
});
return {
stage: PAYPAL_HOSTED_STAGE_LOGIN,
submitted: true,
@@ -427,7 +552,11 @@ async function fillHostedVerificationCode(payload = {}) {
if (inputs.length < 6) {
throw new Error('PayPal hosted checkout 当前页面未显示验证码输入框。');
}
await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'fill', label: 'hosted-paypal-verification-code' }, async () => {
await performPayPalOperationWithDelay({
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_VERIFICATION),
kind: 'fill',
label: 'hosted-paypal-verification-code',
}, async () => {
inputs.forEach((input, index) => fillInput(input, code[index] || ''));
});
return {
@@ -468,11 +597,18 @@ async function fillHostedGuestCheckout(payload = {}) {
fillHostedInputById('billingCity', address.city || '');
fillHostedInputById('billingPostalCode', address.zip || address.postalCode || '');
selectHostedOptionByIdText('billingState', address.state || address.region || '');
const clickResult = await clickHostedSubmitButton();
const phoneCheck = verifyHostedPhoneBeforeSubmit(values.phone);
const clickResult = await clickHostedSubmitButton({
stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
label: 'hosted-paypal-card-submit',
maxAttempts: 4,
});
return {
stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
submitted: true,
verificationRequired: Boolean(clickResult.verificationRequired),
payloadPhone: values.phone,
...phoneCheck,
};
}
@@ -486,7 +622,11 @@ async function clickHostedReviewConsent() {
timeoutMs: 30000,
timeoutMessage: 'PayPal hosted checkout 未找到账单确认按钮。',
});
await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'click', label: 'hosted-paypal-review-consent' }, async () => {
await performPayPalOperationWithDelay({
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_REVIEW),
kind: 'click',
label: 'hosted-paypal-review-consent',
}, async () => {
simulateClick(button);
});
return {
@@ -497,6 +637,16 @@ async function clickHostedReviewConsent() {
async function runPayPalHostedCheckoutStep(payload = {}) {
const stage = detectPayPalHostedStage();
const expectedStage = String(payload.expectedStage || '').trim();
if (expectedStage && stage !== expectedStage) {
return {
stage,
expectedStage,
submitted: false,
skipped: true,
approveReady: Boolean(findApproveButton()),
};
}
if (stage === PAYPAL_HOSTED_STAGE_VERIFICATION) {
return payload.verificationCode || payload.code
? fillHostedVerificationCode(payload)
@@ -508,6 +658,9 @@ async function runPayPalHostedCheckoutStep(payload = {}) {
if (stage === PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) {
return fillHostedGuestCheckout(payload);
}
if (stage === PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) {
return clickHostedCreateAccount(payload);
}
if (stage === PAYPAL_HOSTED_STAGE_REVIEW) {
return clickHostedReviewConsent();
}
@@ -520,6 +673,7 @@ async function runPayPalHostedCheckoutStep(payload = {}) {
function inspectPayPalHostedState() {
const stage = detectPayPalHostedStage();
const createAccountButton = findHostedCreateAccountButton();
return {
url: location.href,
readyState: document.readyState,
@@ -527,6 +681,7 @@ function inspectPayPalHostedState() {
verificationInputsVisible: hasHostedVerificationInputs(),
hasGuestCardFields: Boolean(document.getElementById('cardNumber')),
hasHostedEmailInput: Boolean(document.getElementById('email') || findEmailInput()),
createAccountReady: Boolean(createAccountButton && isVisibleElement(createAccountButton) && isEnabledControl(createAccountButton)),
reviewConsentReady: Boolean(findHostedReviewConsentButton()),
approveReady: Boolean(findApproveButton()),
bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240),