feat: 更新 Plus Checkout 逻辑,优化子框架的就绪状态处理,增加地址输入和 PayPal 付款方法的检测功能

This commit is contained in:
QLHazyCoder
2026-04-26 03:08:12 +08:00
parent 1659b7f4f0
commit f454105ff5
7 changed files with 314 additions and 24 deletions
+4 -5
View File
@@ -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 };
}
}
+41 -6
View File
@@ -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;
}, {
+15 -4
View File
@@ -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();
}
+9 -9
View File
@@ -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. 将里面的内容全部清空,替换为下方脚本代码。
+12
View File
@@ -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);
});
+201
View File
@@ -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);
});
@@ -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: [