YouTube用動画フィルター(再生済み/プレイリスト/低視聴回数/古い動画/ライブ)

YouTubeのおすすめ・動画一覧で再生済み動画・プレイリスト・低視聴回数・古い動画・ライブ視聴者数が少ない動画を非表示にします。再生済み動画はしきい値%で指定可能。オプションはコードから直接変更、もしくはメニューで変更可能。

// ==UserScript==
// @name         YouTube用動画フィルター(再生済み/プレイリスト/低視聴回数/古い動画/ライブ)
// @namespace    http://tampermonkey.net/
// @description  YouTubeのおすすめ・動画一覧で再生済み動画・プレイリスト・低視聴回数・古い動画・ライブ視聴者数が少ない動画を非表示にします。再生済み動画はしきい値%で指定可能。オプションはコードから直接変更、もしくはメニューで変更可能。
// @author       sanpin
// @match        *://*.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM_registerMenuCommand
// @version      2.62
// ==/UserScript==

if (window.top !== window.self) return;

const OPTION_STORAGE_KEY = 'YT_VideoFilter_Settings';

//下記コードから値の変更ができます。Tamparmonkeyメニューからも変更できます。
const DEFAULT_SETTINGS = {
    watchedThreshold: 0,// 視聴済み動画のしきい値(0=無効、1~100で視聴済み判定の%しきい値)
    viewThreshold: 1000, // 再生回数のしきい値(0=無効、1000→1000回再生以下の動画を非表示)
    ageThresholdYears: 0, // Y年以上前の動画を非表示(0=無効、1→1年より古い動画を非表示、2→2年より古い動画...)
    liveViewerThreshold: 500,// ライブ視聴者数のしきい値(0=無効、500→視聴者数が500人以下のライブを非表示)
    playlistFilter: 0, // プレイリスト判定の有効無効(0=無効,1=有効)
    disableOnSubs: true, // チャンネル内の動画は無視するか(true=有効、false=無効)
};

// セレクタまとめ
const VIDEO_ITEM_SELECTORS = [
    "ytd-rich-item-renderer",
    "ytd-compact-video-renderer",
    ".yt-lockup-view-model-wiz",
    ".yt-lockup-view-model--compact"
];

const METADATA_SELECTORS = [
    ".inline-metadata-item.style-scope.ytd-video-meta-block",
    "span.yt-core-attributed-string.yt-content-metadata-view-model-wiz__metadata-text",
    "span.yt-core-attributed-string.yt-content-metadata-view-model__metadata-text"
];

const OLD_VIDEO_SELECTORS = [
    ".yt-content-metadata-view-model__metadata-row .yt-core-attributed-string.yt-content-metadata-view-model__metadata-text",
    ".inline-metadata-item.style-scope.ytd-video-meta-block",
    "span.yt-core-attributed-string.yt-content-metadata-view-model-wiz__metadata-text"
];

const LIVE_VIEWER_SELECTORS = [
    ".yt-content-metadata-view-model-wiz__metadata-text",
    "span.yt-core-attributed-string",
    "span.inline-metadata-item.style-scope.ytd-video-meta-block"
];

const WATCHED_PROGRESS_SELECTORS = [
    'ytd-thumbnail-overlay-resume-playback-renderer #progress',
    '.yt-thumbnail-view-model__progress-bar',
    '.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment'
];

if (!localStorage.getItem(OPTION_STORAGE_KEY)) {
    localStorage.setItem(OPTION_STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS));
}

function getSettings() {
    try {
        return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(OPTION_STORAGE_KEY)) };
    } catch {
        return { ...DEFAULT_SETTINGS };
    }
}

function saveSettings(settings) {
    localStorage.setItem(OPTION_STORAGE_KEY, JSON.stringify(settings));
}

function parseViewCount(text) {
    if (!text) return 0;
    const multipliers = { "K": 1e3, "M": 1e6, "万": 1e4, "億": 1e8 };
    let numText = text.replace(/[^0-9\.KM万億]/g, "");
    let unit = Object.keys(multipliers).find(u => numText.includes(u)) || "";
    numText = numText.replace(unit, "");
    return numText ? parseFloat(numText) * (multipliers[unit] || 1) : 0;
}

function parseLiveViewerCount(text) {
    if (!text) return 0;
    const m = text.replace(/,/g, "").match(/([0-9]+)/);
    return m ? parseInt(m[1], 10) : 0;
}

function isHistoryPage() {
    return location.pathname === "/feed/history";
}

function isSubscriptionsPage() {
    return location.pathname.includes('/@') && location.pathname.includes('/videos');
}

