xHamster Zippser Filter

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
        });
    }

})();