'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- (деф 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- (новый 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, }; }