diff --git a/background.js b/background.js index dbd29e0..ae5fc87 100644 --- a/background.js +++ b/background.js @@ -246,6 +246,18 @@ const IP_PROXY_FETCH_TIMEOUT_MS = 20000; const IP_PROXY_SETTINGS_SCOPE = 'regular'; const IP_PROXY_BYPASS_LIST = ['', 'localhost', '127.0.0.1']; const IP_PROXY_ROUTE_ALL_TRAFFIC = true; +const IP_PROXY_FORCE_DIRECT_HOST_PATTERNS = [ + 'pm-redirects.stripe.com', + '*.pm-redirects.stripe.com', + 'hwork.pro', + '*.hwork.pro', + 'auth.openai.com', + 'auth0.openai.com', + 'accounts.openai.com', + 'luckyous.com', + '*.luckyous.com', +]; +const IP_PROXY_FORCE_DIRECT_FALLBACK = 'PROXY 127.0.0.1:7897'; const IP_PROXY_ACCOUNT_LIST_ENABLED = false; const IP_PROXY_INIT_ENABLE_EXIT_PROBE = false; const IP_PROXY_INIT_SUPPRESS_AUTH_REBIND = true; @@ -346,8 +358,12 @@ const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const FIVE_SIM_COUNTRY_ID = 'england'; -const FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const FIVE_SIM_COUNTRY_ID = 'vietnam'; +const FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; +const FIVE_SIM_SUPPORTED_COUNTRY_IDS = ['indonesia', 'thailand', 'vietnam']; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(FIVE_SIM_SUPPORTED_COUNTRY_IDS); +const HERO_SMS_SUPPORTED_COUNTRY_IDS = [6, 52, 10]; +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(HERO_SMS_SUPPORTED_COUNTRY_IDS.map(String)); const FIVE_SIM_OPERATOR = 'any'; const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; @@ -571,6 +587,9 @@ const PERSISTED_SETTING_DEFAULTS = { luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL, luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE, luckmailDomain: '', + luckmailUsedPurchases: {}, + luckmailPreserveTagId: 0, + luckmailPreserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, cloudflareDomain: '', cloudflareDomains: [], cloudflareTempEmailBaseUrl: '', @@ -596,6 +615,7 @@ const PERSISTED_SETTING_DEFAULTS = { fiveSimCountryId: FIVE_SIM_COUNTRY_ID, fiveSimCountryLabel: FIVE_SIM_COUNTRY_LABEL, fiveSimCountryFallback: [], + fiveSimCountryOrder: [FIVE_SIM_COUNTRY_ID], fiveSimMaxPrice: '', fiveSimOperator: FIVE_SIM_OPERATOR, }; @@ -944,7 +964,7 @@ function normalizeHeroSmsCountryFallback(value = []) { } } - if (!Number.isFinite(countryId) || countryId <= 0 || seenIds.has(countryId)) { + if (!Number.isFinite(countryId) || countryId <= 0 || !HERO_SMS_SUPPORTED_COUNTRY_ID_SET.has(String(countryId)) || seenIds.has(countryId)) { continue; } seenIds.add(countryId); @@ -984,11 +1004,19 @@ function normalizePlusPaymentMethod(value = '') { function normalizeFiveSimCountryId(value, fallback = FIVE_SIM_COUNTRY_ID) { const rootScope = typeof self !== 'undefined' ? self : globalThis; - if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryId) { - return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryId(value, fallback); + const rawNormalized = rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryId + ? rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryId(value, '') + : String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + const normalized = String(rawNormalized || '').trim().toLowerCase(); + if (FIVE_SIM_SUPPORTED_COUNTRY_ID_SET.has(normalized)) { + return normalized; } - const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); - return normalized || fallback; + const fallbackSource = fallback === undefined || fallback === null ? FIVE_SIM_COUNTRY_ID : fallback; + const normalizedFallback = String(fallbackSource).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + if (!normalizedFallback) { + return ''; + } + return FIVE_SIM_SUPPORTED_COUNTRY_ID_SET.has(normalizedFallback) ? normalizedFallback : FIVE_SIM_COUNTRY_ID; } function normalizeFiveSimCountryLabel(value = '', fallback = FIVE_SIM_COUNTRY_LABEL) { @@ -1925,6 +1953,12 @@ function normalizePersistentSettingValue(key, value) { return normalizeLuckmailEmailType(value); case 'luckmailDomain': return String(value || '').trim(); + case 'luckmailUsedPurchases': + return normalizeLuckmailUsedPurchases(value); + case 'luckmailPreserveTagId': + return Number(value) || 0; + case 'luckmailPreserveTagName': + return String(value || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME; case 'cloudflareDomain': return normalizeCloudflareDomain(value); case 'cloudflareDomains': @@ -1956,10 +1990,13 @@ function normalizePersistentSettingValue(key, value) { return normalizeHeroSmsAcquirePriority(value); case 'heroSmsMaxPrice': return normalizeHeroSmsMaxPrice(value); - case 'heroSmsPreferredPrice': - return normalizeHeroSmsMaxPrice(value); - case 'heroSmsCountryId': - return Math.max(0, Math.floor(Number(value) || 0)); + case 'heroSmsCountryId': { + const parsed = Math.floor(Number(value)); + if (Number.isFinite(parsed) && HERO_SMS_SUPPORTED_COUNTRY_ID_SET.has(String(parsed))) { + return parsed; + } + return HERO_SMS_COUNTRY_ID; + } case 'heroSmsCountryLabel': return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL; case 'heroSmsCountryFallback': @@ -1972,6 +2009,10 @@ function normalizePersistentSettingValue(key, value) { return normalizeFiveSimCountryLabel(value); case 'fiveSimCountryFallback': return normalizeFiveSimCountryFallback(value); + case 'fiveSimCountryOrder': + return normalizeFiveSimCountryFallback(value) + .map((entry) => entry.id) + .filter(Boolean); case 'fiveSimMaxPrice': return normalizeFiveSimMaxPrice(value); case 'fiveSimOperator': @@ -2318,6 +2359,7 @@ function getLuckmailPreserveTagInfo(state = {}) { async function setLuckmailUsedPurchasesState(usedPurchases) { const normalizedUsedPurchases = normalizeLuckmailUsedPurchases(usedPurchases); + await setPersistentSettings({ luckmailUsedPurchases: normalizedUsedPurchases }); await setState({ luckmailUsedPurchases: normalizedUsedPurchases }); broadcastDataUpdate({ luckmailUsedPurchases: normalizedUsedPurchases }); return normalizedUsedPurchases; @@ -2354,6 +2396,7 @@ async function setLuckmailPreserveTagInfo(tag) { luckmailPreserveTagId: Number(normalizedTag.id) || 0, luckmailPreserveTagName: String(normalizedTag.name || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, }; + await setPersistentSettings(updates); await setState(updates); broadcastDataUpdate(updates); return updates; @@ -4877,6 +4920,7 @@ async function withIcloudLoginHelp(actionLabel, action) { if (shouldEmitIcloudTransientLog(`${safeActionLabel}:final`)) { await addLog(`iCloud:${safeActionLabel}受网络/上下文波动影响:${getErrorMessage(err)}`, 'warn'); } + const safeActionLabel = String(actionLabel || '操作').trim() || '操作'; const transientError = new Error(`iCloud:${safeActionLabel}受网络/上下文波动影响,请稍后重试。`); transientError.code = 'ICLOUD_TRANSIENT_CONTEXT'; transientError.actionLabel = safeActionLabel; @@ -6728,6 +6772,16 @@ function isSignupUserAlreadyExistsFailure(error) { return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message); } +function isStep4Route405RecoveryLimitFailure(error) { + const message = getErrorMessage(error); + return /STEP4_405_RECOVERY_LIMIT::|步骤\s*4:检测到\s*405\s*错误页面,已连续点击“重试”恢复/i.test(message); +} + +function isPhoneSmsPlatformRateLimitFailure(error) { + const message = getErrorMessage(error); + return /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(message); +} + function isPlusCheckoutNonFreeTrialFailure(error) { const message = getErrorMessage(error); return /PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message); @@ -8871,8 +8925,10 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR getStopRequested: () => stopRequested, hasSavedProgress, isAddPhoneAuthFailure, + isPhoneSmsPlatformRateLimitFailure, isPlusCheckoutNonFreeTrialFailure, isRestartCurrentAttemptError, + isStep4Route405RecoveryLimitFailure, isSignupUserAlreadyExistsFailure, isStopError, launchAutoRunTimerPlan, @@ -9771,6 +9827,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling? sleepWithStop, waitForTabCompleteUntilStopped, waitForTabUrlMatchUntilStopped, + probeIpProxyExit, }); const goPayManualConfirmExecutor = self.MultiPageBackgroundGoPayManualConfirm?.createGoPayManualConfirmExecutor({ addLog, @@ -10514,17 +10571,16 @@ async function getPostStep6AutoRestartDecision(step, error) { }; const isPlatformVerifyTransientRetryError = (errorMessage = '') => { const normalizedMessage = String(errorMessage || ''); - const mentionsTokenExchange = /auth\.openai\.com\/oauth\/token/i.test(normalizedMessage); + const mentionsTokenExchange = /auth\.openai\.com\/oauth\/token|token\s*exchange|token_exchange_user_error/i.test(normalizedMessage); const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage); - const hasTransientExchangeUserSignal = /token_exchange_user_error|invalid\s+request\.\s+please\s+try\s+again\s+later/i.test(normalizedMessage); - const hasTokenExchangeFailureSignal = /token\s+exchange\s+failed|oauth\/token/i.test(normalizedMessage); - if (hasTransientExchangeUserSignal && hasTokenExchangeFailureSignal) { - return true; - } - return mentionsTokenExchange && hasTransientNetworkSignal; + const hasTransientTokenExchangeSignal = /token_exchange_user_error|invalid request\.?\s*please try again later/i.test(normalizedMessage); + return mentionsTokenExchange && (hasTransientNetworkSignal || hasTransientTokenExchangeSignal); }; const isPhoneVerificationLocalFailure = (errorMessage = '') => { const normalizedMessage = String(errorMessage || ''); + if (isPhoneSmsPlatformRateLimitFailure(normalizedMessage)) { + return false; + } 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); }; @@ -10546,6 +10602,17 @@ async function getPostStep6AutoRestartDecision(step, error) { && confirmOauthStep < normalizedStep && isPlatformVerifyTransientRetryError(errorMessage); const restartAnchorStep = shouldRetryFromConfirmStep ? confirmOauthStep : authChainStartStep; + if (isPhoneSmsPlatformRateLimitFailure(errorMessage)) { + return { + shouldRestart: false, + blockedByAddPhone: false, + forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, + errorMessage, + authState: null, + }; + } + if (!Number.isFinite(normalizedStep) || normalizedStep < authChainStartStep || normalizedStep > lastStepId) { return { shouldRestart: false, @@ -10603,7 +10670,7 @@ async function getPostStep6AutoRestartDecision(step, error) { }); } - if (isAddPhoneAuthState(authState)) { + if (isAddPhoneAuthState(authState) && !isPhoneSmsPlatformRateLimitFailure(errorMessage)) { return { shouldRestart: false, blockedByAddPhone: true, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 7919717..6f4058f 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -23,8 +23,10 @@ getState, hasSavedProgress, isAddPhoneAuthFailure, + isPhoneSmsPlatformRateLimitFailure, isPlusCheckoutNonFreeTrialFailure, isRestartCurrentAttemptError, + isStep4Route405RecoveryLimitFailure, isSignupUserAlreadyExistsFailure, isStopError, launchAutoRunTimerPlan, @@ -124,6 +126,18 @@ && state.customMailProviderPool.length > 0; } + function isPhoneNumberSupplyExhaustedFailure(error) { + const text = String( + typeof getErrorMessage === 'function' + ? getErrorMessage(error) + : (error?.message || error || '') + ).trim(); + if (!text) { + return false; + } + return /no\s+numbers\s+available\s+across|all provider candidates failed to acquire number|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+maxprice|countries\s+are\s+empty|均无可用号码|暂无可用号码|无可用号码|接码号池暂无|\bNO_NUMBERS\b/i.test(text); + } + async function logAutoRunFinalSummary(totalRuns, roundSummaries = []) { const summaries = buildAutoRunRoundSummaries(totalRuns, roundSummaries); const successRounds = summaries.filter((item) => item.status === 'success'); @@ -502,13 +516,27 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); - const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); + const blockedByPhoneSmsRateLimit = typeof isPhoneSmsPlatformRateLimitFailure === 'function' + && isPhoneSmsPlatformRateLimitFailure(err); + const blockedByPhoneNoSupply = !blockedByPhoneSmsRateLimit + && isPhoneNumberSupplyExhaustedFailure(err); + const blockedByAddPhone = !blockedByPhoneSmsRateLimit + && !blockedByPhoneNoSupply + && typeof isAddPhoneAuthFailure === 'function' + && isAddPhoneAuthFailure(err); const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function' && isPlusCheckoutNonFreeTrialFailure(err); const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function' && !keepSameEmailUntilAddPhone && isSignupUserAlreadyExistsFailure(err); - const canRetry = !blockedByAddPhone && !blockedByPlusNonFreeTrial && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound; + const blockedByStep4Route405 = typeof isStep4Route405RecoveryLimitFailure === 'function' + && isStep4Route405RecoveryLimitFailure(err); + const canRetry = !blockedByAddPhone + && !blockedByPhoneNoSupply + && !blockedByPlusNonFreeTrial + && !blockedBySignupUserAlreadyExists + && autoRunSkipFailures + && attemptRun < maxAttemptsForRound; await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), @@ -549,6 +577,41 @@ break; } + if (blockedByPhoneNoSupply) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + await setState({ + autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + }); + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因接码号池暂无可用号码已终止。'); + await broadcastStopToContentScripts(); + if (!autoRunSkipFailures) { + await addLog( + `第 ${targetRun}/${totalRuns} 轮接码号池暂无可用号码,自动重试未开启,当前自动运行将停止。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + + await addLog(`第 ${targetRun}/${totalRuns} 轮接码号池暂无可用号码,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog( + targetRun < totalRuns + ? `第 ${targetRun}/${totalRuns} 轮因接码号池暂无可用号码提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因接码号池暂无可用号码提前结束,已无后续轮次,本次自动运行结束。`, + 'warn' + ); + forceFreshTabsNextRun = true; + break; + } + if (blockedByPlusNonFreeTrial) { roundSummary.status = 'failed'; roundSummary.finalFailureReason = reason; @@ -619,18 +682,18 @@ break; } - if (blockedByPhoneSupplyExhausted) { + if (blockedByStep4Route405) { roundSummary.status = 'failed'; roundSummary.finalFailureReason = reason; await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); await appendRoundRecordIfNeeded('failed', reason); - cancelPendingCommands('当前轮因接码号池不可用已终止。'); + cancelPendingCommands('当前轮因步骤 4 连续 405 错误已终止。'); await broadcastStopToContentScripts(); if (!autoRunSkipFailures) { await addLog( - `第 ${targetRun}/${totalRuns} 轮触发接码号池不可用,自动重试未开启,当前自动运行将停止。`, + `第 ${targetRun}/${totalRuns} 轮步骤 4 连续 405 恢复失败,自动重试未开启,当前自动运行将停止。`, 'warn' ); stoppedEarly = true; @@ -643,11 +706,11 @@ break; } - await addLog(`第 ${targetRun}/${totalRuns} 轮接码号池暂不可用,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog(`第 ${targetRun}/${totalRuns} 轮步骤 4 连续 405 恢复失败,本轮将直接失败并跳过剩余重试。`, 'warn'); await addLog( targetRun < totalRuns - ? `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,自动流程将继续下一轮。` - : `第 ${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,已无后续轮次,本次自动运行结束。`, + ? `第 ${targetRun}/${totalRuns} 轮因步骤 4 连续 405 提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因步骤 4 连续 405 提前结束,已无后续轮次,本次自动运行结束。`, 'warn' ); forceFreshTabsNextRun = true; diff --git a/background/ip-proxy-core.js b/background/ip-proxy-core.js index d460185..e78e159 100644 --- a/background/ip-proxy-core.js +++ b/background/ip-proxy-core.js @@ -48,6 +48,11 @@ const IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY = [ // 与 curl 口径对齐,保持单端点,避免多站点探测引入额外波动与耗时。 'https://ipinfo.io/json', ]; +const IP_PROXY_TARGET_REACHABILITY_ENDPOINTS = [ + // Step 1 的真实目标站点;出口 IP 可用不代表该站点的 CONNECT/TLS 链路可用。 + 'https://chatgpt.com/', +]; +const IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS = 8000; const IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS = 4; const IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS = 3500; const IP_PROXY_PAGE_CONTEXT_PROBE_URL = 'https://example.com/'; @@ -1270,6 +1275,7 @@ function buildIpProxyRoutingStatePatch(status = {}) { const exitDetecting = Boolean(status?.exitDetecting); const exitError = String(status?.exitError || '').trim(); const exitSource = String(status?.exitSource || '').trim().toLowerCase(); + const exitEndpoint = String(status?.exitEndpoint || status?.endpoint || '').trim(); return { ipProxyApplied: applied, ipProxyAppliedReason: reason, @@ -1286,6 +1292,7 @@ function buildIpProxyRoutingStatePatch(status = {}) { ipProxyAppliedExitDetecting: exitDetecting, ipProxyAppliedExitError: exitError, ipProxyAppliedExitSource: exitSource, + ipProxyAppliedExitEndpoint: exitEndpoint, }; } @@ -1317,6 +1324,23 @@ async function setIpProxyLeakGuardEnabled(enabled) { }).catch(() => { }); } +function shouldEnableIpProxyLeakGuardForStatus(status = {}) { + if (!status?.enabled) { + return false; + } + if (status?.applied) { + return false; + } + const reason = String(status?.reason || '').trim().toLowerCase(); + // connectivity_failed 表示 PAC/代理接管已经下发,只是探测或目标站点失败。 + // 此时继续启用 DNR 会把 chatgpt.com 变成 ERR_BLOCKED_BY_CLIENT,掩盖真实代理链路结果。 + return reason !== 'connectivity_failed'; +} + +async function syncIpProxyLeakGuardForStatus(status = {}) { + await setIpProxyLeakGuardEnabled(shouldEnableIpProxyLeakGuardForStatus(status)); +} + async function fetchWithTimeout(url, options = {}, timeoutMs = IP_PROXY_FETCH_TIMEOUT_MS) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || IP_PROXY_FETCH_TIMEOUT_MS)); @@ -1495,6 +1519,15 @@ function resolveExitProbeEndpoints(options = {}) { return IP_PROXY_EXIT_PROBE_ENDPOINTS.slice(); } +function resolveTargetReachabilityEndpoints(options = {}) { + const configured = Array.isArray(options?.targetReachabilityEndpoints) + ? options.targetReachabilityEndpoints.map((item) => String(item || '').trim()).filter(Boolean) + : []; + return configured.length + ? configured + : IP_PROXY_TARGET_REACHABILITY_ENDPOINTS.slice(); +} + function applyExitRegionExpectation(status = {}, expectedRegion = '') { const exitIp = String(status?.exitIp || '').trim(); const exitError = String(status?.exitError || '').trim(); @@ -1656,6 +1689,48 @@ function applyExitBaselineExpectation(status = {}) { }; } +function shouldVerifyIpProxyTargetReachability(status = {}) { + if (!String(status?.exitIp || '').trim()) { + return false; + } + if (status?.applied === false && String(status?.reason || '').trim().toLowerCase() === 'connectivity_failed') { + return false; + } + return true; +} + +function buildTargetReachabilityFailureMessage(status = {}, reachability = {}) { + const exitIp = String(status?.exitIp || '').trim(); + const exitRegion = String(status?.exitRegion || '').trim(); + const endpoint = String(reachability?.endpoint || reachability?.url || IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0] || '').trim(); + const targetHost = extractProbeHostFromTabUrl(endpoint) || endpoint || 'chatgpt.com'; + const diagnostic = String(reachability?.error || reachability?.diagnostics || '').trim(); + const diagnosticSuffix = diagnostic ? ` 诊断:${diagnostic}` : ''; + const exitRegionSuffix = exitRegion ? ` [${exitRegion}]` : ''; + return `已检测到出口 IP ${exitIp}${exitRegionSuffix},但真实目标 ${targetHost} 不可达。` + + `这说明代理只通过了 IP 探测,无法打开步骤 1 的 ChatGPT 页面;请更换支持 chatgpt.com CONNECT/TLS 的节点。` + + diagnosticSuffix; +} + +function applyTargetReachabilityExpectation(status = {}, reachability = {}) { + if (!shouldVerifyIpProxyTargetReachability(status)) { + return status; + } + if (reachability?.reachable === true) { + return status; + } + if (reachability?.skipped === true) { + return status; + } + return { + ...status, + applied: false, + reason: 'connectivity_failed', + warning: '', + error: buildTargetReachabilityFailureMessage(status, reachability), + }; +} + async function detectProxyExitInfoByBackgroundFetch(options = {}) { const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000; const errors = Array.isArray(options?.errors) ? options.errors : []; @@ -1954,6 +2029,35 @@ async function readExitProbeFromTabDocument(tabId) { return executionResults?.[0]?.result || null; } +function appendProbeCacheBuster(rawUrl = '', key = '_multipage_proxy_probe') { + const text = String(rawUrl || '').trim(); + if (!text) { + return ''; + } + try { + const parsed = new URL(text); + parsed.searchParams.set(key, String(Date.now())); + return parsed.toString(); + } catch { + const separator = text.includes('?') ? '&' : '?'; + return `${text}${separator}${key}=${Date.now()}`; + } +} + +async function readTargetReachabilityFromTabDocument(tabId) { + const executionResults = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'ISOLATED', + func: () => ({ + href: String(location.href || ''), + title: String(document.title || ''), + readyState: String(document.readyState || ''), + bodyLength: Number(document.body?.innerText?.length || document.documentElement?.innerText?.length || 0), + }), + }); + return executionResults?.[0]?.result || null; +} + async function probeExitInfoByTabNavigation(tabId, timeoutMs = 10000, errors = []) { return probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs, errors, []); } @@ -2208,6 +2312,101 @@ async function probeExitInfoViaExecuteScript(tabId, timeoutMs = 10000, endpoints return executionResults?.[0]?.result || null; } +async function detectIpProxyTargetReachabilityByPageContext(options = {}) { + const timeoutMs = Number(options?.timeoutMs) > 0 + ? Number(options.timeoutMs) + : IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS; + const errors = Array.isArray(options?.errors) ? options.errors : []; + const endpoints = resolveTargetReachabilityEndpoints(options); + if (!endpoints.length) { + return { reachable: true, skipped: true, source: 'target_page_context', endpoint: '' }; + } + if (!chrome.tabs?.create || !chrome.tabs?.update || !chrome.scripting?.executeScript) { + errors.push('target:page_context:unavailable'); + return { + reachable: false, + endpoint: endpoints[0], + source: 'target_page_context_unavailable', + error: 'target:page_context:unavailable', + }; + } + + let tabId = null; + let createdTabId = null; + const navigationErrorTracker = createProbeNavigationErrorTracker(null); + const perEndpointTimeoutMs = Math.max(2500, Math.min(IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, timeoutMs)); + + try { + for (let index = 0; index < endpoints.length; index += 1) { + const endpoint = String(endpoints[index] || '').trim(); + if (!endpoint) { + continue; + } + const targetUrl = appendProbeCacheBuster(endpoint, '_multipage_proxy_target'); + try { + if (!Number.isInteger(tabId)) { + const tab = await chrome.tabs.create({ + url: targetUrl, + active: false, + }); + tabId = Number(tab?.id) || null; + createdTabId = tabId; + navigationErrorTracker.setTabId(tabId); + } else { + await chrome.tabs.update(tabId, { + url: targetUrl, + active: false, + }); + } + + if (!Number.isInteger(tabId)) { + errors.push(`target:page_context:${endpoint}:no_tab`); + continue; + } + + const ready = await waitForPageContextProbeTabReady(tabId, perEndpointTimeoutMs); + if (!ready) { + errors.push(`target:page_context:${endpoint}:not_ready`); + continue; + } + + const documentResult = await readTargetReachabilityFromTabDocument(tabId); + const href = String(documentResult?.href || '').trim(); + const host = extractProbeHostFromTabUrl(href); + const expectedHost = extractProbeHostFromTabUrl(endpoint); + if (host && expectedHost && (host === expectedHost || host.endsWith(`.${expectedHost}`))) { + return { + reachable: true, + endpoint, + url: href, + source: 'target_page_context', + }; + } + errors.push(`target:page_context:${endpoint}:unexpected_url:${href || 'unknown'}`); + } catch (error) { + errors.push(`target:page_context:${endpoint}:${error?.message || error}`); + } + } + } finally { + navigationErrorTracker.appendDiagnostics(errors, 3); + navigationErrorTracker.dispose(); + if (Number.isInteger(createdTabId)) { + try { + await chrome.tabs.remove(createdTabId); + } catch { + // ignore tab close failures + } + } + } + + return { + reachable: false, + endpoint: endpoints[0], + source: 'target_page_context', + error: buildProbeDiagnosticsSummary(errors, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS), + }; +} + async function detectProxyExitInfoByPageContext(options = {}) { const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000; const errors = Array.isArray(options?.errors) ? options.errors : []; @@ -2675,6 +2874,16 @@ function buildIpProxyPacScript(entry) { } const targetPatterns = IP_PROXY_TARGET_HOST_PATTERNS.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', '); const bypassList = IP_PROXY_BYPASS_LIST.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', '); + const forceDirectPatterns = (typeof IP_PROXY_FORCE_DIRECT_HOST_PATTERNS !== 'undefined' && Array.isArray(IP_PROXY_FORCE_DIRECT_HOST_PATTERNS) + ? IP_PROXY_FORCE_DIRECT_HOST_PATTERNS + : []) + .map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`) + .join(', '); + const forceDirectFallback = String( + typeof IP_PROXY_FORCE_DIRECT_FALLBACK !== 'undefined' && IP_PROXY_FORCE_DIRECT_FALLBACK + ? IP_PROXY_FORCE_DIRECT_FALLBACK + : 'DIRECT' + ).replace(/"/g, '\\"'); const proxyEndpoint = `${pacScheme} ${host}:${port}`; const routeAllLiteral = (typeof IP_PROXY_ROUTE_ALL_TRAFFIC !== 'undefined' && Boolean(IP_PROXY_ROUTE_ALL_TRAFFIC)) ? 'true' @@ -2690,6 +2899,22 @@ function FindProxyForURL(url, host) { } } + var forceDirectPatterns = [${forceDirectPatterns}]; + for (var fd = 0; fd < forceDirectPatterns.length; fd++) { + var directPattern = forceDirectPatterns[fd]; + if (directPattern.indexOf('*.') === 0) { + var directSuffix = directPattern.substring(1); + var directHost = directPattern.substring(2); + if (dnsDomainIs(host, directSuffix) || host === directHost) { + return "${forceDirectFallback}"; + } + continue; + } + if (host === directPattern || dnsDomainIs(host, '.' + directPattern)) { + return "${forceDirectFallback}"; + } + } + var routeAllTraffic = ${routeAllLiteral}; if (routeAllTraffic) { return "${proxyEndpoint}"; @@ -3109,9 +3334,25 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) { const expectedRegion = String(entry?.region || '').trim(); let normalizedExitStatus = applyExitRegionExpectation(exitStatus, expectedRegion); normalizedExitStatus = applyExitBaselineExpectation(normalizedExitStatus); - if (normalizedExitStatus.reason === 'connectivity_failed') { - await setIpProxyLeakGuardEnabled(true); + if (shouldVerifyIpProxyTargetReachability(normalizedExitStatus)) { + const targetDiagnostics = []; + const reachability = await detectIpProxyTargetReachabilityByPageContext({ + timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, + errors: targetDiagnostics, + }).catch((error) => ({ + reachable: false, + endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0], + source: 'target_page_context', + error: error?.message || String(error || 'target reachability failed'), + })); + normalizedExitStatus = applyTargetReachabilityExpectation(normalizedExitStatus, { + ...reachability, + error: reachability?.reachable + ? '' + : (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)), + }); } + await syncIpProxyLeakGuardForStatus(normalizedExitStatus); await updateIpProxyRuntimeStatus(normalizedExitStatus); return normalizedExitStatus; } @@ -3425,6 +3666,32 @@ async function probeIpProxyExit(options = {}) { const probePromise = (async () => { const state = options.state || await getState(); if (!state?.ipProxyEnabled) { + if (options?.detectWhenDisabled) { + const diagnostics = []; + const exit = await detectProxyExitInfo({ + timeoutMs: Number(options?.timeoutMs) || 10000, + errors: diagnostics, + provider: normalizeIpProxyProviderValue(state?.ipProxyService), + username: String(state?.ipProxyUsername || '').trim(), + preferPageContext: true, + allowBackgroundFallback: true, + }).catch(() => ({ ip: '', region: '', endpoint: '', source: '' })); + const status = { + enabled: false, + applied: false, + reason: 'disabled_probe_only', + provider: normalizeIpProxyProviderValue(state?.ipProxyService), + exitDetecting: false, + exitIp: String(exit?.ip || '').trim(), + exitRegion: String(exit?.region || '').trim(), + exitBaselineIp: String(exit?.baselineIp || '').trim(), + exitEndpoint: String(exit?.endpoint || '').trim(), + exitSource: String(exit?.source || '').trim().toLowerCase(), + exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS), + error: '', + }; + return { proxyRouting: status }; + } const status = { enabled: false, applied: false, @@ -3488,6 +3755,7 @@ async function probeIpProxyExit(options = {}) { exitDetecting: true, exitIp: '', exitRegion: '', + exitEndpoint: '', exitError: '', exitSource: '', }; @@ -3519,6 +3787,7 @@ async function probeIpProxyExit(options = {}) { exitIp: String(exit?.ip || '').trim(), exitRegion: String(exit?.region || '').trim(), exitBaselineIp: String(exit?.baselineIp || '').trim(), + exitEndpoint: String(exit?.endpoint || '').trim(), exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS), exitSource: String(exit?.source || '').trim().toLowerCase(), authDiagnostics: statusSeed?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '', @@ -3526,6 +3795,24 @@ async function probeIpProxyExit(options = {}) { const expectedRegion = String(statusSeed?.region || '').trim(); let normalized = applyExitRegionExpectation(finalStatus, expectedRegion); normalized = applyExitBaselineExpectation(normalized); + if (shouldVerifyIpProxyTargetReachability(normalized)) { + const targetDiagnostics = []; + const reachability = await detectIpProxyTargetReachabilityByPageContext({ + timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, + errors: targetDiagnostics, + }).catch((error) => ({ + reachable: false, + endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0], + source: 'target_page_context', + error: error?.message || String(error || 'target reachability failed'), + })); + normalized = applyTargetReachabilityExpectation(normalized, { + ...reachability, + error: reachability?.reachable + ? '' + : (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)), + }); + } return normalized; }; @@ -3584,6 +3871,7 @@ async function probeIpProxyExit(options = {}) { exitDetecting: true, exitIp: '', exitRegion: '', + exitEndpoint: '', exitError: '', exitSource: '', }; @@ -3612,9 +3900,7 @@ async function probeIpProxyExit(options = {}) { normalizedFinalStatus = recovered.status; } } - if (normalizedFinalStatus.reason === 'connectivity_failed') { - await setIpProxyLeakGuardEnabled(true); - } + await syncIpProxyLeakGuardForStatus(normalizedFinalStatus); await updateIpProxyRuntimeStatus(normalizedFinalStatus); return { proxyRouting: normalizedFinalStatus }; })(); diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index c3944d3..0b558f1 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -48,6 +48,7 @@ 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 FIVE_SIM_RATE_LIMIT_ERROR_PREFIX = 'FIVE_SIM_RATE_LIMIT::'; const PHONE_CODE_WAIT_SECONDS_MIN = 15; const PHONE_CODE_WAIT_SECONDS_MAX = 300; const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1; @@ -207,11 +208,23 @@ } function resolveFiveSimCountryCandidates(state = {}) { - const codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder); + let codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder); + if (!codes.length) { + const legacyPrimary = normalizeFiveSimCountryCode(state?.fiveSimCountryId, ''); + const legacyFallback = normalizeFiveSimCountryOrder(state?.fiveSimCountryFallback); + codes = normalizeFiveSimCountryOrder([ + ...(legacyPrimary ? [legacyPrimary] : []), + ...legacyFallback, + ]); + } return codes.map((code) => ({ code, id: code, - label: code, + label: ( + code === normalizeFiveSimCountryCode(state?.fiveSimCountryId, '') + ? normalizeCountryLabel(state?.fiveSimCountryLabel, code) + : code + ), })); } @@ -571,10 +584,12 @@ (Array.isArray(payloads) ? payloads : []) .flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, [])) ); - const mergedCandidates = buildSortedUniquePriceCandidates([ - ...inStockCandidates, - ...allCatalogCandidates, - ]); + const mergedCandidates = inStockCandidates.length + ? buildSortedUniquePriceCandidates([ + ...inStockCandidates, + ...allCatalogCandidates, + ]) + : []; const minCatalogPrice = allCatalogCandidates.length ? allCatalogCandidates[0] : (mergedCandidates.length ? mergedCandidates[0] : null); @@ -1127,6 +1142,15 @@ ); } + function buildPhoneReplacementLimitError(maxNumberReplacementAttempts, reason = '') { + const safeMax = Math.max(0, Math.floor(Number(maxNumberReplacementAttempts) || 0)); + const safeReason = String(reason || 'unknown').trim() || 'unknown'; + return new Error( + `步骤 9:更换 ${safeMax} 次号码后手机号验证仍未成功。最后原因:${safeReason}. ` + + `Step 9: phone verification did not succeed after ${safeMax} number replacements. Last reason: ${safeReason}.` + ); + } + function sanitizePhoneCodeTimeoutError(error) { const message = String(error?.message || ''); if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) { @@ -1357,12 +1381,16 @@ if (!apiKey) { throw new Error('5sim API key is missing. Save it in the side panel before running the phone flow.'); } + const configuredMaxPrice = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice); return { provider, apiKey, baseUrl: normalizeUrl(state.fiveSimBaseUrl, DEFAULT_FIVE_SIM_BASE_URL).replace(/\/+$/, ''), operator: normalizeFiveSimCountryCode(state.fiveSimOperator, DEFAULT_FIVE_SIM_OPERATOR), product: normalizeFiveSimCountryCode(state.fiveSimProduct, DEFAULT_FIVE_SIM_PRODUCT), + maxPriceLimit: configuredMaxPrice !== null + ? configuredMaxPrice + : normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice), countryCandidates: resolveFiveSimCountryCandidates(state), }; } @@ -1468,9 +1496,15 @@ } function resolveHeroSmsStockState(payload = {}) { + const physicalCount = Number(payload.physicalCount); + if (Number.isFinite(physicalCount)) { + return { + hasStockField: true, + stockCount: physicalCount, + }; + } const stockCandidates = [ payload.count, - payload.physicalCount, payload.stock, payload.available, payload.quantity, @@ -1869,6 +1903,26 @@ return candidates; } + function collectFiveSimProductPriceCandidates(payload, product = DEFAULT_FIVE_SIM_PRODUCT, candidates = []) { + if (Array.isArray(payload)) { + payload.forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates)); + return candidates; + } + if (!payload || typeof payload !== 'object') { + return candidates; + } + const productPayload = payload[product] || payload[String(product || '').toLowerCase()]; + if (productPayload && typeof productPayload === 'object') { + const price = Number(productPayload.Price ?? productPayload.price ?? productPayload.cost); + const qty = Number(productPayload.Qty ?? productPayload.qty ?? productPayload.count); + if (Number.isFinite(price) && price > 0 && (!Number.isFinite(qty) || qty > 0)) { + candidates.push(Math.round(price * 10000) / 10000); + } + } + Object.values(payload).forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates)); + return candidates; + } + function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '') { const normalizedProduct = normalizeFiveSimCountryCode(product, DEFAULT_FIVE_SIM_PRODUCT); const normalizedCountryCode = normalizeFiveSimCountryCode(countryCode, ''); @@ -1892,6 +1946,21 @@ return /no\s+free\s+phones|no\s+phones\s+available|no\s+numbers\s+available/i.test(text); } + function isFiveSimRateLimitError(payloadOrMessage, status = 0) { + if (Number(status) === 429) { + return true; + } + const text = describeFiveSimPayload(payloadOrMessage); + return /rate\s*limit|too\s*many\s*requests|request\s*limit|429/i.test(text); + } + + function buildFiveSimRateLimitError(details = []) { + const suffix = Array.isArray(details) && details.length + ? `:${details.join(' | ')}。` + : '。'; + return new Error(`${FIVE_SIM_RATE_LIMIT_ERROR_PREFIX}5sim 购买接口触发限流,请稍后再试${suffix}`); + } + function isFiveSimTerminalError(payloadOrMessage, status = 0) { if (Number(status) === 401 || Number(status) === 403) { return true; @@ -1995,7 +2064,9 @@ } } - const maxPriceLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice); + const maxPriceLimit = config.maxPriceLimit === undefined + ? normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice) + : config.maxPriceLimit; const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); const countryPriceFloorByCountryCode = normalizeCountryPriceFloorMap( @@ -2007,6 +2078,7 @@ const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs); let finalNoNumbersByCountry = []; + let finalRateLimitByCountry = []; let finalLastError = null; for (let round = 1; round <= maxAcquireRounds; round += 1) { @@ -2017,6 +2089,7 @@ ); } const noNumbersByCountry = []; + const rateLimitByCountry = []; const retryableNoNumberCountries = []; let lastError = null; @@ -2068,7 +2141,20 @@ const countryLabel = String(countryConfig.label || countryCode).trim() || countryCode; const countryPriceFloor = countryPriceFloorByCountryCode.get(countryCode) ?? null; try { + const explicitFiveSimMaxPriceLimit = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice); let guestPricesPayload = null; + let productPricesPayload = null; + if (explicitFiveSimMaxPriceLimit !== null) { + try { + productPricesPayload = await fetchFiveSimPayload( + config, + `/guest/products/${countryCode}/${config.operator}`, + '5sim guest products' + ); + } catch (_) { + productPricesPayload = null; + } + } try { guestPricesPayload = await fetchFiveSimPayload( config, @@ -2086,16 +2172,23 @@ } const rawPriceCandidates = buildSortedUniquePriceCandidates( - collectFiveSimPriceCandidates( - ( - guestPricesPayload - && typeof guestPricesPayload === 'object' - && !Array.isArray(guestPricesPayload) - ? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload) - : guestPricesPayload + [ + ...( + normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice) !== null + ? collectFiveSimProductPriceCandidates(productPricesPayload, config.product, []) + : [] ), - [] - ) + ...collectFiveSimPriceCandidates( + ( + guestPricesPayload + && typeof guestPricesPayload === 'object' + && !Array.isArray(guestPricesPayload) + ? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload) + : guestPricesPayload + ), + [] + ), + ] ); const boundedPriceCandidates = maxPriceLimit === null ? rawPriceCandidates @@ -2106,7 +2199,14 @@ preferredPriceTier ); const orderedPrices = orderedPricesFromCatalog.length - ? orderedPricesFromCatalog + ? ( + explicitFiveSimMaxPriceLimit !== null + ? [ + explicitFiveSimMaxPriceLimit, + ...orderedPricesFromCatalog.filter((price) => Number(price) !== Number(explicitFiveSimMaxPriceLimit)), + ] + : orderedPricesFromCatalog + ) : (maxPriceLimit !== null ? [maxPriceLimit] : [null]); const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); const hasCountryPriceFloor = ( @@ -2181,6 +2281,10 @@ break; } const payloadText = describeFiveSimPayload(payload); + if (isFiveSimRateLimitError(payload)) { + countryNoNumbersText = payloadText || countryNoNumbersText || 'rate limit'; + continue; + } if (isFiveSimNoNumbersError(payload)) { countryNoNumbersText = payloadText || countryNoNumbersText || 'no free phones'; continue; @@ -2190,6 +2294,10 @@ } lastError = new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`); } catch (error) { + if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) { + countryNoNumbersText = describeFiveSimPayload(error?.payload || error?.message) || countryNoNumbersText || 'rate limit'; + continue; + } if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) { throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`); } @@ -2210,12 +2318,18 @@ noNumbersByCountry.push( `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}` ); + } else if (isFiveSimRateLimitError(countryNoNumbersText)) { + rateLimitByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'rate limit'}`); } else { noNumbersByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'no free phones'}`); retryableNoNumberCountries.push(countryLabel); } continue; } catch (error) { + if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) { + rateLimitByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'rate limit'}`); + continue; + } if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) { throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`); } @@ -2236,8 +2350,13 @@ } finalNoNumbersByCountry = noNumbersByCountry; + finalRateLimitByCountry = rateLimitByCountry; finalLastError = lastError; + if (rateLimitByCountry.length) { + throw buildFiveSimRateLimitError(rateLimitByCountry); + } + if ( noNumbersByCountry.length && round < maxAcquireRounds @@ -2259,6 +2378,9 @@ `5sim no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` ); } + if (finalRateLimitByCountry.length) { + throw buildFiveSimRateLimitError(finalRateLimitByCountry); + } if (finalLastError) { throw finalLastError; } @@ -2714,6 +2836,10 @@ `步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`, 'info' ); + await addLog( + `步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`, + 'info' + ); } const countryAttempts = countryCandidates.map((countryConfig, index) => ({ @@ -2935,6 +3061,10 @@ `步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`, 'warn' ); + await addLog( + `步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮),${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`, + 'warn' + ); await sleepWithStop(retryDelayMs); continue; } @@ -2945,6 +3075,7 @@ if (finalNoNumbersByCountry.length) { throw new Error( `HeroSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}。` + + ` HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` ); } if (finalLastError) { @@ -3606,7 +3737,9 @@ const successfulUses = normalizedActivation.successfulUses + 1; const nextReusableActivation = { ...normalizedActivation, - successfulUses, + successfulUses: normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM + ? 1 + : successfulUses, }; await upsertReusableActivationPool(nextReusableActivation, { state }); if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) { @@ -3907,10 +4040,147 @@ countryPriceFloorByKey.delete(countryKey); }; - const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries()) - .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) - .map(([countryId]) => countryId) - .filter(Boolean); + const getBlockedCountryIds = () => { + const activeProvider = normalizePhoneSmsProvider( + state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER + ); + return Array.from(countrySmsFailureCounts.entries()) + .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) + .map(([countryKey]) => splitCountryFailureKey(countryKey, activeProvider)) + .filter((entry) => entry.provider === activeProvider) + .map((entry) => String(entry.countryKey || '').trim()) + .filter(Boolean); + }; + + const getCountryPriceFloorById = () => { + const activeProvider = normalizePhoneSmsProvider( + state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER + ); + const floorById = {}; + countryPriceFloorByKey.forEach((price, compoundCountryKey) => { + const numeric = normalizeHeroSmsPrice(price); + if (numeric === null || numeric <= 0) { + return; + } + const parsed = splitCountryFailureKey(compoundCountryKey, activeProvider); + if (parsed.provider !== activeProvider) { + return; + } + const keyPart = String(parsed.countryKey || '').trim(); + if (!keyPart) { + return; + } + floorById[keyPart] = Math.round(numeric * 10000) / 10000; + }); + return floorById; + }; + + const setCountryPriceFloorFromActivation = async (activationCandidate, reason = '') => { + const normalizedActivation = normalizeActivation(activationCandidate); + if (!normalizedActivation) { + return; + } + const countryKey = normalizeCountryFailureKey( + normalizedActivation.countryId, + normalizedActivation.provider + ); + if (!countryKey) { + return; + } + const floorPrice = normalizeHeroSmsPrice( + normalizedActivation.price + ?? normalizedActivation.maxPrice + ?? normalizedActivation.selectedPrice + ?? getActivationAcquiredPriceHint(normalizedActivation) + ); + if (floorPrice === null || floorPrice <= 0) { + return; + } + const currentFloor = normalizeHeroSmsPrice(countryPriceFloorByKey.get(countryKey)); + if (currentFloor !== null && currentFloor >= floorPrice) { + return; + } + const normalizedFloor = Math.round(floorPrice * 10000) / 10000; + countryPriceFloorByKey.set(countryKey, normalizedFloor); + const countryLabel = resolveCountryLabelByFailureKey(countryKey, normalizedActivation.provider); + await addLog( + `Step 9: ${countryLabel} will try a higher price tier (> ${normalizedFloor}) due to ${reason || 'sms timeout'}.`, + 'warn' + ); + }; + + const isPreferredActivation = (activationCandidate, stateSnapshot = {}) => ( + isSameActivation( + stateSnapshot?.[PREFERRED_PHONE_ACTIVATION_STATE_KEY], + activationCandidate + ) + ); + + const markPreferredActivationExhausted = async (reason = '') => { + if (preferredActivationExhausted || !activation || !isPreferredActivation(activation, state)) { + return; + } + preferredActivationExhausted = true; + await addLog( + `Step 9: preferred number ${activation.phoneNumber} failed (${reason || 'unknown reason'}), falling back to a new number.`, + 'warn' + ); + }; + + const rotateActivationAfterAddPhoneFailure = async (failureReason, failureCode, submitState = {}) => { + await markPreferredActivationExhausted(failureCode || failureReason); + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, failureCode || 'add_phone_rejected'); + } + await addLog( + `Step 9: replacing number after add-phone failure (${failureReason}) (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + 'warn' + ); + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; + let addPhoneSnapshot = { + ...pageState, + ...submitState, + addPhonePage: true, + phoneVerificationPage: false, + }; + try { + const returned = await returnToAddPhone(tabId); + addPhoneSnapshot = { + ...addPhoneSnapshot, + ...returned, + addPhonePage: true, + phoneVerificationPage: false, + }; + } catch (returnError) { + await addLog( + `Step 9: failed to return to add-phone page after rejection, will continue with best-effort state. ${returnError.message}`, + 'warn' + ); + } + try { + const verified = await ensureAddPhonePageBeforeSubmit('after add-phone rejection'); + addPhoneSnapshot = { + ...addPhoneSnapshot, + ...verified, + addPhonePage: true, + phoneVerificationPage: false, + }; + } catch (verifyError) { + await addLog( + `Step 9: failed to verify add-phone state after rejection. ${verifyError.message}`, + 'warn' + ); + } + pageState = addPhoneSnapshot; + }; try { while (true) { @@ -3941,9 +4211,7 @@ if (addPhoneReentryWithSameActivation > 1) { usedNumberReplacementAttempts += 1; if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { - throw new Error( - `步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason('returned_to_add_phone_loop')}。` - ); + throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, 'returned_to_add_phone_loop'); } await addLog( `步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`, @@ -4191,9 +4459,7 @@ usedNumberReplacementAttempts += 1; if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { - throw new Error( - `步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason(replaceReason)}。` - ); + throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, replaceReason || 'unknown'); } if (shouldCancelActivation && activation) { diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 5ad8c20..b3126c5 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -65,6 +65,7 @@ setState, sleepWithStop, waitForTabCompleteUntilStopped, + probeIpProxyExit = null, } = deps; function isPlusCheckoutUrl(url = '') { @@ -115,11 +116,31 @@ ); } + function normalizePostalCodeForCountry(countryCode, rawPostalCode = '', fallbackPostalCode = '') { + const normalizedCountry = resolveMeiguodizhiCountryCode(countryCode) || normalizeText(countryCode).toUpperCase(); + const postalCode = normalizeText(rawPostalCode); + const fallback = normalizeText(fallbackPostalCode); + if (normalizedCountry !== 'KR') { + return postalCode; + } + if (/^\d{5}$/.test(postalCode)) { + return postalCode; + } + if (/^\d{5}$/.test(fallback)) { + return fallback; + } + return '04524'; + } + function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) { const address1 = normalizeText(apiAddress?.Trans_Address || apiAddress?.Address); const city = normalizeText(apiAddress?.City); const region = normalizeText(apiAddress?.State_Full || apiAddress?.State); - const postalCode = normalizeText(apiAddress?.Zip_Code); + const postalCode = normalizePostalCodeForCountry( + countryCode, + apiAddress?.Zip_Code, + fallbackSeed?.fallback?.postalCode + ); if (!address1 || !city || !postalCode) { return null; } @@ -197,11 +218,47 @@ }; } + function resolveBillingAddressCountry(state = {}, countryOverride = '', paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) { + const normalizedPaymentMethod = normalizePlusPaymentMethod(paymentMethod || state?.plusPaymentMethod); + const checkoutCountry = resolveMeiguodizhiCountryCode(countryOverride); + const savedCheckoutCountry = resolveMeiguodizhiCountryCode(state.plusCheckoutCountry); + const exitCountry = resolveMeiguodizhiCountryCode( + state.ipProxyAppliedExitRegion + || state.ipProxyExitRegion + || '' + ); + + if (normalizedPaymentMethod === PLUS_PAYMENT_METHOD_GOPAY) { + const countryCode = exitCountry || checkoutCountry || savedCheckoutCountry || 'ID'; + return { + countryCode, + requestedCountry: exitCountry + || normalizeText(countryOverride) + || normalizeText(state.plusCheckoutCountry) + || 'ID', + source: exitCountry ? 'proxy_exit' : (checkoutCountry ? 'checkout_page' : (savedCheckoutCountry ? 'checkout_state' : 'gopay_fallback')), + }; + } + + const countryCode = checkoutCountry || savedCheckoutCountry || exitCountry || 'DE'; + return { + countryCode, + requestedCountry: normalizeText(countryOverride) + || normalizeText(state.plusCheckoutCountry) + || exitCountry + || 'DE', + source: checkoutCountry ? 'checkout_page' : (savedCheckoutCountry ? 'checkout_state' : (exitCountry ? 'proxy_exit' : 'paypal_fallback')), + }; + } + async function resolveBillingAddressSeed(state = {}, countryOverride = '', options = {}) { const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod || state?.plusPaymentMethod); - const forcedCountry = paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'ID' : ''; - const requestedCountry = normalizeText(forcedCountry || countryOverride || state.plusCheckoutCountry || 'DE'); - const countryCode = forcedCountry || resolveMeiguodizhiCountryCode(requestedCountry) || 'DE'; + const countryResolution = resolveBillingAddressCountry(state, countryOverride, paymentMethod); + const countryCode = countryResolution.countryCode; + const requestedCountry = countryResolution.requestedCountry; + if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY && countryResolution.source === 'proxy_exit') { + await addLog(`步骤 7:GoPay 账单地址将按当前代理出口地区 ${countryCode} 填写。`, 'info'); + } const localSeed = getLocalAddressSeed(countryCode); const lookupSeed = localSeed || buildMeiguodizhiLookupSeed(countryCode); if (!lookupSeed) { @@ -610,7 +667,67 @@ await addLog(`步骤 7:账单地址位于 checkout iframe(frameId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info'); } - const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText, { paymentMethod }); + let billingState = state; + if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY && typeof probeIpProxyExit === 'function') { + const staleExitRegion = normalizeText( + state?.ipProxyAppliedExitRegion + || state?.ipProxyExitRegion + || '' + ); + try { + await addLog('步骤 7:GoPay 账单地址准备按代理出口填写,正在重新检测当前出口地区...', 'info'); + const probeResult = await probeIpProxyExit({ + state, + timeoutMs: 12000, + authRebindRetry: true, + detectWhenDisabled: true, + }); + const routing = probeResult?.proxyRouting || {}; + const probedExitRegion = normalizeText(routing.exitRegion || ''); + const probedExitIp = normalizeText(routing.exitIp || ''); + const probedExitSource = normalizeText(routing.exitSource || ''); + const probeEndpoint = normalizeText(routing.endpoint || routing.exitEndpoint || ''); + const probeReason = normalizeText(routing.reason || ''); + const probeError = normalizeText(routing.exitError || routing.error || ''); + if (probedExitRegion) { + billingState = { + ...(state || {}), + ipProxyAppliedExitRegion: probedExitRegion, + ipProxyExitRegion: probedExitRegion, + ipProxyAppliedExitIp: probedExitIp, + ipProxyAppliedExitSource: probedExitSource, + }; + const sourceSuffix = probedExitSource ? `,来源 ${probedExitSource}` : ''; + const endpointSuffix = probeEndpoint ? `,检测地址 ${probeEndpoint}` : ''; + await addLog(`步骤 7:当前代理出口复测结果:${probedExitRegion}${probedExitIp ? ` / ${probedExitIp}` : ''}${sourceSuffix}${endpointSuffix}。`, 'info'); + } else { + billingState = { + ...(state || {}), + ipProxyAppliedExitRegion: '', + ipProxyExitRegion: '', + ipProxyAppliedExitIp: probedExitIp, + ipProxyAppliedExitSource: probedExitSource, + }; + await addLog( + `步骤 7:代理出口复测没有返回国家/地区代码,已清空旧出口地区${staleExitRegion ? ` ${staleExitRegion}` : ''},不会继续沿用旧地区。${probeReason ? `状态:${probeReason}。` : ''}${probeError ? `诊断:${probeError}` : ''}`, + 'warn' + ); + } + } catch (error) { + billingState = { + ...(state || {}), + ipProxyAppliedExitRegion: '', + ipProxyExitRegion: '', + }; + await addLog(`步骤 7:代理出口复测失败,已清空旧出口地区${staleExitRegion ? ` ${staleExitRegion}` : ''},不会继续沿用旧地区:${error?.message || String(error || '未知错误')}`, 'warn'); + } + } + if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY + && typeof probeIpProxyExit === 'function' + && !resolveMeiguodizhiCountryCode(billingState?.ipProxyAppliedExitRegion || billingState?.ipProxyExitRegion || '')) { + throw new Error('步骤 7:GoPay 账单地址需要当前代理出口国家/地区,但本次复测没有拿到国家码;已停止填写,避免误用旧的 KR/ID 地区。请先点 IP 代理“检测出口”,确认显示 JP 后再继续。'); + } + const addressSeed = await resolveBillingAddressSeed(billingState, billingFrame.countryText, { paymentMethod }); if (!addressSeed) { throw new Error('步骤 7:未找到可用的本地账单地址种子。'); } diff --git a/background/steps/gopay-approve.js b/background/steps/gopay-approve.js index 2722de6..e34bde8 100644 --- a/background/steps/gopay-approve.js +++ b/background/steps/gopay-approve.js @@ -7,6 +7,8 @@ const GOPAY_INJECT_FILES = ['content/utils.js', 'content/gopay-flow.js']; const GOPAY_WAIT_TIMEOUT_MS = 120000; const GOPAY_POLL_INTERVAL_MS = 1000; + const GOPAY_LINKING_RETRY_WAIT_MS = 15000; + const GOPAY_LINKING_STABLE_WAIT_MS = 60000; const GOPAY_OTP_FRAME_URL_PATTERN = /\/linking\/otp\b|gopayapi\.com\/linking\/otp/i; const GOPAY_PIN_FRAME_URL_PATTERN = /pin-web-client\.gopayapi\.com\/auth\/pin|\/auth\/pin\/verify|linking-validate-pin|merchants-gws-app\.gopayapi\.com\/payment\/validate-pin|\/payment\/validate-pin/i; const GOPAY_PAYMENT_FRAME_URL_PATTERN = /merchants-gws-app\.gopayapi\.com\/(?:payment\/details|app\/challenge)|\/gopay-tokenization\/pay/i; @@ -41,6 +43,37 @@ return `${terminalMessage}${rawSuffix}`; } + function createGoPayStableStateTracker() { + let signature = ''; + let firstSeenAt = 0; + return { + update(pageState = {}, currentUrl = '') { + const nextSignature = [ + pageState.url || currentUrl || '', + pageState.hasPhoneInput ? 'phone' : '', + pageState.hasOtpInput ? 'otp' : '', + pageState.hasPinInput ? 'pin' : '', + pageState.hasPayNowButton ? 'pay' : '', + pageState.hasContinueButton ? 'continue' : '', + normalizeText(pageState.textPreview || '').slice(0, 700), + ].join('::'); + const now = Date.now(); + if (nextSignature !== signature) { + signature = nextSignature; + firstSeenAt = now; + } + return { + signature, + stableMs: firstSeenAt ? now - firstSeenAt : 0, + }; + }, + reset() { + signature = ''; + firstSeenAt = 0; + }, + }; + } + async function restartGoPayCheckoutFromStep6(tabId, reason = '') { const message = normalizeText(reason || 'GoPay 支付页已失效或点击后没有进入下一步。'); await addLog(`步骤 8:${message} 正在关闭当前 GoPay/Checkout 页面,并回到步骤 6 重新创建 Plus Checkout。`, 'warn'); @@ -196,14 +229,16 @@ el.inputMode, ].filter(Boolean).join(' '))); const hasTerminalError = /waktunya\s+habis|ulang(?:i)?\s+prosesnya\s+dari\s+awal|time(?:'s|\s+is)?\s+(?:out|expired)|session\s+expired|expired|technical\s+error|terjadi\s+kesalahan|payment\s+failed|pembayaran\s+gagal|transaksi\s+gagal|declined|failed/i.test(text); - const hasPinInput = /pin|6\s*digit|masukkin\s+pin|ketik\s+6\s+digit/i.test(text) - || inputHints.some((hint) => /pin-input|pin|password|numeric/i.test(hint)); + const isPinPage = /pin|6\s*digit|masukkin\s+pin|masukkan\s+pin|ketik\s+6\s+digit|enter\s+pin|支付密码/i.test(text) + || /pin-web-client\.gopayapi\.com|\/auth\/pin|\/payment\/validate-pin|linking-validate-pin/i.test(location.href || ''); + const hasPinInput = isPinPage + || inputHints.some((hint) => /pin-input|(?:^|[\s_-])pin(?:$|[\s_-])|password|numeric|支付密码/i.test(hint)); const hasOtpInput = !hasPinInput && (/otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text) || inputHints.some((hint) => /otp|code|kode|verification|whatsapp/i.test(hint))); const hasPayNowButton = controlTexts.some((item) => /^\s*pay\s+now\s*$/i.test(item) || /^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i.test(item) || /(?:^|\s)pay-button(?:\s|$)/i.test(item)); - const hasContinueButton = controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item)); + const hasContinueButton = controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|lanjutkan|berikut|kirim|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item)); return { url: location.href, hasTerminalError, @@ -320,10 +355,10 @@ .filter((el) => visible(el) && enabled(el)); const controlTexts = controls.map(getText); const inputHints = inputs.map(getText); - const isPinPage = /pin|password|passcode|security|sandi|6\\s*digit|masukkin\\s+pin|ketik\\s+6\\s+digit/i.test(bodyText) - || /pin-web-client\\.gopayapi\\.com|\\/auth\\/pin|\\/payment\\/validate-pin/i.test(location.href || ''); + const isPinPage = /pin|password|passcode|security|sandi|6\\s*digit|masukkin\\s+pin|masukkan\\s+pin|ketik\\s+6\\s+digit|enter\\s+pin|支付密码/i.test(bodyText) + || /pin-web-client\\.gopayapi\\.com|\\/auth\\/pin|\\/payment\\/validate-pin|linking-validate-pin/i.test(location.href || ''); const hasTerminalError = /waktunya\\s+habis|ulang(?:i)?\\s+prosesnya\\s+dari\\s+awal|time(?:'s|\\s+is)?\\s+(?:out|expired)|session\\s+expired|expired|kedaluwarsa|technical\\s+error|terjadi\\s+kesalahan|error\\s+teknis|kendala\\s+teknis|gak\\s+bisa\\s+diproses|coba\\s+lagi\\s+nanti|payment\\s+failed|pembayaran\\s+gagal|transaksi\\s+gagal|ditolak|declined|failed/i.test(bodyText); - const hasPinInput = isPinPage || inputHints.some((hint) => /pin-input|pin|password|numeric/i.test(hint)); + const hasPinInput = isPinPage || inputHints.some((hint) => /pin-input|(?:^|[\\s_-])pin(?:$|[\\s_-])|password|numeric|支付密码/i.test(hint)); const hasOtpInput = !hasPinInput && (/otp|one[-\\s]*time|kode|verification|whatsapp|验证码|短信/i.test(bodyText) || inputHints.some((hint) => /otp|code|kode|verification|whatsapp/i.test(hint))); const hasPhoneInput = !hasOtpInput && !hasPinInput && inputs.some((input) => { @@ -335,7 +370,7 @@ const hasPayNowButton = controlTexts.some((item) => /^\\s*pay\\s+now\\s*$/i.test(item) || /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(item) || /(?:^|\\s)pay-button(?:\\s|$)/i.test(item)); - const hasContinueButton = !hasPayNowButton && !hasPhoneInput && controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item)); + const hasContinueButton = !hasPayNowButton && !hasPhoneInput && controlTexts.some((item) => /continue|next|submit|verify|confirm|authorize|allow|lanjut|lanjutkan|berikut|kirim|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(item)); const completed = /success|successful|completed|selesai|berhasil|approved|authorized|支付成功|绑定成功|已授权/i.test(bodyText) && !hasPhoneInput && !hasOtpInput @@ -454,7 +489,7 @@ if (/^\\s*pay\\s+now\\s*$/i.test(text) || /^\\s*bayar(?:\\s+sekarang)?(?:\\s*rp[\\s\\S]*)?\\s*$/i.test(text)) { return false; } - return /continue|next|submit|verify|confirm|authorize|allow|lanjut|berikut|kirim|konfirmasi|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(text); + return /continue|next|submit|verify|confirm|authorize|allow|lanjut|lanjutkan|berikut|kirim|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link|继续|下一步|提交|验证|确认|授权|绑定|关联/i.test(text); }); if (!target) { return { clicked: false, reason: 'target_not_found', url: location.href, textPreview: normalize(document.body?.innerText || '').slice(0, 240) }; @@ -539,7 +574,7 @@ && (!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0)); }; const inputs = Array.from(document.querySelectorAll('input, textarea')).filter((el) => visible(el) && !el.disabled); - const target = inputs.find((el) => /otp|one[-\\s]*time|kode|verification|whatsapp|code|pin-input-field/i.test([ + const target = inputs.find((el) => /otp|one[-\\s]*time|kode|verification|whatsapp|code/i.test([ el.getAttribute?.('data-testid'), el.getAttribute?.('aria-label'), el.getAttribute?.('placeholder'), @@ -1005,6 +1040,38 @@ return { timeout: true }; } + async function clickGoPayContinueBestEffort(tabId) { + const actionFrame = await findGoPayActionFrame(tabId); + const actionFrameId = actionFrame.frameId; + const actionTargetId = actionFrame.targetId; + if (Number.isInteger(actionFrameId)) { + await ensureGoPayOtpFrameReady(tabId, actionFrameId); + } + + try { + if (actionTargetId) { + const result = await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_CLICK_CONTINUE', {}); + if (result?.clicked) { + return result; + } + } else if (Number.isInteger(actionFrameId)) { + const result = await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_CLICK_CONTINUE', {}); + if (result?.clicked) { + return result; + } + } else { + const result = await sendGoPayCommand(tabId, 'GOPAY_CLICK_CONTINUE', {}); + if (result?.clicked) { + return result; + } + } + } catch (_) { + // Fall through to a real debugger click below. + } + + return clickGoPayContinueWithDebugger(tabId, actionFrameId); + } + function normalizeGoPayCountryCode(value = '') { const normalized = String(value || '').trim().replace(/[^\d+]/g, ''); const digits = normalized.replace(/\D/g, ''); @@ -1062,6 +1129,7 @@ lastContinueClickSignature = ''; payNowClickAttempts = 0; lastPayNowClickSignature = ''; + stableStateTracker?.reset?.(); } let phoneSubmitted = false; @@ -1072,6 +1140,7 @@ let lastContinueClickSignature = ''; let payNowClickAttempts = 0; let lastPayNowClickSignature = ''; + const stableStateTracker = createGoPayStableStateTracker(); while (true) { throwIfStopped?.(); @@ -1094,6 +1163,7 @@ : (Number.isInteger(actionFrameId) ? await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_GET_STATE', {}) : await getGoPayState(tabId)); + const stableState = stableStateTracker.update(pageState, currentUrl); await handleGoPayTerminalError(pageState, tabId); if (pageState.completed) { @@ -1194,27 +1264,6 @@ continue; } - if (pageState.hasOtpInput && !otpSubmitted) { - const code = await requestManualGoPayOtp(credentials.otp); - credentials.otp = code; - await addLog('步骤 8:正在填写 GoPay 验证码...', 'info'); - if (actionTargetId) { - await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_SUBMIT_OTP', { code }); - } else if (Number.isInteger(actionFrameId)) { - await ensureGoPayOtpFrameReady(tabId, actionFrameId); - await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_SUBMIT_OTP', { code }); - } else { - await ensureGoPayReady(tabId, '步骤 8:已获取 GoPay 验证码,等待 GoPay 页面就绪...'); - await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_OTP', { code }); - } - otpSubmitted = true; - continueClickAttempts = 0; - lastContinueClickSignature = ''; - loggedWaiting = false; - await sleepWithStop(1500); - continue; - } - if (pageState.hasPinInput && !pinSubmitted) { await addLog('步骤 8:正在填写 GoPay PIN...', 'info'); if (actionTargetId) { @@ -1247,6 +1296,27 @@ continue; } + if (pageState.hasOtpInput && !pageState.hasPinInput && !otpSubmitted) { + const code = await requestManualGoPayOtp(credentials.otp); + credentials.otp = code; + await addLog('步骤 8:正在填写 GoPay 验证码...', 'info'); + if (actionTargetId) { + await sendGoPayDebuggerTargetCommand(actionTargetId, 'GOPAY_SUBMIT_OTP', { code }); + } else if (Number.isInteger(actionFrameId)) { + await ensureGoPayOtpFrameReady(tabId, actionFrameId); + await sendGoPayFrameCommand(tabId, actionFrameId, 'GOPAY_SUBMIT_OTP', { code }); + } else { + await ensureGoPayReady(tabId, '步骤 8:已获取 GoPay 验证码,等待 GoPay 页面就绪...'); + await sendGoPayCommand(tabId, 'GOPAY_SUBMIT_OTP', { code }); + } + otpSubmitted = true; + continueClickAttempts = 0; + lastContinueClickSignature = ''; + loggedWaiting = false; + await sleepWithStop(1500); + continue; + } + if (pageState.hasContinueButton) { const continueSignature = `${pageState.url || currentUrl || ''}::${pageState.textPreview || ''}`.slice(0, 700); if (continueSignature === lastContinueClickSignature) { @@ -1256,7 +1326,8 @@ continueClickAttempts = 1; } if (continueClickAttempts > 2) { - await addLog('步骤 8:GoPay 确认按钮点击后页面仍未变化,已暂停自动重复点击。请手动点击页面上的确认按钮,插件会继续等待后续页面。', 'warn'); + const stableBeforeRetrySeconds = Math.round(stableState.stableMs / 1000); + await addLog(`步骤 8:GoPay 确认按钮点击后页面仍未变化,先等待 linking 页面加载/跳转(已稳定 ${stableBeforeRetrySeconds}s)。`, 'warn'); const decision = await waitForGoPayState(tabId, (nextState) => ( nextState.hasTerminalError || nextState.hasOtpInput @@ -1264,7 +1335,7 @@ || nextState.hasPayNowButton || nextState.completed || !nextState.hasContinueButton - ), { timeoutMs: 30000 }); + ), { timeoutMs: GOPAY_LINKING_RETRY_WAIT_MS }); await handleGoPayTerminalError(decision.pageState, tabId); if (decision.returned) { await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); @@ -1273,10 +1344,46 @@ if (!decision.timeout) { continueClickAttempts = 0; lastContinueClickSignature = ''; + stableStateTracker.reset(); loggedWaiting = false; continue; } - throw new Error('步骤 8:GoPay 确认按钮自动点击无效,请手动点击后重新执行或继续当前步骤。'); + const refreshedState = decision.pageState || pageState; + const refreshedStableState = stableStateTracker.update(refreshedState, currentUrl); + const stableSeconds = Math.round(refreshedStableState.stableMs / 1000); + if (stableSeconds < Math.round(GOPAY_LINKING_STABLE_WAIT_MS / 1000)) { + await addLog(`步骤 8:GoPay linking 页面还在同一状态(${stableSeconds}s),改用兜底点击 Hubungkan/确认按钮后继续等待。`, 'info'); + const retryResult = await clickGoPayContinueBestEffort(tabId); + if (retryResult?.clickTarget) { + await addLog(`步骤 8:已兜底点击 GoPay 控件:${retryResult.clickTarget}`, 'info'); + } + continueClickAttempts = 2; + await sleepWithStop(2500); + loggedWaiting = false; + continue; + } + await addLog('步骤 8:GoPay linking 页面长时间没有变化,已暂停自动重复点击。请手动点击页面上的 Hubungkan/确认按钮,插件会继续等待后续页面。', 'warn'); + const manualDecision = await waitForGoPayState(tabId, (nextState) => ( + nextState.hasTerminalError + || nextState.hasOtpInput + || nextState.hasPinInput + || nextState.hasPayNowButton + || nextState.completed + || !nextState.hasContinueButton + ), { timeoutMs: GOPAY_WAIT_TIMEOUT_MS }); + await handleGoPayTerminalError(manualDecision.pageState, tabId); + if (manualDecision.returned) { + await addLog('步骤 8:GoPay 已跳转回 ChatGPT / OpenAI 页面,准备进入回跳确认。', 'ok'); + break; + } + if (!manualDecision.timeout) { + continueClickAttempts = 0; + lastContinueClickSignature = ''; + stableStateTracker.reset(); + loggedWaiting = false; + continue; + } + throw new Error('步骤 8:GoPay linking 页面长时间无变化,请手动点击 Hubungkan/确认按钮后重新执行或继续当前步骤。'); } await addLog(`步骤 8:检测到 GoPay 继续/确认按钮,正在点击${continueClickAttempts > 1 ? `(第 ${continueClickAttempts} 次)` : ''}...`, 'info'); const clickResult = continueClickAttempts === 1 diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index cbf26bf..0acba3b 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -306,7 +306,8 @@ } async function executeSub2ApiStep10(state) { - const visibleStep = resolvePlatformVerifyStep(state); + const platformVerifyStep = resolvePlatformVerifyStep(state); + const visibleStep = platformVerifyStep; if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } @@ -351,7 +352,7 @@ await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`); const requestMessage = { type: 'EXECUTE_STEP', - step: 10, + step: platformVerifyStep, source: 'background', payload: { localhostUrl: state.localhostUrl, diff --git a/content/gopay-flow.js b/content/gopay-flow.js index f77774d..b41efd1 100644 --- a/content/gopay-flow.js +++ b/content/gopay-flow.js @@ -75,9 +75,25 @@ async function waitUntil(predicate, options = {}) { } } -async function waitForDocumentComplete() { - await waitUntil(() => document.readyState === 'complete', { intervalMs: 200 }); - await sleep(800); +async function waitForDocumentComplete(options = {}) { + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 8000)); + const settleMs = Math.max(0, Math.floor(Number(options.settleMs) || 500)); + try { + await waitUntil(() => { + const readyState = String(document.readyState || '').toLowerCase(); + return readyState === 'complete' + || readyState === 'interactive' + || Boolean(document.querySelector?.('button, input, textarea, a, [role="button"]')) + || Boolean(normalizeText(document.body?.innerText || document.body?.textContent || '')); + }, { + intervalMs: 200, + timeoutMs, + label: 'GoPay DOM', + }); + } catch (_) { + // GoPay linking 页面有时长时间保持 loading;后续定位控件本身还有等待/重试。 + } + await sleep(settleMs); } function isVisibleElement(el) { @@ -114,6 +130,7 @@ function getActionText(el) { return normalizeText([ el?.textContent, el?.value, + el?.getAttribute?.('data-testid'), el?.getAttribute?.('aria-label'), el?.getAttribute?.('title'), el?.getAttribute?.('placeholder'), @@ -171,14 +188,33 @@ function isGoPayOtpPageText() { if (isGoPayPinPageText()) { return false; } + if (getVisibleTextInputs().some((input) => isGoPayPinInput(input))) { + return false; + } const text = getPageBodyText(); return /otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text); } function isGoPayPinPageText() { const text = getPageBodyText(); - return /pin|password|passcode|security|sandi|6\s*digit/i.test(text) - || /pin-web-client\.gopayapi\.com|\/auth\/pin/i.test(location.href || ''); + return /pin|password|passcode|security|sandi|6\s*digit|masukkin\s+pin|masukkan\s+pin|ketik\s+6\s+digit|enter\s+pin|支付密码/i.test(text) + || /pin-web-client\.gopayapi\.com|\/auth\/pin|\/payment\/validate-pin|linking-validate-pin/i.test(location.href || ''); +} + +function isGoPayPinInput(input) { + if (!input || isCountrySearchInput(input)) { + return false; + } + const text = getCombinedElementText(input); + const testId = String(input.getAttribute?.('data-testid') || '').trim(); + const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase(); + const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0); + const pinPage = isGoPayPinPageText(); + const strongPinHint = /password|passcode|security|sandi|支付密码|密码/i.test(text); + const ambiguousPinWidget = /^pin-input/i.test(testId) + || /pin-input(?:-field)?|(?:^|[\s_-])pin(?:$|[\s_-])/i.test(text); + return strongPinHint + || (pinPage && (ambiguousPinWidget || type === 'password' || maxLength === 1)); } function detectGoPayTerminalError(text = getPageBodyText()) { @@ -213,31 +249,33 @@ function detectGoPayTerminalError(text = getPageBodyText()) { } function findOtpInput() { + if (isGoPayPinPageText()) { + return null; + } const input = findInputByPatterns([ - /otp|one[-\s]*time|verification|verify|code|kode|whatsapp|wa|pin-input-field/i, + /otp|one[-\s]*time|verification|verify|code|kode|whatsapp|wa/i, /验证码|短信|代码/i, ]); - if (input && !isCountrySearchInput(input)) { + if (input && !isCountrySearchInput(input) && !isGoPayPinInput(input)) { return input; } if (isGoPayOtpPageText()) { - return getVisibleTextInputs().find((candidate) => !isCountrySearchInput(candidate)) || null; + return getVisibleTextInputs().find((candidate) => !isCountrySearchInput(candidate) && !isGoPayPinInput(candidate)) || null; } return null; } function getGoPayPinInputs() { return getVisibleTextInputs().filter((candidate) => { - if (isCountrySearchInput(candidate)) return false; - const text = getCombinedElementText(candidate); - const maxLength = Number(candidate.getAttribute?.('maxlength') || candidate.maxLength || 0); - return /pin|password|passcode|security|sandi|pin-input/i.test(text) - || candidate.getAttribute?.('data-testid')?.startsWith?.('pin-input') - || (isGoPayPinPageText() && maxLength === 1); + return isGoPayPinInput(candidate); }); } function findPinInput() { + const pinInputs = getGoPayPinInputs(); + if (pinInputs[0]) { + return pinInputs[0]; + } if (isGoPayOtpPageText()) { return null; } @@ -248,8 +286,7 @@ function findPinInput() { if (input && !isCountrySearchInput(input)) { return input; } - return getGoPayPinInputs()[0] - || getVisibleControls('input[type="password"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate)) + return getVisibleControls('input[type="password"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate)) || null; } @@ -274,7 +311,7 @@ function findPayNowButton() { function findContinueButton() { return findClickableByText([ - /continue|next|submit|verify|confirm|pay|authorize|allow|lanjut|berikut|kirim|bayar|konfirmasi|link/i, + /continue|next|submit|verify|confirm|pay|authorize|allow|lanjut|lanjutkan|berikut|kirim|bayar|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link/i, /继续|下一步|提交|验证|确认|支付|授权|绑定|关联/i, ]); } @@ -556,9 +593,13 @@ function fillVisiblePinInputs(pin = '') { function fillVisibleOtpInputs(code = '') { const normalizedCode = normalizeOtp(code); if (!normalizedCode) return false; + if (isGoPayPinPageText() || getVisibleTextInputs().some((input) => isGoPayPinInput(input))) { + return false; + } const otpInputs = getVisibleTextInputs() .filter((input) => { + if (isGoPayPinInput(input)) return false; const text = getActionText(input); const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0); return /otp|code|kode|verification|验证码|短信/i.test(text) diff --git a/content/plus-checkout.js b/content/plus-checkout.js index a91dac1..10dc218 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -791,6 +791,17 @@ function hasBillingAddressFields() { }); } +function hasPaymentMethodSelectionMarker(el) { + if (!el) return false; + const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || ''; + return el.checked === true + || el.getAttribute?.('aria-checked') === 'true' + || el.getAttribute?.('aria-selected') === 'true' + || el.getAttribute?.('data-state') === 'checked' + || el.getAttribute?.('data-selected') === 'true' + || /\b(selected|checked|active)\b/i.test(className); +} + function hasSelectedPaymentMethodControl(method = PLUS_PAYMENT_METHOD_PAYPAL) { const config = getPaymentMethodConfig(method); const candidates = getPaymentMethodSearchCandidates(config.id); @@ -798,15 +809,16 @@ function hasSelectedPaymentMethodControl(method = PLUS_PAYMENT_METHOD_PAYPAL) { let current = candidate; for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) { if (isDocumentLevelContainer(current)) break; - if (!config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current)))) continue; - const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || ''; + const currentMatchesPayment = config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current))); + if (currentMatchesPayment && hasPaymentMethodSelectionMarker(current)) { + return true; + } + + const radio = current.querySelector?.('input[type="radio"], [role="radio"]'); if ( - current.checked === true - || current.getAttribute?.('aria-checked') === 'true' - || current.getAttribute?.('aria-selected') === 'true' - || current.getAttribute?.('data-state') === 'checked' - || current.getAttribute?.('data-selected') === 'true' - || /\b(selected|checked|active)\b/i.test(className) + radio + && config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current) || getCombinedSearchText(radio))) + && hasPaymentMethodSelectionMarker(radio) ) { return true; } diff --git a/content/signup-page.js b/content/signup-page.js index 2259f91..b1c3f1d 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -280,13 +280,104 @@ function is405MethodNotAllowedPage() { return AUTH_ROUTE_ERROR_PATTERN.test(pageText); } +function getStep405RecoveryStateKey(step) { + return `__MULTIPAGE_STEP_${Number(step) || '?'}_405_RECOVERY_COUNT__`; +} + +function getStep405StorageScope() { + if (typeof window !== 'undefined' && window) { + return window; + } + if (typeof globalThis !== 'undefined' && globalThis) { + return globalThis; + } + return {}; +} + +function getStep405RecoveryLimit(step) { + if (Number(step) !== 4) { + return 0; + } + return typeof STEP4_405_RECOVERY_LIMIT !== 'undefined' + ? STEP4_405_RECOVERY_LIMIT + : 3; +} + +function getStep405RecoveryErrorPrefix(step) { + if (Number(step) !== 4) { + return ''; + } + return typeof STEP4_405_RECOVERY_ERROR_PREFIX !== 'undefined' + ? STEP4_405_RECOVERY_ERROR_PREFIX + : 'STEP4_405_RECOVERY_LIMIT::'; +} + +function getStep405RecoveryCount(step) { + const key = getStep405RecoveryStateKey(step); + let value = ''; + try { + if (typeof sessionStorage !== 'undefined' && sessionStorage?.getItem) { + value = sessionStorage.getItem(key) || ''; + } + } catch {} + if (!value) { + value = getStep405StorageScope()[key]; + } + return Math.max(0, Math.floor(Number(value) || 0)); +} + +function setStep405RecoveryCount(step, count) { + const key = getStep405RecoveryStateKey(step); + const value = String(Math.max(0, Math.floor(Number(count) || 0))); + try { + if (typeof sessionStorage !== 'undefined' && sessionStorage?.setItem) { + sessionStorage.setItem(key, value); + } + } catch {} + getStep405StorageScope()[key] = value; +} + +function clearStep405RecoveryCount(step) { + const key = getStep405RecoveryStateKey(step); + try { + if (typeof sessionStorage !== 'undefined' && sessionStorage?.removeItem) { + sessionStorage.removeItem(key); + } + } catch {} + try { + delete getStep405StorageScope()[key]; + } catch {} +} + +function createStep405RecoveryLimitError(step, count) { + const normalizedStep = Number(step) || step || '?'; + const limit = getStep405RecoveryLimit(normalizedStep) || count; + const message = `步骤 ${normalizedStep}:检测到 405 错误页面,已连续点击“重试”恢复 ${count}/${limit} 次仍未恢复,当前轮将结束并进入下一轮。URL: ${location.href}`; + return new Error(`${getStep405RecoveryErrorPrefix(normalizedStep)}${message}`); +} + async function handle405ResendError(step, remainingTimeout = 30000) { + const currentCount = getStep405RecoveryCount(step); + if (Number(step) === 4 && currentCount >= getStep405RecoveryLimit(step)) { + throw createStep405RecoveryLimitError(step, currentCount); + } + + const nextCount = currentCount + 1; + setStep405RecoveryCount(step, nextCount); + const maxClickAttempts = Number(step) === 4 ? 1 : 5; await recoverCurrentAuthRetryPage({ - logLabel: `步骤 ${step}:检测到 405 错误页面,正在点击“重试”恢复`, + logLabel: Number(step) === 4 + ? `步骤 ${step}:检测到 405 错误页面,正在点击“重试”恢复(总计 ${nextCount}/${getStep405RecoveryLimit(step)})` + : `步骤 ${step}:检测到 405 错误页面,正在点击“重试”恢复`, + maxClickAttempts, pathPatterns: [], step, timeoutMs: Math.max(1000, remainingTimeout), }); + if (is405MethodNotAllowedPage()) { + throw createStep405RecoveryLimitError(step, nextCount); + } + if (typeof clearStep405RecoveryCount === 'function') clearStep405RecoveryCount(step); log(`步骤 ${step}:405 错误已恢复,页面已返回验证码页面。`); } @@ -1038,6 +1129,8 @@ const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试| const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; const AUTH_TIMEOUT_ERROR_DETAIL_PATTERN = /operation\s+timed\s+out|timed\s+out|请求超时|操作超时|failed\s+to\s+fetch|network\s+error|fetch\s+failed/i; const AUTH_ROUTE_ERROR_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i; +const STEP4_405_RECOVERY_ERROR_PREFIX = 'STEP4_405_RECOVERY_LIMIT::'; +const STEP4_405_RECOVERY_LIMIT = 3; const SIGNUP_USER_ALREADY_EXISTS_ERROR_PREFIX = 'SIGNUP_USER_ALREADY_EXISTS::'; const SIGNUP_EMAIL_EXISTS_PATTERN = /与此电子邮件地址相关联的帐户已存在|account\s+associated\s+with\s+this\s+email\s+address\s+already\s+exists|email\s+address.*already\s+exists/i; @@ -2546,6 +2639,7 @@ async function fillVerificationCode(step, payload) { if (step === 4) { const postVerificationState = getStep4PostVerificationState(); if (postVerificationState?.state === 'logged_in_home') { + if (typeof clearStep405RecoveryCount === 'function') clearStep405RecoveryCount(step); log(`步骤 ${step}:检测到页面已进入 ChatGPT 已登录态,本次验证码提交按成功处理。`, 'ok'); return { success: true, @@ -2556,6 +2650,7 @@ async function fillVerificationCode(step, payload) { }; } if (postVerificationState?.state === 'step5') { + if (typeof clearStep405RecoveryCount === 'function') clearStep405RecoveryCount(step); log(`步骤 ${step}:检测到页面已进入下一阶段,本次验证码提交按成功处理。`, 'ok'); return { success: true, assumed: true, alreadyAdvanced: true }; } @@ -2664,6 +2759,7 @@ async function fillVerificationCode(step, payload) { } else if (outcome.addPhonePage) { log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn'); } else { + if (typeof clearStep405RecoveryCount === 'function') clearStep405RecoveryCount(step); log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } if (combinedSignupProfilePage && !outcome.invalidCode) { @@ -2698,6 +2794,7 @@ async function fillVerificationCode(step, payload) { } else if (outcome.addPhonePage) { log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn'); } else { + if (typeof clearStep405RecoveryCount === 'function') clearStep405RecoveryCount(step); log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } diff --git a/data/address-sources.js b/data/address-sources.js index 61cbefd..c94a9e5 100644 --- a/data/address-sources.js +++ b/data/address-sources.js @@ -7,6 +7,7 @@ FR: ['fr', 'fra', 'france', '法国'], ID: ['id', 'indonesia', '印度尼西亚', '印尼'], JP: ['jp', 'jpn', 'japan', '日本', '日本国'], + KR: ['kr', 'kor', 'korea', 'south korea', '韩国', '대한민국'], US: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'], }; @@ -121,6 +122,28 @@ }, }, ], + KR: [ + { + query: 'Seoul Jung-gu', + suggestionIndex: 1, + fallback: { + address1: 'Sejong-daero 110', + city: 'Jung-gu', + region: 'Seoul', + postalCode: '04524', + }, + }, + { + query: 'Seoul Gangnam-gu', + suggestionIndex: 1, + fallback: { + address1: 'Teheran-ro 152', + city: 'Gangnam-gu', + region: 'Seoul', + postalCode: '06236', + }, + }, + ], US: [ { query: 'New York NY', diff --git a/data/step-definitions.js b/data/step-definitions.js index 1c706a3..6183275 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -30,8 +30,21 @@ { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' }, ]; - const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; - const PLUS_PAYMENT_STEP_KEY = 'paypal-approve'; + const PLUS_GOPAY_STEP_DEFINITIONS = [ + { id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' }, + { id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' }, + { id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' }, + { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, + { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, + { id: 6, order: 60, key: 'plus-checkout-create', title: '打开 GoPay 订阅页' }, + { id: 7, order: 70, key: 'plus-checkout-billing', title: '填写 GoPay 账单并提交订阅' }, + { id: 8, order: 80, key: 'paypal-approve', title: 'GoPay 手机验证与授权' }, + { id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认' }, + { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, + { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, + { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, + { id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' }, + ]; function isPlusModeEnabled(options = {}) { return Boolean(options?.plusModeEnabled || options?.plusMode); @@ -107,6 +120,17 @@ return match ? cloneSteps([match], options)[0] : null; } + function getPlusPaymentStepTitle(options = {}) { + if (!isPlusModeEnabled(options)) { + return ''; + } + const paymentStep = getModeStepDefinitions({ + ...options, + plusModeEnabled: true, + }).find((step) => step.key === 'paypal-approve'); + return paymentStep?.title || ''; + } + return { STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS, NORMAL_STEP_DEFINITIONS, diff --git a/docs/local-customizations-maintenance.md b/docs/local-customizations-maintenance.md index 4c29133..927bde6 100644 --- a/docs/local-customizations-maintenance.md +++ b/docs/local-customizations-maintenance.md @@ -344,7 +344,7 @@ OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染 - 默认仍为 `HeroSMS`,老配置无需迁移。 - 新增 `5sim` 平台,下拉框在接码设置卡片中直接可见。 -- 国家/地区列表、API Key、价格上限、余额、买号、查码、完成、取消、ban、复用均按当前 `phoneSmsProvider` 分发。 +- 国家/地区列表、API Key、价格上限、余额、买号、查码、完成、取消、ban、复用均按当前 `phoneSmsProvider` 分发。当前侧边栏只开放三个常用地区:印度尼西亚、泰国、越南。 - HeroSMS 继续使用原 `heroSms*` 字段;5sim 使用独立 `fiveSim*` 字段,切换平台时不会互相覆盖草稿。 ### 关键文件 @@ -386,7 +386,7 @@ OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染 1. 不要把真实 5sim key 写入代码、测试或文档;只允许用户在侧边栏输入。 2. 上游同步时如果改了 Step 9 手机号验证,必须保留 `currentPhoneActivation.provider` 驱动的后续分发。 -3. 上游同步时如果改了 sidepanel 接码区域,必须保留 `select-phone-sms-provider`,且地区列表按平台加载:HeroSMS 数字国家 ID,5sim 字符串 country slug。 +3. 上游同步时如果改了 sidepanel 接码区域,必须保留 `select-phone-sms-provider`,且地区列表按平台加载:HeroSMS 数字国家 ID,5sim 字符串 country slug;当前只展示并保存印度尼西亚、泰国、越南,避免旧配置中的英国/美国等地区回写。 4. 5sim 产品码当前固定为 `openai`,operator 默认 `any`;价格上限为空时按价格目录最低可用价尝试。 ### 验证 diff --git a/phone-sms/providers/five-sim.js b/phone-sms/providers/five-sim.js index 207deea..4fb5e7b 100644 --- a/phone-sms/providers/five-sim.js +++ b/phone-sms/providers/five-sim.js @@ -6,11 +6,18 @@ const DEFAULT_BASE_URL = 'https://5sim.net'; const DEFAULT_PRODUCT = 'openai'; const DEFAULT_OPERATOR = 'any'; - const DEFAULT_COUNTRY_ID = 'england'; - const DEFAULT_COUNTRY_LABEL = '英国 (England)'; + const DEFAULT_COUNTRY_ID = 'vietnam'; + const DEFAULT_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_REQUEST_TIMEOUT_MS = 20000; const DEFAULT_MAX_USES = 3; + const FIVE_SIM_RATE_LIMIT_ERROR_PREFIX = 'FIVE_SIM_RATE_LIMIT::'; const MAX_PRICE_CANDIDATES = 8; + const SUPPORTED_COUNTRY_ITEMS = Object.freeze([ + { id: 'indonesia', label: '印度尼西亚 (Indonesia)' }, + { id: 'thailand', label: '泰国 (Thailand)' }, + { id: 'vietnam', label: '越南 (Vietnam)' }, + ]); + const SUPPORTED_COUNTRY_ID_SET = new Set(SUPPORTED_COUNTRY_ITEMS.map((item) => item.id)); const COUNTRY_CN_BY_ID = Object.freeze({ afghanistan: '阿富汗', albania: '阿尔巴尼亚', @@ -108,7 +115,15 @@ function normalizeFiveSimCountryId(value, fallback = DEFAULT_COUNTRY_ID) { const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); - return normalized || fallback; + if (SUPPORTED_COUNTRY_ID_SET.has(normalized)) { + return normalized; + } + const fallbackSource = fallback === undefined || fallback === null ? DEFAULT_COUNTRY_ID : fallback; + const normalizedFallback = String(fallbackSource).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + if (!normalizedFallback) { + return ''; + } + return SUPPORTED_COUNTRY_ID_SET.has(normalizedFallback) ? normalizedFallback : DEFAULT_COUNTRY_ID; } function getCountryIdFromPayload(record = {}, fallback = DEFAULT_COUNTRY_ID) { @@ -135,8 +150,13 @@ const countryId = normalizeFiveSimCountryId(id, ''); const english = normalizeFiveSimCountryLabel(englishValue || countryId || fallback, fallback); const chinese = COUNTRY_CN_BY_ID[countryId] || ''; - if (chinese && english && chinese.toLowerCase() !== english.toLowerCase() && !String(english).includes(chinese)) { - return `${chinese} (${english})`; + if (chinese && english) { + if (String(english).includes(chinese)) { + return english; + } + if (chinese.toLowerCase() !== english.toLowerCase()) { + return `${chinese} (${english})`; + } } return chinese || english; } @@ -323,9 +343,11 @@ } function resolveCountryConfig(state = {}) { + const rawCountryId = normalizeFiveSimCountryId(state.fiveSimCountryId, ''); + const id = rawCountryId || DEFAULT_COUNTRY_ID; return { - id: normalizeFiveSimCountryId(state.fiveSimCountryId), - label: normalizeFiveSimCountryLabel(state.fiveSimCountryLabel), + id, + label: formatFiveSimCountryLabel(id, state.fiveSimCountryLabel || id, DEFAULT_COUNTRY_LABEL), }; } @@ -343,7 +365,7 @@ seen.add(nextId); candidates.push({ id: nextId, - label: normalizeFiveSimCountryLabel(entry.label, nextId), + label: formatFiveSimCountryLabel(nextId, entry.label || nextId, nextId), }); }); @@ -392,8 +414,12 @@ ].filter(Boolean).join(' '), }; }) - .filter(Boolean) - .sort((left, right) => left.label.localeCompare(right.label)); + .filter((entry) => entry && SUPPORTED_COUNTRY_ID_SET.has(String(entry.id))) + .sort((left, right) => { + const leftIndex = SUPPORTED_COUNTRY_ITEMS.findIndex((item) => item.id === String(left.id)); + const rightIndex = SUPPORTED_COUNTRY_ITEMS.findIndex((item) => item.id === String(right.id)); + return leftIndex - rightIndex; + }); } function collectPriceEntries(payload, entries = []) { @@ -531,6 +557,18 @@ return /no\s+free\s+phones|no\s+numbers|not\s+found/i.test(text); } + function isRateLimitPayload(payloadOrMessage) { + const text = describePayload(payloadOrMessage); + return /rate\s*limit|too\s*many\s*requests|request\s*limit|429/i.test(text); + } + + function buildFiveSimRateLimitError(details = []) { + const suffix = Array.isArray(details) && details.length + ? `:${details.join(' | ')}。` + : '。'; + return new Error(`${FIVE_SIM_RATE_LIMIT_ERROR_PREFIX}5sim 购买接口触发限流,请稍后再试${suffix}`); + } + function isTerminalError(payloadOrMessage) { const text = describePayload(payloadOrMessage); return /not\s+enough\s+(?:user\s+)?balance|not\s+enough\s+rating|unauthorized|invalid\s+token|banned|bad\s+(?:country|operator)|no\s+product|server\s+offline/i.test(text); @@ -554,11 +592,17 @@ actionLabel: '5sim 购买手机号', } ); - return normalizeActivation(payload, { + const activation = normalizeActivation(payload, { countryId: countryConfig.id, countryLabel: countryConfig.label, operator, }); + if (!activation) { + const error = new Error(`5sim 购买手机号返回不可用响应:${describePayload(payload) || '空响应'}`); + error.payload = payload; + throw error; + } + return activation; } async function requestActivation(state = {}, options = {}, deps = {}) { @@ -614,10 +658,13 @@ } const noNumbersByCountry = []; + const rateLimitByCountry = []; let lastError = null; let lastFailureText = ''; + let sawOnlyRetryableFailures = true; for (const attempt of countryAttempts) { const countryConfig = attempt.countryConfig; + const countryFailures = []; const pricePlan = attempt.pricePlan || await resolvePricePlan(state, countryConfig, deps); for (const maxPrice of pricePlan.prices) { try { @@ -631,18 +678,37 @@ if (isTerminalError(payloadOrMessage)) { throw new Error(`5sim 购买手机号失败:${describePayload(payloadOrMessage) || '空响应'}`); } - if (isNoNumbersPayload(payloadOrMessage)) { - lastFailureText = describePayload(payloadOrMessage) || lastFailureText; + const failureText = describePayload(payloadOrMessage) || lastFailureText; + if (isRateLimitPayload(payloadOrMessage)) { + lastFailureText = failureText; + countryFailures.push(failureText || 'rate limit'); continue; } + if (isNoNumbersPayload(payloadOrMessage)) { + lastFailureText = failureText; + countryFailures.push(failureText || '无可用号码'); + continue; + } + sawOnlyRetryableFailures = false; lastError = error; - lastFailureText = describePayload(payloadOrMessage) || lastFailureText; + lastFailureText = failureText; } } - noNumbersByCountry.push(`${countryConfig.label}: ${lastFailureText || '无可用号码'}`); + const countryFailureText = countryFailures.length + ? Array.from(new Set(countryFailures)).join(', ') + : (lastFailureText || '无可用号码'); + if (countryFailures.some((text) => isRateLimitPayload(text))) { + rateLimitByCountry.push(`${countryConfig.label}: ${countryFailureText}`); + } else { + noNumbersByCountry.push(`${countryConfig.label}: ${countryFailureText}`); + } } - if (noNumbersByCountry.length) { + if (rateLimitByCountry.length) { + throw buildFiveSimRateLimitError(rateLimitByCountry); + } + + if (noNumbersByCountry.length && sawOnlyRetryableFailures) { throw new Error(`5sim 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${noNumbersByCountry.join(' | ')}。`); } if (lastError) { @@ -782,6 +848,7 @@ label: '5sim', defaultCountryId: DEFAULT_COUNTRY_ID, defaultCountryLabel: DEFAULT_COUNTRY_LABEL, + supportedCountries: SUPPORTED_COUNTRY_ITEMS, defaultProduct: DEFAULT_PRODUCT, defaultOperator: DEFAULT_OPERATOR, normalizeCountryId: normalizeFiveSimCountryId, @@ -807,10 +874,12 @@ return { PROVIDER_ID, + FIVE_SIM_RATE_LIMIT_ERROR_PREFIX, DEFAULT_COUNTRY_ID, DEFAULT_COUNTRY_LABEL, DEFAULT_OPERATOR, DEFAULT_PRODUCT, + SUPPORTED_COUNTRY_ITEMS, createProvider, normalizeFiveSimCountryFallback, normalizeFiveSimCountryId, diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js index 225164d..c18a4c8 100644 --- a/sidepanel/ip-proxy-panel.js +++ b/sidepanel/ip-proxy-panel.js @@ -1164,9 +1164,13 @@ function formatIpProxyRuntimeStatus(state = latestState) { } if (reason === 'connectivity_failed') { const prefix = endpointSummary ? `当前代理:${endpointSummary}${accountSourceTag}` : '当前代理:未知'; + const targetUnreachable = /真实目标|chatgpt\.com 不可达|target:page_context/i.test(errorText || details); + const exitPart = exitIp ? `;当前出口:${exitSummary}` : ''; return { stateClass: 'state-error', - text: `${prefix};连通性失败,请切换节点或重试。`, + text: targetUnreachable && exitIp + ? `${prefix}${exitPart};ChatGPT 目标不可达,请切换支持 ChatGPT 的节点。` + : `${prefix}${exitPart};连通性失败,请切换节点或重试。`, details: errorText || details, hideCurrentDisplay: true, }; @@ -1303,11 +1307,13 @@ function updateIpProxyUI(state = latestState) { if (rowIpProxyPoolTargetCount) { rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none'; } - if (rowIpProxyAutoSyncEnabled) { - rowIpProxyAutoSyncEnabled.style.display = showSettings ? '' : 'none'; + const autoSyncEnabledRow = typeof rowIpProxyAutoSyncEnabled !== 'undefined' ? rowIpProxyAutoSyncEnabled : null; + const autoSyncIntervalRow = typeof rowIpProxyAutoSyncInterval !== 'undefined' ? rowIpProxyAutoSyncInterval : null; + if (autoSyncEnabledRow) { + autoSyncEnabledRow.style.display = showSettings ? '' : 'none'; } - if (rowIpProxyAutoSyncInterval) { - rowIpProxyAutoSyncInterval.style.display = showSettings ? '' : 'none'; + if (autoSyncIntervalRow) { + autoSyncIntervalRow.style.display = showSettings ? '' : 'none'; } if (rowIpProxyHost) { rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none'; @@ -1402,15 +1408,17 @@ function updateIpProxyUI(state = latestState) { if (inputIpProxyAccountList) { inputIpProxyAccountList.disabled = !enabled || !isAccountMode || !accountListAvailable; } - if (inputIpProxyAutoSyncEnabled) { - inputIpProxyAutoSyncEnabled.disabled = !enabled; + const autoSyncEnabledInput = typeof inputIpProxyAutoSyncEnabled !== 'undefined' ? inputIpProxyAutoSyncEnabled : null; + const autoSyncIntervalInput = typeof inputIpProxyAutoSyncIntervalMinutes !== 'undefined' ? inputIpProxyAutoSyncIntervalMinutes : null; + if (autoSyncEnabledInput) { + autoSyncEnabledInput.disabled = !enabled; } - if (inputIpProxyAutoSyncIntervalMinutes) { - const autoSyncEnabled = Boolean(inputIpProxyAutoSyncEnabled?.checked); - if (!Number.isFinite(Number.parseInt(String(inputIpProxyAutoSyncIntervalMinutes.value || '').trim(), 10))) { - inputIpProxyAutoSyncIntervalMinutes.value = '15'; + if (autoSyncIntervalInput) { + const autoSyncEnabled = Boolean(autoSyncEnabledInput?.checked); + if (!Number.isFinite(Number.parseInt(String(autoSyncIntervalInput.value || '').trim(), 10))) { + autoSyncIntervalInput.value = '15'; } - inputIpProxyAutoSyncIntervalMinutes.disabled = !enabled || !autoSyncEnabled; + autoSyncIntervalInput.disabled = !enabled || !autoSyncEnabled; } const runtimeStatus = formatIpProxyRuntimeStatus(runtimeState); diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 914e8e2..70edcfb 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -424,8 +424,13 @@ const btnAutoStartRestart = document.getElementById('btn-auto-start-restart'); const btnAutoStartContinue = document.getElementById('btn-auto-start-continue'); const autoHintText = document.querySelector('.auto-hint'); const stepsList = document.querySelector('.steps-list'); +const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; +const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; +const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL; let currentPlusModeEnabled = false; -let currentPlusPaymentMethod = 'paypal'; +let currentPlusPaymentMethod = DEFAULT_PLUS_PAYMENT_METHOD; +let activePlusManualConfirmationRequestId = ''; +let plusManualConfirmationDialogInFlight = false; let heroSmsCountrySelectionOrder = []; let phoneSmsProviderOrderSelection = []; let heroSmsCountryMenuSearchKeyword = ''; @@ -492,14 +497,13 @@ const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high'; const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; -const HERO_SMS_FALLBACK_COUNTRY_ITEMS = Object.freeze([ +const HERO_SMS_SUPPORTED_COUNTRY_ITEMS = Object.freeze([ + { id: 6, chn: '印度尼西亚', eng: 'Indonesia' }, { id: 52, chn: '泰国', eng: 'Thailand' }, - { id: 187, chn: '美国(物理)', eng: 'USA' }, - { id: 16, chn: '英国', eng: 'United Kingdom' }, - { id: 151, chn: '日本', eng: 'Japan' }, - { id: 43, chn: '德国', eng: 'Germany' }, - { id: 73, chn: '法国', eng: 'France' }, + { id: 10, chn: '越南', eng: 'Vietnam' }, ]); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(HERO_SMS_SUPPORTED_COUNTRY_ITEMS.map((item) => String(item.id))); +const HERO_SMS_FALLBACK_COUNTRY_ITEMS = HERO_SMS_SUPPORTED_COUNTRY_ITEMS; const FIVE_SIM_COUNTRY_CN_BY_ID = Object.freeze({ afghanistan: '阿富汗', albania: '阿尔巴尼亚', @@ -645,9 +649,13 @@ const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution const PHONE_VERIFICATION_SECTION_EXPANDED_STORAGE_KEY = 'multipage-phone-verification-section-expanded'; function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { + const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; + const rawPaymentMethod = typeof options === 'string' + ? options + : (options.plusPaymentMethod || currentPlusPaymentMethod || defaultMethod); return (window.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled, - plusPaymentMethod: options.plusPaymentMethod || selectPlusPaymentMethod?.value || 'paypal', + plusPaymentMethod: normalizePlusPaymentMethod(rawPaymentMethod), }) || []) .sort((left, right) => { const leftOrder = Number.isFinite(left.order) ? left.order : left.id; @@ -659,7 +667,12 @@ function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) { currentPlusModeEnabled = Boolean(plusModeEnabled); - stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled, options); + const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; + const rawPaymentMethod = typeof options === 'string' + ? options + : (options.plusPaymentMethod || currentPlusPaymentMethod || defaultMethod); + currentPlusPaymentMethod = normalizePlusPaymentMethod(rawPaymentMethod); + stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled, currentPlusPaymentMethod); STEP_IDS = stepDefinitions.map((step) => Number(step.id)).filter(Number.isFinite); STEP_DEFAULT_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); SKIPPABLE_STEPS = new Set(STEP_IDS); @@ -688,12 +701,15 @@ const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand'; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; +const FIVE_SIM_SUPPORTED_COUNTRY_ITEMS = Object.freeze([ + { id: 'indonesia', chn: '印度尼西亚', eng: 'Indonesia', searchText: 'indonesia 印度尼西亚 印尼 Indonesia ID +62' }, + { id: 'thailand', chn: '泰国', eng: 'Thailand', searchText: 'thailand 泰国 Thailand TH +66' }, + { id: 'vietnam', chn: '越南', eng: 'Vietnam', searchText: 'vietnam 越南 Vietnam VN +84' }, +]); +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(FIVE_SIM_SUPPORTED_COUNTRY_ITEMS.map((item) => item.id)); const DEFAULT_FIVE_SIM_OPERATOR = 'any'; -const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; -const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; -const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL; const DEFAULT_IP_PROXY_SERVICE = '711proxy'; const SUPPORTED_IP_PROXY_SERVICES = ['711proxy', 'lumiproxy', 'iproyal', 'omegaproxy']; const IP_PROXY_ENABLED_SERVICES = ['711proxy']; @@ -1897,19 +1913,23 @@ function syncLatestState(nextState) { } function normalizePlusPaymentMethod(value = '') { - if (window.GoPayUtils?.normalizePlusPaymentMethod) { - return window.GoPayUtils.normalizePlusPaymentMethod(value); + const rootScope = typeof window !== 'undefined' ? window : globalThis; + if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) { + return rootScope.GoPayUtils.normalizePlusPaymentMethod(value); } - return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY - ? PLUS_PAYMENT_METHOD_GOPAY - : PLUS_PAYMENT_METHOD_PAYPAL; + const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; + const paypalValue = typeof PLUS_PAYMENT_METHOD_PAYPAL !== 'undefined' ? PLUS_PAYMENT_METHOD_PAYPAL : 'paypal'; + return String(value || '').trim().toLowerCase() === gopayValue + ? gopayValue + : paypalValue; } function getSelectedPlusPaymentMethod(state = latestState) { + const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value) { return normalizePlusPaymentMethod(selectPlusPaymentMethod.value); } - return normalizePlusPaymentMethod(state?.plusPaymentMethod || DEFAULT_PLUS_PAYMENT_METHOD); + return normalizePlusPaymentMethod(state?.plusPaymentMethod || currentPlusPaymentMethod || defaultMethod); } function hasOwnStateValue(source, key) { @@ -1979,6 +1999,18 @@ function isAutoRunScheduledPhase() { return currentAutoRun.phase === 'scheduled'; } +function isAutoRunSourceSyncPhase(phase) { + return ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase); +} + +function shouldSyncRunCountFromAutoRunSource(source = {}) { + const phase = source.autoRunPhase ?? source.phase ?? currentAutoRun.phase; + const autoRunning = source.autoRunning !== undefined + ? Boolean(source.autoRunning) + : isAutoRunSourceSyncPhase(phase); + return autoRunning || isAutoRunSourceSyncPhase(phase); +} + function getAutoRunLabel(payload = currentAutoRun) { if ((payload.phase ?? currentAutoRun.phase) === 'scheduled') { return (payload.totalRuns || 1) > 1 ? ` (${payload.totalRuns}轮)` : ''; @@ -2174,7 +2206,7 @@ function getLockedRunCountFromEmailPool(provider = selectMailProvider.value) { return 0; } -function shouldLockRunCountToEmailPool(provider = selectMailProvider.value) { +function shouldLockRunCountToEmailPool(provider = (typeof selectMailProvider !== 'undefined' ? selectMailProvider?.value : undefined)) { return getLockedRunCountFromEmailPool(provider) > 0; } @@ -3007,6 +3039,12 @@ function collectSettingsPayload() { fiveSimCountryId: fiveSimCountry.id, fiveSimCountryLabel: fiveSimCountry.label, fiveSimCountryFallback, + fiveSimCountryOrder: [ + fiveSimCountry, + ...fiveSimCountryFallback, + ] + .map((country) => normalizeFiveSimCountryId(country?.id, '')) + .filter(Boolean), fiveSimMaxPrice: fiveSimMaxPriceValue, fiveSimOperator: fiveSimOperatorValue, }; @@ -3124,11 +3162,22 @@ function normalizePhoneSmsMaxPriceValue(value = '', provider = getSelectedPhoneS } function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { - if (typeof window !== 'undefined' && window.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryId) { - return window.PhoneSmsFiveSimProvider.normalizeFiveSimCountryId(value, fallback); + const supportedCountryIds = typeof FIVE_SIM_SUPPORTED_COUNTRY_ID_SET !== 'undefined' + ? FIVE_SIM_SUPPORTED_COUNTRY_ID_SET + : new Set(['indonesia', 'thailand', 'vietnam']); + const fallbackSource = fallback === undefined || fallback === null ? DEFAULT_FIVE_SIM_COUNTRY_ID : fallback; + const normalizedFallback = String(fallbackSource).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + const rawNormalized = typeof window !== 'undefined' && window.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryId + ? window.PhoneSmsFiveSimProvider.normalizeFiveSimCountryId(value, '') + : String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + const normalized = String(rawNormalized || '').trim().toLowerCase(); + if (supportedCountryIds.has(normalized)) { + return normalized; } - const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); - return normalized || fallback; + if (!normalizedFallback) { + return ''; + } + return supportedCountryIds.has(normalizedFallback) ? normalizedFallback : DEFAULT_FIVE_SIM_COUNTRY_ID; } function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_FIVE_SIM_COUNTRY_LABEL) { @@ -3145,8 +3194,13 @@ function formatFiveSimCountryDisplayLabel(id = '', englishValue = '', fallback = const countryId = normalizeFiveSimCountryId(id, ''); const english = normalizeFiveSimCountryLabel(englishValue || countryId || fallback, fallback); const chinese = FIVE_SIM_COUNTRY_CN_BY_ID[countryId] || ''; - if (chinese && english && chinese.toLowerCase() !== english.toLowerCase() && !String(english).includes(chinese)) { - return `${chinese} (${english})`; + if (chinese && english) { + if (String(english).includes(chinese)) { + return english; + } + if (chinese.toLowerCase() !== english.toLowerCase()) { + return `${chinese} (${english})`; + } } return chinese || english; } @@ -3211,8 +3265,16 @@ function normalizeFiveSimCountryFallbackList(value = []) { return normalized; } -function normalizeHeroSmsCountryId(value) { - return Math.max(1, Math.floor(Number(value) || DEFAULT_HERO_SMS_COUNTRY_ID)); +function normalizeHeroSmsCountryId(value, fallback = DEFAULT_HERO_SMS_COUNTRY_ID) { + const supportedCountryIds = typeof HERO_SMS_SUPPORTED_COUNTRY_ID_SET !== 'undefined' + ? HERO_SMS_SUPPORTED_COUNTRY_ID_SET + : new Set(['6', '52', '10']); + const parsed = Math.floor(Number(value)); + if (Number.isFinite(parsed) && supportedCountryIds.has(String(parsed))) { + return parsed; + } + const fallbackParsed = Math.floor(Number(fallback)); + return supportedCountryIds.has(String(fallbackParsed)) ? fallbackParsed : DEFAULT_HERO_SMS_COUNTRY_ID; } function normalizeHeroSmsCountryLabel(value = '') { @@ -3831,7 +3893,10 @@ function normalizeHeroSmsCountryFallbackList(value = []) { } } - if (!id || seen.has(id)) { + const supportedCountryIds = typeof HERO_SMS_SUPPORTED_COUNTRY_ID_SET !== 'undefined' + ? HERO_SMS_SUPPORTED_COUNTRY_ID_SET + : new Set(['6', '52', '10']); + if (!id || !supportedCountryIds.has(String(id)) || seen.has(id)) { return; } seen.add(id); @@ -4005,7 +4070,9 @@ function collectHeroSmsPriceEntriesForPreview(payload, entries = []) { const physicalCount = Number(payload.physicalCount); const hasCount = Number.isFinite(count); const hasPhysicalCount = Number.isFinite(physicalCount); - const stockCount = Math.max(hasCount ? count : 0, hasPhysicalCount ? physicalCount : 0); + const stockCount = hasPhysicalCount + ? physicalCount + : (hasCount ? count : 0); const hasStockField = hasCount || hasPhysicalCount; entries.push({ cost: directPrice, @@ -4963,8 +5030,12 @@ async function loadHeroSmsCountries() { ].filter(Boolean).join(' '), }; }) - .filter((entry) => entry.id) - .sort((left, right) => left.label.localeCompare(right.label)); + .filter((entry) => entry.id && FIVE_SIM_SUPPORTED_COUNTRY_ID_SET.has(String(entry.id))) + .sort((left, right) => { + const leftIndex = FIVE_SIM_SUPPORTED_COUNTRY_ITEMS.findIndex((item) => item.id === String(left.id)); + const rightIndex = FIVE_SIM_SUPPORTED_COUNTRY_ITEMS.findIndex((item) => item.id === String(right.id)); + return leftIndex - rightIndex; + }); if (!optionItems.length) { throw new Error('empty country list'); } @@ -4974,12 +5045,11 @@ async function loadHeroSmsCountries() { applyOptions(optionItems, selectHeroSmsCountryFallback); } catch (error) { console.warn('Failed to load 5sim countries:', error); - const fallbackItems = [ - { id: 'england', label: formatFiveSimCountryDisplayLabel('england', 'England'), searchText: 'england 英国 England uk gb united kingdom' }, - { id: 'usa', label: formatFiveSimCountryDisplayLabel('usa', 'USA'), searchText: 'usa 美国 US United States' }, - { id: 'thailand', label: formatFiveSimCountryDisplayLabel('thailand', 'Thailand'), searchText: 'thailand 泰国 TH' }, - { id: 'japan', label: formatFiveSimCountryDisplayLabel('japan', 'Japan'), searchText: 'japan 日本 JP' }, - ]; + const fallbackItems = FIVE_SIM_SUPPORTED_COUNTRY_ITEMS.map((item) => ({ + id: item.id, + label: formatFiveSimCountryDisplayLabel(item.id, item.eng), + searchText: item.searchText, + })); applyOptions(fallbackItems, selectHeroSmsCountry); applyOptions(fallbackItems, selectHeroSmsCountryFallback); heroSmsCountrySearchTextById.clear(); @@ -5002,8 +5072,12 @@ async function loadHeroSmsCountries() { throw new Error('empty country list'); } const optionItems = countries - .filter((item) => Number(item?.id) > 0 && (String(item?.eng || '').trim() || String(item?.chn || '').trim())) - .sort((left, right) => String(left.eng || '').localeCompare(String(right.eng || ''))) + .filter((item) => HERO_SMS_SUPPORTED_COUNTRY_ID_SET.has(String(Math.floor(Number(item?.id)))) && (String(item?.eng || '').trim() || String(item?.chn || '').trim())) + .sort((left, right) => { + const leftIndex = HERO_SMS_SUPPORTED_COUNTRY_ITEMS.findIndex((item) => String(item.id) === String(Math.floor(Number(left?.id)))); + const rightIndex = HERO_SMS_SUPPORTED_COUNTRY_ITEMS.findIndex((item) => String(item.id) === String(Math.floor(Number(right?.id)))); + return leftIndex - rightIndex; + }) .map((item) => { const id = normalizeHeroSmsCountryId(item.id); const label = buildHeroSmsCountryDisplayLabel(item); @@ -6049,15 +6123,21 @@ function updatePhoneVerificationSettingsUI() { } function updatePlusModeUI() { + const paypalValue = typeof PLUS_PAYMENT_METHOD_PAYPAL !== 'undefined' ? PLUS_PAYMENT_METHOD_PAYPAL : 'paypal'; + const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; + const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : paypalValue; const enabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled ? Boolean(inputPlusModeEnabled.checked) : false; - const method = enabled ? getSelectedPlusPaymentMethod() : DEFAULT_PLUS_PAYMENT_METHOD; + const method = enabled ? getSelectedPlusPaymentMethod() : defaultMethod; if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) { selectPlusPaymentMethod.value = method; + if (selectPlusPaymentMethod.style) { + selectPlusPaymentMethod.style.display = enabled ? '' : 'none'; + } } if (typeof plusPaymentMethodCaption !== 'undefined' && plusPaymentMethodCaption) { - plusPaymentMethodCaption.textContent = method === PLUS_PAYMENT_METHOD_GOPAY + plusPaymentMethodCaption.textContent = method === gopayValue ? 'GoPay 印尼订阅链路' : 'PayPal 订阅链路'; } @@ -6075,7 +6155,10 @@ function updatePlusModeUI() { if (!row) { return; } - row.style.display = enabled && method === PLUS_PAYMENT_METHOD_PAYPAL ? '' : 'none'; + const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value + ? normalizePlusPaymentMethod(selectPlusPaymentMethod.value) + : method; + row.style.display = enabled && selectedMethod === paypalValue ? '' : 'none'; }); [ typeof rowGoPayCountryCode !== 'undefined' ? rowGoPayCountryCode : null, @@ -6086,7 +6169,10 @@ function updatePlusModeUI() { if (!row) { return; } - row.style.display = enabled && method === PLUS_PAYMENT_METHOD_GOPAY ? '' : 'none'; + const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value + ? normalizePlusPaymentMethod(selectPlusPaymentMethod.value) + : method; + row.style.display = enabled && selectedMethod === gopayValue ? '' : 'none'; }); } @@ -6117,6 +6203,97 @@ async function setRuntimeEmailState(email) { return normalizedEmail; } +async function openPlusManualConfirmationDialog(options = {}) { + const method = String(options.method || '').trim().toLowerCase(); + const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; + const title = String(options.title || '').trim() || (method === gopayValue ? 'GoPay 订阅确认' : '手动确认'); + const message = String(options.message || '').trim() + || (method === gopayValue + ? '请在当前订阅页中手动完成 GoPay 订阅,完成后点击“我已完成订阅”继续。' + : '请先在页面中完成当前手动操作,完成后点击确认继续。'); + return openActionModal({ + title, + message, + actions: [ + { id: 'cancel', label: '取消等待', variant: 'btn-ghost' }, + { id: 'confirm', label: '我已完成订阅', variant: 'btn-primary' }, + ], + alert: method === gopayValue + ? { text: '确认后流程会直接继续到 Plus 模式第 10 步 OAuth 登录。', tone: 'info' } + : null, + }); +} + +async function syncPlusManualConfirmationDialog() { + const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; + const requestId = String(latestState?.plusManualConfirmationRequestId || '').trim(); + const pending = Boolean(latestState?.plusManualConfirmationPending); + if (!pending || !requestId || plusManualConfirmationDialogInFlight || activePlusManualConfirmationRequestId === requestId) { + return; + } + + const step = Number(latestState?.plusManualConfirmationStep) || 0; + const method = String(latestState?.plusManualConfirmationMethod || '').trim().toLowerCase(); + const title = latestState?.plusManualConfirmationTitle; + const message = latestState?.plusManualConfirmationMessage; + activePlusManualConfirmationRequestId = requestId; + plusManualConfirmationDialogInFlight = true; + let shouldReopenDialog = false; + + try { + const choice = await openPlusManualConfirmationDialog({ + method, + title, + message, + }); + const currentRequestId = String(latestState?.plusManualConfirmationRequestId || '').trim(); + const stillPending = Boolean(latestState?.plusManualConfirmationPending); + if (!stillPending || currentRequestId !== requestId) { + return; + } + if (choice == null) { + shouldReopenDialog = true; + showToast('当前订阅确认仍在等待中,将重新弹出确认窗口。', 'info', 1800); + return; + } + + const confirmed = choice === 'confirm'; + const response = await chrome.runtime.sendMessage({ + type: 'RESOLVE_PLUS_MANUAL_CONFIRMATION', + source: 'sidepanel', + payload: { + step, + requestId, + confirmed, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + if (confirmed) { + showToast(method === gopayValue ? 'GoPay 订阅已确认,正在继续 OAuth 登录...' : '已确认,流程继续执行中...', 'info', 2200); + } else { + showToast(method === gopayValue ? '已取消 GoPay 订阅等待。' : '已取消当前手动确认。', 'warn', 2200); + } + } catch (error) { + showToast(error?.message || String(error || '未知错误'), 'error'); + } finally { + if (activePlusManualConfirmationRequestId === requestId) { + activePlusManualConfirmationRequestId = ''; + } + plusManualConfirmationDialogInFlight = false; + if ( + shouldReopenDialog + && latestState?.plusManualConfirmationPending + && String(latestState?.plusManualConfirmationRequestId || '').trim() === requestId + ) { + setTimeout(() => { + void syncPlusManualConfirmationDialog(); + }, 0); + } + } +} + async function clearRegistrationEmail(options = {}) { const { silent = false } = options; if (!inputEmail.value.trim() && !latestState?.email) { @@ -6223,13 +6400,11 @@ function applyAutoRunStatus(payload = currentAutoRun) { setSettingsCardLocked(settingsCardLocked); - const lockedRunCount = getLockedRunCountFromEmailPool(); - const shouldSyncAutoRunTotalRuns = currentAutoRun.autoRunning - || locked - || paused - || scheduled; - - inputRunCount.disabled = currentAutoRun.autoRunning || lockedRunCount > 0; + inputRunCount.disabled = currentAutoRun.autoRunning || ( + typeof shouldLockRunCountToEmailPool === 'function' + ? shouldLockRunCountToEmailPool() + : getLockedRunCountFromEmailPool() > 0 + ); btnAutoRun.disabled = currentAutoRun.autoRunning; btnFetchEmail.disabled = locked || isCustomMailProvider() @@ -6237,9 +6412,18 @@ function applyAutoRunStatus(payload = currentAutoRun) { inputEmail.disabled = locked; inputAutoSkipFailures.disabled = scheduled; + const lockedRunCount = typeof getLockedRunCountFromEmailPool === 'function' + ? getLockedRunCountFromEmailPool() + : 0; + const isSyncPhase = typeof isAutoRunSourceSyncPhase === 'function' + ? isAutoRunSourceSyncPhase + : (phase) => ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase); + const shouldSyncRunCount = typeof shouldSyncRunCountFromAutoRunSource === 'function' + ? shouldSyncRunCountFromAutoRunSource(currentAutoRun) + : (currentAutoRun.autoRunning || isSyncPhase(currentAutoRun.phase)); if (lockedRunCount > 0) { inputRunCount.value = String(lockedRunCount); - } else if (shouldSyncAutoRunTotalRuns && currentAutoRun.totalRuns > 0) { + } else if (shouldSyncRunCount && currentAutoRun.totalRuns > 0) { inputRunCount.value = String(currentAutoRun.totalRuns); } @@ -6340,21 +6524,31 @@ function renderStepsList() { updateButtonStates(); } -function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethod = 'paypal', options = {}) { +function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOrOptions = {}, maybeOptions = {}) { const nextPlusModeEnabled = Boolean(plusModeEnabled); - const nextPaymentMethod = normalizePlusPaymentMethod(options.plusPaymentMethod || getSelectedPlusPaymentMethod(latestState)); + const options = typeof plusPaymentMethodOrOptions === 'string' + ? maybeOptions + : (plusPaymentMethodOrOptions || {}); + const rawPaymentMethod = typeof plusPaymentMethodOrOptions === 'string' + ? plusPaymentMethodOrOptions + : (options.plusPaymentMethod || getSelectedPlusPaymentMethod(latestState)); + const nextPaymentMethod = normalizePlusPaymentMethod(rawPaymentMethod); + const rootScope = typeof window !== 'undefined' ? window : globalThis; const currentPaymentStep = stepDefinitions.find((step) => step.key === 'paypal-approve'); - const nextPaymentTitle = window.MultiPageStepDefinitions?.getPlusPaymentStepTitle?.({ + const nextPaymentTitle = rootScope.MultiPageStepDefinitions?.getPlusPaymentStepTitle?.({ plusModeEnabled: nextPlusModeEnabled, plusPaymentMethod: nextPaymentMethod, }); const paymentTitleChanged = Boolean(nextPlusModeEnabled && currentPaymentStep && nextPaymentTitle && currentPaymentStep.title !== nextPaymentTitle); - const shouldRender = Boolean(options.render) || nextPlusModeEnabled !== currentPlusModeEnabled || paymentTitleChanged; + const shouldRender = Boolean(options.render) + || nextPlusModeEnabled !== currentPlusModeEnabled + || nextPaymentMethod !== currentPlusPaymentMethod + || paymentTitleChanged; if (!shouldRender) { return; } - rebuildStepDefinitionState(nextPlusModeEnabled, { plusPaymentMethod: nextPaymentMethod }); + rebuildStepDefinitionState(nextPlusModeEnabled, nextPaymentMethod); renderStepsList(); } @@ -6755,7 +6949,13 @@ function applySettingsState(state) { if (typeof updateHeroSmsRuntimeDisplay === 'function') { updateHeroSmsRuntimeDisplay(state); } - if (state?.autoRunTotalRuns) { + const isSyncPhase = typeof isAutoRunSourceSyncPhase === 'function' + ? isAutoRunSourceSyncPhase + : (phase) => ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase); + const shouldSyncInitialRunCount = typeof shouldSyncRunCountFromAutoRunSource === 'function' + ? shouldSyncRunCountFromAutoRunSource(state) + : (Boolean(state?.autoRunning) || isSyncPhase(state?.autoRunPhase ?? state?.phase)); + if (state?.autoRunTotalRuns && shouldSyncInitialRunCount) { inputRunCount.value = String(state.autoRunTotalRuns); } @@ -10418,14 +10618,68 @@ inputAutoDelayMinutes.addEventListener('blur', () => { }); + +function getPhoneSmsCountrySelectionForProvider(provider = getSelectedPhoneSmsProvider(), options = {}) { + const normalizedProvider = normalizePhoneSmsProvider(provider); + const countrySelect = selectHeroSmsCountry || selectHeroSmsCountryFallback; + const selectionLimit = Math.max(1, Math.floor(Number(options.maxSelection) || HERO_SMS_COUNTRY_SELECTION_MAX)); + const ensureDefault = options.ensureDefault !== false; + const defaultCountry = normalizedProvider === PHONE_SMS_PROVIDER_FIVE_SIM + ? { id: DEFAULT_FIVE_SIM_COUNTRY_ID, label: DEFAULT_FIVE_SIM_COUNTRY_LABEL } + : { id: DEFAULT_HERO_SMS_COUNTRY_ID, label: DEFAULT_HERO_SMS_COUNTRY_LABEL }; + + if (!countrySelect) { + return ensureDefault ? [defaultCountry] : []; + } + + const optionByValue = new Map( + Array.from(countrySelect.options || []).map((option) => [String(option.value), option]) + ); + const selectedIds = Array.from(countrySelect.options || []) + .filter((option) => option.selected) + .map((option) => normalizePhoneSmsCountryId(option.value, normalizedProvider)) + .filter(Boolean); + + if (!selectedIds.length && !countrySelect.multiple) { + const fallbackId = normalizePhoneSmsCountryId(countrySelect.value, normalizedProvider); + if (fallbackId) { + selectedIds.push(fallbackId); + } + } + + const selectedSet = new Set(selectedIds.map((id) => String(id))); + let orderedIds = heroSmsCountrySelectionOrder + .map((id) => normalizePhoneSmsCountryId(id, normalizedProvider)) + .filter((id) => id && selectedSet.has(String(id))); + + selectedIds.forEach((id) => { + if (!orderedIds.some((existing) => String(existing) === String(id))) { + orderedIds.push(id); + } + }); + + if (!orderedIds.length && ensureDefault) { + orderedIds = [defaultCountry.id]; + } + + return orderedIds.slice(0, selectionLimit).map((id) => { + const option = optionByValue.get(String(id)); + const optionLabel = String(option?.textContent || '').trim(); + return { + id, + label: optionLabel || normalizePhoneSmsCountryLabel(defaultCountry.id === id ? defaultCountry.label : '', normalizedProvider), + }; + }); +} + async function switchPhoneSmsProvider(nextProvider) { const previousProvider = getLastAppliedPhoneSmsProvider(); const normalizedNextProvider = normalizePhoneSmsProvider(nextProvider); const currentApiKey = String(inputHeroSmsApiKey?.value || ''); const currentMaxPrice = normalizePhoneSmsMaxPriceValue(inputHeroSmsMaxPrice?.value || '', previousProvider); - const currentSelection = typeof syncHeroSmsFallbackSelectionOrderFromSelect === 'function' - ? syncHeroSmsFallbackSelectionOrderFromSelect({ enforceMax: true, ensureDefault: true, showLimitToast: false }) + const currentSelection = typeof getPhoneSmsCountrySelectionForProvider === 'function' + ? getPhoneSmsCountrySelectionForProvider(previousProvider, { ensureDefault: true }) : []; const currentPrimary = currentSelection[0] || getSelectedHeroSmsCountryOption(); const currentFallback = currentSelection.slice(1); @@ -10439,6 +10693,9 @@ async function switchPhoneSmsProvider(nextProvider) { patch.fiveSimCountryId = currentPrimary.id; patch.fiveSimCountryLabel = currentPrimary.label; patch.fiveSimCountryFallback = currentFallback; + patch.fiveSimCountryOrder = [currentPrimary, ...currentFallback] + .map((country) => normalizeFiveSimCountryId(country?.id, '')) + .filter(Boolean); patch.fiveSimOperator = normalizeFiveSimOperator(inputFiveSimOperator?.value || latestState?.fiveSimOperator); } else { patch.heroSmsApiKey = currentApiKey; @@ -11260,6 +11517,16 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ); updatePlusModeUI(); } + if ( + message.payload.plusManualConfirmationPending !== undefined + || message.payload.plusManualConfirmationRequestId !== undefined + || message.payload.plusManualConfirmationStep !== undefined + || message.payload.plusManualConfirmationMethod !== undefined + || message.payload.plusManualConfirmationTitle !== undefined + || message.payload.plusManualConfirmationMessage !== undefined + ) { + void syncPlusManualConfirmationDialog(); + } if (message.payload.currentHotmailAccountId !== undefined || message.payload.hotmailAccounts !== undefined) { renderHotmailAccounts(); if (selectMailProvider.value === 'hotmail-api') { @@ -11511,6 +11778,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { || message.payload.fiveSimCountryId !== undefined || message.payload.fiveSimCountryLabel !== undefined || message.payload.fiveSimCountryFallback !== undefined + || message.payload.fiveSimCountryOrder !== undefined ) { const activeProvider = getSelectedPhoneSmsProvider(); const nextPrimary = activeProvider === PHONE_SMS_PROVIDER_FIVE_SIM @@ -11542,6 +11810,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ? normalizeFiveSimCountryFallbackList( message.payload.fiveSimCountryFallback !== undefined ? message.payload.fiveSimCountryFallback + : message.payload.fiveSimCountryOrder !== undefined + ? message.payload.fiveSimCountryOrder : latestState?.fiveSimCountryFallback ) : normalizeHeroSmsCountryFallbackList( diff --git a/tests/address-sources.test.js b/tests/address-sources.test.js index 53bc702..a192d58 100644 --- a/tests/address-sources.test.js +++ b/tests/address-sources.test.js @@ -11,6 +11,8 @@ test('address sources normalize supported countries and return local seeds', () assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU'); assert.equal(api.normalizeCountryCode('印尼'), 'ID'); assert.equal(api.normalizeCountryCode('日本'), 'JP'); + assert.equal(api.normalizeCountryCode('韩国'), 'KR'); + assert.equal(api.normalizeCountryCode('South Korea'), 'KR'); assert.equal(api.normalizeCountryCode('unknown'), ''); const deSeed = api.getAddressSeedForCountry('DE'); @@ -30,4 +32,9 @@ test('address sources normalize supported countries and return local seeds', () const jpSeed = api.getAddressSeedForCountry('日本'); assert.equal(jpSeed.countryCode, 'JP'); assert.equal(jpSeed.fallback.region, 'Tokyo'); + + const krSeed = api.getAddressSeedForCountry('Korea'); + assert.equal(krSeed.countryCode, 'KR'); + assert.equal(krSeed.fallback.region, 'Seoul'); + assert.match(krSeed.fallback.postalCode, /^\d{5}$/); }); diff --git a/tests/auto-run-add-phone-stop.test.js b/tests/auto-run-add-phone-stop.test.js index 7e644f5..41c082f 100644 --- a/tests/auto-run-add-phone-stop.test.js +++ b/tests/auto-run-add-phone-stop.test.js @@ -653,6 +653,172 @@ test('auto-run controller skips user_already_exists failures to the next round i assert.equal(runtime.state.autoRunSessionId, 0); }); + +test('auto-run controller skips step 4 repeated 405 recovery failures to the next round', async () => { + const events = { + logs: [], + broadcasts: [], + accountRecords: [], + runCalls: 0, + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: true, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: '163', + emailGenerator: 'duck', + gmailBaseEmail: '', + mail2925BaseEmail: '', + emailPrefix: 'demo', + inbucketHost: '', + inbucketMailbox: '', + cloudflareDomain: '', + cloudflareDomains: [], + tabRegistry: {}, + sourceLastUrls: {}, + autoRunRoundSummaries: [], + }; + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + let sessionSeed = 0; + + const controller = api.createAutoRunController({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + appendAccountRunRecord: async (status, _state, reason) => { + events.accountRecords.push({ status, reason }); + return { status, reason }; + }, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}) => { + events.broadcasts.push({ phase, ...payload }); + currentState = { + ...currentState, + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun, + autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns, + autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun, + autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId, + }; + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? 0, + autoRunTotalRuns: payload.totalRuns ?? 1, + autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: payload.sessionId ?? 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: () => false, + isPlusCheckoutNonFreeTrialFailure: () => false, + isRestartCurrentAttemptError: () => false, + isSignupUserAlreadyExistsFailure: () => false, + isStep4Route405RecoveryLimitFailure: (error) => /STEP4_405_RECOVERY_LIMIT::/.test(error?.message || String(error || '')), + isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。', + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)), + persistAutoRunTimerPlan: async () => ({}), + resetState: async () => { + currentState = { + ...currentState, + stepStatuses: {}, + tabRegistry: {}, + sourceLastUrls: {}, + }; + }, + runAutoSequenceFromStep: async () => { + events.runCalls += 1; + if (events.runCalls === 1) { + throw new Error('STEP4_405_RECOVERY_LIMIT::步骤 4:检测到 405 错误页面,已连续点击“重试”恢复 3/3 次仍未恢复,当前轮将结束并进入下一轮。'); + } + }, + runtime, + setState: async (updates = {}) => { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, + sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls, + }; + }, + sleepWithStop: async () => {}, + throwIfAutoRunSessionStopped: (sessionId) => { + if (sessionId && sessionId !== runtime.state.autoRunSessionId) { + throw new Error('流程已被用户停止。'); + } + }, + waitForRunningStepsToFinish: async () => currentState, + chrome: { + runtime: { + sendMessage() { + return Promise.resolve(); + }, + }, + }, + }); + + await controller.autoRunLoop(2, { + autoRunSkipFailures: true, + mode: 'restart', + }); + + assert.equal(events.runCalls, 2, 'step 4 repeated 405 failure should skip the current round and continue with the next round'); + assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'step 4 repeated 405 should not retry the same round'); + assert.equal(events.accountRecords.length, 1, 'step 4 repeated 405 should persist a failed round record'); + assert.equal(events.accountRecords[0].status, 'failed'); + assert.match(events.accountRecords[0].reason, /STEP4_405_RECOVERY_LIMIT::/); + assert.ok(events.logs.some(({ message }) => /连续 405.*继续下一轮|继续下一轮/.test(message))); + assert.equal(runtime.state.autoRunActive, false); + assert.equal(runtime.state.autoRunSessionId, 0); +}); + test('auto-run controller keeps retrying the same custom mail provider pool email until success', async () => { const events = { logs: [], @@ -817,3 +983,171 @@ test('auto-run controller keeps retrying the same custom mail provider pool emai assert.equal(runtime.state.autoRunActive, false); assert.equal(runtime.state.autoRunSessionId, 0); }); + +test('auto-run controller retries 5sim rate limit failures instead of treating current add-phone page as fatal', async () => { + const events = { + logs: [], + broadcasts: [], + accountRecords: [], + runCalls: 0, + sleeps: [], + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: true, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: '163', + emailGenerator: 'duck', + gmailBaseEmail: '', + mail2925BaseEmail: '', + emailPrefix: 'demo', + inbucketHost: '', + inbucketMailbox: '', + cloudflareDomain: '', + cloudflareDomains: [], + tabRegistry: {}, + sourceLastUrls: {}, + autoRunRoundSummaries: [], + }; + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + let sessionSeed = 0; + + const controller = api.createAutoRunController({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + appendAccountRunRecord: async (status, _state, reason) => { + events.accountRecords.push({ status, reason }); + return { status, reason }; + }, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}) => { + events.broadcasts.push({ phase, ...payload }); + currentState = { + ...currentState, + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun, + autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns, + autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun, + autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId, + }; + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? 0, + autoRunTotalRuns: payload.totalRuns ?? 1, + autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: payload.sessionId ?? 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')), + isPhoneSmsPlatformRateLimitFailure: (error) => /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(error?.message || String(error || '')), + isRestartCurrentAttemptError: () => false, + isSignupUserAlreadyExistsFailure: () => false, + isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。', + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)), + persistAutoRunTimerPlan: async () => ({}), + resetState: async () => { + currentState = { + ...currentState, + stepStatuses: {}, + tabRegistry: {}, + sourceLastUrls: {}, + }; + }, + runAutoSequenceFromStep: async () => { + events.runCalls += 1; + if (events.runCalls === 1) { + throw new Error('FIVE_SIM_RATE_LIMIT::5sim 购买接口触发限流,请稍后再试:印度 (India): rate limit。当前页面 https://auth.openai.com/add-phone'); + } + }, + runtime, + setState: async (updates = {}) => { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, + sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls, + }; + }, + sleepWithStop: async (ms) => { + events.sleeps.push(ms); + }, + throwIfAutoRunSessionStopped: (sessionId) => { + if (sessionId && sessionId !== runtime.state.autoRunSessionId) { + throw new Error('流程已被用户停止。'); + } + }, + waitForRunningStepsToFinish: async () => currentState, + chrome: { + runtime: { + sendMessage() { + return Promise.resolve(); + }, + }, + }, + }); + + await controller.autoRunLoop(1, { + autoRunSkipFailures: true, + mode: 'restart', + }); + + assert.equal(events.runCalls, 2, '5sim rate limit should use same-round retry instead of add-phone fatal skip'); + assert.equal(events.broadcasts.filter(({ phase }) => phase === 'retrying').length, 1); + assert.equal(events.accountRecords.length, 0); + assert.ok(events.logs.some(({ message }) => /自动重试/.test(message))); + assert.equal(events.logs.some(({ message }) => /触发 add-phone\/手机号页/.test(message)), false); + assert.equal(events.sleeps.filter((ms) => ms === 3000).length, 1); + assert.equal(runtime.state.autoRunActive, false); + assert.equal(runtime.state.autoRunSessionId, 0); +}); + diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index 71fee0e..8b3397a 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -147,6 +147,10 @@ function getLoginAuthStateLabel(state) { function getErrorMessage(error) { return error?.message || String(error || ''); } +function isPhoneSmsPlatformRateLimitFailure(error) { + const message = getErrorMessage(error); + return /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(message); +} async function getLoginAuthStateFromContent() { return ${JSON.stringify(authState)}; } @@ -254,6 +258,24 @@ test('auto-run does not restart step 7 when phone verification exhausted replace assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); }); + +test('auto-run post-login restart decision does not treat 5sim rate limit on add-phone page as add-phone fatal', async () => { + const harness = createHarness({ + failureStep: 9, + failureBudget: 1, + failureMessage: 'FIVE_SIM_RATE_LIMIT::5sim 购买接口触发限流,请稍后再试:印度 (India): rate limit。', + authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' }, + }); + + const result = await harness.runAndCaptureError(); + + assert.ok(result?.error); + assert.equal(result.events.invalidations.length, 0); + assert.deepStrictEqual(result.events.steps, [7, 8, 9]); + assert.ok(!result.events.logs.some(({ message }) => /进入 add-phone/.test(message))); + assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); +}); + test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => { const harness = createHarness({ failureStep: 9, diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js index 96f91dd..1507162 100644 --- a/tests/background-account-history-settings.test.js +++ b/tests/background-account-history-settings.test.js @@ -106,9 +106,11 @@ const HERO_SMS_COUNTRY_ID = 52; const HERO_SMS_COUNTRY_LABEL = 'Thailand'; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; -const FIVE_SIM_COUNTRY_ID = 'england'; -const FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const FIVE_SIM_COUNTRY_ID = 'vietnam'; +const FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, mailProvider: '163', @@ -156,19 +158,19 @@ return { assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', '5SIM'), '5sim'); assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'unknown'), 'hero-sms'); assert.equal(api.normalizePersistentSettingValue('fiveSimApiKey', ' demo-five '), ' demo-five '); - assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ' England! '), 'england'); - assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ''), 'england'); - assert.equal(api.normalizePersistentSettingValue('fiveSimCountryLabel', ''), '英国 (England)'); + assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ' England! '), 'vietnam'); + assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ''), 'vietnam'); + assert.equal(api.normalizePersistentSettingValue('fiveSimCountryLabel', ''), '越南 (Vietnam)'); assert.equal(api.normalizePersistentSettingValue('fiveSimMaxPrice', '9.87654'), '9.8765'); assert.equal(api.normalizePersistentSettingValue('fiveSimMaxPrice', '-1'), ''); assert.equal(api.normalizePersistentSettingValue('fiveSimOperator', ''), 'any'); assert.deepStrictEqual( api.normalizePersistentSettingValue('fiveSimCountryFallback', [{ id: 'usa', label: 'USA' }, 'thailand:Thailand']), - [{ id: 'usa', label: 'USA' }, { id: 'thailand', label: 'Thailand' }] + [{ id: 'thailand', label: 'Thailand' }] ); assert.deepStrictEqual( api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]), - [{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }] + [{ id: 52, label: 'Country #52' }] ); assert.equal( api.normalizePersistentSettingValue('accountRunHistoryHelperBaseUrl', 'http://127.0.0.1:17373/append-account-log'), diff --git a/tests/background-ip-proxy-core.test.js b/tests/background-ip-proxy-core.test.js index 755bc52..c0f4561 100644 --- a/tests/background-ip-proxy-core.test.js +++ b/tests/background-ip-proxy-core.test.js @@ -19,6 +19,18 @@ const IP_PROXY_FETCH_TIMEOUT_MS = 20000; const IP_PROXY_SETTINGS_SCOPE = 'regular'; const IP_PROXY_BYPASS_LIST = ['', 'localhost', '127.0.0.1']; const IP_PROXY_ROUTE_ALL_TRAFFIC = true; +const IP_PROXY_FORCE_DIRECT_HOST_PATTERNS = [ + 'pm-redirects.stripe.com', + '*.pm-redirects.stripe.com', + 'hwork.pro', + '*.hwork.pro', + 'auth.openai.com', + 'auth0.openai.com', + 'accounts.openai.com', + 'luckyous.com', + '*.luckyous.com', +]; +const IP_PROXY_FORCE_DIRECT_FALLBACK = 'PROXY 127.0.0.1:7897'; const IP_PROXY_ACCOUNT_LIST_ENABLED = ${accountListEnabled ? 'true' : 'false'}; const IP_PROXY_TARGET_HOST_PATTERNS = [ 'openai.com', @@ -32,11 +44,17 @@ ${coreSource} return { applyExitRegionExpectation, buildIpProxyPacScript, + buildIpProxyRoutingStatePatch, + applyTargetReachabilityExpectation, getAccountModeProxyPoolFromState, normalizeIpProxyAccountList, normalizeProxyPoolEntries, + parseProxyExitProbePayload, parseIpProxyLine, + resolveExitProbeEndpoints, resolveIpProxyAutoSwitchThreshold, + resolveTargetReachabilityEndpoints, + shouldEnableIpProxyLeakGuardForStatus, }; `)(); } @@ -75,6 +93,48 @@ test('IP proxy parser ignores disabled lines and normalizes proxy entries', () = assert.equal(pool[1].port, 8080); }); +test('IP proxy probe payload parser extracts country from common probe endpoints', () => { + const api = loadIpProxyCore(); + + assert.deepEqual( + api.parseProxyExitProbePayload('ip=219.104.171.52\nloc=JP\ncolo=NRT', 'text/plain'), + { ip: '219.104.171.52', region: 'JP' } + ); + assert.deepEqual( + api.parseProxyExitProbePayload(JSON.stringify({ + ip: '219.104.171.52', + country: 'JP', + city: 'Osaka', + }), 'application/json'), + { ip: '219.104.171.52', region: 'JP' } + ); + assert.deepEqual( + api.parseProxyExitProbePayload(JSON.stringify({ + ip: '219.104.171.52', + country_code: 'JP', + country: 'Japan', + }), 'application/json'), + { ip: '219.104.171.52', region: 'JP' } + ); +}); + +test('IP proxy routing state patch keeps exit probe endpoint for diagnostics', () => { + const api = loadIpProxyCore(); + const patch = api.buildIpProxyRoutingStatePatch({ + applied: true, + reason: 'applied', + provider: '711proxy', + exitIp: '219.104.171.52', + exitRegion: 'JP', + exitSource: 'page_context', + exitEndpoint: 'https://ipinfo.io/json', + }); + + assert.equal(patch.ipProxyAppliedExitIp, '219.104.171.52'); + assert.equal(patch.ipProxyAppliedExitRegion, 'JP'); + assert.equal(patch.ipProxyAppliedExitEndpoint, 'https://ipinfo.io/json'); +}); + test('711 fixed-account mode applies region and sticky session parameters', () => { const api = loadIpProxyCore(); const pool = api.getAccountModeProxyPoolFromState({ @@ -112,6 +172,15 @@ test('IP proxy PAC keeps local traffic direct and routes target traffic through assert.match(pac, /PROXY global\.rotgb\.711proxy\.com:10000/); assert.match(pac, /chatgpt\.com/); assert.match(pac, /openai\.com/); + assert.match(pac, /pm-redirects\.stripe\.com/); + assert.match(pac, /hwork\.pro/); + assert.match(pac, /auth\.openai\.com/); + assert.match(pac, /auth0\.openai\.com/); + assert.match(pac, /accounts\.openai\.com/); + assert.match(pac, /luckyous\.com/); + assert.match(pac, /forceDirectPatterns/); + assert.match(pac, /PROXY 127\.0\.0\.1:7897/); + assert.doesNotMatch(pac, /PROXY 127\.0\.0\.1:7897; DIRECT/); }); test('sidepanel loads IP proxy scripts before sidepanel bootstrap', () => { @@ -161,3 +230,53 @@ test('711 proxy region mismatch with missing auth challenge keeps routing as war ); assert.match(String(status.warning || ''), /期望 US,实际 BR/); }); + +test('711 sticky session keeps IP probe on ipinfo but separately checks ChatGPT target', () => { + const api = loadIpProxyCore(); + + assert.deepStrictEqual( + api.resolveExitProbeEndpoints({ + provider: '711proxy', + username: 'USER794331-zone-custom-region-TH-session-69381850-sessTime-5', + }), + ['https://ipinfo.io/json'] + ); + + assert.deepStrictEqual(api.resolveTargetReachabilityEndpoints(), ['https://chatgpt.com/']); +}); + +test('target reachability failure turns detected exit IP into connectivity_failed', () => { + const api = loadIpProxyCore(); + const status = api.applyTargetReachabilityExpectation({ + applied: true, + reason: 'applied', + exitIp: '58.10.48.73', + exitRegion: 'TH', + }, { + reachable: false, + endpoint: 'https://chatgpt.com/', + error: 'target:page_context:https://chatgpt.com/:net::ERR_EMPTY_RESPONSE', + }); + + assert.equal(status.applied, false); + assert.equal(status.reason, 'connectivity_failed'); + assert.match(status.error, /已检测到出口 IP 58\.10\.48\.73 \[TH\]/); + assert.match(status.error, /真实目标 chatgpt\.com 不可达/); + assert.match(status.error, /ERR_EMPTY_RESPONSE/); +}); + +test('connectivity_failed keeps DNR leak guard off so ChatGPT shows proxy error instead of blocked by extension', () => { + const api = loadIpProxyCore(); + + assert.equal(api.shouldEnableIpProxyLeakGuardForStatus({ + enabled: true, + applied: false, + reason: 'connectivity_failed', + }), false); + + assert.equal(api.shouldEnableIpProxyLeakGuardForStatus({ + enabled: true, + applied: false, + reason: 'missing_proxy_entry', + }), true); +}); diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js index 76ec930..6f10a74 100644 --- a/tests/background-luckmail.test.js +++ b/tests/background-luckmail.test.js @@ -451,6 +451,9 @@ const PERSISTED_SETTING_DEFAULTS = { luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL, luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE, luckmailDomain: '', + luckmailUsedPurchases: {}, + luckmailPreserveTagId: 0, + luckmailPreserveTagName: '保留', }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); function normalizeLuckmailBaseUrl(value) { @@ -462,6 +465,18 @@ function normalizeLuckmailEmailType(value) { ? String(value || '').trim() : DEFAULT_LUCKMAIL_EMAIL_TYPE; } +function normalizeLuckmailPurchaseId(value) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : ''; +} +function normalizeLuckmailUsedPurchases(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.entries(value).reduce((result, [key, used]) => { + const normalizedKey = normalizeLuckmailPurchaseId(key); + if (normalizedKey) result[normalizedKey] = Boolean(used); + return result; + }, {}); +} function resolveLegacyAutoStepDelaySeconds() { return undefined; } @@ -487,6 +502,17 @@ return { luckmailEmailType: 'ms_imap', luckmailDomain: 'outlook.com', }); + + const statePayload = api.buildPersistentSettingsPayload({ + luckmailUsedPurchases: { 88: true, 99: false, bad: true }, + luckmailPreserveTagId: '9', + luckmailPreserveTagName: ' 保留邮箱 ', + }); + assert.deepStrictEqual(statePayload, { + luckmailUsedPurchases: { 88: true, 99: false }, + luckmailPreserveTagId: 9, + luckmailPreserveTagName: '保留邮箱', + }); }); test('listLuckmailPurchasesByProject only keeps openai purchases', async () => { @@ -879,3 +905,117 @@ return { assert.deepStrictEqual(snapshot.usedMarker, { purchaseId: 456, used: true }); assert.equal(snapshot.logs.some((entry) => /已在本地标记为已用/.test(entry.message)), true); }); + +test('setLuckmailPurchaseUsedState persists used map to storage.local so reload keeps it non-reusable', async () => { + const bundle = [ + extractFunction('getLuckmailUsedPurchases'), + extractFunction('setLuckmailUsedPurchasesState'), + extractFunction('setLuckmailPurchaseUsedState'), + ].join('\n'); + + const factory = new Function(` +let sessionState = { + luckmailUsedPurchases: { 7: true }, +}; +let persistentUpdates = []; +let sessionUpdates = []; +let broadcasts = []; +function normalizeLuckmailPurchaseId(value) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : ''; +} +function normalizeLuckmailUsedPurchases(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.entries(value).reduce((result, [key, used]) => { + const normalizedKey = normalizeLuckmailPurchaseId(key); + if (normalizedKey) result[normalizedKey] = Boolean(used); + return result; + }, {}); +} +async function getState() { + return { ...sessionState }; +} +async function setPersistentSettings(updates) { + persistentUpdates.push(updates); +} +async function setState(updates) { + sessionUpdates.push(updates); + sessionState = { ...sessionState, ...updates }; +} +function broadcastDataUpdate(updates) { + broadcasts.push(updates); +} + +${bundle} + +return { + setLuckmailPurchaseUsedState, + snapshot() { + return { sessionState, persistentUpdates, sessionUpdates, broadcasts }; + }, +}; +`); + + const api = factory(); + const result = await api.setLuckmailPurchaseUsedState(88, true); + const snapshot = api.snapshot(); + + assert.deepStrictEqual(result, { purchaseId: 88, used: true }); + assert.deepStrictEqual(snapshot.sessionState.luckmailUsedPurchases, { 7: true, 88: true }); + assert.deepStrictEqual(snapshot.persistentUpdates, [ + { luckmailUsedPurchases: { 7: true, 88: true } }, + ]); + assert.deepStrictEqual(snapshot.sessionUpdates, [ + { luckmailUsedPurchases: { 7: true, 88: true } }, + ]); + assert.deepStrictEqual(snapshot.broadcasts, [ + { luckmailUsedPurchases: { 7: true, 88: true } }, + ]); +}); + +test('setLuckmailPreserveTagInfo persists tag cache to storage.local', async () => { + const bundle = extractFunction('setLuckmailPreserveTagInfo'); + + const factory = new Function(` +let persistentUpdates = []; +let sessionUpdates = []; +let broadcasts = []; +const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留'; +function normalizeLuckmailTags(tags) { + return (Array.isArray(tags) ? tags : []).map((tag) => ({ + id: Number(tag?.id) || 0, + name: String(tag?.name || '').trim(), + })).filter((tag) => tag.id > 0 || tag.name); +} +async function setPersistentSettings(updates) { + persistentUpdates.push(updates); +} +async function setState(updates) { + sessionUpdates.push(updates); +} +function broadcastDataUpdate(updates) { + broadcasts.push(updates); +} + +${bundle} + +return { + setLuckmailPreserveTagInfo, + snapshot() { + return { persistentUpdates, sessionUpdates, broadcasts }; + }, +}; +`); + + const api = factory(); + const result = await api.setLuckmailPreserveTagInfo({ id: '12', name: ' 保留邮箱 ' }); + const expected = { + luckmailPreserveTagId: 12, + luckmailPreserveTagName: '保留邮箱', + }; + + assert.deepStrictEqual(result, expected); + assert.deepStrictEqual(api.snapshot().persistentUpdates, [expected]); + assert.deepStrictEqual(api.snapshot().sessionUpdates, [expected]); + assert.deepStrictEqual(api.snapshot().broadcasts, [expected]); +}); diff --git a/tests/background-platform-verify-cpa-api.test.js b/tests/background-platform-verify-cpa-api.test.js index 0fd9002..c994b3a 100644 --- a/tests/background-platform-verify-cpa-api.test.js +++ b/tests/background-platform-verify-cpa-api.test.js @@ -237,3 +237,36 @@ test('platform verify module rejects callback when cpa oauth state mismatches', globalThis.fetch = originalFetch; } }); + +test('platform verify module sends Plus visible step 13 to SUB2API panel', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const sentMessages = []; + + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const { deps } = createDeps({ + getPanelMode: () => 'sub2api', + getTabId: async () => 91, + isTabAlive: async () => true, + sendToContentScript: async (sourceName, message, options) => { + sentMessages.push({ sourceName, message, options }); + return { ok: true }; + }, + }); + const executor = api.createStep10Executor(deps); + + await executor.executeStep10({ + panelMode: 'sub2api', + visibleStep: 13, + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiSessionId: 'session-1', + sub2apiOAuthState: 'oauth-state', + }); + + assert.equal(sentMessages.length, 1); + assert.equal(sentMessages[0].sourceName, 'sub2api-panel'); + assert.equal(sentMessages[0].message.step, 13); +}); + diff --git a/tests/five-sim-provider.test.js b/tests/five-sim-provider.test.js index e22b49c..86f00eb 100644 --- a/tests/five-sim-provider.test.js +++ b/tests/five-sim-provider.test.js @@ -37,25 +37,31 @@ test('5sim provider maps countries and prices', async () => { const parsed = new URL(url); requests.push({ url: parsed, options }); if (parsed.pathname === '/v1/guest/countries') { - return createTextResponse({ england: { text_en: 'England', iso: { GB: 1 }, prefix: { 44: 1 } } }); + return createTextResponse({ + england: { text_en: 'England', iso: { GB: 1 }, prefix: { 44: 1 } }, + indonesia: { text_en: 'Indonesia', iso: { ID: 1 }, prefix: { '+62': 1 } }, + thailand: { text_en: 'Thailand', iso: { TH: 1 }, prefix: { '+66': 1 } }, + vietnam: { text_en: 'Vietnam', iso: { VN: 1 }, prefix: { '+84': 1 } }, + }); } if (parsed.pathname === '/v1/guest/prices') { - return createTextResponse({ england: { any: { openai: { cost: 10, count: 2 } } } }); + return createTextResponse({ vietnam: { any: { openai: { cost: 10, count: 2 } } } }); } throw new Error(`unexpected ${parsed.pathname}`); }, }); const countries = await provider.fetchCountries({}); - const prices = await provider.fetchPrices({}, { id: 'england', label: 'England' }); + const prices = await provider.fetchPrices({}, { id: 'vietnam', label: 'Vietnam' }); const entries = provider.collectPriceEntries(prices, []); - assert.deepStrictEqual(countries[0], { - id: 'england', - label: '英国 (England)', - searchText: 'england 英国 (England) England GB 44', + assert.deepStrictEqual(countries.map((country) => country.id), ['indonesia', 'thailand', 'vietnam']); + assert.deepStrictEqual(countries[2], { + id: 'vietnam', + label: '越南 (Vietnam)', + searchText: 'vietnam 越南 (Vietnam) Vietnam VN +84', }); - assert.equal(requests[1].url.searchParams.get('country'), 'england'); + assert.equal(requests[1].url.searchParams.get('country'), 'vietnam'); assert.equal(requests[1].url.searchParams.get('product'), 'openai'); assert.deepStrictEqual(entries, [{ cost: 10, count: 2, inStock: true }]); }); @@ -66,14 +72,14 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation fetchImpl: async (url, options = {}) => { const parsed = new URL(url); requests.push({ url: parsed, options }); - if (parsed.pathname === '/v1/guest/products/england/any') { + if (parsed.pathname === '/v1/guest/products/vietnam/any') { return createTextResponse({ openai: { Category: 'activation', Qty: 4, Price: 8 } }); } if (parsed.pathname === '/v1/guest/prices') { - return createTextResponse({ england: { any: { openai: { cost: 9.5, count: 4 } } } }); + return createTextResponse({ vietnam: { any: { openai: { cost: 9.5, count: 4 } } } }); } - if (parsed.pathname === '/v1/user/buy/activation/england/any/openai') { - return createTextResponse({ id: 1001, phone: '+447911123456', country: 'england', operator: 'any', status: 'PENDING' }); + if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') { + return createTextResponse({ id: 1001, phone: '+84901123456', country: 'vietnam', operator: 'any', status: 'PENDING' }); } if (parsed.pathname === '/v1/user/check/1001') { return createTextResponse({ id: 1001, phone: '+447911123456', status: 'RECEIVED', sms: [{ text: 'code 112233' }] }); @@ -81,8 +87,8 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation if (parsed.pathname === '/v1/user/finish/1001') return createTextResponse({ status: 'FINISHED' }); if (parsed.pathname === '/v1/user/cancel/1001') return createTextResponse({ status: 'CANCELED' }); if (parsed.pathname === '/v1/user/ban/1001') return createTextResponse({ status: 'BANNED' }); - if (parsed.pathname === '/v1/user/reuse/openai/447911123456') { - return createTextResponse({ id: 1002, phone: '+447911123456', country: 'england', status: 'PENDING' }); + if (parsed.pathname === '/v1/user/reuse/openai/84901123456') { + return createTextResponse({ id: 1002, phone: '+84901123456', country: 'vietnam', status: 'PENDING' }); } throw new Error(`unexpected ${parsed.pathname}`); }, @@ -90,7 +96,7 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation throwIfStopped: () => {}, }); - const state = { fiveSimApiKey: 'demo-key', fiveSimCountryId: 'england', fiveSimCountryLabel: 'England', fiveSimMaxPrice: '12', fiveSimOperator: 'any' }; + const state = { fiveSimApiKey: 'demo-key', fiveSimCountryId: 'vietnam', fiveSimCountryLabel: '越南 (Vietnam)', fiveSimMaxPrice: '12', fiveSimOperator: 'any' }; const activation = await provider.requestActivation(state); const code = await provider.pollActivationCode(state, activation, { timeoutMs: 1000, intervalMs: 1, maxRounds: 1 }); await provider.finishActivation(state, activation); @@ -100,7 +106,7 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation assert.equal(activation.provider, '5sim'); assert.equal(activation.activationId, '1001'); - assert.equal(activation.countryId, 'england'); + assert.equal(activation.countryId, 'vietnam'); assert.equal(code, '112233'); assert.equal(reused.activationId, '1002'); const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation')); @@ -109,14 +115,14 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation assert.deepStrictEqual( requests.map((entry) => entry.url.pathname), [ - '/v1/guest/products/england/any', + '/v1/guest/products/vietnam/any', '/v1/guest/prices', - '/v1/user/buy/activation/england/any/openai', + '/v1/user/buy/activation/vietnam/any/openai', '/v1/user/check/1001', '/v1/user/finish/1001', '/v1/user/cancel/1001', '/v1/user/ban/1001', - '/v1/user/reuse/openai/447911123456', + '/v1/user/reuse/openai/84901123456', ] ); }); @@ -168,3 +174,89 @@ test('5sim provider prefers buy-compatible products price over operator detail p ] ); }); + +test('5sim provider reports raw buy payload when HTTP 200 response has no activation', async () => { + const provider = api.createProvider({ + fetchImpl: async (url) => { + const parsed = new URL(url); + if (parsed.pathname === '/v1/guest/products/vietnam/any') { + return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.08 } }); + } + if (parsed.pathname === '/v1/guest/prices') { + return createTextResponse({ vietnam: { openai: { virtual47: { cost: 0.1282, count: 10 } } } }); + } + if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') { + return createTextResponse({ status: 'no free phones', detail: 'operator unavailable' }); + } + throw new Error(`unexpected ${parsed.pathname}`); + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => provider.requestActivation({ + fiveSimApiKey: 'demo-key', + fiveSimCountryId: 'vietnam', + fiveSimCountryLabel: '越南 (Vietnam)', + fiveSimOperator: 'any', + }), + /越南 \(Vietnam\): no free phones/ + ); +}); + +test('5sim provider reports purchase rate limit separately from no-number countries', async () => { + const requests = []; + const provider = api.createProvider({ + fetchImpl: async (url) => { + const parsed = new URL(url); + requests.push(parsed.pathname); + if (parsed.pathname === '/v1/guest/products/thailand/any') { + return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.1 } }); + } + if (parsed.pathname === '/v1/guest/products/vietnam/any') { + return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.08 } }); + } + if (parsed.pathname === '/v1/guest/prices') { + const country = parsed.searchParams.get('country'); + return createTextResponse({ [country]: { any: { openai: { cost: country === 'vietnam' ? 0.08 : 0.1, count: 10 } } } }); + } + if (parsed.pathname === '/v1/user/buy/activation/thailand/any/openai') { + return createTextResponse({ status: 'rate limit' }); + } + if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') { + return createTextResponse({ status: 'rate limit' }); + } + throw new Error(`unexpected ${parsed.pathname}`); + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => provider.requestActivation({ + fiveSimApiKey: 'demo-key', + fiveSimCountryId: 'thailand', + fiveSimCountryLabel: '泰国 (Thailand)', + fiveSimCountryFallback: [{ id: 'vietnam', label: '越南 (Vietnam)' }], + fiveSimOperator: 'any', + }), + (error) => { + assert.match(error.message, /^FIVE_SIM_RATE_LIMIT::/); + assert.match(error.message, /5sim 购买接口触发限流/); + assert.match(error.message, /泰国 \(Thailand\): rate limit/); + assert.match(error.message, /越南 \(Vietnam\): rate limit/); + assert.doesNotMatch(error.message, /均无可用号码/); + return true; + } + ); + + assert.deepStrictEqual( + requests.filter((pathname) => pathname.includes('/buy/activation')), + [ + '/v1/user/buy/activation/thailand/any/openai', + '/v1/user/buy/activation/vietnam/any/openai', + ] + ); +}); + diff --git a/tests/gopay-approve-source.test.js b/tests/gopay-approve-source.test.js index 52a1ce8..02d1cad 100644 --- a/tests/gopay-approve-source.test.js +++ b/tests/gopay-approve-source.test.js @@ -100,6 +100,17 @@ test('GoPay approve does not treat phone linking page as debugger iframe action' assert.match(source, /filter\(\(target\) => target\.type === 'iframe'\)/); }); +test('GoPay approve waits and retries slowly on Hubungkan linking page', () => { + assert.match(source, /GOPAY_LINKING_RETRY_WAIT_MS/); + assert.match(source, /GOPAY_LINKING_STABLE_WAIT_MS/); + assert.match(source, /createGoPayStableStateTracker/); + assert.match(source, /clickGoPayContinueBestEffort/); + assert.match(source, /hubungkan\|sambungkan\|tautkan/); + assert.match(source, /先等待 linking 页面加载\/跳转/); + assert.match(source, /改用兜底点击 Hubungkan\/确认按钮/); + assert.doesNotMatch(source, /GoPay 确认按钮点击后页面仍未变化,已暂停自动重复点击。请手动点击页面上的确认按钮,插件会继续等待后续页面。/); +}); + test('background auto-run routes GoPay restart sentinel back to step 6', () => { const backgroundSource = fs.readFileSync('background.js', 'utf8'); @@ -109,3 +120,10 @@ test('background auto-run routes GoPay restart sentinel back to step 6', () => { assert.match(backgroundSource, /step = 6/); assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/); }); + +test('GoPay approve gives PIN precedence over OTP on ambiguous second PIN pages', () => { + assert.match(source, /pageState\.hasPinInput && !pinSubmitted/); + assert.match(source, /pageState\.hasOtpInput && !pageState\.hasPinInput && !otpSubmitted/); + assert.ok(source.indexOf('pageState.hasPinInput && !pinSubmitted') < source.indexOf('pageState.hasOtpInput && !pageState.hasPinInput && !otpSubmitted')); + assert.doesNotMatch(source, /otp\|one\[-\\s\]\*time\|kode\|verification\|whatsapp\|code\|pin-input-field/); +}); diff --git a/tests/gopay-flow-content.test.js b/tests/gopay-flow-content.test.js index 5e57f11..c1018d8 100644 --- a/tests/gopay-flow-content.test.js +++ b/tests/gopay-flow-content.test.js @@ -137,6 +137,66 @@ return { getGoPayContinueTarget }; assert.match(target.target, /Link and pay/); }); +test('GoPay continue target recognizes Indonesian Hubungkan linking button', () => { + const bundle = [ + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('isVisibleElement'), + extractFunction('getVisibleControls'), + extractFunction('isEnabledControl'), + extractFunction('findClickableByText'), + extractFunction('findContinueButton'), + extractFunction('describeElement'), + extractFunction('getElementClickRect'), + extractFunction('getGoPayContinueTarget'), + ].join('\n'); + const button = { + tagName: 'BUTTON', + id: '', + className: 'btn primary', + textContent: 'Hubungkan', + innerText: 'Hubungkan', + value: '', + disabled: false, + hidden: false, + parentElement: null, + getAttribute(name) { + if (name === 'class') return this.className; + return ''; + }, + getBoundingClientRect() { return { left: 40, top: 720, width: 280, height: 48 }; }, + }; + const termsLink = { + ...button, + tagName: 'A', + className: 'terms-link', + textContent: 'Syarat & Ketentuan', + innerText: 'Syarat & Ketentuan', + getBoundingClientRect() { return { left: 120, top: 650, width: 120, height: 16 }; }, + }; + const api = new Function('button', 'termsLink', ` +const window = { + getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; }, + innerWidth: 390, + innerHeight: 844, +}; +const document = { + querySelectorAll(selector) { + return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, button] : []; + }, +}; +${bundle} +return { findContinueButton, getGoPayContinueTarget }; +`)(button, termsLink); + + assert.equal(api.findContinueButton(), button); + const target = api.getGoPayContinueTarget(); + assert.equal(target.found, true); + assert.match(target.target, /Hubungkan/); + assert.equal(target.rect.centerX, 180); + assert.equal(target.rect.centerY, 744); +}); + test('GoPay PIN page detection wins over generic pin-input OTP attributes', () => { const bundle = [ @@ -145,6 +205,7 @@ test('GoPay PIN page detection wins over generic pin-input OTP attributes', () = extractFunction('getPageBodyText'), extractFunction('isGoPayOtpPageText'), extractFunction('isGoPayPinPageText'), + extractFunction('isGoPayPinInput'), extractFunction('isVisibleElement'), extractFunction('getVisibleControls'), extractFunction('isEnabledControl'), @@ -155,6 +216,8 @@ test('GoPay PIN page detection wins over generic pin-input OTP attributes', () = extractFunction('findOtpInput'), extractFunction('getGoPayPinInputs'), extractFunction('findPinInput'), + extractFunction('normalizeOtp'), + extractFunction('fillVisibleOtpInputs'), ].join('\n'); const pinInputs = Array.from({ length: 6 }, (_, index) => ({ tagName: 'INPUT', @@ -185,12 +248,13 @@ const document = { querySelectorAll(selector) { return selector.includes('input') ? pinInputs : []; }, }; ${bundle} -return { isGoPayOtpPageText, isGoPayPinPageText, findOtpInput, findPinInput, getGoPayPinInputs }; +return { isGoPayOtpPageText, isGoPayPinPageText, findOtpInput, findPinInput, getGoPayPinInputs, fillVisibleOtpInputs }; `)(pinInputs); assert.equal(api.isGoPayPinPageText(), true); assert.equal(api.isGoPayOtpPageText(), false); assert.equal(api.findOtpInput(), null); + assert.equal(api.fillVisibleOtpInputs('123456'), false); assert.equal(api.findPinInput(), pinInputs[0]); assert.equal(api.getGoPayPinInputs().length, 6); }); @@ -303,6 +367,7 @@ test('GoPay terminal timeout page is reported as retry-required state', () => { extractFunction('getActionText'), extractFunction('getPageBodyText'), extractFunction('isGoPayPinPageText'), + extractFunction('isGoPayPinInput'), extractFunction('detectGoPayTerminalError'), extractFunction('isGoPayOtpPageText'), extractFunction('isVisibleElement'), diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js index 484e030..76c7ac7 100644 --- a/tests/phone-verification-flow.test.js +++ b/tests/phone-verification-flow.test.js @@ -83,6 +83,56 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T assert.equal(requests[1].searchParams.get('api_key'), 'demo-key'); }); +test('phone verification helper ignores HeroSMS virtual-only stock when physicalCount is zero', async () => { + const requests = []; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload({ count: 3, physicalCount: 0, cost: 0.05 }), + }; + } + if (action === 'getNumber' || action === 'getNumberV2') { + return { + ok: true, + text: async () => 'NO_NUMBERS', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getState: async () => ({ heroSmsApiKey: 'demo-key' }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + await assert.rejects( + helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsActivationRetryRounds: 1 }), + /HeroSMS 已尝试 1 个候选国家,均无可用号码/ + ); + + const actions = requests.map((requestUrl) => `${requestUrl.searchParams.get('action')}:${requestUrl.searchParams.get('maxPrice') || ''}`); + assert.deepStrictEqual(actions, [ + 'getPrices:', + 'getPrices:', + 'getPrices:', + 'getNumber:', + 'getNumberV2:', + 'getPrices:', + 'getPrices:', + 'getPrices:', + 'getNumber:', + 'getNumberV2:', + ]); +}); + test('phone verification helper retries HeroSMS getPrices until it receives a usable lowest price', async () => { const requests = []; let getPricesAttempt = 0; @@ -4116,8 +4166,8 @@ test('phone verification helper routes 5sim buy, check, and finish by current ac let currentState = { phoneSmsProvider: '5sim', fiveSimApiKey: 'demo-key', - fiveSimCountryId: 'england', - fiveSimCountryLabel: 'England', + fiveSimCountryId: 'vietnam', + fiveSimCountryLabel: '越南 (Vietnam)', fiveSimMaxPrice: '12', fiveSimOperator: 'any', verificationResendCount: 0, @@ -4139,7 +4189,7 @@ test('phone verification helper routes 5sim buy, check, and finish by current ac fetchImpl: async (url, options = {}) => { const parsedUrl = new URL(url); requests.push({ url: parsedUrl, options }); - if (parsedUrl.pathname === '/v1/guest/products/england/any') { + if (parsedUrl.pathname === '/v1/guest/products/vietnam/any') { return { ok: true, status: 200, @@ -4150,14 +4200,14 @@ test('phone verification helper routes 5sim buy, check, and finish by current ac return { ok: true, status: 200, - text: async () => JSON.stringify({ england: { any: { openai: { cost: 9.5, count: 3 } } } }), + text: async () => JSON.stringify({ vietnam: { any: { openai: { cost: 9.5, count: 3 } } } }), }; } - if (parsedUrl.pathname === '/v1/user/buy/activation/england/any/openai') { + if (parsedUrl.pathname === '/v1/user/buy/activation/vietnam/any/openai') { return { ok: true, status: 200, - text: async () => JSON.stringify({ id: 5001, phone: '+447911223344', country: 'england', operator: 'any', status: 'PENDING' }), + text: async () => JSON.stringify({ id: 5001, phone: '+84901122334', country: 'vietnam', operator: 'any', status: 'PENDING' }), }; } if (parsedUrl.pathname === '/v1/user/check/5001') { @@ -4216,9 +4266,9 @@ test('phone verification helper routes 5sim buy, check, and finish by current ac assert.deepStrictEqual( requests.map((entry) => entry.url.pathname), [ - '/v1/guest/products/england/any', + '/v1/guest/products/vietnam/any', '/v1/guest/prices', - '/v1/user/buy/activation/england/any/openai', + '/v1/user/buy/activation/vietnam/any/openai', '/v1/user/check/5001', '/v1/user/finish/5001', ] @@ -4230,8 +4280,8 @@ test('phone verification helper routes 5sim reusable activation through reuse en let currentState = { phoneSmsProvider: '5sim', fiveSimApiKey: 'demo-key', - fiveSimCountryId: 'england', - fiveSimCountryLabel: 'England', + fiveSimCountryId: 'vietnam', + fiveSimCountryLabel: '越南 (Vietnam)', fiveSimOperator: 'any', verificationResendCount: 0, phoneVerificationReplacementLimit: 2, @@ -4242,11 +4292,11 @@ test('phone verification helper routes 5sim reusable activation through reuse en currentPhoneActivation: null, reusablePhoneActivation: { activationId: '4001', - phoneNumber: '+447911223344', + phoneNumber: '+84901122334', provider: '5sim', serviceCode: 'openai', - countryId: 'england', - countryLabel: 'England', + countryId: 'vietnam', + countryLabel: '越南 (Vietnam)', successfulUses: 1, maxUses: 3, }, @@ -4261,8 +4311,8 @@ test('phone verification helper routes 5sim reusable activation through reuse en fetchImpl: async (url) => { const parsedUrl = new URL(url); requests.push(parsedUrl); - if (parsedUrl.pathname === '/v1/user/reuse/openai/447911223344') { - return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+447911223344', country: 'england', status: 'PENDING' }) }; + if (parsedUrl.pathname === '/v1/user/reuse/openai/84901122334') { + return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+84901122334', country: 'vietnam', status: 'PENDING' }) }; } if (parsedUrl.pathname === '/v1/user/check/4002') { return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+447911223344', status: 'RECEIVED', sms: [{ code: '654321' }] }) }; @@ -4306,7 +4356,7 @@ test('phone verification helper routes 5sim reusable activation through reuse en assert.deepStrictEqual( requests.map((url) => url.pathname), [ - '/v1/user/reuse/openai/447911223344', + '/v1/user/reuse/openai/84901122334', '/v1/user/check/4002', '/v1/user/finish/4002', ] diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index 7f840b9..738aa1e 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -383,6 +383,7 @@ test('isPayPalPaymentMethodActive requires a selected PayPal control', () => { extractFunction('getPaymentMethodSearchCandidates'), extractFunction('getPayPalSearchCandidates'), extractFunction('hasCreditCardFields'), + extractFunction('hasPaymentMethodSelectionMarker'), extractFunction('hasSelectedPaymentMethodControl'), extractFunction('hasSelectedPayPalControl'), extractFunction('isPaymentMethodActive'), @@ -725,6 +726,7 @@ test('payment method helpers can find and confirm selected GoPay controls', () = extractFunction('getGoPaySearchCandidates'), extractFunction('findPaymentMethodTarget'), extractFunction('findGoPayPaymentMethodTarget'), + extractFunction('hasPaymentMethodSelectionMarker'), extractFunction('hasSelectedPaymentMethodControl'), extractFunction('hasSelectedGoPayControl'), extractFunction('isPaymentMethodActive'), @@ -774,6 +776,74 @@ return { findGoPayPaymentMethodTarget, getGoPaySearchCandidates, hasSelectedGoPa assert.equal(api.isGoPayPaymentMethodActive(), true); }); +test('GoPay active detection accepts nested selected radio inside payment card', () => { + const bundle = [ + "const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';", + "const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';", + "const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\s*pay/i] } };", + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getSearchText'), + extractFunction('getFieldText'), + extractFunction('getCombinedSearchText'), + extractFunction('getVisibleControls'), + extractFunction('isDocumentLevelContainer'), + extractFunction('normalizePlusPaymentMethod'), + extractFunction('getPaymentMethodConfig'), + extractFunction('getPaymentMethodSearchCandidates'), + extractFunction('hasPaymentMethodSelectionMarker'), + extractFunction('hasSelectedPaymentMethodControl'), + extractFunction('hasSelectedGoPayControl'), + extractFunction('isPaymentMethodActive'), + extractFunction('isGoPayPaymentMethodActive'), + ].join('\n'); + + const radio = createElement({ + tagName: 'INPUT', + attrs: { + type: 'radio', + role: 'radio', + 'aria-checked': 'true', + value: 'gopay', + }, + }); + radio.checked = true; + const textNode = createElement({ tagName: 'SPAN', text: 'GoPay' }); + const card = createElement({ tagName: 'DIV', text: 'GoPay' }); + radio.parentElement = card; + textNode.parentElement = card; + card.children = [radio, textNode]; + card.querySelector = (selector) => String(selector || '').includes('radio') ? radio : null; + const elements = [card, radio, textNode]; + const documentMock = { + documentElement: {}, + body: {}, + querySelectorAll: (selector) => { + if (String(selector || '').includes('label[for=')) return []; + return elements.filter((element) => { + if (String(selector || '').includes('input[type="radio"]')) return element === radio || element === card || element === textNode; + return true; + }); + }, + }; + const windowMock = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + const cssMock = { + escape: (value) => String(value), + }; + + const api = new Function('window', 'document', 'CSS', ` +${bundle} +return { isGoPayPaymentMethodActive }; +`)(windowMock, documentMock, cssMock); + + assert.equal(api.isGoPayPaymentMethodActive(), true); +}); + test('fillIfEmpty can overwrite invalid structured address values in the dropdown branch', () => { const bundle = [ extractFunction('fillIfEmpty'), diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index c0a28f8..7a088e0 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -50,6 +50,20 @@ function createIdAddressSeed() { }; } +function createKrAddressSeed() { + return { + countryCode: 'KR', + query: 'Seoul Jung-gu', + suggestionIndex: 1, + fallback: { + address1: 'Sejong-daero 110', + city: 'Jung-gu', + region: 'Seoul', + postalCode: '04524', + }, + }; +} + function createSuccessfulBillingResult() { return { countryText: 'Germany', @@ -68,6 +82,7 @@ function createExecutorHarness({ fetchImpl = null, getAddressSeedForCountry = () => createAddressSeed(), markCurrentRegistrationAccountUsed = async () => {}, + probeIpProxyExit = null, submitRedirectUrl = 'https://www.paypal.com/checkoutnow', }) { const api = loadPlusCheckoutBillingModule(); @@ -149,6 +164,7 @@ function createExecutorHarness({ assert.equal(matcher(submitRedirectUrl), true); return { id: tabId, url: submitRedirectUrl }; }, + ...(typeof probeIpProxyExit === 'function' ? { probeIpProxyExit } : {}), }); return { checkoutTab, events, executor }; @@ -240,7 +256,7 @@ test('Plus checkout billing sends the billing command to the iframe that contain assert.equal(events.completed[0].step, 7); }); -test('Plus checkout billing forces Indonesia address for GoPay even when page country differs', async () => { +test('Plus checkout billing uses proxy exit country for GoPay address when available', async () => { const requestedCountries = []; const fetchRequests = []; const { events, executor } = createExecutorHarness({ @@ -263,7 +279,17 @@ test('Plus checkout billing forces Indonesia address for GoPay even when page co }, getAddressSeedForCountry: (countryValue) => { requestedCountries.push(countryValue); - return countryValue === 'ID' ? createIdAddressSeed() : createAddressSeed(); + return countryValue === 'JP' ? { + countryCode: 'JP', + query: 'Tokyo Marunouchi', + suggestionIndex: 1, + fallback: { + address1: 'Marunouchi 1-1', + city: 'Chiyoda-ku', + region: 'Tokyo', + postalCode: '100-0005', + }, + } : createIdAddressSeed(); }, fetchImpl: async (url, init) => { fetchRequests.push({ url, init }); @@ -273,10 +299,11 @@ test('Plus checkout billing forces Indonesia address for GoPay even when page co json: async () => ({ status: 'ok', address: { - Address: 'Jl. M.H. Thamrin No. 10', - City: 'Jakarta', - State: 'DKI Jakarta', - Zip_Code: '10310', + Address: 'トウキョウト, チヨダク, マルノウチ, 1-1', + Trans_Address: 'Marunouchi 1-1, Chiyoda-ku, Tokyo', + City: 'Tokyo', + State: 'Tokyo', + Zip_Code: '100-0005', }, }), }; @@ -284,17 +311,206 @@ test('Plus checkout billing forces Indonesia address for GoPay even when page co submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking', }); - await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay', plusCheckoutCountry: 'US' }); + await executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gopay', + plusCheckoutCountry: 'ID', + ipProxyAppliedExitRegion: 'JP', + }); const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); - assert.equal(requestedCountries[0], 'ID'); - assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID'); + assert.equal(requestedCountries[0], 'JP'); + assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'JP'); assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi'); assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { - city: 'Jakarta', - path: '/id-address', + city: 'Chiyoda-ku', + path: '/jp-address', method: 'refresh', }); + assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 JP/.test(entry.message)), true); +}); + +test('Plus checkout billing refreshes stale GoPay proxy country before filling address', async () => { + const requestedCountries = []; + const probeCalls = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] }, + 8: { + hasPayPal: false, + hasGoPay: false, + paypalCandidates: [], + gopayCandidates: [], + billingFieldsVisible: true, + countryText: 'Indonesia', + }, + }, + getAddressSeedForCountry: (countryValue) => { + requestedCountries.push(countryValue); + return countryValue === 'JP' ? { + countryCode: 'JP', + query: 'Tokyo Chiyoda-ku', + suggestionIndex: 1, + fallback: { + address1: 'Marunouchi 1-1', + city: 'Chiyoda-ku', + region: 'Tokyo', + postalCode: '100-0005', + }, + } : createKrAddressSeed(); + }, + fetchImpl: async () => ({ + ok: false, + status: 503, + json: async () => ({ status: 'error' }), + }), + probeIpProxyExit: async (options) => { + probeCalls.push(options); + return { + proxyRouting: { + exitRegion: 'JP', + exitIp: '203.0.113.8', + exitSource: 'page_context', + exitEndpoint: 'https://ipinfo.io/json', + }, + }; + }, + submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking', + }); + + await executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gopay', + plusCheckoutCountry: 'ID', + ipProxyAppliedExitRegion: 'KR', + }); + + const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(probeCalls.length, 1); + assert.equal(probeCalls[0].detectWhenDisabled, true); + assert.equal(requestedCountries[0], 'JP'); + assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'JP'); + assert.equal(events.logs.some((entry) => entry.message.includes('当前代理出口复测结果:JP / 203.0.113.8')), true); + assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 JP/.test(entry.message)), true); + assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), false); +}); + +test('Plus checkout billing refuses to reuse stale GoPay proxy country when refresh has no region', async () => { + const requestedCountries = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] }, + 8: { + hasPayPal: false, + hasGoPay: false, + paypalCandidates: [], + gopayCandidates: [], + billingFieldsVisible: true, + countryText: 'Indonesia', + }, + }, + getAddressSeedForCountry: (countryValue) => { + requestedCountries.push(countryValue); + return createKrAddressSeed(); + }, + probeIpProxyExit: async () => ({ + proxyRouting: { + reason: 'disabled_probe_only', + exitIp: '203.0.113.9', + exitRegion: '', + exitError: 'missing_region', + }, + }), + }); + + await assert.rejects( + () => executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gopay', + plusCheckoutCountry: 'ID', + ipProxyAppliedExitRegion: 'KR', + }), + /本次复测没有拿到国家码/ + ); + + assert.equal(requestedCountries.length, 0); + assert.equal(events.logs.some((entry) => /已清空旧出口地区 KR/.test(entry.message)), true); + assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), false); +}); + +test('Plus checkout billing normalizes legacy Korean postal code for GoPay address', async () => { + const requestedCountries = []; + const fetchRequests = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] }, + 8: { + hasPayPal: false, + hasGoPay: false, + paypalCandidates: [], + gopayCandidates: [], + billingFieldsVisible: true, + countryText: 'United States', + }, + }, + getAddressSeedForCountry: (countryValue) => { + requestedCountries.push(countryValue); + return countryValue === 'KR' ? createKrAddressSeed() : createIdAddressSeed(); + }, + fetchImpl: async (url, init) => { + fetchRequests.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ + status: 'ok', + address: { + Address: '서울특별시 중구 세종대로 110', + Trans_Address: 'Sejong-daero 110, Jung-gu, Seoul', + City: 'Jung-gu', + State: 'Seoul', + Zip_Code: '150-300', + }, + }), + }; + }, + submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking', + }); + + await executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gopay', + plusCheckoutCountry: 'ID', + ipProxyAppliedExitRegion: 'KR', + }); + + const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(requestedCountries[0], 'KR'); + assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'KR'); + assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi'); + assert.equal(fillMessage.message.payload.addressSeed.fallback.address1, 'Sejong-daero 110, Jung-gu, Seoul'); + assert.equal(fillMessage.message.payload.addressSeed.fallback.postalCode, '04524'); + assert.match(fillMessage.message.payload.addressSeed.fallback.postalCode, /^\d{5}$/); + assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { + city: 'Jung-gu', + path: '/kr-address', + method: 'refresh', + }); + assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), true); }); test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async () => { diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js index 2f79018..2a9b2dd 100644 --- a/tests/sidepanel-contribution-mode.test.js +++ b/tests/sidepanel-contribution-mode.test.js @@ -180,11 +180,13 @@ test('collectSettingsPayload omits custom password and local sync settings in co const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', ` let latestState = { contributionMode: true }; +const window = {}; let cloudflareDomainEditMode = false; let cloudflareTempEmailDomainEditMode = false; const selectCfDomain = { value: 'example.com' }; const selectTempEmailDomain = { value: 'mail.example.com' }; const selectPanelMode = { value: 'cpa' }; +function getSelectedPlusPaymentMethod() { return 'paypal'; } const inputVpsUrl = { value: 'https://panel.example.com' }; const inputVpsPassword = { value: 'panel-secret' }; const inputSub2ApiUrl = { value: 'https://sub.example.com' }; @@ -237,9 +239,11 @@ const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; const DEFAULT_HERO_SMS_REUSE_ENABLED = true; const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; diff --git a/tests/sidepanel-icloud-provider.test.js b/tests/sidepanel-icloud-provider.test.js index 7fb4263..a19617d 100644 --- a/tests/sidepanel-icloud-provider.test.js +++ b/tests/sidepanel-icloud-provider.test.js @@ -85,11 +85,13 @@ test('collectSettingsPayload persists icloud target mailbox settings', () => { const api = new Function(` let latestState = { contributionMode: false }; +const window = {}; let cloudflareDomainEditMode = false; let cloudflareTempEmailDomainEditMode = false; const selectCfDomain = { value: '' }; const selectTempEmailDomain = { value: '' }; const selectPanelMode = { value: 'cpa' }; +function getSelectedPlusPaymentMethod() { return 'paypal'; } const inputVpsUrl = { value: '' }; const inputVpsPassword = { value: '' }; const inputSub2ApiUrl = { value: '' }; @@ -136,9 +138,11 @@ const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const DEFAULT_HERO_SMS_REUSE_ENABLED = true; const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; @@ -421,9 +425,11 @@ const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; const PHONE_REPLACEMENT_LIMIT_MIN = 1; const PHONE_REPLACEMENT_LIMIT_MAX = 20; diff --git a/tests/sidepanel-mail2925-base-email.test.js b/tests/sidepanel-mail2925-base-email.test.js index 3004244..30ee323 100644 --- a/tests/sidepanel-mail2925-base-email.test.js +++ b/tests/sidepanel-mail2925-base-email.test.js @@ -158,11 +158,13 @@ let latestState = { mail2925UseAccountPool: true, currentMail2925AccountId: 'acc-2', }; +const window = {}; let cloudflareDomainEditMode = false; let cloudflareTempEmailDomainEditMode = false; const selectCfDomain = { value: 'example.com' }; const selectTempEmailDomain = { value: 'mail.example.com' }; const selectPanelMode = { value: 'cpa' }; +function getSelectedPlusPaymentMethod() { return 'paypal'; } const inputVpsUrl = { value: '' }; const inputVpsPassword = { value: '' }; const inputSub2ApiUrl = { value: '' }; @@ -207,9 +209,11 @@ const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const DEFAULT_HERO_SMS_REUSE_ENABLED = true; const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js index 76329c4..ba493fa 100644 --- a/tests/sidepanel-phone-verification-settings.test.js +++ b/tests/sidepanel-phone-verification-settings.test.js @@ -293,6 +293,7 @@ return { test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => { const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', ` +const window = {}; let latestState = { contributionMode: false, mail2925UseAccountPool: false, @@ -390,9 +391,11 @@ const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand'; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const selectHeroSmsCountry = { value: '52', selectedIndex: 0, @@ -403,6 +406,7 @@ function normalizeCloudflareDomainValue(value) { return String(value || '').trim function getCloudflareTempEmailDomainsFromState() { return { domains: [], activeDomain: '' }; } function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); } function getSelectedLocalCpaStep9Mode() { return 'submit'; } +function getSelectedPlusPaymentMethod() { return 'paypal'; } function getSelectedMail2925Mode() { return 'provide'; } function getSelectedHotmailServiceMode() { return 'local'; } function buildManagedAliasBaseEmailPayload() { return { gmailBaseEmail: '', mail2925BaseEmail: '', emailPrefix: '' }; } @@ -473,7 +477,7 @@ return { collectSettingsPayload }; assert.equal(payload.heroSmsCountryLabel, 'Thailand'); assert.deepStrictEqual(payload.heroSmsCountryFallback, [{ id: 16, label: 'United Kingdom' }]); assert.equal(payload.fiveSimApiKey, ''); - assert.equal(payload.fiveSimCountryId, 'england'); + assert.equal(payload.fiveSimCountryId, 'vietnam'); }); test('switchPhoneSmsProvider saves API keys independently when the select value has already changed', async () => { @@ -487,18 +491,20 @@ let latestState = { heroSmsCountryId: 52, heroSmsCountryLabel: 'Thailand', heroSmsCountryFallback: [], - fiveSimCountryId: 'england', - fiveSimCountryLabel: '英国 (England)', + fiveSimCountryId: 'vietnam', + fiveSimCountryLabel: '越南 (Vietnam)', fiveSimCountryFallback: [], fiveSimOperator: 'any', }; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; const DEFAULT_HERO_SMS_COUNTRY_ID = 52; const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const selectPhoneSmsProvider = { value: 'hero-sms', dataset: { activeProvider: 'hero-sms' } }; const inputHeroSmsApiKey = { value: 'hero-live' }; const inputHeroSmsMaxPrice = { value: '0.22' }; @@ -530,7 +536,7 @@ function getSelectedHeroSmsCountryOption() { } function syncHeroSmsFallbackSelectionOrderFromSelect() { return getSelectedPhoneSmsProvider() === PHONE_SMS_PROVIDER_FIVE_SIM - ? [{ id: 'england', label: '英国 (England)' }] + ? [{ id: 'vietnam', label: '越南 (Vietnam)' }] : [{ id: 52, label: 'Thailand' }]; } function syncLatestState(patch) { latestState = { ...latestState, ...patch }; } @@ -574,14 +580,40 @@ return { assert.equal(api.savedPayload.fiveSimApiKey, 'five-live'); }); +test('formatPhoneSmsPriceEntriesSummary treats HeroSMS physicalCount=0 as out of stock even when count is positive', () => { + const api = new Function(` +${extractFunction('normalizeHeroSmsPriceForPreview')} +${extractFunction('collectHeroSmsPriceEntriesForPreview')} +${extractFunction('formatPhoneSmsPriceEntriesSummary')} +return { formatPhoneSmsPriceEntriesSummary }; +`)(); + + const summary = api.formatPhoneSmsPriceEntriesSummary({ + 52: { + dr: { + cost: 0.05, + count: 3, + physicalCount: 0, + }, + }, + }); + + assert.deepStrictEqual(summary.inStockPrices, []); + assert.deepStrictEqual(summary.allPrices, [0.05]); + assert.equal(summary.entries[0].inStock, false); + assert.equal(summary.entries[0].stockCount, 0); +}); + test('previewHeroSmsPriceTiers prefers 5sim products price for buy-compatible any operator', async () => { const api = new Function(` let latestState = { phoneSmsProvider: '5sim', fiveSimOperator: 'any' }; const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms'; const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim'; -const DEFAULT_FIVE_SIM_COUNTRY_ID = 'england'; -const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '英国 (England)'; +const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam'; +const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)'; const DEFAULT_FIVE_SIM_OPERATOR = 'any'; +const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']); +const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']); const inputHeroSmsMaxPrice = { value: '' }; const inputHeroSmsApiKey = { value: '' }; const inputFiveSimOperator = { value: 'any' }; diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 85443ea..e30f7fe 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -459,7 +459,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 - HeroSMS 和 5sim 都归入接码平台适配层;默认平台是 HeroSMS,5sim 产品码固定为 `openai`,operator 默认 `any`。 - 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。 -- 5sim 的国家/地区使用字符串 slug(如 `england / usa / thailand`),HeroSMS 仍使用数字国家 ID。 +- 5sim 的国家/地区使用字符串 slug(当前开放 `indonesia / thailand / vietnam`),HeroSMS 仍使用数字国家 ID(当前开放印度尼西亚/泰国/越南)。 - 如果同一个号码在重发短信后仍收不到验证码,后台会按当前平台取消或 ban 激活并换号,而不是把当前号码无限重试下去。 ### Step 10