Pixiv Tag Filter (User Page) + Progreso + Export/Import + Multilenguaje

Filtrado de ilustraciones por tags en páginas de usuario de Pixiv. Incluye progreso visual, exportar/importar lista de tags y soporte español/inglés. Optimizado para evitar penalizaciones del sitio.

// ==UserScript==
// @name         Pixiv Tag Filter (User Page) + Progreso + Export/Import + Multilenguaje
// @namespace    http://tampermonkey.net/
// @version      2025-04-19.2
// @description  Filtrado de ilustraciones por tags en páginas de usuario de Pixiv. Incluye progreso visual, exportar/importar lista de tags y soporte español/inglés. Optimizado para evitar penalizaciones del sitio.
// @author       Luis
// @match        https://www.pixiv.net/en/users/*/artworks*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant        GM_xmlhttpRequest
// @connect      pixiv.net
// ==/UserScript==

(function () {
    'use strict';

    const lang = navigator.language.startsWith('en') ? 'en' : 'es';
    const t = {
        es: {
            editTags: '✏️ Editar tags',
            filter: '🚫 Filtrar contenido',
            export: '📤 Exportar tags',
            import: '📥 Importar tags',
            prompt: 'Tags a excluir separados por comas:',
            updated: 'Lista actualizada.',
            filtering: (a, b) => `🔍 Filtrando ${a}/${b}...`,
            done: (n) => `✅ Filtrado completado. Coincidencias excluidas: ${n}`,
            importSuccess: 'Lista importada correctamente.',
            importError: 'Archivo inválido o con error.'
        },
        en: {
            editTags: '✏️ Edit tags',
            filter: '🚫 Filter content',
            export: '📤 Export tags',
            import: '📥 Import tags',
            prompt: 'Tags to exclude (comma-separated):',
            updated: 'Exclusion list updated.',
            filtering: (a, b) => `🔍 Filtering ${a}/${b}...`,
            done: (n) => `✅ Filtering complete. Matches excluded: ${n}`,
            importSuccess: 'List imported successfully.',
            importError: 'Invalid or broken file.'
        }
    }[lang];

    const STORAGE_KEY = 'pixivTagExclusions';
    const MAX_CONCURRENT = 4;
    const MIN_DELAY = 200;
    const MAX_DELAY = 400;

    function getExclusionList() {
        const raw = localStorage.getItem(STORAGE_KEY);
        return raw ? JSON.parse(raw) : [];
    }

    function saveExclusionList(list) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
    }

    function shuffle(array) {
        for (let i = array.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [array[i], array[j]] = [array[j], array[i]];
        }
        return array;
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function delayRandom() {
        return sleep(MIN_DELAY + Math.random() * (MAX_DELAY - MIN_DELAY));
    }

    function createButton(text, topOffset, onClick) {
        const btn = document.createElement('button');
        btn.textContent = text;
        btn.style.position = 'fixed';
        btn.style.top = `${topOffset}px`;
        btn.style.right = '20px';
        btn.style.zIndex = '9999';
        btn.style.padding = '8px';
        btn.style.background = '#1e90ff';
        btn.style.color = 'white';
        btn.style.border = 'none';
        btn.style.borderRadius = '5px';
        btn.style.cursor = 'pointer';
        btn.addEventListener('click', onClick);
        document.body.appendChild(btn);
    }

    function showLoadingMessage(text) {
        let div = document.getElementById('pixiv-filter-msg');
        if (!div) {
            div = document.createElement('div');
            div.id = 'pixiv-filter-msg';
            div.style.position = 'fixed';
            div.style.top = '50%';
            div.style.left = '50%';
            div.style.transform = 'translate(-50%, -50%)';
            div.style.zIndex = '99999';
            div.style.backgroundColor = 'yellow';
            div.style.padding = '15px 25px';
            div.style.fontSize = '20px';
            div.style.boxShadow = '0 0 10px black';
            div.style.borderRadius = '10px';
            document.body.appendChild(div);
        }
        div.textContent = text;
    }

    function hideLoadingMessage(delay = 3000) {
        const div = document.getElementById('pixiv-filter-msg');
        if (div) {
            setTimeout(() => div.remove(), delay);
        }
    }

    const cache = new Map();

    function fetchTagsFromArtwork(id) {
        return new Promise(resolve => {
            if (cache.has(id)) return resolve(cache.get(id));

            delayRandom().then(() => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://www.pixiv.net/ajax/illust/${id}`,
                    responseType: 'json',
                    headers: {
                        'Referer': 'https://www.pixiv.net/'
                    },
                    onload: res => {
                        try {
                            const tags = res.response.body.tags.tags.map(t => t.tag.toLowerCase());
                            cache.set(id, tags);
                            resolve(tags);
                        } catch (err) {
                            console.error(`[${id}] Error parseando`, err);
                            resolve([]);
                        }
                    },
                    onerror: err => {
                        console.error(`[${id}] Error de red`, err);
                        resolve([]);
                    }
                });
            });
        });
    }

    async function fetchTagsAndFilter() {
        const excludeList = getExclusionList().map(t => t.toLowerCase());
        const thumbs = Array.from(document.querySelectorAll('a[href*="/artworks/"]'))
            .filter((a, i, arr) => arr.findIndex(b => b.href === a.href) === i);

        const queue = shuffle(thumbs.map(thumb => {
            const match = thumb.href.match(/artworks\/(\d+)/);
            return match ? { id: match[1], thumb } : null;
        }).filter(Boolean));

        let index = 0, active = 0, matched = 0, total = queue.length;

        showLoadingMessage(t.filtering(0, total));

        return new Promise(resolve => {
            function next() {
                if (index >= queue.length && active === 0) {
                    showLoadingMessage(t.done(matched));
                    hideLoadingMessage();
                    resolve();
                    return;
                }

                while (active < MAX_CONCURRENT && index < queue.length) {
                    const { id, thumb } = queue[index++];
                    active++;

                    fetchTagsFromArtwork(id).then(tags => {
                        const match = tags.some(tag => excludeList.includes(tag));
                        if (match) {
                            const li = thumb.closest('li');
                            if (li) li.style.display = 'none';
                            matched++;
                        }
                    }).finally(() => {
                        active--;
                        showLoadingMessage(t.filtering(index, total));
                        if (index % 10 === 0) {
                            sleep(2000 + Math.random() * 2000).then(next);
                        } else {
                            next();
                        }
                    });
                }
            }
            next();
        });
    }

    function exportTags() {
        const data = JSON.stringify(getExclusionList(), null, 2);
        const blob = new Blob([data], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'pixiv_tag_exclusions.json';
        a.click();
        URL.revokeObjectURL(url);
    }

    function importTags() {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json,application/json';
        input.addEventListener('change', () => {
            const file = input.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = () => {
                try {
                    const list = JSON.parse(reader.result);
                    if (Array.isArray(list)) {
                        saveExclusionList(list.map(t => t.trim()).filter(Boolean));
                        alert(t.importSuccess);
                    } else {
                        alert(t.importError);
                    }
                } catch {
                    alert(t.importError);
                }
            };
            reader.readAsText(file);
        });
        input.click();
    }

    window.addEventListener('load', () => {
        createButton(t.editTags, 100, () => {
            const input = prompt(t.prompt, getExclusionList().join(', '));
            if (input !== null) {
                const list = input.split(',').map(t => t.trim()).filter(Boolean);
                saveExclusionList(list);
                alert(t.updated);
            }
        });

        createButton(t.filter, 150, fetchTagsAndFilter);
        createButton(t.export, 200, exportTags);
        createButton(t.import, 250, importTags);
    });
})();