From aa4e52b9a707215bf618a38b511e8e918ee3d96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B4=E5=9C=A3=E4=BD=91?= Date: Sun, 26 Apr 2026 22:22:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(plus):=20=E5=A2=9E=E5=BC=BA=20checkout=20?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=E8=AF=95=E7=94=A8=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit c52f5058a4b5b68902bccb4d1f099d00c471b7bc) --- background.js | 7 + background/auto-run-controller.js | 45 +++- background/steps/fill-plus-checkout.js | 138 ++++++++-- content/plus-checkout.js | 253 +++++++++++++++--- tests/plus-checkout-address-input.test.js | 227 ++++++++++++++++ ...us-checkout-billing-tab-resolution.test.js | 41 +++ 6 files changed, 652 insertions(+), 59 deletions(-) diff --git a/background.js b/background.js index 3d76f54..cafe74e 100644 --- a/background.js +++ b/background.js @@ -6828,6 +6828,11 @@ function isSignupUserAlreadyExistsFailure(error) { return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message); } +function isPlusCheckoutNonFreeTrialFailure(error) { + const message = getErrorMessage(error); + return /PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message); +} + function isStep9RecoverableAuthError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); return /STEP9_OAUTH_RETRY::/i.test(message) @@ -8955,6 +8960,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR getStopRequested: () => stopRequested, hasSavedProgress, isAddPhoneAuthFailure, + isPlusCheckoutNonFreeTrialFailure, isRestartCurrentAttemptError, isSignupUserAlreadyExistsFailure, isStopError, @@ -9828,6 +9834,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling? getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry, getTabId, isTabAlive, + markCurrentRegistrationAccountUsed, sendTabMessageUntilStopped, setState, sleepWithStop, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 9f190ff..7919717 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -23,6 +23,7 @@ getState, hasSavedProgress, isAddPhoneAuthFailure, + isPlusCheckoutNonFreeTrialFailure, isRestartCurrentAttemptError, isSignupUserAlreadyExistsFailure, isStopError, @@ -502,15 +503,12 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); - const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err); + const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function' + && isPlusCheckoutNonFreeTrialFailure(err); const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function' && !keepSameEmailUntilAddPhone && isSignupUserAlreadyExistsFailure(err); - const canRetry = !blockedByAddPhone - && !blockedByPhoneSupplyExhausted - && !blockedBySignupUserAlreadyExists - && autoRunSkipFailures - && attemptRun < maxAttemptsForRound; + const canRetry = !blockedByAddPhone && !blockedByPlusNonFreeTrial && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound; await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), @@ -551,6 +549,41 @@ break; } + if (blockedByPlusNonFreeTrial) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + await setState({ + autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + }); + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因 Plus 免费试用资格不可用已终止。'); + await broadcastStopToContentScripts(); + if (!autoRunSkipFailures) { + await addLog( + `第 ${targetRun}/${totalRuns} 轮检测到 Plus 今日应付金额非 0,自动重试未开启,当前自动运行将停止。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + + await addLog(`第 ${targetRun}/${totalRuns} 轮没有 Plus 免费试用资格,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog( + targetRun < totalRuns + ? `第 ${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,已无后续轮次,本次自动运行结束。`, + 'warn' + ); + forceFreshTabsNextRun = true; + break; + } + if (blockedBySignupUserAlreadyExists) { roundSummary.status = 'failed'; roundSummary.finalFailureReason = reason; diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index e182799..28fdc18 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -5,6 +5,8 @@ const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i; const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500; + const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3; + const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000; 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', '阿根廷'] }, @@ -42,10 +44,10 @@ getAddressSeedForCountry, getTabId, isTabAlive, + markCurrentRegistrationAccountUsed, setState, sleepWithStop, waitForTabCompleteUntilStopped, - waitForTabUrlMatchUntilStopped, } = deps; function isPlusCheckoutUrl(url = '') { @@ -294,6 +296,28 @@ }); } + async function waitForPayPalRedirectAfterSubmit(tabId) { + 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 跳转。'); + } + const url = String(tab.url || ''); + if (/paypal\./i.test(url)) { + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + return true; + } + if (url && !isPlusCheckoutUrl(url)) { + await addLog(`步骤 7:点击订阅后页面跳转到非 PayPal 地址:${url}`, 'warn'); + return false; + } + await sleepWithStop(500); + } + return false; + } + async function inspectCheckoutFrame(tabId, frame) { try { const result = await sendFrameMessage(tabId, frame.frameId, { @@ -366,6 +390,44 @@ || null; } + function findCheckoutAmountInspection(inspections = []) { + return inspections.find((item) => item.result?.checkoutAmountSummary?.hasTodayDue) + || null; + } + + async function inspectCheckoutAmountSummary(tabId) { + const frames = await getReadyCheckoutFrames(tabId); + const inspections = await inspectCheckoutFrames(tabId, frames); + const amountInspection = findCheckoutAmountInspection(inspections); + return amountInspection?.result?.checkoutAmountSummary || null; + } + + async function ensureFreeTrialAmount(tabId, state = {}, options = {}) { + const phaseLabel = String(options.phaseLabel || '').trim() || '提交前'; + const amountSummary = await inspectCheckoutAmountSummary(tabId); + if (!amountSummary?.hasTodayDue) { + await addLog(`步骤 7:${phaseLabel}未能识别 checkout 的“今日应付金额”,为避免误判将继续执行。`, 'warn'); + return; + } + + if (amountSummary.isZero) { + await addLog(`步骤 7:${phaseLabel}已确认今日应付金额为 ${amountSummary.rawAmount || '0'},继续执行。`, 'ok'); + return; + } + + const amountLabel = amountSummary.rawAmount || ( + Number.isFinite(Number(amountSummary.amount)) ? String(amountSummary.amount) : '未知金额' + ); + await addLog(`步骤 7:${phaseLabel}检测到今日应付金额不是 0(${amountLabel}),说明当前账号没有免费试用资格,将跳过 PayPal 提交。`, '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 提交。`); + } + async function getReadyCheckoutFrames(tabId) { return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId)); } @@ -468,6 +530,9 @@ logMessage: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...', }); const readyFrames = await getReadyCheckoutFrames(tabId); + await ensureFreeTrialAmount(tabId, state, { + phaseLabel: 'Checkout 页面加载后', + }); const paymentFrame = await resolvePaymentFrame(tabId, readyFrames); if (paymentFrame.frameId === null) { const frameSummary = buildFrameSummary(paymentFrame.inspections); @@ -535,8 +600,9 @@ addressSeed, }, }); - if (suggestionResult?.error) { - throw new Error(suggestionResult.error); + const suggestionError = suggestionResult?.error || ''; + if (suggestionError) { + await addLog(`步骤 7:Google 地址推荐不可用,将改用本地地址字段兜底:${suggestionError}`, 'warn'); } const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, { @@ -544,6 +610,7 @@ source: 'background', payload: { addressSeed, + overwriteStructuredAddress: Boolean(suggestionError), }, }); if (structuredResult?.error) { @@ -552,7 +619,7 @@ result = { ...structuredResult, - selectedAddressText: suggestionResult?.selectedAddressText || '', + selectedAddressText: suggestionError ? '' : (suggestionResult?.selectedAddressText || ''), }; } else { result = await sendFrameMessage(tabId, billingFrame.frameId, { @@ -569,31 +636,56 @@ } } - await addLog('步骤 7:账单地址已填写完成,正在定位订阅按钮...', 'info'); - const subscribeFrame = await waitForSubscribeFrame(tabId, [ - { frameId: 0, url: '' }, - { frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' }, - { frameId: billingFrame.frameId, url: billingFrame.frameUrl || '' }, - ]); - const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, { - type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE', - source: 'background', - payload: {}, - }); - if (subscribeResult?.error) { - throw new Error(subscribeResult.error); - } - await setState({ plusCheckoutTabId: tabId, plusBillingCountryText: result?.countryText || '', plusBillingAddress: result?.structuredAddress || null, }); + await ensureFreeTrialAmount(tabId, state, { + phaseLabel: '提交订阅前', + }); - await addLog('步骤 7:账单地址已提交,正在等待跳转到 PayPal...', 'info'); - await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url)); - await waitForTabCompleteUntilStopped(tabId); - await sleepWithStop(1000); + let redirectedToPayPal = false; + let lastSubmitError = ''; + for (let attempt = 1; attempt <= PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS; attempt += 1) { + await addLog( + attempt === 1 + ? '步骤 7:账单地址已填写完成,等待 3 秒让 checkout 完成校验...' + : `步骤 7:准备第 ${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS} 次重新提交账单地址...`, + attempt === 1 ? 'info' : 'warn' + ); + await sleepWithStop(3000); + await addLog('步骤 7:正在定位订阅按钮...', 'info'); + const subscribeFrame = await waitForSubscribeFrame(tabId, [ + { frameId: 0, url: '' }, + { frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' }, + { frameId: billingFrame.frameId, url: billingFrame.frameUrl || '' }, + ]); + const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, { + type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE', + source: 'background', + payload: { + beforeClickDelayMs: attempt === 1 ? 700 : 1200, + }, + }); + if (subscribeResult?.error) { + lastSubmitError = subscribeResult.error; + await addLog(`步骤 7:点击订阅失败(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}):${lastSubmitError}`, 'warn'); + continue; + } + + await addLog(`步骤 7:账单地址已提交,正在等待跳转到 PayPal(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS})...`, 'info'); + redirectedToPayPal = await waitForPayPalRedirectAfterSubmit(tabId); + if (redirectedToPayPal) { + break; + } + lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 PayPal`; + await addLog(`步骤 7:${lastSubmitError},将重试提交。`, 'warn'); + } + + if (!redirectedToPayPal) { + throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 PayPal。${lastSubmitError}`); + } await completeStepFromBackground(7, { plusBillingCountryText: result?.countryText || '', diff --git a/content/plus-checkout.js b/content/plus-checkout.js index e409849..22f7deb 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -83,7 +83,7 @@ async function handlePlusCheckoutCommand(message) { case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS': return ensurePlusStructuredBillingAddress(message.payload || {}); case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE': - return clickPlusSubscribe(); + return clickPlusSubscribe(message.payload || {}); case 'PLUS_CHECKOUT_GET_STATE': return inspectPlusCheckoutState(); default: @@ -94,12 +94,17 @@ async function handlePlusCheckoutCommand(message) { async function waitUntil(predicate, options = {}) { const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250)); const label = String(options.label || '条件').trim() || '条件'; + 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(`${label}等待超时`); + } await sleep(intervalMs); } } @@ -126,24 +131,97 @@ function normalizeText(text = '') { return String(text || '').replace(/\s+/g, ' ').trim(); } -function normalizePlusPaymentMethod(value = '') { - return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal'; +function parseLocalizedAmount(rawValue = '') { + const raw = normalizeText(rawValue); + const match = raw.match(/(?:[$€£¥]\s*)?([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)(?:\s*[$€£¥])?/); + if (!match) return null; + let numericText = String(match[1] || '').trim(); + const lastComma = numericText.lastIndexOf(','); + const lastDot = numericText.lastIndexOf('.'); + if (lastComma > -1 && lastDot > -1) { + const decimalSeparator = lastComma > lastDot ? ',' : '.'; + const thousandsSeparator = decimalSeparator === ',' ? '.' : ','; + numericText = numericText + .replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '') + .replace(decimalSeparator, '.'); + } else if (lastComma > -1) { + numericText = numericText.replace(',', '.'); + } + const amount = Number(numericText.replace(/[^\d.+-]/g, '')); + return Number.isFinite(amount) + ? { + amount, + raw: match[0], + } + : null; } -function buildPlusCheckoutConfig(payload = {}) { - const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod); - const config = PLUS_CHECKOUT_CONFIGS[paymentMethod] || PLUS_CHECKOUT_CONFIGS.paypal; +function getTextAfterTodayDueLabel(text = '') { + const normalized = normalizeText(text); + const match = normalized.match(/(?:今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today)/i); + if (!match) return ''; + return normalized.slice((match.index || 0) + match[0].length).trim(); +} + +function getCheckoutAmountSummary() { + const elements = getVisibleControls('div, span, p, strong, b'); + const labelPattern = /今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today/i; + const amountPattern = /[$€£¥]\s*[+-]?\d|[+-]?\d+(?:[.,]\d{1,2})?\s*[$€£¥]/; + + for (const element of elements) { + const text = normalizeText(element.innerText || element.textContent || ''); + if (!labelPattern.test(text)) continue; + + const candidates = []; + const afterLabelText = getTextAfterTodayDueLabel(text); + if (afterLabelText) candidates.push(afterLabelText); + + const parent = element.parentElement; + if (parent) { + for (const child of Array.from(parent.children || [])) { + if (child === element) continue; + const childText = normalizeText(child.innerText || child.textContent || ''); + if (amountPattern.test(childText)) { + candidates.push(childText); + } + } + const parentAfterLabelText = getTextAfterTodayDueLabel(parent.innerText || parent.textContent || ''); + if (parentAfterLabelText) candidates.push(parentAfterLabelText); + } + + const grandparent = parent?.parentElement; + if (grandparent) { + const grandparentAfterLabelText = getTextAfterTodayDueLabel(grandparent.innerText || grandparent.textContent || ''); + if (grandparentAfterLabelText) candidates.push(grandparentAfterLabelText); + } + + for (const candidate of candidates) { + const parsed = parseLocalizedAmount(candidate); + if (!parsed) continue; + return { + hasTodayDue: true, + amount: parsed.amount, + isZero: Math.abs(parsed.amount) < 0.005, + rawAmount: parsed.raw, + labelText: text.slice(0, 160), + }; + } + + return { + hasTodayDue: true, + amount: null, + isZero: false, + rawAmount: '', + labelText: text.slice(0, 160), + }; + } + return { - paymentMethod, - paymentLabel: config.paymentLabel, - checkoutUrlPrefix: config.checkoutUrlPrefix, - checkoutPayload: { - ...PLUS_CHECKOUT_BASE_PAYLOAD, - billing_details: { - country: config.billing_details.country, - currency: config.billing_details.currency, - }, - }, + hasTodayDue: false, + amount: null, + isZero: false, + rawAmount: '', + labelText: '', }; } @@ -152,9 +230,14 @@ function getActionText(el) { el?.textContent, el?.value, el?.getAttribute?.('aria-label'), + el?.getAttribute?.('aria-labelledby'), el?.getAttribute?.('title'), el?.getAttribute?.('placeholder'), el?.getAttribute?.('name'), + el?.getAttribute?.('autocomplete'), + el?.getAttribute?.('data-elements-stable-field-name'), + el?.getAttribute?.('data-field'), + el?.getAttribute?.('data-field-name'), el?.id, ].filter(Boolean).join(' ')); } @@ -670,6 +753,7 @@ async function clickAddressSuggestion(seed = {}) { }, { label: '地址推荐列表', intervalMs: 250, + timeoutMs: 6000, }); const suggestionIndex = Math.max(0, Math.min( @@ -820,7 +904,7 @@ function findRegionDropdown() { if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false; const text = getFieldText(control); if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false; - return /state|province|county/i.test(text) + return /state|province|county|prefecture|administrative|administrative[_-]?area/i.test(text) || /(?:^|\s)region(?:\s|$)/i.test(text) || /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text); }) || null; @@ -956,24 +1040,24 @@ async function selectCountryDropdown(countryDropdown, value) { function getStructuredAddressFields() { const address1 = findInputByFieldText([ - /address\s*(?:line)?\s*1|street/i, - /地址\s*1|街道|详细地址/i, + /address\s*(?:line)?\s*1|address[_-]?line[_-]?1|address\[(?:address_)?line1\]|line\s*1|street|street[_-]?address/i, + /地址\s*1|街道|详细地址|住所/i, ]); const address2 = findInputByFieldText([ - /address\s*(?:line)?\s*2|apt|suite|unit/i, + /address\s*(?:line)?\s*2|address[_-]?line[_-]?2|address\[(?:address_)?line2\]|line\s*2|apt|suite|unit/i, /地址\s*2|公寓|单元|门牌/i, ]); const city = findInputByFieldText([ - /city|town|suburb/i, - /城市|市区/i, + /city|town|suburb|locality|address[_-]?level[_-]?2|address\[city\]/i, + /城市|市区|区市町村|市区町村|市町村/i, ]); const region = findInputByFieldText([ - /state|province|region|county/i, - /省|州|地区/i, + /state|province|region|county|prefecture|administrative|administrative[_-]?area|address[_-]?level[_-]?1|address\[state\]/i, + /省|州|地区|辖区|都道府县|都道府県/i, ]); const postalCode = findInputByFieldText([ - /postal|zip|postcode/i, - /邮编|邮政/i, + /postal|zip|postcode|postal[_-]?code|zip[_-]?code|address\[postal_code\]/i, + /邮编|邮政|郵便番号/i, ]); return { address1, address2, city, region, postalCode }; } @@ -1012,6 +1096,7 @@ async function ensureStructuredAddress(seed, options = {}) { }, { label: '结构化账单地址字段', intervalMs: 250, + timeoutMs: 6000, }); fillIfEmpty(fields.address1, fallback.address1, { overwrite }); @@ -1038,12 +1123,111 @@ async function ensureStructuredAddress(seed, options = {}) { } function findSubscribeButton() { + const submitButtons = getVisibleControls('button[type="submit"], input[type="submit"]'); + const exactSubmit = submitButtons.find((button) => ( + isEnabledControl(button) + && /订阅|subscribe|购买\s*ChatGPT\s*Plus|start\s*subscription|place\s*order/i.test(getCombinedSearchText(button)) + )); + if (exactSubmit) { + return exactSubmit; + } + return findClickableByText([ /订阅|继续|确认|支付/i, /subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i, ]); } +function isBusySubscribeButton(button) { + if (!button) return true; + const text = getActionText(button); + return button.disabled + || button.getAttribute?.('aria-disabled') === 'true' + || button.getAttribute?.('aria-busy') === 'true' + || button.closest?.('[aria-busy="true"], [data-loading="true"], [data-state="loading"]') + || /loading|processing|submitting|请稍候|处理中|加载中/i.test(text); +} + +function getAssociatedForm(button) { + if (!button) return null; + if (button.form) return button.form; + const formId = String(button.getAttribute?.('form') || '').trim(); + if (formId) { + return document.getElementById(formId) || null; + } + return button.closest?.('form') || null; +} + +async function humanLikeClick(el) { + throwIfStopped(); + if (!el) { + throw new Error('无法点击空元素。'); + } + + el.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'instant' }); + await sleep(300); + if (typeof el.focus === 'function') { + el.focus({ preventScroll: true }); + await sleep(150); + } + + const rect = el.getBoundingClientRect(); + const clientX = Math.floor(rect.left + rect.width / 2); + const clientY = Math.floor(rect.top + rect.height / 2); + const eventInit = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX, + clientY, + button: 0, + buttons: 1, + }; + const pointerCtor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent; + const events = [ + ['pointerover', pointerCtor], + ['pointerenter', pointerCtor], + ['mouseover', MouseEvent], + ['mouseenter', MouseEvent], + ['pointermove', pointerCtor], + ['mousemove', MouseEvent], + ['pointerdown', pointerCtor], + ['mousedown', MouseEvent], + ['pointerup', pointerCtor], + ['mouseup', MouseEvent], + ['click', MouseEvent], + ]; + + for (const [type, EventCtor] of events) { + throwIfStopped(); + el.dispatchEvent(new EventCtor(type, eventInit)); + await sleep(type === 'mousedown' || type === 'pointerdown' ? 120 : 30); + } + + if (typeof el.click === 'function') { + await sleep(120); + el.click(); + } + + const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase(); + const form = getAssociatedForm(el); + if ( + form + && typeof form.requestSubmit === 'function' + && ( + (String(el.tagName || '').toUpperCase() === 'BUTTON' && (!type || type === 'submit')) + || (String(el.tagName || '').toUpperCase() === 'INPUT' && type === 'submit') + ) + ) { + await sleep(250); + form.requestSubmit(el); + } + + console.log('[MultiPage:plus-checkout] 已执行拟人工点击', summarizeElementForDebug(el)); + log(`已拟人工点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`); +} + async function fillPlusBillingAndSubmit(payload = {}) { await waitForDocumentComplete(); await selectPayPalPaymentMethod(); @@ -1117,23 +1301,31 @@ async function selectPlusAddressSuggestion(payload = {}) { async function ensurePlusStructuredBillingAddress(payload = {}) { await waitForDocumentComplete(); - const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}); + const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}, { + overwrite: Boolean(payload.overwriteStructuredAddress), + }); return { countryText: readCountryText(), structuredAddress, }; } -async function clickPlusSubscribe() { +async function clickPlusSubscribe(payload = {}) { + if (payload.ensurePayPalActive && !isPayPalPaymentMethodActive()) { + await selectPayPalPaymentMethod(); + } + const subscribeButton = await waitUntil(() => { const button = findSubscribeButton(); - return button && isEnabledControl(button) ? button : null; + return button && isEnabledControl(button) && !isBusySubscribeButton(button) ? button : null; }, { label: '订阅按钮', intervalMs: 250, + timeoutMs: 10000, }); - simulateClick(subscribeButton); + await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0))); + await humanLikeClick(subscribeButton); return { clicked: true, }; @@ -1151,6 +1343,7 @@ function inspectPlusCheckoutState() { cardFieldsVisible: hasCreditCardFields(), billingFieldsVisible: hasBillingAddressFields(), hasSubscribeButton: Boolean(findSubscribeButton()), + checkoutAmountSummary: getCheckoutAmountSummary(), addressFieldValues: { address1: structuredAddress.address1?.value || '', city: structuredAddress.city?.value || '', diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index 5ab4de8..e2de0f9 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -180,6 +180,75 @@ return { findAddressSearchInput, isNonAddressSearchInput }; assert.equal(await api.findAddressSearchInput(), addressInput); }); +test('getCheckoutAmountSummary reads non-zero today due amount', () => { + const label = createElement({ tagName: 'DIV', text: '今日应付金额' }); + const amount = createElement({ tagName: 'DIV', text: '€19.33' }); + const row = createElement({ tagName: 'DIV', text: '今日应付金额 €19.33' }); + label.parentElement = row; + amount.parentElement = row; + row.children = [label, amount]; + row.parentElement = null; + + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('parseLocalizedAmount'), + extractFunction('getTextAfterTodayDueLabel'), + extractFunction('getVisibleControls'), + extractFunction('getCheckoutAmountSummary'), + 'return { getCheckoutAmountSummary };', + ].join('\n'); + + const api = new Function('window', 'document', bundle)( + { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }, + { + querySelectorAll: () => [label, amount, row], + } + ); + + const summary = api.getCheckoutAmountSummary(); + assert.equal(summary.hasTodayDue, true); + assert.equal(summary.isZero, false); + assert.equal(summary.amount, 19.33); + assert.equal(summary.rawAmount, '€19.33'); +}); + +test('getCheckoutAmountSummary accepts zero today due amount', () => { + const label = createElement({ tagName: 'DIV', text: '今日应付金额' }); + const amount = createElement({ tagName: 'DIV', text: '€0.00' }); + const row = createElement({ tagName: 'DIV', text: '今日应付金额 €0.00' }); + label.parentElement = row; + amount.parentElement = row; + row.children = [label, amount]; + row.parentElement = null; + + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('parseLocalizedAmount'), + extractFunction('getTextAfterTodayDueLabel'), + extractFunction('getVisibleControls'), + extractFunction('getCheckoutAmountSummary'), + 'return { getCheckoutAmountSummary };', + ].join('\n'); + + const api = new Function('window', 'document', bundle)( + { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }, + { + querySelectorAll: () => [label, amount, row], + } + ); + + const summary = api.getCheckoutAmountSummary(); + assert.equal(summary.hasTodayDue, true); + assert.equal(summary.isZero, true); + assert.equal(summary.amount, 0); +}); + test('isPayPalPaymentMethodActive requires a selected PayPal control', () => { const bundle = [ extractFunction('isVisibleElement'), @@ -234,6 +303,164 @@ return { isPayPalPaymentMethodActive }; assert.equal(api.isPayPalPaymentMethodActive(), true); }); +test('getStructuredAddressFields recognizes Stripe localized address field names', () => { + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getFieldText'), + extractFunction('getVisibleControls'), + extractFunction('getVisibleTextInputs'), + extractFunction('findInputByFieldText'), + extractFunction('getStructuredAddressFields'), + ].join('\n'); + + const address1 = createInput({ name: 'addressLine1', placeholder: '住所' }); + const city = createInput({ name: 'locality', placeholder: '市区町村' }); + const region = createInput({ name: 'administrativeArea', placeholder: '辖区' }); + const postalCode = createInput({ name: 'postalCode', placeholder: '郵便番号' }); + const inputs = [address1, city, region, postalCode]; + const documentMock = { + querySelectorAll: (selector) => { + if (selector === 'input, textarea') return inputs; + if (String(selector || '').includes('label[for=')) return []; + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { getStructuredAddressFields }; +`)(windowMock, documentMock, cssMock); + + assert.deepEqual(api.getStructuredAddressFields(), { + address1, + address2: null, + city, + region, + postalCode, + }); +}); + +test('findSubscribeButton prefers the submit subscription button', () => { + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getSearchText'), + extractFunction('getFieldText'), + extractFunction('getCombinedSearchText'), + extractFunction('getVisibleControls'), + extractFunction('findClickableByText'), + extractFunction('isEnabledControl'), + extractFunction('findSubscribeButton'), + ].join('\n'); + + const submitButton = createElement({ + tagName: 'BUTTON', + text: '订阅', + attrs: { + 'aria-label': '订阅', + type: 'submit', + }, + }); + submitButton.type = 'submit'; + + const genericButton = createElement({ + tagName: 'BUTTON', + text: '继续', + attrs: { + type: 'button', + }, + }); + genericButton.type = 'button'; + + const documentMock = { + body: {}, + documentElement: {}, + querySelectorAll: (selector) => { + if (selector === 'button[type="submit"], input[type="submit"]') return [submitButton]; + if (selector === 'button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]') { + return [genericButton, submitButton]; + } + if (String(selector || '').includes('label[for=')) return []; + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { findSubscribeButton }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.findSubscribeButton(), submitButton); +}); + +test('humanLikeClick submits a detached submit button through its form attribute', async () => { + const bundle = [ + 'function throwIfStopped() {}', + 'function sleep() { return Promise.resolve(); }', + 'function summarizeElementForDebug() { return {}; }', + 'function log() {}', + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getAssociatedForm'), + extractFunction('humanLikeClick'), + ].join('\n'); + + let submittedWith = null; + const form = { + requestSubmit(button) { + submittedWith = button; + }, + }; + const button = createElement({ + tagName: 'BUTTON', + text: '订阅', + attrs: { + form: '_r_l_', + type: 'submit', + 'aria-label': '订阅', + }, + }); + button.type = 'submit'; + button.scrollIntoView = () => {}; + button.focus = () => {}; + button.dispatchEvent = () => true; + button.click = () => {}; + + const documentMock = { + getElementById: (id) => (id === '_r_l_' ? form : null), + }; + const windowMock = {}; + class FakeMouseEvent { + constructor(type, init = {}) { + this.type = type; + Object.assign(this, init); + } + } + + const api = new Function('window', 'document', 'MouseEvent', 'PointerEvent', 'console', ` +${bundle} +return { humanLikeClick }; +`)(windowMock, documentMock, FakeMouseEvent, undefined, { log() {} }); + + await api.humanLikeClick(button); + assert.equal(submittedWith, button); +}); + test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => { const bundle = [ 'function throwIfStopped() {}', diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 24abcc4..07c0038 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -53,6 +53,7 @@ function createExecutorHarness({ readyByFrame = {}, fetchImpl = null, getAddressSeedForCountry = () => createAddressSeed(), + markCurrentRegistrationAccountUsed = async () => {}, }) { const api = loadPlusCheckoutBillingModule(); const events = { @@ -100,6 +101,9 @@ function createExecutorHarness({ if (message.type === 'PLUS_CHECKOUT_GET_STATE') { return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] }; } + if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') { + checkoutTab.url = 'https://www.paypal.com/checkoutnow'; + } return createSuccessfulBillingResult(); }, }, @@ -121,6 +125,7 @@ function createExecutorHarness({ getAddressSeedForCountry, getTabId: async () => null, isTabAlive: async () => false, + markCurrentRegistrationAccountUsed, setState: async (updates) => events.states.push(updates), sleepWithStop: async () => {}, waitForTabCompleteUntilStopped: async () => checkoutTab, @@ -134,6 +139,42 @@ function createExecutorHarness({ return { checkoutTab, events, executor }; } +test('Plus checkout billing stops before PayPal when today due amount is non-zero', async () => { + const markCalls = []; + const { events, executor } = createExecutorHarness({ + frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }], + stateByFrame: { + 0: { + hasPayPal: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + billingFieldsVisible: true, + hasSubscribeButton: true, + checkoutAmountSummary: { + hasTodayDue: true, + amount: 19.33, + isZero: false, + rawAmount: '€19.33', + }, + }, + }, + markCurrentRegistrationAccountUsed: async (state, options) => { + markCalls.push({ state, options }); + return { updated: true }; + }, + }); + + await assert.rejects( + () => executor.executePlusCheckoutBilling({ email: 'paid@example.com' }), + /PLUS_CHECKOUT_NON_FREE_TRIAL::/ + ); + + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'), false); + assert.equal(events.completed.length, 0); + assert.equal(markCalls.length, 1); + assert.equal(markCalls[0].state.email, 'paid@example.com'); + assert.equal(events.logs.some((entry) => /今日应付金额不是 0/.test(entry.message)), true); +}); + test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => { const { checkoutTab, events, executor } = createExecutorHarness({ frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],