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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();