Skip to content

Instantly share code, notes, and snippets.

@PieterSpruijt
Last active February 22, 2026 16:07
Show Gist options
  • Select an option

  • Save PieterSpruijt/4ef62ae44d24ee269bd78da859354fc7 to your computer and use it in GitHub Desktop.

Select an option

Save PieterSpruijt/4ef62ae44d24ee269bd78da859354fc7 to your computer and use it in GitHub Desktop.
// ==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