From 72697ffc83ca593b45ccc3eef8f62922b7282947 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 01:42:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E4=B8=83=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 1 + background/steps/fill-plus-checkout.js | 374 ++++++++++++++++- content/plus-checkout.js | 390 +++++++++++++++++- docs/使用教程.md | 76 +++- ...us-checkout-billing-tab-resolution.test.js | 210 ++++++++++ 项目完整链路说明.md | 2 +- 项目文件结构说明.md | 1 + 7 files changed, 1016 insertions(+), 38 deletions(-) create mode 100644 tests/plus-checkout-billing-tab-resolution.test.js diff --git a/background.js b/background.js index 7cd6e21..9f5e5ab 100644 --- a/background.js +++ b/background.js @@ -6915,6 +6915,7 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c }); const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?.createPlusCheckoutBillingExecutor({ addLog, + chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, generateRandomName, diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 6fa2dd1..64a92dc 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -3,33 +3,287 @@ })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutBillingModule() { const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; 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; function createPlusCheckoutBillingExecutor(deps = {}) { const { addLog, + chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, generateRandomName, getAddressSeedForCountry, getTabId, isTabAlive, - sendTabMessageUntilStopped, setState, sleepWithStop, waitForTabCompleteUntilStopped, waitForTabUrlMatchUntilStopped, } = deps; + function isPlusCheckoutUrl(url = '') { + return PLUS_CHECKOUT_URL_PATTERN.test(String(url || '')); + } + + async function getAlivePlusCheckoutTabId(tabId) { + if (!Number.isInteger(tabId) || tabId <= 0) { + return null; + } + if (!chrome?.tabs?.get) { + return tabId; + } + const tab = await chrome.tabs.get(tabId).catch(() => null); + return tab && isPlusCheckoutUrl(tab.url) ? tabId : null; + } + + async function getCurrentPlusCheckoutTabId() { + if (!chrome?.tabs?.query) { + return null; + } + + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []); + const activeCheckoutTab = activeTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url)); + if (activeCheckoutTab) { + return activeCheckoutTab.id; + } + + const checkoutTabs = await chrome.tabs.query({ url: 'https://chatgpt.com/checkout/*' }).catch(() => []); + const checkoutTab = checkoutTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url)); + return checkoutTab?.id || null; + } + + async function getCheckoutFrames(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)); + } + + async function pingCheckoutFrame(tabId, frameId) { + try { + const pong = await chrome.tabs.sendMessage(tabId, { + type: 'PING', + source: 'background', + payload: {}, + }, { + frameId: Number.isInteger(frameId) ? frameId : 0, + }); + return Boolean(pong?.ok && (!pong.source || pong.source === PLUS_CHECKOUT_SOURCE)); + } catch { + return false; + } + } + + async function ensurePlusCheckoutFrameReady(tabId, frameId) { + if (await pingCheckoutFrame(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: [PLUS_CHECKOUT_SOURCE], + }); + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [frameId] }, + files: PLUS_CHECKOUT_INJECT_FILES, + }); + } catch { + // If the frame was already injected or navigated mid-injection, ping once more below. + } + + await sleepWithStop(PLUS_CHECKOUT_FRAME_READY_DELAY_MS); + return await pingCheckoutFrame(tabId, frameId); + } + + async function ensurePlusCheckoutFramesReady(tabId, frames) { + const checkedFrames = []; + for (const frame of frames) { + const ready = await ensurePlusCheckoutFrameReady(tabId, frame.frameId); + checkedFrames.push({ ...frame, ready }); + } + return checkedFrames; + } + + async function sendFrameMessage(tabId, frameId, message) { + return chrome.tabs.sendMessage(tabId, message, { + frameId: Number.isInteger(frameId) ? frameId : 0, + }); + } + + async function inspectCheckoutFrame(tabId, frame) { + if (frame.ready === false) { + return { frame, error: 'content-script-not-ready' }; + } + try { + const result = await sendFrameMessage(tabId, frame.frameId, { + type: 'PLUS_CHECKOUT_GET_STATE', + source: 'background', + payload: {}, + }); + if (result?.error) { + return { frame, error: result.error }; + } + return { frame, result: result || {} }; + } catch (error) { + return { frame, error: error?.message || String(error || '') }; + } + } + + function isPaymentFrameUrl(url = '') { + return /elements-inner-payment|componentName=payment/i.test(String(url || '')); + } + + function isAddressFrameUrl(url = '') { + return /elements-inner-address|componentName=address/i.test(String(url || '')); + } + + function isAutocompleteFrameUrl(url = '') { + return /elements-inner-autocompl/i.test(String(url || '')); + } + + function buildFrameSummary(inspections) { + return inspections + .map((item) => { + const flags = []; + if (item.result?.hasPayPal) flags.push('paypal'); + if (item.result?.billingFieldsVisible) flags.push('billing'); + if (item.result?.hasSubscribeButton) flags.push('subscribe'); + if (!flags.length && item.error) flags.push(item.error); + if (!flags.length) flags.push('no-match'); + return `${item.frame.frameId}:${item.frame.url || 'about:blank'}:${flags.join(',')}`; + }) + .slice(0, 8) + .join(' | '); + } + + async function inspectCheckoutFrames(tabId, frames) { + const inspections = []; + for (const frame of frames) { + const inspection = await inspectCheckoutFrame(tabId, frame); + inspections.push(inspection); + } + return inspections; + } + + function pickPaymentFrame(inspections) { + return inspections.find((item) => item.result?.hasPayPal || item.result?.paypalCandidates?.length) + || inspections.find((item) => isPaymentFrameUrl(item.frame.url)) + || null; + } + + function pickBillingFrame(inspections) { + return inspections.find((item) => item.result?.billingFieldsVisible) + || inspections.find((item) => isAddressFrameUrl(item.frame.url)) + || null; + } + + function pickSubscribeFrame(inspections) { + return inspections.find((item) => item.result?.hasSubscribeButton) + || inspections.find((item) => item.frame.frameId === 0) + || null; + } + + async function getReadyCheckoutFrames(tabId) { + return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId)); + } + + async function resolveOptionalFrameByUrl(tabId, predicate) { + const frames = await getCheckoutFrames(tabId); + const frame = frames.find((item) => predicate(item.url)); + if (!frame) { + return null; + } + const ready = await ensurePlusCheckoutFrameReady(tabId, frame.frameId); + return { + frame, + ready, + }; + } + + async function resolvePaymentFrame(tabId, frames) { + const inspections = await inspectCheckoutFrames(tabId, frames); + const picked = pickPaymentFrame(inspections); + if (picked) { + return { + frameId: picked.frame.frameId, + frameUrl: picked.frame.url || '', + ready: picked.frame.ready !== false, + inspections, + }; + } + + return { + frameId: null, + frameUrl: '', + inspections, + }; + } + + async function waitForBillingFrame(tabId) { + while (true) { + const frames = await getReadyCheckoutFrames(tabId); + const inspections = await inspectCheckoutFrames(tabId, frames); + const picked = pickBillingFrame(inspections); + if (picked) { + return { + frameId: picked.frame.frameId, + frameUrl: picked.frame.url || '', + ready: picked.frame.ready !== false, + inspections, + }; + } + await sleepWithStop(250); + } + } + + async function waitForSubscribeFrame(tabId, candidateFrames) { + const frames = candidateFrames.length ? candidateFrames : [{ frameId: 0, url: '' }]; + while (true) { + const readyFrames = await ensurePlusCheckoutFramesReady(tabId, frames); + const inspections = await inspectCheckoutFrames(tabId, readyFrames); + const picked = pickSubscribeFrame(inspections); + if (picked) { + return picked.frame; + } + await sleepWithStop(250); + } + } + async function getCheckoutTabId(state = {}) { const registeredTabId = await getTabId(PLUS_CHECKOUT_SOURCE); if (registeredTabId && await isTabAlive(PLUS_CHECKOUT_SOURCE)) { - return registeredTabId; + const aliveRegisteredTabId = await getAlivePlusCheckoutTabId(registeredTabId); + if (aliveRegisteredTabId) { + return aliveRegisteredTabId; + } } const storedTabId = Number(state.plusCheckoutTabId) || 0; if (storedTabId) { - return storedTabId; + const aliveStoredTabId = await getAlivePlusCheckoutTabId(storedTabId); + if (aliveStoredTabId) { + return aliveStoredTabId; + } } - throw new Error('步骤 7:未找到 Plus Checkout 标签页,请先完成步骤 6。'); + const currentCheckoutTabId = await getCurrentPlusCheckoutTabId(); + if (currentCheckoutTabId) { + await addLog('步骤 7:检测到当前已在 Plus Checkout 页面,直接接管当前标签页。', 'info'); + return currentCheckoutTabId; + } + throw new Error('步骤 7:未找到 Plus Checkout 标签页。请先打开 Plus Checkout 页面,或完成步骤 6。'); } async function executePlusCheckoutBilling(state = {}) { @@ -43,6 +297,19 @@ injectSource: PLUS_CHECKOUT_SOURCE, logMessage: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...', }); + const readyFrames = await getReadyCheckoutFrames(tabId); + const paymentFrame = await resolvePaymentFrame(tabId, readyFrames); + if (paymentFrame.frameId === null) { + const frameSummary = buildFrameSummary(paymentFrame.inspections); + throw new Error(`步骤 7:未在主页面或 iframe 中发现 PayPal DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`); + } + if (!paymentFrame.ready) { + throw new Error(`步骤 7:已定位到 PayPal 所在 iframe(frameId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`); + } + + if (paymentFrame.frameId !== 0) { + await addLog(`步骤 7:PayPal 位于 checkout iframe(frameId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info'); + } const randomName = generateRandomName(); const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' '); @@ -53,18 +320,99 @@ throw new Error('步骤 7:未找到可用的本地账单地址种子。'); } - await addLog(`步骤 7:正在选择 PayPal 并填写账单地址(${addressSeed.countryCode} / ${addressSeed.query})...`, 'info'); - const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, { - type: 'FILL_PLUS_BILLING_AND_SUBMIT', + await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info'); + const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, { + type: 'PLUS_CHECKOUT_SELECT_PAYPAL', source: 'background', - payload: { - fullName, - addressSeed, - }, + payload: {}, }); + if (paymentResult?.error) { + throw new Error(paymentResult.error); + } - if (result?.error) { - throw new Error(result.error); + const billingFrame = await waitForBillingFrame(tabId); + if (!billingFrame.ready) { + throw new Error(`步骤 7:已定位到账单地址 iframe(frameId=${billingFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`); + } + if (billingFrame.frameId !== paymentFrame.frameId) { + await addLog(`步骤 7:账单地址位于 checkout iframe(frameId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info'); + } + + await addLog(`步骤 7:正在填写账单地址(${addressSeed.countryCode} / ${addressSeed.query})...`, 'info'); + const autocompleteFrame = await resolveOptionalFrameByUrl(tabId, isAutocompleteFrameUrl); + let result = null; + if (autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) { + if (!autocompleteFrame.ready) { + throw new Error('步骤 7:发现 Google 地址推荐 iframe,但无法注入账单脚本。请提供该 iframe 的控制台结构。'); + } + await addLog(`步骤 7:Google 地址推荐位于独立 iframe(frameId=${autocompleteFrame.frame.frameId}),将拆分输入与选择动作。`, 'info'); + + const queryResult = await sendFrameMessage(tabId, billingFrame.frameId, { + type: 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY', + source: 'background', + payload: { + fullName, + addressSeed, + }, + }); + if (queryResult?.error) { + throw new Error(queryResult.error); + } + + const suggestionResult = await sendFrameMessage(tabId, autocompleteFrame.frame.frameId, { + type: 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION', + source: 'background', + payload: { + addressSeed, + }, + }); + if (suggestionResult?.error) { + throw new Error(suggestionResult.error); + } + + const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, { + type: 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS', + source: 'background', + payload: { + addressSeed, + }, + }); + if (structuredResult?.error) { + throw new Error(structuredResult.error); + } + + result = { + ...structuredResult, + selectedAddressText: suggestionResult?.selectedAddressText || '', + }; + } else { + result = await sendFrameMessage(tabId, billingFrame.frameId, { + type: 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS', + source: 'background', + payload: { + fullName, + addressSeed, + }, + }); + + if (result?.error) { + throw new Error(result.error); + } + } + + 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({ diff --git a/content/plus-checkout.js b/content/plus-checkout.js index a4c18f1..598f5a7 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -16,6 +16,7 @@ const PLUS_CHECKOUT_PAYLOAD = { is_coupon_from_query_param: false, }, }; +const PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS = 5000; if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') { document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1'); @@ -24,6 +25,12 @@ if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== ' if ( message.type === 'CREATE_PLUS_CHECKOUT' || message.type === 'FILL_PLUS_BILLING_AND_SUBMIT' + || message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' + || message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' + || message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY' + || message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION' + || message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS' + || message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' || message.type === 'PLUS_CHECKOUT_GET_STATE' ) { resetStopState(); @@ -49,6 +56,18 @@ async function handlePlusCheckoutCommand(message) { return createPlusCheckoutSession(); case 'FILL_PLUS_BILLING_AND_SUBMIT': return fillPlusBillingAndSubmit(message.payload || {}); + case 'PLUS_CHECKOUT_SELECT_PAYPAL': + return selectPlusPayPalPaymentMethod(); + case 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS': + return fillPlusBillingAddress(message.payload || {}); + case 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY': + return fillPlusAddressQuery(message.payload || {}); + case 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION': + return selectPlusAddressSuggestion(message.payload || {}); + case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS': + return ensurePlusStructuredBillingAddress(message.payload || {}); + case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE': + return clickPlusSubscribe(); case 'PLUS_CHECKOUT_GET_STATE': return inspectPlusCheckoutState(); default: @@ -103,6 +122,21 @@ function getActionText(el) { ].filter(Boolean).join(' ')); } +function getSearchText(el) { + const datasetValues = el?.dataset ? Object.values(el.dataset) : []; + return normalizeText([ + getActionText(el), + el?.getAttribute?.('alt'), + el?.getAttribute?.('role'), + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('src'), + el?.getAttribute?.('href'), + el?.getAttribute?.('xlink:href'), + typeof el?.className === 'string' ? el.className : el?.getAttribute?.('class'), + ...datasetValues, + ].filter(Boolean).join(' ')); +} + function getFieldText(el) { const id = el?.id || ''; const labels = []; @@ -123,6 +157,13 @@ function getFieldText(el) { ].filter(Boolean).join(' ')); } +function getCombinedSearchText(el) { + return normalizeText([ + getSearchText(el), + getFieldText(el), + ].filter(Boolean).join(' ')); +} + function getVisibleControls(selector) { return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement); } @@ -161,6 +202,179 @@ function findInputByFieldText(patterns, options = {}) { }) || null; } +function isDocumentLevelContainer(el) { + return !el + || el === document.documentElement + || el === document.body + || ['HTML', 'BODY', 'MAIN'].includes(el.tagName); +} + +function isPaymentCardSized(el) { + if (!isVisibleElement(el) || isDocumentLevelContainer(el)) return false; + const rect = el.getBoundingClientRect(); + const maxWidth = Math.max(320, Math.min(window.innerWidth * 0.95, 900)); + const maxHeight = Math.max(140, Math.min(window.innerHeight * 0.45, 320)); + return rect.width >= 64 + && rect.height >= 28 + && rect.width <= maxWidth + && rect.height <= maxHeight; +} + +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]')) { + return current; + } + } + return null; +} + +function findPaymentCardAncestor(el, pattern) { + let current = el; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + if (!isVisibleElement(current)) continue; + if (isDocumentLevelContainer(current)) break; + const text = getSearchText(current); + if (pattern.test(text) && isPaymentCardSized(current)) { + return current; + } + } + return null; +} + +function getAncestorChainSummary(el, limit = 6) { + const chain = []; + let current = el; + for (let depth = 0; current && depth < limit; depth += 1, current = current.parentElement) { + if (isDocumentLevelContainer(current)) break; + const rect = current.getBoundingClientRect(); + chain.push({ + tag: String(current.tagName || '').toLowerCase(), + role: current.getAttribute?.('role') || '', + id: current.id || '', + className: typeof current.className === 'string' ? current.className.slice(0, 120) : '', + testId: current.getAttribute?.('data-testid') || '', + ariaLabel: current.getAttribute?.('aria-label') || '', + ariaChecked: current.getAttribute?.('aria-checked') || '', + ariaSelected: current.getAttribute?.('aria-selected') || '', + rect: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + text: getCombinedSearchText(current).slice(0, 180), + }); + } + return chain; +} + +function getPayPalSearchCandidates() { + const selector = [ + 'button', + 'a', + 'label', + '[role="button"]', + '[role="radio"]', + 'input[type="radio"]', + '[tabindex]', + '[data-testid]', + '[aria-label]', + '[title]', + 'img', + 'svg', + 'span', + 'div', + ].join(', '); + + return getVisibleControls(selector) + .filter((el) => /paypal/i.test(getCombinedSearchText(el))) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height); + }); +} + +function findPayPalPaymentMethodTarget() { + const paypalPattern = /paypal/i; + const directClickable = findClickableByText([paypalPattern]); + 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 candidates = getPayPalSearchCandidates(); + for (const candidate of candidates) { + const interactive = findInteractiveAncestor(candidate); + if (interactive && paypalPattern.test(getCombinedSearchText(interactive))) { + return interactive; + } + const card = findPaymentCardAncestor(candidate, paypalPattern); + if (card) { + return card; + } + } + + return null; +} + +function summarizeElementForDebug(el) { + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { + tag: String(el.tagName || '').toLowerCase(), + role: el.getAttribute?.('role') || '', + text: getSearchText(el).slice(0, 160), + rect: `${Math.round(rect.width)}x${Math.round(rect.height)}`, + chain: getAncestorChainSummary(el, 3), + }; +} + +function getPayPalCandidateSummaries(limit = 6) { + return getPayPalSearchCandidates() + .map(summarizeElementForDebug) + .filter(Boolean) + .slice(0, limit); +} + +function getPaymentTextPreview(limit = 10) { + const seen = new Set(); + const pattern = /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)) + .map((text) => text.slice(0, 180)) + .filter((text) => { + if (seen.has(text)) return false; + seen.add(text); + return true; + }) + .slice(0, limit); +} + +function getPayPalDiagnostics(reason = '') { + return { + reason, + url: location.href, + readyState: document.readyState, + paypalCandidates: getPayPalCandidateSummaries(), + paymentTextPreview: getPaymentTextPreview(), + cardFieldsVisible: hasCreditCardFields(), + billingFieldsVisible: hasBillingAddressFields(), + }; +} + +function writePayPalDiagnostics(reason, level = 'info') { + const diagnostics = getPayPalDiagnostics(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'); + return diagnostics; +} + async function createPlusCheckoutSession() { await waitForDocumentComplete(); log('Plus:正在读取 ChatGPT 登录会话...'); @@ -199,23 +413,42 @@ async function createPlusCheckoutSession() { } async function selectPayPalPaymentMethod() { - const paypalPattern = /paypal/i; - const existingSelected = findClickableByText([/paypal/i]); - if (existingSelected) { - simulateClick(existingSelected); - await sleep(600); - return true; + let lastDiagnosticsAt = 0; + const target = await waitUntil(() => { + const currentTarget = findPayPalPaymentMethodTarget(); + if (currentTarget) { + return currentTarget; + } + + const now = Date.now(); + if (!lastDiagnosticsAt || now - lastDiagnosticsAt >= PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS) { + lastDiagnosticsAt = now; + writePayPalDiagnostics('正在等待可点击的 PayPal 付款方式', 'warn'); + } + return null; + }, { + label: 'PayPal 付款方式', + intervalMs: 250, + }); + console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target)); + simulateClick(target); + await sleep(1000); + + if (!isPayPalPaymentMethodActive()) { + const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error'); + throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`); } - const radios = getVisibleControls('input[type="radio"], [role="radio"]'); - const paypalRadio = radios.find((el) => paypalPattern.test(getFieldText(el))); - if (paypalRadio) { - simulateClick(paypalRadio); - await sleep(600); - return true; - } + log('Plus Checkout:已确认 PayPal 付款方式生效。'); + return true; +} - throw new Error('Plus Checkout:未找到 PayPal 付款方式。'); +async function selectPlusPayPalPaymentMethod() { + await waitForDocumentComplete(); + await selectPayPalPaymentMethod(); + return { + paymentSelected: true, + }; } async function fillFullName(fullName) { @@ -251,7 +484,7 @@ function readCountryText() { function isLikelyAddressSearchInput(input) { const text = getFieldText(input); - if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州/i.test(text)) { + if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|card|card\s*number|expiry|expiration|security|cvc|cvv|cc-|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州|银行卡|卡号|有效期|安全码/i.test(text)) { return false; } if (/address|street|billing|search|line\s*1|地址|街道|账单/i.test(text)) { @@ -260,6 +493,52 @@ function isLikelyAddressSearchInput(input) { return false; } +function hasCreditCardFields() { + return getVisibleTextInputs().some((input) => { + const text = getFieldText(input); + return /card\s*number|card|expiry|expiration|security\s*code|cvc|cvv|银行卡|卡号|有效期|安全码/i.test(text); + }); +} + +function hasBillingAddressFields() { + return getVisibleTextInputs().some((input) => { + const text = getFieldText(input); + return /address|street|billing|line\s*1|地址|街道|账单/i.test(text) + && !/card\s*number|card|expiry|expiration|security|cvc|cvv|银行卡|卡号|有效期|安全码/i.test(text); + }); +} + +function hasSelectedPayPalControl() { + const paypalPattern = /paypal/i; + const candidates = getPayPalSearchCandidates(); + 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; + const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || ''; + if ( + current.checked === true + || current.getAttribute?.('aria-checked') === 'true' + || current.getAttribute?.('aria-selected') === 'true' + || current.getAttribute?.('data-state') === 'checked' + || current.getAttribute?.('data-selected') === 'true' + || /\b(selected|checked|active)\b/i.test(className) + ) { + return true; + } + } + return false; + }); +} + +function isPayPalPaymentMethodActive() { + if (hasSelectedPayPalControl()) { + return true; + } + return getPayPalSearchCandidates().length > 0 && !hasCreditCardFields(); +} + async function findAddressSearchInput() { return waitUntil(() => { const direct = findInputByFieldText([ @@ -302,10 +581,11 @@ function getAddressSuggestions() { } async function selectAddressSuggestion(seed) { - const addressInput = await findAddressSearchInput(); - fillInput(addressInput, seed.query || 'Berlin Mitte'); - await sleep(800); + await fillAddressQuery(seed); + return clickAddressSuggestion(seed); +} +async function clickAddressSuggestion(seed = {}) { const suggestions = await waitUntil(() => { const options = getAddressSuggestions(); return options.length ? options : null; @@ -327,6 +607,15 @@ async function selectAddressSuggestion(seed) { }; } +async function fillAddressQuery(seed = {}) { + const addressInput = await findAddressSearchInput(); + fillInput(addressInput, seed.query || 'Berlin Mitte'); + await sleep(800); + return { + filled: true, + }; +} + function getStructuredAddressFields() { const address1 = findInputByFieldText([ /address\s*(?:line)?\s*1|street/i, @@ -404,6 +693,24 @@ function findSubscribeButton() { async function fillPlusBillingAndSubmit(payload = {}) { await waitForDocumentComplete(); await selectPayPalPaymentMethod(); + const billingResult = await fillPlusBillingAddress(payload); + + if (payload.skipSubmit) { + return { + ...billingResult, + submitted: false, + }; + } + + await clickPlusSubscribe(); + return { + ...billingResult, + submitted: true, + }; +} + +async function fillPlusBillingAddress(payload = {}) { + await waitForDocumentComplete(); await fillFullName(payload.fullName || ''); const countryText = readCountryText(); @@ -420,6 +727,43 @@ async function fillPlusBillingAndSubmit(payload = {}) { const selected = await selectAddressSuggestion(seed); const structuredAddress = await ensureStructuredAddress(seed); + return { + countryText, + selectedAddressText: selected.selectedText, + structuredAddress, + }; +} + +async function fillPlusAddressQuery(payload = {}) { + await waitForDocumentComplete(); + await fillFullName(payload.fullName || ''); + const seed = payload.addressSeed || {}; + await fillAddressQuery(seed); + return { + countryText: readCountryText(), + queryFilled: true, + }; +} + +async function selectPlusAddressSuggestion(payload = {}) { + await waitForDocumentComplete(); + const selected = await clickAddressSuggestion(payload.addressSeed || {}); + return { + selectedAddressText: selected.selectedText, + suggestionIndex: selected.suggestionIndex, + }; +} + +async function ensurePlusStructuredBillingAddress(payload = {}) { + await waitForDocumentComplete(); + const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}); + return { + countryText: readCountryText(), + structuredAddress, + }; +} + +async function clickPlusSubscribe() { const subscribeButton = await waitUntil(() => { const button = findSubscribeButton(); return button && isEnabledControl(button) ? button : null; @@ -430,9 +774,7 @@ async function fillPlusBillingAndSubmit(payload = {}) { simulateClick(subscribeButton); return { - countryText, - selectedAddressText: selected.selectedText, - structuredAddress, + clicked: true, }; } @@ -442,7 +784,11 @@ function inspectPlusCheckoutState() { url: location.href, readyState: document.readyState, countryText: readCountryText(), - hasPayPal: Boolean(findClickableByText([/paypal/i])), + hasPayPal: Boolean(findPayPalPaymentMethodTarget()), + paypalCandidates: getPayPalCandidateSummaries(), + paymentTextPreview: getPaymentTextPreview(), + cardFieldsVisible: hasCreditCardFields(), + billingFieldsVisible: hasBillingAddressFields(), hasSubscribeButton: Boolean(findSubscribeButton()), addressFieldValues: { address1: structuredAddress.address1?.value || '', diff --git a/docs/使用教程.md b/docs/使用教程.md index d419c53..54b2598 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,4 +1,6 @@ -本教程用于说明相关项目地址、扩展更新方式、`QQ 邮箱`切换邮箱的使用方法,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。 +# Codex 注册扩展相关项目、更新、邮箱切换、PayPal 与 Clash Verge 配置教程 + +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -6,6 +8,7 @@ - 已经安装本扩展,想更新到最新版本 - 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务` - 需要临时切换 `QQ 邮箱` 地址继续使用 +- 需要注册并使用 `PayPal` 个人账户 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` ## 准备内容 @@ -15,6 +18,8 @@ - 可以打开浏览器的 `扩展程序管理` 页面 - 已准备好 `Cloudflare Temp Email` 后端地址;如需随机子域,域名解析也已配置完成 - 一个可正常登录的 `QQ 邮箱` +- 一个可正常接收短信的手机号 +- 一张可在线支付的借记卡或信用卡 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` - 已安装 `Clash Verge`,并已导入可用订阅 @@ -114,7 +119,59 @@ 这两个邮箱地址使用完成后,可以直接删除。 删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。 -### 第五部分:配置 `Clash Verge` 的 `🔁 非港轮询` +### 第五部分:`PayPal` 注册与绑卡使用教程 + +1. 打开注册页面 + 打开 `https://www.paypal.com/signin`。 + 然后点击 `注册`。 + +2. 选择账户类型 + 选择 `个人账户`。 + 如果页面显示英文,通常对应 `Individual Account`。 + +3. 选择国家或地区 + 在 `国家/地区` 中直接选择 `中国`。 + 后续会绑定手机号,按中国手机号流程继续即可。 + +4. 输入手机号并继续 + 按页面提示输入手机号。 + 如果页面要求短信验证,就先完成短信验证再继续下一步。 + +5. 填写登录信息和个人信息 + 按页面提示继续填写邮箱、密码,以及姓名、出生日期、地址和联系方式。 + `PayPal` 官方说明,中国大陆居民注册时必须使用身份证上的中文姓名,不能使用拼音。 + 填完后勾选协议并完成注册。 + +6. 完成邮箱确认 + 注册成功后,按页面或邮箱提示确认邮箱地址。 + 如页面继续提示验证手机号,也一并完成。 + +7. 进入 `钱包` 或主页绑卡 + 注册成功后,进入主页。 + 然后在页面中找到 `关联卡或银行账户`,也可以直接进入 `钱包` 页面操作。 + +8. 选择关联借记卡或信用卡 + 点击 `关联借记卡或信用卡`。 + 如果你要先完成最基础的付款配置,优先把卡先绑上。 + +9. 填写银行卡信息 + 按页面提示输入银行卡号、有效期、安全代码和账单地址。 + 有效期一般在卡正面,通常写成 `10/45` 这种格式,表示 `2045 年 10 月` 到期。 + 安全代码一般在卡背面,输入三位数的那个。 + 如果卡背面分成两段数字,一段四位、一段三位,选择三位数的那段。 + 账单地址如果页面默认信息正确,保持默认即可。 + +10. 完成绑卡并检查结果 + 点击 `关联卡`。 + 有时候页面会报错,但你仍然可以去 `钱包` 页面重新看一下,因为卡有可能已经绑定成功。 + +11. 检查右上角通知并完成认证 + 绑卡成功后,记得查看右上角的通知图标。 + 如果通知图标标红,或者页面提示账户需要认证,请按提示补交资料。 + 常见情况是上传身份证件。 + `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 + +### 第六部分:配置 `Clash Verge` 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -236,6 +293,18 @@ function main(config, profileName) { 可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。 +### `PayPal` 绑卡时报错怎么办? + +先去 `钱包` 页面再看一眼,有时虽然页面提示报错,但实际上已经绑定成功。 +如果还没有成功,优先检查账单地址是否与银行卡账单地址一致。 +`PayPal` 官方还说明,绑定新卡时会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,如果这一步被银行拒绝,绑卡也会失败。 + +### `PayPal` 右上角通知标红怎么办? + +先点开通知查看具体要求。 +如果页面要求 `Confirm your Identity` 或账户认证,就按提示上传身份证件或其他所需材料。 +`PayPal` 官方说明,资料通常会在 `2 个工作日` 内审核,但某些情况可能更久。 + ### 为什么没有看到 `🔁 非港轮询`? 请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。 @@ -247,6 +316,9 @@ function main(config, profileName) { - 开启 `随机子域` 前,请先确认后端已经配置 `RANDOM_SUBDOMAIN_DOMAINS`,并且 Cloudflare DNS 已完成 `MX *` 设置。 - 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。 - `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。 +- 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 +- `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。 +- 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。 - 使用 `git pull` 更新扩展是最方便、最推荐的方式。 - 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。 diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js new file mode 100644 index 0000000..c69c45c --- /dev/null +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -0,0 +1,210 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadPlusCheckoutBillingModule() { + const source = fs.readFileSync('background/steps/fill-plus-checkout.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutBilling;`)(globalScope); +} + +function createAddressSeed() { + return { + countryCode: 'DE', + query: 'Berlin Mitte', + suggestionIndex: 1, + fallback: { + address1: 'Unter den Linden', + city: 'Berlin', + region: 'Berlin', + postalCode: '10117', + }, + }; +} + +function createSuccessfulBillingResult() { + return { + countryText: 'Germany', + structuredAddress: { + address1: 'Unter den Linden', + city: 'Berlin', + postalCode: '10117', + }, + }; +} + +function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { + const api = loadPlusCheckoutBillingModule(); + const events = { + completed: [], + ensuredTabs: [], + injectedAllFrames: false, + logs: [], + messages: [], + states: [], + waitedUrls: [], + }; + const checkoutTab = { + id: 42, + url: 'https://chatgpt.com/checkout/openai_ie/cs_test', + status: 'complete', + }; + + const executor = api.createPlusCheckoutBillingExecutor({ + addLog: async (message, level = 'info') => events.logs.push({ message, level }), + chrome: { + tabs: { + get: async (tabId) => (tabId === checkoutTab.id ? checkoutTab : null), + query: async (queryInfo) => { + if (queryInfo.active && queryInfo.currentWindow) { + return [checkoutTab]; + } + if (queryInfo.url === 'https://chatgpt.com/checkout/*') { + return [checkoutTab]; + } + return []; + }, + sendMessage: async (tabId, message, options = {}) => { + const frameId = Number.isInteger(options.frameId) ? options.frameId : 0; + events.messages.push({ tabId, message, frameId }); + if (message.type === 'PING') { + if (readyByFrame[frameId] === false) { + throw new Error('No receiving end'); + } + return { ok: true, source: 'plus-checkout' }; + } + if (message.type === 'PLUS_CHECKOUT_GET_STATE') { + return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] }; + } + return createSuccessfulBillingResult(); + }, + }, + scripting: { + executeScript: async (details) => { + if (details.target?.allFrames) { + events.injectedAllFrames = true; + } + }, + }, + webNavigation: { + getAllFrames: async () => frames, + }, + }, + completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }), + ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }), + generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }), + getAddressSeedForCountry: () => createAddressSeed(), + getTabId: async () => null, + isTabAlive: async () => false, + setState: async (updates) => events.states.push(updates), + sleepWithStop: async () => {}, + waitForTabCompleteUntilStopped: async () => checkoutTab, + waitForTabUrlMatchUntilStopped: async (tabId, matcher) => { + events.waitedUrls.push({ tabId }); + assert.equal(matcher('https://www.paypal.com/checkoutnow'), true); + return { id: tabId, url: 'https://www.paypal.com/checkoutnow' }; + }, + }); + + return { checkoutTab, events, executor }; +} + +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' }], + stateByFrame: { + 0: { + hasPayPal: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + billingFieldsVisible: true, + hasSubscribeButton: true, + }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + assert.deepEqual(events.ensuredTabs[0], { source: 'plus-checkout', tabId: checkoutTab.id }); + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' && entry.frameId === 0), true); + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' && entry.frameId === 0), true); + assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' && entry.frameId === 0), true); + assert.equal(events.completed[0].step, 7); + assert.equal(events.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true); + assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true); +}); + +test('Plus checkout billing sends the billing command to the iframe that contains PayPal', async () => { + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'); + const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'); + assert.equal(selectMessage.frameId, 7); + assert.equal(fillMessage.frameId, 8); + assert.equal(subscribeMessage.frameId, 0); + assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true); + assert.equal(events.completed[0].step, 7); +}); + +test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => { + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + { frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + 9: { hasPayPal: false, paypalCandidates: [] }, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'); + const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'); + const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS'); + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(fillQueryMessage.frameId, 8); + assert.equal(suggestionMessage.frameId, 9); + assert.equal(ensureAddressMessage.frameId, 8); + assert.equal(combinedFillMessage, undefined); + assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true); + assert.equal(events.completed[0].step, 7); +}); + +test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => { + const { executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + }, + readyByFrame: { + 7: false, + }, + }); + + await assert.rejects( + executor.executePlusCheckoutBilling({}), + /已定位到 PayPal 所在 iframe(frameId=7),但账单脚本无法注入该 iframe/ + ); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index b4a5308..ba779b1 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -493,7 +493,7 @@ Plus 模式可见步骤: 1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。 2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`。 -3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。 +3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frame,Google 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。 4. 第 8 步 `PayPal 登录与授权`:在 PayPal 页面填写 `paypalEmail / paypalPassword`,登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。 5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。 6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 68e1dd3..36fa027 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -175,6 +175,7 @@ - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成时的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 +- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 - `tests/content-utils.test.js`:测试内容脚本公共工具层对 `mail.126.com` 等网页邮箱来源的识别逻辑。