Geocaching Helper

Get hints from geochachs

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
                });
            });
        }
    }

})
();