feat(gopay): support GoPay Plus checkout flow

This commit is contained in:
朴圣佑
2026-05-01 02:02:33 +08:00
committed by QLHazyCoder
parent 2eb913e00b
commit d851cc4d36
24 changed files with 3798 additions and 162 deletions
+87 -5
View File
@@ -4,6 +4,7 @@ importScripts(
'managed-alias-utils.js',
'mail2925-utils.js',
'paypal-utils.js',
'gopay-utils.js',
'phone-sms/providers/hero-sms.js',
'phone-sms/providers/five-sim.js',
'phone-sms/providers/registry.js',
@@ -36,6 +37,7 @@ importScripts(
'background/steps/fill-plus-checkout.js',
'background/steps/gopay-manual-confirm.js',
'background/steps/paypal-approve.js',
'background/steps/gopay-approve.js',
'background/steps/plus-return-confirm.js',
'background/steps/oauth-login.js',
'background/steps/fetch-login-code.js',
@@ -347,6 +349,9 @@ const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const FIVE_SIM_COUNTRY_ID = 'england';
const FIVE_SIM_COUNTRY_LABEL = '英国 (England)';
const FIVE_SIM_OPERATOR = 'any';
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL;
const DISPLAY_TIMEZONE = 'Asia/Shanghai';
const MICROSOFT_TOKEN_DNR_RULE_ID = 1001;
const PERSISTENT_ALIAS_STATE_KEYS = [
@@ -516,10 +521,14 @@ const PERSISTED_SETTING_DEFAULTS = {
codex2apiAdminKey: '',
customPassword: '',
plusModeEnabled: false,
plusPaymentMethod: 'paypal',
plusPaymentMethod: DEFAULT_PLUS_PAYMENT_METHOD,
paypalEmail: '',
paypalPassword: '',
currentPayPalAccountId: '',
gopayCountryCode: '+86',
gopayPhone: '',
gopayOtp: '',
gopayPin: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
oauthFlowTimeoutEnabled: true,
@@ -645,6 +654,7 @@ const DEFAULT_STATE = {
plusBillingCountryText: '',
plusBillingAddress: null,
plusPaypalApprovedAt: null,
plusGoPayApprovedAt: null,
plusReturnUrl: '',
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: '',
@@ -962,6 +972,16 @@ function normalizePhoneSmsProvider(value = '') {
: PHONE_SMS_PROVIDER_HERO_SMS;
}
function normalizePlusPaymentMethod(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
return rootScope.GoPayUtils.normalizePlusPaymentMethod(value);
}
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: PLUS_PAYMENT_METHOD_PAYPAL;
}
function normalizeFiveSimCountryId(value, fallback = FIVE_SIM_COUNTRY_ID) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryId) {
@@ -1803,14 +1823,30 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '').trim();
case 'customPassword':
return String(value || '');
case 'plusPaymentMethod':
return normalizePlusPaymentMethod(value);
case 'paypalEmail':
return String(value || '').trim();
case 'paypalPassword':
return String(value || '');
case 'currentPayPalAccountId':
return String(value || '').trim();
case 'plusPaymentMethod':
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
case 'gopayCountryCode':
return self.GoPayUtils?.normalizeGoPayCountryCode
? self.GoPayUtils.normalizeGoPayCountryCode(value)
: String(value || '+86').trim();
case 'gopayPhone':
return self.GoPayUtils?.normalizeGoPayPhone
? self.GoPayUtils.normalizeGoPayPhone(value)
: String(value || '').trim();
case 'gopayOtp':
return self.GoPayUtils?.normalizeGoPayOtp
? self.GoPayUtils.normalizeGoPayOtp(value)
: String(value || '').trim().replace(/[^\d]/g, '');
case 'gopayPin':
return self.GoPayUtils?.normalizeGoPayPin
? self.GoPayUtils.normalizeGoPayPin(value)
: String(value || '');
case 'autoRunSkipFailures':
case 'oauthFlowTimeoutEnabled':
case 'autoRunDelayEnabled':
@@ -6697,6 +6733,11 @@ function isPlusCheckoutNonFreeTrialFailure(error) {
return /PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message);
}
function isGoPayCheckoutRestartRequiredFailure(error) {
const message = getErrorMessage(error);
return /GOPAY_RESTART_FROM_STEP6::|GOPAY_RETRY_REQUIRED::/i.test(message);
}
function isStep9RecoverableAuthError(error) {
const message = String(typeof error === 'string' ? error : error?.message || '');
return /STEP9_OAUTH_RETRY::/i.test(message)
@@ -6746,6 +6787,7 @@ function getDownstreamStateResets(step, state = {}) {
plusBillingCountryText: '',
plusBillingAddress: null,
plusPaypalApprovedAt: null,
plusGoPayApprovedAt: null,
plusReturnUrl: '',
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: '',
@@ -6828,6 +6870,7 @@ function getDownstreamStateResets(step, state = {}) {
plusBillingCountryText: '',
plusBillingAddress: null,
plusPaypalApprovedAt: null,
plusGoPayApprovedAt: null,
plusReturnUrl: '',
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: '',
@@ -6838,6 +6881,7 @@ function getDownstreamStateResets(step, state = {}) {
} : {}),
...(step === 8 ? {
plusPaypalApprovedAt: null,
plusGoPayApprovedAt: null,
plusReturnUrl: '',
} : {}),
lastLoginCode: null,
@@ -9202,6 +9246,7 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) {
async function runAutoSequenceFromStep(startStep, context = {}) {
const { targetRun, totalRuns, attemptRuns, continued = false } = context;
let postStep7RestartCount = 0;
let goPayCheckoutRestartCount = 0;
let step4RestartCount = 0;
let currentStartStep = startStep;
let continueCurrentAttempt = continued;
@@ -9270,6 +9315,23 @@ async function runAutoSequenceFromStep(startStep, context = {}) {
throw err;
}
if (step === 8 && isGoPayCheckoutRestartRequiredFailure(err)) {
goPayCheckoutRestartCount += 1;
if (goPayCheckoutRestartCount > 3) {
await addLog(`步骤 8GoPay Checkout 已连续重建 ${goPayCheckoutRestartCount - 1} 次仍失败,停止自动重试。原因:${getErrorMessage(err)}`, 'error');
throw err;
}
await addLog(
`步骤 8:检测到 GoPay 支付页失效/卡死,准备关闭旧页并回到步骤 6 重新创建 Checkout(第 ${goPayCheckoutRestartCount}/3 次)。原因:${getErrorMessage(err)}`,
'warn'
);
await invalidateDownstreamAfterStepRestart(5, {
logLabel: `步骤 8 GoPay 支付页失效后准备回到步骤 6 重试(第 ${goPayCheckoutRestartCount}/3 次)`,
});
step = 6;
continue;
}
if (step === 4) {
if (isSignupUserAlreadyExistsFailure(err)) {
throw err;
@@ -9690,6 +9752,7 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c
sendTabMessageUntilStopped,
setState,
sleepWithStop,
throwIfStopped,
waitForTabCompleteUntilStopped,
});
const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?.createPlusCheckoutBillingExecutor({
@@ -9731,6 +9794,24 @@ const payPalApproveExecutor = self.MultiPageBackgroundPayPalApprove?.createPayPa
waitForTabCompleteUntilStopped,
waitForTabUrlMatchUntilStopped,
});
const goPayApproveExecutor = self.MultiPageBackgroundGoPayApprove?.createGoPayApproveExecutor({
addLog,
chrome,
completeStepFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
getTabId,
isTabAlive,
registerTab,
sendTabMessageUntilStopped,
setState,
sleepWithStop,
waitForTabCompleteUntilStopped,
clickWithDebugger,
requestGoPayOtpInput: (payload = {}) => chrome.runtime.sendMessage({
type: 'REQUEST_GOPAY_OTP_INPUT',
payload,
}),
});
const plusReturnConfirmExecutor = self.MultiPageBackgroundPlusReturnConfirm?.createPlusReturnConfirmExecutor({
addLog,
completeStepFromBackground,
@@ -9768,8 +9849,9 @@ const stepExecutorsByKey = {
'clear-login-cookies': () => step6Executor.executeStep6(),
'plus-checkout-create': (state) => plusCheckoutCreateExecutor.executePlusCheckoutCreate(state),
'plus-checkout-billing': (state) => plusCheckoutBillingExecutor.executePlusCheckoutBilling(state),
'gopay-subscription-confirm': (state) => goPayManualConfirmExecutor.executeGoPayManualConfirm(state),
'paypal-approve': (state) => payPalApproveExecutor.executePayPalApprove(state),
'paypal-approve': (state) => normalizePlusPaymentMethod(state?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GOPAY
? goPayApproveExecutor.executeGoPayApprove(state)
: payPalApproveExecutor.executePayPalApprove(state),
'plus-checkout-return': (state) => plusReturnConfirmExecutor.executePlusReturnConfirm(state),
'oauth-login': (state) => step7Executor.executeStep7(state),
'fetch-login-code': (state) => step8Executor.executeStep8(state),
+14 -6
View File
@@ -4,6 +4,7 @@
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/';
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
function createPlusCheckoutCreateExecutor(deps = {}) {
const {
@@ -40,9 +41,18 @@
return tabId;
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY ? 'gopay' : 'paypal';
}
function getPlusPaymentMethodLabel(method = 'paypal') {
return normalizePlusPaymentMethod(method) === PLUS_PAYMENT_METHOD_GOPAY ? 'GoPay' : 'PayPal';
}
async function executePlusCheckoutCreate(state = {}) {
const checkoutModeLabel = getCheckoutModeLabel(state);
await addLog(`步骤 6:正在打开新的 ChatGPT 会话,准备创建${checkoutModeLabel}...`, 'info');
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
const paymentMethodLabel = getPlusPaymentMethodLabel(paymentMethod);
await addLog('步骤 6:正在新打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info');
const tabId = await openFreshChatGptTabForCheckoutCreate();
await waitForTabCompleteUntilStopped(tabId);
@@ -56,9 +66,7 @@
const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
type: 'CREATE_PLUS_CHECKOUT',
source: 'background',
payload: {
paymentMethod: normalizePlusPaymentMethod(state?.plusPaymentMethod),
},
payload: { paymentMethod },
});
if (result?.error) {
@@ -85,7 +93,7 @@
plusCheckoutCurrency: result.currency || 'EUR',
});
await addLog(`步骤 6${checkoutModeLabel}已就绪`, 'info');
await addLog(`步骤 6Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步`, 'info');
await completeStepFromBackground(6, {
plusCheckoutCountry: result.country || 'DE',
+72 -28
View File
@@ -7,6 +7,22 @@
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3;
const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000;
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PAYMENT_METHOD_CONFIGS = {
[PLUS_PAYMENT_METHOD_PAYPAL]: {
id: PLUS_PAYMENT_METHOD_PAYPAL,
label: 'PayPal',
selectMessageType: 'PLUS_CHECKOUT_SELECT_PAYPAL',
redirectPattern: /paypal\./i,
},
[PLUS_PAYMENT_METHOD_GOPAY]: {
id: PLUS_PAYMENT_METHOD_GOPAY,
label: 'GoPay',
selectMessageType: 'PLUS_CHECKOUT_SELECT_GOPAY',
redirectPattern: /gopay|gojek|midtrans|xendit|stripe|checkout/i,
},
};
const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz';
const MEIGUODIZHI_COUNTRY_CONFIG = {
AR: { path: '/ar-address', city: 'Buenos Aires', aliases: ['ar', 'argentina', '阿根廷'] },
@@ -18,6 +34,7 @@
FR: { path: '/fr-address', city: 'Paris', aliases: ['fr', 'fra', 'france', '法国'] },
GB: { path: '/uk-address', city: 'London', aliases: ['gb', 'uk', 'united kingdom', 'britain', 'england', '英国'] },
HK: { path: '/hk-address', city: 'Hong Kong', aliases: ['hk', 'hong kong', '香港'] },
ID: { path: '/id-address', city: 'Jakarta', aliases: ['id', 'indonesia', '印度尼西亚', '印尼'] },
IT: { path: '/it-address', city: 'Rome', aliases: ['it', 'ita', 'italy', '意大利'] },
JP: { path: '/jp-address', city: 'Tokyo', aliases: ['jp', 'jpn', 'japan', '日本', '日本国'] },
KR: { path: '/kr-address', city: 'Seoul', aliases: ['kr', 'kor', 'korea', 'south korea', '韩国'] },
@@ -62,6 +79,16 @@
return normalizeText(value).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: PLUS_PAYMENT_METHOD_PAYPAL;
}
function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) {
return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL];
}
function resolveMeiguodizhiCountryCode(value = '') {
const normalized = normalizeText(value);
const upper = normalized.toUpperCase();
@@ -73,7 +100,7 @@
compact === code.toLowerCase()
|| (config.aliases || []).some((alias) => {
const compactAlias = compactCountryText(alias);
return compact === compactAlias || compact.includes(compactAlias);
return compact === compactAlias || (compactAlias.length >= 4 && compact.includes(compactAlias));
})
));
return match?.[0] || '';
@@ -170,9 +197,11 @@
};
}
async function resolveBillingAddressSeed(state = {}, countryOverride = '') {
const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE');
const countryCode = resolveMeiguodizhiCountryCode(requestedCountry) || 'DE';
async function resolveBillingAddressSeed(state = {}, countryOverride = '', options = {}) {
const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod || state?.plusPaymentMethod);
const forcedCountry = paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'ID' : '';
const requestedCountry = normalizeText(forcedCountry || countryOverride || state.plusCheckoutCountry || 'DE');
const countryCode = forcedCountry || resolveMeiguodizhiCountryCode(requestedCountry) || 'DE';
const localSeed = getLocalAddressSeed(countryCode);
const lookupSeed = localSeed || buildMeiguodizhiLookupSeed(countryCode);
if (!lookupSeed) {
@@ -296,21 +325,22 @@
});
}
async function waitForPayPalRedirectAfterSubmit(tabId) {
async function waitForPaymentRedirectAfterSubmit(tabId, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const paymentConfig = getPaymentMethodConfig(paymentMethod);
const startedAt = Date.now();
while (Date.now() - startedAt < PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS) {
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab) {
throw new Error('步骤 7:checkout 标签页已关闭,无法继续等待 PayPal 跳转。');
throw new Error(`步骤 7:checkout 标签页已关闭,无法继续等待 ${paymentConfig.label} 跳转。`);
}
const url = String(tab.url || '');
if (/paypal\./i.test(url)) {
if (paymentConfig.redirectPattern.test(url) && !isPlusCheckoutUrl(url)) {
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
return true;
}
if (url && !isPlusCheckoutUrl(url)) {
await addLog(`步骤 7:点击订阅后页面跳转到非 PayPal 地址:${url}`, 'warn');
await addLog(`步骤 7:点击订阅后页面跳转到非 ${paymentConfig.label} 识别地址:${url}`, 'warn');
return false;
}
await sleepWithStop(500);
@@ -318,6 +348,10 @@
return false;
}
async function waitForPayPalRedirectAfterSubmit(tabId) {
return waitForPaymentRedirectAfterSubmit(tabId, PLUS_PAYMENT_METHOD_PAYPAL);
}
async function inspectCheckoutFrame(tabId, frame) {
try {
const result = await sendFrameMessage(tabId, frame.frameId, {
@@ -353,6 +387,7 @@
.map((item) => {
const flags = [];
if (item.result?.hasPayPal) flags.push('paypal');
if (item.result?.hasGoPay) flags.push('gopay');
if (item.result?.billingFieldsVisible) flags.push('billing');
if (item.result?.hasSubscribeButton) flags.push('subscribe');
if (!flags.length && item.error) flags.push(item.error);
@@ -372,7 +407,13 @@
return inspections;
}
function pickPaymentFrame(inspections) {
function pickPaymentFrame(inspections, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const normalizedPaymentMethod = normalizePlusPaymentMethod(paymentMethod);
if (normalizedPaymentMethod === PLUS_PAYMENT_METHOD_GOPAY) {
return inspections.find((item) => item.result?.hasGoPay || item.result?.gopayCandidates?.length)
|| inspections.find((item) => isPaymentFrameUrl(item.frame.url))
|| null;
}
return inspections.find((item) => item.result?.hasPayPal || item.result?.paypalCandidates?.length)
|| inspections.find((item) => isPaymentFrameUrl(item.frame.url))
|| null;
@@ -418,14 +459,14 @@
const amountLabel = amountSummary.rawAmount || (
Number.isFinite(Number(amountSummary.amount)) ? String(amountSummary.amount) : '未知金额'
);
await addLog(`步骤 7${phaseLabel}检测到今日应付金额不是 0${amountLabel}),说明当前账号没有免费试用资格,将跳过 PayPal 提交。`, 'warn');
await addLog(`步骤 7${phaseLabel}检测到今日应付金额不是 0${amountLabel}),说明当前账号没有免费试用资格,将跳过支付提交。`, 'warn');
if (typeof markCurrentRegistrationAccountUsed === 'function') {
await markCurrentRegistrationAccountUsed(state, {
reason: 'plus-checkout-non-free-trial',
logPrefix: 'Plus Checkout:当前账号没有免费试用资格',
});
}
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(${amountLabel}),当前账号没有免费试用资格,已跳过 PayPal 提交。`);
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(${amountLabel}),当前账号没有免费试用资格,已跳过支付提交。`);
}
async function getReadyCheckoutFrames(tabId) {
@@ -445,9 +486,9 @@
};
}
async function resolvePaymentFrame(tabId, frames) {
async function resolvePaymentFrame(tabId, frames, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const inspections = await inspectCheckoutFrames(tabId, frames);
const picked = pickPaymentFrame(inspections);
const picked = pickPaymentFrame(inspections, paymentMethod);
if (picked) {
return {
frameId: picked.frame.frameId,
@@ -519,6 +560,8 @@
}
async function executePlusCheckoutBilling(state = {}) {
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
const paymentConfig = getPaymentMethodConfig(paymentMethod);
const tabId = await getCheckoutTabId(state);
await addLog('步骤 7:正在等待 Plus Checkout 页面加载完成...', 'info');
await waitForTabCompleteUntilStopped(tabId);
@@ -533,27 +576,27 @@
await ensureFreeTrialAmount(tabId, state, {
phaseLabel: 'Checkout 页面加载后',
});
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames);
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames, paymentMethod);
if (paymentFrame.frameId === null) {
const frameSummary = buildFrameSummary(paymentFrame.inspections);
throw new Error(`步骤 7:未在主页面或 iframe 中发现 PayPal DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`);
throw new Error(`步骤 7:未在主页面或 iframe 中发现 ${paymentConfig.label} DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`);
}
if (!paymentFrame.ready) {
throw new Error(`步骤 7:已定位到 PayPal 所在 iframeframeId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`);
throw new Error(`步骤 7:已定位到 ${paymentConfig.label} 所在 iframeframeId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`);
}
if (paymentFrame.frameId !== 0) {
await addLog(`步骤 7PayPal 位于 checkout iframeframeId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info');
await addLog(`步骤 7${paymentConfig.label} 位于 checkout iframeframeId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info');
}
const randomName = generateRandomName();
const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' ');
await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info');
await addLog(`步骤 7:正在切换 ${paymentConfig.label} 付款方式...`, 'info');
const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, {
type: 'PLUS_CHECKOUT_SELECT_PAYPAL',
type: paymentConfig.selectMessageType,
source: 'background',
payload: {},
payload: { paymentMethod },
});
if (paymentResult?.error) {
throw new Error(paymentResult.error);
@@ -567,7 +610,7 @@
await addLog(`步骤 7:账单地址位于 checkout iframeframeId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info');
}
const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText);
const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText, { paymentMethod });
if (!addressSeed) {
throw new Error('步骤 7:未找到可用的本地账单地址种子。');
}
@@ -645,7 +688,7 @@
phaseLabel: '提交订阅前',
});
let redirectedToPayPal = false;
let redirectedToPayment = false;
let lastSubmitError = '';
for (let attempt = 1; attempt <= PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
await addLog(
@@ -666,6 +709,7 @@
source: 'background',
payload: {
beforeClickDelayMs: attempt === 1 ? 700 : 1200,
paymentMethod,
},
});
if (subscribeResult?.error) {
@@ -674,17 +718,17 @@
continue;
}
await addLog(`步骤 7:账单地址已提交,正在等待跳转到 PayPal${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}...`, 'info');
redirectedToPayPal = await waitForPayPalRedirectAfterSubmit(tabId);
if (redirectedToPayPal) {
await addLog(`步骤 7:账单地址已提交,正在等待跳转到 ${paymentConfig.label}${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}...`, 'info');
redirectedToPayment = await waitForPaymentRedirectAfterSubmit(tabId, paymentMethod);
if (redirectedToPayment) {
break;
}
lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 PayPal`;
lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 ${paymentConfig.label}`;
await addLog(`步骤 7${lastSubmitError},将重试提交。`, 'warn');
}
if (!redirectedToPayPal) {
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 PayPal${lastSubmitError}`);
if (!redirectedToPayment) {
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}${lastSubmitError}`);
}
await completeStepFromBackground(7, {
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -2,6 +2,7 @@
root.MultiPageBackgroundPlusReturnConfirm = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusReturnConfirmModule() {
const PAYPAL_SOURCE = 'paypal-flow';
const GOPAY_SOURCE = 'gopay-flow';
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_RETURN_SETTLE_WAIT_MS = 20000;
@@ -21,6 +22,10 @@
if (paypalTabId && await isTabAlive(PAYPAL_SOURCE)) {
return paypalTabId;
}
const gopayTabId = await getTabId(GOPAY_SOURCE);
if (gopayTabId && await isTabAlive(GOPAY_SOURCE)) {
return gopayTabId;
}
const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE);
if (checkoutTabId) {
return checkoutTabId;
@@ -29,17 +34,17 @@
if (storedTabId) {
return storedTabId;
}
throw new Error('步骤 9:未找到 Plus / PayPal 标签页,无法确认订阅回跳。');
throw new Error('步骤 9:未找到 Plus / PayPal / GoPay 标签页,无法确认订阅回跳。');
}
function isReturnUrl(url = '') {
return /https:\/\/(?:chatgpt\.com|chat\.openai\.com|openai\.com)\//i.test(String(url || ''))
&& !/paypal\./i.test(String(url || ''));
&& !/paypal\.|gopay|gojek|midtrans|xendit|stripe/i.test(String(url || ''));
}
async function executePlusReturnConfirm(state = {}) {
const tabId = await resolveReturnTabId(state);
await addLog('步骤 9:正在等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面...', 'info');
await addLog('步骤 9:正在等待支付授权后回跳到 ChatGPT / OpenAI 页面...', 'info');
const tab = await waitForTabUrlMatchUntilStopped(tabId, isReturnUrl);
await addLog('步骤 9:已检测到订阅回跳页面,固定等待 20 秒让页面完成加载。', 'info');
await sleepWithStop(PLUS_RETURN_SETTLE_WAIT_MS);
+725
View File
@@ -0,0 +1,725 @@
// content/gopay-flow.js — GoPay authorization helper.
console.log('[MultiPage:gopay-flow] Content script loaded on', location.href);
const GOPAY_FLOW_LISTENER_SENTINEL = 'data-multipage-gopay-flow-listener';
if (document.documentElement.getAttribute(GOPAY_FLOW_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(GOPAY_FLOW_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'GOPAY_GET_STATE'
|| message.type === 'GOPAY_SUBMIT_PHONE'
|| message.type === 'GOPAY_SUBMIT_OTP'
|| message.type === 'GOPAY_SUBMIT_PIN'
|| message.type === 'GOPAY_CLICK_CONTINUE'
|| message.type === 'GOPAY_GET_CONTINUE_TARGET'
|| message.type === 'GOPAY_CLICK_PAY_NOW'
|| message.type === 'GOPAY_GET_PAY_NOW_TARGET'
) {
resetStopState();
handleGoPayCommand(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:gopay-flow] 消息监听已存在,跳过重复注册');
}
async function handleGoPayCommand(message) {
switch (message.type) {
case 'GOPAY_GET_STATE':
return inspectGoPayState();
case 'GOPAY_SUBMIT_PHONE':
return submitGoPayPhone(message.payload || {});
case 'GOPAY_SUBMIT_OTP':
return submitGoPayOtp(message.payload || {});
case 'GOPAY_SUBMIT_PIN':
return submitGoPayPin(message.payload || {});
case 'GOPAY_CLICK_CONTINUE':
return clickGoPayContinue();
case 'GOPAY_GET_CONTINUE_TARGET':
return getGoPayContinueTarget();
case 'GOPAY_CLICK_PAY_NOW':
return clickGoPayPayNow();
case 'GOPAY_GET_PAY_NOW_TARGET':
return getGoPayPayNowTarget();
default:
throw new Error(`gopay-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 || `${options.label || 'GoPay 页面状态'}等待超时`);
}
await sleep(intervalMs);
}
}
async function waitForDocumentComplete() {
await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 });
await sleep(800);
}
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 getVisibleTextInputs() {
return getVisibleControls('input, textarea')
.filter((input) => {
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
});
}
function findInputByPatterns(patterns) {
return getVisibleTextInputs().find((input) => {
const text = getActionText(input);
return patterns.some((pattern) => pattern.test(text));
}) || null;
}
function findPhoneInput() {
const input = findInputByPatterns([
/gopay|go\s*pay|phone|mobile|whatsapp|wa|nomor|ponsel|telepon|hp/i,
/手机|手机号|电话号码|电话/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
return getVisibleControls('input[type="tel"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate)) || null;
}
function isCountrySearchInput(input) {
const text = getActionText(input);
return /country|country\s*name|country\s*code|国家|地区|区号/i.test(text);
}
function getPageBodyText() {
return normalizeText(document.body?.innerText || document.body?.textContent || '');
}
function isGoPayOtpPageText() {
if (isGoPayPinPageText()) {
return false;
}
const text = getPageBodyText();
return /otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text);
}
function isGoPayPinPageText() {
const text = getPageBodyText();
return /pin|password|passcode|security|sandi|6\s*digit/i.test(text)
|| /pin-web-client\.gopayapi\.com|\/auth\/pin/i.test(location.href || '');
}
function detectGoPayTerminalError(text = getPageBodyText()) {
const normalizedText = normalizeText(text);
if (!normalizedText) return null;
if (/waktunya\s+habis|ulang(?:i)?\s+prosesnya\s+dari\s+awal|time(?:'s|\s+is)?\s+(?:out|expired)|session\s+expired|expired|kedaluwarsa/i.test(normalizedText)) {
return {
code: 'expired',
message: 'GoPay 支付会话已超时,需要重新创建 Plus Checkout。',
rawText: normalizedText.slice(0, 240),
};
}
if (/technical\s+error|don[']t\s+worry|try\s+again|terjadi\s+kesalahan|error\s+teknis/i.test(normalizedText)) {
return {
code: 'technical-error',
message: 'GoPay 页面显示技术错误,需要重新发起支付授权。',
rawText: normalizedText.slice(0, 240),
};
}
if (/payment\s+failed|pembayaran\s+gagal|transaksi\s+gagal|ditolak|declined|failed/i.test(normalizedText)) {
return {
code: 'failed',
message: 'GoPay 页面显示支付失败,需要重新发起支付授权。',
rawText: normalizedText.slice(0, 240),
};
}
return null;
}
function findOtpInput() {
const input = findInputByPatterns([
/otp|one[-\s]*time|verification|verify|code|kode|whatsapp|wa|pin-input-field/i,
/验证码|短信|代码/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
if (isGoPayOtpPageText()) {
return getVisibleTextInputs().find((candidate) => !isCountrySearchInput(candidate)) || null;
}
return null;
}
function getGoPayPinInputs() {
return getVisibleTextInputs().filter((candidate) => {
if (isCountrySearchInput(candidate)) return false;
const text = getCombinedElementText(candidate);
const maxLength = Number(candidate.getAttribute?.('maxlength') || candidate.maxLength || 0);
return /pin|password|passcode|security|sandi|pin-input/i.test(text)
|| candidate.getAttribute?.('data-testid')?.startsWith?.('pin-input')
|| (isGoPayPinPageText() && maxLength === 1);
});
}
function findPinInput() {
if (isGoPayOtpPageText()) {
return null;
}
const input = findInputByPatterns([
/pin|password|passcode|security|sandi|pin-input/i,
/密码|支付密码/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
return getGoPayPinInputs()[0]
|| getVisibleControls('input[type="password"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate))
|| null;
}
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) => {
if (!isEnabledControl(el)) return false;
const text = getActionText(el);
return normalizedPatterns.some((pattern) => pattern.test(text));
}) || null;
}
function findPayNowButton() {
return findClickableByText([
/^\s*pay\s+now\s*$/i,
/^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i,
/^\s*支付\s*$/i,
/^\s*立即支付\s*$/i,
]);
}
function findContinueButton() {
return findClickableByText([
/continue|next|submit|verify|confirm|pay|authorize|allow|lanjut|berikut|kirim|bayar|konfirmasi|link/i,
/继续|下一步|提交|验证|确认|支付|授权|绑定|关联/i,
]);
}
function describeElement(el) {
if (!el) return '';
const rect = el.getBoundingClientRect?.();
const parts = [String(el.tagName || '').toUpperCase()];
if (el.id) parts.push(`#${el.id}`);
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class');
if (className) parts.push(`.${String(className).trim().replace(/\s+/g, '.')}`);
const text = getActionText(el) || normalizeText(el.innerText || el.textContent || '');
if (text) parts.push(`"${text.slice(0, 60)}"`);
if (rect) parts.push(`@${Math.round(rect.left)},${Math.round(rect.top)} ${Math.round(rect.width)}x${Math.round(rect.height)}`);
return parts.filter(Boolean).join(' ');
}
function dispatchPointerMouseSequence(target) {
const rect = target.getBoundingClientRect?.();
const clientX = rect ? Math.round(rect.left + rect.width / 2) : 0;
const clientY = rect ? Math.round(rect.top + rect.height / 2) : 0;
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
view: window,
detail: 1,
button: 0,
buttons: 1,
clientX,
clientY,
screenX: window.screenX + clientX,
screenY: window.screenY + clientY,
};
if (typeof PointerEvent === 'function') {
target.dispatchEvent(new PointerEvent('pointerover', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
target.dispatchEvent(new PointerEvent('pointerenter', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true, bubbles: false }));
target.dispatchEvent(new PointerEvent('pointermove', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
target.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
}
target.dispatchEvent(new MouseEvent('mouseover', eventInit));
target.dispatchEvent(new MouseEvent('mouseenter', { ...eventInit, bubbles: false }));
target.dispatchEvent(new MouseEvent('mousemove', eventInit));
target.dispatchEvent(new MouseEvent('mousedown', eventInit));
if (typeof PointerEvent === 'function') {
target.dispatchEvent(new PointerEvent('pointerup', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 }));
}
target.dispatchEvent(new MouseEvent('mouseup', { ...eventInit, buttons: 0 }));
target.dispatchEvent(new MouseEvent('click', { ...eventInit, buttons: 0 }));
}
async function humanClickElement(el, options = {}) {
if (!el) {
throw new Error('GoPay 页面未找到可点击元素。');
}
el.scrollIntoView?.({ block: 'center', inline: 'center' });
await sleep(Math.max(0, Number(options.beforeMs) || 120));
try {
el.focus?.({ preventScroll: true });
} catch (_) {
try { el.focus?.(); } catch (__) {}
}
dispatchPointerMouseSequence(el);
await sleep(Math.max(0, Number(options.afterDispatchMs) || 120));
if (typeof el.click === 'function') {
el.click();
}
await sleep(Math.max(0, Number(options.afterMs) || 1000));
}
async function clickContinueIfPresent(options = {}) {
const button = findContinueButton();
if (!button) {
return { clicked: false, target: '' };
}
await humanClickElement(button, options);
return { clicked: true, target: describeElement(button) };
}
function normalizePhoneNumber(value = '') {
return String(value || '').trim().replace(/[^\d+]/g, '');
}
function normalizeGoPayCountryCode(value = '') {
const normalized = String(value || '').trim().replace(/[^\d+]/g, '');
const digits = normalized.replace(/\D/g, '');
return digits ? `+${digits}` : '+86';
}
function getCountryCodeDigits(value = '') {
return normalizeGoPayCountryCode(value).replace(/\D/g, '');
}
function normalizeGoPayNationalPhone(value = '', countryCode = '+86') {
const countryDigits = getCountryCodeDigits(countryCode);
let digits = normalizePhoneNumber(value).replace(/\D/g, '');
if (countryDigits && digits.startsWith(countryDigits)) {
digits = digits.slice(countryDigits.length);
}
return digits;
}
function getCombinedElementText(el) {
return normalizeText([
getActionText(el),
el?.innerText,
el?.textContent,
typeof el?.className === 'string' ? el.className : el?.getAttribute?.('class'),
].filter(Boolean).join(' '));
}
function robustClick(el) {
if (!el) return;
el.scrollIntoView?.({ block: 'center', inline: 'center' });
try {
el.focus?.();
} catch (_) {}
const eventInit = { bubbles: true, cancelable: true, view: window };
if (typeof PointerEvent === 'function') {
el.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
}
el.dispatchEvent(new MouseEvent('mousedown', eventInit));
if (typeof PointerEvent === 'function') {
el.dispatchEvent(new PointerEvent('pointerup', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
}
el.dispatchEvent(new MouseEvent('mouseup', eventInit));
el.dispatchEvent(new MouseEvent('click', eventInit));
if (typeof el.click === 'function') {
el.click();
}
}
function readSelectedCountryCodeText() {
const candidates = getVisibleControls('.phone-code, .phone-code-wrapper, [class*="phone-code"], button, [role="button"], [tabindex]');
for (const candidate of candidates) {
const match = getCombinedElementText(candidate).match(/\+\d{1,4}/);
if (match) return normalizeGoPayCountryCode(match[0]);
}
const bodyMatch = normalizeText(document.body?.innerText || document.body?.textContent || '').match(/Phone number:\s*(\+\d{1,4})/i);
return bodyMatch ? normalizeGoPayCountryCode(bodyMatch[1]) : '';
}
function findCountryCodeToggle() {
const preferred = getVisibleControls('.phone-code-wrapper, [class*="phone-code-wrapper"], .phone-code, [class*="phone-code"]')
.find((el) => /\+\d{1,4}/.test(getCombinedElementText(el)));
if (preferred) return preferred;
return getVisibleControls('button, [role="button"], [tabindex], div, span')
.find((el) => /\+\d{1,4}/.test(getCombinedElementText(el)) && /phone|code|country|\+\d{1,4}/i.test(getCombinedElementText(el))) || null;
}
function findCountryCodeOption(countryCode = '+86') {
const normalized = normalizeGoPayCountryCode(countryCode);
const digits = getCountryCodeDigits(normalized);
const countryAliases = {
'1': [/United States|USA|Canada|美国|加拿大/i],
'60': [/Malaysia|马来西亚/i],
'62': [/Indonesia|印尼|印度尼西亚/i],
'63': [/Philippines|菲律宾/i],
'65': [/Singapore|新加坡/i],
'66': [/Thailand|泰国/i],
'84': [/Vietnam|越南/i],
'86': [/China|中国|Mainland/i],
'91': [/India|印度/i],
'852': [/Hong Kong|香港/i],
'853': [/Macau|Macao|澳门/i],
'886': [/Taiwan|台湾/i],
}[digits] || [];
const controls = getVisibleControls('li.country-item, .country-item, [class*="country-item"], [role="option"], li, button, [role="button"], a, [tabindex], div, span')
.map((el) => el.closest?.('.country-item') || el)
.filter((el, index, list) => el && list.indexOf(el) === index)
.filter((el) => {
const rect = el.getBoundingClientRect();
const text = getCombinedElementText(el);
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '';
return text.includes(normalized)
&& rect.width > 20
&& rect.height > 8
&& rect.width < Math.max(480, window.innerWidth * 0.8)
&& rect.height < Math.max(120, window.innerHeight * 0.35)
&& !/phone-number-input-wrapper|gopay-tokenization-content|asphalt-theme|search-country|country-list/i.test(className);
});
const matchesAlias = (el) => {
const text = getCombinedElementText(el);
return countryAliases.some((pattern) => pattern.test(text));
};
return controls.find((el) => /country-item/i.test(typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '') && matchesAlias(el))
|| controls.find((el) => String(el.tagName || '').toUpperCase() === 'LI' && matchesAlias(el))
|| controls.find((el) => el.getAttribute?.('role') === 'option' && matchesAlias(el))
|| controls.find(matchesAlias)
|| null;
}
async function ensureGoPayCountryCode(countryCode = '+86') {
const normalized = normalizeGoPayCountryCode(countryCode);
const selected = readSelectedCountryCodeText();
if (selected === normalized) {
return { changed: false, countryCode: normalized, selected };
}
const toggle = findCountryCodeToggle();
if (!toggle) {
throw new Error(`GoPay 页面未找到国家区号切换控件,当前识别区号:${selected || '未知'},目标区号:${normalized}`);
}
robustClick(toggle);
await sleep(500);
const countryDropdown = document.querySelector('.search-country');
if (countryDropdown && window.getComputedStyle(countryDropdown).display === 'none') {
countryDropdown.style.display = 'block';
await sleep(100);
}
const countrySearchInput = getVisibleTextInputs().find(isCountrySearchInput);
if (countrySearchInput) {
fillInput(countrySearchInput, normalized);
await sleep(300);
}
const option = await waitUntil(() => findCountryCodeOption(normalized), {
label: `GoPay 国家区号 ${normalized}`,
intervalMs: 250,
timeoutMs: 8000,
});
robustClick(option);
await sleep(500);
const nextSelected = readSelectedCountryCodeText();
if (nextSelected === normalized && countryDropdown) {
countryDropdown.style.display = 'none';
}
if (nextSelected !== normalized) {
throw new Error(`GoPay 国家区号切换失败:目标 ${normalized},当前 ${nextSelected || '未知'}`);
}
return {
changed: true,
countryCode: normalized,
selected: nextSelected,
};
}
function normalizeOtp(value = '') {
return String(value || '').trim().replace(/[^\d]/g, '');
}
function fillDigitInputs(inputs = [], code = '') {
const normalizedCode = normalizeOtp(code);
if (!normalizedCode || !inputs.length) return false;
normalizedCode.split('').forEach((digit, index) => {
const input = inputs[index];
if (!input) return;
try {
input.focus?.();
} catch (_) {}
input.dispatchEvent(new KeyboardEvent('keydown', { key: digit, code: `Digit${digit}`, bubbles: true, cancelable: true }));
fillInput(input, digit);
input.dispatchEvent(new KeyboardEvent('keyup', { key: digit, code: `Digit${digit}`, bubbles: true, cancelable: true }));
});
return true;
}
function fillVisiblePinInputs(pin = '') {
const normalizedPin = normalizeOtp(pin);
if (!normalizedPin) return false;
const pinInputs = getGoPayPinInputs();
const digitInputs = pinInputs.filter((input) => {
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
return maxLength > 0 && maxLength <= 1;
});
if (digitInputs.length >= Math.min(4, normalizedPin.length)) {
return fillDigitInputs(digitInputs, normalizedPin);
}
const input = findPinInput() || pinInputs[0];
if (!input) return false;
fillInput(input, normalizedPin);
return true;
}
function fillVisibleOtpInputs(code = '') {
const normalizedCode = normalizeOtp(code);
if (!normalizedCode) return false;
const otpInputs = getVisibleTextInputs()
.filter((input) => {
const text = getActionText(input);
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
return /otp|code|kode|verification|验证码|短信/i.test(text)
|| (maxLength > 0 && maxLength <= 1)
|| (maxLength > 1 && maxLength <= 8);
});
const digitInputs = otpInputs.filter((input) => {
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
return maxLength > 0 && maxLength <= 1;
});
if (digitInputs.length >= Math.min(4, normalizedCode.length)) {
return fillDigitInputs(digitInputs, normalizedCode);
}
const input = findOtpInput() || otpInputs[0];
if (!input) return false;
fillInput(input, normalizedCode);
return true;
}
async function submitGoPayPhone(payload = {}) {
await waitForDocumentComplete();
const countryCode = normalizeGoPayCountryCode(payload.countryCode || payload.gopayCountryCode || '+86');
const phone = normalizeGoPayNationalPhone(payload.phone || payload.gopayPhone || '', countryCode);
if (!phone) {
throw new Error('GoPay 手机号为空,请先在侧边栏配置。');
}
const countryResult = await ensureGoPayCountryCode(countryCode);
const input = await waitUntil(() => findPhoneInput(), {
label: 'GoPay 手机号输入框',
intervalMs: 250,
timeoutMs: 15000,
});
fillInput(input, phone);
const clickResult = await clickContinueIfPresent();
return {
phoneSubmitted: true,
countryCode,
countryChanged: Boolean(countryResult.changed),
clicked: Boolean(clickResult.clicked),
clickTarget: clickResult.target || '',
phase: 'phone_submitted',
};
}
async function submitGoPayOtp(payload = {}) {
await waitForDocumentComplete();
const code = normalizeOtp(payload.code || payload.otp || '');
if (!code) {
throw new Error('GoPay WhatsApp 验证码为空。');
}
const filled = await waitUntil(() => fillVisibleOtpInputs(code), {
label: 'GoPay 验证码输入框',
intervalMs: 250,
timeoutMs: 15000,
});
const clickResult = await clickContinueIfPresent();
return {
otpSubmitted: Boolean(filled),
clicked: Boolean(clickResult.clicked),
clickTarget: clickResult.target || '',
phase: 'otp_submitted',
};
}
async function submitGoPayPin(payload = {}) {
await waitForDocumentComplete();
const pin = normalizeOtp(payload.pin || payload.gopayPin || '');
if (!pin) {
throw new Error('GoPay PIN 为空,请先在侧边栏配置。');
}
const filled = await waitUntil(() => fillVisiblePinInputs(pin), {
label: 'GoPay PIN 输入框',
intervalMs: 250,
timeoutMs: 15000,
});
const clickResult = await clickContinueIfPresent();
return {
pinSubmitted: Boolean(filled),
clicked: Boolean(clickResult.clicked),
clickTarget: clickResult.target || '',
phase: 'pin_submitted',
};
}
function getElementClickRect(el) {
if (!el) return null;
const rect = el.getBoundingClientRect?.();
if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
return null;
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
};
}
function getGoPayContinueTarget() {
const button = findContinueButton();
return {
found: Boolean(button),
target: describeElement(button),
rect: getElementClickRect(button),
};
}
function getGoPayPayNowTarget() {
const button = findPayNowButton();
return {
found: Boolean(button),
target: describeElement(button),
rect: getElementClickRect(button),
};
}
async function clickGoPayContinue() {
await waitForDocumentComplete();
const clickResult = await clickContinueIfPresent({ afterMs: 1200 });
return { clicked: Boolean(clickResult.clicked), clickTarget: clickResult.target || '' };
}
async function clickGoPayPayNow() {
await waitForDocumentComplete();
const button = findPayNowButton();
if (!button) {
return { clicked: false, clickTarget: '' };
}
await humanClickElement(button, { afterMs: 1500 });
return { clicked: true, clickTarget: describeElement(button) };
}
function inspectGoPayState() {
const bodyText = normalizeText(document.body?.innerText || document.body?.textContent || '');
const phoneInput = findPhoneInput();
const otpInput = findOtpInput();
const pinInput = findPinInput();
const payNowButton = findPayNowButton();
const continueButton = findContinueButton();
const terminalError = detectGoPayTerminalError(bodyText);
const successTextMatched = /success|successful|completed|selesai|berhasil|approved|authorized|支付成功|绑定成功|已授权/i.test(bodyText);
const completed = !phoneInput && !otpInput && !pinInput && successTextMatched;
const selectedCountryCode = readSelectedCountryCodeText();
return {
url: location.href,
readyState: document.readyState,
selectedCountryCode,
hasPhoneInput: Boolean(phoneInput),
hasOtpInput: Boolean(otpInput),
hasPinInput: Boolean(pinInput),
hasPayNowButton: Boolean(payNowButton),
hasContinueButton: Boolean(continueButton),
hasTerminalError: Boolean(terminalError),
terminalError,
completed,
textPreview: bodyText.slice(0, 500),
inputHints: getVisibleTextInputs().map((input) => getActionText(input).slice(0, 120)).filter(Boolean).slice(0, 12),
};
}
+211 -51
View File
@@ -5,7 +5,7 @@ console.log('[MultiPage:plus-checkout] Content script loaded on', location.href)
window.__MULTIPAGE_PLUS_CHECKOUT_READY__ = true;
const PLUS_CHECKOUT_LISTENER_SENTINEL = 'data-multipage-plus-checkout-listener';
const PLUS_CHECKOUT_BASE_PAYLOAD = {
const PLUS_CHECKOUT_PAYLOAD_BASE = {
entry_point: 'all_plans_pricing_modal',
plan_name: 'chatgptplusplan',
checkout_ui_mode: 'custom',
@@ -33,6 +33,32 @@ const PLUS_CHECKOUT_CONFIGS = {
},
};
const PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS = 5000;
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PAYMENT_METHOD_CONFIGS = {
[PLUS_PAYMENT_METHOD_PAYPAL]: {
id: PLUS_PAYMENT_METHOD_PAYPAL,
label: 'PayPal',
diagnosticLabel: 'PayPal',
checkoutMerchantPath: 'openai_ie',
billingDetails: {
country: 'DE',
currency: 'EUR',
},
patterns: [/paypal/i],
},
[PLUS_PAYMENT_METHOD_GOPAY]: {
id: PLUS_PAYMENT_METHOD_GOPAY,
label: 'GoPay',
diagnosticLabel: 'GoPay',
checkoutMerchantPath: 'openai_llc',
billingDetails: {
country: 'ID',
currency: 'IDR',
},
patterns: [/gopay|go\s*pay/i],
},
};
if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1');
@@ -42,6 +68,7 @@ if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '
message.type === 'CREATE_PLUS_CHECKOUT'
|| message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'
|| message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'
|| message.type === 'PLUS_CHECKOUT_SELECT_GOPAY'
|| message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'
|| message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'
|| message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'
@@ -73,7 +100,9 @@ async function handlePlusCheckoutCommand(message) {
case 'FILL_PLUS_BILLING_AND_SUBMIT':
return fillPlusBillingAndSubmit(message.payload || {});
case 'PLUS_CHECKOUT_SELECT_PAYPAL':
return selectPlusPayPalPaymentMethod();
return selectPlusPayPalPaymentMethod(message.payload || {});
case 'PLUS_CHECKOUT_SELECT_GOPAY':
return selectPlusGoPayPaymentMethod(message.payload || {});
case 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS':
return fillPlusBillingAddress(message.payload || {});
case 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY':
@@ -291,9 +320,9 @@ function getVisibleControls(selector) {
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"], [tabindex]');
const candidates = getVisibleControls('button, a, [role="button"], [role="tab"], input[type="button"], input[type="submit"], [tabindex]');
return candidates.find((el) => {
const text = getActionText(el);
const text = getCombinedSearchText(el);
return normalizedPatterns.some((pattern) => pattern.test(text));
}) || null;
}
@@ -367,7 +396,7 @@ function findInteractiveAncestor(el) {
let current = el;
for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
if (!isVisibleElement(current) || isDocumentLevelContainer(current)) continue;
if (current.matches?.('button, a, label, [role="button"], [role="radio"], input[type="radio"], [tabindex]')) {
if (current.matches?.('button, a, label, [role="button"], [role="radio"], [role="tab"], input[type="radio"], [tabindex]')) {
return current;
}
}
@@ -387,6 +416,16 @@ function findPaymentCardAncestor(el, pattern) {
return null;
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: PLUS_PAYMENT_METHOD_PAYPAL;
}
function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) {
return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL];
}
function getAncestorChainSummary(el, limit = 6) {
const chain = [];
let current = el;
@@ -409,13 +448,15 @@ function getAncestorChainSummary(el, limit = 6) {
return chain;
}
function getPayPalSearchCandidates() {
function getPaymentMethodSearchCandidates(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
const selector = [
'button',
'a',
'label',
'[role="button"]',
'[role="radio"]',
'[role="tab"]',
'input[type="radio"]',
'[tabindex]',
'[data-testid]',
@@ -428,7 +469,10 @@ function getPayPalSearchCandidates() {
].join(', ');
return getVisibleControls(selector)
.filter((el) => /paypal/i.test(getCombinedSearchText(el)))
.filter((el) => {
const text = getCombinedSearchText(el);
return config.patterns.some((pattern) => pattern.test(text));
})
.sort((left, right) => {
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
@@ -436,26 +480,36 @@ function getPayPalSearchCandidates() {
});
}
function findPayPalPaymentMethodTarget() {
const paypalPattern = /paypal/i;
const directClickable = findClickableByText([paypalPattern]);
function getPayPalSearchCandidates() {
return getPaymentMethodSearchCandidates(PLUS_PAYMENT_METHOD_PAYPAL);
}
function getGoPaySearchCandidates() {
return getPaymentMethodSearchCandidates(PLUS_PAYMENT_METHOD_GOPAY);
}
function findPaymentMethodTarget(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
const directClickable = findClickableByText(config.patterns);
if (directClickable) {
return directClickable;
}
const radios = getVisibleControls('input[type="radio"], [role="radio"]');
const paypalRadio = radios.find((el) => paypalPattern.test(getCombinedSearchText(el)));
if (paypalRadio) {
return paypalRadio;
const matchedRadio = radios.find((el) => config.patterns.some((pattern) => pattern.test(getCombinedSearchText(el))));
if (matchedRadio) {
return matchedRadio;
}
const candidates = getPayPalSearchCandidates();
const candidates = getPaymentMethodSearchCandidates(method);
for (const candidate of candidates) {
const interactive = findInteractiveAncestor(candidate);
if (interactive && paypalPattern.test(getCombinedSearchText(interactive))) {
if (interactive && config.patterns.some((pattern) => pattern.test(getCombinedSearchText(interactive)))) {
return interactive;
}
const card = findPaymentCardAncestor(candidate, paypalPattern);
const card = config.patterns
.map((pattern) => findPaymentCardAncestor(candidate, pattern))
.find(Boolean);
if (card) {
return card;
}
@@ -464,6 +518,14 @@ function findPayPalPaymentMethodTarget() {
return null;
}
function findPayPalPaymentMethodTarget() {
return findPaymentMethodTarget(PLUS_PAYMENT_METHOD_PAYPAL);
}
function findGoPayPaymentMethodTarget() {
return findPaymentMethodTarget(PLUS_PAYMENT_METHOD_GOPAY);
}
function summarizeElementForDebug(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
@@ -476,16 +538,24 @@ function summarizeElementForDebug(el) {
};
}
function getPayPalCandidateSummaries(limit = 6) {
return getPayPalSearchCandidates()
function getPaymentMethodCandidateSummaries(method = PLUS_PAYMENT_METHOD_PAYPAL, limit = 6) {
return getPaymentMethodSearchCandidates(method)
.map(summarizeElementForDebug)
.filter(Boolean)
.slice(0, limit);
}
function getPayPalCandidateSummaries(limit = 6) {
return getPaymentMethodCandidateSummaries(PLUS_PAYMENT_METHOD_PAYPAL, limit);
}
function getGoPayCandidateSummaries(limit = 6) {
return getPaymentMethodCandidateSummaries(PLUS_PAYMENT_METHOD_GOPAY, limit);
}
function getPaymentTextPreview(limit = 10) {
const seen = new Set();
const pattern = /paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i;
const pattern = /gopay|go\s*pay|paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i;
return getVisibleControls('button, a, label, [role="button"], [role="radio"], input[type="radio"], input[type="button"], input[type="submit"], [data-testid]')
.map((el) => getCombinedSearchText(el))
.filter((text) => text && pattern.test(text))
@@ -499,26 +569,67 @@ function getPaymentTextPreview(limit = 10) {
}
function getPayPalDiagnostics(reason = '') {
return getPaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_PAYPAL, reason);
}
function getGoPayDiagnostics(reason = '') {
return getPaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_GOPAY, reason);
}
function getPaymentMethodDiagnostics(method = PLUS_PAYMENT_METHOD_PAYPAL, reason = '') {
const config = getPaymentMethodConfig(method);
return {
reason,
url: location.href,
readyState: document.readyState,
paymentMethod: config.id,
paymentMethodLabel: config.label,
paymentCandidates: getPaymentMethodCandidateSummaries(config.id),
paypalCandidates: getPayPalCandidateSummaries(),
gopayCandidates: getGoPayCandidateSummaries(),
paymentTextPreview: getPaymentTextPreview(),
cardFieldsVisible: hasCreditCardFields(),
billingFieldsVisible: hasBillingAddressFields(),
};
}
function writePayPalDiagnostics(reason, level = 'info') {
const diagnostics = getPayPalDiagnostics(reason);
function writePaymentMethodDiagnostics(method = PLUS_PAYMENT_METHOD_PAYPAL, reason = '', level = 'info') {
const config = getPaymentMethodConfig(method);
const diagnostics = getPaymentMethodDiagnostics(config.id, reason);
const writer = typeof console[level] === 'function' ? console[level] : console.info;
writer.call(console, '[MultiPage:plus-checkout] PayPal diagnostics', diagnostics);
log(`Plus Checkout${reason}PayPal 候选 ${diagnostics.paypalCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}`, level === 'error' ? 'error' : 'warn');
writer.call(console, `[MultiPage:plus-checkout] ${config.diagnosticLabel} diagnostics`, diagnostics);
log(`Plus Checkout${reason}${config.label} 候选 ${diagnostics.paymentCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}`, level === 'error' ? 'error' : 'warn');
return diagnostics;
}
async function createPlusCheckoutSession(payload = {}) {
function writePayPalDiagnostics(reason, level = 'info') {
return writePaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_PAYPAL, reason, level);
}
function writeGoPayDiagnostics(reason, level = 'info') {
return writePaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_GOPAY, reason, level);
}
function buildPlusCheckoutPayload(paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(paymentMethod);
return {
...JSON.parse(JSON.stringify(PLUS_CHECKOUT_PAYLOAD_BASE)),
billing_details: {
...config.billingDetails,
},
};
}
function buildPlusCheckoutUrl(checkoutSessionId, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const sessionId = String(checkoutSessionId || '').trim();
if (!sessionId) {
throw new Error('创建 Plus Checkout 失败:未返回 checkout_session_id。');
}
const config = getPaymentMethodConfig(paymentMethod);
return `https://chatgpt.com/checkout/${config.checkoutMerchantPath}/${sessionId}`;
}
async function createPlusCheckoutSession(options = {}) {
await waitForDocumentComplete();
const checkoutConfig = buildPlusCheckoutConfig(payload);
log('Plus:正在读取 ChatGPT 登录会话...');
@@ -533,6 +644,8 @@ async function createPlusCheckoutSession(payload = {}) {
}
log('Plus:正在创建 checkout 会话...');
const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod);
const checkoutPayload = buildPlusCheckoutPayload(paymentMethod);
const response = await fetch('https://chatgpt.com/backend-api/payments/checkout', {
method: 'POST',
credentials: 'include',
@@ -540,7 +653,7 @@ async function createPlusCheckoutSession(payload = {}) {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(checkoutConfig.checkoutPayload),
body: JSON.stringify(checkoutPayload),
});
const data = await response.json().catch(() => ({}));
@@ -550,17 +663,17 @@ async function createPlusCheckoutSession(payload = {}) {
}
return {
checkoutUrl: `${checkoutConfig.checkoutUrlPrefix}${data.checkout_session_id}`,
country: checkoutConfig.checkoutPayload.billing_details.country,
currency: checkoutConfig.checkoutPayload.billing_details.currency,
paymentMethod: checkoutConfig.paymentMethod,
checkoutUrl: buildPlusCheckoutUrl(data.checkout_session_id, paymentMethod),
country: checkoutPayload.billing_details.country,
currency: checkoutPayload.billing_details.currency,
};
}
async function selectPayPalPaymentMethod() {
async function selectPaymentMethod(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
let lastDiagnosticsAt = 0;
const target = await waitUntil(() => {
const currentTarget = findPayPalPaymentMethodTarget();
const currentTarget = findPaymentMethodTarget(config.id);
if (currentTarget) {
return currentTarget;
}
@@ -568,31 +681,49 @@ async function selectPayPalPaymentMethod() {
const now = Date.now();
if (!lastDiagnosticsAt || now - lastDiagnosticsAt >= PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS) {
lastDiagnosticsAt = now;
writePayPalDiagnostics('正在等待可点击的 PayPal 付款方式', 'warn');
writePaymentMethodDiagnostics(config.id, `正在等待可点击的 ${config.label} 付款方式`, 'warn');
}
return null;
}, {
label: 'PayPal 付款方式',
label: `${config.label} 付款方式`,
intervalMs: 250,
});
console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target));
console.info(`[MultiPage:plus-checkout] ${config.label} target selected`, summarizeElementForDebug(target));
simulateClick(target);
log('Plus Checkout:已点击 PayPal 付款方式,正在确认选中状态。');
log(`Plus Checkout:已点击 ${config.label} 付款方式,正在确认选中状态。`);
if (!await waitForPayPalPaymentMethodActive()) {
const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error');
throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}`);
if (!await waitForPaymentMethodActive(config.id)) {
const diagnostics = writePaymentMethodDiagnostics(config.id, `点击 ${config.label} 后页面仍未进入 ${config.label} 账单表单`, 'error');
throw new Error(`Plus Checkout:已尝试点击 ${config.label},但页面未切换到 ${config.label} 表单。请提供控制台 ${config.label} diagnostics 结构。候选数量:${diagnostics.paymentCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}`);
}
log('Plus Checkout:已确认 PayPal 付款方式生效。');
log(`Plus Checkout:已确认 ${config.label} 付款方式生效。`);
return true;
}
async function selectPayPalPaymentMethod() {
return selectPaymentMethod(PLUS_PAYMENT_METHOD_PAYPAL);
}
async function selectGoPayPaymentMethod() {
return selectPaymentMethod(PLUS_PAYMENT_METHOD_GOPAY);
}
async function selectPlusPayPalPaymentMethod() {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
await selectPaymentMethod(PLUS_PAYMENT_METHOD_PAYPAL);
return {
paymentSelected: true,
paymentMethod: PLUS_PAYMENT_METHOD_PAYPAL,
};
}
async function selectPlusGoPayPaymentMethod() {
await waitForDocumentComplete();
await selectPaymentMethod(PLUS_PAYMENT_METHOD_GOPAY);
return {
paymentSelected: true,
paymentMethod: PLUS_PAYMENT_METHOD_GOPAY,
};
}
@@ -660,14 +791,14 @@ function hasBillingAddressFields() {
});
}
function hasSelectedPayPalControl() {
const paypalPattern = /paypal/i;
const candidates = getPayPalSearchCandidates();
function hasSelectedPaymentMethodControl(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
const candidates = getPaymentMethodSearchCandidates(config.id);
return candidates.some((candidate) => {
let current = candidate;
for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) {
if (isDocumentLevelContainer(current)) break;
if (!paypalPattern.test(getCombinedSearchText(current))) continue;
if (!config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current)))) continue;
const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || '';
if (
current.checked === true
@@ -684,15 +815,31 @@ function hasSelectedPayPalControl() {
});
}
function isPayPalPaymentMethodActive() {
return hasSelectedPayPalControl();
function hasSelectedPayPalControl() {
return hasSelectedPaymentMethodControl(PLUS_PAYMENT_METHOD_PAYPAL);
}
async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
function hasSelectedGoPayControl() {
return hasSelectedPaymentMethodControl(PLUS_PAYMENT_METHOD_GOPAY);
}
function isPaymentMethodActive(method = PLUS_PAYMENT_METHOD_PAYPAL) {
return hasSelectedPaymentMethodControl(method);
}
function isPayPalPaymentMethodActive() {
return isPaymentMethodActive(PLUS_PAYMENT_METHOD_PAYPAL);
}
function isGoPayPaymentMethodActive() {
return isPaymentMethodActive(PLUS_PAYMENT_METHOD_GOPAY);
}
async function waitForPaymentMethodActive(method = PLUS_PAYMENT_METHOD_PAYPAL, timeoutMs = 5000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
throwIfStopped();
if (isPayPalPaymentMethodActive()) {
if (isPaymentMethodActive(method)) {
return true;
}
await sleep(250);
@@ -700,6 +847,10 @@ async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
return false;
}
async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
return waitForPaymentMethodActive(PLUS_PAYMENT_METHOD_PAYPAL, timeoutMs);
}
async function findAddressSearchInput() {
return waitUntil(() => {
const direct = findInputByFieldText([
@@ -820,6 +971,7 @@ function getCountryCandidates(value = '') {
FR: ['France', '法国'],
GB: ['United Kingdom', 'UK', 'Britain', 'England', '英国'],
HK: ['Hong Kong', '香港'],
ID: ['Indonesia', '印度尼西亚', '印尼'],
IT: ['Italy', '意大利'],
JP: ['Japan', '日本', '日本国'],
KR: ['Korea', 'South Korea', '韩国'],
@@ -834,6 +986,10 @@ function getCountryCandidates(value = '') {
US: ['United States', 'United States of America', 'USA', '美国'],
VN: ['Vietnam', '越南'],
};
const indonesiaCandidates = aliases.ID || [];
if (compact === 'id' || compact === 'indonesia' || compact === '印度尼西亚' || compact === '印尼') {
return Array.from(new Set([raw, 'ID', ...indonesiaCandidates].filter(Boolean)));
}
const direct = aliases[String(raw || '').trim().toUpperCase()] || [];
const matched = Object.entries(aliases).find(([code, names]) => {
if (String(code).toLowerCase() === compact) return true;
@@ -1230,7 +1386,8 @@ async function humanLikeClick(el) {
async function fillPlusBillingAndSubmit(payload = {}) {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
await selectPaymentMethod(paymentMethod);
const billingResult = await fillPlusBillingAddress(payload);
if (payload.skipSubmit) {
@@ -1311,8 +1468,9 @@ async function ensurePlusStructuredBillingAddress(payload = {}) {
}
async function clickPlusSubscribe(payload = {}) {
if (payload.ensurePayPalActive && !isPayPalPaymentMethodActive()) {
await selectPayPalPaymentMethod();
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
if ((payload.ensurePayPalActive || payload.ensurePaymentActive) && !isPaymentMethodActive(paymentMethod)) {
await selectPaymentMethod(paymentMethod);
}
const subscribeButton = await waitUntil(() => {
@@ -1338,7 +1496,9 @@ function inspectPlusCheckoutState() {
readyState: document.readyState,
countryText: readCountryText(),
hasPayPal: Boolean(findPayPalPaymentMethodTarget()),
hasGoPay: Boolean(findGoPayPaymentMethodTarget()),
paypalCandidates: getPayPalCandidateSummaries(),
gopayCandidates: getGoPayCandidateSummaries(),
paymentTextPreview: getPaymentTextPreview(),
cardFieldsVisible: hasCreditCardFields(),
billingFieldsVisible: hasBillingAddressFields(),
+144
View File
@@ -0,0 +1,144 @@
// content/whatsapp-flow.js — WhatsApp Web code reader for GoPay.
console.log('[MultiPage:whatsapp-flow] Content script loaded on', location.href);
const WHATSAPP_FLOW_LISTENER_SENTINEL = 'data-multipage-whatsapp-flow-listener';
if (document.documentElement.getAttribute(WHATSAPP_FLOW_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(WHATSAPP_FLOW_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'WHATSAPP_GET_STATE'
|| message.type === 'WHATSAPP_FIND_CODE'
) {
resetStopState();
handleWhatsAppCommand(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:whatsapp-flow] 消息监听已存在,跳过重复注册');
}
async function handleWhatsAppCommand(message) {
switch (message.type) {
case 'WHATSAPP_GET_STATE':
return inspectWhatsAppState();
case 'WHATSAPP_FIND_CODE':
return findWhatsAppCode(message.payload || {});
default:
throw new Error(`whatsapp-flow.js 不处理消息:${message.type}`);
}
}
function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function getBodyText() {
return normalizeText(document.body?.innerText || document.body?.textContent || '');
}
function extractVerificationCode(text = '') {
const normalized = normalizeText(text);
const preferredPatterns = [
/(?:gopay|gojek|openai|chatgpt|kode|code|otp|verification|verifikasi|whatsapp|验证码|驗證碼|代码|代碼)[^\d]{0,40}(\d{4,8})/i,
/(\d{4,8})[^\d]{0,40}(?:gopay|gojek|openai|chatgpt|kode|code|otp|verification|verifikasi|验证码|驗證碼|代码|代碼)/i,
];
for (const pattern of preferredPatterns) {
const match = normalized.match(pattern);
if (match?.[1]) {
return match[1];
}
}
const genericMatch = normalized.match(/(?<!\d)(\d{4,8})(?!\d)/);
return genericMatch?.[1] || '';
}
function getMessageCandidates() {
const selectors = [
'[data-testid*="msg" i]',
'[data-pre-plain-text]',
'.message-in',
'[role="row"]',
'div',
'span',
];
const seen = new Set();
const messages = [];
for (const selector of selectors) {
for (const el of Array.from(document.querySelectorAll(selector))) {
const text = normalizeText(el.innerText || el.textContent || '');
if (!text || text.length < 4 || seen.has(text)) continue;
seen.add(text);
messages.push(text);
}
}
return messages.slice(-80);
}
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 1000));
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) {
return null;
}
await sleep(intervalMs);
}
}
async function findWhatsAppCode(payload = {}) {
const timeoutMs = Math.max(0, Math.floor(Number(payload.timeoutMs) || 0));
const result = await waitUntil(() => {
const messages = getMessageCandidates();
for (let index = messages.length - 1; index >= 0; index -= 1) {
const text = messages[index];
const code = extractVerificationCode(text);
if (code) {
return {
code,
messageText: text.slice(0, 500),
};
}
}
const code = extractVerificationCode(getBodyText());
return code ? { code, messageText: getBodyText().slice(0, 500) } : null;
}, {
intervalMs: 1000,
timeoutMs,
});
return result || {
code: '',
messageText: '',
};
}
function inspectWhatsAppState() {
const bodyText = getBodyText();
const code = extractVerificationCode(bodyText);
return {
url: location.href,
readyState: document.readyState,
loggedIn: !/use whatsapp on your computer|link a device|scan|qr|使用 WhatsApp|扫码|掃碼/i.test(bodyText),
code,
textPreview: bodyText.slice(0, 500),
messagePreview: getMessageCandidates().slice(-8),
};
}
+23
View File
@@ -5,6 +5,7 @@
AU: ['au', 'aus', 'australia', '澳大利亚'],
DE: ['de', 'deu', 'germany', 'deutschland', '德国'],
FR: ['fr', 'fra', 'france', '法国'],
ID: ['id', 'indonesia', '印度尼西亚', '印尼'],
JP: ['jp', 'jpn', 'japan', '日本', '日本国'],
US: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'],
};
@@ -76,6 +77,28 @@
},
},
],
ID: [
{
query: 'Jakarta Indonesia',
suggestionIndex: 1,
fallback: {
address1: 'Jalan M.H. Thamrin No. 1',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '10310',
},
},
{
query: 'Jakarta Selatan',
suggestionIndex: 1,
fallback: {
address1: 'Jalan Jenderal Sudirman Kav. 52-53',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '12190',
},
},
],
JP: [
{
query: 'Tokyo Marunouchi',
+22 -18
View File
@@ -30,26 +30,23 @@
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
];
const PLUS_GOPAY_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '打开 GoPay 订阅页' },
{ id: 7, order: 70, key: 'gopay-subscription-confirm', title: '等待 GoPay 订阅确认' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
];
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_STEP_KEY = 'paypal-approve';
function isPlusModeEnabled(options = {}) {
return Boolean(options?.plusModeEnabled || options?.plusMode);
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: 'paypal';
}
function getPlusPaymentStepTitle(options = {}) {
return normalizePlusPaymentMethod(options?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GOPAY
? 'GoPay 手机验证与授权'
: 'PayPal 登录与授权';
}
function getModeStepDefinitions(options = {}) {
@@ -61,12 +58,18 @@
: PLUS_PAYPAL_STEP_DEFINITIONS;
}
function cloneSteps(steps = []) {
return steps.map((step) => ({ ...step }));
function cloneSteps(steps = [], options = {}) {
const plusModeEnabled = isPlusModeEnabled(options);
return steps.map((step) => ({
...step,
title: plusModeEnabled && step.key === PLUS_PAYMENT_STEP_KEY
? getPlusPaymentStepTitle(options)
: step.title,
}));
}
function getSteps(options = {}) {
return cloneSteps(getModeStepDefinitions(options));
return cloneSteps(getModeStepDefinitions(options), options);
}
function getAllSteps() {
@@ -101,7 +104,7 @@
function getStepById(id, options = {}) {
const numericId = Number(id);
const match = getModeStepDefinitions(options).find((step) => step.id === numericId);
return match ? { ...match } : null;
return match ? cloneSteps([match], options)[0] : null;
}
return {
@@ -112,6 +115,7 @@
PLUS_GOPAY_STEP_DEFINITIONS,
getAllSteps,
getLastStepId,
getPlusPaymentStepTitle,
getStepById,
getStepIds,
getSteps,
+36 -7
View File
@@ -15,7 +15,8 @@
5. SUB2API 多分组创建账号。
6. OAuth 登录使用全新标签页。
7. 接码平台多平台适配:HeroSMS + 5sim。
8. 本地导出配置 `/config.json` 忽略规则
8. Plus 支付方式适配:PayPal + GoPay
9. 本地导出配置 `/config.json` 忽略规则。
推荐每次同步后执行:
@@ -225,7 +226,7 @@ node --test \
Plus checkout 页面加载后尽早判断是否有免费试用资格:
- 如果“今日应付金额”不是 0,直接跳过 PayPal 填写/提交。
- 如果“今日应付金额”不是 0,直接跳过支付填写/提交。
- 被跳过的账号也要标记为“已用”,避免下次重复拿到。
- 提交前再检查一次金额,防止页面中途变化。
@@ -256,11 +257,39 @@ Plus checkout 页面加载后尽早判断是否有免费试用资格:
### 维护注意
1. 不要把免费资格判断放到地址/PayPal 填写之后。
1. 不要把免费资格判断放到账单地址或支付方式填写之后。
2. 不要把 `PLUS_CHECKOUT_NON_FREE_TRIAL::` 当作普通可重试错误。
3. 标记账号已用时要读取 fresh state,避免 step 局部传入的 state 过期。
## 5. SUB2API 多分组创建账号
## 5. Plus 支付方式适配:PayPal + GoPay
### 目标
Plus 模式新增 `plusPaymentMethod`
- `paypal`:保留现有 PayPal 账号池、登录与授权。
- `gopay`:创建 checkout 时使用印尼账单国家 `ID / IDR`,第 7 步选择 GoPay,第 8 步填写 GoPay 手机号;验证码优先使用侧边栏已填值,否则执行时弹出手动输入框;提交验证码后继续填写 GoPay PIN。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `sidepanel/sidepanel.html` | Plus 支付下拉、GoPay 手机号、GoPay 验证码、GoPay PIN 输入。 |
| `sidepanel/sidepanel.js` | 保存 `plusPaymentMethod / gopayCountryCode / gopayPhone / gopayOtp / gopayPin`,按支付方式切换配置行,第 8 步标题按 PayPal / GoPay 动态匹配,并提供 GoPay 验证码手动输入弹窗。 |
| `gopay-utils.js` | GoPay 支付方式、手机号、验证码、PIN 规范化工具。 |
| `content/plus-checkout.js` | 创建 GoPay checkout payload,识别/选择 GoPay 付款方式。 |
| `background/steps/fill-plus-checkout.js` | 第 7 步按支付方式选择 PayPal 或 GoPay,并为 GoPay 使用印尼地址。 |
| `background/steps/gopay-approve.js` | 第 8 步 GoPay 手机号、手动验证码、PIN 自动化骨架。 |
| `content/gopay-flow.js` | GoPay 页面手机号/验证码/PIN 输入脚本,包含国家区号切换、贴近人工点击的确认按钮事件序列,并在必要时交给后台 debugger 真实鼠标事件兜底。 |
| `content/whatsapp-flow.js` | WhatsApp Web 验证码读取脚本。 |
### 维护注意
1. GoPay 验证码和 PIN 只能通过侧边栏输入或运行时弹窗临时填写,不要写入代码、测试或文档。
2. PayPal 和 GoPay 共享 Plus 可见步骤,第 8 步仍使用 `paypal-approve` 这个 step key,但展示标题会按 `plusPaymentMethod` 变为 PayPal 或 GoPay 文案,后台也会按该字段分发到 PayPal 或 GoPay executor。
3. GoPay 页面和 WhatsApp Web 的真实 DOM 可能变化;后续联调时优先在每个页面抓 `GOPAY_GET_STATE / WHATSAPP_GET_STATE` 输出,再补精确选择器。
## 6. SUB2API 多分组创建账号
### 目标
@@ -290,7 +319,7 @@ Plus checkout 页面加载后尽早判断是否有免费试用资格:
上游如果只按单个 `sub2apiGroupId` 写回,需要保留本地 `sub2apiGroupIds` 数组逻辑。
## 6. OAuth 登录使用全新标签页
## 7. OAuth 登录使用全新标签页
### 目标
@@ -307,7 +336,7 @@ OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染
上游如果改 OAuth tab 打开方式,确认仍保留“全新标签页”语义,不要退回到复用已有 tab。
## 7. 接码平台多平台适配:HeroSMS + 5sim
## 8. 接码平台多平台适配:HeroSMS + 5sim
### 目标
@@ -371,7 +400,7 @@ node --check sidepanel/sidepanel.js
node --test tests/five-sim-provider.test.js tests/phone-verification-flow.test.js tests/sidepanel-phone-verification-settings.test.js
```
## 8. 本地配置文件忽略
## 9. 本地配置文件忽略
### 目标
+56
View File
@@ -0,0 +1,56 @@
(function attachGoPayUtils(root, factory) {
root.GoPayUtils = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createGoPayUtils() {
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: PLUS_PAYMENT_METHOD_PAYPAL;
}
const DEFAULT_GOPAY_COUNTRY_CODE = '+86';
function normalizeGoPayCountryCode(value = '') {
const normalized = String(value || '').trim().replace(/[^\d+]/g, '');
const digits = normalized.replace(/\D/g, '');
return digits ? `+${digits}` : DEFAULT_GOPAY_COUNTRY_CODE;
}
function normalizeGoPayPhone(value = '') {
return String(value || '').trim().replace(/[^\d+]/g, '');
}
function normalizeGoPayPhoneForCountry(value = '', countryCode = DEFAULT_GOPAY_COUNTRY_CODE) {
const normalizedPhone = normalizeGoPayPhone(value);
const normalizedCountryCode = normalizeGoPayCountryCode(countryCode);
const countryDigits = normalizedCountryCode.replace(/\D/g, '');
let nationalNumber = normalizedPhone.replace(/\D/g, '');
if (countryDigits && nationalNumber.startsWith(countryDigits)) {
nationalNumber = nationalNumber.slice(countryDigits.length);
}
return nationalNumber;
}
function normalizeGoPayPin(value = '') {
return String(value || '').trim().replace(/[^\d]/g, '');
}
function normalizeGoPayOtp(value = '') {
return String(value || '').trim().replace(/[^\d]/g, '');
}
return {
DEFAULT_GOPAY_COUNTRY_CODE,
PLUS_PAYMENT_METHOD_GOPAY,
PLUS_PAYMENT_METHOD_PAYPAL,
normalizeGoPayCountryCode,
normalizeGoPayPhone,
normalizeGoPayPhoneForCountry,
normalizeGoPayOtp,
normalizeGoPayPin,
normalizePlusPaymentMethod,
};
});
+50 -6
View File
@@ -228,20 +228,25 @@
<span class="data-label">Plus 模式</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout 订阅流程">
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout 支付授权流程">
<input type="checkbox" id="input-plus-mode-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<div class="setting-group setting-group-secondary">
<select id="select-plus-payment-method" class="data-select plus-payment-method-select" style="display:none;" aria-label="Plus 支付方式">
<option value="paypal">PayPal 支付</option>
<option value="gopay">GoPay 支付</option>
</select>
<span class="setting-caption">Plus 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-plus-payment-method" style="display:none;">
<span class="data-label">Plus 支付</span>
<div class="data-inline">
<select id="select-plus-payment-method" class="data-select">
<option value="paypal">PayPal</option>
<option value="gopay">GoPay</option>
</select>
<span class="setting-caption" id="plus-payment-method-caption">PayPal 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-paypal-account" style="display:none;">
<span class="data-label">PayPal 账号</span>
@@ -252,6 +257,44 @@
<button id="btn-add-paypal-account" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row" id="row-gopay-country-code" style="display:none;">
<span class="data-label">GoPay 区号</span>
<select id="select-gopay-country-code" class="data-select">
<option value="+86">中国 +86</option>
<option value="+62">印度尼西亚 +62</option>
<option value="+1">美国/加拿大 +1</option>
<option value="+852">香港 +852</option>
<option value="+853">澳门 +853</option>
<option value="+886">台湾 +886</option>
<option value="+60">马来西亚 +60</option>
<option value="+65">新加坡 +65</option>
<option value="+66">泰国 +66</option>
<option value="+84">越南 +84</option>
<option value="+63">菲律宾 +63</option>
<option value="+91">印度 +91</option>
</select>
</div>
<div class="data-row" id="row-gopay-phone" style="display:none;">
<span class="data-label">GoPay 手机</span>
<input type="text" id="input-gopay-phone" class="data-input"
placeholder="请输入不含区号的 GoPay / WhatsApp 手机号" autocomplete="tel" />
</div>
<div class="data-row" id="row-gopay-otp" style="display:none;">
<span class="data-label">GoPay 验证码</span>
<input type="text" id="input-gopay-otp" class="data-input"
placeholder="可选;留空时执行中会弹窗手动输入" inputmode="numeric" autocomplete="one-time-code" />
</div>
<div class="data-row" id="row-gopay-pin" style="display:none;">
<span class="data-label">GoPay PIN</span>
<div class="input-with-icon">
<input type="password" id="input-gopay-pin" class="data-input data-input-with-icon"
placeholder="请输入 GoPay PIN" autocomplete="off" />
<button id="btn-toggle-gopay-pin" class="input-icon-btn" type="button"
data-password-toggle="input-gopay-pin" data-show-label="显示 GoPay PIN"
data-hide-label="隐藏 GoPay PIN" aria-label="显示 GoPay PIN"
title="显示 GoPay PIN"></button>
</div>
</div>
<div class="data-row module-divider-start" id="row-mail-provider">
<span class="data-label">邮箱服务</span>
<div class="data-inline">
@@ -1783,6 +1826,7 @@
<script src="../managed-alias-utils.js"></script>
<script src="../mail2925-utils.js"></script>
<script src="../paypal-utils.js"></script>
<script src="../gopay-utils.js"></script>
<script src="../phone-sms/providers/hero-sms.js"></script>
<script src="../phone-sms/providers/five-sim.js"></script>
<script src="../phone-sms/providers/registry.js"></script>
+198 -25
View File
@@ -162,10 +162,20 @@ const inputCodex2ApiAdminKey = document.getElementById('input-codex2api-admin-ke
const rowCustomPassword = document.getElementById('row-custom-password');
const rowPlusMode = document.getElementById('row-plus-mode');
const inputPlusModeEnabled = document.getElementById('input-plus-mode-enabled');
const rowPlusPaymentMethod = document.getElementById('row-plus-payment-method');
const selectPlusPaymentMethod = document.getElementById('select-plus-payment-method');
const plusPaymentMethodCaption = document.getElementById('plus-payment-method-caption');
const rowPayPalAccount = document.getElementById('row-paypal-account');
const selectPayPalAccount = document.getElementById('select-paypal-account');
const btnAddPayPalAccount = document.getElementById('btn-add-paypal-account');
const rowGoPayCountryCode = document.getElementById('row-gopay-country-code');
const selectGoPayCountryCode = document.getElementById('select-gopay-country-code');
const rowGoPayPhone = document.getElementById('row-gopay-phone');
const inputGoPayPhone = document.getElementById('input-gopay-phone');
const rowGoPayOtp = document.getElementById('row-gopay-otp');
const inputGoPayOtp = document.getElementById('input-gopay-otp');
const rowGoPayPin = document.getElementById('row-gopay-pin');
const inputGoPayPin = document.getElementById('input-gopay-pin');
const selectMailProvider = document.getElementById('select-mail-provider');
const btnMailLogin = document.getElementById('btn-mail-login');
const rowCustomMailProviderPool = document.getElementById('row-custom-mail-provider-pool');
@@ -634,21 +644,10 @@ const AUTO_RUN_PLUS_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-plus
const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger';
const PHONE_VERIFICATION_SECTION_EXPANDED_STORAGE_KEY = 'multipage-phone-verification-section-expanded';
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
}
function getSelectedPlusPaymentMethod() {
if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) {
return normalizePlusPaymentMethod(selectPlusPaymentMethod.value);
}
return normalizePlusPaymentMethod(latestState?.plusPaymentMethod || currentPlusPaymentMethod);
}
function getStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethod = 'paypal') {
function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) {
return (window.MultiPageStepDefinitions?.getSteps?.({
plusModeEnabled,
plusPaymentMethod: normalizePlusPaymentMethod(plusPaymentMethod),
plusPaymentMethod: options.plusPaymentMethod || selectPlusPaymentMethod?.value || 'paypal',
}) || [])
.sort((left, right) => {
const leftOrder = Number.isFinite(left.order) ? left.order : left.id;
@@ -658,10 +657,9 @@ function getStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethod =
});
}
function rebuildStepDefinitionState(plusModeEnabled = false, plusPaymentMethod = 'paypal') {
function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) {
currentPlusModeEnabled = Boolean(plusModeEnabled);
currentPlusPaymentMethod = normalizePlusPaymentMethod(plusPaymentMethod);
stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled, currentPlusPaymentMethod);
stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled, options);
STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite);
STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending']));
SKIPPABLE_STEPS = new Set(STEP_IDS);
@@ -693,6 +691,9 @@ const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL;
const DEFAULT_IP_PROXY_SERVICE = '711proxy';
const SUPPORTED_IP_PROXY_SERVICES = ['711proxy', 'lumiproxy', 'iproyal', 'omegaproxy'];
const IP_PROXY_ENABLED_SERVICES = ['711proxy'];
@@ -1895,6 +1896,22 @@ function syncLatestState(nextState) {
renderAccountRecords(latestState);
}
function normalizePlusPaymentMethod(value = '') {
if (window.GoPayUtils?.normalizePlusPaymentMethod) {
return window.GoPayUtils.normalizePlusPaymentMethod(value);
}
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: PLUS_PAYMENT_METHOD_PAYPAL;
}
function getSelectedPlusPaymentMethod(state = latestState) {
if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value) {
return normalizePlusPaymentMethod(selectPlusPaymentMethod.value);
}
return normalizePlusPaymentMethod(state?.plusPaymentMethod || DEFAULT_PLUS_PAYMENT_METHOD);
}
function hasOwnStateValue(source, key) {
return Object.prototype.hasOwnProperty.call(source, key);
}
@@ -2845,6 +2862,7 @@ function collectSettingsPayload() {
const currentPayPalAccount = typeof getCurrentPayPalAccount === 'function'
? getCurrentPayPalAccount(latestState)
: payPalAccounts.find((account) => account?.id === String(latestState?.currentPayPalAccountId || '').trim()) || null;
const plusPaymentMethod = getSelectedPlusPaymentMethod();
return {
...(contributionModeEnabled ? {} : {
panelMode: selectPanelMode.value,
@@ -2887,6 +2905,26 @@ function collectSettingsPayload() {
paypalPassword: String(currentPayPalAccount?.password || latestState?.paypalPassword || ''),
currentPayPalAccountId: String(latestState?.currentPayPalAccountId || '').trim(),
paypalAccounts: payPalAccounts,
gopayCountryCode: window.GoPayUtils?.normalizeGoPayCountryCode
? window.GoPayUtils.normalizeGoPayCountryCode(typeof selectGoPayCountryCode !== 'undefined' && selectGoPayCountryCode ? selectGoPayCountryCode.value : latestState?.gopayCountryCode)
: (typeof selectGoPayCountryCode !== 'undefined' && selectGoPayCountryCode
? String(selectGoPayCountryCode.value || '+86').trim()
: String(latestState?.gopayCountryCode || '+86').trim()),
gopayPhone: window.GoPayUtils?.normalizeGoPayPhone
? window.GoPayUtils.normalizeGoPayPhone(typeof inputGoPayPhone !== 'undefined' && inputGoPayPhone ? inputGoPayPhone.value : latestState?.gopayPhone)
: (typeof inputGoPayPhone !== 'undefined' && inputGoPayPhone
? String(inputGoPayPhone.value || '').trim()
: String(latestState?.gopayPhone || '').trim()),
gopayOtp: window.GoPayUtils?.normalizeGoPayOtp
? window.GoPayUtils.normalizeGoPayOtp(typeof inputGoPayOtp !== 'undefined' && inputGoPayOtp ? inputGoPayOtp.value : latestState?.gopayOtp)
: (typeof inputGoPayOtp !== 'undefined' && inputGoPayOtp
? String(inputGoPayOtp.value || '').trim().replace(/[^\d]/g, '')
: String(latestState?.gopayOtp || '').trim().replace(/[^\d]/g, '')),
gopayPin: window.GoPayUtils?.normalizeGoPayPin
? window.GoPayUtils.normalizeGoPayPin(typeof inputGoPayPin !== 'undefined' && inputGoPayPin ? inputGoPayPin.value : latestState?.gopayPin)
: (typeof inputGoPayPin !== 'undefined' && inputGoPayPin
? String(inputGoPayPin.value || '')
: String(latestState?.gopayPin || '')),
...(contributionModeEnabled ? {} : {
customPassword: inputPassword.value,
}),
@@ -6014,18 +6052,41 @@ function updatePlusModeUI() {
const enabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled
? Boolean(inputPlusModeEnabled.checked)
: false;
const paymentMethod = getSelectedPlusPaymentMethod();
const method = enabled ? getSelectedPlusPaymentMethod() : DEFAULT_PLUS_PAYMENT_METHOD;
if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) {
selectPlusPaymentMethod.value = paymentMethod;
selectPlusPaymentMethod.style.display = enabled ? '' : 'none';
selectPlusPaymentMethod.value = method;
}
if (typeof plusPaymentMethodCaption !== 'undefined' && plusPaymentMethodCaption) {
plusPaymentMethodCaption.textContent = method === PLUS_PAYMENT_METHOD_GOPAY
? 'GoPay 印尼订阅链路'
: 'PayPal 订阅链路';
}
[
typeof rowPlusPaymentMethod !== 'undefined' ? rowPlusPaymentMethod : null,
].forEach((row) => {
if (!row) {
return;
}
row.style.display = enabled ? '' : 'none';
});
[
typeof rowPayPalAccount !== 'undefined' ? rowPayPalAccount : null,
].forEach((row) => {
if (!row) {
return;
}
row.style.display = enabled && paymentMethod === 'paypal' ? '' : 'none';
row.style.display = enabled && method === PLUS_PAYMENT_METHOD_PAYPAL ? '' : 'none';
});
[
typeof rowGoPayCountryCode !== 'undefined' ? rowGoPayCountryCode : null,
typeof rowGoPayPhone !== 'undefined' ? rowGoPayPhone : null,
typeof rowGoPayOtp !== 'undefined' ? rowGoPayOtp : null,
typeof rowGoPayPin !== 'undefined' ? rowGoPayPin : null,
].forEach((row) => {
if (!row) {
return;
}
row.style.display = enabled && method === PLUS_PAYMENT_METHOD_GOPAY ? '' : 'none';
});
}
@@ -6281,15 +6342,19 @@ function renderStepsList() {
function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethod = 'paypal', options = {}) {
const nextPlusModeEnabled = Boolean(plusModeEnabled);
const nextPlusPaymentMethod = normalizePlusPaymentMethod(plusPaymentMethod);
const shouldRender = Boolean(options.render)
|| nextPlusModeEnabled !== currentPlusModeEnabled
|| nextPlusPaymentMethod !== currentPlusPaymentMethod;
const nextPaymentMethod = normalizePlusPaymentMethod(options.plusPaymentMethod || getSelectedPlusPaymentMethod(latestState));
const currentPaymentStep = stepDefinitions.find((step) => step.key === 'paypal-approve');
const nextPaymentTitle = window.MultiPageStepDefinitions?.getPlusPaymentStepTitle?.({
plusModeEnabled: nextPlusModeEnabled,
plusPaymentMethod: nextPaymentMethod,
});
const paymentTitleChanged = Boolean(nextPlusModeEnabled && currentPaymentStep && nextPaymentTitle && currentPaymentStep.title !== nextPaymentTitle);
const shouldRender = Boolean(options.render) || nextPlusModeEnabled !== currentPlusModeEnabled || paymentTitleChanged;
if (!shouldRender) {
return;
}
rebuildStepDefinitionState(nextPlusModeEnabled, nextPlusPaymentMethod);
rebuildStepDefinitionState(nextPlusModeEnabled, { plusPaymentMethod: nextPaymentMethod });
renderStepsList();
}
@@ -6359,6 +6424,29 @@ function applySettingsState(state) {
if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) {
selectPlusPaymentMethod.value = normalizePlusPaymentMethod(state?.plusPaymentMethod);
}
if (typeof selectGoPayCountryCode !== 'undefined' && selectGoPayCountryCode) {
const normalizedGoPayCountryCode = window.GoPayUtils?.normalizeGoPayCountryCode
? window.GoPayUtils.normalizeGoPayCountryCode(state?.gopayCountryCode)
: String(state?.gopayCountryCode || '+86').trim();
const hasOption = Array.from(selectGoPayCountryCode.options || [])
.some((option) => option.value === normalizedGoPayCountryCode);
if (!hasOption && normalizedGoPayCountryCode) {
const option = document.createElement('option');
option.value = normalizedGoPayCountryCode;
option.textContent = `自定义 ${normalizedGoPayCountryCode}`;
selectGoPayCountryCode.appendChild(option);
}
selectGoPayCountryCode.value = normalizedGoPayCountryCode || '+86';
}
if (typeof inputGoPayPhone !== 'undefined' && inputGoPayPhone) {
inputGoPayPhone.value = state?.gopayPhone || '';
}
if (typeof inputGoPayOtp !== 'undefined' && inputGoPayOtp) {
inputGoPayOtp.value = state?.gopayOtp || '';
}
if (typeof inputGoPayPin !== 'undefined' && inputGoPayPin) {
inputGoPayPin.value = state?.gopayPin || '';
}
inputVpsUrl.value = state?.vpsUrl || '';
inputVpsPassword.value = state?.vpsPassword || '';
setLocalCpaStep9Mode(state?.localCpaStep9Mode);
@@ -7293,6 +7381,56 @@ function getCustomVerificationPromptCopy(step) {
};
}
function normalizeGoPayOtpInputValue(value = '') {
return window.GoPayUtils?.normalizeGoPayOtp
? window.GoPayUtils.normalizeGoPayOtp(value)
: String(value || '').trim().replace(/[^\d]/g, '');
}
async function openGoPayOtpInputDialog(payload = {}) {
if (!sharedFormDialog?.open) {
throw new Error('验证码输入弹窗未加载,请刷新扩展后重试。');
}
const initialCode = normalizeGoPayOtpInputValue(payload.code || inputGoPayOtp?.value || latestState?.gopayOtp || '');
const result = await sharedFormDialog.open({
title: '输入 GoPay 验证码',
message: '请把当前 GoPay 页面收到的验证码填到这里,确认后插件会继续填写验证码并进入 PIN 步骤。',
confirmLabel: '提交验证码',
confirmVariant: 'btn-primary',
fields: [
{
key: 'code',
label: '验证码',
type: 'text',
required: true,
requiredMessage: '请输入 GoPay 验证码。',
placeholder: '请输入数字验证码',
inputMode: 'numeric',
autocomplete: 'one-time-code',
value: initialCode,
validate: (value) => {
const normalized = normalizeGoPayOtpInputValue(value);
if (!normalized) return '请输入 GoPay 验证码。';
if (normalized.length < 4) return 'GoPay 验证码长度过短,请检查。';
return '';
},
},
],
});
const code = normalizeGoPayOtpInputValue(result?.code || '');
if (!code) {
return { cancelled: true, code: '' };
}
if (inputGoPayOtp) {
inputGoPayOtp.value = code;
}
syncLatestState({ gopayOtp: code });
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => {});
return { code };
}
async function openCustomVerificationConfirmDialog(step) {
const promptCopy = getCustomVerificationPromptCopy(step);
if (step === 8 || step === 11) {
@@ -9401,6 +9539,31 @@ selectPlusPaymentMethod?.addEventListener('change', () => {
saveSettings({ silent: true }).catch(() => { });
});
selectPlusPaymentMethod?.addEventListener('change', () => {
updatePlusModeUI();
syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled?.checked), {
render: true,
plusPaymentMethod: selectPlusPaymentMethod.value,
});
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
[
selectGoPayCountryCode,
inputGoPayPhone,
inputGoPayOtp,
inputGoPayPin,
].forEach((input) => {
input?.addEventListener('input', () => {
markSettingsDirty(true);
scheduleSettingsAutoSave();
});
input?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
});
selectMailProvider.addEventListener('change', async () => {
const previousProvider = latestState?.mailProvider || '';
const previousMail2925Mode = latestState?.mail2925Mode;
@@ -10781,6 +10944,16 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return true;
}
case 'REQUEST_GOPAY_OTP_INPUT': {
(async () => {
const result = await openGoPayOtpInputDialog(message.payload || {});
sendResponse(result || { cancelled: true, code: '' });
})().catch((err) => {
sendResponse({ error: err.message });
});
return true;
}
case 'SECURITY_BLOCKED_ALERT': {
openConfirmModal({
title: message.payload?.title || '流程已完全停止',
+5
View File
@@ -9,6 +9,7 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(api.normalizeCountryCode('Deutschland'), 'DE');
assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU');
assert.equal(api.normalizeCountryCode('印尼'), 'ID');
assert.equal(api.normalizeCountryCode('日本'), 'JP');
assert.equal(api.normalizeCountryCode('unknown'), '');
@@ -22,6 +23,10 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(fallbackSeed.countryCode, 'AU');
assert.equal(fallbackSeed.fallback.region, 'New South Wales');
const idSeed = api.getAddressSeedForCountry('Indonesia');
assert.equal(idSeed.countryCode, 'ID');
assert.equal(idSeed.fallback.region, 'DKI Jakarta');
const jpSeed = api.getAddressSeedForCountry('日本');
assert.equal(jpSeed.countryCode, 'JP');
assert.equal(jpSeed.fallback.region, 'Tokyo');
+8
View File
@@ -12,5 +12,13 @@ test('background imports step registry and shared step definitions', () => {
assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/paypal-approve\.js/);
assert.match(source, /background\/steps\/gopay-approve\.js/);
assert.match(source, /background\/steps\/plus-return-confirm\.js/);
});
test('GoPay approve executor receives debugger click and manual OTP helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /createGoPayApproveExecutor\(\{[\s\S]*clickWithDebugger[\s\S]*requestGoPayOtpInput[\s\S]*\}\)/);
assert.match(source, /REQUEST_GOPAY_OTP_INPUT/);
});
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/gopay-approve.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) throw new Error(`missing function ${name}`);
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('GoPay OTP always requests manual confirmation even when a previous code exists', () => {
const body = extractFunction('requestManualGoPayOtp');
assert.doesNotMatch(body, /if\s*\(existingCode\)\s*\{\s*return existingCode;\s*\}/);
assert.match(body, /requestGoPayOtpInput\(\{ code: existingCode \}\)/);
assert.match(body, /检测到上次保存的 GoPay 验证码/);
});
test('GoPay approve handles final payment details iframe as an action frame', () => {
assert.match(source, /GOPAY_PAYMENT_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/details/);
assert.match(source, /app\\\/challenge/);
assert.match(source, /inspectGoPayFramesByDom/);
assert.match(source, /getGoPayDomFramePriority/);
assert.match(source, /paymentFrames/);
assert.match(source, /frameState\?\.hasPayNowButton/);
assert.match(source, /getGoPayDomFrameKind/);
assert.match(source, /return 'payment'/);
assert.match(source, /sendGoPayFrameCommand\(tabId, actionFrameId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /getGoPayDebuggerTargets/);
assert.match(source, /chrome\.debugger\.getTargets/);
assert.match(source, /targetId: picked\.targetId/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_SUBMIT_PIN'/);
assert.match(source, /Input\.insertText/);
assert.match(source, /最终 Bayar 确认/);
});
test('GoPay approve treats merchant validate-pin iframe as PIN entry frame', () => {
assert.match(source, /GOPAY_PIN_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/validate-pin/);
assert.match(source, /kind: 'pin'/);
assert.match(source, /GOPAY_SUBMIT_PIN/);
});
test('GoPay approve closes terminal checkout but does not restart on top-level Pay now alone', () => {
assert.match(source, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(source, /restartGoPayCheckoutFromStep6/);
assert.match(source, /chrome\?\.tabs\?\.remove/);
assert.match(source, /handleGoPayTerminalError\(pageState, tabId\)/);
assert.match(source, /nextState\.hasTerminalError/);
assert.doesNotMatch(source, /GoPay 顶层 Pay now 兜底点击后仍未进入下一步,当前支付会话需要重新创建/);
});
test('GoPay approve falls back to clicking Bayar inside any iframe before top-level Pay now retry', () => {
assert.match(source, /clickGoPayPayButtonInAnyFrame/);
assert.match(source, /data-testid/);
assert.match(source, /pay-button/);
assert.match(source, /已在 GoPay iframe 中点击 Bayar 按钮/);
assert.match(source, /不再自动回退步骤 6/);
});
test('GoPay approve does not treat phone linking page as debugger iframe action', () => {
assert.match(source, /type === 'tel'/);
assert.match(source, /const hasContinueButton = !hasPayNowButton && !hasPhoneInput/);
assert.match(source, /filter\(\(target\) => target\.type === 'iframe'\)/);
});
test('background auto-run routes GoPay restart sentinel back to step 6', () => {
const backgroundSource = fs.readFileSync('background.js', 'utf8');
assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/);
assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /step = 6/);
assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/);
});
+341
View File
@@ -0,0 +1,341 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/gopay-flow.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
} else if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('GoPay human click helper dispatches pointer and mouse sequence before native click', async () => {
const bundle = [
extractFunction('dispatchPointerMouseSequence'),
extractFunction('humanClickElement'),
].join('\n');
const events = [];
const button = {
tagName: 'BUTTON',
scrollIntoView() { events.push('scroll'); },
focus() { events.push('focus'); },
click() { events.push('native-click'); },
getBoundingClientRect() { return { left: 10, top: 20, width: 100, height: 40 }; },
dispatchEvent(event) {
events.push(event.type);
return true;
},
};
const api = new Function('button', 'events', `
const window = { screenX: 0, screenY: 0 };
class MouseEvent { constructor(type, init = {}) { this.type = type; this.init = init; } }
class PointerEvent extends MouseEvent {}
async function sleep() { events.push('sleep'); }
${bundle}
return { humanClickElement };
`)(button, events);
await api.humanClickElement(button, { beforeMs: 1, afterDispatchMs: 1, afterMs: 1 });
assert.deepEqual(events.slice(0, 3), ['scroll', 'sleep', 'focus']);
assert.ok(events.includes('pointerdown'));
assert.ok(events.includes('mousedown'));
assert.ok(events.includes('mouseup'));
assert.ok(events.includes('click'));
assert.equal(events.at(-2), 'native-click');
});
test('GoPay continue target exposes a debugger-clickable rect', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findContinueButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayContinueTarget'),
].join('\n');
const button = {
tagName: 'BUTTON',
id: 'link-and-pay',
className: 'btn primary',
textContent: 'Link and pay',
innerText: 'Link and pay',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'class') return this.className;
return '';
},
getBoundingClientRect() { return { left: 20, top: 30, width: 160, height: 44 }; },
};
const api = new Function('button', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 390,
innerHeight: 844,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [button] : [];
},
};
${bundle}
return { getGoPayContinueTarget };
`)(button);
const target = api.getGoPayContinueTarget();
assert.equal(target.found, true);
assert.equal(target.rect.centerX, 100);
assert.equal(target.rect.centerY, 52);
assert.match(target.target, /Link and pay/);
});
test('GoPay PIN page detection wins over generic pin-input OTP attributes', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isGoPayPinPageText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('isCountrySearchInput'),
extractFunction('getCombinedElementText'),
extractFunction('findInputByPatterns'),
extractFunction('findOtpInput'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
].join('\n');
const pinInputs = Array.from({ length: 6 }, (_, index) => ({
tagName: 'INPUT',
type: 'text',
id: '',
className: 'pin-input password',
textContent: '',
value: '',
placeholder: '○',
maxLength: 1,
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'maxlength') return '1';
if (name === 'data-testid') return `pin-input-${index}`;
if (name === 'class') return this.className;
if (name === 'placeholder') return this.placeholder;
return '';
},
getBoundingClientRect() { return { width: 40, height: 40 }; },
}));
const api = new Function('pinInputs', `
const location = { href: 'https://pin-web-client.gopayapi.com/auth/pin/verify' };
const window = { getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; } };
const document = {
body: { innerText: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN', textContent: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN' },
querySelectorAll(selector) { return selector.includes('input') ? pinInputs : []; },
};
${bundle}
return { isGoPayOtpPageText, isGoPayPinPageText, findOtpInput, findPinInput, getGoPayPinInputs };
`)(pinInputs);
assert.equal(api.isGoPayPinPageText(), true);
assert.equal(api.isGoPayOtpPageText(), false);
assert.equal(api.findOtpInput(), null);
assert.equal(api.findPinInput(), pinInputs[0]);
assert.equal(api.getGoPayPinInputs().length, 6);
});
test('GoPay Pay now button is detected separately from generic continue actions', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayPayNowTarget'),
].join('\n');
const payButton = {
tagName: 'BUTTON',
id: '',
className: 'btn full primary btn-theme',
textContent: 'Pay now',
innerText: 'Pay now',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 411, top: 689, width: 388, height: 38 }; },
};
const refreshButton = {
...payButton,
className: 'refresh-button',
textContent: 'Refresh',
innerText: 'Refresh',
getBoundingClientRect() { return { left: 705, top: 346, width: 90, height: 30 }; },
};
const api = new Function('payButton', 'refreshButton', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 1280,
innerHeight: 800,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [refreshButton, payButton] : [];
},
};
${bundle}
return { findPayNowButton, getGoPayPayNowTarget };
`)(payButton, refreshButton);
assert.equal(api.findPayNowButton(), payButton);
assert.equal(api.getGoPayPayNowTarget().found, true);
assert.match(api.getGoPayPayNowTarget().target, /Pay now/);
});
test('GoPay final Bayar amount button is detected without matching terms link', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
].join('\n');
const bayarButton = {
tagName: 'BUTTON',
className: 'bg-brand text-white',
textContent: 'Bayar\nRp 1',
innerText: 'Bayar\nRp 1',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 16, top: 556, width: 388, height: 44 }; },
};
const termsLink = {
...bayarButton,
tagName: 'A',
className: 'font-semibold text-brand cursor-pointer',
textContent: 'Syarat & Ketentuan',
innerText: 'Syarat & Ketentuan',
getBoundingClientRect() { return { left: 224, top: 608, width: 104, height: 16 }; },
};
const api = new Function('bayarButton', 'termsLink', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, bayarButton] : [];
},
};
${bundle}
return { findPayNowButton };
`)(bayarButton, termsLink);
assert.equal(api.findPayNowButton(), bayarButton);
});
test('GoPay terminal timeout page is reported as retry-required state', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayPinPageText'),
extractFunction('detectGoPayTerminalError'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('findInputByPatterns'),
extractFunction('findPhoneInput'),
extractFunction('isCountrySearchInput'),
extractFunction('findOtpInput'),
extractFunction('getCombinedElementText'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('findContinueButton'),
extractFunction('readSelectedCountryCodeText'),
extractFunction('inspectGoPayState'),
].join('\n');
const api = new Function(`
const location = { href: 'https://merchants-gws-app.gopayapi.com/app/challenge?reference=test' };
const window = { getComputedStyle() { return { display: 'none', visibility: 'hidden', opacity: '0' }; } };
const document = {
body: { innerText: 'Yah, waktunya habis\\nKalau kamu mau coba lagi, tutup halaman ini dan ulangi prosesnya dari awal, ya.', textContent: '' },
readyState: 'complete',
querySelectorAll() { return []; },
};
${bundle}
return { inspectGoPayState, detectGoPayTerminalError };
`)();
const state = api.inspectGoPayState();
assert.equal(state.hasTerminalError, true);
assert.equal(state.terminalError.code, 'expired');
assert.match(state.terminalError.message, /重新创建 Plus Checkout|超时/);
});
+15
View File
@@ -0,0 +1,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadGoPayUtils() {
const source = fs.readFileSync('gopay-utils.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.GoPayUtils;`)(globalScope);
}
test('GoPay utils normalize manual OTP input', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456');
assert.equal(api.normalizeGoPayOtp('abc'), '');
});
+195
View File
@@ -38,6 +38,121 @@ test('plus checkout content script can be injected repeatedly on the same page',
assert.equal(context.__MULTIPAGE_PLUS_CHECKOUT_READY__, true);
});
function createPlusCheckoutMessageHarness({ checkoutSessionId = 'cs_test_123' } = {}) {
const attrs = new Map();
let listener = null;
const fetchCalls = [];
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href: 'https://chatgpt.com/' },
window: {},
document: {
readyState: 'complete',
documentElement: {
getAttribute(name) {
return attrs.get(name) || null;
},
setAttribute(name, value) {
attrs.set(name, String(value));
},
},
},
chrome: {
runtime: {
onMessage: {
addListener(fn) {
listener = fn;
},
},
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
log() {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === '/api/auth/session') {
return {
ok: true,
status: 200,
json: async () => ({ accessToken: 'test-access-token' }),
};
}
if (url === 'https://chatgpt.com/backend-api/payments/checkout') {
return {
ok: true,
status: 200,
json: async () => ({ checkout_session_id: checkoutSessionId }),
};
}
throw new Error(`unexpected fetch url: ${url}`);
},
};
context.window = context;
vm.createContext(context);
vm.runInContext(source, context);
assert.equal(typeof listener, 'function');
async function send(message) {
return await new Promise((resolve) => {
listener(message, {}, resolve);
});
}
return { send, fetchCalls };
}
test('CREATE_PLUS_CHECKOUT keeps PayPal on DE/EUR and openai_ie merchant path by default', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_paypal' });
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: {},
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_ie/cs_paypal');
assert.equal(result.country, 'DE');
assert.equal(result.currency, 'EUR');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
assert.ok(checkoutCall);
assert.equal(checkoutCall.options.method, 'POST');
assert.equal(checkoutCall.options.headers.Authorization, 'Bearer test-access-token');
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.plan_name, 'chatgptplusplan');
assert.deepEqual(payload.billing_details, { country: 'DE', currency: 'EUR' });
});
test('CREATE_PLUS_CHECKOUT uses ID/IDR and openai_llc merchant path for GoPay', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_gopay' });
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: { paymentMethod: 'gopay' },
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_llc/cs_gopay');
assert.equal(result.country, 'ID');
assert.equal(result.currency, 'IDR');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
assert.ok(checkoutCall);
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.entry_point, 'all_plans_pricing_modal');
assert.equal(payload.checkout_ui_mode, 'custom');
assert.deepEqual(payload.billing_details, { country: 'ID', currency: 'IDR' });
assert.deepEqual(payload.promo_campaign, {
promo_campaign_id: 'plus-1-month-free',
is_coupon_from_query_param: false,
});
});
function extractFunction(name) {
const plainStart = source.indexOf(`function ${name}(`);
const asyncStart = source.indexOf(`async function ${name}(`);
@@ -251,6 +366,9 @@ test('getCheckoutAmountSummary accepts zero today due amount', () => {
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
@@ -260,9 +378,14 @@ test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
extractFunction('getVisibleControls'),
extractFunction('getVisibleTextInputs'),
extractFunction('isDocumentLevelContainer'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('getPayPalSearchCandidates'),
extractFunction('hasCreditCardFields'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedPayPalControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isPayPalPaymentMethodActive'),
].join('\n');
@@ -579,6 +702,78 @@ return { findCountryDropdown, findRegionDropdown, matchesCountryOption, matchesR
assert.equal(api.matchesRegionOption('東京都', 'Tokyo'), true);
});
test('payment method helpers can find and confirm selected GoPay controls', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('isDocumentLevelContainer'),
extractFunction('isPaymentCardSized'),
extractFunction('findInteractiveAncestor'),
extractFunction('findPaymentCardAncestor'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('getGoPaySearchCandidates'),
extractFunction('findPaymentMethodTarget'),
extractFunction('findGoPayPaymentMethodTarget'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedGoPayControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isGoPayPaymentMethodActive'),
].join('\n');
const gopayButton = createElement({
text: 'GoPay',
attrs: {
id: 'gopay-tab',
role: 'tab',
'data-testid': 'gopay',
'aria-selected': 'true',
value: 'gopay',
},
});
const elements = [gopayButton];
const documentMock = {
documentElement: {},
body: {},
querySelectorAll: (selector) => {
if (String(selector || '').includes('label[for=')) return [];
return elements;
},
};
const windowMock = {
innerWidth: 1200,
innerHeight: 900,
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
function findClickableByText(patterns) {
return elements.find((el) => patterns.some((pattern) => pattern.test(getCombinedSearchText(el)))) || null;
}
const elements = document.querySelectorAll('*');
${bundle}
return { findGoPayPaymentMethodTarget, getGoPaySearchCandidates, hasSelectedGoPayControl, isGoPayPaymentMethodActive };
`)(windowMock, documentMock, cssMock);
assert.equal(api.findGoPayPaymentMethodTarget(), gopayButton);
assert.equal(api.getGoPaySearchCandidates()[0], gopayButton);
assert.equal(api.hasSelectedGoPayControl(), true);
assert.equal(api.isGoPayPaymentMethodActive(), true);
});
test('fillIfEmpty can overwrite invalid structured address values in the dropdown branch', () => {
const bundle = [
extractFunction('fillIfEmpty'),
@@ -36,6 +36,20 @@ function createAuAddressSeed() {
};
}
function createIdAddressSeed() {
return {
countryCode: 'ID',
query: 'Jakarta Indonesia',
suggestionIndex: 1,
fallback: {
address1: 'Jalan M.H. Thamrin No. 1',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '10310',
},
};
}
function createSuccessfulBillingResult() {
return {
countryText: 'Germany',
@@ -54,6 +68,7 @@ function createExecutorHarness({
fetchImpl = null,
getAddressSeedForCountry = () => createAddressSeed(),
markCurrentRegistrationAccountUsed = async () => {},
submitRedirectUrl = 'https://www.paypal.com/checkoutnow',
}) {
const api = loadPlusCheckoutBillingModule();
const events = {
@@ -102,7 +117,7 @@ function createExecutorHarness({
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
checkoutTab.url = submitRedirectUrl;
}
return createSuccessfulBillingResult();
},
@@ -131,8 +146,8 @@ function createExecutorHarness({
waitForTabCompleteUntilStopped: async () => checkoutTab,
waitForTabUrlMatchUntilStopped: async (tabId, matcher) => {
events.waitedUrls.push({ tabId });
assert.equal(matcher('https://www.paypal.com/checkoutnow'), true);
return { id: tabId, url: 'https://www.paypal.com/checkoutnow' };
assert.equal(matcher(submitRedirectUrl), true);
return { id: tabId, url: submitRedirectUrl };
},
});
@@ -225,6 +240,106 @@ test('Plus checkout billing sends the billing command to the iframe that contain
assert.equal(events.completed[0].step, 7);
});
test('Plus checkout billing forces Indonesia address for GoPay even when page country differs', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'United States',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'ID' ? createIdAddressSeed() : createAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: 'Jl. M.H. Thamrin No. 10',
City: 'Jakarta',
State: 'DKI Jakarta',
Zip_Code: '10310',
},
}),
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay', plusCheckoutCountry: 'US' });
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'ID');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Jakarta',
path: '/id-address',
method: 'refresh',
});
});
test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async () => {
const { checkoutTab, events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
],
stateByFrame: {
0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: () => createIdAddressSeed(),
fetchImpl: async () => ({
ok: false,
status: 404,
json: async () => ({ status: 'error' }),
}),
submitRedirectUrl: 'https://gopay.co.id/payment/session',
});
await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay' });
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_GOPAY');
const paypalSelectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(selectMessage.frameId, 7);
assert.equal(selectMessage.message.payload.paymentMethod, 'gopay');
assert.equal(paypalSelectMessage, undefined);
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay');
assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session');
assert.equal(events.completed[0].step, 7);
});
test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => {
const { events, executor } = createExecutorHarness({
frames: [
+9 -1
View File
@@ -15,12 +15,20 @@ test('sidepanel loads reusable form dialog and paypal manager before sidepanel b
assert.ok(managerIndex < sidepanelIndex);
});
test('sidepanel html contains paypal select and add button controls', () => {
test('sidepanel html contains paypal select and GoPay controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-plus-payment-method"/);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="row-gopay-phone"/);
assert.match(html, /id="input-gopay-phone"/);
assert.match(html, /id="row-gopay-otp"/);
assert.match(html, /id="input-gopay-otp"/);
assert.match(html, /id="row-gopay-pin"/);
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /id="shared-form-modal"/);
});
+8 -3
View File
@@ -55,6 +55,10 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
);
assert.equal(plusSteps.some((step) => step.key === 'clear-login-cookies'), false);
assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), true);
assert.equal(plusSteps.find((step) => step.key === 'paypal-approve')?.title, 'PayPal 登录与授权');
const goPaySteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gopay' });
assert.equal(goPaySteps.find((step) => step.key === 'paypal-approve')?.title, 'GoPay 手机验证与授权');
assert.equal(api.getStepById(8, { plusModeEnabled: true, plusPaymentMethod: 'gopay' })?.title, 'GoPay 手机验证与授权');
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13);
assert.equal(plusSteps[5].title, '创建 Plus Checkout');
@@ -92,13 +96,14 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
assert.ok(definitionsIndex < sidepanelIndex);
});
test('sidepanel html exposes Plus mode payment controls and PayPal settings', () => {
test('sidepanel html exposes Plus mode, PayPal, and GoPay settings', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="input-plus-mode-enabled"/);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /<option value="paypal">PayPal 支付<\/option>/);
assert.match(html, /<option value="gopay">GoPay 支付<\/option>/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="input-gopay-phone"/);
assert.match(html, /id="input-gopay-otp"/);
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /id="shared-form-modal"/);
});
+6 -5
View File
@@ -44,7 +44,7 @@
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro``v` 版本误显示为比 `Ultra` 更新
- 展示一个单独的“接码”开关与“接码平台”下拉;接码平台当前支持 HeroSMS / 5sim,开启后才展示所选平台的国家、API Key、价格上限等设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证
- 展示 `Plus 模式` 开关与 PayPal 账号池配置;开启后 PayPal 配置行会切换为“账号下拉框 + 添加按钮”,添加按钮复用公共表单弹窗录入账号和密码;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
- 展示 `Plus 模式` 开关与 Plus 支付方式配置;支付方式支持 PayPal / GoPayPayPal 展示账号下拉与添加按钮,GoPay 展示手机号和 PIN;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
### 2.2 Background Service Worker
@@ -160,6 +160,7 @@
- Codex2API 配置
- IP 代理持久配置:`ipProxyEnabled`、服务商、模式、API 地址、服务商配置快照、账号列表、固定 Host / Port / Protocol / Username / Password、地区参数、session 与自动切换阈值
- Plus 模式开关 `plusModeEnabled`
- Plus 支付方式 `plusPaymentMethod`GoPay 配置 `gopayPhone / gopayPin`
- PayPal 账号池配置 `paypalAccounts / currentPayPalAccountId`,以及供后台步骤兼容读取的 `paypalEmail / paypalPassword`
- 邮箱 provider 配置
- Hotmail 账号池
@@ -499,10 +500,10 @@ Plus 模式通过 `plusModeEnabled` 开启,目标是在普通注册资料完
Plus 模式可见步骤:
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`
3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frameGoogle 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。
4. 第 8 步 `PayPal 登录与授权`:后台优先读取侧边栏当前选中的 PayPal 账号;为兼容旧链路,也会把该账号同步回 `paypalEmail / paypalPassword`。当页面处于账号输入阶段时,内容脚本会固定对邮箱输入框执行 `focus -> clear -> fill -> blur -> click next`,即使文本框里已经预填了同一个账号,也会重新触发输入事件,避免 PayPal 因跳过重填而停在邮箱页;登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”
5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session。PayPal 使用 `DE / EUR` `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`GoPay 使用 `ID / IDR``https://chatgpt.com/checkout/openai_llc/{checkout_session_id}`
3. 第 7 步 `填写账单并提交订阅``plusPaymentMethod` 选择 PayPal 或 GoPay,生成账单全名,从 `data/address-sources.js` 读取地址 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。GoPay 模式固定使用印尼地址,不根据 IP 国家推断。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frameGoogle 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。
4. 第 8 步按当前支付方式显示为 `PayPal 登录与授权``GoPay 手机验证与授权`,底层 step key 仍为 `paypal-approve`。PayPal 模式后台优先读取侧边栏当前选中的 PayPal 账号;GoPay 模式读取侧边栏的国家区号、手机号、可选验证码和 PIN,先在 GoPay 页面填写手机号;验证码优先用侧边栏已填值,否则弹出插件输入框让用户手动填写,提交后继续填写 PIN
5. 第 9 步 `订阅回跳确认`:等待 PayPal / GoPay 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
7. 第 11 步:复用原 Step 8 登录验证码执行器,但状态和日志按 Plus 可见第 11 步记录。
8. 第 12 步:复用原 Step 9 OAuth 同意页点击和 localhost callback 捕获执行器,但状态和日志按 Plus 可见第 12 步记录。