GeoGuessr Auto-Save to Google Sheets

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
    }

})();