Ускоряет загрузку страниц: защита LCP, корректный lazy-loading, приоритет видео на хостингах.
// ==UserScript==
// @name I Hate Waiting
// @name:en I Hate Waiting
// @namespace https://tampermonkey.net/
// @version 3.5.2
// @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
// ==/UserScript==
(function () {
'use strict';
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'}`);
/* ── ТИП СТРАНИЦЫ ───────────────────────────────────── */
const VIDEO_HOSTS = [
'youtube.com', 'youtu.be', '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',
'smotret.tv', 'platform.rambler.ru', 'kinopoisk.ru',
'zetflix.bet', 'zetflix.to', 'zetflix.online', 'zetflix.app'
];
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 = ['ytimg.com', 'ggpht.com'];
// googlevideo.com НАМЕРЕННО УБРАН из YT: SABR выбирает rr2---sn-*.googlevideo.com динамически,
// статический preconnect к bare-домену → wrong origin ~80% → только _dynamicPreconnect.
const VIDEO_CDN_MAP = {
'youtube.com': _YT_CDNS,
'youtu.be': _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': ['strm.yandex.net', 'yastatic.net', 'avatars.mds.yandex.net'],
// strm.yandex.net — HLS-сегменты dzen.ru/video; yastatic.net — JS/CSS плеера; avatars.mds.yandex.net — превью.
'vimeo.com': ['vimeocdn.com', 'player.vimeo.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-узел для всех пяти доменов одинаково, без статики.
'dailymotion.com':['dmcdn.net'],
'twitch.tv': ['player.twitch.tv'],
// 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 И
// сторонний Akamai (upos-bstar1-mirrorakam.akamaized.net) — статический список
// в принципе не может покрыть "какой из N провайдеров выберется в этот раз".
// Динамический XHR-хук уже подтверждённо ловит оба варианта корректно (regex
// с .m4s расширением матчит без изменений). Запись для bilibili.tv намеренно
// НЕ добавляется — был бы тот же архитектурный просчёт, просто для другого TLD.
'tiktok.com': ['tiktokcdn.com'],
// tiktokcdn.com — geo-CDN: статический preconnect с задержкой 200ms (см. STATIC_DELAYS).
'odysee.com': ['odycdn.com'],
};
// Производный плоский Set — НЕ редактировать вручную.
const STATIC_PRECONNECT_HOSTS = new Set(Object.values(VIDEO_CDN_MAP).flat());
// Задержки (ms) — ключ: hostname сайта (для _schedulePreconnect) или CDN-домен (для initVideoPreconnect).
// YouTube = 0 (уже быстро при DCL). TikTok = 200 (geo-CDN, не занимаем слоты при старте).
const STATIC_DELAYS = {
'tiktok.com': 200, // задержка на уровне хоста: все CDN tiktok.com ждут 200ms
'tiktokcdn.com': 200, // fallback: если используется по CDN-ключу
};
// 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';
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 → покрывается выше.
return /googlevideo|cdnvideohub|ttvnw|jtvnw|bilivideo|odycdn|vimeocdn|dmcdn|userapi|vkuser|strm\.yandex/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 — раскомментировать если сломается
//'recaptcha.google.com',
'hcaptcha.com', // hCaptcha — раскомментировать если сломается
]);
// 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();
const isTracker = url => {
try {
const h = new URL(url, location.origin).hostname;
if (_trackerCache.has(h)) return _trackerCache.get(h);
const r = TRACKER_SET.has(h) || TRACKER_SUFFIXES.some(s => h.endsWith('.' + s) || h === s);
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';
}
if (EXTREME_MODE) {
if (tag === 'LINK') {
const rel = node.getAttribute('rel') || '';
if (RE_PRELOAD.test(rel)) {
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.1.6: _isProtectedScript() заменяет inline same-origin проверку
if (tag === 'SCRIPT' && node.src && SAME_ORIGIN_SCRIPT_PROTECT) {
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" где угодно в строке запроса.
const _segmentUrlRe = /\.(ts|m4s|m4v|mp4|webm|m3u8|mpd)(\?|$)|\/seg-|\/segment-|\/chunk-|\/fragment-|[?&]range=|[?&]bytes=\d+-\d+|videoplayback|initplayback/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 && _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' && _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).
// YouTube = 0 (немедленно при DCL), TikTok = 200ms, остальные = 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 (location.hostname.endsWith('youtube.com') || location.hostname.endsWith('youtu.be')) {
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);
}
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) {
custom.style.contentVisibility = 'visible';
log('[IHW] VK/OK container boost (no <video> in DOM)');
// 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 (/youtube\.com|youtu\.be/.test(location.hostname)) {
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);
}
})();