GeoGuessr Friends: Tiny Flag by Name

Adds a small country flag next to every visible player name across the GeoGuessr interface — from friends lists and the side panel to leaderboards, search results, and more — making it easy to see where players are from at a glance.

当前为 2025-08-14 提交的版本,查看 最新版本

// ==UserScript==
// @name         GeoGuessr Friends: Tiny Flag by Name
// @namespace    gg-friends-flag-stable
// @version      3.0.2
// @description  Adds a small country flag next to every visible player name across the GeoGuessr interface — from friends lists and the side panel to leaderboards, search results, and more — making it easy to see where players are from at a glance.
// @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();
  }
})();