fix(plus): 增强 checkout 免费试用校验

(cherry picked from commit c52f5058a4b5b68902bccb4d1f099d00c471b7bc)
This commit is contained in:
朴圣佑
2026-04-26 22:22:44 +08:00
committed by QLHazyCoder
parent 75e259ae5c
commit aa4e52b9a7
6 changed files with 652 additions and 59 deletions
+7
View File
@@ -6828,6 +6828,11 @@ function isSignupUserAlreadyExistsFailure(error) {
return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message);
}
function isPlusCheckoutNonFreeTrialFailure(error) {
const message = getErrorMessage(error);
return /PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message);
}
function isStep9RecoverableAuthError(error) {
const message = String(typeof error === 'string' ? error : error?.message || '');
return /STEP9_OAUTH_RETRY::/i.test(message)
@@ -8955,6 +8960,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
getStopRequested: () => stopRequested,
hasSavedProgress,
isAddPhoneAuthFailure,
isPlusCheckoutNonFreeTrialFailure,
isRestartCurrentAttemptError,
isSignupUserAlreadyExistsFailure,
isStopError,
@@ -9828,6 +9834,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling?
getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry,
getTabId,
isTabAlive,
markCurrentRegistrationAccountUsed,
sendTabMessageUntilStopped,
setState,
sleepWithStop,
+39 -6
View File
@@ -23,6 +23,7 @@
getState,
hasSavedProgress,
isAddPhoneAuthFailure,
isPlusCheckoutNonFreeTrialFailure,
isRestartCurrentAttemptError,
isSignupUserAlreadyExistsFailure,
isStopError,
@@ -502,15 +503,12 @@
const reason = getErrorMessage(err);
roundSummary.failureReasons.push(reason);
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err);
const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function'
&& isPlusCheckoutNonFreeTrialFailure(err);
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
&& !keepSameEmailUntilAddPhone
&& isSignupUserAlreadyExistsFailure(err);
const canRetry = !blockedByAddPhone
&& !blockedByPhoneSupplyExhausted
&& !blockedBySignupUserAlreadyExists
&& autoRunSkipFailures
&& attemptRun < maxAttemptsForRound;
const canRetry = !blockedByAddPhone && !blockedByPlusNonFreeTrial && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
@@ -551,6 +549,41 @@
break;
}
if (blockedByPlusNonFreeTrial) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await appendRoundRecordIfNeeded('failed', reason);
cancelPendingCommands('当前轮因 Plus 免费试用资格不可用已终止。');
await broadcastStopToContentScripts();
if (!autoRunSkipFailures) {
await addLog(
`${targetRun}/${totalRuns} 轮检测到 Plus 今日应付金额非 0,自动重试未开启,当前自动运行将停止。`,
'warn'
);
stoppedEarly = true;
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
sessionId: 0,
});
break;
}
await addLog(`${targetRun}/${totalRuns} 轮没有 Plus 免费试用资格,本轮将直接失败并跳过剩余重试。`, 'warn');
await addLog(
targetRun < totalRuns
? `${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,自动流程将继续下一轮。`
: `${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,已无后续轮次,本次自动运行结束。`,
'warn'
);
forceFreshTabsNextRun = true;
break;
}
if (blockedBySignupUserAlreadyExists) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
+115 -23
View File
@@ -5,6 +5,8 @@
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 PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3;
const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000;
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', '阿根廷'] },
@@ -42,10 +44,10 @@
getAddressSeedForCountry,
getTabId,
isTabAlive,
markCurrentRegistrationAccountUsed,
setState,
sleepWithStop,
waitForTabCompleteUntilStopped,
waitForTabUrlMatchUntilStopped,
} = deps;
function isPlusCheckoutUrl(url = '') {
@@ -294,6 +296,28 @@
});
}
async function waitForPayPalRedirectAfterSubmit(tabId) {
const startedAt = Date.now();
while (Date.now() - startedAt < PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS) {
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab) {
throw new Error('步骤 7checkout 标签页已关闭,无法继续等待 PayPal 跳转。');
}
const url = String(tab.url || '');
if (/paypal\./i.test(url)) {
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
return true;
}
if (url && !isPlusCheckoutUrl(url)) {
await addLog(`步骤 7:点击订阅后页面跳转到非 PayPal 地址:${url}`, 'warn');
return false;
}
await sleepWithStop(500);
}
return false;
}
async function inspectCheckoutFrame(tabId, frame) {
try {
const result = await sendFrameMessage(tabId, frame.frameId, {
@@ -366,6 +390,44 @@
|| null;
}
function findCheckoutAmountInspection(inspections = []) {
return inspections.find((item) => item.result?.checkoutAmountSummary?.hasTodayDue)
|| null;
}
async function inspectCheckoutAmountSummary(tabId) {
const frames = await getReadyCheckoutFrames(tabId);
const inspections = await inspectCheckoutFrames(tabId, frames);
const amountInspection = findCheckoutAmountInspection(inspections);
return amountInspection?.result?.checkoutAmountSummary || null;
}
async function ensureFreeTrialAmount(tabId, state = {}, options = {}) {
const phaseLabel = String(options.phaseLabel || '').trim() || '提交前';
const amountSummary = await inspectCheckoutAmountSummary(tabId);
if (!amountSummary?.hasTodayDue) {
await addLog(`步骤 7${phaseLabel}未能识别 checkout 的“今日应付金额”,为避免误判将继续执行。`, 'warn');
return;
}
if (amountSummary.isZero) {
await addLog(`步骤 7${phaseLabel}已确认今日应付金额为 ${amountSummary.rawAmount || '0'},继续执行。`, 'ok');
return;
}
const amountLabel = amountSummary.rawAmount || (
Number.isFinite(Number(amountSummary.amount)) ? String(amountSummary.amount) : '未知金额'
);
await addLog(`步骤 7${phaseLabel}检测到今日应付金额不是 0${amountLabel}),说明当前账号没有免费试用资格,将跳过 PayPal 提交。`, 'warn');
if (typeof markCurrentRegistrationAccountUsed === 'function') {
await markCurrentRegistrationAccountUsed(state, {
reason: 'plus-checkout-non-free-trial',
logPrefix: 'Plus Checkout:当前账号没有免费试用资格',
});
}
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(${amountLabel}),当前账号没有免费试用资格,已跳过 PayPal 提交。`);
}
async function getReadyCheckoutFrames(tabId) {
return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId));
}
@@ -468,6 +530,9 @@
logMessage: '步骤 7Checkout 页面仍在加载,等待账单填写脚本就绪...',
});
const readyFrames = await getReadyCheckoutFrames(tabId);
await ensureFreeTrialAmount(tabId, state, {
phaseLabel: 'Checkout 页面加载后',
});
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames);
if (paymentFrame.frameId === null) {
const frameSummary = buildFrameSummary(paymentFrame.inspections);
@@ -535,8 +600,9 @@
addressSeed,
},
});
if (suggestionResult?.error) {
throw new Error(suggestionResult.error);
const suggestionError = suggestionResult?.error || '';
if (suggestionError) {
await addLog(`步骤 7:Google 地址推荐不可用,将改用本地地址字段兜底:${suggestionError}`, 'warn');
}
const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, {
@@ -544,6 +610,7 @@
source: 'background',
payload: {
addressSeed,
overwriteStructuredAddress: Boolean(suggestionError),
},
});
if (structuredResult?.error) {
@@ -552,7 +619,7 @@
result = {
...structuredResult,
selectedAddressText: suggestionResult?.selectedAddressText || '',
selectedAddressText: suggestionError ? '' : (suggestionResult?.selectedAddressText || ''),
};
} else {
result = await sendFrameMessage(tabId, billingFrame.frameId, {
@@ -569,31 +636,56 @@
}
}
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 ensureFreeTrialAmount(tabId, state, {
phaseLabel: '提交订阅前',
});
await addLog('步骤 7:账单地址已提交,正在等待跳转到 PayPal...', 'info');
await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url));
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
let redirectedToPayPal = false;
let lastSubmitError = '';
for (let attempt = 1; attempt <= PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
await addLog(
attempt === 1
? '步骤 7:账单地址已填写完成,等待 3 秒让 checkout 完成校验...'
: `步骤 7:准备第 ${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS} 次重新提交账单地址...`,
attempt === 1 ? 'info' : 'warn'
);
await sleepWithStop(3000);
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: {
beforeClickDelayMs: attempt === 1 ? 700 : 1200,
},
});
if (subscribeResult?.error) {
lastSubmitError = subscribeResult.error;
await addLog(`步骤 7:点击订阅失败(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}):${lastSubmitError}`, 'warn');
continue;
}
await addLog(`步骤 7:账单地址已提交,正在等待跳转到 PayPal(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}...`, 'info');
redirectedToPayPal = await waitForPayPalRedirectAfterSubmit(tabId);
if (redirectedToPayPal) {
break;
}
lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 PayPal`;
await addLog(`步骤 7${lastSubmitError},将重试提交。`, 'warn');
}
if (!redirectedToPayPal) {
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 PayPal。${lastSubmitError}`);
}
await completeStepFromBackground(7, {
plusBillingCountryText: result?.countryText || '',
+223 -30
View File
@@ -83,7 +83,7 @@ async function handlePlusCheckoutCommand(message) {
case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS':
return ensurePlusStructuredBillingAddress(message.payload || {});
case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE':
return clickPlusSubscribe();
return clickPlusSubscribe(message.payload || {});
case 'PLUS_CHECKOUT_GET_STATE':
return inspectPlusCheckoutState();
default:
@@ -94,12 +94,17 @@ async function handlePlusCheckoutCommand(message) {
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
const label = String(options.label || '条件').trim() || '条件';
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
const startedAt = Date.now();
while (true) {
throwIfStopped();
const value = await predicate();
if (value) {
return value;
}
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
throw new Error(`${label}等待超时`);
}
await sleep(intervalMs);
}
}
@@ -126,24 +131,97 @@ function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
function parseLocalizedAmount(rawValue = '') {
const raw = normalizeText(rawValue);
const match = raw.match(/(?:[$€£¥]\s*)?([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)(?:\s*[$€£¥])?/);
if (!match) return null;
let numericText = String(match[1] || '').trim();
const lastComma = numericText.lastIndexOf(',');
const lastDot = numericText.lastIndexOf('.');
if (lastComma > -1 && lastDot > -1) {
const decimalSeparator = lastComma > lastDot ? ',' : '.';
const thousandsSeparator = decimalSeparator === ',' ? '.' : ',';
numericText = numericText
.replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '')
.replace(decimalSeparator, '.');
} else if (lastComma > -1) {
numericText = numericText.replace(',', '.');
}
const amount = Number(numericText.replace(/[^\d.+-]/g, ''));
return Number.isFinite(amount)
? {
amount,
raw: match[0],
}
: null;
}
function buildPlusCheckoutConfig(payload = {}) {
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
const config = PLUS_CHECKOUT_CONFIGS[paymentMethod] || PLUS_CHECKOUT_CONFIGS.paypal;
function getTextAfterTodayDueLabel(text = '') {
const normalized = normalizeText(text);
const match = normalized.match(/(?:今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today)/i);
if (!match) return '';
return normalized.slice((match.index || 0) + match[0].length).trim();
}
function getCheckoutAmountSummary() {
const elements = getVisibleControls('div, span, p, strong, b');
const labelPattern = /今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today/i;
const amountPattern = /[$€£¥]\s*[+-]?\d|[+-]?\d+(?:[.,]\d{1,2})?\s*[$€£¥]/;
for (const element of elements) {
const text = normalizeText(element.innerText || element.textContent || '');
if (!labelPattern.test(text)) continue;
const candidates = [];
const afterLabelText = getTextAfterTodayDueLabel(text);
if (afterLabelText) candidates.push(afterLabelText);
const parent = element.parentElement;
if (parent) {
for (const child of Array.from(parent.children || [])) {
if (child === element) continue;
const childText = normalizeText(child.innerText || child.textContent || '');
if (amountPattern.test(childText)) {
candidates.push(childText);
}
}
const parentAfterLabelText = getTextAfterTodayDueLabel(parent.innerText || parent.textContent || '');
if (parentAfterLabelText) candidates.push(parentAfterLabelText);
}
const grandparent = parent?.parentElement;
if (grandparent) {
const grandparentAfterLabelText = getTextAfterTodayDueLabel(grandparent.innerText || grandparent.textContent || '');
if (grandparentAfterLabelText) candidates.push(grandparentAfterLabelText);
}
for (const candidate of candidates) {
const parsed = parseLocalizedAmount(candidate);
if (!parsed) continue;
return {
hasTodayDue: true,
amount: parsed.amount,
isZero: Math.abs(parsed.amount) < 0.005,
rawAmount: parsed.raw,
labelText: text.slice(0, 160),
};
}
return {
hasTodayDue: true,
amount: null,
isZero: false,
rawAmount: '',
labelText: text.slice(0, 160),
};
}
return {
paymentMethod,
paymentLabel: config.paymentLabel,
checkoutUrlPrefix: config.checkoutUrlPrefix,
checkoutPayload: {
...PLUS_CHECKOUT_BASE_PAYLOAD,
billing_details: {
country: config.billing_details.country,
currency: config.billing_details.currency,
},
},
hasTodayDue: false,
amount: null,
isZero: false,
rawAmount: '',
labelText: '',
};
}
@@ -152,9 +230,14 @@ function getActionText(el) {
el?.textContent,
el?.value,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('aria-labelledby'),
el?.getAttribute?.('title'),
el?.getAttribute?.('placeholder'),
el?.getAttribute?.('name'),
el?.getAttribute?.('autocomplete'),
el?.getAttribute?.('data-elements-stable-field-name'),
el?.getAttribute?.('data-field'),
el?.getAttribute?.('data-field-name'),
el?.id,
].filter(Boolean).join(' '));
}
@@ -670,6 +753,7 @@ async function clickAddressSuggestion(seed = {}) {
}, {
label: '地址推荐列表',
intervalMs: 250,
timeoutMs: 6000,
});
const suggestionIndex = Math.max(0, Math.min(
@@ -820,7 +904,7 @@ function findRegionDropdown() {
if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false;
const text = getFieldText(control);
if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false;
return /state|province|county/i.test(text)
return /state|province|county|prefecture|administrative|administrative[_-]?area/i.test(text)
|| /(?:^|\s)region(?:\s|$)/i.test(text)
|| /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text);
}) || null;
@@ -956,24 +1040,24 @@ async function selectCountryDropdown(countryDropdown, value) {
function getStructuredAddressFields() {
const address1 = findInputByFieldText([
/address\s*(?:line)?\s*1|street/i,
/地址\s*1|街道|详细地址/i,
/address\s*(?:line)?\s*1|address[_-]?line[_-]?1|address\[(?:address_)?line1\]|line\s*1|street|street[_-]?address/i,
/地址\s*1|街道|详细地址|住所/i,
]);
const address2 = findInputByFieldText([
/address\s*(?:line)?\s*2|apt|suite|unit/i,
/address\s*(?:line)?\s*2|address[_-]?line[_-]?2|address\[(?:address_)?line2\]|line\s*2|apt|suite|unit/i,
/地址\s*2|公寓|单元|门牌/i,
]);
const city = findInputByFieldText([
/city|town|suburb/i,
/城市|市区/i,
/city|town|suburb|locality|address[_-]?level[_-]?2|address\[city\]/i,
/城市|市区|区市町村|市区町村|市町村/i,
]);
const region = findInputByFieldText([
/state|province|region|county/i,
/省|州|地区/i,
/state|province|region|county|prefecture|administrative|administrative[_-]?area|address[_-]?level[_-]?1|address\[state\]/i,
/省|州|地区|辖区|都道府县|都道府県/i,
]);
const postalCode = findInputByFieldText([
/postal|zip|postcode/i,
/邮编|邮政/i,
/postal|zip|postcode|postal[_-]?code|zip[_-]?code|address\[postal_code\]/i,
/邮编|邮政|郵便番号/i,
]);
return { address1, address2, city, region, postalCode };
}
@@ -1012,6 +1096,7 @@ async function ensureStructuredAddress(seed, options = {}) {
}, {
label: '结构化账单地址字段',
intervalMs: 250,
timeoutMs: 6000,
});
fillIfEmpty(fields.address1, fallback.address1, { overwrite });
@@ -1038,12 +1123,111 @@ async function ensureStructuredAddress(seed, options = {}) {
}
function findSubscribeButton() {
const submitButtons = getVisibleControls('button[type="submit"], input[type="submit"]');
const exactSubmit = submitButtons.find((button) => (
isEnabledControl(button)
&& /订阅|subscribe|购买\s*ChatGPT\s*Plus|start\s*subscription|place\s*order/i.test(getCombinedSearchText(button))
));
if (exactSubmit) {
return exactSubmit;
}
return findClickableByText([
/订阅|继续|确认|支付/i,
/subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i,
]);
}
function isBusySubscribeButton(button) {
if (!button) return true;
const text = getActionText(button);
return button.disabled
|| button.getAttribute?.('aria-disabled') === 'true'
|| button.getAttribute?.('aria-busy') === 'true'
|| button.closest?.('[aria-busy="true"], [data-loading="true"], [data-state="loading"]')
|| /loading|processing|submitting|请稍候|处理中|加载中/i.test(text);
}
function getAssociatedForm(button) {
if (!button) return null;
if (button.form) return button.form;
const formId = String(button.getAttribute?.('form') || '').trim();
if (formId) {
return document.getElementById(formId) || null;
}
return button.closest?.('form') || null;
}
async function humanLikeClick(el) {
throwIfStopped();
if (!el) {
throw new Error('无法点击空元素。');
}
el.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'instant' });
await sleep(300);
if (typeof el.focus === 'function') {
el.focus({ preventScroll: true });
await sleep(150);
}
const rect = el.getBoundingClientRect();
const clientX = Math.floor(rect.left + rect.width / 2);
const clientY = Math.floor(rect.top + rect.height / 2);
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX,
clientY,
button: 0,
buttons: 1,
};
const pointerCtor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
const events = [
['pointerover', pointerCtor],
['pointerenter', pointerCtor],
['mouseover', MouseEvent],
['mouseenter', MouseEvent],
['pointermove', pointerCtor],
['mousemove', MouseEvent],
['pointerdown', pointerCtor],
['mousedown', MouseEvent],
['pointerup', pointerCtor],
['mouseup', MouseEvent],
['click', MouseEvent],
];
for (const [type, EventCtor] of events) {
throwIfStopped();
el.dispatchEvent(new EventCtor(type, eventInit));
await sleep(type === 'mousedown' || type === 'pointerdown' ? 120 : 30);
}
if (typeof el.click === 'function') {
await sleep(120);
el.click();
}
const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase();
const form = getAssociatedForm(el);
if (
form
&& typeof form.requestSubmit === 'function'
&& (
(String(el.tagName || '').toUpperCase() === 'BUTTON' && (!type || type === 'submit'))
|| (String(el.tagName || '').toUpperCase() === 'INPUT' && type === 'submit')
)
) {
await sleep(250);
form.requestSubmit(el);
}
console.log('[MultiPage:plus-checkout] 已执行拟人工点击', summarizeElementForDebug(el));
log(`已拟人工点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
}
async function fillPlusBillingAndSubmit(payload = {}) {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
@@ -1117,23 +1301,31 @@ async function selectPlusAddressSuggestion(payload = {}) {
async function ensurePlusStructuredBillingAddress(payload = {}) {
await waitForDocumentComplete();
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {});
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}, {
overwrite: Boolean(payload.overwriteStructuredAddress),
});
return {
countryText: readCountryText(),
structuredAddress,
};
}
async function clickPlusSubscribe() {
async function clickPlusSubscribe(payload = {}) {
if (payload.ensurePayPalActive && !isPayPalPaymentMethodActive()) {
await selectPayPalPaymentMethod();
}
const subscribeButton = await waitUntil(() => {
const button = findSubscribeButton();
return button && isEnabledControl(button) ? button : null;
return button && isEnabledControl(button) && !isBusySubscribeButton(button) ? button : null;
}, {
label: '订阅按钮',
intervalMs: 250,
timeoutMs: 10000,
});
simulateClick(subscribeButton);
await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0)));
await humanLikeClick(subscribeButton);
return {
clicked: true,
};
@@ -1151,6 +1343,7 @@ function inspectPlusCheckoutState() {
cardFieldsVisible: hasCreditCardFields(),
billingFieldsVisible: hasBillingAddressFields(),
hasSubscribeButton: Boolean(findSubscribeButton()),
checkoutAmountSummary: getCheckoutAmountSummary(),
addressFieldValues: {
address1: structuredAddress.address1?.value || '',
city: structuredAddress.city?.value || '',
+227
View File
@@ -180,6 +180,75 @@ return { findAddressSearchInput, isNonAddressSearchInput };
assert.equal(await api.findAddressSearchInput(), addressInput);
});
test('getCheckoutAmountSummary reads non-zero today due amount', () => {
const label = createElement({ tagName: 'DIV', text: '今日应付金额' });
const amount = createElement({ tagName: 'DIV', text: '€19.33' });
const row = createElement({ tagName: 'DIV', text: '今日应付金额 €19.33' });
label.parentElement = row;
amount.parentElement = row;
row.children = [label, amount];
row.parentElement = null;
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('parseLocalizedAmount'),
extractFunction('getTextAfterTodayDueLabel'),
extractFunction('getVisibleControls'),
extractFunction('getCheckoutAmountSummary'),
'return { getCheckoutAmountSummary };',
].join('\n');
const api = new Function('window', 'document', bundle)(
{
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
},
{
querySelectorAll: () => [label, amount, row],
}
);
const summary = api.getCheckoutAmountSummary();
assert.equal(summary.hasTodayDue, true);
assert.equal(summary.isZero, false);
assert.equal(summary.amount, 19.33);
assert.equal(summary.rawAmount, '€19.33');
});
test('getCheckoutAmountSummary accepts zero today due amount', () => {
const label = createElement({ tagName: 'DIV', text: '今日应付金额' });
const amount = createElement({ tagName: 'DIV', text: '€0.00' });
const row = createElement({ tagName: 'DIV', text: '今日应付金额 €0.00' });
label.parentElement = row;
amount.parentElement = row;
row.children = [label, amount];
row.parentElement = null;
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('parseLocalizedAmount'),
extractFunction('getTextAfterTodayDueLabel'),
extractFunction('getVisibleControls'),
extractFunction('getCheckoutAmountSummary'),
'return { getCheckoutAmountSummary };',
].join('\n');
const api = new Function('window', 'document', bundle)(
{
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
},
{
querySelectorAll: () => [label, amount, row],
}
);
const summary = api.getCheckoutAmountSummary();
assert.equal(summary.hasTodayDue, true);
assert.equal(summary.isZero, true);
assert.equal(summary.amount, 0);
});
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
const bundle = [
extractFunction('isVisibleElement'),
@@ -234,6 +303,164 @@ return { isPayPalPaymentMethodActive };
assert.equal(api.isPayPalPaymentMethodActive(), true);
});
test('getStructuredAddressFields recognizes Stripe localized address field names', () => {
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getFieldText'),
extractFunction('getVisibleControls'),
extractFunction('getVisibleTextInputs'),
extractFunction('findInputByFieldText'),
extractFunction('getStructuredAddressFields'),
].join('\n');
const address1 = createInput({ name: 'addressLine1', placeholder: '住所' });
const city = createInput({ name: 'locality', placeholder: '市区町村' });
const region = createInput({ name: 'administrativeArea', placeholder: '辖区' });
const postalCode = createInput({ name: 'postalCode', placeholder: '郵便番号' });
const inputs = [address1, city, region, postalCode];
const documentMock = {
querySelectorAll: (selector) => {
if (selector === 'input, textarea') return inputs;
if (String(selector || '').includes('label[for=')) return [];
return [];
},
};
const windowMock = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
${bundle}
return { getStructuredAddressFields };
`)(windowMock, documentMock, cssMock);
assert.deepEqual(api.getStructuredAddressFields(), {
address1,
address2: null,
city,
region,
postalCode,
});
});
test('findSubscribeButton prefers the submit subscription button', () => {
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('getVisibleControls'),
extractFunction('findClickableByText'),
extractFunction('isEnabledControl'),
extractFunction('findSubscribeButton'),
].join('\n');
const submitButton = createElement({
tagName: 'BUTTON',
text: '订阅',
attrs: {
'aria-label': '订阅',
type: 'submit',
},
});
submitButton.type = 'submit';
const genericButton = createElement({
tagName: 'BUTTON',
text: '继续',
attrs: {
type: 'button',
},
});
genericButton.type = 'button';
const documentMock = {
body: {},
documentElement: {},
querySelectorAll: (selector) => {
if (selector === 'button[type="submit"], input[type="submit"]') return [submitButton];
if (selector === 'button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]') {
return [genericButton, submitButton];
}
if (String(selector || '').includes('label[for=')) return [];
return [];
},
};
const windowMock = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
${bundle}
return { findSubscribeButton };
`)(windowMock, documentMock, cssMock);
assert.equal(api.findSubscribeButton(), submitButton);
});
test('humanLikeClick submits a detached submit button through its form attribute', async () => {
const bundle = [
'function throwIfStopped() {}',
'function sleep() { return Promise.resolve(); }',
'function summarizeElementForDebug() { return {}; }',
'function log() {}',
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getAssociatedForm'),
extractFunction('humanLikeClick'),
].join('\n');
let submittedWith = null;
const form = {
requestSubmit(button) {
submittedWith = button;
},
};
const button = createElement({
tagName: 'BUTTON',
text: '订阅',
attrs: {
form: '_r_l_',
type: 'submit',
'aria-label': '订阅',
},
});
button.type = 'submit';
button.scrollIntoView = () => {};
button.focus = () => {};
button.dispatchEvent = () => true;
button.click = () => {};
const documentMock = {
getElementById: (id) => (id === '_r_l_' ? form : null),
};
const windowMock = {};
class FakeMouseEvent {
constructor(type, init = {}) {
this.type = type;
Object.assign(this, init);
}
}
const api = new Function('window', 'document', 'MouseEvent', 'PointerEvent', 'console', `
${bundle}
return { humanLikeClick };
`)(windowMock, documentMock, FakeMouseEvent, undefined, { log() {} });
await api.humanLikeClick(button);
assert.equal(submittedWith, button);
});
test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => {
const bundle = [
'function throwIfStopped() {}',
@@ -53,6 +53,7 @@ function createExecutorHarness({
readyByFrame = {},
fetchImpl = null,
getAddressSeedForCountry = () => createAddressSeed(),
markCurrentRegistrationAccountUsed = async () => {},
}) {
const api = loadPlusCheckoutBillingModule();
const events = {
@@ -100,6 +101,9 @@ function createExecutorHarness({
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
checkoutTab.url = 'https://www.paypal.com/checkoutnow';
}
return createSuccessfulBillingResult();
},
},
@@ -121,6 +125,7 @@ function createExecutorHarness({
getAddressSeedForCountry,
getTabId: async () => null,
isTabAlive: async () => false,
markCurrentRegistrationAccountUsed,
setState: async (updates) => events.states.push(updates),
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => checkoutTab,
@@ -134,6 +139,42 @@ function createExecutorHarness({
return { checkoutTab, events, executor };
}
test('Plus checkout billing stops before PayPal when today due amount is non-zero', async () => {
const markCalls = [];
const { events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
billingFieldsVisible: true,
hasSubscribeButton: true,
checkoutAmountSummary: {
hasTodayDue: true,
amount: 19.33,
isZero: false,
rawAmount: '€19.33',
},
},
},
markCurrentRegistrationAccountUsed: async (state, options) => {
markCalls.push({ state, options });
return { updated: true };
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({ email: 'paid@example.com' }),
/PLUS_CHECKOUT_NON_FREE_TRIAL::/
);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'), false);
assert.equal(events.completed.length, 0);
assert.equal(markCalls.length, 1);
assert.equal(markCalls[0].state.email, 'paid@example.com');
assert.equal(events.logs.some((entry) => /今日应付金额不是 0/.test(entry.message)), true);
});
test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => {
const { checkoutTab, events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],