FreshRSS Duplicate Filter

Mark as read and hide older articles in the FreshRSS feed list that have the same title, URL and content within a category or feed.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        FreshRSS Duplicate Filter
// @namespace   https://github.com/hiroki-miya
// @version     1.0.6
// @description Mark as read and hide older articles in the FreshRSS feed list that have the same title, URL and content within a category or feed.
// @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-end
// ==/UserScript==

(() => {
  'use strict';

  // -----------------------------
  // Small DOM helpers
  // -----------------------------
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));

  // -----------------------------
  // UI styles (ALL via GM_addStyle)
  // -----------------------------

  GM_addStyle(`
    #freshrss-duplicate-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;
      max-width: 92vw;
    }
    #freshrss-duplicate-filter > h2{
      box-shadow: inset 0 0 0 0.5px black;
      padding: 5px 10px;
      text-align: center;
      cursor: move;
      user-select: none;
      margin: 0 0 6px 0;
    }
    #freshrss-duplicate-filter > h4{
      margin-top: 0;
      margin-bottom: 6px;
    }
    #fdfs-categories{
      margin-bottom: 10px;
      max-height: 60vh;
      overflow-y: auto;
      padding-right: 8px;
    }
    #fdfs-categories label{ display: block; }
    #freshrss-duplicate-filter button{ margin-right: 6px; }
  `);

  // -----------------------------
  // Settings
  // -----------------------------
  const DEFAULT_SELECTED = [];
  const DEFAULT_LIMIT = 300;

  let selectedCategories = GM_getValue('selectedCategories', DEFAULT_SELECTED);
  let checkLimit = GM_getValue('checkLimit', DEFAULT_LIMIT);

  // -----------------------------
  // Sidebar titles (keep the existing sidebar order)
  // -----------------------------
  function listSidebarTitlesInOrder() {
    const sidebar = $('#sidebar');
    if (!sidebar) return [];

    const out = [];
    const seen = new Set();
    const push = (t) => {
      t = (t || '').trim();
      if (t && !seen.has(t)) {
        seen.add(t);
        out.push(t);
      }
    };

    const categories = $$('.category', sidebar);
    if (categories.length) {
      categories.forEach((cat) => {
        push(
          $(':scope > a span.title, :scope > a .title, :scope > a', cat)
            ?.textContent,
        );
        $$(
          ':scope ul li a span.title, :scope ul li a .title, :scope ul li a',
          cat,
        ).forEach((a) => push(a.textContent));
      });
      return out;
    }

    $$('a span.title, a .title, .title', sidebar).forEach((el) =>
      push(el.textContent),
    );
    return out;
  }

  function getActiveSidebarTitles() {
    const cat = $(
      '#sidebar .category.active > a span.title, #sidebar .category.active > a .title',
    )?.textContent?.trim();
    const feed = $(
      '#sidebar .category.active ul li.active > a span.title, #sidebar .category.active ul li.active > a .title',
    )?.textContent?.trim();
    const deep = $$(
      '#sidebar li.active a span.title, #sidebar li.active a .title',
    )
      .map((e) => e.textContent.trim())
      .filter(Boolean)
      .pop();

    return [cat, feed, deep].filter(Boolean);
  }

  function shouldRunHere() {
    if (!Array.isArray(selectedCategories) || selectedCategories.length === 0)
      return false;
    return getActiveSidebarTitles().some((t) => selectedCategories.includes(t));
  }

  // -----------------------------
  // Settings UI
  // -----------------------------
  const PANEL_ID = 'freshrss-duplicate-filter';
  const PANEL_POS_KEY = 'fdf_panel_pos';

  function showSettings() {
    // remove existing panel to avoid duplicates
    document.getElementById(PANEL_ID)?.remove();

    const titles = listSidebarTitlesInOrder();
    const selectedSet = new Set(selectedCategories || []);

    const panel = document.createElement('div');
    panel.id = PANEL_ID;

    const h2 = document.createElement('h2');
    h2.textContent = 'Duplicate Filter Settings';
    panel.appendChild(h2);

    const h4 = document.createElement('h4');
    h4.textContent = 'Select category or feed';
    panel.appendChild(h4);

    const list = document.createElement('div');
    list.id = 'fdfs-categories';

    titles.forEach((t) => {
      const label = document.createElement('label');

      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.value = t;
      cb.checked = selectedSet.has(t);

      label.appendChild(cb);
      label.appendChild(document.createTextNode(' ' + t));
      list.appendChild(label);
    });
    panel.appendChild(list);

    const limitLabel = document.createElement('label');
    limitLabel.textContent = 'Check Limit: ';

    const limitInput = document.createElement('input');
    limitInput.type = 'number';
    limitInput.id = 'checkLimit';
    limitInput.min = '1';
    limitInput.value = String(checkLimit);

    limitLabel.appendChild(limitInput);
    panel.appendChild(limitLabel);

    panel.appendChild(document.createElement('br'));

    const saveBtn = document.createElement('button');
    saveBtn.id = 'fdfs-save';
    saveBtn.textContent = 'Save';

    const closeBtn = document.createElement('button');
    closeBtn.id = 'fdfs-close';
    closeBtn.textContent = 'Close';

    panel.appendChild(saveBtn);
    panel.appendChild(closeBtn);

    document.body.appendChild(panel);

    // restore position if saved
    const pos = GM_getValue(PANEL_POS_KEY, null);
    if (pos && typeof pos.left === 'number' && typeof pos.top === 'number') {
      panel.style.left = pos.left + 'px';
      panel.style.top = pos.top + 'px';
      panel.style.transform = 'none';
    }

    makeDraggable(panel, h2, (left, top) =>
      GM_setValue(PANEL_POS_KEY, { left, top }),
    );

    saveBtn.addEventListener('click', () => {
      selectedCategories = Array.from(
        panel.querySelectorAll(
          '#fdfs-categories input[type="checkbox"]:checked',
        ),
      ).map((el) => el.value);
      checkLimit = Math.max(1, parseInt(limitInput.value, 10) || DEFAULT_LIMIT);

      GM_setValue('selectedCategories', selectedCategories);
      GM_setValue('checkLimit', checkLimit);

      panel.remove();
      scheduleRun(true);
    });

    closeBtn.addEventListener('click', () => panel.remove());
  }

  // Make element draggable (handle = header)
  function makeDraggable(elmnt, handle, onSave) {
    let startX = 0,
      startY = 0,
      startLeft = 0,
      startTop = 0;

    handle.addEventListener('mousedown', (e) => {
      e.preventDefault();

      const rect = elmnt.getBoundingClientRect();
      elmnt.style.left = rect.left + 'px';
      elmnt.style.top = rect.top + 'px';
      elmnt.style.transform = 'none';

      startX = e.clientX;
      startY = e.clientY;
      startLeft = rect.left;
      startTop = rect.top;

      const onMove = (ev) => {
        ev.preventDefault();
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;

        const maxLeft = Math.max(0, window.innerWidth - elmnt.offsetWidth);
        const maxTop = Math.max(0, window.innerHeight - elmnt.offsetHeight);

        const left = Math.min(maxLeft, Math.max(0, startLeft + dx));
        const top = Math.min(maxTop, Math.max(0, startTop + dy));

        elmnt.style.left = left + 'px';
        elmnt.style.top = top + 'px';
      };

      const onUp = () => {
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);

        const left = parseFloat(elmnt.style.left) || 0;
        const top = parseFloat(elmnt.style.top) || 0;
        if (typeof onSave === 'function') onSave(left, top);
      };

      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });
  }

  GM_registerMenuCommand('Settings', showSettings);

  // -----------------------------
  // Duplicate key: Title + URL
  // -----------------------------
  function normalizeText(s) {
    return (s || '')
      .replace(/\u200B|\u200C|\u200D|\uFEFF/g, '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function canonicalizeUrl(url) {
    if (!url) return '';
    try {
      const u = new URL(url, location.href);
      u.hash = '';

      // Remove common tracking params
      const drop = new Set([
        'utm_source',
        'utm_medium',
        'utm_campaign',
        'utm_term',
        'utm_content',
        'utm_id',
        'utm_name',
        'utm_reader',
        'ref',
        'source',
      ]);
      for (const k of Array.from(u.searchParams.keys())) {
        if (drop.has(k)) u.searchParams.delete(k);
      }

      // Normalize trailing slashes (except root)
      if (u.pathname.length > 1) u.pathname = u.pathname.replace(/\/+$/, '/');
      return u.toString();
    } catch {
      return url;
    }
  }

  function getTitleUrlKey(flux) {
    const a = $(
      'a.item-element.title, a.title, .item-title a, h1 a, h2 a, h3 a',
      flux,
    );
    const title = normalizeText(a?.textContent);
    const url = canonicalizeUrl(a?.href);
    return title && url ? `t:${title}||u:${url}` : '';
  }

  // -----------------------------
  // FreshRSS context/jsonVars (CSRF + URL templates)
  // -----------------------------
  function htmlDecode(s) {
    const t = document.createElement('textarea');
    t.innerHTML = s;
    return t.value;
  }

  function getFreshRssContext() {
    if (window.context && typeof window.context === 'object')
      return window.context;

    const el = $('#jsonVars');
    if (!el) return null;

    const raw = (el.textContent || '').trim();
    if (!raw) return null;

    const txt =
      raw.includes('"') || raw.includes('&#') ? htmlDecode(raw) : raw;
    try {
      return JSON.parse(txt);
    } catch {
      return null;
    }
  }

  function findCsrfToken(ctx) {
    const c = ctx && (ctx.csrf || ctx._csrf || ctx.csrf_token || ctx.token);
    if (typeof c === 'string' && c) return c;

    const meta = $(
      'meta[name="csrf-token"], meta[name="csrf"], meta[name="_csrf"]',
    );
    if (meta?.content) return meta.content;

    const inp = $(
      'input[name="_csrf"], input[name="csrf"], input[name="csrf_token"]',
    );
    if (inp?.value) return inp.value;

    return '';
  }

  function findReadUrlTemplate(ctx) {
    const found = [];
    const seen = new Set();

    const walk = (o) => {
      if (!o || typeof o !== 'object' || seen.has(o)) return;
      seen.add(o);

      for (const k of Object.keys(o)) {
        const v = o[k];
        if (typeof v === 'string') {
          if (/c=entry/i.test(v) && /a=read/i.test(v)) found.push(v);
        } else if (v && typeof v === 'object') {
          walk(v);
        }
      }
    };

    walk(ctx);
    return found.find((s) => /ajax=1/i.test(s)) || found[0] || '';
  }

  // -----------------------------
  // Extract entry id from a '.flux' node
  // -----------------------------
  function extractEntryId(flux) {
    // dataset often contains numeric ids
    const ds = flux.dataset || {};
    for (const k of [
      'entryId',
      'entry',
      'id',
      'fluxId',
      'id_entry',
      'idEntry',
    ]) {
      const v = ds[k];
      if (v && /^\d+$/.test(v)) return v;
    }

    // id attribute like 'flux_12345'
    if (flux.id) {
      const m = flux.id.match(/(\d+)/);
      if (m) return m[1];
    }

    // as a last resort, parse an href parameter
    const a = $('a[href*="id="], a[href*="entry="], a[href*="c=entry"]', flux);
    if (a?.href) {
      try {
        const u = new URL(a.href, location.href);
        const id =
          u.searchParams.get('id') ||
          u.searchParams.get('entry') ||
          u.searchParams.get('entry_id');
        if (id && /^\d+$/.test(id)) return id;
      } catch {
        /* ignore */
      }
    }

    return '';
  }

  // -----------------------------
  // Mark read (persist on server)
  // -----------------------------
  async function markReadPersist(flux) {
    const entryId = extractEntryId(flux);
    if (!entryId) return false;

    // If FreshRSS exposes helper, use it first.
    try {
      if (typeof window.mark_read === 'function') {
        window.mark_read(flux, true, true);
        return true;
      }
    } catch {
      /* ignore */
    }

    const ctx = getFreshRssContext();
    const csrf = findCsrfToken(ctx);

    // Build URL: prefer context-provided template, otherwise generic fallback.
    let url = '';
    const tpl = findReadUrlTemplate(ctx);

    if (tpl) {
      try {
        if (/\{id\}/i.test(tpl)) url = tpl.replace(/\{id\}/gi, entryId);
        else if (/%d/.test(tpl)) url = tpl.replace(/%d/g, entryId);
        else {
          const u = new URL(tpl, location.href);
          u.searchParams.set('id', entryId);
          u.searchParams.set('ajax', '1');
          url = u.toString();
        }
      } catch {
        url = '';
      }
    }

    if (!url) {
      const u = new URL(location.href);
      u.searchParams.set('c', 'entry');
      u.searchParams.set('a', 'read');
      u.searchParams.set('id', entryId);
      u.searchParams.set('ajax', '1');
      url = u.toString();
    }

    const headers = { 'X-Requested-With': 'XMLHttpRequest' };
    if (csrf) {
      headers['X-CSRF-Token'] = csrf;
      headers['X-CSRF-TOKEN'] = csrf;
    }

    const body = new URLSearchParams();
    if (csrf) body.set('_csrf', csrf);

    // POST preferred, then GET fallback
    try {
      const r = await fetch(url, {
        method: 'POST',
        credentials: 'same-origin',
        headers: {
          ...headers,
          'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        },
        body: body.toString(),
      });
      if (r.ok) return true;
    } catch {
      /* ignore */
    }

    try {
      const r2 = await fetch(url, {
        method: 'GET',
        credentials: 'same-origin',
      });
      return r2.ok;
    } catch {
      return false;
    }
  }

  function hideDuplicate(flux) {
    // Hide immediately, remove later after the read request has time to finish.
    flux.style.display = 'none';
    setTimeout(() => flux.remove(), 3000);
  }

  // -----------------------------
  // Main: scan current DOM order, keep newest unique
  // -----------------------------
  function runOnce() {
    if (!shouldRunHere()) return;

    const stream = $('#stream');
    if (!stream) return;

    const items = $$('.flux', stream);
    if (!items.length) return;

    const seen = new Set();
    items.slice(0, Math.max(1, checkLimit)).forEach((flux) => {
      const key = getTitleUrlKey(flux);
      if (!key) return;

      if (seen.has(key)) {
        markReadPersist(flux);
        hideDuplicate(flux);
      } else {
        seen.add(key);
      }
    });
  }

  // -----------------------------
  // Scheduling / observers
  // -----------------------------
  function debounce(fn, ms) {
    let t;
    return () => {
      clearTimeout(t);
      t = setTimeout(fn, ms);
    };
  }

  const debouncedRun = debounce(runOnce, 350);

  function scheduleRun(force = false) {
    if (!force && !shouldRunHere()) return;
    if ('requestIdleCallback' in window)
      requestIdleCallback(() => debouncedRun(), { timeout: 1500 });
    else debouncedRun();
  }

  function setup() {
    const stream = $('#stream');
    if (!stream) return void setTimeout(setup, 800);

    // Stream updates (infinite scroll / AJAX load)
    new MutationObserver((muts) => {
      const hasRelevantChanges = muts.some((m) => {
        if (!m.addedNodes?.length) return false;
        return Array.from(m.addedNodes).some((n) => {
          if (!(n instanceof HTMLElement)) return false;
          // Direct .flux element
          if (n.classList.contains('flux')) return true;
          // Container with .flux children
          if (n.querySelector?.('.flux')) return true;
          // Child of .flux (content added to flux items during infinite scroll)
          if (n.closest?.('.flux')) return true;
          return false;
        });
      });
      if (hasRelevantChanges) scheduleRun();
    }).observe(stream, { childList: true, subtree: true });

    // Sidebar navigation
    $('#sidebar')?.addEventListener(
      'click',
      (e) => {
        if (e.target?.closest?.('a')) setTimeout(() => scheduleRun(), 700);
      },
      true,
    );

    // Initial run after first paint
    setTimeout(() => scheduleRun(), 1200);
  }

  if (document.readyState === 'loading')
    document.addEventListener('DOMContentLoaded', setup, { once: true });
  else setup();
})();