function isLivePage() {
    return location.pathname.includes('/streams');
}

function isPlaylistElement(element) {
    const playlistThumb = element.querySelector('ytd-playlist-thumbnail:not([hidden])');
    if (playlistThumb) return true;
    const aList = element.querySelectorAll('a[href*="list="]');
    for (const a of aList) {
        const href = a.getAttribute('href');
        if (/list=[^&]+/.test(href) && /v=/.test(href)) return true;
    }
    const stack = element.querySelector('yt-collections-stack');
    if (stack && stack.offsetParent !== null) return true;
    const badge = element.querySelector('.badge-shape-wiz__text');
    if (badge && badge.textContent.match(/^\d+\s*本の動画/)) {
        let p = badge.parentElement;
        while (p) {
            if (p.tagName && p.tagName.toLowerCase() === 'ytd-playlist-thumbnail') return true;
            p = p.parentElement;
        }
    }
    const playlistLabel = element.querySelector('a, span');
    if (playlistLabel && playlistLabel.textContent.trim() === 'プレイリスト') {
        let p = playlistLabel.parentElement;
        while (p) {
            if (p.tagName && p.tagName.toLowerCase() === 'ytd-playlist-thumbnail') return true;
            p = p.parentElement;
        }
    }
    return false;
}

function isLiveVideo(videoElement, settings) {
    if (settings.liveViewerThreshold === 0) return false;
    for (const selector of LIVE_VIEWER_SELECTORS) {
        const elems = videoElement.querySelectorAll(selector);
        for (const elem of elems) {
            if (elem && /[0-9,]+ 人が視聴中/.test(elem.innerText)) {
                const count = parseLiveViewerCount(elem.innerText);
                if (count > 0 && count <= settings.liveViewerThreshold) return true;
                return false;
            }
        }
    }
    return false;
}

function isBadVideo(videoElement, settings) {
    for (const selector of METADATA_SELECTORS) {
        const elems = videoElement.querySelectorAll(selector);
        for (const elem of elems) {
            if (elem && elem.innerText.includes('回視聴')) {
                const viewCount = parseViewCount(elem.innerText);
                if (viewCount >= 0 && viewCount < settings.viewThreshold) return true;
            }
        }
    }
    return false;
}

function isOldVideo(el, settings) {
    if (settings.ageThresholdYears === 0) return false;
    const dateTexts = [];
    OLD_VIDEO_SELECTORS.forEach(sel => el.querySelectorAll(sel).forEach(elm => {
        if (elm.innerText && /前/.test(elm.innerText)) dateTexts.push(elm.innerText);
    }));
    for (const dateText of dateTexts) {
        const yearMatch = /([0-9]+)\s*年.*前/.exec(dateText);
        if (yearMatch && parseInt(yearMatch[1], 10) >= settings.ageThresholdYears) return true;
        const monthMatch = /([0-9]+)\s*ヶ月.*前/.exec(dateText);
        if (monthMatch && parseInt(monthMatch[1], 10) >= settings.ageThresholdYears * 12) return true;
    }
    return false;
}

function isWatchedVideo(videoElement, settings) {
    if (settings.watchedThreshold === 0) return false;
    for (const selector of WATCHED_PROGRESS_SELECTORS) {
        const el = videoElement.querySelector(selector);
        if (el && el.style && el.style.width) {
            const percent = parseFloat(el.style.width);
            if (!isNaN(percent) && percent >= settings.watchedThreshold) return true;
        }
    }
    return false;
}

function hideBadVideo(videoElement, settings) {
    if (!videoElement) return;
    if (isLivePage() || isHistoryPage()) return;
    if (settings.disableOnSubs && isSubscriptionsPage()) return;

    if (settings.playlistFilter === 1 && isPlaylistElement(videoElement)) {
        videoElement.style.display = "none";
        videoElement.dataset.filtered = "1";
        return;
    }

    if (isLiveVideo(videoElement, settings) ||
        isBadVideo(videoElement, settings) ||
        isOldVideo(videoElement, settings) ||
        isWatchedVideo(videoElement, settings)) {
        videoElement.style.display = "none";
        videoElement.dataset.filtered = "1";
    } else {
        videoElement.style.display = "";
        delete videoElement.dataset.filtered;
    }
}

// IntersectionObserver
const observer = new IntersectionObserver(entries => {
    const settings = getSettings();
    if (settings.disableOnSubs && isSubscriptionsPage()) return;
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            hideBadVideo(entry.target, settings);
            observer.unobserve(entry.target);
        }
    });
}, { rootMargin: "300px" });

