GoLocale

Automatically redirects URLs to their preferred language equivalents

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Advertisement:

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

Advertisement:

// ==UserScript==
// @name         GoLocale
// @namespace    https://github.com/tonioriol/userscripts
// @version      0.8.1
// @description  Automatically redirects URLs to their preferred language equivalents
// @author       Toni Oriol
// @match        *://*/*
// @icon         data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22%234A90E2%22%3E%3Cpath d=%22M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z%22/%3E%3C/svg%3E
// @grant        GM.getValue
// @grant        GM.setValue
// @license      AGPL-3.0-or-later
// ==/UserScript==

/**
 * TODO:
 * - [ ] make the ui so it doesnt collide NEVER with the page css
 * - [ ] make the button that shows up to stop the golocale to act to have a navigate to prev page button (and then also show it there to disable the redirects, so when we are back using that button it doesnt try again also!)
 */

// Configuration groups
const LANGUAGE_CONFIG = {
  targetLang: "ca",
  altLang: "va"
};

const DETECTION_CONFIG = {
  urlParams: ["lang", "ln", "hl"]
};


// Utility functions
const getBaseDomain = (hostname) => {
  const parts = hostname.split('.');
  if (parts.length >= 3 && parts[0].length >= 2 && parts[0].length <= 3) {
    const possibleLangCode = parts[0].toLowerCase();
    if (/^[a-z]{2,3}$/.test(possibleLangCode)) {
      return parts.slice(1).join('.');
    }
  }
  return hostname;
};

const getStorageKey = (domain, type) => `golocale_${type}_${getBaseDomain(domain)}`;

// Session-history helpers
const GOLOCALE_STORAGE_PREFIX = "__golocale_redirected_";

const getRedirectMarkerKey = (url) => {
  try {
    const normalized = new URL(url).href;
    return GOLOCALE_STORAGE_PREFIX + normalized;
  } catch {
    return GOLOCALE_STORAGE_PREFIX + url;
  }
};

const markUrlAsRedirected = (fromUrl, toUrl) => {
  try {
    if (typeof sessionStorage === "undefined") return;
    const key = getRedirectMarkerKey(fromUrl);
    sessionStorage.setItem(key, JSON.stringify({
      redirectedTo: toUrl,
      redirectedAt: Date.now()
    }));
  } catch (error) {
    console.warn("[GoLocale] Failed to mark URL as redirected:", error);
  }
};

const wasUrlRedirected = (url) => {
  try {
    if (typeof sessionStorage === "undefined") return null;
    const key = getRedirectMarkerKey(url);
    const value = sessionStorage.getItem(key);
    return value ? JSON.parse(value) : null;
  } catch {
    return null;
  }
};

const getTargetLanguageHints = () => {
  return [LANGUAGE_CONFIG.targetLang, LANGUAGE_CONFIG.altLang]
    .filter(Boolean)
    .map((lang) => String(lang).toLowerCase());
};

const matchesTargetLanguageHint = (value, targetLangs = getTargetLanguageHints()) => {
  if (!value) return false;

  const normalized = String(value).toLowerCase();
  const baseCode = normalized.split(/[-_]/)[0];

  return targetLangs.includes(normalized) || targetLangs.includes(baseCode);
};

const urlHasTargetLanguageHint = (url) => {
  try {
    const u = new URL(url);
    const targetLangs = getTargetLanguageHints();
    const subdomain = u.hostname.split(".")[0];
    const pathSegments = u.pathname.split("/").filter(Boolean);

    if (matchesTargetLanguageHint(subdomain, targetLangs)) {
      return true;
    }

    if (pathSegments.some((segment) => matchesTargetLanguageHint(segment, targetLangs))) {
      return true;
    }

    return DETECTION_CONFIG.urlParams.some((param) => {
      return matchesTargetLanguageHint(u.searchParams.get(param), targetLangs);
    });
  } catch {
    return false;
  }
};

const shouldSkipRedirect = (currentUrl, referrer = "") => {
  const marker = wasUrlRedirected(currentUrl);
  console.log("[GoLocale] Checking skip - marker:", marker, "referrer:", referrer);

  if (!marker) {
    console.log("[GoLocale] No redirect marker found");
    return false;
  }

  // Check Navigation Timing API for back_forward
  try {
    const navEntry = performance?.getEntriesByType?.("navigation")?.[0];
    if (navEntry?.type === "back_forward") {
      console.log("[GoLocale] Navigation Timing reports back_forward - skipping");
      return true;
    }
  } catch { /* ignore */ }

  try {
    if (performance?.navigation?.type === 2) {
      console.log("[GoLocale] Legacy nav API reports back_forward - skipping");
      return true;
    }
  } catch { /* ignore */ }

  // Fallback: referrer matches the URL we redirected to
  try {
    if (referrer && String(referrer) === String(marker.redirectedTo)) {
      console.log("[GoLocale] Referrer matches redirect target - skipping");
      return true;
    }
  } catch { /* ignore */ }

  console.log("[GoLocale] Not a back navigation - will redirect");
  return false;
};

