Strava GPX→STL (Embed gpxtruder)

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