Skip to content

Instantly share code, notes, and snippets.

@scorphus
Last active June 15, 2026 12:39
Show Gist options
  • Select an option

  • Save scorphus/7103f51247fa7382a0a42c1ac7d6ee66 to your computer and use it in GitHub Desktop.

Select an option

Save scorphus/7103f51247fa7382a0a42c1ac7d6ee66 to your computer and use it in GitHub Desktop.
stravatize — send komoot tours straight to the other orange app (bookmarklet)

stravatize

Send a komoot tour straight to the other orange app — one tap, no subscription on either side. komootloose's mischievous sibling.

What it does

Click the bookmarklet while on the other app's website (logged in), paste a komoot tour link or ID — public and link-shared tours both work — and it rebuilds the tour as a native route in your account, then takes you straight to it. Everything runs in your browser with your own session; nothing is sent anywhere else.

Install

The bookmarklet is the one-line javascript: URL in stravatize.txt — copy the whole line, then:

  • Desktop (any browser): add a new bookmark, name it stravatize, and paste the line as its URL.
  • iPhone / iPad (Safari): bookmark any page, then Edit the bookmark and replace its URL with the pasted line.
  • Android (Chrome / Firefox): same as desktop; to run it, open the other app's site, type stravatize in the address bar and tap the bookmark suggestion.

Use

  1. Open the other orange app's website and make sure you're logged in.
  2. Tap the bookmark.
  3. Paste a komoot tour link (or just the ID) into the prompt.
  4. You land on the freshly created route.

Notes

  • Unofficial. It talks to the site's internal route API, which can change without notice — if it breaks, check back here.
  • Routes are currently created with Everyone visibility and sport type Ride.
  • The readable source is in stravatize.js — audit it before you paste, as you should with any bookmarklet.

Prefer a plain GPX file instead? That's what komootloose is for.

