Speedrun.com Table Exporter

Export Speedrun.com leaderboard tables to JSON with flags, variables, and timestamped filenames

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Speedrun.com Table Exporter
// @namespace    local
// @version      5.2
// @description  Export Speedrun.com leaderboard tables to JSON with flags, variables, and timestamped filenames
// @author       local
// @license      MIT
// @match        *://www.speedrun.com/*
// @run-at       document-idle
// @grant        GM_setClipboard
// ==/UserScript==

/*
MIT License

Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files, to deal in the Software
without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
*/

(function () {
    'use strict';

    function clean(text) {
        return (text || '').replace(/\s+/g, ' ').trim();
    }

    function absUrl(url) {
        if (!url) return '';
        return new URL(url, location.origin).href;
    }

    function makeKey(text) {
        return clean(text)
            .toLowerCase()
            .replace(/%/g, 'percent')
            .replace(/[^a-z0-9]+/g, '_')
            .replace(/^_+|_+$/g, '');
    }

    function getTimestamp() {
        const d = new Date();

        const pad = (n, size = 2) => String(n).padStart(size, '0');

        return [
            d.getFullYear(),
            pad(d.getMonth() + 1),
            pad(d.getDate())
        ].join('-') + '_' + [
            pad(d.getHours()),
            pad(d.getMinutes()),
            pad(d.getSeconds())
        ].join('-') + '-' + pad(d.getMilliseconds(), 3);
    }

    function getTableHeaders(table) {
        const rows = Array.from(table.querySelectorAll('thead tr'));

        for (const row of rows) {
            const headers = Array.from(row.children).map(cell => clean(cell.textContent));

            if (headers.some(Boolean)) {
                return headers;
            }
        }

        const wrapper = table.closest('.x-leaderboard-table');

        if (wrapper) {
            const stickyCells = Array.from(wrapper.querySelectorAll('.x-sticky-header-row > div'));
            const headers = stickyCells.map(cell => clean(cell.textContent));

            if (headers.some(Boolean)) {
                return headers;
            }
        }

        return [];
    }

    function findColumn(headers, names) {
        return headers.findIndex(header => {
            const key = makeKey(header);
            return names.some(name => key === name || key.includes(name));
        });
    }

    function getFlag(cell) {
        if (!cell) {
            return {
                flag_name: '',
                flag_url: ''
            };
        }

        const img = cell.querySelector('img[src*="/images/flags/"]');

        return {
            flag_name: img ? clean(img.getAttribute('alt')) : '',
            flag_url: img ? absUrl(img.getAttribute('src')) : ''
        };
    }

    function getUsername(cell) {
        if (!cell) return '';

        const username = cell.querySelector('.x-username span:last-child');
        if (username) return clean(username.textContent);

        const userLink = cell.querySelector('a[href*="/users/"]');
        if (userLink) return clean(userLink.textContent);

        return clean(cell.textContent);
    }

    function getUserLink(cell) {
        if (!cell) return '';

        const userLink = cell.querySelector('a[href*="/users/"]');
        return userLink ? absUrl(userLink.getAttribute('href')) : '';
    }

    function getRunLink(cell) {
        if (!cell) return '';

        const runLink = cell.querySelector('a[href*="/runs/"], a[href*="/run/"]');
        return runLink ? absUrl(runLink.getAttribute('href')) : '';
    }

    function getRank(cell) {
        if (!cell) return '';

        const img = cell.querySelector('img[alt]');
        if (img) {
            const alt = img.getAttribute('alt').toLowerCase();

            if (alt.includes('first')) return '1';
            if (alt.includes('second')) return '2';
            if (alt.includes('third')) return '3';
        }

        return clean(cell.textContent);
    }

    function getTime(cell) {
        if (!cell) return '';

        const link = cell.querySelector('a[href*="/runs/"], a[href*="/run/"]');
        const text = clean(link ? link.textContent : cell.textContent);

        const match = text.match(
            /\d+\s*h\s*\d+\s*m\s*\d+\s*s\s*\d+\s*ms|\d+\s*m\s*\d+\s*s\s*\d+\s*ms|\d+\s*s\s*\d+\s*ms|\d+\s*h\s*\d+\s*m\s*\d+\s*s|\d+\s*m\s*\d+\s*s|\d+:\d{2}(?::\d{2})?/i
        );

        return match ? match[0] : text;
    }

    function getCellValue(cell) {
        if (!cell) return '';

        const username = cell.querySelector('.x-username span:last-child');
        if (username) return clean(username.textContent);

        return clean(cell.textContent);
    }

    function parsePlace(cell, place) {
        const userFlag = getFlag(cell);

        return {
            place,
            player: getUsername(cell),
            time: getTime(cell),
            run_link: getRunLink(cell),
            user_link: getUserLink(cell),
            user_flag_name: userFlag.flag_name,
            user_flag_url: userFlag.flag_url
        };
    }

    function parseLevelTopTable(table) {
        return Array.from(table.querySelectorAll('tbody tr')).map(row => {
            const cells = Array.from(row.children);
            const levelCell = cells[0];
            const levelLink = levelCell ? levelCell.querySelector('a') : null;

            return {
                type: 'level_top',
                level: clean(levelCell ? levelCell.textContent : ''),
                level_link: levelLink ? absUrl(levelLink.getAttribute('href')) : '',
                first: parsePlace(cells[1], 1),
                second: parsePlace(cells[2], 2),
                third: parsePlace(cells[3], 3)
            };
        });
    }

    function getExtraColumns(headers, cells, standardIndexes) {
        const variables = {};

        headers.forEach((header, index) => {
            const key = makeKey(header);

            if (!key) return;
            if (standardIndexes.includes(index)) return;

            const value = getCellValue(cells[index]);

            if (value) {
                variables[key] = value;
            }
        });

        return variables;
    }

    function parseNormalLeaderboard(table) {
        const headers = getTableHeaders(table);

        const rankIndex = findColumn(headers, ['rank', 'place']);
        const playerIndex = findColumn(headers, ['player', 'runner']);
        const timeIndex = findColumn(headers, ['time', 'real_time', 'game_time']);
        const dateIndex = findColumn(headers, ['date']);
        const platformIndex = findColumn(headers, ['platform']);

        const finalRankIndex = rankIndex >= 0 ? rankIndex : 0;
        const finalPlayerIndex = playerIndex >= 0 ? playerIndex : 1;
        const finalTimeIndex = timeIndex >= 0 ? timeIndex : 3;
        const finalDateIndex = dateIndex >= 0 ? dateIndex : 4;
        const finalPlatformIndex = platformIndex >= 0 ? platformIndex : 5;

        const standardIndexes = [
            finalRankIndex,
            finalPlayerIndex,
            finalTimeIndex,
            finalDateIndex,
            finalPlatformIndex
        ];

        return Array.from(table.querySelectorAll('tbody tr')).map(row => {
            const cells = Array.from(row.children);

            const rankCell = cells[finalRankIndex];
            const playerCell = cells[finalPlayerIndex];
            const timeCell = cells[finalTimeIndex];
            const dateCell = cells[finalDateIndex];
            const platformCell = cells[finalPlatformIndex];

            const userFlag = getFlag(playerCell);
            const platformFlag = getFlag(platformCell);
            const variables = getExtraColumns(headers, cells, standardIndexes);

            const item = {
                type: 'leaderboard',
                rank: getRank(rankCell),
                player: getUsername(playerCell),
                time: getTime(timeCell),
                date: clean(dateCell ? dateCell.textContent : ''),
                platform: clean(platformCell ? platformCell.textContent : ''),
                run_link: getRunLink(timeCell) || getRunLink(row),
                user_link: getUserLink(playerCell),
                user_flag_name: userFlag.flag_name,
                user_flag_url: userFlag.flag_url,
                platform_flag_name: platformFlag.flag_name,
                platform_flag_url: platformFlag.flag_url,
                variables
            };

            Object.keys(variables).forEach(key => {
                item[key] = variables[key];
            });

            return item;
        }).filter(item => item.player || item.time || item.run_link);
    }

    function parseTable(table) {
        const headers = getTableHeaders(table).map(h => h.toLowerCase());

        if (
            headers.includes('level') &&
            headers.some(h => h.includes('first place'))
        ) {
            return parseLevelTopTable(table);
        }

        return parseNormalLeaderboard(table);
    }

    function findBestTable() {
        const tables = Array.from(document.querySelectorAll('table'));

        return tables.find(table => {
            const headers = getTableHeaders(table).join(' ').toLowerCase();

            return (
                (headers.includes('player') && headers.includes('time')) ||
                (headers.includes('level') && headers.includes('first place'))
            );
        }) || null;
    }

    function downloadJson(json) {
        const timestamp = getTimestamp();
        const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
        const url = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = url;
        a.download = `speedrun-table-${timestamp}.json`;
        document.body.appendChild(a);
        a.click();

        a.remove();
        URL.revokeObjectURL(url);
    }

    function copyText(text) {
        try {
            GM_setClipboard(text, 'text');
        } catch (e) {
            navigator.clipboard?.writeText(text);
        }
    }

    function notify(text) {
        const box = document.createElement('div');

        box.textContent = text;
        box.style.position = 'fixed';
        box.style.right = '20px';
        box.style.bottom = '20px';
        box.style.zIndex = '999999';
        box.style.padding = '12px 16px';
        box.style.background = '#111';
        box.style.color = '#fff';
        box.style.borderRadius = '8px';
        box.style.fontSize = '14px';
        box.style.fontFamily = 'Arial, sans-serif';

        document.body.appendChild(box);
        setTimeout(() => box.remove(), 3500);
    }

    function exportTable() {
        const table = findBestTable();

        if (!table) {
            notify('Table not found');
            console.warn('[SRC Exporter] Table not found');
            return;
        }

        const data = parseTable(table);

        if (!data.length) {
            notify('No data parsed');
            console.warn('[SRC Exporter] No data parsed');
            return;
        }

        const json = JSON.stringify(data, null, 2);

        console.log('[SRC Exporter] Exported JSON:', data);

        copyText(json);
        downloadJson(json);

        notify(`Done: ${data.length} rows. JSON copied and downloaded.`);
    }

    window.addEventListener('keydown', function (e) {
        const active = document.activeElement;
        const tag = active ? active.tagName : '';

        if (tag === 'INPUT' || tag === 'TEXTAREA' || (active && active.isContentEditable)) {
            return;
        }

        if (e.key.toLowerCase() === 'q') {
            exportTable();
        }
    }, true);

    console.log('[SRC Exporter] Loaded. Press Q to export.');
})();