// ==UserScript==
// @name Reddit Advanced Content Filter v2.0 (Stable+Features+ReplaceText+UI Colors Fixed)
// @namespace reddit-filter
// @version 2.0.7
// @description Filters Reddit content by keywords, regex, user, subreddit with target & normalization options. Includes text replacement for comments. Enhanced UI colors with select fix.
// @author YourName (Modified by Assistant)
// @match https://www.reddit.com/*
// @match https://old.reddit.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_log
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js
// @license MIT
// ==/UserScript==
/* global DOMPurify, GM_setValue, GM_getValue, GM_registerMenuCommand, GM_log, GM_info */
(function() {
'use strict';
// --- Constants ---
const SCRIPT_PREFIX = 'RACF';
const DEBOUNCE_DELAY_MS = 250;
const STATS_SAVE_DEBOUNCE_MS = 2000;
const CONFIG_STORAGE_KEY = 'config_v1.7'; // Mantener si la estructura principal no cambia drásticamente
const STATS_STORAGE_KEY = 'stats_v1';
const RULE_TYPES = ['keyword', 'user', 'subreddit'];
const FILTER_ACTIONS = ['hide', 'blur', 'border', 'collapse', 'replace_text'];
const DEBUG_LOGGING = false;
// --- Default Structures ---
const DEFAULT_CONFIG = {
rules: [],
filterTypes: ['posts', 'comments'], filterAction: 'hide',
whitelist: { subreddits: [], users: [] },
blacklist: { subreddits: [], users: [] },
uiVisible: true, activeTab: 'settings',
uiPosition: { top: '100px', left: null, right: '20px' }
};
const DEFAULT_STATS = {
totalProcessed: 0, totalFiltered: 0, totalWhitelisted: 0,
filteredByType: { posts: 0, comments: 0, messages: 0 },
filteredByRule: {},
filteredByAction: { hide: 0, blur: 0, border: 0, collapse: 0, replace_text: 0 }
};
if (!window.MutationObserver) { GM_log(`[${SCRIPT_PREFIX}] MutationObserver not supported.`); }
class RedditFilter {
constructor() {
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
this.processedNodes = new WeakSet();
this.selectors = {};
this.isOldReddit = false;
this.observer = null;
this.uiContainer = null;
this.shadowRoot = null;
this.scrollTimer = null;
this.lastFilterTime = 0;
this.filterApplyDebounceTimer = null;
this.statsSaveDebounceTimer = null;
this.uiUpdateDebounceTimer = null;
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.dragInitialLeft = 0;
this.dragInitialTop = 0;
this.domPurify = (typeof DOMPurify === 'undefined') ? { sanitize: (t) => t } : DOMPurify;
this.originalContentCache = new WeakMap();
}
log(message) {
GM_log(`[${SCRIPT_PREFIX}] ${message}`);
}
debugLog(message, ...args) {
if (DEBUG_LOGGING) {
console.log(`[${SCRIPT_PREFIX} DEBUG] ${message}`, ...args);
}
}
async init() {
this.log(`Initializing v${GM_info?.script?.version || '2.0.6'}...`);
try {
await this.loadConfig();
await this.loadStats();
this.detectRedditVersion();
this.injectUI();
this.updateUI();
this.registerMenuCommands();
this.initializeObserver();
this.addScrollListener();
setTimeout(() => this.applyFilters(document.body), 500);
this.log(`Initialization complete.`);
} catch (error) {
this.log(`Init failed: ${error.message}`);
console.error(`[${SCRIPT_PREFIX}] Init failed:`, error);
}
}
async loadConfig() {
try {
const sC = await GM_getValue(CONFIG_STORAGE_KEY, null);
if (sC) {
const pC = JSON.parse(sC);
this.config = {
...DEFAULT_CONFIG, ...pC,
whitelist: { ...DEFAULT_CONFIG.whitelist, ...(pC.whitelist || {}) },
blacklist: { ...DEFAULT_CONFIG.blacklist, ...(pC.blacklist || {}) },
uiPosition: { ...DEFAULT_CONFIG.uiPosition, ...(pC.uiPosition || {}) },
rules: Array.isArray(pC.rules) ? pC.rules : []
};
if (!FILTER_ACTIONS.includes(this.config.filterAction)) {
this.log(`Invalid filterAction '${this.config.filterAction}' found in config, defaulting to '${DEFAULT_CONFIG.filterAction}'.`);
this.config.filterAction = DEFAULT_CONFIG.filterAction;
}
this.log(`Config loaded.`);
} else {
this.log(`No saved config found. Using defaults.`);
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
}
} catch (e) {
this.log(`Failed to load config: ${e.message}. Using defaults.`);
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
await this.saveConfig();
}
}
async saveConfig() {
try {
if (!this.config) this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
if (!Array.isArray(this.config.rules)) this.config.rules = [];
if (!this.config.whitelist) this.config.whitelist = { subreddits: [], users: [] };
if (!this.config.blacklist) this.config.blacklist = { subreddits: [], users: [] };
if (!this.config.uiPosition) this.config.uiPosition = JSON.parse(JSON.stringify(DEFAULT_CONFIG.uiPosition));
if (!FILTER_ACTIONS.includes(this.config.filterAction)) this.config.filterAction = DEFAULT_CONFIG.filterAction;
await GM_setValue(CONFIG_STORAGE_KEY, JSON.stringify(this.config));
} catch (e) {
this.log(`Failed to save config: ${e.message}`);
console.error(`[${SCRIPT_PREFIX}] Failed save config:`, e);
}
}
async loadStats() {
try {
const sS = await GM_getValue(STATS_STORAGE_KEY, null);
if (sS) {
const pS = JSON.parse(sS);
const defaultActions = DEFAULT_STATS.filteredByAction;
const loadedActions = pS.filteredByAction || {};
const mergedActions = { ...defaultActions };
for (const action in loadedActions) {
if (FILTER_ACTIONS.includes(action)) {
mergedActions[action] = loadedActions[action];
}
}
this.stats = {
...DEFAULT_STATS, ...pS,
filteredByType: { ...DEFAULT_STATS.filteredByType, ...(pS.filteredByType || {}) },
filteredByRule: { ...DEFAULT_STATS.filteredByRule, ...(pS.filteredByRule || {}) },
filteredByAction: mergedActions
};
} else {
this.log(`No saved stats found. Using defaults.`);
this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
}
} catch (e) {
this.log(`Failed to load stats: ${e.message}. Resetting.`);
this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
await this.saveStats();
}
}
async saveStats() {
try {
await GM_setValue(STATS_STORAGE_KEY, JSON.stringify(this.stats));
} catch (e) {
this.log(`Failed to save stats: ${e.message}`);
}
}
debouncedSaveStats() {
if (this.statsSaveDebounceTimer) clearTimeout(this.statsSaveDebounceTimer);
this.statsSaveDebounceTimer = setTimeout(async () => {
await this.saveStats();
this.statsSaveDebounceTimer = null;
}, STATS_SAVE_DEBOUNCE_MS);
}
async resetStats() {
if (confirm("Reset all filter statistics? This cannot be undone.")) {
this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
await this.saveStats();
this.updateUI();
this.log(`Stats reset.`);
}
}
detectRedditVersion() {
const isOldDomain = window.location.hostname === 'old.reddit.com';
const hasOldBodyClass = document.body.classList.contains('listing-page') || document.body.classList.contains('comments-page');
if (isOldDomain || hasOldBodyClass) {
this.isOldReddit = true;
this.selectors = {
post: '.thing.link:not(.promoted)',
comment: '.thing.comment',
postSubredditSelector: '.tagline .subreddit',
postAuthorSelector: '.tagline .author',
commentAuthorSelector: '.tagline .author',
postTitleSelector: 'a.title',
postBodySelector: '.usertext-body .md, .expando .usertext-body .md',
commentBodySelector: '.usertext-body .md',
commentEntry: '.entry',
commentContentContainer: '.child'
};
this.log(`Old Reddit detected.`);
} else {
this.isOldReddit = false;
this.selectors = {
post: 'shreddit-post',
comment: 'shreddit-comment',
postSubredditSelector: '[slot="subreddit-name"]',
postAuthorSelector: '[slot="author-name"]',
commentAuthorSelector: '[slot="author-name"]',
postTitleSelector: '[slot="title"]',
postBodySelector: '#post-rtjson-content, [data-post-click-location="text-body"], [slot="text-body"]',
commentBodySelector: 'div[slot="comment"]',
commentEntry: ':host',
commentContentContainer: '[slot="comment"]'
};
this.log(`New Reddit detected.`);
}
this.selectors.message = '.message';
}
injectUI() {
if (this.uiContainer) return;
this.uiContainer = document.createElement('div');
this.uiContainer.id = `${SCRIPT_PREFIX}-ui-container`;
this.uiContainer.style.cssText = `position: fixed; z-index: 9999; top: ${this.config.uiPosition.top || '100px'}; ${this.config.uiPosition.left ? `left: ${this.config.uiPosition.left}; right: auto;` : `left: auto; right: ${this.config.uiPosition.right || '20px'};`}`;
this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
this.shadowRoot = this.uiContainer.attachShadow({ mode: 'open' });
const uiContent = document.createElement('div');
uiContent.innerHTML = `
<div class="racf-card">
<div class="racf-tabs" id="racf-drag-handle">
<button class="racf-tab-btn active" data-tab="settings">Settings</button>
<button class="racf-tab-btn" data-tab="stats">Statistics</button>
</div>
<button id="racf-close-btn" class="racf-close-btn" title="Close Panel">×</button>
<div id="racf-settings-content" class="racf-tab-content active">
<h4>Filter Settings</h4>
<div class="racf-add-rule-section">
<div class="racf-input-group">
<label for="racf-rule-input">Rule Text:</label>
<input type="text" id="racf-rule-input" placeholder="Keyword, /regex/, user, subreddit">
</div>
<div class="racf-input-group">
<label for="racf-rule-type">Rule Type:</label>
<select id="racf-rule-type">
<option value="keyword" selected>Keyword/Regex</option>
<option value="user">User</option>
<option value="subreddit">Subreddit</option>
</select>
</div>
<div class="racf-input-group">
<label for="racf-rule-target">Apply In:</label>
<select id="racf-rule-target">
<option value="both" selected>Title & Body</option>
<option value="title">Title Only</option>
<option value="body">Body Only</option>
</select>
</div>
<div class="racf-input-group racf-checkbox-group">
<label for="racf-rule-normalize">Normalize:</label>
<input type="checkbox" id="racf-rule-normalize" title="Ignore accents/case (Keywords only, not Regex)">
<small>(Keywords only)</small>
</div>
<button id="racf-add-rule-btn">Add Rule</button>
</div>
<div class="racf-section">
<label>Filter Types:</label>
<label><input type="checkbox" class="racf-filter-type" value="posts"> Posts</label>
<label><input type="checkbox" class="racf-filter-type" value="comments"> Comments</label>
</div>
<div class="racf-section">
<label for="racf-filter-action">Filter Action:</label>
<select id="racf-filter-action"></select>
</div>
<div class="racf-section">
<label>Active Rules (<span id="racf-rule-count">0</span>):</label>
<ul id="racf-rule-list"></ul>
</div>
<div class="racf-section">
<small>Global Whitelists/Blacklists are managed via JSON Import/Export.</small>
</div>
<div class="racf-section racf-buttons">
<button id="racf-import-btn">Import (.json)</button>
<button id="racf-export-btn">Export (.json)</button>
<input type="file" id="racf-import-file-input" accept=".json" style="display: none;">
</div>
<div class="racf-section racf-buttons">
<button id="racf-clear-processed-btn">Clear Processed Cache</button>
</div>
</div>
<div id="racf-stats-content" class="racf-tab-content">
<h4>Filter Statistics</h4>
<div class="racf-stats-grid">
<div>Total Processed:</div>
<div id="racf-stats-processed">0</div>
<div>Total Filtered:</div>
<div id="racf-stats-filtered">0</div>
<div>Filtering Rate:</div>
<div id="racf-stats-rate">0%</div>
<div>Total Whitelisted:</div>
<div id="racf-stats-whitelisted">0</div>
<div>Filtered Posts:</div>
<div id="racf-stats-type-posts">0</div>
<div>Filtered Comments:</div>
<div id="racf-stats-type-comments">0</div>
<div>Action - Hide:</div>
<div id="racf-stats-action-hide">0</div>
<div>Action - Blur:</div>
<div id="racf-stats-action-blur">0</div>
<div>Action - Border:</div>
<div id="racf-stats-action-border">0</div>
<div>Action - Collapse:</div>
<div id="racf-stats-action-collapse">0</div>
<div>Action - Replace Text:</div>
<div id="racf-stats-action-replace_text">0</div>
</div>
<div class="racf-section">
<label>Most Active Rules:</label>
<ul id="racf-stats-rule-list"><li>No rules active yet.</li></ul>
</div>
<div class="racf-section racf-buttons">
<button id="racf-reset-stats-btn">Reset Statistics</button>
</div>
</div>
</div>`;
const styles = document.createElement('style');
// --- CSS con el ARREGLO para el contraste de <select> ---
styles.textContent = `
:host { font-family: sans-serif; font-size: 14px; }
.racf-card { background-color: #f9f9f9; border: 1px solid #ccc; border-radius: 5px; padding: 0; box-shadow: 0 2px 5px rgba(0,0,0,.2); min-width: 380px; position: relative; color: #333; }
.racf-tabs { display: flex; border-bottom: 1px solid #ccc; cursor: move; user-select: none; }
/* Tab Button Styles */
.racf-tab-btn {
flex: 1; padding: 10px 15px; background: #e9ecef; /* Lighter gray */
border: none; border-right: 1px solid #dee2e6; /* Slightly darker border */
cursor: pointer; font-size: 14px; color: #495057; /* Darker gray text */
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
.racf-tab-btn:last-child { border-right: none; }
.racf-tab-btn:hover { background: #d3d9df; color: #212529; } /* Darker hover */
.racf-tab-btn.active {
background: #f9f9f9; /* Match card background */
color: #0056b3; /* Active color (dark blue) */
border-bottom: 1px solid #f9f9f9; /* Hide bottom edge */
border-top: 3px solid #007bff; /* Highlight with blue top border */
margin-bottom: -1px; /* Pull content up */
font-weight: 700;
}
.racf-tab-content { display: none; padding: 15px; }
.racf-tab-content.active { display: block; }
.racf-card h4 { margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; color: #0056b3; } /* Title color */
.racf-section { margin-bottom: 15px; }
.racf-section small { font-weight: normal; font-style: italic; color: #555; font-size: 0.9em; }
.racf-add-rule-section { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; align-items: flex-end; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
.racf-input-group { display: flex; flex-direction: column; gap: 3px; }
.racf-input-group label { font-size: .9em; font-weight: 700; color: #495057; } /* Label color */
.racf-checkbox-group { flex-direction: row; align-items: center; gap: 5px; margin-top: auto; }
.racf-checkbox-group label { margin-bottom: 0; }
.racf-checkbox-group small { margin-left: 0; }
/* === ARREGLO AQUÍ === */
input[type=text], select {
width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px;
box-sizing: border-box; font-size: 14px; background-color: #fff; /* Fondo blanco */
color: #212529; /* <<-- Añadido: Color de texto oscuro explícito para inputs y selects */
}
input[type=text]:focus, select:focus { border-color: #80bdff; outline: 0; box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); } /* Focus style */
.racf-section input[type=checkbox] { margin-right: 3px; vertical-align: middle; }
.racf-section label+label { margin-left: 10px; font-weight: 400; }
/* General Button Style (fallback/less important buttons) */
button {
padding: 8px 12px; border: 1px solid #adb5bd; /* Gray border */
background-color: #f8f9fa; /* Very light gray */
color: #212529; /* Dark text */
border-radius: 3px; cursor: pointer; font-size: 14px;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, color 0.15s ease-in-out;
}
button:hover {
background-color: #e9ecef; /* Slightly darker gray */
border-color: #a1a8af;
color: #212529;
}
button:active {
background-color: #dee2e6;
border-color: #939ba1;
}
/* Primary Action Button (Add Rule) */
#racf-add-rule-btn {
padding: 8px 15px; margin-top: 15px; grid-column: 1 / -1;
background-color: #007bff; /* Blue */
color: #fff; /* White text */
border-color: #007bff;
font-weight: bold;
}
#racf-add-rule-btn:hover { background-color: #0056b3; border-color: #0056b3; }
/* Secondary Action Buttons (Import/Export) */
#racf-import-btn, #racf-export-btn {
background-color: #28a745; /* Green */
color: #fff; /* White text */
border-color: #28a745;
}
#racf-import-btn:hover, #racf-export-btn:hover { background-color: #218838; border-color: #1e7e34; }
/* Neutral Action Button (Clear Cache) */
#racf-clear-processed-btn {
background-color: #6c757d; /* Gray */
color: #fff; /* White text */
border-color: #6c757d;
}
#racf-clear-processed-btn:hover { background-color: #5a6268; border-color: #545b62; }
/* Danger Action Buttons (Remove Rule, Reset Stats) */
#racf-reset-stats-btn {
background-color: #dc3545; /* Red */
color: #fff; /* White text */
border-color: #dc3545;
}
#racf-reset-stats-btn:hover { background-color: #c82333; border-color: #bd2130; }
/* Remove Rule Button (Specific styling + Danger color) */
#racf-rule-list button.racf-remove-btn {
background: #dc3545; /* Red */
border: 1px solid #dc3545;
color: #fff; /* White text */
padding: 3px 7px; /* Keep smaller padding */
font-size: 11px; /* Keep smaller font */
margin-left: 5px; flex-shrink: 0; line-height: 1;
}
#racf-rule-list button.racf-remove-btn:hover { background-color: #c82333; border-color: #bd2130; }
#racf-rule-list, #racf-stats-rule-list { list-style: none; padding: 0; max-height: 180px; overflow-y: auto; border: 1px solid #eee; margin-top: 5px; background: #fff; }
#racf-rule-list li, #racf-stats-rule-list li { padding: 6px 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; font-size: 12px; }
#racf-rule-list li:last-child, #racf-stats-rule-list li:last-child { border-bottom: none; }
#racf-rule-list .racf-rule-details { flex-grow: 1; margin-right: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
#racf-rule-list .racf-rule-type-badge { font-size: .8em; padding: 1px 4px; border-radius: 3px; background-color: #6c757d; color: #fff; flex-shrink: 0; text-transform: uppercase; } /* Badge color */
#racf-rule-list .racf-rule-text { word-break: break-all; font-family: monospace; background: #e9ecef; padding: 1px 3px; border-radius: 2px; color: #212529;} /* Rule text bg/color */
#racf-stats-rule-list .racf-rule-text { flex-grow: 1; margin-right: 10px; word-break: break-all; font-family: monospace; background: #e9ecef; padding: 1px 3px; border-radius: 2px; color: #212529;}
#racf-stats-rule-list .racf-rule-count { font-weight: 700; margin-left: 10px; flex-shrink: 0; background-color: #007bff; color: #fff; padding: 2px 5px; border-radius: 10px; font-size: 0.9em;} /* Count badge */
.racf-buttons { margin-top: 15px; border-top: 1px solid #eee; padding-top: 10px; display: flex; gap: 10px; flex-wrap: wrap; }
.racf-buttons button { flex-grow: 1; margin: 0; }
/* Close Button Style */
.racf-close-btn {
position: absolute; top: 5px; right: 10px; background: 0 0; border: none;
font-size: 24px; /* Slightly larger */
font-weight: 700; color: #6c757d; /* Darker gray */
cursor: pointer; z-index: 10; margin: 0 !important; padding: 0 5px; line-height: 1;
}
.racf-close-btn:hover { color: #343a40; /* Even darker */ }
.racf-stats-grid { display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-bottom: 15px; font-size: 13px; }
.racf-stats-grid div:nth-child(odd) { font-weight: 700; text-align: right; color: #495057; } /* Stats label color */
.racf-stats-grid div:nth-child(even) { font-family: monospace; color: #0056b3; } /* Stats value color */
`;
this.shadowRoot.appendChild(styles);
this.shadowRoot.appendChild(uiContent);
this.injectGlobalStyles();
document.body.insertAdjacentElement('beforeend', this.uiContainer);
this.addUIEventListeners();
this.log(`UI injected with select contrast fix.`);
}
injectGlobalStyles() {
const styleId = `${SCRIPT_PREFIX}-global-styles`;
let globalStyleSheet = document.getElementById(styleId);
if (!globalStyleSheet) {
globalStyleSheet = document.createElement("style");
globalStyleSheet.id = styleId;
document.head.appendChild(globalStyleSheet);
}
const commentEntrySelector = this.selectors.commentEntry || '.comment';
const commentContentContainerSelector = this.selectors.commentContentContainer || '.child';
const commentTaglineSelector = this.isOldReddit ? '.entry > .tagline' : 'header';
const commentFormSelector = this.isOldReddit ? '.entry > form' : 'shreddit-composer';
globalStyleSheet.textContent = `
.${SCRIPT_PREFIX}-hide { display: none !important; height: 0 !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; border: none !important; visibility: hidden !important; }
.${SCRIPT_PREFIX}-blur { filter: blur(5px) !important; transition: filter 0.2s ease; cursor: pointer; }
.${SCRIPT_PREFIX}-blur:hover { filter: none !important; }
.${SCRIPT_PREFIX}-border { outline: 3px solid red !important; outline-offset: -1px; }
.${SCRIPT_PREFIX}-collapse > ${commentContentContainerSelector},
.${SCRIPT_PREFIX}-collapse ${commentFormSelector} { display: none !important; }
.${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .child,
.${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .usertext { display: none !important; }
.${SCRIPT_PREFIX}-collapse > ${commentTaglineSelector} { opacity: 0.6 !important; }
.${SCRIPT_PREFIX}-collapse > ${commentTaglineSelector}::after,
.${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .tagline::after {
content: " [Filtered]"; font-style: italic; font-size: 0.9em; color: grey;
margin-left: 5px; display: inline; vertical-align: baseline;
}
.${SCRIPT_PREFIX}-hide.thing.comment,
.${SCRIPT_PREFIX}-hide.shreddit-comment { }
`;
}
addUIEventListeners() {
const q = (s) => this.shadowRoot.querySelector(s);
const qa = (s) => this.shadowRoot.querySelectorAll(s);
q('#racf-drag-handle')?.addEventListener('mousedown', this.dragMouseDown.bind(this));
qa('.racf-tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tabId = e.target.dataset.tab;
if (!tabId) return;
qa('.racf-tab-btn').forEach(b => b.classList.remove('active'));
qa('.racf-tab-content').forEach(c => c.classList.remove('active'));
e.target.classList.add('active');
q(`#racf-${tabId}-content`)?.classList.add('active');
this.config.activeTab = tabId;
if (tabId === 'stats') {
this.updateUI();
}
});
});
q('#racf-add-rule-btn').addEventListener('click', () => this.handleAddRule());
q('#racf-rule-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') this.handleAddRule(); });
q('#racf-rule-list').addEventListener('click', (e) => {
const removeButton = e.target.closest('button.racf-remove-btn');
if (removeButton) {
const ruleIndex = parseInt(removeButton.dataset.ruleIndex, 10);
if (!isNaN(ruleIndex)) {
this.removeRuleByIndex(ruleIndex);
} else {
const ruleType = removeButton.dataset.ruleType;
const ruleText = removeButton.dataset.ruleText;
const indexToRemove = (this.config.rules || []).findIndex(r => r.type === ruleType && r.text === ruleText);
if (indexToRemove > -1) {
this.removeRuleByIndex(indexToRemove);
} else {
this.log(`Could not find rule to remove: ${ruleType} - ${ruleText}`);
}
}
}
});
qa('.racf-filter-type').forEach(cb => {
cb.addEventListener('change', (e) => this.handleFilterTypeChange(e));
});
q('#racf-filter-action').addEventListener('change', (e) => {
const newAction = e.target.value;
if (FILTER_ACTIONS.includes(newAction)) {
this.config.filterAction = newAction;
this.saveConfigAndApplyFilters();
} else {
this.log(`Invalid filter action selected: ${newAction}`);
e.target.value = this.config.filterAction;
}
});
q('#racf-export-btn').addEventListener('click', () => this.exportConfig());
q('#racf-import-btn').addEventListener('click', () => { q('#racf-import-file-input')?.click(); });
q('#racf-import-file-input')?.addEventListener('change', (e) => this.importConfig(e));
q('#racf-clear-processed-btn').addEventListener('click', () => {
this.processedNodes = new WeakSet();
this.originalContentCache = new WeakMap();
this.log(`Processed node cache and original content cache cleared.`);
this.applyFilters(document.body);
});
q('#racf-reset-stats-btn').addEventListener('click', () => this.resetStats());
q('#racf-close-btn').addEventListener('click', () => this.toggleUIVisibility(false));
}
// --- Funciones de Dragging (sin cambios) ---
dragMouseDown(e) {
if(e.button !== 0 || e.target.closest('button')) return;
e.preventDefault();
this.isDragging = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
const r = this.uiContainer.getBoundingClientRect();
this.dragInitialLeft = r.left;
this.dragInitialTop = r.top;
this.elementDragBound = this.elementDrag.bind(this);
this.closeDragElementBound = this.closeDragElement.bind(this);
document.addEventListener('mousemove', this.elementDragBound);
document.addEventListener('mouseup', this.closeDragElementBound);
}
elementDrag(e) {
if (!this.isDragging) return;
e.preventDefault();
const dX = e.clientX - this.dragStartX;
const dY = e.clientY - this.dragStartY;
let nT = this.dragInitialTop + dY;
let nL = this.dragInitialLeft + dX;
nT = Math.max(0, Math.min(nT, window.innerHeight - this.uiContainer.offsetHeight));
nL = Math.max(0, Math.min(nL, window.innerWidth - this.uiContainer.offsetWidth));
this.uiContainer.style.top = `${nT}px`;
this.uiContainer.style.left = `${nL}px`;
this.uiContainer.style.right = 'auto';
}
closeDragElement() {
if (!this.isDragging) return;
this.isDragging = false;
document.removeEventListener('mousemove', this.elementDragBound);
document.removeEventListener('mouseup', this.closeDragElementBound);
this.config.uiPosition.top = this.uiContainer.style.top;
this.config.uiPosition.left = this.uiContainer.style.left;
this.config.uiPosition.right = null;
this.saveConfig();
}
// --- Fin Funciones de Dragging ---
updateUI() {
if (!this.shadowRoot) return;
const q = (s) => this.shadowRoot.querySelector(s);
const qa = (s) => this.shadowRoot.querySelectorAll(s);
const ruleListEl = q('#racf-rule-list');
if (ruleListEl) {
ruleListEl.innerHTML = '';
(this.config.rules || []).forEach((rule, index) => {
const li = document.createElement('li');
const safeText = this.domPurify.sanitize(rule.text || '', { USE_PROFILES: { html: false } });
const typeTitle = `Type: ${rule.type}`;
const regexTitle = rule.isRegex ? ' (Regular Expression)' : '';
const caseTitle = (rule.type === 'keyword' && !rule.isRegex) ? (rule.caseSensitive ? ' (Case Sensitive)' : ' (Case Insensitive)') : '';
const targetTitle = `Applies to: ${rule.target || 'both'}`;
const normTitle = rule.normalize ? ' (Normalized: Ignores accents/case)' : '';
li.innerHTML = `
<div class="racf-rule-details">
<span class="racf-rule-type-badge" title="${typeTitle}">${rule.type}</span>
<span class="racf-rule-text">${safeText}</span>
${rule.isRegex ? `<small title="Regular Expression${caseTitle}">(R${rule.caseSensitive ? '' : 'i'})</small>` : ''}
${rule.type === 'keyword' && !rule.isRegex && !rule.caseSensitive && !rule.normalize ? '<small title="Case Insensitive">(i)</small>' : ''}
<small title="${targetTitle}">[${rule.target || 'both'}]</small>
${rule.normalize ? `<small title="${normTitle}">(Norm)</small>` : ''}
</div>
<button class="racf-remove-btn" data-rule-index="${index}" title="Remove Rule">X</button>
`;
ruleListEl.appendChild(li);
});
q('#racf-rule-count').textContent = (this.config.rules || []).length;
}
qa('.racf-filter-type').forEach(cb => {
cb.checked = (this.config.filterTypes || []).includes(cb.value);
});
const actionSelect = q('#racf-filter-action');
if (actionSelect) {
if (actionSelect.options.length === 0) {
FILTER_ACTIONS.forEach(action => {
const option = document.createElement('option');
option.value = action;
switch(action) {
case 'hide': option.textContent = 'Hide Completely'; break;
case 'blur': option.textContent = 'Blur (Hover to Reveal)'; break;
case 'border': option.textContent = 'Red Border'; break;
case 'collapse': option.textContent = 'Collapse (Comments Only)'; break;
case 'replace_text': option.textContent = 'Replace Text (Comments Only)'; break;
default: option.textContent = action.charAt(0).toUpperCase() + action.slice(1);
}
actionSelect.appendChild(option);
});
}
actionSelect.value = this.config.filterAction;
}
q('#racf-stats-processed').textContent = this.stats.totalProcessed;
q('#racf-stats-filtered').textContent = this.stats.totalFiltered;
const rate = this.stats.totalProcessed > 0 ? ((this.stats.totalFiltered / this.stats.totalProcessed) * 100).toFixed(1) : 0;
q('#racf-stats-rate').textContent = `${rate}%`;
q('#racf-stats-whitelisted').textContent = this.stats.totalWhitelisted;
q('#racf-stats-type-posts').textContent = this.stats.filteredByType?.posts || 0;
q('#racf-stats-type-comments').textContent = this.stats.filteredByType?.comments || 0;
q('#racf-stats-action-hide').textContent = this.stats.filteredByAction?.hide || 0;
q('#racf-stats-action-blur').textContent = this.stats.filteredByAction?.blur || 0;
q('#racf-stats-action-border').textContent = this.stats.filteredByAction?.border || 0;
q('#racf-stats-action-collapse').textContent = this.stats.filteredByAction?.collapse || 0;
q('#racf-stats-action-replace_text').textContent = this.stats.filteredByAction?.replace_text || 0;
if (this.config.activeTab === 'stats') {
const statsRuleListEl = q('#racf-stats-rule-list');
if (statsRuleListEl) {
statsRuleListEl.innerHTML = '';
const sortedRules = Object.entries(this.stats.filteredByRule || {})
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a);
if (sortedRules.length === 0) {
statsRuleListEl.innerHTML = '<li>No rules have triggered yet.</li>';
} else {
sortedRules.slice(0, 20).forEach(([ruleText, count]) => {
const li = document.createElement('li');
const safeRuleText = this.domPurify.sanitize(ruleText, { USE_PROFILES: { html: false } });
li.innerHTML = `<span class="racf-rule-text">${safeRuleText}</span><span class="racf-rule-count" title="Times triggered">${count}</span>`;
statsRuleListEl.appendChild(li);
});
}
}
}
if (this.uiContainer) {
this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
const activeTabId = this.config.activeTab || 'settings';
qa('.racf-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === activeTabId));
qa('.racf-tab-content').forEach(c => c.classList.toggle('active', c.id === `racf-${activeTabId}-content`));
}
}
normalizeText(text) {
if (typeof text !== 'string') return '';
try {
return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
} catch (e) {
this.log(`Error normalizing text: ${e.message}. Falling back to simple toLowerCase.`);
return text.toLowerCase();
}
}
handleAddRule() {
const inputElem = this.shadowRoot.querySelector('#racf-rule-input');
const typeElem = this.shadowRoot.querySelector('#racf-rule-type');
const targetElem = this.shadowRoot.querySelector('#racf-rule-target');
const normalizeElem = this.shadowRoot.querySelector('#racf-rule-normalize');
if (!inputElem || !typeElem || !targetElem || !normalizeElem) {
this.log("Error: UI elements for rule creation not found.");
alert("Error: UI elements missing. Cannot add rule.");
return;
}
const ruleInputText = inputElem.value.trim();
const ruleType = typeElem.value;
const ruleTarget = targetElem.value;
const ruleNormalize = normalizeElem.checked;
if (!ruleInputText) {
alert("Rule text cannot be empty.");
inputElem.focus();
return;
}
if (!RULE_TYPES.includes(ruleType)) {
alert("Invalid rule type selected.");
return;
}
let text = ruleInputText;
let isRegex = false;
let caseSensitive = true;
if (ruleType === 'keyword') {
if (text.startsWith('/') && text.length > 2) {
const lastSlashIndex = text.lastIndexOf('/');
if (lastSlashIndex > 0) {
const pattern = text.substring(1, lastSlashIndex);
const flags = text.substring(lastSlashIndex + 1);
try {
new RegExp(pattern, flags);
isRegex = true;
caseSensitive = !flags.includes('i');
text = text;
} catch (e) {
alert(`Invalid Regular Expression: ${e.message}\nPattern: ${text}`);
return;
}
} else {
isRegex = false;
caseSensitive = false;
}
} else {
isRegex = false;
caseSensitive = false;
}
} else if (ruleType === 'user' || ruleType === 'subreddit') {
text = text.replace(/^(u\/|r\/)/i, '');
isRegex = false;
caseSensitive = false;
text = text.toLowerCase();
}
if (ruleNormalize && ruleType === 'keyword' && !isRegex) {
caseSensitive = false;
}
const newRule = {
type: ruleType,
text: text,
isRegex: isRegex,
caseSensitive: caseSensitive,
target: ruleTarget,
normalize: (ruleType === 'keyword' && !isRegex && ruleNormalize)
};
if (!this.config.rules) this.config.rules = [];
const ruleExists = this.config.rules.some(r =>
r.type === newRule.type && r.text === newRule.text &&
r.isRegex === newRule.isRegex && r.caseSensitive === newRule.caseSensitive &&
r.target === newRule.target && r.normalize === newRule.normalize
);
if (ruleExists) {
alert("An identical rule already exists.");
inputElem.value = '';
return;
}
this.config.rules.push(newRule);
this.log(`Rule added: ${JSON.stringify(newRule)}`);
inputElem.value = '';
normalizeElem.checked = false;
targetElem.value = 'both';
typeElem.value = 'keyword';
inputElem.focus();
this.saveConfigAndApplyFilters();
this.updateUI();
}
removeRuleByIndex(index) {
if (!this.config.rules || index < 0 || index >= this.config.rules.length) {
this.log(`Invalid index for rule removal: ${index}`);
return;
}
const removed = this.config.rules.splice(index, 1);
this.log(`Rule removed: ${JSON.stringify(removed[0])}`);
this.saveConfigAndApplyFilters();
this.updateUI();
}
handleFilterTypeChange(event) {
const { value, checked } = event.target;
if (!this.config.filterTypes) this.config.filterTypes = [];
const index = this.config.filterTypes.indexOf(value);
if (checked && index === -1) {
this.config.filterTypes.push(value);
} else if (!checked && index > -1) {
this.config.filterTypes.splice(index, 1);
}
this.saveConfigAndApplyFilters();
}
initializeObserver() {
if (!window.MutationObserver) {
this.log("MutationObserver not supported, filtering might be incomplete on dynamic content.");
return;
}
if (this.observer) {
this.observer.disconnect();
}
this.observer = new MutationObserver(this.mutationCallback.bind(this));
this.observer.observe(document.body, { childList: true, subtree: true });
this.log("MutationObserver initialized.");
}
mutationCallback(mutationsList) {
const nodesToCheck = new Set();
let hasRelevantChanges = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.id === `${SCRIPT_PREFIX}-ui-container` || node.closest(`#${SCRIPT_PREFIX}-ui-container`)) {
return;
}
if (node.matches && (node.matches(this.selectors.post) || node.matches(this.selectors.comment))) {
nodesToCheck.add(node);
hasRelevantChanges = true;
}
if (node.querySelectorAll) {
try {
node.querySelectorAll(`${this.selectors.post}, ${this.selectors.comment}`).forEach(el => {
nodesToCheck.add(el);
hasRelevantChanges = true;
});
} catch (e) {
this.debugLog(`Error querying inside added node: ${e.message}`, node);
}
}
}
});
}
}
if (hasRelevantChanges && nodesToCheck.size > 0) {
this.debugLog(`MutationObserver detected ${nodesToCheck.size} new potential nodes.`);
this.applyFilters(Array.from(nodesToCheck));
}
}
applyFilters(nodesOrRoot) {
let itemsToProcess = [];
const startTime = performance.now();
const elementSet = new Set();
const collectElements = (root) => {
if (!root || root.nodeType !== Node.ELEMENT_NODE) return;
try {
if (root.matches && (root.matches(this.selectors.post) || root.matches(this.selectors.comment))) {
if (!this.processedNodes.has(root)) {
elementSet.add(root);
}
}
root.querySelectorAll(`${this.selectors.post}, ${this.selectors.comment}`).forEach(el => {
if (!this.processedNodes.has(el)) {
elementSet.add(el);
}
});
} catch(e) {
this.log(`Error collecting nodes: ${e.message}`);
console.error("Collection Error Node:", root, e);
}
};
if (Array.isArray(nodesOrRoot)) {
nodesOrRoot.forEach(n => collectElements(n));
} else if (nodesOrRoot?.nodeType === Node.ELEMENT_NODE) {
collectElements(nodesOrRoot);
} else {
this.debugLog("applyFilters called with invalid input:", nodesOrRoot);
return;
}
itemsToProcess = Array.from(elementSet);
if (itemsToProcess.length === 0) {
return;
}
this.debugLog(`Applying filters to ${itemsToProcess.length} new nodes...`);
let statsChanged = false;
let processedCount = 0;
let filteredCount = 0;
let whitelistedCount = 0;
itemsToProcess.forEach(node => {
this.processedNodes.add(node);
processedCount++;
statsChanged = true;
try {
const filterResult = this.shouldFilterNode(node);
if (filterResult.whitelisted) {
whitelistedCount++;
this.unfilterNode(node);
this.debugLog(`Node whitelisted: ${filterResult.reason}`, node);
} else if (filterResult.filter) {
filteredCount++;
const nodeType = filterResult.nodeType;
const effectiveAction = this.getEffectiveAction(this.config.filterAction, nodeType);
if (nodeType && this.stats.filteredByType) {
this.stats.filteredByType[nodeType] = (this.stats.filteredByType[nodeType] || 0) + 1;
}
if (effectiveAction && this.stats.filteredByAction) {
this.stats.filteredByAction[effectiveAction] = (this.stats.filteredByAction[effectiveAction] || 0) + 1;
}
const ruleStatText = filterResult.ruleText || `type:${filterResult.reason}`;
if (ruleStatText && this.stats.filteredByRule) {
this.stats.filteredByRule[ruleStatText] = (this.stats.filteredByRule[ruleStatText] || 0) + 1;
}
this.filterNode(node, filterResult.reason, nodeType, effectiveAction);
this.debugLog(`Node filtered (${effectiveAction}): ${filterResult.reason}`, node);
} else {
this.unfilterNode(node);
this.debugLog(`Node not filtered: ${filterResult.reason}`, node);
}
} catch (error) {
this.log(`Error filtering node: ${error.message}`);
console.error(`[${SCRIPT_PREFIX}] Filter error details:`, error, node);
try { this.unfilterNode(node); } catch {}
}
});
if (statsChanged) {
this.stats.totalProcessed += processedCount;
this.stats.totalFiltered += filteredCount;
this.stats.totalWhitelisted += whitelistedCount;
this.debouncedSaveStats();
if (this.uiUpdateDebounceTimer) clearTimeout(this.uiUpdateDebounceTimer);
this.uiUpdateDebounceTimer = setTimeout(() => {
this.updateUI();
this.uiUpdateDebounceTimer = null;
}, 300);
}
this.lastFilterTime = performance.now();
const duration = this.lastFilterTime - startTime;
if (itemsToProcess.length > 0) {
this.debugLog(`Filtering ${itemsToProcess.length} nodes took ${duration.toFixed(2)} ms.`);
}
}
getEffectiveAction(configuredAction, nodeType) {
if (nodeType !== 'comments') {
if (configuredAction === 'collapse' || configuredAction === 'replace_text') {
return 'hide';
}
}
return configuredAction;
}
shouldFilterNode(node) {
let nodeType = null;
if (node.matches(this.selectors.post)) nodeType = 'posts';
else if (node.matches(this.selectors.comment)) nodeType = 'comments';
else return { filter: false, reason: "Not a target post/comment element", nodeType: null };
let result = { filter: false, reason: "No match", whitelisted: false, ruleText: null, nodeType: nodeType };
if (!(this.config.filterTypes || []).includes(nodeType)) {
result.reason = `Filtering disabled for type '${nodeType}'`;
return result;
}
const subreddit = this.extractSubreddit(node, nodeType)?.toLowerCase();
const author = this.extractAuthor(node, nodeType)?.toLowerCase();
if (subreddit && (this.config.blacklist?.subreddits || []).includes(subreddit)) {
return { ...result, filter: true, reason: `Globally Blacklisted Subreddit: r/${subreddit}`, ruleText: `bl-sub:${subreddit}` };
}
if (author && (this.config.blacklist?.users || []).includes(author)) {
return { ...result, filter: true, reason: `Globally Blacklisted User: u/${author}`, ruleText: `bl-user:${author}` };
}
if (subreddit && (this.config.whitelist?.subreddits || []).includes(subreddit)) {
return { ...result, whitelisted: true, reason: `Globally Whitelisted Subreddit: r/${subreddit}` };
}
if (author && (this.config.whitelist?.users || []).includes(author)) {
return { ...result, whitelisted: true, reason: `Globally Whitelisted User: u/${author}` };
}
let contentCache = { title: null, body: null, checked: false };
for (const rule of (this.config.rules || [])) {
let match = false;
const ruleStatText = `[${rule.type}${rule.isRegex?'(R)':''}${rule.normalize?'(N)':''}${rule.target?`-${rule.target}`:''}] ${rule.text}`;
let reasonSuffix = "";
try {
switch (rule.type) {
case 'keyword':
const target = rule.target || 'both';
if (!contentCache.checked) {
const extracted = this.extractContent(node, nodeType);
contentCache.title = extracted.title;
contentCache.body = extracted.body;
contentCache.checked = true;
this.debugLog(`Extracted content for node: Title: ${!!contentCache.title}, Body: ${!!contentCache.body}`, node);
}
let contentToTest = [];
let testedAreas = [];
if ((target === 'title' || target === 'both') && contentCache.title) {
contentToTest.push(contentCache.title);
testedAreas.push('title');
}
if ((target === 'body' || target === 'both') && contentCache.body) {
contentToTest.push(contentCache.body);
testedAreas.push('body');
}
if (contentToTest.length === 0) {
this.debugLog(`Skipping rule ${ruleStatText}: no content found for target '${target}'`, node);
continue;
}
reasonSuffix = ` in ${testedAreas.join('&')}`;
let pattern = rule.text;
let testFunc;
if (rule.isRegex) {
const regexMatch = pattern.match(/^\/(.+)\/([gimyus]*)$/);
if (regexMatch) {
try {
const regex = new RegExp(regexMatch[1], regexMatch[2]);
testFunc = (text) => regex.test(text);
reasonSuffix += ` (Regex${regex.flags.includes('i') ? ', Insensitive' : ''})`;
} catch (reError) {
this.log(`Rule error (invalid regex) ${ruleStatText}: ${reError.message}`);
continue;
}
} else {
this.log(`Rule error (malformed regex literal) ${ruleStatText}`);
continue;
}
} else {
const useNormalization = rule.normalize;
const isCaseSensitive = rule.caseSensitive;
const comparePattern = useNormalization
? this.normalizeText(pattern)
: (isCaseSensitive ? pattern : pattern.toLowerCase());
testFunc = (text) => {
if (!text) return false;
const compareContent = useNormalization
? this.normalizeText(text)
: (isCaseSensitive ? text : text.toLowerCase());
return compareContent.includes(comparePattern);
};
reasonSuffix += `${useNormalization ? ' (Normalized)' : (isCaseSensitive ? ' (Case Sensitive)' : ' (Case Insensitive)')}`;
}
match = contentToTest.some(text => testFunc(text));
break;
case 'user':
if (!author) continue;
match = author === rule.text;
reasonSuffix = ` (author: u/${author})`;
break;
case 'subreddit':
if (!subreddit || nodeType !== 'posts') continue;
match = subreddit === rule.text;
reasonSuffix = ` (subreddit: r/${subreddit})`;
break;
} // End switch
if (match) {
const safeRuleDisplay = this.domPurify.sanitize(rule.text, { USE_PROFILES: { html: false } });
return { ...result, filter: true, reason: `Rule Match: [${rule.type}] '${safeRuleDisplay}'${reasonSuffix}`, ruleText: ruleStatText };
}
} catch (e) {
this.log(`Rule processing error for ${ruleStatText}: ${e.message}`);
console.error(`[${SCRIPT_PREFIX}] Rule processing error details:`, e, rule, node);
}
} // End for loop
result.reason = "No matching rules or blacklists";
return result;
}
extractContent(node, nodeType) {
const result = { title: null, body: null };
try {
if (nodeType === 'posts' && this.selectors.postTitleSelector) {
const titleElement = node.querySelector(this.selectors.postTitleSelector);
if (titleElement) {
result.title = titleElement.textContent?.trim() || null;
if (result.title) result.title = result.title.replace(/\s+/g, ' ');
}
}
let bodySelector = null;
if (nodeType === 'posts' && this.selectors.postBodySelector) {
bodySelector = this.selectors.postBodySelector;
} else if (nodeType === 'comments' && this.selectors.commentBodySelector) {
bodySelector = this.selectors.commentBodySelector;
}
if (bodySelector) {
const bodyElement = node.querySelector(bodySelector);
if (bodyElement) {
result.body = bodyElement.textContent?.trim() || null;
if (result.body) result.body = result.body.replace(/\s+/g, ' ');
} else if (this.isOldReddit && nodeType === 'posts') {
const oldPostBody = node.querySelector('.expando .usertext-body .md');
if (oldPostBody) {
result.body = oldPostBody.textContent?.trim() || null;
if (result.body) result.body = result.body.replace(/\s+/g, ' ');
}
}
}
} catch (e) {
this.log(`Error extracting content (type: ${nodeType}): ${e.message}`);
console.error("Extraction Error Node:", node, e);
}
return result;
}
extractSubreddit(node, nodeType) {
if (nodeType !== 'posts' || !this.selectors.postSubredditSelector) return null;
try {
const subElement = node.querySelector(this.selectors.postSubredditSelector);
if (subElement) {
return subElement.textContent?.trim().replace(/^r\//i, '') || null;
}
if (!this.isOldReddit) {
const linkSub = node.querySelector('a[data-testid="subreddit-name"]');
if (linkSub) return linkSub.textContent?.trim().replace(/^r\//i, '') || null;
}
return null;
} catch (e) {
this.log(`Error extracting subreddit: ${e.message}`);
return null;
}
}
extractAuthor(node, nodeType) {
const selector = nodeType === 'posts' ? this.selectors.postAuthorSelector : this.selectors.commentAuthorSelector;
if (!selector) return null;
try {
const authorElement = node.querySelector(selector);
if (authorElement) {
const authorText = authorElement.textContent?.trim();
if (!authorText || ['[deleted]', '[removed]', ''].includes(authorText.toLowerCase())) {
return null;
}
return authorText.replace(/^u\//i, '') || null;
}
if (!this.isOldReddit) {
const linkAuthor = node.querySelector('a[data-testid="post-author-link"], a[data-testid="comment-author-link"]');
if (linkAuthor) {
const authorText = linkAuthor.textContent?.trim();
if (authorText && !['[deleted]', '[removed]', ''].includes(authorText.toLowerCase())) {
return authorText.replace(/^u\//i, '') || null;
}
}
}
return null;
} catch (e) {
this.log(`Error extracting author (type ${nodeType}): ${e.message}`);
return null;
}
}
filterNode(node, reason, nodeType, action) {
this.unfilterNode(node);
const effectiveAction = this.getEffectiveAction(action, nodeType);
const shortReason = reason.substring(0, 200) + (reason.length > 200 ? '...' : '');
const filterAttrValue = `${SCRIPT_PREFIX}: Filtered [${effectiveAction}] (${shortReason})`;
if (effectiveAction === 'replace_text' && nodeType === 'comments') {
this.replaceCommentText(node, shortReason);
node.setAttribute('data-racf-filter-reason', filterAttrValue);
node.title = filterAttrValue;
} else if (FILTER_ACTIONS.includes(effectiveAction) && effectiveAction !== 'replace_text') {
const actionClass = `${SCRIPT_PREFIX}-${effectiveAction}`;
node.classList.add(actionClass);
node.setAttribute('data-racf-filter-reason', filterAttrValue);
node.title = filterAttrValue;
this.debugLog(`Applied class '${actionClass}' to node:`, node);
} else {
this.log(`Invalid or non-applicable action '${action}' for type '${nodeType}'. Defaulting to hide.`);
node.classList.add(`${SCRIPT_PREFIX}-hide`);
const fallbackAttrValue = `${SCRIPT_PREFIX}: Filtered [hide - fallback] (${shortReason})`;
node.setAttribute('data-racf-filter-reason', fallbackAttrValue);
node.title = fallbackAttrValue;
}
}
replaceCommentText(commentNode, reason) {
const bodySelector = this.selectors.commentBodySelector;
if (!bodySelector) {
this.log("Error: commentBodySelector not defined. Cannot replace text.");
return;
}
const commentBody = commentNode.querySelector(bodySelector);
if (!commentBody) {
this.debugLog("Comment body element not found using selector:", bodySelector, "on node:", commentNode);
return;
}
if (!this.originalContentCache.has(commentBody)) {
const currentHTML = commentBody.innerHTML;
if (!currentHTML.includes(`[${SCRIPT_PREFIX}: Text Filtered`)) {
this.originalContentCache.set(commentBody, currentHTML);
this.debugLog("Stored original content for:", commentBody);
} else {
this.debugLog("Attempted to store placeholder, skipping cache.", commentBody);
}
}
const placeholderHTML = `<p style="color: grey; font-style: italic; margin: 0; padding: 5px 0;">[${SCRIPT_PREFIX}: Text Filtered (${reason})]</p>`;
if (commentBody.innerHTML !== placeholderHTML) {
commentBody.innerHTML = placeholderHTML;
commentNode.classList.add(`${SCRIPT_PREFIX}-text-replaced`);
this.debugLog("Replaced text content for node:", commentNode);
} else {
this.debugLog("Text content already replaced, skipping.", commentNode);
}
}
unfilterNode(node) {
let wasModified = false;
FILTER_ACTIONS.forEach(action => {
if (action !== 'replace_text') {
const className = `${SCRIPT_PREFIX}-${action}`;
if (node.classList.contains(className)) {
node.classList.remove(className);
wasModified = true;
}
}
});
const textReplacedMarker = `${SCRIPT_PREFIX}-text-replaced`;
if (node.classList.contains(textReplacedMarker)) {
node.classList.remove(textReplacedMarker);
wasModified = true;
const bodySelector = this.selectors.commentBodySelector;
const commentBody = bodySelector ? node.querySelector(bodySelector) : null;
if (commentBody && this.originalContentCache.has(commentBody)) {
const originalHTML = this.originalContentCache.get(commentBody);
if (commentBody.innerHTML.includes(`[${SCRIPT_PREFIX}: Text Filtered`)) {
commentBody.innerHTML = originalHTML;
this.debugLog("Restored original text content for node:", node);
} else {
this.debugLog("Skipped text restore, content wasn't placeholder.", commentBody);
}
this.originalContentCache.delete(commentBody);
} else if (commentBody) {
this.debugLog("Could not restore original text (not in cache?), node:", node);
if (commentBody.innerHTML.includes(`[${SCRIPT_PREFIX}: Text Filtered`)) {
commentBody.innerHTML = '<!-- [RACF] Content restoration failed -->';
}
}
}
if (node.hasAttribute('data-racf-filter-reason')) {
node.removeAttribute('data-racf-filter-reason');
wasModified = true;
}
if (node.title?.startsWith(SCRIPT_PREFIX)) {
node.removeAttribute('title');
wasModified = true;
}
if (wasModified) {
this.debugLog("Unfiltered node:", node);
}
}
addScrollListener() {
let scrollTimeout = null;
const handleScroll = () => {
if (scrollTimeout !== null) {
window.clearTimeout(scrollTimeout);
}
if (performance.now() - this.lastFilterTime < DEBOUNCE_DELAY_MS / 2) {
return;
}
scrollTimeout = setTimeout(() => {
window.requestAnimationFrame(() => {
this.debugLog("Scroll ended, applying filters...");
this.applyFilters(document.body);
});
scrollTimeout = null;
}, DEBOUNCE_DELAY_MS);
};
window.addEventListener('scroll', handleScroll, { passive: true });
this.log("Scroll listener added.");
}
exportConfig() {
try {
const configToExport = {
...this.config,
rules: this.config.rules || [],
filterTypes: this.config.filterTypes || [],
filterAction: FILTER_ACTIONS.includes(this.config.filterAction) ? this.config.filterAction : DEFAULT_CONFIG.filterAction,
whitelist: this.config.whitelist || { subreddits: [], users: [] },
blacklist: this.config.blacklist || { subreddits: [], users: [] },
uiVisible: this.config.uiVisible,
activeTab: this.config.activeTab || 'settings',
uiPosition: this.config.uiPosition || DEFAULT_CONFIG.uiPosition
};
const configString = JSON.stringify(configToExport, null, 2);
const blob = new Blob([configString], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
link.setAttribute('download', `reddit-filter-config-${timestamp}.json`);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.log("Config exported successfully.");
} catch (e) {
this.log(`Export error: ${e.message}`);
alert(`Config export failed: ${e.message}`);
console.error("Export Error:", e);
}
}
async importConfig(event) {
const fileInput = event.target;
const file = fileInput?.files?.[0];
if (!file) {
this.log("Import cancelled or no file selected.");
return;
}
if (!file.type || !file.type.match('application/json')) {
alert('Import failed: Please select a valid .json file.');
if (fileInput) fileInput.value = null;
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
try {
const importedConfig = JSON.parse(content);
if (typeof importedConfig !== 'object' || importedConfig === null) {
throw new Error("Invalid JSON format. Root should be an object.");
}
const newConfig = {
...DEFAULT_CONFIG,
...importedConfig,
rules: Array.isArray(importedConfig.rules) ? importedConfig.rules : [],
filterTypes: Array.isArray(importedConfig.filterTypes) ? importedConfig.filterTypes.filter(t => ['posts', 'comments', 'messages'].includes(t)) : DEFAULT_CONFIG.filterTypes,
filterAction: FILTER_ACTIONS.includes(importedConfig.filterAction) ? importedConfig.filterAction : DEFAULT_CONFIG.filterAction,
whitelist: {
subreddits: Array.isArray(importedConfig.whitelist?.subreddits) ? importedConfig.whitelist.subreddits.map(s => String(s).toLowerCase()) : [],
users: Array.isArray(importedConfig.whitelist?.users) ? importedConfig.whitelist.users.map(u => String(u).toLowerCase()) : []
},
blacklist: {
subreddits: Array.isArray(importedConfig.blacklist?.subreddits) ? importedConfig.blacklist.subreddits.map(s => String(s).toLowerCase()) : [],
users: Array.isArray(importedConfig.blacklist?.users) ? importedConfig.blacklist.users.map(u => String(u).toLowerCase()) : []
},
uiPosition: { ...DEFAULT_CONFIG.uiPosition, ...(typeof importedConfig.uiPosition === 'object' ? importedConfig.uiPosition : {}) },
uiVisible: typeof importedConfig.uiVisible === 'boolean' ? importedConfig.uiVisible : DEFAULT_CONFIG.uiVisible,
activeTab: typeof importedConfig.activeTab === 'string' ? importedConfig.activeTab : DEFAULT_CONFIG.activeTab
};
newConfig.rules = newConfig.rules.filter(rule =>
rule && typeof rule === 'object' &&
RULE_TYPES.includes(rule.type) &&
typeof rule.text === 'string' && rule.text.trim() !== '' &&
typeof rule.isRegex === 'boolean' &&
typeof rule.caseSensitive === 'boolean' &&
typeof rule.normalize === 'boolean' &&
['title', 'body', 'both'].includes(rule.target)
).map(rule => {
if (rule.type === 'user' || rule.type === 'subreddit') {
rule.text = rule.text.toLowerCase();
rule.caseSensitive = false;
rule.normalize = false;
rule.isRegex = false;
}
if (rule.normalize) rule.caseSensitive = false;
return rule;
});
this.config = newConfig;
if (this.uiContainer && this.config.uiPosition) {
this.uiContainer.style.top = this.config.uiPosition.top || DEFAULT_CONFIG.uiPosition.top;
if (this.config.uiPosition.left !== null && this.config.uiPosition.left !== undefined) {
this.uiContainer.style.left = this.config.uiPosition.left;
this.uiContainer.style.right = 'auto';
} else {
this.uiContainer.style.left = 'auto';
this.uiContainer.style.right = this.config.uiPosition.right || DEFAULT_CONFIG.uiPosition.right;
}
this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
}
this.log(`Config imported successfully. ${newConfig.rules.length} rules loaded.`);
await this.saveConfig();
this.updateUI();
this.processedNodes = new WeakSet();
this.originalContentCache = new WeakMap();
this.applyFilters(document.body);
alert('Configuration imported successfully!');
} catch (err) {
alert(`Import error: ${err.message}\nPlease ensure the file is a valid configuration JSON.`);
this.log(`Import error: ${err.message}`);
console.error("Import Error:", err);
} finally {
if (fileInput) fileInput.value = null;
}
};
reader.onerror = (e) => {
alert(`File read error: ${e.target.error}`);
this.log(`File read error: ${e.target.error}`);
if (fileInput) fileInput.value = null;
};
reader.readAsText(file);
}
registerMenuCommands() {
GM_registerMenuCommand('Toggle Filter Panel', () => this.toggleUIVisibility());
GM_registerMenuCommand('Re-apply All Filters', () => {
this.log(`Manual re-filter requested.`);
this.processedNodes = new WeakSet();
this.originalContentCache = new WeakMap();
this.applyFilters(document.body);
});
GM_registerMenuCommand('Reset Filter Statistics', () => this.resetStats());
}
toggleUIVisibility(forceState = null) {
const shouldBeVisible = forceState !== null ? forceState : !this.config.uiVisible;
if (shouldBeVisible !== this.config.uiVisible) {
this.config.uiVisible = shouldBeVisible;
if (this.uiContainer) {
this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
}
this.saveConfig();
if (this.config.uiVisible) {
this.updateUI();
}
const optionsBtn = document.getElementById(`${SCRIPT_PREFIX}-options-btn`);
if (optionsBtn) {
optionsBtn.textContent = this.config.uiVisible ? 'Ocultar RedditCurator' : 'Mostrar RedditCurator';
optionsBtn.title = this.config.uiVisible ? 'Ocultar RedditCurator' : 'Mostrar RedditCurator';
}
}
}
async saveConfigAndApplyFilters() {
await this.saveConfig();
if (this.filterApplyDebounceTimer) clearTimeout(this.filterApplyDebounceTimer);
this.filterApplyDebounceTimer = setTimeout(() => {
this.log(`Config changed, re-applying all filters...`);
this.processedNodes = new WeakSet();
this.originalContentCache = new WeakMap();
this.applyFilters(document.body);
this.filterApplyDebounceTimer = null;
}, 150);
}
} // --- Fin de la clase RedditFilter ---
// -------------------------------
// Botón Flotante de Opciones
// -------------------------------
function addOptionsButton() {
const buttonId = `${SCRIPT_PREFIX}-options-btn`;
if (document.getElementById(buttonId)) return;
const btn = document.createElement('button');
btn.id = buttonId;
btn.style.position = 'fixed';
btn.style.bottom = '15px';
btn.style.right = '15px';
btn.style.zIndex = '10000';
btn.style.padding = '8px 16px';
btn.style.backgroundColor = '#0079D3';
btn.style.color = 'white';
btn.style.border = '1px solid #006abd';
btn.style.borderRadius = '20px';
btn.style.cursor = 'pointer';
btn.style.fontWeight = 'bold';
btn.style.fontSize = '13px';
btn.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
btn.style.transition = 'background-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease';
btn.style.fontFamily = 'inherit';
btn.style.lineHeight = '1.5';
btn.onmouseover = () => {
btn.style.backgroundColor = '#005fa3';
btn.style.boxShadow = '0 6px 12px rgba(0,0,0,0.3)';
};
btn.onmouseout = () => {
btn.style.backgroundColor = '#0079D3';
btn.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
btn.style.transform = 'scale(1)';
};
btn.onmousedown = () => { btn.style.transform = 'scale(0.97)'; };
btn.onmouseup = () => { btn.style.transform = 'scale(1)'; };
const instance = window.redditAdvancedFilterInstance_1_7;
btn.textContent = (instance && instance.config.uiVisible) ? 'Ocultar RedditCurator' : 'Mostrar RedditCurator';
btn.title = (instance && instance.config.uiVisible) ? 'Mostrar RedditCurator' : 'Mostrar RedditCurator';
btn.addEventListener('click', () => {
const currentInstance = window.redditAdvancedFilterInstance_1_7;
if (currentInstance) {
currentInstance.toggleUIVisibility();
} else {
console.warn(`[${SCRIPT_PREFIX}] Filter instance not found when clicking options button.`);
}
});
document.body.appendChild(btn);
}
// -------------------------------
// Inicialización del Script
// -------------------------------
function runScript() {
const instanceName = 'redditAdvancedFilterInstance_1_7';
if (window[instanceName]) {
const runningVersion = window[instanceName].constructor.version || GM_info?.script?.version || 'unknown';
GM_log(`[${SCRIPT_PREFIX}] Instance already running (v${runningVersion}). Skipping init.`);
if (!document.getElementById(`${SCRIPT_PREFIX}-options-btn`)) {
addOptionsButton();
const btn = document.getElementById(`${SCRIPT_PREFIX}-options-btn`);
if(btn) {
const instance = window[instanceName];
btn.textContent = (instance && instance.config.uiVisible) ? 'Ocultar RedditCurator' : 'Mostrar RedditCurator';
btn.title = (instance && instance.config.uiVisible) ? 'Ocultar RedditCurator' : 'Mostrar RedditCurator';
}
}
return;
}
window[instanceName] = new RedditFilter();
window[instanceName].init().then(() => {
addOptionsButton();
}).catch(error => {
GM_log(`[${SCRIPT_PREFIX}] Critical error during initialization: ${error.message}`);
console.error(`[${SCRIPT_PREFIX}] Initialization failed:`, error);
delete window[instanceName];
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runScript);
} else {
runScript();
}
})(); // --- Fin del IIFE ---