Get hints from geochachs
// ==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();
});
});
}
}
})
();