// ==UserScript== // @name Better Scoreboard [ Geoguessr ] [ geoguessr.com ] // @version 1.3 // @author Han75 - @Han75#4985 // @description Improved lookup tool for geoguesser challenge leaderboard. // @match https://www.geoguessr.com/* // @require http://code.jquery.com/jquery-latest.js // @namespace han75.com // @downloadURL none // ==/UserScript== /* * API endpoint for the Geoguessr challenge mode * * Edpoint: api/ v3/ results/ scores/ / */ const endpoint = "https://www.geoguessr.com/api/v3/results/scores/"; /** * Data is holds every record collected from geoguessr's API. * DATA IS * Map..(2) >> * WHERE * {ID:DATA}= {Scoreboard_Pos : Name, uid, pfp, Game_Token, Total_Tcore, Total_Distance, All_Scores, All_Distances, {All API "guesses" data}}} */ var data = {} /* * nameMap IS * Map< String : STRING > * WHERE * {NAME : ID} = {name:coreBd_Pos} */ var nameMap = {} // Number of players in the lobby let numPlayers = 0; $(document).ready(function () { //render context waitForKeyElements(".results_switch__Qj1HI", start); }); function start() { id = $(`meta[property='og:url']`).attr("content").split("/")[4]; $('.results_switch__Qj1HI').after('
'); $('#bsbHeaderContainer').after('
'); console.log('should now set css'); $('#bsbHeaderContainer').append('
'); /** HEADER */ $('#bsbHeader').append(`
`); $('#bsbHeader').append('Search Single Record'); $('#bsbHeader').append(`
`); $('#bsbHeader').append('Search Record Range(max:50)'); $('#bsbHeader').append(`
`); $('#bsbInfo').append('

Better Scoreboard

'); $('#bsbInfo').append('

By Han75

'); $('#singularContainer').append(`
`); $('#singularContainer').append(`
`); $('.bsbSearchOption[name="rankSearch"]').append(``); $('.bsbSearchOption[name="nameSearch"]').append(``); $('#rangeContainer').append(`
`); $('#rangeContainer').append(`
`); $('.bsbSearchOption[name="rangeFirst"]').append(``); $('.bsbSearchOption[name="rangeLast"]').append(``); $('#bsbHeader').append('Export All Records As .json') /**BODY */ $('#bsbBodyContainer').append('
'); $('') /* ALL HTML GOES ABOVE THIS LINE*/ /* ALL CSS GOES BELOW THIS LINE*/ $('#bsbHeader').css({ "display": "flex", 'flex-direction': 'column',"background-color": "#4D5180", "text-align": "center","border-radius":"25px" }); $('#bsbHeaderContainer').css({"width":"60%", "display": "flex", 'flex-direction': 'column' }); $('.bsbSearchContainer').css({ "display": "flex", "flex-direction": "row" }); $('.bsbSearchTab').css({ "display": "block", "text-align": "left", "background-color": "rgb(121,80,229)", "padding": "5px" }); $('.bsbSearchTab').mouseover(function () { $(this).css({ "background-color": "rgb(157,41,56)", "cursor": "pointer" }); }).mouseout(function () { $(this).css({ "background-color": "rgb(121,80,229)", "cursor": "auto" }); }); /***Search Singular Tab Animate Open and Close */ $('#singularTab').click(function () { $('#singularContainer').slideToggle({ "opacity": "show", "bottom": "100" }, 500); //$('#singularTab').css("background-color","#4D5180"); $('#singularTab').addClass('singularTriggerClose'); }); $('.singularTriggerClose').click(function () { $('#singularContainer').slideToggle({ "opacity": "show", "top": "100" }, 500); //$('#singularTab').css("background-color","rgb(121,80,229)"); $('#singularTab').removeClass('singularTriggerClose'); }); /***Search Range Tab Animate Open and Close */ $('#rangeTab').click(function () { $('#rangeContainer').slideToggle({ "opacity": "show", "bottom": "100" }, 500); //$('#rangeTab').css("background-color","#4D5180"); $('#rangeTab').addClass('rangeTriggerClose'); }); $('.rangeTriggerClose').click(function () { $('#rangeContainer').slideToggle({ "opacity": "show", "top": "100" }, 500); //$('#rangeTab').css("background-color","rgb(121,80,229)"); $('#rangeTab').removeClass('rangeTriggerClose'); }); $('#rangeTab').click(); $('.bsbSearchOption').css({"width":"50%", "border": "1px solid rgb(70,35,57)", "border-radius": "5px", "display": "flex", "flex-direction": "column" }); $(".bsgGuiBtn").css({"padding":"10px"}) $("#bsbSearchPosBtn").prop("disabled", true); $("#bsbSearchNameBtn").prop("disabled", true); $("#bsbSearchRangeBtn").prop("disabled", true); $("#bsbSearchPosBtn").click(findPosition); $("#bsbSearchNameBtn").click(findUsername); $("#bsbSearchRangeBtn").click(findRange); $("#exportRecords").click(download); $("#exportRecords").css({ "color": "white", "padding": "10px", "font-weight": "bold" }); $(".bsb") $('.bsbGuiBtn').css({"border":"none","padding":"10px","font-family":"var(--font-neo-sans);","background-color": "rgb(71,62,96)","color":"white"}) $('.bsbGuiBtn').mouseover(function () { $(this).css({ "background-color": "rgb(26,26,46)", "cursor": "pointer" }); }).mouseout(function () { $(this).css({ "background-color": "rgb(71,62,96)", "cursor": "auto" }); }); $('#exportRecords').mouseover(function () { $(this).css({"cursor": "pointer" }); }).mouseout(function () { $(this).css({"cursor": "auto" }); }); $('#bsbBodyContainer').css("width","100%"); $('.bsbInputLbl').css({"padding":"4px","font-weight":"bold"}); $('#bsbInfo').css("padding","10px"); // The final stack trace is || RoundUp(NumPlayers/50) api requests <- getData() <- getNumPlayers(log(playercount) api requests) // I thought this was a bad choice at first, but it’s probably better than accidentally sending sh*t loads of API requests because of preliminary fetch delays and getting slammed with a rate limit //This ones for testing, only 100 records //findNumberOfPlayers(0,100); //This ones for final findNumberOfPlayers(0,6000); } /** * Finds how many players are in a lobby * @param {2 Numbers} LowerBound,UpperBound */ async function findNumberOfPlayers(lowerBound, upperBound) { //Binary search babyy if (Math.ceil(lowerBound) >= Math.floor(upperBound) - 1) { getData(Math.ceil(lowerBound)); } else { let midpoint = Math.ceil((lowerBound + upperBound) / 2); //Fetch 1 entry from API, change search bounds and search again fetch(`${endpoint}${id}/${midpoint}/1`, { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" }).then((response) => response.json()) .then((resp) => { if (resp.length == 0) { findNumberOfPlayers(lowerBound, midpoint); } else { findNumberOfPlayers(midpoint, upperBound); } });; } } /** * Loads all of the data and stores it in data dictionary. * */ async function getData(nPlayers) { numPlayers = nPlayers; //Fetch all the players. //Can fetch a max of 50 datapoints at a time for (let i = 0; i < nPlayers; i += 50) { //accept parameter is used by native application and I added it to ensure that it Satan himself(CORS) doesn't stop me fetch(`${endpoint}${id}/${i}/50`, { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" }).then((response) => response.json()).then((resp) => { // I hate the variable name. resp ? Like ew whyd i do that for (let k = 0; k < resp.length; k++) { let scores = []; let dists = []; let tDist = 0; for (let round = 0; round < resp[k]["game"]["player"]["guesses"].length; round++) { scores.push(resp[k]["game"]["player"]["guesses"][round]["roundScoreInPoints"]); dists.push((resp[k]["game"]["player"]["guesses"][round]["distanceInMeters"] / 1000).toFixed(3)); tDist += resp[k]["game"]["player"]["guesses"][round]["distanceInMeters"] / 1000; } data[i + k] = { "name": resp[k]["playerName"], "uid": resp[k]["userId"], "pic": resp[k]["pinUrl"], "gametoken": resp[k]["gameToken"], "totscore": resp[k]["game"]["player"]["totalScore"]["amount"], "totDist": tDist.toFixed(3), "scores": scores, "dists": dists, "guesses":resp[k]["game"]["player"]["guesses"]} // Map user name to score for 1 : 1 correlation nameMap[resp[k]["playerName"].toLowerCase()] = i + k; } }) } $('#bsbInfo').append(`

