Last active
February 22, 2026 16:07
-
-
Save PieterSpruijt/4ef62ae44d24ee269bd78da859354fc7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name GeoGuessr Duels Collector | |
| // @namespace http://tampermonkey.net/ | |
| // @version 3.0 | |
| // @description Fetches full duel & team duel history + captures live games. Shows a floating panel — no console needed. | |
| // @author You | |
| // @match https://www.geoguessr.com/* | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ── CONFIG ──────────────────────────────────────────────────────────────── | |
| const DELAY_MS = 500; // ms between requests (increase if rate limited) | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Capture native fetch before any page script can replace it | |
| const _origFetch = window.fetch.bind(window); | |
| // ── DATA STORE ──────────────────────────────────────────────────────────── | |
| window.__geo = { | |
| fetchedAt: null, | |
| duels: [], | |
| teamDuels: [], | |
| summary: { totalDuels: 0, totalTeamDuels: 0 }, | |
| }; | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| // ── UI STATE ────────────────────────────────────────────────────────────── | |
| let isFetching = false, stopRequested = false; | |
| function uiEl(id) { return document.getElementById(id); } | |
| function setStatus(text) { | |
| const el = uiEl('gc-status'); | |
| if (el) el.textContent = text; | |
| console.log('[GeoCollector]', text); | |
| } | |
| function setProgress(n, total) { | |
| const wrap = uiEl('gc-bar-wrap'); | |
| const bar = uiEl('gc-bar'); | |
| const lbl = uiEl('gc-bar-lbl'); | |
| if (!wrap) return; | |
| if (total == null) { wrap.style.display = 'none'; return; } | |
| wrap.style.display = ''; | |
| bar.style.width = (total > 0 ? Math.round(n / total * 100) : 0) + '%'; | |
| if (lbl) lbl.textContent = total > 0 ? `${n} / ${total}` : ''; | |
| } | |
| function setFetchState(active) { | |
| isFetching = active; | |
| const btn = uiEl('gc-start'); | |
| const dl = uiEl('gc-dl'); | |
| if (btn) { | |
| btn.textContent = active ? '■ Stop' : '▶ Fetch history'; | |
| btn.dataset.mode = active ? 'stop' : 'start'; | |
| btn.style.background = active ? 'rgba(239,68,68,.15)' : '#388bfd'; | |
| btn.style.color = active ? '#ef4444' : '#fff'; | |
| btn.style.borderColor = active ? 'rgba(239,68,68,.4)' : '#388bfd'; | |
| } | |
| if (dl && window.__geo.duels.length + window.__geo.teamDuels.length > 0) { | |
| dl.disabled = false; | |
| } | |
| } | |
| function updateCount() { | |
| const el = uiEl('gc-count'); | |
| if (!el) return; | |
| const d = window.__geo.duels.length, t = window.__geo.teamDuels.length; | |
| el.textContent = (d || t) | |
| ? `${(d + t).toLocaleString()} games collected (${d} solo · ${t} team)` | |
| : ''; | |
| } | |
| // ── INJECTED UI PANEL ───────────────────────────────────────────────────── | |
| function injectUI() { | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| #geo-collector { | |
| position: fixed; bottom: 18px; right: 18px; z-index: 2147483647; | |
| background: #161b22; border: 1px solid #30363d; border-radius: 10px; | |
| width: 230px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| font-size: 12px; color: #cdd9e5; | |
| box-shadow: 0 8px 32px rgba(0,0,0,.65), 0 0 0 1px rgba(255,255,255,.04); | |
| user-select: none; transition: height .15s; | |
| } | |
| #geo-collector.gc-minimized .gc-body { display: none; } | |
| .gc-head { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 8px 10px 8px 12px; border-bottom: 1px solid #30363d; | |
| cursor: move; border-radius: 10px 10px 0 0; | |
| } | |
| .gc-minimized .gc-head { border-bottom: none; border-radius: 10px; } | |
| .gc-title { font-weight: 600; font-size: 11.5px; display: flex; align-items: center; gap: 6px; } | |
| .gc-live-dot { | |
| width: 6px; height: 6px; border-radius: 50%; background: #22c55e; flex-shrink: 0; | |
| box-shadow: 0 0 0 0 rgba(34,197,94,.4); | |
| animation: gc-pulse 2.2s ease-in-out infinite; | |
| } | |
| @keyframes gc-pulse { | |
| 0% { box-shadow: 0 0 0 0 rgba(34,197,94,.5); } | |
| 50% { box-shadow: 0 0 0 5px rgba(34,197,94,.0); } | |
| 100% { box-shadow: 0 0 0 0 rgba(34,197,94,.0); } | |
| } | |
| .gc-minbtn { | |
| background: none; border: none; color: #636e7b; cursor: pointer; | |
| font-size: 16px; line-height: 1; padding: 0 3px; border-radius: 4px; | |
| } | |
| .gc-minbtn:hover { color: #cdd9e5; background: #1c2128; } | |
| .gc-body { padding: 10px 12px 12px; } | |
| .gc-status { | |
| font-size: 11px; color: #8b949e; line-height: 1.55; | |
| min-height: 30px; margin-bottom: 8px; word-break: break-word; | |
| } | |
| .gc-count { font-size: 10.5px; color: #636e7b; margin-bottom: 9px; min-height: 14px; } | |
| .gc-bar-wrap { | |
| height: 3px; background: #30363d; border-radius: 2px; | |
| margin-bottom: 4px; display: none; overflow: hidden; | |
| } | |
| .gc-bar { height: 100%; border-radius: 2px; background: #388bfd; transition: width .25s; width: 0%; } | |
| .gc-bar-lbl { font-size: 10px; color: #636e7b; text-align: right; margin-bottom: 9px; min-height: 13px; } | |
| .gc-btns { display: flex; gap: 6px; } | |
| .gc-btn { | |
| flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid #30363d; | |
| font-size: 11px; cursor: pointer; font-weight: 500; | |
| transition: opacity .15s; white-space: nowrap; | |
| } | |
| .gc-btn:hover:not(:disabled) { opacity: .8; } | |
| .gc-btn:disabled { opacity: .3; cursor: default; pointer-events: none; } | |
| .gc-btn-dl { | |
| background: rgba(34,197,94,.12); color: #22c55e; | |
| border-color: rgba(34,197,94,.3); | |
| }`; | |
| document.head.appendChild(style); | |
| const el = document.createElement('div'); | |
| el.id = 'geo-collector'; | |
| el.innerHTML = ` | |
| <div class="gc-head" id="gc-drag"> | |
| <span class="gc-title"> | |
| <span class="gc-live-dot"></span> | |
| Geo Collector | |
| </span> | |
| <button class="gc-minbtn" id="gc-minbtn" title="Minimise">—</button> | |
| </div> | |
| <div class="gc-body"> | |
| <div class="gc-status" id="gc-status">Live capture active. Ready to fetch history.</div> | |
| <div class="gc-count" id="gc-count"></div> | |
| <div class="gc-bar-wrap" id="gc-bar-wrap"><div class="gc-bar" id="gc-bar"></div></div> | |
| <div class="gc-bar-lbl" id="gc-bar-lbl"></div> | |
| <div class="gc-btns"> | |
| <button class="gc-btn" id="gc-start" data-mode="start" | |
| style="background:#388bfd;color:#fff;border-color:#388bfd"> | |
| ▶ Fetch history | |
| </button> | |
| <button class="gc-btn gc-btn-dl" id="gc-dl" disabled>↓ Save</button> | |
| </div> | |
| </div>`; | |
| document.body.appendChild(el); | |
| // Minimize / restore | |
| let minimised = false; | |
| uiEl('gc-minbtn').onclick = () => { | |
| minimised = !minimised; | |
| el.classList.toggle('gc-minimized', minimised); | |
| uiEl('gc-minbtn').textContent = minimised ? '+' : '—'; | |
| }; | |
| // Drag | |
| let dragging = false, ox = 0, oy = 0; | |
| const head = uiEl('gc-drag'); | |
| head.addEventListener('mousedown', e => { | |
| if (e.target.tagName === 'BUTTON') return; | |
| dragging = true; | |
| const r = el.getBoundingClientRect(); | |
| ox = e.clientX - r.left; oy = e.clientY - r.top; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', e => { | |
| if (!dragging) return; | |
| const x = Math.max(0, Math.min(e.clientX - ox, window.innerWidth - el.offsetWidth)); | |
| const y = Math.max(0, Math.min(e.clientY - oy, window.innerHeight - el.offsetHeight)); | |
| el.style.right = ''; el.style.bottom = ''; | |
| el.style.left = x + 'px'; el.style.top = y + 'px'; | |
| }); | |
| document.addEventListener('mouseup', () => { dragging = false; }); | |
| // Start / Stop | |
| uiEl('gc-start').onclick = () => { | |
| if (isFetching) { | |
| stopRequested = true; | |
| setStatus('Stopping after current request…'); | |
| } else { | |
| fetchHistory(); | |
| } | |
| }; | |
| // Download | |
| uiEl('gc-dl').onclick = () => window.__geoDownload(); | |
| updateCount(); | |
| } | |
| // ── API HELPERS ─────────────────────────────────────────────────────────── | |
| async function apiGet(url) { | |
| const res = await _origFetch(url, { credentials: 'include' }); | |
| if (res.status === 401) throw new Error('Not logged in – please sign in to GeoGuessr first'); | |
| if (res.status === 429) throw new Error('Rate limited – try again in a minute'); | |
| if (!res.ok) throw new Error(`API error ${res.status}`); | |
| return res; | |
| } | |
| // ── ACTIVITY FEED (ID SCRAPE) ───────────────────────────────────────────── | |
| function extractIdsFromEntry(entry) { | |
| const duelIds = [], teamDuelIds = []; | |
| try { | |
| const payload = JSON.parse(entry.payload); | |
| const events = Array.isArray(payload) ? payload.map(e => e.payload) : [payload]; | |
| for (const p of events) { | |
| if (!p?.gameId || !p?.gameMode) continue; | |
| if (p.gameMode === 'Duels') duelIds.push(p.gameId); | |
| if (p.gameMode === 'TeamDuels') teamDuelIds.push(p.gameId); | |
| } | |
| } catch { /* no parseable duel payload */ } | |
| return { duelIds, teamDuelIds }; | |
| } | |
| async function fetchAllActivityIds() { | |
| const duelIds = new Set(), teamDuelIds = new Set(); | |
| let paginationToken = '', page = 0; | |
| while (true) { | |
| if (stopRequested) break; | |
| page++; | |
| const url = '/api/v4/feed/private' + | |
| (paginationToken ? `?paginationToken=${encodeURIComponent(paginationToken)}` : ''); | |
| const res = await apiGet(url); | |
| const data = await res.json(); | |
| if (!data.entries?.length) break; | |
| for (const entry of data.entries) { | |
| const { duelIds: d, teamDuelIds: t } = extractIdsFromEntry(entry); | |
| d.forEach(id => duelIds.add(id)); | |
| t.forEach(id => teamDuelIds.add(id)); | |
| } | |
| setStatus(`Scanning activity feed… page ${page}\n${duelIds.size} solo · ${teamDuelIds.size} team duels found`); | |
| setProgress(null); | |
| paginationToken = data.paginationToken ?? ''; | |
| if (!paginationToken) break; | |
| await sleep(DELAY_MS); | |
| } | |
| return { duelIds: [...duelIds], teamDuelIds: [...teamDuelIds] }; | |
| } | |
| // ── GAME DETAILS ────────────────────────────────────────────────────────── | |
| async function fetchDuelById(id) { | |
| const res = await _origFetch(`https://game-server.geoguessr.com/api/duels/${id}`, { credentials: 'include' }); | |
| if (!res.ok) throw new Error(`game-server ${res.status} for ${id}`); | |
| return res.json(); | |
| } | |
| async function fetchAllDetails(ids, bucket, label) { | |
| for (let i = 0; i < ids.length; i++) { | |
| if (stopRequested) break; | |
| setStatus(`Fetching ${label}… ${i + 1} / ${ids.length}`); | |
| setProgress(i + 1, ids.length); | |
| try { | |
| window.__geo[bucket].push(await fetchDuelById(ids[i])); | |
| window.__geo.summary.totalDuels = window.__geo.duels.length; | |
| window.__geo.summary.totalTeamDuels = window.__geo.teamDuels.length; | |
| updateCount(); | |
| } catch (e) { | |
| console.warn(`[GeoCollector] Skipping ${ids[i]}: ${e.message}`); | |
| } | |
| await sleep(DELAY_MS); | |
| } | |
| } | |
| // ── HISTORY FETCH (MAIN) ────────────────────────────────────────────────── | |
| async function fetchHistory() { | |
| if (isFetching) return; | |
| stopRequested = false; | |
| setFetchState(true); | |
| setProgress(null); | |
| try { | |
| setStatus('Scanning activity feed…'); | |
| const { duelIds, teamDuelIds } = await fetchAllActivityIds(); | |
| if (stopRequested) { | |
| setStatus(`Stopped. Found ${duelIds.length} solo + ${teamDuelIds.length} team IDs.`); | |
| setFetchState(false); setProgress(null); return; | |
| } | |
| setStatus(`Found ${duelIds.length} solo · ${teamDuelIds.length} team duels.\nFetching game data…`); | |
| await fetchAllDetails(duelIds, 'duels', 'solo duels'); | |
| await fetchAllDetails(teamDuelIds, 'teamDuels', 'team duels'); | |
| window.__geo.fetchedAt = new Date().toISOString(); | |
| window.__geo.summary = { | |
| totalDuels: window.__geo.duels.length, | |
| totalTeamDuels: window.__geo.teamDuels.length, | |
| }; | |
| const d = window.__geo.duels.length, t = window.__geo.teamDuels.length; | |
| if (stopRequested) { | |
| setStatus(`Stopped early. Collected ${d} solo + ${t} team duels.\nClick ↓ Save to download.`); | |
| } else { | |
| setStatus(`Done! ${d} solo + ${t} team duels.\nClick ↓ Save to download.`); | |
| } | |
| setFetchState(false); | |
| setProgress(null); | |
| const dl = uiEl('gc-dl'); | |
| if (dl) dl.disabled = false; | |
| } catch (err) { | |
| setStatus(`Error: ${err.message}`); | |
| setFetchState(false); | |
| setProgress(null); | |
| } | |
| } | |
| // ── LIVE INTERCEPTION ───────────────────────────────────────────────────── | |
| const LIVE_RE = /\/(api\/duels|api\/v[34]\/(duels|games))(\/|$|\?)/i; | |
| const GG_HOST = /https?:\/\/([^/]*\.)?geoguessr\.com/i; | |
| function isTarget(url) { return GG_HOST.test(url) && LIVE_RE.test(url); } | |
| function getVal(obj, ...keys) { | |
| for (const k of keys) { const v = obj?.[k]; if (v != null) return v; } | |
| } | |
| function normMode(mode) { | |
| if (!mode) return null; | |
| const m = String(mode).toLowerCase(); | |
| if (m.includes('team')) return 'teamDuel'; | |
| if (m.includes('duel')) return 'duel'; | |
| return null; | |
| } | |
| function handleLivePayload(payload) { | |
| if (!payload || typeof payload !== 'object') return; | |
| if (payload.entries || payload.games || payload.items) return; | |
| for (const t of [payload, payload.game].filter(Boolean)) { | |
| const id = getVal(t, 'gameId', 'id', 'token'); | |
| const mode = normMode( | |
| getVal(t, 'mode', 'gameMode', 'type') || | |
| getVal(t?.game || {}, 'mode', 'gameMode', 'type') | |
| ); | |
| if (!id || !mode) continue; | |
| const bucket = mode === 'teamDuel' ? 'teamDuels' : 'duels'; | |
| if (window.__geo[bucket].some(g => (g.gameId ?? g.id) === id)) continue; | |
| window.__geo[bucket].push(payload); | |
| window.__geo.summary.totalDuels = window.__geo.duels.length; | |
| window.__geo.summary.totalTeamDuels = window.__geo.teamDuels.length; | |
| updateCount(); | |
| console.log('[GeoCollector] Live game captured:', id, `(${mode})`); | |
| const dl = uiEl('gc-dl'); | |
| if (dl) dl.disabled = false; | |
| } | |
| } | |
| window.fetch = function (...args) { | |
| const p = _origFetch(...args); | |
| const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); | |
| if (isTarget(url)) { | |
| p.then(res => { | |
| if (!/application\/json/i.test(res.headers?.get?.('content-type') || '')) return; | |
| res.clone().json().then(j => handleLivePayload(j)).catch(() => { }); | |
| }).catch(() => { }); | |
| } | |
| return p; | |
| }; | |
| (function patchXHR() { | |
| const origOpen = XMLHttpRequest.prototype.open; | |
| const origSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function (_method, url) { | |
| this.__gg_url = url; | |
| return origOpen.apply(this, arguments); | |
| }; | |
| XMLHttpRequest.prototype.send = function () { | |
| if (this.addEventListener) { | |
| this.addEventListener('loadend', function () { | |
| try { | |
| const url = String(this.__gg_url || ''); | |
| if (!isTarget(url)) return; | |
| if (!/application\/json/i.test(this.getResponseHeader('Content-Type') || '')) return; | |
| handleLivePayload(JSON.parse(this.responseText)); | |
| } catch { /* swallow */ } | |
| }); | |
| } | |
| return origSend.apply(this, arguments); | |
| }; | |
| })(); | |
| // ── DOWNLOAD HELPER ─────────────────────────────────────────────────────── | |
| window.__geoDownload = function () { | |
| const data = window.__geo; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = Object.assign(document.createElement('a'), { | |
| href: url, | |
| download: `geoguessr_duels_${new Date().toISOString().slice(0, 10)}.json`, | |
| }); | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(url), 5_000); | |
| setStatus(`Saved! ${data.summary.totalDuels} solo + ${data.summary.totalTeamDuels} team duels.`); | |
| }; | |
| // ── KICKOFF ─────────────────────────────────────────────────────────────── | |
| console.log('[GeoCollector] Loaded. Intercepting live games.'); | |
| if (document.body) injectUI(); | |
| else document.addEventListener('DOMContentLoaded', injectUI); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment