YouTube Chat Filter

Filters messages in YouTube stream chat.

当前为 2022-08-13 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        YouTube Chat Filter
// @version     1.0
// @description Filters messages in YouTube stream chat.
// @author      Callum Latham
// @namespace   https://greasyfork.org/users/696211-ctl2
// @license     MIT
// @match       *://www.youtube.com/*
// @match       *://youtube.com/*
// @require     https://greasyfork.org/scripts/446506-config/code/$Config.js?version=1081062
// @require     https://greasyfork.org/scripts/449472-boolean/code/$Boolean.js?version=1081058
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// ==/UserScript==

// Don't run outside the chat frame
if (window.frameElement.id !== 'chatframe') {
    // noinspection JSAnnotator
    return;
}

window.addEventListener('load', async () => {
    // STATIC CONSTS

    const LONG_PRESS_TIME = 400;
    const ACTIVE_COLOUR = 'var(--yt-spec-call-to-action)';
    const CHAT_LIST_SELECTOR = '#items.yt-live-chat-item-list-renderer';
    const FILTER_CLASS = 'cf';
    const TITLE = 'YouTube Chat Filter';
    const PRIORITIES = {
        'VERIFIED': 'Verification Badge',
        'MODERATOR': 'Moderator Badge',
        'MEMBER': 'Membership Badge',
        'LONG': 'Long',
        'RECENT': 'Recent',
        'SUPERCHAT': 'Superchat',
        'MEMBERSHIP_RENEWAL': 'Membership Renewal',
        'MEMBERSHIP_GIFT_OUT': 'Membership Gift (Given)',
        'MEMBERSHIP_GIFT_IN': 'Membership Gift (Received)',
        'EMOJI': 'Emojis'
    };

    // ELEMENT CONSTS

    const STREAMER = window.parent.document.querySelector('#channel-name').innerText;
    const ROOT_ELEMENT = document.body.querySelector('#chat');
    const [BUTTON, SVG, COUNTER] = await (async () => {
        const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';

        const [button, svgContainer, svg] = await new Promise((resolve) => {
            const template = document.body.querySelector('#overflow');
            const button = template.cloneNode(true);
            const svgContainer = button.querySelector('yt-icon');

            button.style.visibility = 'hidden';

            template.parentElement.insertBefore(button, template);

            window.setTimeout(() => {
                const path = document.createElementNS(SVG_NAMESPACE, 'path');

                path.setAttribute('d', 'M128.25,175.6c1.7,1.8,2.7,4.1,2.7,6.6v139.7l60-51.3v-88.4c0-2.5,1-4.8,2.7-6.6L295.15,65H26.75L128.25,175.6z');

                const rectangle = document.createElementNS(SVG_NAMESPACE, 'rect');

                rectangle.setAttribute('x', '13.95');
                rectangle.setAttribute('y', '0');
                rectangle.setAttribute('width', '294');
                rectangle.setAttribute('height', '45');

                const svg = document.createElementNS(SVG_NAMESPACE, 'svg');

                svg.setAttribute('viewBox', '-50 -50 400 400');
                svg.setAttribute('x', '0');
                svg.setAttribute('y', '0');
                svg.setAttribute('focusable', 'false');

                svg.append(path, rectangle);

                svgContainer.innerHTML = '';
                svgContainer.append(svg);

                button.style.removeProperty('visibility');

                resolve([button, svgContainer, svg]);
            }, 0);
        });

        const counter = (() => {
            const container = document.createElement('div');

            container.style.position = 'absolute';
            container.style.left = '9px';
            container.style.bottom = '9px';
            container.style.fontSize = '1.1em';
            container.style.lineHeight = 'normal';
            container.style.width = '1.6em';
            container.style.display = 'flex';
            container.style.alignItems = 'center';

            const svg = (() => {
                const circle = document.createElementNS(SVG_NAMESPACE, 'circle');

                circle.setAttribute('r', '50');
                circle.style.color = 'var(--yt-live-chat-header-background-color)';
                circle.style.opacity = '0.65';

                const svg = document.createElementNS(SVG_NAMESPACE, 'svg');

                svg.setAttribute('viewBox', '-70 -70 140 140');

                svg.append(circle);

                return svg;
            })();

            const text = document.createElement('span');

            text.style.position = 'absolute';
            text.style.width = '100%';
            text.innerText = '?';

            container.append(text, svg);

            svgContainer.append(container);

            return text;
        })();

        return [button, svg, counter];
    })();

    // STATE INTERFACES

    const $active = new $Boolean('YTCF_IS_ACTIVE');

    const $config = new $Config(
        'YTCF_TREE',
        (() => {
            const regexPredicate = (value) => {
                try {
                    RegExp(value);
                } catch (_) {
                    return 'Value must be a valid regular expression.';
                }

                return true;
            };

            return {
                'children': [
                    {
                        'label': 'Filters',
                        'children': [],
                        'seed': {
                            'label': 'Description',
                            'value': '',
                            'children': [
                                {
                                    'label': 'Streamer Regex',
                                    'children': [],
                                    'seed': {
                                        'value': '^',
                                        'predicate': regexPredicate
                                    }
                                },
                                {
                                    'label': 'Author Regex',
                                    'children': [],
                                    'seed': {
                                        'value': '^',
                                        'predicate': regexPredicate
                                    }
                                },
                                {
                                    'label': 'Message Regex',
                                    'children': [],
                                    'seed': {
                                        'value': '^',
                                        'predicate': regexPredicate
                                    }
                                }
                            ]
                        }
                    },
                    {
                        'label': 'Options',
                        'children': [
                            {
                                'label': 'Case-Sensitive Regex?',
                                'value': false
                            },
                            {
                                'label': 'Show Intro Message?',
                                'value': false
                            },
                            {
                                'label': 'Pause on Mouse Over?',
                                'value': false
                            },
                            {
                                'label': 'Queue Time (ms)',
                                'value': 1000,
                                'predicate': (value) => value >= 0 ? true : 'Queue time must be positive'
                            }
                        ]
                    },
                    {
                        'label': 'Preferences',
                        'children': [
                            {
                                'label': 'Requirements',
                                'children': [
                                    {
                                        'label': 'OR',
                                        'children': [],
                                        'poolId': 0
                                    },
                                    {
                                        'label': 'AND',
                                        'children': [],
                                        'poolId': 0
                                    }
                                ]
                            },
                            {
                                'label': 'Priorities (High to Low)',
                                'poolId': 0,
                                'children': Object.values(PRIORITIES).map(label => ({
                                    label,
                                    'value': label !== PRIORITIES.EMOJI && label !== PRIORITIES.MEMBERSHIP_GIFT_IN
                                }))
                            }
                        ]
                    }
                ]
            };
        })(),
        (() => {
            const EVALUATORS = (() => {
                const getEvaluator = (evaluator, isDesired) => isDesired ? evaluator : (_) => 1 - evaluator(_);

                return {
                    [PRIORITIES.RECENT]: getEvaluator.bind(null, () => 1),
                    [PRIORITIES.LONG]: getEvaluator.bind(null, _ => _.querySelector('#message').textContent.length),
                    [PRIORITIES.SUPERCHAT]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-paid-message-renderer')),
                    [PRIORITIES.MEMBERSHIP_RENEWAL]: getEvaluator.bind(null, _ => _.matches('yt-live-chat-membership-item-renderer')),
                    [PRIORITIES.MEMBERSHIP_GIFT_OUT]: getEvaluator.bind(null, _ => _.matches('ytd-sponsorships-live-chat-gift-purchase-announcement-renderer')),
                    [PRIORITIES.MEMBERSHIP_GIFT_IN]: getEvaluator.bind(null, _ => _.matches('ytd-sponsorships-live-chat-gift-redemption-announcement-renderer')),
                    [PRIORITIES.EMOJI]: getEvaluator.bind(null, _ => Boolean(_.querySelector('.emoji'))),
                    [PRIORITIES.MEMBER]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chat-badges > [type=member]'))),
                    [PRIORITIES.MODERATOR]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chip-badges > [type=verified]'))),
                    [PRIORITIES.VERIFIED]: getEvaluator.bind(null, _ => Boolean(_.querySelector('#chat-badges > [type=moderator]')))
                };
            })();

            return ([rawFilters, options, {'children': [{'children': [softRequirements, hardRequirements]}, priorities]}]) => ({
                'filters': (() => {
                    const filters = [];

                    const getRegex = options.children[0].value ?
                        ({value}) => new RegExp(value) :
                        ({value}) => new RegExp(value, 'i');
                    const matchesStreamer = (node) => getRegex(node).test(STREAMER);

                    for (const filter of rawFilters.children) {
                        const [{'children': streamers}, {'children': authors}, {'children': messages}] = filter.children;

                        if (streamers.length === 0 || streamers.some(matchesStreamer)) {
                            filters.push({
                                'authors': authors.map(getRegex),
                                'messages': messages.map(getRegex)
                            });
                        }
                    }

                    return filters;
                })(),
                'showIntroMessage': options.children[1].value,
                'pauseOnHover': options.children[2].value,
                'queueTime': options.children[3].value,
                'requirements': {
                    'soft': softRequirements.children.map(({
                        label, 'value': isDesired
                    }) => EVALUATORS[label](isDesired)),
                    'hard': hardRequirements.children.map(({
                        label, 'value': isDesired
                    }) => EVALUATORS[label](isDesired))
                },
                'comparitors': (() => {
                    const getComparitor = (getValue, low, high) => {
                        low = getValue(low);
                        high = getValue(high);

                        return low < high ? -1 : low === high ? 0 : 1;
                    };

                    return priorities.children.map(({
                        label, 'value': isDesired
                    }) => getComparitor.bind(null, EVALUATORS[label](isDesired)));
                })()
            });
        })(),
        TITLE,
        {
            'headBase': '#ff0000',
            'headButtonExit': '#000000',
            'borderHead': '#ffffff',
            'nodeBase': ['#222222', '#111111'],
            'borderTooltip': '#570000'
        },
        {'zIndex': 10000}
    );

    // CSS

    (function style() {
        function addStyle(sheet, selector, rules) {
            const ruleString = rules.map(
                ([selector, rule]) => `${selector}:${typeof rule === 'function' ? rule() : rule} !important;`
            );

            sheet.insertRule(`${selector}{${ruleString.join('')}}`);
        }

        const styleElement = document.createElement('style');
        const {sheet} = document.head.appendChild(styleElement);

        const styles = [
            [`${CHAT_LIST_SELECTOR}`, [
                ['bottom', 'inherit']
            ]],
            [`${CHAT_LIST_SELECTOR} > :not(.${FILTER_CLASS})`, [
                ['display', 'none']
            ]]
        ];

        for (const style of styles) {
            addStyle(sheet, style[0], style[1]);
        }
    })();

    // STATE

    let queuedPost;

    // FILTERING

    function doFilter() {
        const chatListElement = ROOT_ELEMENT.querySelector(CHAT_LIST_SELECTOR);

        let doQueue = false;
        let paused = false;

        function showPost(post) {
            const config = $config.get();

            post.classList.add(FILTER_CLASS);

            queuedPost = undefined;

            if (config && config.queueTime > 0) {
                // Start queueing
                doQueue = true;

                window.setTimeout(() => {
                    doQueue = false;

                    // Unqueue
                    if (!paused) {
                        acceptPost();
                    }
                }, config.queueTime);
            }
        }

        function acceptPost(post = queuedPost) {
            /** Unqueue iff:
             * - Nothing was queued at the most recent unqueue
             * - No posts have been shown since the last unqueue
             * - A post is queued
             */
            if (!post) {
                return;
            }

            if (doQueue || paused) {
                queuedPost = post;
            } else {
                showPost(post);
            }
        }

        window.document.body.addEventListener('mouseenter', () => {
            const config = $config.get();

            if (config && config.pauseOnHover) {
                paused = true;
            }
        });

        window.document.body.addEventListener('mouseleave', () => {
            const config = $config.get();

            paused = false;

            if (config && config.pauseOnHover) {
                acceptPost();
            }
        });

        function processPost(post) {
            const config = $config.get();

            // Test insta-accept
            if (
                (!config || !$active.get()) ||
                (config.showIntroMessage && post.matches('yt-live-chat-viewer-engagement-message-renderer')) ||
                post.matches('yt-live-chat-placeholder-item-renderer')
            ) {
                showPost(post);

                return;
            }

            // Test reject
            if (
                config.filters.some(filter =>
                    // Test author filter
                    (filter.authors.length > 0 && filter.authors.some(_ => _.test(post.querySelector('#author-name')?.textContent))) ||
                    // Test message filter
                    (filter.messages.length > 0 && filter.messages.some(_ => _.test(post.querySelector('#message')?.textContent)))
                ) ||
                // Test requirements
                (config.requirements.soft.length > 0 && !config.requirements.soft.some(passes => passes(post))) ||
                config.requirements.hard.some(passes => !passes(post))
            ) {
                return;
            }

            // Test inferior to queued post
            if (queuedPost) {
                for (const comparitor of config.comparitors) {
                    const rating = comparitor(post, queuedPost);

                    if (rating < 0) {
                        return;
                    }

                    if (rating > 0) {
                        break;
                    }
                }
            }

            acceptPost(post);
        }

        // Handle new posts
        new MutationObserver((mutations) => {
            for (const {addedNodes} of mutations) {
                addedNodes.forEach(processPost);
            }
        }).observe(
            chatListElement,
            {childList: true}
        );
    }

    // BUTTON LISTENERS

    (() => {
        let timeout;

        const updateSvg = () => {
            SVG.style[`${$active.get() ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);
        };

        const updateCounter = () => {
            const config = $config.get();
            const count = config ? config.filters.length : 0;

            queuedPost = undefined;

            COUNTER.style[`${count > 0 ? 'set' : 'remove'}Property`]('color', ACTIVE_COLOUR);

            COUNTER.innerText = `${count}`;
        };

        const onShortClick = (event) => {
            if (timeout && event.button === 0) {
                timeout = window.clearTimeout(timeout);

                $active.toggle();

                updateSvg();
            }
        };

        const onLongClick = () => {
            timeout = undefined;

            $config.edit()
                .then(updateCounter)
                .catch(({message}) => {
                    if (window.confirm(`${message}\n\nWould you like to erase your data?`)) {
                        $config.reset();

                        updateCounter();
                    }
                });
        };

        Promise.allSettled([
            $active.init()
                .then(updateSvg),
            $config.init()
                .then(updateCounter)
        ])
            .then((responses) => {
                for (const response of responses) {
                    if ('reason' in response) {
                        window.alert(response.reason.message);
                    }
                }

                BUTTON.addEventListener('mouseup', onShortClick);

                BUTTON.addEventListener('mousedown', (event) => {
                    if (event.button === 0) {
                        timeout = window.setTimeout(onLongClick, LONG_PRESS_TIME);
                    }
                });
            });
    })();

    doFilter();

    // Restart if the chat element gets replaced
    // This happens when switching between 'Top Chat Replay' and 'Live Chat Replay'
    new MutationObserver((mutations) => {
        for (const {addedNodes} of mutations) {
            for (const node of addedNodes) {
                if (node.matches('yt-live-chat-item-list-renderer')) {
                    doFilter();
                }
            }
        }
    }).observe(
        ROOT_ELEMENT.querySelector('#item-list'),
        {childList: true}
    );
});