FreshRSS Keyword Highlight

Highlight articles in FreshRSS that match the rule. Rules are described by regular expressions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        FreshRSS Keyword Highlight
// @namespace   https://github.com/hiroki-miya
// @version     1.0.3
// @description Highlight articles in FreshRSS that match the rule. 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';

    // Highlight Color
    const highlightColor = '#ffff60';

    // Retrieve saved highlights
    let savedHighlights = GM_getValue('highlights', {});

    // Define editingHighlightName globally (the name of the highlight currently being edited)
    let editingHighlightName = null;

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

    // Function to render the highlight list
    function updateHighlightList() {
        // Sort highlights
        const highlightNames = Object.keys(savedHighlights).sort((a, b) => a.localeCompare(b, sortLocale));
        const highlightList = highlightNames.map(name => {
            return `
                <div class="highlight-item">
                    <div class="highlight-name">${name}</div>
                    <button class="edit-highlight" data-name="${name}">Edit</button>
                    <button class="delete-highlight" data-name="${name}">Delete</button>
                </div>
            `;
        }).join('');

        // Render the highlight list
        document.getElementById('highlight-list').innerHTML = highlightList || 'No registered highlight rules';

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

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

                editingHighlightName = highlightName;

                // Update the form heading for editing
                document.querySelector('#highlight-edit-title').innerText = 'Edit Existing Highlight Rule';
                document.querySelector('#fkh-save').innerText = 'Update';
            });
        });

        // Re-register the highlight delete button events
        Array.from(document.querySelectorAll('.delete-highlight')).forEach(button => {
            button.addEventListener('click', () => {
                const highlightName = button.getAttribute('data-name');
                delete savedHighlights[highlightName];
                GM_setValue('highlights', savedHighlights);
                updateHighlightList();
                applyAllHighlights();
            });
        });
    }

    // Display highlight settings
    function showSettings() {
        const settingsHTML = `
            <h2>Keyword Highlight Rule Settings</h2>
            <h4>Saved Highlight Rules</h4>
            <div id="highlight-list"></div>
            <br>
            <hr>
            <h4 id="highlight-edit-title">Create New Highlight Rule</h4>
            <div id="highlight-edit">
            <div><label>Highlight Name</label><input type="text" id="highlight-name"></div>
            <div><label>FreshRSS Feed List URL</label><input type="text" id="highlight-currentUrl"></div>
            <div><label>Title</label><input type="text" id="highlight-title"></div>
            <div><label>Content URL</label><input type="text" id="highlight-url"></div>
            <div><label class="highlight-info-label">Content<div title="article.flux_content.innerText" class="highlight-info">i</div></label><input type="text" id="highlight-content"></div>
            <div><label class="highlight-info-label">Text<div title="div.text.innerHTML" class="highlight-info">i</div></label><input type="text" id="highlight-text"></div>
            <div><label>Case insensitive?</label><div><input type="checkbox" id="highlight-case"></div></div>
            <br>
            </div>
            <button id="fkh-save">Save</button>
            <button id="fkh-clear">Clear</button>
            <button id="fkh-close">Close</button>
        `;

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

        // Initial render of saved highlight list
        updateHighlightList();

        // Make settikeywords panel draggable
        makeDraggable(settingsDiv);

        // Save or update button event
        document.getElementById('fkh-save').addEventListener('click', () => {
            const highlightName = document.getElementById('highlight-name').value;
            const highlightCurrentUrl = document.getElementById('highlight-currentUrl').value;
            const highlightTitle = document.getElementById('highlight-title').value;
            const highlightUrl = document.getElementById('highlight-url').value;
            const highlightContent = document.getElementById('highlight-content').value;
            const highlightText = document.getElementById('highlight-text').value;
            const caseInsensitive = document.getElementById('highlight-case').checked;

            if (!highlightName) {
                alert('Please enter a highlight name');
                return;
            }

            // Save or update the highlight
            savedHighlights[highlightName] = {
                currentUrl: highlightCurrentUrl,
                title: highlightTitle,
                url: highlightUrl,
                content: highlightContent,
                text: highlightText,
                caseInsensitive: caseInsensitive
            };

            // If the highlight name was changed during editing, delete the old highlight
            if (editingHighlightName && editingHighlightName !== highlightName) {
                delete savedHighlights[editingHighlightName];
            }

            GM_setValue('highlights', savedHighlights);

            showTooltip('Saved');

            initEdit();

            // Update highlight list
            updateHighlightList();

            // Apply highlights immediately after saving
            applyAllHighlights();
        });

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

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

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

        // Update the form heading for creating a new highlight
        document.querySelector('#highlight-edit-title').innerText = 'Create New Highlight Rule';
        document.querySelector('#fkh-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;
        }
    }

    // Apply all highlights automatically
    function applyAllHighlights() {
        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 matchesAnyHighlight = false;

            // Check all saved highlights
            for (let highlightName in savedHighlights) {
                const highlight = savedHighlights[highlightName];
                const regexFlags = highlight.caseInsensitive ? 'i' : '';
                const currentUrlMatch = !highlight.currentUrl || new RegExp(highlight.currentUrl, regexFlags).test(currentPageUrl);
                const titleMatch = !highlight.title || new RegExp(highlight.title, regexFlags).test(title);
                const urlMatch = !highlight.url || new RegExp(highlight.url, regexFlags).test(url);
                const contentMatch = !highlight.content || new RegExp(highlight.content, regexFlags).test(content);
                const textMatch = !highlight.text || new RegExp(highlight.text, regexFlags).test(text);

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

                // Check if all highlight conditions are met (AND condition)
                if (currentUrlMatch && titleMatch && urlMatch && contentMatch && textMatch) {
                    matchesAnyHighlight = true;
                    break;
                }
            }

            // Add ng class to articles matching the highlight
            if (matchesAnyHighlight) {
                article.classList.add('highlight');
                article.style.backgroundColor = highlightColor;
            } else {
                article.classList.remove('highlight');
                article.style.backgroundColor = null;
            }
        });
    }

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

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

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