Tampermonkey Video Filter v4

Filters posts with videos using dynamic content detection, improved performance.

As of 2025-08-15. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name          Tampermonkey Video Filter v4
// @namespace    http://tampermonkey.net/
// @version      1.1.3
// @description  Filters posts with videos using dynamic content detection, improved performance.
// @author       harryangstrom, xdegeneratex, remuru, AI Assistant
// @match        https://*.coomer.party/*
// @match        https://*.coomer.su/*
// @match        https://*.coomer.st/*
// @match        https://*.kemono.su/*
// @match        https://*.kemono.party/*
// @match        https://*.kemono.cr/*
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// @license MIT
// ==/UserScript==


(function() {
    'use strict';

    const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm']; // Добавил webm для большей совместимости
    const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
    const POSTS_PER_PAGE = 50;
    const API_DELAY = 1000; // Уменьшил задержку, т.к. мы не так сильно нагружаем браузер
    const SUBSTRING_TITLE_LENGTH = 100;
    const LS_COLLAPSE_KEY = 'videoFilterPanelCollapsed_v1';

    let currentDomain = window.location.hostname;
    let allFoundVideoUrls = [];
    let videoIntersectionObserver = null;
    let isPanelCollapsed = false;

    // --- UI Elements ---
    const uiContainer = document.createElement('div');
    uiContainer.id = 'video-filter-ui';
    uiContainer.style.position = 'fixed';
    uiContainer.style.bottom = '10px';
    uiContainer.style.right = '10px';
    uiContainer.style.backgroundColor = '#2c2c2e';
    uiContainer.style.color = '#e0e0e0';
    uiContainer.style.border = '1px solid #444444';
    const initialUiContainerPadding = '12px';
    uiContainer.style.padding = initialUiContainerPadding;
    uiContainer.style.zIndex = '9999';
    uiContainer.style.fontFamily = 'Arial, sans-serif';
    uiContainer.style.fontSize = '14px';
    uiContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.5)';
    uiContainer.style.borderRadius = '4px';
    uiContainer.style.transition = 'width 0.2s ease-in-out, height 0.2s ease-in-out, padding 0.2s ease-in-out';

    const collapseButton = document.createElement('button');
    collapseButton.id = 'video-filter-collapse-button';
    collapseButton.innerHTML = '»';
    collapseButton.title = 'Collapse/Expand Panel';
    collapseButton.style.position = 'absolute';
    collapseButton.style.bottom = '8px';
    collapseButton.style.left = '8px';
    collapseButton.style.width = '25px';
    collapseButton.style.height = '60px';
    collapseButton.style.display = 'flex';
    collapseButton.style.alignItems = 'center';
    collapseButton.style.justifyContent = 'center';
    collapseButton.style.padding = '0';
    collapseButton.style.fontSize = '16px';
    collapseButton.style.backgroundColor = '#4a4a4c';
    collapseButton.style.color = '#f0f0f0';
    collapseButton.style.border = '1px solid #555555';
    collapseButton.style.borderRadius = '3px';
    collapseButton.style.cursor = 'pointer';
    collapseButton.style.zIndex = '1';

    const panelMainContent = document.createElement('div');
    panelMainContent.id = 'video-filter-main-content';
    panelMainContent.style.marginLeft = '30px';

    const pageRangeInput = document.createElement('input');
    pageRangeInput.type = 'text';
    pageRangeInput.id = 'video-filter-page-range';
    pageRangeInput.value = '1';
    pageRangeInput.placeholder = 'e.g., 1, 2-5, 7';
    pageRangeInput.style.width = '100px';
    pageRangeInput.style.marginRight = '8px';
    pageRangeInput.style.padding = '6px 8px';
    pageRangeInput.style.backgroundColor = '#1e1e1e';
    pageRangeInput.style.color = '#e0e0e0';
    pageRangeInput.style.border = '1px solid #555555';
    pageRangeInput.style.borderRadius = '3px';

    const filterButton = document.createElement('button');
    filterButton.id = 'video-filter-button';
    filterButton.textContent = 'Filter Videos';

    const copyUrlsButton = document.createElement('button');
    copyUrlsButton.id = 'video-copy-urls-button';
    copyUrlsButton.textContent = 'Copy Video URLs';
    copyUrlsButton.disabled = true;

    const baseButtonBg = '#3a3a3c';
    const hoverButtonBg = '#4a4a4c';
    const disabledButtonBg = '#303030';
    const disabledButtonColor = '#777777';

    collapseButton.onmouseenter = () => { if (collapseButton.style.backgroundColor !== disabledButtonBg) collapseButton.style.backgroundColor = hoverButtonBg; };
    collapseButton.onmouseleave = () => { if (collapseButton.style.backgroundColor !== disabledButtonBg) collapseButton.style.backgroundColor = '#4a4a4c'; };

    function styleButton(button, disabled = false) {
        if (disabled) {
            button.style.backgroundColor = disabledButtonBg;
            button.style.color = disabledButtonColor;
            button.style.cursor = 'default';
        } else {
            button.style.backgroundColor = baseButtonBg;
            button.style.color = '#f0f0f0';
            button.style.cursor = 'pointer';
        }
        button.style.marginRight = '8px';
        button.style.padding = '6px 12px';
        button.style.border = '1px solid #555555';
        button.style.borderRadius = '3px';
    }

    [filterButton, copyUrlsButton].forEach(btn => {
        styleButton(btn, btn.disabled);
        btn.onmouseenter = () => { if (!btn.disabled) btn.style.backgroundColor = hoverButtonBg; };
        btn.onmouseleave = () => { if (!btn.disabled) btn.style.backgroundColor = baseButtonBg; };
    });

    const statusMessage = document.createElement('div');
    statusMessage.id = 'video-filter-status';
    statusMessage.style.marginTop = '8px';
    statusMessage.style.fontSize = '12px';
    statusMessage.style.minHeight = '15px';
    statusMessage.style.color = '#cccccc';

    panelMainContent.appendChild(document.createTextNode('Pages: '));
    panelMainContent.appendChild(pageRangeInput);
    panelMainContent.appendChild(filterButton);
    panelMainContent.appendChild(copyUrlsButton);
    panelMainContent.appendChild(statusMessage);

    uiContainer.appendChild(collapseButton);
    uiContainer.appendChild(panelMainContent);
    document.body.appendChild(uiContainer);


    function togglePanelCollapse() {
        isPanelCollapsed = !isPanelCollapsed;
        if (isPanelCollapsed) {
            panelMainContent.style.display = 'none';
            collapseButton.innerHTML = '«';
            uiContainer.style.width = '41px';
            uiContainer.style.height = '80px';
            uiContainer.style.padding = '0';
        } else {
            panelMainContent.style.display = 'block';
            collapseButton.innerHTML = '»';
            uiContainer.style.width = '';
            uiContainer.style.height = '';
            uiContainer.style.padding = initialUiContainerPadding;
        }
        localStorage.setItem(LS_COLLAPSE_KEY, isPanelCollapsed.toString());
    }
    collapseButton.addEventListener('click', togglePanelCollapse);

    const initiallyCollapsed = localStorage.getItem(LS_COLLAPSE_KEY) === 'true';
    if (initiallyCollapsed) {
        togglePanelCollapse();
    }

    function setupVideoIntersectionObserver() {
        if (videoIntersectionObserver) {
            videoIntersectionObserver.disconnect();
        }
        const options = { root: null, rootMargin: '200px 0px', threshold: 0.01 };
        videoIntersectionObserver = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const videoElement = entry.target;
                    const sourceElement = videoElement.querySelector('source[data-src]');
                    if (sourceElement) {
                        const videoUrl = sourceElement.getAttribute('data-src');
                        sourceElement.setAttribute('src', videoUrl);
                        videoElement.load();
                        sourceElement.removeAttribute('data-src');
                        observer.unobserve(videoElement);
                    }
                }
            });
        }, options);
    }

    function showStatus(message, type = 'info') {
        statusMessage.textContent = message;
        switch (type) {
            case 'error': statusMessage.style.color = '#ff6b6b'; break;
            case 'success': statusMessage.style.color = '#76c7c0'; break;
            case 'info': default: statusMessage.style.color = '#cccccc'; break;
        }
        if (type === 'success' && message.includes("Copied")) {
            setTimeout(() => {
                if (statusMessage.textContent === message) {
                    statusMessage.textContent = '';
                    statusMessage.style.color = '#cccccc';
                }
            }, 3000);
        }
    }

    function parsePageRange(inputStr) {
        const pages = new Set();
        if (!inputStr || inputStr.trim() === '') {
            showStatus('Error: Page range cannot be empty.', 'error');
            return null;
        }
        const parts = inputStr.split(',');
        for (const part of parts) {
            if (part.includes('-')) {
                const [startStr, endStr] = part.split('-');
                const start = parseInt(startStr, 10);
                const end = parseInt(endStr, 10);
                if (isNaN(start) || isNaN(end) || start <= 0 || end < start) {
                    showStatus(`Error: Invalid range "${part}". Start must be > 0 and end >= start.`, 'error');
                    return null;
                }
                for (let i = start; i <= end; i++) pages.add(i);
            } else {
                const page = parseInt(part, 10);
                if (isNaN(page) || page <= 0) {
                    showStatus(`Error: Invalid page number "${part}". Must be > 0.`, 'error');
                    return null;
                }
                pages.add(page);
            }
        }
        if (pages.size === 0) {
            showStatus('Error: No valid pages specified.', 'error');
            return null;
        }
        return Array.from(pages).sort((a, b) => a - b);
    }

    function determinePageContext() {
        const pathname = window.location.pathname;
        const searchParams = new URLSearchParams(window.location.search);
        const query = searchParams.get('q');
        const userProfileMatch = pathname.match(/^\/([^/]+)\/user\/([^/]+)$/);

        if (userProfileMatch && !query) return { type: 'profile', service: userProfileMatch[1], userId: userProfileMatch[2] };
        if (userProfileMatch && query) return { type: 'user_search', service: userProfileMatch[1], userId: userProfileMatch[2], query };
        if (pathname === '/posts') return { type: 'global_search', query: query || null };

        // Упрощенная логика для popular, т.к. в этой версии она не используется
        if (pathname === '/posts/popular') return { type: 'popular_posts', date: new Date().toISOString().slice(0, 10), period: 'week' };

        console.error('Video Filter: Unknown page structure for context. Path:', pathname);
        return null;
    }

    // ИЗМЕНЕНО: Обновлена логика для использования правильного API endpoint на страницах авторов
    function buildApiUrl(context, offset) {
        let baseApiUrl = `https://${currentDomain}/api/v1`;
        let queryParts = [`o=${offset}`];

        switch (context.type) {
            case 'profile':
                return `${baseApiUrl}/${context.service}/user/${context.userId}/posts?${queryParts.join('&')}`;
            case 'user_search':
                if (context.query) queryParts.push(`q=${encodeURIComponent(context.query)}`);
                return `${baseApiUrl}/${context.service}/user/${context.userId}/posts-legacy?${queryParts.join('&')}`;
            case 'global_search':
                if (context.query) queryParts.push(`q=${encodeURIComponent(context.query)}`);
                return `${baseApiUrl}/posts?${queryParts.join('&')}`;
            case 'popular_posts':
                queryParts.push(`date=${encodeURIComponent(context.date)}`);
                queryParts.push(`period=${encodeURIComponent(context.period)}`);
                return `${baseApiUrl}/posts/popular?${queryParts.join('&')}`;
            default:
                return null;
        }
    }

    // ИЗМЕНЕНО: Добавлены кастомные заголовки для обхода защиты (ошибка 403)
    function fetchData(apiUrl) {
        const headers = {
            "Accept": "application/json, text/plain, */*",
            "Referer": window.location.href,
            "User-Agent": navigator.userAgent,
            "X-Requested-With": "XMLHttpRequest"
        };
        // Особый случай для coomer, который требует странный заголовок Accept
        if (currentDomain.includes('coomer')) {
            headers['Accept'] = 'text/css';
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: apiUrl,
                headers: headers, // Используем кастомные заголовки
                onload: resp => {
                    if (resp.status >= 200 && resp.status < 300) {
                        try {
                            resolve(JSON.parse(resp.responseText));
                        } catch (e) {
                            reject(`Error parsing JSON: ${e.message}`);
                        }
                    } else {
                        reject(`API request failed: ${resp.status} ${resp.statusText}`);
                    }
                },
                onerror: resp => reject(`Network error: ${resp.statusText || 'Unknown'}`)
            });
        });
    }

    function isVideoFile(filenameOrPath) {
        if (!filenameOrPath) return false;
        const lowerName = filenameOrPath.toLowerCase();
        return VIDEO_EXTENSIONS.some(ext => lowerName.endsWith('.' + ext));
    }

    function isImageFile(filenameOrPath) {
        if (!filenameOrPath) return false;
        const lowerName = filenameOrPath.toLowerCase();
        return IMAGE_EXTENSIONS.some(ext => lowerName.endsWith('.' + ext));
    }

    function getPostPreviewUrl(post, apiPreviewsEntry) {
        // ... (логика получения превью)
        return null; // Упрощено для совместимости
    }

    function getFirstVideoUrlFromPost(post) {
        const domain = `https://${currentDomain}/data`;
        if (post.file && (isVideoFile(post.file.name) || isVideoFile(post.file.path))) {
            return domain + (post.file.path || post.file.name);
        }
        if (post.attachments) {
            for (const att of post.attachments) {
                if (isVideoFile(att.name) || isVideoFile(att.path)) {
                    return domain + (att.path || att.name);
                }
            }
        }
        return null;
    }

    function createPostCardHtml(post, previewUrl) {
        const postDate = new Date(post.published || post.added);
        const formattedDate = postDate.toLocaleString();
        const dateTimeAttr = postDate.toISOString();
        const attachmentCount = post.attachments ? post.attachments.length : 0;
        const attachmentText = attachmentCount === 1 ? "1 Attachment" : `${attachmentCount} Attachments`;

        let displayTitle = (post.title || '').trim();
        if (!displayTitle && post.content) {
            const tempDiv = document.createElement('div');
            tempDiv.innerHTML = post.content;
            let contentForTitle = (tempDiv.textContent || "").trim();
            if (contentForTitle) {
                displayTitle = contentForTitle.substring(0, SUBSTRING_TITLE_LENGTH) + (contentForTitle.length > SUBSTRING_TITLE_LENGTH ? '...' : '');
            }
        }
        displayTitle = displayTitle || 'No Title';

        let mediaHtml = '';
        const firstVideoUrl = getFirstVideoUrlFromPost(post);

        if (firstVideoUrl) {
            const posterAttribute = previewUrl ? `poster="${previewUrl}"` : '';
            mediaHtml = `
            <div style="text-align: center; margin-bottom: 5px; background-color: #000;">
                <video class="lazy-load-video" controls preload="none" width="100%" style="max-height: 300px; display: block;" ${posterAttribute}>
                    <source data-src="${firstVideoUrl}" type="video/mp4">
                </video>
            </div>`;
        } else if (previewUrl) {
            mediaHtml = `<div style="text-align: center; margin-bottom: 5px;"><img src="${previewUrl}" style="max-width: 100%; height: auto; max-height: 200px; object-fit: contain;"></div>`;
        } else {
            mediaHtml = `<div style="height: 100px; display: flex; align-items: center; justify-content: center; background-color: #333; color: #aaa;">No Preview</div>`;
        }

        const postLink = `/${post.service}/user/${post.user}/post/${post.id}`;
        return `
        <article class="post-card post-card--preview">
          <a class="fancy-link" href="${postLink}" target="_blank" rel="noopener noreferrer">
           <header class="post-card__header" title="${displayTitle.replace(/"/g, '"')}">${displayTitle}</header>
           ${mediaHtml}
           <footer class="post-card__footer">
             <div>
                <div>
                  <time datetime="${dateTimeAttr}">${formattedDate}</time>
                  <div>${attachmentCount > 0 ? attachmentText : 'No Attachments'}</div>
                </div>
              </div>
            </footer>
          </a>
        </article>`;
    }

    async function handleFilter() {
        showStatus('');
        filterButton.textContent = 'Filtering...';
        styleButton(filterButton, true); filterButton.disabled = true;
        styleButton(copyUrlsButton, true); copyUrlsButton.disabled = true;
        allFoundVideoUrls = [];

        setupVideoIntersectionObserver();

        const pagesToFetch = parsePageRange(pageRangeInput.value);
        if (!pagesToFetch) {
            styleButton(filterButton, false); filterButton.disabled = false;
            return;
        }

        const context = determinePageContext();
        if (!context) {
             showStatus('Error: Page context invalid for filtering.', 'error');
            styleButton(filterButton, false); filterButton.disabled = false;
            return;
        }

        const postListContainer = document.querySelector('.card-list__items');
        if (!postListContainer) {
            showStatus('Error: Post container not found.', 'error');
            styleButton(filterButton, false); filterButton.disabled = false;
            return;
        }
        postListContainer.style.setProperty('--card-size', '350px');
        postListContainer.innerHTML = '';

        document.querySelectorAll('.paginator menu, .content > menu.Paginator').forEach(menu => menu.style.display = 'none');
        const paginatorInfo = document.querySelector('.paginator > small, .content > div > small.subtle-text');
        if (paginatorInfo) paginatorInfo.textContent = `Filtering posts...`;

        let totalVideoPostsFound = 0;

        for (let i = 0; i < pagesToFetch.length; i++) {
            const pageNum = pagesToFetch[i];
            const offset = (pageNum - 1) * POSTS_PER_PAGE;
            const apiUrl = buildApiUrl(context, offset);
            if (!apiUrl) {
                showStatus('Error: Could not build API URL.', 'error');
                break;
            }

            filterButton.textContent = `Filtering Page ${i + 1}/${pagesToFetch.length}...`;
            showStatus(`Fetching page ${pageNum}...`, 'info');

            try {
                const apiResponse = await fetchData(apiUrl);
                // ИЗМЕНЕНО: Более надежная проверка на массив
                let posts = Array.isArray(apiResponse) ? apiResponse : (apiResponse.results || apiResponse.posts);

                if (!Array.isArray(posts)) {
                    console.error("Could not extract a valid posts array from API response:", apiResponse);
                    showStatus(`Warning: Unexpected API structure on page ${pageNum}.`, 'error');
                    continue;
                }

                for (let postIndex = 0; postIndex < posts.length; postIndex++) {
                    const post = posts[postIndex];
                    const videoUrl = getFirstVideoUrlFromPost(post);

                    if (videoUrl) {
                        totalVideoPostsFound++;
                        allFoundVideoUrls.push(videoUrl);
                        const previewUrl = getPostPreviewUrl(post, null); // Упрощено
                        const cardHtml = createPostCardHtml(post, previewUrl);
                        postListContainer.insertAdjacentHTML('beforeend', cardHtml);
                    }
                }
                if (paginatorInfo) paginatorInfo.textContent = `Showing ${totalVideoPostsFound} video posts.`;

            } catch (error) {
                showStatus(`Error on page ${pageNum}: ${error}`, 'error');
                console.error("Filter error:", error);
            }
            if (i < pagesToFetch.length - 1) await new Promise(resolve => setTimeout(resolve, API_DELAY));
        }

        postListContainer.querySelectorAll('video.lazy-load-video').forEach(videoEl => {
            if (videoEl.querySelector('source[data-src]')) {
                videoIntersectionObserver.observe(videoEl);
            }
        });

        filterButton.textContent = 'Filter Videos';
        styleButton(filterButton, false); filterButton.disabled = false;

        if (totalVideoPostsFound > 0) {
            showStatus(`Filter complete. Found ${totalVideoPostsFound} video posts.`, 'success');
            styleButton(copyUrlsButton, false); copyUrlsButton.disabled = false;
        } else {
            showStatus('Filter complete. No video posts found.', 'info');
            styleButton(copyUrlsButton, true); copyUrlsButton.disabled = true;
        }
    }
    filterButton.addEventListener('click', handleFilter);


    function handleCopyUrls() {
        if (allFoundVideoUrls.length === 0) {
            showStatus("No video URLs to copy.", 'error');
            return;
        }
        const uniqueUrls = [...new Set(allFoundVideoUrls)];
        GM_setClipboard(uniqueUrls.join('\n'));
        copyUrlsButton.textContent = `Copied ${uniqueUrls.length} URLs!`;
        showStatus(`Copied ${uniqueUrls.length} unique video URLs!`, 'success');
        setTimeout(() => { copyUrlsButton.textContent = 'Copy Video URLs'; }, 3000);
    }
    copyUrlsButton.addEventListener('click', handleCopyUrls);


    function handleUrlChangeAndSetStatus() {
        const currentContext = determinePageContext();
        allFoundVideoUrls = [];
        styleButton(copyUrlsButton, true);
        copyUrlsButton.disabled = true;

        if (videoIntersectionObserver) {
            videoIntersectionObserver.disconnect();
        }

        if (currentContext) {
            showStatus("Filter ready. Enter page range and click 'Filter Videos'.", 'info');
            styleButton(filterButton, false);
            filterButton.disabled = false;
        } else {
            showStatus("Page context not recognized. Filter disabled.", 'error');
            styleButton(filterButton, true);
            filterButton.disabled = true;
        }
    }

    const originalPushState = history.pushState;
    history.pushState = function() {
        originalPushState.apply(this, arguments);
        window.dispatchEvent(new Event('custompushstate'));
    };

    const originalReplaceState = history.replaceState;
    history.replaceState = function() {
        originalReplaceState.apply(this, arguments);
        window.dispatchEvent(new Event('customreplacestate'));
    };

    window.addEventListener('popstate', handleUrlChangeAndSetStatus);
    window.addEventListener('custompushstate', handleUrlChangeAndSetStatus);
    window.addEventListener('customreplacestate', handleUrlChangeAndSetStatus);

    handleUrlChangeAndSetStatus();

})();