From 0795108014ee8a9aa9398fea97124799c81b9efe Mon Sep 17 00:00:00 2001 From: QLHazyCoder Date: Mon, 4 May 2026 19:38:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 67 ++++- background/message-router.js | 71 ++++-- background/steps/submit-signup-email.js | 71 +++++- content/phone-auth.js | 44 +++- content/phone-country-utils.js | 237 ++++++++++++++++++ content/signup-page.js | 135 +++++++++- manifest.json | 1 + ...ckground-message-router-step2-skip.test.js | 28 +++ .../background-signup-step2-branching.test.js | 140 +++++++++++ tests/phone-country-utils.test.js | 63 +++++ 项目完整链路说明.md | 8 +- 项目文件结构说明.md | 10 +- 12 files changed, 836 insertions(+), 39 deletions(-) create mode 100644 content/phone-country-utils.js create mode 100644 tests/phone-country-utils.test.js diff --git a/background.js b/background.js index 678fdd7..d945375 100644 --- a/background.js +++ b/background.js @@ -2617,9 +2617,58 @@ function broadcastIcloudAliasesChanged(payload = {}) { }).catch(() => { }); } +function normalizePhoneIdentityDigits(value = '') { + return String(value || '').replace(/\D+/g, ''); +} + +function getPhoneActivationPhoneNumber(activation = null) { + if (!activation || typeof activation !== 'object' || Array.isArray(activation)) { + return ''; + } + return String( + activation.phoneNumber + ?? activation.number + ?? activation.phone + ?? '' + ).trim(); +} + +function isPhoneActivationForNumber(activation, phoneNumber) { + const activationPhone = getPhoneActivationPhoneNumber(activation); + const targetPhone = String(phoneNumber || '').trim(); + if (!activationPhone || !targetPhone) { + return false; + } + if (activationPhone === targetPhone) { + return true; + } + const activationDigits = normalizePhoneIdentityDigits(activationPhone); + const targetDigits = normalizePhoneIdentityDigits(targetPhone); + return Boolean(activationDigits && targetDigits && activationDigits === targetDigits); +} + async function setEmailStateSilently(email) { - await setState({ email }); - broadcastDataUpdate({ email }); + const normalizedEmail = String(email || '').trim() || null; + const currentState = await getState(); + const updates = { + email: normalizedEmail, + }; + + if (normalizedEmail) { + updates.accountIdentifierType = 'email'; + updates.accountIdentifier = normalizedEmail; + updates.signupPhoneNumber = ''; + updates.signupPhoneActivation = null; + updates.signupPhoneCompletedActivation = null; + updates.signupPhoneVerificationRequestedAt = null; + updates.signupPhoneVerificationPurpose = ''; + } else if (String(currentState?.accountIdentifierType || '').trim().toLowerCase() === 'email') { + updates.accountIdentifierType = null; + updates.accountIdentifier = ''; + } + + await setState(updates); + broadcastDataUpdate(updates); } async function setEmailState(email) { @@ -2640,9 +2689,21 @@ async function setSignupPhoneStateSilently(phoneNumber) { if (normalizedPhoneNumber) { updates.accountIdentifierType = 'phone'; updates.accountIdentifier = normalizedPhoneNumber; + if (!isPhoneActivationForNumber(currentState?.signupPhoneActivation, normalizedPhoneNumber)) { + updates.signupPhoneActivation = null; + updates.signupPhoneVerificationRequestedAt = null; + updates.signupPhoneVerificationPurpose = ''; + } + if (!isPhoneActivationForNumber(currentState?.signupPhoneCompletedActivation, normalizedPhoneNumber)) { + updates.signupPhoneCompletedActivation = null; + } } else if (String(currentState?.accountIdentifierType || '').trim().toLowerCase() === 'phone') { updates.accountIdentifierType = null; updates.accountIdentifier = ''; + updates.signupPhoneActivation = null; + updates.signupPhoneCompletedActivation = null; + updates.signupPhoneVerificationRequestedAt = null; + updates.signupPhoneVerificationPurpose = ''; } await setState(updates); @@ -10051,7 +10112,7 @@ async function resumeAutoRun() { // ============================================================ const SIGNUP_ENTRY_URL = 'https://chatgpt.com/'; -const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/phone-auth.js', 'content/signup-page.js']; +const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/phone-country-utils.js', 'content/phone-auth.js', 'content/signup-page.js']; const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({ chrome, addLog, diff --git a/background/message-router.js b/background/message-router.js index 5a32d02..1777f3f 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -179,6 +179,59 @@ : ''; } + function resolveEmailIdentityPayload(payload = {}) { + const directEmail = String(payload?.email || '').trim(); + if (directEmail) { + return directEmail; + } + return String(payload?.accountIdentifierType || '').trim().toLowerCase() === 'email' + ? String(payload?.accountIdentifier || '').trim() + : ''; + } + + async function syncStepAccountIdentityFromPayload(payload = {}) { + const identifierType = String(payload?.accountIdentifierType || '').trim().toLowerCase(); + const signupPhoneNumber = resolveSignupPhonePayload(payload); + if (identifierType === 'phone' || signupPhoneNumber) { + if (signupPhoneNumber) { + await setSignupPhoneStateSilently(signupPhoneNumber); + } + const updates = {}; + if (Object.prototype.hasOwnProperty.call(payload, 'signupPhoneActivation')) { + updates.signupPhoneActivation = payload.signupPhoneActivation || null; + } + if (Object.prototype.hasOwnProperty.call(payload, 'signupPhoneCompletedActivation')) { + updates.signupPhoneCompletedActivation = payload.signupPhoneCompletedActivation || null; + } + if (Object.keys(updates).length) { + await setState(updates); + broadcastDataUpdate(updates); + } + return; + } + + const email = resolveEmailIdentityPayload(payload); + if (identifierType === 'email' || email) { + if (email) { + await setEmailState(email); + } + const updates = { + signupPhoneNumber: '', + signupPhoneActivation: null, + signupPhoneCompletedActivation: null, + signupPhoneVerificationRequestedAt: null, + signupPhoneVerificationPurpose: '', + ...(email ? { + accountIdentifierType: 'email', + accountIdentifier: email, + } : {}), + }; + await setSignupPhoneStateSilently(null); + await setState(updates); + broadcastDataUpdate(updates); + } + } + function isStepProtectedFromAutoSkip(status) { return status === 'running' || status === 'completed' @@ -367,15 +420,7 @@ break; } case 2: - if (payload.email) { - await setEmailState(payload.email); - } - { - const signupPhoneNumber = resolveSignupPhonePayload(payload); - if (signupPhoneNumber) { - await setSignupPhoneStateSilently(signupPhoneNumber); - } - } + await syncStepAccountIdentityFromPayload(payload); if (payload.skipRegistrationFlow) { const latestState = await getState(); for (const skipStep of [3, 4, 5]) { @@ -399,13 +444,7 @@ } break; case 3: - if (payload.email) await setEmailState(payload.email); - { - const signupPhoneNumber = resolveSignupPhonePayload(payload); - if (signupPhoneNumber) { - await setSignupPhoneStateSilently(signupPhoneNumber); - } - } + await syncStepAccountIdentityFromPayload(payload); if (payload.signupVerificationRequestedAt) { await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt }); } diff --git a/background/steps/submit-signup-email.js b/background/steps/submit-signup-email.js index d0c2524..5a917f8 100644 --- a/background/steps/submit-signup-email.js +++ b/background/steps/submit-signup-email.js @@ -221,11 +221,63 @@ return signupTabId; } - async function executeSignupPhoneEntry(state) { + function normalizeSignupPhoneActivationForStep2(activation) { + if (typeof phoneVerificationHelpers?.normalizeActivation === 'function') { + return phoneVerificationHelpers.normalizeActivation(activation); + } + if (!activation || typeof activation !== 'object' || Array.isArray(activation)) { + return null; + } + const activationId = String(activation.activationId ?? activation.id ?? activation.activation ?? '').trim(); + const phoneNumber = String(activation.phoneNumber ?? activation.number ?? activation.phone ?? '').trim(); + if (!activationId || !phoneNumber) { + return null; + } + return { + ...activation, + activationId, + phoneNumber, + }; + } + + function getSignupPhoneNumberFromState(state = {}) { + return String( + state?.signupPhoneNumber + || (String(state?.accountIdentifierType || '').trim().toLowerCase() === 'phone' ? state?.accountIdentifier : '') + || '' + ).trim(); + } + + async function resolveSignupPhoneForStep2(state = {}) { + const existingActivation = normalizeSignupPhoneActivationForStep2(state?.signupPhoneActivation); + if (existingActivation?.phoneNumber) { + await addLog(`步骤 2:复用当前注册手机号 ${existingActivation.phoneNumber},不重新获取号码。`); + return { + phoneNumber: existingActivation.phoneNumber, + activation: existingActivation, + }; + } + + const manualPhoneNumber = getSignupPhoneNumberFromState(state); + if (manualPhoneNumber) { + await addLog(`步骤 2:使用手动填写的注册手机号 ${manualPhoneNumber},本轮不会重新获取号码。`, 'warn'); + return { + phoneNumber: manualPhoneNumber, + activation: null, + }; + } + if (typeof phoneVerificationHelpers?.prepareSignupPhoneActivation !== 'function') { throw new Error('手机号注册流程不可用:接码模块尚未初始化。'); } + const activation = await phoneVerificationHelpers.prepareSignupPhoneActivation(state); + return { + phoneNumber: activation.phoneNumber, + activation, + }; + } + async function executeSignupPhoneEntry(state) { let signupTabId = await ensureSignupTabForStep2(); if (await shouldForceAuthEntryRetry(signupTabId)) { await addLog('步骤 2:检测到当前位于已登录 ChatGPT 首页,先切换认证入口页再提交手机号。', 'warn'); @@ -261,8 +313,9 @@ } } - const activation = await phoneVerificationHelpers.prepareSignupPhoneActivation(state); - let step2Result = await submitSignupPhone(activation.phoneNumber, activation, { + const signupPhone = await resolveSignupPhoneForStep2(state); + const { phoneNumber, activation } = signupPhone; + let step2Result = await submitSignupPhone(phoneNumber, activation, { timeoutMs: 45000, retryDelayMs: 700, logMessage: '步骤 2:官网注册入口正在切换,等待手机号注册入口恢复...', @@ -278,7 +331,7 @@ await addLog('步骤 2:手机号注册入口不可用或通信超时,正在重新准备手机号注册入口后重试一次...', 'warn'); signupTabId = (await ensureSignupEntryPageReady(2)).tabId; await ensureSignupPhoneEntryReady(signupTabId); - step2Result = await submitSignupPhone(activation.phoneNumber, activation, { + step2Result = await submitSignupPhone(phoneNumber, activation, { timeoutMs: 45000, retryDelayMs: 700, logMessage: '步骤 2:手机号注册入口已就绪,正在重新提交手机号...', @@ -295,22 +348,22 @@ ) { return; } - if (typeof phoneVerificationHelpers?.cancelSignupPhoneActivation === 'function') { + if (activation && typeof phoneVerificationHelpers?.cancelSignupPhoneActivation === 'function') { await phoneVerificationHelpers.cancelSignupPhoneActivation(state, activation).catch(() => {}); } throw new Error(finalErrorMessage); } - await addLog(`步骤 2:手机号 ${activation.phoneNumber} 已提交,正在等待页面加载并确认下一步入口...`); + await addLog(`步骤 2:手机号 ${phoneNumber} 已提交,正在等待页面加载并确认下一步入口...`); const landingResult = await ensureSignupPostIdentityPageReadyInTab(signupTabId, 2, { skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage), }); await completeStepFromBackground(2, { accountIdentifierType: 'phone', - accountIdentifier: activation.phoneNumber, - signupPhoneNumber: activation.phoneNumber, - signupPhoneActivation: activation, + accountIdentifier: phoneNumber, + signupPhoneNumber: phoneNumber, + signupPhoneActivation: activation || null, nextSignupState: landingResult?.state || step2Result?.state || 'password_page', nextSignupUrl: landingResult?.url || step2Result?.url || '', skippedPasswordStep: landingResult?.state === 'phone_verification_page' || landingResult?.state === 'profile_page', diff --git a/content/phone-auth.js b/content/phone-auth.js index e9488da..84804aa 100644 --- a/content/phone-auth.js +++ b/content/phone-auth.js @@ -26,6 +26,8 @@ const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i; const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i; const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3; + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const phoneCountryUtils = rootScope?.MultiPagePhoneCountryUtils || globalThis?.MultiPagePhoneCountryUtils || {}; let lastPhoneRoute405RecoveryFailedAt = 0; let activePhoneResendPromise = null; @@ -36,6 +38,9 @@ } function normalizePhoneDigits(value) { + if (typeof phoneCountryUtils.normalizePhoneDigits === 'function') { + return phoneCountryUtils.normalizePhoneDigits(value); + } let digits = String(value || '').replace(/\D+/g, ''); if (digits.startsWith('00')) { digits = digits.slice(2); @@ -48,27 +53,39 @@ } function normalizeCountryLabel(value) { + if (typeof phoneCountryUtils.normalizeCountryLabel === 'function') { + return phoneCountryUtils.normalizeCountryLabel(value); + } return String(value || '') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/&/g, ' and ') - .replace(/[^\w\s]/g, ' ') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function getOptionLabel(option) { + if (typeof phoneCountryUtils.getOptionLabel === 'function') { + return phoneCountryUtils.getOptionLabel(option); + } return String(option?.textContent || option?.label || '') .replace(/\s+/g, ' ') .trim(); } function normalizeCountryOptionValue(value) { + if (typeof phoneCountryUtils.normalizeCountryOptionValue === 'function') { + return phoneCountryUtils.normalizeCountryOptionValue(value); + } return String(value || '').trim().toUpperCase(); } function getRegionDisplayName(regionCode, locale) { + if (typeof phoneCountryUtils.getRegionDisplayName === 'function') { + return phoneCountryUtils.getRegionDisplayName(regionCode, locale); + } const normalizedRegionCode = normalizeCountryOptionValue(regionCode); const normalizedLocale = String(locale || '').trim(); if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') { @@ -84,6 +101,14 @@ } function getCountryOptionMatchLabels(option) { + if (typeof phoneCountryUtils.getOptionMatchLabels === 'function') { + return phoneCountryUtils.getOptionMatchLabels(option, { + document: typeof document !== 'undefined' ? document : null, + navigator: rootScope?.navigator || globalThis?.navigator || null, + getOptionLabel, + }); + } + const labels = new Set(); const pushLabel = (value) => { const label = String(value || '').replace(/\s+/g, ' ').trim(); @@ -128,8 +153,11 @@ } function extractDialCodeFromText(value) { - const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/); - return String(match?.[1] || match?.[2] || '').trim(); + if (typeof phoneCountryUtils.extractDialCodeFromText === 'function') { + return phoneCountryUtils.extractDialCodeFromText(value); + } + const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*\(\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/); + return String(match?.[1] || match?.[2] || match?.[3] || '').trim(); } function getCountryButtonText() { @@ -240,6 +268,13 @@ if (!select) { return null; } + if (typeof phoneCountryUtils.findOptionByCountryLabel === 'function') { + return phoneCountryUtils.findOptionByCountryLabel(select.options, countryLabel, { + document: typeof document !== 'undefined' ? document : null, + navigator: rootScope?.navigator || globalThis?.navigator || null, + getOptionLabel, + }); + } const normalizedTarget = normalizeCountryLabel(countryLabel); if (!normalizedTarget) { return null; @@ -267,6 +302,9 @@ if (!select) { return null; } + if (typeof phoneCountryUtils.findOptionByPhoneNumber === 'function') { + return phoneCountryUtils.findOptionByPhoneNumber(select.options, phoneNumber, { getOptionLabel }); + } const digits = normalizePhoneDigits(phoneNumber); if (!digits) { return null; diff --git a/content/phone-country-utils.js b/content/phone-country-utils.js new file mode 100644 index 0000000..7f21c43 --- /dev/null +++ b/content/phone-country-utils.js @@ -0,0 +1,237 @@ +(function attachPhoneCountryUtils(root, factory) { + root.MultiPagePhoneCountryUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPhoneCountryUtils() { + const KNOWN_DIAL_CODES = Object.freeze([ + '1246', '1264', '1268', '1284', '1340', '1345', '1441', '1473', '1649', '1664', '1670', '1671', '1684', + '1721', '1758', '1767', '1784', '1809', '1829', '1849', '1868', '1869', '1876', + '971', '962', '886', '880', '856', '855', '852', '853', '673', '672', '670', '599', '598', '597', '596', + '595', '594', '593', '592', '591', '590', '509', '508', '507', '506', '505', '504', '503', '502', '501', + '423', '421', '420', '389', '387', '386', '385', '383', '382', '381', '380', '379', '378', '377', '376', + '375', '374', '373', '372', '371', '370', '359', '358', '357', '356', '355', '354', '353', '352', '351', + '350', '299', '298', '297', '291', '290', '269', '268', '267', '266', '265', '264', '263', '262', '261', + '260', '258', '257', '256', '255', '254', '253', '252', '251', '250', '249', '248', '247', '246', '245', + '244', '243', '242', '241', '240', '239', '238', '237', '236', '235', '234', '233', '232', '231', '230', + '229', '228', '227', '226', '225', '224', '223', '222', '221', '220', '218', '216', '213', '212', '211', + '98', '95', '94', '93', '92', '91', '90', '89', '88', '86', '84', '82', '81', '66', '65', '64', '63', + '62', '61', '60', '58', '57', '56', '55', '54', '53', '52', '51', '49', '48', '47', '46', '45', '44', + '43', '41', '40', '39', '36', '34', '33', '32', '31', '30', '27', '20', '7', '1', + ]); + + function normalizePhoneDigits(value) { + let digits = String(value || '').replace(/\D+/g, ''); + if (digits.startsWith('00')) { + digits = digits.slice(2); + } + return digits; + } + + function extractDialCodeFromText(value) { + const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*\(\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/); + return String(match?.[1] || match?.[2] || match?.[3] || '').trim(); + } + + function normalizeCountryLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&/g, ' and ') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + function normalizeCountryOptionValue(value) { + return String(value || '').trim().toUpperCase(); + } + + function getCountryLabelAliases(value) { + const aliases = new Set(); + const addAlias = (alias) => { + const normalized = normalizeCountryLabel(alias); + if (normalized) { + aliases.add(normalized); + } + }; + + const raw = String(value || '').trim(); + addAlias(raw); + + const normalized = normalizeCountryLabel(raw); + const compact = normalized.replace(/\s+/g, ''); + if ( + /(?:^|\s)(?:gb|uk)(?:\s|$)/i.test(raw) + || /england|united\s*kingdom|great\s*britain|\bbritain\b/i.test(raw) + || /英国|英格兰|大不列颠/.test(raw) + || ['gb', 'uk', 'england', 'unitedkingdom', 'greatbritain', 'britain'].includes(compact) + ) { + [ + 'GB', + 'UK', + 'United Kingdom', + 'Great Britain', + 'Britain', + 'England', + '英国', + '英格兰', + '大不列颠', + ].forEach(addAlias); + } + + return Array.from(aliases); + } + + function getRegionDisplayName(regionCode, locale) { + const normalizedRegionCode = normalizeCountryOptionValue(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 getOptionLabel(option) { + return String(option?.textContent || option?.label || '') + .replace(/\s+/g, ' ') + .trim(); + } + + function getOptionMatchLabels(option, options = {}) { + const labels = new Set(); + const pushLabel = (value) => { + const label = String(value || '').replace(/\s+/g, ' ').trim(); + if (label) { + labels.add(label); + } + }; + + const getLabel = typeof options.getOptionLabel === 'function' + ? options.getOptionLabel + : getOptionLabel; + pushLabel(getLabel(option)); + + const regionCode = normalizeCountryOptionValue(option?.value); + if (/^[A-Z]{2}$/.test(regionCode)) { + pushLabel(regionCode); + pushLabel(getRegionDisplayName(regionCode, 'en')); + + const pageLocale = String( + options.pageLocale + || options.document?.documentElement?.lang + || options.document?.documentElement?.getAttribute?.('lang') + || options.navigator?.language + || '' + ).trim(); + if (pageLocale && !/^en(?:[-_]|$)/i.test(pageLocale)) { + pushLabel(getRegionDisplayName(regionCode, pageLocale)); + } + } + + return Array.from(labels); + } + + function resolveDialCodeFromPhoneNumber(phoneNumber = '', texts = []) { + const digits = normalizePhoneDigits(phoneNumber); + if (!digits) { + return ''; + } + + const textDialCodes = texts + .map((text) => normalizePhoneDigits(extractDialCodeFromText(text))) + .filter((dialCode) => dialCode && digits.startsWith(dialCode) && digits.length > dialCode.length) + .sort((left, right) => right.length - left.length); + if (textDialCodes[0]) { + return textDialCodes[0]; + } + + return KNOWN_DIAL_CODES.find((code) => digits.startsWith(code) && digits.length > code.length) || ''; + } + + function findOptionByCountryLabel(options, countryLabel, config = {}) { + const source = Array.from(options || []); + const normalizedTargets = getCountryLabelAliases(countryLabel); + if (source.length === 0 || normalizedTargets.length === 0) { + return null; + } + + return source.find((option) => ( + getOptionMatchLabels(option, config).some((label) => normalizedTargets.includes(normalizeCountryLabel(label))) + )) + || source.find((option) => { + const normalizedLabels = getOptionMatchLabels(option, config) + .map((label) => normalizeCountryLabel(label)) + .filter(Boolean); + return normalizedLabels.some((optionLabel) => normalizedTargets.some((normalizedTarget) => ( + optionLabel.length > 2 + && normalizedTarget.length > 2 + && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)) + ))); + }) + || null; + } + + function findOptionByPhoneNumber(options, phoneNumber, config = {}) { + const source = Array.from(options || []); + const digits = normalizePhoneDigits(phoneNumber); + if (source.length === 0 || !digits) { + return null; + } + + const getLabel = typeof config.getOptionLabel === 'function' + ? config.getOptionLabel + : getOptionLabel; + let bestMatch = null; + let bestDialCodeLength = 0; + for (const option of source) { + const dialCode = normalizePhoneDigits(extractDialCodeFromText(getLabel(option))); + if (!dialCode || !digits.startsWith(dialCode) || dialCode.length <= bestDialCodeLength) { + continue; + } + bestMatch = option; + bestDialCodeLength = dialCode.length; + } + return bestMatch; + } + + function findElementByDialCode(elements, phoneNumber, config = {}) { + const source = Array.from(elements || []); + const digits = normalizePhoneDigits(phoneNumber); + if (source.length === 0 || !digits) { + return null; + } + + const getText = typeof config.getText === 'function' ? config.getText : getOptionLabel; + let bestMatch = null; + let bestDialCodeLength = 0; + for (const element of source) { + const dialCode = normalizePhoneDigits(extractDialCodeFromText(getText(element))); + if (!dialCode || !digits.startsWith(dialCode) || dialCode.length <= bestDialCodeLength) { + continue; + } + bestMatch = element; + bestDialCodeLength = dialCode.length; + } + return bestMatch; + } + + return { + extractDialCodeFromText, + findElementByDialCode, + findOptionByCountryLabel, + findOptionByPhoneNumber, + getCountryLabelAliases, + getOptionLabel, + getOptionMatchLabels, + getRegionDisplayName, + normalizeCountryLabel, + normalizeCountryOptionValue, + normalizePhoneDigits, + resolveDialCodeFromPhoneNumber, + }; +}); diff --git a/content/signup-page.js b/content/signup-page.js index 862e01e..482fc89 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1139,6 +1139,12 @@ async function fillSignupEmailAndContinue(email, step) { } function normalizePhoneDigits(value) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.normalizePhoneDigits === 'function') { + return phoneCountryUtils.normalizePhoneDigits(value); + } let digits = String(value || '').replace(/\D+/g, ''); if (digits.startsWith('00')) { digits = digits.slice(2); @@ -1147,6 +1153,12 @@ function normalizePhoneDigits(value) { } function extractDialCodeFromText(value) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.extractDialCodeFromText === 'function') { + return phoneCountryUtils.extractDialCodeFromText(value); + } const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*\(\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/); return String(match?.[1] || match?.[2] || match?.[3] || '').trim(); } @@ -1158,6 +1170,12 @@ function dispatchSignupPhoneFieldEvents(element) { } function normalizeSignupCountryLabel(value) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.normalizeCountryLabel === 'function') { + return phoneCountryUtils.normalizeCountryLabel(value); + } return String(value || '') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') @@ -1169,6 +1187,12 @@ function normalizeSignupCountryLabel(value) { } function getSignupCountryLabelAliases(value) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.getCountryLabelAliases === 'function') { + return phoneCountryUtils.getCountryLabelAliases(value); + } const aliases = new Set(); const addAlias = (alias) => { const normalized = normalizeSignupCountryLabel(alias); @@ -1205,16 +1229,34 @@ function getSignupCountryLabelAliases(value) { } function getSignupPhoneOptionLabel(option) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.getOptionLabel === 'function') { + return phoneCountryUtils.getOptionLabel(option); + } return String(option?.textContent || option?.label || '') .replace(/\s+/g, ' ') .trim(); } function normalizeSignupCountryOptionValue(value) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.normalizeCountryOptionValue === 'function') { + return phoneCountryUtils.normalizeCountryOptionValue(value); + } return String(value || '').trim().toUpperCase(); } function getSignupRegionDisplayName(regionCode, locale) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.getRegionDisplayName === 'function') { + return phoneCountryUtils.getRegionDisplayName(regionCode, locale); + } const normalizedRegionCode = normalizeSignupCountryOptionValue(regionCode); const normalizedLocale = String(locale || '').trim(); if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') { @@ -1230,6 +1272,18 @@ function getSignupRegionDisplayName(regionCode, locale) { } function getSignupPhoneCountryMatchLabels(option) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.getOptionMatchLabels === 'function') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + return phoneCountryUtils.getOptionMatchLabels(option, { + document: typeof document !== 'undefined' ? document : null, + navigator: rootScope?.navigator || globalThis?.navigator || null, + getOptionLabel: getSignupPhoneOptionLabel, + }); + } + const labels = new Set(); const pushLabel = (value) => { const label = String(value || '').replace(/\s+/g, ' ').trim(); @@ -1408,6 +1462,12 @@ function getSignupPhoneHiddenNumberInput(phoneInput = getSignupPhoneInput()) { } function resolveSignupPhoneDialCodeFromNumber(phoneNumber = '', texts = []) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.resolveDialCodeFromPhoneNumber === 'function') { + return phoneCountryUtils.resolveDialCodeFromPhoneNumber(phoneNumber, texts); + } const digits = normalizePhoneDigits(phoneNumber); if (!digits) { return ''; @@ -1517,6 +1577,16 @@ function findSignupPhoneCountryOptionByLabel(phoneInput, countryLabel) { if (!select) { return null; } + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.findOptionByCountryLabel === 'function') { + return phoneCountryUtils.findOptionByCountryLabel(select.options, countryLabel, { + document: typeof document !== 'undefined' ? document : null, + navigator: (typeof self !== 'undefined' ? self : globalThis)?.navigator || globalThis?.navigator || null, + getOptionLabel: getSignupPhoneOptionLabel, + }); + } const normalizedTargets = getSignupCountryLabelAliases(countryLabel); if (normalizedTargets.length === 0) { return null; @@ -1544,6 +1614,14 @@ function findSignupPhoneCountryOptionByPhoneNumber(phoneInput, phoneNumber) { if (!select) { return null; } + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.findOptionByPhoneNumber === 'function') { + return phoneCountryUtils.findOptionByPhoneNumber(select.options, phoneNumber, { + getOptionLabel: getSignupPhoneOptionLabel, + }); + } const digits = normalizePhoneDigits(phoneNumber); if (!digits) { return null; @@ -1601,6 +1679,18 @@ function findSignupPhoneCountryListboxOption(targetOption, options = {}) { return byLabel; } + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.findElementByDialCode === 'function') { + const byPhoneNumber = phoneCountryUtils.findElementByDialCode(candidates, options.phoneNumber, { + getText: getActionText, + }); + if (byPhoneNumber) { + return byPhoneNumber; + } + } + const targetDialCode = resolveSignupPhoneTargetDialCode(options, targetOption); if (!targetDialCode) { const digits = normalizePhoneDigits(options.phoneNumber); @@ -2819,11 +2909,17 @@ function getLoginSubmitButton({ allowDisabled = false } = {}) { } function normalizeCountryLabel(value) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.normalizeCountryLabel === 'function') { + return phoneCountryUtils.normalizeCountryLabel(value); + } return String(value || '') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/&/g, ' and ') - .replace(/[^\w\s]/g, ' ') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); @@ -2836,12 +2932,30 @@ function getLoginPhoneCountrySelect(phoneInput) { } function getLoginPhoneCountryOptionLabel(option) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.getOptionLabel === 'function') { + return phoneCountryUtils.getOptionLabel(option); + } return String(option?.textContent || option?.label || '') .replace(/\s+/g, ' ') .trim(); } function getLoginPhoneCountryOptionMatchLabels(option) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.getOptionMatchLabels === 'function') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + return phoneCountryUtils.getOptionMatchLabels(option, { + document: typeof document !== 'undefined' ? document : null, + navigator: rootScope?.navigator || globalThis?.navigator || null, + getOptionLabel: getLoginPhoneCountryOptionLabel, + }); + } + const labels = new Set(); const pushLabel = (value) => { const label = String(value || '').replace(/\s+/g, ' ').trim(); @@ -2856,6 +2970,17 @@ function getLoginPhoneCountryOptionMatchLabels(option) { } function findLoginPhoneCountryOptionByLabel(select, countryLabel) { + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (select && typeof phoneCountryUtils.findOptionByCountryLabel === 'function') { + return phoneCountryUtils.findOptionByCountryLabel(select.options, countryLabel, { + document: typeof document !== 'undefined' ? document : null, + navigator: (typeof self !== 'undefined' ? self : globalThis)?.navigator || globalThis?.navigator || null, + getOptionLabel: getLoginPhoneCountryOptionLabel, + }); + } + const normalizedTarget = normalizeCountryLabel(countryLabel); if (!select || !normalizedTarget) { return null; @@ -2881,6 +3006,14 @@ function findLoginPhoneCountryOptionByNumber(select, phoneNumber) { if (!select) { return null; } + const phoneCountryUtils = (typeof self !== 'undefined' ? self : globalThis)?.MultiPagePhoneCountryUtils + || globalThis?.MultiPagePhoneCountryUtils + || {}; + if (typeof phoneCountryUtils.findOptionByPhoneNumber === 'function') { + return phoneCountryUtils.findOptionByPhoneNumber(select.options, phoneNumber, { + getOptionLabel: getLoginPhoneCountryOptionLabel, + }); + } const digits = normalizePhoneDigits(phoneNumber); if (!digits) { return null; diff --git a/manifest.json b/manifest.json index 575447c..da871fc 100644 --- a/manifest.json +++ b/manifest.json @@ -51,6 +51,7 @@ "content/activation-utils.js", "content/utils.js", "content/auth-page-recovery.js", + "content/phone-country-utils.js", "content/phone-auth.js", "content/signup-page.js" ], diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index 24a40c4..9cb5bfd 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/tests/background-message-router-step2-skip.test.js @@ -181,6 +181,34 @@ test('message router syncs signup phone runtime state from step 2 payload immedi assert.deepStrictEqual(events.signupPhoneStates, []); }); +test('message router clears stale signup phone runtime when step 2 resolves email identity', async () => { + const { router, events } = createRouter({ + state: { + stepStatuses: { 3: 'pending' }, + accountIdentifierType: 'phone', + accountIdentifier: '+66959916439', + signupPhoneNumber: '+66959916439', + signupPhoneActivation: { activationId: 'old', phoneNumber: '+66959916439' }, + }, + }); + + await router.handleStepData(2, { + email: 'user@example.com', + accountIdentifierType: 'email', + accountIdentifier: 'user@example.com', + }); + + assert.deepStrictEqual(events.emailStates, ['user@example.com']); + assert.deepStrictEqual(events.signupPhoneSilentStates, [null]); + assert.ok(events.stateUpdates.some((updates) => ( + updates.accountIdentifierType === 'email' + && updates.accountIdentifier === 'user@example.com' + && updates.signupPhoneNumber === '' + && updates.signupPhoneActivation === null + && updates.signupPhoneCompletedActivation === null + ))); +}); + test('message router does not overwrite a completed step 3 when step 2 is replayed', async () => { const { router, events } = createRouter({ state: { stepStatuses: { 3: 'completed' } }, diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js index fc1bccf..f289b72 100644 --- a/tests/background-signup-step2-branching.test.js +++ b/tests/background-signup-step2-branching.test.js @@ -180,6 +180,146 @@ test('step 2 uses phone activation when resolved signup method is phone', async ]); }); +test('step 2 reuses existing signup phone activation without acquiring a new number', async () => { + const completedPayloads = []; + const sequence = []; + const sentPayloads = []; + const activation = { + activationId: 'existing-signup-activation', + phoneNumber: '+446700000001', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + countryLabel: 'United Kingdom', + }; + + const executor = step2Api.createStep2Executor({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + completeStepFromBackground: async (step, payload) => { + completedPayloads.push({ step, payload }); + }, + ensureContentScriptReadyOnTab: async () => {}, + ensureSignupEntryPageReady: async () => ({ tabId: 15 }), + ensureSignupPostIdentityPageReadyInTab: async () => ({ + state: 'phone_verification_page', + url: 'https://auth.openai.com/phone-verification', + }), + getTabId: async () => 15, + isTabAlive: async () => true, + phoneVerificationHelpers: { + normalizeActivation: (record) => record, + prepareSignupPhoneActivation: async () => { + throw new Error('prepareSignupPhoneActivation should not run when signup activation already exists'); + }, + cancelSignupPhoneActivation: async () => { + throw new Error('activation should not be cancelled on success'); + }, + }, + resolveSignupMethod: () => 'phone', + resolveSignupEmailForFlow: async () => { + throw new Error('email resolver should not run for phone signup'); + }, + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') { + sequence.push('ensureSignupPhoneEntryReady'); + return { ready: true, state: 'phone_entry' }; + } + sequence.push('submitSignupPhone'); + sentPayloads.push(message.payload); + return { submitted: true }; + }, + SIGNUP_PAGE_INJECT_FILES: [], + }); + + await executor.executeStep2({ + signupMethod: 'phone', + signupPhoneActivation: activation, + }); + + assert.deepStrictEqual(sequence, [ + 'ensureSignupPhoneEntryReady', + 'submitSignupPhone', + ]); + assert.deepStrictEqual(sentPayloads, [ + { + signupMethod: 'phone', + phoneNumber: '+446700000001', + countryId: 16, + countryLabel: 'United Kingdom', + }, + ]); + assert.equal(completedPayloads[0].payload.signupPhoneActivation, activation); +}); + +test('step 2 submits manual signup phone without acquiring a number', async () => { + const completedPayloads = []; + const sentPayloads = []; + + const executor = step2Api.createStep2Executor({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + completeStepFromBackground: async (step, payload) => { + completedPayloads.push({ step, payload }); + }, + ensureContentScriptReadyOnTab: async () => {}, + ensureSignupEntryPageReady: async () => ({ tabId: 16 }), + ensureSignupPostIdentityPageReadyInTab: async () => ({ + state: 'phone_verification_page', + url: 'https://auth.openai.com/phone-verification', + }), + getTabId: async () => 16, + isTabAlive: async () => true, + phoneVerificationHelpers: { + prepareSignupPhoneActivation: async () => { + throw new Error('prepareSignupPhoneActivation should not run for manual signup phone'); + }, + }, + resolveSignupMethod: () => 'phone', + resolveSignupEmailForFlow: async () => { + throw new Error('email resolver should not run for phone signup'); + }, + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') { + return { ready: true, state: 'phone_entry' }; + } + sentPayloads.push(message.payload); + return { submitted: true }; + }, + SIGNUP_PAGE_INJECT_FILES: [], + }); + + await executor.executeStep2({ + signupMethod: 'phone', + signupPhoneNumber: '+446700000002', + accountIdentifierType: 'phone', + accountIdentifier: '+446700000002', + }); + + assert.deepStrictEqual(sentPayloads, [ + { + signupMethod: 'phone', + phoneNumber: '+446700000002', + countryId: null, + countryLabel: '', + }, + ]); + assert.deepStrictEqual(completedPayloads, [ + { + step: 2, + payload: { + accountIdentifierType: 'phone', + accountIdentifier: '+446700000002', + signupPhoneNumber: '+446700000002', + signupPhoneActivation: null, + nextSignupState: 'phone_verification_page', + nextSignupUrl: 'https://auth.openai.com/phone-verification', + skippedPasswordStep: true, + }, + }, + ]); +}); + test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on chatgpt home', async () => { const completedPayloads = []; const logs = []; diff --git a/tests/phone-country-utils.test.js b/tests/phone-country-utils.test.js new file mode 100644 index 0000000..c158d3f --- /dev/null +++ b/tests/phone-country-utils.test.js @@ -0,0 +1,63 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadPhoneCountryUtils() { + const source = fs.readFileSync('content/phone-country-utils.js', 'utf8'); + const root = {}; + return new Function('self', `${source}; return self.MultiPagePhoneCountryUtils;`)(root); +} + +test('phone country utils extracts dial codes from current OpenAI country labels', () => { + const utils = loadPhoneCountryUtils(); + + assert.equal(utils.extractDialCodeFromText('United Kingdom (+44)'), '44'); + assert.equal(utils.extractDialCodeFromText('United Kingdom +(44)'), '44'); + assert.equal(utils.extractDialCodeFromText('United Kingdom +44'), '44'); +}); + +test('phone country utils resolves country dial code from provider phone numbers', () => { + const utils = loadPhoneCountryUtils(); + + assert.equal(utils.resolveDialCodeFromPhoneNumber('447423278610'), '44'); + assert.equal(utils.resolveDialCodeFromPhoneNumber('+8613800138000'), '86'); + assert.equal(utils.resolveDialCodeFromPhoneNumber('12461234567'), '1246'); +}); + +test('phone country utils finds country options by phone dial code and country aliases', () => { + const utils = loadPhoneCountryUtils(); + const options = [ + { value: 'ID', textContent: 'Indonesia +(62)' }, + { value: 'GB', textContent: 'United Kingdom +(44)' }, + ]; + + assert.equal(utils.findOptionByPhoneNumber(options, '447423278610'), options[1]); + assert.equal(utils.findOptionByCountryLabel(options, 'England'), options[1]); +}); + +test('phone country utils is loaded before phone auth content scripts', () => { + const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); + const authScript = manifest.content_scripts.find((entry) => ( + Array.isArray(entry.matches) + && entry.matches.some((match) => match.includes('auth.openai.com')) + )); + + assert.ok(authScript, 'missing auth content script'); + assert.ok(authScript.js.includes('content/phone-country-utils.js')); + assert.ok( + authScript.js.indexOf('content/phone-country-utils.js') < authScript.js.indexOf('content/phone-auth.js'), + 'phone-country-utils.js must load before phone-auth.js' + ); + assert.ok( + authScript.js.indexOf('content/phone-country-utils.js') < authScript.js.indexOf('content/signup-page.js'), + 'phone-country-utils.js must load before signup-page.js' + ); + + const background = fs.readFileSync('background.js', 'utf8'); + const injectLine = background.match(/const\s+SIGNUP_PAGE_INJECT_FILES\s*=\s*\[[^\n]+\]/)?.[0] || ''; + assert.ok(injectLine.includes("'content/phone-country-utils.js'")); + assert.ok( + injectLine.indexOf("'content/phone-country-utils.js'") < injectLine.indexOf("'content/phone-auth.js'"), + 'dynamic signup injection must load phone-country-utils.js before phone-auth.js' + ); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 604b24b..ae4424b 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -44,7 +44,7 @@ - 在日志区通过“记录”按钮打开独立的账号记录覆盖层,并展示成功/失败/停止/重试统计与分页列表;phone-only 记录会回退展示手机号 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro` 或 `v` 版本误显示为比 `Ultra` 更新 - 展示一个单独的“接码”开关、注册方式 `signupMethod` 与“接码平台”下拉;接码平台当前支持 HeroSMS / 5sim / NexSMS。普通模式下开启接码后可把注册方式切到手机号注册,并在 OAuth 登录链路命中手机号登录验证码页时继续复用同一手机号续跑短信验证 -- 侧栏额外提供一个运行态“注册手机号”输入框:第 2 步自动拿到号码后立即回填;用户手动接管手机号注册时也可以直接改写这一个运行态槽位,后续第 4 步和命中手机号验证码的登录链路统一从这份运行态手机号继续同步身份与展示 +- 侧栏在接码卡内提供一个独立运行态“注册手机号”输入框,位于接码订单运行状态下方;第 2 步自动拿到号码后立即回填,用户手动接管手机号注册时也可以直接改写这一个运行态槽位。这个输入框表达账号身份,不等同于接码订单;后续自动拉短信仍依赖 `signupPhoneActivation / signupPhoneCompletedActivation` - 展示 `Plus 模式` 开关与 Plus 支付方式配置;支付方式支持 PayPal / GoPay,PayPal 展示账号池下拉与添加按钮,GoPay 展示手机号和 PIN;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步 - 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作 @@ -151,11 +151,13 @@ - 当前邮箱 / 密码 - 当前手机号注册运行态 `signupPhoneNumber / signupPhoneActivation / signupPhoneCompletedActivation` - 侧栏“注册手机号”输入框只同步运行态 `signupPhoneNumber / accountIdentifierType / accountIdentifier`,不属于持久配置,也不参与配置导入导出 +- 邮箱身份写入时会把 `accountIdentifierType / accountIdentifier` 改回邮箱,并清理旧的 `signupPhone*` 注册手机号运行态,避免后续 Step 4 / Step 8 被旧手机号污染而误走短信分支 - 本轮联动自检重点: 1. Step 2 一旦自动取到手机号,必须立刻广播并回填侧栏运行态手机号。 2. sidepanel 重新加载后,必须从当前 state 恢复“注册手机号”文本框,而不是只恢复邮箱。 3. 用户手动修改“注册手机号”时,只能改运行态身份槽位,不能误写入持久配置或配置导入导出。 - 4. 第 4 步继续依赖当前手机号注册 activation 获取验证码,但显示与身份同步统一以侧栏运行态手机号为准,避免 UI 空白和后台状态脱节。 + 4. 第 4 步继续依赖当前手机号注册 activation 获取验证码;只有手机号字符串、没有 activation 时,只能作为页面填写来源,不能伪造成自动接码订单。 + 5. 显示与身份同步统一以侧栏运行态手机号为准,避免 UI 空白和后台状态脱节。 - 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail` - 当前手机号验证激活记录 `currentPhoneActivation` - 可复用的手机号验证激活记录 `reusablePhoneActivation` @@ -326,7 +328,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 2. 打开或复用注册页 3. 邮箱注册时,如果注册弹窗默认停留在手机号输入模式,会先尝试点击 `继续使用电子邮件地址登录 / Continue using email address` 一类按钮切回邮箱输入模式 4. 邮箱注册分支:提交邮箱;邮箱输入框识别同时兼容本地化占位与 `aria-label` -5. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,再从接码平台申请号码;内容脚本会按号码区号和平台国家名切换手机号国家下拉框,必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交 +5. 手机号注册分支:先点击官网“免费注册”并切换到手机号注册入口,必要时展开更多选项;确认手机号输入页就绪后,按“已有 signup activation > 手动填写的运行态手机号 > 接码平台新申请号码”的顺序决定手机号来源,避免重复买号。内容脚本会通过 `content/phone-country-utils.js` 共享工具按号码最长区号和平台国家名切换手机号国家下拉框,兼容 `+(44)` / `(+44)` / `+44` 等页面文案;必须确认可视下拉按钮的区号已同步后才填写并提交,若仍显示旧国家则停止提交 6. 以当前统一账号标识先写入一条“停止(流程尚未完成)”的记录占位;邮箱账号占位邮箱,phone-only 账号占位手机号 7. 等待身份提交后的真实落地页 8. 如果进入密码页,则继续执行 Step 3 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index f1c1885..c8331c9 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -76,7 +76,7 @@ - `background/steps/platform-verify.js`:平台回调验证实现,普通模式为步骤 10,Plus 模式为可见步骤 13;负责 CPA / SUB2API 回调验证,以及 Codex2API 的协议式 callback code/state 交换,所有平台验证日志和完成信号都按当前可见步骤上报。 - `background/steps/plus-return-confirm.js`:Plus 模式第 9 步实现,负责等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒再完成。 - `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。 -- `background/steps/submit-signup-email.js`:步骤 2 实现,负责按 `signupMethod` 在邮箱注册与手机号注册之间分发;邮箱分支会先点击官网“免费注册”再提交邮箱,手机号分支会先点击官网“免费注册”并切到手机号注册入口,确认手机号输入页就绪后再申请接码平台号码并提交手机号,最后根据落地页判断是否跳过后续密码步骤。 +- `background/steps/submit-signup-email.js`:步骤 2 实现,负责按 `signupMethod` 在邮箱注册与手机号注册之间分发;邮箱分支会先点击官网“免费注册”再提交邮箱并清理旧手机号注册运行态,手机号分支会先点击官网“免费注册”并切到手机号注册入口,确认手机号输入页就绪后优先复用已有 signup activation,其次使用手动运行态手机号,最后才申请接码平台号码并提交,最后根据落地页判断是否跳过后续密码步骤。 ## `content/` @@ -89,6 +89,7 @@ - `content/mail-163.js`:163 / 163 VIP / 126 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。 - `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、在账号页固定执行 `focus -> clear -> fill -> blur` 重新触发邮箱输入事件、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。 +- `content/phone-country-utils.js`:手机号国家/区号共享工具层,负责手机号纯数字归一化、国家文案/别名归一化、`+(44)` / `(+44)` / `+44` 区号提取、按号码最长区号匹配国家选项,并由注册页与后续手机号验证页共同复用。 - `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 - `content/plus-checkout.js`:ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 @@ -146,7 +147,7 @@ receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时 额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收 码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密 - 钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器;设置卡片还新增“授权总超时”开关,用于控制 Step 7 后链 5 分钟总预算。 + 钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;接码卡内把注册方式、接码配置、接码订单运行态与“注册手机号”身份运行态分层展示,“注册手机号”位于订单运行状态下方,不嵌入当前分配/验证码网格;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器;设置卡片还新增“授权总超时”开关,用于控制 Step 7 后链 5 分钟总预算。 - `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。 - `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装 配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启 @@ -161,7 +162,7 @@ 会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置,接码开关关闭时会直接隐藏相关配置;账号记录快照同步改为默认自动模式,只 要本地 helper 可用就会自动落盘;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑; Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;IP 代理配置保存、服务配置快照、开关应用和运行态回显由这里接入消息路由,具体解析/状态渲染下沉到 `sidepanel/ip-proxy-panel.js`;侧边栏初始化与点击“自动”前会刷新一次贡献站 - 公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示;当前会保存、回显并热更新 `oauthFlowTimeoutEnabled` 设置;日志渲染只读取结构化 `entry.step` 生成步骤标签,不再正则解析日志正文。 + 公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示;当前会保存、回显并热更新 `oauthFlowTimeoutEnabled` 设置;日志渲染只读取结构化 `entry.step` 生成步骤标签,不再正则解析日志正文;注册手机号输入框只同步运行态身份,不写入持久配置。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Ultra` / 历史 `Pro` / legacy `v` 版本族排序、缓存读取与版本展示。 ## `tests/` @@ -190,7 +191,7 @@ - `tests/background-mail2925-session-module.test.js`:测试 2925 会话模块已接入且导出工厂,并覆盖命中上限后“禁用 24 小时 + 切下一个号 + 自动登录”的核心链路。 - `tests/background-mail2925-signup-flow.test.js`:测试注册页辅助层在 2925 provider 下,会先确保账号池里已分配可用账号,再继续生成别名邮箱。 - `tests/background-message-router-module.test.js`:测试消息路由模块已接入且导出工厂。 -- `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑。 +- `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑,并覆盖邮箱身份写入时清理旧手机号注册运行态。 - `tests/background-navigation-utils-module.test.js`:测试导航工具模块已接入且导出工厂。 - `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。 - `tests/background-paypal-account-store-module.test.js`:测试 PayPal 账号池后台模块已接入、导出工厂,并覆盖当前账号选择会同步回兼容字段 `paypalEmail / paypalPassword`。 @@ -206,6 +207,7 @@ - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成、等待标签稳定完成,以及等待过程中的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/phone-auth-country-match.test.js`:测试认证页手机号国家选择在页面本地化时,仍能用 HeroSMS 的英文国家名匹配对应国家选项。 +- `tests/phone-country-utils.test.js`:测试手机号国家/区号共享工具的区号提取、最长前缀解析、国家别名匹配,以及 manifest 与后台动态注入顺序。 - `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 - `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。 - `tests/paypal-flow-content.test.js`:测试 PayPal 内容脚本对可见邮箱/密码输入框的识别,并覆盖邮箱页即使已预填相同账号也会先清空再重填后继续下一步。