From 4a66db4c88f14f0f6cd6d403586fc4899afb5b09 Mon Sep 17 00:00:00 2001 From: AdministratorEbaka1337 Date: Tue, 8 Sep 2026 18:15:13 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Invoke-RestMethod.txt | 11 + chrome-launcher-ctx.js | 1007 ++++++++++++++++++++++++++++++++++++++++ proxies.txt | 1 + proxy-forwarder.js | 102 ++++ token-server-ctx.js | 155 +++++++ 5 files changed, 1276 insertions(+) create mode 100644 Invoke-RestMethod.txt create mode 100644 chrome-launcher-ctx.js create mode 100644 proxies.txt create mode 100644 proxy-forwarder.js create mode 100644 token-server-ctx.js diff --git a/Invoke-RestMethod.txt b/Invoke-RestMethod.txt new file mode 100644 index 0000000..abf1a71 --- /dev/null +++ b/Invoke-RestMethod.txt @@ -0,0 +1,11 @@ +Invoke-RestMethod http://127.0.0.1:3003/selftest -Method Post -Headers $H ` + -ContentType 'application/json' -Body '{"oauth":"01pd89xuyo232p47vzskda7ybhghha"}' | ConvertTo-Json + +Invoke-RestMethod http://127.0.0.1:3003/health -Headers @{'x-api-key'='GCHksldkkqg2254fobvmgjhtrk4o35igojvkfdothrypewohkj74365@'} + + +Invoke-RestMethod http://127.0.0.1:3003/selftest -Method Post -Headers @{'x-api-key'='GCHksldkkqg2254fobvmgjhtrk4o35igojvkfdothrypewohkj74365@'} -ContentType 'application/json' -Body '{"oauth":"01pd89xuyo232p47vzskda7ybhghha"}' | ConvertTo-Json + + + +(Invoke-RestMethod http://127.0.0.1:3003/probe-ips -Headers @{'x-api-key'='GCHksldkkqg2254fobvmgjhtrk4o35igojvkfdothrypewohkj74365@'}).slots | Format-Table slot,ip,proxy diff --git a/chrome-launcher-ctx.js b/chrome-launcher-ctx.js new file mode 100644 index 0000000..01f282e --- /dev/null +++ b/chrome-launcher-ctx.js @@ -0,0 +1,1007 @@ +'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, + }; +} diff --git a/proxies.txt b/proxies.txt new file mode 100644 index 0000000..2832dab --- /dev/null +++ b/proxies.txt @@ -0,0 +1 @@ +res-unlimited-5d47cf3e.plainproxies.com:8080:HdOnr8PeUa-session-049459-ttl-2400:qj05VPa8lFPfBTr \ No newline at end of file diff --git a/proxy-forwarder.js b/proxy-forwarder.js new file mode 100644 index 0000000..8185d05 --- /dev/null +++ b/proxy-forwarder.js @@ -0,0 +1,102 @@ +'use strict'; +/* + * proxy-forwarder.js — локальный прокси-«перекладчик» на слот. + * + * ЗАЧЕМ. Прокси у browser-context в CDP фиксируется при создании контекста — + * сменить его на лету нельзя, только пересоздать контекст (а это прогрев ~10с и + * нестабильность в Node при частом destroy). Поэтому контекст навсегда смотрит в + * ОДИН локальный адрес (127.0.0.1:PORT), а за этим адресом стоит вот этот + * форвардер, у которого upstream (реальный резидентский прокси) можно менять + * одной переменной. Смена IP аккаунта = setUpstream(...) + reload страницы, + * БЕЗ пересоздания контекста → ~2-5с вместо ~10-18с. + * + * Держит только то, что реально нужно минту: HTTPS через CONNECT-туннель и + * обычный HTTP forward. Upstream — HTTP-прокси с Basic-авторизацией (формат + * плейнпрокси host:port:user:pass). SOCKS не поддерживаем: наши чат-прокси HTTP. + * + * Зависимостей нет — только встроенные http/net. + */ + +import http from 'http'; +import net from 'net'; + +/** + * Поднять форвардер на 127.0.0.1:port. + * @returns {{ server: http.Server, setUpstream: (u: {host,port,user?,pass?}|null)=>void, getUpstream: ()=>object|null, close: ()=>void }} + */ +export function startForwarder(port) { + // upstream живёт в замыкании: setUpstream меняет его атомарно, новые + // соединения (после reload страницы) идут уже через новый прокси. + let upstream = null; + + const authHeader = (u) => + u && u.user ? 'Basic ' + Buffer.from(`${u.user}:${u.pass || ''}`).toString('base64') : null; + + const server = http.createServer((req, res) => { + // Обычный HTTP (не CONNECT). Твич весь на https, так что путь редкий, но + // держим, чтобы случайный http-ресурс не ломал страницу. + const u = upstream; + if (!u) { res.writeHead(502); res.end('no upstream'); return; } + const headers = { ...req.headers }; + const a = authHeader(u); + if (a) headers['proxy-authorization'] = a; + const preq = http.request( + { host: u.host, port: u.port, method: req.method, path: req.url, headers }, + (pres) => { res.writeHead(pres.statusCode || 502, pres.headers); pres.pipe(res); }, + ); + preq.on('error', () => { try { res.writeHead(502); res.end(); } catch (_) {} }); + req.pipe(preq); + }); + + // HTTPS: браузер шлёт CONNECT host:443 — открываем туннель до цели ЧЕРЕЗ upstream. + server.on('connect', (req, clientSocket, head) => { + const u = upstream; + if (!u) { try { clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch (_) {} return; } + + const upSock = net.connect(u.port, u.host, () => { + let line = `CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n`; + const a = authHeader(u); + if (a) line += `Proxy-Authorization: ${a}\r\n`; + line += 'Connection: keep-alive\r\n\r\n'; + upSock.write(line); + }); + + let header = Buffer.alloc(0); + let piped = false; + const onData = (d) => { + if (piped) return; + header = Buffer.concat([header, d]); + const idx = header.indexOf('\r\n\r\n'); + if (idx === -1) return; // ответ upstream ещё не дочитан + const status = header.slice(0, header.indexOf('\r\n')).toString(); + upSock.removeListener('data', onData); + if (!/^HTTP\/1\.[01] 200/.test(status)) { // upstream отказал (авторизация/цель) + try { clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\n\r\n`); } catch (_) {} + try { upSock.end(); } catch (_) {} + return; + } + piped = true; + try { clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); } catch (_) {} + const rest = header.slice(idx + 4); // тело, прилипшее к заголовку + if (rest.length) clientSocket.write(rest); + if (head && head.length) upSock.write(head); + upSock.pipe(clientSocket); + clientSocket.pipe(upSock); + }; + upSock.on('data', onData); + + const kill = () => { try { upSock.destroy(); } catch (_) {} try { clientSocket.destroy(); } catch (_) {} }; + upSock.on('error', kill); + clientSocket.on('error', kill); + }); + + server.on('clientError', (_e, sock) => { try { sock.destroy(); } catch (_) {} }); + server.listen(port, '127.0.0.1'); + + return { + server, + setUpstream(u) { upstream = u; }, + getUpstream() { return upstream; }, + close() { try { server.close(); } catch (_) {} }, + }; +} diff --git a/token-server-ctx.js b/token-server-ctx.js new file mode 100644 index 0000000..62f6f79 --- /dev/null +++ b/token-server-ctx.js @@ -0,0 +1,155 @@ +'use strict'; +/* + * token-server-ctx.js — тот же HTTP-контракт, что и token-server.js, но поверх + * chrome-launcher-ctx.js (1 процесс Chrome × N browser contexts). + * + * Отдельный файл + отдельный порт, чтобы гонять A/B рядом со старым сервером, + * не трогая боевой. Когда убедишься что лучше — в token-server.js меняется + * ОДНА строка импорта на './chrome-launcher-ctx.js', этот файл больше не нужен. + * + * Запуск на VM: + * $env:PORT="3003"; $env:INSTANCES="30"; $env:SHARDS="1" + * pm2 start token-server-ctx.js --name tokens-ctx + */ + +import 'dotenv/config'; +import express from 'express'; +import { + getTokenForAccount, registerHlsSession, ensurePool, shutdownPool, + rotateProxies, poolStats, probeExitIps, selfTest, +} from './chrome-launcher-ctx.js'; + +const app = express(); +const PORT = process.env.PORT || 3003; +const API_KEY = process.env.API_KEY || 'GCHksldkkqg2254fobvmgjhtrk4o35igojvkfdothrypewohkj74365@'; + +async function startPool() { + try { + await ensurePool(); + } catch (e) { + console.error('[pool] Ошибка инициализации пула:', e.message, '— повтор через 10с'); + setTimeout(startPool, 10000); + } +} +startPool(); + +app.use(express.json()); + +app.use((req, res, next) => { + if (req.headers['x-api-key'] !== API_KEY) { + return res.status(401).json({ ok: false, error: 'unauthorized' }); + } + next(); +}); + +app.get('/health', (_req, res) => res.json({ ok: true })); + +// Диагностика пула — сколько контекстов живо/занято +app.get('/stats', (_req, res) => res.json({ ok: true, ...poolStats() })); + +// Реальные exit-IP по контекстам — проверка, что прокси развели IP. +// Ходит в ipify из каждого контекста, на 30 слотах занимает несколько секунд. +app.get('/probe-ips', async (_req, res) => { + try { + res.json({ ok: true, ...(await probeExitIps()) }); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// POST /token body: { oauth, proxy? } → { ok, token, sessionId, deviceId, proxy, userAgent, headers } +// proxy (host:port:user:pass или url) — минтить на КОНКРЕТНОМ прокси аккаунта: +// форвардер слота переключается на него + reload. Без proxy — на прокси слота (как было). +app.post('/token', async (req, res) => { + const { oauth, proxy } = req.body || {}; + if (!oauth) return res.status(400).json({ ok: false, error: 'oauth required' }); + + try { + const result = await getTokenForAccount(oauth, { timeout: 60000, proxy: proxy || null }); + res.json({ ok: true, ...result }); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// POST /selftest body: { oauth, target? } +// Минт + follow/unfollow внутри ОДНОГО контекста. integrityFailed=false → минт живой, +// значит «failed integrity check» в панели даёт маршрутизация GQL, а не контексты. +app.post('/selftest', async (req, res) => { + const { oauth, target, mode } = req.body || {}; + if (!oauth) return res.status(400).json({ ok: false, error: 'oauth required' }); + + try { + const opts = {}; + if (target) opts.target = target; + if (mode) opts.mode = mode; // fetch | sniff | both (деф both) + const result = await selfTest(oauth, opts); + res.json({ ok: true, ...result }); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// POST /hls-register body: { oauth, channel } → { ok, status, streams } +// Дёргается панелью из дропсов (server.js _dropsRegisterHls). Ответ отдаём как есть — +// панель читает data.ok и data.streams на верхнем уровне. +app.post('/hls-register', async (req, res) => { + const { oauth, channel } = req.body || {}; + if (!oauth || !channel) return res.status(400).json({ ok: false, error: 'oauth and channel required' }); + + try { + const result = await registerHlsSession(oauth, channel, { timeout: 45000 }); + res.json(result); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// POST /rotate-pool — пересоздаёт контексты со свежими sticky-session (новые exit-IP) +app.post('/rotate-pool', async (_req, res) => { + console.log('[token-server-ctx] Запрос на ротацию контекстов...'); + try { + await rotateProxies(); + res.json({ ok: true, message: 'Контексты обновлены', ...poolStats() }); + } catch (e) { + console.error('[token-server-ctx] Ошибка ротации:', e.message); + res.status(500).json({ ok: false, error: e.message }); + } +}); + +const server = app.listen(PORT, () => { + console.log(`Token server (ctx) running on port ${PORT}`); + console.log(`API key: ${API_KEY}`); +}); + +server.on('error', (e) => { + if (e.code === 'EADDRINUSE') { + console.error(`[server] Порт ${PORT} занят, повтор через 3с...`); + setTimeout(() => server.listen(PORT), 3000); + } else { + console.error('[server] Ошибка:', e.message); + } +}); + +// Watchdog — если сервер не отвечает за 10с, выходим, pm2 поднимет заново +setInterval(async () => { + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10000); + await fetch(`http://127.0.0.1:${PORT}/health`, { headers: { 'x-api-key': API_KEY }, signal: ctrl.signal }); + clearTimeout(t); + } catch (e) { + console.error('[watchdog] Сервер не отвечает, перезапуск:', e.message); + process.exit(1); + } +}, 2 * 60 * 1000); + +function gracefulShutdown() { + console.log('[server] Завершение...'); + shutdownPool(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5000).unref(); +} + +process.on('SIGTERM', gracefulShutdown); +process.on('SIGINT', gracefulShutdown);