From 336fdb0c3aca85b5e9b3ee23a59a0854d3fc6e82 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 15:29:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E8=84=9A=E6=9C=AC=E5=8A=A0=E8=BD=BD=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=92=8C=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 20 +- background/steps/create-plus-checkout.js | 2 - content/plus-checkout.js | 1 + content/utils.js | 17 +- mail-provider-utils.js | 240 ++++++++++++----------- tests/content-utils.test.js | 13 ++ tests/plus-checkout-create-wait.test.js | 10 +- 7 files changed, 174 insertions(+), 129 deletions(-) diff --git a/background.js b/background.js index 8190aae..94e90f2 100644 --- a/background.js +++ b/background.js @@ -4122,6 +4122,19 @@ async function ensureContentScriptReadyOnTab(source, tabId, options = {}) { return tabRuntime.ensureContentScriptReadyOnTab(source, tabId, options); } +function isContentScriptReadyPong(source, pong) { + if (!pong?.ok) return false; + if (pong.source && pong.source !== source) return false; + if (source === 'plus-checkout') { + return Boolean(pong.plusCheckoutReady); + } + return true; +} + +function isUnrecoverableContentScriptInjectError(error) { + return /Could not load file/i.test(String(error?.message || error || '')); +} + async function ensureContentScriptReadyOnTabUntilStopped(source, tabId, options = {}) { const { inject = null, @@ -4134,7 +4147,7 @@ async function ensureContentScriptReadyOnTabUntilStopped(source, tabId, options while (true) { throwIfStopped(); const pong = await pingContentScriptOnTab(tabId); - if (pong?.ok && (!pong.source || pong.source === source)) { + if (isContentScriptReadyPong(source, pong)) { await registerTab(source, tabId); return; } @@ -4159,10 +4172,13 @@ async function ensureContentScriptReadyOnTabUntilStopped(source, tabId, options }); } catch (error) { console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTabUntilStopped] inject failed for ${source}:`, error?.message || error); + if (isUnrecoverableContentScriptInjectError(error)) { + throw new Error(`${getSourceLabel(source)} 内容脚本文件加载失败:${error?.message || error}。请在扩展管理页重新加载当前扩展,确认文件已包含在已加载的扩展目录中。`); + } } const pongAfterInject = await pingContentScriptOnTab(tabId); - if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) { + if (isContentScriptReadyPong(source, pongAfterInject)) { await registerTab(source, tabId); return; } diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 485aa94..ba8ca83 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -21,8 +21,6 @@ async function executePlusCheckoutCreate() { await addLog('步骤 6:正在打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info'); const tabId = await reuseOrCreateTab(PLUS_CHECKOUT_SOURCE, PLUS_CHECKOUT_ENTRY_URL, { - inject: PLUS_CHECKOUT_INJECT_FILES, - injectSource: PLUS_CHECKOUT_SOURCE, reloadIfSameUrl: false, }); diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 1beca2d..749a733 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -1,6 +1,7 @@ // content/plus-checkout.js — ChatGPT Plus checkout helper. console.log('[MultiPage:plus-checkout] Content script loaded on', location.href); +window.__MULTIPAGE_PLUS_CHECKOUT_READY__ = true; const PLUS_CHECKOUT_LISTENER_SENTINEL = 'data-multipage-plus-checkout-listener'; const PLUS_CHECKOUT_PAYLOAD = { diff --git a/content/utils.js b/content/utils.js index a38143d..f208aca 100644 --- a/content/utils.js +++ b/content/utils.js @@ -34,6 +34,10 @@ const SCRIPT_SOURCE = (() => { }); })(); +function getRuntimeScriptSource() { + return window.__MULTIPAGE_SOURCE || SCRIPT_SOURCE; +} + const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; let flowStopped = false; @@ -51,7 +55,8 @@ if (!window.__MULTIPAGE_UTILS_LISTENER_READY__) { if (message.type === 'PING') { sendResponse({ ok: true, - source: SCRIPT_SOURCE, + source: getRuntimeScriptSource(), + plusCheckoutReady: Boolean(window.__MULTIPAGE_PLUS_CHECKOUT_READY__), }); } }); @@ -265,7 +270,7 @@ function fillSelect(el, value) { function log(message, level = 'info') { chrome.runtime.sendMessage({ type: 'LOG', - source: SCRIPT_SOURCE, + source: getRuntimeScriptSource(), step: null, payload: { message, level, timestamp: Date.now() }, error: null, @@ -279,7 +284,7 @@ function reportReady() { console.log(LOG_PREFIX, '内容脚本已就绪'); const message = { type: 'CONTENT_SCRIPT_READY', - source: SCRIPT_SOURCE, + source: getRuntimeScriptSource(), step: null, payload: {}, error: null, @@ -303,7 +308,7 @@ function reportComplete(step, data = {}) { log(`步骤 ${step} 已成功完成`, 'ok'); const message = { type: 'STEP_COMPLETE', - source: SCRIPT_SOURCE, + source: getRuntimeScriptSource(), step, payload: data, error: null, @@ -334,7 +339,7 @@ function reportError(step, errorMessage) { log(`步骤 ${step} 失败:${errorMessage}`, 'error'); const message = { type: 'STEP_ERROR', - source: SCRIPT_SOURCE, + source: getRuntimeScriptSource(), step, payload: {}, error: errorMessage, @@ -435,6 +440,6 @@ function shouldReportReadyForFrame(source, isChildFrame) { // 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)) { +if (shouldReportReadyForFrame(getRuntimeScriptSource(), window !== window.top)) { reportReady(); } diff --git a/mail-provider-utils.js b/mail-provider-utils.js index 6bd3960..0a61579 100644 --- a/mail-provider-utils.js +++ b/mail-provider-utils.js @@ -1,127 +1,131 @@ -const HOTMAIL_PROVIDER = 'hotmail-api'; -const GMAIL_PROVIDER = 'gmail'; -const NETEASE_LIST_PATH = '/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D'; -const ICLOUD_TARGET_MAILBOX_TYPE_INBOX = 'icloud-inbox'; -const ICLOUD_TARGET_MAILBOX_TYPE_FORWARD = 'forward-mailbox'; -const ICLOUD_FORWARD_MAIL_PROVIDER_OPTIONS = [ - { value: 'qq', label: 'QQ 邮箱' }, - { value: '163', label: '163 邮箱' }, - { value: '163-vip', label: '163 VIP 邮箱' }, - { value: '126', label: '126 邮箱' }, - { value: GMAIL_PROVIDER, label: 'Gmail 邮箱' }, -]; +(function attachMailProviderUtils(root, factory) { + const api = factory(); -function normalizeMailProvider(value = '') { - const normalized = String(value || '').trim().toLowerCase(); - switch (normalized) { - case HOTMAIL_PROVIDER: - case '163': - case '163-vip': - case '126': - case 'qq': - case 'inbucket': - return normalized; - default: - return '163'; - } -} - -function normalizeIcloudTargetMailboxType(value = '') { - return String(value || '').trim().toLowerCase() === ICLOUD_TARGET_MAILBOX_TYPE_FORWARD - ? ICLOUD_TARGET_MAILBOX_TYPE_FORWARD - : ICLOUD_TARGET_MAILBOX_TYPE_INBOX; -} - -function normalizeIcloudForwardMailProvider(value = '') { - const normalized = String(value || '').trim().toLowerCase(); - return ICLOUD_FORWARD_MAIL_PROVIDER_OPTIONS.some((option) => option.value === normalized) - ? normalized - : 'qq'; -} - -function getIcloudForwardMailProviderOptions() { - return ICLOUD_FORWARD_MAIL_PROVIDER_OPTIONS.map((option) => ({ ...option })); -} - -function getIcloudForwardMailConfig(provider = 'qq') { - const normalizedProvider = normalizeIcloudForwardMailProvider(provider); - if (normalizedProvider === GMAIL_PROVIDER) { - return { - source: 'gmail-mail', - url: 'https://mail.google.com/mail/u/0/#inbox', - label: 'Gmail 邮箱', - inject: ['content/activation-utils.js', 'content/utils.js', 'content/gmail-mail.js'], - injectSource: 'gmail-mail', - }; + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; } - return getMailProviderConfig({ mailProvider: normalizedProvider }); -} + if (root) { + root.MailProviderUtils = api; + } +})(typeof self !== 'undefined' ? self : globalThis, function createMailProviderUtils() { + const HOTMAIL_PROVIDER = 'hotmail-api'; + const GMAIL_PROVIDER = 'gmail'; + const NETEASE_LIST_PATH = '/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D'; + const ICLOUD_TARGET_MAILBOX_TYPE_INBOX = 'icloud-inbox'; + const ICLOUD_TARGET_MAILBOX_TYPE_FORWARD = 'forward-mailbox'; + const ICLOUD_FORWARD_MAIL_PROVIDER_OPTIONS = [ + { value: 'qq', label: 'QQ 邮箱' }, + { value: '163', label: '163 邮箱' }, + { value: '163-vip', label: '163 VIP 邮箱' }, + { value: '126', label: '126 邮箱' }, + { value: GMAIL_PROVIDER, label: 'Gmail 邮箱' }, + ]; -function getMailProviderConfig(state = {}, options = {}) { - const provider = normalizeMailProvider(state.mailProvider); - const normalizeInbucketOrigin = options.normalizeInbucketOrigin || (() => ''); - - if (provider === HOTMAIL_PROVIDER) { - return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(微软 Graph)' }; - } - if (provider === '163') { - return { - source: 'mail-163', - url: `https://mail.163.com${NETEASE_LIST_PATH}`, - label: '163 邮箱', - }; - } - if (provider === '163-vip') { - return { - source: 'mail-163', - url: `https://webmail.vip.163.com${NETEASE_LIST_PATH}`, - label: '163 VIP 邮箱', - }; - } - if (provider === '126') { - return { - source: 'mail-163', - url: `https://mail.126.com${NETEASE_LIST_PATH}`, - label: '126 邮箱', - }; - } - if (provider === 'inbucket') { - const host = normalizeInbucketOrigin(state.inbucketHost); - const mailbox = String(state.inbucketMailbox || '').trim(); - if (!host) { - return { error: 'Inbucket 主机地址为空或无效。' }; + function normalizeMailProvider(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + switch (normalized) { + case HOTMAIL_PROVIDER: + case '163': + case '163-vip': + case '126': + case 'qq': + case 'inbucket': + return normalized; + default: + return '163'; } - if (!mailbox) { - return { error: 'Inbucket 邮箱名称为空。' }; - } - return { - source: 'inbucket-mail', - url: `${host}/m/${encodeURIComponent(mailbox)}/`, - label: `Inbucket 邮箱(${mailbox})`, - navigateOnReuse: true, - inject: ['content/activation-utils.js', 'content/utils.js', 'content/inbucket-mail.js'], - injectSource: 'inbucket-mail', - }; } - return { source: 'qq-mail', url: 'https://wx.mail.qq.com/', label: 'QQ 邮箱' }; -} -const api = { - GMAIL_PROVIDER, - HOTMAIL_PROVIDER, - getIcloudForwardMailConfig, - getIcloudForwardMailProviderOptions, - getMailProviderConfig, - normalizeIcloudForwardMailProvider, - normalizeIcloudTargetMailboxType, - normalizeMailProvider, -}; + function normalizeIcloudTargetMailboxType(value = '') { + return String(value || '').trim().toLowerCase() === ICLOUD_TARGET_MAILBOX_TYPE_FORWARD + ? ICLOUD_TARGET_MAILBOX_TYPE_FORWARD + : ICLOUD_TARGET_MAILBOX_TYPE_INBOX; + } -if (typeof module !== 'undefined' && module.exports) { - module.exports = api; -} + function normalizeIcloudForwardMailProvider(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return ICLOUD_FORWARD_MAIL_PROVIDER_OPTIONS.some((option) => option.value === normalized) + ? normalized + : 'qq'; + } -if (typeof self !== 'undefined') { - self.MailProviderUtils = api; -} + function getIcloudForwardMailProviderOptions() { + return ICLOUD_FORWARD_MAIL_PROVIDER_OPTIONS.map((option) => ({ ...option })); + } + + function getIcloudForwardMailConfig(provider = 'qq') { + const normalizedProvider = normalizeIcloudForwardMailProvider(provider); + if (normalizedProvider === GMAIL_PROVIDER) { + return { + source: 'gmail-mail', + url: 'https://mail.google.com/mail/u/0/#inbox', + label: 'Gmail 邮箱', + inject: ['content/activation-utils.js', 'content/utils.js', 'content/gmail-mail.js'], + injectSource: 'gmail-mail', + }; + } + + return getMailProviderConfig({ mailProvider: normalizedProvider }); + } + + function getMailProviderConfig(state = {}, options = {}) { + const provider = normalizeMailProvider(state.mailProvider); + const normalizeInbucketOrigin = options.normalizeInbucketOrigin || (() => ''); + + if (provider === HOTMAIL_PROVIDER) { + return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(微软 Graph)' }; + } + if (provider === '163') { + return { + source: 'mail-163', + url: `https://mail.163.com${NETEASE_LIST_PATH}`, + label: '163 邮箱', + }; + } + if (provider === '163-vip') { + return { + source: 'mail-163', + url: `https://webmail.vip.163.com${NETEASE_LIST_PATH}`, + label: '163 VIP 邮箱', + }; + } + if (provider === '126') { + return { + source: 'mail-163', + url: `https://mail.126.com${NETEASE_LIST_PATH}`, + label: '126 邮箱', + }; + } + if (provider === 'inbucket') { + const host = normalizeInbucketOrigin(state.inbucketHost); + const mailbox = String(state.inbucketMailbox || '').trim(); + if (!host) { + return { error: 'Inbucket 主机地址为空或无效。' }; + } + if (!mailbox) { + return { error: 'Inbucket 邮箱名称为空。' }; + } + return { + source: 'inbucket-mail', + url: `${host}/m/${encodeURIComponent(mailbox)}/`, + label: `Inbucket 邮箱(${mailbox})`, + navigateOnReuse: true, + inject: ['content/activation-utils.js', 'content/utils.js', 'content/inbucket-mail.js'], + injectSource: 'inbucket-mail', + }; + } + return { source: 'qq-mail', url: 'https://wx.mail.qq.com/', label: 'QQ 邮箱' }; + } + + return { + GMAIL_PROVIDER, + HOTMAIL_PROVIDER, + getIcloudForwardMailConfig, + getIcloudForwardMailProviderOptions, + getMailProviderConfig, + normalizeIcloudForwardMailProvider, + normalizeIcloudTargetMailboxType, + normalizeMailProvider, + }; +}); diff --git a/tests/content-utils.test.js b/tests/content-utils.test.js index 01380a5..8703fdd 100644 --- a/tests/content-utils.test.js +++ b/tests/content-utils.test.js @@ -92,3 +92,16 @@ return { shouldReportReadyForFrame }; assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true); assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true); }); + +test('getRuntimeScriptSource follows injected source overrides after utils is already loaded', () => { + const bundle = [extractFunction('getRuntimeScriptSource')].join('\n'); + const api = new Function('window', 'SCRIPT_SOURCE', ` +${bundle} +return { getRuntimeScriptSource }; +`); + + const windowRef = {}; + assert.equal(api(windowRef, 'chatgpt').getRuntimeScriptSource(), 'chatgpt'); + windowRef.__MULTIPAGE_SOURCE = 'plus-checkout'; + assert.equal(api(windowRef, 'chatgpt').getRuntimeScriptSource(), 'plus-checkout'); +}); diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index 3fd9f41..097c645 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -25,7 +25,10 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page' ensureContentScriptReadyOnTabUntilStopped: async () => { events.push({ type: 'ready' }); }, - reuseOrCreateTab: async () => 42, + reuseOrCreateTab: async (source, url, options) => { + events.push({ type: 'reuse-tab', source, url, options }); + return 42; + }, sendTabMessageUntilStopped: async () => ({ checkoutUrl: 'https://checkout.stripe.com/c/pay/session', country: 'US', @@ -44,6 +47,11 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page' await executor.executePlusCheckoutCreate(); + const reuseEvent = events.find((event) => event.type === 'reuse-tab'); + assert.equal(reuseEvent.source, 'plus-checkout'); + assert.equal(reuseEvent.options.reloadIfSameUrl, false); + assert.equal(Object.hasOwn(reuseEvent.options, 'inject'), false); + const sleepEvents = events.filter((event) => event.type === 'sleep'); assert.deepStrictEqual(sleepEvents.map((event) => event.ms), [1000, 1000]); From 6c2bec652073af7b057c91d946c45b7a35334d41 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 15:50:37 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Plus=20Checkou?= =?UTF-8?q?t=20=E5=88=9B=E5=BB=BA=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=87=8D=E7=94=A8=20ChatGPT=20=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=E5=B9=B6=E4=BC=98=E5=8C=96=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 3 + background/steps/create-plus-checkout.js | 55 +++++++++++++++++- tests/plus-checkout-create-wait.test.js | 71 ++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/background.js b/background.js index 94e90f2..bddd1d3 100644 --- a/background.js +++ b/background.js @@ -7067,6 +7067,9 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + registerTab, reuseOrCreateTab, sendTabMessageUntilStopped, setState, diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index ba8ca83..8686f95 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -2,8 +2,21 @@ root.MultiPageBackgroundPlusCheckoutCreate = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutCreateModule() { const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + const SIGNUP_PAGE_SOURCE = 'signup-page'; const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/'; const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; + const PLUS_CHECKOUT_ONLY_INJECT_FILES = ['content/plus-checkout.js']; + + function isReusableChatGptTabUrl(url = '') { + try { + const parsed = new URL(String(url || '')); + const hostname = parsed.hostname.toLowerCase(); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname) + && !/^\/checkout(?:\/|$)/i.test(parsed.pathname || ''); + } catch { + return false; + } + } function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -11,6 +24,9 @@ chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + registerTab, reuseOrCreateTab, sendTabMessageUntilStopped, setState, @@ -18,16 +34,49 @@ waitForTabCompleteUntilStopped, } = deps; - async function executePlusCheckoutCreate() { - await addLog('步骤 6:正在打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info'); + async function getReusableSignupChatGptTabId() { + if (typeof getTabId !== 'function' || typeof isTabAlive !== 'function') { + return null; + } + const tabId = await getTabId(SIGNUP_PAGE_SOURCE); + if (!tabId || !(await isTabAlive(SIGNUP_PAGE_SOURCE))) { + return null; + } + const tab = await chrome.tabs.get(tabId).catch(() => null); + return tab && isReusableChatGptTabUrl(tab.url) ? tabId : null; + } + + async function resolveChatGptTabForCheckoutCreate() { + const existingSignupTabId = await getReusableSignupChatGptTabId(); + if (existingSignupTabId) { + await chrome.tabs.update(existingSignupTabId, { active: true }); + if (typeof registerTab === 'function') { + await registerTab(PLUS_CHECKOUT_SOURCE, existingSignupTabId); + } + await addLog('步骤 6:检测到第 5 步已打开 ChatGPT 页面,直接接管当前标签页创建 Plus Checkout。', 'info'); + return { + tabId: existingSignupTabId, + injectFiles: PLUS_CHECKOUT_ONLY_INJECT_FILES, + }; + } + const tabId = await reuseOrCreateTab(PLUS_CHECKOUT_SOURCE, PLUS_CHECKOUT_ENTRY_URL, { reloadIfSameUrl: false, }); + return { + tabId, + injectFiles: PLUS_CHECKOUT_INJECT_FILES, + }; + } + + async function executePlusCheckoutCreate() { + await addLog('步骤 6:正在打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info'); + const { tabId, injectFiles } = await resolveChatGptTabForCheckoutCreate(); await waitForTabCompleteUntilStopped(tabId); await sleepWithStop(1000); await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, { - inject: PLUS_CHECKOUT_INJECT_FILES, + inject: injectFiles, injectSource: PLUS_CHECKOUT_SOURCE, logMessage: '步骤 6:ChatGPT 页面仍在加载,等待 Plus Checkout 脚本就绪...', }); diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index 097c645..02325fa 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -62,3 +62,74 @@ test('Plus checkout create does not wait 20 seconds after opening checkout page' assert.equal(events.some((event) => event.type === 'sleep' && event.ms === 20000), false); assert.equal(events.some((event) => event.type === 'log' && /固定等待 20 秒后继续下一步/.test(event.message)), false); }); + +test('Plus checkout create reuses the ChatGPT tab from signup step 5', async () => { + const events = []; + const executor = api.createPlusCheckoutCreateExecutor({ + addLog: async (message, level = 'info') => { + events.push({ type: 'log', message, level }); + }, + chrome: { + tabs: { + get: async (tabId) => { + events.push({ type: 'tab-get', tabId }); + return { id: tabId, url: 'https://chatgpt.com/' }; + }, + update: async (tabId, payload) => { + events.push({ type: 'tab-update', tabId, payload }); + }, + }, + }, + completeStepFromBackground: async (step, payload) => { + events.push({ type: 'complete', step, payload }); + }, + ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => { + events.push({ type: 'ready', source, tabId, options }); + }, + getTabId: async (source) => { + events.push({ type: 'get-tab-id', source }); + return source === 'signup-page' ? 55 : null; + }, + isTabAlive: async (source) => { + events.push({ type: 'alive', source }); + return source === 'signup-page'; + }, + registerTab: async (source, tabId) => { + events.push({ type: 'register', source, tabId }); + }, + reuseOrCreateTab: async () => { + events.push({ type: 'reuse-tab' }); + return 42; + }, + sendTabMessageUntilStopped: async () => ({ + checkoutUrl: 'https://checkout.stripe.com/c/pay/session', + country: 'US', + currency: 'USD', + }), + setState: async (payload) => { + events.push({ type: 'set-state', payload }); + }, + sleepWithStop: async (ms) => { + events.push({ type: 'sleep', ms }); + }, + waitForTabCompleteUntilStopped: async (tabId) => { + events.push({ type: 'tab-complete', tabId }); + }, + }); + + await executor.executePlusCheckoutCreate(); + + assert.equal(events.some((event) => event.type === 'reuse-tab'), false); + assert.deepEqual( + events.find((event) => event.type === 'register'), + { type: 'register', source: 'plus-checkout', tabId: 55 } + ); + assert.deepEqual( + events.find((event) => event.type === 'ready').options.inject, + ['content/plus-checkout.js'] + ); + assert.equal( + events.some((event) => event.type === 'log' && /直接接管当前标签页/.test(event.message)), + true + ); +}); From 5271ec5b5936c4b8003e3acf8622ec40eb34550c Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 15:59:14 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=97=A5=E6=9C=AC=E5=9C=B0=E5=8C=BA=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/fill-plus-checkout.js | 4 + content/plus-checkout.js | 159 ++++++++++++++++++++-- data/address-sources.js | 23 ++++ tests/address-sources.test.js | 5 + tests/plus-checkout-address-input.test.js | 86 ++++++++++++ 5 files changed, 264 insertions(+), 13 deletions(-) diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index cec29ba..c02c58a 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -12,6 +12,7 @@ FR: '/fr-address', US: '/', }; + const MEIGUODIZHI_SUPPORTED_COUNTRIES = new Set(Object.keys(MEIGUODIZHI_PATH_BY_COUNTRY)); function createPlusCheckoutBillingExecutor(deps = {}) { const { @@ -75,6 +76,9 @@ if (typeof fetchImpl !== 'function') { return null; } + if (!MEIGUODIZHI_SUPPORTED_COUNTRIES.has(countryCode)) { + return null; + } const path = MEIGUODIZHI_PATH_BY_COUNTRY[countryCode] || MEIGUODIZHI_PATH_BY_COUNTRY.DE; const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || ''); const response = await fetchImpl(MEIGUODIZHI_ADDRESS_ENDPOINT, { diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 749a733..6ba1026 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -503,6 +503,10 @@ function readCountryText() { const option = countrySelect.selectedOptions?.[0]; return option?.textContent || countrySelect.value || ''; } + const countryDropdown = findCountryDropdown(); + if (countryDropdown) { + return getCountryDropdownValue(countryDropdown); + } return ''; } @@ -664,14 +668,16 @@ function getRegionCandidates(value) { tas: 'Tasmania', vic: 'Victoria', wa: 'Western Australia', + tokyo: '東京都', + osaka: '大阪府', }; - const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, ''); + const compact = raw.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); const candidates = [raw]; if (aliases[compact]) { candidates.push(aliases[compact]); } for (const [abbr, name] of Object.entries(aliases)) { - const compactName = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + const compactName = name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); if (compact === compactName) { candidates.push(abbr.toUpperCase()); } @@ -679,13 +685,73 @@ function getRegionCandidates(value) { return Array.from(new Set(candidates.filter(Boolean))); } +function getCountryCandidates(value = '') { + const raw = normalizeText(value); + const compact = raw.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); + const aliases = { + AU: ['Australia', '澳大利亚'], + DE: ['Germany', 'Deutschland', '德国'], + FR: ['France', '法国'], + JP: ['Japan', '日本', '日本国'], + US: ['United States', 'United States of America', 'USA', '美国'], + }; + const direct = aliases[String(raw || '').trim().toUpperCase()] || []; + const matched = Object.entries(aliases).find(([code, names]) => { + if (String(code).toLowerCase() === compact) return true; + return names.some((name) => { + const normalizedName = normalizeText(name).toLowerCase(); + const compactName = normalizedName.replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); + return compact === compactName || normalizedName === raw.toLowerCase(); + }); + }); + return Array.from(new Set([raw, ...direct, ...(matched ? matched[1] : [])].filter(Boolean))); +} + +function matchesCountryOption(text, desiredValue) { + const normalizedText = normalizeText(text).toLowerCase(); + const compactText = normalizedText.replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); + if (!compactText) return false; + return getCountryCandidates(desiredValue).some((candidate) => { + const normalizedCandidate = normalizeText(candidate).toLowerCase(); + const compactCandidate = normalizedCandidate.replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); + if (!compactCandidate) return false; + return normalizedText === normalizedCandidate + || compactText === compactCandidate + || (compactCandidate.length > 3 && compactText.includes(compactCandidate)); + }); +} + +function findCountryDropdown() { + const controls = getVisibleControls('select, button, [role="button"], [role="combobox"], [aria-haspopup="listbox"]'); + return controls.find((control) => { + if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false; + const text = getFieldText(control); + return /country/i.test(text) || /\u56fd\u5bb6|\u56fd\u5bb6\u6216\u5730\u533a/.test(text); + }) || null; +} + +function getCountryDropdownValue(control) { + if (!control) return ''; + if (String(control.tagName || '').toUpperCase() === 'SELECT') { + const selected = control.selectedOptions?.[0]; + return normalizeText(selected?.textContent || control.value || ''); + } + return normalizeText( + control.getAttribute?.('aria-valuetext') + || control.getAttribute?.('aria-label') + || control.getAttribute?.('data-value') + || control.textContent + || '' + ); +} + function matchesRegionOption(text, desiredValue) { const normalizedText = normalizeText(text).toLowerCase(); - const compactText = normalizedText.replace(/[^a-z0-9]/g, ''); + const compactText = normalizedText.replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); if (!compactText) return false; return getRegionCandidates(desiredValue).some((candidate) => { const normalizedCandidate = normalizeText(candidate).toLowerCase(); - const compactCandidate = normalizedCandidate.replace(/[^a-z0-9]/g, ''); + const compactCandidate = normalizedCandidate.replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); if (!compactCandidate) return false; return normalizedText === normalizedCandidate || compactText === compactCandidate @@ -701,7 +767,7 @@ function findRegionDropdown() { if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false; return /state|province|county/i.test(text) || /(?:^|\s)region(?:\s|$)/i.test(text) - || /\u5dde|\u7701/.test(text); + || /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text); }) || null; } @@ -786,6 +852,53 @@ async function selectRegionDropdown(regionDropdown, value) { return true; } +async function selectCountryDropdown(countryDropdown, value) { + if (!countryDropdown || !value) return false; + if (matchesCountryOption(getCountryDropdownValue(countryDropdown), value)) { + return false; + } + + if (String(countryDropdown.tagName || '').toUpperCase() === 'SELECT') { + const option = Array.from(countryDropdown.options || []).find((item) => ( + matchesCountryOption(item.textContent || '', value) + || matchesCountryOption(item.value || '', value) + )); + if (!option) { + throw new Error(`Plus Checkout: country dropdown option "${value}" was not found.`); + } + countryDropdown.value = option.value; + option.selected = true; + countryDropdown.dispatchEvent(new Event('input', { bubbles: true })); + countryDropdown.dispatchEvent(new Event('change', { bubbles: true })); + await sleep(500); + return true; + } + + simulateClick(countryDropdown); + await sleep(250); + const startedAt = Date.now(); + let option = null; + while (Date.now() - startedAt < 2500) { + throwIfStopped(); + option = getVisibleRegionOptions().find((item) => ( + matchesCountryOption(getActionText(item) || item.textContent || '', value) + )); + if (option) break; + await sleep(100); + } + if (!option) { + const visibleOptions = getVisibleRegionOptions() + .map((item) => normalizeText(getActionText(item) || item.textContent || '')) + .filter(Boolean) + .slice(0, 12) + .join(' | '); + throw new Error(`Plus Checkout: country dropdown option "${value}" was not found. Visible options: ${visibleOptions || 'none'}.`); + } + simulateClick(option); + await sleep(700); + return true; +} + function getStructuredAddressFields() { const address1 = findInputByFieldText([ /address\s*(?:line)?\s*1|street/i, @@ -810,15 +923,31 @@ function getStructuredAddressFields() { return { address1, address2, city, region, postalCode }; } -function fillIfEmpty(input, value) { +function fillIfEmpty(input, value, options = {}) { if (!input || !value) return false; - if (String(input.value || '').trim()) return false; + if (!options.overwrite && String(input.value || '').trim()) return false; + if (options.overwrite && String(input.value || '').trim() === String(value || '').trim()) return false; fillInput(input, value); return true; } -async function ensureStructuredAddress(seed) { +function isDropdownStructuredAddressForm(fields = getStructuredAddressFields()) { + return Boolean( + findCountryDropdown() + && findRegionDropdown() + && fields.address1 + && fields.city + && fields.postalCode + ); +} + +async function ensureStructuredAddress(seed, options = {}) { const fallback = seed?.fallback || {}; + const overwrite = Boolean(options.overwrite); + const countryDropdown = findCountryDropdown(); + if (countryDropdown && seed?.countryCode) { + await selectCountryDropdown(countryDropdown, seed.countryCode); + } const fields = await waitUntil(() => { const currentFields = getStructuredAddressFields(); if (currentFields.address1 || currentFields.city || currentFields.postalCode) { @@ -830,10 +959,10 @@ async function ensureStructuredAddress(seed) { intervalMs: 250, }); - fillIfEmpty(fields.address1, fallback.address1); - fillIfEmpty(fields.city, fallback.city); + fillIfEmpty(fields.address1, fallback.address1, { overwrite }); + fillIfEmpty(fields.city, fallback.city, { overwrite }); await selectRegionDropdown(findRegionDropdown(), fallback.region); - fillIfEmpty(fields.postalCode, fallback.postalCode); + fillIfEmpty(fields.postalCode, fallback.postalCode, { overwrite }); await sleep(500); const latest = getStructuredAddressFields(); @@ -895,10 +1024,14 @@ async function fillPlusBillingAddress(payload = {}) { }, }; let selected = { selectedText: '' }; - if (!seed.skipAutocomplete) { + const fields = getStructuredAddressFields(); + const useDirectStructuredBranch = Boolean(seed.skipAutocomplete || isDropdownStructuredAddressForm(fields)); + if (!useDirectStructuredBranch) { selected = await selectAddressSuggestion(seed); } - const structuredAddress = await ensureStructuredAddress(seed); + const structuredAddress = await ensureStructuredAddress(seed, { + overwrite: useDirectStructuredBranch, + }); return { countryText, diff --git a/data/address-sources.js b/data/address-sources.js index df56c85..b670fb6 100644 --- a/data/address-sources.js +++ b/data/address-sources.js @@ -5,6 +5,7 @@ AU: ['au', 'aus', 'australia', '澳大利亚'], DE: ['de', 'deu', 'germany', 'deutschland', '德国'], FR: ['fr', 'fra', 'france', '法国'], + JP: ['jp', 'jpn', 'japan', '日本', '日本国'], US: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'], }; @@ -75,6 +76,28 @@ }, }, ], + JP: [ + { + query: 'Tokyo Marunouchi', + suggestionIndex: 1, + fallback: { + address1: 'Marunouchi 1-1', + city: 'Chiyoda-ku', + region: 'Tokyo', + postalCode: '100-0005', + }, + }, + { + query: 'Osaka Umeda', + suggestionIndex: 1, + fallback: { + address1: 'Umeda 3-1', + city: 'Kita-ku', + region: 'Osaka', + postalCode: '530-0001', + }, + }, + ], US: [ { query: 'New York NY', diff --git a/tests/address-sources.test.js b/tests/address-sources.test.js index 98dd917..5ffeb6f 100644 --- a/tests/address-sources.test.js +++ b/tests/address-sources.test.js @@ -9,6 +9,7 @@ test('address sources normalize supported countries and return local seeds', () assert.equal(api.normalizeCountryCode('Deutschland'), 'DE'); assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU'); + assert.equal(api.normalizeCountryCode('日本'), 'JP'); assert.equal(api.normalizeCountryCode('unknown'), ''); const deSeed = api.getAddressSeedForCountry('DE'); @@ -20,4 +21,8 @@ test('address sources normalize supported countries and return local seeds', () const fallbackSeed = api.getAddressSeedForCountry('unknown', { fallbackCountry: 'AU' }); assert.equal(fallbackSeed.countryCode, 'AU'); assert.equal(fallbackSeed.fallback.region, 'New South Wales'); + + const jpSeed = api.getAddressSeedForCountry('日本'); + assert.equal(jpSeed.countryCode, 'JP'); + assert.equal(jpSeed.fallback.region, 'Tokyo'); }); diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index fd95e91..68214cb 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -253,3 +253,89 @@ return { selectRegionDropdown }; assert.deepEqual(clicks, [stateDropdown, options[1]]); }); + +test('country and region helpers recognize the dropdown-style localized address form', () => { + const bundle = [ + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getFieldText'), + extractFunction('getVisibleControls'), + extractFunction('isEnabledControl'), + extractFunction('isDocumentLevelContainer'), + extractFunction('getCountryCandidates'), + extractFunction('matchesCountryOption'), + extractFunction('findCountryDropdown'), + extractFunction('getRegionCandidates'), + extractFunction('matchesRegionOption'), + extractFunction('findRegionDropdown'), + ].join('\n'); + + const countryDropdown = createElement({ + tagName: 'DIV', + text: '国家或地区 日本', + attrs: { + role: 'combobox', + 'aria-haspopup': 'listbox', + }, + }); + const regionDropdown = createElement({ + tagName: 'DIV', + text: '辖区 选择', + attrs: { + role: 'combobox', + 'aria-haspopup': 'listbox', + }, + }); + const elements = [countryDropdown, regionDropdown]; + const documentMock = { + documentElement: {}, + body: {}, + querySelectorAll: (selector) => { + if (String(selector || '').includes('label[for=')) return []; + if (String(selector || '').includes('combobox') || String(selector || '').includes('button') || String(selector || '').includes('select')) { + return elements; + } + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { findCountryDropdown, findRegionDropdown, matchesCountryOption, matchesRegionOption }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.findCountryDropdown(), countryDropdown); + assert.equal(api.findRegionDropdown(), regionDropdown); + assert.equal(api.matchesCountryOption('日本', 'JP'), true); + assert.equal(api.matchesCountryOption('德国', 'DE'), true); + assert.equal(api.matchesRegionOption('東京都', 'Tokyo'), true); +}); + +test('fillIfEmpty can overwrite invalid structured address values in the dropdown branch', () => { + const bundle = [ + extractFunction('fillIfEmpty'), + ].join('\n'); + const input = { value: '77022' }; + const writes = []; + const api = new Function('input', 'writes', ` +function fillInput(el, value) { + writes.push(value); + el.value = value; +} +${bundle} +return { fillIfEmpty }; +`)(input, writes); + + assert.equal(api.fillIfEmpty(input, '100-0005'), false); + assert.equal(input.value, '77022'); + assert.equal(api.fillIfEmpty(input, '100-0005', { overwrite: true }), true); + assert.equal(input.value, '100-0005'); + assert.deepEqual(writes, ['100-0005']); +}); From 344158e465c8a32a6a2f628567ceefde44fcf58c Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 15:59:51 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Plus=20Checkou?= =?UTF-8?q?t=20=E8=B4=A6=E5=8D=95=E5=9C=B0=E5=9D=80=E5=A4=84=E7=90=86?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A=E5=9B=BD=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8F=8A=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=9B=BD?= =?UTF-8?q?=E5=AE=B6=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/fill-plus-checkout.js | 107 ++++++++++++++---- content/plus-checkout.js | 17 +++ ...us-checkout-billing-tab-resolution.test.js | 52 ++++++++- 3 files changed, 155 insertions(+), 21 deletions(-) diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index c02c58a..e182799 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -6,13 +6,30 @@ const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i; const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500; const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz'; - const MEIGUODIZHI_PATH_BY_COUNTRY = { - AU: '/au-address', - DE: '/de-address', - FR: '/fr-address', - US: '/', + const MEIGUODIZHI_COUNTRY_CONFIG = { + AR: { path: '/ar-address', city: 'Buenos Aires', aliases: ['ar', 'argentina', '阿根廷'] }, + AU: { path: '/au-address', city: 'Sydney', aliases: ['au', 'aus', 'australia', '澳大利亚'] }, + CA: { path: '/ca-address', city: 'Toronto', aliases: ['ca', 'canada', '加拿大'] }, + CN: { path: '/cn-address', city: 'Shanghai', aliases: ['cn', 'china', '中国'] }, + DE: { path: '/de-address', city: 'Berlin', aliases: ['de', 'deu', 'germany', 'deutschland', '德国'] }, + ES: { path: '/es-address', city: 'Madrid', aliases: ['es', 'esp', 'spain', '西班牙'] }, + FR: { path: '/fr-address', city: 'Paris', aliases: ['fr', 'fra', 'france', '法国'] }, + GB: { path: '/uk-address', city: 'London', aliases: ['gb', 'uk', 'united kingdom', 'britain', 'england', '英国'] }, + HK: { path: '/hk-address', city: 'Hong Kong', aliases: ['hk', 'hong kong', '香港'] }, + IT: { path: '/it-address', city: 'Rome', aliases: ['it', 'ita', 'italy', '意大利'] }, + JP: { path: '/jp-address', city: 'Tokyo', aliases: ['jp', 'jpn', 'japan', '日本', '日本国'] }, + KR: { path: '/kr-address', city: 'Seoul', aliases: ['kr', 'kor', 'korea', 'south korea', '韩国'] }, + MY: { path: '/my-address', city: 'Kuala Lumpur', aliases: ['my', 'malaysia', '马来西亚'] }, + NL: { path: '/nl-address', city: 'Amsterdam', aliases: ['nl', 'netherlands', 'holland', '荷兰'] }, + PH: { path: '/ph-address', city: 'Manila', aliases: ['ph', 'philippines', '菲律宾'] }, + RU: { path: '/ru-address', city: 'Moscow', aliases: ['ru', 'russia', '俄罗斯'] }, + SG: { path: '/sg-address', city: 'Singapore', aliases: ['sg', 'singapore', '新加坡'] }, + TH: { path: '/th-address', city: 'Bangkok', aliases: ['th', 'thailand', '泰国'] }, + TR: { path: '/tr-address', city: 'Istanbul', aliases: ['tr', 'turkey', 'turkiye', '土耳其'] }, + TW: { path: '/tw-address', city: 'Taipei', aliases: ['tw', 'taiwan', '台湾'] }, + US: { path: '/', city: 'New York', aliases: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'] }, + VN: { path: '/vn-address', city: 'Ho Chi Minh City', aliases: ['vn', 'vietnam', '越南'] }, }; - const MEIGUODIZHI_SUPPORTED_COUNTRIES = new Set(Object.keys(MEIGUODIZHI_PATH_BY_COUNTRY)); function createPlusCheckoutBillingExecutor(deps = {}) { const { @@ -39,6 +56,27 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } + function compactCountryText(value = '') { + return normalizeText(value).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); + } + + function resolveMeiguodizhiCountryCode(value = '') { + const normalized = normalizeText(value); + const upper = normalized.toUpperCase(); + if (MEIGUODIZHI_COUNTRY_CONFIG[upper]) { + return upper; + } + const compact = compactCountryText(normalized); + const match = Object.entries(MEIGUODIZHI_COUNTRY_CONFIG).find(([code, config]) => ( + compact === code.toLowerCase() + || (config.aliases || []).some((alias) => { + const compactAlias = compactCountryText(alias); + return compact === compactAlias || compact.includes(compactAlias); + }) + )); + return match?.[0] || ''; + } + function hasCompleteAddressFallback(seed) { const fallback = seed?.fallback || {}; return Boolean( @@ -49,9 +87,9 @@ } function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) { - const address1 = normalizeText(apiAddress?.Address); + const address1 = normalizeText(apiAddress?.Trans_Address || apiAddress?.Address); const city = normalizeText(apiAddress?.City); - const region = normalizeText(apiAddress?.State || apiAddress?.State_Full); + const region = normalizeText(apiAddress?.State_Full || apiAddress?.State); const postalCode = normalizeText(apiAddress?.Zip_Code); if (!address1 || !city || !postalCode) { return null; @@ -76,11 +114,12 @@ if (typeof fetchImpl !== 'function') { return null; } - if (!MEIGUODIZHI_SUPPORTED_COUNTRIES.has(countryCode)) { + const countryConfig = MEIGUODIZHI_COUNTRY_CONFIG[countryCode]; + if (!countryConfig?.path) { return null; } - const path = MEIGUODIZHI_PATH_BY_COUNTRY[countryCode] || MEIGUODIZHI_PATH_BY_COUNTRY.DE; - const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || ''); + const path = countryConfig.path; + const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || countryConfig.city); const response = await fetchImpl(MEIGUODIZHI_ADDRESS_ENDPOINT, { method: 'POST', headers: { @@ -102,18 +141,43 @@ return buildDirectAddressSeed(countryCode, data.address || {}, fallbackSeed); } - async function resolveBillingAddressSeed(state = {}, countryOverride = '') { - const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE'); - const fallbackSeed = getAddressSeedForCountry(requestedCountry, { + function getLocalAddressSeed(countryCode) { + if (typeof getAddressSeedForCountry !== 'function') { + return null; + } + const seed = getAddressSeedForCountry(countryCode, { fallbackCountry: 'DE', }); - if (!fallbackSeed) { - throw new Error('步骤 7:未找到可用的本地账单地址种子。'); - } + return seed?.countryCode === countryCode ? seed : null; + } - const countryCode = fallbackSeed.countryCode || 'DE'; + function buildMeiguodizhiLookupSeed(countryCode) { + const config = MEIGUODIZHI_COUNTRY_CONFIG[countryCode]; + if (!config) { + return null; + } + return { + countryCode, + query: config.city, + fallback: { + address1: '', + city: config.city, + region: '', + postalCode: '', + }, + }; + } + + async function resolveBillingAddressSeed(state = {}, countryOverride = '') { + const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE'); + const countryCode = resolveMeiguodizhiCountryCode(requestedCountry) || 'DE'; + const localSeed = getLocalAddressSeed(countryCode); + const lookupSeed = localSeed || buildMeiguodizhiLookupSeed(countryCode); + if (!lookupSeed) { + throw new Error(`步骤 7:无法识别账单国家或地区:${requestedCountry || '空'}`); + } try { - const remoteSeed = await fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed); + const remoteSeed = await fetchMeiguodizhiAddressSeed(countryCode, lookupSeed); if (hasCompleteAddressFallback(remoteSeed)) { await addLog( `步骤 7:已从 meiguodizhi 接口获取账单地址(${remoteSeed.fallback.city} / ${remoteSeed.fallback.postalCode}),将跳过 Google 地址推荐。`, @@ -126,7 +190,10 @@ await addLog(`步骤 7:meiguodizhi 地址接口不可用,回退到本地地址种子:${error?.message || String(error || '')}`, 'warn'); } - return fallbackSeed; + if (hasCompleteAddressFallback(localSeed)) { + return localSeed; + } + throw new Error(`步骤 7:${requestedCountry} 的 meiguodizhi 地址不可用,且没有本地兜底地址。`); } async function getAlivePlusCheckoutTabId(tabId) { diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 6ba1026..81a10ad 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -689,11 +689,28 @@ function getCountryCandidates(value = '') { const raw = normalizeText(value); const compact = raw.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); const aliases = { + AR: ['Argentina', '阿根廷'], AU: ['Australia', '澳大利亚'], + CA: ['Canada', '加拿大'], + CN: ['China', '中国'], DE: ['Germany', 'Deutschland', '德国'], + ES: ['Spain', '西班牙'], FR: ['France', '法国'], + GB: ['United Kingdom', 'UK', 'Britain', 'England', '英国'], + HK: ['Hong Kong', '香港'], + IT: ['Italy', '意大利'], JP: ['Japan', '日本', '日本国'], + KR: ['Korea', 'South Korea', '韩国'], + MY: ['Malaysia', '马来西亚'], + NL: ['Netherlands', 'Holland', '荷兰'], + PH: ['Philippines', '菲律宾'], + RU: ['Russia', '俄罗斯'], + SG: ['Singapore', '新加坡'], + TH: ['Thailand', '泰国'], + TR: ['Turkey', 'Turkiye', '土耳其'], + TW: ['Taiwan', '台湾'], US: ['United States', 'United States of America', 'USA', '美国'], + VN: ['Vietnam', '越南'], }; const direct = aliases[String(raw || '').trim().toUpperCase()] || []; const matched = Object.entries(aliases).find(([code, names]) => { diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 350d6f5..24abcc4 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -344,7 +344,7 @@ test('Plus checkout billing uses the detected checkout country before choosing a await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' }); const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); - assert.equal(requestedCountries[0], 'Australia'); + assert.equal(requestedCountries[0], 'AU'); assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'AU'); assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.region, 'New South Wales'); assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { @@ -354,6 +354,56 @@ test('Plus checkout billing uses the detected checkout country before choosing a }); }); +test('Plus checkout billing uses meiguodizhi country paths for localized countries without local seeds', async () => { + const fetchRequests = []; + 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, + countryText: '日本', + }, + }, + fetchImpl: async (url, init) => { + fetchRequests.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ + status: 'ok', + address: { + Address: 'トウキョウト, ミナトク, シバダイモン, 10-4', + Trans_Address: '10-4, Shiba Daimon 2-chome, Minato-ku, Tokyo', + City: 'Tokyo', + State: 'Tokyo', + Zip_Code: '105-0012', + }, + }), + }; + }, + }); + + await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' }); + + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'JP'); + assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, '10-4, Shiba Daimon 2-chome, Minato-ku, Tokyo'); + assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { + city: 'Tokyo', + path: '/jp-address', + method: 'refresh', + }); +}); + test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => { const { executor } = createExecutorHarness({ frames: [ From 818791f39509c4dc4547f53f129b15fe356e42f8 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 16:28:24 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Plus=20Checkou?= =?UTF-8?q?t=20=E5=86=85=E5=AE=B9=E8=84=9A=E6=9C=AC=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E6=AC=A1=E6=B3=A8=E5=85=A5=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=8A=9F=E8=83=BD=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/plus-checkout.js | 2 + docs/使用教程.md | 91 +++++++++++++++++++++-- tests/plus-checkout-address-input.test.js | 34 +++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 81a10ad..216b194 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -1,5 +1,6 @@ // content/plus-checkout.js — ChatGPT Plus checkout helper. +(function attachPlusCheckoutContentScript() { console.log('[MultiPage:plus-checkout] Content script loaded on', location.href); window.__MULTIPAGE_PLUS_CHECKOUT_READY__ = true; @@ -1121,3 +1122,4 @@ function inspectPlusCheckoutState() { }, }; } +})(); diff --git a/docs/使用教程.md b/docs/使用教程.md index 2b2e1c1..4926491 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,12 +1,13 @@ -# Codex 注册扩展相关项目、更新、邮箱切换、PayPal 与 Clash Verge 配置教程 +# Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 - 需要拉取并部署 `cpa` 或 `sub2api` 项目 - 已经安装本扩展,想更新到最新版本 - 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务` +- 需要使用 `iCloud+` 的 `隐藏邮件地址` 作为隐私邮箱 - 需要临时切换 `QQ 邮箱` 地址继续使用 - 需要注册并使用 `PayPal` 个人账户 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` @@ -17,6 +18,8 @@ - 已安装好的扩展文件夹 - 可以打开浏览器的 `扩展程序管理` 页面 - 已准备好 `Cloudflare Temp Email` 后端地址;如需随机子域,域名解析也已配置完成 +- 一个已开通 `iCloud+` 的 Apple ID +- 一个用于接收转发邮件的邮箱 - 一个可正常登录的 `QQ 邮箱` - 一个可正常接收短信的手机号 - 一张可在线支付的借记卡或信用卡 @@ -100,7 +103,67 @@ 8. 查看搭建参考 如果你还没有部署后端,可参考 [LINUX DO 教程](https://linux.do/t/topic/316819)。 -### 第四部分:`QQ 邮箱`切换邮箱使用教程 +### 第四部分:`iCloud 隐私邮箱` 使用方法 + +1. 准备 Apple ID + 如果条件允许,建议使用非日常主力 Apple ID。 + 这样即使触发风控,也不会影响你平时常用的 Apple ID。 + +2. 在 `iPhone` 或 `iPad` 上开通并启用 `iCloud 邮件` + 打开 `设置` App。 + 点击顶部你的姓名,进入 `Apple ID` 设置。 + 点击 `iCloud`。 + 找到 `邮件`,打开右侧开关。 + 如果是首次开通,系统会提示你创建电子邮件地址。 + 输入你想要的邮箱前缀,例如 `yourname`。 + 系统会自动检查名称是否可用。 + 点击 `下一步` 后,即可创建 `yourname@icloud.com` 邮箱。 + +3. 在 `Mac` 上启用 `iCloud 邮件` + 点击屏幕左上角的苹果菜单。 + 打开 `系统设置`。 + 点击你的姓名,进入 `Apple ID` 设置。 + 点击 `iCloud`。 + 在应用列表中找到 `iCloud 邮件`,确认开关已经打开。 + 如果是首次开通,系统会引导你创建新的 `@icloud.com` 邮箱地址,按屏幕提示输入前缀即可完成。 + +4. 进入 `隐藏邮件地址` + 在 `iCloud` 页面往下滑,找到 `iCloud+` 服务。 + 点击 `隐藏邮件地址`。 + 这里就是后续要使用的隐私邮箱功能。 + +5. 先手动创建一批隐私邮箱 + 建议先手动生成一个隐私邮箱确认流程可用。 + 如果插件端直接生成,可能会遇到冷却时间或风控。 + 更稳妥的做法是先手动创建一批,例如 `20` 个左右,让插件后续自动读取。 + 同时确认 `转发至` 已经设置为你要接收邮件的邮箱。 + 完成后,手机端或平板端的准备工作就结束了。 + +6. 配置插件端的邮箱服务 + 在插件里把 `邮箱服务` 选择为你用于接收转发邮件的邮箱。 + 然后登录该邮箱,确保能正常收信。 + +7. 配置插件端的邮箱生成 + 将 `邮件生成` 选择为 `iCloud 隐私邮箱`。 + 往下滑到对应配置区域。 + 先在网页中登录你的 `iCloud`。 + 回到插件的 `隐私邮箱配置` 中点击刷新,等待插件拉取你已经创建好的隐私邮箱。 + +8. 选择邮箱使用方式 + 建议选择 `复用未使用的`。 + 如果你希望使用后释放数量,可以勾选 `使用后删除`。 + 如果不删除,隐私邮箱可以保留为长期邮箱使用。 + 目前观察到,如果一直不删除,手动创建到 `20` 多个后可能会提示数量已满,后续需要继续观察具体原因。 + +9. 控制创建频率 + 建议每天最多新建 `3` 个左右。 + 测试中一天创建过多会触发 `iCloud` 风控提示。 + +10. 查看 Apple 官方说明 + `iCloud 邮件` 主地址创建说明:[Create a primary email address for iCloud Mail](https://support.apple.com/is-is/guide/icloud/mmdd8d1c5c/icloud) + `隐藏邮件地址` 创建与转发说明:[Create and edit Hide My Email addresses on iCloud.com](https://support.apple.com/lv-lv/guide/icloud/mm1a876f7aed/icloud) + +### 第五部分:`QQ 邮箱`切换邮箱使用教程 1. 登录 `QQ 邮箱` 先登录你当前正在使用的 `QQ 邮箱` 账号。 @@ -119,7 +182,7 @@ 这两个邮箱地址使用完成后,可以直接删除。 删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。 -### 第五部分:`PayPal` 注册与绑卡使用教程 +### 第六部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 @@ -171,14 +234,14 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第六部分:0元试用 ChatGPT Plus 教程 +### 第七部分:0元试用 ChatGPT Plus 教程 本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 #### 准备工作 1. 已有一个登录状态的 ChatGPT 账户。 -2. 一个可用的 PayPal 账户(参考第五部分进行注册和绑卡)。 +2. 一个可用的 PayPal 账户(参考第六部分进行注册和绑卡)。 3. 能够接收生成的账单地址的真实地址或虚拟地址。 4. Chrome 浏览器(推荐使用地址补全功能)。 @@ -326,7 +389,7 @@ - **PayPal 登录后页面无法继续跳转** 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 -### 第七部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第八部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -448,6 +511,17 @@ function main(config, profileName) { 可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。 +### `iCloud 隐私邮箱` 插件刷新后没有邮箱怎么办? + +先确认你已经在网页中登录 `iCloud`,并且已经在 `隐藏邮件地址` 里手动创建过隐私邮箱。 +然后回到插件的 `隐私邮箱配置` 里重新刷新,等待插件拉取已创建的邮箱。 + +### `iCloud 隐私邮箱` 要不要勾选 `使用后删除`? + +如果你想让邮箱数量及时释放,可以勾选 `使用后删除`。 +如果你想把某些地址当作长期邮箱保留,可以不删除。 +需要注意的是,如果一直不删除,隐私邮箱数量可能会很快达到上限。 + ### `PayPal` 绑卡时报错怎么办? 先去 `钱包` 页面再看一眼,有时虽然页面提示报错,但实际上已经绑定成功。 @@ -469,6 +543,9 @@ function main(config, profileName) { - 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。 - 如果同时把 `Cloudflare Temp Email` 用作 `邮箱生成` 和 `邮箱服务`,请同时检查 `Admin Auth`、`Custom Auth`、`Temp 域名` 和 `邮件接收` 是否都已配置。 - 开启 `随机子域` 前,请先确认后端已经配置 `RANDOM_SUBDOMAIN_DOMAINS`,并且 Cloudflare DNS 已完成 `MX *` 设置。 +- 使用 `iCloud 隐私邮箱` 前,建议先手动创建一批地址,再让插件读取并复用。 +- `iCloud 隐私邮箱` 不建议短时间大量新建,测试中一天创建过多会触发 `iCloud` 风控提示,建议每天最多新建 `3` 个左右。 +- 如果条件允许,建议使用非日常主力 Apple ID 配置 `iCloud 隐私邮箱`,避免影响平时常用账号。 - 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。 - `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。 - 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index 68214cb..5ab4de8 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -1,9 +1,43 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); +const vm = require('node:vm'); const source = fs.readFileSync('content/plus-checkout.js', 'utf8'); +test('plus checkout content script can be injected repeatedly on the same page', () => { + const attrs = new Map(); + const context = { + console: { log() {}, warn() {}, error() {}, info() {} }, + location: { href: 'https://chatgpt.com/' }, + window: {}, + document: { + documentElement: { + getAttribute(name) { + return attrs.get(name) || null; + }, + setAttribute(name, value) { + attrs.set(name, String(value)); + }, + }, + }, + chrome: { + runtime: { + onMessage: { + addListener() {}, + }, + }, + }, + }; + context.window = context; + vm.createContext(context); + + vm.runInContext(source, context); + vm.runInContext(source, context); + + assert.equal(context.__MULTIPAGE_PLUS_CHECKOUT_READY__, true); +}); + function extractFunction(name) { const plainStart = source.indexOf(`function ${name}(`); const asyncStart = source.indexOf(`async function ${name}(`);