Discourse AI

최근 머스크가 트위터 추천 알고리즘을 오픈소스로 공개했는데, 이 플러그인에서 구현할 수 있을까요?
아래는 특정 포럼 사용자가 작성한 Tampermonkey 스크립트로, 트위터 추천 알고리즘을 구현한 것입니다. 이론상으로는 어떤 Discourse 포럼에서도 사용할 수 있습니다:

// ==UserScript==
// @name         *** Algorithm
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  X 알고리즘과 DeepSeek AI를 결합한 지능형 추천 시스템, 개인화된 주제 추천 제공
// @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';

    // ========== 알고리즘 설정 파라미터 (조정 가능) ==========
    const CONFIG = {
        // 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,

        // 점수 최대값 파라미터
        X_SCORE_MAX: 45,
        AI_SCORE_MAX: 55,

        // 점수 임계값 파라미터
        MAX_SCORE: 100,
        MIN_DISPLAY_SCORE: 20,

        // 캐시 파라미터
        PROFILE_CACHE_TTL: 86400000,      // 사용자 프로필 캐시: 24시간
        TOPIC_SCORE_CACHE_TTL: 3600000,   // 개별 주제 AI 점수 캐시: 1시간

        // API 파라미터
        MAX_TOPICS_PER_BATCH: 30,
        MAX_LIKED_TITLES: 15,       // 좋아요 누른 주제 수
        MAX_REPLIED_TITLES: 10,     // 답글 단 주제 수
        MAX_CREATED_TITLES: 5,      // 생성한 주제 수
        API_RETRY_COUNT: 2,
        API_RETRY_DELAY: 1000,

        // 사용자 프로필 분석 차원
        PROFILE_CATEGORIES: ['기술 관심사', '콘텐츠 선호도', '상호작용 습관', '전문 분야', '읽기 깊이'],
    };

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

    // ========== 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;
        }
    `);

    // ========== 주제 AI 점수 캐시 함수 ==========
    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);
        }
    }

    // ========== 추천 탭 주입 ==========
    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)">추천</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');
            }
        });
    }

    // ========== 로딩 표시 ==========
    let originalTableContent = null;

    function showLoading(show, text = 'X 알고리즘과 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;
            }
        }
    }

    // ========== 제목 추출을 위한 헬퍼 함수 ==========
    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') : '';
    }

    // ========== 최적화: 더 상세한 사용자 프로필 가져오기 ==========
    async function fetchUserProfile(username) {
        if (!username) {
            console.log('[추천] 로그인되지 않음, 기본 프로필 사용');
            return "기본";
        }

        const cacheKey = `user_profile_v9_${username}`;
        const cached = GM_getValue(cacheKey);
        if (cached && (Date.now() - cached.time < CONFIG.PROFILE_CACHE_TTL)) {
            console.log(`[추천] 사용자 프로필(캐시):\n${cached.profile}`);
            return cached.profile;
        }

        try {
            // 다양한 사용자 행동 데이터 가져오기
            const [likedData, repliedData, createdData] = await Promise.all([
                fetch(`/user_actions.json?username=${username}&filter=1`).then(r => r.json()).catch(() => null), // 좋아요
                fetch(`/user_actions.json?username=${username}&filter=5`).then(r => r.json()).catch(() => null), // 답변
                fetch(`/user_actions.json?username=${username}&filter=4`).then(r => r.json()).catch(() => null), // 생성
            ]);

            //不同类型的 제목 추출
            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 "기본";

            // 더 상세한 분석 프롬프트 구성
            const prompt = `당신은 전문적인 사용자 행동 분석가입니다. 다음 사용자 행동 데이터에 기반하여 상세한 사용자 프로필을 생성해 주세요:\n\n**사용자가 좋아요를 누른 주제**(관심사를 가장 잘 반영):\n${likedTitles || '데이터 없음'}\n\n**사용자가 답변한 주제**(참여도와 전문 분야를 반영):\n${repliedTitles || '데이터 없음'}\n\n**사용자가 생성한 주제**(능동적인 관심사를 반영):\n${createdTitles || '데이터 없음'}\n\n다음 차원에서 사용자 프로필을 분석하고, 각 차원을 한 문장으로 요약해 주세요:\n1. **기술 관심사**: 사용자가 어떤 기술 스택, 도구 또는 플랫폼에 관심을 보이는지\n2. **콘텐츠 선호도**: 튜토리얼, 토론, 뉴스, Q&A 중 어떤 유형을 선호하는지\n3. **상호작용 습관**: 심도 있는 참여자인지, 아니면 얕게 훑어보는 독자인지\n4. **전문 분야**: 주로 집중하는 기술 분야 또는 산업 방향\n5. **독서 깊이**: 입문, 중급, 또는 전문가 수준의 콘텐츠를 선호하는지\n\n출력 형식(번호를 붙이지 말고 5줄로 직접 출력):\n기술 관심사: [분석 결과]\n콘텐츠 선호도: [분석 결과]\n상호작용 습관: [분석 결과]\n전문 분야: [분석 결과]\n독서 깊이: [분석 결과]`;

            const profile = await callDeepSeek(prompt, false);

            if (profile && profile.length > 0) {
                GM_setValue(cacheKey, { time: Date.now(), profile: profile });
                console.log(`[추천] 사용자 프로필:\n${profile}`);
                return profile;
            }
            return "기본";
        } catch (e) {
            console.error('[추천] 사용자 프로필 가져오기 실패:', e);
            return "기본";
        }
    }

    // ========== 추천 프로세스 시작 ==========
    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('[추천] 추천 프로세스 실패:', e);
        }
    }

    // ========== 주제 배치 점수화 ==========
    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;
            });
        }
    }

    // ========== 최적화: 더 정밀한 배치 주제 점수화 ==========
    async function getAIScoresForBatch(topics) {
        const topicList = topics.map((t, idx) =>
            `${idx + 1}. [ID:${t.id}] ${t.title}`
        ).join('\n');

        const prompt = `당신은 콘텐츠 추천 전문가입니다. 사용자 프로필을 기반으로 다음 주제들에 점수를 매겨 주세요.\n\n**사용자 프로필**:\n${userProfile}\n\n**점수 매길 주제**:\n${topicList}\n\n**점수 기준**(0-10점):\n- **9-10점**: 사용자 프로필과 매우 잘 일치, 사용자가 매우 관심 있을 가능성 높음\n- **7-8점**: 사용자 프로필과 잘 일치, 사용자가 관심 있을 가능성 높음\n- **5-6점**: 사용자 프로필과 부분적으로 일치, 사용자가 관심 있을 가능성 있음\n- **3-4점**: 사용자 프로필과 관련이 약함, 사용자가 관심 없을 가능성 높음\n- **0-2점**: 사용자 프로필과 무관하거나 반대, 사용자가 관심 없음\n\n**점수 고려 요소**:\n1. 주제 내용과 사용자 기술 관심사의 일치도\n2. 주제 유형과 사용자 콘텐츠 선호도의 일치성\n3. 주제 깊이와 사용자 독서 깊이의 적합성\n4. 주제 분야와 사용자 전문 분야의 관련성\n\nJSON 형식으로 반환하세요. 키는 주제 ID(순수 숫자), 값은 점수(0-10 숫자)입니다.\n예시: {"123": 8.5, "456": 5.0}\n\nJSON만 반환하고 다른 텍스트는 포함하지 마세요.`;

        const scores = await callDeepSeek(prompt, true);

        // 점수 클리닝 및 검증
        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;
    }

    // ========== 최적화: 더 견고한 최종 점수 계산 ==========
    function calculateFinalScores(topics, xScoresRaw, aiScoresMap) {
        const xScores = Object.values(xScoresRaw);
        if (xScores.length === 0) return;

        // 분위수 정규화를 사용하여 극단값의 영향을 방지
        const sortedX = [...xScores].sort((a, b) => a - b);
        const p5 = sortedX[Math.floor(sortedX.length * 0.05)];  // 5% 분위수
        const p95 = sortedX[Math.floor(sortedX.length * 0.95)]; // 95% 분위수
        const rangeX = p95 - p5;

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

            let xScoreNormalized;
            if (rangeX === 0) {
                xScoreNormalized = CONFIG.X_SCORE_MAX / 2;
            } else {
                // p5에서 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;

            // 최종 점수: X 알고리즘(45%) + AI 점수(55%)
            const finalScore = xScoreNormalized + aiScoreNormalized;
            scoreMap[id] = Math.round(finalScore * 10) / 10;
        });
    }

    // ========== 새 주제의 최종 점수 계산 ==========
    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;
        });
    }

    // ========== 새로 로드된 주제 처리 (스크롤 로드) ==========
    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('[추천] 새 주제 처리 실패:', e);
        }

        isProcessingNewTopics = false;
    }

    // ========== 단일 주제 데이터 가져오기 ==========
    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;
        }
    }

    // ========== 정렬 및 표시 ==========
    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);
        });
    }

    // ========== 모든 행의 배지 업데이트 ==========
    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);
            }
        });
    }

    // ========== 점수 배지 추가 ==========
    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');
            }
        }
    }

    // ========== "계산 중" 배지 추가 ==========
    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 = '⏳ 계산 중...';
            badge.classList.add('calculating');
        }
    }

    // ========== 최적화: 개선된 JSON 클리닝 로직 ==========
    function cleanJsonResponse(content) {
        if (!content) return content;

        let cleaned = content.trim();

        // 마크다운 코드 블록 마커 제거
        cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');

        // 가능한 텍스트 설명 제거
        const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
        if (jsonMatch) {
            cleaned = jsonMatch[0];
        }

        return cleaned.trim();
    }

    // ========== 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('[추천] 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('[추천] 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('[추천] API 호출 타임아웃');
                    if (retryCount < CONFIG.API_RETRY_COUNT) {
                        setTimeout(() => {
                            callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
                        }, CONFIG.API_RETRY_DELAY);
                    } else {
                        resolve(isJson ? {} : "");
                    }
                }
            });
        });
    }

    // ========== 최적화: 더 지능적인 X 알고리즘 점수화 ==========
    function calculateXScore(topic) {
        const now = new Date();
        const created = new Date(topic.created_at);
        const hoursOld = Math.max(0, (now - created) / (1000 * 60 * 60));

        // 기본 상호작용 점수
        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;

        // 상호작용률 보너스 계산 (높은 상호작용률은 더 높은 콘텐츠 품질을 의미)
        const views = topic.views || 1;
        const engagementRate = ((topic.like_count || 0) + (topic.posts_count || 0)) / views;
        const engagementBoost = 1 + Math.min(engagementRate * 10, 2); // 최대 2배 보너스

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

        // 고정(핀) 보너스
        if (topic.pinned) {
            score *= CONFIG.PINNED_BOOST;
        }

        // 시간 감쇠 (신선도)
        const timeFactor = Math.pow(hoursOld + CONFIG.TIME_DECAY_OFFSET, CONFIG.TIME_DECAY_FACTOR);

        return score / timeFactor;
    }

    // ========== 현재 사용자 정보 가져오기 ==========
    function getCurrentUserInfo() {
        try {
            // 방법 1: 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
                    };
                }
            }

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

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

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

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

            // 방법 6: 프리로드 데이터
            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;
        }
    }

    // ========== 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 });
        }
    });

    // ========== 스크립트 시작 ==========
    navObserver.observe(document.body, { childList: true, subtree: true });
    injectRecommendTab();

    console.log('[추천 시스템] 로드됨 - X 알고리즘과 DeepSeek AI 융합');

})();

다음은 discourse 포럼에서의 효과입니다:


1개의 좋아요