// ==UserScript==
// @name Inoreader Feed Manager with Whitelist and Enhanced URL Filtering (Debug Version)
// @namespace https://greasyfork.org/en/users/567951-stuart-saddler
// @author Stuart Saddler
// @version 2.9
// @description Automatically removes posts from your Inoreader feed containing blocked keywords, using whole-word matching for single terms and exact matching for phrases. Also filters duplicates. Articles are processed smoothly without flashing.
// @license MIT
// @icon https://i.ibb.co/s5Hc8Rj/iFM-Icon.jpg
// @match https://*.inoreader.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addStyle
// @downloadURL https://update.greasyfork.org/scripts/522378/Inoreader%20Feed%20Manager%20with%20Whitelist%20and%20Enhanced%20URL%20Filtering%20%28Debug%20Version%29.user.js
// @updateURL https://update.greasyfork.org/scripts/522378/Inoreader%20Feed%20Manager%20with%20Whitelist%20and%20Enhanced%20URL%20Filtering%20%28Debug%20Version%29.meta.js
// ==/UserScript==
// Source: https://github.com/thepick/userscripts
(function () {
'use strict';
// Popup modal styling for configuration interfaces
GM_addStyle(`
.inoreader-popup-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999999;
display: flex;
justify-content: center;
align-items: center;
}
.inoreader-popup-dialog {
background: white;
color: #333;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 80vh;
overflow-y: auto;
width: 80%;
max-width: 600px;
position: relative;
font-family: Arial, sans-serif;
}
.inoreader-popup-dialog h3 {
margin-top: 0;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
color: #0079d3;
font-size: 1.5em;
font-weight: bold;
}
.inoreader-popup-dialog h4.inoreader-subheading {
color: #0079d3;
font-size: 1.2em;
margin-top: 1em;
margin-bottom: 0.5em;
}
.inoreader-popup-content {
margin-top: 20px;
font-size: 1em;
color: #555;
}
.inoreader-popup-buttons {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.inoreader-popup-buttons button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
font-family: Arial, sans-serif;
transition: opacity 0.2s ease-in-out;
}
.inoreader-popup-buttons .btn-cancel {
background: #f2f2f2;
color: #333;
}
.inoreader-popup-buttons .btn-save,
.inoreader-popup-buttons .btn-ok {
background: #0079d3;
color: white;
}
.inoreader-popup-buttons button:hover {
opacity: 0.9;
}
.inoreader-popup-dialog textarea {
width: 100%;
height: 150px;
margin-top: 10px;
padding: 8px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
background: #f9f9f9;
color: #000;
margin-bottom: 1.5em;
}
.inoreader-popup-dialog p {
font-size: 0.9em;
margin-bottom: 10px;
color: #000;
}
.inoreader-popup-dialog,
.inoreader-popup-dialog a,
.inoreader-popup-dialog li {
color: #000 !important;
}
.filtered-article-link {
color: #0079d3;
text-decoration: none;
}
.filtered-article-link:hover {
text-decoration: underline;
}
.url-keyword-hint {
color: #d63384;
font-size: 0.85em;
}
`);
// Responsive column layout for magazine view articles
GM_addStyle(`
.view_style_4 .article-container,
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) .ar.article_magazine {
width: calc(25% - 20px) !important;
max-width: none !important;
margin: 10px;
box-sizing: border-box;
}
@media only screen and (max-width: 1200px) {
.view_style_4 .article-container,
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) .ar.article_magazine {
width: calc(33.33% - 20px) !important;
}
}
@media only screen and (max-width: 900px) {
.view_style_4 .article-container,
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) .ar.article_magazine {
width: calc(50% - 20px) !important;
}
}
@media only screen and (max-width: 600px) {
.view_style_4 .article-container,
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) .ar.article_magazine {
width: 100% !important;
margin: 10px 0 !important;
}
}
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) {
display: flex !important;
flex-wrap: wrap !important;
justify-content: space-between;
}
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) .ar.article_magazine img,
.view_style_4 #reader_pane:not(#reader_pane_articles_search_result) .ar.article_magazine .article_magazine_title_link {
max-width: 100% !important;
height: auto !important;
}
h5.articles_group_feed.articles_group_date {
width: 100%;
display: flex;
justify-content: center;
margin: 1em 0;
}
h5.articles_group_feed.articles_group_date > span {
text-align: center;
display: inline-block;
}
`);
// Configuration variables and state management
const DEBUG = false; // Set to true to enable detailed debugging output
let blocklist = GM_getValue('blocklist') ? JSON.parse(GM_getValue('blocklist')) : [];
let whitelist = GM_getValue('whitelist') ? JSON.parse(GM_getValue('whitelist')) : [];
let blockedArticles = GM_getValue('blockedArticles') ? JSON.parse(GM_getValue('blockedArticles')) : [];
let sessionBlockedArticles = [];
let blocklistCount = 0;
let sensitivity = GM_getValue('sensitivity', 'Medium');
let duplicateCount = 0;
let seenArticles = GM_getValue('seenArticles') ? JSON.parse(GM_getValue('seenArticles')) : [];
let filteredOutArticles = [];
const menuCommandIds = {
blocklistConfig: null,
whitelistConfig: null,
duplicates: null,
sensitivityHigh: null,
sensitivityMedium: null,
sensitivityLow: null,
};
// Sensitivity levels control how duplicates are detected
const sensitivityLevels = {
Low: { overlapThreshold: 0.80, minOverlapCount: 4 },
Medium: { overlapThreshold: 0.60, minOverlapCount: 3 },
High: { overlapThreshold: 0.30, minOverlapCount: 2 },
};
// Common filler words to ignore when detecting duplicates
const GENERIC_NOUNS = new Set([
'report', 'story', 'investigation', 'evidence', 'review', 'update',
'announcement', 'research', 'analysis', 'comments', 'authority',
'information', 'details', 'charges', 'expert',
'discover', 'release', 'findings',
'coverage', 'remarks', 'significance', 'context',
'issue', 'situation', 'matter', 'topic', 'concern',
'consideration', 'aspect', 'focus', 'subject', 'theme',
'incident', 'injuries', 'attack', 'scene', 'crash',
'storm', 'parade', 'festival', 'event', 'flashback',
'memories', 'history'
]);
const GENERIC_VERBS = new Set([
'is', 'are', 'was', 'were', 'be', 'been', 'being',
'has', 'have', 'had', 'do', 'does', 'did',
'know', 'report', 'found', 'say', 'claims', 'investigate',
'release', 'reveal', 'announce', 'confirm', 'cover',
'discover', 'comment', 'dies', 'marks',
'announces', 'reports', 'finds', 'explores', 'addresses',
'examines', 'reviews', 'mentions', 'states', 'asserts',
'declares', 'proclaims', 'emphasizes', 'underscores',
'live', 'said', 'says', 'like', 'will', 'can', 'could', 'may', 'would',
'kills', 'crashes', 'celebrates', 'warns', 'declares',
'drives', 'found', 'announces', 'reports', 'expels',
'escalates', 'pounds', 'resumes', 'welcomes', 'bans'
]);
const GENERIC_ADJECTIVES = new Set([
'new', 'latest', 'big', 'significant', 'historical', 'similar',
'recent', 'major', 'other', 'exclusive', 'breaking',
'important', 'notable', 'remarkable', 'influential',
'pivotal', 'groundbreaking', 'prominent', 'critical',
'key', 'essential', 'integral', 'fundamental',
'deadly', 'chaotic', 'widespread', 'live', 'top',
'three-day', 'stormy', 'underrated'
]);
const GENERIC_ADVERBS = new Set([
'quickly', 'slowly', 'recently', 'already', 'immediately',
'suddenly', 'abruptly', 'eventually', 'ultimately',
'finally', 'gradually', 'smoothly', 'carefully',
'eagerly', 'readily', 'swiftly', 'promptly', 'thoroughly',
'very', 'just', 'only', 'reportedly', 'live', 'currently', 'now', 'still',
'officially'
]);
const EXTRA_COMMON = new Set([
'face', 'faces', 'about', 'after', 'all', 'also', 'and',
'any', 'as', 'at', 'but', 'by', "don't", 'even', 'every',
'for', 'from', 'he', 'her', 'him', 'his', 'how', 'if', 'in',
'into', 'it', 'its', 'more', 'no', 'not', 'now', 'on', 'one',
'or', 'our', 'out', 'over', 'she', 'so', 'some', 'than',
'their', 'them', 'then', 'there', 'they', 'through', 'to',
'under', 'up', 'us', 'was', 'we', 'what', 'when', 'where',
'which', 'while', 'who', 'why', 'with', 'within', 'you', 'your',
'at least', 'across', 'after', 'among', 'before', 'during',
'every', 'less', 'most', 'per', 'several', 'until',
'near', 'latest'
]);
const FILLER_WORDS = new Set([
...GENERIC_NOUNS,
...GENERIC_VERBS,
...GENERIC_ADJECTIVES,
...GENERIC_ADVERBS,
...EXTRA_COMMON,
]);
// Create and show popup modal for user interfaces
function createPopup(title, content, buttons) {
if (DEBUG) console.log(`[createPopup] Creating popup: ${title}`);
const overlay = document.createElement('div');
overlay.className = 'inoreader-popup-overlay';
const popup = document.createElement('div');
popup.className = 'inoreader-popup-dialog';
const h3 = document.createElement('h3');
h3.textContent = title;
const contentEl = document.createElement('div');
contentEl.className = 'inoreader-popup-content';
contentEl.appendChild(content);
const buttonContainer = document.createElement('div');
buttonContainer.className = 'inoreader-popup-buttons';
buttons.forEach(btn => {
const b = document.createElement('button');
b.textContent = btn.text;
if (btn.text.toLowerCase() === 'cancel') {
b.classList.add('btn-cancel');
} else if (btn.text.toLowerCase() === 'save' || btn.text.toLowerCase() === 'ok') {
b.classList.add(btn.text.toLowerCase() === 'save' ? 'btn-save' : 'btn-ok');
}
b.addEventListener('click', () => btn.onClick(overlay));
buttonContainer.appendChild(b);
});
popup.appendChild(h3);
popup.appendChild(contentEl);
popup.appendChild(buttonContainer);
overlay.appendChild(popup);
document.body.appendChild(overlay);
// Close if user clicks outside the popup dialog box
overlay.addEventListener('click', (evt) => {
if (evt.target === overlay) {
overlay.remove();
}
});
}
// Show confirmation popup with OK/Cancel options that triggers page reload
function showConfirmationPopup(message) {
if (DEBUG) console.log(`[showConfirmationPopup] ${message}`);
const msg = document.createElement('p');
msg.textContent = message;
const buttons = [
{
text: 'Cancel',
onClick: (ov) => ov.remove()
},
{
text: 'OK',
onClick: (ov) => {
ov.remove();
window.location.reload();
}
}
];
createPopup('Confirmation', msg, buttons);
}
// Escape string for safe use in RegExp construction
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// [NEW] Creates a regex for a term, using word boundaries for single words
// and exact matching for phrases.
function termToRegex(term) {
if (!term || typeof term !== 'string' || term.trim() === '') return null;
try {
const esc = escapeRegExp(term);
const containsSpace = /\s/.test(term);
if (containsSpace) {
// For phrases, match the exact phrase
return new RegExp(esc, 'i');
} else {
// For single words, enforce whole word matching
return new RegExp(`\\b${esc}\\b`, 'i');
}
} catch (e) {
if (DEBUG) console.error(`[Inoreader Feed Manager] Failed to create regex for term: "${term}"`, e);
return null;
}
}
// Highlight blocklist words in text string with yellow background for UI display
function highlightWords(text, words) {
if (!words || words.length === 0) return text;
let safeText = text;
words.forEach(word => {
const regex = termToRegex(word); // Use the new robust regex for highlighting
if(regex) {
safeText = safeText.replace(regex, `$&`);
}
});
return safeText;
}
// Extract article URL from article node using multiple possible selectors
function getArticleUrl(articleNode) {
const linkEl = articleNode.querySelector('a.article_title_link, a.article_magazine_title_link, a.article_title, h2.article_title a, h3.article_title a');
return linkEl ? linkEl.href : '';
}
// [REVISED] Find blocklist keywords in text, respecting whitelist overrides.
// Now uses the robust termToRegex function for both lists.
function getMatchedBlocklistWords(text) {
if (!text) return [];
const lower = text.toLowerCase();
// First, check if the entire text is whitelisted by any phrase.
const isTextWhitelisted = whitelist.some(phrase => {
const regex = termToRegex(phrase);
const found = regex && regex.test(lower);
if (DEBUG && found) {
console.log(`[getMatchedBlocklistWords] Found whitelist override phrase "${phrase}" in text`);
}
return found;
});
if (isTextWhitelisted) {
return []; // Whitelisted, so no blocklist words can match.
}
// If not whitelisted, find all matching blocklist terms.
const matched = [];
blocklist.forEach(term => {
const regex = termToRegex(term);
if (regex && regex.test(lower)) {
if (DEBUG) {
console.log(`[getMatchedBlocklistWords] Term "${term}" matched, not overridden`);
}
matched.push(term);
}
});
return matched;
}
// Show blocklist configuration interface with current session blocked articles
function showBlocklistConfigUI() {
if (DEBUG) console.log(`[showBlocklistConfigUI] invoked`);
const titleText = `Configure Blocklist (Total filtered: ${blocklistCount})`;
const blocklistInstructions = document.createElement('p');
blocklistInstructions.textContent = 'Enter keywords or phrases to block, one per line (case-insensitive). Matches whole words or exact phrases in titles, descriptions, or URLs.';
const blocklistTxt = document.createElement('textarea');
blocklistTxt.value = blocklist.join('\n');
const allowedInstructions = document.createElement('p');
allowedInstructions.textContent = 'Enter phrases where blocklisted words are allowed (whitelist), one per line (case-insensitive).';
const allowedTxt = document.createElement('textarea');
allowedTxt.value = whitelist.join('\n');
const middleButtonContainer = document.createElement('div');
middleButtonContainer.className = 'inoreader-popup-buttons';
const cancelMiddleBtn = document.createElement('button');
cancelMiddleBtn.textContent = 'Cancel';
cancelMiddleBtn.classList.add('btn-cancel');
cancelMiddleBtn.onclick = () => document.querySelector('.inoreader-popup-overlay').remove();
const saveMiddleBtn = document.createElement('button');
saveMiddleBtn.textContent = 'Save';
saveMiddleBtn.classList.add('btn-save');
saveMiddleBtn.onclick = () => {
const newBlocklist = blocklistTxt.value
.split('\n')
.map(t => t.trim())
.filter(t => t.length > 0);
const newWhitelist = allowedTxt.value
.split('\n')
.map(t => t.trim())
.filter(t => t.length > 0);
blocklist = newBlocklist;
whitelist = newWhitelist;
GM_setValue('blocklist', JSON.stringify(blocklist));
GM_setValue('whitelist', JSON.stringify(whitelist));
sessionBlockedArticles = [];
blocklistCount = 0;
updateMenuLabels();
document.querySelector('.inoreader-popup-overlay').remove();
showConfirmationPopup('Blocklist and Whitelist updated. Reload now?');
};
middleButtonContainer.appendChild(cancelMiddleBtn);
middleButtonContainer.appendChild(saveMiddleBtn);
const blockedArticlesTitle = document.createElement('h4');
blockedArticlesTitle.className = 'inoreader-subheading';
blockedArticlesTitle.textContent = 'Blocked Articles (Current Session):';
const blockedArticlesDiv = document.createElement('div');
if (sessionBlockedArticles.length === 0) {
const p = document.createElement('p');
p.textContent = 'No articles have been blocked this session.';
blockedArticlesDiv.appendChild(p);
} else {
const ul = document.createElement('ul');
sessionBlockedArticles.forEach(art => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = art.url;
link.target = '_blank';
let titleHtml = highlightWords(art.title, art.matchedTitleKeywords);
if (
art.matchedTitleKeywords.length === 0 &&
art.matchedDescriptionKeywords.length > 0
) {
titleHtml += ` (Description)`;
}
link.innerHTML = titleHtml;
link.className = 'filtered-article-link';
li.appendChild(link);
if (art.matchedDescriptionKeywords && art.matchedDescriptionKeywords.length > 0) {
const descContainer = document.createElement('div');
descContainer.style.fontSize = '0.85em';
descContainer.style.marginTop = '5px';
descContainer.textContent = `*Desc Keywords: ${art.matchedDescriptionKeywords.join(', ')}`;
li.appendChild(descContainer);
}
if (art.matchedUrlKeywords && art.matchedUrlKeywords.length > 0) {
const urlContainer = document.createElement('div');
urlContainer.style.fontSize = '0.85em';
urlContainer.style.marginTop = '5px';
urlContainer.style.color = '#d63384';
urlContainer.textContent = `*URL Keywords: ${art.matchedUrlKeywords.join(', ')}`;
li.appendChild(urlContainer);
}
ul.appendChild(li);
});
blockedArticlesDiv.appendChild(ul);
}
const content = document.createElement('div');
content.appendChild(blocklistInstructions);
content.appendChild(blocklistTxt);
content.appendChild(allowedInstructions);
content.appendChild(allowedTxt);
content.appendChild(middleButtonContainer);
content.appendChild(blockedArticlesTitle);
content.appendChild(blockedArticlesDiv);
const buttons = [
{
text: 'Cancel',
onClick: (ov) => ov.remove()
},
{
text: 'Save',
onClick: (ov) => {
const newBlocklist = blocklistTxt.value
.split('\n')
.map(t => t.trim())
.filter(t => t.length > 0);
const newWhitelist = allowedTxt.value
.split('\n')
.map(t => t.trim())
.filter(t => t.length > 0);
blocklist = newBlocklist;
whitelist = newWhitelist;
GM_setValue('blocklist', JSON.stringify(blocklist));
GM_setValue('whitelist', JSON.stringify(whitelist));
if (DEBUG) console.log('Blocklist updated:', blocklist);
if (DEBUG) console.log('Whitelist updated:', whitelist);
sessionBlockedArticles = [];
blocklistCount = 0;
updateMenuLabels();
ov.remove();
showConfirmationPopup('Blocklist and Whitelist updated. Reload now?');
}
}
];
const finalContent = document.createElement('div');
finalContent.appendChild(content);
createPopup(titleText, finalContent, buttons);
}
// Show whitelist configuration interface
function showWhitelistConfigUI() {
if (DEBUG) console.log(`[showWhitelistConfigUI] invoked`);
const titleText = `Configure Whitelist`;
const instructions = document.createElement('p');
instructions.textContent = 'Enter phrases where blocklisted words are allowed, one per line (case-insensitive).';
const txt = document.createElement('textarea');
txt.value = whitelist.join('\n');
const content = document.createElement('div');
content.appendChild(instructions);
content.appendChild(txt);
const buttons = [
{
text: 'Cancel',
onClick: (ov) => ov.remove()
},
{
text: 'Save',
onClick: (ov) => {
const newList = txt.value
.split('\n')
.map(t => t.trim())
.filter(t => t.length > 0);
whitelist = newList;
GM_setValue('whitelist', JSON.stringify(whitelist));
if (DEBUG) console.log('Whitelist updated:', whitelist);
ov.remove();
showConfirmationPopup('Whitelist updated. Reload now?');
}
}
];
const finalContent = document.createElement('div');
finalContent.appendChild(content);
createPopup(titleText, finalContent, buttons);
}
// Simple Porter stemmer algorithm to normalize words for duplicate detection
function porterStemmer(word) {
if (word.length < 3) return word;
let w = word;
if (w.endsWith('sses')) {
w = w.slice(0, -2);
} else if (w.endsWith('ies')) {
w = w.slice(0, -2);
} else if (w.endsWith('ss')) {
} else if (w.endsWith('s')) {
w = w.slice(0, -1);
}
if (w.endsWith('ed')) {
const base = w.slice(0, -2);
if (base.match(/[aeiouy]/)) {
w = base;
}
} else if (w.endsWith('ing')) {
const base = w.slice(0, -3);
if (base.match(/[aeiouy]/)) {
w = base;
}
}
if (w.endsWith('ational')) {
w = w.replace('ational', 'ate');
} else if (w.endsWith('tional')) {
w = w.replace('tional', 'tion');
} else if (w.endsWith('enci')) {
w = w.replace('enci', 'ence');
} else if (w.endsWith('anci')) {
w = w.replace('anci', 'ance');
} else if (w.endsWith('izer')) {
w = w.replace('izer', 'ize');
}
if (w.endsWith('ment')) {
w = w.slice(0, -4);
} else if (w.endsWith('ness')) {
w = w.slice(0, -4);
}
if (w.endsWith('ous')) {
w = w.slice(0, -3);
}
if (w.endsWith('ative')) w = w.replace('ative', '');
if (w.endsWith('ful')) w = w.replace('ful', '');
return w;
}
// Extract keyword stems from titles for duplicate checking, ignoring filler words
function extractKeyPhrasesForDuplicates(str) {
let cleaned = str.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
const words = cleaned.split(' ').filter(Boolean);
const stems = new Set();
for (let w of words) {
if (/^\d+$/.test(w)) continue;
if (FILLER_WORDS.has(w)) continue;
const stem = porterStemmer(w);
if (stem.length < 2) continue;
stems.add(stem);
}
return stems;
}
// Check if the given stem set matches any previously seen article as a duplicate
function isDuplicate(stemSet) {
const { overlapThreshold, minOverlapCount } = sensitivityLevels[sensitivity];
for (const item of seenArticles) {
const intersection = new Set([...stemSet].filter(k => item.keywords.has(k)));
const ratio = intersection.size / Math.min(stemSet.size, item.keywords.size);
if (ratio >= overlapThreshold && intersection.size >= minOverlapCount) {
return true;
}
}
return false;
}
// [REVISED] Main article processing function: now uses unified filtering logic.
function removeDuplicates() {
if (DEBUG) console.log('removeDuplicates() called');
const articleNodes = document.querySelectorAll('div.ar');
let localRemoved = 0;
for (const articleNode of articleNodes) {
if (articleNode.dataset.processed === 'true') continue;
// Immediately hide article while processing to prevent flashing
if (!articleNode.dataset.hidden) {
articleNode.style.visibility = 'hidden';
articleNode.dataset.hidden = 'true';
}
articleNode.dataset.processed = 'true';
const titleNode = articleNode.querySelector(
'a.article_title_link, a.article_magazine_title_link, a.article_title, h2.article_title, h3.article_title, [class*="article_title"]'
);
if (!titleNode) {
articleNode.style.visibility = 'visible'; // Show if no title found
continue;
}
const title = titleNode.textContent.trim();
const articleUrl = getArticleUrl(articleNode);
const descNode = articleNode.querySelector(
'.article_magazine_content, .article_tile_content, .article_content, .snippet, [class*="content"]'
);
const desc = descNode ? descNode.textContent : '';
// Unified filtering for URL, Title, and Description
const matchedUrlWords = getMatchedBlocklistWords(articleUrl);
const matchedTitleWords = getMatchedBlocklistWords(title);
const matchedDescWords = getMatchedBlocklistWords(desc);
if (matchedUrlWords.length > 0 || matchedTitleWords.length > 0 || matchedDescWords.length > 0) {
if (DEBUG) {
console.log(`[Blocklist] Removing article "${title}" due to keywords. URL: [${matchedUrlWords.join(', ')}], Title: [${matchedTitleWords.join(', ')}], Desc: [${matchedDescWords.join(', ')}]`);
}
const blockedArticleData = {
title: title,
url: articleUrl || titleNode.href,
matchedUrlKeywords: matchedUrlWords,
matchedTitleKeywords: matchedTitleWords,
matchedDescriptionKeywords: matchedDescWords,
};
blockedArticles.push(blockedArticleData);
GM_setValue('blockedArticles', JSON.stringify(blockedArticles));
sessionBlockedArticles.push(blockedArticleData);
articleNode.remove();
blocklistCount++;
updateMenuLabels();
continue;
}
// Duplicate detection: Remove articles considered duplicates based on stem matching
const titleStems = extractKeyPhrasesForDuplicates(title);
if (isDuplicate(titleStems)) {
if (DEBUG) console.log(`Duplicate found and removed: "${title}"`);
filteredOutArticles.push({ title: title, url: articleUrl || titleNode.href });
articleNode.remove();
localRemoved++;
} else {
// Article passed all filters - show it and record for future duplicate checking
articleNode.style.visibility = 'visible';
seenArticles.push({ keywords: titleStems });
GM_setValue('seenArticles', JSON.stringify(seenArticles));
if (seenArticles.length > 1000) {
seenArticles.shift();
GM_setValue('seenArticles', JSON.stringify(seenArticles));
}
}
}
if (localRemoved > 0) {
duplicateCount += localRemoved;
updateMenuLabels();
if (DEBUG) console.log(`[removeDuplicates] Removed ${localRemoved} duplicates, total duplicate removals: ${duplicateCount}`);
}
}
// Show list of duplicate articles removed in current session
function showFilteredArticles() {
const titleText = `Duplicates (Total removed: ${duplicateCount})`;
if (filteredOutArticles.length === 0) {
const msg = document.createElement('p');
msg.textContent = 'No articles have been removed as duplicates this session.';
const btns = [{ text: 'OK', onClick: ov => ov.remove() }];
createPopup(titleText, msg, btns);
return;
}
const cont = document.createElement('div');
const ul = document.createElement('ul');
filteredOutArticles.forEach(art => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = art.url;
link.target = '_blank';
link.textContent = art.title;
link.className = 'filtered-article-link';
li.appendChild(link);
ul.appendChild(li);
});
cont.appendChild(ul);
const btns = [{ text: 'OK', onClick: ov => ov.remove() }];
createPopup(titleText, cont, btns);
}
// Show list of blocked articles in current session with matched keywords highlighted
function showBlockedArticles() {
const titleText = `Blocked Articles (Total filtered: ${blocklistCount})`;
if (sessionBlockedArticles.length === 0) {
const msg = document.createElement('p');
msg.textContent = 'No articles have been blocked this session.';
const btns = [{ text: 'OK', onClick: ov => ov.remove() }];
createPopup(titleText, msg, btns);
return;
}
const cont = document.createElement('div');
const ul = document.createElement('ul');
sessionBlockedArticles.forEach(art => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = art.url;
link.target = '_blank';
let titleHtml = highlightWords(art.title, art.matchedTitleKeywords);
if (
art.matchedTitleKeywords.length === 0 &&
art.matchedDescriptionKeywords.length > 0
) {
titleHtml += ` (Description)`;
}
link.innerHTML = titleHtml;
link.className = 'filtered-article-link';
li.appendChild(link);
if (art.matchedDescriptionKeywords && art.matchedDescriptionKeywords.length > 0) {
const descContainer = document.createElement('div');
descContainer.style.fontSize = '0.85em';
descContainer.style.marginTop = '5px';
descContainer.textContent = `*Desc Keywords: ${art.matchedDescriptionKeywords.join(', ')}`;
li.appendChild(descContainer);
}
if (art.matchedUrlKeywords && art.matchedUrlKeywords.length > 0) {
const urlContainer = document.createElement('div');
urlContainer.style.fontSize = '0.85em';
urlContainer.style.marginTop = '5px';
urlContainer.style.color = '#d63384';
urlContainer.textContent = `*URL Keywords: ${art.matchedUrlKeywords.join(', ')}`;
li.appendChild(urlContainer);
}
ul.appendChild(li);
});
cont.appendChild(ul);
const btns = [{ text: 'OK', onClick: ov => ov.remove() }];
createPopup(titleText, cont, btns);
}
// Update userscript menu commands with current filtering counts and sensitivity options
function updateMenuLabels() {
Object.values(menuCommandIds).forEach(cmdId => {
if (cmdId !== null) {
GM_unregisterMenuCommand(cmdId);
}
});
menuCommandIds.whitelistConfig = GM_registerMenuCommand(
`Whitelist`,
showWhitelistConfigUI
);
menuCommandIds.blocklistConfig = GM_registerMenuCommand(
`Blocklist (Total filtered: ${blocklistCount})`,
showBlocklistConfigUI
);
menuCommandIds.duplicates = GM_registerMenuCommand(
`Duplicates (Total removed: ${duplicateCount})`,
showFilteredArticles
);
menuCommandIds.sensitivityHigh = GM_registerMenuCommand(
`Duplicate Sensitivity: High${sensitivity === 'High' ? ' ✔️' : ''}`,
() => setSensitivity('High')
);
menuCommandIds.sensitivityMedium = GM_registerMenuCommand(
`Duplicate Sensitivity: Medium${sensitivity === 'Medium' ? ' ✔️' : ''}`,
() => setSensitivity('Medium')
);
menuCommandIds.sensitivityLow = GM_registerMenuCommand(
`Duplicate Sensitivity: Low${sensitivity === 'Low' ? ' ✔️' : ''}`,
() => setSensitivity('Low')
);
}
// Set duplicate detection sensitivity level and save to persistent storage
function setSensitivity(level) {
if (!sensitivityLevels[level]) return;
sensitivity = level;
GM_setValue('sensitivity', sensitivity);
showConfirmationPopup(`Duplicate detection sensitivity set to ${level}. Reload now?`);
}
// Initialize persistent data from storage for runtime use
function initData() {
seenArticles = seenArticles.map(item => ({
keywords: new Set(Array.isArray(item.keywords) ? item.keywords : [])
}));
GM_setValue('seenArticles', JSON.stringify(seenArticles));
}
// Initialize script: setup data, menus, observers, and start article filtering
function init() {
if (DEBUG) console.log('Initializing Inoreader Feed Manager...');
initData();
updateMenuLabels();
// Ensure blocked articles have proper keyword fields for UI display
blockedArticles.forEach(art => {
if (!art.matchedTitleKeywords) art.matchedTitleKeywords = [];
if (!art.matchedDescriptionKeywords) art.matchedDescriptionKeywords = [];
if (!art.matchedUrlKeywords) art.matchedUrlKeywords = [];
});
GM_setValue('blockedArticles', JSON.stringify(blockedArticles));
let debounceTimer = null;
const DEBOUNCE_DELAY = 50; // Reduced delay for faster response and smoother experience
// Observe DOM for feed changes and re-run filtering as needed
const observer = new MutationObserver(() => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(removeDuplicates, DEBOUNCE_DELAY);
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial filtering pass on page load
removeDuplicates();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();