From 2eb913e00bcf432133daf9ff45e8da1028c5660e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B4=E5=9C=A3=E4=BD=91?= Date: Thu, 30 Apr 2026 15:12:58 +0800 Subject: [PATCH] feat(phone-sms): support 5sim provider (cherry picked from commit 7d10cab9765ed662b5088f791c8635fd8cc63e44) --- background.js | 327 +-- background/phone-verification-flow.js | 954 +++----- docs/local-customizations-maintenance.md | 81 +- phone-sms/providers/five-sim.js | 822 +++++++ phone-sms/providers/hero-sms.js | 213 ++ phone-sms/providers/registry.js | 43 + sidepanel/sidepanel.html | 340 +++ sidepanel/sidepanel.js | 2000 +++++++---------- ...ackground-account-history-settings.test.js | 25 +- tests/five-sim-provider.test.js | 170 ++ tests/phone-verification-flow.test.js | 212 +- tests/sidepanel-contribution-mode.test.js | 58 + tests/sidepanel-custom-email-pool.test.js | 9 + tests/sidepanel-icloud-provider.test.js | 105 +- tests/sidepanel-mail2925-base-email.test.js | 49 + ...epanel-phone-verification-settings.test.js | 280 ++- 项目完整链路说明.md | 21 +- 17 files changed, 3569 insertions(+), 2140 deletions(-) create mode 100644 phone-sms/providers/five-sim.js create mode 100644 phone-sms/providers/hero-sms.js create mode 100644 phone-sms/providers/registry.js create mode 100644 tests/five-sim-provider.test.js diff --git a/background.js b/background.js index cb80c39..8fb26ce 100644 --- a/background.js +++ b/background.js @@ -4,6 +4,9 @@ importScripts( 'managed-alias-utils.js', 'mail2925-utils.js', 'paypal-utils.js', + 'phone-sms/providers/hero-sms.js', + 'phone-sms/providers/five-sim.js', + 'phone-sms/providers/registry.js', 'background/phone-verification-flow.js', 'background/account-run-history.js', 'background/contribution-oauth.js', @@ -338,6 +341,12 @@ const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high'; const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; +const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; +const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; +const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; +const FIVE_SIM_COUNTRY_ID = 'england'; +const FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const FIVE_SIM_OPERATOR = 'any'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = [ @@ -565,6 +574,7 @@ const PERSISTED_SETTING_DEFAULTS = { hotmailAccounts: [], mail2925Accounts: [], paypalAccounts: [], + phoneSmsProvider: DEFAULT_PHONE_SMS_PROVIDER, heroSmsApiKey: '', heroSmsReuseEnabled: DEFAULT_HERO_SMS_REUSE_ENABLED, heroSmsAcquirePriority: DEFAULT_HERO_SMS_ACQUIRE_PRIORITY, @@ -573,16 +583,12 @@ const PERSISTED_SETTING_DEFAULTS = { heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, heroSmsCountryFallback: [], - phonePreferredActivation: null, fiveSimApiKey: '', - fiveSimBaseUrl: DEFAULT_FIVE_SIM_BASE_URL, - fiveSimCountryOrder: [], - fiveSimOperator: DEFAULT_FIVE_SIM_OPERATOR, - fiveSimProduct: DEFAULT_FIVE_SIM_PRODUCT, - nexSmsApiKey: '', - nexSmsBaseUrl: DEFAULT_NEX_SMS_BASE_URL, - nexSmsCountryOrder: [], - nexSmsServiceCode: DEFAULT_NEX_SMS_SERVICE_CODE, + fiveSimCountryId: FIVE_SIM_COUNTRY_ID, + fiveSimCountryLabel: FIVE_SIM_COUNTRY_LABEL, + fiveSimCountryFallback: [], + fiveSimMaxPrice: '', + fiveSimOperator: FIVE_SIM_OPERATOR, }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -944,156 +950,99 @@ function normalizeHeroSmsCountryFallback(value = []) { return normalized; } -function normalizePhonePreferredActivation(value = null) { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - - const provider = normalizePhoneSmsProvider(value.provider || ''); - const activationId = String(value.activationId ?? value.id ?? value.activation ?? '').trim(); - const phoneNumber = String(value.phoneNumber ?? value.phone ?? value.number ?? '').trim(); - if (!activationId || !phoneNumber) { - return null; - } - - const normalized = { - provider, - activationId, - phoneNumber, - successfulUses: Math.max(0, Math.floor(Number(value.successfulUses) || 0)), - maxUses: Math.max(1, Math.floor(Number(value.maxUses) || 3)), - }; - - if (provider === PHONE_SMS_PROVIDER_5SIM) { - const countryCode = normalizeFiveSimCountryCode(value.countryCode || value.countryId || '', ''); - if (countryCode) { - normalized.countryId = countryCode; - normalized.countryCode = countryCode; - } - } else if (provider === PHONE_SMS_PROVIDER_NEXSMS) { - const countryId = normalizeNexSmsCountryId(value.countryId, -1); - if (countryId >= 0) { - normalized.countryId = countryId; - } - } else { - const countryId = Math.floor(Number(value.countryId) || 0); - if (countryId > 0) { - normalized.countryId = countryId; - } - } - - const countryLabel = String(value.countryLabel || '').trim(); - if (countryLabel) { - normalized.countryLabel = countryLabel; - } - const serviceCode = String(value.serviceCode || '').trim(); - if (serviceCode) { - normalized.serviceCode = serviceCode; - } - const expiresAt = Number(value.expiresAt); - if (Number.isFinite(expiresAt) && expiresAt > 0) { - normalized.expiresAt = Math.floor(expiresAt); - } - - return normalized; -} function normalizePhoneSmsProvider(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsProviderRegistry?.normalizeProviderId) { + return rootScope.PhoneSmsProviderRegistry.normalizeProviderId(value); + } const normalized = String(value || '').trim().toLowerCase(); - if (normalized === PHONE_SMS_PROVIDER_5SIM) { - return PHONE_SMS_PROVIDER_5SIM; - } - if (normalized === PHONE_SMS_PROVIDER_NEXSMS) { - return PHONE_SMS_PROVIDER_NEXSMS; - } - return PHONE_SMS_PROVIDER_HERO; + return normalized === PHONE_SMS_PROVIDER_FIVE_SIM + ? PHONE_SMS_PROVIDER_FIVE_SIM + : PHONE_SMS_PROVIDER_HERO_SMS; } -function normalizePhoneSmsProviderOrder(value = [], fallbackOrder = []) { - const source = Array.isArray(value) - ? value - : String(value || '') - .split(/[\r\n,,;;|/]+/) - .map((entry) => String(entry || '').trim()) - .filter(Boolean); - const normalized = []; - const seen = new Set(); - - source.forEach((entry) => { - const provider = normalizePhoneSmsProvider(entry); - if (seen.has(provider)) { - return; - } - seen.add(provider); - normalized.push(provider); - }); - - if (normalized.length) { - return normalized.slice(0, 3); +function normalizeFiveSimCountryId(value, fallback = FIVE_SIM_COUNTRY_ID) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryId) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryId(value, fallback); } - - const fallback = Array.isArray(fallbackOrder) ? fallbackOrder : []; - const fallbackNormalized = []; - fallback.forEach((providerEntry) => { - const provider = normalizePhoneSmsProvider(providerEntry); - if (!provider || fallbackNormalized.includes(provider)) { - return; - } - fallbackNormalized.push(provider); - }); - - return fallbackNormalized.slice(0, 3); -} - -function normalizeFiveSimBaseUrl(value = '', fallback = DEFAULT_FIVE_SIM_BASE_URL) { - const trimmed = String(value || '').trim(); - if (!trimmed) { - return fallback; - } - try { - const parsed = new URL(trimmed); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return fallback; - } - return parsed.toString().replace(/\/$/, ''); - } catch { - return fallback; - } -} - -function normalizeFiveSimCountryCode(value = '', fallback = 'thailand') { - const normalized = String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]/g, ''); + const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); return normalized || fallback; } -function normalizeFiveSimCountryOrder(value = []) { +function normalizeFiveSimCountryLabel(value = '', fallback = FIVE_SIM_COUNTRY_LABEL) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryLabel) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryLabel(value, fallback); + } + if (rootScope.PhoneSmsFiveSimProvider?.formatFiveSimCountryLabel) { + return rootScope.PhoneSmsFiveSimProvider.formatFiveSimCountryLabel('', value, fallback); + } + return String(value || '').trim() || fallback; +} + +function normalizeFiveSimOperator(value = '', fallback = FIVE_SIM_OPERATOR) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimOperator) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimOperator(value || fallback); + } + return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; +} + +function normalizeFiveSimMaxPrice(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimMaxPrice) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimMaxPrice(value); + } + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return ''; + } + const numeric = Number(rawValue); + if (!Number.isFinite(numeric) || numeric <= 0) { + return ''; + } + return String(Math.round(numeric * 10000) / 10000); +} + +function normalizeFiveSimCountryFallback(value = []) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryFallback) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryFallback(value); + } const source = Array.isArray(value) ? value : String(value || '') .split(/[\r\n,,;;]+/) .map((entry) => String(entry || '').trim()) .filter(Boolean); - const seen = new Set(); + const seenIds = new Set(); const normalized = []; for (const entry of source) { - let code = ''; + let countryId = ''; + let countryLabel = ''; if (entry && typeof entry === 'object' && !Array.isArray(entry)) { - code = normalizeFiveSimCountryCode(entry.code || entry.country || entry.id || '', ''); + countryId = normalizeFiveSimCountryId(entry.countryId ?? entry.id ?? entry.slug, ''); + countryLabel = String((entry.countryLabel ?? entry.label ?? entry.name ?? entry.text_en) || '').trim(); } else { - code = normalizeFiveSimCountryCode(entry, ''); + const text = String(entry || '').trim(); + const structuredMatch = text.match(/^([a-z0-9_-]+)\s*(?:[:|/-]\s*(.+))?$/i); + countryId = normalizeFiveSimCountryId(structuredMatch?.[1] || text, ''); + countryLabel = String(structuredMatch?.[2] || '').trim(); } - if (!code || seen.has(code)) { + if (!countryId || seenIds.has(countryId)) { continue; } - seen.add(code); - normalized.push(code); - if (normalized.length >= 10) { + seenIds.add(countryId); + normalized.push({ + id: countryId, + label: countryLabel || normalizeFiveSimCountryLabel('', countryId), + }); + if (normalized.length >= 20) { break; } } @@ -1101,85 +1050,6 @@ function normalizeFiveSimCountryOrder(value = []) { return normalized; } -function normalizeFiveSimOperator(value = '', fallback = DEFAULT_FIVE_SIM_OPERATOR) { - return normalizeFiveSimCountryCode(value, fallback); -} - -function normalizeFiveSimProduct(value = '', fallback = DEFAULT_FIVE_SIM_PRODUCT) { - return normalizeFiveSimCountryCode(value, fallback); -} - -function normalizeNexSmsBaseUrl(value = '', fallback = DEFAULT_NEX_SMS_BASE_URL) { - const trimmed = String(value || '').trim(); - if (!trimmed) { - return fallback; - } - try { - const parsed = new URL(trimmed); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return fallback; - } - return parsed.toString().replace(/\/$/, ''); - } catch { - return fallback; - } -} - -function normalizeNexSmsCountryId(value, fallback = 0) { - const parsed = Math.floor(Number(value)); - if (Number.isFinite(parsed) && parsed >= 0) { - return parsed; - } - const fallbackParsed = Math.floor(Number(fallback)); - if (Number.isFinite(fallbackParsed) && fallbackParsed >= 0) { - return fallbackParsed; - } - return 0; -} - -function normalizeNexSmsCountryOrder(value = []) { - const source = Array.isArray(value) - ? value - : String(value || '') - .split(/[\r\n,,;;]+/) - .map((entry) => String(entry || '').trim()) - .filter(Boolean); - const seen = new Set(); - const normalized = []; - for (const entry of source) { - const countryId = normalizeNexSmsCountryId( - entry && typeof entry === 'object' && !Array.isArray(entry) - ? (entry.id || entry.countryId || entry.country || '') - : entry, - -1 - ); - if (countryId < 0 || seen.has(countryId)) { - continue; - } - seen.add(countryId); - normalized.push(countryId); - if (normalized.length >= 10) { - break; - } - } - return normalized; -} - -function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_NEX_SMS_SERVICE_CODE) { - const normalized = String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]/g, ''); - if (normalized) { - return normalized; - } - const fallbackNormalized = String(fallback || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]/g, ''); - return fallbackNormalized || 'ot'; -} - function resolveLegacyAutoStepDelaySeconds(input = {}) { const hasLegacyMin = input.autoStepRandomDelayMinSeconds !== undefined; const hasLegacyMax = input.autoStepRandomDelayMaxSeconds !== undefined; @@ -2040,6 +1910,8 @@ function normalizePersistentSettingValue(key, value) { return normalizeMail2925Accounts(value); case 'paypalAccounts': return normalizePayPalAccounts(value); + case 'phoneSmsProvider': + return normalizePhoneSmsProvider(value); case 'heroSmsApiKey': return String(value || ''); case 'heroSmsReuseEnabled': @@ -2056,26 +1928,18 @@ function normalizePersistentSettingValue(key, value) { return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL; case 'heroSmsCountryFallback': return normalizeHeroSmsCountryFallback(value); - case 'phonePreferredActivation': - return normalizePhonePreferredActivation(value); case 'fiveSimApiKey': - return String(value || '').trim(); - case 'fiveSimBaseUrl': - return normalizeFiveSimBaseUrl(value, DEFAULT_FIVE_SIM_BASE_URL); - case 'fiveSimCountryOrder': - return normalizeFiveSimCountryOrder(value); + return String(value || ''); + case 'fiveSimCountryId': + return normalizeFiveSimCountryId(value); + case 'fiveSimCountryLabel': + return normalizeFiveSimCountryLabel(value); + case 'fiveSimCountryFallback': + return normalizeFiveSimCountryFallback(value); + case 'fiveSimMaxPrice': + return normalizeFiveSimMaxPrice(value); case 'fiveSimOperator': - return normalizeFiveSimOperator(value, DEFAULT_FIVE_SIM_OPERATOR); - case 'fiveSimProduct': - return normalizeFiveSimProduct(value, DEFAULT_FIVE_SIM_PRODUCT); - case 'nexSmsApiKey': - return String(value || '').trim(); - case 'nexSmsBaseUrl': - return normalizeNexSmsBaseUrl(value, DEFAULT_NEX_SMS_BASE_URL); - case 'nexSmsCountryOrder': - return normalizeNexSmsCountryOrder(value); - case 'nexSmsServiceCode': - return normalizeNexSmsServiceCode(value, DEFAULT_NEX_SMS_SERVICE_CODE); + return normalizeFiveSimOperator(value); default: return value; } @@ -9693,6 +9557,7 @@ const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.crea setState, sleepWithStop, throwIfStopped, + createFiveSimProvider: self.PhoneSmsFiveSimProvider?.createProvider, }); const step1Executor = self.MultiPageBackgroundStep1?.createStep1Executor({ addLog, diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index cb18f0c..c3944d3 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -23,6 +23,7 @@ DEFAULT_NEX_SMS_COUNTRY_ORDER = [1], DEFAULT_NEX_SMS_SERVICE_CODE = 'ot', DEFAULT_HERO_SMS_REUSE_ENABLED = true, + createFiveSimProvider = null, HERO_SMS_COUNTRY_ID = 52, HERO_SMS_COUNTRY_LABEL = 'Thailand', HERO_SMS_SERVICE_CODE = 'dr', @@ -69,17 +70,8 @@ const DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS = 2000; const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; - const HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high'; - const PHONE_SMS_PROVIDER_HERO = 'hero-sms'; - const PHONE_SMS_PROVIDER_5SIM = '5sim'; - const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms'; - const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO; - const DEFAULT_PHONE_SMS_PROVIDER_ORDER = [ - PHONE_SMS_PROVIDER_HERO, - PHONE_SMS_PROVIDER_5SIM, - PHONE_SMS_PROVIDER_NEXSMS, - ]; - const MAX_PHONE_REUSABLE_POOL = 12; + const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; + const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::'; const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; @@ -652,6 +644,120 @@ return normalizedMap; } + function normalizePhoneSmsProvider(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === PHONE_SMS_PROVIDER_FIVE_SIM + ? PHONE_SMS_PROVIDER_FIVE_SIM + : PHONE_SMS_PROVIDER_HERO_SMS; + } + + function getActivationProviderId(activation = {}, state = {}) { + return normalizePhoneSmsProvider(activation?.provider || state?.phoneSmsProvider); + } + + function getPhoneSmsProviderLabel(providerId) { + return normalizePhoneSmsProvider(providerId) === PHONE_SMS_PROVIDER_FIVE_SIM ? '5sim' : 'HeroSMS'; + } + + function formatStep9Reason(reason = '') { + const text = String(reason || '').trim(); + if (!text) { + return '未知'; + } + const normalized = text.toLowerCase(); + const reasonMap = { + returned_to_add_phone_loop: '反复返回添加手机号页', + phone_number_used: '手机号已被使用', + sms_not_received: '未收到短信', + sms_timeout: '短信超时', + resend_throttled: '重发短信被限流', + code_rejected: '验证码被拒绝', + unknown: '未知', + }; + if (reasonMap[normalized]) { + return reasonMap[normalized]; + } + const timeoutWindowMatch = text.match(/^sms_timeout_after_(\d+)_windows$/i); + if (timeoutWindowMatch) { + return `连续 ${timeoutWindowMatch[1]} 轮等待后仍未收到短信`; + } + return text; + } + + function isPhoneSmsReuseEnabled(state = {}) { + if (normalizePhoneSmsProvider(state?.phoneSmsProvider) === PHONE_SMS_PROVIDER_FIVE_SIM) { + return state?.fiveSimReuseEnabled !== false; + } + return normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled); + } + + function createResolvedFiveSimProvider() { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const factory = createFiveSimProvider || rootScope.PhoneSmsFiveSimProvider?.createProvider; + if (typeof factory !== 'function') { + throw new Error('5sim 平台适配器未加载。'); + } + return factory({ + addLog, + fetchImpl, + requestTimeoutMs: DEFAULT_PHONE_REQUEST_TIMEOUT_MS, + sleepWithStop, + throwIfStopped, + }); + } + + function getFiveSimProviderForState(_state = {}) { + return createResolvedFiveSimProvider(); + } + + function normalizeFiveSimCountryId(value, fallback = 'england') { + const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + return normalized || fallback; + } + + function normalizeFiveSimCountryLabel(value = '', fallback = '英国 (England)') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryLabel) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryLabel(value, fallback); + } + if (rootScope.PhoneSmsFiveSimProvider?.formatFiveSimCountryLabel) { + return rootScope.PhoneSmsFiveSimProvider.formatFiveSimCountryLabel('', value, fallback); + } + return String(value || '').trim() || fallback; + } + + function normalizeFiveSimCountryFallbackList(value = []) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryFallback) { + return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryFallback(value); + } + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seen = new Set(); + const normalized = []; + for (const entry of source) { + let id = ''; + let label = ''; + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + id = normalizeFiveSimCountryId(entry.id ?? entry.countryId ?? entry.slug, ''); + label = String((entry.label ?? entry.countryLabel ?? entry.name ?? entry.text_en) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structured = text.match(/^([a-z0-9_-]+)\s*(?:[:|/-]\s*(.+))?$/i); + id = normalizeFiveSimCountryId(structured?.[1] || text, ''); + label = String(structured?.[2] || '').trim(); + } + if (!id || seen.has(id)) continue; + seen.add(id); + normalized.push({ id, label: label || normalizeFiveSimCountryLabel('', id) }); + } + return normalized; + } + function normalizeCountryFallbackList(value = []) { const source = Array.isArray(value) ? value @@ -764,33 +870,18 @@ } const statusAction = String(record.statusAction || '').trim(); const countryLabel = String(record.countryLabel || '').trim(); - const countryCode = normalizeFiveSimCountryCode( - record.countryCode ?? record.countryId ?? '', - provider === PHONE_SMS_PROVIDER_5SIM ? 'thailand' : '' - ); - const expiresAt = normalizeTimestampMs( - record.expiresAt - ?? record.expireAt - ?? record.expires - ?? record.expiredAt - ?? record.expired_at - ); - const defaultServiceCode = provider === PHONE_SMS_PROVIDER_5SIM - ? DEFAULT_FIVE_SIM_PRODUCT - : (provider === PHONE_SMS_PROVIDER_NEXSMS ? DEFAULT_NEX_SMS_SERVICE_CODE : HERO_SMS_SERVICE_CODE); + const rawProvider = String(record.provider || '').trim(); + const provider = normalizePhoneSmsProvider(rawProvider); + const rawCountryId = record.countryId ?? record.country; + const fallbackCountryId = provider === PHONE_SMS_PROVIDER_FIVE_SIM ? 'england' : HERO_SMS_COUNTRY_ID; return { activationId, phoneNumber, provider, - serviceCode: String(record.serviceCode || defaultServiceCode).trim() || defaultServiceCode, - countryId: provider === PHONE_SMS_PROVIDER_5SIM - ? countryCode - : ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? normalizeNexSmsCountryId(record.countryId, 0) - : normalizeCountryId(record.countryId, HERO_SMS_COUNTRY_ID) - ), - ...(provider === PHONE_SMS_PROVIDER_5SIM ? { countryCode } : {}), + serviceCode: String(record.serviceCode || HERO_SMS_SERVICE_CODE).trim() || HERO_SMS_SERVICE_CODE, + countryId: provider === PHONE_SMS_PROVIDER_FIVE_SIM + ? normalizeFiveSimCountryId(rawCountryId, fallbackCountryId) + : normalizeCountryId(rawCountryId, fallbackCountryId), ...(countryLabel ? { countryLabel } : {}), successfulUses: normalizeUseCount(record.successfulUses), maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), @@ -886,15 +977,13 @@ } const fallback = {}; - const provider = normalizePhoneSmsProvider(record.provider || ''); + const rawProvider = String(record.provider || '').trim(); + const provider = rawProvider ? normalizePhoneSmsProvider(rawProvider) : ''; const serviceCode = String(record.serviceCode || '').trim(); - const countryId = provider === PHONE_SMS_PROVIDER_5SIM - ? normalizeFiveSimCountryCode(record.countryId || record.countryCode || '', '') - : ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? normalizeNexSmsCountryId(record.countryId, -1) - : Math.floor(Number(record.countryId)) - ); + const rawCountryId = record.countryId ?? record.country; + const countryId = provider === PHONE_SMS_PROVIDER_FIVE_SIM + ? normalizeFiveSimCountryId(rawCountryId, '') + : Math.floor(Number(rawCountryId)); const countryLabel = String(record.countryLabel || '').trim(); const statusAction = String(record.statusAction || '').trim(); @@ -904,16 +993,11 @@ if (serviceCode) { fallback.serviceCode = serviceCode; } - if ( - (provider === PHONE_SMS_PROVIDER_5SIM && countryId) - || (provider === PHONE_SMS_PROVIDER_NEXSMS && Number.isFinite(countryId) && countryId >= 0) - || ( - provider !== PHONE_SMS_PROVIDER_5SIM - && provider !== PHONE_SMS_PROVIDER_NEXSMS - && Number.isFinite(countryId) - && countryId > 0 - ) - ) { + if (provider === PHONE_SMS_PROVIDER_FIVE_SIM) { + if (countryId) { + fallback.countryId = countryId; + } + } else if (Number.isFinite(countryId) && countryId > 0) { fallback.countryId = countryId; if (provider === PHONE_SMS_PROVIDER_5SIM) { fallback.countryCode = countryId; @@ -984,8 +1068,8 @@ } function buildPhoneCodeTimeoutError(lastResponse = '') { - const suffix = lastResponse ? ` Last provider status: ${lastResponse}` : ''; - return new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}Timed out waiting for the phone verification code.${suffix}`); + const suffix = lastResponse ? ` HeroSMS 最后状态:${lastResponse}` : ''; + return new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${suffix}`); } function isPhoneCodeTimeoutError(error) { @@ -1037,9 +1121,9 @@ } function buildPhoneRestartStep7Error(phoneNumber = '') { - const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : ''; + const suffix = phoneNumber ? ` 当前号码:${phoneNumber}。` : ''; return new Error( - `${PHONE_RESTART_STEP7_ERROR_PREFIX}Phone verification could not receive an SMS after resend. Restart step 7 with a new number.${suffix}` + `${PHONE_RESTART_STEP7_ERROR_PREFIX}手机验证重发后仍未收到短信,请从步骤 7 重新获取新号码。${suffix}` ); } @@ -1048,7 +1132,7 @@ if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) { return error; } - return new Error(message.slice(PHONE_CODE_TIMEOUT_ERROR_PREFIX.length).trim() || 'Timed out waiting for the phone verification code.'); + return new Error(message.slice(PHONE_CODE_TIMEOUT_ERROR_PREFIX.length).trim() || '等待手机验证码超时。'); } function sanitizePhoneRestartStep7Error(error) { @@ -1058,7 +1142,7 @@ } return new Error( message.slice(PHONE_RESTART_STEP7_ERROR_PREFIX.length).trim() - || 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number.' + || '手机验证重发后仍未收到短信,请从步骤 7 重新获取新号码。' ); } @@ -2571,6 +2655,10 @@ } async function requestPhoneActivation(state = {}, options = {}) { + if (normalizePhoneSmsProvider(state?.phoneSmsProvider) === PHONE_SMS_PROVIDER_FIVE_SIM) { + const provider = getFiveSimProviderForState(state); + return provider.requestActivation(state, options); + } const config = resolvePhoneConfig(state); if (config.provider === PHONE_SMS_PROVIDER_5SIM) { return requestFiveSimActivation(state, options); @@ -2596,7 +2684,7 @@ countryCandidates = allCountryCandidates; if (blockedCountryIds.size) { await addLog( - 'Step 9: all selected countries reached the temporary SMS-failure skip threshold, lifting skip for this acquire round.', + '步骤 9:已选国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。', 'warn' ); } @@ -2623,7 +2711,7 @@ for (let round = 1; round <= maxAcquireRounds; round += 1) { if (maxAcquireRounds > 1) { await addLog( - `Step 9: HeroSMS acquiring phone number (round ${round}/${maxAcquireRounds})...`, + `步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`, 'info' ); } @@ -2844,7 +2932,7 @@ && retryableNoNumberCountries.length > 0 ) { await addLog( - `Step 9: HeroSMS has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`, + `步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`, 'warn' ); await sleepWithStop(retryDelayMs); @@ -2856,19 +2944,23 @@ if (finalNoNumbersByCountry.length) { throw new Error( - `HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` + `HeroSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}。` ); } if (finalLastError) { throw finalLastError; } - throw new Error(`HeroSMS failed to acquire a phone number. Last status: ${finalLastFailureText || 'unknown'}.`); + throw new Error(`HeroSMS 获取手机号失败,最后状态:${finalLastFailureText || '未知'}。`); } async function reactivatePhoneActivation(state = {}, activation) { const normalizedActivation = normalizeActivation(activation); if (!normalizedActivation) { - throw new Error('Reusable phone activation is missing.'); + throw new Error('缺少可复用的手机号接码订单。'); + } + if (getActivationProviderId(normalizedActivation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) { + const provider = getFiveSimProviderForState(state); + return provider.reuseActivation(state, normalizedActivation); } const config = resolvePhoneConfig(state); @@ -2903,7 +2995,7 @@ const nextActivation = parseActivationPayload(payload, normalizedActivation); if (!nextActivation) { const text = describeHeroSmsPayload(payload); - throw new Error(`HeroSMS reactivate failed: ${text || 'empty response'}`); + throw new Error(`HeroSMS 复用手机号失败:${text || '空响应'}`); } return nextActivation; } @@ -2950,13 +3042,34 @@ } async function completePhoneActivation(state = {}, activation) { - forgetActivationAcquiredPriceHint(activation); + if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) { + const provider = getFiveSimProviderForState(state); + await provider.finishActivation(state, activation); + return; + } await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)'); } async function cancelPhoneActivation(state = {}, activation) { try { - forgetActivationAcquiredPriceHint(activation); + if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) { + const provider = getFiveSimProviderForState(state); + await provider.cancelActivation(state, activation); + return; + } + await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)'); + } catch (_) { + // Best-effort cleanup. + } + } + + async function banPhoneActivation(state = {}, activation) { + try { + if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) { + const provider = getFiveSimProviderForState(state); + await provider.banActivation(state, activation); + return; + } await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)'); } catch (_) { // Best-effort cleanup. @@ -2969,6 +3082,10 @@ return; } try { + if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) { + // 5sim does not expose a HeroSMS-style setStatus(3) resend primitive. + return; + } await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)'); } catch (_) { // Best-effort request only. @@ -2978,7 +3095,11 @@ async function pollPhoneActivationCode(state = {}, activation, options = {}) { const normalizedActivation = normalizeActivation(activation); if (!normalizedActivation) { - throw new Error('Phone activation is missing.'); + throw new Error('缺少手机号接码订单。'); + } + if (getActivationProviderId(normalizedActivation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) { + const provider = getFiveSimProviderForState(state); + return provider.pollActivationCode(state, normalizedActivation, options); } const statusAction = resolveActivationStatusAction(normalizedActivation); @@ -3178,7 +3299,7 @@ async function readPhonePageState(tabId, timeoutMs = 10000) { await ensureStep8SignupPageReady(tabId, { timeoutMs, - logMessage: 'Step 9: waiting for auth page content script to recover before phone verification.', + logMessage: '步骤 9:等待认证页脚本恢复后继续手机号验证。', }); const result = await sendToContentScriptResilient('signup-page', { type: 'STEP8_GET_STATE', @@ -3188,7 +3309,7 @@ timeoutMs, responseTimeoutMs: timeoutMs, retryDelayMs: 600, - logMessage: 'Step 9: auth page is switching, waiting to inspect phone verification state again...', + logMessage: '步骤 9:认证页正在切换,等待后重新检查手机号验证状态...', }); if (result?.error) { @@ -3197,44 +3318,25 @@ return result || {}; } + function resolveCountryCandidatesForProvider(state = {}, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) { + if (normalizePhoneSmsProvider(providerId) === PHONE_SMS_PROVIDER_FIVE_SIM) { + return getFiveSimProviderForState(state).resolveCountryCandidates(state); + } + return resolveCountryCandidates(state); + } + function resolveCountryConfigFromActivation(activation, fallbackState = {}) { - const provider = normalizePhoneSmsProvider( - activation?.provider || fallbackState?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER - ); - const candidates = provider === PHONE_SMS_PROVIDER_5SIM - ? resolveFiveSimCountryCandidates(fallbackState) - : ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? resolveNexSmsCountryCandidates(fallbackState) - : resolveCountryCandidates(fallbackState) - ); + const providerId = getActivationProviderId(activation, fallbackState); + const candidates = resolveCountryCandidatesForProvider(fallbackState, providerId); if (activation && typeof activation === 'object') { - if (provider === PHONE_SMS_PROVIDER_5SIM) { - const countryCode = normalizeFiveSimCountryCode( - activation.countryCode || activation.countryId || '', - '' - ); - if (countryCode) { - const matched = candidates.find((entry) => String(entry.id || entry.code || '') === countryCode); - if (matched) { - return matched; - } - return { - id: countryCode, - code: countryCode, - label: normalizeCountryLabel(activation.countryLabel, countryCode), - }; - } - } else if (provider === PHONE_SMS_PROVIDER_NEXSMS) { - const countryId = normalizeNexSmsCountryId(activation.countryId, -1); - if (countryId >= 0) { - const matched = candidates.find((entry) => normalizeNexSmsCountryId(entry.id, -1) === countryId); - if (matched) { - return matched; - } + if (providerId === PHONE_SMS_PROVIDER_FIVE_SIM) { + const countryId = normalizeFiveSimCountryId(activation.countryId, ''); + if (countryId) { + const matched = candidates.find((entry) => String(entry.id) === countryId); + if (matched) return matched; return { id: countryId, - label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`), + label: normalizeFiveSimCountryLabel(activation.countryLabel, countryId), }; } } else { @@ -3251,27 +3353,16 @@ } } } - if (provider === PHONE_SMS_PROVIDER_5SIM) { - return candidates[0] || { - id: '', - code: '', - label: '', - }; - } - if (provider === PHONE_SMS_PROVIDER_NEXSMS) { - return candidates[0] || { - id: 0, - label: '', - }; - } - return candidates[0] || resolveCountryConfig(fallbackState); + return candidates[0] || (providerId === PHONE_SMS_PROVIDER_FIVE_SIM + ? { id: 'england', label: 'England' } + : resolveCountryConfig(fallbackState)); } async function submitPhoneNumber(tabId, phoneNumber, activation = null) { const state = await getState(); const countryConfig = resolveCountryConfigFromActivation(activation, state); const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'submit add-phone number' }) + ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: '提交添加手机号' }) : 30000; const result = await sendToContentScriptResilient('signup-page', { type: 'SUBMIT_PHONE_NUMBER', @@ -3285,7 +3376,7 @@ timeoutMs, responseTimeoutMs: timeoutMs, retryDelayMs: 600, - logMessage: 'Step 9: waiting for add-phone page to become ready...', + logMessage: '步骤 9:等待添加手机号页面就绪...', }); if (result?.error) { @@ -3296,7 +3387,7 @@ async function submitPhoneVerificationCode(tabId, code) { const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(45000, { step: 9, actionLabel: 'submit phone verification code' }) + ? await getOAuthFlowStepTimeoutMs(45000, { step: 9, actionLabel: '提交手机验证码' }) : 45000; const result = await sendToContentScriptResilient('signup-page', { type: 'SUBMIT_PHONE_VERIFICATION_CODE', @@ -3306,7 +3397,7 @@ timeoutMs, responseTimeoutMs: timeoutMs, retryDelayMs: 600, - logMessage: 'Step 9: waiting for phone verification page before filling the SMS code...', + logMessage: '步骤 9:等待手机验证码页面就绪后填写短信验证码...', }); if (result?.error) { @@ -3323,17 +3414,12 @@ type: 'RESEND_PHONE_VERIFICATION_CODE', source: 'background', payload: {}, - }; - const result = typeof sendToContentScript === 'function' - ? await sendToContentScript('signup-page', request, { - responseTimeoutMs: timeoutMs, - }) - : await sendToContentScriptResilient('signup-page', request, { - timeoutMs, - responseTimeoutMs: timeoutMs, - retryDelayMs: 600, - logMessage: 'Step 9: waiting for the phone verification resend button...', - }); + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: '步骤 9:等待手机验证码重发按钮出现...', + }); if (result?.error) { throw new Error(result.error); @@ -3353,7 +3439,7 @@ timeoutMs, responseTimeoutMs: timeoutMs, retryDelayMs: 600, - logMessage: 'Step 9: returning to add-phone page to replace the phone number...', + logMessage: '步骤 9:返回添加手机号页面以更换号码...', }); if (result?.error) { @@ -3451,230 +3537,64 @@ } async function acquirePhoneActivation(state = {}, options = {}) { - const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER); - const providerOrder = resolvePhoneProviderOrder(state, provider); - const countryCandidates = provider === PHONE_SMS_PROVIDER_5SIM - ? resolveFiveSimCountryCandidates(state) - : ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? resolveNexSmsCountryCandidates(state) - : resolveCountryCandidates(state) - ); - if ( - (provider === PHONE_SMS_PROVIDER_5SIM || provider === PHONE_SMS_PROVIDER_NEXSMS) - && !countryCandidates.length - ) { - throw new Error(`Step 9: ${provider === PHONE_SMS_PROVIDER_5SIM ? '5sim' : 'NexSMS'} countries are empty. Please select at least one country in 接码设置。`); - } - const normalizeCountryKey = (value) => ( - provider === PHONE_SMS_PROVIDER_5SIM - ? normalizeFiveSimCountryCode(value, '') - : ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? String(normalizeNexSmsCountryId(value, -1)) - : String(normalizeCountryId(value, 0)) - ) + const activeProviderId = normalizePhoneSmsProvider(state?.phoneSmsProvider); + const countryCandidates = resolveCountryCandidatesForProvider(state, activeProviderId); + const normalizeBlockedCountryId = (value) => ( + activeProviderId === PHONE_SMS_PROVIDER_FIVE_SIM + ? normalizeFiveSimCountryId(value, '') + : normalizeCountryId(value, 0) ); const blockedCountryIds = new Set( (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) - .map((value) => normalizeCountryKey(value)) - .filter((id) => ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? (id !== '' && id !== null && id !== undefined) - : Boolean(id && id !== '0') - )) + .map(normalizeBlockedCountryId) + .filter(Boolean) ); const allowedCountryIds = new Set( countryCandidates - .map((entry) => normalizeCountryKey(entry.id || entry.code)) - .filter((id) => ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? (id !== '' && id !== null && id !== undefined && !blockedCountryIds.has(id)) - : Boolean(id && id !== '0' && !blockedCountryIds.has(id)) - )) + .map((entry) => normalizeBlockedCountryId(entry.id)) + .filter((id) => id && !blockedCountryIds.has(id)) ); - const preferredCountryLabel = countryCandidates[0]?.label || ( - provider === PHONE_SMS_PROVIDER_5SIM - ? '' - : ( - provider === PHONE_SMS_PROVIDER_NEXSMS - ? '' - : HERO_SMS_COUNTRY_LABEL - ) + const preferredCountryLabel = countryCandidates[0]?.label || (activeProviderId === PHONE_SMS_PROVIDER_FIVE_SIM ? 'England' : HERO_SMS_COUNTRY_LABEL); + const resolveCountryLabelById = (countryId) => ( + countryCandidates.find((entry) => normalizeBlockedCountryId(entry.id) === normalizeBlockedCountryId(countryId))?.label + || preferredCountryLabel ); - const resolveCountryLabelById = (countryId) => { - const normalizedCountryKey = normalizeCountryKey(countryId); - return countryCandidates.find((entry) => normalizeCountryKey(entry.id || entry.code) === normalizedCountryKey)?.label - || preferredCountryLabel; - }; - const scopedStateForProvider = (providerName) => ({ - ...state, - phoneSmsProvider: normalizePhoneSmsProvider(providerName), - }); - const preferredActivation = normalizeActivation(state[PREFERRED_PHONE_ACTIVATION_STATE_KEY]); - let failedPreferredActivation = null; - const canTryPreferredActivation = ( - !Boolean(options?.skipPreferredActivation) - && preferredActivation - && (provider === PHONE_SMS_PROVIDER_HERO || provider === PHONE_SMS_PROVIDER_5SIM) - && preferredActivation.provider === provider - && !blockedCountryIds.has(normalizeCountryKey(preferredActivation.countryId)) - && allowedCountryIds.has(normalizeCountryKey(preferredActivation.countryId)) - && preferredActivation.successfulUses < preferredActivation.maxUses - ); - if (canTryPreferredActivation) { + const reuseEnabled = isPhoneSmsReuseEnabled(state); + const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); + if ( + reuseEnabled + && + reusableActivation + && reusableActivation.provider === activeProviderId + && !blockedCountryIds.has(normalizeBlockedCountryId(reusableActivation.countryId)) + && allowedCountryIds.has(normalizeBlockedCountryId(reusableActivation.countryId)) + && reusableActivation.successfulUses < reusableActivation.maxUses + ) { try { const reactivated = await reactivatePhoneActivation(state, preferredActivation); await addLog( - `Step 9: using preferred number ${reactivated.phoneNumber}${reactivated.countryId ? ` (${resolveCountryLabelById(reactivated.countryId)})` : ''}.`, + `步骤 9:复用 ${resolveCountryLabelById(reactivated.countryId)} 号码 ${reactivated.phoneNumber}(第 ${reactivated.successfulUses + 1}/${reactivated.maxUses} 次)。`, 'info' ); await resetPhoneNoSupplyFailureStreak(state); return reactivated; } catch (error) { - failedPreferredActivation = preferredActivation; - await removeReusableActivationFromPool(preferredActivation, { state }).catch(() => {}); - await addLog( - `Step 9: preferred number ${preferredActivation.phoneNumber} is unavailable, falling back to a new number. ${error.message}`, - 'warn' - ); - } - } - const reuseEnabled = normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled); - const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); - const reusableActivationPool = readReusableActivationPoolFromState(state); - const reusableCandidates = []; - const seenReusableKeys = new Set(); - const pushReusableCandidate = (candidate) => { - const normalizedCandidate = normalizeActivation(candidate); - if (!normalizedCandidate) { - return; - } - const candidateKey = buildActivationIdentityKey(normalizedCandidate); - if (!candidateKey || seenReusableKeys.has(candidateKey)) { - return; - } - seenReusableKeys.add(candidateKey); - reusableCandidates.push(normalizedCandidate); - }; - pushReusableCandidate(reusableActivation); - reusableActivationPool.forEach((candidate) => pushReusableCandidate(candidate)); - - if (reuseEnabled && (provider === PHONE_SMS_PROVIDER_HERO || provider === PHONE_SMS_PROVIDER_5SIM)) { - for (const candidateActivation of reusableCandidates) { - if (candidateActivation.provider !== provider) { - continue; - } - if (isSameActivation(candidateActivation, failedPreferredActivation)) { - continue; - } - if (candidateActivation.successfulUses >= candidateActivation.maxUses) { - continue; - } - if (blockedCountryIds.has(normalizeCountryKey(candidateActivation.countryId))) { - continue; - } - if (!allowedCountryIds.has(normalizeCountryKey(candidateActivation.countryId))) { - continue; - } - try { - const reactivated = await reactivatePhoneActivation(state, candidateActivation); - await addLog( - `Step 9: reusing ${resolveCountryLabelById(reactivated.countryId)} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`, - 'info' - ); - await resetPhoneNoSupplyFailureStreak(state); - return reactivated; - } catch (error) { - await addLog(`Step 9: failed to reuse phone number ${candidateActivation.phoneNumber}, falling back to a new number. ${error.message}`, 'warn'); - await removeReusableActivationFromPool(candidateActivation, { state }).catch(() => {}); - if (isSameActivation(reusableActivation, candidateActivation)) { - await clearReusableActivation(); - } - } + await addLog(`步骤 9:复用号码 ${reusableActivation.phoneNumber} 失败,将改为获取新号码。${error.message}`, 'warn'); + await clearReusableActivation(); } } - let lastProviderError = null; - const providerErrors = []; - const skippedFallbackProviders = []; - for (const providerCandidate of providerOrder) { - const useBlockedCountryIds = providerCandidate === provider - ? Array.from(blockedCountryIds) - : []; - const useCountryPriceFloorByCountryId = ( - providerCandidate === provider - && options?.countryPriceFloorByCountryId - && typeof options.countryPriceFloorByCountryId === 'object' - ) - ? options.countryPriceFloorByCountryId - : {}; - try { - const activation = await requestPhoneActivation( - scopedStateForProvider(providerCandidate), - { - blockedCountryIds: useBlockedCountryIds, - countryPriceFloorByCountryId: useCountryPriceFloorByCountryId, - } - ); - const providerLabel = providerCandidate === PHONE_SMS_PROVIDER_5SIM - ? '5sim' - : (providerCandidate === PHONE_SMS_PROVIDER_NEXSMS ? 'NexSMS' : HERO_SMS_SERVICE_LABEL); - const providerCountryLabel = providerCandidate === provider - ? resolveCountryLabelById(activation.countryId) - : String(activation?.countryLabel || activation?.countryId || '').trim(); - if (providerCandidate !== provider) { - await addLog( - `Step 9: primary provider ${provider} has no usable number, fallback succeeded on ${providerLabel}${providerCountryLabel ? ` / ${providerCountryLabel}` : ''}.`, - 'warn' - ); - } - await addLog( - `Step 9: acquired ${providerLabel}${providerCountryLabel ? ` / ${providerCountryLabel}` : ''} number ${activation.phoneNumber}.`, - 'info' - ); - await resetPhoneNoSupplyFailureStreak(state); - return activation; - } catch (error) { - if (isStopRequestedError(error)) { - throw error; - } - const providerErrorMessage = String(error?.message || error || 'unknown error'); - const providerLabel = providerCandidate === PHONE_SMS_PROVIDER_5SIM - ? '5sim' - : (providerCandidate === PHONE_SMS_PROVIDER_NEXSMS ? 'NexSMS' : HERO_SMS_SERVICE_LABEL); - if ( - providerCandidate !== provider - && /step\s*9:\s*(?:5sim|nexsms)\s+countries\s+are\s+empty/i.test(providerErrorMessage) - ) { - skippedFallbackProviders.push(`${providerLabel}: countries are empty`); - await addLog( - `Step 9: skipping fallback provider ${providerLabel} because countries are empty in 接码设置。`, - 'warn' - ); - continue; - } - lastProviderError = error; - providerErrors.push(`${providerCandidate}: ${providerErrorMessage}`); - } - } - - if (providerErrors.length) { - await logNoSupplyDiagnostics(state, providerOrder, providerErrors); - const skippedSuffix = skippedFallbackProviders.length - ? ` | skipped fallback providers: ${skippedFallbackProviders.join('; ')}` - : ''; - throw new Error(`Step 9: all provider candidates failed to acquire number. ${providerErrors.join(' | ')}${skippedSuffix}`); - } - throw lastProviderError || new Error('Step 9: failed to acquire phone activation.'); + const activation = await requestPhoneActivation(state, { blockedCountryIds: Array.from(blockedCountryIds) }); + await addLog( + `步骤 9:已从 ${getPhoneSmsProviderLabel(activation.provider)} 获取 ${resolveCountryLabelById(activation.countryId)} 号码 ${activation.phoneNumber}。`, + 'info' + ); + return activation; } async function markActivationReusableAfterSuccess(state, activation) { const normalizedActivation = normalizeActivation(activation); - const reusableProvider = normalizedActivation?.provider; - const canPersistReusableActivation = reusableProvider === PHONE_SMS_PROVIDER_HERO - || reusableProvider === PHONE_SMS_PROVIDER_5SIM; - if (!canPersistReusableActivation) { + if (!isPhoneSmsReuseEnabled(state)) { await clearReusableActivation(); return; } @@ -3705,7 +3625,7 @@ async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { const normalizedActivation = normalizeActivation(activation); if (!normalizedActivation) { - throw new Error('Phone activation is missing.'); + throw new Error('缺少手机号接码订单。'); } const providerLabel = normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM ? '5sim' @@ -3723,14 +3643,14 @@ for (let windowIndex = 1; windowIndex <= timeoutWindows; windowIndex += 1) { await setPhoneRuntimeCountdown(normalizedActivation, waitSeconds, windowIndex, timeoutWindows); await addLog( - `Step 9: waiting up to ${waitSeconds} seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/${timeoutWindows}).`, + `步骤 9:等待号码 ${normalizedActivation.phoneNumber} 接收短信,最长 ${waitSeconds} 秒(第 ${windowIndex}/${timeoutWindows} 轮)。`, 'info' ); try { const code = await pollPhoneActivationCode(state, normalizedActivation, { actionLabel: windowIndex === 1 - ? `poll phone verification code from ${providerLabel}` - : `poll resent phone verification code from ${providerLabel}`, + ? '从接码平台轮询手机验证码' + : '从接码平台轮询重发后的手机验证码', timeoutMs: waitSeconds * 1000, intervalMs: pollIntervalSeconds * 1000, maxRounds: pollMaxRounds, @@ -3746,7 +3666,7 @@ lastLoggedStatus = statusText; lastLoggedPollCount = pollCount; await addLog( - `Step 9: ${providerLabel} status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed, round ${pollCount}/${pollMaxRounds}).`, + `步骤 9:${getPhoneSmsProviderLabel(normalizedActivation.provider)} 号码 ${normalizedActivation.phoneNumber} 状态:${statusText}(已等待 ${Math.ceil(elapsedMs / 1000)} 秒,第 ${pollCount}/${pollMaxRounds} 次轮询)。`, 'info' ); }, @@ -3775,7 +3695,7 @@ if (windowIndex < timeoutWindows) { await addLog( - `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within ${waitSeconds} seconds, requesting another SMS.`, + `步骤 9:号码 ${normalizedActivation.phoneNumber} 在 ${waitSeconds} 秒内未收到短信,正在请求再次发送。`, 'warn' ); if (!usePageResend) { @@ -3788,7 +3708,7 @@ await requestAdditionalPhoneSms(state, normalizedActivation); if (resendTriggeredForCurrentNumber) { await addLog( - `Step 9: resend already used once for ${normalizedActivation.phoneNumber}; continue polling without another page resend to avoid rate limit.`, + `步骤 9:号码 ${normalizedActivation.phoneNumber} 已触发过一次页面重发;为避免限流,将继续轮询不再点击重发。`, 'warn' ); continue; @@ -3796,14 +3716,14 @@ try { await resendPhoneVerificationCode(tabId); resendTriggeredForCurrentNumber = true; - await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info'); + await addLog('步骤 9:已点击手机验证码页面的“重新发送短信”。', 'info'); } catch (resendError) { if (isStopRequestedError(resendError)) { throw resendError; } if (isPhoneResendThrottledError(resendError)) { await addLog( - `Step 9: resend is throttled for ${normalizedActivation.phoneNumber}, replacing number immediately. ${resendError.message}`, + `步骤 9:号码 ${normalizedActivation.phoneNumber} 重发短信被限流,立即更换号码。${resendError.message}`, 'warn' ); await clearPhoneRuntimeCountdown(); @@ -3813,25 +3733,13 @@ reason: 'resend_throttled', }; } - if (isPhoneRoute405RecoveryError(resendError)) { - await addLog( - `Step 9: phone verification page is stuck on route-405 retry loop for ${normalizedActivation.phoneNumber}, replacing number immediately. ${resendError.message}`, - 'warn' - ); - await clearPhoneRuntimeCountdown(); - return { - code: '', - replaceNumber: true, - reason: 'route_405_retry_loop', - }; - } - await addLog(`Step 9: failed to click resend on the phone verification page. ${resendError.message}`, 'warn'); + await addLog(`步骤 9:点击手机验证码页面重发按钮失败。${resendError.message}`, 'warn'); } continue; } await addLog( - `Step 9: no SMS for ${normalizedActivation.phoneNumber} after ${timeoutWindows} window(s), replacing the number inside step 9.`, + `步骤 9:号码 ${normalizedActivation.phoneNumber} 连续 ${timeoutWindows} 轮未收到短信,将在步骤 9 内更换号码。`, 'warn' ); await clearPhoneRuntimeCountdown(); @@ -3843,7 +3751,7 @@ } } - throw new Error('Phone verification did not complete successfully.'); + throw new Error('手机号验证未完成。'); } async function completePhoneVerificationFlow(tabId, initialPageState = null) { @@ -3958,182 +3866,51 @@ return latest; }; - const getCountryFailureCount = (countryId) => { - const countryKey = normalizeCountryFailureKey(countryId); - if (!countryKey) { + const getCountryFailureKey = (countryId, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => ( + normalizePhoneSmsProvider(providerId) === PHONE_SMS_PROVIDER_FIVE_SIM + ? normalizeFiveSimCountryId(countryId, '') + : String(normalizeCountryId(countryId, 0) || '') + ); + + const getCountryFailureCount = (countryId, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => { + const normalizedCountryId = getCountryFailureKey(countryId, providerId); + if (!normalizedCountryId) { return 0; } return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(countryKey)) || 0)); }; - const markCountrySmsFailure = async (countryId, reason = 'sms_timeout') => { - const countryKey = normalizeCountryFailureKey(countryId); - if (!countryKey) { + const markCountrySmsFailure = async (countryId, reason = 'sms_timeout', providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => { + const normalizedCountryId = getCountryFailureKey(countryId, providerId); + if (!normalizedCountryId) { return; } const nextCount = getCountryFailureCount(countryKey) + 1; countrySmsFailureCounts.set(countryKey, nextCount); if (nextCount >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) { - const countryLabel = resolveCountryLabelByFailureKey(countryKey); + const matched = resolveCountryCandidatesForProvider(state, providerId) + .find((entry) => getCountryFailureKey(entry.id, providerId) === normalizedCountryId); + const countryLabel = matched?.label || (providerId === PHONE_SMS_PROVIDER_FIVE_SIM ? normalizedCountryId : `Country #${normalizedCountryId}`); await addLog( - `Step 9: ${countryLabel} reached ${nextCount} SMS failures (${reason}); next acquisition will fallback to other selected country candidates first.`, + `步骤 9:${countryLabel} 已累计 ${nextCount} 次短信失败(${formatStep9Reason(reason)});下次获取号码会优先尝试其它已选国家。`, 'warn' ); } }; - const clearCountrySmsFailure = (countryId) => { - const countryKey = normalizeCountryFailureKey(countryId); - if (!countryKey) { + const clearCountrySmsFailure = (countryId, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => { + const normalizedCountryId = getCountryFailureKey(countryId, providerId); + if (!normalizedCountryId) { return; } countrySmsFailureCounts.delete(countryKey); countryPriceFloorByKey.delete(countryKey); }; - const getBlockedCountryIds = () => { - const activeProvider = normalizePhoneSmsProvider( - state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER - ); - return Array.from(countrySmsFailureCounts.entries()) - .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) - .map(([countryKey]) => splitCountryFailureKey(countryKey, activeProvider)) - .filter((entry) => entry.provider === activeProvider) - .map((entry) => String(entry.countryKey || '').trim()) - .filter(Boolean); - }; - - const getCountryPriceFloorById = () => { - const activeProvider = normalizePhoneSmsProvider( - state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER - ); - const floorById = {}; - countryPriceFloorByKey.forEach((price, compoundCountryKey) => { - const numeric = normalizeHeroSmsPrice(price); - if (numeric === null || numeric <= 0) { - return; - } - const parsed = splitCountryFailureKey(compoundCountryKey, activeProvider); - if (parsed.provider !== activeProvider) { - return; - } - const keyPart = String(parsed.countryKey || '').trim(); - if (!keyPart) { - return; - } - floorById[keyPart] = Math.round(numeric * 10000) / 10000; - }); - return floorById; - }; - - const setCountryPriceFloorFromActivation = async (activationCandidate, reason = '') => { - const normalizedActivation = normalizeActivation(activationCandidate); - if (!normalizedActivation) { - return; - } - const countryKey = normalizeCountryFailureKey( - normalizedActivation.countryId, - normalizedActivation.provider - ); - if (!countryKey) { - return; - } - const floorPrice = normalizeHeroSmsPrice( - normalizedActivation.price - ?? normalizedActivation.maxPrice - ?? normalizedActivation.selectedPrice - ?? getActivationAcquiredPriceHint(normalizedActivation) - ); - if (floorPrice === null || floorPrice <= 0) { - return; - } - const currentFloor = normalizeHeroSmsPrice(countryPriceFloorByKey.get(countryKey)); - if (currentFloor !== null && currentFloor >= floorPrice) { - return; - } - const normalizedFloor = Math.round(floorPrice * 10000) / 10000; - countryPriceFloorByKey.set(countryKey, normalizedFloor); - const countryLabel = resolveCountryLabelByFailureKey(countryKey, normalizedActivation.provider); - await addLog( - `Step 9: ${countryLabel} will try a higher price tier (> ${normalizedFloor}) due to ${reason || 'sms timeout'}.`, - 'warn' - ); - }; - - const isPreferredActivation = (activationCandidate, stateSnapshot = {}) => ( - isSameActivation( - stateSnapshot?.[PREFERRED_PHONE_ACTIVATION_STATE_KEY], - activationCandidate - ) - ); - - const markPreferredActivationExhausted = async (reason = '') => { - if (preferredActivationExhausted || !activation || !isPreferredActivation(activation, state)) { - return; - } - preferredActivationExhausted = true; - await addLog( - `Step 9: preferred number ${activation.phoneNumber} failed (${reason || 'unknown reason'}), falling back to a new number.`, - 'warn' - ); - }; - - const rotateActivationAfterAddPhoneFailure = async (failureReason, failureCode, submitState = {}) => { - await markPreferredActivationExhausted(failureCode || failureReason); - usedNumberReplacementAttempts += 1; - if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { - throw new Error( - `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: ${failureCode || 'add_phone_rejected'}.` - ); - } - await addLog( - `Step 9: replacing number after add-phone failure (${failureReason}) (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, - 'warn' - ); - if (shouldCancelActivation && activation) { - await cancelPhoneActivation(state, activation); - } - await clearCurrentActivation(); - activation = null; - shouldCancelActivation = false; - preferReuseExistingActivationOnAddPhone = false; - addPhoneReentryWithSameActivation = 0; - let addPhoneSnapshot = { - ...pageState, - ...submitState, - addPhonePage: true, - phoneVerificationPage: false, - }; - try { - const returned = await returnToAddPhone(tabId); - addPhoneSnapshot = { - ...addPhoneSnapshot, - ...returned, - addPhonePage: true, - phoneVerificationPage: false, - }; - } catch (returnError) { - await addLog( - `Step 9: failed to return to add-phone page after rejection, will continue with best-effort state. ${returnError.message}`, - 'warn' - ); - } - try { - const verified = await ensureAddPhonePageBeforeSubmit('after add-phone rejection'); - addPhoneSnapshot = { - ...addPhoneSnapshot, - ...verified, - addPhonePage: true, - phoneVerificationPage: false, - }; - } catch (verifyError) { - await addLog( - `Step 9: failed to verify add-phone state after rejection. ${verifyError.message}`, - 'warn' - ); - } - pageState = addPhoneSnapshot; - }; + const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries()) + .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) + .map(([countryId]) => countryId) + .filter(Boolean); try { while (true) { @@ -4165,11 +3942,11 @@ usedNumberReplacementAttempts += 1; if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { throw new Error( - `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: returned_to_add_phone_loop.` + `步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason('returned_to_add_phone_loop')}。` ); } await addLog( - `Step 9: current number ${activation.phoneNumber} returned to add-phone repeatedly, replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + `步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`, 'warn' ); if (shouldCancelActivation && activation) { @@ -4188,7 +3965,7 @@ continue; } await addLog( - `Step 9: add-phone returned, re-submitting current number ${activation.phoneNumber} before requesting a new number.`, + `步骤 9:页面返回添加手机号,将先重新提交当前号码 ${activation.phoneNumber},暂不获取新号码。`, 'warn' ); } @@ -4211,16 +3988,36 @@ if (submitResult.addPhoneRejected) { const addPhoneRejectText = String(submitResult.errorText || submitResult.url || 'unknown error'); if (isPhoneNumberUsedError(addPhoneRejectText)) { - await rotateActivationAfterAddPhoneFailure( - `add-phone rejected ${activation.phoneNumber} as already used (${addPhoneRejectText})`, - 'phone_number_used', - submitResult + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw new Error( + `步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason('phone_number_used')}。` + ); + } + + await addLog( + `步骤 9:添加手机号页面提示 ${activation.phoneNumber} 已被使用(${addPhoneRejectText}),正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`, + 'warn' ); + if (shouldCancelActivation && activation) { + await banPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; + pageState = { + ...pageState, + ...submitResult, + addPhonePage: true, + phoneVerificationPage: false, + }; continue; } await addLog( - `Step 9: add-phone rejected current number but did not mark it as used (${addPhoneRejectText}), retrying once with the same number.`, + `步骤 9:添加手机号页面拒绝当前号码,但未明确提示已使用(${addPhoneRejectText}),将用同一号码再试一次。`, 'warn' ); let retrySubmitError = null; @@ -4245,12 +4042,12 @@ continue; } throw new Error( - `Step 9: add-phone keeps rejecting current number without explicit "used" status: ${retryRejectText}.` + `步骤 9:添加手机号页面持续拒绝当前号码,但没有明确“已使用”状态:${submitResult.errorText || submitResult.url || '未知错误'}。` ); } } - await addLog('Step 9: submitted the phone number on add-phone page.', 'info'); + await addLog('步骤 9:已在添加手机号页面提交号码。', 'info'); pageState = { ...pageState, ...submitResult, @@ -4270,7 +4067,7 @@ } if (!activation) { - throw new Error('The auth page is waiting for a phone verification code, but no HeroSMS activation is stored for this run.'); + throw new Error('认证页面正在等待手机验证码,但当前运行没有保存手机号接码订单。'); } let shouldReplaceNumber = false; @@ -4290,49 +4087,12 @@ await setPhoneRuntimeState({ [PHONE_VERIFICATION_CODE_STATE_KEY]: String(codeResult.code || '').trim(), }); - await addLog(`Step 9: received phone verification code ${codeResult.code}.`, 'info'); - let submitResult = null; - let submitError = null; - try { - submitResult = await submitPhoneVerificationCode(tabId, codeResult.code); - } catch (error) { - submitError = error; - } - if (submitError) { - const submitErrorText = String(submitError?.message || submitError || 'unknown error'); - if (isPhoneNumberUsedError(submitErrorText)) { - shouldReplaceNumber = true; - replaceReason = 'phone_number_used'; - await addLog( - `Step 9: phone verification failed with used-number signal (${submitErrorText}), replacing with a new number immediately.`, - 'warn' - ); - break; - } - if (isPhoneNumberInvalidError(submitErrorText)) { - shouldReplaceNumber = true; - replaceReason = 'phone_number_invalid'; - await addLog( - `Step 9: phone verification failed with invalid-number signal (${submitErrorText}), replacing with a new number immediately.`, - 'warn' - ); - break; - } - if (isPhoneRoute405RecoveryError(submitErrorText)) { - shouldReplaceNumber = true; - replaceReason = 'route_405_retry_loop'; - await addLog( - `Step 9: phone verification page entered route-405 retry loop (${submitErrorText}), replacing with a new number immediately.`, - 'warn' - ); - break; - } - throw submitError; - } + await addLog(`步骤 9:已收到手机验证码 ${codeResult.code}。`, 'info'); + const submitResult = await submitPhoneVerificationCode(tabId, codeResult.code); if (submitResult.returnedToAddPhone) { await addLog( - 'Step 9: phone verification returned to add-phone after code submission, will try current number first.', + '步骤 9:提交验证码后返回添加手机号页面,将先重试当前号码。', 'warn' ); preferReuseExistingActivationOnAddPhone = true; @@ -4350,8 +4110,12 @@ if (isPhoneNumberUsedError(invalidErrorText)) { shouldReplaceNumber = true; replaceReason = 'phone_number_used'; + if (shouldCancelActivation && activation) { + await banPhoneActivation(state, activation); + shouldCancelActivation = false; + } await addLog( - `Step 9: phone number was rejected as already used (${invalidErrorText}), replacing with a new number immediately.`, + `步骤 9:手机号被提示已使用(${invalidErrorText}),立即更换新号码。`, 'warn' ); break; @@ -4360,8 +4124,12 @@ if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { shouldReplaceNumber = true; replaceReason = 'code_rejected'; + if (shouldCancelActivation && activation) { + await banPhoneActivation(state, activation); + shouldCancelActivation = false; + } await addLog( - `Step 9: phone verification code was rejected ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} times (${invalidErrorText}), replacing the number.`, + `步骤 9:手机验证码连续 ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} 次被拒(${invalidErrorText}),将更换号码。`, 'warn' ); break; @@ -4372,41 +4140,20 @@ await requestAdditionalPhoneSms(state, activation); try { await resendPhoneVerificationCode(tabId); - await addLog('Step 9: clicked "Resend text message" after the phone code was rejected.', 'info'); + await addLog('步骤 9:手机验证码被拒后已点击“重新发送短信”。', 'info'); } catch (resendError) { - if (isStopRequestedError(resendError)) { - throw resendError; - } - if (isPhoneResendThrottledError(resendError)) { - shouldReplaceNumber = true; - replaceReason = 'resend_throttled'; - await addLog( - `Step 9: resend is throttled after code rejection (${resendError.message}), replacing number immediately.`, - 'warn' - ); - break; - } - if (isPhoneRoute405RecoveryError(resendError)) { - shouldReplaceNumber = true; - replaceReason = 'route_405_retry_loop'; - await addLog( - `Step 9: phone verification page entered route-405 retry loop after code rejection (${resendError.message}), replacing number immediately.`, - 'warn' - ); - break; - } - await addLog(`Step 9: failed to click resend after code rejection. ${resendError.message}`, 'warn'); + await addLog(`步骤 9:验证码被拒后点击重发失败。${resendError.message}`, 'warn'); } if (shouldReplaceNumber) { break; } await addLog( - `Step 9: phone verification code was rejected, requested another SMS (${remainingResendRequests} resend attempts left).`, + `步骤 9:手机验证码被拒,已请求再次发送短信(剩余 ${remainingResendRequests} 次重发)。`, 'warn' ); } else { await addLog( - 'Step 9: phone verification code was rejected and the configured resend budget is exhausted, retrying with the current activation window.', + '步骤 9:手机验证码被拒,配置的重发次数已用完,将在当前接码窗口内继续重试。', 'warn' ); } @@ -4415,11 +4162,11 @@ await completePhoneActivation(state, activation); await markActivationReusableAfterSuccess(state, activation); - clearCountrySmsFailure(activation.countryId); + clearCountrySmsFailure(activation.countryId, activation.provider); shouldCancelActivation = false; await clearCurrentActivation(); addPhoneReentryWithSameActivation = 0; - await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); + await addLog('步骤 9:手机号验证已完成,等待 OAuth 授权页。', 'ok'); return submitResult; } @@ -4427,7 +4174,7 @@ if (pageState?.addPhonePage) { continue; } - throw new Error('Phone verification did not complete successfully.'); + throw new Error('手机号验证未完成。'); } if ( @@ -4438,25 +4185,14 @@ || /^sms_timeout_after_/i.test(String(replaceReason || '')) ) ) { - await setCountryPriceFloorFromActivation(activation, replaceReason || 'sms_timeout'); - if ( - replaceReason === 'resend_throttled' - || replaceReason === 'route_405_retry_loop' - ) { - await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout'); - } else if (/^sms_timeout_after_/i.test(String(replaceReason || ''))) { - await addLog( - `Step 9: ${activation.countryLabel || activation.countryId || 'current country'} timed out on SMS; keep the same provider/country for number replacement first and avoid country block on timeout.`, - 'info' - ); - } + await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout', activation.provider); } await markPreferredActivationExhausted(replaceReason || 'replace_number'); usedNumberReplacementAttempts += 1; if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { throw new Error( - `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: ${replaceReason || 'unknown'}.` + `步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason(replaceReason)}。` ); } @@ -4476,7 +4212,7 @@ try { returnResult = await returnToAddPhone(tabId); } catch (returnError) { - await addLog(`Step 9: failed to return to add-phone page before replacing number. ${returnError.message}`, 'warn'); + await addLog(`步骤 9:更换号码前返回添加手机号页面失败。${returnError.message}`, 'warn'); } if (!returnResult?.addPhonePage) { @@ -4510,7 +4246,7 @@ } await addLog( - `Step 9: replacing number and retrying inside step 9 (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + `步骤 9:正在更换号码并在步骤 9 内重试(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`, 'warn' ); pageState = { diff --git a/docs/local-customizations-maintenance.md b/docs/local-customizations-maintenance.md index bb72e95..a0cf56a 100644 --- a/docs/local-customizations-maintenance.md +++ b/docs/local-customizations-maintenance.md @@ -14,7 +14,8 @@ 4. Plus 免费试用资格提前判断,以及跳过账号仍标记“已用”。 5. SUB2API 多分组创建账号。 6. OAuth 登录使用全新标签页。 -7. 本地导出配置 `/config.json` 忽略规则。 +7. 接码平台多平台适配:HeroSMS + 5sim。 +8. 本地导出配置 `/config.json` 忽略规则。 推荐每次同步后执行: @@ -37,6 +38,8 @@ cc7671e feat(sub2api): 支持多分组创建账号 b3790c1 fix(auth): OAuth 登录使用全新标签页 ``` +补充:接码平台多平台适配是当前分支新增的本地自定义功能;真实 5sim API Key 只允许通过侧边栏输入并保存到本地配置,不写入仓库、测试或文档。 + 排查本地定制范围可用: ```bash @@ -304,7 +307,71 @@ OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染 上游如果改 OAuth tab 打开方式,确认仍保留“全新标签页”语义,不要退回到复用已有 tab。 -## 7. 本地配置文件忽略 +## 7. 接码平台多平台适配:HeroSMS + 5sim + +### 目标 + +手机号验证 Step 9 支持按平台切换接码能力: + +- 默认仍为 `HeroSMS`,老配置无需迁移。 +- 新增 `5sim` 平台,下拉框在接码设置卡片中直接可见。 +- 国家/地区列表、API Key、价格上限、余额、买号、查码、完成、取消、ban、复用均按当前 `phoneSmsProvider` 分发。 +- HeroSMS 继续使用原 `heroSms*` 字段;5sim 使用独立 `fiveSim*` 字段,切换平台时不会互相覆盖草稿。 + +### 关键文件 + +| 文件 | 作用 | +| --- | --- | +| `phone-sms/providers/hero-sms.js` | HeroSMS 适配层,承接 HeroSMS 余额、价格、国家规范化等平台逻辑。 | +| `phone-sms/providers/five-sim.js` | 5sim 适配层,封装 profile、countries、prices、buy、check、finish、cancel、ban、reuse。 | +| `phone-sms/providers/registry.js` | 接码平台注册表,按 `phoneSmsProvider` 选择平台模块。 | +| `background.js` | 持久字段默认值、导入脚本、配置规范化、phone flow helper 注入。 | +| `background/phone-verification-flow.js` | Step 9 运行态按 activation.provider 分发后续查码/完成/取消/ban/reuse。 | +| `sidepanel/sidepanel.html` | 接码平台下拉、余额按钮、5sim operator 输入。 | +| `sidepanel/sidepanel.js` | 平台切换、地区列表联动、价格/余额查询、配置收集与恢复。 | +| `tests/five-sim-provider.test.js` | 5sim provider 单测。 | +| `tests/phone-verification-flow.test.js` | 5sim Step 9 buy/check/finish/reuse 分发测试。 | +| `tests/sidepanel-phone-verification-settings.test.js` | 接码平台下拉和侧边栏联动测试。 | + +### 关键状态字段 + +- 通用: + - `phoneSmsProvider: "hero-sms" | "5sim"` +- HeroSMS: + - `heroSmsApiKey` + - `heroSmsCountryId` + - `heroSmsCountryLabel` + - `heroSmsCountryFallback` + - `heroSmsMaxPrice` + - `heroSmsReuseEnabled` + - `heroSmsAcquirePriority` +- 5sim: + - `fiveSimApiKey` + - `fiveSimCountryId` + - `fiveSimCountryLabel` + - `fiveSimCountryFallback` + - `fiveSimMaxPrice` + - `fiveSimOperator` + +### 维护注意 + +1. 不要把真实 5sim key 写入代码、测试或文档;只允许用户在侧边栏输入。 +2. 上游同步时如果改了 Step 9 手机号验证,必须保留 `currentPhoneActivation.provider` 驱动的后续分发。 +3. 上游同步时如果改了 sidepanel 接码区域,必须保留 `select-phone-sms-provider`,且地区列表按平台加载:HeroSMS 数字国家 ID,5sim 字符串 country slug。 +4. 5sim 产品码当前固定为 `openai`,operator 默认 `any`;价格上限为空时按价格目录最低可用价尝试。 + +### 验证 + +```bash +node --check phone-sms/providers/hero-sms.js +node --check phone-sms/providers/five-sim.js +node --check phone-sms/providers/registry.js +node --check background/phone-verification-flow.js +node --check sidepanel/sidepanel.js +node --test tests/five-sim-provider.test.js tests/phone-verification-flow.test.js tests/sidepanel-phone-verification-settings.test.js +``` + +## 8. 本地配置文件忽略 ### 目标 @@ -388,3 +455,13 @@ rg -n "normalizeSub2ApiGroupNames|getGroupsByNames|sub2apiGroupIds|input-sub2api tests ``` +检查接码多平台适配: + +```bash +rg -n "phoneSmsProvider|select-phone-sms-provider|PhoneSmsFiveSimProvider|PhoneSmsProviderRegistry|fiveSim" \ + background.js \ + background/phone-verification-flow.js \ + phone-sms \ + sidepanel \ + tests +``` diff --git a/phone-sms/providers/five-sim.js b/phone-sms/providers/five-sim.js new file mode 100644 index 0000000..207deea --- /dev/null +++ b/phone-sms/providers/five-sim.js @@ -0,0 +1,822 @@ +// phone-sms/providers/five-sim.js — 5sim 接码平台适配层 +(function attachFiveSimSmsProvider(root, factory) { + root.PhoneSmsFiveSimProvider = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createFiveSimSmsProviderModule() { + const PROVIDER_ID = '5sim'; + const DEFAULT_BASE_URL = 'https://5sim.net'; + const DEFAULT_PRODUCT = 'openai'; + const DEFAULT_OPERATOR = 'any'; + const DEFAULT_COUNTRY_ID = 'england'; + const DEFAULT_COUNTRY_LABEL = '英国 (England)'; + const DEFAULT_REQUEST_TIMEOUT_MS = 20000; + const DEFAULT_MAX_USES = 3; + const MAX_PRICE_CANDIDATES = 8; + const COUNTRY_CN_BY_ID = Object.freeze({ + afghanistan: '阿富汗', + albania: '阿尔巴尼亚', + algeria: '阿尔及利亚', + angola: '安哥拉', + argentina: '阿根廷', + armenia: '亚美尼亚', + australia: '澳大利亚', + austria: '奥地利', + azerbaijan: '阿塞拜疆', + bahamas: '巴哈马', + bahrain: '巴林', + bangladesh: '孟加拉国', + belarus: '白俄罗斯', + belgium: '比利时', + bolivia: '玻利维亚', + bosnia: '波黑', + brazil: '巴西', + bulgaria: '保加利亚', + cambodia: '柬埔寨', + cameroon: '喀麦隆', + canada: '加拿大', + chile: '智利', + china: '中国', + colombia: '哥伦比亚', + croatia: '克罗地亚', + cyprus: '塞浦路斯', + czech: '捷克', + denmark: '丹麦', + egypt: '埃及', + england: '英国', + estonia: '爱沙尼亚', + ethiopia: '埃塞俄比亚', + finland: '芬兰', + france: '法国', + georgia: '格鲁吉亚', + germany: '德国', + ghana: '加纳', + greece: '希腊', + hongkong: '中国香港', + hungary: '匈牙利', + india: '印度', + indonesia: '印度尼西亚', + ireland: '爱尔兰', + israel: '以色列', + italy: '意大利', + japan: '日本', + jordan: '约旦', + kazakhstan: '哈萨克斯坦', + kenya: '肯尼亚', + kyrgyzstan: '吉尔吉斯斯坦', + laos: '老挝', + latvia: '拉脱维亚', + lithuania: '立陶宛', + malaysia: '马来西亚', + mexico: '墨西哥', + moldova: '摩尔多瓦', + morocco: '摩洛哥', + myanmar: '缅甸', + nepal: '尼泊尔', + netherlands: '荷兰', + newzealand: '新西兰', + nigeria: '尼日利亚', + norway: '挪威', + pakistan: '巴基斯坦', + paraguay: '巴拉圭', + peru: '秘鲁', + philippines: '菲律宾', + poland: '波兰', + portugal: '葡萄牙', + romania: '罗马尼亚', + russia: '俄罗斯', + saudiarabia: '沙特阿拉伯', + serbia: '塞尔维亚', + singapore: '新加坡', + slovakia: '斯洛伐克', + slovenia: '斯洛文尼亚', + southafrica: '南非', + spain: '西班牙', + srilanka: '斯里兰卡', + sweden: '瑞典', + switzerland: '瑞士', + taiwan: '中国台湾', + tajikistan: '塔吉克斯坦', + tanzania: '坦桑尼亚', + thailand: '泰国', + turkey: '土耳其', + ukraine: '乌克兰', + uruguay: '乌拉圭', + usa: '美国', + uzbekistan: '乌兹别克斯坦', + venezuela: '委内瑞拉', + vietnam: '越南', + }); + + function normalizeFiveSimCountryId(value, fallback = DEFAULT_COUNTRY_ID) { + const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + return normalized || fallback; + } + + function getCountryIdFromPayload(record = {}, fallback = DEFAULT_COUNTRY_ID) { + if (record?.country && typeof record.country === 'object' && !Array.isArray(record.country)) { + return normalizeFiveSimCountryId(record.country.name || record.country.id || record.country.slug, fallback); + } + return normalizeFiveSimCountryId(record.countryId ?? record.country, fallback); + } + + function getCountryLabelFromPayload(record = {}, fallbackLabel = DEFAULT_COUNTRY_LABEL, fallbackId = DEFAULT_COUNTRY_ID) { + if (record?.country && typeof record.country === 'object' && !Array.isArray(record.country)) { + const countryId = normalizeFiveSimCountryId(record.country.name || record.country.id || record.country.slug || fallbackId, fallbackId); + return formatFiveSimCountryLabel(countryId, record.country.text_en || record.country.label || countryId, fallbackLabel || fallbackId); + } + const countryId = normalizeFiveSimCountryId(record.countryId ?? record.country ?? fallbackId, fallbackId); + return normalizeFiveSimCountryLabel(record.countryLabel, formatFiveSimCountryLabel(countryId, countryId, fallbackLabel || fallbackId)); + } + + function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) { + return String(value || '').trim() || fallback; + } + + function formatFiveSimCountryLabel(id = '', englishValue = '', fallback = DEFAULT_COUNTRY_LABEL) { + const countryId = normalizeFiveSimCountryId(id, ''); + const english = normalizeFiveSimCountryLabel(englishValue || countryId || fallback, fallback); + const chinese = COUNTRY_CN_BY_ID[countryId] || ''; + if (chinese && english && chinese.toLowerCase() !== english.toLowerCase() && !String(english).includes(chinese)) { + return `${chinese} (${english})`; + } + return chinese || english; + } + + function normalizeFiveSimOperator(value = '') { + return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || DEFAULT_OPERATOR; + } + + function normalizeFiveSimMaxPrice(value = '') { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return ''; + } + const numeric = Number(rawValue); + if (!Number.isFinite(numeric) || numeric <= 0) { + return ''; + } + return String(Math.round(numeric * 10000) / 10000); + } + + function normalizeFiveSimCountryFallback(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seen = new Set(); + const normalized = []; + + for (const entry of source) { + let id = ''; + let label = ''; + + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + id = normalizeFiveSimCountryId(entry.id ?? entry.countryId ?? entry.slug, ''); + label = String((entry.label ?? entry.countryLabel ?? entry.text_en ?? entry.name) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structured = text.match(/^([a-z0-9_-]+)\s*(?:[:|/-]\s*(.+))?$/i); + id = normalizeFiveSimCountryId(structured?.[1] || text, ''); + label = String(structured?.[2] || '').trim(); + } + + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + normalized.push({ + id, + label: label || formatFiveSimCountryLabel(id, id, id), + }); + if (normalized.length >= 20) { + break; + } + } + + return normalized; + } + + function normalizePrice(value) { + const price = Number(value); + if (!Number.isFinite(price) || price < 0) { + return null; + } + return price; + } + + function buildSortedUniquePriceCandidates(values = []) { + return Array.from( + new Set( + values + .map((value) => normalizePrice(value)) + .filter((value) => value !== null) + .map((value) => Math.round(value * 10000) / 10000) + ) + ) + .sort((left, right) => left - right) + .slice(0, MAX_PRICE_CANDIDATES); + } + + function describePayload(raw) { + if (typeof raw === 'string') { + return raw.trim(); + } + if (raw && typeof raw === 'object') { + const direct = String(raw.message || raw.msg || raw.error || raw.title || raw.status || '').trim(); + if (direct) { + return direct; + } + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } + } + return String(raw || '').trim(); + } + + function parsePayload(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + return ''; + } + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; + } + + function normalizeBaseUrl(value = '') { + const trimmed = String(value || '').trim() || DEFAULT_BASE_URL; + try { + const url = new URL(trimmed); + return `${url.origin}${url.pathname.replace(/\/+$/, '')}`; + } catch { + return DEFAULT_BASE_URL; + } + } + + function buildUrl(config = {}, path = '', query = {}) { + const url = new URL(path, `${normalizeBaseUrl(config.baseUrl)}/`); + Object.entries(query || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + url.searchParams.set(key, String(value)); + }); + return url.toString(); + } + + async function fetchJson(config = {}, path = '', options = {}) { + const { query = {}, actionLabel = '5sim 请求', requireAuth = true } = options; + const token = String(config.apiKey || '').trim(); + if (requireAuth && !token) { + throw new Error('缺少 5sim API Key,请先在侧边栏接码设置中填写并保存。'); + } + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS) + : null; + + try { + const response = await config.fetchImpl(buildUrl(config, path, query), { + method: 'GET', + signal: controller?.signal, + headers: { + Accept: 'application/json', + ...(requireAuth ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + const text = await response.text(); + const payload = parsePayload(text); + if (!response.ok) { + const error = new Error(`${actionLabel}失败:${describePayload(payload) || response.status}`); + error.payload = payload; + error.status = response.status; + throw error; + } + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`${actionLabel}超时。`); + } + throw error; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + function resolveConfig(state = {}, deps = {}) { + return { + apiKey: String(state.fiveSimApiKey || '').trim(), + baseUrl: DEFAULT_BASE_URL, + fetchImpl: deps.fetchImpl, + requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, + }; + } + + function resolveCountryConfig(state = {}) { + return { + id: normalizeFiveSimCountryId(state.fiveSimCountryId), + label: normalizeFiveSimCountryLabel(state.fiveSimCountryLabel), + }; + } + + function resolveCountryCandidates(state = {}) { + const primary = resolveCountryConfig(state); + const fallbackList = normalizeFiveSimCountryFallback(state.fiveSimCountryFallback); + const seen = new Set([primary.id]); + const candidates = [primary]; + + fallbackList.forEach((entry) => { + const nextId = normalizeFiveSimCountryId(entry.id, ''); + if (!nextId || seen.has(nextId)) { + return; + } + seen.add(nextId); + candidates.push({ + id: nextId, + label: normalizeFiveSimCountryLabel(entry.label, nextId), + }); + }); + + return candidates; + } + + async function fetchBalance(state = {}, deps = {}) { + const config = resolveConfig(state, deps); + const payload = await fetchJson(config, '/v1/user/profile', { + actionLabel: '5sim 查询余额', + }); + return { + balance: Number(payload?.balance), + frozenBalance: Number(payload?.frozen_balance), + rating: Number(payload?.rating), + raw: payload, + }; + } + + async function fetchCountries(_state = {}, deps = {}) { + const config = { + baseUrl: DEFAULT_BASE_URL, + fetchImpl: deps.fetchImpl, + requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, + }; + const payload = await fetchJson(config, '/v1/guest/countries', { + actionLabel: '5sim 查询国家列表', + requireAuth: false, + }); + return Object.entries(payload || {}) + .map(([slug, entry]) => { + const id = normalizeFiveSimCountryId(slug, ''); + if (!id) { + return null; + } + return { + id, + label: formatFiveSimCountryLabel(id, entry?.text_en || slug, slug), + searchText: [ + slug, + formatFiveSimCountryLabel(id, entry?.text_en || slug, slug), + entry?.text_en, + entry?.text_ru, + Object.keys(entry?.iso || {}).join(' '), + Object.keys(entry?.prefix || {}).join(' '), + ].filter(Boolean).join(' '), + }; + }) + .filter(Boolean) + .sort((left, right) => left.label.localeCompare(right.label)); + } + + function collectPriceEntries(payload, entries = []) { + if (Array.isArray(payload)) { + payload.forEach((entry) => collectPriceEntries(entry, entries)); + return entries; + } + if (!payload || typeof payload !== 'object') { + return entries; + } + + const cost = normalizePrice(payload.cost ?? payload.Price ?? payload.price); + if (cost !== null) { + const count = Number(payload.count ?? payload.Qty ?? payload.qty); + entries.push({ + cost, + count: Number.isFinite(count) ? count : 0, + inStock: !Number.isFinite(count) || count > 0, + }); + } + + Object.values(payload).forEach((entry) => collectPriceEntries(entry, entries)); + return entries; + } + + async function fetchPrices(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) { + const config = { + baseUrl: DEFAULT_BASE_URL, + fetchImpl: deps.fetchImpl, + requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, + }; + return fetchJson(config, '/v1/guest/prices', { + query: { + country: normalizeFiveSimCountryId(countryConfig?.id), + product: DEFAULT_PRODUCT, + }, + actionLabel: '5sim 查询价格', + requireAuth: false, + }); + } + + async function fetchProducts(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) { + const config = { + baseUrl: DEFAULT_BASE_URL, + fetchImpl: deps.fetchImpl, + requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, + }; + const operator = normalizeFiveSimOperator(state.fiveSimOperator); + return fetchJson( + config, + `/v1/guest/products/${encodeURIComponent(normalizeFiveSimCountryId(countryConfig?.id))}/${encodeURIComponent(operator)}`, + { + actionLabel: '5sim 查询产品价格', + requireAuth: false, + } + ); + } + + async function resolvePricePlan(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) { + const userLimitText = normalizeFiveSimMaxPrice(state.fiveSimMaxPrice); + const userLimit = userLimitText ? Number(userLimitText) : null; + let priceCandidates = []; + + try { + const productsPayload = await fetchProducts(state, countryConfig, deps); + const openaiProduct = productsPayload?.[DEFAULT_PRODUCT]; + const productPrice = normalizePrice(openaiProduct?.Price ?? openaiProduct?.price ?? openaiProduct?.cost); + const productQty = Number(openaiProduct?.Qty ?? openaiProduct?.qty ?? openaiProduct?.count); + if (productPrice !== null && (!Number.isFinite(productQty) || productQty > 0)) { + priceCandidates.push(productPrice); + } + } catch (_) { + // Products endpoint is only used to find the buy-compatible operator price. + } + + try { + const payload = await fetchPrices(state, countryConfig, deps); + priceCandidates = [ + ...priceCandidates, + ...buildSortedUniquePriceCandidates( + collectPriceEntries(payload, []) + .filter((entry) => entry.inStock) + .map((entry) => entry.cost) + ), + ]; + } catch (_) { + // Best-effort lookup. Purchase can still be attempted without a catalog price. + } + priceCandidates = buildSortedUniquePriceCandidates(priceCandidates); + + const minCatalogPrice = priceCandidates.length > 0 ? priceCandidates[0] : null; + if (userLimit !== null) { + const bounded = priceCandidates.filter((price) => price <= userLimit); + return { + prices: bounded.length > 0 ? [userLimit, ...bounded.filter((price) => price !== userLimit)] : [userLimit], + userLimit, + minCatalogPrice, + }; + } + + if (priceCandidates.length > 0) { + return { prices: priceCandidates, userLimit: null, minCatalogPrice }; + } + return { prices: [null], userLimit: null, minCatalogPrice: null }; + } + + function normalizeActivation(record, fallback = {}) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + return null; + } + const activationId = String(record.activationId ?? record.id ?? '').trim(); + const phoneNumber = String(record.phoneNumber ?? record.phone ?? '').trim(); + if (!activationId || !phoneNumber) { + return null; + } + const countryId = getCountryIdFromPayload(record, fallback.countryId || DEFAULT_COUNTRY_ID); + const countryLabel = getCountryLabelFromPayload(record, fallback.countryLabel || countryId, countryId); + return { + activationId, + phoneNumber, + provider: PROVIDER_ID, + serviceCode: DEFAULT_PRODUCT, + countryId, + countryLabel, + successfulUses: Math.max(0, Math.floor(Number(record.successfulUses) || 0)), + maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_MAX_USES)), + operator: normalizeFiveSimOperator(record.operator || fallback.operator), + ...(record.price !== undefined ? { price: Number(record.price) } : {}), + ...(record.status ? { status: String(record.status) } : {}), + }; + } + + function isNoNumbersPayload(payloadOrMessage) { + const text = describePayload(payloadOrMessage); + return /no\s+free\s+phones|no\s+numbers|not\s+found/i.test(text); + } + + function isTerminalError(payloadOrMessage) { + const text = describePayload(payloadOrMessage); + return /not\s+enough\s+(?:user\s+)?balance|not\s+enough\s+rating|unauthorized|invalid\s+token|banned|bad\s+(?:country|operator)|no\s+product|server\s+offline/i.test(text); + } + + async function buyActivationWithPrice(state = {}, countryConfig, maxPrice, deps = {}) { + const config = resolveConfig(state, deps); + const operator = normalizeFiveSimOperator(state.fiveSimOperator); + const query = {}; + if (maxPrice !== null && maxPrice !== undefined) { + query.maxPrice = maxPrice; + } + if (state.fiveSimReuseEnabled !== false) { + query.reuse = 1; + } + const payload = await fetchJson( + config, + `/v1/user/buy/activation/${encodeURIComponent(normalizeFiveSimCountryId(countryConfig.id))}/${encodeURIComponent(operator)}/${DEFAULT_PRODUCT}`, + { + query, + actionLabel: '5sim 购买手机号', + } + ); + return normalizeActivation(payload, { + countryId: countryConfig.id, + countryLabel: countryConfig.label, + operator, + }); + } + + async function requestActivation(state = {}, options = {}, deps = {}) { + const allCountryCandidates = resolveCountryCandidates(state); + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeFiveSimCountryId(value, '')) + .filter(Boolean) + ); + let countryCandidates = allCountryCandidates.filter((entry) => !blockedCountryIds.has(normalizeFiveSimCountryId(entry.id, ''))); + if (!countryCandidates.length) { + countryCandidates = allCountryCandidates; + if (blockedCountryIds.size && typeof deps.addLog === 'function') { + await deps.addLog( + '步骤 9:已选 5sim 国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。', + 'warn' + ); + } + } + + const acquirePriority = String(state?.heroSmsAcquirePriority || 'country').trim().toLowerCase() === 'price' ? 'price' : 'country'; + const countryAttempts = countryCandidates.map((countryConfig, index) => ({ + index, + countryConfig, + pricePlan: null, + orderingPrice: Number.POSITIVE_INFINITY, + })); + + if (acquirePriority === 'price') { + for (const attempt of countryAttempts) { + const pricePlan = await resolvePricePlan(state, attempt.countryConfig, deps); + const numericPrices = Array.isArray(pricePlan?.prices) + ? pricePlan.prices.map(Number).filter((value) => Number.isFinite(value) && value >= 0) + : []; + const minCandidatePrice = numericPrices.length ? Math.min(...numericPrices) : null; + const cappedByUserLimit = ( + pricePlan?.userLimit !== null + && pricePlan?.userLimit !== undefined + && pricePlan?.minCatalogPrice !== null + && pricePlan?.minCatalogPrice !== undefined + && Number(pricePlan.minCatalogPrice) > Number(pricePlan.userLimit) + ); + attempt.pricePlan = pricePlan; + attempt.orderingPrice = cappedByUserLimit + ? Number.POSITIVE_INFINITY + : (minCandidatePrice !== null ? minCandidatePrice : Number.POSITIVE_INFINITY); + } + countryAttempts.sort((left, right) => ( + left.orderingPrice !== right.orderingPrice + ? left.orderingPrice - right.orderingPrice + : left.index - right.index + )); + } + + const noNumbersByCountry = []; + let lastError = null; + let lastFailureText = ''; + for (const attempt of countryAttempts) { + const countryConfig = attempt.countryConfig; + const pricePlan = attempt.pricePlan || await resolvePricePlan(state, countryConfig, deps); + for (const maxPrice of pricePlan.prices) { + try { + const activation = await buyActivationWithPrice(state, countryConfig, maxPrice, deps); + if (activation) { + return activation; + } + lastFailureText = '空响应'; + } catch (error) { + const payloadOrMessage = error?.payload || error?.message; + if (isTerminalError(payloadOrMessage)) { + throw new Error(`5sim 购买手机号失败:${describePayload(payloadOrMessage) || '空响应'}`); + } + if (isNoNumbersPayload(payloadOrMessage)) { + lastFailureText = describePayload(payloadOrMessage) || lastFailureText; + continue; + } + lastError = error; + lastFailureText = describePayload(payloadOrMessage) || lastFailureText; + } + } + noNumbersByCountry.push(`${countryConfig.label}: ${lastFailureText || '无可用号码'}`); + } + + if (noNumbersByCountry.length) { + throw new Error(`5sim 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${noNumbersByCountry.join(' | ')}。`); + } + if (lastError) { + throw lastError; + } + throw new Error(`5sim 获取手机号失败,最后状态:${lastFailureText || '未知'}。`); + } + + async function reuseActivation(state = {}, activation, deps = {}) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + throw new Error('缺少可复用的 5sim 手机号订单。'); + } + const phoneDigits = String(normalizedActivation.phoneNumber || '').replace(/\D+/g, ''); + if (!phoneDigits) { + throw new Error('可复用的 5sim 手机号无效。'); + } + const config = resolveConfig(state, deps); + const numberWithoutPlus = String(normalizedActivation.phoneNumber || '') + .replace(/^\+/, '') + .replace(/[^0-9]+/g, ''); + const payload = await fetchJson(config, `/v1/user/reuse/${DEFAULT_PRODUCT}/${numberWithoutPlus || phoneDigits}`, { + actionLabel: '5sim 复用手机号', + }); + return normalizeActivation(payload, normalizedActivation); + } + + async function finishActivation(state = {}, activation, deps = {}) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) return ''; + const config = resolveConfig(state, deps); + const payload = await fetchJson(config, `/v1/user/finish/${encodeURIComponent(normalizedActivation.activationId)}`, { + actionLabel: '5sim 完成订单', + }); + return describePayload(payload); + } + + async function cancelActivation(state = {}, activation, deps = {}) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) return ''; + const config = resolveConfig(state, deps); + const payload = await fetchJson(config, `/v1/user/cancel/${encodeURIComponent(normalizedActivation.activationId)}`, { + actionLabel: '5sim 取消订单', + }); + return describePayload(payload); + } + + async function banActivation(state = {}, activation, deps = {}) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) return ''; + const config = resolveConfig(state, deps); + const payload = await fetchJson(config, `/v1/user/ban/${encodeURIComponent(normalizedActivation.activationId)}`, { + actionLabel: '5sim 拉黑号码', + }); + return describePayload(payload); + } + + function extractVerificationCode(rawCodeOrText) { + const trimmed = String(rawCodeOrText || '').trim(); + if (!trimmed) { + return ''; + } + const digitMatch = trimmed.match(/\b(\d{4,8})\b/); + return digitMatch?.[1] || trimmed; + } + + function extractCodeFromOrder(payload) { + const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + for (let index = smsList.length - 1; index >= 0; index -= 1) { + const message = smsList[index] || {}; + const code = extractVerificationCode(message.code) || extractVerificationCode(message.text); + if (code) { + return code; + } + } + return ''; + } + + async function pollActivationCode(state = {}, activation, options = {}, deps = {}) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + throw new Error('缺少手机号接码订单。'); + } + const config = resolveConfig(state, deps); + const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 180000); + const intervalMs = Math.max(1000, Number(options.intervalMs) || 5000); + const maxRoundsRaw = Math.floor(Number(options.maxRounds)); + const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0; + const start = Date.now(); + let pollCount = 0; + let lastResponse = ''; + + while (Date.now() - start < timeoutMs) { + if (maxRounds > 0 && pollCount >= maxRounds) { + break; + } + deps.throwIfStopped?.(); + const payload = await fetchJson(config, `/v1/user/check/${encodeURIComponent(normalizedActivation.activationId)}`, { + actionLabel: '5sim 查询验证码', + }); + pollCount += 1; + lastResponse = describePayload(payload); + if (typeof options.onStatus === 'function') { + await options.onStatus({ + activation: normalizedActivation, + elapsedMs: Date.now() - start, + pollCount, + statusText: String(payload?.status || lastResponse || '未知'), + timeoutMs, + }); + } + const code = extractCodeFromOrder(payload); + if (code) { + return code; + } + const status = String(payload?.status || '').trim().toUpperCase(); + if (['CANCELED', 'BANNED', 'FINISHED', 'TIMEOUT'].includes(status)) { + throw new Error(`5sim 查询验证码失败:订单状态 ${status}`); + } + await deps.sleepWithStop(intervalMs); + } + + const suffix = lastResponse ? ` 5sim 最后状态:${lastResponse}` : ''; + throw new Error(`PHONE_CODE_TIMEOUT::等待手机验证码超时。${suffix}`); + } + + function createProvider(deps = {}) { + const providerDeps = { + fetchImpl: deps.fetchImpl, + sleepWithStop: deps.sleepWithStop, + throwIfStopped: deps.throwIfStopped, + addLog: deps.addLog, + requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, + }; + return { + id: PROVIDER_ID, + label: '5sim', + defaultCountryId: DEFAULT_COUNTRY_ID, + defaultCountryLabel: DEFAULT_COUNTRY_LABEL, + defaultProduct: DEFAULT_PRODUCT, + defaultOperator: DEFAULT_OPERATOR, + normalizeCountryId: normalizeFiveSimCountryId, + normalizeCountryLabel: normalizeFiveSimCountryLabel, + formatCountryLabel: formatFiveSimCountryLabel, + normalizeCountryFallback: normalizeFiveSimCountryFallback, + normalizeMaxPrice: normalizeFiveSimMaxPrice, + normalizeOperator: normalizeFiveSimOperator, + resolveCountryCandidates, + requestActivation: (state, options) => requestActivation(state, options, providerDeps), + reuseActivation: (state, activation) => reuseActivation(state, activation, providerDeps), + finishActivation: (state, activation) => finishActivation(state, activation, providerDeps), + cancelActivation: (state, activation) => cancelActivation(state, activation, providerDeps), + banActivation: (state, activation) => banActivation(state, activation, providerDeps), + pollActivationCode: (state, activation, options) => pollActivationCode(state, activation, options, providerDeps), + fetchBalance: (state) => fetchBalance(state, providerDeps), + fetchCountries: (state) => fetchCountries(state, providerDeps), + fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps), + collectPriceEntries, + describePayload, + }; + } + + return { + PROVIDER_ID, + DEFAULT_COUNTRY_ID, + DEFAULT_COUNTRY_LABEL, + DEFAULT_OPERATOR, + DEFAULT_PRODUCT, + createProvider, + normalizeFiveSimCountryFallback, + normalizeFiveSimCountryId, + normalizeFiveSimCountryLabel, + formatFiveSimCountryLabel, + normalizeFiveSimMaxPrice, + normalizeFiveSimOperator, + }; +}); diff --git a/phone-sms/providers/hero-sms.js b/phone-sms/providers/hero-sms.js new file mode 100644 index 0000000..bcd5acc --- /dev/null +++ b/phone-sms/providers/hero-sms.js @@ -0,0 +1,213 @@ +// phone-sms/providers/hero-sms.js — HeroSMS 接码平台适配层 +(function attachHeroSmsProvider(root, factory) { + root.PhoneSmsHeroSmsProvider = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createHeroSmsProviderModule() { + const PROVIDER_ID = 'hero-sms'; + const DEFAULT_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php'; + const DEFAULT_SERVICE_CODE = 'dr'; + const DEFAULT_SERVICE_LABEL = 'OpenAI'; + const DEFAULT_COUNTRY_ID = 52; + const DEFAULT_COUNTRY_LABEL = 'Thailand'; + const DEFAULT_REQUEST_TIMEOUT_MS = 20000; + + function normalizeHeroSmsCountryId(value, fallback = DEFAULT_COUNTRY_ID) { + const parsed = Math.floor(Number(value)); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + const fallbackParsed = Math.floor(Number(fallback)); + return Number.isFinite(fallbackParsed) && fallbackParsed > 0 ? fallbackParsed : DEFAULT_COUNTRY_ID; + } + + function normalizeHeroSmsCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) { + return String(value || '').trim() || fallback; + } + + function normalizeHeroSmsMaxPrice(value = '') { + const rawValue = String(value ?? '').trim(); + if (!rawValue) return ''; + const numeric = Number(rawValue); + if (!Number.isFinite(numeric) || numeric <= 0) return ''; + return String(Math.round(numeric * 10000) / 10000); + } + + function normalizeHeroSmsCountryFallback(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seen = new Set(); + const normalized = []; + for (const entry of source) { + let id = 0; + let label = ''; + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + id = normalizeHeroSmsCountryId(entry.id ?? entry.countryId, 0); + label = String((entry.label ?? entry.countryLabel) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/); + id = normalizeHeroSmsCountryId(structured?.[1] || text, 0); + label = String(structured?.[2] || '').trim(); + } + if (!id || seen.has(id)) continue; + seen.add(id); + normalized.push({ id, label: label || `Country #${id}` }); + if (normalized.length >= 20) break; + } + return normalized; + } + + function normalizeBaseUrl(value = '') { + const trimmed = String(value || '').trim() || DEFAULT_BASE_URL; + try { + return new URL(trimmed).toString(); + } catch { + return DEFAULT_BASE_URL; + } + } + + function buildUrl(config = {}, query = {}) { + const url = new URL(normalizeBaseUrl(config.baseUrl)); + Object.entries(query || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return; + url.searchParams.set(key, String(value)); + }); + return url.toString(); + } + + function parsePayload(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return ''; + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { return JSON.parse(trimmed); } catch { return trimmed; } + } + return trimmed; + } + + function describePayload(raw) { + if (typeof raw === 'string') return raw.trim(); + if (raw && typeof raw === 'object') { + const direct = String(raw.message || raw.msg || raw.error || raw.title || raw.status || '').trim(); + if (direct) return direct; + try { return JSON.stringify(raw); } catch { return String(raw); } + } + return String(raw || '').trim(); + } + + function resolveConfig(state = {}, deps = {}) { + return { + apiKey: String(state.heroSmsApiKey || '').trim(), + baseUrl: state.heroSmsBaseUrl || DEFAULT_BASE_URL, + fetchImpl: deps.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null), + requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, + }; + } + + async function fetchPayload(config, query, actionLabel = 'HeroSMS request') { + if (query.api_key === undefined && config.apiKey) { + query = { api_key: config.apiKey, ...query }; + } + if (!config.fetchImpl) { + throw new Error('HeroSMS fetch implementation is unavailable.'); + } + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS) + : null; + try { + const response = await config.fetchImpl(buildUrl(config, query), { + method: 'GET', + signal: controller?.signal, + }); + const text = await response.text(); + const payload = parsePayload(text); + if (!response.ok) { + const error = new Error(`${actionLabel} failed: ${describePayload(payload) || response.status}`); + error.payload = payload; + error.status = response.status; + throw error; + } + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`${actionLabel} timed out.`); + } + throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + + function resolveCountryConfig(state = {}) { + return { + id: normalizeHeroSmsCountryId(state.heroSmsCountryId), + label: normalizeHeroSmsCountryLabel(state.heroSmsCountryLabel), + }; + } + + function resolveCountryCandidates(state = {}) { + const primary = resolveCountryConfig(state); + const seen = new Set([primary.id]); + const candidates = [primary]; + normalizeHeroSmsCountryFallback(state.heroSmsCountryFallback).forEach((entry) => { + const id = normalizeHeroSmsCountryId(entry.id, 0); + if (!id || seen.has(id)) return; + seen.add(id); + candidates.push({ id, label: normalizeHeroSmsCountryLabel(entry.label, `Country #${id}`) }); + }); + return candidates; + } + + async function fetchBalance(state = {}, deps = {}) { + const config = resolveConfig(state, deps); + if (!config.apiKey) { + throw new Error('HeroSMS API key is missing. Save it in the side panel before querying balance.'); + } + const payload = await fetchPayload(config, { action: 'getBalance' }, 'HeroSMS getBalance'); + const balance = Number(String(describePayload(payload)).replace(/^ACCESS_BALANCE:/i, '').trim()); + return { balance, raw: payload }; + } + + async function fetchPrices(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) { + const config = resolveConfig(state, deps); + return fetchPayload(config, { + action: 'getPrices', + service: DEFAULT_SERVICE_CODE, + country: normalizeHeroSmsCountryId(countryConfig?.id), + }, 'HeroSMS getPrices'); + } + + function createProvider(deps = {}) { + return { + id: PROVIDER_ID, + label: 'HeroSMS', + defaultCountryId: DEFAULT_COUNTRY_ID, + defaultCountryLabel: DEFAULT_COUNTRY_LABEL, + defaultProduct: DEFAULT_SERVICE_LABEL, + normalizeCountryId: normalizeHeroSmsCountryId, + normalizeCountryLabel: normalizeHeroSmsCountryLabel, + normalizeCountryFallback: normalizeHeroSmsCountryFallback, + normalizeMaxPrice: normalizeHeroSmsMaxPrice, + resolveCountryCandidates, + fetchBalance: (state) => fetchBalance(state, deps), + fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, deps), + describePayload, + }; + } + + return { + PROVIDER_ID, + DEFAULT_BASE_URL, + DEFAULT_COUNTRY_ID, + DEFAULT_COUNTRY_LABEL, + DEFAULT_SERVICE_CODE, + DEFAULT_SERVICE_LABEL, + createProvider, + describePayload, + normalizeHeroSmsCountryFallback, + normalizeHeroSmsCountryId, + normalizeHeroSmsCountryLabel, + normalizeHeroSmsMaxPrice, + }; +}); diff --git a/phone-sms/providers/registry.js b/phone-sms/providers/registry.js new file mode 100644 index 0000000..5ee7ced --- /dev/null +++ b/phone-sms/providers/registry.js @@ -0,0 +1,43 @@ +// phone-sms/providers/registry.js — 接码平台注册表 +(function attachPhoneSmsProviderRegistry(root, factory) { + root.PhoneSmsProviderRegistry = factory(root); +})(typeof self !== 'undefined' ? self : globalThis, function createPhoneSmsProviderRegistry(root) { + const PROVIDER_HERO_SMS = 'hero-sms'; + const PROVIDER_FIVE_SIM = '5sim'; + const DEFAULT_PROVIDER = PROVIDER_HERO_SMS; + + function normalizeProviderId(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === PROVIDER_FIVE_SIM ? PROVIDER_FIVE_SIM : PROVIDER_HERO_SMS; + } + + function getProviderModule(providerId = DEFAULT_PROVIDER) { + const normalized = normalizeProviderId(providerId); + if (normalized === PROVIDER_FIVE_SIM) { + return root.PhoneSmsFiveSimProvider || null; + } + return root.PhoneSmsHeroSmsProvider || null; + } + + function createProvider(providerId = DEFAULT_PROVIDER, deps = {}) { + const module = getProviderModule(providerId); + if (!module || typeof module.createProvider !== 'function') { + throw new Error(`Phone SMS provider is not loaded: ${normalizeProviderId(providerId)}`); + } + return module.createProvider(deps); + } + + function getProviderLabel(providerId = DEFAULT_PROVIDER) { + return normalizeProviderId(providerId) === PROVIDER_FIVE_SIM ? '5sim' : 'HeroSMS'; + } + + return { + PROVIDER_HERO_SMS, + PROVIDER_FIVE_SIM, + DEFAULT_PROVIDER, + normalizeProviderId, + getProviderModule, + createProvider, + getProviderLabel, + }; +}); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 3bff678..067e5a5 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -466,6 +466,343 @@ +
+
+
+ + 手机号验证与接码平台获取策略 +
+
+ + +
+
+
+ 接码平台 +
+ + HeroSMS / OpenAI / Thailand +
+
+ +
+
+
+
+ + 用于浏览器代理接管与出口切换 +
+
+ + +
+
+ +