Plua模式开发
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// content/paypal-flow.js — PayPal login and approval helper.
|
||||
|
||||
console.log('[MultiPage:paypal-flow] Content script loaded on', location.href);
|
||||
|
||||
const PAYPAL_FLOW_LISTENER_SENTINEL = 'data-multipage-paypal-flow-listener';
|
||||
|
||||
if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1');
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (
|
||||
message.type === 'PAYPAL_GET_STATE'
|
||||
|| message.type === 'PAYPAL_SUBMIT_LOGIN'
|
||||
|| message.type === 'PAYPAL_DISMISS_PROMPTS'
|
||||
|| message.type === 'PAYPAL_CLICK_APPROVE'
|
||||
) {
|
||||
resetStopState();
|
||||
handlePayPalCommand(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:paypal-flow] 消息监听已存在,跳过重复注册');
|
||||
}
|
||||
|
||||
async function handlePayPalCommand(message) {
|
||||
switch (message.type) {
|
||||
case 'PAYPAL_GET_STATE':
|
||||
return inspectPayPalState();
|
||||
case 'PAYPAL_SUBMIT_LOGIN':
|
||||
return submitPayPalLogin(message.payload || {});
|
||||
case 'PAYPAL_DISMISS_PROMPTS':
|
||||
return dismissPayPalPrompts();
|
||||
case 'PAYPAL_CLICK_APPROVE':
|
||||
return clickPayPalApprove();
|
||||
default:
|
||||
throw new Error(`paypal-flow.js 不处理消息:${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(predicate, options = {}) {
|
||||
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
|
||||
while (true) {
|
||||
throwIfStopped();
|
||||
const value = await predicate();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDocumentComplete() {
|
||||
await waitUntil(() => document.readyState === 'complete', { 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 getVisibleControls(selector) {
|
||||
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
|
||||
}
|
||||
|
||||
function isEnabledControl(el) {
|
||||
return Boolean(el)
|
||||
&& !el.disabled
|
||||
&& el.getAttribute?.('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
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"]');
|
||||
return candidates.find((el) => {
|
||||
const text = getActionText(el);
|
||||
return normalizedPatterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findInputByPatterns(patterns) {
|
||||
const inputs = getVisibleControls('input')
|
||||
.filter((input) => {
|
||||
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
|
||||
return !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
|
||||
});
|
||||
return inputs.find((input) => {
|
||||
const text = getActionText(input);
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findEmailInput() {
|
||||
return findInputByPatterns([
|
||||
/email|login|user|账号|邮箱/i,
|
||||
]) || getVisibleControls('input[type="email"]').find(isVisibleElement) || null;
|
||||
}
|
||||
|
||||
function findPasswordInput() {
|
||||
return findInputByPatterns([
|
||||
/password|pass|密码/i,
|
||||
]) || getVisibleControls('input[type="password"]').find(isVisibleElement) || null;
|
||||
}
|
||||
|
||||
function findLoginNextButton() {
|
||||
return findClickableByText([
|
||||
/next|continue|login|log\s*in|sign\s*in/i,
|
||||
/下一步|继续|登录|登入/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function findApproveButton() {
|
||||
return findClickableByText([
|
||||
/同意并继续|同意|继续|授权|确认并继续/i,
|
||||
/agree\s*(?:and)?\s*continue|continue|accept|authorize|agree|pay\s*now/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function findPasskeyPromptButtons() {
|
||||
const promptPatterns = [
|
||||
/passkey|通行密钥|安全密钥|下次登录|faster|save/i,
|
||||
];
|
||||
const bodyText = normalizeText(document.body?.innerText || '');
|
||||
const likelyPrompt = promptPatterns.some((pattern) => pattern.test(bodyText));
|
||||
if (!likelyPrompt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cancelOrClose = getVisibleControls('button, a, [role="button"]')
|
||||
.filter((el) => {
|
||||
const text = getActionText(el);
|
||||
return /取消|稍后|不保存|不用|关闭|cancel|not now|maybe later|skip|close|x/i.test(text)
|
||||
|| el.getAttribute?.('aria-label')?.match(/close|关闭/i);
|
||||
});
|
||||
|
||||
const iconCloseButtons = getVisibleControls('button, [role="button"]')
|
||||
.filter((el) => {
|
||||
const text = getActionText(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (/^×$|^x$/i.test(text) || /close|关闭/i.test(text))
|
||||
&& rect.width <= 64
|
||||
&& rect.height <= 64;
|
||||
});
|
||||
|
||||
return [...cancelOrClose, ...iconCloseButtons];
|
||||
}
|
||||
|
||||
function hasPasskeyPrompt() {
|
||||
return findPasskeyPromptButtons().length > 0;
|
||||
}
|
||||
|
||||
async function submitPayPalLogin(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
|
||||
const email = normalizeText(payload.email || '');
|
||||
const password = String(payload.password || '');
|
||||
if (!password) {
|
||||
throw new Error('PayPal 密码为空,请先在侧边栏配置。');
|
||||
}
|
||||
|
||||
let passwordInput = findPasswordInput();
|
||||
const emailInput = findEmailInput();
|
||||
|
||||
if (!passwordInput && emailInput && email) {
|
||||
fillInput(emailInput, email);
|
||||
const nextButton = findLoginNextButton();
|
||||
if (nextButton && isEnabledControl(nextButton)) {
|
||||
simulateClick(nextButton);
|
||||
}
|
||||
passwordInput = await waitUntil(() => findPasswordInput(), { intervalMs: 250 });
|
||||
} else if (!passwordInput && emailInput && !email) {
|
||||
throw new Error('PayPal 账号为空,请先在侧边栏配置。');
|
||||
} else if (emailInput && email && !String(emailInput.value || '').trim()) {
|
||||
fillInput(emailInput, email);
|
||||
}
|
||||
|
||||
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { intervalMs: 250 });
|
||||
fillInput(passwordInput, password);
|
||||
await sleep(1000);
|
||||
|
||||
const loginButton = await waitUntil(() => {
|
||||
const button = findClickableByText([
|
||||
/login|log\s*in|sign\s*in|continue/i,
|
||||
/登录|登入|继续/i,
|
||||
]);
|
||||
return button && isEnabledControl(button) ? button : null;
|
||||
}, { intervalMs: 250 });
|
||||
|
||||
simulateClick(loginButton);
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
async function dismissPayPalPrompts() {
|
||||
await waitForDocumentComplete();
|
||||
const buttons = findPasskeyPromptButtons();
|
||||
let clicked = 0;
|
||||
for (const button of buttons) {
|
||||
if (!isVisibleElement(button) || !isEnabledControl(button)) {
|
||||
continue;
|
||||
}
|
||||
simulateClick(button);
|
||||
clicked += 1;
|
||||
await sleep(500);
|
||||
}
|
||||
return {
|
||||
clicked,
|
||||
hasPromptAfterClick: hasPasskeyPrompt(),
|
||||
};
|
||||
}
|
||||
|
||||
async function clickPayPalApprove() {
|
||||
await waitForDocumentComplete();
|
||||
await dismissPayPalPrompts().catch(() => ({ clicked: 0 }));
|
||||
|
||||
const button = findApproveButton();
|
||||
if (!button || !isEnabledControl(button)) {
|
||||
return {
|
||||
clicked: false,
|
||||
state: inspectPayPalState(),
|
||||
};
|
||||
}
|
||||
|
||||
simulateClick(button);
|
||||
return {
|
||||
clicked: true,
|
||||
buttonText: getActionText(button),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPayPalState() {
|
||||
const emailInput = findEmailInput();
|
||||
const passwordInput = findPasswordInput();
|
||||
const approveButton = findApproveButton();
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
needsLogin: Boolean(emailInput || passwordInput),
|
||||
hasEmailInput: Boolean(emailInput),
|
||||
hasPasswordInput: Boolean(passwordInput),
|
||||
approveReady: Boolean(approveButton && isEnabledControl(approveButton)),
|
||||
approveButtonText: approveButton ? getActionText(approveButton) : '',
|
||||
hasPasskeyPrompt: hasPasskeyPrompt(),
|
||||
bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// 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 || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,9 +32,10 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
|
||||
handleCommand(message).then((result) => {
|
||||
sendResponse({ ok: true, ...(result || {}) });
|
||||
}).catch(err => {
|
||||
const reportedStep = Number(message.payload?.visibleStep) || message.step;
|
||||
if (isStopError(err)) {
|
||||
if (message.step) {
|
||||
log(`步骤 ${message.step || 8}:已被用户停止。`, 'warn');
|
||||
if (reportedStep) {
|
||||
log(`步骤 ${reportedStep || 8}:已被用户停止。`, 'warn');
|
||||
}
|
||||
sendResponse({ stopped: true, error: err.message });
|
||||
return;
|
||||
@@ -46,8 +47,8 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.step) {
|
||||
reportError(message.step, err.message);
|
||||
if (reportedStep) {
|
||||
reportError(reportedStep, err.message);
|
||||
}
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
|
||||
@@ -68,7 +68,8 @@ async function handleStep(step, payload = {}) {
|
||||
case 1:
|
||||
return step1_generateOpenAiAuthUrl(payload);
|
||||
case 10:
|
||||
return step9_submitOpenAiCallback(payload);
|
||||
case 12:
|
||||
return step9_submitOpenAiCallback({ ...(payload || {}), visibleStep: step });
|
||||
default:
|
||||
throw new Error(`sub2api-panel.js 不处理步骤 ${step}`);
|
||||
}
|
||||
@@ -501,6 +502,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
}
|
||||
|
||||
async function step9_submitOpenAiCallback(payload = {}) {
|
||||
const visibleStep = Number(payload?.visibleStep) || 10;
|
||||
const callback = parseLocalhostCallback(payload.localhostUrl || '');
|
||||
const backgroundState = await getBackgroundState();
|
||||
const flowEmail = String(backgroundState.email || '').trim();
|
||||
@@ -574,7 +576,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
createPayload.extra = extra;
|
||||
}
|
||||
|
||||
log(`步骤 10:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`);
|
||||
log(`步骤 ${visibleStep}:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`);
|
||||
const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
|
||||
method: 'POST',
|
||||
token,
|
||||
@@ -582,8 +584,8 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
});
|
||||
|
||||
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
|
||||
log(`步骤 10:${verifiedStatus}`, 'ok');
|
||||
reportComplete(10, {
|
||||
log(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok');
|
||||
reportComplete(visibleStep, {
|
||||
localhostUrl: callback.url,
|
||||
verifiedStatus,
|
||||
});
|
||||
|
||||
+16
-12
@@ -86,7 +86,9 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
|
||||
async function handleStep(step, payload) {
|
||||
switch (step) {
|
||||
case 1: return await step1_getOAuthLink(payload);
|
||||
case 10: return await step9_vpsVerify(payload);
|
||||
case 10:
|
||||
case 12:
|
||||
return await step9_vpsVerify({ ...(payload || {}), visibleStep: step });
|
||||
default:
|
||||
throw new Error(`vps-panel.js 不处理步骤 ${step}`);
|
||||
}
|
||||
@@ -1009,27 +1011,29 @@ async function step1_getOAuthLink(payload, options = {}) {
|
||||
// ============================================================
|
||||
|
||||
async function step9_vpsVerify(payload) {
|
||||
await ensureOAuthManagementPage(payload?.vpsPassword, 9);
|
||||
const visibleStep = Number(payload?.visibleStep) || 10;
|
||||
const confirmStep = visibleStep === 12 ? 11 : 9;
|
||||
await ensureOAuthManagementPage(payload?.vpsPassword, confirmStep);
|
||||
|
||||
// 优先从 payload 读取 localhostUrl;没有时再回退到全局状态
|
||||
let localhostUrl = payload?.localhostUrl;
|
||||
if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) {
|
||||
throw new Error('步骤 10 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 9。');
|
||||
throw new Error(`步骤 ${visibleStep} 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 ${confirmStep}。`);
|
||||
}
|
||||
if (!localhostUrl) {
|
||||
log('步骤 10:payload 中没有 localhostUrl,正在从状态中读取...');
|
||||
log(`步骤 ${visibleStep}:payload 中没有 localhostUrl,正在从状态中读取...`);
|
||||
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
|
||||
localhostUrl = state.localhostUrl;
|
||||
if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) {
|
||||
throw new Error('步骤 10 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 9。');
|
||||
throw new Error(`步骤 ${visibleStep} 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 ${confirmStep}。`);
|
||||
}
|
||||
}
|
||||
if (!localhostUrl) {
|
||||
throw new Error('未找到 localhost 回调地址,请先完成步骤 8。');
|
||||
throw new Error(`未找到 localhost 回调地址,请先完成步骤 ${confirmStep}。`);
|
||||
}
|
||||
log(`步骤 10:已获取 localhostUrl:${localhostUrl.slice(0, 60)}...`);
|
||||
log(`步骤 ${visibleStep}:已获取 localhostUrl:${localhostUrl.slice(0, 60)}...`);
|
||||
|
||||
log('步骤 10:正在查找回调地址输入框...');
|
||||
log(`步骤 ${visibleStep}:正在查找回调地址输入框...`);
|
||||
|
||||
// Find the callback URL input
|
||||
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
@@ -1046,7 +1050,7 @@ async function step9_vpsVerify(payload) {
|
||||
|
||||
await humanPause(600, 1500);
|
||||
fillInput(urlInput, localhostUrl);
|
||||
log(`步骤 10:已填写回调地址:${localhostUrl.slice(0, 80)}...`);
|
||||
log(`步骤 ${visibleStep}:已填写回调地址:${localhostUrl.slice(0, 80)}...`);
|
||||
|
||||
// Find and click the callback submit button in supported UI languages.
|
||||
const callbackSubmitPattern = /提交回调\s*URL|Submit\s+Callback\s+URL|Отправить\s+Callback\s+URL/i;
|
||||
@@ -1067,9 +1071,9 @@ async function step9_vpsVerify(payload) {
|
||||
|
||||
await humanPause(450, 1200);
|
||||
simulateClick(submitBtn);
|
||||
log('步骤 10:已点击回调提交按钮,正在等待认证结果...');
|
||||
log(`步骤 ${visibleStep}:已点击回调提交按钮,正在等待认证结果...`);
|
||||
|
||||
const verifiedStatus = await waitForExactSuccessBadge();
|
||||
log(`步骤 10:${verifiedStatus}`, 'ok');
|
||||
reportComplete(10, { localhostUrl, verifiedStatus });
|
||||
log(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok');
|
||||
reportComplete(visibleStep, { localhostUrl, verifiedStatus });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user