GeoGuessr JSON Export

One-click JSON exporter for GeoGuessr game results

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         GeoGuessr JSON Export
// @version      1.3
// @namespace    https://github.com/asmodeo
// @icon         https://parmageo.vercel.app/gg.ico
// @description  One-click JSON exporter for GeoGuessr game results
// @author       Parma
// @match        *://*.geoguessr.com/*
// @license      MIT
// @connect      nominatim.openstreetmap.org
// @connect      flagcdn.com
// ==/UserScript==

(function () {
    'use strict';

    // ──────────────────────────────────────────────────────────────────────
    //  PERSISTENT SETTINGS
    // ──────────────────────────────────────────────────────────────────────

    let tagSettings = {
        includeCountryCode: true,
        includeMapName: true
    };

    let roundsExpandedState = true;

    function loadSettings() {
        try {
            const tags = JSON.parse(localStorage.getItem('ggjsonexport_tag_settings'));
            if (tags && typeof tags === 'object') {
                tagSettings.includeCountryCode = !!tags.includeCountryCode;
                tagSettings.includeMapName = !!tags.includeMapName;
            }
        } catch (e) { /* ignore */ }

        try {
            const saved = localStorage.getItem('ggjsonexport_rounds_expanded');
            if (saved !== null) {
                roundsExpandedState = (saved === 'true');
            }
        } catch (e) { /* ignore */ }
    }

    function saveTagSettings() {
        try {
            localStorage.setItem('ggjsonexport_tag_settings', JSON.stringify(tagSettings));
        } catch (e) { /* ignore */ }
    }

    function saveRoundsExpanded(value) {
        try {
            localStorage.setItem('ggjsonexport_rounds_expanded', String(value));
        } catch (e) { /* ignore */ }
    }

    loadSettings();

    // ──────────────────────────────────────────────────────────────────────
    //  COUNTRY RESOLUTION CACHE
    // ──────────────────────────────────────────────────────────────────────

    const countryCache = new Map();
    let saveCacheTimeout = null;

    function loadCountryCache() {
        try {
            const cached = localStorage.getItem('ggjsonexport_country_cache');
            if (cached) {
                const parsed = JSON.parse(cached);
                for (const [key, value] of Object.entries(parsed)) {
                    // Value may be string or null
                    countryCache.set(key, value === null ? null : value);
                }
            }
        } catch (e) {
            console.warn('Failed to load country cache:', e);
        }
    }

    function scheduleSaveCountryCache() {
        clearTimeout(saveCacheTimeout);
        saveCacheTimeout = setTimeout(() => {
            try {
                const cacheObj = {};
                for (const [key, value] of countryCache.entries()) {
                    cacheObj[key] = value;
                }
                localStorage.setItem('ggjsonexport_country_cache', JSON.stringify(cacheObj));
            } catch (e) { /* ignore */ }
        }, 1000);
    }

    /**
     * Fetches the ISO country code for given coordinates using Nominatim.
     * Uses persistent cache via localStorage.
     */
    async function getCountryCode(lat, lng) {
        const key = `${lat},${lng}`;
        if (countryCache.has(key)) {
            return countryCache.get(key);
        }

        try {
            const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=en-US`;
            const res = await fetch(url, {
                headers: {
                    'User-Agent': 'GeoGuessrJSONExport/1.3 (Userscript; https://github.com/asmodeo)'
                }
            });

            if (!res.ok) {
                console.warn('Nominatim request failed:', res.status, res.statusText);
                countryCache.set(key, null);
                scheduleSaveCountryCache();
                return null;
            }

            const data = await res.json();
            const code = data?.address?.country_code || null;
            countryCache.set(key, code);
            scheduleSaveCountryCache();
            return code;
        } catch (e) {
            console.warn('Error resolving country from Nominatim:', e);
            countryCache.set(key, null);
            scheduleSaveCountryCache();
            return null;
        }
    }

    // Load cache on init
    loadCountryCache();

    // ──────────────────────────────────────────────────────────────────────
    //  STATE VARIABLES
    // ──────────────────────────────────────────────────────────────────────

    let widget = null;
    let observer = null;
    let isActive = false;
    let currentGameData = null;
    let currentGameId = null;
    let roundCheckboxes = [];

    // ──────────────────────────────────────────────────────────────────────
    //  UTILS
    // ──────────────────────────────────────────────────────────────────────

    const US_STATE_NAMES = {
        al: 'Alabama', ak: 'Alaska', az: 'Arizona', ar: 'Arkansas', ca: 'California', co: 'Colorado', ct: 'Connecticut', de: 'Delaware', fl: 'Florida', ga: 'Georgia',
        hi: 'Hawaii', id: 'Idaho', il: 'Illinois', in: 'Indiana', ia: 'Iowa', ks: 'Kansas', ky: 'Kentucky', la: 'Louisiana', me: 'Maine', md: 'Maryland',
        ma: 'Massachusetts', mi: 'Michigan', mn: 'Minnesota', ms: 'Mississippi', mo: 'Missouri', mt: 'Montana', ne: 'Nebraska', nv: 'Nevada', nh: 'New Hampshire', nj: 'New Jersey',
        nm: 'New Mexico', ny: 'New York', nc: 'North Carolina', nd: 'North Dakota', oh: 'Ohio', ok: 'Oklahoma', or: 'Oregon', pa: 'Pennsylvania', ri: 'Rhode Island', sc: 'South Carolina',
        sd: 'South Dakota', tn: 'Tennessee', tx: 'Texas', ut: 'Utah', vt: 'Vermont', va: 'Virginia', wa: 'Washington', wv: 'West Virginia', wi: 'Wisconsin', wy: 'Wyoming',
        dc: 'District of Columbia'
    };
    const USDC_FLAG = 'https://upload.wikimedia.org/wikipedia/commons/0/03/Flag_of_Washington%2C_D.C.svg';

    function getCountryName(code) {
        if (!code || code.length !== 2) return null;
        try {
            return new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase());
        } catch (e) {
            return code.toUpperCase();
        }
    }

    function applySpinStyles() {
        if (document.getElementById('ggjsonexport-spin-styles')) return;
        const style = document.createElement('style');
        style.id = 'ggjsonexport-spin-styles';
        style.textContent = `
        @keyframes ggjsonexport-rotate {
            to { transform: rotate(360deg); }
        }
        .ggjsonexport-loading-svg {
            animation: ggjsonexport-rotate 1.2s linear infinite;
        }
    `;
        document.head.appendChild(style);
    }

    function createFlagElement(flagId, isResolving = false, isCompound = false) {
        const flag = document.createElement('span');
        flag.style.cssText = `
        display: inline-block;
        width: 24px;
        height: 16px;
        margin-left: 4px;
        cursor: default;
        vertical-align: middle;
        overflow: hidden;
        display: flex;
        align-items: center;
        justify-content: center;
    `;

        if (flagId) {
            const img = document.createElement('img');
            let src = '';
            if (isCompound && flagId.toLowerCase() === 'us-dc') {
                src = USDC_FLAG;
            } else {
                src = `https://flagcdn.com/${flagId.toLowerCase()}.svg`;
            }
            img.src = src;
            img.alt = flagId;
            img.style.cssText = `
                width: 100%;
                height: 100%;
                display: block;
                object-fit: contain;
                object-position: center;
                image-rendering: -webkit-optimize-contrast;
            `;
            img.loading = 'lazy';
            flag.appendChild(img);

            let tooltipText;
            // Compound IDs for US States
            if (isCompound) {
                const code = flagId.slice(3);
                tooltipText = US_STATE_NAMES[code] || flagId.toUpperCase();
            } else {
                tooltipText = getCountryName(flagId) || flagId.toUpperCase();
            }
            flag.title = tooltipText;
            return flag;
        }

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('width', '16');
        svg.setAttribute('height', '16');
        svg.setAttribute('fill', 'none');
        svg.setAttribute('stroke', '#aaa');
        svg.setAttribute('stroke-width', '1');
        svg.setAttribute('stroke-linecap', 'round');
        svg.setAttribute('stroke-linejoin', 'round');

        if (isResolving) {
            applySpinStyles();
            svg.classList.add('ggjsonexport-loading-svg');
            const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            path.setAttribute('d', 'M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z');
            svg.appendChild(path);
            flag.title = 'Resolving country...';
        } else {
            const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
            path.setAttribute('d', 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z');
            svg.appendChild(path);
            flag.title = 'Country unknown';
        }

        flag.appendChild(svg);
        return flag;
    }

    // ──────────────────────────────────────────────────────────────────────
    //  UTILITY FUNCTIONS
    // ──────────────────────────────────────────────────────────────────────

    function hex2a(hex) {
        if (typeof hex !== 'string' || hex.length % 2 !== 0) return null;
        return hex.match(/.{2}/g)
            .map(byte => String.fromCharCode(parseInt(byte, 16)))
            .join('');
    }

    function googleMapsLink(pano) {
        if (!pano || typeof pano.lat !== 'number' || typeof pano.lng !== 'number') return null;
        const fov = 180 / Math.pow(2, pano.zoom ?? 0);
        let url = `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${pano.lat},${pano.lng}&heading=${pano.heading ?? 0}&pitch=${pano.pitch ?? 0}&fov=${fov}`;
        if (pano.panoId) {
            const decoded = hex2a(pano.panoId);
            if (decoded) url += `&pano=${decoded}`;
        }
        return url;
    }

    // ──────────────────────────────────────────────────────────────────────
    //  DATA PARSING
    // ──────────────────────────────────────────────────────────────────────

    function parseRoundData(round, index = 0, isUsStateStreak = false) {
        if (!round) return null;

        const pano = (round.panorama?.lat != null && round.panorama?.lng != null)
            ? round.panorama
            : round;

        if (typeof pano.lat !== 'number' || typeof pano.lng !== 'number') {
            return null;
        }

        let countryCode = null;
        let stateCode = null;

        if (isUsStateStreak) {
            countryCode = 'US';
            stateCode = round.streakLocationCode || null;
        } else {
            countryCode =
                round.panorama?.countryCode ||
                round.answer?.countryCode ||
                round.streakLocationCode ||
                null;
        }

        const roundNum = round.roundNumber ?? (index + 1);

        return { pano, roundNum, countryCode, stateCode };
    }

    // ──────────────────────────────────────────────────────────────────────
    //  JSON EXPORT
    // ──────────────────────────────────────────────────────────────────────

    function buildJsonForExport(selectedRounds, mapName, gameType, gameId) {
        const name = `${gameType}_${gameId}`;
        const coords = selectedRounds.map(r => {
            const p = r.pano;
            const tags = [];
            if (tagSettings.includeCountryCode && r.countryCode) {
                tags.push(r.countryCode.toUpperCase());
            }
            if (tagSettings.includeMapName && mapName) {
                tags.push(`map: ${mapName}`);
            }

            const location = {
                lat: p.lat,
                lng: p.lng,
                heading: p.heading ?? 0,
                pitch: p.pitch ?? 0,
                zoom: p.zoom ?? 0,
                panoId: p.panoId ? hex2a(p.panoId) : null,
            };

            if (tags.length > 0) {
                location.extra = { tags };
            }

            return location;
        });
        return JSON.stringify({ name, customCoordinates: coords }, null, 2);
    }

    // ──────────────────────────────────────────────────────────────────────
    //  UI COMPONENTS
    // ──────────────────────────────────────────────────────────────────────

    function createWidget() {
        if (widget) return widget;

        widget = document.createElement('div');
        widget.id = 'ggjsonexport-export-widget';
        widget.style.cssText = `
            position: fixed; bottom: 20px; right: 80px; width: 240px;
            background: #252525;
            color: #e6e6e6;
            border: 1px solid #444;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
            z-index: 1;
            font-family: var(--default-font);
            overflow: hidden;
        `;

        const header = document.createElement('div');
        header.style.cssText = `
            display: flex; align-items: center; padding: 10px 12px;
            background: #2d2d2d;
            border-bottom: 1px solid #444;
            gap: 8px;
        `;

        const jsonCheckbox = document.createElement('input');
        jsonCheckbox.type = 'checkbox';
        jsonCheckbox.id = 'ggjsonexport-select-all';
        jsonCheckbox.title = 'Select/deselect all played rounds';
        jsonCheckbox.checked = true;
        jsonCheckbox.style.cursor = 'pointer';
        jsonCheckbox.style.marginTop = '2px';
        jsonCheckbox.addEventListener('change', toggleSelectAll);

        const copyBtn = createIconButton('M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z', 'Copy JSON', (e) => copyJsonHandler(e));
        const downloadBtn = createIconButton('M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z', 'Download JSON', (e) => downloadJsonHandler(e));

        const tagsBtn = createIconButton(
            'M17.63,5.84C17.27,5.33 16.67,5 16,5L6,5.01C4.9,5.01 4,5.9 4,7v10c0,1.1 0.9,1.99 2,1.99L16,19c0.67,0 1.27,-0.33 1.63,-0.84L22,12l-4.37,-6.16z',
            'Tag settings',
            toggleTagsDropdown
        );
        tagsBtn.id = 'ggjsonexport-tags-btn';

        const jsonLabel = document.createElement('span');
        jsonLabel.textContent = 'JSON';
        jsonLabel.style.cssText = 'font-size: 13px; font-weight: 600; flex: 1; cursor: default;';

        const toggleBtn = createIconButton('M7 10l5 5 5-5z', 'Toggle rounds', toggleContent);
        toggleBtn.id = 'ggjsonexport-toggle-btn';

        header.append(jsonCheckbox, copyBtn, downloadBtn, tagsBtn, jsonLabel, toggleBtn);
        widget.appendChild(header);

        // Tags dropdown
        const tagsDropdown = document.createElement('div');
        tagsDropdown.id = 'ggjsonexport-tags-dropdown';
        tagsDropdown.style.cssText = `
            display: none;
            padding: 8px;
            background: #1f1f1f;
            border-top: 1px solid #444;
            font-size: 12px;
            gap: 6px;
            flex-direction: column;
        `;

        const tagsTitle = document.createElement('span');
        tagsTitle.textContent = 'Tags to include:';
        tagsTitle.style.cssText = 'font-weight: bold; margin-bottom: 6px; cursor: default;';
        tagsDropdown.appendChild(tagsTitle);

        const countryCheckContainer = document.createElement('label');
        countryCheckContainer.style.cssText = 'display: flex; align-items: center; gap: 6px; cursor: pointer;';
        const countryCheck = document.createElement('input');
        countryCheck.type = 'checkbox';
        countryCheck.style.cursor = 'pointer';
        countryCheck.checked = tagSettings.includeCountryCode;
        countryCheck.addEventListener('change', (e) => {
            tagSettings.includeCountryCode = e.target.checked;
            saveTagSettings();
        });
        countryCheckContainer.append(countryCheck, document.createTextNode('Country Code'));

        const mapCheckContainer = document.createElement('label');
        mapCheckContainer.style.cssText = 'display: flex; align-items: center; gap: 6px; cursor: pointer;';
        const mapCheck = document.createElement('input');
        mapCheck.type = 'checkbox';
        mapCheck.style.cursor = 'pointer';
        mapCheck.checked = tagSettings.includeMapName;
        mapCheck.addEventListener('change', (e) => {
            tagSettings.includeMapName = e.target.checked;
            saveTagSettings();
        });
        mapCheckContainer.append(mapCheck, document.createTextNode('Map Name'));

        tagsDropdown.append(countryCheckContainer, mapCheckContainer);
        widget.appendChild(tagsDropdown);

        const content = document.createElement('div');
        content.id = 'ggjsonexport-rounds-content';
        content.style.cssText = `
            max-height: 190px; overflow-y: auto; padding: 6px;
            background: #1f1f1f; display: ${roundsExpandedState ? 'block' : 'none'};
        `;
        widget.appendChild(content);

        // Set toggle button icon
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('width', '16');
        svg.setAttribute('height', '16');
        svg.setAttribute('fill', 'currentColor');
        const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        pathEl.setAttribute('d', roundsExpandedState ? 'M7 10l5 5 5-5z' : 'M7 15l5-5 5 5z');
        svg.appendChild(pathEl);
        toggleBtn.innerHTML = '';
        toggleBtn.appendChild(svg);

        document.body.appendChild(widget);
        return widget;
    }

    function toggleTagsDropdown(e) {
        e.stopPropagation();
        const dropdown = document.getElementById('ggjsonexport-tags-dropdown');
        if (dropdown.style.display === 'block') {
            dropdown.style.display = 'none';
        } else {
            dropdown.style.display = 'block';
        }
    }

    async function createRoundItem(round, index) {
        const item = document.createElement('div');
        item.style.cssText = `
        display: flex; align-items: center; padding: 5px 8px; gap: 6px;
        border-radius: 4px; font-size: 12px;
        background: #1f1f1f;
        cursor: default;
    `;
        item.addEventListener('mouseenter', () => item.style.backgroundColor = '#333333');
        item.addEventListener('mouseleave', () => item.style.backgroundColor = '#1f1f1f');

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = true;
        checkbox.style.marginTop = '2px';
        checkbox.style.cursor = 'pointer';
        checkbox.addEventListener('change', updateSelectAllState);

        const copyLinkBtn = createIconButton(
            'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z',
            'Copy Maps Link',
            (e) => {
                e.stopPropagation();
                const link = googleMapsLink(round.pano);
                if (link) {
                    copyToClipboard(link);
                    showTempTooltip('Link copied!', e.currentTarget);
                }
            }
        );

        const mapsBtn = createMapsIconButton(googleMapsLink(round.pano));
        const roundNum = round.roundNum;
        const roundLabel = document.createElement('span');
        roundLabel.textContent = `Round ${roundNum}`;
        roundLabel.style.flex = '1';
        roundLabel.style.cursor = 'default';

        const { lat, lng } = round.pano;
        let flagEl;

        // Handle US State Streaks with compound flag ids
        const isUsStateStreak = currentGameData?.isUsStateStreak;
        let resolvedCode = round.countryCode;
        let flagId = null;
        let isCompoundFlag = false;

        if (isUsStateStreak && round.stateCode) {
            flagId = `us-${round.stateCode}`;
            isCompoundFlag = true;
            resolvedCode = 'US'; // Ensure JSON has 'US' as countryCode
        } else {
            if (!resolvedCode && lat != null && lng != null) {
                const cacheKey = `${lat},${lng}`;
                if (countryCache.has(cacheKey)) {
                    const cachedCode = countryCache.get(cacheKey);
                    if (cachedCode) {
                        resolvedCode = cachedCode;
                    }
                }
            }

            if (resolvedCode) {
                flagId = resolvedCode;
            }
        }

        if (flagId) {
            flagEl = createFlagElement(flagId, false, isCompoundFlag);
            round.countryCode = resolvedCode;
        } else {
            flagEl = createFlagElement(null, true); // spinning globe
            resolveCountryForRound(round, flagEl, index);
        }

        item.append(checkbox, copyLinkBtn, mapsBtn, roundLabel, flagEl);
        roundCheckboxes.push({ el: checkbox, roundData: round });

        // Clicking the item selects the round on the page
        const supportedModes = ['duels', 'teamduels', 'standard', 'challenge', 'battleroyalecountries', 'battleroyaledistance'];
        if (currentGameData?.gameType && supportedModes.includes(currentGameData.gameType)) {
            item.addEventListener('click', (e) => {
                if (e.target === checkbox || e.target.closest('button, a')) {
                    return;
                }
                clickPageRoundByIndex(index);
            });
        }

        return item;
    }

    async function resolveCountryForRound(round, flagEl, index) {
        await new Promise(r => setTimeout(r, 1000 * index));
        if (!round.pano?.lat || !round.pano?.lng) return;

        const code = await getCountryCode(round.pano.lat, round.pano.lng);
        if (code) {
            round.countryCode = code;
            const newFlag = createFlagElement(code);
            flagEl.replaceWith(newFlag);
        } else {
            const unknownFlag = createFlagElement(null, false);
            flagEl.replaceWith(unknownFlag);
        }
    }

    function createIconButton(path, title, handler) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.title = title;
        btn.style.cssText = `
            width: 20px; height: 20px; border: none; background: transparent; padding: 0;
            cursor: pointer; display: flex; align-items: center; justify-content: center;
            color: #aaa; flex-shrink: 0; transition: color 0.2s;
        `;

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('width', '16');
        svg.setAttribute('height', '16');
        svg.setAttribute('fill', 'currentColor');
        const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        pathEl.setAttribute('d', path);
        svg.appendChild(pathEl);
        btn.appendChild(svg);

        btn.addEventListener('click', handler);
        btn.addEventListener('mouseenter', () => btn.style.color = '#2196F3');
        btn.addEventListener('mouseleave', () => btn.style.color = '#aaa');
        return btn;
    }

    function createMapsIconButton(link) {
        const a = document.createElement('a');
        a.href = link || '#';
        a.target = '_blank';
        a.rel = 'noopener noreferrer';
        a.title = 'Open in Google Maps';
        a.style.cssText = `
            display: inline-block; width: 20px; height: 20px;
            background: url('https://www.google.com/s2/favicons?sz=64&domain=google.com') center/contain no-repeat;
            cursor: pointer; text-decoration: none; transition: transform 0.2s;
        `;
        a.addEventListener('click', (e) => {
            e.preventDefault();
            if (link) window.open(link, '_blank', 'noopener,noreferrer');
        });
        a.addEventListener('mouseenter', () => a.style.transform = 'scale(1.1)');
        a.addEventListener('mouseleave', () => a.style.transform = '');
        return a;
    }

    /**
     * Clicks the played round element at the given index based on current game mode.
     */
    function clickPageRoundByIndex(index) {
        if (!currentGameData?.gameType || index < 0) return;

        const gameType = currentGameData.gameType;
        let roundElements = [];

        if (['duels', 'teamduels'].includes(gameType)) {
            roundElements = document.querySelectorAll('[class*="game-summary_playedRound__"]');
        }
        else if (['standard', 'challenge'].includes(gameType)) {
            roundElements = document.querySelectorAll(
                '[class*="coordinate-results_hideOnSmallScreen__"][class*="coordinate-results_clickableColumn__"]'
            );
        }
        else if (['battleroyalecountries'].includes(gameType)) {
            roundElements = document.querySelectorAll('[class*="tabs_tab__"] button');
        }
        else if (['battleroyaledistance'].includes(gameType)) {
            roundElements = document.querySelectorAll('[class*="distance_round__"]:not([class*="distance_header__"])');
        }

        if (index < roundElements.length) {
            roundElements[index].click();
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    //  SELECTION & EXPORT HANDLERS
    // ──────────────────────────────────────────────────────────────────────

    function toggleSelectAll(e) {
        const checked = e.target.checked;
        roundCheckboxes.forEach(({ el }) => el.checked = checked);
        updateSelectAllState();
    }

    function updateSelectAllState() {
        const all = roundCheckboxes.length > 0;
        const allChecked = all && roundCheckboxes.every(({ el }) => el.checked);
        const someChecked = roundCheckboxes.some(({ el }) => el.checked);
        const selectAll = document.getElementById('ggjsonexport-select-all');
        if (selectAll) {
            selectAll.checked = allChecked;
            selectAll.indeterminate = all && !allChecked && someChecked;
            selectAll.disabled = !all;
        }
    }

    function getSelectedRounds() {
        return roundCheckboxes
            .filter(({ el }) => el.checked)
            .map(({ roundData }) => roundData);
    }

    function copyToClipboard(text) {
        navigator.clipboard.writeText(text).catch(() => {
            const ta = document.createElement('textarea');
            ta.value = text;
            ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0';
            document.body.appendChild(ta);
            ta.select();
            document.execCommand('copy');
            document.body.removeChild(ta);
        });
    }

    function showTempTooltip(msg, targetElement = null) {
        const existing = document.querySelector('#ggjsonexport-tooltip');
        if (existing) existing.remove();

        if (!targetElement) {
            console.warn('Tooltip target missing');
            return;
        }

        const tooltip = document.createElement('div');
        tooltip.id = 'ggjsonexport-tooltip';
        tooltip.textContent = msg;
        tooltip.style.cssText = `
            position: fixed;
            background: #333;
            color: white;
            padding: 6px 10px;
            border-radius: 4px;
            font-size: 12px;
            z-index: 2;
            opacity: 0;
            transition: opacity 0.2s;
            pointer-events: none;
            white-space: nowrap;
        `;

        const rect = targetElement.getBoundingClientRect();

        document.body.appendChild(tooltip);

        const tooltipWidth = tooltip.offsetWidth;
        tooltip.style.left = (rect.left + rect.width / 2 - tooltipWidth / 2) + 'px';
        tooltip.style.top = (rect.top - 32) + 'px';

        setTimeout(() => tooltip.style.opacity = '1', 10);
        setTimeout(() => {
            tooltip.style.opacity = '0';
            setTimeout(() => tooltip.remove(), 200);
        }, 1500);
    }

    function copyJsonHandler(e) {
        const json = buildJsonForExport(getSelectedRounds(), currentGameData.mapName, currentGameData.gameType, currentGameId);
        copyToClipboard(json);
        showTempTooltip('JSON copied!', e.currentTarget);
    }

    function downloadJsonHandler(e) {
        const json = buildJsonForExport(getSelectedRounds(), currentGameData.mapName, currentGameData.gameType, currentGameId);
        const filename = `${currentGameData.gameType}_${currentGameId}.json`;
        const blob = new Blob([json], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
        showTempTooltip('JSON downloaded!', e.currentTarget);
    }

    function toggleContent() {
        const content = document.getElementById('ggjsonexport-rounds-content');
        const isHidden = content.style.display === 'none';
        const isVisible = !isHidden;

        content.style.display = isVisible ? 'none' : 'block';

        const toggleBtn = document.getElementById('ggjsonexport-toggle-btn');
        toggleBtn.innerHTML = '';
        const path = isVisible ? 'M7 15l5-5 5 5z' : 'M7 10l5 5 5-5z';
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('width', '16');
        svg.setAttribute('height', '16');
        svg.setAttribute('fill', 'currentColor');
        const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        pathEl.setAttribute('d', path);
        svg.appendChild(pathEl);
        toggleBtn.appendChild(svg);

        saveRoundsExpanded(!isVisible);
    }

    // ──────────────────────────────────────────────────────────────────────
    //  REACT DATA EXTRACTION
    // ──────────────────────────────────────────────────────────────────────

    function extractGameDataFromReact(element) {
        let current = element;
        while (current && current !== document.body) {
            const fiberKey = Object.keys(current).find(k => k.startsWith('__reactFiber$'));
            if (fiberKey) {
                const result = deepSearchReactNode(current[fiberKey]);
                if (result) return result;
            }
            current = current.parentElement;
        }
        return null;
    }

    function deepSearchReactNode(node, visited = new WeakSet()) {
        if (!node || typeof node !== 'object' || visited.has(node)) return null;
        visited.add(node);

        let props = node.memoizedProps || node.pendingProps;
        if (props) {
            const found = extractFromProps(props);
            if (found) return found;
        }

        const children = props?.children;
        if (children) {
            const childArray = Array.isArray(children) ? children : [children];
            for (const child of childArray) {
                if (child && typeof child === 'object') {
                    if (child.$$typeof && child.props) {
                        const found = extractFromProps(child.props);
                        if (found) return found;
                    }
                    if (child.memoizedProps || child.pendingProps || child.child || child.sibling) {
                        const nestedResult = deepSearchReactNode(child, visited);
                        if (nestedResult) return nestedResult;
                    }
                }
            }
        }

        return deepSearchReactNode(node.child, visited) || deepSearchReactNode(node.sibling, visited);
    }

    function extractFromProps(props) {
        if (!props || typeof props !== 'object') return null;
        // Standard Game Results
        if (props.preselectedGame) {
            return {
                rounds: props.preselectedGame.rounds,
                mapName: props.preselectedGame.mapName,
                gameType: props.preselectedGame.type.toLowerCase()  // 'standard', 'challenge'
            }
        }
        // Country Streaks
        if (props.selectedGame) {
            const isUsStateStreak = props.selectedGame.streakType === 'UsStateStreak'; // Special case
            return {
                rounds: props.selectedGame.rounds,
                mapName: props.selectedGame.mapName,
                gameType: props.selectedGame.streakType.toLowerCase(),  // 'CountryStreak', 'UsStateStreak'
                isUsStateStreak
            };
        }
        // Duels / Team Duels
        if (props.game) {
            // Game generates sets of 5 rounds for duels, we filter only played rounds
            return {
                rounds: props.game.rounds?.filter(round => round.hasProcessedRoundTimeout) || [],
                mapName: props.game.options?.map?.name,
                gameType: props.game.gameType.toLowerCase()   // 'Duels', 'TeamDuels'
            };
        }
        // Battle Royale
        if (props.children) { // Search children because we're using a parent container as target
            const children = Array.isArray(props.children) ? props.children : [props.children];
            for (const child of children) {
                if (child?.props?.summary && child?.props?.lobby) {
                    return {
                        rounds: child?.props?.summary?.rounds,
                        mapName: child?.props?.lobby?.mapName,
                        gameType: child?.props?.lobby?.gameType.toLowerCase()  // 'BattleRoyaleCountries', 'BattleRoyaleDistance'
                    };
                }
            }
        }
        return null;
    }

    // ──────────────────────────────────────────────────────────────────────
    //  PAGE DETECTION & ACTIVATION
    // ──────────────────────────────────────────────────────────────────────

    function getGameIdFromUrl() {
        const duelsGame = window.location.pathname.match(/\/(?:team-)?duels\/([^\/]+)\/summary/);
        if (duelsGame) return duelsGame[1];

        const brGame = window.location.pathname.match(/\/battle-royale\/([^\/]+)\/summary/);
        if (brGame) return brGame[1];

        const standardGame = window.location.pathname.match(/\/results\/([^\/]+)/);
        if (standardGame) return standardGame[1];

        return null;
    }

    function isDuelSummaryPage() {
        return /\/(?:team-)?duels\/[^\/]+\/summary/.test(window.location.pathname);
    }

    function isBattleRoyaleSummaryPage() {
        return window.location.pathname.includes('/battle-royale/') && window.location.pathname.includes('/summary');
    }

    function isGameResultsPage() {
        return window.location.pathname.includes('/results/');
    }

    async function activate() {
        if (isActive) return;

        // Finding React containers
        let container = null;
        if (isDuelSummaryPage()) {
            container = document.querySelector('[class*="game-summary_innerContainer_"]');
        } else if (isBattleRoyaleSummaryPage()) {
            container = document.querySelector('[class*="in-game_layout_"]');
        } else if (isGameResultsPage()) {
            container = document.querySelector('[class*="results_container_"]');
        }

        if (!container) return;

        const game = extractGameDataFromReact(container);
        const gameId = getGameIdFromUrl();
        if (!game || !gameId) return;

        roundCheckboxes = [];
        currentGameData = game;
        currentGameId = gameId;

        createWidget();
        await updateWidgetContent();

        observer = new MutationObserver(() => { });
        observer.observe(container, { childList: true, subtree: true });
        isActive = true;
    }

    async function updateWidgetContent() {
        const content = document.getElementById('ggjsonexport-rounds-content');
        content.innerHTML = '';

        let rounds = currentGameData.rounds || [];

        const parsedRounds = rounds
            .map((r, idx) => parseRoundData(r, idx, currentGameData.isUsStateStreak))
            .filter(Boolean);

        if (parsedRounds.length === 0) {
            content.textContent = 'No rounds found.';
            content.style.color = '#aaa';
            content.style.padding = '12px';
            content.style.background = 'transparent';
        } else {
            for (let i = 0; i < parsedRounds.length; i++) {
                const item = await createRoundItem(parsedRounds[i], i);
                content.appendChild(item);
            }
        }

        updateSelectAllState();
    }

    function deactivate() {
        if (observer) {
            observer.disconnect();
            observer = null;
        }
        if (widget) {
            widget.remove();
            widget = null;
        }
        document.removeEventListener('click', closeTagsDropdownOnClickOutside);
        isActive = false;
        currentGameData = null;
        currentGameId = null;
        roundCheckboxes = [];
    }

    function onPageChange() {
        if (isDuelSummaryPage() || isGameResultsPage() || isBattleRoyaleSummaryPage()) {
            setTimeout(activate, 300);
        } else {
            deactivate();
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    //  GLOBAL CLICK LISTENER (TO CLOSE TAGS DROPDOWN)
    // ──────────────────────────────────────────────────────────────────────

    function closeTagsDropdownOnClickOutside(e) {
        const dropdown = document.getElementById('ggjsonexport-tags-dropdown');
        const toggleBtn = document.getElementById('ggjsonexport-tags-btn');
        if (!dropdown || dropdown.style.display !== 'block') return;

        if (!dropdown.contains(e.target) && e.target !== toggleBtn) {
            dropdown.style.display = 'none';
        }
    }

    document.addEventListener('click', closeTagsDropdownOnClickOutside);

    // ──────────────────────────────────────────────────────────────────────
    //  INITIALIZATION
    // ──────────────────────────────────────────────────────────────────────

    const originalPush = history.pushState;
    const originalReplace = history.replaceState;
    history.pushState = function (...args) { originalPush.apply(this, args); onPageChange(); };
    history.replaceState = function (...args) { originalReplace.apply(this, args); onPageChange(); };
    window.addEventListener('popstate', onPageChange);

    if (isDuelSummaryPage() || isGameResultsPage() || isBattleRoyaleSummaryPage()) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => setTimeout(activate, 300));
        } else {
            setTimeout(activate, 300);
        }
    }
})();