feat: 更新 Plus Checkout 逻辑,优化子框架的就绪状态处理,增加地址输入和 PayPal 付款方法的检测功能
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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. 在右上角或对应位置找到并双击打开 `全局扩展脚本`。
|
||||

|
||||
3. 将里面的内容全部清空,替换为下方脚本代码。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user