diff --git a/content/signup-page.js b/content/signup-page.js index c309cb1..235e8c2 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1151,6 +1151,256 @@ function extractDialCodeFromText(value) { return String(match?.[1] || match?.[2] || '').trim(); } +function dispatchSignupPhoneFieldEvents(element) { + if (!element) return; + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); +} + +function normalizeSignupCountryLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&/g, ' and ') + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function getSignupPhoneOptionLabel(option) { + return String(option?.textContent || option?.label || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeSignupCountryOptionValue(value) { + return String(value || '').trim().toUpperCase(); +} + +function getSignupRegionDisplayName(regionCode, locale) { + const normalizedRegionCode = normalizeSignupCountryOptionValue(regionCode); + const normalizedLocale = String(locale || '').trim(); + if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') { + return ''; + } + try { + return String( + new Intl.DisplayNames([normalizedLocale], { type: 'region' }).of(normalizedRegionCode) || '' + ).trim(); + } catch { + return ''; + } +} + +function getSignupPhoneCountryMatchLabels(option) { + const labels = new Set(); + const pushLabel = (value) => { + const label = String(value || '').replace(/\s+/g, ' ').trim(); + if (label) { + labels.add(label); + } + }; + + pushLabel(getSignupPhoneOptionLabel(option)); + + const regionCode = normalizeSignupCountryOptionValue(option?.value); + if (/^[A-Z]{2}$/.test(regionCode)) { + pushLabel(regionCode); + pushLabel(getSignupRegionDisplayName(regionCode, 'en')); + + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const pageLocale = String( + document?.documentElement?.lang + || document?.documentElement?.getAttribute?.('lang') + || rootScope?.navigator?.language + || '' + ).trim(); + if (pageLocale && !/^en(?:[-_]|$)/i.test(pageLocale)) { + pushLabel(getSignupRegionDisplayName(regionCode, pageLocale)); + } + } + + return Array.from(labels); +} + +function isSameSignupCountryOption(left, right) { + if (!left || !right) { + return false; + } + + const leftValue = normalizeSignupCountryOptionValue(left.value); + const rightValue = normalizeSignupCountryOptionValue(right.value); + if (leftValue && rightValue) { + return leftValue === rightValue; + } + + return normalizeSignupCountryLabel(getSignupPhoneOptionLabel(left)) === normalizeSignupCountryLabel(getSignupPhoneOptionLabel(right)); +} + +function getSignupPhoneForm(phoneInput = getSignupPhoneInput()) { + return phoneInput?.closest?.('form') || null; +} + +function getSignupPhoneCountrySelect(phoneInput = getSignupPhoneInput()) { + const form = getSignupPhoneForm(phoneInput); + if (!form || typeof form.querySelector !== 'function') { + return null; + } + return form.querySelector('select'); +} + +function getSignupPhoneSelectedCountryOption(phoneInput = getSignupPhoneInput()) { + const select = getSignupPhoneCountrySelect(phoneInput); + if (!select || select.selectedIndex < 0) { + return null; + } + return select.options?.[select.selectedIndex] || null; +} + +function getSignupPhoneCountryButtonText(phoneInput = getSignupPhoneInput()) { + const form = getSignupPhoneForm(phoneInput); + if (!form || typeof form.querySelector !== 'function') return ''; + const button = form.querySelector('button[aria-haspopup="listbox"]'); + if (!button) return ''; + const valueNode = button.querySelector('.react-aria-SelectValue'); + return String(valueNode?.textContent || button.textContent || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function getSignupPhoneDisplayedDialCode(phoneInput = getSignupPhoneInput()) { + const buttonDialCode = extractDialCodeFromText(getSignupPhoneCountryButtonText(phoneInput)); + if (buttonDialCode) { + return buttonDialCode; + } + const inputRoot = phoneInput?.closest?.('fieldset, form, [data-rac], div') || document; + const visibleText = String(inputRoot?.textContent || '').replace(/\s+/g, ' ').trim(); + const rootDialCode = extractDialCodeFromText(visibleText); + if (rootDialCode) { + return rootDialCode; + } + const pageDialCode = extractDialCodeFromText(getPageTextSnapshot()); + if (pageDialCode) { + return pageDialCode; + } + return ''; +} + +function getSignupPhoneHiddenNumberInput(phoneInput = getSignupPhoneInput()) { + const form = getSignupPhoneForm(phoneInput); + if (!form || typeof form.querySelector !== 'function') { + return null; + } + return form.querySelector('input[name="phoneNumber"]'); +} + +function findSignupPhoneCountryOptionByLabel(phoneInput, countryLabel) { + const select = getSignupPhoneCountrySelect(phoneInput); + if (!select) { + return null; + } + const normalizedTarget = normalizeSignupCountryLabel(countryLabel); + if (!normalizedTarget) { + return null; + } + + const options = Array.from(select.options || []); + return options.find((option) => ( + getSignupPhoneCountryMatchLabels(option).some((label) => normalizeSignupCountryLabel(label) === normalizedTarget) + )) + || options.find((option) => { + const normalizedLabels = getSignupPhoneCountryMatchLabels(option) + .map((label) => normalizeSignupCountryLabel(label)) + .filter(Boolean); + return normalizedLabels.some((optionLabel) => ( + optionLabel.length > 2 + && normalizedTarget.length > 2 + && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)) + )); + }) + || null; +} + +function findSignupPhoneCountryOptionByPhoneNumber(phoneInput, phoneNumber) { + const select = getSignupPhoneCountrySelect(phoneInput); + if (!select) { + return null; + } + const digits = normalizePhoneDigits(phoneNumber); + if (!digits) { + return null; + } + + let bestMatch = null; + let bestDialCodeLength = 0; + for (const option of Array.from(select.options || [])) { + const dialCode = normalizePhoneDigits(extractDialCodeFromText(getSignupPhoneOptionLabel(option))); + if (!dialCode || !digits.startsWith(dialCode)) { + continue; + } + if (dialCode.length > bestDialCodeLength) { + bestMatch = option; + bestDialCodeLength = dialCode.length; + } + } + return bestMatch; +} + +async function trySelectSignupPhoneCountryOption(select, targetOption) { + if (!select || !targetOption) { + return false; + } + const selectedOption = select.selectedIndex >= 0 + ? (select.options?.[select.selectedIndex] || null) + : null; + if (selectedOption && isSameSignupCountryOption(selectedOption, targetOption)) { + return true; + } + select.value = String(targetOption.value || ''); + dispatchSignupPhoneFieldEvents(select); + await sleep(250); + const nextSelectedOption = select.selectedIndex >= 0 + ? (select.options?.[select.selectedIndex] || null) + : null; + return Boolean(nextSelectedOption && isSameSignupCountryOption(nextSelectedOption, targetOption)); +} + +async function ensureSignupPhoneCountrySelected(phoneInput, options = {}) { + const select = getSignupPhoneCountrySelect(phoneInput); + if (!select) { + return { + hasSelect: false, + matched: false, + selectedOption: null, + }; + } + + const byLabel = findSignupPhoneCountryOptionByLabel(phoneInput, options.countryLabel); + if (await trySelectSignupPhoneCountryOption(select, byLabel)) { + return { + hasSelect: true, + matched: true, + selectedOption: getSignupPhoneSelectedCountryOption(phoneInput), + }; + } + + const byPhoneNumber = findSignupPhoneCountryOptionByPhoneNumber(phoneInput, options.phoneNumber); + if (await trySelectSignupPhoneCountryOption(select, byPhoneNumber)) { + return { + hasSelect: true, + matched: true, + selectedOption: getSignupPhoneSelectedCountryOption(phoneInput), + }; + } + + return { + hasSelect: true, + matched: false, + selectedOption: getSignupPhoneSelectedCountryOption(phoneInput), + }; +} + function toNationalPhoneNumber(value, dialCode) { const digits = normalizePhoneDigits(value); const normalizedDialCode = normalizePhoneDigits(dialCode); @@ -1167,24 +1417,41 @@ function toNationalPhoneNumber(value, dialCode) { return digits; } +function toE164PhoneNumber(value, dialCode) { + const digits = normalizePhoneDigits(value); + const normalizedDialCode = normalizePhoneDigits(dialCode); + const isExplicitInternational = /^\s*(?:\+|00)\s*\d/.test(String(value || '').trim()); + if (!digits) { + return ''; + } + if (isExplicitInternational) { + return `+${digits}`; + } + if (!normalizedDialCode) { + return `+${digits}`; + } + if (digits.startsWith(normalizedDialCode)) { + return `+${digits}`; + } + if (digits.startsWith('0')) { + return `+${normalizedDialCode}${digits.slice(1)}`; + } + return `+${normalizedDialCode}${digits}`; +} + function resolveSignupPhoneDialCode(phoneInput, options = {}) { const { phoneNumber = '', countryLabel = '' } = options; - const inputRoot = phoneInput?.closest?.('fieldset, form, [data-rac], div') || document; - const visibleText = String(inputRoot?.textContent || '').replace(/\s+/g, ' ').trim(); - const rootDialCode = extractDialCodeFromText(visibleText); - if (rootDialCode) { - return rootDialCode; - } - const pageDialCode = extractDialCodeFromText(getPageTextSnapshot()); - if (pageDialCode) { - return pageDialCode; + const displayedDialCode = getSignupPhoneDisplayedDialCode(phoneInput); + if (displayedDialCode) { + return displayedDialCode; } const countryText = String(countryLabel || '').trim(); + if (/australia|澳大利亚/i.test(countryText)) return '61'; if (/thailand|泰国/i.test(countryText)) return '66'; if (/vietnam|越南/i.test(countryText)) return '84'; if (/england|united kingdom|英国|uk/i.test(countryText)) return '44'; const digits = normalizePhoneDigits(phoneNumber); - const knownDialCodes = ['66', '84', '44', '1', '81', '82', '86', '852', '855', '856', '60', '62', '63', '65']; + const knownDialCodes = ['66', '84', '61', '44', '1', '81', '82', '86', '852', '855', '856', '60', '62', '63', '65']; return knownDialCodes.find((code) => digits.startsWith(code) && digits.length > code.length) || ''; } @@ -1278,6 +1545,14 @@ async function submitSignupPhoneNumberAndContinue(payload = {}) { throw new Error(`步骤 2:未找到可用的手机号输入入口。URL: ${location.href}`); } + const countrySelection = await ensureSignupPhoneCountrySelected(snapshot.phoneInput, { + countryLabel, + phoneNumber, + }); + if (countrySelection.hasSelect && !countrySelection.matched) { + log(`步骤 2:手机号国家下拉框未能自动切换到 ${countryLabel || phoneNumber},将继续使用当前国家。`, 'warn'); + } + const dialCode = resolveSignupPhoneDialCode(snapshot.phoneInput, { phoneNumber, countryId: payload.countryId, @@ -1291,6 +1566,11 @@ async function submitSignupPhoneNumberAndContinue(payload = {}) { log(`步骤 2:正在填写手机号:${phoneNumber}`); await humanPause(500, 1400); fillInput(snapshot.phoneInput, inputValue); + const hiddenPhoneNumberInput = getSignupPhoneHiddenNumberInput(snapshot.phoneInput); + const e164PhoneNumber = toE164PhoneNumber(phoneNumber, dialCode); + if (hiddenPhoneNumberInput && e164PhoneNumber) { + fillInput(hiddenPhoneNumberInput, e164PhoneNumber); + } log(`步骤 2:手机号已填写:${phoneNumber}${dialCode ? `(区号 +${dialCode},本地号 ${inputValue})` : ''}`); const continueButton = getSignupEmailContinueButton({ allowDisabled: true }); diff --git a/tests/signup-step2-email-switch.test.js b/tests/signup-step2-email-switch.test.js index 48dbfd2..744afd5 100644 --- a/tests/signup-step2-email-switch.test.js +++ b/tests/signup-step2-email-switch.test.js @@ -625,6 +625,299 @@ return { assert.deepEqual(api.getClicks(), ['免费注册', 'Continue with phone number']); }); +test('submitSignupPhoneNumberAndContinue auto-switches signup country before filling the local phone number', async () => { + const api = new Function(` +const logs = []; +const clicks = []; +const filled = []; +const selectEvents = []; +let now = 0; + +const continueButton = { + textContent: 'Continue', + value: '', + disabled: false, + getAttribute(name) { + if (name === 'type') return 'submit'; + if (name === 'aria-disabled') return 'false'; + return ''; + }, + getBoundingClientRect() { + return { width: 200, height: 48 }; + }, +}; + +const countryOptions = [ + { value: 'AU', textContent: 'Australia (+61)', buttonText: '澳大利亚 (+61)' }, + { value: 'GB', textContent: 'United Kingdom (+44)', buttonText: '英国 (+44)' }, +]; + +const countrySelect = { + options: countryOptions, + selectedIndex: 0, + dispatchEvent(event) { + selectEvents.push(event?.type || ''); + return true; + }, +}; + +Object.defineProperty(countrySelect, 'value', { + get() { + return countryOptions[countrySelect.selectedIndex]?.value || ''; + }, + set(nextValue) { + const nextIndex = countryOptions.findIndex((option) => option.value === String(nextValue || '')); + if (nextIndex >= 0) { + countrySelect.selectedIndex = nextIndex; + } + }, +}); + +const hiddenPhoneInput = { + kind: 'hidden-phone', + value: '', + getAttribute() { + return ''; + }, +}; + +const phoneInput = { + kind: 'phone', + value: '', + getAttribute(name) { + if (name === 'type') return 'tel'; + return ''; + }, + closest(selector) { + if (selector === 'form') { + return form; + } + return form; + }, +}; + +const selectValueNode = { + get textContent() { + return countryOptions[countrySelect.selectedIndex]?.buttonText || ''; + }, +}; + +const countryButton = { + querySelector(selector) { + return selector === '.react-aria-SelectValue' ? selectValueNode : null; + }, + get textContent() { + return selectValueNode.textContent; + }, +}; + +const form = { + textContent: '英国 (+44)', + querySelector(selector) { + if (selector === 'select') return countrySelect; + if (selector === 'input[name="phoneNumber"]') return hiddenPhoneInput; + if (selector === 'button[aria-haspopup="listbox"]') return countryButton; + return null; + }, +}; + +const document = { + documentElement: { + lang: 'zh-CN', + getAttribute(name) { + return name === 'lang' ? 'zh-CN' : ''; + }, + }, + querySelector(selector) { + if (selector === SIGNUP_PHONE_INPUT_SELECTOR) { + return phoneInput; + } + if (selector === 'button[type="submit"], input[type="submit"]') { + return continueButton; + } + return null; + }, + querySelectorAll(selector) { + if (selector === 'button, a, [role="button"], [role="link"]') { + return []; + } + if (selector === 'a, button, [role="button"], [role="link"]') { + return []; + } + if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') { + return [continueButton]; + } + if (selector === 'input') { + return [phoneInput]; + } + return []; + }, +}; + +const location = { + href: 'https://chatgpt.com/', +}; + +const window = { + setTimeout(fn) { + fn(); + }, +}; + +const Date = { + now() { + return now; + }, +}; + +class Event { + constructor(type) { + this.type = type; + } +} + +function isVisibleElement(el) { + return Boolean(el); +} + +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\\s+/g, ' ') + .trim(); +} + +function getSignupPasswordInput() { + return null; +} + +function isSignupPasswordPage() { + return false; +} + +function getSignupPasswordSubmitButton() { + return null; +} + +function findSignupEntryTrigger() { + return null; +} + +function getSignupPasswordDisplayedEmail() { + return ''; +} + +function getPageTextSnapshot() { + return countryButton.textContent; +} + +function throwIfStopped() {} +function isStopError() { return false; } + +function log(message, level = 'info') { + logs.push({ message, level }); +} + +async function humanPause() {} + +function simulateClick(target) { + clicks.push(getActionText(target)); +} + +function fillInput(target, value) { + target.value = value; + filled.push({ target: target.kind, value }); +} + +async function sleep(ms) { + now += ms; +} + +${extractConst('SIGNUP_ENTRY_TRIGGER_PATTERN')} +${extractConst('SIGNUP_EMAIL_INPUT_SELECTOR')} +${extractConst('SIGNUP_PHONE_INPUT_SELECTOR')} +${extractConst('SIGNUP_SWITCH_TO_EMAIL_PATTERN')} +${extractConst('SIGNUP_SWITCH_ACTION_PATTERN')} +${extractConst('SIGNUP_EMAIL_ACTION_PATTERN')} +${extractConst('SIGNUP_WORK_EMAIL_PATTERN')} +${extractConst('SIGNUP_PHONE_ACTION_PATTERN')} +${extractConst('SIGNUP_SWITCH_TO_PHONE_PATTERN')} +${extractConst('SIGNUP_MORE_OPTIONS_PATTERN')} + +${extractFunction('getSignupEmailInput')} +${extractFunction('getSignupPhoneInput')} +${extractFunction('findSignupUseEmailTrigger')} +${extractFunction('findSignupUsePhoneTrigger')} +${extractFunction('findSignupMoreOptionsTrigger')} +${extractFunction('getSignupEmailContinueButton')} +${extractFunction('inspectSignupEntryState')} +${extractFunction('getSignupEntryStateSummary')} +function getSignupEntryDiagnostics() { return {}; } +${extractFunction('normalizePhoneDigits')} +${extractFunction('extractDialCodeFromText')} +${extractFunction('dispatchSignupPhoneFieldEvents')} +${extractFunction('normalizeSignupCountryLabel')} +${extractFunction('getSignupPhoneOptionLabel')} +${extractFunction('normalizeSignupCountryOptionValue')} +${extractFunction('getSignupRegionDisplayName')} +${extractFunction('getSignupPhoneCountryMatchLabels')} +${extractFunction('isSameSignupCountryOption')} +${extractFunction('getSignupPhoneForm')} +${extractFunction('getSignupPhoneCountrySelect')} +${extractFunction('getSignupPhoneSelectedCountryOption')} +${extractFunction('getSignupPhoneCountryButtonText')} +${extractFunction('getSignupPhoneDisplayedDialCode')} +${extractFunction('getSignupPhoneHiddenNumberInput')} +${extractFunction('findSignupPhoneCountryOptionByLabel')} +${extractFunction('findSignupPhoneCountryOptionByPhoneNumber')} +${extractFunction('trySelectSignupPhoneCountryOption')} +${extractFunction('ensureSignupPhoneCountrySelected')} +${extractFunction('toNationalPhoneNumber')} +${extractFunction('toE164PhoneNumber')} +${extractFunction('resolveSignupPhoneDialCode')} +${extractFunction('waitForSignupPhoneEntryState')} +${extractFunction('submitSignupPhoneNumberAndContinue')} + +return { + async run() { + return submitSignupPhoneNumberAndContinue({ + phoneNumber: '+447859232013', + countryLabel: 'United Kingdom', + }); + }, + getClicks() { + return clicks.slice(); + }, + getFilled() { + return filled.slice(); + }, + getSelectValue() { + return countrySelect.value; + }, + getSelectEvents() { + return selectEvents.slice(); + }, +}; +`)(); + + const result = await api.run(); + + assert.equal(result.submitted, true); + assert.equal(result.phoneInputValue, '7859232013'); + assert.equal(api.getSelectValue(), 'GB'); + assert.deepEqual(api.getSelectEvents(), ['input', 'change']); + assert.deepEqual(api.getClicks(), ['Continue']); + assert.deepEqual(api.getFilled(), [ + { target: 'phone', value: '7859232013' }, + { target: 'hidden-phone', value: '+447859232013' }, + ]); +}); + test('submitSignupPhoneNumberAndContinue switches from email mode to phone mode and submits local number', async () => { const api = new Function(` const logs = []; @@ -815,7 +1108,25 @@ ${extractFunction('getSignupEntryStateSummary')} function getSignupEntryDiagnostics() { return {}; } ${extractFunction('normalizePhoneDigits')} ${extractFunction('extractDialCodeFromText')} +${extractFunction('dispatchSignupPhoneFieldEvents')} +${extractFunction('normalizeSignupCountryLabel')} +${extractFunction('getSignupPhoneOptionLabel')} +${extractFunction('normalizeSignupCountryOptionValue')} +${extractFunction('getSignupRegionDisplayName')} +${extractFunction('getSignupPhoneCountryMatchLabels')} +${extractFunction('isSameSignupCountryOption')} +${extractFunction('getSignupPhoneForm')} +${extractFunction('getSignupPhoneCountrySelect')} +${extractFunction('getSignupPhoneSelectedCountryOption')} +${extractFunction('getSignupPhoneCountryButtonText')} +${extractFunction('getSignupPhoneDisplayedDialCode')} +${extractFunction('getSignupPhoneHiddenNumberInput')} +${extractFunction('findSignupPhoneCountryOptionByLabel')} +${extractFunction('findSignupPhoneCountryOptionByPhoneNumber')} +${extractFunction('trySelectSignupPhoneCountryOption')} +${extractFunction('ensureSignupPhoneCountrySelected')} ${extractFunction('toNationalPhoneNumber')} +${extractFunction('toE164PhoneNumber')} ${extractFunction('resolveSignupPhoneDialCode')} ${extractFunction('waitForSignupPhoneEntryState')} ${extractFunction('submitSignupPhoneNumberAndContinue')}