From f454105ff5d83c0e157630645820a4d19f1e3e1f Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 03:08:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20Plus=20Checkout=20?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AD=90=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E7=9A=84=E5=B0=B1=E7=BB=AA=E7=8A=B6=E6=80=81=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=9C=B0=E5=9D=80=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=92=8C=20PayPal=20=E4=BB=98=E6=AC=BE=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E7=9A=84=E6=A3=80=E6=B5=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/fill-plus-checkout.js | 9 +- content/plus-checkout.js | 47 +++- content/utils.js | 19 +- docs/使用教程.md | 18 +- tests/content-utils.test.js | 12 ++ tests/plus-checkout-address-input.test.js | 201 ++++++++++++++++++ ...us-checkout-billing-tab-resolution.test.js | 32 +++ 7 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 tests/plus-checkout-address-input.test.js diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 64a92dc..7e58f65 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -125,9 +125,6 @@ } 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', @@ -137,9 +134,11 @@ if (result?.error) { return { frame, error: result.error }; } - return { frame, result: result || {} }; + return { frame: { ...frame, ready: true }, result: result || {} }; } catch (error) { - return { frame, error: error?.message || String(error || '') }; + const readyError = frame.ready === false ? 'content-script-not-ready' : ''; + const message = error?.message || String(error || ''); + return { frame, error: readyError ? `${readyError}: ${message}` : message }; } } diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 598f5a7..40bfcc4 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -202,6 +202,29 @@ function findInputByFieldText(patterns, options = {}) { }) || null; } +function getDirectFieldHintText(el) { + const id = el?.id || ''; + const labels = []; + if (id) { + labels.push(...Array.from(document.querySelectorAll(`label[for="${CSS.escape(id)}"]`)).map((label) => label.textContent)); + } + const wrappingLabel = el?.closest?.('label'); + if (wrappingLabel) { + labels.push(wrappingLabel.textContent); + } + return normalizeText([ + getActionText(el), + ...labels, + ].filter(Boolean).join(' ')); +} + +function isNonAddressSearchInput(input) { + const directText = getDirectFieldHintText(input); + const type = String(input?.getAttribute?.('type') || input?.type || '').trim().toLowerCase(); + return /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(directText) + || ['email', 'tel', 'password'].includes(type); +} + function isDocumentLevelContainer(el) { return !el || el === document.documentElement @@ -432,9 +455,9 @@ async function selectPayPalPaymentMethod() { }); console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target)); simulateClick(target); - await sleep(1000); + log('Plus Checkout:已点击 PayPal 付款方式,正在确认选中状态。'); - if (!isPayPalPaymentMethodActive()) { + if (!await waitForPayPalPaymentMethodActive()) { const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error'); throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}。`); } @@ -484,6 +507,9 @@ function readCountryText() { function isLikelyAddressSearchInput(input) { const text = getFieldText(input); + if (isNonAddressSearchInput(input)) { + return false; + } 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; } @@ -533,10 +559,19 @@ function hasSelectedPayPalControl() { } function isPayPalPaymentMethodActive() { - if (hasSelectedPayPalControl()) { - return true; + return hasSelectedPayPalControl(); +} + +async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + throwIfStopped(); + if (isPayPalPaymentMethodActive()) { + return true; + } + await sleep(250); } - return getPayPalSearchCandidates().length > 0 && !hasCreditCardFields(); + return false; } async function findAddressSearchInput() { @@ -547,7 +582,7 @@ async function findAddressSearchInput() { ], { exclude: (input) => /city|state|province|postal|zip|country|城市|省|州|邮编|国家|地区/i.test(getFieldText(input)), }); - if (direct) return direct; + if (direct && !isNonAddressSearchInput(direct)) return direct; const candidates = getVisibleTextInputs().filter(isLikelyAddressSearchInput); return candidates[0] || null; }, { diff --git a/content/utils.js b/content/utils.js index 7eab2e4..a38143d 100644 --- a/content/utils.js +++ b/content/utils.js @@ -421,9 +421,20 @@ async function humanPause(min = 250, max = 850) { await sleep(duration); } -// Auto-report ready on load -// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration -const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'gmail-mail' || SCRIPT_SOURCE === 'mail-2925' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top; -if (!_isMailChildFrame) { +function shouldReportReadyForFrame(source, isChildFrame) { + if (!isChildFrame) return true; + return ![ + 'qq-mail', + 'mail-163', + 'gmail-mail', + 'mail-2925', + 'inbucket-mail', + 'plus-checkout', + ].includes(source); +} + +// Auto-report ready on load. Child frames are probed explicitly by frameId, so +// they should not overwrite the tab-level registration or spam the side panel. +if (shouldReportReadyForFrame(SCRIPT_SOURCE, window !== window.top)) { reportReady(); } diff --git a/docs/使用教程.md b/docs/使用教程.md index 54b2598..c58dda8 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱切换、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -21,15 +21,15 @@ - 一个可正常接收短信的手机号 - 一张可在线支付的借记卡或信用卡 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` -- 已安装 `Clash Verge`,并已导入可用订阅 +- 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),并已导入可用订阅 ## 操作步骤 ### 第一部分:相关项目地址与部署说明 1. 查看项目地址 - `cpa` 项目地址:`https://github.com/router-for-me/CLIProxyAPI` - `sub2api` 项目地址:`https://github.com/Wei-Shaw/sub2api` + `cpa` 项目地址:[https://github.com/router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) + `sub2api` 项目地址:[https://github.com/Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 2. 拉取项目到本地 先将你需要的项目拉取到本地目录。 @@ -72,7 +72,7 @@ 如果两边都选择了它,就需要把两套配置都填完整。 2. 填写 `Temp API` - 这里填写 `Cloudflare Temp Email` 后端地址,例如 `https://your-worker-domain`。 + 这里填写 `Cloudflare Temp Email` 后端地址,例如 [https://your-worker-domain](https://your-worker-domain)。 不论你是拿它来生成邮箱,还是接收转发邮件,这一项都需要先配好。 3. 作为 `邮箱生成` 使用时填写 `Admin Auth` @@ -106,7 +106,7 @@ 先登录你当前正在使用的 `QQ 邮箱` 账号。 2. 进入 `账号与安全` 页面 - 打开 `账号与安全` 页面:`https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/` + 打开 `账号与安全` 页面:[https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/](https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/) 3. 进入 `账号管理` 在 `账号与安全` 页面中找到 `账号管理`。 @@ -122,7 +122,7 @@ ### 第五部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 - 打开 `https://www.paypal.com/signin`。 + 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 然后点击 `注册`。 2. 选择账户类型 @@ -171,11 +171,11 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第六部分:配置 `Clash Verge` 的 `🔁 非港轮询` +### 第六部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 -1. 打开 `Clash Verge`,进入左侧的 `订阅`(`Profiles`)界面。 +1. 打开 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),进入左侧的 `订阅`(`Profiles`)界面。 2. 在右上角或对应位置找到并双击打开 `全局扩展脚本`。 ![05d15cd026ec21d0cffde04ad0d1a6c9](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163252--05d15cd026ec21d0cffde04ad0d1a6c9--0a59ba73acff.png) 3. 将里面的内容全部清空,替换为下方脚本代码。 diff --git a/tests/content-utils.test.js b/tests/content-utils.test.js index e3a11d0..01380a5 100644 --- a/tests/content-utils.test.js +++ b/tests/content-utils.test.js @@ -80,3 +80,15 @@ return { detectScriptSource }; 'mail-163' ); }); + +test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => { + const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n'); + const api = new Function(` +${bundle} +return { shouldReportReadyForFrame }; +`)(); + + assert.equal(api.shouldReportReadyForFrame('plus-checkout', true), false); + assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true); + assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true); +}); diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js new file mode 100644 index 0000000..99bf2d6 --- /dev/null +++ b/tests/plus-checkout-address-input.test.js @@ -0,0 +1,201 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/plus-checkout.js', 'utf8'); + +function extractFunction(name) { + const plainStart = source.indexOf(`function ${name}(`); + const asyncStart = source.indexOf(`async function ${name}(`); + const start = asyncStart >= 0 + ? asyncStart + : plainStart; + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = index; + break; + } + } + + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function createInput({ id = '', name = '', placeholder = '', containerText = '' }) { + const attrs = { + id, + name, + placeholder, + type: 'text', + }; + const container = { + textContent: containerText, + }; + return { + id, + name, + type: 'text', + value: '', + textContent: '', + getAttribute: (key) => attrs[key] || '', + closest: (selector) => { + if (selector === 'label') return null; + if (String(selector || '').includes('[data-testid]')) return container; + return null; + }, + getBoundingClientRect: () => ({ width: 240, height: 40 }), + }; +} + +function createElement({ tagName = 'BUTTON', text = '', attrs = {}, className = '' }) { + return { + tagName, + textContent: text, + value: '', + className, + dataset: {}, + id: attrs.id || '', + checked: false, + getAttribute: (key) => attrs[key] || '', + closest: () => null, + getBoundingClientRect: () => ({ width: 180, height: 64 }), + }; +} + +test('findAddressSearchInput skips the name field when its container says billing address', async () => { + const bundle = [ + 'function throwIfStopped() {}', + 'function sleep() { return Promise.resolve(); }', + extractFunction('waitUntil'), + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getFieldText'), + extractFunction('getVisibleControls'), + extractFunction('getVisibleTextInputs'), + extractFunction('findInputByFieldText'), + extractFunction('getDirectFieldHintText'), + extractFunction('isNonAddressSearchInput'), + extractFunction('isLikelyAddressSearchInput'), + extractFunction('findAddressSearchInput'), + ].join('\n'); + + const nameInput = createInput({ + name: 'name', + placeholder: 'Name', + containerText: 'Billing address', + }); + const addressInput = createInput({ + name: 'addressLine1', + placeholder: 'Address', + containerText: 'Billing address', + }); + const inputs = [nameInput, addressInput]; + const documentMock = { + readyState: 'complete', + querySelectorAll: (selector) => { + if (selector === 'input, textarea') return inputs; + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { findAddressSearchInput, isNonAddressSearchInput }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.isNonAddressSearchInput(nameInput), true); + assert.equal(await api.findAddressSearchInput(), addressInput); +}); + +test('isPayPalPaymentMethodActive requires a selected PayPal control', () => { + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getSearchText'), + extractFunction('getFieldText'), + extractFunction('getCombinedSearchText'), + extractFunction('getVisibleControls'), + extractFunction('getVisibleTextInputs'), + extractFunction('isDocumentLevelContainer'), + extractFunction('getPayPalSearchCandidates'), + extractFunction('hasCreditCardFields'), + extractFunction('hasSelectedPayPalControl'), + extractFunction('isPayPalPaymentMethodActive'), + ].join('\n'); + + const paypalButton = createElement({ + text: 'PayPal', + attrs: { + id: 'paypal-tab', + role: 'tab', + 'aria-selected': '', + }, + }); + const elements = [paypalButton]; + const documentMock = { + documentElement: {}, + body: {}, + querySelectorAll: (selector) => { + if (selector === 'input, textarea') return []; + if (String(selector || '').includes('label[for=')) return []; + return elements; + }, + }; + const windowMock = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { isPayPalPaymentMethodActive }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.isPayPalPaymentMethodActive(), false); + paypalButton.getAttribute = (key) => (key === 'aria-selected' ? 'true' : (paypalButton.id && key === 'id' ? paypalButton.id : '')); + assert.equal(api.isPayPalPaymentMethodActive(), true); +}); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index c69c45c..679e055 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -67,12 +67,16 @@ function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { sendMessage: async (tabId, message, options = {}) => { const frameId = Number.isInteger(options.frameId) ? options.frameId : 0; events.messages.push({ tabId, message, frameId }); + const hasConfiguredState = Object.prototype.hasOwnProperty.call(stateByFrame, frameId); if (message.type === 'PING') { if (readyByFrame[frameId] === false) { throw new Error('No receiving end'); } return { ok: true, source: 'plus-checkout' }; } + if (readyByFrame[frameId] === false && !hasConfiguredState) { + throw new Error('No receiving end'); + } if (message.type === 'PLUS_CHECKOUT_GET_STATE') { return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] }; } @@ -159,6 +163,34 @@ test('Plus checkout billing sends the billing command to the iframe that contain assert.equal(events.completed[0].step, 7); }); +test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => { + const { events, executor } = createExecutorHarness({ + frames: [ + { 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: true, + paypalCandidates: [{ tag: 'button', text: 'PayPal' }], + hasSubscribeButton: true, + }, + 7: { hasPayPal: false, paypalCandidates: [] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + }, + readyByFrame: { + 0: false, + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'); + assert.equal(selectMessage.frameId, 0); + 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: [