// Redirect using pushState + document.write:
// 1. pushState(target) → changes URL to /ca/, creating entry with /es/ underneath
// 2. document.open/write/close(html) → replaces page content with fetched HTML
// NO navigation occurs at all, so history is never modified beyond the pushState.
// Back from /ca/ will land on /es/ because pushState preserved it.
const pushStateAndWrite = (targetUrl, html) => {
  // Use native pushState to bypass SPA framework patches
  const nativePush = typeof History !== "undefined"
    ? History.prototype.pushState
    : null;

  if (!nativePush) {
    console.log("[GoLocale] History API unavailable, falling back to location.href");
    location.href = targetUrl;
    return;
  }

  console.log("[GoLocale] pushState to:", targetUrl, "(history.length before:", history.length + ")");

  // pushState changes the URL without navigating. /es/ entry stays underneath.
  nativePush.call(history, null, "", targetUrl);

  console.log("[GoLocale] pushState done (history.length after:", history.length + ")");
  console.log("[GoLocale] Writing fetched HTML into document (", html.length, "bytes)");

  // document.open/write/close replaces the page content without any navigation.
  // This does NOT create or modify history entries.
  // The browser will parse the HTML, execute scripts, load resources, etc.
  document.open();
  document.write(html);
  document.close();
};

const notify = (message, buttonText, callback) => {
  document.getElementById('golocale-notify')?.remove();

  const notification = document.createElement('div');
  notification.id = 'golocale-notify';
  notification.innerHTML = `
    <div
      style="position: fixed; top: 10px; right: 10px; z-index: 999999; padding: 12px 12px 12px 32px; background: #333; color: white; border-radius: 4px; font: 14px sans-serif; ${!buttonText ? 'cursor: pointer;' : ''}"
      ${!buttonText ? 'onclick="this.parentElement.remove()"' : ''}>
      <button
        style="position: absolute; left: 8px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; background: #666; color: white; border: none; border-radius: 0; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;"
        onclick="this.parentElement.parentElement.remove()">
        ×
      </button>
      <span>${message}</span>
      ${buttonText ? `
        <button
          class="golocale-action-btn"
          style="margin-left: 8px; padding: 4px 8px; background: #666; color: white; border: none; border-radius: 3px; cursor: pointer;">
          ${buttonText}
        </button>
      ` : ''}
    </div>
  `;

  if (buttonText && callback) {
    const actionBtn = notification.querySelector('.golocale-action-btn');
    actionBtn.onclick = () => {
      notification.remove();
      callback();
    };
  }

  document.body.appendChild(notification);
  setTimeout(() => notification.remove?.(), 30000);
};

