Files
tokenserv/chrome-launcher-ctx.js

1008 lines
52 KiB
JavaScript
Raw Permalink 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.
'use strict';
/*
* chrome-launcher-ctx.js — минтинг integrity на ОДНОМ (или нескольких) процессах
* Chrome с изоляцией через browser contexts.
*
* Зачем: в chrome-launcher.js прокси задаётся флагом --proxy-server, а он
* process-global → 30 слотов = 30 процессов Chrome. Target.createBrowserContext
* принимает proxyServer НА КОНТЕКСТ (проверено scale-test.js), поэтому один
* процесс держит N контекстов, у каждого свой exit-IP и свой cookie-jar.
*
* Drop-in замена chrome-launcher.js: те же экспорты, тот же объект ответа
* { token, sessionId, deviceId, proxy, userAgent, headers }.
* token-server.js менять не нужно — достаточно поменять строку import.
*
* ENV:
* CHROME_PATH — путь к Chrome
* INSTANCES / POOL_SIZE — сколько КОНТЕКСТОВ (деф 30)
* SHARDS — сколько процессов Chrome (деф 1); контексты делятся поровну
* CDP_BASE_PORT — базовый порт CDP (деф 9500; у старого пула 9300 — не конфликтует)
* LAUNCH_BATCH — сколько контекстов поднимать разом (деф 4)
* LAUNCH_BATCH_DELAY_MS — пауза между батчами (деф 3000)
* CTX_WARM_MS — ПОТОЛОК ожидания готовности страницы (деф 10000)
* CTX_WARM_MIN_MS — раньше этого готовность не проверяем (деф 2000)
* CTX_WARM_POLL_MS — период опроса готовности (деф 250)
* PROXY_TTL_SEC — TTL sticky-сессии, если в строке прокси нет ttl-<N> (деф 2400)
* PROXY_CYCLE_SEC — раз в N сек переезжать на следующие прокси из списка (деф 0 = off)
* BLOCK_ASSETS — 1 = резать Image/Media/Font/Stylesheet (экономит трафик, деф 0)
*
* Прокси: proxies.txt, форматы host:port:user:pass, http://user:pass@host:port,
* http://host:port@user:pass, socks5://... Строк может быть сильно больше, чем
* контекстов — лишние лежат в резерве и подхватываются при rotate-pool.
*/
import { spawn } from 'child_process';
import CDP from 'chrome-remote-interface';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CHROME_PATH = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const PROFILES_DIR = path.resolve(__dirname, 'chrome-profiles-ctx');
// PROXIES_FILE=<путь> — можно подсунуть другой список (или пустой файл, чтобы
// поднять контексты direct и проверить, не в репутации ли IP дело).
const PROXIES_FILE = process.env.PROXIES_FILE
? path.resolve(process.env.PROXIES_FILE)
: path.resolve(__dirname, 'proxies.txt');
const POOL_SIZE = parseInt(process.env.INSTANCES || process.env.POOL_SIZE || '', 10) || 30;
const SHARDS = Math.max(1, parseInt(process.env.SHARDS || '', 10) || 1);
const BASE_PORT = parseInt(process.env.CDP_BASE_PORT || '', 10) || 9500;
const LAUNCH_BATCH = parseInt(process.env.LAUNCH_BATCH || '', 10) || 4;
const LAUNCH_BATCH_DELAY_MS = parseInt(process.env.LAUNCH_BATCH_DELAY_MS || '', 10) || 3000;
// Прогрев теперь адаптивный: WARM_MS — это ПОТОЛОК, а не фиксированная пауза.
// Выходим раньше, как только страница отчиталась о готовности (бандл + Kasada).
const WARM_MS = parseInt(process.env.CTX_WARM_MS || '', 10) || 10000;
const WARM_MIN_MS = parseInt(process.env.CTX_WARM_MIN_MS || '', 10) || 2000;
const WARM_POLL_MS = parseInt(process.env.CTX_WARM_POLL_MS || '', 10) || 250;
const DEFAULT_TTL_SEC = parseInt(process.env.PROXY_TTL_SEC || '2400', 10);
const BLOCK_ASSETS = process.env.BLOCK_ASSETS === '1';
// Периодический переезд контекстов на следующие прокси из списка (для статиков,
// когда прокси сильно больше, чем контекстов). 0 = выключено.
const PROXY_CYCLE_SEC = parseInt(process.env.PROXY_CYCLE_SEC || '0', 10) || 0;
const BLOCK_TYPES = new Set(['Image', 'Media', 'Font', 'Stylesheet']);
const WARM_URL = process.env.CTX_WARM_URL || 'https://www.twitch.tv/directory';
const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
const CLIENT_VERSION = 'b5611e70-7b3d-410c-b902-b3b77317a9af';
// Хэши persisted-запросов для selfTest (те же, что в scale-test.js)
const FOLLOW_HASH = '800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08';
const UNFOLLOW_HASH = 'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ── Fingerprint минта (как в chrome-launcher.js) ─────────────────────────────
// ФОЛБЭК, а не источник правды. UA и Client Hints берём из САМОГО браузера
// (readFingerprint → navigator.userAgent / navigator.userAgentData), потому что
// бинарь — настоящий Google Chrome (CHROME_PATH). Раньше здесь и в --user-agent
// навязывался YaBrowser: navigator.userAgent кричал «YaBrowser/26.3.0.0», а
// TLS/движок/родные Client Hints оставались хромовыми → Kasada ловила рассинхрон
// и отбивала свежий интегрити (IntegrityCheckFailed при mint=True, той же прокси
// и том же device). Эти значения используются, только если чтение из браузера
// упало; держим их хромовыми, чтобы даже фолбэк был непротиворечив.
const MINT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36';
const MINT_HEADERS = {
'sec-ch-ua': '"Chromium";v="144", "Google Chrome";v="144", "Not_A Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
};
const STEALTH_SOURCE = `
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
Object.defineProperty(navigator, 'oscpu', { get: () => undefined });
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Object.defineProperty(navigator, 'language', { get: () => 'en-US' });
Object.defineProperty(navigator, 'languages', { get: () => Object.freeze(['en-US', 'en']) });
// navigator.userAgentData НЕ подменяем — оставляем родной у Chrome.
// Прежний блок выдавал бренды YaBrowser (Chromium/YaBrowser/Not_A Brand и
// fullVersionList с YaBrowser/26.3.0.0), что расходилось с настоящим бинарём
// Google Chrome и с navigator.userAgent → Kasada ловила рассинхрон Client
// Hints и браковала интегрити. readFingerprint теперь читает родные brands.
const _getParam = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(p) {
if (p === 37445) return 'Google Inc. (NVIDIA)';
if (p === 37446) return 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)';
return _getParam.call(this, p);
};
try {
const _getParam2 = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function(p) {
if (p === 37445) return 'Google Inc. (NVIDIA)';
if (p === 37446) return 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)';
return _getParam2.call(this, p);
};
} catch(_) {}
`;
// ── Прокси ───────────────────────────────────────────────────────────────────
// Парсер перенесён сюда из sticky-proxy.js, чтобы файл не тянул зависимостей:
// при статик-прокси оттуда нужен был только разбор строки.
// Поддерживает host:port:user:pass, http://user:pass@host:port, socks5://...
// sticky-строка = session-/ttl- в username, тогда exit-IP ротируется session'ом.
function parseStickyProxy(line) {
let l = String(line || '').trim();
if (!l || l.startsWith('#')) return null;
let proto = null;
const scheme = l.match(/^(socks5|socks4|socks|https?):\/\/(.+)$/i);
if (scheme) {
proto = scheme[1].toLowerCase();
if (proto === 'socks') proto = 'socks5';
l = scheme[2];
}
let host, port, user = '', pass = '';
if (l.includes('@')) {
const at = l.lastIndexOf('@');
const creds = l.slice(0, at);
const hp = l.slice(at + 1);
const ci = creds.indexOf(':');
user = ci >= 0 ? creds.slice(0, ci) : creds;
pass = ci >= 0 ? creds.slice(ci + 1) : '';
const hpParts = hp.split(':');
host = hpParts[0];
port = hpParts[1];
} else {
const parts = l.split(':');
if (parts.length >= 4) { host = parts[0]; port = parts[1]; user = parts[2]; pass = parts.slice(3).join(':'); }
else if (parts.length === 2) { host = parts[0]; port = parts[1]; }
else return null;
}
if (!host || !port) return null;
if (!proto) proto = 'http';
const ttlMatch = /ttl-(\d+)/i.exec(user || '');
return {
proto, host, port: Number(port), user, pass,
ttlSec: ttlMatch ? parseInt(ttlMatch[1], 10) : 0,
sticky: /(?:^|-)(session|ttl)-/i.test(user || ''),
};
}
// username со свежим session-<id> (новый sticky exit-IP). Для статиков не вызывается.
function freshSessionUser(user) {
const rnd = String(crypto.randomInt(100000000, 999999999));
const u = String(user || '');
if (/session-[A-Za-z0-9]+/i.test(u)) return u.replace(/session-[A-Za-z0-9]+/i, `session-${rnd}`);
return u ? `${u}-session-${rnd}` : `session-${rnd}`;
}
// Разбор строки. Сначала нестандартный http://host:port@user:pass (parseStickyProxy
// его перепутает — примет host:port за креды), дальше общий парсер выше.
function parseLine(line) {
const str = String(line || '').trim();
if (!str || str.startsWith('#')) return null;
const nonStd = str.match(/^(https?):\/\/([^@:]+):(\d+)@([^:@]+):(.+)$/);
if (nonStd) {
return { proto: nonStd[1].toLowerCase(), host: nonStd[2], port: Number(nonStd[3]), user: nonStd[4], pass: nonStd[5], ttlSec: 0, sticky: false };
}
return parseStickyProxy(str);
}
function proxyUrlOf(p) {
if (!p.user) return `${p.proto}://${p.host}:${p.port}`;
return `${p.proto}://${encodeURIComponent(p.user)}:${encodeURIComponent(p.pass || '')}@${p.host}:${p.port}`;
}
const STICKY_BASES = []; // [{ proto, host, port, user, pass, ttlSec }] — ротация session'ом
const HTTP_PROXIES = []; // [{ proto, host, port, user, pass }] — статика, IP фиксирован
let proxyOffset = 0; // сдвиг по списку статиков (rotate-pool берёт следующие IP)
{
let lines = [];
try {
if (fs.existsSync(PROXIES_FILE)) {
lines = fs.readFileSync(PROXIES_FILE, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
}
} catch (e) {
console.error('[ctx-pool] proxies.txt read error:', e.message);
}
let bad = 0;
for (const line of lines) {
const p = parseLine(line);
if (!p || !p.host || !p.port || Number.isNaN(p.port)) { bad++; continue; }
if (p.sticky) STICKY_BASES.push({ proto: p.proto, host: p.host, port: p.port, user: p.user, pass: p.pass, ttlSec: p.ttlSec });
else HTTP_PROXIES.push({ proto: p.proto, host: p.host, port: p.port, user: p.user, pass: p.pass });
}
if (bad) console.warn(`[ctx-pool] ⚠ не распознано строк прокси: ${bad}`);
if (STICKY_BASES.length) console.log(`[ctx-pool] Sticky-прокси: ${STICKY_BASES.length} шт. — у каждого контекста своя session/exit-IP`);
if (HTTP_PROXIES.length) console.log(`[ctx-pool] Статик-прокси: ${HTTP_PROXIES.length} шт. — по одному IP на контекст, остальные в резерве под rotate-pool`);
if (!STICKY_BASES.length && !HTTP_PROXIES.length) console.warn('[ctx-pool] ⚠ прокси не найдены — все контексты пойдут direct (один exit-IP на всех)');
}
// Прокси для контекста: sticky → своя session (свой exit-IP), иначе http round-robin.
function proxyForSlot(slotIndex) {
if (STICKY_BASES.length) {
const base = STICKY_BASES[slotIndex % STICKY_BASES.length];
const user = freshSessionUser(base.user);
return {
proxyArg: `${base.proto}://${base.host}:${base.port}`,
auth: { username: user, password: base.pass },
proxyUrl: `${base.proto}://${encodeURIComponent(user)}:${encodeURIComponent(base.pass || '')}@${base.host}:${base.port}`,
ttlMs: (base.ttlSec || DEFAULT_TTL_SEC) * 1000,
proxyTag: (/session-([A-Za-z0-9]+)/i.exec(user) || [])[1] || '?',
};
}
if (HTTP_PROXIES.length) {
// Статик: IP не меняется, поэтому «ротация» = переезд контекста на следующий
// прокси из списка (proxyOffset). 500 строк при 30 контекстах = 16 наборов IP.
const p = HTTP_PROXIES[(proxyOffset + slotIndex) % HTTP_PROXIES.length];
return {
proxyArg: `${p.proto}://${p.host}:${p.port}`,
auth: p.user ? { username: p.user, password: p.pass } : null,
proxyUrl: proxyUrlOf(p),
ttlMs: 0,
proxyTag: `${p.host}:${p.port}`,
};
}
return { proxyArg: null, auth: null, proxyUrl: null, ttlMs: 0, proxyTag: null };
}
// ── Состояние пула ───────────────────────────────────────────────────────────
const shards = []; // [{ index, port, profileDir, proc, browser, _dead }]
const slots = []; // [{ index, shard, browserContextId, targetId, client, proxyUrl, busy, _dead, ... }]
let _shuttingDown = false;
let poolInitPromise = null;
const _respawningShards = new Set();
const _respawningSlots = new Set();
// shardIndex -> сколько раз подряд не удалось перезапустить (для растущей паузы).
// Обнуляется при успешном подъёме шарда.
const _shardRespawnFails = new Map();
export function shutdownPool() {
_shuttingDown = true;
for (const slot of slots) {
if (slot.rotateTimer) { try { clearTimeout(slot.rotateTimer); } catch (_) {} }
try { slot.client.removeAllListeners(); slot.client.close(); } catch (_) {}
}
slots.length = 0;
for (const shard of shards) {
try { shard.browser.removeAllListeners(); shard.browser.close(); } catch (_) {}
try { shard.proc.kill('SIGKILL'); } catch (_) {}
}
shards.length = 0;
}
process.on('exit', () => shutdownPool());
// ── Шард = процесс Chrome ────────────────────────────────────────────────────
async function spawnShard(shardIndex) {
const port = BASE_PORT + shardIndex;
const profileDir = path.join(PROFILES_DIR, `shard_${shardIndex}`);
fs.mkdirSync(profileDir, { recursive: true });
for (const lock of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
try { fs.unlinkSync(path.join(profileDir, lock)); } catch (_) {}
}
// Без --proxy-server: прокси задаётся на контекст. taskkill НЕ делаем —
// на VM может параллельно крутиться старый пул (A/B).
const proc = spawn(CHROME_PATH, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--no-first-run',
'--no-default-browser-check',
'--no-restore-last-session',
'--disable-session-crashed-bubble',
'--disable-blink-features=AutomationControlled',
'--window-size=1280,800',
'--window-position=-32000,-32000',
'--mute-audio',
// Без --user-agent: Chrome представляется СВОИМ родным UA. Навязанный
// YaBrowser расходился с бинарём и Client Hints → Kasada браковала интегрити.
// Реальный UA/бренды читает readFingerprint из самого браузера и отдаёт C#.
'--lang=en-US',
'--accept-lang=en-US,en;q=0.9',
'about:blank',
], { stdio: 'ignore', detached: false });
proc.on('error', e => console.error(`[shard:${shardIndex}] spawn error:`, e.message));
proc.on('exit', (code) => {
if (_shuttingDown) return;
console.warn(`[shard:${shardIndex}] Chrome вышел (code ${code}) — все его контексты потеряны, перезапуск через 3с`);
const sh = shards.find(s => s.index === shardIndex);
if (sh) sh._dead = true;
for (const slot of slots) if (slot.shardIndex === shardIndex) slot._dead = true;
if (!_respawningShards.has(shardIndex)) setTimeout(() => _respawnShard(shardIndex), 3000);
});
await sleep(3000);
let ver = null;
for (let i = 0; i < 15 && !ver; i++) {
try { ver = await CDP.Version({ host: '127.0.0.1', port }); } catch (_) { await sleep(1000); }
}
if (!ver) throw new Error(`[shard:${shardIndex}] CDP не поднялся на порту ${port}`);
const browser = await CDP({ target: ver.webSocketDebuggerUrl });
// Keepalive-таргет в ДЕФОЛТНОМ контексте, который не закрывается никогда.
// Chrome завершается сам (exit code 0), когда у него не остаётся ни одного окна.
// При ротации контекст удаляется до создания нового, и если это был последний
// контекст шарда — браузер выходил «чисто», унося все слоты. Этот таргет
// держит процесс живым независимо от того, что происходит с контекстами.
let keepAliveTargetId = null;
try {
({ targetId: keepAliveTargetId } = await browser.Target.createTarget({ url: 'about:blank' }));
} catch (e) {
console.warn(`[shard:${shardIndex}] keepalive-таргет не создан: ${e.message}`);
}
const shard = { index: shardIndex, port, profileDir, proc, browser, keepAliveTargetId, _dead: false };
console.log(`[shard:${shardIndex}] Chrome PID=${proc.pid} порт ${port} готов`);
return shard;
}
// Соединение с браузером мертво? Ретраить создание контекста в закрытый сокет
// бессмысленно — ни одна попытка не пройдёт, пока шард не поднимут заново.
function isDeadConnectionError(err) {
const m = String(err?.message || err);
return /WebSocket is not open|WebSocket connection closed|readyState 3|socket hang up|ECONNREFUSED|ECONNRESET/i.test(m);
}
async function _respawnShard(shardIndex) {
if (_shuttingDown || _respawningShards.has(shardIndex)) return;
_respawningShards.add(shardIndex);
// Выкидываем мёртвый шард и его контексты
const oldSlots = slots.filter(s => s.shardIndex === shardIndex);
for (const slot of oldSlots) {
if (slot.rotateTimer) { try { clearTimeout(slot.rotateTimer); } catch (_) {} }
try { slot.client.removeAllListeners(); await slot.client.close(); } catch (_) {}
const i = slots.indexOf(slot);
if (i !== -1) slots.splice(i, 1);
}
const si = shards.findIndex(s => s.index === shardIndex);
if (si !== -1) {
const dead = shards[si];
try { dead.browser.removeAllListeners(); await dead.browser.close(); } catch (_) {}
try { dead.proc.kill('SIGKILL'); } catch (_) {}
shards.splice(si, 1);
}
try {
const shard = await spawnShard(shardIndex);
shards.push(shard);
for (const slot of oldSlots) {
await createSlot(shard, slot.index).then(s => slots.push(s)).catch(e =>
console.error(`[slot:${slot.index}] не поднялся после рестарта шарда:`, e.message));
}
_respawningShards.delete(shardIndex);
_shardRespawnFails.delete(shardIndex); // поднялся — счётчик неудач обнуляем
console.log(`[shard:${shardIndex}] перезапущен (${oldSlots.length} контекстов)`);
} catch (e) {
_respawningShards.delete(shardIndex);
// Растущая пауза вместо фиксированных 10с: если Chrome не стартует по системной
// причине (занят профиль, кончилось место), долбить раз в 10 секунд бесконечно
// только маскирует проблему в логах. Потолок — 2 минуты.
const fails = (_shardRespawnFails.get(shardIndex) || 0) + 1;
_shardRespawnFails.set(shardIndex, fails);
const delay = Math.min(120000, 5000 * 2 ** (fails - 1));
console.error(`[shard:${shardIndex}] перезапуск не удался (попытка ${fails}):`, e.message, `— повтор через ${Math.round(delay / 1000)}с`);
if (!_shuttingDown) setTimeout(() => _respawnShard(shardIndex), delay);
}
}
// Ждём готовности страницы вместо фиксированной паузы: опрашиваем два признака —
// загружен бандл твича и поднялся Kasada. На быстрых прокси выходим за 3-4с, на
// медленных ждём до WARM_MS. Время каждого прогрева пишем в лог — по нему видно,
// есть ли смысл дальше крутить CTX_WARM_MS.
// NB: признак Kasada определён эмпирически (глобал KPSDK или его кука). Если в
// логах постоянно kasada=нет, а минт при этом рабочий — признак надо уточнить.
async function waitWarm(client, slotIndex, proxyTag) {
const t0 = Date.now();
const deadline = t0 + WARM_MS;
let state = { bundle: false, kasada: false };
while (Date.now() < deadline) {
if (Date.now() - t0 >= WARM_MIN_MS) {
try {
const r = await client.Runtime.evaluate({
expression: `JSON.stringify({ bundle: !!window.__twilightBuildID, kasada: (typeof window.KPSDK !== 'undefined') || /kpsdk/i.test(document.cookie) })`,
returnByValue: true,
});
state = JSON.parse(r?.result?.value || '{}');
if (state.bundle && state.kasada) break;
} catch (_) {}
}
await sleep(WARM_POLL_MS);
}
const ms = Date.now() - t0;
const tag = `bundle=${state.bundle ? 'да' : 'нет'} kasada=${state.kasada ? 'да' : 'нет'}`;
if (state.bundle && state.kasada) {
console.log(`[slot:${slotIndex}] прогрев ${(ms / 1000).toFixed(1)}с (${tag})`);
} else {
console.warn(`[slot:${slotIndex}] прогрев ${(ms / 1000).toFixed(1)}с — готовность не подтверждена (${tag}, ${proxyTag || 'direct'})`);
}
return { ms, ...state };
}
// ── Слот = browser context со своим прокси и своим cookie-jar ────────────────
// Дохлый прокси в списке — норма при сотнях статиков, поэтому прогрев проверяется,
// и при провале контекст пересоздаётся на СЛЕДУЮЩЕМ прокси (сдвиг на POOL_SIZE).
async function createSlot(shard, slotIndex, attempts = 3) {
let lastErr;
for (let attempt = 0; attempt < attempts; attempt++) {
// Шард уже помечен мёртвым (сработал proc.on('exit')) — ждать нечего,
// контексты поднимет _respawnShard после перезапуска Chrome.
if (shard._dead) throw new Error(`[slot:${slotIndex}] шард ${shard.index} мёртв — контекст не создаём`);
try {
return await createSlotOnce(shard, slotIndex, attempt);
} catch (e) {
lastErr = e;
// Сокет к браузеру закрыт: следующие попытки упадут так же. Раньше здесь
// молотилось 1/3 → 2/3 → 3/3 с одинаковой ошибкой и только тормозило рестарт.
if (isDeadConnectionError(e)) {
shard._dead = true;
throw new Error(`[slot:${slotIndex}] соединение с браузером закрыто — ждём перезапуска шарда`);
}
console.warn(`[slot:${slotIndex}] попытка ${attempt + 1}/${attempts}: ${e.message}`);
if (attempt < attempts - 1) await sleep(500 * (attempt + 1)); // пауза перед следующим прокси
}
}
throw lastErr || new Error(`[slot:${slotIndex}] не удалось поднять контекст`);
}
async function createSlotOnce(shard, slotIndex, attempt = 0) {
// Сдвиг на POOL_SIZE — чтобы ретрай попал на другой прокси, а не на тот же
const { proxyArg, auth, proxyUrl, ttlMs, proxyTag } = proxyForSlot(slotIndex + attempt * POOL_SIZE);
const ctxOpts = proxyArg
? { proxyServer: proxyArg, proxyBypassList: '<-loopback>' }
: {};
const { browserContextId } = await shard.browser.Target.createBrowserContext(ctxOpts);
let targetId = null;
let client = null;
try {
({ targetId } = await shard.browser.Target.createTarget({ url: 'about:blank', browserContextId }));
client = await CDP({ host: '127.0.0.1', port: shard.port, target: targetId });
// Авторизация прокси — на контекст (у sticky у каждого свой session-ный username)
if (auth || BLOCK_ASSETS) {
await client.Fetch.enable({ handleAuthRequests: !!auth, patterns: [{ urlPattern: '*' }] });
client.Fetch.requestPaused(async ({ requestId, resourceType }) => {
if (BLOCK_ASSETS && BLOCK_TYPES.has(resourceType)) {
await client.Fetch.failRequest({ requestId, errorReason: 'BlockedByClient' }).catch(() => {});
} else {
await client.Fetch.continueRequest({ requestId }).catch(() => {});
}
});
if (auth) {
client.Fetch.authRequired(async ({ requestId, authChallenge }) => {
const response = authChallenge.source === 'Proxy'
? { response: 'ProvideCredentials', username: auth.username, password: auth.password }
: { response: 'Default' };
await client.Fetch.continueWithAuth({ requestId, authChallengeResponse: response }).catch(() => {});
});
}
}
await client.Page.addScriptToEvaluateOnNewDocument({ source: STEALTH_SOURCE });
await client.Runtime.enable();
await client.Network.enable();
// Прогрев: один раз грузим twitch.tv — Kasada инициализируется, дальше страница живёт.
// Page.navigate отдаёт errorText, если прокси не отозвался — так ловим дохлые строки.
const nav = await client.Page.navigate({ url: WARM_URL });
if (nav && nav.errorText) throw new Error(`прогрев: ${nav.errorText} через ${proxyTag || 'direct'}`);
await waitWarm(client, slotIndex, proxyTag);
const slot = {
index: slotIndex,
shardIndex: shard.index,
port: shard.port,
browserContextId,
targetId,
client,
proxyUrl,
ttlMs,
busy: false,
_dead: false,
needsRotate: false,
rotateTimer: null,
};
console.log(`[slot:${slotIndex}] контекст готов (shard ${shard.index}${proxyTag ? `, via ${proxyTag}` : ''}${proxyUrl ? '' : ', direct'})`);
if (ttlMs) {
const wait = Math.max(60000, Math.floor(ttlMs * 0.8 + Math.random() * ttlMs * 0.15));
slot.rotateTimer = setTimeout(() => rotateSlot(slotIndex), wait);
}
return slot;
} catch (e) {
// Контекст не поднялся (чаще всего дохлый прокси) — прибираем за собой,
// иначе в Chrome копятся пустые контексты, а вызывающий ретраит на другом прокси.
try { if (client) { client.removeAllListeners(); await client.close(); } } catch (_) {}
await shard.browser.Target.disposeBrowserContext({ browserContextId }).catch(() => {});
throw e;
}
}
// Ротация exit-IP: выкидываем контекст и создаём новый со свежей session.
// Процесс Chrome НЕ трогаем — в этом весь смысл контекстов (было: рестарт браузера ~13с).
async function rotateSlot(slotIndex) {
if (_shuttingDown || _respawningSlots.has(slotIndex)) return;
const slot = slots.find(s => s.index === slotIndex);
if (!slot || slot._dead) return;
if (slot.busy) { slot.needsRotate = true; return; }
_respawningSlots.add(slotIndex);
slot._dead = true;
const shard = shards.find(s => s.index === slot.shardIndex && !s._dead);
try {
if (slot.rotateTimer) { try { clearTimeout(slot.rotateTimer); } catch (_) {} }
// ПОРЯДОК ВАЖЕН: сначала поднимаем новый контекст, и только потом сносим старый.
// Раньше было наоборот — контекст удалялся первым, и если он оказывался последним
// у шарда, Chrome оставался без окон и выходил с кодом 0, унося все слоты.
// (Keepalive-таргет в spawnShard страхует тот же случай на уровне процесса.)
const i = slots.indexOf(slot);
if (i !== -1) slots.splice(i, 1);
if (shard) {
const fresh = await createSlot(shard, slotIndex);
slots.push(fresh);
// Новый контекст готов — прибираем старый.
try { slot.client.removeAllListeners(); await slot.client.close(); } catch (_) {}
await shard.browser.Target.disposeBrowserContext({ browserContextId: slot.browserContextId }).catch(() => {});
console.log(`[slot:${slotIndex}] ротация exit-IP выполнена`);
} else {
try { slot.client.removeAllListeners(); await slot.client.close(); } catch (_) {}
}
} catch (e) {
console.error(`[slot:${slotIndex}] ошибка ротации:`, e.message, '— повтор через 10с');
if (!_shuttingDown) setTimeout(() => rotateSlot(slotIndex), 10000);
} finally {
_respawningSlots.delete(slotIndex);
}
}
// ── Инициализация пула ───────────────────────────────────────────────────────
async function initPool() {
console.log(`[ctx-pool] Запуск: ${SHARDS} процесс(ов) Chrome × ${POOL_SIZE} контекстов всего`);
for (let i = 0; i < SHARDS; i++) {
try { shards.push(await spawnShard(i)); }
catch (e) { console.error('[ctx-pool] шард не поднялся:', e.message); }
}
if (!shards.length) throw new Error('Ни один процесс Chrome не поднялся');
const totalBatches = Math.ceil(POOL_SIZE / LAUNCH_BATCH);
for (let i = 0; i < POOL_SIZE; i += LAUNCH_BATCH) {
const batchNum = Math.floor(i / LAUNCH_BATCH) + 1;
const batch = Array.from({ length: Math.min(LAUNCH_BATCH, POOL_SIZE - i) }, (_, j) => {
const slotIndex = i + j;
const shard = shards[slotIndex % shards.length];
return createSlot(shard, slotIndex);
});
const results = await Promise.allSettled(batch);
for (const r of results) {
if (r.status === 'fulfilled') slots.push(r.value);
else console.error('[ctx-pool] контекст не поднялся:', r.reason?.message);
}
console.log(`[ctx-pool] батч ${batchNum}/${totalBatches} готов (${slots.length}/${POOL_SIZE})`);
if (i + LAUNCH_BATCH < POOL_SIZE) await sleep(LAUNCH_BATCH_DELAY_MS);
}
console.log(`[ctx-pool] Готово: ${slots.length}/${POOL_SIZE} контекстов на ${shards.length} процессе(ах)`);
// Периодический переезд по списку прокси — чтобы задействовать весь пул IP,
// а не первые POOL_SIZE адресов. 0 = выключено.
if (PROXY_CYCLE_SEC > 0) {
console.log(`[ctx-pool] авто-цикл прокси: каждые ${PROXY_CYCLE_SEC}с`);
setInterval(() => { void rotateProxies(); }, PROXY_CYCLE_SEC * 1000).unref();
}
}
export async function ensurePool() {
if (!poolInitPromise) {
poolInitPromise = initPool().catch(e => { poolInitPromise = null; throw e; });
}
return poolInitPromise;
}
// Занимаем свободный контекст. В отличие от старого пула контекст лочится:
// два параллельных минта в одном контексте перетирали бы auth-token друг другу.
let _rr = 0;
async function acquireSlot(waitMs = 20000) {
const deadline = Date.now() + waitMs;
while (true) {
const alive = slots.filter(s => !s._dead && !s.busy);
if (alive.length) {
const slot = alive[_rr++ % alive.length];
slot.busy = true;
return slot;
}
if (!slots.some(s => !s._dead)) throw new Error('Нет живых контекстов Chrome');
if (Date.now() > deadline) throw new Error('Все контексты заняты');
await sleep(100);
}
}
function releaseSlot(slot) {
slot.busy = false;
if (slot.needsRotate) {
slot.needsRotate = false;
void rotateSlot(slot.index);
}
}
// Реальный fingerprint контекста (UA + Client Hints) — GQL должен идти с тем же.
async function readFingerprint(client) {
let userAgent = MINT_UA, headers = MINT_HEADERS;
try {
const uaR = await client.Runtime.evaluate({ expression: 'navigator.userAgent', returnByValue: true });
if (uaR?.result?.value) userAgent = uaR.result.value;
const chR = await client.Runtime.evaluate({
expression: 'JSON.stringify(navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null)',
returnByValue: true,
});
const ch = chR?.result?.value ? JSON.parse(chR.result.value) : null;
if (ch?.brands?.length) {
headers = {
'sec-ch-ua': ch.brands.map(b => `"${b.brand}";v="${b.version}"`).join(', '),
'sec-ch-ua-mobile': ch.mobile ? '?1' : '?0',
'sec-ch-ua-platform': `"${ch.platform || 'Windows'}"`,
};
}
} catch (_) {}
return { userAgent, headers };
}
// ── API (сигнатуры как в chrome-launcher.js) ─────────────────────────────────
// Минт на УЖЕ занятом слоте. Вынесен из getTokenForAccount, чтобы selfTest мог
// минтить и тут же дёрнуть GQL в том же контексте — с гарантированно тем же exit-IP.
async function mintOnSlot(slot, oauth, timeout) {
const { index, client } = slot;
const deviceId = crypto.randomBytes(8).toString('hex');
const sessionId = crypto.randomBytes(8).toString('hex');
const requestId = crypto.randomBytes(15).toString('hex');
// client-version — ЖИВОЙ билд-ID из самого браузера, а не хардкод. twitch.tv
// держит его в window.__twilightBuildID и им же шлёт заголовок Client-Version;
// константа устаревала от каждого веб-деплоя Twitch. Страница после прогрева
// стоит на twitch.tv (WARM_URL), готовность прогрева проверяется по этой же
// глобали (см. warmup), так что на момент минта она есть. Фолбэк — константа.
let clientVersion = CLIENT_VERSION;
try {
const cvR = await client.Runtime.evaluate({
expression: 'window.__twilightBuildID || ""', returnByValue: true,
});
if (cvR?.result?.value) clientVersion = cvR.result.value;
} catch (_) {}
{
console.log(`[slot:${index}] → fetch integrity oauth=${oauth.slice(0, 8)}... proxy=${slot.proxyUrl ? 'via' : 'direct'}`);
await client.Network.setCookie({
name: 'auth-token', value: oauth,
domain: '.twitch.tv', path: '/', secure: true, httpOnly: false, sameSite: 'None',
});
// Kasada уже инициализирован прогревом — браузер сам добавит x-kpsdk-*
const expression = `
(async () => {
const resp = await fetch('https://gql.twitch.tv/integrity', {
method: 'POST',
headers: {
'accept': '*/*',
'accept-language': 'en-US,en;q=0.9',
'authorization': 'OAuth ${oauth}',
'client-id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
'client-request-id': '${requestId}',
'client-session-id': '${sessionId}',
'client-version': '${clientVersion}',
'content-length': '0',
'origin': 'https://www.twitch.tv',
'referer': 'https://www.twitch.tv/',
'x-device-id': '${deviceId}',
},
});
const data = await resp.json();
return JSON.stringify({ token: data.token, expiration: data.expiration });
})()
`;
const result = await Promise.race([
client.Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true }),
new Promise((_, reject) => setTimeout(() => reject(new Error('Runtime.evaluate timeout')), timeout)),
]);
if (result.exceptionDetails) throw new Error(`Runtime.evaluate error: ${result.exceptionDetails.text}`);
const parsed = JSON.parse(result.result.value);
if (!parsed.token) throw new Error('integrity token missing in response');
console.log(`[slot:${index}] ✅ token length=${parsed.token.length}`);
// expiration — АБСОЛЮТНЫЙ срок жизни токена (unix-ms), отдаёт Twitch в ответе
// /integrity. Раньше выбрасывали → C# ставил срок «от балды» (фикс. 35 мин) и
// на коротко-живущих токенах слал мёртвые → failed integrity → шторм переминтов.
return { token: parsed.token, sessionId, deviceId, clientVersion, expiration: parsed.expiration };
}
}
export async function getTokenForAccount(oauth, { timeout = 30000 } = {}) {
await ensurePool();
const slot = await acquireSlot();
try {
const { token, sessionId, deviceId, expiration } = await mintOnSlot(slot, oauth, timeout);
const { userAgent, headers } = await readFingerprint(slot.client);
return { token, sessionId, deviceId, expiration, proxy: slot.proxyUrl, userAgent, headers };
} finally {
releaseSlot(slot);
}
}
export async function registerHlsSession(oauth, channel, { timeout = 45000 } = {}) {
await ensurePool();
const slot = await acquireSlot();
const { index, client } = slot;
try {
console.log(`[slot:${index}] → hls-register ${channel} oauth=${oauth.slice(0, 8)}...`);
await client.Network.setCookie({
name: 'auth-token', value: oauth,
domain: '.twitch.tv', path: '/', secure: true, httpOnly: false, sameSite: 'None',
});
const expression = `
(async () => {
const gqlResp = await fetch('https://gql.twitch.tv/gql', {
method: 'POST',
headers: {
'authorization': 'OAuth ${oauth}',
'client-id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
'content-type': 'text/plain;charset=UTF-8',
'origin': 'https://www.twitch.tv',
'referer': 'https://www.twitch.tv/',
},
body: JSON.stringify([{
query: '{ streamPlaybackAccessToken(channelName: "${channel}", params: {platform: "web", playerBackend: "mediaplayer", playerType: "site"}) { value signature } }'
}]),
});
const gqlData = await gqlResp.json();
const pat = (Array.isArray(gqlData) ? gqlData[0] : gqlData)?.data?.streamPlaybackAccessToken;
if (!pat?.value) return JSON.stringify({ ok: false, error: 'no PAT', details: JSON.stringify(gqlData).slice(0, 300) });
const p = Math.floor(Math.random() * 9999999);
const usherUrl = 'https://usher.twitchapps.com/api/channel/hls/${channel}.m3u8?sig=' + encodeURIComponent(pat.signature) + '&token=' + encodeURIComponent(pat.value) + '&allow_source=true&allow_spectre=true&p=' + p;
const usherResp = await fetch(usherUrl, { headers: { 'origin': 'https://www.twitch.tv', 'referer': 'https://www.twitch.tv/' } });
const text = await usherResp.text();
const lines = text.split('\\n').filter(l => l.startsWith('http')).length;
return JSON.stringify({ ok: usherResp.ok, status: usherResp.status, streams: lines });
})()
`;
const result = await Promise.race([
client.Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true }),
new Promise((_, reject) => setTimeout(() => reject(new Error('hls-register timeout')), timeout)),
]);
if (result.exceptionDetails) throw new Error('Runtime.evaluate: ' + result.exceptionDetails.text);
const parsed = JSON.parse(result.result.value);
console.log(`[slot:${index}] hls-register ${channel}:`, JSON.stringify(parsed));
return parsed;
} finally {
releaseSlot(slot);
}
}
// POST /rotate-pool: пересоздаём все контексты со свежими session (новые exit-IP).
// Процессы Chrome живут — раньше это был полный рестарт пула на несколько минут.
let _rotatingAll = false;
export async function rotateProxies() {
if (_rotatingAll) { console.log('[ctx-pool] ротация уже идёт — пропускаем'); return; }
_rotatingAll = true;
try {
const indexes = slots.map(s => s.index);
// Статик-прокси IP не меняют — сдвигаем окно по списку, чтобы контексты
// переехали на другие адреса. Sticky меняют IP сами (новый session).
if (HTTP_PROXIES.length && !STICKY_BASES.length) {
proxyOffset += Math.max(1, indexes.length);
console.log(`[ctx-pool] сдвиг по списку статиков: offset=${proxyOffset % HTTP_PROXIES.length} из ${HTTP_PROXIES.length}`);
}
console.log(`[ctx-pool] ротация всех контекстов: ${indexes.length} шт.`);
for (let i = 0; i < indexes.length; i += LAUNCH_BATCH) {
await Promise.allSettled(indexes.slice(i, i + LAUNCH_BATCH).map(idx => rotateSlot(idx)));
}
console.log(`[ctx-pool] ротация завершена: ${slots.filter(s => !s._dead).length} живых контекстов`);
} finally {
_rotatingAll = false;
}
}
// Follow + сразу unfollow данным integrity. Один и тот же запрос для обоих
// режимов минта — сравниваем именно токены, а не способ отправки.
function buildFollowExpression(oauth, target, mint) {
return `
(async () => {
const H = {
'authorization': 'OAuth ${oauth}',
'client-id': '${CLIENT_ID}',
'client-integrity': ${JSON.stringify(mint.token)},
'client-session-id': ${JSON.stringify(mint.sessionId)},
'client-version': ${JSON.stringify(mint.clientVersion)},
'x-device-id': ${JSON.stringify(mint.deviceId)},
'content-type': 'text/plain;charset=UTF-8',
};
const idResp = await fetch('https://gql.twitch.tv/gql', {
method: 'POST',
headers: { 'client-id': '${CLIENT_ID}', 'content-type': 'text/plain' },
body: JSON.stringify({ query: 'query{user(login:"${target}"){id}}' }),
});
const idJson = await idResp.json();
const id = idJson && idJson.data && idJson.data.user && idJson.data.user.id;
if (!id) return JSON.stringify({ error: 'канал ${target} не найден' });
const gql = async (op, hash, vars) => {
const r = await fetch('https://gql.twitch.tv/gql', {
method: 'POST', headers: H,
body: JSON.stringify([{ operationName: op, variables: vars, extensions: { persistedQuery: { version: 1, sha256Hash: hash } } }]),
});
return { status: r.status, text: await r.text() };
};
const f = await gql('FollowButton_FollowUser', '${FOLLOW_HASH}', { input: { disableNotifications: false, targetID: String(id) } });
await gql('FollowButton_UnfollowUser', '${UNFOLLOW_HASH}', { input: { targetID: String(id) } });
return JSON.stringify({ status: f.status, failed: /failed integrity check|IntegrityCheckFailed/i.test(f.text), body: f.text.slice(0, 300) });
})()
`;
}
async function runFollow(slot, oauth, target, mint, timeout) {
const res = await Promise.race([
slot.client.Runtime.evaluate({ expression: buildFollowExpression(oauth, target, mint), awaitPromise: true, returnByValue: true }),
new Promise((_, rej) => setTimeout(() => rej(new Error('follow timeout')), timeout)),
]);
if (res.exceptionDetails) throw new Error(res.exceptionDetails.text);
return JSON.parse(res.result.value);
}
// Минт «как у самой страницы»: ставим куку, грузим канал и перехватываем
// Client-Integrity из GQL-запросов твича вместе с его device-id/session-id.
// Медленно (до ~40с), зато токен заведомо прошёл Kasada — эталон для сравнения.
async function sniffOnSlot(slot, oauth, target, timeout = 40000) {
const { index, client } = slot;
await client.Network.setCookie({
name: 'auth-token', value: oauth,
domain: '.twitch.tv', path: '/', secure: true, httpOnly: false, sameSite: 'None',
});
await client.Network.setCookie({ name: 'consent', value: '1', domain: '.twitch.tv', path: '/' }).catch(() => {});
let mint = null;
const onReq = ({ request }) => {
if (mint || !request || !request.url.includes('/gql')) return;
const h = request.headers || {};
const t = h['Client-Integrity'] || h['client-integrity'];
if (!t || !t.startsWith('v4.local.')) return;
mint = {
token: t,
sessionId: h['Client-Session-Id'] || h['client-session-id'] || '',
deviceId: h['X-Device-Id'] || h['x-device-id'] || '',
clientVersion: h['Client-Version'] || h['client-version'] || CLIENT_VERSION,
};
};
client.on('Network.requestWillBeSent', onReq);
try {
await client.Page.navigate({ url: `https://www.twitch.tv/${target}` });
const deadline = Date.now() + timeout;
while (!mint && Date.now() < deadline) await sleep(500);
if (!mint) throw new Error(`Client-Integrity не перехвачен за ${Math.round(timeout / 1000)}с`);
console.log(`[slot:${index}] ✅ sniff token length=${mint.token.length}`);
return mint;
} finally {
try { client.removeListener('Network.requestWillBeSent', onReq); } catch (_) {}
await client.Page.navigate({ url: WARM_URL }).catch(() => {});
}
}
// Диагностика «failed integrity check». Минт и GQL — в ОДНОМ контексте, то есть
// заведомо с одного exit-IP и одного fingerprint, поэтому маршрутизация исключена.
// Два режима минта сравниваются одним и тем же follow:
// fetch — как в боевом лаунчере: fetch('/integrity') из прогретой страницы;
// sniff — как делает сама страница: Client-Integrity из её GQL-запросов.
// fetch падает, sniff проходит → виноват способ минта, а не контексты/прокси.
export async function selfTest(oauth, { target = 'monstercat', timeout = 45000, mode = 'both' } = {}) {
await ensurePool();
const slot = await acquireSlot();
const { index, client } = slot;
const out = {
slot: index,
proxy: slot.proxyUrl ? slot.proxyUrl.replace(/\/\/[^@/]*@/, '//') : null,
exitIp: null, fetchMint: null, sniffMint: null, error: null,
};
const attempt = async (name, mintFn) => {
const r = { tokenLen: null, gqlStatus: null, integrityFailed: null, body: null, error: null };
try {
const mint = await mintFn();
r.tokenLen = mint.token.length;
const f = await runFollow(slot, oauth, target, mint, timeout);
if (f.error) r.error = f.error;
else { r.gqlStatus = f.status; r.integrityFailed = f.failed; r.body = f.body; }
} catch (e) {
r.error = String(e.message || e);
}
console.log(`[slot:${index}] selftest/${name}: token=${r.tokenLen} gql=${r.gqlStatus} integrityFailed=${r.integrityFailed}${r.error ? ' err=' + r.error : ''}`);
return r;
};
try {
try {
const r = await client.Runtime.evaluate({
expression: `fetch('https://api.ipify.org/?format=text', { cache: 'no-store' }).then(r => r.text())`,
awaitPromise: true, returnByValue: true,
});
out.exitIp = String(r?.result?.value || '').trim().slice(0, 45);
} catch (_) {}
if (mode === 'fetch' || mode === 'both') {
out.fetchMint = await attempt('fetch', () => mintOnSlot(slot, oauth, timeout));
}
if (mode === 'sniff' || mode === 'both') {
out.sniffMint = await attempt('sniff', () => sniffOnSlot(slot, oauth, target));
}
} catch (e) {
out.error = String(e.message || e);
} finally {
releaseSlot(slot);
}
return out;
}
// Диагностика: реальный exit-IP каждого контекста. Главная проверка всей затеи —
// что per-context proxyServer действительно развёл IP, иначе IP-привязка integrity
// сломается. ipify отдаёт Access-Control-Allow-Origin: *, поэтому fetch со
// страницы twitch.tv проходит.
export async function probeExitIps({ timeout = 15000, batch = 5 } = {}) {
await ensurePool();
const alive = slots.filter(s => !s._dead);
const out = [];
for (let i = 0; i < alive.length; i += batch) {
const chunk = alive.slice(i, i + batch);
const res = await Promise.all(chunk.map(async (slot) => {
let ip = null, err = null;
try {
const r = await Promise.race([
slot.client.Runtime.evaluate({
expression: `fetch('https://api.ipify.org/?format=text', { cache: 'no-store' }).then(r => r.text())`,
awaitPromise: true, returnByValue: true,
}),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), timeout)),
]);
ip = String(r?.result?.value || '').trim().slice(0, 45);
} catch (e) { err = e.message; }
// пароль из URL прокси не отдаём наружу
const proxy = slot.proxyUrl ? slot.proxyUrl.replace(/\/\/[^@/]*@/, '//') : null;
return { slot: slot.index, proxy, ip, err };
}));
out.push(...res);
}
const ips = out.map(o => o.ip).filter(ip => /^\d+\.\d+\.\d+\.\d+$/.test(ip || ''));
const unique = new Set(ips);
console.log(`[ctx-pool] probe exit-IP: ${unique.size} уникальных из ${out.length} контекстов`);
return { total: out.length, resolved: ips.length, uniqueIps: unique.size, slots: out };
}
// Диагностика для /stats
export function poolStats() {
return {
shards: shards.length,
slots: slots.length,
alive: slots.filter(s => !s._dead).length,
busy: slots.filter(s => s.busy).length,
proxied: slots.filter(s => s.proxyUrl).length,
};
}