├── .gitignore ├── .env-example ├── package.json ├── README.md └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /activities 3 | .env -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | NIKE_CLIENT_ID= 2 | NIKE_REFRESH_TOKEN= 3 | 4 | STRAVA_REFRESH_TOKEN= 5 | STRAVA_CLIENT_ID= 6 | STRAVA_CLIENT_SECRET= 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nrc2strava", 3 | "version": "1.0.0", 4 | "description": "Import activities from Nike Run Club to Strava", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "author": "Alex Pryshchepa", 10 | "license": "ISC", 11 | "dependencies": { 12 | "dotenv": "^8.2.0", 13 | "form-data": "^3.0.0", 14 | "lodash": "^4.17.21", 15 | "node-fetch": "^2.6.7", 16 | "rimraf": "^3.0.2", 17 | "xmlbuilder": "^14.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nrc2strava 2 | 3 | Most accurate way to migrate from NRC to Strava (includes elevation & heart rate data) 4 | 5 | ## Install 6 | 7 | - Install [Node](https://nodejs.org/) 8 | - **npm install** 9 | 10 | ## Usage 11 | 12 | - Specify **env** variables 13 | - **npm start nike** - fetch all Nike Run Club activities that have **GPS** data and create gpx files 14 | - **npm start strava** - upload all gpx files to Strava 15 | 16 | ### Notes 17 | 18 | 1. Gpx file for activity without GPS data will not be created 19 | 2. You need to specify **env** variables to fetch and upload 20 | 3. You can get **NIKE** refresh token by login to your account - [website](https://www.nike.com/), and look to your browser local storage `unite.nike.com` domain and `com.nike.commerce.nikedotcom.web.credential` key, with next json keys `refresh_token`, `unite_session.clientId`. Do not use logout feature on website, as it will invalidate your token. 21 | 4. You can get **Stava** env variables by creating Strava App. Visit `https://www.strava.com/settings/api` to create the app (its free). You can set any valid domain name. 22 | 1. Login into your app, open URL (replace UPPERCASE values first) `https://www.strava.com/oauth/authorize?client_id=CLIENT_ID&response_type=code&redirect_uri=https://YOUR_DOMAIN_FOR_APP&approval_prompt=force&scope=activity:write` 23 | 2. You will be redirected to your app webpage (redirect url) with the `code` query parameter, copy it. 24 | 3. Send one more request to obtain refresh token 25 | 4. `curl -X POST https://www.strava.com/api/v3/oauth/token -d client_id=CLIENT_ID -d client_secret=CLIENT_SECRET -d code=CODE_STEP_2 -d grant_type=authorization_code` 26 | 5. Set `STRAVA_REFRESH_TOKEN` from the previous request 27 | 5. Duplicated activities will not be loaded 28 | 6. It is just a fast working solution 29 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const fs = require("fs"); 3 | const fetch = require("node-fetch"); 4 | const path = require("path"); 5 | const rimraf = require("rimraf"); 6 | const builder = require("xmlbuilder"); 7 | const { get, set, find } = require("lodash"); 8 | const FormData = require("form-data"); 9 | 10 | const activitiesFolder = "activities"; 11 | const dow = [ 12 | "Sunday", 13 | "Monday", 14 | "Tuesday", 15 | "Wednesday", 16 | "Thursday", 17 | "Friday", 18 | "Saturday" 19 | ]; 20 | 21 | const nikeEndpoints = { 22 | getActivitiesByTime: time => 23 | `https://api.nike.com/sport/v3/me/activities/after_time/${time}`, 24 | getActivitiesById: uuid => 25 | `https://api.nike.com/sport/v3/me/activities/after_id/${uuid}`, 26 | getActivityById: uuid => 27 | `https://api.nike.com/sport/v3/me/activity/${uuid}?metrics=ALL` 28 | }; 29 | 30 | const getNikeBearer = async () => { 31 | const result = await fetch( 32 | 'https://api.nike.com/idn/shim/oauth/2.0/token', 33 | { 34 | method: 'POST', 35 | body: JSON.stringify({ 36 | 'client_id': process.env.NIKE_CLIENT_ID, 37 | 'grant_type': 'refresh_token', 38 | 'ux_id': 'com.nike.sport.running.ios.6.5.1', 39 | 'refresh_token': process.env.NIKE_REFRESH_TOKEN 40 | }), 41 | headers: { 'Content-Type': 'application/json' } 42 | } 43 | ) 44 | const response = await result.json(); 45 | return response.access_token; 46 | } 47 | 48 | const nikeFetch = (url, token) => 49 | fetch(url, { 50 | headers: { 51 | Authorization: `Bearer ${token}` 52 | } 53 | }); 54 | 55 | const getNikeActivitiesIds = async () => { 56 | let ids = []; 57 | let timeOffset = 0; 58 | const nikeToken = await getNikeBearer(); 59 | while (timeOffset !== undefined) { 60 | await nikeFetch(nikeEndpoints.getActivitiesByTime(timeOffset), nikeToken) 61 | .then(res => { 62 | if (res.status === 401) { 63 | return Promise.reject("Nike token is not valid"); 64 | } 65 | 66 | if (res.ok) return res.json(); 67 | 68 | return Promise.reject("Something went wrong"); 69 | }) 70 | .then(data => { 71 | const { activities, paging } = data; 72 | 73 | if (activities === undefined) { 74 | timeOffset = undefined; 75 | 76 | return Promise.reject("Something went wrong. no activities found"); 77 | } 78 | 79 | activities.forEach(a => ids.push(a.id)); 80 | timeOffset = paging.after_time; 81 | 82 | return Promise.resolve( 83 | `Successfully retrieved ${activities.length} ids` 84 | ); 85 | }) 86 | .then(msg => console.log(msg)) 87 | .catch(err => console.log(err)); 88 | } 89 | 90 | console.log(`Total ${ids.length} ids retrieved`); 91 | return ids; 92 | }; 93 | 94 | const buildGpx = data => { 95 | const day = dow[new Date(data.start_epoch_ms).getDay()]; 96 | const getISODate = ms => new Date(ms).toISOString(); 97 | const lats = find(data.metrics, ["type", "latitude"]); 98 | const lons = find(data.metrics, ["type", "longitude"]); 99 | const elevs = find(data.metrics, ["type", "elevation"]); 100 | const hrs = find(data.metrics, ["type", "heart_rate"]); 101 | let points = []; 102 | 103 | const root = { 104 | gpx: { 105 | "@creator": "StravaGPX", 106 | "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", 107 | "@xsi:schemaLocation": 108 | "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd", 109 | "@version": "1.1", 110 | "@xmlns": "http://www.topografix.com/GPX/1/1", 111 | "@xmlns:gpxtpx": 112 | "http://www.garmin.com/xmlschemas/TrackPointExtension/v1", 113 | "@xmlns:gpxx": "http://www.garmin.com/xmlschemas/GpxExtensions/v3", 114 | metadata: { 115 | time: getISODate(data.start_epoch_ms) 116 | }, 117 | trk: { 118 | name: `${day} run - NRC`, 119 | type: 9 120 | } 121 | } 122 | }; 123 | 124 | if (lats && lons) { 125 | points = lats.values.map((lat, index) => ({ 126 | time: lat.start_epoch_ms, 127 | latitude: lat.value, 128 | longitude: get(lons.values[index], "value") 129 | })); 130 | } 131 | 132 | if (elevs) { 133 | let idx = 0; 134 | 135 | points = points.map(point => { 136 | if ( 137 | elevs.values[idx].start_epoch_ms < point.time && 138 | idx < elevs.values.length - 1 139 | ) { 140 | idx++; 141 | } 142 | 143 | return { 144 | ...point, 145 | elevation: elevs.values[idx].value 146 | }; 147 | }); 148 | } 149 | 150 | if (hrs) { 151 | let idx = 0; 152 | 153 | points = points.map(point => { 154 | if ( 155 | hrs.values[idx].start_epoch_ms < point.time && 156 | idx < hrs.values.length - 1 157 | ) { 158 | idx++; 159 | } 160 | 161 | return { 162 | ...point, 163 | heartrate: hrs.values[idx].value 164 | }; 165 | }); 166 | } 167 | 168 | set( 169 | root, 170 | "gpx.trk.trkseg.trkpt", 171 | points.map(point => { 172 | const el = { 173 | "@lat": point.latitude, 174 | "@lon": point.longitude, 175 | time: getISODate(point.time) 176 | }; 177 | 178 | if (point.elevation) { 179 | el.ele = point.elevation; 180 | } 181 | 182 | if (point.heartrate) { 183 | el.extensions = { 184 | "gpxtpx:TrackPointExtension": { 185 | "gpxtpx:hr": { 186 | "#text": point.heartrate 187 | } 188 | } 189 | }; 190 | } 191 | 192 | return el; 193 | }) 194 | ); 195 | 196 | return builder.create(root, { encoding: "UTF-8" }).end({ pretty: true }); 197 | }; 198 | 199 | if (process.argv.includes("nike") && !process.argv.includes("strava")) { 200 | (async () => { 201 | const nikeToken = await getNikeBearer(); 202 | rimraf(path.join(__dirname, activitiesFolder), () => { 203 | fs.mkdirSync(path.join(__dirname, activitiesFolder)); 204 | getNikeActivitiesIds().then(ids => { 205 | ids.map(id => { 206 | nikeFetch(nikeEndpoints.getActivityById(id), nikeToken) 207 | .then(res => { 208 | if (res.status === 401) { 209 | return Promise.reject("Nike token is not valid"); 210 | } 211 | 212 | if (res.ok) return res.json(); 213 | 214 | return Promise.reject("Something went wrong"); 215 | }) 216 | .then(async data => { 217 | if (data.type !== "run") { 218 | return Promise.reject("Is not a running activity"); 219 | } 220 | 221 | if ( 222 | !data.metric_types.includes("latitude") && 223 | !data.metric_types.includes("longitude") 224 | ) { 225 | return Promise.reject("Activity without gps data"); 226 | } 227 | 228 | return await new Promise((resolve, reject) => { 229 | fs.writeFile( 230 | path.join( 231 | __dirname, 232 | activitiesFolder, 233 | `activity_${data.id}.gpx` 234 | ), 235 | buildGpx(data), 236 | err => { 237 | if (err) { 238 | reject(err); 239 | } 240 | 241 | resolve(`Successfully created ${id} activity!`); 242 | } 243 | ); 244 | }); 245 | }) 246 | .then(msg => console.log(msg)) 247 | .catch(err => console.log(err)); 248 | }); 249 | }); 250 | }); 251 | })(); 252 | } 253 | 254 | if (process.argv.includes("strava") && !process.argv.includes("nike")) { 255 | if ([process.env.STRAVA_CLIENT_ID, process.env.STRAVA_CLIENT_SECRET, process.env.STRAVA_REFRESH_TOKEN].filter(v => !!v).length !== 3) { 256 | throw new Error('Please set your application paramenters in .env'); 257 | } 258 | 259 | (async () => { 260 | const refreshParams = new URLSearchParams(); 261 | refreshParams.append('client_id', process.env.STRAVA_CLIENT_ID); 262 | refreshParams.append('client_secret', process.env.STRAVA_CLIENT_SECRET); 263 | refreshParams.append('grant_type', 'refresh_token'); 264 | refreshParams.append('refresh_token', process.env.STRAVA_REFRESH_TOKEN); 265 | const tokenResponse = await fetch("https://www.strava.com/api/v3/oauth/token", { 266 | headers: { 267 | 'Content-Type': 'application/x-www-form-urlencoded', 268 | }, 269 | method: "POST", 270 | body: refreshParams, 271 | }) 272 | 273 | const response = await tokenResponse.json(); 274 | 275 | fs.readdir(activitiesFolder, async (err, files) => { 276 | Promise.all( 277 | files.map(file => { 278 | const form = new FormData(); 279 | 280 | form.append("description", "Uploaded from NRC"); 281 | form.append("data_type", "gpx"); 282 | form.append("file", fs.createReadStream(`./activities/${file}`)); 283 | 284 | return fetch("https://www.strava.com/api/v3/uploads", { 285 | method: "POST", 286 | headers: { 287 | Authorization: `Bearer ${response.access_token}` 288 | }, 289 | body: form 290 | }) 291 | .then(res => { 292 | if (res.status === 401) { 293 | return Promise.reject("Strava token is not valid"); 294 | } 295 | 296 | if (res.ok) return Promise.resolve(`Activity ${file} uploaded`); 297 | 298 | return Promise.reject("Something went wrong"); 299 | }) 300 | .then(msg => console.log(msg)) 301 | .catch(err => console.log(err)); 302 | }) 303 | ) 304 | .then(() => console.log("Finish")) 305 | .catch(err => console.log(err)); 306 | }); 307 | })(); 308 | } 309 | --------------------------------------------------------------------------------