|
@@ -0,0 +1,1130 @@
|
|
|
|
|
+// content/checkout-stripe.js — Stripe checkout page automation for ChatGPT Plus subscription
|
|
|
|
|
+(function attachCheckoutStripe() {
|
|
|
|
|
+ if (document.documentElement.hasAttribute('data-multipage-checkout-stripe-listener')) return;
|
|
|
|
|
+ document.documentElement.setAttribute('data-multipage-checkout-stripe-listener', '');
|
|
|
|
|
+
|
|
|
|
|
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
|
|
+ if (message.type === 'EXECUTE_STEP' && message.step === 7) {
|
|
|
|
|
+ resetStopState();
|
|
|
|
|
+ runStripeCheckout(message.payload || {}).then(
|
|
|
|
|
+ (result) => sendResponse(result),
|
|
|
|
|
+ (err) => sendResponse({ error: err.message })
|
|
|
|
|
+ );
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (message.type === 'CHECKOUT_STRIPE_SELECT_ADDRESS_SUGGESTION') {
|
|
|
|
|
+ resetStopState();
|
|
|
|
|
+ selectGoogleAddressSuggestionOnly(message.payload || {}).then(
|
|
|
|
|
+ (result) => sendResponse(result),
|
|
|
|
|
+ (err) => sendResponse({ error: err.message })
|
|
|
|
|
+ );
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (message.type === 'CHECKOUT_STRIPE_GET_STATE') {
|
|
|
|
|
+ sendResponse(inspectCheckoutStripeState());
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (message.type === 'CHECKOUT_STRIPE_SELECT_PAYPAL') {
|
|
|
|
|
+ resetStopState();
|
|
|
|
|
+ selectPayPalPaymentMethod(message.payload || {}).then(
|
|
|
|
|
+ (result) => sendResponse(result),
|
|
|
|
|
+ (err) => sendResponse({ error: err.message })
|
|
|
|
|
+ );
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (message.type === 'CHECKOUT_STRIPE_FILL_BILLING_ADDRESS') {
|
|
|
|
|
+ resetStopState();
|
|
|
|
|
+ fillStripeBillingAddress(message.payload || {}).then(
|
|
|
|
|
+ (result) => sendResponse(result),
|
|
|
|
|
+ (err) => sendResponse({ error: err.message })
|
|
|
|
|
+ );
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (message.type === 'CHECKOUT_STRIPE_CLICK_SUBMIT') {
|
|
|
|
|
+ resetStopState();
|
|
|
|
|
+ clickStripeCheckoutSubmit(message.payload || {}).then(
|
|
|
|
|
+ (result) => sendResponse(result),
|
|
|
|
|
+ (err) => sendResponse({ error: err.message })
|
|
|
|
|
+ );
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ async function runStripeCheckout(payload) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ log('开始执行 Stripe 结账页面自动化...');
|
|
|
|
|
+ await sleep(1800);
|
|
|
|
|
+
|
|
|
|
|
+ // 1. Click PayPal option
|
|
|
|
|
+ await selectPayPalPaymentMethod({ relaxedActivation: true });
|
|
|
|
|
+
|
|
|
|
|
+ await sleep(1000);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. Fill billing address
|
|
|
|
|
+ await fillStripeBillingAddress(payload);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. Click submit
|
|
|
|
|
+ await clickStripeCheckoutSubmit({ beforeClickDelayMs: 1500 });
|
|
|
|
|
+
|
|
|
|
|
+ log('Stripe 结账表单已提交');
|
|
|
|
|
+ reportComplete(7, {});
|
|
|
|
|
+ return { ok: true };
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (isStopError(e)) throw e;
|
|
|
|
|
+ log('Stripe 结账流程出错: ' + e.message, 'error');
|
|
|
|
|
+ reportError(7, e.message);
|
|
|
|
|
+ return { error: e.message };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function inspectCheckoutStripeState() {
|
|
|
|
|
+ const addressFields = getStructuredAddressFields();
|
|
|
|
|
+ return {
|
|
|
|
|
+ url: location.href,
|
|
|
|
|
+ readyState: document.readyState,
|
|
|
|
|
+ hasPayPal: Boolean(findPayPalPaymentMethodTarget()),
|
|
|
|
|
+ paypalCandidates: getPayPalCandidateSummaries(),
|
|
|
|
|
+ billingFieldsVisible: hasBillingAddressFields(addressFields),
|
|
|
|
|
+ hasSubmitButton: Boolean(findSubmitButton()),
|
|
|
|
|
+ addressFieldValues: {
|
|
|
|
|
+ address1: addressFields.address1?.value || '',
|
|
|
|
|
+ city: addressFields.city?.value || '',
|
|
|
|
|
+ region: addressFields.region?.value || getSelectText(addressFields.regionSelect) || '',
|
|
|
|
|
+ postalCode: addressFields.postalCode?.value || '',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function selectPayPalPaymentMethod(options = {}) {
|
|
|
|
|
+ log('正在寻找 PayPal 选项...');
|
|
|
|
|
+ const autoJsTarget = findAutoJsPayPalTarget();
|
|
|
|
|
+ if (autoJsTarget) {
|
|
|
|
|
+ await clickPayPalLikeAutoJs(autoJsTarget);
|
|
|
|
|
+ await sleep(450);
|
|
|
|
|
+ await clickPayPalLikeAutoJs(autoJsTarget);
|
|
|
|
|
+ const autoJsActive = await waitForPayPalPaymentMethodActive(2500);
|
|
|
|
|
+ if (autoJsActive) {
|
|
|
|
|
+ log('已按 auto.js 方式确认 PayPal 选项生效');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log('auto.js 方式点击 PayPal 后未观察到标准选中标记,继续按 hosted checkout 宽松模式执行。', 'warn');
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ paymentSelected: autoJsActive,
|
|
|
|
|
+ relaxed: !autoJsActive,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const target = await waitForPayPalPaymentMethodTarget(10000);
|
|
|
|
|
+ if (!target) {
|
|
|
|
|
+ if (options.relaxedActivation) {
|
|
|
|
|
+ log('未找到 PayPal 选项按钮,当前为宽松模式,继续执行...', 'warn');
|
|
|
|
|
+ return { paymentSelected: false, relaxed: true };
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error('未找到 PayPal 付款方式,无法切换。');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const clickTargets = getPayPalActivationTargets(target);
|
|
|
|
|
+ for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
|
|
|
+ for (const candidate of clickTargets) {
|
|
|
|
|
+ dispatchRobustActivation(candidate);
|
|
|
|
|
+ await sleep(220);
|
|
|
|
|
+ if (hasSelectedPayPalControl() || hasBillingAddressFields()) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await sleep(450);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const active = await waitForPayPalPaymentMethodActive(3500);
|
|
|
|
|
+ if (!active) {
|
|
|
|
|
+ log('点击 PayPal 后未观察到标准选中标记,继续按 hosted checkout 宽松模式执行。', 'warn');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log('已确认 PayPal 选项生效');
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ paymentSelected: active,
|
|
|
|
|
+ relaxed: !active,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function fillStripeBillingAddress(payload = {}) {
|
|
|
|
|
+ const addr = normalizeCheckoutAddress(payload.address || {});
|
|
|
|
|
+ if (payload.autoJsDirectSelectors) {
|
|
|
|
|
+ log('参考 auto.js 直接填写账单字段...');
|
|
|
|
|
+ fillBillingAddressDirectSelectors(addr);
|
|
|
|
|
+ await sleep(800);
|
|
|
|
|
+ hideAutocompleteDropdowns();
|
|
|
|
|
+ await ensureTermsCheckbox();
|
|
|
|
|
+
|
|
|
|
|
+ const latest = getStructuredAddressFields();
|
|
|
|
|
+ return {
|
|
|
|
|
+ countryText: readCountryText(),
|
|
|
|
|
+ selectedAutocompleteAddress: false,
|
|
|
|
|
+ structuredAddress: {
|
|
|
|
|
+ address1: latest.address1?.value || '',
|
|
|
|
|
+ city: latest.city?.value || '',
|
|
|
|
|
+ region: latest.region?.value || getSelectText(latest.regionSelect) || '',
|
|
|
|
|
+ postalCode: latest.postalCode?.value || '',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log('正在填写账单地址...');
|
|
|
|
|
+ const selectedAutocompleteAddress = await fillBillingAddressLine1FromGoogle(addr);
|
|
|
|
|
+ if (!selectedAutocompleteAddress) {
|
|
|
|
|
+ log('未能选择 Google 地址下拉项,改用手动填写地址兜底。', 'warn');
|
|
|
|
|
+ const fields = getStructuredAddressFields();
|
|
|
|
|
+ const address1Input = fields.address1 || findAddressSearchInput();
|
|
|
|
|
+ if (address1Input) {
|
|
|
|
|
+ fillInput(address1Input, addr.street);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ fillBySelector('#billingAddressLine1', addr.street);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await sleep(900);
|
|
|
|
|
+ const fields = getStructuredAddressFields();
|
|
|
|
|
+ fillMissingControl(fields.city || document.querySelector('#billingLocality'), addr.city);
|
|
|
|
|
+ fillMissingControl(fields.postalCode || document.querySelector('#billingPostalCode'), addr.zip.substring(0, 5));
|
|
|
|
|
+ fillRegionControlByText(fields.regionSelect || fields.region || document.querySelector('#billingAdministrativeArea'), addr.state);
|
|
|
|
|
+ await sleep(800);
|
|
|
|
|
+ hideAutocompleteDropdowns();
|
|
|
|
|
+ await ensureTermsCheckbox();
|
|
|
|
|
+
|
|
|
|
|
+ const latest = getStructuredAddressFields();
|
|
|
|
|
+ return {
|
|
|
|
|
+ countryText: readCountryText(),
|
|
|
|
|
+ selectedAutocompleteAddress,
|
|
|
|
|
+ structuredAddress: {
|
|
|
|
|
+ address1: latest.address1?.value || '',
|
|
|
|
|
+ city: latest.city?.value || '',
|
|
|
|
|
+ region: latest.region?.value || getSelectText(latest.regionSelect) || '',
|
|
|
|
|
+ postalCode: latest.postalCode?.value || '',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function clickStripeCheckoutSubmit(payload = {}) {
|
|
|
|
|
+ await ensureTermsCheckbox();
|
|
|
|
|
+ await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0)));
|
|
|
|
|
+ await clickSubmitButton();
|
|
|
|
|
+ return { clicked: true };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function normalizeCheckoutAddress(addr = {}) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ street: normalizeText(addr.street || addr.address1 || '123 Main St'),
|
|
|
|
|
+ city: normalizeText(addr.city || 'New York'),
|
|
|
|
|
+ state: normalizeText(addr.state || addr.region || 'New York'),
|
|
|
|
|
+ zip: normalizeText(addr.zip || addr.postalCode || '10001').substring(0, 5),
|
|
|
|
|
+ query: normalizeText(addr.query || addr.autocompleteQuery || ''),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getStructuredAddressFields() {
|
|
|
|
|
+ const address1 = getVisibleElementById('billingAddressLine1') || findInputByFieldText([
|
|
|
|
|
+ /address\s*(?:line)?\s*1|address[_-]?line[_-]?1|address\[(?:address_)?line1\]|line\s*1|street|street[_-]?address/i,
|
|
|
|
|
+ /地址\s*1|街道|详细地址|住所/i,
|
|
|
|
|
+ ], {
|
|
|
|
|
+ exclude: (input) => isNonAddressSearchInput(input),
|
|
|
|
|
+ }) || findAddressSearchInput();
|
|
|
|
|
+ const city = getVisibleElementById('billingLocality') || findInputByFieldText([
|
|
|
|
|
+ /city|town|suburb|locality|address[_-]?level[_-]?2|address\[city\]/i,
|
|
|
|
|
+ /城市|市区|区市町村|市区町村|市町村/i,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const postalCode = getVisibleElementById('billingPostalCode') || findInputByFieldText([
|
|
|
|
|
+ /postal|zip|postcode|postal[_-]?code|zip[_-]?code|address\[postal_code\]/i,
|
|
|
|
|
+ /邮编|邮政|郵便番号/i,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const regionSelect = getVisibleElementById('billingAdministrativeArea');
|
|
|
|
|
+ const region = regionSelect || findInputByFieldText([
|
|
|
|
|
+ /state|province|region|county|prefecture|administrative|administrative[_-]?area|address[_-]?level[_-]?1|address\[state\]/i,
|
|
|
|
|
+ /省|州|地区|辖区|都道府县|都道府県/i,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ address1,
|
|
|
|
|
+ city,
|
|
|
|
|
+ postalCode,
|
|
|
|
|
+ region,
|
|
|
|
|
+ regionSelect: regionSelect && regionSelect.tagName === 'SELECT' ? regionSelect : null,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getVisibleElementById(id) {
|
|
|
|
|
+ const el = document.getElementById(id);
|
|
|
|
|
+ return el && isVisibleNode(el) ? el : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function hasBillingAddressFields(fields = getStructuredAddressFields()) {
|
|
|
|
|
+ if (fields?.address1 || fields?.city || fields?.postalCode) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return getVisibleTextInputs().some((input) => {
|
|
|
|
|
+ const text = getFieldText(input);
|
|
|
|
|
+ return /address|street|billing|line\s*1|地址|街道|账单/i.test(text)
|
|
|
|
|
+ && !/card\s*number|card|expiry|expiration|security|cvc|cvv|name|email|e-mail|phone|tel|country|region|postal|zip|city|state|province|银行卡|卡号|有效期|安全码|姓名|邮箱|电话|国家|地区|邮编|城市|省|州/i.test(text);
|
|
|
|
|
+ }) || Boolean(findAddressSearchInput());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function readCountryText() {
|
|
|
|
|
+ const country = document.getElementById('billingCountry')
|
|
|
|
|
+ || findInputByFieldText([/country|region/i, /国家|地区/i])
|
|
|
|
|
+ || Array.from(document.querySelectorAll('select')).find((select) => (
|
|
|
|
|
+ isVisibleNode(select) && /country|region|国家|地区/i.test(getFieldText(select))
|
|
|
|
|
+ ));
|
|
|
|
|
+ if (!country) return '';
|
|
|
|
|
+ if (country.tagName === 'SELECT') {
|
|
|
|
|
+ return getSelectText(country) || country.value || '';
|
|
|
|
|
+ }
|
|
|
|
|
+ return country.value || country.textContent || '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getSelectText(select) {
|
|
|
|
|
+ if (!select || select.tagName !== 'SELECT') return '';
|
|
|
|
|
+ return normalizeText(select.selectedOptions?.[0]?.textContent || select.value || '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findInputByFieldText(patterns = [], options = {}) {
|
|
|
|
|
+ const excluded = options.exclude || (() => false);
|
|
|
|
|
+ return getVisibleTextInputs().find((input) => {
|
|
|
|
|
+ if (excluded(input)) return false;
|
|
|
|
|
+ return patterns.some((pattern) => pattern.test(getFieldText(input)));
|
|
|
|
|
+ }) || null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getVisibleControls(selector) {
|
|
|
|
|
+ return Array.from(document.querySelectorAll(selector)).filter((el) => isVisibleNode(el));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 getVisibleFormControls() {
|
|
|
|
|
+ return Array.from(document.querySelectorAll('input, textarea, select'))
|
|
|
|
|
+ .filter((el) => {
|
|
|
|
|
+ const type = String(el.getAttribute('type') || el.type || '').toLowerCase();
|
|
|
|
|
+ return type !== 'hidden' && isVisibleNode(el);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getFieldText(el) {
|
|
|
|
|
+ if (!el) return '';
|
|
|
|
|
+ const id = el.id ? String(el.id) : '';
|
|
|
|
|
+ const labelText = id
|
|
|
|
|
+ ? Array.from(document.querySelectorAll(`label[for="${cssEscape(id)}"]`)).map((label) => label.textContent || '').join(' ')
|
|
|
|
|
+ : '';
|
|
|
|
|
+ const wrappingLabel = el.closest?.('label')?.textContent || '';
|
|
|
|
|
+ const container = el.closest?.('[data-testid], [class], div, section, fieldset');
|
|
|
|
|
+ return normalizeText([
|
|
|
|
|
+ id,
|
|
|
|
|
+ el.name,
|
|
|
|
|
+ el.getAttribute?.('autocomplete'),
|
|
|
|
|
+ el.getAttribute?.('aria-label'),
|
|
|
|
|
+ el.getAttribute?.('placeholder'),
|
|
|
|
|
+ labelText,
|
|
|
|
|
+ wrappingLabel,
|
|
|
|
|
+ container && !isDocumentLevelContainer(container) ? container.textContent || '' : '',
|
|
|
|
|
+ ].filter(Boolean).join(' '));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getDirectFieldHintText(el) {
|
|
|
|
|
+ if (!el) return '';
|
|
|
|
|
+ const id = el.id ? String(el.id) : '';
|
|
|
|
|
+ const labelText = id
|
|
|
|
|
+ ? Array.from(document.querySelectorAll(`label[for="${cssEscape(id)}"]`)).map((label) => label.textContent || '').join(' ')
|
|
|
|
|
+ : '';
|
|
|
|
|
+ const wrappingLabel = el.closest?.('label')?.textContent || '';
|
|
|
|
|
+ return normalizeText([
|
|
|
|
|
+ id,
|
|
|
|
|
+ el.name,
|
|
|
|
|
+ el.getAttribute?.('autocomplete'),
|
|
|
|
|
+ el.getAttribute?.('aria-label'),
|
|
|
|
|
+ el.getAttribute?.('placeholder'),
|
|
|
|
|
+ labelText,
|
|
|
|
|
+ wrappingLabel,
|
|
|
|
|
+ ].filter(Boolean).join(' '));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isNonAddressSearchInput(input) {
|
|
|
|
|
+ const directText = getDirectFieldHintText(input);
|
|
|
|
|
+ const type = String(input?.getAttribute?.('type') || input?.type || '').trim().toLowerCase();
|
|
|
|
|
+ return /name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|card|card\s*number|expiry|expiration|security|cvc|cvv|cc-|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州|银行卡|卡号|有效期|安全码/i.test(directText)
|
|
|
|
|
+ || ['email', 'tel', 'password'].includes(type);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isLikelyAddressSearchInput(input) {
|
|
|
|
|
+ const text = getFieldText(input);
|
|
|
|
|
+ if (isNonAddressSearchInput(input)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (/name|email|e-mail|phone|tel|password|coupon|promo|country|region|postal|zip|city|state|province|card|card\s*number|expiry|expiration|security|cvc|cvv|cc-|全名|姓名|邮箱|电话|密码|国家|地区|邮编|城市|省|州|银行卡|卡号|有效期|安全码/i.test(text)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return /address|street|billing|search|line\s*1|地址|街道|账单/i.test(text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findAddressSearchInput() {
|
|
|
|
|
+ const direct = findInputByFieldText([
|
|
|
|
|
+ /address|street|billing|search|line\s*1/i,
|
|
|
|
|
+ /地址|街道|账单/i,
|
|
|
|
|
+ ], {
|
|
|
|
|
+ exclude: (input) => isNonAddressSearchInput(input)
|
|
|
|
|
+ || /city|state|province|postal|zip|country|region|城市|省|州|邮编|国家|地区/i.test(getFieldText(input)),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (direct) return direct;
|
|
|
|
|
+ return getVisibleTextInputs().filter(isLikelyAddressSearchInput)[0] || null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isDocumentLevelContainer(el) {
|
|
|
|
|
+ return !el
|
|
|
|
|
+ || el === document.documentElement
|
|
|
|
|
+ || el === document.body
|
|
|
|
|
+ || ['HTML', 'BODY', 'MAIN'].includes(el.tagName);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function cssEscape(value) {
|
|
|
|
|
+ if (window.CSS?.escape) return window.CSS.escape(value);
|
|
|
|
|
+ return String(value || '').replace(/["\\]/g, '\\$&');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findPayPalPaymentMethodTarget() {
|
|
|
|
|
+ const directSelectors = [
|
|
|
|
|
+ '[data-testid="paypal-accordion-item-button"]',
|
|
|
|
|
+ '[data-testid*="paypal" i]',
|
|
|
|
|
+ '.paypal-accordion-item button',
|
|
|
|
|
+ 'button[aria-label*="PayPal" i]',
|
|
|
|
|
+ '[aria-label*="PayPal" i]',
|
|
|
|
|
+ '[title*="PayPal" i]',
|
|
|
|
|
+ '[role="radio"][aria-label*="PayPal" i]',
|
|
|
|
|
+ 'input[type="radio"][value*="paypal" i]',
|
|
|
|
|
+ 'button[value*="paypal" i]',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ for (const selector of directSelectors) {
|
|
|
|
|
+ const target = Array.from(document.querySelectorAll(selector)).find((el) => isVisibleNode(el));
|
|
|
|
|
+ if (target) return target;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const directClickable = findClickableByText([/paypal/i]);
|
|
|
|
|
+ if (directClickable) return directClickable;
|
|
|
|
|
+
|
|
|
|
|
+ const radios = getVisibleControls('input[type="radio"], [role="radio"]');
|
|
|
|
|
+ const matchedRadio = radios.find((el) => /paypal/i.test(getCombinedSearchText(el)));
|
|
|
|
|
+ if (matchedRadio) return matchedRadio;
|
|
|
|
|
+
|
|
|
|
|
+ for (const candidate of getPayPalSearchCandidates()) {
|
|
|
|
|
+ const interactive = findInteractiveAncestor(candidate);
|
|
|
|
|
+ if (interactive && /paypal/i.test(getCombinedSearchText(interactive))) {
|
|
|
|
|
+ return interactive;
|
|
|
|
|
+ }
|
|
|
|
|
+ const card = findPaymentCardAncestor(candidate, /paypal/i);
|
|
|
|
|
+ if (card) {
|
|
|
|
|
+ return card;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getPayPalCandidateSummaries() {
|
|
|
|
|
+ return getPayPalSearchCandidates()
|
|
|
|
|
+ .slice(0, 8)
|
|
|
|
|
+ .map((el) => ({
|
|
|
|
|
+ tag: el.tagName,
|
|
|
|
|
+ id: el.id || '',
|
|
|
|
|
+ role: el.getAttribute?.('role') || '',
|
|
|
|
|
+ text: normalizeText(getCombinedSearchText(el)).slice(0, 120),
|
|
|
|
|
+ visible: isVisibleNode(el),
|
|
|
|
|
+ checked: el.checked === true || el.getAttribute?.('aria-checked') === 'true',
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findClickableByText(patterns = []) {
|
|
|
|
|
+ const candidates = getVisibleControls('button, a, [role="button"], [role="radio"], [role="tab"], input[type="button"], input[type="submit"], input[type="radio"], label, [tabindex]');
|
|
|
|
|
+ return candidates.find((el) => {
|
|
|
|
|
+ const text = getCombinedSearchText(el);
|
|
|
|
|
+ return patterns.some((pattern) => pattern.test(text));
|
|
|
|
|
+ }) || null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getPayPalActivationTargets(target) {
|
|
|
|
|
+ const candidates = [];
|
|
|
|
|
+ const push = (el) => {
|
|
|
|
|
+ if (!el || !isVisibleNode(el) || isDocumentLevelContainer(el)) return;
|
|
|
|
|
+ if (!candidates.includes(el)) candidates.push(el);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ push(target);
|
|
|
|
|
+ push(target?.querySelector?.('input[type="radio"], [role="radio"], button, [role="button"]'));
|
|
|
|
|
+ push(target?.closest?.('input[type="radio"], [role="radio"], button, [role="button"], label, [tabindex]'));
|
|
|
|
|
+ push(findInteractiveAncestor(target));
|
|
|
|
|
+ push(findPaymentCardAncestor(target, /paypal/i));
|
|
|
|
|
+
|
|
|
|
|
+ let current = target;
|
|
|
|
|
+ for (let depth = 0; current && depth < 7; depth += 1, current = current.parentElement) {
|
|
|
|
|
+ if (isDocumentLevelContainer(current)) break;
|
|
|
|
|
+ if (/paypal/i.test(getCombinedSearchText(current))) {
|
|
|
|
|
+ push(current.querySelector?.('input[type="radio"], [role="radio"], button, [role="button"]'));
|
|
|
|
|
+ if (isPaymentCardSized(current)) push(current);
|
|
|
|
|
+ if (current.matches?.('button, [role="button"], [role="radio"], label, [tabindex]')) push(current);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const candidate of getPayPalSearchCandidates().slice(0, 8)) {
|
|
|
|
|
+ push(candidate.closest?.('button, [role="button"], [role="radio"], label, [tabindex]'));
|
|
|
|
|
+ push(findInteractiveAncestor(candidate));
|
|
|
|
|
+ push(findPaymentCardAncestor(candidate, /paypal/i));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return candidates.slice(0, 10);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getPayPalSearchCandidates() {
|
|
|
|
|
+ const selector = [
|
|
|
|
|
+ 'button',
|
|
|
|
|
+ 'a',
|
|
|
|
|
+ 'label',
|
|
|
|
|
+ '[role="button"]',
|
|
|
|
|
+ '[role="radio"]',
|
|
|
|
|
+ '[role="tab"]',
|
|
|
|
|
+ 'input[type="radio"]',
|
|
|
|
|
+ '[tabindex]',
|
|
|
|
|
+ '[data-testid]',
|
|
|
|
|
+ '[aria-label]',
|
|
|
|
|
+ '[title]',
|
|
|
|
|
+ 'img',
|
|
|
|
|
+ 'svg',
|
|
|
|
|
+ 'span',
|
|
|
|
|
+ 'div',
|
|
|
|
|
+ ].join(', ');
|
|
|
|
|
+
|
|
|
|
|
+ return getVisibleControls(selector)
|
|
|
|
|
+ .filter((el) => /paypal/i.test(getCombinedSearchText(el)))
|
|
|
|
|
+ .sort((left, right) => {
|
|
|
|
|
+ const leftRect = left.getBoundingClientRect();
|
|
|
|
|
+ const rightRect = right.getBoundingClientRect();
|
|
|
|
|
+ return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findInteractiveAncestor(el) {
|
|
|
|
|
+ let current = el;
|
|
|
|
|
+ for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
|
|
|
|
|
+ if (!isVisibleNode(current) || isDocumentLevelContainer(current)) continue;
|
|
|
|
|
+ if (current.matches?.('button, a, label, [role="button"], [role="radio"], [role="tab"], input[type="radio"], [tabindex]')) {
|
|
|
|
|
+ return current;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isPaymentCardSized(el) {
|
|
|
|
|
+ if (!isVisibleNode(el) || isDocumentLevelContainer(el)) return false;
|
|
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
|
|
+ const maxWidth = Math.max(320, Math.min(window.innerWidth * 0.95, 900));
|
|
|
|
|
+ const maxHeight = Math.max(140, Math.min(window.innerHeight * 0.45, 340));
|
|
|
|
|
+ return rect.width >= 64
|
|
|
|
|
+ && rect.height >= 28
|
|
|
|
|
+ && rect.width <= maxWidth
|
|
|
|
|
+ && rect.height <= maxHeight;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findPaymentCardAncestor(el, pattern) {
|
|
|
|
|
+ let current = el;
|
|
|
|
|
+ for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
|
|
|
|
|
+ if (!isVisibleNode(current)) continue;
|
|
|
|
|
+ if (isDocumentLevelContainer(current)) break;
|
|
|
|
|
+ const text = getCombinedSearchText(current);
|
|
|
|
|
+ if (pattern.test(text) && isPaymentCardSized(current)) {
|
|
|
|
|
+ return current;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getCombinedSearchText(el) {
|
|
|
|
|
+ if (!el) return '';
|
|
|
|
|
+ return [
|
|
|
|
|
+ el.textContent,
|
|
|
|
|
+ el.getAttribute?.('aria-label'),
|
|
|
|
|
+ el.getAttribute?.('data-testid'),
|
|
|
|
|
+ el.id,
|
|
|
|
|
+ el.name,
|
|
|
|
|
+ el.value,
|
|
|
|
|
+ el.className && typeof el.className === 'string' ? el.className : '',
|
|
|
|
|
+ ].filter(Boolean).join(' ');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function waitForPayPalPaymentMethodTarget(timeoutMs = 10000) {
|
|
|
|
|
+ const startedAt = Date.now();
|
|
|
|
|
+ while (Date.now() - startedAt < timeoutMs) {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ const target = findPayPalPaymentMethodTarget();
|
|
|
|
|
+ if (target) return target;
|
|
|
|
|
+ await sleep(250);
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function waitForPayPalPaymentMethodActive(timeoutMs = 3500) {
|
|
|
|
|
+ const startedAt = Date.now();
|
|
|
|
|
+ while (Date.now() - startedAt < timeoutMs) {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ if (hasSelectedPayPalControl()) return true;
|
|
|
|
|
+ await sleep(250);
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function hasSelectedPayPalControl() {
|
|
|
|
|
+ const target = findPayPalPaymentMethodTarget();
|
|
|
|
|
+ let current = target;
|
|
|
|
|
+ for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) {
|
|
|
|
|
+ if (isDocumentLevelContainer(current)) break;
|
|
|
|
|
+ if (/paypal/i.test(getCombinedSearchText(current)) && hasPaymentMethodSelectionMarker(current)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const radio = current.querySelector?.('input[type="radio"], [role="radio"]');
|
|
|
|
|
+ if (
|
|
|
|
|
+ radio
|
|
|
|
|
+ && /paypal/i.test(getCombinedSearchText(current) || getCombinedSearchText(radio))
|
|
|
|
|
+ && hasPaymentMethodSelectionMarker(radio)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function hasPaymentMethodSelectionMarker(el) {
|
|
|
|
|
+ if (!el) return false;
|
|
|
|
|
+ const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '';
|
|
|
|
|
+ return el.checked === true
|
|
|
|
|
+ || el.getAttribute?.('aria-checked') === 'true'
|
|
|
|
|
+ || el.getAttribute?.('aria-selected') === 'true'
|
|
|
|
|
+ || el.getAttribute?.('data-state') === 'checked'
|
|
|
|
|
+ || el.getAttribute?.('data-selected') === 'true'
|
|
|
|
|
+ || /\b(selected|checked|active)\b/i.test(className);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function ensureTermsCheckbox() {
|
|
|
|
|
+ const cb = document.getElementById('termsOfServiceConsentCheckbox')
|
|
|
|
|
+ || Array.from(document.querySelectorAll('input[type="checkbox"]')).find((input) => /terms|service|agree|consent/i.test(getFieldText(input)));
|
|
|
|
|
+ if (cb && !cb.checked) {
|
|
|
|
|
+ dispatchPointerMouseClick(cb);
|
|
|
|
|
+ log('已勾选服务条款');
|
|
|
|
|
+ await sleep(300);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findSubmitButton() {
|
|
|
|
|
+ const direct = document.querySelector('button[data-testid="submit-button"]')
|
|
|
|
|
+ || document.querySelector('button[data-testid="hosted-payment-submit-button"]')
|
|
|
|
|
+ || document.querySelector('button[data-atomic-wait-intent="Submit_Email"]')
|
|
|
|
|
+ || document.querySelector('button.SubmitButton--complete');
|
|
|
|
|
+ if (direct && isVisibleNode(direct)) return direct;
|
|
|
|
|
+
|
|
|
|
|
+ const patterns = [/^下一页$/, /^下一步$/, /^next$/i, /subscribe|pay|continue|agree/i, /訂閱|处理中|同意|付款|继续/];
|
|
|
|
|
+ return Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]')).find((el) => {
|
|
|
|
|
+ if (!isVisibleNode(el) || el.disabled) return false;
|
|
|
|
|
+ const text = normalizeText(el.textContent || el.value || el.getAttribute?.('aria-label') || '');
|
|
|
|
|
+ return patterns.some((pattern) => pattern.test(text));
|
|
|
|
|
+ }) || null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function dispatchRobustActivation(el) {
|
|
|
|
|
+ dispatchPointerMouseClick(el);
|
|
|
|
|
+ if (typeof el.focus === 'function') {
|
|
|
|
|
+ el.focus({ preventScroll: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ [' ', 'Enter'].forEach((key) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ el.dispatchEvent(new KeyboardEvent('keydown', {
|
|
|
|
|
+ key,
|
|
|
|
|
+ code: key === ' ' ? 'Space' : 'Enter',
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ }));
|
|
|
|
|
+ el.dispatchEvent(new KeyboardEvent('keyup', {
|
|
|
|
|
+ key,
|
|
|
|
|
+ code: key === ' ' ? 'Space' : 'Enter',
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ }));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Some synthetic keyboard events can be rejected on hardened checkout nodes.
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function dispatchPointerMouseClick(el) {
|
|
|
|
|
+ if (!el) throw new Error('无法点击空元素。');
|
|
|
|
|
+ el.scrollIntoView?.({ block: 'center', inline: 'nearest' });
|
|
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
|
|
+ const clientX = Math.max(0, Math.floor(rect.left + rect.width / 2));
|
|
|
|
|
+ const clientY = Math.max(0, Math.floor(rect.top + rect.height / 2));
|
|
|
|
|
+ ['pointerdown', 'mouseover', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach((type) => {
|
|
|
|
|
+ const EventCtor = type.startsWith('pointer') && typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
|
|
|
|
|
+ el.dispatchEvent(new EventCtor(type, {
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ view: window,
|
|
|
|
|
+ button: 0,
|
|
|
|
|
+ buttons: type === 'pointerup' || type === 'mouseup' || type === 'click' ? 0 : 1,
|
|
|
|
|
+ clientX,
|
|
|
|
|
+ clientY,
|
|
|
|
|
+ pointerId: 1,
|
|
|
|
|
+ pointerType: 'mouse',
|
|
|
|
|
+ isPrimary: true,
|
|
|
|
|
+ }));
|
|
|
|
|
+ });
|
|
|
|
|
+ if (typeof el.click === 'function') {
|
|
|
|
|
+ el.click();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fillBySelector(selector, value) {
|
|
|
|
|
+ const el = document.querySelector(selector);
|
|
|
|
|
+ if (el) {
|
|
|
|
|
+ fillInput(el, value);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log(`未找到元素: ${selector}`, 'warn');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fillMissingBySelector(selector, value) {
|
|
|
|
|
+ const el = document.querySelector(selector);
|
|
|
|
|
+ if (!el) {
|
|
|
|
|
+ log(`未找到元素: ${selector}`, 'warn');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (String(el.value || '').trim()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fillInput(el, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fillMissingControl(el, value) {
|
|
|
|
|
+ if (!el) return false;
|
|
|
|
|
+ if (String(el.value || '').trim()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ fillInput(el, value);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fillSelectByText(selector, text) {
|
|
|
|
|
+ const el = document.querySelector(selector);
|
|
|
|
|
+ if (!el) {
|
|
|
|
|
+ log(`未找到 select: ${selector}`, 'warn');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let i = 0; i < el.options.length; i++) {
|
|
|
|
|
+ const opt = el.options[i];
|
|
|
|
|
+ if (opt.text.toLowerCase().includes(text.toLowerCase()) || opt.value.toLowerCase().includes(text.toLowerCase())) {
|
|
|
|
|
+ fillSelect(el, opt.value);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ log(`未在 select 中找到匹配项: ${text}`, 'warn');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fillRegionControlByText(el, text) {
|
|
|
|
|
+ if (!el) return false;
|
|
|
|
|
+ if (el.tagName === 'SELECT') {
|
|
|
|
|
+ for (let i = 0; i < el.options.length; i++) {
|
|
|
|
|
+ const opt = el.options[i];
|
|
|
|
|
+ if (opt.text.toLowerCase().includes(text.toLowerCase()) || opt.value.toLowerCase().includes(text.toLowerCase())) {
|
|
|
|
|
+ fillSelect(el, opt.value);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ log(`未在地区 select 中找到匹配项: ${text}`, 'warn');
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return fillMissingControl(el, text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fillBillingAddressDirectSelectors(addr = {}) {
|
|
|
|
|
+ fillBySelector('#billingAddressLine1', addr.street);
|
|
|
|
|
+ fillBySelector('#billingLocality', addr.city);
|
|
|
|
|
+ fillBySelector('#billingPostalCode', addr.zip.substring(0, 5));
|
|
|
|
|
+ fillSelectByText('#billingAdministrativeArea', addr.state);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function fillBillingAddressLine1FromGoogle(addr = {}) {
|
|
|
|
|
+ const input = getStructuredAddressFields().address1 || findAddressSearchInput();
|
|
|
|
|
+ if (!input) {
|
|
|
|
|
+ log('未找到地址栏 1 输入框,无法触发 Google 地址下拉。', 'warn');
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const query = buildGoogleAddressQuery(addr);
|
|
|
|
|
+ log(`正在地址栏 1 输入完整地址以触发 Google 下拉: ${query}`);
|
|
|
|
|
+ await typeAddressQueryForAutocomplete(input, query);
|
|
|
|
|
+
|
|
|
|
|
+ const item = await waitForGoogleAddressSuggestion(query, 6500);
|
|
|
|
|
+ if (item) {
|
|
|
|
|
+ const itemText = getSuggestionText(item) || '首个地址建议';
|
|
|
|
|
+ log(`正在选择 Google 地址建议: ${itemText}`);
|
|
|
|
|
+ clickAutocompleteSuggestion(item);
|
|
|
|
|
+ await waitForAddressAutofill(input, query, 2200);
|
|
|
|
|
+ dispatchInputBlur(input);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const externalFrameSelected = await selectAddressSuggestionInExternalFrame(query);
|
|
|
|
|
+ if (externalFrameSelected) {
|
|
|
|
|
+ await waitForAddressAutofill(input, query, 2200);
|
|
|
|
|
+ dispatchInputBlur(input);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log('未检测到可点击的 Google 地址建议,尝试使用键盘选择首个建议...', 'warn');
|
|
|
|
|
+ const keyboardSelected = await chooseAutocompleteWithKeyboard(input, query);
|
|
|
|
|
+ if (keyboardSelected) {
|
|
|
|
|
+ dispatchInputBlur(input);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findAutoJsPayPalTarget() {
|
|
|
|
|
+ return document.querySelector('[data-testid="paypal-accordion-item-button"]')
|
|
|
|
|
+ || document.querySelector('.paypal-accordion-item button');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function clickPayPalLikeAutoJs(target) {
|
|
|
|
|
+ if (!target) return;
|
|
|
|
|
+ target.scrollIntoView?.({ block: 'center', inline: 'nearest' });
|
|
|
|
|
+ if (typeof target.click === 'function') {
|
|
|
|
|
+ target.click();
|
|
|
|
|
+ }
|
|
|
|
|
+ await sleep(600);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function selectGoogleAddressSuggestionOnly(payload = {}) {
|
|
|
|
|
+ const query = normalizeText(payload.query || '');
|
|
|
|
|
+ const item = await waitForGoogleAddressSuggestion(query, 5500);
|
|
|
|
|
+ if (!item) {
|
|
|
|
|
+ return { error: '未找到 Google 地址建议项' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const itemText = getSuggestionText(item) || '首个地址建议';
|
|
|
|
|
+ log(`正在独立 autocomplete iframe 中选择 Google 地址建议: ${itemText}`);
|
|
|
|
|
+ clickAutocompleteSuggestion(item);
|
|
|
|
|
+ await sleep(900);
|
|
|
|
|
+ return {
|
|
|
|
|
+ ok: true,
|
|
|
|
|
+ selectedAddressText: itemText,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function selectAddressSuggestionInExternalFrame(query) {
|
|
|
|
|
+ if (!chrome?.runtime?.sendMessage) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await chrome.runtime.sendMessage({
|
|
|
|
|
+ type: 'CHECKOUT_STRIPE_SELECT_AUTOCOMPLETE_FRAME',
|
|
|
|
|
+ source: 'checkout-stripe',
|
|
|
|
|
+ payload: { query },
|
|
|
|
|
+ });
|
|
|
|
|
+ if (response?.ok) {
|
|
|
|
|
+ log(`已在独立 Google 地址 iframe 中选择建议: ${response.selectedAddressText || '首个地址建议'}`);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (response?.error) {
|
|
|
|
|
+ log(`独立 Google 地址 iframe 未完成选择: ${response.error}`, 'warn');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ log(`尝试选择独立 Google 地址 iframe 失败: ${error?.message || error}`, 'warn');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function buildGoogleAddressQuery(addr = {}) {
|
|
|
|
|
+ const explicitQuery = normalizeText(addr.query || addr.autocompleteQuery || '');
|
|
|
|
|
+ if (explicitQuery) return explicitQuery;
|
|
|
|
|
+
|
|
|
|
|
+ const street = normalizeText(addr.street || addr.address1 || '123 Main St');
|
|
|
|
|
+ const city = normalizeText(addr.city || 'New York');
|
|
|
|
|
+ const state = normalizeText(addr.state || addr.region || 'New York');
|
|
|
|
|
+ const zip = normalizeText(addr.zip || addr.postalCode || '10001').substring(0, 5);
|
|
|
|
|
+ return [street, city, state, zip].filter(Boolean).join(', ');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function typeAddressQueryForAutocomplete(input, query) {
|
|
|
|
|
+ input.scrollIntoView?.({ block: 'center', inline: 'nearest' });
|
|
|
|
|
+ input.focus();
|
|
|
|
|
+ await sleep(150);
|
|
|
|
|
+
|
|
|
|
|
+ setNativeInputValue(input, '');
|
|
|
|
|
+ input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
+ input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
+ await sleep(120);
|
|
|
|
|
+
|
|
|
|
|
+ for (const char of String(query || '')) {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ input.dispatchEvent(new KeyboardEvent('keydown', {
|
|
|
|
|
+ key: char,
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ }));
|
|
|
|
|
+ setNativeInputValue(input, `${input.value || ''}${char}`);
|
|
|
|
|
+ dispatchAutocompleteInputEvent(input, char);
|
|
|
|
|
+ input.dispatchEvent(new KeyboardEvent('keyup', {
|
|
|
|
|
+ key: char,
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ }));
|
|
|
|
|
+ await sleep(18);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function setNativeInputValue(input, value) {
|
|
|
|
|
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
|
|
|
|
|
+ if (descriptor?.set) {
|
|
|
|
|
+ descriptor.set.call(input, value);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ input.value = value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function dispatchAutocompleteInputEvent(input, data) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ input.dispatchEvent(new InputEvent('input', {
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ data,
|
|
|
|
|
+ inputType: 'insertText',
|
|
|
|
|
+ }));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function dispatchInputBlur(input) {
|
|
|
|
|
+ input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
+ input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function waitForGoogleAddressSuggestion(query, timeoutMs = 5000) {
|
|
|
|
|
+ const start = Date.now();
|
|
|
|
|
+ while (Date.now() - start < timeoutMs) {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ const items = getVisibleAutocompleteSuggestions(query);
|
|
|
|
|
+ if (items.length) {
|
|
|
|
|
+ const matching = findBestAddressSuggestion(items, query);
|
|
|
|
|
+ return matching || items[0];
|
|
|
|
|
+ }
|
|
|
|
|
+ await sleep(250);
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getVisibleAutocompleteSuggestions(query = '') {
|
|
|
|
|
+ const selectors = [
|
|
|
|
|
+ { selector: '.pac-container .pac-item', generic: false },
|
|
|
|
|
+ { selector: '#billing-address-autocomplete-results [role="option"]', generic: false },
|
|
|
|
|
+ { selector: '.AddressAutocomplete-results [role="option"]', generic: false },
|
|
|
|
|
+ { selector: '[class*="AddressAutocomplete"] [role="option"]', generic: false },
|
|
|
|
|
+ { selector: '[data-testid*="address" i] [role="option"]', generic: false },
|
|
|
|
|
+ { selector: '[role="listbox"] [role="option"]', generic: false },
|
|
|
|
|
+ { selector: '[role="option"]', generic: false },
|
|
|
|
|
+ { selector: '[role="listbox"] li', generic: true },
|
|
|
|
|
+ { selector: '.autocomplete-dropdown [role="option"]', generic: false },
|
|
|
|
|
+ { selector: '.autocomplete-dropdown li', generic: true },
|
|
|
|
|
+ { selector: 'li', generic: true },
|
|
|
|
|
+ ];
|
|
|
|
|
+ const seen = new Set();
|
|
|
|
|
+ const items = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const config of selectors) {
|
|
|
|
|
+ document.querySelectorAll(config.selector).forEach((item) => {
|
|
|
|
|
+ if (seen.has(item) || !isVisibleNode(item)) return;
|
|
|
|
|
+ if (!isLikelyAddressSuggestion(item, query, config.generic)) return;
|
|
|
|
|
+ seen.add(item);
|
|
|
|
|
+ items.push(item);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return items;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function findBestAddressSuggestion(items, query) {
|
|
|
|
|
+ let best = null;
|
|
|
|
|
+ let bestScore = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ const score = scoreAddressSuggestion(item, query);
|
|
|
|
|
+ if (score > bestScore) {
|
|
|
|
|
+ best = item;
|
|
|
|
|
+ bestScore = score;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return best || null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getSuggestionText(item) {
|
|
|
|
|
+ return normalizeText(item?.textContent || item?.getAttribute?.('aria-label') || '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function normalizeText(value = '') {
|
|
|
|
|
+ return String(value || '').replace(/\s+/g, ' ').trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getAddressQueryTokens(query = '') {
|
|
|
|
|
+ return normalizeText(query)
|
|
|
|
|
+ .toLowerCase()
|
|
|
|
|
+ .split(/[^a-z0-9]+/i)
|
|
|
|
|
+ .map((part) => part.trim())
|
|
|
|
|
+ .filter((part) => part.length >= 3);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function scoreAddressSuggestion(item, query = '') {
|
|
|
|
|
+ const text = getSuggestionText(item).toLowerCase();
|
|
|
|
|
+ if (!text) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ const tokens = getAddressQueryTokens(query);
|
|
|
|
|
+ let score = 0;
|
|
|
|
|
+ tokens.forEach((token) => {
|
|
|
|
|
+ if (text.includes(token)) score += 1;
|
|
|
|
|
+ });
|
|
|
|
|
+ if (/\d/.test(text)) score += 2;
|
|
|
|
|
+ if (/street|st\.?|avenue|ave\.?|road|rd\.?|drive|dr\.?|boulevard|blvd\.?|lane|ln\.?|way|court|ct\.?/i.test(text)) {
|
|
|
|
|
+ score += 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ return score;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isLikelyAddressSuggestion(item, query = '', generic = false) {
|
|
|
|
|
+ const text = getSuggestionText(item);
|
|
|
|
|
+ if (!text || text.length < 3) return false;
|
|
|
|
|
+
|
|
|
|
|
+ if (!generic) return true;
|
|
|
|
|
+
|
|
|
|
|
+ const lowered = text.toLowerCase();
|
|
|
|
|
+ if (/terms|privacy|subscribe|paypal|card|payment|email|phone|下一步|提交|付款|订阅/i.test(lowered)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return scoreAddressSuggestion(item, query) > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isVisibleNode(node) {
|
|
|
|
|
+ const rect = node.getBoundingClientRect();
|
|
|
|
|
+ const style = window.getComputedStyle(node);
|
|
|
|
|
+ return rect.width > 0
|
|
|
|
|
+ && rect.height > 0
|
|
|
|
|
+ && style.visibility !== 'hidden'
|
|
|
|
|
+ && style.display !== 'none';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function clickAutocompleteSuggestion(item) {
|
|
|
|
|
+ item.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
|
|
|
+ ['pointerdown', 'mouseover', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach((type) => {
|
|
|
|
|
+ const EventCtor = type.startsWith('pointer') && typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
|
|
|
|
|
+ item.dispatchEvent(new EventCtor(type, {
|
|
|
|
|
+ bubbles: true,
|
|
|
|
|
+ cancelable: true,
|
|
|
|
|
+ view: window,
|
|
|
|
|
+ }));
|
|
|
|
|
+ });
|
|
|
|
|
+ if (typeof item.click === 'function') {
|
|
|
|
|
+ item.click();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function chooseAutocompleteWithKeyboard(input, query = '') {
|
|
|
|
|
+ input.focus();
|
|
|
|
|
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
|
|
|
+ input.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown', code: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
|
|
|
+ await sleep(250);
|
|
|
|
|
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
|
|
|
|
+ input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }));
|
|
|
|
|
+ return waitForAddressAutofill(input, query, 2200);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function waitForAddressAutofill(input, query = '', timeoutMs = 1800) {
|
|
|
|
|
+ const startedAt = Date.now();
|
|
|
|
|
+ const originalQuery = normalizeText(query);
|
|
|
|
|
+ while (Date.now() - startedAt < timeoutMs) {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ const line1 = normalizeText(input?.value || '');
|
|
|
|
|
+ const fields = getStructuredAddressFields();
|
|
|
|
|
+ const city = normalizeText(fields.city?.value || document.querySelector('#billingLocality')?.value || '');
|
|
|
|
|
+ const zip = normalizeText(fields.postalCode?.value || document.querySelector('#billingPostalCode')?.value || '');
|
|
|
|
|
+ if (city || zip || (line1 && line1 !== originalQuery)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ await sleep(200);
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function hideAutocompleteDropdowns() {
|
|
|
|
|
+ log('正在隐藏剩余 Google 地址补全框...');
|
|
|
|
|
+ document.querySelectorAll([
|
|
|
|
|
+ '.pac-container',
|
|
|
|
|
+ '.pac-item',
|
|
|
|
|
+ 'div[role="listbox"]',
|
|
|
|
|
+ '.AddressAutocomplete-results',
|
|
|
|
|
+ '[class*="AddressAutocomplete"]',
|
|
|
|
|
+ '#billing-address-autocomplete-results',
|
|
|
|
|
+ ].join(', ')).forEach((el) => {
|
|
|
|
|
+ el.style.setProperty('display', 'none', 'important');
|
|
|
|
|
+ el.style.setProperty('visibility', 'hidden', 'important');
|
|
|
|
|
+ el.style.setProperty('height', '0', 'important');
|
|
|
|
|
+ el.style.setProperty('overflow', 'hidden', 'important');
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function clickSubmitButton(retries = 0) {
|
|
|
|
|
+ throwIfStopped();
|
|
|
|
|
+ const btn = findSubmitButton();
|
|
|
|
|
+
|
|
|
|
|
+ if (btn) {
|
|
|
|
|
+ const rect = btn.getBoundingClientRect();
|
|
|
|
|
+ if (btn.disabled || rect.height === 0) {
|
|
|
|
|
+ log('提交按钮被禁用或不可见,等待中...');
|
|
|
|
|
+ if (retries < 12) {
|
|
|
|
|
+ await sleep(800);
|
|
|
|
|
+ return clickSubmitButton(retries + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error('提交按钮一直不可用,已超时');
|
|
|
|
|
+ }
|
|
|
|
|
+ log(`正在点击提交按钮: ${btn.textContent.trim()}`);
|
|
|
|
|
+ dispatchPointerMouseClick(btn);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (retries < 12) {
|
|
|
|
|
+ log(`未找到提交按钮,重试中... (${retries + 1})`);
|
|
|
|
|
+ await sleep(800);
|
|
|
|
|
+ return clickSubmitButton(retries + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error('在 Stripe 页面上未找到提交按钮');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.documentElement.setAttribute('data-multipage-checkout-stripe-ready', '');
|
|
|
|
|
+})();
|