FreshRSS NG Filter

Mark as read and hide articles matching the rule in FreshRSS. Rules are described by regular expressions.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        FreshRSS NG Filter
// @namespace   https://github.com/hiroki-miya
// @version     1.0.4
// @description Mark as read and hide articles matching the rule in FreshRSS. Rules are described by regular expressions.
// @author      hiroki-miya
// @license     MIT
// @match       https://freshrss.example.net/*
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @run-at      document-idle
// ==/UserScript==

(function() {
    'use strict';

    // Language for sorting
    const sortLocale = 'ja';

    // Retrieve saved filters
    let savedFilters = GM_getValue('filters', {});

    // Define editingFilterName globally (the name of the filter currently being edited)
    let editingFilterName = null;

    // Add styles
    GM_addStyle(`
        #freshrss-ng-filter {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 10000;
            background-color: white;
            border: 1px solid black;
            padding: 10px;
            width: max-content;
        }
        #freshrss-ng-filter > h2 {
            box-shadow: inset 0 0 0 0.5px black;
            padding: 5px 10px;
            text-align: center;
            cursor: move;
        }
        #freshrss-ng-filter > h4 {
            margin-top: 0;
        }
        #filter-list {
            margin-bottom: 10px;
            max-height: 50vh;
            overflow-y: auto;
        }
        .filter-item  {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        #filter-edit > div {
            display: flex;
            justify-content: space-between;
            align-items: center;
            line-height: 2;
            margin-bottom: 5px;
        }
        #filter-edit > div input {
            line-height: 2;
            margin: 0;
        }
        .filter-name,
        #filter-edit > div > label {
            flex-grow: 1;
            margin-right: 10px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        #filter-edit > div > div:has(input[type="checkbox"]) {
            margin-left: 5px;
            max-width: 90%;
            width: 300px;
        }
        #filter-edit > div input[type="checkbox"] {
            transform: scale(1.5);
            margin-left: 4px;
        }
        .edit-filter, .delete-filter,
        #filter-edit > div > input {
            margin-left: 5px;
        }
        .filter-info-label {
            display: inline;
        }
        .filter-info {
            display: inline-block;
            border-radius: 50%;
            width: 16px;
            height: 16px;
            min-height: 16px;
            line-height: 1.2;
            margin-left: 4px;
            position: relative;
            top: -6px;
            text-align: center;
            background-color: black;
            color: white;
            font-weight: 700;
        }
    `);

    // Function to render the filter list
    function updateFilterList() {
        // Sort filters
        const filterNames = Object.keys(savedFilters).sort((a, b) => a.localeCompare(b, sortLocale));
        const filterList = filterNames.map(name => {
            const filter = savedFilters[name];
            const checked = filter.disabled ? 'checked' : '';
            return `
                <div class="filter-item">
                    <div class="filter-name">${name}</div>
                    <button class="edit-filter" data-name="${name}">Edit</button>
                    <button class="delete-filter" data-name="${name}">Delete</button>
                    <label><input type="checkbox" class="disable-filter" data-name="${name}" ${checked}> Disabled</label>
                </div>
            `;
        }).join('');

        // Render the filter list
        document.getElementById('filter-list').innerHTML = filterList || 'No registered filters';

        // Re-register the filter edit button events
        Array.from(document.querySelectorAll('.edit-filter')).forEach(button => {
            button.addEventListener('click', () => {
                const filterName = button.getAttribute('data-name');
                const filter = savedFilters[filterName];

                // Pre-fill the form with the filter values
                document.getElementById('filter-name').value = filterName;
                document.getElementById('filter-currentUrl').value = filter.currentUrl || '';
                document.getElementById('filter-title').value = filter.title || '';
                document.getElementById('filter-url').value = filter.url || '';
                document.getElementById('filter-content').value = filter.content || '';
                document.getElementById('filter-text').value = filter.text || '';
                document.getElementById('filter-case').checked = filter.caseInsensitive || false;

                editingFilterName = filterName;

                // Update the form heading for editing
                document.querySelector('#filter-edit-title').innerText = 'Edit Existing Filter';
                document.querySelector('#fnfs-save').innerText = 'Update';
            });
        });

        Array.from(document.querySelectorAll('.disable-filter')).forEach(checkbox => {
            checkbox.addEventListener('change', (e) => {
                const filterName = e.target.getAttribute('data-name');
                savedFilters[filterName].disabled = e.target.checked;
                GM_setValue('filters', savedFilters);
                applyAllFilters();
            });
        });

        document.getElementById('fnfs-toggle-all-filters').innerText = areFiltersDisabled() ? 'Enable All Filters' : 'Disable All Filters';
        // Re-register the filter delete button events
        Array.from(document.querySelectorAll('.delete-filter')).forEach(button => {
            button.addEventListener('click', () => {
                const filterName = button.getAttribute('data-name');
                delete savedFilters[filterName];
                GM_setValue('filters', savedFilters);
                updateFilterList();
                applyAllFilters();
            });
        });
    }

    function areFiltersDisabled() {
        return Object.values(savedFilters).every(filter => filter.disabled);
    }

    function toggleAllFilters() {
        const disableAll = !areFiltersDisabled();
        Object.keys(savedFilters).forEach(filterName => {
            savedFilters[filterName].disabled = disableAll;
        });
        GM_setValue('filters', savedFilters);
        updateFilterList();
        applyAllFilters();
    }

    // Display filter settings
    function showSettings() {
        const settingsHTML = `
            <h2>NG Filter Settings</h2>
            <h4>Saved Filters</h4>
            <div id="filter-list"></div>
            <button id="fnfs-toggle-all-filters">${areFiltersDisabled() ? 'Enable All Filters' : 'Disable All Filters'}</button>
            <br>
            <hr>
            <h4 id="filter-edit-title">Create New Filter</h4>
            <div id="filter-edit">
            <div><label>Filter Name</label><input type="text" id="filter-name"></div>
            <div><label>FreshRSS Feed List URL</label><input type="text" id="filter-currentUrl"></div>
            <div><label>Title</label><input type="text" id="filter-title"></div>
            <div><label>Content URL</label><input type="text" id="filter-url"></div>
            <div><label class="filter-info-label">Content<div title="article.flux_content.innerText" class="filter-info">i</div></label><input type="text" id="filter-content"></div>
            <div><label class="filter-info-label">Text<div title="div.text.innerHTML" class="filter-info">i</div></label><input type="text" id="filter-text"></div>
            <div><label>Case insensitive?</label><div><input type="checkbox" id="filter-case"></div></div>
            <br>
            </div>
            <button id="fnfs-save">Save</button>
            <button id="fnfs-clear">Clear</button>
            <button id="fnfs-close">Close</button>
        `;

        const settingsDiv = document.createElement('div');
        settingsDiv.id = 'freshrss-ng-filter';
        settingsDiv.innerHTML = settingsHTML;
        document.body.appendChild(settingsDiv);

        // Initial render of saved filter list
        updateFilterList();

        // Make settings panel draggable
        makeDraggable(settingsDiv);

        // Save or update button event
        document.getElementById('fnfs-save').addEventListener('click', () => {
            const filterName = document.getElementById('filter-name').value;
            const filterCurrentUrl = document.getElementById('filter-currentUrl').value;
            const filterTitle = document.getElementById('filter-title').value;
            const filterUrl = document.getElementById('filter-url').value;
            const filterContent = document.getElementById('filter-content').value;
            const filterText = document.getElementById('filter-text').value;
            const caseInsensitive = document.getElementById('filter-case').checked;

            if (!filterName) {
                alert('Please enter a filter name');
                return;
            }

            // Save or update the filter
            savedFilters[filterName] = {
                currentUrl: filterCurrentUrl,
                title: filterTitle,
                url: filterUrl,
                content: filterContent,
                text: filterText,
                caseInsensitive: caseInsensitive,
                disabled: false
            };

            // If the filter name was changed during editing, delete the old filter
            if (editingFilterName && editingFilterName !== filterName) {
                delete savedFilters[editingFilterName];
            }

            GM_setValue('filters', savedFilters);

            showTooltip('Saved');

            initEdit();

            // Update filter list
            updateFilterList();

            // Apply filters immediately after saving
            applyAllFilters();
        });

        // Clear button event
        document.getElementById('fnfs-clear').addEventListener('click', () => {
            initEdit();
        });

        // Close button event
        document.getElementById('fnfs-close').addEventListener('click', () => {
            document.body.removeChild(settingsDiv);
        });

        document.getElementById('fnfs-toggle-all-filters').addEventListener('click', toggleAllFilters);
    }

    function initEdit() {
        editingFilterName = null;
        document.getElementById('filter-name').value = '';
        document.getElementById('filter-currentUrl').value = '';
        document.getElementById('filter-title').value = '';
        document.getElementById('filter-url').value = '';
        document.getElementById('filter-content').value = '';
        document.getElementById('filter-text').value = '';
        document.getElementById('filter-case').checked = false;

        // Update the form heading for creating a new filter
        document.querySelector('#filter-edit-title').innerText = 'Create New Filter';
        document.querySelector('#fnfs-save').innerText = 'Save';
    }

    // Function to display the tooltip
    function showTooltip(message) {
        // Create the tooltip element
        const tooltip = document.createElement('div');
        tooltip.textContent = message;
        tooltip.style.position = 'fixed';
        tooltip.style.top = '50%';
        tooltip.style.left = '50%';
        tooltip.style.transform = 'translate(-50%, -50%)';
        tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
        tooltip.style.color = 'white';
        tooltip.style.padding = '10px 20px';
        tooltip.style.borderRadius = '5px';
        tooltip.style.zIndex = '10000';
        tooltip.style.fontSize = '16px';
        tooltip.style.textAlign = 'center';

        // Add the tooltip to the page
        document.body.appendChild(tooltip);

        // Automatically remove the tooltip after 1 second
        setTimeout(() => {
            document.body.removeChild(tooltip);
        }, 1000);
    }

    // Make element draggable
    function makeDraggable(elmnt) {
        const header = elmnt.querySelector('h2');
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        header.onmousedown = dragMouseDown;

        function dragMouseDown(e) {
            e = e || window.event;
            e.preventDefault();

            // Get the mouse cursor position at startup:
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e = e || window.event;
            e.preventDefault();

            // Calculate the new cursor position:
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;

            // Set the element's new position:
            elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
            elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
        }
    }

    // Mark as read and hide articles
    function markAsNG(articleElement) {
        if (!articleElement) return;

        // Check if mark_read function is available
        if (typeof mark_read === 'function') {
            mark_read(articleElement, true, true);
        } else {
            // Fallback: manually add 'read' class and trigger 'read' event
            articleElement.classList.add('read');
            const event = new Event('read');
            articleElement.dispatchEvent(event);
        }

        // Hide the article
        articleElement.remove();
    }

    // Apply all filters automatically
    function applyAllFilters() {
        const articles = Array.from(document.querySelectorAll('#stream > .flux'));
        const currentPageUrl = window.location.href;

        articles.forEach(article => {
            const title = article.querySelector('a.item-element.title')?.innerText || '';
            const url = article.querySelector('a.item-element.title')?.href || '';
            const content = article.querySelector('.flux_content')?.innerText || '';
            const text = article.querySelector('div.text')?.innerHTML || '';

            let matchesAnyFilter = false;

            for (let filterName in savedFilters) {
                const filter = savedFilters[filterName];
                if (filter.disabled) continue;

                const regexFlags = filter.caseInsensitive ? 'i' : '';
                const currentUrlMatch = !filter.currentUrl || new RegExp(filter.currentUrl, regexFlags).test(currentPageUrl);
                const titleMatch = !filter.title || new RegExp(filter.title, regexFlags).test(title);
                const urlMatch = !filter.url || new RegExp(filter.url, regexFlags).test(url);
                const contentMatch = !filter.content || new RegExp(filter.content, regexFlags).test(content);
                const textMatch = !filter.text || new RegExp(filter.text, regexFlags).test(text);

//                 console.log('titleMatch(' + titleMatch + '): ' + filter.title + ' = ' + title + '\n' +
//                      'urlMatch(' + urlMatch + '): ' + filter.url + ' = ' + url + '\n' +
//                      'contentMatch(' + contentMatch + '): ' + filter.content + ' = ' + content + '\n' +
//                      'textMatch(' + textMatch + '): ' + filter.text + ' = ' + text + '\n');

                // Check if all filter conditions are met (AND condition)
                if (currentUrlMatch && titleMatch && urlMatch && contentMatch && textMatch) {
                    markAsNG(article);
                    break;
                }
            }
        });
    }

    // Setup MutationObserver
    function setupObserver() {
        const targetNode = document.querySelector('#stream');
        if (targetNode) {
            const observer = new MutationObserver(applyAllFilters);
            observer.observe(targetNode, { childList: true, subtree: true });
            applyAllFilters();
        } else {
            // Retry if #stream is not found
            setTimeout(setupObserver, 1000);
        }
    }

    // Register settings screen
    GM_registerMenuCommand('Settings', showSettings);

    // Start setupObserver when the script starts
    setupObserver();
})();