SteamDB App Parser

Parse SteamDB Related Apps and DLC into structured format for SLSsteam.

// ==UserScript==
// @name         SteamDB App Parser
// @namespace    ViolentMonkey
// @version      1.2
// @description  Parse SteamDB Related Apps and DLC into structured format for SLSsteam.
// @author       Tasteless Void
// @match        https://steamdb.info/app/*
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    // === Configuration ===
    const IGNORE_TYPES = ['Demo', 'Soundtrack', 'Beta'];

    // === Helper Functions ===

    const getMainAppId = () => location.pathname.match(/\/app\/(\d+)\//)?.[1] || null;

    const extractAppIdFromRow = (row) => {
        const attr = row.getAttribute('data-appid');
        if (attr) return attr;

        for (const cell of row.querySelectorAll('td')) {
            const text = cell.textContent.trim();
            if (/^\d{4,}$/.test(text)) return text;
            const link = cell.querySelector('a[href*="/app/"]');
            const match = link?.href.match(/\/app\/(\d+)\//);
            if (match) return match[1];
        }

        return null;
    };

    const extractCleanName = (cell) => {
        // First try to get the name from a <b> tag (for the new format)
        const boldElement = cell.querySelector('b');
        if (boldElement) {
            return boldElement.textContent.trim();
        }

        // Fallback to the original method, but exclude muted text
        const mutedElement = cell.querySelector('.muted');
        if (mutedElement) {
            // Clone the cell and remove muted elements to get clean text
            const clone = cell.cloneNode(true);
            const mutedClones = clone.querySelectorAll('.muted');
            mutedClones.forEach(el => el.remove());
            return clone.textContent.trim();
        }

        // Original fallback
        return cell.textContent.trim();
    };

    const parseAppTable = (selector, mainAppId, ignoreTypes = []) => {
        const results = [];
        const rows = document.querySelectorAll(`${selector} tbody tr`);

        for (const row of rows) {
            const cells = row.querySelectorAll('td');
            if (cells.length < 2) continue;

            const type = cells[1]?.textContent.trim();
            const nameCell = cells[2] || cells[1]; // Use second cell for type, third for name
            const name = extractCleanName(nameCell) || type;
            const appId = extractAppIdFromRow(row);

            if (!appId || appId === mainAppId || ignoreTypes.includes(type)) continue;

            results.push({ appId, appName: name.replace(/"/g, '\\"'), type });
        }

        return results;
    };

    const generateYamlOutput = (mainAppId, additionalApps, dlcEntries) => {
        const dlcMap = new Map();
        for (const { appId, appName } of dlcEntries) {
            dlcMap.set(appId, appName);
        }

        const lines = [];

        if (additionalApps.length) {
            lines.push('AdditionalApps:');
            for (const { appId, appName } of [...additionalApps].sort((a, b) => a.appId - b.appId)) {
                lines.push(`  - ${appId}\t# ${appName}`);
            }
        }

        if (dlcMap.size) {
            lines.push('\nDlcData:');
            lines.push(`  ${mainAppId}:`);
            for (const [appId, appName] of [...dlcMap.entries()].sort((a, b) => a[0] - b[0])) {
                lines.push(`    ${appId}: "${appName}"`);
            }
        }

        return lines.join('\n');
    };

    const generateAdditionalAppsOnly = (additionalApps) => {
        if (!additionalApps.length) return '';

        const lines = [];
        for (const { appId, appName } of [...additionalApps].sort((a, b) => a.appId - b.appId)) {
            lines.push(`  - ${appId}\t# ${appName}`);
        }
        return lines.join('\n');
    };

    const generateDlcOnly = (mainAppId, dlcEntries) => {
        if (!dlcEntries.length) return '';

        const dlcMap = new Map();
        for (const { appId, appName } of dlcEntries) {
            dlcMap.set(appId, appName);
        }

        const lines = [`${mainAppId}:`];
        for (const [appId, appName] of [...dlcMap.entries()].sort((a, b) => a[0] - b[0])) {
            lines.push(`    ${appId}: "${appName}"`);
        }
        return lines.join('\n');
    };

    const showOutputModal = (output, additionalApps, dlcEntries, mainAppId) => {
        const overlay = document.createElement('div');
        overlay.style.cssText = `
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.5); display: flex; justify-content: center;
            align-items: center; z-index: 10000;
        `;

        const box = document.createElement('div');
        box.style.cssText = `
            background: #fff; padding: 20px; border-radius: 8px;
            max-width: 90%; max-height: 90%; overflow: auto;
        `;

        const textarea = document.createElement('textarea');
        textarea.readOnly = true;
        textarea.value = output;
        textarea.style.cssText = `
            width: 100%; height: 500px; font-family: monospace;
            font-size: 12px; border: 1px solid #ccc; padding: 10px;
            margin-bottom: 15px;
        `;

        const buttonRow = document.createElement('div');
        buttonRow.style.cssText = `
            text-align: center;
            display: flex;
            gap: 10px;
            justify-content: center;
            flex-wrap: wrap;
        `;

        const makeButton = (label, color, callback) => {
            const btn = document.createElement('button');
            btn.textContent = label;
            btn.style.cssText = `
                padding: 10px 15px;
                background: ${color}; color: white;
                border: none; border-radius: 4px; cursor: pointer;
                font-size: 12px; white-space: nowrap;
            `;
            btn.onclick = callback;
            return btn;
        };

        const copyToClipboard = (text, successMessage) => {
            textarea.value = text;
            textarea.select();
            document.execCommand('copy');
            alert(successMessage);
            textarea.value = output; // Reset to full output
        };

        buttonRow.appendChild(makeButton('Copy All', '#007bff', () => {
            copyToClipboard(output, 'Full output copied to clipboard!');
        }));

        if (additionalApps.length) {
            buttonRow.appendChild(makeButton('Copy AdditionalApps Only', '#28a745', () => {
                const appsOnly = generateAdditionalAppsOnly(additionalApps);
                copyToClipboard(appsOnly, 'AdditionalApps section copied to clipboard!');
            }));
        }

        if (dlcEntries.length) {
            buttonRow.appendChild(makeButton('Copy DLC Only', '#ffc107', () => {
                const dlcOnly = generateDlcOnly(mainAppId, dlcEntries);
                copyToClipboard(dlcOnly, 'DLC section copied to clipboard!');
            }));
        }

        buttonRow.appendChild(makeButton('Close', '#6c757d', () => overlay.remove()));

        box.appendChild(textarea);
        box.appendChild(buttonRow);
        overlay.appendChild(box);
        document.body.appendChild(overlay);

        overlay.onclick = (e) => {
            if (e.target === overlay) overlay.remove();
        };
    };

    const ensureLinkedTabLoaded = () => {
        const tab = document.getElementById('tab-linked');
        if (!tab || tab.classList.contains('active')) return Promise.resolve();

        tab.click();
        return new Promise((resolve) => {
            const waitUntilLoaded = () => {
                const isReady = document.querySelector('#linked table tbody tr');
                if (isReady) resolve();
                else setTimeout(waitUntilLoaded, 300);
            };
            waitUntilLoaded();
        });
    };

    const createParseButton = () => {
        if (document.getElementById('steamdb-parse-button')) return;

        const btn = document.createElement('button');
        btn.id = 'steamdb-parse-button';
        btn.textContent = 'Parse SteamDB Data';
        btn.style.cssText = `
            position: fixed; bottom: 20px; right: 20px;
            background: #28a745; color: white;
            border: none; padding: 12px 20px;
            border-radius: 6px; cursor: pointer;
            font-size: 14px; font-weight: bold;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
            z-index: 1000;
        `;

        btn.onclick = async () => {
            const mainAppId = getMainAppId();
            if (!mainAppId) return;

            await ensureLinkedTabLoaded();

            const linkedApps = parseAppTable('#linked', mainAppId, IGNORE_TYPES);
            const dlcApps = parseAppTable('#dlc', mainAppId);

            const additionalApps = linkedApps;
            const dlcCombined = [...linkedApps.filter(e => e.type === 'DLC'), ...dlcApps];

            const output = generateYamlOutput(mainAppId, additionalApps, dlcCombined);
            if (!output) {
                alert('Could not parse any app data.');
                return;
            }

            showOutputModal(output, additionalApps, dlcCombined, mainAppId);
        };

        document.body.appendChild(btn);
    };

    const waitForTabs = () => {
        const hasTab = document.getElementById('tab-linked') || document.getElementById('tab-dlc');
        if (hasTab) createParseButton();
        else setTimeout(waitForTabs, 1000);
    };

    const observeNavigationChanges = () => {
        let previousPath = location.pathname;

        const observer = new MutationObserver(() => {
            if (location.pathname !== previousPath) {
                previousPath = location.pathname;
                setTimeout(waitForTabs, 1000);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
    };

    // === Bootstrapping ===

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            waitForTabs();
            observeNavigationChanges();
        });
    } else {
        waitForTabs();
        observeNavigationChanges();
    }
})();