diff --git a/background.js b/background.js index 8fb26ce..dbd29e0 100644 --- a/background.js +++ b/background.js @@ -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(`步骤 8:GoPay 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), diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 2af4366..88e8cad 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -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(`步骤 6:Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步。`, 'info'); await completeStepFromBackground(6, { plusCheckoutCountry: result.country || 'DE', diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 28fdc18..5ad8c20 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -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 所在 iframe(frameId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`); + throw new Error(`步骤 7:已定位到 ${paymentConfig.label} 所在 iframe(frameId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`); } if (paymentFrame.frameId !== 0) { - await addLog(`步骤 7:PayPal 位于 checkout iframe(frameId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info'); + await addLog(`步骤 7:${paymentConfig.label} 位于 checkout iframe(frameId=${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 iframe(frameId=${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, { diff --git a/background/steps/gopay-approve.js b/background/steps/gopay-approve.js new file mode 100644 index 0000000..2722de6 --- /dev/null +++ b/background/steps/gopay-approve.js @@ -0,0 +1,1335 @@ +(function attachBackgroundGoPayApprove(root, factory) { + root.MultiPageBackgroundGoPayApprove = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundGoPayApproveModule() { + const GOPAY_SOURCE = 'gopay-flow'; + const GOPAY_OTP_SOURCE = 'gopay-otp-flow'; + const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + const GOPAY_INJECT_FILES = ['content/utils.js', 'content/gopay-flow.js']; + const GOPAY_WAIT_TIMEOUT_MS = 120000; + const GOPAY_POLL_INTERVAL_MS = 1000; + const GOPAY_OTP_FRAME_URL_PATTERN = /\/linking\/otp\b|gopayapi\.com\/linking\/otp/i; + const GOPAY_PIN_FRAME_URL_PATTERN = /pin-web-client\.gopayapi\.com\/auth\/pin|\/auth\/pin\/verify|linking-validate-pin|merchants-gws-app\.gopayapi\.com\/payment\/validate-pin|\/payment\/validate-pin/i; + const GOPAY_PAYMENT_FRAME_URL_PATTERN = /merchants-gws-app\.gopayapi\.com\/(?:payment\/details|app\/challenge)|\/gopay-tokenization\/pay/i; + + function createGoPayApproveExecutor(deps = {}) { + const { + addLog, + chrome, + completeStepFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + registerTab, + sendTabMessageUntilStopped, + setState, + sleepWithStop, + waitForTabCompleteUntilStopped, + clickWithDebugger = null, + requestGoPayOtpInput = null, + throwIfStopped = null, + } = deps; + + function normalizeText(value = '') { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function formatGoPayTerminalError(pageState = {}) { + const terminalError = pageState?.terminalError || {}; + const terminalMessage = normalizeText(terminalError.message || 'GoPay 页面显示支付失败或会话已失效。'); + const rawText = normalizeText(terminalError.rawText || pageState?.textPreview || ''); + const rawSuffix = rawText ? ` 页面提示:${rawText.slice(0, 180)}` : ''; + return `${terminalMessage}${rawSuffix}`; + } + + async function restartGoPayCheckoutFromStep6(tabId, reason = '') { + const message = normalizeText(reason || 'GoPay 支付页已失效或点击后没有进入下一步。'); + await addLog(`步骤 8:${message} 正在关闭当前 GoPay/Checkout 页面,并回到步骤 6 重新创建 Plus Checkout。`, 'warn'); + if (Number.isInteger(tabId) && chrome?.tabs?.remove) { + await chrome.tabs.remove(tabId).catch(() => {}); + } + await setState({ + plusCheckoutTabId: null, + plusCheckoutUrl: null, + plusReturnUrl: '', + plusPaypalApprovedAt: null, + plusGoPayApprovedAt: null, + }); + throw new Error(`GOPAY_RESTART_FROM_STEP6::步骤 8:${message} 已关闭当前支付页,请从步骤 6 重新创建 Plus Checkout。`); + } + + async function handleGoPayTerminalError(pageState = {}, tabId = null) { + if (pageState?.hasTerminalError || pageState?.terminalError) { + await restartGoPayCheckoutFromStep6(tabId, formatGoPayTerminalError(pageState)); + } + } + + function isGoPayUrl(url = '') { + return /gopay|gojek|midtrans|xendit|stripe|checkout/i.test(String(url || '')) + && !/chatgpt\.com\/checkout/i.test(String(url || '')); + } + + function isReturnUrl(url = '') { + return /https:\/\/(?:chatgpt\.com|chat\.openai\.com|openai\.com)\//i.test(String(url || '')) + && !/gopay|gojek|midtrans|xendit|stripe/i.test(String(url || '')); + } + + async function findOpenTabId(predicate) { + if (!chrome?.tabs?.query) { + return 0; + } + const tabs = await chrome.tabs.query({}).catch(() => []); + const candidates = (Array.isArray(tabs) ? tabs : []) + .filter((tab) => Number.isInteger(tab?.id) && predicate(tab.url || '')); + if (!candidates.length) { + return 0; + } + const match = candidates.find((tab) => tab.active && tab.currentWindow) + || candidates.find((tab) => tab.active) + || candidates[0]; + if (match?.id && chrome?.tabs?.update) { + await chrome.tabs.update(match.id, { active: true }).catch(() => {}); + } + return match?.id || 0; + } + + async function resolveGoPayTabId(state = {}) { + const registeredGoPayTabId = await getTabId(GOPAY_SOURCE); + if (registeredGoPayTabId && await isTabAlive(GOPAY_SOURCE)) { + return registeredGoPayTabId; + } + const discoveredGoPayTabId = await findOpenTabId(isGoPayUrl); + if (discoveredGoPayTabId) { + await addLog('步骤 8:已从当前浏览器标签中发现 GoPay 页面,正在接管继续执行。', 'info'); + if (typeof registerTab === 'function') { + await registerTab(GOPAY_SOURCE, discoveredGoPayTabId); + } + return discoveredGoPayTabId; + } + const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE); + if (checkoutTabId) { + if (typeof registerTab === 'function') { + await registerTab(GOPAY_SOURCE, checkoutTabId); + } + return checkoutTabId; + } + const storedTabId = Number(state.plusCheckoutTabId) || 0; + if (storedTabId) { + if (typeof registerTab === 'function') { + await registerTab(GOPAY_SOURCE, storedTabId); + } + return storedTabId; + } + throw new Error('步骤 8:未找到 GoPay 标签页,请先完成步骤 7。'); + } + + async function ensureGoPayReady(tabId, logMessage = '') { + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(800); + await ensureContentScriptReadyOnTabUntilStopped(GOPAY_SOURCE, tabId, { + inject: GOPAY_INJECT_FILES, + injectSource: GOPAY_SOURCE, + logMessage: logMessage || '步骤 8:GoPay 页面仍在加载,等待脚本就绪...', + }); + } + + async function getTabFrames(tabId) { + if (!chrome?.webNavigation?.getAllFrames) { + return [{ frameId: 0, url: '' }]; + } + const frames = await chrome.webNavigation.getAllFrames({ tabId }).catch(() => null); + if (!Array.isArray(frames) || !frames.length) { + return [{ frameId: 0, url: '' }]; + } + return frames + .filter((frame) => Number.isInteger(frame?.frameId)) + .sort((left, right) => Number(left.frameId) - Number(right.frameId)); + } + + function isGoPayOtpFrameUrl(url = '') { + return GOPAY_OTP_FRAME_URL_PATTERN.test(String(url || '')); + } + + function isGoPayPinFrameUrl(url = '') { + return GOPAY_PIN_FRAME_URL_PATTERN.test(String(url || '')); + } + + function isGoPayPaymentFrameUrl(url = '') { + return GOPAY_PAYMENT_FRAME_URL_PATTERN.test(String(url || '')); + } + + async function inspectGoPayFramesByDom(tabId) { + if (!chrome?.scripting?.executeScript) { + return []; + } + try { + const results = await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: () => { + const normalize = (value = '') => String(value || '').replace(/\s+/g, ' ').trim(); + const visible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect?.(); + const style = window.getComputedStyle?.(el); + return Boolean(rect && rect.width > 0 && rect.height > 0) + && (!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0)); + }; + const controls = Array.from(document.querySelectorAll('button, a, [role="button"], input[type="button"], input[type="submit"]')) + .filter((el) => visible(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'); + const inputs = Array.from(document.querySelectorAll('input, textarea')).filter(visible); + const text = normalize(document.body?.innerText || document.body?.textContent || ''); + const controlTexts = controls.map((el) => normalize([ + el.textContent, + el.value, + el.getAttribute?.('data-testid'), + el.getAttribute?.('aria-label'), + el.getAttribute?.('title'), + el.id, + ].filter(Boolean).join(' '))); + const inputHints = inputs.map((el) => normalize([ + el.getAttribute?.('data-testid'), + el.getAttribute?.('aria-label'), + el.getAttribute?.('placeholder'), + el.getAttribute?.('name'), + el.id, + el.className, + el.type, + el.inputMode, + ].filter(Boolean).join(' '))); + const hasTerminalError = /waktunya\s+habis|ulang(?:i)?\s+prosesnya\s+dari\s+awal|time(?:'s|\s+is)?\s+(?:out|expired)|session\s+expired|expired|technical\s+error|terjadi\s+kesalahan|payment\s+failed|pembayaran\s+gagal|transaksi\s+gagal|declined|failed/i.test(text); + const hasPinInput = /pin|6\s*digit|masukkin\s+pin|ketik\s+6\s+digit/i.test(text) + || inputHints.some((hint) => /pin-input|pin|password|numeric/i.test(hint)); + const hasOtpInput = !hasPinInput && (/otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text) + || inputHints.some((hint) => /otp|code|kode|verification|whatsapp/i.test(hint))); + const hasPayNowButton = controlTexts.some((item) => /^\s*pay\s+now\s*$/i.test(item) + || /^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i.test(item) + || /(?:^|\s)pay-button(?:\s|$)/i.test(item)); + const hasContinueButton = controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item)); + return { + url: location.href, + hasTerminalError, + hasPinInput, + hasOtpInput, + hasPayNowButton, + hasContinueButton, + textPreview: text.slice(0, 240), + }; + }, + }); + return (Array.isArray(results) ? results : []) + .filter((item) => Number.isInteger(item?.frameId) && item.result) + .map((item) => ({ + frameId: item.frameId, + ...(item.result || {}), + })); + } catch (_) { + return []; + } + } + + function getGoPayDomFrameKind(frame = {}) { + if (frame.hasPinInput) return 'pin'; + if (frame.hasOtpInput) return 'otp'; + if (frame.hasPayNowButton) return 'payment'; + if (frame.hasContinueButton) return 'payment'; + if (frame.hasTerminalError) return 'payment'; + return ''; + } + + function getGoPayDomFramePriority(frame = {}) { + if (frame.hasTerminalError) return 120; + if (frame.hasPinInput) return 110; + if (frame.hasOtpInput) return 100; + if (frame.hasPayNowButton) return 90; + if (frame.hasContinueButton) return 80; + return 0; + } + + function isGoPayDebuggerTargetUrl(url = '') { + const value = String(url || ''); + return isGoPayOtpFrameUrl(value) + || isGoPayPinFrameUrl(value) + || isGoPayPaymentFrameUrl(value) + || /gopayapi\.com|app\.midtrans\.com\/snap/i.test(value); + } + + function getGoPayDebuggerProbePriority(probe = {}) { + const domPriority = getGoPayDomFramePriority(probe); + const url = String(probe.url || ''); + const typeBonus = probe.type === 'iframe' ? 30 : 0; + const urlBonus = isGoPayPinFrameUrl(url) + ? 12 + : (isGoPayPaymentFrameUrl(url) ? 10 : (isGoPayOtpFrameUrl(url) ? 8 : 0)); + return domPriority + typeBonus + urlBonus; + } + + async function getGoPayDebuggerTargets(tabId) { + if (!chrome?.debugger?.getTargets) { + return []; + } + const targets = await chrome.debugger.getTargets().catch(() => []); + return (Array.isArray(targets) ? targets : []) + .filter((target) => target?.id && isGoPayDebuggerTargetUrl(target.url || target.title || '')) + .filter((target) => { + if (!Number.isInteger(tabId) || !Number.isInteger(target.tabId)) { + return true; + } + return target.tabId === tabId; + }); + } + + function buildGoPayDebuggerStateExpression() { + return `(() => { + const normalize = (value = '') => String(value || '').replace(/\\s+/g, ' ').trim(); + const visible = (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 && (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden' || Number(nodeStyle.opacity) === 0)) { + return false; + } + node = node.parentElement; + } + const rect = el.getBoundingClientRect?.(); + return Boolean(rect && rect.width > 0 && rect.height > 0); + }; + const enabled = (el) => Boolean(el) + && !el.disabled + && el.getAttribute?.('aria-disabled') !== 'true'; + const getText = (el) => normalize([ + el?.textContent, + el?.innerText, + el?.value, + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.getAttribute?.('placeholder'), + el?.getAttribute?.('name'), + el?.id, + typeof el?.className === 'string' ? el.className : el?.getAttribute?.('class'), + el?.type, + el?.inputMode, + ].filter(Boolean).join(' ')); + const bodyText = normalize(document.body?.innerText || document.body?.textContent || ''); + const controls = Array.from(document.querySelectorAll('button, a, [role="button"], input[type="button"], input[type="submit"]')) + .filter((el) => visible(el) && enabled(el)); + const inputs = Array.from(document.querySelectorAll('input, textarea')) + .filter((el) => visible(el) && enabled(el)); + const controlTexts = controls.map(getText); + const inputHints = inputs.map(getText); + const isPinPage = /pin|password|passcode|security|sandi|6\\s*digit|masukkin\\s+pin|ketik\\s+6\\s+digit/i.test(bodyText) + || /pin-web-client\\.gopayapi\\.com|\\/auth\\/pin|\\/payment\\/validate-pin/i.test(location.href || ''); + const hasTerminalError = /waktunya\\s+habis|ulang(?:i)?\\s+prosesnya\\s+dari\\s+awal|time(?:'s|\\s+is)?\\s+(?:out|expired)|session\\s+expired|expired|kedaluwarsa|technical\\s+error|terjadi\\s+kesalahan|error\\s+teknis|kendala\\s+teknis|gak\\s+bisa\\s+diproses|coba\\s+lagi\\s+nanti|payment\\s+failed|pembayaran\\s+gagal|transaksi\\s+gagal|ditolak|declined|failed/i.test(bodyText); + const hasPinInput = isPinPage || inputHints.some((hint) => /pin-input|pin|password|numeric/i.test(hint)); + const hasOtpInput = !hasPinInput && (/otp|one[-\\s]*time|kode|verification|whatsapp|验证码|短信/i.test(bodyText) + || inputHints.some((hint) => /otp|code|kode|verification|whatsapp/i.test(hint))); + const hasPhoneInput = !hasOtpInput && !hasPinInput && inputs.some((input) => { + const hint = getText(input); + const type = String(input.type || input.getAttribute?.('type') || '').toLowerCase(); + return type === 'tel' + || /gopay|go\\s*pay|phone|mobile|whatsapp|wa|nomor|ponsel|telepon|hp|手机|手机号|电话号码|电话/i.test(hint); + }); + const hasPayNowButton = controlTexts.some((item) => /^\\s*pay\\s+now\\s*$/i.test(item) + || /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(item) + || /(?:^|\\s)pay-button(?:\\s|$)/i.test(item)); + const hasContinueButton = !hasPayNowButton && !hasPhoneInput && controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item)); + const completed = /success|successful|completed|selesai|berhasil|approved|authorized|支付成功|绑定成功|已授权/i.test(bodyText) + && !hasPhoneInput + && !hasOtpInput + && !hasPinInput; + return { + url: location.href, + readyState: document.readyState, + hasTerminalError, + hasPhoneInput, + hasPinInput, + hasOtpInput, + hasPayNowButton, + hasContinueButton, + completed, + textPreview: bodyText.slice(0, 500), + inputHints: inputHints.slice(0, 12), + }; + })()`; + } + + function buildGoPayDebuggerClickPayExpression() { + return `(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const normalize = (value = '') => String(value || '').replace(/\\s+/g, ' ').trim(); + const visible = (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 && (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden' || Number(nodeStyle.opacity) === 0)) { + return false; + } + node = node.parentElement; + } + const rect = el.getBoundingClientRect?.(); + return Boolean(rect && rect.width > 0 && rect.height > 0); + }; + const getText = (el) => normalize([ + el?.textContent, + el?.innerText, + el?.value, + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.id, + ].filter(Boolean).join(' ')); + const candidates = Array.from(document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')) + .filter((el) => visible(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'); + const target = candidates.find((el) => { + const text = getText(el); + return /(?:^|\\s)pay-button(?:\\s|$)/i.test(text) + || /^\\s*pay\\s+now\\s*$/i.test(text) + || /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(text); + }); + if (!target) { + return { clicked: false, reason: 'target_not_found', url: location.href, textPreview: normalize(document.body?.innerText || '').slice(0, 240) }; + } + target.scrollIntoView?.({ block: 'center', inline: 'center' }); + await sleep(120); + try { target.focus?.({ preventScroll: true }); } catch (_) { try { target.focus?.(); } catch (__) {} } + 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, clientX, clientY, button: 0, buttons: 1 }; + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerover', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true })); + 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('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 })); + target.click?.(); + await sleep(500); + return { + clicked: true, + url: location.href, + target: \`\${String(target.tagName || '').toUpperCase()} \${getText(target)}\`.trim(), + rect: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height, centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 } : null, + }; + })()`; + } + + function buildGoPayDebuggerClickContinueExpression() { + return `(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const normalize = (value = '') => String(value || '').replace(/\\s+/g, ' ').trim(); + const visible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect?.(); + const style = window.getComputedStyle?.(el); + return Boolean(rect && rect.width > 0 && rect.height > 0) + && (!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0)); + }; + const getText = (el) => normalize([ + el?.textContent, + el?.innerText, + el?.value, + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.id, + ].filter(Boolean).join(' ')); + const candidates = Array.from(document.querySelectorAll('button, a, [role="button"], input[type="button"], input[type="submit"]')) + .filter((el) => visible(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'); + const target = candidates.find((el) => { + const text = getText(el); + if (/^\\s*pay\\s+now\\s*$/i.test(text) || /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(text)) { + return false; + } + return /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(text); + }); + if (!target) { + return { clicked: false, reason: 'target_not_found', url: location.href, textPreview: normalize(document.body?.innerText || '').slice(0, 240) }; + } + target.scrollIntoView?.({ block: 'center', inline: 'center' }); + await sleep(120); + try { target.focus?.({ preventScroll: true }); } catch (_) { try { target.focus?.(); } catch (__) {} } + 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, clientX, clientY, button: 0, buttons: 1 }; + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true })); + } + 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 })); + target.click?.(); + await sleep(500); + return { + clicked: true, + url: location.href, + target: \`\${String(target.tagName || '').toUpperCase()} \${getText(target)}\`.trim(), + }; + })()`; + } + + function buildGoPayDebuggerFocusPinExpression() { + return `(() => { + const visible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect?.(); + const style = window.getComputedStyle?.(el); + return Boolean(rect && rect.width > 0 && rect.height > 0) + && (!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0)); + }; + const inputs = Array.from(document.querySelectorAll('input, textarea')).filter((el) => visible(el) && !el.disabled); + const target = inputs.find((el) => /pin/i.test([ + el.getAttribute?.('data-testid'), + el.getAttribute?.('aria-label'), + el.getAttribute?.('placeholder'), + el.getAttribute?.('name'), + el.id, + el.className, + el.type, + el.inputMode, + ].filter(Boolean).join(' '))) || inputs.find((el) => String(el.type || '').toLowerCase() === 'password') || inputs[0]; + if (!target) { + return { focused: false, reason: 'pin_input_not_found', textPreview: String(document.body?.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 240) }; + } + target.scrollIntoView?.({ block: 'center', inline: 'center' }); + try { target.focus?.({ preventScroll: true }); } catch (_) { try { target.focus?.(); } catch (__) {} } + target.click?.(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + if (setter) { + setter.call(target, ''); + } else { + target.value = ''; + } + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + const rect = target.getBoundingClientRect?.(); + return { + focused: true, + target: \`\${String(target.tagName || '').toUpperCase()} \${target.getAttribute?.('data-testid') || target.type || ''}\`.trim(), + rect: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height, centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 } : null, + }; + })()`; + } + + function buildGoPayDebuggerFocusOtpExpression() { + return `(() => { + const normalize = (value = '') => String(value || '').replace(/\\s+/g, ' ').trim(); + const visible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect?.(); + const style = window.getComputedStyle?.(el); + return Boolean(rect && rect.width > 0 && rect.height > 0) + && (!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0)); + }; + const inputs = Array.from(document.querySelectorAll('input, textarea')).filter((el) => visible(el) && !el.disabled); + const target = inputs.find((el) => /otp|one[-\\s]*time|kode|verification|whatsapp|code|pin-input-field/i.test([ + el.getAttribute?.('data-testid'), + el.getAttribute?.('aria-label'), + el.getAttribute?.('placeholder'), + el.getAttribute?.('name'), + el.id, + el.className, + el.type, + el.inputMode, + ].filter(Boolean).join(' '))) || inputs[0]; + if (!target) { + return { focused: false, reason: 'otp_input_not_found', textPreview: normalize(document.body?.innerText || '').slice(0, 240) }; + } + target.scrollIntoView?.({ block: 'center', inline: 'center' }); + try { target.focus?.({ preventScroll: true }); } catch (_) { try { target.focus?.(); } catch (__) {} } + target.click?.(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + if (setter) { + setter.call(target, ''); + } else { + target.value = ''; + } + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + const rect = target.getBoundingClientRect?.(); + return { + focused: true, + target: \`\${String(target.tagName || '').toUpperCase()} \${target.getAttribute?.('data-testid') || target.type || ''}\`.trim(), + rect: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height, centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 } : null, + }; + })()`; + } + + async function withDebuggerTarget(targetId, callback) { + if (!chrome?.debugger?.attach || !chrome?.debugger?.sendCommand || !targetId) { + throw new Error('debugger_target_unavailable'); + } + const target = { targetId }; + let attached = false; + try { + await chrome.debugger.attach(target, '1.3'); + attached = true; + } catch (err) { + throw new Error(`GoPay iframe 调试器附加失败:${err?.message || String(err || 'unknown_error')}`); + } + try { + return await callback(target); + } finally { + if (attached) { + await chrome.debugger.detach(target).catch(() => {}); + } + } + } + + async function evaluateGoPayDebuggerTarget(targetId, expression) { + return withDebuggerTarget(targetId, async (debuggee) => { + const result = await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result?.exceptionDetails) { + throw new Error(result.exceptionDetails?.text || 'GoPay iframe 脚本执行失败'); + } + return result?.result?.value || {}; + }); + } + + async function typeDigitsWithDebugger(debuggee, digits, delayMs = 180) { + for (const digit of String(digits || '').split('')) { + throwIfStopped?.(); + await chrome.debugger.sendCommand(debuggee, 'Input.insertText', { text: digit }); + await sleepWithStop(delayMs); + } + } + + async function submitGoPayOtpWithDebuggerTarget(targetId, code = '') { + const normalizedCode = normalizeGoPayOtp(code); + if (!normalizedCode) { + throw new Error('GoPay WhatsApp 验证码为空。'); + } + return withDebuggerTarget(targetId, async (debuggee) => { + const focused = await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: buildGoPayDebuggerFocusOtpExpression(), + returnByValue: true, + awaitPromise: true, + }); + const focusValue = focused?.result?.value || {}; + if (!focusValue.focused) { + throw new Error(focusValue.reason || 'GoPay 验证码输入框未找到'); + } + await sleepWithStop(200); + await typeDigitsWithDebugger(debuggee, normalizedCode, 160); + await sleepWithStop(500); + const continueResult = await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: buildGoPayDebuggerClickContinueExpression(), + returnByValue: true, + awaitPromise: true, + }).catch(() => null); + const continueValue = continueResult?.result?.value || {}; + return { + otpSubmitted: true, + clicked: Boolean(continueValue.clicked), + clickTarget: continueValue.target || '', + phase: 'otp_submitted_debugger_target', + target: focusValue.target || '', + }; + }); + } + + async function submitGoPayPinWithDebuggerTarget(targetId, pin = '') { + const normalizedPin = normalizeGoPayOtp(pin); + if (!normalizedPin) { + throw new Error('GoPay PIN 为空,请先在侧边栏配置。'); + } + return withDebuggerTarget(targetId, async (debuggee) => { + const focused = await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: buildGoPayDebuggerFocusPinExpression(), + returnByValue: true, + awaitPromise: true, + }); + const focusValue = focused?.result?.value || {}; + if (!focusValue.focused) { + throw new Error(focusValue.reason || 'GoPay PIN 输入框未找到'); + } + await sleepWithStop(250); + await typeDigitsWithDebugger(debuggee, normalizedPin, 220); + await sleepWithStop(500); + return { + pinSubmitted: true, + clicked: false, + clickTarget: '', + phase: 'pin_submitted_debugger_target', + target: focusValue.target || '', + }; + }); + } + + async function inspectGoPayDebuggerTargets(tabId) { + const targets = await getGoPayDebuggerTargets(tabId); + const probes = []; + for (const target of targets) { + try { + const state = await evaluateGoPayDebuggerTarget(target.id, buildGoPayDebuggerStateExpression()); + const probe = { + targetId: target.id, + type: target.type || '', + title: target.title || '', + tabId: target.tabId, + url: state.url || target.url || '', + ...state, + }; + if (getGoPayDebuggerProbePriority(probe) > 0) { + probes.push(probe); + } + } catch (_) { + // Ignore targets that are navigating or cannot be attached at the moment. + } + } + return probes.sort((left, right) => getGoPayDebuggerProbePriority(right) - getGoPayDebuggerProbePriority(left)); + } + + async function sendGoPayDebuggerTargetCommand(targetId, type, payload = {}) { + if (type === 'GOPAY_GET_STATE') { + return evaluateGoPayDebuggerTarget(targetId, buildGoPayDebuggerStateExpression()); + } + if (type === 'GOPAY_CLICK_PAY_NOW') { + const result = await evaluateGoPayDebuggerTarget(targetId, buildGoPayDebuggerClickPayExpression()); + if (!result?.clicked) { + return { clicked: false, clickTarget: '', reason: result?.reason || 'target_not_found' }; + } + return { + clicked: true, + clickTarget: result.target || 'GoPay iframe Bayar', + phase: 'pay_clicked_debugger_target', + }; + } + if (type === 'GOPAY_CLICK_CONTINUE') { + const result = await evaluateGoPayDebuggerTarget(targetId, buildGoPayDebuggerClickContinueExpression()); + if (!result?.clicked) { + return { clicked: false, clickTarget: '', reason: result?.reason || 'target_not_found' }; + } + return { + clicked: true, + clickTarget: result.target || 'GoPay iframe continue', + phase: 'continue_clicked_debugger_target', + }; + } + if (type === 'GOPAY_SUBMIT_OTP') { + return submitGoPayOtpWithDebuggerTarget(targetId, payload.code || payload.otp || ''); + } + if (type === 'GOPAY_SUBMIT_PIN') { + return submitGoPayPinWithDebuggerTarget(targetId, payload.pin || payload.gopayPin || ''); + } + throw new Error(`GoPay iframe 调试器暂不支持命令:${type}`); + } + + async function findGoPayActionFrame(tabId) { + const frames = await getTabFrames(tabId); + const domFrames = (await inspectGoPayFramesByDom(tabId)) + .filter((item) => Number.isInteger(item.frameId) && item.frameId !== 0 && getGoPayDomFramePriority(item) > 0) + .sort((left, right) => getGoPayDomFramePriority(right) - getGoPayDomFramePriority(left)); + if (domFrames.length) { + const picked = domFrames[0]; + return { + frameId: picked.frameId, + kind: getGoPayDomFrameKind(picked), + url: picked.url || frames.find((item) => item.frameId === picked.frameId)?.url || '', + }; + } + + const debuggerFrames = (await inspectGoPayDebuggerTargets(tabId)) + .filter((target) => target.type === 'iframe'); + if (debuggerFrames.length) { + const picked = debuggerFrames[0]; + return { + frameId: null, + targetId: picked.targetId, + kind: getGoPayDomFrameKind(picked), + url: picked.url || '', + via: 'debugger-target', + }; + } + + const pinFrame = frames.find((item) => isGoPayPinFrameUrl(item.url)); + if (pinFrame && Number.isInteger(pinFrame.frameId)) { + return { frameId: pinFrame.frameId, kind: 'pin', url: pinFrame.url || '' }; + } + const otpFrame = frames.find((item) => isGoPayOtpFrameUrl(item.url)); + if (otpFrame && Number.isInteger(otpFrame.frameId)) { + return { frameId: otpFrame.frameId, kind: 'otp', url: otpFrame.url || '' }; + } + + const paymentFrames = frames + .filter((item) => Number.isInteger(item.frameId) && item.frameId !== 0 && isGoPayPaymentFrameUrl(item.url)); + for (const frame of paymentFrames) { + const ready = await ensureGoPayOtpFrameReady(tabId, frame.frameId); + if (!ready) continue; + try { + const frameState = await sendGoPayFrameCommand(tabId, frame.frameId, 'GOPAY_GET_STATE', {}); + if (frameState?.hasPayNowButton || frameState?.hasPinInput || frameState?.hasOtpInput || frameState?.hasContinueButton || frameState?.hasTerminalError) { + return { frameId: frame.frameId, kind: getGoPayDomFrameKind(frameState) || 'payment', url: frame.url || '' }; + } + } catch (_) { + // Keep scanning; frame may still be navigating. + } + } + + return { frameId: null, kind: '', url: '' }; + } + + async function sendGoPayFrameCommand(tabId, frameId, type, payload = {}) { + const result = await chrome.tabs.sendMessage(tabId, { + type, + source: 'background', + payload, + }, { frameId }); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function pingGoPayFrame(tabId, frameId) { + try { + const pong = await chrome.tabs.sendMessage(tabId, { + type: 'PING', + source: 'background', + payload: {}, + }, { frameId }); + return Boolean(pong?.ok && (!pong.source || pong.source === GOPAY_OTP_SOURCE || pong.source === GOPAY_SOURCE)); + } catch (_) { + return false; + } + } + + async function ensureGoPayOtpFrameReady(tabId, frameId) { + if (!Number.isInteger(frameId)) { + return false; + } + if (await pingGoPayFrame(tabId, frameId)) { + return true; + } + if (!chrome?.scripting?.executeScript) { + return false; + } + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [frameId] }, + func: (injectedSource) => { + window.__MULTIPAGE_SOURCE = injectedSource; + }, + args: [GOPAY_OTP_SOURCE], + }); + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [frameId] }, + files: GOPAY_INJECT_FILES, + }); + } catch (_) { + // The frame may navigate during injection; the caller will retry on the next loop. + } + await sleepWithStop(300); + return await pingGoPayFrame(tabId, frameId); + } + + async function getGoPayState(tabId) { + const result = await sendTabMessageUntilStopped(tabId, GOPAY_SOURCE, { + type: 'GOPAY_GET_STATE', + source: 'background', + payload: {}, + }); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function sendGoPayCommand(tabId, type, payload = {}) { + const result = await sendTabMessageUntilStopped(tabId, GOPAY_SOURCE, { + type, + source: 'background', + payload, + }); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function clickGoPayTargetWithDebugger(tabId, targetMessageType = 'GOPAY_GET_CONTINUE_TARGET', frameId = null) { + if (typeof clickWithDebugger !== 'function') { + return { clicked: false, reason: 'debugger_click_unavailable' }; + } + const target = Number.isInteger(frameId) + ? await sendGoPayFrameCommand(tabId, frameId, targetMessageType, {}) + : await sendGoPayCommand(tabId, targetMessageType, {}); + const rect = target?.rect || null; + if (!target?.found || !rect || !Number.isFinite(rect.centerX) || !Number.isFinite(rect.centerY)) { + return { clicked: false, reason: 'target_not_found', clickTarget: target?.target || '' }; + } + await clickWithDebugger(tabId, rect); + return { clicked: true, clickTarget: target.target || '' }; + } + + + async function clickGoPayContinueWithDebugger(tabId, frameId = null) { + return clickGoPayTargetWithDebugger(tabId, 'GOPAY_GET_CONTINUE_TARGET', frameId); + } + + async function clickGoPayPayNowWithDebugger(tabId, frameId = null) { + return clickGoPayTargetWithDebugger(tabId, 'GOPAY_GET_PAY_NOW_TARGET', frameId); + } + + async function clickGoPayPayButtonInAnyFrame(tabId) { + if (!chrome?.scripting?.executeScript) { + return { clicked: false, reason: 'scripting_unavailable' }; + } + try { + const results = await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: () => { + const normalize = (value = '') => String(value || '').replace(/\s+/g, ' ').trim(); + const isVisible = (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 && (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden' || Number(nodeStyle.opacity) === 0)) { + return false; + } + node = node.parentElement; + } + const rect = el.getBoundingClientRect?.(); + return Boolean(rect && rect.width > 0 && rect.height > 0); + }; + const getText = (el) => normalize([ + el?.textContent, + el?.value, + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.id, + ].filter(Boolean).join(' ')); + const candidates = Array.from(document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')) + .filter((el) => isVisible(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'); + const target = candidates.find((el) => { + const text = getText(el); + return /(?:^|\s)pay-button(?:\s|$)/i.test(text) + || /^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i.test(text); + }); + if (!target) { + return { clicked: false, url: location.href }; + } + target.scrollIntoView?.({ block: 'center', inline: 'center' }); + try { target.focus?.(); } catch (_) {} + 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, clientX, clientY, button: 0, buttons: 1 }; + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true })); + } + 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 })); + target.click?.(); + return { + clicked: true, + url: location.href, + target: `${String(target.tagName || '').toUpperCase()} ${getText(target)}`.trim(), + }; + }, + }); + const clicked = (Array.isArray(results) ? results : []).find((item) => item?.result?.clicked); + if (clicked?.result?.clicked) { + return { + clicked: true, + frameId: clicked.frameId, + url: clicked.result.url || '', + clickTarget: clicked.result.target || '', + }; + } + } catch (error) { + return { clicked: false, reason: error?.message || String(error || 'unknown_error') }; + } + return { clicked: false, reason: 'target_not_found' }; + } + + async function waitForGoPayState(tabId, predicate, options = {}) { + const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || GOPAY_WAIT_TIMEOUT_MS)); + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab) { + throw new Error('步骤 8:GoPay 标签页已关闭,无法继续。'); + } + const url = String(tab.url || ''); + if (isReturnUrl(url)) { + return { returned: true, url }; + } + await ensureGoPayReady(tabId, '步骤 8:GoPay 页面正在切换,等待脚本重新就绪...'); + const actionFrame = await findGoPayActionFrame(tabId); + const actionFrameId = actionFrame.frameId; + const actionTargetId = actionFrame.targetId; + if (Number.isInteger(actionFrameId)) { + await ensureGoPayOtpFrameReady(tabId, actionFrameId); + } + const pageState = actionTargetId + ? await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_GET_STATE', {}) + : (Number.isInteger(actionFrameId) + ? await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_GET_STATE', {}) + : await getGoPayState(tabId)); + if (predicate(pageState, tab)) { + return { pageState, tab }; + } + await sleepWithStop(GOPAY_POLL_INTERVAL_MS); + } + return { timeout: true }; + } + + function normalizeGoPayCountryCode(value = '') { + const normalized = String(value || '').trim().replace(/[^\d+]/g, ''); + const digits = normalized.replace(/\D/g, ''); + return digits ? `+${digits}` : '+86'; + } + + function normalizeGoPayOtp(value = '') { + return String(value || '').trim().replace(/[^\d]/g, ''); + } + + function resolveGoPayCredentials(state = {}) { + return { + countryCode: normalizeGoPayCountryCode(state.gopayCountryCode || '+86'), + phone: normalizeText(state.gopayPhone || ''), + otp: normalizeGoPayOtp(state.gopayOtp || ''), + pin: String(state.gopayPin || ''), + }; + } + + async function requestManualGoPayOtp(existingCode = '') { + if (typeof requestGoPayOtpInput !== 'function') { + throw new Error('步骤 8:未配置 GoPay 验证码,也无法打开侧边栏输入弹窗。'); + } + await addLog(existingCode + ? '步骤 8:检测到上次保存的 GoPay 验证码,将弹窗请你确认或改填新验证码。' + : '步骤 8:请在侧边栏弹窗中输入 GoPay 验证码,提交后会继续填写 PIN。', 'info'); + const response = await requestGoPayOtpInput({ code: existingCode }); + if (response?.error) { + throw new Error(response.error); + } + const code = normalizeGoPayOtp(response?.code || ''); + if (!code || response?.cancelled) { + throw new Error('步骤 8:GoPay 验证码输入已取消。'); + } + return code; + } + + async function executeGoPayApprove(state = {}) { + const credentials = resolveGoPayCredentials(state); + if (!credentials.phone) { + throw new Error('步骤 8:未配置 GoPay 手机号,请先在侧边栏填写。'); + } + if (!credentials.pin) { + throw new Error('步骤 8:未配置 GoPay PIN,请先在侧边栏填写。'); + } + + const tabId = await resolveGoPayTabId(state); + await ensureGoPayReady(tabId); + await setState({ plusCheckoutTabId: tabId }); + + function resetTransientStepFlags() { + otpSubmitted = false; + pinSubmitted = false; + continueClickAttempts = 0; + lastContinueClickSignature = ''; + payNowClickAttempts = 0; + lastPayNowClickSignature = ''; + } + + let phoneSubmitted = false; + let otpSubmitted = false; + let pinSubmitted = false; + let loggedWaiting = false; + let continueClickAttempts = 0; + let lastContinueClickSignature = ''; + let payNowClickAttempts = 0; + let lastPayNowClickSignature = ''; + + while (true) { + throwIfStopped?.(); + const tab = await chrome.tabs.get(tabId).catch(() => null); + const currentUrl = String(tab?.url || ''); + if (currentUrl && isReturnUrl(currentUrl)) { + await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); + break; + } + + await ensureGoPayReady(tabId, '步骤 8:GoPay 页面正在切换,等待脚本重新就绪...'); + const actionFrame = await findGoPayActionFrame(tabId); + const actionFrameId = actionFrame.frameId; + const actionTargetId = actionFrame.targetId; + if (Number.isInteger(actionFrameId)) { + await ensureGoPayOtpFrameReady(tabId, actionFrameId); + } + const pageState = actionTargetId + ? await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_GET_STATE', {}) + : (Number.isInteger(actionFrameId) + ? await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_GET_STATE', {}) + : await getGoPayState(tabId)); + await handleGoPayTerminalError(pageState, tabId); + + if (pageState.completed) { + await addLog('步骤 8:GoPay 页面已显示完成状态,准备进入回跳确认。', 'ok'); + break; + } + + if (pageState.hasPayNowButton) { + const payNowSignature = `${actionFrame.kind || 'top'}::${pageState.url || currentUrl || ''}::${pageState.textPreview || ''}`.slice(0, 700); + if (payNowSignature === lastPayNowClickSignature) { + payNowClickAttempts += 1; + } else { + lastPayNowClickSignature = payNowSignature; + payNowClickAttempts = 1; + } + if (!Number.isInteger(actionFrameId) && payNowClickAttempts > 2) { + const framePayResult = await clickGoPayPayButtonInAnyFrame(tabId); + if (framePayResult?.clicked) { + await addLog(`步骤 8:顶层仍显示 Pay now,但已在 GoPay iframe 中点击 Bayar 按钮:${framePayResult.clickTarget || 'Bayar'}。`, 'info'); + await sleepWithStop(2500); + resetTransientStepFlags(); + loggedWaiting = false; + continue; + } + await addLog('步骤 8:顶层 Pay now 已重复出现,暂未识别到 iframe 内 Bayar/PIN;继续等待页面切换,不再自动回退步骤 6。', 'warn'); + await sleepWithStop(3000); + loggedWaiting = false; + continue; + } + const paymentLabel = actionFrame.kind === 'payment' ? '最终 Bayar 确认' : 'Pay now'; + await addLog(`步骤 8:检测到 GoPay ${paymentLabel} 按钮,正在点击完成支付...`, 'info'); + const payResult = actionTargetId + ? await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_CLICK_PAY_NOW', {}) + : (Number.isInteger(actionFrameId) + ? await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_CLICK_PAY_NOW', {}) + : await sendGoPayCommand(tabId, 'GOPAY_CLICK_PAY_NOW', {})); + if (payResult?.clickTarget) { + await addLog(`步骤 8:已点击 GoPay 支付按钮:${payResult.clickTarget}`, 'info'); + } + await sleepWithStop(2500); + const decision = await waitForGoPayState(tabId, (nextState) => ( + nextState.hasTerminalError + || nextState.completed + || !nextState.hasPayNowButton + ), { timeoutMs: 15000 }); + await handleGoPayTerminalError(decision.pageState, tabId); + if (decision.returned) { + await addLog('步骤 8:GoPay 支付后已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); + break; + } + if (decision.pageState) { + resetTransientStepFlags(); + } + if (decision.timeout) { + if (!Number.isInteger(actionFrameId)) { + const framePayResult = await clickGoPayPayButtonInAnyFrame(tabId); + if (framePayResult?.clicked) { + await addLog(`步骤 8:已在 GoPay iframe 中点击 Bayar 按钮:${framePayResult.clickTarget || 'Bayar'}。`, 'info'); + await sleepWithStop(2500); + resetTransientStepFlags(); + loggedWaiting = false; + continue; + } + } + const debuggerResult = await clickGoPayPayNowWithDebugger(tabId, actionFrameId); + if (debuggerResult?.clickTarget) { + await addLog(`步骤 8:已使用 debugger 点击 GoPay 支付按钮:${debuggerResult.clickTarget}`, 'info'); + } + await sleepWithStop(2500); + if (!Number.isInteger(actionFrameId)) { + const lateFramePayResult = await clickGoPayPayButtonInAnyFrame(tabId); + if (lateFramePayResult?.clicked) { + await addLog(`步骤 8:顶层 Pay now 点击后,已在 GoPay iframe 中补点 Bayar 按钮:${lateFramePayResult.clickTarget || 'Bayar'}。`, 'info'); + await sleepWithStop(2500); + resetTransientStepFlags(); + loggedWaiting = false; + continue; + } + await addLog('步骤 8:顶层 Pay now 兜底点击后仍未识别到 iframe 内 Bayar/PIN,继续等待,不自动回退步骤 6。', 'warn'); + } + } + resetTransientStepFlags(); + loggedWaiting = false; + continue; + } + + if (pageState.hasPhoneInput && !phoneSubmitted) { + await addLog(`步骤 8:正在切换 GoPay 区号 ${credentials.countryCode} 并填写手机号...`, 'info'); + await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_PHONE', { + countryCode: credentials.countryCode, + phone: credentials.phone, + }); + phoneSubmitted = true; + continueClickAttempts = 0; + lastContinueClickSignature = ''; + loggedWaiting = false; + await sleepWithStop(1500); + continue; + } + + if (pageState.hasOtpInput && !otpSubmitted) { + const code = await requestManualGoPayOtp(credentials.otp); + credentials.otp = code; + await addLog('步骤 8:正在填写 GoPay 验证码...', 'info'); + if (actionTargetId) { + await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_SUBMIT_OTP', { code }); + } else if (Number.isInteger(actionFrameId)) { + await ensureGoPayOtpFrameReady(tabId, actionFrameId); + await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_SUBMIT_OTP', { code }); + } else { + await ensureGoPayReady(tabId, '步骤 8:已获取 GoPay 验证码,等待 GoPay 页面就绪...'); + await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_OTP', { code }); + } + otpSubmitted = true; + continueClickAttempts = 0; + lastContinueClickSignature = ''; + loggedWaiting = false; + await sleepWithStop(1500); + continue; + } + + if (pageState.hasPinInput && !pinSubmitted) { + await addLog('步骤 8:正在填写 GoPay PIN...', 'info'); + if (actionTargetId) { + await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_SUBMIT_PIN', { pin: credentials.pin }); + } else if (Number.isInteger(actionFrameId)) { + await ensureGoPayOtpFrameReady(tabId, actionFrameId); + await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_SUBMIT_PIN', { pin: credentials.pin }); + } else { + await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_PIN', { pin: credentials.pin }); + } + pinSubmitted = true; + continueClickAttempts = 0; + lastContinueClickSignature = ''; + loggedWaiting = false; + const afterPinDecision = await waitForGoPayState(tabId, (nextState) => ( + nextState.hasTerminalError + || nextState.hasPayNowButton + || nextState.completed + ), { timeoutMs: 20000 }); + await handleGoPayTerminalError(afterPinDecision.pageState, tabId); + if (afterPinDecision.returned) { + await addLog('步骤 8:GoPay PIN 后已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); + break; + } + if (afterPinDecision.pageState?.hasPayNowButton) { + await addLog('步骤 8:GoPay PIN 已通过,等待点击 Pay now 完成支付。', 'info'); + resetTransientStepFlags(); + } + await sleepWithStop(800); + continue; + } + + if (pageState.hasContinueButton) { + const continueSignature = `${pageState.url || currentUrl || ''}::${pageState.textPreview || ''}`.slice(0, 700); + if (continueSignature === lastContinueClickSignature) { + continueClickAttempts += 1; + } else { + lastContinueClickSignature = continueSignature; + continueClickAttempts = 1; + } + if (continueClickAttempts > 2) { + await addLog('步骤 8:GoPay 确认按钮点击后页面仍未变化,已暂停自动重复点击。请手动点击页面上的确认按钮,插件会继续等待后续页面。', 'warn'); + const decision = await waitForGoPayState(tabId, (nextState) => ( + nextState.hasTerminalError + || nextState.hasOtpInput + || nextState.hasPinInput + || nextState.hasPayNowButton + || nextState.completed + || !nextState.hasContinueButton + ), { timeoutMs: 30000 }); + await handleGoPayTerminalError(decision.pageState, tabId); + if (decision.returned) { + await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); + break; + } + if (!decision.timeout) { + continueClickAttempts = 0; + lastContinueClickSignature = ''; + loggedWaiting = false; + continue; + } + throw new Error('步骤 8:GoPay 确认按钮自动点击无效,请手动点击后重新执行或继续当前步骤。'); + } + await addLog(`步骤 8:检测到 GoPay 继续/确认按钮,正在点击${continueClickAttempts > 1 ? `(第 ${continueClickAttempts} 次)` : ''}...`, 'info'); + const clickResult = continueClickAttempts === 1 + ? (actionTargetId + ? await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_CLICK_CONTINUE', {}) + : (Number.isInteger(actionFrameId) + ? await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_CLICK_CONTINUE', {}) + : await sendGoPayCommand(tabId, 'GOPAY_CLICK_CONTINUE', {}))) + : await clickGoPayContinueWithDebugger(tabId, actionFrameId); + if (clickResult?.clickTarget) { + await addLog(`步骤 8:已点击 GoPay 控件:${clickResult.clickTarget}${continueClickAttempts > 1 ? '(debugger 真实鼠标事件)' : ''}`, 'info'); + } + await sleepWithStop(2000); + loggedWaiting = false; + continue; + } + + if (!loggedWaiting) { + loggedWaiting = true; + await addLog('步骤 8:等待 GoPay 手机号、验证码、PIN 或完成页面出现...', 'info'); + } + + const decision = await waitForGoPayState(tabId, (nextState) => ( + nextState.hasTerminalError + || nextState.hasPhoneInput + || nextState.hasOtpInput + || nextState.hasPinInput + || nextState.hasContinueButton + || nextState.hasPayNowButton + || nextState.completed + ), { timeoutMs: 10000 }); + await handleGoPayTerminalError(decision.pageState, tabId); + if (decision.returned) { + await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); + break; + } + if (decision.timeout) { + await sleepWithStop(GOPAY_POLL_INTERVAL_MS); + } + } + + await setState({ plusGoPayApprovedAt: Date.now() }); + await completeStepFromBackground(8, { + plusGoPayApprovedAt: Date.now(), + }); + } + + return { + executeGoPayApprove, + }; + } + + return { + createGoPayApproveExecutor, + }; +}); diff --git a/background/steps/plus-return-confirm.js b/background/steps/plus-return-confirm.js index 7e9a952..99dd08b 100644 --- a/background/steps/plus-return-confirm.js +++ b/background/steps/plus-return-confirm.js @@ -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); diff --git a/content/gopay-flow.js b/content/gopay-flow.js new file mode 100644 index 0000000..f77774d --- /dev/null +++ b/content/gopay-flow.js @@ -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), + }; +} diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 22f7deb..a91dac1 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -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(), diff --git a/content/whatsapp-flow.js b/content/whatsapp-flow.js new file mode 100644 index 0000000..99ef22b --- /dev/null +++ b/content/whatsapp-flow.js @@ -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(/(? 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), + }; +} diff --git a/data/address-sources.js b/data/address-sources.js index b670fb6..61cbefd 100644 --- a/data/address-sources.js +++ b/data/address-sources.js @@ -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', diff --git a/data/step-definitions.js b/data/step-definitions.js index eaa765a..1c706a3 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -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, diff --git a/docs/local-customizations-maintenance.md b/docs/local-customizations-maintenance.md index a0cf56a..4c29133 100644 --- a/docs/local-customizations-maintenance.md +++ b/docs/local-customizations-maintenance.md @@ -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. 本地配置文件忽略 ### 目标 diff --git a/gopay-utils.js b/gopay-utils.js new file mode 100644 index 0000000..995ee00 --- /dev/null +++ b/gopay-utils.js @@ -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, + }; +}); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 067e5a5..79bc1dd 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -228,19 +228,24 @@ Plus 模式
-
-
- -
+ Plus 订阅链路 +
+ + + + + +
邮箱服务
@@ -1783,6 +1826,7 @@ + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index d66b85c..914e8e2 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -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 || '流程已完全停止', diff --git a/tests/address-sources.test.js b/tests/address-sources.test.js index 5ffeb6f..53bc702 100644 --- a/tests/address-sources.test.js +++ b/tests/address-sources.test.js @@ -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'); diff --git a/tests/background-step-registry.test.js b/tests/background-step-registry.test.js index d4afc3e..d49bcba 100644 --- a/tests/background-step-registry.test.js +++ b/tests/background-step-registry.test.js @@ -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/); +}); diff --git a/tests/gopay-approve-source.test.js b/tests/gopay-approve-source.test.js new file mode 100644 index 0000000..52a1ce8 --- /dev/null +++ b/tests/gopay-approve-source.test.js @@ -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/); +}); diff --git a/tests/gopay-flow-content.test.js b/tests/gopay-flow-content.test.js new file mode 100644 index 0000000..5e57f11 --- /dev/null +++ b/tests/gopay-flow-content.test.js @@ -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|超时/); +}); diff --git a/tests/gopay-utils.test.js b/tests/gopay-utils.test.js new file mode 100644 index 0000000..9689575 --- /dev/null +++ b/tests/gopay-utils.test.js @@ -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'), ''); +}); diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index e2de0f9..7f840b9 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -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'), diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 07c0038..c0a28f8 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -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: [ diff --git a/tests/sidepanel-paypal-manager.test.js b/tests/sidepanel-paypal-manager.test.js index b58535c..c5f173a 100644 --- a/tests/sidepanel-paypal-manager.test.js +++ b/tests/sidepanel-paypal-manager.test.js @@ -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"/); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index a3b7769..58bb710 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -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, /