function update() {
    if (isLivePage() || isHistoryPage()) return;
    const settings = getSettings();
    document.querySelectorAll(VIDEO_ITEM_SELECTORS.join(',')).forEach(video => {
        hideBadVideo(video, settings);
        observer.observe(video);
    });
}

// Tampermonkey メニュー登録
if (!window._yt_video_filter_menu_registered) {
    window._yt_video_filter_menu_registered = true;
    const settings = getSettings();

    function reloadAlert(msg) { alert(msg + '\nページを手動でリロードしてください。'); }

    GM_registerMenuCommand(`チャンネル内の動画無視: ${settings.disableOnSubs ? "ON" : "OFF"}`, () => {
        settings.disableOnSubs = !settings.disableOnSubs;
        saveSettings(settings);
        reloadAlert('チャンネル内動画無視を切り替えました。');
    });
    GM_registerMenuCommand(`古い動画フィルター: ${settings.ageThresholdYears === 0 ? "OFF" : settings.ageThresholdYears + "年以上前を非表示"}`, () => {
        const val = parseInt(prompt('何年以上前の動画を非表示にしますか?\n0で無効化', settings.ageThresholdYears), 10);
        if (!isNaN(val) && val >= 0) { settings.ageThresholdYears = val; saveSettings(settings); reloadAlert(val === 0 ? "古い動画フィルターを無効化しました。" : `古い動画フィルターを${val}年以上前に設定しました。`); } else { alert('無効な値です'); }
    });
    GM_registerMenuCommand(`ライブ視聴者フィルター: ${settings.liveViewerThreshold === 0 ? "OFF" : settings.liveViewerThreshold + "人以下を非表示"}`, () => {
        const val = parseInt(prompt('ライブ視聴者数のしきい値を入力してください。\n0で無効', settings.liveViewerThreshold), 10);
        if (!isNaN(val) && val >= 0) { settings.liveViewerThreshold = val; saveSettings(settings); alert(val === 0 ? 'ライブ視聴者フィルターを無効化しました。' : `ライブ視聴者数しきい値を${val}人に設定しました。`); } else { alert('無効な値です'); }
    });
    GM_registerMenuCommand(`再生済み動画非表示: ${settings.watchedThreshold === 0 ? "OFF" : settings.watchedThreshold + "%"}`, () => {
        const val = parseInt(prompt('再生済み動画非表示%を入力(0で無効)', settings.watchedThreshold), 10);
        if (!isNaN(val) && val >= 0 && val <= 100) { settings.watchedThreshold = val; saveSettings(settings); alert(val === 0 ? '再生済み動画非表示を無効化しました。' : `再生済み動画非表示を${val}%に設定しました。`); } else { alert('無効な値です'); }
    });
    GM_registerMenuCommand(`視聴回数しきい値: ${settings.viewThreshold}回`, () => {
        const val = parseInt(prompt('視聴回数のしきい値を入力(0以上)', settings.viewThreshold), 10);
        if (!isNaN(val) && val >= 0) { settings.viewThreshold = val; saveSettings(settings); reloadAlert(`視聴回数しきい値を${val}回に変更しました。`); } else { alert('無効な値です'); }
    });
    GM_registerMenuCommand(`プレイリスト要素非表示: ${settings.playlistFilter ? "ON" : "OFF"}`, () => {
        settings.playlistFilter = settings.playlistFilter ? 0 : 1; saveSettings(settings); reloadAlert('プレイリスト要素フィルターを切り替えました。');
    });
}

// ブラウザバック時の修正
window.addEventListener("popstate", () => {
    document.querySelectorAll("[data-filtered]").forEach(el => { el.style.display = ""; delete el.dataset.filtered; });
    setTimeout(update, 500);
});

// ページロード&動的監視
window.addEventListener("load", () => {
    update();
    ["yt-navigate-finish", "yt-page-data-updated", "yt-action"].forEach(event => {
        window.addEventListener(event, () => setTimeout(update, 500));
    });
    const mutationObserver = new MutationObserver(mutations => {
        if (isLivePage() || isHistoryPage()) return;
        const settings = getSettings();
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType !== 1) return;
                if (node.matches?.(VIDEO_ITEM_SELECTORS.join(','))) {
                    hideBadVideo(node, settings);
                    observer.observe(node);
                } else if (node.querySelectorAll) {
                    node.querySelectorAll(VIDEO_ITEM_SELECTORS.join(',')).forEach(video => {
                        hideBadVideo(video, settings);
                        observer.observe(video);
                    });
                }
            });
        });
    });
    mutationObserver.observe(document.body, { childList: true, subtree: true });
});