(async () => {
try {
const input = prompt('komoot tour link or ID');
if (!input) return;
const t = input.trim();
let id, share = '';
if (/^\d+$/.test(t)) {
id = t;
} else {
const m = t.match(/komoot\.[^/]+\/(?:[a-z]{2}-[a-z]{2}\/)?tour\/(\d+)/);
if (!m) return alert('Cannot parse that — paste a komoot tour link or a plain ID.');
id = m[1];
const st = (t.match(/[?&]share_token=([^&#]+)/) || [])[1];
if (st) share = '?share_token=' + st;
}
const api = 'https://api.komoot.de/v007/tours/';
const get = u => fetch(u).then(r => r.ok ? r.json() : Promise.reject('komoot says ' + r.status));
const [tour, coords] = await Promise.all([get(api + id + share), get(api + id + '/coordinates' + share)]);
const pts = coords.items;
if (!pts || pts.length < 2) return alert('No coordinates in this tour.');
const enc = vals => vals.map(v => {
v = v < 0 ? ~(v << 1) : v << 1;
let s = '';
while (v >= 32) { s += String.fromCharCode((32 | (v & 31)) + 63); v >>= 5; }
return s + String.fromCharCode(v + 63);
}).join('');
const poly = seg => {
let la = 0, ln = 0, out = '';
for (const p of seg) {
const a = Math.round(p.lat * 1e5), b = Math.round(p.lng * 1e5);
out += enc([a - la, b - ln]);
la = a; ln = b;
}
return out;
};
const hav = (p, q) => {
const r = Math.PI / 180;
const h = Math.sin((q.lat - p.lat) * r / 2) ** 2 +
Math.cos(p.lat * r) * Math.cos(q.lat * r) * Math.sin((q.lng - p.lng) * r / 2) ** 2;
return 2 * 6371008.8 * Math.asin(Math.sqrt(h));
};
const blob = seg => {
const cum = [0];
for (let i = 1; i < seg.length; i++) cum.push(cum[i - 1] + hav(seg[i - 1], seg[i]));
const total = cum[cum.length - 1], N = 50, ints = [];
let pd = 0, pe = 0, j = 0;
for (let k = 0; k <= N; k++) {
const d = total * k / N;
while (j < cum.length - 2 && cum[j + 1] < d) j++;
const span = cum[j + 1] - cum[j], f = span ? (d - cum[j]) / span : 0;
const alt = seg[j].alt + f * (seg[j + 1].alt - seg[j].alt);
const di = Math.round(d * 10), ei = Math.round(alt * 100);
ints.push(di - pd, ei - pe);
pd = di; pe = ei;
}
return { encoding: 'DrewsBadIdea', data: enc(ints) };
};
const mkleg = (seg, idx) => {
let len = 0, gain = 0, loss = 0;
for (let i = 1; i < seg.length; i++) {
len += hav(seg[i - 1], seg[i]);
const d = seg[i].alt - seg[i - 1].alt;
if (d > 0) gain += d; else loss -= d;
}
return {
legType: 'Fixed',
paths: [{
length: len, elevationGain: gain, elevationLoss: loss, gradeAdjustedLength: len,
pathType: 'Crow',
origin: { lat: seg[0].lat, lng: seg[0].lng },
target: { lat: seg[seg.length - 1].lat, lng: seg[seg.length - 1].lng },
elevation: blob(seg),
polyline: { encoding: 'Google', data: poly(seg) },
surfaceTypeOffsets: null, directions: null,
}],
startElement: idx,
};
};
const n = pts.length, cuts = [0, Math.floor(n / 3), Math.floor(2 * n / 3), n - 1];
const elements = cuts.map(i => ({
elementType: 'Waypoint',
waypoint: { point: { lat: pts[i].lat, lng: pts[i].lng }, snapUncertainty: 215.23788452148438 },
}));
const legs = cuts.slice(0, -1).map((c, i) => mkleg(pts.slice(c, cuts[i + 1] + 1), i));
const html = await (await fetch('/dashboard', { credentials: 'same-origin' })).text();
const token = (html.match(/name="csrf(?:-token)?" content="([^"]+)"/) || [])[1];
const aid = (html.match(/\/athletes\/(\d+)/) || [])[1];
if (!token || !aid) return alert('Could not find your session — are you logged in?');
const payload = { props: {
name: tour.name || 'komootloose route',
description: '',
visibility: 'Everyone',
starred: false,
elements, legs,
routePrefs: { routeType: 'Ride', surfaceType: 'Unknown', popularity: 0.5, elevation: 0, straightLine: true },
athleteId: Number(aid),
} };
const r = await fetch('/api/next/data/routes/create-route', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': token, 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(payload),
});
const body = await r.text();
if (!r.ok) return alert('Create failed (' + r.status + '): ' + body.slice(0, 200));
location.assign('/routes/' + JSON.parse(body).createRoute);
} catch (e) {
alert('komootloose: ' + e);
}
})()
javascript:(async%20()%20%3D%3E%20%7B%20try%20%7B%20const%20input%20%3D%20prompt('komoot%20tour%20link%20or%20ID')%3B%20if%20(!input)%20return%3B%20const%20t%20%3D%20input.trim()%3B%20let%20id%2C%20share%20%3D%20''%3B%20if%20(%2F%5E%5Cd%2B%24%2F.test(t))%20%7B%20id%20%3D%20t%3B%20%7D%20else%20%7B%20const%20m%20%3D%20t.match(%2Fkomoot%5C.%5B%5E%2F%5D%2B%5C%2F(%3F%3A%5Ba-z%5D%7B2%7D-%5Ba-z%5D%7B2%7D%5C%2F)%3Ftour%5C%2F(%5Cd%2B)%2F)%3B%20if%20(!m)%20return%20alert('Cannot%20parse%20that%20%E2%80%94%20paste%20a%20komoot%20tour%20link%20or%20a%20plain%20ID.')%3B%20id%20%3D%20m%5B1%5D%3B%20const%20st%20%3D%20(t.match(%2F%5B%3F%26%5Dshare_token%3D(%5B%5E%26%23%5D%2B)%2F)%20%7C%7C%20%5B%5D)%5B1%5D%3B%20if%20(st)%20share%20%3D%20'%3Fshare_token%3D'%20%2B%20st%3B%20%7D%20const%20api%20%3D%20'https%3A%2F%2Fapi.komoot.de%2Fv007%2Ftours%2F'%3B%20const%20get%20%3D%20u%20%3D%3E%20fetch(u).then(r%20%3D%3E%20r.ok%20%3F%20r.json()%20%3A%20Promise.reject('komoot%20says%20'%20%2B%20r.status))%3B%20const%20%5Btour%2C%20coords%5D%20%3D%20await%20Promise.all(%5Bget(api%20%2B%20id%20%2B%20share)%2C%20get(api%20%2B%20id%20%2B%20'%2Fcoordinates'%20%2B%20share)%5D)%3B%20const%20pts%20%3D%20coords.items%3B%20if%20(!pts%20%7C%7C%20pts.length%20%3C%202)%20return%20alert('No%20coordinates%20in%20this%20tour.')%3B%20const%20enc%20%3D%20vals%20%3D%3E%20vals.map(v%20%3D%3E%20%7B%20v%20%3D%20v%20%3C%200%20%3F%20~(v%20%3C%3C%201)%20%3A%20v%20%3C%3C%201%3B%20let%20s%20%3D%20''%3B%20while%20(v%20%3E%3D%2032)%20%7B%20s%20%2B%3D%20String.fromCharCode((32%20%7C%20(v%20%26%2031))%20%2B%2063)%3B%20v%20%3E%3E%3D%205%3B%20%7D%20return%20s%20%2B%20String.fromCharCode(v%20%2B%2063)%3B%20%7D).join('')%3B%20const%20poly%20%3D%20seg%20%3D%3E%20%7B%20let%20la%20%3D%200%2C%20ln%20%3D%200%2C%20out%20%3D%20''%3B%20for%20(const%20p%20of%20seg)%20%7B%20const%20a%20%3D%20Math.round(p.lat%20*%201e5)%2C%20b%20%3D%20Math.round(p.lng%20*%201e5)%3B%20out%20%2B%3D%20enc(%5Ba%20-%20la%2C%20b%20-%20ln%5D)%3B%20la%20%3D%20a%3B%20ln%20%3D%20b%3B%20%7D%20return%20out%3B%20%7D%3B%20const%20hav%20%3D%20(p%2C%20q)%20%3D%3E%20%7B%20const%20r%20%3D%20Math.PI%20%2F%20180%3B%20const%20h%20%3D%20Math.sin((q.lat%20-%20p.lat)%20*%20r%20%2F%202)%20**%202%20%2B%20Math.cos(p.lat%20*%20r)%20*%20Math.cos(q.lat%20*%20r)%20*%20Math.sin((q.lng%20-%20p.lng)%20*%20r%20%2F%202)%20**%202%3B%20return%202%20*%206371008.8%20*%20Math.asin(Math.sqrt(h))%3B%20%7D%3B%20const%20blob%20%3D%20seg%20%3D%3E%20%7B%20const%20cum%20%3D%20%5B0%5D%3B%20for%20(let%20i%20%3D%201%3B%20i%20%3C%20seg.length%3B%20i%2B%2B)%20cum.push(cum%5Bi%20-%201%5D%20%2B%20hav(seg%5Bi%20-%201%5D%2C%20seg%5Bi%5D))%3B%20const%20total%20%3D%20cum%5Bcum.length%20-%201%5D%2C%20N%20%3D%2050%2C%20ints%20%3D%20%5B%5D%3B%20let%20pd%20%3D%200%2C%20pe%20%3D%200%2C%20j%20%3D%200%3B%20for%20(let%20k%20%3D%200%3B%20k%20%3C%3D%20N%3B%20k%2B%2B)%20%7B%20const%20d%20%3D%20total%20*%20k%20%2F%20N%3B%20while%20(j%20%3C%20cum.length%20-%202%20%26%26%20cum%5Bj%20%2B%201%5D%20%3C%20d)%20j%2B%2B%3B%20const%20span%20%3D%20cum%5Bj%20%2B%201%5D%20-%20cum%5Bj%5D%2C%20f%20%3D%20span%20%3F%20(d%20-%20cum%5Bj%5D)%20%2F%20span%20%3A%200%3B%20const%20alt%20%3D%20seg%5Bj%5D.alt%20%2B%20f%20*%20(seg%5Bj%20%2B%201%5D.alt%20-%20seg%5Bj%5D.alt)%3B%20const%20di%20%3D%20Math.round(d%20*%2010)%2C%20ei%20%3D%20Math.round(alt%20*%20100)%3B%20ints.push(di%20-%20pd%2C%20ei%20-%20pe)%3B%20pd%20%3D%20di%3B%20pe%20%3D%20ei%3B%20%7D%20return%20%7B%20encoding%3A%20'DrewsBadIdea'%2C%20data%3A%20enc(ints)%20%7D%3B%20%7D%3B%20const%20mkleg%20%3D%20(seg%2C%20idx)%20%3D%3E%20%7B%20let%20len%20%3D%200%2C%20gain%20%3D%200%2C%20loss%20%3D%200%3B%20for%20(let%20i%20%3D%201%3B%20i%20%3C%20seg.length%3B%20i%2B%2B)%20%7B%20len%20%2B%3D%20hav(seg%5Bi%20-%201%5D%2C%20seg%5Bi%5D)%3B%20const%20d%20%3D%20seg%5Bi%5D.alt%20-%20seg%5Bi%20-%201%5D.alt%3B%20if%20(d%20%3E%200)%20gain%20%2B%3D%20d%3B%20else%20loss%20-%3D%20d%3B%20%7D%20return%20%7B%20legType%3A%20'Fixed'%2C%20paths%3A%20%5B%7B%20length%3A%20len%2C%20elevationGain%3A%20gain%2C%20elevationLoss%3A%20loss%2C%20gradeAdjustedLength%3A%20len%2C%20pathType%3A%20'Crow'%2C%20origin%3A%20%7B%20lat%3A%20seg%5B0%5D.lat%2C%20lng%3A%20seg%5B0%5D.lng%20%7D%2C%20target%3A%20%7B%20lat%3A%20seg%5Bseg.length%20-%201%5D.lat%2C%20lng%3A%20seg%5Bseg.length%20-%201%5D.lng%20%7D%2C%20elevation%3A%20blob(seg)%2C%20polyline%3A%20%7B%20encoding%3A%20'Google'%2C%20data%3A%20poly(seg)%20%7D%2C%20surfaceTypeOffsets%3A%20null%2C%20directions%3A%20null%2C%20%7D%5D%2C%20startElement%3A%20idx%2C%20%7D%3B%20%7D%3B%20const%20n%20%3D%20pts.length%2C%20cuts%20%3D%20%5B0%2C%20Math.floor(n%20%2F%203)%2C%20Math.floor(2%20*%20n%20%2F%203)%2C%20n%20-%201%5D%3B%20const%20elements%20%3D%20cuts.map(i%20%3D%3E%20(%7B%20elementType%3A%20'Waypoint'%2C%20waypoint%3A%20%7B%20point%3A%20%7B%20lat%3A%20pts%5Bi%5D.lat%2C%20lng%3A%20pts%5Bi%5D.lng%20%7D%2C%20snapUncertainty%3A%20215.23788452148438%20%7D%2C%20%7D))%3B%20const%20legs%20%3D%20cuts.slice(0%2C%20-1).map((c%2C%20i)%20%3D%3E%20mkleg(pts.slice(c%2C%20cuts%5Bi%20%2B%201%5D%20%2B%201)%2C%20i))%3B%20const%20html%20%3D%20await%20(await%20fetch('%2Fdashboard'%2C%20%7B%20credentials%3A%20'same-origin'%20%7D)).text()%3B%20const%20token%20%3D%20(html.match(%2Fname%3D%22csrf(%3F%3A-token)%3F%22%20content%3D%22(%5B%5E%22%5D%2B)%22%2F)%20%7C%7C%20%5B%5D)%5B1%5D%3B%20const%20aid%20%3D%20(html.match(%2F%5C%2Fathletes%5C%2F(%5Cd%2B)%2F)%20%7C%7C%20%5B%5D)%5B1%5D%3B%20if%20(!token%20%7C%7C%20!aid)%20return%20alert('Could%20not%20find%20your%20session%20%E2%80%94%20are%20you%20logged%20in%3F')%3B%20const%20payload%20%3D%20%7B%20props%3A%20%7B%20name%3A%20tour.name%20%7C%7C%20'komootloose%20route'%2C%20description%3A%20''%2C%20visibility%3A%20'Everyone'%2C%20starred%3A%20false%2C%20elements%2C%20legs%2C%20routePrefs%3A%20%7B%20routeType%3A%20'Ride'%2C%20surfaceType%3A%20'Unknown'%2C%20popularity%3A%200.5%2C%20elevation%3A%200%2C%20straightLine%3A%20true%20%7D%2C%20athleteId%3A%20Number(aid)%2C%20%7D%20%7D%3B%20const%20r%20%3D%20await%20fetch('%2Fapi%2Fnext%2Fdata%2Froutes%2Fcreate-route'%2C%20%7B%20method%3A%20'POST'%2C%20credentials%3A%20'same-origin'%2C%20headers%3A%20%7B%20'Content-Type'%3A%20'application%2Fjson'%2C%20'x-csrf-token'%3A%20token%2C%20'X-Requested-With'%3A%20'XMLHttpRequest'%20%7D%2C%20body%3A%20JSON.stringify(payload)%2C%20%7D)%3B%20const%20body%20%3D%20await%20r.text()%3B%20if%20(!r.ok)%20return%20alert('Create%20failed%20('%20%2B%20r.status%20%2B%20')%3A%20'%20%2B%20body.slice(0%2C%20200))%3B%20location.assign('%2Froutes%2F'%20%2B%20JSON.parse(body).createRoute)%3B%20%7D%20catch%20(e)%20%7B%20alert('komootloose%3A%20'%20%2B%20e)%3B%20%7D%20%7D)()%20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment