您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Downloads all lakes and trails near overabundant lakes, with resume and KML export.
// ==UserScript== // @name WDFW Overabundant Lakes & Trails KML Exporter // @namespace http://tampermonkey.net/ // @version 2025-05-09 // @description Downloads all lakes and trails near overabundant lakes, with resume and KML export. // @match https://wdfw.wa.gov/fishing/locations/high-lakes/overabundant?name=&species= // @icon https://www.google.com/s2/favicons?sz=64&domain=wa.gov // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @require https://cdnjs.cloudflare.com/ajax/libs/mapbox-polyline/1.1.1/polyline.min.js // @connect alltrails.com // @connect wdfw.wa.gov // @license GNU GPLv3 // ==/UserScript== (function () { 'use strict'; let TRAIL_RADIUS_MILES = GM_getValue('trailRadius', 5); let style = document.createElement('style'); style.type = 'text/css'; style.innerHTML = ` #gmky_panel { position: fixed; top: 10px; right: 10px; z-index: 9999; background: #fff; padding: 10px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); width:12em; height: 12.5em; display: flex; flex-direction: column; } #gmky_panel > div { display: inline-block; vertical-align: bottom; height: 1.5em; margin-top: .5em; } #gmky_panel label { font-weight:bolder; display: inline-block; } #gmky_panel > div > label { width:6em; } #gmky_panel select { font-size:10pt; display: inline-block; width:2.25em; height:auto; text-align-last:center; padding: 0; } #status-bar { margin-top:0em; font-size = '14px'; color = '#333'; } #status-bar label{ width: 3em; } #status-bar div span{ display: inline-block; text-align: right; } #status-bar div b{ display: inline-block; width: 1em; text-align:center; } #status-bar div a{ font-size: 8pt; text-decoration:none; } #status-bar div a:hover{ text-decoration: underline; } .gmky_button { padding: 10px 15px; background: #4CAF50; color: white; border: none; font-size: 16px; cursor: pointer; margin-bottom:.75em; } `; document.head.appendChild(style); // --- UI Setup --- function addButtons() { const panel = document.createElement('div'); panel.id = 'gmky_panel'; // Lake Button const lakeBtn = document.createElement('button'); lakeBtn.textContent = (isLakesLoaded()) ? 'Lake KML' : 'Load Lakes'; lakeBtn.id = 'gmky_lake_button'; lakeBtn.classList.add("gmky_button"); lakeBtn.onclick = handleLakeButton; panel.appendChild(lakeBtn); const trailRadiusDiv = document.createElement('div'); trailRadiusDiv.innerHTML = ` <span> <label for="trail-radius-select">Trail Radius:</label> <span> <select id="trail-radius-select" class="status-dropdown"> <option value=".5">1/2</option> <option value=".75">3/4</option> <option value="1">1</option> <option value="2">2</option> <option value="3">3</option> <option value="4">4</option> <option value="5">5</option> </select> mile(s) </span> </span> `; panel.appendChild(trailRadiusDiv); const select = trailRadiusDiv.querySelector('#trail-radius-select'); select.onchange = (e) =>{ e.preventDefault(); GM_deleteValue('trails'); GM_deleteValue('trailState'); GM_deleteValue('trailMaps'); let trbtn = document.getElementById('gmky_trail_button'); if (trbtn) trbtn.textContent = 'Load Trails'; GM_setValue('trailRadius', e.target.value); TRAIL_RADIUS_MILES = e.target.value; updateStatusBar(); }; // Trail Button const trailBtn = document.createElement('button'); trailBtn.textContent = (isTrailLoaded()) ? 'Trail KML' : 'Load Trails'; trailBtn.id = 'gmky_trail_button'; trailBtn.classList.add("gmky_button"); trailBtn.onclick = handleTrailButton; panel.appendChild(trailBtn); document.body.appendChild(panel); // Status bar const statusDiv = document.createElement('div'); statusDiv.id = 'status-bar'; const lakeDiv = makeStatusDiv('lakes'); const trailsDiv = makeStatusDiv('trails'); statusDiv.appendChild(lakeDiv); statusDiv.appendChild(trailsDiv); panel.appendChild(statusDiv); updateStatusBar(); } function makeStatusDiv(name) { const labelName = name.charAt(0).toUpperCase() + name.slice(1); const div = document.createElement('div'); div.innerHTML = ` <label>${labelName}:</label> <span id="${name}-loaded">0</span> <b>/</b> <span id="${name}-total">0</span> <span> <a href="#" class="status-clear">(clear)</a> </span> `; // Attach the onclick handler to the (clear) link const clearLink = div.querySelector('.status-clear'); clearLink.addEventListener('click', function(e) { e.preventDefault(); // Prevents page jump switch (name) { case 'lakes':{ GM_deleteValue('lakes'); GM_deleteValue('lakeState'); let lkbtn = document.getElementById('gmky_lake_button'); if (lkbtn) lkbtn.textContent = 'Load Lakes'; } case 'trails':{ GM_deleteValue('trails'); GM_deleteValue('trailState'); GM_deleteValue('trailMaps'); let trbtn = document.getElementById('gmky_trail_button'); if (trbtn) trbtn.textContent = 'Load Trails'; break; } } updateStatusBar(); }); return div; } // --- Status Bar --- function updateStatusBar() { const trailMaps = GM_getValue('trailMaps', []); const lakeState = GM_getValue('lakeState', { loaded: 0, total: 0, page: 0 }); const trailState = GM_getValue('trailState', { loaded: 0, total: 0, lakeIndex: 0 }); const trailRadius = GM_getValue('trailRadius', 5); document.getElementById('lakes-loaded').textContent = lakeState.loaded; document.getElementById('lakes-total').textContent = lakeState.total; document.getElementById('trails-loaded').textContent = trailMaps.length; document.getElementById('trails-total').textContent = trailState.total; document.getElementById('trail-radius-select').value = trailRadius; } function isLakesLoaded(){ let lakes = GM_getValue('lakes', []); let { loaded, total, page } = GM_getValue('lakeState', { loaded: 0, total: 0, page: 0 }); return (total === lakes.length && total !== 0) } // --- Lake Handling --- async function handleLakeButton(e) { const btn = e.target; if (btn.textContent === 'Lake KML') { const lakes = GM_getValue('lakes', []); downloadLakeKML(lakes); return; } btn.disabled = true; btn.textContent = 'Loading Lakes...'; let lakes = GM_getValue('lakes', []); let { loaded, total, page } = GM_getValue('lakeState', { loaded: 0, total: 0, page: 0 }); try { const footer = document.querySelector('div.view-footer'); total = (!footer) ? 0 : parseInt(footer.textContent.match(/of\s+(\d+)/i)[1]); GM_setValue('lakeState', { loaded, total, page }); updateStatusBar(); } catch(e){} const lastPageLink = document.querySelector('.pager__item--last a'); const pages = lastPageLink ? parseInt(lastPageLink.href.match(/page=(\d+)/)[1]) + 1 : 1; for (let p = page; p < pages; p++) { const pageLakes = await fetchLakePage(p); let { loaded, total, page } = GM_getValue('lakeState', { loaded: 0, total: 0, page: 0 }); GM_setValue('lakeState', { loaded, total, page: p + 1 }); } btn.textContent = 'Lake KML'; btn.disabled = false; } function fetchLakePage(page) { return new Promise((resolve, reject) => { const url = `https://wdfw.wa.gov/fishing/locations/high-lakes/overabundant?name=&species=&page=${page}`; GM_xmlhttpRequest({ method: 'GET', url, onload: async (response) => { let lakes = GM_getValue('lakes', []); let { loaded, total, page } = GM_getValue('lakeState', { loaded: 0, total: 0, page: 0 }); const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); const rows = doc.querySelectorAll('table tbody tr'); for (const row of rows) { const cells = row.querySelectorAll('td'); const name = cells[0].textContent.trim(); const href = cells[0].querySelector('a')?.href || ''; const acres = cells[1].textContent.trim(); const elevation = cells[2].textContent.trim(); const county = cells[3].textContent.trim(); const lat = cells[4].querySelectorAll('span')[0]?.textContent.trim() || ''; const long = cells[4].querySelectorAll('span')[1]?.textContent.trim() || ''; const species = await fetchFishSpecies(href); lakes.push({ name, href, acres, elevation, county, lat, long, species }); loaded++; GM_setValue('lakes', lakes); GM_setValue('lakeState', { loaded, total, page }); updateStatusBar(); } resolve(lakes); }, onerror: reject }); }); } function fetchFishSpecies(lake_url) { return new Promise((resolve, reject) => { if (!lake_url) return resolve(''); GM_xmlhttpRequest({ method: 'GET', url: lake_url, onload: function(response) { try { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); const speciesItems = doc.querySelectorAll('li.field-item'); const speciesList = Array.from(speciesItems).map(item => item.innerText.trim()); resolve(speciesList.join(', ')); } catch (error) { resolve(''); } }, onerror: function() { resolve(''); } }); }); } function downloadLakeKML(lakes) { let kml = `<?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2">\n<Document>\n`; lakes.forEach(lake => { kml += ` <Placemark> <name>${encodeXML(lake.name)}</name> <description><![CDATA[ County: ${encodeXML(lake.county)}<br/> Elevation: ${encodeXML(lake.elevation)}<br/> Acres: ${encodeXML(lake.acres)}<br/> Species: ${encodeXML(lake.species)} ]]></description> <Point> <coordinates>${lake.long},${lake.lat},0</coordinates> </Point> </Placemark> `; }); kml += `</Document>\n</kml>`; saveKML(kml, 'lakes.kml'); } function isTrailLoaded(){ let trails = GM_getValue('trails', []); let { loaded, total, lakeIndex } = GM_getValue('trailState', { loaded: 0, total: 0, lakeIndex: 0 }); const trailMaps = GM_getValue('trailMaps', []); const lakes = GM_getValue('lakes', []); return (lakes.length === lakeIndex && trailMaps.length === total && total !== 0) } async function handleTrailButton(e) { const btn = e.target; if (btn.textContent === 'Trail KML') { const trailMaps = GM_getValue('trailMaps', []); ["Hard", "Moderate", "Easy"].forEach((difficulty)=>{downloadTrailKML(trailMaps.filter((t)=>{return t.details.difficulty===difficulty}),difficulty)}); return; } btn.disabled = true; btn.textContent = 'Loading Trails...'; let trails = GM_getValue('trails', []); let { loaded, total, lakeIndex } = GM_getValue('trailState', { loaded: 0, total: 0, lakeIndex: 0 }); const lakes = GM_getValue('lakes', []); if (lakes.length === 0) { alert('Load lakes first!'); btn.disabled = false; btn.textContent = 'Load Trails'; return; } // Always start with a fresh Set for unique slugs let uniqueSlugs = new Set(trails.map(t => t.slug)); // Set initial total for the status bar GM_setValue('trailState', { loaded, total: uniqueSlugs.size, lakeIndex }); updateStatusBar(); for (let i = lakeIndex; i < lakes.length; i++) { const lake = lakes[i]; const location = getBoundingBox(parseFloat(lake.lat), parseFloat(lake.long), TRAIL_RADIUS_MILES); const foundTrails = await fetchSearchResults(location); // Track unique slugs in real-time let newThisLake = 0; for (const t of foundTrails) { if (!uniqueSlugs.has(t.slug)) { uniqueSlugs.add(t.slug); trails.push({ ...t, lakeName: lake.name }); newThisLake++; } } total = uniqueSlugs.size; // Save progress and update status bar after each lake GM_setValue('trails', trails); GM_setValue('trailState', { loaded, total, lakeIndex: i + 1 }); updateStatusBar(); } await fetchTrailData(trails); btn.textContent = 'Trail KML'; btn.disabled = false; GM_setValue('trailState', { loaded, total, lakeIndex: lakes.length }); updateStatusBar(); } function getBoundingBox(lat, lng, miles) { const milesPerLatDegree = 69.0; const milesPerLngDegree = 69.0 * Math.cos(lat * Math.PI / 180); const halfSideMiles = miles / 2; return { topLeft: { lat: lat + halfSideMiles / milesPerLatDegree, lng: lng - halfSideMiles / milesPerLngDegree }, bottomRight: { lat: lat - halfSideMiles / milesPerLatDegree, lng: lng + halfSideMiles / milesPerLngDegree } }; } function fetchSearchResults(location) { return new Promise((resolve, reject) => { const body = { filters: {}, location: { mapRotation: 0, ...location }, recordTypesToReturn: ["trail"], sort: "best_match", recordAttributesToRetrieve: ["ID", "name", "slug"], resultsToInclude: ["searchResults"] }; GM_xmlhttpRequest({ method: "POST", url: "https://www.alltrails.com/api/alltrails/explore/v1/search?", data: JSON.stringify(body), headers: { "Content-Type": "application/json", "Referer": "https://www.alltrails.com/" }, onload: function (response) { try { if(response.responseText.includes('captcha')){ alert('Alltrails thinks you are a bot. Navigate to their page and do a captcha'); reject('Alltrails thinks you are a bot. Navigate to their page and do a captcha'); } else { const json = JSON.parse(response.responseText); resolve(json.searchResults || []); } } catch (e) { reject(e); } }, onerror: reject }); }); } async function fetchTrailData(trails) { for (const trail of trails) { try { const trailMaps = GM_getValue('trailMaps', []); if(!trailMaps.some(obj => obj.slug === trail.slug)){ let {loaded, total, lakeIndex} = GM_getValue('trailState', {}); const [map, html] = await Promise.all([ fetchTrailMapData(trail.slug), fetchTrailDetails(trail.slug) ]); const details = extractTrailDetails(html); trailMaps.push({ slug: trail.slug, map: map.data, details }); GM_setValue('trailMaps', trailMaps); GM_setValue('trailState', { loaded: loaded+1, total, lakeIndex }); updateStatusBar(); } } catch (e) {} } } function fetchTrailDetails(slug) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://www.alltrails.com/${slug}`, onload: res => resolve(res.responseText), onerror: reject }); }); } function fetchTrailMapData(slug) { return new Promise((resolve, reject) => { const mapSlug = slug.replace('trail/', 'explore/trail/'); GM_xmlhttpRequest({ method: 'GET', url: `https://www.alltrails.com/${mapSlug}?mobileMap=false&initFlyover=true&flyoverReturnToTrail`, onload: response => { const match = response.responseText.match(/<div data-react-class="SearchApp" data-react-props="({.+?})"/); if (match && match[1]) { try { const props = JSON.parse(match[1].replace(/"/g, '"')); resolve({ slug, data: props.initialExploreMap }); } catch (e) { reject(e); } } else { reject(new Error("Map data not found")); } }, onerror: reject }); }); } function extractTrailDetails(trailHTML) { const parser = new DOMParser(); const doc = parser.parseFromString(trailHTML, 'text/html'); const details = { length: '', elevation: '', time: '', type: '', difficulty: '' }; const statBlocks = doc.querySelectorAll('.TrailStats_stat__O2GvM'); statBlocks.forEach(stat => { const labelEl = stat.querySelector('.TrailStats_statLabel__vKMLy'); const valueEl = stat.querySelector('.TrailStats_statValueSm__HlKIU'); if (!labelEl) return; const label = labelEl.textContent.trim().toLowerCase(); if (label.includes('length')) details.length = valueEl?.textContent.trim() || ''; else if (label.includes('elevation')) details.elevation = valueEl?.textContent.trim() || ''; else if (label.includes('estimated time')) details.time = valueEl?.textContent.trim() || ''; else if (label.match(/loop|out.*back|point.*point/i)) details.type = labelEl.textContent.trim(); }); if (!details.type) { statBlocks.forEach(stat => { const labelEl = stat.querySelector('.TrailStats_statLabel__vKMLy'); if (labelEl && ['loop', 'out & back', 'point to point'].includes(labelEl.textContent.trim().toLowerCase())) { details.type = labelEl.textContent.trim(); } }); } const difficultyEl = doc.querySelector('[data-testid="trail-difficulty"]'); if (difficultyEl) details.difficulty = difficultyEl.textContent.trim(); return details; } function downloadTrailKML(trailMaps, difficulty) { const MAX_SIZE = 5 * 1024 * 1024; // 5 MB in bytes const FILE_PREFIX = `overabundant_lake_trails-${TRAIL_RADIUS_MILES}_miles-${difficulty}_`; const FILE_SUFFIX = '.kml'; const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; let fileIndex = 0; let placemarks = []; let kmlHeader = `<?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2">\n<Document>\n`; let kmlFooter = `</Document>\n</kml>`; let currentSize = kmlHeader.length + kmlFooter.length; function saveKMLFile(placemarksArr, index) { let kmlContent = kmlHeader + placemarksArr.join('') + kmlFooter; let filename = `${FILE_PREFIX}${ALPHABET[index] || index}${FILE_SUFFIX}`; saveKML(kmlContent, filename); } for (const { slug, map, details } of trailMaps) { const name = map.name; const waypoints = map.waypoints || []; const routes = map.routes || []; const description = ` Difficulty: ${details.difficulty || 'N/A'}<br/> Length: ${details.length || 'N/A'}<br/> Elevation Gain: ${details.elevation || 'N/A'}<br/> Estimated Time: ${details.time || 'N/A'}<br/> Type: ${details.type || 'N/A'}<br/><br/> https://alltrails.com/${slug} `.trim(); const color = (details.difficulty === 'Hard') ? '#ff0000ff' : ((details.difficulty === 'Moderate') ? '#ff00a5ff': '#ff00ff00') // Add routes as placemarks for (const route of routes) { for (const segment of route.lineSegments) { const points = segment.polyline.pointsData; if (typeof points === 'string') { const decoded = polyline.decode(points); const coords = decoded.map(p => `${p[1]},${p[0]},0`).join(' '); const placemark = ` <Placemark> <name>${encodeXML(name)}</name> <Style> <IconStyle> <scale>1</scale> <Icon> <href>https://maps.google.com/mapfiles/kml/paddle/grn-blank.png</href> </Icon> </IconStyle> <LineStyle> <color>${color}</color> </LineStyle> </Style> <description><![CDATA[${description}]]></description> <LineString><coordinates>${coords}</coordinates></LineString> </Placemark> `; if (currentSize + placemark.length > MAX_SIZE) { saveKMLFile(placemarks, fileIndex); fileIndex++; placemarks = []; currentSize = kmlHeader.length + kmlFooter.length; } placemarks.push(placemark); currentSize += placemark.length; } } } // Add waypoints as placemarks for (const waypoint of waypoints) { const placemark = ` <Placemark> <name>${encodeXML(waypoint.name)}</name> <Style> <IconStyle> <scale>1</scale> <Icon> <href>https://maps.google.com/mapfiles/kml/paddle/ylw-blank.png</href> </Icon> </IconStyle> </Style> <Point> <coordinates>${waypoint.location.longitude},${waypoint.location.latitude},0</coordinates> </Point> <description><![CDATA[${waypoint.description || ''}]]></description> </Placemark> `; if (currentSize + placemark.length > MAX_SIZE) { saveKMLFile(placemarks, fileIndex); fileIndex++; placemarks = []; currentSize = kmlHeader.length + kmlFooter.length; } placemarks.push(placemark); currentSize += placemark.length; } } // Save any remaining placemarks if (placemarks.length > 0) { saveKMLFile(placemarks, fileIndex); } } function saveKML(kml, filename) { const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function encodeXML(unsafe) { return (unsafe && unsafe.replace) ? unsafe .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'") : ''; } // --- Init --- function waitForTableAndCreateButtons() { const table = document.querySelector('table tbody'); if (!table) { setTimeout(waitForTableAndCreateButtons, 500); return; } addButtons(); } waitForTableAndCreateButtons(); })();