// ==UserScript==
// @name 4PDA Link Checker
// @author brant34
// @namespace http://tampermonkey.net/
// @version 2.6
// @description Проверка ссылок + нарушений 3.9 (с обработкой скрытых/удалённых постов, с переходом к ним)
// @match https://4pda.to/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect *
// ==/UserScript==
(function () {
'use strict';
const style = document.createElement('style');
style.textContent = `
#link-checker-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background: #0055A4;
color: #fff;
padding: 10px;
font-size: 14px;
border-bottom: 1px solid #004080;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
border-radius: 0 0 6px 6px;
max-height: 200px;
overflow-y: auto;
transition: max-height 0.3s ease;
}
#link-checker-panel.collapsed {
max-height: 28px !important;
overflow: hidden !important;
}
#link-checker-panel.collapsed #link-checker-list,
#link-checker-panel.collapsed #link-checker-menu {
display: none !important;
}
#link-checker-menu {
margin-top: 8px;
display: none;
}
#link-checker-menu button {
margin-right: 8px;
margin-bottom: 4px;
background: #ffffff22;
color: #fff;
border: 1px solid #fff;
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
}
#link-checker-header {
display: flex;
align-items: center;
gap: 10px;
}
.link-checker-entry[data-keyword] {
background-color: #fff3e0;
border-left: 3px solid orange;
padding: 2px;
margin-bottom: 2px;
}
.link-checker-entry a {
color: #ffe;
}
#ignore-domains-modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
z-index: 10000;
color: #000;
}
#ignore-domains-modal.show {
display: block;
}
#modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
#modal-overlay.show {
display: block;
}
#domain-list li {
cursor: pointer;
padding: 5px;
margin-bottom: 5px;
border-radius: 3px;
}
#domain-list li:hover {
background-color: #f0f0f0;
}
`;
document.head.appendChild(style);
const links = document.querySelectorAll('a[href*="4pda.to/stat/go?u="]');
let pendingRequests = links.length;
let brokenLinksCount = 0;
const rule39Exceptions = [
'https://vk.com/4pdaru',
'http://vk.com/4pdaru',
'https://t.me/real4pda'
];
const rule39Hosts = [
'boosty.to',
'gofile.io',
'hyp.sh',
'halabtech.com',
'needrom.com',
't.me',
'tx.me',
'telegram.org',
'terabox.com',
'vk.com'
];
const skipHosts = [
'https://invisioncommunity.com',
'https://twitter.com/4pdaru',
'http://www.invisionboard.com',
'http://www.invisionpower.com'
];
const skipUrls = [
'http://twitter.com/4pdaru',
'https://twitter.com/4pdaru'
];
// Базовый список проблемных хостов
const defaultKnownFalse403Hosts = [
't.me',
'tx.me',
'telegram.org',
'vk.com',
'samsung.com',
'kfhost.net',
'samsung-up.com'
];
// Получение или инициализация списка игнорируемых доменов
let ignoredDomains = GM_getValue('ignoredDomains', [...defaultKnownFalse403Hosts]);
const panel = document.createElement('div');
panel.id = 'link-checker-panel';
const header = document.createElement('div');
header.id = 'link-checker-header';
const gear = document.createElement('span');
gear.innerHTML = '⚙️';
gear.style.cursor = 'pointer';
gear.title = 'Меню';
header.appendChild(gear);
const title = document.createElement('span');
title.innerHTML = `<b>Проверка ссылок... (${pendingRequests} осталось)</b>`;
title.id = 'link-checker-title';
header.appendChild(title);
panel.appendChild(header);
const menu = document.createElement('div');
menu.id = 'link-checker-menu';
menu.innerHTML = `
<button id="manual-check">🔄 Ручной поиск</button>
<button id="remove-broken">🗑 Скрыть битые ссылки</button>
<button id="ignore-domains">⚠️ Настроить игнорируемые домены</button>
`;
panel.appendChild(menu);
const container = document.createElement('div');
container.id = 'link-checker-list';
panel.appendChild(container);
document.body.appendChild(panel);
const panelEntries = new Map();
// Рекурсивная проверка текста
function getAllTextContent(element) {
let text = '';
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while (node = walker.nextNode()) {
text += node.textContent.trim();
}
return text;
}
// Проверка видимости поста
function isPostVisible(post) {
if (!post) return false;
const table = post.closest('table[data-post]');
const isInDOM = document.contains(post);
const hasOffsetParent = post.offsetParent !== null;
const style = table ? window.getComputedStyle(table) : window.getComputedStyle(post);
const isHiddenByClass = table && (table.classList.contains('hidepin') || table.classList.contains('deletedpost'));
const isHiddenByStyle = style.display === 'none' || style.visibility === 'hidden';
const content = getAllTextContent(post);
const isHiddenByText = content.includes('[HIDE]');
const isDeletedByText = content.includes('[DELETE]');
console.log(`Проверка видимости: ${post.id}, inDOM: ${isInDOM}, offsetParent: ${hasOffsetParent}, hiddenByClass: ${isHiddenByClass}, hiddenByStyle: ${isHiddenByStyle}, hiddenByText: ${isHiddenByText}, deletedByText: ${isDeletedByText}, content: "${content}"`);
return isInDOM && hasOffsetParent && !isHiddenByClass && !isHiddenByStyle && !isHiddenByText && !isDeletedByText;
}
// Поиск слов VPN, ВПН, КВН
const keywordRegex = /(^|[^а-яa-z0-9])(VPN|ВПН|КВН)(?![а-яa-z0-9])/gi;
const posts = document.querySelectorAll('.post_body');
for (const post of posts) {
const postContainer = post.closest('div.postcolor[id^="post-"]');
if (!postContainer) continue;
if (keywordRegex.test(post.textContent)) {
const matched = post.textContent.match(keywordRegex).join(', ');
const postId = postContainer.id;
const isVisible = isPostVisible(postContainer);
const entry = document.createElement('div');
entry.className = 'link-checker-entry';
entry.setAttribute('data-keyword', 'true');
entry.setAttribute('data-post-id', postId);
const entryHTML = `🟠 Найдено слово: <b>${matched}</b> (пост: ${postId})`;
entry.setAttribute('data-original-html', entryHTML);
entry.innerHTML = isVisible ? entryHTML : `${entryHTML} <i>(скрыт/удалён)</i>`;
entry.style.cursor = 'pointer';
entry.onclick = () => handleEntryClick(postContainer, postId);
document.getElementById('link-checker-list').appendChild(entry);
panelEntries.set(postId + '-keyword', { entry, postId });
if (isVisible) {
post.style.backgroundColor = '#fff3e0';
post.style.border = '2px solid orange';
}
}
}
// Проверка всех ссылок на нарушение правила 3.9
const allLinks = document.querySelectorAll('a[href]');
for (const a of allLinks) {
const href = a.href;
try {
const url = new URL(href);
if (rule39Hosts.some(host => url.hostname.includes(host))) {
const linkText = a.innerText || 'Без текста';
addRule39Link(href, linkText, a);
}
} catch (e) {
// ignore malformed URLs
}
}
gear.addEventListener('click', () => {
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
});
document.getElementById('manual-check')?.addEventListener('click', () => location.reload());
document.getElementById('remove-broken')?.addEventListener('click', () => {
document.querySelectorAll('[data-broken-link="true"]').forEach(e => e.remove());
container.innerHTML = '';
title.innerHTML = `<b>🛠 Битые ссылки скрыты.</b>`;
menu.style.display = 'none';
});
// Модальное окно для настройки доменов
const modalOverlay = document.createElement('div');
modalOverlay.id = 'modal-overlay';
document.body.appendChild(modalOverlay);
const modal = document.createElement('div');
modal.id = 'ignore-domains-modal';
modal.innerHTML = `
<h3>Игнорируемые домены</h3>
<p>Введите домен (например, example.com) и нажмите "Добавить". Кликните на домен, чтобы удалить его.</p>
<input type="text" id="domain-input" placeholder="Домен" style="width: 200px; padding: 5px; margin-right: 10px;">
<button id="add-domain">Добавить</button>
<ul id="domain-list" style="list-style-type: none; padding: 0; margin-top: 10px;"></ul>
<button id="close-modal" style="margin-top: 10px;">Закрыть</button>
`;
document.body.appendChild(modal);
function updateDomainList() {
const list = document.getElementById('domain-list');
list.innerHTML = '';
ignoredDomains.forEach(domain => {
const li = document.createElement('li');
li.textContent = domain;
li.addEventListener('click', () => {
const index = ignoredDomains.indexOf(domain);
if (index !== -1) {
ignoredDomains.splice(index, 1);
updateDomainList();
showNotification(`Домен ${domain} удалён из списка игнорируемых.`);
}
});
list.appendChild(li);
});
GM_setValue('ignoredDomains', ignoredDomains);
}
document.getElementById('ignore-domains')?.addEventListener('click', () => {
modal.classList.add('show');
modalOverlay.classList.add('show');
updateDomainList();
});
document.getElementById('close-modal')?.addEventListener('click', () => {
modal.classList.remove('show');
modalOverlay.classList.remove('show');
});
document.getElementById('add-domain')?.addEventListener('click', () => {
const input = document.getElementById('domain-input');
const domain = input.value.trim();
if (domain && !ignoredDomains.includes(domain)) {
ignoredDomains.push(domain);
updateDomainList();
showNotification(`Домен ${domain} добавлен в список игнорируемых.`);
}
input.value = '';
});
modalOverlay.addEventListener('click', () => {
modal.classList.remove('show');
modalOverlay.classList.remove('show');
});
function showNotification(message) {
const notification = document.createElement('div');
notification.style.cssText = 'position: fixed; top: 10px; right: 10px; background: #ff4444; color: white; padding: 10px; border-radius: 4px; z-index: 10000;';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}
function addRule39Link(href, linkText, originalLink) {
if (rule39Exceptions.includes(href)) return;
const post = originalLink.closest('div.postcolor[id^="post-"]');
const postId = post ? post.id : 'unknown';
const isVisible = post ? isPostVisible(post) : false;
const entry = document.createElement('div');
entry.className = 'link-checker-entry';
entry.setAttribute('data-post-id', postId);
const entryHTML = `🚫 Нарушение п. 3.9: <a href="${href}" target="_blank">${href}</a> (текст: ${linkText}, пост: ${postId})`;
entry.setAttribute('data-original-html', entryHTML);
entry.innerHTML = isVisible ? entryHTML : `${entryHTML} <i>(скрыт/удалён)</i>`;
entry.style.cursor = 'pointer';
entry.onclick = () => handleEntryClick(post, postId);
document.getElementById('link-checker-list').appendChild(entry);
panelEntries.set(postId + '-rule39-' + href, { entry, postId });
if (isVisible) {
originalLink.style.border = '2px solid green';
originalLink.style.backgroundColor = '#e8f5e9';
originalLink.style.padding = '2px';
originalLink.title = 'Нарушение правила 3.9 (запрещённый ресурс)';
originalLink.setAttribute('data-rule-39', 'true');
}
}
function addBrokenLink(realUrl, status, linkText, originalLink) {
brokenLinksCount++;
const post = originalLink.closest('div.postcolor[id^="post-"]');
const postId = post ? post.id : 'unknown';
const isVisible = post ? isPostVisible(post) : false;
const entry = document.createElement('div');
entry.className = 'link-checker-entry';
entry.setAttribute('data-post-id', postId);
const entryHTML = `❌ <a href="${realUrl}" target="_blank">${realUrl}</a> (статус: ${status}, текст: ${linkText}, пост: ${postId})`;
entry.setAttribute('data-original-html', entryHTML);
entry.innerHTML = isVisible ? entryHTML : `${entryHTML} <i>(скрыт/удалён)</i>`;
entry.style.cursor = 'pointer';
entry.onclick = () => handleEntryClick(post, postId);
document.getElementById('link-checker-list').appendChild(entry);
panelEntries.set(postId + '-broken-' + realUrl, { entry, postId });
if (isVisible) {
originalLink.style.border = '2px solid red';
originalLink.style.backgroundColor = '#ffebee';
originalLink.style.padding = '2px';
originalLink.title = 'Битая ссылка (статус: ' + status + ')';
originalLink.setAttribute('data-broken-link', 'true');
}
}
function handleEntryClick(post, postId) {
if (!post) {
showNotification(`Пост ${postId} не найден в DOM.`);
return;
}
const table = post.closest('table[data-post]');
const isInDOM = document.contains(post);
const hasOffsetParent = post.offsetParent !== null;
const style = table ? window.getComputedStyle(table) : window.getComputedStyle(post);
const isHiddenByClass = table && (table.classList.contains('hidepin') || table.classList.contains('deletedpost'));
const isHiddenByStyle = style.display === 'none' || style.visibility === 'hidden';
const content = getAllTextContent(post);
const isHiddenByText = content.includes('[HIDE]');
const isDeletedByText = content.includes('[DELETE]');
// Прокрутка непосредственно к целевому посту
post.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Уведомление о статусе поста
if (!isInDOM) {
showNotification(`Пост ${postId} отсутствует в DOM.`);
} else if (!hasOffsetParent) {
showNotification(`Пост ${postId} не отображается (нет offsetParent).`);
} else if (isHiddenByClass) {
showNotification(`Пост ${postId} скрыт/удалён (класс ${table.classList.contains('hidepin') ? 'hidepin' : 'deletedpost'}).`);
} else if (isHiddenByStyle) {
showNotification(`Пост ${postId} скрыт стилями (display: ${style.display}, visibility: ${style.visibility}).`);
} else if (isHiddenByText) {
showNotification(`Пост ${postId} скрыт текстом [HIDE].`);
} else if (isDeletedByText) {
showNotification(`Пост ${postId} удалён текстом [DELETE].`);
} else {
showNotification(`Пост ${postId} отображен.`);
}
}
function updatePanel() {
title.innerHTML = `<b>Проверка ссылок... (${pendingRequests} осталось)</b>`;
if (pendingRequests === 0) {
title.innerHTML = `<b>Все ссылки проверены. Найдено ${brokenLinksCount} битых ссылок.</b>`;
}
}
function checkLink(realUrl, linkText, originalLink, method = 'HEAD', attempt = 1) {
const urlObj = new URL(realUrl);
const isIgnoredHost = ignoredDomains.some(host => urlObj.hostname.includes(host));
GM_xmlhttpRequest({
method: method,
url: realUrl,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
},
timeout: 10000,
onload: function (response) {
console.log(`Ссылка: ${realUrl}, Метод: ${method}, Статус: ${response.status}, Final URL: ${response.finalUrl || 'N/A'}, Response Headers: ${response.responseHeaders || 'N/A'}`);
// Обработка редиректов
if ([301, 302, 303, 307, 308].includes(response.status) && response.finalUrl && attempt < 3) {
console.log(`Редирект: ${realUrl} -> ${response.finalUrl}`);
checkLink(response.finalUrl, linkText, originalLink, method, attempt + 1);
return;
}
// Пропускаем 403 для игнорируемых хостов
if (response.status === 403 && isIgnoredHost) {
console.log(`Игнорируем 403 для ${realUrl} (игнорируемый хост)`);
pendingRequests--;
updatePanel();
return;
}
// Пробуем GET для проблемных хостов при 403
if (response.status === 403 && !isIgnoredHost && method === 'HEAD' && attempt < 3) {
console.log(`Статус 403 для проблемного хоста ${urlObj.hostname}, пробуем GET`);
checkLink(realUrl, linkText, originalLink, 'GET', attempt + 1);
return;
}
// Проверяем другие коды ошибок
if ([404, 410].includes(response.status)) {
addBrokenLink(realUrl, response.status, linkText, originalLink);
} else if (response.status >= 200 && response.status < 300) {
console.log(`Ссылка ${realUrl} рабочая (статус: ${response.status})`);
} else {
console.warn(`Неопределённый статус для ${realUrl}: ${response.status}`);
}
pendingRequests--;
updatePanel();
},
onerror: function () {
console.log(`Ошибка проверки ссылки: ${realUrl}`);
addBrokenLink(realUrl, 'Ошибка', linkText, originalLink);
pendingRequests--;
updatePanel();
},
ontimeout: function () {
console.log(`Таймаут проверки ссылки: ${realUrl}`);
if (!isIgnoredHost && method === 'HEAD' && attempt < 3) {
console.log(`Таймаут для проблемного хоста ${urlObj.hostname}, пробуем GET`);
checkLink(realUrl, linkText, originalLink, 'GET', attempt + 1);
} else {
addBrokenLink(realUrl, 'Таймаут', linkText, originalLink);
pendingRequests--;
updatePanel();
}
}
});
}
links.forEach((a, i) => {
const urlParams = new URLSearchParams(a.href.split('?')[1]);
const realUrl = decodeURIComponent(urlParams.get('u') || '');
const linkText = a.innerText || 'Без текста';
if (!realUrl) {
console.warn('Пустой URL найден для ссылки:', a.href);
pendingRequests--;
updatePanel();
return;
}
if (skipUrls.includes(realUrl) || skipHosts.some(host => realUrl.startsWith(host))) {
console.log(`⏩ Пропущена ссылка: ${realUrl}`);
pendingRequests--;
updatePanel();
return;
}
if (rule39Hosts.some(host => realUrl.includes(host))) {
addRule39Link(realUrl, linkText, a);
}
setTimeout(() => {
checkLink(realUrl, linkText, a);
}, i * 100);
});
if (links.length === 0) {
title.innerHTML = `<b>Ссылок для проверки не найдено.</b>`;
}
function getVisibleText(element) {
const clone = element.cloneNode(true);
clone.querySelectorAll('script, style').forEach(el => el.remove());
let text = '';
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
while (walker.nextNode()) {
text += walker.currentNode.nodeValue + ' ';
}
const links = clone.querySelectorAll('a[href]');
links.forEach(link => {
const href = link.getAttribute('href');
if (href) text += ' ' + href;
const linkText = link.textContent.trim();
if (linkText) text += ' ' + linkText;
});
text = text.replace(/\[.\]/g, '.').replace(/[-_]/g, '.');
return text.replace(/[\n\r\s]+/g, ' ').toLowerCase().trim();
}
window.addEventListener('load', () => {
setTimeout(() => {
const posts = document.querySelectorAll('div.postcolor[id^="post-"]');
for (const post of posts) {
const postId = post.id;
const text = getVisibleText(post);
console.log("VPN-тест: пост", post.id, "| текст:", text);
if (text.includes('vpn') || text.includes('квн') || text.includes('впн')) {
const isVisible = isPostVisible(post);
const entry = document.createElement('div');
entry.className = 'link-checker-entry';
entry.setAttribute('data-post-id', postId);
const entryHTML = `⚠️ Обсуждение VPN (пост: ${postId})`;
entry.setAttribute('data-original-html', entryHTML);
entry.innerHTML = isVisible ? entryHTML : `${entryHTML} <i>(скрыт/удалён)</i>`;
entry.style.cursor = 'pointer';
entry.onclick = () => handleEntryClick(post, postId);
document.getElementById('link-checker-list').appendChild(entry);
panelEntries.set(postId + '-vpn', { entry, postId });
if (isVisible) {
post.style.backgroundColor = '#fff3e0';
post.style.border = '2px solid orange';
post.title = 'Обсуждение VPN';
post.setAttribute('data-rule-39', 'true');
}
}
}
}, 1000);
});
const postContainer = document.querySelector('.ipsForum_topic') || document.body;
const observer = new MutationObserver((mutations) => {
let needsUpdate = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.removedNodes) {
const post = node.querySelector('div.postcolor[id^="post-"]') || node.closest('div.postcolor[id^="post-"]');
if (post) {
needsUpdate = true;
console.log(`Обнаружено удаление поста: ${post.id}`);
break;
}
}
for (const node of mutation.addedNodes) {
const post = node.querySelector('div.postcolor[id^="post-"]') || node.closest('div.postcolor[id^="post-"]');
if (post) {
needsUpdate = true;
console.log(`Обнаружено добавление поста: ${post.id}`);
}
}
} else if (mutation.type === 'characterData') {
const post = mutation.target.parentNode.closest('div.postcolor[id^="post-"]');
if (post) {
needsUpdate = true;
console.log(`Изменение текста в посте: ${post.id}`);
}
} else if (mutation.type === 'attributes') {
const post = mutation.target.closest('div.postcolor[id^="post-"]');
if (post || mutation.target.classList.contains('hidepin') || mutation.target.classList.contains('deletedpost')) {
needsUpdate = true;
console.log(`Изменение атрибутов или классов (hidepin/deletedpost): ${post ? post.id : 'неизвестно'}`);
}
}
}
if (needsUpdate) {
setTimeout(() => {
panelEntries.forEach(({ entry, postId }) => {
const post = document.querySelector(`div.postcolor[id="${postId}"]`);
const isVisible = post ? isPostVisible(post) : false;
const originalHTML = entry.getAttribute('data-original-html');
entry.innerHTML = isVisible ? originalHTML : `${originalHTML} <i>(скрыт/удалён)</i>`;
console.log(`Обновление статуса поста: ${postId}, visible: ${isVisible}`);
});
}, 500);
}
});
observer.observe(postContainer, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'hidden'],
characterData: true
});
setInterval(() => {
let needsUpdate = false;
panelEntries.forEach(({ entry, postId }) => {
const post = document.querySelector(`div.postcolor[id="${postId}"]`);
const isVisible = post ? isPostVisible(post) : false;
const originalHTML = entry.getAttribute('data-original-html');
const currentHTML = entry.innerHTML;
const expectedHTML = isVisible ? originalHTML : `${originalHTML} <i>(скрыт/удалён)</i>`;
if (currentHTML !== expectedHTML) {
entry.innerHTML = expectedHTML;
console.log(`Периодическое обновление статуса поста: ${postId}, visible: ${isVisible}`);
needsUpdate = true;
}
});
if (needsUpdate) {
console.log('Периодическая проверка: обновлены статусы постов');
}
}, 2000);
})();