// ==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)
});
}));
Donate for the site OpenUserJS
Are you sure you want to go to an external site to donate a monetary value?
WARNING: Some countries laws may supersede the payment processors policy such as the GDPR and PayPal. While it is highly appreciated to donate, please check with your countries privacy and identity laws regarding privacy of information first. Use at your utmost discretion.