diff --git a/.gitignore b/.gitignore index 1d65524..60d2a87 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ /data/account-run-history.json .npm-test.log .omx/ -/node_modules \ No newline at end of file +/node_modules +/.runtime diff --git a/background.js b/background.js index 01bb275..86a1012 100644 --- a/background.js +++ b/background.js @@ -178,12 +178,12 @@ const { const LOG_PREFIX = '[MultiPage:bg]'; const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; const ICLOUD_SETUP_URLS = [ - 'https://setup.icloud.com.cn/setup/ws/1', 'https://setup.icloud.com/setup/ws/1', + 'https://setup.icloud.com.cn/setup/ws/1', ]; const ICLOUD_LOGIN_URLS = [ - 'https://www.icloud.com.cn/', 'https://www.icloud.com/', + 'https://www.icloud.com.cn/', ]; const ICLOUD_REQUEST_TIMEOUT_MS = 15000; const ICLOUD_LIST_MAX_ATTEMPTS = 3; @@ -268,9 +268,13 @@ const IP_PROXY_TARGET_HOST_PATTERNS = [ 'myip.ipip.net', ]; const AUTO_RUN_TIMER_ALARM_NAME = 'auto-run-timer'; +const IP_PROXY_AUTO_SYNC_ALARM_NAME = 'ip-proxy-auto-sync'; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds'; const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry'; +const IP_PROXY_AUTO_SYNC_INTERVAL_MIN_MINUTES = 1; +const IP_PROXY_AUTO_SYNC_INTERVAL_MAX_MINUTES = 1440; +const IP_PROXY_AUTO_SYNC_DEFAULT_INTERVAL_MINUTES = 15; const AUTO_RUN_DELAY_MIN_MINUTES = 1; const AUTO_RUN_DELAY_MAX_MINUTES = 1440; const AUTO_RUN_RETRY_DELAY_MS = 3000; @@ -313,9 +317,26 @@ const HERO_SMS_SERVICE_CODE = 'dr'; const HERO_SMS_SERVICE_LABEL = 'OpenAI'; const HERO_SMS_COUNTRY_ID = 52; const HERO_SMS_COUNTRY_LABEL = 'Thailand'; +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 = Object.freeze([ + PHONE_SMS_PROVIDER_HERO, + PHONE_SMS_PROVIDER_5SIM, + PHONE_SMS_PROVIDER_NEXSMS, +]); +const DEFAULT_FIVE_SIM_BASE_URL = 'https://5sim.net/v1'; +const DEFAULT_FIVE_SIM_PRODUCT = 'openai'; +const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const DEFAULT_FIVE_SIM_COUNTRY_ORDER = Object.freeze(['thailand']); +const DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net'; +const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot'; +const DEFAULT_NEX_SMS_COUNTRY_ORDER = Object.freeze([1]); const DEFAULT_HERO_SMS_REUSE_ENABLED = true; 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 DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; @@ -474,6 +495,8 @@ const PERSISTED_SETTING_DEFAULTS = { ipProxyAccountSessionPrefix: '', ipProxyAccountLifeMinutes: '', ipProxyPoolTargetCount: '20', + ipProxyAutoSyncEnabled: false, + ipProxyAutoSyncIntervalMinutes: IP_PROXY_AUTO_SYNC_DEFAULT_INTERVAL_MINUTES, ipProxyHost: '', ipProxyPort: '', ipProxyProtocol: DEFAULT_IP_PROXY_PROTOCOL, @@ -495,6 +518,8 @@ const PERSISTED_SETTING_DEFAULTS = { autoRunDelayMinutes: 30, autoStepDelaySeconds: null, phoneVerificationEnabled: false, + phoneSmsProvider: DEFAULT_PHONE_SMS_PROVIDER, + phoneSmsProviderOrder: [], verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT, phoneVerificationReplacementLimit: DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT, phoneCodeWaitSeconds: DEFAULT_PHONE_CODE_WAIT_SECONDS, @@ -543,9 +568,20 @@ const PERSISTED_SETTING_DEFAULTS = { heroSmsReuseEnabled: DEFAULT_HERO_SMS_REUSE_ENABLED, heroSmsAcquirePriority: DEFAULT_HERO_SMS_ACQUIRE_PRIORITY, heroSmsMaxPrice: '', + heroSmsPreferredPrice: '', 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, }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -634,7 +670,11 @@ const DEFAULT_STATE = { currentLuckmailMailCursor: null, currentPhoneActivation: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, reusablePhoneActivation: null, + phoneReusableActivationPool: [], heroSmsLastPriceTiers: [], heroSmsLastPriceCountryId: 0, heroSmsLastPriceCountryLabel: '', @@ -706,6 +746,27 @@ function normalizeAutoRunFallbackThreadIntervalMinutes(value) { ); } +function normalizeIpProxyAutoSyncIntervalMinutes(value, fallback = IP_PROXY_AUTO_SYNC_DEFAULT_INTERVAL_MINUTES) { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return Math.min( + IP_PROXY_AUTO_SYNC_INTERVAL_MAX_MINUTES, + Math.max(IP_PROXY_AUTO_SYNC_INTERVAL_MIN_MINUTES, Math.floor(Number(fallback) || IP_PROXY_AUTO_SYNC_DEFAULT_INTERVAL_MINUTES)) + ); + } + const numeric = Number(rawValue); + if (!Number.isFinite(numeric)) { + return Math.min( + IP_PROXY_AUTO_SYNC_INTERVAL_MAX_MINUTES, + Math.max(IP_PROXY_AUTO_SYNC_INTERVAL_MIN_MINUTES, Math.floor(Number(fallback) || IP_PROXY_AUTO_SYNC_DEFAULT_INTERVAL_MINUTES)) + ); + } + return Math.min( + IP_PROXY_AUTO_SYNC_INTERVAL_MAX_MINUTES, + Math.max(IP_PROXY_AUTO_SYNC_INTERVAL_MIN_MINUTES, Math.floor(numeric)) + ); +} + function normalizeAutoStepDelaySeconds(value, fallback = null) { const rawValue = String(value ?? '').trim(); if (!rawValue) { @@ -828,9 +889,14 @@ function normalizeHeroSmsMaxPrice(value = '') { } function normalizeHeroSmsAcquirePriority(value = '') { - return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE - ? HERO_SMS_ACQUIRE_PRIORITY_PRICE - : HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE) { + return HERO_SMS_ACQUIRE_PRIORITY_PRICE; + } + if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) { + return HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH; + } + return HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; } function normalizeHeroSmsCountryFallback(value = []) { @@ -877,6 +943,242 @@ 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 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; +} + +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); + } + + 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, ''); + return normalized || fallback; +} + +function normalizeFiveSimCountryOrder(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 code = ''; + + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + code = normalizeFiveSimCountryCode(entry.code || entry.country || entry.id || '', ''); + } else { + code = normalizeFiveSimCountryCode(entry, ''); + } + + if (!code || seen.has(code)) { + continue; + } + seen.add(code); + normalized.push(code); + if (normalized.length >= 10) { + break; + } + } + + 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; @@ -1413,6 +1715,13 @@ function normalizePersistentSettingValue(key, value) { return normalizeIpProxyAccountLifeMinutes(value || ''); case 'ipProxyPoolTargetCount': return normalizeIpProxyPoolTargetCount(value || '', 20); + case 'ipProxyAutoSyncEnabled': + return Boolean(value); + case 'ipProxyAutoSyncIntervalMinutes': + return normalizeIpProxyAutoSyncIntervalMinutes( + value, + PERSISTED_SETTING_DEFAULTS.ipProxyAutoSyncIntervalMinutes + ); case 'ipProxyHost': return String(value || '').trim(); case 'ipProxyPort': @@ -1472,6 +1781,10 @@ function normalizePersistentSettingValue(key, value) { case 'phoneVerificationEnabled': case 'plusModeEnabled': return Boolean(value); + case 'phoneSmsProvider': + return normalizePhoneSmsProvider(value); + case 'phoneSmsProviderOrder': + return normalizePhoneSmsProviderOrder(value); case 'autoRunFallbackThreadIntervalMinutes': return normalizeAutoRunFallbackThreadIntervalMinutes(value); case 'autoRunDelayMinutes': @@ -1567,12 +1880,34 @@ function normalizePersistentSettingValue(key, value) { return normalizeHeroSmsAcquirePriority(value); case 'heroSmsMaxPrice': return normalizeHeroSmsMaxPrice(value); + case 'heroSmsPreferredPrice': + return normalizeHeroSmsMaxPrice(value); case 'heroSmsCountryId': - return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID)); + return Math.max(0, Math.floor(Number(value) || 0)); case 'heroSmsCountryLabel': 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); + 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); default: return value; } @@ -2119,6 +2454,7 @@ async function resetState() { 'tabRegistry', 'sourceLastUrls', 'reusablePhoneActivation', + 'phoneReusableActivationPool', 'luckmailApiKey', 'luckmailBaseUrl', 'luckmailEmailType', @@ -2152,6 +2488,11 @@ async function resetState() { ) ? prev.reusablePhoneActivation : null; + const phoneReusableActivationPool = Array.isArray(prev.phoneReusableActivationPool) + ? prev.phoneReusableActivationPool + .map((entry) => normalizePhonePreferredActivation(entry)) + .filter(Boolean) + : []; await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, @@ -2174,6 +2515,7 @@ async function resetState() { currentLuckmailMailCursor: null, // Keep reusable phone activation across round resets so the same number can be reactivated up to maxUses. reusablePhoneActivation, + phoneReusableActivationPool, preferredIcloudHost: prev.preferredIcloudHost || '', }); } @@ -4278,10 +4620,18 @@ async function getPreferredIcloudLoginUrl(error = null, state = null) { return getIcloudLoginUrlForHost(messageHint); } - return ICLOUD_LOGIN_URLS[0]; + return getIcloudLoginUrlForHost('icloud.com') || ICLOUD_LOGIN_URLS[0]; } async function getPreferredIcloudSetupUrls(state = null, error = null) { + const currentState = state || await getState(); + const configuredHost = getConfiguredIcloudHostPreference(currentState); + if (configuredHost) { + const forcedSetupUrl = getIcloudSetupUrlForHost(configuredHost); + if (forcedSetupUrl) { + return [forcedSetupUrl]; + } + } const preferredLoginUrl = await getPreferredIcloudLoginUrl(error, state); const preferredHost = normalizeIcloudHost(new URL(preferredLoginUrl).host); const preferredSetupUrl = getIcloudSetupUrlForHost(preferredHost); @@ -4437,6 +4787,7 @@ async function promptIcloudLogin(error, actionLabel = 'iCloud 操作') { } async function withIcloudLoginHelp(actionLabel, action) { + const safeActionLabel = String(actionLabel || 'iCloud 操作').trim() || 'iCloud 操作'; const maxTransientAttempts = Math.max(1, Number(ICLOUD_TRANSIENT_RETRY_MAX_ATTEMPTS) || 1); const retryDelayMs = Math.max(300, Number(ICLOUD_TRANSIENT_RETRY_DELAY_MS) || 1200); for (let attempt = 1; attempt <= maxTransientAttempts; attempt += 1) { @@ -4449,17 +4800,18 @@ async function withIcloudLoginHelp(actionLabel, action) { } if (isIcloudTransientContextError(err)) { if (attempt < maxTransientAttempts) { - if (shouldEmitIcloudTransientLog(`${actionLabel}:retry:${attempt}/${maxTransientAttempts}`)) { - await addLog(`iCloud:${actionLabel}受网络/上下文波动影响,正在重试(${attempt}/${maxTransientAttempts})...`, 'warn'); + if (shouldEmitIcloudTransientLog(`${safeActionLabel}:retry:${attempt}/${maxTransientAttempts}`)) { + await addLog(`iCloud:${safeActionLabel}受网络/上下文波动影响,正在重试(${attempt}/${maxTransientAttempts})...`, 'warn'); } await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt)); continue; } - if (shouldEmitIcloudTransientLog(`${actionLabel}:final`)) { - await addLog(`iCloud:${actionLabel}受网络/上下文波动影响:${getErrorMessage(err)}`, 'warn'); + if (shouldEmitIcloudTransientLog(`${safeActionLabel}:final`)) { + await addLog(`iCloud:${safeActionLabel}受网络/上下文波动影响:${getErrorMessage(err)}`, 'warn'); } - const transientError = new Error('iCloud 别名加载受网络/上下文波动影响,请稍后重试。'); + const transientError = new Error(`iCloud:${safeActionLabel}受网络/上下文波动影响,请稍后重试。`); transientError.code = 'ICLOUD_TRANSIENT_CONTEXT'; + transientError.actionLabel = safeActionLabel; transientError.cause = err; throw transientError; } @@ -4589,12 +4941,10 @@ async function waitForIcloudMailTabReady(tabId, timeoutMs = 8000) { async function ensureIcloudMailContextTab(tabs = [], targetHost = '', preferredHost = '') { const tabList = Array.isArray(tabs) ? tabs : []; - if (tabList.some((tab) => isIcloudMailPageUrl(tab?.url))) { - return tabList; - } - - const fallbackHost = targetHost - || preferredHost + const normalizedTargetHost = normalizeIcloudHost(targetHost); + const normalizedPreferredHost = normalizeIcloudHost(preferredHost); + const fallbackHost = normalizedTargetHost + || normalizedPreferredHost || await getOpenIcloudHostPreference() || 'icloud.com'; const fallbackMailUrl = getIcloudMailUrlForHost(fallbackHost) || getIcloudMailUrlForHost('icloud.com'); @@ -4602,9 +4952,51 @@ async function ensureIcloudMailContextTab(tabs = [], targetHost = '', preferredH return tabList; } + const readHostFromTab = (tab) => { + try { + return normalizeIcloudHost(new URL(String(tab?.url || '')).hostname); + } catch { + return ''; + } + }; + + const mailTabs = tabList.filter((tab) => isIcloudMailPageUrl(tab?.url)); + if (mailTabs.length > 0) { + if (fallbackHost) { + const hasTargetHostMailTab = mailTabs.some((tab) => readHostFromTab(tab) === fallbackHost); + if (!hasTargetHostMailTab && Number.isInteger(mailTabs[0]?.id)) { + try { + await chrome.tabs.update(mailTabs[0].id, { url: fallbackMailUrl, active: false }); + await waitForIcloudMailTabReady(mailTabs[0].id, 9000); + try { + return await chrome.tabs.query({ + url: ICLOUD_TAB_URL_PATTERNS, + }); + } catch { + return tabList; + } + } catch {} + } + } + return tabList; + } + + const sameHostIcloudTab = tabList.find((tab) => ( + Number.isInteger(tab?.id) && readHostFromTab(tab) === fallbackHost + )); + const anyIcloudTab = tabList.find((tab) => Number.isInteger(tab?.id)); + try { - const created = await chrome.tabs.create({ url: fallbackMailUrl, active: false }); - await waitForIcloudMailTabReady(created?.id, 9000); + if (sameHostIcloudTab?.id) { + await chrome.tabs.update(sameHostIcloudTab.id, { url: fallbackMailUrl, active: false }); + await waitForIcloudMailTabReady(sameHostIcloudTab.id, 9000); + } else if (anyIcloudTab?.id) { + await chrome.tabs.update(anyIcloudTab.id, { url: fallbackMailUrl, active: false }); + await waitForIcloudMailTabReady(anyIcloudTab.id, 9000); + } else { + const created = await chrome.tabs.create({ url: fallbackMailUrl, active: false }); + await waitForIcloudMailTabReady(created?.id, 9000); + } } catch {} try { @@ -4647,8 +5039,9 @@ async function icloudRequestViaPageContext(method, url, options = {}) { contentType = '', } = options; const state = await getState(); - const targetHost = normalizeIcloudHost(new URL(url).hostname); - const preferredHost = normalizeIcloudHost(state?.preferredIcloudHost); + const configuredHost = getConfiguredIcloudHostPreference(state); + const targetHost = configuredHost || normalizeIcloudHost(new URL(url).hostname); + const preferredHost = configuredHost || normalizeIcloudHost(state?.preferredIcloudHost); let tabs = await chrome.tabs.query({ url: ICLOUD_TAB_URL_PATTERNS, @@ -5029,7 +5422,7 @@ async function validateIcloudSessionViaPageContext(tabId, setupUrl) { } } -async function resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state) { +async function resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state, options = {}) { const errors = []; let tabs = []; try { @@ -5041,7 +5434,11 @@ async function resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state) { return { service: null, errors, noTab: false }; } - const preferredHost = normalizeIcloudHost(state?.preferredIcloudHost); + const explicitHost = normalizeIcloudHost(options?.hostPreference || options?.preferredHost || ''); + const configuredHost = getConfiguredIcloudHostPreference(state); + const preferredHost = explicitHost + || configuredHost + || normalizeIcloudHost(state?.preferredIcloudHost); tabs = await ensureIcloudMailContextTab(tabs, preferredHost, preferredHost); if (!tabs.length) { return { service: null, errors: [], noTab: true }; @@ -5083,9 +5480,11 @@ async function resolveIcloudPremiumMailService(options = {}) { const errors = []; const state = await getState(); const explicitHost = normalizeIcloudHost(options?.hostPreference || options?.preferredHost || ''); - const setupUrls = explicitHost + const configuredHost = getConfiguredIcloudHostPreference(state); + const effectiveHost = explicitHost || configuredHost; + const setupUrls = effectiveHost ? (() => { - const forcedSetupUrl = getIcloudSetupUrlForHost(explicitHost); + const forcedSetupUrl = getIcloudSetupUrlForHost(effectiveHost); return forcedSetupUrl ? [forcedSetupUrl] : []; })() : await getPreferredIcloudSetupUrls(state); @@ -5111,7 +5510,9 @@ async function resolveIcloudPremiumMailService(options = {}) { service, errors: pageContextErrors, noTab: pageContextNoTab = false, - } = await resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state); + } = await resolveIcloudPremiumMailServiceViaPageContext(setupUrls, state, { + hostPreference: effectiveHost, + }); if (service) { const preferredIcloudHost = normalizeIcloudHost(new URL(service.setupUrl).host); if (preferredIcloudHost && preferredIcloudHost !== normalizeIcloudHost(state.preferredIcloudHost)) { @@ -5130,7 +5531,7 @@ async function resolveIcloudPremiumMailService(options = {}) { throw new Error(errors.length ? `Could not validate iCloud session. ${errors.join(' | ')}` - : 'Could not validate iCloud session. 请先在当前浏览器登录 icloud.com.cn 或 icloud.com。'); + : `Could not validate iCloud session. 请先在当前浏览器登录 ${effectiveHost || 'icloud.com 或 icloud.com.cn'}。`); } function getIcloudAliasLabel() { @@ -5339,9 +5740,12 @@ async function fetchIcloudHideMyEmail(options = {}) { return withIcloudLoginHelp('获取 iCloud 隐私邮箱', async () => { throwIfStopped(); const generateNew = Boolean(options?.generateNew); + const preferredHost = String(options?.hostPreference || options?.preferredHost || '').trim(); await addLog('iCloud:正在加载别名列表并校验当前浏览器登录状态...', 'info'); - const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService(); + const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService( + preferredHost ? { hostPreference: preferredHost } : {} + ); await addLog(`iCloud:已通过 ${new URL(setupUrl).host} 验证会话`, 'ok'); await addLog(`iCloud:当前 Hide My Email 服务节点 ${new URL(serviceUrl).host}`, 'info'); @@ -5382,7 +5786,9 @@ async function fetchIcloudHideMyEmail(options = {}) { throw err; } await addLog('iCloud:生成候选别名失败,正在刷新服务节点后再试一次...', 'warn'); - const refreshedService = await resolveIcloudPremiumMailService(); + const refreshedService = await resolveIcloudPremiumMailService( + preferredHost ? { hostPreference: preferredHost } : {} + ); activeServiceUrl = refreshedService.serviceUrl; generated = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/generate`, { timeoutMs: ICLOUD_REQUEST_TIMEOUT_MS, @@ -5461,7 +5867,9 @@ async function fetchIcloudHideMyEmail(options = {}) { await addLog(`iCloud:保留请求异常,但已在列表确认别名 ${alias},继续使用。`, 'warn'); } else if (isIcloudRetryableError(reserveErr)) { await addLog(`iCloud:列表中尚未出现 ${generatedAlias},正在刷新服务节点后重试保留一次...`, 'warn'); - const refreshedService = await resolveIcloudPremiumMailService(); + const refreshedService = await resolveIcloudPremiumMailService( + preferredHost ? { hostPreference: preferredHost } : {} + ); activeServiceUrl = refreshedService.serviceUrl; const reservedRetry = await icloudRequest('POST', `${activeServiceUrl}/v1/hme/reserve`, { data: reserveData, @@ -6071,7 +6479,28 @@ function isStopError(error) { function isRetryableContentScriptTransportError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); - return /back\/forward cache|message channel is closed|Receiving end does not exist|port closed before a response was received|A listener indicated an asynchronous response|did not respond in \d+s/i.test(message); + return /back\/forward cache|message channel is closed|Receiving end does not exist|port closed before a response was received|A listener indicated an asynchronous response|did not respond in \d+s|failed to fetch|networkerror|network error|fetch failed|load failed/i.test(message); +} + +function isStepFetchNetworkRetryableError(error) { + const message = String(getErrorMessage(error) || '').toLowerCase(); + return /failed to fetch|networkerror|network error|fetch failed|load failed|net::err_/i.test(message); +} + +function getStepFetchNetworkRetryPolicy(step) { + if (typeof STEP_FETCH_NETWORK_RETRY_POLICIES === 'undefined' || !(STEP_FETCH_NETWORK_RETRY_POLICIES instanceof Map)) { + return null; + } + + const policy = STEP_FETCH_NETWORK_RETRY_POLICIES.get(Number(step)); + if (!policy) { + return null; + } + + return { + maxAttempts: Math.max(1, Math.floor(Number(policy.maxAttempts) || 1)), + cooldownMs: Math.max(0, Math.floor(Number(policy.cooldownMs) || 0)), + }; } const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ @@ -6313,6 +6742,9 @@ function getDownstreamStateResets(step, state = {}) { lastLoginCode: null, localhostUrl: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, }; } if (step === 2) { @@ -6329,6 +6761,9 @@ function getDownstreamStateResets(step, state = {}) { lastLoginCode: null, localhostUrl: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, }; } if (step === 3 || step === 4) { @@ -6344,6 +6779,9 @@ function getDownstreamStateResets(step, state = {}) { lastLoginCode: null, localhostUrl: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, }; } if (step === 5 || step === 6 || step === 7 || step === 8) { @@ -6372,6 +6810,9 @@ function getDownstreamStateResets(step, state = {}) { pendingPhoneActivationConfirmation: null, localhostUrl: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, }; } if (step === 9) { @@ -6380,6 +6821,9 @@ function getDownstreamStateResets(step, state = {}) { plusReturnUrl: '', localhostUrl: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, }; } if (stepKey === 'oauth-login' || stepKey === 'fetch-login-code') { @@ -6391,6 +6835,9 @@ function getDownstreamStateResets(step, state = {}) { pendingPhoneActivationConfirmation: null, localhostUrl: null, currentPhoneVerificationCode: '', + currentPhoneVerificationCountdownEndsAt: 0, + currentPhoneVerificationCountdownWindowIndex: 0, + currentPhoneVerificationCountdownWindowTotal: 0, }; } if (stepKey === 'confirm-oauth') { @@ -7693,6 +8140,12 @@ async function requestStop(options = {}) { // Step Execution // ============================================================ +const STEP_FETCH_NETWORK_RETRY_POLICIES = new Map([ + [4, { maxAttempts: 3, cooldownMs: 12000 }], + [8, { maxAttempts: 3, cooldownMs: 12000 }], + [9, { maxAttempts: 3, cooldownMs: 12000 }], +]); + async function executeStep(step, options = {}) { const { deferRetryableTransportError = false } = options; console.log(LOG_PREFIX, `Executing step ${step}`); @@ -7708,23 +8161,62 @@ async function executeStep(step, options = {}) { await setStepStatus(step, 'running'); await addLog(`步骤 ${step} 开始执行`); await humanStepDelay(); + const fetchRetryPolicy = typeof getStepFetchNetworkRetryPolicy === 'function' + ? getStepFetchNetworkRetryPolicy(step) + : null; + const isFetchRetryable = (error) => { + if (typeof isStepFetchNetworkRetryableError === 'function') { + return isStepFetchNetworkRetryableError(error); + } + return isRetryableContentScriptTransportError(error); + }; + let attempt = 1; - state = await getState(); + while (true) { + state = await getState(); - // Set flow start time on first step - if (step === 1 && !state.flowStartTime) { - await setState({ flowStartTime: Date.now() }); + // Set flow start time on first step + if (step === 1 && !state.flowStartTime) { + await setState({ flowStartTime: Date.now() }); + } + + const activeStepRegistry = getStepRegistryForState(state); + if (!activeStepRegistry?.getStepDefinition?.(step)) { + throw new Error(`当前模式下不存在步骤:${step}`); + } + + try { + await activeStepRegistry.executeStep(step, { + ...state, + visibleStep: Number(step), + stepDefinition: getStepDefinitionForState(step, state), + }); + + if (attempt > 1) { + await addLog( + `[NETWORK_FETCH_RETRY] 步骤 ${step}:网络请求异常已恢复,当前重试成功(${attempt}/${fetchRetryPolicy?.maxAttempts || attempt})。`, + 'ok' + ); + } + break; + } catch (attemptError) { + if (!fetchRetryPolicy || !isFetchRetryable(attemptError) || attempt >= fetchRetryPolicy.maxAttempts) { + throw attemptError; + } + + const nextAttempt = attempt + 1; + const cooldownMs = fetchRetryPolicy.cooldownMs; + const cooldownSeconds = Math.max(1, Math.ceil(cooldownMs / 1000)); + await addLog( + `[NETWORK_FETCH_RETRY] 步骤 ${step}:检测到网络请求异常(${getErrorMessage(attemptError)}),${cooldownSeconds} 秒后重试(${nextAttempt}/${fetchRetryPolicy.maxAttempts})。`, + 'warn' + ); + if (cooldownMs > 0) { + await sleepWithStop(cooldownMs); + } + attempt = nextAttempt; + } } - - const activeStepRegistry = getStepRegistryForState(state); - if (!activeStepRegistry?.getStepDefinition?.(step)) { - throw new Error(`当前模式下不存在步骤:${step}`); - } - await activeStepRegistry.executeStep(step, { - ...state, - visibleStep: Number(step), - stepDefinition: getStepDefinitionForState(step, state), - }); } catch (err) { executionError = err; const errorState = await getState(); @@ -8025,6 +8517,7 @@ let autoRunTotalRuns = 1; let autoRunAttemptRun = 0; let autoRunSessionId = 0; let autoRunSessionSeed = 0; +let ipProxyAutoSyncRunning = false; const EMAIL_FETCH_MAX_ATTEMPTS = 5; const VERIFICATION_POLL_MAX_ROUNDS = 5; const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000; @@ -8125,6 +8618,82 @@ function resolveIpProxyCandidateCountForAutoSwitch(state = {}, mode = 'account', return 0; } +function resolveIpProxyAutoSyncIntervalMinutes(value, fallback = IP_PROXY_AUTO_SYNC_DEFAULT_INTERVAL_MINUTES) { + return normalizeIpProxyAutoSyncIntervalMinutes(value, fallback); +} + +async function clearIpProxyAutoSyncAlarm() { + await chrome.alarms.clear(IP_PROXY_AUTO_SYNC_ALARM_NAME); +} + +async function ensureIpProxyAutoSyncAlarm(stateOverride = null) { + const state = stateOverride || await getState(); + const enabled = Boolean(state?.ipProxyAutoSyncEnabled); + if (!enabled) { + await clearIpProxyAutoSyncAlarm(); + return false; + } + const intervalMinutes = resolveIpProxyAutoSyncIntervalMinutes( + state?.ipProxyAutoSyncIntervalMinutes, + PERSISTED_SETTING_DEFAULTS.ipProxyAutoSyncIntervalMinutes + ); + const existingAlarm = await chrome.alarms.get(IP_PROXY_AUTO_SYNC_ALARM_NAME); + const existingPeriod = Number(existingAlarm?.periodInMinutes) || 0; + if (!existingAlarm || Math.abs(existingPeriod - intervalMinutes) > 0.0001) { + await chrome.alarms.clear(IP_PROXY_AUTO_SYNC_ALARM_NAME); + await chrome.alarms.create(IP_PROXY_AUTO_SYNC_ALARM_NAME, { + periodInMinutes: intervalMinutes, + delayInMinutes: intervalMinutes, + }); + } + return true; +} + +async function runIpProxyAutoSync(trigger = 'alarm') { + if (ipProxyAutoSyncRunning) { + return { skipped: true, reason: 'running' }; + } + ipProxyAutoSyncRunning = true; + try { + const state = await getState(); + if (!state?.ipProxyAutoSyncEnabled) { + await clearIpProxyAutoSyncAlarm(); + return { skipped: true, reason: 'disabled' }; + } + if (!state?.ipProxyEnabled) { + return { skipped: true, reason: 'proxy_disabled' }; + } + const mode = typeof normalizeIpProxyMode === 'function' + ? normalizeIpProxyMode(state?.ipProxyMode) + : String(state?.ipProxyMode || 'account').trim().toLowerCase(); + const result = await refreshIpProxyPool({ + state, + mode, + skipExitProbe: true, + }); + if (typeof addLog === 'function') { + const display = String(result?.display || '').trim(); + await addLog( + display + ? `IP 代理自动同步完成(${trigger}):${display}` + : `IP 代理自动同步完成(${trigger})。`, + 'info' + ).catch(() => {}); + } + return { skipped: false, result }; + } catch (error) { + if (typeof addLog === 'function') { + await addLog( + `IP 代理自动同步失败:${error?.message || String(error || '未知错误')}`, + 'warn' + ).catch(() => {}); + } + return { skipped: true, reason: 'error', error: error?.message || String(error || '未知错误') }; + } finally { + ipProxyAutoSyncRunning = false; + } +} + async function maybeSwitchIpProxyAfterAutoRunRoundSuccess(payload = {}) { if (typeof switchIpProxy !== 'function') { return null; @@ -8910,6 +9479,14 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create }); const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.createPhoneVerificationHelpers({ addLog, + broadcastDataUpdate, + DEFAULT_FIVE_SIM_BASE_URL, + DEFAULT_FIVE_SIM_COUNTRY_ORDER, + DEFAULT_FIVE_SIM_OPERATOR, + DEFAULT_FIVE_SIM_PRODUCT, + DEFAULT_NEX_SMS_BASE_URL, + DEFAULT_NEX_SMS_COUNTRY_ORDER, + DEFAULT_NEX_SMS_SERVICE_CODE, DEFAULT_HERO_SMS_BASE_URL, DEFAULT_HERO_SMS_REUSE_ENABLED, DEFAULT_PHONE_CODE_WAIT_SECONDS, @@ -8924,6 +9501,7 @@ const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.crea HERO_SMS_COUNTRY_LABEL, HERO_SMS_SERVICE_CODE, HERO_SMS_SERVICE_LABEL, + sendToContentScript, sendToContentScriptResilient, setState, sleepWithStop, @@ -8976,6 +9554,8 @@ const step4Executor = self.MultiPageBackgroundStep4?.createStep4Executor({ chrome, completeStepFromBackground, confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass, + generateRandomBirthday, + generateRandomName, ensureMail2925MailboxSession, ensureIcloudMailSession: ensureIcloudMailSessionForVerification, getMailConfig, @@ -8986,7 +9566,9 @@ const step4Executor = self.MultiPageBackgroundStep4?.createStep4Executor({ CLOUDFLARE_TEMP_EMAIL_PROVIDER, resolveVerificationStep: verificationFlowHelpers.resolveVerificationStep, reuseOrCreateTab, + sendToContentScript, sendToContentScriptResilient, + isRetryableContentScriptTransportError, shouldUseCustomRegistrationEmail, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, throwIfStopped, @@ -9207,6 +9789,9 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter isStopError, isTabAlive, launchAutoRunTimerPlan, + ensureIpProxyAutoSyncAlarm, + clearIpProxyAutoSyncAlarm, + runIpProxyAutoSync, listIcloudAliases, listLuckmailPurchasesForManagement, refreshIpProxyPool, @@ -9796,6 +10381,11 @@ async function getPostStep6AutoRestartDecision(step, error) { const normalizedMessage = String(errorMessage || ''); const mentionsTokenExchange = /auth\.openai\.com\/oauth\/token/i.test(normalizedMessage); const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage); + const hasTransientExchangeUserSignal = /token_exchange_user_error|invalid\s+request\.\s+please\s+try\s+again\s+later/i.test(normalizedMessage); + const hasTokenExchangeFailureSignal = /token\s+exchange\s+failed|oauth\/token/i.test(normalizedMessage); + if (hasTransientExchangeUserSignal && hasTokenExchangeFailureSignal) { + return true; + } return mentionsTokenExchange && hasTransientNetworkSignal; }; const isPhoneVerificationLocalFailure = (errorMessage = '') => { @@ -10678,12 +11268,17 @@ async function executeStep10(state) { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name !== AUTO_RUN_TIMER_ALARM_NAME) { + if (alarm.name === AUTO_RUN_TIMER_ALARM_NAME) { + launchAutoRunTimerPlan('alarm').catch((err) => { + console.error(LOG_PREFIX, 'Failed to resume auto run from timer alarm:', err); + }); return; } - launchAutoRunTimerPlan('alarm').catch((err) => { - console.error(LOG_PREFIX, 'Failed to resume auto run from timer alarm:', err); - }); + if (alarm.name === IP_PROXY_AUTO_SYNC_ALARM_NAME) { + runIpProxyAutoSync('alarm').catch((err) => { + console.error(LOG_PREFIX, 'Failed to run IP proxy auto sync alarm:', err); + }); + } }); chrome.runtime.onStartup.addListener(() => { @@ -10698,6 +11293,9 @@ chrome.runtime.onStartup.addListener(() => { console.error(LOG_PREFIX, 'Failed to restore IP proxy settings on startup:', err); }); } + ensureIpProxyAutoSyncAlarm().catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore IP proxy auto sync alarm on startup:', err); + }); }); chrome.runtime.onInstalled.addListener(() => { @@ -10712,6 +11310,9 @@ chrome.runtime.onInstalled.addListener(() => { console.error(LOG_PREFIX, 'Failed to restore IP proxy settings on install/update:', err); }); } + ensureIpProxyAutoSyncAlarm().catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore IP proxy auto sync alarm on install/update:', err); + }); }); restoreAutoRunTimerIfNeeded().catch((err) => { @@ -10725,3 +11326,6 @@ if (IP_PROXY_INIT_AUTO_APPLY) { console.error(LOG_PREFIX, 'Failed to restore IP proxy settings:', err); }); } +ensureIpProxyAutoSyncAlarm().catch((err) => { + console.error(LOG_PREFIX, 'Failed to restore IP proxy auto sync alarm:', err); +}); diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 25270f2..9f190ff 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -97,6 +97,26 @@ .join(';'); } + function isPhoneNumberSupplyExhaustedFailure(errorLike) { + const message = String( + typeof errorLike === 'string' + ? errorLike + : (errorLike?.message || errorLike || '') + ).trim(); + if (!message) { + return false; + } + const hasGlobalNoSupplySignal = /Step\s*9:\s*all\s+provider\s+candidates\s+failed\s+to\s+acquire\s+number|(?:HeroSMS|5sim|NexSMS)\s+no\s+numbers\s+available\s+across|no\s+numbers\s+within\s+maxPrice|no\s+free\s+phones|numbers?\s+not\s+found/i.test(message); + if (!hasGlobalNoSupplySignal) { + return false; + } + const hasRecoverableStep9RotationSignal = /phone\s+verification\s+did\s+not\s+succeed\s+after\s+\d+\s+number\s+replacements|sms_timeout_after_|route_405_retry_loop|resend_throttled|activation_not_found|order\s+not\s+found/i.test(message); + if (hasRecoverableStep9RotationSignal) { + return false; + } + return true; + } + function shouldKeepCustomMailProviderPoolEmail(state = {}) { return String(state?.mailProvider || '').trim().toLowerCase() === 'custom' && Array.isArray(state?.customMailProviderPool) @@ -482,10 +502,15 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); + const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err); const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function' && !keepSameEmailUntilAddPhone && isSignupUserAlreadyExistsFailure(err); - const canRetry = !blockedByAddPhone && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound; + const canRetry = !blockedByAddPhone + && !blockedByPhoneSupplyExhausted + && !blockedBySignupUserAlreadyExists + && autoRunSkipFailures + && attemptRun < maxAttemptsForRound; await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), @@ -561,6 +586,41 @@ break; } + if (blockedByPhoneSupplyExhausted) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + await setState({ + autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + }); + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因接码号池不可用已终止。'); + await broadcastStopToContentScripts(); + if (!autoRunSkipFailures) { + await addLog( + `第 ${targetRun}/${totalRuns} 轮触发接码号池不可用,自动重试未开启,当前自动运行将停止。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + + await addLog(`第 ${targetRun}/${totalRuns} 轮接码号池暂不可用,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog( + targetRun < totalRuns + ? `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,已无后续轮次,本次自动运行结束。`, + 'warn' + ); + forceFreshTabsNextRun = true; + break; + } + if (canRetry) { const retryIndex = attemptRun; if (isRestartCurrentAttemptError(err)) { diff --git a/background/generated-email-helpers.js b/background/generated-email-helpers.js index 022d6af..55e5566 100644 --- a/background/generated-email-helpers.js +++ b/background/generated-email-helpers.js @@ -284,9 +284,16 @@ } if (generator === 'icloud') { const stateFetchMode = String(mergedState.icloudFetchMode || '').trim().toLowerCase(); - return fetchIcloudHideMyEmail({ + const icloudOptions = { generateNew: Boolean(options.generateNew) || stateFetchMode === 'always_new', - }); + }; + if (mergedState.icloudHostPreference !== undefined) { + icloudOptions.hostPreference = mergedState.icloudHostPreference; + } + if (mergedState.preferredIcloudHost !== undefined) { + icloudOptions.preferredHost = mergedState.preferredIcloudHost; + } + return fetchIcloudHideMyEmail(icloudOptions); } if (generator === 'cloudflare') { return fetchCloudflareEmail(mergedState, options); diff --git a/background/ip-proxy-core.js b/background/ip-proxy-core.js index 5f5c9d8..d460185 100644 --- a/background/ip-proxy-core.js +++ b/background/ip-proxy-core.js @@ -3,6 +3,7 @@ let ipProxyAuthListenerInstalled = false; let ipProxyErrorListenerInstalled = false; let currentIpProxyAuthEntry = null; let ipProxyExitDetectionToken = 0; +let ipProxyProbeInFlightPromise = null; let lastAppliedIpProxyEntrySignature = ''; let ipProxyAuthHostVariantToggle = false; const ipProxyHostResolveCache = new Map(); @@ -573,6 +574,17 @@ function normalizeIpProxyServiceProfile(rawValue = {}) { const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) ? rawValue : {}; + const normalizeAutoSyncInterval = (value = '', fallback = 15) => { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return Math.max(1, Math.min(1440, Number(fallback) || 15)); + } + const numeric = Number.parseInt(rawValue, 10); + if (!Number.isFinite(numeric)) { + return Math.max(1, Math.min(1440, Number(fallback) || 15)); + } + return Math.max(1, Math.min(1440, numeric)); + }; return { mode: normalizeIpProxyMode(raw.mode), apiUrl: String(raw.apiUrl || '').trim(), @@ -580,6 +592,8 @@ function normalizeIpProxyServiceProfile(rawValue = {}) { accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''), accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''), poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20), + autoSyncEnabled: Boolean(raw.autoSyncEnabled), + autoSyncIntervalMinutes: normalizeAutoSyncInterval(raw.autoSyncIntervalMinutes, 15), host: String(raw.host || '').trim(), port: String(normalizeIpProxyPort(raw.port || '') || ''), protocol: normalizeIpProxyProtocol(raw.protocol), @@ -597,6 +611,8 @@ function buildIpProxyServiceProfileFromState(state = {}) { accountSessionPrefix: state?.ipProxyAccountSessionPrefix, accountLifeMinutes: state?.ipProxyAccountLifeMinutes, poolTargetCount: state?.ipProxyPoolTargetCount, + autoSyncEnabled: state?.ipProxyAutoSyncEnabled, + autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes, host: state?.ipProxyHost, port: state?.ipProxyPort, protocol: state?.ipProxyProtocol, @@ -1595,20 +1611,14 @@ function applyExitRegionExpectation(status = {}, expectedRegion = '') { const authHint = authSummary ? ` 诊断:probe:${authSummary}` : ''; const mismatchMessage = `代理出口地区与预期不一致:期望 ${expected},实际 ${detected || 'unknown'}${sourceHint}。${usernameHint}${missingAuthChallengeHint}${authHint}`.trim(); if (normalizedProvider === '711proxy') { - if (missingAuthChallenge) { - return { - ...status, - applied: false, - reason: 'connectivity_failed', - error: mismatchMessage, - warning: '', - }; - } + const warningPrefix = missingAuthChallenge + ? '地区校验未通过且未触发代理鉴权挑战,疑似匿名链路;先保留代理接管并给出强告警:' + : '地区校验未通过,但已保留代理接管:'; return { ...status, applied: true, reason: 'applied_with_warning', - warning: `地区校验未通过,但已保留代理接管:${mismatchMessage}`, + warning: `${warningPrefix}${mismatchMessage}`, error: '', }; } @@ -3172,9 +3182,11 @@ async function refreshIpProxyPool(options = {}) { forceAuthRebind: true, suppressAuthRebind: false, resetNetworkState: true, - skipExitProbe: false, + skipExitProbe: Boolean(options?.skipExitProbe), + } + : { + skipExitProbe: Boolean(options?.skipExitProbe), } - : undefined ); } @@ -3249,9 +3261,11 @@ async function switchIpProxy(direction = 'next', options = {}) { forceAuthRebind: true, suppressAuthRebind: false, resetNetworkState: true, - skipExitProbe: false, + skipExitProbe: Boolean(options?.skipExitProbe), + } + : { + skipExitProbe: Boolean(options?.skipExitProbe), } - : undefined ); } const summary = buildProxyPoolSummary(pool, nextIndex); @@ -3299,7 +3313,7 @@ async function changeIpProxyExit(options = {}) { forceAuthRebind: true, suppressAuthRebind: false, resetNetworkState: true, - skipExitProbe: false, + skipExitProbe: Boolean(options?.skipExitProbe), }); const runtime = getIpProxyRuntimeSnapshot(state, mode, provider); @@ -3405,6 +3419,10 @@ async function tryRecoverApiProxyByRotation(options = {}) { } async function probeIpProxyExit(options = {}) { + if (ipProxyProbeInFlightPromise) { + return ipProxyProbeInFlightPromise; + } + const probePromise = (async () => { const state = options.state || await getState(); if (!state?.ipProxyEnabled) { const status = { @@ -3599,6 +3617,15 @@ async function probeIpProxyExit(options = {}) { } await updateIpProxyRuntimeStatus(normalizedFinalStatus); return { proxyRouting: normalizedFinalStatus }; + })(); + ipProxyProbeInFlightPromise = probePromise; + try { + return await probePromise; + } finally { + if (ipProxyProbeInFlightPromise === probePromise) { + ipProxyProbeInFlightPromise = null; + } + } } async function ensureIpProxySettingsAppliedFromCurrentState(options = {}) { diff --git a/background/logging-status.js b/background/logging-status.js index 15aeff7..21cf691 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -58,7 +58,7 @@ function isVerificationMailPollingError(error) { const message = getErrorMessage(error); - return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); + return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s|405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/(?:email|phone)-verification/i.test(message); } function isAddPhoneAuthFailure(error) { diff --git a/background/message-router.js b/background/message-router.js index 3bb81d7..ed32560 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -61,6 +61,9 @@ isStopError, isTabAlive, launchAutoRunTimerPlan, + ensureIpProxyAutoSyncAlarm, + clearIpProxyAutoSyncAlarm, + runIpProxyAutoSync, listIcloudAliases, listLuckmailPurchasesForManagement, refreshIpProxyPool, @@ -328,7 +331,11 @@ const step5Status = latestState.stepStatuses?.[5]; if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') { await setStepStatus(5, 'skipped'); - await addLog('步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。', 'warn'); + if (payload.skipProfileStepReason === 'combined_verification_profile') { + await addLog('步骤 4:当前验证码页已内嵌完成注册资料提交,已自动跳过步骤 5。', 'warn'); + } else { + await addLog('步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。', 'warn'); + } } } break; @@ -716,6 +723,12 @@ oauthFlowDeadlineSourceUrl: null, } : {}), }; + if (Object.prototype.hasOwnProperty.call(updates, 'icloudHostPreference')) { + const nextHostPreference = String(updates.icloudHostPreference || '').trim().toLowerCase(); + stateUpdates.preferredIcloudHost = nextHostPreference === 'icloud.com' || nextHostPreference === 'icloud.com.cn' + ? nextHostPreference + : ''; + } if (stepModeChanged && typeof getStepIdsForState === 'function') { const nextStateForSteps = { ...currentState, ...stateUpdates }; stateUpdates.stepStatuses = Object.fromEntries( @@ -725,6 +738,19 @@ } await setState(stateUpdates); const mergedState = await getState(); + const hasIpProxyAutoSyncSettingChanged = ( + Object.prototype.hasOwnProperty.call(updates, 'ipProxyAutoSyncEnabled') + || Object.prototype.hasOwnProperty.call(updates, 'ipProxyAutoSyncIntervalMinutes') + ); + if (hasIpProxyAutoSyncSettingChanged) { + if (Boolean(mergedState?.ipProxyAutoSyncEnabled)) { + if (typeof ensureIpProxyAutoSyncAlarm === 'function') { + await ensureIpProxyAutoSyncAlarm(mergedState); + } + } else if (typeof clearIpProxyAutoSyncAlarm === 'function') { + await clearIpProxyAutoSyncAlarm(); + } + } const hasIpProxyUpdates = Object.keys(updates).some((key) => key.startsWith('ipProxy')); const hasIpProxyEnabledUpdate = Object.prototype.hasOwnProperty.call(updates, 'ipProxyEnabled'); const previousIpProxyEnabled = Boolean(currentState?.ipProxyEnabled); @@ -773,11 +799,19 @@ ).trim().toLowerCase() === 'gopay' ? 'GoPay' : 'PayPal'; - await addLog(`Plus 鏀粯鏂瑰紡宸插垏鎹负 ${selectedPlusPaymentMethod}锛屽凡鏇存柊瀵瑰簲鐨?Plus 姝ラ銆?`, 'info'); + await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info'); } return { ok: true, state: await getState(), proxyRouting }; } + case 'RUN_IP_PROXY_AUTO_SYNC_NOW': { + if (typeof runIpProxyAutoSync !== 'function') { + throw new Error('IP 代理自动同步能力尚未接入。'); + } + const result = await runIpProxyAutoSync('manual'); + return { ok: true, ...result }; + } + case 'REFRESH_IP_PROXY_POOL': { if (typeof refreshIpProxyPool !== 'function') { throw new Error('IP 代理池能力尚未接入。'); @@ -785,6 +819,7 @@ const result = await refreshIpProxyPool({ maxItems: message.payload?.maxItems, mode: message.payload?.mode, + skipExitProbe: message.payload?.skipExitProbe, }); return { ok: true, ...result }; } @@ -797,6 +832,7 @@ maxItems: message.payload?.maxItems, mode: message.payload?.mode, forceRefresh: message.payload?.forceRefresh, + skipExitProbe: message.payload?.skipExitProbe, }); return { ok: true, ...result }; } @@ -807,6 +843,7 @@ } const result = await changeIpProxyExit({ mode: message.payload?.mode, + skipExitProbe: message.payload?.skipExitProbe, }); return { ok: true, ...result }; } @@ -833,7 +870,7 @@ ); const timeoutMs = Number(message.payload?.timeoutMs) > 0 ? Number(message.payload.timeoutMs) - : (is711AccountMode ? (shouldPreRebindBeforeProbe ? 8000 : 6000) : undefined); + : (is711AccountMode ? (shouldPreRebindBeforeProbe ? 15000 : 12000) : undefined); // 手动“检测出口”前先轻量应用当前配置,避免读取到旧代理链路状态。 if (probeState?.ipProxyEnabled && typeof applyIpProxySettingsFromState === 'function') { diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index 4f5e4f0..cb18f0c 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -8,11 +8,20 @@ fetchImpl = (...args) => fetch(...args), getOAuthFlowStepTimeoutMs, getState, + sendToContentScript, sendToContentScriptResilient, setState, + broadcastDataUpdate = null, sleepWithStop, throwIfStopped, DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php', + DEFAULT_FIVE_SIM_BASE_URL = 'https://5sim.net/v1', + DEFAULT_FIVE_SIM_PRODUCT = 'openai', + DEFAULT_FIVE_SIM_OPERATOR = 'any', + DEFAULT_FIVE_SIM_COUNTRY_ORDER = ['thailand'], + DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net', + DEFAULT_NEX_SMS_COUNTRY_ORDER = [1], + DEFAULT_NEX_SMS_SERVICE_CODE = 'ot', DEFAULT_HERO_SMS_REUSE_ENABLED = true, HERO_SMS_COUNTRY_ID = 52, HERO_SMS_COUNTRY_LABEL = 'Thailand', @@ -27,6 +36,12 @@ const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation'; const PHONE_VERIFICATION_CODE_STATE_KEY = 'currentPhoneVerificationCode'; const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation'; + const REUSABLE_PHONE_ACTIVATION_POOL_STATE_KEY = 'phoneReusableActivationPool'; + const PREFERRED_PHONE_ACTIVATION_STATE_KEY = 'phonePreferredActivation'; + const PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY = 'currentPhoneVerificationCountdownEndsAt'; + const PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY = 'currentPhoneVerificationCountdownWindowIndex'; + const PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY = 'currentPhoneVerificationCountdownWindowTotal'; + const PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY = 'phoneNoSupplyFailureStreak'; const HERO_SMS_LAST_PRICE_TIERS_KEY = 'heroSmsLastPriceTiers'; const HERO_SMS_LAST_PRICE_COUNTRY_ID_KEY = 'heroSmsLastPriceCountryId'; const HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY = 'heroSmsLastPriceCountryLabel'; @@ -54,10 +69,24 @@ 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_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::'; + const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::'; const PHONE_SMS_FAILURE_SKIP_THRESHOLD = 2; + const MAX_ACTIVATION_PRICE_HINTS = 256; + const activationPriceHintsByKey = new Map(); function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) { const trimmed = String(value || '').trim(); @@ -75,10 +104,148 @@ return String(value || '').trim(); } + function normalizePhoneSmsProvider(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; + } + + function isFiveSimProvider(state = {}) { + return normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER) === PHONE_SMS_PROVIDER_5SIM; + } + + 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 normalized = []; + const seen = new Set(); + source.forEach((entry) => { + const id = normalizeNexSmsCountryId( + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry.id || entry.countryId || entry.country || '') + : entry, + -1 + ); + if (id < 0 || seen.has(id)) { + return; + } + seen.add(id); + normalized.push(id); + }); + return normalized.slice(0, 10); + } + + function resolveNexSmsCountryCandidates(state = {}) { + const ids = normalizeNexSmsCountryOrder(state?.nexSmsCountryOrder); + return ids.map((id) => ({ + id, + label: `Country #${id}`, + })); + } + + 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 normalizeFiveSimCountryCode(value = '', fallback = 'thailand') { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ''); + return normalized || fallback; + } + + function normalizeFiveSimCountryOrder(value = []) { + 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 code = normalizeFiveSimCountryCode( + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry.code || entry.country || entry.id || '') + : entry, + '' + ); + if (!code || seen.has(code)) { + return; + } + seen.add(code); + normalized.push(code); + }); + + return normalized.slice(0, 10); + } + + function resolveFiveSimCountryCandidates(state = {}) { + const codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder); + return codes.map((code) => ({ + code, + id: code, + label: code, + })); + } + function normalizeUseCount(value) { return Math.max(0, Math.floor(Number(value) || 0)); } + function normalizeTimestampMs(value) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + if (numeric >= 1000000000000) { + return Math.floor(numeric); + } + if (numeric >= 1000000000) { + return Math.floor(numeric * 1000); + } + } + + const text = String(value || '').trim(); + if (!text) { + return 0; + } + const parsed = Date.parse(text); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0; + } + function normalizePhoneReplacementLimit(value) { const parsed = Math.floor(Number(value)); if (!Number.isFinite(parsed) || parsed <= 0) { @@ -119,7 +286,26 @@ if (!text) { return false; } - return /already\s+linked\s+to\s+the\s+maximum\s+number\s+of\s+accounts|phone\s+number\s+is\s+already\s+(?:in\s+use|linked|registered)|phone\s+number\s+has\s+already\s+been\s+used|already\s+associated\s+with\s+another\s+account|not\s+eligible\s+to\s+be\s+used|cannot\s+be\s+used\s+for\s+verification|号码.*(?:已|被).*(?:使用|占用|绑定|注册)|手机号.*(?:已|被).*(?:使用|占用|绑定|注册)|该手机号.*(?:已|被).*(?:使用|占用|绑定|注册)/i.test(text); + return /phone_max_usage_exceeded|phone_number_in_use|already\s+linked\s+to\s+the\s+maximum\s+number\s+of\s+accounts|phone\s+number\s+is\s+already\s+(?:in\s+use|linked|registered)|phone\s+number\s+has\s+already\s+been\s+used|already\s+associated\s+with\s+another\s+account|not\s+eligible\s+to\s+be\s+used|cannot\s+be\s+used\s+for\s+verification|号码.*(?:已|被).*(?:使用|占用|绑定|注册)|手机号.*(?:已|被).*(?:使用|占用|绑定|注册)|该手机号.*(?:已|被).*(?:使用|占用|绑定|注册)/i.test(text); + } + + function isPhoneNumberInvalidError(value) { + const text = String(value || '').trim(); + if (!text) { + return false; + } + return /phone\s+number\s+is\s+not\s+valid|invalid\s+phone\s+number|invalid\s+phone|not\s+a\s+valid\s+phone|号码.*无效|手机号.*无效|电话号码.*无效/i.test(text); + } + + function isRecoverableAddPhoneSubmitError(value) { + const text = String(value || '').trim(); + if (!text) { + return false; + } + return ( + isPhoneNumberInvalidError(text) + || /failed\s+to\s+select\b.*add-phone\s+page|missing\s+the\s+country\s+option|could\s+not\s+determine\s+the\s+dial\s+code|add-phone\s+page\s+is\s+missing\s+the\s+phone\s+number\s+input|add-phone\s+page\s+is\s+missing\s+the\s+submit\s+button/i.test(text) + ); } function normalizeCountryId(value, fallback = HERO_SMS_COUNTRY_ID) { @@ -178,9 +364,292 @@ } function normalizeHeroSmsAcquirePriority(value = '') { - return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE - ? HERO_SMS_ACQUIRE_PRIORITY_PRICE - : HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE) { + return HERO_SMS_ACQUIRE_PRIORITY_PRICE; + } + if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) { + return HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH; + } + return HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; + } + + 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); + } + + const fallback = Array.isArray(fallbackOrder) ? fallbackOrder : []; + if (!fallback.length) { + return []; + } + const fallbackNormalized = []; + fallback.forEach((entry) => { + const provider = normalizePhoneSmsProvider(entry); + if (!provider || fallbackNormalized.includes(provider)) { + return; + } + fallbackNormalized.push(provider); + }); + + return fallbackNormalized.slice(0, 3); + } + + function resolvePhoneProviderOrder(state = {}, preferredProvider = '') { + const currentProvider = normalizePhoneSmsProvider( + preferredProvider || state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER + ); + const hasExplicitOrder = Array.isArray(state?.phoneSmsProviderOrder) + ? state.phoneSmsProviderOrder.length > 0 + : String(state?.phoneSmsProviderOrder || '').trim().length > 0; + if (hasExplicitOrder) { + const explicitOrder = normalizePhoneSmsProviderOrder( + state?.phoneSmsProviderOrder, + [] + ); + if (explicitOrder.length) { + return explicitOrder; + } + return [currentProvider]; + } + const fallbackOrder = normalizePhoneSmsProviderOrder( + [currentProvider], + DEFAULT_PHONE_SMS_PROVIDER_ORDER + ); + if (fallbackOrder[0] === currentProvider) { + return fallbackOrder; + } + const withoutCurrent = fallbackOrder.filter((provider) => provider !== currentProvider); + return [currentProvider, ...withoutCurrent].slice(0, 3); + } + + function reorderPriceCandidates(prices = [], acquirePriority = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY, preferredPrice = null) { + const hasNullTier = Array.isArray(prices) + && prices.some((value) => value === null || value === undefined || String(value).trim() === ''); + const normalized = Array.from( + new Set( + (Array.isArray(prices) ? prices : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.round(value * 10000) / 10000) + ) + ).sort((left, right) => left - right); + const ordered = acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH + ? normalized.reverse() + : normalized; + const preferred = Number(preferredPrice); + if (!Number.isFinite(preferred) || preferred <= 0) { + if (ordered.length) { + return ordered; + } + return hasNullTier ? [null] : []; + } + const normalizedPreferred = Math.round(preferred * 10000) / 10000; + const withoutPreferred = ordered.filter((value) => value !== normalizedPreferred); + return [normalizedPreferred, ...withoutPreferred]; + } + + function filterPriceCandidatesAboveFloor(prices = [], minExclusivePrice = null) { + const floor = normalizeHeroSmsPrice(minExclusivePrice); + if (floor === null || floor <= 0) { + return Array.isArray(prices) ? [...prices] : []; + } + return (Array.isArray(prices) ? prices : []).filter((value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return false; + } + const normalized = Math.round(numeric * 10000) / 10000; + return normalized > floor; + }); + } + + function shouldUseHeroSmsExpandedPriceLookup(state = {}) { + if (typeof state?.heroSmsUseExpandedPriceLookup === 'boolean') { + return state.heroSmsUseExpandedPriceLookup; + } + const runningInNode = ( + typeof process !== 'undefined' + && process + && process.versions + && process.versions.node + ); + // Runtime default: enabled in extension/browser; tests in Node can opt-in explicitly. + return !runningInNode; + } + + async function fetchHeroSmsPricePayloads(config, countryConfig, options = {}) { + const payloads = []; + const errors = []; + const actions = Array.isArray(options.actions) && options.actions.length + ? options.actions + : ( + shouldUseHeroSmsExpandedPriceLookup(options.state || {}) + ? ['getPricesExtended', 'getPrices'] + : ['getPrices'] + ); + + for (const action of actions) { + try { + const query = { + action, + service: HERO_SMS_SERVICE_CODE, + country: countryConfig.id, + }; + if (action === 'getPricesExtended') { + query.freePrice = 'true'; + } + const payload = await fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); + payloads.push(payload); + } catch (error) { + errors.push({ + action, + message: describeHeroSmsPayload(error?.payload || error?.message || ''), + }); + } + } + + return { + payloads, + errors, + }; + } + + function collectHeroSmsPriceCandidatesIncludingZeroStock(payload, candidates = []) { + if (Array.isArray(payload)) { + payload.forEach((entry) => collectHeroSmsPriceCandidatesIncludingZeroStock(entry, candidates)); + return candidates; + } + if (!payload || typeof payload !== 'object') { + return candidates; + } + + const cost = normalizeHeroSmsPrice(payload.cost); + if (cost !== null) { + candidates.push(cost); + } + + Object.entries(payload).forEach(([key]) => { + const keyedPrice = normalizeHeroSmsPrice(key); + if (keyedPrice !== null) { + const value = payload[key]; + if (value && typeof value === 'object') { + const stockState = resolveHeroSmsStockState(value); + if (stockState.hasStockField) { + candidates.push(keyedPrice); + } + return; + } + const numericCount = Number(value); + if (Number.isFinite(numericCount)) { + candidates.push(keyedPrice); + } + } + }); + + Object.values(payload).forEach((value) => collectHeroSmsPriceCandidatesIncludingZeroStock(value, candidates)); + return candidates; + } + + async function resolveHeroSmsPricePlanFromPricePayloads(config, countryConfig, state = {}, payloads = []) { + const userLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice); + const inStockCandidates = buildSortedUniquePriceCandidates( + (Array.isArray(payloads) ? payloads : []) + .flatMap((payload) => collectHeroSmsPriceCandidates(payload, [])) + ); + const allCatalogCandidates = buildSortedUniquePriceCandidates( + (Array.isArray(payloads) ? payloads : []) + .flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, [])) + ); + const mergedCandidates = buildSortedUniquePriceCandidates([ + ...inStockCandidates, + ...allCatalogCandidates, + ]); + const minCatalogPrice = allCatalogCandidates.length + ? allCatalogCandidates[0] + : (mergedCandidates.length ? mergedCandidates[0] : null); + + if (userLimit !== null) { + const bounded = mergedCandidates.filter((price) => price <= userLimit); + if (bounded.length > 0) { + const boundedPlan = { + prices: bounded, + userLimit, + minCatalogPrice, + syntheticUserLimitProbe: false, + }; + await persistHeroSmsPricePlanSnapshot(countryConfig, boundedPlan); + return boundedPlan; + } + const userLimitedPlan = { + prices: [userLimit], + userLimit, + minCatalogPrice, + syntheticUserLimitProbe: true, + }; + await persistHeroSmsPricePlanSnapshot(countryConfig, userLimitedPlan); + return userLimitedPlan; + } + + if (mergedCandidates.length > 0) { + const plan = { + prices: mergedCandidates, + userLimit: null, + minCatalogPrice, + syntheticUserLimitProbe: false, + }; + await persistHeroSmsPricePlanSnapshot(countryConfig, plan); + return plan; + } + const fallbackPlan = { + prices: [null], + userLimit: null, + minCatalogPrice: null, + syntheticUserLimitProbe: false, + }; + await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan); + return fallbackPlan; + } + + function normalizeCountryPriceFloorMap(rawMap = {}, normalizeCountryKey) { + const normalizedMap = new Map(); + if (!rawMap || typeof rawMap !== 'object') { + return normalizedMap; + } + Object.entries(rawMap).forEach(([rawCountryKey, rawPrice]) => { + const countryKey = String( + typeof normalizeCountryKey === 'function' + ? normalizeCountryKey(rawCountryKey) + : rawCountryKey + ).trim(); + if (!countryKey) { + return; + } + const normalizedPrice = normalizeHeroSmsPrice(rawPrice); + if (normalizedPrice === null || normalizedPrice <= 0) { + return; + } + normalizedMap.set(countryKey, Math.round(normalizedPrice * 10000) / 10000); + }); + return normalizedMap; } function normalizeCountryFallbackList(value = []) { @@ -225,8 +694,27 @@ } function resolveCountryConfig(state = {}) { + const hasExplicitPrimaryCountry = Object.prototype.hasOwnProperty.call(state || {}, 'heroSmsCountryId'); + const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback); + const primaryCountryId = normalizeCountryId(state.heroSmsCountryId, 0); + if (primaryCountryId > 0) { + return { + id: primaryCountryId, + label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL), + }; + } + if (hasExplicitPrimaryCountry) { + if (fallbackList.length) { + const firstFallback = fallbackList[0]; + return { + id: normalizeCountryId(firstFallback.id, 0), + label: normalizeCountryLabel(firstFallback.label, `Country #${firstFallback.id}`), + }; + } + return null; + } return { - id: normalizeCountryId(state.heroSmsCountryId, HERO_SMS_COUNTRY_ID), + id: normalizeCountryId(HERO_SMS_COUNTRY_ID, HERO_SMS_COUNTRY_ID), label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL), }; } @@ -234,6 +722,14 @@ function resolveCountryCandidates(state = {}) { const primary = resolveCountryConfig(state); const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback); + if (!primary || !Number.isFinite(primary.id) || primary.id <= 0) { + return fallbackList + .map((entry) => ({ + id: normalizeCountryId(entry.id, 0), + label: normalizeCountryLabel(entry.label, `Country #${entry.id}`), + })) + .filter((entry) => entry.id > 0); + } const seen = new Set([primary.id]); const candidates = [primary]; @@ -256,6 +752,7 @@ if (!record || typeof record !== 'object' || Array.isArray(record)) { return null; } + const provider = normalizePhoneSmsProvider(record.provider || ''); const activationId = String( record.activationId ?? record.id ?? record.activation ?? '' ).trim(); @@ -267,28 +764,137 @@ } 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); return { activationId, phoneNumber, - provider: String(record.provider || 'hero-sms').trim() || 'hero-sms', - serviceCode: String(record.serviceCode || HERO_SMS_SERVICE_CODE).trim() || HERO_SMS_SERVICE_CODE, - countryId: normalizeCountryId(record.countryId, HERO_SMS_COUNTRY_ID), + 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 } : {}), ...(countryLabel ? { countryLabel } : {}), successfulUses: normalizeUseCount(record.successfulUses), maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), + ...(expiresAt > 0 ? { expiresAt } : {}), ...(statusAction ? { statusAction } : {}), }; } + function normalizeActivationPool(value = []) { + const source = Array.isArray(value) ? value : []; + const normalized = []; + const seen = new Set(); + source.forEach((entry) => { + const activation = normalizeActivation(entry); + if (!activation) { + return; + } + const key = buildActivationIdentityKey(activation); + if (!key || seen.has(key)) { + return; + } + seen.add(key); + normalized.push(activation); + }); + return normalized.slice(0, MAX_PHONE_REUSABLE_POOL); + } + + function buildActivationIdentityKey(activation) { + const normalized = normalizeActivation(activation); + if (!normalized) { + return ''; + } + return [ + normalizePhoneSmsProvider(normalized.provider || ''), + String(normalized.activationId || '').trim(), + String(normalized.phoneNumber || '').trim(), + ].join('::'); + } + + function isSameActivation(left, right) { + const leftKey = buildActivationIdentityKey(left); + const rightKey = buildActivationIdentityKey(right); + return Boolean(leftKey && rightKey && leftKey === rightKey); + } + + function rememberActivationAcquiredPrice(activation, price) { + const key = buildActivationIdentityKey(activation); + const normalizedPrice = normalizeHeroSmsPrice(price); + if (!key || normalizedPrice === null || normalizedPrice <= 0) { + return; + } + const roundedPrice = Math.round(normalizedPrice * 10000) / 10000; + activationPriceHintsByKey.set(key, roundedPrice); + while (activationPriceHintsByKey.size > MAX_ACTIVATION_PRICE_HINTS) { + const oldest = activationPriceHintsByKey.keys().next(); + if (oldest?.done) { + break; + } + activationPriceHintsByKey.delete(oldest.value); + } + } + + function getActivationAcquiredPriceHint(activation) { + const key = buildActivationIdentityKey(activation); + if (!key) { + return null; + } + const raw = activationPriceHintsByKey.get(key); + const normalizedPrice = normalizeHeroSmsPrice(raw); + return normalizedPrice === null || normalizedPrice <= 0 + ? null + : Math.round(normalizedPrice * 10000) / 10000; + } + + function forgetActivationAcquiredPriceHint(activation) { + const key = buildActivationIdentityKey(activation); + if (!key) { + return; + } + activationPriceHintsByKey.delete(key); + } + + async function setPhoneRuntimeState(updates = {}) { + await setState(updates); + if (typeof broadcastDataUpdate === 'function') { + broadcastDataUpdate(updates); + } + } + function normalizeActivationFallback(record) { if (!record || typeof record !== 'object' || Array.isArray(record)) { return null; } const fallback = {}; - const provider = String(record.provider || '').trim(); + const provider = normalizePhoneSmsProvider(record.provider || ''); const serviceCode = String(record.serviceCode || '').trim(); - const countryId = Math.floor(Number(record.countryId)); + 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 countryLabel = String(record.countryLabel || '').trim(); const statusAction = String(record.statusAction || '').trim(); @@ -298,8 +904,20 @@ if (serviceCode) { fallback.serviceCode = serviceCode; } - if (Number.isFinite(countryId) && countryId > 0) { + 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 + ) + ) { fallback.countryId = countryId; + if (provider === PHONE_SMS_PROVIDER_5SIM) { + fallback.countryCode = countryId; + } } if (countryLabel) { fallback.countryLabel = countryLabel; @@ -366,7 +984,7 @@ } function buildPhoneCodeTimeoutError(lastResponse = '') { - const suffix = lastResponse ? ` Last HeroSMS status: ${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}`); } @@ -385,6 +1003,39 @@ return /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试/i.test(message); } + function isPhoneRoute405RecoveryError(error) { + const message = String(error?.message || error || '').trim(); + if (!message) { + return false; + } + if (message.startsWith(PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX)) { + return true; + } + return /route\s+error.*405|405\s+method\s+not\s+allowed|post\s+request\s+to\s+["']?\/phone-verification|did\s+not\s+provide\s+an?\s+[`'"]?action/i.test(message); + } + + function isPhoneActivationOrderMissingError(error, provider = '') { + const message = String(error?.message || error || '').trim(); + if (!message) { + return false; + } + const normalizedProvider = normalizePhoneSmsProvider(provider); + if (normalizedProvider === PHONE_SMS_PROVIDER_5SIM) { + return /5sim\s+check\s+activation\s+failed.*order\s+not\s+found|order\s+not\s+found|activation\s+not\s+found|no\s+such\s+order/i.test(message); + } + return /activation\s+not\s+found|order\s+not\s+found|no\s+such\s+order/i.test(message); + } + + function isStopRequestedError(error) { + const message = String(error?.message || error || '').trim(); + if (!message) { + return false; + } + return message === '流程已被用户停止。' + || /已被用户停止/.test(message) + || /flow\s+was\s+stopped|stopped\s+by\s+user/i.test(message); + } + function buildPhoneRestartStep7Error(phoneNumber = '') { const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : ''; return new Error( @@ -447,14 +1098,214 @@ } } + function parseFiveSimPayload(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + return ''; + } + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + + function describeFiveSimPayload(raw) { + if (typeof raw === 'string') { + return raw.trim(); + } + if (raw && typeof raw === 'object') { + const message = String(raw.message || raw.error || raw.msg || raw.statusText || '').trim(); + if (message) { + return message; + } + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } + } + return String(raw || '').trim(); + } + + async function fetchFiveSimPayload(config, path, actionLabel, options = {}) { + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS) + : null; + + try { + const requestUrl = new URL(path.replace(/^\/+/, ''), `${config.baseUrl.replace(/\/+$/, '')}/`); + const query = (options && options.query && typeof options.query === 'object') ? options.query : {}; + Object.entries(query).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + requestUrl.searchParams.set(key, String(value)); + }); + + const response = await fetchImpl(requestUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${config.apiKey}`, + }, + signal: controller?.signal, + }); + const text = await response.text(); + const payload = parseFiveSimPayload(text); + if (!response.ok) { + const requestError = new Error(`${actionLabel} failed: ${describeFiveSimPayload(payload) || response.status}`); + requestError.payload = payload; + requestError.status = response.status; + throw requestError; + } + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`${actionLabel} timed out.`); + } + throw error; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + function parseNexSmsPayload(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + return ''; + } + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + + function describeNexSmsPayload(raw) { + if (typeof raw === 'string') { + return raw.trim(); + } + if (raw && typeof raw === 'object') { + const message = String(raw.message || raw.error || raw.msg || raw.statusText || '').trim(); + if (message) { + return message; + } + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } + } + return String(raw || '').trim(); + } + + function isNexSmsSuccessPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return false; + } + return Number(payload.code) === 0; + } + + async function fetchNexSmsPayload(config, path, actionLabel, options = {}) { + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS) + : null; + + try { + const method = String(options.method || 'GET').trim().toUpperCase() || 'GET'; + const requestUrl = new URL(path.replace(/^\/+/, ''), `${config.baseUrl.replace(/\/+$/, '')}/`); + requestUrl.searchParams.set('apiKey', config.apiKey); + const query = (options && options.query && typeof options.query === 'object') ? options.query : {}; + Object.entries(query).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + requestUrl.searchParams.set(key, String(value)); + }); + const headers = { + Accept: 'application/json', + ...(options.headers && typeof options.headers === 'object' ? options.headers : {}), + }; + const requestInit = { + method, + headers, + signal: controller?.signal, + }; + if (method !== 'GET' && method !== 'HEAD' && options.body !== undefined) { + requestInit.body = typeof options.body === 'string' + ? options.body + : JSON.stringify(options.body); + if (!requestInit.headers['Content-Type']) { + requestInit.headers['Content-Type'] = 'application/json'; + } + } + const response = await fetchImpl(requestUrl.toString(), requestInit); + const text = await response.text(); + const payload = parseNexSmsPayload(text); + if (!response.ok) { + const requestError = new Error(`${actionLabel} failed: ${describeNexSmsPayload(payload) || response.status}`); + requestError.payload = payload; + requestError.status = response.status; + throw requestError; + } + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`${actionLabel} timed out.`); + } + throw error; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + function resolvePhoneConfig(state = {}) { + const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER); + if (provider === PHONE_SMS_PROVIDER_5SIM) { + const apiKey = normalizeApiKey(state.fiveSimApiKey || state.heroSmsApiKey); + if (!apiKey) { + throw new Error('5sim API key is missing. Save it in the side panel before running the phone flow.'); + } + return { + provider, + apiKey, + baseUrl: normalizeUrl(state.fiveSimBaseUrl, DEFAULT_FIVE_SIM_BASE_URL).replace(/\/+$/, ''), + operator: normalizeFiveSimCountryCode(state.fiveSimOperator, DEFAULT_FIVE_SIM_OPERATOR), + product: normalizeFiveSimCountryCode(state.fiveSimProduct, DEFAULT_FIVE_SIM_PRODUCT), + countryCandidates: resolveFiveSimCountryCandidates(state), + }; + } + + if (provider === PHONE_SMS_PROVIDER_NEXSMS) { + const apiKey = normalizeApiKey(state.nexSmsApiKey || state.heroSmsApiKey); + if (!apiKey) { + throw new Error('NexSMS API key is missing. Save it in the side panel before running the phone flow.'); + } + return { + provider, + apiKey, + baseUrl: normalizeUrl(state.nexSmsBaseUrl, DEFAULT_NEX_SMS_BASE_URL).replace(/\/+$/, ''), + serviceCode: normalizeNexSmsServiceCode(state.nexSmsServiceCode, DEFAULT_NEX_SMS_SERVICE_CODE), + countryCandidates: resolveNexSmsCountryCandidates(state), + }; + } + const apiKey = normalizeApiKey(state.heroSmsApiKey); if (!apiKey) { throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.'); } return { + provider, apiKey, baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), + countryCandidates: resolveCountryCandidates(state), }; } @@ -485,7 +1336,7 @@ return { activationId: String(accessNumberMatch[1] || '').trim(), phoneNumber: String(accessNumberMatch[2] || '').trim(), - provider: normalizedFallback?.provider || 'hero-sms', + provider: normalizedFallback?.provider || PHONE_SMS_PROVIDER_HERO, serviceCode: normalizedFallback?.serviceCode || HERO_SMS_SERVICE_CODE, countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID, ...(normalizedFallback?.countryLabel ? { countryLabel: normalizedFallback.countryLabel } : {}), @@ -507,11 +1358,54 @@ } function normalizeHeroSmsPrice(value) { - const price = Number(value); - if (!Number.isFinite(price) || price < 0) { + const direct = Number(value); + if (Number.isFinite(direct) && direct >= 0) { + return direct; + } + + const text = String(value ?? '').trim(); + if (!text) { return null; } - return price; + + // HeroSMS occasionally returns formatted price strings such as "$0.1183". + // Extract the first decimal token so those tiers can still participate in + // fallback selection and pricing diagnostics. + const matched = text.match(/-?\d+(?:[.,]\d+)?/); + if (!matched) { + return null; + } + const normalizedText = String(matched[0] || '').replace(',', '.'); + const parsed = Number(normalizedText); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; + } + + function resolveHeroSmsStockState(payload = {}) { + const stockCandidates = [ + payload.count, + payload.physicalCount, + payload.stock, + payload.available, + payload.quantity, + payload.qty, + payload.left, + payload.free, + ] + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + if (!stockCandidates.length) { + return { + hasStockField: false, + stockCount: 0, + }; + } + return { + hasStockField: true, + stockCount: Math.max(...stockCandidates), + }; } function collectHeroSmsPriceCandidates(payload, candidates = []) { @@ -525,15 +1419,35 @@ const cost = normalizeHeroSmsPrice(payload.cost); if (cost !== null) { - const count = Number(payload.count); - const physicalCount = Number(payload.physicalCount); - const hasCount = Number.isFinite(count); - const hasPhysicalCount = Number.isFinite(physicalCount); - if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) { + const stockState = resolveHeroSmsStockState(payload); + if (!stockState.hasStockField || stockState.stockCount > 0) { candidates.push(cost); } } + // Some HeroSMS responses expose price tiers as object keys: + // { "0.05": { count: 0 }, "0.35": { count: 12 } }. + // Parse those keyed tiers so higher-price stock is not missed. + Object.entries(payload).forEach(([key, value]) => { + const keyedPrice = normalizeHeroSmsPrice(key); + if (keyedPrice === null) { + return; + } + if (value && typeof value === 'object') { + const stockState = resolveHeroSmsStockState(value); + // Ignore numeric keys that are actually country/service IDs. + // Keyed price tiers are considered valid only when stock fields exist. + if (stockState.hasStockField && stockState.stockCount > 0) { + candidates.push(keyedPrice); + } + return; + } + const numericCount = Number(value); + if (Number.isFinite(numericCount) && numericCount > 0) { + candidates.push(keyedPrice); + } + }); + Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates)); return candidates; } @@ -590,17 +1504,128 @@ return /\bNO_BALANCE\b|\bNOT_ENOUGH_BALANCE\b|\bBAD_KEY\b|\bINVALID_KEY\b|\bBANNED\b|\bACCOUNT_BANNED\b|\bWRONG_KEY\b/i.test(text); } + function isProviderNoSupplyFailureMessage(message = '') { + const text = String(message || '').trim(); + if (!text) { + return false; + } + return /no\s+numbers\s+available\s+across|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+maxprice|step\s*9:\s*(?:5sim|nexsms)\s+countries\s+are\s+empty|\bNO_NUMBERS\b/i.test(text); + } + + function resolveNoSupplyDiagnosticsContext(state = {}, providerOrder = []) { + const order = Array.isArray(providerOrder) && providerOrder.length + ? providerOrder + : resolvePhoneProviderOrder(state, state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER); + const heroCountryCount = resolveCountryCandidates(state).length; + const fiveSimCountryCount = resolveFiveSimCountryCandidates(state).length; + const nexSmsCountryCount = resolveNexSmsCountryCandidates(state).length; + const maxPrice = normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice); + const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + return { + order, + heroCountryCount, + fiveSimCountryCount, + nexSmsCountryCount, + maxPrice, + acquirePriority, + }; + } + + function formatNoSupplySuggestion(context = {}) { + const suggestions = []; + const maxPrice = Number(context?.maxPrice); + if (!Number.isFinite(maxPrice) || maxPrice <= 0) { + suggestions.push('先设置价格上限(建议 >= 0.12)'); + } else if (maxPrice < 0.12) { + suggestions.push('先提高价格上限(当前偏低)'); + } + + if ((context?.heroCountryCount || 0) <= 1) { + suggestions.push('HeroSMS 增加国家回退'); + } + if ((context?.fiveSimCountryCount || 0) <= 0) { + suggestions.push('5sim 至少选择 1 个国家'); + } + if ((context?.nexSmsCountryCount || 0) <= 0) { + suggestions.push('NexSMS 至少选择 1 个国家'); + } + if (String(context?.acquirePriority || '') === HERO_SMS_ACQUIRE_PRIORITY_COUNTRY) { + suggestions.push('可尝试切到“价格优先”'); + } + + const unique = Array.from(new Set(suggestions)); + if (!unique.length) { + return '优先提高价格上限,并调整服务商/国家优先级后重试'; + } + return unique.slice(0, 3).join(';'); + } + + async function resetPhoneNoSupplyFailureStreak(state = {}) { + const latestState = (typeof getState === 'function') + ? (await getState().catch(() => state)) + : state; + const current = Math.max(0, Math.floor(Number(latestState?.[PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]) || 0)); + if (current > 0) { + await setPhoneRuntimeState({ + [PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]: 0, + }); + } + } + + async function logNoSupplyDiagnostics(state = {}, providerOrder = [], providerErrors = []) { + const allNoSupply = Array.isArray(providerErrors) + && providerErrors.length > 0 + && providerErrors.every((entry) => isProviderNoSupplyFailureMessage(entry)); + if (!allNoSupply) { + await resetPhoneNoSupplyFailureStreak(state); + return false; + } + + const latestState = (typeof getState === 'function') + ? (await getState().catch(() => state)) + : state; + const previousStreak = Math.max( + 0, + Math.floor(Number(latestState?.[PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]) || 0) + ); + const nextStreak = previousStreak + 1; + await setPhoneRuntimeState({ + [PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]: nextStreak, + }); + + const context = resolveNoSupplyDiagnosticsContext( + latestState && typeof latestState === 'object' ? latestState : state, + providerOrder + ); + const maxPriceText = context.maxPrice === null ? '未设置' : String(context.maxPrice); + const providerOrderText = context.order.join(' > '); + const suggestion = formatNoSupplySuggestion(context); + await addLog( + `Step 9 diagnostics: 无号连续失败 ${nextStreak} 次;maxPrice=${maxPriceText};providerOrder=${providerOrderText};国家数 HeroSMS=${context.heroCountryCount}, 5sim=${context.fiveSimCountryCount}, NexSMS=${context.nexSmsCountryCount}。建议:${suggestion}。`, + nextStreak >= 2 ? 'warn' : 'info' + ); + return true; + } + async function resolveCheapestPhoneActivationPrice(config, countryConfig) { for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { try { - const payload = await fetchHeroSmsPayload(config, { - action: 'getPrices', - service: HERO_SMS_SERVICE_CODE, - country: countryConfig.id, - }, 'HeroSMS getPrices'); - const price = findLowestHeroSmsPrice(payload); - if (price !== null) { - return price; + const { payloads } = await fetchHeroSmsPricePayloads(config, countryConfig, { state }); + const price = findLowestHeroSmsPrice( + payloads && payloads.length + ? payloads + : [] + ); + const normalizedPrice = Number.isFinite(Number(price)) ? Number(price) : null; + if (normalizedPrice !== null) { + return normalizedPrice; + } + const fallbackCandidates = buildSortedUniquePriceCandidates( + (Array.isArray(payloads) ? payloads : []) + .flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, [])) + ); + if (fallbackCandidates.length > 0) { + return fallbackCandidates[0]; } } catch (_) { // Best-effort lookup only. @@ -629,46 +1654,36 @@ } async function resolvePhoneActivationPricePlan(config, countryConfig, state = {}) { - const userLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice); - let priceCandidates = []; - for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { try { - const payload = await fetchHeroSmsPayload(config, { - action: 'getPrices', - service: HERO_SMS_SERVICE_CODE, - country: countryConfig.id, - }, 'HeroSMS getPrices'); - priceCandidates = buildSortedUniquePriceCandidates( - collectHeroSmsPriceCandidates(payload, []) + const { payloads } = await fetchHeroSmsPricePayloads(config, countryConfig, { state }); + const plan = await resolveHeroSmsPricePlanFromPricePayloads( + config, + countryConfig, + state, + payloads ); - if (priceCandidates.length > 0) { - break; + if ( + Array.isArray(plan?.prices) + && plan.prices.length > 0 + && ( + plan.prices.some((price) => Number.isFinite(Number(price)) && Number(price) > 0) + || plan.syntheticUserLimitProbe + ) + ) { + return plan; } } catch (_) { // best effort } } - const minCatalogPrice = priceCandidates.length > 0 ? priceCandidates[0] : null; - if (userLimit !== null) { - const bounded = priceCandidates.filter((price) => price <= userLimit); - if (bounded.length > 0) { - const boundedPlan = { prices: bounded, userLimit, minCatalogPrice }; - await persistHeroSmsPricePlanSnapshot(countryConfig, boundedPlan); - return boundedPlan; - } - const userLimitedPlan = { prices: [userLimit], userLimit, minCatalogPrice }; - await persistHeroSmsPricePlanSnapshot(countryConfig, userLimitedPlan); - return userLimitedPlan; - } - - if (priceCandidates.length > 0) { - const plan = { prices: priceCandidates, userLimit: null, minCatalogPrice }; - await persistHeroSmsPricePlanSnapshot(countryConfig, plan); - return plan; - } - const fallbackPlan = { prices: [null], userLimit: null, minCatalogPrice: null }; + const fallbackPlan = { + prices: [null], + userLimit: null, + minCatalogPrice: null, + syntheticUserLimitProbe: false, + }; await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan); return fallbackPlan; } @@ -681,7 +1696,9 @@ }; if (options.maxPrice !== null && options.maxPrice !== undefined) { query.maxPrice = options.maxPrice; - query.fixedPrice = 'true'; + if (options.fixedPrice !== false) { + query.fixedPrice = 'true'; + } } return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); } @@ -696,6 +1713,7 @@ try { return await fetchPhoneActivationPayload(config, countryConfig, action, { maxPrice: nextMaxPrice, + fixedPrice: options.fixedPrice, }); } catch (error) { const updatedMaxPrice = extractHeroSmsWrongMaxPrice(error?.payload || error?.message); @@ -731,9 +1749,841 @@ } } + function collectFiveSimPriceCandidates(payload, candidates = []) { + if (Array.isArray(payload)) { + payload.forEach((entry) => collectFiveSimPriceCandidates(entry, candidates)); + return candidates; + } + if (!payload || typeof payload !== 'object') { + return candidates; + } + const cost = Number(payload.cost); + const count = Number(payload.count); + if (Number.isFinite(cost) && cost > 0) { + if (!Number.isFinite(count) || count > 0) { + candidates.push(Math.round(cost * 10000) / 10000); + } + } + Object.entries(payload).forEach(([key, value]) => { + const keyedPrice = Number(key); + if (!Number.isFinite(keyedPrice) || keyedPrice <= 0) { + return; + } + if (value && typeof value === 'object') { + const keyedCount = Number(value.count); + if (!Number.isFinite(keyedCount) || keyedCount > 0) { + candidates.push(Math.round(keyedPrice * 10000) / 10000); + } + return; + } + const numericCount = Number(value); + if (!Number.isFinite(numericCount) || numericCount > 0) { + candidates.push(Math.round(keyedPrice * 10000) / 10000); + } + }); + Object.values(payload).forEach((entry) => collectFiveSimPriceCandidates(entry, candidates)); + return candidates; + } + + function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '') { + const normalizedProduct = normalizeFiveSimCountryCode(product, DEFAULT_FIVE_SIM_PRODUCT); + const normalizedCountryCode = normalizeFiveSimCountryCode(countryCode, ''); + const root = payload && typeof payload === 'object' + ? (payload[normalizedProduct] || payload) + : {}; + const countryPayload = ( + normalizedCountryCode + ? (root?.[normalizedCountryCode] || root) + : root + ); + const candidates = collectFiveSimPriceCandidates(countryPayload, []); + if (!candidates.length) { + return null; + } + return Math.min(...candidates); + } + + function isFiveSimNoNumbersError(payloadOrMessage) { + const text = describeFiveSimPayload(payloadOrMessage); + return /no\s+free\s+phones|no\s+phones\s+available|no\s+numbers\s+available/i.test(text); + } + + function isFiveSimTerminalError(payloadOrMessage, status = 0) { + if (Number(status) === 401 || Number(status) === 403) { + return true; + } + const text = describeFiveSimPayload(payloadOrMessage); + return /not\s+enough\s+balance|no\s+balance|unauthorized|invalid\s+token|forbidden|bad\s+key|wrong\s+key|banned/i.test(text); + } + + async function resolveFiveSimLowestPrice(config, countryCode) { + try { + const payload = await fetchFiveSimPayload( + config, + '/guest/prices', + '5sim guest prices', + { + query: { + country: countryCode, + product: config.product, + }, + } + ); + return findLowestFiveSimPrice(payload, config.product, countryCode); + } catch { + return null; + } + } + + function parseFiveSimActivationPayload(payload, fallback = {}) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return null; + } + const activationId = String(payload.id || payload.activationId || '').trim(); + const phoneNumber = String(payload.phone || payload.number || '').trim(); + if (!activationId || !phoneNumber) { + return null; + } + + const fallbackCountryCode = normalizeFiveSimCountryCode( + fallback.countryCode || fallback.countryId || '', + 'thailand' + ); + const countryCode = normalizeFiveSimCountryCode( + payload.country || payload.country_name || payload.countryCode || payload.countryId || fallbackCountryCode, + fallbackCountryCode + ); + const countryLabel = String( + payload.country_name + || payload.countryName + || payload.country + || fallback.countryLabel + || countryCode + ).trim(); + + return { + activationId, + phoneNumber, + provider: PHONE_SMS_PROVIDER_5SIM, + serviceCode: normalizeFiveSimCountryCode(payload.product || fallback.serviceCode || DEFAULT_FIVE_SIM_PRODUCT, DEFAULT_FIVE_SIM_PRODUCT), + countryId: countryCode, + countryCode, + countryLabel: countryLabel || countryCode, + successfulUses: normalizeUseCount(payload.successfulUses ?? fallback.successfulUses ?? 0), + maxUses: Math.max(1, Math.floor(Number(payload.maxUses ?? fallback.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), + ...(() => { + const expiresAt = normalizeTimestampMs( + payload.expiresAt + ?? payload.expires + ?? payload.expireAt + ?? payload.expired_at + ?? payload.expiredAt + ?? fallback.expiresAt + ); + return expiresAt > 0 ? { expiresAt } : {}; + })(), + }; + } + + async function requestFiveSimActivation(state = {}, options = {}) { + const config = resolvePhoneConfig(state); + const allCountryCandidates = Array.isArray(config.countryCandidates) && config.countryCandidates.length + ? config.countryCandidates + : []; + if (!allCountryCandidates.length) { + throw new Error('Step 9: 5sim countries are empty. Please select at least one country in 接码设置。'); + } + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeFiveSimCountryCode(value, '')) + .filter(Boolean) + ); + let countryCandidates = allCountryCandidates.filter( + (entry) => !blockedCountryIds.has(normalizeFiveSimCountryCode(entry.code || entry.id || '', '')) + ); + if (!countryCandidates.length) { + 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.', + 'warn' + ); + } + } + + const maxPriceLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice); + const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); + const countryPriceFloorByCountryCode = normalizeCountryPriceFloorMap( + options?.countryPriceFloorByCountryId, + (value) => normalizeFiveSimCountryCode(value, '') + ); + const configuredAcquireRounds = normalizePhoneActivationRetryRounds(state?.heroSmsActivationRetryRounds); + const maxAcquireRounds = Math.max(2, configuredAcquireRounds); + const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs); + + let finalNoNumbersByCountry = []; + let finalLastError = null; + + for (let round = 1; round <= maxAcquireRounds; round += 1) { + if (maxAcquireRounds > 1) { + await addLog( + `Step 9: 5sim acquiring phone number (round ${round}/${maxAcquireRounds})...`, + 'info' + ); + } + const noNumbersByCountry = []; + const retryableNoNumberCountries = []; + let lastError = null; + + let orderedCountryCandidates = [...countryCandidates]; + if ( + (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) + && countryCandidates.length > 1 + ) { + const rankedCandidates = []; + for (const [index, countryConfig] of countryCandidates.entries()) { + const countryCode = normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', 'thailand'); + const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode); + rankedCandidates.push({ + index, + countryConfig, + lowestPrice: Number.isFinite(Number(lowestPrice)) ? Number(lowestPrice) : null, + }); + } + rankedCandidates.sort((left, right) => { + const leftPrice = left.lowestPrice; + const rightPrice = right.lowestPrice; + const leftHasPrice = Number.isFinite(leftPrice); + const rightHasPrice = Number.isFinite(rightPrice); + if (leftHasPrice && rightHasPrice && leftPrice !== rightPrice) { + return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH + ? (rightPrice - leftPrice) + : (leftPrice - rightPrice); + } + if (leftHasPrice !== rightHasPrice) { + return leftHasPrice ? -1 : 1; + } + return left.index - right.index; + }); + orderedCountryCandidates = rankedCandidates.map((entry) => entry.countryConfig); + const rankedSummary = rankedCandidates + .map((entry) => { + const countryCode = normalizeFiveSimCountryCode(entry.countryConfig.code || entry.countryConfig.id || '', 'thailand'); + const countryLabel = String(entry.countryConfig.label || countryCode).trim() || countryCode; + return Number.isFinite(entry.lowestPrice) + ? `${countryLabel}:${entry.lowestPrice}` + : `${countryLabel}:n/a`; + }) + .join(' | '); + await addLog(`Step 9: 5sim price-priority ranking: ${rankedSummary}`, 'info'); + } + + for (const countryConfig of orderedCountryCandidates) { + const countryCode = normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', 'thailand'); + const countryLabel = String(countryConfig.label || countryCode).trim() || countryCode; + const countryPriceFloor = countryPriceFloorByCountryCode.get(countryCode) ?? null; + try { + let guestPricesPayload = null; + try { + guestPricesPayload = await fetchFiveSimPayload( + config, + '/guest/prices', + '5sim guest prices', + { + query: { + country: countryCode, + product: config.product, + }, + } + ); + } catch (_) { + guestPricesPayload = null; + } + + const rawPriceCandidates = buildSortedUniquePriceCandidates( + collectFiveSimPriceCandidates( + ( + guestPricesPayload + && typeof guestPricesPayload === 'object' + && !Array.isArray(guestPricesPayload) + ? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload) + : guestPricesPayload + ), + [] + ) + ); + const boundedPriceCandidates = maxPriceLimit === null + ? rawPriceCandidates + : rawPriceCandidates.filter((price) => Number(price) <= maxPriceLimit); + const orderedPricesFromCatalog = reorderPriceCandidates( + boundedPriceCandidates, + acquirePriority, + preferredPriceTier + ); + const orderedPrices = orderedPricesFromCatalog.length + ? orderedPricesFromCatalog + : (maxPriceLimit !== null ? [maxPriceLimit] : [null]); + const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); + const hasCountryPriceFloor = ( + countryPriceFloor !== null + && Number.isFinite(Number(countryPriceFloor)) + && Number(countryPriceFloor) > 0 + ); + const hasAlternativeCountries = orderedCountryCandidates.some((entry) => ( + normalizeFiveSimCountryCode(entry.code || entry.id || '', '') + !== normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', '') + )); + // When a floor is set (e.g. timeout on current tier), do NOT fall back to the + // original lowest-tier list; that would retry the same tier forever and block + // country/provider fallback. + const pricesToTry = hasCountryPriceFloor + ? ( + floorFilteredPrices.length + ? floorFilteredPrices + : (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1)) + ) + : (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices); + + if (!pricesToTry.length) { + const lowestCatalog = rawPriceCandidates.length ? rawPriceCandidates[0] : null; + if ( + maxPriceLimit !== null + && lowestCatalog !== null + && Number(lowestCatalog) > Number(maxPriceLimit) + ) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestCatalog}` + ); + } else if (countryPriceFloor !== null && boundedPriceCandidates.length) { + noNumbersByCountry.push( + `${countryLabel}: no higher price tier above ${countryPriceFloor} for current fallback attempt` + ); + } else if (rawPriceCandidates.length) { + const tierText = rawPriceCandidates.join(', '); + noNumbersByCountry.push(`${countryLabel}: all visible price tiers unavailable (${tierText})`); + retryableNoNumberCountries.push(countryLabel); + } else { + noNumbersByCountry.push(`${countryLabel}: no free phones`); + retryableNoNumberCountries.push(countryLabel); + } + continue; + } + + let acquiredActivation = null; + let countryNoNumbersText = ''; + for (const candidatePrice of pricesToTry) { + try { + const payload = await fetchFiveSimPayload( + config, + `/user/buy/activation/${countryCode}/${config.operator}/${config.product}`, + '5sim buy activation', + { + query: { + ...(candidatePrice !== null && candidatePrice !== undefined ? { maxPrice: candidatePrice } : {}), + ...(normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled) ? { reuse: 1 } : {}), + }, + } + ); + const activation = parseFiveSimActivationPayload(payload, { + countryCode, + countryLabel, + serviceCode: config.product, + }); + if (activation) { + const priceValue = Number(candidatePrice); + rememberActivationAcquiredPrice(activation, priceValue); + acquiredActivation = activation; + break; + } + const payloadText = describeFiveSimPayload(payload); + if (isFiveSimNoNumbersError(payload)) { + countryNoNumbersText = payloadText || countryNoNumbersText || 'no free phones'; + continue; + } + if (isFiveSimTerminalError(payload)) { + throw new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`); + } + lastError = new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`); + } catch (error) { + if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) { + throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`); + } + if (isFiveSimNoNumbersError(error?.payload || error?.message)) { + countryNoNumbersText = describeFiveSimPayload(error?.payload || error?.message) || countryNoNumbersText || 'no free phones'; + continue; + } + lastError = error; + } + } + + if (acquiredActivation) { + return acquiredActivation; + } + + const lowestPrice = rawPriceCandidates.length ? rawPriceCandidates[0] : await resolveFiveSimLowestPrice(config, countryCode); + if (maxPriceLimit !== null && lowestPrice !== null && Number(lowestPrice) > Number(maxPriceLimit)) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}` + ); + } else { + noNumbersByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'no free phones'}`); + retryableNoNumberCountries.push(countryLabel); + } + continue; + } catch (error) { + if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) { + throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`); + } + if (isFiveSimNoNumbersError(error?.payload || error?.message)) { + const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode); + if (maxPriceLimit !== null && lowestPrice !== null && lowestPrice > maxPriceLimit) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}` + ); + } else { + noNumbersByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'no free phones'}`); + retryableNoNumberCountries.push(countryLabel); + } + continue; + } + lastError = error; + } + } + + finalNoNumbersByCountry = noNumbersByCountry; + finalLastError = lastError; + + if ( + noNumbersByCountry.length + && round < maxAcquireRounds + && retryableNoNumberCountries.length > 0 + ) { + await addLog( + `Step 9: 5sim has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`, + 'warn' + ); + await sleepWithStop(retryDelayMs); + continue; + } + + break; + } + + if (finalNoNumbersByCountry.length) { + throw new Error( + `5sim no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` + ); + } + if (finalLastError) { + throw finalLastError; + } + throw new Error('5sim failed to acquire a phone number.'); + } + + function isNexSmsNoNumbersError(payloadOrMessage) { + const text = describeNexSmsPayload(payloadOrMessage); + return /numbers?\s+not\s+found|暂无可用|no\s+numbers|no\s+stock|库存.*0|not\s+available/i.test(text); + } + + function isNexSmsPendingMessage(payloadOrMessage) { + const text = describeNexSmsPayload(payloadOrMessage); + return /no\s+sms|暂无短信|waiting|not\s+arrived|empty|未收到|短信为空|no\s+records/i.test(text); + } + + function isNexSmsTerminalError(payloadOrMessage, status = 0) { + if (Number(status) === 401 || Number(status) === 403) { + return true; + } + const text = describeNexSmsPayload(payloadOrMessage); + return /invalid\s*api\s*key|bad[_\s-]*key|wrong[_\s-]*key|unauthorized|forbidden|no\s*balance|insufficient\s*balance|余额不足|账号.*封禁|banned/i.test(text); + } + + function collectNexSmsPriceCandidates(countryData = {}) { + const candidates = []; + const pushCandidate = (value) => { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + candidates.push(Math.round(numeric * 10000) / 10000); + } + }; + + pushCandidate(countryData.minPrice); + pushCandidate(countryData.medianPrice); + pushCandidate(countryData.maxPrice); + + if (countryData.priceMap && typeof countryData.priceMap === 'object') { + Object.entries(countryData.priceMap).forEach(([priceKey, count]) => { + const availableCount = Number(count); + if (!Number.isFinite(availableCount) || availableCount <= 0) { + return; + } + pushCandidate(priceKey); + }); + } + + return buildSortedUniquePriceCandidates(candidates); + } + + async function resolveNexSmsCountryPricePlan(config, countryConfig, state = {}) { + const countryId = normalizeNexSmsCountryId(countryConfig?.id, -1); + if (countryId < 0) { + throw new Error(`NexSMS countryId is invalid: ${countryConfig?.id}`); + } + const payload = await fetchNexSmsPayload( + config, + '/api/getCountryByService', + 'NexSMS getCountryByService', + { + query: { + serviceCode: config.serviceCode, + countryId, + }, + } + ); + if (!isNexSmsSuccessPayload(payload)) { + throw new Error(`NexSMS getCountryByService failed: ${describeNexSmsPayload(payload) || 'empty response'}`); + } + const countryData = (payload && typeof payload === 'object' && !Array.isArray(payload)) + ? (payload.data || {}) + : {}; + const countryLabel = normalizeCountryLabel( + countryData.countryName || countryConfig?.label, + `Country #${countryId}` + ); + const prices = collectNexSmsPriceCandidates(countryData); + const minCatalogPrice = prices.length + ? prices[0] + : (() => { + const minPrice = Number(countryData.minPrice); + return Number.isFinite(minPrice) && minPrice > 0 + ? Math.round(minPrice * 10000) / 10000 + : null; + })(); + const userLimit = normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice); + const filteredPrices = userLimit === null + ? prices + : prices.filter((price) => price <= userLimit); + + return { + countryId, + countryLabel, + prices: filteredPrices, + userLimit, + minCatalogPrice, + rawPayload: payload, + }; + } + + function parseNexSmsActivationPayload(payload, fallback = {}) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return null; + } + if (!isNexSmsSuccessPayload(payload)) { + return null; + } + const data = payload.data || {}; + const phoneCandidates = Array.isArray(data.phoneNumbers) + ? data.phoneNumbers + : (Array.isArray(data.numbers) ? data.numbers : []); + const phoneNumber = String( + data.phoneNumber + || data.phone + || phoneCandidates[0] + || fallback.phoneNumber + || '' + ).trim(); + if (!phoneNumber) { + return null; + } + const countryId = normalizeNexSmsCountryId( + data.countryId ?? fallback.countryId, + 0 + ); + const countryLabel = normalizeCountryLabel( + data.countryName || fallback.countryLabel, + `Country #${countryId}` + ); + const serviceCode = normalizeNexSmsServiceCode( + data.serviceCode || fallback.serviceCode || DEFAULT_NEX_SMS_SERVICE_CODE, + DEFAULT_NEX_SMS_SERVICE_CODE + ); + return { + activationId: phoneNumber, + phoneNumber, + provider: PHONE_SMS_PROVIDER_NEXSMS, + serviceCode, + countryId, + countryLabel, + successfulUses: normalizeUseCount(fallback.successfulUses ?? 0), + maxUses: 1, + }; + } + + async function requestNexSmsActivation(state = {}, options = {}) { + const config = resolvePhoneConfig(state); + const allCountryCandidates = Array.isArray(config.countryCandidates) && config.countryCandidates.length + ? config.countryCandidates + : resolveNexSmsCountryCandidates(state); + if (!allCountryCandidates.length) { + throw new Error('Step 9: NexSMS countries are empty. Please select at least one country in 接码设置。'); + } + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeNexSmsCountryId(value, -1)) + .filter((id) => id >= 0) + ); + let countryCandidates = allCountryCandidates.filter((entry) => { + const id = normalizeNexSmsCountryId(entry.id, -1); + return id >= 0 && !blockedCountryIds.has(id); + }); + if (!countryCandidates.length) { + 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.', + 'warn' + ); + } + } + + const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); + const countryPriceFloorByCountryId = normalizeCountryPriceFloorMap( + options?.countryPriceFloorByCountryId, + (value) => String(normalizeNexSmsCountryId(value, -1)) + ); + const configuredAcquireRounds = normalizePhoneActivationRetryRounds(state?.heroSmsActivationRetryRounds); + const maxAcquireRounds = Math.max(2, configuredAcquireRounds); + const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs); + let finalNoNumbersByCountry = []; + let finalLastError = null; + + for (let round = 1; round <= maxAcquireRounds; round += 1) { + if (maxAcquireRounds > 1) { + await addLog( + `Step 9: NexSMS acquiring phone number (round ${round}/${maxAcquireRounds})...`, + 'info' + ); + } + + const candidateAttempts = []; + for (const [index, countryConfig] of countryCandidates.entries()) { + candidateAttempts.push({ + index, + countryConfig, + pricePlan: null, + orderingPrice: Number.POSITIVE_INFINITY, + }); + } + + if ( + (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) + && candidateAttempts.length > 1 + ) { + for (const attempt of candidateAttempts) { + try { + const pricePlan = await resolveNexSmsCountryPricePlan(config, attempt.countryConfig, state); + attempt.pricePlan = pricePlan; + const orderedForRanking = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier); + attempt.orderingPrice = Array.isArray(orderedForRanking) && orderedForRanking.length + ? Number(orderedForRanking[0]) + : Number.POSITIVE_INFINITY; + } catch (error) { + attempt.pricePlan = null; + attempt.orderingPrice = Number.POSITIVE_INFINITY; + attempt.lookupError = error; + } + } + candidateAttempts.sort((left, right) => { + if (left.orderingPrice !== right.orderingPrice) { + return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH + ? (right.orderingPrice - left.orderingPrice) + : (left.orderingPrice - right.orderingPrice); + } + return left.index - right.index; + }); + const rankingSummary = candidateAttempts.map((attempt) => { + const id = normalizeNexSmsCountryId(attempt.countryConfig.id, -1); + const label = String(attempt.countryConfig.label || `Country #${id}`).trim() || `Country #${id}`; + return Number.isFinite(attempt.orderingPrice) + ? `${label}:${attempt.orderingPrice}` + : `${label}:n/a`; + }).join(' | '); + await addLog(`Step 9: NexSMS price-priority ranking: ${rankingSummary}`, 'info'); + } + + const noNumbersByCountry = []; + const retryableNoNumberCountries = []; + let lastError = null; + + for (const attempt of candidateAttempts) { + const countryId = normalizeNexSmsCountryId(attempt.countryConfig.id, -1); + const countryLabel = normalizeCountryLabel(attempt.countryConfig.label, `Country #${countryId}`); + const countryPriceFloor = countryPriceFloorByCountryId.get(String(countryId)) ?? null; + let pricePlan = attempt.pricePlan; + if (!pricePlan) { + try { + pricePlan = await resolveNexSmsCountryPricePlan(config, attempt.countryConfig, state); + } catch (error) { + if (isNexSmsTerminalError(error?.payload || error?.message, error?.status)) { + throw new Error(`NexSMS price lookup failed: ${describeNexSmsPayload(error?.payload || error?.message) || 'unknown terminal error'}`); + } + lastError = error; + continue; + } + } + + if (!Array.isArray(pricePlan.prices) || !pricePlan.prices.length) { + if ( + pricePlan.userLimit !== null + && pricePlan.minCatalogPrice !== null + && pricePlan.minCatalogPrice > pricePlan.userLimit + ) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within maxPrice=${pricePlan.userLimit}; lowest listed=${pricePlan.minCatalogPrice}` + ); + } else { + const reason = describeNexSmsPayload(pricePlan.rawPayload) || 'no price candidates'; + noNumbersByCountry.push(`${countryLabel}: ${reason}`); + retryableNoNumberCountries.push(countryLabel); + } + continue; + } + + const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier); + const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); + const hasCountryPriceFloor = ( + countryPriceFloor !== null + && Number.isFinite(Number(countryPriceFloor)) + && Number(countryPriceFloor) > 0 + ); + const hasAlternativeCountries = candidateAttempts.some((entry) => ( + normalizeNexSmsCountryId(entry?.countryConfig?.id, -1) + !== normalizeNexSmsCountryId(attempt?.countryConfig?.id, -1) + )); + // With an explicit floor, only try higher tiers. If none exist, we should + // move to country/provider fallback instead of retrying the same tier again. + const pricesToTry = hasCountryPriceFloor + ? ( + floorFilteredPrices.length + ? floorFilteredPrices + : (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1)) + ) + : (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices); + if (!pricesToTry.length) { + if ( + countryPriceFloor !== null + && Array.isArray(pricePlan.prices) + && pricePlan.prices.length > 0 + ) { + noNumbersByCountry.push( + `${countryLabel}: no higher price tier above ${countryPriceFloor} for current fallback attempt` + ); + } else { + noNumbersByCountry.push(`${countryLabel}: ${describeNexSmsPayload(pricePlan.rawPayload) || 'no numbers found'}`); + retryableNoNumberCountries.push(countryLabel); + } + continue; + } + for (const price of pricesToTry) { + try { + const payload = await fetchNexSmsPayload( + config, + '/api/order/purchase', + 'NexSMS purchase', + { + method: 'POST', + body: { + serviceCode: config.serviceCode, + countryId, + quantity: 1, + price, + }, + } + ); + if (!isNexSmsSuccessPayload(payload)) { + if (isNexSmsNoNumbersError(payload)) { + continue; + } + if (isNexSmsTerminalError(payload)) { + throw new Error(`NexSMS purchase failed: ${describeNexSmsPayload(payload) || 'empty response'}`); + } + lastError = new Error(`NexSMS purchase failed: ${describeNexSmsPayload(payload) || 'empty response'}`); + continue; + } + const activation = parseNexSmsActivationPayload(payload, { + countryId, + countryLabel, + serviceCode: config.serviceCode, + }); + if (!activation) { + lastError = new Error('NexSMS purchase succeeded but did not return a phone number.'); + continue; + } + const numericPrice = Number(price); + rememberActivationAcquiredPrice(activation, numericPrice); + return activation; + } catch (error) { + if (isNexSmsTerminalError(error?.payload || error?.message, error?.status)) { + throw new Error(`NexSMS purchase failed: ${describeNexSmsPayload(error?.payload || error?.message) || 'unknown terminal error'}`); + } + if (isNexSmsNoNumbersError(error?.payload || error?.message)) { + continue; + } + lastError = error; + } + } + + const fallbackReason = describeNexSmsPayload(pricePlan.rawPayload) || 'no numbers found'; + noNumbersByCountry.push(`${countryLabel}: ${fallbackReason}`); + retryableNoNumberCountries.push(countryLabel); + } + + finalNoNumbersByCountry = noNumbersByCountry; + finalLastError = lastError; + + if ( + noNumbersByCountry.length + && round < maxAcquireRounds + && retryableNoNumberCountries.length > 0 + ) { + await addLog( + `Step 9: NexSMS has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`, + 'warn' + ); + await sleepWithStop(retryDelayMs); + continue; + } + + break; + } + + if (finalNoNumbersByCountry.length) { + throw new Error( + `NexSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` + ); + } + if (finalLastError) { + throw finalLastError; + } + throw new Error('NexSMS failed to acquire a phone number.'); + } + async function requestPhoneActivation(state = {}, options = {}) { const config = resolvePhoneConfig(state); - const allCountryCandidates = resolveCountryCandidates(state); + if (config.provider === PHONE_SMS_PROVIDER_5SIM) { + return requestFiveSimActivation(state, options); + } + if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) { + return requestNexSmsActivation(state, options); + } + const allCountryCandidates = Array.isArray(config.countryCandidates) && config.countryCandidates.length + ? config.countryCandidates + : resolveCountryCandidates(state); + if (!allCountryCandidates.length) { + throw new Error('Step 9: HeroSMS countries are empty. Please select at least one country in 接码设置。'); + } const blockedCountryIds = new Set( (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) .map((value) => normalizeCountryId(value, 0)) @@ -752,6 +2602,11 @@ } } const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); + const countryPriceFloorByCountryId = normalizeCountryPriceFloorMap( + options?.countryPriceFloorByCountryId, + (value) => String(normalizeCountryId(value, 0)) + ); const requestActions = ['getNumber', 'getNumberV2']; const configuredAcquireRounds = normalizePhoneActivationRetryRounds( state?.heroSmsActivationRetryRounds @@ -780,15 +2635,19 @@ orderingPrice: Number.POSITIVE_INFINITY, })); - if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE) { + if ( + acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE + || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH + ) { for (const attempt of countryAttempts) { const pricePlan = await resolvePhoneActivationPricePlan(config, attempt.countryConfig, state); - const numericPrices = Array.isArray(pricePlan?.prices) - ? pricePlan.prices + const orderedPrices = reorderPriceCandidates(pricePlan?.prices, acquirePriority, preferredPriceTier); + const numericPrices = Array.isArray(orderedPrices) + ? orderedPrices .map((value) => Number(value)) - .filter((value) => Number.isFinite(value) && value >= 0) + .filter((value) => Number.isFinite(value) && value > 0) : []; - const minCandidatePrice = numericPrices.length ? Math.min(...numericPrices) : null; + const candidateOrderingPrice = numericPrices.length ? numericPrices[0] : null; const cappedByUserLimit = ( pricePlan?.userLimit !== null && pricePlan?.userLimit !== undefined @@ -799,14 +2658,19 @@ attempt.pricePlan = pricePlan; attempt.orderingPrice = cappedByUserLimit ? Number.POSITIVE_INFINITY - : (minCandidatePrice !== null ? minCandidatePrice : Number.POSITIVE_INFINITY); + : (candidateOrderingPrice !== null ? candidateOrderingPrice : Number.POSITIVE_INFINITY); } } - if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE && countryAttempts.length > 1) { + if ( + (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) + && countryAttempts.length > 1 + ) { countryAttempts.sort((left, right) => { if (left.orderingPrice !== right.orderingPrice) { - return left.orderingPrice - right.orderingPrice; + return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH + ? (right.orderingPrice - left.orderingPrice) + : (left.orderingPrice - right.orderingPrice); } return left.index - right.index; }); @@ -819,6 +2683,8 @@ for (const attempt of countryAttempts) { const countryConfig = attempt.countryConfig; + const countryIdKey = String(normalizeCountryId(countryConfig?.id, 0)); + const countryPriceFloor = countryPriceFloorByCountryId.get(countryIdKey) ?? null; const buildFallbackActivation = (requestAction) => ({ countryId: countryConfig.id, ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), @@ -826,21 +2692,96 @@ const pricePlan = attempt.pricePlan || await resolvePhoneActivationPricePlan(config, countryConfig, state); let noNumbersObservedInCountry = false; - for (const maxPrice of pricePlan.prices) { + const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier); + const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); + const hasCountryPriceFloor = ( + countryPriceFloor !== null + && Number.isFinite(Number(countryPriceFloor)) + && Number(countryPriceFloor) > 0 + ); + const hasAlternativeCountries = countryAttempts.some((entry) => ( + String(normalizeCountryId(entry?.countryConfig?.id, 0)) + !== String(normalizeCountryId(countryConfig?.id, 0)) + )); + // Same rule as 5sim/NexSMS: once floor is set, never re-try lower/equal tiers. + // Keep a probe fallback only for HeroSMS (single-country/no-tier environments), + // so replacement-limit behavior remains stable while still allowing country fallback. + const pricesToTry = hasCountryPriceFloor + ? ( + floorFilteredPrices.length + ? floorFilteredPrices + : (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1)) + ) + : (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices); + const rawTierText = Array.isArray(pricePlan?.prices) && pricePlan.prices.length + ? pricePlan.prices + .map((value) => (value === null || value === undefined ? 'auto' : String(value))) + .join(', ') + : 'none'; + await addLog( + `Step 9: HeroSMS ${countryConfig.label} price plan resolved -> tiers=[${rawTierText}], userLimit=${pricePlan?.userLimit ?? 'none'}, minCatalog=${pricePlan?.minCatalogPrice ?? 'n/a'}.`, + 'info' + ); + if (pricesToTry.length > 1 || countryPriceFloor !== null) { + const tierText = pricesToTry + .map((value) => (value === null || value === undefined ? 'auto' : String(value))) + .join(', '); + await addLog( + `Step 9: HeroSMS ${countryConfig.label} price candidates: ${tierText}${countryPriceFloor !== null ? ` (floor>${countryPriceFloor})` : ''}.`, + 'info' + ); + } + if (!pricesToTry.length) { + if ( + countryPriceFloor !== null + && Array.isArray(pricePlan.prices) + && pricePlan.prices.length > 0 + ) { + noNumbersByCountry.push( + `${countryConfig.label}: no higher price tier above ${countryPriceFloor} for current fallback attempt` + ); + continue; + } + if ( + pricePlan.userLimit !== null + && pricePlan.minCatalogPrice !== null + && pricePlan.minCatalogPrice > pricePlan.userLimit + ) { + noNumbersByCountry.push( + `${countryConfig.label}: no numbers within maxPrice=${pricePlan.userLimit}; lowest listed=${pricePlan.minCatalogPrice}` + ); + } else { + noNumbersByCountry.push( + `${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}` + ); + retryableNoNumberCountries.push(countryConfig.label); + } + continue; + } + for (const maxPrice of pricesToTry) { for (const requestAction of requestActions) { try { + const fixedPrice = !Boolean(pricePlan.syntheticUserLimitProbe); + await addLog( + `Step 9: HeroSMS ${countryConfig.label} trying ${requestAction} at tier ${maxPrice === null || maxPrice === undefined ? 'auto' : maxPrice}.`, + 'info' + ); const payload = await requestPhoneActivationWithPrice( config, countryConfig, requestAction, maxPrice, - { userLimit: pricePlan.userLimit } + { + userLimit: pricePlan.userLimit, + fixedPrice, + } ); const activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); if (activation) { - const { countryLabel: _ignoredCountryLabel, ...activationWithoutCountryLabel } = activation; + const numericPrice = Number(maxPrice); + rememberActivationAcquiredPrice(activation, numericPrice); return { - ...activationWithoutCountryLabel, + ...activation, countryId: countryConfig.id, }; } @@ -872,6 +2813,9 @@ } if (noNumbersObservedInCountry) { + const tiersTriedText = pricesToTry + .map((value) => (value === null || value === undefined ? 'auto' : String(value))) + .join(', '); if ( pricePlan.userLimit !== null && pricePlan.minCatalogPrice !== null @@ -882,7 +2826,7 @@ ); } else { noNumbersByCountry.push( - `${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}` + `${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}${tiersTriedText ? ` (tiers tried: ${tiersTriedText})` : ''}` ); retryableNoNumberCountries.push(countryConfig.label); } @@ -928,6 +2872,30 @@ } const config = resolvePhoneConfig(state); + if (config.provider === PHONE_SMS_PROVIDER_5SIM) { + const reuseProduct = normalizeFiveSimCountryCode( + normalizedActivation.serviceCode || config.product || DEFAULT_FIVE_SIM_PRODUCT, + DEFAULT_FIVE_SIM_PRODUCT + ); + const reuseNumber = String(normalizedActivation.phoneNumber || '').replace(/[^\d]/g, ''); + if (!reuseNumber) { + throw new Error('5sim reuse activation failed: phone number is missing.'); + } + const payload = await fetchFiveSimPayload( + config, + `/user/reuse/${reuseProduct}/${reuseNumber}`, + '5sim reuse activation' + ); + const nextActivation = parseFiveSimActivationPayload(payload, normalizedActivation); + if (!nextActivation) { + const text = describeFiveSimPayload(payload); + throw new Error(`5sim reuse activation failed: ${text || 'empty response'}`); + } + return nextActivation; + } + if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) { + throw new Error('NexSMS does not support activation reuse for this flow.'); + } const payload = await fetchHeroSmsPayload(config, { action: 'reactivate', id: normalizedActivation.activationId, @@ -946,6 +2914,33 @@ return ''; } const config = resolvePhoneConfig(state); + if (config.provider === PHONE_SMS_PROVIDER_5SIM) { + const endpoint = status === 6 + ? `/user/finish/${normalizedActivation.activationId}` + : `/user/cancel/${normalizedActivation.activationId}`; + const payload = await fetchFiveSimPayload(config, endpoint, actionLabel || '5sim set status'); + return describeFiveSimPayload(payload); + } + if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) { + if (status === 6) { + return 'NexSMS complete skipped'; + } + const payload = await fetchNexSmsPayload( + config, + '/api/close/activation', + actionLabel || 'NexSMS close activation', + { + method: 'POST', + body: { + phoneNumber: normalizedActivation.phoneNumber, + }, + } + ); + if (!isNexSmsSuccessPayload(payload)) { + throw new Error(`NexSMS close activation failed: ${describeNexSmsPayload(payload) || 'empty response'}`); + } + return describeNexSmsPayload(payload); + } const payload = await fetchHeroSmsPayload(config, { action: 'setStatus', id: normalizedActivation.activationId, @@ -955,11 +2950,13 @@ } async function completePhoneActivation(state = {}, activation) { + forgetActivationAcquiredPriceHint(activation); await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)'); } async function cancelPhoneActivation(state = {}, activation) { try { + forgetActivationAcquiredPriceHint(activation); await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)'); } catch (_) { // Best-effort cleanup. @@ -967,6 +2964,10 @@ } async function requestAdditionalPhoneSms(state = {}, activation) { + const config = resolvePhoneConfig(state); + if (config.provider !== PHONE_SMS_PROVIDER_HERO) { + return; + } try { await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)'); } catch (_) { @@ -997,6 +2998,116 @@ const start = Date.now(); let lastResponse = ''; let pollCount = 0; + const extractVerificationCode = (rawCode) => { + const trimmed = String(rawCode || '').trim(); + if (!trimmed) { + return ''; + } + const digitMatch = trimmed.match(/\b(\d{4,8})\b/); + return digitMatch?.[1] || ''; + }; + + if (config.provider === PHONE_SMS_PROVIDER_5SIM) { + while (Date.now() - start < timeoutMs) { + if (maxRounds > 0 && pollCount >= maxRounds) { + break; + } + throwIfStopped(); + const payload = await fetchFiveSimPayload( + config, + `/user/check/${normalizedActivation.activationId}`, + '5sim check activation' + ); + const text = describeFiveSimPayload(payload); + lastResponse = text; + pollCount += 1; + + const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + const directCode = extractVerificationCode(payload?.code || payload?.sms_code); + const smsCode = directCode || smsList + .map((smsItem) => extractVerificationCode(smsItem?.code || smsItem?.text || smsItem?.message || '')) + .find(Boolean); + if (smsCode) { + return smsCode; + } + + const statusText = String(payload?.status || '').trim().toUpperCase(); + if (/^(RECEIVED|PENDING|RETRY|PREPARE|WAITING)$/i.test(statusText) || !statusText) { + if (typeof options.onStatus === 'function') { + await options.onStatus({ + activation: normalizedActivation, + elapsedMs: Date.now() - start, + pollCount, + statusText: statusText || text || 'PENDING', + timeoutMs, + }); + } + await sleepWithStop(intervalMs); + continue; + } + + if (/^(CANCELED|CANCELLED|BANNED|FINISHED|EXPIRED|TIMEOUT)$/i.test(statusText)) { + throw new Error(`5sim activation ended before receiving SMS: ${statusText}`); + } + + throw new Error(`5sim check activation failed: ${text || statusText || 'empty response'}`); + } + + throw buildPhoneCodeTimeoutError(lastResponse); + } + + if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) { + while (Date.now() - start < timeoutMs) { + if (maxRounds > 0 && pollCount >= maxRounds) { + break; + } + throwIfStopped(); + const payload = await fetchNexSmsPayload( + config, + '/api/sms/messages', + 'NexSMS get sms messages', + { + query: { + phoneNumber: normalizedActivation.phoneNumber, + format: 'json_latest', + }, + } + ); + const text = describeNexSmsPayload(payload); + lastResponse = text; + pollCount += 1; + + if (typeof options.onStatus === 'function') { + await options.onStatus({ + activation: normalizedActivation, + elapsedMs: Date.now() - start, + pollCount, + statusText: text || 'PENDING', + timeoutMs, + }); + } + + if (isNexSmsSuccessPayload(payload)) { + const directCode = extractVerificationCode(payload?.data?.code || payload?.data?.text || ''); + if (directCode) { + return directCode; + } + await sleepWithStop(intervalMs); + continue; + } + + if (isNexSmsPendingMessage(payload)) { + await sleepWithStop(intervalMs); + continue; + } + if (isNexSmsTerminalError(payload)) { + throw new Error(`NexSMS get sms messages failed: ${text || 'unknown terminal error'}`); + } + await sleepWithStop(intervalMs); + } + + throw buildPhoneCodeTimeoutError(lastResponse); + } while (Date.now() - start < timeoutMs) { if (maxRounds > 0 && pollCount >= maxRounds) { @@ -1021,15 +3132,6 @@ }); } - const extractVerificationCode = (rawCode) => { - const trimmed = String(rawCode || '').trim(); - if (!trimmed) { - return ''; - } - const digitMatch = trimmed.match(/\b(\d{4,8})\b/); - return digitMatch?.[1] || trimmed; - }; - const v2Code = ( payload && typeof payload === 'object' @@ -1045,12 +3147,15 @@ const okMatch = text.match(/^STATUS_OK:(.+)$/i); if (okMatch) { - const rawCode = String(okMatch[1] || '').trim(); - const digitMatch = rawCode.match(/\b(\d{4,8})\b/); - return digitMatch?.[1] || rawCode; + const extractedCode = extractVerificationCode(okMatch[1] || ''); + if (extractedCode) { + return extractedCode; + } + await sleepWithStop(intervalMs); + continue; } - if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)$/i.test(text)) { + if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)(?::.+)?$/i.test(text)) { await sleepWithStop(intervalMs); continue; } @@ -1093,20 +3198,72 @@ } function resolveCountryConfigFromActivation(activation, fallbackState = {}) { - const candidates = resolveCountryCandidates(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) + ); if (activation && typeof activation === 'object') { - const countryId = normalizeCountryId(activation.countryId, 0); - if (countryId > 0) { - const matched = candidates.find((entry) => entry.id === countryId); - if (matched) { - return matched; + 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; + } + return { + id: countryId, + label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`), + }; + } + } else { + const countryId = normalizeCountryId(activation.countryId, 0); + if (countryId > 0) { + const matched = candidates.find((entry) => entry.id === countryId); + if (matched) { + return matched; + } + return { + id: countryId, + label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`), + }; } - return { - id: countryId, - label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`), - }; } } + 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); } @@ -1160,18 +3317,23 @@ async function resendPhoneVerificationCode(tabId) { const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'resend phone verification code' }) - : 30000; - const result = await sendToContentScriptResilient('signup-page', { + ? await getOAuthFlowStepTimeoutMs(65000, { step: 9, actionLabel: 'resend phone verification code' }) + : 65000; + const request = { type: 'RESEND_PHONE_VERIFICATION_CODE', source: 'background', payload: {}, - }, { - timeoutMs, - responseTimeoutMs: timeoutMs, - retryDelayMs: 600, - logMessage: 'Step 9: waiting for the phone verification resend button...', - }); + }; + 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...', + }); if (result?.error) { throw new Error(result.error); @@ -1201,18 +3363,63 @@ } async function persistCurrentActivation(activation) { - await setState({ - [PHONE_ACTIVATION_STATE_KEY]: activation || null, + const normalizedActivation = normalizeActivation(activation); + const updates = { + [PHONE_ACTIVATION_STATE_KEY]: normalizedActivation || null, [PHONE_VERIFICATION_CODE_STATE_KEY]: '', - }); + }; + if (!normalizedActivation) { + updates[PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY] = 0; + updates[PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY] = 0; + updates[PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY] = 0; + } + await setPhoneRuntimeState(updates); } async function persistReusableActivation(activation) { - await setState({ - [REUSABLE_PHONE_ACTIVATION_STATE_KEY]: activation || null, + await setPhoneRuntimeState({ + [REUSABLE_PHONE_ACTIVATION_STATE_KEY]: normalizeActivation(activation) || null, }); } + function readReusableActivationPoolFromState(state = {}) { + return normalizeActivationPool(state?.[REUSABLE_PHONE_ACTIVATION_POOL_STATE_KEY]); + } + + async function persistReusableActivationPool(pool = []) { + await setPhoneRuntimeState({ + [REUSABLE_PHONE_ACTIVATION_POOL_STATE_KEY]: normalizeActivationPool(pool), + }); + } + + async function upsertReusableActivationPool(activation, options = {}) { + const normalized = normalizeActivation(activation); + if (!normalized) { + return []; + } + const state = options?.state || await getState(); + const existingPool = readReusableActivationPoolFromState(state); + const filtered = existingPool.filter((entry) => !isSameActivation(entry, normalized)); + const nextPool = [normalized, ...filtered].slice(0, MAX_PHONE_REUSABLE_POOL); + await persistReusableActivationPool(nextPool); + return nextPool; + } + + async function removeReusableActivationFromPool(activation, options = {}) { + const normalized = normalizeActivation(activation); + if (!normalized) { + return []; + } + const state = options?.state || await getState(); + const existingPool = readReusableActivationPoolFromState(state); + const nextPool = existingPool.filter((entry) => !isSameActivation(entry, normalized)); + if (nextPool.length === existingPool.length) { + return existingPool; + } + await persistReusableActivationPool(nextPool); + return nextPool; + } + async function clearCurrentActivation() { await persistCurrentActivation(null); } @@ -1221,57 +3428,253 @@ await persistReusableActivation(null); } + async function setPhoneRuntimeCountdown(activation, waitSeconds, windowIndex, windowTotal) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + return; + } + const safeWaitSeconds = Math.max(0, Math.floor(Number(waitSeconds) || 0)); + await setPhoneRuntimeState({ + [PHONE_ACTIVATION_STATE_KEY]: normalizedActivation, + [PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY]: Date.now() + safeWaitSeconds * 1000, + [PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY]: Math.max(0, Math.floor(Number(windowIndex) || 0)), + [PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY]: Math.max(0, Math.floor(Number(windowTotal) || 0)), + }); + } + + async function clearPhoneRuntimeCountdown() { + await setPhoneRuntimeState({ + [PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY]: 0, + [PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY]: 0, + [PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY]: 0, + }); + } + async function acquirePhoneActivation(state = {}, options = {}) { - const countryCandidates = resolveCountryCandidates(state); + 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 blockedCountryIds = new Set( (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) - .map((value) => normalizeCountryId(value, 0)) - .filter((id) => id > 0) + .map((value) => normalizeCountryKey(value)) + .filter((id) => ( + provider === PHONE_SMS_PROVIDER_NEXSMS + ? (id !== '' && id !== null && id !== undefined) + : Boolean(id && id !== '0') + )) ); const allowedCountryIds = new Set( countryCandidates - .map((entry) => normalizeCountryId(entry.id, 0)) - .filter((id) => id > 0 && !blockedCountryIds.has(id)) + .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)) + )) ); - const preferredCountryLabel = countryCandidates[0]?.label || HERO_SMS_COUNTRY_LABEL; - const resolveCountryLabelById = (countryId) => ( - countryCandidates.find((entry) => entry.id === normalizeCountryId(countryId, 0))?.label - || preferredCountryLabel + const preferredCountryLabel = countryCandidates[0]?.label || ( + provider === PHONE_SMS_PROVIDER_5SIM + ? '' + : ( + provider === PHONE_SMS_PROVIDER_NEXSMS + ? '' + : HERO_SMS_COUNTRY_LABEL + ) ); - const reuseEnabled = normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled); - const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); - if ( - reuseEnabled - && - reusableActivation - && !blockedCountryIds.has(normalizeCountryId(reusableActivation.countryId, 0)) - && allowedCountryIds.has(reusableActivation.countryId) - && reusableActivation.successfulUses < reusableActivation.maxUses - ) { + 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) { try { - const reactivated = await reactivatePhoneActivation(state, reusableActivation); + const reactivated = await reactivatePhoneActivation(state, preferredActivation); await addLog( - `Step 9: reusing ${resolveCountryLabelById(reactivated.countryId)} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`, + `Step 9: using preferred number ${reactivated.phoneNumber}${reactivated.countryId ? ` (${resolveCountryLabelById(reactivated.countryId)})` : ''}.`, 'info' ); + await resetPhoneNoSupplyFailureStreak(state); return reactivated; } catch (error) { - await addLog(`Step 9: failed to reuse phone number ${reusableActivation.phoneNumber}, falling back to a new number. ${error.message}`, 'warn'); - await clearReusableActivation(); + 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(); + } + } } } - const activation = await requestPhoneActivation(state, { blockedCountryIds: Array.from(blockedCountryIds) }); - await addLog( - `Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${resolveCountryLabelById(activation.countryId)} number ${activation.phoneNumber}.`, - 'info' - ); - return activation; + 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.'); } async function markActivationReusableAfterSuccess(state, activation) { const normalizedActivation = normalizeActivation(activation); - if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) { + const reusableProvider = normalizedActivation?.provider; + const canPersistReusableActivation = reusableProvider === PHONE_SMS_PROVIDER_HERO + || reusableProvider === PHONE_SMS_PROVIDER_5SIM; + if (!canPersistReusableActivation) { await clearReusableActivation(); return; } @@ -1281,15 +3684,22 @@ } const successfulUses = normalizedActivation.successfulUses + 1; - if (successfulUses >= normalizedActivation.maxUses) { + const nextReusableActivation = { + ...normalizedActivation, + successfulUses, + }; + await upsertReusableActivationPool(nextReusableActivation, { state }); + if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) { await clearReusableActivation(); return; } + if (successfulUses >= normalizedActivation.maxUses) { + await clearReusableActivation(); + await removeReusableActivationFromPool(nextReusableActivation, { state }); + return; + } - await persistReusableActivation({ - ...normalizedActivation, - successfulUses, - }); + await persistReusableActivation(nextReusableActivation); } async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { @@ -1297,6 +3707,10 @@ if (!normalizedActivation) { throw new Error('Phone activation is missing.'); } + const providerLabel = normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM + ? '5sim' + : (normalizedActivation.provider === PHONE_SMS_PROVIDER_NEXSMS ? 'NexSMS' : 'HeroSMS'); + const usePageResend = normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM; const waitSeconds = normalizePhoneCodeWaitSeconds(state?.phoneCodeWaitSeconds); const timeoutWindows = normalizePhoneCodeTimeoutWindows(state?.phoneCodeTimeoutWindows); @@ -1307,6 +3721,7 @@ let resendTriggeredForCurrentNumber = false; 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}).`, 'info' @@ -1314,8 +3729,8 @@ try { const code = await pollPhoneActivationCode(state, normalizedActivation, { actionLabel: windowIndex === 1 - ? 'poll phone verification code from HeroSMS' - : 'poll resent phone verification code from HeroSMS', + ? `poll phone verification code from ${providerLabel}` + : `poll resent phone verification code from ${providerLabel}`, timeoutMs: waitSeconds * 1000, intervalMs: pollIntervalSeconds * 1000, maxRounds: pollMaxRounds, @@ -1331,17 +3746,30 @@ lastLoggedStatus = statusText; lastLoggedPollCount = pollCount; await addLog( - `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed, round ${pollCount}/${pollMaxRounds}).`, + `Step 9: ${providerLabel} status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed, round ${pollCount}/${pollMaxRounds}).`, 'info' ); }, }); + await clearPhoneRuntimeCountdown(); return { code, replaceNumber: false, }; } catch (error) { if (!isPhoneCodeTimeoutError(error)) { + if (isPhoneActivationOrderMissingError(error, normalizedActivation.provider)) { + await addLog( + `Step 9: ${providerLabel} activation for ${normalizedActivation.phoneNumber} became invalid (${error.message || error}), replacing number immediately.`, + 'warn' + ); + await clearPhoneRuntimeCountdown(); + return { + code: '', + replaceNumber: true, + reason: 'activation_not_found', + }; + } throw error; } @@ -1350,6 +3778,13 @@ `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within ${waitSeconds} seconds, requesting another SMS.`, 'warn' ); + if (!usePageResend) { + await addLog( + `Step 9: ${providerLabel} keeps the same verification page session and skips page resend to avoid route 405 / resend throttling; continue polling this number.`, + 'warn' + ); + continue; + } await requestAdditionalPhoneSms(state, normalizedActivation); if (resendTriggeredForCurrentNumber) { await addLog( @@ -1363,17 +3798,33 @@ resendTriggeredForCurrentNumber = true; await addLog('Step 9: clicked "Resend text message" on the phone verification page.', '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}`, 'warn' ); + await clearPhoneRuntimeCountdown(); return { code: '', replaceNumber: true, 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'); } continue; @@ -1383,6 +3834,7 @@ `Step 9: no SMS for ${normalizedActivation.phoneNumber} after ${timeoutWindows} window(s), replacing the number inside step 9.`, 'warn' ); + await clearPhoneRuntimeCountdown(); return { code: '', replaceNumber: true, @@ -1404,29 +3856,125 @@ state.phoneVerificationReplacementLimit ); let usedNumberReplacementAttempts = 0; + let preferredActivationExhausted = false; let preferReuseExistingActivationOnAddPhone = false; let addPhoneReentryWithSameActivation = 0; const countrySmsFailureCounts = new Map(); + const countryPriceFloorByKey = new Map(); + const normalizeCountryFailureKey = (countryId, provider = activation?.provider || state?.phoneSmsProvider || '') => { + const normalizedProvider = normalizePhoneSmsProvider(provider || state?.phoneSmsProvider || ''); + if (normalizedProvider === PHONE_SMS_PROVIDER_5SIM) { + const normalizedCountryCode = normalizeFiveSimCountryCode(countryId, ''); + return normalizedCountryCode ? `${normalizedProvider}:${normalizedCountryCode}` : ''; + } + if (normalizedProvider === PHONE_SMS_PROVIDER_NEXSMS) { + const normalizedCountryId = normalizeNexSmsCountryId(countryId, -1); + return normalizedCountryId >= 0 ? `${normalizedProvider}:${normalizedCountryId}` : ''; + } + const normalizedCountryId = normalizeCountryId(countryId, 0); + return normalizedCountryId > 0 ? `${normalizedProvider}:${normalizedCountryId}` : ''; + }; + const splitCountryFailureKey = (countryKey, providerHint = '') => { + const fallbackProvider = normalizePhoneSmsProvider( + providerHint || activation?.provider || state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER + ); + const raw = String(countryKey || '').trim(); + if (!raw) { + return { provider: fallbackProvider, countryKey: '' }; + } + const idx = raw.indexOf(':'); + if (idx <= 0) { + return { provider: fallbackProvider, countryKey: raw }; + } + const providerPrefix = normalizePhoneSmsProvider(raw.slice(0, idx)); + const keyPart = raw.slice(idx + 1).trim(); + return { + provider: providerPrefix || fallbackProvider, + countryKey: keyPart, + }; + }; + const resolveCountryLabelByFailureKey = (countryKey, provider = activation?.provider || state?.phoneSmsProvider || '') => { + const parsed = splitCountryFailureKey(countryKey, provider); + const normalizedProvider = normalizePhoneSmsProvider(parsed.provider || provider || state?.phoneSmsProvider || ''); + const normalizedCountryKey = String(parsed.countryKey || '').trim(); + if (!normalizedCountryKey) { + return 'Unknown country'; + } + if (normalizedProvider === PHONE_SMS_PROVIDER_5SIM) { + const matched = resolveFiveSimCountryCandidates(state) + .find((entry) => String(entry.id || entry.code || '') === normalizedCountryKey); + return matched?.label || normalizedCountryKey || 'Unknown country'; + } + if (normalizedProvider === PHONE_SMS_PROVIDER_NEXSMS) { + const normalizedCountryId = normalizeNexSmsCountryId(normalizedCountryKey, -1); + const matched = resolveNexSmsCountryCandidates(state) + .find((entry) => normalizeNexSmsCountryId(entry.id, -1) === normalizedCountryId); + return matched?.label || `Country #${normalizedCountryId}`; + } + const normalizedCountryId = normalizeCountryId(normalizedCountryKey, 0); + const matched = resolveCountryCandidates(state) + .find((entry) => normalizeCountryId(entry.id, 0) === normalizedCountryId); + return matched?.label || `Country #${normalizedCountryId}`; + }; + + const ensureAddPhonePageBeforeSubmit = async (attemptLabel = 'before submit') => { + let snapshot = null; + try { + snapshot = await readPhonePageState(tabId, 12000); + } catch (error) { + await addLog( + `Step 9: failed to inspect auth page ${attemptLabel}. ${error.message}`, + 'warn' + ); + snapshot = null; + } + + if (snapshot?.addPhonePage) { + return snapshot; + } + + try { + const returned = await returnToAddPhone(tabId); + const merged = { + ...(snapshot || {}), + ...(returned || {}), + }; + if (merged?.addPhonePage) { + return merged; + } + } catch (error) { + await addLog( + `Step 9: failed to return to add-phone page ${attemptLabel}. ${error.message}`, + 'warn' + ); + } + + const latest = await readPhonePageState(tabId, 15000); + if (!latest?.addPhonePage) { + throw new Error( + `Step 9: auth page is not on add-phone before phone submit (${attemptLabel}). URL: ${latest?.url || 'unknown'}` + ); + } + return latest; + }; const getCountryFailureCount = (countryId) => { - const normalizedCountryId = normalizeCountryId(countryId, 0); - if (!normalizedCountryId) { + const countryKey = normalizeCountryFailureKey(countryId); + if (!countryKey) { return 0; } - return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(normalizedCountryId)) || 0)); + return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(countryKey)) || 0)); }; const markCountrySmsFailure = async (countryId, reason = 'sms_timeout') => { - const normalizedCountryId = normalizeCountryId(countryId, 0); - if (!normalizedCountryId) { + const countryKey = normalizeCountryFailureKey(countryId); + if (!countryKey) { return; } - const nextCount = getCountryFailureCount(normalizedCountryId) + 1; - countrySmsFailureCounts.set(normalizedCountryId, nextCount); + const nextCount = getCountryFailureCount(countryKey) + 1; + countrySmsFailureCounts.set(countryKey, nextCount); if (nextCount >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) { - const matched = resolveCountryCandidates(state) - .find((entry) => normalizeCountryId(entry.id, 0) === normalizedCountryId); - const countryLabel = matched?.label || `Country #${normalizedCountryId}`; + const countryLabel = resolveCountryLabelByFailureKey(countryKey); await addLog( `Step 9: ${countryLabel} reached ${nextCount} SMS failures (${reason}); next acquisition will fallback to other selected country candidates first.`, 'warn' @@ -1435,17 +3983,157 @@ }; const clearCountrySmsFailure = (countryId) => { - const normalizedCountryId = normalizeCountryId(countryId, 0); - if (!normalizedCountryId) { + const countryKey = normalizeCountryFailureKey(countryId); + if (!countryKey) { return; } - countrySmsFailureCounts.delete(normalizedCountryId); + countrySmsFailureCounts.delete(countryKey); + countryPriceFloorByKey.delete(countryKey); }; - const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries()) - .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) - .map(([countryId]) => normalizeCountryId(countryId, 0)) - .filter((countryId) => countryId > 0); + 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; + }; try { while (true) { @@ -1455,9 +4143,18 @@ } if (pageState?.addPhonePage) { + const addPhoneUrlText = String(pageState?.url || '').trim().toLowerCase(); + const looksLikeAddPhoneUrl = /\/add-phone(?:[/?#]|$)/i.test(addPhoneUrlText); + if (!looksLikeAddPhoneUrl) { + pageState = await ensureAddPhonePageBeforeSubmit( + activation ? 'with current activation' : 'with new activation' + ); + } if (!activation) { activation = await acquirePhoneActivation(state, { blockedCountryIds: getBlockedCountryIds(), + countryPriceFloorByCountryId: getCountryPriceFloorById(), + skipPreferredActivation: preferredActivationExhausted, }); shouldCancelActivation = true; await persistCurrentActivation(activation); @@ -1496,35 +4193,29 @@ ); } - let submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); + let submitResult = null; + try { + submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); + } catch (submitError) { + const submitErrorText = String(submitError?.message || submitError || 'unknown error'); + if (isRecoverableAddPhoneSubmitError(submitErrorText)) { + await rotateActivationAfterAddPhoneFailure( + submitErrorText, + 'add_phone_submit_failed', + { url: pageState?.url || '' } + ); + continue; + } + throw submitError; + } if (submitResult.addPhoneRejected) { const addPhoneRejectText = String(submitResult.errorText || submitResult.url || 'unknown error'); if (isPhoneNumberUsedError(addPhoneRejectText)) { - usedNumberReplacementAttempts += 1; - if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { - throw new Error( - `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: phone_number_used.` - ); - } - - await addLog( - `Step 9: add-phone rejected ${activation.phoneNumber} as already used (${addPhoneRejectText}), replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, - 'warn' + await rotateActivationAfterAddPhoneFailure( + `add-phone rejected ${activation.phoneNumber} as already used (${addPhoneRejectText})`, + 'phone_number_used', + submitResult ); - if (shouldCancelActivation && activation) { - await cancelPhoneActivation(state, activation); - } - await clearCurrentActivation(); - activation = null; - shouldCancelActivation = false; - preferReuseExistingActivationOnAddPhone = false; - addPhoneReentryWithSameActivation = 0; - pageState = { - ...pageState, - ...submitResult, - addPhonePage: true, - phoneVerificationPage: false, - }; continue; } @@ -1532,10 +4223,29 @@ `Step 9: add-phone rejected current number but did not mark it as used (${addPhoneRejectText}), retrying once with the same number.`, 'warn' ); - submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); - if (submitResult.addPhoneRejected) { + let retrySubmitError = null; + try { + submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); + } catch (submitError) { + retrySubmitError = submitError; + } + if (retrySubmitError || submitResult.addPhoneRejected) { + const retryRejectText = String( + retrySubmitError?.message + || submitResult?.errorText + || submitResult?.url + || 'unknown error' + ); + if (isPhoneNumberUsedError(retryRejectText) || isRecoverableAddPhoneSubmitError(retryRejectText)) { + await rotateActivationAfterAddPhoneFailure( + `add-phone keeps rejecting ${activation.phoneNumber} (${retryRejectText})`, + isPhoneNumberUsedError(retryRejectText) ? 'phone_number_used' : 'add_phone_rejected', + submitResult || {} + ); + continue; + } throw new Error( - `Step 9: add-phone keeps rejecting current number without explicit "used" status: ${submitResult.errorText || submitResult.url || 'unknown error'}.` + `Step 9: add-phone keeps rejecting current number without explicit "used" status: ${retryRejectText}.` ); } } @@ -1571,16 +4281,54 @@ const codeResult = await waitForPhoneCodeOrRotateNumber(tabId, state, activation); if (codeResult.replaceNumber) { + await markPreferredActivationExhausted(codeResult.reason || 'sms_timeout'); shouldReplaceNumber = true; replaceReason = codeResult.reason || 'sms_not_received'; break; } - await setState({ + await setPhoneRuntimeState({ [PHONE_VERIFICATION_CODE_STATE_KEY]: String(codeResult.code || '').trim(), }); await addLog(`Step 9: received phone verification code ${codeResult.code}.`, 'info'); - const submitResult = await submitPhoneVerificationCode(tabId, codeResult.code); + 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; + } if (submitResult.returnedToAddPhone) { await addLog( @@ -1626,8 +4374,32 @@ await resendPhoneVerificationCode(tabId); await addLog('Step 9: clicked "Resend text message" after the phone code was rejected.', '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'); } + if (shouldReplaceNumber) { + break; + } await addLog( `Step 9: phone verification code was rejected, requested another SMS (${remainingResendRequests} resend attempts left).`, 'warn' @@ -1660,10 +4432,26 @@ if ( activation - && (replaceReason === 'resend_throttled' || /^sms_timeout_after_/i.test(String(replaceReason || ''))) + && ( + replaceReason === 'resend_throttled' + || replaceReason === 'route_405_retry_loop' + || /^sms_timeout_after_/i.test(String(replaceReason || '')) + ) ) { - await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout'); + 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 markPreferredActivationExhausted(replaceReason || 'replace_number'); usedNumberReplacementAttempts += 1; if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { @@ -1680,13 +4468,47 @@ shouldCancelActivation = false; addPhoneReentryWithSameActivation = 0; - let returnResult = { addPhonePage: true, phoneVerificationPage: false }; + let returnResult = { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }; try { returnResult = await returnToAddPhone(tabId); } catch (returnError) { await addLog(`Step 9: failed to return to add-phone page before replacing number. ${returnError.message}`, 'warn'); } + if (!returnResult?.addPhonePage) { + try { + const stateSnapshot = await readPhonePageState(tabId, 12000); + if (stateSnapshot?.addPhonePage) { + returnResult = { + ...returnResult, + ...stateSnapshot, + addPhonePage: true, + phoneVerificationPage: false, + }; + } + } catch (_) { + // Best effort: keep fallback state for compatibility with tests and older flows. + } + } + try { + const verifiedAddPhoneState = await ensureAddPhonePageBeforeSubmit('after replace-number rotation'); + returnResult = { + ...returnResult, + ...verifiedAddPhoneState, + addPhonePage: true, + phoneVerificationPage: false, + }; + } catch (verifyError) { + await addLog( + `Step 9: failed to verify add-phone page after number replacement. ${verifyError.message}`, + 'warn' + ); + } + await addLog( `Step 9: replacing number and retrying inside step 9 (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, 'warn' diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 9f59517..b9bd3b0 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -316,6 +316,8 @@ let mailPollingAttempt = 1; let lastMailPollingError = null; let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; + let retryWithoutStep7Streak = 0; + const maxRetryWithoutStep7Streak = 3; while (true) { try { @@ -331,6 +333,7 @@ if (Number(result?.lastResendAt) > 0) { stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0); } + retryWithoutStep7Streak = 0; return; } catch (err) { const visibleStep = getVisibleStep(currentState, 8); @@ -361,8 +364,21 @@ mailPollingAttempt += 1; if (retryWithoutStep7) { + retryWithoutStep7Streak += 1; + if (retryWithoutStep7Streak > maxRetryWithoutStep7Streak) { + await addLog( + `步骤 ${visibleStep}:邮箱通信异常在当前链路已连续重试 ${retryWithoutStep7Streak} 次,改为回到步骤 ${authLoginStep} 重新发起授权链路,避免空轮询循环。`, + 'warn' + ); + await rerunStep7ForStep8Recovery({ + logMessage: `步骤 ${visibleStep}:邮箱通信异常持续未恢复,正在回到步骤 ${authLoginStep} 重新发起登录流程...`, + }); + currentState = await getState(); + retryWithoutStep7Streak = 0; + continue; + } await addLog( - `步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`, + `步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}(连续同链路重试 ${retryWithoutStep7Streak}/${maxRetryWithoutStep7Streak})。`, 'warn' ); const latestState = await getState(); @@ -390,6 +406,7 @@ } continue; } + retryWithoutStep7Streak = 0; await addLog( isStep8RestartStep7Error(currentError) ? `步骤 ${visibleStep}:检测到认证页进入重试/超时报错状态,准备从步骤 ${authLoginStep} 重新开始(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS})...` diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index fc0a646..4fe13b2 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -9,6 +9,8 @@ chrome, completeStepFromBackground, confirmCustomVerificationStepBypass, + generateRandomBirthday, + generateRandomName, ensureMail2925MailboxSession, ensureIcloudMailSession, getMailConfig, @@ -19,12 +21,29 @@ CLOUDFLARE_TEMP_EMAIL_PROVIDER, resolveVerificationStep, reuseOrCreateTab, + sendToContentScript, sendToContentScriptResilient, + isRetryableContentScriptTransportError = () => false, shouldUseCustomRegistrationEmail, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, throwIfStopped, } = deps; + function buildSignupProfileForVerificationStep() { + const name = typeof generateRandomName === 'function' ? generateRandomName() : null; + const birthday = typeof generateRandomBirthday === 'function' ? generateRandomBirthday() : null; + if (!name?.firstName || !name?.lastName || !birthday) { + return null; + } + return { + firstName: name.firstName, + lastName: name.lastName, + year: birthday.year, + month: birthday.month, + day: birthday.day, + }; + } + function getExpectedMail2925MailboxEmail(state = {}) { if (Boolean(state?.mail2925UseAccountPool)) { const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); @@ -80,24 +99,73 @@ throwIfStopped(); await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...'); - const prepareResult = await sendToContentScriptResilient( - 'signup-page', - { - type: 'PREPARE_SIGNUP_VERIFICATION', - step: 4, - source: 'background', - payload: { - password: state.password || state.customPassword || '', - prepareSource: 'step4_execute', - prepareLogLabel: '步骤 4 执行', - }, + const prepareRequest = { + type: 'PREPARE_SIGNUP_VERIFICATION', + step: 4, + source: 'background', + payload: { + password: state.password || state.customPassword || '', + prepareSource: 'step4_execute', + prepareLogLabel: '步骤 4 执行', }, - { - timeoutMs: 30000, - retryDelayMs: 700, - logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...', + }; + const prepareTimeoutMs = 30000; + const prepareResponseTimeoutMs = 30000; + const prepareStartAt = Date.now(); + let prepareResult = null; + + while (Date.now() - prepareStartAt < prepareTimeoutMs) { + throwIfStopped(); + + try { + prepareResult = typeof sendToContentScript === 'function' + ? await sendToContentScript('signup-page', prepareRequest, { + responseTimeoutMs: prepareResponseTimeoutMs, + }) + : await sendToContentScriptResilient('signup-page', prepareRequest, { + timeoutMs: Math.max(1000, prepareTimeoutMs - (Date.now() - prepareStartAt)), + responseTimeoutMs: prepareResponseTimeoutMs, + retryDelayMs: 700, + logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...', + }); + break; + } catch (error) { + if (!isRetryableContentScriptTransportError(error)) { + throw error; + } + + const remainingMs = Math.max(0, prepareTimeoutMs - (Date.now() - prepareStartAt)); + if (remainingMs <= 0) { + throw error; + } + + const recoverResult = await sendToContentScriptResilient('signup-page', { + type: 'RECOVER_AUTH_RETRY_PAGE', + step: 4, + source: 'background', + payload: { + flow: 'signup', + step: 4, + timeoutMs: Math.min(12000, remainingMs), + maxClickAttempts: 2, + logLabel: '步骤 4:检测到注册认证重试页,正在点击“重试”恢复', + }, + }, { + timeoutMs: Math.min(12000, remainingMs), + responseTimeoutMs: Math.min(12000, remainingMs), + retryDelayMs: 700, + logMessage: '步骤 4:认证页正在切换,等待页面重新就绪后继续检测...', + }); + + if (recoverResult?.error) { + throw new Error(recoverResult.error); + } } - ); + } + + if (!prepareResult) { + throw new Error('步骤 4:等待注册验证码页面就绪超时,请刷新认证页后重试。'); + } if (prepareResult && prepareResult.error) { throw new Error(prepareResult.error); @@ -152,12 +220,14 @@ LUCKMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER, ].includes(mail.provider); + const signupProfile = buildSignupProfileForVerificationStep(); await resolveVerificationStep(4, state, mail, { filterAfterTimestamp: verificationFilterAfterTimestamp, sessionKey: verificationSessionKey, disableTimeBudgetCap: mail.provider === '2925', requestFreshCodeFirst: shouldRequestFreshCodeFirst, + signupProfile, resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER ? 15000 : ((mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 1637dc8..af7023f 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -135,6 +135,26 @@ } } + function isSub2ApiTransientExchangeError(error) { + const message = normalizeString(error?.message || error); + if (!message) { + return false; + } + const tokenExchangeFailure = /auth\.openai\.com\/oauth\/token/i.test(message); + const transientNetworkSignal = /unexpected\s+eof|eof|connection\s+refused|i\/o\s+timeout|context\s+deadline\s+exceeded|connection\s+reset|broken\s+pipe|failed\s+to\s+fetch|temporarily\s+unavailable|timeout/i.test(message); + const transientExchangeUserSignal = /token_exchange_user_error|invalid\s+request\.\s+please\s+try\s+again\s+later/i.test(message); + if (transientExchangeUserSignal) { + return true; + } + return tokenExchangeFailure && transientNetworkSignal; + } + + async function sleep(ms = 0) { + const timeout = Math.max(0, Number(ms) || 0); + if (!timeout) return; + await new Promise((resolve) => setTimeout(resolve, timeout)); + } + async function fetchCodex2ApiJson(origin, path, options = {}) { const controller = new AbortController(); const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); @@ -286,6 +306,7 @@ } async function executeSub2ApiStep10(state) { + const visibleStep = resolvePlatformVerifyStep(state); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } @@ -327,8 +348,8 @@ injectSource: 'sub2api-panel', }); - await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...'); - const result = await sendToContentScript('sub2api-panel', { + await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`); + const requestMessage = { type: 'EXECUTE_STEP', step: 10, source: 'background', @@ -345,12 +366,32 @@ sub2apiGroupId: state.sub2apiGroupId, sub2apiDraftName: state.sub2apiDraftName, }, - }, { - responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, - }); - - if (result?.error) { - throw new Error(result.error); + }; + const maxExchangeAttempts = 3; + let lastError = null; + for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) { + try { + const result = await sendToContentScript('sub2api-panel', requestMessage, { + responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, + }); + if (result?.error) { + throw new Error(result.error); + } + return; + } catch (error) { + lastError = error; + if (!isSub2ApiTransientExchangeError(error) || attempt >= maxExchangeAttempts) { + throw error; + } + await addLog( + `步骤 ${visibleStep}:SUB2API 回调交换出现临时网络波动(${error.message}),正在重试 ${attempt + 1}/${maxExchangeAttempts}...`, + 'warn' + ); + await sleep(1200 * attempt); + } + } + if (lastError) { + throw lastError; } } diff --git a/background/verification-flow.js b/background/verification-flow.js index 1a8dfea..aa1d8bd 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -1,6 +1,9 @@ (function attachBackgroundVerificationFlow(root, factory) { root.MultiPageBackgroundVerificationFlow = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundVerificationFlowModule() { + const ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS = 18000; + const ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS = 22000; + function createVerificationFlowHelpers(deps = {}) { const { addLog, @@ -46,6 +49,33 @@ return step === 4 ? '注册' : '登录'; } + function capIcloudMailPollingTimeouts(mail, timedPoll) { + const defaultResponseTimeoutMs = Math.max(1000, Number(timedPoll?.responseTimeoutMs) || 30000); + const defaultTimeoutMs = Math.max(defaultResponseTimeoutMs, Number(timedPoll?.timeoutMs) || defaultResponseTimeoutMs); + if (mail?.source !== 'icloud-mail') { + return { + responseTimeoutMs: defaultResponseTimeoutMs, + timeoutMs: defaultTimeoutMs, + capped: false, + }; + } + + const cappedResponseTimeoutMs = Math.max( + 5000, + Math.min(defaultResponseTimeoutMs, ICLOUD_MAIL_POLL_RESPONSE_TIMEOUT_CAP_MS) + ); + const cappedTimeoutMs = Math.max( + cappedResponseTimeoutMs, + Math.min(defaultTimeoutMs, ICLOUD_MAIL_POLL_TOTAL_TIMEOUT_CAP_MS) + ); + + return { + responseTimeoutMs: cappedResponseTimeoutMs, + timeoutMs: cappedTimeoutMs, + capped: cappedResponseTimeoutMs < defaultResponseTimeoutMs || cappedTimeoutMs < defaultTimeoutMs, + }; + } + function isLikelyLoggedInChatgptHomeUrl(rawUrl) { const url = String(rawUrl || '').trim(); if (!url) return false; @@ -375,6 +405,10 @@ const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1); const disableTimeBudgetCap = Boolean(options.disableTimeBudgetCap); const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel); + const minPollingResponseTimeoutMs = Math.max( + 3000, + Number(options.minPollingResponseTimeoutMs) || 5000 + ); if (!disableTimeBudgetCap && remainingMs !== null) { nextPayload.maxAttempts = Math.max( @@ -386,7 +420,10 @@ const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000); const responseTimeoutMs = disableTimeBudgetCap || remainingMs === null ? defaultResponseTimeoutMs - : Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs)); + : Math.max( + minPollingResponseTimeoutMs, + Math.min(defaultResponseTimeoutMs, remainingMs) + ); return { payload: nextPayload, @@ -583,9 +620,27 @@ const resendIntervalMs = Math.max(0, Number(pollOverrides.resendIntervalMs) || 0); let lastResendAt = Number(pollOverrides.lastResendAt) || 0; let usedResendRequests = 0; + let pollOnlyNoResendRounds = 0; + let transportErrorStreak = 0; + const maxTransportErrorStreak = mail?.source === 'icloud-mail' ? 6 : 4; + const maxIcloudNoResendRounds = mail?.source === 'icloud-mail' ? 4 : 0; + const hasExistingResendTimestamp = Number(lastResendAt) > 0; + const initialRoundNoResendWindowMs = resendIntervalMs > 0 + ? Math.max(10000, Math.min(45000, resendIntervalMs)) + : 0; + const initialRoundNoResendUntil = hasExistingResendTimestamp + ? 0 + : (initialRoundNoResendWindowMs > 0 ? (Date.now() + initialRoundNoResendWindowMs) : 0); for (let round = 1; round <= totalRounds; round++) { throwIfStopped(); + if (round === 1 && initialRoundNoResendUntil > 0) { + const waitSeconds = Math.max(1, Math.ceil((initialRoundNoResendUntil - Date.now()) / 1000)); + await addLog( + `步骤 ${step}:首次进入验证码轮询,先等待 ${waitSeconds} 秒观察新邮件,避免过早重复重发。`, + 'info' + ); + } if (round > 1) { lastResendAt = await requestVerificationCodeResend(step, pollOverrides); usedResendRequests += 1; @@ -619,6 +674,13 @@ pollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱` ); + const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll); + if (timeoutWindow.capped) { + await addLog( + `步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`, + 'info' + ); + } const result = await sendToMailContentScriptResilient( mail, { @@ -628,9 +690,9 @@ payload: timedPoll.payload, }, { - timeoutMs: timedPoll.timeoutMs, + timeoutMs: timeoutWindow.timeoutMs, maxRecoveryAttempts: 2, - responseTimeoutMs: timedPoll.responseTimeoutMs, + responseTimeoutMs: timeoutWindow.responseTimeoutMs, } ); @@ -646,6 +708,8 @@ throw new Error(`步骤 ${step}:再次收到了相同的${getVerificationCodeLabel(step)}验证码:${result.code}`); } + transportErrorStreak = 0; + return { ...result, lastResendAt, @@ -661,16 +725,52 @@ } throw err; } + const isTransportError = isRetryableVerificationTransportError(err); + if (isTransportError) { + transportErrorStreak += 1; + lastError = err; + await addLog(`步骤 ${step}:${err.message}`, 'warn'); + if (transportErrorStreak >= maxTransportErrorStreak) { + throw new Error( + `步骤 ${step}:${mail?.label || '邮箱'}页面通信异常连续 ${transportErrorStreak} 次,已停止当前轮询以避免重复重发验证码。最后错误:${err.message}` + ); + } + const fallbackIntervalMs = Math.max( + 800, + Math.min( + 3000, + Number(payloadOverrides.intervalMs) + || Number(pollOverrides.intervalMs) + || 2000 + ) + ); + await sleepWithStop(fallbackIntervalMs); + continue; + } + transportErrorStreak = 0; lastError = err; await addLog(`步骤 ${step}:${err.message}`, 'warn'); } + if (mail?.source === 'icloud-mail' && maxIcloudNoResendRounds > 0) { + pollOnlyNoResendRounds += 1; + if (pollOnlyNoResendRounds >= maxIcloudNoResendRounds) { + throw new Error( + `步骤 ${step}:iCloud 邮箱连续 ${pollOnlyNoResendRounds} 轮轮询均未拿到验证码且未触发重发,已停止当前链路以避免空轮询循环,请刷新邮箱页后重试。` + ); + } + } + const remainingBeforeResendMs = lastResendAt > 0 ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt)) : 0; - if (remainingBeforeResendMs > 0) { + const initialCooldownMs = (round === 1 && initialRoundNoResendUntil > 0) + ? Math.max(0, initialRoundNoResendUntil - Date.now()) + : 0; + const effectiveCooldownMs = Math.max(remainingBeforeResendMs, initialCooldownMs); + if (effectiveCooldownMs > 0) { await addLog( - `步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`, + `步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(effectiveCooldownMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`, 'info' ); const configuredIntervalMs = Math.max( @@ -680,7 +780,7 @@ || 3000 ); const cooldownSleepMs = Math.min( - remainingBeforeResendMs, + effectiveCooldownMs, Math.max(1000, Math.min(configuredIntervalMs, 3000)) ); await sleepWithStop(cooldownSleepMs); @@ -840,6 +940,13 @@ pollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱` ); + const timeoutWindow = capIcloudMailPollingTimeouts(mail, timedPoll); + if (timeoutWindow.capped) { + await addLog( + `步骤 ${step}:iCloud 邮箱轮询已启用快速超时保护(${Math.ceil(timeoutWindow.timeoutMs / 1000)} 秒),避免页面无响应导致长时间卡住。`, + 'info' + ); + } const result = await sendToMailContentScriptResilient( mail, { @@ -849,9 +956,9 @@ payload: timedPoll.payload, }, { - timeoutMs: timedPoll.timeoutMs, + timeoutMs: timeoutWindow.timeoutMs, maxRecoveryAttempts: 2, - responseTimeoutMs: timedPoll.responseTimeoutMs, + responseTimeoutMs: timeoutWindow.responseTimeoutMs, } ); @@ -909,7 +1016,10 @@ type: 'FILL_CODE', step, source: 'background', - payload: { code }, + payload: { + code, + ...(step === 4 && options.signupProfile ? { signupProfile: options.signupProfile } : {}), + }, }; let result; if (typeof sendToContentScriptResilient === 'function') { @@ -1144,6 +1254,9 @@ code: result.code, phoneVerificationRequired: Boolean(submitResult.addPhonePage), ...(step === 4 && submitResult?.skipProfileStep ? { skipProfileStep: true } : {}), + ...(step === 4 && submitResult?.skipProfileStepReason + ? { skipProfileStepReason: submitResult.skipProfileStepReason } + : {}), }); triggerPostSuccessMailboxCleanup(step, mail); return { diff --git a/content/auth-page-recovery.js b/content/auth-page-recovery.js index 63417f8..8b65c57 100644 --- a/content/auth-page-recovery.js +++ b/content/auth-page-recovery.js @@ -71,8 +71,9 @@ : false; const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); const userAlreadyExistsBlocked = /user_already_exists/i.test(text); + const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text); - if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { return null; } @@ -84,6 +85,7 @@ titleMatched, detailMatched, routeErrorMatched, + fetchFailedMatched, maxCheckAttemptsBlocked, userAlreadyExistsBlocked, }; diff --git a/content/icloud-mail.js b/content/icloud-mail.js index adc402f..e6befa7 100644 --- a/content/icloud-mail.js +++ b/content/icloud-mail.js @@ -1,5 +1,6 @@ const ICLOUD_MAIL_PREFIX = '[MultiPage:icloud-mail]'; const isTopFrame = window === window.top; +const ICLOUD_POLL_SESSION_CACHE = new Map(); console.log(ICLOUD_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child'); @@ -12,7 +13,10 @@ function isMailApplicationFrame() { if (isTopFrame) { console.log(ICLOUD_MAIL_PREFIX, 'Top frame detected; waiting for mail iframe.'); -} else { +} + +const shouldHandlePollEmailInCurrentFrame = !isTopFrame || isMailApplicationFrame(); +if (shouldHandlePollEmailInCurrentFrame) { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'POLL_EMAIL') { if (!isMailApplicationFrame()) { @@ -221,19 +225,76 @@ if (isTopFrame) { } } + function normalizePollSessionKey(payload = {}) { + const raw = String(payload?.sessionKey || '').trim(); + if (raw) { + return raw; + } + return ''; + } + + function getOrCreatePollSessionBaseline(sessionKey, currentItems = []) { + if (!sessionKey) { + return { + signatures: new Set(currentItems.map(buildItemSignature)), + fallbackCarry: 0, + fromCache: false, + }; + } + + const cached = ICLOUD_POLL_SESSION_CACHE.get(sessionKey); + if (cached && cached.signatures instanceof Set) { + return { + signatures: new Set(cached.signatures), + fallbackCarry: Math.max(0, Number(cached.fallbackCarry) || 0), + fromCache: true, + }; + } + + return { + signatures: new Set(currentItems.map(buildItemSignature)), + fallbackCarry: 0, + fromCache: false, + }; + } + + function persistPollSessionBaseline(sessionKey, signatures, fallbackCarry = 0) { + if (!sessionKey) { + return; + } + ICLOUD_POLL_SESSION_CACHE.set(sessionKey, { + signatures: new Set(signatures || []), + fallbackCarry: Math.max(0, Number(fallbackCarry) || 0), + updatedAt: Date.now(), + }); + if (ICLOUD_POLL_SESSION_CACHE.size > 12) { + const oldest = Array.from(ICLOUD_POLL_SESSION_CACHE.entries()) + .sort((left, right) => Number(left?.[1]?.updatedAt || 0) - Number(right?.[1]?.updatedAt || 0)) + .slice(0, ICLOUD_POLL_SESSION_CACHE.size - 12); + oldest.forEach(([key]) => ICLOUD_POLL_SESSION_CACHE.delete(key)); + } + } + async function handlePollEmail(step, payload) { const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); const FALLBACK_AFTER = 3; + const pollSessionKey = normalizePollSessionKey(payload); const normalizedSenderFilters = senderFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean); const normalizedSubjectFilters = subjectFilters.map((filter) => String(filter || '').toLowerCase()).filter(Boolean); log(`步骤 ${step}:开始轮询 iCloud 邮箱(最多 ${maxAttempts} 次)`); await waitForElement('.content-container', 10000); await sleep(1500); - - const existingSignatures = new Set(collectThreadItems().map(buildItemSignature)); - log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`); + const currentItems = collectThreadItems(); + const sessionBaseline = getOrCreatePollSessionBaseline(pollSessionKey, currentItems); + const existingSignatures = sessionBaseline.signatures; + let fallbackCarry = sessionBaseline.fallbackCarry; + if (sessionBaseline.fromCache) { + log(`步骤 ${step}:已复用当前会话旧邮件快照(${existingSignatures.size} 封)。`); + } else { + log(`步骤 ${step}:已记录当前 ${existingSignatures.size} 封旧邮件快照`); + } for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { log(`步骤 ${step}:正在轮询 iCloud 邮箱,第 ${attempt}/${maxAttempts} 次`); @@ -244,7 +305,7 @@ if (isTopFrame) { } const items = collectThreadItems(); - const useFallback = attempt > FALLBACK_AFTER; + const useFallback = (fallbackCarry + attempt) > FALLBACK_AFTER; for (const item of items) { const signature = buildItemSignature(item); @@ -287,6 +348,11 @@ if (isTopFrame) { const source = useFallback && existingSignatures.has(signature) ? '回退匹配邮件' : '新邮件'; log(`步骤 ${step}:已找到验证码:${code}(来源:${source})`, 'ok'); + persistPollSessionBaseline( + pollSessionKey, + new Set(collectThreadItems().map(buildItemSignature)), + 0 + ); return { ok: true, code, @@ -304,6 +370,13 @@ if (isTopFrame) { } } + fallbackCarry += maxAttempts; + persistPollSessionBaseline( + pollSessionKey, + new Set(collectThreadItems().map(buildItemSignature)), + fallbackCarry + ); + throw new Error( `${Math.round((maxAttempts * intervalMs) / 1000)} 秒后仍未在 iCloud 邮箱中找到新的匹配邮件。请手动检查收件箱。` ); diff --git a/content/phone-auth.js b/content/phone-auth.js index e9701f5..e9488da 100644 --- a/content/phone-auth.js +++ b/content/phone-auth.js @@ -19,7 +19,15 @@ waitForElement, } = deps; const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; + const PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::'; + const PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS = 6000; + const PHONE_RESEND_ROUTE_405_MAX_RECOVERIES = 2; + const PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS = 12000; const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i; + const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i; + const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3; + let lastPhoneRoute405RecoveryFailedAt = 0; + let activePhoneResendPromise = null; function dispatchInputEvents(element) { if (!element) return; @@ -35,6 +43,10 @@ return digits; } + function isExplicitInternationalPhoneInput(value) { + return /^\s*(?:\+|00)\s*\d/.test(String(value || '').trim()); + } + function normalizeCountryLabel(value) { return String(value || '') .normalize('NFKD') @@ -151,23 +163,31 @@ function toNationalPhoneNumber(value, dialCode) { const digits = normalizePhoneDigits(value); const normalizedDialCode = normalizePhoneDigits(dialCode); + const isExplicitInternational = isExplicitInternationalPhoneInput(value); if (!digits) { return ''; } if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) { return digits.slice(normalizedDialCode.length); } + if (isExplicitInternational) { + return digits; + } return digits; } function toE164PhoneNumber(value, dialCode) { const digits = normalizePhoneDigits(value); const normalizedDialCode = normalizePhoneDigits(dialCode); + const isExplicitInternational = isExplicitInternationalPhoneInput(value); if (!digits) { return ''; } + if (isExplicitInternational) { + return `+${digits}`; + } if (!normalizedDialCode) { - return digits.startsWith('+') ? digits : `+${digits}`; + return `+${digits}`; } if (digits.startsWith(normalizedDialCode)) { return `+${digits}`; @@ -234,34 +254,71 @@ .map((label) => normalizeCountryLabel(label)) .filter(Boolean); return normalizedLabels.some((optionLabel) => ( - optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel) + optionLabel.length > 2 + && normalizedTarget.length > 2 + && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)) )); }) || null; } - async function ensureCountrySelected(countryLabel) { + function findCountryOptionByPhoneNumber(phoneNumber) { + const select = getCountrySelect(); + if (!select) { + return null; + } + const digits = normalizePhoneDigits(phoneNumber); + if (!digits) { + return null; + } + + let bestMatch = null; + let bestDialCodeLength = 0; + for (const option of Array.from(select.options || [])) { + const dialCode = normalizePhoneDigits(extractDialCodeFromText(getOptionLabel(option))); + if (!dialCode || !digits.startsWith(dialCode)) { + continue; + } + if (dialCode.length > bestDialCodeLength) { + bestMatch = option; + bestDialCodeLength = dialCode.length; + } + } + return bestMatch; + } + + async function trySelectCountryOption(select, targetOption) { + if (!select || !targetOption) { + return false; + } + const selectedOption = getSelectedCountryOption(); + if (selectedOption && isSameCountryOption(selectedOption, targetOption)) { + return true; + } + select.value = String(targetOption.value || ''); + dispatchInputEvents(select); + await sleep(250); + const nextSelectedOption = getSelectedCountryOption(); + return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption)); + } + + async function ensureCountrySelected(countryLabel, phoneNumber = '') { const select = getCountrySelect(); if (!select) { return false; } - const targetOption = findCountryOptionByLabel(countryLabel); - if (!targetOption) { - throw new Error(`Add-phone page is missing the country option for "${countryLabel}".`); - } - - const selectedOption = getSelectedCountryOption(); - if (selectedOption && isSameCountryOption(selectedOption, targetOption)) { + const byLabel = findCountryOptionByLabel(countryLabel); + if (await trySelectCountryOption(select, byLabel)) { return true; } - select.value = String(targetOption.value || ''); - dispatchInputEvents(select); - await sleep(250); + const byPhoneNumber = findCountryOptionByPhoneNumber(phoneNumber); + if (await trySelectCountryOption(select, byPhoneNumber)) { + return true; + } - const nextSelectedOption = getSelectedCountryOption(); - return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption)); + return Boolean(getSelectedCountryOption()); } function getAddPhoneSubmitButton() { @@ -398,6 +455,81 @@ return ''; } + function getAuthRetryButton(options = {}) { + const { allowDisabled = false } = options; + const direct = document.querySelector('button[data-dd-action-name="Try again"]'); + if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) { + return direct; + } + + const candidates = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]'); + return Array.from(candidates).find((element) => { + if (!isVisibleElement(element) || (!allowDisabled && !isActionEnabled(element))) { + return false; + } + const text = String(getActionText?.(element) || '').trim(); + return /重试|try\s+again/i.test(text); + }) || null; + } + + function is405MethodNotAllowedPage() { + const path = String(location?.pathname || ''); + if (!/\/phone-verification(?:[/?#]|$)/i.test(path) && !/\/add-phone(?:[/?#]|$)/i.test(path)) { + return false; + } + const text = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim(); + const title = String(document?.title || ''); + const matched = PHONE_ROUTE_405_PATTERN.test(text) || PHONE_ROUTE_405_PATTERN.test(title); + if (!matched) { + return false; + } + return Boolean(getAuthRetryButton({ allowDisabled: true })); + } + + async function recoverPhoneRoute405(timeout = 12000, options = {}) { + const now = Date.now(); + if ( + lastPhoneRoute405RecoveryFailedAt > 0 + && now - lastPhoneRoute405RecoveryFailedAt < PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS + ) { + throw new Error( + `${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route is still in 405 recovery cooldown (${Math.ceil((PHONE_ROUTE_405_RECOVERY_COOLDOWN_MS - (now - lastPhoneRoute405RecoveryFailedAt)) / 1000)}s left). URL: ${location.href}` + ); + } + + const startedAt = Date.now(); + let clicked = 0; + const maxRetryClicks = Math.max( + 1, + Math.floor(Number(options?.maxRetryClicks) || PHONE_ROUTE_405_MAX_RECOVERY_CLICKS) + ); + while (Date.now() - startedAt < timeout) { + throwIfStopped(); + if (!is405MethodNotAllowedPage()) { + return; + } + const retryButton = getAuthRetryButton({ allowDisabled: true }); + if (retryButton && isActionEnabled(retryButton)) { + if (clicked >= maxRetryClicks) { + lastPhoneRoute405RecoveryFailedAt = Date.now(); + throw new Error( + `${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route stayed on 405 after ${clicked} retry click(s). URL: ${location.href}` + ); + } + clicked += 1; + await humanPause(200, 500); + simulateClick(retryButton); + await sleep(1000); + continue; + } + await sleep(250); + } + lastPhoneRoute405RecoveryFailedAt = Date.now(); + throw new Error( + `${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification route 405 recovery timed out after ${clicked} retry click(s). URL: ${location.href}` + ); + } + async function waitForAddPhoneReady(timeout = 20000) { const start = Date.now(); while (Date.now() - start < timeout) { @@ -414,6 +546,10 @@ const start = Date.now(); while (Date.now() - start < timeout) { throwIfStopped(); + if (is405MethodNotAllowedPage()) { + await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start)))); + continue; + } if (isPhoneVerificationPageReady()) { return { phoneVerificationPage: true, @@ -448,18 +584,15 @@ async function submitPhoneNumber(payload = {}) { const countryLabel = String(payload.countryLabel || '').trim(); - if (!countryLabel) { - throw new Error('Missing country label for add-phone submission.'); - } - + const isExplicitInternational = isExplicitInternationalPhoneInput(payload.phoneNumber); await waitForAddPhoneReady(); - const countrySelected = await ensureCountrySelected(countryLabel); + const countrySelected = await ensureCountrySelected(countryLabel, payload.phoneNumber); if (!countrySelected) { - throw new Error(`Failed to select "${countryLabel}" on the add-phone page.`); + throw new Error(`Failed to select "${countryLabel || 'target country'}" on the add-phone page.`); } const dialCode = getDisplayedDialCode(); - if (!dialCode) { + if (!dialCode && !isExplicitInternational) { throw new Error(`Could not determine the dial code for "${countryLabel}" on the add-phone page.`); } @@ -498,6 +631,10 @@ const start = Date.now(); while (Date.now() - start < timeout) { throwIfStopped(); + if (is405MethodNotAllowedPage()) { + await recoverPhoneRoute405(Math.min(12000, Math.max(1000, timeout - (Date.now() - start)))); + continue; + } const errorText = getVerificationErrorText(); if (errorText) { @@ -565,40 +702,81 @@ fillInput(codeInput, code); await sleep(250); simulateClick(submitButton); + if (is405MethodNotAllowedPage()) { + await recoverPhoneRoute405(12000); + } return waitForPhoneVerificationOutcome(); } async function resendPhoneVerificationCode(timeout = 45000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - throwIfStopped(); - const throttledText = getPhoneResendThrottleText(); - if (throttledText) { - throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`); - } - const resendButton = getPhoneVerificationResendButton({ allowDisabled: true }); - if (resendButton && isActionEnabled(resendButton)) { - await humanPause(250, 700); - simulateClick(resendButton); - await sleep(1000); - const afterClickThrottleText = getPhoneResendThrottleText(); - if (afterClickThrottleText) { - throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`); + if (activePhoneResendPromise) { + return activePhoneResendPromise; + } + + activePhoneResendPromise = (async () => { + const start = Date.now(); + const route405RecoveryStart = Date.now(); + let route405RecoveryCount = 0; + const recoverRoute405WithinResend = async () => { + route405RecoveryCount += 1; + if (route405RecoveryCount > PHONE_RESEND_ROUTE_405_MAX_RECOVERIES) { + throw new Error( + `${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend stayed on route-405 page after ${PHONE_RESEND_ROUTE_405_MAX_RECOVERIES} recovery round(s). URL: ${location.href}` + ); } - return { - resent: true, - url: location.href, - }; + const recoveryBudgetLeft = PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS - (Date.now() - route405RecoveryStart); + if (recoveryBudgetLeft <= 0) { + throw new Error( + `${PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX}Phone verification resend exceeded route-405 recovery budget (${PHONE_RESEND_ROUTE_405_MAX_RECOVERY_TOTAL_MS}ms). URL: ${location.href}` + ); + } + const remainingTimeout = Math.max(1000, timeout - (Date.now() - start)); + const recoveryTimeout = Math.max(1000, Math.min(12000, recoveryBudgetLeft, remainingTimeout)); + await recoverPhoneRoute405(recoveryTimeout); + }; + + while (Date.now() - start < timeout) { + throwIfStopped(); + if (is405MethodNotAllowedPage()) { + await recoverRoute405WithinResend(); + continue; + } + const throttledText = getPhoneResendThrottleText(); + if (throttledText) { + throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`); + } + const resendButton = getPhoneVerificationResendButton({ allowDisabled: true }); + if (resendButton && isActionEnabled(resendButton)) { + await humanPause(250, 700); + simulateClick(resendButton); + await sleep(1000); + if (is405MethodNotAllowedPage()) { + await recoverRoute405WithinResend(); + continue; + } + const afterClickThrottleText = getPhoneResendThrottleText(); + if (afterClickThrottleText) { + throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`); + } + return { + resent: true, + url: location.href, + }; + } + await sleep(250); } - await sleep(250); - } - const timeoutThrottleText = getPhoneResendThrottleText(); - if (timeoutThrottleText) { - throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`); - } + const timeoutThrottleText = getPhoneResendThrottleText(); + if (timeoutThrottleText) { + throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`); + } - throw new Error('Timed out waiting for the phone verification resend button.'); + throw new Error('Timed out waiting for the phone verification resend button.'); + })().finally(() => { + activePhoneResendPromise = null; + }); + + return activePhoneResendPromise; } async function returnToAddPhone(timeout = 20000) { diff --git a/content/signup-page.js b/content/signup-page.js index f36207b..2259f91 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1036,8 +1036,8 @@ const CONTINUE_ACTION_PATTERN = /继续|continue/i; const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/i; const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i; const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; -const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时/i; -const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405/i; +const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时|failed\s+to\s+fetch|network\s+error|fetch\s+failed/i; +const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i; const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::'; const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i; @@ -1144,7 +1144,14 @@ function isLikelyLoggedInChatgptHomeUrl(rawUrl = location.href) { } } -function getStep4PostVerificationState() { +function getStep4PostVerificationState(options = {}) { + const { ignoreVerificationVisibility = false } = options; + // Newer auth flows can briefly render profile fields before the email-verification + // form fully exits. Do not advance to Step 5 while verification UI is still present. + if (!ignoreVerificationVisibility && isVerificationPageStillVisible()) { + return null; + } + if (isStep5Ready() || isSignupProfilePageUrl()) { return { state: 'step5', @@ -1536,10 +1543,11 @@ function getAuthTimeoutErrorPageState(options = {}) { || AUTH_TIMEOUT_ERROR_TITLE_PATTERN.test(document.title || ''); const detailMatched = AUTH_TIMEOUT_ERROR_DETAIL_PATTERN.test(text); const routeErrorMatched = AUTH_ROUTE_ERROR_PATTERN.test(text); + const fetchFailedMatched = /failed\s+to\s+fetch|network\s+error|fetch\s+failed/i.test(text); const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text); const userAlreadyExistsBlocked = /user_already_exists/i.test(text); - if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { + if (!titleMatched && !detailMatched && !routeErrorMatched && !fetchFailedMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) { return null; } @@ -1551,6 +1559,7 @@ function getAuthTimeoutErrorPageState(options = {}) { titleMatched, detailMatched, routeErrorMatched, + fetchFailedMatched, maxCheckAttemptsBlocked, userAlreadyExistsBlocked, }; @@ -2382,7 +2391,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) { } if (step === 4) { - const postVerificationState = getStep4PostVerificationState(); + const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true }); if (postVerificationState?.state === 'logged_in_home') { return { success: true, @@ -2417,7 +2426,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) { throw createSignupUserAlreadyExistsError(); } - const postVerificationState = getStep4PostVerificationState(); + const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true }); if (postVerificationState?.state === 'logged_in_home') { return { success: true, @@ -2531,12 +2540,25 @@ async function waitForSplitVerificationInputsFilled(inputs, code, timeout = 2500 } async function fillVerificationCode(step, payload) { - const { code } = payload; + const { code, signupProfile } = payload; if (!code) throw new Error('未提供验证码。'); - if (step === 4 && isStep5Ready()) { - log(`步骤 ${step}:检测到页面已进入下一阶段,本次验证码提交按成功处理。`, 'ok'); - return { success: true, assumed: true, alreadyAdvanced: true }; + if (step === 4) { + const postVerificationState = getStep4PostVerificationState(); + if (postVerificationState?.state === 'logged_in_home') { + log(`步骤 ${step}:检测到页面已进入 ChatGPT 已登录态,本次验证码提交按成功处理。`, 'ok'); + return { + success: true, + assumed: true, + alreadyAdvanced: true, + skipProfileStep: true, + url: postVerificationState.url || location.href, + }; + } + if (postVerificationState?.state === 'step5') { + log(`步骤 ${step}:检测到页面已进入下一阶段,本次验证码提交按成功处理。`, 'ok'); + return { success: true, assumed: true, alreadyAdvanced: true }; + } } if (step === 8) { if (isStep8Ready()) { @@ -2554,6 +2576,18 @@ async function fillVerificationCode(step, payload) { await waitForLoginVerificationPageReady(); } + const combinedSignupProfilePage = step === 4 + && await waitForCombinedSignupVerificationProfilePage(); + if (combinedSignupProfilePage) { + if (!signupProfile || !signupProfile.firstName || !signupProfile.lastName) { + throw new Error('当前注册验证码页面要求同时填写资料,但未提供姓名或生日数据。'); + } + await step5_fillNameBirthday({ + ...signupProfile, + prefillOnly: true, + }); + } + // Find code input — could be a single input or multiple separate inputs // Retry with 405 error recovery if needed const maxRetries = 3; @@ -2632,6 +2666,10 @@ async function fillVerificationCode(step, payload) { } else { log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } + if (combinedSignupProfilePage && !outcome.invalidCode) { + outcome.skipProfileStep = true; + outcome.skipProfileStepReason = 'combined_verification_profile'; + } return outcome; } @@ -2663,6 +2701,11 @@ async function fillVerificationCode(step, payload) { log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } + if (combinedSignupProfilePage && !outcome.invalidCode) { + outcome.skipProfileStep = true; + outcome.skipProfileStepReason = 'combined_verification_profile'; + } + return outcome; } @@ -3333,8 +3376,53 @@ function getStep5DirectCompletionPayload({ isAgeMode = false } = {}) { return payload; } +function isCombinedSignupVerificationProfilePage() { + if (!isEmailVerificationPage() || !isVerificationPageStillVisible()) { + return false; + } + + if (!document.querySelector('form[action*="email-verification/register" i]')) { + return false; + } + + const nameInput = document.querySelector('input[name="name"], input[autocomplete="name"]'); + if (!nameInput || !isVisibleElement(nameInput)) { + return false; + } + + const ageInput = document.querySelector('input[name="age"]'); + if (ageInput && isVisibleElement(ageInput)) { + return true; + } + + const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]'); + const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]'); + const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]'); + return Boolean( + yearSpinner + && monthSpinner + && daySpinner + && isVisibleElement(yearSpinner) + && isVisibleElement(monthSpinner) + && isVisibleElement(daySpinner) + ); +} + +async function waitForCombinedSignupVerificationProfilePage(timeout = 2500) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (isCombinedSignupVerificationProfilePage()) { + return true; + } + await sleep(100); + } + + return isCombinedSignupVerificationProfilePage(); +} + async function step5_fillNameBirthday(payload) { - const { firstName, lastName, age, year, month, day } = payload; + const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload; if (!firstName || !lastName) throw new Error('未提供姓名数据。'); const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null); @@ -3533,6 +3621,11 @@ async function step5_fillNameBirthday(payload) { } + if (prefillOnly) { + log('步骤 4:混合注册页资料已预填,继续填写验证码。', 'info'); + return { prefilled: true }; + } + // Click "完成帐户创建" button await sleep(500); const completeBtn = document.querySelector('button[type="submit"]') diff --git a/docs/错误重试分层策略.md b/docs/错误重试分层策略.md new file mode 100644 index 0000000..46c7f59 --- /dev/null +++ b/docs/错误重试分层策略.md @@ -0,0 +1,72 @@ +# 错误重试分层策略(步骤 4/8/9/自动运行) + +本文档说明当前实现中的三层重试语义,避免把“当步重试 / 小循环重试 / 大循环重试”混在一起理解。 + +## 1. 第一层:当步重试(Step 内部) + +定义:不离开当前步骤,在当前页面链路内恢复或替换资源。 + +典型位置: +- `background/steps/fetch-signup-code.js`(步骤 4 页面恢复) +- `background/steps/fetch-login-code.js` + `background/verification-flow.js`(步骤 8 邮箱轮询与重发) +- `background/phone-verification-flow.js`(步骤 9 号码轮换、重发、回到 add-phone) + +常见动作: +- 点击 `Try again` 恢复 405/重试页; +- 同一步内重新拉验证码; +- 步骤 9 内直接换号(不立刻回步骤 7); +- 同提供商/同国家内换价格档位或换号码。 + +## 2. 第二层:小循环重试(授权链路重开) + +定义:当前轮次内回到授权链路起点(通常步骤 7),再走 7→8→9。 + +触发来源: +- `background.js` 的 `getPostStep6AutoRestartDecision(...)`; +- 步骤 8 检测到邮箱通信异常且当前链路连续重试超限; +- 步骤 9 触发 `PHONE_RESTART_STEP7` 类错误(例如回调窗口过期/链路不可恢复)。 + +特点: +- 还是同一“轮次”; +- 不进入线程间隔(20 分钟); +- 优先用于“流程状态错位/页面链路失效”类问题。 + +## 3. 第三层:大循环重试(自动运行 attempt/round) + +定义:由自动运行控制器决定是否进入下一次 attempt,或等待线程间隔后继续。 + +位置: +- `background/auto-run-controller.js` + +关键点: +- `autoRunSkipFailures=true` 时,允许同轮多次 attempt; +- 某些错误会被视为“本轮直接失败并跳过剩余重试”; +- 若配置了线程间隔,会出现“等待 N 分钟后第 x 次尝试”日志。 + +## 4. 错误分类优先级(当前实现) + +1) 先做当步恢复(能在步骤内闭环就不升级) +2) 当步失败后,再判断是否走小循环(回步骤 7) +3) 小循环仍失败,才交给大循环(attempt / round / 线程间隔) + +## 5. 已落实的关键规则(与近期问题对应) + +- `Failed to fetch / network error / fetch failed`: + 步骤 4/8/9 进入有上限的网络重试(当前默认 3 次,冷却 12 秒)。 + +- `Step 9: 5sim check activation failed: order not found`: + 归类为“号码失效”,在步骤 9 内立即换号(当步重试),不应直接触发线程间隔。 + +- `Route Error 405`(手机号验证码页): + 优先当步恢复;若落入 route-405 循环,步骤 9 内换号。 + +- `add-phone / phone-verification` 页面状态: + 表示流程已进入手机链路,自动运行层默认不再盲目回步骤 7,避免破坏当前链路。 + +## 6. 关于“第几轮尝试 + 暂停”含义 + +- “第 x/y 轮第 n 次尝试”是自动运行控制器的外层 attempt 计数; +- “线程间隔:等待 20 分钟...”表示已经升级到第三层(大循环); +- 用户手动停止会中断当前计划;恢复时是否回到步骤 7,取决于当前页面状态与回调是否过期: + - 回调未过期且仍在可恢复链路:优先当前链路继续; + - 回调已过期或链路断裂:回步骤 7 重拉授权。 diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js index bb8c2d5..225164d 100644 --- a/sidepanel/ip-proxy-panel.js +++ b/sidepanel/ip-proxy-panel.js @@ -13,7 +13,10 @@ function normalizeIpProxyService(value = '') { const ipProxyActionState = { busy: false, action: '', + startedAt: 0, }; +const IP_PROXY_ACTION_LOCK_TIMEOUT_MS = 25000; +let ipProxyDeferredProbeTimer = 0; const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded'; let ipProxySectionExpanded = false; @@ -78,12 +81,14 @@ function getIpProxyActionState() { return { busy: Boolean(ipProxyActionState.busy), action: normalizeIpProxyActionType(ipProxyActionState.action), + startedAt: Number(ipProxyActionState.startedAt) || 0, }; } function setIpProxyActionBusy(action = '', busy = false) { ipProxyActionState.busy = Boolean(busy); ipProxyActionState.action = ipProxyActionState.busy ? normalizeIpProxyActionType(action) : ''; + ipProxyActionState.startedAt = ipProxyActionState.busy ? Date.now() : 0; } async function runIpProxyActionWithLock(action = '', runner) { @@ -104,10 +109,23 @@ async function runIpProxyActionWithLock(action = '', runner) { updateIpProxyUI(latestState); } + const actionLabel = getIpProxyActionLabel(nextAction); + const timeoutMs = Math.max(5000, Number(IP_PROXY_ACTION_LOCK_TIMEOUT_MS) || 25000); + let timeoutId = 0; try { - const value = await runner(); + const value = await Promise.race([ + Promise.resolve().then(() => runner()), + new Promise((_, reject) => { + timeoutId = globalThis.setTimeout(() => { + reject(new Error(`${actionLabel}超时(${Math.round(timeoutMs / 1000)} 秒),已自动解锁,请重试。`)); + }, timeoutMs); + }), + ]); return { skipped: false, value }; } finally { + if (timeoutId) { + globalThis.clearTimeout(timeoutId); + } setIpProxyActionBusy(nextAction, false); if (typeof updateIpProxyUI === 'function') { updateIpProxyUI(latestState); @@ -115,6 +133,24 @@ async function runIpProxyActionWithLock(action = '', runner) { } } +function scheduleIpProxyExitProbe(options = {}) { + const { + silent = true, + delayMs = 80, + } = options; + const delay = Math.max(0, Number(delayMs) || 0); + if (ipProxyDeferredProbeTimer) { + globalThis.clearTimeout(ipProxyDeferredProbeTimer); + ipProxyDeferredProbeTimer = 0; + } + ipProxyDeferredProbeTimer = globalThis.setTimeout(() => { + ipProxyDeferredProbeTimer = 0; + Promise.resolve() + .then(() => probeIpProxyExit({ silent })) + .catch(() => {}); + }, delay); +} + function normalizeIpProxyMode(value = '') { const normalized = String(value || '').trim().toLowerCase(); return SUPPORTED_IP_PROXY_MODES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_MODE; @@ -210,6 +246,8 @@ function normalizeIpProxyServiceProfile(rawValue = {}) { accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''), accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''), poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20), + autoSyncEnabled: Boolean(raw.autoSyncEnabled), + autoSyncIntervalMinutes: String(Math.max(1, Math.min(1440, Number.parseInt(String(raw.autoSyncIntervalMinutes ?? '').trim(), 10) || 15))), host: String(raw.host || '').trim(), port: String(normalizeIpProxyPort(raw.port || '') || ''), protocol: normalizeIpProxyProtocol(raw.protocol), @@ -227,6 +265,8 @@ function buildIpProxyServiceProfileFromFlatState(state = {}) { accountSessionPrefix: state?.ipProxyAccountSessionPrefix, accountLifeMinutes: state?.ipProxyAccountLifeMinutes, poolTargetCount: state?.ipProxyPoolTargetCount, + autoSyncEnabled: state?.ipProxyAutoSyncEnabled, + autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes, host: state?.ipProxyHost, port: state?.ipProxyPort, protocol: state?.ipProxyProtocol, @@ -387,6 +427,8 @@ function buildCurrentIpProxyServiceProfileFromInputs() { accountSessionPrefix: inputIpProxyAccountSessionPrefix?.value || '', accountLifeMinutes: inputIpProxyAccountLifeMinutes?.value || '', poolTargetCount: inputIpProxyPoolTargetCount?.value || '', + autoSyncEnabled: Boolean(inputIpProxyAutoSyncEnabled?.checked), + autoSyncIntervalMinutes: inputIpProxyAutoSyncIntervalMinutes?.value || '', host: inputIpProxyHost?.value || '', port: inputIpProxyPort?.value || '', protocol: selectIpProxyProtocol?.value || '', @@ -419,6 +461,8 @@ function buildIpProxyStatePatchFromServiceProfile(service = '', profile = {}) { ipProxyAccountSessionPrefix: normalizedProfile.accountSessionPrefix, ipProxyAccountLifeMinutes: normalizedProfile.accountLifeMinutes, ipProxyPoolTargetCount: normalizedProfile.poolTargetCount, + ipProxyAutoSyncEnabled: normalizedProfile.autoSyncEnabled, + ipProxyAutoSyncIntervalMinutes: Number.parseInt(String(normalizedProfile.autoSyncIntervalMinutes || '15').trim(), 10) || 15, ipProxyHost: normalizedProfile.host, ipProxyPort: normalizedProfile.port, ipProxyProtocol: normalizedProfile.protocol, @@ -449,6 +493,12 @@ function applyIpProxyServiceProfileToInputs(profile = {}, options = {}) { if (inputIpProxyPoolTargetCount) { inputIpProxyPoolTargetCount.value = normalizedProfile.poolTargetCount; } + if (inputIpProxyAutoSyncEnabled) { + inputIpProxyAutoSyncEnabled.checked = Boolean(normalizedProfile.autoSyncEnabled); + } + if (inputIpProxyAutoSyncIntervalMinutes) { + inputIpProxyAutoSyncIntervalMinutes.value = String(normalizedProfile.autoSyncIntervalMinutes || '15'); + } if (inputIpProxyHost) { inputIpProxyHost.value = normalizedProfile.host; } @@ -1058,6 +1108,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { stateClass: 'state-idle', text: '未启用,沿用浏览器默认/全局代理。', details: '', + hideCurrentDisplay: false, }; } @@ -1073,6 +1124,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { stateClass: warningText ? 'state-warning' : 'state-applied', text: `${statusText}${briefWarning}`, details, + hideCurrentDisplay: true, }; } @@ -1082,6 +1134,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { stateClass: 'state-warning', text: '已启用,但当前没有可用代理(已阻断直连)。', details: errorText, + hideCurrentDisplay: false, }; } return { @@ -1090,6 +1143,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { ? '已启用,但账号模式没有可用代理。请先填写代理列表,或填写 Host/Port。已阻断所有网站直连。' : '已启用,但当前没有可用代理。请先点击“拉取”获取 IP 列表。已阻断所有网站直连。', details: '', + hideCurrentDisplay: false, }; } if (reason === 'proxy_api_unavailable') { @@ -1097,6 +1151,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { stateClass: 'state-error', text: '已启用,但当前浏览器不支持扩展代理 API,无法应用。', details, + hideCurrentDisplay: false, }; } if (reason === 'apply_failed') { @@ -1104,6 +1159,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { stateClass: 'state-error', text: '已启用,但代理应用失败(已回退默认代理)。', details: errorText || details, + hideCurrentDisplay: false, }; } if (reason === 'connectivity_failed') { @@ -1112,6 +1168,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { stateClass: 'state-error', text: `${prefix};连通性失败,请切换节点或重试。`, details: errorText || details, + hideCurrentDisplay: true, }; } @@ -1121,6 +1178,7 @@ function formatIpProxyRuntimeStatus(state = latestState) { ? `已启用,等待生效:${endpointWithRegion}${authSuffix}` : '已启用,等待拉取并应用代理。', details, + hideCurrentDisplay: false, }; } @@ -1137,6 +1195,10 @@ function setIpProxyRuntimeStatusDisplay(status = {}) { if (ipProxyRuntimeDot) { ipProxyRuntimeDot.title = text; } + const runtimeMeta = ipProxyCurrent?.closest?.('.ip-proxy-runtime-meta') || null; + if (runtimeMeta) { + runtimeMeta.style.display = status?.hideCurrentDisplay ? 'none' : ''; + } const detailsText = String(status?.details || '').trim(); if (ipProxyRuntimeDetails && ipProxyRuntimeDetailsText) { if (detailsText) { @@ -1241,6 +1303,12 @@ function updateIpProxyUI(state = latestState) { if (rowIpProxyPoolTargetCount) { rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none'; } + if (rowIpProxyAutoSyncEnabled) { + rowIpProxyAutoSyncEnabled.style.display = showSettings ? '' : 'none'; + } + if (rowIpProxyAutoSyncInterval) { + rowIpProxyAutoSyncInterval.style.display = showSettings ? '' : 'none'; + } if (rowIpProxyHost) { rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none'; } @@ -1334,6 +1402,16 @@ function updateIpProxyUI(state = latestState) { if (inputIpProxyAccountList) { inputIpProxyAccountList.disabled = !enabled || !isAccountMode || !accountListAvailable; } + if (inputIpProxyAutoSyncEnabled) { + inputIpProxyAutoSyncEnabled.disabled = !enabled; + } + if (inputIpProxyAutoSyncIntervalMinutes) { + const autoSyncEnabled = Boolean(inputIpProxyAutoSyncEnabled?.checked); + if (!Number.isFinite(Number.parseInt(String(inputIpProxyAutoSyncIntervalMinutes.value || '').trim(), 10))) { + inputIpProxyAutoSyncIntervalMinutes.value = '15'; + } + inputIpProxyAutoSyncIntervalMinutes.disabled = !enabled || !autoSyncEnabled; + } const runtimeStatus = formatIpProxyRuntimeStatus(runtimeState); setIpProxyRuntimeStatusDisplay(runtimeStatus); @@ -1395,6 +1473,7 @@ async function refreshIpProxyPoolByApi(options = {}) { source: 'sidepanel', payload: { mode, + skipExitProbe: true, }, }); if (response?.error) { @@ -1425,7 +1504,7 @@ async function refreshIpProxyPoolByApi(options = {}) { syncLatestState(patch); } updateIpProxyUI(latestState); - await probeIpProxyExit({ silent: true }).catch(() => {}); + scheduleIpProxyExitProbe({ silent: true }); if (!silent) { if (mode === 'account') { @@ -1447,6 +1526,7 @@ async function switchIpProxyToNext(options = {}) { direction: 'next', mode, forceRefresh: false, + skipExitProbe: true, }, }); if (response?.error) { @@ -1476,7 +1556,7 @@ async function switchIpProxyToNext(options = {}) { syncLatestState(patch); } updateIpProxyUI(latestState); - await probeIpProxyExit({ silent: true }).catch(() => {}); + scheduleIpProxyExitProbe({ silent: true }); if (!silent) { showToast(`已切换代理:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800); } @@ -1491,6 +1571,7 @@ async function changeIpProxyExitBySession(options = {}) { source: 'sidepanel', payload: { mode, + skipExitProbe: true, }, }); if (response?.error) { @@ -1520,7 +1601,7 @@ async function changeIpProxyExitBySession(options = {}) { syncLatestState(patch); } updateIpProxyUI(latestState); - await probeIpProxyExit({ silent: true }).catch(() => {}); + scheduleIpProxyExitProbe({ silent: true }); if (!silent) { showToast(`已执行 Change:${response?.display || formatIpProxyCurrentDisplay(latestState).text}`, 'success', 1800); } diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 0452938..4fdc30d 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -1161,6 +1161,59 @@ header { flex: 1; } +.country-order-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text-primary); + font-size: 11px; + line-height: 1.2; +} + +.country-order-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.country-order-chip-label { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.country-order-remove { + border: none; + background: transparent; + color: var(--text-muted); + width: 16px; + height: 16px; + border-radius: 999px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.country-order-remove:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.country-order-separator { + color: var(--text-muted); + font-size: 11px; +} + .data-value.has-value { color: var(--text-primary); } .mono { @@ -2114,6 +2167,14 @@ header { min-width: 0; } +.hero-sms-runtime-select { + flex: 1 1 auto; + min-width: 0; + width: 100%; + height: 33px; + min-height: 33px; +} + .hero-sms-price-preview-stack { width: 100%; display: flex; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 87eca16..8b0448b 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -420,331 +420,6 @@ -
-
-
- - 手机号验证与 HeroSMS 获取策略 -
-
- - -
-
- -
-
-
-
- - 用于浏览器代理接管与出口切换 -
-
- - -
-
- -