Загрузка данных


// ==UserScript==
// @name         I Hate Waiting
// @name:en      I Hate Waiting
// @namespace    https://tampermonkey.net/
// @version      3.5.16
// @license      MIT
// @description  Ускоряет загрузку страниц: защита LCP, корректный lazy-loading, приоритет видео на хостингах.
// @description:en Speeds up page loading: on video-hosting sites, priority is given to the main video; on other sites, priority is given to visible content.
// @author       Kimi + Qwen + Claude + Grok + DeepSeek + twicks other programmers
// @match        *://*/*
// @grant        none
// @run-at       document-start
// @compatible   firefox 132+ Violentmonkey
// @compatible   firefox 132+ Tampermonkey
// @compatible   chrome  101+ Violentmonkey
// @compatible   chrome  101+ Tampermonkey
// @compatible   chrome  101+ ScriptCat
// @compatible   safari 18.0+ Stay
// @compatible   edge 101+ Tampermonkey
// @compatible   opera 87+ Tampermonkey
// @compatible   android Via Browser
// @downloadURL https://update.greasyfork.org/scripts/571522/I%20Hate%20Waiting.user.js
// @updateURL https://update.greasyfork.org/scripts/571522/I%20Hate%20Waiting.meta.js
// ==/UserScript==

(function () {
    'use strict';

    // v3.5.13-fix2: единственное изменение относительно fix1 — защита собственных
    // dynamic-preconnect ссылок от удаления EXTREME_MODE LINK guard'ом (см.
    // _doPreconnect и processNode/LINK ниже, помечено "v3.5.13-fix2"). Остальные
    // наработки (src-watcher на переключаемых iframe, postMessage-фолбэк для
    // canplay, расширенный XHR/fetch-триггер для агрегаторов без прямого доступа
    // к <video>) — отдельными патчами поверх этой версии, после валидации.

    if (window.top !== window) return;

    // v3.0.21: SPA mode bridge перенесён внутрь _getMode() — читает sessionStorage 'ihw:cur'
    // напрямую, без отдельного restore-кода в начале скрипта.

    /* ── ВРЕМЕННЫЙ ПЕРЕКЛЮЧАТЕЛЬ ───────────────────────── */
    // Раскомментировать нужную строку для тестирования в режиме инкогнито
    // или без нажатия на кнопку. Работает благодаря тому, что sessionStorage
    // читается _getMode() первым (до localStorage).
    // После теста — закомментировать обратно.
    //
    // sessionStorage.setItem('ihw:cur:' + location.hostname, 'off');  localStorage.setItem('ihw:off:' + location.hostname, '1'); ['extreme','auto','on'].forEach(k => localStorage.removeItem('ihw:'+k+':'+location.hostname));
    // sessionStorage.setItem('ihw:cur:' + location.hostname, 'on');   ['off','extreme','auto'].forEach(k => localStorage.removeItem('ihw:'+k+':'+location.hostname)); localStorage.setItem('ihw:on:' + location.hostname, '1');
    // sessionStorage.setItem('ihw:cur:' + location.hostname, 'ext');  ['off','on','auto'].forEach(k => localStorage.removeItem('ihw:'+k+':'+location.hostname)); localStorage.setItem('ihw:extreme:' + location.hostname, '1');
    // sessionStorage.setItem('ihw:cur:' + location.hostname, 'auto'); ['off','on','extreme'].forEach(k => localStorage.removeItem('ihw:'+k+':'+location.hostname)); localStorage.setItem('ihw:auto:' + location.hostname, '1');
    // sessionStorage.removeItem('ihw:cur:' + location.hostname); ['off','on','extreme','auto'].forEach(k => localStorage.removeItem('ihw:'+k+':'+location.hostname)); // сброс в AUTO

    /* ── ОТЛАДКА ────────────────────────────────────────── */
    const DEBUG = false;
    const log = (...args) => { if (DEBUG) console.log(...args); };

    // v3.4.7: визуальное подтверждение успешного буста видео (canplay/loadeddata/rVFC/immediate) —
    // рамка вокруг кнопки на 2с. На хостах с моделью "одно видео за переход" (YouTube/Rutube)
    // срабатывает один раз за визит. На бесконечных лентах (TikTok) — на каждое просмотренное
    // видео, не чаще раз в 2с (естественный cooldown самой рамки), подтверждая что буст
    // продолжает работать на протяжении скролла, а не "сломался" посередине.
    // Не влияет на саму метрику canplay/rVFC — триггерится ПОСЛЕ её записи.
    // По умолчанию включено; false — буст работает как раньше, без визуального сигнала.
    const ENABLE_BOOST_FLASH = true;

    /* ── УСТРОЙСТВО ─────────────────────────────────────── */
    const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
        || (navigator.maxTouchPoints > 1 && window.innerWidth < 1024);
    const MODE = isMobile ? 'Mobile' : 'Desktop';

    /* ── БРАУЗЕР ────────────────────────────────────────── */
    // Перемещено выше _isSlow (v3.1.4): isChromium нужен для rtt/downlink guard.
    // Firefox не поддерживает Network Information API — _slowRtt/_slowDownlink всегда false.
    const isFirefox = typeof InstallTrigger !== 'undefined' || navigator.userAgent.includes('Firefox');
    const isChromium = !isFirefox && !!window.chrome;

    /* ── НАСТРОЙКИ ──────────────────────────────────────── */
    const PAUSE_ON_HIDDEN = true;
    const BTN_BOTTOM = '70px';

    // v3.0.23: LCP-буст для Mixed Content (фото/блоги/новости/SPA).
    // Переприоритизирует in-flight запрос картинки через LCP Observer (быстрое соединение)
    // или проактивно в processNode (медленное соединение). На видеохостингах и AI-чатах
    // не применяется. decoding не трогаем. Двойной работы нет.
    const LCP_IMAGE_BOOST = true;

    // v3.0.23: защита скриптов собственного домена от занижения приоритета.
    // Критичный багфикс для EXTREME_MODE: Vue/React/Next.js чанки с того же домена
    // получали fetchPriority='low', что ломало гидратацию (habr.com — 16с LCP).
    // Флаг отдельный чтобы можно было отключить при тестировании.
    const SAME_ORIGIN_SCRIPT_PROTECT = true;
    // v3.1.6: единая функция защиты скриптов — используется в EXTREME_MODE processNode
    // вместо дублирующейся inline-логики. Заменяет разрозненные проверки:
    //   new URL(node.src).origin === location.origin  — same-origin
    //   vendor путь (node_modules, /assets/, /static/)  — сторонние бандлы встроены в свой домен
    //   data-ihw-protect="true"  — явная пользовательская защита (будущее)
    // DeepSeek: data-ihw-protect нигде не устанавливается сейчас, но хук полезен на будущее.
    const _isProtectedScript = (node) => {
        // Вызывается только для скриптов с node.src (inline без src не доходят сюда).
        // v3.1.7: убран ложный guard !node.src → он скрывал что функция вызывалась только для src-скриптов.
        try {
            const url = new URL(node.src, location.origin);
            if (url.origin === location.origin) return true;  // свой домен
        } catch { return true; }
        if (node.getAttribute('data-ihw-protect') === 'true') return true;
        return false;
    };
	
	// кол-во динамических preconnect для одной вкладки
	const DYNAMIC_PRECONNECT_LIMIT = 3;
	
    /* ── КАЧЕСТВО СОЕДИНЕНИЯ (один раз, синхронно) ──────── */
    // Иерархия: Mobile > effectiveType (OS-кэш, 0ms) > TTFB > rtt/downlink.
    // TTFB доступен с document-start: первый байт уже пришёл до запуска скрипта.
    // Ловит "LTE через кабельный роутер", который effectiveType не видит.
    // Resource Timing по ресурсам — намеренно убран (нужны загруженные ресурсы, поздно).
    const _navEntry = performance.getEntriesByType('navigation')[0];
    // fetchStart вместо requestStart — учитывает время редиректов (yandex.ru→dzen.ru).
    const _ttfb = _navEntry ? Math.round(_navEntry.responseStart - _navEntry.fetchStart) : 0;
    const _conn = navigator.connection; // undefined на Firefox/Safari — штатно

    /* ── ЕДИНАЯ ОЦЕНКА КАЧЕСТВА СОЕДИНЕНИЯ (v3.2.0-fix5) ─
     *  Консенсус DeepSeek/Kimi/Qwen/ChatGPT:
     *  Единый источник истины вместо трёх разных измерителей:
     *    _isSlow (статич.), _isSlowNow() (кэш 2с), _boostSlow (без кэша, без effectiveType).
     *
     *  _staticSlow: isMobile + TTFB — не меняются за время жизни вкладки.
     *  Динамическая часть (только Chromium): saveData, rtt, effectiveType, downlink.
     *    saveData: явное намерение пользователя — приоритет 1.
     *    rtt > 300: ping-спайки невидимые для TTFB (видеосегменты деградируют позже).
     *    effectiveType: weak hint (EWMA, может отставать) — только в комбинации с rtt.
     *    downlink < 2 + rtt > 250: комбинация (ChatGPT) — один downlink нестабилен на Android.
     *  Firefox/Safari: _conn = undefined → только _staticSlow.
     *  Кэш 2с (useCache=true): для hot path (processNode, тысячи вызовов).
     *    Kimi: кэш обновляется ВСЕГДА независимо от ветки — нет баги DeepSeek v1.
     *  Без кэша (useCache=false): для критических точек (lockMs, SPA reset, лог).
     *  Сброс кэша: online/offline/connection.change.
     * ─────────────────────────────────────────────────── */
    const _staticSlow = isMobile || (_ttfb > 600);
    let _lastConnCheck = 0, _lastConnResult = false;
    let _lastSlowReason = ''; // v3.3.3: последний триггер деградации для тултипа кнопки

    function _isConnSlow(useCache = true) {
        const now = performance.now();
        if (useCache && (now - _lastConnCheck < 2000)) return _lastConnResult;

        let result = false;
        if (_staticSlow) {
            result = true;
            if (!useCache) _lastSlowReason = isMobile ? 'mobile' : `TTFB:${_ttfb}ms`;
        } else if (isChromium && _conn) {
			// saveData: прямое намерение → приоритет
            // rtt > 300: ping-спайк (реальное ухудшение)
            // effectiveType ≤ 3g + rtt > 250: комбинация (защита от ложных 3g на desktop)
            // downlink < 2 + rtt > 250: комбинация (стабильнее чем downlink отдельно)
            const ect = _conn.effectiveType;
            const rtt = _conn.rtt || 0;
            if (_conn.saveData === true) {
                result = true;
                if (!useCache) _lastSlowReason = 'saveData:true';
            } else if (rtt > 300) {
                result = true;
                if (!useCache) _lastSlowReason = `rtt:${rtt}ms`;
            } else if (['slow-2g', '2g', '3g'].includes(ect) && rtt > 250) {
                result = true;
                if (!useCache) _lastSlowReason = `effectiveType:${ect}, rtt:${rtt}ms`;
            } else if (_conn.downlink < 2 && rtt > 250) {
                result = true;
                if (!useCache) _lastSlowReason = `downlink:${_conn.downlink}Mbps, rtt:${rtt}ms`;
            }
        }
        // Кэш обновляем ВСЕГДА (фикс бага где _staticSlow=true не кэшировался)
        if (useCache) { _lastConnCheck = now; _lastConnResult = result; }
        return result;
    }

    // Стартовое значение для LCP Observer — выбирает стратегию один раз при старте.
    const _isSlow = _isConnSlow(false);
    // Обратная совместимость для hot path (processNode, _isCriticalNode).
    const _isSlowNow = () => _isConnSlow(true);
    // Единая точка сброса кэша — используется в _resetVideoState и событиях сети.
    const _resetConnCache = () => { _lastConnCheck = 0; };
    window.addEventListener('online',  _resetConnCache);
    window.addEventListener('offline', _resetConnCache);
    if (_conn?.addEventListener) _conn.addEventListener('change', () => {
        _resetConnCache();
        // v3.3.0: mid-session degradation lock.
        // Соединение ухудшилось после старта видео → временно блокируем IMG.
        // Консенсус KIMI/DeepSeek: расширяем существующий listener, не создаём новый.
        if (_isConnSlow(false) && !_videoLocked && _videoBoosted) {
            _videoLocked = true;
            setTimeout(() => { _videoLocked = false; }, 2000);
            log('[IHW] Connection degraded mid-session → temporary IMG lock (2s)');
        }
    });

    /* ── НЕСТАБИЛЬНОСТЬ СОЕДИНЕНИЯ (v3.3.0) ─────────────────
     *  _unstableScore: 0–100, accumulates from Resource Timing + stalled events.
     *  >40 = unstable, >70 = critical. Decay при успешных загрузках.
     *  Единственный источник «текущей нестабильности» — не дублирует _isConnSlow
     *  (_isConnSlow отвечает на «медленная ли сеть», _unstableScore — на «рвётся ли»).
     * ─────────────────────────────────────────────────────── */
    let _unstableScore = 0;

    // v3.2.0-fix3: лог _isSlow перенесён после OFF guard — в OFF консоль должна быть чистой.

    /* ── РЕЖИМ ──────────────────────────────────────────── */
    function _resolveGlobalMode() { return isMobile ? 'EXT' : 'ON'; }
    const _globalAutoMode = _resolveGlobalMode();
    const _localExt = localStorage.getItem('ihw:extreme:' + location.hostname) === '1';
    const _isAutoMode = localStorage.getItem('ihw:auto:' + location.hostname) === '1'
        && !localStorage.getItem('ihw:off:' + location.hostname);
    const EXTREME_MODE = _localExt || (_isAutoMode && _globalAutoMode === 'EXT');

    let _initDone = false;
    const _t0 = performance.now();
    let _spaT0 = _t0; // v3.2.0: обновляется при SPA-переходе → canplay/First frame относительно него
    let _btn = null;
    let _blockedCount = 0;
    let _canplayMs = 0;
    let _firstFrameMs = 0;              // v3.2.0-fix8: loadeddata metric (ChatGPT: отдельно от canplay)
    let _videoBoosted = false;
    let _videoLocked = false; // v3.1.4: Video Dominance Window — временная пауза IMG на видеохостингах
    // v3.4.2: шрифты откладываются только 1 раз за сессию вкладки.
    // При SPA-переходе _deferFontsUntilCanplay вызывается повторно для нового видео —
    // без этого флага шрифты снова уйдут в media='print' и вернутся → FOUT.
    // Канал критичен только при первой загрузке страницы; на SPA-переходах
    // шрифты уже загружены/восстановлены — повторная отсрочка не даёт выигрыша.
    let _fontsDeferredOnce = false;
    let _boostRef = null; // v3.0.23: ref на boostMainVideo — решает TDZ при ранних MO вызовах

    function _getMode() {
        // v3.0.21: sessionStorage читается первым — он переживает VK SPA-навигацию,
        // тогда как VK асинхронно очищает localStorage после перехвата location.reload().
        // При настоящем hard-reload (F5 / Ctrl+Shift+R) sessionStorage сбрасывается
        // браузером сам по себе, и мы корректно падаем обратно на localStorage.
        const h = location.hostname;
        try {
            const ss = sessionStorage.getItem('ihw:cur:' + h);
            if (ss === 'off')  return 'OFF';
            if (ss === 'ext')  return 'EXT';
            if (ss === 'on')   return 'ON';
            if (ss === 'auto') return 'AUTO';
        } catch (e) {}
        if (localStorage.getItem(SITE_KEY) === '1') return 'OFF';
        if (localStorage.getItem('ihw:extreme:' + h) === '1') return 'EXT';
        if (localStorage.getItem('ihw:on:' + h) === '1') return 'ON';
        return 'AUTO';
    }
    function _getModeLabel() {
        // v3.0.22: используем _getMode() — он читает sessionStorage первым,
        // что важно на VK/OK где localStorage может быть очищен SPA-навигацией.
        const mode = _getMode();
        if (mode === 'OFF')  return '[OFF]';
        if (mode === 'EXT')  return '[ON[E]]';
        if (mode === 'AUTO') return `[ON[A]=${EXTREME_MODE ? 'ON[E]' : 'ON'}]`;
        return '[ON]';
    }

    /* ── КНОПКА (hoisted, используется в OFF-guard) ─────── */
    // v3.4.7: визуальное подтверждение успешного буста — box-shadow (не border, чтобы не
    // вызывать reflow/смещение layout кнопки). Видна и при opacity:0.5 (idle-состояние кнопки).
    // clearTimeout перед новым setTimeout — обязательно: без него на лентах с переходами
    // чаще 2с старый отложенный сброс может погасить рамку раньше нового цикла (мерцание).
    // Пропускается если открыты long-press метрики (_metricActive) — не конфликтует визуально.
    let _flashTimer = null;
    const _flashBoostSuccess = () => {
        if (!ENABLE_BOOST_FLASH || !_btn || _btn._metricActive) return;
        _btn.style.boxShadow = '0 0 0 2px #4ade80';
        clearTimeout(_flashTimer);
        _flashTimer = setTimeout(() => { if (_btn) _btn.style.boxShadow = ''; }, 2000);
    };

    function _renderBtn() {
        const mode = _getMode();
        const slow = _isConnSlow(false); // синхронно, без кэша — момент перерисовки кнопки
        const btn = document.createElement('button');
        // v3.4.7-fix: стабильный id — нужен чтобы EXTREME_MODE-CSS (box-shadow:none!important
        // на html.ihw-extreme *) мог точечно исключить именно эту кнопку через :not().
        // Без этого _flashBoostSuccess() молча перекрывался глобальным правилом — буст
        // реально срабатывал (видно по логам), но рамка не отображалась только в ON[E].
        btn.id = 'ihw-btn';
        const bg = mode === 'OFF' ? '#888' : mode === 'EXT' ? '#7a4a1e' : mode === 'AUTO' ? '#3a5a3a' : '#5a9fd4';
        const fg = mode === 'OFF' ? '#ddd' : '#fff';
        const lbl = mode === 'OFF' ? 'OFF' : mode === 'EXT' ? 'ON[E]' : mode === 'AUTO' ? 'ON[A]' : 'ON';
        // v3.2.2: визуальный индикатор медленного соединения.
        // Добавляем «·» к тексту и янтарный цвет лейбла — минимально, без изменения размера/layout.
        // Не создаём дополнительных DOM-элементов — только цвет текста и title.
        // Обновляется при каждой перерисовке кнопки (SPA-nav, F5, Ctrl+Shift+R, смена режима).
        // _isConnSlow(false) — без кэша, точный текущий замер (уже вычислен к этому моменту).
        const slowDot   = slow ? ' \u2193' : '';     // v3.3.3: ↓ вместо · при деградации
        const lblColor  = slow ? '#ffd166' : fg;	// янтарный (#ffd166) vs обычный белый
        const slowTitle = slow && _lastSlowReason
            ? `Деградация: ${_lastSlowReason}`
            : '';
        btn.style.cssText = [
            'position:fixed', 'bottom:' + BTN_BOTTOM, 'right:12px', 'z-index:2147483647',
            'font-size:11px', 'padding:3px 7px', 'border:none', 'border-radius:4px',
            'cursor:pointer', 'opacity:0.5', 'transition:opacity .2s,background .2s',
            'font-family:system-ui,sans-serif', 'line-height:1.4',
            `background:${bg};color:${lblColor}`
        ].join(';');
        btn.textContent = lbl + slowDot;
        btn._originalText = lbl + slowDot;
        btn._metricActive = false;
        if (slowTitle) btn.title = slowTitle;

        btn.addEventListener('mouseenter', () => {
            if (btn._metricActive) return;
            const tips = { ON: 'Вкл. ускорение Extreme? ON[E]', OFF: 'Вкл. обычное ускорение? (ON)', EXT: 'Вкл. режим Авто? ON[A]', AUTO: 'Выкл. ускорение? OFF' };
            btn.textContent = tips[mode] || mode; btn.style.opacity = '0.95';
        });
        btn.addEventListener('mouseleave', () => {
            btn.style.opacity = '0.5';
            if (!btn._metricActive) btn.textContent = btn._originalText;
        });

        let _pressTimer = null, _longFired = false;
        const _startPress = () => {
            _longFired = false;
            _pressTimer = setTimeout(() => {
                _longFired = true;
                const nav = performance.getEntriesByType('navigation')[0];
                if (!nav) return;
                const ttfb = Math.round(nav.responseStart - nav.fetchStart);
                const dom = document.getElementsByTagName('*').length;
                const kb = nav.transferSize ? Math.round(nav.transferSize / 1024) : 0;
                const savedBg = btn.style.background;
                const disp = mode === 'AUTO' ? `ON[A]=${_globalAutoMode === 'EXT' ? 'ON[E]' : 'ON'}` : mode;
                btn._metricActive = true;
                // v3.4.7: start:Nms — время старта видео (canplay/loadeddata/rVFC/immediate),
                // не привязано к ENABLE_BOOST_FLASH (отдельная настройка). На видеохостингах
                // с моделью "одно видео за переход" — фиксировано на весь визит. На лентах
                // (TikTok) — обновляется на каждое просмотренное видео (см. t0 в _boostVideoElement).
                btn.textContent = `TTFB:${ttfb} DOM:${dom > 999 ? (dom / 1000).toFixed(1) + 'k' : dom} ↓${kb || 'кэш'}kb ✕${_blockedCount} mode:${disp}${_canplayMs ? ' start:' + _canplayMs + 'ms' : ''}`;
                btn.style.cssText += ';font-size:9px;white-space:nowrap';
                setTimeout(() => {
                    if (btn) { btn._metricActive = false; btn.textContent = btn._originalText; btn.style.background = savedBg; btn.style.fontSize = '11px'; btn.style.whiteSpace = ''; }
                }, 4000);
            }, 600);
        };
        btn.addEventListener('pointerdown', _startPress, { passive: true });
        btn.addEventListener('pointerup', () => clearTimeout(_pressTimer), { passive: true });
        btn.addEventListener('pointerleave', () => clearTimeout(_pressTimer), { passive: true });
        btn.addEventListener('pointercancel', () => clearTimeout(_pressTimer), { passive: true });

        btn.addEventListener('click', () => {
            if (_longFired) { _longFired = false; return; }
            const cur = _getMode(), h = location.hostname;

            // Карусель режимов: AUTO → OFF → ON → EXT → AUTO
            const nextMode = cur === 'AUTO' ? 'OFF' : cur === 'OFF' ? 'ON' : cur === 'ON' ? 'EXT' : 'AUTO';

            // v3.0.21: sessionStorage 'ihw:cur' — основной мост через VK SPA-навигацию.
            // _getMode() читает его первым. При настоящем hard-reload sessionStorage
            // сбрасывается браузером, мы корректно падаем на localStorage.
            const ssKey = { 'OFF': 'off', 'ON': 'on', 'EXT': 'ext', 'AUTO': 'auto' }[nextMode];
            try { sessionStorage.setItem('ihw:cur:' + h, ssKey); } catch (e) {}

            // localStorage — для hard-reload и сайтов без SPA-перехвата reload()
            ['off', 'extreme', 'auto', 'on'].forEach(k => localStorage.removeItem('ihw:' + k + ':' + h));
            if (nextMode === 'OFF')  localStorage.setItem(SITE_KEY, '1');
            else if (nextMode === 'EXT')  localStorage.setItem('ihw:extreme:' + h, '1');
            else if (nextMode === 'ON')   localStorage.setItem('ihw:on:' + h, '1');
            else if (nextMode === 'AUTO') localStorage.setItem('ihw:auto:' + h, '1');

            // Немедленно перерисовываем кнопку — на VK SPA reload() перехватывается
            // и скрипт не реинициализируется, поэтому это единственный визуальный отклик.
            if (_btn) { _btn.remove(); _btn = null; }
            _renderBtn();

            location.reload();
        });

        _btn = btn;
        document.documentElement.appendChild(btn);
    }

    /* ── OFF GUARD: полное молчание ─────────────────────── */
    const SITE_KEY = 'ihw:off:' + location.hostname;
    // v3.2.0: расширен до _getMode() — ловит sessionStorage-based OFF (Ctrl+Shift+R тест).
    // До этого: только localStorage check → _isSlow лог выводился в консоль в режиме OFF.
    // _isSlow вычисляется раньше (строка ~130) — его лог переносим после guard невозможно
    // без рефакторинга, поэтому guard проверяет ОБА хранилища.

    if (_getMode() === 'OFF') {
        _renderBtn();
        return;
    }

    /* ── LCP METRIC OBSERVER (только DEBUG, только не-OFF) ── */
    // v3.2.0-fix5: обновлён под новую логику _isConnSlow (комбинации rtt+ect, rtt+downlink).
    if (DEBUG) {
        if (_isSlow) {
            const _slowReason = [
                isMobile                                                                       && 'mobile',
                (_ttfb > 600)                                                                  && `TTFB:${_ttfb}ms`,
                (isChromium && _conn?.saveData)                                                && 'saveData:true',
                (isChromium && _conn?.rtt > 300)                                               && `rtt:${_conn.rtt}ms`,
                (isChromium && ['slow-2g','2g','3g'].includes(_conn?.effectiveType) && (_conn?.rtt||0) > 250)
                                                                                               && `ect:${_conn.effectiveType}+rtt:${_conn.rtt}ms`,
                (isChromium && (_conn?.downlink||99) < 2 && (_conn?.rtt||0) > 250)             && `dl:${_conn.downlink}Mbps+rtt:${_conn.rtt}ms`,
            ].filter(Boolean).join(', ');
            log(`[IHW] _isSlow=true (${_slowReason})`);
        } else {
            log(`[IHW] _isSlow=false. TTFB:${_ttfb}ms, ect:${_conn?.effectiveType || 'n/a'}, rtt:${_conn?.rtt ?? 'n/a'}ms`);
        }
    }
    // Показывает: элемент, размер, время, fetchPriority и кто его назначил.
    // v3.0.23: `assigned-by` теперь смотрит и data-ihw-lcp-boosted (LCP Image Boost)
    if (DEBUG && window.PerformanceObserver) {
        try {
            new PerformanceObserver(list => {
                const e = list.getEntries().pop();
                if (!e) return;
                const el = e.element;
                const tag = el
                    ? el.tagName + (el.id ? '#' + el.id : '') + (el.className ? '.' + String(el.className).trim().split(/\s+/)[0] : '')
                    : '(no element)';
                const fp = el?.fetchPriority || el?.getAttribute?.('fetchpriority') || 'unset';
                const who = el?.hasAttribute?.('data-ihw-lcp-boosted') ? 'IHW[LCP]'
                          : el?.hasAttribute?.('data-ihw-boosted') ? 'IHW[video]' : 'browser';
                console.log(`[IHW DEBUG] LCP: ${tag} | ${(e.size / 1024).toFixed(1)}KB | ${Math.round(e.startTime)}ms | fetchPriority:${fp} | assigned-by:${who}`);
            }).observe({ type: 'largest-contentful-paint', buffered: true });
        } catch (e) { }
    }

    /* ── МЕТРИКИ (только DEBUG, только не-OFF) ───────────── */
    function _logExtendedMetrics(modeLabel) {
        const nav = performance.getEntriesByType('navigation')[0];
        if (!nav || nav.loadEventEnd <= 0) {
            setTimeout(() => _logExtendedMetrics(modeLabel), 100);
            return;
        }
        const ttfb = Math.round(nav.responseStart - nav.fetchStart);
        const dDCL = Math.round(nav.loadEventEnd - nav.domContentLoadedEventEnd);
        const loadMs = Math.round(nav.loadEventEnd - _t0);
        const dom = document.getElementsByTagName('*').length;
        const kb = nav.transferSize ? Math.round(nav.transferSize / 1024) : 0;

        let line = `[IHW] ${modeLabel} ${location.hostname} | ` +
                   `TTFB:${ttfb}ms load:${loadMs}ms ΔDCL:${dDCL}ms ` +
                   `DOM:${dom} KB:${kb || 'cache'} ✕${_blockedCount}`;
        if (_canplayMs) line += ` start:${_canplayMs}ms${_firstFrameMs && _firstFrameMs !== _canplayMs ? `(frame:${_firstFrameMs}ms)` : ''}`;
        // v3.0.23: итоговые флаги активных буст-механизмов в одну строку
        const _lcpBoostEl = document.querySelector('[data-ihw-lcp-boosted]');
        if (LCP_IMAGE_BOOST && PAGE === 'Mixed Content' && !shouldSkipScroll) {
            line += _lcpBoostEl
                ? ` LCP:IHW✓(${_lcpBoostEl.src?.split('/').pop()?.slice(0,20) || 'img'})`
                : ` LCP:browser✓`;
        }
        const _sameOriginScripts = SAME_ORIGIN_SCRIPT_PROTECT && EXTREME_MODE
            ? document.querySelectorAll('script[src][fetchpriority]:not([fetchpriority="low"])').length
            : 0;
        if (SAME_ORIGIN_SCRIPT_PROTECT && EXTREME_MODE) {
            line += ` SOP:${_sameOriginScripts > 0 ? _sameOriginScripts + '✓' : '0(нет своих)'}`;
        }
        console.log(line);
    }

    /* ── БРАУЗЕР (лог) ──────────────────────────────────── */
    log(`[IHW] Browser: ${isFirefox ? 'Firefox' : isChromium ? 'Chromium' : 'Other'}`);

    // v3.5.12: Zetflix-class (cdnvideohub-плеер, мультиисточник).
    // АРХИТЕКТУРА: страница встраивает player.cdnvideohub.com/s2/.../frame/ как cross-origin
    // iframe; сам iframe — мультиисточниковый агрегатор (OKcdn, VKVideo, interkh.com и др.).
    //
    // player.cdnvideohub.com — iframe-хост плеера (GET /s2/v2.8.0/frame/). Аналог
    //   player.vimeo.com: стабильный production entry-point. Preconnect прогревает
    //   TLS до монтажа iframe → ускоряет появление плеера. → 0ms.
    //
    // plapi.cdnvideohub.com — API плейлистов (XHR GET /api/v1/player/sv/playlist?pub=…&aggr=kp&id=…
    //   и /api/v1/player/sv/video/…). Критичен: без ответа плеер не знает CDN-ноду источника.
    //   Запросы идут из main window (video-player.umd.js на странице хостинга) → виден
    //   XHR-хуку. → 0ms.
    //
    // Ротационные CDN сегментов (vdNNN.okcdn.ru, 6c-97-be-r504.vkvideo.cloud,
    //   *.interkh.com, *.mycdn.me) — все внутри cross-origin iframe → XHR/fetch-хук
    //   main window их не видит. Структурное ограничение, не решается без инвазивных методов.
    //   Частично покрываются динамическим preconnect через _segmentUrlRe (/player/sv/).
    const _ZFLX_CDNS = ['player.cdnvideohub.com', 'plapi.cdnvideohub.com'];
    // Хосты-потребители этого плеера (используется и здесь, и в VIDEO_CDN_MAP ниже).
    // Один массив — одна правка при добавлении новых доменов Zetflix или аналогов.
    const _ZFLX_HOSTS = ['zetflix.bet', 'zetflix.to', 'zetflix.online', 'zetflix.app'];
	const _YT_HOSTS = ['youtube.com', 'youtu.be'];   // Youtube Group
	// v3.5.14-fix: ИСПРАВЛЕНО — ранее ошибочно решил не добавлять LordFilm в VIDEO_HOSTS,
	const _LF_HOSTS = ['lordfilm.org', 'lordfilm.fi', 'lordfilms.lt', 'lordfilm.top', 'lordfilm.hk', 'llordfilm.ru'];
	// v3.5.15: KINOGO — тот же класс агрегатора, что LordFilm/Zetflix. Не добавляется в
	// VIDEO_HOSTS отдельным комментарием-исключением (как раньше ошибочно для LordFilm) —
	// добавляется сразу и туда, и в VIDEO_CDN_MAP (см. ниже), т.к. isVideoHost чисто
	// URL-based, DOM-эвристики нет (см. историю правок LordFilm).
	const _KG_HOSTS = ['kinogo.biz', 'kinigo.ec', 'kinogo.pl', 'kinogo.parts', 'kinogo.eu', 'kinogo.la', 'kinogo.film', 'kinogo.se', 'kinogo.net'];
	// v3.5.16: LIVETV.SX — та же прозрачная same-origin iframe-обёртка
	// (webplayer.iframe.php), что и smotret.tv. Реальный CDN (emb.apl423.me,
	// azplay43.me, YouTube-бэкенд) — за той же window.top-границей, которую
	// сознательно не пересекаем (см. историю правок про smotret.tv). В
	// VIDEO_CDN_MAP записи НЕТ — преконнектить нечего, main.src всегда
	// same-origin (_doPreconnect его пропускает штатно). Польза здесь —
	// не preconnect, а fetchPriority='high'/loading='eager' на самом
	// iframe-документе (см. boostMainVideo) — ускоряет старт первого хопа
	// цепочки, не сам CDN за ним. Известных алиасов домена пока нет —
	// один хост, легко расширить массив при появлении данных.
	const _LTV_HOSTS = ['livetv.sx'];
	
    /* ── ТИП СТРАНИЦЫ ───────────────────────────────────── */
    const VIDEO_HOSTS = [
        'vimeo.com', 'rutube.ru', 'twitch.tv',
        'dailymotion.com', 'ok.ru', 'vk.com', 'vkvideo.ru', 'video.mail.ru',
        'my.mail.ru', 'bilibili.com', 'bilibili.tv', 'tiktok.com', 'odysee.com',
        'platform.rambler.ru', 'kinopoisk.ru', 'smotret.tv',
        ..._ZFLX_HOSTS, ..._YT_HOSTS, ..._LF_HOSTS, ..._KG_HOSTS, ..._LTV_HOSTS
    ];
    const VIDEO_HOST_EXCEPTIONS = ['alice.yandex.ru'];
    const VIDEO_PATH_SEGMENTS = new Set(['video', 'videos', 'live', 'clip', 'stream', 'watch', 'player']);
    /* ── PRECONNECT АРХИТЕКТУРА (v3.2.0) ────────────────────
     *  VIDEO_CDN_MAP — единственный источник истины для статических CDN.
     *  STATIC_PRECONNECT_HOSTS — производный Set, автоматически из .flat().
     *    Set дедуплицирует: ytimg.com (youtube.com + youtu.be) → одна запись.
     *    Защита от дурака: дубликаты в MAP → Set проглотит без шума.
     *  STATIC_DELAYS — хосты с задержкой (geo-CDN, не мешаем критичным ресурсам старта).
     *  initVideoPreconnect: CDN только текущего хоста с учётом задержек из STATIC_DELAYS.
     *  _isVideoCDN: STATIC_PRECONNECT_HOSTS.has() + regex для динамических CDN (SABR, HLS-ноды).
     *    "Для любого хоста" = глобальный поиск по всем CDN, не только VIDEO_CDN_MAP[location.hostname].
     *    Нужно для EXTREME_MODE LINK guard: тег vimeocdn.com может появиться на youtube.com.
     *  _dynamicPreconnect: вызывается из boostMainVideo на currentSrc/source/iframe.src.
     * ─────────────────────────────────────────────────────── */

    // Shared CDN-массивы для хостов с одинаковым набором — одно место редактирования.
    // v3.5.1: _VK_CDNS УДАЛЁН — подтверждено по 3 независимым логам (vk.com, vkvideo.ru,
    // ok.ru): userapi.com и vkuser.net ни разу не встречаются как реальный host запроса —
    // только пронумерованные поддомены (sun9-NN.userapi.com, vk6-NN.vkuser.net), которые
    // exact-match Set.has() в STATIC_PRECONNECT_HOSTS не покрывает в принципе. Тот же
    // паттерн что у googlevideo.com (см. _YT_CDNS ниже) и rutube.ru (убран в v3.4.8) —
    // CDN с ротацией edge-нод: статика к apex-домену бесполезна, ловит только _dynamicPreconnect.
    const _YT_CDNS = ['i.ytimg.com', 'yt3.ggpht.com'];
    // googlevideo.com НАМЕРЕННО УБРАН из YT: SABR выбирает rr2---sn-*.googlevideo.com динамически,
    // статический preconnect к bare-домену → wrong origin ~80% → только _dynamicPreconnect.
    // i.ytimg.com/yt3.ggpht.com — подтверждённые устоявшиеся домены (i.ytimg.com = миниатюры,
    // yt3.ggpht.com = аватарки). Задержка 300ms (STATIC_DELAYS) — не конкурируют с видеопотоком.

    // Dailymotion CDN: подтверждено по логам и Network-скриншотам.
    // geo.dailymotion.com — гео-роутинг/манифест, на критическом пути к canplay → 0ms.
    // s1.dmcdn.net, s2.dmcdn.net — превью/аватарки, аналог ytimg.com/ggpht.com → 300ms.
    // static1.dmcdn.net (шрифты) — НЕ добавляется: _deferFontsUntilCanplay намеренно
    // задерживает шрифты до canplay, преконнект к шрифтовому CDN до видео противоречит этому.
    const _DM_CDNS = ['geo.dailymotion.com', 's1.dmcdn.net', 's2.dmcdn.net'];

    const VIDEO_CDN_MAP = {
		// v.3.5.13-fix1
		// оптимизация описания хостов и доменов для Youtube Group
		...(Object.fromEntries(_YT_HOSTS.map(h => [h, [..._YT_CDNS]]))),
        // v3.4.8: rtbcdn.ru убран: рекламный/вспомогательный CDN Rutube — только динамический. Реальный CDN — ротационные поддомены вида river-N-NNN.rtbcdn.ru (как googlevideo.com у
        // YouTube) — статически их прописать невозможно, ловятся только динамически
        // через XHR-хук ниже (Rutube грузит HLS-сегменты через XMLHttpRequest, не fetch).
        // rtbcdn.ru убран: рекламный/вспомогательный CDN Rutube — только динамический.
        'dzen.ru':        ['static.dzeninfra.ru', 'iv.okcdn.ru', 'avatars.dzeninfra.ru'],
        // v.3.5.11
		// static.dzeninfra.ru = Все React-бандлы плеера, layout, bundles. Критическая: без плеера нет video-элемента. Критическая: FCP полностью зависит.  Это основной CDN для всех React-бандлов Dzen: content-long-desktop.modern.bundle.js, dzen-layout-desktop.modern.bundle.js, react-with-dom.min.js — всё грузится именно отсюда. Без прогрева соединения браузер ждёт DNS+TLS перед каждым чанком. В логе видно 6 Prefetch removed: https://static.dzeninfra.ru/... — в EXTREME_MODE мы сохраняес их preload-теги, чтобы не замедляет инициализацию плеера.
		// iv.okcdn.ru = Постер/превью видео (/videoPreview?type=32).Важная: постер появляется до canplay.Важная: poster = LCP-кандидат
		// avatars.dzeninfra.ru = Превью карточек ленты (scale_720, smart_crop). Важности для canplay нет. Важность Средняя: LCP ленты.
		// avatars.mds.yandex.net = Аватары пользователей в комментариях. Важность для canplay/LCP: низкая.
		// 
        // v3.5.6: 'vimeocdn.com' (apex) УДАЛЁН — реальные CDN-хосты видеосегментов суть поддомены
        // (vod-adaptive-ak.vimeocdn.com / skyfire.vimeocdn.com), браузер не переиспользует
        // соединение с apex для поддоменов. Подтверждено тремя независимыми сессиями:
        // session-1: vod-adaptive-ak.vimeocdn.com (Akamai), session-2: skyfire.vimeocdn.com,
        // session-3: снова vod-adaptive-ak.vimeocdn.com. Ротация между CDN-провайдерами
        // непредсказуема статически — dynamic XHR-хук ловит оба через [?&]range= паттерн.
        // player.vimeo.com — стабильный config-endpoint (200 OK во всех сессиях),
        // обязателен для инициализации плеера → 0ms.
        // f.vimeocdn.com — CDN для player.js (335 KB, критичен для воспроизведения)
        // + Next.js чанки страницы → 300ms (STATIC_DELAYS), не конкурирует с видеопотоком.
        // skyfire.vimeo.com (без cdn) — гипотеза DeepSeek, не подтверждена ни в одном
        // из трёх логов и скринов; odgcdn.com — OCR-ошибка DeepSeek (y→g), домена нет.
        'vimeo.com':      ['player.vimeo.com', 'f.vimeocdn.com'],
        // v3.5.1: 'vk.com'/'vkvideo.ru' (_VK_CDNS) и 'ok.ru' УДАЛЕНЫ из карты.
        // VK Group (vk.com + vkvideo.ru + ok.ru) использует ОБЩУЮ CDN-инфраструктуру
        // с ротацией edge-нод на всех пяти доменах сразу (userapi.com, vkuser.net,
        // mycdn.me, okcdn.ru, cdnvideo.ru) — подтверждено по логам всех трёх хостов:
        // реальные запросы идут на sun9-NN.userapi.com / vk6-NN.vkuser.net /
        // vdNNN.okcdn.ru / vkvdNNN.okcdn.ru, ни разу не на bare-домен. Преконнект
        // к apex-домену открывает соединение, которое физически не переиспользуется
        // браузером для запроса к другому hostname (даже под одним wildcard-CDN) —
        // подтверждено: dynamic-хук всегда открывает ОТДЕЛЬНОЕ соединение к
        // конкретной поднод momента спустя, статическое не помогает вообще.
        // Динамический XHR/fetch-хук (regex с v3.5.0, bytes=N-M паттерн) ловит
        // реальный CDN-узел для всех пяти доменов одинаково, без статики.
        //
        // ── VK GROUP (vk.com / vkvideo.ru / ok.ru) ──────────────────────────────────
        // v3.5.9-fix4: добавлены CDN-резолверы VK Group.
        // АРХИТЕКТУРА: двухэтапная — сначала резолвер получает signed URL,
        // затем браузер идёт напрямую на edge-ноду (ротационную).
        //
        // api.vk.com — CDN URL resolver: POST /method/video.getPlayerConfig
        //   → возвращает signed URL к vkvdNNN.okcdn.ru / vkN-NN.vkuser.net.
        //   Без этого ответа плеер не знает CDN-ноду. Sec-Fetch-Site: cross-site,
        //   200 OK, подтверждён в 2/2 сессиях vkvideo.ru и vk.com. → 0ms.
        //   Аналог api.bilibili.tv→upos-* и bl.rutube.ru в архитектуре VK.
        //
        // api.okcdn.ru — OK CDN player init (POST /fb.do, cross-site, 2/2 сессиях,
        //   200 OK). Плеер окончательно не инициализируется без ответа. → 0ms.
		//
        // vkvdNNN.okcdn.ru / vkN-NN.vkuser.net — ротационные CDN видеопотока:
        //   НЕ в статике (session-1: vkvd452.okcdn.ru, session-2: vk6-9.vkuser.net).
        //   Ловятся dynamic 'direct'-путём из video.currentSrc.
        //   _isVideoCDN покрывает оба через 'okcdn' (fix4) + 'vkuser' (уже был).
		// v3.5.11: добавлены api.vkvideo.ru и st.vkvideo.ru.
		//
		// api.vkvideo.ru — API плеера vkvideo.ru (POST /method/video.getSnackBarInfo,
		//   video.getVideoDiscover, video.trackPlayerEvents). Критичен для инициализации
		//   плеера на vkvideo.ru. Стабильный домен, 0ms.
		//
		// st.vkvideo.ru — статика JS/CSS плеера (dist/projects/vkvideo-web/chunks/js/*,
		//   css/al/*). Стабильный домен, ускоряет FCP. 1500ms (STATIC_DELAYS) —
		//   не конкурирует с dynamic preconnect к CDN видеопотока.
		//   Примечание: st1-N.vkvideo.ru / st1-N.vk.com (ротационный суффикс N=1..20+)
		//   НЕ добавляются ни в static, ни в regex — хрупкий хост.
        'vk.com':      ['api.vk.com', 'api.vkvideo.ru', 'api.okcdn.ru', 'st.vkvideo.ru'],
        'vkvideo.ru':  ['api.vkvideo.ru', 'api.vk.com', 'api.okcdn.ru', 'st.vkvideo.ru'],
		// st.okcdn.ru — st.okcdn.ru/static/MegaPlayer/10-12-24/okHtml5Player.main.html, max-age=31536000 (1 год кэш). 
		// Стабильный хост плеера OK.ru — аналог player.vimeo.com. Инициализация плеера начинается с его ответа. Добавляем в статику → 0ms.
		//
		// st-ok.cdn-vk.ru — иконки/утилиты (большинство)/стили (stringUtils, dateTimeUtils, lazy-icons). 200 OK (from disk cache) 		 
		// iv.okcdn.ru - видео preview (не основная задача, не добавляем)	
		// vdNNN.okcdn.ru / okN-NN.vkuser.net (session-1: vd698, session-2: vd409) — ротационные edge-ноды DASH-сегментов. URL паттерн /expires/TIMESTAMP/clientType/.../fn/sNNN.a.m4s → .m4s
		// ловится _segmentUrlRe → Dynamic preconnect via fetch (1/3). Уже работает правильно, менять ничего не нужно.
        'ok.ru':       ['st-ok.cdn-vk.ru', 'st.okcdn.ru'],
        // v3.5.5: 'dailymotion.com': ['dmcdn.net'] УДАЛЕНО. Седьмое подряд подтверждение
        // того же паттерна — голый dmcdn.net встречается только в наших собственных строках
        // лога, реальные ресурсы идут с поддоменов (static1.dmcdn.net для шрифтов/иконок,
        // vodN.cf.dmcdn.net для .m4s-сегментов) — ни один не покрывается exact-match Set.
        // Отдельное ограничение (НЕ решается этим удалением, не планируется решать):
        // .m4s-сегменты (vodN.cf.dmcdn.net) не ловятся ни XHR-хуком, ни fetch-хуком,
        // ни даже PerformanceObserver — подтверждено двумя раундами диагностики (тестовый
        // хук + контрольный PO, второй раз с увеличенным окном и стартом сразу на F5,
        // с визуально подтверждённой сетевой активностью в Network во время теста).
        // Два вероятных механизма, неразличимых по консоли: (1) раннее связывание —
        // dmp.photon_vendor бандл захватывает ссылку на оригинальный XMLHttpRequest.open
        // раньше любого монки-патча (нашего и даже вручную вставленного тестового);
        // (2) подписанный/зашифрованный путь сегмента (sec2(...) в URL) не отдаёт
        // Timing-Allow-Origin → запись может вообще не попадать в Resource Timing для
        // строгих реализаций спеки. Оба варианта — вне досягаемости без хрупких методов
        // (тот же класс решения что отклонён для VK cross-origin iframe и Twitch Worker).
        // ── TWITCH.TV ─────────────────────────────────────────────────────────────
        // v3.5.7: добавлена CDN-карта Twitch после v3.5.3-удаления.
        // АРХИТЕКТУРА: Amazon IVS Player (WASM Worker) — все сегменты грузятся внутри
        //   Web Worker (amazon-ivs-wasmworker.min-*.wasm), self.fetch Worker'а не перехватывается
        //   window.fetch/XHR-хуком главного потока. Структурное ограничение — не решается
        //   без инвазивного патчинга Worker'а (ломко, вне философии IHW).
        //   _dynamicPreconnect(video) 'direct'-путь частично ловит ранний CloudFront-ресурс
        //   (manifest на главном потоке до передачи Worker'у) — единственное покрытие для CDN.
        //
        // k.twitchcdn.net — КЛЮЧЕВАЯ НАХОДКА: путь p.js идентичен в обеих сессиях
        //   (149e9513-01fa.../2d206a39-8ed7.../p.js). Deployment/version UUID — стабилен
        //   между сессиями, меняется только с деплоем Twitch. Роль: ранний JS-бандл
        //   (~70KB, type=script, Sec-Fetch-Site: cross-site, no-cors), загружается
        //   ДО основного React-бандла twitch.tv. Preconnect 0ms ускоряет LCP страницы
        //   → быстрее инициализация React → быстрее монтаж плеера → косвенно rVFC.
        //   Диск-кэш у повторных визитов, для первого визита — реальный выигрыш.
        //   → 300ms (STATIC_DELAYS): не конкурирует с critical path Worker→CDN.
        //
        // *.cloudfront.net — реальный CDN сегментов Twitch (live: d1ndex63qxojbr,
        //   VOD: dgeft87wbj63p). Per-broadcast distribution ID — ротируется каждую
        //   сессию, статически непредсказуем. Ловится 'direct'-путём из currentSrc.
        //   EXTREME_MODE gap: cloudfront НЕ в _isVideoCDN regex (слишком широко —
        //   Amazon CDN для тысяч сайтов), но STATIC_PRECONNECT_HOSTS через 'k.twitchcdn.net'
        //   + _isVideoCDN 'twitchcdn' покрывают защиту нашего k.twitchcdn.net preconnect.
        //   Решение для cloudfront EXTREME_MODE — см. комментарий в _isVideoCDN ниже.
        //
        // player.twitch.tv — в 3.5.3 удалён: ни разу не встречался как :authority
        //   реального запроса (только наша строка preconnect-лога). Не возвращать.
        // usher.ttvnw.net — master m3u8 Twitch (GET /vod/NNN.m3u8?acmb=…). Покрыт
        //   'ttvnw' regex в _isVideoCDN. Но запросы идут из Worker → fetch-хук не видит.
        //   Не добавлять в статику: preconnect с main thread бесполезен для Worker'а.
        // static-cdn.jtvnw.net — аватары каналов (profile_image-70x70.png, no-cache).
        //   Декоративные, не влияют на canplay/rVFC → не добавлять.
        // player.stats.live-video.net, global.poe.live-video.net — аналитика/телеметрия.
        //   Блокируются uBlock, не нужны → не добавлять.
        // assets.twitch.tv — same-origin JS (twitch.tv). Preconnect к same-origin
        //   бессмысленен: соединение уже открыто от навигации.
        'twitch.tv':      ['k.twitchcdn.net'],
        // v3.5.3: 'twitch.tv': ['player.twitch.tv'] УДАЛЕНО. Подтверждено логом: домен
        // ни разу не встречается как реальный host — только наша же строка преконнекта.
        // Реальный CDN — *.cloudfront.net с per-broadcast distribution ID (меняется
        // каждую сессию, статически непредсказуем — тот же паттерн что у всех
        // остальных видеохостингов с ротацией CDN-нод).
        // Отдельное архитектурное ограничение (НЕ решается этим удалением и не
        // планируется решать): Twitch использует Amazon IVS Player — сегменты грузятся
        // ВНУТРИ Web Worker (amazon-ivs-wasmworker, WASM-демуксер), отдельный JS-realm
        // с собственным self.fetch. window.fetch/XMLHttpRequest.prototype хуки на главном
        // потоке физически не видят запросы из Worker'а — структурно то же ограничение
        // что у VK в cross-origin iframe. Патчить Worker (перехват new Worker() +
        // инъекция в scope) — инвазивный хак, ломкий при любом изменении бандла Twitch,
        // сознательно не реализуется (вне философии минимального overhead).
        // _dynamicPreconnect(video)'s 'direct'-путь иногда ловит ранний CloudFront-ресурс
        // (manifest/bootstrap на главном потоке, до передачи управления воркеру) — это
        // единственное частичное покрытие что у нас есть для Twitch, без статики.
        // ttvnw.net/jtvnw.net — HLS-ноды Twitch → аналог googlevideo.com → только _dynamicPreconnect.
        // v3.5.2: 'bilibili.com': ['bilivideo.com'] УДАЛЕНО. Подтверждено логом (bilibili.tv,
        // XHR Type, Network-скриншоты): bare bilivideo.com ни разу не реальный host —
        // только upos-sz-mirrorcosbstar1.bilivideo.com. Хуже того, Bilibili использует ДВА
        // полностью разных CDN-провайдера в одной сессии — собственный bilivideo.com И
        // ── BILIBILI.TV ────────────────────────────────────────────────────────────
        // v3.5.8: добавлен api.bilibili.tv — резолвер CDN-URL для DASH-сегментов.
        // АРХИТЕКТУРА: api.bilibili.tv/intl/gateway/web/v2/ugc/playlist → возвращает
        // реальные URL к upos-* CDN-нодам. Без ответа от api.bilibili.tv плеер не
        // знает, с какого CDN грузить .m4s сегменты. Same-site (bilibili.tv-семейство).
        // Подтверждён 2/2 сессиях (200 OK). mb_meta_s:609ms→mb_meta_e:1945ms → preconnect
        // сокращает этот 1.3s gap на 1-2 RTT. → 0ms (первым в массиве, критический путь).
        //
        // api.bilibili.tv/ugc/playlist → CDN URLs:
        //   upos-bstar1-mirrorakam.akamaized.net  — Akamai (session-1 + session-2 primary)
        //   upos-sz-mirrorcosbstar1.bilivideo.com — Bilibili собственный CDN (session-2 secondary)
        // Оба ротационные → не в статике. _segmentUrlRe ловит через .m4s, _isVideoCDN
        // через 'bilivideo'. akamaized.net — generic Akamai, не добавляем (слишком широко).
        //
        // i0.hdslb.com — НЕ добавлять: конфиг-endpoint китайской Bilibili-инфраструктуры
        //   (/bfs/polaris_web_conf/…/event.cnf), загружается поздно (~80s в таймлайне),
        //   не участвует в CDN-цепочке видео. cross-site запрос с conditional GET.
        // s1.hdslb.com — НЕ добавлять: reporter/analytics SDK, блокируется uBlock.
        // data.bilibili.tv — НЕ добавлять: аналитика/логирование (log/web?...), блокируется.
        'bilibili.tv':    ['api.bilibili.tv'],
        // ── TIKTOK.COM ────────────────────────────────────────────────────────────
        // v3.5.8: актуализирован комментарий (v3.5.4 устарел).
        // АРХИТЕКТУРА: TikTok захватывает window.fetch через eval/Function-контекст
        // (Initiator VM317:23) — большинство видеозапросов (2.8MB, 4.7MB .mp4) IHW
        // не видит. НО: в v3.5.5+ dynamic preconnect частично работает:
        //   fetch (1/3): v16-webapp-prime.tiktok.com  — через /video/tos/ матч в _segmentUrlRe
        //   xhr   (2/3): v16-webapp.tiktokcdn-eu.com  — через /video/tos/ матч
        //   fetch (3/3): v19-webapp-prime.tiktok.com  — через /video/tos/ матч
        // CSP: TikTok имеет connect-src директиву (Report-Only пока), preconnect работает.
        //
        // Статический VIDEO_CDN_MAP для tiktok.com НЕ восстанавливается:
        //   v16-webapp-prime.tiktok.com, v19-webapp-prime.tiktok.com — ВЕРСИОННЫЕ поддомены
        //   (v16/v19 меняются с деплоем: возможны v17/v20/etc). Статика хрупка.
        //   v16-webapp.tiktokcdn-eu.com — geo-CDN (eu), не universal.
        // _isVideoCDN: добавлен 'tiktokcdn' в v3.5.8 → dynamic preconnect к
        //   *.tiktokcdn-eu.com защищён от EXTREME_MODE удаления.
        // vmok-manifest.json — manifest адаптивного видео, грузится через eval-контекст
        //   → _segmentUrlRe его не видит, добавление бессмысленно.
        // tiktokcdn.com — geo-CDN: статический preconnect с задержкой 200ms (STATIC_DELAYS).
        //   Подтверждён только один раз — пересмотреть при новых данных.
        // ── RUTUBE.RU ─────────────────────────────────────────────────────────────
        // v3.5.7: добавлена полная CDN-карта Rutube (подтверждено 3 независимыми сессиями).
        // АРХИТЕКТУРА CDN: двухуровневая схема — балансировщик → edge-нода:
        //   bl.rutube.ru  →  river-6-NNN.rtbcdn.ru  (внешний CDN, rtbcdn)
        //                →  river-1.rutube.ru        (собственная инфраструктура)
        //
        // api.expf.ru — geo-routing API Rutube (GET /api/v1/geo, cross-site,
        //   кастомные заголовки Sdk-Name/Token). Определяет доступный CDN-нод ДО
        //   запроса к bl.rutube.ru → стоит в цепочке РАНЬШЕ балансировщика.
        //   Подтверждён 3/3 сессиях (200 OK). Preconnect 0ms — ускоряет geo-lookup
        //   → быстрее выбор CDN-ноды → быстрее первый запрос к bl.rutube.ru.
        //
        // bl.rutube.ru — балансировщик/диспетчер: отдаёт master HLS-плейлист
        //   (/route/{hash}.m3u8?guids=…). Ловится _segmentUrlRe через .m3u8-матч →
        //   всегда первый dynamic preconnect (1/3) во всех сессиях. Preconnect 0ms
        //   (стабильный хост, 100% сессий) прогревает соединение ДО запуска плеера →
        //   ускоряет появление <video> в DOM (React монтирует плеер после ответа
        //   балансировщика) → прямое влияние на rVFC. Без этого рендер ждёт 1-2 RTT.
        //
        // static.rtbcdn.ru — JS-бандлы плеера (210.js, 325.js, 2.114.0/web/js/…).
        //   Стабильный хост (не ротируется), диск-кэш у повторных визитов → для
        //   первого визита preconnect даёт ускорение инициализации. → 300ms (STATIC_DELAYS),
        //   не конкурирует с видеопотоком.
        //
        // static.rutube.ru — HLS.js плеер (static/player_sdk/hls/1.4.3/hls.min.js).
        //   Аналог static.rtbcdn.ru, но менее критичен (bundled позже). Стабилен.
        //   → 300ms (STATIC_DELAYS). Диск-кэш у повторных визитов.
        //
        // river-6-NNN.rtbcdn.ru / river-1.rutube.ru — реальные CDN-ноды HLS-сегментов.
        //   НЕ в статике: ротационные (session-1: river-6-605, session-2: river-6-604,
        //   session-3: снова river-6-604 + river-1.rutube.ru). Ловятся dynamic XHR-хуком
        //   (1/3→2/3→3/3). _isVideoCDN regex дополнен 'rtbcdn' в v3.5.7 для покрытия.
        //
        // pic.rtbcdn.ru — субтитры (.srt файлы). Не влияет на canplay/rVFC → не добавлять.
        // goya.rutube.ru — реклама/аналитика (event-трекинг). Блокируется uBlock → не добавлять.
        // balancer?svcid=fe73 — внутренний роутинг Rutube, same-origin → не нужен.
        'rutube.ru':      ['api.expf.ru', 'bl.rutube.ru', 'static.rtbcdn.ru', 'static.rutube.ru'],
        // ── ODYSEE.COM ────────────────────────────────────────────────────────────
        // v3.5.6: 'odycdn.com' (apex) ЗАМЕНЁН на 'player.odycdn.com' — реальный хост HLS-сегментов
        // (/v6/streams/…/v1_sNNNNNN.ts), подтверждён в 100% сессий (скрины :authority +
        // dynamic preconnect логи). Apex odycdn.com физически не переиспользуется браузером
        // для поддоменов — static preconnect к нему был бесполезен для canplay/rVFC.
        // player.odycdn.com — стабильный production entry point CDN Odysee → 0ms.
        // thumbnails.odycdn.com — превью видео (optimize/s:…/plain/…) → 500ms (STATIC_DELAYS),
        // не конкурирует с видеопотоком. Подтверждён в нескольких сессиях, поддомен стабилен.
        // 
        'odysee.com':     ['player.odycdn.com', 'thumbnails.odycdn.com'],
        'dailymotion.com': [..._DM_CDNS],
        // v3.5.12: Zetflix/Lordfilm-class — cdnvideohub мультиисточниковый плеер.
        // Хосты перечислены в _ZFLX_HOSTS; CDN — в _ZFLX_CDNS (одна правка = все строки).
        ...(Object.fromEntries(_ZFLX_HOSTS.map(h => [h, [..._ZFLX_CDNS]]))),
        // v3.5.14: LordFilm-класс — портал-агрегатор, НЕ то же самое, что Zetflix/cdnvideohub:
        // нет единого player-хоста, дефолтный транслятор — один из 2-4 равновероятных
        // вариантов (COLLAPS/SHIZAHD/ALLOHA/VEOVEO), выбор пользователя непредсказуем.
        // Подтверждено диагностикой (14 независимых прогонов, разные фильмы, разные
        // сессии с интервалом ~20 минут): единственный стабильный элемент — резолвер
        // api.femd.ws (100% прогонов, включая "чистый" запуск без единого клика; XHR
        // уходит автоматически ~750ms после навигации). То, во что он резолвится
        // (api.ortified.ws / api.namy.ws / api.stiven-king.com — наблюдались все три
        // в рамках одной сессии) НЕ стабильно (падало с 7/8 до 3/7 за 15-20 минут) →
        // сознательно не в статике, ловится динамически (_resolverUrlRe в fetch/XHR-хуке
        // + src-watcher в boostMainVideo, см. ниже). Остальные источники (SHIZAHD/ALLOHA/
        // VEOVEO — общая инфраструктура bd.shizahd.pro/alocdn.shizahd.pro/global.temptcdn.com)
        // требуют клика пользователя — какой именно будет выбран, угадать нельзя →
        // статики для них нет, ускоряются тем же динамическим путём.
        ...(Object.fromEntries(_LF_HOSTS.map(h => [h, ['api.femd.ws']]))),
        // v3.5.15: KINOGO-класс — тот же тип агрегатора (переключаемые кнопки-источники,
        // непредсказуемый выбор пользователя). cinemar.cc — дефолтный, авто-детектируемый
        // источник, подтверждён 2/2 независимых сессий на разных фильмах (см. историю
        // правок). В отличие от ortified.ws/namy.ws (случайные словосочетания под .ws,
        // явные анти-блок шлюзы) — осмысленное "брендовое" имя, что структурно ближе к
        // постоянному домену. Уверенность умеренно-высокая, не железная — стоит
        // переподтвердить через 2-3 недели, как и с LordFilm. Остальные источники —
        // тем же динамическим путём (_resolverUrlRe + src-watcher), угадывать какой
        // выберет пользователь нельзя.
        ...(Object.fromEntries(_KG_HOSTS.map(h => [h, ['cinemar.cc']]))),
    };

    // Производный плоский Set — НЕ редактировать вручную.
    const STATIC_PRECONNECT_HOSTS = new Set(Object.values(VIDEO_CDN_MAP).flat());

    // Задержки (ms) — ключ: CDN-домен (для initVideoPreconnect) или hostname сайта (для _schedulePreconnect).
    // v3.5.5: заполнен для миниатюр/аватарок — не конкурируют за полосу с видеопотоком.
    // YouTube: миниатюры i.ytimg.com и аватарки yt3.ggpht.com → 300ms.
    // Dailymotion: s1.dmcdn.net и s2.dmcdn.net (превью, аватарки) → 300ms.
    // geo.dailymotion.com (гео-роутинг/манифест) — 0ms, критичен для canplay, не откладывается.
    // Добавить 'example-cdn.com': N для любого будущего CDN с конкурирующими ресурсами.
    const STATIC_DELAYS = {
        'i.ytimg.com':              300,  // YouTube: миниатюры
        'yt3.ggpht.com':            300,  // YouTube: аватарки
        's1.dmcdn.net':             300,  // Dailymotion: превью/аватарки
        's2.dmcdn.net':             300,  // Dailymotion: превью/аватарки
        'f.vimeocdn.com':           300,  // v3.5.6 Vimeo: player.js (335KB) + Next.js чанки; не конкурирует с видеопотоком
        'thumbnails.odycdn.com':    500,  // v3.5.6 Odysee: превью (optimize/s:…); уступает player.odycdn.com
        // v3.5.8 ВАЖНО: хосты из VIDEO_CDN_MAP имеют задержку 0ms по умолчанию
        // (initVideoPreconnect: STATIC_DELAYS[c] || 0). Здесь указываются ТОЛЬКО
        // хосты из VIDEO_CDN_MAP, которым нужна задержка > 0ms, чтобы не конкурировать
        // с критичным видеопотоком. api.expf.ru (Rutube, 0ms) — НЕ нужен в STATIC_DELAYS.
        // v3.5.7 Rutube: static.rtbcdn.ru → 300ms (JS-бандлы плеера, не конкурируют).
        // v3.5.7 Rutube: static.rutube.ru → 300ms (HLS.js, аналогично static.rtbcdn.ru).
        'static.rtbcdn.ru':       300,    // v3.5.7 Rutube: JS-бандлы плеера (210.js, 325.js, 2.114.0/web/js/…)
        'static.rutube.ru':       300,    // v3.5.7 Rutube: HLS.js (player_sdk/hls/1.4.3/hls.min.js)
        // v3.5.7 Twitch: k.twitchcdn.net → 300ms. Deployment UUID стабилен между сессиями
        // (149e9513.../2d206a39.../p.js идентичен в 2/2 сессиях). Ранний JS-бандл ~70KB.
        'k.twitchcdn.net':         300,   // v3.5.7 Twitch: ранний p.js бандл, UUID стабилен до следующего деплоя
		// v3.5.11 VK Group: задержки для не-критичных static-хостов.
		// api.okcdn.ru (0ms по умолчанию) — авторизация OK CDN плеера (/fb.do).
		// Задержка не нужна — оставляем 0ms (умолчание).
		//   НЕ добавлять в STATIC_DELAYS: 0ms = не указывать.
		'st.vkvideo.ru':           1500,  // v3.5.11 VK Group: JS/CSS статика плеера.
										//   Не конкурирует с api.* (критичный путь).
										//   1500ms → после dynamic preconnect к CDN.
		'avatars.dzeninfra.ru': 	300, // v.3.5.11 DZEN: Превью карточек ленты (scale_720, smart_crop). Важность Средняя: LCP ленты.
    };

    // v3.4.0: _preconnectFromUrl — единая точка принятия решения "стоит ли preconnect-ить хост".
    // Используется И прямым путём из <video>.currentSrc, И из fetch-перехватчика.
    // saveData — единственный network-aware guard (заменяет старый isMobile-guard):
    // на мобильном без экономии трафика дополнительный TCP/TLS к CDN — полезен (LTE+кабельный
    // роутер сценарий), а с saveData=true — пользователь явно попросил экономить, не лезем.
    // source — только для лога: 'direct' (из <video>) или 'fetch' (из перехватчика).
    function _preconnectFromUrl(url, source) {
        if (!url || url.startsWith('blob:') || url.startsWith('data:')) return false;
        if (_conn?.saveData) return false;
        if (_dynamicPreconnectedCount >= DYNAMIC_PRECONNECT_LIMIT) return false;
        try {
            const host = new URL(url, location.origin).hostname;
            if (host === location.hostname) return false;
            if (STATIC_PRECONNECT_HOSTS.has(host)) return true;     // уже покрыт статически
            if (_dynamicPreconnectedSet.has(host)) return true;     // уже сделан в этой сессии
            if (isTracker('https://' + host)) return false;
            _doPreconnect(host);
            _dynamicPreconnectedSet.add(host);
            _dynamicPreconnectedCount++;
            log(`[IHW] Dynamic preconnect via ${source} (${_dynamicPreconnectedCount}/${DYNAMIC_PRECONNECT_LIMIT}): ${host}`);
            return true;
        } catch (e) { return false; }
    }

    // _dynamicPreconnect: вызывается из boostMainVideo на найденном <video>.
    // v3.4.0: PerformanceObserver убран — fetch-перехват (см. ниже, Video Content)
    // проактивно ловит сегменты ДО появления записи в Resource Timing.
    // Остаётся: прямой currentSrc/src (для не-MSE плееров: Rutube, Vimeo, Odysee, прямой MP4)
    // + loadedmetadata fallback если currentSrc ещё не назначен в момент вызова.
    function _dynamicPreconnect(video) {
        if (!video) return;
        const _tryDirect = () => _preconnectFromUrl(video.currentSrc || video.src || '', 'direct');
        if (_tryDirect()) return;
		// Дополнительный путь: loadedmetadata для прямых потоков без MSE
        if (video.readyState >= 1) _tryDirect();
        else video.addEventListener('loadedmetadata', _tryDirect, { once: true });
    }

    let _dynamicPreconnectedCount = 0;
    let _dynamicPreconnectedSet = new Set();

    const _preconnected = new Set();

    function _doPreconnect(host) {
        if (!host || _preconnected.has(host) || host === location.hostname) return;
        if (isTracker('https://' + host)) return;
        _preconnected.add(host);
        const l = document.createElement('link');
        l.rel = 'preconnect'; l.href = 'https://' + host; l.crossOrigin = 'anonymous';
        // v3.5.13-fix2: маркер "наш" preconnect — единственная точка создания
        // video-preconnect ссылок (сюда стекаются все пути: static VIDEO_CDN_MAP,
        // _dynamicPreconnect от iframe/video.src, _preconnectFromUrl от fetch/xhr-хука).
        // Используется EXTREME_MODE LINK guard'ом ниже, чтобы не удалять собственное
        // же решение сразу после его создания (см. комментарий там).
        l.dataset.ihwPreconnect = '1';
        document.head.appendChild(l);
        log('[IHW Video] Preconnect →', host);
    }

    // v3.0.19: _warmupCDN удалён.
    // QUIC beacon → uBlock блокирует на уровне webRequest (до JS).
    // HEAD warmup → googlevideo.com/videoplayback?expire=… подписан только под GET,
    //   всегда отвечает 405 Method Not Allowed или 403 Forbidden.
    // Оба метода не работают в реальных условиях и дают шум в консоли.
    // _doPreconnect уже покрывает DNS prefetch + TLS handshake warmup — достаточно.

    function _isVideoCDN(host) {
        if (!host || host === location.hostname) return false;
        if (isTracker('https://' + host)) return false;
        // STATIC_PRECONNECT_HOSTS покрывает все стабильные CDN из VIDEO_CDN_MAP.
        if (STATIC_PRECONNECT_HOSTS.has(host)) return true;
        // Динамические CDN (SABR-ноды, HLS-ноды) — не в STATIC_PRECONNECT_HOSTS по определению,
        // но нужны для EXTREME_MODE LINK guard (не удалять их preconnect-теги).
        // yastatic убран из regex: он в STATIC_PRECONNECT_HOSTS через dzen.ru → покрывается выше.
        // v3.5.7: добавлены:
        //   rtbcdn    — river-6-NNN.rtbcdn.ru (Rutube CDN edge-ноды), подтверждено 3/3 сессиях.
        //               Без этого EXTREME_MODE удалял бы preconnect к реальным нодам Rutube.
        //   twitchcdn — k.twitchcdn.net (Twitch JS CDN). Защищает dynamic preconnect от
        //               EXTREME_MODE удаления. Намеренно НЕ 'cloudfront' (слишком широко:
        //               Amazon CDN тысяч сайтов). ОБСУЖДЕНИЕ (п.2): *.cloudfront.net от
        //               twitch.tv ('direct' путь из currentSrc) в EXTREME_MODE удаляется —
        //               это неоптимально, но приемлемо: 'direct' срабатывает после монтажа
        //               <video> (поздно для preconnect), реальный выигрыш минимален;
        //               более чистое решение — STATIC_PRECONNECT_HOSTS.has(h) уже возвращает
        //               true для k.twitchcdn.net → наш статический preconnect защищён;
        //               для cloudfront изменение _isVideoCDN нецелесообразно без
        //               host-специфичной проверки (if location.hostname === 'twitch.tv')
        //               что противоречит универсальности функции.
        //   tiktokcdn — v16-webapp.tiktokcdn-eu.com и другие *.tiktokcdn-{geo}.com.
        //               Dynamic preconnect (2/3) ловит через /video/tos/ паттерн. Без
        //               этой записи EXTREME_MODE удалял бы preconnect к EU CDN TikTok.
        //               Намеренно НЕ 'tiktok.com' (слишком широко — весь домен TikTok).
        // v3.5.9-fix3: добавлен 'okcdn' — vkvdNNN.okcdn.ru / vdNNN.okcdn.ru
        // (VK Group видеопоток). Без этого EXTREME_MODE удалял бы dynamic preconnect.
        // Уже есть в PerformanceObserver-фильтре строка 1554 — теперь согласовано.
        // v/3.5.11 добавлен 'dzeninfra' чтобы EXTREME_MODE не удалял <link rel="preload"> к static.dzeninfra.ru (Это основной CDN для всех React-бандлов Dzen) и avatars.dzeninfra.ru:
        // v3.5.13: добавлены cdnvideo (vgtrkregion-reg.cdnvideo.ru — VGTRK HLS CDN,
        // виден через PO в same-origin proxy-realm на агрегаторах типа smotret.tv)
        // и thestream (streaming.thestream.cyou — HLS relay CDN).
        // Оба паттерна универсальны — не привязаны к конкретному хосту.
        // _segmentUrlRe уже матчит .ts/.m3u8 с этих доменов.
        // Эти записи защищают dynamic preconnect от удаления EXTREME_MODE
        // если он возникнет в IHW-экземпляре внутри proxy-realm (@match *://*/*).
        // v3.5.14-fix2: добавлены vkvideo|interkh|mycdn|mvapspdmpg — сегменты видеопотока
        // (*.vkvideo.cloud/.../seg-NN-AN-v1-A1.m4s, *.interkh.com/..., *.mycdn.me,
        // vnNNNNN.mvapspdmpg.com/.../*.ts), выявленные на LordFilm. На самом LordFilm они
        // за cross-origin границей, куда IHW не достаёт (_segmentUrlRe их там не увидит
        // физически) — но встречаются и на Kinogo-подобных агрегаторах, где расположение
        // может быть иным и они окажутся достижимы. _segmentUrlRe уже покрывает эти URL
        // по расширению (.ts/.m4s) и /seg- без хост-специфики — правки только здесь, чтобы
        // EXTREME_MODE не удалял такой dynamic preconnect, если он где-то состоится.
        // vkuser (уже был) — это VK user-content CDN, отдельный от vkvideo.cloud (видеопоток).
        // 'okcdn' — уже покрывает vdNNN.okcdn.ru (v3.5.9-fix3), доп. правка не нужна.
		// apl423|azplay43|livetv902 видеосегменты на livetv.sx, но мы их не словим (v.3.5.16)
        return /googlevideo|cdnvideohub|ttvnw|jtvnw|bilivideo|odycdn|vimeocdn|rtbcdn|dmcdn|userapi|vkuser|okcdn|strm\.yandex|twitchcdn|tiktokcdn|dzeninfra|cdnvideo|thestream|vkvideo|interkh|mycdn|mvapspdmpg|cinemar|apl423|azplay43|livetv902/i.test(host);
    }

    // initDynamicPreconnect удалён в v3.2.0-fix1: заменён функцией _dynamicPreconnect,
    // которая вызывается из boostMainVideo на main.currentSrc/source напрямую.
    // PerformanceObserver в этой функции запускался post-boost — слишком поздно
    // (браузер уже установил соединение к CDN самостоятельно).

    function initVideoPreconnect() {
        if (isMobile) return;
        const h = location.hostname.replace(/^www\./, '');
        const cdns = VIDEO_CDN_MAP[h] || [];
        if (!cdns.length) return;
        
        cdns.forEach(c => {
            const delay = STATIC_DELAYS[c] || 0;
            if (delay > 0) setTimeout(() => _doPreconnect(c), delay);
            else _doPreconnect(c);
        });
		// v3.2.0-fix4: показываем задержку для каждого CDN из STATIC_DELAYS (0 если не задана)
        const cdnInfo = cdns.map(c => `${c}(${STATIC_DELAYS[c] || 0}ms)`).join(', ');
        log(`[IHW Video] Static preconnect (${h}): ${cdnInfo}`);
    }

    const isVideoHost = (() => {
        const host = location.hostname;
        if (VIDEO_HOST_EXCEPTIONS.includes(host)) return false;
        if (VIDEO_HOSTS.some(h => host.endsWith(h))) return true;
        return location.pathname.split('/').some(s => VIDEO_PATH_SEGMENTS.has(s.toLowerCase().split('?')[0]));
    })();
    const PAGE = isVideoHost ? 'Video Content' : 'Mixed Content';
    // v3.0.22: полный лог режима только при DEBUG=true.
    // Показывает Platform (Desktop/Mobile), полный Mode-лейбл и тип страницы.
    log(`[IHW] Platform:${MODE} | Mode:${_getModeLabel()} | Page:${PAGE}`);

    /* ── ЗАЩИТА AI-ЧАТОВ (v3.0.16) ─────────────────────── */
    // Гибридный подход: ручной массив известных чатов (0 мс) + лёгкий фоллбэк
    // для неизвестных доменов. Цель: не применять агрессивные оптимизации
    // (lazy-iframe, content-visibility, scroll-behavior:auto) на страницах,
    // где поле ввода может "улететь" или застрять.
    const CHAT_SKIP_HOSTS = new Set([
        'chatgpt.com', 'claude.ai', 'chat.deepseek.com', 'qwen.ai',
        'grok.com', 'gemini.google.com', 'perplexity.ai', 'alice.yandex.ru'
    ]);

    // Лёгкий фоллбэк-детект (<0.5 мс, синхронный, без тяжёлого скана DOM).
    // Проверяет hostname, pathname, title и 1–2 быстрых ARIA-селектора.
    function _isLikelyChat() {
        const host = location.hostname;
        if (CHAT_SKIP_HOSTS.has(host)) return true;

        // Дешёвые строковые проверки (O(1), не вызывают reflow)
        if (/chat\.|messanger|conversation/i.test(host)) return true;
        const path = location.pathname.toLowerCase();
        if (/(^|\/)(chat|messages|conversation|thread|dialog)(\/|$)/.test(path)) return true;
        
		// // Были ложные срабатывания на "вклюЧАТь" и др. фразы в котрые входит как составная часть наши ключевые термины. Теперь с обязательной проверкой что они отдельностоящие и отделены пробелами в качестве границ
		// Вариант 1: lookahead/lookbehind для пробелов/границ строки
		if (/(?<!\p{L})(чат|chat|messages|conversation)(?!\p{L})/iu.test(document.title)) return true;

        // Быстрые DOM-проверки только если DOM уже доступен.
        // v3.0.23: убран [aria-live="polite"] — слишком широкий селектор:
        //   Google Translate, toast-уведомления, screen reader announcer-divы —
        //   все ставят aria-live="polite" и не являются чатами.
        // [role="log"] семантически специфичен для потока сообщений (чаты, консоли).
        // data-testid*="message-list" добавлен для Slack/Teams/Discord паттернов.
        if (document.readyState !== 'loading') {
            if (document.querySelector('[role="log"], .chat-input, [data-testid*="chat"], [data-testid*="message-list"]'))
                return true;
        }
        return false;
    }

    // Флаг shouldSkipScroll + кэш в sessionStorage (1 проверка на сессию вкладки).
    // Это защищает от повторных проверок при SPA-навигации и экономит батарею.
    const _chatKey = 'ihw:skip_chat:' + location.hostname;
    let shouldSkipScroll = sessionStorage.getItem(_chatKey);
    if (shouldSkipScroll === null) {
        shouldSkipScroll = _isLikelyChat();
        sessionStorage.setItem(_chatKey, shouldSkipScroll ? '1' : '0');
    } else {
        shouldSkipScroll = shouldSkipScroll === '1';
    }

    if (shouldSkipScroll) log('[IHW] Chat detected, aggressive opts skipped');

    /* ── LCP IMAGE BOOST (v3.0.23) ─────────────────────── */
    // Перенесён после shouldSkipScroll — иначе TDZ (Cannot access before initialization).
    // Реактивная стратегия (!_isSlow): LCP Observer после paint, переприоритизирует in-flight.
    // Проактивная стратегия (_isSlow): processNode сразу при появлении img в DOM.
    if (LCP_IMAGE_BOOST && !shouldSkipScroll && PAGE === 'Mixed Content'
            && !_isSlow && window.PerformanceObserver) {
        // v3.1.5-fix1: лог перенесён сюда — PAGE уже объявлен, контекст точнее (реактивный путь)
        log('[IHW] _isSlow=false → LCP boost: реактивный (Observer активен)');
        try {
            let _lcpBoosted = 0;
            const _lcpObs = new PerformanceObserver(list => {
                if (_lcpBoosted >= 2) return;
                for (const entry of list.getEntries()) {
                    const el = entry.element;
                    const t = Math.round(entry.startTime);
                    const kb = (entry.size / 1024).toFixed(1);
                    const tag = el ? el.tagName : '?';
                    const src = el?.src?.slice(0, 70) || el?.currentSrc?.slice(0, 70) || '(no src)';

                    // Placeholder/spinner — слишком рано, кандидат ещё не финальный
                    if (entry.startTime < 300) {
                        log(`[IHW] LCP candidate skip (placeholder, ${t}ms): ${tag} ${kb}KB`);
                        continue;
                    }
                    // Не img — браузер уже справляется (video poster, text)
                    if (!el || el.tagName !== 'IMG') {
                        log(`[IHW] LCP candidate agreed — браузер справится: ${tag} | ${kb}KB | ${t}ms | fetchPriority:${el?.fetchPriority || 'unset'}`);
                        continue;
                    }
                    // Уже загружена — переприоритизировать поздно
                    if (el.complete) {
                        log(`[IHW] LCP candidate agreed — IMG уже загружена: ${kb}KB | ${t}ms | src:${src}`);
                        continue;
                    }
                    // Браузер уже поставил high — не мешаем
                    if (el.fetchPriority === 'high') {
                        log(`[IHW] LCP candidate agreed — браузер уже high: ${kb}KB | ${t}ms | src:${src}`);
                        continue;
                    }
                    // Мы уже бустили (проактивно через processNode или повторный observer)
                    if (el.hasAttribute('data-ihw-lcp-boosted')) {
                        log(`[IHW] LCP candidate agreed — уже IHW-бустирован: ${kb}KB | ${t}ms | src:${src}`);
                        continue;
                    }
                    // Корректируем: браузер выставил не high, а мы видим что запрос ещё in-flight
                    const prevPriority = el.fetchPriority || el.getAttribute('fetchpriority') || 'unset';
                    el.fetchPriority = 'high';
                    if (el.loading === 'lazy') { el.loading = 'eager'; }
                    if (el.parentElement?.tagName === 'PICTURE') {
                        el.parentElement.querySelectorAll('source').forEach(s => {
                            if (!s.fetchPriority) s.fetchPriority = 'high';
                        });
                    }
                    el.setAttribute('data-ihw-lcp-boosted', 'true');
                    _lcpBoosted++;
                    log(`[IHW] LCP candidate OVERRIDDEN #${_lcpBoosted} — браузер: ${prevPriority} → IHW: high | ${kb}KB | ${t}ms | src:${src}`);
                }
            });
            _lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
            setTimeout(() => { try { _lcpObs.disconnect(); } catch (e) {} }, 8000);
        } catch (e) { log('[IHW] LCP boost error:', e); }
    }

    /* ── ТРЕКЕРЫ ────────────────────────────────────────── */
    // v3.1.7: гибридная структура TRACKERS.
    // Exact-match домены → Set (O(1) lookup). endsWith-суффиксы → короткий Array (O(N), N≤40).
    // Precompiled regex для шрифтовых CDN и preload-паттернов — создаётся 1 раз, не при каждом вызове.
    const RE_FONTS   = /fonts\.(googleapis|gstatic|bunny\.net)|use\.typekit\.net|fast\.fonts\.net/;
    const RE_PRELOAD = /preload|preconnect|modulepreload|dns-prefetch/;

    // Точные hostname (Set — O(1))
    const TRACKER_SET = new Set([
        'google-analytics.com', 'googletagmanager.com', 'doubleclick.net',
        'googlesyndication.com', 'googleadservices.com', 'googletagservices.com',
        'connect.facebook.net', 'fbcdn.net',
        'scorecardresearch.com', 'quantserve.com', 'outbrain.com', 'taboola.com',
        'moatads.com', 'adnxs.com', 'openx.net', 'adtelligent.com',
        'advertising.com', 'adsrvr.org', 'rubiconproject.com', 'pubmatic.com',
        'casalemedia.com', 'smartadserver.com', 'appnexus.com', 'criteo.com',
        'bidswitch.net', 'rlcdn.com', 'bluekai.com', 'demdex.net',
        'amazon-adsystem.com', 'adcolony.com', 'media.net', '33across.com',
        'clarity.ms', 'hotjar.com', 'mixpanel.com', 'segment.io', 'segment.com',
        'heap.io', 'heapanalytics.com', 'amplitude.com', 'fullstory.com',
        'logrocket.com', 'mouseflow.com', 'inspectlet.com', 'clicktale.net',
        'contentsquare.net', 'optimizely.com', 'crazy-egg.com',
        'deltarockme.com', 'vak345.com',
        'mc.yandex.ru', 'mc.yandex.net', 'top-fwz1.mail.ru',
        'redirect.appmetrica.yandex.com', 'counter.yadro.ru',
        'cnt.tbz.liveinternet.ru', 'open.rambler.ru',
        'ads.vk.com', 'target.my.com', 'rb.mail.ru', 'tbgcounter.com',
        'static.hotjar.com', 'vc.hotjar.io',
        'stats.g.doubleclick.net', 'bat.bing.com', 'ad.doubleclick.net', 'ad.atdmt.com',
        'adsymptotic.com', 'creativecdn.com', 'go.sonobi.com',
        'snigelweb.com', 'sharethrough.com', 'triplelift.com',
        'yieldmo.com', 'yieldlab.net', 'smartclip.net',
        'spoutable.com', 'undertone.com', 'indexexchange.com',
        'sovrn.com', 'lijit.com', 'contextweb.com', 'pulsepoint.com',
        'onesignal.com', 'pusher.com', 'pushcrew.com',
        'aimtell.com', 'subscribers.com', 'pushassist.com',
        'chartbeat.com', 'chartbeat.net', 'krxd.net',
        'clickagy.com', 'agkn.com', 'exelator.com', 'eyeota.net',
        'spotxchange.com', 'cxense.com', 'adobedtm.com', 'omtrdc.net',
        '2mdn.net', 'hlserve.com', 'cdn.syndication.twimg.com',
    ]);
    // endsWith-суффиксы для поддоменов (Array — только те что не покрываются точным Set)
    const TRACKER_SUFFIXES = [
        'google.com', 'facebook.com', 'fb.com',
        'vk.com', 'mail.ru',
    ];
    const TRACKER_EXCEPTIONS = new Set([
		'cloudflare.com',            // Cloudflare Challenge / Turnstile капча
        'challenges.cloudflare.com', // прямой домен капчи Cloudflare
        'recaptcha.net',          // Google reCAPTCHA — раскомментировать если сломается
        'hcaptcha.com',           // hCaptcha — раскомментировать если сломается
		'api.vk.com',     		// v3.5.11: CDN-резолвер плеера VK (POST /method/video.*).
								// vk.com в TRACKER_SUFFIXES → api.vk.com ложно блокировался
								// на vkvideo.ru (cross-site). api.vk.com — не трекер,
								// критичен для initVideoPreconnect и _doPreconnect.
		]);

    // v3.1.7: кэш по hostname — isTracker вызывается на каждой ноде в hot path.
    // Без кэша: URL parsing + TRACKER_SET.has + TRACKER_SUFFIXES.some на каждый вызов.
    // С кэшем: 1 Map.get после первого вызова для данного hostname. Лимит 500 — защита от
    // рекламных поддоменов (px123.ads.example.com) бесконечно раздувающих Map.
    const _trackerCache = new Map();
    // v3.5.9-fix2: eTLD+1 текущего сайта — защита от ложных срабатываний isTracker.
    // Проблема: 'vk.com' в TRACKER_SUFFIXES → st1-16.vk.com (CDN VK) = tracker → node.remove()
    // → VK CSS/JS файлы удаляются → страница не рендерится (пустой плеер).
    // Аналогично 'mail.ru' блокировал бы video.mail.ru субдомены.
    // Решение: хост совпадающий по eTLD+1 с текущей страницей — НЕ трекер (same-site CDN).
    // eTLD+1 = последние 2 части hostname (корректно для .com/.ru/.net, достаточно для нашего случая).
    const _siteEtld1 = (() => { const p = location.hostname.split('.'); return p.slice(-2).join('.'); })();
    const _isSameSite = h => { const p = h.split('.'); return p.slice(-2).join('.') === _siteEtld1; };
    const isTracker = url => {
        try {
            const h = new URL(url, location.origin).hostname;
            if (_trackerCache.has(h)) return _trackerCache.get(h);
            // v3.5.10: TRACKER_SET (точные домены) блокируется всегда (ads.vk.com, stats.vk-portal.net).
            // TRACKER_SUFFIXES (wildcard *.vk.com, *.mail.ru) — НЕ блокирует same-site хосты:
            // st1-16.vk.com на vk.com → same-site CDN, не трекер.
            const inSet = TRACKER_SET.has(h);
            const inSuffix = !_isSameSite(h) && TRACKER_SUFFIXES.some(s => h.endsWith('.' + s) || h === s);
            const r = inSet || inSuffix;
            if (_trackerCache.size > 500) _trackerCache.clear();
            _trackerCache.set(h, r);
            return r;
        } catch { return false; }
    };
    const isException = url => {
        try {
            const u = new URL(url, location.origin);
            const host = u.hostname;
            if (TRACKER_EXCEPTIONS.has(host)) return true;
            // v3.2.4: path-based исключение для Google reCAPTCHA на google.com.
            // Проблема: google.com в TRACKERS (реклама/аналитика), но
            // https://google.com/recaptcha/api2/anchor — легитимная капча.
            // Точечная проверка пути вместо добавления всего google.com в исключения.
            if (host === 'google.com' && u.pathname.startsWith('/recaptcha/')) return true;
            if (host === 'www.google.com' && u.pathname.startsWith('/recaptcha/')) return true;
            return false;
        } catch { return false; }
    };

    const _origBeacon = navigator.sendBeacon.bind(navigator);
    navigator.sendBeacon = (url, data) => isTracker(url) ? false : _origBeacon(url, data);

    /* ── font-display:swap (всегда, кроме OFF) ──────────── */
    const _fs = document.createElement('style');
    _fs.textContent = '@font-face{font-display:swap}';
    (document.head || document.documentElement).appendChild(_fs);

    /* ── EXTREME MODE ───────────────────────────────────── */
    if (EXTREME_MODE) {
        try {
            Object.defineProperty(navigator, 'connection', {
                value: { effectiveType: 'slow-2g', saveData: true, rtt: 2200, downlink: 0.05 },
                configurable: true
            });
            log('[IHW Extreme] Fake Save-Data + slow-2g');
        } catch (e) { }
        document.documentElement?.classList.add('ihw-extreme');
        const _xCss = document.createElement('style');
        _xCss.textContent =
            // v3.4.7-fix: :not(#ihw-btn) добавлен в селектор — иначе это же правило
            // (box-shadow:none!important) перекрывало _flashBoostSuccess() на самой
            // кнопке скрипта, делая визуальное подтверждение буста невидимым в ON[E].
            'html.ihw-extreme *:not(#ihw-btn),html.ihw-extreme :before,html.ihw-extreme :after{' +
            'background-image:none!important;filter:none!important;' +
            'backdrop-filter:none!important;box-shadow:none!important;' +
            'text-shadow:none!important;border-radius:0!important;' +
            'animation:none!important;transition:none!important}' +
            'html.ihw-extreme img,html.ihw-extreme video{image-rendering:crisp-edges!important}';
        (document.head || document.documentElement).appendChild(_xCss);
        document.querySelectorAll('video,audio').forEach(m => {
            if (!m.hasAttribute('data-ihw-boosted')) { m.preload = 'none'; m.autoplay = false; }
        });
        document.querySelectorAll('[autofocus]').forEach(el => el.removeAttribute('autofocus'));
        document.querySelectorAll('input,textarea,[contenteditable]').forEach(el => el.spellcheck = false);
    }

    /* ── Базовый CSS ────────────────────────────────────── */
    const _css = document.createElement('style');
    _css.textContent = 'html,body{visibility:visible!important;opacity:1!important}';
    (document.head || document.documentElement).appendChild(_css);

    /* ── VK / OK / vkvideo.ru: хост-специфичные оптимизации (v3.0.18) ──── */
    // Аналогично YouTube-блоку: только стабильные structural anchors и
    // product-level классы. Без webpack-патчинга, MutationObserver, DOM removal.
    // display:none для скелетонов — верный выбор (Kimi прав):
    //   скелетоны — чисто визуальные заглушки, не несут функционала.
    //   display:none полностью исключает из render tree → лучше для FCP чем
    //   visibility:hidden, который всё ещё резервирует место и требует paint.
    // #rightColumn для OK: product-level ID, стабилен годами. Если OK когда-
    //   либо переименует его в navigation+ads — просто перестанет работать,
    //   но сайт останется цел (только display:none, не DOM removal).
    // [class*="ad-overlay"] — НЕ берём: слишком широко, риск задеть subtitles/controls.
    
    if (/\b(vk\.com|vkvideo\.ru|ok\.ru)\b/.test(location.hostname)) {
        const _vkCss = document.createElement('style');
        _vkCss.textContent =
            // Скелетоны: убираем из render tree до гидратации React/Vue.
            // Именованные VK-классы стабильны (product-level, не CSS-modules).
            '#FeedPageSkeleton,.LeftMenuLegacySkeleton,.TopSearchRoot .SkeletonIso,' +
            '.ProfileMenuSkeletonRoot,.skeleton,.skeleton-loader,.placeholder-animation,' +
            '[class*="skeleton"],[class*="Skeleton"],.animated-background' +
            '{display:none!important}' +
            // Рекламные structural anchors: VK
            '#ads_left,.videoplayer_ads,.videoplayer_ads_actions,' +
            '.videoplayer_ads_media_el,.rb-adman-ad-actions' +
            '{display:none!important}' +
            // Рекламные structural anchors: OK
            '#rightColumn,#hook_Block_StickyBannerContainer' +
            '{display:none!important}' +
            // VK Video: blur restriction overlay
            // data-testid — stableнее чем hashed class, но следим:
            // если VK сменит testid — просто перестанет работать.
            '[data-testid="video_card_restriction_overlay"]{display:none!important}' +
            // Blur на превью (только img, не глобально — чтобы не задеть UI)
            'img[class*="Blur"],img[class*="blur"]{filter:none!important;-webkit-filter:none!important}' +
            // Изоляция плеера: уменьшаем объём перерисовок при обновлении UI вокруг
            '.videoplayer,.videoplayer_media,[class*="videoplayer"]:not([class*="ads"])' +
            '{contain:layout style!important}' +
            // Фиксированные элементы в отдельный compositor layer
            '#masthead,.vkuiFixedLayout,.TopNav,.TopSearchRoot,.LeftMenu' +
            '{will-change:transform!important}' +

            // v3.2.1: content-visibility для ленты новостей (консенсус всех 4 ИИ).
            // Лента VK — однонаправленный поток вниз, посты имеют стабильную структуру.
            // contain-intrinsic-size 500px 150px — двухмерная эвристика (Kimi) для точности.
            // content-visibility:auto запоминает реальную высоту после первого рендера.
            // НЕ для .im-mess (сообщения) — VKUI Messenger уже виртуализирован (Kimi/ChatGPT).
            // Без условия _isConnSlow: прирост есть и на быстрой сети (CPU/GPU) (решение пользователя).
			// v3.2.1-fix1: article[data-index] убран — слишком широкий.
            // На VK data-index используется в Stories, рекомендациях, событиях.
            // content-visibility:auto на них → scrollbar jump из-за вариативной высоты.
            // .feed_row + .post[data-post-id] достаточно точно покрывают посты ленты.
            '.feed_row,.post[data-post-id]' +
            '{content-visibility:auto;contain-intrinsic-size:500px 150px}' +

            // v3.2.1: contain для статичных зон (меню, футер, правые колонки).
            // Ограничиваем область перерисовки — изменение внутри блока не каскадит в страницу.
            // Не используем translate3d/will-change для плеера — Chromium 2026 уже layerized (ChatGPT).
            // .LeftMenu__root — product-level VKUI класс, не CSS Modules rotation.
            '.LeftMenu__root,.vkitFooter__root,.vkitRightMenu__container,#stl_left' +
            '{contain:paint layout!important}' +

            // v3.2.1: overscroll-behavior для внутренних скроллеров (UX фикс).
            // Предотвращает "тянучку" страницы при достижении границы чата/диалогов.
            // Риск нулевой — не ломает layout, не отменяет скролл.
            '._im_peer_history,._im_dialogs_list,.vkuiCustomScrollView__host' +
            '{overscroll-behavior:contain!important}';
        (document.head || document.documentElement).appendChild(_vkCss);
        log('[IHW] VK/OK: applied host-specific optimizations');
    }

    /* ── Bilibili: скрытие рекламы/промо CSS (v3.4.9) ───────────────────
     *  Источник: разбор донора "Скрыть рекламу и рекламные разделы на Bilibili"
     *  (Greasyfork). Взят только список селекторов — механизм донора (рекурсивный
     *  JS-обход + inline-стили на каждый узел + setInterval(1000)) НЕ копируется,
     *  заменён на единый <style> блок, как и для VK/OK/YouTube.
     *  Исключено из donor-списка:
     *    - весь .bili-danmaku-x-* (vote/score/guide/cmd-shrink/link/follow-to-electric):
     *      сам донор предупреждает что блокирует опцию интерактивных комментариев —
     *      это функциональный элемент (голосование/счёт в данму), не реклама.
     *      Исключена вся семья целиком, не выборочно (как .ytp-cued-thumbnail-overlay
     *      на YouTube ранее) — единичный подтверждённый риск в группе исключает группу.
     *    - .vcd: слишком общее/короткое имя класса, риск ложного срабатывания.
     *    - div[data-v-2ce37bb8].btn-ad: data-v-XXXXXXXX — автогенерируемый Vue.js
     *      scoped-CSS хеш, пересобирается на каждый билд фронтенда — классический
     *      хрупкий селектор, ломается при следующем деплое Bilibili.
     *    - a[data-loc-id="4331"]: числовой ID локации, может быть переназначен при
     *      редизайне без видимых изменений в разметке — менее заметный отказ чем
     *      переименование класса, консервативно исключён.
     *  Все оставшиеся — семантические, product-level имена (ad-report.*, activity-m-v1.*,
     *  video-card-ad-small, bpx-player-*, slide-ad-exp) — не build-хеши.
     * ──────────────────────────────────────────────────────────────────── */
    if (/\bbilibili\.(com|tv)\b/.test(location.hostname)) {
        const _biliCss = document.createElement('style');
        _biliCss.textContent =
            '.bpx-player-qoeFeedback,' +
            '.ad-report.strip-ad.left-banner,' +
            '.ad-report.ad-floor-exp.left-banner,' +
            '.ad-report.ad-floor-exp.right-bottom-banner,' +
            '.ad-report.video-card-ad-small,' +
            '.activity-m-v1.act-end,' +
            '.activity-m-v1.act-now,' +
            '.video-card-ad-small,' +
            '.video-page-game-card-small,' +
            '.slide-ad-exp,#slide-ad-exp,#slide_ad,' +
            '#right-bottom-banner,#bannerAd,#activity_vote,' +
            '.pop-live-small-mode.part-1,' +
            '.ad-floor-cover.b-img' +
            '{display:none!important}';
        (document.head || document.documentElement).appendChild(_biliCss);
        log('[IHW] Bilibili: applied host-specific optimizations');
    }

    /* ── Яндекс SERP: скрытие рекламных меток (v3.0.18) ─────────────── */
    // Только стабильные ARIA-атрибуты и named-классы.
    // НЕ используем :has(.serp-item) для скрытия целых карточек —
    //   риск ложных срабатываний + стоимость CSS engine на :has() высокого уровня.
    // НЕ используем MutationObserver/TreeWalker — антипаттерн для perf-скрипта.
    // Цель: убрать рекламные метки и баннеры до first paint → улучшение LCP
    //   (реклама часто занимает above-the-fold на поиске Яндекса).
    // Сетевые запросы Директа CSS не отменяет — только визуальный выигрыш.
	if ((location.hostname.includes('yandex.') || location.hostname.endsWith('dzen.ru'))&& PAGE !== 'Video Content') {
        const _yaCss = document.createElement('style');
        _yaCss.textContent =
            // ARIA-метки: семантически стабильны (часть a11y-контракта)
            '[aria-label="Реклама"],[aria-label="Промо"]' +
            '{display:none!important}' +
            // v3.1.0: расширенные селекторы из обновлённого Yandex SERP: Promo скрипта.
            // Named-классы и data-атрибуты Яндекса — product-level, не CSS-modules
            '.AdvLabel,.AdvLabel-Text,.AdvCaption,.PromoOffer,.direct-label,' +
            '.mg-adv-label,[data-baobab-name="adv"],.DistributionLinkBro,' +
            '[data-testid^="promo"],[data-fast-name="PromoOffer"]' +
            '{display:none!important}' +
            // Рекламные карточки: realty, finance — специфичные structural selectors
            '.RealtyListing-AdvItem,.OfferSnippet_highlight' +
            '{display:none!important}';
        (document.head || document.documentElement).appendChild(_yaCss);
        log('[IHW] Yandex: ad labels hidden');
    }

    /* ── Rutube: host-specific оптимизации (v3.0.26) ──────────────── */
    // Консенсус Kimi/Qwen/DeepSeek: только CSS-инъекция structural anchors.
    // Без fetch-перехвата, без MutationObserver, без postMessage-хаков.
    // display:none исключает из render tree до first paint → ускоряет LCP.
    // Graceful degradation: если Rutube сменит классы — сайт останется цел,
    // просто реклама перестанет скрываться.
    if (location.hostname.endsWith('rutube.ru')) {
        const _rtCss = document.createElement('style');
        _rtCss.textContent =
            // BEM-классы преролла — product-level, не CSS-modules, живут годами
            '.preroll,.player__preroll,.preroll__container,' +
            '.video-adv,.player__overlay-adv,' +
            // Семантические data-маркеры рекламного контракта сайта
            '[data-adv],[data-ad],' +
            // ID интеграций AdFox: меняются только при смене рекламного провайдера
            '#advertising,#ADFOX_FEED_BANNER' +
            '{display:none!important}' +
            // Изоляция основного видео: уменьшает перерисовки при обновлении UI.
            // data-testid="video" — стабильный e2e-контракт Rutube (SponsorBlock подтверждает)
            'video[data-testid="video"]' +
            '{contain:layout style!important}';
        (document.head || document.documentElement).appendChild(_rtCss);
        log('[IHW] Rutube: applied host-specific optimizations');
    }

    /* ── Универсальная защита скроллеров ────────────────── */
    function isInsideScroller(el) {
        let p = el.parentElement;
        while (p && p !== document.body) {
            const s = window.getComputedStyle(p);
            if (/auto|scroll/.test(s.overflowY) || /auto|scroll/.test(s.overflow)) return true;
            p = p.parentElement;
        }
        return false;
    }

    /* ── v3.0.15: упрощённый поиск видео (без VK/OK хаков) ── */
    function _findVideosDeep() {
        const found = new Set([...document.querySelectorAll('video')]);
        // Для плееров с известными обёртками — ищем внутри, но без VK/OK специфики
        const hosts = ['.videoplayer_media', '#video-poplayer-cnt', '.video-page-layout-module__player'];
        hosts.forEach(sel => {
            document.querySelectorAll(sel).forEach(host => {
                host.querySelectorAll('video').forEach(v => found.add(v));
            });
        });
        return [...found];
    }

    /* ── MutationObserver ───────────────────────────────── */
    const seen = new WeakSet();
    const processNode = node => {
        if (!(node instanceof HTMLElement) || seen.has(node)) return;
        seen.add(node);
        const tag = node.tagName;

        // v3.0.25: VIDEO на видеохостингах — полностью исключаем из processNode.
        // boostMainVideo — единственный owner VIDEO-логики на Video Content.
        // Устраняет race condition с SABR YouTube/Vimeo/VK без зональных эвристик.
        // Консенсус всех ИИ-анализов (Kimi, Grok, DeepSeek, ChatGPT, Claude).
        if (tag === 'VIDEO' && PAGE === 'Video Content') return;

        // v3.1.4: Video Dominance Window — искусственный bandwidth pressure.
        // На медленном соединении/прокси fetchPriority='high' реально давит на браузер:
        // узкий канал даёт видео приоритет. На быстром соединении fetchPriority игнорируется —
        // polymer добавляет 20-40 IMG рекомендаций, они конкурируют с SABR-сегментами.
        // Лок воспроизводит эффект прокси: новые IMG без LCP-буста откладываются в lazy
        // пока canplay не пришёл (или safety-timeout 1500ms). LCP-бустированные картинки
        // защищены data-ihw-lcp-boosted — они не затронуты (header/thumbnail в начальном HTML).
        // Безопасно: к моменту boost'а polymer уже отработал, LCP разрешён до этого.
        if (_videoLocked && PAGE === 'Video Content' && tag === 'IMG'
                && !node.hasAttribute('data-ihw-lcp-boosted')
                // v3.1.6-fix1: не трогаем 1×1 трекеры и иконки — они не потребляют bandwidth
                && !(node.width === 1 && node.height === 1)) {
            if (!node.loading) node.loading = 'lazy';
            return;
        }

        const src = node.src || node.href || '';

        if (src && isTracker(src) && !isException(src)) {
            // v3.2.4: DEBUG лог заблокированных трекеров.
            // Позволяет исключить домен если блокировка ломает сайт (добавить в TRACKER_EXCEPTIONS).
            log(`[IHW] Tracker blocked: ${new URL(src, location.origin).hostname} (<${tag}>)`);
            node.remove(); _blockedCount++; return;
        }

        if (tag === 'LINK' && node.rel === 'prefetch' && !isException(src)) {
            if (isVideoHost) {
                try { if (new URL(src, location.origin).hostname.endsWith(location.hostname)) return; } catch { }
            }
            log(`[IHW] Prefetch removed: ${src.slice(0, 80)}`);
            node.remove(); _blockedCount++; return;
        }
        // Шрифты откладываем ТОЛЬКО на Video Content
        if (tag === 'LINK' && RE_FONTS.test(src)) {
            if (PAGE !== 'Video Content') return;
            if (node.dataset.ihwFontDeferred) return;
            node.media = 'print';
            setTimeout(() => {
                if (node.parentNode && !node.dataset.ihwFontDeferred) node.media = 'all';
            }, 6000);
            return;
        }

        // v3.0.4: loading=lazy только для поздней динамики (>1000мс).
        // Preload scanner уже обработал начальный HTML.
        // Защита LCP на Pinterest/Unsplash и других фотобанках.
        if (tag === 'IMG') {
            if (!node.decoding) node.decoding = 'async';
            // v3.0.23 companion fix: не навешиваем lazy если LCP Observer уже забустил
            if (performance.now() > 1000 && !node.hasAttribute('loading')
                    && !node.hasAttribute('data-ihw-lcp-boosted')) {
                node.loading = 'lazy';
            }
            // v3.0.23: проактивный буст на медленных соединениях.
            // На быстрых — реактивный LCP Observer выше это сделает сам после paint.
            // v3.1.5: _isSlowNow() вместо _isSlow — ловим деградацию сети mid-page.
            // v3.1.5: rAF-retry при getBoundingClientRect()=0 (layout ещё не вычислен):
            //   IMG добавленная через MO на ранней стадии имеет width=height=0 до layout.
            //   Без retry: r.height > 80 не выполняется → boost пропускается даже при _isSlow.
            //   На prom.ua (TTFB:618ms, _isSlow=true) именно это было root cause пропуска.
            if (LCP_IMAGE_BOOST && _isSlowNow() && !shouldSkipScroll && PAGE === 'Mixed Content'
                    && !node.hasAttribute('data-ihw-lcp-boosted') && !node.complete) {
                const _tryBoostImg = (n, isRetry) => {
                    if (n.hasAttribute('data-ihw-lcp-boosted') || n.complete) return;
                    const r = n.getBoundingClientRect();
                    if (r.width === 0 && r.height === 0 && !isRetry) {
                        // Layout ещё не вычислен — ждём один кадр и повторяем
                        requestAnimationFrame(() => _tryBoostImg(n, true));
                        return;
                    }
                    if (r.top < window.innerHeight && r.height > 80 && r.width * r.height > 8000) {
                        n.fetchPriority = 'high';
                        if (n.loading === 'lazy') n.loading = 'eager';
                        n.setAttribute('data-ihw-lcp-boosted', 'true');
                        log('[IHW] Slow-conn LCP preemptive boost' + (isRetry ? ' (rAF retry)' : '') + ':', n.src?.slice(0, 80));
                    }
                };
                _tryBoostImg(node, false);
            }
        }

        if (node.hasAttribute('autofocus')) node.removeAttribute('autofocus');

        // v3.0.16: на AI-чатах не трогаем iframe (виджеты ввода, превью)
        if (shouldSkipScroll && tag === 'IFRAME') return;
        if (tag === 'IFRAME' && !node.fetchPriority && !isVideoHost) {
            if (node.offsetWidth < 200 || node.offsetHeight < 100) node.fetchPriority = 'low';
        }
        // v3.5.14-fix2: ловля нового iframe-узла при переключении источника перенесена
        // в отдельный наблюдатель без таймаута (см. boostMainVideo/src-watcher) — здесь,
        // в processNode, она зависела от общего mo, который отключается через 30с
        // (mo.disconnect() ниже), что пропускало переключения источника позже 30с
        // после загрузки страницы.

        if (EXTREME_MODE) {
            if (tag === 'LINK') {
                const rel = node.getAttribute('rel') || '';
                if (RE_PRELOAD.test(rel)) {
                    // v3.5.13-fix2: preconnect, добавленный самим IHW (_doPreconnect —
                    // единственная точка создания video-preconnect ссылок), никогда не
                    // удаляется собственным LINK guard'ом. _isVideoCDN ниже — статический
                    // список ключевых слов конкретных CDN (googlevideo|cdnvideohub|okcdn|…),
                    // принципиально не покрывает агрегаторы с непредсказуемым доменом
                    // первого хопа (LordFilm/Zetflix-класс: домен разный на каждой
                    // странице/трансляторе, иногда меняется даже между перезагрузками —
                    // см. диагностику). Удаление собственного же осознанного решения
                    // сразу после его создания — self-sabotage, а не защита. Проверка
                    // через dataset — ноль обслуживания, не хардкодит ни один домен.
                    if (node.dataset.ihwPreconnect) return;
                    try {
                        const h = new URL(node.href || '', location.origin).hostname;
                        // v3.1.6: защита Video CDN preconnect от EXTREME_MODE.
                        if (PAGE === 'Video Content' && _isVideoCDN(h)) return;
                        // v3.2.4: проверка исключений (капча) перед удалением.
                        // Без этого google.com/recaptcha/* удалялся → капча ломалась.
                        if (isException(node.href || '')) return;
                        if (!h.endsWith(location.hostname)) {
                            log(`[IHW] EXTREME: removed ${rel} → ${h} (cross-domain)`);
                            node.remove(); _blockedCount++; return;
                        }
                    } catch { }
                }
            }
            if (['VIDEO', 'AUDIO', 'SCRIPT'].includes(tag) && !node.fetchPriority) {
                // v3.5.9-fix: SCRIPT на Video Content НЕ депривиоризируется — они могут
                // быть критичными SPA-бандлами с cross-origin домена того же владельца.
                // Пример: vk.com/video загружает SPA-бандлы с st.vkvideo.ru (cross-origin
                // к vk.com, но критичен для React-инициализации). fetchPriority='low'
                // на таких скриптах ломало React hydration → бесконечный componentDidCatch.
                // На Video Content главный буст — boostMainVideo, не script deprioritize.
                // v3.1.6: _isProtectedScript() заменяет inline same-origin проверку
                if (tag === 'SCRIPT' && node.src && SAME_ORIGIN_SCRIPT_PROTECT) {
                    if (PAGE === 'Video Content') {
                        // Video Content: не занижаем приоритет никаких скриптов.
                        // Риск конкуренции с видеопотоком минимален: браузер сам
                        // приоритизирует media-запросы выше script после canplay.
                    } else if (_isProtectedScript(node)) {
                        log(`[IHW] SAME_ORIGIN_PROTECT: скрипт сохранён (не занижен): ${node.src.slice(0, 80)}`);
                    } else {
                        node.fetchPriority = 'low';
                    }
                } else if (!(tag === 'VIDEO' && node.hasAttribute('data-ihw-boosted'))) {
                    node.fetchPriority = 'low';
                }
            }
            if (tag === 'INPUT' || tag === 'TEXTAREA' || node.hasAttribute('contenteditable')) node.spellcheck = false;
        }

        // v3.0.26: Единая логика медиа — все режимы (ON / ON[A] / ON[E]).
        // Вынесено из EXTREME_MODE: конкурирующие медиа приглушаются всегда.
        // VIDEO+VideoContent сюда не доходит — early return в начале processNode (v3.0.25).
        // v3.1.5: VIDEO на Mixed Content — только явно вне viewport.
        //   До этого: preload='none' ставился ВСЕМ видео на Mixed Content.
        //   Баг на yapx.ru: MP4-видео является главным контентом страницы — получало
        //   preload='none' и никогда не загружалось (OFF работал, ON — нет).
        //   Нельзя отличить "главное видео" от "фонового" на Mixed Content по URL/атрибутам.
        //   Безопасная эвристика: throttle только для видео у которых layout подтверждён
        //   (height>0) и они явно за пределами viewport (top > 2×innerHeight).
        //   Если layout ещё не вычислен (width=height=0) — консервативно не трогаем.
        if ((tag === 'VIDEO' || tag === 'AUDIO') && !node.hasAttribute('data-ihw-boosted')) {
            if (tag === 'AUDIO') {
                node.preload = 'none';
                node.autoplay = false;
            } else if (PAGE !== 'Video Content') {
                // Mixed Content: throttle только видео явно вне viewport
                const r = node.getBoundingClientRect();
                const isClearlyBelowFold = r.height > 0 && r.top > window.innerHeight * 2;
                if (isClearlyBelowFold) {
                    node.preload = 'none';
                    node.autoplay = false;
                }
                // width=height=0 (layout pending) или в/около viewport → не трогаем
            }
            // VIDEO + Video Content → boostMainVideo (early return выше)
        }
    };

    // v3.1.0: rAF-debounce для некритичных нод в MutationObserver.
    // Критичные (IMG при _isSlow+Mixed → LCP boost) — обрабатываем синхронно.
    // Остальные (LINK, SCRIPT, AUDIO, прочее) — батчим в rAF.
    // На бесконечных лентах (VK, Яндекс) это снижает количество microtask вызовов c N до 1.
    // Риск (DeepSeek): 16-32ms задержка для IMG при _isSlow → решено: такие IMG идут синхронно.
    let _rafPending = false;
    const _rafQueue = [];
    const _flushRafQueue = () => { _rafPending = false; while (_rafQueue.length) processNode(_rafQueue.shift()); };
    const _isCriticalNode = (n) => {
        if (!(n instanceof HTMLElement)) return false;
        const t = n.tagName;
        // IMG при медленном соединении на Mixed Content → проактивный LCP boost нельзя откладывать.
        // v3.1.5: _isSlowNow() вместо _isSlow — реагируем на ping-спайки возникшие после старта.
        if (t === 'IMG' && LCP_IMAGE_BOOST && _isSlowNow() && PAGE === 'Mixed Content') return true;
        return false;
    };
    const mo = new MutationObserver(muts => {
        const _now = performance.now(); // v3.1.7: один вызов на весь batch вместо N вызовов
        for (const m of muts) {
            for (const n of m.addedNodes) {
                // v3.1.2: <video> появился в DOM на видеохостинге → немедленный boost через rAF.
                // Root cause нестабильности v3.1.1: v3.0.25 правильно убрал VIDEO ownership
                // из processNode (early return), но заодно убрал быстрый триггер буста.
                // Следствие: MO видит <video>, делает ничего, следующая попытка tryBoost
                // через 1500ms — за это время браузер уже грузит картинки рекомендаций и скрипты.
                // Теперь: MO видит <video> → один rAF (16ms для layout) → _boostRef() напрямую.
                // Безопасно: processNode в v3.0.25 уже не устанавливает preload/autoplay для VIDEO
                // на Video Content, race condition с SABR исключён. tryBoost остаётся safety net.
                if (n instanceof HTMLElement && n.tagName === 'VIDEO'
                        && PAGE === 'Video Content' && !_videoBoosted && _boostRef) {
                    log('[IHW] Video found by MO → rAF boost in ~16ms');
                    requestAnimationFrame(() => {
                        if (!_videoBoosted) { const r = _boostRef(); if (r === true) _videoBoosted = true; }
                    });
                    continue; // в processNode всё равно early return — в очередь не добавляем
                }
                if (_isCriticalNode(n)) {
                    processNode(n);
                } else {
                    _rafQueue.push(n);
                    if (!_rafPending) { _rafPending = true; requestAnimationFrame(_flushRafQueue); }
                }
            }
        }
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });

    /* ── runRenderOpts (v3.0.2) ─────────────────────────── */
	// Запускается после load. Браузер уже приоритизировал видимые изображения.
    // Оставшиеся без loading — безопасно перевести в lazy.
    const runRenderOpts = () => {
        // v3.0.16: на AI-чатах не рискуем — пропускаем batch-оптимизацию img,
        // чтобы не сломать динамическую подгрузку аватарок/превью сообщений.
        if (shouldSkipScroll) return;
        if (document.readyState !== 'complete') {
            window.addEventListener('load', runRenderOpts, { once: true });
            return;
        }
        requestIdleCallback((deadline) => {
            const images = document.querySelectorAll('img:not([loading])');
            let idx = 0;
            function processBatch() {
                while (idx < images.length && deadline.timeRemaining() > 0) {
                    images[idx++].loading = 'lazy';
                }
                if (idx < images.length) {
                    requestIdleCallback(processBatch, { timeout: 2000 });
                } else {
                    log('[IHW] runRenderOpts: lazy applied to', images.length, 'images');
                }
            }
            processBatch();
        }, { timeout: 2000 });
    };

    /* ── VIDEO CONTENT ──────────────────────────────────── */
    if (PAGE === 'Video Content') {
        /* ── Resource Timing Observer → _unstableScore (v3.3.0) ──────
         *  Консенсус KIMI+DeepSeek: реальная картина нестабильности сети
         *  по видео-сегментам (не effectiveType который отстаёт на 10–30с).
         *  DeepSeek уточнение: смотрим скорость KB/s, не только duration.
         *  <10 KB/s при duration>3s = признак стрессовой загрузки (не просто большой сегмент).
         *  transferSize=0 + duration>1s = вероятный failed/abort.
         *  Decay при успешных быстрых сегментах: −4 за каждый <800ms.
         *  Только Video Content: на Mixed нет видео-сегментов, PO бесполезен.
         * ──────────────────────────────────────────────────────────── */
        if (window.PerformanceObserver) {
            try {
                const _segmentPO = new PerformanceObserver(list => {
                    for (const e of list.getEntries()) {
                        if (!/\.(ts|m4s|mp4|webm)(\?|$)|videoplayback|cdnvideohub|mycdn\.me|okcdn/i.test(e.name)) continue;
                        const dur = e.duration;
                        const kb = e.transferSize / 1024;
                        if ((dur > 3000 && kb / (dur / 1000) < 10) || (e.transferSize === 0 && dur > 1000)) {
                            _unstableScore = Math.min(100, _unstableScore + 12);
                        } else if (dur < 800) {
                            _unstableScore = Math.max(0, _unstableScore - 4);
                        }
                    }
                });
                _segmentPO.observe({ type: 'resource', buffered: false });
                // Отключаем через 60s после canplay — нагрузка не оправдана для стабильных сессий
                setTimeout(() => { try { _segmentPO.disconnect(); } catch(e){} }, 120000);
            } catch(e) {}
        }

        /* ── Fetch/XHR-перехват: ранний _preconnectFromUrl для MSE-плееров (v3.4.0, расширено v3.4.8) ──
         *  YouTube SABR / OK.ru / Twitch грузят видеосегменты через fetch() из JS
         *  (MediaSource.appendBuffer). currentSrc на <video> в этот момент = blob:/пусто —
         *  _dynamicPreconnect(video) не может извлечь хост из элемента.
         *  Этот перехватчик видит URL сегмента ДО его выполнения → preconnect к реальной
         *  CDN-ноде (rr2---sn-*.googlevideo.com и т.п.) сразу, не ждёт Resource Timing entry.
         *  Дешёвая операция: regex.test() по строке URL — без сети/DOM, ~0.01ms.
         *  Безопасно на Mobile и при saveData: фактический preconnect внутри
         *  _preconnectFromUrl сам проверяет saveData и лимит — здесь не дублируем.
         *
         *  v3.4.8: добавлен симметричный XHR-хук — РЕГРЕССИЯ обнаружена и закрыта.
         *  Старое обоснование "XHR не перехватываем" (VK al_video.php, нужен
         *  response.clone().text() на тело) было верным для ОДНОГО конкретного
         *  сценария (URL сегмента внутри JSON-тела ответа), но ошибочно трактовалось
         *  как общий запрет на любой XHR-перехват. Diagnostика на Rutube показала:
         *  HLS.js там грузит .ts-сегменты через XMLHttpRequest (не fetch), и сам URL
         *  сегмента доступен синхронно в XMLHttpRequest.prototype.open(method, url) —
         *  БЕЗ обращения к телу ответа. По стоимости идентично fetch-хуку: один
         *  regex.test() на строку, ноль сетевых/DOM операций. Перехватывается только
         *  .open() — .send()/response не трогаем, тело ответа нигде не читается —
         *  оригинальное ограничение (не парсить XHR-тело) остаётся в силе нетронутым.
         *  Оба хука переиспользуют один _segmentUrlRe — нет дублирования паттерна.
         * ──────────────────────────────────────────────────────────────────────── */
        // v3.5.0: [?&]range= вместо \?range= (был узкий якорь — требовал range= ПЕРВЫМ
        // параметром сразу после ?, на практике редко так бывает). Добавлен [?&]bytes=\d+-\d+
        // — VK/OK (vkvideo.ru использует CDN okcdn.ru, подтверждено скриншотом Network)
        // отдаёt byte-range сегменты БЕЗ расширения файла в пути (просто "/?expires=...") и
        // с параметром bytes=1241433-2377309 вместо ожидаемого range= — другая, но
        // семантически идентичная конвенция именования. \d+-\d+ проверяет реальный
        // паттерн диапазона, не просто слово "bytes" где угодно в строке запроса.
        // v3.5.4: добавлен /video/tos/ — TikTok (ByteDance CDN, v16/v19-webapp-prime.tiktok.com
        // и tiktokcdn-eu.com) отдаёт видео БЕЗ расширения файла в пути (просто хэш-ID),
        // mime_type=video_mp4 — значение query-параметра, не расширение, byte-range идёт
        // через HTTP-заголовок Range:, не через URL — ни одна из существовавших альтернатив
        // не матчила. Диагностика (тестовый fetch-хук + PerformanceObserver из консоли)
        // опровергла обе прежние гипотезы (ранний захват window.fetch, Service Worker) —
        // оказался банальный пробел в URL-паттерне, той же категории что bytes= у VK/OK.
        // v3.5.6: добавлен /playlist\.json — Vimeo HLS-манифест на CDN-хосте
        // (vod-adaptive-ak / skyfire.vimeocdn.com) приходит ДО первого mp4-сегмента
        // → dynamic preconnect к CDN на ~1 RTT раньше. Нет ложных срабатываний.
		// v3.5.11: добавлен /player/sv для Zetflix Group
		// v3.5.15-fix2: добавлены .dash и /frag- — сегменты OK.ru MegaPlayer
        // (frag-high-NNNNN.dash, st*.okcdn.ru), подтверждено скриншотом реального трафика
        // (200 OK). Не привязано к конкретному сайту — MegaPlayer используется на многих
        // площадках, встроивших OK.ru видео; там, где сегменты достижимы из main-window,
        // это даст dynamic preconnect так же, как и остальные паттерны в этом regex.
        const _segmentUrlRe = /\.(ts|m4s|m4v|mp4|webm|m3u8|mpd|dash)(\?|$)|\/seg-|\/segment-|\/chunk-|\/fragment-|\/frag-|[?&]range=|[?&]bytes=\d+-\d+|\/video\/tos\/|videoplayback|initplayback|\/playlist\.json|\/player\/sv\//i;

        // v3.5.14: LordFilm/Zetflix/Kinogo-класс — резолвер-запрос возвращает не сегмент,
        // а JSON/redirect с адресом реального плеера (api.femd.ws → api.ortified.ws,
        // global.temptcdn.com/balancer-api, см. диагностику LordFilm). Семантически другая
        // категория, чем _segmentUrlRe (сегменты потока) — отдельный regex, тот же хук,
        // тот же _preconnectFromUrl → ноль дублирования кода, только доп. test(). Не
        // привязан к конкретному домену агрегатора — сработает на любом сайте с похожим
        // URL-паттерном резолвера без правок.
        const _resolverUrlRe = /\/embed\/movie\/|\/embed\/trailer\/|\/balancer-api\/iframe/i;
        if (typeof window.fetch === 'function') {
            try {
                const _origFetch = window.fetch;
                window.fetch = function(...args) {
                    const p = _origFetch.apply(this, args);
                    try {
                        const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
                        if (url) {
                            // v3.5.14: отдельное сообщение при срабатывании именно
                            // _resolverUrlRe — иначе по логу не различить "хук не
                            // сработал" от "сработал, но хост уже покрыт статикой"
                            // (_preconnectFromUrl молчит в обоих случаях).
                            if (_resolverUrlRe.test(url)) { log('[IHW] Resolver URL (fetch):', url.slice(0, 150)); _preconnectFromUrl(url, 'fetch'); }
                            else if (_segmentUrlRe.test(url)) _preconnectFromUrl(url, 'fetch');
                        }
                    } catch (e) {}
                    return p;
                };
            } catch (e) { log('[IHW] fetch hook unavailable:', e.message); }
        }
        if (typeof XMLHttpRequest !== 'undefined') {
            try {
                const _origOpen = XMLHttpRequest.prototype.open;
                XMLHttpRequest.prototype.open = function(method, url, ...rest) {
                    try {
                        if (typeof url === 'string') {
                            if (_resolverUrlRe.test(url)) { log('[IHW] Resolver URL (xhr):', url.slice(0, 150)); _preconnectFromUrl(url, 'xhr'); }
                            else if (_segmentUrlRe.test(url)) _preconnectFromUrl(url, 'xhr');
                        }
                    } catch (e) {}
                    return _origOpen.call(this, method, url, ...rest);
                };
            } catch (e) { log('[IHW] XHR hook unavailable:', e.message); }
        }

        // v3.2.4: _preloadPoster — общая функция с CSS.escape дедупликацией.
        // Объявлена ДО boostMainVideo (и _ytBootstrap) чтобы обе могли вызвать её без TDZ.
        // CSS.escape: кавычки/спецсимволы в URL не сломают querySelector.
        // fetchPriority='high': ускоряет загрузку LCP-изображения (консенсус Kimi/DeepSeek).
        // Не грузим blob:/data: — они всегда локальные.
        const _preloadPoster = (url) => {
            if (!url || typeof url !== 'string') return;
            if (url.startsWith('blob:') || url.startsWith('data:')) return;
            if (!url.startsWith('http')) return;
            try {
                if (document.querySelector(`link[rel="preload"][as="image"][href="${CSS.escape(url)}"]`)) return;
                const l = document.createElement('link');
                l.rel = 'preload'; l.as = 'image'; l.href = url; l.fetchPriority = 'high';
                document.head.appendChild(l);
                log('[IHW] Poster preload:', url.slice(0, 80));
            } catch (e) {}
        };

        // v3.1.4: единообразный preconnect для всех видеохостингов.
        // YouTube делал синхронный preconnect в _ytBootstrap (DCL) — выигрыш 100-300ms.
        // Остальные хосты (Rutube, Vimeo, VK) шли через requestIdleCallback+500ms —
        // несоответствие без причины. Теперь: VIDEO_CDN_MAP вызывается синхронно
        // при DOMContentLoaded для всех хостов. PerformanceObserver (для динамических
        // CDN-нод) остаётся внутри initVideoPreconnect и запускается тоже при DCL.
        // YouTube дедуплицирует через _preconnected Set — двойных link нет.
        // v3.2.0-fix3: единственная точка вызова initVideoPreconnect — через _schedulePreconnect.
        // Задержка берётся из STATIC_DELAYS по hostname хоста (не CDN).
        // v3.5.4: STATIC_DELAYS пуст (TikTok-запись убрана вместе с её CDN-картой) —
        // механизм рабочий, ждёт будущих хостов. По умолчанию для всех _hostDelay=0.
         // v3.2.0-fix4: для YouTube закомментировано — preconnect перенесён в _ytBootstrap
        // (атомарно с CSS-инъекцией, нет race между двумя DCL-листенерами).
        // Заскомментировать строки ниже чтобы вернуться к общему планировщику без исключения:
        const _h = location.hostname.replace(/^www\./, '');
        
        const _hostDelay = STATIC_DELAYS[_h] || 0;
        const _schedulePreconnect = () => {
			           if (_h === 'youtube.com' || _h === 'youtu.be') return; // v3.2.0-fix4: YouTube → _ytBootstrap эту строку закомментировать, чтобы планировщик был общим для всех без исключений (включая youtube, а в _ytBootstrap закоментировать вызов initVideoPreconnect)
            if (_hostDelay > 0) setTimeout(initVideoPreconnect, _hostDelay);
            else initVideoPreconnect();
        };
        if (document.readyState === 'loading')
            document.addEventListener('DOMContentLoaded', _schedulePreconnect, { once: true });
        else _schedulePreconnect();

        /* ── v3.0.15: шрифты — canplay + таймаут fallback ── */
        function _deferFontsUntilCanplay(mainVideo) {
            // v3.4.2: повторный вызов на SPA-переходе (тот же <video> переиспользуется,
            // _boostVideoElement выполняется снова) переводил уже восстановленные шрифты
            // обратно в media='print' и сразу возвращал → видимый FOUT без выигрыша
            // (канал критичен только при первой загрузке, не на SPA-переходе).
            if (_fontsDeferredOnce) return;
            const fontLinks = document.querySelectorAll(
                'link[href*="fonts.googleapis"], link[href*="fonts.gstatic"], ' +
                'link[href*="bunny.net"], link[href*="typekit.net"], link[href*="fast.fonts.net"], ' +
                'link[rel="preload"][as="font"]'
            );
            if (!fontLinks.length) return;
            _fontsDeferredOnce = true; // фиксируем факт отсрочки — повторов не будет
            let restored = false;
            const _restore = () => {
                if (restored) return;
                restored = true;
                clearTimeout(_timer);
                mainVideo.removeEventListener('error', _restore);
                fontLinks.forEach(lnk => {
                    if (!lnk.dataset.ihwFontDeferred) return;
                    delete lnk.dataset.ihwFontDeferred;
                    delete lnk.dataset.ihwOrigMedia;
                    lnk.media = lnk.dataset.ihwOrigMedia || 'all';
                    if (lnk.rel === 'preload' && lnk.as === 'font') lnk.fetchPriority = 'low';
                });
                log('[IHW Video] Шрифты восстановлены');
            };
            // v3.3.0: увеличенный таймаут шрифтов при нестабильной сети.
            // Консенсус KIMI: 15s вместо 8s при _unstableScore > 40.
            const _fontTimeout = _unstableScore > 40 ? 15000 : 8000;
            const _timer = setTimeout(_restore, _fontTimeout); // fallback
            mainVideo.addEventListener('canplay', _restore, { once: true });
            mainVideo.addEventListener('error', _restore, { once: true });
            fontLinks.forEach(lnk => {
                lnk.dataset.ihwOrigMedia = lnk.media || 'all';
                lnk.media = 'print';
                lnk.dataset.ihwFontDeferred = '1';
            });
        }

        if (_YT_HOSTS.some(h => location.hostname.endsWith(h))) {
            const noop = () => { };
            window.ytcsi = { tick: noop, span: noop, info: noop, setTick: noop, lastTick: noop };
            window.ytStats = noop;
            const _ytBootstrap = () => {
                try { if (window.yt?.config_) window.yt.config_.ENABLE_LOGGING = false; } catch (e) { }
                // v3.2.0-fix3: initVideoPreconnect() перенесён в _schedulePreconnect (единственная точка).
                // v3.2.0-fix4: ВОЗВРАЩЁН в _ytBootstrap как YouTube-исключение.
                // Причина: при TTFB>600ms MO ловит <video> ДО DCL — но _schedulePreconnect тоже DCL.
                // Разница: _ytBootstrap и _schedulePreconnect — два отдельных addEventListener(DCL).
                // Порядок их вызова не гарантирован. Внутри _ytBootstrap CSS-инъекция и preconnect
                // выполняются атомарно в одном обработчике — нет race между двумя листенерами.
                // _schedulePreconnect для YouTube закомментирован ниже (можно раскомментировать
                // для возврата к общему планировщику без ytBootstrap-исключения).
                initVideoPreconnect();
                log('[IHW] YT: early CDN preconnect initiated');
                // v3.2.4-fix1: ранний poster preload в _ytBootstrap УДАЛЁН.
                // YouTube не использует атрибут poster= на <video> — всё превью через CSS
                // background-image в .ytp-cued-thumbnail-overlay-image, который создаётся
                // polymer JS ПОСЛЕ DCL → элемент не существует в момент этого вызова.
                // #thumbnail img[src*="/vi/"] совпадает с рекомендательными превью боковой
                // панели (серверный рендер, есть в DOM при DCL), не с текущим видео →
                // риск preload рандомной рекомендации с fetchPriority='high'.
                // _preloadPoster() остаётся в boostMainVideo/loadstart для хостов
                // которые реально используют атрибут poster= (direct MP4, custom players).
                let c = 'ytd-masthead,#masthead-container{will-change:transform}' +
                    'ytd-rich-shelf-renderer[is-shorts],ytd-reel-shelf-renderer,#shorts-container{display:none!important}' +
                    // v3.1.0: CSS-изоляция рекомендаций и комментариев.
                    // Kimi: content-visibility:auto снижает raster/layout cost без влияния на сеть.
                    // contain-intrinsic-size — эвристика для сайдбара, предотвращает scrollbar jump.
                    // Не скрываем и не убираем из DOM — только откладываем рендер до viewport.
                    '#secondary,#comments,ytd-watch-next-secondary-results-renderer' +
                    '{content-visibility:auto;contain-intrinsic-size:300px 200px}' +
                    // v3.0.18: расширенный набор рекламных селекторов (из YouTube Ad-Bypass 2025-2026)
                    // .ad-showing>video / .ad-interrupting>video — НЕ скрываем:
                    //   YouTube переиспользует один <video> для рекламы и контента;
                    //   скрытие сломает boostMainVideo и canplay основного видео.
                    'ytd-ad-slot-renderer,ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
                    '#player-ads,ytd-in-feed-ad-layout-renderer,' +
                    'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
                    '#masthead-ad,.ytp-ad-player-overlay,.ytp-ad-message-container,' +
                    '.yt-mealbar-promo-renderer,' +
                    'tp-yt-paper-dialog:has(#feedback.ytd-enforcement-message-view-model)' +
                    '{display:none!important}' +
                    // v3.2.1: YouTube Ads-Bypass — дополнительные безопасные рекламные элементы.
                    // 11 новых селекторов прошедших отбор DeepSeek (без активного скипа рекламы:
                    // это задача uBlock. Без .ad-showing>video / .ad-interrupting>video — один <video>
                    // используется и для рекламы и для контента, скрытие сломает boostMainVideo).
                    // Без .ytp-cued-thumbnail-overlay — превью при перемотке, легитимный UX.
                    // Без #root.yt-chips-search-renderer-header-v2 — слишком общий, ломает UI.
                    // Без div:has(> div#banner) — потенциальные ложные срабатывания.
                    'ytd-player-legacy-desktop-watch-ads-renderer,' +
                    '.ytp-ad-player-overlay-layout__player-card-container,' +
                    '.ytp-ad-player-overlay-layout__ad-info-container,' +
                    '.ytp-ad-player-overlay-layout__ad-disclosure-banner-container,' +
                    'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"],' +
                    'ytmusic-mealbar-promo-renderer,' +
                    '.ytd-video-masthead-ad-v3-renderer,' +
                    'ytd-ad-selection-preview-renderer,' +
                    '.ytp-ad-image-overlay,' +
                    '.ytp-ad-avatar,' +
                    '.ytp-ad-button-vm' +
                    '{display:none!important}' +
                    'yt-img-shadow{background-color:transparent!important}' +
                    '.ytp-ambient-light,.ytp-ambient-mode-enabled,ytd-watch-flexy[ambient-mode-enabled] .ytp-ambient-light{display:none!important}' +
                    'ytd-watch-flexy,#cinematics{backdrop-filter:none!important}' +
                    '#comments,#secondary,ytd-watch-next-secondary-results-renderer{contain:layout style paint}';
                if (isFirefox) c += 'html{scrollbar-width:thin}';
                // v3.0.16-fix7: скрываем hover-превью на шкале — только EXTREME.
                // В обычном режиме пользователи ориентируются по превью при перемотке.
                // v3.0.26: pause-overlay и endscreen — все режимы.
                // Консенсус Kimi/Grok/Qwen: эти оверлеи создают тяжёлый render tree
                // (сетка <img> с превью) и конкурируют за bandwidth с основным видео.
                // display:none до first paint → браузер не считает layout, не грузит превью.
                // .ytp-* — product-level классы HTML5-плеера, стабильны с 2015 года.
                // Рекомендации (ytd-watch-next-secondary-results-renderer) НЕ скрываем.
                c += '.ytp-pause-overlay,' +
                     '.ytp-fullscreen-grid-stills-container,.ytp-fullscreen-grid,' +
                     '.ytp-modern-videowall-still,.html5-endscreen,.videowall-endscreen' +
                     '{display:none!important}';
                if (EXTREME_MODE) c += '.ytp-inline-preview{display:none!important}';
                const s = document.createElement('style'); s.textContent = c; document.head.appendChild(s);
                try {
                    const f = window.yt?.config_?.EXPERIMENT_FLAGS;
                    if (f && typeof f === 'object') Object.assign(f, {
                        web_animated_actions: false, web_animated_like: false,
                        web_animated_like_lazy_load: false,
                        kevlar_watch_cinematics: false, web_cinematic_theater_mode: false,
                        web_cinematic_fullscreen: false, enable_cinematic_blur_desktop_loading: false,
                        kevlar_measure_ambient_mode_idle: false, smartimation_background: false,
                        kevlar_refresh_on_theme_change: false,
                    });
                } catch (e) { }
                const cm = document.querySelector('ytd-comments#comments');
                if (cm) { cm.style.contentVisibility = 'hidden'; new IntersectionObserver(e => { if (e[0].isIntersecting) cm.style.contentVisibility = ''; }, { rootMargin: '200px' }).observe(cm); }
            };
            if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _ytBootstrap, { once: true });
            else _ytBootstrap();
        }

        /* ── _boostVideoElement: единая точка буста видеоэлемента (v3.4.0) ─────
         *  Консенсус KIMI/DeepSeek: вынести inline-буст из boostMainVideo в функцию
         *  чтобы её мог вызвать и boostMainVideo (через tryBoost) и prototype.play hook
         *  (для Shadow DOM видео которое querySelectorAll не видит).
         *  data-ihw-boosted — ренtrancy guard: ПЕРВОЙ строкой, до load()/play().
         *  v3.4.6: третий опциональный параметр t0 — точка отсчёта для метрики
         *  start:Nms. По умолчанию _spaT0 (старт скрипта/SPA-перехода) — поведение
         *  для обычных хостов (YouTube/Rutube/Odysee) не меняется.
         *  На бесконечных лентах (TikTok) без SPA-сброса _spaT0 не обновляется
         *  между видео в ленте — метрика монотонно росла бы (время с открытия
         *  вкладки), что бессмысленно. play-hook передаёт свой локальный t0 =
         *  момент перехвата нативного play() — самый ранний доступный сигнал
         *  «видео стало активным» (подтверждено диагностикой: play() опережает
         *  IntersectionObserver-кроссинг 0.9 на ~650мс) — без нового таймера/observer.
         * ──────────────────────────────────────────────────────────────────── */
        const _boostVideoElement = (main, source, t0 = _spaT0) => {
            if (!main || main.hasAttribute('data-ihw-boosted')) return false;
            main.setAttribute('data-ihw-boosted', 'true');  // замок — первой строкой
            // v3.4.3: страховка нулевой стоимости (KIMI/DeepSeek консенсус).
            // На переиспользуемых <video> (YouTube SPA) флаг может «залипнуть» от
            // предыдущего цикла раньше чем сюда дойдёт глобальная очистка в _resetVideoState.
            // Явный сброс здесь не вредит ни одному хосту и закрывает этот путь гонки.
            main._ihwHandled = false;
            log(`[IHW] Found main <video>: ${main.tagName} (via ${source})`);

            // v3.4.2: реордеринг — слушатели навешиваются ДО preload/load()/fetchPriority.
            // load() не вызывает события синхронно, реального риска не было, но порядок
            // "listeners → сетевая операция" безопаснее и единообразнее (DeepSeek).
            _videoLocked = true;
            const _lockSlow = _isConnSlow(false);
            const lockMs = (() => {
                const base = _lockSlow ? 2500 : 800;
                if (_unstableScore > 70) return Math.min(base * 3, 10000);
                if (_unstableScore > 40) return Math.min(base * 2, 6000);
                return base;
            })();
            log(`[IHW] Video Dominance Window: locked ${lockMs}ms (${_lockSlow ? 'slow' : 'fast'}, score:${_unstableScore})`);
            // v3.4.3: source-параметр (KIMI/DeepSeek консенсус) — явно показывает чем снят лок:
            // 'timeout' / 'canplay' / 'rVFC'. Раньше это приходилось угадывать по расстоянию
            // строк в логе (ненадёжная методология, источник долгой путаницы в диагностике Odysee).
            const _unlockVideo = (src) => { if (!_videoLocked) return; _videoLocked = false; log(`[IHW] Video Dominance Window: unlocked via ${src}`); };
            setTimeout(() => _unlockVideo('timeout'), lockMs);
            main.addEventListener('canplay', () => _unlockVideo('canplay'), { once: true });
            if (main.requestVideoFrameCallback) main.requestVideoFrameCallback(() => _unlockVideo('rVFC'));

            // v3.4.2: защита от listener leak при переиспользовании <video> (YouTube SPA).
            // stalled/waiting/loadstart — без {once:true} (должны жить весь срок жизни видео).
            // data-ihw-listeners — единообразно с data-ihw-boosted (DOM-атрибут, не JS-флаг):
            // видно в DevTools, не теряется при сериализации, симметрично сбрасывается
            // в _resetVideoState вместе с data-ihw-boosted.
			
			// v3.3.0: stalled/waiting → ре-активация Video Dominance Window.
            // Консенсус KIMI/DeepSeek: после снятия лока сеть может деградировать снова.
            // stalled = TCP «в тумане», данные не идут → жёсткий лок 3s + score++.
            // waiting = буфер кончился, сеть жива но медленная → только при slow, 1s.
            if (!main.hasAttribute('data-ihw-listeners')) {
                main.addEventListener('stalled', () => {
                    if (!_videoLocked) { _videoLocked = true; _unstableScore = Math.min(100, _unstableScore + 15); log('[IHW] Video stalled → IMG locked (3s)'); setTimeout(() => { _videoLocked = false; }, 3000); }
                });
                main.addEventListener('waiting', () => {
                    if (!_videoLocked && _isConnSlow(false)) { _videoLocked = true; setTimeout(() => { _videoLocked = false; }, 1000); log('[IHW] Video waiting + slow conn → IMG lock (1s)'); }
                });
				// v3.1.3 + v3.2.4: re-boost при SPA-переходе (loadstart).
                main.addEventListener('loadstart', () => {
                    // v3.4.4: сброс замка при переиспользовании узла (TikTok-подобные ленты).
                    // Условие && main._ihwHandled (не просто hasAttribute, консенсус DeepSeek
                    // против упрощения KIMI): _ihwHandled выставляется позже data-ihw-boosted
                    // (внутри _onVideoStart, асинхронно через canplay/loadeddata/rVFC). На
                    // быстром скролле readyState нередко 0 в момент play-hook буста — мы
                    // оказываемся в else-ветке _boostVideoElement, регистрируем слушатели,
                    // и loadstart СЛЕДУЮЩЕГО видео может сработать раньше чем ТЕКУЩИЙ буст
                    // успел подтвердить готовность. Безусловный сброс (просто hasAttribute)
                    // в этот момент снял бы замок с ещё не завершённого цикла — следующий
                    // play()-burst (тройной, по логу TikTok) повторно войдёт в синхронный
                    // путь _boostVideoElement (preconnect/шрифты/poster/querySelectorAll)
                    // для того же физического видео без необходимости. && _ihwHandled
                    // гарантирует: сбрасываем только когда предыдущий цикл буста реально
                    // завершился (метрика записана), не трогаем замок если буст "в полёте".
                    if (main.hasAttribute('data-ihw-boosted') && main._ihwHandled) {
                        main.removeAttribute('data-ihw-boosted');
                        main._ihwHandled = false;
                        log('[IHW] Reset boost lock on loadstart (node reuse)');
                    }
                    if (main.hasAttribute('data-ihw-boosted') && main.fetchPriority !== 'high') {
                        main.fetchPriority = 'high'; main.preload = 'auto';
                        log('[IHW] Re-boost fetchPriority on loadstart (SPA transition)');
						// v3.2.4: re-preload постера при SPA-переходе
                        const newPoster = main.poster || main.getAttribute('poster');
                        if (newPoster) _preloadPoster(newPoster);
                    }
                });
                main.setAttribute('data-ihw-listeners', '');
            }

            main.preload = 'auto';
            const _vSrc = main.currentSrc || main.src || '';
            if (!_vSrc.startsWith('blob:') && main.networkState <= 1) main.load();
            main.fetchPriority = 'high';

            _dynamicPreconnect(main);
            main.querySelectorAll('source').forEach(s => {
                if (s.src && !s.src.startsWith('blob:')) _preconnectFromUrl(s.src, 'source-el');
            });
            _deferFontsUntilCanplay(main);
			// v3.2.4: _preloadPoster с CSS.escape дедупликацией — без дублей при SPA.
            _preloadPoster(main.poster || main.getAttribute('poster') || '');
            main.querySelectorAll('source').forEach(s => { if (!s.fetchPriority) s.fetchPriority = 'high'; });
            const vs = main.src || main.currentSrc || '';
            if (vs.includes('.m3u8')) {
                try { const l = document.createElement('link'); l.rel = 'preload'; l.as = 'fetch'; l.fetchPriority = 'high'; l.href = vs; l.crossOrigin = 'anonymous'; document.head.appendChild(l); } catch (e) {}
            }
            main.setAttribute('playsinline', '');

            if (main.readyState >= 2) {
                // v3.4.5: main._ihwHandled = true добавлен сюда — критичный фикс.
                // Без этого узел, прошедший immediate-путь (readyState>=2 уже к моменту
                // буста — частый случай на TikTok при спокойном скролле, видео прогружено
                // заранее), никогда не получал _ihwHandled=true. Условие сброса в loadstart
                // (hasAttribute && _ihwHandled) для такого узла было вечно ложным →
                // data-ihw-boosted никогда не снимался → play-hook навсегда блокировался
                // на этом переиспользуемом узле (подтверждено логом: после нескольких
                // успешных циклов оба узла TikTok "залипали", дальнейший скролл молчал).
                main._ihwHandled = true;
                main.play().catch(e => { if (e.name !== 'NotAllowedError') log('[IHW] play() blocked:', e.message); });
                _canplayMs = Math.round(performance.now() - t0);
                log(`[IHW] Video ready immediately: ${_canplayMs}ms`);
                _flashBoostSuccess();
            } else {
					// v3.2.0-fix9: единый _onVideoStart — co-trigger для canplay, loadeddata и rVFC.
                    // Кто пришёл первым — тот снимает Video Dominance Window и логируется как start.
                    // canplay   = браузер готов воспроизводить (достаточно буфера)
                    // loadeddata = первый кадр декодирован (может прийти раньше на OK.ru)
                    // rVFC      = GPU нарисовал первый кадр 
                    // _ihwHandled атомарный флаг предотвращает двойной вызов play().
                const _onVideoStart = (ev) => {
                    if (main._ihwHandled) return; main._ihwHandled = true;
                    _canplayMs = Math.round(performance.now() - t0);
                    main.play().catch(e => { if (e.name !== 'NotAllowedError') log('[IHW] play() blocked:', e.message); });
                    log(`[IHW] Video ready via ${ev}: ${_canplayMs}ms`);
                    _flashBoostSuccess();
                };
				// 1. Навешиваем ДО проверки readyState
                main.addEventListener('canplay',    () => _onVideoStart('canplay'),    { once: true });
                main.addEventListener('loadeddata', () => _onVideoStart('loadeddata'), { once: true });
				// 2. rVFC: первый реально нарисованный GPU кадр (Chromium only, Firefox — нет rVFC).
                if (main.requestVideoFrameCallback) {
                    main.requestVideoFrameCallback(() => {
                        _firstFrameMs = Math.round(performance.now() - t0);
                        if (!main._ihwHandled) _onVideoStart('rVFC');
                        else log(`[IHW] First frame rendered: ${_firstFrameMs}ms`);
                    });
                }
				// v3.4.3: хвостовая проверка `if (readyState>=2 && currentSrc) _onVideoStart('immediate')`
				// УДАЛЕНА — недостижимый код. Между входом в этот else (readyState<2 подтверждён
				// строкой выше) и этим местом выполняется только синхронный JS без yield в event
				// loop — readyState физически не может измениться браузером в середине нашего
				// синхронного блока. Условие здесь гарантированно тождественно условию входа в else
				// (false), строка никогда не выполнялась ни на одном хосте. Сам внешний if/else
				// (readyState>=2 проверка на входе в функцию) — оставлен, он реален и нужен.
            }
            return true;
        };

        const boostMainVideo = () => {
            const videos = _findVideosDeep();
            const visible = videos.filter(v => {
                const rect = v.getBoundingClientRect();
                return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight * 1.5;
            });

            if (visible.length) {
                const main = visible.reduce((a, b) => {
                    const sa = a.getBoundingClientRect(), sb = b.getBoundingClientRect();
                    return (sb.width * sb.height || 1) > (sa.width * sa.height || 1) ? b : a;
                });
                return _boostVideoElement(main, 'tryBoost');
            }

            const iframes = [...document.querySelectorAll('iframe')].filter(fr => {
                if (fr.offsetWidth < 200 || fr.offsetHeight < 100) return false;
                const s = (fr.src || fr.name || fr.id || fr.className || '').toLowerCase();
                return /video|player|embed|rutube|vimeo|vk|ok\.ru|dzen|yandex|twitch|dailymotion|bilibili|tiktok/i.test(s);
            });
            if (iframes.length) {
                const main = iframes.reduce((a, b) => (b.offsetWidth * b.offsetHeight) > (a.offsetWidth * a.offsetHeight) ? b : a);
                log(`[IHW] Main video <iframe>: ${main.offsetWidth}x${main.offsetHeight}`);
                if (main.dataset.lazySrc) { main.src = main.dataset.lazySrc; delete main.dataset.lazySrc; }
                main.setAttribute('data-ihw-boosted', 'true'); main.loading = 'eager'; main.fetchPriority = 'high';
                // v3.2.0-fix3: iframe src — прямой URL, не MSE. Передаём как простой объект.
                if (main.src) {
                    const _iv = { currentSrc: main.src, src: main.src, readyState: 1, addEventListener: () => {} };
                    _dynamicPreconnect(_iv);
                }
                // v3.5.14-fix3: LordFilm/Zetflix-класс — агрегаторы с переключаемыми
                // источниками (кнопки-трансляторы, набор и число не фиксированы).
                // Переключение не всегда меняет src — location.href не меняется →
                // _resetVideoState не срабатывает → _dynamicPreconnect выше выполнился бы
                // только для источника, загруженного первым. ПОДТВЕРЖДЕНО ПОЛЛИНГОМ (см.
                // историю правок): у LordFilm/Zetflix ВСЕ iframe-источники создаются заранее
                // и одновременно присутствуют в DOM — переключение это смена размера
                // (0×0 ↔ реальные пиксели) уже существующего узла, src НЕ меняется вообще
                // (ни разу за всю сессию). У Kinogo — наоборот, узел один и тот же, но src
                // меняется по-настоящему. Ловим ОБА паттерна одним кодом: по каждому IFRAME
                // храним последнее (src, "активен ли по размеру") состояние и считаем
                // переключением любое изменение — либо src, либо переход из неактивного
                // размера в активный (или оба сразу). WeakMap — без утечек памяти при
                // удалении узлов. Не хардкодит домены/кнопки/их число/механизм площадки.
                if (!document.documentElement.dataset.ihwSrcWatch) {
                    document.documentElement.dataset.ihwSrcWatch = '1';
                    const _srcWatchState = new WeakMap();
                    const _knownIframes = new Set();
                    const _onSourceCandidate = (t) => {
                        if (!t || t.tagName !== 'IFRAME') return;
                        _knownIframes.add(t);
                        // v3.5.15: баг fix3 — при !t.src был ранний return ДО записи состояния.
                        // LordFilm/Zetflix очищают src у скрытого iframe (экономия ресурсов) и
                        // восстанавливают при возврате — если "опустевшее" состояние не
                        // записано, восстановление того же значения выглядит как "ничего не
                        // изменилось", и переключение обратно перестаёт логироваться
                        // (подтверждено полингом: poll#0 показывал srcProp:'' в скрытом виде).
                        // Состояние пишем ВСЕГДА, действуем (лог+preconnect) — только когда
                        // есть реальный src и элемент сейчас активен по размеру.
                        const src = t.src || '';
                        const big = t.offsetWidth >= 200 && t.offsetHeight >= 100;
                        const prev = _srcWatchState.get(t);
                        _srcWatchState.set(t, { src, big });
                        if (!src || !big) return; // пустой src или сейчас скрыт — нечего преконнектить
                        if (!prev || prev.src !== src || !prev.big) {
                            log('[IHW Video] Source switched →', src);
                            _dynamicPreconnect({ currentSrc: src, src, readyState: 1, addEventListener: () => {} });
                        }
                    };
                    new MutationObserver(muts => {
                        // v3.5.15-fix1: подтверждено логами (LordFilm A→B→A) — возврат на
                        // источник иногда не даёт вообще НИ ОДНОЙ мутации в пределах старого
                        // корня document.body (управляющий класс/атрибут был, по всей
                        // видимости, на <html> — вне подсчёта body). Корень наблюдения поднят
                        // до documentElement (как и у общего mo ниже) — это покрывает весь
                        // документ, а не только body. Плюс: при ЛЮБОЙ мутации в пределах
                        // документа перепроверяем ВСЕ уже встреченные iframe, а не только тот
                        // узел, что технически мутировал — управляющий атрибут может жить на
                        // произвольном предке, не обязательно на самом iframe и не обязательно
                        // на том, что попал в конкретную mutation record.
                        for (const t of _knownIframes) _onSourceCandidate(t);
                        for (const m of muts) {
                            if (m.type === 'attributes' && m.target && m.target.tagName === 'IFRAME') {
                                _onSourceCandidate(m.target);
                            } else if (m.type === 'childList') {
                                for (const n of m.addedNodes) {
                                    if (n.nodeType !== 1) continue;
                                    if (n.tagName === 'IFRAME') _onSourceCandidate(n);
                                    else if (n.querySelectorAll) n.querySelectorAll('iframe').forEach(_onSourceCandidate);
                                }
                            }
                        }
                    }).observe(document.documentElement, { attributes: true, attributeFilter: ['src', 'style', 'width', 'height', 'class'], childList: true, subtree: true });
                }
                return true;
            }

            // v3.0.15: VK/OK — ускоряем контейнер, не ждём <video>
            const isVKOK = /vk\.com|vkvideo\.ru|ok\.ru/.test(location.hostname);
            const custom = document.querySelector('[class*="player"],[id*="player"],[class*="Player"],[id*="Player"],[data-video],[data-player],[data-src*="video"]');
            if (custom && custom.offsetWidth > 200) {
                log(`[IHW] Custom player wrapper: <${custom.tagName}>#${custom.id || '(no-id)'} .${(custom.className || '').split(' ')[0]}`);
                custom.setAttribute('data-ihw-boosted', 'true');
                if (isVKOK) {
                    // v3.5.9-fix3: contentVisibility='visible' УДАЛЁН для VK/OK.
                    // На новой MobX 6-based SPA vkvideo.ru установка CSS-свойства
                    // на контейнер до завершения React-монтирования вызывала
                    // 'adhoc tasks must be groupable' → бесконечный componentDidCatch.
                    // DCL на vkvideo.ru = 8-10s, React монтирует плеер позже —
                    // в этот момент наш tryBoost уже нашёл контейнер и модифицировал
                    // его layout-свойство, что MobX воспринял как внешнее состояние.
                    log('[IHW] VK/OK custom player wrapper found (no <video> in DOM yet)');
                    // v3.4.0: document-level play listener (fix6-fix9, OK.ru-специфичный,
                    // composedPath/event-retargeting workarounds) УДАЛЁН.
                    // Заменён HTMLMediaElement.prototype.play hook (см. ниже, после tryBoost) —
                    // `this` внутри hook'а = РЕАЛЬНЫЙ <video> элемент напрямую, без composedPath,
                    // без привязки к конкретному Shadow Root/контейнеру, без WeakSet.
                    // VK (closed shadow в cross-origin iframe vkvideo.ru) — известное
                    // ограничение: prototype-патч действует только в своём JS-realm.
                    // Если @match покрывает iframe-домен и нет @noframes — hook сработает и там
                    // (отдельный экземпляр скрипта в iframe). Если @noframes — VK остаётся
                    // нерешённым случаем, как и было до v3.4.0. OK.ru — same-document closed
                    // shadow, hook работает гарантированно.
                }
                return isVKOK ? 'custom-vk' : 'custom';
            }

            log('[IHW] No main video or player wrapper found');
            return false;
        };
        // v3.0.23 fix: присваиваем ref сразу после объявления функции,
        // чтобы processNode/MO мог вызвать её до load event без TDZ.
        _boostRef = boostMainVideo;

        /* ── HTMLMediaElement.prototype.play hook (v3.4.0, обобщён в v3.4.4) ──
         *  Изначально только для OK.ru (Shadow DOM). Диагностика TikTok (диалог
         *  с многоAI консенсусом) показала: тот же hook — единственный рабочий
         *  триггер для ЛЮБОГО видеохостинга с бесконечной лентой (TikTok,
         *  потенциально Reels/Shorts), где плеер переиспользует пул из N
         *  <video>-узлов, подменяя им src без пересоздания DOM-узла.
         *  MutationObserver принципиально не видит такую смену — он реагирует
         *  на добавление/удаление узлов, а не на смену атрибутов существующего.
         *  pushState/SPA-машинерия тоже не подходит — на TikTok при скролле
         *  нет ни одного вызова pushState/replaceState (подтверждено логом).
         *  play() же вызывается плеером САМ при каждой смене активной карточки
         *  (подтверждено: тройной burst за 2-5мс на каждом переходе) и
         *  опережает альтернативу (IntersectionObserver) на ~650мс по логу —
         *  IntersectionObserver отклонён не из экономии кода, а как объективно
         *  более медленный/неоднозначный триггер для этого UX-паттерна.
         *
         *  PAGE==='Video Content' guard — критичен: hook вызывает _boostVideoElement
         *  напрямую, минуя boostMainVideo (где раньше был единственный PAGE-чек).
         *  Без него лендинг с фоновым <video autoplay loop muted> на Mixed Content
         *  получил бы агрессивный буст, не предназначенный для таких страниц.
         *  Проверяется ПЕРВОЙ — до hasAttribute и до getBoundingClientRect.
         *
         *  Геометрия — абсолютные пиксели (200×150) + видимость во вьюпорте,
         *  НЕ проценты от innerWidth/innerHeight (консенсус DeepSeek/Qwen/KIMI):
         *  процентный порог либо слишком жёсткий для десктопа (YouTube-плеер
         *  часто занимает 60-70% ширины окна, не 90%+), либо слишком мягкий
         *  для отсева превью на разных разрешениях. Абсолютные 200×150 —
         *  тот же порог что уже используется для отсева мусора в коде, единый
         *  для mobile/desktop (isMobile-ветвление обсуждалось и отклонено как
         *  усложнение без доказанного выигрыша — ни один реальный кейс не
         *  показал разницу между 150×100 и 200×150 в пользу первого).
         *
         *  Reentrancy: data-ihw-boosted (первая строка _boostVideoElement) — замок.
         *  !_videoBoosted убран из условия — глобальный флаг создан под модель
         *  "одно видео за переход", на ленте он навечно блокирует повторный
         *  буст после первого успеха без единого события которое его сбросит.
         *  data-ihw-boosted на самом элементе — достаточная защита от реентерации.
         *
         *  VK (cross-origin iframe, closed shadow) — известное ограничение:
         *  prototype-патч действует только в своём JS-realm. Если @match
         *  покрывает iframe-домен и нет @noframes — сработает и там. OK.ru
         *  (same-document closed shadow) — работает гарантированно, как и раньше.
         * ──────────────────────────────────────────────────────────────────── */
        try {
            const _origPlay = HTMLMediaElement.prototype.play;
            HTMLMediaElement.prototype.play = function() {
                if (this.tagName === 'VIDEO' && PAGE === 'Video Content' && !this.hasAttribute('data-ihw-boosted')) {
                    const el = this;
                    // v3.4.6: _playT0 — момент перехвата нативного play(), отдельная точка
                    // отсчёта для метрики на бесконечных лентах (см. комментарий у t0
                    // параметра _boostVideoElement выше). Захватывается здесь, а не внутри
                    // rAF — play() и есть самый ранний сигнал «видео стало активным».
                    const _playT0 = performance.now();
                    requestAnimationFrame(() => {
                        if (el.hasAttribute('data-ihw-boosted')) return;
                        const r = el.getBoundingClientRect();
                        if (r.width > 200 && r.height > 150 && r.top < window.innerHeight && r.bottom > 0) {
                            const ok = _boostVideoElement(el, 'play-hook', _playT0);
                            // v3.4.5: _videoBoosted=true восстановлен — критичный фикс.
                            // Убран в v3.4.4 с обоснованием "на TikTok tryBoost уже исчерпан
                            // к моменту скролла, флаг ни на что не влияет" — верно для TikTok,
                            // но неверно как общее правило. На OK.ru play-hook может успешно
                            // забустить видео ВНУТРИ окна ретраев tryBoost (видео стартует на
                            // ~30с, что меньше суммарного окна ~8 попыток с экспоненциальной
                            // задержкой ~45-50с). Без этого флага tryBoost не получал сигнала
                            // об успехе и продолжал бесполезные попытки до 8/8 даже после
                            // того как видео уже было найдено и играло (подтверждено логом).
                            // На TikTok эта строка не влияет на способность hook'а бустить
                            // СЛЕДУЮЩИЕ видео в ленте — условие самого hook'а (выше) проверяет
                            // только per-элементный data-ihw-boosted, не этот глобальный флаг.
                            if (ok) {
                                _videoBoosted = true;
                                // v3.4.6: симметрично tryBoost'у ("Main video boosted successfully") —
                                // play-hook раньше не подтверждал завершение буста, только "Found".
                                log('[IHW] Main video boosted successfully (play-hook)');
                            }
                        }
                    });
                }
                return _origPlay.apply(this, arguments);
            };
            log('[IHW] HTMLMediaElement.prototype.play hook installed (universal)');
        } catch (e) { log('[IHW] prototype.play hook failed:', e.message); }

        let _boostAttempt = 0;
        // v3.4.0: динамический _maxAttempts через _isConnSlow(false) — без новой метрики,
        // используем уже существующий governor. На slow conn (rtt/downlink/3g/saveData/TTFB)
        // OK.ru/VK SPA-плеер может инициализироваться дольше 15с (старое окно: 8 попыток).
        // 12 попыток с тем же экспоненциальным расписанием (хардлимит 10000ms на попытку,
        // см. tryBoost) даёт суммарное окно ~50-60с — покрывает медленный SPA без хардкода
        // под конкретный хостинг. Пересчитывается в _resetVideoState на каждый SPA-переход
        // (качество сети могло измениться).
        let _maxAttempts = PAGE === 'Video Content' ? (_isConnSlow(false) ? 12 : 8) : 4;
        let _maxCustomAttempts = _maxAttempts - 1; // v3.0.25: соотношение 8→7 / 4→3 сохранено

        const tryBoost = () => {
            if (_videoBoosted) return;
            log(`[IHW] Boost attempt ${_boostAttempt + 1}/${_maxAttempts}...`);

            const result = boostMainVideo();
            if (result === true) {
                _videoBoosted = true;
                log('[IHW] Main video boosted successfully');
                return;
            }

            if (result === 'custom' || result === 'custom-vk') {
                log('[IHW] Custom player wrapper detected, waiting for native <video>...');
                if (_boostAttempt >= _maxCustomAttempts) {
                    log('[IHW] Custom player confirmed (no native <video> found after all attempts)');
                    return;
                }
            } else {
                log('[IHW] No video element found yet');
            }

            if (_boostAttempt >= _maxAttempts) {
                log('[IHW] Boost attempts exhausted (no video found)');
                return;
            }

            // v3.3.0: адаптивне розкладання retry.
            // slow+нестабильное: больше времени на инициализацию polymer/React при плохой сети.
            // Консенсус KIMI/DeepSeek: slow first=600ms, fast=300ms, хардлимит 10000ms.
            const _slow = _isConnSlow(true);
            const d = _boostAttempt === 0
                ? (_slow ? 600 : 300)
                : Math.min((_slow ? 2000 : 1500) * Math.pow(2, _boostAttempt - 1), 10000);
            _boostAttempt++;
            log(`[IHW] Retrying boost in ${d}ms`);
            setTimeout(tryBoost, d);
        };
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tryBoost, { once: true });
        else tryBoost();

        // v3.3.0: online → перезапуск boost (внутри Video Content — доступ к tryBoost/_boostAttempt).
        // Если все 8 попыток исчерпались пока сеть была недоступна, скрипт молчал вечно.
        // Консенсус KIMI/DeepSeek: 300ms задержка (DNS/TCP успевает восстановиться), score −30.
        // Не создаём новый listener для _resetConnCache — он уже в outer scope.
        window.addEventListener('online', () => {
            if (!_videoBoosted) {
                _unstableScore = Math.max(0, _unstableScore - 30);
                setTimeout(() => {
                    if (!_videoBoosted) {
                        _boostAttempt = 0;
                        _resetConnCache();
                        log('[IHW] Connection restored → restarting boost');
                        tryBoost();
                    }
                }, 300);
            }
        });

        /* ── SPA-НАВИГАЦИЯ: СБРОС RUNTIME-СОСТОЯНИЯ (фикс Nav2/Nav3) ── */
        // v3.1.7: _videoBoosted=true оставался от предыдущей страницы при SPA-переходе →
        // tryBoost пропускал новую страницу. Три слоя: быстрый (YouTube-события) →
        // универсальный (History API) → надёжный (title MO).
        // Консенсус DeepSeek/Claude/Kimi: setTimeout(50) — не медленный fallback, а
        // необходимая синхронизация с polymer: yt-navigate-start срабатывает ДО удаления
        // старого DOM. Promise.resolve().then() (Qwen) выполняется в том же тике —
        // querySelectorAll('video') найдёт стale элемент → tryBoost исчерпает попытки.
        // replaceState НЕ патчим: YouTube вызывает его при обновлении таймкода (?t=120).
        // FLAGS (LCP_IMAGE_BOOST, EXTREME_MODE и др.) остаются статичными — контракт архитектуры.
        // TTFB при SPA = нерелевантен (нет нового HTTP-запроса) → _lastSlowTs сбрасываем
        // чтобы _isSlowNow() перечитал navigator.connection.rtt/downlink актуально.
        let _lastNavUrl = location.href;
        let _resetTimer = null;

        const _resetVideoState = (source) => {
            if (location.href === _lastNavUrl) return;  // не SPA (replaceState для таймкода)
            _lastNavUrl = location.href;
            _videoBoosted = false;
            _boostAttempt = 0;
            _videoLocked  = false;
            _resetConnCache();
            _canplayMs    = 0;
            _firstFrameMs = 0;      // v3.2.0-fix8: сброс метрики первого кадра
            _spaT0        = performance.now();
            // v3.4.2: глобальная очистка всех <video> на странице (DeepSeek + Qwen + KIMI).
            // Root cause бага "video already playing, but boost retries fail forever":
            // YouTube переиспользует тот же <video> между SPA-переходами. data-ihw-boosted
            // от предыдущего видео никогда не снимался → _boostVideoElement (guard на
            // 1-й строке) возвращал false → tryBoost логировал "No video element found yet"
            // все 8/12 попыток, хотя видео реально играло.
            // querySelectorAll('video') не пронизывает closed Shadow DOM (OK.ru) — но там
            // при SPA создаётся НОВЫЙ <video> без атрибутов, очистка для него не нужна (no-op).
            // data-ihw-listeners снимается синхронно с data-ihw-boosted — семантически связаны
            // (оба означают "видео обработано в текущей сессии"), иначе на повторном проходе
            // _boostVideoElement не навесит stalled/waiting/loadstart заново.
            // _ihwHandled (JS-флаг _onVideoStart) — сбрасывается чтобы canplay/loadeddata/rVFC
            // нового видео снова сработали (иначе метрики/play() не пишутся для второго видео).
            document.querySelectorAll('video').forEach(v => {
                v.removeAttribute('data-ihw-boosted');
                v.removeAttribute('data-ihw-listeners');
                v._ihwHandled = false;
            });
            _dynamicPreconnectedCount = 0;
            _dynamicPreconnectedSet.clear();
            // v3.2.2-fix1: сброс _logged флага для повторного статического preconnect.
            // На SPA флаг оставался true → повторный вызов initVideoPreconnect не логировал ничего,
            // но и link-теги дедуплицировались → видно 7+ пустых вызовов без логов в fix5-fix7.
            initVideoPreconnect._logged = false;
            // v3.4.0: OK.ru play-listener cleanup убран — заменён HTMLMediaElement.prototype.play
            // hook (глобальный, патчится один раз, не привязан к DOM-элементу/Shadow Root).
            // _okBoostedVideos/data-ihw-play-listener/data-ihw-ok-observer больше не существуют.
            // v3.2.0: логируем _isSlowNow() после сброса кэша — помогает понять
            // применяет ли governor slow/fast режим на новой SPA-странице
            const _nowSlow = _isConnSlow(false); // свежие данные без кэша после SPA-перехода
            // v3.4.0: пересчёт _maxAttempts на новой SPA-странице — качество сети могло
            // измениться (VPN переключил сервер, LTE→WiFi). Используем _nowSlow — то же
            // измерение, без повторного вызова _isConnSlow (нет лишнего сегмента в _netSegments).
            _maxAttempts = PAGE === 'Video Content' ? (_nowSlow ? 12 : 8) : 4;
            _maxCustomAttempts = _maxAttempts - 1;
            if (DEBUG) {
                const _r = [
                    isMobile                                                     && 'mobile',
                    _conn?.saveData                                               && 'saveData',
                    _conn && ['slow-2g','2g','3g'].includes(_conn.effectiveType)  && `ect:${_conn.effectiveType}`,
                    _conn && _conn.rtt > 300                                      && `rtt:${_conn.rtt}ms`,
                    _conn && _conn.downlink < 2                                   && `dl:${_conn.downlink}Mbps`,
                    (_ttfb > 600)                                                 && `TTFB:${_ttfb}ms`,
                ].filter(Boolean).join(', ');
                log(`[IHW] SPA reset (${source}) → _isSlow=${_nowSlow}${_r ? ' (' + _r + ')' : ''}`);
            } else {
                log(`[IHW] SPA reset (${source}) → state cleared`);
            }
            if (_resetTimer) return;
            _resetTimer = setTimeout(() => {
                _resetTimer = null;
                if (!_videoBoosted) tryBoost();
            }, 50);
        };

        // Tier 1: YouTube-события (быстрые, хрупкие — wrapped в try-catch)
        if (_YT_HOSTS.some(h => location.hostname.endsWith(h))) {
            try {
                // start: ДО polymer-рендера — сброс максимально ранний
                // finish: ПОСЛЕ рендера — дополнительный шанс если start не сработал
                window.addEventListener('yt-navigate-start',  () => _resetVideoState('yt-start'),  { passive: true });
                window.addEventListener('yt-navigate-finish', () => _resetVideoState('yt-finish'), { passive: true });
            } catch (e) { log('[IHW] yt-navigate hooks unavailable (fallback active)'); }
        }

        // Tier 2: History API — универсально для любого SPA (VK, Vimeo, Rutube и др.)
        const _origPush = history.pushState;
        history.pushState = function() { _origPush.apply(this, arguments); _resetVideoState('pushState'); };
        window.addEventListener('popstate',   () => _resetVideoState('popstate'),   { passive: true });
        window.addEventListener('hashchange', () => _resetVideoState('hashchange'), { passive: true });

        // Tier 3: Title MO — надёжный fallback (YouTube меняет <title> при каждом SPA-переходе)
        // Один observer на один элемент (не subtree) — минимальная нагрузка.
        // Дешевле чем проверка location.href в основном MO на каждой из тысяч нод.
        const _titleEl = document.querySelector('title');
        if (_titleEl) new MutationObserver(() => {
            if (location.href !== _lastNavUrl) _resetVideoState('title-mo');
        }).observe(_titleEl, { childList: true });

        if (PAUSE_ON_HIDDEN) {
            document.addEventListener('visibilitychange', () => {
                const vs = [...document.querySelectorAll('video')].filter(v => v.offsetWidth > 0 && v.offsetHeight > 0);
                if (!vs.length) return;
                const main = vs.reduce((a, b) => b.offsetWidth * b.offsetHeight > a.offsetWidth * a.offsetHeight ? b : a);
                if (document.pictureInPictureElement) return;
                if (document.hidden) {
                    main._ihw_wasPlaying = !main.paused;
                    if (main._ihw_wasPlaying) main.pause();
                } else {
                    if (main._ihw_wasPlaying) { main.fetchPriority = 'high'; main.play().catch(() => { }); }
                    main._ihw_wasPlaying = undefined;
                }
            });
        }
    }

    /* ── MIXED CONTENT ──────────────────────────────────── */
    if (PAGE === 'Mixed Content') {
        // v3.1.7: boost fetchPriority для <link rel="preload" as="image"> в исходном HTML.
        // Браузер ставит medium на preload-image без явного fetchpriority.
        // Hero-картинки часто объявлены именно так. Один синхронный querySelectorAll при DCL —
        // до первого paint. Не дублирует LCP Observer: тот реагирует post-paint.
        const _boostPreloadImages = () => {
            document.querySelectorAll('link[rel="preload"][as="image"]').forEach(l => {
                if (!l.fetchPriority || l.fetchPriority === 'auto') {
                    l.fetchPriority = 'high';
                    log('[IHW] preload[as=image] → fetchPriority:high (IHW override):', l.href?.slice(0, 80));
                } else {
                    // браузер уже назначил приоритет — соглашаемся
                    log(`[IHW] preload[as=image] agreed with browser (${l.fetchPriority}):`, l.href?.slice(0, 80));
                }
            });
        };
        if (document.readyState === 'loading')
            document.addEventListener('DOMContentLoaded', _boostPreloadImages, { once: true });
        else _boostPreloadImages();
        const lazyIframeObserver = new IntersectionObserver(entries => {
            for (const e of entries) if (e.isIntersecting && e.target.dataset.lazySrc) {
                e.target.src = e.target.dataset.lazySrc; delete e.target.dataset.lazySrc; lazyIframeObserver.unobserve(e.target);
            }
        }, { rootMargin: '300px' });

        // v3.0.20: безопасная установка preload/autoplay для video.
        // НЕ трогаем video с пустым/about:blank src и без <source> детей:
        //   браузер попытается загрузить placeholder как медиа → ERR_UNKNOWN_URL_SCHEME
        //   → video переходит в ERROR state → сайт реактивно блокирует overlay-кнопку play.
        // Также пропускаем video помеченные как ihw-boosted (уже под нашим контролем).
        const _safeSetVideoMeta = (v) => {
            if (v.hasAttribute('data-ihw-boosted')) return;
            const src = v.currentSrc || v.getAttribute('src') || '';
            const hasSources = v.querySelector('source[src]') !== null;
            if (!src && !hasSources) return;                       // нет src вообще
            if (/^about:|^javascript:/i.test(src)) return;        // placeholder src
            v.autoplay = false;
            v.preload = 'metadata';
        };

        const initMixed = () => {
            // v3.0.16: на AI-чатах применяем только безопасное (async decoding,
            // autoplay off), но НЕ трогаем lazy-iframe и скроллеры.
            if (shouldSkipScroll) {
                document.querySelectorAll('img').forEach(img => { if (!img.decoding) img.decoding = 'async'; });
                document.querySelectorAll('video').forEach(v => { _safeSetVideoMeta(v); });
                return;
            }
            const vh = window.innerHeight;
            document.querySelectorAll('img').forEach(img => {
                if (!img.decoding) img.decoding = 'async';
            });
            [...document.querySelectorAll('iframe')].forEach(fr => {
                const top = fr.getBoundingClientRect().top;
                if (top > vh * 2 && fr.src) {
                    if (isInsideScroller(fr)) {
                        log('[IHW] lazy-iframe: пропущен (внутри скроллера)');
                        return;
                    }
                    const rect = fr.getBoundingClientRect();
                    const pb = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
                    if (fr.offsetHeight < 120 && rect.top > pb - vh * 1.5) return;
                    fr.dataset.lazySrc = fr.src; fr.removeAttribute('src'); lazyIframeObserver.observe(fr);
                }
            });
            document.querySelectorAll('video').forEach(v => { _safeSetVideoMeta(v); });
        };
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initMixed, { once: true });
        else initMixed();
    }

    /* ── MOBILE ─────────────────────────────────────────── */
    if (isMobile) {
        const _mh = document.createElement('style');
        _mh.textContent = '@media(hover:none){*{transition:none!important;animation:none!important}}' +
            'a,button,[role="button"],input,select,textarea,label,summary' +
            '{touch-action:manipulation;-webkit-tap-highlight-color:transparent}';
        (document.head || document.documentElement).appendChild(_mh);
        document.addEventListener('play', e => {
            const el = e.target;
            if ((el.tagName === 'VIDEO' || el.tagName === 'AUDIO') && el.autoplay) { el.pause(); el.autoplay = false; }
        }, { capture: true, passive: true });
        document.addEventListener('touchstart', () => { }, { passive: true });
        window.addEventListener('wheel', () => { }, { passive: true });
    }

    /* ── КНОПКА (не-OFF) ────────────────────────────────── */
    _renderBtn();

    /* ── DNS PREFETCH (v3.0.16-fix4: обёртка с гарантированным fallback) ── */
    let dnsPrefetchDone = false;
    
    // Надёжная обёртка: requestIdleCallback → setTimeout fallback
    const _runWhenIdle = (fn, timeout = 2000) => {
        if (window.requestIdleCallback) {
            const id = requestIdleCallback(fn, { timeout });
            // Двойная страховка: если idle не вызовется за timeout, force через setTimeout
            setTimeout(() => cancelIdleCallback(id), timeout + 100);
        } else {
            setTimeout(fn, timeout);
        }
    };

    const addDnsPrefetch = domains => {
        if (dnsPrefetchDone || !domains.length) return;
        const head = document.head || document.documentElement;
        domains.forEach(d => { 
            const l = document.createElement('link'); 
            l.rel = 'dns-prefetch'; 
            l.href = '//' + d; 
            head.appendChild(l); 
        });
        dnsPrefetchDone = true;
        log('[IHW] DNS prefetch added:', domains);
    };

    const createSecondScreenSentinel = () => {
        if (dnsPrefetchDone) { log('[IHW] Sentinel: already done'); return; }
        if (document.querySelector('.ihw-sentinel')) { log('[IHW] Sentinel: already exists'); return; }
        
        log('[IHW] Sentinel: creating...');
        const s = document.createElement('div');
        s.className = 'ihw-sentinel';
        s.style.cssText = 'position:absolute;top:2200px;left:0;width:1px;height:1px;pointer-events:none;visibility:hidden';
        document.documentElement.appendChild(s);

        let obs = null;
        let fallbackTimer = null;
        
        const cleanup = () => {
            if (obs) { try { obs.disconnect(); } catch(e) {} obs = null; }
            if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
            if (s.parentNode) s.remove();
        };

        const doScan = (source) => {
            if (dnsPrefetchDone) return;
            cleanup();
            
            const ext = new Set();
            document.querySelectorAll('a[href^="http"], img[src^="http"], iframe[src^="http"]').forEach(el => {
                try { 
                    const h = new URL(el.href || el.src, location.origin).hostname; 
                    if (h && !h.endsWith(location.hostname)) ext.add(h); 
                } catch {}
            });
            
            const list = [...ext].slice(0, 10).filter(d => !isTracker('https://' + d));
            if (list.length) {
                addDnsPrefetch(list);
            } else {
                log('[IHW] Sentinel: no clean domains');
                sessionStorage.setItem('ihw:dns_done:' + location.hostname, '1');
            }
        };

        // Пробуем через IntersectionObserver (пользователь скроллит)
        try {
            obs = new IntersectionObserver(entries => {
                if (entries[0].isIntersecting) {
                    log('[IHW] Sentinel: viewport intersected');
                    doScan('observer');
                }
            }, { rootMargin: '500px 0px' });
            obs.observe(s);
        } catch(e) {
            log('[IHW] Sentinel: observer failed, force scan');
            doScan('observer-fail');
            return;
        }

        // Fallback 1: через 3с если страница короткая (sentinel уже в viewport)
        fallbackTimer = setTimeout(() => {
            const rect = s.getBoundingClientRect();
            if (rect.top < window.innerHeight + 500) {
                log('[IHW] Sentinel: fallback short-page');
                doScan('fallback-short');
            }
        }, 3000);

        // Fallback 2: через 8с в любом случае (force scan)
        setTimeout(() => {
            if (!dnsPrefetchDone) {
                log('[IHW] Sentinel: fallback force');
                doScan('fallback-force');
            }
        }, 8000);
    };

    /* ── ФИНАЛИЗАЦИЯ ────────────────────────────────────── */
    const onLoadHandler = () => {
        if (_initDone) return;
        _initDone = true;
        log('[IHW] Finalize: running');
        if (PAGE === 'Mixed Content') {
            _runWhenIdle(runRenderOpts, 500);
            log('[IHW] Finalize: launching sentinel');
            createSecondScreenSentinel();
        }
        setTimeout(() => mo.disconnect(), PAGE === 'Video Content' ? 30000 : 4000);
        document.querySelectorAll('noscript').forEach(n => n.remove());
        if (EXTREME_MODE) {
            document.querySelectorAll('video:not([data-ihw-boosted])').forEach(v => { try { v.disablePictureInPicture = true; } catch (e) { } });
        }
        if (DEBUG) _logExtendedMetrics(_getModeLabel());
    };

    // v3.0.16-fix5: гарантированный запуск — если уже complete, вызываем сразу
    if (document.readyState === 'complete') {
        log('[IHW] Finalize: document already complete, calling directly');
        onLoadHandler();
    } else {
        log('[IHW] Finalize: waiting for load event');
        window.addEventListener('load', onLoadHandler, { once: true });
        // v3.0.23: принудительный запуск через 8с если window.load не пришёл.
        // Сценарий: сторонний скрипт (Google Translate, Яндекс.Метрика) зависает
        // или получает ERR_TIMED_OUT (30с!) → load event задерживается на всё это время
        // → Finalize, sentinel и runRenderOpts не запускаются → страница выглядит «зависшей».
        // 8с — достаточно для нормальной загрузки, раньше Google Translate timeout (30с).
        setTimeout(() => {
            if (!_initDone) {
                log('[IHW] Finalize: hard timeout — load event не пришёл за 8с, запускаем принудительно');
                onLoadHandler();
            }
        }, 8000);
    }

})();