Загрузка данных
// ==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);
}
})();