${nPlayers + 1} players

`); $("#bsbSearchPosBtn").prop("disabled", false); $("#bsbSearchNameBtn").prop("disabled", false); $('#bsbSearchRangeBtn').prop("disabled",false); } // TODO: I want the @p to say “N records found (download)” function download() { //TODO: do this function later take a break, jesus. console.log(`File will have ${Object.keys(data).length} entries`); var dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data)); console.log(`File will have ${Object.keys(data).length} entries`); $('#exportRecords').attr("href", dataStr); $('#exportRecords').attr("download", `Geoguessr_Export_n${numPlayers}.json`); } /** * Searches data by Scoreboard Position from the input box */ // refactor to a better name function findPosition() { let position = Math.floor($('#searchPosition').val() - 1); if (position in data) { let a = data[position]; // I’m not gonna cap I straight up ripped these fancy ahh stylized ahh divs straight from the results table, whose gonna stop me? 😈 $("#bsbBody").empty(); $("#bsbBody").append(`
Round 1
Round 2
Round 3
Round 4
Round 5
Total
`); //Literally what is this naming convention? Nothing about this makes sense. You don’t have to make it this hard on yourself, @GeoGuessr’s singular front end engineer $("#bsbBody").append(`
${position + 1}.
${a["scores"][0]} pts
${a["dists"][0]} km
${a["scores"][1]} pts
${a["dists"][1]} km
${a["scores"][2]} pts
${a["dists"][2]} km
${a["scores"][3]} pts
${a["dists"][3]} km
${a["scores"][4]} pts
${a["dists"][4]} km
${a["totscore"]} pts
${a["totDist"]} km
`); // When I click the div I open game overview in a new tab // I can’t show it on this page because I can’t add the the native event listener to the new div because it’s disgustingly obfuscated $("#bsbEntry").click(function (e) { e.preventDefault(); window.open(`https://www.geoguessr.com/results/${a["gametoken"]}`); }); } else if (position < 1 || position > numPlayers) { $('#searchPosition').val(""); } else { $("#bsbBody").empty(); $("#bsbBody").append(`
Undefined error or you broke the script probably. This isn’t gonna show up any other way
`); } } /** * searches data by username */ function findUsername() { // Get that shi from the input box. MAN I love jquery let name = $('#searchName').val(); if (name.toLowerCase() in nameMap) { let position = nameMap[name]; let a = data[nameMap[name]]; // Everything below this like is exactly the same as searchPosition $("#bsbBody").empty(); $("#bsbBody").append(`
Round 1
Round 2
Round 3
Round 4
Round 5
Total
`); $("#bsbBody").append(`
${position + 1}.
${a["scores"][0]} pts
${a["dists"][0]} km
${a["scores"][1]} pts
${a["dists"][1]} km
${a["scores"][2]} pts
${a["dists"][2]} km
${a["scores"][3]} pts
${a["dists"][3]} km
${a["scores"][4]} pts
${a["dists"][4]} km
${a["totscore"]} pts
${a["totDist"]} km
`) $("#bsbEntry").click(function (e) { e.preventDefault(); window.open(`https://www.geoguessr.com/results/${a["gametoken"]}`); }); } else { $("#bsbBody").empty(); $("#bsbBody").append(`
User not found
`); } } function findRange(){ let rStart = Math.floor($("#searchRangeFirst").val())-1; let rEnd = Math.floor($("#searchRangeLast").val())-1; let n = rEnd-rStart; $("#bsbBody").empty(); if(rEnd>numPlayers||rEnd<0||rStart>numPlayers||rStart<0){ $("#bsbBody").append(`
Invalid search bounds. Please change your search
`); }else if(n<=0||n>50){ $("#bsbBody").append(`
Number of records to search must be between 0 and 50
`); }else{ $("#bsbBody").append(`
Round 1
Round 2
Round 3
Round 4
Round 5
Total
`); for(let i=rStart;i
${a["scores"][0]} pts
${a["dists"][0]} km
${a["scores"][1]} pts
${a["dists"][1]} km
${a["scores"][2]} pts
${a["dists"][2]} km
${a["scores"][3]} pts
${a["dists"][3]} km
${a["scores"][4]} pts
${a["dists"][4]} km
${a["totscore"]} pts
${a["totDist"]} km
`); $("#bsbBody").append(`
`) // When I click the div I open game overview in a new tab // I can’t show it on this page because I can’t add the the native event listener to the new div because it’s disgustingly obfuscated $(`#bsbEntry${i}`).click(function (e) { e.preventDefault(); window.open(`https://www.geoguessr.com/results/${a["gametoken"]}`); }); } } } /*****waitforkeyelements.js Used to combat no page refresh due to AJAX. * * @Author BrockA * / /*--- waitForKeyElements(): A utility function, for Greasemonkey scripts, that detects and handles AJAXed content. Usage example: waitForKeyElements ( "div.comments" , commentCallbackFunction ); //--- Page-specific function to do what we want when the node is found. function commentCallbackFunction (jNode) { jNode.text ("This comment changed by waitForKeyElements()."); } IMPORTANT: This function requires your script to have loaded jQuery. */ function waitForKeyElements ( selectorTxt, /* Required: The jQuery selector string that specifies the desired element(s). */ actionFunction, /* Required: The code to run when elements are found. It is passed a jNode to the matched element. */ bWaitOnce, /* Optional: If false, will continue to scan for new elements even after the first match is found. */ iframeSelector /* Optional: If set, identifies the iframe to search. */ ) { var targetNodes, btargetsFound; if (typeof iframeSelector == "undefined") targetNodes = $(selectorTxt); else targetNodes = $(iframeSelector).contents () .find (selectorTxt); if (targetNodes && targetNodes.length > 0) { btargetsFound = true; /*--- Found target node(s). Go through each and act if they are new. */ targetNodes.each ( function () { var jThis = $(this); var alreadyFound = jThis.data ('alreadyFound') || false; if (!alreadyFound) { //--- Call the payload function. var cancelFound = actionFunction (jThis); if (cancelFound) btargetsFound = false; else jThis.data ('alreadyFound', true); } } ); } else { btargetsFound = false; } //--- Get the timer-control variable for this selector. var controlObj = waitForKeyElements.controlObj || {}; var controlKey = selectorTxt.replace (/[^\w]/g, "_"); var timeControl = controlObj [controlKey]; //--- Now set or clear the timer as appropriate. if (btargetsFound && bWaitOnce && timeControl) { //--- The only condition where we need to clear the timer. clearInterval (timeControl); delete controlObj [controlKey] } else { //--- Set a timer, if needed. if ( ! timeControl) { timeControl = setInterval ( function () { waitForKeyElements ( selectorTxt, actionFunction, bWaitOnce, iframeSelector ); }, 300 ); controlObj [controlKey] = timeControl; } } waitForKeyElements.controlObj = controlObj; }