Geocaching Helper

Get hints from geochachs

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Geocaching Helper
// @namespace    https://www.geocaching.com/
// @version      0.3.2
// @description  Get hints from geochachs
// @license      Copyright vcoords2
// @supportURL   https://vcoords2.alwaysdata.net
// @icon         https://vcoords2.alwaysdata.net/favicon.ico
// @author       xxx
// @match        https://www.geocaching.com/account/dashboard*
// @match        https://www.geocaching.com/play/map*
// @match        https://www.geocaching.com/geocache/*
// @match        https://vcoords2.alwaysdata.net/*
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @run-at       document-start
// @grant        unsafeWindow
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      vcoords2.alwaysdata.net
// @connect      localhost
// @connect      localhost:8000
// @connect      127.0.0.1
// @connect      127.0.0.1:8000
// ==/UserScript==

(function () {
    'use strict';

    const href = window.location.href;
    const LOG = '[VC2]';


    // const API_BASE_URL = 'http://localhost:8000';
    const API_BASE_URL = 'https://vcoords2.alwaysdata.net';

    function apiBase() {
        return API_BASE_URL.replace(/\/+$/, '');
    }

    function getApiSyncUrl() {
        return apiBase() + '/api/caches/bulk-upsert/';
    }

    function getApiUserUrl(userId) {
        return apiBase() + '/api/users/' + encodeURIComponent(userId) + '/';
    }

    function getApiCacheLookupUrl(cacheCodeOrId) {
        return apiBase() + '/api/caches/' + encodeURIComponent(cacheCodeOrId) + '/';
    }

    function getApiCacheResolveUrl(cacheCodeOrId) {
        return apiBase() + '/api/caches/' + encodeURIComponent(cacheCodeOrId) + '/resolve/';
    }

    function getCSSURL() {
        return apiBase() + '/static/css/hint.css';
    }

    function addStyles() {
        if (document.getElementById('tm-geo-hint-styles')) {
            return;
        }

        GM_xmlhttpRequest({
            method: 'GET',
            url: getCSSURL(),
            onload: function (response) {
                if (response.status === 200) {
                    const style = GM_addStyle(response.responseText);
                    style.id = 'tm-geo-hint-styles';
                    console.log(LOG, 'CSS geladen');
                } else {
                    console.error(LOG, 'CSS konnte nicht geladen werden:', response.status);
                }
            },
            onerror: function (error) {
                console.error(LOG, 'CSS-Ladefehler:', error);
            }
        });
    }

    function showTmMessage(text, type = 'info') {
        function Message(text, type) {
            const allowedTypes = ['info', 'warn', 'error'];

            if (!allowedTypes.includes(type)) {
                type = 'info';
            }

            let container = document.getElementById('tm-message-container');

            if (!container) {
                container = document.createElement('div');
                container.id = 'tm-message-container';
                document.body.appendChild(container);
            }

            const messageBox = document.createElement('div');
            messageBox.className = `tm-message-box ${type}`;
            messageBox.textContent = text;

            container.appendChild(messageBox);

            requestAnimationFrame(() => {
                messageBox.classList.add('visible');
            });

            setTimeout(() => {
                messageBox.classList.remove('visible');

                setTimeout(() => {
                    messageBox.remove();

                    if (container.children.length === 0) {
                        container.remove();
                    }
                }, 250);
            }, 20000);
        }

        onBodyReady(() => {
            Message(text, type);
        });

    }

    function decimalToGeocachingFormat(lat, lng) {
        const latNum = parseFloat(lat);
        const lngNum = parseFloat(lng);

        if (isNaN(latNum) || isNaN(lngNum)) {
            return "";
        }

        const latHemisphere = latNum >= 0 ? "N" : "S";
        const lngHemisphere = lngNum >= 0 ? "E" : "W";

        const absLat = Math.abs(latNum);
        const absLng = Math.abs(lngNum);

        const latDeg = Math.floor(absLat);
        const lngDeg = Math.floor(absLng);

        const latMin = (absLat - latDeg) * 60;
        const lngMin = (absLng - lngDeg) * 60;

        return (
            latHemisphere + "" +
            String(latDeg).padStart(2, "0") + "° " +
            latMin.toFixed(3).padStart(6, "0") + "' " +
            lngHemisphere + "" +
            String(lngDeg).padStart(3, "0") + "° " +
            lngMin.toFixed(3).padStart(6, "0") + "'"
        );
    }

    // ==========================
    // Auth / Token
    // ==========================

    const GcAuth = (() => {
        const STORAGE_VERSION = 'v0.1';
        const KEY_PREFIX = `tmGc_${STORAGE_VERSION}_`;

        const KEY_ACCOUNT_ID = `${KEY_PREFIX}AccountId`;
        const KEY_ACCOUNT_CODE = `${KEY_PREFIX}AccountCode`;
        const KEY_USERNAME = `${KEY_PREFIX}Username`;
        const KEY_TOKEN = `${KEY_PREFIX}AccessToken`;
        const KEY_EXPIRE = `${KEY_PREFIX}AccessTokenExpiresAt`;
        const KEY_DEBUG = `${KEY_PREFIX}debug`;

        const state = {
            _accountId: GM_getValue(KEY_ACCOUNT_ID, null),
            _username: GM_getValue(KEY_USERNAME, null),
            _accountCode: GM_getValue(KEY_ACCOUNT_CODE, null),
            _token: GM_getValue(KEY_TOKEN, null),
            _expire: GM_getValue(KEY_EXPIRE, null)
        };

        const load = () => {
            state._accountId = GM_getValue(KEY_ACCOUNT_ID, null);
            state._username = GM_getValue(KEY_USERNAME, null);
            state._accountCode = GM_getValue(KEY_ACCOUNT_CODE, null);
            state._token = GM_getValue(KEY_TOKEN, null);
            state._expire = GM_getValue(KEY_EXPIRE, null);
            console.log(LOG, "state loaded", state);
        }

        load();

        const save = (key, value) => {
            console.log(LOG, "saved", key, value)
            try {
                GM_setValue(key, value);
            } catch (e) {
            }
        };

        const resetToken = () => {
            state._token = null;
            state._expire = null;

            save(KEY_TOKEN, null);
            save(KEY_EXPIRE, null);
            showTmMessage("User changed. Access token reset.", "warn");
            console.log(LOG, 'Access-Token zurückgesetzt.');
        };


        const isTokenValid = () => {
            if (!state._token || !state._expire) return false;

            const exp = new Date(state._expire).getTime();

            return !isNaN(exp) && exp > Date.now();
        };

        const saveTokenResponse = data => {
            if (!data?.access_token) return false;

            state._token = data.access_token;

            const expiresIn = parseInt(data.expires_in, 10);

            state._expire = !isNaN(expiresIn)
                ? new Date(Date.now() + expiresIn * 1000).toISOString()
                : data.expires_at || null;

            save(KEY_TOKEN, state._token);
            save(KEY_EXPIRE, state._expire);

            console.log(LOG, 'Access-Token gespeichert. Läuft ab:', state._expire);

            return true;
        };

        const requestNewAccessToken = () => {
            return fetch('/account/oauth/token', {
                method: 'POST',
                credentials: 'include',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({})
            })
                .then(res => {
                    if (!res.ok) {
                        showTmMessage("Token request failed: HTTP " + res.status, "error");
                        throw new Error('Token-Request fehlgeschlagen: HTTP ' + res.status);
                    }

                    return res.json();
                })
                .then(data => {
                    if (!saveTokenResponse(data)) {
                        showTmMessage("No valid token response received.", "error");
                        throw new Error('Keine gültige Token-Antwort erhalten.');
                    }

                    return state._token;
                });
        };

        // noinspection JSUnusedGlobalSymbols
        return {
            get debugMode() {
                return GM_getValue(KEY_DEBUG);
            },
            get userId() {
                return state._accountId;
            },
            get username() {
                return state._username;
            },
            get userCode() {
                return state._accountCode;
            },

            get token() {
                return state._token;
            },

            get expire() {
                return state._expire;
            },

            clearGcAuth() {

                GM_setValue(KEY_ACCOUNT_ID, null);
                GM_setValue(KEY_USERNAME, null);
                GM_setValue(KEY_ACCOUNT_CODE, null);
                GM_setValue(KEY_TOKEN, null);
                GM_setValue(KEY_EXPIRE, null);
            },

            setGcDebug(debug = true) {
                GM_setValue(KEY_DEBUG, debug);
            },

            setUser(accountId, username, accountCode) {
                if (accountId == null || accountId === '') {
                    console.warn(LOG, 'accountId muss übergeben werden.');
                    return;
                }
                if (accountCode == null || accountCode === '') {
                    console.warn(LOG, 'accountCode muss übergeben werden.');
                    return;
                }

                let changed = false;

                if (state._accountId !== accountId) {
                    console.log(LOG, state._accountId, accountId)
                    state._accountId = accountId;
                    save(KEY_ACCOUNT_ID, accountId);
                    changed = true;
                }

                if (username != null && username !== '' && state._username !== username) {
                    state._username = username;
                    save(KEY_USERNAME, username);
                    changed = true;
                }
                if (state._accountCode !== accountCode) {
                    console.log(LOG, state._accountCode, accountId)
                    state._accountCode = accountCode;
                    save(KEY_ACCOUNT_CODE, accountCode);
                    changed = true;
                }
                if (changed) {
                    resetToken();
                }
            }
            ,

            ensureValidAccessToken() {
                return isTokenValid()
                    ? Promise.resolve(state._token)
                    : requestNewAccessToken();
            }
            ,

            getAuthHeaders(token) {
                if (state._accountId == null || state._accountId === '') {
                    throw new Error('accountId fehlt. Bitte zuerst setUser(accountId, username) aufrufen.');
                }

                return {
                    Authorization: 'Bearer ' + token,
                    Accept: 'application/json',
                    'Content-Type': 'application/json',
                    'X-User-ID': String(state._accountId),
                    'X-User-Name': String(state._username),
                    'X-User-Code': String(state._accountCode),
                };
            }
        }
            ;
    })();


    // ==========================
    // Allgemein
    // ==========================

    function enableDebugWindow(debug) {
    if (!debug) return;

    const debugBox = document.createElement("div");
    debugBox.id = "tm-debug-window";
    debugBox.style.position = "fixed";
    debugBox.style.right = "10px";
    debugBox.style.bottom = "10px";
    debugBox.style.width = "400px";
    debugBox.style.height = "300px";
    debugBox.style.background = "rgba(0, 0, 0, 0.85)";
    debugBox.style.color = "#00ff88";
    debugBox.style.fontFamily = "monospace";
    debugBox.style.fontSize = "12px";
    debugBox.style.border = "1px solid #555";
    debugBox.style.borderRadius = "8px";
    debugBox.style.zIndex = "999999";
    debugBox.style.boxShadow = "0 0 10px rgba(0,0,0,0.5)";
    debugBox.style.display = "flex";
    debugBox.style.flexDirection = "column";
    debugBox.style.overflow = "hidden";

    const header = document.createElement("div");
    header.style.display = "flex";
    header.style.justifyContent = "space-between";
    header.style.alignItems = "center";
    header.style.padding = "10px";
    header.style.background = "rgba(20, 20, 20, 0.95)";
    header.style.borderBottom = "1px solid #555";
    header.style.flexShrink = "0";

    const title = document.createElement("div");
    title.textContent = "Debug Console";
    title.style.fontWeight = "bold";
    title.style.color = "#ffffff";

    const buttonWrap = document.createElement("div");
    buttonWrap.style.display = "flex";
    buttonWrap.style.gap = "6px";

    const copyBtn = document.createElement("button");
    copyBtn.textContent = "Copy";
    copyBtn.style.background = "#3366aa";
    copyBtn.style.color = "#fff";
    copyBtn.style.border = "none";
    copyBtn.style.borderRadius = "4px";
    copyBtn.style.cursor = "pointer";
    copyBtn.style.padding = "2px 6px";
    copyBtn.style.fontSize = "12px";

    const closeBtn = document.createElement("button");
    closeBtn.textContent = "✖";
    closeBtn.style.background = "#aa3333";
    closeBtn.style.color = "#fff";
    closeBtn.style.border = "none";
    closeBtn.style.borderRadius = "4px";
    closeBtn.style.cursor = "pointer";
    closeBtn.style.padding = "2px 6px";
    closeBtn.style.fontSize = "12px";

    closeBtn.addEventListener("click", () => {
        debugBox.remove();
        GcAuth.setGcDebug(false);
    });

    const content = document.createElement("div");
    content.style.flex = "1";
    content.style.overflowY = "auto";
    content.style.padding = "10px";
    content.style.whiteSpace = "pre-wrap";
    content.style.wordBreak = "break-word";

    copyBtn.addEventListener("click", async () => {
        try {
            await navigator.clipboard.writeText(content.innerText);
            copyBtn.textContent = "Kopiert!";
            setTimeout(() => {
                copyBtn.textContent = "Copy";
            }, 1500);
        } catch (err) {
            copyBtn.textContent = "Fehler";
            setTimeout(() => {
                copyBtn.textContent = "Copy";
            }, 1500);
            console.error("Kopieren fehlgeschlagen:", err);
        }
    });

    buttonWrap.appendChild(copyBtn);
    buttonWrap.appendChild(closeBtn);

    header.appendChild(title);
    header.appendChild(buttonWrap);

    debugBox.appendChild(header);
    debugBox.appendChild(content);

    document.body.appendChild(debugBox);

    function addMessage(type, args) {
        if (!document.body.contains(debugBox)) return;

        const line = document.createElement("div");
        line.style.marginBottom = "4px";

        if (type === "error") line.style.color = "#ff6666";
        else if (type === "warn") line.style.color = "#ffcc00";
        else if (type === "info") line.style.color = "#66ccff";
        else line.style.color = "#00ff88";

        line.textContent = `[${type.toUpperCase()}] ` + args.map(arg => {
            if (typeof arg === "object") {
                try {
                    return JSON.stringify(arg, null, 2);
                } catch {
                    return String(arg);
                }
            }
            return String(arg);
        }).join(" ");

        content.appendChild(line);
        content.scrollTop = content.scrollHeight;
    }

    const original = {
        log: console.log,
        warn: console.warn,
        error: console.error,
        info: console.info
    };

    console.log = function (...args) {
        original.log.apply(console, args);
        addMessage("log", args);
    };

    console.warn = function (...args) {
        original.warn.apply(console, args);
        addMessage("warn", args);
    };

    console.error = function (...args) {
        original.error.apply(console, args);
        addMessage("error", args);
    };

    console.info = function (...args) {
        original.info.apply(console, args);
        addMessage("info", args);
    };
}

    function onBodyReady(fn) {
        if (document.body) fn();
        else document.addEventListener('DOMContentLoaded', fn, {once: true});
    }

    function getText(selector) {
        return document.querySelector(selector)?.textContent?.trim() || null;
    }

    function parseLatLon(text) {
        if (!text) return {lat: null, lng: null};

        const m = text.replace(/\s+/g, ' ').trim().match(
            /([NS])\s*(\d{1,3})[°º]\s*([\d.,]+)['’]?\s*([EW])\s*(\d{1,3})[°º]\s*([\d.,]+)['’]?/i
        );

        if (!m) return {lat: null, lng: null};

        const [, ns, latDeg, latMin, ew, lngDeg, lngMin] = m;

        let lat = Number(latDeg) + Number(latMin.replace(',', '.')) / 60;
        let lng = Number(lngDeg) + Number(lngMin.replace(',', '.')) / 60;

        if (ns.toUpperCase() === 'S') lat *= -1;
        if (ew.toUpperCase() === 'W') lng *= -1;

        return {lat, lng};
    }

    function addLink() {
        console.log(LOG, "addLink gestartet");

        let tries = 0;
        const maxTries = 50;

        const timer = setInterval(() => {
            tries++;

            const targetElement = document.querySelector("span.legal");

            if (!targetElement) {
                console.log(LOG, "span.legal noch nicht gefunden, Versuch:", tries);
                if (tries >= maxTries) {
                    clearInterval(timer);
                    console.log(LOG, "Abbruch: span.legal nicht gefunden");
                }
                return;
            }

            if (document.querySelector("#gc-helper-link")) {
                clearInterval(timer);
                console.log(LOG, "GC Helper Link existiert bereits");
                return;
            }

            const separator = document.createElement("span");
            separator.className = "gc-helper-separator";
            separator.textContent = "|";

            const newLink = document.createElement("a");
            newLink.id = "gc-helper-link";
            newLink.href = "https://vcoords2.alwaysdata.com";
            newLink.textContent = "GC Helper";
            newLink.target = "_blank";
            newLink.rel = "noopener noreferrer";

            targetElement.appendChild(separator);
            targetElement.appendChild(newLink);

            clearInterval(timer);
            console.log(LOG, "GC Helper Link eingefügt");
        }, 500);
    }

    // ==========================
    // Dashboard
    // ==========================

    function captureAccountId() {
        const fromScriptsID = () => {
            for (const script of unsafeWindow.document.querySelectorAll('script')) {
                const text = script.textContent || '';
                const match = text.match(/accountId\s*:\s*(\d+)/);
                if (match) return match[1];
            }
            return null;
        };

        const fromScriptsName = () => {
            for (const script of unsafeWindow.document.querySelectorAll('script')) {
                const text = script.textContent || '';
                const match = text.match(/username\s*:\s*"(.+)",/);
                if (match) return match[1];
            }
            return null;
        };

        const fromScriptsCode = () => {
            for (const script of unsafeWindow.document.querySelectorAll('script')) {
                const text = script.textContent || '';
                const match = text.match(/window\['userRef'\]\s*=\s*'([^']+)'/);
                if (match) return match[1];
            }
            return null;
        }
        return new Promise((resolve, reject) => {
            const tryCapture = (retries = 40) => {
                const id = unsafeWindow.chromeSettings?.accountId || fromScriptsID();
                const name = unsafeWindow.chromeSettings?.username || fromScriptsName();
                const code = unsafeWindow.userRef || serverParameters['user:info']?.referenceCode || fromScriptsCode();

                if (id) {
                    GcAuth.setUser(id, name, code);
                    resolve();
                    return true;
                }

                if (retries > 0) {
                    setTimeout(() => tryCapture(retries - 1), 250);
                } else {
                    reject(new Error('accountId oder username nicht gefunden'));

                }
            };

            tryCapture();
        });
    }


    let profileList = null;

    function getProfileList() {
        if (!profileList) {
            profileList = document.querySelector(
                'ul.list-none.m-0.p-0.text-semantic-fg-neutral-secondary.gap-1.flex.flex-col'
            );
        }
        return profileList;
    }

    function insertAccountId(retries = 40) {
        const ul = getProfileList();

        if (!ul) {
            if (retries > 0) setTimeout(() => insertAccountId(retries - 1), 250);
            return;
        }

        ul.querySelector('li[data-tm-gc-account-id]')?.remove();

        const li = document.createElement('li');
        li.className = 'flex gap-1 items-center text-xs leading-4';
        li.dataset.tmGcAccountId = 'true';
        li.textContent = 'Account ID: ' + GcAuth.userId;

        ul.appendChild(li);
    }

    function formatDate(value) {
        if (!value) return '';

        const d = new Date(value);

        return isNaN(d.getTime()) ? String(value) : d.toLocaleString('de-DE');
    }

    function fetchUserStats(retries = 20) {
        const ul = getProfileList();
        if (!ul) {
            if (retries > 0) setTimeout(() => fetchUserStats(retries - 1), 250);
            return;
        }

        GcAuth.ensureValidAccessToken()
            .then(token => {

                GM_xmlhttpRequest({
                    method: 'GET',
                    url: getApiUserUrl(GcAuth.userId),
                    headers: GcAuth.getAuthHeaders(token),
                    onload: res => {

                        if (res.status !== 200) {
                            console.log(LOG, "error in dashboard", res.status, res.responseText);
                            return;

                        }

                        let data;
                        try {
                            data = JSON.parse(res.responseText);
                        } catch (e) {
                            console.log(LOG, "error in dashboard parse", res.status, res.responseText);

                            return;
                        }

                        ul.querySelector('li[data-tm-gc-user-stats]')?.remove();

                        const li = document.createElement('li');
                        li.className = 'flex gap-1 items-start text-xs leading-4';
                        li.dataset.tmGcUserStats = 'true';

                        li.innerHTML =
                            'Tokens: ' + (data.tokens ?? 0) +
                            '<br>Hints get: ' + (data.hints_get ?? 0) +
                            '<br>Caches send: ' + (data.caches_added ?? 0)
                        if (data.next_free_token_at) {
                            li.innerHTML += '<br>Next free token: ' + formatDate(data.next_free_token_at);
                        }

                        ul.appendChild(li);
                    }
                });
            })
            .catch(err => console.warn(LOG, 'User-Stats ohne Token übersprungen:', err));
    }

    function dashboard() {
        captureAccountId().then(() => {
            insertAccountId();
            fetchUserStats();
        }).catch((err) => {
            console.error(LOG, 'Fehler:', err.message);
        });
    }


    // ==========================
    // Cache page
    // ==========================

    let lookupStarted = false;
    let observerStarted = false;
    let hasHintCoordinates = false;


    const ALLOWED_CACHE_TYPES = [3, 5, 8, 1858];

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function waitFor(condition, retries = 40, delay = 1000) {
        for (let i = 0; i < retries; i++) {
            if (condition()) {
                return true;
            }

            await sleep(delay);
        }

        return false;
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                ...options,
                onload: resolve,
                onerror: reject
            });
        });
    }

    async function extractMinimalCacheData() {
        const found = await waitFor(
            () => typeof unsafeWindow.userDefinedCoords !== 'undefined',
            40,
            1000
        );

        if (!found) {
            console.warn(LOG, 'window.userDefinedCoords wurde nicht gefunden.');
        }

        const analytics = getAnalyticsData();
        const posted = getPostedCoordinates();
        const corrected = getNewCoordinates();
        const cache_props = getTerraAndDifficulty();

        const data = {
            id: analytics.cache_id ?? null,
            code: getGcCode(),
            name: getCacheName(),
            geocacheType: analytics.cache_type ?? null,
            terrain: cache_props.terra,
            difficulty: cache_props.difficulty,
            lat: posted.lat,
            lng: posted.lng,
            new_lat: corrected.new_lat,
            new_lng: corrected.new_lng
        };

        window.__gcExtractedCacheData = data;

        console.log(LOG, 'Minimal Cache-Daten extrahiert:');
        console.log(data);

        return data;
    }

    function isValidCacheData(data) {
        return Boolean(
            data?.code &&
            data?.name &&
            data?.lat !== null &&
            data?.lng !== null
        );
    }

    function getAnalyticsData() {
        if (unsafeWindow.submitForReviewAnalyticsData) {
            return unsafeWindow.submitForReviewAnalyticsData;
        }
        if (window.submitForReviewAnalyticsData) {
            return window.submitForReviewAnalyticsData;
        }

        for (const script of document.querySelectorAll('script')) {
            const text = script.textContent || '';

            if (!text.includes('submitForReviewAnalyticsData')) continue;

            const jsonParseMatch = text.match(/submitForReviewAnalyticsData\s*=\s*JSON\.parse\('([^']+)'\)/);

            if (jsonParseMatch) {
                try {
                    window.submitForReviewAnalyticsData = JSON.parse(jsonParseMatch[1].replace(/\\"/g, '"'));
                    return window.submitForReviewAnalyticsData;
                } catch (e) {
                }
            }

            const objectMatch = text.match(/submitForReviewAnalyticsData\s*=\s*(\{[\s\S]*?\});/);

            if (objectMatch) {
                try {
                    window.submitForReviewAnalyticsData = Function('"use strict";return (' + objectMatch[1] + ')')();
                    return window.submitForReviewAnalyticsData;
                } catch (e) {
                }
            }
        }

        return {};
    }

    function getGcCode() {
        return (
            getAnalyticsData()?.gc_code ||
            unsafeWindow.location.pathname.match(/\/geocache\/(GC[A-Z0-9]+)/i)?.[1]?.toUpperCase() ||
            document.body?.innerText?.match(/\bGC[A-Z0-9]+\b/i)?.[0]?.toUpperCase() ||
            null
        );
    }

    function getCacheName() {
        return (
            getText('#ctl00_ContentBody_CacheName') ||
            getText('[id*="CacheName"]') ||
            document.querySelector('meta[property="og:title"]')?.content?.trim() ||
            document.title?.replace(/\s*\|.*$/, '').trim() ||
            null
        );
    }

    function getPostedCoordinates() {
        const selectors = [
            '#uxLatLon',
            '[id*="LatLon"]',
            '[class*="coordinates"]',
            '[class*="Coordinates"]'
        ];

        for (const selector of selectors) {
            const parsed = parseLatLon(getText(selector));

            if (parsed.lat !== null && parsed.lng !== null) {
                return parsed;
            }
        }

        return parseLatLon(document.body?.innerText || '');
    }

    function getNewCoordinates() {
        const value =
            unsafeWindow.userDefinedCoords?.data?.newLatLng ||
            unsafeWindow.userDefinedCoords?.newLatLng ||
            unsafeWindow.correctedCoordinates ||
            unsafeWindow.correctedCoords ||
            null;

        if (Array.isArray(value)) {
            return {
                new_lat: value[0] ?? null,
                new_lng: value[1] ?? null
            };
        }

        if (value && typeof value === 'object') {
            return {
                new_lat: value.lat ?? value.latitude ?? null,
                new_lng: value.lng ?? value.longitude ?? value.lon ?? null
            };
        }

        if (typeof value === 'string') {
            const parsed = parseLatLon(value);

            return {
                new_lat: parsed.lat,
                new_lng: parsed.lng
            };
        }

        const match = (document.body?.innerText || '').match(
            /(?:Corrected Coordinates|Korrigierte Koordinaten|Neue Koordinaten)[\s\S]{0,300}?([NS]\s*\d{1,3}[°º]\s*[\d.,]+['’]?\s*[EW]\s*\d{1,3}[°º]\s*[\d.,]+['’]?)/i
        );

        const parsed = match ? parseLatLon(match[1]) : {lat: null, lng: null};

        return {
            new_lat: parsed.lat,
            new_lng: parsed.lng
        };
    }

    function getTerraAndDifficulty() {
        const meta = document.querySelector('meta[property="og:description"]');
        if (!meta) return null;

        const content = meta.getAttribute('content') || '';

        const terrainMatch = content.match(/terrain\s+is\s+([0-9.]+)/i);
        const difficultyMatch = content.match(/difficulty\s+is\s+([0-9.]+)/i);

        return {
            terra: terrainMatch ? parseFloat(terrainMatch[1]) : null,
            difficulty: difficultyMatch ? parseFloat(difficultyMatch[1]) : null
        };
    }

    async function runGeocacheExtractor() {
        if (lookupStarted) {
            return true;
        }

        const data = await extractMinimalCacheData();

        if (!isValidCacheData(data)) {
            console.warn(LOG, 'Extrahierte Cache-Daten sind unvollständig:', data);
            return false;
        }

        lookupStarted = true;

        return await fetchCacheDetailsForMystery(data);
    }

    async function fetchCacheDetailsForMystery(pageCacheData) {
        if (!pageCacheData?.code) {
            console.warn(LOG, 'Kein GC-Code vorhanden; Cache-Lookup übersprungen.');
            return false;
        }

        const geocacheType = Number(pageCacheData.geocacheType);

        if (!ALLOWED_CACHE_TYPES.includes(geocacheType)) {
            console.log(LOG, `geocacheType ${geocacheType} ist nicht erlaubt; API-Lookup übersprungen.`);
            return false;
        }

        try {
            const token = await GcAuth.ensureValidAccessToken();

            const res = await gmRequest({
                method: 'PUT',
                url: getApiCacheLookupUrl(pageCacheData.code),
                headers: GcAuth.getAuthHeaders(token),
                data: JSON.stringify(pageCacheData)
            });

            console.log(LOG, 'Cache-Lookup Status:', res.status);

            if (res.status !== 200) {
                console.warn(LOG, 'Cache-Lookup fehlgeschlagen:', res.status, res.responseText);
                return false;
            }

            let apiCacheData;

            try {
                apiCacheData = JSON.parse(res.responseText);
            } catch (e) {
                console.warn(LOG, 'Cache-Lookup JSON konnte nicht gelesen werden:', e);
                return false;
            }

            console.log(LOG, 'Cache-Lookup Daten:');
            console.log(LOG, apiCacheData);

            handleCacheLookupResult(apiCacheData, pageCacheData);

            return true;

        } catch (err) {
            console.warn(LOG, 'Kein gültiger Token oder Fehler beim Cache-Lookup:', err);
            return false;
        }
    }

    function handleCacheLookupResult(apiCacheData) {
        const coordinate_status = apiCacheData.coordinate_status;
        hasHintCoordinates = coordinate_status==="available" || coordinate_status==="fetched"
        console.log(
            LOG,
            "Coordinate status", coordinate_status);
        addStatusBehindCoordinates(coordinate_status);
        if (hasHintCoordinates) {
            checkForCorrectedCoordinatesDialog();
        }
    }

    function startObserverWhenBodyExists() {
        if (observerStarted) {
            return;
        }

        if (!document.body) {
            requestAnimationFrame(startObserverWhenBodyExists);
            return;
        }

        observerStarted = true;

        checkForCorrectedCoordinatesDialog();

        new MutationObserver(checkForCorrectedCoordinatesDialog).observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    function addStatusBehindCoordinates(coordinate_status = null) {
        const coordinatesSpan = document.querySelector('#uxLatLon');
        const coordinatesButton = document.querySelector('#uxLatLonLink');

        if (!coordinatesSpan || !coordinatesButton) {
            return;
        }

        let status = coordinatesSpan.parentElement.querySelector('.tm-latlon-hint-status');

        // Falls noch nicht vorhanden, neu erstellen
        if (!status) {
            status = document.createElement('span');
            status.classList.add('tm-hint-status', 'tm-latlon-hint-status');
            coordinatesSpan.insertAdjacentElement('afterend', status);
        }

        // Alte Zustandsklassen entfernen
        status.classList.remove('available', 'unavailable', 'unknown', "fetched");

        if (coordinate_status === "fetched") {
            status.classList.add('fetched');
            status.textContent = '⬇';
            status.title = 'Coordinates fetched';
            status.setAttribute('aria-label', 'Coordinates fetched');
        } else if(coordinate_status === "available") {
                status.classList.add('available');
                status.textContent = '✔';
                status.title = 'Coordinates available';
                status.setAttribute('aria-label', 'Coordinates available');
        } else if (coordinate_status === "unavailable") {
            status.classList.add('unavailable');
            status.textContent = '✖';
            status.title = 'No hint coordinates available';
            status.setAttribute('aria-label', 'No hint coordinates available');
        } else {
            status.classList.add('unknown');
            status.textContent = '?';
            status.title = 'Coordinate status unknown';
            status.setAttribute('aria-label', 'Coordinate status unknown');
        }
    }

    function addHintButton(ccuUpdate) {
        if (!hasHintCoordinates) {
            return;
        }

        if (ccuUpdate.querySelector('.tm-get-hint-row')) {
            return;
        }

        const originalDl = ccuUpdate.querySelector('dl');
        const input = ccuUpdate.querySelector('input.cc-parse-text');

        if (!originalDl || !input) {
            return;
        }

        const dt = document.createElement('dt');
        dt.textContent = 'Hint:';

        const dd = document.createElement('dd');

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'tm-get-hint-button';
        button.title = hasHintCoordinates
            ? 'Coordinates available'
            : 'No hint coordinates available';

        if (!hasHintCoordinates) {
            button.disabled = true;
        }

        const label = document.createElement('span');
        label.textContent = 'Get hint';

        button.appendChild(label);

        button.addEventListener('click', () => {
            if (!hasHintCoordinates) {
                return;
            }

            input.value = "";
            getNewCacheCoordinaten().then(function (coords) {
                if (coords) {
                    console.log(LOG, 'Neue Koordinaten:', coords);
                    showTmMessage('Token used', 'info');

                    // Ziel-Eingabeelement mit Attribut data-testid finden
                    const input = document.querySelector("[data-testid='corrected-coords-input']");

                    if (input) {
                        // `value` korrekt aktualisieren über den Prototyp
                        const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
                            window.HTMLInputElement.prototype, "value"
                        ).set;
                        nativeInputValueSetter.call(input, coords);

                        // Events auslösen, damit React-Framework-Logik getriggert wird
                        input.dispatchEvent(new Event("input", {bubbles: true}));
                        input.dispatchEvent(new Event("change", {bubbles: true}));

                        console.log('Wert aktualisiert:', input.value);
                    } else {
                        console.error('Input mit data-testid nicht gefunden!');
                    }
                } else {
                    console.log(LOG, 'Keine neuen Koordinaten vorhanden.');
                }
            });

            input.dispatchEvent(new Event('input', {bubbles: true}));
            input.dispatchEvent(new Event('change', {bubbles: true}));

        });

        dd.appendChild(button);

        const wrapper = document.createElement('dl');
        wrapper.className = 'tm-get-hint-row';
        wrapper.appendChild(dt);
        wrapper.appendChild(dd);

        originalDl.insertAdjacentElement('afterend', wrapper);
    }

    function getNewCacheCoordinaten() {
        return new Promise(function (resolve) {
            const pageCacheData = window.__gcExtractedCacheData;

            if (!pageCacheData || !pageCacheData.code) {
                console.warn(LOG, 'Kein GC-Code vorhanden; Cache-Resolve übersprungen.');
                resolve("");
                return;
            }

            GcAuth.ensureValidAccessToken()
                .then(function (token) {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: getApiCacheResolveUrl(pageCacheData.code),
                        headers: GcAuth.getAuthHeaders(token),
                        onload: function (res) {
                            console.log(LOG, 'Cache-Resolve Status:', res.status);
                            console.log(LOG, 'Cahce-Resolve Data:', res.responseText);

                            if (res.status === 403) {
                                console.warn(LOG, 'Keine Tokens mehr vorhanden:', res.responseText);
                                showTmMessage("No tokens left", "error");
                                resolve("");
                                return;
                            }

                            if (res.status !== 200) {
                                console.warn(LOG, 'Cache-Resolve fehlgeschlagen:', res.status, res.responseText);
                                resolve("");
                                return;
                            }

                            let data;

                            try {
                                data = JSON.parse(res.responseText);
                            } catch (e) {
                                console.warn(LOG, 'Cache-Resolve JSON konnte nicht gelesen werden:', e);
                                resolve("");
                                return;
                            }

                            if (!data || data.detail) {
                                resolve("");
                                return;
                            }

                            if (
                                data.cache &&
                                data.cache.newCoordinates &&
                                data.cache.newCoordinates.lat &&
                                data.cache.newCoordinates.lng
                            ) {
                                resolve(decimalToGeocachingFormat(
                                    data.cache.newCoordinates.lat,
                                    data.cache.newCoordinates.lng
                                ));
                                return;
                            }

                            resolve("");
                        },
                        onerror: function (err) {
                            console.warn(LOG, 'Fehler beim Cache-Resolve:', err);
                            resolve("");
                        }
                    });
                })
                .catch(function (err) {
                    console.warn(LOG, 'Kein gültiger Token; Cache-Resolve übersprungen.', err);
                    resolve("");
                });
        });
    }

    function checkForCorrectedCoordinatesDialog() {
        document.querySelectorAll('div.ccu-update').forEach(addHintButton);
    }

    function geocache() {
        addStatusBehindCoordinates();

        captureAccountId()
            .then(() => runGeocacheExtractor())
            .then((success) => {
                if (!success) {
                    console.warn(LOG, 'Geocache-Extractor war nicht erfolgreich; Observer wird nicht gestartet.');
                    return;
                }

                startObserverWhenBodyExists();
            })
            .catch((err) => {
                console.warn(LOG, 'Initialisierung fehlgeschlagen:', err);
            });
    }

    // ==========================
    // Map
    // ==========================

    function buildCachePayload(result) {
        const corrected = result.userCorrectedCoordinates || {};
        const posted = result.postedCoordinates || {};
        const owner = result.owner || {};

        return {
            id: result.id,
            code: result.code,
            name: result.name,
            premiumOnly: result.premiumOnly,
            favoritePoints: result.favoritePoints,
            geocacheType: result.geocacheType,
            container_type: result.containerType,
            difficulty: result.difficulty,
            terrain: result.terrain,
            cacheStatus: result.cacheStatus,
            lat: posted.latitude || null,
            lng: posted.longitude || null,
            details_url: result.detailsUrl,
            placed_date: result.placedDate,
            owner_code: owner.code || null,
            owner_username: owner.username || null,
            region: result.region,
            country: result.country,
            attributes: result.attributes || [],
            new_lat: corrected.latitude || null,
            new_lng: corrected.longitude || null
        };
    }

    function sendCachesToApi(results) {
        if (!Array.isArray(results) || results.length === 0) return;
        GcAuth.ensureValidAccessToken()
            .then(token => {
                const payload = results.map(buildCachePayload);
                console.log(payload);
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: getApiSyncUrl(),
                    headers: GcAuth.getAuthHeaders(token),
                    data: JSON.stringify(payload),
                    onload: res => {
                        let data = null;
                        try {
                            data = JSON.parse(res.responseText);
                        } catch (e) {
                            console.log(LOG, "Error in pars json", e);
                        }
                        console.log(LOG, 'Cache-Sync abgeschlossen:', payload.length, 'Status:', res.status);
                        console.log(LOG, data);
                        if (data.token_credited > 0) {
                            showTmMessage("Tokens: " + data.token_credited);
                        }
                    },
                    onerror: err => {
                        console.warn(LOG, 'Fehler beim Cache-Sync:', err);
                    }
                });
            })
            .catch(err => console.warn(LOG, 'Kein gültiger Token; Cache-Sync übersprungen.', err));
    }

    function captureMapData() {
        const tryCapture = (retries = 40) => {
            const script = document.getElementById('__NEXT_DATA__');

            if (!script?.textContent) {
                if (retries > 0) setTimeout(() => tryCapture(retries - 1), 1000);
                return;
            }

            let json;

            try {
                json = JSON.parse(script.textContent);
            } catch (e) {
                if (retries > 0) setTimeout(() => tryCapture(retries - 1), 1000);
                return;
            }

            const pageProps = json?.props?.pageProps;

            if (!pageProps) return;

            if (pageProps.gcUser) {
                let gcUser = pageProps.gcUser;
                GcAuth.setUser(gcUser.id, gcUser.username, gcUser.referenceCode);
            }

            const results = pageProps.searchResults?.results;

            if (Array.isArray(results) && results.length) {
                console.log(LOG, 'Map:', results.length, 'Caches gefunden.');
                sendCachesToApi(results);
            }
        };

        tryCapture();
    }

    function setupMapReloadListeners() {
        document.addEventListener('click', e => {
            const btn = e.target.closest('button[data-event-category="data"]');
            const label = btn?.getAttribute('data-event-label');

            if (label === 'Filters - Apply' || label === 'Map - Search This Area') {
                setTimeout(captureMapData, 800);
            }
        }, true);
    }

    // ==========================
    // vcoords2
    // ==========================

    function injectGcAuthInfo() {
        const main = document.querySelector('main');
        if (!main) return;

        const section = document.createElement('section');
        section.style.marginTop = '20px';
        section.style.padding = '16px';
        section.style.border = '1px solid #ccc';
        section.style.borderRadius = '8px';
        section.style.background = '#f9f9f9';

        const render = () => {
            const STORAGE_VERSION = 'v0.1';
            const KEY_PREFIX = `tmGc_${STORAGE_VERSION}_`;

            const userId = GM_getValue(`${KEY_PREFIX}AccountId`, null);
            const username = GM_getValue(`${KEY_PREFIX}Username`, null);
            const token = GM_getValue(`${KEY_PREFIX}AccessToken`, null);
            const expire = GM_getValue(`${KEY_PREFIX}AccessTokenExpiresAt`, null);
            const debug = GM_getValue(`${KEY_PREFIX}debug`, false);

            section.innerHTML = `
            <h3>GC Auth</h3>
            <ul style="padding-left:20px;">
                <li><strong>User-ID:</strong> ${userId ?? '—'}</li>
                <li><strong>Username:</strong> ${username ?? '—'}</li>
                <li>
                    <strong>Token:</strong>
                    <details style="margin-top:4px;">
                        <summary>${token ? 'Anzeigen' : '—'}</summary>
                        <div style="margin-top:8px; word-break: break-all; overflow-wrap: anywhere; white-space: normal;">
                            ${token ?? '—'}
                        </div>
                    </details>
                </li>
                <li><strong>Expire:</strong> ${expire ?? '—'}</li>
                <li><strong>Debug:</strong> ${String(debug)}</li>
            </ul>
            <button id="tm-gc-auth-delete" type="button">Alle Daten löschen</button>
            <button id="tm-gc-debug-true" type="button" style="margin-left:10px;">Toggle Debug</button>
            <span id="tm-gc-auth-status" style="margin-left:10px;color:green;"></span>
        `;

            section.querySelector('#tm-gc-auth-delete')?.addEventListener('click', () => {
                GcAuth.clearGcAuth();
                section.querySelector('#tm-gc-auth-status').textContent = 'Alle Daten gelöscht.';
                render();
            });

            section.querySelector('#tm-gc-debug-true')?.addEventListener('click', () => {
                GcAuth.setGcDebug(!GcAuth.debugMode);
                section.querySelector('#tm-gc-auth-status').textContent = "Toggle Debug?!?";
                render();
            });
        };

        render();
        main.appendChild(section);
    }


    // ==========================
    // Start
    // ==========================
    console.log(LOG, "Base URL:", apiBase() );
    onBodyReady(() => {
        enableDebugWindow(GcAuth.debugMode);
    });
    if (href.includes("geocaching.com")) {
        addStyles();
        if (href.includes('/account/dashboard')) {
            onBodyReady(() => {
                console.log(LOG, 'Dashboard-Modus aktiv.');
                addLink();
                dashboard();
            });
        } else if (href.includes('/geocache/')) {
            onBodyReady(() => {
                console.log(LOG, 'Geocache-Minimal-Modus aktiv.');
                addLink();
                geocache();

            });
        } else if (href.includes('/play/map')) {
            onBodyReady(() => {
                console.log(LOG, 'Map-Modus aktiv. API_BASE_URL=', API_BASE_URL);
                captureMapData();
                setupMapReloadListeners();
            });
        }
    } else if (href.includes("vcoords2.alwaysdata.net")) {
        if (href.includes('/debug')) {
            onBodyReady(() => {
                console.log(LOG, "vcoords2 debug page");
                window.addEventListener('load', () => {
                    injectGcAuthInfo();
                });
            });
        }
    }

})
();