Files
FlowPilot/content/plus-checkout.js
T
2026-04-26 01:41:40 +08:00

455 lines
14 KiB
JavaScript

// content/plus-checkout.js — ChatGPT Plus checkout helper.
console.log('[MultiPage:plus-checkout] Content script loaded on', location.href);
const PLUS_CHECKOUT_LISTENER_SENTINEL = 'data-multipage-plus-checkout-listener';
const PLUS_CHECKOUT_PAYLOAD = {
entry_point: 'all_plans_pricing_modal',
plan_name: 'chatgptplusplan',
billing_details: {
country: 'DE',
currency: 'EUR',
},
checkout_ui_mode: 'custom',
promo_campaign: {
promo_campaign_id: 'plus-1-month-free',
is_coupon_from_query_param: false,
},
};
if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'CREATE_PLUS_CHECKOUT'
|| message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'
|| message.type === 'PLUS_CHECKOUT_GET_STATE'
) {
resetStopState();
handlePlusCheckoutCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch((err) => {
if (isStopError(err)) {
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
}
});
} else {
console.log('[MultiPage:plus-checkout] 消息监听已存在,跳过重复注册');
}
async function handlePlusCheckoutCommand(message) {
switch (message.type) {
case 'CREATE_PLUS_CHECKOUT':
return createPlusCheckoutSession();
case 'FILL_PLUS_BILLING_AND_SUBMIT':
return fillPlusBillingAndSubmit(message.payload || {});
case 'PLUS_CHECKOUT_GET_STATE':
return inspectPlusCheckoutState();
default:
throw new Error(`plus-checkout.js 不处理消息:${message.type}`);
}
}
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
const label = String(options.label || '条件').trim() || '条件';
while (true) {
throwIfStopped();
const value = await predicate();
if (value) {
return value;
}
await sleep(intervalMs);
}
}
async function waitForDocumentComplete() {
await waitUntil(() => document.readyState === 'complete', {
label: '页面加载完成',
intervalMs: 200,
});
await sleep(1000);
}
function isVisibleElement(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(rect.width) > 0
&& Number(rect.height) > 0;
}
function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function getActionText(el) {
return normalizeText([
el?.textContent,
el?.value,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.getAttribute?.('placeholder'),
el?.getAttribute?.('name'),
el?.id,
].filter(Boolean).join(' '));
}
function getFieldText(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);
}
const container = el?.closest?.('[data-testid], [class], div, section, fieldset');
if (container) {
labels.push(container.textContent);
}
return normalizeText([
getActionText(el),
...labels,
].filter(Boolean).join(' '));
}
function getVisibleControls(selector) {
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
}
function findClickableByText(patterns) {
const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns])
.filter(Boolean);
const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]');
return candidates.find((el) => {
const text = getActionText(el);
return normalizedPatterns.some((pattern) => pattern.test(text));
}) || null;
}
function isEnabledControl(el) {
return Boolean(el)
&& !el.disabled
&& el.getAttribute?.('aria-disabled') !== 'true';
}
function getVisibleTextInputs() {
return getVisibleControls('input, textarea')
.filter((el) => {
const type = String(el.getAttribute('type') || el.type || '').trim().toLowerCase();
return !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
});
}
function findInputByFieldText(patterns, options = {}) {
const inputs = getVisibleTextInputs();
const excluded = options.exclude || (() => false);
return inputs.find((input) => {
if (excluded(input)) return false;
const text = getFieldText(input);
return patterns.some((pattern) => pattern.test(text));
}) || null;
}
async function createPlusCheckoutSession() {
await waitForDocumentComplete();
log('Plus:正在读取 ChatGPT 登录会话...');
const sessionResponse = await fetch('/api/auth/session', {
credentials: 'include',
});
const session = await sessionResponse.json().catch(() => ({}));
const accessToken = session?.accessToken;
if (!accessToken) {
throw new Error('请先登录 ChatGPT,当前页面未返回可用 accessToken。');
}
log('Plus:正在创建 checkout 会话...');
const response = await fetch('https://chatgpt.com/backend-api/payments/checkout', {
method: 'POST',
credentials: 'include',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(PLUS_CHECKOUT_PAYLOAD),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.checkout_session_id) {
const detail = data?.detail || data?.message || `HTTP ${response.status}`;
throw new Error(`创建 Plus Checkout 失败:${detail}`);
}
return {
checkoutUrl: `https://chatgpt.com/checkout/openai_ie/${data.checkout_session_id}`,
country: PLUS_CHECKOUT_PAYLOAD.billing_details.country,
currency: PLUS_CHECKOUT_PAYLOAD.billing_details.currency,
};
}
async function selectPayPalPaymentMethod() {
const paypalPattern = /paypal/i;
const existingSelected = findClickableByText([/paypal/i]);
if (existingSelected) {
simulateClick(existingSelected);
await sleep(600);
return true;
}
const radios = getVisibleControls('input[type="radio"], [role="radio"]');
const paypalRadio = radios.find((el) => paypalPattern.test(getFieldText(el)));
if (paypalRadio) {
simulateClick(paypalRadio);
await sleep(600);
return true;
}
throw new Error('Plus Checkout:未找到 PayPal 付款方式。');
}
async function fillFullName(fullName) {
const value = normalizeText(fullName);
if (!value) return false;
const input = findInputByFieldText([
/full\s*name|name\s*on|cardholder|billing\s*name/i,
/姓名|全名|持卡人/i,
]);
if (!input) {
return false;
}
fillInput(input, value);
await sleep(300);
return true;
}
function readCountryText() {
const countryInput = findInputByFieldText([
/country|region/i,
/国家|地区/i,
]);
if (countryInput?.value) {
return countryInput.value;
}
const countrySelect = getVisibleControls('select').find((select) => /country|region|国家|地区/i.test(getFieldText(select)));
if (countrySelect) {
const option = countrySelect.selectedOptions?.[0];
return option?.textContent || countrySelect.value || '';
}
return '';
}
function isLikelyAddressSearchInput(input) {
const text = getFieldText(input);
if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州/i.test(text)) {
return false;
}
if (/address|street|billing|search|line\s*1|地址|街道|账单/i.test(text)) {
return true;
}
return false;
}
async function findAddressSearchInput() {
return waitUntil(() => {
const direct = findInputByFieldText([
/address|street|billing|search|line\s*1/i,
/地址|街道|账单/i,
], {
exclude: (input) => /city|state|province|postal|zip|country|城市|||邮编|国家|地区/i.test(getFieldText(input)),
});
if (direct) return direct;
const candidates = getVisibleTextInputs().filter(isLikelyAddressSearchInput);
return candidates[0] || null;
}, {
label: '地址搜索输入框',
intervalMs: 250,
});
}
function getAddressSuggestions() {
const selectors = [
'[role="listbox"] [role="option"]',
'[role="option"]',
'.pac-container .pac-item',
'[data-testid*="address" i] [role="option"]',
'li',
];
const seen = new Set();
const results = [];
for (const selector of selectors) {
for (const el of Array.from(document.querySelectorAll(selector))) {
if (!isVisibleElement(el)) continue;
const text = normalizeText(el.textContent || el.getAttribute?.('aria-label') || '');
if (!text || text.length < 3) continue;
const key = `${selector}:${text}`;
if (seen.has(key)) continue;
seen.add(key);
results.push(el);
}
}
return results;
}
async function selectAddressSuggestion(seed) {
const addressInput = await findAddressSearchInput();
fillInput(addressInput, seed.query || 'Berlin Mitte');
await sleep(800);
const suggestions = await waitUntil(() => {
const options = getAddressSuggestions();
return options.length ? options : null;
}, {
label: '地址推荐列表',
intervalMs: 250,
});
const suggestionIndex = Math.max(0, Math.min(
suggestions.length - 1,
Math.floor(Number(seed.suggestionIndex) || 0)
));
const target = suggestions[suggestionIndex] || suggestions[0];
simulateClick(target);
await sleep(1200);
return {
selectedText: normalizeText(target.textContent || ''),
suggestionIndex,
};
}
function getStructuredAddressFields() {
const address1 = findInputByFieldText([
/address\s*(?:line)?\s*1|street/i,
/地址\s*1|街道|详细地址/i,
]);
const address2 = findInputByFieldText([
/address\s*(?:line)?\s*2|apt|suite|unit/i,
/地址\s*2|公寓|单元|门牌/i,
]);
const city = findInputByFieldText([
/city|town|suburb/i,
/城市|市区/i,
]);
const region = findInputByFieldText([
/state|province|region|county/i,
/省|州|地区/i,
]);
const postalCode = findInputByFieldText([
/postal|zip|postcode/i,
/邮编|邮政/i,
]);
return { address1, address2, city, region, postalCode };
}
function fillIfEmpty(input, value) {
if (!input || !value) return false;
if (String(input.value || '').trim()) return false;
fillInput(input, value);
return true;
}
async function ensureStructuredAddress(seed) {
const fallback = seed?.fallback || {};
const fields = await waitUntil(() => {
const currentFields = getStructuredAddressFields();
if (currentFields.address1 || currentFields.city || currentFields.postalCode) {
return currentFields;
}
return null;
}, {
label: '结构化账单地址字段',
intervalMs: 250,
});
fillIfEmpty(fields.address1, fallback.address1);
fillIfEmpty(fields.city, fallback.city);
fillIfEmpty(fields.region, fallback.region);
fillIfEmpty(fields.postalCode, fallback.postalCode);
await sleep(500);
const latest = getStructuredAddressFields();
const missing = [];
if (!String(latest.address1?.value || '').trim()) missing.push('地址1');
if (!String(latest.city?.value || '').trim()) missing.push('城市');
if (!String(latest.postalCode?.value || '').trim()) missing.push('邮编');
if (missing.length) {
throw new Error(`Plus Checkout:账单地址字段未填写完整:${missing.join('、')}`);
}
return {
address1: latest.address1?.value || '',
city: latest.city?.value || '',
region: latest.region?.value || '',
postalCode: latest.postalCode?.value || '',
};
}
function findSubscribeButton() {
return findClickableByText([
/订阅|继续|确认|支付/i,
/subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i,
]);
}
async function fillPlusBillingAndSubmit(payload = {}) {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
await fillFullName(payload.fullName || '');
const countryText = readCountryText();
const seed = payload.addressSeed || {
query: 'Berlin Mitte',
suggestionIndex: 1,
fallback: {
address1: 'Unter den Linden',
city: 'Berlin',
region: 'Berlin',
postalCode: '10117',
},
};
const selected = await selectAddressSuggestion(seed);
const structuredAddress = await ensureStructuredAddress(seed);
const subscribeButton = await waitUntil(() => {
const button = findSubscribeButton();
return button && isEnabledControl(button) ? button : null;
}, {
label: '订阅按钮',
intervalMs: 250,
});
simulateClick(subscribeButton);
return {
countryText,
selectedAddressText: selected.selectedText,
structuredAddress,
};
}
function inspectPlusCheckoutState() {
const structuredAddress = getStructuredAddressFields();
return {
url: location.href,
readyState: document.readyState,
countryText: readCountryText(),
hasPayPal: Boolean(findClickableByText([/paypal/i])),
hasSubscribeButton: Boolean(findSubscribeButton()),
addressFieldValues: {
address1: structuredAddress.address1?.value || '',
city: structuredAddress.city?.value || '',
region: structuredAddress.region?.value || '',
postalCode: structuredAddress.postalCode?.value || '',
},
};
}