feat: add PayPal hosted checkout proxy support

This commit is contained in:
QLHazyCoder
2026-05-25 12:10:46 +08:00
parent f5658db583
commit 5f73c505a0
4 changed files with 647 additions and 4 deletions
@@ -10,6 +10,11 @@
const PLUS_PAYMENT_METHOD_PAYPAL_HOSTED = 'paypal-hosted';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const LOCAL_CHECKOUT_PROXY_HEALTH_URL = 'http://127.0.0.1:21988/health';
const LOCAL_CHECKOUT_PROXY_URL = 'socks5://127.0.0.1:21987';
const LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE = 'regular';
const LOCAL_CHECKOUT_PROXY_TIMEOUT_MS = 1200;
const LOCAL_CHECKOUT_PROXY_SETTLE_MS = 350;
const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top';
const GPC_HELPER_PHONE_MODE_AUTO = 'auto';
const GPC_HELPER_PHONE_MODE_MANUAL = 'manual';
@@ -24,6 +29,7 @@
const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login';
const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout';
const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account';
const PAYPAL_HOSTED_STAGE_SECURITY_CODE = 'security_code';
const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent';
const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval';
const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown';
@@ -58,6 +64,7 @@
sleepWithStop,
waitForTabCompleteUntilStopped,
waitForTabUrlMatchUntilStopped = null,
withCheckoutCreationProxy = null,
throwIfStopped = () => {},
} = deps;
@@ -78,6 +85,194 @@
});
}
function parseSocks5Endpoint(proxyUrl = '') {
const text = String(proxyUrl || '').trim();
if (!text) {
return null;
}
let parsed = null;
try {
parsed = new URL(text);
} catch {
return null;
}
if (String(parsed.protocol || '').replace(/:$/, '').toLowerCase() !== 'socks5') {
return null;
}
const host = String(parsed.hostname || '').replace(/^\[|\]$/g, '').trim();
const port = Number.parseInt(String(parsed.port || ''), 10);
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return { host, port };
}
function buildCheckoutCreationPacScript(endpoint) {
const proxyHost = String(endpoint?.host || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const port = Number.parseInt(String(endpoint?.port || ''), 10);
return `
function FindProxyForURL(url, host) {
host = String(host || '').toLowerCase();
if (host === 'chatgpt.com' || dnsDomainIs(host, '.chatgpt.com')) {
return "SOCKS5 ${proxyHost}:${port}";
}
return "DIRECT";
}`.trim();
}
function callChromeProxySettings(method, details = {}) {
const proxySettings = chrome?.proxy?.settings;
if (!proxySettings || typeof proxySettings[method] !== 'function') {
return Promise.reject(new Error('当前浏览器不支持扩展代理 API'));
}
return new Promise((resolve, reject) => {
proxySettings[method](details, (value) => {
const lastError = chrome?.runtime?.lastError;
if (lastError) {
reject(new Error(lastError.message || String(lastError)));
return;
}
resolve(value);
});
});
}
function canControlProxySettings(details = {}) {
const level = String(details?.levelOfControl || '').trim();
return !level || level === 'controlled_by_this_extension' || level === 'controllable_by_this_extension';
}
async function readProxySettingsSnapshot() {
return callChromeProxySettings('get', { incognito: false });
}
async function restoreProxySettingsSnapshot(snapshot = null) {
const value = snapshot?.value;
const level = String(snapshot?.levelOfControl || '').trim();
if (level === 'controlled_by_this_extension' && value && typeof value === 'object') {
await callChromeProxySettings('set', {
value,
scope: LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE,
});
return;
}
await callChromeProxySettings('clear', {
scope: LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE,
});
}
async function fetchLocalCheckoutProxyHealth() {
if (typeof fetchImpl !== 'function') {
return null;
}
const controller = typeof AbortController === 'function' ? new AbortController() : null;
let timer = null;
try {
timer = controller
? setTimeout(() => controller.abort(), LOCAL_CHECKOUT_PROXY_TIMEOUT_MS)
: null;
const response = await fetchImpl(`${LOCAL_CHECKOUT_PROXY_HEALTH_URL}?t=${Date.now()}`, {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json,text/plain,*/*' },
...(controller ? { signal: controller.signal } : {}),
});
if (!response?.ok) {
return null;
}
const payload = await response.json().catch(() => ({}));
if (!payload?.ok) {
return null;
}
const endpoint = parseSocks5Endpoint(payload.localProxy || LOCAL_CHECKOUT_PROXY_URL);
return endpoint ? { endpoint, payload } : null;
} catch {
return null;
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function applyTemporaryCheckoutProxy(endpoint) {
const pacScript = buildCheckoutCreationPacScript(endpoint);
await callChromeProxySettings('set', {
value: {
mode: 'pac_script',
pacScript: {
data: pacScript,
mandatory: true,
},
},
scope: LOCAL_CHECKOUT_PROXY_SETTINGS_SCOPE,
});
}
async function runWithLocalCheckoutCreationProxy(action) {
if (typeof withCheckoutCreationProxy === 'function') {
return withCheckoutCreationProxy({
healthUrl: LOCAL_CHECKOUT_PROXY_HEALTH_URL,
localProxyUrl: LOCAL_CHECKOUT_PROXY_URL,
}, action);
}
if (!chrome?.proxy?.settings || typeof fetchImpl !== 'function') {
return action();
}
const health = await fetchLocalCheckoutProxyHealth();
if (!health?.endpoint) {
return action();
}
let snapshot = null;
let applied = false;
let result = null;
let proxyError = null;
let restoreFailed = false;
try {
try {
snapshot = await readProxySettingsSnapshot();
} catch (error) {
return action();
}
if (!canControlProxySettings(snapshot)) {
return action();
}
await applyTemporaryCheckoutProxy(health.endpoint);
applied = true;
await sleepWithStop(LOCAL_CHECKOUT_PROXY_SETTLE_MS);
result = await action();
if (result?.error && !result?.stopped) {
proxyError = new Error(result.error);
}
} catch (error) {
if (!applied) {
return action();
}
proxyError = error;
} finally {
if (applied) {
try {
await restoreProxySettingsSnapshot(snapshot);
await sleepWithStop(LOCAL_CHECKOUT_PROXY_SETTLE_MS);
} catch (error) {
restoreFailed = true;
}
}
}
if (result && !proxyError) {
return result;
}
if (proxyError) {
if (restoreFailed) {
throw proxyError;
}
return action();
}
return null;
}
function normalizePlusPaymentMethod(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
@@ -589,6 +784,16 @@
return result || {};
}
async function submitHostedPayPalSecurityCode(tabId, config = {}, stepKey = PAYPAL_HOSTED_STEP_CREATE_ACCOUNT) {
const stepNumber = getHostedStepNumber(stepKey);
const verificationCode = await pollHostedVerificationCode(config.verificationUrl);
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:已获取 PayPal 手机验证码,正在填写。`, 'info');
return runHostedPayPalStep(tabId, {
expectedStage: PAYPAL_HOSTED_STAGE_SECURITY_CODE,
securityCode: verificationCode,
});
}
function getHostedStageOrder(stage = '') {
switch (stage) {
case PAYPAL_HOSTED_STAGE_LOGIN:
@@ -597,6 +802,8 @@
return 2;
case PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT:
return 3;
case PAYPAL_HOSTED_STAGE_SECURITY_CODE:
return 3.5;
case PAYPAL_HOSTED_STAGE_REVIEW:
return 4;
case PAYPAL_HOSTED_STAGE_OUTSIDE:
@@ -634,7 +841,7 @@
try {
const pageState = await getHostedPayPalState(tabId);
lastStage = pageState?.hostedStage || lastStage;
if (predicate(pageState)) {
if (await predicate(pageState)) {
return pageState;
}
} catch (error) {
@@ -934,6 +1141,19 @@
}
const pageState = await getHostedPayPalState(tabId);
const config = await getHostedCheckoutRuntimeConfig(state);
if (pageState.hostedStage === PAYPAL_HOSTED_STAGE_SECURITY_CODE) {
await submitHostedPayPalSecurityCode(tabId, config, stepKey);
const nextState = await waitForHostedPayPalStage(
tabId,
(stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_SECURITY_CODE,
{ label: `步骤 ${stepNumber}:等待 PayPal 验证码提交后跳转` }
);
await completeHostedStep(stepKey, tabId, {
plusHostedCheckoutLastStage: nextState.hostedStage || '',
});
return;
}
if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_REVIEW)
&& pageState.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) {
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),创建确认节点直接完成。`, 'info');
@@ -952,7 +1172,13 @@
});
const nextState = await waitForHostedPayPalStage(
tabId,
(stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT,
async (stateInfo) => {
if (stateInfo?.hostedStage === PAYPAL_HOSTED_STAGE_SECURITY_CODE) {
await submitHostedPayPalSecurityCode(tabId, config, stepKey);
return false;
}
return stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT;
},
{ label: `步骤 ${stepNumber}:等待 PayPal 创建确认页跳转` }
);
await completeHostedStep(stepKey, tabId, {
@@ -1392,7 +1618,9 @@
const paymentMethodLabel = getPlusPaymentMethodLabel(paymentMethod);
const checkoutModeLabel = getCheckoutModeLabel(state);
await addLog(`步骤 6:正在打开新的 ChatGPT 会话,准备创建${checkoutModeLabel}...`, 'info');
const tabId = await openFreshChatGptTabForCheckoutCreate();
let tabId = 0;
const createCheckout = async () => {
tabId = await openFreshChatGptTabForCheckoutCreate();
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
@@ -1402,11 +1630,15 @@
logMessage: '步骤 6:正在等待 ChatGPT 页面完成加载,再继续创建订阅页...',
});
const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
return sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
type: 'CREATE_PLUS_CHECKOUT',
source: 'background',
payload: { paymentMethod },
});
};
const result = paymentMethod === PLUS_PAYMENT_METHOD_PAYPAL_HOSTED
? await runWithLocalCheckoutCreationProxy(createCheckout)
: await createCheckout();
if (result?.error) {
throw new Error(result.error);
+101
View File
@@ -8,6 +8,7 @@ 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_CREATE_ACCOUNT = 'create_account';
const PAYPAL_HOSTED_STAGE_SECURITY_CODE = 'security_code';
const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent';
const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval';
const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown';
@@ -15,6 +16,7 @@ const PAYPAL_HOSTED_STEP_KEYS = {
[PAYPAL_HOSTED_STAGE_LOGIN]: 'paypal-hosted-email',
[PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT]: 'paypal-hosted-card',
[PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT]: 'paypal-hosted-create-account',
[PAYPAL_HOSTED_STAGE_SECURITY_CODE]: 'paypal-hosted-create-account',
[PAYPAL_HOSTED_STAGE_REVIEW]: 'paypal-hosted-review',
};
@@ -308,10 +310,63 @@ function findHostedReviewConsentButton() {
]);
}
function getHostedSecurityCodeInputs() {
const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
const pageLooksLikeSecurityCode = /enter\s+(?:your\s+)?code|6[-\s]*digit\s+code|security\s+code|verification\s+code|we\s+sent\s+a\s+6[-\s]*digit\s+code/i.test(pageText);
const visibleInputs = 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);
});
const candidates = visibleInputs
.filter((input) => {
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
const metadata = getActionText(input);
return maxLength === 1 || /otp|code|verification|security|one[-\s]*time/i.test(metadata);
});
if (candidates.length < 6 && pageLooksLikeSecurityCode && visibleInputs.length >= 6) {
return visibleInputs.slice(0, 6);
}
const singleDigitInputs = candidates.filter((input) => {
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
const valueLength = String(input.value || '').length;
return maxLength === 1 || valueLength <= 1;
});
return singleDigitInputs.length >= 6 ? singleDigitInputs.slice(0, 6) : candidates;
}
function getHostedSecurityCodeSingleInput() {
return getVisibleControls('input').find((input) => {
const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase();
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
const metadata = getActionText(input);
return isEnabledControl(input)
&& !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type)
&& maxLength >= 6
&& /otp|code|verification|security|one[-\s]*time/i.test(metadata);
}) || null;
}
function findHostedSecurityCodeSubmitButton() {
return findClickableByText([
/submit|continue|next|verify|confirm|done/i,
]);
}
function isHostedSecurityCodePage() {
const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
const hasSecurityText = /enter\s+(?:your\s+)?code|6[-\s]*digit\s+code|security\s+code|verification\s+code|we\s+sent\s+a\s+6[-\s]*digit\s+code/i.test(pageText);
return hasSecurityText && (getHostedSecurityCodeInputs().length >= 6 || Boolean(getHostedSecurityCodeSingleInput()));
}
function detectPayPalHostedStage() {
if (!/paypal\./i.test(String(location?.host || ''))) {
return PAYPAL_HOSTED_STAGE_OUTSIDE;
}
if (isHostedSecurityCodePage()) {
return PAYPAL_HOSTED_STAGE_SECURITY_CODE;
}
if (isHostedGuestCheckoutPage()) {
return PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT;
}
@@ -465,6 +520,48 @@ function verifyHostedPhoneBeforeSubmit(expectedPhone = '') {
};
}
function normalizeHostedSecurityCode(value = '') {
const code = String(value || '').replace(/\D/g, '').slice(0, 6);
return /^\d{6}$/.test(code) ? code : '';
}
async function submitHostedSecurityCode(payload = {}) {
await waitForDocumentComplete();
const code = normalizeHostedSecurityCode(payload.securityCode || payload.verificationCode || payload.code || '');
if (!code) {
throw new Error('PayPal hosted checkout 验证码为空或不是 6 位数字。');
}
const digitInputs = getHostedSecurityCodeInputs();
const singleInput = getHostedSecurityCodeSingleInput();
if (digitInputs.length >= 6) {
digitInputs.slice(0, 6).forEach((input, index) => {
fillInput(input, code[index]);
});
} else if (singleInput) {
fillInput(singleInput, code);
} else {
throw new Error('PayPal hosted checkout 未找到验证码输入框。');
}
const submitButton = findHostedSecurityCodeSubmitButton();
if (submitButton && isVisibleElement(submitButton) && isEnabledControl(submitButton)) {
await performPayPalOperationWithDelay({
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_SECURITY_CODE),
kind: 'click',
label: 'hosted-paypal-security-code-submit',
}, async () => {
simulateClick(submitButton);
});
}
await sleep(1000);
return {
stage: PAYPAL_HOSTED_STAGE_SECURITY_CODE,
securityCodeSubmitted: true,
submitted: Boolean(submitButton),
inputCount: digitInputs.length >= 6 ? 6 : 1,
};
}
async function clickHostedCreateAccount(payload = {}) {
await waitForDocumentComplete();
const button = await waitUntil(() => {
@@ -640,6 +737,9 @@ async function runPayPalHostedCheckoutStep(payload = {}) {
if (stage === PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) {
return clickHostedCreateAccount(payload);
}
if (stage === PAYPAL_HOSTED_STAGE_SECURITY_CODE) {
return submitHostedSecurityCode(payload);
}
if (stage === PAYPAL_HOSTED_STAGE_REVIEW) {
return clickHostedReviewConsent();
}
@@ -659,6 +759,7 @@ function inspectPayPalHostedState() {
hostedStage: stage,
hasGuestCardFields: Boolean(document.getElementById('cardNumber')),
hasHostedEmailInput: Boolean(document.getElementById('email') || findEmailInput()),
securityCodeVisible: stage === PAYPAL_HOSTED_STAGE_SECURITY_CODE,
createAccountReady: Boolean(createAccountButton && isVisibleElement(createAccountButton) && isEnabledControl(createAccountButton)),
reviewConsentReady: Boolean(findHostedReviewConsentButton()),
approveReady: Boolean(findApproveButton()),