// ==UserScript==
// @name Guess Peek (Geoguessr)
// @namespace alienperfect
// @version 1.4.9
// @description Click on your pin to see where you've guessed!
// @author Alien Perfect
// @match https://www.geoguessr.com/*
// @icon https://www.google.com/s2/favicons?sz=32&domain=geoguessr.com
// @run-at document-start
// @grant GM_addStyle
// @grant GM_info
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_openInTab
// @grant unsafeWindow
// @grant window.onurlchange
// ==/UserScript==
"use strict";
const SEARCH_RADIUS = 250000;
const STORAGE_CAP = 30;
const SCRIPT_NAME = GM_info.script.name;
const GAMES_API = "https://www.geoguessr.com/api/v3/games/";
const SELECTORS = {
marker: "[data-qa='guess-marker']",
markerList: "[class*='map-pin_']:not([data-qa='correct-location-marker'])",
roundEnd: "[data-qa='close-round-result']",
gameEnd: "[data-qa='play-again-button']",
results: "[data-qa='results-map']",
};
let svs, markerObserver, markerListObserver;
function interceptFetch() {
const _fetch = unsafeWindow.fetch;
unsafeWindow.fetch = async (resource, options) => {
const response = await _fetch(resource, options);
const url = resource.toString();
if (url.includes(GAMES_API) && options) {
await getGuessData(response, options);
}
return response;
};
}
async function getGuessData(response, options) {
try {
const resp = await response.clone().json();
const token = getGameToken(location.pathname);
const round = resp.round;
const guessList = resp.player.guesses;
const gameFinished = resp.state === "finished";
const challenge = resp.type === "challenge";
if (!token) throw new Error("token is null!");
if (options.method === "POST") {
const guess = guessList.at(-1);
const coords = { lat: guess.lat, lng: guess.lng };
const pano = await getNearestPano(coords);
savePano(token, round, pano);
observeMarker(round);
}
if (gameFinished && !challenge) observeMarkerList();
} catch (error) {
console.error(`${SCRIPT_NAME} error: ${error}`);
}
}
async function getNearestPano(coords) {
let pano = {};
let panorama, oldRadius;
let radius = SEARCH_RADIUS;
if (!svs) initSVS();
// eslint-disable-next-line no-constant-condition
while (true) {
try {
panorama = await svs.getPanorama({
location: coords,
radius: radius,
source: "outdoor",
preference: "nearest",
});
radius = computeDistanceBetween(coords, panorama.data.location.latLng);
pano.radius = radius;
pano.url = getStreetViewUrl(panorama.data.location.pano);
if (oldRadius && radius >= oldRadius) break;
oldRadius = radius;
} catch (e) {
break;
}
}
return pano;
}
function savePano(token, round, pano) {
const panos = GM_getValue(token, { [round]: pano });
const history = GM_getValue("history", []);
panos[round] = pano;
GM_setValue(token, panos);
// Don't duplicate the same token for each pano.
if (!history.includes(token)) {
history.unshift(token);
GM_setValue("history", history);
}
cleanStorage(history);
}
function cleanStorage(history) {
if (history.length > STORAGE_CAP) {
const lastItem = history.pop();
GM_setValue("history", history);
GM_deleteValue(lastItem);
}
}
function observeMarker(round) {
markerObserver = new MutationObserver(() => {
const roundEnd = document.querySelector(SELECTORS.roundEnd);
const marker = document.querySelector(SELECTORS.marker);
const token = getGameToken(location.pathname);
const panoList = GM_getValue(token);
if (!(roundEnd && marker && panoList)) return;
markerObserver.disconnect();
updateMarker(marker, panoList[round]);
});
markerObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
function observeMarkerList() {
markerListObserver = new MutationObserver(() => {
const results =
document.querySelector(SELECTORS.results) ||
document.querySelector(SELECTORS.gameEnd);
const markerList = document.querySelectorAll(SELECTORS.markerList);
const token = getGameToken(location.pathname);
const panoList = GM_getValue(token);
// No point in checking this page.
if (!panoList) return markerListObserver.disconnect();
if (!(results && markerList.length > 0)) return;
markerListObserver.disconnect();
for (const [round, pano] of Object.entries(panoList)) {
const marker = markerList.item(parseInt(round) - 1);
updateMarker(marker, pano);
}
});
markerListObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
function updateMarker(marker, pano) {
let distance = convertDistance(SEARCH_RADIUS);
const tooltip = document.createElement("div");
tooltip.className = "peek-tooltip";
tooltip.textContent = `No location was found within ${distance}!`;
marker.setAttribute("data-pano", "false");
if (Object.keys(pano).length > 0) {
distance = convertDistance(pano.radius);
tooltip.textContent = `Click to see the nearest location! [${distance}]`;
marker.setAttribute("data-pano", "true");
marker.addEventListener("click", () => {
GM_openInTab(pano.url, { active: true });
});
}
marker.append(tooltip);
}
function initObservers() {
stopObservers();
if (inResults()) observeMarkerList();
}
function stopObservers() {
if (markerObserver) markerObserver.disconnect();
if (markerListObserver) markerListObserver.disconnect();
}
function inResults() {
return location.pathname.includes("/results/");
}
function getGameToken(url) {
const token = url.match(/[0-9a-zA-Z]{16}/);
return token ? token[0] : null;
}
function getStreetViewUrl(panoId) {
return `https://www.google.com/maps/@?api=1&map_action=pano&pano=${panoId}`;
}
function convertDistance(distance) {
if (distance >= 1000) return (distance / 1000).toFixed(1) + " km";
return distance.toFixed(1) + " m";
}
function computeDistanceBetween(coords1, coords2) {
return unsafeWindow.google.maps.geometry.spherical.computeDistanceBetween(
coords1,
coords2,
);
}
function initSVS() {
svs = new unsafeWindow.google.maps.StreetViewService();
}
function main() {
interceptFetch();
initObservers();
window.addEventListener("urlchange", initObservers);
console.log(`${SCRIPT_NAME} is running!`);
GM_addStyle(`
.peek-tooltip {
display: none;
position: absolute;
width: 120px;
background: #323232;
border-radius: 4px;
text-align: center;
padding: 0.5rem;
font-size: 0.9rem;
right: 50%;
bottom: 220%;
margin-right: -60px;
opacity: 90%;
z-index: 4;
}
.peek-tooltip:after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #323232 transparent transparent transparent;
}
[data-pano="true"]:hover .peek-tooltip,
[data-pano="false"]:hover .peek-tooltip {
display: block;
}
[data-pano="true"] > :first-child {
cursor: pointer;
--border-color: #E91E63 !important;
--border-size-factor: 2 !important;
}
[data-pano="false"] > :first-child {
cursor: initial;
--border-color: #323232 !important;
--border-size-factor: 1.5 !important;
}
`);
}
main();