Files
FlowPilot/background/steps/fill-plus-checkout.js
T

612 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function attachBackgroundPlusCheckoutBilling(root, factory) {
root.MultiPageBackgroundPlusCheckoutBilling = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusCheckoutBillingModule() {
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i;
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz';
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', '越南'] },
};
function createPlusCheckoutBillingExecutor(deps = {}) {
const {
addLog,
chrome,
completeStepFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null,
generateRandomName,
getAddressSeedForCountry,
getTabId,
isTabAlive,
setState,
sleepWithStop,
waitForTabCompleteUntilStopped,
waitForTabUrlMatchUntilStopped,
} = deps;
function isPlusCheckoutUrl(url = '') {
return PLUS_CHECKOUT_URL_PATTERN.test(String(url || ''));
}
function normalizeText(value = '') {
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(
normalizeText(fallback.address1)
&& normalizeText(fallback.city)
&& normalizeText(fallback.postalCode)
);
}
function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) {
const address1 = normalizeText(apiAddress?.Trans_Address || apiAddress?.Address);
const city = normalizeText(apiAddress?.City);
const region = normalizeText(apiAddress?.State_Full || apiAddress?.State);
const postalCode = normalizeText(apiAddress?.Zip_Code);
if (!address1 || !city || !postalCode) {
return null;
}
return {
...(fallbackSeed || {}),
countryCode,
query: [address1, city].filter(Boolean).join(', '),
source: 'meiguodizhi',
skipAutocomplete: true,
fallback: {
...(fallbackSeed?.fallback || {}),
address1,
city,
region,
postalCode,
},
};
}
async function fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed) {
if (typeof fetchImpl !== 'function') {
return null;
}
const countryConfig = MEIGUODIZHI_COUNTRY_CONFIG[countryCode];
if (!countryConfig?.path) {
return null;
}
const path = countryConfig.path;
const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || countryConfig.city);
const response = await fetchImpl(MEIGUODIZHI_ADDRESS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
city,
path,
method: 'refresh',
}),
});
if (!response?.ok) {
throw new Error(`HTTP ${response?.status || 0}`);
}
const data = await response.json();
if (data?.status !== 'ok') {
throw new Error(data?.message || data?.status || 'unknown response');
}
return buildDirectAddressSeed(countryCode, data.address || {}, fallbackSeed);
}
function getLocalAddressSeed(countryCode) {
if (typeof getAddressSeedForCountry !== 'function') {
return null;
}
const seed = getAddressSeedForCountry(countryCode, {
fallbackCountry: 'DE',
});
return seed?.countryCode === countryCode ? seed : null;
}
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, lookupSeed);
if (hasCompleteAddressFallback(remoteSeed)) {
await addLog(
`步骤 7:已从 meiguodizhi 接口获取账单地址(${remoteSeed.fallback.city} / ${remoteSeed.fallback.postalCode}),将跳过 Google 地址推荐。`,
'info'
);
return remoteSeed;
}
await addLog('步骤 7meiguodizhi 接口返回的地址字段不完整,回退到本地地址种子。', 'warn');
} catch (error) {
await addLog(`步骤 7meiguodizhi 地址接口不可用,回退到本地地址种子:${error?.message || String(error || '')}`, 'warn');
}
if (hasCompleteAddressFallback(localSeed)) {
return localSeed;
}
throw new Error(`步骤 7${requestedCountry} 的 meiguodizhi 地址不可用,且没有本地兜底地址。`);
}
async function getAlivePlusCheckoutTabId(tabId) {
if (!Number.isInteger(tabId) || tabId <= 0) {
return null;
}
if (!chrome?.tabs?.get) {
return tabId;
}
const tab = await chrome.tabs.get(tabId).catch(() => null);
return tab && isPlusCheckoutUrl(tab.url) ? tabId : null;
}
async function getCurrentPlusCheckoutTabId() {
if (!chrome?.tabs?.query) {
return null;
}
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []);
const activeCheckoutTab = activeTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url));
if (activeCheckoutTab) {
return activeCheckoutTab.id;
}
const checkoutTabs = await chrome.tabs.query({ url: 'https://chatgpt.com/checkout/*' }).catch(() => []);
const checkoutTab = checkoutTabs.find((tab) => Number.isInteger(tab?.id) && isPlusCheckoutUrl(tab.url));
return checkoutTab?.id || null;
}
async function getCheckoutFrames(tabId) {
if (!chrome?.webNavigation?.getAllFrames) {
return [{ frameId: 0, url: '' }];
}
const frames = await chrome.webNavigation.getAllFrames({ tabId }).catch(() => null);
if (!Array.isArray(frames) || !frames.length) {
return [{ frameId: 0, url: '' }];
}
return frames
.filter((frame) => Number.isInteger(frame?.frameId))
.sort((left, right) => Number(left.frameId) - Number(right.frameId));
}
async function pingCheckoutFrame(tabId, frameId) {
try {
const pong = await chrome.tabs.sendMessage(tabId, {
type: 'PING',
source: 'background',
payload: {},
}, {
frameId: Number.isInteger(frameId) ? frameId : 0,
});
return Boolean(pong?.ok && (!pong.source || pong.source === PLUS_CHECKOUT_SOURCE));
} catch {
return false;
}
}
async function ensurePlusCheckoutFrameReady(tabId, frameId) {
if (await pingCheckoutFrame(tabId, frameId)) {
return true;
}
if (!chrome?.scripting?.executeScript) {
return false;
}
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds: [frameId] },
func: (injectedSource) => {
window.__MULTIPAGE_SOURCE = injectedSource;
},
args: [PLUS_CHECKOUT_SOURCE],
});
await chrome.scripting.executeScript({
target: { tabId, frameIds: [frameId] },
files: PLUS_CHECKOUT_INJECT_FILES,
});
} catch {
// If the frame was already injected or navigated mid-injection, ping once more below.
}
await sleepWithStop(PLUS_CHECKOUT_FRAME_READY_DELAY_MS);
return await pingCheckoutFrame(tabId, frameId);
}
async function ensurePlusCheckoutFramesReady(tabId, frames) {
const checkedFrames = [];
for (const frame of frames) {
const ready = await ensurePlusCheckoutFrameReady(tabId, frame.frameId);
checkedFrames.push({ ...frame, ready });
}
return checkedFrames;
}
async function sendFrameMessage(tabId, frameId, message) {
return chrome.tabs.sendMessage(tabId, message, {
frameId: Number.isInteger(frameId) ? frameId : 0,
});
}
async function inspectCheckoutFrame(tabId, frame) {
try {
const result = await sendFrameMessage(tabId, frame.frameId, {
type: 'PLUS_CHECKOUT_GET_STATE',
source: 'background',
payload: {},
});
if (result?.error) {
return { frame, error: result.error };
}
return { frame: { ...frame, ready: true }, result: result || {} };
} catch (error) {
const readyError = frame.ready === false ? 'content-script-not-ready' : '';
const message = error?.message || String(error || '');
return { frame, error: readyError ? `${readyError}: ${message}` : message };
}
}
function isPaymentFrameUrl(url = '') {
return /elements-inner-payment|componentName=payment/i.test(String(url || ''));
}
function isAddressFrameUrl(url = '') {
return /elements-inner-address|componentName=address/i.test(String(url || ''));
}
function isAutocompleteFrameUrl(url = '') {
return /elements-inner-autocompl/i.test(String(url || ''));
}
function buildFrameSummary(inspections) {
return inspections
.map((item) => {
const flags = [];
if (item.result?.hasPayPal) flags.push('paypal');
if (item.result?.billingFieldsVisible) flags.push('billing');
if (item.result?.hasSubscribeButton) flags.push('subscribe');
if (!flags.length && item.error) flags.push(item.error);
if (!flags.length) flags.push('no-match');
return `${item.frame.frameId}:${item.frame.url || 'about:blank'}:${flags.join(',')}`;
})
.slice(0, 8)
.join(' | ');
}
async function inspectCheckoutFrames(tabId, frames) {
const inspections = [];
for (const frame of frames) {
const inspection = await inspectCheckoutFrame(tabId, frame);
inspections.push(inspection);
}
return inspections;
}
function pickPaymentFrame(inspections) {
return inspections.find((item) => item.result?.hasPayPal || item.result?.paypalCandidates?.length)
|| inspections.find((item) => isPaymentFrameUrl(item.frame.url))
|| null;
}
function pickBillingFrame(inspections) {
return inspections.find((item) => item.result?.billingFieldsVisible)
|| inspections.find((item) => isAddressFrameUrl(item.frame.url))
|| null;
}
function pickSubscribeFrame(inspections) {
return inspections.find((item) => item.result?.hasSubscribeButton)
|| inspections.find((item) => item.frame.frameId === 0)
|| null;
}
async function getReadyCheckoutFrames(tabId) {
return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId));
}
async function resolveOptionalFrameByUrl(tabId, predicate) {
const frames = await getCheckoutFrames(tabId);
const frame = frames.find((item) => predicate(item.url));
if (!frame) {
return null;
}
const ready = await ensurePlusCheckoutFrameReady(tabId, frame.frameId);
return {
frame,
ready,
};
}
async function resolvePaymentFrame(tabId, frames) {
const inspections = await inspectCheckoutFrames(tabId, frames);
const picked = pickPaymentFrame(inspections);
if (picked) {
return {
frameId: picked.frame.frameId,
frameUrl: picked.frame.url || '',
ready: picked.frame.ready !== false,
inspections,
};
}
return {
frameId: null,
frameUrl: '',
inspections,
};
}
async function waitForBillingFrame(tabId) {
while (true) {
const frames = await getReadyCheckoutFrames(tabId);
const inspections = await inspectCheckoutFrames(tabId, frames);
const picked = pickBillingFrame(inspections);
if (picked) {
return {
frameId: picked.frame.frameId,
frameUrl: picked.frame.url || '',
countryText: picked.result?.countryText || '',
ready: picked.frame.ready !== false,
inspections,
};
}
await sleepWithStop(250);
}
}
async function waitForSubscribeFrame(tabId, candidateFrames) {
const frames = candidateFrames.length ? candidateFrames : [{ frameId: 0, url: '' }];
while (true) {
const readyFrames = await ensurePlusCheckoutFramesReady(tabId, frames);
const inspections = await inspectCheckoutFrames(tabId, readyFrames);
const picked = pickSubscribeFrame(inspections);
if (picked) {
return picked.frame;
}
await sleepWithStop(250);
}
}
async function getCheckoutTabId(state = {}) {
const registeredTabId = await getTabId(PLUS_CHECKOUT_SOURCE);
if (registeredTabId && await isTabAlive(PLUS_CHECKOUT_SOURCE)) {
const aliveRegisteredTabId = await getAlivePlusCheckoutTabId(registeredTabId);
if (aliveRegisteredTabId) {
return aliveRegisteredTabId;
}
}
const storedTabId = Number(state.plusCheckoutTabId) || 0;
if (storedTabId) {
const aliveStoredTabId = await getAlivePlusCheckoutTabId(storedTabId);
if (aliveStoredTabId) {
return aliveStoredTabId;
}
}
const currentCheckoutTabId = await getCurrentPlusCheckoutTabId();
if (currentCheckoutTabId) {
await addLog('步骤 7:检测到当前已在 Plus Checkout 页面,直接接管当前标签页。', 'info');
return currentCheckoutTabId;
}
throw new Error('步骤 7:未找到 Plus Checkout 标签页。请先打开 Plus Checkout 页面,或完成步骤 6。');
}
async function executePlusCheckoutBilling(state = {}) {
const tabId = await getCheckoutTabId(state);
await addLog('步骤 7:正在等待 Plus Checkout 页面加载完成...', 'info');
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, {
inject: PLUS_CHECKOUT_INJECT_FILES,
injectSource: PLUS_CHECKOUT_SOURCE,
logMessage: '步骤 7Checkout 页面仍在加载,等待账单填写脚本就绪...',
});
const readyFrames = await getReadyCheckoutFrames(tabId);
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames);
if (paymentFrame.frameId === null) {
const frameSummary = buildFrameSummary(paymentFrame.inspections);
throw new Error(`步骤 7:未在主页面或 iframe 中发现 PayPal DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`);
}
if (!paymentFrame.ready) {
throw new Error(`步骤 7:已定位到 PayPal 所在 iframeframeId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`);
}
if (paymentFrame.frameId !== 0) {
await addLog(`步骤 7PayPal 位于 checkout iframeframeId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info');
}
const randomName = generateRandomName();
const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' ');
await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info');
const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, {
type: 'PLUS_CHECKOUT_SELECT_PAYPAL',
source: 'background',
payload: {},
});
if (paymentResult?.error) {
throw new Error(paymentResult.error);
}
const billingFrame = await waitForBillingFrame(tabId);
if (!billingFrame.ready) {
throw new Error(`步骤 7:已定位到账单地址 iframeframeId=${billingFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`);
}
if (billingFrame.frameId !== paymentFrame.frameId) {
await addLog(`步骤 7:账单地址位于 checkout iframeframeId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info');
}
const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText);
if (!addressSeed) {
throw new Error('步骤 7:未找到可用的本地账单地址种子。');
}
await addLog(`步骤 7:正在填写账单地址(${addressSeed.countryCode} / ${addressSeed.query}...`, 'info');
const autocompleteFrame = await resolveOptionalFrameByUrl(tabId, isAutocompleteFrameUrl);
let result = null;
if (!addressSeed.skipAutocomplete && autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) {
if (!autocompleteFrame.ready) {
throw new Error('步骤 7:发现 Google 地址推荐 iframe,但无法注入账单脚本。请提供该 iframe 的控制台结构。');
}
await addLog(`步骤 7:Google 地址推荐位于独立 iframeframeId=${autocompleteFrame.frame.frameId}),将拆分输入与选择动作。`, 'info');
const queryResult = await sendFrameMessage(tabId, billingFrame.frameId, {
type: 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY',
source: 'background',
payload: {
fullName,
addressSeed,
},
});
if (queryResult?.error) {
throw new Error(queryResult.error);
}
const suggestionResult = await sendFrameMessage(tabId, autocompleteFrame.frame.frameId, {
type: 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION',
source: 'background',
payload: {
addressSeed,
},
});
if (suggestionResult?.error) {
throw new Error(suggestionResult.error);
}
const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, {
type: 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS',
source: 'background',
payload: {
addressSeed,
},
});
if (structuredResult?.error) {
throw new Error(structuredResult.error);
}
result = {
...structuredResult,
selectedAddressText: suggestionResult?.selectedAddressText || '',
};
} else {
result = await sendFrameMessage(tabId, billingFrame.frameId, {
type: 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS',
source: 'background',
payload: {
fullName,
addressSeed,
},
});
if (result?.error) {
throw new Error(result.error);
}
}
await addLog('步骤 7:账单地址已填写完成,正在定位订阅按钮...', 'info');
const subscribeFrame = await waitForSubscribeFrame(tabId, [
{ frameId: 0, url: '' },
{ frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' },
{ frameId: billingFrame.frameId, url: billingFrame.frameUrl || '' },
]);
const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, {
type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE',
source: 'background',
payload: {},
});
if (subscribeResult?.error) {
throw new Error(subscribeResult.error);
}
await setState({
plusCheckoutTabId: tabId,
plusBillingCountryText: result?.countryText || '',
plusBillingAddress: result?.structuredAddress || null,
});
await addLog('步骤 7:账单地址已提交,正在等待跳转到 PayPal...', 'info');
await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url));
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
await completeStepFromBackground(7, {
plusBillingCountryText: result?.countryText || '',
});
}
return {
executePlusCheckoutBilling,
};
}
return {
createPlusCheckoutBillingExecutor,
};
});