diff --git a/README.md b/README.md index 122562e..7cf5f51 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ 一百五十个号,一个401: -进官网,获取最新交流群: - - + @@ -74,7 +75,6 @@ - 至少准备一种验证码接收方式: - DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发 - Cloudflare 自定义域邮箱前缀 + QQ / 163 / Inbucket 转发 - - iCloud Hide My Email,可选择直接从 iCloud 收件箱收码,或转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码 - 手动填写一个可收信邮箱 - 如果使用 `QQ` / `163` / `163 VIP` / `126` / `Inbucket`,对应页面需要提前能正常打开 @@ -592,7 +592,6 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 支持: - `Hotmail`(远程服务 / 本地助手) -- `iCloud` 收件箱,或 iCloud Hide My Email 转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码 - `content/qq-mail.js` - `content/mail-163.js`(163 / 163 VIP / 126) - `content/inbucket-mail.js` @@ -774,7 +773,7 @@ content/utils.js 通用工具:等待元素、点击、日志、停 content/vps-panel.js CPA 面板步骤:内部 OAuth 刷新 / Step 10 content/signup-page.js ChatGPT 官网 + OpenAI 注册/登录页步骤:Step 1 / 2 / 3 / 5 / 7 / 9 hotmail-utils.js Hotmail 收信相关通用辅助 -mail-provider-utils.js 网页邮箱 provider 与 iCloud 转发收码配置辅助 +mail-provider-utils.js 网页邮箱 provider 配置辅助 content/duck-mail.js Duck 邮箱自动获取 content/qq-mail.js QQ 邮箱验证码轮询 content/mail-163.js 163 / 163 VIP / 126 邮箱验证码轮询 diff --git a/background.js b/background.js index b7624a0..52014b9 100644 --- a/background.js +++ b/background.js @@ -3,10 +3,12 @@ importScripts( 'managed-alias-utils.js', 'mail2925-utils.js', + 'paypal-utils.js', 'background/phone-verification-flow.js', 'background/account-run-history.js', 'background/contribution-oauth.js', 'background/mail-2925-session.js', + 'background/paypal-account-store.js', 'background/ip-proxy-provider-711proxy.js', 'background/ip-proxy-core.js', 'background/panel-bridge.js', @@ -263,6 +265,21 @@ const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600; const VERIFICATION_RESEND_COUNT_MIN = 0; const VERIFICATION_RESEND_COUNT_MAX = 20; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; +const PHONE_REPLACEMENT_LIMIT_MIN = 1; +const PHONE_REPLACEMENT_LIMIT_MAX = 20; +const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; +const PHONE_CODE_WAIT_SECONDS_MIN = 15; +const PHONE_CODE_WAIT_SECONDS_MAX = 300; +const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60; +const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1; +const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10; +const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2; +const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1; +const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30; +const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5; +const PHONE_CODE_POLL_ROUNDS_MIN = 1; +const PHONE_CODE_POLL_ROUNDS_MAX = 120; +const DEFAULT_PHONE_CODE_POLL_ROUNDS = 4; const LEGACY_AUTO_STEP_DELAY_KEYS = ['autoStepRandomDelayMinSeconds', 'autoStepRandomDelayMaxSeconds']; const LEGACY_VERIFICATION_RESEND_COUNT_KEYS = ['signupVerificationResendCount', 'loginVerificationResendCount']; const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; @@ -281,6 +298,10 @@ 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 DEFAULT_HERO_SMS_REUSE_ENABLED = true; +const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; +const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; +const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = [ @@ -436,6 +457,7 @@ const PERSISTED_SETTING_DEFAULTS = { plusModeEnabled: false, paypalEmail: '', paypalPassword: '', + currentPayPalAccountId: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, autoRunDelayEnabled: false, @@ -443,6 +465,11 @@ const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, phoneVerificationEnabled: false, verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT, + phoneVerificationReplacementLimit: DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT, + phoneCodeWaitSeconds: DEFAULT_PHONE_CODE_WAIT_SECONDS, + phoneCodeTimeoutWindows: DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS, + phoneCodePollIntervalSeconds: DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS, + phoneCodePollMaxRounds: DEFAULT_PHONE_CODE_POLL_ROUNDS, mailProvider: '163', mail2925Mode: DEFAULT_MAIL_2925_MODE, mail2925UseAccountPool: false, @@ -480,9 +507,14 @@ const PERSISTED_SETTING_DEFAULTS = { cloudflareTempEmailDomains: [], hotmailAccounts: [], mail2925Accounts: [], + paypalAccounts: [], heroSmsApiKey: '', + heroSmsReuseEnabled: DEFAULT_HERO_SMS_REUSE_ENABLED, + heroSmsAcquirePriority: DEFAULT_HERO_SMS_ACQUIRE_PRIORITY, + heroSmsMaxPrice: '', heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, + heroSmsCountryFallback: [], }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -523,6 +555,8 @@ const DEFAULT_STATE = { lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 localhostUrl: null, // 运行时捕获到的 localhost 回调地址,不要手动预填。 + cpaOAuthState: null, // CPA OAuth state。 + cpaManagementOrigin: null, // CPA 管理接口 origin。 sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。 sub2apiOAuthState: null, // SUB2API OpenAI Auth state。 sub2apiGroupId: null, // SUB2API 目标分组 ID。 @@ -562,7 +596,14 @@ const DEFAULT_STATE = { currentLuckmailPurchase: null, currentLuckmailMailCursor: null, currentPhoneActivation: null, + currentPhoneVerificationCode: '', reusablePhoneActivation: null, + heroSmsLastPriceTiers: [], + heroSmsLastPriceCountryId: 0, + heroSmsLastPriceCountryLabel: '', + heroSmsLastPriceUserLimit: '', + heroSmsLastPriceAt: 0, + pendingPhoneActivationConfirmation: null, autoRunning: false, // 当前是否处于自动运行中。 autoRunPhase: 'idle', // 当前自动运行阶段。 autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 @@ -579,6 +620,7 @@ const DEFAULT_STATE = { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + currentPayPalAccountId: null, currentHotmailAccountId: null, currentMail2925AccountId: null, preferredIcloudHost: '', @@ -661,6 +703,143 @@ function normalizeVerificationResendCount(value, fallback) { ); } +function normalizePhoneVerificationReplacementLimit(value, fallback = DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_REPLACEMENT_LIMIT_MAX, + Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT)) + ); + } + return Math.min( + PHONE_REPLACEMENT_LIMIT_MAX, + Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodeWaitSeconds(value, fallback = DEFAULT_PHONE_CODE_WAIT_SECONDS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_WAIT_SECONDS_MAX, + Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_WAIT_SECONDS)) + ); + } + return Math.min( + PHONE_CODE_WAIT_SECONDS_MAX, + Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodeTimeoutWindows(value, fallback = DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_TIMEOUT_WINDOWS_MAX, + Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS)) + ); + } + return Math.min( + PHONE_CODE_TIMEOUT_WINDOWS_MAX, + Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodePollIntervalSeconds(value, fallback = DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, + Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS)) + ); + } + return Math.min( + PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, + Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodePollMaxRounds(value, fallback = DEFAULT_PHONE_CODE_POLL_ROUNDS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_POLL_ROUNDS_MAX, + Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_POLL_ROUNDS)) + ); + } + return Math.min( + PHONE_CODE_POLL_ROUNDS_MAX, + Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.floor(numeric)) + ); +} + +function normalizeHeroSmsMaxPrice(value = '') { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return ''; + } + const numeric = Number(rawValue); + if (!Number.isFinite(numeric) || numeric <= 0) { + return ''; + } + return String(Math.round(numeric * 10000) / 10000); +} + +function normalizeHeroSmsAcquirePriority(value = '') { + return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE + ? HERO_SMS_ACQUIRE_PRIORITY_PRICE + : HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; +} + +function normalizeHeroSmsCountryFallback(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seenIds = new Set(); + const normalized = []; + + for (const entry of source) { + let countryId = 0; + let countryLabel = ''; + + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + countryId = Math.floor(Number(entry.countryId ?? entry.id) || 0); + countryLabel = String((entry.countryLabel ?? entry.label) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structuredMatch = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/); + if (structuredMatch) { + countryId = Math.floor(Number(structuredMatch[1]) || 0); + countryLabel = String(structuredMatch[2] || '').trim(); + } else { + countryId = Math.floor(Number(text) || 0); + } + } + + if (!Number.isFinite(countryId) || countryId <= 0 || seenIds.has(countryId)) { + continue; + } + seenIds.add(countryId); + normalized.push({ + id: countryId, + label: countryLabel || `Country #${countryId}`, + }); + if (normalized.length >= 20) { + break; + } + } + + return normalized; +} + function resolveLegacyAutoStepDelaySeconds(input = {}) { const hasLegacyMin = input.autoStepRandomDelayMinSeconds !== undefined; const hasLegacyMax = input.autoStepRandomDelayMaxSeconds !== undefined; @@ -1246,6 +1425,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'paypalPassword': return String(value || ''); + case 'currentPayPalAccountId': + return String(value || '').trim(); case 'autoRunSkipFailures': case 'autoRunDelayEnabled': case 'phoneVerificationEnabled': @@ -1259,6 +1440,16 @@ function normalizePersistentSettingValue(key, value) { return normalizeAutoStepDelaySeconds(value, PERSISTED_SETTING_DEFAULTS.autoStepDelaySeconds); case 'verificationResendCount': return normalizeVerificationResendCount(value, DEFAULT_VERIFICATION_RESEND_COUNT); + case 'phoneVerificationReplacementLimit': + return normalizePhoneVerificationReplacementLimit(value, DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT); + case 'phoneCodeWaitSeconds': + return normalizePhoneCodeWaitSeconds(value, DEFAULT_PHONE_CODE_WAIT_SECONDS); + case 'phoneCodeTimeoutWindows': + return normalizePhoneCodeTimeoutWindows(value, DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS); + case 'phoneCodePollIntervalSeconds': + return normalizePhoneCodePollIntervalSeconds(value, DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS); + case 'phoneCodePollMaxRounds': + return normalizePhoneCodePollMaxRounds(value, DEFAULT_PHONE_CODE_POLL_ROUNDS); case 'mailProvider': return normalizeMailProvider(value); case 'mail2925Mode': @@ -1326,12 +1517,22 @@ function normalizePersistentSettingValue(key, value) { return normalizeHotmailAccounts(value); case 'mail2925Accounts': return normalizeMail2925Accounts(value); + case 'paypalAccounts': + return normalizePayPalAccounts(value); case 'heroSmsApiKey': return String(value || ''); + case 'heroSmsReuseEnabled': + return Boolean(value); + case 'heroSmsAcquirePriority': + return normalizeHeroSmsAcquirePriority(value); + case 'heroSmsMaxPrice': + return normalizeHeroSmsMaxPrice(value); case 'heroSmsCountryId': return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID)); case 'heroSmsCountryLabel': return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL; + case 'heroSmsCountryFallback': + return normalizeHeroSmsCountryFallback(value); default: return value; } @@ -1877,6 +2078,7 @@ async function resetState() { 'accounts', 'tabRegistry', 'sourceLastUrls', + 'reusablePhoneActivation', 'luckmailApiKey', 'luckmailBaseUrl', 'luckmailEmailType', @@ -1891,6 +2093,25 @@ async function resetState() { getPersistedAliasState(), ]); const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), persistedSettings, prev); + const reusablePhoneActivation = ( + prev.reusablePhoneActivation + && typeof prev.reusablePhoneActivation === 'object' + && !Array.isArray(prev.reusablePhoneActivation) + && String( + prev.reusablePhoneActivation.activationId + ?? prev.reusablePhoneActivation.id + ?? prev.reusablePhoneActivation.activation + ?? '' + ).trim() + && String( + prev.reusablePhoneActivation.phoneNumber + ?? prev.reusablePhoneActivation.number + ?? prev.reusablePhoneActivation.phone + ?? '' + ).trim() + ) + ? prev.reusablePhoneActivation + : null; await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, @@ -1911,6 +2132,8 @@ async function resetState() { luckmailPreserveTagName: String(prev.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, currentLuckmailPurchase: null, currentLuckmailMailCursor: null, + // Keep reusable phone activation across round resets so the same number can be reactivated up to maxUses. + reusablePhoneActivation, preferredIcloudHost: prev.preferredIcloudHost || '', }); } @@ -1941,6 +2164,50 @@ function generatePassword() { return pw.split('').sort(() => Math.random() - 0.5).join(''); } +function normalizePayPalAccount(account = {}) { + if (self.PayPalUtils?.normalizePayPalAccount) { + return self.PayPalUtils.normalizePayPalAccount(account); + } + return { + id: String(account.id || crypto.randomUUID()), + email: String(account.email || '').trim().toLowerCase(), + password: String(account.password || ''), + createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : Date.now(), + updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : Date.now(), + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + }; +} + +function normalizePayPalAccounts(accounts) { + if (self.PayPalUtils?.normalizePayPalAccounts) { + return self.PayPalUtils.normalizePayPalAccounts(accounts); + } + return Array.isArray(accounts) ? accounts.map((account) => normalizePayPalAccount(account)) : []; +} + +function findPayPalAccount(accounts, accountId) { + if (self.PayPalUtils?.findPayPalAccount) { + return self.PayPalUtils.findPayPalAccount(accounts, accountId); + } + const normalizedId = String(accountId || '').trim(); + if (!normalizedId) return null; + return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null; +} + +function upsertPayPalAccountInList(accounts, nextAccount) { + if (self.PayPalUtils?.upsertPayPalAccountInList) { + return self.PayPalUtils.upsertPayPalAccountInList(accounts, nextAccount); + } + const normalizedNext = normalizePayPalAccount(nextAccount); + const list = normalizePayPalAccounts(accounts); + const existingIndex = list.findIndex((account) => account.id === normalizedNext.id); + if (existingIndex >= 0) { + list[existingIndex] = normalizedNext; + return list; + } + return [...list, normalizedNext]; +} + function normalizeHotmailAccount(account = {}) { const normalizedLastAuthAt = Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0; const normalizedStatus = String( @@ -5251,6 +5518,13 @@ async function finalizeIcloudAliasAfterSuccessfulFlow(state) { } } +async function finalizePhoneActivationAfterSuccessfulFlow(state) { + if (typeof phoneVerificationHelpers?.finalizePendingPhoneActivationConfirmation !== 'function') { + return null; + } + return phoneVerificationHelpers.finalizePendingPhoneActivationConfirmation(state); +} + // ============================================================ // Tab Registry // ============================================================ @@ -5973,6 +6247,8 @@ function getDownstreamStateResets(step, state = {}) { return { ...plusRuntimeResets, oauthUrl: null, + cpaOAuthState: null, + cpaManagementOrigin: null, sub2apiSessionId: null, sub2apiOAuthState: null, sub2apiGroupId: null, @@ -5986,9 +6262,11 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 2) { @@ -6000,9 +6278,11 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 3 || step === 4) { @@ -6013,9 +6293,11 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 5 || step === 6 || step === 7 || step === 8) { @@ -6035,13 +6317,17 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 9) { return { + pendingPhoneActivationConfirmation: null, plusReturnUrl: '', localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (stepKey === 'oauth-login' || stepKey === 'fetch-login-code') { @@ -6050,11 +6336,14 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (stepKey === 'confirm-oauth') { return { + pendingPhoneActivationConfirmation: null, localhostUrl: null, }; } @@ -6811,6 +7100,8 @@ async function handleStepData(step, payload) { if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; + if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || null; if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; if (Object.keys(updates).length) { @@ -6905,6 +7196,7 @@ async function handleStepData(step, payload) { if ((shouldUseCustomRegistrationEmail(latestState) || shouldClearCustomPoolEmail) && latestState.email) { await setEmailStateSilently(null); } + await finalizePhoneActivationAfterSuccessfulFlow(latestState); break; } } @@ -7556,6 +7848,39 @@ function isMail2925PoolExhaustedPauseError(error) { return /^MAIL2925_POOL_EXHAUSTED_PAUSE::/.test(message); } +const payPalAccountStore = self.MultiPageBackgroundPayPalAccountStore?.createPayPalAccountStore({ + broadcastDataUpdate, + findPayPalAccount, + getState, + normalizePayPalAccount, + normalizePayPalAccounts, + setPersistentSettings, + setState, + upsertPayPalAccountInList, +}); + +async function syncPayPalAccounts(accounts) { + return payPalAccountStore?.syncPayPalAccounts?.(accounts) || []; +} + +async function upsertPayPalAccount(input = {}) { + if (!payPalAccountStore?.upsertPayPalAccount) { + throw new Error('PayPal 账号存储能力尚未接入。'); + } + return payPalAccountStore.upsertPayPalAccount(input); +} + +async function setCurrentPayPalAccount(accountId) { + if (!payPalAccountStore?.setCurrentPayPalAccount) { + throw new Error('PayPal 账号选择能力尚未接入。'); + } + return payPalAccountStore.setCurrentPayPalAccount(accountId); +} + +function getCurrentPayPalAccount(state = null) { + return payPalAccountStore?.getCurrentPayPalAccount?.(state || {}) || null; +} + const generatedEmailHelpers = self.MultiPageGeneratedEmailHelpers?.createGeneratedEmailHelpers({ addLog, buildGeneratedAliasEmail, @@ -8505,6 +8830,11 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.createPhoneVerificationHelpers({ addLog, DEFAULT_HERO_SMS_BASE_URL, + DEFAULT_HERO_SMS_REUSE_ENABLED, + DEFAULT_PHONE_CODE_WAIT_SECONDS, + DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS, + DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS, + DEFAULT_PHONE_CODE_POLL_ROUNDS, ensureStep8SignupPageReady, getOAuthFlowStepTimeoutMs, getState, @@ -8741,6 +9071,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter deleteHotmailAccounts, deleteIcloudAlias, deleteUsedIcloudAliases, + findPayPalAccount, disableUsedLuckmailPurchases, doesStepUseCompletionSignal, ensureMail2925MailboxSession, @@ -8749,6 +9080,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter executeStepViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, + finalizePhoneActivationAfterSuccessfulFlow, finalizeStep3Completion: async () => { const currentState = await getState(); const signupTabId = await getTabId('signup-page'); @@ -8785,9 +9117,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter listIcloudAliases, listLuckmailPurchasesForManagement, refreshIpProxyPool, + getCurrentPayPalAccount, getCurrentMail2925Account, normalizeHotmailAccounts, normalizeMail2925Accounts, + normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, notifyStepComplete, @@ -8803,6 +9137,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter selectLuckmailPurchase, switchIpProxy, changeIpProxyExit, + setCurrentPayPalAccount, setCurrentHotmailAccount, setCurrentMail2925Account, setContributionMode, @@ -8822,9 +9157,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter startAutoRunLoop, pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, + syncPayPalAccounts, deleteMail2925Account, deleteMail2925Accounts, testHotmailAccountMailAccess, + upsertPayPalAccount, upsertMail2925Account, upsertHotmailAccount, verifyHotmailAccount, @@ -9348,6 +9685,10 @@ async function getPostStep6AutoRestartDecision(step, error) { const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage); return mentionsTokenExchange && hasTransientNetworkSignal; }; + const isPhoneVerificationLocalFailure = (errorMessage = '') => { + const normalizedMessage = String(errorMessage || ''); + return /HeroSMS|phone verification did not succeed|number replacements|sms_timeout_after_resend|phone number is already linked|add-phone keeps rejecting current number|接码|手机号|手机验证码|步骤\s*9.*(?:手机号|验证码)|Step\s*9.*phone verification/i.test(normalizedMessage); + }; const normalizedStep = Number(step); const errorMessage = getErrorMessage(error); @@ -9378,6 +9719,17 @@ async function getPostStep6AutoRestartDecision(step, error) { }; } + if (isPhoneVerificationLocalFailure(errorMessage)) { + return { + shouldRestart: false, + blockedByAddPhone: true, + forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, + errorMessage, + authState: null, + }; + } + if (shouldForceRestartFromStep7) { return { shouldRestart: true, @@ -9980,6 +10332,116 @@ function getStep8EffectLabel(effect) { } } +function isStep9OAuthLocalhostTimeoutError(error, visibleStep = 9) { + const message = getErrorMessage(error); + if (!message) { + return false; + } + if (!/从拿到 OAuth 登录地址开始/.test(message)) { + return false; + } + if (!/localhost 回调|OAuth localhost 回调/i.test(message)) { + return false; + } + const normalizedStep = Number(visibleStep); + if (Number.isFinite(normalizedStep) && normalizedStep > 0) { + const stepPrefix = new RegExp(`步骤\\s*${normalizedStep}\\s*:`); + if (!stepPrefix.test(message)) { + return false; + } + } + return true; +} + +async function recoverOAuthLocalhostTimeout(details = {}) { + const { + error, + state, + visibleStep = 9, + } = details; + + if (!isStep9OAuthLocalhostTimeoutError(error, visibleStep)) { + return null; + } + + const authLoginStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(state || {}) + : FINAL_OAUTH_CHAIN_START_STEP; + const loginCodeStep = Number(visibleStep) >= 12 ? 11 : 8; + + await addLog( + `步骤 ${visibleStep}:检测到 OAuth localhost 回调等待窗口已过期,正在复核认证页并回到步骤 ${authLoginStep} 重拉授权链路。`, + 'warn' + ); + + let authState = null; + try { + authState = await getLoginAuthStateFromContent({ + timeoutMs: 10000, + responseTimeoutMs: 10000, + logMessage: `步骤 ${visibleStep}:正在复核认证页状态,确认是否可自动恢复 localhost 回调链路...`, + }); + } catch (inspectError) { + await addLog( + `步骤 ${visibleStep}:复核认证页状态失败(${getErrorMessage(inspectError)}),将先尝试按步骤 ${loginCodeStep} 收尾恢复。`, + 'warn' + ); + } + + if (isAddPhoneAuthState(authState)) { + const stateLabel = getLoginAuthStateLabel(authState.state); + await addLog( + `步骤 ${visibleStep}:当前认证页为 ${stateLabel},将直接回到步骤 ${authLoginStep} 重新拉起授权链路,避免步骤 8/9 恢复冲突。`, + 'warn' + ); + } else if (authState && authState.state && !['verification_page', 'oauth_consent_page'].includes(authState.state)) { + const stateLabel = getLoginAuthStateLabel(authState.state); + await addLog( + `步骤 ${visibleStep}:当前认证页为 ${stateLabel},不满足快速恢复条件,将回到步骤 ${authLoginStep} 重开授权链路。`, + 'warn' + ); + } + + const latestState = await getState(); + if (!step7Executor?.executeStep7 || !step8Executor?.executeStep8) { + return null; + } + + await addLog( + `步骤 ${visibleStep}:正在自动重开步骤 ${authLoginStep} -> ${loginCodeStep},恢复到可继续捕获 localhost 回调的状态。`, + 'warn' + ); + await step7Executor.executeStep7({ + ...latestState, + visibleStep: authLoginStep, + }); + + const stateAfterStep7 = await getState(); + await step8Executor.executeStep8({ + ...stateAfterStep7, + visibleStep: loginCodeStep, + }); + + const recoveredState = await getState(); + const oauthUrl = String(recoveredState?.oauthUrl || state?.oauthUrl || '').trim(); + if (oauthUrl && typeof startOAuthFlowTimeoutWindow === 'function') { + await startOAuthFlowTimeoutWindow({ + step: Number(visibleStep) || 9, + oauthUrl, + }); + } + + await setState({ + localhostUrl: null, + }); + + await addLog( + `步骤 ${visibleStep}:已恢复到步骤 ${authLoginStep} -> ${loginCodeStep} 收尾状态,并刷新 OAuth localhost 回调等待窗口,准备重试当前步骤。`, + 'warn' + ); + return await getState(); +} + const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ addLog, chrome, @@ -9997,6 +10459,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ getStep8TabUpdatedListener, isTabAlive, prepareStep8DebuggerClick, + recoverOAuthLocalhostTimeout, reloadStep8ConsentPage, reuseOrCreateTab, setStep8PendingReject, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index e1a6d92..25270f2 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -392,6 +392,7 @@ inbucketMailbox: prevState.inbucketMailbox, cloudflareDomain: prevState.cloudflareDomain, cloudflareDomains: prevState.cloudflareDomains, + reusablePhoneActivation: prevState.reusablePhoneActivation, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), autoRunSessionId: sessionId, tabRegistry: {}, diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 81a1131..3928dda 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -3,19 +3,15 @@ })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() { const API_BASE_URL = 'https://apikey.qzz.io/oauth/api'; const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); - const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'expired', 'error']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); const CALLBACK_FINAL_STATUSES = new Set(['submitted']); const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']); - const CONTRIBUTION_SOURCE_CPA = 'cpa'; - const CONTRIBUTION_SOURCE_SUB2API = 'sub2api'; - const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池'; - const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus'; const RUNTIME_DEFAULTS = { contributionMode: false, contributionModeExpected: false, - contributionSource: CONTRIBUTION_SOURCE_SUB2API, - contributionTargetGroupName: CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME, + contributionSource: 'sub2api', + contributionTargetGroupName: 'codex号池', contributionNickname: '', contributionQq: '', contributionSessionId: '', @@ -44,7 +40,8 @@ } = deps; let listenersBound = false; - const inFlightCapturedCallbackTasks = new Map(); + const pendingCallbackSubmissions = new Map(); + const pendingCapturedCallbacks = new Map(); function normalizeString(value = '') { return String(value || '').trim(); @@ -71,6 +68,9 @@ case 'auto_rejected': case 'rejected': return 'auto_rejected'; + case 'manual_review_required': + case 'manual_review': + return 'manual_review_required'; case 'expired': case 'timeout': return 'expired'; @@ -111,63 +111,6 @@ return FINAL_STATUSES.has(normalizeContributionStatus(status)); } - function runCapturedCallbackOnce(callbackUrl, executor) { - const normalizedUrl = normalizeString(callbackUrl); - if (!normalizedUrl) { - return Promise.resolve().then(executor); - } - - const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl); - if (existingTask) { - return existingTask; - } - - let task = null; - task = Promise.resolve() - .then(executor) - .finally(() => { - if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) { - inFlightCapturedCallbackTasks.delete(normalizedUrl); - } - }); - inFlightCapturedCallbackTasks.set(normalizedUrl, task); - return task; - } - - function normalizeContributionSource(value = '') { - const normalized = normalizeString(value).toLowerCase(); - return normalized === CONTRIBUTION_SOURCE_SUB2API - ? CONTRIBUTION_SOURCE_SUB2API - : CONTRIBUTION_SOURCE_CPA; - } - - function resolveContributionRouting(state = {}) { - const currentStatus = normalizeContributionStatus(state.contributionStatus); - const currentSource = normalizeContributionSource(state.contributionSource); - const hasActiveSession = Boolean( - normalizeString(state.contributionSessionId) - && currentStatus - && !FINAL_STATUSES.has(currentStatus) - ); - - if (hasActiveSession) { - return { - source: currentSource, - targetGroupName: currentSource === CONTRIBUTION_SOURCE_SUB2API - ? (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME) - : '', - }; - } - - const source = CONTRIBUTION_SOURCE_SUB2API; - return { - source, - targetGroupName: Boolean(state.plusModeEnabled) - ? CONTRIBUTION_SUB2API_PLUS_GROUP_NAME - : (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME), - }; - } - function getStatusLabel(status = '') { switch (normalizeContributionStatus(status)) { case 'started': @@ -175,11 +118,13 @@ case 'waiting': return '等待提交回调'; case 'processing': - return '已提交回调,等待服务端确认'; + return '已提交回调,等待 CPA 确认'; case 'auto_approved': - return '贡献成功,服务端已确认'; + return '贡献成功,CPA 已确认'; case 'auto_rejected': return '贡献未通过确认'; + case 'manual_review_required': + return '已提交,等待人工处理'; case 'expired': return '贡献会话已超时'; case 'error': @@ -315,6 +260,41 @@ return qq; } + function isPlusModeState(state = {}) { + return Boolean(state?.plusModeEnabled); + } + + function normalizeContributionModeSource(value = '') { + const normalized = normalizeString(value).toLowerCase(); + return normalized === 'sub2api' ? 'sub2api' : 'cpa'; + } + + function resolveContributionModeRoutingState(state = {}) { + const currentStatus = normalizeString(state?.contributionStatus).toLowerCase(); + const currentSource = normalizeContributionModeSource(state?.contributionSource); + const hasActiveSession = Boolean( + normalizeString(state?.contributionSessionId) + && currentStatus + && !FINAL_STATUSES.has(currentStatus) + ); + + if (hasActiveSession) { + return { + source: currentSource, + targetGroupName: currentSource === 'sub2api' + ? (normalizeString(state?.contributionTargetGroupName) || 'codex号池') + : '', + }; + } + + return { + source: 'sub2api', + targetGroupName: isPlusModeState(state) + ? 'openai-plus' + : (normalizeString(state?.contributionTargetGroupName) || 'codex号池'), + }; + } + function buildStatusMessage(status, payload = {}) { const label = getStatusLabel(status); const details = [ @@ -494,68 +474,87 @@ return currentState; } - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: 'submitting', - contributionCallbackMessage: buildCallbackMessage('submitting'), - }); - - try { - const payload = await fetchContributionJson('/submit-callback', { - method: 'POST', - body: { - session_id: sessionId, - callback_url: normalizedUrl, - }, - }); - - const nextStatus = 'submitted'; - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: nextStatus, - contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), - }); - - if (typeof closeLocalhostCallbackTabs === 'function') { - await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); - } - - return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); - } catch (error) { - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: 'failed', - contributionCallbackMessage: `回调提交失败:${error.message}`, - }); - - if (typeof addLog === 'function') { - await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); - } - - throw error; + const dedupeKey = `${sessionId}::${normalizedUrl}`; + if (pendingCallbackSubmissions.has(dedupeKey)) { + return pendingCallbackSubmissions.get(dedupeKey); } + + const task = (async () => { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'submitting', + contributionCallbackMessage: buildCallbackMessage('submitting'), + }); + + try { + const payload = await fetchContributionJson('/submit-callback', { + method: 'POST', + body: { + session_id: sessionId, + callback_url: normalizedUrl, + }, + }); + + const nextStatus = 'submitted'; + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: nextStatus, + contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); + } catch (error) { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'failed', + contributionCallbackMessage: `回调提交失败:${error.message}`, + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); + } + + throw error; + } finally { + pendingCallbackSubmissions.delete(dedupeKey); + } + })(); + + pendingCallbackSubmissions.set(dedupeKey, task); + return task; } async function handleCapturedCallback(rawUrl, metadata = {}) { + const currentState = await getState(); + if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { + return currentState; + } + if (!isContributionCallbackUrl(rawUrl, currentState)) { + return currentState; + } + const normalizedUrl = normalizeString(rawUrl); - return runCapturedCallbackOnce(normalizedUrl, async () => { - const currentState = await getState(); - if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { - return currentState; - } - if (!isContributionCallbackUrl(normalizedUrl, currentState)) { - return currentState; - } - - const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); - if ( - normalizedUrl - && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl - && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') - ) { - return currentState; - } + const callbackDedupeKey = `${normalizeString(currentState.contributionSessionId)}::${normalizedUrl}`; + if (pendingCapturedCallbacks.has(callbackDedupeKey)) { + return pendingCapturedCallbacks.get(callbackDedupeKey); + } + if (pendingCallbackSubmissions.has(callbackDedupeKey)) { + return pendingCallbackSubmissions.get(callbackDedupeKey); + } + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if ( + normalizedUrl + && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl + && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') + ) { + return currentState; + } + const task = (async () => { await applyRuntimeUpdates({ contributionCallbackUrl: normalizedUrl, contributionCallbackStatus: 'captured', @@ -573,8 +572,13 @@ }); } catch { return getState(); + } finally { + pendingCapturedCallbacks.delete(callbackDedupeKey); } - }); + })(); + + pendingCapturedCallbacks.set(callbackDedupeKey, task); + return task; } async function pollContributionStatus(options = {}) { @@ -597,16 +601,6 @@ const callbackState = deriveCallbackState(mergedPayload, currentState); const updates = { contributionLastPollAt: Date.now(), - contributionSource: normalizeContributionSource( - mergedPayload.source - || mergedPayload.source_kind - || currentState.contributionSource - ), - contributionTargetGroupName: normalizeString( - mergedPayload.target_group_name - || mergedPayload.group_name - || currentState.contributionTargetGroupName - ), contributionStatus: normalizedStatus, contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload), contributionCallbackUrl: callbackState.callbackUrl, @@ -648,7 +642,6 @@ async function startContributionFlow(options = {}) { const currentState = options.stateOverride || await getState(); const shouldOpenAuthTab = options.openAuthTab !== false; - const routing = resolveContributionRouting(currentState); if (!currentState.contributionMode) { throw new Error('请先进入贡献模式。'); } @@ -666,14 +659,15 @@ return pollContributionStatus({ reason: 'resume_existing' }); } + const routingState = resolveContributionModeRoutingState(currentState); const payload = await fetchContributionJson('/start', { method: 'POST', body: { nickname: buildNickname(currentState, options.nickname), qq: buildContributionQq(currentState, options.qq), email: normalizeString(currentState.email), - source: routing.source, - target_group_name: routing.targetGroupName, + source: routingState.source, + target_group_name: routingState.targetGroupName, channel: 'codex-extension', }, }); @@ -686,12 +680,6 @@ } await applyRuntimeUpdates({ - contributionSource: normalizeContributionSource(payload.source || routing.source), - contributionTargetGroupName: normalizeString( - payload.target_group_name - || payload.group_name - || routing.targetGroupName - ), contributionSessionId: sessionId, contributionAuthUrl: authUrl, contributionAuthState: authState, @@ -722,7 +710,7 @@ } function onTabUpdated(tabId, changeInfo, tab) { - const candidateUrl = normalizeString(changeInfo?.url); + const candidateUrl = normalizeString(changeInfo?.url || tab?.url); if (!candidateUrl) { return; } diff --git a/background/message-router.js b/background/message-router.js index 2c5886e..7ce7a6e 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -32,11 +32,14 @@ executeStepViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, + finalizePhoneActivationAfterSuccessfulFlow, finalizeStep3Completion, finalizeIcloudAliasAfterSuccessfulFlow, findHotmailAccount, + findPayPalAccount, flushCommand, getCurrentLuckmailPurchase, + getCurrentPayPalAccount, getCurrentMail2925Account, getPendingAutoRunTimerPlan, getSourceLabel, @@ -62,6 +65,7 @@ refreshIpProxyPool, normalizeHotmailAccounts, normalizeMail2925Accounts, + normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, notifyStepComplete, @@ -79,6 +83,7 @@ selectLuckmailPurchase, switchIpProxy, changeIpProxyExit, + setCurrentPayPalAccount, setCurrentMail2925Account, setCurrentHotmailAccount, setContributionMode, @@ -99,7 +104,9 @@ deleteMail2925Account, deleteMail2925Accounts, syncHotmailAccounts, + syncPayPalAccounts, testHotmailAccountMailAccess, + upsertPayPalAccount, upsertMail2925Account, upsertHotmailAccount, verifyHotmailAccount, @@ -203,9 +210,33 @@ }); } await finalizeIcloudAliasAfterSuccessfulFlow(latestState); + if (typeof finalizePhoneActivationAfterSuccessfulFlow === 'function') { + await finalizePhoneActivationAfterSuccessfulFlow(latestState); + } } async function handleStepData(step, payload) { + if (step === 1) { + const updates = {}; + if (payload.oauthUrl) { + updates.oauthUrl = payload.oauthUrl; + broadcastDataUpdate({ oauthUrl: payload.oauthUrl }); + } + if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; + if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; + if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; + if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; + if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || null; + if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; + if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; + if (Object.keys(updates).length) { + await setState(updates); + } + return; + } + const stateForStep = await getState(); const stepKey = getStepKeyForState(step, stateForStep); @@ -252,24 +283,6 @@ } switch (step) { - case 1: { - const updates = {}; - if (payload.oauthUrl) { - updates.oauthUrl = payload.oauthUrl; - broadcastDataUpdate({ oauthUrl: payload.oauthUrl }); - } - if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; - if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; - if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; - if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; - if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; - if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; - if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; - if (Object.keys(updates).length) { - await setState(updates); - } - break; - } case 2: if (payload.email) { await setEmailState(payload.email); @@ -779,6 +792,16 @@ return { ok: true, account }; } + case 'UPSERT_PAYPAL_ACCOUNT': { + const account = await upsertPayPalAccount(message.payload || {}); + return { ok: true, account }; + } + + case 'SELECT_PAYPAL_ACCOUNT': { + const account = await setCurrentPayPalAccount(String(message.payload?.accountId || '')); + return { ok: true, account }; + } + case 'DELETE_HOTMAIL_ACCOUNT': { await deleteHotmailAccount(String(message.payload?.accountId || '')); return { ok: true }; diff --git a/background/panel-bridge.js b/background/panel-bridge.js index a667960..2b0f077 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -43,6 +43,78 @@ return message || `Codex2API 请求失败(HTTP ${responseStatus})。`; } + function deriveCpaManagementOrigin(vpsUrl) { + const normalizedUrl = String(vpsUrl || '').trim(); + if (!normalizedUrl) { + throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。'); + } + let parsed; + try { + parsed = new URL(normalizedUrl); + } catch { + throw new Error('CPA 地址格式无效,请先在侧边栏检查。'); + } + return parsed.origin; + } + + function getCpaApiErrorMessage(payload, responseStatus = 500) { + const candidates = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ]; + const message = candidates + .map((value) => String(value || '').trim()) + .find(Boolean); + return message || `CPA 管理接口请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCpaManagementJson(origin, path, options = {}) { + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const managementKey = String(options.managementKey || '').trim(); + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (managementKey) { + headers.Authorization = `Bearer ${managementKey}`; + headers['X-Management-Key'] = managementKey; + } + + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCpaApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('CPA 管理接口请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function fetchCodex2ApiJson(origin, path, options = {}) { const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); const controller = new AbortController(); @@ -97,49 +169,48 @@ if (!state.vpsUrl) { throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。'); } - - await addLog(`${logLabel}:正在打开 CPA 面板...`); - - const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js']; - await closeConflictingTabsForSource('vps-panel', state.vpsUrl); - - const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true }); - const tabId = tab.id; - await rememberSourceLastUrl('vps-panel', state.vpsUrl); - - await addLog(`${logLabel}:CPA 面板已打开,正在等待页面进入目标地址...`); - const matchedTab = await waitForTabUrlFamily('vps-panel', tabId, state.vpsUrl, { - timeoutMs: 15000, - retryDelayMs: 400, - }); - if (!matchedTab) { - await addLog(`${logLabel}:CPA 页面尚未完全进入目标地址,继续尝试连接内容脚本...`, 'warn'); + const managementKey = String(state.vpsPassword || '').trim(); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); } - await ensureContentScriptReadyOnTab('vps-panel', tabId, { - inject: injectFiles, - timeoutMs: 45000, - retryDelayMs: 900, - logMessage: `${logLabel}:CPA 面板仍在加载,正在重试连接内容脚本...`, + const origin = deriveCpaManagementOrigin(state.vpsUrl); + + await addLog(`${logLabel}:正在通过 CPA 管理接口获取 OAuth 授权链接...`); + const result = await fetchCpaManagementJson(origin, '/v0/management/codex-auth-url', { + method: 'GET', + managementKey, }); - const result = await sendToContentScriptResilient('vps-panel', { - type: 'REQUEST_OAUTH_URL', - source: 'background', - payload: { - vpsPassword: state.vpsPassword, - logStep: 7, - }, - }, { - timeoutMs: 30000, - retryDelayMs: 700, - logMessage: `${logLabel}:CPA 面板通信未就绪,正在等待页面恢复...`, - }); + const oauthUrl = String( + result?.url + || result?.auth_url + || result?.authUrl + || result?.data?.url + || result?.data?.auth_url + || result?.data?.authUrl + || '' + ).trim(); + const oauthState = String( + result?.state + || result?.auth_state + || result?.authState + || result?.data?.state + || result?.data?.auth_state + || result?.data?.authState + || '' + ).trim() + || extractStateFromAuthUrl(oauthUrl); - if (result?.error) { - throw new Error(result.error); + if (!oauthUrl || !oauthUrl.startsWith('http')) { + throw new Error('CPA 管理接口未返回有效的 auth_url。'); } - return result || {}; + + return { + oauthUrl, + cpaOAuthState: oauthState || null, + cpaManagementOrigin: origin, + }; } async function requestCodex2ApiOAuthUrl(state, options = {}) { diff --git a/background/paypal-account-store.js b/background/paypal-account-store.js new file mode 100644 index 0000000..6d6c889 --- /dev/null +++ b/background/paypal-account-store.js @@ -0,0 +1,110 @@ +(function attachBackgroundPayPalAccountStore(root, factory) { + root.MultiPageBackgroundPayPalAccountStore = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalAccountStoreModule() { + function createPayPalAccountStore(deps = {}) { + const { + broadcastDataUpdate, + findPayPalAccount, + getState, + normalizePayPalAccount, + normalizePayPalAccounts, + setPersistentSettings, + setState, + upsertPayPalAccountInList, + } = deps; + + async function syncSelectedPayPalAccountState(account = null) { + const updates = account + ? { + currentPayPalAccountId: account.id, + paypalEmail: String(account.email || '').trim(), + paypalPassword: String(account.password || ''), + } + : { + currentPayPalAccountId: '', + paypalEmail: '', + paypalPassword: '', + }; + + await setPersistentSettings(updates); + await setState({ + currentPayPalAccountId: updates.currentPayPalAccountId || null, + paypalEmail: updates.paypalEmail, + paypalPassword: updates.paypalPassword, + }); + broadcastDataUpdate({ + currentPayPalAccountId: updates.currentPayPalAccountId || null, + paypalEmail: updates.paypalEmail, + paypalPassword: updates.paypalPassword, + }); + } + + async function syncPayPalAccounts(accounts) { + const normalized = normalizePayPalAccounts(accounts); + await setPersistentSettings({ paypalAccounts: normalized }); + await setState({ paypalAccounts: normalized }); + broadcastDataUpdate({ paypalAccounts: normalized }); + + const state = await getState(); + if (state.currentPayPalAccountId && !findPayPalAccount(normalized, state.currentPayPalAccountId)) { + await syncSelectedPayPalAccountState(null); + } + return normalized; + } + + function getCurrentPayPalAccount(state = {}) { + return findPayPalAccount(state.paypalAccounts, state.currentPayPalAccountId) || null; + } + + async function upsertPayPalAccount(input = {}) { + const state = await getState(); + const accounts = normalizePayPalAccounts(state.paypalAccounts); + const normalizedEmail = String(input?.email || '').trim().toLowerCase(); + const existing = input?.id + ? findPayPalAccount(accounts, input.id) + : accounts.find((account) => account.email === normalizedEmail) || null; + const normalized = normalizePayPalAccount({ + ...(existing || {}), + ...input, + id: input?.id || existing?.id || crypto.randomUUID(), + createdAt: existing?.createdAt || Date.now(), + updatedAt: Date.now(), + }); + + const nextAccounts = typeof upsertPayPalAccountInList === 'function' + ? upsertPayPalAccountInList(accounts, normalized) + : accounts.concat(normalized); + + await syncPayPalAccounts(nextAccounts); + + if (state.currentPayPalAccountId === normalized.id) { + await syncSelectedPayPalAccountState(normalized); + } + + return normalized; + } + + async function setCurrentPayPalAccount(accountId) { + const state = await getState(); + const accounts = normalizePayPalAccounts(state.paypalAccounts); + const account = findPayPalAccount(accounts, accountId); + if (!account) { + throw new Error('未找到对应的 PayPal 账号。'); + } + + await syncSelectedPayPalAccountState(account); + return account; + } + + return { + getCurrentPayPalAccount, + setCurrentPayPalAccount, + syncPayPalAccounts, + upsertPayPalAccount, + }; + } + + return { + createPayPalAccountStore, + }; +}); diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index ca16eae..4f5e4f0 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -13,23 +13,51 @@ sleepWithStop, throwIfStopped, DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php', + DEFAULT_HERO_SMS_REUSE_ENABLED = true, HERO_SMS_COUNTRY_ID = 52, HERO_SMS_COUNTRY_LABEL = 'Thailand', HERO_SMS_SERVICE_CODE = 'dr', HERO_SMS_SERVICE_LABEL = 'OpenAI', + DEFAULT_PHONE_CODE_WAIT_SECONDS = 60, + DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2, + DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5, + DEFAULT_PHONE_CODE_POLL_ROUNDS = 4, } = deps; const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation'; + const PHONE_VERIFICATION_CODE_STATE_KEY = 'currentPhoneVerificationCode'; const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation'; - const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000; + 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'; + const HERO_SMS_LAST_PRICE_USER_LIMIT_KEY = 'heroSmsLastPriceUserLimit'; + const HERO_SMS_LAST_PRICE_AT_KEY = 'heroSmsLastPriceAt'; + const PHONE_CODE_WAIT_SECONDS_MIN = 15; + const PHONE_CODE_WAIT_SECONDS_MAX = 300; + const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1; + const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10; + const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1; + const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30; + const PHONE_CODE_POLL_ROUNDS_MIN = 1; + const PHONE_CODE_POLL_ROUNDS_MAX = 120; + const DEFAULT_PHONE_POLL_INTERVAL_MS = DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS * 1000; const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000; const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000; const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3; - const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000; const DEFAULT_PHONE_NUMBER_MAX_USES = 3; + const DEFAULT_PHONE_NUMBER_REPLACEMENT_LIMIT = 3; const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3; + const MAX_PHONE_PRICE_CANDIDATES = 8; + const DEFAULT_PHONE_ACTIVATION_RETRY_ROUNDS = 3; + const PHONE_ACTIVATION_RETRY_ROUNDS_MIN = 1; + const PHONE_ACTIVATION_RETRY_ROUNDS_MAX = 10; + const DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS = 2000; + const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; + const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; 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_SMS_FAILURE_SKIP_THRESHOLD = 2; function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) { const trimmed = String(value || '').trim(); @@ -51,13 +79,179 @@ return Math.max(0, Math.floor(Number(value) || 0)); } + function normalizePhoneReplacementLimit(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_NUMBER_REPLACEMENT_LIMIT; + } + return Math.max(1, Math.min(20, parsed)); + } + + function normalizePhoneActivationRetryRounds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_ACTIVATION_RETRY_ROUNDS; + } + return Math.max(PHONE_ACTIVATION_RETRY_ROUNDS_MIN, Math.min(PHONE_ACTIVATION_RETRY_ROUNDS_MAX, parsed)); + } + + function normalizePhoneActivationRetryDelayMs(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS; + } + return Math.max(500, Math.min(30000, parsed)); + } + + function normalizeHeroSmsPriceLimit(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.round(parsed * 10000) / 10000; + } + + function isPhoneNumberUsedError(value) { + const text = String(value || '').trim(); + 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); + } + + function normalizeCountryId(value, fallback = HERO_SMS_COUNTRY_ID) { + 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 normalizeCountryLabel(value = '', fallback = HERO_SMS_COUNTRY_LABEL) { + return String(value || '').trim() || fallback; + } + + function normalizePhoneCodeWaitSeconds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_WAIT_SECONDS; + } + return Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.min(PHONE_CODE_WAIT_SECONDS_MAX, parsed)); + } + + function normalizePhoneCodeTimeoutWindows(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS; + } + return Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.min(PHONE_CODE_TIMEOUT_WINDOWS_MAX, parsed)); + } + + function normalizePhoneCodePollIntervalSeconds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS; + } + return Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.min(PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, parsed)); + } + + function normalizePhoneCodePollMaxRounds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_POLL_ROUNDS; + } + return Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.min(PHONE_CODE_POLL_ROUNDS_MAX, parsed)); + } + + function normalizeHeroSmsReuseEnabled(value) { + if (value === undefined || value === null) { + return Boolean(DEFAULT_HERO_SMS_REUSE_ENABLED); + } + return Boolean(value); + } + + function normalizeHeroSmsAcquirePriority(value = '') { + return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE + ? HERO_SMS_ACQUIRE_PRIORITY_PRICE + : HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; + } + + function normalizeCountryFallbackList(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seen = new Set(); + const normalized = []; + + for (const entry of source) { + let id = 0; + let label = ''; + + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + id = normalizeCountryId(entry.id ?? entry.countryId, 0); + label = String((entry.label ?? entry.countryLabel) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/); + if (structured) { + id = normalizeCountryId(structured[1], 0); + label = String(structured[2] || '').trim(); + } else { + id = normalizeCountryId(text, 0); + } + } + + if (!Number.isFinite(id) || id <= 0 || seen.has(id)) { + continue; + } + seen.add(id); + normalized.push({ + id, + label: label || `Country #${id}`, + }); + } + + return normalized; + } + function resolveCountryConfig(state = {}) { return { - id: Math.max(1, Math.floor(Number(state.heroSmsCountryId) || HERO_SMS_COUNTRY_ID)), - label: String(state.heroSmsCountryLabel || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL, + id: normalizeCountryId(state.heroSmsCountryId, HERO_SMS_COUNTRY_ID), + label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL), }; } + function resolveCountryCandidates(state = {}) { + const primary = resolveCountryConfig(state); + const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback); + const seen = new Set([primary.id]); + const candidates = [primary]; + + fallbackList.forEach((entry) => { + const nextId = normalizeCountryId(entry.id, 0); + if (!Number.isFinite(nextId) || nextId <= 0 || seen.has(nextId)) { + return; + } + seen.add(nextId); + candidates.push({ + id: nextId, + label: normalizeCountryLabel(entry.label, `Country #${nextId}`), + }); + }); + + return candidates; + } + function normalizeActivation(record) { if (!record || typeof record !== 'object' || Array.isArray(record)) { return null; @@ -72,12 +266,14 @@ return null; } const statusAction = String(record.statusAction || '').trim(); + const countryLabel = String(record.countryLabel || '').trim(); 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: Number(record.countryId) || HERO_SMS_COUNTRY_ID, + countryId: normalizeCountryId(record.countryId, HERO_SMS_COUNTRY_ID), + ...(countryLabel ? { countryLabel } : {}), successfulUses: normalizeUseCount(record.successfulUses), maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), ...(statusAction ? { statusAction } : {}), @@ -93,6 +289,7 @@ const provider = String(record.provider || '').trim(); const serviceCode = String(record.serviceCode || '').trim(); const countryId = Math.floor(Number(record.countryId)); + const countryLabel = String(record.countryLabel || '').trim(); const statusAction = String(record.statusAction || '').trim(); if (provider) { @@ -104,6 +301,9 @@ if (Number.isFinite(countryId) && countryId > 0) { fallback.countryId = countryId; } + if (countryLabel) { + fallback.countryLabel = countryLabel; + } if (Object.prototype.hasOwnProperty.call(record, 'successfulUses')) { fallback.successfulUses = normalizeUseCount(record.successfulUses); } @@ -174,6 +374,17 @@ return String(error?.message || '').startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX); } + function isPhoneResendThrottledError(error) { + const message = String(error?.message || error || '').trim(); + if (!message) { + return false; + } + if (message.startsWith(PHONE_RESEND_THROTTLED_ERROR_PREFIX)) { + return true; + } + 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 buildPhoneRestartStep7Error(phoneNumber = '') { const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : ''; return new Error( @@ -252,31 +463,37 @@ const directActivation = normalizeActivation(payload); if (directActivation) { const statusAction = normalizedFallback?.statusAction || directActivation.statusAction; - return { - ...directActivation, - provider: normalizedFallback?.provider || directActivation.provider, - serviceCode: normalizedFallback?.serviceCode || directActivation.serviceCode, - countryId: normalizedFallback?.countryId || directActivation.countryId, - successfulUses: normalizedFallback?.successfulUses ?? directActivation.successfulUses, - maxUses: normalizedFallback?.maxUses ?? directActivation.maxUses, - ...(statusAction ? { statusAction } : {}), - }; - } + return { + ...directActivation, + provider: normalizedFallback?.provider || directActivation.provider, + serviceCode: normalizedFallback?.serviceCode || directActivation.serviceCode, + countryId: normalizedFallback?.countryId || directActivation.countryId, + ...( + normalizedFallback?.countryLabel || directActivation.countryLabel + ? { countryLabel: normalizedFallback?.countryLabel || directActivation.countryLabel } + : {} + ), + successfulUses: normalizedFallback?.successfulUses ?? directActivation.successfulUses, + maxUses: normalizedFallback?.maxUses ?? directActivation.maxUses, + ...(statusAction ? { statusAction } : {}), + }; + } const text = describeHeroSmsPayload(payload); const accessNumberMatch = text.match(/^ACCESS_NUMBER:([^:]+):(.+)$/i); if (accessNumberMatch) { - return { - activationId: String(accessNumberMatch[1] || '').trim(), - phoneNumber: String(accessNumberMatch[2] || '').trim(), - provider: normalizedFallback?.provider || 'hero-sms', - serviceCode: normalizedFallback?.serviceCode || HERO_SMS_SERVICE_CODE, - countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID, - successfulUses: normalizedFallback?.successfulUses ?? 0, - maxUses: normalizedFallback?.maxUses ?? DEFAULT_PHONE_NUMBER_MAX_USES, - ...(normalizedFallback?.statusAction ? { statusAction: normalizedFallback.statusAction } : {}), - }; - } + return { + activationId: String(accessNumberMatch[1] || '').trim(), + phoneNumber: String(accessNumberMatch[2] || '').trim(), + provider: normalizedFallback?.provider || 'hero-sms', + serviceCode: normalizedFallback?.serviceCode || HERO_SMS_SERVICE_CODE, + countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID, + ...(normalizedFallback?.countryLabel ? { countryLabel: normalizedFallback.countryLabel } : {}), + successfulUses: normalizedFallback?.successfulUses ?? 0, + maxUses: normalizedFallback?.maxUses ?? DEFAULT_PHONE_NUMBER_MAX_USES, + ...(normalizedFallback?.statusAction ? { statusAction: normalizedFallback.statusAction } : {}), + }; + } if (/^ACCESS_READY$/i.test(text) && normalizedFallback) { return normalizedFallback; @@ -329,6 +546,19 @@ return Math.min(...candidates); } + function buildSortedUniquePriceCandidates(values = []) { + return Array.from( + new Set( + values + .map((value) => normalizeHeroSmsPrice(value)) + .filter((value) => value !== null) + .map((value) => Math.round(value * 10000) / 10000) + ) + ) + .sort((left, right) => left - right) + .slice(0, MAX_PHONE_PRICE_CANDIDATES); + } + function isHeroSmsNoNumbersPayload(payload) { return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload)); } @@ -355,6 +585,11 @@ return /failed to fetch|networkerror|load failed/i.test(message); } + function isHeroSmsTerminalError(payloadOrMessage) { + const text = describeHeroSmsPayload(payloadOrMessage); + 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); + } + async function resolveCheapestPhoneActivationPrice(config, countryConfig) { for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { try { @@ -374,6 +609,70 @@ return null; } + async function persistHeroSmsPricePlanSnapshot(countryConfig, pricePlan) { + if (typeof setState !== 'function') { + return; + } + const prices = Array.isArray(pricePlan?.prices) + ? pricePlan.prices.filter((price) => Number.isFinite(Number(price))) + : []; + const userLimit = pricePlan?.userLimit === null || pricePlan?.userLimit === undefined + ? '' + : String(pricePlan.userLimit); + await setState({ + [HERO_SMS_LAST_PRICE_TIERS_KEY]: prices, + [HERO_SMS_LAST_PRICE_COUNTRY_ID_KEY]: normalizeCountryId(countryConfig?.id, 0), + [HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY]: normalizeCountryLabel(countryConfig?.label, HERO_SMS_COUNTRY_LABEL), + [HERO_SMS_LAST_PRICE_USER_LIMIT_KEY]: userLimit, + [HERO_SMS_LAST_PRICE_AT_KEY]: Date.now(), + }); + } + + 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, []) + ); + if (priceCandidates.length > 0) { + break; + } + } 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 }; + await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan); + return fallbackPlan; + } + async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) { const query = { action, @@ -387,10 +686,11 @@ return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); } - async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice) { + async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice, options = {}) { let nextMaxPrice = maxPrice; let retriedWithUpdatedPrice = false; let retriedWithoutPrice = false; + const userLimit = normalizeHeroSmsPriceLimit(options.userLimit); while (true) { try { @@ -405,6 +705,11 @@ && !retriedWithUpdatedPrice && updatedMaxPrice !== null ) { + if (userLimit !== null && updatedMaxPrice > userLimit) { + throw new Error( + `HeroSMS ${action} failed: WRONG_MAX_PRICE requires ${updatedMaxPrice}, which exceeds configured maxPrice=${userLimit}.` + ); + } nextMaxPrice = updatedMaxPrice; retriedWithUpdatedPrice = true; continue; @@ -426,40 +731,194 @@ } } - async function requestPhoneActivation(state = {}) { + async function requestPhoneActivation(state = {}, options = {}) { const config = resolvePhoneConfig(state); - const countryConfig = resolveCountryConfig(state); - const maxPrice = await resolveCheapestPhoneActivationPrice(config, countryConfig); - const buildFallbackActivation = (requestAction) => ({ - countryId: countryConfig.id, - ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), - }); - let requestAction = 'getNumber'; - let payload; - - try { - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); - } catch (error) { - if (!isHeroSmsNoNumbersPayload(error?.payload || error?.message)) { - throw error; + const allCountryCandidates = resolveCountryCandidates(state); + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeCountryId(value, 0)) + .filter((id) => id > 0) + ); + let countryCandidates = allCountryCandidates.filter( + (entry) => !blockedCountryIds.has(normalizeCountryId(entry.id, 0)) + ); + 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' + ); } - requestAction = 'getNumberV2'; - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + } + const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const requestActions = ['getNumber', 'getNumberV2']; + const configuredAcquireRounds = normalizePhoneActivationRetryRounds( + state?.heroSmsActivationRetryRounds + ); + const maxAcquireRounds = Math.max(2, configuredAcquireRounds); + const retryDelayMs = normalizePhoneActivationRetryDelayMs( + state?.heroSmsActivationRetryDelayMs + ); + + let finalNoNumbersByCountry = []; + let finalLastError = null; + let finalLastFailureText = ''; + + for (let round = 1; round <= maxAcquireRounds; round += 1) { + if (maxAcquireRounds > 1) { + await addLog( + `Step 9: HeroSMS acquiring phone number (round ${round}/${maxAcquireRounds})...`, + 'info' + ); + } + + const countryAttempts = countryCandidates.map((countryConfig, index) => ({ + index, + countryConfig, + pricePlan: null, + orderingPrice: Number.POSITIVE_INFINITY, + })); + + if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE) { + for (const attempt of countryAttempts) { + const pricePlan = await resolvePhoneActivationPricePlan(config, attempt.countryConfig, state); + const numericPrices = Array.isArray(pricePlan?.prices) + ? pricePlan.prices + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value >= 0) + : []; + const minCandidatePrice = numericPrices.length ? Math.min(...numericPrices) : null; + const cappedByUserLimit = ( + pricePlan?.userLimit !== null + && pricePlan?.userLimit !== undefined + && pricePlan?.minCatalogPrice !== null + && pricePlan?.minCatalogPrice !== undefined + && Number(pricePlan.minCatalogPrice) > Number(pricePlan.userLimit) + ); + attempt.pricePlan = pricePlan; + attempt.orderingPrice = cappedByUserLimit + ? Number.POSITIVE_INFINITY + : (minCandidatePrice !== null ? minCandidatePrice : Number.POSITIVE_INFINITY); + } + } + + if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE && countryAttempts.length > 1) { + countryAttempts.sort((left, right) => { + if (left.orderingPrice !== right.orderingPrice) { + return left.orderingPrice - right.orderingPrice; + } + return left.index - right.index; + }); + } + + const noNumbersByCountry = []; + const retryableNoNumberCountries = []; + let lastError = null; + let lastFailureText = ''; + + for (const attempt of countryAttempts) { + const countryConfig = attempt.countryConfig; + const buildFallbackActivation = (requestAction) => ({ + countryId: countryConfig.id, + ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), + }); + const pricePlan = attempt.pricePlan || await resolvePhoneActivationPricePlan(config, countryConfig, state); + let noNumbersObservedInCountry = false; + + for (const maxPrice of pricePlan.prices) { + for (const requestAction of requestActions) { + try { + const payload = await requestPhoneActivationWithPrice( + config, + countryConfig, + requestAction, + maxPrice, + { userLimit: pricePlan.userLimit } + ); + const activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); + if (activation) { + const { countryLabel: _ignoredCountryLabel, ...activationWithoutCountryLabel } = activation; + return { + ...activationWithoutCountryLabel, + countryId: countryConfig.id, + }; + } + const payloadText = describeHeroSmsPayload(payload); + if (isHeroSmsNoNumbersPayload(payload)) { + noNumbersObservedInCountry = true; + lastFailureText = payloadText || lastFailureText; + continue; + } + if (isHeroSmsTerminalError(payload)) { + throw new Error(`HeroSMS ${requestAction} failed: ${payloadText || 'empty response'}`); + } + lastFailureText = payloadText || lastFailureText; + lastError = new Error(`HeroSMS ${requestAction} failed: ${payloadText || 'empty response'}`); + } catch (error) { + const payloadOrMessage = error?.payload || error?.message; + if (isHeroSmsTerminalError(payloadOrMessage)) { + throw new Error(`HeroSMS ${requestAction} failed: ${describeHeroSmsPayload(payloadOrMessage) || 'empty response'}`); + } + if (isHeroSmsNoNumbersPayload(payloadOrMessage)) { + noNumbersObservedInCountry = true; + lastFailureText = describeHeroSmsPayload(payloadOrMessage) || lastFailureText; + continue; + } + lastFailureText = describeHeroSmsPayload(payloadOrMessage) || lastFailureText; + lastError = error; + } + } + } + + if (noNumbersObservedInCountry) { + 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; + } + } + + finalNoNumbersByCountry = noNumbersByCountry; + finalLastError = lastError; + finalLastFailureText = lastFailureText; + + if ( + noNumbersByCountry.length + && round < maxAcquireRounds + && retryableNoNumberCountries.length > 0 + ) { + await addLog( + `Step 9: HeroSMS has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`, + 'warn' + ); + await sleepWithStop(retryDelayMs); + continue; + } + + break; } - let activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); - if (!activation && requestAction === 'getNumber' && isHeroSmsNoNumbersPayload(payload)) { - requestAction = 'getNumberV2'; - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); - activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); + if (finalNoNumbersByCountry.length) { + throw new Error( + `HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` + ); } - - if (!activation) { - const text = describeHeroSmsPayload(payload); - throw new Error(`HeroSMS ${requestAction} failed: ${text || 'empty response'}`); + if (finalLastError) { + throw finalLastError; } - - return activation; + throw new Error(`HeroSMS failed to acquire a phone number. Last status: ${finalLastFailureText || 'unknown'}.`); } async function reactivatePhoneActivation(state = {}, activation) { @@ -533,11 +992,16 @@ : DEFAULT_PHONE_POLL_TIMEOUT_MS ); const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_PHONE_POLL_INTERVAL_MS); + const maxRoundsRaw = Math.floor(Number(options.maxRounds)); + const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0; const start = Date.now(); let lastResponse = ''; let pollCount = 0; while (Date.now() - start < timeoutMs) { + if (maxRounds > 0 && pollCount >= maxRounds) { + break; + } throwIfStopped(); const payload = await fetchHeroSmsPayload(config, { action: statusAction, @@ -628,9 +1092,27 @@ return result || {}; } - async function submitPhoneNumber(tabId, phoneNumber) { + function resolveCountryConfigFromActivation(activation, fallbackState = {}) { + const candidates = 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; + } + return { + id: countryId, + label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`), + }; + } + } + return candidates[0] || resolveCountryConfig(fallbackState); + } + + async function submitPhoneNumber(tabId, phoneNumber, activation = null) { const state = await getState(); - const countryConfig = resolveCountryConfig(state); + const countryConfig = resolveCountryConfigFromActivation(activation, state); const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'submit add-phone number' }) : 30000; @@ -721,6 +1203,7 @@ async function persistCurrentActivation(activation) { await setState({ [PHONE_ACTIVATION_STATE_KEY]: activation || null, + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', }); } @@ -738,18 +1221,37 @@ await persistReusableActivation(null); } - async function acquirePhoneActivation(state = {}) { - const countryConfig = resolveCountryConfig(state); + async function acquirePhoneActivation(state = {}, options = {}) { + const countryCandidates = resolveCountryCandidates(state); + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeCountryId(value, 0)) + .filter((id) => id > 0) + ); + const allowedCountryIds = new Set( + countryCandidates + .map((entry) => normalizeCountryId(entry.id, 0)) + .filter((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 reuseEnabled = normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled); const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); if ( + reuseEnabled + && reusableActivation - && reusableActivation.countryId === countryConfig.id + && !blockedCountryIds.has(normalizeCountryId(reusableActivation.countryId, 0)) + && allowedCountryIds.has(reusableActivation.countryId) && reusableActivation.successfulUses < reusableActivation.maxUses ) { try { const reactivated = await reactivatePhoneActivation(state, reusableActivation); await addLog( - `Step 9: reusing ${countryConfig.label} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`, + `Step 9: reusing ${resolveCountryLabelById(reactivated.countryId)} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`, 'info' ); return reactivated; @@ -757,20 +1259,22 @@ await addLog(`Step 9: failed to reuse phone number ${reusableActivation.phoneNumber}, falling back to a new number. ${error.message}`, 'warn'); await clearReusableActivation(); } - } else if (reusableActivation && reusableActivation.countryId !== countryConfig.id) { - await clearReusableActivation(); } - const activation = await requestPhoneActivation(state); + const activation = await requestPhoneActivation(state, { blockedCountryIds: Array.from(blockedCountryIds) }); await addLog( - `Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${countryConfig.label} number ${activation.phoneNumber}.`, + `Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${resolveCountryLabelById(activation.countryId)} number ${activation.phoneNumber}.`, 'info' ); return activation; } - async function markActivationReusableAfterSuccess(activation) { + async function markActivationReusableAfterSuccess(state, activation) { const normalizedActivation = normalizeActivation(activation); + if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) { + await clearReusableActivation(); + return; + } if (!normalizedActivation) { await clearReusableActivation(); return; @@ -794,12 +1298,17 @@ throw new Error('Phone activation is missing.'); } + const waitSeconds = normalizePhoneCodeWaitSeconds(state?.phoneCodeWaitSeconds); + const timeoutWindows = normalizePhoneCodeTimeoutWindows(state?.phoneCodeTimeoutWindows); + const pollIntervalSeconds = normalizePhoneCodePollIntervalSeconds(state?.phoneCodePollIntervalSeconds); + const pollMaxRounds = normalizePhoneCodePollMaxRounds(state?.phoneCodePollMaxRounds); let lastLoggedStatus = ''; let lastLoggedPollCount = 0; + let resendTriggeredForCurrentNumber = false; - for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) { + for (let windowIndex = 1; windowIndex <= timeoutWindows; windowIndex += 1) { await addLog( - `Step 9: waiting up to 60 seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/2).`, + `Step 9: waiting up to ${waitSeconds} seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/${timeoutWindows}).`, 'info' ); try { @@ -807,7 +1316,9 @@ actionLabel: windowIndex === 1 ? 'poll phone verification code from HeroSMS' : 'poll resent phone verification code from HeroSMS', - timeoutMs: DEFAULT_PHONE_CODE_WAIT_WINDOW_MS, + timeoutMs: waitSeconds * 1000, + intervalMs: pollIntervalSeconds * 1000, + maxRounds: pollMaxRounds, onStatus: async ({ elapsedMs, pollCount, statusText }) => { const shouldLog = ( pollCount === 1 @@ -820,7 +1331,7 @@ lastLoggedStatus = statusText; lastLoggedPollCount = pollCount; await addLog( - `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, + `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed, round ${pollCount}/${pollMaxRounds}).`, 'info' ); }, @@ -834,26 +1345,49 @@ throw error; } - if (windowIndex === 1) { + if (windowIndex < timeoutWindows) { await addLog( - `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within 60 seconds, requesting another SMS.`, + `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within ${waitSeconds} seconds, requesting another SMS.`, 'warn' ); await requestAdditionalPhoneSms(state, normalizedActivation); + if (resendTriggeredForCurrentNumber) { + await addLog( + `Step 9: resend already used once for ${normalizedActivation.phoneNumber}; continue polling without another page resend to avoid rate limit.`, + 'warn' + ); + continue; + } try { await resendPhoneVerificationCode(tabId); + resendTriggeredForCurrentNumber = true; await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info'); } catch (resendError) { + if (isPhoneResendThrottledError(resendError)) { + await addLog( + `Step 9: resend is throttled for ${normalizedActivation.phoneNumber}, replacing number immediately. ${resendError.message}`, + 'warn' + ); + return { + code: '', + replaceNumber: true, + reason: 'resend_throttled', + }; + } await addLog(`Step 9: failed to click resend on the phone verification page. ${resendError.message}`, 'warn'); } continue; } await addLog( - `Step 9: still no SMS for ${normalizedActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, + `Step 9: no SMS for ${normalizedActivation.phoneNumber} after ${timeoutWindows} window(s), replacing the number inside step 9.`, 'warn' ); - throw buildPhoneRestartStep7Error(normalizedActivation.phoneNumber); + return { + code: '', + replaceNumber: true, + reason: `sms_timeout_after_${timeoutWindows}_windows`, + }; } } @@ -866,6 +1400,52 @@ let pageState = initialPageState || await readPhonePageState(tabId); let shouldCancelActivation = false; let remainingResendRequests = Math.max(0, Number(state.verificationResendCount) || 0); + const maxNumberReplacementAttempts = normalizePhoneReplacementLimit( + state.phoneVerificationReplacementLimit + ); + let usedNumberReplacementAttempts = 0; + let preferReuseExistingActivationOnAddPhone = false; + let addPhoneReentryWithSameActivation = 0; + const countrySmsFailureCounts = new Map(); + + const getCountryFailureCount = (countryId) => { + const normalizedCountryId = normalizeCountryId(countryId, 0); + if (!normalizedCountryId) { + return 0; + } + return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(normalizedCountryId)) || 0)); + }; + + const markCountrySmsFailure = async (countryId, reason = 'sms_timeout') => { + const normalizedCountryId = normalizeCountryId(countryId, 0); + if (!normalizedCountryId) { + return; + } + const nextCount = getCountryFailureCount(normalizedCountryId) + 1; + countrySmsFailureCounts.set(normalizedCountryId, 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}`; + await addLog( + `Step 9: ${countryLabel} reached ${nextCount} SMS failures (${reason}); next acquisition will fallback to other selected country candidates first.`, + 'warn' + ); + } + }; + + const clearCountrySmsFailure = (countryId) => { + const normalizedCountryId = normalizeCountryId(countryId, 0); + if (!normalizedCountryId) { + return; + } + countrySmsFailureCounts.delete(normalizedCountryId); + }; + + const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries()) + .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) + .map(([countryId]) => normalizeCountryId(countryId, 0)) + .filter((countryId) => countryId > 0); try { while (true) { @@ -875,17 +1455,91 @@ } if (pageState?.addPhonePage) { - if (activation) { - await cancelPhoneActivation(state, activation); - await clearCurrentActivation(); - activation = null; - shouldCancelActivation = false; + if (!activation) { + activation = await acquirePhoneActivation(state, { + blockedCountryIds: getBlockedCountryIds(), + }); + shouldCancelActivation = true; + await persistCurrentActivation(activation); + addPhoneReentryWithSameActivation = 0; + } else if (preferReuseExistingActivationOnAddPhone) { + addPhoneReentryWithSameActivation += 1; + if (addPhoneReentryWithSameActivation > 1) { + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw new Error( + `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: returned_to_add_phone_loop.` + ); + } + await addLog( + `Step 9: current number ${activation.phoneNumber} returned to add-phone repeatedly, replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + 'warn' + ); + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; + pageState = { + ...pageState, + addPhonePage: true, + phoneVerificationPage: false, + }; + continue; + } + await addLog( + `Step 9: add-phone returned, re-submitting current number ${activation.phoneNumber} before requesting a new number.`, + 'warn' + ); + } + + let submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); + 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' + ); + 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; + } + + await addLog( + `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) { + throw new Error( + `Step 9: add-phone keeps rejecting current number without explicit "used" status: ${submitResult.errorText || submitResult.url || 'unknown error'}.` + ); + } } - activation = await acquirePhoneActivation(state); - shouldCancelActivation = true; - await persistCurrentActivation(activation); - const submitResult = await submitPhoneNumber(tabId, activation.phoneNumber); await addLog('Step 9: submitted the phone number on add-phone page.', 'info'); pageState = { ...pageState, @@ -893,6 +1547,8 @@ addPhonePage: false, phoneVerificationPage: true, }; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; } if (!pageState?.phoneVerificationPage) { @@ -908,6 +1564,7 @@ } let shouldReplaceNumber = false; + let replaceReason = ''; for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) { throwIfStopped(); @@ -915,18 +1572,22 @@ const codeResult = await waitForPhoneCodeOrRotateNumber(tabId, state, activation); if (codeResult.replaceNumber) { shouldReplaceNumber = true; + replaceReason = codeResult.reason || 'sms_not_received'; break; } + await setState({ + [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); if (submitResult.returnedToAddPhone) { await addLog( - 'Step 9: phone verification returned to add-phone after code submission, replacing the current number.', + 'Step 9: phone verification returned to add-phone after code submission, will try current number first.', 'warn' ); - shouldReplaceNumber = true; + preferReuseExistingActivationOnAddPhone = true; pageState = { ...pageState, ...submitResult, @@ -937,10 +1598,25 @@ } if (submitResult.invalidCode) { - if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { - throw new Error( - `Phone verification code was rejected after ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} attempts: ${submitResult.errorText || submitResult.url || 'unknown error'}` + const invalidErrorText = String(submitResult.errorText || submitResult.url || 'unknown error'); + if (isPhoneNumberUsedError(invalidErrorText)) { + shouldReplaceNumber = true; + replaceReason = 'phone_number_used'; + await addLog( + `Step 9: phone number was rejected as already used (${invalidErrorText}), replacing with a new number immediately.`, + 'warn' ); + break; + } + + if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { + shouldReplaceNumber = true; + replaceReason = 'code_rejected'; + await addLog( + `Step 9: phone verification code was rejected ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} times (${invalidErrorText}), replacing the number.`, + 'warn' + ); + break; } if (remainingResendRequests > 0) { @@ -966,16 +1642,61 @@ } await completePhoneActivation(state, activation); - await markActivationReusableAfterSuccess(activation); + await markActivationReusableAfterSuccess(state, activation); + clearCountrySmsFailure(activation.countryId); shouldCancelActivation = false; await clearCurrentActivation(); - await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); - return submitResult; - } + addPhoneReentryWithSameActivation = 0; + await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); + return submitResult; + } if (!shouldReplaceNumber) { + if (pageState?.addPhonePage) { + continue; + } throw new Error('Phone verification did not complete successfully.'); } + + if ( + activation + && (replaceReason === 'resend_throttled' || /^sms_timeout_after_/i.test(String(replaceReason || ''))) + ) { + await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout'); + } + + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw new Error( + `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: ${replaceReason || 'unknown'}.` + ); + } + + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + addPhoneReentryWithSameActivation = 0; + + let returnResult = { addPhonePage: true, phoneVerificationPage: false }; + try { + returnResult = await returnToAddPhone(tabId); + } catch (returnError) { + await addLog(`Step 9: failed to return to add-phone page before replacing number. ${returnError.message}`, 'warn'); + } + + await addLog( + `Step 9: replacing number and retrying inside step 9 (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + 'warn' + ); + pageState = { + ...pageState, + ...returnResult, + addPhonePage: true, + phoneVerificationPage: false, + }; } } catch (error) { if (shouldCancelActivation && activation) { diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index 88607c9..e172945 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -16,6 +16,7 @@ getTabId, isTabAlive, prepareStep8DebuggerClick, + recoverOAuthLocalhostTimeout, reloadStep8ConsentPage, reuseOrCreateTab, sleepWithStop, @@ -44,19 +45,43 @@ async function executeStep9(state) { const visibleStep = getVisibleStep(state, 9); - if (!state.oauthUrl) { + let activeState = state; + + if (!activeState.oauthUrl) { const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}。`); } await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`); - const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(240000, { - step: visibleStep, - actionLabel: 'OAuth localhost 回调', - }) - : 240000; + let callbackTimeoutMs = 240000; + let timeoutRecoveryAttempted = false; + while (true) { + try { + callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(240000, { + step: visibleStep, + actionLabel: 'OAuth localhost 回调', + oauthUrl: activeState?.oauthUrl || '', + }) + : 240000; + break; + } catch (error) { + if (timeoutRecoveryAttempted || typeof recoverOAuthLocalhostTimeout !== 'function') { + throw error; + } + const recoveredState = await recoverOAuthLocalhostTimeout({ + error, + state: activeState, + visibleStep, + }); + if (!recoveredState) { + throw error; + } + activeState = recoveredState; + timeoutRecoveryAttempted = true; + } + } return new Promise((resolve, reject) => { let resolved = false; @@ -124,7 +149,7 @@ await chrome.tabs.update(signupTabId, { active: true }); await addLog(`步骤 ${visibleStep}:已切回认证页,正在准备调试器点击...`); } else { - signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl); + signupTabId = await reuseOrCreateTab('signup-page', activeState.oauthUrl); await addLog(`步骤 ${visibleStep}:已重新打开认证页,正在准备调试器点击...`); } diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 0af5cd4..9f59517 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -27,6 +27,7 @@ reuseOrCreateTab, setState, shouldUseCustomRegistrationEmail, + sleepWithStop, STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS, throwIfStopped, @@ -167,10 +168,26 @@ }); } - async function runStep8Attempt(state) { + function getStep8ResendIntervalMs(state = {}) { + const mail = getMailConfig(state); + if (mail?.provider === LUCKMAIL_PROVIDER) { + return 15000; + } + if (mail?.provider === HOTMAIL_PROVIDER || mail?.provider === '2925') { + return 0; + } + return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0); + } + + async function runStep8Attempt(state, runtime = {}) { const visibleStep = getVisibleStep(state, 8); const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); + const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; + let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt); + const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function' + ? runtime.onResendRequestedAt + : null; const stepStartedAt = Date.now(); const verificationFilterAfterTimestamp = mail.provider === '2925' @@ -267,6 +284,16 @@ disableTimeBudgetCap: mail.provider === '2925', getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep), requestFreshCodeFirst: false, + lastResendAt: latestResendAt, + onResendRequestedAt: async (requestedAt) => { + const numericRequestedAt = Number(requestedAt) || 0; + if (numericRequestedAt > 0) { + latestResendAt = Math.max(latestResendAt, numericRequestedAt); + } + if (notifyResendRequestedAt) { + await notifyResendRequestedAt(latestResendAt); + } + }, targetEmail: fixedTargetEmail, resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER ? 15000 @@ -274,6 +301,9 @@ ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS), }); + return { + lastResendAt: latestResendAt, + }; } function isStep8RestartStep7Error(error) { @@ -285,10 +315,22 @@ let currentState = state; let mailPollingAttempt = 1; let lastMailPollingError = null; + let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0; while (true) { try { - await runStep8Attempt(currentState); + const result = await runStep8Attempt(currentState, { + stickyLastResendAt, + onResendRequestedAt: async (requestedAt) => { + const numericRequestedAt = Number(requestedAt) || 0; + if (numericRequestedAt > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, numericRequestedAt); + } + }, + }); + if (Number(result?.lastResendAt) > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0); + } return; } catch (err) { const visibleStep = getVisibleStep(currentState, 8); @@ -323,7 +365,29 @@ `步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`, 'warn' ); - currentState = await getState(); + const latestState = await getState(); + const latestStateResendAt = Number(latestState?.loginVerificationRequestedAt) || 0; + if (latestStateResendAt > 0) { + stickyLastResendAt = Math.max(stickyLastResendAt, latestStateResendAt); + } + currentState = latestState; + if (stickyLastResendAt > 0 && (!latestStateResendAt || latestStateResendAt < stickyLastResendAt)) { + currentState = { + ...latestState, + loginVerificationRequestedAt: stickyLastResendAt, + }; + } + const resendIntervalMs = getStep8ResendIntervalMs(currentState); + const remainingBeforeRetryMs = stickyLastResendAt > 0 && resendIntervalMs > 0 + ? Math.max(0, resendIntervalMs - (Date.now() - stickyLastResendAt)) + : 0; + if (remainingBeforeRetryMs > 0 && typeof sleepWithStop === 'function') { + await addLog( + `步骤 ${visibleStep}:上轮已触发重发验证码,为避免重复重发,先等待 ${Math.ceil(remainingBeforeRetryMs / 1000)} 秒后继续当前链路重试。`, + 'info' + ); + await sleepWithStop(Math.min(remainingBeforeRetryMs, 3000)); + } continue; } await addLog( diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index d12ca49..f24178e 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -40,17 +40,14 @@ return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message); } - function getVisibleStep(state, fallback = 7) { - const visibleStep = Math.floor(Number(state?.visibleStep) || 0); - return visibleStep > 0 ? visibleStep : fallback; - } - async function executeStep7(state) { - const visibleStep = getVisibleStep(state, 7); if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); } + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + const completionStep = visibleStep > 0 ? visibleStep : 7; + let attempt = 0; let lastError = null; @@ -60,22 +57,22 @@ try { const currentState = attempt === 1 ? state : await getState(); const password = currentState.password || currentState.customPassword || ''; - const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState, { visibleStep }); + const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); if (typeof startOAuthFlowTimeoutWindow === 'function') { - await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl }); + await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl }); } const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(180000, { - step: visibleStep, + step: 7, actionLabel: 'OAuth 登录并进入验证码页', oauthUrl, }) : 180000; if (attempt === 1) { - await addLog(`步骤 ${visibleStep}:正在打开最新 OAuth 链接并登录...`); + await addLog('步骤 7:正在打开最新 OAuth 链接并登录...'); } else { - await addLog(`步骤 ${visibleStep}:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn'); + await addLog(`步骤 7:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn'); } await reuseOrCreateTab('signup-page', oauthUrl); @@ -89,14 +86,13 @@ payload: { email: currentState.email, password, - visibleStep, }, }, { timeoutMs: loginTimeoutMs, responseTimeoutMs: loginTimeoutMs, retryDelayMs: 700, - logMessage: `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪后继续登录...`, + logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...', } ); @@ -108,13 +104,14 @@ const completionPayload = { loginVerificationRequestedAt: result.loginVerificationRequestedAt || null, }; - if (result.skipLoginVerificationStep) { - completionPayload.skipLoginVerificationStep = true; + if (Object.prototype.hasOwnProperty.call(result || {}, 'skipLoginVerificationStep')) { + completionPayload.skipLoginVerificationStep = Boolean(result.skipLoginVerificationStep); } - if (result.directOAuthConsentPage) { - completionPayload.directOAuthConsentPage = true; + if (Object.prototype.hasOwnProperty.call(result || {}, 'directOAuthConsentPage')) { + completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage); } - await completeStepFromBackground(visibleStep, completionPayload); + + await completeStepFromBackground(completionStep, completionPayload); return; } @@ -132,7 +129,7 @@ } if (isManagementSecretConfigError(err)) { await addLog( - `步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, + `步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, 'error' ); throw err; @@ -142,11 +139,11 @@ break; } - await addLog(`步骤 ${visibleStep}:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn'); + await addLog(`步骤 7:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn'); } } - throw new Error(`步骤 ${visibleStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`); + throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`); } return { executeStep7 }; diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index d69540d..3d1e390 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -99,17 +99,30 @@ return result || {}; } + function resolvePayPalCredentials(state = {}) { + const currentId = String(state?.currentPayPalAccountId || '').trim(); + const accounts = Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : []; + const selectedAccount = currentId + ? accounts.find((account) => String(account?.id || '').trim() === currentId) || null + : null; + return { + email: String(selectedAccount?.email || state?.paypalEmail || '').trim(), + password: String(selectedAccount?.password || state?.paypalPassword || ''), + }; + } + async function submitLogin(tabId, state = {}) { - if (!state.paypalPassword) { - throw new Error('步骤 8:未配置 PayPal 密码,请先在侧边栏填写。'); + const credentials = resolvePayPalCredentials(state); + if (!credentials.password) { + throw new Error('步骤 8:未配置可用的 PayPal 账号,请先在侧边栏添加并选择账号。'); } await addLog('步骤 8:正在填写 PayPal 登录信息并提交...', 'info'); const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { type: 'PAYPAL_SUBMIT_LOGIN', source: 'background', payload: { - email: state.paypalEmail || '', - password: state.paypalPassword || '', + email: credentials.email, + password: credentials.password, }, }); if (result?.error) { diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index b88f295..1637dc8 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -26,31 +26,23 @@ return String(value || '').trim(); } - function getVisibleStep(state, fallback = 10) { + function resolvePlatformVerifyStep(state = {}) { const visibleStep = Math.floor(Number(state?.visibleStep) || 0); - return visibleStep > 0 ? visibleStep : fallback; + return visibleStep >= 10 ? visibleStep : 10; } - function getConfirmStepForVisibleStep(visibleStep) { - return visibleStep >= 13 ? 12 : 9; - } - - function getAuthLoginStepForVisibleStep(visibleStep) { - return visibleStep >= 13 ? 10 : 7; - } - - function parseLocalhostCallback(rawUrl, visibleStep = 10, confirmStep = 9) { + function parseLocalhostCallback(rawUrl) { let parsed; try { parsed = new URL(rawUrl); } catch { - throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。'); } const code = normalizeString(parsed.searchParams.get('code')); const state = normalizeString(parsed.searchParams.get('state')); if (!code || !state) { - throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。'); } return { @@ -72,6 +64,77 @@ return details || `Codex2API 请求失败(HTTP ${responseStatus})。`; } + function deriveCpaManagementOrigin(vpsUrl) { + const normalizedUrl = normalizeString(vpsUrl); + if (!normalizedUrl) { + throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。'); + } + let parsed; + try { + parsed = new URL(normalizedUrl); + } catch { + throw new Error('CPA 地址格式无效,请先在侧边栏检查。'); + } + return parsed.origin; + } + + function getCpaApiErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ] + .map((value) => normalizeString(value)) + .find(Boolean); + return details || `CPA 管理接口请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCpaManagementJson(origin, path, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const managementKey = normalizeString(options.managementKey); + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (managementKey) { + headers.Authorization = `Bearer ${managementKey}`; + headers['X-Management-Key'] = managementKey; + } + + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCpaApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('CPA 管理接口请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function fetchCodex2ApiJson(origin, path, options = {}) { const controller = new AbortController(); const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); @@ -122,95 +185,88 @@ } async function executeCpaStep10(state) { - const visibleStep = getVisibleStep(state, 10); - const confirmStep = getConfirmStepForVisibleStep(visibleStep); + const platformVerifyStep = resolvePlatformVerifyStep(state); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } if (!state.localhostUrl) { - throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); } if (!state.vpsUrl) { throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。'); } if (shouldBypassStep9ForLocalCpa(state)) { - await addLog(`步骤 ${visibleStep}:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。`, 'info'); - await completeStepFromBackground(visibleStep, { + await addLog('步骤 10:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info'); + await completeStepFromBackground(platformVerifyStep, { localhostUrl: state.localhostUrl, verifiedStatus: 'local-auto', }); return; } - await addLog(`步骤 ${visibleStep}:正在打开 CPA 面板...`); - - const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js']; - let tabId = await getTabId('vps-panel'); - const alive = tabId && await isTabAlive('vps-panel'); - - if (!alive) { - tabId = await reuseOrCreateTab('vps-panel', state.vpsUrl, { - inject: injectFiles, - reloadIfSameUrl: true, - }); - } else { - await closeConflictingTabsForSource('vps-panel', state.vpsUrl, { excludeTabIds: [tabId] }); - await chrome.tabs.update(tabId, { active: true }); - await rememberSourceLastUrl('vps-panel', state.vpsUrl); + const callback = parseLocalhostCallback(state.localhostUrl); + const expectedState = normalizeString(state.cpaOAuthState); + if (expectedState && expectedState !== callback.state) { + throw new Error('CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); + } + const managementKey = normalizeString(state.vpsPassword); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); } - await ensureContentScriptReadyOnTab('vps-panel', tabId, { - inject: injectFiles, - timeoutMs: 45000, - retryDelayMs: 900, - logMessage: `步骤 ${visibleStep}:CPA 面板仍在加载,正在重试连接...`, - }); + await addLog('步骤 10:正在通过 CPA 管理接口提交回调地址...'); + try { + const origin = normalizeString(state.cpaManagementOrigin) || deriveCpaManagementOrigin(state.vpsUrl); + const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', { + method: 'POST', + managementKey, + body: { + provider: 'codex', + redirect_url: callback.url, + }, + }); - await addLog(`步骤 ${visibleStep}:正在填写回调地址...`); - const result = await sendToContentScriptResilient('vps-panel', { - type: 'EXECUTE_STEP', - step: visibleStep, - source: 'background', - payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword, visibleStep }, - }, { - timeoutMs: 125000, - responseTimeoutMs: 125000, - retryDelayMs: 700, - logMessage: `步骤 ${visibleStep}:CPA 面板通信未就绪,正在等待页面恢复...`, - }); - - if (result?.error) { - throw new Error(result.error); + const verifiedStatus = normalizeString(result?.message) + || normalizeString(result?.status_message) + || 'CPA 已通过接口提交回调'; + await addLog(`步骤 10:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(platformVerifyStep, { + localhostUrl: callback.url, + verifiedStatus, + }); + } catch (error) { + const reason = normalizeString(error?.message) || 'unknown error'; + await addLog(`步骤 10:CPA 接口提交失败:${reason}`, 'error'); + throw error; } } async function executeCodex2ApiStep10(state) { - const visibleStep = getVisibleStep(state, 10); - const confirmStep = getConfirmStepForVisibleStep(visibleStep); + const platformVerifyStep = resolvePlatformVerifyStep(state); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } if (!state.localhostUrl) { - throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); } if (!state.codex2apiSessionId) { - throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); + throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。'); } if (!normalizeString(state.codex2apiAdminKey)) { throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); } - const callback = parseLocalhostCallback(state.localhostUrl, visibleStep, confirmStep); + const callback = parseLocalhostCallback(state.localhostUrl); const expectedState = normalizeString(state.codex2apiOAuthState); if (expectedState && expectedState !== callback.state) { - throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); + throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); } const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); const origin = new URL(codex2apiUrl).origin; - await addLog(`步骤 ${visibleStep}:正在向 Codex2API 提交回调并创建账号...`); + await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...'); const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', { adminKey: state.codex2apiAdminKey, method: 'POST', @@ -222,21 +278,19 @@ }); const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; - await addLog(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok'); - await completeStepFromBackground(visibleStep, { + await addLog(`步骤 10:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(platformVerifyStep, { localhostUrl: callback.url, verifiedStatus, }); } async function executeSub2ApiStep10(state) { - const visibleStep = getVisibleStep(state, 10); - const confirmStep = getConfirmStepForVisibleStep(visibleStep); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } if (!state.localhostUrl) { - throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); } if (!state.sub2apiSessionId) { throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。'); @@ -251,7 +305,7 @@ const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - await addLog(`步骤 ${visibleStep}:正在打开 SUB2API 后台...`); + await addLog('步骤 10:正在打开 SUB2API 后台...'); let tabId = await getTabId('sub2api-panel'); const alive = tabId && await isTabAlive('sub2api-panel'); @@ -273,13 +327,12 @@ injectSource: 'sub2api-panel', }); - await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`); + await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...'); const result = await sendToContentScript('sub2api-panel', { type: 'EXECUTE_STEP', - step: visibleStep, + step: 10, source: 'background', payload: { - visibleStep, localhostUrl: state.localhostUrl, sub2apiUrl, sub2apiEmail: state.sub2apiEmail, diff --git a/background/verification-flow.js b/background/verification-flow.js index 017b3bb..1a8dfea 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -222,6 +222,28 @@ return Math.min(20, Math.max(0, Math.floor(numeric))); } + function getVerificationRequestedAtStateKey(step) { + if (Number(step) === 4) return 'signupVerificationRequestedAt'; + if (Number(step) === 8) return 'loginVerificationRequestedAt'; + return ''; + } + + function resolveInitialVerificationRequestedAt(step, state = {}, fallback = 0) { + const stateKey = getVerificationRequestedAtStateKey(step); + const candidateValues = [ + fallback, + stateKey ? state?.[stateKey] : 0, + ]; + + for (const value of candidateValues) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.floor(numeric); + } + } + return 0; + } + function getLegacyVerificationResendCountDefault(step, options = {}) { const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst); const legacyMaxRounds = Math.max(1, Math.floor(Number(VERIFICATION_POLL_MAX_ROUNDS) || 1)); @@ -985,9 +1007,23 @@ : getConfiguredVerificationResendCount(step, state, { requestFreshCodeFirst }); const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15; const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); - let lastResendAt = Number(options.lastResendAt) || 0; + const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function' + ? options.onResendRequestedAt + : null; + let lastResendAt = resolveInitialVerificationRequestedAt( + step, + state, + Number(options.lastResendAt) || 0 + ); - const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => { + const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => { + if (externalOnResendRequestedAt) { + try { + await externalOnResendRequestedAt(requestedAt); + } catch (_) { + // Keep resend flow best-effort; state sync callback failures should not break verification. + } + } return nextFilterAfterTimestamp; }; diff --git a/content/paypal-flow.js b/content/paypal-flow.js index 9f34e51..7a505e6 100644 --- a/content/paypal-flow.js +++ b/content/paypal-flow.js @@ -227,6 +227,18 @@ function getPayPalLoginPhase(emailInput, passwordInput) { return ''; } +function refillPayPalEmailInput(emailInput, email) { + if (!emailInput) return; + if (typeof emailInput.focus === 'function') { + emailInput.focus(); + } + fillInput(emailInput, ''); + fillInput(emailInput, email); + if (typeof emailInput.blur === 'function') { + emailInput.blur(); + } +} + async function submitPayPalLogin(payload = {}) { await waitForDocumentComplete(); @@ -241,9 +253,7 @@ async function submitPayPalLogin(payload = {}) { const emailNextButton = findEmailNextButton(); if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) { - if (normalizeText(emailInput.value || '') !== email) { - fillInput(emailInput, email); - } + refillPayPalEmailInput(emailInput, email); simulateClick(emailNextButton); return { submitted: false, @@ -253,9 +263,7 @@ async function submitPayPalLogin(payload = {}) { } if (!passwordInput && emailInput && email) { - if (normalizeText(emailInput.value || '') !== email) { - fillInput(emailInput, email); - } + refillPayPalEmailInput(emailInput, email); const nextButton = await waitUntil(() => { const button = findEmailNextButton() || findLoginNextButton(); return button && isEnabledControl(button) ? button : null; @@ -272,8 +280,8 @@ async function submitPayPalLogin(payload = {}) { }; } else if (!passwordInput && emailInput && !email) { throw new Error('PayPal 账号为空,请先在侧边栏配置。'); - } else if (emailInput && email && normalizeText(emailInput.value || '') !== email) { - fillInput(emailInput, email); + } else if (emailInput && email) { + refillPayPalEmailInput(emailInput, email); } passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), { diff --git a/content/phone-auth.js b/content/phone-auth.js index 595f32f..e9701f5 100644 --- a/content/phone-auth.js +++ b/content/phone-auth.js @@ -18,6 +18,8 @@ throwIfStopped, waitForElement, } = deps; + const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; + 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; function dispatchInputEvents(element) { if (!element) return; @@ -312,6 +314,90 @@ return matches?.[0] ? matches[0].replace(/\s+/g, ' ').trim() : ''; } + function getAddPhoneErrorText() { + const form = getAddPhoneForm(); + if (!form) { + return ''; + } + + const messages = []; + const selectors = [ + '.react-aria-FieldError', + '[slot="errorMessage"]', + '[id$="-error"]', + '[data-invalid="true"] + *', + '[aria-invalid="true"] + *', + '[class*="error"]', + ]; + for (const selector of selectors) { + form.querySelectorAll(selector).forEach((el) => { + const text = String(el?.textContent || '').replace(/\s+/g, ' ').trim(); + if (text) { + messages.push(text); + } + }); + } + + const invalidInput = form.querySelector('input[aria-invalid="true"], input[data-invalid="true"]'); + if (invalidInput) { + const wrapper = invalidInput.closest('form, [data-rac], div'); + const text = String(wrapper?.textContent || '').replace(/\s+/g, ' ').trim(); + if (text) { + messages.push(text); + } + } + + const preferred = messages.find((text) => ( + /already|used|linked|eligible|invalid|phone|号码|手机号|错误|失败|try\s+again/i.test(text) + )); + return preferred || messages[0] || ''; + } + + function getPhoneVerificationInlineMessages() { + const form = getPhoneVerificationForm(); + if (!form) { + return []; + } + const messages = []; + const selectors = [ + '.react-aria-FieldError', + '[slot="errorMessage"]', + '[id$="-error"]', + '[data-invalid="true"] + *', + '[aria-invalid="true"] + *', + '[class*="error"]', + ]; + for (const selector of selectors) { + form.querySelectorAll(selector).forEach((element) => { + const text = String(element?.textContent || '').replace(/\s+/g, ' ').trim(); + if (text) { + messages.push(text); + } + }); + } + const verificationError = String(getVerificationErrorText?.() || '').trim(); + if (verificationError) { + messages.push(verificationError); + } + return messages; + } + + function getPhoneResendThrottleText() { + const inlineMatch = getPhoneVerificationInlineMessages() + .find((text) => PHONE_RESEND_THROTTLED_PATTERN.test(text)); + if (inlineMatch) { + return inlineMatch; + } + const pageSnapshot = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim(); + if (pageSnapshot && PHONE_RESEND_THROTTLED_PATTERN.test(pageSnapshot)) { + const concise = pageSnapshot.match( + /tried\s+to\s+resend\s+too\s+many\s+times[^.。!?]*[.。!?]?|please\s+try\s+again\s+later[^.。!?]*[.。!?]?|发送.*过于频繁[^。!?]*[。!?]?|稍后再试[^。!?]*[。!?]?/i + ); + return String(concise?.[0] || pageSnapshot).trim(); + } + return ''; + } + async function waitForAddPhoneReady(timeout = 20000) { const start = Date.now(); while (Date.now() - start < timeout) { @@ -335,8 +421,28 @@ url: location.href, }; } + if (isAddPhonePageReady()) { + const errorText = getAddPhoneErrorText(); + if (errorText) { + return { + addPhoneRejected: true, + errorText, + url: location.href, + }; + } + } await sleep(150); } + if (isAddPhonePageReady()) { + const errorText = getAddPhoneErrorText(); + if (errorText) { + return { + addPhoneRejected: true, + errorText, + url: location.href, + }; + } + } throw new Error('Timed out waiting for phone verification page.'); } @@ -466,11 +572,19 @@ 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}`); + } return { resent: true, url: location.href, @@ -479,6 +593,11 @@ await sleep(250); } + 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.'); } diff --git a/docs/使用教程.md b/docs/使用教程.md index 0512821..92ef7c2 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`HeroSMS` 手机接码扩展能力、`PayPal` 注册绑卡流程、扩展内置动态 IP 代理、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -10,7 +10,9 @@ - 需要使用 `iCloud+` 的 `隐藏邮件地址` 作为隐私邮箱 - 需要临时切换 `QQ 邮箱` 地址继续使用 - 需要使用 `网易邮箱`、`网易邮箱大师` 注册多个邮箱或替身邮箱 +- 需要在 `Step 9` 的手机号验证阶段使用 `HeroSMS` 接码(含多国家回退与价格策略) - 需要注册并使用 `PayPal` 个人账户 +- 需要在扩展内使用 `711Proxy` 动态 IP,并在自动流程中按轮次切换出口 - 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` @@ -25,7 +27,9 @@ - 一个可正常登录的 `QQ 邮箱` - 手机端已安装 `网易邮箱` 或 `网易邮箱大师` - 一个可正常接收短信的手机号 +- 一个可用的 `HeroSMS API Key`(用于手机号接码) - 一张可在线支付的借记卡或信用卡 +- 如需使用扩展内置动态 IP,需准备 `711Proxy` 的 Host、Port、代理账号和代理密码 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` - 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),并已导入可用订阅 @@ -219,7 +223,71 @@ 只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。 建议注册一个后换节奏再继续,不要一口气连续创建。 -### 第七部分:`PayPal` 注册与绑卡使用教程 +### 第七部分:`HeroSMS` 手机接码扩展使用教程(新增) + +本部分用于说明扩展中 `Step 9` 手机号验证链路的最新接码能力与推荐用法。 +目标是减少“重复重发导致封禁”与“同国家持续拿不到码”的失败率,并让失败可以在 `Step 9` 内部自愈。 + +#### 一、入口与启用 + +1. 打开扩展侧栏,找到 `接码设置`。 +2. 打开右侧接码开关(`IP 代理`下方同风格开关)。 +3. 展开设置后,先确认 `接码平台` 显示为 `HeroSMS / OpenAI`。 + +#### 二、国家与优先级(新增能力) + +1. 在 `接码国家` 中多选国家(最多 `3` 个),按你的业务顺序排列。 +2. 在 `国家优先级` 中选择策略: + - `国家优先`:严格按你选择的顺序先后尝试。 + - `价格优先`:在候选国家里先选可用价格更低的国家,再尝试拿号。 +3. 当同一国家连续失败达到阈值后,流程会自动优先尝试下一个候选国家。 + +#### 三、价格控制与价格预览(新增能力) + +1. 填写 `接码 API`(支持右侧眼睛按钮显示/隐藏,避免粘贴错误)。 +2. 在 `价格` 一行点击 `查询价格`,查看候选国家当前档位。 +3. 设置 `价格上限`(`maxPrice`)限制成本,避免异常高价拿号。 +4. 如果日志提示 `no numbers within maxPrice`,说明上限太低,按当前价格结果适度上调即可。 + +#### 四、号码复用与参数建议 + +1. `号码复用` 开关: + - 开启:同号码在可复用范围内优先复用,减少频繁拿号。 + - 关闭:每次优先新号,适合风控更严格的场景。 +2. 建议参数(稳妥起步): + - `验证码重发`:`0` 或 `1` + - `换号上限`:`3` + - `验证码限时`:`60` 秒 + - `超时次数`:`2` + - `轮询间隔`:`5` 秒 + - `轮询次数`:`3-4` 次(不要设置过高) + +#### 五、失败自愈策略(新增能力) + +当前版本已内置以下保护逻辑: + +1. 当页面出现 `Tried to resend too many times. Please try again later.` 时,流程会停止继续狂点 `Resend`,改为换号/换国家路径。 +2. 单个号码不会无限重发,避免被目标站点判定重发过频。 +3. 收不到短信时优先在 `Step 9` 内局部恢复,不直接回卷到 `Step 1`。 + +#### 六、运行状态怎么看 + +在 `运行状态` 中重点看: + +1. `当前分配`:当前拿到的手机号与国家。 +2. `验证码`:是否已收到可提交验证码。 +3. `价格预览`:当前国家候选的可用价格信息(查询后刷新)。 + +#### 七、常见问题与处理 + +1. `no numbers available across ...` + 表示候选国家当前无号或价格上限过低。先提高价格上限,再调整国家顺序。 +2. `NO_NUMBERS` 频繁出现 + 增加候选国家数量(最多 3 个),并优先启用 `价格优先`。 +3. 页面提示重发过多 + 不要手工持续点重发,让流程自动换号/换国家即可。 + +### 第八部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 @@ -271,14 +339,14 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第八部分:0元试用 ChatGPT Plus 教程 +### 第九部分:0元试用 ChatGPT Plus 教程 本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 #### 准备工作 1. 已有一个登录状态的 ChatGPT 账户。 -2. 一个可用的 PayPal 账户(参考第七部分进行注册和绑卡)。 +2. 一个可用的 PayPal 账户(参考第八部分进行注册和绑卡)。 3. 能够接收生成的账单地址的真实地址或虚拟地址。 4. Chrome 浏览器(推荐使用地址补全功能)。 @@ -426,7 +494,113 @@ - **PayPal 登录后页面无法继续跳转** 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 -### 第九部分:节点检测与纯净度检查网站 +### 第十部分:扩展内置动态 IP 代理使用教程 + +本部分说明扩展侧边栏里的 `IP代理` 功能。 +它和 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 这类系统代理不一样:扩展会通过浏览器代理 API 给当前浏览器设置 PAC 代理,并自动回填代理鉴权。当前首版只开放 `711Proxy` 的账号密码模式,`API 拉取` 和多账号列表入口暂未开放。 + +#### 一、什么时候需要打开 `IP代理` + +如果你已经在 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 或其他客户端里稳定使用节点,可以继续使用外部代理。 +如果你希望在扩展里直接控制动态 IP、检查当前出口、按成功轮次自动切换,或者不想依赖系统全局代理,就使用这里的 `IP代理`。 + +开启 `IP代理` 后,扩展会接管浏览器代理。 +关闭 `IP代理` 后,浏览器会恢复默认代理或外部全局代理。 + +#### 二、注册或登录 `711Proxy` + +在侧边栏打开 `IP代理` 后,`代理服务` 当前固定为 `711Proxy(首版)`。 +点击旁边的 `注册` 按钮,按页面提示完成注册或登录。 + +在网站中完成注册、登录和套餐准备后,进入代理信息页面,找到可用于账号密码代理的 `Host`、`Port`、`Username` 和 `Password`。 + +#### 三、填写固定账号代理 + +1. 在扩展侧边栏打开 `IP代理` 开关。 +2. `代理模式` 保持 `账号密码`。 +3. 填写 `代理 Host`,例如 `global.rotgb.711proxy.com`。 +4. 填写 `代理 Port`,例如 `10000`。 +5. `代理协议` 一般保持 `HTTP`,除非你的代理信息明确要求 `HTTPS`、`SOCKS5` 或 `SOCKS4`。 +6. 填写 `代理账号` 和 `代理密码`。 +7. 按需填写 `地区参数`,例如 `US`、`DE`、`HK`。 +8. 按需填写 `会话(session)` 和 `时长(life)`。 + +`地区参数` 只接受两个字母的国家或地区代码。 +`会话(session)` 会被追加或写回到用户名里的 `session-xxxx`。 +`时长(life)` 会被追加或写回到用户名里的 `sessTime-xx`,711Proxy 的有效范围按 `5-180` 分钟使用更稳。 + +如果你的 `711Proxy` 用户名里已经带了 `region-US`、`session-xxxx` 或 `sessTime-30`,扩展会优先识别用户名里的值。 +如果你在表单里修改地区、session 或时长,扩展会把它们同步回最终生效的代理用户名。 + +#### 四、同步、下一条、Change 和检测出口 + +填写或修改代理信息后,不要只保存后就直接开跑。建议按下面顺序操作: + +1. 点击 `同步` + 扩展会保存当前配置,生成代理规则,应用到浏览器,并自动做一次出口检测。 + 这是修改 Host、Port、账号、密码、地区、session 或时长后最常用的按钮。 + +2. 查看 `代理状态` + 状态卡会显示当前代理、当前出口 IP、出口地区和诊断详情。 + 如果显示 `当前代理` 和 `当前出口`,说明扩展已经能看到代理出口。 + +3. 点击 `检测出口` + 它只重新检测当前出口,不主动更换节点。 + 如果你怀疑检测结果没刷新,或者刚刚切换过外部网络,可以点它复测。 + +4. 点击 `检查IP` + 它会打开 `https://ipinfo.io/what-is-my-ip`,用于人工确认网页看到的公网 IP。 + +5. 点击 `下一条` + 当前首版主要是固定账号模式。如果只有一条可用代理,`下一条` 会重绑当前节点并复测,不保证一定换出口。 + 如果后续开放多账号列表或 API 池,`下一条` 才会真正切到池里的下一条。 + +6. 点击 `Change` + `Change` 只支持 `711Proxy + 账号密码模式 + 用户名包含 session` 的场景。 + 它会保持当前 session,强制重绑代理链路并刷新出口,适合想换出口但不想改整条账号配置时使用。 + +#### 五、任务切换阈值 + +`任务切换阈值` 表示自动流程成功多少轮后尝试自动切换一次代理。 +默认是 `20` 轮,可填写 `1-500`。 + +需要注意: + +- 当前只有一条固定账号时,即使命中阈值,也会跳过自动切换。 +- 如果后续有多条可切换代理,命中阈值后会自动执行 `下一条`。 +- 这个阈值只影响自动流程成功后的切换节奏,不会替代你手动点击 `同步` 或 `检测出口`。 + +#### 六、状态卡怎么看 + +常见状态含义如下: + +- `未开启`:扩展没有接管浏览器代理,继续使用默认网络或外部全局代理。 +- `当前代理:host:port;当前出口:x.x.x.x`:代理已经应用,并检测到出口 IP。 +- `已启用,但账号模式没有可用代理`:Host/Port 为空或格式不完整,需要先补齐代理信息。 +- `连通性失败`:代理应用了,但没有检测到可用出口,优先检查 Host、Port、协议、账号和密码。 +- `地区校验未通过`:检测到的出口地区和你填写的地区参数不一致。711Proxy 场景下扩展会保留接管并给出警告,你需要决定是否继续使用这个出口。 +- `当前链路未收到 407 代理鉴权挑战`:代理服务没有按浏览器扩展代理 API 预期返回标准鉴权挑战,可能导致账号密码无法自动回填。可以尝试 `Change`、关闭再开启 `IP代理`、换 session,或者更换代理节点。 + +如果代理不可用,扩展会启用目标站点阻断保护,避免在代理失效时继续直连访问目标站点。修好代理后重新点击 `同步` 或 `检测出口` 即可恢复。 + +#### 七、推荐使用顺序 + +首次配置建议按这个顺序: + +1. 点击 `注册` 打开 `711Proxy`,准备代理账号信息。 +2. 打开扩展侧边栏的 `IP代理`。 +3. 填写 Host、Port、协议、账号、密码。 +4. 填写地区参数,例如 `US`。 +5. 填写 session 和时长,例如 session 为随机前缀、时长为 `30`。 +6. 点击 `同步`。 +7. 确认 `代理状态` 里有出口 IP。 +8. 点击 `检查IP`,人工确认网页检测结果。 +9. 再继续执行注册、登录或自动流程。 + +如果后续要换出口,优先尝试 `Change`。 +如果 `Change` 不可用,再调整 session 或代理账号后点击 `同步`。 + +### 第十一部分:节点检测与纯净度检查网站 本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。 建议每次切换节点后都重新打开这些网站检查一次。 @@ -460,7 +634,7 @@ 接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。 最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。 -### 第十部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第十二部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -569,7 +743,7 @@ function main(config, profileName) { 5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。 ![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png) -### 第十一部分:订阅节点与自建推荐 +### 第十三部分:订阅节点与自建推荐 如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。 @@ -661,6 +835,23 @@ function main(config, profileName) { 请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。 +### 扩展 `IP代理` 开启后,为什么状态还是没有出口 IP? + +先确认 Host、Port、协议、代理账号和代理密码都已填写正确。 +修改这些字段后,需要点击 `同步` 才会重新应用代理并检测出口。 +如果诊断提示没有收到 `407` 代理鉴权挑战,可以尝试点击 `Change`、关闭再开启 `IP代理`、更换 session,或者换一条代理节点。 + +### `Change` 按钮为什么点不了? + +`Change` 只在 `711Proxy` 的账号密码模式下可用,并且当前生效用户名必须包含 `session-xxxx` 这类 session 参数。 +如果没有 session,请先在 `会话(session)` 中填写一个随机前缀,再点击 `同步`。 + +### `IP代理` 和 Clash Verge 应该同时开吗? + +通常二选一即可。 +如果开启扩展内置 `IP代理`,浏览器会优先使用扩展设置的 PAC 代理;如果关闭它,浏览器会回到默认网络或外部全局代理。 +排查问题时建议先只保留一种代理方式,确认出口 IP 稳定后再继续流程。 + ### 网站测速结果好,是否代表节点纯净? 不代表。 @@ -688,6 +879,9 @@ function main(config, profileName) { - 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 - `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。 - 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。 +- 使用扩展内置 `IP代理` 时,修改 Host、Port、账号、密码、地区、session 或时长后,请点击 `同步` 让新配置真正生效。 +- `711Proxy` 的 `Change` 需要用户名中包含 session;没有 session 时请先填写 `会话(session)` 并同步。 +- 扩展 `IP代理` 报连通性失败时,不要直接继续自动流程,先用 `检测出口` 和 `检查IP` 确认网页看到的出口 IP。 - 检查节点时,先确认出口 IP,再看风险标签和泄露检测,最后再看网站测速。 - [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 主要用于判断访问速度和连通性,不要单独用它判断节点纯净度。 - [IPPure](https://ippure.com/) 如果提示 `WebRTC` 或 `DNS` 泄露,请优先检查浏览器和代理客户端的相关设置。 diff --git a/paypal-utils.js b/paypal-utils.js new file mode 100644 index 0000000..b949bc7 --- /dev/null +++ b/paypal-utils.js @@ -0,0 +1,56 @@ +(function attachPayPalUtils(root, factory) { + root.PayPalUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPayPalUtils() { + function normalizePayPalAccount(account = {}) { + const normalizedEmail = String(account.email || '').trim().toLowerCase(); + const now = Date.now(); + return { + id: String(account.id || crypto.randomUUID()), + email: normalizedEmail, + password: String(account.password || ''), + createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : now, + updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : now, + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + }; + } + + function normalizePayPalAccounts(accounts) { + if (!Array.isArray(accounts)) return []; + + const deduped = new Map(); + for (const account of accounts) { + const normalized = normalizePayPalAccount(account); + if (!normalized.email) continue; + deduped.set(normalized.id, normalized); + } + return [...deduped.values()]; + } + + function findPayPalAccount(accounts = [], accountId = '') { + const normalizedId = String(accountId || '').trim(); + if (!normalizedId) return null; + return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null; + } + + function upsertPayPalAccountInList(accounts = [], nextAccount = null) { + if (!nextAccount) { + return normalizePayPalAccounts(accounts); + } + + const normalizedNext = normalizePayPalAccount(nextAccount); + const list = normalizePayPalAccounts(accounts); + const existingIndex = list.findIndex((account) => account.id === normalizedNext.id); + if (existingIndex >= 0) { + list[existingIndex] = normalizedNext; + return list; + } + return [...list, normalizedNext]; + } + + return { + findPayPalAccount, + normalizePayPalAccount, + normalizePayPalAccounts, + upsertPayPalAccountInList, + }; +}); diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 806ecad..768b480 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -123,6 +123,48 @@ def log_info(message): print(f"[HotmailHelper] {message}", flush=True) +def is_openai_sender(address): + sender = str(address or "").strip().lower() + return "openai" in sender + + +def get_message_body_content(message): + body = message.get("body") or {} + if not isinstance(body, dict): + return "" + return str(body.get("content") or "").strip() + + +def log_openai_messages(messages, transport=""): + for message in messages or []: + sender_info = message.get("from", {}).get("emailAddress", {}) or {} + sender = str(sender_info.get("address") or "").strip() + if not is_openai_sender(sender): + continue + + sender_name = str(sender_info.get("name") or "").strip() + mailbox = str(message.get("mailbox") or "").strip() or "INBOX" + subject = str(message.get("subject") or "").strip() + transport_label = str(transport or "").strip() or "unknown" + base = ( + f"transport={transport_label} mailbox={mailbox} sender={sender} " + f"senderName={sender_name or '-'} subject={subject}" + ) + + log_info(f"openai mail received {base}") + + body_content = get_message_body_content(message) + if body_content: + log_info(f"openai mail full body start {base}") + print(body_content, flush=True) + log_info("openai mail full body end") + continue + + preview = str(message.get("bodyPreview") or "").strip() + if preview: + log_info(f"openai mail preview {base} preview={compact_text(preview, 1000)}") + + def get_proxy_debug_context(): names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"] parts = [] @@ -481,6 +523,9 @@ def normalize_message(message_id, raw_bytes, mailbox): } }, "bodyPreview": body[:500], + "body": { + "content": body, + }, "receivedDateTime": to_iso_string(timestamp_ms), "receivedTimestamp": timestamp_ms, } @@ -680,6 +725,7 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top): try: log_info(f"message collection start transport={transport_name}") result = collector(email_addr, client_id, refresh_token, mailboxes, top) + log_openai_messages(result.get("messages") or [], transport=transport_name) log_info( f"message collection success transport={transport_name} " f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}" @@ -722,6 +768,10 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, preview = str(message.get("bodyPreview", "")) combined = " ".join([sender, subject.lower(), preview.lower()]) code = extract_code(" ".join([subject, preview, sender])) + if not code: + body_content = get_message_body_content(message) + if body_content: + code = extract_code(" ".join([subject, body_content, sender])) if not code or code in excluded: return None diff --git a/sidepanel/form-dialog.js b/sidepanel/form-dialog.js new file mode 100644 index 0000000..0e44133 --- /dev/null +++ b/sidepanel/form-dialog.js @@ -0,0 +1,269 @@ +(function attachSidepanelFormDialog(globalScope) { + const FORM_EYE_OPEN_ICON = ''; + const FORM_EYE_CLOSED_ICON = ''; + + function syncPasswordToggleButton(button, input, labels) { + if (!button || !input) return; + const isHidden = input.type === 'password'; + button.innerHTML = isHidden ? FORM_EYE_OPEN_ICON : FORM_EYE_CLOSED_ICON; + button.setAttribute('aria-label', isHidden ? labels.show : labels.hide); + button.title = isHidden ? labels.show : labels.hide; + } + + function createFormDialog(context = {}) { + const { + overlay = null, + titleNode = null, + closeButton = null, + messageNode = null, + alertNode = null, + fieldsContainer = null, + cancelButton = null, + confirmButton = null, + documentRef = globalScope.document, + } = context; + + let resolver = null; + let currentConfig = null; + let currentInputs = []; + + function setHidden(node, hidden) { + if (!node) return; + node.hidden = Boolean(hidden); + } + + function resetAlert() { + if (!alertNode) return; + alertNode.textContent = ''; + alertNode.className = 'modal-alert modal-form-alert'; + alertNode.hidden = true; + } + + function setAlert(message = '', tone = 'danger') { + if (!alertNode) return; + const text = String(message || '').trim(); + if (!text) { + resetAlert(); + return; + } + alertNode.textContent = text; + alertNode.className = `modal-alert modal-form-alert${tone === 'danger' ? ' is-danger' : ''}`; + alertNode.hidden = false; + } + + function close(result = null) { + if (resolver) { + resolver(result); + resolver = null; + } + currentConfig = null; + currentInputs = []; + resetAlert(); + if (fieldsContainer) { + fieldsContainer.innerHTML = ''; + } + if (overlay) { + overlay.hidden = true; + } + } + + function buildFieldNode(field, values) { + const wrapper = documentRef.createElement('div'); + wrapper.className = 'modal-form-row'; + + const label = documentRef.createElement('label'); + label.className = 'modal-form-label'; + const labelText = String(field.label || field.key || '').trim(); + label.textContent = labelText; + wrapper.appendChild(label); + + let input = null; + if (field.type === 'textarea') { + input = documentRef.createElement('textarea'); + input.className = 'data-textarea'; + } else if (field.type === 'select') { + input = documentRef.createElement('select'); + input.className = 'data-select'; + const options = Array.isArray(field.options) ? field.options : []; + options.forEach((option) => { + const optionNode = documentRef.createElement('option'); + optionNode.value = String(option?.value || ''); + optionNode.textContent = String(option?.label || option?.value || ''); + input.appendChild(optionNode); + }); + } else { + input = documentRef.createElement('input'); + input.type = field.type === 'password' ? 'password' : 'text'; + input.className = field.type === 'password' + ? 'data-input data-input-with-icon' + : 'data-input'; + } + + const normalizedValue = Object.prototype.hasOwnProperty.call(values, field.key) + ? values[field.key] + : field.value; + if (normalizedValue !== undefined && normalizedValue !== null) { + input.value = String(normalizedValue); + } + if (field.placeholder) { + input.placeholder = String(field.placeholder); + } + if (field.autocomplete) { + input.autocomplete = String(field.autocomplete); + } + if (field.inputMode) { + input.inputMode = String(field.inputMode); + } + if (field.rows && field.type === 'textarea') { + input.rows = Number(field.rows) || 3; + } + input.dataset.fieldKey = String(field.key || ''); + label.htmlFor = field.key; + input.id = field.key; + if (field.type === 'password') { + const inputShell = documentRef.createElement('div'); + inputShell.className = 'input-with-icon'; + inputShell.appendChild(input); + + const toggleButton = documentRef.createElement('button'); + toggleButton.className = 'input-icon-btn'; + toggleButton.type = 'button'; + const labels = { + show: String(field.showPasswordLabel || `\u663e\u793a${labelText || '\u5bc6\u7801'}`), + hide: String(field.hidePasswordLabel || `\u9690\u85cf${labelText || '\u5bc6\u7801'}`), + }; + syncPasswordToggleButton(toggleButton, input, labels); + toggleButton.addEventListener('click', () => { + input.type = input.type === 'password' ? 'text' : 'password'; + syncPasswordToggleButton(toggleButton, input, labels); + }); + + inputShell.appendChild(toggleButton); + wrapper.appendChild(inputShell); + } else { + wrapper.appendChild(input); + } + + if (field.type !== 'textarea') { + input.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + void handleConfirm(); + }); + } + + currentInputs.push({ field, input }); + return wrapper; + } + + function collectValues() { + return currentInputs.reduce((result, item) => { + result[item.field.key] = item.input.value; + return result; + }, {}); + } + + async function handleConfirm() { + if (!currentConfig) { + close(null); + return; + } + + const values = collectValues(); + resetAlert(); + + for (const item of currentInputs) { + const { field, input } = item; + const rawValue = values[field.key]; + const textValue = String(rawValue || '').trim(); + if (field.required && !textValue) { + setAlert(field.requiredMessage || `${field.label || field.key}不能为空。`); + input.focus?.(); + return; + } + if (typeof field.validate === 'function') { + const validationMessage = await field.validate(rawValue, values); + if (validationMessage) { + setAlert(validationMessage); + input.focus?.(); + return; + } + } + } + + close(values); + } + + function bindEvents() { + overlay?.addEventListener('click', (event) => { + if (event.target === overlay) { + close(null); + } + }); + closeButton?.addEventListener('click', () => close(null)); + cancelButton?.addEventListener('click', () => close(null)); + confirmButton?.addEventListener('click', () => { + void handleConfirm(); + }); + } + + async function open(config = {}) { + if (!overlay || !titleNode || !fieldsContainer || !confirmButton) { + return null; + } + if (resolver) { + close(null); + } + + currentConfig = config || {}; + currentInputs = []; + titleNode.textContent = String(currentConfig.title || '填写表单'); + if (messageNode) { + const message = String(currentConfig.message || '').trim(); + messageNode.textContent = message; + setHidden(messageNode, !message); + } + resetAlert(); + if (currentConfig.alert?.text) { + setAlert(currentConfig.alert.text, currentConfig.alert.tone || 'danger'); + } + + confirmButton.textContent = String(currentConfig.confirmLabel || '确认'); + confirmButton.className = `btn ${currentConfig.confirmVariant || 'btn-primary'} btn-sm`; + fieldsContainer.innerHTML = ''; + + const initialValues = currentConfig.initialValues && typeof currentConfig.initialValues === 'object' + ? currentConfig.initialValues + : {}; + const fields = Array.isArray(currentConfig.fields) ? currentConfig.fields : []; + fields.forEach((field) => { + fieldsContainer.appendChild(buildFieldNode(field, initialValues)); + }); + + overlay.hidden = false; + const firstInput = currentInputs[0]?.input || null; + if (firstInput && typeof globalScope.requestAnimationFrame === 'function') { + globalScope.requestAnimationFrame(() => firstInput.focus?.()); + } else { + firstInput?.focus?.(); + } + + return new Promise((resolve) => { + resolver = resolve; + }); + } + + bindEvents(); + + return { + close, + open, + }; + } + + globalScope.SidepanelFormDialog = { + createFormDialog, + }; +})(window); diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js index fe6e115..bb8c2d5 100644 --- a/sidepanel/ip-proxy-panel.js +++ b/sidepanel/ip-proxy-panel.js @@ -14,6 +14,47 @@ const ipProxyActionState = { busy: false, action: '', }; +const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded'; +let ipProxySectionExpanded = false; + +function readIpProxySectionExpanded() { + try { + return globalThis.localStorage?.getItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY) === '1'; + } catch (err) { + return false; + } +} + +function persistIpProxySectionExpanded(expanded) { + try { + if (expanded) { + globalThis.localStorage?.setItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY, '1'); + } else { + globalThis.localStorage?.removeItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY); + } + } catch (err) { + // Ignore storage errors; the in-memory collapsed state is still enough for this session. + } +} + +function setIpProxySectionExpanded(expanded) { + ipProxySectionExpanded = Boolean(expanded); + persistIpProxySectionExpanded(ipProxySectionExpanded); + if (typeof updateIpProxyUI === 'function') { + updateIpProxyUI(latestState); + } +} + +function toggleIpProxySectionExpanded() { + setIpProxySectionExpanded(!ipProxySectionExpanded); +} + +function initIpProxySectionExpandedState() { + ipProxySectionExpanded = readIpProxySectionExpanded(); + if (typeof updateIpProxyUI === 'function') { + updateIpProxyUI(latestState); + } +} function normalizeIpProxyActionType(value = '') { const normalized = String(value || '').trim().toLowerCase(); @@ -866,14 +907,18 @@ function buildIpProxyActionHintText(options = {}) { const mode = normalizeIpProxyModeForCurrentRelease(options?.mode || DEFAULT_IP_PROXY_MODE); const poolCount = Math.max(0, Number(options?.poolCount) || 0); const changeAvailable = Boolean(options?.changeAvailable); + const dynamicPoolCount = poolCount > 0 ? poolCount : 1; if (mode === 'api') { - return '下一条:切到已拉取代理池的下一条。Change:仅账号模式可用。'; + const nextPart = poolCount > 1 + ? `下一条:当前共 ${dynamicPoolCount} 条节点,切到已拉取代理池的下一条节点。` + : `下一条:当前仅 ${dynamicPoolCount} 条节点,执行重绑复测(不保证更换出口)。`; + return `${nextPart} Change:仅账号模式可用。`; } const nextPart = poolCount > 1 - ? '下一条:切到代理池的下一条节点。' - : '下一条:当前仅 1 条节点,执行重绑复测(不保证更换出口)。'; + ? `下一条:当前共 ${dynamicPoolCount} 条节点,切到代理池的下一条节点。` + : `下一条:当前仅 ${dynamicPoolCount} 条节点,执行重绑复测(不保证更换出口)。`; const changePart = changeAvailable ? 'Change:保持当前 session 重绑链路并复测出口。' : 'Change:需 711 账号模式且用户名包含 session。'; @@ -890,7 +935,6 @@ function setIpProxyCurrentDisplay(text = '', hasValue = false) { function formatIpProxyCurrentDisplay(state = latestState) { const mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode); if (mode === 'account') { - const runtime = getIpProxyRuntimeSnapshot(state, mode); const current = getIpProxyCurrentEntry(state); if (!current) { return { @@ -898,10 +942,8 @@ function formatIpProxyCurrentDisplay(state = latestState) { hasValue: false, }; } - const count = runtime.pool.length > 0 ? runtime.pool.length : 1; - const index = runtime.index; return { - text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''} (${Math.min(index + 1, count)}/${count})`, + text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''}`, hasValue: true, }; } @@ -919,7 +961,7 @@ function formatIpProxyCurrentDisplay(state = latestState) { const region = String(current.region || '').trim(); const label = region ? `${current.host}:${current.port} [${region}]` : `${current.host}:${current.port}`; return { - text: `${label}${count ? ` (${Math.min(index + 1, count)}/${count})` : ''}`, + text: label, hasValue: true, }; } @@ -945,19 +987,7 @@ function buildIpProxyCurrentDisplayText(display = {}, runtimeStatus = {}) { if (!hasValue || !rawText) { return rawText; } - const runtimeText = String(runtimeStatus?.text || '').trim().toLowerCase(); - if (!runtimeText) { - return rawText; - } - const endpointToken = extractIpProxyEndpointToken(rawText); - if (!endpointToken || !runtimeText.includes(endpointToken)) { - return rawText; - } - const indexToken = extractIpProxyIndexToken(rawText); - if (indexToken) { - return `节点 ${indexToken}`; - } - return '当前节点'; + return rawText; } function formatIpProxyRuntimeStatus(state = latestState) { @@ -1158,6 +1188,7 @@ function setIpProxyEnabledInlineStatus(state = {}, enabled = getSelectedIpProxyE function updateIpProxyUI(state = latestState) { const enabled = getSelectedIpProxyEnabled(); + const showSettings = enabled && ipProxySectionExpanded; const mode = getSelectedIpProxyMode(); const service = normalizeIpProxyService(selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE); const apiModeAvailable = isIpProxyApiModeAvailable(); @@ -1172,64 +1203,70 @@ function updateIpProxyUI(state = latestState) { const busyAction = normalizeIpProxyActionType(actionState.action); const runtimeState = state || latestState || {}; - setIpProxyEnabledInlineStatus(runtimeState, enabled); - if (rowIpProxyEnabled) { rowIpProxyEnabled.style.display = ''; } + if (btnToggleIpProxySection) { + btnToggleIpProxySection.disabled = !enabled; + btnToggleIpProxySection.textContent = showSettings ? '收起设置' : '展开设置'; + btnToggleIpProxySection.title = enabled + ? (showSettings ? '收起 IP 代理设置' : '展开 IP 代理设置') + : '开启 IP 代理后可展开设置'; + btnToggleIpProxySection.setAttribute('aria-expanded', String(showSettings)); + } if (rowIpProxyFold) { - rowIpProxyFold.style.display = enabled ? '' : 'none'; + rowIpProxyFold.style.display = showSettings ? '' : 'none'; } if (rowIpProxyService) { - rowIpProxyService.style.display = enabled ? '' : 'none'; + rowIpProxyService.style.display = showSettings ? '' : 'none'; } if (rowIpProxyMode) { - rowIpProxyMode.style.display = enabled ? '' : 'none'; + rowIpProxyMode.style.display = showSettings ? '' : 'none'; } if (rowIpProxyLayout) { - rowIpProxyLayout.style.display = enabled ? '' : 'none'; + rowIpProxyLayout.style.display = showSettings ? '' : 'none'; } if (rowIpProxyApiUrl) { - rowIpProxyApiUrl.style.display = enabled && apiModeAvailable && isApiMode ? '' : 'none'; + rowIpProxyApiUrl.style.display = showSettings && apiModeAvailable && isApiMode ? '' : 'none'; } if (rowIpProxyAccountList) { - rowIpProxyAccountList.style.display = enabled && isAccountMode && accountListAvailable ? '' : 'none'; + rowIpProxyAccountList.style.display = showSettings && isAccountMode && accountListAvailable ? '' : 'none'; } if (rowIpProxyAccountSessionPrefix) { - rowIpProxyAccountSessionPrefix.style.display = enabled && showSessionOptions ? '' : 'none'; + rowIpProxyAccountSessionPrefix.style.display = showSettings && showSessionOptions ? '' : 'none'; } if (rowIpProxyAccountLifeMinutes) { - rowIpProxyAccountLifeMinutes.style.display = enabled && showSessionOptions ? '' : 'none'; + rowIpProxyAccountLifeMinutes.style.display = showSettings && showSessionOptions ? '' : 'none'; } if (rowIpProxyPoolTargetCount) { - rowIpProxyPoolTargetCount.style.display = enabled ? '' : 'none'; + rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none'; } if (rowIpProxyHost) { - rowIpProxyHost.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyPort) { - rowIpProxyPort.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyPort.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyProtocol) { - rowIpProxyProtocol.style.display = enabled ? '' : 'none'; + rowIpProxyProtocol.style.display = showSettings ? '' : 'none'; } if (rowIpProxyUsername) { - rowIpProxyUsername.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyUsername.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyPassword) { - rowIpProxyPassword.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyPassword.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyRegion) { - rowIpProxyRegion.style.display = enabled && isAccountMode ? '' : 'none'; + rowIpProxyRegion.style.display = showSettings && isAccountMode ? '' : 'none'; } if (rowIpProxyActions) { - rowIpProxyActions.style.display = enabled ? '' : 'none'; + rowIpProxyActions.style.display = showSettings ? '' : 'none'; } if (ipProxyActionButtons) { - ipProxyActionButtons.style.display = enabled ? '' : 'none'; + ipProxyActionButtons.style.display = showSettings ? '' : 'none'; } if (rowIpProxyRuntimeStatus) { - rowIpProxyRuntimeStatus.style.display = enabled ? '' : 'none'; + rowIpProxyRuntimeStatus.style.display = showSettings ? '' : 'none'; } if (ipProxyLayout) { ipProxyLayout.classList.toggle('is-account-only', !apiModeAvailable); @@ -1256,7 +1293,7 @@ function updateIpProxyUI(state = latestState) { ipProxyApiPanel.classList.toggle('is-disabled', !apiModeAvailable); ipProxyApiPanel.setAttribute('aria-disabled', String(!apiModeAvailable)); ipProxyApiPanel.hidden = !apiModeAvailable; - ipProxyApiPanel.style.display = enabled && apiModeAvailable ? '' : 'none'; + ipProxyApiPanel.style.display = showSettings && apiModeAvailable ? '' : 'none'; } if (inputIpProxyApiUrl) { inputIpProxyApiUrl.disabled = !enabled || !apiModeAvailable; @@ -1305,11 +1342,12 @@ function updateIpProxyUI(state = latestState) { setIpProxyCurrentDisplay(currentDisplayText, currentDisplay.hasValue); const runtimeSnapshot = getIpProxyRuntimeSnapshot(runtimeState, mode, service); const runtimePoolCount = Array.isArray(runtimeSnapshot?.pool) ? runtimeSnapshot.pool.length : 0; + const runtimePoolCountForDisplay = runtimePoolCount > 0 ? runtimePoolCount : 1; const hasCurrentEntry = Boolean(getIpProxyCurrentEntry(runtimeState)); const changeAvailable = canChangeIpProxyExitWithCurrentSession(runtimeState); const nextActionTitle = runtimePoolCount > 1 - ? '切换到代理池下一条节点并应用' - : '当前仅 1 条节点:重绑当前节点并复测连通性(不保证更换出口)'; + ? `切换到代理池下一条节点并应用(当前共 ${runtimePoolCountForDisplay} 条)` + : `当前仅 ${runtimePoolCountForDisplay} 条节点:重绑当前节点并复测连通性(不保证更换出口)`; if (btnIpProxyRefresh) { btnIpProxyRefresh.disabled = actionBusy || !enabled || !canOperate; diff --git a/sidepanel/paypal-manager.js b/sidepanel/paypal-manager.js new file mode 100644 index 0000000..97c1be0 --- /dev/null +++ b/sidepanel/paypal-manager.js @@ -0,0 +1,191 @@ +(function attachSidepanelPayPalManager(globalScope) { + function createPayPalManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + paypalUtils = {}, + } = context; + + let actionInFlight = false; + + function getPayPalAccounts(currentState = state.getLatestState()) { + return helpers.getPayPalAccounts(currentState); + } + + function getCurrentPayPalAccountId(currentState = state.getLatestState()) { + return String(currentState?.currentPayPalAccountId || '').trim(); + } + + function buildSelectOptions(accounts = []) { + if (!accounts.length) { + return ''; + } + return accounts.map((account) => ( + `` + )).join(''); + } + + function applyPayPalAccountMutation(account) { + if (!account?.id) return; + const latestState = state.getLatestState(); + const nextAccounts = typeof paypalUtils.upsertPayPalAccountInList === 'function' + ? paypalUtils.upsertPayPalAccountInList(getPayPalAccounts(latestState), account) + : [...getPayPalAccounts(latestState), account]; + state.syncLatestState({ paypalAccounts: nextAccounts }); + renderPayPalAccounts(); + } + + function renderPayPalAccounts() { + if (!dom.selectPayPalAccount) return; + + const latestState = state.getLatestState(); + const accounts = getPayPalAccounts(latestState); + const currentId = getCurrentPayPalAccountId(latestState); + + dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts); + dom.selectPayPalAccount.disabled = accounts.length === 0; + dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : ''; + } + + async function syncSelectedPayPalAccount(options = {}) { + const { silent = false } = options; + const accountId = String(dom.selectPayPalAccount?.value || '').trim(); + if (!accountId) { + state.syncLatestState({ + currentPayPalAccountId: null, + paypalEmail: '', + paypalPassword: '', + }); + renderPayPalAccounts(); + return null; + } + + const response = await runtime.sendMessage({ + type: 'SELECT_PAYPAL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) { + throw new Error(response.error); + } + + state.syncLatestState({ + currentPayPalAccountId: response.account?.id || accountId, + paypalEmail: String(response.account?.email || '').trim(), + paypalPassword: String(response.account?.password || ''), + }); + renderPayPalAccounts(); + if (!silent) { + helpers.showToast(`已切换当前 PayPal 账号为 ${response.account?.email || accountId}`, 'success', 1800); + } + return response.account || null; + } + + async function openPayPalAccountDialog() { + if (typeof helpers.openFormDialog !== 'function') { + throw new Error('表单弹窗能力未加载,请刷新扩展后重试。'); + } + return helpers.openFormDialog({ + title: '添加 PayPal 账号', + confirmLabel: '保存账号', + confirmVariant: 'btn-primary', + fields: [ + { + key: 'email', + label: 'PayPal 账号', + type: 'text', + placeholder: '请输入 PayPal 登录邮箱', + autocomplete: 'username', + required: true, + requiredMessage: '请先填写 PayPal 账号。', + validate: (value) => { + const normalized = String(value || '').trim(); + if (!normalized.includes('@')) { + return 'PayPal 账号需填写邮箱格式。'; + } + return ''; + }, + }, + { + key: 'password', + label: 'PayPal 密码', + type: 'password', + placeholder: '请输入 PayPal 登录密码', + autocomplete: 'current-password', + required: true, + requiredMessage: '请先填写 PayPal 密码。', + }, + ], + }); + } + + async function handleAddPayPalAccount() { + if (actionInFlight) return; + + const formValues = await openPayPalAccountDialog(); + if (!formValues) { + return; + } + + actionInFlight = true; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = true; + } + + try { + const response = await runtime.sendMessage({ + type: 'UPSERT_PAYPAL_ACCOUNT', + source: 'sidepanel', + payload: { + email: String(formValues.email || '').trim(), + password: String(formValues.password || ''), + }, + }); + if (response?.error) { + throw new Error(response.error); + } + + applyPayPalAccountMutation(response.account); + if (response.account?.id) { + state.syncLatestState({ currentPayPalAccountId: response.account.id }); + renderPayPalAccounts(); + dom.selectPayPalAccount.value = response.account.id; + await syncSelectedPayPalAccount({ silent: true }); + } + helpers.showToast(`已保存 PayPal 账号 ${response.account?.email || ''}`, 'success', 2200); + } catch (error) { + helpers.showToast(`保存 PayPal 账号失败:${error.message}`, 'error'); + throw error; + } finally { + actionInFlight = false; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = false; + } + } + } + + function bindPayPalEvents() { + dom.btnAddPayPalAccount?.addEventListener('click', () => { + void handleAddPayPalAccount(); + }); + dom.selectPayPalAccount?.addEventListener('change', () => { + void syncSelectedPayPalAccount().catch((error) => { + helpers.showToast(error.message, 'error'); + renderPayPalAccounts(); + }); + }); + } + + return { + bindPayPalEvents, + renderPayPalAccounts, + syncSelectedPayPalAccount, + }; + } + + globalScope.SidepanelPayPalManager = { + createPayPalManager, + }; +})(window); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index df0dce6..cf8a5af 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -607,7 +607,12 @@ header { Data Card ============================================================ */ -#data-section { margin-bottom: 14px; } +#data-section { + margin-bottom: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} .data-card { background: var(--bg-surface); @@ -769,6 +774,21 @@ header { gap: 8px; } +#settings-card .data-row.module-divider-start { + position: relative; + margin-top: 10px; + padding-top: 12px; +} + +#settings-card .data-row.module-divider-start::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 1px solid color-mix(in srgb, var(--border) 76%, transparent); +} + .data-check-row { align-items: flex-start; } @@ -785,6 +805,19 @@ header { display: block; } +.ip-proxy-card { + margin-top: 10px; +} + +.ip-proxy-header-actions { + flex: 0 0 auto; + align-items: center; +} + +#btn-toggle-ip-proxy-section { + white-space: nowrap; +} + .ip-proxy-fold { width: 100%; border: none; @@ -801,6 +834,39 @@ header { padding-top: 0; } +.phone-verification-card { + margin-top: 10px; +} + +.phone-verification-header-actions { + flex: 0 0 auto; + align-items: center; +} + +#btn-toggle-phone-verification-section { + white-space: nowrap; +} + +.phone-verification-fold-row { + display: block; +} + +.phone-verification-fold { + width: 100%; + border: none; + border-radius: 0; + background: transparent; + padding: 0; +} + +.phone-verification-fold-body { + display: flex; + flex-direction: column; + gap: 8px; + border-top: none; + padding-top: 0; +} + .ip-proxy-layout-row { display: block; } @@ -858,17 +924,20 @@ header { } } -.ip-proxy-enabled-inline { - justify-content: flex-end; - gap: 10px; -} - .ip-proxy-actions-inline { flex-wrap: wrap; - align-items: center; + align-items: flex-start; row-gap: 6px; } +#row-ip-proxy-actions { + align-items: flex-start; +} + +#row-ip-proxy-actions > .data-label { + padding-top: 9px; +} + .ip-proxy-action-grid { width: 100%; display: flex; @@ -888,42 +957,6 @@ header { color: var(--text-secondary); } -.ip-proxy-enabled-status { - margin-left: auto; - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 6px; - min-width: 92px; - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-align: right; - white-space: nowrap; -} - -.ip-proxy-enabled-status-dot { - width: 7px; - height: 7px; - border-radius: 999px; - background: var(--text-muted); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 25%, transparent); - flex-shrink: 0; -} - -.ip-proxy-enabled-status.is-on { - color: var(--green); -} - -.ip-proxy-enabled-status.is-on .ip-proxy-enabled-status-dot { - background: var(--green); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 30%, transparent); -} - -.ip-proxy-enabled-status.is-off { - color: var(--text-muted); -} - .ip-proxy-runtime-status { display: inline-flex; align-items: flex-start; @@ -946,19 +979,25 @@ header { .ip-proxy-runtime-main { min-width: 0; + font-size: 12px; + line-height: 1.45; } .ip-proxy-runtime-meta { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; } .ip-proxy-check-ip-btn { - min-width: 64px; - padding-inline: 10px; + min-width: 0; + padding-inline: 8px; flex-shrink: 0; + margin-left: 0; + position: absolute; + top: 0; + right: 0; } .ip-proxy-runtime-current { @@ -972,6 +1011,14 @@ header { color: var(--text-primary); } +#row-ip-proxy-runtime-status { + align-items: flex-start; +} + +#row-ip-proxy-runtime-status > .data-label { + padding-top: 9px; +} + .ip-proxy-runtime-dot { width: 8px; height: 8px; @@ -1000,20 +1047,53 @@ header { .ip-proxy-runtime-details { margin: 0; padding: 0; + min-width: 0; + width: 100%; + padding-right: 84px; +} + +.ip-proxy-runtime-details-row { + position: relative; + min-width: 0; + width: 100%; + min-height: 24px; } .ip-proxy-runtime-details summary { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; cursor: pointer; user-select: none; color: var(--text-secondary); font-size: 11px; line-height: 1.4; + list-style: none; +} + +.ip-proxy-runtime-details summary::-webkit-details-marker { + display: none; +} + +.ip-proxy-runtime-details summary::after { + content: '▾'; + font-size: 10px; + line-height: 1; + color: inherit; + transform: rotate(-90deg); + transform-origin: center; + transition: transform var(--transition); } .ip-proxy-runtime-details[open] summary { color: var(--text-primary); } +.ip-proxy-runtime-details[open] summary::after { + transform: rotate(0deg); +} + .ip-proxy-runtime-details-text { margin-top: 4px; font-size: 11px; @@ -1869,6 +1949,271 @@ header { text-align: center; } +.hero-sms-country-stack { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; +} + +.hero-sms-country-mainline { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.hero-sms-country-note { + font-size: 12px; + color: var(--text-muted); +} + +.hero-sms-reuse-max-inline { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: nowrap; +} + +.hero-sms-reuse-max-left { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; +} + +.hero-sms-reuse-max-right { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.hero-sms-max-price-input { + width: 72px; + text-align: center; +} + +.hero-sms-country-menu { + position: relative; + flex: 1; + min-width: 260px; +} + +.hero-sms-country-menu-btn { + width: 100%; + height: 33px; + min-height: 33px; + padding-top: 0; + padding-bottom: 0; + justify-content: flex-start; + overflow: hidden; + text-overflow: ellipsis; +} + +.hero-sms-country-menu-btn[aria-expanded="true"] { + border-color: var(--blue); + color: var(--blue); + background: var(--blue-soft); +} + +.hero-sms-country-menu-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 1200; + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + max-height: 180px; + overflow-y: auto; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); +} + +.hero-sms-country-menu-search { + padding-bottom: 6px; + border-bottom: 1px solid var(--border-subtle); +} + +.hero-sms-country-menu-search-input { + width: 100%; +} + +.hero-sms-country-menu-dropdown[hidden] { + display: none !important; +} + +.hero-sms-country-menu-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; +} + +.hero-sms-country-menu-item-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hero-sms-country-menu-item-badge { + flex: 0 0 auto; + min-width: 42px; + text-align: right; + color: var(--brand); + font-weight: 700; +} + +.hero-sms-runtime-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 12px; +} + +.hero-sms-runtime-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.hero-sms-runtime-cell-span2 { + grid-column: 1 / -1; +} + +.hero-sms-runtime-key { + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; +} + +.hero-sms-runtime-value { + flex: 1 1 auto; + min-width: 0; +} + +.hero-sms-price-preview-stack { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} + +.hero-sms-price-preview-head { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 8px; +} + +#btn-hero-sms-price-preview { + height: 33px; + min-height: 33px; + padding-top: 0; + padding-bottom: 0; + align-self: flex-start; +} + +.hero-sms-price-controls-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 12px; +} + +.hero-sms-price-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.hero-sms-price-control .setting-controls { + margin-left: auto; + width: 104px; + justify-content: flex-start; +} + +.hero-sms-price-control-reuse { + justify-content: space-between; +} + +#row-hero-sms-max-price, +#row-phone-code-settings-group { + align-items: flex-start; +} + +#row-hero-sms-max-price > .data-label, +#row-phone-code-settings-group > .data-label { + padding-top: 9px; +} + +.hero-sms-toggle-controls { + width: 104px; + justify-content: flex-start; +} + +.hero-sms-price-preview-result { + width: 100%; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-surface); + padding: 6px 8px; +} + +.hero-sms-price-preview-text { + display: block; + white-space: pre-line; + line-height: 1.45; +} + +.hero-sms-settings-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 12px; +} + +.hero-sms-settings-cell { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + min-width: 0; +} + +.hero-sms-settings-cell .setting-controls { + margin-left: auto; +} + +.hero-sms-settings-caption { + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; +} + .data-unit { font-size: 12px; font-weight: 600; @@ -2722,6 +3067,40 @@ header { cursor: pointer; } +.modal-form-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 14px; +} + +.modal-form-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal-form-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.modal-form-row .data-input, +.modal-form-row .data-select, +.modal-form-row .data-textarea { + width: 100%; +} + +.modal-form-row .input-with-icon { + width: 100%; +} + +.modal-form-alert { + margin-top: -4px; + margin-bottom: 14px; +} + .modal-actions { display: flex; justify-content: flex-end; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 7db0a30..c693382 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -184,7 +184,13 @@ -
- IP代理 -
+ + +
+ 账户密码 +
+ + +
+
+
+ Plus 模式 +
+
+ +
+ PayPal 订阅链路 +
+
+ +
+ 邮箱服务 +
+ + +
+
+ + +
+ 邮箱生成 + +
+ + + + + + +
+ 注册邮箱 +
+ + +
+
+
+ 延迟 +
+
+ +
+ + 分钟 +
+
+
+ 步间间隔 +
+ + +
+
+
+
+
+ 自动重试 +
+
+ +
+
+ 线程间隔 +
+ + 分钟 +
+
+
+
+
+ OAuth + 等待中... +
+
+ 回调 +
+ 等待中... + +
+
+
+
+
+
+ + 手机号验证与 HeroSMS 获取策略 +
+
+ + +
+
+ +
+
+
+
+ + 用于浏览器代理接管与出口切换 +
+
+ - - - 未开启 -
@@ -331,259 +705,26 @@
- - -
- 账户密码 -
- - -
-
-
- Plus 模式 -
-
- -
- PayPal 订阅链路 -
-
- - -
- 邮箱服务 -
- - -
-
- - -
- 邮箱生成 - -
- - - - - - -
- 注册邮箱 -
- - -
-
-
- 延迟 -
-
- -
- - 分钟 -
-
-
- 步间间隔 -
- - -
-
-
-
-
- 自动重试 -
-
- -
-
- 线程间隔 -
- - 分钟 -
-
-
-
-
- 接码 -
-
- -
-
- 验证码重发 -
- - -
-
-
-
- - - - -
- OAuth - 等待中... -
-
- 回调 -
- 等待中... - -
-
+ +
+ + QQ交流群,便于大家交流 + 最新版本运行日志