// ==UserScript==
// @name HDRezka Series Grid View
// @namespace http://tampermonkey.net/
// @version 1.8
// @description Добавляет отдельную сетку всех сериалов с постерами и кратким описанием из правого блока "Горячие обновления сериалов" на HDRezka (Не на всех зеркалах)
// @author ChatGPT
// @include /^https?:\/\/.*rezk.*\/.*$/
// @icon https://media.discordapp.net/attachments/613661716017840179/1427647608230383646/avatar.png?ex=68ef9ff2&is=68ee4e72&hm=ee9f11e29cda41f4153208dda58179b8d7e431ee4a8d1a187dbed21346b1e314&=&format=webp&quality=lossless&width=150&height=150
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let hideTimer = null;
const HIDE_DELAY = 300;
let remoteCache = {}; // Local in-memory cache
const CACHE_KEY = 'hdrezka_series_grid_cache';
// --- State Variables ---
let currentPopupTriggerHref = null; // Tracks which poster link opened the popup
// --- Grid Configuration ---
const ITEMS_PER_PAGE = 20;
let currentPage = 1;
let uniqueSeriesData = [];
// --- Hover Popup Configuration ---
const POPUP_WIDTH = 300;
const POPUP_HEIGHT = 270;
// 1. CSS STYLING (Unchanged)
GM_addStyle(`
/* --- Hover Popup Styles --- */
#tm_rezka_popup {
position: fixed;
z-index: 100005;
padding: 10px;
background: rgba(18, 18, 18, 0.95);
border: 2px solid #ff5917;
border-radius: 5px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.8);
width: ${POPUP_WIDTH}px;
height: ${POPUP_HEIGHT}px;
pointer-events: auto;
display: none;
transition: opacity 0.2s;
opacity: 0;
color: #ccc;
font-family: sans-serif;
font-size: 13px;
line-height: 1.3;
flex-direction: column;
box-sizing: border-box;
}
#tm_rezka_popup.floating-details-mode {
display: flex !important;
}
#tm_rezka_popup .tm-title-popup, #tm_rezka_popup .tm-episode-popup {
flex-shrink: 0;
color: #fff; font-weight: bold; font-size: 14px; margin-bottom: 5px;
display: block;
}
#tm_rezka_popup .tm-episode-popup {
font-size: 11px;
color: #ff5917;
margin-bottom: 10px;
}
#tm_rezka_popup .tm-ratings {
flex-shrink: 0;
display: flex;
gap: 10px;
padding: 5px 0 10px;
border-bottom: 1px solid #333;
margin-bottom: 10px;
}
#tm_rezka_popup .tm-rating-item { font-size: 11px; text-align: center; }
#tm_rezka_popup .tm-rating-score { font-weight: bold; font-size: 14px; color: #FFC107; }
#tm_rezka_popup .tm-rating-label { font-size: 10px; color: #888; }
#tm_rezka_popup .tm-description-wrapper {
flex-grow: 1;
overflow-y: auto;
font-size: 12px;
box-sizing: border-box;
}
#tm_grid_button {
position: fixed;
top: 10px;
right: 10px;
z-index: 100001;
background: #ff5917;
color: white;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
font-family: sans-serif;
font-size: 12px;
}
#tm_grid_overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 100000;
display: none;
overflow-y: auto;
color: #ccc;
font-family: sans-serif;
padding: 20px;
}
#tm_grid_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 2px solid #ff5917;
padding-bottom: 10px;
}
#tm_grid_title {
color: #fff;
font-size: 24px;
}
#tm_grid_controls {
display: flex;
gap: 10px;
}
#tm_grid_close, #tm_clear_cache_button {
background: #ff5917;
color: white;
border: none;
padding: 8px 15px;
cursor: pointer;
border-radius: 3px;
}
#tm_clear_cache_button {
background: #d9534f;
}
#tm_grid_container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.tm_grid_item {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 5px;
padding: 15px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.tm_grid_item .tm-image {
width: 100px;
flex-shrink: 0;
}
.tm_grid_item .tm-image img {
width: 100%;
border-radius: 3px;
}
.tm_grid_item .tm-info {
flex-grow: 1;
}
.tm_grid_item .tm-title {
color: #ff5917;
font-weight: bold;
font-size: 16px;
display: block;
margin-bottom: 5px;
}
.tm_grid_item .tm-details {
font-size: 11px;
color: #888;
margin-bottom: 10px;
}
.tm_grid_item .tm-episode-info {
font-size: 13px;
color: #ccc;
margin-bottom: 10px;
}
.tm_grid_item .tm-rating-mini {
font-size: 11px;
color: #FFC107;
font-weight: bold;
}
#tm_pagination {
display: flex;
justify-content: center;
padding: 20px 0;
}
.tm_page_button {
background: #333;
color: white;
border: 1px solid #ff5917;
padding: 5px 10px;
margin: 0 5px;
cursor: pointer;
border-radius: 3px;
}
.tm_page_button:disabled {
background: #111;
border-color: #555;
color: #555;
cursor: not-allowed;
}
.tm_page_button.current {
background: #ff5917;
}
`);
// --- Cache Management ---
async function loadCache() {
const storedCache = await GM.getValue(CACHE_KEY, '{}');
try {
remoteCache = JSON.parse(storedCache);
} catch (e) {
console.error("Error loading cache, starting fresh.", e);
remoteCache = {};
}
}
function saveCache() {
GM.setValue(CACHE_KEY, JSON.stringify(remoteCache));
}
async function clearCache() {
if (confirm("Вы уверены, что хотите очистить кэш постеров и описаний? Все данные будут загружены снова при следующем просмотре.")) {
remoteCache = {};
await GM.deleteValue(CACHE_KEY);
alert("Кэш очищен. Перезагрузите страницу для обновления Grid.");
if (document.getElementById('tm_grid_overlay').style.display === 'block') {
document.getElementById('tm_grid_overlay').style.display = 'none';
}
}
}
// --- Data Extraction & Aggregation ---
function extractLocalDetails(link) {
const listItemInner = link.closest('.b-seriesupdate__block_list_item_inner');
if (!listItemInner) return {};
const title = link.textContent.trim();
const seasonElement = listItemInner.querySelector('.season');
const episodeElement = listItemInner.querySelector('.cell.cell-2');
const seasonText = seasonElement ? seasonElement.textContent.trim() : '';
const episodeText = episodeElement ? episodeElement.textContent.trim() : '';
const href = link.getAttribute('href');
return {
id: href,
title: title,
details: `${seasonText} | ${episodeText}`.replace(/\| *$/, '').trim(),
href: href,
episodeInfo: listItemInner.querySelector('.cell.cell-2').textContent.trim()
};
}
// *** MISSING FUNCTION DEFINITION ***
function aggregateUniqueSeriesData() {
const links = document.querySelectorAll('.b-seriesupdate__block_list_link');
const uniqueHrefs = new Set();
const results = [];
links.forEach(link => {
const href = link.getAttribute('href');
if (href && !uniqueHrefs.has(href)) {
uniqueHrefs.add(href);
results.push(extractLocalDetails(link));
}
});
uniqueSeriesData = results;
}
// *********************************
function extractRemoteData(htmlString) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const posterImg = doc.querySelector('img[itemprop="image"]');
let posterUrl = null;
if (posterImg) {
posterUrl = posterImg.getAttribute('data-src') || posterImg.src;
if (posterUrl && posterUrl.startsWith('/')) {
posterUrl = window.location.origin + posterUrl;
}
}
const descElement = doc.querySelector('.b-post__description_text');
const description = descElement ? descElement.textContent.trim() : 'Описание не найдено.';
const ratings = {};
const rezkaScore = doc.querySelector('.b-post__rating .num');
ratings.rezka = rezkaScore ? rezkaScore.textContent.trim() : 'N/A';
const imdbScore = doc.querySelector('.b-post__info_rates.imdb .bold');
ratings.imdb = imdbScore ? imdbScore.textContent.trim() : 'N/A';
const kpScore = doc.querySelector('.b-post__info_rates.kp .bold');
ratings.kp = kpScore ? kpScore.textContent.trim() : 'N/A';
return { posterUrl, description, ratings };
} catch (e) {
return { posterUrl: null, description: 'Ошибка при парсинге описания.', ratings: { rezka: 'N/A', imdb: 'N/A', kp: 'N/A' } };
}
}
function fetchRemoteData(href) {
if (remoteCache[href]) {
return Promise.resolve(remoteCache[href]);
}
let url = href;
if (!url.startsWith('http')) {
url = window.location.origin + href;
}
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: url,
onload: function(response) {
if (response.status === 200) {
const data = extractRemoteData(response.responseText);
if (data.description !== 'Ошибка при парсинге описания.') {
remoteCache[href] = data;
saveCache();
}
resolve(data);
} else {
resolve({ posterUrl: null, description: 'Ошибка сети.', ratings: {} });
}
},
onerror: function(error) {
resolve({ posterUrl: null, description: 'Ошибка GM_xmlHttpRequest.', ratings: {} });
}
});
});
}
// --- Hover Persistence Logic ---
function clearHideTimer() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
}
function delayedHide() {
clearHideTimer();
const popup = document.getElementById('tm_rezka_popup');
hideTimer = setTimeout(() => {
const isHoveringPopup = popup.matches(':hover');
if (isHoveringPopup || currentPopupTriggerHref) {
return;
}
popup.style.opacity = 0;
setTimeout(() => {
if (popup.style.opacity == 0) {
popup.style.display = 'none';
currentPopupTriggerHref = null;
}
}, 200);
}, HIDE_DELAY);
}
function createOrGetPopup() {
let popup = document.getElementById('tm_rezka_popup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'tm_rezka_popup';
document.body.appendChild(popup);
popup.addEventListener('mouseenter', clearHideTimer);
popup.addEventListener('mouseleave', handlePopupMouseLeave);
}
return popup;
}
function handlePopupMouseLeave() {
currentPopupTriggerHref = null;
delayedHide();
}
function renderHoverContent(series, remoteData, isLoading = false) {
const ratings = remoteData.ratings || {};
let ratingHtml = '';
let descriptionText = remoteData.description || 'Загрузка описания...';
if (!isLoading && ratings.imdb) {
ratingHtml = `
<div class="tm-ratings">
<div class="tm-rating-item">
<span class="tm-rating-score">${ratings.imdb}</span><br>
<span class="tm-rating-label">IMDb</span>
</div>
<div class="tm-rating-item">
<span class="tm-rating-score">${ratings.kp}</span><br>
<span class="tm-rating-label">Кинопоиск</span>
</div>
</div>
`;
}
if (isLoading) {
descriptionText = 'Загрузка описания...';
}
return `
${ratingHtml}
<span class="tm-title-popup">${series.title}</span>
<span class="tm-episode-popup">${series.details}</span>
<div class="tm-description-wrapper">${descriptionText}</div>
`;
}
async function handlePosterMouseEnter(event) {
clearHideTimer();
const posterContainer = event.currentTarget;
const href = posterContainer.getAttribute('data-href');
const series = uniqueSeriesData.find(item => item.href === href);
if (!series) return;
currentPopupTriggerHref = href;
const popup = createOrGetPopup();
popup.classList.add('floating-details-mode');
const rect = posterContainer.getBoundingClientRect();
let top = rect.bottom + 1;
let left = rect.left;
if (top + POPUP_HEIGHT > window.innerHeight) {
top = rect.top - POPUP_HEIGHT - 1;
}
const maxLeft = window.innerWidth - POPUP_WIDTH - 10;
popup.style.top = `${Math.max(10, top)}px`;
popup.style.left = `${Math.min(maxLeft, left)}px`;
popup.style.display = 'block';
popup.style.opacity = 1;
let remoteData = remoteCache[href];
if (!remoteData || !remoteData.ratings || !remoteData.description) {
popup.innerHTML = renderHoverContent(series, remoteData || {}, true);
remoteData = await fetchRemoteData(href);
if (currentPopupTriggerHref !== href && !popup.matches(':hover')) return;
popup.innerHTML = renderHoverContent(series, remoteData, false);
} else {
popup.innerHTML = renderHoverContent(series, remoteData, false);
}
}
function handlePosterMouseLeave() {
currentPopupTriggerHref = null;
delayedHide();
}
// --- Grid Rendering and Pagination ---
function createGridOverlay() {
let overlay = document.getElementById('tm_grid_overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'tm_grid_overlay';
overlay.innerHTML = `
<div id="tm_grid_header">
<span id="tm_grid_title">Обновления Сериалов (Всего: 0)</span>
<div id="tm_grid_controls">
<button id="tm_clear_cache_button">Очистить кэш</button>
<button id="tm_grid_close">Закрыть (Esc)</button>
</div>
</div>
<div id="tm_grid_container"></div>
<div id="tm_pagination"></div>
`;
document.body.appendChild(overlay);
document.getElementById('tm_grid_close').onclick = toggleGridVisibility;
document.getElementById('tm_clear_cache_button').onclick = clearCache;
document.addEventListener('keydown', (e) => {
if (e.key === "Escape" && overlay.style.display === 'block') {
toggleGridVisibility();
}
});
}
return overlay;
}
function renderGridItem(seriesItem) {
const itemDiv = document.createElement('div');
itemDiv.className = 'tm_grid_item';
const remoteData = remoteCache[seriesItem.href] || {};
const isCached = remoteData.posterUrl && remoteData.description;
let posterHtml = `<div class="tm-image" data-href="${seriesItem.href}" style="cursor: pointer;">
<div style="width:100px; height:150px; background:#333; display:flex; align-items:center; justify-content:center; border-radius:3px; font-size:11px;">${isCached ? 'Cached' : 'Loading...'}</div>
</div>`;
let ratingHtml = '';
if (remoteData.posterUrl) {
posterHtml = `<div class="tm-image" data-href="${seriesItem.href}" style="cursor: pointer;">
<img src="${remoteData.posterUrl}" loading="lazy">
</div>`;
}
if (remoteData.ratings && remoteData.ratings.imdb !== 'N/A') {
ratingHtml = `<div class="tm-rating-mini">IMDb: ${remoteData.ratings.imdb} | KP: ${remoteData.ratings.kp}</div>`;
}
itemDiv.innerHTML = `
${posterHtml}
<div class="tm-info">
<a class="tm-title" href="${seriesItem.href}" target="_blank">${seriesItem.title}</a>
<div class="tm-details">${seriesItem.details}</div>
<div class="tm-episode-info">${seriesItem.episodeInfo}</div>
${ratingHtml}
</div>
`;
// ATTACH HOVER LISTENERS
const posterArea = itemDiv.querySelector('.tm-image');
posterArea.addEventListener('mouseenter', handlePosterMouseEnter);
posterArea.addEventListener('mouseleave', handlePosterMouseLeave);
// Async fetching (Only fetch if not fully cached)
if (!isCached) {
fetchRemoteData(seriesItem.href).then(data => {
if (document.getElementById('tm_grid_overlay').style.display !== 'block') return;
if (data.posterUrl) {
const currentPosterArea = itemDiv.querySelector('.tm-image');
if(currentPosterArea){
if (currentPosterArea.textContent.includes('Loading') || currentPosterArea.textContent.includes('Cached')) {
currentPosterArea.innerHTML = `<img src="${data.posterUrl}" loading="lazy">`;
}
}
const updatedRatingHtml = `<div class="tm-rating-mini">IMDb: ${data.ratings.imdb} | KP: ${data.ratings.kp}</div>`;
if (!ratingHtml) {
const infoDiv = itemDiv.querySelector('.tm-info');
infoDiv.insertAdjacentHTML('beforeend', updatedRatingHtml);
}
}
});
}
return itemDiv;
}
function renderPagination(totalPages) {
const paginationContainer = document.getElementById('tm_pagination');
paginationContainer.innerHTML = '';
if (totalPages <= 1) return;
const createButton = (text, page, isCurrent = false, isDisabled = false) => {
const btn = document.createElement('button');
btn.textContent = text;
btn.className = `tm_page_button${isCurrent ? ' current' : ''}`;
btn.disabled = isDisabled;
if (!isDisabled && !isCurrent) {
btn.onclick = () => {
currentPage = page;
renderGrid(uniqueSeriesData);
document.getElementById('tm_grid_overlay').scrollTop = 0;
};
}
return btn;
};
paginationContainer.appendChild(createButton('«', currentPage - 1, false, currentPage === 1));
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
for (let i = startPage; i <= endPage; i++) {
paginationContainer.appendChild(createButton(i, i, i === currentPage));
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
paginationContainer.appendChild(document.createTextNode(' ... '));
}
paginationContainer.appendChild(createButton(totalPages, totalPages, totalPages === currentPage));
}
paginationContainer.appendChild(createButton('»', currentPage + 1, false, currentPage === totalPages));
}
function renderGrid(data) {
const container = document.getElementById('tm_grid_container');
container.innerHTML = '';
if (data.length === 0) {
container.innerHTML = '<p style="text-align:center; color:#888;">Нет данных об обновлениях.</p>';
return;
}
const totalPages = Math.ceil(data.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentData = data.slice(startIndex, endIndex);
currentData.forEach(item => {
container.appendChild(renderGridItem(item));
});
document.getElementById('tm_grid_title').textContent = `Обновления Сериалов (Всего: ${data.length})`;
renderPagination(totalPages);
}
function toggleGridVisibility() {
const overlay = createGridOverlay();
if (overlay.style.display === 'block') {
overlay.style.display = 'none';
delayedHide();
} else {
aggregateUniqueSeriesData();
currentPage = 1;
renderGrid(uniqueSeriesData);
overlay.style.display = 'block';
}
}
// --- Main Initialization ---
async function initialize() {
await loadCache();
// 1. Create the toggle button
const button = document.createElement('div');
button.id = 'tm_grid_button';
button.textContent = 'Открыть Обновления (Grid)';
button.onclick = toggleGridVisibility;
document.body.appendChild(button);
// 2. Pre-create the overlay and hover popup (initially hidden)
createGridOverlay();
createOrGetPopup();
}
initialize();
})();