xHamster Zippser Filter

Hides videos from specified channels, hides watched videos, and excludes videos by title phrases on Xhamster.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         xHamster Zippser Filter
// @namespace    http://tampermonkey.net/
// @version      1.0.3
// @description  Hides videos from specified channels, hides watched videos, and excludes videos by title phrases on Xhamster.
// @author       https://github.com/zippser
// @match        https://*.xhamster.com/*
// @exclude      https://*.xhamster.com/embedframe/*
// @exclude      https://*.xhamster.com/user/*/comments
// @grant        GM_addStyle
// @license      GPL-3.0
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const LOGO = `[XHAMSTER FILTER V1.0.3]`;
    const VIDEO_CONTAINER_SELECTOR = 'div.thumb-list__item.video-thumb.video-thumb--type-video';
    const WATCHED_MARKER_SELECTOR = 'div.thumb-image-container__watched[data-role="video-watched"]';

    // --- CONFIGURATION ---
    const UPLOADERS_TO_HIDE = [
         "FinDom Goaldigger",  "JOI Trainer"
    ];
    const EXCLUDE_PHRASES = [
                "cei", "fart", "pee", "foot"
    ];
    // --- END CONFIGURATION ---

    /** Hides the video card element. */
    function hideVideoCard(element) {
        if (element && element.style.display !== 'none') {
            element.style.display = 'none';
        }
    }

    /** Extracts video title. */
    function getTitleFromContainer(container) {
        const titleElement = container.querySelector('.video-thumb-info__name');
        return titleElement ? titleElement.textContent.trim() : "";
    }

    /** Extracts uploader name. */
    function getUploaderFromContainer(container) {
        const uploaderElement = container.querySelector('a.video-uploader__name');
        return uploaderElement ? uploaderElement.textContent.trim() : "";
    }

    /** Applies filtering logic to all video containers. */
    function applyFilters() {
        const videoContainers = document.querySelectorAll(VIDEO_CONTAINER_SELECTOR);
        let hiddenCount = 0;

        videoContainers.forEach(container => {
            if (container.style.display === 'none') return; // Skip if already hidden

            let shouldHide = false;

            // Check 1: Exclude by Title Phrase
            const videoTitle = getTitleFromContainer(container);
            if (videoTitle && EXCLUDE_PHRASES.some(phrase => videoTitle.toLowerCase().includes(phrase.toLowerCase()))) {
                console.log(`${LOGO} Hiding video (Title Excluded): "${videoTitle}"`);
                shouldHide = true;
            }
            if (shouldHide) {
                hideVideoCard(container);
                hiddenCount++;
                return;
            }

            // Check 2: Hide Watched Videos
            if (container.querySelector(WATCHED_MARKER_SELECTOR)) {
                // console.log(`${LOGO} Hiding watched video (marker found): ${container.dataset.videoId}`);
                shouldHide = true;
            }
            if (shouldHide) {
                hideVideoCard(container);
                hiddenCount++;
                return;
            }

            // Check 3: Hide Videos from Blacklisted Uploaders
            const uploaderName = getUploaderFromContainer(container);
            if (uploaderName && UPLOADERS_TO_HIDE.some(blockedName => uploaderName.toLowerCase().includes(blockedName.toLowerCase()))) {
                console.log(`${LOGO} Hiding video from blacklisted uploader: "${uploaderName}"`);
                shouldHide = true;
            }
            if (shouldHide) {
                hideVideoCard(container);
                hiddenCount++;
            }
        });

        if (hiddenCount > 0) {
            console.log(`${LOGO} Filtered ${hiddenCount} videos in this pass.`);
        }
    }

    // --- Observer Setup ---
    let observer;
    let observerTimeout;
    const observerTarget = document.getElementById('content') || document.body;

    function startObserver() {
        if (!observerTarget) {
            console.error(`${LOGO} Observer target not found. Dynamic filtering might fail.`);
            return;
        }

        // Re-apply filters immediately when the observer is activated or re-activated
        applyFilters();

        const observerConfig = { childList: true, subtree: true };
        observer = new MutationObserver((mutationsList) => {
            let contentAddedOrChanged = false;
            for (const mutation of mutationsList) {
                // Check if nodes were added OR removed (sometimes elements change attributes)
                if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
                    // More general check: look for video containers anywhere in the changes
                     for (const node of mutation.addedNodes) {
                         if (node.nodeType === 1 && (node.matches(VIDEO_CONTAINER_SELECTOR) || node.querySelectorAll(VIDEO_CONTAINER_SELECTOR).length > 0)) {
                             contentAddedOrChanged = true;
                             break;
                         }
                     }
                     // If not found in added nodes, check potentially changed parents
                     if (!contentAddedOrChanged && mutation.target && (mutation.target.matches(VIDEO_CONTAINER_SELECTOR) || mutation.target.querySelectorAll(VIDEO_CONTAINER_SELECTOR).length > 0)) {
                         contentAddedOrChanged = true;
                     }
                }
                if (contentAddedOrChanged) break;
            }

            if (contentAddedOrChanged) {
                clearTimeout(observerTimeout);
                observerTimeout = setTimeout(applyFilters, 250); // Debounce
            }
        });

        observer.observe(observerTarget, observerConfig);
        console.log(`${LOGO} Mutation Observer established.`);
    }

    // --- Initialization ---

    // Try to start the observer as early as possible.
    // Using requestAnimationFrame can help ensure it's set up after the initial DOM is ready,
    // but before the browser is fully idle, potentially catching faster loads.
    window.addEventListener('DOMContentLoaded', () => {
         // Apply filters once DOM is ready, before observer starts fully
         applyFilters();
         // Then start the observer which will also call applyFilters() initially
         startObserver();
    });

    // Fallback: if DOMContentLoaded doesn't fire as expected, ensure observer starts eventually
    // (though @run-at document-idle should cover this)
    if (document.readyState !== 'loading') {
         // If already past loading state, start observer immediately
         applyFilters(); // Apply filters once more just in case
         startObserver();
    } else {
        // Otherwise, wait for DOMContentLoaded
        window.addEventListener('DOMContentLoaded', () => {
             applyFilters(); // Apply filters once more just in case
             startObserver();
        });
    }

})();