GeoGuessr Auto-Save to Google Sheets

Automatically saves all solo games to Google Sheets and tracks current games

// ==UserScript==
// @name         GeoGuessr Auto-Save to Google Sheets
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Automatically saves all solo games to Google Sheets and tracks current games
// @author       Flykii
// @match        https://www.geoguessr.com/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      www.geoguessr.com
// @connect      script.google.com
// @connect      script.googleusercontent.com
// @connect      *
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        GOOGLE_SCRIPT_URL: 'YOUR_URL_HERE',
        DELAY_MS: 500,
        INITIAL_SCAN_PAGES: 20,
        CHECK_INTERVAL: 30000
    };

    let isScanning = false;
    let currentGameToken = null;
    let lastProcessedTime = GM_getValue('lastProcessedTime', Date.now() - 24*60*60*1000);

    function init() {
        console.log('Solo Stats - GeoGuessr Auto-Save to Google Sheets');

        setTimeout(() => {
            scanRecentGames();
        }, 2000);

        startGameMonitoring();

        setInterval(() => {
            if (!isScanning) {
                scanRecentGames();
            }
        }, CONFIG.CHECK_INTERVAL);

        let currentUrl = window.location.href;
        setInterval(() => {
            if (window.location.href !== currentUrl) {
                currentUrl = window.location.href;
                handleUrlChange(currentUrl);
            }
        }, 1000);
        checkFirstTime();
    }
    function createHistoricalImportUI() {
        const importUI = document.createElement('div');
        importUI.id = 'historical-import-ui';
        importUI.innerHTML = `
            <div style="
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                background: white;
                border: 2px solid #4caf50;
                border-radius: 10px;
                padding: 20px;
                box-shadow: 0 4px 20px rgba(0,0,0,0.3);
                z-index: 10001;
                font-family: Arial, sans-serif;
                max-width: 400px;
                text-align: center;
            ">
                <h3 style="margin-top: 0; color: #4caf50;">Import Historical Games into your Google Sheet</h3>
                <p style="margin: 15px 0;">Import your previous solo Geoguessr games?</p>
                <div style="margin: 15px 0;">
                    <label for="pageCount">Pages to scan:</label><br>
                    <input type="number" id="pageCount" value="10" min="1" max="100" style="
                        margin: 5px;
                        padding: 5px;
                        border: 1px solid #ddd;
                        border-radius: 4px;
                        width: 60px;
                    ">
                </div>
                <div style="margin: 15px 0;">
                    <button id="startImport" style="
                        background: #4caf50;
                        color: white;
                        border: none;
                        padding: 10px 20px;
                        border-radius: 5px;
                        cursor: pointer;
                        margin: 5px;
                    ">Start Import</button>
                    <button id="cancelImport" style="
                        background: #f44336;
                        color: white;
                        border: none;
                        padding: 10px 20px;
                        border-radius: 5px;
                        cursor: pointer;
                        margin: 5px;
                    ">Cancel</button>
                </div>
                <div id="importProgress" style="display: none; margin: 15px 0;">
                    <div style="background: #f0f0f0; border-radius: 10px; overflow: hidden;">
                        <div id="progressBar" style="
                            background: #4caf50;
                            height: 20px;
                            width: 0%;
                            transition: width 0.3s ease;
                        "></div>
                    </div>
                    <p id="progressText">Starting...</p>
                </div>
            </div>
        `;

        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);
            z-index: 10000;
        `;
        
        document.body.appendChild(overlay);
        overlay.appendChild(importUI);

        document.getElementById('startImport').onclick = () => startHistoricalImport();
        document.getElementById('cancelImport').onclick = () => {
            document.body.removeChild(overlay);
        };
    }

    async function startHistoricalImport() {
        const pageCount = parseInt(document.getElementById('pageCount').value) || 10;
        const progressDiv = document.getElementById('importProgress');
        const progressBar = document.getElementById('progressBar');
        const progressText = document.getElementById('progressText');
        
        document.getElementById('startImport').style.display = 'none';
        document.getElementById('cancelImport').style.display = 'none';
        progressDiv.style.display = 'block';

        let importedCount = 0;
        let totalChecked = 0;
        let currentPage = 0;
        let paginationToken = null;

        try {
            while (currentPage < pageCount) {
                progressText.textContent = `Scanning page ${currentPage + 1}/${pageCount}...`;
                progressBar.style.width = `${(currentPage / pageCount) * 100}%`;

                const feedData = await fetchFeedData(paginationToken);
                
                if (!feedData || !feedData.entries || feedData.entries.length === 0) {
                    break;
                }

                for (const entry of feedData.entries) {
                    const gameTokens = extractGameTokens(entry);
                    totalChecked += gameTokens.length;
                    
                    for (const token of gameTokens) {
                        const saved = await saveGameIfSolo(token);
                        if (saved) {
                            importedCount++;
                            progressText.textContent = `Found ${importedCount} games (${totalChecked} checked) - Page ${currentPage + 1}/${pageCount}`;
                        }
                        await delay(200);
                    }
                }

                if (!feedData.paginationToken) break;
                paginationToken = feedData.paginationToken;
                currentPage++;
                await delay(500);
            }

            progressBar.style.width = '100%';
            progressText.innerHTML = `
                Import completed<br>
                <strong>${importedCount}</strong> new games imported<br>
                <small>${totalChecked} games checked total</small>
            `;

            GM_setValue('lastProcessedTime', Date.now());

            setTimeout(() => {
                const overlay = document.querySelector('#historical-import-ui').parentElement.parentElement;
                document.body.removeChild(overlay);
            }, 3000);

        } catch (error) {
            progressText.innerHTML = `Error during import: ${error.message}`;
            console.error('Historical import error:', error);
            
            setTimeout(() => {
                progressDiv.innerHTML += '<br><button onclick="this.parentElement.parentElement.parentElement.parentElement.remove()" style="background: #f44336; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; margin-top: 10px;">Close</button>';
            }, 1000);
        }
    }

    function checkFirstTime() {
        const firstTime = GM_getValue('firstTimeUser', true);
        if (firstTime) {
            setTimeout(() => {
                createHistoricalImportUI();
                GM_setValue('firstTimeUser', false);
            }, 3000);
        }
    }





    function startGameMonitoring() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.addedNodes.length > 0) {
                    checkForGameElements();
                }
            });
        });

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

    function handleUrlChange(url) {
        const gameMatch = url.match(/\/games\/([a-zA-Z0-9_-]+)/);
        if (gameMatch && gameMatch[1] !== currentGameToken) {
            currentGameToken = gameMatch[1];
            console.log('Solo Stats - New game :', currentGameToken);

            setTimeout(() => {
                saveCurrentGame(currentGameToken);
            }, 5000);
        }
    }

    function checkForGameElements() {
        const gameEndElements = document.querySelectorAll('[data-qa="game-finished"], .result-view, .results__summary');
        if (gameEndElements.length > 0 && currentGameToken) {
            setTimeout(() => {
                saveCurrentGame(currentGameToken);
            }, 2000);
        }
    }

    async function scanRecentGames() {
        if (isScanning) return;

        isScanning = true;
        console.log('Solo Stats - Scanning recent games...');

        try {
            let paginationToken = null;
            let page = 0;
            let newGamesFound = 0;

            while (page < CONFIG.INITIAL_SCAN_PAGES) {
                const feedData = await fetchFeedData(paginationToken);

                if (!feedData || !feedData.entries || feedData.entries.length === 0) {
                    break;
                }

                for (const entry of feedData.entries) {
                    const entryTime = new Date(entry.created || entry.time).getTime();
                    if (entryTime <= lastProcessedTime) {
                        page = CONFIG.INITIAL_SCAN_PAGES;
                        break;
                    }

                    const gameTokens = extractGameTokens(entry);
                    for (const token of gameTokens) {
                        const saved = await saveGameIfSolo(token);
                        if (saved) {
                            newGamesFound++;
                        }
                        await delay(CONFIG.DELAY_MS);
                    }
                }

                if (!feedData.paginationToken) break;
                paginationToken = feedData.paginationToken;
                page++;
                await delay(CONFIG.DELAY_MS);
            }

            if (newGamesFound > 0) {
                GM_setValue('lastProcessedTime', Date.now());
                console.log(`Solo Stats - ${newGamesFound} new games saved`);
            } else {
                console.log('Solo Stats - 0 New games found');
            }

        } catch (error) {
            console.error('❌ Erreur lors du scan:', error);
        } finally {
            isScanning = false;
        }
    }

    function extractGameTokens(entry) {
        const tokens = [];

        try {
            if (entry.payload && entry.payload.token) {
                tokens.push(entry.payload.token);
            }
            else if (entry.payload && typeof entry.payload === 'string') {
                if (entry.payload.startsWith('[')) {
                    const payloadArray = JSON.parse(entry.payload);
                    for (const item of payloadArray) {
                        if (item.payload && item.payload.gameToken) {
                            tokens.push(item.payload.gameToken);
                        }
                    }
                } else {
                    const payloadData = JSON.parse(entry.payload);
                    if (payloadData.gameToken) {
                        tokens.push(payloadData.gameToken);
                    }
                }
            }
            else if (entry.payload && entry.payload.gameToken) {
                tokens.push(entry.payload.gameToken);
            }

            const fullEntryStr = JSON.stringify(entry);
            const gameMatches = fullEntryStr.match(/"gameToken":"([a-zA-Z0-9_-]+)"/g);
            if (gameMatches) {
                gameMatches.forEach(match => {
                    const token = match.match(/"gameToken":"([a-zA-Z0-9_-]+)"/)[1];
                    if (!tokens.includes(token)) {
                        tokens.push(token);
                    }
                });
            }

            const gameUrlMatch = fullEntryStr.match(/https:\/\/geoguessr\.com\/games\/([a-zA-Z0-9_-]+)/);
            if (gameUrlMatch && !tokens.includes(gameUrlMatch[1])) {
                tokens.push(gameUrlMatch[1]);
            }

        } catch (error) {
            console.warn('extraction tokens error:', error);
        }

        return tokens;
    }

    async function saveCurrentGame(token) {
        return await saveGameIfSolo(token);
    }

    async function saveGameIfSolo(token) {
        try {
            const gameData = await fetchGameData(token);
            if (!gameData) return false;

            if (gameData.type === 'duels' || gameData.type === 'team-duels' || gameData.type === 'battle_royale' ||
                (gameData.players && gameData.players.length > 1)) {
                return false;
            }

            const savedGames = GM_getValue('savedGames', '{}');
            const savedGamesObj = JSON.parse(savedGames);
            if (savedGamesObj[token]) {
                return false;
            }

            const gameInfo = formatGameData(gameData);
            const success = await sendToGoogleSheets(gameInfo);

            if (success) {
                savedGamesObj[token] = true;
                GM_setValue('savedGames', JSON.stringify(savedGamesObj));

                console.log(`Solo Stats - Game saved: ${gameData.mapName} - ${gameInfo.score} points`);
                return true;
            }

        } catch (error) {
            console.error(`Solo Stats - Error savong game ${token}:`, error);
        }

        return false;
    }

    function formatGameData(gameData) {
        const score = gameData.player?.totalScore?.amount || 0;
        const distance = gameData.player?.totalDistanceInMeters || 0;
        const gameMode = getGameModeFromRestrictions(gameData);

        const rounds = gameData.rounds?.map((round, index) => {
            const guess = gameData.player?.guesses?.[index] || {};

            return {
                roundNumber: index + 1,
                score: guess.roundScoreInPoints || guess.roundScore?.amount || 0,
                distance: guess.distanceInMeters || guess.distance?.meters?.amount * 1000 || 0,
                country: round.streakLocationCode || 'unknown',
                lat: round.lat,
                lng: round.lng,
                guessLat: guess.lat,
                guessLng: guess.lng,
                time: guess.time || 0
            };
        }) || [];

        return {
            token: gameData.token,
            date: new Date().toISOString(),
            score: parseInt(score) || 0,
            distance: distance,
            mapName: gameData.mapName || 'Unknown',
            mapId: gameData.map,
            gameMode: gameMode,
            timeLimit: gameData.timeLimit || 0,
            rounds: rounds,
            restrictions: {
                forbidMoving: gameData.forbidMoving || false,
                forbidZooming: gameData.forbidZooming || false,
                forbidRotating: gameData.forbidRotating || false
            }
        };
    }

    function getGameModeFromRestrictions(gameData) {
        const { forbidMoving, forbidZooming, forbidRotating } = gameData;

        if (forbidMoving && forbidZooming && forbidRotating) {
            return 'NMPZ';
        } else if (forbidMoving && !forbidZooming && !forbidRotating) {
            return 'No Move';
        } else if (!forbidMoving && !forbidZooming && !forbidRotating) {
            return 'Moving';
        } else {
            const restrictions = [];
            if (forbidMoving) restrictions.push('NM');
            if (!forbidZooming) restrictions.push('Z');
            if (!forbidRotating) restrictions.push('R');
            return restrictions.length > 0 ? restrictions.join('') : 'Custom';
        }
    }

    async function sendToGoogleSheets(gameData) {
        const userId = await generateUserId();

        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: CONFIG.GOOGLE_SCRIPT_URL,
                headers: {
                    'Content-Type': 'application/json',
                },
                data: JSON.stringify({
                    action: 'saveGame',
                    gameData: gameData,
                    userId: userId
                }),
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const result = JSON.parse(response.responseText);
                            if (result.success) {
                                showSpreadsheetLink(result.spreadsheetUrl);
                                resolve(true);
                            } else {
                                console.error('Error Google Sheets :', result.error);
                                resolve(false);
                            }
                        } catch (error) {
                            console.error('Erreur parsing Google Sheets response :', error);
                            resolve(false);
                        }
                    } else {
                        console.error(`Error HTTP Google Sheets : ${response.status}`);
                        resolve(false);
                    }
                },
                onerror: function(error) {
                    console.error('Erreur request Google Sheets :', error);
                    resolve(false);
                }
            });
        });
    }

    async function generateUserId() {
        let userId = GM_getValue('userId');
        if (!userId) {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            ctx.textBaseline = 'top';
            ctx.font = '14px Arial';
            ctx.fillText('GeoGuessr User ID', 2, 2);

            const fingerprint = canvas.toDataURL();
            const timestamp = Date.now();
            const random = Math.random().toString(36).substring(2, 15);

            userId = btoa(`${fingerprint.substring(0, 20)}-${timestamp}-${random}`).substring(0, 16);
            GM_setValue('userId', userId);
        }
        return userId;
    }

    function showSpreadsheetLink(url) {
        const shown = GM_getValue('spreadsheetLinkShown', false);
        if (!shown && url) {
            console.log('Solo Stats - GeoGuessr:', url);

            const notification = document.createElement('div');
            notification.innerHTML = `
                <div style="
                    position: fixed;
                    top: 20px;
                    right: 20px;
                    background: #4caf50;
                    color: white;
                    padding: 15px 20px;
                    border-radius: 8px;
                    z-index: 10000;
                    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                    font-family: Arial, sans-serif;
                    max-width: 300px;
                ">
                    <div style="font-weight: bold; margin-bottom: 8px;">📊 GeoGuessr Stats</div>
                    <div style="font-size: 13px; margin-bottom: 10px;">Solo Stats - Games auto saved</div>
                    <a href="${url}" target="_blank" style="
                        color: white;
                        text-decoration: underline;
                        font-size: 13px;
                    ">Your stats</a>
                    <button onclick="this.parentElement.parentElement.remove()" style="
                        float: right;
                        background: none;
                        border: none;
                        color: white;
                        cursor: pointer;
                        font-size: 16px;
                        margin-top: -5px;
                    ">×</button>
                </div>
            `;

            document.body.appendChild(notification);
            setTimeout(() => {
                if (notification.parentElement) {
                    notification.remove();
                }
            }, 10000);

            GM_setValue('spreadsheetLinkShown', true);
        }
    }

    async function fetchFeedData(paginationToken) {
        let url = '/api/v4/feed/private?count=50';
        if (paginationToken) {
            url += `&paginationToken=${encodeURIComponent(paginationToken)}`;
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                headers: {
                    'Content-Type': 'application/json',
                },
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const data = JSON.parse(response.responseText);
                            resolve(data);
                        } catch (error) {
                            reject(new Error('Error parsing JSON: ' + error.message));
                        }
                    } else {
                        reject(new Error(`Error HTTP: ${response.status}`));
                    }
                },
                onerror: function(error) {
                    reject(new Error('Errr request: ' + error.message));
                }
            });
        });
    }

    async function fetchGameData(token) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `/api/v3/games/${token}`,
                headers: {
                    'Content-Type': 'application/json',
                },
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const data = JSON.parse(response.responseText);
                            resolve(data);
                        } catch (error) {
                            console.warn(`Errorr parsing game ${token}:`, error);
                            resolve(null);
                        }
                    } else {
                        console.warn(`cant look up for game ${token} : ${response.status}`);
                        resolve(null);
                    }
                },
                onerror: function() {
                    resolve(null);
                }
            });
        });
    }

    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

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

})();