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 模式