Strava GPX→STL (Embed gpxtruder)

在 Strava 活动/路线页面右下角增加“导出 STL”按钮;自动获取当前页面 GPX,内嵌运行 gpxtruder 完整页面,并把 GPX 作为已选文件传入,保持原有功能不变

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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