Backloggd RetroAchievements Integration

Integrates RetroAchievements stats directly into Backloggd profiles and game pages.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Backloggd RetroAchievements Integration
// @namespace    http://tampermonkey.net/
// @version      7.0.0
// @description  Integrates RetroAchievements stats directly into Backloggd profiles and game pages.
// @author       [smvsch]
// @match        *://*.backloggd.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @connect      retroachievements.org
// @connect      vuycmvpcepeqwuujfsdu.supabase.co
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==================== CONFIGURATION ====================
    const CONFIG = {
        PROXY_URL: "https://vuycmvpcepeqwuujfsdu.supabase.co/functions/v1/proxy-handler"
    };
    // =======================================================

    let lastUrl = window.location.href;

    // --- REACIVE INJECTION LOGIC ---
    function attemptInjection(retries = 10) {
        if (retries <= 0) return;

        const isGamePage = window.location.pathname.includes('/games/');
        const isProfilePage = window.location.pathname.startsWith('/u/');

        if (!isGamePage && !isProfilePage) return;
        if (document.getElementById('ra-integration-card')) return;

        let targetArea = document.querySelector('.row.mt-3') || document.querySelector('.card-body') || document.querySelector('main');

        if (targetArea) {
            const raContainer = document.createElement('div');
            raContainer.id = 'ra-integration-card';
            raContainer.style = 'background: #1c2025; border: 1px solid #2b3036; border-radius: 6px; margin: 20px 0; width: 100%; padding: 18px; font-family: inherit; box-sizing: border-box;';
            targetArea.parentNode.insertBefore(raContainer, targetArea.nextSibling);

            if (isProfilePage && getProfileOwner()) {
                handleProfilePage(raContainer, getProfileOwner(), getLoggedUser());
            } else if (isGamePage) {
                handleGamePage(raContainer, getLoggedUser());
            }
        } else {
            setTimeout(() => attemptInjection(retries - 1), 100);
        }
    }

    function getLoggedUser() {
        const userNav = document.querySelector('a.nav-link[href^="/u/"]');
        return userNav ? userNav.getAttribute('href').split('/')[2] : null;
    }

    function getProfileOwner() {
        const path = window.location.pathname;
        return path.startsWith('/u/') ? path.split('/')[2] : null;
    }

    // ==================== ROUTE A: GAME TRACKING ====================
    function handleGamePage(container, loggedUser) {
        const titleElement = document.querySelector('h1') || document.querySelector('.game-page-title');
        if (!titleElement) return;
        const gameTitle = titleElement.innerText.trim();
        const gamePathKey = "MANUAL_ID_" + window.location.pathname.replace(/\/games\//, '').replace(/\//g, '');

        let RA_USER = GM_getValue("RA_USERNAME", "");
        let RA_API_KEY = GM_getValue("RA_API_KEY", "");

        if (!RA_USER || !RA_API_KEY) {
            container.innerHTML = `
                <div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 8px;">RetroAchievements Global Sync</div>
                <p style="font-size: 12px; color: #9fa6af; margin: 0 0 12px 0;">Link your account once to publish your achievements to the community database seamlessly.</p>
                <div style="display: flex; gap: 10px; max-width: 500px; margin-bottom: 10px;">
                    <input type="text" id="ra-set-user" placeholder="RA Username" style="padding: 6px 12px; background: #111417; border: 1px solid #2b3036; color: #fff; border-radius: 4px; font-size: 12px; flex: 1;">
                    <input type="password" id="ra-set-key" placeholder="Web API Key" style="padding: 6px 12px; background: #111417; border: 1px solid #2b3036; color: #fff; border-radius: 4px; font-size: 12px; flex: 1;">
                    <button id="ra-save-btn" style="padding: 6px 16px; background: #e91e63; color: #fff; font-weight: 600; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">Link & Publish</button>
                </div>
                <div style="font-size: 11px;"><a href="https://retroachievements.org/controlpanel.php" target="_blank" style="color: #ff4081; text-decoration: none;">🔑 Get your Web API Key here (Keys section) ↗</a></div>
            `;
            document.getElementById('ra-save-btn').addEventListener('click', () => {
                const user = document.getElementById('ra-set-user').value.trim();
                const key = document.getElementById('ra-set-key').value.trim();
                if (user && key && loggedUser) {
                    GM_setValue("RA_USERNAME", user);
                    GM_setValue("RA_API_KEY", key);
                    publishToSupabase(loggedUser, user);
                }
            });
            return;
        }

        container.innerHTML = `
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px;">
                <div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase;">RetroAchievements Progress</div>
                <div style="display: flex; gap: 12px;">
                    <button id="ra-override-btn" style="background:none; border:none; color:#9fa6af; cursor:pointer; font-size:11px; padding:0; text-decoration: underline;">Manual Link ID</button>
                    <button id="ra-clear-btn" style="background:none; border:none; color:#ff4d4d; cursor:pointer; font-size:11px; padding:0; text-decoration: underline;">Unlink</button>
                </div>
            </div>
            <div id="ra-content" style="font-size: 13px; color: #9fa6af;">Synchronizing achievement indexes...</div>
        `;

        document.getElementById('ra-clear-btn').addEventListener('click', () => { if(confirm("Disconnect account?")) { GM_setValue("RA_USERNAME", ""); GM_setValue("RA_API_KEY", ""); location.reload(); } });
        document.getElementById('ra-override-btn').addEventListener('click', () => {
            let currentManualId = GM_getValue(gamePathKey, "");
            let manualId = prompt("Enter RetroAchievements Game ID manually:", currentManualId);
            if (manualId !== null) { GM_setValue(gamePathKey, manualId.trim()); location.reload(); }
        });

        const progressUrl = `https://retroachievements.org/API/API_GetUserCompletionProgress.php?z=${RA_USER}&y=${RA_API_KEY}&u=${RA_USER}&c=500`;
        fetchRAData(progressUrl, gameTitle, gamePathKey, RA_USER, RA_API_KEY);
    }

    // ==================== ROUTE B: SOCIAL PROFILES ====================
    function handleProfilePage(container, profileOwner, loggedUser) {
        container.innerHTML = `<div id="ra-content" style="font-size: 13px; color: #9fa6af;">Checking network database...</div>`;
        fetchFromSupabase(profileOwner, (raUsername) => {
            if (!raUsername) {
                container.innerHTML = `<div style="font-size:12px; color:#6c757d; font-style:italic;">This user hasn't linked their RetroAchievements profile to the script network yet.</div>`;
                return;
            }
            let MY_USER = GM_getValue("RA_USERNAME", "");
            let MY_API_KEY = GM_getValue("RA_API_KEY", "");
            if (!MY_USER || !MY_API_KEY) {
                container.innerHTML = `<div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase; margin-bottom: 4px;">RetroAchievements Profile</div><div style="font-size:12px; color:#9fa6af;">This profile maps to RA user <strong>${raUsername}</strong>. Link your own account on any game page to see their full arcade showcase stats here!</div>`;
                return;
            }
            const summaryUrl = `https://retroachievements.org/API/API_GetUserSummary.php?z=${MY_USER}&y=${MY_API_KEY}&u=${raUsername}&g=0&a=0`;
            GM_xmlhttpRequest({
                method: "GET",
                url: summaryUrl,
                onload: function(res) {
                    try {
                        let data = JSON.parse(res.responseText);
                        const points = data.Points || 0;
                        const rank = data.Rank ? parseInt(data.Rank).toLocaleString() : "Unranked";
                        const userPic = data.UserPic ? `https://media.retroachievements.org${data.UserPic}` : 'https://retroachievements.org/images/no_avatar.png';
                        container.innerHTML = `
                            <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
                                <div style="font-size: 12px; font-weight: 700; color: #9fa6af; letter-spacing: 0.8px; text-transform: uppercase;">RetroAchievements Showcase</div>
                                <a href="https://retroachievements.org/user/${raUsername}" target="_blank" style="color: #ff4081; text-decoration: none; font-size: 12px; font-weight: 600;">View Full Profile ↗</a>
                            </div>
                            <div style="display: flex; gap: 16px; align-items: center;">
                                <img src="${userPic}" style="width: 54px; height: 54px; border-radius: 4px; border: 1px solid #2b3036; background: #111417;">
                                <div style="display: flex; flex: 1; justify-content: space-around; background: #111417; border: 1px solid #2b3036; border-radius: 4px; padding: 10px;">
                                    <div style="text-align: center;"><div style="font-size: 10px; text-transform: uppercase; color: #6c757d; font-weight: 700; letter-spacing: 0.5px;">RA Handle</div><div style="font-size: 14px; color: #fff; font-weight: 600;">${raUsername}</div></div>
                                    <div style="text-align: center;"><div style="font-size: 10px; text-transform: uppercase; color: #6c757d; font-weight: 700; letter-spacing: 0.5px;">Score</div><div style="font-size: 14px; color: #ffb300; font-weight: 600;">Points: ${points.toLocaleString()}</div></div>
                                    <div style="text-align: center;"><div style="font-size: 10px; text-transform: uppercase; color: #6c757d; font-weight: 700; letter-spacing: 0.5px;">Global Rank</div><div style="font-size: 14px; color: #26a65b; font-weight: 600;"># ${rank}</div></div>
                                </div>
                            </div>
                        `;
                    } catch(e) { container.innerHTML = `<div style="font-size:12px; color:#ff4d4d;">Failed to load user statistics panel.</div>`; }
                }
            });
        });
    }

    // ==================== NETWORKING & ENGINE ====================
    function publishToSupabase(backloggdUser, raUser) {
        GM_xmlhttpRequest({
            method: "POST",
            url: `${CONFIG.PROXY_URL}/user_mappings`,
            headers: { "Content-Type": "application/json", "Prefer": "resolution=merge-duplicates" },
            data: JSON.stringify({ backloggd_username: backloggdUser.toLowerCase(), ra_username: raUser }),
            onload: function() { location.reload(); }
        });
    }

    function fetchFromSupabase(backloggdUser, callback) {
        GM_xmlhttpRequest({
            method: "GET",
            url: `${CONFIG.PROXY_URL}/user_mappings?backloggd_username=eq.${backloggdUser.toLowerCase()}&select=ra_username`,
            onload: function(res) {
                try { let data = JSON.parse(res.responseText); callback(data && data.length > 0 ? data[0].ra_username : null); } catch(e) { callback(null); }
            }
        });
    }

    function fetchRAData(url, gameTitle, gamePathKey, user, apiKey) {
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: function(response) {
                try {
                    let parsed = JSON.parse(response.responseText.trim());
                    let gamesArray = parsed && parsed.Results ? parsed.Results : (parsed && typeof parsed === 'object' ? Object.values(parsed) : []);
                    let savedManualId = GM_getValue(gamePathKey, "");
                    let match = savedManualId ? gamesArray.find(g => g && g.GameID && g.GameID.toString() === savedManualId) : gamesArray.find(g => g && g.Title && cleanTitle(g.Title) === cleanTitle(gameTitle));
                    if (match || savedManualId) { fetchGameBadges(match ? match.GameID : savedManualId, user, apiKey, savedManualId ? true : false); } else { renderManualForm(gameTitle, gamePathKey); }
                } catch (e) { document.getElementById('ra-content').innerHTML = `Local storage index array verification parsed offline.`; }
            }
        });
    }

    function fetchGameBadges(gameId, user, apiKey, isOverridden) {
        GM_xmlhttpRequest({
            method: "GET",
            url: `https://retroachievements.org/API/API_GetGameInfoAndUserProgress.php?z=${user}&y=${apiKey}&u=${user}&g=${gameId}`,
            onload: function(res) {
                try { renderSteamStyleUI(JSON.parse(res.responseText), isOverridden); } catch(e) { document.getElementById('ra-content').innerHTML = `Failed loading target trophy rows.`; }
            }
        });
    }

    function cleanTitle(str) { return str.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); }

    function renderManualForm(gameTitle, gamePathKey) {
        const container = document.getElementById('ra-content');
        const searchUrl = `https://retroachievements.org/search?query=${encodeURIComponent(gameTitle)}`;

        container.innerHTML = `
            <div style="font-size: 13px; color: #9fa6af; margin-bottom: 10px;">
                <strong>Auto-match failed.</strong> Link this game manually:
            </div>
            <div style="display: flex; gap: 8px; margin-bottom: 12px;">
                <input type="text" id="ra-manual-input" placeholder="Paste ID (e.g. 1234)..."
                    style="flex: 1; padding: 6px; background: #111417; border: 1px solid #2b3036; color: #fff; border-radius: 4px; font-size: 12px;">
                <button id="ra-manual-save-btn" style="padding: 6px 12px; background: #e91e63; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600;">Link ID</button>
            </div>
            <div style="font-size: 11px; color: #6c757d; line-height: 1.6;">
                1. <a href="${searchUrl}" target="_blank" style="color: #ff4081; text-decoration: underline;">Search for "${gameTitle}" on RA</a><br>
                2. Click your game from the search results.<br>
                3. Copy the ID number from the URL address bar.<br>
                4. Paste that ID above and click "Link ID".
            </div>
        `;

        document.getElementById('ra-manual-save-btn').addEventListener('click', () => {
            const inputId = document.getElementById('ra-manual-input').value.trim();
            if(inputId) {
                GM_setValue(gamePathKey, inputId);
                location.reload();
            }
        });
    }

    function renderSteamStyleUI(gameData, isOverridden) {
        const achList = Object.values(gameData.Achievements || {});
        const maxAch = achList.length;
        const unlockedList = achList.filter(a => a.DateEarned || a.DateEarnedHardcore);
        const awarded = unlockedList.length;
        const pct = maxAch > 0 ? ((awarded / maxAch) * 100).toFixed(1) : "0.0";
        const mainColor = (maxAch > 0 && awarded === maxAch) ? '#26a65b' : '#e91e63';
        let badgesToShow = [...unlockedList].sort((a, b) => new Date(b.DateEarnedHardcore || b.DateEarned || 0) - new Date(a.DateEarnedHardcore || a.DateEarned || 0));
        if (badgesToShow.length < 6) badgesToShow = badgesToShow.concat(achList.filter(a => !a.DateEarned && !a.DateEarnedHardcore).slice(0, 6 - badgesToShow.length));
        badgesToShow = badgesToShow.slice(0, 6);
        let badgesHTML = '<div style="display: flex; gap: 10px; margin-top: 8px;">';
        badgesToShow.forEach(ach => {
            const isUnlocked = ach.DateEarned || ach.DateEarnedHardcore;
            const imgStyle = isUnlocked ? 'width:100%; height:100%; object-fit:cover;' : 'width:100%; height:100%; object-fit:cover; filter:grayscale(1) opacity(0.35);';
            badgesHTML += `<div style="position: relative; width: 44px; height: 44px; border-radius: 4px; overflow: hidden; border: 1px solid #2b3036; background: #111417;" title="${ach.Title}"><img src="https://media.retroachievements.org/Badge/${ach.BadgeName}.png" style="${imgStyle}"></div>`;
        });
        badgesHTML += '</div>';
        document.getElementById('ra-content').innerHTML = `
            <div style="display: grid; grid-template-columns: 1fr 240px; gap: 30px; align-items: center;">
                <div>
                    <div style="font-size: 15px; font-weight: 600; color: #fff;">${gameData.Title} <span style="font-size: 12px; color: #9fa6af; font-weight: normal;">(${gameData.ConsoleName || 'System'})</span></div>
                    <div style="margin-top: 10px;"><div style="font-size: 11px; color: #6c757d; text-transform: uppercase;">Trophy Showcase</div>${badgesHTML}</div>
                </div>
                <div>
                    <div style="display: flex; justify-content: space-between; font-size: 12px; color: #fff; font-weight: 600;"><span>Progress</span><span>${awarded}/${maxAch} (${pct}%)</span></div>
                    <div style="background: #111417; border: 1px solid #2b3036; border-radius: 4px; height: 10px; width: 100%; overflow: hidden;"><div style="background: ${mainColor}; height: 100%; width: ${pct}%;"></div></div>
                </div>
            </div>`;
    }

    function init() {
        lastUrl = window.location.href;
        attemptInjection(20);
    }

    window.addEventListener('popstate', () => {
        setTimeout(init, 500);
    });

    const originalPushState = history.pushState;
    history.pushState = function(...args) {
        originalPushState.apply(this, args);
        setTimeout(init, 500);
    };

    const observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            init();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    init();
})();