Files
FlowPilot/background/ip-proxy-core.js
T
2026-05-12 11:24:12 +08:00

3986 lines
132 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// background/ip-proxy-core.js — IP代理核心(解析/应用/切换/探测)
let ipProxyAuthListenerInstalled = false;
let ipProxyErrorListenerInstalled = false;
let currentIpProxyAuthEntry = null;
let ipProxyExitDetectionToken = 0;
let ipProxyProbeInFlightPromise = null;
let lastAppliedIpProxyEntrySignature = '';
let ipProxyAuthHostVariantToggle = false;
const ipProxyHostResolveCache = new Map();
const IP_PROXY_HOST_RESOLVE_CACHE_TTL_MS = 60 * 1000;
const ipProxyHostResolveCursor = new Map();
let lastAppliedIpProxyAuthSnapshot = {
host: '',
port: 0,
username: '',
password: '',
};
let ipProxyAuthDiagnostics = {
challengeCount: 0,
providedCount: 0,
lastIsProxy: null,
lastStatusCode: 0,
lastHost: '',
lastPort: 0,
lastAt: 0,
};
let ipProxyLastRuntimeError = {
error: '',
details: '',
fatal: false,
at: 0,
};
function normalizeAutomationWindowId(value) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = Math.floor(Number(value));
return Number.isInteger(numeric) && numeric >= 0 ? numeric : null;
}
async function createAutomationScopedTab(createProperties = {}, options = {}) {
const windowId = normalizeAutomationWindowId(
options?.automationWindowId
?? options?.windowId
?? options?.state?.automationWindowId
?? null
);
if (windowId === null) {
return chrome.tabs.create(createProperties || {});
}
try {
return await chrome.tabs.create({
...(createProperties || {}),
windowId,
});
} catch {
return chrome.tabs.create(createProperties || {});
}
}
async function queryAutomationScopedTabs(queryInfo = {}, options = {}) {
const windowId = normalizeAutomationWindowId(
options?.automationWindowId
?? options?.windowId
?? options?.state?.automationWindowId
?? null
);
if (windowId === null) {
return chrome.tabs.query(queryInfo || {});
}
const scopedQuery = {
...(queryInfo || {}),
windowId,
};
delete scopedQuery.currentWindow;
try {
return await chrome.tabs.query(scopedQuery);
} catch {
return chrome.tabs.query(queryInfo || {});
}
}
const IP_PROXY_EXIT_PROBE_ENDPOINTS = [
'http://ip-api.com/json?lang=en',
'http://ipinfo.io/json',
'https://ipinfo.io/json',
'http://myip.ipip.net',
'https://chatgpt.com/cdn-cgi/trace',
'https://ipwho.is/',
'https://ipapi.co/json/',
'https://api.ipify.org?format=json',
'https://api64.ipify.org?format=json',
'https://httpbin.org/ip',
'https://checkip.amazonaws.com',
'https://ident.me',
];
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/';
const IP_PROXY_PAGE_CONTEXT_BASELINE_URL = 'https://ifconfig.co/json';
const IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS = 8000;
const IP_PROXY_PAGE_CONTEXT_BASELINE_TIMEOUT_MS = 6000;
const IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS = 8;
const IP_PROXY_GUARD_BLOCK_RULE_ID = 10991;
const IP_PROXY_GUARD_REGEX = '^https?:\\/\\/([^\\/]+\\.)?(chatgpt\\.com|openai\\.com)(\\/|$)';
function normalizeIpProxyProviderValue(value = '') {
const normalized = String(value || '').trim().toLowerCase();
const enabledValues = Array.isArray(globalThis.IP_PROXY_ENABLED_SERVICE_VALUES)
? globalThis.IP_PROXY_ENABLED_SERVICE_VALUES
: [];
if (enabledValues.includes(normalized)) {
return normalized;
}
if (IP_PROXY_SERVICE_VALUES.includes(normalized)) {
return DEFAULT_IP_PROXY_SERVICE;
}
return DEFAULT_IP_PROXY_SERVICE;
}
function normalizeProxyHostForCompare(value = '') {
return String(value || '').trim().toLowerCase().replace(/\.$/, '');
}
function stripProxyHostTrailingDot(value = '') {
return String(value || '').trim().replace(/\.$/, '');
}
function isIpv4LikeHost(value = '') {
const text = String(value || '').trim();
if (!text) {
return false;
}
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(text);
}
function isIpv6LikeHost(value = '') {
const text = String(value || '').trim();
if (!text) {
return false;
}
const compact = text.replace(/^\[/, '').replace(/\]$/, '');
return compact.includes(':');
}
function canUseProxyHostVariant(value = '') {
const host = stripProxyHostTrailingDot(value);
if (!host) {
return false;
}
if (isIpv4LikeHost(host) || isIpv6LikeHost(host)) {
return false;
}
return /[A-Za-z]/.test(host) && host.includes('.');
}
function isValidIpv4Address(value = '') {
const text = String(value || '').trim();
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(text)) {
return false;
}
const parts = text.split('.');
return parts.length === 4 && parts.every((part) => {
const numeric = Number.parseInt(part, 10);
return Number.isInteger(numeric) && numeric >= 0 && numeric <= 255;
});
}
function readCachedResolvedProxyHosts(host = '') {
const key = stripProxyHostTrailingDot(host).toLowerCase();
if (!key) {
return [];
}
const cached = ipProxyHostResolveCache.get(key);
if (!cached || !Array.isArray(cached?.ips) || !cached?.expiresAt) {
return [];
}
if (cached.expiresAt <= Date.now()) {
ipProxyHostResolveCache.delete(key);
return [];
}
return cached.ips;
}
function writeCachedResolvedProxyHosts(host = '', ips = []) {
const key = stripProxyHostTrailingDot(host).toLowerCase();
if (!key) {
return;
}
const normalized = Array.from(new Set((Array.isArray(ips) ? ips : [])
.map((item) => String(item || '').trim())
.filter((item) => isValidIpv4Address(item))));
if (!normalized.length) {
return;
}
ipProxyHostResolveCache.set(key, {
ips: normalized,
expiresAt: Date.now() + IP_PROXY_HOST_RESOLVE_CACHE_TTL_MS,
});
if (!ipProxyHostResolveCursor.has(key)) {
ipProxyHostResolveCursor.set(key, 0);
}
}
function pickNextResolvedProxyHost(host = '', candidates = []) {
const key = stripProxyHostTrailingDot(host).toLowerCase();
const list = Array.isArray(candidates)
? candidates.map((item) => String(item || '').trim()).filter((item) => isValidIpv4Address(item))
: [];
if (!key || !list.length) {
return '';
}
const cursor = Number(ipProxyHostResolveCursor.get(key) || 0);
const index = Math.abs(cursor) % list.length;
ipProxyHostResolveCursor.set(key, cursor + 1);
return String(list[index] || '').trim();
}
async function resolveProxyHostIpv4Candidates(host = '', timeoutMs = 3500) {
const normalizedHost = stripProxyHostTrailingDot(host);
if (!canUseProxyHostVariant(normalizedHost)) {
return [];
}
const cached = readCachedResolvedProxyHosts(normalizedHost);
if (cached.length) {
return cached;
}
const endpoint = `https://dns.google/resolve?name=${encodeURIComponent(normalizedHost)}&type=A`;
const response = await fetchWithTimeout(endpoint, {
method: 'GET',
cache: 'no-store',
headers: {
Accept: 'application/dns-json, application/json, text/plain, */*',
},
}, timeoutMs);
if (!response.ok) {
return [];
}
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== 'object') {
return [];
}
const answers = Array.isArray(payload?.Answer) ? payload.Answer : [];
const ips = answers
.filter((item) => Number(item?.type) === 1)
.map((item) => String(item?.data || '').trim())
.filter((ip) => isValidIpv4Address(ip));
const uniqueIps = Array.from(new Set(ips));
if (!uniqueIps.length) {
return [];
}
writeCachedResolvedProxyHosts(normalizedHost, uniqueIps);
return uniqueIps;
}
async function maybeResolveProxyHostVariantForAuthSwitch(entry = {}, options = {}) {
const force = Boolean(options?.force);
if (!force) {
return entry;
}
const provider = normalizeIpProxyProviderValue(entry?.provider || DEFAULT_IP_PROXY_SERVICE);
const allow711ResolvedIp = Boolean(options?.allow711ResolvedIp);
// 711 默认仍保持域名路由;仅在“多账号列表切换”场景下允许解析 IP 变体,
// 用于强制打断同 host:port 连接复用,提升触发 407 挑战的概率。
if (provider === '711proxy' && !allow711ResolvedIp) {
return entry;
}
const protocol = normalizeIpProxyProtocol(entry?.protocol || DEFAULT_IP_PROXY_PROTOCOL);
if (protocol !== 'http') {
return entry;
}
if (!String(entry?.username || '').trim()) {
return entry;
}
const host = stripProxyHostTrailingDot(entry?.host || '');
if (!canUseProxyHostVariant(host)) {
return entry;
}
const candidates = await resolveProxyHostIpv4Candidates(host, Number(options?.timeoutMs) || 3500).catch(() => []);
if (!candidates.length) {
return entry;
}
const selectedHost = pickNextResolvedProxyHost(host, candidates);
if (!selectedHost || !isValidIpv4Address(selectedHost)) {
return entry;
}
return {
...entry,
host: selectedHost,
};
}
function buildHostVariantForProxy(value = '', useTrailingDot = false) {
const host = stripProxyHostTrailingDot(value);
if (!canUseProxyHostVariant(host)) {
return host;
}
return useTrailingDot ? `${host}.` : host;
}
function shouldForceProxyConnectionDrainForEntry(entry = {}) {
const nextHost = normalizeProxyHostForCompare(entry?.host || '');
const nextPort = normalizeIpProxyPort(entry?.port);
const nextUser = String(entry?.username || '').trim();
const nextPass = String(entry?.password || '');
if (!nextHost || !nextPort || !nextUser) {
return false;
}
const previousHost = normalizeProxyHostForCompare(lastAppliedIpProxyAuthSnapshot?.host || '');
const previousPort = normalizeIpProxyPort(lastAppliedIpProxyAuthSnapshot?.port);
if (!previousHost || !previousPort) {
return false;
}
const sameEndpoint = nextHost === previousHost && nextPort === previousPort;
if (!sameEndpoint) {
return false;
}
const previousUser = String(lastAppliedIpProxyAuthSnapshot?.username || '').trim();
const previousPass = String(lastAppliedIpProxyAuthSnapshot?.password || '');
return nextUser !== previousUser || nextPass !== previousPass;
}
function buildEffectiveProxyEntryForApply(entry = {}, options = {}) {
const forceRotateVariant = Boolean(options?.forceRotateVariant);
const next = { ...(entry || {}) };
const provider = normalizeIpProxyProviderValue(next?.provider || DEFAULT_IP_PROXY_SERVICE);
const originalHost = String(next?.host || '').trim();
if (provider === '711proxy') {
const allow711HostVariant = Boolean(options?.allow711HostVariant) && forceRotateVariant;
if (allow711HostVariant && canUseProxyHostVariant(originalHost)) {
ipProxyAuthHostVariantToggle = !ipProxyAuthHostVariantToggle;
next.host = buildHostVariantForProxy(originalHost, ipProxyAuthHostVariantToggle);
return next;
}
// 单账号场景下保持 host 稳定,避免不必要的链路扰动。
next.host = stripProxyHostTrailingDot(originalHost);
return next;
}
const canVariant = canUseProxyHostVariant(originalHost);
if (!canVariant) {
next.host = stripProxyHostTrailingDot(originalHost);
return next;
}
if (forceRotateVariant) {
ipProxyAuthHostVariantToggle = !ipProxyAuthHostVariantToggle;
}
next.host = buildHostVariantForProxy(originalHost, ipProxyAuthHostVariantToggle);
return next;
}
function resetIpProxyAuthDiagnostics() {
ipProxyAuthDiagnostics = {
challengeCount: 0,
providedCount: 0,
lastIsProxy: null,
lastStatusCode: 0,
lastHost: '',
lastPort: 0,
lastAt: 0,
};
}
function recordIpProxyAuthChallenge(details = {}, provided = false) {
const host = String(details?.challenger?.host || '').trim();
const port = Number.parseInt(String(details?.challenger?.port || ''), 10) || 0;
const statusCode = Number.parseInt(String(details?.statusCode || ''), 10) || 0;
const isProxy = details?.isProxy === true;
ipProxyAuthDiagnostics.challengeCount += 1;
if (provided) {
ipProxyAuthDiagnostics.providedCount += 1;
}
ipProxyAuthDiagnostics.lastIsProxy = isProxy;
ipProxyAuthDiagnostics.lastStatusCode = statusCode;
ipProxyAuthDiagnostics.lastHost = host;
ipProxyAuthDiagnostics.lastPort = port;
ipProxyAuthDiagnostics.lastAt = Date.now();
}
function getIpProxyAuthDiagnosticsSummary() {
const lastHost = String(ipProxyAuthDiagnostics?.lastHost || '').trim();
const lastPort = Number(ipProxyAuthDiagnostics?.lastPort || 0);
const hostPort = lastHost
? `${lastHost}${lastPort > 0 ? `:${lastPort}` : ''}`
: 'unknown';
const isProxyText = ipProxyAuthDiagnostics?.lastIsProxy === null
? 'n/a'
: (ipProxyAuthDiagnostics.lastIsProxy ? 'true' : 'false');
return `auth(challenge=${Number(ipProxyAuthDiagnostics?.challengeCount || 0)},provided=${Number(ipProxyAuthDiagnostics?.providedCount || 0)},isProxy=${isProxyText},status=${Number(ipProxyAuthDiagnostics?.lastStatusCode || 0)},host=${hostPort})`;
}
function appendIpProxyAuthDiagnosticsToErrors(errors = []) {
if (!Array.isArray(errors)) {
return;
}
errors.push(`probe:${getIpProxyAuthDiagnosticsSummary()}`);
}
function parseIpProxyAuthDiagnosticsSummary(summary = '') {
const text = String(summary || '').trim();
if (!text) {
return null;
}
const challenge = Number.parseInt(String(text.match(/challenge=(\d+)/i)?.[1] || ''), 10) || 0;
const provided = Number.parseInt(String(text.match(/provided=(\d+)/i)?.[1] || ''), 10) || 0;
return { challenge, provided };
}
function resetIpProxyRuntimeErrorDiagnostics() {
ipProxyLastRuntimeError = {
error: '',
details: '',
fatal: false,
at: 0,
};
}
function getIpProxyRuntimeErrorSummary() {
const error = String(ipProxyLastRuntimeError?.error || '').trim();
const details = String(ipProxyLastRuntimeError?.details || '').trim();
const fatal = Boolean(ipProxyLastRuntimeError?.fatal);
if (!error && !details) {
return '';
}
return `proxy_error(error=${error || 'unknown'},fatal=${fatal ? 1 : 0},details=${details || 'n/a'})`;
}
function appendIpProxyRuntimeErrorDiagnosticsToErrors(errors = [], maxAgeMs = 30000) {
if (!Array.isArray(errors)) {
return;
}
const summary = getIpProxyRuntimeErrorSummary();
if (!summary) {
return;
}
const ageMs = Date.now() - Number(ipProxyLastRuntimeError?.at || 0);
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > Math.max(1000, Number(maxAgeMs) || 30000)) {
return;
}
errors.push(`probe:${summary}`);
}
function buildProbeDiagnosticsSummary(errors = [], maxItems = 4) {
if (!Array.isArray(errors) || !errors.length) {
return '';
}
const normalized = errors
.map((item) => String(item || '').trim())
.filter(Boolean);
if (!normalized.length) {
return '';
}
const authItem = normalized.find((item) => /probe:auth\(/i.test(item)) || '';
const proxyErrorItem = normalized.find((item) => /probe:proxy_error\(/i.test(item)) || '';
const navigationItem = normalized.find((item) => /probe:page_context:navigation_error:/i.test(item)) || '';
const pageContextItem = normalized.find((item) => /probe:page_context:/i.test(item)) || '';
const slowRetryItem = normalized.find((item) => /probe:bg:slow_retry:/i.test(item)) || '';
const retryItem = normalized.find((item) => /probe:bg:retry:/i.test(item)) || '';
const abortFallbackItem = normalized.find((item) => /probe:bg:abort_storm_page_fallback/i.test(item)) || '';
const headLimit = Math.max(1, Number(maxItems) || 4);
const headItems = normalized.slice(0, headLimit);
const mustKeepItems = [
authItem,
proxyErrorItem,
navigationItem,
pageContextItem,
slowRetryItem,
retryItem,
abortFallbackItem,
].filter(Boolean);
for (const mustKeepItem of mustKeepItems) {
if (headItems.includes(mustKeepItem)) {
continue;
}
if (headItems.length >= headLimit) {
headItems[headItems.length - 1] = mustKeepItem;
} else {
headItems.push(mustKeepItem);
}
}
return headItems.join(' | ');
}
function normalizeIpProxyMode(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return IP_PROXY_MODE_VALUES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_MODE;
}
function normalizeIpProxyProtocol(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return IP_PROXY_PROTOCOL_VALUES.includes(normalized) ? normalized : DEFAULT_IP_PROXY_PROTOCOL;
}
function normalizeIpProxyPort(value = '') {
const numeric = Number.parseInt(String(value || '').trim(), 10);
if (!Number.isInteger(numeric) || numeric <= 0 || numeric > 65535) {
return 0;
}
return numeric;
}
function normalizeIpProxyCurrentIndex(value, fallback = 0) {
const numeric = Number.parseInt(String(value ?? ''), 10);
if (!Number.isInteger(numeric) || numeric < 0) {
return Math.max(0, Number(fallback) || 0);
}
return numeric;
}
function normalizeIpProxyPoolTargetCount(value = '', fallback = 20) {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return String(Math.max(1, Math.min(500, Number(fallback) || 20)));
}
const numeric = Number.parseInt(rawValue, 10);
if (!Number.isInteger(numeric)) {
return String(Math.max(1, Math.min(500, Number(fallback) || 20)));
}
return String(Math.max(1, Math.min(500, numeric)));
}
function normalizeIpProxyAccountLifeMinutes(value = '') {
const raw = String(value ?? '').trim();
if (!raw) return '';
const numeric = Number.parseInt(raw, 10);
if (!Number.isInteger(numeric)) return '';
return String(Math.max(1, Math.min(1440, numeric)));
}
function normalizeIpProxyAccountSessionPrefix(value = '') {
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32);
}
function normalizeIpProxyAccountList(value = '') {
return String(value || '')
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter((line) => {
if (!line) {
return false;
}
// 允许用注释快速停用账号列表行,避免“看似注释、实际仍生效”。
if (/^(?:#|\/\/|;)/.test(line)) {
return false;
}
return true;
})
.join('\n');
}
function inferRegionFromProxyUsername(username = '') {
const text = String(username || '').trim();
if (!text) {
return '';
}
const match = text.match(/(?:^|[-_])(?:region|area|country)[-_:]?([A-Za-z]{2})\b/i);
if (!match) {
return '';
}
return String(match[1] || '').trim().toUpperCase();
}
function inferRegionFromProxyHost(host = '') {
const text = String(host || '').trim().toLowerCase().replace(/\.$/, '');
if (!text || !text.includes('.')) {
return '';
}
const firstLabel = String(text.split('.')[0] || '').trim();
if (!/^[a-z]{2}$/.test(firstLabel)) {
return '';
}
return firstLabel.toUpperCase();
}
function shouldApplyConfiguredRegionFallbackToEntry(entry = {}, context = {}) {
const provider = normalizeIpProxyProviderValue(
context?.provider || entry?.provider || DEFAULT_IP_PROXY_SERVICE
);
const configuredRegion = String(context?.configuredRegion || '').trim();
if (!configuredRegion) {
return false;
}
if (Boolean(context?.hasAccountList)) {
return false;
}
const entryRegion = String(entry?.region || '').trim();
if (entryRegion) {
return false;
}
if (provider !== '711proxy') {
return true;
}
const usernameRegion = inferRegionFromProxyUsername(entry?.username || '');
if (usernameRegion) {
return true;
}
const hostRegion = inferRegionFromProxyHost(entry?.host || '');
if (hostRegion) {
return true;
}
// 711 固定账号在 zone-custom 混播场景下,不使用旧的配置地区兜底,避免误报“卡在 AF”。
return false;
}
function resolveIpProxyAccountEntrySource(state = {}, mode = normalizeIpProxyMode(state?.ipProxyMode)) {
const normalizedMode = normalizeIpProxyMode(mode);
if (normalizedMode !== 'account') {
return 'non_account';
}
return hasConfiguredAccountListEntries(state) ? 'account_list' : 'fixed_account';
}
function isIpProxyAccountListEnabled() {
return Boolean(typeof IP_PROXY_ACCOUNT_LIST_ENABLED === 'undefined'
? true
: IP_PROXY_ACCOUNT_LIST_ENABLED);
}
function normalizeIpProxyServiceProfile(rawValue = {}) {
const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue))
? rawValue
: {};
const normalizeAutoSyncInterval = (value = '', fallback = 15) => {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return Math.max(1, Math.min(1440, Number(fallback) || 15));
}
const numeric = Number.parseInt(rawValue, 10);
if (!Number.isFinite(numeric)) {
return Math.max(1, Math.min(1440, Number(fallback) || 15));
}
return Math.max(1, Math.min(1440, numeric));
};
return {
mode: normalizeIpProxyMode(raw.mode),
apiUrl: String(raw.apiUrl || '').trim(),
accountList: normalizeIpProxyAccountList(raw.accountList || ''),
accountSessionPrefix: normalizeIpProxyAccountSessionPrefix(raw.accountSessionPrefix || ''),
accountLifeMinutes: normalizeIpProxyAccountLifeMinutes(raw.accountLifeMinutes || ''),
poolTargetCount: normalizeIpProxyPoolTargetCount(raw.poolTargetCount || '', 20),
autoSyncEnabled: Boolean(raw.autoSyncEnabled),
autoSyncIntervalMinutes: normalizeAutoSyncInterval(raw.autoSyncIntervalMinutes, 15),
host: String(raw.host || '').trim(),
port: String(normalizeIpProxyPort(raw.port || '') || ''),
protocol: normalizeIpProxyProtocol(raw.protocol),
username: String(raw.username || '').trim(),
password: String(raw.password || ''),
region: String(raw.region || '').trim(),
};
}
function buildIpProxyServiceProfileFromState(state = {}) {
return normalizeIpProxyServiceProfile({
mode: state?.ipProxyMode,
apiUrl: state?.ipProxyApiUrl,
accountList: state?.ipProxyAccountList,
accountSessionPrefix: state?.ipProxyAccountSessionPrefix,
accountLifeMinutes: state?.ipProxyAccountLifeMinutes,
poolTargetCount: state?.ipProxyPoolTargetCount,
autoSyncEnabled: state?.ipProxyAutoSyncEnabled,
autoSyncIntervalMinutes: state?.ipProxyAutoSyncIntervalMinutes,
host: state?.ipProxyHost,
port: state?.ipProxyPort,
protocol: state?.ipProxyProtocol,
username: state?.ipProxyUsername,
password: state?.ipProxyPassword,
region: state?.ipProxyRegion,
});
}
function normalizeIpProxyServiceProfiles(rawValue = {}, fallbackState = {}) {
const raw = (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue))
? rawValue
: {};
const fallbackProfile = buildIpProxyServiceProfileFromState(fallbackState);
const result = {};
IP_PROXY_SERVICE_VALUES.forEach((service) => {
const serviceRaw = raw[service];
if (serviceRaw && typeof serviceRaw === 'object' && !Array.isArray(serviceRaw)) {
result[service] = normalizeIpProxyServiceProfile(serviceRaw);
return;
}
result[service] = normalizeIpProxyServiceProfile(fallbackProfile);
});
return result;
}
function normalizeIpProxyEntriesFromObjectCandidate(candidate, provider = DEFAULT_IP_PROXY_SERVICE) {
if (!candidate || typeof candidate !== 'object') {
return null;
}
const directProxyText = String(
candidate.proxy
|| candidate.proxy_url
|| candidate.proxyUrl
|| candidate.proxyAddress
|| ''
).trim();
if (directProxyText) {
const parsed = parseIpProxyLine(directProxyText, provider);
if (parsed) {
return {
...parsed,
region: String(candidate.region || candidate.country || candidate.city || parsed.region || '').trim(),
};
}
}
const host = String(
candidate.host
|| candidate.ip
|| candidate.server
|| candidate.address
|| candidate.proxy_host
|| candidate.proxyHost
|| ''
).trim();
const port = normalizeIpProxyPort(
candidate.port
|| candidate.proxy_port
|| candidate.proxyPort
|| ''
);
if (!host || !port) {
return null;
}
const username = String(
candidate.username
|| candidate.user
|| candidate.account
|| candidate.proxy_user
|| candidate.proxyUser
|| ''
).trim();
const password = String(
candidate.password
|| candidate.pass
|| candidate.pwd
|| candidate.proxy_pass
|| candidate.proxyPass
|| ''
);
const protocol = normalizeIpProxyProtocol(
candidate.protocol
|| candidate.schema
|| candidate.scheme
|| candidate.type
|| ''
);
const region = String(candidate.region || candidate.country || candidate.city || '').trim();
return {
host,
port,
username,
password,
protocol,
region,
provider: normalizeIpProxyProviderValue(candidate.provider || provider),
};
}
function parseIpProxyLine(line, provider = DEFAULT_IP_PROXY_SERVICE) {
const text = String(line || '').trim();
if (!text) {
return null;
}
const schemaMatch = text.match(/^(https?|socks4|socks5):\/\/(.+)$/i);
const protocol = normalizeIpProxyProtocol(schemaMatch ? schemaMatch[1] : '');
const rawBody = schemaMatch ? schemaMatch[2] : text;
const firstSegment = rawBody.split(/[/?#]/)[0];
const parts = firstSegment.split(':');
if (parts.length < 2) {
return null;
}
const host = String(parts[0] || '').trim();
const port = normalizeIpProxyPort(parts[1]);
if (!host || !port) {
return null;
}
const username = String(parts[2] || '').trim();
const password = parts.length >= 4 ? String(parts.slice(3).join(':') || '') : '';
return {
host,
port,
username,
password,
protocol,
region: '',
provider: normalizeIpProxyProviderValue(provider),
};
}
function enumerateContiguousIpv4WithColonCandidates(text = '', startIndex = 0) {
const source = String(text || '');
const candidates = [];
const sourceLength = source.length;
const isDigitCode = (code) => code >= 48 && code <= 57;
const readOctet = (offset, maxLen = 3) => {
let end = offset;
while (end < sourceLength && end < offset + maxLen) {
const code = source.charCodeAt(end);
if (!isDigitCode(code)) {
break;
}
end += 1;
}
if (end <= offset) {
return null;
}
const value = Number.parseInt(source.slice(offset, end), 10);
if (!Number.isInteger(value) || value < 0 || value > 255) {
return null;
}
return { value, end };
};
for (let l1 = 1; l1 <= 3; l1 += 1) {
const o1 = readOctet(startIndex, l1);
if (!o1 || source[o1.end] !== '.') continue;
for (let l2 = 1; l2 <= 3; l2 += 1) {
const o2 = readOctet(o1.end + 1, l2);
if (!o2 || source[o2.end] !== '.') continue;
for (let l3 = 1; l3 <= 3; l3 += 1) {
const o3 = readOctet(o2.end + 1, l3);
if (!o3 || source[o3.end] !== '.') continue;
for (let l4 = 1; l4 <= 3; l4 += 1) {
const o4 = readOctet(o3.end + 1, l4);
if (!o4 || source[o4.end] !== ':') continue;
const host = `${o1.value}.${o2.value}.${o3.value}.${o4.value}`;
candidates.push({
host,
hostEnd: o4.end,
portStart: o4.end + 1,
});
}
}
}
}
return candidates;
}
function parseContiguousProxyStream(rawText = '', provider = DEFAULT_IP_PROXY_SERVICE) {
const source = String(rawText || '').trim();
if (!source) {
return [];
}
if (!/^\d/.test(source) || !source.includes(':')) {
return [];
}
const memo = new Map();
const solve = (offset = 0, previousHost = '') => {
const key = `${offset}|${previousHost}`;
if (memo.has(key)) {
return memo.get(key);
}
if (offset >= source.length) {
const terminal = { score: 0, tokens: [] };
memo.set(key, terminal);
return terminal;
}
let best = null;
const hostCandidates = enumerateContiguousIpv4WithColonCandidates(source, offset);
for (const candidate of hostCandidates) {
const { host, portStart } = candidate;
for (let portLen = 2; portLen <= 5; portLen += 1) {
const portEnd = portStart + portLen;
if (portEnd > source.length) {
break;
}
const portText = source.slice(portStart, portEnd);
if (!/^\d+$/.test(portText)) {
break;
}
const port = Number.parseInt(portText, 10);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
continue;
}
const next = solve(portEnd, host);
if (!next) {
continue;
}
const hostContinuityBonus = previousHost
? (previousHost === host ? 8 : -4)
: 0;
const portLengthBonus = portLen * 2;
const tinyPortPenalty = port < 1000 ? -40 : 0;
const score = next.score + 100 + hostContinuityBonus + portLengthBonus + tinyPortPenalty;
if (!best || score > best.score) {
best = {
score,
tokens: [{
host,
port,
provider: normalizeIpProxyProviderValue(provider),
}, ...next.tokens],
};
}
}
}
memo.set(key, best);
return best;
};
const solved = solve(0, '');
if (!solved || !Array.isArray(solved.tokens) || solved.tokens.length <= 0) {
return [];
}
return solved.tokens.map((item) => ({
host: String(item.host || '').trim(),
port: normalizeIpProxyPort(item.port),
username: '',
password: '',
protocol: DEFAULT_IP_PROXY_PROTOCOL,
region: '',
provider: normalizeIpProxyProviderValue(item.provider || provider),
})).filter((item) => item.host && item.port);
}
function normalizeProxyPoolEntries(value = [], provider = DEFAULT_IP_PROXY_SERVICE) {
const result = [];
const seen = new Set();
const append = (entry) => {
if (!entry || !entry.host || !entry.port) {
return;
}
const normalizedEntry = {
host: String(entry.host || '').trim(),
port: normalizeIpProxyPort(entry.port),
username: String(entry.username || '').trim(),
password: String(entry.password || ''),
protocol: normalizeIpProxyProtocol(entry.protocol || ''),
region: String(entry.region || '').trim(),
provider: normalizeIpProxyProviderValue(entry.provider || provider),
};
if (!normalizedEntry.host || !normalizedEntry.port) {
return;
}
const dedupKey = `${normalizedEntry.host}:${normalizedEntry.port}:${normalizedEntry.username}:${normalizedEntry.password}:${normalizedEntry.protocol}`;
if (seen.has(dedupKey)) {
return;
}
seen.add(dedupKey);
result.push(normalizedEntry);
};
if (typeof value === 'string') {
const rawText = String(value || '');
normalizeIpProxyAccountList(rawText).split('\n').forEach((line) => {
append(parseIpProxyLine(line, provider));
});
if (!result.length) {
const contiguousEntries = parseContiguousProxyStream(rawText, provider);
contiguousEntries.forEach((entry) => append(entry));
}
if (!result.length) {
// Lumi API 在 lb 参数异常时,可能返回无分隔的 host:port 串,这里做兜底拆分。
const contiguousMatches = rawText.match(/\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}/g) || [];
contiguousMatches.forEach((token) => {
append(parseIpProxyLine(token, provider));
});
}
return result;
}
if (Array.isArray(value)) {
value.forEach((item) => {
if (typeof item === 'string') {
append(parseIpProxyLine(item, provider));
return;
}
append(normalizeIpProxyEntriesFromObjectCandidate(item, provider));
});
return result;
}
if (value && typeof value === 'object') {
append(normalizeIpProxyEntriesFromObjectCandidate(value, provider));
}
return result;
}
function normalizeIpProxyListFromPayload(payload, provider = DEFAULT_IP_PROXY_SERVICE) {
if (Array.isArray(payload)) {
return normalizeProxyPoolEntries(payload, provider);
}
if (!payload || typeof payload !== 'object') {
if (typeof payload === 'string') {
return normalizeProxyPoolEntries(payload, provider);
}
return [];
}
const candidateArrays = [
payload.data,
payload.list,
payload.items,
payload.proxies,
payload.result,
payload.rows,
];
for (const candidate of candidateArrays) {
if (Array.isArray(candidate) && candidate.length > 0) {
return normalizeProxyPoolEntries(candidate, provider);
}
}
const singleCandidate = normalizeIpProxyEntriesFromObjectCandidate(payload, provider);
return singleCandidate ? [singleCandidate] : [];
}
function getAccountModeProxyPoolFromState(state = {}, provider = DEFAULT_IP_PROXY_SERVICE) {
const normalizedProvider = normalizeIpProxyProviderValue(provider || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE);
const accountListEnabled = isIpProxyAccountListEnabled();
const rawLines = normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean);
const lines = accountListEnabled ? rawLines : [];
const hasAccountList = lines.length > 0;
const transformState = hasAccountList
? {
...state,
ipProxyHost: '',
ipProxyPort: '',
ipProxyUsername: '',
ipProxyPassword: '',
ipProxyRegion: '',
ipProxyAccountSessionPrefix: '',
ipProxyAccountLifeMinutes: '',
}
: state;
let pool = lines.map((line) => parseIpProxyLine(line, normalizedProvider)).filter(Boolean);
if (!pool.length && !hasAccountList) {
const host = String(state?.ipProxyHost || '').trim();
const port = normalizeIpProxyPort(state?.ipProxyPort);
if (host && port) {
pool = [{
host,
port,
username: String(state?.ipProxyUsername || '').trim(),
password: String(state?.ipProxyPassword || ''),
protocol: normalizeIpProxyProtocol(state?.ipProxyProtocol),
region: '',
provider: normalizedProvider,
}];
} else if (!accountListEnabled && rawLines.length) {
const legacyEntry = parseIpProxyLine(rawLines[0], normalizedProvider);
if (legacyEntry) {
pool = [legacyEntry];
}
}
}
const configuredRegion = String(state?.ipProxyRegion || '').trim();
if (!pool.length) {
return [];
}
return pool.map((entry, index) => {
let nextEntry = { ...entry };
if (typeof transformIpProxyAccountEntryByProvider === 'function') {
const transformed = transformIpProxyAccountEntryByProvider(normalizedProvider, nextEntry, {
state: transformState,
index,
hasAccountList,
});
if (transformed && typeof transformed === 'object') {
nextEntry = transformed;
}
}
const inferredRegion = inferRegionFromProxyUsername(nextEntry.username);
if (inferredRegion) {
nextEntry.region = inferredRegion;
} else if (!String(nextEntry.region || '').trim()) {
const inferredHostRegion = inferRegionFromProxyHost(nextEntry.host);
if (inferredHostRegion) {
nextEntry.region = inferredHostRegion;
}
}
if (shouldApplyConfiguredRegionFallbackToEntry(nextEntry, {
provider: normalizedProvider,
configuredRegion,
hasAccountList,
})) {
nextEntry.region = configuredRegion;
}
return nextEntry;
});
}
function hasConfiguredAccountListEntries(state = {}) {
if (!isIpProxyAccountListEnabled()) {
return false;
}
const lines = normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean);
return lines.length > 0;
}
function getAccountListParseFailureHint(state = {}, provider = DEFAULT_IP_PROXY_SERVICE) {
if (!isIpProxyAccountListEnabled()) {
return '';
}
const normalizedProvider = normalizeIpProxyProviderValue(provider || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE);
const lines = normalizeIpProxyAccountList(state?.ipProxyAccountList || '').split('\n').filter(Boolean);
if (!lines.length) {
return '';
}
const parsedCount = lines
.map((line) => parseIpProxyLine(line, normalizedProvider))
.filter(Boolean)
.length;
if (parsedCount > 0) {
return '';
}
if (normalizedProvider === '711proxy') {
return '账号列表已填写,但未解析出有效条目。请按每行 host:port:username:password 填写。';
}
return '账号列表已填写,但未解析出有效条目。请检查列表格式。';
}
function resolveIpProxyPoolTargetCountForMode(state = {}, mode = normalizeIpProxyMode(state?.ipProxyMode)) {
const normalizedMode = normalizeIpProxyMode(mode);
// `ipProxyPoolTargetCount` 语义是“任务成功轮次阈值”,
// 拉取/切换代理池条数不再复用该字段,避免语义混淆。
if (normalizedMode === 'account') {
return 500;
}
return 100;
}
function resolveIpProxyAutoSwitchThreshold(state = {}) {
return Math.max(
1,
Math.min(500, Number(normalizeIpProxyPoolTargetCount(state?.ipProxyPoolTargetCount, 20)) || 20)
);
}
function buildProxyPoolSummary(pool = [], preferredIndex = 0) {
const normalizedPool = Array.isArray(pool) ? pool : [];
if (!normalizedPool.length) {
return {
count: 0,
index: 0,
current: null,
display: '暂无可用代理',
};
}
const index = normalizeIpProxyCurrentIndex(preferredIndex, 0) % normalizedPool.length;
const current = normalizedPool[index];
const region = String(current?.region || '').trim();
return {
count: normalizedPool.length,
index,
current,
display: `${current.host}:${current.port}${region ? ` [${region}]` : ''} (${index + 1}/${normalizedPool.length})`,
};
}
function getIpProxyRuntimeFieldNames(mode = DEFAULT_IP_PROXY_MODE) {
const normalizedMode = normalizeIpProxyMode(mode);
if (normalizedMode === 'account') {
return {
poolKey: 'ipProxyAccountPool',
indexKey: 'ipProxyAccountCurrentIndex',
currentKey: 'ipProxyAccountCurrent',
};
}
return {
poolKey: 'ipProxyApiPool',
indexKey: 'ipProxyApiCurrentIndex',
currentKey: 'ipProxyApiCurrent',
};
}
function getIpProxyRuntimeSnapshot(
state = {},
mode = normalizeIpProxyMode(state?.ipProxyMode),
provider = normalizeIpProxyProviderValue(state?.ipProxyService)
) {
const normalizedMode = normalizeIpProxyMode(mode);
const normalizedProvider = normalizeIpProxyProviderValue(provider);
const fields = getIpProxyRuntimeFieldNames(normalizedMode);
const hasModePool = Array.isArray(state?.[fields.poolKey]);
const hasModeCurrent = state?.[fields.currentKey] !== undefined && state?.[fields.currentKey] !== null;
const hasModeIndex = state?.[fields.indexKey] !== undefined && state?.[fields.indexKey] !== null;
const modePool = normalizeProxyPoolEntries(state?.[fields.poolKey], normalizedProvider);
const modeCurrent = normalizeProxyPoolEntries(
state?.[fields.currentKey] ? [state[fields.currentKey]] : [],
normalizedProvider
)[0] || null;
const pool = hasModePool ? modePool : [];
const current = hasModeCurrent ? modeCurrent : null;
const indexSource = hasModeIndex ? state?.[fields.indexKey] : 0;
return {
mode: normalizedMode,
provider: normalizedProvider,
...fields,
hasModePool,
hasModeCurrent,
hasModeIndex,
pool,
current,
index: normalizeIpProxyCurrentIndex(indexSource, 0),
};
}
function isApiModeProxyConfigAvailable(state = {}) {
return Boolean(String(state?.ipProxyApiUrl || '').trim());
}
function buildIpProxyRuntimeStatePatch(mode = DEFAULT_IP_PROXY_MODE, runtime = {}, provider = DEFAULT_IP_PROXY_SERVICE) {
const normalizedMode = normalizeIpProxyMode(mode);
const normalizedProvider = normalizeIpProxyProviderValue(provider);
const fields = getIpProxyRuntimeFieldNames(normalizedMode);
const pool = normalizeProxyPoolEntries(runtime?.pool, normalizedProvider);
const summary = buildProxyPoolSummary(pool, runtime?.index);
const explicitCurrent = normalizeProxyPoolEntries(
runtime?.current ? [runtime.current] : [],
normalizedProvider
)[0] || null;
const current = explicitCurrent || summary.current || null;
return {
[fields.poolKey]: pool,
[fields.indexKey]: summary.index,
[fields.currentKey]: current,
// 兼容旧状态字段,保持当前激活模式的摘要可见。
ipProxyPool: pool,
ipProxyCurrentIndex: summary.index,
ipProxyCurrent: current,
};
}
function getIpProxyCurrentEntryFromState(state = {}) {
const mode = normalizeIpProxyMode(state?.ipProxyMode);
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
if (mode === 'api' && !isApiModeProxyConfigAvailable(state)) {
return null;
}
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
if (mode === 'account') {
const hasAccountList = hasConfiguredAccountListEntries(state);
const accountPool = getAccountModeProxyPoolFromState(state, provider);
if (hasAccountList) {
if (!accountPool.length) {
return null;
}
const index = normalizeIpProxyCurrentIndex(runtime.index, 0) % accountPool.length;
return accountPool[index];
}
if (accountPool.length) {
const index = normalizeIpProxyCurrentIndex(runtime.index, 0) % accountPool.length;
return accountPool[index];
}
const host = String(state?.ipProxyHost || '').trim();
const port = normalizeIpProxyPort(state?.ipProxyPort);
if (!host || !port) return null;
const entry = {
host,
port,
username: String(state?.ipProxyUsername || '').trim(),
password: String(state?.ipProxyPassword || ''),
protocol: normalizeIpProxyProtocol(state?.ipProxyProtocol),
region: '',
provider,
};
const inferredRegionFromUsername = inferRegionFromProxyUsername(entry.username);
if (inferredRegionFromUsername) {
entry.region = inferredRegionFromUsername;
} else {
const inferredRegionFromHost = inferRegionFromProxyHost(entry.host);
if (inferredRegionFromHost) {
entry.region = inferredRegionFromHost;
}
}
const configuredRegion = String(state?.ipProxyRegion || '').trim();
if (shouldApplyConfiguredRegionFallbackToEntry(entry, {
provider,
configuredRegion,
hasAccountList: false,
})) {
entry.region = configuredRegion;
}
return entry;
}
const pool = runtime.pool;
if (pool.length) {
const index = normalizeIpProxyCurrentIndex(runtime.index, 0) % pool.length;
return pool[index];
}
const fallback = normalizeProxyPoolEntries(runtime.current ? [runtime.current] : [], provider)[0];
return fallback || null;
}
function buildIpProxyRoutingStatePatch(status = {}) {
const provider = normalizeIpProxyProviderValue(status?.provider || DEFAULT_IP_PROXY_SERVICE);
const host = String(status?.host || '').trim();
const port = normalizeIpProxyPort(status?.port);
const region = String(status?.region || '').trim();
const hasAuth = Boolean(status?.hasAuth);
const applied = Boolean(status?.applied);
const reason = String(status?.reason || (applied ? 'applied' : 'disabled')).trim().toLowerCase();
const error = String(status?.error || '').trim();
const warning = String(status?.warning || '').trim();
const exitIp = String(status?.exitIp || '').trim();
const exitRegion = String(status?.exitRegion || '').trim();
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,
ipProxyAppliedAt: Date.now(),
ipProxyAppliedHost: host,
ipProxyAppliedPort: port,
ipProxyAppliedRegion: region,
ipProxyAppliedHasAuth: hasAuth,
ipProxyAppliedProvider: provider,
ipProxyAppliedError: error,
ipProxyAppliedWarning: warning,
ipProxyAppliedExitIp: exitIp,
ipProxyAppliedExitRegion: exitRegion,
ipProxyAppliedExitDetecting: exitDetecting,
ipProxyAppliedExitError: exitError,
ipProxyAppliedExitSource: exitSource,
ipProxyAppliedExitEndpoint: exitEndpoint,
};
}
async function setIpProxyLeakGuardEnabled(enabled) {
if (!chrome.declarativeNetRequest?.updateDynamicRules) {
return;
}
const removeRuleIds = [IP_PROXY_GUARD_BLOCK_RULE_ID];
const addRules = enabled ? [{
id: IP_PROXY_GUARD_BLOCK_RULE_ID,
priority: 100,
action: {
type: 'block',
},
condition: {
regexFilter: IP_PROXY_GUARD_REGEX,
resourceTypes: [
'main_frame',
'sub_frame',
'xmlhttprequest',
'websocket',
'other',
],
},
}] : [];
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds,
addRules,
}).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));
try {
return await fetch(url, {
...options,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function extractIpv4FromText(value = '') {
const text = String(value || '').trim();
if (!text) {
return '';
}
const ipv4Match = text.match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/);
if (ipv4Match) {
return ipv4Match[1];
}
const ipv6Match = text.match(/\b([0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7})\b/i);
return ipv6Match ? String(ipv6Match[1] || '').trim() : '';
}
function normalizeProbeRegionCode(value = '') {
const code = String(value || '')
.trim()
.toUpperCase()
.replace(/[^A-Z]/g, '');
return /^[A-Z]{2}$/.test(code) ? code : '';
}
function pickProbeRegion(parsed = {}) {
const countryCodeCandidates = [
parsed?.countryCode,
parsed?.country_code,
parsed?.countrycode,
parsed?.countryAlpha2,
parsed?.country_alpha2,
parsed?.countryISO,
parsed?.countryIso,
parsed?.iso_country,
parsed?.geo?.countryCode,
parsed?.geo?.country_code,
parsed?.location?.countryCode,
parsed?.location?.country_code,
];
for (const candidate of countryCodeCandidates) {
const code = normalizeProbeRegionCode(candidate);
if (code) {
return code;
}
}
const fallbackCandidates = [
parsed?.regionCode,
parsed?.region_code,
parsed?.country,
parsed?.country_name,
parsed?.countryName,
parsed?.regionName,
parsed?.region,
parsed?.city,
];
for (const candidate of fallbackCandidates) {
const text = String(candidate || '').trim();
if (text) {
return text;
}
}
return '';
}
function parseProxyExitProbePayload(rawText = '', contentType = '') {
const text = String(rawText || '').trim();
if (!text) {
return { ip: '', region: '' };
}
if (/^[-_a-zA-Z0-9]+=/m.test(text)) {
const kv = {};
text.split(/\r?\n+/).forEach((line) => {
const index = line.indexOf('=');
if (index <= 0) return;
const key = String(line.slice(0, index) || '').trim().toLowerCase();
if (!key) return;
kv[key] = String(line.slice(index + 1) || '').trim();
});
const kvIp = extractIpv4FromText(
kv.ip
|| kv.query
|| kv.clientip
|| kv.client_ip
|| ''
);
const kvRegion = normalizeProbeRegionCode(kv.loc || kv.country_code || kv.countrycode || '');
if (kvIp) {
return {
ip: kvIp,
region: kvRegion,
};
}
}
const normalizedContentType = String(contentType || '').toLowerCase();
if (normalizedContentType.includes('application/json') || text.startsWith('{') || text.startsWith('[')) {
try {
const parsed = JSON.parse(text);
const ip = extractIpv4FromText(
parsed?.ip
|| parsed?.query
|| parsed?.origin
|| parsed?.address
|| parsed?.ipAddress
|| parsed?.ip_address
|| ''
);
const region = pickProbeRegion(parsed);
if (ip) {
return { ip, region };
}
} catch {
// fall through to regex parse
}
}
const ip = extractIpv4FromText(text);
if (!ip) {
return { ip: '', region: '' };
}
return {
ip,
region: '',
};
}
function normalizeRegionToken(value = '') {
return String(value || '')
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
}
function hasProxyExitRegionMismatch(expectedRegion = '', detectedRegion = '') {
const expected = normalizeRegionToken(expectedRegion);
const detected = normalizeRegionToken(detectedRegion);
if (!expected || !detected) {
return false;
}
if (expected === detected) {
return false;
}
if (expected.length >= 2 && detected.includes(expected)) {
return false;
}
if (detected.length >= 2 && expected.includes(detected)) {
return false;
}
return true;
}
function has711SessionToken(username = '') {
const text = String(username || '').trim();
if (!text) {
return false;
}
return /(?:^|[-_])session[-_:][A-Za-z0-9_-]+\b/i.test(text);
}
function resolveExitProbeEndpoints(options = {}) {
const provider = normalizeIpProxyProviderValue(options?.provider || '');
const username = String(options?.username || '').trim();
if (provider === '711proxy' && has711SessionToken(username)) {
return IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY.slice();
}
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();
if (!exitIp) {
const hasBackgroundAbortStorm = /probe:bg:/.test(exitError)
&& /signal is aborted without reason/i.test(exitError);
const hasBackgroundAbortFallback = /probe:bg:abort_storm_page_fallback/i.test(exitError);
const hasProxyRuntimeError = /probe:proxy_error\(/i.test(exitError);
const hasAuth = Boolean(status?.hasAuth);
const challengeMatch = exitError.match(/challenge=(\d+)/i);
const providedMatch = exitError.match(/provided=(\d+)/i);
const challengeCount = Number.parseInt(String(challengeMatch?.[1] || ''), 10) || 0;
const providedCount = Number.parseInt(String(providedMatch?.[1] || ''), 10) || 0;
const missingProxyChallenge = hasAuth && challengeCount <= 0 && providedCount <= 0;
const hasErrorPageFailure = /Frame with ID 0 is showing error page|Cannot access contents of the page/i.test(exitError);
const netErrorCode = exitError.match(/net::ERR_[A-Z_]+/i)?.[0] || '';
const navigationHint = netErrorCode
? `导航错误:${netErrorCode}`
: '';
const tunnelFailedWithoutChallenge = missingProxyChallenge
&& /ERR_TUNNEL_CONNECTION_FAILED/i.test(netErrorCode || exitError);
const humanizedError = hasErrorPageFailure
? (
hasAuth
? '探测页已进入浏览器错误页(常见于代理鉴权失败、代理节点不可用或网络被拦截)。请先检查代理账号/密码与节点可用性,再重试检测。'
: '探测页已进入浏览器错误页(常见于代理节点不可用、代理协议不匹配或网络被拦截)。请先检查代理节点、端口与协议配置,再重试检测。'
)
: '';
const authDiagnostics = exitError.match(/probe:auth\([^)]+\)/i)?.[0] || '';
const challengeHint = missingProxyChallenge
? '当前链路未收到 407 代理鉴权挑战(可能是代理端返回非标准拒绝,例如 630 no Proxy-Authorization),浏览器无法触发扩展鉴权回填。建议切换 API 模式,或更换会返回标准 407 的账号节点。'
: '';
const compatibilityHint = tunnelFailedWithoutChallenge
? '当前账号模式节点可能采用非标准代理鉴权链路(未发起 407 挑战),该链路在 Chrome 扩展代理 API 下无法自动回填凭据。'
: '';
const mergedErrorParts = [
hasBackgroundAbortStorm
? (
hasBackgroundAbortFallback
? '后台探测请求连续超时中断(代理隧道未在时限内建立),且已自动回退页面探测但仍未拿到可用出口。'
: '后台探测请求连续超时中断(代理隧道未在时限内建立)。这通常是代理节点链路不可用或被上游丢弃,并非探测页面本身异常。'
)
: '',
hasProxyRuntimeError
? '已捕获浏览器代理栈错误事件(chrome.proxy.onProxyError),请优先依据诊断中的 proxy_error 字段排查节点/协议兼容性。'
: '',
humanizedError,
navigationHint,
compatibilityHint,
challengeHint,
].filter(Boolean);
const mergedError = mergedErrorParts.length
? `${mergedErrorParts.join(' ')}${authDiagnostics ? ` 诊断:${authDiagnostics}` : ''}`
: '';
const mergedWithDiagnostics = mergedError
? (
exitError
&& !mergedError.includes(exitError)
? `${mergedError} 诊断:${exitError}`
: mergedError
)
: '';
return {
...status,
applied: false,
reason: 'connectivity_failed',
error: mergedWithDiagnostics || exitError || '未检测到代理出口 IP,无法确认代理已生效。',
};
}
const expected = String(expectedRegion || '').trim();
const entrySource = String(status?.entrySource || '').trim();
const normalizedProvider = normalizeIpProxyProviderValue(status?.provider || DEFAULT_IP_PROXY_SERVICE);
const authSummary = String(status?.authDiagnostics || '').trim();
const parsedAuthSummary = parseIpProxyAuthDiagnosticsSummary(authSummary);
const missingAuthChallenge = Boolean(status?.hasAuth)
&& parsedAuthSummary
&& parsedAuthSummary.challenge <= 0
&& parsedAuthSummary.provided <= 0;
if (!expected) {
return status;
}
const detected = String(status?.exitRegion || '').trim();
if (!detected) {
return {
...status,
applied: false,
reason: 'connectivity_failed',
error: `已检测到出口 IP ${exitIp},但地区探测未返回国家/地区代码,暂无法校验期望地区 ${expected}`,
};
}
if (!hasProxyExitRegionMismatch(expected, detected)) {
return status;
}
const sourceHint = entrySource === 'account_list'
? '(来源:账号列表)'
: (entrySource === 'fixed_account' ? '(来源:固定账号)' : '');
const usernameRegion = inferRegionFromProxyUsername(status?.username || '');
const usernameHint = usernameRegion
? (
hasProxyExitRegionMismatch(expected, usernameRegion)
? ` 当前账号用户名地区标记为 ${usernameRegion},与期望不一致。`
: ` 当前账号用户名地区标记为 ${usernameRegion}`
)
: '';
const missingAuthChallengeHint = Boolean(status?.hasAuth)
&& parsedAuthSummary
&& parsedAuthSummary.challenge <= 0
&& parsedAuthSummary.provided <= 0
? ' 当前链路未触发代理鉴权挑战(407),可能复用了历史代理连接/鉴权缓存,或代理端未返回标准 407 导致账号参数未被浏览器回填(可能走匿名链路)。可先关闭再开启代理,或切换到下一条节点后重试。'
: '';
const authHint = authSummary ? ` 诊断:probe:${authSummary}` : '';
const mismatchMessage = `代理出口地区与预期不一致:期望 ${expected},实际 ${detected || 'unknown'}${sourceHint}${usernameHint}${missingAuthChallengeHint}${authHint}`.trim();
if (normalizedProvider === '711proxy') {
const warningPrefix = missingAuthChallenge
? '地区校验未通过且未触发代理鉴权挑战,疑似匿名链路;先保留代理接管并给出强告警:'
: '地区校验未通过,但已保留代理接管:';
return {
...status,
applied: true,
reason: 'applied_with_warning',
warning: `${warningPrefix}${mismatchMessage}`,
error: '',
};
}
return {
...status,
applied: false,
reason: 'connectivity_failed',
error: mismatchMessage,
};
}
function applyExitBaselineExpectation(status = {}) {
const exitIp = String(status?.exitIp || '').trim();
const baselineIp = String(status?.exitBaselineIp || '').trim();
const exitSource = String(status?.exitSource || '').trim().toLowerCase();
if (exitIp && exitSource.includes('background') && !baselineIp) {
return {
...status,
applied: false,
reason: 'connectivity_failed',
error: `已检测到出口 IP ${exitIp},但系统基线网络探测失败,无法确认是否经过插件代理链路。`,
};
}
if (!exitIp || !baselineIp) {
return status;
}
if (exitIp !== baselineIp) {
return status;
}
return {
...status,
applied: false,
reason: 'connectivity_failed',
error: `检测到出口 IP ${exitIp} 与系统基线网络一致,疑似未经过插件代理链路。`,
};
}
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 : [];
const configuredEndpoints = Array.isArray(options?.probeEndpoints)
? options.probeEndpoints.map((item) => String(item || '').trim()).filter(Boolean)
: [];
const availableEndpoints = configuredEndpoints.length
? configuredEndpoints
: IP_PROXY_EXIT_PROBE_ENDPOINTS;
const requestedPerEndpointTimeoutMs = Number(options?.backgroundPerEndpointTimeoutMs);
const perRequestTimeoutMs = Math.max(
1200,
Math.min(
Number.isFinite(requestedPerEndpointTimeoutMs) && requestedPerEndpointTimeoutMs > 0
? requestedPerEndpointTimeoutMs
: IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS,
timeoutMs
)
);
const maxEndpoints = Math.max(
1,
Math.min(
availableEndpoints.length,
Number.isFinite(Number(options?.backgroundMaxEndpoints))
? Number(options.backgroundMaxEndpoints)
: IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS
)
);
const probeEndpoints = availableEndpoints.slice(0, maxEndpoints);
let baselineIp = '';
try {
const baselineUrl = `${IP_PROXY_PAGE_CONTEXT_BASELINE_URL}?_multipage_proxy_baseline=${Date.now()}`;
const baselineResponse = await fetchWithTimeout(baselineUrl, {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json, text/plain, */*' },
}, perRequestTimeoutMs);
if (!baselineResponse.ok) {
errors.push(`probe:bg:baseline:status:${baselineResponse.status}`);
} else {
const baselineText = await baselineResponse.text();
const parsedBaseline = parseProxyExitProbePayload(
baselineText,
baselineResponse.headers.get('content-type')
);
baselineIp = String(parsedBaseline?.ip || '').trim();
if (!baselineIp) {
errors.push('probe:bg:baseline:empty_parse');
}
}
} catch (baselineError) {
errors.push(`probe:bg:baseline:${baselineError?.message || baselineError}`);
}
const runProbePass = async (endpoints, requestTimeoutMs, diagnosticPrefix = 'probe:bg') => {
let bestEffortResult = null;
let abortLikeCount = 0;
for (const endpoint of endpoints) {
try {
const url = endpoint.includes('?') ? `${endpoint}&_t=${Date.now()}` : `${endpoint}?_t=${Date.now()}`;
const response = await fetchWithTimeout(url, {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json, text/plain, */*' },
}, requestTimeoutMs);
if (!response.ok) {
errors.push(`${diagnosticPrefix}:${endpoint}:status:${response.status}`);
continue;
}
const text = await response.text();
const parsed = parseProxyExitProbePayload(text, response.headers.get('content-type'));
if (parsed.ip) {
const result = {
ip: parsed.ip,
region: parsed.region,
source: 'background_fallback',
endpoint,
baselineIp,
};
if (parsed.region) {
return {
result,
bestEffortResult: result,
abortLikeCount,
};
}
if (!bestEffortResult) {
bestEffortResult = result;
}
errors.push(`${diagnosticPrefix}:${endpoint}:missing_region`);
continue;
}
errors.push(`${diagnosticPrefix}:${endpoint}:empty_parse`);
} catch (error) {
const message = String(error?.message || error || '').trim();
if (/signal is aborted without reason|abort/i.test(message)) {
abortLikeCount += 1;
}
errors.push(`${diagnosticPrefix}:${endpoint}:${message}`);
}
}
return {
result: bestEffortResult,
bestEffortResult,
abortLikeCount,
};
};
const firstPass = await runProbePass(probeEndpoints, perRequestTimeoutMs, 'probe:bg');
if (firstPass?.result?.ip && firstPass?.result?.region) {
return firstPass.result;
}
if (!firstPass?.result?.ip
&& firstPass?.abortLikeCount >= Math.min(2, probeEndpoints.length)
&& options?.backgroundSlowRetry !== false) {
const retryTimeoutMs = Math.max(perRequestTimeoutMs + 2500, 7000);
const retryEndpoints = probeEndpoints.slice(0, Math.min(2, probeEndpoints.length));
errors.push(`probe:bg:slow_retry:${retryTimeoutMs}ms`);
const retryPass = await runProbePass(retryEndpoints, retryTimeoutMs, 'probe:bg:retry');
if (retryPass?.result?.ip) {
return retryPass.result;
}
}
if (!firstPass?.bestEffortResult?.ip) {
appendIpProxyRuntimeErrorDiagnosticsToErrors(errors, 45000);
}
return firstPass?.bestEffortResult || { ip: '', region: '', source: 'background_fallback', baselineIp };
}
function isPageContextProbeHost(hostname = '') {
const normalized = String(hostname || '').trim().toLowerCase();
return normalized === 'chatgpt.com'
|| normalized.endsWith('.chatgpt.com')
|| normalized === 'openai.com'
|| normalized.endsWith('.openai.com')
|| normalized === 'example.com'
|| normalized.endsWith('.example.com')
|| normalized === 'ip-api.com'
|| normalized.endsWith('.ip-api.com')
|| normalized === 'httpbin.org'
|| normalized === 'ipwho.is'
|| normalized.endsWith('.ipwho.is')
|| normalized === 'ipinfo.io'
|| normalized.endsWith('.ipinfo.io')
|| normalized === 'ipapi.co'
|| normalized.endsWith('.ipapi.co');
}
function isProbeErrorPageExecutionError(error) {
const message = String(error?.message || error || '');
return /Frame with ID 0 is showing error page|Cannot access contents of the page/i.test(message);
}
function hasProbeErrorPageDiagnostics(errors = []) {
if (!Array.isArray(errors) || !errors.length) {
return false;
}
return errors.some((item) => isProbeErrorPageExecutionError(String(item || '')));
}
function createProbeNavigationErrorTracker(initialTabId = null) {
if (!chrome.webNavigation?.onErrorOccurred?.addListener) {
return {
setTabId: () => {},
appendDiagnostics: () => {},
dispose: () => {},
};
}
let activeTabId = Number.isInteger(initialTabId) ? Number(initialTabId) : null;
const entries = [];
const listener = (details = {}) => {
if (!Number.isInteger(activeTabId) || Number(details?.tabId) !== activeTabId) {
return;
}
if (Number(details?.frameId) !== 0) {
return;
}
const errorCode = String(details?.error || '').trim();
if (!errorCode) {
return;
}
const url = String(details?.url || '').trim();
entries.push({
error: errorCode,
host: extractProbeHostFromTabUrl(url),
url,
});
};
try {
chrome.webNavigation.onErrorOccurred.addListener(listener);
} catch {
return {
setTabId: () => {},
appendDiagnostics: () => {},
dispose: () => {},
};
}
return {
setTabId(nextTabId) {
activeTabId = Number.isInteger(nextTabId) ? Number(nextTabId) : null;
},
appendDiagnostics(errors, maxItems = 2) {
if (!Array.isArray(errors) || !entries.length) {
return;
}
const limit = Math.max(1, Number(maxItems) || 2);
const recent = entries.slice(-limit);
for (const item of recent) {
const errorCode = String(item?.error || '').trim();
if (!errorCode) {
continue;
}
const host = String(item?.host || '').trim() || 'unknown';
errors.push(`probe:page_context:navigation_error:${errorCode}@${host}`);
}
},
dispose() {
try {
chrome.webNavigation.onErrorOccurred.removeListener(listener);
} catch {
// ignore remove listener failures
}
},
};
}
function extractProbeHostFromTabUrl(rawUrl = '') {
try {
const parsed = new URL(rawUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return '';
}
return parsed.hostname || '';
} catch {
return '';
}
}
async function pickExistingPageContextProbeTabId(options = {}) {
const tabs = await queryAutomationScopedTabs({}, options);
const candidates = tabs
.filter((tab) => Number.isInteger(tab?.id))
.filter((tab) => isPageContextProbeHost(extractProbeHostFromTabUrl(tab?.url || '')));
if (!candidates.length) {
return null;
}
const preferred = candidates.find((tab) => String(tab?.status || '').toLowerCase() === 'complete');
return Number((preferred || candidates[0]).id) || null;
}
async function waitForPageContextProbeTabReady(tabId, timeoutMs = 15000) {
const timeout = Math.max(1500, Number(timeoutMs) || 15000);
if (typeof waitForTabComplete === 'function') {
const result = await waitForTabComplete(tabId, {
timeoutMs: timeout,
retryDelayMs: 300,
});
return Boolean(result?.id);
}
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const tab = await chrome.tabs.get(tabId);
if (String(tab?.status || '').toLowerCase() === 'complete') {
return true;
}
} catch {
return false;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
async function readExitProbeFromTabDocument(tabId) {
const executionResults = await chrome.scripting.executeScript({
target: { tabId },
world: 'ISOLATED',
func: () => {
const preText = String(document.querySelector('pre')?.textContent || '').trim();
const bodyText = String(document.body?.innerText || document.documentElement?.innerText || '').trim();
return {
url: String(location.href || ''),
text: preText || bodyText,
};
},
});
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, []);
}
async function probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs = 10000, errors = [], endpointsOverride = []) {
if (!Number.isInteger(tabId)) {
return null;
}
const configuredEndpoints = Array.isArray(endpointsOverride)
? endpointsOverride.map((item) => String(item || '').trim()).filter(Boolean)
: [];
const availableEndpoints = configuredEndpoints.length
? configuredEndpoints
: IP_PROXY_EXIT_PROBE_ENDPOINTS;
const endpoints = availableEndpoints.slice(0, 6);
const perEndpointTimeoutMs = Math.max(
2000,
Math.min(IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS, Number(timeoutMs) || 10000)
);
let bestEffortResult = null;
for (const endpoint of endpoints) {
const endpointText = String(endpoint || '').trim();
if (!endpointText) {
continue;
}
const probeTargetUrl = endpointText.includes('?')
? `${endpointText}&_multipage_proxy_probe=${Date.now()}`
: `${endpointText}?_multipage_proxy_probe=${Date.now()}`;
try {
await chrome.tabs.update(tabId, {
url: probeTargetUrl,
active: false,
});
const ready = await waitForPageContextProbeTabReady(tabId, perEndpointTimeoutMs);
if (!ready) {
if (Array.isArray(errors)) {
errors.push(`probe:page_context:navigation:${endpointText}:not_ready`);
}
continue;
}
const documentResult = await readExitProbeFromTabDocument(tabId);
const parsed = parseProxyExitProbePayload(String(documentResult?.text || ''), 'application/json');
if (parsed?.ip) {
const result = {
ip: String(parsed.ip || '').trim(),
region: String(parsed.region || '').trim(),
endpoint: String(documentResult?.url || probeTargetUrl || endpointText).trim(),
source: 'page_context_navigation',
};
if (result.region) {
return result;
}
if (!bestEffortResult) {
bestEffortResult = result;
}
if (Array.isArray(errors)) {
errors.push(`probe:page_context:navigation:${endpointText}:missing_region`);
}
continue;
}
if (Array.isArray(errors)) {
errors.push(`probe:page_context:navigation:${endpointText}:empty_parse`);
}
} catch (error) {
if (Array.isArray(errors)) {
errors.push(`probe:page_context:navigation:${endpointText}:${error?.message || error}`);
}
}
}
return bestEffortResult;
}
async function probeExitInfoViaExecuteScript(tabId, timeoutMs = 10000, endpointsOverride = []) {
const perRequestTimeoutMs = Math.max(
1200,
Math.min(IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS, Number(timeoutMs) || 10000)
);
const configuredEndpoints = Array.isArray(endpointsOverride)
? endpointsOverride.map((item) => String(item || '').trim()).filter(Boolean)
: [];
const availableEndpoints = configuredEndpoints.length
? configuredEndpoints
: IP_PROXY_EXIT_PROBE_ENDPOINTS;
const endpoints = availableEndpoints.slice(0, IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS);
const executionResults = await chrome.scripting.executeScript({
target: { tabId },
world: 'ISOLATED',
func: async (payload) => {
const extractIpv4 = (value) => {
const text = String(value || '').trim();
if (!text) return '';
const match = text.match(/\b(\d{1,3}(?:\.\d{1,3}){3})\b/);
return match ? match[1] : '';
};
const normalizeRegionCode = (value) => {
const code = String(value || '')
.trim()
.toUpperCase()
.replace(/[^A-Z]/g, '');
return /^[A-Z]{2}$/.test(code) ? code : '';
};
const pickRegion = (parsed) => {
const codeCandidates = [
parsed?.countryCode,
parsed?.country_code,
parsed?.countrycode,
parsed?.countryAlpha2,
parsed?.country_alpha2,
parsed?.countryISO,
parsed?.countryIso,
parsed?.iso_country,
parsed?.geo?.countryCode,
parsed?.geo?.country_code,
parsed?.location?.countryCode,
parsed?.location?.country_code,
];
for (const candidate of codeCandidates) {
const code = normalizeRegionCode(candidate);
if (code) return code;
}
const fallbackCandidates = [
parsed?.regionCode,
parsed?.region_code,
parsed?.country,
parsed?.country_name,
parsed?.countryName,
parsed?.regionName,
parsed?.region,
parsed?.city,
];
for (const candidate of fallbackCandidates) {
const text = String(candidate || '').trim();
if (text) return text;
}
return '';
};
const parsePayload = (rawText, contentType) => {
const text = String(rawText || '').trim();
if (!text) {
return { ip: '', region: '' };
}
const normalizedContentType = String(contentType || '').toLowerCase();
if (normalizedContentType.includes('application/json') || text.startsWith('{') || text.startsWith('[')) {
try {
const parsed = JSON.parse(text);
const ip = extractIpv4(
parsed?.ip
|| parsed?.query
|| parsed?.origin
|| parsed?.address
|| parsed?.ipAddress
|| parsed?.ip_address
|| ''
);
const region = pickRegion(parsed);
if (ip) {
return { ip, region };
}
} catch {
// fall through to regex parse
}
}
const ipMatch = extractIpv4(text);
return {
ip: ipMatch || '',
region: '',
};
};
const fetchWithTimeout = async (url, timeoutValue) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutValue) || 10000));
try {
return await fetch(url, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
headers: {
Accept: 'application/json, text/plain, */*',
},
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
};
const endpoints = Array.isArray(payload?.endpoints) ? payload.endpoints : [];
const normalizedEndpoints = [];
const endpointSet = new Set();
for (const endpoint of endpoints) {
const text = String(endpoint || '').trim();
if (!text || !/^https?:\/\//i.test(text) || endpointSet.has(text)) {
continue;
}
endpointSet.add(text);
normalizedEndpoints.push(text);
}
const timeoutValue = Math.max(1000, Number(payload?.timeoutMs) || 10000);
const diagnostics = [];
let bestEffortResult = null;
for (const endpoint of normalizedEndpoints.slice(0, 8)) {
try {
const url = endpoint.includes('?') ? `${endpoint}&_t=${Date.now()}` : `${endpoint}?_t=${Date.now()}`;
const response = await fetchWithTimeout(url, timeoutValue);
if (!response.ok) {
diagnostics.push(`${endpoint}:status:${response.status}`);
continue;
}
const text = await response.text();
const parsed = parsePayload(text, response.headers.get('content-type'));
if (parsed.ip) {
const result = {
ip: parsed.ip,
region: parsed.region,
endpoint,
diagnostics,
};
if (parsed.region) {
return result;
}
if (!bestEffortResult) {
bestEffortResult = result;
}
diagnostics.push(`${endpoint}:missing_region`);
continue;
}
diagnostics.push(`${endpoint}:empty_parse`);
} catch (error) {
diagnostics.push(`${endpoint}:${error?.message || error}`);
}
}
return bestEffortResult || {
ip: '',
region: '',
endpoint: '',
diagnostics,
};
},
args: [{
endpoints,
timeoutMs: perRequestTimeoutMs,
}],
});
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 createAutomationScopedTab({
url: targetUrl,
active: false,
}, options);
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 : [];
const probeEndpoints = Array.isArray(options?.probeEndpoints)
? options.probeEndpoints.map((item) => String(item || '').trim()).filter(Boolean)
: [];
let sawProbeErrorPage = false;
if (!chrome.scripting?.executeScript) {
errors.push('probe:page_context:scripting_unavailable');
return { ip: '', region: '', source: 'page_context_unavailable' };
}
let probeTabId = null;
let createdProbeTabId = null;
const probeUrl = `${IP_PROXY_PAGE_CONTEXT_PROBE_URL}?_multipage_proxy_probe=${Date.now()}`;
try {
const tab = await createAutomationScopedTab({
url: probeUrl,
active: false,
}, options);
probeTabId = Number(tab?.id) || null;
createdProbeTabId = probeTabId;
} catch (error) {
errors.push(`probe:page_context:create_probe_tab_failed:${error?.message || error}`);
probeTabId = await pickExistingPageContextProbeTabId(options);
}
if (!Number.isInteger(probeTabId)) {
errors.push('probe:page_context:no_probe_tab');
return { ip: '', region: '', source: 'page_context_unavailable' };
}
const navigationErrorTracker = createProbeNavigationErrorTracker(probeTabId);
let hasSuccessfulIp = false;
try {
const ready = await waitForPageContextProbeTabReady(
probeTabId,
Math.max(2500, Math.min(IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS, timeoutMs + 2500))
);
if (!ready) {
errors.push('probe:page_context:probe_tab_not_ready');
return { ip: '', region: '', source: 'page_context_unavailable' };
}
let ip = '';
let region = '';
let endpoint = '';
let canUseDocumentProbe = false;
try {
const probeTab = await chrome.tabs.get(probeTabId);
const probeHost = extractProbeHostFromTabUrl(probeTab?.url || '');
const expectedHost = extractProbeHostFromTabUrl(probeUrl);
canUseDocumentProbe = Boolean(probeHost && expectedHost && probeHost === expectedHost);
} catch {
canUseDocumentProbe = false;
}
if (canUseDocumentProbe) {
try {
const documentResult = await readExitProbeFromTabDocument(probeTabId);
const parsedDocument = parseProxyExitProbePayload(String(documentResult?.text || ''), 'application/json');
ip = String(parsedDocument?.ip || '').trim();
region = String(parsedDocument?.region || '').trim();
endpoint = String(documentResult?.url || '').trim();
if (ip) {
let baselineIp = '';
try {
const baselineUrl = `${IP_PROXY_PAGE_CONTEXT_BASELINE_URL}?_multipage_proxy_baseline=${Date.now()}`;
await chrome.tabs.update(probeTabId, {
url: baselineUrl,
active: false,
});
const baselineReady = await waitForPageContextProbeTabReady(
probeTabId,
Math.max(2000, Math.min(IP_PROXY_PAGE_CONTEXT_BASELINE_TIMEOUT_MS, timeoutMs + 1500))
);
if (baselineReady) {
const baselineResult = await readExitProbeFromTabDocument(probeTabId);
const parsedBaseline = parseProxyExitProbePayload(String(baselineResult?.text || ''), 'application/json');
baselineIp = String(parsedBaseline?.ip || '').trim();
if (!baselineIp) {
errors.push('probe:page_context:baseline_empty_parse');
}
} else {
errors.push('probe:page_context:baseline_not_ready');
}
} catch (baselineError) {
errors.push(`probe:page_context:baseline_failed:${baselineError?.message || baselineError}`);
}
hasSuccessfulIp = true;
return {
ip,
region,
source: 'page_context',
endpoint: endpoint || probeUrl,
baselineIp,
};
}
errors.push('probe:page_context:document_empty_parse');
} catch (documentError) {
if (isProbeErrorPageExecutionError(documentError)) {
sawProbeErrorPage = true;
}
errors.push(`probe:page_context:document_failed:${documentError?.message || documentError}`);
}
} else {
errors.push('probe:page_context:document_probe_skipped');
}
const navigationProbeResult = await probeExitInfoByTabNavigationWithEndpoints(
probeTabId,
timeoutMs,
errors,
probeEndpoints
);
if (navigationProbeResult?.ip) {
hasSuccessfulIp = true;
return {
ip: String(navigationProbeResult.ip || '').trim(),
region: String(navigationProbeResult.region || '').trim(),
source: 'page_context',
endpoint: String(navigationProbeResult.endpoint || '').trim(),
};
}
let scriptResult = null;
try {
scriptResult = await probeExitInfoViaExecuteScript(probeTabId, timeoutMs, probeEndpoints);
} catch (scriptError) {
if (isProbeErrorPageExecutionError(scriptError)) {
sawProbeErrorPage = true;
}
errors.push(`probe:page_context:script_failed:${scriptError?.message || scriptError}`);
if (!createdProbeTabId && isProbeErrorPageExecutionError(scriptError)) {
const retryUrl = `${IP_PROXY_PAGE_CONTEXT_PROBE_URL}?_multipage_proxy_probe=${Date.now()}&retry=1`;
const retryTab = await createAutomationScopedTab({
url: retryUrl,
active: false,
}, options);
const retryTabId = Number(retryTab?.id) || 0;
if (retryTabId > 0) {
createdProbeTabId = retryTabId;
navigationErrorTracker.setTabId(retryTabId);
const retryReady = await waitForPageContextProbeTabReady(
retryTabId,
Math.max(2500, Math.min(IP_PROXY_PAGE_CONTEXT_READY_TIMEOUT_MS, timeoutMs + 2500))
);
if (retryReady) {
scriptResult = await probeExitInfoViaExecuteScript(retryTabId, timeoutMs, probeEndpoints);
}
}
} else {
throw scriptError;
}
}
if (!scriptResult) {
return {
ip: '',
region: '',
source: sawProbeErrorPage ? 'page_context_error_page' : 'page_context_unavailable',
};
}
ip = String(scriptResult?.ip || '').trim();
region = String(scriptResult?.region || '').trim();
if (ip) {
hasSuccessfulIp = true;
return {
ip,
region,
source: 'page_context',
endpoint: String(scriptResult?.endpoint || '').trim(),
};
}
if (Array.isArray(scriptResult?.diagnostics) && scriptResult.diagnostics.length) {
for (const item of scriptResult.diagnostics.slice(0, 4)) {
errors.push(`probe:page_context:script:${String(item)}`);
}
} else {
errors.push('probe:page_context:script_empty');
}
} catch (error) {
const message = String(error?.message || error || '');
if (isProbeErrorPageExecutionError(error)) {
sawProbeErrorPage = true;
}
if (!/probe:page_context:script_failed/i.test(message)) {
errors.push(`probe:page_context:script_failed:${message}`);
}
} finally {
if (!hasSuccessfulIp) {
navigationErrorTracker.appendDiagnostics(errors, 2);
}
navigationErrorTracker.dispose();
if (Number.isInteger(createdProbeTabId)) {
try {
await chrome.tabs.remove(createdProbeTabId);
} catch {
// ignore tab close failures
}
}
}
return {
ip: '',
region: '',
source: sawProbeErrorPage ? 'page_context_error_page' : 'page_context_unavailable',
};
}
async function detectProxyExitInfo(options = {}) {
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
const errors = Array.isArray(options?.errors) ? options.errors : [];
const preferPageContext = options?.preferPageContext !== false;
const allowBackgroundFallback = options?.allowBackgroundFallback === true;
const allowPageContextFallbackOnBackgroundAbort = options?.allowPageContextFallbackOnBackgroundAbort !== false;
const probeEndpoints = resolveExitProbeEndpoints({
provider: options?.provider || '',
username: options?.username || '',
});
if (preferPageContext) {
const pageResult = await detectProxyExitInfoByPageContext({
timeoutMs,
errors,
probeEndpoints,
automationWindowId: options?.automationWindowId,
windowId: options?.windowId,
state: options?.state,
});
if (pageResult?.ip) {
return pageResult;
}
const shouldFallbackToBackground = allowBackgroundFallback
|| String(pageResult?.source || '').trim().toLowerCase() === 'page_context_error_page'
|| hasProbeErrorPageDiagnostics(errors);
if (!shouldFallbackToBackground) {
return {
ip: '',
region: '',
source: String(pageResult?.source || 'page_context_unavailable'),
};
}
if (!allowBackgroundFallback) {
errors.push('probe:page_context:error_page_auto_fallback');
}
}
const backgroundResult = await detectProxyExitInfoByBackgroundFetch({
timeoutMs,
errors,
probeEndpoints,
});
if (!preferPageContext
&& allowPageContextFallbackOnBackgroundAbort
&& !backgroundResult?.ip
&& Array.isArray(errors)
&& errors.some((item) => /probe:bg:.*signal is aborted without reason/i.test(String(item || '')))) {
errors.push('probe:bg:abort_storm_page_fallback');
const pageResult = await detectProxyExitInfoByPageContext({
timeoutMs: Math.max(5000, Math.min(9000, timeoutMs)),
errors,
probeEndpoints,
automationWindowId: options?.automationWindowId,
windowId: options?.windowId,
state: options?.state,
});
errors.push(`probe:bg:abort_storm_page_fallback_result:${String(pageResult?.source || 'unknown')}`);
if (pageResult?.ip) {
return pageResult;
}
}
return backgroundResult;
}
async function pullIpProxyPoolFromApi(state = {}, options = {}) {
const apiUrl = String(state?.ipProxyApiUrl || '').trim();
if (!apiUrl) {
throw new Error('代理 API 不能为空。');
}
let parsedUrl;
try {
parsedUrl = new URL(apiUrl);
} catch {
throw new Error('代理 API 不是有效 URL。');
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('代理 API 仅支持 http/https。');
}
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : IP_PROXY_FETCH_TIMEOUT_MS;
const response = await fetchWithTimeout(apiUrl, {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json, text/plain, */*' },
}, timeoutMs);
if (!response.ok) {
throw new Error(`代理 API 请求失败(HTTP ${response.status})。`);
}
const rawText = await response.text();
let payload = rawText;
try {
payload = JSON.parse(rawText);
} catch {
payload = rawText;
}
const pool = normalizeIpProxyListFromPayload(payload, provider);
if (!pool.length && typeof payload === 'string') {
return normalizeProxyPoolEntries(payload, provider).slice(0, Number(options.maxItems) || 100);
}
const maxItems = Math.max(1, Math.min(500, Number(options.maxItems) || 100));
return pool.slice(0, maxItems);
}
function installIpProxyAuthListener() {
if (ipProxyAuthListenerInstalled) {
return;
}
if (!chrome.webRequest?.onAuthRequired?.addListener) {
return;
}
chrome.webRequest.onAuthRequired.addListener(
(details, callback) => {
try {
const auth = currentIpProxyAuthEntry;
if (!auth?.username) {
recordIpProxyAuthChallenge(details, false);
callback();
return;
}
const isProxyChallenge = details?.isProxy === true;
const challengerHost = String(details?.challenger?.host || '').trim().toLowerCase();
const challengerPort = Number.parseInt(String(details?.challenger?.port || ''), 10) || 0;
const authHost = String(auth?.host || '').trim().toLowerCase();
const authPort = Number.parseInt(String(auth?.port || ''), 10) || 0;
const challengerMatched = Boolean(
challengerHost
&& authHost
&& challengerHost === authHost
&& (!authPort || !challengerPort || authPort === challengerPort)
);
const proxyAuthStatus = Number.parseInt(String(details?.statusCode || ''), 10) === 407;
const shouldProvide = isProxyChallenge || challengerMatched || proxyAuthStatus;
recordIpProxyAuthChallenge(details, shouldProvide);
if (!shouldProvide) {
callback();
return;
}
callback({
authCredentials: {
username: auth.username,
password: String(auth.password || ''),
},
});
} catch {
recordIpProxyAuthChallenge(details, false);
callback();
}
},
{ urls: ['<all_urls>'] },
['asyncBlocking']
);
ipProxyAuthListenerInstalled = true;
}
function installIpProxyErrorListener() {
if (ipProxyErrorListenerInstalled) {
return;
}
if (!chrome.proxy?.onProxyError?.addListener) {
return;
}
chrome.proxy.onProxyError.addListener((details = {}) => {
ipProxyLastRuntimeError = {
error: String(details?.error || '').trim(),
details: String(details?.details || '').trim(),
fatal: Boolean(details?.fatal),
at: Date.now(),
};
});
ipProxyErrorListenerInstalled = true;
}
function callChromeProxySettings(method, details) {
const proxySettings = chrome.proxy?.settings;
if (!proxySettings || typeof proxySettings[method] !== 'function') {
return Promise.reject(new Error('当前浏览器不支持扩展代理 API'));
}
return new Promise((resolve, reject) => {
proxySettings[method](details, () => {
const lastError = chrome.runtime?.lastError;
if (lastError) {
reject(new Error(lastError.message || String(lastError)));
return;
}
resolve(true);
});
});
}
function getChromeProxySettings(details = { incognito: false }) {
const proxySettings = chrome.proxy?.settings;
if (!proxySettings || typeof proxySettings.get !== 'function') {
return Promise.reject(new Error('当前浏览器不支持扩展代理 API'));
}
return new Promise((resolve, reject) => {
proxySettings.get(details, (value) => {
const lastError = chrome.runtime?.lastError;
if (lastError) {
reject(new Error(lastError.message || String(lastError)));
return;
}
resolve(value || {});
});
});
}
function validateProxyControlAfterApply(details, entry) {
const level = String(details?.levelOfControl || '').trim();
if (level && level !== 'controlled_by_this_extension') {
return {
ok: false,
message: `代理控制权不在当前扩展(levelOfControl=${level || 'unknown'}`,
};
}
const mode = String(details?.value?.mode || '').trim().toLowerCase();
if (mode !== 'pac_script') {
return {
ok: false,
message: `代理模式不是 pac_script(当前为 ${mode || 'unknown'}`,
};
}
const pacData = String(details?.value?.pacScript?.data || '');
const endpoint = `${entry.host}:${entry.port}`;
if (pacData && !pacData.includes(endpoint)) {
return {
ok: false,
message: `PAC 未包含当前代理节点 ${endpoint},疑似被其他代理配置覆盖`,
};
}
return {
ok: true,
message: '',
};
}
function buildIpProxyPacScript(entry) {
const normalizedProtocol = normalizeIpProxyProtocol(entry?.protocol || DEFAULT_IP_PROXY_PROTOCOL);
const host = String(entry?.host || '').trim();
const port = normalizeIpProxyPort(entry?.port);
if (!host || !port) {
return '';
}
let pacScheme = 'PROXY';
if (normalizedProtocol === 'https') {
pacScheme = 'HTTPS';
} else if (normalizedProtocol === 'socks4') {
pacScheme = 'SOCKS4';
} else if (normalizedProtocol === 'socks5') {
pacScheme = 'SOCKS5';
}
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'
: 'false';
return `
function FindProxyForURL(url, host) {
if (!host) return "DIRECT";
var bypassList = [${bypassList}];
for (var i = 0; i < bypassList.length; i++) {
var bypass = bypassList[i];
if (shExpMatch(host, bypass) || host === bypass) {
return "DIRECT";
}
}
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}";
}
var targets = [${targetPatterns}];
var matched = false;
for (var j = 0; j < targets.length; j++) {
var pattern = targets[j];
if (pattern.indexOf('*.') === 0) {
var suffix = pattern.substring(1);
var direct = pattern.substring(2);
if (dnsDomainIs(host, suffix) || host === direct) {
matched = true;
break;
}
continue;
}
if (host === pattern || dnsDomainIs(host, '.' + pattern)) {
matched = true;
break;
}
}
if (!matched) {
return "DIRECT";
}
return "${proxyEndpoint}";
}`.trim();
}
function buildIpProxyEntrySignature(entry = {}) {
return [
normalizeIpProxyProtocol(entry?.protocol || DEFAULT_IP_PROXY_PROTOCOL),
String(entry?.host || '').trim().toLowerCase(),
String(normalizeIpProxyPort(entry?.port) || ''),
String(entry?.username || '').trim(),
String(entry?.password || ''),
].join('|');
}
async function clearIpProxySettings(options = {}) {
currentIpProxyAuthEntry = null;
if (options?.resetAppliedSignature !== false) {
lastAppliedIpProxyEntrySignature = '';
}
if (options?.resetHostVariant !== false) {
ipProxyAuthHostVariantToggle = false;
}
if (options?.resetLastAppliedAuthSnapshot === true) {
lastAppliedIpProxyAuthSnapshot = {
host: '',
port: 0,
username: '',
password: '',
};
}
await callChromeProxySettings('clear', { scope: IP_PROXY_SETTINGS_SCOPE });
}
async function clearIpProxyNetworkState() {
try {
if (chrome.webRequest?.handlerBehaviorChanged) {
await new Promise((resolve) => {
try {
chrome.webRequest.handlerBehaviorChanged(() => resolve());
} catch {
resolve();
}
});
}
if (chrome.browsingData?.remove) {
await chrome.browsingData.remove(
{ since: 0 },
{
cache: true,
cacheStorage: true,
serviceWorkers: true,
}
);
}
} catch {
// ignore cache clear failures; proxy apply can still continue
}
}
async function forceProxyConnectionDrainBeforeAuthSwitch() {
try {
await callChromeProxySettings('set', {
value: { mode: 'direct' },
scope: IP_PROXY_SETTINGS_SCOPE,
});
} catch {
// ignore direct mode switch failures
}
await new Promise((resolve) => setTimeout(resolve, 1200));
await clearIpProxyNetworkState().catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 800));
}
async function updateIpProxyRuntimeStatus(status = {}) {
const patch = buildIpProxyRoutingStatePatch(status);
await setState(patch);
broadcastDataUpdate(patch);
return patch;
}
async function applyIpProxySettingsFromState(state = {}, options = {}) {
const resolvedState = state || await getState();
const enabled = Boolean(resolvedState?.ipProxyEnabled);
if (!enabled) {
try {
await clearIpProxySettings({ resetLastAppliedAuthSnapshot: true });
} catch {
// ignore clear failures when already clear
}
await setIpProxyLeakGuardEnabled(false);
const status = {
enabled: false,
applied: false,
reason: 'disabled',
provider: normalizeIpProxyProviderValue(resolvedState?.ipProxyService),
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
error: '',
};
await updateIpProxyRuntimeStatus(status);
return status;
}
if (!chrome.proxy?.settings?.set) {
await setIpProxyLeakGuardEnabled(true);
const status = {
enabled: true,
applied: false,
reason: 'proxy_api_unavailable',
provider: normalizeIpProxyProviderValue(resolvedState?.ipProxyService),
error: '当前浏览器不支持扩展代理 API。',
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
};
await updateIpProxyRuntimeStatus(status);
return status;
}
const mode = normalizeIpProxyMode(resolvedState?.ipProxyMode);
const provider = normalizeIpProxyProviderValue(resolvedState?.ipProxyService);
if (mode === 'api' && !isApiModeProxyConfigAvailable(resolvedState)) {
const clearRuntimePatch = buildIpProxyRuntimeStatePatch(mode, {
pool: [],
index: 0,
current: null,
}, provider);
await setState(clearRuntimePatch);
broadcastDataUpdate(clearRuntimePatch);
try {
await clearIpProxySettings({ resetLastAppliedAuthSnapshot: true });
} catch {
// ignore clear failures
}
await setIpProxyLeakGuardEnabled(true);
const status = {
enabled: true,
applied: false,
reason: 'missing_proxy_entry',
provider,
error: 'API 模式已启用,但代理 API 为空。',
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
exitSource: '',
};
await updateIpProxyRuntimeStatus(status);
return status;
}
const entry = getIpProxyCurrentEntryFromState(resolvedState);
if (!entry) {
try {
await clearIpProxySettings({ resetLastAppliedAuthSnapshot: true });
} catch {
// ignore clear failures
}
await setIpProxyLeakGuardEnabled(true);
const status = {
enabled: true,
applied: false,
reason: 'missing_proxy_entry',
provider: normalizeIpProxyProviderValue(resolvedState?.ipProxyService),
error: mode === 'account'
? getAccountListParseFailureHint(resolvedState, provider)
: '',
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
};
await updateIpProxyRuntimeStatus(status);
return status;
}
const explicitForceAuthRebind = Boolean(options?.forceAuthRebind);
const suppressAuthRebind = Boolean(options?.suppressAuthRebind);
const hasAccountListConfigured = mode === 'account' && hasConfiguredAccountListEntries(resolvedState);
const hasMultipleAccountEntries = mode === 'account'
&& hasAccountListConfigured
&& getAccountModeProxyPoolFromState(resolvedState, provider).length > 1;
const shouldForceDrain = !suppressAuthRebind && (
explicitForceAuthRebind
|| shouldForceProxyConnectionDrainForEntry(entry)
|| (
provider === '711proxy'
&& hasAccountListConfigured
&& Boolean(String(entry?.username || '').trim())
)
);
let effectiveEntry = buildEffectiveProxyEntryForApply(entry, {
forceRotateVariant: shouldForceDrain,
allow711HostVariant: hasMultipleAccountEntries,
});
if (shouldForceDrain) {
effectiveEntry = await maybeResolveProxyHostVariantForAuthSwitch(effectiveEntry, {
force: true,
timeoutMs: 3500,
// 仅多节点账号列表场景允许解析 IP 变体;
// 单账号显式重绑仅使用 host 字面量变体,避免解析 IP 后链路不稳定。
allow711ResolvedIp: hasMultipleAccountEntries,
}).catch(() => effectiveEntry);
}
const pacScript = buildIpProxyPacScript(effectiveEntry);
if (!pacScript) {
await setIpProxyLeakGuardEnabled(true);
const status = {
enabled: true,
applied: false,
reason: 'missing_proxy_entry',
host: entry.host,
port: entry.port,
region: entry.region,
hasAuth: Boolean(entry.username || entry.password),
provider: normalizeIpProxyProviderValue(entry.provider || resolvedState?.ipProxyService),
error: '代理配置不完整,无法生成 PAC 规则。',
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
};
await updateIpProxyRuntimeStatus(status);
return status;
}
const entrySignature = buildIpProxyEntrySignature(effectiveEntry);
const shouldResetNetworkState = Boolean(
entrySignature
&& entrySignature !== lastAppliedIpProxyEntrySignature
&& !options.skipExitProbe
&& options.resetNetworkState !== false
);
try {
installIpProxyAuthListener();
installIpProxyErrorListener();
resetIpProxyRuntimeErrorDiagnostics();
if (shouldForceDrain) {
if (typeof addLog === 'function') {
await addLog(
explicitForceAuthRebind
? '正在执行代理鉴权重绑:重置代理连接并切换主机变体,避免复用旧鉴权缓存。'
: '检测到同节点切换代理账号,正在重置代理连接并切换主机变体,避免复用旧鉴权缓存。',
'info'
).catch(() => {});
if (String(effectiveEntry?.host || '').trim() !== String(entry?.host || '').trim()) {
await addLog(`代理账号切换:已启用主机缓存隔离(${entry.host} -> ${effectiveEntry.host})。`, 'info').catch(() => {});
}
}
await setIpProxyLeakGuardEnabled(true);
await forceProxyConnectionDrainBeforeAuthSwitch();
}
currentIpProxyAuthEntry = effectiveEntry?.username
? {
host: effectiveEntry.host,
port: effectiveEntry.port,
username: effectiveEntry.username,
password: String(effectiveEntry.password || ''),
}
: null;
await clearIpProxySettings({
resetAppliedSignature: false,
resetHostVariant: false,
}).catch(() => {});
currentIpProxyAuthEntry = effectiveEntry?.username
? {
host: effectiveEntry.host,
port: effectiveEntry.port,
username: effectiveEntry.username,
password: String(effectiveEntry.password || ''),
}
: null;
if (shouldResetNetworkState) {
await clearIpProxyNetworkState();
}
await callChromeProxySettings('set', {
value: {
mode: 'pac_script',
pacScript: {
data: pacScript,
// 官方文档建议启用 mandatory,避免 PAC 失效时静默回落到 DIRECT。
mandatory: true,
},
},
scope: IP_PROXY_SETTINGS_SCOPE,
});
const proxySettings = await getChromeProxySettings({ incognito: false }).catch(() => null);
const takeoverCheck = validateProxyControlAfterApply(proxySettings || {}, effectiveEntry);
if (!takeoverCheck.ok) {
throw new Error(takeoverCheck.message || '代理接管校验失败。');
}
await setIpProxyLeakGuardEnabled(false);
lastAppliedIpProxyEntrySignature = entrySignature;
lastAppliedIpProxyAuthSnapshot = {
host: String(entry?.host || '').trim(),
port: normalizeIpProxyPort(entry?.port),
username: String(entry?.username || '').trim(),
password: String(entry?.password || ''),
};
} catch (error) {
lastAppliedIpProxyEntrySignature = '';
await setIpProxyLeakGuardEnabled(true);
const status = {
enabled: true,
applied: false,
reason: 'apply_failed',
host: entry.host,
port: entry.port,
region: entry.region,
hasAuth: Boolean(entry.username || entry.password),
provider: normalizeIpProxyProviderValue(entry.provider || resolvedState?.ipProxyService),
error: error?.message || String(error || '代理设置失败'),
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
};
await updateIpProxyRuntimeStatus(status);
return status;
}
const status = {
enabled: true,
applied: true,
reason: 'applied',
host: entry.host,
port: entry.port,
region: entry.region,
username: String(entry.username || '').trim(),
entrySource: resolveIpProxyAccountEntrySource(resolvedState, mode),
hasAuth: Boolean(entry.username || entry.password),
provider: normalizeIpProxyProviderValue(entry.provider || resolvedState?.ipProxyService),
error: '',
exitDetecting: !options.skipExitProbe,
exitIp: '',
exitRegion: '',
exitError: '',
exitSource: '',
};
await updateIpProxyRuntimeStatus(status);
if (shouldForceDrain) {
// 同节点切换账号后给网络栈一点时间建立新隧道,避免立即探测命中旧连接。
await new Promise((resolve) => setTimeout(resolve, 900));
}
if (options.skipExitProbe) {
return status;
}
const token = ++ipProxyExitDetectionToken;
const diagnostics = [];
resetIpProxyAuthDiagnostics();
const exit = await detectProxyExitInfo({
timeoutMs: 10000,
errors: diagnostics,
provider: status?.provider || provider,
username: status?.username || entry?.username || '',
// 优先使用页面上下文探测,确保探测链路与真实浏览器页面一致;
// 后台探测仅作为补充兜底,避免单一路径误判。
preferPageContext: true,
allowBackgroundFallback: true,
state,
}).catch(() => ({ ip: '', region: '' }));
if (!exit?.ip && Boolean(status?.hasAuth)) {
appendIpProxyAuthDiagnosticsToErrors(diagnostics);
}
if (token !== ipProxyExitDetectionToken) {
return status;
}
const latest = await getState();
if (!latest?.ipProxyEnabled) {
return status;
}
const exitStatus = {
...status,
exitDetecting: false,
exitIp: String(exit?.ip || '').trim(),
exitRegion: String(exit?.region || '').trim(),
exitBaselineIp: String(exit?.baselineIp || '').trim(),
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
exitSource: String(exit?.source || '').trim().toLowerCase(),
authDiagnostics: status?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '',
};
const expectedRegion = String(entry?.region || '').trim();
let normalizedExitStatus = applyExitRegionExpectation(exitStatus, expectedRegion);
normalizedExitStatus = applyExitBaselineExpectation(normalizedExitStatus);
if (shouldVerifyIpProxyTargetReachability(normalizedExitStatus)) {
const targetDiagnostics = [];
const reachability = await detectIpProxyTargetReachabilityByPageContext({
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
errors: targetDiagnostics,
state,
}).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;
}
function getNextIpProxyPoolIndex(currentIndex = 0, poolLength = 0, direction = 'next') {
const length = Math.max(0, Number(poolLength) || 0);
if (length <= 0) return 0;
const normalized = normalizeIpProxyCurrentIndex(currentIndex, 0) % length;
if (String(direction || '').toLowerCase() === 'prev') {
return (normalized - 1 + length) % length;
}
return (normalized + 1) % length;
}
async function refreshIpProxyPool(options = {}) {
const state = options.state || await getState();
const mode = normalizeIpProxyMode(options.mode || state?.ipProxyMode);
const maxItems = Math.max(
1,
Math.min(500, Number(options.maxItems) || resolveIpProxyPoolTargetCountForMode(state, mode))
);
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
let pool = [];
if (mode === 'account') {
pool = getAccountModeProxyPoolFromState(state, provider).slice(0, maxItems);
if (!pool.length) {
const parseFailureHint = getAccountListParseFailureHint(state, provider);
if (parseFailureHint) {
throw new Error(parseFailureHint);
}
throw new Error('账号密码模式没有可用代理,请先填写代理列表,或填写 Host/Port。');
}
} else {
pool = await pullIpProxyPoolFromApi(state, {
maxItems,
timeoutMs: options.timeoutMs,
});
if (!pool.length) {
throw new Error('代理列表为空,请检查 API 返回。');
}
}
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
const summary = buildProxyPoolSummary(pool, runtime.index);
const updates = {
ipProxyService: provider,
...buildIpProxyRuntimeStatePatch(mode, {
pool,
index: summary.index,
current: summary.current,
}, provider),
};
await setState(updates);
broadcastDataUpdate(updates);
let proxyRouting = null;
if (state?.ipProxyEnabled) {
const applyState = {
...state,
...updates,
};
const shouldRebindSingleAccountEntry = mode === 'account' && pool.length <= 1;
proxyRouting = await applyIpProxySettingsFromState(
applyState,
shouldRebindSingleAccountEntry
? {
forceAuthRebind: true,
suppressAuthRebind: false,
resetNetworkState: true,
skipExitProbe: Boolean(options?.skipExitProbe),
}
: {
skipExitProbe: Boolean(options?.skipExitProbe),
}
);
}
return {
mode,
provider,
count: summary.count,
index: summary.index,
current: summary.current,
display: summary.display,
pool,
proxyRouting,
};
}
async function switchIpProxy(direction = 'next', options = {}) {
const state = options.state || await getState();
const mode = normalizeIpProxyMode(options.mode || state?.ipProxyMode);
if (mode === 'api' && !isApiModeProxyConfigAvailable(state)) {
throw new Error('API 模式代理 URL 为空,请先填写代理 API 地址。');
}
const maxItems = Math.max(
1,
Math.min(500, Number(options.maxItems) || resolveIpProxyPoolTargetCountForMode(state, mode))
);
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
let pool = [];
if (mode === 'account') {
pool = getAccountModeProxyPoolFromState(state, provider).slice(0, maxItems);
} else {
pool = runtime.pool.slice(0, maxItems);
}
if (!pool.length) {
if (mode === 'api' && options.forceRefresh !== false) {
return refreshIpProxyPool({
...options,
mode,
state,
});
}
throw new Error(mode === 'account'
? '账号密码模式没有可切换的代理,请先填写代理列表或 Host/Port。'
: '当前没有可切换代理,请先点击“拉取”获取 IP 列表。');
}
const nextIndex = getNextIpProxyPoolIndex(runtime.index, pool.length, direction);
const current = pool[nextIndex];
const updates = {
ipProxyService: provider,
...buildIpProxyRuntimeStatePatch(mode, {
pool,
index: nextIndex,
current,
}, provider),
};
await setState(updates);
broadcastDataUpdate(updates);
let proxyRouting = null;
if (state?.ipProxyEnabled) {
const applyState = {
...state,
...updates,
};
const shouldRebindSingleAccountEntry = mode === 'account' && pool.length <= 1;
proxyRouting = await applyIpProxySettingsFromState(
applyState,
shouldRebindSingleAccountEntry
? {
forceAuthRebind: true,
suppressAuthRebind: false,
resetNetworkState: true,
skipExitProbe: Boolean(options?.skipExitProbe),
}
: {
skipExitProbe: Boolean(options?.skipExitProbe),
}
);
}
const summary = buildProxyPoolSummary(pool, nextIndex);
return {
mode,
provider,
count: summary.count,
index: summary.index,
current: summary.current,
display: summary.display,
pool,
proxyRouting,
};
}
async function changeIpProxyExit(options = {}) {
const state = options.state || await getState();
if (!state?.ipProxyEnabled) {
throw new Error('请先启用 IP 代理。');
}
const mode = normalizeIpProxyMode(options.mode || state?.ipProxyMode);
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
if (mode !== 'account') {
throw new Error('Change 仅支持账号密码模式。');
}
if (provider !== '711proxy') {
throw new Error('Change 当前仅支持 711Proxy。');
}
const entry = getIpProxyCurrentEntryFromState(state);
if (!entry || !entry.host || !entry.port) {
throw new Error('当前没有可用代理条目,无法执行 Change。');
}
const username = String(entry?.username || '').trim();
if (!has711SessionToken(username)) {
throw new Error('当前账号未配置 session,无法执行 Change。请先在账号中追加 session 参数。');
}
if (typeof addLog === 'function') {
await addLog('正在执行 Change:保持当前 session,重绑代理链路并刷新出口。', 'info').catch(() => {});
}
const proxyRouting = await applyIpProxySettingsFromState(state, {
forceAuthRebind: true,
suppressAuthRebind: false,
resetNetworkState: true,
skipExitProbe: Boolean(options?.skipExitProbe),
});
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
const pool = runtime.pool.length
? runtime.pool
: getAccountModeProxyPoolFromState(state, provider);
const summary = buildProxyPoolSummary(pool, runtime.index);
return {
mode,
provider,
count: summary.count,
index: summary.index,
current: summary.current,
display: summary.display,
pool,
proxyRouting,
action: 'change',
};
}
async function tryRecoverApiProxyByRotation(options = {}) {
const maxAttempts = Math.max(1, Math.min(12, Number(options?.maxAttempts) || 4));
let latestStatus = options?.fallbackStatus || null;
let attempts = 0;
let refreshedPool = false;
const runSingleRotationPass = async () => {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const state = await getState();
if (!state?.ipProxyEnabled) {
break;
}
const mode = normalizeIpProxyMode(state?.ipProxyMode);
if (mode !== 'api') {
break;
}
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
const runtime = getIpProxyRuntimeSnapshot(state, mode, provider);
if (!Array.isArray(runtime?.pool) || runtime.pool.length <= 1) {
break;
}
const switched = await switchIpProxy('next', {
mode,
forceRefresh: false,
state,
}).catch(() => null);
attempts += 1;
const status = switched?.proxyRouting || null;
if (status) {
latestStatus = status;
}
if (status?.applied && status?.reason !== 'connectivity_failed') {
if (typeof addLog === 'function') {
const nodeText = Number.isInteger(switched?.index)
&& Number.isInteger(switched?.count)
&& switched.count > 0
? `(第 ${switched.index + 1}/${switched.count} 个节点)`
: '';
const refreshText = refreshedPool ? '(已自动刷新 IP 池)' : '';
await addLog(`IP 代理自动恢复成功:API 模式已切换到可用节点${nodeText}${refreshText}`, 'ok').catch(() => {});
}
return true;
}
}
return false;
};
const firstPassRecovered = await runSingleRotationPass();
if (firstPassRecovered) {
return { recovered: true, attempts, status: latestStatus };
}
if (options?.allowRefresh !== false) {
const latestState = await getState();
if (latestState?.ipProxyEnabled && normalizeIpProxyMode(latestState?.ipProxyMode) === 'api') {
await refreshIpProxyPool({
mode: 'api',
state: latestState,
forceRefresh: true,
}).catch(() => null);
refreshedPool = true;
const secondPassRecovered = await runSingleRotationPass();
if (secondPassRecovered) {
return { recovered: true, attempts, status: latestStatus };
}
}
}
if (latestStatus && attempts > 0) {
const suffix = refreshedPool
? `已自动轮换 ${attempts} 个 API 节点,并刷新 IP 池后重试,仍不可用。`
: `已自动轮换 ${attempts} 个 API 节点但仍不可用。`;
const currentError = String(latestStatus.error || '').trim();
latestStatus = {
...latestStatus,
error: currentError
? `${currentError} ${suffix}`
: suffix,
};
}
return { recovered: false, attempts, status: latestStatus };
}
async function probeIpProxyExit(options = {}) {
if (ipProxyProbeInFlightPromise) {
return ipProxyProbeInFlightPromise;
}
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,
state,
}).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,
reason: 'disabled',
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
error: '',
};
await updateIpProxyRuntimeStatus(status);
return { proxyRouting: status };
}
const mode = normalizeIpProxyMode(state?.ipProxyMode);
const provider = normalizeIpProxyProviderValue(state?.ipProxyService);
const entry = getIpProxyCurrentEntryFromState(state);
if (!entry) {
const status = {
enabled: true,
applied: false,
reason: 'missing_proxy_entry',
provider,
error: mode === 'account'
? getAccountListParseFailureHint(state, provider)
: '',
exitDetecting: false,
exitIp: '',
exitRegion: '',
exitError: '',
};
await updateIpProxyRuntimeStatus(status);
return { proxyRouting: status };
}
installIpProxyAuthListener();
installIpProxyErrorListener();
resetIpProxyRuntimeErrorDiagnostics();
currentIpProxyAuthEntry = entry?.username
? {
host: entry.host,
port: entry.port,
username: entry.username,
password: String(entry.password || ''),
}
: null;
const probingStatus = {
enabled: true,
applied: true,
reason: String(state?.ipProxyAppliedReason || 'applied'),
host: entry.host,
port: entry.port,
region: entry.region,
username: String(entry.username || '').trim(),
entrySource: resolveIpProxyAccountEntrySource(state, mode),
hasAuth: Boolean(entry.username || entry.password),
provider: normalizeIpProxyProviderValue(entry.provider || state?.ipProxyService),
error: String(state?.ipProxyAppliedError || ''),
exitDetecting: true,
exitIp: '',
exitRegion: '',
exitEndpoint: '',
exitError: '',
exitSource: '',
};
// 上一轮连通性失败会启用 fail-close 规则,手动检测前先解除,避免自阻断影响探测。
await setIpProxyLeakGuardEnabled(false);
await updateIpProxyRuntimeStatus(probingStatus);
const runProbeRound = async (statusSeed = probingStatus, timeoutMs = Number(options?.timeoutMs) || 10000) => {
const diagnostics = [];
resetIpProxyAuthDiagnostics();
const exit = await detectProxyExitInfo({
timeoutMs,
errors: diagnostics,
provider: statusSeed?.provider || probingStatus?.provider || provider,
username: statusSeed?.username || probingStatus?.username || '',
// 手动探测与自动应用保持一致:先页面上下文,再后台补充。
preferPageContext: true,
allowBackgroundFallback: true,
state,
}).catch(() => ({ ip: '', region: '' }));
if (!exit?.ip && Boolean(statusSeed?.hasAuth)) {
appendIpProxyAuthDiagnosticsToErrors(diagnostics);
if (typeof addLog === 'function') {
await addLog(`代理出口探测失败:${buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)}`, 'warn').catch(() => {});
}
}
const finalStatus = {
...statusSeed,
exitDetecting: false,
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() : '',
};
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,
state,
}).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;
};
let normalizedFinalStatus = await runProbeRound(probingStatus, Number(options?.timeoutMs) || 10000);
const parsedAuthSummary = parseIpProxyAuthDiagnosticsSummary(String(normalizedFinalStatus?.authDiagnostics || ''));
const missingAuthChallenge = Boolean(normalizedFinalStatus?.hasAuth)
&& parsedAuthSummary
&& parsedAuthSummary.challenge <= 0
&& parsedAuthSummary.provided <= 0;
const shouldRetryAuthRebind = mode === 'account'
&& provider === '711proxy'
&& Boolean(String(entry?.username || '').trim())
&& missingAuthChallenge
&& options?.authRebindRetry !== false;
if (shouldRetryAuthRebind) {
const maxRebindAttempts = Math.max(1, Math.min(3, Number(options?.authRebindMaxAttempts) || 2));
for (let attempt = 0; attempt < maxRebindAttempts; attempt += 1) {
if (typeof addLog === 'function') {
const hint = attempt === 0 ? '首次重绑复测' : `${attempt + 1} 次重绑复测`;
await addLog(`检测到代理链路未触发 407 挑战,正在执行${hint}`, 'info').catch(() => {});
}
const latestBeforeRebind = await getState();
const reboundStatus = await applyIpProxySettingsFromState(latestBeforeRebind, {
skipExitProbe: true,
resetNetworkState: true,
forceAuthRebind: true,
}).catch(() => null);
const latestAfterRebind = await getState();
const reboundEntry = getIpProxyCurrentEntryFromState(latestAfterRebind);
if (!reboundStatus?.applied || !reboundEntry?.host || !reboundEntry?.port) {
break;
}
currentIpProxyAuthEntry = reboundEntry?.username
? {
host: reboundEntry.host,
port: reboundEntry.port,
username: reboundEntry.username,
password: String(reboundEntry.password || ''),
}
: null;
const retryProbingStatus = {
...probingStatus,
reason: String(reboundStatus?.reason || probingStatus.reason || 'applied'),
host: reboundEntry.host,
port: reboundEntry.port,
region: String(reboundEntry.region || '').trim(),
username: String(reboundEntry.username || '').trim(),
entrySource: resolveIpProxyAccountEntrySource(
latestAfterRebind,
normalizeIpProxyMode(latestAfterRebind?.ipProxyMode)
),
hasAuth: Boolean(reboundEntry.username || reboundEntry.password),
provider: normalizeIpProxyProviderValue(reboundEntry.provider || latestAfterRebind?.ipProxyService),
error: String(reboundStatus?.error || ''),
exitDetecting: true,
exitIp: '',
exitRegion: '',
exitEndpoint: '',
exitError: '',
exitSource: '',
};
await updateIpProxyRuntimeStatus(retryProbingStatus);
normalizedFinalStatus = await runProbeRound(
retryProbingStatus,
Math.max(9000, Number(options?.timeoutMs) || 10000)
);
const retryAuthSummary = parseIpProxyAuthDiagnosticsSummary(String(normalizedFinalStatus?.authDiagnostics || ''));
const stillMissingChallenge = Boolean(normalizedFinalStatus?.hasAuth)
&& retryAuthSummary
&& retryAuthSummary.challenge <= 0
&& retryAuthSummary.provided <= 0;
if (!stillMissingChallenge) {
break;
}
}
}
if (normalizedFinalStatus.reason === 'connectivity_failed'
&& normalizeIpProxyMode(state?.ipProxyMode) === 'api') {
const recovered = await tryRecoverApiProxyByRotation({
maxAttempts: options?.autoRotateMaxAttempts,
fallbackStatus: normalizedFinalStatus,
});
if (recovered?.status) {
normalizedFinalStatus = recovered.status;
}
}
await syncIpProxyLeakGuardForStatus(normalizedFinalStatus);
await updateIpProxyRuntimeStatus(normalizedFinalStatus);
return { proxyRouting: normalizedFinalStatus };
})();
ipProxyProbeInFlightPromise = probePromise;
try {
return await probePromise;
} finally {
if (ipProxyProbeInFlightPromise === probePromise) {
ipProxyProbeInFlightPromise = null;
}
}
}
async function ensureIpProxySettingsAppliedFromCurrentState(options = {}) {
const state = options.state || await getState();
return applyIpProxySettingsFromState(state, options);
}