// ==UserScript==
// @name Komoot Tour Downloader
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Easily download komoot tours as GPX
// @author Ulysses
// @match https://www.komoot.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=komoot.com
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const downloadGPXToken = "Download GPX";
const BASIC_URL_REGEX =
/https:\/\/www.komoot.([a-z]+)(\/[a-z][a-z]-[a-z][a-z])?/;
const TOUR_URL_REGEX =
/https:\/\/www.komoot.([a-z]+)(\/[a-z][a-z]-[a-z][a-z])?\/(tour|smarttour)\/([a-z0-9]+)/;
const getId = () => {
const match = window.location.href.match(TOUR_URL_REGEX);
if (match === null) {
return undefined;
}
const [, , , , id] = match; // Use the match variable directly
return id;
};
const getName = () => document.title.split("|")[0];
const containsGPX = () => getId() !== undefined;
const getPositionsFromMainPage = async () => {
const response = await fetch(window.location.href, {
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"accept-language": "es-ES,es;q=0.9,en;q=0.8,ca;q=0.7",
"cache-control": "max-age=0",
"sec-ch-ua":
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
},
referrerPolicy: "strict-origin-when-cross-origin",
body: null,
method: "GET",
mode: "cors",
credentials: "include",
});
const html = await response.text();
const regex = /kmtBoot.setProps\("(.*)"\)/m;
const regexResult = regex.exec(html);
const jsonText = regexResult[1].replace(/\\"/gm, '"').replace(/\\"/gm, '"');
const json = JSON.parse(jsonText);
return json.page._embedded.tour._embedded.coordinates.items;
};
const buildGPX = (
id,
name,
positions
) => `<?xml version="1.0" encoding="UTF-8"?>
<gpx creator="Komoot Extension" version="1.1"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/11.xsd"
xmlns:ns3="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:ns2="http://www.garmin.com/xmlschemas/GpxExtensions/v3">
<trk>
<name>${id} - ${name}</name>
<type>cycling</type>
<trkseg>
${positions
.map(
(position) => `<trkpt lat="${position[`lat`]}" lon="${position[`lng`]}">
<ele>${position[`alt`]}</ele>
</trkpt>`
)
.join("")}
</trkseg>
</trk>
</gpx>`;
const configureAsBubble = (container) => {
container.style.background = "white";
container.style.border = "1px solid #00c300";
container.style.borderRadius = "8px";
container.style.padding = "5px 10px 5px 10px";
};
const createButton = (
document,
onDownload,
configureAsBubble,
downloadGPXToken
) => {
const button = document.createElement("button");
button.setAttribute("name", "ke-download-button");
configureAsBubble(button);
button.onclick = onDownload;
const buttonContent = document.createElement("div");
buttonContent.style.display = "flex";
const spinner = document.createElement("div");
spinner.innerHTML = `<svg viewBox="0 0 32 32" width="24" height="24" stroke-width="4" fill="none" stroke="currentcolor" role="img" class="css-yay0ma">
<title>loading</title>
<circle cx="16" cy="16" r="12" opacity="0.125"></circle>
<circle cx="16" cy="16" r="12" stroke-dasharray="75.39822368615503" stroke-dashoffset="56.548667764616276" class="css-wpcq6n"></circle>
</svg>`;
spinner.style.display = "none";
spinner.setAttribute("name", "ke-spinner");
buttonContent.append(spinner);
const image = document.createElement("div");
image.innerHTML = `<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzY3IiBoZWlnaHQ9IjM2NyIgdmlld0JveD0iMCAwIDM2NyAzNjciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjE4My41IiBjeT0iMTgzLjUiIHI9IjE4My41IiBmaWxsPSJ1cmwoI3BhaW50MF9saW5lYXJfMl80KSIvPgo8cGF0aCBkPSJNODMgMTAyLjJDODMgOTEuMDkgOTIuMDkgODIgMTAzLjIgODJIMjY0LjhDMjcwLjE1NyA4MiAyNzUuMjk1IDg0LjEyODIgMjc5LjA4NCA4Ny45MTY0QzI4Mi44NzIgOTEuNzA0NyAyODUgOTYuODQyNiAyODUgMTAyLjJWMjYzLjhDMjg1IDI2OS4xNTcgMjgyLjg3MiAyNzQuMjk1IDI3OS4wODQgMjc4LjA4NEMyNzUuMjk1IDI4MS44NzIgMjcwLjE1NyAyODQgMjY0LjggMjg0SDEwMy4yQzk3Ljg0MjYgMjg0IDkyLjcwNDcgMjgxLjg3MiA4OC45MTY0IDI3OC4wODRDODUuMTI4MiAyNzQuMjk1IDgzIDI2OS4xNTcgODMgMjYzLjhWMTAyLjJaTTIyNC40IDIyMy40SDI2NC44VjEwMi4ySDEwMy4yVjIyMy40SDE0My42QzE0My42IDIzNC41MSAxNTIuNjkgMjQzLjYgMTYzLjggMjQzLjZIMjA0LjJDMjA5LjU1NyAyNDMuNiAyMTQuNjk1IDI0MS40NzIgMjE4LjQ4NCAyMzcuNjg0QzIyMi4yNzIgMjMzLjg5NSAyMjQuNCAyMjguNzU3IDIyNC40IDIyMy40Wk0xNzMuOSAxNjIuOFYxMzIuNUgxOTQuMVYxNjIuOEgyMjQuNEwxODQgMjAzLjJMMTQzLjYgMTYyLjhIMTczLjlaIiBmaWxsPSJ3aGl0ZSIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzJfNCIgeDE9IjE4My41IiB5MT0iMCIgeDI9IjE4My41IiB5Mj0iMzY3IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CjxzdG9wIHN0b3AtY29sb3I9IiM4RkNFM0MiLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNjRBMzIyIi8+CjwvbGluZWFyR3JhZGllbnQ+CjwvZGVmcz4KPC9zdmc+Cg=="/>`;
image.style.width = "20px";
image.setAttribute("name", "ke-logo");
buttonContent.append(image);
const text = document.createElement("span");
text.style.marginLeft = "8px";
text.innerText = downloadGPXToken;
buttonContent.append(text);
button.append(buttonContent);
return button;
};
const onDownload = async () => {
const id = getId();
const name = getName();
document.getElementsByName("ke-download-button").forEach((element) => {
element.disabled = true;
});
document.getElementsByName("ke-logo").forEach((element) => {
element.style.display = "none";
});
document.getElementsByName("ke-spinner").forEach((element) => {
element.style.display = "";
});
const positions = await getPositionsFromMainPage(id);
const gpx = buildGPX(id, name, positions);
const element = document.createElement("a");
element.setAttribute(
"href",
"data:application/gpx+xml;charset=utf-8," + encodeURIComponent(gpx)
);
element.setAttribute("download", id + ".gpx");
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
document.getElementsByName("ke-download-button").forEach((element) => {
element.disabled = false;
});
document.getElementsByName("ke-logo").forEach((element) => {
element.style.display = "";
});
document.getElementsByName("ke-spinner").forEach((element) => {
element.style.display = "none";
});
};
const run = () => {
var observer = new MutationObserver(function (mutations) {
const shouldAddButton = containsGPX();
const downloadButtons = document.getElementsByName("ke-download-button");
const isButtonAlreadyAdded = downloadButtons.length > 0;
if (shouldAddButton && isButtonAlreadyAdded) return;
if (!shouldAddButton && isButtonAlreadyAdded) {
// Remove button
downloadButtons.forEach((button) => button.remove());
} else if (shouldAddButton && !isButtonAlreadyAdded) {
// Add button
const body = document.querySelector("body");
const target = body;
const container = document.createElement("div");
container.style.position = "absolute";
container.style.top = "60px";
container.style.right = "20px";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.alignItems = "end";
const button = createButton(document, onDownload, configureAsBubble, downloadGPXToken);
container.appendChild(button);
target.appendChild(container);
}
});
observer.observe(document, {
childList: true,
subtree: true, // needed if the node you're targeting is not the direct parent
});
};
run();
})();