您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a GPX export button to download your trackable locations as a GPX file for use in other mapping applications.
// ==UserScript== // @name Geocaching Trackable GPX Exporter // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Add a GPX export button to download your trackable locations as a GPX file for use in other mapping applications. // @author ViezeVingertjes // @match *://*.geocaching.com/track/search.aspx* // @icon https://www.google.com/s2/favicons?sz=64&domain=geocaching.com // @grant none // @run-at document-end // ==/UserScript== (function() { 'use strict'; console.log("Geocaching Trackable GPX Exporter loaded!"); /** * Helper function to check if an element is visible * @param {Element} element - The DOM element to check * @returns {boolean} - Whether the element is visible */ function isElementVisible(element) { if (!element) return false; const style = window.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && element.offsetWidth > 0 && element.offsetHeight > 0; } /** * Extracts trackable information from anchor elements on the page * @returns {Map} Map of trackable objects with id as key */ function extractTrackablesFromPage() { const anchorElements = document.querySelectorAll("a"); const trackablesMap = new Map(); const trackableUrlPrefix = "https://www.geocaching.com/track/details.aspx?id="; anchorElements.forEach(anchor => { const href = anchor.getAttribute("href"); if (href && href.startsWith(trackableUrlPrefix)) { try { const url = new URL(href, document.baseURI); const id = url.searchParams.get("id"); const name = anchor.innerText.trim(); if (id && name && !trackablesMap.has(id)) { trackablesMap.set(id, { id, name }); } } catch (e) { console.error("Error parsing URL or extracting trackable info:", e, href); } } }); return trackablesMap; } /** * Extracts pagination information from the page * @returns {Object} Pagination info with currentPage, totalPages, and page link elements */ function extractPaginationInfo() { const paginationInfo = { currentPage: 1, totalPages: 1, pageLinks: [] }; // Look for pagination text that shows "Page: X of Y" const paginationText = document.querySelector('.PageBuilderWidget b:nth-of-type(2)'); const totalPagesText = document.querySelector('.PageBuilderWidget b:nth-of-type(3)'); if (paginationText && totalPagesText) { try { paginationInfo.currentPage = parseInt(paginationText.textContent, 10); paginationInfo.totalPages = parseInt(totalPagesText.textContent, 10); } catch (e) { console.error("Error parsing pagination numbers:", e); } } // Find all page links const pageLinks = document.querySelectorAll('a[id^="ctl00_ContentBody_ResultsPager_lbGoToPage_"]'); if (pageLinks.length > 0) { paginationInfo.pageLinks = Array.from(pageLinks); } // Find next page button const nextPageBtn = document.querySelector('a[href^="javascript:__doPostBack"][href*="Next"]'); if (nextPageBtn) { paginationInfo.nextPageBtn = nextPageBtn; } return paginationInfo; } /** * Fetches trackables from a specific page using a hidden iframe approach * @param {number} pageNum - The page number to fetch * @returns {Promise<Map>} Promise that resolves with trackables from that page */ async function fetchPageTrackablesViaIframe(pageNum) { try { console.log(`Attempting to fetch trackables from page ${pageNum}`); // Find the page link const pageLinks = document.querySelectorAll('a[id^="ctl00_ContentBody_ResultsPager_lbGoToPage_"]'); const pageLink = Array.from(pageLinks).find(link => link.textContent.trim() === String(pageNum)); if (!pageLink) { throw new Error(`Could not find link for page ${pageNum}`); } // Create a hidden iframe to load the page const iframe = document.createElement('iframe'); iframe.style.position = 'absolute'; iframe.style.left = '-9999px'; iframe.style.width = '1px'; iframe.style.height = '1px'; iframe.style.border = 'none'; // Add sandbox attribute to prevent script execution which causes the cookie errors iframe.setAttribute('sandbox', 'allow-forms allow-same-origin'); iframe.name = `trackable-page-${pageNum}-frame`; document.body.appendChild(iframe); // Prepare URL for iframe const href = pageLink.getAttribute('href'); const match = href.match(/__doPostBack\('([^']+)','([^']*)'\)/); if (!match) { throw new Error(`Could not parse postback parameters for page ${pageNum}`); } // Create a promise that resolves when the iframe loads const iframeLoadPromise = new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error(`Timeout fetching page ${pageNum}`)); }, 15000); // 15 second timeout iframe.onload = () => { clearTimeout(timeoutId); resolve(); }; iframe.onerror = () => { clearTimeout(timeoutId); reject(new Error(`Error loading page ${pageNum}`)); }; }); // Navigate the iframe to the page url by creating a navigation function const safeNavigate = () => { try { // Create a simple form to handle the postback const form = document.createElement('form'); form.style.display = 'none'; form.action = window.location.href; form.method = 'post'; form.target = iframe.name; // Create and add the necessary postback fields const eventTarget = document.createElement('input'); eventTarget.type = 'hidden'; eventTarget.name = '__EVENTTARGET'; eventTarget.value = match[1]; form.appendChild(eventTarget); const eventArgument = document.createElement('input'); eventArgument.type = 'hidden'; eventArgument.name = '__EVENTARGUMENT'; eventArgument.value = match[2]; form.appendChild(eventArgument); // Copy all hidden inputs from the current page to our form document.querySelectorAll('input[type="hidden"]').forEach(hiddenInput => { if (hiddenInput.name !== '__EVENTTARGET' && hiddenInput.name !== '__EVENTARGUMENT') { const input = document.createElement('input'); input.type = 'hidden'; input.name = hiddenInput.name; input.value = hiddenInput.value; form.appendChild(input); } }); // Add form to document, submit it, then remove it document.body.appendChild(form); form.submit(); document.body.removeChild(form); } catch (e) { console.error(`Error navigating iframe to page ${pageNum}:`, e); // As a fallback, try to directly set the src const siteUrl = window.location.origin + window.location.pathname; iframe.src = `${siteUrl}?${new URLSearchParams({page: pageNum}).toString()}`; } }; // Navigate the iframe safeNavigate(); // Wait for the iframe to load await iframeLoadPromise; // Extract trackables from the iframe content const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; const trackablesMap = new Map(); // Find trackable links in the iframe const anchorElements = iframeDoc.querySelectorAll("a"); const trackableUrlPrefix = "https://www.geocaching.com/track/details.aspx?id="; const relativeUrlPrefix = "/track/details.aspx?id="; anchorElements.forEach(anchor => { const href = anchor.getAttribute("href"); if (href && (href.startsWith(trackableUrlPrefix) || href.startsWith(relativeUrlPrefix))) { try { // Extract the ID from the URL - handle both absolute and relative URLs let id; if (href.startsWith(trackableUrlPrefix)) { id = href.substring(trackableUrlPrefix.length); } else { id = href.substring(relativeUrlPrefix.length); } // Sometimes there are additional URL parameters if (id.includes('&')) { id = id.split('&')[0]; } const name = anchor.innerText.trim(); if (id && name && !trackablesMap.has(id)) { trackablesMap.set(id, { id, name }); } } catch (e) { console.error(`Error parsing URL or extracting trackable info from page ${pageNum}:`, e, href); } } }); // Clean up document.body.removeChild(iframe); console.log(`Found ${trackablesMap.size} trackables on page ${pageNum}`); return trackablesMap; } catch (error) { console.error(`Error fetching page ${pageNum}:`, error); return new Map(); // Return empty map on error } } /** * Processes trackables from all pages using an iframe approach * @returns {Promise<Map>} Promise that resolves with a map of all trackables */ async function processAllPagesTrackables() { const allTrackables = new Map(); const paginationInfo = extractPaginationInfo(); // First, get trackables from the current page const currentPageTrackables = extractTrackablesFromPage(); currentPageTrackables.forEach((trackable, id) => { allTrackables.set(id, trackable); }); console.log(`Found ${currentPageTrackables.size} trackables on current page (page ${paginationInfo.currentPage})`); // If only one page, return immediately if (paginationInfo.totalPages <= 1) { console.log(`Only one page of trackables found, no need to fetch more pages`); return allTrackables; } // Find the status message element if it exists const statusElement = document.querySelector('#trackable-gpx-export div[style*="color: #666"]'); if (statusElement) { statusElement.textContent = `Finding trackables across ${paginationInfo.totalPages} pages...`; } // Process each page sequentially - we need to do this because of the ASP.NET viewstate for (let page = 1; page <= paginationInfo.totalPages; page++) { // Skip the current page as we already processed it if (page === paginationInfo.currentPage) { console.log(`Skipping current page ${page} as we already processed it`); continue; } try { if (statusElement) { statusElement.textContent = `Fetching trackables from page ${page} of ${paginationInfo.totalPages}...`; } // Fetch trackables from this page const pageTrackables = await fetchPageTrackablesViaIframe(page); // Add these trackables to our collection pageTrackables.forEach((trackable, id) => { allTrackables.set(id, trackable); }); if (statusElement) { statusElement.textContent = `Found ${allTrackables.size} trackables so far (processed ${page} of ${paginationInfo.totalPages} pages)`; } } catch (error) { console.error(`Error processing page ${page}:`, error); // Continue with next page despite error } } return allTrackables; } /** * Parse trackable stops from the map page HTML content * @param {string} htmlContent - HTML content from the trackable map page * @param {string} trackableId - ID of the trackable for error reporting * @returns {Array} Array of stop objects with coordinates and cache names */ function parseTrackableStops(htmlContent, trackableId) { const stops = []; const tbStopsRegex = /var tbStops\s*=\s*(\[[\s\S]*?\])\s*;/; const match = htmlContent.match(tbStopsRegex); if (!match || !match[1]) { console.warn(`tbStops not found for trackable ${trackableId}`); return stops; } try { const arrayContentString = match[1]; // Use a more robust regex that handles special characters in cache names // Pattern: { coordinates part, name part } const objectPattern = /\{\s*ll\s*:\s*\[\s*([\d\.-]+)\s*,\s*([\d\.-]+)\s*\]\s*,\s*n\s*:\s*"([^"]*)"\s*\}/g; let objectMatch; while ((objectMatch = objectPattern.exec(arrayContentString)) !== null) { if (objectMatch && objectMatch[1] && objectMatch[2] && objectMatch[3]) { try { const lat = parseFloat(objectMatch[1]); const lon = parseFloat(objectMatch[2]); const name = objectMatch[3]; stops.push({ coordinates: [lat, lon], cacheName: name }); } catch (e) { console.error(`Error parsing coordinates for trackable ${trackableId}:`, e); } } else { console.error(`Failed to extract data from object string for trackable ${trackableId}`); } } } catch (e) { console.error(`Error processing tbStops for trackable ${trackableId}:`, e); } return stops; } /** * Fetches and processes trackable stops data * @param {Object} trackable - The trackable object to enrich with stops * @returns {Object} The enriched trackable object */ async function fetchTrackableStops(trackable) { const mapUrl = `https://www.geocaching.com/track/map_gm.aspx?ID=${trackable.id}`; try { const response = await fetch(mapUrl); if (!response.ok) { console.error(`Failed to fetch ${mapUrl}: ${response.status} ${response.statusText}`); trackable.stops = []; return trackable; } const htmlContent = await response.text(); trackable.stops = parseTrackableStops(htmlContent, trackable.id); } catch (error) { console.error(`Error fetching stops for trackable ${trackable.id}:`, error); trackable.stops = []; } return trackable; } /** * Enriches trackables with their stop information * @param {Array} trackables - Array of trackable objects * @returns {Array} Array of enriched trackable objects */ async function enrichTrackablesWithStops(trackables) { if (trackables.length === 0) { console.log("No trackables found to enrich."); return []; } const enrichedTrackables = await Promise.all( trackables.map(trackable => fetchTrackableStops(trackable)) ); console.log("Enriched Trackables (with stops):", enrichedTrackables); return enrichedTrackables; } /** * Generate GPX file content from trackable data * @param {Array} trackables - Array of trackable objects with their stops * @returns {string} GPX file content */ function generateGPX(trackables) { // Filter trackables with stops and extract the last stop (current location) of each trackable const trackablesWithStops = trackables.filter(t => t.stops && t.stops.length > 0); if (trackablesWithStops.length === 0) { return null; } // Start the GPX content with correct metadata tags let gpxContent = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?> <gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd" version="1.1" creator="Geocaching Trackable GPX Exporter"> <metadata> <name>Geocaching Trackables</name> <desc>Current locations of Geocaching trackables</desc> <time>${new Date().toISOString()}</time> </metadata> `; // Add waypoints for each trackable's current location with correct GPX tags trackablesWithStops.forEach(trackable => { const lastStop = trackable.stops[trackable.stops.length - 1]; const [lat, lon] = lastStop.coordinates; gpxContent += ` <wpt lat="${lat}" lon="${lon}"> <name>${escapeXml(trackable.name)}</name> <desc>Trackable: ${escapeXml(trackable.name)} at ${escapeXml(lastStop.cacheName)}</desc> <sym>Geocache</sym> <extensions> <trackable id="${trackable.id}" name="${escapeXml(trackable.name)}" /> <cache name="${escapeXml(lastStop.cacheName)}" /> </extensions> </wpt> `; }); // Close the GPX content gpxContent += `</gpx>`; return gpxContent; } /** * Escape XML special characters * @param {string} text - Text to escape * @returns {string} Escaped text */ function escapeXml(text) { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Download content as a file * @param {string} content - File content * @param {string} fileName - Name of the file * @param {string} contentType - MIME type of the file */ function downloadFile(content, fileName, contentType) { const blob = new Blob([content], { type: contentType }); const url = URL.createObjectURL(blob); const downloadLink = document.createElement('a'); downloadLink.href = url; downloadLink.download = fileName; // Simulate click to trigger download document.body.appendChild(downloadLink); downloadLink.click(); // Clean up document.body.removeChild(downloadLink); URL.revokeObjectURL(url); } // Add a global variable to track if processing is currently in progress let isProcessingTrackables = false; // Add a global variable to track if we're currently generating a GPX file let isLoadingGPX = false; /** * Exports trackables to a GPX file, including pagination handling */ async function exportAllTrackablesToGPX() { // Create a status message container const statusContainer = document.createElement('div'); statusContainer.style.marginTop = '10px'; statusContainer.style.color = '#666'; statusContainer.style.fontSize = '14px'; statusContainer.textContent = 'Finding trackables across pages...'; document.getElementById('trackable-gpx-export').appendChild(statusContainer); try { // First collect all trackables from all pages const allTrackables = await processAllPagesTrackables(); // Update status message statusContainer.textContent = `Found ${allTrackables.size} trackables. Fetching location data...`; // Create a progress counter for enrichment let enrichedCount = 0; // Enrich trackables with stops data const enrichedTrackables = []; // Use Promise.all to process trackables in parallel const trackableArray = Array.from(allTrackables.values()); const batchSize = 10; // Process in batches to avoid overwhelming the server for (let i = 0; i < trackableArray.length; i += batchSize) { const batch = trackableArray.slice(i, i + batchSize); // Process this batch const batchPromises = batch.map(trackable => fetchTrackableStops(trackable) .then(enrichedTrackable => { enrichedCount++; // Update status every few trackables if (enrichedCount % 5 === 0 || enrichedCount === allTrackables.size) { statusContainer.textContent = `Processing trackable locations (${enrichedCount}/${allTrackables.size})...`; } return enrichedTrackable; }) ); // Wait for this batch to complete before starting the next one const batchResults = await Promise.all(batchPromises); enrichedTrackables.push(...batchResults); } // Filter out trackables with no stops const trackablesWithLocations = enrichedTrackables.filter(tb => tb.stops && tb.stops.length > 0); // Update status message statusContainer.textContent = `Generating GPX file for ${trackablesWithLocations.length} trackables...`; // Export the GPX file using the correct function names const gpxContent = generateGPX(trackablesWithLocations); downloadFile(gpxContent, `geocaching-trackables-${new Date().toISOString().slice(0, 10)}.gpx`, 'application/gpx+xml'); // Update final status message statusContainer.textContent = `GPX exported successfully with ${trackablesWithLocations.length} trackables.`; setTimeout(() => { // Fade out the status message after a few seconds statusContainer.style.transition = 'opacity 1s'; statusContainer.style.opacity = '0'; setTimeout(() => statusContainer.remove(), 1000); }, 5000); } catch (error) { console.error('Error exporting trackables to GPX:', error); statusContainer.textContent = `Error: ${error.message || 'Failed to export GPX file'}`; statusContainer.style.color = '#e74c3c'; } } /** * Function to add the export button to the page */ function addExportButton() { // First check if we have any trackables on the page const trackables = extractTrackablesFromPage(); if (trackables.size === 0) { // No trackables found, don't add the button return; } // Check if button already exists if (document.getElementById('trackable-gpx-export')) { return; } const container = document.createElement('div'); container.id = 'trackable-gpx-export'; container.style.margin = '20px 0'; container.style.padding = '0'; const exportButton = document.createElement('div'); exportButton.innerHTML = '<i class="fa fa-download" style="margin-right: 5px;"></i> Export Trackables as GPX'; exportButton.style.display = 'inline-block'; exportButton.style.padding = '8px 16px'; exportButton.style.backgroundColor = '#4CAF50'; exportButton.style.color = 'white'; exportButton.style.borderRadius = '4px'; exportButton.style.cursor = 'pointer'; exportButton.style.fontWeight = 'bold'; exportButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; exportButton.style.userSelect = 'none'; // Add hover effect exportButton.onmouseover = () => { exportButton.style.backgroundColor = '#45a049'; }; exportButton.onmouseout = () => { exportButton.style.backgroundColor = '#4CAF50'; }; // Add click handler exportButton.onclick = (e) => { e.preventDefault(); // Check if the button is disabled if (exportButton.getAttribute('disabled') === 'true') { return; } // Disable the button during processing exportButton.setAttribute('disabled', 'true'); exportButton.style.backgroundColor = '#cccccc'; exportButton.style.cursor = 'not-allowed'; // Start the export process exportAllTrackablesToGPX() .finally(() => { // Re-enable the button when done exportButton.removeAttribute('disabled'); exportButton.style.backgroundColor = '#4CAF50'; exportButton.style.cursor = 'pointer'; }); }; container.appendChild(exportButton); // Find the best container to insert the button const tableContainer = document.querySelector('.Table, table, .table-container'); if (tableContainer && tableContainer.parentNode) { tableContainer.parentNode.insertBefore(container, tableContainer); } else { // Fallback to main content area const contentArea = document.querySelector('#Content, #content, .Content'); if (contentArea) { if (contentArea.firstChild) { contentArea.insertBefore(container, contentArea.firstChild); } else { contentArea.appendChild(container); } } } } // Run initialization when the script loads function initialize() { console.log("Initializing GPX exporter..."); // Check if there are trackables on the page before proceeding const initialTrackables = extractTrackablesFromPage(); if (initialTrackables.size === 0) { console.log("No trackables found on page. Setting up observer to watch for trackables to be loaded."); // Still set up the mutation observer to catch when trackables might be loaded via AJAX setupMutationObserver(); return; } // Add button immediately addExportButton(); // Set up retry mechanism in case the page structure isn't fully loaded let retryCount = 0; const maxRetries = 5; function retryAddButton() { if (document.getElementById('trackable-gpx-export')) { console.log("Export button already exists, no need to retry"); return; } if (retryCount < maxRetries) { retryCount++; console.log(`Retrying to add export button (attempt ${retryCount}/${maxRetries})`); addExportButton(); setTimeout(retryAddButton, 1000); } } // Start retry mechanism setTimeout(retryAddButton, 1000); // Also check for AJAX-loaded content setupMutationObserver(); } // Set up mutation observer to watch for AJAX content changes function setupMutationObserver() { const observer = new MutationObserver((mutations) => { // Don't add the button if we already have it if (document.getElementById('trackable-gpx-export')) { return; } let shouldAddButton = false; // Check if any mutations affect our elements of interest (trackable links) for (const mutation of mutations) { // Skip mutations that don't add nodes if (mutation.type !== 'childList' || mutation.addedNodes.length === 0) { continue; } // Look for relevant data tables or trackable links if (mutation.target.classList.contains('Table') || mutation.target.querySelector('.Table') || mutation.target.querySelector('a[href*="track/details.aspx"]')) { // Double-check that there are actually trackables on the page const trackablesMap = extractTrackablesFromPage(); if (trackablesMap.size > 0) { shouldAddButton = true; break; } } } if (shouldAddButton) { console.log('Content changed, adding export button'); addExportButton(); } }); // Start observing with configuration observer.observe(document.body, { childList: true, subtree: true }); } // Start initialization if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { // DOM already loaded, initialize now initialize(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址