Синхронный ZIP-движок. Умные закладки (точный фикс нумерации страниц). Скачивание прямо из списка глав. Смайлы в чате. Фикс шапки.
// ==UserScript==
// @name BetterMangaBuff
// @namespace https://mangabaf-dl
// @version 3
// @description Синхронный ZIP-движок. Умные закладки (точный фикс нумерации страниц). Скачивание прямо из списка глав. Смайлы в чате. Фикс шапки.
// @author countlynxz
// @match *://mangabuff.ru/*
// @match *://*.mangabuff.ru/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect *
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const isChapterPage = () => location.pathname.includes('/manga/') && /\/\d+/.test(location.pathname);
// =========================================================================
// 1. СТИЛИ ИНТЕРФЕЙСА
// =========================================================================
GM_addStyle(`
body.mb-reader-mode header.reader__header,
body.mb-reader-mode .reader__header {
background: transparent !important; background-color: transparent !important;
backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
border-bottom: none !important; box-shadow: none !important;
display: flex !important; align-items: center !important; justify-content: space-between !important;
}
body.mb-reader-mode .reader__header > div {
background: rgba(20, 20, 20, 0.75) !important;
backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important; border-radius: 12px !important;
padding: 6px 12px !important; transition: all 0.2s ease !important;
}
body.mb-reader-mode .reader__header [class*="dropdown-menu"],
body.mb-reader-mode .reader__header .dropdown-menu {
background: #141414 !important; background-color: #141414 !important;
border: 1px solid #262626 !important; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6) !important;
border-radius: 8px !important; padding: 8px 0 !important;
}
body.mb-reader-mode .reader__header [class*="dropdown-menu"] a,
body.mb-reader-mode .reader__header .dropdown-menu a {
color: #b3b3b3 !important; padding: 10px 16px !important; display: flex !important;
align-items: center !important; gap: 10px !important; transition: background 0.15s ease, color 0.15s ease !important;
}
body.mb-reader-mode .reader__header [class*="dropdown-menu"] a:hover {
background: #222 !important; color: #fff !important;
}
/* Чат */
input[placeholder*="Отправить сообщение"], textarea[placeholder*="Отправить сообщение"],
div:has(> input[placeholder*="Отправить сообщение"]) {
border: none !important; outline: none !important; box-shadow: none !important;
}
.custom-emoji-btn { background: none; border: none; font-size: 20px; cursor: pointer; padding: 0 8px; transition: transform 0.1s; user-select: none; }
.custom-emoji-btn:hover { transform: scale(1.15); }
.custom-emoji-picker { position: absolute; bottom: 50px; left: 10px; background: #1e1e1e; border: 1px solid #333; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); padding: 10px; display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; z-index: 99999; max-height: 200px; overflow-y: auto; width: 220px; }
.custom-emoji-item { font-size: 20px; cursor: pointer; text-align: center; padding: 4px; border-radius: 4px; transition: background 0.1s; user-select: none; }
.custom-emoji-item:hover { background: #333; }
/* Кнопки скачивания */
#mb-download-btn { position: fixed; bottom: 20px; right: 20px; z-index: 99998; background: #ff5c5c; color: #fff; border: none; padding: 12px 20px; border-radius: 8px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.2); transition: all 0.2s ease; }
#mb-download-btn:hover { background: #e04e4e; transform: scale(1.03); }
#mb-download-btn.loading { background: #666; cursor: wait; }
.mb-list-dl-btn { background: rgba(255, 92, 92, 0.1); color: #ff5c5c; border: 1px solid rgba(255, 92, 92, 0.3); border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 14px; margin-left: 15px; transition: all 0.2s ease; z-index: 10; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; }
.mb-list-dl-btn:hover { background: rgba(255, 92, 92, 0.8); color: #fff; transform: translateY(-1px); }
.mb-list-dl-btn.loading { background: #666; color: #fff; border-color: #666; cursor: wait; transform: none; }
/* Умные закладки */
#mb-bookmark-toast { position: fixed; top: 80px; left: 50%; transform: translateX(-50%); z-index: 99999; background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(8px); border: 1px solid #333; color: #fff; padding: 10px 20px; border-radius: 30px; font-size: 14px; box-shadow: 0 10px 25px rgba(0,0,0,0.6); display: flex; align-items: center; gap: 15px; animation: mbSlideDown 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
#mb-bookmark-toast span.mb-bm-link { color: #5eff5e; font-weight: bold; cursor: pointer; text-decoration: underline; transition: color 0.2s; }
#mb-bookmark-toast span.mb-bm-link:hover { color: #8cff8c; }
#mb-bookmark-toast span.mb-bm-close { cursor: pointer; color: #888; font-size: 16px; margin-left: 5px; }
#mb-bookmark-toast span.mb-bm-close:hover { color: #fff; }
@keyframes mbSlideDown { from { top: -50px; opacity: 0; } to { top: 80px; opacity: 1; } }
/* Уведомления */
.mb-toast { position: fixed; bottom: 80px; right: 20px; z-index: 99999; background: #222; color: #fff; padding: 10px 15px; border-radius: 6px; font-size: 14px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); animation: mbFadeIn 0.3s ease; }
@keyframes mbFadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.mbd-ok { color: #5eff5e; font-weight: bold; }
.mbd-err { color: #ff5e5e; font-weight: bold; }
`);
// =========================================================================
// 2. БАЗОВЫЕ ФУНКЦИИ (Архиватор и Уведомления)
// =========================================================================
function showToast(html, text, duration = 3000) {
const t = document.createElement('div');
t.className = 'mb-toast'; t.innerHTML = html || text;
document.body.appendChild(t);
setTimeout(() => t.remove(), duration);
}
function makeZip(files) {
const crcTable = [];
for (let n = 0; n < 256; n++) { let c = n;
for (let k = 0; k < 8; k++) c = ((c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1));
crcTable[n] = c; }
function getCRC32(data) { let crc = 0 ^ (-1);
for (let i = 0; i < data.length; i++) crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xFF];
return (crc ^ (-1)) >>> 0; }
const parts = []; const centralDirectory = []; let currentOffset = 0;
const now = new Date();
const time = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xFFFF;
const date = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
for (const file of files) {
const nameBytes = new TextEncoder().encode(file.name);
const dataBytes = file.data; const crc = getCRC32(dataBytes); const size = dataBytes.length;
const lfh = new Uint8Array(30 + nameBytes.length); const view = new DataView(lfh.buffer);
view.setUint32(0, 0x04034b50, true); view.setUint16(4, 10, true); view.setUint16(10, time, true); view.setUint16(12, date, true);
view.setUint32(14, crc, true); view.setUint32(18, size, true); view.setUint32(22, size, true); view.setUint16(26, nameBytes.length, true);
lfh.set(nameBytes, 30); parts.push(lfh); parts.push(dataBytes);
const cdh = new Uint8Array(46 + nameBytes.length); const cdView = new DataView(cdh.buffer);
cdView.setUint32(0, 0x02014b50, true); cdView.setUint16(4, 20, true); cdView.setUint16(6, 10, true); cdView.setUint16(12, time, true);
cdView.setUint16(14, date, true); cdView.setUint32(16, crc, true); cdView.setUint32(20, size, true); cdView.setUint32(24, size, true);
cdView.setUint16(28, nameBytes.length, true); cdView.setUint32(42, currentOffset, true); cdh.set(nameBytes, 46);
centralDirectory.push(cdh); currentOffset += lfh.length + dataBytes.length;
}
const cdBlob = new Blob(centralDirectory); const eocd = new Uint8Array(22); const eocdView = new DataView(eocd.buffer);
eocdView.setUint32(0, 0x06054b50, true); eocdView.setUint16(8, files.length, true); eocdView.setUint16(10, files.length, true);
eocdView.setUint32(12, cdBlob.size, true); eocdView.setUint32(16, currentOffset, true);
parts.push(cdBlob); parts.push(eocd); return new Blob(parts, { type: 'application/zip' });
}
// =========================================================================
// 3. УМНЫЕ ЗАКЛАДКИ (Исправленный точный подсчет)
// =========================================================================
let scrollTimeout;
window.addEventListener('scroll', () => {
if (!isChapterPage()) return;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const images = document.querySelectorAll('.reader-images img, .page-image img, [class*="chapter"] img, .reader img');
if (!images.length) return;
let currentIndex = 0;
// Ищем картинку, которая сейчас пересекает центр экрана
for (let i = 0; i < images.length; i++) {
const rect = images[i].getBoundingClientRect();
if (rect.top <= window.innerHeight / 2 && rect.bottom >= window.innerHeight / 2) {
currentIndex = i;
break;
}
}
// Сохраняем индекс картинки, если ушли дальше первой
if (currentIndex > 0) {
localStorage.setItem('mb_bm_' + location.pathname, currentIndex);
}
}, 500);
});
function checkSmartBookmark() {
if (!isChapterPage()) return;
const savedIndexStr = localStorage.getItem('mb_bm_' + location.pathname);
if (!savedIndexStr) return;
const savedIndex = parseInt(savedIndexStr, 10);
const oldToast = document.getElementById('mb-bookmark-toast');
if (oldToast) oldToast.remove();
if (savedIndex && savedIndex > 0) {
const toast = document.createElement('div');
toast.id = 'mb-bookmark-toast';
// Отображаем ровно сохраненное число без накруток, теперь оно совпадает со страницей
toast.innerHTML = `
📍 Вы остановились на ${savedIndex} стр.
<span class="mb-bm-link">Вернуться</span>
<span class="mb-bm-close">✖</span>
`;
toast.querySelector('.mb-bm-link').addEventListener('click', () => {
const images = document.querySelectorAll('.reader-images img, .page-image img, [class*="chapter"] img, .reader img');
if (images[savedIndex]) {
images[savedIndex].scrollIntoView({ behavior: 'smooth', block: 'start' });
toast.remove();
} else {
showToast(null, '❌ Страница еще не подгрузилась, листайте чуть медленнее...', 3000);
}
});
toast.querySelector('.mb-bm-close').addEventListener('click', () => toast.remove());
document.body.appendChild(toast);
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 10000);
}
}
// =========================================================================
// 4. СКАЧИВАНИЕ (В читалке и из списка)
// =========================================================================
async function downloadChapterBackground(url, chapterNameFallback, btnElement) {
showToast(null, `⏳ Запрос главы...`, 2000);
GM_xmlhttpRequest({
method: 'GET', url: url,
onload: async function(res) {
if (res.status === 200) {
const doc = new DOMParser().parseFromString(res.responseText, "text/html");
const urls = [];
doc.querySelectorAll('.reader-images img, .page-image img, [class*="chapter"] img, .reader img').forEach(img => {
const src = img.getAttribute('data-src') || img.src;
if (src && !urls.includes(src) && !src.includes('avatar') && !src.includes('logo')) urls.push(src);
});
if (!urls.length) {
showToast(null, '❌ Страницы в этой главе не найдены!', 3000);
btnElement.classList.remove('loading'); btnElement.innerHTML = '📥'; return;
}
let downloadedFiles = [], done = 0, errors = 0; btnElement.innerHTML = `0/${urls.length}`;
await Promise.all(urls.map((imgUrl, index) => new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET', url: imgUrl, responseType: 'arraybuffer', headers: { Referer: location.origin },
onload: function (imgRes) {
if (imgRes.status === 200 && imgRes.response) {
try {
const ext = imgUrl.split('.').pop().split('?')[0] || 'jpg';
downloadedFiles.push({ name: String(index + 1).padStart(3, '0') + '.' + ext, data: new Uint8Array(imgRes.response), index: index });
done++;
} catch(e) { errors++; }
} else errors++;
btnElement.innerHTML = `${done + errors}/${urls.length}`; resolve();
},
onerror: () => { errors++; btnElement.innerHTML = `${done + errors}/${urls.length}`; resolve(); },
ontimeout: () => { errors++; btnElement.innerHTML = `${done + errors}/${urls.length}`; resolve(); }
});
})));
if (done === 0) {
showToast(null, '❌ Ошибка загрузки страниц.', 3000);
btnElement.classList.remove('loading'); btnElement.innerHTML = '📥'; return;
}
btnElement.innerHTML = '📦';
let safeName = chapterNameFallback.replace(/[^\w\sа-яА-ЯёЁ]/g, '').trim().slice(0, 40) || 'chapter';
let mangaTitle = document.title.split('-')[0].trim().replace(/[^\w\sа-яА-ЯёЁ]/g, '');
setTimeout(() => {
try {
downloadedFiles.sort((a, b) => a.index - b.index);
const a = document.createElement('a');
a.href = URL.createObjectURL(makeZip(downloadedFiles));
a.download = `${mangaTitle}_${safeName}`.replace(/\s+/g, '_') + '.zip';
a.click(); URL.revokeObjectURL(a.href);
showToast(`<span class="mbd-ok">✅ ${safeName} скачана!</span>`, 4000);
} catch (err) { showToast(null, '❌ Ошибка архива.', 4000); }
finally { btnElement.classList.remove('loading'); btnElement.innerHTML = '📥'; }
}, 50);
} else {
showToast(null, '❌ Ошибка доступа.', 3000);
btnElement.classList.remove('loading'); btnElement.innerHTML = '📥';
}
}
});
}
function injectListDownloadButtons() {
if (!location.pathname.includes('/manga/') || isChapterPage()) return;
document.querySelectorAll('a[href*="/manga/"]:not(.mb-dl-injected)').forEach(link => {
const href = link.getAttribute('href'); if (!href) return;
const text = link.innerText.toLowerCase();
if (/\/\d+(?:\/\d+)?\/?$/.test(href) && (text.includes('том') || text.includes('глава') || text.includes('прочитано'))) {
const rightPart = Array.from(link.children).find(child => /\d{2}\.\d{2}\.\d{4}/.test(child.innerText) || child.innerText.includes('K') || child.innerText.toLowerCase().includes('прочитано'));
if (!rightPart) return;
link.classList.add('mb-dl-injected');
const dlBtn = document.createElement('button');
dlBtn.innerHTML = '📥'; dlBtn.className = 'mb-list-dl-btn'; dlBtn.title = 'Скачать главу';
dlBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
if (dlBtn.classList.contains('loading')) return;
dlBtn.classList.add('loading'); dlBtn.innerHTML = '⏳';
const chapNameMatch = link.innerText.match(/Том\s*\d+\s*Глава\s*\d+(?:\.\d+)?/i) || link.innerText.match(/Глава\s*\d+(?:\.\d+)?/i);
let chapName = 'Chapter';
if (chapNameMatch) { chapName = chapNameMatch[0]; }
else {
const parts = href.split('/').filter(Boolean);
chapName = !isNaN(parts[parts.length - 2]) ? `Том ${parts[parts.length - 2]} Глава ${parts[parts.length - 1]}` : `Глава ${parts[parts.length - 1]}`;
}
downloadChapterBackground(link.href, chapName, dlBtn);
});
rightPart.appendChild(dlBtn);
}
});
}
// =========================================================================
// 5. ОСТАЛЬНОЙ ИНТЕРФЕЙС
// =========================================================================
function injectPromoLink() {
const subMenu = document.querySelector('.header__link-sub');
if (subMenu && !subMenu.querySelector('a[href*="promo-code"]')) {
const a = document.createElement('a'); a.href = 'https://mangabuff.ru/promo-code'; a.className = 'mb-promo-link'; a.innerText = 'Промокоды';
const sLink = subMenu.querySelector('a'); if (sLink) a.className += ' ' + sLink.className;
subMenu.appendChild(a);
}
}
const emojis = ['😀','😂','🤣','😊','😍','😘','😜','🤫','🤔','😎','🙄','🤡','💩','🔥','✨','💯','👍','👎','❤️','💔','😭','😡','😱','💀','👽','🤖','👑','👀','💬','🐾','🐈','🦊','🍕','🎮','🎲'];
function initEmojiPicker() {
const input = document.querySelector('input[placeholder*="Отправить сообщение"], textarea[placeholder*="Отправить сообщение"]');
if (!input || input.dataset.emojiInit) return; input.dataset.emojiInit = "true";
const dBtn = input.parentElement.querySelector('button, div[class*="theme"]');
const eBtn = document.createElement('button'); eBtn.type = 'button'; eBtn.className = 'custom-emoji-btn'; eBtn.innerText = '😀';
const picker = document.createElement('div'); picker.className = 'custom-emoji-picker'; picker.style.display = 'none';
input.parentElement.style.position = 'relative';
emojis.forEach(e => {
const s = document.createElement('span'); s.className = 'custom-emoji-item'; s.innerText = e;
s.addEventListener('click', ev => {
ev.stopPropagation(); const start = input.selectionStart, end = input.selectionEnd, val = input.value;
input.value = val.substring(0, start) + e + val.substring(end); input.focus();
input.selectionStart = input.selectionEnd = start + e.length; input.dispatchEvent(new Event('input', { bubbles: true }));
});
picker.appendChild(s);
});
if (dBtn) dBtn.after(eBtn); else input.before(eBtn);
input.parentElement.appendChild(picker);
eBtn.addEventListener('click', ev => { ev.stopPropagation(); picker.style.display = picker.style.display === 'none' ? 'grid' : 'none'; });
document.addEventListener('click', () => { picker.style.display = 'none'; });
}
function manageReaderUI() {
if (isChapterPage()) {
document.body.classList.add('mb-reader-mode');
setTimeout(checkSmartBookmark, 1000);
if (!document.getElementById('mb-download-btn')) {
const btn = document.createElement('button'); btn.id = 'mb-download-btn'; btn.innerHTML = '⬇ Скачать главу'; document.body.appendChild(btn);
btn.addEventListener('click', async () => {
if (btn.classList.contains('loading')) return;
const urls = [];
document.querySelectorAll('.reader-images img, .page-image img, [class*="chapter"] img, .reader img').forEach(img => {
const src = img.getAttribute('data-src') || img.src; if (src && !urls.includes(src) && !src.includes('avatar')) urls.push(src);
});
if (!urls.length) { showToast(null, '❌ Картинки не найдены!', 4000); return; }
btn.classList.add('loading'); let files = [], done = 0, errs = 0; btn.innerHTML = `⏳ 0/${urls.length}`;
await Promise.all(urls.map((url, i) => new Promise(res => {
GM_xmlhttpRequest({
method: 'GET', url: url, responseType: 'arraybuffer',
onload: r => { if (r.status===200 && r.response) { try { files.push({ name: String(i+1).padStart(3, '0')+'.'+(url.split('.').pop().split('?')[0]||'jpg'), data: new Uint8Array(r.response), index: i }); done++; } catch(e){errs++;} } else errs++; btn.innerHTML=`⏳ ${done+errs}/${urls.length}`; res(); },
onerror: () => { errs++; btn.innerHTML=`⏳ ${done+errs}/${urls.length}`; res(); }
});
})));
if (done === 0) { btn.classList.remove('loading'); btn.innerHTML = '⬇ Скачать главу'; return; }
btn.innerHTML = '📦 Упаковываю...';
const m = location.pathname.match(/chapter[-_]?(\d+(?:[._]\d+)?)/i) || location.pathname.match(/\/(\d+(?:[._]\d+)?)\/?$/);
const chap = m ? 'ch' + m[1] : 'chapter'; const title = document.title.replace(/[^\w\sа-яА-ЯёЁ]/g, '').slice(0, 40);
setTimeout(() => {
try { files.sort((a, b) => a.index - b.index); const a = document.createElement('a'); a.href = URL.createObjectURL(makeZip(files)); a.download = title+'_'+chap+'.zip'; a.click(); showToast(`<span class="mbd-ok">✅ Скачано: ${done} стр.</span>`, 5000); }
catch (e) { showToast(null, '❌ Ошибка архива.'); } finally { btn.classList.remove('loading'); btn.innerHTML = '⬇ Скачать главу'; }
}, 50);
});
}
} else {
document.body.classList.remove('mb-reader-mode');
const btn = document.getElementById('mb-download-btn'); if (btn) btn.remove();
}
}
// =========================================================================
// НАБЛЮДАТЕЛИ (SPA Навигация и Хоткеи)
// =========================================================================
document.addEventListener('keydown', e => {
if (!isChapterPage() || ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
let dir = null; if (['ArrowLeft', 'a', 'A', 'ф', 'Ф'].includes(e.key)) dir = 'prev'; if (['ArrowRight', 'd', 'D', 'в', 'В'].includes(e.key)) dir = 'next';
if (!dir) return;
for (let el of document.querySelectorAll('a, button')) {
const text = el.innerText?.toLowerCase() || ''; const href = el.getAttribute('href') || '';
if (dir === 'next' && (text.includes('след') || href.includes('next') || el.classList.contains('next'))) { el.click(); break; }
if (dir === 'prev' && (text.includes('пред') || text.includes('прош') || href.includes('prev') || el.classList.contains('prev'))) { el.click(); break; }
}
});
let lastUrl = location.href;
const globalObserver = new MutationObserver(() => {
injectPromoLink(); initEmojiPicker(); injectListDownloadButtons();
if (location.href !== lastUrl) { lastUrl = location.href; manageReaderUI(); }
});
globalObserver.observe(document.body, { childList: true, subtree: true });
manageReaderUI(); setTimeout(initEmojiPicker, 1000);
})();