IA de Discourse

Recientemente, Musk abrió el código del algoritmo de recomendación de Twitter. ¿Se puede implementar esto en este plugin?
A continuación, se muestra un script de Greasemonkey escrito por un usuario de un foro que implementa el algoritmo de recomendación de Twitter. En teoría, es compatible con cualquier foro Discourse:

// ==UserScript==
// @name         *** Algorithm
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  Sistema de recomendación inteligente que fusiona el algoritmo de X y DeepSeek AI, ofreciendo recomendaciones de temas personalizadas
// @author       ***
// @match        ****
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @connect      2c2ch1u11-share-api-0.hf.space
// ==/UserScript==

(function() {
    'use strict';

    const API_KEY = '***';
    const API_URL = '***/v1/chat/completions';
    const MODEL = 'deepseek-chat';

    // ========== Parámetros de configuración del algoritmo (ajustables para optimización) ==========
    const CONFIG = {
        // Parámetros de peso del algoritmo de X
        WEIGHT_LIKES: 0.5,
        WEIGHT_REPLIES: 13.5,
        WEIGHT_VIEWS: 0.015,
        PINNED_BOOST: 2.0,
        TIME_DECAY_FACTOR: 1.5,
        TIME_DECAY_OFFSET: 2,

        // Parámetros de puntuación máxima
        X_SCORE_MAX: 45,
        AI_SCORE_MAX: 55,

        // Parámetros de umbral de puntuación
        MAX_SCORE: 100,
        MIN_DISPLAY_SCORE: 20,

        // Parámetros de caché
        PROFILE_CACHE_TTL: 86400000,      // Caché del perfil de usuario: 24 horas
        TOPIC_SCORE_CACHE_TTL: 3600000,   // Caché de puntuación AI de un solo tema: 1 hora

        // Parámetros de API
        MAX_TOPICS_PER_BATCH: 30,
        MAX_LIKED_TITLES: 15,       // Número de temas con me gusta
        MAX_REPLIED_TITLES: 10,     // Número de temas respondidos
        MAX_CREATED_TITLES: 5,      // Número de temas creados
        API_RETRY_COUNT: 2,
        API_RETRY_DELAY: 1000,

        // Dimensiones de análisis del perfil de usuario
        PROFILE_CATEGORIES: ['Intereses técnicos', 'Preferencias de contenido', 'Hábitos de interacción', 'Áreas de especialización', 'Profundidad de lectura'],
    };

    let isRecommendMode = false;
    let scoreMap = {};
    let allTopicsData = {};
    let sortTimeout = null;
    let userProfile = "";
    let isLoading = false;
    let isProcessingNewTopics = false;

    // ========== Estilos CSS ==========
    GM_addStyle(`
        .nav-pills > li > a,
        .nav-pills > li.ember-view > a,
        .navigation-container .nav-pills > li > a {
            border-bottom: 3px solid transparent !important;
            transition: all 0.2s ease;
        }

        body.recommend-mode-active .nav-pills > li:not(#nav-item-recommend) > a,
        body.recommend-mode-active .nav-pills > li.ember-view:not(#nav-item-recommend) > a,
        body.recommend-mode-active .navigation-container .nav-pills > li:not(#nav-item-recommend) > a {
            color: var(--primary-medium) !important;
            border-bottom-color: transparent !important;
        }

        .nav-pills li#nav-item-recommend.active > a,
        body.recommend-mode-active .nav-pills li#nav-item-recommend > a {
            color: var(--tertiary) !important;
            border-bottom: 3px solid var(--tertiary) !important;
            font-weight: 600;
        }

        .nav-pills li#nav-item-recommend > a:hover {
            color: var(--tertiary) !important;
            opacity: 0.8;
        }

        .recommend-loading-container {
            width: 100%;
            padding: 60px 20px;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            min-height: 300px;
        }

        .recommend-loading-text {
            color: var(--primary-medium);
            font-size: 15px;
            margin-bottom: 24px;
            text-align: center;
        }

        .recommend-spinner {
            width: 36px;
            height: 36px;
            border: 3px solid var(--primary-low);
            border-top: 3px solid var(--primary-medium);
            border-radius: 50%;
            animation: spin 0.8s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        .manus-score-badge {
            font-size: 0.75em;
            color: var(--tertiary);
            margin-left: 8px;
            padding: 2px 6px;
            background: var(--tertiary-very-low);
            border-radius: 4px;
            font-weight: 500;
            transition: all 0.3s ease;
        }

        .manus-score-badge.calculating {
            color: var(--primary-medium);
            background: var(--primary-very-low);
            animation: pulse 1.5s ease-in-out infinite;
        }

        @keyframes pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.5; }
        }

        .manus-score-loading {
            font-size: 0.75em;
            color: var(--primary-medium);
            margin-left: 8px;
        }

        .recommend-full-overlay {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: var(--secondary);
            z-index: 100;
            min-height: 500px;
        }

        .recommend-loading-row {
            position: relative;
            z-index: 101;
        }
    `);

    // ========== Funciones de caché de puntuación AI de temas ==========
    function getTopicScoreCache() {
        return GM_getValue('topic_ai_scores_cache', {});
    }

    function setTopicScoreCache(topicId, aiScore) {
        const cache = getTopicScoreCache();
        cache[topicId] = { score: aiScore, time: Date.now() };
        GM_setValue('topic_ai_scores_cache', cache);
    }

    function getCachedTopicScore(topicId) {
        const cache = getTopicScoreCache();
        const entry = cache[topicId];
        if (entry && (Date.now() - entry.time < CONFIG.TOPIC_SCORE_CACHE_TTL)) {
            return entry.score;
        }
        return null;
    }

    function cleanExpiredCache() {
        const cache = getTopicScoreCache();
        const now = Date.now();
        let cleaned = false;
        for (const [id, entry] of Object.entries(cache)) {
            if (now - entry.time > CONFIG.TOPIC_SCORE_CACHE_TTL) {
                delete cache[id];
                cleaned = true;
            }
        }
        if (cleaned) {
            GM_setValue('topic_ai_scores_cache', cache);
        }
    }

    // ========== Inyectar pestaña de recomendaciones ==========
    function injectRecommendTab() {
        if (document.querySelector('#nav-item-recommend')) return;
        const navPills = document.querySelector('.nav-pills');
        if (!navPills) return;

        const recommendTab = document.createElement('li');
        recommendTab.id = 'nav-item-recommend';
        recommendTab.className = 'ember-view nav-item_recommend';
        recommendTab.innerHTML = `<a href="javascript:void(0)">Recomendados</a>`;

        const latestTab = navPills.querySelector('.nav-item_latest') ||
                          navPills.querySelector('[data-filter-type="latest"]')?.parentElement ||
                          navPills.firstChild;
        navPills.insertBefore(recommendTab, latestTab);

        recommendTab.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();

            if (isLoading) return;

            isRecommendMode = true;
            document.body.classList.add('recommend-mode-active');

            navPills.querySelectorAll('li').forEach(li => {
                li.classList.remove('active');
                li.querySelector('a')?.classList.remove('active');
            });
            recommendTab.classList.add('active');

            cleanExpiredCache();

            showLoading(true);
            await startRecommendation();
            showLoading(false);

            sortAndDisplayRows();
        });

        navPills.querySelectorAll('li:not(#nav-item-recommend)').forEach(tab => {
            tab.addEventListener('click', () => {
                isRecommendMode = false;
                document.body.classList.remove('recommend-mode-active');
                recommendTab.classList.remove('active');
                document.querySelectorAll('.manus-score-badge').forEach(b => b.remove());
                document.querySelectorAll('.manus-score-loading').forEach(b => b.remove());
            });
        });

        window.addEventListener('popstate', () => {
            if (isRecommendMode) {
                isRecommendMode = false;
                document.body.classList.remove('recommend-mode-active');
                recommendTab.classList.remove('active');
            }
        });
    }

    // ========== Indicador de carga ==========
    let originalTableContent = null;

    function showLoading(show, text = 'Fusionando el algoritmo de X y las recomendaciones de DeepSeek AI...') {
        isLoading = show;

        const topicListContainer = document.querySelector('.topic-list-container') ||
                                   document.querySelector('.topic-list')?.parentElement ||
                                   document.querySelector('.topic-list');
        const tbody = document.querySelector('.topic-list tbody');

        if (!topicListContainer) return;

        if (show) {
            if (!originalTableContent && tbody) {
                originalTableContent = tbody.innerHTML;
            }

            if (tbody) {
                tbody.innerHTML = `
                    <tr class="recommend-loading-row">
                        <td colspan="100%">
                            <div class="recommend-loading-container">
                                <div class="recommend-loading-text">${text}</div>
                                <div class="recommend-spinner"></div>
                            </div>
                        </td>
                    </tr>
                `;
            }

            topicListContainer.style.position = 'relative';

            let overlay = topicListContainer.querySelector('.recommend-full-overlay');
            if (!overlay) {
                overlay = document.createElement('div');
                overlay.className = 'recommend-full-overlay';
                topicListContainer.appendChild(overlay);
            }
        } else {
            topicListContainer.querySelector('.recommend-full-overlay')?.remove();

            if (originalTableContent && tbody) {
                tbody.innerHTML = originalTableContent;
                originalTableContent = null;
            }
        }
    }

    // ========== Función auxiliar para extraer títulos ==========
    function extractTitles(data, maxCount) {
        if (!data?.user_actions) return '';

        const titles = [...new Set(
            data.user_actions
                .filter(a => a.title)
                .map(a => a.title)
        )].slice(0, maxCount);

        return titles.length > 0 ? titles.join('\n') : '';
    }

    // ========== Optimización: Obtención de perfil de usuario más detallada ==========
    async function fetchUserProfile(username) {
        if (!username) {
            console.log('[Recomendación] No conectado, usando perfil por defecto');
            return "Por defecto";
        }

        const cacheKey = `user_profile_v9_${username}`;
        const cached = GM_getValue(cacheKey);
        if (cached && (Date.now() - cached.time < CONFIG.PROFILE_CACHE_TTL)) {
            console.log(`[Recomendación] Perfil de usuario (caché):\n${cached.profile}`);
            return cached.profile;
        }

        try {
            // Obtener varios tipos de datos de comportamiento de usuario
            const [likedData, repliedData, createdData] = await Promise.all([
                fetch(`/user_actions.json?username=${username}&filter=1`).then(r => r.json()).catch(() => null), // Me gusta
                fetch(`/user_actions.json?username=${username}&filter=5`).then(r => r.json()).catch(() => null), // Respuestas
                fetch(`/user_actions.json?username=${username}&filter=4`).then(r => r.json()).catch(() => null), // Creación
            ]);

            // Extraer diferentes tipos de títulos
            const likedTitles = extractTitles(likedData, CONFIG.MAX_LIKED_TITLES);
            const repliedTitles = extractTitles(repliedData, CONFIG.MAX_REPLIED_TITLES);
            const createdTitles = extractTitles(createdData, CONFIG.MAX_CREATED_TITLES);

            if (!likedTitles && !repliedTitles && !createdTitles) return "Por defecto";

            // Construir prompt de análisis más detallado
            const prompt = `Eres un analista profesional de comportamiento de usuarios. Por favor, genera un perfil de usuario detallado basado en los siguientes datos de comportamiento:

**Temas que al usuario le gustaron** (reflejan mejor los intereses):
${likedTitles || 'Sin datos'}

**Temas a los que el usuario respondió** (reflejan participación y campo profesional):
${repliedTitles || 'Sin datos'}

**Temas creados por el usuario** (reflejan puntos de atención activa):
${createdTitles || 'Sin datos'}

Por favor, analiza el perfil del usuario desde las siguientes dimensiones, resumiendo cada una en una frase:
1. **Interés técnico**: Qué stacks tecnológicos, herramientas o plataformas sigue el usuario
2. **Preferencia de contenido**: Prefiere tutoriales, discusiones, noticias o contenido de preguntas y respuestas
3. **Hábitos de interacción**: Es un participante profundo o un navegador superficial
4. **Campo profesional**: Principales áreas tecnológicas o direcciones de la industria en las que se enfoca
5. **Profundidad de lectura**: Prefiere contenido de nivel introductorio, avanzado o experto

Formato de salida (sin numeración, salida directa de 5 líneas):
Interés técnico: [Resultado del análisis]
Preferencia de contenido: [Resultado del análisis]
Hábitos de interacción: [Resultado del análisis]
Campo profesional: [Resultado del análisis]
Profundidad de lectura: [Resultado del análisis]`;

            const profile = await callDeepSeek(prompt, false);

            if (profile && profile.length > 0) {
                GM_setValue(cacheKey, { time: Date.now(), profile: profile });
                console.log(`[Recomendación] Perfil de usuario:\n${profile}`);
                return profile;
            }
            return "Por defecto";
        } catch (e) {
            console.error('[Recomendación] Error al obtener el perfil de usuario:', e);
            return "Por defecto";
        }
    }

    // ========== Iniciar proceso de recomendación ==========
    async function startRecommendation() {
        try {
            const currentUser = getCurrentUserInfo();

            if (!userProfile) {
                userProfile = await fetchUserProfile(currentUser?.username);
            }

            const response = await fetch('/latest.json');
            const data = await response.json();
            const topics = data.topic_list.topics;

            topics.forEach(t => {
                allTopicsData[t.id] = t;
            });

            const uncachedTopics = [];
            const xScoresRaw = {};
            const aiScoresMap = {};

            topics.forEach(topic => {
                xScoresRaw[topic.id] = calculateXScore(topic);

                const cachedScore = getCachedTopicScore(topic.id);
                if (cachedScore !== null) {
                    aiScoresMap[topic.id] = cachedScore;
                } else {
                    uncachedTopics.push(topic);
                }
            });

            if (uncachedTopics.length > 0) {
                await batchScoreTopics(uncachedTopics, aiScoresMap);
            }

            calculateFinalScores(topics, xScoresRaw, aiScoresMap);

        } catch (e) {
            console.error('[Recomendación] Fallo en el proceso de recomendación:', e);
        }
    }

    // ========== Puntuación por lotes de temas ==========
    async function batchScoreTopics(topics, aiScoresMap) {
        for (let i = 0; i < topics.length; i += CONFIG.MAX_TOPICS_PER_BATCH) {
            const batch = topics.slice(i, i + CONFIG.MAX_TOPICS_PER_BATCH);
            const aiScores = await getAIScoresForBatch(batch);

            batch.forEach(topic => {
                const aiScore = aiScores[topic.id] || 5;
                setTopicScoreCache(topic.id, aiScore);
                aiScoresMap[topic.id] = aiScore;
            });
        }
    }

    // ========== Optimización: Puntuación por lotes de temas más precisa ==========
    async function getAIScoresForBatch(topics) {
        const topicList = topics.map((t, idx) =>
            `${idx + 1}. [ID:${t.id}] ${t.title}`
        ).join('\n');

        const prompt = `Eres un experto en recomendación de contenido. Por favor, puntúa los siguientes temas basándote en el perfil del usuario.

**Perfil del usuario**:
${userProfile}

**Temas a puntuar**:
${topicList}

**Criterios de puntuación** (0-10 puntos):
- **9-10 puntos**: Coincidencia alta con el perfil del usuario, es muy probable que le interese
- **7-8 puntos**: Coincidencia media con el perfil del usuario, es probable que le interese
- **5-6 puntos**: Coincidencia parcial con el perfil del usuario, podría interesarle
- **3-4 puntos**: Debil asociación con el perfil del usuario, es poco probable que le interese
- **0-2 puntos**: Sin relación o opuesto al perfil del usuario, no le interesa

**Factores a considerar para la puntuación**:
1. Grado de coincidencia entre el contenido del tema y los intereses técnicos del usuario
2. Consistencia entre el tipo de tema y las preferencias de contenido del usuario
3. Adecuación entre la profundidad del tema y la profundidad de lectura del usuario
4. Relevancia entre el campo del tema y el campo profesional del usuario

Por favor, devuelve el formato JSON, donde la clave sea el ID del tema (solo números) y el valor sea la puntuación (número de 0-10).
Ejemplo: {"123": 8.5, "456": 5.0}

Solo devuelve JSON, sin otro texto.`;

        const scores = await callDeepSeek(prompt, true);

        // Limpieza y validación de puntuaciones
        const cleanedScores = {};
        for (const [id, score] of Object.entries(scores)) {
            const numId = parseInt(id, 10);
            const numScore = parseFloat(score);
            if (!isNaN(numId) && !isNaN(numScore) && numScore >= 0 && numScore <= 10) {
                cleanedScores[numId] = Math.round(numScore * 10) / 10;
            }
        }

        return cleanedScores;
    }

    // ========== Optimización: Cálculo de puntuación final más robusto ==========
    function calculateFinalScores(topics, xScoresRaw, aiScoresMap) {
        const xScores = Object.values(xScoresRaw);
        if (xScores.length === 0) return;

        // Usar normalización por cuantiles para evitar la influencia de valores extremos
        const sortedX = [...xScores].sort((a, b) => a - b);
        const p5 = sortedX[Math.floor(sortedX.length * 0.05)];  // Cuantil 5%
        const p95 = sortedX[Math.floor(sortedX.length * 0.95)]; // Cuantil 95%
        const rangeX = p95 - p5;

        topics.forEach(topic => {
            const id = topic.id;

            let xScoreNormalized;
            if (rangeX === 0) {
                xScoreNormalized = CONFIG.X_SCORE_MAX / 2;
            } else {
                // Limitar al rango de p5 a p95
                const clampedX = Math.max(p5, Math.min(p95, xScoresRaw[id]));
                xScoreNormalized = ((clampedX - p5) / rangeX) * CONFIG.X_SCORE_MAX;
            }

            const aiScore = aiScoresMap[id] || 5;
            const aiScoreNormalized = (aiScore / 10) * CONFIG.AI_SCORE_MAX;

            // Puntuación final: Algoritmo X (45%) + Puntuación AI (55%)
            const finalScore = xScoreNormalized + aiScoreNormalized;
            scoreMap[id] = Math.round(finalScore * 10) / 10;
        });
    }

    // ========== Calcular puntuación final de nuevos temas ==========
    function calculateFinalScoresForNew(topicIds, xScoresRaw, aiScoresMap) {
        const allXScores = Object.values(scoreMap).length > 0
            ? [...Object.values(xScoresRaw), ...Object.keys(allTopicsData).map(id => calculateXScore(allTopicsData[id]))]
            : Object.values(xScoresRaw);

        if (allXScores.length === 0) return;

        const sortedX = [...allXScores].sort((a, b) => a - b);
        const p5 = sortedX[Math.floor(sortedX.length * 0.05)];
        const p95 = sortedX[Math.floor(sortedX.length * 0.95)];
        const rangeX = p95 - p5;

        topicIds.forEach(id => {
            if (xScoresRaw[id] === undefined) return;

            let xScoreNormalized;
            if (rangeX === 0) {
                xScoreNormalized = CONFIG.X_SCORE_MAX / 2;
            } else {
                const clampedX = Math.max(p5, Math.min(p95, xScoresRaw[id]));
                xScoreNormalized = ((clampedX - p5) / rangeX) * CONFIG.X_SCORE_MAX;
            }

            const aiScore = aiScoresMap[id] || 5;
            const aiScoreNormalized = (aiScore / 10) * CONFIG.AI_SCORE_MAX;

            const finalScore = xScoreNormalized + aiScoreNormalized;
            scoreMap[id] = Math.round(finalScore * 10) / 10;
        });
    }

    // ========== Procesar temas cargados nuevos (carga por desplazamiento) ==========
    async function processNewTopics(newRows) {
        if (!isRecommendMode || isProcessingNewTopics) return;
        isProcessingNewTopics = true;

        try {
            const newTopicIds = [];
            newRows.forEach(row => {
                const id = row.getAttribute('data-topic-id');
                if (id && !scoreMap[id]) {
                    newTopicIds.push(id);
                }
            });

            if (newTopicIds.length === 0) {
                isProcessingNewTopics = false;
                return;
            }

            newTopicIds.forEach(id => {
                const row = document.querySelector(`tr[data-topic-id="${id}"]`);
                if (row) {
                    addCalculatingBadge(row);
                }
            });

            const uncachedTopics = [];
            const xScoresRaw = {};
            const aiScoresMap = {};

            for (const id of newTopicIds) {
                const topicData = allTopicsData[id] || await fetchTopicData(id);
                if (!topicData) continue;

                allTopicsData[id] = topicData;
                xScoresRaw[id] = calculateXScore(topicData);

                const cachedScore = getCachedTopicScore(id);
                if (cachedScore !== null) {
                    aiScoresMap[id] = cachedScore;
                } else {
                    uncachedTopics.push(topicData);
                }
            }

            if (uncachedTopics.length > 0) {
                await batchScoreTopics(uncachedTopics, aiScoresMap);
            }

            calculateFinalScoresForNew(newTopicIds, xScoresRaw, aiScoresMap);

            updateRowBadges();

        } catch (e) {
            console.error('[Recomendación] Error al procesar nuevos temas:', e);
        }

        isProcessingNewTopics = false;
    }

    // ========== Obtener datos de un solo tema ==========
    async function fetchTopicData(topicId) {
        try {
            const row = document.querySelector(`tr[data-topic-id="${topicId}"]`);
            if (row) {
                const title = row.querySelector('.title')?.textContent?.trim() || '';
                const replies = parseInt(row.querySelector('.posts')?.textContent) || 0;
                const views = parseInt(row.querySelector('.views')?.textContent?.replace(/[k,]/gi, '')) || 0;
                const likes = parseInt(row.querySelector('.likes')?.textContent) || 0;

                return {
                    id: topicId,
                    title: title,
                    posts_count: replies,
                    views: views,
                    like_count: likes,
                    created_at: new Date().toISOString(),
                    pinned: row.classList.contains('pinned')
                };
            }
            return null;
        } catch (e) {
            return null;
        }
    }

    // ========== Ordenar y mostrar ==========
    function sortAndDisplayRows() {
        if (!isRecommendMode) return;
        const tbody = document.querySelector('.topic-list tbody');
        if (!tbody) return;

        const rows = Array.from(tbody.querySelectorAll('tr.topic-list-item'));

        rows.sort((a, b) => {
            const idA = a.getAttribute('data-topic-id');
            const idB = b.getAttribute('data-topic-id');
            const scoreA = scoreMap[idA] || 0;
            const scoreB = scoreMap[idB] || 0;
            return scoreB - scoreA;
        });

        let hiddenCount = 0;
        rows.forEach(row => {
            const id = row.getAttribute('data-topic-id');
            const score = scoreMap[id];

            if (score === undefined) {
                row.style.display = '';
                addCalculatingBadge(row);
            } else if (score < CONFIG.MIN_DISPLAY_SCORE) {
                row.style.display = 'none';
                hiddenCount++;
            } else {
                row.style.display = '';
                addScoreBadge(row);
            }
            tbody.appendChild(row);
        });
    }

    // ========== Actualizar insignias de todas las filas ==========
    function updateRowBadges() {
        if (!isRecommendMode) return;
        const rows = document.querySelectorAll('tr.topic-list-item');
        rows.forEach(row => {
            const id = row.getAttribute('data-topic-id');
            const score = scoreMap[id];

            if (score === undefined) {
                addCalculatingBadge(row);
            } else if (score < CONFIG.MIN_DISPLAY_SCORE) {
                row.style.display = 'none';
            } else {
                row.style.display = '';
                addScoreBadge(row);
            }
        });
    }

    // ========== Añadir insignia de puntuación ==========
    function addScoreBadge(row) {
        const id = row.getAttribute('data-topic-id');
        const score = scoreMap[id];

        row.querySelector('.manus-score-loading')?.remove();

        if (score !== undefined) {
            let badge = row.querySelector('.manus-score-badge');
            if (!badge) {
                badge = document.createElement('span');
                badge.className = 'manus-score-badge';
                const titleContainer = row.querySelector('.link-top-line') ||
                                      row.querySelector('.title')?.parentElement ||
                                      row.querySelector('.main-link') ||
                                      row.querySelector('td:first-child');
                if (titleContainer) {
                    titleContainer.appendChild(badge);
                }
            }
            if (badge) {
                badge.textContent = `🔥 ${score.toFixed(1)}`;
                badge.classList.remove('calculating');
            }
        }
    }

    // ========== Añadir insignia "Calculando" ==========
    function addCalculatingBadge(row) {
        const id = row.getAttribute('data-topic-id');

        if (scoreMap[id] !== undefined) return;

        let badge = row.querySelector('.manus-score-badge');
        if (!badge) {
            badge = document.createElement('span');
            badge.className = 'manus-score-badge calculating';
            const titleContainer = row.querySelector('.link-top-line') ||
                                  row.querySelector('.title')?.parentElement ||
                                  row.querySelector('.main-link') ||
                                  row.querySelector('td:first-child');
            if (titleContainer) {
                titleContainer.appendChild(badge);
            }
        }
        if (badge) {
            badge.textContent = '⏳ Calculando...';
            badge.classList.add('calculating');
        }
    }

    // ========== Optimización: Mejorar lógica de limpieza JSON ==========
    function cleanJsonResponse(content) {
        if (!content) return content;

        let cleaned = content.trim();

        // Eliminar marcas de bloques de código Markdown
        cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');

        // Eliminar posibles explicaciones de texto
        const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
        if (jsonMatch) {
            cleaned = jsonMatch[0];
        }

        return cleaned.trim();
    }

    // ========== Llamada API ==========
    async function callDeepSeek(prompt, isJson = true, retryCount = 0) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: API_URL,
                headers: {
                    "Content-Type": "application/json",
                    "Authorization": `Bearer ${API_KEY}`
                },
                data: JSON.stringify({
                    model: MODEL,
                    messages: [{ role: "user", content: prompt }],
                    temperature: 0.3,
                    max_tokens: 1000
                }),
                timeout: 30000,
                onload: (res) => {
                    try {
                        if (res.status >= 200 && res.status < 300) {
                            let content = JSON.parse(res.responseText).choices[0].message.content;

                            if (isJson) {
                                content = cleanJsonResponse(content);
                                resolve(JSON.parse(content));
                            } else {
                                resolve(content);
                            }
                        } else {
                            throw new Error(`HTTP ${res.status}`);
                        }
                    } catch(e) {
                        console.error('[Recomendación] Error al analizar respuesta API:', e);
                        if (retryCount < CONFIG.API_RETRY_COUNT) {
                            setTimeout(() => {
                                callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
                            }, CONFIG.API_RETRY_DELAY);
                        } else {
                            resolve(isJson ? {} : "");
                        }
                    }
                },
                onerror: (err) => {
                    console.error('[Recomendación] Fallo en llamada API:', err);
                    if (retryCount < CONFIG.API_RETRY_COUNT) {
                        setTimeout(() => {
                            callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
                        }, CONFIG.API_RETRY_DELAY);
                    } else {
                        resolve(isJson ? {} : "");
                    }
                },
                ontimeout: () => {
                    console.error('[Recomendación] Tiempo de espera agotado en llamada API');
                    if (retryCount < CONFIG.API_RETRY_COUNT) {
                        setTimeout(() => {
                            callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
                        }, CONFIG.API_RETRY_DELAY);
                    } else {
                        resolve(isJson ? {} : "");
                    }
                }
            });
        });
    }

    // ========== Optimización: Algoritmo X de puntuación más inteligente ==========
    function calculateXScore(topic) {
        const now = new Date();
        const created = new Date(topic.created_at);
        const hoursOld = Math.max(0, (now - created) / (1000 * 60 * 60));

        // Puntuación básica de interacción
        const likeScore = (topic.like_count || 0) * CONFIG.WEIGHT_LIKES;
        const replyScore = (topic.posts_count || 0) * CONFIG.WEIGHT_REPLIES;
        const viewScore = (topic.views || 0) * CONFIG.WEIGHT_VIEWS;

        // Calcular bonificación por tasa de interacción (contenido con alta tasa de interacción tiene mayor calidad)
        const views = topic.views || 1;
        const engagementRate = ((topic.like_count || 0) + (topic.posts_count || 0)) / views;
        const engagementBoost = 1 + Math.min(engagementRate * 10, 2); // Bonificación máxima de 2x

        let score = (likeScore + replyScore + viewScore) * engagementBoost;

        // Bonificación por fijado
        if (topic.pinned) {
            score *= CONFIG.PINNED_BOOST;
        }

        // Decaimiento temporal (frescura)
        const timeFactor = Math.pow(hoursOld + CONFIG.TIME_DECAY_OFFSET, CONFIG.TIME_DECAY_FACTOR);

        return score / timeFactor;
    }

    // ========== Obtener información del usuario actual ==========
    function getCurrentUserInfo() {
        try {
            // Método 1: Contenedor Discourse
            const container = window.Discourse?.__container__;
            if (container) {
                const currentUser = container.lookup('service:current-user');
                if (currentUser?.username) {
                    return {
                        username: currentUser.username,
                        id: currentUser.id,
                        name: currentUser.name,
                        trust_level: currentUser.trust_level
                    };
                }
            }

            // Método 2: User.current()
            if (window.User?.current?.()?.username) {
                const user = window.User.current();
                return { username: user.username, id: user.id };
            }

            // Método 3: #current-user
            const userLink = document.querySelector('#current-user a[data-user-card]');
            if (userLink) {
                return { username: userLink.getAttribute('data-user-card') };
            }

            // Método 4: header
            const headerUser = document.querySelector('.header-dropdown-toggle.current-user button');
            if (headerUser) {
                const img = headerUser.querySelector('img');
                if (img?.alt) {
                    return { username: img.alt };
                }
            }

            // Método 5: d-header
            const anyUserCard = document.querySelector('.d-header [data-user-card]');
            if (anyUserCard) {
                return { username: anyUserCard.getAttribute('data-user-card') };
            }

            // Método 6: Datos precargados
            const preloadData = document.querySelector('#data-preloaded');
            if (preloadData) {
                try {
                    const data = JSON.parse(preloadData.dataset.preloaded || '{}');
                    const currentUser = JSON.parse(data.currentUser || '{}');
                    if (currentUser.username) {
                        return { username: currentUser.username };
                    }
                } catch (e) {}
            }

            return null;
        } catch (e) {
            return null;
        }
    }

    // ========== Escuchar cambios en el DOM ==========
    const listObserver = new MutationObserver((mutations) => {
        if (!isRecommendMode || isLoading) return;

        const newRows = [];
        mutations.forEach(m => {
            m.addedNodes.forEach(node => {
                if (node.nodeType === 1) {
                    if (node.classList?.contains('topic-list-item')) {
                        newRows.push(node);
                    } else if (node.querySelectorAll) {
                        node.querySelectorAll('.topic-list-item').forEach(row => newRows.push(row));
                    }
                }
            });
        });

        if (newRows.length > 0) {
            newRows.forEach(row => {
                const id = row.getAttribute('data-topic-id');
                if (id && scoreMap[id] === undefined) {
                    addCalculatingBadge(row);
                } else if (scoreMap[id] !== undefined) {
                    if (scoreMap[id] >= CONFIG.MIN_DISPLAY_SCORE) {
                        addScoreBadge(row);
                    } else {
                        row.style.display = 'none';
                    }
                }
            });

            clearTimeout(sortTimeout);
            sortTimeout = setTimeout(() => processNewTopics(newRows), 500);
        }
    });

    const navObserver = new MutationObserver(() => {
        injectRecommendTab();
        const tbody = document.querySelector('.topic-list tbody');
        if (tbody && !tbody.dataset.observing) {
            tbody.dataset.observing = 'true';
            listObserver.observe(tbody, { childList: true });
        }
    });

    // ========== Iniciar script ==========
    navObserver.observe(document.body, { childList: true, subtree: true });
    injectRecommendTab();

    console.log('[Sistema de recomendación] Cargado - Fusión de Algoritmo X y DeepSeek AI');

})();

A continuación se muestra el efecto en el foro de Discourse:


1 me gusta