您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在 Strava 活动/路线页面右下角增加“导出 STL”按钮;自动获取当前页面 GPX,内嵌运行 gpxtruder 完整页面,并把 GPX 作为已选文件传入,保持原有功能不变
// ==UserScript== // @name Strava GPX→STL (Embed gpxtruder) // @namespace https://github.com/qixiaoyu0315/gpxtruder // @version 1.0.1 // @description 在 Strava 活动/路线页面右下角增加“导出 STL”按钮;自动获取当前页面 GPX,内嵌运行 gpxtruder 完整页面,并把 GPX 作为已选文件传入,保持原有功能不变 // @author qixiaoyu0315 // @match https://www.strava.com/activities/* // @match https://www.strava.com/routes/* // @icon https://www.strava.com/favicon.ico // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ====== 可按需修改的配置 ===== const CDN_BASE = 'https://cdn.jsdelivr.net/gh/qixiaoyu0315/gpxtruder@tag5/'; const INDEX_HTML_URL = CDN_BASE + 'index.html'; const MODAL_WIDTH = 'min(1600px, 95vw)'; const MODAL_HEIGHT = 'min(1020px, 92vh)'; // ====== 样式与按钮 ====== GM_addStyle(` #gpxtruder-fab { position: fixed; right: 20px; bottom: 20px; z-index: 999999; background: #fc4c02; color: #fff; border-radius: 10px; padding: 12px 16px; font-size: 14px; font-weight: 600; box-shadow: 0 6px 20px rgba(0,0,0,0.25); cursor: pointer; border: none; } #gpxtruder-fab:hover { filter: brightness(1.05); } #gpxtruder-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); backdrop-filter: blur(1px); z-index: 999998; display: none; } #gpxtruder-modal { position: fixed; left: 50%; top: 50%; transform: translate(-50%,-50%); width: ${MODAL_WIDTH}; height: ${MODAL_HEIGHT}; z-index: 999999; display: none; border-radius: 16px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.35); background: #111; } #gpxtruder-iframe { width: 100%; height: 100%; border: 0; display: block; background: #111; } #gpxtruder-close { position: absolute; top: 8px; right: 1500px; z-index: 2; background: rgba(0,0,0,0.6); color: #fff; border: 0; border-radius: 10px; padding: 6px 10px; cursor: pointer; font-size: 12px; } `); function ensureUI() { if (document.getElementById('gpxtruder-fab')) return; const fab = document.createElement('button'); fab.id = 'gpxtruder-fab'; fab.textContent = '导出 STL'; fab.title = '从当前 Strava 页面自动抓取 GPX 并生成 STL'; fab.addEventListener('click', onFabClick); document.body.appendChild(fab); const backdrop = document.createElement('div'); backdrop.id = 'gpxtruder-backdrop'; backdrop.addEventListener('click', closeModal); const modal = document.createElement('div'); modal.id = 'gpxtruder-modal'; const closeBtn = document.createElement('button'); closeBtn.id = 'gpxtruder-close'; closeBtn.textContent = '关闭(Esc)'; closeBtn.addEventListener('click', closeModal); const iframe = document.createElement('iframe'); iframe.id = 'gpxtruder-iframe'; iframe.setAttribute('referrerpolicy', 'no-referrer'); modal.appendChild(closeBtn); modal.appendChild(iframe); document.body.appendChild(backdrop); document.body.appendChild(modal); window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); }); } function openModal() { document.getElementById('gpxtruder-backdrop').style.display = 'block'; document.getElementById('gpxtruder-modal').style.display = 'block'; } function closeModal() { document.getElementById('gpxtruder-backdrop').style.display = 'none'; document.getElementById('gpxtruder-modal').style.display = 'none'; } function buildStravaGpxUrl() { const u = new URL(location.href); const parts = u.pathname.split('/').filter(Boolean); if (parts[0] === 'activities' && parts[1]) { return `https://www.strava.com/activities/${parts[1]}/export_gpx`; } if (parts[0] === 'routes' && parts[1]) { return `https://www.strava.com/routes/${parts[1]}/export_gpx`; } return null; } function fetchGpxText(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Accept': 'application/gpx+xml,text/xml,*/*;q=0.1' }, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) resolve(resp.responseText); else reject(new Error(`GPX 下载失败:HTTP ${resp.status}`)); }, onerror: (e) => reject(new Error('GPX 下载失败:网络错误')), }); }); } function fetchIndexHtml() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: INDEX_HTML_URL, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { let html = resp.responseText || ''; // 1) 在 <head> 中插入 <base> html = html.replace(/<head([^>]*)>/i, `<head$1><base href="${CDN_BASE}">`); // 2) 替换所有 "js/..." 路径为 CDN_BASE + "js/..." html = html.replace(/(['"])(js\/[^'"]+)/g, `$1${CDN_BASE}$2`); resolve(html); } else { reject(new Error(`index.html 获取失败:HTTP ${resp.status}`)); } }, onerror: () => reject(new Error('index.html 获取失败:网络错误')), }); }); } async function injectGpxIntoIframe(iframe, gpxText) { const doc = iframe.contentDocument; if (!doc) throw new Error('无法访问 gpxtruder 文档'); const fileInput = await waitFor(() => { return doc.querySelector('input[type="file"]'); }, 15000, 100); if (!fileInput) throw new Error('未找到 gpxtruder 的文件选择控件'); const file = new File([gpxText], 'strava.gpx', { type: 'application/gpx+xml' }); const dt = new DataTransfer(); dt.items.add(file); fileInput.files = dt.files; const ev = new Event('change', { bubbles: true }); fileInput.dispatchEvent(ev); } function waitFor(getter, timeout = 10000, interval = 50) { return new Promise((resolve) => { const start = Date.now(); const t = setInterval(() => { let val; try { val = getter(); } catch (e) {} if (val) { clearInterval(t); resolve(val); } else if (Date.now() - start > timeout) { clearInterval(t); resolve(null); } }, interval); }); } async function onFabClick() { try { ensureUI(); const gpxUrl = buildStravaGpxUrl(); if (!gpxUrl) { alert('当前页面无法确定 GPX 导出地址(仅支持活动页 /activities/{id} 或路线页 /routes/{id})。'); return; } const gpxText = await fetchGpxText(gpxUrl); openModal(); const iframe = document.getElementById('gpxtruder-iframe'); const html = await fetchIndexHtml(); iframe.srcdoc = html; await new Promise((r) => iframe.addEventListener('load', r, { once: true })); await injectGpxIntoIframe(iframe, gpxText); } catch (err) { console.error(err); alert('处理失败:' + (err && err.message ? err.message : err)); } } ensureUI(); })();