您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
analyze geoguessr replay data
// ==UserScript== // @name Geoguessr Replay Analyzer // @namespace https://greasyfork.org/users/1179204 // @version 0.0.2 // @description analyze geoguessr replay data // @author KaKa // @match https://www.geoguessr.com/duels/* // @match https://www.geoguessr.com/team-duels/* // @run-at document-end // @icon https://www.google.com/s2/favicons?sz=64&domain=geoguessr.com // @license BSD // @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-annotation.min.js // ==/UserScript== (function() { let replayData,playersList,selectedPlayer,rounds,currentGameId async function getReplayer(gameId,round){ let replayControls = document.querySelector('[class^="replay_main__"]'); const keys = Object.keys(replayControls) const key = keys.find(key => key.startsWith("__reactProps")) const props = replayControls[key] playersList=props.children[4].props.players rounds=Math.max(...playersList.map(player => player.guesses?.length || 0)); selectedPlayer=props.children[4].props.selectedPlayer currentGameId=gameId replayData=await fetchReplayData(currentGameId,selectedPlayer.playerId,round) } async function fetchReplayData( gameId,userId,round) { const url = `https://game-server.geoguessr.com/api/replays/${userId}/${gameId}/${round}`; try { const response = await fetch(url,{method: "GET",credentials: "include"}); if (!response.ok) { console.error(`HTTP error! Status: ${response.status}`); return null } return await response.json(); } catch (error) { console.error('Error fetching replay data:', error); return null; } } function parseUrl() { const url = window.location.href; const urlObj = new URL(url); const pathSegments = urlObj.pathname.split('/'); const gameId = pathSegments.length > 2 ? pathSegments[2] : null; const round = urlObj.searchParams.get("round"); return { gameId, round }; } async function downloadPanoramaImage(panoId, fileName, w, h, zoom,d) { return new Promise(async (resolve, reject) => { try { let canvas, ctx, tilesPerRow, tilesPerColumn, tileUrl, imageUrl; const tileWidth = 512; const tileHeight = 512; let zoomTiles; imageUrl = `https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=apiv3&panoid=${panoId}&output=tile&zoom=${zoom}&nbt=0&fover=2`; zoomTiles = [2, 4, 8, 16, 32]; tilesPerRow = Math.min(Math.ceil(w / tileWidth), zoomTiles[zoom - 1]); tilesPerColumn = Math.min(Math.ceil(h / tileHeight), zoomTiles[zoom - 1] / 2); const canvasWidth = tilesPerRow * tileWidth; const canvasHeight = tilesPerColumn * tileHeight; canvas = document.createElement('canvas'); ctx = canvas.getContext('2d'); canvas.width = canvasWidth; canvas.height = canvasHeight; const loadTile = (x, y) => { return new Promise(async (resolveTile) => { let tile; tileUrl = `${imageUrl}&x=${x}&y=${y}`; try { tile = await loadImage(tileUrl); ctx.drawImage(tile, x * tileWidth, y * tileHeight, tileWidth, tileHeight); resolveTile(); } catch (error) { console.error(`Error loading tile at ${x},${y}:`, error); resolveTile(); } }); }; let tilePromises = []; for (let y = 0; y < tilesPerColumn; y++) { for (let x = 0; x < tilesPerRow; x++) { tilePromises.push(loadTile(x, y)); } } await Promise.all(tilePromises); if(d){ resolve(canvas.toDataURL('image/jpeg'));} else{ canvas.toBlob(blob => { const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); resolve(); }, 'image/jpeg');} } catch (error) { Swal.fire({ title: 'Error!', text: error.toString(), icon: 'error', backdrop: false }); reject(error); } }); } async function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'Anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load image from ${url}`)); img.src = url; }); } async function searchGooglePano(t, e, z) { try { const u = `https://maps.googleapis.com/$rpc/google.internal.maps.mapsjs.v1.MapsJsInternalService/${t}`; const r=50*(21-z)**2 let payload = createPayload(t,e,r); const response = await fetch(u, { method: "POST", headers: { "content-type": "application/json+protobuf", "x-user-agent": "grpc-web-javascript/0.1" }, body: payload, mode: "cors", credentials: "omit" }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } else { const data = await response.json(); if(t=='GetMetadata'){ return { panoId: data[1][0][1][1], heading: data[1][0][5][0][1][2][0], worldHeight:data[1][0][2][2][0], worldWidth:data[1][0][2][2][1] }; } return { panoId: data[1][1][1], heading: data[1][5][0][1][2][0] }; } } catch (error) { console.error(`Failed to fetch metadata: ${error.message}`); } } function createPayload(mode,coorData,r) { let payload; if(!r)r=50 if (mode === 'GetMetadata') { payload = [["apiv3",null,null,null,"US",null,null,null,null,null,[[0]]],["en","US"],[[[2,coorData]]],[[1,2,3,4,8,6]]]; } else if (mode === 'SingleImageSearch') { payload =[["apiv3"], [[null,null,coorData.lat,coorData.lng],r], [null,["en","US"],null,null,null,null,null,null,[2],null,[[[2,true,2],[10,true,2]]]], [[1,2,3,4,8,6]]] } else { throw new Error("Invalid mode!"); } return JSON.stringify(payload); } function analyze(round){ Swal.fire({ title: 'Replay Analysis', html: ` <div style="text-align: center; font-family: sans-serif;"> <div style="margin-bottom: 10px;"> <select id="roundSelect" style="background: #db173e; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;"></select> <select id="playerSelect" style="background: #007bff; color: white; font-size: 16px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; margin: 5px;"></select> <button id="toggleEventBtn" style="background: #28a745; color: white; font-size: 14px; padding: 8px 15px; border: 2px solid grey; border-radius: 6px; cursor: pointer; margin: 5px;">Event Analysis</button> <button id="toggleSVBtn" style="background: #ffc107; color: black; font-size: 14px; padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer;">StreetView Analysis</button> </div> <canvas id="chartCanvas" width="300" height="150" style="background: white; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);"></canvas> <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 20px; margin-top: 5px;"> <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 300px; text-align: center;"> <p><strong>Event Density:</strong> <span id="eventDensity">Loading...</span></p> <p><strong>Avgerage Gap Time:</strong> <span id="AvgGapTime">Loading...</span></p> <p><strong>Pano Event Ratio:</strong> <span id="streetViewRatio">Loading...</span></p> <p><strong>First PanoZoom:</strong> <span id="firstPanoZoomTime">Loading...</span></p> <p><strong>Longest Single Gap:</strong> <span id="longestGapTime">Loading...</span></p> </div> <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); width: 300px; text-align: center;"> <p><strong>Gap Count:</strong> <span id="stagnationCount">Loading...</span></p> <p><strong>Total Gap Time:</strong> <span id="stagnationTime">Loading...</span></p> <p><strong>Map Event Ratio:</strong> <span id="mapEventRatio">Loading...</span></p> <p><strong>First Map Zoom:</strong> <span id="firstMapZoomTime">Loading...</span></p> <p><strong>Pano POV Speed:</strong> <span id="avgPovSpeed">Loading...</span></p> </div> </div> </div> `, width: 800, showCloseButton: true, backdrop:null, didOpen: () => { const canvas = document.getElementById('chartCanvas') const ctx = canvas.getContext('2d', {willReadFrequently: true }) const playerSelect = document.getElementById("playerSelect"); const roundSelect = document.getElementById("roundSelect"); playersList.forEach(player => { let option = document.createElement("option"); option.value = player.playerId; option.textContent = player.nick; playerSelect.appendChild(option); }); if(selectedPlayer)playerSelect.value=selectedPlayer.playerId; if(rounds){ for (let i = 1; i <= rounds; i++) { let option =document.createElement("option") option.value = i; option.textContent=`Round ${i}` roundSelect.appendChild(option); } } if(round)roundSelect.value=parseInt(round) const toggleSVBtn = document.getElementById('toggleSVBtn'); const toggleEventBtn = document.getElementById('toggleEventBtn'); function updateChartData(data, playerName) { chart.resize() const interval = 1000; const eventTypes = [ "PanoPov", "PanoZoom", "MapPosition", "MapZoom", "PinPosition", "MapDisplay", "PanoPosition", "GuessWithLatLng" ]; const keyEventTypes = ["PinPosition", "MapDisplay", "GuessWithLatLng", "PanoPosition","Timer"]; const eventColors = { "MapZoom": "#0000FF", "MapPosition": "#FFA500", "PanoPov": "#00FF00", "GuessWithLatLng": "#FF0000", "PinPosition": "#00FFFF", "MapDisplay": "#800080", "PanoZoom": "#FF69B4", "PanoPosition": "#1E90FF" }; const eventBuckets = {}; const allEventTimes = {}; eventTypes.forEach(eventType => { eventBuckets[eventType] = {}; }); keyEventTypes.forEach(eventType => { allEventTimes[eventType] = []; }); data.forEach(event => { const eventTime = event.time; const relativeTime = eventTime - data[0].time; if(eventBuckets[event.type]){ const bucket = Math.floor(relativeTime / interval); if (!eventBuckets[event.type][bucket]) { eventBuckets[event.type][bucket] = 0; } eventBuckets[event.type][bucket]++; } if(allEventTimes[event.type]){ allEventTimes[event.type].push(relativeTime); } }); const labels = []; const maxBucket = Math.max( ...Object.values(eventBuckets).flatMap(bucket => Object.keys(bucket).map(Number)) ); for (let i = 0; i <= maxBucket; i++) { const relativeSeconds = (i * interval + interval / 2) / 1000; // 获取3秒区间的中点 const minutes = Math.floor(relativeSeconds / 60); const seconds = Math.floor(relativeSeconds % 60); const formattedTime = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; labels.push(formattedTime); } const datasets = eventTypes.map(eventType => { const dataPoints = labels.map((label, index) => eventBuckets[eventType][index] || 0); return { label: eventType, data: dataPoints, fill: false, borderColor: eventColors[eventType], backgroundColor: eventColors[eventType], tension: 0.5, hidden: true }; }); const totalEventsData = labels.map((label, index) => { let total = 0; eventTypes.forEach(eventType => { total += eventBuckets[eventType][index] || 0; }); return total; }); datasets.push({ label: 'Total Events', data: totalEventsData, fill: false, borderColor: 'rgba(0,0,0,0.6)', backgroundColor: 'rgba(0,0,0,0.6)', tension: 0.5 }); const annotations = []; Object.keys(allEventTimes).forEach(eventType => { allEventTimes[eventType].forEach(eventTime => { const xPosition = eventTime / 1000; annotations.push({ type: 'line', xMin: xPosition, xMax: xPosition, borderColor: eventColors[eventType], borderWidth: 1.5, borderDash: [5, 5], }); }); }); chart.data.datasets = datasets; chart.data.labels = labels; chart.options.plugins.annotation.annotations = annotations; chart.update();} const chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, plugins: { legend: { display: true, labels: { boxWidth: 30, boxHeight: 15, padding: 30 }, position: 'top', align: 'center', labels: { usePointStyle: true, padding: 20, pointStyle: 'rectRounded' }, }, tooltip: { mode: 'index', intersect: false,enabled:false }, annotation: { annotations: [], }, }, scales: { x: { title: { display: true } }, y: { title: { display: true, text: 'Event Counts' }, beginAtZero: true } }, }, }); function updateEventAnalysisData(data) { const { eventDensity, stagnationTime, stagnationCount, AvgGapTime, streetViewRatio, mapEventRatio, firstMapZoomTime, firstPanoZoomTime,longestGapTime,avgPovSpeed} = updateEventAnalysis(data); document.getElementById('eventDensity').textContent = eventDensity.toFixed(2) + " times/s"; document.getElementById('stagnationTime').textContent = stagnationTime.toFixed(2) + " s"; document.getElementById('longestGapTime').textContent = longestGapTime.toFixed(2) + " s"; document.getElementById('avgPovSpeed').textContent = avgPovSpeed.toFixed(2) + " °/s"; document.getElementById('stagnationCount').textContent = stagnationCount; document.getElementById('AvgGapTime').textContent =!stagnationCount?'None': `${(parseFloat(stagnationTime/stagnationCount)).toFixed(2)}s`; document.getElementById('streetViewRatio').textContent = (streetViewRatio * 100).toFixed(2) + "%"; document.getElementById('mapEventRatio').textContent = (mapEventRatio * 100).toFixed(2) + "%"; document.getElementById('firstMapZoomTime').textContent = firstMapZoomTime === null ? "None" : "At " + firstMapZoomTime + " s"; document.getElementById('firstPanoZoomTime').textContent = firstPanoZoomTime === null ? "None" : "At " + firstPanoZoomTime + " s"; } updateChartData(replayData); updateEventAnalysisData(replayData); playerSelect.onchange = async () => { canvas.style.pointerEvents = 'auto'; try{ replayData=await fetchReplayData(currentGameId,playerSelect.value,roundSelect.value) selectedPlayer=playersList.find(player=>player.playerId==playerSelect.value) } catch(e){ console.error("Error fetching replay data") return } updateChartData(replayData); updateEventAnalysisData(replayData); }; roundSelect.onchange = async () => { canvas.style.pointerEvents = 'auto'; try{ replayData=await fetchReplayData(currentGameId,playerSelect.value,roundSelect.value) } catch(e){ console.error("Error fetching replay data") return } updateChartData(replayData); updateEventAnalysisData(replayData); }; toggleEventBtn.addEventListener('click',()=>{ toggleSVBtn.style.border='none' toggleEventBtn.style.border='2px solid grey' canvas.style.pointerEvents = 'auto'; updateChartData(replayData); updateEventAnalysisData(replayData); }) toggleSVBtn.addEventListener('click',async () => { toggleEventBtn.style.border='none' toggleSVBtn.style.border='2px solid grey' canvas.style.pointerEvents='none' var centerHeading; const panoIds = replayData .filter(item => item.type === 'PanoPosition' && item.payload?.panoId) .map(item => item.payload.panoId); if(panoIds.length>1){ var panoId=panoIds[Math.floor(Math.random() * panoIds.length)] } else{ panoId=panoIds[0] } const metaData = await searchGooglePano('GetMetadata',panoId ); var w = metaData.worldWidth; var h = metaData.worldHeight; centerHeading = metaData.heading; try { const imageUrl = await downloadPanoramaImage(panoId, panoId, w, h, w==13312?5:3, true); const img = await loadImage(imageUrl); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); let lastPanoPov = { heading: 0, pitch: 0 }; let stagnationPoints =[]; const heatData = replayData.filter(event => ["PanoZoom", "PanoPov"].includes(event.type)).map((event, index, events) => { let heading, pitch, type; let time = event.time; if (event.type === "PanoPov") { [heading, pitch] =[event.payload.heading,event.payload.pitch] lastPanoPov = { heading, pitch }; type = "PanoPov"; } else if (event.type === "PanoZoom") { heading = lastPanoPov.heading; pitch = lastPanoPov.pitch; type = "PanoZoom"; } if (index > 0) { const prevEvent = events[index - 1]; const timeDiff = Math.abs(time - prevEvent.time); if (timeDiff > 3000) { stagnationPoints.push(index); } } return { heading, pitch, type}; }); drawHeatMapOnImage(canvas, heatData, centerHeading,stagnationPoints); } catch (error) { console.error('Error downloading panorama image:', error); } })} }); } function drawHeatMapOnImage(canvas, heatData, centerHeading,points) { const ctx = canvas.getContext('2d'); heatData.forEach((point, index) => { let headingDifference = point.heading - centerHeading; if (headingDifference > 180) { headingDifference -= 360; } else if (headingDifference < -180) { headingDifference += 360; } const x = (headingDifference + 180) / 360 * canvas.width; const y = (90 - point.pitch) / 180 * canvas.height; ctx.beginPath(); if(canvas.width===13312) ctx.arc(x, y, (points.includes(index))?80:40, 0,2* Math.PI); else ctx.arc(x, y, (points.includes(index))?30:15, 0,2* Math.PI); if (points.includes(index)) { ctx.fillStyle = 'yellow'; } else if (point.type === "PanoZoom") { ctx.fillStyle = '#FF0000'; } else if (point.type === "PanoPov") { ctx.fillStyle = '#00FF00'; } ctx.fill(); }); } function updateEventAnalysis(data) { let totalEvents = 0; let totalTime = 0; let stagnationTime = 0; let stagnationCount = 0; let switchCount = 0; let streetViewEvents = 0; let mapEvents = 0; let lastEventTime = null; let longestGapTime = 0; let totalHeadingDifference = 0; let totalTimeGap = 0; let lastPanoPovEventTime = null; let lastHeading = null; data.forEach(event => { const eventTime = event.time; const relativeTime = Math.floor((eventTime - data[0].time) / 1000); totalEvents++; totalTime = relativeTime; if (event.type.includes("Pano")) { streetViewEvents++; } else if (event.type.includes("Map")) { mapEvents++; } if (lastEventTime !== null) { const timeGap = (eventTime - lastEventTime) / 1000; if (timeGap >= 3) { if (timeGap > longestGapTime) longestGapTime = timeGap; stagnationTime += timeGap; stagnationCount++; } } if (event.type === "PanoPov" && lastPanoPovEventTime !== null) { const headingDifference = Math.abs(event.payload.heading - lastHeading); const timeGap = (eventTime - lastPanoPovEventTime) / 1000; totalHeadingDifference += headingDifference; totalTimeGap += timeGap; } lastEventTime = eventTime; if (event.type === "PanoPov") { lastPanoPovEventTime = eventTime; lastHeading =event.payload.heading; } if (event.type === "Switch") { switchCount++; } }); const eventDensity = totalEvents / totalTime; const streetViewRatio = streetViewEvents / totalEvents; const mapEventRatio = mapEvents / totalEvents; let firstMapZoomTime = null; let firstMapZoomTime_ = null; let firstPanoZoomTime_ = null; let firstPanoZoomTime = null; data.forEach(event => { if (event.type === "MapZoom" && !firstMapZoomTime) { if (firstMapZoomTime_ === null) firstMapZoomTime_ = 1; else { firstMapZoomTime = Math.floor((event.time - data[0].time) / 1000); } } if (event.type === "PanoZoom" && !firstPanoZoomTime) { if (firstPanoZoomTime_ === null) firstPanoZoomTime_ = 1; else { firstPanoZoomTime = Math.floor((event.time - data[0].time) / 1000); } } }); let avgPovSpeed = 0; if (totalTimeGap > 0) { avgPovSpeed = totalHeadingDifference / totalTimeGap; } return { eventDensity, stagnationTime, stagnationCount, streetViewRatio, mapEventRatio, firstPanoZoomTime, firstMapZoomTime, longestGapTime, avgPovSpeed }; } let onKeyDown =async (e) => { if (e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) { return; } if (e.shiftKey&&(e.key === 'K' || e.key === 'k')){ const {gameId, round}=parseUrl() await getReplayer(gameId,round) analyze(round) } } document.addEventListener("keydown", onKeyDown); })();