NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name GeoGuessr Path Logger - beta // @namespace xsanda // @description Add a trace of where you have been to GeoGuessr’s results screen // @version 0.4.8 // @include https://www.geoguessr.com/* // @require https://openuserjs.org/src/libs/xsanda/Run_code_as_client.js // @require https://openuserjs.org/src/libs/xsanda/Google_Maps_Promise.js // @run-at document-start // @connect path-logger.xsanda.me // @grant GM.xmlHttpRequest // @license MIT // ==/UserScript== /*jshint esversion: 8 */ /* globals unsafeWindow, runAsClient, googleMapsPromise, exportFunction */ // An AWS server, running the code at https://gist.github.com/xsanda/f916f964c100b9194d21a21628364d98 const SERVER = "https://path-logger.xsanda.me"; // Send a route trace to the server. unsafeWindow.saveRouteToServer = exportFunction((data) => { GM.xmlHttpRequest({ method: 'POST', url: SERVER + "/challenges.php", data: JSON.stringify(data), onabort: e => console.error('abort submitting route', e), onerror: e => console.error('error submitting route', e), ontimeout: e => console.error('timeout submitting route', e), onload: response => { if (response.status > 400) console.error("Server failure", response); } }); }, unsafeWindow); // Receive the route traces from the server. unsafeWindow.getRoutesFromServer = exportFunction((token, resolve, reject) => { try { GM.xmlHttpRequest({ method: 'GET', url: SERVER + "/challenges/" + token + ".json", onabort: e => (console.error('aborted submitting route', e), reject(e)), onerror: e => (console.error('error submitting route', e), reject(e)), ontimeout: e => (console.error('timeout submitting route', e), reject(e)), onload: response => { if (response.status > 400) { console.error("Server failure", response), reject(response.status); } else resolve(response.responseText); } }); } catch (e) { console.error('error submitting route', e); reject(e); } }, unsafeWindow); // When Google maps has loaded, run the following code: googleMapsPromise.then(() => runAsClient(() => { const google = window.google; const KEEP_FOR = 1000 * 60 * 60 * 24 * 7; // 1 week // Keep a track of the lines drawn on the map, so they can be removed let markers = []; const NOT_GIVEN = Symbol(); // An ES5 version of ?. const safeNav = (obj, ...path) => { if (obj === null || obj === undefined) return undefined; const [first = NOT_GIVEN, ...rest] = path; if (first === NOT_GIVEN) return obj; let next; for (const option of Array.isArray(first) ? first : [first]) { next = typeof option === 'function' ? option(obj) : obj[option]; if (next !== undefined && next !== null) break; } return safeNav(next, ...rest); }; // window.next?.router?.components?.[window?.next?.router?.route]?.props?.pageProps const getProps = (...rest) => safeNav( window, 'next', 'router', 'components', safeNav(window, 'next', 'router', 'route'), 'props', 'pageProps', ...rest, ); // Get the current user. const getGame = (...rest) => getProps( ['gamePlayedByCurrentUser', 'game'], ...rest, ); const loadGameForChallenge = async () => { const match = /^\/challenge\/(\w+)/.exec(location.pathname); if (!match) return; const props = getProps(); if (!props || props.game || props.gamePlayedByCurrentUser) return; const token = match[1]; const serverData = await fetch(`https://www.geoguessr.com/api/v3/challenges/${token}`, { "method": "POST" }); props.game = await serverData.json(); }; const isBreakdown = () => location.pathname.startsWith("/results/"); const isChallenge = () => location.pathname.startsWith("/challenge/"); const isGamePage = () => isChallenge() || location.pathname.startsWith("/results/") || location.pathname.startsWith("/game/"); // Detect if a results screen is visible, so the traces should be shown const resultShown = () => !!document.querySelector('.result') || location.href.includes('results'); // Detect if only a single result is shown const singleResult = () => !!document.querySelector("[data-qa=guess-description]") || !!document.querySelector('.country-streak-result__sub-title'); // Get the game ID, for storing the trace against const challengeToken = () => getProps('challenge', 'token') || getProps('challengeToken'); const id = () => challengeToken() || getGame('token'); const roundNumber = () => { const el = document.querySelector('[data-qa=round-number] :nth-child(2)'); return el ? parseInt(el.innerHTML) : 0; }; const roundID = () => id() + '-' + roundNumber(); // Get the location of the street view const getPosition = sv => ({ lat: sv.position.lat(), lng: sv.position.lng(), }); // Record the time a game was played const updateTimestamp = () => { const timestamps = JSON.parse(localStorage.timestamps || "{}"); timestamps[id()] = Date.now(); localStorage.timestamps = JSON.stringify(timestamps); }; // Remove all games older than a week const clearOldGames = logErrors(() => { const timestamps = JSON.parse(localStorage.timestamps || "{}"); // Delete all games older than a week const cutoff = Date.now() - KEEP_FOR; for (const [gameID, gameTime] of Object.entries(timestamps)) { if (gameTime < cutoff) { delete timestamps[gameID]; for (const recording of Object.keys(localStorage).filter(key => key.startsWith(gameID + '-'))) { delete localStorage[recording]; } } } localStorage.timestamps = JSON.stringify(timestamps); }); // Keep a track of whether we are in a round already let inGame = false; // Keep a track of the current round’s route let route; // Keep a track of the start location for the current round, for detecting the return to start button let start; clearOldGames(); // Wrap a function in an error logger. This is good for making sure that errors in callbacks aren’t lost function logErrors(f) { return async (...args) => { try { await f(...args); } catch (e) { console.error("GeoGuessr Path logger error:", e); } }; } // Handle the street view being navigated const onMove = logErrors((sv) => { try { if (!isGamePage()) return; const position = getPosition(sv); if (!inGame) { // Do nothing if the map is being updated in the background, e.g. on page load while the results are still shown if (resultShown()) return; // otherwise start the round inGame = true; start = position; route = []; } // If we’re at the start, begin a new trace if (position.lat == start.lat && position.lng == start.lng) route.push([]); // Add the location to the trace route[route.length - 1].push(position); } catch (e) { console.error("GeoGuessr Path Logger Error:", e); } }); let mapState = 0; // The geometry API isn’t loaded unless a Street View has been displayed since the last load. const loadGeometry = () => new Promise(logErrors((resolve, reject) => { const existingScript = document.querySelector("script[src^='https://maps.googleapis.com/maps-api-v3/api/js/']"); if (!existingScript) reject("No Google Maps loaded yet"); const libraryURL = existingScript.src.replace(/(.+\/)(.+?)(\.js)/, '$1geometry$3'); document.head.appendChild(Object.assign(document.createElement("script"), { onload: resolve, type: "text/javascript", src: libraryURL, })); })); const saveRoute = async () => { // encode the route to reduce the storage required. const encodedRoutes = route.map(path => google.maps.geometry.encoding.encodePath(path.map(point => new google.maps.LatLng(point)))); localStorage[roundID()] = JSON.stringify(encodedRoutes); // Get the current user info let game = getGame(); if (game && game.type === "challenge") { window.saveRouteToServer({ player: game.player.id, challenge: challengeToken(), round: roundNumber(), route: encodedRoutes }); } updateTimestamp(); }; const showMoreMarkers = (map, traces, color) => { const newMarkers = (traces || []).flatMap(route => ( // Render each trace within each round as a red line route.map(polyline => ( new google.maps.Polyline({ path: google.maps.geometry.encoding.decodePath(polyline), geodesic: true, strokeColor: color || '#FF0000', strokeOpacity: 1.0, strokeWeight: 2, }) )) )); // Add all traces to the map newMarkers.forEach(m => m.setMap(map)); markers.push(...newMarkers); }; const showMarkers = (map, traces, color) => { // Hide all previous traces markers.forEach(m => m.setMap(null)); markers = []; showMoreMarkers(map, traces, color); }; const playerCache = { token: undefined, loaded: 0, // Promise<{ id /* unique, not in DOM */, pinUrl /* unique or empty */, nick /* not unique */, score /* sorted */ }> leaderboardPromise: undefined, }; const loadPlayers = async (token, count) => { if (playerCache.token !== token || playerCache.loaded < count) { playerCache.token = token; playerCache.loaded = count; playerCache.leaderboardPromise = fetch(`https://www.geoguessr.com/api/v3/results/scores/${token}/0/${count + 1}`) .then(response => response.json()) .then(results => results.map(result => ({ id: result.userId, pinUrl: result.pinUrl, nick: result.game.player.nick, score: result.totalScore }))); } return await playerCache.leaderboardPromise; }; const findPlayer = async ({ score, pinUrl, nick }) => { // SVG images are only used when no user picture is given const rawUrl = pinUrl && pinUrl.endsWith(".svg") ? "" : pinUrl; // Check the current player const currentPlayer = getGame('player'); if (currentPlayer.totalScore.amount === score && currentPlayer.pin.url === rawUrl && currentPlayer.nick.trim() === nick.trim()) return currentPlayer.id; // Load the leaderboard (because it is not possible to get the user ID from the DOM) const visible = Array.from(document.querySelectorAll('.results-highscore__player-place')).filter(x => x.innerText).length; const results = await loadPlayers(challengeToken(), visible); const selected = results.filter(result => ( (!score || result.score === score) && (pinUrl === undefined || result.pinUrl === rawUrl) && (!nick || result.nick.trim() === nick.trim()) )); return safeNav(selected, 0, 'id'); }; const getSelectedPlayers = async () => { const PLAYER_IMG_SELECTOR = '.results-highscore__player-pin img'; const PLAYER_NAME_SELECTOR = '.results-highscore__player-nick'; const selected = [...document.querySelectorAll('.results-highscore__player-cell.results-highscore__cell--selected')]; const selectedTotals = [...document.querySelectorAll([ '.results-highscore__guess-cell--total.results-highscore__cell--selected .results-highscore__guess-cell-score', '.results-highscore__country-cell.results-highscore__cell--selected .results-highscore__guess-cell-score' ].join())]; const foundPlayers = selected.map((el, i) => findPlayer({ pinUrl: safeNav(el.querySelector('.results-highscore__player-pin img'), 'src', str => str.replace(/^.+(?=pin)/, '')), nick: el.querySelector('.results-highscore__player-nick').innerText, score: Number.parseInt(selectedTotals[i].innerText.replace(/,/g, '')), })); return (await Promise.all(foundPlayers)).filter(Boolean); }; const routesCache = { token: undefined, dataPromise: undefined, }; const fetchOthersRoutes = async (token) => { if (routesCache.token !== token) { routesCache.dataPromise = new Promise((resolve, reject) => window.getRoutesFromServer(token, resolve, reject)) .then(res => JSON.parse(res)) .catch(() => ({})); } return await routesCache.dataPromise; }; const showMyMarkers = (map) => { // Show all rounds for the current game when viewing the full results const roundsToShow = singleResult() ? [roundID()] : Object.keys(localStorage).filter(map => map.startsWith(id())); const routes = roundsToShow.map(key => localStorage[key]).filter(Boolean).map(route => JSON.parse(route)); if (routes.length === 0) return false; showMoreMarkers(map, routes); return true; }; const showAllMarkers = logErrors(async (map) => { const players = await getSelectedPlayers(); const routes = await fetchOthersRoutes(challengeToken()); const me = getGame('player', 'id'); const myPos = players.indexOf(me); showMarkers(null, []); if (myPos !== -1) { const loadedLocally = showMyMarkers(map); if (loadedLocally) players.splice(myPos, 1); } showMoreMarkers(map, players.flatMap(player => Object.values(routes[player] || {})), "#F99"); }); let clickHandler; const onMapUpdate = logErrors(async (map) => { if (!isGamePage()) return; // Wait for any server requests to resolve await Promise.all([ loadGameForChallenge(), !google.maps.geometry && loadGeometry(), ]); // create a checksum of the game state, only updating the map when this changes, to save on computation const newMapState = (inGame ? 5 : 0) + (resultShown() ? 10 : 0) + (singleResult() ? 20 : 0) + roundNumber(); if (newMapState == mapState) return; mapState = newMapState; // If we’re looking at the results, draw the traces again if (resultShown()) { // If we were in a round the last time we checked, then we need to save the route if (inGame) saveRoute(), inGame = false; if (isBreakdown() && getGame('type') === "challenge") { const resultsTablePromise = new Promise(resolve => { const interval = setInterval(() => { const element = document.querySelector('.results-highscore'); if (element) clearInterval(interval), resolve(element); }, 100); }); resultsTablePromise.then(logErrors(element => { showAllMarkers(map); element.removeEventListener('click', clickHandler); clickHandler = () => setTimeout(logErrors(() => showAllMarkers(map))); element.addEventListener('click', clickHandler); })); } else { showMarkers(null); showMyMarkers(map); } } else if (markers.length) { showMarkers(null); } }); // When a StreetViewPanorama is constructed, add a listener for moving const oldSV = google.maps.StreetViewPanorama; google.maps.StreetViewPanorama = Object.assign(function (...args) { const res = oldSV.apply(this, args); this.addListener('position_changed', () => onMove(this)); return res; }, { prototype: Object.create(oldSV.prototype) }); // When a Map is constructed, add a listener for updating const oldMap = google.maps.Map; google.maps.Map = Object.assign(function (...args) { const res = oldMap.apply(this, args); this.addListener('idle', () => onMapUpdate(this)); return res; }, { prototype: Object.create(oldMap.prototype) }); }));