собственно, сабж 
провайдер Ростелеком
Отредактировано Merlin777 (Вс, 21 Дек 2025 15:34:45)
Единый форум поддержки |
Привет, Гость! Войдите или зарегистрируйтесь.
Вы здесь » Единый форум поддержки » Сообщения об ошибках » форумы mybb доступны только через VPN
собственно, сабж 
провайдер Ростелеком
Отредактировано Merlin777 (Вс, 21 Дек 2025 15:34:45)
Merlin777
Нужны более точные сведения. Будет возможность сделать трассировку при отключеном ВПН?
На компьютере с ОС Windows:
На клавиатуре нажать комбинацию клавиш WIN + R
В появившемся окне вставить cmd -> нажать Enter
Вписать tracert forum.mybb.ru -> нажать Enter
Дождаться надписи Трассировка завершена
Сделать скриншот окна и прикрепить его в этой теме.
Merlin777
Со стороны Ростелекома не видится проблем, трассировка успешная до наших IP, значит проблема не в маршрутизации. У меня тоже Ростелеком, проблем с доступом не наблюдаю.
Есть подозрение, что проблему может вызывать бан IP по многократному превышению допустимого числа запросов в секунду. Но для проверки мне понадобится ваш IP (без VPN естественно), актуальный на момент выявленной проблемы (т.е. когда не можете попасть на форумы).
Блокировка автоматически снимается спустя час после вынесения.
У вас на форуме много скриптов, создающих дополнительные запросы, возможно какой-то (какие-то) из них не оптимизирован(ы) и в какие-то моменты начинают ддосить запросами сервер, вследствие чего IP попадает под блокировку.
С VPN проблема может не проявляться или быть менее выраженной ввиду снижения скорости передачи данных (и времени ответа), вследствие чего запросы идут с меньшей частотой и количество их в секунду снижается.
Added after 18 minutes 16 seconds:
Вот этот скрипт у вас множество одновременных запросов генерирует: https://forumstatic.ru/files/0010/54/6f/78002.js?v=26 довольно часто вызывая 503 ошибку, что легко увидеть, взглянув в консоль. Если открыть несколько вкладок или с разных устройств с одним IP, вполне реально словить бан по IP. При этом совершенно непонятно, для чего делать несколько отдельных запросов на каждый пост, запросы вида: https://gribnikikybani.mybb.ru/api.php?method=post.get&post_id=55479&fields=id%2Cusername%2Cmessage%2Cposted%2Cnum_replies%2Csubject&_=1766382026049
когда можно передать post_id массивом или просто через запятую в один-единственный запрос и работать с полученными данными для всех post_id разом.
Форум не доступен не со всех VPN, что сразу отменяет вашу теорию про бан
Сейчас я пишу удалённо с физического сервера в стойке в Европе, так сказать, использую служебное подожение в личных целях
Про запросы через скрипт - там НЕТ "многократному превышению допустимого числа запросов в секунду", там задержка по времени везде между запросами.
Сначала запрашивается список тем, потом идут запросы на содержание постов, посты санитизируются и показываются в лайв-боксе - и это при первоначальном наполнении лайв-бокса. Через 7 секунд запрашивается список ТЕМ, и в ИЗМЕНИВШИХСЯ ПО ВРЕМЕНИ темах - новые посты запрашиваются по одному. Это основная логика обновления (там ещё есть
).
Но даже если бы все 10 постов запрашивались каждые 7 секунд, ещё и с задержкой по времени между собой, это вообще как-то не "тянет" на DDOS атаку
Проблемы с доступом ещё и до лайв-бокса наблюдались, если что
И браузеры в неактивных вкладках блокируют внешние запросы, так что что 100 вкладок открыть с главной страницей, что одну, результат не меняется
Насчёт "можно передать post_id массивом или просто через запятую в один-единственный запрос и работать с полученными данными для всех post_id разом" это идея хорошая, если Вы поможете это сделать, приведя пример рабочего кода
Кроме всего этого, в коде есть поддержка Чёрного списка.
Если последнее сообщение в теме написано пользователем из чёрного списка, то оно не должно отображаться.
Вместо этого запрашивается последняя страница темы (через post.get с topic_id и limit=30, сортировка по дате в обратном порядке).
Среди сообщений на этой странице ищется первое (снизу вверх), автор которого НЕ в чёрном списке.
Для этого используется post.get с topic_id, а не с конкретным post_id.
Чтобы исключить точечные запросы по get для Чёрного списка, Вы бы могли добавить в API опцию исключения сообщений от заданных ников из выдачи, типа exclude_user_id или exclude_username, это бы радикально решило вопрос 
Отредактировано Merlin777 (Пн, 22 Дек 2025 23:33:22)
Я код не выкладывал в исходниках по причине того, что нужно было бы объяснять всё, там много неочевидной логики
Но для улучшения запросов к серверу, без кода видимо не обойтись
Исходный код JS части лайв-бокса, последняя версия:
Код:// Настройки фильтрации и пагинации const POSTS_PER_PAGE = 30; const EXCLUDED_FORUM_IDS = new Set([42, 50]); // ID разделов, которые не показывать const EXCLUDED_TOPIC_IDS = new Set([]); // ID тем, которые не показывать const Utils = { // Загружает список заблокированных пользователей из localStorage getBlockedUserIdsSet() { try { const raw = localStorage.getItem('blockedUsers'); if (!raw) return new Set(); const list = JSON.parse(raw); const ids = list .filter(item => item && typeof item === 'object' && item.id != null) .map(item => { const id = Number(item.id); return isNaN(id) ? null : id; }) .filter(id => id !== null); return new Set(ids); } catch (e) { return new Set(); } }, // Извлекает "чистое" название темы из формата "color;;bg;;title" extractCleanTitle(titleText) { if (!titleText || !titleText.includes(';;')) return titleText || ''; const parts = titleText.split(';;'); if (parts.length >= 3) { return parts.slice(2).join(';;'); } return titleText; }, // Обрезает текст с многоточием truncate(text, maxLength = 40) { if (!text) return ""; text = text.trim(); return text.length > maxLength ? text.slice(0, maxLength) + "…" : text; }, // Генерирует уникальный ID для строки темы getTopicId(item) { return `topic_${item[4]}`; // item[4] = topic ID } }; const Settings = { // Асинхронная загрузка настроек пользователя loadUserSettings() { return new Promise((resolve) => { const defaultSettings = { enabled: true, showNewestOnTop: false, maxTopicLength: 40, maxMobileTopicLength: 15, maxMessageLength: 60, maxMobileAuthorLength: 12, coloredTopics: true, topicCount: 6, rowHeight: 37, ignoreBlockedOnLastPage: true, openLinksInNewTab: false, // ← ДОБАВЛЕНО updatedAt: 0 }; try { const localSettings = localStorage.getItem('liveBoxUserSettings'); if (localSettings) { const parsed = JSON.parse(localSettings); resolve({ ...defaultSettings, ...parsed }); return; } } catch (e) { } resolve(defaultSettings); }); }, // Синхронная версия — для частого использования в UI/API getLiveSettingsSync() { const defaultSettings = { enabled: true, showNewestOnTop: false, maxTopicLength: 40, maxMobileTopicLength: 15, maxMessageLength: 60, maxMobileAuthorLength: 12, coloredTopics: true, topicCount: 6, rowHeight: 33, ignoreBlockedOnLastPage: true, openLinksInNewTab: false, // ← ДОБАВЛЕНО updatedAt: 0 }; try { const localSettings = localStorage.getItem('liveBoxUserSettings'); if (localSettings) { return { ...defaultSettings, ...JSON.parse(localSettings) }; } } catch (e) { } return defaultSettings; }, // Сохранение настроек в localStorage saveUserSettings(settings) { return new Promise((resolve, reject) => { try { localStorage.setItem('liveBoxUserSettings', JSON.stringify(settings)); resolve(); } catch (error) { reject(error); } }); }, // Проверка, включён ли Live-бокс shouldShowLiveBox() { return this.loadUserSettings().then(settings => settings.enabled); }, // Скрывает стандартный блок "Выбор отображения страниц" в профиле hideDisplaySettingsSection() { const fieldsets = document.querySelectorAll('fieldset'); for (const fieldset of fieldsets) { const legendSpan = fieldset.querySelector('legend > span'); if (legendSpan && legendSpan.textContent.trim() === 'Выбор отображения страниц') { fieldset.remove(); break; } } }, // Добавляет панель настроек Live-бокса в профиль (только на своей странице) addProfileSettings() { if (document.getElementById('live-settings-accordion')) return; const urlParams = new URLSearchParams(window.location.search); const profileId = urlParams.get('id'); if (!profileId) return; // Определяем, чей это профиль — свой или чужой let myId = null; if (typeof pun_user !== 'undefined' && pun_user.id && !pun_user.is_guest) { myId = String(pun_user.id); } if (!myId) { const profileLinks = document.querySelectorAll('a[href*="/profile.php?id="]'); for (const link of profileLinks) { try { const hrefId = new URL(link.href).searchParams.get('id'); if (hrefId) { myId = hrefId; break; } } catch (e) { } } } if (!myId || myId !== profileId) return; const punMain = document.getElementById('pun-main'); if (!punMain) { setTimeout(() => this.addProfileSettings(), 500); return; } this.hideDisplaySettingsSection(); this.loadUserSettings().then(settings => { const isMobile = window.innerWidth <= 768; const containerStyle = isMobile ? 'width: 100%;' : 'display: inline-block;'; // Генерация полей ввода (разные для мобилки и десктопа) let textSettingsHtml = ''; if (isMobile) { textSettingsHtml += ` <div> <label> Длина темы (моб.): <input type="number" id="max-mobile-topic-length" value="${settings.maxMobileTopicLength || 15}" min="1" max="20" /> </label> </div> <div> <label> Длина ника (моб.): <input type="number" id="max-mobile-author-length" value="${settings.maxMobileAuthorLength || 12}" min="5" max="20" /> </label> </div> `; } else { textSettingsHtml += ` <div> <label> Количество символов в теме (макс. 60): <input type="number" id="max-topic-length" value="${settings.maxTopicLength || 40}" min="1" max="60" /> </label> </div> <div> <label> Количество символов в сообщении (макс. 60): <input type="number" id="max-message-length" value="${settings.maxMessageLength || 60}" min="1" max="60" /> </label> </div> `; } // Формируем HTML всей панели let settingsHtml = ` <div id="live-settings-accordion" class="live-settings-accordion" style="${containerStyle}"> <div class="live-settings-header"> <span>🟢 Настройки Live-бокса</span> <span class="live-settings-toggle">▼</span> </div> <div class="live-settings-content"> <div class="live-settings-inner"> <div> <label> <input type="checkbox" id="live-enabled" ${settings.enabled ? 'checked' : ''}> 🟢 Включить Live-бокс на главной странице </label> </div> <div> <label> <input type="checkbox" id="live-sort-newest-on-top" ${settings.showNewestOnTop ? 'checked' : ''}> 📌 Новые темы сверху <small style="display:block;color:#777;font-weight:normal;">(если не отмечено — новые снизу)</small> </label> </div> <div> <label> Количество отображаемых тем (3–8): <input type="number" id="live-topic-count" value="${settings.topicCount || 5}" min="3" max="8" /> </label> </div> <div> <label> Высота строки (px): <input type="number" id="live-row-height" value="${settings.rowHeight || 35}" min="20" max="60" /> </label> </div> ${textSettingsHtml} <div> <label> <input type="checkbox" id="colored-topics-enabled" ${settings.coloredTopics ? 'checked' : ''}> 🎨 Раскрашивать цветные темы </label> </div> <div> <label> <input type="checkbox" id="open-links-in-new-tab" ${settings.openLinksInNewTab ? 'checked' : ''}> 🔗 Открывать ссылки в новом окне </label> </div> <div> <label style="display:block;margin-top:8px;"> <input type="checkbox" id="ignore-blocked-on-last-page" ${settings.ignoreBlockedOnLastPage ? 'checked' : ''}> 🚫 Игнорировать сообщения от пользователей из чёрного списка <small style="display:block;color:#777;font-weight:normal;">(проверяются только посты на последней странице темы)</small> </label> </div> <button id="save-live-settings" class="button">💾 Сохранить настройки</button> <div id="live-settings-status"></div> </div> </div> </div> `; // Вставка перед формой редактирования профиля или в #pun-main const profileForm = document.querySelector('form#edit-profile, form[name="edit_profile"]'); const targetContainer = profileForm || punMain; const tempDiv = document.createElement('div'); tempDiv.innerHTML = settingsHtml.trim(); const settingsEl = tempDiv.firstElementChild; if (profileForm) { profileForm.parentNode.insertBefore(settingsEl, profileForm); } else { punMain.prepend(settingsEl); } // Обработчик аккордеона (раскрытие/сворачивание) const header = document.querySelector('.live-settings-header'); if (header) { header.addEventListener('click', () => { const content = header.nextElementSibling; const toggle = header.querySelector('.live-settings-toggle'); if (!content || !toggle) return; const isOpen = content.style.maxHeight && content.style.maxHeight !== '0px'; if (isOpen) { content.style.maxHeight = '0px'; content.style.padding = '0'; toggle.style.transform = 'rotate(0deg)'; } else { const inner = content.querySelector('.live-settings-inner'); if (inner) { const innerHeight = inner.scrollHeight; content.style.maxHeight = innerHeight + 'px'; content.style.padding = '20px 16px'; toggle.style.transform = 'rotate(180deg)'; } } }); } // Обработчик сохранения настроек const saveBtn = document.getElementById('save-live-settings'); if (saveBtn) { saveBtn.addEventListener('click', e => { e.preventDefault(); // Сбор значений const enabled = document.getElementById('live-enabled')?.checked || false; const newestOnTop = document.getElementById('live-sort-newest-on-top')?.checked || false; const colored = document.getElementById('colored-topics-enabled')?.checked || false; const ignoreBlocked = document.getElementById('ignore-blocked-on-last-page')?.checked || false; const openLinksInNewTab = document.getElementById('open-links-in-new-tab')?.checked || false; // ← ДОБАВЛЕНО const topicCount = parseInt(document.getElementById('live-topic-count')?.value) || 6; const rowHeight = parseInt(document.getElementById('live-row-height')?.value) || 33; const newSettings = { enabled, showNewestOnTop: newestOnTop, coloredTopics: colored, ignoreBlockedOnLastPage: ignoreBlocked, openLinksInNewTab, // ← ДОБАВЛЕНО topicCount, rowHeight, updatedAt: Date.now() }; if (isMobile) { newSettings.maxMobileTopicLength = parseInt(document.getElementById('max-mobile-topic-length')?.value) || 15; newSettings.maxMobileAuthorLength = parseInt(document.getElementById('max-mobile-author-length')?.value) || 12; } else { newSettings.maxTopicLength = parseInt(document.getElementById('max-topic-length')?.value) || 40; newSettings.maxMessageLength = parseInt(document.getElementById('max-message-length')?.value) || 60; } // Сохранение + обратная связь this.saveUserSettings(newSettings).then(() => { const statusEl = document.getElementById('live-settings-status'); if (statusEl) { // ✅ Обёрнут в .live-emoji для мгновенного отображения statusEl.innerHTML = '<span class="live-emoji">✅</span> Настройки успешно сохранены!'; statusEl.style.display = 'block'; setTimeout(() => { statusEl.style.display = 'none'; }, 3000); } }).catch(error => { const statusEl = document.getElementById('live-settings-status'); if (statusEl) { statusEl.textContent = '❌ Ошибка: ' + (error.message || error); statusEl.style.display = 'block'; setTimeout(() => { statusEl.style.display = 'none'; }, 5000); } }); }); } }); } }; const API = { // Получает последние темы через API fetchTopics(isInitial = false, currentLimit = null, retryCountAjax = 0, context = {}) { const { isFetching, paused, retryDelay = 1500 } = context; if (paused || isFetching) return; // Параметры для адаптивной загрузки const INITIAL_LIMIT = 15; const REGULAR_LIMIT = 15; const INITIAL_STEP = 10; const REGULAR_STEP = 6; const INITIAL_MAX = 80; const REGULAR_MAX = 40; const maxTopics = 8; const limit = currentLimit !== null ? currentLimit : (isInitial ? INITIAL_LIMIT : REGULAR_LIMIT); const step = isInitial ? INITIAL_STEP : REGULAR_STEP; const maxLimit = isInitial ? INITIAL_MAX : REGULAR_MAX; context.isFetching = true; const fields = 'id,subject,last_username,last_user_id,last_post_id,last_post_date,forum_id,num_replies'; const url = `/api.php?method=topic.getRecent&limit=${limit}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`; fetch(url) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); }) .then(data => { context.isFetching = false; if (!data?.response || !Array.isArray(data.response)) { if (retryCountAjax < 10) setTimeout(() => this.fetchTopics(isInitial, currentLimit, retryCountAjax + 1, context), retryDelay); return; } // Фильтрация и преобразование тем let validTopics = data.response .filter(topic => { if (!topic.last_post_id || !topic.last_post_date || !topic.id) return false; if (EXCLUDED_FORUM_IDS.has(Number(topic.forum_id))) return false; if (EXCLUDED_TOPIC_IDS.has(Number(topic.id))) return false; return true; }) .map(topic => { const timestamp = parseInt(topic.last_post_date); const author = topic.last_username || 'Гость'; const pid = topic.last_post_id; const url = `/viewtopic.php?pid=${pid}#pid${pid}`; const title = topic.subject || '[Без названия]'; const tid = topic.id; const lastUserId = topic.last_user_id ? Number(topic.last_user_id) : 0; const numReplies = topic.num_replies ? parseInt(topic.num_replies) : 0; return [timestamp, author, url, title, tid, pid, lastUserId, numReplies]; }); if (validTopics.length >= maxTopics) { UI.processTopics(validTopics.slice(0, maxTopics), context); return; } if (limit < maxLimit) { setTimeout(() => this.fetchTopics(isInitial, limit + step, retryCountAjax, context), 100); } else { UI.processTopics(validTopics, context); } }) .catch(error => { context.isFetching = false; console.error('Ошибка API topic.getRecent:', error); if (retryCountAjax < 10) setTimeout(() => this.fetchTopics(isInitial, currentLimit, retryCountAjax + 1, context), retryDelay); }); }, // Загружает последнее сообщение темы (с учётом чёрного списка) fetchPost(item, context) { const topicId = item[4]; const lastPostId = item[5]; const lastUserId = item[6]; const numReplies = item[7]; const totalPosts = numReplies + 1; const lastPage = Math.ceil(totalPosts / POSTS_PER_PAGE); const rowTopicId = Utils.getTopicId(item); const userSettings = Settings.getLiveSettingsSync(); const shouldFilter = userSettings.ignoreBlockedOnLastPage; if (!shouldFilter) { return this._loadPostById(lastPostId, item, topicId, lastPage, context); } const blockedSet = Utils.getBlockedUserIdsSet(); if (blockedSet.size === 0) { return this._loadPostById(lastPostId, item, topicId, lastPage, context); } if (!blockedSet.has(lastUserId)) { return this._loadPostById(lastPostId, item, topicId, lastPage, context); } // Если автор в чёрном списке — ищем видимый пост на последней странице this.findVisiblePostOnLastPage(topicId, blockedSet).then(visiblePost => { if (!visiblePost) { const row = document.querySelector(`.post-row[data-topic-id="${rowTopicId}"]`); if (row) row.remove(); return; } const newItem = [...item]; newItem[0] = Math.floor(new Date(visiblePost.posted * 1000).getTime() / 1000); newItem[1] = visiblePost.username; newItem[2] = `/viewtopic.php?id=${topicId}&p=${lastPage}#p${visiblePost.id}`; newItem[5] = visiblePost.id; newItem[6] = visiblePost.user_id; UI.handlePostUpdate( rowTopicId, `post_${visiblePost.id}`, newItem, visiblePost.message, false, visiblePost.id, context ); }).catch(() => { const row = document.querySelector(`.post-row[data-topic-id="${rowTopicId}"]`); if (row) { const loadingMsg = row.querySelector('.loading-msg'); if (loadingMsg) loadingMsg.textContent = '[ошибка]'; } }); }, // Загружает конкретный пост по ID _loadPostById(postId, item, topicId, page, context) { const finalMsgId = `post_${postId}`; const rowTopicId = Utils.getTopicId(item); if (context.seenMessages.has(finalMsgId)) return; context.pendingPosts++; const fields = 'id,username,message,posted,num_replies,subject'; const url = `/api.php?method=post.get&post_id=${postId}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`; fetch(url) .then(response => response.json()) .then(data => { context.pendingPosts--; if (!data?.response || !Array.isArray(data.response) || data.response.length === 0) { UI.handlePostUpdate(rowTopicId, finalMsgId, item, "[сообщения не найдены]", true, null, context); if (context.pendingPosts <= 0 && context.firstLoad) context.firstLoad = false; return; } const post = data.response[0]; const messageText = post.message || ''; const colorSubject = post.subject || item[3]; const newItem = [...item]; newItem[2] = `/viewtopic.php?id=${topicId}&p=${page}#p${post.id}`; newItem[3] = colorSubject; UI.handlePostUpdate(rowTopicId, finalMsgId, newItem, messageText, false, post.id, context); if (context.pendingPosts <= 0 && context.firstLoad) context.firstLoad = false; }) .catch(() => { context.pendingPosts--; UI.handlePostUpdate(rowTopicId, finalMsgId, item, "[недоступно]", true, null, context); if (context.pendingPosts <= 0 && context.firstLoad) context.firstLoad = false; }); }, // Ищет первый незаблокированный пост на последней странице темы async findVisiblePostOnLastPage(topicId, blockedSet) { const fields = 'id,user_id,username,posted,message'; const url = `/api.php?method=post.get&topic_id=${topicId}&limit=${POSTS_PER_PAGE}&sort_by=posted&sort_dir=desc&fields=${encodeURIComponent(fields)}&_=${Date.now()}`; try { const response = await fetch(url); const data = await response.json(); if (!data?.response || !Array.isArray(data.response)) return null; for (const post of data.response) { const userId = Number(post.user_id); if (!blockedSet.has(userId)) { return { id: post.id, user_id: userId, username: post.username || 'Гость', posted: post.posted, message: post.message || '' }; } } return null; } catch (error) { return null; } } }; const UI = { bodyGrid: null, wrapper: null, // Главная функция запуска Live-бокса на главной странице runLiveBox() { if (window.location.pathname !== "/" && !window.location.pathname.endsWith("index.php")) return; const maxTopics = 8; let paused = false, activeTab = true, isFetching = false; const seenMessages = new Set(), trackedPosts = new Map(); let inactiveTime = 0, checkInterval = 7000, firstLoad = true, pendingPosts = 0; let lastTopicIds = new Set(); let forceFullReloadNextTime = false; let lastShowNewestOnTop = Settings.getLiveSettingsSync().showNewestOnTop; // 🔁 Мгновенная пересортировка при изменении настройки const instantlyReapplySorting = () => { const settings = Settings.getLiveSettingsSync(); const rows = Array.from(this.bodyGrid.querySelectorAll('.post-row')); rows.sort((a, b) => { const tsA = Number(a.getAttribute('data-timestamp')) || 0; const tsB = Number(b.getAttribute('data-timestamp')) || 0; return settings.showNewestOnTop ? tsB - tsA : tsA - tsB; }); rows.forEach(row => this.bodyGrid.appendChild(row)); if (settings.showNewestOnTop) { if (this.wrapper) this.wrapper.scrollTop = 0; } else { if (this.wrapper) this.wrapper.scrollTop = this.wrapper.scrollHeight; } }; // Проверка настроек каждые 300 мс — для мгновенного обновления сортировки setInterval(() => { const current = Settings.getLiveSettingsSync().showNewestOnTop; if (current !== lastShowNewestOnTop) { lastShowNewestOnTop = current; instantlyReapplySorting(); } }, 300); // Пауза при переходе на другую вкладку document.addEventListener("visibilitychange", () => { activeTab = document.visibilityState === "visible"; if (activeTab) { API.fetchTopics(true, null, 0, { isFetching, paused, retryDelay: 1500, seenMessages, trackedPosts, firstLoad, pendingPosts, lastTopicIds, forceFullReloadNextTime }); inactiveTime = 0; } }); // Восстановление состояния паузы const storedPaused = localStorage.getItem('livePaused'); if (storedPaused !== null) paused = storedPaused === 'true'; // Удаление старого контейнера, если есть const existingContainer = document.getElementById('live-posts-container'); if (existingContainer) existingContainer.remove(); // Определение места вставки let containerParent = null; const punDebug = document.getElementById('pun-debug'); const punUlinks = document.getElementById('pun-ulinks'); const body = document.body; if (punDebug) { containerParent = punDebug.parentNode; const container = document.createElement('div'); container.id = 'live-posts-container'; containerParent.replaceChild(container, punDebug); } else if (punUlinks) { containerParent = punUlinks.parentNode; const container = document.createElement('div'); container.id = 'live-posts-container'; containerParent.insertBefore(container, punUlinks.nextSibling); } else { const container = document.createElement('div'); container.id = 'live-posts-container'; body.prepend(container); } // 💠 Добавление CSS для корректного отображения эмодзи { const style = document.createElement('style'); style.textContent = '.live-emoji{font-family:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif !important;}'; document.head.appendChild(style); } // Расчёт высоты контейнера const userSettingsForHeight = Settings.getLiveSettingsSync(); const topicCount = Math.max(3, Math.min(8, userSettingsForHeight.topicCount || 6)); const rowHeight = Math.max(20, Math.min(60, userSettingsForHeight.rowHeight || 33)); const wrapperHeight = rowHeight * topicCount; // HTML-шаблон Live-бокса const containerContent = ` <div class="live-posts-container"> <div class="live-header-grid"> <div class="header-cell tcl"> <div class="left-controls"> <label class="switch"> <input type="checkbox" id="pause-updates"> <span class="slider"></span> </label> <span class="pause-label">Стоп</span> </div> <div class="author-label"><span class="live-emoji">🧑💻</span> Автор</div> </div> <div class="header-cell tc2"><span class="live-emoji">🕒</span> Время</div> <div class="header-cell tcr"><span class="live-emoji">📁</span> Тема / <span class="live-emoji">💬</span> Сообщение</div> </div> <div class="live-body-wrapper" style="max-height: ${wrapperHeight}px;"> <div class="live-body-grid" id="live-posts-body"> <div class="post-row"><div class="post-cell">Загрузка сообщений...</div></div> </div> </div> </div> `; const container = document.getElementById('live-posts-container'); container.innerHTML = containerContent; this.bodyGrid = document.getElementById('live-posts-body'); this.wrapper = document.querySelector('.live-body-wrapper'); // Обработчик кнопки паузы const pauseCheckbox = document.getElementById('pause-updates'); if (pauseCheckbox) { pauseCheckbox.checked = paused; pauseCheckbox.addEventListener('change', () => { paused = pauseCheckbox.checked; localStorage.setItem('livePaused', paused); }); } // Контекст для передачи в API и обработчики const context = { isFetching, paused, retryDelay: 1500, seenMessages, trackedPosts, firstLoad, pendingPosts, lastTopicIds, forceFullReloadNextTime, inactiveTime, checkInterval, activeTab, maxTopics, bodyGrid: this.bodyGrid, wrapper: this.wrapper, getTopicId: Utils.getTopicId, getLiveSettingsSync: Settings.getLiveSettingsSync, handlePostUpdate: this.handlePostUpdate.bind(this), processTopics: this.processTopics.bind(this), instantlyReapplySorting }; // Периодическая проверка, не удалили ли сообщения const revalidatePosts = () => { if (paused || !activeTab || trackedPosts.size === 0) return; const now = Date.now(), maxRecheck = 5, minInterval = 20000; const rows = Array.from(this.bodyGrid.querySelectorAll('.post-row')).slice(-maxRecheck); rows.forEach(el => { const msgId = el.getAttribute('data-msg-id'); if (!msgId) return; const tracked = trackedPosts.get(msgId); if (!tracked || !tracked.postId) return; if (now - (tracked.lastCheck || 0) < minInterval) return; tracked.lastCheck = now; const item = tracked.item, topicId = Utils.getTopicId(item), postId = tracked.postId; let attempts = 0; const load = () => { attempts++; const fields = 'id,username,message,posted'; const url = `/api.php?method=post.get&post_id=${postId}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`; fetch(url) .then(response => response.json()) .then(data => { if (!data?.response || !Array.isArray(data.response) || data.response.length === 0) { this.handlePostUpdate(topicId, msgId, item, "[сообщение удалено]", true, null, context); trackedPosts.delete(msgId); return; } const post = data.response[0], messageText = post.message || ''; this.handlePostUpdate(topicId, msgId, item, messageText, false, postId, context); }) .catch(error => { if (attempts < 5) setTimeout(load, 1000); else { this.handlePostUpdate(topicId, msgId, item, "[сообщение недоступно]", true, null, context); trackedPosts.delete(msgId); } }); }; load(); }); }; // Отслеживание удаления строк (для очистки кэша) const liveObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.removedNodes.forEach((node) => { if (node.nodeType === 1 && node.classList.contains('post-row')) { const topicId = node.getAttribute('data-topic-id'); const msgId = node.getAttribute('data-msg-id'); if (msgId) { seenMessages.delete(msgId); trackedPosts.delete(msgId); } } }); }); }); liveObserver.observe(this.bodyGrid, { childList: true, subtree: true }); // Основной цикл обновления setInterval(() => { if (!activeTab) { inactiveTime += checkInterval; let interval = 15000; if (inactiveTime >= 180000) interval = 60000; else if (inactiveTime >= 60000) interval = 30000; if (inactiveTime % interval === 0) { API.fetchTopics(true, null, 0, context); } return; } inactiveTime = 0; const isFullReload = forceFullReloadNextTime; if (isFullReload) { forceFullReloadNextTime = false; } API.fetchTopics(isFullReload, null, 0, context); }, checkInterval); setInterval(revalidatePosts, 20000); // Обновление при возврате в онлайн window.addEventListener('online', () => { seenMessages.clear(); trackedPosts.clear(); API.fetchTopics(true, null, 0, context); }); // Первый запуск API.fetchTopics(true, null, 0, context); }, // Обработка массива тем — обновление DOM processTopics(content, context) { const { bodyGrid, getTopicId, getLiveSettingsSync, wrapper } = context; const currentSeenTopics = new Set(); const currentSettings = getLiveSettingsSync(); const isMobileNow = window.innerWidth <= 768; // Обнаружение удалённых тем → триггер полной перезагрузки const newTopicIds = new Set(); content.forEach(item => { newTopicIds.add(getTopicId(item)); }); if (newTopicIds.size > 0) { let hasDisappeared = false; context.lastTopicIds.forEach(id => { if (!newTopicIds.has(id)) { hasDisappeared = true; } }); if (hasDisappeared) { context.forceFullReloadNextTime = true; } } context.lastTopicIds = newTopicIds; // Сортировка данных if (currentSettings.showNewestOnTop) { content.sort((a, b) => b[0] - a[0]); } else { content.sort((a, b) => a[0] - b[0]); } // Очистка "загрузки..." const firstRow = bodyGrid.querySelector('.post-row'); if (firstRow && firstRow.querySelector('.loading-msg')) { bodyGrid.innerHTML = ''; } // Обновление или создание строк content.forEach(item => { const topicId = getTopicId(item); const ts = new Date(1000 * item[0]); const timeStr = `${String(ts.getDate()).padStart(2,'0')}.${String(ts.getMonth()+1).padStart(2,'0')} ${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}`; let existingRow = null; bodyGrid.querySelectorAll('.post-row').forEach(row => { if (row.getAttribute('data-topic-id') === topicId) { existingRow = row; } }); if (existingRow) { existingRow.setAttribute('data-timestamp', item[0].toString()); const currentTimeText = existingRow.querySelector('.tc2')?.textContent.trim() || ''; const newTimeText = `<span class="live-emoji">🕒</span> ${timeStr}`; if (currentTimeText !== newTimeText) { existingRow.querySelector('.tc2').innerHTML = newTimeText; } const postId = item[5], finalMsgId = postId ? `post_${postId}` : item[0] + "_api"; const tracked = context.trackedPosts.get(finalMsgId); const currentLastPostId = tracked ? tracked.postId : null; if (currentLastPostId !== postId) { context.seenMessages.delete(finalMsgId); context.trackedPosts.delete(finalMsgId); API.fetchPost(item, context); } currentSeenTopics.add(topicId); return; } // Новая строка const author = isMobileNow ? Utils.truncate(item[1], currentSettings.maxMobileAuthorLength || 12) : item[1]; const topicTitle = item[3]; const processedTopicTitle = currentSettings.coloredTopics ? this.processTopicTitle(topicTitle, isMobileNow ? (currentSettings.maxMobileTopicLength || 15) : (currentSettings.maxTopicLength || 40)) : Utils.truncate(Utils.extractCleanTitle(topicTitle), isMobileNow ? (currentSettings.maxMobileTopicLength || 15) : (currentSettings.maxTopicLength || 40)); const row = document.createElement('div'); row.className = 'post-row'; row.setAttribute('data-topic-id', topicId); row.setAttribute('data-timestamp', item[0].toString()); row.innerHTML = ` <div class="post-cell tcl"><span class="live-emoji">🧑💻</span> ${author}</div> <div class="post-cell tc2"><span class="live-emoji">🕒</span> ${timeStr}</div> <div class="post-cell tcr"><span class="loading-msg">Загрузка...</span></div> `.trim(); bodyGrid.appendChild(row); currentSeenTopics.add(topicId); }); // Загрузка постов по очереди с задержкой content.forEach((item, i) => { setTimeout(() => API.fetchPost(item, context), i * 200); }); if (context.firstLoad) context.firstLoad = false; // Удаление устаревших строк bodyGrid.querySelectorAll('.post-row').forEach(row => { const topicId = row.getAttribute('data-topic-id'); if (!currentSeenTopics.has(topicId)) { row.remove(); const msgId = row.getAttribute('data-msg-id'); if (msgId) { context.seenMessages.delete(msgId); context.trackedPosts.delete(msgId); } } }); // Принудительная сортировка DOM const rows = Array.from(bodyGrid.querySelectorAll('.post-row')); rows.sort((a, b) => { const tsA = Number(a.getAttribute('data-timestamp')) || 0; const tsB = Number(b.getAttribute('data-timestamp')) || 0; return currentSettings.showNewestOnTop ? tsB - tsA : tsA - tsB; }); rows.forEach(row => bodyGrid.appendChild(row)); // Прокрутка после обновления if (!context.firstLoad) { if (currentSettings.showNewestOnTop) { if (wrapper) wrapper.scrollTop = 0; } else { if (wrapper) wrapper.scrollTop = wrapper.scrollHeight; } } }, // Обновление конкретного сообщения в строке handlePostUpdate(topicId, msgId, item, rawContent, isDeleted, postId = null, context) { const { bodyGrid, wrapper, getLiveSettingsSync } = context; const row = bodyGrid.querySelector(`.post-row[data-topic-id="${topicId}"]`); if (!row) return; row.setAttribute('data-timestamp', item[0].toString()); const finalMsgId = postId ? `post_${postId}` : msgId; row.setAttribute('data-msg-id', finalMsgId); const authorCell = row.querySelector('.tcl'); const timeCell = row.querySelector('.tc2'); const messageCell = row.querySelector('.tcr'); const isMobileNow = window.innerWidth <= 768; const userSettings = getLiveSettingsSync(); const maxMobileAuthorLength = userSettings.maxMobileAuthorLength || 12; const author = isMobileNow ? Utils.truncate(item[1], maxMobileAuthorLength) : item[1]; authorCell.innerHTML = `<span class="live-emoji">🧑💻</span> ${author}`; const ts = new Date(1000 * item[0]); const timeStr = `${String(ts.getDate()).padStart(2, '0')}.${String(ts.getMonth() + 1).padStart(2, '0')} ${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}`; timeCell.innerHTML = `<span class="live-emoji">🕒</span> ${timeStr}`; const topicTitle = item[3]; const maxTopicLength = isMobileNow ? (userSettings.maxMobileTopicLength || 15) : (userSettings.maxTopicLength || 40); const processedTopicTitle = userSettings.coloredTopics ? this.processTopicTitle(topicTitle, maxTopicLength) : Utils.truncate(Utils.extractCleanTitle(topicTitle), maxTopicLength); // ← УЧЁТ openLinksInNewTab const targetAttr = userSettings.openLinksInNewTab ? ' target="_blank"' : ''; const topicLink = `<a href="${item[2]}"${targetAttr}>${processedTopicTitle}</a>`; let newHtml = topicLink; if (!isMobileNow && !isDeleted) { let cleanText = this.sanitizePostContent(rawContent); cleanText = cleanText.replace(/^\s*[\d,\s]+\s+написал\(а\):\s*/i, ''); cleanText = cleanText.replace(/[\s\u00A0]+/g, ' ').trim(); if (cleanText && cleanText !== "[пусто]") { const maxMessageLength = userSettings.maxMessageLength || 60; cleanText = Utils.truncate(cleanText, maxMessageLength); newHtml += `<div>"${cleanText}"</div>`; } } else if (isDeleted) { newHtml += `<div style="color:#999;">${rawContent}</div>`; } const extractTextFromHtml = (html) => { const temp = document.createElement('div'); temp.innerHTML = html; return temp.textContent.trim(); }; const currentMessageText = extractTextFromHtml(messageCell.innerHTML); const newMessageText = extractTextFromHtml(newHtml); if (currentMessageText !== newMessageText) { messageCell.innerHTML = newHtml; context.seenMessages.add(finalMsgId); context.trackedPosts.set(finalMsgId, { item, lastCheck: Date.now(), content: newHtml, msgId, postId }); if (!context.firstLoad) { row.classList.add('edited-highlight'); setTimeout(() => row.classList.remove('edited-highlight'), 2000); } if (userSettings.showNewestOnTop) { if (wrapper) wrapper.scrollTop = 0; } else { if (wrapper) wrapper.scrollTop = wrapper.scrollHeight; } } }, // Очистка HTML-контента от потенциально опасных или нежелательных элементов sanitizePostContent(htmlString) { if (!htmlString || typeof htmlString !== 'string') return "[пусто]"; let cleaned = htmlString.replace(/\[html\][\s\S]*?\[\/html\]/gi, '[HTML контент]'); cleaned = cleaned .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') .replace(/<iframe\b[^>]*src\s*=\s*["'][^"']*ads[^"']*["'][^>]*>/gi, ''); const tempDiv = document.createElement('div'); tempDiv.innerHTML = cleaned; // Замена видео на "[видео]" const isVideoIframe = (iframe) => { let src = (iframe.getAttribute('src') || '').trim(); if (!src) return false; try { src = decodeURIComponent(src); } catch (e) { } const videoHosts = [ 'youtube.com', 'youtu.be', 'vimeo.com', 'rutube.ru', 'rutube.com', 'sendvid.com', 'vkvideo.ru', 'vkvideo.com' ]; if (videoHosts.some(host => src.includes(host))) return true; if (src.startsWith('/embed/') && /^[a-zA-Z0-9_-]{6,}$/.test(src.split('/')[2])) { return true; } return false; }; tempDiv.querySelectorAll('iframe').forEach(iframe => { if (isVideoIframe(iframe)) { const placeholder = document.createTextNode('[видео]'); iframe.parentNode.replaceChild(placeholder, iframe); } }); tempDiv.querySelectorAll('video, embed[type="application/x-shockwave-flash"]').forEach(el => { const placeholder = document.createTextNode('[видео]'); el.parentNode.replaceChild(placeholder, el); }); tempDiv.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href') || ''; if (/https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be|vimeo\.com|rutube\.(?:ru|com)|sendvid\.com|vkvideo\.(?:ru|com))/i.test(href)) { const placeholder = document.createTextNode('[видео]'); a.parentNode.replaceChild(placeholder, a); } }); // Полная очистка недопустимых тегов const disallowed = ['script', 'style', 'object', 'embed', '.fs-box']; disallowed.forEach(sel => { tempDiv.querySelectorAll(sel).forEach(el => el.remove()); }); // Замена специфичных блоков на текстовые метки const replacements = [ { selector: '.quote-box', text: '[Цитата]' }, { selector: '.spoiler-box.media-box', text: '[СпойлерМедиа]' }, { selector: '.quote-box.hide-box', text: '[Спойлер]' }, { selector: '.spoiler-box.text-box', text: '[СпойлерТекст]' }, { selector: '.code-box', text: '[Код]' }, { selector: '.quote-box.spoiler-box', text: '[СпойлерБокс]' }, { selector: 'img', text: '[картинка]' } ]; replacements.forEach(({ selector, text }) => { tempDiv.querySelectorAll(selector).forEach(el => { const placeholder = document.createTextNode(text); el.parentNode.replaceChild(placeholder, el); }); }); // Очистка текстовых узлов от [html]...[/html] const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT); let node, textNodes = []; while (node = walker.nextNode()) textNodes.push(node); textNodes.forEach(node => { let txt = node.textContent; txt = txt.replace(/\[html\][\s\S]*?\[\/html\]/gi, '[HTML контент]'); node.textContent = txt; }); let finalText = tempDiv.textContent.trim() || "[пусто]"; finalText = finalText.replace(/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|vimeo\.com\/|rutube\.(?:ru|com)\/|sendvid\.com\/|vkvideo\.(?:ru|com)\/)[a-zA-Z0-9_-]+/gi, '[видео]'); return finalText; }, // Обработка цветных тем (формат color;;bg;;title) processTopicTitle(titleText, maxLength = null) { if (!titleText.includes(';;')) { return maxLength !== null ? (titleText.length > maxLength ? titleText.slice(0, maxLength) + "…" : titleText) : titleText; } const parts = titleText.split(';;'); if (parts.length < 3) { return maxLength !== null ? (titleText.length > maxLength ? titleText.slice(0, maxLength) + "…" : titleText) : titleText; } const color = parts[0]; const bg = parts[1]; let cleanTitle = parts.slice(2).join(';;'); if (maxLength !== null) { cleanTitle = cleanTitle.length > maxLength ? cleanTitle.slice(0, maxLength) + "…" : cleanTitle; } return `<span style="color:${color};background-color:${bg};padding:0 2px;border-radius:2px;">${cleanTitle}</span>`; } }; // Запуск после полной загрузки DOM document.addEventListener('DOMContentLoaded', () => { // Если страница профиля — показываем настройки (только на своей) if ( window.location.pathname.includes('/profile.php') || window.location.pathname.includes('/user/') ) { if (!document.getElementById('live-settings-accordion')) { Settings.addProfileSettings(); } return; } // Если не главная страница — ничего не делаем if (window.location.pathname !== '/' && !window.location.pathname.endsWith('index.php')) { return; } // На главной — запускаем Live-бокс, если включён Settings.shouldShowLiveBox().then(shouldShow => { if (shouldShow) { UI.runLiveBox(); } }); });
Отредактировано Merlin777 (Пн, 22 Дек 2025 18:51:49)
Вы здесь » Единый форум поддержки » Сообщения об ошибках » форумы mybb доступны только через VPN