fix(plus): 增强 checkout 免费试用校验
(cherry picked from commit c52f5058a4b5b68902bccb4d1f099d00c471b7bc)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('步骤 7:checkout 标签页已关闭,无法继续等待 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: '步骤 7:Checkout 页面仍在加载,等待账单填写脚本就绪...',
|
||||
});
|
||||
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
@@ -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 || '',
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
Reference in New Issue
Block a user