GeoGuessr AutoSaver

Autosave your GeoGuessr games in a local folder.

// ==UserScript==
// @name         GeoGuessr AutoSaver
// @match        https://www.geoguessr.com/*
// @version      0.1
// @description  Autosave your GeoGuessr games in a local folder.
// @author       Jan Justi
// @website      https://github.com/janjusti/geoguessr-autosaver
// @license      GPL-3.0
// @grant        GM_xmlhttpRequest
// @require      https://unpkg.com/[email protected]/dist/axios.min.js
// @namespace https://greasyfork.org/users/1502987
// ==/UserScript==

(async function () {
    'use strict';

    const DB_NAME = 'folder-access-db';
    const STORE_NAME = 'handles';
    const HANDLE_KEY = 'dirHandle';
    const GAMESERVER_BASE_URL = 'https://game-server.geoguessr.com/api';

    function openDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, 1);
            request.onupgradeneeded = () => {
                request.result.createObjectStore(STORE_NAME);
            };
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async function getStoredHandle() {
        const db = await openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction(STORE_NAME, 'readonly');
            const store = tx.objectStore(STORE_NAME);
            const request = store.get(HANDLE_KEY);
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    async function storeHandle(handle) {
        const db = await openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction(STORE_NAME, 'readwrite');
            const store = tx.objectStore(STORE_NAME);
            const request = store.put(handle, HANDLE_KEY);
            request.onsuccess = () => resolve();
            request.onerror = () => reject(request.error);
        });
    }

    async function verifyPermission(handle) {
        const options = { mode: 'read' };
        if ((await handle.queryPermission(options)) === 'granted') return true;
        if ((await handle.requestPermission(options)) === 'granted') return true;
        return false;
    }

    async function isDirHandleValid(dirHandle) {
        try {
            for await (const _ of dirHandle.values()) {
                break; // we just need to confirm we can iterate
            }
            return true;
        } catch (e) {
            return false;
        }
    }

    async function getLatestDownloadedMatch() {
        let dirHandle = await getStoredHandle();

        if (!dirHandle || !(await verifyPermission(dirHandle)) || !(await isDirHandleValid(dirHandle))) {
            try {
                dirHandle = await window.showDirectoryPicker();
                if (!(await verifyPermission(dirHandle))) {
                    alert('Permission denied.');
                    return null;
                }
                await storeHandle(dirHandle);
            } catch (err) {
                notify('Could not access configured folder. Select another folder.', 'error');
                return null;
            }
        }

        try {
            const latestFileHandle = await dirHandle.getFileHandle('latest.txt');
            const file = await latestFileHandle.getFile();
            const text = await file.text();
            const latestFileName = text.trim();
            const ldm = (latestFileName ?? 'NoJSONFileDetected').replace(/\.json$/i, '');
            return { ldm, dirHandle };
        } catch (e) {
            if (e.name === 'NotFoundError') {
                const createNew = confirm(
                    `"latest.txt" not found in the selected folder.\n\nClick OK to create a new one, or Cancel to choose another folder.`
                );

                if (createNew) {
                    try {
                        const fileHandle = await dirHandle.getFileHandle('latest.txt', { create: true });
                        const writable = await fileHandle.createWritable();
                        await writable.write('');
                        await writable.close();
                        return { ldm: 'NoJSONFileDetected', dirHandle };
                    } catch (err) {
                        alert('Failed to create latest.txt.');
                        return null;
                    }
                } else {
                    try {
                        dirHandle = await window.showDirectoryPicker();
                        if (!(await verifyPermission(dirHandle))) {
                            alert('Permission denied.');
                            return null;
                        }
                        await storeHandle(dirHandle);
                        return await getLatestDownloadedMatch(); // retry with new folder
                    } catch {
                        alert('Could not access new folder.');
                        return null;
                    }
                }
            } else {
                console.error('Unexpected error while accessing latest.txt:', e);
                alert('Unexpected error while accessing latest.txt.');
                return null;
            }
        }
    }


    function sleepRandom(min = 1000, max = 2000) {
        const delay = Math.floor(Math.random() * (max - min + 1)) + min;
        return new Promise(resolve => setTimeout(resolve, delay));
    }

    function extractGameIds(entry, gameIds, latestDownloadedMatch) {
        const payloadJson = JSON.parse(entry.payload);
        const payloadArray = Array.isArray(payloadJson) ? payloadJson : [payloadJson];

        for (const payload of payloadArray) {
            let id, mode, timeStr;

            if (payload.gameId !== undefined) {
                id = payload.gameId;
                mode = payload.gameMode;
                timeStr = entry.time;
            } else if (payload.payload && payload.payload.gameId !== undefined) {
                id = payload.payload.gameId;
                mode = payload.payload.gameMode;
                timeStr = payload.time ?? entry.time;
            } else {
                continue;
            }

            if (id == latestDownloadedMatch) {
                return true;
            }

            gameIds.push({ id, time: timeStr, mode });
        }

        return false;
    }

    async function getGameIds(session, latestDownloadedMatch) {
        const gameIds = [];
        let paginationToken = null;
        let playerId = null;
        let fetchCount = 0;
        const maxFetches = -1; // limit for testing

        try {
            while (true) {
                fetchCount++;
                if (maxFetches > 0 && fetchCount > maxFetches) break;

                notify(`Fetching games...${paginationToken ? ` (page ${fetchCount})` : ''}`);
                const response = await session.get('https://www.geoguessr.com/api/v4/feed/private', {
                    params: { paginationToken }
                });
                const data = response.data;
                paginationToken = data.paginationToken;

                if (!playerId && data.entries.length > 0) {
                    playerId = data.entries[0].user.id;
                }

                let foundOlder = false;

                for (const entry of data.entries) {
                    try {
                        foundOlder = extractGameIds(entry, gameIds, latestDownloadedMatch);
                        if (foundOlder) break;
                    } catch (error) {
                        console.error(error);
                    }
                }

                if (!paginationToken || foundOlder) break;
                await sleepRandom();
            }
        } catch (error) {
            console.error("An error occurred while fetching game IDs:", error);
        }

        return gameIds;
    }

    async function saveGameJson(dirHandle, gameId, data) {
        const fileHandle = await dirHandle.getFileHandle(`${gameId}.json`, { create: true });
        const writable = await fileHandle.createWritable();
        await writable.write(JSON.stringify(data));
        await writable.close();
    }

    async function updateLatestFile(dirHandle, newFilename) {
        const fileHandle = await dirHandle.getFileHandle('latest.txt', { create: true });
        const writable = await fileHandle.createWritable();
        await writable.write(newFilename);
        await writable.close();
    }

    function cropToMinutes(isoString) {
        return isoString.replace("T", " ").slice(0, 16);
    }

    async function downloadGameIds(gameIds, dirHandle, session) {
        gameIds.sort((a, b) => new Date(a.time) - new Date(b.time));
        const total = gameIds.length;

        for (let i = 0; i < total; i++) {
            const { id, time, mode } = gameIds[i];
            const shortId = id.split('-')[0];
            let url;

            if (mode === 'Duels' || mode === 'TeamDuels') {
                url = `${GAMESERVER_BASE_URL}/duels/${id}`;
            } else if (mode === 'BattleRoyaleDistance' || mode === 'BattleRoyaleCountries') {
                url = `${GAMESERVER_BASE_URL}/battle-royale/${id}`;
            } else {
                notify(`(${i + 1}/${total}) Unsupported mode "${mode}" for game ${shortId}`, 'alert');
                continue;
            }

            try {
                const response = await session.get(url);
                await saveGameJson(dirHandle, id, response.data);
                notify(`(${i + 1}/${total}) Saved ${shortId} (${mode}|${cropToMinutes(time)})`);
                await updateLatestFile(dirHandle, `${id}.json`);
            } catch (e) {
                const errStr = `(${i + 1}/${total}) Failed to download ${id}`;
                notify(errStr, 'error');
                console.warn(errStr, e);
            }

            if (i != total - 1) {
                await sleepRandom(1000, 3000);
            }
        }
    }

    function notify(message, type = null, duration = null) {
        const containerId = 'autosaver-notify-container';

        if (duration === null) {
            duration =
                type === 'error' ? 7000 :
            type === 'alert' ? 5000 :
            type === 'ok' ? 4000 :
            3000;
        }

        let container = document.getElementById(containerId);
        if (!container) {
            container = document.createElement('div');
            container.id = containerId;
            container.style.position = 'fixed';
            container.style.bottom = '10px';
            container.style.right = '10px';
            container.style.zIndex = '9999';
            container.style.display = 'flex';
            container.style.flexDirection = 'column';
            container.style.gap = '5px';
            document.body.appendChild(container);
        }

        const box = document.createElement('div');
        box.textContent = message;

        box.style.background =
            type === 'ok' ? '#4CAF50' :
        type === 'error' ? '#f44336' :
        type === 'alert' ? '#ff9800' :
        '#333';

        box.style.color = '#fff';
        box.style.padding = '8px 12px';
        box.style.borderRadius = '4px';
        box.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.3)';
        box.style.fontSize = '14px';
        container.appendChild(box);

        setTimeout(() => {
            box.remove();
        }, duration);
    }

    function onElementAppearOnceUntilGone(selector, callback) {
        let activeElement = null;
        let hasRun = false;

        const observer = new MutationObserver(() => {
            const el = document.querySelector(selector);

            if (!hasRun && el && location.pathname.includes('/multiplayer')) {
                hasRun = true;
                activeElement = el;
                callback(el);
            }

            // Reset if the element is removed
            if (hasRun && activeElement && !document.body.contains(activeElement)) {
                hasRun = false;
                activeElement = null;
            }
        });

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

    async function init() {
        try {
            notify('Starting AutoSaver...');
            const { ldm, dirHandle } = await getLatestDownloadedMatch();
            notify(`Latest Downloaded: ${ldm.split('-')[0]}`)

            const session = axios.create({
                withCredentials: true
            });
            const gameIds = await getGameIds(session, ldm);
            notify(`${gameIds.length} game(s) to download`);

            await downloadGameIds(gameIds, dirHandle, session);
            notify('Done.', 'ok');
        } catch (e) {
            notify('AutoSaver failed to run :(', 5000, 'error');
            console.warn(e);
        }
    }

    onElementAppearOnceUntilGone('div[class*="division-header"]', (el) => {
        init();
    });

})();