const isTargetLanguage = (html) => {
  const targetLangs = [LANGUAGE_CONFIG.targetLang, LANGUAGE_CONFIG.altLang].filter(Boolean);
  console.log("[GoLocale] Checking if page is in target languages:", targetLangs);

  const langAttr = html.match(/<html[^>]*lang=["']([^"']*)/i);
  const lang = langAttr?.[1]?.toLowerCase();
  console.log("[GoLocale] HTML lang attribute found:", lang);

  const langCode = lang?.split("-")[0];
  if (targetLangs.includes(lang) || targetLangs.includes(langCode)) {
    console.log("[GoLocale] Page is already in target language (HTML attribute)");
    return true;
  }

  if (lang && !targetLangs.includes(langCode)) {
    console.log("[GoLocale] HTML lang attribute indicates different language, not using franc detection");
    return false;
  }

  const detectedLang = francDetect(html);
  console.log("[GoLocale] Franc detected language code:", detectedLang);

  const mappedLang = iso6393To1[detectedLang];
  console.log("[GoLocale] Mapped to 2-letter code:", mappedLang);

  if (targetLangs.includes(mappedLang)) {
    console.log("[GoLocale] Page is in target language (detected by franc)");
    return true;
  }

  console.log("[GoLocale] Page is NOT in target language");
  return false;
};

// Returns { isTarget, html } so we can reuse the fetched HTML for document.write
const fetchAndCheckLanguage = async (url) => {
  console.log("[GoLocale] Testing URL candidate:", url);

  try {
    const response = await fetch(url);
    console.log("[GoLocale] Response status:", response.status);

    if (response.status >= 400) {
      console.log("[GoLocale] URL failed with status >= 400");
      return { isTarget: false, html: null };
    }

    const html = await response.text();
    console.log("[GoLocale] Fetched HTML length:", html.length);

    const result = isTargetLanguage(html);
    console.log("[GoLocale] URL candidate result:", result);
    return { isTarget: result, html: result ? html : null };
  } catch (error) {
    console.error("[GoLocale] Error fetching URL:", url, error);

    console.log("[GoLocale] Trying no-cors fallback");
    try {
      const noCorsResponse = await fetch(url, { mode: "no-cors" });
      console.log("[GoLocale] No-cors response type:", noCorsResponse.type);
      if (noCorsResponse.type === "opaque") {
        console.log("[GoLocale] No-cors succeeded, assuming URL is valid (no HTML available)");
        return { isTarget: true, html: null };
      }
    } catch (noCorsError) {
      console.error("[GoLocale] No-cors fallback also failed:", noCorsError);
    }

    return { isTarget: false, html: null };
  }
};

// URL generation strategies
const replaceLanguageCodes = (url, targetLang) => {
  const currentLangMap = typeof window !== 'undefined' ? window.langMap : global.langMap;
  const target = currentLangMap.get(targetLang);

  if (!target) {
    console.log("[GoLocale] Language not in ISO map, using pattern replacement for:", targetLang);
    return url.replace(/\/([a-z]{2,3})\//g, (match, langCode) => {
      return currentLangMap.has(langCode.toLowerCase()) ? `/${targetLang}/` : match;
    });
  }

  return url.replace(/(?<!\.)\b([a-z]{2,3})\b/gi, (match) => {
    const found = currentLangMap.get(match.toLowerCase());
    return found
      ? found.iso6391 === match.toLowerCase()
        ? target.iso6391 || match
        : found.iso6392B === match.toLowerCase()
        ? target.iso6392B || match
        : found.iso6392T === match.toLowerCase()
        ? target.iso6392T || match
        : match
      : match;
  });
};

const injectPath = (url, targetLang) => {
  const u = new URL(url);
  u.pathname = `/${targetLang}${u.pathname}`;
  return u.toString();
};

const replaceSubdomain = (url, targetLang) => {
  const u = new URL(url);
  const hostParts = u.hostname.split(".");
  const currentLangMap = typeof window !== 'undefined' ? window.langMap : global.langMap;

  if (hostParts.length >= 2 && currentLangMap.has(hostParts[0].toLowerCase())) {
    console.log("[GoLocale] Replacing subdomain language code:", hostParts[0], "with", targetLang);
    hostParts[0] = targetLang;
    u.hostname = hostParts.join(".");
    return u.toString();
  }

  return null;
};

const injectSubdomain = (url, targetLang) => {
  const u = new URL(url);
  u.hostname = `${targetLang}.${u.hostname}`;
  return u.toString();
};

const injectParams = (url, targetLang) => {
  return DETECTION_CONFIG.urlParams.map((param) => {
    const u = new URL(url);
    u.searchParams.set(param, targetLang);
    return u.toString();
  });
};

const generateUrlCandidates = (url) => {
  console.log("[GoLocale] Generating URL candidates for:", url);
  const candidates = [];
  const targetLangs = [LANGUAGE_CONFIG.targetLang, LANGUAGE_CONFIG.altLang].filter(Boolean);

  for (const targetLang of targetLangs) {
    console.log("[GoLocale] Generating candidates for language:", targetLang);

    candidates.push(replaceLanguageCodes(url, targetLang));

    const replacedSubdomainUrl = replaceSubdomain(url, targetLang);
    if (replacedSubdomainUrl) {
      candidates.push(replacedSubdomainUrl);
    }

    candidates.push(injectPath(url, targetLang));
    candidates.push(injectSubdomain(url, targetLang));
    candidates.push(...injectParams(url, targetLang));
  }

  console.log("[GoLocale] Total candidates generated:", candidates.length);
  return candidates;
};

const handleNotification = async () => {
  if (await GM.getValue("notify", false)) {
    await GM.setValue("notify", false);

    notify("GoLocale Redirected", "Stop", async () => {
      await GM.setValue(getStorageKey(location.hostname, 'user_disabled'), true);
      console.log("[GoLocale] User disabled redirects for domain:", getBaseDomain(location.hostname));
    });
  }
};

const tryRedirect = async () => {
  const url = location.href;
  console.log("[GoLocale] Starting redirect attempt for:", url);
  console.log("[GoLocale] document.referrer:", document.referrer);
  console.log("[GoLocale] history.length:", history.length);

  // Check if user has disabled redirects for this domain
  const userDisabledKey = getStorageKey(location.hostname, 'user_disabled');
  const userDisabled = await GM.getValue(userDisabledKey, false);

  if (userDisabled) {
    console.log("[GoLocale] Skipping - user disabled redirects for this domain");

    setTimeout(() => {
      if (!document.querySelector('[data-golocale]')) {
        notify("Redirects Disabled", "Enable", async () => {
          await GM.setValue(userDisabledKey, false);
          console.log("[GoLocale] Re-enabled redirects for domain:", getBaseDomain(location.hostname));
          notify("Redirects enabled!");
        });
      }
    }, 1000);

    return;
  }

  // Check if we should skip redirect (user navigated back from a GoLocale redirect)
  if (shouldSkipRedirect(url, document.referrer)) {
    console.log("[GoLocale] Skipping redirect - back navigation detected");
    return;
  }

  if (urlHasTargetLanguageHint(url)) {
    console.log("[GoLocale] URL already contains a target language hint, skipping to avoid redirect loops");
    return;
  }

  // Check if current page is already in target language
  console.log("[GoLocale] Checking current page language...");
  const currentPageHtml = document.documentElement.outerHTML;
  if (isTargetLanguage(currentPageHtml)) {
    console.log("[GoLocale] Current page is already in target language, no redirect needed");
    return;
  }

  const candidates = generateUrlCandidates(url);
  const filteredCandidates = [...new Set(candidates)].filter((c) => c !== url);
  console.log("[GoLocale] Filtered candidates (excluding original URL):", filteredCandidates);

  for (let i = 0; i < filteredCandidates.length; i++) {
    const candidate = filteredCandidates[i];
    console.log(`[GoLocale] Testing candidate ${i + 1}/${filteredCandidates.length}:`, candidate);

    const { isTarget, html } = await fetchAndCheckLanguage(candidate);
    if (isTarget) {
      console.log("[GoLocale] Found working candidate:", candidate);

      // Mark the current URL so we can detect back navigation later
      markUrlAsRedirected(url, candidate);

      await GM.setValue("notify", true);

      if (html) {
        // Best path: pushState changes URL + document.write replaces content.
        // Zero navigation = history is never modified beyond the pushState.
        pushStateAndWrite(candidate, html);
      } else {
        // Fallback: no HTML available (no-cors), use regular navigation
        console.log("[GoLocale] No HTML available, falling back to location.href");
        location.href = candidate;
      }
      return;
    }
  }

  console.log("[GoLocale] No suitable candidates found, staying on current page");
};

// Main execution
(async () => {
  console.log("[GoLocale] Script starting...");

  if (typeof window === "undefined" || window !== window.top) {
    console.log("[GoLocale] Skipping - not in top-level window");
    return;
  }

  // Dynamic imports
  console.log("[GoLocale] Loading language detection libraries...");
  const { franc: francDetect } = await import("https://cdn.jsdelivr.net/npm/[email protected]/+esm");
  const { iso6393: iso6393Data, iso6393To1 } = await import("https://cdn.jsdelivr.net/npm/[email protected]/+esm");
  console.log("[GoLocale] Libraries loaded successfully");

  // Create language map for O(1) lookups
  window.langMap = new Map(
    iso6393Data.flatMap((lang) =>
      [lang.iso6391, lang.iso6392B, lang.iso6392T]
        .filter(Boolean)
        .map((code) => [code.toLowerCase(), lang])
    )
  );

  const startRedirect = async () => {
    console.log("[GoLocale] Starting redirect process...");
    await tryRedirect();
    console.log("[GoLocale] Redirect process completed");
  };

  if (document.readyState === "loading") {
    console.log("[GoLocale] Waiting for DOMContentLoaded...");
    window.addEventListener("DOMContentLoaded", startRedirect);
  } else {
    console.log("[GoLocale] DOM already loaded, executing immediately");
    startRedirect();
  }

  window.addEventListener("load", async () => {
    await handleNotification();
  });
})();

// Export functions for testing using CommonJS
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    replaceLanguageCodes,
    injectPath,
    injectSubdomain,
    injectParams,
    isTargetLanguage,
    tryRedirect,
    urlHasTargetLanguageHint
  };
}