Filter HuggingFace models by positive/negative keywords with a floating, draggable, persistent UI. Supports infinite scroll, dark mode, auto-filter, multi-language (EN/ES/ZH + 30 Google Translate), and localStorage persistence.
// ==UserScript==
// @name HuggingFace Model Filter
// @name:es Filtro de Modelos de HuggingFace
// @name:zh-CN HuggingFace 模型过滤器
// @namespace https://github.com/Milor123/huggingface-model-filter
// @version 1.4
// @description Filter HuggingFace models by positive/negative keywords with a floating, draggable, persistent UI. Supports infinite scroll, dark mode, auto-filter, multi-language (EN/ES/ZH + 30 Google Translate), and localStorage persistence.
// @description:es Filtra modelos de HuggingFace por palabras clave positivas/negativas con una interfaz flotante, arrastrable y persistente. Soporta scroll infinito, modo oscuro, auto-filtro, multi-idioma (EN/ES/ZH + 30 idiomas con Google Translate) y persistencia en localStorage.
// @description:zh-CN 通过正面/负面关键词筛选 HuggingFace 模型,带有浮动、可拖拽、持久化的界面。支持无限滚动、暗黑模式、自动筛选、多语言(英语/西班牙语/中文 + 30 种 Google 翻译语言)和 localStorage 持久化。
// @author Mateo Bohorquez (Milor123)
// @match https://huggingface.co/models*
// @match https://huggingface.co/models?*
// @grant GM_addStyle
// @run-at document-end
// @license MIT
// @homepageURL https://github.com/Milor123/huggingface-model-filter
// @supportURL https://github.com/Milor123/huggingface-model-filter/issues
// @icon https://huggingface.co/favicon.ico
// ==/UserScript==
/*
* HuggingFace Model Filter v1.4
* https://github.com/Milor123/huggingface-model-filter
*
* Filter HuggingFace models by positive/negative keywords. Includes a draggable
* floating panel with infinite scroll, dark mode, and persistent settings.
* Supports English, Chinese, Spanish, and opt-in auto-translate via Google.
*
* License: MIT
*/
(function() {
'use strict';
// ==========================================
// I18N - Translations
// ==========================================
const LOCALES = {
en: {
panelTitle: '🔍 HF Model Filter',
minimizeTitle: 'Minimize',
positiveLabel: '✅ Positive keywords (show only these)',
positivePlaceholder: 'uncensored, qwen, llama, 7b, gguf...',
positiveHint: 'Comma-separated. Only models matching at least one are shown',
negativeLabel: '❌ Negative keywords (hide)',
negativePlaceholder: 'beta, deprecated, old, test...',
negativeHint: 'Comma-separated. Matching models are hidden/dimmed',
hideNegative: 'Completely hide negative matches',
dimNegative: 'Only dim negatives (show faintly)',
highlightPositive: 'Highlight positive matches',
caseSensitive: 'Case sensitive',
matchFullWord: 'Match whole word only',
total: 'Total',
visible: 'Visible',
filtered: 'Filtered',
applyBtn: 'Apply Filters',
applyDone: '✓ Applied!',
resetBtn: 'Reset',
tipsTitle: '💡 Tips:',
tips: [
'Use "gguf, qwen" to find Qwen models in GGUF format',
'Filter "uncensored" to see uncensored models only',
'Exclude "beta, alpha" to avoid unstable versions',
],
badgeMatch: '✓ Match',
badgeBlocked: '✗ Blocked',
langLabel: '🌐 Language',
langEn: 'English',
langZh: '中文',
langEs: 'Español',
autoTranslateLabel: 'Auto-translate (Google)',
autoTranslateNote: 'Choose a language below to translate the UI via Google.',
translating: 'Translating...',
translateFailed: 'Auto-translate unavailable',
selectLang: 'Select language',
},
zh: {
panelTitle: '🔍 HF 模型筛选',
minimizeTitle: '最小化',
positiveLabel: '✅ 正向关键词(仅显示这些)',
positivePlaceholder: 'uncensored, qwen, llama, 7b, gguf...',
positiveHint: '用逗号分隔。仅显示匹配至少一个关键词的模型',
negativeLabel: '❌ 负向关键词(隐藏)',
negativePlaceholder: 'beta, deprecated, old, test...',
negativeHint: '用逗号分隔。匹配的模型将被隐藏或变暗',
hideNegative: '完全隐藏负向匹配',
dimNegative: '仅变暗显示负向匹配',
highlightPositive: '高亮显示正向匹配',
caseSensitive: '区分大小写',
matchFullWord: '匹配完整单词',
total: '总计',
visible: '可见',
filtered: '已过滤',
applyBtn: '应用筛选',
applyDone: '✓ 已应用!',
resetBtn: '重置',
tipsTitle: '💡 提示:',
tips: [
'使用"gguf, qwen"查找 GGUF 格式的 Qwen 模型',
'筛选"uncensored"仅查看无审查模型',
'排除"beta, alpha"以避免不稳定版本',
],
badgeMatch: '✓ 匹配',
badgeBlocked: '✗ 已屏蔽',
langLabel: '🌐 语言',
langEn: 'English',
langZh: '中文',
langEs: 'Español',
autoTranslateLabel: '自动翻译(谷歌)',
autoTranslateNote: '在下方选择语言以通过谷歌翻译界面。',
translating: '翻译中...',
translateFailed: '自动翻译不可用',
selectLang: '选择语言',
},
es: {
panelTitle: '🔍 HF Model Filter',
minimizeTitle: 'Minimizar',
positiveLabel: '✅ Palabras positivas (mostrar solo estos)',
positivePlaceholder: 'uncensored, qwen, llama, 7b, gguf...',
positiveHint: 'Separados por comas. Solo se muestran modelos que coincidan',
negativeLabel: '❌ Palabras negativas (ocultar)',
negativePlaceholder: 'beta, deprecated, old, test...',
negativeHint: 'Separados por comas. Se ocultan/difuminan modelos que coincidan',
hideNegative: 'Ocultar completamente los negativos',
dimNegative: 'Solo difuminar negativos (ver transparencia)',
highlightPositive: 'Resaltar coincidencias positivas',
caseSensitive: 'Distinguir mayúsculas/minúsculas',
matchFullWord: 'Coincidir palabra completa',
total: 'Total',
visible: 'Visibles',
filtered: 'Filtrados',
applyBtn: 'Aplicar Filtros',
applyDone: '✓ Aplicado!',
resetBtn: 'Limpiar',
tipsTitle: '💡 Tips:',
tips: [
'Usa "gguf, qwen" para encontrar modelos Qwen en formato GGUF',
'Filtra "uncensored" para ver solo modelos sin censura',
'Excluye "beta, alpha" para evitar versiones inestables',
],
badgeMatch: '✓ Match',
badgeBlocked: '✗ Blocked',
langLabel: '🌐 Idioma',
langEn: 'English',
langZh: '中文',
langEs: 'Español',
autoTranslateLabel: 'Traducción automática (Google)',
autoTranslateNote: 'Elegí un idioma abajo para traducir la interfaz vía Google.',
translating: 'Traduciendo...',
translateFailed: 'Traducción automática no disponible',
selectLang: 'Seleccionar idioma',
}
};
const AVAILABLE_LANGS = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' },
{ code: 'zh', name: '中文' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
{ code: 'pt', name: 'Português' },
{ code: 'it', name: 'Italiano' },
{ code: 'ja', name: '日本語' },
{ code: 'ko', name: '한국어' },
{ code: 'ru', name: 'Русский' },
{ code: 'ar', name: 'العربية' },
{ code: 'hi', name: 'हिन्दी' },
{ code: 'nl', name: 'Nederlands' },
{ code: 'pl', name: 'Polski' },
{ code: 'tr', name: 'Türkçe' },
{ code: 'vi', name: 'Tiếng Việt' },
{ code: 'th', name: 'ไทย' },
{ code: 'sv', name: 'Svenska' },
{ code: 'cs', name: 'Čeština' },
{ code: 'el', name: 'Ελληνικά' },
{ code: 'he', name: 'עברית' },
{ code: 'uk', name: 'Українська' },
{ code: 'ro', name: 'Română' },
{ code: 'hu', name: 'Magyar' },
{ code: 'da', name: 'Dansk' },
{ code: 'fi', name: 'Suomi' },
{ code: 'no', name: 'Norsk' },
{ code: 'id', name: 'Bahasa Indonesia' },
{ code: 'ms', name: 'Bahasa Melayu' },
{ code: 'bn', name: 'বাংলা' },
];
function getBrowserLang() {
const lang = (navigator.language || navigator.userLanguage || '').toLowerCase();
if (lang.startsWith('zh')) return 'zh';
if (lang.startsWith('es')) return 'es';
return 'en';
}
function getBrowserLangCode() {
return (navigator.language || navigator.userLanguage || 'en').toLowerCase().split('-')[0];
}
function getLangName(code) {
try {
return new Intl.DisplayNames([code], { type: 'language' }).of(code) || code;
} catch(e) {
return code;
}
}
// ==========================================
// CONFIGURACIÓN PERSISTENTE
// ==========================================
const STORAGE_KEY = 'hf_model_filter_config';
const CACHE_KEY = 'hf_filter_trans_cache';
const defaultConfig = {
positiveKeywords: [],
negativeKeywords: [],
hideOnNegative: true,
dimOnNegative: false,
highlightPositive: true,
caseSensitive: false,
matchFullWord: false,
showStats: true,
autoFilter: true,
position: { x: 20, y: 100 },
locale: getBrowserLang(),
autoTranslate: false,
autoTranslateLang: '',
};
let config = { ...defaultConfig };
let filteredCount = 0;
let totalCount = 0;
let translationCache = {};
let isTranslating = false;
// ==========================================
// UTILIDADES
// ==========================================
function t(key) {
if (config.autoTranslate && config.autoTranslateLang && !['en', 'zh', 'es'].includes(config.autoTranslateLang)) {
const cacheKey = key + '__' + config.autoTranslateLang;
if (translationCache[cacheKey] !== undefined) {
return translationCache[cacheKey];
}
}
const locale = LOCALES[config.locale] || LOCALES.en;
return locale[key] !== undefined ? locale[key] : LOCALES.en[key];
}
function loadConfig() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) config = { ...defaultConfig, ...JSON.parse(saved) };
} catch(e) { console.log('HF Filter: No config found'); }
}
function saveConfig() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
}
function loadTranslationCache() {
try {
const saved = localStorage.getItem(CACHE_KEY);
if (saved) translationCache = JSON.parse(saved);
} catch(e) {}
}
function saveTranslationCache() {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(translationCache));
} catch(e) {}
}
// ==========================================
// AUTO-TRANSLATE (Google Translate, no key)
// ==========================================
async function googleTranslate(text, targetLang) {
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=' + encodeURIComponent(targetLang) + '&dt=t';
const body = new URLSearchParams({ q: text });
const res = await fetch(url, { method: 'POST', body });
const data = await res.json();
return data[0].map(s => s[0]).join('');
}
async function doAutoTranslate() {
const target = config.autoTranslateLang;
if (!target) return;
const supported = ['en', 'zh', 'es'];
if (supported.includes(target)) {
config.locale = target;
saveConfig();
rebuildUI();
return;
}
const sampleKey = Object.keys(LOCALES.en)[0] + '__' + target;
if (translationCache[sampleKey] !== undefined) {
rebuildUI();
return;
}
isTranslating = true;
rebuildUI();
const sep = '\n@@@SEP@@@\n';
const arrSep = '\n@@@ARR@@@\n';
const keys = Object.keys(LOCALES.en);
const combined = keys.map(k => {
const v = LOCALES.en[k];
return Array.isArray(v) ? v.join(arrSep) : v;
}).join(sep);
try {
const translated = await googleTranslate(combined, target);
const parts = translated.split(sep);
keys.forEach((key, i) => {
if (parts[i] === undefined) return;
const orig = LOCALES.en[key];
let val = parts[i];
if (Array.isArray(orig)) {
val = val.split(arrSep);
}
translationCache[key + '__' + target] = val;
});
saveTranslationCache();
} catch(e) {
console.log('HF Filter: Auto-translate failed', e);
}
isTranslating = false;
rebuildUI();
}
function normalizeText(text) {
if (!text) return '';
return config.caseSensitive ? text : text.toLowerCase();
}
function matchesKeyword(text, keyword) {
if (!text || !keyword) return false;
const t = normalizeText(text);
const k = normalizeText(keyword.trim());
if (!k) return false;
if (config.matchFullWord) {
const regex = new RegExp(`\\b${k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, config.caseSensitive ? '' : 'i');
return regex.test(text);
}
return t.includes(k);
}
// ==========================================
// LÓGICA DE FILTRADO
// ==========================================
function getModelCards() {
return document.querySelectorAll('article.overview-card-wrapper, [class*="overview-card"], article[class*="group/repo"]');
}
function analyzeModel(card) {
const titleEl = card.querySelector('h4, h3, [class*="truncate"], header h4');
const title = titleEl ? titleEl.textContent : '';
const fullText = card.textContent || '';
const metaText = Array.from(card.querySelectorAll('span, div[class*="text-gray"]'))
.map(el => el.textContent)
.join(' ');
return {
title: title.trim(),
fullText: fullText.trim(),
metaText: metaText.trim(),
element: card
};
}
function checkKeywords(text) {
const pos = config.positiveKeywords.filter(k => k.trim()).some(k => matchesKeyword(text, k));
const neg = config.negativeKeywords.filter(k => k.trim()).some(k => matchesKeyword(text, k));
return { positive: pos, negative: neg };
}
function applyFilter() {
const cards = getModelCards();
totalCount = cards.length;
filteredCount = 0;
cards.forEach(card => {
const model = analyzeModel(card);
const checks = checkKeywords(model.fullText);
card.style.display = '';
card.style.opacity = '1';
card.style.filter = 'none';
card.style.transform = 'scale(1)';
card.style.transition = 'all 0.3s ease';
card.style.border = 'none';
card.style.boxShadow = '';
card.style.position = 'relative';
const oldBadge = card.querySelector('.hf-filter-badge');
if (oldBadge) oldBadge.remove();
let action = 'show';
if (checks.negative && config.hideOnNegative) {
action = 'hide';
} else if (checks.negative && config.dimOnNegative) {
action = 'dim';
} else if (checks.positive && config.highlightPositive) {
action = 'highlight';
} else if (config.positiveKeywords.length > 0 && !checks.positive) {
action = 'dim';
}
switch(action) {
case 'hide':
card.style.display = 'none';
filteredCount++;
break;
case 'dim':
card.style.opacity = '0.15';
card.style.filter = 'grayscale(100%) blur(1px)';
break;
case 'highlight':
card.style.border = '2px solid #10b981';
card.style.boxShadow = '0 0 15px rgba(16, 185, 129, 0.3)';
card.style.transform = 'scale(1.02)';
card.style.zIndex = '10';
addBadge(card, t('badgeMatch'), '#10b981');
break;
}
if (checks.negative && action !== 'hide') {
addBadge(card, t('badgeBlocked'), '#ef4444');
}
});
updateStats();
}
function addBadge(card, text, color) {
const badge = document.createElement('div');
badge.className = 'hf-filter-badge';
badge.textContent = text;
badge.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
background: ${color};
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: bold;
z-index: 100;
pointer-events: none;
font-family: system-ui, -apple-system, sans-serif;
`;
card.style.position = 'relative';
card.appendChild(badge);
}
function updateStats() {
const statsEl = document.getElementById('hf-filter-stats');
if (!statsEl || !config.showStats) return;
const visible = totalCount - filteredCount;
statsEl.innerHTML = `
<span style="color: #6b7280;">${t('total')}: ${totalCount}</span>
<span style="color: #10b981; margin-left: 8px;">${t('visible')}: ${visible}</span>
<span style="color: #ef4444; margin-left: 8px;">${t('filtered')}: ${filteredCount}</span>
`;
}
// ==========================================
// INTERFAZ DE USUARIO
// ==========================================
function createUI() {
const oldUI = document.getElementById('hf-filter-panel');
if (oldUI) oldUI.remove();
const tipsHtml = t('tips').map(tip => `<li>${tip}</li>`).join('');
const localeOptions = ['en', 'zh', 'es'].map(code =>
`<option value="${code}" ${config.locale === code ? 'selected' : ''}>${t('lang' + code.charAt(0).toUpperCase() + code.slice(1))}</option>`
).join('');
const langOptions = `<option value="">— ${t('selectLang')} —</option>`
+ AVAILABLE_LANGS.map(l =>
`<option value="${l.code}" ${config.autoTranslateLang === l.code ? 'selected' : ''}>${l.name}</option>`
).join('');
const panel = document.createElement('div');
panel.id = 'hf-filter-panel';
panel.innerHTML = `
<div id="hf-filter-header">
<span>${t('panelTitle')}</span>
<button id="hf-filter-toggle" title="${t('minimizeTitle')}">−</button>
</div>
<div id="hf-filter-body">
<div class="hf-lang-selector">
<label class="hf-label">${t('langLabel')}</label>
<select id="hf-lang" ${config.autoTranslate ? 'disabled' : ''}>${localeOptions}</select>
</div>
<div class="hf-section">
<label class="hf-label">${t('positiveLabel')}</label>
<textarea id="hf-positive" placeholder="${t('positivePlaceholder')}">${config.positiveKeywords.join(', ')}</textarea>
<div class="hf-hint">${t('positiveHint')}</div>
</div>
<div class="hf-section">
<label class="hf-label">${t('negativeLabel')}</label>
<textarea id="hf-negative" placeholder="${t('negativePlaceholder')}">${config.negativeKeywords.join(', ')}</textarea>
<div class="hf-hint">${t('negativeHint')}</div>
</div>
<div class="hf-options">
<label class="hf-checkbox">
<input type="checkbox" id="hf-hide-negative" ${config.hideOnNegative ? 'checked' : ''}>
<span>${t('hideNegative')}</span>
</label>
<label class="hf-checkbox">
<input type="checkbox" id="hf-dim-negative" ${config.dimOnNegative ? 'checked' : ''}>
<span>${t('dimNegative')}</span>
</label>
<label class="hf-checkbox">
<input type="checkbox" id="hf-highlight" ${config.highlightPositive ? 'checked' : ''}>
<span>${t('highlightPositive')}</span>
</label>
<label class="hf-checkbox">
<input type="checkbox" id="hf-case" ${config.caseSensitive ? 'checked' : ''}>
<span>${t('caseSensitive')}</span>
</label>
<label class="hf-checkbox">
<input type="checkbox" id="hf-word" ${config.matchFullWord ? 'checked' : ''}>
<span>${t('matchFullWord')}</span>
</label>
<hr class="hf-options-divider">
<label class="hf-checkbox">
<input type="checkbox" id="hf-auto-translate" ${config.autoTranslate ? 'checked' : ''}>
<span>${t('autoTranslateLabel')}</span>
</label>
<div id="hf-auto-lang-wrap" style="padding-left: 24px; ${config.autoTranslate ? '' : 'display: none;'}">
<select id="hf-auto-lang" class="hf-auto-lang-select">${langOptions}</select>
<span id="hf-auto-trans-status" style="display: ${isTranslating ? 'inline' : 'none'}; color: #6b7280; font-size: 12px; margin-left: 8px;">⏳ ${t('translating')}</span>
</div>
<div class="hf-hint" style="padding-left: 24px;">${t('autoTranslateNote')}</div>
</div>
<div id="hf-filter-stats"></div>
<div class="hf-buttons">
<button id="hf-apply" class="hf-btn hf-btn-primary">${t('applyBtn')}</button>
<button id="hf-reset" class="hf-btn hf-btn-secondary">${t('resetBtn')}</button>
</div>
<div class="hf-tips">
<strong>${t('tipsTitle')}</strong>
<ul>${tipsHtml}</ul>
</div>
</div>
`;
document.body.appendChild(panel);
applyStyles();
attachEvents();
makeDraggable(panel);
updateStats();
}
function applyStyles() {
GM_addStyle(`
#hf-filter-panel {
position: fixed;
top: ${config.position.y}px;
left: ${config.position.x}px;
width: 320px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: #1f2937;
overflow: hidden;
transition: opacity 0.3s, transform 0.3s;
}
#hf-filter-panel.minimized {
width: auto;
min-width: 180px;
}
#hf-filter-panel.minimized #hf-filter-body {
display: none;
}
#hf-filter-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
font-weight: 600;
font-size: 14px;
}
#hf-filter-toggle {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
#hf-filter-toggle:hover {
background: rgba(255,255,255,0.3);
}
#hf-filter-body {
padding: 16px;
max-height: 70vh;
overflow-y: auto;
}
.hf-lang-selector {
margin-bottom: 16px;
}
.hf-lang-selector select {
width: 100%;
padding: 8px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fafafa;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.hf-lang-selector select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.hf-lang-selector select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hf-trans-status {
display: block;
font-size: 11px;
margin-top: 4px;
}
.hf-trans-loading {
color: #f59e0b;
}
.hf-trans-active {
color: #10b981;
}
.hf-auto-lang-select {
width: 100%;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 12px;
background: white;
color: #1f2937;
margin-top: 6px;
cursor: pointer;
}
.hf-auto-lang-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.hf-section {
margin-bottom: 16px;
}
.hf-label {
display: block;
font-weight: 600;
margin-bottom: 6px;
color: #374151;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.hf-section textarea {
width: 100%;
min-height: 60px;
padding: 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 13px;
resize: vertical;
font-family: inherit;
background: #fafafa;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.hf-section textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
background: white;
}
.hf-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.hf-options-divider {
border: none;
border-top: 1px solid #e5e7eb;
margin: 8px 0;
}
.hf-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
}
.hf-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: #4b5563;
}
.hf-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #667eea;
cursor: pointer;
}
#hf-filter-stats {
text-align: center;
padding: 8px;
background: #f3f4f6;
border-radius: 8px;
margin-bottom: 12px;
font-size: 12px;
font-weight: 500;
}
.hf-buttons {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.hf-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.hf-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.hf-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.hf-btn-secondary {
background: #e5e7eb;
color: #374151;
}
.hf-btn-secondary:hover {
background: #d1d5db;
}
.hf-tips {
font-size: 11px;
color: #6b7280;
background: #fef3c7;
padding: 10px;
border-radius: 8px;
border-left: 3px solid #f59e0b;
}
.hf-tips strong {
color: #92400e;
}
.hf-tips ul {
margin: 4px 0 0 16px;
padding: 0;
}
.hf-tips li {
margin-bottom: 2px;
}
#hf-filter-body::-webkit-scrollbar {
width: 6px;
}
#hf-filter-body::-webkit-scrollbar-track {
background: transparent;
}
#hf-filter-body::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
@media (prefers-color-scheme: dark) {
#hf-filter-panel {
background: rgba(17, 24, 39, 0.95);
color: #f3f4f6;
border-color: rgba(255,255,255,0.1);
}
.hf-section textarea {
background: #1f2937;
border-color: #374151;
color: #f3f4f6;
}
.hf-section textarea:focus {
background: #111827;
}
.hf-label {
color: #d1d5db;
}
.hf-lang-selector select {
background: #1f2937;
border-color: #374151;
color: #f3f4f6;
}
.hf-lang-selector select:focus {
background: #111827;
}
.hf-options {
background: #1f2937;
}
.hf-options-divider {
border-color: #374151;
}
.hf-checkbox {
color: #d1d5db;
}
#hf-filter-stats {
background: #1f2937;
color: #d1d5db;
}
.hf-btn-secondary {
background: #374151;
color: #f3f4f6;
}
.hf-btn-secondary:hover {
background: #4b5563;
}
.hf-tips {
background: rgba(245, 158, 11, 0.15);
color: #e5e7eb;
}
.hf-tips strong {
color: #fbbf24;
}
.hf-auto-lang-select {
background: #1f2937;
color: #f3f4f6;
border-color: #374151;
}
.hf-auto-lang-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}
}
`);
}
function rebuildUI() {
createUI();
}
function attachEvents() {
const panel = document.getElementById('hf-filter-panel');
document.getElementById('hf-filter-toggle').addEventListener('click', () => {
panel.classList.toggle('minimized');
const btn = document.getElementById('hf-filter-toggle');
btn.textContent = panel.classList.contains('minimized') ? '+' : '−';
});
document.getElementById('hf-lang').addEventListener('change', (e) => {
config.locale = e.target.value;
saveConfig();
rebuildUI();
});
document.getElementById('hf-auto-translate').addEventListener('change', (e) => {
if (e.target.checked) {
config.autoTranslate = true;
if (!config.autoTranslateLang) {
config.autoTranslateLang = getBrowserLangCode();
}
saveConfig();
rebuildUI();
if (config.autoTranslateLang) {
doAutoTranslate();
}
} else {
config.autoTranslate = false;
saveConfig();
rebuildUI();
}
});
document.getElementById('hf-auto-lang').addEventListener('change', (e) => {
config.autoTranslateLang = e.target.value;
saveConfig();
if (e.target.value) {
isTranslating = false;
doAutoTranslate();
}
});
document.getElementById('hf-apply').addEventListener('click', () => {
const posText = document.getElementById('hf-positive').value;
const negText = document.getElementById('hf-negative').value;
config.positiveKeywords = posText.split(',').map(s => s.trim()).filter(Boolean);
config.negativeKeywords = negText.split(',').map(s => s.trim()).filter(Boolean);
config.hideOnNegative = document.getElementById('hf-hide-negative').checked;
config.dimOnNegative = document.getElementById('hf-dim-negative').checked;
config.highlightPositive = document.getElementById('hf-highlight').checked;
config.caseSensitive = document.getElementById('hf-case').checked;
config.matchFullWord = document.getElementById('hf-word').checked;
saveConfig();
applyFilter();
const btn = document.getElementById('hf-apply');
const originalText = btn.textContent;
btn.textContent = t('applyDone');
btn.style.background = '#10b981';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
});
document.getElementById('hf-reset').addEventListener('click', () => {
document.getElementById('hf-positive').value = '';
document.getElementById('hf-negative').value = '';
document.getElementById('hf-hide-negative').checked = true;
document.getElementById('hf-dim-negative').checked = false;
document.getElementById('hf-highlight').checked = true;
document.getElementById('hf-case').checked = false;
document.getElementById('hf-word').checked = false;
config = { ...defaultConfig, locale: config.locale, autoTranslate: config.autoTranslate, autoTranslateLang: config.autoTranslateLang };
saveConfig();
applyFilter();
const cards = getModelCards();
cards.forEach(card => {
card.style.display = '';
card.style.opacity = '1';
card.style.filter = 'none';
card.style.transform = 'scale(1)';
card.style.border = 'none';
card.style.boxShadow = '';
const badge = card.querySelector('.hf-filter-badge');
if (badge) badge.remove();
});
updateStats();
});
let debounceTimer;
['hf-positive', 'hf-negative'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
if (!config.autoFilter) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
document.getElementById('hf-apply').click();
}, 800);
});
});
document.querySelectorAll('.hf-options input[type="checkbox"]').forEach(cb => {
if (cb.id === 'hf-auto-translate') return;
cb.addEventListener('change', () => {
document.getElementById('hf-apply').click();
});
});
}
function makeDraggable(element) {
const header = element.querySelector('#hf-filter-header');
let isDragging = false;
let startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
element.style.transition = 'none';
header.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
element.style.left = `${startLeft + dx}px`;
element.style.top = `${startTop + dy}px`;
element.style.right = 'auto';
element.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
header.style.cursor = 'move';
element.style.transition = 'opacity 0.3s, transform 0.3s';
const rect = element.getBoundingClientRect();
config.position = { x: rect.left, y: rect.top };
saveConfig();
}
});
}
// ==========================================
// OBSERVADOR PARA INFINITE SCROLL
// ==========================================
function setupObserver() {
const observer = new MutationObserver((mutations) => {
let shouldFilter = false;
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 && (
node.matches?.('article.overview-card-wrapper') ||
node.querySelector?.('article.overview-card-wrapper')
)) {
shouldFilter = true;
}
});
});
if (shouldFilter) {
setTimeout(applyFilter, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// ==========================================
// INICIALIZACIÓN
// ==========================================
function init() {
loadConfig();
loadTranslationCache();
createUI();
setupObserver();
if (config.positiveKeywords.length > 0 || config.negativeKeywords.length > 0) {
setTimeout(applyFilter, 500);
}
if (config.autoTranslate) {
doAutoTranslate();
}
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(() => {
applyFilter();
}, 1000);
}
}).observe(document, { subtree: true, childList: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
setTimeout(init, 2000);
})();