Adds a small country flag after friends' names in both the main friends list and the side panel, optimized for GeoGuessr's markup.
目前為
// ==UserScript==
// @name GeoGuessr Friends: Tiny Flag by Name (stable, documented)
// @namespace gg-friends-flag-stable
// @version 3.0.1
// @description Adds a small country flag after friends' names in both the main friends list and the side panel, optimized for GeoGuessr's markup.
// @match https://www.geoguessr.com/*
// @run-at document-start
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// Base URL for GeoGuessr's own flag icons
const FLAG_BASE = "https://www.geoguessr.com/static/flags";
// API endpoint to get detailed user info (including countryCode)
const USER_API = (id) => `/api/v3/users/${id}`;
// CSS selector for the element containing the username
const NAME_SEL = '[class*="user-nick_nick__"]';
// CSS selector for the profile link (contains user ID in href)
const LINK_SEL = "a[href^='/user/']";
// --- Inject some basic CSS styling for our flag ---
GM_addStyle(`
.gg-flag {
display:inline-block;
margin-left:6px;
vertical-align:middle;
line-height:1;
}
.gg-flag img {
height:12px;
width:auto;
display:inline-block;
vertical-align:middle;
}
`);
// --- Request queue (limits concurrent fetches to avoid lag) ---
const MAX_CONCURRENCY = 20;
let running = 0;
const queue = [];
function enqueue(task) {
return new Promise((resolve, reject) => {
queue.push({ task, resolve, reject });
pump();
});
}
function pump() {
while (running < MAX_CONCURRENCY && queue.length) {
const { task, resolve, reject } = queue.shift();
running++;
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => { running--; pump(); });
}
}
// --- Caches to avoid duplicate work ---
const countryById = new Map(); // userId -> Promise<countryCode|null>
const processed = new WeakSet(); // name elements already processed
const observed = new WeakSet(); // nodes already given to IntersectionObserver
// Extract user ID from a link like "/user/<id>"
function extractIdFromHref(href) {
const m = String(href||"").match(/\/user\/([^/?#]+)/i);
return m ? m[1] : null;
}
// Fetch the user's country code (cached)
function getCountry(id) {
if (!id) return Promise.resolve(null);
if (countryById.has(id)) return countryById.get(id);
const p = enqueue(() =>
fetch(USER_API(id), { credentials: "include" })
.then(r => (r.ok ? r.json() : null))
.then(j => (j && j.countryCode ? String(j.countryCode).toUpperCase() : null))
.catch(() => null)
);
countryById.set(id, p);
return p;
}
// Insert a small flag element after the username
function placeFlagAfterName(nameEl, code) {
if (!nameEl || !code) return;
if (processed.has(nameEl)) return;
// If a flag is already present, update it and return
const next = nameEl.nextElementSibling;
if (next && next.classList.contains("gg-flag")) {
const img = next.querySelector("img");
if (img) img.src = `${FLAG_BASE}/${code}.svg`;
processed.add(nameEl);
return;
}
// Create the flag container
const span = document.createElement("span");
span.className = "gg-flag";
const img = document.createElement("img");
img.alt = "";
img.decoding = "async";
img.src = `${FLAG_BASE}/${code}.svg`;
img.onerror = () => span.remove();
span.appendChild(img);
// Insert flag directly after the username div (before flair badges)
nameEl.insertAdjacentElement("afterend", span);
processed.add(nameEl);
}
// Process a profile link to fetch and insert the flag
async function handleAnchor(a) {
if (!a) return;
// Find the username element inside the link
const nameEl = a.querySelector(NAME_SEL);
if (!nameEl || processed.has(nameEl)) return;
const id = extractIdFromHref(a.getAttribute("href"));
if (!id) return;
const code = await getCountry(id);
if (!code) return;
placeFlagAfterName(nameEl, code);
}
// IntersectionObserver: only process visible friend cards
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target);
observed.delete(e.target);
handleAnchor(e.target.closest(LINK_SEL) || e.target);
}
}, { root: null, rootMargin: "0px 0px 200px 0px", threshold: 0 });
// Start watching a profile link
function watchAnchor(a) {
if (!a || observed.has(a)) return;
observed.add(a);
const nameEl = a.querySelector(NAME_SEL);
io.observe(nameEl || a);
}
// Scan a DOM subtree for friend profile links
function scan(root) {
if (!root || root.nodeType !== 1) return;
root.querySelectorAll(LINK_SEL).forEach(watchAnchor);
}
// MutationObserver: react to new friend cards being added to the DOM
const mo = new MutationObserver((muts) => {
for (const m of muts) {
if (m.addedNodes && m.addedNodes.length) {
for (const n of m.addedNodes) scan(n);
}
}
});
// Initialize observers
function start() {
if (!document.body) { requestAnimationFrame(start); return; }
mo.observe(document.documentElement, { childList: true, subtree: true });
scan(document);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
})();