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